@reclaimprotocol/agent 0.1.24 → 0.1.25
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 -2
- package/lib/cdp/claims.d.ts +21 -0
- package/lib/cdp/claims.js +129 -0
- package/lib/cdp/client.d.ts +0 -10
- package/lib/cdp/container.d.ts +85 -0
- package/lib/cdp/container.js +481 -0
- package/lib/live/tunnel.d.ts +6 -3
- package/lib/live/tunnel.js +57 -17
- package/lib/mcp/instructions.js +2 -1
- package/lib/mcp/start.js +58 -3
- package/lib/mcp/tools/agent/attach.d.ts +22 -2
- package/lib/mcp/tools/agent/attach.js +197 -55
- package/lib/mcp/tools/agent/build.d.ts +5 -0
- package/lib/mcp/tools/agent/build.js +9 -2
- package/lib/mcp/tools/agent/index.d.ts +1 -2
- package/lib/mcp/tools/agent/index.js +1 -1
- package/lib/mcp/tools/agent/live-view.d.ts +3 -7
- package/lib/mcp/tools/agent/live-view.js +74 -81
- package/lib/mcp/tools/agent/session.d.ts +14 -0
- package/lib/mcp/tools/agent/session.js +14 -8
- package/package.json +2 -2
- package/skill/SKILL.common.md +115 -38
- package/skill/builder-mode.md +9 -7
- package/skill/interception.md +3 -2
- package/skill/old-mode.md +10 -8
- package/src/cdp/claims.ts +153 -0
- package/src/cdp/client.ts +0 -8
- package/src/cdp/container.ts +593 -0
- package/src/live/tunnel.ts +71 -20
- package/src/mcp/instructions.ts +2 -1
- package/src/mcp/start.ts +65 -2
- package/src/mcp/tools/agent/attach.ts +229 -60
- package/src/mcp/tools/agent/build.ts +10 -2
- package/src/mcp/tools/agent/index.ts +6 -2
- package/src/mcp/tools/agent/live-view.ts +77 -93
- package/src/mcp/tools/agent/session.ts +14 -8
- package/lib/cdp/browser-proxy.d.ts +0 -78
- package/lib/cdp/browser-proxy.js +0 -186
- package/lib/live/page.d.ts +0 -20
- package/lib/live/page.js +0 -134
- package/lib/live/viewer.d.ts +0 -28
- package/lib/live/viewer.js +0 -232
- package/lib/mcp/tools/agent/proxy.d.ts +0 -3
- package/lib/mcp/tools/agent/proxy.js +0 -199
- package/src/live/page.ts +0 -145
- package/src/live/viewer.ts +0 -293
package/README.md
CHANGED
|
@@ -12,6 +12,7 @@ This package **is** the Reclaim MCP server (bin: `reclaim-mcp-server`). It depen
|
|
|
12
12
|
|
|
13
13
|
- **Node 20 or later.** Any line works, including non-LTS ones such as 25, but a current LTS line (22 or 24) is recommended.
|
|
14
14
|
- **A local Chrome, Chromium, or Edge**, used for capture and for the dashboard login.
|
|
15
|
+
- **Docker** — only for `attach_browser`'s `container` mode, which runs the Reclaim browser runtime (the same image the hosted browser uses) and gives you a link you can send to whoever holds the account. It is the free way to get that link; the hosted remote browser is the other, and it is chargeable. Not needed if you are doing the work yourself. The image is amd64, so Apple Silicon runs it translated. OrbStack handles that itself; Docker Desktop needs Rosetta enabled. `RECLAIM_BROWSER_RUNTIME_IMAGE` overrides the pinned digest.
|
|
15
16
|
- **`cloudflared`** — not required up front. `share_browser_view` uses an existing install if one is on `PATH`, and otherwise downloads it once to `~/.reclaim/bin` on first share (never at install time). Set `RECLAIM_CLOUDFLARED_PATH` to point at your own copy.
|
|
16
17
|
|
|
17
18
|
The first time you call `run_proof`, the agent downloads the ZK circuit files it needs (about 280 MB), so allow a moment for that first proof. The download is deferred to the first proof rather than run at install time, so the MCP server itself starts immediately.
|
|
@@ -87,7 +88,7 @@ These capture/synthesis/proof tools are backend-independent and registered in bo
|
|
|
87
88
|
|
|
88
89
|
| Tool | Purpose |
|
|
89
90
|
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
90
|
-
| `attach_browser` | Connect
|
|
91
|
+
| `attach_browser` | Connect to a browser. Grouped by use case: working alone → local Chrome (launched, or the one already open); someone else has to sign in → the Reclaim runtime in Docker (free) or a hosted remote browser (chargeable). The tool description asks which case applies before listing anything, so an agent can put the choice to the developer and connect in one call |
|
|
91
92
|
| `list_tabs` | List open Chrome tabs |
|
|
92
93
|
| `navigate`, `wait_for_page`, `eval_in_page` | Drive the tab; poll for login completion |
|
|
93
94
|
| `start_capture`, `stop_capture` | Network capture lifecycle |
|
|
@@ -100,7 +101,7 @@ These capture/synthesis/proof tools are backend-independent and registered in bo
|
|
|
100
101
|
| `create_provider_version_from_capture` | Publish a drafted provider to the backend |
|
|
101
102
|
| `authenticate_builder` | In old-devtools mode only, sign in to Builder for remote-browser allocation |
|
|
102
103
|
| `list_builder_organizations` | In old-devtools mode only, list organization ids for a quota-accounted Builder browser |
|
|
103
|
-
| `share_browser_view`, `stop_browser_view` | Put
|
|
104
|
+
| `share_browser_view`, `stop_browser_view` | Put the `container` browser's own live view behind a public HTTPS URL (cloudflared quick tunnel), so someone elsewhere can watch it and act in it — from a phone if they like. Container mode only: a `builder` browser already returns a hosted `liveViewUrl`, and a plain local browser has no view to share |
|
|
104
105
|
| `dispose_browser` | Stop captures, disconnect CDP, and immediately release a Builder-hosted browser |
|
|
105
106
|
| `get_session`, `reset_session` | Session state |
|
|
106
107
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Whether a process is still running. Signal 0 checks without delivering. */
|
|
2
|
+
export declare function processAlive(pid: number): boolean;
|
|
3
|
+
/** Slots with at least one live claimant, so a caller can avoid them. */
|
|
4
|
+
export declare function busySlots(): Set<string>;
|
|
5
|
+
/**
|
|
6
|
+
* Slots THIS process already holds.
|
|
7
|
+
*
|
|
8
|
+
* Its own claim must not push it onto a new slot: re-attaching in one session
|
|
9
|
+
* should come back to the browser it is already running, not start a second.
|
|
10
|
+
*/
|
|
11
|
+
export declare function ownSlots(): Set<string>;
|
|
12
|
+
/** Take a claim on `slot`. Returns the token to release it with. */
|
|
13
|
+
export declare function claim(slot: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* Give up this process's claim.
|
|
16
|
+
*
|
|
17
|
+
* Returns true when nobody is left, which is the caller's signal to stop the
|
|
18
|
+
* container. Returns false while another live session still holds it — stopping
|
|
19
|
+
* then would pull the browser out from under them.
|
|
20
|
+
*/
|
|
21
|
+
export declare function release(slot: string, token: string): boolean;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which sessions are using which browser container.
|
|
3
|
+
*
|
|
4
|
+
* A container is worth sharing: about 2 GB of RAM and 7 to 17 seconds to start,
|
|
5
|
+
* so a developer running one session after another should get the same one back
|
|
6
|
+
* rather than pay for a new one each time.
|
|
7
|
+
*
|
|
8
|
+
* It can only be shared SEQUENTIALLY, and the profile is why. Every container
|
|
9
|
+
* mounts a host directory as Chromium's `user-data-dir`, and the runtime
|
|
10
|
+
* deletes `SingletonLock` on boot — so a second container on the same profile
|
|
11
|
+
* is a second writer on one cookie jar. Chromium does not support that, and the
|
|
12
|
+
* failure is silent corruption of the thing most worth keeping: the login.
|
|
13
|
+
*
|
|
14
|
+
* So each container gets a SLOT. A slot has one profile and at most one live
|
|
15
|
+
* claimant. A session adopts the first slot nobody holds; when the last claim
|
|
16
|
+
* on a slot goes, the container stops.
|
|
17
|
+
*
|
|
18
|
+
* State lives in a file because each MCP session is its own OS process, with
|
|
19
|
+
* no shared memory to count in. Claims are process ids, and liveness is a
|
|
20
|
+
* signal-0 check — the same test the orphan reaper uses. It fails the harmless
|
|
21
|
+
* way: a recycled pid keeps a container alive rather than pulling one out from
|
|
22
|
+
* under a running session.
|
|
23
|
+
*/
|
|
24
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { dirname, join } from 'node:path';
|
|
26
|
+
import { LOGGER } from "../logger.js";
|
|
27
|
+
import { reclaimHomeDirPath } from "../paths.js";
|
|
28
|
+
/** Distinguishes claims made by this same process. */
|
|
29
|
+
let claimSeq = 0;
|
|
30
|
+
/** The pid a token belongs to, or 0 when it is malformed. */
|
|
31
|
+
function tokenPid(token) {
|
|
32
|
+
const pid = Number(token.split('.')[0]);
|
|
33
|
+
return Number.isInteger(pid) ? pid : 0;
|
|
34
|
+
}
|
|
35
|
+
function claimsPath() {
|
|
36
|
+
return join(reclaimHomeDirPath(), 'browser-runtime-claims.json');
|
|
37
|
+
}
|
|
38
|
+
function read() {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(readFileSync(claimsPath(), 'utf8'));
|
|
41
|
+
// Drop claims whose process is gone: that is what makes a slot free again
|
|
42
|
+
// after a session was killed without cleaning up.
|
|
43
|
+
const live = {};
|
|
44
|
+
for (const [slot, tokens] of Object.entries(parsed)) {
|
|
45
|
+
const alive = (Array.isArray(tokens) ? tokens : [])
|
|
46
|
+
.filter((token) => processAlive(tokenPid(token)));
|
|
47
|
+
if (alive.length) {
|
|
48
|
+
live[slot] = alive;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return live;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Missing or unreadable: nobody has claimed anything.
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function write(claims) {
|
|
59
|
+
const path = claimsPath();
|
|
60
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
61
|
+
// Write then rename, so a reader never sees a half-written file. Two sessions
|
|
62
|
+
// racing here means last-writer-wins on a file that is pruned on every read,
|
|
63
|
+
// which costs at most one redundant container.
|
|
64
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
65
|
+
writeFileSync(temp, `${JSON.stringify(claims, null, '\t')}\n`);
|
|
66
|
+
renameSync(temp, path);
|
|
67
|
+
}
|
|
68
|
+
/** Whether a process is still running. Signal 0 checks without delivering. */
|
|
69
|
+
export function processAlive(pid) {
|
|
70
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
process.kill(pid, 0);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
// EPERM means it exists but belongs to another user — alive either way.
|
|
79
|
+
return err.code === 'EPERM';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Slots with at least one live claimant, so a caller can avoid them. */
|
|
83
|
+
export function busySlots() {
|
|
84
|
+
return new Set(Object.keys(read()));
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Slots THIS process already holds.
|
|
88
|
+
*
|
|
89
|
+
* Its own claim must not push it onto a new slot: re-attaching in one session
|
|
90
|
+
* should come back to the browser it is already running, not start a second.
|
|
91
|
+
*/
|
|
92
|
+
export function ownSlots() {
|
|
93
|
+
const mine = new Set();
|
|
94
|
+
for (const [slot, tokens] of Object.entries(read())) {
|
|
95
|
+
if (tokens.some((token) => tokenPid(token) === process.pid)) {
|
|
96
|
+
mine.add(slot);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return mine;
|
|
100
|
+
}
|
|
101
|
+
/** Take a claim on `slot`. Returns the token to release it with. */
|
|
102
|
+
export function claim(slot) {
|
|
103
|
+
const token = `${process.pid}.${++claimSeq}`;
|
|
104
|
+
const claims = read();
|
|
105
|
+
claims[slot] = [...claims[slot] ?? [], token];
|
|
106
|
+
write(claims);
|
|
107
|
+
LOGGER.debug({ slot, held: claims[slot] }, 'claimed a browser slot');
|
|
108
|
+
return token;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Give up this process's claim.
|
|
112
|
+
*
|
|
113
|
+
* Returns true when nobody is left, which is the caller's signal to stop the
|
|
114
|
+
* container. Returns false while another live session still holds it — stopping
|
|
115
|
+
* then would pull the browser out from under them.
|
|
116
|
+
*/
|
|
117
|
+
export function release(slot, token) {
|
|
118
|
+
const claims = read();
|
|
119
|
+
const remaining = (claims[slot] ?? []).filter((held) => held !== token);
|
|
120
|
+
if (remaining.length) {
|
|
121
|
+
claims[slot] = remaining;
|
|
122
|
+
write(claims);
|
|
123
|
+
LOGGER.debug({ slot, remaining }, 'released a claim; others still hold it');
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
delete claims[slot];
|
|
127
|
+
write(claims);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
package/lib/cdp/client.d.ts
CHANGED
|
@@ -19,16 +19,6 @@ interface CdpTabEventMap {
|
|
|
19
19
|
'Network.loadingFinished': {
|
|
20
20
|
requestId: string;
|
|
21
21
|
};
|
|
22
|
-
/** Screencast frames, for the live view (`live/viewer.ts`). Each carries a
|
|
23
|
-
* `sessionId` that MUST be acked with `Page.screencastFrameAck`, or CDP
|
|
24
|
-
* stops sending. */
|
|
25
|
-
'Page.screencastFrame': {
|
|
26
|
-
data: string;
|
|
27
|
-
sessionId: number;
|
|
28
|
-
metadata?: {
|
|
29
|
-
timestamp?: number;
|
|
30
|
-
};
|
|
31
|
-
};
|
|
32
22
|
}
|
|
33
23
|
export interface CdpTabClient {
|
|
34
24
|
send: (method: string, params?: object) => Promise<unknown>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pinned runtime image.
|
|
3
|
+
*
|
|
4
|
+
* A digest, not a tag. Upstream publishes 40-char git SHAs plus moving
|
|
5
|
+
* `main`/`latest` aliases and no semver at all, and its own docs say to
|
|
6
|
+
* "prefer immutable digests". Pinning one means an agent release cannot have
|
|
7
|
+
* the browser changed underneath it.
|
|
8
|
+
*
|
|
9
|
+
* Digest of `latest` as of 2026-08-22, built from popcorn-oss
|
|
10
|
+
* `4df751c25c7b329699033957b93f6486837e4acb`.
|
|
11
|
+
*/
|
|
12
|
+
export declare const BROWSER_RUNTIME_IMAGE: string;
|
|
13
|
+
export interface ContainerBrowser {
|
|
14
|
+
/** Local viewer origin — what `share_browser_view` tunnels. */
|
|
15
|
+
viewerUrl: string;
|
|
16
|
+
/** Browser-level CDP websocket, for `connectCdpUrl`. */
|
|
17
|
+
cdpUrl: string;
|
|
18
|
+
/** Loopback port the viewer is published on. */
|
|
19
|
+
viewerPort: number;
|
|
20
|
+
/** Docker's name for this container. */
|
|
21
|
+
name: string;
|
|
22
|
+
/** Whether the image had to be pulled first — worth telling the developer. */
|
|
23
|
+
pulled: boolean;
|
|
24
|
+
/** Which slot this browser occupies, for diagnostics. */
|
|
25
|
+
slot: number;
|
|
26
|
+
stop: () => Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
export interface ContainerOptions {
|
|
29
|
+
/** Loopback port for the viewer. Allocated when omitted. */
|
|
30
|
+
viewerPort?: number;
|
|
31
|
+
/** Loopback port for full CDP. Allocated when omitted. */
|
|
32
|
+
cdpPort?: number;
|
|
33
|
+
/** Override the pinned image, e.g. a local `build.sh` output. */
|
|
34
|
+
image?: string;
|
|
35
|
+
/** Framebuffer size, which is also the browser window's size. */
|
|
36
|
+
width?: number;
|
|
37
|
+
height?: number;
|
|
38
|
+
/** Page to open on start. */
|
|
39
|
+
url?: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Where the container's Chromium profile lives on the host.
|
|
43
|
+
*
|
|
44
|
+
* Mounted, unlike the reference dev script, for two reasons. Provider authoring
|
|
45
|
+
* means signing in once and capturing an authenticated request, which a profile
|
|
46
|
+
* that dies with the container makes impossible. And the profile holds the
|
|
47
|
+
* persisted fingerprint identity — canvas/audio seed, timezone, locale — which
|
|
48
|
+
* upstream warns must stay stable, since "flipping any of them against the same
|
|
49
|
+
* cookie jar is itself a bot signal".
|
|
50
|
+
*/
|
|
51
|
+
export declare function containerProfileDir(slot?: number): string;
|
|
52
|
+
/** Docker CLI present AND its daemon reachable. */
|
|
53
|
+
export declare function dockerAvailable(): Promise<boolean>;
|
|
54
|
+
/** Message for the case we cannot fix for them. */
|
|
55
|
+
export declare function dockerMissingMessage(): string;
|
|
56
|
+
/**
|
|
57
|
+
* The `docker run` argv.
|
|
58
|
+
*
|
|
59
|
+
* A pure function because every flag here is load bearing and none of it is
|
|
60
|
+
* obvious from the outside: which ports are loopback-only, that the image is
|
|
61
|
+
* digest-pinned, that the profile is mounted. Tests assert on this directly
|
|
62
|
+
* rather than trying to observe a running container.
|
|
63
|
+
*/
|
|
64
|
+
export declare function buildRunArgs(opts: ContainerOptions & {
|
|
65
|
+
viewerPort: number;
|
|
66
|
+
cdpPort: number;
|
|
67
|
+
}, image: string, name: string, profileDir: string): string[];
|
|
68
|
+
/**
|
|
69
|
+
* Start a browser container and wait until it is actually serving.
|
|
70
|
+
*
|
|
71
|
+
* Ports are allocated per call, so concurrent sessions never land on the same
|
|
72
|
+
* container. Pass `viewerPort` to pin one — re-attaching with the same port
|
|
73
|
+
* reuses the container that is already there.
|
|
74
|
+
*/
|
|
75
|
+
export declare function startContainer(options?: ContainerOptions): Promise<ContainerBrowser>;
|
|
76
|
+
/**
|
|
77
|
+
* Remove containers whose owning process is gone.
|
|
78
|
+
*
|
|
79
|
+
* Shutdown cleanup covers an orderly exit, but a SIGKILL or a crash runs
|
|
80
|
+
* nothing — and a 1.3 GB container with a live browser in it should not outlive
|
|
81
|
+
* the session that made it. Reaping at startup catches those, and the owner-PID
|
|
82
|
+
* label keeps it safe: a container whose owner still runs belongs to another
|
|
83
|
+
* session and is left alone.
|
|
84
|
+
*/
|
|
85
|
+
export declare function reapOrphanedContainers(): Promise<string[]>;
|