@mirasim/computer-use 0.1.0-rc.5 → 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.
- package/README.md +20 -1
- package/dist/platform/browser/bridge/client.d.ts +2 -0
- package/dist/platform/browser/bridge/client.js +1 -1
- package/dist/platform/browser/bridge/model-commands.d.ts +1 -1
- package/dist/platform/browser/bridge/model-commands.js +1 -1
- package/dist/platform/browser/bridge/model-core.d.ts +15 -1
- 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 +12 -1
- package/dist/platform/browser/bridge-tabs.js +1 -1
- package/dist/platform/browser/cdp-connection.js +1 -1
- package/dist/platform/browser/driver-verbs.d.ts +1 -0
- package/dist/platform/browser/driver-verbs.js +2 -2
- package/dist/platform/browser/driver.d.ts +13 -1
- 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/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/chromium/scroll.js +5 -5
- package/dist/platform/driver-base.d.ts +1 -0
- package/dist/platform/driver-base.js +1 -1
- package/dist/platform/driver.d.ts +1 -0
- package/dist/platform/driver.js +4 -4
- 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 +17 -17
- 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 +347 -80
- 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 +268 -4
- 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
|
/**
|
|
@@ -70,36 +134,225 @@ export class MirasimTabGroup {
|
|
|
70
134
|
* (the only record of ownership), and with no current window throw "No current window".
|
|
71
135
|
*/
|
|
72
136
|
this._userTabIds = new Set();
|
|
137
|
+
/**
|
|
138
|
+
* Tabs the BRIDGE created (relay.js `ontabcreatedforagent`): the agent's, whatever the race
|
|
139
|
+
* below decided. A new tab's first `onUpdated` can arrive before the bridge has attached it,
|
|
140
|
+
* and `_onTabUpdated` would then file it as the user's and keep it out of the group.
|
|
141
|
+
*/
|
|
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();
|
|
73
148
|
this._onTabCreatedListener = this._onTabCreated.bind(this);
|
|
74
149
|
this._onTabUpdatedListener = this._onTabUpdated.bind(this);
|
|
75
150
|
this._onTabRemovedListener = this._onTabRemoved.bind(this);
|
|
76
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
|
+
};
|
|
77
157
|
|
|
78
158
|
/** @type {(() => void) | undefined} */
|
|
79
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;
|
|
80
163
|
|
|
164
|
+
connection.gate = this;
|
|
81
165
|
this._connection.onclose = () => this._onConnectionClose();
|
|
82
166
|
this._connection.ontabattached = (tabId) => this._onTabAttached(tabId);
|
|
83
|
-
this._connection.ontabdetached = (tabId) => this.
|
|
167
|
+
this._connection.ontabdetached = (tabId) => this.ontabstate?.(tabId);
|
|
168
|
+
this._connection.ontabrevoked = (tabId) => this.ontabstate?.(tabId);
|
|
169
|
+
this._connection.ontabcreatedforagent = (tabId) => {
|
|
170
|
+
this._agentCreatedTabIds.add(tabId);
|
|
171
|
+
this._userTabIds.delete(tabId);
|
|
172
|
+
};
|
|
173
|
+
this._unwatchPolicy = policy.onChange(() => void this.reevaluate());
|
|
84
174
|
chrome.tabs.onCreated.addListener(this._onTabCreatedListener);
|
|
85
175
|
chrome.tabs.onUpdated.addListener(this._onTabUpdatedListener);
|
|
86
176
|
chrome.tabs.onRemoved.addListener(this._onTabRemovedListener);
|
|
87
177
|
chrome.tabs.onReplaced.addListener(this._onTabReplacedListener);
|
|
178
|
+
chrome.tabGroups.onUpdated.addListener(this._onGroupUpdatedListener);
|
|
179
|
+
chrome.tabGroups.onRemoved.addListener(this._onGroupRemovedListener);
|
|
88
180
|
|
|
89
181
|
/** Resolves once the initial handshake (adopt + initialized) is done. */
|
|
90
182
|
this.ready = this._initialize();
|
|
91
183
|
}
|
|
92
184
|
|
|
93
185
|
connectedTabIds() {
|
|
94
|
-
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;
|
|
95
200
|
}
|
|
96
201
|
|
|
97
202
|
close(reason) {
|
|
98
203
|
this._connection.close(reason);
|
|
99
204
|
}
|
|
100
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
|
+
|
|
252
|
+
/**
|
|
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
|
+
|
|
101
354
|
/**
|
|
102
|
-
* Adopt the existing mirasim
|
|
355
|
+
* Adopt the existing mirasim groups' tabs, and open NOTHING: a connect is not a request for a
|
|
103
356
|
* page (the driver binds a listed tab or opens its own, platform/browser/tabs.ts
|
|
104
357
|
* `claimSessionTab`), and a seeded blank tab would later count as the user's, unclosable. The
|
|
105
358
|
* group comes back with the agent's first tab (`_addTabToGroup`). Ends with `didInitialize`,
|
|
@@ -109,18 +362,22 @@ export class MirasimTabGroup {
|
|
|
109
362
|
// Identity BEFORE any tab: the bridge keys what it remembers on the browser session, and a
|
|
110
363
|
// tab announced ahead of the nonce is a tab the bridge forgets (relay.js `didConnect`).
|
|
111
364
|
this._connection.didConnect(await browserSessionId());
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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 });
|
|
116
372
|
for (const tab of tabs) {
|
|
117
373
|
if (tab.id === undefined) continue;
|
|
118
374
|
this._groupTabIds.add(tab.id);
|
|
119
|
-
|
|
375
|
+
this._connection.attachTab(tab);
|
|
120
376
|
}
|
|
121
377
|
}
|
|
122
|
-
// Everything else the browser has open:
|
|
123
|
-
// 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.
|
|
124
381
|
await this._attachEverythingElse();
|
|
125
382
|
this._connection.didInitialize();
|
|
126
383
|
}
|
|
@@ -137,108 +394,134 @@ export class MirasimTabGroup {
|
|
|
137
394
|
}
|
|
138
395
|
|
|
139
396
|
_onTabUpdated(tabId, changeInfo, tab) {
|
|
140
|
-
if (changeInfo.groupId !== undefined) this._onTabGroupChanged(tabId, tab);
|
|
397
|
+
if (changeInfo.groupId !== undefined) void this._onTabGroupChanged(tabId, tab);
|
|
141
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
|
+
}
|
|
142
409
|
if (tab.discarded || tab.status === "unloaded") {
|
|
143
|
-
|
|
410
|
+
this._connection.attachTab(tab);
|
|
144
411
|
return;
|
|
145
412
|
}
|
|
146
|
-
// Chrome resets per-tab badge state on navigation; re-apply it.
|
|
147
|
-
if (this._connection.attachedTabs.has(tabId)) void this._updateBadge(tabId, CONNECTED_BADGE);
|
|
148
413
|
// Any tab, not just the group's: a tab opened after connect becomes a surface on its first
|
|
149
|
-
// navigation. Group membership decides OWNERSHIP, not reachability.
|
|
150
|
-
|
|
151
|
-
if (!this._groupTabIds.has(tabId)) this._userTabIds.add(tabId);
|
|
414
|
+
// navigation where the scope lists it. Group membership decides OWNERSHIP, not reachability.
|
|
415
|
+
if (!this._connection.attachedTabs.has(tabId)) {
|
|
416
|
+
if (!this._groupTabIds.has(tabId) && !this._agentCreatedTabIds.has(tabId)) this._userTabIds.add(tabId);
|
|
152
417
|
this._connection.attachTab(tab);
|
|
153
418
|
}
|
|
154
419
|
}
|
|
155
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
|
+
|
|
156
437
|
/** Single entry point for membership changes, user-dragged or self-inflicted. */
|
|
157
|
-
_onTabGroupChanged(tabId, tab) {
|
|
158
|
-
|
|
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);
|
|
159
444
|
const wasInGroup = this._groupTabIds.has(tabId);
|
|
160
445
|
if (inOurGroup === wasInGroup) return;
|
|
161
446
|
if (inOurGroup) {
|
|
162
|
-
//
|
|
447
|
+
// Handed over: the agent may use it (and in `group` scope, only now sees it at all).
|
|
163
448
|
this._groupTabIds.add(tabId);
|
|
164
449
|
this._userTabIds.delete(tabId);
|
|
165
|
-
|
|
450
|
+
this._declined.delete(tabId);
|
|
451
|
+
this._connection.attachTab(tab);
|
|
452
|
+
this.ontabstate?.(tabId);
|
|
166
453
|
} else {
|
|
167
|
-
// Dragged out:
|
|
168
|
-
this.
|
|
169
|
-
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);
|
|
170
458
|
}
|
|
171
459
|
}
|
|
172
460
|
|
|
173
461
|
_onTabRemoved(tabId) {
|
|
174
462
|
this._groupTabIds.delete(tabId);
|
|
175
463
|
this._userTabIds.delete(tabId);
|
|
464
|
+
this._agentCreatedTabIds.delete(tabId);
|
|
465
|
+
this._inheritedTabIds.delete(tabId);
|
|
466
|
+
this._declined.delete(tabId);
|
|
467
|
+
this.policy.forget(tabId);
|
|
176
468
|
}
|
|
177
469
|
|
|
178
470
|
/** Newer Chrome versions replace the tab id when discarding its renderer. */
|
|
179
471
|
async _onTabReplaced(addedTabId, removedTabId) {
|
|
180
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);
|
|
181
476
|
this._onTabRemoved(removedTabId);
|
|
182
477
|
let tab;
|
|
183
478
|
try { tab = await chrome.tabs.get(addedTabId); } catch { return; }
|
|
184
479
|
this._userTabIds.delete(addedTabId);
|
|
185
480
|
if (grouped) this._groupTabIds.add(addedTabId);
|
|
186
481
|
else this._userTabIds.add(addedTabId);
|
|
187
|
-
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);
|
|
188
486
|
}
|
|
189
487
|
|
|
190
488
|
/**
|
|
191
|
-
*
|
|
192
|
-
* 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`.
|
|
193
491
|
*/
|
|
194
492
|
async _attachEverythingElse() {
|
|
195
493
|
let tabs = [];
|
|
196
494
|
try { tabs = await chrome.tabs.query({}); } catch (e) { debugLog("tab query failed:", e); return; }
|
|
197
495
|
for (const tab of tabs) {
|
|
198
496
|
if (tab.id === undefined || this._connection.attachedTabs.has(tab.id)) continue;
|
|
199
|
-
if (isNonDebuggableUrl(tab.url)) continue;
|
|
200
497
|
if (!this._groupTabIds.has(tab.id)) this._userTabIds.add(tab.id);
|
|
201
498
|
this._connection.attachTab(tab);
|
|
202
499
|
}
|
|
203
500
|
}
|
|
204
501
|
|
|
205
502
|
_onTabAttached(tabId) {
|
|
206
|
-
|
|
503
|
+
this.ontabstate?.(tabId);
|
|
207
504
|
// The user's own tab is attached but NOT grouped: that would relabel it and physically move
|
|
208
505
|
// it in their window.
|
|
209
506
|
if (this._userTabIds.has(tabId)) return;
|
|
210
507
|
void this._addTabToGroup(tabId);
|
|
211
508
|
}
|
|
212
509
|
|
|
213
|
-
/** Detached (drag-out, close, external). Clear the badge; the tab keeps its group
|
|
214
|
-
* membership — the user's intent is still there and a navigation re-attaches. */
|
|
215
|
-
_onTabDetached(tabId) {
|
|
216
|
-
void this._updateBadge(tabId, { text: "" });
|
|
217
|
-
}
|
|
218
|
-
|
|
219
510
|
_onConnectionClose() {
|
|
220
511
|
chrome.tabs.onCreated.removeListener(this._onTabCreatedListener);
|
|
221
512
|
chrome.tabs.onUpdated.removeListener(this._onTabUpdatedListener);
|
|
222
513
|
chrome.tabs.onRemoved.removeListener(this._onTabRemovedListener);
|
|
223
514
|
chrome.tabs.onReplaced.removeListener(this._onTabReplacedListener);
|
|
224
|
-
|
|
225
|
-
|
|
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).
|
|
226
521
|
this._groupTabIds.clear();
|
|
227
522
|
this.onclose?.();
|
|
228
523
|
}
|
|
229
524
|
|
|
230
|
-
async _updateBadge(tabId, { text, color, title }) {
|
|
231
|
-
try {
|
|
232
|
-
await Promise.all([
|
|
233
|
-
chrome.action.setBadgeText({ tabId, text }),
|
|
234
|
-
chrome.action.setTitle({ tabId, title: title || "" }),
|
|
235
|
-
color ? chrome.action.setBadgeBackgroundColor({ tabId, color }) : Promise.resolve(),
|
|
236
|
-
]);
|
|
237
|
-
} catch {
|
|
238
|
-
/* tab already closed */
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
525
|
/** Move an attached tab into the group, creating the group on first use. */
|
|
243
526
|
async _addTabToGroup(tabId) {
|
|
244
527
|
if (this._groupTabIds.has(tabId)) return;
|
|
@@ -247,9 +530,10 @@ export class MirasimTabGroup {
|
|
|
247
530
|
// the group, so a failed join drops the id and retries once via the create branch.
|
|
248
531
|
for (const attempt of [0, 1]) {
|
|
249
532
|
try {
|
|
250
|
-
await
|
|
533
|
+
await retryOnDrag(async () => {
|
|
251
534
|
if (this._groupId === null) {
|
|
252
535
|
this._groupId = await chrome.tabs.group({ tabIds: [tabId] });
|
|
536
|
+
this._groupIds.add(this._groupId);
|
|
253
537
|
await chrome.tabGroups.update(this._groupId, { color: GROUP_COLOR, title: GROUP_TITLE });
|
|
254
538
|
} else {
|
|
255
539
|
await chrome.tabs.group({ groupId: this._groupId, tabIds: [tabId] });
|
|
@@ -258,26 +542,9 @@ export class MirasimTabGroup {
|
|
|
258
542
|
this._groupTabIds.add(tabId);
|
|
259
543
|
return;
|
|
260
544
|
} catch (e) {
|
|
261
|
-
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; }
|
|
262
546
|
debugLog("error adding tab to group:", e);
|
|
263
547
|
}
|
|
264
548
|
}
|
|
265
549
|
}
|
|
266
|
-
|
|
267
|
-
/** Chrome throws "user may be dragging a tab" mid-drag; retry until it clears. */
|
|
268
|
-
async _retryOnDrag(fn) {
|
|
269
|
-
const delays = [0, 100, 200, 400, 800];
|
|
270
|
-
let lastError;
|
|
271
|
-
for (const delay of delays) {
|
|
272
|
-
if (delay) await new Promise((r) => setTimeout(r, delay));
|
|
273
|
-
try {
|
|
274
|
-
await fn();
|
|
275
|
-
return;
|
|
276
|
-
} catch (e) {
|
|
277
|
-
if (!String(e?.message).includes("user may be dragging a tab")) throw e;
|
|
278
|
-
lastError = e;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
throw lastError;
|
|
282
|
-
}
|
|
283
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": {
|