@reclaimprotocol/agent 0.1.22 → 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/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 +1 -1
- package/skill/SKILL.common.md +9 -0
- 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/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,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
|
|
@@ -54,15 +66,34 @@ export function buildModeTools(config, currentOldMode) {
|
|
|
54
66
|
+ 'backend). Also reports WHY that mode is in effect (source: '
|
|
55
67
|
+ 'a USE_OLD_DEVTOOLS env override, the persisted config, or '
|
|
56
68
|
+ 'the default) and which mode is persisted for the next '
|
|
57
|
-
+ 'start. Use set_devtools_mode to change it.'
|
|
69
|
+
+ 'start. Use set_devtools_mode to change it. Also reports '
|
|
70
|
+
+ 'AUTH status for BOTH auth systems — builderAuthenticated (the '
|
|
71
|
+
+ 'Builder OAuth login). In BUILDER mode this is the only login '
|
|
72
|
+
+ 'that exists and covers everything, including org listing '
|
|
73
|
+
+ '(list_orgs) and remote-browser allocation. In OLD-DEVTOOLS '
|
|
74
|
+
+ 'mode it is a SEPARATE, additional login (authenticate_builder) '
|
|
75
|
+
+ 'needed only for two Builder-exclusive features: remote-browser '
|
|
76
|
+
+ '(Popcorn) allocation and org listing (list_builder_organizations '
|
|
77
|
+
+ '— old-devtools has no generated org tool of its own). '
|
|
78
|
+
+ 'oldDevtoolsAuthenticated (the OLD-mode reclaim_authenticate) is '
|
|
79
|
+
+ 'required separately for create_provider_version_from_capture '
|
|
80
|
+
+ 'and get_me_providers, and only exists/matters in old-devtools '
|
|
81
|
+
+ 'mode. Both are presence checks, not '
|
|
82
|
+
+ 'validity checks — a stale old-devtools token still reports '
|
|
83
|
+
+ 'true here; a real publish/list call will surface staleness '
|
|
84
|
+
+ 'with a clear error pointing back at reclaim_authenticate.',
|
|
58
85
|
inputSchema: { type: 'object', properties: {} },
|
|
59
86
|
}, async () => {
|
|
60
87
|
const persisted = config.read()?.mode;
|
|
88
|
+
const oldIdentity = config.read()?.old_devtools;
|
|
61
89
|
return {
|
|
62
90
|
mode: current,
|
|
63
91
|
source: oldModeSource(persisted),
|
|
64
92
|
persistedMode: persisted ?? null,
|
|
65
93
|
configPath: defaultConfigPath(),
|
|
94
|
+
builderAuthenticated: hasValidBuilderSession(config),
|
|
95
|
+
oldDevtoolsAuthenticated: !!oldIdentity,
|
|
96
|
+
oldDevtoolsIdentityKind: oldIdentity?.kind ?? null,
|
|
66
97
|
};
|
|
67
98
|
}),
|
|
68
99
|
];
|
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` etc. 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 — i.e. 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, {
|
package/lib/old/to-register.d.ts
CHANGED
|
@@ -51,13 +51,24 @@ export interface OldProviderOpts {
|
|
|
51
51
|
* field from the request body. Free-form JSON. */
|
|
52
52
|
extensionConfig?: unknown;
|
|
53
53
|
/** Provider-level `disableRequestReplay` (HAWKEYE document replay skip).
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* `
|
|
59
|
-
* .
|
|
54
|
+
* The publish tool always forces this `true` — there is no author-facing
|
|
55
|
+
* way to enable document replay. Config (add-version) ONLY: the register
|
|
56
|
+
* endpoint hardcodes it to false regardless of what's sent, so the
|
|
57
|
+
* publish tool always follows a register up with an add-version call to
|
|
58
|
+
* actually apply it (see `old/tools/publish.ts`). The builder backend's
|
|
59
|
+
* inverse-named equivalent, `webSettings.clientOptions.inapp
|
|
60
|
+
* .interceptorOptions.isDocumentRequestReplayEnabled`, is likewise always
|
|
61
|
+
* force-disabled by `providerToCreateVersionRequest`. */
|
|
60
62
|
disableRequestReplay?: boolean;
|
|
63
|
+
/** Interception mechanism — `NONE` (the publish tool's default) for the
|
|
64
|
+
* standard capture→replay flow; `HAWKEYE`/`MSWJS`/`XHOOK`/`CDP` for a
|
|
65
|
+
* genuine live-interception provider. Old-devtools calls this
|
|
66
|
+
* `injectionType`; builder's equivalent is `webSettings.clientOptions
|
|
67
|
+
* .inapp.interceptorOptions.interceptorType` (no `NONE` value there —
|
|
68
|
+
* omitting the whole object is builder's no-interception signal
|
|
69
|
+
* instead). See `old/client.ts`'s `RegisterProviderBody.injectionType`
|
|
70
|
+
* doc for the register-persistence caveat. */
|
|
71
|
+
injectionType?: 'NONE' | 'MSWJS' | 'XHOOK' | 'CDP' | 'HAWKEYE';
|
|
61
72
|
}
|
|
62
73
|
interface OldResponseMatch {
|
|
63
74
|
value: string;
|
package/lib/old/to-register.js
CHANGED
|
@@ -133,6 +133,9 @@ export function providerToRegisterBody(providers, opts) {
|
|
|
133
133
|
if (opts.stepsToFollow !== undefined) {
|
|
134
134
|
body.stepsToFollow = opts.stepsToFollow;
|
|
135
135
|
}
|
|
136
|
+
if (opts.injectionType !== undefined) {
|
|
137
|
+
body.injectionType = opts.injectionType;
|
|
138
|
+
}
|
|
136
139
|
return body;
|
|
137
140
|
}
|
|
138
141
|
/** Body for `POST /api/providers/:providerId/config` — adds a new version to
|
|
@@ -173,6 +176,9 @@ export function providerToConfigBody(providers, opts, version, versionInfo) {
|
|
|
173
176
|
if (opts.disableRequestReplay !== undefined) {
|
|
174
177
|
providerConfig.disableRequestReplay = opts.disableRequestReplay;
|
|
175
178
|
}
|
|
179
|
+
if (opts.injectionType !== undefined) {
|
|
180
|
+
providerConfig.injectionType = opts.injectionType;
|
|
181
|
+
}
|
|
176
182
|
return { providerConfig, version, versionInfo };
|
|
177
183
|
}
|
|
178
184
|
/**
|
|
@@ -13,7 +13,9 @@ export function getMeProvidersTool(client) {
|
|
|
13
13
|
'first). Returns provider metadata including each `providerId` — ' +
|
|
14
14
|
'pass one back to create_provider_version_from_capture to publish a ' +
|
|
15
15
|
'new version of that provider instead of creating a duplicate. ' +
|
|
16
|
-
'Requires reclaim_authenticate first
|
|
16
|
+
'Requires reclaim_authenticate (old-devtools login) first — this ' +
|
|
17
|
+
'is separate from authenticate_builder/Builder auth, which does ' +
|
|
18
|
+
'not authorize this call. Check status with get_devtools_mode.',
|
|
17
19
|
inputSchema: {
|
|
18
20
|
type: 'object',
|
|
19
21
|
properties: {
|
package/lib/old/tools/publish.js
CHANGED
|
@@ -57,6 +57,9 @@ export function createVersionFromCaptureTool(client, attachRef) {
|
|
|
57
57
|
return defineTool({
|
|
58
58
|
name: 'create_provider_version_from_capture',
|
|
59
59
|
description: 'Publish drafted ReclaimProvider(s) to the devtools backend. ' +
|
|
60
|
+
'Requires its OWN old-devtools login via reclaim_authenticate — ' +
|
|
61
|
+
'separate from authenticate_builder/Builder auth, which does not ' +
|
|
62
|
+
'authorize this call. Check status with get_devtools_mode. \n\n' +
|
|
60
63
|
'Pass the `provider` object from propose_provider VERBATIM and the ' +
|
|
61
64
|
'`initialUrl` the user landed on (the login/landing page, NOT the ' +
|
|
62
65
|
'API url). \n\n' +
|
|
@@ -74,8 +77,9 @@ export function createVersionFromCaptureTool(client, attachRef) {
|
|
|
74
77
|
"PUBLIC provider you don't own, omit `providerId` to register your " +
|
|
75
78
|
'own copy (you cannot edit someone else\'s provider here). \n\n' +
|
|
76
79
|
'For data gated behind login/navigation, pass `customInjection` — a ' +
|
|
77
|
-
'
|
|
78
|
-
'
|
|
80
|
+
'USER SCRIPT (same concept as the builder\'s `jsUserScripts` — ' +
|
|
81
|
+
'devtools just calls the field `customInjection`) that drives the ' +
|
|
82
|
+
'page to where the request fires. \n\n' +
|
|
79
83
|
'BY DESIGN, this backend only ever CREATES A NEW VERSION (POST) — ' +
|
|
80
84
|
'there is no partial-patch/edit endpoint, and this tool does NOT ' +
|
|
81
85
|
'read or carry anything forward from the previous version. Every ' +
|
|
@@ -91,15 +95,20 @@ export function createVersionFromCaptureTool(client, attachRef) {
|
|
|
91
95
|
'values plus your change) back into this call. ' +
|
|
92
96
|
'`allowedInjectedRequestData`/`useIncognitoWebview`/' +
|
|
93
97
|
'`extensionConfig` only take effect on add-version — the register ' +
|
|
94
|
-
'endpoint (no `providerId`) ignores them
|
|
95
|
-
'
|
|
96
|
-
'
|
|
97
|
-
'
|
|
98
|
+
'endpoint (no `providerId`) ignores them, so on a fresh register ' +
|
|
99
|
+
'this tool automatically follows up with an add-version call that ' +
|
|
100
|
+
'applies them (also where document replay gets force-disabled — ' +
|
|
101
|
+
'see below). Per-request fields you may set on ' +
|
|
98
102
|
'`provider`/`providers`: `credentials` (omit/same-origin/include, ' +
|
|
99
103
|
'default include), `urlType` (REGEX/CONSTANT/TEMPLATE, default ' +
|
|
100
104
|
'derived from {{placeholders}}), and `isOptional` on each ' +
|
|
101
105
|
'responseMatch (default false). \n\n' +
|
|
102
106
|
'Captured requests must be GET/POST. OPRF redactions are supported. ' +
|
|
107
|
+
'Document replay — the HAWKEYE-level flag controlling whether the ' +
|
|
108
|
+
'page\'s own document/navigation request gets genuinely re-sent ' +
|
|
109
|
+
'through the interceptor at verification time — is ALWAYS ' +
|
|
110
|
+
'published disabled (`disableRequestReplay: true`); there is no ' +
|
|
111
|
+
'way to enable it here. ' +
|
|
103
112
|
'On success, if a browser is still attached, the response\'s ' +
|
|
104
113
|
'`_notes` reminds you to confirm with the developer before ' +
|
|
105
114
|
'calling `dispose_browser` — never close it unprompted, they ' +
|
|
@@ -388,13 +397,24 @@ export function createVersionFromCaptureTool(client, attachRef) {
|
|
|
388
397
|
description: 'Free-form JSON passed through to the client. Add-version ' +
|
|
389
398
|
'ONLY.',
|
|
390
399
|
},
|
|
391
|
-
|
|
392
|
-
type: '
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
'
|
|
396
|
-
'
|
|
397
|
-
'
|
|
400
|
+
injectionType: {
|
|
401
|
+
type: 'string',
|
|
402
|
+
enum: ['NONE', 'MSWJS', 'XHOOK', 'CDP', 'HAWKEYE'],
|
|
403
|
+
description: 'Interception mechanism for this version. Omit to leave it ' +
|
|
404
|
+
'unset — the devtools backend\'s own column default ' +
|
|
405
|
+
'(`HAWKEYE`) applies, which is inert and harmless for the ' +
|
|
406
|
+
'standard capture→replay flow with no interceptor script ' +
|
|
407
|
+
'installed. Only set this to `HAWKEYE`/`MSWJS`/`XHOOK` ' +
|
|
408
|
+
'when you are ALSO supplying a `customInjection` script ' +
|
|
409
|
+
'that calls ' +
|
|
410
|
+
'`window.reclaimInterceptor.addResponseMiddleware(...)` for ' +
|
|
411
|
+
'genuine live interception, or set `NONE` explicitly if you ' +
|
|
412
|
+
'want to be certain no interception is configured — see ' +
|
|
413
|
+
'injection-api.md\'s "Recognizing this on a provider you ' +
|
|
414
|
+
'didn\'t author" section for what these values actually do. ' +
|
|
415
|
+
'Builder calls the equivalent concept `interceptorType` (no ' +
|
|
416
|
+
'`NONE` there — omitting the whole `interceptorOptions` ' +
|
|
417
|
+
'object is its no-interception signal instead).',
|
|
398
418
|
},
|
|
399
419
|
},
|
|
400
420
|
required: ['initialUrl'],
|
|
@@ -429,7 +449,13 @@ export function createVersionFromCaptureTool(client, attachRef) {
|
|
|
429
449
|
stepsToFollow: args.stepsToFollow,
|
|
430
450
|
useIncognitoWebview: args.useIncognitoWebview,
|
|
431
451
|
extensionConfig: args.extensionConfig,
|
|
432
|
-
|
|
452
|
+
// Author-settable, no forced default — the backend's own HAWKEYE
|
|
453
|
+
// column default applies when omitted, same as before this field
|
|
454
|
+
// existed at all.
|
|
455
|
+
injectionType: args.injectionType,
|
|
456
|
+
// Always disabled — see the tool description above; there is no
|
|
457
|
+
// author-facing way to enable document replay.
|
|
458
|
+
disableRequestReplay: true,
|
|
433
459
|
allowedInjectedRequestData: args.allowedInjectedRequestData
|
|
434
460
|
? injectedRequestDataFrom(args.allowedInjectedRequestData)
|
|
435
461
|
: undefined
|
|
@@ -468,11 +494,12 @@ export function createVersionFromCaptureTool(client, attachRef) {
|
|
|
468
494
|
'every page load (before navigating to initialUrl), matching ' +
|
|
469
495
|
'the builder\'s jsUserScripts.');
|
|
470
496
|
}
|
|
471
|
-
// disableRequestReplay is honored ONLY on add-version,
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
|
|
497
|
+
// disableRequestReplay is honored ONLY on add-version, and it's now
|
|
498
|
+
// always forced true — so every register is immediately followed by
|
|
499
|
+
// an add-version call that actually applies it (which also lands
|
|
500
|
+
// an explicit injectionType, when passed — unverified whether
|
|
501
|
+
// register persists it — and any other register-ignored fields the
|
|
502
|
+
// caller passed).
|
|
476
503
|
const REGISTER_IGNORED_KEYS = [
|
|
477
504
|
'allowedInjectedRequestData',
|
|
478
505
|
'useIncognitoWebview',
|
|
@@ -484,34 +511,51 @@ export function createVersionFromCaptureTool(client, attachRef) {
|
|
|
484
511
|
notes.push(`${ignoredOnRegister.join(', ')} ` +
|
|
485
512
|
`${ignoredOnRegister.length > 1 ? 'are' : 'is'} ` +
|
|
486
513
|
'ignored on register (no providerId) — the devtools backend only ' +
|
|
487
|
-
'reads them when adding a version. ' +
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
'created to set disableRequestReplay.'
|
|
491
|
-
: 'Publish once to create the provider, then call this ' +
|
|
492
|
-
'again WITH the returned providerId to set them.'));
|
|
514
|
+
'reads them when adding a version. They were applied by the ' +
|
|
515
|
+
'automatic follow-up version this tool always creates to ' +
|
|
516
|
+
'force-disable document replay.');
|
|
493
517
|
}
|
|
494
518
|
const body = providerToRegisterBody(providers, opts);
|
|
495
519
|
const registered = await client.registerProvider(body);
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
520
|
+
const newProviderId = extractProviderId(registered);
|
|
521
|
+
if (newProviderId) {
|
|
522
|
+
// The provider already exists as of the register call above —
|
|
523
|
+
// a failure past this point must NOT reject the tool and
|
|
524
|
+
// discard `registered`/`newProviderId`, or a retry without
|
|
525
|
+
// `providerId` would register a DUPLICATE provider. Catch and
|
|
526
|
+
// warn instead, telling the caller to resume WITH this
|
|
527
|
+
// providerId.
|
|
528
|
+
try {
|
|
499
529
|
const versionsResp = await client.listVersions(newProviderId);
|
|
500
530
|
const followVersion = bumpPatchString(extractVersions(versionsResp));
|
|
501
|
-
const configBody = providerToConfigBody(providers,
|
|
531
|
+
const configBody = providerToConfigBody(providers, opts, followVersion, DEFAULT_VERSION_INFO);
|
|
502
532
|
await client.updateProviderConfig(newProviderId, configBody);
|
|
503
|
-
notes.push(
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
533
|
+
notes.push(`Created an automatic follow-up version (${followVersion}) to ` +
|
|
534
|
+
'force-disable document replay (disableRequestReplay)' +
|
|
535
|
+
(opts.injectionType !== undefined
|
|
536
|
+
? ` and set injectionType (${opts.injectionType})`
|
|
537
|
+
: '') +
|
|
538
|
+
' — the register endpoint ignores/may ignore those fields, so ' +
|
|
539
|
+
'every fresh register gets one. It also landed any other ' +
|
|
540
|
+
'register-ignored fields you passed.');
|
|
507
541
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
542
|
+
catch (err) {
|
|
543
|
+
const detail = isBoom(err) ? err.message : String(err);
|
|
544
|
+
notes.push(`Provider ${newProviderId} WAS created, but the automatic ` +
|
|
545
|
+
`follow-up version (to force-disable document replay${opts.injectionType !== undefined
|
|
546
|
+
? ' and set injectionType'
|
|
547
|
+
: ''}) failed: ${detail}. Do NOT retry without providerId — that ` +
|
|
548
|
+
'would register a duplicate. Instead, re-run ' +
|
|
549
|
+
'create_provider_version_from_capture WITH ' +
|
|
550
|
+
`providerId: "${newProviderId}" to add the follow-up version.`);
|
|
513
551
|
}
|
|
514
552
|
}
|
|
553
|
+
else {
|
|
554
|
+
notes.push('Could not force-disable document replay: the ' +
|
|
555
|
+
'register response did not include the new providerId. Call ' +
|
|
556
|
+
'this tool again WITH `providerId` to create a version that ' +
|
|
557
|
+
'applies them.');
|
|
558
|
+
}
|
|
515
559
|
return withNotes(registered);
|
|
516
560
|
}
|
|
517
561
|
// New version of an existing provider — pick the next patch above
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replay a drafted provider's AUTHENTICATED request FROM INSIDE the live
|
|
3
|
+
* attached browser tab, over CDP `Runtime.evaluate` — so cookies/session are
|
|
4
|
+
* always the real browser's, for every browser source the MCP can attach to
|
|
5
|
+
* (local Chrome, remote Popcorn, or a caller-supplied custom CDP endpoint all
|
|
6
|
+
* funnel through one `AuthoringBackend.sendCdp`). Ports the pattern already
|
|
7
|
+
* proven in the cloud/Popcorn authoring backend
|
|
8
|
+
* (`packages/app/src/author/backend.ts` `pageFetch`/`authenticatedFetch`),
|
|
9
|
+
* adapted to the MCP's single-page `sendCdp(method, params)` (no separate
|
|
10
|
+
* `pageSession` argument). The ANONYMOUS auth-bound check does NOT go through
|
|
11
|
+
* the page — see `replayProviderInPage`'s doc comment below for why.
|
|
12
|
+
*/
|
|
13
|
+
import type { ReclaimProvider } from '../provider/schema.ts';
|
|
14
|
+
import { type ReplayResult } from './replay.ts';
|
|
15
|
+
/** Everything this module needs from an `AuthoringBackend` — just the raw CDP
|
|
16
|
+
* seam, so callers can pass a minimal object instead of a full backend. */
|
|
17
|
+
export interface PageReplayTarget {
|
|
18
|
+
sendCdp(method: string, params?: Record<string, unknown>): Promise<unknown>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Replay `provider` and judge the response. Without `withoutSecrets`, replays
|
|
22
|
+
* as the logged-in session — run FROM INSIDE the live tab
|
|
23
|
+
* (`authenticatedPageFetch`) so cookies are the real browser's, which a
|
|
24
|
+
* detached call could never reproduce without manually re-deriving them.
|
|
25
|
+
* WITH `withoutSecrets` (the auth-bound check — "does this still work with
|
|
26
|
+
* no credentials, i.e. is it actually public"), replay instead through the
|
|
27
|
+
* detached Node-side `replayProvider`: the whole point of that check is to
|
|
28
|
+
* prove NO secret rides along, and the live page's own global `fetch` can't
|
|
29
|
+
* give that guarantee — a site (or a script installed via test_user_script)
|
|
30
|
+
* can wrap `fetch` and re-attach a token from localStorage/a service worker
|
|
31
|
+
* even under `credentials:'omit'`. A fresh, page-uninvolved fetch has no
|
|
32
|
+
* such surface.
|
|
33
|
+
*/
|
|
34
|
+
export declare function replayProviderInPage(backend: PageReplayTarget, provider: ReclaimProvider, secrets: Record<string, string>, opts?: {
|
|
35
|
+
withoutSecrets?: boolean;
|
|
36
|
+
}): Promise<ReplayResult>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { buildReplayRequest, evaluateReplay, replayProvider } from "./replay.js";
|
|
2
|
+
/** Run `fetch(...)` for `provider`'s (already-templated) request inside the
|
|
3
|
+
* attached tab, via `Runtime.evaluate`. `credentials` selects the visitor:
|
|
4
|
+
* `'omit'` = anonymous (the auth-bound check), `'include'` = the logged-in
|
|
5
|
+
* user (the reproducibility gate). Same-origin only — the tab is already on
|
|
6
|
+
* the target site; a cross-origin/CORS failure comes back as `error`. */
|
|
7
|
+
async function pageFetch(backend, provider, credentials, extraSecrets) {
|
|
8
|
+
const req = buildReplayRequest(provider, extraSecrets);
|
|
9
|
+
const expression = `(async () => {
|
|
10
|
+
try {
|
|
11
|
+
const r = await fetch(${JSON.stringify(req.url)}, {
|
|
12
|
+
method: ${JSON.stringify(req.method)},
|
|
13
|
+
headers: ${JSON.stringify(req.headers)},
|
|
14
|
+
credentials: ${JSON.stringify(credentials)},${req.body !== undefined
|
|
15
|
+
? `\n\t\t\t\tbody: ${JSON.stringify(req.body)},`
|
|
16
|
+
: ''}
|
|
17
|
+
})
|
|
18
|
+
return { status: r.status, body: await r.text() }
|
|
19
|
+
} catch(e) { return { error: String((e && e.message) || e) } }
|
|
20
|
+
})()`;
|
|
21
|
+
try {
|
|
22
|
+
const res = (await backend.sendCdp('Runtime.evaluate', {
|
|
23
|
+
expression,
|
|
24
|
+
awaitPromise: true,
|
|
25
|
+
returnByValue: true,
|
|
26
|
+
}));
|
|
27
|
+
if (res.exceptionDetails) {
|
|
28
|
+
return {
|
|
29
|
+
error: res.exceptionDetails.exception?.description
|
|
30
|
+
?? res.exceptionDetails.text
|
|
31
|
+
?? 'page fetch evaluation error',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return res.result?.value ?? { error: 'page fetch returned no value' };
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Authenticated re-issue for the reproducibility gate. Cookies ride along
|
|
41
|
+
* via `credentials: 'include'` (a `Cookie` header can't be set from
|
|
42
|
+
* `fetch()` — it's a forbidden header name), so only NON-cookie secrets
|
|
43
|
+
* (Authorization, X-* tokens) are injected explicitly. */
|
|
44
|
+
function authenticatedPageFetch(backend, provider, secrets) {
|
|
45
|
+
const extra = {};
|
|
46
|
+
for (const [k, v] of Object.entries(secrets)) {
|
|
47
|
+
if (k.toLowerCase() !== 'cookie') {
|
|
48
|
+
extra[k] = v;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return pageFetch(backend, provider, 'include', extra);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Replay `provider` and judge the response. Without `withoutSecrets`, replays
|
|
55
|
+
* as the logged-in session — run FROM INSIDE the live tab
|
|
56
|
+
* (`authenticatedPageFetch`) so cookies are the real browser's, which a
|
|
57
|
+
* detached call could never reproduce without manually re-deriving them.
|
|
58
|
+
* WITH `withoutSecrets` (the auth-bound check — "does this still work with
|
|
59
|
+
* no credentials, i.e. is it actually public"), replay instead through the
|
|
60
|
+
* detached Node-side `replayProvider`: the whole point of that check is to
|
|
61
|
+
* prove NO secret rides along, and the live page's own global `fetch` can't
|
|
62
|
+
* give that guarantee — a site (or a script installed via test_user_script)
|
|
63
|
+
* can wrap `fetch` and re-attach a token from localStorage/a service worker
|
|
64
|
+
* even under `credentials:'omit'`. A fresh, page-uninvolved fetch has no
|
|
65
|
+
* such surface.
|
|
66
|
+
*/
|
|
67
|
+
export async function replayProviderInPage(backend, provider, secrets, opts) {
|
|
68
|
+
if (opts?.withoutSecrets) {
|
|
69
|
+
return replayProvider(provider, {});
|
|
70
|
+
}
|
|
71
|
+
const result = await authenticatedPageFetch(backend, provider, secrets);
|
|
72
|
+
if (result.error !== undefined || result.status === undefined) {
|
|
73
|
+
throw new Error(`in-browser replay failed: ${result.error ?? 'no response'}`);
|
|
74
|
+
}
|
|
75
|
+
return evaluateReplay(result.status, result.body ?? '', provider);
|
|
76
|
+
}
|
package/lib/proof/replay.d.ts
CHANGED
|
@@ -5,4 +5,32 @@ export interface ReplayResult {
|
|
|
5
5
|
matchesOriginal: boolean;
|
|
6
6
|
hints?: string[];
|
|
7
7
|
}
|
|
8
|
+
/** Expand `{{name}}` placeholders from `paramValues`. Matches the attestor
|
|
9
|
+
* SDK's behavior so replay and proof both hit the same final URL. */
|
|
10
|
+
export declare function expandTemplate(s: string, params: Record<string, string> | undefined): string;
|
|
11
|
+
/** Build the request the wire replay actually sends: `{{name}}` placeholders
|
|
12
|
+
* in the URL/headers/body expanded from `provider.paramValues`, secrets
|
|
13
|
+
* layered onto the public headers. Shared by every replay transport (Node
|
|
14
|
+
* `fetch` here, or a `fetch` run inside the live browser tab) so they all
|
|
15
|
+
* send the identical request. */
|
|
16
|
+
export interface ReplayRequest {
|
|
17
|
+
url: string;
|
|
18
|
+
method: string;
|
|
19
|
+
headers: Record<string, string>;
|
|
20
|
+
body?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function buildReplayRequest(provider: ReclaimProvider, secrets: Record<string, string>): ReplayRequest;
|
|
23
|
+
/**
|
|
24
|
+
* Judge a replayed response against `provider`'s matchers — the part of
|
|
25
|
+
* replay that's identical regardless of HOW the request was sent (detached
|
|
26
|
+
* Node `fetch` or a live in-browser `fetch`). Separated from the actual
|
|
27
|
+
* network call so both transports share one evaluation.
|
|
28
|
+
*/
|
|
29
|
+
export declare function evaluateReplay(status: number, body: string, provider: ReclaimProvider): ReplayResult;
|
|
30
|
+
/** Detached Node-side replay: sends the request directly from this process,
|
|
31
|
+
* NOT from any attached browser tab — its cookies/secrets are exactly the
|
|
32
|
+
* `secrets` passed in, never a real browser session. Used by tests and by
|
|
33
|
+
* any caller with no live browser to replay through (the MCP's authoring
|
|
34
|
+
* backend instead runs the SAME request inside the attached tab — see
|
|
35
|
+
* `proof/page-replay.ts` — so replay reflects real session cookies). */
|
|
8
36
|
export declare function replayProvider(provider: ReclaimProvider, secrets: Record<string, string>): Promise<ReplayResult>;
|
package/lib/proof/replay.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Expand `{{name}}` placeholders from `paramValues`. Matches the attestor
|
|
2
2
|
* SDK's behavior so replay and proof both hit the same final URL. */
|
|
3
|
-
function expandTemplate(s, params) {
|
|
3
|
+
export function expandTemplate(s, params) {
|
|
4
4
|
if (!params) {
|
|
5
5
|
return s;
|
|
6
6
|
}
|
|
@@ -9,7 +9,7 @@ function expandTemplate(s, params) {
|
|
|
9
9
|
return v !== undefined ? v : match;
|
|
10
10
|
});
|
|
11
11
|
}
|
|
12
|
-
export
|
|
12
|
+
export function buildReplayRequest(provider, secrets) {
|
|
13
13
|
const params = provider.paramValues;
|
|
14
14
|
const url = expandTemplate(provider.url, params);
|
|
15
15
|
const expandedHeaders = {};
|
|
@@ -17,21 +17,29 @@ export async function replayProvider(provider, secrets) {
|
|
|
17
17
|
expandedHeaders[k] = expandTemplate(v, params);
|
|
18
18
|
}
|
|
19
19
|
const headers = { ...expandedHeaders, ...secrets };
|
|
20
|
-
const
|
|
20
|
+
const body = provider.body !== undefined
|
|
21
|
+
? expandTemplate(provider.body, params)
|
|
22
|
+
: undefined;
|
|
23
|
+
return {
|
|
24
|
+
url,
|
|
21
25
|
method: provider.method,
|
|
22
26
|
headers,
|
|
23
|
-
|
|
27
|
+
...(body !== undefined ? { body } : {}),
|
|
24
28
|
};
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Judge a replayed response against `provider`'s matchers — the part of
|
|
32
|
+
* replay that's identical regardless of HOW the request was sent (detached
|
|
33
|
+
* Node `fetch` or a live in-browser `fetch`). Separated from the actual
|
|
34
|
+
* network call so both transports share one evaluation.
|
|
35
|
+
*/
|
|
36
|
+
export function evaluateReplay(status, body, provider) {
|
|
37
|
+
const params = provider.paramValues;
|
|
30
38
|
const hints = [];
|
|
31
|
-
if (
|
|
39
|
+
if (status === 401 || status === 403) {
|
|
32
40
|
hints.push('auth/permission failed');
|
|
33
41
|
}
|
|
34
|
-
if (
|
|
42
|
+
if (status >= 500) {
|
|
35
43
|
hints.push('upstream server error');
|
|
36
44
|
}
|
|
37
45
|
for (const m of provider.responseMatches) {
|
|
@@ -45,22 +53,35 @@ export async function replayProvider(provider, secrets) {
|
|
|
45
53
|
value: expandTemplate(m.value, params),
|
|
46
54
|
};
|
|
47
55
|
if (!matchesBody(body, expanded)) {
|
|
48
|
-
if (
|
|
56
|
+
if (status >= 200 && status < 300) {
|
|
49
57
|
hints.push(`matcher ${expanded.type} did not match — ` +
|
|
50
58
|
'value may have changed or matcher is too tight');
|
|
51
59
|
}
|
|
52
|
-
const result = {
|
|
53
|
-
status: res.status,
|
|
54
|
-
body,
|
|
55
|
-
matchesOriginal: false,
|
|
56
|
-
};
|
|
60
|
+
const result = { status, body, matchesOriginal: false };
|
|
57
61
|
if (hints.length > 0) {
|
|
58
62
|
result.hints = hints;
|
|
59
63
|
}
|
|
60
64
|
return result;
|
|
61
65
|
}
|
|
62
66
|
}
|
|
63
|
-
return { status
|
|
67
|
+
return { status, body, matchesOriginal: status < 400 };
|
|
68
|
+
}
|
|
69
|
+
/** Detached Node-side replay: sends the request directly from this process,
|
|
70
|
+
* NOT from any attached browser tab — its cookies/secrets are exactly the
|
|
71
|
+
* `secrets` passed in, never a real browser session. Used by tests and by
|
|
72
|
+
* any caller with no live browser to replay through (the MCP's authoring
|
|
73
|
+
* backend instead runs the SAME request inside the attached tab — see
|
|
74
|
+
* `proof/page-replay.ts` — so replay reflects real session cookies). */
|
|
75
|
+
export async function replayProvider(provider, secrets) {
|
|
76
|
+
const req = buildReplayRequest(provider, secrets);
|
|
77
|
+
const res = await fetch(req.url, {
|
|
78
|
+
method: req.method,
|
|
79
|
+
headers: req.headers,
|
|
80
|
+
redirect: 'manual',
|
|
81
|
+
...(req.body !== undefined ? { body: req.body } : {}),
|
|
82
|
+
});
|
|
83
|
+
const body = await res.text();
|
|
84
|
+
return evaluateReplay(res.status, body, provider);
|
|
64
85
|
}
|
|
65
86
|
function matchesBody(body, m) {
|
|
66
87
|
if (m.type === 'regex') {
|
package/lib/provider/draft.d.ts
CHANGED
|
@@ -57,4 +57,6 @@ export declare function assertNoOprfUrlConflict(url: string, targets: DraftTarge
|
|
|
57
57
|
* header (same class of problem as {@link assertNoOprfUrlConflict} for
|
|
58
58
|
* OPRF-hashed targets, which has no such budget at all). */
|
|
59
59
|
export declare function assertNoSecretUrlConflict(url: string, secrets: Record<string, string>, writeRedactionMode?: ReclaimProvider['writeRedactionMode']): void;
|
|
60
|
-
export declare function draftProvider(req: CapturedRequest, targets: DraftTarget[], name: string, hash?: OprfMode
|
|
60
|
+
export declare function draftProvider(req: CapturedRequest, targets: DraftTarget[], name: string, hash?: OprfMode, opts?: {
|
|
61
|
+
shortenNames?: boolean;
|
|
62
|
+
}): DraftResult;
|