@reclaimprotocol/agent 0.1.36 → 0.1.38
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 +8 -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/example-app.d.ts +2 -0
- package/lib/mcp/tools/agent/example-app.js +167 -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 +50 -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 +9 -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/example-app.ts +227 -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
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { deriveFromPrivateKey, generateKeypair } from '@reclaimprotocol/client'
|
|
2
|
+
import type { ReclaimClient } from '@reclaimprotocol/client/api'
|
|
3
|
+
import assert from 'node:assert'
|
|
4
|
+
import { isAbsolute, relative, resolve } from 'node:path'
|
|
5
|
+
import { defineTool, type RegisteredTool } from '../../server.ts'
|
|
6
|
+
import { normalizeEthHex, readDotenvKey, upsertEnvVar } from './eth-key.ts'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates the organization's eth key pair for session binding and encrypted
|
|
10
|
+
* results: writes the private key to the app's server env file and registers
|
|
11
|
+
* only the public key with Builder, so the private key never passes through
|
|
12
|
+
* the agent's transcript. Reuses a key the env file already holds, so a
|
|
13
|
+
* repeat call (for example, to turn on encryption) keeps the same key.
|
|
14
|
+
*/
|
|
15
|
+
export function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool {
|
|
16
|
+
return defineTool<{
|
|
17
|
+
orgId: string
|
|
18
|
+
projectDir: string
|
|
19
|
+
envFile?: string
|
|
20
|
+
envVar?: string
|
|
21
|
+
encryptResults?: boolean
|
|
22
|
+
}>(
|
|
23
|
+
{
|
|
24
|
+
name: 'issue_org_keypair',
|
|
25
|
+
description:
|
|
26
|
+
'Sets up the organization\'s eth key pair, used for TEE session '
|
|
27
|
+
+ 'binding (`sessions.create({ orgEthPrivateKey })`) and, with '
|
|
28
|
+
+ '`encryptResults`, encrypted result delivery '
|
|
29
|
+
+ '(`results.receive(body, { orgEthPrivateKey })`). Writes the '
|
|
30
|
+
+ 'private key to a server env file in the project (reusing a key '
|
|
31
|
+
+ 'already there) and registers only the public key. Returns the '
|
|
32
|
+
+ 'address, file, and variable; never the key. Returns 409 when the '
|
|
33
|
+
+ 'org already has a different key: ask the developer for that key '
|
|
34
|
+
+ 'rather than replacing it.',
|
|
35
|
+
inputSchema: {
|
|
36
|
+
type: 'object',
|
|
37
|
+
required: ['orgId', 'projectDir'],
|
|
38
|
+
properties: {
|
|
39
|
+
orgId: { type: 'string', format: 'uuid' },
|
|
40
|
+
projectDir: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
description: 'Absolute path of the app\'s project directory.',
|
|
43
|
+
},
|
|
44
|
+
envFile: {
|
|
45
|
+
type: 'string',
|
|
46
|
+
description:
|
|
47
|
+
'Env file to write, relative to `projectDir`. Default `.env`.',
|
|
48
|
+
},
|
|
49
|
+
envVar: {
|
|
50
|
+
type: 'string',
|
|
51
|
+
pattern: '^[A-Za-z_][A-Za-z0-9_]*$',
|
|
52
|
+
description:
|
|
53
|
+
'Variable name to set. Default `RECLAIM_ORG_ETH_PRIVATE_KEY`.',
|
|
54
|
+
},
|
|
55
|
+
encryptResults: {
|
|
56
|
+
type: 'boolean',
|
|
57
|
+
description:
|
|
58
|
+
'Encrypt delivered results to this key. Default false.',
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
annotations: { openWorldHint: true, destructiveHint: false },
|
|
63
|
+
},
|
|
64
|
+
async(args) => {
|
|
65
|
+
assert(
|
|
66
|
+
isAbsolute(args.projectDir),
|
|
67
|
+
new Error('`projectDir` must be an absolute path.'),
|
|
68
|
+
)
|
|
69
|
+
const envFile = args.envFile || '.env'
|
|
70
|
+
const envVar = args.envVar || 'RECLAIM_ORG_ETH_PRIVATE_KEY'
|
|
71
|
+
const envPath = resolve(args.projectDir, envFile)
|
|
72
|
+
const inside = relative(args.projectDir, envPath)
|
|
73
|
+
assert(
|
|
74
|
+
inside && !inside.startsWith('..') && !isAbsolute(inside),
|
|
75
|
+
new Error('`envFile` must be inside `projectDir`.'),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
const existing = readDotenvKey(envPath, envVar)
|
|
79
|
+
const keypair = existing
|
|
80
|
+
? deriveFromPrivateKey(normalizeEthHex(existing))
|
|
81
|
+
: generateKeypair()
|
|
82
|
+
// Register first: a 409 (the org has another key) must not leave a
|
|
83
|
+
// key Builder doesn't know in the env file.
|
|
84
|
+
const canEncryptResult = args.encryptResults ?? false
|
|
85
|
+
await client.call('SetOrgKeypair', {
|
|
86
|
+
params: { orgId: args.orgId },
|
|
87
|
+
body: {
|
|
88
|
+
publicKey: keypair.publicKey,
|
|
89
|
+
canEncryptResult,
|
|
90
|
+
replace: false,
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
const path = upsertEnvVar(
|
|
94
|
+
args.projectDir, envFile, envVar, keypair.privateKey,
|
|
95
|
+
)
|
|
96
|
+
return {
|
|
97
|
+
orgId: args.orgId,
|
|
98
|
+
address: keypair.address,
|
|
99
|
+
canEncryptResult,
|
|
100
|
+
envFile: path,
|
|
101
|
+
envVar,
|
|
102
|
+
reused: Boolean(existing),
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import * as oauth from 'openid-client'
|
|
2
|
+
import type { CachedSession } from './session-cache.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `offline_access` makes Better Auth's `oauth-provider` issue a refresh token
|
|
6
|
+
* (it issues one only when the grant carries that scope), so the session can
|
|
7
|
+
* outlive the 1-hour access token.
|
|
8
|
+
*/
|
|
9
|
+
export const SCOPE = 'openid profile email offline_access'
|
|
10
|
+
|
|
11
|
+
/** Where Better Auth mounts its OAuth endpoints under the Builder origin. */
|
|
12
|
+
export function authBaseFor(baseUrl: string) {
|
|
13
|
+
return `${baseUrl}/auth`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Request options every openid-client call against Builder's auth server
|
|
18
|
+
* needs. Pass them to `dynamicClientRegistration` or `discovery`; the
|
|
19
|
+
* returned `Configuration` keeps them for its later calls.
|
|
20
|
+
*/
|
|
21
|
+
export function oauthRequestOptions(authBase: string) {
|
|
22
|
+
// openid-client/oauth4webapi refuse any non-HTTPS issuer by default
|
|
23
|
+
// ("only requests to HTTPS are allowed"). Better Auth's own dev default
|
|
24
|
+
// (`canonicalOrigin()`, packages/app/src/consts.ts) is plain
|
|
25
|
+
// `http://localhost:4001` — the server side has no HTTPS requirement at
|
|
26
|
+
// all — so a local `RECLAIM_API_URL=http://localhost:...` would otherwise
|
|
27
|
+
// always fail here. Loosen it ONLY for loopback hosts, matching what the
|
|
28
|
+
// server already trusts (`authTrustedOrigins()` defaults to
|
|
29
|
+
// localhost:4000/4001 outside production); a non-loopback http:// origin
|
|
30
|
+
// still gets the strict check.
|
|
31
|
+
const isLoopbackAuthBase =
|
|
32
|
+
['localhost', '127.0.0.1', '[::1]'].includes(new URL(authBase).hostname)
|
|
33
|
+
return {
|
|
34
|
+
// Better Auth exposes its metadata at the RFC 8414 well-known path
|
|
35
|
+
// (`.well-known/oauth-authorization-server`), not the OIDC one.
|
|
36
|
+
algorithm: 'oauth2' as const,
|
|
37
|
+
// RFC 7591 §3.2.1 requires 201 Created on a successful registration;
|
|
38
|
+
// openid-client enforces that strictly. Better Auth's `oauth-provider`
|
|
39
|
+
// plugin returns 200 with an otherwise well-formed body. `customFetch`
|
|
40
|
+
// persists onto the returned `Configuration` for ALL its future calls
|
|
41
|
+
// (token exchange included, which correctly returns 200), so the URL,
|
|
42
|
+
// not just method+status, gates the rewrite.
|
|
43
|
+
[oauth.customFetch]: customFetchForOauth,
|
|
44
|
+
...(isLoopbackAuthBase ? { execute: [oauth.allowInsecureRequests] } : {}),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The session to cache for a token response. A refresh response may omit
|
|
50
|
+
* `refresh_token` (no rotation), so the caller passes the one it already has.
|
|
51
|
+
*/
|
|
52
|
+
export function sessionFromTokens(
|
|
53
|
+
tokens: oauth.TokenEndpointResponse,
|
|
54
|
+
client: { clientId: string, authBase: string, refreshToken?: string },
|
|
55
|
+
): CachedSession {
|
|
56
|
+
return {
|
|
57
|
+
token: tokens.access_token,
|
|
58
|
+
expiresAt: new Date(
|
|
59
|
+
Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
|
60
|
+
).toISOString(),
|
|
61
|
+
refreshToken: tokens.refresh_token ?? client.refreshToken,
|
|
62
|
+
clientId: client.clientId,
|
|
63
|
+
authBase: client.authBase,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function customFetchForOauth(
|
|
68
|
+
url: string,
|
|
69
|
+
options: oauth.CustomFetchOptions,
|
|
70
|
+
) {
|
|
71
|
+
// openid-client hands over a fetch-compatible options bag whose `body`
|
|
72
|
+
// type is narrower than, but assignable in practice to, `RequestInit`'s.
|
|
73
|
+
const res = await fetch(url, options as RequestInit)
|
|
74
|
+
if(
|
|
75
|
+
options.method === 'POST' && res.status === 200
|
|
76
|
+
&& new URL(url).pathname.endsWith('/oauth2/register')
|
|
77
|
+
) {
|
|
78
|
+
return new Response(await res.text(), {
|
|
79
|
+
status: 201,
|
|
80
|
+
headers: res.headers,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return res
|
|
85
|
+
}
|
|
@@ -1,57 +1,60 @@
|
|
|
1
|
-
import { join } from 'node:path'
|
|
2
1
|
import { findProjectRoot } from '../../../paths.ts'
|
|
3
2
|
import { defineTool, type RegisteredTool } from '../../server.ts'
|
|
4
|
-
import {
|
|
3
|
+
import { GENERATED_KEY_NOTE, resolveOrCreateEthKey } from './eth-key.ts'
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
|
-
* Decide which eth proof-owner key `run_proof`
|
|
8
|
-
*
|
|
9
|
-
* `~/.reclaim` cache) from:
|
|
6
|
+
* Decide which eth proof-owner key `run_proof` signs with. The key is a raw
|
|
7
|
+
* 0x-hex Ethereum private key, resolved (no `~/.reclaim` cache) from:
|
|
10
8
|
* 1. `RECLAIM_PRIVATE_KEY` in the MCP env
|
|
11
9
|
* 2. a `RECLAIM_PRIVATE_KEY=0x…` line in the project `.env`
|
|
12
10
|
* 3. a file at `RECLAIM_PRIVATE_KEY_FILE` holding the raw hex
|
|
13
|
-
* 4. none →
|
|
14
|
-
*
|
|
11
|
+
* 4. none → a local test key is generated into the project `.env`. Nobody
|
|
12
|
+
* is asked; the agent tells the developer afterwards.
|
|
15
13
|
* Never returns the private key — only the derived address.
|
|
16
14
|
*/
|
|
17
15
|
export function resolveOwnerKeyTool(): RegisteredTool {
|
|
18
|
-
return defineTool<
|
|
16
|
+
return defineTool<{ projectDir?: string }>(
|
|
19
17
|
{
|
|
20
18
|
name: 'resolve_owner_key',
|
|
21
19
|
description:
|
|
22
|
-
'Finds the eth proof-owner key `run_proof` signs with
|
|
23
|
-
+ '
|
|
24
|
-
+ '
|
|
25
|
-
+ '
|
|
26
|
-
+ '
|
|
27
|
-
+ '
|
|
28
|
-
+ '
|
|
29
|
-
+ '
|
|
30
|
-
|
|
31
|
-
|
|
20
|
+
'Finds the eth proof-owner key `run_proof` signs with, creating a '
|
|
21
|
+
+ 'local test key when none exists. `run_proof` does this itself, '
|
|
22
|
+
+ 'so call it only to show the signing address. Checks '
|
|
23
|
+
+ 'RECLAIM_PRIVATE_KEY in the MCP env, a RECLAIM_PRIVATE_KEY line in '
|
|
24
|
+
+ 'the project .env, then the file at RECLAIM_PRIVATE_KEY_FILE. When '
|
|
25
|
+
+ 'none is set, it writes a new key to the project .env (mode 0600, '
|
|
26
|
+
+ 'added to .gitignore). Returns { ready: true, address, source, '
|
|
27
|
+
+ 'path?, note? }; `source` is `generated` when it created the key, '
|
|
28
|
+
+ 'and `note` then says to tell the developer once, after the fact. '
|
|
29
|
+
+ 'Do not ask the developer for a key. Never returns the key.',
|
|
30
|
+
inputSchema: {
|
|
31
|
+
type: 'object',
|
|
32
|
+
properties: {
|
|
33
|
+
projectDir: {
|
|
34
|
+
type: 'string',
|
|
35
|
+
description:
|
|
36
|
+
'Project directory whose `.env` holds the key. Defaults to '
|
|
37
|
+
+ 'the closest ancestor of cwd containing .git or '
|
|
38
|
+
+ 'package.json.',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
annotations: {
|
|
43
|
+
readOnlyHint: false,
|
|
44
|
+
destructiveHint: false,
|
|
45
|
+
idempotentHint: true,
|
|
46
|
+
openWorldHint: false,
|
|
47
|
+
},
|
|
32
48
|
},
|
|
33
|
-
async() => {
|
|
34
|
-
const projectDir = findProjectRoot(process.cwd())
|
|
35
|
-
const
|
|
36
|
-
if(resolved) {
|
|
37
|
-
return {
|
|
38
|
-
ready: true,
|
|
39
|
-
address: resolved.address,
|
|
40
|
-
source: resolved.source,
|
|
41
|
-
...(resolved.path ? { path: resolved.path } : {}),
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
49
|
+
async(args) => {
|
|
50
|
+
const projectDir = findProjectRoot(args.projectDir || process.cwd())
|
|
51
|
+
const key = resolveOrCreateEthKey(projectDir)
|
|
45
52
|
return {
|
|
46
|
-
ready:
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
'issue_credentials, or import one they already hold via ' +
|
|
52
|
-
'import_credentials (a key file). Then call ' +
|
|
53
|
-
'resolve_owner_key again and pass its address to run_proof as ' +
|
|
54
|
-
'ownerAddress.',
|
|
53
|
+
ready: true,
|
|
54
|
+
address: key.address,
|
|
55
|
+
source: key.source,
|
|
56
|
+
...(key.path ? { path: key.path } : {}),
|
|
57
|
+
...(key.source === 'generated' ? { note: GENERATED_KEY_NOTE } : {}),
|
|
55
58
|
}
|
|
56
59
|
},
|
|
57
60
|
)
|
|
@@ -1,13 +1,26 @@
|
|
|
1
|
+
import { PROBLEM_CONTENT_TYPE } from '@reclaimprotocol/client/api'
|
|
2
|
+
import * as oauth from 'openid-client'
|
|
1
3
|
import { ConfigStore } from '../../../config/store.ts'
|
|
4
|
+
import { LOGGER } from '../../../logger.ts'
|
|
5
|
+
import { oauthRequestOptions, sessionFromTokens } from './oauth-config.ts'
|
|
2
6
|
|
|
3
7
|
export type CachedSession = {
|
|
4
8
|
token: string
|
|
5
|
-
/** ISO timestamp when the token expires
|
|
9
|
+
/** ISO timestamp when the access token expires. */
|
|
6
10
|
expiresAt: string
|
|
7
11
|
/** Email of the user who authorized (if available). */
|
|
8
12
|
email?: string
|
|
13
|
+
/** OAuth refresh token; trades for a new access token near expiry. */
|
|
14
|
+
refreshToken?: string
|
|
15
|
+
/** Public OAuth client the tokens were issued to (refresh needs it). */
|
|
16
|
+
clientId?: string
|
|
17
|
+
/** Auth server base URL that issued the tokens. */
|
|
18
|
+
authBase?: string
|
|
9
19
|
}
|
|
10
20
|
|
|
21
|
+
/** Refresh this long before the access token actually expires. */
|
|
22
|
+
const EXPIRY_MARGIN_MS = 60 * 1000
|
|
23
|
+
|
|
11
24
|
/** Persist the auth token to the `session` section of the unified config. */
|
|
12
25
|
export function writeSession(session: CachedSession) {
|
|
13
26
|
new ConfigStore().write({ session })
|
|
@@ -18,14 +31,121 @@ export function writeSession(session: CachedSession) {
|
|
|
18
31
|
* valid (not yet expired). Returns undefined on any error.
|
|
19
32
|
*/
|
|
20
33
|
export function readSession(): CachedSession | undefined {
|
|
34
|
+
const s = readStoredSession()
|
|
35
|
+
if(!s || new Date(s.expiresAt) <= new Date()) {
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return s
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The cached session even when its access token has expired. */
|
|
43
|
+
export function readStoredSession(): CachedSession | undefined {
|
|
21
44
|
const s = new ConfigStore().read()?.session
|
|
22
|
-
|
|
45
|
+
return s?.token && s.expiresAt ? s : undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Wraps `fetch` for the Builder client so a cached sign-in keeps working past
|
|
50
|
+
* the access token's lifetime. Each request uses the cached session's token,
|
|
51
|
+
* refreshed via the OAuth refresh grant when it's about to expire or when
|
|
52
|
+
* Builder answers 401. If no refresh is possible, the 401 tells the agent to
|
|
53
|
+
* sign in again with `signInTool`. Without a cached session, requests pass
|
|
54
|
+
* through untouched. Don't install it when `RECLAIM_API_TOKEN` is set: that
|
|
55
|
+
* token takes precedence and is never refreshed.
|
|
56
|
+
*/
|
|
57
|
+
export function sessionRefreshingFetch(
|
|
58
|
+
signInTool: string,
|
|
59
|
+
baseFetch: typeof fetch = fetch,
|
|
60
|
+
): typeof fetch {
|
|
61
|
+
// One refresh at a time: Better Auth rotates refresh tokens and revokes
|
|
62
|
+
// the whole family when a used one is replayed.
|
|
63
|
+
let refreshing: Promise<CachedSession | undefined> | undefined
|
|
64
|
+
return async(input, init) => {
|
|
65
|
+
let session = readStoredSession()
|
|
66
|
+
if(!session) {
|
|
67
|
+
return baseFetch(input, init)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const expiring =
|
|
71
|
+
new Date(session.expiresAt).getTime() - Date.now() < EXPIRY_MARGIN_MS
|
|
72
|
+
const triedRefresh = expiring && !!session.refreshToken
|
|
73
|
+
if(triedRefresh) {
|
|
74
|
+
session = await refreshOnce(session) ?? session
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const res = await baseFetch(input, withBearer(init, session.token))
|
|
78
|
+
if(res.status !== 401) {
|
|
79
|
+
return res
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const next = triedRefresh ? undefined : await refreshOnce(session)
|
|
83
|
+
if(next) {
|
|
84
|
+
return baseFetch(input, withBearer(init, next.token))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return sessionExpiredResponse(signInTool)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function refreshOnce(session: CachedSession) {
|
|
91
|
+
refreshing ??= refreshSession(session)
|
|
92
|
+
.finally(() => {
|
|
93
|
+
refreshing = undefined
|
|
94
|
+
})
|
|
95
|
+
return refreshing
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Trade the session's refresh token for a new access token and cache the
|
|
101
|
+
* result. Returns undefined when the session can't be refreshed; a refresh
|
|
102
|
+
* token the server rejects is dropped so later calls stop retrying it.
|
|
103
|
+
*/
|
|
104
|
+
export async function refreshSession(
|
|
105
|
+
session: CachedSession,
|
|
106
|
+
): Promise<CachedSession | undefined> {
|
|
107
|
+
const { refreshToken, clientId, authBase } = session
|
|
108
|
+
if(!refreshToken || !clientId || !authBase) {
|
|
23
109
|
return undefined
|
|
24
110
|
}
|
|
25
111
|
|
|
26
|
-
|
|
27
|
-
|
|
112
|
+
try {
|
|
113
|
+
const config = await oauth.discovery(
|
|
114
|
+
new URL(authBase),
|
|
115
|
+
clientId,
|
|
116
|
+
undefined,
|
|
117
|
+
oauth.None(),
|
|
118
|
+
oauthRequestOptions(authBase),
|
|
119
|
+
)
|
|
120
|
+
const tokens = await oauth.refreshTokenGrant(config, refreshToken)
|
|
121
|
+
const next: CachedSession = {
|
|
122
|
+
...sessionFromTokens(tokens, { clientId, authBase, refreshToken }),
|
|
123
|
+
email: session.email,
|
|
124
|
+
}
|
|
125
|
+
writeSession(next)
|
|
126
|
+
return next
|
|
127
|
+
} catch(err) {
|
|
128
|
+
LOGGER.warn({ err }, 'builder session refresh failed')
|
|
129
|
+
if(err instanceof oauth.ResponseBodyError) {
|
|
130
|
+
writeSession({ ...session, refreshToken: undefined })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return undefined
|
|
28
134
|
}
|
|
135
|
+
}
|
|
29
136
|
|
|
30
|
-
|
|
137
|
+
function withBearer(init: RequestInit | undefined, token: string) {
|
|
138
|
+
const headers = new Headers(init?.headers)
|
|
139
|
+
headers.set('Authorization', `Bearer ${token}`)
|
|
140
|
+
return { ...init, headers }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function sessionExpiredResponse(signInTool: string) {
|
|
144
|
+
return new Response(JSON.stringify({
|
|
145
|
+
type: 'about:blank',
|
|
146
|
+
title: 'Unauthorized',
|
|
147
|
+
status: 401,
|
|
148
|
+
detail: 'Your Builder sign-in expired and could not be refreshed. '
|
|
149
|
+
+ `Call ${signInTool} to sign in again, then retry.`,
|
|
150
|
+
}), { status: 401, headers: { 'content-type': PROBLEM_CONTENT_TYPE } })
|
|
31
151
|
}
|
|
@@ -3,18 +3,18 @@ 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
|
|
7
|
-
* expired. Mirrors `readSession`'s check, but against
|
|
8
|
-
* `ConfigStore` instance (not always the default
|
|
9
|
-
* path) so this stays test-isolatable and
|
|
10
|
-
* tool's config reads. */
|
|
6
|
+
/** Whether `config`'s cached builder session token is present and either
|
|
7
|
+
* not yet expired or refreshable. Mirrors `readSession`'s check, but against
|
|
8
|
+
* the CALLER's `ConfigStore` instance (not always the default
|
|
9
|
+
* `~/.reclaim/config.json` path) so this stays test-isolatable and
|
|
10
|
+
* consistent with the rest of this tool's config reads. */
|
|
11
11
|
function hasValidBuilderSession(config: ConfigStore): boolean {
|
|
12
12
|
const session = config.read()?.session
|
|
13
13
|
if(!session?.token || !session.expiresAt) {
|
|
14
14
|
return false
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
return new Date(session.expiresAt) > new Date()
|
|
17
|
+
return !!session.refreshToken || new Date(session.expiresAt) > new Date()
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
package/src/old/login-cdp.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { connectCdp } from '../cdp/client.ts'
|
|
2
2
|
import {
|
|
3
3
|
defaultProfileDir,
|
|
4
|
-
|
|
4
|
+
launchLocalBrowser,
|
|
5
5
|
waitForCdpReady,
|
|
6
6
|
} from '../cdp/launcher.ts'
|
|
7
7
|
import { ConfigStore } from '../config/store.ts'
|
|
@@ -160,8 +160,7 @@ export async function captureFirebaseTokenViaCdp(opts: {
|
|
|
160
160
|
)
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
|
|
164
|
-
await waitForCdpReady(port, 10_000)
|
|
163
|
+
await launchLocalBrowser({ port, profileDir: defaultProfileDir() })
|
|
165
164
|
launchedByUs = true
|
|
166
165
|
}
|
|
167
166
|
|
package/src/proof/attestor.ts
CHANGED
|
@@ -218,9 +218,8 @@ export async function runAttestorProof(
|
|
|
218
218
|
verified: false,
|
|
219
219
|
error:
|
|
220
220
|
'No eth proof-owner key available (no RECLAIM_PRIVATE_KEY in env, ' +
|
|
221
|
-
'.env, or RECLAIM_PRIVATE_KEY_FILE). Call resolve_owner_key,
|
|
222
|
-
'
|
|
223
|
-
'and retry run_proof.',
|
|
221
|
+
'.env, or RECLAIM_PRIVATE_KEY_FILE). Call resolve_owner_key, ' +
|
|
222
|
+
'which creates a local test key, and retry run_proof.',
|
|
224
223
|
errorKind: 'missing-owner-key',
|
|
225
224
|
}
|
|
226
225
|
}
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
parseClaim,
|
|
13
13
|
resolveAttestorUrl,
|
|
14
14
|
} from './proof-core.ts'
|
|
15
|
-
import { ensureZkFiles } from './zk-files.ts'
|
|
15
|
+
import { ensureZkFiles, type ZkFilesState, zkFilesState } from './zk-files.ts'
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Default time budget for one check. A live run takes about 8 s; a longer
|
|
@@ -63,11 +63,16 @@ const COIN_GECKO_SECRET_HEADERS = {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export type AttestorCheckErrorKind =
|
|
66
|
-
/** Socket, connection, DNS, TLS, or timeout failure
|
|
66
|
+
/** Socket, connection, DNS, TLS, or timeout failure, with the proof files
|
|
67
|
+
* ready: the attestor itself didn't answer. */
|
|
67
68
|
| 'attestor-unreachable'
|
|
68
69
|
/** The attestor answered and rejected or failed the claim. */
|
|
69
70
|
| 'attestor-error'
|
|
70
|
-
/**
|
|
71
|
+
/** The proof files are still downloading; the attestor wasn't contacted. */
|
|
72
|
+
| 'proof-files-downloading'
|
|
73
|
+
/** The proof file download failed; the attestor wasn't contacted. */
|
|
74
|
+
| 'proof-files-failed'
|
|
75
|
+
/** Other local setup failed before the attestor was contacted. */
|
|
71
76
|
| 'setup-failed'
|
|
72
77
|
|
|
73
78
|
export interface AttestorCheckResult {
|
|
@@ -77,6 +82,8 @@ export interface AttestorCheckResult {
|
|
|
77
82
|
extractedParameters?: Record<string, string>
|
|
78
83
|
error?: string
|
|
79
84
|
errorKind?: AttestorCheckErrorKind
|
|
85
|
+
/** The proof file download state when the check finished. */
|
|
86
|
+
proofFiles?: ZkFilesState
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
/** Creates the claim on the attestor (`createClaimOnAttestor` by default). */
|
|
@@ -91,6 +98,8 @@ export interface CheckAttestorOptions {
|
|
|
91
98
|
createClaim?: CreateClaim
|
|
92
99
|
/** Replaces the ZK file download in tests. */
|
|
93
100
|
prepare?: () => Promise<void>
|
|
101
|
+
/** Reports the ZK file download state (`zkFilesState` by default). */
|
|
102
|
+
proofFilesState?: () => ZkFilesState
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
/** Prefix of the error raised when the check exceeds its time budget. */
|
|
@@ -135,11 +144,17 @@ export async function checkAttestor(
|
|
|
135
144
|
ensureAttestorCrypto()
|
|
136
145
|
await ensureZkFiles()
|
|
137
146
|
})
|
|
147
|
+
const proofFilesState = opts.proofFilesState ?? zkFilesState
|
|
138
148
|
const startedAt = Date.now()
|
|
139
149
|
const finish = (
|
|
140
150
|
outcome: Omit<AttestorCheckResult, 'attestorUrl' | 'durationMs'>,
|
|
141
151
|
): AttestorCheckResult => {
|
|
142
|
-
return {
|
|
152
|
+
return {
|
|
153
|
+
...outcome,
|
|
154
|
+
attestorUrl,
|
|
155
|
+
durationMs: Date.now() - startedAt,
|
|
156
|
+
proofFiles: proofFilesState(),
|
|
157
|
+
}
|
|
143
158
|
}
|
|
144
159
|
|
|
145
160
|
let timer: NodeJS.Timeout | undefined
|
|
@@ -153,14 +168,7 @@ export async function checkAttestor(
|
|
|
153
168
|
try {
|
|
154
169
|
await Promise.race([prepare(), timeout])
|
|
155
170
|
} catch(err) {
|
|
156
|
-
|
|
157
|
-
return finish({
|
|
158
|
-
ok: false,
|
|
159
|
-
error,
|
|
160
|
-
errorKind: error.startsWith(CHECK_TIMEOUT_MESSAGE)
|
|
161
|
-
? 'attestor-unreachable'
|
|
162
|
-
: 'setup-failed',
|
|
163
|
-
})
|
|
171
|
+
return finish({ ok: false, ...setupFailure(messageOf(err)) })
|
|
164
172
|
}
|
|
165
173
|
|
|
166
174
|
const claim = await Promise.race([
|
|
@@ -202,6 +210,41 @@ export async function checkAttestor(
|
|
|
202
210
|
} finally {
|
|
203
211
|
clearTimeout(timer)
|
|
204
212
|
}
|
|
213
|
+
|
|
214
|
+
/** Classify a failure before the attestor was contacted by the proof
|
|
215
|
+
* file download state, so a slow or failed download never reads as an
|
|
216
|
+
* unreachable attestor. */
|
|
217
|
+
function setupFailure(error: string) {
|
|
218
|
+
const files = proofFilesState()
|
|
219
|
+
if(files.status === 'downloading') {
|
|
220
|
+
const done = files.bytesDownloaded !== undefined
|
|
221
|
+
? `, ${files.bytesDownloaded}`
|
|
222
|
+
+ (files.totalBytes !== undefined ? `/${files.totalBytes}` : '')
|
|
223
|
+
+ ' bytes'
|
|
224
|
+
: ''
|
|
225
|
+
return {
|
|
226
|
+
error:
|
|
227
|
+
`proof files are still downloading (${files.method}, `
|
|
228
|
+
+ `${Math.round(files.elapsedMs / 1000)}s${done}); the attestor `
|
|
229
|
+
+ 'was not contacted. Run check_attestor again shortly.',
|
|
230
|
+
errorKind: 'proof-files-downloading' as const,
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if(files.status === 'failed') {
|
|
235
|
+
return {
|
|
236
|
+
error: `proof file download failed: ${files.reason}`,
|
|
237
|
+
errorKind: 'proof-files-failed' as const,
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
error,
|
|
243
|
+
errorKind: error.startsWith(CHECK_TIMEOUT_MESSAGE)
|
|
244
|
+
? 'attestor-unreachable' as const
|
|
245
|
+
: 'setup-failed' as const,
|
|
246
|
+
}
|
|
247
|
+
}
|
|
205
248
|
}
|
|
206
249
|
|
|
207
250
|
function messageOf(err: unknown): string {
|