@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
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
{
|
|
2
|
+
"tipUsing": {
|
|
3
|
+
"message": "Mirasim Agent 正在使用这个标签页。点击打开面板。"
|
|
4
|
+
},
|
|
5
|
+
"tipHandedOver": {
|
|
6
|
+
"message": "已交给 Mirasim Agent。点击打开面板。"
|
|
7
|
+
},
|
|
8
|
+
"tipJoin": {
|
|
9
|
+
"message": "点击把这个标签页交给 Mirasim Agent。"
|
|
10
|
+
},
|
|
11
|
+
"tipBlocked": {
|
|
12
|
+
"message": "被 Mirasim 站点规则阻止。点击打开面板。"
|
|
13
|
+
},
|
|
14
|
+
"tipRestricted": {
|
|
15
|
+
"message": "Mirasim 无法使用这个页面。点击打开面板。"
|
|
16
|
+
},
|
|
17
|
+
"tipPausedPrefix": {
|
|
18
|
+
"message": "Agent 已暂停。"
|
|
19
|
+
},
|
|
20
|
+
"tipOfflinePrefix": {
|
|
21
|
+
"message": "Mirasim 未连接。"
|
|
22
|
+
},
|
|
23
|
+
"handOverFailed": {
|
|
24
|
+
"message": "没能交出这个标签页:"
|
|
25
|
+
},
|
|
26
|
+
"statusConnected": {
|
|
27
|
+
"message": "已连接"
|
|
28
|
+
},
|
|
29
|
+
"statusOffline": {
|
|
30
|
+
"message": "未连接,启动 Mirasim 后 Agent 才能使用浏览器"
|
|
31
|
+
},
|
|
32
|
+
"pause": {
|
|
33
|
+
"message": "暂停 Agent"
|
|
34
|
+
},
|
|
35
|
+
"resume": {
|
|
36
|
+
"message": "恢复 Agent"
|
|
37
|
+
},
|
|
38
|
+
"pausedNote": {
|
|
39
|
+
"message": "Agent 已暂停:恢复之前,它发来的浏览器命令都会被拒绝。"
|
|
40
|
+
},
|
|
41
|
+
"sectionThisTab": {
|
|
42
|
+
"message": "当前标签页"
|
|
43
|
+
},
|
|
44
|
+
"tabStateNone": {
|
|
45
|
+
"message": "在标签页上点工具栏图标打开面板,才能管理那个标签页。"
|
|
46
|
+
},
|
|
47
|
+
"tabStateRestricted": {
|
|
48
|
+
"message": "浏览器自带的页面无法交给 Mirasim。"
|
|
49
|
+
},
|
|
50
|
+
"tabStateBlocked": {
|
|
51
|
+
"message": "被站点规则阻止,Agent 无法使用。"
|
|
52
|
+
},
|
|
53
|
+
"tabStateUsing": {
|
|
54
|
+
"message": "Agent 正在使用这个标签页。"
|
|
55
|
+
},
|
|
56
|
+
"tabStateHanded": {
|
|
57
|
+
"message": "已交给 Agent,它可以使用这个标签页。"
|
|
58
|
+
},
|
|
59
|
+
"tabStateGranted": {
|
|
60
|
+
"message": "你已允许 Agent 使用这个标签页。"
|
|
61
|
+
},
|
|
62
|
+
"tabStateOutsideAll": {
|
|
63
|
+
"message": "不在 Mirasim 分组里,但当前允许所有标签页,Agent 也能使用。"
|
|
64
|
+
},
|
|
65
|
+
"tabStateOutsideAsk": {
|
|
66
|
+
"message": "未交给 Agent,它使用前会先问你。"
|
|
67
|
+
},
|
|
68
|
+
"tabStateOutside": {
|
|
69
|
+
"message": "未交给 Agent,它看不到这个标签页。"
|
|
70
|
+
},
|
|
71
|
+
"handOver": {
|
|
72
|
+
"message": "交给 Agent"
|
|
73
|
+
},
|
|
74
|
+
"takeBack": {
|
|
75
|
+
"message": "收回"
|
|
76
|
+
},
|
|
77
|
+
"sectionAgentTabs": {
|
|
78
|
+
"message": "Agent 正在使用的标签页"
|
|
79
|
+
},
|
|
80
|
+
"agentTabsNone": {
|
|
81
|
+
"message": "目前没有"
|
|
82
|
+
},
|
|
83
|
+
"sectionScope": {
|
|
84
|
+
"message": "Agent 能访问的范围"
|
|
85
|
+
},
|
|
86
|
+
"scopeGroup": {
|
|
87
|
+
"message": "只限 Mirasim 分组"
|
|
88
|
+
},
|
|
89
|
+
"scopeGroupHint": {
|
|
90
|
+
"message": "你交出的标签页、Agent 自己开的标签页和它们的弹窗。其他标签页它看不到。"
|
|
91
|
+
},
|
|
92
|
+
"scopeAsk": {
|
|
93
|
+
"message": "每次问我"
|
|
94
|
+
},
|
|
95
|
+
"scopeAskHint": {
|
|
96
|
+
"message": "Agent 能看到所有标签页的标题和网址,使用分组外的标签页前先问你。"
|
|
97
|
+
},
|
|
98
|
+
"scopeAll": {
|
|
99
|
+
"message": "所有标签页"
|
|
100
|
+
},
|
|
101
|
+
"scopeAllHint": {
|
|
102
|
+
"message": "Agent 能看到并使用所有标签页。"
|
|
103
|
+
},
|
|
104
|
+
"sectionSites": {
|
|
105
|
+
"message": "站点规则"
|
|
106
|
+
},
|
|
107
|
+
"blockedLabel": {
|
|
108
|
+
"message": "不允许 Agent 使用这些网站"
|
|
109
|
+
},
|
|
110
|
+
"sitesPlaceholder": {
|
|
111
|
+
"message": "每行一个网站,例如 bank.com(包括它的子域名)"
|
|
112
|
+
},
|
|
113
|
+
"allowOnlyLabel": {
|
|
114
|
+
"message": "只允许 Agent 使用以下网站"
|
|
115
|
+
},
|
|
116
|
+
"save": {
|
|
117
|
+
"message": "保存"
|
|
118
|
+
},
|
|
119
|
+
"saved": {
|
|
120
|
+
"message": "已保存"
|
|
121
|
+
},
|
|
122
|
+
"sectionLog": {
|
|
123
|
+
"message": "最近被拦下"
|
|
124
|
+
},
|
|
125
|
+
"logNone": {
|
|
126
|
+
"message": "还没有被拦下的请求"
|
|
127
|
+
},
|
|
128
|
+
"logTab": {
|
|
129
|
+
"message": "标签页 $1"
|
|
130
|
+
},
|
|
131
|
+
"reason_site_blocked": {
|
|
132
|
+
"message": "阻止名单里的网站"
|
|
133
|
+
},
|
|
134
|
+
"reason_site_not_allowed": {
|
|
135
|
+
"message": "不在允许名单里"
|
|
136
|
+
},
|
|
137
|
+
"reason_out_of_scope": {
|
|
138
|
+
"message": "未交出的标签页"
|
|
139
|
+
},
|
|
140
|
+
"reason_ask_pending": {
|
|
141
|
+
"message": "没等到你的确认"
|
|
142
|
+
},
|
|
143
|
+
"reason_declined": {
|
|
144
|
+
"message": "你拒绝了"
|
|
145
|
+
},
|
|
146
|
+
"reason_paused": {
|
|
147
|
+
"message": "Agent 已暂停"
|
|
148
|
+
},
|
|
149
|
+
"reason_clear_cookies": {
|
|
150
|
+
"message": "清空所有 cookie"
|
|
151
|
+
},
|
|
152
|
+
"reason_cookie_site": {
|
|
153
|
+
"message": "受限网站的 cookie"
|
|
154
|
+
},
|
|
155
|
+
"footerExtension": {
|
|
156
|
+
"message": "扩展 $1"
|
|
157
|
+
},
|
|
158
|
+
"footerBridge": {
|
|
159
|
+
"message": "桥接:$1"
|
|
160
|
+
},
|
|
161
|
+
"footerBridgeOld": {
|
|
162
|
+
"message": "桥接:旧版本"
|
|
163
|
+
},
|
|
164
|
+
"askTitle": {
|
|
165
|
+
"message": "允许 Mirasim Agent 使用这个标签页吗?"
|
|
166
|
+
},
|
|
167
|
+
"askBody": {
|
|
168
|
+
"message": "允许后,在这个标签页关闭之前,Agent 都能读取和操作它。"
|
|
169
|
+
},
|
|
170
|
+
"allow": {
|
|
171
|
+
"message": "允许"
|
|
172
|
+
},
|
|
173
|
+
"deny": {
|
|
174
|
+
"message": "不允许"
|
|
175
|
+
},
|
|
176
|
+
"askGone": {
|
|
177
|
+
"message": "这个标签页已经关闭。"
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -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>
|
package/extension/ask.js
ADDED
|
@@ -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));
|
package/extension/background.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|