@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
@@ -0,0 +1,245 @@
1
+ /**
2
+ * What the agent may reach in this browser: the user's own settings, enforced HERE.
3
+ *
4
+ * The extension is the one layer of the connection the agent does not run, so the limits live
5
+ * in it and hold whichever bridge is on the other end (the desktop app's, an npm package's, an
6
+ * older one). The relay asks this module before it lists a tab, attaches one, runs a command or
7
+ * forwards an event (relay.js), and the tab group says which tabs are in scope (group.js).
8
+ *
9
+ * - **scope** — `group` (the default): tabs in the mirasim group, tabs the agent opened and
10
+ * the popups of those; `all`: every tab, as before 0.1.3; `ask`: every tab is listed, and the
11
+ * first attach of one outside the group waits for the user's yes in a small window.
12
+ * - **site rules** — a block list, and an optional allow-only list, both of host names that
13
+ * also cover their subdomains. They hold in every scope: a blocked site is neither listed,
14
+ * opened, navigated to nor read, and its cookies and downloads are filtered out.
15
+ * - **pause** — every tab is let go of, and every command but the keepalive and the tab
16
+ * listing is refused until the user resumes (group.js `reevaluate`, relay.js).
17
+ *
18
+ * Settings live in `chrome.storage.sync` (they follow the user's Chrome profile); the pause,
19
+ * the ask-mode grants and the log of refusals live in `chrome.storage.session`, gone with the
20
+ * browser session they describe.
21
+ */
22
+
23
+ export const SETTINGS_KEY = "mirasim-access";
24
+ export const STATE_KEY = "mirasim-access-state";
25
+ /** Set once the user has opened the panel; until then the toolbar shows "new". */
26
+ export const PANEL_SEEN_KEY = "mirasim-panel-seen";
27
+ export const SCOPES = ["group", "all", "ask"];
28
+ export const DEFAULT_SETTINGS = Object.freeze({ scope: "group", blocked: [], allowOnly: false, allowed: [] });
29
+ /** How many refusals the panel shows. */
30
+ const LOG_LIMIT = 20;
31
+ /** The same refusal again within this window is one entry, not a flood (a paused agent retries). */
32
+ const LOG_DEDUPE_MS = 2_000;
33
+
34
+ /**
35
+ * A pattern as the user typed it, reduced to a host name: `https://www.Bank.com/login` and
36
+ * `*.bank.com` both become what they mean (`www.bank.com`, `bank.com`). Empty for nonsense.
37
+ */
38
+ export function normalizePattern(text) {
39
+ let s = String(text ?? "").trim().toLowerCase();
40
+ if (!s) return "";
41
+ s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
42
+ s = s.split(/[/?#]/)[0];
43
+ s = s.replace(/^.*@/, "").replace(/:\d+$/, "");
44
+ s = s.replace(/^(\*\.)+/, "").replace(/^\.+/, "").replace(/\.+$/, "");
45
+ return /^[a-z0-9.-]+$/.test(s) || /^\[[0-9a-f:]+\]$/.test(s) ? s : "";
46
+ }
47
+
48
+ /** `host` is `pattern` or one of its subdomains. */
49
+ export function hostMatches(host, pattern) {
50
+ return host === pattern || host.endsWith(`.${pattern}`);
51
+ }
52
+
53
+ /**
54
+ * The host a URL loads from, or null for one that loads nothing from the web (about:blank,
55
+ * data:, the browser's own pages). A blob: URL is its origin's.
56
+ */
57
+ export function hostOf(url) {
58
+ if (!url) return null;
59
+ try {
60
+ let u = new URL(url);
61
+ if (u.protocol === "blob:") u = new URL(u.pathname);
62
+ if (!/^(https?|wss?|ftp):$/.test(u.protocol)) return null;
63
+ return u.hostname.toLowerCase();
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ function cleanList(list) {
70
+ return [...new Set((Array.isArray(list) ? list : []).map(normalizePattern).filter(Boolean))];
71
+ }
72
+
73
+ /** Settings as stored, made whole: unknown fields dropped, every list normalized. */
74
+ export function cleanSettings(raw) {
75
+ const s = raw && typeof raw === "object" ? raw : {};
76
+ return {
77
+ scope: SCOPES.includes(s.scope) ? s.scope : DEFAULT_SETTINGS.scope,
78
+ blocked: cleanList(s.blocked),
79
+ allowOnly: s.allowOnly === true,
80
+ allowed: cleanList(s.allowed),
81
+ };
82
+ }
83
+
84
+ export class AccessPolicy {
85
+ constructor() {
86
+ this.settings = cleanSettings(DEFAULT_SETTINGS);
87
+ this.paused = false;
88
+ /** Ask mode: tabs the user said yes to, until they close. */
89
+ this.grants = new Set();
90
+ /** @type {Array<{at: number, reason: string, tabId?: number, url?: string, detail?: string}>} */
91
+ this.log = [];
92
+ /** @type {Set<() => void>} */
93
+ this._listeners = new Set();
94
+ chrome.storage.onChanged.addListener((changes, area) => {
95
+ if (area === "sync" && changes[SETTINGS_KEY]) {
96
+ this.settings = cleanSettings(changes[SETTINGS_KEY].newValue);
97
+ this._emit();
98
+ } else if (area === "session" && changes[STATE_KEY]) {
99
+ const before = this.paused;
100
+ this._adoptState(changes[STATE_KEY].newValue);
101
+ if (before !== this.paused) this._emit();
102
+ }
103
+ });
104
+ this.ready = this._load();
105
+ }
106
+
107
+ async _load() {
108
+ const [sync, session] = await Promise.all([
109
+ chrome.storage.sync.get(SETTINGS_KEY).catch(() => ({})),
110
+ chrome.storage.session.get(STATE_KEY).catch(() => ({})),
111
+ ]);
112
+ this.settings = cleanSettings(sync?.[SETTINGS_KEY]);
113
+ this._adoptState(session?.[STATE_KEY]);
114
+ }
115
+
116
+ _adoptState(state) {
117
+ this.paused = state?.paused === true;
118
+ this.grants = new Set(Array.isArray(state?.grants) ? state.grants.filter(Number.isInteger) : []);
119
+ this.log = Array.isArray(state?.log) ? state.log.slice(-LOG_LIMIT) : [];
120
+ }
121
+
122
+ _saveState() {
123
+ return chrome.storage.session
124
+ .set({ [STATE_KEY]: { paused: this.paused, grants: [...this.grants], log: this.log } })
125
+ .catch(() => {});
126
+ }
127
+
128
+ /** Called whenever what is reachable may have changed: settings, or the pause. */
129
+ onChange(listener) {
130
+ this._listeners.add(listener);
131
+ return () => this._listeners.delete(listener);
132
+ }
133
+
134
+ _emit() {
135
+ for (const listener of this._listeners) {
136
+ try {
137
+ listener();
138
+ } catch {
139
+ /* one listener's failure is not the others' */
140
+ }
141
+ }
142
+ }
143
+
144
+ async saveSettings(next) {
145
+ this.settings = cleanSettings(next);
146
+ await chrome.storage.sync.set({ [SETTINGS_KEY]: this.settings });
147
+ this._emit();
148
+ return this.settings;
149
+ }
150
+
151
+ async setPaused(paused) {
152
+ this.paused = paused === true;
153
+ await this._saveState();
154
+ this._emit();
155
+ }
156
+
157
+ grant(tabId) {
158
+ this.grants.add(tabId);
159
+ void this._saveState();
160
+ }
161
+
162
+ forget(tabId) {
163
+ if (this.grants.delete(tabId)) void this._saveState();
164
+ }
165
+
166
+ /** Anything stricter than "every tab, every site"? */
167
+ restrictive() {
168
+ const s = this.settings;
169
+ return s.scope !== "all" || s.blocked.length > 0 || s.allowOnly;
170
+ }
171
+
172
+ /**
173
+ * Why a URL is off limits, or null when it is not: `site-blocked` for the block list,
174
+ * `site-not-allowed` for a host outside the allow-only list. A URL that loads nothing from
175
+ * the web (about:blank, data:) is never refused here.
176
+ */
177
+ siteVerdict(url) {
178
+ const host = hostOf(url);
179
+ if (host === null) return null;
180
+ const { blocked, allowOnly, allowed } = this.settings;
181
+ if (blocked.some((p) => hostMatches(host, p))) return "site-blocked";
182
+ if (allowOnly && !allowed.some((p) => hostMatches(host, p))) return "site-not-allowed";
183
+ return null;
184
+ }
185
+
186
+ /**
187
+ * May the agent see this cookie? A cookie set for a parent domain is sent to the blocked
188
+ * subdomain too, so `.bank.com` is withheld when `online.bank.com` is blocked; on an allow list,
189
+ * a cookie counts when it is sent to some allowed host.
190
+ */
191
+ cookieAllowed(domain) {
192
+ const d = String(domain ?? "").replace(/^\./, "").toLowerCase();
193
+ if (!d) return true;
194
+ const related = (p) => hostMatches(d, p) || hostMatches(p, d);
195
+ const { blocked, allowOnly, allowed } = this.settings;
196
+ if (blocked.some(related)) return false;
197
+ if (allowOnly && !allowed.some(related)) return false;
198
+ return true;
199
+ }
200
+
201
+ record(entry) {
202
+ const last = this.log[this.log.length - 1];
203
+ const now = Date.now();
204
+ if (last && last.reason === entry.reason && last.tabId === entry.tabId && last.url === entry.url && now - last.at < LOG_DEDUPE_MS) return;
205
+ this.log.push({ at: now, ...entry });
206
+ if (this.log.length > LOG_LIMIT) this.log.splice(0, this.log.length - LOG_LIMIT);
207
+ void this._saveState();
208
+ }
209
+ }
210
+
211
+ /**
212
+ * A refusal the bridge passes on to the agent as the command's error: what was refused and
213
+ * what the agent can do about it, in words a model relays to the person.
214
+ */
215
+ export function refusal(reason, { tabId, url } = {}) {
216
+ const host = hostOf(url) ?? url ?? "this site";
217
+ switch (reason) {
218
+ case "site-blocked":
219
+ return `${host} is blocked by the user's Mirasim site rules; the agent cannot open or read it.`;
220
+ case "site-not-allowed":
221
+ return `${host} is not on the user's Mirasim allow list; the agent can only use the sites on it.`;
222
+ case "out-of-scope":
223
+ return `Tab ${tabId} is not one the user handed to the agent. Ask them to click the Mirasim toolbar icon on that tab to hand it over, or open a tab of your own.`;
224
+ case "ask-pending":
225
+ return `Waiting for the user to allow tab ${tabId} in the Mirasim prompt; try again once they answer.`;
226
+ case "declined":
227
+ return `The user declined to let the agent use tab ${tabId}.`;
228
+ case "paused":
229
+ return "The user paused the agent from the Mirasim extension; wait until they resume it.";
230
+ case "clear-cookies":
231
+ return "Clearing every browser cookie is refused while the user limits what the agent can reach.";
232
+ case "cookie-site":
233
+ return `Cookies of ${host} are off limits under the user's Mirasim site rules.`;
234
+ default:
235
+ return "Refused by the user's Mirasim access settings.";
236
+ }
237
+ }
238
+
239
+ export class AccessError extends Error {
240
+ constructor(reason, details = {}) {
241
+ super(refusal(reason, details));
242
+ this.reason = reason;
243
+ this.details = details;
244
+ }
245
+ }
@@ -0,0 +1,26 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Mirasim</title>
6
+ <link rel="stylesheet" href="panel.css" />
7
+ </head>
8
+ <body class="ask">
9
+ <main>
10
+ <header>
11
+ <img src="icons/icon-32.png" alt="" width="24" height="24" />
12
+ <div class="brand"><strong data-i18n="askTitle"></strong></div>
13
+ </header>
14
+ <div class="card">
15
+ <div id="tab-title" class="tab-title"></div>
16
+ <div id="tab-host" class="tab-host"></div>
17
+ </div>
18
+ <p class="quiet" data-i18n="askBody"></p>
19
+ <div class="row">
20
+ <button id="allow" type="button" data-i18n="allow"></button>
21
+ <button id="deny" type="button" class="ghost" data-i18n="deny"></button>
22
+ </div>
23
+ </main>
24
+ <script src="ask.js" type="module"></script>
25
+ </body>
26
+ </html>
@@ -0,0 +1,20 @@
1
+ /** The ask window (`ask` scope, group.js `_ask`): may the agent use this tab? */
2
+
3
+ const t = (key) => chrome.i18n.getMessage(key) || key;
4
+ for (const el of document.querySelectorAll("[data-i18n]")) el.textContent = t(el.dataset.i18n);
5
+
6
+ const tabId = Number(new URLSearchParams(location.search).get("tab"));
7
+ const tab = await chrome.tabs.get(tabId).catch(() => null);
8
+ if (!tab) {
9
+ document.getElementById("tab-title").textContent = t("askGone");
10
+ document.getElementById("allow").disabled = true;
11
+ } else {
12
+ document.getElementById("tab-title").textContent = tab.title || tab.url;
13
+ document.getElementById("tab-host").textContent = tab.url;
14
+ }
15
+
16
+ function answer(allow) {
17
+ chrome.runtime.sendMessage({ type: "access.answer", tabId, allow }, () => window.close());
18
+ }
19
+ document.getElementById("allow").addEventListener("click", () => answer(true));
20
+ document.getElementById("deny").addEventListener("click", () => answer(false));
@@ -19,8 +19,10 @@
19
19
  */
20
20
 
21
21
  import { RelayConnection, debugLog } from "./relay.js";
22
- import { MirasimTabGroup } from "./group.js";
22
+ import { MirasimTabGroup, addToMirasimGroup } from "./group.js";
23
23
  import { nativeTransport, webSocketTransport } from "./transport.js";
24
+ import { AccessPolicy, PANEL_SEEN_KEY } from "./access.js";
25
+ import { Toolbar } from "./toolbar.js";
24
26
 
25
27
  const CONNECT_TIMEOUT_MS = 10_000;
26
28
  /** How long the native probe waits for `pong` before calling the route dead. */
@@ -65,6 +67,9 @@ class BridgeExtension {
65
67
  this._timer = null;
66
68
  this._lastError = null;
67
69
  this._updateVersion = null;
70
+ /** The user's access settings, held by every connection (access.js). */
71
+ this._policy = new AccessPolicy();
72
+ this._toolbar = new Toolbar(this._policy, () => this._activeGroup);
68
73
  chrome.runtime.onUpdateAvailable.addListener(({ version }) => {
69
74
  this._updateVersion = version;
70
75
  if (this._activeGroup) this._activeGroup.updateAvailable(version);
@@ -91,7 +96,14 @@ class BridgeExtension {
91
96
  });
92
97
  return false;
93
98
  default:
94
- return false;
99
+ // The panel and the ask window: this extension's own pages, nothing else.
100
+ if (sender?.id !== chrome.runtime.id || !sender.url?.startsWith(chrome.runtime.getURL(""))) return false;
101
+ if (!String(message?.type).startsWith("panel.") && message?.type !== "access.answer") return false;
102
+ this._onPageMessage(message).then(
103
+ (result) => sendResponse({ ok: true, result }),
104
+ (e) => sendResponse({ ok: false, error: e?.message ?? String(e) }),
105
+ );
106
+ return true;
95
107
  }
96
108
  });
97
109
 
@@ -109,6 +121,67 @@ class BridgeExtension {
109
121
  void this._connectNative();
110
122
  }
111
123
 
124
+ // ─── the panel and the ask window ────────────────────────────────────────
125
+
126
+ async _onPageMessage(message) {
127
+ await this._policy.ready;
128
+ const group = this._activeGroup;
129
+ switch (message.type) {
130
+ case "panel.state":
131
+ return await this._panelState(message.tabId);
132
+ case "panel.seen":
133
+ await chrome.storage.local.set({ [PANEL_SEEN_KEY]: true });
134
+ return {};
135
+ case "panel.settings":
136
+ return await this._policy.saveSettings(message.settings);
137
+ case "panel.pause":
138
+ await this._policy.setPaused(message.paused === true);
139
+ return {};
140
+ case "panel.handOver":
141
+ await addToMirasimGroup(message.tabId);
142
+ await this._toolbar.refresh(message.tabId);
143
+ return {};
144
+ case "panel.takeBack":
145
+ if (group) await group.takeBack(message.tabId);
146
+ else await chrome.tabs.ungroup(message.tabId).catch(() => {});
147
+ await this._toolbar.refresh(message.tabId);
148
+ return {};
149
+ case "panel.focusTab": {
150
+ const tab = await chrome.tabs.update(message.tabId, { active: true });
151
+ if (tab?.windowId !== undefined) await chrome.windows.update(tab.windowId, { focused: true });
152
+ return {};
153
+ }
154
+ case "access.answer":
155
+ group?.answerAsk(message.tabId, message.allow === true);
156
+ return {};
157
+ default:
158
+ throw new Error(`Unknown message: ${message.type}`);
159
+ }
160
+ }
161
+
162
+ /** Everything the panel shows, in one answer. */
163
+ async _panelState(tabId) {
164
+ const group = this._activeGroup;
165
+ const tab = Number.isInteger(tabId) ? await chrome.tabs.get(tabId).catch(() => null) : null;
166
+ const using = [];
167
+ for (const id of group?.connectedTabIds() ?? []) {
168
+ const t = await chrome.tabs.get(id).catch(() => null);
169
+ if (t) using.push({ id, title: t.title ?? "", url: t.url ?? "" });
170
+ }
171
+ return {
172
+ connected: !!group,
173
+ transport: this._transport,
174
+ error: group ? null : this._lastError,
175
+ extension: chrome.runtime.getManifest().version,
176
+ bridge: group?.bridgeInfo ?? null,
177
+ paused: this._policy.paused,
178
+ settings: this._policy.settings,
179
+ tab: tab ? { id: tab.id, title: tab.title ?? "", url: tab.url ?? "", ...(await this._toolbar.stateOf(tab)) } : null,
180
+ using,
181
+ log: this._policy.log,
182
+ };
183
+ }
184
+
112
185
  // ─── native messaging ────────────────────────────────────────────────────
113
186
 
114
187
  /** Try each host in turn; keep the first that answers `pong`. */
@@ -257,11 +330,13 @@ class BridgeExtension {
257
330
  async _adopt(connection, transport) {
258
331
  this._activeGroup?.close("Replaced by a new bridge connection");
259
332
  this._activeGroup = null;
260
- const group = new MirasimTabGroup(connection);
333
+ const group = new MirasimTabGroup(connection, this._policy);
334
+ group.ontabstate = (tabId) => void this._toolbar.refresh(tabId);
261
335
  group.onclose = () => {
262
336
  if (this._activeGroup !== group) return;
263
337
  this._activeGroup = null;
264
338
  this._transport = null;
339
+ void this._toolbar.refreshAll();
265
340
  // Native reconnects on its own; the ws path needs a new hand-over.
266
341
  if (transport === "native") this._scheduleRetry();
267
342
  };
@@ -273,6 +348,7 @@ class BridgeExtension {
273
348
  // Armed WHILE connected, deliberately — see HEARTBEAT_MINUTES.
274
349
  this._armAlarm(HEARTBEAT_MINUTES);
275
350
  await group.ready;
351
+ void this._toolbar.refreshAll();
276
352
  if (this._updateVersion) group.updateAvailable(this._updateVersion);
277
353
  }
278
354
  }