syncme-mcp 0.0.0-stage → 1.0.1
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/LICENSE +75 -0
- package/PRIVACY.md +11 -0
- package/README.md +80 -2
- package/approve.html +17 -0
- package/approve.js +76 -0
- package/background.js +112 -0
- package/icons/128.png +0 -0
- package/icons/16.png +0 -0
- package/icons/32.png +0 -0
- package/icons/48.png +0 -0
- package/manifest.json +33 -0
- package/package.json +19 -4
- package/syncme.js +501 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Required Notice: Copyright Kevin Lin (https://github.com/levinkin)
|
|
2
|
+
|
|
3
|
+
# PolyForm Noncommercial License 1.0.0
|
|
4
|
+
|
|
5
|
+
<https://polyformproject.org/licenses/noncommercial/1.0.0>
|
|
6
|
+
|
|
7
|
+
## Acceptance
|
|
8
|
+
|
|
9
|
+
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
|
|
10
|
+
|
|
11
|
+
## Copyright License
|
|
12
|
+
|
|
13
|
+
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
|
|
14
|
+
|
|
15
|
+
## Distribution License
|
|
16
|
+
|
|
17
|
+
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
|
|
18
|
+
|
|
19
|
+
## Notices
|
|
20
|
+
|
|
21
|
+
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
|
|
22
|
+
|
|
23
|
+
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
|
24
|
+
|
|
25
|
+
## Changes and New Works License
|
|
26
|
+
|
|
27
|
+
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
|
|
28
|
+
|
|
29
|
+
## Patent License
|
|
30
|
+
|
|
31
|
+
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
|
|
32
|
+
|
|
33
|
+
## Noncommercial Purposes
|
|
34
|
+
|
|
35
|
+
Any noncommercial purpose is a permitted purpose.
|
|
36
|
+
|
|
37
|
+
## Personal Uses
|
|
38
|
+
|
|
39
|
+
Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
|
|
40
|
+
|
|
41
|
+
## Noncommercial Organizations
|
|
42
|
+
|
|
43
|
+
Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
|
|
44
|
+
|
|
45
|
+
## Fair Use
|
|
46
|
+
|
|
47
|
+
You may have "fair use" rights for the software under the law. These terms do not limit them.
|
|
48
|
+
|
|
49
|
+
## No Other Rights
|
|
50
|
+
|
|
51
|
+
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
|
|
52
|
+
|
|
53
|
+
## Patent Defense
|
|
54
|
+
|
|
55
|
+
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
|
56
|
+
|
|
57
|
+
## Violations
|
|
58
|
+
|
|
59
|
+
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
|
|
60
|
+
|
|
61
|
+
## No Liability
|
|
62
|
+
|
|
63
|
+
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
|
|
64
|
+
|
|
65
|
+
## Definitions
|
|
66
|
+
|
|
67
|
+
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
|
|
68
|
+
|
|
69
|
+
**You** refers to the individual or entity agreeing to these terms.
|
|
70
|
+
|
|
71
|
+
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
|
72
|
+
|
|
73
|
+
**Your licenses** are all the licenses granted to you for the software under these terms.
|
|
74
|
+
|
|
75
|
+
**Use** means anything you do with the software requiring one of your licenses.
|
package/PRIVACY.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# syncme privacy policy
|
|
2
|
+
|
|
3
|
+
syncme doesn't collect, send or sell any data.
|
|
4
|
+
|
|
5
|
+
when you click copy on a site, the extension reads that site's cookies and passes them to the syncme bridge running on your own computer. the bridge saves them into a separate chrome profile on that same computer. nothing leaves your machine and there's no server.
|
|
6
|
+
|
|
7
|
+
the extension only reads cookies for sites you added yourself. the list of saved sites and the auto-copy setting are kept in chrome's local extension storage.
|
|
8
|
+
|
|
9
|
+
no analytics, no tracking, no third parties.
|
|
10
|
+
|
|
11
|
+
questions: open an issue at https://github.com/levinkin/syncme/issues
|
package/README.md
CHANGED
|
@@ -1,3 +1,81 @@
|
|
|
1
|
-
#
|
|
1
|
+
# syncme
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
copies your logins for a site from your normal chrome into a separate chrome that ai agents and scripts can drive. so jev, browser-use or playwright can use your accounts without touching your real browser.
|
|
4
|
+
|
|
5
|
+
## how it works
|
|
6
|
+
|
|
7
|
+
1. when you're on a site you want, click the syncme icon and it asks if you want to copy it over
|
|
8
|
+
2. syncme grabs that site's cookies and puts them in its own chrome profile (the "ai browser"). this runs headless in the background, nothing pops up
|
|
9
|
+
3. when a script needs the browser, run `npx -y syncme-mcp open`. the ai browser opens as normal chrome with your logins already in it and prints a cdp url to connect to
|
|
10
|
+
|
|
11
|
+
it only copies cookies for sites you said yes to. no passwords, no other sites. syncme never touches your real chrome profile.
|
|
12
|
+
|
|
13
|
+
there's also a setting to re-copy all your saved sites every time the ai browser starts, so logins stay fresh.
|
|
14
|
+
|
|
15
|
+
## setup
|
|
16
|
+
|
|
17
|
+
needs chrome and node 20 or newer. works on mac and windows.
|
|
18
|
+
|
|
19
|
+
there's nothing to install: the extension talks to the bridge over a websocket on 127.0.0.1, and any command starts the bridge itself.
|
|
20
|
+
|
|
21
|
+
1. install the extension (chrome web store, or load this folder unpacked from `chrome://extensions` with developer mode on)
|
|
22
|
+
2. click the syncme icon and use one of the connect buttons, or run `npx -y syncme-mcp setup` to wire up claude code, codex, cursor, windsurf and claude desktop
|
|
23
|
+
|
|
24
|
+
## commands
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
npx -y syncme-mcp open # start the ai browser, prints {"cdp": "http://127.0.0.1:<port>"}
|
|
28
|
+
npx -y syncme-mcp sync https://example.com # copy one site, asks you in chrome first if it's not saved yet
|
|
29
|
+
npx -y syncme-mcp stop # close the ai browser
|
|
30
|
+
npx -y syncme-mcp shutdown # close it and stop the bridge
|
|
31
|
+
npx -y syncme-mcp setup # add the mcp server to the ai apps you have installed
|
|
32
|
+
npx -y syncme-mcp unpair # forget the paired extension, for when you reinstall it
|
|
33
|
+
npx -y syncme-mcp pack # build syncme.zip for the web store
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
connecting from playwright:
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
import {chromium} from 'playwright-core';
|
|
40
|
+
import {execFileSync} from 'node:child_process';
|
|
41
|
+
|
|
42
|
+
const {cdp} = JSON.parse(execFileSync('npx', ['-y', 'syncme-mcp', 'open']));
|
|
43
|
+
const browser = await chromium.connectOverCDP(cdp);
|
|
44
|
+
await (await browser.contexts()[0].newPage()).goto('https://example.com');
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## mcp
|
|
48
|
+
|
|
49
|
+
you can also hook it up as an mcp server so your ai can ask for a site itself. it calls `copy_site` with something like `https://github.com`, you get a popup in chrome asking if it can have that login, and if you say yes it copies it and gets back the cdp url. there's also `open_browser` and `close_browser`.
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
claude mcp add --scope user syncme -- npx -y syncme-mcp
|
|
53
|
+
codex mcp add syncme -- npx -y syncme-mcp
|
|
54
|
+
npx -y syncme-mcp setup # does both of the above, plus the json configs
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
or by hand:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"mcpServers": {
|
|
62
|
+
"syncme": {
|
|
63
|
+
"command": "npx",
|
|
64
|
+
"args": ["-y", "syncme-mcp"]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
keep chrome open with the extension for it to work, since syncme takes the cookies from there.
|
|
71
|
+
|
|
72
|
+
## notes
|
|
73
|
+
|
|
74
|
+
- the ai browser profile lives in `~/Library/Application Support/syncme` on mac, `%LOCALAPPDATA%\syncme` on windows and `~/.local/state/syncme` on linux
|
|
75
|
+
- removing a site from the list stops future copies, but the ai browser keeps cookies it already has
|
|
76
|
+
- the extension and syncme pair the first time they connect and check each other after that, so other accounts on your computer can't pose as either one
|
|
77
|
+
- the bridge listens only on `127.0.0.1:47621`, only accepts a websocket whose origin is this extension, and only takes commands from a local token only your user can read
|
|
78
|
+
|
|
79
|
+
## license
|
|
80
|
+
|
|
81
|
+
[PolyForm Noncommercial 1.0.0](LICENSE). free to use, change and share for anything that isn't commercial. if you want to use it for a business, ask me.
|
package/approve.html
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en"><meta charset="utf-8"><title>syncme</title>
|
|
3
|
+
<style>body{font:15px system-ui;width:340px;margin:16px}button{padding:8px 14px}li{margin:6px 0}li button{padding:2px 8px;margin-left:6px}label{display:block;margin:14px 0}#question{font-weight:600}</style>
|
|
4
|
+
<p id="question"></p>
|
|
5
|
+
<button id="copy">Copy</button> <button id="deny" hidden>No</button>
|
|
6
|
+
<p id="status" role="status"></p>
|
|
7
|
+
<div id="settings">
|
|
8
|
+
<label><input type="checkbox" id="auto"> Copy all saved sites when the AI browser starts</label>
|
|
9
|
+
<h2>Saved sites</h2>
|
|
10
|
+
<ul id="approved"></ul>
|
|
11
|
+
<h2>Connect your ai</h2>
|
|
12
|
+
<p id="waiting" hidden>connect an ai app first.</p>
|
|
13
|
+
<p id="pairing" hidden><span></span> <button id="repair">Re-pair</button></p>
|
|
14
|
+
<p><a id="cursor" hidden>Add to Cursor</a> <a id="vscode" hidden>Add to VS Code</a></p>
|
|
15
|
+
<button id="claude">Copy Claude Code command</button> <button id="setup">Copy setup command</button>
|
|
16
|
+
</div>
|
|
17
|
+
<script src="approve.js"></script></html>
|
package/approve.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const status = document.querySelector('#status');
|
|
2
|
+
const question = document.querySelector('#question');
|
|
3
|
+
const copyButton = document.querySelector('#copy');
|
|
4
|
+
const auto = document.querySelector('#auto');
|
|
5
|
+
let site;
|
|
6
|
+
async function render() {
|
|
7
|
+
const {approved = []} = await chrome.storage.local.get('approved');
|
|
8
|
+
const list = document.querySelector('#approved');
|
|
9
|
+
list.replaceChildren();
|
|
10
|
+
for (const value of approved) {
|
|
11
|
+
const li = document.createElement('li');
|
|
12
|
+
li.append(document.createTextNode(new URL(value).host));
|
|
13
|
+
const button = document.createElement('button');
|
|
14
|
+
button.textContent = 'Remove';
|
|
15
|
+
button.onclick = async () => {
|
|
16
|
+
const {approved = []} = await chrome.storage.local.get('approved');
|
|
17
|
+
await chrome.storage.local.set({approved: approved.filter(x => x !== value)});
|
|
18
|
+
status.textContent = 'Removed. The AI browser keeps cookies it already has.';
|
|
19
|
+
await render();
|
|
20
|
+
};
|
|
21
|
+
li.append(button); list.append(li);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const requested = new URLSearchParams(location.search).get('site');
|
|
25
|
+
if (requested) {
|
|
26
|
+
question.textContent = `Your AI wants your ${new URL(requested).host} login. Copy it?`;
|
|
27
|
+
document.querySelector('#settings').hidden = true;
|
|
28
|
+
const deny = document.querySelector('#deny');
|
|
29
|
+
deny.hidden = false;
|
|
30
|
+
copyButton.onclick = () => chrome.runtime.sendMessage({op: 'allow', answer: true});
|
|
31
|
+
deny.onclick = () => chrome.runtime.sendMessage({op: 'allow', answer: false});
|
|
32
|
+
} else chrome.tabs.query({active: true, currentWindow: true}).then(([tab]) => {
|
|
33
|
+
const u = tab?.url ? new URL(tab.url) : null;
|
|
34
|
+
if (u && ['http:', 'https:'].includes(u.protocol)) {
|
|
35
|
+
site = u.origin;
|
|
36
|
+
question.textContent = `Copy ${u.host} to your AI browser?`;
|
|
37
|
+
} else {
|
|
38
|
+
question.textContent = 'Open a website to copy it.';
|
|
39
|
+
copyButton.hidden = true;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
if (!requested) copyButton.onclick = async () => {
|
|
43
|
+
copyButton.disabled = true;
|
|
44
|
+
status.textContent = 'Copying...';
|
|
45
|
+
try {
|
|
46
|
+
const result = await chrome.runtime.sendMessage({op: 'copy', origin: site});
|
|
47
|
+
status.textContent = result?.ok ? `Copied ${result.count} cookies.` : result?.error === 'Busy' ? 'Busy, try again in a moment.' : result?.error === 'Not connected' ? 'Connect an ai app first.' : 'Copy failed.';
|
|
48
|
+
} catch (_) {
|
|
49
|
+
status.textContent = 'Copy failed.';
|
|
50
|
+
} finally {
|
|
51
|
+
copyButton.disabled = false;
|
|
52
|
+
}
|
|
53
|
+
await render();
|
|
54
|
+
};
|
|
55
|
+
chrome.storage.local.get('autoSync').then(({autoSync}) => { auto.checked = autoSync === true; });
|
|
56
|
+
auto.onchange = () => chrome.storage.local.set({autoSync: auto.checked});
|
|
57
|
+
const server = {name: 'syncme', command: 'npx', args: ['-y', 'syncme-mcp']};
|
|
58
|
+
const links = {
|
|
59
|
+
cursor: 'cursor://anysphere.cursor-deeplink/mcp/install?name=syncme&config=' + btoa(JSON.stringify({command: server.command, args: server.args})),
|
|
60
|
+
vscode: 'vscode:mcp/install?' + encodeURIComponent(JSON.stringify(server)),
|
|
61
|
+
};
|
|
62
|
+
for (const [id, href] of Object.entries(links)) {
|
|
63
|
+
const link = document.querySelector('#' + id);
|
|
64
|
+
link.href = href;
|
|
65
|
+
link.hidden = false;
|
|
66
|
+
}
|
|
67
|
+
document.querySelector('#claude').onclick = () => navigator.clipboard.writeText('claude mcp add syncme -- npx -y syncme-mcp');
|
|
68
|
+
document.querySelector('#setup').onclick = () => navigator.clipboard.writeText('npx -y syncme-mcp setup');
|
|
69
|
+
chrome.storage.session.get(['connected', 'pairing']).then(({connected, pairing}) => {
|
|
70
|
+
document.querySelector('#waiting').hidden = connected === true || Boolean(pairing);
|
|
71
|
+
const box = document.querySelector('#pairing');
|
|
72
|
+
box.hidden = !pairing;
|
|
73
|
+
box.firstChild.textContent = pairing === 'lost' ? 'syncme lost its pairing with this extension.' : 'syncme is paired with a different extension. Run npx -y syncme-mcp unpair, then hit Re-pair.';
|
|
74
|
+
});
|
|
75
|
+
document.querySelector('#repair').onclick = () => { chrome.runtime.sendMessage({op: 'repair'}); document.querySelector('#pairing').hidden = true; };
|
|
76
|
+
render();
|
package/background.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
let socket;
|
|
2
|
+
function origin(value) {
|
|
3
|
+
const u = new URL(value);
|
|
4
|
+
if (!['http:', 'https:'].includes(u.protocol) || u.username || u.password || u.pathname !== '/' || u.search || u.hash || u.origin !== value) throw Error('Enter an exact http(s) origin without a trailing slash.');
|
|
5
|
+
return u;
|
|
6
|
+
}
|
|
7
|
+
const pending = new Map();
|
|
8
|
+
const asking = new Map();
|
|
9
|
+
async function allow(value) {
|
|
10
|
+
const w = await chrome.windows.create({url: 'approve.html?site=' + encodeURIComponent(value), type: 'popup', width: 380, height: 220, focused: true});
|
|
11
|
+
return new Promise(resolve => {
|
|
12
|
+
const timer = setTimeout(() => done(false), 100000);
|
|
13
|
+
function done(answer) {
|
|
14
|
+
if (!asking.delete(w.id)) return;
|
|
15
|
+
clearTimeout(timer);
|
|
16
|
+
chrome.windows.remove(w.id).catch(() => {});
|
|
17
|
+
resolve(answer);
|
|
18
|
+
}
|
|
19
|
+
asking.set(w.id, done);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
chrome.windows.onRemoved.addListener(id => asking.get(id)?.(false));
|
|
23
|
+
const hex = bytes => [...new Uint8Array(bytes)].map(b => b.toString(16).padStart(2, '0')).join('');
|
|
24
|
+
const random = () => hex(crypto.getRandomValues(new Uint8Array(32)));
|
|
25
|
+
async function sign(secret, purpose) {
|
|
26
|
+
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), {name: 'HMAC', hash: 'SHA-256'}, false, ['sign']);
|
|
27
|
+
return hex(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(purpose)));
|
|
28
|
+
}
|
|
29
|
+
function connect() {
|
|
30
|
+
if (socket) return;
|
|
31
|
+
const ws = new WebSocket('ws://127.0.0.1:47621/');
|
|
32
|
+
socket = ws;
|
|
33
|
+
let secret, nonce, verified = false;
|
|
34
|
+
const send = value => ws.send(JSON.stringify(value));
|
|
35
|
+
const keepAlive = setInterval(() => verified && ws.readyState === WebSocket.OPEN && send({op: 'ping'}), 20000);
|
|
36
|
+
const refuse = async pairing => { await chrome.storage.session.set({pairing}); ws.close(); };
|
|
37
|
+
let chain = Promise.resolve();
|
|
38
|
+
ws.onmessage = event => { chain = chain.then(() => handle(event)).catch(() => {}); };
|
|
39
|
+
const handle = async event => {
|
|
40
|
+
let message;
|
|
41
|
+
try { message = JSON.parse(event.data); } catch (_) { return; }
|
|
42
|
+
if (!verified) {
|
|
43
|
+
if (message.op === 'hello' && !nonce) {
|
|
44
|
+
({secret} = await chrome.storage.local.get('secret'));
|
|
45
|
+
if (!message.paired) {
|
|
46
|
+
if (secret) return refuse('lost');
|
|
47
|
+
secret = random();
|
|
48
|
+
await chrome.storage.local.set({secret});
|
|
49
|
+
send({op: 'pair', secret});
|
|
50
|
+
} else if (!secret) return refuse('other');
|
|
51
|
+
nonce = random();
|
|
52
|
+
send({op: 'proof', proof: await sign(secret, 'extension:' + message.nonce), nonce});
|
|
53
|
+
} else if (message.op === 'refused') return refuse('other');
|
|
54
|
+
else if (message.op === 'ready' && nonce && message.proof === await sign(secret, 'server:' + nonce)) {
|
|
55
|
+
if (socket !== ws || ws.readyState !== WebSocket.OPEN) return;
|
|
56
|
+
verified = true;
|
|
57
|
+
await chrome.storage.session.set({connected: true, pairing: null});
|
|
58
|
+
} else ws.close();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (message.op === 'copied') { pending.get(message.id)?.(message); pending.delete(message.id); return; }
|
|
62
|
+
const {approved = [], autoSync = false} = await chrome.storage.local.get(['approved', 'autoSync']);
|
|
63
|
+
if (message.op === 'list') { send({id: message.id, origins: autoSync ? approved : []}); return; }
|
|
64
|
+
let stage = 'request';
|
|
65
|
+
try {
|
|
66
|
+
if (message.op !== 'sync' || typeof message.id !== 'string') throw Error('Invalid request');
|
|
67
|
+
const u = origin(message.origin);
|
|
68
|
+
if (!approved.includes(u.origin)) {
|
|
69
|
+
if (!await allow(u.origin)) throw Error('Origin not approved');
|
|
70
|
+
const {approved: now = []} = await chrome.storage.local.get('approved');
|
|
71
|
+
await chrome.storage.local.set({approved: [...new Set([...now, u.origin])]});
|
|
72
|
+
}
|
|
73
|
+
stage = 'cookies';
|
|
74
|
+
const cookies = (await chrome.cookies.getAll({domain: u.hostname})).filter(c => c.domain.replace(/^\./, '') === u.hostname && !c.partitionKey && (!c.secure || u.protocol === 'https:'));
|
|
75
|
+
send({id: message.id, origin: u.origin, cookies});
|
|
76
|
+
} catch (_) { try { send({id: message.id, error: stage === 'cookies' ? 'Cookie API refused' : 'Origin unavailable or not approved'}); } catch (_) {} }
|
|
77
|
+
};
|
|
78
|
+
ws.onclose = ws.onerror = () => {
|
|
79
|
+
clearInterval(keepAlive);
|
|
80
|
+
if (socket !== ws) return;
|
|
81
|
+
socket = undefined;
|
|
82
|
+
verified = false;
|
|
83
|
+
chrome.storage.session.set({connected: false});
|
|
84
|
+
for (const done of pending.values()) done({error: 'Not connected'});
|
|
85
|
+
pending.clear();
|
|
86
|
+
};
|
|
87
|
+
ws.isVerified = () => verified;
|
|
88
|
+
}
|
|
89
|
+
async function copy(value) {
|
|
90
|
+
const u = origin(value);
|
|
91
|
+
const {approved = []} = await chrome.storage.local.get('approved');
|
|
92
|
+
await chrome.storage.local.set({approved: [...new Set([...approved, u.origin])]});
|
|
93
|
+
connect();
|
|
94
|
+
if (socket?.readyState !== WebSocket.OPEN || !socket.isVerified()) return {error: 'Not connected'};
|
|
95
|
+
const id = crypto.randomUUID();
|
|
96
|
+
const result = new Promise(resolve => pending.set(id, resolve));
|
|
97
|
+
socket.send(JSON.stringify({op: 'copy', id, origin: u.origin}));
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
chrome.runtime.onStartup.addListener(connect);
|
|
101
|
+
chrome.runtime.onInstalled.addListener(connect);
|
|
102
|
+
chrome.alarms.create('connect', {periodInMinutes: 0.5});
|
|
103
|
+
chrome.alarms.onAlarm.addListener(connect);
|
|
104
|
+
chrome.runtime.onMessage.addListener((message, sender, reply) => {
|
|
105
|
+
if (sender.id !== chrome.runtime.id) return;
|
|
106
|
+
if (message.op === 'allow') { asking.get(sender.tab?.windowId)?.(message.answer === true); return; }
|
|
107
|
+
if (message.op === 'repair') { chrome.storage.local.remove('secret').then(() => chrome.storage.session.set({pairing: null})).then(() => { socket?.close(); socket = undefined; connect(); }); return; }
|
|
108
|
+
if (message.op !== 'copy') return;
|
|
109
|
+
copy(message.origin).then(reply, () => reply({error: 'Invalid site'}));
|
|
110
|
+
return true;
|
|
111
|
+
});
|
|
112
|
+
connect();
|
package/icons/128.png
ADDED
|
Binary file
|
package/icons/16.png
ADDED
|
Binary file
|
package/icons/32.png
ADDED
|
Binary file
|
package/icons/48.png
ADDED
|
Binary file
|
package/manifest.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 3,
|
|
3
|
+
"name": "syncme",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "copy your logins for a site into a separate chrome that your ai agents and scripts can use",
|
|
6
|
+
"icons": {
|
|
7
|
+
"16": "icons/16.png",
|
|
8
|
+
"32": "icons/32.png",
|
|
9
|
+
"48": "icons/48.png",
|
|
10
|
+
"128": "icons/128.png"
|
|
11
|
+
},
|
|
12
|
+
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyDi/Cstz6nR487vgWA2QTykf48Svj3DFUeSMva5uZdxoYIJSxKW1S9d+YGFFM0osrctIUWL6YbeGQYxWcnakI4oItc9x0oKO9w5lLYm1Azzn3zy/Pj/8dN647SyBjeyjW5EDAs2eNwWj15qjOy+y1u3Oqep/8kx0eVHwoHWwojH2trvOIBUwa1zd9eJ8fV4lcrEUr9okjlaZEB3s+qYR/D/ezvOlP3VMHztCL1dptv/Y8DtVVFqC+AkfaOqLEGYfaXufF+cdG/V6DBu6YZ1mww1VET/rjcGKCl+OsEFIwIOU0usOWbLB1JnspVupeq8CDe+baHBU24qQZc3U7UpaUQIDAQAB",
|
|
13
|
+
"permissions": [
|
|
14
|
+
"alarms",
|
|
15
|
+
"cookies",
|
|
16
|
+
"storage"
|
|
17
|
+
],
|
|
18
|
+
"host_permissions": [
|
|
19
|
+
"http://*/*",
|
|
20
|
+
"https://*/*"
|
|
21
|
+
],
|
|
22
|
+
"background": {
|
|
23
|
+
"service_worker": "background.js"
|
|
24
|
+
},
|
|
25
|
+
"action": {
|
|
26
|
+
"default_popup": "approve.html",
|
|
27
|
+
"default_icon": {
|
|
28
|
+
"16": "icons/16.png",
|
|
29
|
+
"32": "icons/32.png"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"options_page": "approve.html"
|
|
33
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "syncme-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "copy your logins for a site into a separate chrome that your ai agents and scripts can use",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"syncme-mcp": "syncme.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=20"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"playwright-core": "^1.49.0",
|
|
14
|
+
"ws": "^8.18.0"
|
|
15
|
+
},
|
|
16
|
+
"license": "PolyForm-Noncommercial-1.0.0",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/levinkin/syncme.git"
|
|
20
|
+
}
|
|
21
|
+
}
|
package/syncme.js
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {spawn, spawnSync} from 'node:child_process';
|
|
3
|
+
import {createHash, createHmac, randomBytes, timingSafeEqual} from 'node:crypto';
|
|
4
|
+
import {chmodSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs';
|
|
5
|
+
import {homedir, tmpdir} from 'node:os';
|
|
6
|
+
import {dirname, join} from 'node:path';
|
|
7
|
+
import {createInterface} from 'node:readline';
|
|
8
|
+
import {fileURLToPath} from 'node:url';
|
|
9
|
+
import {chromium} from 'playwright-core';
|
|
10
|
+
import {WebSocket, WebSocketServer} from 'ws';
|
|
11
|
+
|
|
12
|
+
const ROOT = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const MANIFEST = JSON.parse(readFileSync(join(ROOT, 'manifest.json'), 'utf8'));
|
|
14
|
+
const EXTENSION = createHash('sha256').update(Buffer.from(MANIFEST.key, 'base64')).digest('hex').slice(0, 32).replace(/[0-9a-f]/g, c => String.fromCharCode(97 + parseInt(c, 16)));
|
|
15
|
+
const ORIGIN = 'chrome-extension://' + EXTENSION;
|
|
16
|
+
const STATE = process.platform === 'win32' ? join(process.env.LOCALAPPDATA || homedir(), 'syncme') : process.platform === 'darwin' ? join(homedir(), 'Library', 'Application Support', 'syncme') : join(homedir(), '.local', 'state', 'syncme');
|
|
17
|
+
const PORT = 47621;
|
|
18
|
+
const LIMIT = 1024 * 1024;
|
|
19
|
+
const CLIENT_URL = 'ws://127.0.0.1:' + PORT + '/client';
|
|
20
|
+
const BRIDGE_URL = 'ws://127.0.0.1:' + PORT + '/';
|
|
21
|
+
const SERVER = {command: 'npx', args: ['-y', 'syncme-mcp']};
|
|
22
|
+
const SAME_SITE = {no_restriction: 'None', lax: 'Lax', strict: 'Strict', unspecified: 'Lax'};
|
|
23
|
+
|
|
24
|
+
const rejected = phase => Object.assign(Error('Rejected'), {phase});
|
|
25
|
+
const hex64 = value => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
|
|
26
|
+
const proof = (token, purpose) => createHmac('sha256', token).update(purpose).digest('hex');
|
|
27
|
+
const same = (left, right) => typeof left === 'string' && typeof right === 'string' && left.length === right.length && timingSafeEqual(Buffer.from(left), Buffer.from(right));
|
|
28
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
29
|
+
|
|
30
|
+
function privateState() {
|
|
31
|
+
mkdirSync(STATE, {recursive: true, mode: 0o700});
|
|
32
|
+
if (lstatSync(STATE).isSymbolicLink()) throw Error('Unsafe runtime directory');
|
|
33
|
+
if (process.platform === 'win32') {
|
|
34
|
+
const sid = spawnSync('whoami', ['/user', '/fo', 'csv', '/nh'], {encoding: 'utf8'}).stdout.trim().split(',').pop().trim().replaceAll('"', '');
|
|
35
|
+
const result = spawnSync('icacls', [STATE, '/inheritance:r', '/grant:r', '*' + sid + ':(OI)(CI)F'], {stdio: 'ignore'});
|
|
36
|
+
if (result.status !== 0) throw Error('Unsafe runtime permissions');
|
|
37
|
+
} else chmodSync(STATE, 0o700);
|
|
38
|
+
const token = join(STATE, 'token');
|
|
39
|
+
try {
|
|
40
|
+
writeFileSync(token, randomBytes(32).toString('hex'), {flag: 'wx', mode: 0o600});
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error.code !== 'EEXIST') throw error;
|
|
43
|
+
}
|
|
44
|
+
return readFileSync(token, 'utf8');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function send(socket, value) {
|
|
48
|
+
const data = JSON.stringify(value);
|
|
49
|
+
if (!data.length || Buffer.byteLength(data) > LIMIT) throw Error('Message too large');
|
|
50
|
+
if (socket.readyState !== WebSocket.OPEN) throw Error('Disconnected');
|
|
51
|
+
socket.send(data);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function messages(socket) {
|
|
55
|
+
const queue = [];
|
|
56
|
+
const waiting = [];
|
|
57
|
+
let failure;
|
|
58
|
+
const fail = () => {
|
|
59
|
+
failure = failure || Error('Connection closed');
|
|
60
|
+
for (const waiter of waiting.splice(0)) waiter.reject(failure);
|
|
61
|
+
};
|
|
62
|
+
socket.on('message', data => {
|
|
63
|
+
let value;
|
|
64
|
+
try {
|
|
65
|
+
value = JSON.parse(data.toString());
|
|
66
|
+
} catch {
|
|
67
|
+
return fail();
|
|
68
|
+
}
|
|
69
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return fail();
|
|
70
|
+
if (waiting.length) waiting.shift().resolve(value);
|
|
71
|
+
else queue.push(value);
|
|
72
|
+
});
|
|
73
|
+
socket.on('close', fail);
|
|
74
|
+
socket.on('error', fail);
|
|
75
|
+
return () => {
|
|
76
|
+
if (queue.length) return Promise.resolve(queue.shift());
|
|
77
|
+
if (failure) return Promise.reject(failure);
|
|
78
|
+
return new Promise((resolve, reject) => waiting.push({resolve, reject}));
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function authorize(socket, next, token) {
|
|
83
|
+
const challenge = randomBytes(32).toString('hex');
|
|
84
|
+
send(socket, {challenge});
|
|
85
|
+
const auth = await next();
|
|
86
|
+
if (!hex64(auth.nonce) || !same(auth.proof, proof(token, 'client:' + challenge))) {
|
|
87
|
+
send(socket, {error: 'Unauthorized'});
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
send(socket, {proof: proof(token, 'server:' + auth.nonce)});
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function authenticate(socket, next, token) {
|
|
95
|
+
const {challenge} = await next();
|
|
96
|
+
if (!hex64(challenge)) throw Error('Invalid broker challenge');
|
|
97
|
+
const nonce = randomBytes(32).toString('hex');
|
|
98
|
+
send(socket, {proof: proof(token, 'client:' + challenge), nonce});
|
|
99
|
+
const {proof: answer} = await next();
|
|
100
|
+
if (!same(answer, proof(token, 'server:' + nonce))) throw Error('Untrusted local endpoint');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function origin(value) {
|
|
104
|
+
if (typeof value !== 'string' || value.length > 2048 || !/^[\x21-\x7e]+$/.test(value) || value.includes('\\')) throw rejected('origin');
|
|
105
|
+
let url;
|
|
106
|
+
try {
|
|
107
|
+
url = new URL(value);
|
|
108
|
+
} catch {
|
|
109
|
+
throw rejected('origin');
|
|
110
|
+
}
|
|
111
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash || url.origin !== value) throw rejected('origin');
|
|
112
|
+
if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(url.hostname) || url.hostname.includes('..')) throw rejected('origin');
|
|
113
|
+
return url;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function cookiesFor(site, cookies) {
|
|
117
|
+
if (!Array.isArray(cookies) || cookies.length > 3000) throw rejected('cookies');
|
|
118
|
+
const result = [];
|
|
119
|
+
for (const c of cookies) {
|
|
120
|
+
if (!c || typeof c !== 'object' || typeof c.domain !== 'string' || c.domain.replace(/^\./, '') !== site.hostname || c.partitionKey) throw rejected('cookies');
|
|
121
|
+
for (const field of ['name', 'value', 'domain', 'path']) {
|
|
122
|
+
if (typeof c[field] !== 'string' || c[field].length > 16384) throw rejected('cookies');
|
|
123
|
+
}
|
|
124
|
+
if (!c.path.startsWith('/') || ['secure', 'httpOnly', 'hostOnly', 'session'].some(field => typeof c[field] !== 'boolean')) throw rejected('cookies');
|
|
125
|
+
if (c.secure && site.protocol !== 'https:') throw rejected('cookies');
|
|
126
|
+
const item = {name: c.name, value: c.value, path: c.path, secure: c.secure, httpOnly: c.httpOnly};
|
|
127
|
+
item.domain = c.hostOnly ? site.hostname : c.domain;
|
|
128
|
+
if (!Object.hasOwn(SAME_SITE, c.sameSite)) throw rejected('cookies');
|
|
129
|
+
item.sameSite = SAME_SITE[c.sameSite];
|
|
130
|
+
if (!c.session) {
|
|
131
|
+
const expiry = c.expirationDate;
|
|
132
|
+
if (typeof expiry !== 'number' || !Number.isFinite(expiry) || expiry <= 0) throw rejected('cookies');
|
|
133
|
+
item.expires = expiry;
|
|
134
|
+
}
|
|
135
|
+
result.push(item);
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function connectSocket(url) {
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
const socket = new WebSocket(url, {handshakeTimeout: 5000});
|
|
143
|
+
socket.on('error', () => reject(Error('offline')));
|
|
144
|
+
socket.once('open', () => resolve(socket));
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function startServer(launch) {
|
|
149
|
+
const token = privateState();
|
|
150
|
+
for (let attempt = 0; attempt < 61; attempt++) {
|
|
151
|
+
let socket;
|
|
152
|
+
try {
|
|
153
|
+
socket = await connectSocket(CLIENT_URL);
|
|
154
|
+
await authenticate(socket, messages(socket), token);
|
|
155
|
+
return socket;
|
|
156
|
+
} catch {
|
|
157
|
+
socket?.close();
|
|
158
|
+
if (!launch) return null;
|
|
159
|
+
if (attempt === 0) spawn(process.execPath, [process.argv[1], 'serve'], {detached: true, stdio: 'ignore', windowsHide: true}).unref();
|
|
160
|
+
}
|
|
161
|
+
await sleep(100);
|
|
162
|
+
}
|
|
163
|
+
throw Error('Could not start syncme bridge');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function request(message) {
|
|
167
|
+
const socket = await startServer(message.op === 'sync' || message.op === 'open');
|
|
168
|
+
if (!socket) return {ok: true};
|
|
169
|
+
const next = messages(socket);
|
|
170
|
+
try {
|
|
171
|
+
send(socket, message);
|
|
172
|
+
return await Promise.race([next(), new Promise(resolve => setTimeout(resolve, 150000, {error: 'Timed out'}).unref())]);
|
|
173
|
+
} finally {
|
|
174
|
+
socket.close();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function verify(info, done) {
|
|
179
|
+
const path = new URL(info.req.url, 'http://127.0.0.1').pathname;
|
|
180
|
+
if (path === '/client') return done(!info.req.headers.origin);
|
|
181
|
+
if (path !== '/') return done(false);
|
|
182
|
+
done(info.origin === ORIGIN);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function serve() {
|
|
186
|
+
const token = privateState();
|
|
187
|
+
const server = new WebSocketServer({host: '127.0.0.1', port: PORT, maxPayload: LIMIT, verifyClient: verify});
|
|
188
|
+
server.on('error', () => process.exit(0));
|
|
189
|
+
await new Promise(resolve => server.once('listening', resolve));
|
|
190
|
+
let bridge, context, endpoint;
|
|
191
|
+
let headless = false;
|
|
192
|
+
const pending = new Map();
|
|
193
|
+
let queue = Promise.resolve();
|
|
194
|
+
const enqueue = task => (queue = queue.then(task, task).catch(() => {}));
|
|
195
|
+
|
|
196
|
+
async function closeBrowser() {
|
|
197
|
+
const current = context;
|
|
198
|
+
context = endpoint = undefined;
|
|
199
|
+
if (current) {
|
|
200
|
+
try {
|
|
201
|
+
await current.close();
|
|
202
|
+
} catch {}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function ask(message) {
|
|
207
|
+
if (!bridge) throw rejected('bridge');
|
|
208
|
+
const id = randomBytes(16).toString('hex');
|
|
209
|
+
const response = new Promise((resolve, reject) => {
|
|
210
|
+
const timer = setTimeout(() => pending.delete(id) && reject(rejected('bridge')), 120000);
|
|
211
|
+
pending.set(id, {resolve: value => (clearTimeout(timer), resolve(value)), reject: error => (clearTimeout(timer), reject(error))});
|
|
212
|
+
});
|
|
213
|
+
send(bridge, {...message, id});
|
|
214
|
+
return response;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function bridgeGone(socket) {
|
|
218
|
+
if (bridge !== socket) return;
|
|
219
|
+
bridge = undefined;
|
|
220
|
+
for (const waiter of pending.values()) waiter.reject(Error('Bridge disconnected'));
|
|
221
|
+
pending.clear();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function copyIntoBrowser(values, fromExtension) {
|
|
225
|
+
for (const value of values) origin(value);
|
|
226
|
+
if (values.length && !bridge) throw rejected('bridge');
|
|
227
|
+
if (context) {
|
|
228
|
+
try {
|
|
229
|
+
await context.cookies('https://syncme.invalid');
|
|
230
|
+
} catch {
|
|
231
|
+
context = endpoint = undefined;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
let carried = [];
|
|
235
|
+
if (context && headless && !fromExtension) {
|
|
236
|
+
carried = await context.cookies();
|
|
237
|
+
await context.close();
|
|
238
|
+
context = endpoint = undefined;
|
|
239
|
+
}
|
|
240
|
+
if (!context) {
|
|
241
|
+
headless = fromExtension;
|
|
242
|
+
const profile = join(STATE, 'browser');
|
|
243
|
+
mkdirSync(profile, {recursive: true, mode: 0o700});
|
|
244
|
+
rmSync(join(profile, 'DevToolsActivePort'), {force: true});
|
|
245
|
+
context = await chromium.launchPersistentContext(profile, {channel: 'chrome', headless, args: ['--remote-debugging-address=127.0.0.1', '--remote-debugging-port=0']});
|
|
246
|
+
await context.addCookies(carried);
|
|
247
|
+
const port = Number(readFileSync(join(profile, 'DevToolsActivePort'), 'utf8').split('\n')[0]);
|
|
248
|
+
if (!(port > 0 && port < 65536)) throw Error('Invalid owned browser endpoint');
|
|
249
|
+
endpoint = 'http://127.0.0.1:' + port;
|
|
250
|
+
if (bridge) {
|
|
251
|
+
const listed = await ask({op: 'list'});
|
|
252
|
+
if (!Array.isArray(listed.origins) || listed.origins.length > 1000) throw rejected('bridge');
|
|
253
|
+
for (const value of listed.origins) if (!values.includes(value)) values.push(value);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
let count = 0;
|
|
257
|
+
for (const value of values) {
|
|
258
|
+
const site = origin(value);
|
|
259
|
+
const response = await ask({op: 'sync', origin: value});
|
|
260
|
+
if (response.origin !== value || response.error) throw rejected('bridge');
|
|
261
|
+
const cookies = cookiesFor(site, response.cookies);
|
|
262
|
+
for (const cookie of await context.cookies()) {
|
|
263
|
+
if (cookie.domain.replace(/^\./, '') === site.hostname && (site.protocol === 'https:' || !cookie.secure)) await context.clearCookies({name: cookie.name, domain: cookie.domain, path: cookie.path});
|
|
264
|
+
}
|
|
265
|
+
await context.addCookies(cookies);
|
|
266
|
+
count += cookies.length;
|
|
267
|
+
}
|
|
268
|
+
return count;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function bridgeMessage(socket, message) {
|
|
272
|
+
const waiter = typeof message.id === 'string' ? pending.get(message.id) : undefined;
|
|
273
|
+
if (waiter) {
|
|
274
|
+
pending.delete(message.id);
|
|
275
|
+
waiter.resolve(message);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (message.op === 'copied' || message.op === 'ping') return;
|
|
279
|
+
if (message.op !== 'copy' || typeof message.id !== 'string' || message.id.length > 64) return socket.close();
|
|
280
|
+
if (pending.size) return send(socket, {op: 'copied', id: message.id, error: 'Busy'});
|
|
281
|
+
enqueue(async () => {
|
|
282
|
+
const reply = {op: 'copied', id: message.id};
|
|
283
|
+
try {
|
|
284
|
+
send(socket, {...reply, ok: true, cdp: endpoint, count: await copyIntoBrowser([message.origin], true)});
|
|
285
|
+
} catch (error) {
|
|
286
|
+
try {
|
|
287
|
+
send(socket, {...reply, error: error.phase ? 'Request rejected' : 'Owned browser operation failed', phase: error.phase});
|
|
288
|
+
} catch {}
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function shutdown(socket) {
|
|
294
|
+
for (const waiter of pending.values()) waiter.reject(Error('Shutting down'));
|
|
295
|
+
pending.clear();
|
|
296
|
+
const current = bridge;
|
|
297
|
+
bridge = undefined;
|
|
298
|
+
if (current) current.close();
|
|
299
|
+
server.close();
|
|
300
|
+
setTimeout(() => process.exit(0), 100);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function client(socket, next) {
|
|
304
|
+
const guard = setTimeout(() => socket.close(), 30000);
|
|
305
|
+
let message;
|
|
306
|
+
try {
|
|
307
|
+
if (!(await authorize(socket, next, token))) return socket.close();
|
|
308
|
+
message = await next();
|
|
309
|
+
} catch {
|
|
310
|
+
return socket.close();
|
|
311
|
+
} finally {
|
|
312
|
+
clearTimeout(guard);
|
|
313
|
+
}
|
|
314
|
+
enqueue(() => operate(socket, message));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function operate(socket, message) {
|
|
318
|
+
try {
|
|
319
|
+
const op = message.op;
|
|
320
|
+
if (op === 'sync' || op === 'open') {
|
|
321
|
+
const count = await copyIntoBrowser(op === 'sync' ? [message.origin] : [], false);
|
|
322
|
+
send(socket, {ok: true, cdp: endpoint, count});
|
|
323
|
+
} else if (op === 'stop' || op === 'shutdown') {
|
|
324
|
+
await closeBrowser();
|
|
325
|
+
send(socket, {ok: true});
|
|
326
|
+
} else return send(socket, {error: 'Unknown operation'});
|
|
327
|
+
} catch (error) {
|
|
328
|
+
try {
|
|
329
|
+
send(socket, {error: error.phase ? 'Request rejected' : 'Owned browser operation failed', phase: error.phase});
|
|
330
|
+
} catch {}
|
|
331
|
+
}
|
|
332
|
+
socket.close();
|
|
333
|
+
if (message.op === 'shutdown') shutdown(socket);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
server.on('connection', (socket, incoming) => {
|
|
337
|
+
if (new URL(incoming.url, 'http://127.0.0.1').pathname === '/client') {
|
|
338
|
+
client(socket, messages(socket));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const next = messages(socket);
|
|
342
|
+
(async () => {
|
|
343
|
+
const guard = setTimeout(() => socket.close(), 10000);
|
|
344
|
+
try {
|
|
345
|
+
const pairFile = join(STATE, 'pair');
|
|
346
|
+
let secret = existsSync(pairFile) ? readFileSync(pairFile, 'utf8') : '';
|
|
347
|
+
const nonce = randomBytes(32).toString('hex');
|
|
348
|
+
send(socket, {op: 'hello', nonce, paired: Boolean(secret)});
|
|
349
|
+
let message = await next();
|
|
350
|
+
if (!secret) {
|
|
351
|
+
if (message.op !== 'pair' || !hex64(message.secret)) return socket.close();
|
|
352
|
+
writeFileSync(pairFile, message.secret, {flag: 'wx', mode: 0o600});
|
|
353
|
+
secret = message.secret;
|
|
354
|
+
message = await next();
|
|
355
|
+
}
|
|
356
|
+
if (message.op !== 'proof' || !hex64(message.nonce) || !same(message.proof, proof(secret, 'extension:' + nonce))) {
|
|
357
|
+
send(socket, {op: 'refused'});
|
|
358
|
+
return socket.close();
|
|
359
|
+
}
|
|
360
|
+
clearTimeout(guard);
|
|
361
|
+
send(socket, {op: 'ready', proof: proof(secret, 'server:' + message.nonce)});
|
|
362
|
+
if (bridge) {
|
|
363
|
+
const previous = bridge;
|
|
364
|
+
bridgeGone(previous);
|
|
365
|
+
previous.close();
|
|
366
|
+
}
|
|
367
|
+
bridge = socket;
|
|
368
|
+
for (;;) {
|
|
369
|
+
message = await next();
|
|
370
|
+
if (bridge !== socket) break;
|
|
371
|
+
bridgeMessage(socket, message);
|
|
372
|
+
}
|
|
373
|
+
} catch {
|
|
374
|
+
socket.close();
|
|
375
|
+
bridgeGone(socket);
|
|
376
|
+
} finally {
|
|
377
|
+
clearTimeout(guard);
|
|
378
|
+
}
|
|
379
|
+
})();
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function pack() {
|
|
384
|
+
const stage = mkdtempSync(join(tmpdir(), 'syncme-pack-'));
|
|
385
|
+
const zip = join(ROOT, 'syncme.zip');
|
|
386
|
+
try {
|
|
387
|
+
const {key, ...store} = MANIFEST;
|
|
388
|
+
writeFileSync(join(stage, 'manifest.json'), JSON.stringify(store, null, 2));
|
|
389
|
+
for (const name of ['background.js', 'approve.html', 'approve.js']) copyFileSync(join(ROOT, name), join(stage, name));
|
|
390
|
+
cpSync(join(ROOT, 'icons'), join(stage, 'icons'), {recursive: true});
|
|
391
|
+
rmSync(zip, {force: true});
|
|
392
|
+
const result = process.platform === 'win32'
|
|
393
|
+
? spawnSync('powershell', ['-NoProfile', '-Command', `Compress-Archive -Path '${join(stage, '*')}' -DestinationPath '${zip}' -Force`], {stdio: 'ignore'})
|
|
394
|
+
: spawnSync('zip', ['-r', '-X', '-q', zip, 'manifest.json', 'background.js', 'approve.html', 'approve.js', 'icons'], {cwd: stage, stdio: 'ignore'});
|
|
395
|
+
if (result.status !== 0) throw Error('Pack failed');
|
|
396
|
+
} finally {
|
|
397
|
+
rmSync(stage, {recursive: true, force: true});
|
|
398
|
+
}
|
|
399
|
+
console.log(JSON.stringify({packed: zip}));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function setup() {
|
|
403
|
+
const claude = process.platform === 'win32' ? join(process.env.APPDATA || homedir(), 'Claude', 'claude_desktop_config.json') : join(homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
|
|
404
|
+
const configured = [];
|
|
405
|
+
const failed = [];
|
|
406
|
+
for (const [name, file] of [['claude desktop', claude], ['cursor', join(homedir(), '.cursor', 'mcp.json')], ['windsurf', join(homedir(), '.codeium', 'windsurf', 'mcp_config.json')]]) {
|
|
407
|
+
if (!existsSync(dirname(file))) continue;
|
|
408
|
+
try {
|
|
409
|
+
const existing = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
|
|
410
|
+
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) throw Error('Unreadable config');
|
|
411
|
+
const servers = existing.mcpServers && typeof existing.mcpServers === 'object' && !Array.isArray(existing.mcpServers) ? existing.mcpServers : {};
|
|
412
|
+
writeFileSync(file, JSON.stringify({...existing, mcpServers: {...servers, syncme: SERVER}}, null, 2) + '\n');
|
|
413
|
+
configured.push(name);
|
|
414
|
+
} catch {
|
|
415
|
+
failed.push(file);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
for (const [name, args] of [['claude', ['mcp', 'add', '--scope', 'user', 'syncme', '--', 'npx', '-y', 'syncme-mcp']], ['codex', ['mcp', 'add', 'syncme', '--', 'npx', '-y', 'syncme-mcp']]]) {
|
|
419
|
+
const result = spawnSync(name, args, {stdio: 'ignore'});
|
|
420
|
+
if (result.status === 0) configured.push(name + ' cli');
|
|
421
|
+
else if (!result.error || result.error.code !== 'ENOENT') failed.push(name + ' cli');
|
|
422
|
+
}
|
|
423
|
+
console.log(JSON.stringify({configured, failed}));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function mcp() {
|
|
427
|
+
const tools = [
|
|
428
|
+
{name: 'copy_site', description: "Copy the user's login cookies for one site (like https://github.com) from their Chrome into the AI browser, then return the AI browser's CDP url. If the site isn't saved yet, the user gets asked in Chrome first.", inputSchema: {type: 'object', properties: {site: {type: 'string', description: 'exact origin, like https://github.com'}}, required: ['site']}},
|
|
429
|
+
{name: 'open_browser', description: "Start the AI browser with the user's saved logins and return its CDP url. Connect with Playwright connect_over_cdp or any CDP client.", inputSchema: {type: 'object', properties: {}}},
|
|
430
|
+
{name: 'close_browser', description: 'Close the AI browser.', inputSchema: {type: 'object', properties: {}}},
|
|
431
|
+
];
|
|
432
|
+
const versions = ['2025-06-18', '2025-03-26', '2024-11-05'];
|
|
433
|
+
const names = tools.map(tool => tool.name);
|
|
434
|
+
const write = value => process.stdout.write(JSON.stringify(value) + '\n');
|
|
435
|
+
let queue = Promise.resolve();
|
|
436
|
+
async function handle(line) {
|
|
437
|
+
let message;
|
|
438
|
+
try {
|
|
439
|
+
message = JSON.parse(line);
|
|
440
|
+
} catch {
|
|
441
|
+
return write({jsonrpc: '2.0', id: null, error: {code: -32700, message: 'Parse error'}});
|
|
442
|
+
}
|
|
443
|
+
if (!message || typeof message !== 'object' || Array.isArray(message) || !('id' in message)) return;
|
|
444
|
+
const params = message.params && typeof message.params === 'object' ? message.params : {};
|
|
445
|
+
const args = params.arguments && typeof params.arguments === 'object' ? params.arguments : {};
|
|
446
|
+
const method = message.method;
|
|
447
|
+
if (method === 'tools/call' && !names.includes(params.name)) return write({jsonrpc: '2.0', id: message.id, error: {code: -32602, message: 'Unknown tool'}});
|
|
448
|
+
if (method === 'tools/call' && params.name === 'copy_site' && typeof args.site !== 'string') return write({jsonrpc: '2.0', id: message.id, error: {code: -32602, message: 'copy_site needs a site string'}});
|
|
449
|
+
let result;
|
|
450
|
+
if (method === 'initialize') {
|
|
451
|
+
const requested = params.protocolVersion;
|
|
452
|
+
result = {protocolVersion: versions.includes(requested) ? requested : versions[0], capabilities: {tools: {}}, serverInfo: {name: 'syncme', version: MANIFEST.version}};
|
|
453
|
+
} else if (method === 'tools/list') {
|
|
454
|
+
result = {tools};
|
|
455
|
+
} else if (method === 'tools/call') {
|
|
456
|
+
let response;
|
|
457
|
+
try {
|
|
458
|
+
if (params.name === 'copy_site') {
|
|
459
|
+
origin(args.site);
|
|
460
|
+
response = await request({op: 'sync', origin: args.site});
|
|
461
|
+
} else response = await request({op: params.name === 'open_browser' ? 'open' : 'stop'});
|
|
462
|
+
} catch {
|
|
463
|
+
response = {error: 'syncme bridge unavailable or site invalid'};
|
|
464
|
+
}
|
|
465
|
+
let text;
|
|
466
|
+
if (response.error) text = 'Failed: ' + response.error + '. The site must be an exact origin like https://github.com, Chrome must be open with the syncme extension, and the user must allow the site.';
|
|
467
|
+
else if (params.name === 'close_browser') text = 'AI browser closed.';
|
|
468
|
+
else text = JSON.stringify({cdp: response.cdp, copied_cookies: response.count});
|
|
469
|
+
result = {content: [{type: 'text', text}], isError: Boolean(response.error)};
|
|
470
|
+
} else if (method === 'ping') {
|
|
471
|
+
result = {};
|
|
472
|
+
} else return write({jsonrpc: '2.0', id: message.id, error: {code: -32601, message: 'Method not found'}});
|
|
473
|
+
write({jsonrpc: '2.0', id: message.id, result});
|
|
474
|
+
}
|
|
475
|
+
createInterface({input: process.stdin}).on('line', line => (queue = queue.then(() => handle(line)).catch(() => {})));
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function main() {
|
|
479
|
+
const command = process.argv[2] || '';
|
|
480
|
+
if (!command) return mcp();
|
|
481
|
+
if (command === 'serve') return serve();
|
|
482
|
+
if (command === 'pack') return pack();
|
|
483
|
+
if (command === 'setup') return setup();
|
|
484
|
+
if (command === 'unpair') return rmSync(join(STATE, 'pair'), {force: true});
|
|
485
|
+
if (!['sync', 'open', 'stop', 'shutdown'].includes(command) || process.argv.length !== (command === 'sync' ? 4 : 3)) throw Error('Usage: syncme-mcp open | sync https://example.com | stop | shutdown | setup | unpair | pack');
|
|
486
|
+
const message = {op: command};
|
|
487
|
+
if (command === 'sync') {
|
|
488
|
+
origin(process.argv[3]);
|
|
489
|
+
message.origin = process.argv[3];
|
|
490
|
+
}
|
|
491
|
+
const response = await request(message);
|
|
492
|
+
console.log(JSON.stringify(response));
|
|
493
|
+
if (response.error) process.exitCode = 1;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
try {
|
|
497
|
+
await main();
|
|
498
|
+
} catch {
|
|
499
|
+
console.error(JSON.stringify({error: 'syncme failed; check the origin and that chrome has the syncme extension'}));
|
|
500
|
+
process.exit(1);
|
|
501
|
+
}
|