@reclaimprotocol/agent 0.1.36 → 0.1.37
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 +8 -8
- package/lib/authoring/backend.d.ts +12 -1
- package/lib/authoring/backend.js +3 -0
- package/lib/authoring/tools.js +33 -5
- package/lib/authoring/util.d.ts +43 -0
- package/lib/authoring/util.js +54 -0
- package/lib/cdp/chrome-for-testing.d.ts +14 -0
- package/lib/cdp/chrome-for-testing.js +43 -0
- package/lib/cdp/geo.d.ts +3 -0
- package/lib/cdp/geo.js +27 -15
- package/lib/cdp/launcher.d.ts +79 -1
- package/lib/cdp/launcher.js +244 -24
- package/lib/logger.js +3 -1
- package/lib/mcp/instructions.js +3 -3
- package/lib/mcp/start.js +17 -2
- package/lib/mcp/tools/agent/attach.d.ts +6 -0
- package/lib/mcp/tools/agent/attach.js +58 -118
- package/lib/mcp/tools/agent/build.js +6 -0
- package/lib/mcp/tools/agent/capture.js +33 -10
- package/lib/mcp/tools/agent/check-attestor.js +15 -7
- package/lib/mcp/tools/agent/draft-tools.d.ts +3 -0
- package/lib/mcp/tools/agent/draft-tools.js +170 -0
- package/lib/mcp/tools/agent/pod-proof.js +5 -1
- package/lib/mcp/tools/agent/proof.d.ts +28 -0
- package/lib/mcp/tools/agent/proof.js +72 -0
- package/lib/mcp/tools/agent/shared.d.ts +8 -1
- package/lib/mcp/tools/agent/shared.js +98 -19
- package/lib/mcp/tools/agent/start-integration.d.ts +2 -1
- package/lib/mcp/tools/agent/start-integration.js +10 -6
- package/lib/mcp/tools/authenticate/authenticate.d.ts +48 -10
- package/lib/mcp/tools/authenticate/authenticate.js +212 -116
- package/lib/mcp/tools/authenticate/build.d.ts +2 -2
- package/lib/mcp/tools/authenticate/build.js +6 -3
- package/lib/mcp/tools/authenticate/builder-access.js +5 -2
- package/lib/mcp/tools/authenticate/eth-key.d.ts +13 -2
- package/lib/mcp/tools/authenticate/eth-key.js +29 -5
- package/lib/mcp/tools/authenticate/index.d.ts +1 -1
- package/lib/mcp/tools/authenticate/index.js +1 -1
- package/lib/mcp/tools/authenticate/issue-org-keypair.d.ts +10 -0
- package/lib/mcp/tools/authenticate/issue-org-keypair.js +82 -0
- package/lib/mcp/tools/authenticate/oauth-config.d.ts +31 -0
- package/lib/mcp/tools/authenticate/oauth-config.js +67 -0
- package/lib/mcp/tools/authenticate/resolve-owner-key.d.ts +4 -5
- package/lib/mcp/tools/authenticate/resolve-owner-key.js +40 -36
- package/lib/mcp/tools/authenticate/session-cache.d.ts +25 -1
- package/lib/mcp/tools/authenticate/session-cache.js +95 -4
- package/lib/mcp/tools/mode/mode.js +6 -6
- package/lib/old/login-cdp.js +2 -3
- package/lib/proof/attestor.js +2 -3
- package/lib/proof/check-attestor.d.ts +12 -2
- package/lib/proof/check-attestor.js +40 -10
- package/lib/proof/check-matchers.d.ts +36 -0
- package/lib/proof/check-matchers.js +161 -0
- package/lib/proof/zk-files.d.ts +50 -11
- package/lib/proof/zk-files.js +259 -63
- package/lib/provider/draft.d.ts +24 -1
- package/lib/provider/draft.js +135 -0
- package/lib/provider/matcher.js +171 -4
- package/package.json +6 -4
- package/skill/SKILL.builder.md +7 -2
- package/skill/SKILL.common.md +90 -70
- package/skill/SKILL.old.md +3 -3
- package/skill/builder-mode.md +35 -5
- package/skill/old-mode.md +18 -13
- package/skill/start-integration.md +48 -33
- package/skill/verification-client-integration.md +21 -6
- package/src/authoring/backend.ts +12 -1
- package/src/authoring/tools.ts +48 -7
- package/src/authoring/util.ts +64 -0
- package/src/cdp/chrome-for-testing.ts +66 -0
- package/src/cdp/geo.ts +36 -21
- package/src/cdp/launcher.ts +328 -24
- package/src/logger.ts +3 -1
- package/src/mcp/instructions.ts +3 -3
- package/src/mcp/start.ts +20 -1
- package/src/mcp/tools/agent/attach.ts +65 -128
- package/src/mcp/tools/agent/build.ts +7 -0
- package/src/mcp/tools/agent/capture.ts +54 -11
- package/src/mcp/tools/agent/check-attestor.ts +15 -7
- package/src/mcp/tools/agent/draft-tools.ts +234 -0
- package/src/mcp/tools/agent/pod-proof.ts +5 -1
- package/src/mcp/tools/agent/proof.ts +110 -1
- package/src/mcp/tools/agent/shared.ts +130 -19
- package/src/mcp/tools/agent/start-integration.ts +10 -6
- package/src/mcp/tools/authenticate/authenticate.ts +276 -131
- package/src/mcp/tools/authenticate/build.ts +6 -3
- package/src/mcp/tools/authenticate/builder-access.ts +5 -2
- package/src/mcp/tools/authenticate/eth-key.ts +41 -7
- package/src/mcp/tools/authenticate/index.ts +1 -1
- package/src/mcp/tools/authenticate/issue-org-keypair.ts +106 -0
- package/src/mcp/tools/authenticate/oauth-config.ts +85 -0
- package/src/mcp/tools/authenticate/resolve-owner-key.ts +42 -39
- package/src/mcp/tools/authenticate/session-cache.ts +125 -5
- package/src/mcp/tools/mode/mode.ts +6 -6
- package/src/old/login-cdp.ts +2 -3
- package/src/proof/attestor.ts +2 -3
- package/src/proof/check-attestor.ts +55 -12
- package/src/proof/check-matchers.ts +227 -0
- package/src/proof/zk-files.ts +357 -65
- package/src/provider/draft.ts +215 -7
- package/src/provider/matcher.ts +237 -4
|
@@ -3,15 +3,53 @@ import { spawn } from 'node:child_process'
|
|
|
3
3
|
import { createServer } from 'node:http'
|
|
4
4
|
import * as oauth from 'openid-client'
|
|
5
5
|
import { defineTool, type RegisteredTool } from '../../server.ts'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
authBaseFor,
|
|
8
|
+
oauthRequestOptions,
|
|
9
|
+
SCOPE,
|
|
10
|
+
sessionFromTokens,
|
|
11
|
+
} from './oauth-config.ts'
|
|
12
|
+
import { readSession, writeSession } from './session-cache.ts'
|
|
7
13
|
|
|
8
|
-
|
|
14
|
+
type AuthToolName = 'reclaim_authenticate' | 'authenticate_builder'
|
|
15
|
+
|
|
16
|
+
/** How long a sign-in link stays valid (the loopback listener's lifetime). */
|
|
9
17
|
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000
|
|
18
|
+
/** Longest single `wait_for_sign_in` call; stays under common 60 s tool
|
|
19
|
+
* timeouts. */
|
|
20
|
+
const MAX_WAIT_SECONDS = 55
|
|
21
|
+
const DEFAULT_WAIT_SECONDS = 50
|
|
22
|
+
/** How long a browser launcher gets to fail before it counts as opened. */
|
|
23
|
+
const BROWSER_GRACE_MS = 1500
|
|
24
|
+
|
|
25
|
+
/** Whether the browser launcher started, and why not when it didn't. */
|
|
26
|
+
export type BrowserLaunch = { opened: boolean, reason?: string }
|
|
27
|
+
|
|
28
|
+
export type PendingSignIn = {
|
|
29
|
+
status: 'PENDING'
|
|
30
|
+
signInUrl: string
|
|
31
|
+
browserOpened: boolean
|
|
32
|
+
reason?: string
|
|
33
|
+
/** ISO timestamp when `signInUrl` stops working. */
|
|
34
|
+
expiresAt: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type SignInOutcome =
|
|
38
|
+
| { status: 'AUTHENTICATED', expiresAt: string }
|
|
39
|
+
| { status: 'FAILED', reason: string }
|
|
40
|
+
|
|
41
|
+
type SignInFlow = {
|
|
42
|
+
started: Promise<PendingSignIn>
|
|
43
|
+
completion: Promise<SignInOutcome>
|
|
44
|
+
outcome?: SignInOutcome
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The sign-in in progress (or last finished) for each Builder client. */
|
|
48
|
+
const FLOWS = new WeakMap<ReclaimClient, SignInFlow>()
|
|
10
49
|
|
|
11
50
|
export function authenticateTool(
|
|
12
51
|
client: ReclaimClient,
|
|
13
|
-
name:
|
|
14
|
-
'reclaim_authenticate',
|
|
52
|
+
name: AuthToolName = 'reclaim_authenticate',
|
|
15
53
|
): RegisteredTool {
|
|
16
54
|
return defineTool<Record<string, never>>(
|
|
17
55
|
{
|
|
@@ -25,37 +63,121 @@ export function authenticateTool(
|
|
|
25
63
|
+ 'get_me_providers; those need the separate old-devtools login, '
|
|
26
64
|
+ 'reclaim_authenticate. Check both with get_devtools_mode. '
|
|
27
65
|
: '')
|
|
28
|
-
+ '
|
|
29
|
-
+ '
|
|
30
|
-
+ '
|
|
31
|
-
+ '
|
|
32
|
-
+ '
|
|
33
|
-
+ '
|
|
34
|
-
+ '
|
|
35
|
-
+ '
|
|
66
|
+
+ 'Starts signing the developer in to Builder (OAuth 2.0 '
|
|
67
|
+
+ 'authorization code with PKCE, redirecting to a localhost port '
|
|
68
|
+
+ 'this server opens) and returns at once without waiting for them: '
|
|
69
|
+
+ '`{ status: "PENDING", signInUrl, browserOpened, reason?, '
|
|
70
|
+
+ 'expiresAt }`. It tries to open signInUrl in their browser; '
|
|
71
|
+
+ '`browserOpened` says whether that worked and `reason` why not. '
|
|
72
|
+
+ 'Always give the developer signInUrl and ask them to open it in a '
|
|
73
|
+
+ 'browser on this machine. When browserOpened is false, end your '
|
|
74
|
+
+ 'turn after sharing it — nothing happens until they open it. Then '
|
|
75
|
+
+ 'call `wait_for_sign_in` to confirm. The link expires after 5 '
|
|
76
|
+
+ 'minutes; calling again while one is pending returns the same '
|
|
77
|
+
+ 'link. On success the token is saved in ~/.reclaim/config.json '
|
|
78
|
+
+ 'with mode 0600, refreshed automatically, and never returned. It '
|
|
79
|
+
+ 'authorizes organization-management tools such as `list_orgs`. '
|
|
80
|
+
+ 'Programmatic verification tools use a separate organization key; '
|
|
81
|
+
+ 'see `issue_org_token`. Call with no arguments.',
|
|
36
82
|
inputSchema: { type: 'object', properties: {} },
|
|
37
83
|
annotations: { openWorldHint: true, idempotentHint: true },
|
|
38
84
|
},
|
|
39
|
-
async() =>
|
|
85
|
+
async() => startSignIn(client),
|
|
40
86
|
)
|
|
41
87
|
}
|
|
42
88
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
89
|
+
export function waitForSignInTool(
|
|
90
|
+
client: ReclaimClient,
|
|
91
|
+
authToolName: AuthToolName = 'reclaim_authenticate',
|
|
92
|
+
): RegisteredTool {
|
|
93
|
+
return defineTool<{ timeoutSeconds?: number }>(
|
|
94
|
+
{
|
|
95
|
+
name: 'wait_for_sign_in',
|
|
96
|
+
description:
|
|
97
|
+
`Waits for the Builder sign-in started by \`${authToolName}\` to `
|
|
98
|
+
+ `finish, for up to timeoutSeconds (default ${DEFAULT_WAIT_SECONDS}`
|
|
99
|
+
+ `, max ${MAX_WAIT_SECONDS}). Returns status AUTHENTICATED (with `
|
|
100
|
+
+ 'expiresAt); PENDING (not finished yet: repeats signInUrl — call '
|
|
101
|
+
+ 'again, or remind the developer to open the link); FAILED (with '
|
|
102
|
+
+ `reason: call \`${authToolName}\` for a new link); or NOT_STARTED `
|
|
103
|
+
+ '(no sign-in in progress and no valid session: call '
|
|
104
|
+
+ `\`${authToolName}\`).`,
|
|
105
|
+
inputSchema: {
|
|
106
|
+
type: 'object',
|
|
107
|
+
properties: {
|
|
108
|
+
timeoutSeconds: {
|
|
109
|
+
type: 'integer',
|
|
110
|
+
minimum: 0,
|
|
111
|
+
maximum: MAX_WAIT_SECONDS,
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
116
|
+
},
|
|
117
|
+
async({ timeoutSeconds }) => waitForSignIn(
|
|
118
|
+
client, (timeoutSeconds ?? DEFAULT_WAIT_SECONDS) * 1000,
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Start a sign-in and return its link without waiting for the developer. A
|
|
125
|
+
* sign-in already pending for `client` is returned as is rather than
|
|
126
|
+
* replaced. The flow finishes in the background: the loopback server keeps
|
|
127
|
+
* listening until the browser redirects back or the link expires, and on
|
|
128
|
+
* success caches the session and activates it on `client`.
|
|
129
|
+
*/
|
|
130
|
+
export async function startSignIn(
|
|
131
|
+
client: ReclaimClient,
|
|
132
|
+
// Overridable only so tests can intercept the authorize URL instead of
|
|
133
|
+
// actually spawning a browser; every real caller uses the default.
|
|
134
|
+
openBrowserFn: (url: string) => Promise<BrowserLaunch> = openBrowser,
|
|
135
|
+
): Promise<PendingSignIn> {
|
|
136
|
+
const existing = FLOWS.get(client)
|
|
137
|
+
if(existing && !existing.outcome) {
|
|
138
|
+
return existing.started
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const begun = beginSignIn(client, openBrowserFn)
|
|
142
|
+
const flow: SignInFlow = {
|
|
143
|
+
started: begun.then(({ pending }) => pending),
|
|
144
|
+
completion: begun.then(
|
|
145
|
+
({ completion }) => completion,
|
|
146
|
+
(err) => ({ status: 'FAILED' as const, reason: failureReason(err) }),
|
|
147
|
+
),
|
|
148
|
+
}
|
|
149
|
+
void flow.completion.then((outcome) => {
|
|
150
|
+
flow.outcome = outcome
|
|
151
|
+
})
|
|
152
|
+
FLOWS.set(client, flow)
|
|
153
|
+
return flow.started
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Wait up to `timeoutMs` for `client`'s sign-in to finish. Reports PENDING
|
|
158
|
+
* (with the link again) if it's still open when the wait runs out.
|
|
159
|
+
*/
|
|
160
|
+
export async function waitForSignIn(
|
|
161
|
+
client: ReclaimClient,
|
|
162
|
+
timeoutMs: number,
|
|
163
|
+
): Promise<PendingSignIn | SignInOutcome | { status: 'NOT_STARTED' }> {
|
|
164
|
+
const flow = FLOWS.get(client)
|
|
165
|
+
if(!flow) {
|
|
166
|
+
const session = readSession()
|
|
167
|
+
return session
|
|
168
|
+
? { status: 'AUTHENTICATED', expiresAt: session.expiresAt }
|
|
169
|
+
: { status: 'NOT_STARTED' }
|
|
56
170
|
}
|
|
57
171
|
|
|
58
|
-
|
|
172
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
173
|
+
const outcome = await Promise.race([
|
|
174
|
+
flow.completion,
|
|
175
|
+
new Promise<undefined>((resolve) => {
|
|
176
|
+
timer = setTimeout(() => resolve(undefined), timeoutMs)
|
|
177
|
+
}),
|
|
178
|
+
])
|
|
179
|
+
clearTimeout(timer)
|
|
180
|
+
return outcome ?? flow.started
|
|
59
181
|
}
|
|
60
182
|
|
|
61
183
|
/**
|
|
@@ -67,36 +189,24 @@ const _customFetchForOauth: oauth.CustomFetch = async(
|
|
|
67
189
|
* validation (including a denied/failed login surfacing as
|
|
68
190
|
* `AuthorizationResponseError`) — this function only wires those calls
|
|
69
191
|
* together and owns the loopback server (no library spins that up for you).
|
|
192
|
+
* Resolves once the link is ready; `completion` settles when the flow ends
|
|
193
|
+
* and never rejects.
|
|
70
194
|
*/
|
|
71
|
-
|
|
195
|
+
async function beginSignIn(
|
|
72
196
|
client: ReclaimClient,
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
): Promise<unknown> {
|
|
77
|
-
const authBase = `${client.baseUrl}/auth`
|
|
197
|
+
openBrowserFn: (url: string) => Promise<BrowserLaunch>,
|
|
198
|
+
) {
|
|
199
|
+
const authBase = authBaseFor(client.baseUrl)
|
|
78
200
|
const codeVerifier = oauth.randomPKCECodeVerifier()
|
|
79
201
|
const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier)
|
|
80
202
|
const state = oauth.randomState()
|
|
81
203
|
|
|
82
|
-
// openid-client/oauth4webapi refuse any non-HTTPS issuer by default
|
|
83
|
-
// ("only requests to HTTPS are allowed"). Better Auth's own dev default
|
|
84
|
-
// (`canonicalOrigin()`, packages/app/src/consts.ts) is plain
|
|
85
|
-
// `http://localhost:4001` — the server side has no HTTPS requirement at
|
|
86
|
-
// all — so a local `RECLAIM_API_URL=http://localhost:...` would otherwise
|
|
87
|
-
// always fail here. Loosen it ONLY for loopback hosts, matching what the
|
|
88
|
-
// server already trusts (`authTrustedOrigins()` defaults to
|
|
89
|
-
// localhost:4000/4001 outside production); a non-loopback http:// origin
|
|
90
|
-
// still gets the strict check.
|
|
91
|
-
const isLoopbackAuthBase =
|
|
92
|
-
['localhost', '127.0.0.1', '[::1]'].includes(new URL(authBase).hostname)
|
|
93
|
-
|
|
94
204
|
// Bind the callback port first: the client is registered for this exact
|
|
95
205
|
// loopback redirect.
|
|
96
206
|
const callback = await listenForCallback()
|
|
97
|
-
let
|
|
207
|
+
let config: oauth.Configuration
|
|
98
208
|
try {
|
|
99
|
-
|
|
209
|
+
config = await oauth.dynamicClientRegistration(
|
|
100
210
|
new URL(authBase),
|
|
101
211
|
{
|
|
102
212
|
client_name: 'Reclaim CLI',
|
|
@@ -107,90 +217,83 @@ export async function runOAuthLoopback(
|
|
|
107
217
|
scope: SCOPE,
|
|
108
218
|
},
|
|
109
219
|
undefined,
|
|
110
|
-
|
|
111
|
-
// Better Auth exposes its metadata at the RFC 8414 well-known path
|
|
112
|
-
// (`.well-known/oauth-authorization-server`), not the OIDC one.
|
|
113
|
-
algorithm: 'oauth2',
|
|
114
|
-
// RFC 7591 §3.2.1 requires 201 Created on a successful registration;
|
|
115
|
-
// openid-client enforces that strictly. Better Auth's
|
|
116
|
-
// `oauth-provider` plugin doesn't conform — confirmed live against
|
|
117
|
-
// the real server: `POST .../oauth2/register` returns 200 with an
|
|
118
|
-
// otherwise well-formed body, which openid-client rejects outright
|
|
119
|
-
// ("unexpected HTTP response status code") before ever looking at
|
|
120
|
-
// it. `customFetch` set here persists onto the returned
|
|
121
|
-
// `Configuration` for ALL its future calls too (token exchange
|
|
122
|
-
// included, which correctly returns 200) — so the URL itself, not
|
|
123
|
-
// just method+status, must gate the rewrite, or a genuine 200 from
|
|
124
|
-
// the token endpoint gets corrupted into a 201 and
|
|
125
|
-
// `authorizationCodeGrant` fails instead (confirmed by hitting
|
|
126
|
-
// exactly that when this only checked method === 'POST').
|
|
127
|
-
[oauth.customFetch]: _customFetchForOauth,
|
|
128
|
-
// Applies to the `Configuration` this call returns, so the later
|
|
129
|
-
// `buildAuthorizationUrl`/`authorizationCodeGrant` calls below (which
|
|
130
|
-
// reuse that same `config`) stop enforcing HTTPS too — no separate
|
|
131
|
-
// opt-in needed for them.
|
|
132
|
-
...(isLoopbackAuthBase
|
|
133
|
-
? { execute: [oauth.allowInsecureRequests] }
|
|
134
|
-
: {}),
|
|
135
|
-
},
|
|
220
|
+
oauthRequestOptions(authBase),
|
|
136
221
|
)
|
|
137
|
-
openBrowserFn(
|
|
138
|
-
oauth.buildAuthorizationUrl(config, {
|
|
139
|
-
redirect_uri: callback.redirectUri,
|
|
140
|
-
scope: SCOPE,
|
|
141
|
-
state,
|
|
142
|
-
code_challenge: codeChallenge,
|
|
143
|
-
code_challenge_method: 'S256',
|
|
144
|
-
}).href,
|
|
145
|
-
)
|
|
146
|
-
const callbackUrl = await callback.url
|
|
147
|
-
tokens = await oauth.authorizationCodeGrant(config, callbackUrl, {
|
|
148
|
-
pkceCodeVerifier: codeVerifier,
|
|
149
|
-
expectedState: state,
|
|
150
|
-
})
|
|
151
222
|
} catch(err) {
|
|
152
|
-
// A denied or failed authorization surfaces as this error class with
|
|
153
|
-
// the raw callback params on `.cause` — reword it like the previous
|
|
154
|
-
// hand-rolled check did (RFC 6749 §4.1.2.1's error/error_description).
|
|
155
|
-
if(err instanceof oauth.AuthorizationResponseError) {
|
|
156
|
-
throw new Error(
|
|
157
|
-
err.cause.get('error_description')
|
|
158
|
-
?? err.cause.get('error')
|
|
159
|
-
?? 'Sign-in failed or was cancelled.',
|
|
160
|
-
)
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
throw err
|
|
164
|
-
} finally {
|
|
165
223
|
callback.close()
|
|
224
|
+
throw err
|
|
166
225
|
}
|
|
167
226
|
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
227
|
+
const signInUrl = oauth.buildAuthorizationUrl(config, {
|
|
228
|
+
redirect_uri: callback.redirectUri,
|
|
229
|
+
scope: SCOPE,
|
|
230
|
+
state,
|
|
231
|
+
code_challenge: codeChallenge,
|
|
232
|
+
code_challenge_method: 'S256',
|
|
233
|
+
}).href
|
|
234
|
+
const completion = finish()
|
|
235
|
+
const launch = await openBrowserFn(signInUrl).catch((err) => ({
|
|
236
|
+
opened: false,
|
|
237
|
+
reason: failureReason(err),
|
|
238
|
+
}))
|
|
239
|
+
const pending: PendingSignIn = {
|
|
240
|
+
status: 'PENDING',
|
|
241
|
+
signInUrl,
|
|
242
|
+
browserOpened: launch.opened,
|
|
243
|
+
...(launch.reason ? { reason: launch.reason } : {}),
|
|
244
|
+
expiresAt: callback.expiresAt,
|
|
245
|
+
}
|
|
246
|
+
return { pending, completion }
|
|
173
247
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
248
|
+
async function finish(): Promise<SignInOutcome> {
|
|
249
|
+
try {
|
|
250
|
+
const tokens = await oauth.authorizationCodeGrant(
|
|
251
|
+
config, await callback.url,
|
|
252
|
+
{ pkceCodeVerifier: codeVerifier, expectedState: state },
|
|
253
|
+
)
|
|
254
|
+
const session = sessionFromTokens(tokens, {
|
|
255
|
+
clientId: config.clientMetadata().client_id,
|
|
256
|
+
authBase,
|
|
257
|
+
})
|
|
258
|
+
writeSession(session)
|
|
259
|
+
client.setToken(session.token)
|
|
260
|
+
return { status: 'AUTHENTICATED', expiresAt: session.expiresAt }
|
|
261
|
+
} catch(err) {
|
|
262
|
+
return { status: 'FAILED', reason: failureReason(err) }
|
|
263
|
+
} finally {
|
|
264
|
+
callback.close()
|
|
265
|
+
}
|
|
178
266
|
}
|
|
179
267
|
}
|
|
180
268
|
|
|
269
|
+
function failureReason(err: unknown) {
|
|
270
|
+
// A denied or failed authorization surfaces as this error class with the
|
|
271
|
+
// raw callback params on `.cause` (RFC 6749 §4.1.2.1's
|
|
272
|
+
// error/error_description).
|
|
273
|
+
if(err instanceof oauth.AuthorizationResponseError) {
|
|
274
|
+
return err.cause.get('error_description')
|
|
275
|
+
?? err.cause.get('error')
|
|
276
|
+
?? 'Sign-in failed or was cancelled.'
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return err instanceof Error ? err.message : String(err)
|
|
280
|
+
}
|
|
281
|
+
|
|
181
282
|
/**
|
|
182
283
|
* Bind an ephemeral loopback port and start listening for the provider's
|
|
183
284
|
* redirect. Resolves as soon as the port is known — the `url` promise settles
|
|
184
|
-
* later, when the browser hits `/callback
|
|
185
|
-
* client for this exact redirect URI before
|
|
186
|
-
* The ephemeral port lets any number of
|
|
187
|
-
* RAW callback URL rather than picking
|
|
188
|
-
* `oauth.authorizationCodeGrant` does that
|
|
189
|
-
*
|
|
285
|
+
* later, when the browser hits `/callback`, or rejects when the link expires —
|
|
286
|
+
* so the caller can register a client for this exact redirect URI before
|
|
287
|
+
* sending the user off to authorize. The ephemeral port lets any number of
|
|
288
|
+
* CLIs run concurrently. Hands back the RAW callback URL rather than picking
|
|
289
|
+
* `code`/`state` apart itself — `oauth.authorizationCodeGrant` does that
|
|
290
|
+
* validation. The server and timer are unref'd: a pending sign-in alone
|
|
291
|
+
* doesn't keep the process alive.
|
|
190
292
|
*/
|
|
191
293
|
async function listenForCallback(): Promise<{
|
|
192
294
|
redirectUri: string
|
|
193
295
|
url: Promise<URL>
|
|
296
|
+
expiresAt: string
|
|
194
297
|
close: () => void
|
|
195
298
|
}> {
|
|
196
299
|
let resolveUrl: (url: URL) => void
|
|
@@ -201,10 +304,14 @@ async function listenForCallback(): Promise<{
|
|
|
201
304
|
})
|
|
202
305
|
|
|
203
306
|
const server = createServer()
|
|
307
|
+
const expiresAt = new Date(Date.now() + CALLBACK_TIMEOUT_MS).toISOString()
|
|
204
308
|
const timer = setTimeout(
|
|
205
|
-
() => rejectUrl(new Error(
|
|
309
|
+
() => rejectUrl(new Error(
|
|
310
|
+
'Sign-in link expired (5 min). Call the sign-in tool again for a new '
|
|
311
|
+
+ 'link.',
|
|
312
|
+
)),
|
|
206
313
|
CALLBACK_TIMEOUT_MS,
|
|
207
|
-
)
|
|
314
|
+
).unref()
|
|
208
315
|
|
|
209
316
|
const port = await new Promise<number>((resolve, reject) => {
|
|
210
317
|
server.once('error', reject)
|
|
@@ -220,11 +327,11 @@ async function listenForCallback(): Promise<{
|
|
|
220
327
|
}).catch((err) => {
|
|
221
328
|
// Bind failed before `close()` (returned below) is ever reachable —
|
|
222
329
|
// clear the timer ourselves, or it fires 5 minutes later and rejects
|
|
223
|
-
// the now-unobserved `url` promise (unhandled rejection)
|
|
224
|
-
// the process alive in the meantime.
|
|
330
|
+
// the now-unobserved `url` promise (unhandled rejection).
|
|
225
331
|
clearTimeout(timer)
|
|
226
332
|
throw err
|
|
227
333
|
})
|
|
334
|
+
server.unref()
|
|
228
335
|
// Past bind, a socket error fails the pending wait rather than throwing.
|
|
229
336
|
server.on('error', (err) => rejectUrl(err))
|
|
230
337
|
|
|
@@ -248,6 +355,7 @@ async function listenForCallback(): Promise<{
|
|
|
248
355
|
return {
|
|
249
356
|
redirectUri,
|
|
250
357
|
url,
|
|
358
|
+
expiresAt,
|
|
251
359
|
close: () => {
|
|
252
360
|
clearTimeout(timer)
|
|
253
361
|
server.close()
|
|
@@ -255,18 +363,55 @@ async function listenForCallback(): Promise<{
|
|
|
255
363
|
}
|
|
256
364
|
}
|
|
257
365
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}
|
|
268
|
-
|
|
366
|
+
/**
|
|
367
|
+
* Try to open `url` in the system browser. Never writes to stdout (it carries
|
|
368
|
+
* MCP JSON-RPC); the URL goes to stderr for a developer watching the logs.
|
|
369
|
+
* On Linux without a display server it doesn't try. Otherwise a launcher
|
|
370
|
+
* that errors or exits non-zero within a short grace window counts as not
|
|
371
|
+
* opened.
|
|
372
|
+
*/
|
|
373
|
+
export async function openBrowser(
|
|
374
|
+
url: string,
|
|
375
|
+
{ env = process.env, platform = process.platform } = {},
|
|
376
|
+
): Promise<BrowserLaunch> {
|
|
377
|
+
process.stderr.write(`\nOpen this URL to sign in:\n${url}\n\n`)
|
|
378
|
+
const hasDisplay = platform === 'darwin' || platform === 'win32'
|
|
379
|
+
|| !!env.WAYLAND_DISPLAY || !!env.DISPLAY
|
|
380
|
+
if(!hasDisplay) {
|
|
381
|
+
return {
|
|
382
|
+
opened: false,
|
|
383
|
+
reason: 'no display (WAYLAND_DISPLAY/DISPLAY not set)',
|
|
384
|
+
}
|
|
269
385
|
}
|
|
270
386
|
|
|
271
|
-
|
|
387
|
+
const cmd = platform === 'darwin'
|
|
388
|
+
? 'open'
|
|
389
|
+
: platform === 'win32' ? 'cmd' : 'xdg-open'
|
|
390
|
+
const args = platform === 'win32' ? ['/c', 'start', '', url] : [url]
|
|
391
|
+
return new Promise((resolve) => {
|
|
392
|
+
const timer = setTimeout(() => resolve({ opened: true }), BROWSER_GRACE_MS)
|
|
393
|
+
try {
|
|
394
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true, env })
|
|
395
|
+
child.once('error', (err) => done({
|
|
396
|
+
opened: false,
|
|
397
|
+
reason: `${cmd} failed: ${err.message}`,
|
|
398
|
+
}))
|
|
399
|
+
child.once('exit', (code, signal) => done(
|
|
400
|
+
code === 0
|
|
401
|
+
? { opened: true }
|
|
402
|
+
: {
|
|
403
|
+
opened: false,
|
|
404
|
+
reason: `${cmd} exited with ${code ?? signal}`,
|
|
405
|
+
},
|
|
406
|
+
))
|
|
407
|
+
child.unref()
|
|
408
|
+
} catch(err) {
|
|
409
|
+
done({ opened: false, reason: `${cmd} failed: ${failureReason(err)}` })
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function done(launch: BrowserLaunch) {
|
|
413
|
+
clearTimeout(timer)
|
|
414
|
+
resolve(launch)
|
|
415
|
+
}
|
|
416
|
+
})
|
|
272
417
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { ReclaimClient } from '@reclaimprotocol/client/api'
|
|
2
2
|
import type { RegisteredTool } from '../../server.ts'
|
|
3
|
-
import { authenticateTool } from './authenticate.ts'
|
|
3
|
+
import { authenticateTool, waitForSignInTool } from './authenticate.ts'
|
|
4
4
|
import { importTool } from './import.ts'
|
|
5
5
|
import { issueTool } from './issue.ts'
|
|
6
|
+
import { issueOrgKeypairTool } from './issue-org-keypair.ts'
|
|
6
7
|
import { issueOrgTokenTool } from './issue-org-token.ts'
|
|
7
8
|
import { resolveOwnerKeyTool } from './resolve-owner-key.ts'
|
|
8
9
|
|
|
@@ -22,13 +23,15 @@ export function buildLocalCredentialTools(): RegisteredTool[] {
|
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Full builder-mode credential set: the local key tools plus the backend
|
|
25
|
-
* `reclaim_authenticate`
|
|
26
|
-
* authenticate tool — it has its own.
|
|
26
|
+
* `reclaim_authenticate` sign-in flow and its `wait_for_sign_in` companion.
|
|
27
|
+
* Old mode does NOT use the authenticate tool — it has its own.
|
|
27
28
|
*/
|
|
28
29
|
export function buildCredentialTools(client: ReclaimClient): RegisteredTool[] {
|
|
29
30
|
return [
|
|
30
31
|
authenticateTool(client),
|
|
32
|
+
waitForSignInTool(client),
|
|
31
33
|
issueOrgTokenTool(client),
|
|
34
|
+
issueOrgKeypairTool(client),
|
|
32
35
|
...buildLocalCredentialTools(),
|
|
33
36
|
]
|
|
34
37
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ReclaimClient } from '@reclaimprotocol/client/api'
|
|
2
2
|
import { defineTool, type RegisteredTool } from '../../server.ts'
|
|
3
|
-
import { authenticateTool } from './authenticate.ts'
|
|
3
|
+
import { authenticateTool, waitForSignInTool } from './authenticate.ts'
|
|
4
4
|
|
|
5
5
|
/** Legacy-devtools mode needs a distinct Builder login for its two
|
|
6
6
|
* Builder-exclusive features: allocating quota-accounted Popcorn sessions
|
|
@@ -10,7 +10,10 @@ import { authenticateTool } from './authenticate.ts'
|
|
|
10
10
|
export function buildBuilderAuthenticationTools(
|
|
11
11
|
client: ReclaimClient,
|
|
12
12
|
): RegisteredTool[] {
|
|
13
|
-
return [
|
|
13
|
+
return [
|
|
14
|
+
authenticateTool(client, 'authenticate_builder'),
|
|
15
|
+
waitForSignInTool(client, 'authenticate_builder'),
|
|
16
|
+
]
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
/** Legacy-devtools mode lacks generated Builder API tools, so it needs this
|
|
@@ -7,14 +7,15 @@
|
|
|
7
7
|
* 2. A `.env` file in the project root — its `RECLAIM_PRIVATE_KEY=0x…` line.
|
|
8
8
|
* 3. A key file holding the raw 0x-hex scalar, at `RECLAIM_PRIVATE_KEY_FILE`
|
|
9
9
|
* (absolute or project-relative).
|
|
10
|
-
* 4. Absent —
|
|
10
|
+
* 4. Absent — `resolveOrCreateEthKey` generates a local test key; nobody is
|
|
11
|
+
* asked.
|
|
11
12
|
*
|
|
12
13
|
* Generated/imported keys are written to the project `.env` (mode 0600, and the
|
|
13
14
|
* file is added to `.gitignore`). NO `~/.reclaim` caching. This is a LOCAL
|
|
14
15
|
* signing key only — unrelated to org auth (the org secret) or encryption
|
|
15
16
|
* (the eth decrypt key).
|
|
16
17
|
*/
|
|
17
|
-
import { deriveFromPrivateKey } from '@reclaimprotocol/client'
|
|
18
|
+
import { deriveFromPrivateKey, generateKeypair } from '@reclaimprotocol/client'
|
|
18
19
|
import assert from 'node:assert'
|
|
19
20
|
import {
|
|
20
21
|
chmodSync,
|
|
@@ -37,8 +38,18 @@ const ENV_VAR = 'RECLAIM_PRIVATE_KEY'
|
|
|
37
38
|
/** The dotenv file the agent reads from / writes the key to. */
|
|
38
39
|
export const ENV_FILE = '.env'
|
|
39
40
|
|
|
40
|
-
/** Where the resolved eth key came from.
|
|
41
|
-
|
|
41
|
+
/** Where the resolved eth key came from. `generated`: created just now
|
|
42
|
+
* because none existed. */
|
|
43
|
+
export type EthKeySource = 'env' | 'dotenv' | 'file' | 'generated'
|
|
44
|
+
|
|
45
|
+
/** Tells the agent to inform the developer once, after the fact. */
|
|
46
|
+
export const GENERATED_KEY_NOTE =
|
|
47
|
+
'No proof signing key existed, so a local test signing key was created and '
|
|
48
|
+
+ 'saved to the project .env as RECLAIM_PRIVATE_KEY (mode 0600, '
|
|
49
|
+
+ 'gitignored). Tell the developer once, in one sentence, that you created '
|
|
50
|
+
+ 'it; do not ask for approval. It is a local signing key only: not '
|
|
51
|
+
+ 'registered anywhere and unrelated to org auth. A developer who wants '
|
|
52
|
+
+ 'their own key can use import_credentials.'
|
|
42
53
|
|
|
43
54
|
export type ResolvedEthKey = {
|
|
44
55
|
/** Raw 0x-hex 32-byte scalar. */
|
|
@@ -63,14 +74,19 @@ export function normalizeEthHex(value: string): string {
|
|
|
63
74
|
return '0x' + v.replace(/^0x/, '').toLowerCase()
|
|
64
75
|
}
|
|
65
76
|
|
|
66
|
-
/** Read `RECLAIM_PRIVATE_KEY` from a dotenv file (simple
|
|
67
|
-
|
|
77
|
+
/** Read `name` (default `RECLAIM_PRIVATE_KEY`) from a dotenv file (simple
|
|
78
|
+
* KEY=VALUE parse). */
|
|
79
|
+
export function readDotenvKey(
|
|
80
|
+
path: string,
|
|
81
|
+
name = ENV_VAR,
|
|
82
|
+
): string | undefined {
|
|
68
83
|
if(!existsSync(path)) {
|
|
69
84
|
return undefined
|
|
70
85
|
}
|
|
71
86
|
|
|
87
|
+
const re = new RegExp(`^\\s*${name}\\s*=\\s*(.+?)\\s*$`)
|
|
72
88
|
for(const line of readFileSync(path, 'utf8').split('\n')) {
|
|
73
|
-
const m = line.match(
|
|
89
|
+
const m = line.match(re)
|
|
74
90
|
if(m) {
|
|
75
91
|
return m[1]
|
|
76
92
|
}
|
|
@@ -135,6 +151,24 @@ export function resolveEthKey(
|
|
|
135
151
|
return undefined
|
|
136
152
|
}
|
|
137
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Resolve the eth proof-owner key, or generate a local test key and write it
|
|
156
|
+
* to the project `.env` when none is configured. Never prompts.
|
|
157
|
+
*/
|
|
158
|
+
export function resolveOrCreateEthKey(
|
|
159
|
+
projectDir = findProjectRoot(process.cwd()),
|
|
160
|
+
): ResolvedEthKey {
|
|
161
|
+
const existing = resolveEthKey(projectDir)
|
|
162
|
+
if(existing) {
|
|
163
|
+
return existing
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const { path } = writeEthKey(generateKeypair().privateKey, projectDir)
|
|
167
|
+
const created = resolveEthKey(projectDir)
|
|
168
|
+
assert(created, `generated key not readable from ${path}`)
|
|
169
|
+
return { ...created, source: 'generated' }
|
|
170
|
+
}
|
|
171
|
+
|
|
138
172
|
/** Append `entry` to the project `.gitignore` if not already present. */
|
|
139
173
|
export function ensureGitignored(projectDir: string, entry: string) {
|
|
140
174
|
const path = join(projectDir, '.gitignore')
|
|
@@ -5,4 +5,4 @@ export {
|
|
|
5
5
|
buildBuilderOrganizationTools,
|
|
6
6
|
} from './builder-access.ts'
|
|
7
7
|
export { buildCredentialTools, buildLocalCredentialTools } from './build.ts'
|
|
8
|
-
export { readSession } from './session-cache.ts'
|
|
8
|
+
export { readSession, sessionRefreshingFetch } from './session-cache.ts'
|