@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.
Files changed (101) hide show
  1. package/README.md +8 -8
  2. package/lib/authoring/backend.d.ts +12 -1
  3. package/lib/authoring/backend.js +3 -0
  4. package/lib/authoring/tools.js +33 -5
  5. package/lib/authoring/util.d.ts +43 -0
  6. package/lib/authoring/util.js +54 -0
  7. package/lib/cdp/chrome-for-testing.d.ts +14 -0
  8. package/lib/cdp/chrome-for-testing.js +43 -0
  9. package/lib/cdp/geo.d.ts +3 -0
  10. package/lib/cdp/geo.js +27 -15
  11. package/lib/cdp/launcher.d.ts +79 -1
  12. package/lib/cdp/launcher.js +244 -24
  13. package/lib/logger.js +3 -1
  14. package/lib/mcp/instructions.js +3 -3
  15. package/lib/mcp/start.js +17 -2
  16. package/lib/mcp/tools/agent/attach.d.ts +6 -0
  17. package/lib/mcp/tools/agent/attach.js +58 -118
  18. package/lib/mcp/tools/agent/build.js +6 -0
  19. package/lib/mcp/tools/agent/capture.js +33 -10
  20. package/lib/mcp/tools/agent/check-attestor.js +15 -7
  21. package/lib/mcp/tools/agent/draft-tools.d.ts +3 -0
  22. package/lib/mcp/tools/agent/draft-tools.js +170 -0
  23. package/lib/mcp/tools/agent/pod-proof.js +5 -1
  24. package/lib/mcp/tools/agent/proof.d.ts +28 -0
  25. package/lib/mcp/tools/agent/proof.js +72 -0
  26. package/lib/mcp/tools/agent/shared.d.ts +8 -1
  27. package/lib/mcp/tools/agent/shared.js +98 -19
  28. package/lib/mcp/tools/agent/start-integration.d.ts +2 -1
  29. package/lib/mcp/tools/agent/start-integration.js +10 -6
  30. package/lib/mcp/tools/authenticate/authenticate.d.ts +48 -10
  31. package/lib/mcp/tools/authenticate/authenticate.js +212 -116
  32. package/lib/mcp/tools/authenticate/build.d.ts +2 -2
  33. package/lib/mcp/tools/authenticate/build.js +6 -3
  34. package/lib/mcp/tools/authenticate/builder-access.js +5 -2
  35. package/lib/mcp/tools/authenticate/eth-key.d.ts +13 -2
  36. package/lib/mcp/tools/authenticate/eth-key.js +29 -5
  37. package/lib/mcp/tools/authenticate/index.d.ts +1 -1
  38. package/lib/mcp/tools/authenticate/index.js +1 -1
  39. package/lib/mcp/tools/authenticate/issue-org-keypair.d.ts +10 -0
  40. package/lib/mcp/tools/authenticate/issue-org-keypair.js +82 -0
  41. package/lib/mcp/tools/authenticate/oauth-config.d.ts +31 -0
  42. package/lib/mcp/tools/authenticate/oauth-config.js +67 -0
  43. package/lib/mcp/tools/authenticate/resolve-owner-key.d.ts +4 -5
  44. package/lib/mcp/tools/authenticate/resolve-owner-key.js +40 -36
  45. package/lib/mcp/tools/authenticate/session-cache.d.ts +25 -1
  46. package/lib/mcp/tools/authenticate/session-cache.js +95 -4
  47. package/lib/mcp/tools/mode/mode.js +6 -6
  48. package/lib/old/login-cdp.js +2 -3
  49. package/lib/proof/attestor.js +2 -3
  50. package/lib/proof/check-attestor.d.ts +12 -2
  51. package/lib/proof/check-attestor.js +40 -10
  52. package/lib/proof/check-matchers.d.ts +36 -0
  53. package/lib/proof/check-matchers.js +161 -0
  54. package/lib/proof/zk-files.d.ts +50 -11
  55. package/lib/proof/zk-files.js +259 -63
  56. package/lib/provider/draft.d.ts +24 -1
  57. package/lib/provider/draft.js +135 -0
  58. package/lib/provider/matcher.js +171 -4
  59. package/package.json +6 -4
  60. package/skill/SKILL.builder.md +7 -2
  61. package/skill/SKILL.common.md +90 -70
  62. package/skill/SKILL.old.md +3 -3
  63. package/skill/builder-mode.md +35 -5
  64. package/skill/old-mode.md +18 -13
  65. package/skill/start-integration.md +48 -33
  66. package/skill/verification-client-integration.md +21 -6
  67. package/src/authoring/backend.ts +12 -1
  68. package/src/authoring/tools.ts +48 -7
  69. package/src/authoring/util.ts +64 -0
  70. package/src/cdp/chrome-for-testing.ts +66 -0
  71. package/src/cdp/geo.ts +36 -21
  72. package/src/cdp/launcher.ts +328 -24
  73. package/src/logger.ts +3 -1
  74. package/src/mcp/instructions.ts +3 -3
  75. package/src/mcp/start.ts +20 -1
  76. package/src/mcp/tools/agent/attach.ts +65 -128
  77. package/src/mcp/tools/agent/build.ts +7 -0
  78. package/src/mcp/tools/agent/capture.ts +54 -11
  79. package/src/mcp/tools/agent/check-attestor.ts +15 -7
  80. package/src/mcp/tools/agent/draft-tools.ts +234 -0
  81. package/src/mcp/tools/agent/pod-proof.ts +5 -1
  82. package/src/mcp/tools/agent/proof.ts +110 -1
  83. package/src/mcp/tools/agent/shared.ts +130 -19
  84. package/src/mcp/tools/agent/start-integration.ts +10 -6
  85. package/src/mcp/tools/authenticate/authenticate.ts +276 -131
  86. package/src/mcp/tools/authenticate/build.ts +6 -3
  87. package/src/mcp/tools/authenticate/builder-access.ts +5 -2
  88. package/src/mcp/tools/authenticate/eth-key.ts +41 -7
  89. package/src/mcp/tools/authenticate/index.ts +1 -1
  90. package/src/mcp/tools/authenticate/issue-org-keypair.ts +106 -0
  91. package/src/mcp/tools/authenticate/oauth-config.ts +85 -0
  92. package/src/mcp/tools/authenticate/resolve-owner-key.ts +42 -39
  93. package/src/mcp/tools/authenticate/session-cache.ts +125 -5
  94. package/src/mcp/tools/mode/mode.ts +6 -6
  95. package/src/old/login-cdp.ts +2 -3
  96. package/src/proof/attestor.ts +2 -3
  97. package/src/proof/check-attestor.ts +55 -12
  98. package/src/proof/check-matchers.ts +227 -0
  99. package/src/proof/zk-files.ts +357 -65
  100. package/src/provider/draft.ts +215 -7
  101. 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 { ENV_FILE, resolveEthKey } from './eth-key.ts'
