local-operator-ui 0.22.2 → 0.22.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/out/main/index.js +3493 -13
  2. package/out/preload/index.js +55 -0
  3. package/out/renderer/assets/{_basePickBy-v-Y85eBJ.js → _basePickBy-CKsnU6jT.js} +1 -1
  4. package/out/renderer/assets/{_baseUniq-Dndv7mIT.js → _baseUniq-6PveVVhv.js} +1 -1
  5. package/out/renderer/assets/{agent-details-page-B1l-Ildt.js → agent-details-page-fdxpgoYb.js} +1 -1
  6. package/out/renderer/assets/{agent-hub-page-CpTVj2dS.js → agent-hub-page-CtWQQLCN.js} +1 -1
  7. package/out/renderer/assets/{agents-page-8xM34xSF.js → agents-page-4dwrJ6d4.js} +2 -2
  8. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-Df23ykyB.js → architectureDiagram-IEHRJDOE-DBTlCNPk.js} +1 -1
  9. package/out/renderer/assets/{blockDiagram-JOT3LUYC-Cac8-vwq.js → blockDiagram-JOT3LUYC-lKUtjzDj.js} +1 -1
  10. package/out/renderer/assets/{c4Diagram-VJAJSXHY-RicFnhXc.js → c4Diagram-VJAJSXHY-BdtuLrTi.js} +1 -1
  11. package/out/renderer/assets/channel--6v_06Xi.js +1 -0
  12. package/out/renderer/assets/{chunk-4BMEZGHF-DXZkik7U.js → chunk-4BMEZGHF-DbG1_BNb.js} +1 -1
  13. package/out/renderer/assets/{chunk-A2AXSNBT-pZLWtnhN.js → chunk-A2AXSNBT--Uc3EVZu.js} +1 -1
  14. package/out/renderer/assets/{chunk-AEK57VVT-DNrkOAjU.js → chunk-AEK57VVT-D0xpd5az.js} +1 -1
  15. package/out/renderer/assets/{chunk-D6G4REZN-CorkotEf.js → chunk-D6G4REZN-C5nZ0Zmp.js} +1 -1
  16. package/out/renderer/assets/{chunk-RZ5BOZE2-D4xWVS0R.js → chunk-RZ5BOZE2-DQ3WeOaL.js} +1 -1
  17. package/out/renderer/assets/{chunk-XZIHB7SX-D7WktCYW.js → chunk-XZIHB7SX-Bc_JFEdq.js} +1 -1
  18. package/out/renderer/assets/classDiagram-GIVACNV2-wiHO9nXv.js +1 -0
  19. package/out/renderer/assets/classDiagram-v2-COTLJTTW-wiHO9nXv.js +1 -0
  20. package/out/renderer/assets/clone-DmGWPQX4.js +1 -0
  21. package/out/renderer/assets/{compact-pagination-BlTai0Ff.js → compact-pagination-N564IxRz.js} +1 -1
  22. package/out/renderer/assets/{dagre-OKDRZEBW-c9Mr4-AU.js → dagre-OKDRZEBW-CfBYod0P.js} +1 -1
  23. package/out/renderer/assets/{diagram-SSKATNLV-CmfBg6xX.js → diagram-SSKATNLV-DI6ilR5t.js} +1 -1
  24. package/out/renderer/assets/{diagram-VNBRO52H-D1cSBo4P.js → diagram-VNBRO52H-BkdESKob.js} +1 -1
  25. package/out/renderer/assets/{erDiagram-Q7BY3M3F-fB9ALlLN.js → erDiagram-Q7BY3M3F-CtnEbS5r.js} +1 -1
  26. package/out/renderer/assets/{flowDiagram-4HSFHLVR-Bn3e8oxz.js → flowDiagram-4HSFHLVR-DgsidCUm.js} +1 -1
  27. package/out/renderer/assets/{ganttDiagram-APWFNJXF-DmZUyMxr.js → ganttDiagram-APWFNJXF-XMgwL20d.js} +1 -1
  28. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-CXpqrbfr.js → gitGraphDiagram-7IBYFJ6S-BPYVI3kM.js} +1 -1
  29. package/out/renderer/assets/{graph-DRSRhBY0.js → graph-BtogyUC9.js} +1 -1
  30. package/out/renderer/assets/icon-CYsaFfCz.css +1 -0
  31. package/out/renderer/assets/{icon-CzgfFzcx.js → icon-DABMSMzz.js} +1 -1
  32. package/out/renderer/assets/{index-H_Rgms2j.js → index-DQ17rsqJ.js} +314 -316
  33. package/out/renderer/assets/{index-C25cieuM.js → index-Db3XvHjP.js} +1 -1
  34. package/out/renderer/assets/{index-DkZ3A4PA.js → index-DjO_jtbw.js} +1 -1
  35. package/out/renderer/assets/{infoDiagram-PH2N3AL5-B17SCqgG.js → infoDiagram-PH2N3AL5-CoZWMwTD.js} +1 -1
  36. package/out/renderer/assets/{installer-BYBL1HRx.js → installer-DmUFXWA9.js} +1 -1
  37. package/out/renderer/assets/{journeyDiagram-U35MCT3I-Cml5TxnU.js → journeyDiagram-U35MCT3I-BVgkbGZn.js} +1 -1
  38. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-CGeUw2LK.js → kanban-definition-NDS4AKOZ-DtcEDnnL.js} +1 -1
  39. package/out/renderer/assets/{layout-CsdxABYc.js → layout-Cvkb7qto.js} +1 -1
  40. package/out/renderer/assets/{legacy-agents-page-Cm8UE3k2.js → legacy-agents-page-BWJMFFVz.js} +1 -1
  41. package/out/renderer/assets/{mermaid.core-CgVHZN2K.js → mermaid.core-CwffWL0x.js} +5 -5
  42. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-DVUST4ba.js → mindmap-definition-ALO5MXBD-CzFS9Kxp.js} +1 -1
  43. package/out/renderer/assets/{page-header-jeCj1yzY.js → page-header-BBLGXcLj.js} +1 -1
  44. package/out/renderer/assets/{parseISO-BCF0vJu2.js → parseISO-DqHc1rQw.js} +1 -1
  45. package/out/renderer/assets/{pieDiagram-IB7DONF6-BlYbc34V.js → pieDiagram-IB7DONF6-BI8u4scH.js} +1 -1
  46. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-Cpxqo61P.js → quadrantDiagram-7GDLP6J5-DwVDWIf3.js} +1 -1
  47. package/out/renderer/assets/{radar-MK3ICKWK-BkC9BU27.js → radar-MK3ICKWK-BpOfn4ij.js} +1 -1
  48. package/out/renderer/assets/{radient-auth-buttons-DxPib61R.js → radient-auth-buttons-BlTwSaI-.js} +1 -1
  49. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-DsmIBK9A.js → requirementDiagram-KVF5MWMF-C2TPgGlM.js} +1 -1
  50. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-ClduKe0A.js → sankeyDiagram-QLVOVGJD-ClgYBdgO.js} +1 -1
  51. package/out/renderer/assets/{schedules-page-ISfzO2Sn.js → schedules-page-76g_vQ9v.js} +1 -1
  52. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-BFGhahNN.js → sequenceDiagram-X6HHIX6F-IOczGBAg.js} +1 -1
  53. package/out/renderer/assets/{settings-page-BlqXX31a.js → settings-page-Cdib-QsJ.js} +1 -1
  54. package/out/renderer/assets/{square-pen-qntJecec.js → square-pen-DE2bV_Lx.js} +1 -1
  55. package/out/renderer/assets/{stateDiagram-DGXRK772-CurKWGiG.js → stateDiagram-DGXRK772-QtsxXrlh.js} +1 -1
  56. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-DQIWcoSs.js +1 -0
  57. package/out/renderer/assets/{timeline-definition-BDJGKUSR-_K3YGfmM.js → timeline-definition-BDJGKUSR-BCw4dU6G.js} +1 -1
  58. package/out/renderer/assets/{use-agent-like-mutation-DO1KMtbB.js → use-agent-like-mutation-TwbDXJBO.js} +1 -1
  59. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-pWutYnMr.js → xychartDiagram-VJFVF3MP-C5tom7_g.js} +1 -1
  60. package/out/renderer/index.html +3 -3
  61. package/out/renderer/installer.html +3 -3
  62. package/package.json +3 -2
  63. package/out/renderer/assets/channel-D3vO5bAC.js +0 -1
  64. package/out/renderer/assets/classDiagram-GIVACNV2-DJvhnt-8.js +0 -1
  65. package/out/renderer/assets/classDiagram-v2-COTLJTTW-DJvhnt-8.js +0 -1
  66. package/out/renderer/assets/clone-DoopEJwX.js +0 -1
  67. package/out/renderer/assets/icon-yt1cm_p2.css +0 -1
  68. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-Dvqfa11m.js +0 -1
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: (status) => `The event stream was refused (${status}).`,
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, send) {
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 send(input);
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 handle = relay.subscribe(
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(handle.streamId);
7564
+ relay.unsubscribe(handle2.streamId);
4113
7565
  return;
4114
7566
  }
4115
7567
  sender.send("desktop-stream-event", frame);
4116
7568
  }
4117
7569
  );
4118
- return handle;
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, status, body, isSnippet = false, isFailure = false) {
4567
- const lead = (isSnippet || isFailure) && status ? `${status} — ` : "";
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 sleep = input.sleep ?? defaultSleep;
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) sleep(delayMs);
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 deadline = Date.now() + timeoutMs;
11155
+ const deadline2 = Date.now() + timeoutMs;
7704
11156
  let last = null;
7705
- while (Date.now() < deadline) {
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
- process.env.ELECTRON_RENDERER_URL || node_url.pathToFileURL(path$1.join(__dirname, "../renderer/index.html")).href,
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(