@reclaimprotocol/agent 0.1.21 → 0.1.23
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/lib/authoring/backend.d.ts +9 -7
- package/lib/authoring/tools.js +98 -1
- package/lib/mcp/tools/agent/attach.js +29 -1
- package/lib/mcp/tools/agent/propose.js +27 -3
- package/lib/mcp/tools/agent/publish.js +7 -5
- 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 +128 -119
- 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.js +32 -1
- package/lib/old/client.d.ts +9 -0
- package/lib/old/client.js +19 -1
- package/lib/old/to-register.d.ts +17 -6
- package/lib/old/to-register.js +6 -0
- package/lib/old/tools/me-providers.js +3 -1
- package/lib/old/tools/publish.js +82 -38
- package/lib/proof/page-replay.d.ts +36 -0
- package/lib/proof/page-replay.js +76 -0
- package/lib/proof/replay.d.ts +28 -0
- package/lib/proof/replay.js +39 -18
- package/lib/provider/draft.d.ts +3 -1
- package/lib/provider/draft.js +39 -7
- package/lib/provider/matcher.js +116 -7
- package/lib/provider/to-version.d.ts +8 -6
- package/lib/provider/to-version.js +15 -3
- package/package.json +3 -2
- package/skill/SKILL.builder.md +4 -2
- package/skill/SKILL.common.md +10 -1
- package/skill/SKILL.old.md +1 -1
- package/skill/injection-api.md +44 -15
- package/skill/old-mode.md +3 -1
- package/src/authoring/backend.ts +9 -7
- package/src/authoring/tools.ts +119 -1
- package/src/mcp/tools/agent/attach.ts +40 -1
- package/src/mcp/tools/agent/propose.ts +29 -3
- package/src/mcp/tools/agent/publish.ts +7 -6
- package/src/mcp/tools/agent/shared.ts +19 -8
- package/src/mcp/tools/authenticate/authenticate.ts +150 -163
- package/src/mcp/tools/authenticate/builder-access.ts +8 -3
- package/src/mcp/tools/mode/mode.ts +34 -1
- package/src/old/client.ts +30 -1
- package/src/old/to-register.ts +25 -6
- package/src/old/tools/me-providers.ts +3 -1
- package/src/old/tools/publish.ts +86 -40
- package/src/proof/page-replay.ts +131 -0
- package/src/proof/replay.ts +60 -19
- package/src/provider/draft.ts +46 -6
- package/src/provider/matcher.ts +145 -5
- package/src/provider/to-version.ts +24 -9
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { ReclaimClient } from '@reclaimprotocol/client'
|
|
2
|
-
import assert from 'node:assert'
|
|
3
2
|
import { spawn } from 'node:child_process'
|
|
4
|
-
import { createHash, randomBytes } from 'node:crypto'
|
|
5
3
|
import { createServer } from 'node:http'
|
|
4
|
+
import * as oauth from 'openid-client'
|
|
6
5
|
import { defineTool, type RegisteredTool } from '../../server.ts'
|
|
7
6
|
import { writeSession } from './session-cache.ts'
|
|
8
7
|
|
|
@@ -19,9 +18,17 @@ export function authenticateTool(
|
|
|
19
18
|
name,
|
|
20
19
|
description:
|
|
21
20
|
(name === 'authenticate_builder'
|
|
22
|
-
? '
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
? 'This tool only exists in OLD-DEVTOOLS mode — Builder mode has '
|
|
22
|
+
+ 'no separate version of it; its own reclaim_authenticate '
|
|
23
|
+
+ 'already covers everything below. In old-devtools mode, this '
|
|
24
|
+
+ 'authenticates with Builder for exactly two Builder-exclusive '
|
|
25
|
+
+ 'features: allocating a Popcorn remote browser (attach_browser '
|
|
26
|
+
+ 'mode="builder") and listing Builder organizations '
|
|
27
|
+
+ '(list_builder_organizations — old-devtools has no generated '
|
|
28
|
+
+ 'org tool of its own). It does NOT authorize '
|
|
29
|
+
+ 'create_provider_version_from_capture or get_me_providers, '
|
|
30
|
+
+ 'which need their OWN, separate login via the old-devtools '
|
|
31
|
+
+ 'reclaim_authenticate tool. Check both with get_devtools_mode. '
|
|
25
32
|
: '')
|
|
26
33
|
+ 'Authorize the CLI / MCP to make authenticated API calls. Opens '
|
|
27
34
|
+ 'the '
|
|
@@ -39,43 +46,117 @@ export function authenticateTool(
|
|
|
39
46
|
)
|
|
40
47
|
}
|
|
41
48
|
|
|
42
|
-
|
|
49
|
+
const _customFetchForOauth: oauth.CustomFetch = async(
|
|
50
|
+
url: string,
|
|
51
|
+
options: oauth.CustomFetchOptions,
|
|
52
|
+
) => {
|
|
53
|
+
const res = await fetch(url, options as RequestInit)
|
|
54
|
+
if(
|
|
55
|
+
options.method === 'POST' && res.status === 200 &&
|
|
56
|
+
new URL(url).pathname.endsWith('/oauth2/register')
|
|
57
|
+
) {
|
|
58
|
+
return new Response(await res.text(), {
|
|
59
|
+
status: 201,
|
|
60
|
+
headers: res.headers,
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return res
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Standard OAuth 2.0 Authorization Code + PKCE, native-app style (RFC 8252):
|
|
69
|
+
* dynamically register a public client (RFC 7591 — Better Auth's
|
|
70
|
+
* `oauth-provider` plugin issues no secret for it), send the user to the
|
|
71
|
+
* browser, and catch the redirect on a loopback server. `openid-client`
|
|
72
|
+
* handles PKCE, `state` CSRF protection, and authorization-response
|
|
73
|
+
* validation (including a denied/failed login surfacing as
|
|
74
|
+
* `AuthorizationResponseError`) — this function only wires those calls
|
|
75
|
+
* together and owns the loopback server (no library spins that up for you).
|
|
76
|
+
*/
|
|
77
|
+
export async function runOAuthLoopback(
|
|
78
|
+
client: ReclaimClient,
|
|
79
|
+
// Overridable only so tests can intercept the authorize URL instead of
|
|
80
|
+
// actually spawning a browser; every real caller uses the default.
|
|
81
|
+
openBrowserFn: (url: string) => void = openBrowser,
|
|
82
|
+
): Promise<unknown> {
|
|
43
83
|
const authBase = `${client.baseUrl}/auth`
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
const state =
|
|
84
|
+
const codeVerifier = oauth.randomPKCECodeVerifier()
|
|
85
|
+
const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier)
|
|
86
|
+
const state = oauth.randomState()
|
|
47
87
|
|
|
48
88
|
// Bind the callback port first: the client is registered for this exact
|
|
49
|
-
// loopback redirect
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
let token: { access_token: string, expires_in: number }
|
|
89
|
+
// loopback redirect.
|
|
90
|
+
const callback = await listenForCallback()
|
|
91
|
+
let tokens: oauth.TokenEndpointResponse
|
|
53
92
|
try {
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
93
|
+
const config = await oauth.dynamicClientRegistration(
|
|
94
|
+
new URL(authBase),
|
|
95
|
+
{
|
|
96
|
+
client_name: 'Reclaim CLI',
|
|
97
|
+
redirect_uris: [callback.redirectUri],
|
|
98
|
+
token_endpoint_auth_method: 'none',
|
|
99
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
100
|
+
response_types: ['code'],
|
|
101
|
+
scope: SCOPE,
|
|
102
|
+
},
|
|
103
|
+
undefined,
|
|
104
|
+
{
|
|
105
|
+
// Better Auth exposes its metadata at the RFC 8414 well-known path
|
|
106
|
+
// (`.well-known/oauth-authorization-server`), not the OIDC one.
|
|
107
|
+
algorithm: 'oauth2',
|
|
108
|
+
// RFC 7591 §3.2.1 requires 201 Created on a successful registration;
|
|
109
|
+
// openid-client enforces that strictly. Better Auth's
|
|
110
|
+
// `oauth-provider` plugin doesn't conform — confirmed live against
|
|
111
|
+
// the real server: `POST .../oauth2/register` returns 200 with an
|
|
112
|
+
// otherwise well-formed body, which openid-client rejects outright
|
|
113
|
+
// ("unexpected HTTP response status code") before ever looking at
|
|
114
|
+
// it. `customFetch` set here persists onto the returned
|
|
115
|
+
// `Configuration` for ALL its future calls too (token exchange
|
|
116
|
+
// included, which correctly returns 200) — so the URL itself, not
|
|
117
|
+
// just method+status, must gate the rewrite, or a genuine 200 from
|
|
118
|
+
// the token endpoint gets corrupted into a 201 and
|
|
119
|
+
// `authorizationCodeGrant` fails instead (confirmed by hitting
|
|
120
|
+
// exactly that when this only checked method === 'POST').
|
|
121
|
+
[oauth.customFetch]: _customFetchForOauth,
|
|
122
|
+
},
|
|
123
|
+
)
|
|
124
|
+
openBrowserFn(
|
|
125
|
+
oauth.buildAuthorizationUrl(config, {
|
|
126
|
+
redirect_uri: callback.redirectUri,
|
|
127
|
+
scope: SCOPE,
|
|
60
128
|
state,
|
|
61
|
-
|
|
129
|
+
code_challenge: codeChallenge,
|
|
130
|
+
code_challenge_method: 'S256',
|
|
131
|
+
}).href,
|
|
62
132
|
)
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
redirectUri: callback.redirectUri,
|
|
68
|
-
clientId,
|
|
133
|
+
const callbackUrl = await callback.url
|
|
134
|
+
tokens = await oauth.authorizationCodeGrant(config, callbackUrl, {
|
|
135
|
+
pkceCodeVerifier: codeVerifier,
|
|
136
|
+
expectedState: state,
|
|
69
137
|
})
|
|
138
|
+
} catch(err) {
|
|
139
|
+
// A denied or failed authorization surfaces as this error class with
|
|
140
|
+
// the raw callback params on `.cause` — reword it like the previous
|
|
141
|
+
// hand-rolled check did (RFC 6749 §4.1.2.1's error/error_description).
|
|
142
|
+
if(err instanceof oauth.AuthorizationResponseError) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
err.cause.get('error_description')
|
|
145
|
+
?? err.cause.get('error')
|
|
146
|
+
?? 'Sign-in failed or was cancelled.',
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
throw err
|
|
70
151
|
} finally {
|
|
71
152
|
callback.close()
|
|
72
153
|
}
|
|
73
154
|
|
|
74
155
|
const expiresAt = new Date(
|
|
75
|
-
Date.now() +
|
|
156
|
+
Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
|
76
157
|
).toISOString()
|
|
77
|
-
writeSession({ token:
|
|
78
|
-
client.setToken(
|
|
158
|
+
writeSession({ token: tokens.access_token, expiresAt })
|
|
159
|
+
client.setToken(tokens.access_token)
|
|
79
160
|
|
|
80
161
|
return {
|
|
81
162
|
status: 'AUTHENTICATED',
|
|
@@ -86,57 +167,32 @@ async function runOAuthLoopback(client: ReclaimClient): Promise<unknown> {
|
|
|
86
167
|
|
|
87
168
|
/**
|
|
88
169
|
* Bind an ephemeral loopback port and start listening for the provider's
|
|
89
|
-
* redirect. Resolves as soon as the port is known — the `
|
|
170
|
+
* redirect. Resolves as soon as the port is known — the `url` promise settles
|
|
90
171
|
* later, when the browser hits `/callback` — so the caller can register a
|
|
91
172
|
* client for this exact redirect URI before sending the user off to authorize.
|
|
92
|
-
* The ephemeral port lets any number of CLIs run concurrently.
|
|
93
|
-
*
|
|
173
|
+
* The ephemeral port lets any number of CLIs run concurrently. Hands back the
|
|
174
|
+
* RAW callback URL rather than picking `code`/`state` apart itself —
|
|
175
|
+
* `oauth.authorizationCodeGrant` does that validation. Defined below its
|
|
176
|
+
* caller per repo convention.
|
|
94
177
|
*/
|
|
95
|
-
async function listenForCallback(
|
|
178
|
+
async function listenForCallback(): Promise<{
|
|
96
179
|
redirectUri: string
|
|
97
|
-
|
|
180
|
+
url: Promise<URL>
|
|
98
181
|
close: () => void
|
|
99
182
|
}> {
|
|
100
|
-
let
|
|
101
|
-
let
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
183
|
+
let resolveUrl: (url: URL) => void
|
|
184
|
+
let rejectUrl: (err: Error) => void
|
|
185
|
+
const url = new Promise<URL>((resolve, reject) => {
|
|
186
|
+
resolveUrl = resolve
|
|
187
|
+
rejectUrl = reject
|
|
105
188
|
})
|
|
106
189
|
|
|
107
190
|
const server = createServer()
|
|
108
191
|
const timer = setTimeout(
|
|
109
|
-
() =>
|
|
192
|
+
() => rejectUrl(new Error('Timed out waiting for sign-in (5 min).')),
|
|
110
193
|
CALLBACK_TIMEOUT_MS,
|
|
111
194
|
)
|
|
112
195
|
|
|
113
|
-
server.on('request', (req, res) => {
|
|
114
|
-
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
115
|
-
if(url.pathname !== '/callback') {
|
|
116
|
-
res.writeHead(404).end()
|
|
117
|
-
return
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
res.writeHead(200, { 'content-type': 'text/html' })
|
|
121
|
-
res.end(
|
|
122
|
-
'<!doctype html><meta charset=utf-8><body style="font:16px '
|
|
123
|
-
+ 'system-ui;padding:3rem;text-align:center">Signed in. You can '
|
|
124
|
-
+ 'close this tab and return to your terminal.</body>',
|
|
125
|
-
)
|
|
126
|
-
|
|
127
|
-
const authCode = url.searchParams.get('code')
|
|
128
|
-
if(!authCode || url.searchParams.get('state') !== state) {
|
|
129
|
-
// A denied or failed authorization comes back as `error`/
|
|
130
|
-
// `error_description` (RFC 6749 §4.1.2.1) — surface it verbatim.
|
|
131
|
-
const err = url.searchParams.get('error_description')
|
|
132
|
-
?? url.searchParams.get('error')
|
|
133
|
-
rejectCode(new Error(err ?? 'Sign-in failed or was cancelled.'))
|
|
134
|
-
return
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
resolveCode(authCode)
|
|
138
|
-
})
|
|
139
|
-
|
|
140
196
|
const port = await new Promise<number>((resolve, reject) => {
|
|
141
197
|
server.once('error', reject)
|
|
142
198
|
server.listen(0, '127.0.0.1', () => {
|
|
@@ -148,13 +204,37 @@ async function listenForCallback(state: string): Promise<{
|
|
|
148
204
|
|
|
149
205
|
resolve(addr.port)
|
|
150
206
|
})
|
|
207
|
+
}).catch((err) => {
|
|
208
|
+
// Bind failed before `close()` (returned below) is ever reachable —
|
|
209
|
+
// clear the timer ourselves, or it fires 5 minutes later and rejects
|
|
210
|
+
// the now-unobserved `url` promise (unhandled rejection) while keeping
|
|
211
|
+
// the process alive in the meantime.
|
|
212
|
+
clearTimeout(timer)
|
|
213
|
+
throw err
|
|
151
214
|
})
|
|
152
215
|
// Past bind, a socket error fails the pending wait rather than throwing.
|
|
153
|
-
server.on('error', (err) =>
|
|
216
|
+
server.on('error', (err) => rejectUrl(err))
|
|
217
|
+
|
|
218
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`
|
|
219
|
+
server.on('request', (req, res) => {
|
|
220
|
+
const reqUrl = new URL(req.url ?? '/', redirectUri)
|
|
221
|
+
if(reqUrl.pathname !== '/callback') {
|
|
222
|
+
res.writeHead(404).end()
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
res.writeHead(200, { 'content-type': 'text/html' })
|
|
227
|
+
res.end(
|
|
228
|
+
'<!doctype html><meta charset=utf-8><body style="font:16px '
|
|
229
|
+
+ 'system-ui;padding:3rem;text-align:center">Signed in. You can '
|
|
230
|
+
+ 'close this tab and return to your terminal.</body>',
|
|
231
|
+
)
|
|
232
|
+
resolveUrl(reqUrl)
|
|
233
|
+
})
|
|
154
234
|
|
|
155
235
|
return {
|
|
156
|
-
redirectUri
|
|
157
|
-
|
|
236
|
+
redirectUri,
|
|
237
|
+
url,
|
|
158
238
|
close: () => {
|
|
159
239
|
clearTimeout(timer)
|
|
160
240
|
server.close()
|
|
@@ -162,99 +242,6 @@ async function listenForCallback(state: string): Promise<{
|
|
|
162
242
|
}
|
|
163
243
|
}
|
|
164
244
|
|
|
165
|
-
function authorizeUrl(
|
|
166
|
-
authBase: string,
|
|
167
|
-
opts: {
|
|
168
|
-
clientId: string
|
|
169
|
-
redirectUri: string
|
|
170
|
-
challenge: string
|
|
171
|
-
state: string
|
|
172
|
-
},
|
|
173
|
-
): string {
|
|
174
|
-
return `${authBase}/oauth2/authorize?`
|
|
175
|
-
+ new URLSearchParams({
|
|
176
|
-
response_type: 'code',
|
|
177
|
-
client_id: opts.clientId,
|
|
178
|
-
redirect_uri: opts.redirectUri,
|
|
179
|
-
scope: SCOPE,
|
|
180
|
-
state: opts.state,
|
|
181
|
-
code_challenge: opts.challenge,
|
|
182
|
-
code_challenge_method: 'S256',
|
|
183
|
-
}).toString()
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** Dynamic client registration (RFC 7591) for a public PKCE client. */
|
|
187
|
-
async function registerClient(
|
|
188
|
-
authBase: string,
|
|
189
|
-
redirectUri: string,
|
|
190
|
-
): Promise<string> {
|
|
191
|
-
const res = await fetch(`${authBase}/oauth2/register`, {
|
|
192
|
-
method: 'POST',
|
|
193
|
-
headers: { 'content-type': 'application/json' },
|
|
194
|
-
body: JSON.stringify({
|
|
195
|
-
client_name: 'Reclaim CLI',
|
|
196
|
-
redirect_uris: [redirectUri],
|
|
197
|
-
token_endpoint_auth_method: 'none',
|
|
198
|
-
grant_types: ['authorization_code', 'refresh_token'],
|
|
199
|
-
response_types: ['code'],
|
|
200
|
-
scope: SCOPE,
|
|
201
|
-
}),
|
|
202
|
-
})
|
|
203
|
-
assert(res.ok, new Error(`Client registration failed (${res.status}).`))
|
|
204
|
-
|
|
205
|
-
const json = (await res.json()) as { client_id?: string }
|
|
206
|
-
assert(
|
|
207
|
-
json.client_id,
|
|
208
|
-
new Error('Client registration returned no client_id.'),
|
|
209
|
-
)
|
|
210
|
-
|
|
211
|
-
return json.client_id
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
async function exchangeCode(
|
|
215
|
-
authBase: string,
|
|
216
|
-
opts: {
|
|
217
|
-
code: string
|
|
218
|
-
verifier: string
|
|
219
|
-
redirectUri: string
|
|
220
|
-
clientId: string
|
|
221
|
-
},
|
|
222
|
-
): Promise<{ access_token: string, expires_in: number }> {
|
|
223
|
-
const res = await fetch(`${authBase}/oauth2/token`, {
|
|
224
|
-
method: 'POST',
|
|
225
|
-
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
226
|
-
body: new URLSearchParams({
|
|
227
|
-
grant_type: 'authorization_code',
|
|
228
|
-
code: opts.code,
|
|
229
|
-
redirect_uri: opts.redirectUri,
|
|
230
|
-
code_verifier: opts.verifier,
|
|
231
|
-
// Required even though this is a public client with no secret: it's
|
|
232
|
-
// how the token endpoint identifies the client (RFC 6749 §4.1.3).
|
|
233
|
-
client_id: opts.clientId,
|
|
234
|
-
}).toString(),
|
|
235
|
-
})
|
|
236
|
-
const body = await res.text()
|
|
237
|
-
assert(res.ok, new Error(`Token exchange failed (${res.status}): ${body}`))
|
|
238
|
-
|
|
239
|
-
const json = JSON.parse(body) as {
|
|
240
|
-
access_token?: string
|
|
241
|
-
expires_in?: number
|
|
242
|
-
}
|
|
243
|
-
assert(
|
|
244
|
-
json.access_token,
|
|
245
|
-
new Error('Token exchange returned no access_token.'),
|
|
246
|
-
)
|
|
247
|
-
|
|
248
|
-
return {
|
|
249
|
-
access_token: json.access_token,
|
|
250
|
-
expires_in: json.expires_in ?? 3600,
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function base64url(buf: Buffer): string {
|
|
255
|
-
return buf.toString('base64url')
|
|
256
|
-
}
|
|
257
|
-
|
|
258
245
|
function openBrowser(url: string) {
|
|
259
246
|
const cmd = process.platform === 'darwin'
|
|
260
247
|
? 'open'
|
|
@@ -2,8 +2,11 @@ import type { ReclaimClient } from '@reclaimprotocol/client'
|
|
|
2
2
|
import { defineTool, type RegisteredTool } from '../../server.ts'
|
|
3
3
|
import { authenticateTool } from './authenticate.ts'
|
|
4
4
|
|
|
5
|
-
/** Legacy-devtools mode needs a distinct Builder login
|
|
6
|
-
* quota-accounted Popcorn sessions
|
|
5
|
+
/** Legacy-devtools mode needs a distinct Builder login for its two
|
|
6
|
+
* Builder-exclusive features: allocating quota-accounted Popcorn sessions
|
|
7
|
+
* (see `buildBuilderOrganizationTools` below) and listing Builder
|
|
8
|
+
* organizations. Builder mode needs neither — its native auth tool alone
|
|
9
|
+
* covers both, org listing included (via its generated `list_orgs`). */
|
|
7
10
|
export function buildBuilderAuthenticationTools(
|
|
8
11
|
client: ReclaimClient,
|
|
9
12
|
): RegisteredTool[] {
|
|
@@ -11,7 +14,9 @@ export function buildBuilderAuthenticationTools(
|
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
/** Legacy-devtools mode lacks generated Builder API tools, so it needs this
|
|
14
|
-
* narrow organization-discovery bridge
|
|
17
|
+
* narrow organization-discovery bridge — an org id is required to allocate a
|
|
18
|
+
* remote-browser (Popcorn) session. Builder mode's own `list_orgs` already
|
|
19
|
+
* covers this under its native auth. */
|
|
15
20
|
export function buildBuilderOrganizationTools(
|
|
16
21
|
client: ReclaimClient,
|
|
17
22
|
): RegisteredTool[] {
|
|
@@ -3,6 +3,20 @@ import { defaultConfigPath } from '../../../config/store.ts'
|
|
|
3
3
|
import { oldModeSource } from '../../../consts.ts'
|
|
4
4
|
import { defineTool, type RegisteredTool } from '../../server.ts'
|
|
5
5
|
|
|
6
|
+
/** Whether `config`'s cached builder session token is present and not yet
|
|
7
|
+
* expired. Mirrors `readSession`'s check, but against the CALLER's
|
|
8
|
+
* `ConfigStore` instance (not always the default `~/.reclaim/config.json`
|
|
9
|
+
* path) so this stays test-isolatable and consistent with the rest of this
|
|
10
|
+
* tool's config reads. */
|
|
11
|
+
function hasValidBuilderSession(config: ConfigStore): boolean {
|
|
12
|
+
const session = config.read()?.session
|
|
13
|
+
if(!session?.token || !session.expiresAt) {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return new Date(session.expiresAt) > new Date()
|
|
18
|
+
}
|
|
19
|
+
|
|
6
20
|
/**
|
|
7
21
|
* The `set_devtools_mode` / `get_devtools_mode` tools: persist which backend
|
|
8
22
|
* the MCP server targets (`old` devtools vs the `builder`), and
|
|
@@ -65,16 +79,35 @@ export function buildModeTools(
|
|
|
65
79
|
+ 'backend). Also reports WHY that mode is in effect (source: '
|
|
66
80
|
+ 'a USE_OLD_DEVTOOLS env override, the persisted config, or '
|
|
67
81
|
+ 'the default) and which mode is persisted for the next '
|
|
68
|
-
+ 'start. Use set_devtools_mode to change it.'
|
|
82
|
+
+ 'start. Use set_devtools_mode to change it. Also reports '
|
|
83
|
+
+ 'AUTH status for BOTH auth systems — builderAuthenticated (the '
|
|
84
|
+
+ 'Builder OAuth login). In BUILDER mode this is the only login '
|
|
85
|
+
+ 'that exists and covers everything, including org listing '
|
|
86
|
+
+ '(list_orgs) and remote-browser allocation. In OLD-DEVTOOLS '
|
|
87
|
+
+ 'mode it is a SEPARATE, additional login (authenticate_builder) '
|
|
88
|
+
+ 'needed only for two Builder-exclusive features: remote-browser '
|
|
89
|
+
+ '(Popcorn) allocation and org listing (list_builder_organizations '
|
|
90
|
+
+ '— old-devtools has no generated org tool of its own). '
|
|
91
|
+
+ 'oldDevtoolsAuthenticated (the OLD-mode reclaim_authenticate) is '
|
|
92
|
+
+ 'required separately for create_provider_version_from_capture '
|
|
93
|
+
+ 'and get_me_providers, and only exists/matters in old-devtools '
|
|
94
|
+
+ 'mode. Both are presence checks, not '
|
|
95
|
+
+ 'validity checks — a stale old-devtools token still reports '
|
|
96
|
+
+ 'true here; a real publish/list call will surface staleness '
|
|
97
|
+
+ 'with a clear error pointing back at reclaim_authenticate.',
|
|
69
98
|
inputSchema: { type: 'object', properties: {} },
|
|
70
99
|
},
|
|
71
100
|
async() => {
|
|
72
101
|
const persisted = config.read()?.mode
|
|
102
|
+
const oldIdentity = config.read()?.old_devtools
|
|
73
103
|
return {
|
|
74
104
|
mode: current,
|
|
75
105
|
source: oldModeSource(persisted),
|
|
76
106
|
persistedMode: persisted ?? null,
|
|
77
107
|
configPath: defaultConfigPath(),
|
|
108
|
+
builderAuthenticated: hasValidBuilderSession(config),
|
|
109
|
+
oldDevtoolsAuthenticated: !!oldIdentity,
|
|
110
|
+
oldDevtoolsIdentityKind: oldIdentity?.kind ?? null,
|
|
78
111
|
}
|
|
79
112
|
},
|
|
80
113
|
),
|
package/src/old/client.ts
CHANGED
|
@@ -47,6 +47,15 @@ export interface RegisterProviderBody {
|
|
|
47
47
|
* controller's destructure + its `providerConfig` write, both of which
|
|
48
48
|
* include it alongside `pageTitle`/`userAgent`. */
|
|
49
49
|
stepsToFollow?: string
|
|
50
|
+
/** Interception mechanism (`NONE` = standard replay flow, the default;
|
|
51
|
+
* `HAWKEYE`/`MSWJS`/`XHOOK` = JS-context interception; `CDP` = browser-
|
|
52
|
+
* level). Column default is `HAWKEYE` when omitted entirely — unlike
|
|
53
|
+
* `stepsToFollow` etc. above, whether the register endpoint actually
|
|
54
|
+
* PERSISTS this field is UNVERIFIED (no source access to confirm); sent
|
|
55
|
+
* here on the chance it does, but the publish tool always follows up
|
|
56
|
+
* with a config (add-version) call regardless, which is confirmed to
|
|
57
|
+
* persist it. */
|
|
58
|
+
injectionType?: 'NONE' | 'MSWJS' | 'XHOOK' | 'CDP' | 'HAWKEYE'
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
export interface UpdateProviderConfigBody {
|
|
@@ -353,11 +362,31 @@ export class ReclaimOldClient {
|
|
|
353
362
|
}
|
|
354
363
|
|
|
355
364
|
#asBoom(res: Response, json: unknown): Boom {
|
|
356
|
-
const
|
|
365
|
+
const rawMessage =
|
|
357
366
|
(json && typeof json === 'object' &&
|
|
358
367
|
typeof (json as { message?: unknown }).message === 'string'
|
|
359
368
|
? (json as { message: string }).message
|
|
360
369
|
: undefined) || res.statusText || 'Old devtools request failed'
|
|
370
|
+
// An identity ALREADY SET (as opposed to `#requireIdentity`'s "never
|
|
371
|
+
// authenticated" case, which never reaches the backend) plus either a
|
|
372
|
+
// 401/403 OR a message that itself names token verification means the
|
|
373
|
+
// backend rejected a token we had — i.e. it's stale/invalid, not merely
|
|
374
|
+
// absent. The message check matters because this backend does NOT
|
|
375
|
+
// reliably use 401/403 for it — confirmed live: an actually-expired
|
|
376
|
+
// Firebase token surfaced as "Error while verifying token" with a
|
|
377
|
+
// status this narrow check (401/403-only) missed entirely, silently
|
|
378
|
+
// skipping the enrichment. The backend's own message gives no hint of
|
|
379
|
+
// the fix, and nothing else distinguishes this from Builder auth, so
|
|
380
|
+
// append repo-side guidance rather than replacing the raw detail.
|
|
381
|
+
const staleAuth = !!this.#identity && (
|
|
382
|
+
res.status === 401 || res.status === 403 ||
|
|
383
|
+
/verify.{0,20}token|token.{0,20}verif/i.test(rawMessage)
|
|
384
|
+
)
|
|
385
|
+
const message = staleAuth
|
|
386
|
+
? `${rawMessage} — your old-devtools login looks stale or invalid `
|
|
387
|
+
+ '(this is separate from Builder auth). Re-run reclaim_authenticate '
|
|
388
|
+
+ 'to refresh it, then retry.'
|
|
389
|
+
: rawMessage
|
|
361
390
|
// Shape `data` like a ProblemDetails so remapErrorAsResponse can render
|
|
362
391
|
// `${message}: ${data.detail}` and expose the raw body as structured.
|
|
363
392
|
return new Boom(message, {
|
package/src/old/to-register.ts
CHANGED
|
@@ -61,13 +61,24 @@ export interface OldProviderOpts {
|
|
|
61
61
|
* field from the request body. Free-form JSON. */
|
|
62
62
|
extensionConfig?: unknown
|
|
63
63
|
/** Provider-level `disableRequestReplay` (HAWKEYE document replay skip).
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* `
|
|
69
|
-
* .
|
|
64
|
+
* The publish tool always forces this `true` — there is no author-facing
|
|
65
|
+
* way to enable document replay. Config (add-version) ONLY: the register
|
|
66
|
+
* endpoint hardcodes it to false regardless of what's sent, so the
|
|
67
|
+
* publish tool always follows a register up with an add-version call to
|
|
68
|
+
* actually apply it (see `old/tools/publish.ts`). The builder backend's
|
|
69
|
+
* inverse-named equivalent, `webSettings.clientOptions.inapp
|
|
70
|
+
* .interceptorOptions.isDocumentRequestReplayEnabled`, is likewise always
|
|
71
|
+
* force-disabled by `providerToCreateVersionRequest`. */
|
|
70
72
|
disableRequestReplay?: boolean
|
|
73
|
+
/** Interception mechanism — `NONE` (the publish tool's default) for the
|
|
74
|
+
* standard capture→replay flow; `HAWKEYE`/`MSWJS`/`XHOOK`/`CDP` for a
|
|
75
|
+
* genuine live-interception provider. Old-devtools calls this
|
|
76
|
+
* `injectionType`; builder's equivalent is `webSettings.clientOptions
|
|
77
|
+
* .inapp.interceptorOptions.interceptorType` (no `NONE` value there —
|
|
78
|
+
* omitting the whole object is builder's no-interception signal
|
|
79
|
+
* instead). See `old/client.ts`'s `RegisterProviderBody.injectionType`
|
|
80
|
+
* doc for the register-persistence caveat. */
|
|
81
|
+
injectionType?: 'NONE' | 'MSWJS' | 'XHOOK' | 'CDP' | 'HAWKEYE'
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
interface OldResponseMatch {
|
|
@@ -269,6 +280,10 @@ export function providerToRegisterBody(
|
|
|
269
280
|
body.stepsToFollow = opts.stepsToFollow
|
|
270
281
|
}
|
|
271
282
|
|
|
283
|
+
if(opts.injectionType !== undefined) {
|
|
284
|
+
body.injectionType = opts.injectionType
|
|
285
|
+
}
|
|
286
|
+
|
|
272
287
|
return body
|
|
273
288
|
}
|
|
274
289
|
|
|
@@ -323,6 +338,10 @@ export function providerToConfigBody(
|
|
|
323
338
|
providerConfig.disableRequestReplay = opts.disableRequestReplay
|
|
324
339
|
}
|
|
325
340
|
|
|
341
|
+
if(opts.injectionType !== undefined) {
|
|
342
|
+
providerConfig.injectionType = opts.injectionType
|
|
343
|
+
}
|
|
344
|
+
|
|
326
345
|
return { providerConfig, version, versionInfo }
|
|
327
346
|
}
|
|
328
347
|
|
|
@@ -21,7 +21,9 @@ export function getMeProvidersTool(client: ReclaimOldClient): RegisteredTool {
|
|
|
21
21
|
'first). Returns provider metadata including each `providerId` — ' +
|
|
22
22
|
'pass one back to create_provider_version_from_capture to publish a ' +
|
|
23
23
|
'new version of that provider instead of creating a duplicate. ' +
|
|
24
|
-
'Requires reclaim_authenticate first
|
|
24
|
+
'Requires reclaim_authenticate (old-devtools login) first — this ' +
|
|
25
|
+
'is separate from authenticate_builder/Builder auth, which does ' +
|
|
26
|
+
'not authorize this call. Check status with get_devtools_mode.',
|
|
25
27
|
inputSchema: {
|
|
26
28
|
type: 'object',
|
|
27
29
|
properties: {
|