@reclaimprotocol/agent 0.1.22 → 0.1.24
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 +55 -20
- package/lib/authoring/backend.d.ts +13 -10
- package/lib/authoring/tools.js +106 -9
- package/lib/cdp/browser-proxy.d.ts +78 -0
- package/lib/cdp/browser-proxy.js +186 -0
- package/lib/cdp/capture-engine.js +8 -7
- package/lib/cdp/client.d.ts +10 -0
- package/lib/cdp/geo.d.ts +10 -0
- package/lib/cdp/geo.js +36 -0
- package/lib/cdp/index.d.ts +1 -0
- package/lib/cdp/index.js +1 -0
- package/lib/live/cloudflared.d.ts +15 -0
- package/lib/live/cloudflared.js +268 -0
- package/lib/live/page.d.ts +20 -0
- package/lib/live/page.js +134 -0
- package/lib/live/tunnel.d.ts +23 -0
- package/lib/live/tunnel.js +120 -0
- package/lib/live/viewer.d.ts +28 -0
- package/lib/live/viewer.js +232 -0
- package/lib/mcp/api/operations.gen.js +34 -26
- package/lib/mcp/api/to-zod.d.ts +1 -1
- package/lib/mcp/api/to-zod.js +1 -1
- package/lib/mcp/api/tools.js +1 -1
- package/lib/mcp/instructions.d.ts +15 -2
- package/lib/mcp/instructions.js +101 -38
- package/lib/mcp/start.js +4 -3
- package/lib/mcp/tools/agent/analyze.js +5 -1
- package/lib/mcp/tools/agent/attach.d.ts +8 -1
- package/lib/mcp/tools/agent/attach.js +166 -22
- package/lib/mcp/tools/agent/build.d.ts +13 -8
- package/lib/mcp/tools/agent/build.js +22 -11
- package/lib/mcp/tools/agent/capture.js +5 -1
- package/lib/mcp/tools/agent/eval.js +3 -1
- package/lib/mcp/tools/agent/find.js +6 -5
- package/lib/mcp/tools/agent/how-it-works.d.ts +15 -7
- package/lib/mcp/tools/agent/how-it-works.js +57 -17
- package/lib/mcp/tools/agent/live-view.d.ts +21 -0
- package/lib/mcp/tools/agent/live-view.js +153 -0
- package/lib/mcp/tools/agent/pod-proof.d.ts +8 -0
- package/lib/mcp/tools/agent/pod-proof.js +64 -0
- package/lib/mcp/tools/agent/propose.js +29 -3
- package/lib/mcp/tools/agent/proxy.d.ts +3 -0
- package/lib/mcp/tools/agent/proxy.js +199 -0
- package/lib/mcp/tools/agent/publish.js +49 -77
- package/lib/mcp/tools/agent/session.d.ts +2 -1
- package/lib/mcp/tools/agent/session.js +26 -7
- package/lib/mcp/tools/agent/shared.js +17 -8
- package/lib/mcp/tools/authenticate/authenticate.d.ts +11 -0
- package/lib/mcp/tools/authenticate/authenticate.js +143 -121
- package/lib/mcp/tools/authenticate/builder-access.d.ts +8 -3
- package/lib/mcp/tools/authenticate/builder-access.js +8 -3
- package/lib/mcp/tools/mode/mode.d.ts +1 -1
- package/lib/mcp/tools/mode/mode.js +26 -7
- package/lib/old/client.d.ts +9 -0
- package/lib/old/client.js +19 -1
- package/lib/old/instructions.d.ts +9 -1
- package/lib/old/instructions.js +4 -6
- package/lib/old/login-cdp.d.ts +2 -2
- package/lib/old/login-cdp.js +46 -6
- package/lib/old/to-register.d.ts +20 -8
- package/lib/old/to-register.js +7 -1
- package/lib/old/tools/authenticate.js +9 -1
- package/lib/old/tools/get-provider-info.js +6 -5
- package/lib/old/tools/issue-app-credentials.js +3 -2
- package/lib/old/tools/me-providers.js +3 -1
- package/lib/old/tools/publish.d.ts +1 -1
- package/lib/old/tools/publish.js +132 -129
- package/lib/old/tools/update-provider-metadata.d.ts +1 -1
- package/lib/old/tools/update-provider-metadata.js +1 -1
- package/lib/old/tools/verify-sdk-setup.js +1 -1
- package/lib/proof/attestor.d.ts +3 -3
- package/lib/proof/debug-response-dump.js +4 -4
- package/lib/proof/index.d.ts +1 -0
- package/lib/proof/index.js +1 -0
- package/lib/proof/page-replay.d.ts +36 -0
- package/lib/proof/page-replay.js +76 -0
- package/lib/proof/pod-prover.d.ts +47 -0
- package/lib/proof/pod-prover.js +155 -0
- package/lib/proof/replay.d.ts +28 -0
- package/lib/proof/replay.js +39 -18
- package/lib/provider/dom-xpath.js +11 -9
- package/lib/provider/draft.d.ts +13 -8
- package/lib/provider/draft.js +47 -13
- package/lib/provider/injection-doc.d.ts +23 -1
- package/lib/provider/injection-doc.js +34 -21
- package/lib/provider/match-redaction-warning.d.ts +2 -2
- package/lib/provider/match-redaction-warning.js +3 -3
- package/lib/provider/matcher.js +122 -12
- package/lib/provider/secrets.d.ts +3 -3
- package/lib/provider/secrets.js +3 -3
- package/lib/provider/to-version.d.ts +12 -9
- package/lib/provider/to-version.js +16 -4
- package/lib/tools/registry.d.ts +5 -5
- package/lib/tools/registry.js +5 -5
- package/package.json +4 -3
- package/samples/demo-server/README.md +7 -6
- package/skill/SKILL.builder.md +356 -189
- package/skill/SKILL.common.md +314 -79
- package/skill/SKILL.old.md +340 -155
- package/skill/builder-mode.md +58 -17
- package/skill/hash-validation.md +170 -0
- package/skill/interception.md +235 -0
- package/skill/js-sdk-integration.md +13 -7
- package/skill/old-mode.md +53 -12
- package/skill/user-script.md +61 -0
- package/src/authoring/backend.ts +13 -10
- package/src/authoring/tools.ts +128 -9
- package/src/cdp/capture-engine.ts +8 -7
- package/src/cdp/client.ts +8 -0
- package/src/cdp/geo.ts +52 -0
- package/src/cdp/index.ts +1 -0
- package/src/consts.ts +1 -0
- package/src/live/cloudflared.ts +348 -0
- package/src/live/page.ts +145 -0
- package/src/live/tunnel.ts +157 -0
- package/src/live/viewer.ts +293 -0
- package/src/mcp/api/operations.gen.ts +34 -26
- package/src/mcp/api/to-zod.ts +1 -1
- package/src/mcp/api/tools.ts +1 -1
- package/src/mcp/instructions.ts +121 -48
- package/src/mcp/start.ts +4 -3
- package/src/mcp/tools/agent/analyze.ts +5 -1
- package/src/mcp/tools/agent/attach.ts +194 -22
- package/src/mcp/tools/agent/build.ts +26 -15
- package/src/mcp/tools/agent/capture.ts +5 -1
- package/src/mcp/tools/agent/eval.ts +4 -1
- package/src/mcp/tools/agent/find.ts +6 -5
- package/src/mcp/tools/agent/how-it-works.ts +62 -21
- package/src/mcp/tools/agent/live-view.ts +198 -0
- package/src/mcp/tools/agent/pod-proof.ts +90 -0
- package/src/mcp/tools/agent/propose.ts +31 -3
- package/src/mcp/tools/agent/publish.ts +54 -78
- package/src/mcp/tools/agent/session.ts +26 -5
- package/src/mcp/tools/agent/shared.ts +19 -8
- package/src/mcp/tools/authenticate/authenticate.ts +167 -165
- package/src/mcp/tools/authenticate/builder-access.ts +8 -3
- package/src/mcp/tools/mode/mode.ts +28 -7
- package/src/old/client.ts +30 -1
- package/src/old/instructions.ts +4 -10
- package/src/old/login-cdp.ts +50 -6
- package/src/old/to-register.ts +28 -8
- package/src/old/tools/authenticate.ts +9 -1
- package/src/old/tools/get-provider-info.ts +6 -5
- package/src/old/tools/issue-app-credentials.ts +3 -2
- package/src/old/tools/me-providers.ts +3 -1
- package/src/old/tools/publish.ts +140 -130
- package/src/old/tools/update-provider-metadata.ts +1 -1
- package/src/old/tools/verify-sdk-setup.ts +1 -1
- package/src/proof/attestor.ts +3 -3
- package/src/proof/debug-response-dump.ts +4 -4
- package/src/proof/index.ts +8 -0
- package/src/proof/page-replay.ts +131 -0
- package/src/proof/pod-prover.ts +231 -0
- package/src/proof/replay.ts +60 -19
- package/src/provider/dom-xpath.ts +11 -9
- package/src/provider/draft.ts +56 -13
- package/src/provider/injection-doc.ts +37 -24
- package/src/provider/match-redaction-warning.ts +3 -3
- package/src/provider/matcher.ts +151 -10
- package/src/provider/secrets.ts +3 -3
- package/src/provider/to-version.ts +30 -13
- package/src/tools/registry.ts +5 -5
- package/skill/injection-api.md +0 -335
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import assert from 'node:assert';
|
|
2
1
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import { createHash, randomBytes } from 'node:crypto';
|
|
4
2
|
import { createServer } from 'node:http';
|
|
3
|
+
import * as oauth from 'openid-client';
|
|
5
4
|
import { defineTool } from "../../server.js";
|
|
6
5
|
import { writeSession } from "./session-cache.js";
|
|
7
6
|
const SCOPE = 'openid profile email';
|
|
@@ -10,9 +9,12 @@ export function authenticateTool(client, name = 'reclaim_authenticate') {
|
|
|
10
9
|
return defineTool({
|
|
11
10
|
name,
|
|
12
11
|
description: (name === 'authenticate_builder'
|
|
13
|
-
? '
|
|
14
|
-
+ '
|
|
15
|
-
+ '
|
|
12
|
+
? 'OLD-DEVTOOLS MODE ONLY, and narrow: it authorizes exactly two '
|
|
13
|
+
+ 'Builder-exclusive features — allocating a remote browser '
|
|
14
|
+
+ '(attach_browser mode="builder") and list_builder_organizations. '
|
|
15
|
+
+ 'It does NOT authorize create_provider_version_from_capture or '
|
|
16
|
+
+ 'get_me_providers; those need the separate old-devtools login, '
|
|
17
|
+
+ 'reclaim_authenticate. Check both with get_devtools_mode. '
|
|
16
18
|
: '')
|
|
17
19
|
+ 'Authorize the CLI / MCP to make authenticated API calls. Opens '
|
|
18
20
|
+ 'the '
|
|
@@ -21,44 +23,120 @@ export function authenticateTool(client, name = 'reclaim_authenticate') {
|
|
|
21
23
|
'the user to sign in and approve. On success the access token is ' +
|
|
22
24
|
'cached in ~/.reclaim/config.json and activated for this session.\n\n' +
|
|
23
25
|
'Call with no arguments. This bearer token authenticates ' +
|
|
24
|
-
'dashboard/org-management calls (
|
|
25
|
-
'secret). Programmatic verification calls use a
|
|
26
|
+
'dashboard/org-management calls (for example, list_orgs, or ' +
|
|
27
|
+
'issuing an org secret). Programmatic verification calls use a ' +
|
|
28
|
+
'separate org secret ' +
|
|
26
29
|
'(rorg_…, set as RECLAIM_ORG_SECRET).',
|
|
27
30
|
inputSchema: { type: 'object', properties: {} },
|
|
28
31
|
}, async () => runOAuthLoopback(client));
|
|
29
32
|
}
|
|
30
|
-
async
|
|
33
|
+
const _customFetchForOauth = async (url, options) => {
|
|
34
|
+
const res = await fetch(url, options);
|
|
35
|
+
if (options.method === 'POST' && res.status === 200 &&
|
|
36
|
+
new URL(url).pathname.endsWith('/oauth2/register')) {
|
|
37
|
+
return new Response(await res.text(), {
|
|
38
|
+
status: 201,
|
|
39
|
+
headers: res.headers,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return res;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Standard OAuth 2.0 Authorization Code + PKCE, native-app style (RFC 8252):
|
|
46
|
+
* dynamically register a public client (RFC 7591 — Better Auth's
|
|
47
|
+
* `oauth-provider` plugin issues no secret for it), send the user to the
|
|
48
|
+
* browser, and catch the redirect on a loopback server. `openid-client`
|
|
49
|
+
* handles PKCE, `state` CSRF protection, and authorization-response
|
|
50
|
+
* validation (including a denied/failed login surfacing as
|
|
51
|
+
* `AuthorizationResponseError`) — this function only wires those calls
|
|
52
|
+
* together and owns the loopback server (no library spins that up for you).
|
|
53
|
+
*/
|
|
54
|
+
export async function runOAuthLoopback(client,
|
|
55
|
+
// Overridable only so tests can intercept the authorize URL instead of
|
|
56
|
+
// actually spawning a browser; every real caller uses the default.
|
|
57
|
+
openBrowserFn = openBrowser) {
|
|
31
58
|
const authBase = `${client.baseUrl}/auth`;
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
const state =
|
|
59
|
+
const codeVerifier = oauth.randomPKCECodeVerifier();
|
|
60
|
+
const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier);
|
|
61
|
+
const state = oauth.randomState();
|
|
62
|
+
// openid-client/oauth4webapi refuse any non-HTTPS issuer by default
|
|
63
|
+
// ("only requests to HTTPS are allowed"). Better Auth's own dev default
|
|
64
|
+
// (`canonicalOrigin()`, packages/app/src/consts.ts) is plain
|
|
65
|
+
// `http://localhost:4001` — the server side has no HTTPS requirement at
|
|
66
|
+
// all — so a local `RECLAIM_API_URL=http://localhost:...` would otherwise
|
|
67
|
+
// always fail here. Loosen it ONLY for loopback hosts, matching what the
|
|
68
|
+
// server already trusts (`authTrustedOrigins()` defaults to
|
|
69
|
+
// localhost:4000/4001 outside production); a non-loopback http:// origin
|
|
70
|
+
// still gets the strict check.
|
|
71
|
+
const isLoopbackAuthBase = ['localhost', '127.0.0.1', '[::1]'].includes(new URL(authBase).hostname);
|
|
35
72
|
// Bind the callback port first: the client is registered for this exact
|
|
36
|
-
// loopback redirect
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
let token;
|
|
73
|
+
// loopback redirect.
|
|
74
|
+
const callback = await listenForCallback();
|
|
75
|
+
let tokens;
|
|
40
76
|
try {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
77
|
+
const config = await oauth.dynamicClientRegistration(new URL(authBase), {
|
|
78
|
+
client_name: 'Reclaim CLI',
|
|
79
|
+
redirect_uris: [callback.redirectUri],
|
|
80
|
+
token_endpoint_auth_method: 'none',
|
|
81
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
82
|
+
response_types: ['code'],
|
|
83
|
+
scope: SCOPE,
|
|
84
|
+
}, undefined, {
|
|
85
|
+
// Better Auth exposes its metadata at the RFC 8414 well-known path
|
|
86
|
+
// (`.well-known/oauth-authorization-server`), not the OIDC one.
|
|
87
|
+
algorithm: 'oauth2',
|
|
88
|
+
// RFC 7591 §3.2.1 requires 201 Created on a successful registration;
|
|
89
|
+
// openid-client enforces that strictly. Better Auth's
|
|
90
|
+
// `oauth-provider` plugin doesn't conform — confirmed live against
|
|
91
|
+
// the real server: `POST .../oauth2/register` returns 200 with an
|
|
92
|
+
// otherwise well-formed body, which openid-client rejects outright
|
|
93
|
+
// ("unexpected HTTP response status code") before ever looking at
|
|
94
|
+
// it. `customFetch` set here persists onto the returned
|
|
95
|
+
// `Configuration` for ALL its future calls too (token exchange
|
|
96
|
+
// included, which correctly returns 200) — so the URL itself, not
|
|
97
|
+
// just method+status, must gate the rewrite, or a genuine 200 from
|
|
98
|
+
// the token endpoint gets corrupted into a 201 and
|
|
99
|
+
// `authorizationCodeGrant` fails instead (confirmed by hitting
|
|
100
|
+
// exactly that when this only checked method === 'POST').
|
|
101
|
+
[oauth.customFetch]: _customFetchForOauth,
|
|
102
|
+
// Applies to the `Configuration` this call returns, so the later
|
|
103
|
+
// `buildAuthorizationUrl`/`authorizationCodeGrant` calls below (which
|
|
104
|
+
// reuse that same `config`) stop enforcing HTTPS too — no separate
|
|
105
|
+
// opt-in needed for them.
|
|
106
|
+
...(isLoopbackAuthBase
|
|
107
|
+
? { execute: [oauth.allowInsecureRequests] }
|
|
108
|
+
: {}),
|
|
109
|
+
});
|
|
110
|
+
openBrowserFn(oauth.buildAuthorizationUrl(config, {
|
|
111
|
+
redirect_uri: callback.redirectUri,
|
|
112
|
+
scope: SCOPE,
|
|
46
113
|
state,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
114
|
+
code_challenge: codeChallenge,
|
|
115
|
+
code_challenge_method: 'S256',
|
|
116
|
+
}).href);
|
|
117
|
+
const callbackUrl = await callback.url;
|
|
118
|
+
tokens = await oauth.authorizationCodeGrant(config, callbackUrl, {
|
|
119
|
+
pkceCodeVerifier: codeVerifier,
|
|
120
|
+
expectedState: state,
|
|
54
121
|
});
|
|
55
122
|
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
// A denied or failed authorization surfaces as this error class with
|
|
125
|
+
// the raw callback params on `.cause` — reword it like the previous
|
|
126
|
+
// hand-rolled check did (RFC 6749 §4.1.2.1's error/error_description).
|
|
127
|
+
if (err instanceof oauth.AuthorizationResponseError) {
|
|
128
|
+
throw new Error(err.cause.get('error_description')
|
|
129
|
+
?? err.cause.get('error')
|
|
130
|
+
?? 'Sign-in failed or was cancelled.');
|
|
131
|
+
}
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
56
134
|
finally {
|
|
57
135
|
callback.close();
|
|
58
136
|
}
|
|
59
|
-
const expiresAt = new Date(Date.now() +
|
|
60
|
-
writeSession({ token:
|
|
61
|
-
client.setToken(
|
|
137
|
+
const expiresAt = new Date(Date.now() + (tokens.expires_in ?? 3600) * 1000).toISOString();
|
|
138
|
+
writeSession({ token: tokens.access_token, expiresAt });
|
|
139
|
+
client.setToken(tokens.access_token);
|
|
62
140
|
return {
|
|
63
141
|
status: 'AUTHENTICATED',
|
|
64
142
|
note: 'Access token cached in ~/.reclaim/config.json and active now.',
|
|
@@ -67,42 +145,23 @@ async function runOAuthLoopback(client) {
|
|
|
67
145
|
}
|
|
68
146
|
/**
|
|
69
147
|
* Bind an ephemeral loopback port and start listening for the provider's
|
|
70
|
-
* redirect. Resolves as soon as the port is known — the `
|
|
148
|
+
* redirect. Resolves as soon as the port is known — the `url` promise settles
|
|
71
149
|
* later, when the browser hits `/callback` — so the caller can register a
|
|
72
150
|
* client for this exact redirect URI before sending the user off to authorize.
|
|
73
|
-
* The ephemeral port lets any number of CLIs run concurrently.
|
|
74
|
-
*
|
|
151
|
+
* The ephemeral port lets any number of CLIs run concurrently. Hands back the
|
|
152
|
+
* RAW callback URL rather than picking `code`/`state` apart itself —
|
|
153
|
+
* `oauth.authorizationCodeGrant` does that validation. Defined below its
|
|
154
|
+
* caller per repo convention.
|
|
75
155
|
*/
|
|
76
|
-
async function listenForCallback(
|
|
77
|
-
let
|
|
78
|
-
let
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
156
|
+
async function listenForCallback() {
|
|
157
|
+
let resolveUrl;
|
|
158
|
+
let rejectUrl;
|
|
159
|
+
const url = new Promise((resolve, reject) => {
|
|
160
|
+
resolveUrl = resolve;
|
|
161
|
+
rejectUrl = reject;
|
|
82
162
|
});
|
|
83
163
|
const server = createServer();
|
|
84
|
-
const timer = setTimeout(() =>
|
|
85
|
-
server.on('request', (req, res) => {
|
|
86
|
-
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
87
|
-
if (url.pathname !== '/callback') {
|
|
88
|
-
res.writeHead(404).end();
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
res.writeHead(200, { 'content-type': 'text/html' });
|
|
92
|
-
res.end('<!doctype html><meta charset=utf-8><body style="font:16px '
|
|
93
|
-
+ 'system-ui;padding:3rem;text-align:center">Signed in. You can '
|
|
94
|
-
+ 'close this tab and return to your terminal.</body>');
|
|
95
|
-
const authCode = url.searchParams.get('code');
|
|
96
|
-
if (!authCode || url.searchParams.get('state') !== state) {
|
|
97
|
-
// A denied or failed authorization comes back as `error`/
|
|
98
|
-
// `error_description` (RFC 6749 §4.1.2.1) — surface it verbatim.
|
|
99
|
-
const err = url.searchParams.get('error_description')
|
|
100
|
-
?? url.searchParams.get('error');
|
|
101
|
-
rejectCode(new Error(err ?? 'Sign-in failed or was cancelled.'));
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
resolveCode(authCode);
|
|
105
|
-
});
|
|
164
|
+
const timer = setTimeout(() => rejectUrl(new Error('Timed out waiting for sign-in (5 min).')), CALLBACK_TIMEOUT_MS);
|
|
106
165
|
const port = await new Promise((resolve, reject) => {
|
|
107
166
|
server.once('error', reject);
|
|
108
167
|
server.listen(0, '127.0.0.1', () => {
|
|
@@ -113,75 +172,38 @@ async function listenForCallback(state) {
|
|
|
113
172
|
}
|
|
114
173
|
resolve(addr.port);
|
|
115
174
|
});
|
|
175
|
+
}).catch((err) => {
|
|
176
|
+
// Bind failed before `close()` (returned below) is ever reachable —
|
|
177
|
+
// clear the timer ourselves, or it fires 5 minutes later and rejects
|
|
178
|
+
// the now-unobserved `url` promise (unhandled rejection) while keeping
|
|
179
|
+
// the process alive in the meantime.
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
throw err;
|
|
116
182
|
});
|
|
117
183
|
// Past bind, a socket error fails the pending wait rather than throwing.
|
|
118
|
-
server.on('error', (err) =>
|
|
184
|
+
server.on('error', (err) => rejectUrl(err));
|
|
185
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
186
|
+
server.on('request', (req, res) => {
|
|
187
|
+
const reqUrl = new URL(req.url ?? '/', redirectUri);
|
|
188
|
+
if (reqUrl.pathname !== '/callback') {
|
|
189
|
+
res.writeHead(404).end();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
res.writeHead(200, { 'content-type': 'text/html' });
|
|
193
|
+
res.end('<!doctype html><meta charset=utf-8><body style="font:16px '
|
|
194
|
+
+ 'system-ui;padding:3rem;text-align:center">Signed in. You can '
|
|
195
|
+
+ 'close this tab and return to your terminal.</body>');
|
|
196
|
+
resolveUrl(reqUrl);
|
|
197
|
+
});
|
|
119
198
|
return {
|
|
120
|
-
redirectUri
|
|
121
|
-
|
|
199
|
+
redirectUri,
|
|
200
|
+
url,
|
|
122
201
|
close: () => {
|
|
123
202
|
clearTimeout(timer);
|
|
124
203
|
server.close();
|
|
125
204
|
},
|
|
126
205
|
};
|
|
127
206
|
}
|
|
128
|
-
function authorizeUrl(authBase, opts) {
|
|
129
|
-
return `${authBase}/oauth2/authorize?`
|
|
130
|
-
+ new URLSearchParams({
|
|
131
|
-
response_type: 'code',
|
|
132
|
-
client_id: opts.clientId,
|
|
133
|
-
redirect_uri: opts.redirectUri,
|
|
134
|
-
scope: SCOPE,
|
|
135
|
-
state: opts.state,
|
|
136
|
-
code_challenge: opts.challenge,
|
|
137
|
-
code_challenge_method: 'S256',
|
|
138
|
-
}).toString();
|
|
139
|
-
}
|
|
140
|
-
/** Dynamic client registration (RFC 7591) for a public PKCE client. */
|
|
141
|
-
async function registerClient(authBase, redirectUri) {
|
|
142
|
-
const res = await fetch(`${authBase}/oauth2/register`, {
|
|
143
|
-
method: 'POST',
|
|
144
|
-
headers: { 'content-type': 'application/json' },
|
|
145
|
-
body: JSON.stringify({
|
|
146
|
-
client_name: 'Reclaim CLI',
|
|
147
|
-
redirect_uris: [redirectUri],
|
|
148
|
-
token_endpoint_auth_method: 'none',
|
|
149
|
-
grant_types: ['authorization_code', 'refresh_token'],
|
|
150
|
-
response_types: ['code'],
|
|
151
|
-
scope: SCOPE,
|
|
152
|
-
}),
|
|
153
|
-
});
|
|
154
|
-
assert(res.ok, new Error(`Client registration failed (${res.status}).`));
|
|
155
|
-
const json = (await res.json());
|
|
156
|
-
assert(json.client_id, new Error('Client registration returned no client_id.'));
|
|
157
|
-
return json.client_id;
|
|
158
|
-
}
|
|
159
|
-
async function exchangeCode(authBase, opts) {
|
|
160
|
-
const res = await fetch(`${authBase}/oauth2/token`, {
|
|
161
|
-
method: 'POST',
|
|
162
|
-
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
163
|
-
body: new URLSearchParams({
|
|
164
|
-
grant_type: 'authorization_code',
|
|
165
|
-
code: opts.code,
|
|
166
|
-
redirect_uri: opts.redirectUri,
|
|
167
|
-
code_verifier: opts.verifier,
|
|
168
|
-
// Required even though this is a public client with no secret: it's
|
|
169
|
-
// how the token endpoint identifies the client (RFC 6749 §4.1.3).
|
|
170
|
-
client_id: opts.clientId,
|
|
171
|
-
}).toString(),
|
|
172
|
-
});
|
|
173
|
-
const body = await res.text();
|
|
174
|
-
assert(res.ok, new Error(`Token exchange failed (${res.status}): ${body}`));
|
|
175
|
-
const json = JSON.parse(body);
|
|
176
|
-
assert(json.access_token, new Error('Token exchange returned no access_token.'));
|
|
177
|
-
return {
|
|
178
|
-
access_token: json.access_token,
|
|
179
|
-
expires_in: json.expires_in ?? 3600,
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
function base64url(buf) {
|
|
183
|
-
return buf.toString('base64url');
|
|
184
|
-
}
|
|
185
207
|
function openBrowser(url) {
|
|
186
208
|
const cmd = process.platform === 'darwin'
|
|
187
209
|
? 'open'
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import type { ReclaimClient } from '@reclaimprotocol/client';
|
|
2
2
|
import { type RegisteredTool } from '../../server.ts';
|
|
3
|
-
/** Legacy-devtools mode needs a distinct Builder login
|
|
4
|
-
* quota-accounted Popcorn sessions
|
|
3
|
+
/** Legacy-devtools mode needs a distinct Builder login for its two
|
|
4
|
+
* Builder-exclusive features: allocating quota-accounted Popcorn sessions
|
|
5
|
+
* (see `buildBuilderOrganizationTools` below) and listing Builder
|
|
6
|
+
* organizations. Builder mode needs neither — its native auth tool alone
|
|
7
|
+
* covers both, org listing included (via its generated `list_orgs`). */
|
|
5
8
|
export declare function buildBuilderAuthenticationTools(client: ReclaimClient): RegisteredTool[];
|
|
6
9
|
/** Legacy-devtools mode lacks generated Builder API tools, so it needs this
|
|
7
|
-
* narrow organization-discovery bridge
|
|
10
|
+
* narrow organization-discovery bridge — an org id is required to allocate a
|
|
11
|
+
* remote-browser (Popcorn) session. Builder mode's own `list_orgs` already
|
|
12
|
+
* covers this under its native auth. */
|
|
8
13
|
export declare function buildBuilderOrganizationTools(client: ReclaimClient): RegisteredTool[];
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { defineTool } from "../../server.js";
|
|
2
2
|
import { authenticateTool } from "./authenticate.js";
|
|
3
|
-
/** Legacy-devtools mode needs a distinct Builder login
|
|
4
|
-
* quota-accounted Popcorn sessions
|
|
3
|
+
/** Legacy-devtools mode needs a distinct Builder login for its two
|
|
4
|
+
* Builder-exclusive features: allocating quota-accounted Popcorn sessions
|
|
5
|
+
* (see `buildBuilderOrganizationTools` below) and listing Builder
|
|
6
|
+
* organizations. Builder mode needs neither — its native auth tool alone
|
|
7
|
+
* covers both, org listing included (via its generated `list_orgs`). */
|
|
5
8
|
export function buildBuilderAuthenticationTools(client) {
|
|
6
9
|
return [authenticateTool(client, 'authenticate_builder')];
|
|
7
10
|
}
|
|
8
11
|
/** Legacy-devtools mode lacks generated Builder API tools, so it needs this
|
|
9
|
-
* narrow organization-discovery bridge
|
|
12
|
+
* narrow organization-discovery bridge — an org id is required to allocate a
|
|
13
|
+
* remote-browser (Popcorn) session. Builder mode's own `list_orgs` already
|
|
14
|
+
* covers this under its native auth. */
|
|
10
15
|
export function buildBuilderOrganizationTools(client) {
|
|
11
16
|
return [
|
|
12
17
|
defineTool({
|
|
@@ -7,7 +7,7 @@ import { type RegisteredTool } from '../../server.ts';
|
|
|
7
7
|
* `~/.reclaim/config.json` and applied the NEXT time the server starts —
|
|
8
8
|
* the registered tool set, server instructions, and backend client are all
|
|
9
9
|
* fixed at connect time, so the switch requires a restart/reconnect. Follows
|
|
10
|
-
* the `
|
|
10
|
+
* the `attach_browser` persist-through-config pattern.
|
|
11
11
|
*
|
|
12
12
|
* Registered in BOTH modes (so the user can switch either direction) and
|
|
13
13
|
* outside `buildAgentTools` (so it survives `RECLAIM_AGENT_DISABLED=1`). An
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { defaultConfigPath } from "../../../config/store.js";
|
|
2
2
|
import { oldModeSource } from "../../../consts.js";
|
|
3
3
|
import { defineTool } from "../../server.js";
|
|
4
|
+
/** Whether `config`'s cached builder session token is present and not yet
|
|
5
|
+
* expired. Mirrors `readSession`'s check, but against the CALLER's
|
|
6
|
+
* `ConfigStore` instance (not always the default `~/.reclaim/config.json`
|
|
7
|
+
* path) so this stays test-isolatable and consistent with the rest of this
|
|
8
|
+
* tool's config reads. */
|
|
9
|
+
function hasValidBuilderSession(config) {
|
|
10
|
+
const session = config.read()?.session;
|
|
11
|
+
if (!session?.token || !session.expiresAt) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
return new Date(session.expiresAt) > new Date();
|
|
15
|
+
}
|
|
4
16
|
/**
|
|
5
17
|
* The `set_devtools_mode` / `get_devtools_mode` tools: persist which backend
|
|
6
18
|
* the MCP server targets (`old` devtools vs the `builder`), and
|
|
@@ -8,7 +20,7 @@ import { defineTool } from "../../server.js";
|
|
|
8
20
|
* `~/.reclaim/config.json` and applied the NEXT time the server starts —
|
|
9
21
|
* the registered tool set, server instructions, and backend client are all
|
|
10
22
|
* fixed at connect time, so the switch requires a restart/reconnect. Follows
|
|
11
|
-
* the `
|
|
23
|
+
* the `attach_browser` persist-through-config pattern.
|
|
12
24
|
*
|
|
13
25
|
* Registered in BOTH modes (so the user can switch either direction) and
|
|
14
26
|
* outside `buildAgentTools` (so it survives `RECLAIM_AGENT_DISABLED=1`). An
|
|
@@ -49,20 +61,27 @@ export function buildModeTools(config, currentOldMode) {
|
|
|
49
61
|
}),
|
|
50
62
|
defineTool({
|
|
51
63
|
name: 'get_devtools_mode',
|
|
52
|
-
description: 'Report which backend this
|
|
53
|
-
+ '"
|
|
54
|
-
+ '
|
|
55
|
-
+ '
|
|
56
|
-
+ '
|
|
57
|
-
+ '
|
|
64
|
+
description: 'Report which backend this server targets ("old" devtools or '
|
|
65
|
+
+ '"builder"), WHY (a USE_OLD_DEVTOOLS env override, the persisted '
|
|
66
|
+
+ 'config, or the default), and which mode is persisted for the '
|
|
67
|
+
+ 'next start. Change it with set_devtools_mode. Also reports auth '
|
|
68
|
+
+ 'status for both login systems: builderAuthenticated and '
|
|
69
|
+
+ 'oldDevtoolsAuthenticated. Both are PRESENCE checks, not validity '
|
|
70
|
+
+ 'checks — a stale token still reports true; a real call surfaces '
|
|
71
|
+
+ 'the staleness with an error naming the tool to re-run. Which '
|
|
72
|
+
+ 'login authorizes what: how_it_works({ topic: "auth" }).',
|
|
58
73
|
inputSchema: { type: 'object', properties: {} },
|
|
59
74
|
}, async () => {
|
|
60
75
|
const persisted = config.read()?.mode;
|
|
76
|
+
const oldIdentity = config.read()?.old_devtools;
|
|
61
77
|
return {
|
|
62
78
|
mode: current,
|
|
63
79
|
source: oldModeSource(persisted),
|
|
64
80
|
persistedMode: persisted ?? null,
|
|
65
81
|
configPath: defaultConfigPath(),
|
|
82
|
+
builderAuthenticated: hasValidBuilderSession(config),
|
|
83
|
+
oldDevtoolsAuthenticated: !!oldIdentity,
|
|
84
|
+
oldDevtoolsIdentityKind: oldIdentity?.kind ?? null,
|
|
66
85
|
};
|
|
67
86
|
}),
|
|
68
87
|
];
|
package/lib/old/client.d.ts
CHANGED
|
@@ -48,6 +48,15 @@ export interface RegisterProviderBody {
|
|
|
48
48
|
* controller's destructure + its `providerConfig` write, both of which
|
|
49
49
|
* include it alongside `pageTitle`/`userAgent`. */
|
|
50
50
|
stepsToFollow?: string;
|
|
51
|
+
/** Interception mechanism (`NONE` = standard replay flow, the default;
|
|
52
|
+
* `HAWKEYE`/`MSWJS`/`XHOOK` = JS-context interception; `CDP` = browser-
|
|
53
|
+
* level). Column default is `HAWKEYE` when omitted entirely — unlike
|
|
54
|
+
* `stepsToFollow`, and so on. above, whether the register endpoint actually
|
|
55
|
+
* PERSISTS this field is UNVERIFIED (no source access to confirm); sent
|
|
56
|
+
* here on the chance it does, but the publish tool always follows up
|
|
57
|
+
* with a config (add-version) call regardless, which is confirmed to
|
|
58
|
+
* persist it. */
|
|
59
|
+
injectionType?: 'NONE' | 'MSWJS' | 'XHOOK' | 'CDP' | 'HAWKEYE';
|
|
51
60
|
}
|
|
52
61
|
export interface UpdateProviderConfigBody {
|
|
53
62
|
providerConfig: Record<string, unknown>;
|
package/lib/old/client.js
CHANGED
|
@@ -202,10 +202,28 @@ _ReclaimOldClient_identity = new WeakMap(), _ReclaimOldClient_fetch = new WeakMa
|
|
|
202
202
|
return undefined;
|
|
203
203
|
}
|
|
204
204
|
}, _ReclaimOldClient_asBoom = function _ReclaimOldClient_asBoom(res, json) {
|
|
205
|
-
const
|
|
205
|
+
const rawMessage = (json && typeof json === 'object' &&
|
|
206
206
|
typeof json.message === 'string'
|
|
207
207
|
? json.message
|
|
208
208
|
: undefined) || res.statusText || 'Old devtools request failed';
|
|
209
|
+
// An identity ALREADY SET (as opposed to `#requireIdentity`'s "never
|
|
210
|
+
// authenticated" case, which never reaches the backend) plus either a
|
|
211
|
+
// 401/403 OR a message that itself names token verification means the
|
|
212
|
+
// backend rejected a token we had — that is, it's stale/invalid, not merely
|
|
213
|
+
// absent. The message check matters because this backend does NOT
|
|
214
|
+
// reliably use 401/403 for it — confirmed live: an actually-expired
|
|
215
|
+
// Firebase token surfaced as "Error while verifying token" with a
|
|
216
|
+
// status this narrow check (401/403-only) missed entirely, silently
|
|
217
|
+
// skipping the enrichment. The backend's own message gives no hint of
|
|
218
|
+
// the fix, and nothing else distinguishes this from Builder auth, so
|
|
219
|
+
// append repo-side guidance rather than replacing the raw detail.
|
|
220
|
+
const staleAuth = !!__classPrivateFieldGet(this, _ReclaimOldClient_identity, "f") && (res.status === 401 || res.status === 403 ||
|
|
221
|
+
/verify.{0,20}token|token.{0,20}verif/i.test(rawMessage));
|
|
222
|
+
const message = staleAuth
|
|
223
|
+
? `${rawMessage} — your old-devtools login looks stale or invalid `
|
|
224
|
+
+ '(this is separate from Builder auth). Re-run reclaim_authenticate '
|
|
225
|
+
+ 'to refresh it, then retry.'
|
|
226
|
+
: rawMessage;
|
|
209
227
|
// Shape `data` like a ProblemDetails so remapErrorAsResponse can render
|
|
210
228
|
// `${message}: ${data.detail}` and expose the raw body as structured.
|
|
211
229
|
return new Boom(message, {
|
|
@@ -1 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* MCP server instructions shown to the LLM when running in OLD-devtools mode
|
|
3
|
+
* (`USE_OLD_DEVTOOLS` unset or not `false`). Describes the REDUCED tool
|
|
4
|
+
* surface — there are no org/credential-link/review flows and no generated
|
|
5
|
+
* API tools here. Both modes' instructions load together in
|
|
6
|
+
* `mcp/instructions` (alongside the topic-split authoring guide); re-exported
|
|
7
|
+
* here so old-mode callers keep importing from their own module.
|
|
8
|
+
*/
|
|
9
|
+
export { OLD_MODE_INSTRUCTIONS } from '../mcp/instructions.ts';
|
package/lib/old/instructions.js
CHANGED
|
@@ -2,10 +2,8 @@
|
|
|
2
2
|
* MCP server instructions shown to the LLM when running in OLD-devtools mode
|
|
3
3
|
* (`USE_OLD_DEVTOOLS` unset or not `false`). Describes the REDUCED tool
|
|
4
4
|
* surface — there are no org/credential-link/review flows and no generated
|
|
5
|
-
* API tools here.
|
|
6
|
-
* `
|
|
7
|
-
*
|
|
5
|
+
* API tools here. Both modes' instructions load together in
|
|
6
|
+
* `mcp/instructions` (alongside the topic-split authoring guide); re-exported
|
|
7
|
+
* here so old-mode callers keep importing from their own module.
|
|
8
8
|
*/
|
|
9
|
-
|
|
10
|
-
import { fileURLToPath } from 'node:url';
|
|
11
|
-
export const OLD_MODE_INSTRUCTIONS = readFileSync(fileURLToPath(new URL('../../skill/old-mode.md', import.meta.url)), 'utf8').trim();
|
|
9
|
+
export { OLD_MODE_INSTRUCTIONS } from "../mcp/instructions.js";
|
package/lib/old/login-cdp.d.ts
CHANGED
|
@@ -34,8 +34,8 @@ export interface CdpLoginResult {
|
|
|
34
34
|
* finish signing in, read the resulting Firebase ID token from page storage,
|
|
35
35
|
* then clean up. If we had to launch a dedicated Chrome for this, we close the
|
|
36
36
|
* whole browser afterwards; if we attached to a Chrome that was already up
|
|
37
|
-
* (
|
|
38
|
-
* disrupt it.
|
|
37
|
+
* (for example, the capture flow's), we only close the tab we opened so we
|
|
38
|
+
* don't disrupt it.
|
|
39
39
|
*/
|
|
40
40
|
export declare function captureFirebaseTokenViaCdp(opts: {
|
|
41
41
|
loginUrl?: string;
|
package/lib/old/login-cdp.js
CHANGED
|
@@ -83,12 +83,18 @@ export const EXTRACT_TOKEN_JS = `(async () => {
|
|
|
83
83
|
* finish signing in, read the resulting Firebase ID token from page storage,
|
|
84
84
|
* then clean up. If we had to launch a dedicated Chrome for this, we close the
|
|
85
85
|
* whole browser afterwards; if we attached to a Chrome that was already up
|
|
86
|
-
* (
|
|
87
|
-
* disrupt it.
|
|
86
|
+
* (for example, the capture flow's), we only close the tab we opened so we
|
|
87
|
+
* don't disrupt it.
|
|
88
88
|
*/
|
|
89
89
|
export async function captureFirebaseTokenViaCdp(opts) {
|
|
90
90
|
const loginUrl = opts.loginUrl || oldLoginUrl() || DEFAULT_LOGIN_URL;
|
|
91
|
-
|
|
91
|
+
// 6 minutes, not 3. On a FRESH Chrome profile the dashboard is a cold load
|
|
92
|
+
// of an ~8.6 MB uncompressed bundle before the login form is even
|
|
93
|
+
// interactive — tens of seconds on a slow link — and only then does the
|
|
94
|
+
// developer start typing credentials and a 2FA code. The old 3-minute
|
|
95
|
+
// budget was routinely spent before they could finish, and the retry that
|
|
96
|
+
// followed looked like a 6-minute hang with no output.
|
|
97
|
+
const timeoutMs = opts.timeoutMs ?? 360000;
|
|
92
98
|
const pollMs = opts.pollMs ?? 1000;
|
|
93
99
|
const cfg = new ConfigStore().read()?.browser_agent;
|
|
94
100
|
const mode = cfg?.attachMode ?? 'dedicated';
|
|
@@ -102,7 +108,7 @@ export async function captureFirebaseTokenViaCdp(opts) {
|
|
|
102
108
|
if (mode !== 'dedicated') {
|
|
103
109
|
throw new Error(`attach mode: nothing answering CDP on port ${port}. Launch Chrome ` +
|
|
104
110
|
`with --remote-debugging-port=${port} AND a non-default ` +
|
|
105
|
-
'--user-data-dir, or switch to dedicated mode (
|
|
111
|
+
'--user-data-dir, or switch to dedicated mode (attach_browser).');
|
|
106
112
|
}
|
|
107
113
|
launchChrome({ port, profileDir: defaultProfileDir() });
|
|
108
114
|
await waitForCdpReady(port, 10000);
|
|
@@ -114,6 +120,10 @@ export async function captureFirebaseTokenViaCdp(opts) {
|
|
|
114
120
|
try {
|
|
115
121
|
const session = await handles.attachToTab(targetId);
|
|
116
122
|
const deadline = Date.now() + timeoutMs;
|
|
123
|
+
// Tracks how far the page got, so a timeout can say WHICH thing stalled:
|
|
124
|
+
// a bundle that never finished downloading is a different problem from a
|
|
125
|
+
// developer who walked away mid-sign-in.
|
|
126
|
+
let sawInteractivePage = false;
|
|
117
127
|
while (Date.now() < deadline) {
|
|
118
128
|
const result = await session.send('Runtime.evaluate', {
|
|
119
129
|
expression: EXTRACT_TOKEN_JS,
|
|
@@ -124,10 +134,21 @@ export async function captureFirebaseTokenViaCdp(opts) {
|
|
|
124
134
|
if (typeof value === 'string' && value.length > 0) {
|
|
125
135
|
return { token: value, closedBrowser: launchedByUs };
|
|
126
136
|
}
|
|
137
|
+
if (!sawInteractivePage) {
|
|
138
|
+
sawInteractivePage = await pageIsInteractive(session);
|
|
139
|
+
}
|
|
127
140
|
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
128
141
|
}
|
|
129
|
-
|
|
130
|
-
|
|
142
|
+
const waited = Math.round(timeoutMs / 1000);
|
|
143
|
+
throw new Error(`Timed out after ${waited}s waiting for login. `
|
|
144
|
+
+ (sawInteractivePage
|
|
145
|
+
? 'The page finished loading, so sign-in was never completed — '
|
|
146
|
+
+ 'ask the developer to finish signing in at the opened window, '
|
|
147
|
+
+ 'then re-run reclaim_authenticate.'
|
|
148
|
+
: 'The page never became interactive, so the dashboard was still '
|
|
149
|
+
+ 'loading the whole time. On a fresh Chrome profile its bundle '
|
|
150
|
+
+ 'is a large cold download. Retry (the profile is now warm), or '
|
|
151
|
+
+ 'pass a bigger timeoutMs.'));
|
|
131
152
|
}
|
|
132
153
|
finally {
|
|
133
154
|
// Best-effort cleanup — never mask the real result/error.
|
|
@@ -145,6 +166,25 @@ export async function captureFirebaseTokenViaCdp(opts) {
|
|
|
145
166
|
await handles.close().catch(() => { });
|
|
146
167
|
}
|
|
147
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Whether the login page has reached a usable state. `readyState` alone is not
|
|
171
|
+
* enough for a single-bundle SPA: the document is 'complete' while the app is
|
|
172
|
+
* still parsing, and nothing is clickable yet. Requiring a rendered root as
|
|
173
|
+
* well is what distinguishes "still loading" from "waiting for the human".
|
|
174
|
+
*/
|
|
175
|
+
async function pageIsInteractive(session) {
|
|
176
|
+
try {
|
|
177
|
+
const result = await session.send('Runtime.evaluate', {
|
|
178
|
+
expression: 'document.readyState === "complete" '
|
|
179
|
+
+ '&& !!document.body && document.body.innerText.trim().length > 0',
|
|
180
|
+
returnByValue: true,
|
|
181
|
+
});
|
|
182
|
+
return runtimeResultValue(result) === true;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
148
188
|
function runtimeResultValue(value) {
|
|
149
189
|
if (typeof value !== 'object' || !value || !('result' in value)) {
|
|
150
190
|
return undefined;
|