@mirasim/computer-use 0.1.0-rc.6 → 0.1.0-rc.7

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 (41) hide show
  1. package/README.md +3 -1
  2. package/dist/platform/browser/bridge/model-commands.js +1 -1
  3. package/dist/platform/browser/bridge/model-core.d.ts +7 -0
  4. package/dist/platform/browser/bridge/model-core.js +1 -1
  5. package/dist/platform/browser/bridge/server.js +1 -1
  6. package/dist/platform/browser/bridge-tabs.d.ts +3 -0
  7. package/dist/platform/browser/bridge-tabs.js +1 -1
  8. package/dist/platform/browser/driver-verbs.js +2 -2
  9. package/dist/platform/browser/driver.d.ts +2 -0
  10. package/dist/platform/browser/driver.js +3 -2
  11. package/dist/platform/browser/messages.d.ts +5 -0
  12. package/dist/platform/browser/messages.js +2 -2
  13. package/dist/platform/chromium/actions.js +2 -2
  14. package/dist/platform/chromium/run-js.d.ts +5 -1
  15. package/dist/platform/chromium/run-js.js +2 -2
  16. package/dist/platform/electron/driver.js +1 -1
  17. package/dist/platform/electron/verbs.js +1 -1
  18. package/dist/platform/macos/type-verb.js +1 -1
  19. package/dist/platform/tools/api-contract.d.ts +1 -0
  20. package/dist/platform/tools/api.js +1 -1
  21. package/dist/platform/tools/repl-docs.js +1 -1
  22. package/dist/platform/tools/repl.js +12 -12
  23. package/extension/_locales/en/messages.json +179 -0
  24. package/extension/_locales/zh_CN/messages.json +179 -0
  25. package/extension/access.js +245 -0
  26. package/extension/ask.html +26 -0
  27. package/extension/ask.js +20 -0
  28. package/extension/background.js +79 -3
  29. package/extension/group.js +335 -79
  30. package/extension/icons/icon-128.png +0 -0
  31. package/extension/icons/icon-16.png +0 -0
  32. package/extension/icons/icon-32.png +0 -0
  33. package/extension/icons/icon-48.png +0 -0
  34. package/extension/manifest.json +8 -2
  35. package/extension/panel.css +86 -0
  36. package/extension/panel.html +86 -0
  37. package/extension/panel.js +192 -0
  38. package/extension/relay.js +264 -5
  39. package/extension/toolbar.js +172 -0
  40. package/package.json +5 -5
  41. package/scripts/extension.mjs +4 -2
@@ -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 are the AGENT'S OWN, inside the user's browser.
5
+ * The mirasim tab group — which tabs the agent may use, inside the user's browser.
6
6
  *
7
- * The agent sees every tab; the Chrome tab group titled "mirasim" says whose each tab is:
8
- * - tabs the bridge creates (popups, Target.createTarget) are pulled into the group;
9
- * - a tab outside the group is the user's: attached and actable, but the driver will not close
10
- * or recycle it, and its targets line says `(user's)` (platform/browser/driver.ts
11
- * `listTargets`, types.ts `TargetInfo.own`).
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
- * The group is deliberately NOT a visibility boundary (a task about a page the user already has
14
- * open would need it handed over first): the agent can read anything and close only its own.
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
- const CONNECTED_BADGE = { text: "✓", color: "#0e7490", title: "Mirasim agent is connected to this tab" };
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
- return !!url && NON_DEBUGGABLE_SCHEMES.some((s) => url.startsWith(s));
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._onTabDetached(tabId);
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._groupTabIds];
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
- * Adopt the existing mirasim group's tabs, and open NOTHING: a connect is not a request for a
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
- const existing = (await chrome.tabGroups.query({ title: GROUP_TITLE }))[0];
123
- if (existing) {
124
- this._groupId = existing.id;
125
- const tabs = await chrome.tabs.query({ groupId: existing.id });
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
- if (!isNonDebuggableUrl(tab.url)) this._connection.attachTab(tab);
375
+ this._connection.attachTab(tab);
130
376
  }
131
377
  }
132
- // Everything else the browser has open: attached, never grouped. Failures are per tab and
133
- // silent by design — an undebuggable page (chrome://, the Web Store) is simply not a surface.
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
- if (!isNonDebuggableUrl(tab.url)) this._connection.attachTab(tab);
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
- else if (!isNonDebuggableUrl(tab.url)) {
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
- const inOurGroup = this._groupId !== null && tab.groupId === this._groupId;
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
- // Dragging a tab in grants OWNERSHIP, not access — every tab is reachable already.
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
- if (!isNonDebuggableUrl(tab.url)) this._connection.attachTab(tab);
450
+ this._declined.delete(tabId);
451
+ this._connection.attachTab(tab);
452
+ this.ontabstate?.(tabId);
176
453
  } else {
177
- // Dragged out: theirs again. NOT detached — only the agent's claim is taken back.
178
- this._groupTabIds.delete(tabId);
179
- this._userTabIds.add(tabId);
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 (!isNonDebuggableUrl(tab.url)) this._connection.attachTab(tab);
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
- * Attach every open tab not already attached — the connect-time sweep. Tabs opened LATER
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
- void this._updateBadge(tabId, CONNECTED_BADGE);
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
- // Keep the group and its tabs (the next connection adopts them); only clear the badges.
236
- for (const tabId of this._groupTabIds) void this._updateBadge(tabId, { text: "" });
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 this._retryOnDrag(async () => {
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
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Mirasim",
4
- "version": "0.1.0",
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": {