browserzap 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +201 -2
- package/README.md +31 -19
- package/THIRD_PARTY_NOTICES.md +4 -3
- package/bridge-server/background-runs.js +1 -1
- package/bridge-server/entitlements.js +8 -6
- package/bridge-server/extension-id.js +64 -1
- package/bridge-server/extension-version.js +65 -0
- package/bridge-server/feedback.js +55 -17
- package/bridge-server/index.js +36 -24
- package/bridge-server/limits.js +21 -2
- package/bridge-server/mcp-server.js +166 -16
- package/bridge-server/native-host.js +22 -3
- package/bridge-server/panel.js +10 -4
- package/bridge-server/probe.js +56 -1
- package/bridge-server/profile-labels.js +81 -0
- package/bridge-server/resolvers.js +1 -1
- package/bridge-server/secret.js +1 -1
- package/bridge-server/server.js +91 -16
- package/bridge-server/setup.js +85 -26
- package/bridge-server/tools.js +46 -2
- package/package.json +17 -7
- package/extension/background.js +0 -1
- package/extension/engine.js +0 -1
- package/extension/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/extension/icons/icon128.png +0 -0
- package/extension/icons/icon16.png +0 -0
- package/extension/icons/icon32.png +0 -0
- package/extension/icons/icon48.png +0 -0
- package/extension/logo.svg +0 -7
- package/extension/manifest.json +0 -47
- package/extension/sidepanel.css +0 -2
- package/extension/sidepanel.html +0 -138
- package/extension/sidepanel.js +0 -1
|
@@ -22,11 +22,11 @@
|
|
|
22
22
|
* closes or after a bounded wait with no request.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
const { getOrCreateSecret } = require('./secret');
|
|
25
|
+
const { getOrCreateSecret, configDir } = require('./secret');
|
|
26
26
|
const { DEFAULT_PORT, PROTOCOL_VERSION, fileLogger } = require('./server');
|
|
27
27
|
const { ensureBridge } = require('./ensure-bridge');
|
|
28
28
|
const { helloProbe } = require('./probe');
|
|
29
|
-
const { HOST_NAME, allowedOrigins } = require('./extension-id');
|
|
29
|
+
const { HOST_NAME, allowedOrigins, allowedExtensionIds } = require('./extension-id');
|
|
30
30
|
|
|
31
31
|
/** Inbound frames above this are rejected before parsing. Chrome's own host→Chrome cap is 1 MiB. */
|
|
32
32
|
const MAX_MESSAGE_BYTES = 1024 * 1024;
|
|
@@ -139,11 +139,29 @@ async function handleRequest(request, options = {}) {
|
|
|
139
139
|
return { type: 'hello', port, token, protocolVersion: PROTOCOL_VERSION };
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Whether Chrome's reported caller origin may start this helper.
|
|
144
|
+
*
|
|
145
|
+
* Defense in depth: allowed_origins in the host manifest is the real control.
|
|
146
|
+
* Chrome may pass no origin at all, and anything that is not a
|
|
147
|
+
* chrome-extension:// origin is not ours to judge here. The set of extension
|
|
148
|
+
* ids comes from `dir` (store id only, plus the unpacked id after
|
|
149
|
+
* `browserzap setup --dev`), so it is the same answer `setup` wrote.
|
|
150
|
+
*/
|
|
151
|
+
function isAllowedOrigin(origin, { dir } = {}) {
|
|
152
|
+
if (typeof origin !== 'string' || !origin.startsWith('chrome-extension://')) return true;
|
|
153
|
+
try {
|
|
154
|
+
return allowedOrigins(allowedExtensionIds({ dir })).includes(origin);
|
|
155
|
+
} catch {
|
|
156
|
+
return false; // a config dir we cannot read allows nothing beyond the manifest
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
142
160
|
function main() {
|
|
143
161
|
// Defense in depth: allowed_origins is the real control, but refuse a caller
|
|
144
162
|
// that is not the BrowserZap extension if Chrome tells us who it is.
|
|
145
163
|
const origin = process.argv[2];
|
|
146
|
-
if (
|
|
164
|
+
if (!isAllowedOrigin(origin, { dir: configDir() })) {
|
|
147
165
|
logLine('wrong_origin');
|
|
148
166
|
process.exit(1);
|
|
149
167
|
}
|
|
@@ -204,4 +222,5 @@ module.exports = {
|
|
|
204
222
|
decodeMessage,
|
|
205
223
|
handleRequest,
|
|
206
224
|
configuredPort,
|
|
225
|
+
isAllowedOrigin,
|
|
207
226
|
};
|
package/bridge-server/panel.js
CHANGED
|
@@ -73,9 +73,10 @@ function writeLastRuns(runs) {
|
|
|
73
73
|
* @param {object} io
|
|
74
74
|
* @param {(msg: object, socket: object, profile: string) => boolean} io.deliver send an action for the panel session to that profile; false if it is not connected
|
|
75
75
|
* @param {(msg: object, profile: string) => void} io.toExtension send a panel_event / cancel to that profile's extension
|
|
76
|
+
* @param {(session: string | null) => void} [io.onFeedback] a batch was saved for this session (null: any)
|
|
76
77
|
* @param {(event: string, fields?: object) => void} [io.log]
|
|
77
78
|
*/
|
|
78
|
-
function createPanelRunner({ deliver, toExtension, log = () => {} }) {
|
|
79
|
+
function createPanelRunner({ deliver, toExtension, isSessionLive = () => true, onFeedback = () => {}, log = () => {} }) {
|
|
79
80
|
const service = createWorkflowService({ log });
|
|
80
81
|
const sessionLog = createSessionLog({ session: PANEL_SESSION, client: () => 'side panel' });
|
|
81
82
|
let current = null;
|
|
@@ -438,9 +439,14 @@ function createPanelRunner({ deliver, toExtension, log = () => {} }) {
|
|
|
438
439
|
return { ok: true, data: {} };
|
|
439
440
|
case 'feedback_submit': {
|
|
440
441
|
// Comment mode (docs/COMMENT_MODE.md): stored on this device for the agent's feedback_get.
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
442
|
+
// Addressed to an agent that has gone? Then "any agent" (session null), never an id nobody can read.
|
|
443
|
+
const asked = args.batch && typeof args.batch.session === 'string' ? args.batch.session : null;
|
|
444
|
+
const live = asked ? isSessionLive(asked) : false;
|
|
445
|
+
const saved = feedback.saveBatch(live ? args.batch : { ...args.batch, session: null });
|
|
446
|
+
log('feedback_saved', { batch: saved.id, comments: saved.count, live });
|
|
447
|
+
// Wake a waiting feedback_wait now instead of on its next poll.
|
|
448
|
+
onFeedback(live ? asked : null);
|
|
449
|
+
return { ok: true, data: { batchId: saved.id, count: saved.count, session: live ? asked : null, live } };
|
|
444
450
|
}
|
|
445
451
|
default:
|
|
446
452
|
return { ok: false, error: { code: 'INVALID_ARGUMENT', message: `Unknown panel request "${op}".` } };
|
package/bridge-server/probe.js
CHANGED
|
@@ -82,6 +82,61 @@ function helloProbe({ port, token, session, name, timeoutMs = 3000 }) {
|
|
|
82
82
|
});
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Ask the bridge which Chrome profiles are connected (`browserzap doctor`).
|
|
87
|
+
*
|
|
88
|
+
* Connects like helloProbe, then sends the bridge's own `profiles_list`
|
|
89
|
+
* request (answered by server.js with `{ type: 'bridge_reply', id, ok, data }`)
|
|
90
|
+
* and ends its session on close as helloProbe does, so no session lingers.
|
|
91
|
+
* Resolves { ok: true, profiles } or { ok: false, profiles: [] }. Never logs
|
|
92
|
+
* the token.
|
|
93
|
+
*/
|
|
94
|
+
function listProfiles({ port, token, session, name, timeoutMs = 2000 }) {
|
|
95
|
+
const requestId = 'doctor-profiles';
|
|
96
|
+
return new Promise((resolve) => {
|
|
97
|
+
const ws = new WebSocket(`ws://${BIND_HOST}:${port}`);
|
|
98
|
+
let settled = false;
|
|
99
|
+
const finish = (result, { gracefully = false } = {}) => {
|
|
100
|
+
if (settled) return;
|
|
101
|
+
settled = true;
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
try {
|
|
104
|
+
if (gracefully) ws.close();
|
|
105
|
+
else ws.terminate();
|
|
106
|
+
} catch { /* already closed */ }
|
|
107
|
+
resolve(result);
|
|
108
|
+
};
|
|
109
|
+
const timer = setTimeout(() => finish({ ok: false, profiles: [] }), timeoutMs);
|
|
110
|
+
ws.on('open', () => {
|
|
111
|
+
ws.send(JSON.stringify({ type: 'hello', role: 'agent', token: token ?? '', protocolVersion: PROTOCOL_VERSION, session, name }));
|
|
112
|
+
});
|
|
113
|
+
ws.on('message', (data) => {
|
|
114
|
+
let msg;
|
|
115
|
+
try {
|
|
116
|
+
msg = JSON.parse(String(data));
|
|
117
|
+
} catch {
|
|
118
|
+
return; // the timer covers a stuck handshake
|
|
119
|
+
}
|
|
120
|
+
if (msg.type === 'session_started') {
|
|
121
|
+
// Same rule as helloProbe: a probe's session ends with its socket.
|
|
122
|
+
const close = ws.close.bind(ws);
|
|
123
|
+
ws.close = (...closeArgs) => {
|
|
124
|
+
try { ws.send(JSON.stringify({ type: 'session_end', session: msg.session })); } catch { /* closing anyway */ }
|
|
125
|
+
close(...closeArgs);
|
|
126
|
+
};
|
|
127
|
+
ws.send(JSON.stringify({ type: 'profiles_list', id: requestId }));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (msg.type === 'bridge_reply' && msg.id === requestId) {
|
|
131
|
+
const profiles = msg.data?.profiles ?? msg.evidence?.profiles;
|
|
132
|
+
finish(Array.isArray(profiles) ? { ok: true, profiles } : { ok: false, profiles: [] }, { gracefully: true });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
ws.on('close', () => finish({ ok: false, profiles: [] }));
|
|
136
|
+
ws.on('error', () => finish({ ok: false, profiles: [] }));
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
85
140
|
/** The protocol a refusing bridge says it speaks ("Version mismatch: this bridge speaks protocol 2, …"), or null. */
|
|
86
141
|
function bridgeProtocolFrom(reason) {
|
|
87
142
|
const m = /speaks protocol (\d+)/.exec(String(reason || ''));
|
|
@@ -121,4 +176,4 @@ async function stopBridgeOnPort(port) {
|
|
|
121
176
|
return pids;
|
|
122
177
|
}
|
|
123
178
|
|
|
124
|
-
module.exports = { isListening, helloProbe, bridgeProtocolFrom, nodePidsOnPort, stopBridgeOnPort };
|
|
179
|
+
module.exports = { isListening, helloProbe, listProfiles, bridgeProtocolFrom, nodePidsOnPort, stopBridgeOnPort };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stable names for connected browser profiles, kept per extension install id.
|
|
5
|
+
*
|
|
6
|
+
* An unnamed profile gets "Profile N" once and keeps it across reconnects and bridge restarts; the number is
|
|
7
|
+
* never handed to another install. A rename changes only that install. Names live in `profiles.json` in the
|
|
8
|
+
* config folder (no page data, only id → label). Without a file (tests) they live in memory.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
|
|
13
|
+
const MAX_ENTRIES = 50;
|
|
14
|
+
|
|
15
|
+
function createProfileLabels(file = null) {
|
|
16
|
+
/** @type {Record<string, { label: string, custom: boolean, number?: number, seen: number }>} */
|
|
17
|
+
let entries = {};
|
|
18
|
+
if (file) {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
21
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) entries = parsed;
|
|
22
|
+
} catch {
|
|
23
|
+
/* first run or unreadable: start empty */
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function save() {
|
|
28
|
+
const ids = Object.keys(entries);
|
|
29
|
+
if (ids.length > MAX_ENTRIES) {
|
|
30
|
+
ids.sort((a, b) => entries[a].seen - entries[b].seen).slice(0, ids.length - MAX_ENTRIES).forEach((id) => delete entries[id]);
|
|
31
|
+
}
|
|
32
|
+
if (!file) return;
|
|
33
|
+
try {
|
|
34
|
+
fs.writeFileSync(file, JSON.stringify(entries, null, 2), { mode: 0o600 });
|
|
35
|
+
} catch {
|
|
36
|
+
/* names still work for this run */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function nextNumber() {
|
|
41
|
+
let max = 0;
|
|
42
|
+
for (const entry of Object.values(entries)) if (typeof entry.number === 'number' && entry.number > max) max = entry.number;
|
|
43
|
+
return max + 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
/** The name for an install that just connected: what it sent, else what it had, else a new "Profile N". */
|
|
48
|
+
connect(id, sentLabel) {
|
|
49
|
+
const entry = entries[id];
|
|
50
|
+
if (sentLabel) {
|
|
51
|
+
// A named install takes no number: "Profile N" counts unnamed ones. It keeps one it already had.
|
|
52
|
+
entries[id] = { ...entry, label: sentLabel, custom: true, seen: Date.now() };
|
|
53
|
+
} else if (entry) {
|
|
54
|
+
entry.seen = Date.now();
|
|
55
|
+
} else {
|
|
56
|
+
const number = nextNumber();
|
|
57
|
+
entries[id] = { label: `Profile ${number}`, custom: false, number, seen: Date.now() };
|
|
58
|
+
}
|
|
59
|
+
save();
|
|
60
|
+
return entries[id].label;
|
|
61
|
+
},
|
|
62
|
+
/** The person renamed this install; no other entry changes. */
|
|
63
|
+
rename(id, label) {
|
|
64
|
+
entries[id] = { ...entries[id], label, custom: true, seen: Date.now() };
|
|
65
|
+
save();
|
|
66
|
+
return label;
|
|
67
|
+
},
|
|
68
|
+
/** The person cleared the name: back to this install's own "Profile N". */
|
|
69
|
+
reset(id) {
|
|
70
|
+
const number = entries[id]?.number ?? nextNumber();
|
|
71
|
+
entries[id] = { label: `Profile ${number}`, custom: false, number, seen: Date.now() };
|
|
72
|
+
save();
|
|
73
|
+
return entries[id].label;
|
|
74
|
+
},
|
|
75
|
+
get(id) {
|
|
76
|
+
return entries[id]?.label ?? null;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { createProfileLabels };
|
|
@@ -12,7 +12,7 @@ const { LIMITS } = require('./limits');
|
|
|
12
12
|
* step — so every wait and every retry still goes through the normal tool path
|
|
13
13
|
* and extension policy.
|
|
14
14
|
*
|
|
15
|
-
* Design: docs/NEXT_RELEASE_SCOPE_0.6.md (§3 of the 0.5 doc);
|
|
15
|
+
* Design: docs/archive/NEXT_RELEASE_SCOPE_0.6.md (§3 of the 0.5 doc);
|
|
16
16
|
* the wait kinds it relies on: docs/ADR/ADR-007-waiting-and-timeouts.md.
|
|
17
17
|
*
|
|
18
18
|
* Adding a resolver is one entry here plus a fixture that fails without it.
|
package/bridge-server/secret.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The bridge WebSocket is the only network listener in the system. Upstream
|
|
5
5
|
* shipped it bound to 0.0.0.0 with no authentication at all (finding SEC-01 in
|
|
6
|
-
* docs/REPOSITORY_AUDIT.md), which let any local or LAN process drive the
|
|
6
|
+
* docs/archive/REPOSITORY_AUDIT.md), which let any local or LAN process drive the
|
|
7
7
|
* user's logged-in browser.
|
|
8
8
|
*
|
|
9
9
|
* BrowserZap closes that by requiring a shared secret on the first frame of every
|
package/bridge-server/server.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Derived from Open Browser Control (MIT, Copyright (c) 2026 smankoo) at commit
|
|
11
11
|
* 4e6c262. Changes from upstream are marked "// BROWSERZAP:" and are summarised in
|
|
12
|
-
* docs/PHASE_1_REPORT.md:
|
|
12
|
+
* docs/archive/PHASE_1_REPORT.md:
|
|
13
13
|
*
|
|
14
14
|
* - the listener binds 127.0.0.1 only and refuses to run otherwise (SEC-01)
|
|
15
15
|
* - every connection must authenticate with the per-install secret before it
|
|
@@ -36,6 +36,8 @@ const { getOrCreateSecret, secretMatches, secretPath, configDir } = require('./s
|
|
|
36
36
|
const pairing = require('./pairing');
|
|
37
37
|
const { version: BRIDGE_VERSION } = require('../package.json');
|
|
38
38
|
const { createPanelRunner } = require('./panel');
|
|
39
|
+
const feedback = require('./feedback');
|
|
40
|
+
const { createProfileLabels } = require('./profile-labels');
|
|
39
41
|
const { LIMITS } = require('./limits');
|
|
40
42
|
|
|
41
43
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
@@ -74,7 +76,7 @@ const VALID_AGENT_ACTIONS = new Set([
|
|
|
74
76
|
'status', 'inspect', 'navigate', 'click', 'fill', 'select_option', 'wait_for',
|
|
75
77
|
'screenshot', 'press_key', 'scroll', 'pause', 'resume',
|
|
76
78
|
'new_tab', 'list_tabs', 'switch_tab', 'close_tab', 'notify', 'upload',
|
|
77
|
-
'console', 'network',
|
|
79
|
+
'console', 'network', 'say',
|
|
78
80
|
]);
|
|
79
81
|
|
|
80
82
|
const VALID_MESSAGE_TYPES = new Set([
|
|
@@ -108,6 +110,7 @@ function isValidMessage(msg) {
|
|
|
108
110
|
}
|
|
109
111
|
if (msg.type === 'cancel' && (typeof msg.id !== 'string' || !msg.id)) return false;
|
|
110
112
|
if (msg.type === 'session_start' && typeof msg.name !== 'string') return false;
|
|
113
|
+
if (msg.type === 'session_update' && typeof msg.name !== 'string' && typeof msg.listening !== 'boolean' && !Number.isInteger(msg.chatTabId)) return false;
|
|
111
114
|
return true;
|
|
112
115
|
}
|
|
113
116
|
|
|
@@ -137,10 +140,13 @@ function isLoopbackAddress(address) {
|
|
|
137
140
|
* decision, not a convenience.
|
|
138
141
|
* @param {string} [options.secret] Expected pairing secret. Defaults to the on-disk secret.
|
|
139
142
|
* @param {(msg: string) => void} [options.log]
|
|
143
|
+
* @param {object} [options.limits] Test seam only: timer values to use instead of `limits.js`. The E2E
|
|
144
|
+
* harness shortens the session grace period; no shipped build passes this.
|
|
140
145
|
*/
|
|
141
146
|
function createBridgeServer(options = {}) {
|
|
142
147
|
const port = options.port ?? DEFAULT_PORT;
|
|
143
148
|
const host = options.host ?? BIND_HOST;
|
|
149
|
+
const limits = options.limits ?? LIMITS;
|
|
144
150
|
const expectedSecret = options.secret ?? getOrCreateSecret();
|
|
145
151
|
const log = options.log ?? ((msg) => process.stderr.write(`[browserzap-bridge] ${msg}\n`));
|
|
146
152
|
|
|
@@ -157,13 +163,21 @@ function createBridgeServer(options = {}) {
|
|
|
157
163
|
* Connected browser profiles. Every Chrome profile has its own copy of the
|
|
158
164
|
* extension (its own instance id, tabs and logins), and each may connect
|
|
159
165
|
* once (multi-profile, docs/MULTI_PROFILE.md). The same install reconnecting
|
|
160
|
-
* replaces its own socket.
|
|
161
|
-
*
|
|
166
|
+
* replaces its own socket. `extensionVersion` is what the extension reported
|
|
167
|
+
* in its hello (WP5); null when it is older and reports nothing.
|
|
168
|
+
* `extensionId` is the Chrome extension id: two different ids connected at
|
|
169
|
+
* once mean two different builds (store + developer), not one profile each (B-05).
|
|
170
|
+
* @type {Map<string, { ws: WebSocket, browser: string, label: string, connectedAt: number, extensionVersion: string | null, extensionId: string | null }>}
|
|
162
171
|
*/
|
|
163
172
|
const profiles = new Map();
|
|
164
173
|
/** @type {Map<WebSocket, string>} */
|
|
165
174
|
const socketToProfile = new Map();
|
|
166
175
|
let anonymousProfiles = 0;
|
|
176
|
+
// Names per install id, kept on disk so a restart never renumbers profiles (B-05, 0.8 public).
|
|
177
|
+
// Tests that pass their own secret keep names in memory unless they name a file.
|
|
178
|
+
const labels = createProfileLabels(options.profileLabelsFile !== undefined
|
|
179
|
+
? options.profileLabelsFile
|
|
180
|
+
: (options.secret ? null : path.join(configDir(), 'profiles.json')));
|
|
167
181
|
|
|
168
182
|
/**
|
|
169
183
|
* Agent sessions. `profile` is the browser profile the session acts in: set
|
|
@@ -183,8 +197,21 @@ function createBridgeServer(options = {}) {
|
|
|
183
197
|
};
|
|
184
198
|
const profileList = (selected) => [...profiles.entries()].map(([id, p]) => ({
|
|
185
199
|
id, label: p.label, browser: p.browser, connectedAt: new Date(p.connectedAt).toISOString(), selected: id === selected,
|
|
200
|
+
// What the build said in its hello; null for a build older than 0.7.0 / 0.8.0.
|
|
201
|
+
extensionVersion: p.extensionVersion ?? null,
|
|
202
|
+
extensionId: p.extensionId ?? null,
|
|
186
203
|
}));
|
|
187
204
|
|
|
205
|
+
/**
|
|
206
|
+
* The extension view of the connected profiles, sent on every change so each
|
|
207
|
+
* side panel can warn when two different builds run at once (B-05). An older
|
|
208
|
+
* extension ignores the event, which is why it is additive.
|
|
209
|
+
*/
|
|
210
|
+
function broadcastProfiles() {
|
|
211
|
+
const data = { profiles: profileList(null) };
|
|
212
|
+
for (const p of profiles.values()) safeSend(p.ws, { type: 'event', event: 'profiles', data });
|
|
213
|
+
}
|
|
214
|
+
|
|
188
215
|
// The side panel's workflow runner: a built-in session that drives the
|
|
189
216
|
// extension like an agent would, so every step gets the same checks. A run
|
|
190
217
|
// acts in the profile whose side panel started it.
|
|
@@ -196,6 +223,19 @@ function createBridgeServer(options = {}) {
|
|
|
196
223
|
return true;
|
|
197
224
|
},
|
|
198
225
|
toExtension: (msg, profile) => sendTo(profileSocket(profile), msg),
|
|
226
|
+
// Whether a named session still has a live socket: a message addressed to an agent that has gone is stored
|
|
227
|
+
// as "any agent" instead of being unreadable for ever (feedback.releaseOrphans).
|
|
228
|
+
isSessionLive: (id) => {
|
|
229
|
+
const session = agentSessions.get(id);
|
|
230
|
+
return Boolean(session && (session.builtIn || session.ws));
|
|
231
|
+
},
|
|
232
|
+
// A message was stored: tell the agent it is for (or every agent, when unaddressed) so a feedback_wait returns at
|
|
233
|
+
// once. Only a nudge; the agent still reads the batch from the folder, and an older MCP server ignores the event.
|
|
234
|
+
onFeedback: (session) => {
|
|
235
|
+
for (const [id, s] of agentSessions) {
|
|
236
|
+
if (!s.builtIn && s.ws && (!session || id === session)) safeSend(s.ws, { type: 'event', event: 'feedback_new' });
|
|
237
|
+
}
|
|
238
|
+
},
|
|
199
239
|
log: (event, fields) => log(`panel ${event}${fields ? ` ${JSON.stringify(fields)}` : ''}`),
|
|
200
240
|
});
|
|
201
241
|
if (panel) agentSessions.set(panel.session, { ws: panel.socket, name: panel.sessionName, profile: null, builtIn: true });
|
|
@@ -431,9 +471,11 @@ function createBridgeServer(options = {}) {
|
|
|
431
471
|
if (msg.type === 'profile_update') {
|
|
432
472
|
const label = sanitizeSessionName(msg.label);
|
|
433
473
|
const p = profiles.get(fromProfile);
|
|
434
|
-
if (p
|
|
435
|
-
|
|
436
|
-
|
|
474
|
+
if (p) {
|
|
475
|
+
// An empty name gives this install back its own "Profile N"; no other profile changes.
|
|
476
|
+
p.label = label ? labels.rename(fromProfile, label) : labels.reset(fromProfile);
|
|
477
|
+
log(`Profile renamed to "${p.label}"`);
|
|
478
|
+
broadcastProfiles();
|
|
437
479
|
}
|
|
438
480
|
return;
|
|
439
481
|
}
|
|
@@ -467,13 +509,19 @@ function createBridgeServer(options = {}) {
|
|
|
467
509
|
if (msg.type === 'session_update') {
|
|
468
510
|
const session = agentSessions.get(sessionId);
|
|
469
511
|
const name = sanitizeSessionName(msg.name);
|
|
470
|
-
|
|
512
|
+
const listening = typeof msg.listening === 'boolean' ? msg.listening : undefined;
|
|
513
|
+
// The tab a reply belongs to when the agent read a message from a tab no session owned (updateChatTab).
|
|
514
|
+
const chatTabId = Number.isInteger(msg.chatTabId) && msg.chatTabId >= 0 ? msg.chatTabId : undefined;
|
|
515
|
+
if (!session || (!name && listening === undefined && chatTabId === undefined)) return;
|
|
516
|
+
if (name) {
|
|
471
517
|
session.name = name;
|
|
472
|
-
msg.name = name;
|
|
473
518
|
log(`Agent "${session.name}" renamed (session: ${sessionId})`);
|
|
474
|
-
} else {
|
|
475
|
-
return;
|
|
476
519
|
}
|
|
520
|
+
// Only these fields reach the extension.
|
|
521
|
+
for (const key of Object.keys(msg)) if (!['type', 'session'].includes(key)) delete msg[key];
|
|
522
|
+
if (name) msg.name = name;
|
|
523
|
+
if (listening !== undefined) msg.listening = listening;
|
|
524
|
+
if (chatTabId !== undefined) msg.chatTabId = chatTabId;
|
|
477
525
|
}
|
|
478
526
|
// The bridge, not the agent, decides which session a message belongs to.
|
|
479
527
|
msg.session = sessionId;
|
|
@@ -524,6 +572,7 @@ function createBridgeServer(options = {}) {
|
|
|
524
572
|
if (profiles.get(profileId)?.ws === ws) {
|
|
525
573
|
log(`Browser profile "${profiles.get(profileId).label}" disconnected`);
|
|
526
574
|
profiles.delete(profileId);
|
|
575
|
+
broadcastProfiles();
|
|
527
576
|
}
|
|
528
577
|
return;
|
|
529
578
|
}
|
|
@@ -535,11 +584,11 @@ function createBridgeServer(options = {}) {
|
|
|
535
584
|
// The connection dropped without a goodbye: the agent may come back with
|
|
536
585
|
// the same session id (a restart, a blip), so its tabs wait
|
|
537
586
|
// SESSION_GRACE_MS before the session ends (D-38).
|
|
538
|
-
log(`Agent "${session.name}" disconnected (session: ${sessionId}); ending it in ${
|
|
587
|
+
log(`Agent "${session.name}" disconnected (session: ${sessionId}); ending it in ${limits.SESSION_GRACE_MS / 1000} s unless it reconnects`);
|
|
539
588
|
session.ws = null;
|
|
540
589
|
session.graceTimer = setTimeout(() => {
|
|
541
590
|
if (agentSessions.get(sessionId) === session && !session.ws) endAgentSession(sessionId);
|
|
542
|
-
},
|
|
591
|
+
}, limits.SESSION_GRACE_MS);
|
|
543
592
|
}
|
|
544
593
|
});
|
|
545
594
|
|
|
@@ -552,17 +601,31 @@ function createBridgeServer(options = {}) {
|
|
|
552
601
|
// came from auto-pairing or from a pasted token (ADR-009).
|
|
553
602
|
pairing.recordPairing(id);
|
|
554
603
|
const browser = typeof hello.browser === 'string' ? hello.browser : 'unknown';
|
|
604
|
+
// Additive hello field (WP5): a build older than 0.7.0 sends nothing, which
|
|
605
|
+
// is accepted as it always was. A reconnect that omits it keeps the version
|
|
606
|
+
// this profile reported before.
|
|
607
|
+
const extensionVersion = typeof hello.extensionVersion === 'string' && hello.extensionVersion ? hello.extensionVersion.slice(0, 32) : null;
|
|
608
|
+
// Which build this is. Additive: a pre-0.8 extension sends nothing.
|
|
609
|
+
const extensionId = typeof hello.extensionId === 'string' && /^[a-p]{32}$/.test(hello.extensionId) ? hello.extensionId : null;
|
|
555
610
|
const existing = profiles.get(id);
|
|
556
611
|
if (existing && existing.ws !== ws && existing.ws.readyState === WebSocket.OPEN) {
|
|
557
612
|
log('Browser profile reconnected (replacing old connection)');
|
|
558
613
|
existing.ws.close(CLOSE_REPLACED, 'Replaced by new extension connection');
|
|
559
614
|
}
|
|
560
|
-
const label = sanitizeSessionName(hello.label)
|
|
561
|
-
profiles.set(id, {
|
|
615
|
+
const label = labels.connect(id, sanitizeSessionName(hello.label));
|
|
616
|
+
profiles.set(id, {
|
|
617
|
+
ws, browser, label, connectedAt: Date.now(),
|
|
618
|
+
extensionVersion: extensionVersion ?? existing?.extensionVersion ?? null,
|
|
619
|
+
extensionId: extensionId ?? existing?.extensionId ?? null,
|
|
620
|
+
});
|
|
562
621
|
socketToProfile.set(ws, id);
|
|
563
622
|
log(`Browser profile "${label}" connected (${browser}, ${id.slice(0, 8)}); ${profiles.size} connected`);
|
|
564
623
|
|
|
565
|
-
safeSend(ws, {
|
|
624
|
+
safeSend(ws, {
|
|
625
|
+
type: 'event', event: 'connected',
|
|
626
|
+
data: { protocolVersion: PROTOCOL_VERSION, profile: { id, label }, profiles: profiles.size, profilesList: profileList(id) },
|
|
627
|
+
});
|
|
628
|
+
broadcastProfiles();
|
|
566
629
|
|
|
567
630
|
// The built-in panel session exists in every profile; agent sessions only in the one they act in.
|
|
568
631
|
for (const [sessionId, session] of agentSessions) {
|
|
@@ -581,6 +644,9 @@ function createBridgeServer(options = {}) {
|
|
|
581
644
|
agentSessions.delete(sessionId);
|
|
582
645
|
if (session.ws) socketToSession.delete(session.ws);
|
|
583
646
|
log(`Agent "${session.name}" ended (session: ${sessionId})`);
|
|
647
|
+
// Anything the person sent to this agent and it never read is given back to "any agent" (feedback.releaseOrphans).
|
|
648
|
+
const released = feedback.releaseOrphans({ session: sessionId, liveSessionIds: new Set(agentSessions.keys()) });
|
|
649
|
+
if (released) log(`Released ${released} unread message(s) from "${session.name}"`);
|
|
584
650
|
sendTo(profileSocket(session.profile), { type: 'session_end', session: sessionId });
|
|
585
651
|
}
|
|
586
652
|
|
|
@@ -621,6 +687,15 @@ function createBridgeServer(options = {}) {
|
|
|
621
687
|
|
|
622
688
|
// ─── Lifecycle ─────────────────────────────────────────────────────────────
|
|
623
689
|
|
|
690
|
+
// A killed bridge leaves messages addressed to sessions that no longer exist. No agent is live at start, so
|
|
691
|
+
// every undelivered stamp is released and the next agent to ask can read it (feedback.releaseOrphans).
|
|
692
|
+
try {
|
|
693
|
+
const swept = feedback.releaseOrphans({ liveSessionIds: new Set() });
|
|
694
|
+
if (swept) log(`Released ${swept} unread message(s) left by a previous run`);
|
|
695
|
+
} catch (err) {
|
|
696
|
+
log(`Could not release old messages: ${err.message}`);
|
|
697
|
+
}
|
|
698
|
+
|
|
624
699
|
// Resolves once the listener is bound (or fails), so callers/tests do not race.
|
|
625
700
|
const ready = new Promise((resolve, reject) => {
|
|
626
701
|
wss.once('listening', () => {
|