@evan_zhong/browser-attach 0.16.0 → 0.17.2
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 +5 -5
- package/README.zh.md +5 -5
- package/bin/browser-attach.mjs +3 -3
- package/docs/AGENT-INTEGRATION.md +3 -3
- package/docs/ARCHITECTURE.md +14 -9
- package/docs/TEST-REPORT.md +16 -1
- package/docs/USAGE.md +430 -429
- package/extension/background.js +7 -98
- package/extension/connection.js +142 -0
- package/extension/controller.js +1 -1
- package/extension/manifest.json +1 -1
- package/extension/popup.js +7 -1
- package/extension/snapshot.js +39 -19
- package/extension/version.js +11 -0
- package/extension/wait.js +1 -1
- package/package.json +2 -1
- package/scripts/check-package.mjs +1 -0
- package/src/client.mjs +58 -15
- package/src/config.mjs +5 -1
- package/src/hub.mjs +62 -17
- package/src/mcp.mjs +2 -1
- package/src/setup.mjs +87 -16
package/src/hub.mjs
CHANGED
|
@@ -3,6 +3,8 @@ import http from 'node:http';
|
|
|
3
3
|
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
4
4
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
5
5
|
import { VERSION } from './config.mjs';
|
|
6
|
+
import { syncExtension } from './setup.mjs';
|
|
7
|
+
import { compareVersions } from '../extension/version.js';
|
|
6
8
|
|
|
7
9
|
const MAX_MESSAGE = 24 * 1024 * 1024;
|
|
8
10
|
const OPERATIONS = new Set(['release_tab', 'activate_tab', 'close_tab', 'snapshot', 'read_text', 'type', 'hover', 'drag', 'wait', 'select_option', 'click', 'fill', 'press_key', 'batch', 'scroll', 'navigate', 'screenshot', 'handle_dialog', 'upload_file', 'evaluate', 'logs']);
|
|
@@ -12,6 +14,8 @@ const send = (ws, data) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.
|
|
|
12
14
|
|
|
13
15
|
export async function startHub(config, { port = config.port, operationTimeout = HUB_OPERATION_TIMEOUT_MS } = {}) {
|
|
14
16
|
const browsers = new Map(), clients = new Set(), claims = new Map(), tabClaims = new Map(), queues = new Map();
|
|
17
|
+
const cleanups = new Set();
|
|
18
|
+
let state = 'starting', upgradeError;
|
|
15
19
|
const authorized = token => typeof token === 'string' && Buffer.byteLength(token) === Buffer.byteLength(config.token)
|
|
16
20
|
&& timingSafeEqual(Buffer.from(token), Buffer.from(config.token));
|
|
17
21
|
const bearer = request => authorized(request.headers.authorization?.replace(/^Bearer /, ''));
|
|
@@ -28,13 +32,13 @@ export async function startHub(config, { port = config.port, operationTimeout =
|
|
|
28
32
|
result.then(done, done);
|
|
29
33
|
return result;
|
|
30
34
|
}
|
|
31
|
-
function requestBrowser(browser, owner, command, args) {
|
|
35
|
+
function requestBrowser(browser, owner, command, args, timeout = operationTimeout) {
|
|
32
36
|
return new Promise((resolve, reject) => {
|
|
33
37
|
const id = randomUUID();
|
|
34
38
|
const timer = setTimeout(() => {
|
|
35
39
|
browser.pending.delete(id);
|
|
36
40
|
reject(new OperationTimeout('Browser operation timed out; outcome may be unknown. Connection will release its tabs. List and claim again.'));
|
|
37
|
-
},
|
|
41
|
+
}, timeout);
|
|
38
42
|
browser.pending.set(id, { resolve: value => { clearTimeout(timer); resolve(value); }, reject: error => { clearTimeout(timer); reject(error); } });
|
|
39
43
|
if (browser.ws.readyState !== WebSocket.OPEN) {
|
|
40
44
|
clearTimeout(timer); browser.pending.delete(id); reject(new Error('Browser disconnected')); return;
|
|
@@ -44,12 +48,13 @@ export async function startHub(config, { port = config.port, operationTimeout =
|
|
|
44
48
|
}
|
|
45
49
|
async function execute(owner, observed, method, args) {
|
|
46
50
|
if (method === 'list_browsers') return {
|
|
47
|
-
browsers: [...browsers.values()].map(browser => ({ browser_id: browser.id, ...browser.info, capabilities: CAPABILITIES })),
|
|
51
|
+
browsers: [...browsers.values()].filter(browser => browser.ready).map(browser => ({ browser_id: browser.id, ...browser.info, capabilities: CAPABILITIES })),
|
|
52
|
+
upgrades: [...browsers.values()].filter(browser => !browser.ready).map(browser => ({ instance_id: browser.info.instance_id, version: browser.info.extension_version, error: browser.upgradeError })),
|
|
48
53
|
hint: 'Load the paired extension in each Chrome/Edge profile if empty.',
|
|
49
54
|
};
|
|
50
55
|
if (method === 'list_tabs' || method === 'claim_tab' || method === 'open_tab' || method === 'fetch') {
|
|
51
56
|
const browser = browsers.get(args.browser_id);
|
|
52
|
-
if (!browser) throw new Error('Browser is not connected; call browser_discover kind=browsers again');
|
|
57
|
+
if (!browser?.ready) throw new Error('Browser is not connected; call browser_discover kind=browsers again');
|
|
53
58
|
if (method === 'open_tab' || method === 'fetch') return await requestBrowser(browser, owner, method, args);
|
|
54
59
|
if (method === 'list_tabs') {
|
|
55
60
|
const result = await requestBrowser(browser, owner, method, {});
|
|
@@ -93,10 +98,36 @@ export async function startHub(config, { port = config.port, operationTimeout =
|
|
|
93
98
|
}
|
|
94
99
|
|
|
95
100
|
let closing;
|
|
96
|
-
const server = http.createServer((request, response) => {
|
|
101
|
+
const server = http.createServer(async (request, response) => {
|
|
97
102
|
if (request.headers.origin || !bearer(request)) { response.writeHead(403).end(); return; }
|
|
98
103
|
response.setHeader('Content-Type', 'application/json');
|
|
99
|
-
if (request.url === '/health' && request.method === 'GET') response.end(JSON.stringify({ service: 'browser-attach', version: VERSION,
|
|
104
|
+
if (request.url === '/health' && request.method === 'GET') response.end(JSON.stringify({ service: 'browser-attach', version: VERSION, state,
|
|
105
|
+
browsers: [...browsers.values()].filter(browser => browser.ready).length, clients: clients.size, upgrade_error: upgradeError,
|
|
106
|
+
extensions: [...browsers.values()].map(browser => ({ instance_id: browser.info.instance_id, version: browser.info.extension_version, ready: browser.ready, error: browser.upgradeError })) }));
|
|
107
|
+
else if (request.url === '/upgrade' && request.method === 'POST') {
|
|
108
|
+
try {
|
|
109
|
+
const target = request.headers['x-browser-attach-version'];
|
|
110
|
+
if (compareVersions(target, VERSION) <= 0) { response.writeHead(409).end(JSON.stringify({ error: 'Upgrade target must be newer than this Hub' })); return; }
|
|
111
|
+
if (state !== 'ready' || clients.size || cleanups.size || [...browsers.values()].some(browser => browser.pending.size)) {
|
|
112
|
+
response.writeHead(409).end(JSON.stringify({ error: 'Hub is in use; close other MCP connections and retry the upgrade' })); return;
|
|
113
|
+
}
|
|
114
|
+
state = 'upgrading';
|
|
115
|
+
upgradeError = undefined;
|
|
116
|
+
const connected = [...browsers.values()];
|
|
117
|
+
const outcomes = await Promise.allSettled(connected.map(async browser => {
|
|
118
|
+
const result = await requestBrowser(browser, null, 'prepare_upgrade', {}, 8000);
|
|
119
|
+
if (result.errors?.length || result.prepared !== true) throw new Error(result.errors?.join('; ') || 'Extension did not confirm cleanup');
|
|
120
|
+
}));
|
|
121
|
+
const failed = outcomes.find(item => item.status === 'rejected');
|
|
122
|
+
if (failed) {
|
|
123
|
+
await Promise.allSettled(connected.map(browser => requestBrowser(browser, null, 'resume', {}, 1000)));
|
|
124
|
+
upgradeError = `Extension cleanup failed: ${failed.reason.message}`;
|
|
125
|
+
state = 'ready'; throw new Error(upgradeError);
|
|
126
|
+
}
|
|
127
|
+
response.end(JSON.stringify({ stopping: true, extensions: connected.map(browser => browser.info.instance_id) }));
|
|
128
|
+
setImmediate(() => close());
|
|
129
|
+
} catch (error) { response.writeHead(409).end(JSON.stringify({ error: error.message })); }
|
|
130
|
+
}
|
|
100
131
|
else if (request.url === '/shutdown' && request.method === 'POST') {
|
|
101
132
|
response.end('{"stopping":true}'); setImmediate(() => close());
|
|
102
133
|
} else response.writeHead(404).end();
|
|
@@ -105,8 +136,9 @@ export async function startHub(config, { port = config.port, operationTimeout =
|
|
|
105
136
|
server.on('upgrade', (request, socket, head) => {
|
|
106
137
|
const extension = request.url === '/extension';
|
|
107
138
|
const valid = extension ? request.headers.origin === `chrome-extension://${config.extension_id}`
|
|
108
|
-
: request.url === '/client' && !request.headers.origin && bearer(request);
|
|
139
|
+
: request.url === '/client' && !request.headers.origin && bearer(request) && request.headers['x-browser-attach-version'] === VERSION;
|
|
109
140
|
if (!valid) { socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return; }
|
|
141
|
+
if (state !== 'ready') { socket.end('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n'); return; }
|
|
110
142
|
wss.handleUpgrade(request, socket, head, ws => {
|
|
111
143
|
ws.on('pong', () => { ws.isAlive = true; });
|
|
112
144
|
if (extension) connectExtension(ws); else connectClient(ws);
|
|
@@ -120,18 +152,23 @@ export async function startHub(config, { port = config.port, operationTimeout =
|
|
|
120
152
|
try {
|
|
121
153
|
const data = JSON.parse(raw.toString());
|
|
122
154
|
if (!browser) {
|
|
155
|
+
if (state !== 'ready') { ws.close(1013, 'Hub is preparing an upgrade'); return; }
|
|
123
156
|
const info = data.browser;
|
|
124
|
-
if (data.type !== 'hello' || !authorized(data.token) || !info ||
|
|
157
|
+
if (data.type !== 'hello' || !authorized(data.token) || !info || !['chrome', 'edge'].includes(info.family) ||
|
|
125
158
|
!['instance_id', 'run_id', 'profile_name'].every(key => typeof info[key] === 'string' && info[key].length >= 1 && info[key].length <= 128))
|
|
126
159
|
throw new Error('Invalid pairing or browser identity');
|
|
127
160
|
const id = `${info.instance_id}:${info.run_id}`;
|
|
128
161
|
if (browsers.has(id)) throw new Error('Browser instance is already connected');
|
|
129
|
-
|
|
162
|
+
const comparison = compareVersions(info.extension_version, VERSION);
|
|
163
|
+
if (comparison > 0) throw new Error('Extension is newer than this Hub');
|
|
164
|
+
browser = { id, ws, info, ready: comparison === 0, pending: new Map() };
|
|
130
165
|
browsers.set(id, browser); clearTimeout(helloTimer);
|
|
131
|
-
send(ws, { type: 'connected', browser_id: id });
|
|
166
|
+
if (browser.ready) send(ws, { type: 'connected', browser_id: id });
|
|
167
|
+
else requestBrowser(browser, null, 'reload_extension', { version: VERSION }).catch(error => { browser.upgradeError = error.message; });
|
|
168
|
+
return;
|
|
132
169
|
}
|
|
133
170
|
if (data.type === 'ping') send(ws, { type: 'pong' });
|
|
134
|
-
else if (data.type === 'detached') forget(tabClaims.get(keyFor(browser.id, data.tab_id)));
|
|
171
|
+
else if (browser.ready && data.type === 'detached') forget(tabClaims.get(keyFor(browser.id, data.tab_id)));
|
|
135
172
|
else if (browser.pending.has(data.id)) {
|
|
136
173
|
const pending = browser.pending.get(data.id); browser.pending.delete(data.id);
|
|
137
174
|
if ('error' in data) pending.reject(new Error(String(data.error))); else pending.resolve(data.result || {});
|
|
@@ -168,10 +205,13 @@ export async function startHub(config, { port = config.port, operationTimeout =
|
|
|
168
205
|
});
|
|
169
206
|
ws.on('close', () => {
|
|
170
207
|
disconnected = true; clients.delete(ws);
|
|
171
|
-
chain.
|
|
172
|
-
await Promise.allSettled([...browsers.values()].
|
|
173
|
-
|
|
174
|
-
|
|
208
|
+
const cleanup = chain.then(async () => {
|
|
209
|
+
await Promise.allSettled([...browsers.values()].filter(browser => browser.ready).map(async browser => {
|
|
210
|
+
const result = await requestBrowser(browser, owner, 'release_owner', {});
|
|
211
|
+
if (!result.errors?.length) for (const [handle, claim] of claims) if (claim.owner === owner && claim.browser_id === browser.id) forget(handle);
|
|
212
|
+
}));
|
|
213
|
+
}).finally(() => cleanups.delete(cleanup));
|
|
214
|
+
cleanups.add(cleanup);
|
|
175
215
|
});
|
|
176
216
|
}
|
|
177
217
|
const heartbeat = setInterval(() => {
|
|
@@ -183,6 +223,7 @@ export async function startHub(config, { port = config.port, operationTimeout =
|
|
|
183
223
|
async function close() {
|
|
184
224
|
if (closing) return closing;
|
|
185
225
|
closing = (async () => {
|
|
226
|
+
state = 'stopping';
|
|
186
227
|
clearInterval(heartbeat);
|
|
187
228
|
for (const ws of wss.clients) ws.close(1001, 'Hub stopped');
|
|
188
229
|
const timer = setTimeout(() => { for (const ws of wss.clients) ws.terminate(); }, 1000);
|
|
@@ -191,7 +232,11 @@ export async function startHub(config, { port = config.port, operationTimeout =
|
|
|
191
232
|
})();
|
|
192
233
|
return closing;
|
|
193
234
|
}
|
|
194
|
-
try {
|
|
195
|
-
|
|
235
|
+
try {
|
|
236
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
|
|
237
|
+
config.port = server.address().port;
|
|
238
|
+
if (config.extension_dir) await syncExtension(config);
|
|
239
|
+
state = 'ready';
|
|
240
|
+
} catch (error) { await close(); throw error; }
|
|
196
241
|
return { server, close, port: server.address().port, claims, browsers };
|
|
197
242
|
}
|
package/src/mcp.mjs
CHANGED
|
@@ -147,7 +147,7 @@ export function createMcp(client) {
|
|
|
147
147
|
}, null, args => { dragOptions(args); return args; });
|
|
148
148
|
tool('wait', 'Wait without scrolling or input. actionable requires a stable, visible, enabled, unobscured target in the viewport; network_idle uses observed page requests. Timeout includes the last observed state.', {
|
|
149
149
|
...handle, ...dialogPolicy, timeout_ms: integerRange('timeout_ms', 100, 30000).default(5000), ...selector, ...frameScope, nth: target.nth,
|
|
150
|
-
condition: z.enum(['attached', 'visible', 'actionable', 'text', 'url', 'network_idle']), expected: z.string().max(2000).optional().describe('For text:
|
|
150
|
+
condition: z.enum(['attached', 'visible', 'actionable', 'text', 'url', 'network_idle']), expected: z.string().max(2000).optional().describe('For text: rendered innerText substring; for url: main-frame URL substring. Text may use previous instead.'), previous: z.string().max(100000).optional().describe('Text only: wait for full rendered innerText to differ; alternative to expected.'), idle_ms: integerRange('idle_ms', 100, 10000).default(500),
|
|
151
151
|
}, null, args => { waitOptions(args); return args; });
|
|
152
152
|
tool('select_option', 'Set one HTML select option without native UI, using synthetic input/change (events_trusted=false). Verifies retained selection only.', {
|
|
153
153
|
...handle, ...inputTimeout, ...target, value: z.string().optional().describe('Choose exactly one of value, label or zero-based index; value/label match exactly.'), label: z.string().optional(), index: integerRange('index', 0, Number.MAX_SAFE_INTEGER).optional(),
|
|
@@ -194,6 +194,7 @@ export function createMcp(client) {
|
|
|
194
194
|
|
|
195
195
|
export async function runMcp(configPath) {
|
|
196
196
|
const client = new AttachClient(await ensureHub(configPath));
|
|
197
|
+
await client.connect();
|
|
197
198
|
const server = createMcp(client);
|
|
198
199
|
const transport = new StdioServerTransport();
|
|
199
200
|
transport.onclose = () => client.close();
|
package/src/setup.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { randomBytes, createHash } from 'node:crypto';
|
|
2
|
-
import { cp, mkdir, readFile, writeFile, chmod } from 'node:fs/promises';
|
|
3
|
-
import { join, resolve, dirname } from 'node:path';
|
|
2
|
+
import { cp, copyFile, mkdir, mkdtemp, readFile, writeFile, chmod, rename, rm, rmdir, readdir, stat } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve, dirname, relative, isAbsolute, sep } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
|
-
import { ROOT, DEFAULT_CONFIG, loadConfig, writeJson } from './config.mjs';
|
|
6
|
+
import { ROOT, DEFAULT_CONFIG, VERSION, loadConfig, writeJson } from './config.mjs';
|
|
7
|
+
import { compareVersions } from '../extension/version.js';
|
|
7
8
|
|
|
8
9
|
export const mcpSettings = (configPath = DEFAULT_CONFIG) => ({ command: process.execPath,
|
|
9
10
|
args: [join(ROOT, 'bin/browser-attach.mjs'), 'mcp', '--config', resolve(configPath)],
|
|
@@ -19,33 +20,103 @@ async function privateDirectory(path) {
|
|
|
19
20
|
} else await chmod(path, 0o700);
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
export async function setup({ configPath = DEFAULT_CONFIG, extensionDir, port
|
|
23
|
+
export async function setup({ configPath = DEFAULT_CONFIG, extensionDir, port } = {}) {
|
|
23
24
|
configPath = resolve(configPath);
|
|
24
|
-
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('port must be 1024..65535');
|
|
25
25
|
await privateDirectory(dirname(configPath));
|
|
26
|
-
|
|
27
|
-
if (extensionDir !== resolve(ROOT, 'extension')) {
|
|
28
|
-
await cp(join(ROOT, 'extension'), extensionDir, { recursive: true, filter: source => !source.endsWith('config.generated.js') });
|
|
29
|
-
}
|
|
30
|
-
await privateDirectory(extensionDir);
|
|
31
|
-
const manifestPath = join(extensionDir, 'manifest.json');
|
|
32
|
-
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
26
|
+
const manifest = JSON.parse(await readFile(join(ROOT, 'extension/manifest.json'), 'utf8'));
|
|
33
27
|
const extensionId = [...createHash('sha256').update(Buffer.from(manifest.key, 'base64')).digest('hex').slice(0, 32)]
|
|
34
28
|
.map(char => String.fromCharCode(97 + parseInt(char, 16))).join('');
|
|
35
29
|
let config;
|
|
36
30
|
try { config = await loadConfig(configPath); }
|
|
37
31
|
catch (error) { if (error.cause?.code !== 'ENOENT') throw error; }
|
|
32
|
+
port ??= config?.port ?? 47831;
|
|
33
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('port must be 1024..65535');
|
|
34
|
+
extensionDir = resolve(extensionDir || config?.extension_dir || join(dirname(configPath), 'extension'));
|
|
35
|
+
const configRelative = relative(extensionDir, configPath);
|
|
36
|
+
if (dirname(extensionDir) === extensionDir || (!isAbsolute(configRelative) && configRelative !== '..' && !configRelative.startsWith('..' + sep)))
|
|
37
|
+
throw new Error('Choose a dedicated extension directory outside the pairing file');
|
|
38
38
|
config ||= { port, token: randomBytes(48).toString('base64url'), extension_id: extensionId };
|
|
39
39
|
if (config.port !== port || config.extension_id !== extensionId) throw new Error('Pairing differs from requested port/extension. Stop the hub and remove the pairing config before a fresh setup.');
|
|
40
|
+
if (config.extension_dir && resolve(config.extension_dir) !== extensionDir) throw new Error('Use the paired extension directory, or a separate pairing config for a different directory.');
|
|
40
41
|
config.extension_dir = extensionDir;
|
|
41
|
-
manifest.content_security_policy = { extension_pages: `script-src 'self'; object-src 'self'; connect-src ws://127.0.0.1:${port}` };
|
|
42
|
-
await writeJson(manifestPath, manifest);
|
|
43
|
-
await writeFile(join(extensionDir, 'config.generated.js'), '// Machine-local pairing secret. Never publish.\nexport const CONNECTION = ' +
|
|
44
|
-
JSON.stringify({ url: `ws://127.0.0.1:${port}/extension`, token: config.token }) + ';\n', { mode: 0o600 });
|
|
45
42
|
await writeJson(configPath, config);
|
|
46
43
|
return { config, configPath, extensionDir };
|
|
47
44
|
}
|
|
48
45
|
|
|
46
|
+
// Only the Hub holding the listening port may replace the stable copy.
|
|
47
|
+
export async function syncExtension(config) {
|
|
48
|
+
const source = resolve(ROOT, 'extension'), destination = resolve(config.extension_dir);
|
|
49
|
+
const manifest = JSON.parse(await readFile(join(source, 'manifest.json'), 'utf8'));
|
|
50
|
+
const backup = destination + '.previous';
|
|
51
|
+
async function installed(directory) {
|
|
52
|
+
try {
|
|
53
|
+
const value = JSON.parse(await readFile(join(directory, 'manifest.json'), 'utf8'));
|
|
54
|
+
if (value.key !== manifest.key) throw new Error(`Unrecognized extension directory: ${directory}`);
|
|
55
|
+
return value;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error.code !== 'ENOENT') throw error;
|
|
58
|
+
try { if ((await readdir(directory)).length) throw new Error(`Unrecognized extension directory: ${directory}`); }
|
|
59
|
+
catch (missing) { if (missing.code !== 'ENOENT') throw missing; }
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function files(directory) {
|
|
64
|
+
const entries = await readdir(directory, { recursive: true });
|
|
65
|
+
const kinds = await Promise.all(entries.map(path => stat(join(directory, path))));
|
|
66
|
+
return entries.filter((_, index) => kinds[index].isFile());
|
|
67
|
+
}
|
|
68
|
+
async function install(directory) {
|
|
69
|
+
await privateDirectory(destination);
|
|
70
|
+
const incoming = await files(directory), existing = await files(destination);
|
|
71
|
+
// Keep the loaded directory itself stable and commit its manifest last.
|
|
72
|
+
for (const path of incoming.filter(path => path !== 'manifest.json')) {
|
|
73
|
+
const target = join(destination, path);
|
|
74
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
75
|
+
await copyFile(join(directory, path), target + '.updating');
|
|
76
|
+
await rename(target + '.updating', target);
|
|
77
|
+
}
|
|
78
|
+
for (const path of existing) if (!incoming.includes(path)) await rm(join(destination, path));
|
|
79
|
+
await writeJson(join(destination, 'manifest.json'), JSON.parse(await readFile(join(directory, 'manifest.json'), 'utf8')));
|
|
80
|
+
}
|
|
81
|
+
let current = await installed(destination);
|
|
82
|
+
if (current && compareVersions(current.version, VERSION) > 0) throw new Error('Refusing to downgrade the installed extension');
|
|
83
|
+
const previous = destination === source ? null : await installed(backup);
|
|
84
|
+
if (previous) {
|
|
85
|
+
await install(backup);
|
|
86
|
+
current = previous;
|
|
87
|
+
await rm(backup, { recursive: true });
|
|
88
|
+
}
|
|
89
|
+
await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
|
|
90
|
+
const stage = destination === source ? source : await mkdtemp(destination + '.staging-');
|
|
91
|
+
try {
|
|
92
|
+
if (stage !== source) await cp(source, stage, { recursive: true, filter: path => !path.endsWith('config.generated.js') });
|
|
93
|
+
await privateDirectory(stage);
|
|
94
|
+
manifest.content_security_policy = { extension_pages: `script-src 'self'; object-src 'self'; connect-src ws://127.0.0.1:${config.port}` };
|
|
95
|
+
await writeJson(join(stage, 'manifest.json'), manifest);
|
|
96
|
+
await writeFile(join(stage, 'config.generated.js'), '// Machine-local pairing secret. Never publish.\nexport const CONNECTION = ' +
|
|
97
|
+
JSON.stringify({ url: `ws://127.0.0.1:${config.port}/extension`, token: config.token }) + ';\n', { mode: 0o600 });
|
|
98
|
+
if (stage !== source) {
|
|
99
|
+
if (current) {
|
|
100
|
+
await privateDirectory(stage + '.previous');
|
|
101
|
+
await cp(destination, stage + '.previous', { recursive: true });
|
|
102
|
+
await rename(stage + '.previous', backup);
|
|
103
|
+
} else {
|
|
104
|
+
try { await rmdir(destination); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
105
|
+
await rename(stage, destination);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
try { await install(stage); }
|
|
109
|
+
catch (error) { if (current) await install(backup); throw error; }
|
|
110
|
+
if (current) await rm(backup, { recursive: true });
|
|
111
|
+
}
|
|
112
|
+
} finally {
|
|
113
|
+
if (stage !== source) {
|
|
114
|
+
await rm(stage, { recursive: true, force: true });
|
|
115
|
+
await rm(stage + '.previous', { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
49
120
|
export async function configureNosis(directory = join(homedir(), '.nosis'), { configPath = DEFAULT_CONFIG } = {}) {
|
|
50
121
|
const path = join(directory, 'agent_config.json');
|
|
51
122
|
const data = JSON.parse((await readFile(path, 'utf8')).replace(/^\uFEFF/, ''));
|