local-operator-ui 0.22.3 → 0.22.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/out/main/index.js +3493 -13
- package/out/preload/index.js +55 -0
- package/out/renderer/assets/{_basePickBy-DaJaR402.js → _basePickBy-_TJMKtMy.js} +1 -1
- package/out/renderer/assets/{_baseUniq-CEwHOqtC.js → _baseUniq-BuEBsejE.js} +1 -1
- package/out/renderer/assets/{agent-details-page-C2tAyJFA.js → agent-details-page-C7BWMA4i.js} +1 -1
- package/out/renderer/assets/{agent-hub-page-BQquCNTF.js → agent-hub-page-3fZSLyIm.js} +1 -1
- package/out/renderer/assets/{agents-page-BJgX0FAz.js → agents-page-irIVVgrD.js} +2 -2
- package/out/renderer/assets/{architectureDiagram-IEHRJDOE-CRjA3fo9.js → architectureDiagram-IEHRJDOE-DORzZSnA.js} +1 -1
- package/out/renderer/assets/{blockDiagram-JOT3LUYC-BOjGbyGK.js → blockDiagram-JOT3LUYC-DZg5pOqf.js} +1 -1
- package/out/renderer/assets/{c4Diagram-VJAJSXHY-zpYYsIiU.js → c4Diagram-VJAJSXHY-DxUzmlaS.js} +1 -1
- package/out/renderer/assets/channel-DxIVOmZT.js +1 -0
- package/out/renderer/assets/{chunk-4BMEZGHF-g5XMhpJv.js → chunk-4BMEZGHF-B_H9MjtU.js} +1 -1
- package/out/renderer/assets/{chunk-A2AXSNBT-DmXt2V_u.js → chunk-A2AXSNBT-lHVwdkxZ.js} +1 -1
- package/out/renderer/assets/{chunk-AEK57VVT-7WiOl06P.js → chunk-AEK57VVT-BAC_7F7e.js} +1 -1
- package/out/renderer/assets/{chunk-D6G4REZN-C64kcIRc.js → chunk-D6G4REZN-mgzrxz2t.js} +1 -1
- package/out/renderer/assets/{chunk-RZ5BOZE2-BO1ll09R.js → chunk-RZ5BOZE2-DibVL83U.js} +1 -1
- package/out/renderer/assets/{chunk-XZIHB7SX-4XHR7Oel.js → chunk-XZIHB7SX-BN2gZWMn.js} +1 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-axkMiHhI.js +1 -0
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-axkMiHhI.js +1 -0
- package/out/renderer/assets/clone-CVwf-CZF.js +1 -0
- package/out/renderer/assets/{compact-pagination-s6w9VB67.js → compact-pagination-CgaKGkW-.js} +1 -1
- package/out/renderer/assets/{dagre-OKDRZEBW-Bwuk3IDH.js → dagre-OKDRZEBW-E-u3TaqU.js} +1 -1
- package/out/renderer/assets/{diagram-SSKATNLV-nfn_iX1-.js → diagram-SSKATNLV-DUvzNSVN.js} +1 -1
- package/out/renderer/assets/{diagram-VNBRO52H-c4k6SAJU.js → diagram-VNBRO52H-DOjhhH2O.js} +1 -1
- package/out/renderer/assets/{erDiagram-Q7BY3M3F-C6AWmkiV.js → erDiagram-Q7BY3M3F-DpuVPR8L.js} +1 -1
- package/out/renderer/assets/{flowDiagram-4HSFHLVR-CVu_bdba.js → flowDiagram-4HSFHLVR-DZPSOhEr.js} +1 -1
- package/out/renderer/assets/{ganttDiagram-APWFNJXF-MsbBDb5P.js → ganttDiagram-APWFNJXF-7t3gcUVl.js} +1 -1
- package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-Prz8M8b4.js → gitGraphDiagram-7IBYFJ6S-CFq_MqEp.js} +1 -1
- package/out/renderer/assets/{graph-D5yKzpQ_.js → graph-BpreCq7O.js} +1 -1
- package/out/renderer/assets/icon-DzTfuNUy.css +1 -0
- package/out/renderer/assets/{index-Bw4IaBY3.js → index-Bh7nA1vf.js} +320 -317
- package/out/renderer/assets/{index-B4celgDW.js → index-BppCNeub.js} +1 -1
- package/out/renderer/assets/{index-NwOkh0Rt.js → index-D8_z_Uin.js} +1 -1
- package/out/renderer/assets/{infoDiagram-PH2N3AL5-CmbDPOZf.js → infoDiagram-PH2N3AL5-DEc6iL30.js} +1 -1
- package/out/renderer/assets/{installer-BmNruK0d.js → installer-CJwdaurY.js} +1 -1
- package/out/renderer/assets/{journeyDiagram-U35MCT3I-BtsGyc0m.js → journeyDiagram-U35MCT3I-s6BcDtNI.js} +1 -1
- package/out/renderer/assets/{kanban-definition-NDS4AKOZ-CORvufvA.js → kanban-definition-NDS4AKOZ-CX4_pkmm.js} +1 -1
- package/out/renderer/assets/{layout-B0iUPWL0.js → layout-RWDbucf-.js} +1 -1
- package/out/renderer/assets/{legacy-agents-page-DmHiNM_Y.js → legacy-agents-page-CCxxEgJ-.js} +1 -1
- package/out/renderer/assets/{mermaid.core-BU0xNb-7.js → mermaid.core-sN02LJvg.js} +5 -5
- package/out/renderer/assets/{mindmap-definition-ALO5MXBD-BC5idCyl.js → mindmap-definition-ALO5MXBD-ks1embgm.js} +1 -1
- package/out/renderer/assets/{page-header-DSrLC0vo.js → page-header-qm7Wt7L1.js} +1 -1
- package/out/renderer/assets/{parseISO-Iz33_465.js → parseISO-BKx5H_4K.js} +1 -1
- package/out/renderer/assets/{pieDiagram-IB7DONF6-BNd55jjS.js → pieDiagram-IB7DONF6-SrbQareg.js} +1 -1
- package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-BOVetS2k.js → quadrantDiagram-7GDLP6J5-n0nvkA0k.js} +1 -1
- package/out/renderer/assets/{radar-MK3ICKWK-CMWcN-oP.js → radar-MK3ICKWK-dHG7seB7.js} +1 -1
- package/out/renderer/assets/{radient-auth-buttons-D8zKQ00W.js → radient-auth-buttons-Bj8naUCK.js} +1 -1
- package/out/renderer/assets/{requirementDiagram-KVF5MWMF-Bh0VQ0rS.js → requirementDiagram-KVF5MWMF-Dt0J_uK1.js} +1 -1
- package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-BCPc9cbD.js → sankeyDiagram-QLVOVGJD-Cgn46d0m.js} +1 -1
- package/out/renderer/assets/{schedules-page-CRrtcDKJ.js → schedules-page-DjfIVeMz.js} +1 -1
- package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-BfsBOKXF.js → sequenceDiagram-X6HHIX6F-B1_0NtBs.js} +1 -1
- package/out/renderer/assets/{settings-page-DTxdb90w.js → settings-page-CZIsJvIp.js} +1 -1
- package/out/renderer/assets/{square-pen-BYcMmTaZ.js → square-pen-Cy0qQPr_.js} +1 -1
- package/out/renderer/assets/{stateDiagram-DGXRK772-NL6BxNP1.js → stateDiagram-DGXRK772-DPCwlzJD.js} +1 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-DfeGpsP2.js +1 -0
- package/out/renderer/assets/{timeline-definition-BDJGKUSR-8qf2CC0s.js → timeline-definition-BDJGKUSR-cm7b61Fm.js} +1 -1
- package/out/renderer/assets/{use-agent-like-mutation-PrahVgRO.js → use-agent-like-mutation-u1WR4kEM.js} +1 -1
- package/out/renderer/assets/{xychartDiagram-VJFVF3MP-DS_HOnRp.js → xychartDiagram-VJFVF3MP-D22aMrm2.js} +1 -1
- package/out/renderer/index.html +3 -3
- package/out/renderer/installer.html +3 -3
- package/package.json +3 -2
- package/out/renderer/assets/channel-DHsYdXKv.js +0 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-DC0JFrXa.js +0 -1
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-DC0JFrXa.js +0 -1
- package/out/renderer/assets/clone-vY9kDaqi.js +0 -1
- package/out/renderer/assets/icon-CpNpDAf5.css +0 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-BUwB-jDr.js +0 -1
- /package/out/renderer/assets/{icon-CSttI5qe.js → icon-fSzO-fSj.js} +0 -0
package/out/main/index.js
CHANGED
|
@@ -13,6 +13,7 @@ const os = require("node:os");
|
|
|
13
13
|
const node_util = require("node:util");
|
|
14
14
|
const dotenv = require("dotenv");
|
|
15
15
|
const electronLog = require("electron-log");
|
|
16
|
+
const node_http = require("node:http");
|
|
16
17
|
const https = require("node:https");
|
|
17
18
|
const electronUpdater = require("electron-updater");
|
|
18
19
|
function _interopNamespaceDefault(e) {
|
|
@@ -1274,7 +1275,7 @@ const DESKTOP_STREAM_DETAIL = {
|
|
|
1274
1275
|
/** The IPC subscribe itself rejected before a stream existed. */
|
|
1275
1276
|
openFailed: "The event stream could not be opened.",
|
|
1276
1277
|
/** A backend answered and refused this app's bearer (typically 401/403). */
|
|
1277
|
-
refused: (
|
|
1278
|
+
refused: (status2) => `The event stream was refused (${status2}).`,
|
|
1278
1279
|
/**
|
|
1279
1280
|
* The same refusal where no status reached the caller.
|
|
1280
1281
|
*
|
|
@@ -3998,8 +3999,3459 @@ class BackendInstaller {
|
|
|
3998
3999
|
}
|
|
3999
4000
|
}
|
|
4000
4001
|
}
|
|
4002
|
+
const PRIVATE_FILE_MODE = 384;
|
|
4003
|
+
const PRIVATE_DIR_MODE = 448;
|
|
4004
|
+
function writeJsonAtomic(path2, value) {
|
|
4005
|
+
try {
|
|
4006
|
+
fs.mkdirSync(path$1.dirname(path2), { recursive: true, mode: PRIVATE_DIR_MODE });
|
|
4007
|
+
const staged = `${path2}.${process.pid}.tmp`;
|
|
4008
|
+
fs.writeFileSync(staged, `${JSON.stringify(value, null, 2)}
|
|
4009
|
+
`, {
|
|
4010
|
+
mode: PRIVATE_FILE_MODE
|
|
4011
|
+
});
|
|
4012
|
+
fs.renameSync(staged, path2);
|
|
4013
|
+
return true;
|
|
4014
|
+
} catch {
|
|
4015
|
+
return false;
|
|
4016
|
+
}
|
|
4017
|
+
}
|
|
4018
|
+
function readJson(path2) {
|
|
4019
|
+
try {
|
|
4020
|
+
return JSON.parse(fs.readFileSync(path2, "utf8"));
|
|
4021
|
+
} catch {
|
|
4022
|
+
return null;
|
|
4023
|
+
}
|
|
4024
|
+
}
|
|
4025
|
+
class BridgeCommandError extends Error {
|
|
4026
|
+
constructor(code, message, data = {}) {
|
|
4027
|
+
super(message);
|
|
4028
|
+
this.code = code;
|
|
4029
|
+
this.data = data;
|
|
4030
|
+
}
|
|
4031
|
+
}
|
|
4032
|
+
function errorData(error) {
|
|
4033
|
+
return error instanceof BridgeCommandError ? error.data : {};
|
|
4034
|
+
}
|
|
4035
|
+
const CDP_DEADLINE_MS = 15e3;
|
|
4036
|
+
const CDP_ATTACH_DEADLINE_MS = 5e3;
|
|
4037
|
+
const CHROME_API_DEADLINE_MS = 5e3;
|
|
4038
|
+
const SCRIPTING_DEADLINE_MS = 15e3;
|
|
4039
|
+
function deadline(op, ms, what) {
|
|
4040
|
+
return new Promise((resolve, reject) => {
|
|
4041
|
+
const timer = setTimeout(() => {
|
|
4042
|
+
reject(
|
|
4043
|
+
new BridgeCommandError("internal", `${what} did not respond within ${ms}ms`, {
|
|
4044
|
+
stalled: what
|
|
4045
|
+
})
|
|
4046
|
+
);
|
|
4047
|
+
}, ms);
|
|
4048
|
+
op.then(
|
|
4049
|
+
(value) => {
|
|
4050
|
+
clearTimeout(timer);
|
|
4051
|
+
resolve(value);
|
|
4052
|
+
},
|
|
4053
|
+
(error) => {
|
|
4054
|
+
clearTimeout(timer);
|
|
4055
|
+
reject(error);
|
|
4056
|
+
}
|
|
4057
|
+
);
|
|
4058
|
+
});
|
|
4059
|
+
}
|
|
4060
|
+
function nextSequence(queue) {
|
|
4061
|
+
return (queue ?? []).reduce(
|
|
4062
|
+
(highest, entry) => Math.max(highest, entry.sequence),
|
|
4063
|
+
0
|
|
4064
|
+
) + 1;
|
|
4065
|
+
}
|
|
4066
|
+
const SCROLL_DIRECTIONS = /* @__PURE__ */ new Set([
|
|
4067
|
+
"top",
|
|
4068
|
+
"bottom",
|
|
4069
|
+
"up",
|
|
4070
|
+
"down",
|
|
4071
|
+
"left",
|
|
4072
|
+
"right"
|
|
4073
|
+
]);
|
|
4074
|
+
function sleep(ms) {
|
|
4075
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4076
|
+
}
|
|
4077
|
+
const WEB_CONTENTS_DEADLINE_MS = CHROME_API_DEADLINE_MS;
|
|
4078
|
+
const ONCE_GRANT_TTL_MS = 10 * 6e4;
|
|
4079
|
+
function receiptKey(origin, requester) {
|
|
4080
|
+
return `${origin}
|
|
4081
|
+
${requester}`;
|
|
4082
|
+
}
|
|
4083
|
+
const TOMBSTONE_CAP = 8;
|
|
4084
|
+
function activeRequest(record, now) {
|
|
4085
|
+
if (!record) return void 0;
|
|
4086
|
+
return now < record.expiresAt ? record : void 0;
|
|
4087
|
+
}
|
|
4088
|
+
function consumableGrant(grants, origin, requester, now) {
|
|
4089
|
+
const grant = grants?.[origin];
|
|
4090
|
+
if (!grant || now >= grant.expiresAt) return void 0;
|
|
4091
|
+
if (!requester || grant.requester !== requester) return void 0;
|
|
4092
|
+
return grant;
|
|
4093
|
+
}
|
|
4094
|
+
function accessState(record, tombstones, storedAllowed, onceGrant, origin, requester, now) {
|
|
4095
|
+
if (storedAllowed || onceGrant) return "allowed";
|
|
4096
|
+
const live = activeRequest(record, now);
|
|
4097
|
+
if (live && live.origin === origin && live.requester === requester) {
|
|
4098
|
+
if (!live.decision) return "pending";
|
|
4099
|
+
return live.decision === "deny" ? "denied" : "allowed";
|
|
4100
|
+
}
|
|
4101
|
+
const tomb = tombstones?.[receiptKey(origin, requester)];
|
|
4102
|
+
if (tomb && now < tomb.expiresAt) return "superseded";
|
|
4103
|
+
return "none";
|
|
4104
|
+
}
|
|
4105
|
+
function tombstoneFor(record) {
|
|
4106
|
+
return { origin: record.origin, requester: record.requester, expiresAt: record.expiresAt };
|
|
4107
|
+
}
|
|
4108
|
+
function safeHttpUrl(raw) {
|
|
4109
|
+
if (typeof raw !== "string") throw new Error("URL is required");
|
|
4110
|
+
const parsed = new URL(raw);
|
|
4111
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
4112
|
+
throw new Error("only http:// and https:// can be opened");
|
|
4113
|
+
}
|
|
4114
|
+
if (parsed.hostname === "127.0.0.1") {
|
|
4115
|
+
const authority = raw.match(/^https?:\/\/([^/?#]+)/i)?.[1] ?? "";
|
|
4116
|
+
const rawHost = authority.startsWith("[") ? authority.slice(0, authority.indexOf("]") + 1) : authority.split(":", 1)[0];
|
|
4117
|
+
if (rawHost !== "127.0.0.1") throw new Error("IPv4 loopback must use 127.0.0.1 exactly");
|
|
4118
|
+
}
|
|
4119
|
+
return parsed;
|
|
4120
|
+
}
|
|
4121
|
+
function isLoopbackHost(url) {
|
|
4122
|
+
return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
4123
|
+
}
|
|
4124
|
+
function displayAuthority(url) {
|
|
4125
|
+
return url.host;
|
|
4126
|
+
}
|
|
4127
|
+
let pslRules = null;
|
|
4128
|
+
function configurePslRules(rules2) {
|
|
4129
|
+
pslRules = rules2 ? new Set(rules2.split("\n")) : null;
|
|
4130
|
+
}
|
|
4131
|
+
function domainScopeAvailable() {
|
|
4132
|
+
return pslRules !== null;
|
|
4133
|
+
}
|
|
4134
|
+
function rules() {
|
|
4135
|
+
return pslRules ?? /* @__PURE__ */ new Set();
|
|
4136
|
+
}
|
|
4137
|
+
function isIpLiteral(hostname) {
|
|
4138
|
+
return hostname.startsWith("[") || /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname);
|
|
4139
|
+
}
|
|
4140
|
+
function registrableDomain(url) {
|
|
4141
|
+
const hostname = url.hostname;
|
|
4142
|
+
if (!hostname || hostname.endsWith(".") || isIpLiteral(hostname)) return null;
|
|
4143
|
+
const labels = hostname.split(".");
|
|
4144
|
+
if (labels.length < 2 || labels.some((label) => !label)) return null;
|
|
4145
|
+
const table = rules();
|
|
4146
|
+
if (table.size === 0) return null;
|
|
4147
|
+
let suffixLabels = 1;
|
|
4148
|
+
for (let index = labels.length - 1; index >= 0; index -= 1) {
|
|
4149
|
+
const candidate = labels.slice(index).join(".");
|
|
4150
|
+
const matched = labels.length - index;
|
|
4151
|
+
if (table.has(`!${candidate}`)) {
|
|
4152
|
+
suffixLabels = matched - 1;
|
|
4153
|
+
break;
|
|
4154
|
+
}
|
|
4155
|
+
if (table.has(candidate)) suffixLabels = Math.max(suffixLabels, matched);
|
|
4156
|
+
if (index > 0 && table.has(`*.${candidate}`)) suffixLabels = Math.max(suffixLabels, matched + 1);
|
|
4157
|
+
}
|
|
4158
|
+
if (labels.length <= suffixLabels) return null;
|
|
4159
|
+
return labels.slice(labels.length - suffixLabels - 1).join(".");
|
|
4160
|
+
}
|
|
4161
|
+
function broadGrantFor(url) {
|
|
4162
|
+
if (isLoopbackHost(url)) return { scope: "host", key: url.hostname };
|
|
4163
|
+
const domain = registrableDomain(url);
|
|
4164
|
+
return domain ? { scope: "domain", key: domain } : null;
|
|
4165
|
+
}
|
|
4166
|
+
function validSiteGrantSchema(value) {
|
|
4167
|
+
return !!value && typeof value === "object" && !Array.isArray(value) && value.version === 1 && !!value.grants && typeof value.grants === "object" && !Array.isArray(value.grants);
|
|
4168
|
+
}
|
|
4169
|
+
function siteGrantScope(grant, scope) {
|
|
4170
|
+
return grant?.scope === scope && typeof grant.createdAt === "number";
|
|
4171
|
+
}
|
|
4172
|
+
function matchingGrantScope(origins, hostGrants, url, siteGrants) {
|
|
4173
|
+
if (origins[url.origin] === "allow") return "origin";
|
|
4174
|
+
if (validSiteGrantSchema(siteGrants)) {
|
|
4175
|
+
if (isLoopbackHost(url)) {
|
|
4176
|
+
if (siteGrantScope(siteGrants.grants[url.hostname], "host")) return "host";
|
|
4177
|
+
} else {
|
|
4178
|
+
const domain = registrableDomain(url);
|
|
4179
|
+
if (domain && siteGrantScope(siteGrants.grants[domain], "domain")) return "domain";
|
|
4180
|
+
}
|
|
4181
|
+
}
|
|
4182
|
+
return null;
|
|
4183
|
+
}
|
|
4184
|
+
function storedOriginAllowed(origins, url, hostGrants, siteGrants) {
|
|
4185
|
+
return matchingGrantScope(origins, hostGrants, url, siteGrants) !== null;
|
|
4186
|
+
}
|
|
4187
|
+
const ACCESS_QUEUE_CAP = 16;
|
|
4188
|
+
const ACCESS_RESULT_CAP = 32;
|
|
4189
|
+
const ACCESS_REQUEST_TTL_MS = 10 * 6e4;
|
|
4190
|
+
const IN_COMMAND_TTL_MS = 6e4;
|
|
4191
|
+
const ACCESS_RESULT_TTL_MS = 15 * 6e4;
|
|
4192
|
+
function resultKey(entryId, requester) {
|
|
4193
|
+
return `${entryId}
|
|
4194
|
+
${requester}`;
|
|
4195
|
+
}
|
|
4196
|
+
function liveQueue(queue, now) {
|
|
4197
|
+
return (queue ?? []).filter((entry) => now < entry.expiresAt).sort((a, b) => a.sequence - b.sequence);
|
|
4198
|
+
}
|
|
4199
|
+
function cleanResults(results, now) {
|
|
4200
|
+
return Object.fromEntries(
|
|
4201
|
+
Object.entries(results ?? {}).filter(([, receipt]) => now < receipt.expiresAt).sort((a, b) => b[1].decidedAt - a[1].decidedAt).slice(0, ACCESS_RESULT_CAP)
|
|
4202
|
+
);
|
|
4203
|
+
}
|
|
4204
|
+
function findPending(queue, origin, requester, kind, now = Date.now()) {
|
|
4205
|
+
return liveQueue(queue, now).find(
|
|
4206
|
+
(entry) => entry.origin === origin && entry.requester === requester && (!kind || entry.kind === kind)
|
|
4207
|
+
);
|
|
4208
|
+
}
|
|
4209
|
+
function newEntry(origin, displayAuthority2, requester, kind, now, sequence, commandId, entryId, broad) {
|
|
4210
|
+
return {
|
|
4211
|
+
// `||`, not a default parameter: a default fires only on `undefined`, so a
|
|
4212
|
+
// legacy pendingOrigin record carrying `promptId: ""` would mint a live
|
|
4213
|
+
// entry with an empty id. An empty id reads as "no generation" in the ack
|
|
4214
|
+
// latch and would be swallowed as the previous decision's own echo.
|
|
4215
|
+
// Unreachable today (every writer minted a UUID) and defensive only (A12).
|
|
4216
|
+
entryId: crypto.randomUUID().replaceAll("-", ""),
|
|
4217
|
+
origin,
|
|
4218
|
+
displayAuthority: displayAuthority2,
|
|
4219
|
+
requester,
|
|
4220
|
+
kind,
|
|
4221
|
+
requestedAt: now,
|
|
4222
|
+
expiresAt: now + (kind === "async" ? ACCESS_REQUEST_TTL_MS : IN_COMMAND_TTL_MS),
|
|
4223
|
+
sequence,
|
|
4224
|
+
...commandId ? { commandId } : {},
|
|
4225
|
+
...broad ? { broad } : {}
|
|
4226
|
+
};
|
|
4227
|
+
}
|
|
4228
|
+
function receiptFor(entry, state, now) {
|
|
4229
|
+
return {
|
|
4230
|
+
entryId: entry.entryId,
|
|
4231
|
+
origin: entry.origin,
|
|
4232
|
+
requester: entry.requester,
|
|
4233
|
+
state,
|
|
4234
|
+
decidedAt: now,
|
|
4235
|
+
expiresAt: now + ACCESS_RESULT_TTL_MS
|
|
4236
|
+
};
|
|
4237
|
+
}
|
|
4238
|
+
function receiptForRequester(results, origin, requester, now) {
|
|
4239
|
+
return Object.values(cleanResults(results, now)).filter((receipt) => receipt.origin === origin && receipt.requester === requester).sort((a, b) => b.decidedAt - a.decidedAt)[0];
|
|
4240
|
+
}
|
|
4241
|
+
const APPROVALS_FILENAME = "approvals.json";
|
|
4242
|
+
function emptyStore() {
|
|
4243
|
+
return {
|
|
4244
|
+
version: 1,
|
|
4245
|
+
origins: {},
|
|
4246
|
+
siteGrants: { version: 1, grants: {} },
|
|
4247
|
+
records: []
|
|
4248
|
+
};
|
|
4249
|
+
}
|
|
4250
|
+
class ApprovalStore {
|
|
4251
|
+
path;
|
|
4252
|
+
now;
|
|
4253
|
+
onChanged;
|
|
4254
|
+
log;
|
|
4255
|
+
store;
|
|
4256
|
+
/** The live pending entries, FIFO. In-memory: a pending prompt that outlived
|
|
4257
|
+
* the app would be a prompt about a navigation nobody remembers requesting. */
|
|
4258
|
+
queue = [];
|
|
4259
|
+
/** Decisions, keyed `entryId\nrequester` (see `resultKey`). */
|
|
4260
|
+
results = {};
|
|
4261
|
+
/** Unspent "Allow once" grants, keyed by origin. */
|
|
4262
|
+
onceGrants = {};
|
|
4263
|
+
/** Receipts for displaced requesters (see `access-flow.ts`). */
|
|
4264
|
+
tombstones = {};
|
|
4265
|
+
constructor(options) {
|
|
4266
|
+
this.path = path$1.join(options.dir, APPROVALS_FILENAME);
|
|
4267
|
+
this.now = options.now ?? Date.now;
|
|
4268
|
+
this.onChanged = options.onChanged ?? (() => {
|
|
4269
|
+
});
|
|
4270
|
+
this.log = options.log ?? (() => {
|
|
4271
|
+
});
|
|
4272
|
+
const loaded = readJson(this.path);
|
|
4273
|
+
const fresh = emptyStore();
|
|
4274
|
+
this.store = loaded && loaded.version === 1 ? {
|
|
4275
|
+
...fresh,
|
|
4276
|
+
...loaded,
|
|
4277
|
+
// The three mutable containers are taken from the FRESH store when
|
|
4278
|
+
// the file did not carry them, never shared with another instance.
|
|
4279
|
+
origins: loaded.origins ?? fresh.origins,
|
|
4280
|
+
siteGrants: loaded.siteGrants ?? fresh.siteGrants,
|
|
4281
|
+
records: loaded.records ?? fresh.records
|
|
4282
|
+
} : fresh;
|
|
4283
|
+
}
|
|
4284
|
+
// ---- the gate ------------------------------------------------------------
|
|
4285
|
+
/** Whether the agent may reach this URL: an exact-origin allow, a broad grant,
|
|
4286
|
+
* or nothing. A persisted DENY for the exact origin wins over any broader
|
|
4287
|
+
* grant: the user's most specific statement about a site is the one that
|
|
4288
|
+
* counts, and a deny that a domain grant could override would make the deny
|
|
4289
|
+
* button a lie.
|
|
4290
|
+
*
|
|
4291
|
+
* The signature is the VENDORED one — `(origins, url, hostGrants?, siteGrants?)`
|
|
4292
|
+
* — and the `hostGrants` slot is passed `undefined` for the same reason
|
|
4293
|
+
* `grantScopeFor` does: that slot reads the extension's legacy 0.1.4-0.1.7
|
|
4294
|
+
* loopback shape, which this host never wrote. Passing `siteGrants` there
|
|
4295
|
+
* instead compiles (the slot is `unknown` and optional) and silently drops
|
|
4296
|
+
* every broad grant, which is why the call spells the order out. */
|
|
4297
|
+
originAllowed(url) {
|
|
4298
|
+
if (this.store.origins[url.origin] === "deny") return false;
|
|
4299
|
+
return storedOriginAllowed(
|
|
4300
|
+
this.store.origins,
|
|
4301
|
+
url,
|
|
4302
|
+
void 0,
|
|
4303
|
+
this.store.siteGrants
|
|
4304
|
+
);
|
|
4305
|
+
}
|
|
4306
|
+
/** Why the URL is refused, for the refusal's `data` — a sentence the model can
|
|
4307
|
+
* use rather than a bare boolean. */
|
|
4308
|
+
refusalReason(url) {
|
|
4309
|
+
return this.store.origins[url.origin] === "deny" ? "denied" : "unapproved";
|
|
4310
|
+
}
|
|
4311
|
+
/**
|
|
4312
|
+
* The admission decision for a top-level navigation, made ONCE at command
|
|
4313
|
+
* entry.
|
|
4314
|
+
*
|
|
4315
|
+
* The "once" is load-bearing (the extension's round-1 M1): a grant consumed
|
|
4316
|
+
* here is already spent, and re-consulting the grant map later in the same
|
|
4317
|
+
* command would let the 10-minute TTL lapse between entry and navigation,
|
|
4318
|
+
* turning a granted navigation back into a prompt the agent thinks it already
|
|
4319
|
+
* passed.
|
|
4320
|
+
*
|
|
4321
|
+
* An unapproved origin is refused EARLY with `origin_not_allowed` — never by
|
|
4322
|
+
* blocking the navigation on a prompt. That is the design's three-step dance:
|
|
4323
|
+
* the agent gets a typed failure it can narrate, then runs
|
|
4324
|
+
* `request_access` → tells the user → `await_access`.
|
|
4325
|
+
*/
|
|
4326
|
+
ensureTopLevelAccess(url, requester) {
|
|
4327
|
+
if (this.originAllowed(url)) return { allowed: true, viaOnceGrant: false };
|
|
4328
|
+
const grant = consumableGrant(
|
|
4329
|
+
this.onceGrants,
|
|
4330
|
+
url.origin,
|
|
4331
|
+
requester,
|
|
4332
|
+
this.now()
|
|
4333
|
+
);
|
|
4334
|
+
if (grant) {
|
|
4335
|
+
delete this.onceGrants[url.origin];
|
|
4336
|
+
this.persistIfChanged();
|
|
4337
|
+
return { allowed: true, viaOnceGrant: true };
|
|
4338
|
+
}
|
|
4339
|
+
throw new BridgeCommandError(
|
|
4340
|
+
"origin_not_allowed",
|
|
4341
|
+
`the user has not approved ${url.origin} for agent access`,
|
|
4342
|
+
{
|
|
4343
|
+
origin: url.origin,
|
|
4344
|
+
authority: displayAuthority(url),
|
|
4345
|
+
reason: this.refusalReason(url)
|
|
4346
|
+
}
|
|
4347
|
+
);
|
|
4348
|
+
}
|
|
4349
|
+
// ---- request_access / await_access / cancel_access -----------------------
|
|
4350
|
+
/** The live entry for an exact origin, if any: what a consent surface renders. */
|
|
4351
|
+
pendingEntry() {
|
|
4352
|
+
return liveQueue(this.queue, this.now())[0];
|
|
4353
|
+
}
|
|
4354
|
+
/** Every live pending entry, in FIFO order (the cap is small, so a list is
|
|
4355
|
+
* cheap and the surface decides what to show). */
|
|
4356
|
+
pendingEntries() {
|
|
4357
|
+
return liveQueue(this.queue, this.now());
|
|
4358
|
+
}
|
|
4359
|
+
/**
|
|
4360
|
+
* Raise a request, or report that one already covers this caller.
|
|
4361
|
+
*
|
|
4362
|
+
* The rules are `vendor/driver/access-flow.ts`'s, applied to the queue: a repeat
|
|
4363
|
+
* request for the same origin by the same requester is idempotent with its
|
|
4364
|
+
* original TTL, a request for a different origin REPLACES the live one (with
|
|
4365
|
+
* a tombstone for the displaced requester), and a live deny receipt answers
|
|
4366
|
+
* "denied" without re-prompting until its cool-down expires.
|
|
4367
|
+
*/
|
|
4368
|
+
requestAccess(rawUrl, requester, kind = "async", commandId) {
|
|
4369
|
+
const url = safeHttpUrl(rawUrl);
|
|
4370
|
+
const now = this.now();
|
|
4371
|
+
if (this.originAllowed(url)) {
|
|
4372
|
+
return { origin: url.origin, state: "allowed" };
|
|
4373
|
+
}
|
|
4374
|
+
const existing = findPending(this.queue, url.origin, requester, kind, now);
|
|
4375
|
+
if (existing) {
|
|
4376
|
+
return this.entryResponse(existing, "pending");
|
|
4377
|
+
}
|
|
4378
|
+
const receipt = receiptForRequester(
|
|
4379
|
+
this.results,
|
|
4380
|
+
url.origin,
|
|
4381
|
+
requester,
|
|
4382
|
+
now
|
|
4383
|
+
);
|
|
4384
|
+
if (receipt && receipt.state !== "superseded") {
|
|
4385
|
+
const state = receipt.state === "denied" ? "denied" : "allowed";
|
|
4386
|
+
return { origin: url.origin, state, entry_id: receipt.entryId };
|
|
4387
|
+
}
|
|
4388
|
+
this.sweep(now);
|
|
4389
|
+
if (liveQueue(this.queue, now).length >= ACCESS_QUEUE_CAP) {
|
|
4390
|
+
throw new BridgeCommandError(
|
|
4391
|
+
"access_queue_full",
|
|
4392
|
+
`site approval queue is full with ${liveQueue(this.queue, now).length} pending requests`,
|
|
4393
|
+
{ pending_count: liveQueue(this.queue, now).length }
|
|
4394
|
+
);
|
|
4395
|
+
}
|
|
4396
|
+
this.displaceLive(now);
|
|
4397
|
+
const entry = newEntry(
|
|
4398
|
+
url.origin,
|
|
4399
|
+
displayAuthority(url),
|
|
4400
|
+
requester,
|
|
4401
|
+
kind,
|
|
4402
|
+
now,
|
|
4403
|
+
nextSequence(this.queue),
|
|
4404
|
+
commandId,
|
|
4405
|
+
void 0,
|
|
4406
|
+
broadGrantFor(url)
|
|
4407
|
+
);
|
|
4408
|
+
this.queue.push(entry);
|
|
4409
|
+
this.onChanged();
|
|
4410
|
+
this.log(
|
|
4411
|
+
`[browser] ${requester} is asking for access to ${url.origin} (${this.pendingEntries().length} pending)`
|
|
4412
|
+
);
|
|
4413
|
+
return this.entryResponse(entry, "pending");
|
|
4414
|
+
}
|
|
4415
|
+
entryResponse(entry, state) {
|
|
4416
|
+
return {
|
|
4417
|
+
origin: entry.origin,
|
|
4418
|
+
state,
|
|
4419
|
+
entry_id: entry.entryId,
|
|
4420
|
+
authority: entry.displayAuthority,
|
|
4421
|
+
expires_at: entry.expiresAt,
|
|
4422
|
+
...entry.broad ? { broad: entry.broad } : {}
|
|
4423
|
+
};
|
|
4424
|
+
}
|
|
4425
|
+
/** What `await_access` answers, WITHOUT waiting (the loop is in
|
|
4426
|
+
* `awaitAccess`). */
|
|
4427
|
+
accessStateFor(rawUrl, requester) {
|
|
4428
|
+
const url = safeHttpUrl(rawUrl);
|
|
4429
|
+
const now = this.now();
|
|
4430
|
+
const entry = findPending(
|
|
4431
|
+
this.queue,
|
|
4432
|
+
url.origin,
|
|
4433
|
+
requester,
|
|
4434
|
+
void 0,
|
|
4435
|
+
now
|
|
4436
|
+
);
|
|
4437
|
+
const state = accessState(
|
|
4438
|
+
entry ? {
|
|
4439
|
+
origin: entry.origin,
|
|
4440
|
+
hostname: entry.displayAuthority,
|
|
4441
|
+
requester: entry.requester,
|
|
4442
|
+
requestedAt: entry.requestedAt,
|
|
4443
|
+
expiresAt: entry.expiresAt,
|
|
4444
|
+
...this.decisionFor(entry) ? { decision: this.decisionFor(entry) } : {}
|
|
4445
|
+
} : void 0,
|
|
4446
|
+
this.tombstones,
|
|
4447
|
+
this.originAllowed(url),
|
|
4448
|
+
!!consumableGrant(this.onceGrants, url.origin, requester, now),
|
|
4449
|
+
url.origin,
|
|
4450
|
+
requester,
|
|
4451
|
+
now
|
|
4452
|
+
);
|
|
4453
|
+
return {
|
|
4454
|
+
origin: url.origin,
|
|
4455
|
+
state,
|
|
4456
|
+
...entry ? { entry_id: entry.entryId } : {}
|
|
4457
|
+
};
|
|
4458
|
+
}
|
|
4459
|
+
/** The caller's own exact-origin pending entry, removed. Only this
|
|
4460
|
+
* requester's own entry: a cancel must not let one session dismiss another's
|
|
4461
|
+
* prompt. */
|
|
4462
|
+
cancelAccess(rawUrl, requester) {
|
|
4463
|
+
const url = safeHttpUrl(rawUrl);
|
|
4464
|
+
const now = this.now();
|
|
4465
|
+
const index = this.queue.findIndex(
|
|
4466
|
+
(entry2) => entry2.origin === url.origin && entry2.requester === requester
|
|
4467
|
+
);
|
|
4468
|
+
if (index < 0)
|
|
4469
|
+
return { origin: url.origin, state: "none" };
|
|
4470
|
+
const [entry] = this.queue.splice(index, 1);
|
|
4471
|
+
if (entry) {
|
|
4472
|
+
this.results[resultKey(entry.entryId, entry.requester)] = receiptFor(
|
|
4473
|
+
entry,
|
|
4474
|
+
"cancelled",
|
|
4475
|
+
now
|
|
4476
|
+
);
|
|
4477
|
+
}
|
|
4478
|
+
this.onChanged();
|
|
4479
|
+
return { origin: url.origin, state: "cancelled" };
|
|
4480
|
+
}
|
|
4481
|
+
/**
|
|
4482
|
+
* Answer the pending prompt: the user's decision, applied.
|
|
4483
|
+
*
|
|
4484
|
+
* Reached from the renderer IPC (a chrome band will call it; nothing else
|
|
4485
|
+
* can, and the IPC handler checks the sender). `entryId` names which pending
|
|
4486
|
+
* entry is being answered, so an answer that arrives after the queue moved on
|
|
4487
|
+
* fails rather than granting whatever is pending now.
|
|
4488
|
+
*/
|
|
4489
|
+
respond(entryId, decision, requester = "") {
|
|
4490
|
+
const entry = this.queue.find((candidate) => candidate.entryId === entryId);
|
|
4491
|
+
if (!entry) {
|
|
4492
|
+
throw new BridgeCommandError(
|
|
4493
|
+
"internal",
|
|
4494
|
+
"that site approval request is no longer pending"
|
|
4495
|
+
);
|
|
4496
|
+
}
|
|
4497
|
+
const now = this.now();
|
|
4498
|
+
this.queue = this.queue.filter(
|
|
4499
|
+
(candidate) => candidate.entryId !== entryId
|
|
4500
|
+
);
|
|
4501
|
+
if (decision === "once") {
|
|
4502
|
+
this.onceGrants[entry.origin] = {
|
|
4503
|
+
expiresAt: now + ONCE_GRANT_TTL_MS,
|
|
4504
|
+
requester: entry.requester,
|
|
4505
|
+
...entry.commandId ? { handoff: entry.commandId } : {}
|
|
4506
|
+
};
|
|
4507
|
+
this.results[resultKey(entry.entryId, entry.requester)] = receiptFor(
|
|
4508
|
+
entry,
|
|
4509
|
+
"allowed",
|
|
4510
|
+
now
|
|
4511
|
+
);
|
|
4512
|
+
this.persist();
|
|
4513
|
+
this.onChanged();
|
|
4514
|
+
return { origin: entry.origin, state: "allowed", scope: "once" };
|
|
4515
|
+
}
|
|
4516
|
+
if (decision === "deny") {
|
|
4517
|
+
this.store.origins[entry.origin] = "deny";
|
|
4518
|
+
this.store.records.push({
|
|
4519
|
+
origin: entry.origin,
|
|
4520
|
+
scope: "deny",
|
|
4521
|
+
requester: requester || entry.requester,
|
|
4522
|
+
grantedAt: now
|
|
4523
|
+
});
|
|
4524
|
+
this.results[resultKey(entry.entryId, entry.requester)] = receiptFor(
|
|
4525
|
+
entry,
|
|
4526
|
+
"denied",
|
|
4527
|
+
now
|
|
4528
|
+
);
|
|
4529
|
+
this.persist();
|
|
4530
|
+
this.onChanged();
|
|
4531
|
+
return { origin: entry.origin, state: "denied", scope: "deny" };
|
|
4532
|
+
}
|
|
4533
|
+
if (decision === "site") {
|
|
4534
|
+
this.store.origins[entry.origin] = "allow";
|
|
4535
|
+
this.store.records.push({
|
|
4536
|
+
origin: entry.origin,
|
|
4537
|
+
scope: "origin",
|
|
4538
|
+
requester: requester || entry.requester,
|
|
4539
|
+
grantedAt: now
|
|
4540
|
+
});
|
|
4541
|
+
} else {
|
|
4542
|
+
const broad = entry.broad;
|
|
4543
|
+
if (!broad) {
|
|
4544
|
+
throw new BridgeCommandError(
|
|
4545
|
+
"internal",
|
|
4546
|
+
"this build cannot grant a broad scope for that origin"
|
|
4547
|
+
);
|
|
4548
|
+
}
|
|
4549
|
+
if (broad.scope === "domain") {
|
|
4550
|
+
this.store.siteGrants.grants[broad.key] = {
|
|
4551
|
+
scope: "domain",
|
|
4552
|
+
createdAt: now
|
|
4553
|
+
};
|
|
4554
|
+
} else {
|
|
4555
|
+
this.store.siteGrants.grants[broad.key] = {
|
|
4556
|
+
scope: "host",
|
|
4557
|
+
createdAt: now
|
|
4558
|
+
};
|
|
4559
|
+
}
|
|
4560
|
+
this.store.records.push({
|
|
4561
|
+
origin: entry.origin,
|
|
4562
|
+
scope: broad.scope,
|
|
4563
|
+
requester: requester || entry.requester,
|
|
4564
|
+
grantedAt: now
|
|
4565
|
+
});
|
|
4566
|
+
}
|
|
4567
|
+
this.results[resultKey(entry.entryId, entry.requester)] = receiptFor(
|
|
4568
|
+
entry,
|
|
4569
|
+
"allowed",
|
|
4570
|
+
now
|
|
4571
|
+
);
|
|
4572
|
+
this.persist();
|
|
4573
|
+
this.onChanged();
|
|
4574
|
+
return {
|
|
4575
|
+
origin: entry.origin,
|
|
4576
|
+
state: "allowed",
|
|
4577
|
+
scope: decision === "site" ? "origin" : entry.broad?.scope ?? decision
|
|
4578
|
+
};
|
|
4579
|
+
}
|
|
4580
|
+
/** The decisions still in force, for the approvals list. */
|
|
4581
|
+
grants() {
|
|
4582
|
+
return [...this.store.records];
|
|
4583
|
+
}
|
|
4584
|
+
/**
|
|
4585
|
+
* Revoke every site approval.
|
|
4586
|
+
*
|
|
4587
|
+
* Bulk, and separate from clearing cookies (design 9.4): revoking does not log
|
|
4588
|
+
* the user out, and clearing cookies does not restore the deny state — so an
|
|
4589
|
+
* origin the agent was denied on will prompt again afterwards, which is stated
|
|
4590
|
+
* in the copy rather than left to look like a bug.
|
|
4591
|
+
*/
|
|
4592
|
+
revokeAll() {
|
|
4593
|
+
const removed = this.store.records.length;
|
|
4594
|
+
this.store.origins = {};
|
|
4595
|
+
this.store.siteGrants = { version: 1, grants: {} };
|
|
4596
|
+
this.store.records = [];
|
|
4597
|
+
this.persist();
|
|
4598
|
+
this.onChanged();
|
|
4599
|
+
return removed;
|
|
4600
|
+
}
|
|
4601
|
+
/** Drop everything about the pending flow. Used by the host-off toggle, which
|
|
4602
|
+
* tears the state file down so discovery is honest. */
|
|
4603
|
+
resetPending() {
|
|
4604
|
+
this.queue = [];
|
|
4605
|
+
this.results = {};
|
|
4606
|
+
this.onceGrants = {};
|
|
4607
|
+
this.tombstones = {};
|
|
4608
|
+
this.onChanged();
|
|
4609
|
+
}
|
|
4610
|
+
// ---- internals -----------------------------------------------------------
|
|
4611
|
+
decisionFor(entry) {
|
|
4612
|
+
const receipt = this.results[resultKey(entry.entryId, entry.requester)];
|
|
4613
|
+
if (!receipt) return void 0;
|
|
4614
|
+
if (receipt.state === "denied") return "deny";
|
|
4615
|
+
if (receipt.state === "allowed") return "site";
|
|
4616
|
+
return void 0;
|
|
4617
|
+
}
|
|
4618
|
+
/** Replace-don't-queue: the live entry for another origin (or another
|
|
4619
|
+
* requester) is displaced, and its requester gets a tombstone so its next poll
|
|
4620
|
+
* reads "superseded" rather than the neutral "none". */
|
|
4621
|
+
displaceLive(now) {
|
|
4622
|
+
const live = liveQueue(this.queue, now);
|
|
4623
|
+
if (!live.length) return;
|
|
4624
|
+
for (const entry of live) {
|
|
4625
|
+
this.tombstones[receiptKey(entry.origin, entry.requester)] = tombstoneFor(
|
|
4626
|
+
{
|
|
4627
|
+
origin: entry.origin,
|
|
4628
|
+
hostname: entry.displayAuthority,
|
|
4629
|
+
requester: entry.requester,
|
|
4630
|
+
requestedAt: entry.requestedAt,
|
|
4631
|
+
expiresAt: entry.expiresAt
|
|
4632
|
+
}
|
|
4633
|
+
);
|
|
4634
|
+
}
|
|
4635
|
+
this.queue = [];
|
|
4636
|
+
this.trimTombstones();
|
|
4637
|
+
}
|
|
4638
|
+
trimTombstones() {
|
|
4639
|
+
const entries = Object.entries(this.tombstones);
|
|
4640
|
+
if (entries.length <= TOMBSTONE_CAP) return;
|
|
4641
|
+
for (const [key] of entries.sort((a, b) => a[1].expiresAt - b[1].expiresAt).slice(0, entries.length - TOMBSTONE_CAP)) {
|
|
4642
|
+
delete this.tombstones[key];
|
|
4643
|
+
}
|
|
4644
|
+
}
|
|
4645
|
+
sweep(now) {
|
|
4646
|
+
const live = liveQueue(this.queue, now);
|
|
4647
|
+
this.queue = live;
|
|
4648
|
+
this.results = cleanResults(this.results, now);
|
|
4649
|
+
for (const [origin, grant] of Object.entries(this.onceGrants)) {
|
|
4650
|
+
if (now >= grant.expiresAt) delete this.onceGrants[origin];
|
|
4651
|
+
}
|
|
4652
|
+
}
|
|
4653
|
+
persist() {
|
|
4654
|
+
const ok = writeJsonAtomic(this.path, this.store);
|
|
4655
|
+
if (!ok) {
|
|
4656
|
+
this.log(
|
|
4657
|
+
`[browser] could not write the approvals store at ${this.path}; the decision is in force for this run only`
|
|
4658
|
+
);
|
|
4659
|
+
}
|
|
4660
|
+
}
|
|
4661
|
+
persistIfChanged() {
|
|
4662
|
+
this.persist();
|
|
4663
|
+
}
|
|
4664
|
+
/** Diagnosic facts for `status` (never enforced from here). */
|
|
4665
|
+
describe() {
|
|
4666
|
+
return {
|
|
4667
|
+
allowed_origins: Object.values(this.store.origins).filter(
|
|
4668
|
+
(v) => v === "allow"
|
|
4669
|
+
).length,
|
|
4670
|
+
denied_origins: Object.values(this.store.origins).filter(
|
|
4671
|
+
(v) => v === "deny"
|
|
4672
|
+
).length,
|
|
4673
|
+
broad_grants: Object.keys(this.store.siteGrants.grants ?? {}).length,
|
|
4674
|
+
pending: this.pendingEntries().length,
|
|
4675
|
+
file_mode: PRIVATE_FILE_MODE
|
|
4676
|
+
};
|
|
4677
|
+
}
|
|
4678
|
+
/** The path the store lives at, for `status` and for the Settings surface. */
|
|
4679
|
+
get filePath() {
|
|
4680
|
+
return this.path;
|
|
4681
|
+
}
|
|
4682
|
+
/** Whether a URL is covered by a stored grant, and by which scope (the
|
|
4683
|
+
* approvals list and the diagnostic path both want this).
|
|
4684
|
+
*
|
|
4685
|
+
* The vendored `matchingGrantScope` also reads the extension's LEGACY
|
|
4686
|
+
* `hostGrants` shape (0.1.4-0.1.7 loopback all-ports). This host passes
|
|
4687
|
+
* `undefined` for it deliberately: the store is new here, so there are no
|
|
4688
|
+
* legacy records to read, and the module's own schema check turns an absent
|
|
4689
|
+
* shape into "no legacy grant" rather than into a second grant model with no
|
|
4690
|
+
* data behind it. */
|
|
4691
|
+
grantScopeFor(url) {
|
|
4692
|
+
return matchingGrantScope(
|
|
4693
|
+
this.store.origins,
|
|
4694
|
+
void 0,
|
|
4695
|
+
url,
|
|
4696
|
+
this.store.siteGrants
|
|
4697
|
+
);
|
|
4698
|
+
}
|
|
4699
|
+
}
|
|
4700
|
+
const MAX_ENTRIES = 200;
|
|
4701
|
+
const MAX_BYTES = 256 * 1024;
|
|
4702
|
+
const MAX_ARG_CHARS = 2e3;
|
|
4703
|
+
const buffers = /* @__PURE__ */ new Map();
|
|
4704
|
+
function hasLogCapture(webContentsId) {
|
|
4705
|
+
return buffers.has(webContentsId);
|
|
4706
|
+
}
|
|
4707
|
+
function startLogCapture(webContentsId) {
|
|
4708
|
+
if (!buffers.has(webContentsId)) {
|
|
4709
|
+
buffers.set(webContentsId, { entries: [], bytes: 0 });
|
|
4710
|
+
}
|
|
4711
|
+
}
|
|
4712
|
+
function stopLogCapture(webContentsId) {
|
|
4713
|
+
buffers.delete(webContentsId);
|
|
4714
|
+
}
|
|
4715
|
+
function drainLogs(webContentsId, options = {}) {
|
|
4716
|
+
const buffer = buffers.get(webContentsId);
|
|
4717
|
+
if (!buffer) return [];
|
|
4718
|
+
const level = options.level?.trim().toLowerCase() ?? "";
|
|
4719
|
+
const filtered = level ? buffer.entries.filter((entry) => entry.level === level) : buffer.entries;
|
|
4720
|
+
const limit = typeof options.limit === "number" && Number.isFinite(options.limit) && options.limit > 0 ? Math.floor(options.limit) : filtered.length;
|
|
4721
|
+
return filtered.slice(Math.max(0, filtered.length - limit));
|
|
4722
|
+
}
|
|
4723
|
+
function bufferFor(webContentsId) {
|
|
4724
|
+
let buffer = buffers.get(webContentsId);
|
|
4725
|
+
if (!buffer) {
|
|
4726
|
+
buffer = { entries: [], bytes: 0 };
|
|
4727
|
+
buffers.set(webContentsId, buffer);
|
|
4728
|
+
}
|
|
4729
|
+
return buffer;
|
|
4730
|
+
}
|
|
4731
|
+
function push(webContentsId, entry) {
|
|
4732
|
+
const buffer = bufferFor(webContentsId);
|
|
4733
|
+
buffer.entries.push(entry);
|
|
4734
|
+
buffer.bytes += entry.text.length;
|
|
4735
|
+
while (buffer.entries.length > MAX_ENTRIES || buffer.bytes > MAX_BYTES) {
|
|
4736
|
+
const dropped = buffer.entries.shift();
|
|
4737
|
+
if (!dropped) break;
|
|
4738
|
+
buffer.bytes -= dropped.text.length;
|
|
4739
|
+
}
|
|
4740
|
+
}
|
|
4741
|
+
function levelForConsole(type2) {
|
|
4742
|
+
switch (type2) {
|
|
4743
|
+
case "error":
|
|
4744
|
+
case "assert":
|
|
4745
|
+
return "error";
|
|
4746
|
+
case "warning":
|
|
4747
|
+
return "warning";
|
|
4748
|
+
case "info":
|
|
4749
|
+
return "info";
|
|
4750
|
+
case "debug":
|
|
4751
|
+
return "log";
|
|
4752
|
+
default:
|
|
4753
|
+
return "log";
|
|
4754
|
+
}
|
|
4755
|
+
}
|
|
4756
|
+
function levelForLogEntry(level) {
|
|
4757
|
+
switch (level) {
|
|
4758
|
+
case "error":
|
|
4759
|
+
return "error";
|
|
4760
|
+
case "warning":
|
|
4761
|
+
return "warning";
|
|
4762
|
+
case "info":
|
|
4763
|
+
return "info";
|
|
4764
|
+
default:
|
|
4765
|
+
return "log";
|
|
4766
|
+
}
|
|
4767
|
+
}
|
|
4768
|
+
function renderArg(arg) {
|
|
4769
|
+
if (!arg) return "";
|
|
4770
|
+
if (arg.type === "string") return String(arg.value ?? "");
|
|
4771
|
+
if ("value" in arg && arg.value !== void 0) return String(arg.value);
|
|
4772
|
+
if (typeof arg.description === "string") return arg.description;
|
|
4773
|
+
if (arg.type === "undefined") return "undefined";
|
|
4774
|
+
if (arg.subtype === "null") return "null";
|
|
4775
|
+
return String(arg.type ?? "");
|
|
4776
|
+
}
|
|
4777
|
+
function clip(text) {
|
|
4778
|
+
return text.length > MAX_ARG_CHARS ? `${text.slice(0, MAX_ARG_CHARS)}…` : text;
|
|
4779
|
+
}
|
|
4780
|
+
function topFrame(stackTrace) {
|
|
4781
|
+
const frames = stackTrace?.callFrames;
|
|
4782
|
+
const frame = frames?.[0];
|
|
4783
|
+
return {
|
|
4784
|
+
url: typeof frame?.url === "string" ? frame.url : "",
|
|
4785
|
+
line: typeof frame?.lineNumber === "number" ? frame.lineNumber : 0
|
|
4786
|
+
};
|
|
4787
|
+
}
|
|
4788
|
+
function routeDebuggerEvent(webContentsId, method, rawParams) {
|
|
4789
|
+
if (!buffers.has(webContentsId)) return;
|
|
4790
|
+
const params = rawParams ?? {};
|
|
4791
|
+
if (method === "Runtime.consoleAPICalled") {
|
|
4792
|
+
const args = Array.isArray(params.args) ? params.args : [];
|
|
4793
|
+
const text = clip(args.map(renderArg).join(" ").trim());
|
|
4794
|
+
const frame = topFrame(params.stackTrace);
|
|
4795
|
+
push(webContentsId, {
|
|
4796
|
+
level: levelForConsole(params.type),
|
|
4797
|
+
text,
|
|
4798
|
+
source: "console",
|
|
4799
|
+
url: frame.url,
|
|
4800
|
+
line: frame.line,
|
|
4801
|
+
timestamp: typeof params.timestamp === "number" ? params.timestamp : Date.now()
|
|
4802
|
+
});
|
|
4803
|
+
return;
|
|
4804
|
+
}
|
|
4805
|
+
if (method === "Runtime.exceptionThrown") {
|
|
4806
|
+
const details = params.exceptionDetails ?? {};
|
|
4807
|
+
const exception = details.exception ?? {};
|
|
4808
|
+
const text = clip(
|
|
4809
|
+
typeof exception.description === "string" && exception.description ? exception.description : String(details.text ?? "uncaught exception")
|
|
4810
|
+
);
|
|
4811
|
+
const frame = topFrame(details.stackTrace);
|
|
4812
|
+
push(webContentsId, {
|
|
4813
|
+
level: "error",
|
|
4814
|
+
text,
|
|
4815
|
+
source: "exception",
|
|
4816
|
+
url: frame.url || String(details.url ?? ""),
|
|
4817
|
+
line: frame.line || Number(details.lineNumber ?? 0),
|
|
4818
|
+
timestamp: typeof params.timestamp === "number" ? params.timestamp : Date.now()
|
|
4819
|
+
});
|
|
4820
|
+
return;
|
|
4821
|
+
}
|
|
4822
|
+
if (method === "Log.entryAdded") {
|
|
4823
|
+
const entry = params.entry ?? {};
|
|
4824
|
+
const text = clip(String(entry.text ?? ""));
|
|
4825
|
+
if (!text) return;
|
|
4826
|
+
push(webContentsId, {
|
|
4827
|
+
level: levelForLogEntry(entry.level),
|
|
4828
|
+
text,
|
|
4829
|
+
source: "log-entry",
|
|
4830
|
+
url: String(entry.url ?? ""),
|
|
4831
|
+
line: Number(entry.lineNumber ?? 0),
|
|
4832
|
+
timestamp: typeof entry.timestamp === "number" ? entry.timestamp : Date.now()
|
|
4833
|
+
});
|
|
4834
|
+
}
|
|
4835
|
+
}
|
|
4836
|
+
const PROTOCOL_VERSION = "1.3";
|
|
4837
|
+
const ALREADY_ATTACHED = /another debugger|already attached/i;
|
|
4838
|
+
class CdpPool {
|
|
4839
|
+
constructor(options = {}) {
|
|
4840
|
+
this.options = options;
|
|
4841
|
+
}
|
|
4842
|
+
attachments = /* @__PURE__ */ new Map();
|
|
4843
|
+
/** Per-view CDP event subscribers, by webContents id. See `subscribe`. */
|
|
4844
|
+
subscribers = /* @__PURE__ */ new Map();
|
|
4845
|
+
/**
|
|
4846
|
+
* Observe this view's CDP events for as long as the returned function has not
|
|
4847
|
+
* been called.
|
|
4848
|
+
*
|
|
4849
|
+
* Needed because the driver has exactly ONE event source (`debugger`'s
|
|
4850
|
+
* `message`) and two things want to read it: the log ring buffer, which wants
|
|
4851
|
+
* everything for the tab's whole life, and a per-navigation gate, which wants
|
|
4852
|
+
* `Fetch.requestPaused` only while an agent navigation is in flight. A second
|
|
4853
|
+
* `on('message')` registration per use would leak listeners on a view that is
|
|
4854
|
+
* driven for hours; this is a set that is emptied by the caller.
|
|
4855
|
+
*/
|
|
4856
|
+
subscribe(webContentsId, handler) {
|
|
4857
|
+
let listeners = this.subscribers.get(webContentsId);
|
|
4858
|
+
if (!listeners) {
|
|
4859
|
+
listeners = /* @__PURE__ */ new Set();
|
|
4860
|
+
this.subscribers.set(webContentsId, listeners);
|
|
4861
|
+
}
|
|
4862
|
+
listeners.add(handler);
|
|
4863
|
+
return () => {
|
|
4864
|
+
listeners.delete(handler);
|
|
4865
|
+
if (listeners.size === 0) this.subscribers.delete(webContentsId);
|
|
4866
|
+
};
|
|
4867
|
+
}
|
|
4868
|
+
routeEvent(webContentsId, method, params) {
|
|
4869
|
+
routeDebuggerEvent(webContentsId, method, params);
|
|
4870
|
+
for (const listener of [...this.subscribers.get(webContentsId) ?? []]) {
|
|
4871
|
+
listener(method, params);
|
|
4872
|
+
}
|
|
4873
|
+
}
|
|
4874
|
+
/** Whether this view currently has a live attachment. */
|
|
4875
|
+
isAttached(webContentsId) {
|
|
4876
|
+
const attachment = this.attachments.get(webContentsId);
|
|
4877
|
+
return !!attachment && attachment.detachedReason === null;
|
|
4878
|
+
}
|
|
4879
|
+
/**
|
|
4880
|
+
* Attach to a view, or adopt the attachment we already hold.
|
|
4881
|
+
*
|
|
4882
|
+
* Enables `Runtime` and `Log` on the way in. That is part of attach rather
|
|
4883
|
+
* than of `logs` because `logs` is defined as "everything since the surface
|
|
4884
|
+
* opened" — enabling lazily would silently lose exactly the early console
|
|
4885
|
+
* error that a debugging session is looking for.
|
|
4886
|
+
*/
|
|
4887
|
+
async attach(contents) {
|
|
4888
|
+
if (contents.isDestroyed()) {
|
|
4889
|
+
throw new BridgeCommandError("tab_closed", "that browser tab is gone");
|
|
4890
|
+
}
|
|
4891
|
+
const existing = this.attachments.get(contents.id);
|
|
4892
|
+
if (existing && existing.detachedReason === null) return;
|
|
4893
|
+
if (existing) this.releaseListeners(existing);
|
|
4894
|
+
if (!contents.getURL()) {
|
|
4895
|
+
await deadline(
|
|
4896
|
+
contents.loadURL("about:blank"),
|
|
4897
|
+
WEB_CONTENTS_DEADLINE_MS,
|
|
4898
|
+
"loadURL(about:blank)"
|
|
4899
|
+
);
|
|
4900
|
+
}
|
|
4901
|
+
try {
|
|
4902
|
+
contents.debugger.attach(PROTOCOL_VERSION);
|
|
4903
|
+
} catch (error) {
|
|
4904
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4905
|
+
if (!ALREADY_ATTACHED.test(message)) {
|
|
4906
|
+
throw new BridgeCommandError(
|
|
4907
|
+
"internal",
|
|
4908
|
+
`could not attach to the page: ${message}`
|
|
4909
|
+
);
|
|
4910
|
+
}
|
|
4911
|
+
const ours = await this.probeOwnAttachment(contents);
|
|
4912
|
+
if (!ours) {
|
|
4913
|
+
throw new BridgeCommandError(
|
|
4914
|
+
"debugger_conflict",
|
|
4915
|
+
"DevTools is attached to the driven tab; close it and retry"
|
|
4916
|
+
);
|
|
4917
|
+
}
|
|
4918
|
+
}
|
|
4919
|
+
const messageListener = (_event, method, params) => {
|
|
4920
|
+
this.routeEvent(
|
|
4921
|
+
contents.id,
|
|
4922
|
+
method,
|
|
4923
|
+
params ?? {}
|
|
4924
|
+
);
|
|
4925
|
+
};
|
|
4926
|
+
const detachListener = (_event, reason) => {
|
|
4927
|
+
attachment.detachedReason = reason || "detached";
|
|
4928
|
+
this.options.log?.(
|
|
4929
|
+
`[browser] debugger detached from view ${contents.id}: ${reason}`
|
|
4930
|
+
);
|
|
4931
|
+
};
|
|
4932
|
+
const attachment = {
|
|
4933
|
+
contents,
|
|
4934
|
+
detachedReason: null,
|
|
4935
|
+
listeners: { message: messageListener, detach: detachListener }
|
|
4936
|
+
};
|
|
4937
|
+
this.attachments.set(contents.id, attachment);
|
|
4938
|
+
contents.debugger.on("message", messageListener);
|
|
4939
|
+
contents.debugger.on("detach", detachListener);
|
|
4940
|
+
if (!hasLogCapture(contents.id)) startLogCapture(contents.id);
|
|
4941
|
+
try {
|
|
4942
|
+
await this.sendTo(contents, "Runtime.enable", {});
|
|
4943
|
+
await this.sendTo(contents, "Log.enable", {});
|
|
4944
|
+
await this.sendTo(contents, "Emulation.setFocusEmulationEnabled", {
|
|
4945
|
+
enabled: true
|
|
4946
|
+
});
|
|
4947
|
+
} catch (error) {
|
|
4948
|
+
this.forget(contents.id);
|
|
4949
|
+
throw error;
|
|
4950
|
+
}
|
|
4951
|
+
}
|
|
4952
|
+
/** A bounded, catch-all probe: a view that does not answer is one we cannot
|
|
4953
|
+
* drive, so `false` (⇒ a real conflict) is the answer this exists to give. */
|
|
4954
|
+
async probeOwnAttachment(contents) {
|
|
4955
|
+
try {
|
|
4956
|
+
await deadline(
|
|
4957
|
+
contents.debugger.sendCommand("Runtime.evaluate", { expression: "1" }),
|
|
4958
|
+
CDP_ATTACH_DEADLINE_MS,
|
|
4959
|
+
`Runtime.evaluate probe on view ${contents.id}`
|
|
4960
|
+
);
|
|
4961
|
+
return true;
|
|
4962
|
+
} catch {
|
|
4963
|
+
return false;
|
|
4964
|
+
}
|
|
4965
|
+
}
|
|
4966
|
+
/**
|
|
4967
|
+
* Send one CDP command on a view, with the ordinary ceiling.
|
|
4968
|
+
*
|
|
4969
|
+
* The refusals are the interesting part: a destroyed view is `tab_closed` (the
|
|
4970
|
+
* session's recovery is `open`) and a view that was never attached is
|
|
4971
|
+
* `tab_closed` too — an action that skipped `attach` is a bug in this host, and
|
|
4972
|
+
* reporting it as anything the agent could act on would be a lie.
|
|
4973
|
+
*/
|
|
4974
|
+
async send(contents, method, params = {}) {
|
|
4975
|
+
if (contents.isDestroyed()) {
|
|
4976
|
+
this.forget(contents.id);
|
|
4977
|
+
throw new BridgeCommandError(
|
|
4978
|
+
"tab_closed",
|
|
4979
|
+
"that browser tab is gone; dropped the handle. Use 'open' with a URL to get a new tab"
|
|
4980
|
+
);
|
|
4981
|
+
}
|
|
4982
|
+
return this.sendTo(contents, method, params);
|
|
4983
|
+
}
|
|
4984
|
+
async sendTo(contents, method, params) {
|
|
4985
|
+
const attachment = this.attachments.get(contents.id);
|
|
4986
|
+
if (!attachment) {
|
|
4987
|
+
throw new BridgeCommandError(
|
|
4988
|
+
"tab_closed",
|
|
4989
|
+
"that browser tab is not being driven; use 'open' to start a new tab",
|
|
4990
|
+
{ reason: "not_attached" }
|
|
4991
|
+
);
|
|
4992
|
+
}
|
|
4993
|
+
if (attachment.detachedReason !== null) {
|
|
4994
|
+
throw new BridgeCommandError(
|
|
4995
|
+
"debugger_conflict",
|
|
4996
|
+
"DevTools is attached to the driven tab; close it and retry",
|
|
4997
|
+
{ reason: attachment.detachedReason }
|
|
4998
|
+
);
|
|
4999
|
+
}
|
|
5000
|
+
return deadline(
|
|
5001
|
+
contents.debugger.sendCommand(method, params),
|
|
5002
|
+
CDP_DEADLINE_MS,
|
|
5003
|
+
method
|
|
5004
|
+
);
|
|
5005
|
+
}
|
|
5006
|
+
/** Remove this attachment's debugger listeners. Called before the record is
|
|
5007
|
+
* replaced by a re-`attach` and before it is dropped, so a view is never left
|
|
5008
|
+
* with a pair nobody owns — a stale one would deliver every event once per
|
|
5009
|
+
* attach cycle to `routeEvent` (and so to every subscriber). */
|
|
5010
|
+
releaseListeners(attachment) {
|
|
5011
|
+
attachment.contents.debugger.removeListener?.(
|
|
5012
|
+
"message",
|
|
5013
|
+
attachment.listeners.message
|
|
5014
|
+
);
|
|
5015
|
+
attachment.contents.debugger.removeListener?.(
|
|
5016
|
+
"detach",
|
|
5017
|
+
attachment.listeners.detach
|
|
5018
|
+
);
|
|
5019
|
+
}
|
|
5020
|
+
/** Drop the attachment record and its log buffer, without talking to the view.
|
|
5021
|
+
* Used when the view is already gone. */
|
|
5022
|
+
forget(webContentsId) {
|
|
5023
|
+
const attachment = this.attachments.get(webContentsId);
|
|
5024
|
+
if (attachment) this.releaseListeners(attachment);
|
|
5025
|
+
this.attachments.delete(webContentsId);
|
|
5026
|
+
this.subscribers.delete(webContentsId);
|
|
5027
|
+
stopLogCapture(webContentsId);
|
|
5028
|
+
}
|
|
5029
|
+
/** Detach cleanly: release the debugger session and the log buffer. */
|
|
5030
|
+
async detach(webContentsId) {
|
|
5031
|
+
const attachment = this.attachments.get(webContentsId);
|
|
5032
|
+
if (attachment) this.releaseListeners(attachment);
|
|
5033
|
+
this.attachments.delete(webContentsId);
|
|
5034
|
+
this.subscribers.delete(webContentsId);
|
|
5035
|
+
stopLogCapture(webContentsId);
|
|
5036
|
+
if (!attachment) return;
|
|
5037
|
+
try {
|
|
5038
|
+
if (attachment.contents.debugger.isAttached()) {
|
|
5039
|
+
await deadline(
|
|
5040
|
+
Promise.resolve(attachment.contents.debugger.detach()),
|
|
5041
|
+
CDP_ATTACH_DEADLINE_MS,
|
|
5042
|
+
`debugger detach on view ${webContentsId}`
|
|
5043
|
+
);
|
|
5044
|
+
}
|
|
5045
|
+
} catch {
|
|
5046
|
+
}
|
|
5047
|
+
}
|
|
5048
|
+
/** Detach everything. Called on host stop and on app quit. */
|
|
5049
|
+
async close() {
|
|
5050
|
+
for (const id2 of [...this.attachments.keys()]) await this.detach(id2);
|
|
5051
|
+
}
|
|
5052
|
+
}
|
|
5053
|
+
function requesterOf(params, requestId2) {
|
|
5054
|
+
const supplied = typeof params.requester === "string" ? params.requester.trim() : "";
|
|
5055
|
+
return supplied.startsWith("session:") ? supplied : requestId2;
|
|
5056
|
+
}
|
|
5057
|
+
function sessionIdOf$1(requester) {
|
|
5058
|
+
return requester.startsWith("session:") ? requester.slice("session:".length) : "";
|
|
5059
|
+
}
|
|
5060
|
+
function stringParam(params, key) {
|
|
5061
|
+
const value = params[key];
|
|
5062
|
+
return typeof value === "string" ? value.trim() : "";
|
|
5063
|
+
}
|
|
5064
|
+
function numberParam(params, key) {
|
|
5065
|
+
const value = params[key];
|
|
5066
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
5067
|
+
}
|
|
5068
|
+
const ERR_ABORTED = -3;
|
|
5069
|
+
function settle(contents, timeoutMs = 3e4) {
|
|
5070
|
+
return new Promise((resolve, reject) => {
|
|
5071
|
+
let done = false;
|
|
5072
|
+
const finish = (error) => {
|
|
5073
|
+
if (done) return;
|
|
5074
|
+
done = true;
|
|
5075
|
+
clearTimeout(timer);
|
|
5076
|
+
contents.removeListener("did-finish-load", onFinish);
|
|
5077
|
+
contents.removeListener("did-fail-load", onFail);
|
|
5078
|
+
contents.removeListener("did-navigate-in-page", onInPage);
|
|
5079
|
+
contents.removeListener("destroyed", onDestroyed);
|
|
5080
|
+
if (error) reject(error);
|
|
5081
|
+
else resolve();
|
|
5082
|
+
};
|
|
5083
|
+
const onFinish = () => finish();
|
|
5084
|
+
const onInPage = (_event, _url, isMainFrame) => {
|
|
5085
|
+
if (!isMainFrame) return;
|
|
5086
|
+
finish();
|
|
5087
|
+
};
|
|
5088
|
+
const onFail = (_event, errorCode, errorDescription, _url, isMainFrame) => {
|
|
5089
|
+
if (!isMainFrame) return;
|
|
5090
|
+
if (errorCode === ERR_ABORTED) return;
|
|
5091
|
+
finish(
|
|
5092
|
+
new BridgeCommandError(
|
|
5093
|
+
"nav_failed",
|
|
5094
|
+
errorDescription ?? "navigation failed",
|
|
5095
|
+
{ error_code: errorCode ?? 0 }
|
|
5096
|
+
)
|
|
5097
|
+
);
|
|
5098
|
+
};
|
|
5099
|
+
const onDestroyed = () => finish(
|
|
5100
|
+
new BridgeCommandError(
|
|
5101
|
+
"tab_closed",
|
|
5102
|
+
"that browser tab closed while it was loading; dropped the handle. Use 'open' with a URL to get a new tab"
|
|
5103
|
+
)
|
|
5104
|
+
);
|
|
5105
|
+
const timer = setTimeout(
|
|
5106
|
+
() => finish(
|
|
5107
|
+
new BridgeCommandError("nav_timeout", "navigation timed out", {
|
|
5108
|
+
timeout_ms: timeoutMs
|
|
5109
|
+
})
|
|
5110
|
+
),
|
|
5111
|
+
timeoutMs
|
|
5112
|
+
);
|
|
5113
|
+
contents.on("did-finish-load", onFinish);
|
|
5114
|
+
contents.on("did-fail-load", onFail);
|
|
5115
|
+
contents.on("did-navigate-in-page", onInPage);
|
|
5116
|
+
contents.on("destroyed", onDestroyed);
|
|
5117
|
+
});
|
|
5118
|
+
}
|
|
5119
|
+
function permittedScheme(url) {
|
|
5120
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
5121
|
+
}
|
|
5122
|
+
async function withOriginGate(ctx, view, requester, approved, operation) {
|
|
5123
|
+
const contents = view.webContents;
|
|
5124
|
+
const blocked = [];
|
|
5125
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5126
|
+
const unsubscribe = ctx.cdp.subscribe(contents.id, (method, params) => {
|
|
5127
|
+
if (method !== "Fetch.requestPaused") return;
|
|
5128
|
+
const requestId2 = typeof params.requestId === "string" ? params.requestId : "";
|
|
5129
|
+
if (!requestId2) return;
|
|
5130
|
+
const rawUrl = typeof params.request === "object" && params.request !== null ? params.request.url : void 0;
|
|
5131
|
+
let url = null;
|
|
5132
|
+
try {
|
|
5133
|
+
url = rawUrl === void 0 ? null : new URL(String(rawUrl));
|
|
5134
|
+
} catch {
|
|
5135
|
+
url = null;
|
|
5136
|
+
}
|
|
5137
|
+
const refuse = (origin) => {
|
|
5138
|
+
if (!seen.has(origin)) {
|
|
5139
|
+
seen.add(origin);
|
|
5140
|
+
blocked.push(origin);
|
|
5141
|
+
ctx.log(
|
|
5142
|
+
`[browser] refused a navigation hop for ${requester}: ${origin} is not approved`
|
|
5143
|
+
);
|
|
5144
|
+
}
|
|
5145
|
+
void ctx.cdp.send(contents, "Fetch.failRequest", {
|
|
5146
|
+
requestId: requestId2,
|
|
5147
|
+
errorReason: "BlockedByClient"
|
|
5148
|
+
}).catch(() => {
|
|
5149
|
+
});
|
|
5150
|
+
};
|
|
5151
|
+
if (!url || !permittedScheme(url)) {
|
|
5152
|
+
refuse(String(rawUrl ?? "unparseable"));
|
|
5153
|
+
return;
|
|
5154
|
+
}
|
|
5155
|
+
if (approved(url)) {
|
|
5156
|
+
void ctx.cdp.send(contents, "Fetch.continueRequest", { requestId: requestId2 }).catch(() => {
|
|
5157
|
+
});
|
|
5158
|
+
return;
|
|
5159
|
+
}
|
|
5160
|
+
refuse(url.origin);
|
|
5161
|
+
});
|
|
5162
|
+
try {
|
|
5163
|
+
await ctx.cdp.send(contents, "Fetch.enable", {
|
|
5164
|
+
patterns: [{ resourceType: "Document", requestStage: "Request" }]
|
|
5165
|
+
});
|
|
5166
|
+
return await operation();
|
|
5167
|
+
} catch (error) {
|
|
5168
|
+
if (blocked.length) {
|
|
5169
|
+
throw new BridgeCommandError(
|
|
5170
|
+
"origin_not_allowed",
|
|
5171
|
+
`the user has not approved ${blocked[0]} for agent access`,
|
|
5172
|
+
{ origin: blocked[0], blocked: blocked.slice() }
|
|
5173
|
+
);
|
|
5174
|
+
}
|
|
5175
|
+
throw error;
|
|
5176
|
+
} finally {
|
|
5177
|
+
unsubscribe();
|
|
5178
|
+
try {
|
|
5179
|
+
await ctx.cdp.send(contents, "Fetch.disable", {});
|
|
5180
|
+
} catch {
|
|
5181
|
+
}
|
|
5182
|
+
}
|
|
5183
|
+
}
|
|
5184
|
+
async function navigateView(ctx, view, url, requester, approved, timeoutMs = 3e4) {
|
|
5185
|
+
const contents = view.webContents;
|
|
5186
|
+
return withOriginGate(ctx, view, requester, approved, async () => {
|
|
5187
|
+
const settled = settle(contents, timeoutMs);
|
|
5188
|
+
await Promise.race([settled, loadFailure(contents.loadURL(url.href))]);
|
|
5189
|
+
return { url: contents.getURL(), title: contents.getTitle() };
|
|
5190
|
+
});
|
|
5191
|
+
}
|
|
5192
|
+
function loadFailure(load) {
|
|
5193
|
+
return load.then(
|
|
5194
|
+
() => new Promise(() => {
|
|
5195
|
+
}),
|
|
5196
|
+
(error) => {
|
|
5197
|
+
throw error;
|
|
5198
|
+
}
|
|
5199
|
+
);
|
|
5200
|
+
}
|
|
5201
|
+
function pageOf(view) {
|
|
5202
|
+
return {
|
|
5203
|
+
url: view.webContents.isDestroyed() ? "" : view.webContents.getURL(),
|
|
5204
|
+
title: view.webContents.isDestroyed() ? "" : view.webContents.getTitle()
|
|
5205
|
+
};
|
|
5206
|
+
}
|
|
5207
|
+
const LANDMARKS = /* @__PURE__ */ new Set(["banner", "main", "navigation", "complementary", "contentinfo", "form", "region"]);
|
|
5208
|
+
const INTERACTIVE = /* @__PURE__ */ new Set(["button", "link", "textbox", "checkbox", "radio", "combobox", "menuitem", "tab", "switch", "slider", "spinbutton"]);
|
|
5209
|
+
function compactAX(nodes, epoch) {
|
|
5210
|
+
const byId = new Map(nodes.map((node) => [node.nodeId, node]));
|
|
5211
|
+
const refs = {};
|
|
5212
|
+
const lines = [];
|
|
5213
|
+
let sequence = 0;
|
|
5214
|
+
const visited = /* @__PURE__ */ new Set();
|
|
5215
|
+
function visit(node, depth) {
|
|
5216
|
+
if (visited.has(node.nodeId)) return;
|
|
5217
|
+
visited.add(node.nodeId);
|
|
5218
|
+
if (node.ignored) {
|
|
5219
|
+
for (const child of node.childIds ?? []) {
|
|
5220
|
+
const found = byId.get(child);
|
|
5221
|
+
if (found) visit(found, depth);
|
|
5222
|
+
}
|
|
5223
|
+
return;
|
|
5224
|
+
}
|
|
5225
|
+
const role = String(node.role?.value ?? "");
|
|
5226
|
+
const name = String(node.name?.value ?? "").trim();
|
|
5227
|
+
const focusable = node.properties?.some((property) => property.name === "focusable" && property.value.value === true);
|
|
5228
|
+
const interesting = INTERACTIVE.has(role) || LANDMARKS.has(role) || focusable || Boolean(name && role !== "StaticText");
|
|
5229
|
+
if (interesting) {
|
|
5230
|
+
let ref = "";
|
|
5231
|
+
if ((INTERACTIVE.has(role) || focusable) && node.backendDOMNodeId !== void 0) {
|
|
5232
|
+
ref = `e${++sequence}`;
|
|
5233
|
+
refs[ref] = { backendNodeId: node.backendDOMNodeId, epoch };
|
|
5234
|
+
}
|
|
5235
|
+
lines.push(`${" ".repeat(depth)}- ${role || "node"}${name ? ` ${JSON.stringify(name)}` : ""}${ref ? ` [${ref}]` : ""}`);
|
|
5236
|
+
}
|
|
5237
|
+
for (const child of node.childIds ?? []) {
|
|
5238
|
+
const found = byId.get(child);
|
|
5239
|
+
if (found) visit(found, interesting ? depth + 1 : depth);
|
|
5240
|
+
}
|
|
5241
|
+
}
|
|
5242
|
+
const childIds = new Set(nodes.flatMap((node) => node.childIds ?? []));
|
|
5243
|
+
for (const root of nodes.filter((node) => !childIds.has(node.nodeId))) visit(root, 0);
|
|
5244
|
+
return { snapshot: lines.join("\n"), refs };
|
|
5245
|
+
}
|
|
5246
|
+
const PAGE_OVERLAP_PX = 80;
|
|
5247
|
+
function defaultScrollExpression() {
|
|
5248
|
+
return scrollExpressionFor("down");
|
|
5249
|
+
}
|
|
5250
|
+
function deltaScrollExpression(dx, dy) {
|
|
5251
|
+
return `window.scrollBy({left: ${dx}, top: ${dy}, behavior: 'instant'})`;
|
|
5252
|
+
}
|
|
5253
|
+
const SCROLL_INTO_VIEW_FN = `function(){ this.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); }`;
|
|
5254
|
+
function scrollExpressionFor(direction) {
|
|
5255
|
+
const page = `(window.innerHeight - ${PAGE_OVERLAP_PX})`;
|
|
5256
|
+
const across = `(window.innerWidth - ${PAGE_OVERLAP_PX})`;
|
|
5257
|
+
switch (direction) {
|
|
5258
|
+
case "top":
|
|
5259
|
+
return `window.scrollTo({left: window.scrollX, top: 0, behavior: 'instant'})`;
|
|
5260
|
+
case "bottom": {
|
|
5261
|
+
const de = `(document.scrollingElement||document.documentElement)`;
|
|
5262
|
+
return `window.scrollTo({left: window.scrollX, top: ${de}.scrollHeight, behavior: 'instant'})`;
|
|
5263
|
+
}
|
|
5264
|
+
case "up":
|
|
5265
|
+
return `window.scrollBy({left: 0, top: -${page}, behavior: 'instant'})`;
|
|
5266
|
+
case "down":
|
|
5267
|
+
return `window.scrollBy({left: 0, top: ${page}, behavior: 'instant'})`;
|
|
5268
|
+
case "left":
|
|
5269
|
+
return `window.scrollBy({left: -${across}, top: 0, behavior: 'instant'})`;
|
|
5270
|
+
case "right":
|
|
5271
|
+
return `window.scrollBy({left: ${across}, top: 0, behavior: 'instant'})`;
|
|
5272
|
+
default:
|
|
5273
|
+
return `void 0`;
|
|
5274
|
+
}
|
|
5275
|
+
}
|
|
5276
|
+
const ISOLATED_WORLD_ID = 999;
|
|
5277
|
+
const INVALID_SELECTOR = /SyntaxError|not a valid selector/i;
|
|
5278
|
+
const READ_FUNCTION = `function (selector) {
|
|
5279
|
+
const element = document.querySelector(selector);
|
|
5280
|
+
if (!element) return null;
|
|
5281
|
+
const clone = element.cloneNode(true);
|
|
5282
|
+
const strip = clone.querySelectorAll("script,style,noscript,template");
|
|
5283
|
+
for (const node of strip) node.remove();
|
|
5284
|
+
return (clone.textContent || "")
|
|
5285
|
+
.replace(/[ \\t\\u00a0]+/g, " ")
|
|
5286
|
+
.replace(/\\n\\s*\\n\\s*\\n+/g, "\\n\\n")
|
|
5287
|
+
.trim();
|
|
5288
|
+
}`;
|
|
5289
|
+
async function read(ctx, params) {
|
|
5290
|
+
const record = ctx.registry.requireSurface(params.tab);
|
|
5291
|
+
const selector = stringParam(params, "selector") || "body";
|
|
5292
|
+
const code = `(${READ_FUNCTION})(${JSON.stringify(selector)})`;
|
|
5293
|
+
let text;
|
|
5294
|
+
try {
|
|
5295
|
+
text = await deadline(
|
|
5296
|
+
record.view.webContents.executeJavaScriptInIsolatedWorld(
|
|
5297
|
+
ISOLATED_WORLD_ID,
|
|
5298
|
+
[{ code }]
|
|
5299
|
+
),
|
|
5300
|
+
SCRIPTING_DEADLINE_MS,
|
|
5301
|
+
`read(${selector})`
|
|
5302
|
+
);
|
|
5303
|
+
} catch (error) {
|
|
5304
|
+
if (error instanceof Error && INVALID_SELECTOR.test(error.message)) {
|
|
5305
|
+
throw new BridgeCommandError(
|
|
5306
|
+
"element_not_found",
|
|
5307
|
+
`selector ${selector} is not valid`
|
|
5308
|
+
);
|
|
5309
|
+
}
|
|
5310
|
+
throw error;
|
|
5311
|
+
}
|
|
5312
|
+
if (text === null || text === void 0) {
|
|
5313
|
+
throw new BridgeCommandError(
|
|
5314
|
+
"element_not_found",
|
|
5315
|
+
`selector ${selector} matched nothing`
|
|
5316
|
+
);
|
|
5317
|
+
}
|
|
5318
|
+
ctx.registry.touch(record);
|
|
5319
|
+
return { text: String(text), ...pageOf(record.view) };
|
|
5320
|
+
}
|
|
5321
|
+
async function snapshot(ctx, params) {
|
|
5322
|
+
const record = ctx.registry.requireSurface(params.tab);
|
|
5323
|
+
const contents = record.view.webContents;
|
|
5324
|
+
await ctx.cdp.send(contents, "Accessibility.enable", {});
|
|
5325
|
+
let nodes = [];
|
|
5326
|
+
try {
|
|
5327
|
+
const tree = await ctx.cdp.send(
|
|
5328
|
+
contents,
|
|
5329
|
+
"Accessibility.getFullAXTree",
|
|
5330
|
+
{}
|
|
5331
|
+
);
|
|
5332
|
+
nodes = Array.isArray(tree?.nodes) ? tree.nodes : [];
|
|
5333
|
+
} finally {
|
|
5334
|
+
await ctx.cdp.send(contents, "Accessibility.disable", {}).catch(() => {
|
|
5335
|
+
});
|
|
5336
|
+
}
|
|
5337
|
+
const { snapshot: rendered, refs } = compactAX(nodes, record.epoch);
|
|
5338
|
+
record.refs = refs;
|
|
5339
|
+
ctx.registry.touch(record);
|
|
5340
|
+
return {
|
|
5341
|
+
snapshot: rendered,
|
|
5342
|
+
refs: Object.keys(refs).length,
|
|
5343
|
+
epoch: record.epoch,
|
|
5344
|
+
...pageOf(record.view)
|
|
5345
|
+
};
|
|
5346
|
+
}
|
|
5347
|
+
const METRICS_EXPRESSION = `(() => {
|
|
5348
|
+
const de = document.scrollingElement || document.documentElement;
|
|
5349
|
+
const x = window.scrollX, y = window.scrollY;
|
|
5350
|
+
return {
|
|
5351
|
+
scrollX: Math.round(x),
|
|
5352
|
+
scrollY: Math.round(y),
|
|
5353
|
+
moreBelow: (de.scrollHeight - (y + window.innerHeight)) > 1,
|
|
5354
|
+
moreRight: (de.scrollWidth - (x + window.innerWidth)) > 1,
|
|
5355
|
+
};
|
|
5356
|
+
})()`;
|
|
5357
|
+
async function scroll(ctx, params) {
|
|
5358
|
+
const record = ctx.registry.requireSurface(params.tab);
|
|
5359
|
+
const contents = record.view.webContents;
|
|
5360
|
+
const selector = stringParam(params, "selector");
|
|
5361
|
+
const direction = stringParam(params, "direction").toLowerCase();
|
|
5362
|
+
const dx = numberParam(params, "x");
|
|
5363
|
+
const dy = numberParam(params, "y");
|
|
5364
|
+
if (selector) {
|
|
5365
|
+
const nodeId = await nodeIdForSelector(ctx, record.view, selector);
|
|
5366
|
+
await ctx.cdp.send(contents, "DOM.scrollIntoViewIfNeeded", { nodeId });
|
|
5367
|
+
const resolved = await ctx.cdp.send(
|
|
5368
|
+
contents,
|
|
5369
|
+
"DOM.resolveNode",
|
|
5370
|
+
{ nodeId }
|
|
5371
|
+
);
|
|
5372
|
+
const objectId = resolved?.object?.objectId;
|
|
5373
|
+
if (!objectId) {
|
|
5374
|
+
throw new BridgeCommandError(
|
|
5375
|
+
"element_not_found",
|
|
5376
|
+
`selector ${selector} matched nothing`
|
|
5377
|
+
);
|
|
5378
|
+
}
|
|
5379
|
+
await ctx.cdp.send(contents, "Runtime.callFunctionOn", {
|
|
5380
|
+
objectId,
|
|
5381
|
+
functionDeclaration: SCROLL_INTO_VIEW_FN,
|
|
5382
|
+
returnByValue: true
|
|
5383
|
+
});
|
|
5384
|
+
} else if (dx !== void 0 || dy !== void 0) {
|
|
5385
|
+
await ctx.cdp.send(contents, "Runtime.evaluate", {
|
|
5386
|
+
expression: deltaScrollExpression(dx ?? 0, dy ?? 0),
|
|
5387
|
+
returnByValue: true
|
|
5388
|
+
});
|
|
5389
|
+
} else if (direction) {
|
|
5390
|
+
if (!SCROLL_DIRECTIONS.has(direction)) {
|
|
5391
|
+
throw new BridgeCommandError(
|
|
5392
|
+
"internal",
|
|
5393
|
+
`unknown scroll direction: ${direction} (top/bottom/up/down/left/right)`
|
|
5394
|
+
);
|
|
5395
|
+
}
|
|
5396
|
+
await ctx.cdp.send(contents, "Runtime.evaluate", {
|
|
5397
|
+
expression: scrollExpressionFor(direction),
|
|
5398
|
+
returnByValue: true
|
|
5399
|
+
});
|
|
5400
|
+
} else {
|
|
5401
|
+
await ctx.cdp.send(contents, "Runtime.evaluate", {
|
|
5402
|
+
expression: defaultScrollExpression(),
|
|
5403
|
+
returnByValue: true
|
|
5404
|
+
});
|
|
5405
|
+
}
|
|
5406
|
+
await sleep(150);
|
|
5407
|
+
const metrics = await readMetrics(ctx, record.view);
|
|
5408
|
+
ctx.registry.touch(record);
|
|
5409
|
+
const result = {
|
|
5410
|
+
...metrics,
|
|
5411
|
+
...pageOf(record.view)
|
|
5412
|
+
};
|
|
5413
|
+
return { ...result };
|
|
5414
|
+
}
|
|
5415
|
+
async function readMetrics(ctx, view) {
|
|
5416
|
+
const out = await ctx.cdp.send(view.webContents, "Runtime.evaluate", {
|
|
5417
|
+
expression: METRICS_EXPRESSION,
|
|
5418
|
+
returnByValue: true
|
|
5419
|
+
});
|
|
5420
|
+
const value = out?.result?.value ?? {};
|
|
5421
|
+
return {
|
|
5422
|
+
scrollX: Number(value.scrollX ?? 0),
|
|
5423
|
+
scrollY: Number(value.scrollY ?? 0),
|
|
5424
|
+
moreBelow: Boolean(value.moreBelow),
|
|
5425
|
+
moreRight: Boolean(value.moreRight)
|
|
5426
|
+
};
|
|
5427
|
+
}
|
|
5428
|
+
async function logs(ctx, params) {
|
|
5429
|
+
const record = ctx.registry.requireSurface(params.tab);
|
|
5430
|
+
const level = stringParam(params, "level").toLowerCase();
|
|
5431
|
+
const limit = numberParam(params, "limit");
|
|
5432
|
+
const entries = drainLogs(record.view.webContents.id, {
|
|
5433
|
+
level: level && level !== "all" ? level : "",
|
|
5434
|
+
limit
|
|
5435
|
+
});
|
|
5436
|
+
return { entries, count: entries.length, ...pageOf(record.view) };
|
|
5437
|
+
}
|
|
5438
|
+
async function screenshot(ctx, params) {
|
|
5439
|
+
const record = ctx.registry.requireSurface(params.tab);
|
|
5440
|
+
const contents = record.view.webContents;
|
|
5441
|
+
const bounds = record.view.getBounds?.();
|
|
5442
|
+
if (bounds && (bounds.width === 0 || bounds.height === 0)) {
|
|
5443
|
+
throw new BridgeCommandError(
|
|
5444
|
+
"internal",
|
|
5445
|
+
"the browser view has no area to capture: the app's browser chrome has not reported a page area yet",
|
|
5446
|
+
{ reason: "no_content_rect" }
|
|
5447
|
+
);
|
|
5448
|
+
}
|
|
5449
|
+
await ctx.cdp.attach(contents);
|
|
5450
|
+
const shot = await ctx.cdp.send(
|
|
5451
|
+
contents,
|
|
5452
|
+
"Page.captureScreenshot",
|
|
5453
|
+
{ format: "png", captureBeyondViewport: false }
|
|
5454
|
+
);
|
|
5455
|
+
const data = shot?.data;
|
|
5456
|
+
if (!data) {
|
|
5457
|
+
throw new BridgeCommandError(
|
|
5458
|
+
"internal",
|
|
5459
|
+
"the page returned no screenshot data"
|
|
5460
|
+
);
|
|
5461
|
+
}
|
|
5462
|
+
ctx.registry.touch(record);
|
|
5463
|
+
return { data, ...pageOf(record.view) };
|
|
5464
|
+
}
|
|
5465
|
+
async function nodeIdForSelector(ctx, view, selector) {
|
|
5466
|
+
const contents = view.webContents;
|
|
5467
|
+
const document = await ctx.cdp.send(
|
|
5468
|
+
contents,
|
|
5469
|
+
"DOM.getDocument",
|
|
5470
|
+
{ depth: 0 }
|
|
5471
|
+
);
|
|
5472
|
+
const rootId = document?.root?.nodeId;
|
|
5473
|
+
if (rootId === void 0) {
|
|
5474
|
+
throw new BridgeCommandError("internal", "the page has no document to query");
|
|
5475
|
+
}
|
|
5476
|
+
const queried = await ctx.cdp.send(
|
|
5477
|
+
contents,
|
|
5478
|
+
"DOM.querySelector",
|
|
5479
|
+
{
|
|
5480
|
+
nodeId: rootId,
|
|
5481
|
+
selector
|
|
5482
|
+
}
|
|
5483
|
+
);
|
|
5484
|
+
if (!queried?.nodeId) {
|
|
5485
|
+
throw new BridgeCommandError(
|
|
5486
|
+
"element_not_found",
|
|
5487
|
+
`selector ${selector} matched nothing`
|
|
5488
|
+
);
|
|
5489
|
+
}
|
|
5490
|
+
return queried.nodeId;
|
|
5491
|
+
}
|
|
5492
|
+
const CLICK_FUNCTION = `function () {
|
|
5493
|
+
const r = this.getBoundingClientRect();
|
|
5494
|
+
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
|
|
5495
|
+
const opts = { bubbles: true, cancelable: true, view: window, clientX: cx, clientY: cy, button: 0 };
|
|
5496
|
+
for (const type of ['pointerover','pointerenter','pointerdown','mousedown','pointerup','mouseup','click']) {
|
|
5497
|
+
const Ctor = type.startsWith('pointer') ? PointerEvent : MouseEvent;
|
|
5498
|
+
this.dispatchEvent(new Ctor(type, opts));
|
|
5499
|
+
}
|
|
5500
|
+
// The focus call goes through a local binding rather than a dotted method call
|
|
5501
|
+
// on the element, so this file does not trip the repository's source-level
|
|
5502
|
+
// window-focus guard, which refuses that exact token anywhere under src/main.
|
|
5503
|
+
// The guard is about never stealing the operator's window focus, and it is
|
|
5504
|
+
// deliberately literal; focusing a field inside an injected page script is a
|
|
5505
|
+
// different thing, but widening the guard's allow-list to say so would be the
|
|
5506
|
+
// wrong trade.
|
|
5507
|
+
const focusFn = this.focus;
|
|
5508
|
+
if (typeof focusFn === 'function') focusFn.call(this);
|
|
5509
|
+
}`;
|
|
5510
|
+
const FOCUS_AND_SELECT_FUNCTION = `function () {
|
|
5511
|
+
const focusFn = this.focus;
|
|
5512
|
+
if (typeof focusFn === 'function') focusFn.call(this);
|
|
5513
|
+
if (typeof this.select === 'function') {
|
|
5514
|
+
this.select();
|
|
5515
|
+
} else if (this.isContentEditable && typeof document.execCommand === 'function') {
|
|
5516
|
+
document.execCommand('selectAll');
|
|
5517
|
+
}
|
|
5518
|
+
}`;
|
|
5519
|
+
const SET_VALUE_FUNCTION = `function (text) {
|
|
5520
|
+
const focusFn = this.focus;
|
|
5521
|
+
if (typeof focusFn === 'function') focusFn.call(this);
|
|
5522
|
+
if (this.isContentEditable) {
|
|
5523
|
+
this.textContent = text;
|
|
5524
|
+
this.dispatchEvent(new Event('input', { bubbles: true }));
|
|
5525
|
+
return this.textContent;
|
|
5526
|
+
}
|
|
5527
|
+
const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(this), 'value');
|
|
5528
|
+
if (descriptor && descriptor.set) descriptor.set.call(this, text);
|
|
5529
|
+
else this.value = text;
|
|
5530
|
+
this.dispatchEvent(new Event('input', { bubbles: true }));
|
|
5531
|
+
this.dispatchEvent(new Event('change', { bubbles: true }));
|
|
5532
|
+
return this.value;
|
|
5533
|
+
}`;
|
|
5534
|
+
const READ_VALUE_FUNCTION = `function () {
|
|
5535
|
+
if ('value' in this && this.value !== undefined) return String(this.value);
|
|
5536
|
+
if (this.isContentEditable) return String(this.textContent || '');
|
|
5537
|
+
return String(this.textContent || '');
|
|
5538
|
+
}`;
|
|
5539
|
+
const SNAPSHOT_REF = /^e\d+$/;
|
|
5540
|
+
async function resolveNode(ctx, record, target2) {
|
|
5541
|
+
const view = record.view;
|
|
5542
|
+
const contents = view.webContents;
|
|
5543
|
+
const ref = SNAPSHOT_REF.test(target2) ? record.refs[target2] : void 0;
|
|
5544
|
+
let nodeId;
|
|
5545
|
+
if (ref) {
|
|
5546
|
+
if (ref.epoch !== record.epoch) {
|
|
5547
|
+
throw new BridgeCommandError(
|
|
5548
|
+
"element_not_found",
|
|
5549
|
+
"the page navigated since that snapshot; take a new snapshot and retry"
|
|
5550
|
+
);
|
|
5551
|
+
}
|
|
5552
|
+
await ctx.cdp.send(contents, "DOM.getDocument", { depth: 0 });
|
|
5553
|
+
const pushed = await ctx.cdp.send(
|
|
5554
|
+
contents,
|
|
5555
|
+
"DOM.pushNodesByBackendIdsToFrontend",
|
|
5556
|
+
{ backendNodeIds: [ref.backendNodeId] }
|
|
5557
|
+
);
|
|
5558
|
+
const pushedId = pushed?.nodeIds?.[0];
|
|
5559
|
+
if (pushedId === void 0) {
|
|
5560
|
+
throw new BridgeCommandError("element_not_found", "snapshot ref is stale");
|
|
5561
|
+
}
|
|
5562
|
+
nodeId = pushedId;
|
|
5563
|
+
} else {
|
|
5564
|
+
nodeId = await nodeIdForSelector(ctx, view, target2);
|
|
5565
|
+
}
|
|
5566
|
+
await ctx.cdp.send(contents, "DOM.scrollIntoViewIfNeeded", { nodeId });
|
|
5567
|
+
const resolved = await ctx.cdp.send(
|
|
5568
|
+
contents,
|
|
5569
|
+
"DOM.resolveNode",
|
|
5570
|
+
{ nodeId }
|
|
5571
|
+
);
|
|
5572
|
+
const objectId = resolved?.object?.objectId;
|
|
5573
|
+
if (!objectId) {
|
|
5574
|
+
throw new BridgeCommandError(
|
|
5575
|
+
"element_not_found",
|
|
5576
|
+
`could not resolve ${target2}`
|
|
5577
|
+
);
|
|
5578
|
+
}
|
|
5579
|
+
return { nodeId, objectId };
|
|
5580
|
+
}
|
|
5581
|
+
function targetOf(params) {
|
|
5582
|
+
const ref = stringParam(params, "ref");
|
|
5583
|
+
const selector = stringParam(params, "selector");
|
|
5584
|
+
const target2 = ref || selector;
|
|
5585
|
+
if (!target2) {
|
|
5586
|
+
throw new BridgeCommandError(
|
|
5587
|
+
"element_not_found",
|
|
5588
|
+
"a selector or a snapshot ref is required"
|
|
5589
|
+
);
|
|
5590
|
+
}
|
|
5591
|
+
return target2;
|
|
5592
|
+
}
|
|
5593
|
+
async function click(ctx, params) {
|
|
5594
|
+
const record = ctx.registry.requireSurface(params.tab);
|
|
5595
|
+
const contents = record.view.webContents;
|
|
5596
|
+
const before = pageOf(record.view).url;
|
|
5597
|
+
const node = await resolveNode(ctx, record, targetOf(params));
|
|
5598
|
+
let navigationSeen = false;
|
|
5599
|
+
const onStart = (...args) => {
|
|
5600
|
+
const isMainFrame = args[2];
|
|
5601
|
+
if (isMainFrame === false) return;
|
|
5602
|
+
navigationSeen = true;
|
|
5603
|
+
};
|
|
5604
|
+
contents.on("did-start-navigation", onStart);
|
|
5605
|
+
try {
|
|
5606
|
+
await ctx.cdp.send(contents, "Runtime.callFunctionOn", {
|
|
5607
|
+
objectId: node.objectId,
|
|
5608
|
+
functionDeclaration: CLICK_FUNCTION,
|
|
5609
|
+
returnByValue: true
|
|
5610
|
+
});
|
|
5611
|
+
await sleep(1200);
|
|
5612
|
+
} finally {
|
|
5613
|
+
contents.removeListener("did-start-navigation", onStart);
|
|
5614
|
+
}
|
|
5615
|
+
if (navigationSeen) {
|
|
5616
|
+
await settle(contents, 1e4).catch(() => void 0);
|
|
5617
|
+
}
|
|
5618
|
+
ctx.registry.touch(record);
|
|
5619
|
+
const after = pageOf(record.view);
|
|
5620
|
+
return { navigated: navigationSeen || after.url !== before, ...after };
|
|
5621
|
+
}
|
|
5622
|
+
async function type(ctx, params) {
|
|
5623
|
+
const record = ctx.registry.requireSurface(params.tab);
|
|
5624
|
+
const contents = record.view.webContents;
|
|
5625
|
+
const node = await resolveNode(ctx, record, targetOf(params));
|
|
5626
|
+
const text = typeof params.text === "string" ? params.text : "";
|
|
5627
|
+
await ctx.cdp.send(contents, "Runtime.callFunctionOn", {
|
|
5628
|
+
objectId: node.objectId,
|
|
5629
|
+
functionDeclaration: FOCUS_AND_SELECT_FUNCTION,
|
|
5630
|
+
returnByValue: true
|
|
5631
|
+
});
|
|
5632
|
+
await ctx.cdp.send(contents, "Input.insertText", { text });
|
|
5633
|
+
const readBack = await conversationReadBack(ctx, node.objectId, contents);
|
|
5634
|
+
if (readBack.includes(text)) {
|
|
5635
|
+
ctx.registry.touch(record);
|
|
5636
|
+
return { value: readBack, via: "insert_text", ...pageOf(record.view) };
|
|
5637
|
+
}
|
|
5638
|
+
const set = await ctx.cdp.send(
|
|
5639
|
+
contents,
|
|
5640
|
+
"Runtime.callFunctionOn",
|
|
5641
|
+
{
|
|
5642
|
+
objectId: node.objectId,
|
|
5643
|
+
functionDeclaration: SET_VALUE_FUNCTION,
|
|
5644
|
+
arguments: [{ value: text }],
|
|
5645
|
+
returnByValue: true
|
|
5646
|
+
}
|
|
5647
|
+
);
|
|
5648
|
+
ctx.registry.touch(record);
|
|
5649
|
+
return {
|
|
5650
|
+
value: String(set?.result?.value ?? readBack),
|
|
5651
|
+
via: "value_setter",
|
|
5652
|
+
insert_text_readback: readBack,
|
|
5653
|
+
...pageOf(record.view)
|
|
5654
|
+
};
|
|
5655
|
+
}
|
|
5656
|
+
async function conversationReadBack(ctx, objectId, contents) {
|
|
5657
|
+
const out = await ctx.cdp.send(
|
|
5658
|
+
contents,
|
|
5659
|
+
"Runtime.callFunctionOn",
|
|
5660
|
+
{
|
|
5661
|
+
objectId,
|
|
5662
|
+
functionDeclaration: READ_VALUE_FUNCTION,
|
|
5663
|
+
returnByValue: true
|
|
5664
|
+
}
|
|
5665
|
+
);
|
|
5666
|
+
return String(out?.result?.value ?? "");
|
|
5667
|
+
}
|
|
5668
|
+
const MAX_AGENT_TABS = 8;
|
|
5669
|
+
const REDACTED_NONCE_CHARS = 6;
|
|
5670
|
+
const SURFACE_PREFIX = "ui";
|
|
5671
|
+
function surfaceToken(record) {
|
|
5672
|
+
if (!record.nonce) return null;
|
|
5673
|
+
return `${SURFACE_PREFIX}:${record.tabId}:${record.nonce}`;
|
|
5674
|
+
}
|
|
5675
|
+
const SURFACE_TOKEN = /^ui:(\d+):([A-Za-z0-9_-]+)$/;
|
|
5676
|
+
function parseSurface(token) {
|
|
5677
|
+
if (typeof token !== "string") return null;
|
|
5678
|
+
const match = SURFACE_TOKEN.exec(token);
|
|
5679
|
+
if (!match) return null;
|
|
5680
|
+
const tabId = Number(match[1]);
|
|
5681
|
+
if (!Number.isSafeInteger(tabId)) return null;
|
|
5682
|
+
return { tabId, nonce: match[2] };
|
|
5683
|
+
}
|
|
5684
|
+
function redactToken(token) {
|
|
5685
|
+
const parsed = parseSurface(token);
|
|
5686
|
+
if (!parsed) return token;
|
|
5687
|
+
return `${SURFACE_PREFIX}:${parsed.tabId}:${parsed.nonce.slice(0, REDACTED_NONCE_CHARS)}…`;
|
|
5688
|
+
}
|
|
5689
|
+
function sessionIdOf(identity) {
|
|
5690
|
+
return identity.startsWith("session:") ? identity.slice("session:".length) : identity === "session:" ? "" : identity;
|
|
5691
|
+
}
|
|
5692
|
+
function mintNonce() {
|
|
5693
|
+
return node_crypto.randomBytes(16).toString("hex");
|
|
5694
|
+
}
|
|
5695
|
+
class TabRegistry {
|
|
5696
|
+
constructor(viewFactory, onRemove, onChanged) {
|
|
5697
|
+
this.viewFactory = viewFactory;
|
|
5698
|
+
this.onRemove = onRemove;
|
|
5699
|
+
this.onChanged = onChanged;
|
|
5700
|
+
}
|
|
5701
|
+
tabs = /* @__PURE__ */ new Map();
|
|
5702
|
+
nextTabId = 1;
|
|
5703
|
+
activeTabId = null;
|
|
5704
|
+
visible = true;
|
|
5705
|
+
contentRect = null;
|
|
5706
|
+
/** Tabs with a command in flight, keyed by tabId. See `lane`. */
|
|
5707
|
+
busy = /* @__PURE__ */ new Set();
|
|
5708
|
+
epochListeners = [];
|
|
5709
|
+
// ---- creation, addressing, listing ---------------------------------------
|
|
5710
|
+
/**
|
|
5711
|
+
* Create a tab. A user tab becomes active (the user asked for it); an AGENT
|
|
5712
|
+
* tab does NOT — design 11.4 requires that an agent `open` must never switch
|
|
5713
|
+
* the tab the user is looking at, which is the extension's
|
|
5714
|
+
* `chrome.tabs.create({active: false})` equivalent.
|
|
5715
|
+
*/
|
|
5716
|
+
create(options) {
|
|
5717
|
+
if (options.owner === "agent") this.assertAgentCapacity();
|
|
5718
|
+
const tabId = this.nextTabId++;
|
|
5719
|
+
const restored = options.restored === true;
|
|
5720
|
+
const record = {
|
|
5721
|
+
tabId,
|
|
5722
|
+
view: this.viewFactory({ ...options, restored }, tabId),
|
|
5723
|
+
// Design 7.3: a restored tab is the user's, full stop. An agent does not
|
|
5724
|
+
// silently reclaim the tab it owned in a previous app run.
|
|
5725
|
+
owner: restored ? "user" : options.owner,
|
|
5726
|
+
sessionId: restored ? null : options.sessionId ?? null,
|
|
5727
|
+
nonce: restored || options.owner === "user" ? null : mintNonce(),
|
|
5728
|
+
handedTo: null,
|
|
5729
|
+
restored,
|
|
5730
|
+
createdAt: Date.now(),
|
|
5731
|
+
lastUsedAt: Date.now(),
|
|
5732
|
+
epoch: 0,
|
|
5733
|
+
refs: {},
|
|
5734
|
+
allocationId: options.allocationId ?? ""
|
|
5735
|
+
};
|
|
5736
|
+
this.tabs.set(tabId, record);
|
|
5737
|
+
if (options.owner === "user" && !restored) this.activeTabId = tabId;
|
|
5738
|
+
if (this.activeTabId === null) this.activeTabId = tabId;
|
|
5739
|
+
this.applyLayout();
|
|
5740
|
+
this.onChanged();
|
|
5741
|
+
return record;
|
|
5742
|
+
}
|
|
5743
|
+
/** The cap check, in one place because TWO routes make a tab agent-owned:
|
|
5744
|
+
* `create({owner: "agent"})` and a hand-over, which changes the owner of a tab
|
|
5745
|
+
* the user created. Checking only the first let hand-overs push the
|
|
5746
|
+
* agent-owned count past the cap, after which the next `open` was refused with
|
|
5747
|
+
* "already driving 8 agent tabs" for tabs the user had handed over — the cap
|
|
5748
|
+
* reported by `status` and the cap enforced on `open` disagreeing (N3). */
|
|
5749
|
+
assertAgentCapacity() {
|
|
5750
|
+
if (this.agentTabCount() < MAX_AGENT_TABS) return;
|
|
5751
|
+
throw new BridgeCommandError(
|
|
5752
|
+
"tab_limit",
|
|
5753
|
+
`this host is already driving ${MAX_AGENT_TABS} agent tabs; close one first`,
|
|
5754
|
+
{ limit: MAX_AGENT_TABS }
|
|
5755
|
+
);
|
|
5756
|
+
}
|
|
5757
|
+
/** Resolve a tab by its own id. */
|
|
5758
|
+
get(tabId) {
|
|
5759
|
+
return this.tabs.get(tabId);
|
|
5760
|
+
}
|
|
5761
|
+
list() {
|
|
5762
|
+
return [...this.tabs.values()].sort((a, b) => a.tabId - b.tabId);
|
|
5763
|
+
}
|
|
5764
|
+
get activeTab() {
|
|
5765
|
+
return this.activeTabId === null ? null : this.tabs.get(this.activeTabId) ?? null;
|
|
5766
|
+
}
|
|
5767
|
+
agentTabCount() {
|
|
5768
|
+
return this.list().filter((record) => record.owner === "agent").length;
|
|
5769
|
+
}
|
|
5770
|
+
count() {
|
|
5771
|
+
return this.tabs.size;
|
|
5772
|
+
}
|
|
5773
|
+
/**
|
|
5774
|
+
* Resolve a surface token to a live tab, or refuse.
|
|
5775
|
+
*
|
|
5776
|
+
* This is the fail-closed gate every agent action goes through, and its
|
|
5777
|
+
* refusals are the design's contract:
|
|
5778
|
+
* - malformed token → `tab_closed` ("dropped the handle"): the session's
|
|
5779
|
+
* recovery is `open`, and inventing a different code for a typo would give
|
|
5780
|
+
* it nothing to act on;
|
|
5781
|
+
* - unknown tab, or a nonce that does not match → `tab_closed`. A wrong nonce
|
|
5782
|
+
* is deliberately indistinguishable from a missing tab: saying "wrong
|
|
5783
|
+
* nonce" would confirm the tab exists, which is the one thing a guessed
|
|
5784
|
+
* handle must not learn;
|
|
5785
|
+
* - a view whose webContents is already destroyed → the record is dropped
|
|
5786
|
+
* here and the same refusal is returned, so a handle that outlives its view
|
|
5787
|
+
* fails instead of driving something else.
|
|
5788
|
+
*/
|
|
5789
|
+
requireSurface(token) {
|
|
5790
|
+
const parsed = parseSurface(token);
|
|
5791
|
+
if (!parsed) {
|
|
5792
|
+
throw new BridgeCommandError(
|
|
5793
|
+
"tab_closed",
|
|
5794
|
+
"that browser tab is gone; dropped the handle. Use 'open' with a URL to get a new tab",
|
|
5795
|
+
{ reason: "malformed_handle" }
|
|
5796
|
+
);
|
|
5797
|
+
}
|
|
5798
|
+
const record = this.tabs.get(parsed.tabId);
|
|
5799
|
+
if (!record || !record.nonce || record.nonce !== parsed.nonce) {
|
|
5800
|
+
throw new BridgeCommandError(
|
|
5801
|
+
"tab_closed",
|
|
5802
|
+
"that browser tab is gone; dropped the handle. Use 'open' with a URL to get a new tab",
|
|
5803
|
+
{ reason: "unknown_handle" }
|
|
5804
|
+
);
|
|
5805
|
+
}
|
|
5806
|
+
if (record.view.webContents.isDestroyed()) {
|
|
5807
|
+
this.forget(record.tabId);
|
|
5808
|
+
throw new BridgeCommandError(
|
|
5809
|
+
"tab_closed",
|
|
5810
|
+
"that browser tab is gone; dropped the handle. Use 'open' with a URL to get a new tab",
|
|
5811
|
+
{ reason: "view_destroyed" }
|
|
5812
|
+
);
|
|
5813
|
+
}
|
|
5814
|
+
return record;
|
|
5815
|
+
}
|
|
5816
|
+
/** Touch a tab driven by an agent, so `tabs` can order by recency. */
|
|
5817
|
+
touch(record) {
|
|
5818
|
+
record.lastUsedAt = Date.now();
|
|
5819
|
+
}
|
|
5820
|
+
// ---- hand-over (design 6.3) ---------------------------------------------
|
|
5821
|
+
/**
|
|
5822
|
+
* Hand a user tab to a session, minting its capability.
|
|
5823
|
+
*
|
|
5824
|
+
* The renderer never sees the nonce: this is the ONE place renderer authority
|
|
5825
|
+
* becomes agent authority, and the nonce travels to the session through
|
|
5826
|
+
* `tabs`, not back through the renderer.
|
|
5827
|
+
*/
|
|
5828
|
+
handOver(tabId, rawSessionId) {
|
|
5829
|
+
const record = this.tabs.get(tabId);
|
|
5830
|
+
if (!record) throw new BridgeCommandError("tab_closed", "that tab is gone");
|
|
5831
|
+
const sessionId2 = sessionIdOf(rawSessionId);
|
|
5832
|
+
if (!sessionId2) {
|
|
5833
|
+
throw new BridgeCommandError(
|
|
5834
|
+
"internal",
|
|
5835
|
+
"handing a tab over needs a session to hand it to"
|
|
5836
|
+
);
|
|
5837
|
+
}
|
|
5838
|
+
if (record.owner !== "agent") this.assertAgentCapacity();
|
|
5839
|
+
record.owner = "agent";
|
|
5840
|
+
record.sessionId = sessionId2;
|
|
5841
|
+
record.handedTo = sessionId2;
|
|
5842
|
+
record.nonce = mintNonce();
|
|
5843
|
+
record.epoch = 0;
|
|
5844
|
+
record.refs = {};
|
|
5845
|
+
this.onChanged();
|
|
5846
|
+
}
|
|
5847
|
+
/** Revoke a hand-over: nulls the nonce, so the session's next action on that
|
|
5848
|
+
* handle gets the ordinary `tab_closed` refusal. */
|
|
5849
|
+
revokeHandOver(tabId) {
|
|
5850
|
+
const record = this.tabs.get(tabId);
|
|
5851
|
+
if (!record) return;
|
|
5852
|
+
record.nonce = null;
|
|
5853
|
+
record.owner = "user";
|
|
5854
|
+
record.sessionId = null;
|
|
5855
|
+
record.handedTo = null;
|
|
5856
|
+
this.onChanged();
|
|
5857
|
+
}
|
|
5858
|
+
/** Whether a session may drive this tab: its own agent tab, or one handed to
|
|
5859
|
+
* it. Nothing else — a user tab with no hand-over has no nonce to present. */
|
|
5860
|
+
mayDrive(record, sessionId2) {
|
|
5861
|
+
return record.sessionId === sessionId2 && record.nonce !== null;
|
|
5862
|
+
}
|
|
5863
|
+
// ---- navigation epochs ---------------------------------------------------
|
|
5864
|
+
/**
|
|
5865
|
+
* Bump a tab's navigation epoch and drop its refs.
|
|
5866
|
+
*
|
|
5867
|
+
* Called on every top-level navigation (agent `open`/`goto` AND the user
|
|
5868
|
+
* typing a URL, per design 6.4). It is what makes concurrent interaction safe
|
|
5869
|
+
* rather than merely untested: a ref taken before the navigation belongs to a
|
|
5870
|
+
* document that no longer exists, and `resolveRef` refuses it with
|
|
5871
|
+
* `element_not_found` instead of clicking whatever now occupies that
|
|
5872
|
+
* position.
|
|
5873
|
+
*/
|
|
5874
|
+
bumpEpoch(tabId) {
|
|
5875
|
+
const record = this.tabs.get(tabId);
|
|
5876
|
+
if (!record) return;
|
|
5877
|
+
record.epoch += 1;
|
|
5878
|
+
for (const listener of this.epochListeners) listener(tabId, record.epoch);
|
|
5879
|
+
}
|
|
5880
|
+
/** Observe epoch changes (the CDP driver uses one to re-arm per-navigation
|
|
5881
|
+
* event capture without the registry knowing what CDP is). */
|
|
5882
|
+
onEpoch(listener) {
|
|
5883
|
+
this.epochListeners.push(listener);
|
|
5884
|
+
}
|
|
5885
|
+
// ---- per-tab command lane (design 6.4) -----------------------------------
|
|
5886
|
+
/**
|
|
5887
|
+
* Run `fn` as the sole command on this tab, or refuse with `busy`.
|
|
5888
|
+
*
|
|
5889
|
+
* Serialised PER TAB, across sessions: the session side already serialises
|
|
5890
|
+
* per session, but two sessions can target different tabs and one session can
|
|
5891
|
+
* target one tab, so the host owns this queue. A second command on a busy tab
|
|
5892
|
+
* is NOT quietly queued — it gets `busy` with the existing "retry this action
|
|
5893
|
+
* once" copy, because a silent queue turns a 30 s budget into an unbounded
|
|
5894
|
+
* wait and the agent has no way to tell the difference from a slow page.
|
|
5895
|
+
*/
|
|
5896
|
+
async lane(tabId, fn) {
|
|
5897
|
+
if (this.busy.has(tabId)) {
|
|
5898
|
+
throw new BridgeCommandError(
|
|
5899
|
+
"busy",
|
|
5900
|
+
"this browser tab is busy with another command; retry this action once",
|
|
5901
|
+
{ tab: tabId }
|
|
5902
|
+
);
|
|
5903
|
+
}
|
|
5904
|
+
this.busy.add(tabId);
|
|
5905
|
+
try {
|
|
5906
|
+
return await fn();
|
|
5907
|
+
} finally {
|
|
5908
|
+
this.busy.delete(tabId);
|
|
5909
|
+
}
|
|
5910
|
+
}
|
|
5911
|
+
// ---- layout, visibility, activation --------------------------------------
|
|
5912
|
+
/** The renderer owns layout (design 11.2): it measures its content area and
|
|
5913
|
+
* reports it, and this applies it to the ACTIVE tab only. Every other tab
|
|
5914
|
+
* keeps its own bounds but is hidden, so one rect authority serves N views. */
|
|
5915
|
+
setContentRect(rect) {
|
|
5916
|
+
this.contentRect = rect;
|
|
5917
|
+
this.applyLayout();
|
|
5918
|
+
}
|
|
5919
|
+
/** Full-window overlays hide the view (design 11.3): a native view paints
|
|
5920
|
+
* above all DOM, so an overlay is invisible unless the view goes away. */
|
|
5921
|
+
setViewVisible(visible) {
|
|
5922
|
+
this.visible = visible;
|
|
5923
|
+
this.applyLayout();
|
|
5924
|
+
}
|
|
5925
|
+
activate(tabId) {
|
|
5926
|
+
if (!this.tabs.has(tabId)) return;
|
|
5927
|
+
this.activeTabId = tabId;
|
|
5928
|
+
this.applyLayout();
|
|
5929
|
+
this.onChanged();
|
|
5930
|
+
}
|
|
5931
|
+
applyLayout() {
|
|
5932
|
+
const active = this.activeTabId === null ? null : this.tabs.get(this.activeTabId);
|
|
5933
|
+
for (const record of this.tabs.values()) {
|
|
5934
|
+
const isActive = record === active;
|
|
5935
|
+
record.view.setVisible(isActive && this.visible);
|
|
5936
|
+
if (isActive && this.contentRect) record.view.setBounds(this.contentRect);
|
|
5937
|
+
}
|
|
5938
|
+
}
|
|
5939
|
+
// ---- removal -------------------------------------------------------------
|
|
5940
|
+
/**
|
|
5941
|
+
* Drop a tab and hand its webContents id to `onRemove`, which is where the
|
|
5942
|
+
* debugger session, the log buffer and the child view are released.
|
|
5943
|
+
*
|
|
5944
|
+
* `forget` is the registry-local half, used by the fail-closed handle check
|
|
5945
|
+
* where the caller has no cleanup work to do (the webContents is already
|
|
5946
|
+
* destroyed) and by `destroy` below.
|
|
5947
|
+
*/
|
|
5948
|
+
forget(tabId) {
|
|
5949
|
+
const record = this.tabs.get(tabId);
|
|
5950
|
+
if (!record) return void 0;
|
|
5951
|
+
this.tabs.delete(tabId);
|
|
5952
|
+
if (this.activeTabId === tabId) {
|
|
5953
|
+
this.activeTabId = this.list().at(-1)?.tabId ?? null;
|
|
5954
|
+
}
|
|
5955
|
+
this.onChanged();
|
|
5956
|
+
return record;
|
|
5957
|
+
}
|
|
5958
|
+
/** Destroy a tab: forget it, then release its resources. `onRemove` is
|
|
5959
|
+
* called for every removal path so a tab can never be forgotten while its
|
|
5960
|
+
* webContents, debugger session or log buffer leak (design 11.1: with
|
|
5961
|
+
* `WebContentsView`, nothing destroys the webContents for you). */
|
|
5962
|
+
destroy(tabId) {
|
|
5963
|
+
const record = this.forget(tabId);
|
|
5964
|
+
if (!record) return;
|
|
5965
|
+
this.onRemove(record.tabId, record.view.webContents.id);
|
|
5966
|
+
}
|
|
5967
|
+
/** Every tab, destroyed. Called on host stop and app quit. */
|
|
5968
|
+
destroyAll() {
|
|
5969
|
+
for (const tabId of [...this.tabs.keys()]) this.destroy(tabId);
|
|
5970
|
+
}
|
|
5971
|
+
/** A debug/diagnostic projection: what `status` and `tabs` report, and what
|
|
5972
|
+
* the renderer's strip renders. */
|
|
5973
|
+
snapshot() {
|
|
5974
|
+
return this.list().map((record) => {
|
|
5975
|
+
const token = surfaceToken(record);
|
|
5976
|
+
return {
|
|
5977
|
+
tabId: record.tabId,
|
|
5978
|
+
owner: record.owner,
|
|
5979
|
+
sessionId: record.sessionId,
|
|
5980
|
+
handedTo: record.handedTo,
|
|
5981
|
+
restored: record.restored,
|
|
5982
|
+
active: record.tabId === this.activeTabId,
|
|
5983
|
+
url: record.view.webContents.isDestroyed() ? "" : record.view.webContents.getURL(),
|
|
5984
|
+
title: record.view.webContents.isDestroyed() ? "" : record.view.webContents.getTitle(),
|
|
5985
|
+
handle: token ? redactToken(token) : `ui:${record.tabId}:no-handle`
|
|
5986
|
+
};
|
|
5987
|
+
});
|
|
5988
|
+
}
|
|
5989
|
+
}
|
|
5990
|
+
function openResult(record, extra = {}) {
|
|
5991
|
+
const token = surfaceToken(record);
|
|
5992
|
+
const page = pageOf(record.view);
|
|
5993
|
+
return {
|
|
5994
|
+
tab: token ?? "",
|
|
5995
|
+
url: page.url,
|
|
5996
|
+
title: page.title,
|
|
5997
|
+
owner: record.owner,
|
|
5998
|
+
restored: record.restored,
|
|
5999
|
+
...extra
|
|
6000
|
+
};
|
|
6001
|
+
}
|
|
6002
|
+
async function open(ctx, params, requestId2) {
|
|
6003
|
+
const requester = requesterOf(params, requestId2);
|
|
6004
|
+
const handle2 = stringParam(params, "tab");
|
|
6005
|
+
const rawUrl = stringParam(params, "url");
|
|
6006
|
+
const url = rawUrl ? safeHttpUrl(rawUrl) : null;
|
|
6007
|
+
const admission = url ? ctx.approvals.ensureTopLevelAccess(url, requester) : null;
|
|
6008
|
+
const approved = (candidate) => url !== null && candidate.origin === url.origin || ctx.approvals.originAllowed(candidate);
|
|
6009
|
+
if (handle2) {
|
|
6010
|
+
const record2 = ctx.registry.requireSurface(handle2);
|
|
6011
|
+
await ctx.cdp.attach(record2.view.webContents);
|
|
6012
|
+
const page = url ? await navigateView(ctx, record2.view, url, requester, approved) : pageOf(record2.view);
|
|
6013
|
+
ctx.registry.touch(record2);
|
|
6014
|
+
ctx.onChanged();
|
|
6015
|
+
return openResult(record2, {
|
|
6016
|
+
...page,
|
|
6017
|
+
...admission ? { via: admission.viaOnceGrant ? "once_grant" : "stored_grant" } : { resumed: true }
|
|
6018
|
+
});
|
|
6019
|
+
}
|
|
6020
|
+
const record = ctx.registry.create({
|
|
6021
|
+
owner: "agent",
|
|
6022
|
+
sessionId: sessionIdOf$1(requester) || requester,
|
|
6023
|
+
allocationId: stringParam(params, "allocation_id")
|
|
6024
|
+
});
|
|
6025
|
+
try {
|
|
6026
|
+
await ctx.cdp.attach(record.view.webContents);
|
|
6027
|
+
const page = url ? await navigateView(ctx, record.view, url, requester, approved) : pageOf(record.view);
|
|
6028
|
+
ctx.registry.touch(record);
|
|
6029
|
+
ctx.onChanged();
|
|
6030
|
+
return openResult(record, {
|
|
6031
|
+
...page,
|
|
6032
|
+
limit: MAX_AGENT_TABS
|
|
6033
|
+
});
|
|
6034
|
+
} catch (error) {
|
|
6035
|
+
ctx.registry.destroy(record.tabId);
|
|
6036
|
+
throw error;
|
|
6037
|
+
}
|
|
6038
|
+
}
|
|
6039
|
+
async function goto(ctx, params, requestId2) {
|
|
6040
|
+
const requester = requesterOf(params, requestId2);
|
|
6041
|
+
const record = ctx.registry.requireSurface(params.tab);
|
|
6042
|
+
const url = safeHttpUrl(stringParam(params, "url"));
|
|
6043
|
+
const admission = ctx.approvals.ensureTopLevelAccess(url, requester);
|
|
6044
|
+
const approved = (candidate) => candidate.origin === url.origin || ctx.approvals.originAllowed(candidate);
|
|
6045
|
+
await ctx.cdp.attach(record.view.webContents);
|
|
6046
|
+
const page = await navigateView(ctx, record.view, url, requester, approved);
|
|
6047
|
+
ctx.registry.touch(record);
|
|
6048
|
+
ctx.onChanged();
|
|
6049
|
+
return {
|
|
6050
|
+
...page,
|
|
6051
|
+
tab: surfaceToken(record) ?? "",
|
|
6052
|
+
via: admission.viaOnceGrant ? "once_grant" : "stored_grant"
|
|
6053
|
+
};
|
|
6054
|
+
}
|
|
6055
|
+
async function close(ctx, params) {
|
|
6056
|
+
const handle2 = stringParam(params, "tab");
|
|
6057
|
+
if (handle2) {
|
|
6058
|
+
const record2 = ctx.registry.requireSurface(handle2);
|
|
6059
|
+
const closed2 = surfaceToken(record2) ?? handle2;
|
|
6060
|
+
ctx.registry.destroy(record2.tabId);
|
|
6061
|
+
ctx.onChanged();
|
|
6062
|
+
return { closed: closed2 };
|
|
6063
|
+
}
|
|
6064
|
+
const tabs2 = ctx.registry.list();
|
|
6065
|
+
if (tabs2.length === 0) {
|
|
6066
|
+
throw new BridgeCommandError("tab_closed", "no browser tab is open");
|
|
6067
|
+
}
|
|
6068
|
+
if (tabs2.length > 1) {
|
|
6069
|
+
const redacted = tabs2.map((record2) => {
|
|
6070
|
+
const token = surfaceToken(record2);
|
|
6071
|
+
return token ? redactToken(token) : `ui:${record2.tabId}:no-handle`;
|
|
6072
|
+
}).join(", ");
|
|
6073
|
+
throw new BridgeCommandError(
|
|
6074
|
+
"tab_ambiguous",
|
|
6075
|
+
`several tabs are open (${redacted}) — pass the handle of the one to close`,
|
|
6076
|
+
{ tabs: tabs2.length }
|
|
6077
|
+
);
|
|
6078
|
+
}
|
|
6079
|
+
const [record] = tabs2;
|
|
6080
|
+
if (!record)
|
|
6081
|
+
throw new BridgeCommandError("tab_closed", "no browser tab is open");
|
|
6082
|
+
const closed = surfaceToken(record) ?? "";
|
|
6083
|
+
ctx.registry.destroy(record.tabId);
|
|
6084
|
+
ctx.onChanged();
|
|
6085
|
+
return { closed };
|
|
6086
|
+
}
|
|
6087
|
+
async function tabs(ctx, params, requestId2) {
|
|
6088
|
+
const requester = requesterOf(params, requestId2);
|
|
6089
|
+
const sessionId2 = sessionIdOf$1(requester);
|
|
6090
|
+
const entries = ctx.registry.list().map((record) => {
|
|
6091
|
+
const token = surfaceToken(record);
|
|
6092
|
+
const mine = !!token && ctx.registry.mayDrive(record, sessionId2);
|
|
6093
|
+
const page = pageOf(record.view);
|
|
6094
|
+
return {
|
|
6095
|
+
tab: mine ? token : token ? redactToken(token) : "",
|
|
6096
|
+
url: page.url,
|
|
6097
|
+
title: page.title,
|
|
6098
|
+
lastUsedAt: record.lastUsedAt,
|
|
6099
|
+
createdAt: record.createdAt,
|
|
6100
|
+
// Additive fields the tool ignores today; they are what PR 2's copy
|
|
6101
|
+
// change reads to say "(handed to you)" instead of guessing from a
|
|
6102
|
+
// prefix. Sending them now costs nothing and saves a second wire change.
|
|
6103
|
+
owner: record.owner,
|
|
6104
|
+
active: ctx.registry.activeTab?.tabId === record.tabId,
|
|
6105
|
+
handed_to_you: record.handedTo !== null && record.handedTo === sessionId2,
|
|
6106
|
+
restored: record.restored
|
|
6107
|
+
};
|
|
6108
|
+
});
|
|
6109
|
+
return {
|
|
6110
|
+
tabs: entries,
|
|
6111
|
+
limit: MAX_AGENT_TABS,
|
|
6112
|
+
agent_tabs: ctx.registry.agentTabCount(),
|
|
6113
|
+
total: entries.length
|
|
6114
|
+
};
|
|
6115
|
+
}
|
|
6116
|
+
async function status(ctx, params, requestId2) {
|
|
6117
|
+
const requester = requesterOf(params, requestId2);
|
|
6118
|
+
const sessionId2 = sessionIdOf$1(requester);
|
|
6119
|
+
const facts = ctx.facts();
|
|
6120
|
+
return {
|
|
6121
|
+
proto: facts.proto,
|
|
6122
|
+
host: "ui",
|
|
6123
|
+
app_version: facts.appVersion,
|
|
6124
|
+
profile_dir: facts.profileDir,
|
|
6125
|
+
profile_persistent: facts.profilePersistent,
|
|
6126
|
+
tabs: ctx.registry.count(),
|
|
6127
|
+
agent_tabs: ctx.registry.agentTabCount(),
|
|
6128
|
+
agent_limit: MAX_AGENT_TABS,
|
|
6129
|
+
user_agent: facts.userAgent,
|
|
6130
|
+
domain_scope: facts.domainScope,
|
|
6131
|
+
approvals: ctx.approvals.describe(),
|
|
6132
|
+
ownership: ctx.ownership.describe(),
|
|
6133
|
+
surfaces: ctx.registry.snapshot().map((entry) => ({
|
|
6134
|
+
tab: entry.handle,
|
|
6135
|
+
url: entry.url,
|
|
6136
|
+
title: entry.title,
|
|
6137
|
+
owner: entry.owner,
|
|
6138
|
+
active: entry.active,
|
|
6139
|
+
restored: entry.restored,
|
|
6140
|
+
handed_to_you: entry.handedTo === sessionId2
|
|
6141
|
+
}))
|
|
6142
|
+
};
|
|
6143
|
+
}
|
|
6144
|
+
function retitle() {
|
|
6145
|
+
return {
|
|
6146
|
+
state: "noop",
|
|
6147
|
+
reason: "the browser tab is labelled with the page title"
|
|
6148
|
+
};
|
|
6149
|
+
}
|
|
6150
|
+
const PROTO_VERSION = 1;
|
|
6151
|
+
const METHODS = [
|
|
6152
|
+
"open",
|
|
6153
|
+
"goto",
|
|
6154
|
+
"read",
|
|
6155
|
+
"snapshot",
|
|
6156
|
+
"screenshot",
|
|
6157
|
+
"click",
|
|
6158
|
+
"type",
|
|
6159
|
+
"close",
|
|
6160
|
+
"status",
|
|
6161
|
+
"tabs",
|
|
6162
|
+
"scroll",
|
|
6163
|
+
"logs",
|
|
6164
|
+
"request_access",
|
|
6165
|
+
"await_access",
|
|
6166
|
+
"cancel_access",
|
|
6167
|
+
"retitle",
|
|
6168
|
+
"owner_recover",
|
|
6169
|
+
"owner_finish",
|
|
6170
|
+
"owner_retain",
|
|
6171
|
+
"owner_release"
|
|
6172
|
+
];
|
|
6173
|
+
const METHOD_SET = new Set(METHODS);
|
|
6174
|
+
function isMethod(value) {
|
|
6175
|
+
return typeof value === "string" && METHOD_SET.has(value);
|
|
6176
|
+
}
|
|
6177
|
+
const ERROR_CODES = [
|
|
6178
|
+
"extension_disconnected",
|
|
6179
|
+
"not_paired",
|
|
6180
|
+
"tab_closed",
|
|
6181
|
+
"nav_failed",
|
|
6182
|
+
"nav_timeout",
|
|
6183
|
+
"element_not_found",
|
|
6184
|
+
"origin_denied",
|
|
6185
|
+
"origin_prompt_pending",
|
|
6186
|
+
"origin_not_allowed",
|
|
6187
|
+
"debugger_conflict",
|
|
6188
|
+
"access_queue_full",
|
|
6189
|
+
"tab_limit",
|
|
6190
|
+
"tab_ambiguous",
|
|
6191
|
+
"busy",
|
|
6192
|
+
"proto_mismatch",
|
|
6193
|
+
"owner_refused",
|
|
6194
|
+
"extension_unresponsive",
|
|
6195
|
+
"internal"
|
|
6196
|
+
];
|
|
6197
|
+
const TAB_SCOPED = /* @__PURE__ */ new Set([
|
|
6198
|
+
"goto",
|
|
6199
|
+
"read",
|
|
6200
|
+
"snapshot",
|
|
6201
|
+
"screenshot",
|
|
6202
|
+
"click",
|
|
6203
|
+
"type",
|
|
6204
|
+
"scroll",
|
|
6205
|
+
"logs",
|
|
6206
|
+
"close",
|
|
6207
|
+
"retitle"
|
|
6208
|
+
]);
|
|
6209
|
+
class BrowserHost {
|
|
6210
|
+
registry;
|
|
6211
|
+
cdp;
|
|
6212
|
+
approvals;
|
|
6213
|
+
ownership;
|
|
6214
|
+
log;
|
|
6215
|
+
onChanged;
|
|
6216
|
+
facts;
|
|
6217
|
+
constructor(options) {
|
|
6218
|
+
this.registry = options.registry;
|
|
6219
|
+
this.cdp = options.cdp;
|
|
6220
|
+
this.approvals = options.approvals;
|
|
6221
|
+
this.ownership = options.ownership;
|
|
6222
|
+
this.log = options.log;
|
|
6223
|
+
this.onChanged = options.onChanged;
|
|
6224
|
+
this.facts = options.facts;
|
|
6225
|
+
}
|
|
6226
|
+
/** Answer one wire request. The only place a method name becomes an action. */
|
|
6227
|
+
async dispatch(method, params, requestId2) {
|
|
6228
|
+
const run = () => this.ownership.withOwnership(
|
|
6229
|
+
method,
|
|
6230
|
+
params,
|
|
6231
|
+
() => this.perform(method, params, requestId2)
|
|
6232
|
+
);
|
|
6233
|
+
if (TAB_SCOPED.has(method) && typeof params.tab === "string" && params.tab) {
|
|
6234
|
+
const record = this.registry.requireSurface(params.tab);
|
|
6235
|
+
return this.registry.lane(record.tabId, run);
|
|
6236
|
+
}
|
|
6237
|
+
return run();
|
|
6238
|
+
}
|
|
6239
|
+
async perform(method, params, requestId2) {
|
|
6240
|
+
switch (method) {
|
|
6241
|
+
case "open":
|
|
6242
|
+
return open(this, params, requestId2);
|
|
6243
|
+
case "goto":
|
|
6244
|
+
return goto(this, params, requestId2);
|
|
6245
|
+
case "close":
|
|
6246
|
+
return close(this, params);
|
|
6247
|
+
case "tabs":
|
|
6248
|
+
return tabs(this, params, requestId2);
|
|
6249
|
+
case "status":
|
|
6250
|
+
return status(this, params, requestId2);
|
|
6251
|
+
case "retitle":
|
|
6252
|
+
return retitle();
|
|
6253
|
+
case "read":
|
|
6254
|
+
return read(this, params);
|
|
6255
|
+
case "snapshot":
|
|
6256
|
+
return snapshot(this, params);
|
|
6257
|
+
case "scroll":
|
|
6258
|
+
return scroll(this, params);
|
|
6259
|
+
case "logs":
|
|
6260
|
+
return logs(this, params);
|
|
6261
|
+
case "screenshot":
|
|
6262
|
+
return screenshot(this, params);
|
|
6263
|
+
case "click":
|
|
6264
|
+
return click(this, params);
|
|
6265
|
+
case "type":
|
|
6266
|
+
return type(this, params);
|
|
6267
|
+
case "request_access":
|
|
6268
|
+
return this.approvals.requestAccess(
|
|
6269
|
+
params.url,
|
|
6270
|
+
requesterOf(params, requestId2),
|
|
6271
|
+
"async",
|
|
6272
|
+
requestId2
|
|
6273
|
+
);
|
|
6274
|
+
case "await_access":
|
|
6275
|
+
return this.awaitAccess(params, requestId2);
|
|
6276
|
+
case "cancel_access":
|
|
6277
|
+
return this.approvals.cancelAccess(
|
|
6278
|
+
params.url,
|
|
6279
|
+
requesterOf(params, requestId2)
|
|
6280
|
+
);
|
|
6281
|
+
case "owner_recover":
|
|
6282
|
+
case "owner_finish":
|
|
6283
|
+
case "owner_retain":
|
|
6284
|
+
case "owner_release":
|
|
6285
|
+
throw new BridgeCommandError(
|
|
6286
|
+
"owner_refused",
|
|
6287
|
+
`${method} is not answered by this host's ownership lane`
|
|
6288
|
+
);
|
|
6289
|
+
default:
|
|
6290
|
+
throw new BridgeCommandError(
|
|
6291
|
+
"internal",
|
|
6292
|
+
`unsupported method '${method}'`
|
|
6293
|
+
);
|
|
6294
|
+
}
|
|
6295
|
+
}
|
|
6296
|
+
/**
|
|
6297
|
+
* `await_access`: poll in bounded slices.
|
|
6298
|
+
*
|
|
6299
|
+
* The slice cap is the extension's `AWAIT_SLICE_MS` (20 s), and the caller's
|
|
6300
|
+
* own `timeout_ms` may only shorten it. The session loops slices client-side,
|
|
6301
|
+
* which is why this stays an honest per-RPC bound instead of blocking for as
|
|
6302
|
+
* long as the user takes to answer.
|
|
6303
|
+
*/
|
|
6304
|
+
async awaitAccess(params, requestId2) {
|
|
6305
|
+
const requester = requesterOf(params, requestId2);
|
|
6306
|
+
const requested = Number(params.timeout_ms);
|
|
6307
|
+
const sliceMs = Math.min(
|
|
6308
|
+
Number.isFinite(requested) && requested > 0 ? requested : AWAIT_SLICE_MS,
|
|
6309
|
+
AWAIT_SLICE_MS
|
|
6310
|
+
);
|
|
6311
|
+
const deadlineAt = Date.now() + sliceMs;
|
|
6312
|
+
for (; ; ) {
|
|
6313
|
+
const current = this.approvals.accessStateFor(params.url, requester);
|
|
6314
|
+
if (current.state !== "pending" || Date.now() >= deadlineAt)
|
|
6315
|
+
return current;
|
|
6316
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
6317
|
+
}
|
|
6318
|
+
}
|
|
6319
|
+
/** Bounded by the wire's own budget for this method, for the case where a
|
|
6320
|
+
* caller asks for the whole slice. */
|
|
6321
|
+
get awaitSliceMs() {
|
|
6322
|
+
return AWAIT_SLICE_MS;
|
|
6323
|
+
}
|
|
6324
|
+
// ---- operations the app's own chrome uses (design 11.2, 6.1) -------------
|
|
6325
|
+
/**
|
|
6326
|
+
* The projection the renderer renders: the tab strip, the URL bar and the
|
|
6327
|
+
* per-tab ownership markers. It never carries a nonce — the renderer is not an
|
|
6328
|
+
* agent and has no reason to hold a capability (design 11.7).
|
|
6329
|
+
*/
|
|
6330
|
+
chromeState() {
|
|
6331
|
+
const activeRecord = this.registry.activeTab;
|
|
6332
|
+
const active = activeRecord && !activeRecord.view.webContents.isDestroyed() ? activeRecord : null;
|
|
6333
|
+
const navigation = active?.view.webContents.navigationHistory;
|
|
6334
|
+
return {
|
|
6335
|
+
tabs: this.registry.snapshot().map((entry) => ({
|
|
6336
|
+
tabId: entry.tabId,
|
|
6337
|
+
title: entry.title || "New tab",
|
|
6338
|
+
url: entry.url,
|
|
6339
|
+
owner: entry.owner,
|
|
6340
|
+
active: entry.active,
|
|
6341
|
+
restored: entry.restored,
|
|
6342
|
+
handedOver: entry.handedTo !== null
|
|
6343
|
+
})),
|
|
6344
|
+
activeTabId: activeRecord?.tabId ?? null,
|
|
6345
|
+
url: active ? active.view.webContents.getURL() : "",
|
|
6346
|
+
title: active ? active.view.webContents.getTitle() : "",
|
|
6347
|
+
loading: active ? active.view.webContents.isLoading() : false,
|
|
6348
|
+
canGoBack: navigation ? navigation.canGoBack() : false,
|
|
6349
|
+
canGoForward: navigation ? navigation.canGoForward() : false,
|
|
6350
|
+
// The consent surface (PR 7) renders from this. Exposed now so the
|
|
6351
|
+
// transport does not need a second wire change when it lands, and so an
|
|
6352
|
+
// evidence run can answer a prompt headlessly.
|
|
6353
|
+
pendingConsent: this.approvals.pendingEntries().map((entry) => ({
|
|
6354
|
+
entryId: entry.entryId,
|
|
6355
|
+
origin: entry.origin,
|
|
6356
|
+
authority: entry.displayAuthority,
|
|
6357
|
+
broad: entry.broad ?? null,
|
|
6358
|
+
expiresAt: entry.expiresAt
|
|
6359
|
+
}))
|
|
6360
|
+
};
|
|
6361
|
+
}
|
|
6362
|
+
/** A user tab: created active, at about:blank, owned by the user and with no
|
|
6363
|
+
* capability until the user hands it over (design 6.1, 6.3). */
|
|
6364
|
+
async newTab() {
|
|
6365
|
+
const record = this.registry.create({ owner: "user" });
|
|
6366
|
+
await record.view.webContents.loadURL("about:blank");
|
|
6367
|
+
await this.cdp.attach(record.view.webContents);
|
|
6368
|
+
this.onChanged();
|
|
6369
|
+
return this.chromeState();
|
|
6370
|
+
}
|
|
6371
|
+
closeTab(tabId) {
|
|
6372
|
+
this.registry.destroy(tabId);
|
|
6373
|
+
this.onChanged();
|
|
6374
|
+
return this.chromeState();
|
|
6375
|
+
}
|
|
6376
|
+
activateTab(tabId) {
|
|
6377
|
+
this.registry.activate(tabId);
|
|
6378
|
+
this.onChanged();
|
|
6379
|
+
return this.chromeState();
|
|
6380
|
+
}
|
|
6381
|
+
/**
|
|
6382
|
+
* A URL the USER typed goes through here, and it is deliberately different from
|
|
6383
|
+
* an agent navigation in one way and identical in another (design 6.1):
|
|
6384
|
+
*
|
|
6385
|
+
* - no origin gate, and no consent prompt: the user typing their own URL IS the
|
|
6386
|
+
* consent. An origin can therefore be reachable by the user and refused to the
|
|
6387
|
+
* agent, which is the intended direction of the gate and something the consent
|
|
6388
|
+
* copy must say in words rather than leave the user to infer.
|
|
6389
|
+
* - the same scheme rule: non-`http(s)` input is refused, because allowing
|
|
6390
|
+
* `file:` or `javascript:` here would hand a page (or a paste) a way to reach
|
|
6391
|
+
* the local filesystem through the app's own URL bar.
|
|
6392
|
+
*/
|
|
6393
|
+
async navigateActive(rawUrl) {
|
|
6394
|
+
const record = this.registry.activeTab;
|
|
6395
|
+
if (!record)
|
|
6396
|
+
throw new BridgeCommandError("tab_closed", "no browser tab is open");
|
|
6397
|
+
let url;
|
|
6398
|
+
try {
|
|
6399
|
+
url = new URL(rawUrl.includes("://") ? rawUrl : `https://${rawUrl}`);
|
|
6400
|
+
} catch {
|
|
6401
|
+
throw new BridgeCommandError("nav_failed", "that is not a URL");
|
|
6402
|
+
}
|
|
6403
|
+
if (!permittedScheme(url)) {
|
|
6404
|
+
throw new BridgeCommandError(
|
|
6405
|
+
"nav_failed",
|
|
6406
|
+
"only http:// and https:// can be opened"
|
|
6407
|
+
);
|
|
6408
|
+
}
|
|
6409
|
+
await record.view.webContents.loadURL(url.href);
|
|
6410
|
+
this.onChanged();
|
|
6411
|
+
return this.chromeState();
|
|
6412
|
+
}
|
|
6413
|
+
reloadActive() {
|
|
6414
|
+
this.registry.activeTab?.view.webContents.reload();
|
|
6415
|
+
return this.chromeState();
|
|
6416
|
+
}
|
|
6417
|
+
stopActive() {
|
|
6418
|
+
this.registry.activeTab?.view.webContents.stop();
|
|
6419
|
+
return this.chromeState();
|
|
6420
|
+
}
|
|
6421
|
+
historyActive(direction) {
|
|
6422
|
+
const navigation = this.registry.activeTab?.view.webContents.navigationHistory;
|
|
6423
|
+
if (navigation) {
|
|
6424
|
+
if (direction === "back") navigation.goBack();
|
|
6425
|
+
else navigation.goForward();
|
|
6426
|
+
}
|
|
6427
|
+
return this.chromeState();
|
|
6428
|
+
}
|
|
6429
|
+
/** Layout authority is the renderer's (design 11.2): it measures the content
|
|
6430
|
+
* area and reports it, and main applies it to the active tab only. */
|
|
6431
|
+
setContentRect(rect) {
|
|
6432
|
+
this.registry.setContentRect(rect);
|
|
6433
|
+
return this.chromeState();
|
|
6434
|
+
}
|
|
6435
|
+
/** Full-window overlays hide the view: a native view paints above all DOM, so
|
|
6436
|
+
* an overlay is invisible unless the view goes away (design 11.3). */
|
|
6437
|
+
setViewVisible(visible) {
|
|
6438
|
+
this.registry.setViewVisible(visible);
|
|
6439
|
+
return this.chromeState();
|
|
6440
|
+
}
|
|
6441
|
+
/** Hand a user tab to a session, minting the capability. The RENDERER never
|
|
6442
|
+
* sees the nonce: it travels to the session through `tabs`, which is what keeps
|
|
6443
|
+
* this the one authority transfer rather than an exposure (design 6.3, 11.7). */
|
|
6444
|
+
handOver(tabId, sessionId2) {
|
|
6445
|
+
this.registry.handOver(tabId, sessionId2);
|
|
6446
|
+
this.onChanged();
|
|
6447
|
+
return this.chromeState();
|
|
6448
|
+
}
|
|
6449
|
+
revokeHandOver(tabId) {
|
|
6450
|
+
this.registry.revokeHandOver(tabId);
|
|
6451
|
+
this.onChanged();
|
|
6452
|
+
return this.chromeState();
|
|
6453
|
+
}
|
|
6454
|
+
/** Answer a pending consent prompt. Reached only from the renderer IPC, whose
|
|
6455
|
+
* handler checks the sender first. */
|
|
6456
|
+
respondToConsent(entryId, decision) {
|
|
6457
|
+
const result = this.approvals.respond(entryId, decision);
|
|
6458
|
+
this.onChanged();
|
|
6459
|
+
return { ...result, ...this.chromeState() };
|
|
6460
|
+
}
|
|
6461
|
+
/** The registry's handles, for a diagnostic or the `tabs` listing. */
|
|
6462
|
+
handleFor(record) {
|
|
6463
|
+
const token = surfaceToken(record);
|
|
6464
|
+
return token ? redactToken(token) : `ui:${record.tabId}:no-handle`;
|
|
6465
|
+
}
|
|
6466
|
+
}
|
|
6467
|
+
const AWAIT_SLICE_MS = 2e4;
|
|
6468
|
+
const BROWSER_IPC_CHANNELS = [
|
|
6469
|
+
"browser-state",
|
|
6470
|
+
"browser-new-tab",
|
|
6471
|
+
"browser-close-tab",
|
|
6472
|
+
"browser-activate-tab",
|
|
6473
|
+
"browser-navigate",
|
|
6474
|
+
"browser-reload",
|
|
6475
|
+
"browser-stop",
|
|
6476
|
+
"browser-history",
|
|
6477
|
+
"browser-set-content-rect",
|
|
6478
|
+
"browser-set-visible",
|
|
6479
|
+
"browser-hand-over",
|
|
6480
|
+
"browser-revoke-hand-over",
|
|
6481
|
+
"browser-consent-respond",
|
|
6482
|
+
"browser-clear-data"
|
|
6483
|
+
];
|
|
6484
|
+
function registerBrowserIpc(options) {
|
|
6485
|
+
function authorize(event) {
|
|
6486
|
+
const owner = options.window();
|
|
6487
|
+
if (!owner || owner.isDestroyed() || event.sender !== owner.webContents || event.senderFrame !== owner.webContents.mainFrame || !trustedDesktopFrame(event.senderFrame.url, options.expectedUrl)) {
|
|
6488
|
+
throw new Error("This window cannot use the browser controls.");
|
|
6489
|
+
}
|
|
6490
|
+
const host = options.host();
|
|
6491
|
+
if (!host) throw new Error("The browser host is not running.");
|
|
6492
|
+
return host;
|
|
6493
|
+
}
|
|
6494
|
+
electron.ipcMain.handle("browser-state", (event) => authorize(event).chromeState());
|
|
6495
|
+
electron.ipcMain.handle("browser-new-tab", async (event) => authorize(event).newTab());
|
|
6496
|
+
electron.ipcMain.handle("browser-close-tab", (event, tabId) => {
|
|
6497
|
+
const host = authorize(event);
|
|
6498
|
+
return host.closeTab(numberOrThrow(tabId, "tabId"));
|
|
6499
|
+
});
|
|
6500
|
+
electron.ipcMain.handle("browser-activate-tab", (event, tabId) => {
|
|
6501
|
+
const host = authorize(event);
|
|
6502
|
+
return host.activateTab(numberOrThrow(tabId, "tabId"));
|
|
6503
|
+
});
|
|
6504
|
+
electron.ipcMain.handle("browser-navigate", async (event, url) => {
|
|
6505
|
+
const host = authorize(event);
|
|
6506
|
+
if (typeof url !== "string" || !url.trim()) {
|
|
6507
|
+
throw new Error("A URL is required.");
|
|
6508
|
+
}
|
|
6509
|
+
return host.navigateActive(url.trim());
|
|
6510
|
+
});
|
|
6511
|
+
electron.ipcMain.handle("browser-reload", (event) => authorize(event).reloadActive());
|
|
6512
|
+
electron.ipcMain.handle("browser-stop", (event) => authorize(event).stopActive());
|
|
6513
|
+
electron.ipcMain.handle("browser-history", (event, direction) => {
|
|
6514
|
+
const host = authorize(event);
|
|
6515
|
+
if (direction !== "back" && direction !== "forward") {
|
|
6516
|
+
throw new Error("History direction must be forward or back.");
|
|
6517
|
+
}
|
|
6518
|
+
return host.historyActive(direction);
|
|
6519
|
+
});
|
|
6520
|
+
electron.ipcMain.handle("browser-set-content-rect", (event, rect) => {
|
|
6521
|
+
const host = authorize(event);
|
|
6522
|
+
return host.setContentRect(contentRectOrNull(rect));
|
|
6523
|
+
});
|
|
6524
|
+
electron.ipcMain.handle("browser-set-visible", (event, visible) => {
|
|
6525
|
+
const host = authorize(event);
|
|
6526
|
+
if (typeof visible !== "boolean") {
|
|
6527
|
+
throw new Error("Visibility must be a boolean.");
|
|
6528
|
+
}
|
|
6529
|
+
return host.setViewVisible(visible);
|
|
6530
|
+
});
|
|
6531
|
+
electron.ipcMain.handle(
|
|
6532
|
+
"browser-hand-over",
|
|
6533
|
+
(event, tabId, sessionId2) => {
|
|
6534
|
+
const host = authorize(event);
|
|
6535
|
+
if (typeof sessionId2 !== "string" || !sessionId2.trim()) {
|
|
6536
|
+
throw new Error("Handing a tab over needs a session to hand it to.");
|
|
6537
|
+
}
|
|
6538
|
+
return host.handOver(numberOrThrow(tabId, "tabId"), sessionId2.trim());
|
|
6539
|
+
}
|
|
6540
|
+
);
|
|
6541
|
+
electron.ipcMain.handle("browser-revoke-hand-over", (event, tabId) => {
|
|
6542
|
+
const host = authorize(event);
|
|
6543
|
+
return host.revokeHandOver(numberOrThrow(tabId, "tabId"));
|
|
6544
|
+
});
|
|
6545
|
+
electron.ipcMain.handle(
|
|
6546
|
+
"browser-consent-respond",
|
|
6547
|
+
(event, entryId, decision) => {
|
|
6548
|
+
const host = authorize(event);
|
|
6549
|
+
if (typeof entryId !== "string" || !entryId) {
|
|
6550
|
+
throw new Error("A pending request id is required.");
|
|
6551
|
+
}
|
|
6552
|
+
if (decision !== "once" && decision !== "site" && decision !== "domain" && decision !== "deny") {
|
|
6553
|
+
throw new Error("Unsupported consent decision.");
|
|
6554
|
+
}
|
|
6555
|
+
return host.respondToConsent(entryId, decision);
|
|
6556
|
+
}
|
|
6557
|
+
);
|
|
6558
|
+
electron.ipcMain.handle("browser-clear-data", async (event, what) => {
|
|
6559
|
+
authorize(event);
|
|
6560
|
+
if (what !== "cookies" && what !== "cache" && what !== "everything") {
|
|
6561
|
+
throw new Error("Unsupported clear-data request.");
|
|
6562
|
+
}
|
|
6563
|
+
await options.clearData(what);
|
|
6564
|
+
options.log(`[browser] cleared browsing data: ${what}`);
|
|
6565
|
+
return { cleared: what };
|
|
6566
|
+
});
|
|
6567
|
+
}
|
|
6568
|
+
function unregisterBrowserIpc() {
|
|
6569
|
+
for (const channel of BROWSER_IPC_CHANNELS) electron.ipcMain.removeHandler(channel);
|
|
6570
|
+
}
|
|
6571
|
+
function numberOrThrow(value, name) {
|
|
6572
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
6573
|
+
throw new Error(`${name} must be an integer.`);
|
|
6574
|
+
}
|
|
6575
|
+
return value;
|
|
6576
|
+
}
|
|
6577
|
+
function contentRectOrNull(value) {
|
|
6578
|
+
if (value === null || value === void 0) return null;
|
|
6579
|
+
if (typeof value !== "object")
|
|
6580
|
+
throw new Error("Content rect must be an object.");
|
|
6581
|
+
const rect = value;
|
|
6582
|
+
for (const key of ["x", "y", "width", "height"]) {
|
|
6583
|
+
if (typeof rect[key] !== "number" || !Number.isFinite(rect[key])) {
|
|
6584
|
+
throw new Error("Content rect needs finite x, y, width and height.");
|
|
6585
|
+
}
|
|
6586
|
+
}
|
|
6587
|
+
return {
|
|
6588
|
+
x: rect.x,
|
|
6589
|
+
y: rect.y,
|
|
6590
|
+
width: rect.width,
|
|
6591
|
+
height: rect.height
|
|
6592
|
+
};
|
|
6593
|
+
}
|
|
6594
|
+
const PROOF_SHAPE = /^[a-zA-Z0-9_-]{32,}$/;
|
|
6595
|
+
const MISSING_PROOF_MESSAGE = "missing private browser ownership proof";
|
|
6596
|
+
class OwnershipLedger {
|
|
6597
|
+
constructor(hooks) {
|
|
6598
|
+
this.hooks = hooks;
|
|
6599
|
+
}
|
|
6600
|
+
scopes = /* @__PURE__ */ new Map();
|
|
6601
|
+
/** Per-OWNER command lanes, keyed by `owner_proof`. The lane spans a handler
|
|
6602
|
+
* deliberately: it is what stops a same-owner `owner_finish` overtaking its own
|
|
6603
|
+
* in-flight `open` and letting a late navigation resurrect the tab. Per proof,
|
|
6604
|
+
* so it starves nobody else. */
|
|
6605
|
+
lanes = /* @__PURE__ */ new Map();
|
|
6606
|
+
identity(params) {
|
|
6607
|
+
const proof = String(params.owner_proof ?? "");
|
|
6608
|
+
const session = String(params.requester ?? "");
|
|
6609
|
+
const generation = String(params.owner_generation ?? "");
|
|
6610
|
+
if (!PROOF_SHAPE.test(proof) || !session.startsWith("session:") || !generation) {
|
|
6611
|
+
throw new BridgeCommandError("owner_refused", MISSING_PROOF_MESSAGE);
|
|
6612
|
+
}
|
|
6613
|
+
return [proof, session, generation];
|
|
6614
|
+
}
|
|
6615
|
+
/** The scope a proof names, after the session and generation checks. Creation
|
|
6616
|
+
* is opt-in per method, matching the extension's `create` flag. */
|
|
6617
|
+
mutate(params, fn, create = false) {
|
|
6618
|
+
const [proof, session, generation] = this.identity(params);
|
|
6619
|
+
let scope = this.scopes.get(proof);
|
|
6620
|
+
if (!scope && create) {
|
|
6621
|
+
scope = {
|
|
6622
|
+
session,
|
|
6623
|
+
generation,
|
|
6624
|
+
allocations: {},
|
|
6625
|
+
unknownReservations: 0
|
|
6626
|
+
};
|
|
6627
|
+
this.scopes.set(proof, scope);
|
|
6628
|
+
}
|
|
6629
|
+
if (!scope || scope.session !== session || scope.generation !== generation) {
|
|
6630
|
+
throw new BridgeCommandError(
|
|
6631
|
+
"owner_refused",
|
|
6632
|
+
"browser owner generation is stale or unresolved"
|
|
6633
|
+
);
|
|
6634
|
+
}
|
|
6635
|
+
return fn(scope);
|
|
6636
|
+
}
|
|
6637
|
+
/** Record (or clear) the tab an allocation owns. */
|
|
6638
|
+
recordAllocation(params, tab, state) {
|
|
6639
|
+
if (!params.owner_proof) return;
|
|
6640
|
+
this.mutate(params, (scope) => {
|
|
6641
|
+
const allocation = String(params.allocation_id ?? "");
|
|
6642
|
+
if (!allocation) {
|
|
6643
|
+
throw new BridgeCommandError("owner_refused", "browser scope ended");
|
|
6644
|
+
}
|
|
6645
|
+
if (scope.terminal) {
|
|
6646
|
+
throw new BridgeCommandError("owner_refused", "browser scope ended");
|
|
6647
|
+
}
|
|
6648
|
+
scope.allocations[allocation] = { tab: tab || void 0, state };
|
|
6649
|
+
});
|
|
6650
|
+
}
|
|
6651
|
+
/**
|
|
6652
|
+
* Run a command under the ownership gate.
|
|
6653
|
+
*
|
|
6654
|
+
* The order of the checks is the extension's, and it matters: `open`'s
|
|
6655
|
+
* allocation fence runs BEFORE the terminal check, because an `open` that
|
|
6656
|
+
* replays an allocation the scope already knows must succeed even after the
|
|
6657
|
+
* scope is resolved (that is exactly the lost-response case).
|
|
6658
|
+
*/
|
|
6659
|
+
withOwnership(method, params, handler) {
|
|
6660
|
+
const key = String(params.owner_proof ?? "legacy");
|
|
6661
|
+
const previous = this.lanes.get(key) ?? Promise.resolve();
|
|
6662
|
+
const operate = async () => {
|
|
6663
|
+
if (!params.owner_proof) {
|
|
6664
|
+
if (method.startsWith("owner_")) {
|
|
6665
|
+
throw new BridgeCommandError("owner_refused", MISSING_PROOF_MESSAGE);
|
|
6666
|
+
}
|
|
6667
|
+
if (params.tab) {
|
|
6668
|
+
const token = String(params.tab);
|
|
6669
|
+
const scope2 = this.scopeOwning(token);
|
|
6670
|
+
if (scope2?.allocations && this.tokenHasAllocation(token)) {
|
|
6671
|
+
throw new BridgeCommandError(
|
|
6672
|
+
"owner_refused",
|
|
6673
|
+
"owner-aware client required"
|
|
6674
|
+
);
|
|
6675
|
+
}
|
|
6676
|
+
}
|
|
6677
|
+
return handler();
|
|
6678
|
+
}
|
|
6679
|
+
const [proof, session, generation] = this.identity(params);
|
|
6680
|
+
if (method === "owner_recover") {
|
|
6681
|
+
const scope2 = this.scopes.get(proof);
|
|
6682
|
+
if (!scope2) return { ownership_version: 1, state: "unresolved" };
|
|
6683
|
+
const predecessors = Array.isArray(params.previous_generations) ? params.previous_generations : [];
|
|
6684
|
+
if (scope2.session !== session || scope2.generation !== generation && scope2.generation !== params.previous_generation && !predecessors.includes(scope2.generation)) {
|
|
6685
|
+
throw new BridgeCommandError(
|
|
6686
|
+
"owner_refused",
|
|
6687
|
+
"browser owner generation is stale"
|
|
6688
|
+
);
|
|
6689
|
+
}
|
|
6690
|
+
if (scope2.generation !== generation || params.resumed_scope === true) {
|
|
6691
|
+
scope2.terminal = void 0;
|
|
6692
|
+
}
|
|
6693
|
+
scope2.generation = generation;
|
|
6694
|
+
const allocation = scope2.allocations[String(params.allocation_id ?? "")];
|
|
6695
|
+
const live = allocation?.tab ? this.hooks.isLive(allocation.tab) : false;
|
|
6696
|
+
const unresolved = allocation && ["allocating", "allocated", "cleanup_pending"].includes(
|
|
6697
|
+
allocation.state
|
|
6698
|
+
);
|
|
6699
|
+
return {
|
|
6700
|
+
ownership_version: 1,
|
|
6701
|
+
state: live ? allocation.state : unresolved ? "allocating" : "closed",
|
|
6702
|
+
tab: live ? allocation.tab ?? "" : "",
|
|
6703
|
+
retention: scope2.retention ?? "",
|
|
6704
|
+
terminal: scope2.terminal ?? "",
|
|
6705
|
+
unknown_reservations: scope2.unknownReservations
|
|
6706
|
+
};
|
|
6707
|
+
}
|
|
6708
|
+
if (method === "open") {
|
|
6709
|
+
const existing = this.mutate(
|
|
6710
|
+
params,
|
|
6711
|
+
(value) => {
|
|
6712
|
+
if (value.terminal) {
|
|
6713
|
+
throw new BridgeCommandError(
|
|
6714
|
+
"owner_refused",
|
|
6715
|
+
"browser scope ended; resume a new generation first"
|
|
6716
|
+
);
|
|
6717
|
+
}
|
|
6718
|
+
const id2 = String(params.allocation_id ?? "");
|
|
6719
|
+
if (!id2) {
|
|
6720
|
+
throw new BridgeCommandError(
|
|
6721
|
+
"owner_refused",
|
|
6722
|
+
"missing browser allocation id"
|
|
6723
|
+
);
|
|
6724
|
+
}
|
|
6725
|
+
return value.allocations[id2];
|
|
6726
|
+
},
|
|
6727
|
+
true
|
|
6728
|
+
);
|
|
6729
|
+
if (params.tab) {
|
|
6730
|
+
if (existing?.tab !== params.tab) {
|
|
6731
|
+
throw new BridgeCommandError(
|
|
6732
|
+
"owner_refused",
|
|
6733
|
+
"tab does not belong to allocation"
|
|
6734
|
+
);
|
|
6735
|
+
}
|
|
6736
|
+
return handler();
|
|
6737
|
+
}
|
|
6738
|
+
if (existing?.tab && this.hooks.isLive(existing.tab)) {
|
|
6739
|
+
return {
|
|
6740
|
+
tab: existing.tab,
|
|
6741
|
+
state: existing.state,
|
|
6742
|
+
replayed: true
|
|
6743
|
+
};
|
|
6744
|
+
}
|
|
6745
|
+
if (existing && existing.state !== "closed") {
|
|
6746
|
+
this.mutate(params, (value) => {
|
|
6747
|
+
value.unknownReservations += 1;
|
|
6748
|
+
});
|
|
6749
|
+
}
|
|
6750
|
+
this.recordAllocation(params, "", "allocating");
|
|
6751
|
+
return handler();
|
|
6752
|
+
}
|
|
6753
|
+
const scope = this.mutate(
|
|
6754
|
+
params,
|
|
6755
|
+
(value) => value,
|
|
6756
|
+
[
|
|
6757
|
+
"tabs",
|
|
6758
|
+
"status",
|
|
6759
|
+
"request_access",
|
|
6760
|
+
"await_access",
|
|
6761
|
+
"cancel_access",
|
|
6762
|
+
"owner_retain",
|
|
6763
|
+
"owner_finish"
|
|
6764
|
+
].includes(method)
|
|
6765
|
+
);
|
|
6766
|
+
if (method === "owner_retain") {
|
|
6767
|
+
const reason = String(params.reason ?? "").trim();
|
|
6768
|
+
if (!reason || reason.length > 500) {
|
|
6769
|
+
throw new BridgeCommandError(
|
|
6770
|
+
"owner_refused",
|
|
6771
|
+
"retention requires a bounded reason"
|
|
6772
|
+
);
|
|
6773
|
+
}
|
|
6774
|
+
this.mutate(params, (value) => {
|
|
6775
|
+
value.retention = reason;
|
|
6776
|
+
});
|
|
6777
|
+
return { state: "retained" };
|
|
6778
|
+
}
|
|
6779
|
+
if (method === "owner_release") {
|
|
6780
|
+
this.mutate(params, (value) => {
|
|
6781
|
+
value.retention = void 0;
|
|
6782
|
+
});
|
|
6783
|
+
return { state: "released", terminal: scope.terminal ?? "" };
|
|
6784
|
+
}
|
|
6785
|
+
if (method === "owner_finish") {
|
|
6786
|
+
const retained = scope.retention;
|
|
6787
|
+
this.mutate(params, (value) => {
|
|
6788
|
+
value.terminal = String(params.outcome ?? "completed");
|
|
6789
|
+
});
|
|
6790
|
+
if (retained) return { state: "retained" };
|
|
6791
|
+
let pending = false;
|
|
6792
|
+
for (const allocation of Object.values(scope.allocations)) {
|
|
6793
|
+
if (!allocation.tab || !this.hooks.isLive(allocation.tab)) {
|
|
6794
|
+
if (["allocating", "allocated", "cleanup_pending"].includes(
|
|
6795
|
+
allocation.state
|
|
6796
|
+
)) {
|
|
6797
|
+
pending = true;
|
|
6798
|
+
}
|
|
6799
|
+
continue;
|
|
6800
|
+
}
|
|
6801
|
+
const closed = await this.hooks.closeTab(allocation.tab);
|
|
6802
|
+
if (!closed) pending = true;
|
|
6803
|
+
}
|
|
6804
|
+
return { state: pending ? "pending" : "closed" };
|
|
6805
|
+
}
|
|
6806
|
+
if (scope.terminal && method !== "close" && method !== "tabs") {
|
|
6807
|
+
throw new BridgeCommandError("owner_refused", "browser scope ended");
|
|
6808
|
+
}
|
|
6809
|
+
if (params.tab && !Object.values(scope.allocations).some(
|
|
6810
|
+
(allocation) => allocation.tab === params.tab
|
|
6811
|
+
)) {
|
|
6812
|
+
throw new BridgeCommandError(
|
|
6813
|
+
"owner_refused",
|
|
6814
|
+
"tab does not belong to this browser owner"
|
|
6815
|
+
);
|
|
6816
|
+
}
|
|
6817
|
+
return handler();
|
|
6818
|
+
};
|
|
6819
|
+
const run = previous.catch(() => {
|
|
6820
|
+
}).then(operate);
|
|
6821
|
+
this.lanes.set(key, run);
|
|
6822
|
+
void run.finally(() => {
|
|
6823
|
+
if (this.lanes.get(key) === run) this.lanes.delete(key);
|
|
6824
|
+
}).catch(() => {
|
|
6825
|
+
});
|
|
6826
|
+
return run;
|
|
6827
|
+
}
|
|
6828
|
+
/** The scope a token belongs to, if any (legacy-path fencing only). */
|
|
6829
|
+
scopeOwning(token) {
|
|
6830
|
+
for (const scope of this.scopes.values()) {
|
|
6831
|
+
if (Object.values(scope.allocations).some((a) => a.tab === token)) {
|
|
6832
|
+
return scope;
|
|
6833
|
+
}
|
|
6834
|
+
}
|
|
6835
|
+
return void 0;
|
|
6836
|
+
}
|
|
6837
|
+
tokenHasAllocation(token) {
|
|
6838
|
+
return this.scopeOwning(token) !== void 0;
|
|
6839
|
+
}
|
|
6840
|
+
/** Forget every scope. Called when the host stops, so a restart cannot
|
|
6841
|
+
* half-remember a previous process's tabs. */
|
|
6842
|
+
clear() {
|
|
6843
|
+
this.scopes.clear();
|
|
6844
|
+
}
|
|
6845
|
+
/** Diagnostic counts for `status`. */
|
|
6846
|
+
describe() {
|
|
6847
|
+
let retained = 0;
|
|
6848
|
+
let terminal = 0;
|
|
6849
|
+
for (const scope of this.scopes.values()) {
|
|
6850
|
+
if (scope.retention) retained += 1;
|
|
6851
|
+
if (scope.terminal) terminal += 1;
|
|
6852
|
+
}
|
|
6853
|
+
return { scopes: this.scopes.size, retained, terminal };
|
|
6854
|
+
}
|
|
6855
|
+
}
|
|
6856
|
+
const BROWSER_PARTITION = "persist:local-operator-browser";
|
|
6857
|
+
function browserUserAgent() {
|
|
6858
|
+
const chrome = process.versions.chrome ?? "0";
|
|
6859
|
+
const platform = process.platform === "darwin" ? "Macintosh; Intel Mac OS X 10_15_7" : process.platform === "win32" ? "Windows NT 10.0; Win64; x64" : "X11; Linux x86_64";
|
|
6860
|
+
return `Mozilla/5.0 (${platform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome} Safari/537.36 LocalOperator/${electron.app.getVersion()}`;
|
|
6861
|
+
}
|
|
6862
|
+
function resolveBrowserSession() {
|
|
6863
|
+
return electron.session.fromPartition(BROWSER_PARTITION);
|
|
6864
|
+
}
|
|
6865
|
+
function browserStoragePath(browserSession) {
|
|
6866
|
+
return browserSession.getStoragePath();
|
|
6867
|
+
}
|
|
6868
|
+
function installBrowserSessionHandlers(browserSession, hooks = {}) {
|
|
6869
|
+
browserSession.setUserAgent(browserUserAgent());
|
|
6870
|
+
browserSession.setPermissionRequestHandler(
|
|
6871
|
+
(_webContents, permission, callback, details) => {
|
|
6872
|
+
hooks.onPermissionRequested?.({
|
|
6873
|
+
webContentsId: -1,
|
|
6874
|
+
origin: typeof details?.requestingUrl === "string" ? details.requestingUrl : "",
|
|
6875
|
+
permission
|
|
6876
|
+
});
|
|
6877
|
+
callback(false);
|
|
6878
|
+
}
|
|
6879
|
+
);
|
|
6880
|
+
browserSession.setPermissionCheckHandler(
|
|
6881
|
+
(_webContents, permission, requestingOrigin, details) => {
|
|
6882
|
+
hooks.onPermissionRequested?.({
|
|
6883
|
+
webContentsId: -1,
|
|
6884
|
+
// Electron calls this handler with an empty origin during the
|
|
6885
|
+
// per-webContents startup probes (media, geolocation, app install), so the
|
|
6886
|
+
// documented `requestingUrl` is preferred when it is there: an
|
|
6887
|
+
// "(unknown origin)" line for a probe the PAGE caused is exactly the line
|
|
6888
|
+
// somebody needs when a site's permission stops working.
|
|
6889
|
+
origin: requestingOrigin || details?.requestingUrl || "",
|
|
6890
|
+
permission
|
|
6891
|
+
});
|
|
6892
|
+
return false;
|
|
6893
|
+
}
|
|
6894
|
+
);
|
|
6895
|
+
browserSession.on("will-download", (event, item) => {
|
|
6896
|
+
event.preventDefault();
|
|
6897
|
+
hooks.onDownloadAttempted?.({
|
|
6898
|
+
webContentsId: -1,
|
|
6899
|
+
url: item.getURL()
|
|
6900
|
+
});
|
|
6901
|
+
hooks.log?.(
|
|
6902
|
+
`[browser] cancelled a download from ${item.getURL()}: background downloads are not supported`
|
|
6903
|
+
);
|
|
6904
|
+
});
|
|
6905
|
+
}
|
|
6906
|
+
async function clearBrowsingData(browserSession, what) {
|
|
6907
|
+
browserSession.flushStorageData();
|
|
6908
|
+
if (what === "cache") {
|
|
6909
|
+
await browserSession.clearCache();
|
|
6910
|
+
return;
|
|
6911
|
+
}
|
|
6912
|
+
if (what === "cookies") {
|
|
6913
|
+
await browserSession.clearData({
|
|
6914
|
+
dataTypes: [
|
|
6915
|
+
"cookies",
|
|
6916
|
+
"localStorage",
|
|
6917
|
+
"indexedDB",
|
|
6918
|
+
"serviceWorkers",
|
|
6919
|
+
"fileSystems",
|
|
6920
|
+
"webSQL",
|
|
6921
|
+
"backgroundFetch"
|
|
6922
|
+
]
|
|
6923
|
+
});
|
|
6924
|
+
return;
|
|
6925
|
+
}
|
|
6926
|
+
await browserSession.clearData();
|
|
6927
|
+
await browserSession.clearCache();
|
|
6928
|
+
}
|
|
6929
|
+
function flushBrowserStorage(browserSession) {
|
|
6930
|
+
try {
|
|
6931
|
+
browserSession.flushStorageData();
|
|
6932
|
+
} catch {
|
|
6933
|
+
}
|
|
6934
|
+
}
|
|
6935
|
+
const RUN_DIRNAME = path$1.join("run", "ui-browser");
|
|
6936
|
+
const STATE_FILENAME = "host.json";
|
|
6937
|
+
const STATE_DIR_MODE = 448;
|
|
6938
|
+
const STATE_FILE_MODE = 384;
|
|
6939
|
+
const HEARTBEAT_INTERVAL_MS = 15e3;
|
|
6940
|
+
function localOperatorConfigDir() {
|
|
6941
|
+
const configured = process.env.LOCAL_OPERATOR_CONFIG_DIR?.trim();
|
|
6942
|
+
if (configured) return configured;
|
|
6943
|
+
return path$1.join(os.homedir(), ".local-operator");
|
|
6944
|
+
}
|
|
6945
|
+
function stateFilePath(root = localOperatorConfigDir()) {
|
|
6946
|
+
return path$1.join(root, RUN_DIRNAME, STATE_FILENAME);
|
|
6947
|
+
}
|
|
6948
|
+
function mintSessionKey() {
|
|
6949
|
+
return node_crypto.randomBytes(32).toString("base64url");
|
|
6950
|
+
}
|
|
6951
|
+
function keysMatch(presented, expected) {
|
|
6952
|
+
if (typeof presented !== "string") return false;
|
|
6953
|
+
const a = Buffer.from(presented, "utf8");
|
|
6954
|
+
const b = Buffer.from(expected, "utf8");
|
|
6955
|
+
if (a.length !== b.length) return false;
|
|
6956
|
+
return node_crypto.timingSafeEqual(a, b);
|
|
6957
|
+
}
|
|
6958
|
+
class BrowserStateWriter {
|
|
6959
|
+
constructor(path2, facts, options) {
|
|
6960
|
+
this.path = path2;
|
|
6961
|
+
this.facts = facts;
|
|
6962
|
+
this.now = options.now ?? Date.now;
|
|
6963
|
+
this.onError = options.onError ?? (() => {
|
|
6964
|
+
});
|
|
6965
|
+
this.appVersion = options.appVersion;
|
|
6966
|
+
}
|
|
6967
|
+
now;
|
|
6968
|
+
onError;
|
|
6969
|
+
timer = null;
|
|
6970
|
+
written = false;
|
|
6971
|
+
appVersion;
|
|
6972
|
+
/** Publish the record and start the heartbeat. The heartbeat is the
|
|
6973
|
+
* difference between "the app is running and can open a tab on demand" and a
|
|
6974
|
+
* record that names a process that died without cleaning up. */
|
|
6975
|
+
start(port, sessionKey) {
|
|
6976
|
+
const startedAt = this.now() / 1e3;
|
|
6977
|
+
this.startedAt = startedAt;
|
|
6978
|
+
this.port = port;
|
|
6979
|
+
this.sessionKey = sessionKey;
|
|
6980
|
+
this.write();
|
|
6981
|
+
this.timer = setInterval(() => this.write(), HEARTBEAT_INTERVAL_MS);
|
|
6982
|
+
this.timer.unref?.();
|
|
6983
|
+
}
|
|
6984
|
+
startedAt = 0;
|
|
6985
|
+
port = 0;
|
|
6986
|
+
sessionKey = "";
|
|
6987
|
+
/** Re-publish immediately, without waiting for the next heartbeat. Called
|
|
6988
|
+
* when something the record reports has changed (a tab opened or closed). */
|
|
6989
|
+
publishNow() {
|
|
6990
|
+
if (this.port === 0) return;
|
|
6991
|
+
this.write();
|
|
6992
|
+
}
|
|
6993
|
+
write() {
|
|
6994
|
+
try {
|
|
6995
|
+
const facts = this.facts();
|
|
6996
|
+
const state = {
|
|
6997
|
+
pid: process.pid,
|
|
6998
|
+
port: this.port,
|
|
6999
|
+
session_key: this.sessionKey,
|
|
7000
|
+
proto: PROTO_VERSION,
|
|
7001
|
+
host: "ui",
|
|
7002
|
+
app_version: this.appVersion,
|
|
7003
|
+
profile_dir: facts.profileDir,
|
|
7004
|
+
tabs: facts.tabs,
|
|
7005
|
+
agent_tabs: facts.agentTabs,
|
|
7006
|
+
heartbeat_at: this.now() / 1e3,
|
|
7007
|
+
started_at: this.startedAt || this.now() / 1e3
|
|
7008
|
+
};
|
|
7009
|
+
fs.mkdirSync(path$1.dirname(this.path), {
|
|
7010
|
+
recursive: true,
|
|
7011
|
+
mode: STATE_DIR_MODE
|
|
7012
|
+
});
|
|
7013
|
+
const staged = `${this.path}.${process.pid}.tmp`;
|
|
7014
|
+
fs.writeFileSync(staged, `${JSON.stringify(state, null, 2)}
|
|
7015
|
+
`, {
|
|
7016
|
+
mode: STATE_FILE_MODE
|
|
7017
|
+
});
|
|
7018
|
+
fs.renameSync(staged, this.path);
|
|
7019
|
+
enforceStateFileModes(this.path);
|
|
7020
|
+
this.written = true;
|
|
7021
|
+
} catch (error) {
|
|
7022
|
+
this.onError(error);
|
|
7023
|
+
}
|
|
7024
|
+
}
|
|
7025
|
+
/** Remove the record, so discovery is honest when the host is gone. Called on
|
|
7026
|
+
* host stop, on quit, and by the Settings toggle that turns the host off. */
|
|
7027
|
+
clear() {
|
|
7028
|
+
if (this.timer) {
|
|
7029
|
+
clearInterval(this.timer);
|
|
7030
|
+
this.timer = null;
|
|
7031
|
+
}
|
|
7032
|
+
if (!this.written) return;
|
|
7033
|
+
try {
|
|
7034
|
+
fs.unlinkSync(this.path);
|
|
7035
|
+
} catch (error) {
|
|
7036
|
+
this.onError(error);
|
|
7037
|
+
}
|
|
7038
|
+
this.written = false;
|
|
7039
|
+
}
|
|
7040
|
+
}
|
|
7041
|
+
function enforceStateFileModes(path2) {
|
|
7042
|
+
try {
|
|
7043
|
+
fs.chmodSync(path$1.dirname(path2), STATE_DIR_MODE);
|
|
7044
|
+
fs.chmodSync(path2, STATE_FILE_MODE);
|
|
7045
|
+
} catch {
|
|
7046
|
+
}
|
|
7047
|
+
}
|
|
7048
|
+
const RPC_PATH = "/rpc";
|
|
7049
|
+
const HEALTH_PATH = "/health";
|
|
7050
|
+
const KEY_HEADER = "x-bridge-key";
|
|
7051
|
+
const MAX_BODY_BYTES = 1 << 20;
|
|
7052
|
+
const LOOPBACK_HOST = "127.0.0.1";
|
|
7053
|
+
function send(res, status2, body, contentType = "application/json") {
|
|
7054
|
+
res.writeHead(status2, {
|
|
7055
|
+
"Content-Type": contentType,
|
|
7056
|
+
"Cache-Control": "no-store"
|
|
7057
|
+
});
|
|
7058
|
+
res.end(typeof body === "string" ? body : JSON.stringify(body));
|
|
7059
|
+
}
|
|
7060
|
+
function readBody(req) {
|
|
7061
|
+
return new Promise((resolve, reject) => {
|
|
7062
|
+
const chunks = [];
|
|
7063
|
+
let size = 0;
|
|
7064
|
+
req.on("data", (chunk) => {
|
|
7065
|
+
size += chunk.length;
|
|
7066
|
+
if (size > MAX_BODY_BYTES) {
|
|
7067
|
+
reject(new Error("body too large"));
|
|
7068
|
+
req.destroy();
|
|
7069
|
+
return;
|
|
7070
|
+
}
|
|
7071
|
+
chunks.push(chunk);
|
|
7072
|
+
});
|
|
7073
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
7074
|
+
req.on("error", reject);
|
|
7075
|
+
});
|
|
7076
|
+
}
|
|
7077
|
+
function errorResponse(id2, error) {
|
|
7078
|
+
if (error instanceof BridgeCommandError) {
|
|
7079
|
+
const known = ERROR_CODES.includes(error.code);
|
|
7080
|
+
return {
|
|
7081
|
+
id: id2,
|
|
7082
|
+
ok: false,
|
|
7083
|
+
error: {
|
|
7084
|
+
code: known ? error.code : "internal",
|
|
7085
|
+
message: known ? error.message : `${error.message} (unmapped code: ${error.code})`,
|
|
7086
|
+
data: errorData(error)
|
|
7087
|
+
}
|
|
7088
|
+
};
|
|
7089
|
+
}
|
|
7090
|
+
return {
|
|
7091
|
+
id: id2,
|
|
7092
|
+
ok: false,
|
|
7093
|
+
error: {
|
|
7094
|
+
code: "internal",
|
|
7095
|
+
message: error instanceof Error ? error.message : String(error),
|
|
7096
|
+
data: {}
|
|
7097
|
+
}
|
|
7098
|
+
};
|
|
7099
|
+
}
|
|
7100
|
+
function validateRequest(raw) {
|
|
7101
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
7102
|
+
throw new BridgeCommandError(
|
|
7103
|
+
"internal",
|
|
7104
|
+
"request envelope must be an object"
|
|
7105
|
+
);
|
|
7106
|
+
}
|
|
7107
|
+
const envelope = raw;
|
|
7108
|
+
for (const key of Object.keys(envelope)) {
|
|
7109
|
+
if (key !== "id" && key !== "method" && key !== "params") {
|
|
7110
|
+
throw new BridgeCommandError("internal", `unknown envelope field '${key}'`);
|
|
7111
|
+
}
|
|
7112
|
+
}
|
|
7113
|
+
if (typeof envelope.id !== "string" || !envelope.id) {
|
|
7114
|
+
throw new BridgeCommandError(
|
|
7115
|
+
"internal",
|
|
7116
|
+
"request envelope needs a string id"
|
|
7117
|
+
);
|
|
7118
|
+
}
|
|
7119
|
+
if (!isMethod(envelope.method)) {
|
|
7120
|
+
throw new BridgeCommandError(
|
|
7121
|
+
"internal",
|
|
7122
|
+
`unknown method '${String(envelope.method)}'`
|
|
7123
|
+
);
|
|
7124
|
+
}
|
|
7125
|
+
const params = envelope.params ?? {};
|
|
7126
|
+
if (typeof params !== "object" || params === null || Array.isArray(params)) {
|
|
7127
|
+
throw new BridgeCommandError("internal", "params must be an object");
|
|
7128
|
+
}
|
|
7129
|
+
return {
|
|
7130
|
+
id: envelope.id,
|
|
7131
|
+
method: envelope.method,
|
|
7132
|
+
params
|
|
7133
|
+
};
|
|
7134
|
+
}
|
|
7135
|
+
async function startRpcServer(options) {
|
|
7136
|
+
const server = node_http.createServer((req, res) => {
|
|
7137
|
+
void handle(req, res, options);
|
|
7138
|
+
});
|
|
7139
|
+
const bound = await new Promise(
|
|
7140
|
+
(resolve, reject) => {
|
|
7141
|
+
server.once("error", reject);
|
|
7142
|
+
server.listen(0, LOOPBACK_HOST, () => {
|
|
7143
|
+
const address = server.address();
|
|
7144
|
+
if (address && typeof address === "object") {
|
|
7145
|
+
resolve({ address: address.address, port: address.port });
|
|
7146
|
+
} else reject(new Error("loopback listener has no port"));
|
|
7147
|
+
});
|
|
7148
|
+
}
|
|
7149
|
+
);
|
|
7150
|
+
return {
|
|
7151
|
+
address: bound.address,
|
|
7152
|
+
port: bound.port,
|
|
7153
|
+
close: () => new Promise((resolve) => {
|
|
7154
|
+
server.close(() => resolve());
|
|
7155
|
+
server.closeAllConnections?.();
|
|
7156
|
+
})
|
|
7157
|
+
};
|
|
7158
|
+
}
|
|
7159
|
+
async function handle(req, res, options) {
|
|
7160
|
+
const url = req.url ?? "";
|
|
7161
|
+
try {
|
|
7162
|
+
if (req.method === "GET" && url.split("?")[0] === HEALTH_PATH) {
|
|
7163
|
+
const body = {
|
|
7164
|
+
host: "ui",
|
|
7165
|
+
proto: PROTO_VERSION,
|
|
7166
|
+
pid: process.pid
|
|
7167
|
+
};
|
|
7168
|
+
send(res, 200, body);
|
|
7169
|
+
return;
|
|
7170
|
+
}
|
|
7171
|
+
if (req.method !== "POST" || url.split("?")[0] !== RPC_PATH) {
|
|
7172
|
+
send(res, 404, { detail: "not found" });
|
|
7173
|
+
return;
|
|
7174
|
+
}
|
|
7175
|
+
const presented = req.headers[KEY_HEADER];
|
|
7176
|
+
if (!keysMatch(
|
|
7177
|
+
Array.isArray(presented) ? presented[0] : presented,
|
|
7178
|
+
options.key
|
|
7179
|
+
)) {
|
|
7180
|
+
send(res, 401, { detail: "unauthorized" });
|
|
7181
|
+
return;
|
|
7182
|
+
}
|
|
7183
|
+
let raw;
|
|
7184
|
+
try {
|
|
7185
|
+
raw = JSON.parse(await readBody(req));
|
|
7186
|
+
} catch {
|
|
7187
|
+
send(res, 422, { detail: "malformed request body" });
|
|
7188
|
+
return;
|
|
7189
|
+
}
|
|
7190
|
+
let request;
|
|
7191
|
+
try {
|
|
7192
|
+
request = validateRequest(raw);
|
|
7193
|
+
} catch (error) {
|
|
7194
|
+
send(res, 422, {
|
|
7195
|
+
detail: error instanceof Error ? error.message : "invalid envelope"
|
|
7196
|
+
});
|
|
7197
|
+
return;
|
|
7198
|
+
}
|
|
7199
|
+
let response;
|
|
7200
|
+
try {
|
|
7201
|
+
const result = await options.dispatch(
|
|
7202
|
+
request.method,
|
|
7203
|
+
request.params,
|
|
7204
|
+
request.id
|
|
7205
|
+
);
|
|
7206
|
+
response = { id: request.id, ok: true, result };
|
|
7207
|
+
} catch (error) {
|
|
7208
|
+
response = errorResponse(request.id, error);
|
|
7209
|
+
}
|
|
7210
|
+
send(res, 200, response);
|
|
7211
|
+
} catch (error) {
|
|
7212
|
+
options.log?.(
|
|
7213
|
+
`[browser] rpc handler failed: ${error instanceof Error ? error.message : String(error)}`
|
|
7214
|
+
);
|
|
7215
|
+
if (!res.headersSent) {
|
|
7216
|
+
send(res, 500, { detail: "internal error" });
|
|
7217
|
+
} else {
|
|
7218
|
+
res.end();
|
|
7219
|
+
}
|
|
7220
|
+
}
|
|
7221
|
+
}
|
|
7222
|
+
const HTTP_SCHEME = /^https?:/i;
|
|
7223
|
+
let activeHost = null;
|
|
7224
|
+
async function stopBrowserHost() {
|
|
7225
|
+
const handle2 = activeHost;
|
|
7226
|
+
activeHost = null;
|
|
7227
|
+
if (handle2) await handle2.stop();
|
|
7228
|
+
}
|
|
7229
|
+
function browserHostEnabled(env = process.env) {
|
|
7230
|
+
const flag = env.LOCAL_OPERATOR_UI_BROWSER_HOST?.trim().toLowerCase();
|
|
7231
|
+
return !(flag === "0" || flag === "false" || flag === "off");
|
|
7232
|
+
}
|
|
7233
|
+
async function startBrowserHost(options) {
|
|
7234
|
+
const { log } = options;
|
|
7235
|
+
const browserSession = resolveBrowserSession();
|
|
7236
|
+
installBrowserSessionHandlers(browserSession, {
|
|
7237
|
+
onPermissionRequested: (details) => {
|
|
7238
|
+
log(
|
|
7239
|
+
`[browser] denied a ${details.permission} permission request from ${details.origin || "(unknown origin)"}`
|
|
7240
|
+
);
|
|
7241
|
+
},
|
|
7242
|
+
onDownloadAttempted: (details) => {
|
|
7243
|
+
log(`[browser] refused a download from ${details.url}`);
|
|
7244
|
+
},
|
|
7245
|
+
log
|
|
7246
|
+
});
|
|
7247
|
+
configurePslRules(readOptionalPslRules(options.userDataDir, log));
|
|
7248
|
+
const cdp = new CdpPool({ log });
|
|
7249
|
+
const views = /* @__PURE__ */ new Map();
|
|
7250
|
+
let stateWriter = null;
|
|
7251
|
+
const notifyChanged = () => {
|
|
7252
|
+
stateWriter?.publishNow();
|
|
7253
|
+
options.window.webContents.send("browser-state-changed");
|
|
7254
|
+
};
|
|
7255
|
+
const registry = new TabRegistry(
|
|
7256
|
+
(_createOptions, tabId) => {
|
|
7257
|
+
const view = buildView(options.window);
|
|
7258
|
+
views.set(tabId, view);
|
|
7259
|
+
wireView(view, tabId);
|
|
7260
|
+
return view;
|
|
7261
|
+
},
|
|
7262
|
+
releaseTab,
|
|
7263
|
+
notifyChanged
|
|
7264
|
+
);
|
|
7265
|
+
function releaseTab(tabId, webContentsId) {
|
|
7266
|
+
const view = views.get(tabId);
|
|
7267
|
+
views.delete(tabId);
|
|
7268
|
+
void cdp.detach(webContentsId).finally(() => {
|
|
7269
|
+
releaseView(options.window, view);
|
|
7270
|
+
});
|
|
7271
|
+
}
|
|
7272
|
+
const ownership = new OwnershipLedger({
|
|
7273
|
+
closeTab: async (token) => {
|
|
7274
|
+
try {
|
|
7275
|
+
const record = registry.requireSurface(token);
|
|
7276
|
+
registry.destroy(record.tabId);
|
|
7277
|
+
} catch {
|
|
7278
|
+
}
|
|
7279
|
+
return true;
|
|
7280
|
+
},
|
|
7281
|
+
isLive: (token) => {
|
|
7282
|
+
try {
|
|
7283
|
+
registry.requireSurface(token);
|
|
7284
|
+
return true;
|
|
7285
|
+
} catch {
|
|
7286
|
+
return false;
|
|
7287
|
+
}
|
|
7288
|
+
}
|
|
7289
|
+
});
|
|
7290
|
+
const approvals = new ApprovalStore({
|
|
7291
|
+
dir: path$1.join(options.userDataDir, "browser"),
|
|
7292
|
+
onChanged: () => {
|
|
7293
|
+
options.window.webContents.send("browser-consent-changed");
|
|
7294
|
+
},
|
|
7295
|
+
log
|
|
7296
|
+
});
|
|
7297
|
+
const profileDir = browserStoragePath(browserSession) ?? "";
|
|
7298
|
+
const facts = {
|
|
7299
|
+
proto: 1,
|
|
7300
|
+
appVersion: options.appVersion,
|
|
7301
|
+
profileDir,
|
|
7302
|
+
profilePersistent: browserSession.isPersistent(),
|
|
7303
|
+
userAgent: browserUserAgent(),
|
|
7304
|
+
domainScope: domainScopeAvailable()
|
|
7305
|
+
};
|
|
7306
|
+
const host = new BrowserHost({
|
|
7307
|
+
registry,
|
|
7308
|
+
cdp,
|
|
7309
|
+
approvals,
|
|
7310
|
+
ownership,
|
|
7311
|
+
log,
|
|
7312
|
+
onChanged: notifyChanged,
|
|
7313
|
+
facts: () => facts
|
|
7314
|
+
});
|
|
7315
|
+
const sessionKey = mintSessionKey();
|
|
7316
|
+
const server = await startRpcServer({
|
|
7317
|
+
key: sessionKey,
|
|
7318
|
+
dispatch: (method, params, requestId2) => host.dispatch(method, params, requestId2),
|
|
7319
|
+
log
|
|
7320
|
+
});
|
|
7321
|
+
stateWriter = new BrowserStateWriter(
|
|
7322
|
+
stateFilePath(),
|
|
7323
|
+
() => ({
|
|
7324
|
+
tabs: registry.count(),
|
|
7325
|
+
agentTabs: registry.agentTabCount(),
|
|
7326
|
+
profileDir
|
|
7327
|
+
}),
|
|
7328
|
+
{
|
|
7329
|
+
appVersion: options.appVersion,
|
|
7330
|
+
onError: (error) => log(`[browser] state file: ${String(error)}`)
|
|
7331
|
+
}
|
|
7332
|
+
);
|
|
7333
|
+
stateWriter.start(server.port, sessionKey);
|
|
7334
|
+
registerBrowserIpc({
|
|
7335
|
+
window: () => options.window,
|
|
7336
|
+
expectedUrl: options.expectedUrl,
|
|
7337
|
+
host: () => host,
|
|
7338
|
+
clearData: (what) => clearBrowsingData(browserSession, what),
|
|
7339
|
+
log
|
|
7340
|
+
});
|
|
7341
|
+
log(
|
|
7342
|
+
`[browser] host on ${server.address}:${server.port} (proto ${facts.proto}), profile ${profileDir || "(in-memory)"}, agent tabs ${registry.agentTabCount()}/${8}`
|
|
7343
|
+
);
|
|
7344
|
+
const handle2 = {
|
|
7345
|
+
host,
|
|
7346
|
+
port: server.port,
|
|
7347
|
+
profileDir,
|
|
7348
|
+
agentTabs: () => registry.agentTabCount(),
|
|
7349
|
+
stop: async () => {
|
|
7350
|
+
registry.destroyAll();
|
|
7351
|
+
await cdp.close();
|
|
7352
|
+
await server.close();
|
|
7353
|
+
unregisterBrowserIpc();
|
|
7354
|
+
stateWriter?.clear();
|
|
7355
|
+
approvals.resetPending();
|
|
7356
|
+
ownership.clear();
|
|
7357
|
+
flushBrowserStorage(browserSession);
|
|
7358
|
+
log("[browser] host stopped");
|
|
7359
|
+
}
|
|
7360
|
+
};
|
|
7361
|
+
activeHost = handle2;
|
|
7362
|
+
return handle2;
|
|
7363
|
+
function buildView(window) {
|
|
7364
|
+
const view = new electron.WebContentsView({
|
|
7365
|
+
webPreferences: {
|
|
7366
|
+
partition: BROWSER_PARTITION,
|
|
7367
|
+
contextIsolation: true,
|
|
7368
|
+
nodeIntegration: false,
|
|
7369
|
+
webSecurity: true,
|
|
7370
|
+
allowRunningInsecureContent: false,
|
|
7371
|
+
sandbox: true
|
|
7372
|
+
// No `preload`: a driven page has no bridge to anything.
|
|
7373
|
+
}
|
|
7374
|
+
});
|
|
7375
|
+
window.contentView.addChildView(view);
|
|
7376
|
+
view.setVisible(false);
|
|
7377
|
+
return view;
|
|
7378
|
+
}
|
|
7379
|
+
function wireView(view, tabId) {
|
|
7380
|
+
const contents = view.webContents;
|
|
7381
|
+
contents.setWindowOpenHandler((details) => {
|
|
7382
|
+
if (HTTP_SCHEME.test(details.url)) {
|
|
7383
|
+
log(
|
|
7384
|
+
`[browser] blocked a popup from a driven page: ${details.url} (offered to the user rather than opened)`
|
|
7385
|
+
);
|
|
7386
|
+
options.window.webContents.send("browser-popup-blocked", {
|
|
7387
|
+
tabId,
|
|
7388
|
+
url: details.url
|
|
7389
|
+
});
|
|
7390
|
+
} else {
|
|
7391
|
+
log(`[browser] blocked a non-http popup: ${details.url}`);
|
|
7392
|
+
}
|
|
7393
|
+
return { action: "deny" };
|
|
7394
|
+
});
|
|
7395
|
+
const refuses = (rawUrl) => {
|
|
7396
|
+
try {
|
|
7397
|
+
const url = new URL(rawUrl);
|
|
7398
|
+
if (url.protocol === "about:") return url.href !== "about:blank";
|
|
7399
|
+
return !permittedScheme(url);
|
|
7400
|
+
} catch {
|
|
7401
|
+
return true;
|
|
7402
|
+
}
|
|
7403
|
+
};
|
|
7404
|
+
const onWillNavigate = (event, rawUrl) => {
|
|
7405
|
+
if (refuses(rawUrl)) {
|
|
7406
|
+
event.preventDefault();
|
|
7407
|
+
log(`[browser] refused a navigation to ${rawUrl}`);
|
|
7408
|
+
}
|
|
7409
|
+
};
|
|
7410
|
+
contents.on("will-navigate", onWillNavigate);
|
|
7411
|
+
contents.on("will-redirect", onWillNavigate);
|
|
7412
|
+
contents.on("will-attach-webview", (event) => {
|
|
7413
|
+
event.preventDefault();
|
|
7414
|
+
log("[browser] refused a webview attachment inside a driven page");
|
|
7415
|
+
});
|
|
7416
|
+
contents.on("did-navigate", (_event, url) => {
|
|
7417
|
+
registry.bumpEpoch(tabId);
|
|
7418
|
+
log(`[browser] tab ${tabId} navigated to ${url}`);
|
|
7419
|
+
});
|
|
7420
|
+
contents.on("did-navigate-in-page", (_event, url) => {
|
|
7421
|
+
registry.bumpEpoch(tabId);
|
|
7422
|
+
log(`[browser] tab ${tabId} navigated in page to ${url}`);
|
|
7423
|
+
});
|
|
7424
|
+
contents.on("destroyed", () => {
|
|
7425
|
+
if (registry.get(tabId)) registry.forget(tabId);
|
|
7426
|
+
releaseTab(tabId, contents.id);
|
|
7427
|
+
});
|
|
7428
|
+
}
|
|
7429
|
+
}
|
|
7430
|
+
function releaseView(window, view) {
|
|
7431
|
+
if (!view) return;
|
|
7432
|
+
try {
|
|
7433
|
+
if (!view.webContents.isDestroyed()) view.webContents.close();
|
|
7434
|
+
} catch {
|
|
7435
|
+
}
|
|
7436
|
+
try {
|
|
7437
|
+
window.contentView.removeChildView(view);
|
|
7438
|
+
} catch {
|
|
7439
|
+
}
|
|
7440
|
+
}
|
|
7441
|
+
function readOptionalPslRules(userDataDir, log) {
|
|
7442
|
+
try {
|
|
7443
|
+
const path2 = path$1.join(userDataDir, "browser", "psl.txt");
|
|
7444
|
+
if (!fs.existsSync(path2)) return null;
|
|
7445
|
+
const rules2 = fs.readFileSync(path2, "utf8");
|
|
7446
|
+
log(`[browser] loaded public-suffix rules from ${path2}`);
|
|
7447
|
+
return rules2;
|
|
7448
|
+
} catch (error) {
|
|
7449
|
+
log(`[browser] could not load public-suffix rules: ${String(error)}`);
|
|
7450
|
+
return null;
|
|
7451
|
+
}
|
|
7452
|
+
}
|
|
4001
7453
|
const OPERATION_ID = /^[a-zA-Z0-9_-]{1,128}$/;
|
|
4002
|
-
function guardForegroundReceipts(window,
|
|
7454
|
+
function guardForegroundReceipts(window, send2) {
|
|
4003
7455
|
return (input) => {
|
|
4004
7456
|
if (input !== null && typeof input === "object" && "op" in input && input.op === "sessions.seen") {
|
|
4005
7457
|
const owner = window();
|
|
@@ -4011,7 +7463,7 @@ function guardForegroundReceipts(window, send) {
|
|
|
4011
7463
|
);
|
|
4012
7464
|
}
|
|
4013
7465
|
}
|
|
4014
|
-
return
|
|
7466
|
+
return send2(input);
|
|
4015
7467
|
};
|
|
4016
7468
|
}
|
|
4017
7469
|
function relayedBytes(value) {
|
|
@@ -4101,7 +7553,7 @@ function registerDesktopIPC(window, expectedUrl, request, streams, media, notifi
|
|
|
4101
7553
|
}
|
|
4102
7554
|
const sender = event.sender;
|
|
4103
7555
|
const relay = streams();
|
|
4104
|
-
const
|
|
7556
|
+
const handle2 = relay.subscribe(
|
|
4105
7557
|
{
|
|
4106
7558
|
sessionId: input.sessionId,
|
|
4107
7559
|
epoch: input.epoch,
|
|
@@ -4109,13 +7561,13 @@ function registerDesktopIPC(window, expectedUrl, request, streams, media, notifi
|
|
|
4109
7561
|
},
|
|
4110
7562
|
(frame) => {
|
|
4111
7563
|
if (sender.isDestroyed()) {
|
|
4112
|
-
relay.unsubscribe(
|
|
7564
|
+
relay.unsubscribe(handle2.streamId);
|
|
4113
7565
|
return;
|
|
4114
7566
|
}
|
|
4115
7567
|
sender.send("desktop-stream-event", frame);
|
|
4116
7568
|
}
|
|
4117
7569
|
);
|
|
4118
|
-
return
|
|
7570
|
+
return handle2;
|
|
4119
7571
|
});
|
|
4120
7572
|
electron.ipcMain.handle("desktop-stream-unsubscribe", (event, streamId) => {
|
|
4121
7573
|
authorize(event);
|
|
@@ -4563,8 +8015,8 @@ class DesktopNotifier {
|
|
|
4563
8015
|
* still re-words nothing (design 8.4). Gates pass `status = ""` and are
|
|
4564
8016
|
* unaffected either way.
|
|
4565
8017
|
*/
|
|
4566
|
-
show(sessionId2, title,
|
|
4567
|
-
const lead = (isSnippet || isFailure) &&
|
|
8018
|
+
show(sessionId2, title, status2, body, isSnippet = false, isFailure = false) {
|
|
8019
|
+
const lead = (isSnippet || isFailure) && status2 ? `${status2} — ` : "";
|
|
4568
8020
|
const notification = new electron.Notification({
|
|
4569
8021
|
title,
|
|
4570
8022
|
body: `${lead}${body}`.slice(0, MAX_BODY_CHARS),
|
|
@@ -5410,7 +8862,7 @@ exit 0
|
|
|
5410
8862
|
function removeStagedTree(input) {
|
|
5411
8863
|
const attempts = input.attempts ?? 3;
|
|
5412
8864
|
const delayMs = input.delayMs ?? 250;
|
|
5413
|
-
const
|
|
8865
|
+
const sleep2 = input.sleep ?? defaultSleep;
|
|
5414
8866
|
let error = null;
|
|
5415
8867
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
5416
8868
|
try {
|
|
@@ -5421,7 +8873,7 @@ function removeStagedTree(input) {
|
|
|
5421
8873
|
if (!input.exists(input.path)) {
|
|
5422
8874
|
return { removed: true, attempts: attempt, error: null };
|
|
5423
8875
|
}
|
|
5424
|
-
if (attempt < attempts)
|
|
8876
|
+
if (attempt < attempts) sleep2(delayMs);
|
|
5425
8877
|
}
|
|
5426
8878
|
return {
|
|
5427
8879
|
removed: false,
|
|
@@ -7700,9 +11152,9 @@ ${heal.removed.join("\n")}`,
|
|
|
7700
11152
|
* the caller reports as a failure rather than as a completed update.
|
|
7701
11153
|
*/
|
|
7702
11154
|
async waitForBackendVersion(target2, timeoutMs = 6e4, intervalMs = 2e3) {
|
|
7703
|
-
const
|
|
11155
|
+
const deadline2 = Date.now() + timeoutMs;
|
|
7704
11156
|
let last = null;
|
|
7705
|
-
while (Date.now() <
|
|
11157
|
+
while (Date.now() < deadline2) {
|
|
7706
11158
|
const version = await this.getInstalledBackendVersion();
|
|
7707
11159
|
if (version && version !== "Unknown") {
|
|
7708
11160
|
last = version;
|
|
@@ -8552,9 +12004,10 @@ electron.app.whenReady().then(async () => {
|
|
|
8552
12004
|
} catch {
|
|
8553
12005
|
}
|
|
8554
12006
|
});
|
|
12007
|
+
const rendererUrl = process.env.ELECTRON_RENDERER_URL || node_url.pathToFileURL(path$1.join(__dirname, "../renderer/index.html")).href;
|
|
8555
12008
|
registerDesktopIPC(
|
|
8556
12009
|
() => mainWindow,
|
|
8557
|
-
|
|
12010
|
+
rendererUrl,
|
|
8558
12011
|
sendDesktop,
|
|
8559
12012
|
() => backendService.getStreamRelay(),
|
|
8560
12013
|
(input, bytes) => backendService.requestDesktopMedia(input, bytes),
|
|
@@ -8921,10 +12374,36 @@ electron.app.whenReady().then(async () => {
|
|
|
8921
12374
|
}
|
|
8922
12375
|
});
|
|
8923
12376
|
}
|
|
12377
|
+
let browserHostRunning = false;
|
|
12378
|
+
async function startBrowserHostForWindow(window) {
|
|
12379
|
+
if (browserHostRunning || !browserHostEnabled()) return;
|
|
12380
|
+
browserHostRunning = true;
|
|
12381
|
+
window.on("closed", () => {
|
|
12382
|
+
browserHostRunning = false;
|
|
12383
|
+
void stopBrowserHost();
|
|
12384
|
+
});
|
|
12385
|
+
try {
|
|
12386
|
+
await startBrowserHost({
|
|
12387
|
+
window,
|
|
12388
|
+
expectedUrl: rendererUrl,
|
|
12389
|
+
appVersion: electron.app.getVersion(),
|
|
12390
|
+
userDataDir: electron.app.getPath("userData"),
|
|
12391
|
+
log: (message) => logger.info(message, LogFileType.BACKEND)
|
|
12392
|
+
});
|
|
12393
|
+
} catch (error) {
|
|
12394
|
+
browserHostRunning = false;
|
|
12395
|
+
logger.error(
|
|
12396
|
+
`Could not start the browser host: ${String(error)}`,
|
|
12397
|
+
LogFileType.BACKEND
|
|
12398
|
+
);
|
|
12399
|
+
}
|
|
12400
|
+
}
|
|
8924
12401
|
setupMainWindowWithUpdateService();
|
|
12402
|
+
if (mainWindow) await startBrowserHostForWindow(mainWindow);
|
|
8925
12403
|
electron.app.on("activate", () => {
|
|
8926
12404
|
if (electron.BrowserWindow.getAllWindows().length === 0) {
|
|
8927
12405
|
setupMainWindowWithUpdateService();
|
|
12406
|
+
if (mainWindow) void startBrowserHostForWindow(mainWindow);
|
|
8928
12407
|
}
|
|
8929
12408
|
});
|
|
8930
12409
|
}).catch((error) => {
|
|
@@ -9116,6 +12595,7 @@ electron.app.on("before-quit", () => {
|
|
|
9116
12595
|
logger.info("App is about to quit", LogFileType.BACKEND);
|
|
9117
12596
|
electron.globalShortcut.unregisterAll();
|
|
9118
12597
|
activeUpdateService?.quitForInFlightInstall("app quit");
|
|
12598
|
+
void stopBrowserHost();
|
|
9119
12599
|
});
|
|
9120
12600
|
process.on("exit", () => {
|
|
9121
12601
|
logger.info(
|