@mirasim/computer-use 0.1.0-rc.6 → 0.1.0-rc.8
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/README.md +3 -1
- package/dist/platform/browser/bridge/model-commands.js +1 -1
- package/dist/platform/browser/bridge/model-core.d.ts +7 -0
- package/dist/platform/browser/bridge/model-core.js +1 -1
- package/dist/platform/browser/bridge/server.js +1 -1
- package/dist/platform/browser/bridge-tabs.d.ts +3 -0
- package/dist/platform/browser/bridge-tabs.js +1 -1
- package/dist/platform/browser/driver-connect.d.ts +1 -0
- package/dist/platform/browser/driver-connect.js +1 -1
- package/dist/platform/browser/driver-verbs.js +2 -2
- package/dist/platform/browser/driver.d.ts +2 -0
- package/dist/platform/browser/driver.js +3 -2
- package/dist/platform/browser/messages.d.ts +6 -0
- package/dist/platform/browser/messages.js +2 -2
- package/dist/platform/browser/state.js +2 -2
- package/dist/platform/browser/tabs.js +2 -2
- package/dist/platform/chromium/actions.js +2 -2
- package/dist/platform/chromium/run-js.d.ts +5 -1
- package/dist/platform/chromium/run-js.js +2 -2
- package/dist/platform/electron/driver.js +1 -1
- package/dist/platform/electron/verbs.js +1 -1
- package/dist/platform/macos/type-verb.js +1 -1
- package/dist/platform/tools/api-contract.d.ts +1 -0
- package/dist/platform/tools/api.js +1 -1
- package/dist/platform/tools/repl-docs.js +1 -1
- package/dist/platform/tools/repl.js +12 -12
- package/extension/_locales/en/messages.json +179 -0
- package/extension/_locales/zh_CN/messages.json +179 -0
- package/extension/access.js +245 -0
- package/extension/ask.html +26 -0
- package/extension/ask.js +20 -0
- package/extension/background.js +79 -3
- package/extension/group.js +335 -79
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-32.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/manifest.json +8 -2
- package/extension/panel.css +86 -0
- package/extension/panel.html +86 -0
- package/extension/panel.js +192 -0
- package/extension/relay.js +280 -5
- package/extension/toolbar.js +172 -0
- package/package.json +5 -5
- package/scripts/extension.mjs +4 -2
package/extension/group.js
CHANGED
|
@@ -2,16 +2,21 @@
|
|
|
2
2
|
* Portions Copyright (c) Microsoft Corporation (Playwright), licensed under the Apache License,
|
|
3
3
|
* Version 2.0; modified by MIRASIM, INC. See LICENSE and NOTICE.
|
|
4
4
|
*
|
|
5
|
-
* The mirasim tab group — which tabs
|
|
5
|
+
* The mirasim tab group — which tabs the agent may use, inside the user's browser.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* Every Chrome tab group titled "mirasim" (one per window at most, by our hand) is the agent's
|
|
8
|
+
* side of the browser:
|
|
9
|
+
* - tabs the bridge creates (Target.createTarget) are pulled into the group;
|
|
10
|
+
* - the user hands a tab over by clicking the toolbar icon on it (toolbar.js) or dragging it
|
|
11
|
+
* in, and takes it back by dragging it out or from the panel — which also detaches it;
|
|
12
|
+
* - the driver closes only tabs its session opened; one the user handed over stays theirs to
|
|
13
|
+
* close (platform/browser/driver-tabs.ts `ownPages`).
|
|
12
14
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
+
* How far beyond the group the agent sees is the user's choice (access.js): in the default
|
|
16
|
+
* `group` scope the group, the agent's own tabs and their popups are ALL it can list or attach;
|
|
17
|
+
* `all` lists every tab as before 0.1.3; `ask` lists every tab and asks before attaching one
|
|
18
|
+
* outside the group. The site rules hold in every scope. This class answers those questions for
|
|
19
|
+
* the relay (`connection.gate`), which refuses the rest.
|
|
15
20
|
*
|
|
16
21
|
* Unlike Playwright's extension (which ungroups on disconnect), the group is DURABLE: tabs and
|
|
17
22
|
* group stay on disconnect, and the next connection adopts whatever is still in it.
|
|
@@ -20,16 +25,28 @@
|
|
|
20
25
|
*/
|
|
21
26
|
|
|
22
27
|
import { debugLog } from "./relay.js";
|
|
28
|
+
import { AccessError } from "./access.js";
|
|
23
29
|
|
|
24
30
|
export const GROUP_TITLE = "mirasim";
|
|
25
31
|
const GROUP_COLOR = "cyan";
|
|
26
32
|
/** Where the browser-session nonce lives — see {@link browserSessionId}. */
|
|
27
33
|
const SESSION_KEY = "mirasim-browser-session";
|
|
28
|
-
const NON_DEBUGGABLE_SCHEMES = ["chrome:", "edge:", "devtools:", "chrome-extension:"];
|
|
29
|
-
|
|
34
|
+
const NON_DEBUGGABLE_SCHEMES = ["chrome:", "edge:", "devtools:", "chrome-extension:", "view-source:"];
|
|
35
|
+
/** Pages Chrome never lets an extension's debugger into, whatever their scheme. */
|
|
36
|
+
const NON_DEBUGGABLE_HOSTS = ["chromewebstore.google.com", "chrome.google.com"];
|
|
37
|
+
/** How long an attach waits for the user's answer in `ask` scope; under the bridge's own 30s
|
|
38
|
+
* command timeout (server-core.ts EXTENSION_COMMAND_TIMEOUT_MS), so the refusal is ours. */
|
|
39
|
+
const ASK_WAIT_MS = 20_000;
|
|
30
40
|
|
|
31
41
|
export function isNonDebuggableUrl(url) {
|
|
32
|
-
|
|
42
|
+
if (!url) return false;
|
|
43
|
+
if (NON_DEBUGGABLE_SCHEMES.some((s) => url.startsWith(s))) return true;
|
|
44
|
+
try {
|
|
45
|
+
const u = new URL(url);
|
|
46
|
+
return NON_DEBUGGABLE_HOSTS.includes(u.hostname) && (u.hostname !== "chrome.google.com" || u.pathname.startsWith("/webstore"));
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
33
50
|
}
|
|
34
51
|
|
|
35
52
|
/**
|
|
@@ -51,6 +68,48 @@ export async function browserSessionId() {
|
|
|
51
68
|
return minted;
|
|
52
69
|
}
|
|
53
70
|
|
|
71
|
+
/** Is this one of ours? Read live, so it works with no connection (the toolbar click). */
|
|
72
|
+
export async function isMirasimGroup(groupId) {
|
|
73
|
+
if (groupId === undefined || groupId < 0) return false;
|
|
74
|
+
const group = await chrome.tabGroups.get(groupId).catch(() => null);
|
|
75
|
+
return group?.title === GROUP_TITLE;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Chrome throws "user may be dragging a tab" mid-drag; retry until it clears. */
|
|
79
|
+
async function retryOnDrag(fn) {
|
|
80
|
+
const delays = [0, 100, 200, 400, 800];
|
|
81
|
+
let lastError;
|
|
82
|
+
for (const delay of delays) {
|
|
83
|
+
if (delay) await new Promise((r) => setTimeout(r, delay));
|
|
84
|
+
try {
|
|
85
|
+
return await fn();
|
|
86
|
+
} catch (e) {
|
|
87
|
+
if (!String(e?.message).includes("user may be dragging a tab")) throw e;
|
|
88
|
+
lastError = e;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
throw lastError;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Hand a tab to the agent: into the mirasim group of ITS window, created there if missing — a
|
|
96
|
+
* group in another window would move the user's tab across windows. Needs no connection: the
|
|
97
|
+
* group is durable and the next connection adopts it.
|
|
98
|
+
*/
|
|
99
|
+
export async function addToMirasimGroup(tabId) {
|
|
100
|
+
const tab = await chrome.tabs.get(tabId);
|
|
101
|
+
if (tab.groupId !== undefined && tab.groupId >= 0 && (await isMirasimGroup(tab.groupId))) return;
|
|
102
|
+
const [existing] = await chrome.tabGroups.query({ title: GROUP_TITLE, windowId: tab.windowId });
|
|
103
|
+
await retryOnDrag(async () => {
|
|
104
|
+
if (existing) {
|
|
105
|
+
await chrome.tabs.group({ groupId: existing.id, tabIds: [tabId] });
|
|
106
|
+
} else {
|
|
107
|
+
const groupId = await chrome.tabs.group({ tabIds: [tabId], createProperties: { windowId: tab.windowId } });
|
|
108
|
+
await chrome.tabGroups.update(groupId, { color: GROUP_COLOR, title: GROUP_TITLE });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
54
113
|
export class MirasimTabGroup {
|
|
55
114
|
updateAvailable(version) {
|
|
56
115
|
this._connection.updateAvailable(version);
|
|
@@ -58,10 +117,15 @@ export class MirasimTabGroup {
|
|
|
58
117
|
|
|
59
118
|
/**
|
|
60
119
|
* @param {import("./relay.js").RelayConnection} connection
|
|
120
|
+
* @param {import("./access.js").AccessPolicy} policy
|
|
61
121
|
*/
|
|
62
|
-
constructor(connection) {
|
|
122
|
+
constructor(connection, policy) {
|
|
63
123
|
this._connection = connection;
|
|
124
|
+
this.policy = policy;
|
|
125
|
+
/** The group the agent's own new tabs join (the first mirasim group, else one we create). */
|
|
64
126
|
this._groupId = null;
|
|
127
|
+
/** Every mirasim group, in any window: membership in any of them is a hand-over. */
|
|
128
|
+
this._groupIds = new Set();
|
|
65
129
|
/** Cached group membership so hot-path checks stay synchronous. */
|
|
66
130
|
this._groupTabIds = new Set();
|
|
67
131
|
/**
|
|
@@ -76,40 +140,219 @@ export class MirasimTabGroup {
|
|
|
76
140
|
* and `_onTabUpdated` would then file it as the user's and keep it out of the group.
|
|
77
141
|
*/
|
|
78
142
|
this._agentCreatedTabIds = new Set();
|
|
143
|
+
/** Popups of tabs in scope (a popup window cannot join a group): in scope as their opener. */
|
|
144
|
+
this._inheritedTabIds = new Set();
|
|
145
|
+
/** Ask scope: the user's open question per tab, and the tabs they said no to. */
|
|
146
|
+
this._asks = new Map();
|
|
147
|
+
this._declined = new Set();
|
|
79
148
|
this._onTabCreatedListener = this._onTabCreated.bind(this);
|
|
80
149
|
this._onTabUpdatedListener = this._onTabUpdated.bind(this);
|
|
81
150
|
this._onTabRemovedListener = this._onTabRemoved.bind(this);
|
|
82
151
|
this._onTabReplacedListener = this._onTabReplaced.bind(this);
|
|
152
|
+
this._onGroupUpdatedListener = this._onGroupUpdated.bind(this);
|
|
153
|
+
this._onGroupRemovedListener = (group) => {
|
|
154
|
+
this._groupIds.delete(group.id);
|
|
155
|
+
if (this._groupId === group.id) this._groupId = null;
|
|
156
|
+
};
|
|
83
157
|
|
|
84
158
|
/** @type {(() => void) | undefined} */
|
|
85
159
|
this.onclose = undefined;
|
|
160
|
+
/** A tab's state for the toolbar changed (attached, detached, joined, left). */
|
|
161
|
+
/** @type {((tabId: number) => void) | undefined} */
|
|
162
|
+
this.ontabstate = undefined;
|
|
86
163
|
|
|
164
|
+
connection.gate = this;
|
|
87
165
|
this._connection.onclose = () => this._onConnectionClose();
|
|
88
166
|
this._connection.ontabattached = (tabId) => this._onTabAttached(tabId);
|
|
89
|
-
this._connection.ontabdetached = (tabId) => this.
|
|
167
|
+
this._connection.ontabdetached = (tabId) => this.ontabstate?.(tabId);
|
|
168
|
+
this._connection.ontabrevoked = (tabId) => this.ontabstate?.(tabId);
|
|
90
169
|
this._connection.ontabcreatedforagent = (tabId) => {
|
|
91
170
|
this._agentCreatedTabIds.add(tabId);
|
|
92
171
|
this._userTabIds.delete(tabId);
|
|
93
172
|
};
|
|
173
|
+
this._unwatchPolicy = policy.onChange(() => void this.reevaluate());
|
|
94
174
|
chrome.tabs.onCreated.addListener(this._onTabCreatedListener);
|
|
95
175
|
chrome.tabs.onUpdated.addListener(this._onTabUpdatedListener);
|
|
96
176
|
chrome.tabs.onRemoved.addListener(this._onTabRemovedListener);
|
|
97
177
|
chrome.tabs.onReplaced.addListener(this._onTabReplacedListener);
|
|
178
|
+
chrome.tabGroups.onUpdated.addListener(this._onGroupUpdatedListener);
|
|
179
|
+
chrome.tabGroups.onRemoved.addListener(this._onGroupRemovedListener);
|
|
98
180
|
|
|
99
181
|
/** Resolves once the initial handshake (adopt + initialized) is done. */
|
|
100
182
|
this.ready = this._initialize();
|
|
101
183
|
}
|
|
102
184
|
|
|
103
185
|
connectedTabIds() {
|
|
104
|
-
return [...this.
|
|
186
|
+
return [...this._connection.attachedTabs];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
isAttached(tabId) {
|
|
190
|
+
return this._connection.attachedTabs.has(tabId);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A tab the agent opened, or a popup of one in scope: the agent's even outside a group. */
|
|
194
|
+
isAgentTab(tabId) {
|
|
195
|
+
return this._agentCreatedTabIds.has(tabId) || this._inheritedTabIds.has(tabId);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
get bridgeInfo() {
|
|
199
|
+
return this._connection.bridgeInfo;
|
|
105
200
|
}
|
|
106
201
|
|
|
107
202
|
close(reason) {
|
|
108
203
|
this._connection.close(reason);
|
|
109
204
|
}
|
|
110
205
|
|
|
206
|
+
// ─── what the agent may reach (relay.js `gate`) ──────────────────────────
|
|
207
|
+
|
|
208
|
+
/** The agent's side of the browser, whatever the scope: the groups, its tabs, their popups. */
|
|
209
|
+
_isHandedOver(tabId) {
|
|
210
|
+
return this._groupTabIds.has(tabId) || this._agentCreatedTabIds.has(tabId) || this._inheritedTabIds.has(tabId);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
_inScope(tab) {
|
|
214
|
+
return this.policy.settings.scope !== "group" || this._isHandedOver(tab.id);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Why the agent may not see this tab, or null when it may. */
|
|
218
|
+
_refusalFor(tab) {
|
|
219
|
+
if (!tab || tab.id === undefined) return "out-of-scope";
|
|
220
|
+
if (isNonDebuggableUrl(tab.url) || isNonDebuggableUrl(tab.pendingUrl)) return "out-of-scope";
|
|
221
|
+
const verdict = this.policy.siteVerdict(tab.url) ?? this.policy.siteVerdict(tab.pendingUrl);
|
|
222
|
+
if (verdict) return verdict;
|
|
223
|
+
return this._inScope(tab) ? null : "out-of-scope";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Listed to the bridge at all? */
|
|
227
|
+
visible(tab) {
|
|
228
|
+
return this._refusalFor(tab) === null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** A popup of a tab the agent drives (relay.js forwards those): in scope as its opener. */
|
|
232
|
+
noteCreated(tab) {
|
|
233
|
+
if (tab?.id === undefined || tab.openerTabId === undefined) return;
|
|
234
|
+
if (this._isHandedOver(tab.openerTabId) || this.policy.grants.has(tab.openerTabId)) this._inheritedTabIds.add(tab.id);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The attach itself: everything `visible` asks, and in `ask` scope the user's yes. */
|
|
238
|
+
async authorizeAttach(tabId) {
|
|
239
|
+
const tab = await chrome.tabs.get(tabId).catch(() => null);
|
|
240
|
+
const reason = this._refusalFor(tab);
|
|
241
|
+
if (reason) {
|
|
242
|
+
this.policy.record({ reason, tabId, url: tab?.url });
|
|
243
|
+
throw new AccessError(reason, { tabId, url: tab?.url });
|
|
244
|
+
}
|
|
245
|
+
if (this.policy.settings.scope !== "ask" || this._isHandedOver(tabId) || this.policy.grants.has(tabId)) return;
|
|
246
|
+
if (this._declined.has(tabId)) throw new AccessError("declined", { tabId });
|
|
247
|
+
const allowed = await this._ask(tab);
|
|
248
|
+
if (allowed === undefined) throw new AccessError("ask-pending", { tabId });
|
|
249
|
+
if (!allowed) throw new AccessError("declined", { tabId });
|
|
250
|
+
}
|
|
251
|
+
|
|
111
252
|
/**
|
|
112
|
-
*
|
|
253
|
+
* Ask the user in a small window whether the agent may use `tab`. Resolves true or false with
|
|
254
|
+
* their answer, or undefined after {@link ASK_WAIT_MS}: the window stays up, and an answer
|
|
255
|
+
* given later still counts for the agent's next try.
|
|
256
|
+
*/
|
|
257
|
+
_ask(tab) {
|
|
258
|
+
let ask = this._asks.get(tab.id);
|
|
259
|
+
if (!ask) {
|
|
260
|
+
ask = { waiters: new Set(), windowId: null };
|
|
261
|
+
this._asks.set(tab.id, ask);
|
|
262
|
+
const url = chrome.runtime.getURL(`ask.html?tab=${tab.id}`);
|
|
263
|
+
chrome.windows
|
|
264
|
+
.create({ url, type: "popup", width: 440, height: 300, focused: true })
|
|
265
|
+
.then((win) => {
|
|
266
|
+
ask.windowId = win?.id ?? null;
|
|
267
|
+
})
|
|
268
|
+
.catch((e) => debugLog("ask window failed:", e));
|
|
269
|
+
} else if (ask.windowId !== null) {
|
|
270
|
+
void chrome.windows.update(ask.windowId, { focused: true }).catch(() => {});
|
|
271
|
+
}
|
|
272
|
+
return new Promise((resolve) => {
|
|
273
|
+
const waiter = (answer) => {
|
|
274
|
+
clearTimeout(timer);
|
|
275
|
+
resolve(answer);
|
|
276
|
+
};
|
|
277
|
+
const timer = setTimeout(() => {
|
|
278
|
+
ask.waiters.delete(waiter);
|
|
279
|
+
this.policy.record({ reason: "ask-pending", tabId: tab.id, url: tab.url });
|
|
280
|
+
resolve(undefined);
|
|
281
|
+
}, ASK_WAIT_MS);
|
|
282
|
+
ask.waiters.add(waiter);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** The ask window's answer (background.js routes it here). */
|
|
287
|
+
answerAsk(tabId, allow) {
|
|
288
|
+
const ask = this._asks.get(tabId);
|
|
289
|
+
this._asks.delete(tabId);
|
|
290
|
+
if (allow) {
|
|
291
|
+
this._declined.delete(tabId);
|
|
292
|
+
this.policy.grant(tabId);
|
|
293
|
+
} else {
|
|
294
|
+
this._declined.add(tabId);
|
|
295
|
+
this.policy.record({ reason: "declined", tabId });
|
|
296
|
+
}
|
|
297
|
+
for (const waiter of ask?.waiters ?? []) waiter(allow);
|
|
298
|
+
if (ask?.windowId !== null && ask?.windowId !== undefined) void chrome.windows.remove(ask.windowId).catch(() => {});
|
|
299
|
+
this.ontabstate?.(tabId);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Settings or the pause changed: take away what is no longer the agent's, announce what now
|
|
304
|
+
* is. Tabs already attached when the user switches to `ask` keep going — the switch is about
|
|
305
|
+
* the next tab, not a reason to cut the agent off mid-task.
|
|
306
|
+
*/
|
|
307
|
+
async reevaluate() {
|
|
308
|
+
this._connection.reportAccess(this.accessSummary());
|
|
309
|
+
// The pause lets go of every tab at once rather than refusing what comes next: a refused
|
|
310
|
+
// evaluate leaves the client's page unusable even after the resume, while a detached tab
|
|
311
|
+
// stays listed and is attached again the next time the agent binds it.
|
|
312
|
+
if (this.policy.paused) for (const tabId of [...this._connection.attachedTabs]) this._connection.revoke(tabId, "paused", { keepListed: true });
|
|
313
|
+
let tabs = [];
|
|
314
|
+
try { tabs = await chrome.tabs.query({}); } catch { return; }
|
|
315
|
+
for (const tab of tabs) {
|
|
316
|
+
if (tab.id === undefined) continue;
|
|
317
|
+
if (this.policy.settings.scope === "ask" && this._connection.attachedTabs.has(tab.id)) this.policy.grant(tab.id);
|
|
318
|
+
const reason = this._refusalFor(tab);
|
|
319
|
+
const known = this._connection.isAnnounced(tab.id) || this._connection.attachedTabs.has(tab.id);
|
|
320
|
+
if (reason && known) this._connection.revoke(tab.id, reason, { url: tab.url });
|
|
321
|
+
else if (!reason && !known) this._connection.attachTab(tab);
|
|
322
|
+
this.ontabstate?.(tab.id);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** What the bridge passes on when it lists tabs (relay.js `reportAccess`). */
|
|
327
|
+
accessSummary() {
|
|
328
|
+
const { scope, blocked, allowOnly } = this.policy.settings;
|
|
329
|
+
return { scope, siteRules: blocked.length > 0 || allowOnly, paused: this.policy.paused };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The panel's "take back": out of the group, out of the agent's hands, detached. */
|
|
333
|
+
async takeBack(tabId) {
|
|
334
|
+
this._agentCreatedTabIds.delete(tabId);
|
|
335
|
+
this._inheritedTabIds.delete(tabId);
|
|
336
|
+
this.policy.forget(tabId);
|
|
337
|
+
const tab = await chrome.tabs.get(tabId).catch(() => null);
|
|
338
|
+
if (tab && this._groupIds.has(tab.groupId)) await retryOnDrag(() => chrome.tabs.ungroup(tabId)).catch((e) => debugLog("ungroup failed:", e));
|
|
339
|
+
this._release(tabId, tab);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** A tab leaves the agent's side: detached now, and unlisted where the scope says so. */
|
|
343
|
+
_release(tabId, tab) {
|
|
344
|
+
this._groupTabIds.delete(tabId);
|
|
345
|
+
this._userTabIds.add(tabId);
|
|
346
|
+
this.policy.forget(tabId);
|
|
347
|
+
if (tab && this._refusalFor(tab)) this._connection.revoke(tabId, this._refusalFor(tab), { url: tab.url });
|
|
348
|
+
else this._connection.detachTab(tabId);
|
|
349
|
+
this.ontabstate?.(tabId);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ─── the group and its tabs ──────────────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Adopt the existing mirasim groups' tabs, and open NOTHING: a connect is not a request for a
|
|
113
356
|
* page (the driver binds a listed tab or opens its own, platform/browser/tabs.ts
|
|
114
357
|
* `claimSessionTab`), and a seeded blank tab would later count as the user's, unclosable. The
|
|
115
358
|
* group comes back with the agent's first tab (`_addTabToGroup`). Ends with `didInitialize`,
|
|
@@ -119,18 +362,22 @@ export class MirasimTabGroup {
|
|
|
119
362
|
// Identity BEFORE any tab: the bridge keys what it remembers on the browser session, and a
|
|
120
363
|
// tab announced ahead of the nonce is a tab the bridge forgets (relay.js `didConnect`).
|
|
121
364
|
this._connection.didConnect(await browserSessionId());
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
365
|
+
await this.policy.ready;
|
|
366
|
+
this._connection.reportAccess(this.accessSummary());
|
|
367
|
+
const groups = await chrome.tabGroups.query({ title: GROUP_TITLE });
|
|
368
|
+
for (const group of groups) {
|
|
369
|
+
this._groupIds.add(group.id);
|
|
370
|
+
this._groupId ??= group.id;
|
|
371
|
+
const tabs = await chrome.tabs.query({ groupId: group.id });
|
|
126
372
|
for (const tab of tabs) {
|
|
127
373
|
if (tab.id === undefined) continue;
|
|
128
374
|
this._groupTabIds.add(tab.id);
|
|
129
|
-
|
|
375
|
+
this._connection.attachTab(tab);
|
|
130
376
|
}
|
|
131
377
|
}
|
|
132
|
-
// Everything else the browser has open:
|
|
133
|
-
// silent by design — an undebuggable page (chrome://, the Web Store) is
|
|
378
|
+
// Everything else the browser has open, where the scope lists it: never grouped. Failures
|
|
379
|
+
// are per tab and silent by design — an undebuggable page (chrome://, the Web Store) is
|
|
380
|
+
// simply not a surface.
|
|
134
381
|
await this._attachEverythingElse();
|
|
135
382
|
this._connection.didInitialize();
|
|
136
383
|
}
|
|
@@ -147,36 +394,67 @@ export class MirasimTabGroup {
|
|
|
147
394
|
}
|
|
148
395
|
|
|
149
396
|
_onTabUpdated(tabId, changeInfo, tab) {
|
|
150
|
-
if (changeInfo.groupId !== undefined) this._onTabGroupChanged(tabId, tab);
|
|
397
|
+
if (changeInfo.groupId !== undefined) void this._onTabGroupChanged(tabId, tab);
|
|
151
398
|
if (changeInfo.url === undefined && changeInfo.discarded === undefined && changeInfo.status === undefined) return;
|
|
399
|
+
// A navigation can take a tab out of reach (a blocked site) or bring it back.
|
|
400
|
+
const reason = this._refusalFor(tab);
|
|
401
|
+
if (reason) {
|
|
402
|
+
if (this._connection.isAnnounced(tabId) || this._connection.attachedTabs.has(tabId)) {
|
|
403
|
+
if (changeInfo.url !== undefined && reason !== "out-of-scope") this.policy.record({ reason, tabId, url: tab.url });
|
|
404
|
+
this._connection.revoke(tabId, reason, { url: tab.url });
|
|
405
|
+
this.ontabstate?.(tabId);
|
|
406
|
+
}
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
152
409
|
if (tab.discarded || tab.status === "unloaded") {
|
|
153
|
-
|
|
410
|
+
this._connection.attachTab(tab);
|
|
154
411
|
return;
|
|
155
412
|
}
|
|
156
|
-
// Chrome resets per-tab badge state on navigation; re-apply it.
|
|
157
|
-
if (this._connection.attachedTabs.has(tabId)) void this._updateBadge(tabId, CONNECTED_BADGE);
|
|
158
413
|
// Any tab, not just the group's: a tab opened after connect becomes a surface on its first
|
|
159
|
-
// navigation. Group membership decides OWNERSHIP, not reachability.
|
|
160
|
-
|
|
414
|
+
// navigation where the scope lists it. Group membership decides OWNERSHIP, not reachability.
|
|
415
|
+
if (!this._connection.attachedTabs.has(tabId)) {
|
|
161
416
|
if (!this._groupTabIds.has(tabId) && !this._agentCreatedTabIds.has(tabId)) this._userTabIds.add(tabId);
|
|
162
417
|
this._connection.attachTab(tab);
|
|
163
418
|
}
|
|
164
419
|
}
|
|
165
420
|
|
|
421
|
+
/** A group got (or lost) the mirasim title: its tabs join (or leave) with it. */
|
|
422
|
+
async _onGroupUpdated(group) {
|
|
423
|
+
const ours = group.title === GROUP_TITLE;
|
|
424
|
+
if (ours === this._groupIds.has(group.id)) return;
|
|
425
|
+
if (ours) {
|
|
426
|
+
this._groupIds.add(group.id);
|
|
427
|
+
// A group the user's click made is where the agent's own new tabs go too, not a second one.
|
|
428
|
+
this._groupId ??= group.id;
|
|
429
|
+
} else {
|
|
430
|
+
this._groupIds.delete(group.id);
|
|
431
|
+
if (this._groupId === group.id) this._groupId = null;
|
|
432
|
+
}
|
|
433
|
+
const tabs = await chrome.tabs.query({ groupId: group.id }).catch(() => []);
|
|
434
|
+
for (const tab of tabs) if (tab.id !== undefined) await this._onTabGroupChanged(tab.id, tab);
|
|
435
|
+
}
|
|
436
|
+
|
|
166
437
|
/** Single entry point for membership changes, user-dragged or self-inflicted. */
|
|
167
|
-
_onTabGroupChanged(tabId, tab) {
|
|
168
|
-
|
|
438
|
+
async _onTabGroupChanged(tabId, tab) {
|
|
439
|
+
if (tab.groupId !== undefined && tab.groupId >= 0 && !this._groupIds.has(tab.groupId) && (await isMirasimGroup(tab.groupId))) {
|
|
440
|
+
this._groupIds.add(tab.groupId);
|
|
441
|
+
this._groupId ??= tab.groupId;
|
|
442
|
+
}
|
|
443
|
+
const inOurGroup = this._groupIds.has(tab.groupId);
|
|
169
444
|
const wasInGroup = this._groupTabIds.has(tabId);
|
|
170
445
|
if (inOurGroup === wasInGroup) return;
|
|
171
446
|
if (inOurGroup) {
|
|
172
|
-
//
|
|
447
|
+
// Handed over: the agent may use it (and in `group` scope, only now sees it at all).
|
|
173
448
|
this._groupTabIds.add(tabId);
|
|
174
449
|
this._userTabIds.delete(tabId);
|
|
175
|
-
|
|
450
|
+
this._declined.delete(tabId);
|
|
451
|
+
this._connection.attachTab(tab);
|
|
452
|
+
this.ontabstate?.(tabId);
|
|
176
453
|
} else {
|
|
177
|
-
// Dragged out:
|
|
178
|
-
this.
|
|
179
|
-
this.
|
|
454
|
+
// Dragged out: taken back. Detached at once, and unlisted unless the scope lists every tab.
|
|
455
|
+
this._agentCreatedTabIds.delete(tabId);
|
|
456
|
+
this._inheritedTabIds.delete(tabId);
|
|
457
|
+
this._release(tabId, tab);
|
|
180
458
|
}
|
|
181
459
|
}
|
|
182
460
|
|
|
@@ -184,72 +462,66 @@ export class MirasimTabGroup {
|
|
|
184
462
|
this._groupTabIds.delete(tabId);
|
|
185
463
|
this._userTabIds.delete(tabId);
|
|
186
464
|
this._agentCreatedTabIds.delete(tabId);
|
|
465
|
+
this._inheritedTabIds.delete(tabId);
|
|
466
|
+
this._declined.delete(tabId);
|
|
467
|
+
this.policy.forget(tabId);
|
|
187
468
|
}
|
|
188
469
|
|
|
189
470
|
/** Newer Chrome versions replace the tab id when discarding its renderer. */
|
|
190
471
|
async _onTabReplaced(addedTabId, removedTabId) {
|
|
191
472
|
const grouped = this._groupTabIds.has(removedTabId);
|
|
473
|
+
const agents = this._agentCreatedTabIds.has(removedTabId);
|
|
474
|
+
const inherited = this._inheritedTabIds.has(removedTabId);
|
|
475
|
+
const granted = this.policy.grants.has(removedTabId);
|
|
192
476
|
this._onTabRemoved(removedTabId);
|
|
193
477
|
let tab;
|
|
194
478
|
try { tab = await chrome.tabs.get(addedTabId); } catch { return; }
|
|
195
479
|
this._userTabIds.delete(addedTabId);
|
|
196
480
|
if (grouped) this._groupTabIds.add(addedTabId);
|
|
197
481
|
else this._userTabIds.add(addedTabId);
|
|
198
|
-
if (
|
|
482
|
+
if (agents) this._agentCreatedTabIds.add(addedTabId);
|
|
483
|
+
if (inherited) this._inheritedTabIds.add(addedTabId);
|
|
484
|
+
if (granted) this.policy.grant(addedTabId);
|
|
485
|
+
this._connection.attachTab(tab);
|
|
199
486
|
}
|
|
200
487
|
|
|
201
488
|
/**
|
|
202
|
-
*
|
|
203
|
-
* arrive through `_onTabUpdated`.
|
|
489
|
+
* Announce every open tab the scope lists and the bridge has not heard of — the connect-time
|
|
490
|
+
* sweep. Tabs opened LATER arrive through `_onTabUpdated`.
|
|
204
491
|
*/
|
|
205
492
|
async _attachEverythingElse() {
|
|
206
493
|
let tabs = [];
|
|
207
494
|
try { tabs = await chrome.tabs.query({}); } catch (e) { debugLog("tab query failed:", e); return; }
|
|
208
495
|
for (const tab of tabs) {
|
|
209
496
|
if (tab.id === undefined || this._connection.attachedTabs.has(tab.id)) continue;
|
|
210
|
-
if (isNonDebuggableUrl(tab.url)) continue;
|
|
211
497
|
if (!this._groupTabIds.has(tab.id)) this._userTabIds.add(tab.id);
|
|
212
498
|
this._connection.attachTab(tab);
|
|
213
499
|
}
|
|
214
500
|
}
|
|
215
501
|
|
|
216
502
|
_onTabAttached(tabId) {
|
|
217
|
-
|
|
503
|
+
this.ontabstate?.(tabId);
|
|
218
504
|
// The user's own tab is attached but NOT grouped: that would relabel it and physically move
|
|
219
505
|
// it in their window.
|
|
220
506
|
if (this._userTabIds.has(tabId)) return;
|
|
221
507
|
void this._addTabToGroup(tabId);
|
|
222
508
|
}
|
|
223
509
|
|
|
224
|
-
/** Detached (drag-out, close, external). Clear the badge; the tab keeps its group
|
|
225
|
-
* membership — the user's intent is still there and a navigation re-attaches. */
|
|
226
|
-
_onTabDetached(tabId) {
|
|
227
|
-
void this._updateBadge(tabId, { text: "" });
|
|
228
|
-
}
|
|
229
|
-
|
|
230
510
|
_onConnectionClose() {
|
|
231
511
|
chrome.tabs.onCreated.removeListener(this._onTabCreatedListener);
|
|
232
512
|
chrome.tabs.onUpdated.removeListener(this._onTabUpdatedListener);
|
|
233
513
|
chrome.tabs.onRemoved.removeListener(this._onTabRemovedListener);
|
|
234
514
|
chrome.tabs.onReplaced.removeListener(this._onTabReplacedListener);
|
|
235
|
-
|
|
236
|
-
|
|
515
|
+
chrome.tabGroups.onUpdated.removeListener(this._onGroupUpdatedListener);
|
|
516
|
+
chrome.tabGroups.onRemoved.removeListener(this._onGroupRemovedListener);
|
|
517
|
+
this._unwatchPolicy();
|
|
518
|
+
for (const ask of this._asks.values()) for (const waiter of ask.waiters) waiter(undefined);
|
|
519
|
+
this._asks.clear();
|
|
520
|
+
// Keep the group and its tabs (the next connection adopts them).
|
|
237
521
|
this._groupTabIds.clear();
|
|
238
522
|
this.onclose?.();
|
|
239
523
|
}
|
|
240
524
|
|
|
241
|
-
async _updateBadge(tabId, { text, color, title }) {
|
|
242
|
-
try {
|
|
243
|
-
await Promise.all([
|
|
244
|
-
chrome.action.setBadgeText({ tabId, text }),
|
|
245
|
-
chrome.action.setTitle({ tabId, title: title || "" }),
|
|
246
|
-
color ? chrome.action.setBadgeBackgroundColor({ tabId, color }) : Promise.resolve(),
|
|
247
|
-
]);
|
|
248
|
-
} catch {
|
|
249
|
-
/* tab already closed */
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
525
|
/** Move an attached tab into the group, creating the group on first use. */
|
|
254
526
|
async _addTabToGroup(tabId) {
|
|
255
527
|
if (this._groupTabIds.has(tabId)) return;
|
|
@@ -258,9 +530,10 @@ export class MirasimTabGroup {
|
|
|
258
530
|
// the group, so a failed join drops the id and retries once via the create branch.
|
|
259
531
|
for (const attempt of [0, 1]) {
|
|
260
532
|
try {
|
|
261
|
-
await
|
|
533
|
+
await retryOnDrag(async () => {
|
|
262
534
|
if (this._groupId === null) {
|
|
263
535
|
this._groupId = await chrome.tabs.group({ tabIds: [tabId] });
|
|
536
|
+
this._groupIds.add(this._groupId);
|
|
264
537
|
await chrome.tabGroups.update(this._groupId, { color: GROUP_COLOR, title: GROUP_TITLE });
|
|
265
538
|
} else {
|
|
266
539
|
await chrome.tabs.group({ groupId: this._groupId, tabIds: [tabId] });
|
|
@@ -269,26 +542,9 @@ export class MirasimTabGroup {
|
|
|
269
542
|
this._groupTabIds.add(tabId);
|
|
270
543
|
return;
|
|
271
544
|
} catch (e) {
|
|
272
|
-
if (attempt === 0 && this._groupId !== null) { this._groupId = null; continue; }
|
|
545
|
+
if (attempt === 0 && this._groupId !== null) { this._groupIds.delete(this._groupId); this._groupId = null; continue; }
|
|
273
546
|
debugLog("error adding tab to group:", e);
|
|
274
547
|
}
|
|
275
548
|
}
|
|
276
549
|
}
|
|
277
|
-
|
|
278
|
-
/** Chrome throws "user may be dragging a tab" mid-drag; retry until it clears. */
|
|
279
|
-
async _retryOnDrag(fn) {
|
|
280
|
-
const delays = [0, 100, 200, 400, 800];
|
|
281
|
-
let lastError;
|
|
282
|
-
for (const delay of delays) {
|
|
283
|
-
if (delay) await new Promise((r) => setTimeout(r, delay));
|
|
284
|
-
try {
|
|
285
|
-
await fn();
|
|
286
|
-
return;
|
|
287
|
-
} catch (e) {
|
|
288
|
-
if (!String(e?.message).includes("user may be dragging a tab")) throw e;
|
|
289
|
-
lastError = e;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
throw lastError;
|
|
293
|
-
}
|
|
294
550
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Mirasim",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.3",
|
|
5
|
+
"default_locale": "en",
|
|
5
6
|
"description": "Connect your browser to Mirasim or the open-source Mirasim Computer Use so your agent can complete web tasks you authorize.",
|
|
6
7
|
"permissions": [
|
|
7
8
|
"debugger",
|
|
@@ -27,7 +28,12 @@
|
|
|
27
28
|
"default_icon": {
|
|
28
29
|
"16": "icons/icon-16.png",
|
|
29
30
|
"32": "icons/icon-32.png"
|
|
30
|
-
}
|
|
31
|
+
},
|
|
32
|
+
"default_popup": "panel.html"
|
|
33
|
+
},
|
|
34
|
+
"options_ui": {
|
|
35
|
+
"page": "panel.html",
|
|
36
|
+
"open_in_tab": true
|
|
31
37
|
},
|
|
32
38
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmKZp4T1ESV0xpq+w0n4wH6tCSpSdXGwUyQCM3vmmsxyX8zn1ITThwNgAzu2AK7J7HO9jhqfjJ5brCoKUui/DDOuPPcGLUWM9zcPNGvsqpCpOONd47MAkQL9Tv2hSotcZv27q9M33I8pUOn5S++g38BQXMZbM1f4ZC/dUAmqIQa4uFc0N7VKbCLt7/CxGl7+d0jrvLc/6cFiWIxDX1d73UoZiTbnUiC879wauYcSBSi21MTtiZyooSh5+6hFykbStxTUV1Pa52KD5VEYDMcQmsJHCXOZxk62Ox6wJ2rtdqkZNs6TpwGK5cfpX8irFoWYTNX0Nfvo8kNTxPZh3fZX4cwIDAQAB",
|
|
33
39
|
"content_security_policy": {
|