3
+ import { GENERATED_KEY_NOTE, resolveOrCreateEthKey } from './eth-key.ts'
5
4
 
6
5
  /**
7
- * Decide which eth proof-owner key `run_proof` should sign with, BEFORE
8
- * calling it. The key is a raw 0x-hex Ethereum private key, resolved (no
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 → ask the dev to generate (issue_credentials) or provide one
14
- * (import_credentials), then call again.
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<Record<string, never>>(
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. Call it '
23
- + 'before `run_proof`. Checks RECLAIM_PRIVATE_KEY in the MCP env, a '
24
- + 'RECLAIM_PRIVATE_KEY line in the project .env, then the file at '
25
- + 'RECLAIM_PRIVATE_KEY_FILE. Returns { ready: true, address, source '
26
- + '}: pass `address` to `run_proof` as `ownerAddress`. Returns { '
27
- + 'ready: false } when none exists: ask the developer to create one '
28
- + 'with `issue_credentials` or import one with `import_credentials` '
29
- + '(a key file), then call it again. Never returns the key.',
30
- inputSchema: { type: 'object', properties: {} },
31
- annotations: { readOnlyHint: true, openWorldHint: false },
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 resolved = resolveEthKey(projectDir)
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: false,
47
- message:
48
- 'No eth proof-owner key available — no RECLAIM_PRIVATE_KEY in the ' +
49
- `env, no RECLAIM_PRIVATE_KEY in ${join(projectDir, ENV_FILE)}, and ` +
50
- 'no RECLAIM_PRIVATE_KEY_FILE. Ask the dev to generate one via ' +
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 (2 days from issue). */
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
- if(!s?.token || !s.expiresAt) {
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
- if(new Date(s.expiresAt) <= new Date()) {
27
- return undefined // expired
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
- return s
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 not yet
7
- * expired. Mirrors `readSession`'s check, but against the CALLER's
8
- * `ConfigStore` instance (not always the default `~/.reclaim/config.json`
9
- * path) so this stays test-isolatable and consistent with the rest of this
10
- * tool's config reads. */
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
  /**
@@ -1,7 +1,7 @@
1
1
  import { connectCdp } from '../cdp/client.ts'
2
2
  import {
3
3
  defaultProfileDir,
4
- launchChrome,
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
- launchChrome({ port, profileDir: defaultProfileDir() })
164
- await waitForCdpReady(port, 10_000)
163
+ await launchLocalBrowser({ port, profileDir: defaultProfileDir() })
165
164
  launchedByUs = true
166
165
  }
167
166
 
@@ -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, then ' +
222
- 'issue_credentials (generate) or import_credentials (provide one), ' +
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
- /** Local setup (ZK files) failed before the attestor was contacted. */
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 { ...outcome, attestorUrl, durationMs: Date.now() - startedAt }
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
- const error = messageOf(err)
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 {