@thirdfy/agent-cli 0.4.2 → 0.5.0

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 (35) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.md +4 -6
  3. package/package.json +4 -2
  4. package/src/cli/help.mjs +2 -1
  5. package/src/cli/manifest.mjs +4 -4
  6. package/src/cli/options/index.mjs +2 -0
  7. package/src/cli/options/onboarding/credentials.mjs +0 -4
  8. package/src/cli/options/onboarding/oauthLogin.mjs +8 -0
  9. package/src/commands/account/credits.mjs +9 -7
  10. package/src/commands/discovery.mjs +6 -5
  11. package/src/commands/doctor.mjs +5 -5
  12. package/src/commands/execute.mjs +2 -11
  13. package/src/commands/onboarding/bootstrap.mjs +35 -0
  14. package/src/commands/onboarding/credentials.mjs +2 -25
  15. package/src/commands/onboarding/oauthLogin.mjs +284 -0
  16. package/src/core/chainRegistry.mjs +11 -0
  17. package/src/core/constants.mjs +0 -19
  18. package/src/core/context.mjs +16 -6
  19. package/src/core/errorEnvelope.mjs +6 -0
  20. package/src/core/http.mjs +47 -2
  21. package/src/core/oauth/authServer.mjs +192 -0
  22. package/src/core/oauth/pkce.mjs +17 -0
  23. package/src/core/oauth/session.mjs +117 -0
  24. package/src/lib/creditsUsdSummary.mjs +128 -29
  25. package/src/runtime/actions/selection.mjs +0 -1
  26. package/src/runtime/execution/payloads.mjs +6 -0
  27. package/src/runtime/execution/runners/createExecutionRunners.mjs +0 -3
  28. package/src/runtime/execution/runners/selfLane.mjs +6 -5
  29. package/src/runtime/handlers.mjs +1 -4
  30. package/src/runtime/main.mjs +0 -10
  31. package/src/runtime/owsSigning.mjs +17 -16
  32. package/src/runtime/providerHints/cex.mjs +1 -52
  33. package/src/runtime/bitfinexProbe.mjs +0 -121
  34. package/src/runtime/execution/runners/fallbackHints.mjs +0 -54
  35. package/src/runtime/onboardingHints.mjs +0 -54
@@ -0,0 +1,284 @@
1
+ import { spawn } from 'child_process';
2
+ import { createServer } from 'http';
3
+ import { createCliError } from '../../core/envelope.mjs';
4
+ import { getProfileConfigPath, loadProfileConfig, persistProfileConfig } from '../../core/context.mjs';
5
+ import {
6
+ buildAuthorizeUrl,
7
+ discoverAuthServer,
8
+ exchangeCode,
9
+ registerCliClient,
10
+ resolveIssuer,
11
+ } from '../../core/oauth/authServer.mjs';
12
+ import { createPkcePair, createState } from '../../core/oauth/pkce.mjs';
13
+ import { sessionFromTokens, writeOAuthSession } from '../../core/oauth/session.mjs';
14
+
15
+ /**
16
+ * `thirdfy-agent login` with OAuth: the owner signs in at auth.thirdfy.com in the browser, picks
17
+ * one agent, and approves read (always) and trade (`--trade`). The CLI is a public client with a
18
+ * loopback redirect (RFC 8252): a one-shot listener on 127.0.0.1, PKCE S256, `state`, and
19
+ * `resource` = the API base. The token acts as that one agent; the API checks it with the auth
20
+ * server on every request (THI-104).
21
+ */
22
+
23
+ const DEFAULT_LOGIN_TIMEOUT_SECONDS = 300;
24
+
25
+ const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
26
+ const shortKey = (key) => (key && key.length > 14 ? `${key.slice(0, 6)}…${key.slice(-4)}` : key || '');
27
+
28
+ const FALLBACK_ERRORS = {
29
+ access_denied: ['Sign-in cancelled', 'Nothing was connected. To try again, run <code>thirdfy-agent login</code>.'],
30
+ state_mismatch: ['This sign-in did not come from your terminal', 'For your safety nothing was connected. Start again from your terminal.'],
31
+ issuer_mismatch: ['Unexpected sign-in server', 'Nothing was connected. Start again from your terminal.'],
32
+ exchange_failed: ['The CLI could not finish signing in', 'Nothing was connected. Run <code>thirdfy-agent login</code> to try again.'],
33
+ failed: ['Sign-in not completed', 'Nothing was connected. Run <code>thirdfy-agent login</code> to try again.'],
34
+ };
35
+
36
+ /** The built-in result page, for a sign-in server without hosted ones: the same look, no assets. */
37
+ function fallbackPage({ ok, title, body, facts = [] }) {
38
+ const rows = facts.map(([label, value]) => `<div><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd></div>`).join('');
39
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="referrer" content="no-referrer"><title>${escapeHtml(title)} · Thirdfy</title><style>
40
+ :root{color-scheme:dark}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;background:#000;color:#fafafa;font:15px/1.5 Inter,system-ui,-apple-system,"Segoe UI",sans-serif;padding:16px}
41
+ .card{width:100%;max-width:480px;background:#09090b;border:1px solid #27272a;border-radius:16px;padding:28px 24px 24px}
42
+ .brand{font-weight:700;letter-spacing:.18em;font-size:13px;margin-bottom:20px}.status{display:grid;place-items:center;width:52px;height:52px;border-radius:999px;border:1px solid currentColor;margin-bottom:16px;font-size:24px}
43
+ .ok{color:#4ade80;background:rgba(74,222,128,.08)}.err{color:#f87171;background:rgba(248,113,113,.08)}h1{font-size:22px;line-height:1.25;margin:0 0 10px;font-weight:650}p{margin:8px 0;color:#a1a1aa}
44
+ dl{margin:18px 0 0;border:1px solid #27272a;border-radius:12px;overflow:hidden}dl div{display:flex;justify-content:space-between;gap:12px;padding:10px 14px;background:#18181b}dl div+div{border-top:1px solid #27272a}dt{color:#a1a1aa;font-size:14px}dd{margin:0;font-weight:600}
45
+ code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;color:#fafafa}</style></head><body><main class="card"><div class="brand">THIRDFY</div>
46
+ <div class="status ${ok ? 'ok' : 'err'}" aria-hidden="true">${ok ? '&#10003;' : '&#10005;'}</div><h1>${escapeHtml(title)}</h1><p>${body}</p>${rows ? `<dl>${rows}</dl>` : ''}</main></body></html>`;
47
+ }
48
+
49
+ /**
50
+ * The browser's last page. Like Claude Code, GitHub CLI and Google Cloud CLI, the listener sends
51
+ * the browser to a result page on the sign-in server (`/cli/signed-in`, `/cli/error`) once the CLI
52
+ * holds its tokens. A server without those pages gets the built-in one.
53
+ */
54
+ function resultPages(issuer, hosted) {
55
+ const redirect = (res, location) => res.writeHead(302, { location, 'cache-control': 'no-store' }).end();
56
+ const html = (res, status, page) => res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }).end(page);
57
+ return {
58
+ signedIn(res, { agentKey, canTrade, apiHost }) {
59
+ if (res.writableEnded) return;
60
+ if (hosted) {
61
+ const query = new URLSearchParams({ ...(agentKey ? { agent: agentKey } : {}), access: canTrade ? 'trade' : 'read', ...(apiHost ? { api: apiHost } : {}) });
62
+ return redirect(res, `${issuer}/cli/signed-in?${query}`);
63
+ }
64
+ return html(res, 200, fallbackPage({
65
+ ok: true,
66
+ title: "You're signed in to the Thirdfy CLI",
67
+ body: 'Go back to your terminal to continue. You can close this tab.',
68
+ facts: [
69
+ ...(agentKey ? [['Acts as agent', shortKey(agentKey)]] : []),
70
+ ['Access', canTrade ? 'Read and trade' : 'Read only'],
71
+ ...(apiHost ? [['API', apiHost]] : []),
72
+ ],
73
+ }));
74
+ },
75
+ error(res, reason, status = 200) {
76
+ if (res.writableEnded) return;
77
+ if (hosted) return redirect(res, `${issuer}/cli/error?reason=${encodeURIComponent(reason)}`);
78
+ const [title, body] = FALLBACK_ERRORS[reason] || FALLBACK_ERRORS.failed;
79
+ return html(res, status, fallbackPage({ ok: false, title, body }));
80
+ },
81
+ };
82
+ }
83
+
84
+ /** Whether the sign-in server has the hosted result pages (auth.thirdfy.com 0.5.1+). */
85
+ async function hasHostedResultPages(issuer, timeoutMs) {
86
+ try {
87
+ const response = await fetch(`${issuer}/cli/signed-in`, { headers: { accept: 'text/html' }, signal: AbortSignal.timeout(Math.min(timeoutMs, 5000)) });
88
+ return response.ok && String(response.headers.get('content-type') || '').includes('text/html');
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ function openBrowser(url) {
95
+ const platform = process.platform;
96
+ const [command, args] =
97
+ // Windows: not `cmd /c start`, which splits the URL at every `&` of its query.
98
+ platform === 'darwin' ? ['open', [url]] : platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]] : ['xdg-open', [url]];
99
+ try {
100
+ const child = spawn(command, args, { stdio: 'ignore', detached: true });
101
+ child.on('error', () => {});
102
+ child.unref();
103
+ return true;
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Listen on 127.0.0.1 (any free port) for one `/callback` that carries our `state`. The browser's
111
+ * response is held until the CLI has its tokens: `result` resolves with the code and that response.
112
+ */
113
+ function startLoopbackListener({ state, issuer, timeoutMs, pages }) {
114
+ let settle;
115
+ const result = new Promise((resolve, reject) => {
116
+ settle = { resolve, reject };
117
+ });
118
+ const server = createServer((req, res) => {
119
+ const url = new URL(req.url || '/', 'http://127.0.0.1');
120
+ if (url.pathname !== '/callback') {
121
+ res.writeHead(404, { 'content-type': 'text/plain' }).end('Not found');
122
+ return;
123
+ }
124
+ const params = url.searchParams;
125
+ if (params.get('state') !== state) {
126
+ pages.error(res, 'state_mismatch', 400);
127
+ return;
128
+ }
129
+ const iss = params.get('iss');
130
+ if (iss && iss.replace(/\/+$/, '') !== issuer) {
131
+ pages.error(res, 'issuer_mismatch', 400);
132
+ settle.reject(createCliError('OAUTH_ISSUER_MISMATCH', `The sign-in answer came from ${iss}, not ${issuer}.`, { blockedStage: 'auth' }));
133
+ return;
134
+ }
135
+ const error = params.get('error');
136
+ if (error) {
137
+ pages.error(res, error === 'access_denied' ? 'access_denied' : 'failed');
138
+ settle.reject(
139
+ createCliError(error === 'access_denied' ? 'OAUTH_ACCESS_DENIED' : 'OAUTH_AUTHORIZE_FAILED', params.get('error_description') || `Sign-in failed: ${error}`, {
140
+ blockedStage: 'auth',
141
+ oauthError: error,
142
+ }),
143
+ );
144
+ return;
145
+ }
146
+ const code = params.get('code');
147
+ if (!code) {
148
+ pages.error(res, 'failed', 400);
149
+ return;
150
+ }
151
+ settle.resolve({ code, res });
152
+ });
153
+ const timer = setTimeout(() => {
154
+ settle.reject(createCliError('OAUTH_LOGIN_TIMEOUT', `No sign-in arrived within ${Math.round(timeoutMs / 1000)} seconds. Run \`thirdfy-agent login\` again.`, { blockedStage: 'auth' }));
155
+ }, timeoutMs);
156
+ timer.unref?.();
157
+ const ready = new Promise((resolve, reject) => {
158
+ server.once('error', reject);
159
+ server.listen(0, '127.0.0.1', () => resolve(server.address().port));
160
+ });
161
+ const close = () => {
162
+ clearTimeout(timer);
163
+ server.close();
164
+ server.closeAllConnections?.();
165
+ };
166
+ return { ready, result, close };
167
+ }
168
+
169
+ /** Which agent the owner picked on the consent page: the token response does not say. */
170
+ async function fetchGrantedAgent(ctx, accessToken) {
171
+ try {
172
+ const response = await fetch(`${ctx.apiBase}/api/v1/agent/oauth/session`, {
173
+ headers: { Authorization: `Bearer ${accessToken}`, accept: 'application/json', 'x-client': 'agent-cli' },
174
+ signal: AbortSignal.timeout(ctx.timeoutMs || 30_000),
175
+ });
176
+ const body = await response.json().catch(() => ({}));
177
+ if (!response.ok) return { error: body?.error || `HTTP ${response.status}` };
178
+ const data = body?.data || body;
179
+ return {
180
+ agentKey: data?.agentKey ? String(data.agentKey).toLowerCase() : null,
181
+ ownerDid: data?.ownerDid || null,
182
+ canTrade: data?.canTrade === true,
183
+ };
184
+ } catch (error) {
185
+ return { error: error?.message || 'network error' };
186
+ }
187
+ }
188
+
189
+ export async function commandOAuthLogin(ctx, flags, { printEnvelope, version }) {
190
+ const timeoutMs = ctx.timeoutMs || 30_000;
191
+ const resource = ctx.apiBase;
192
+ const issuer = resolveIssuer(resource, flags);
193
+ const metadata = await discoverAuthServer(issuer, resource, timeoutMs);
194
+
195
+ const config = loadProfileConfig();
196
+ const knownClients = config.auth?.oauthClients && typeof config.auth.oauthClients === 'object' ? config.auth.oauthClients : {};
197
+ let clientId = String(knownClients[issuer] || '').trim();
198
+ if (!clientId) {
199
+ clientId = await registerCliClient(metadata, { version, timeoutMs });
200
+ const latest = loadProfileConfig();
201
+ const auth = latest.auth && typeof latest.auth === 'object' ? latest.auth : {};
202
+ persistProfileConfig({ ...latest, auth: { ...auth, oauthClients: { ...(auth.oauthClients || {}), [issuer]: clientId } } });
203
+ }
204
+
205
+ const loginSeconds = Number(flags.loginTimeout) > 0 ? Number(flags.loginTimeout) : DEFAULT_LOGIN_TIMEOUT_SECONDS;
206
+ const state = createState();
207
+ const pkce = createPkcePair();
208
+ const pages = resultPages(issuer, await hasHostedResultPages(issuer, timeoutMs));
209
+ const listener = startLoopbackListener({ state, issuer, timeoutMs: loginSeconds * 1000, pages });
210
+ let session;
211
+ let grant;
212
+ let browserResponse;
213
+ try {
214
+ const port = await listener.ready;
215
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
216
+ const scope = ['mcp:read', ...(flags.trade ? ['mcp:trade'] : []), 'offline_access'].join(' ');
217
+ const authorizeUrl = buildAuthorizeUrl(metadata, { clientId, redirectUri, challenge: pkce.challenge, state, scope, resource });
218
+ const opened = flags.browser === false ? false : openBrowser(authorizeUrl);
219
+ // stderr: stdout stays a single JSON envelope with --json.
220
+ process.stderr.write(
221
+ `${opened ? 'Opening your browser to sign in to Thirdfy.' : 'Open this URL in a browser on this machine to sign in to Thirdfy:'}\n${opened ? 'If it did not open, use: ' : ''}${authorizeUrl}\nWaiting for the sign-in (${loginSeconds}s)...\n`,
222
+ );
223
+ const { code, res } = await listener.result;
224
+ browserResponse = res;
225
+ try {
226
+ const tokens = await exchangeCode(metadata, { clientId, code, redirectUri, verifier: pkce.verifier, resource }, timeoutMs);
227
+ const base = {
228
+ issuer,
229
+ clientId,
230
+ resource,
231
+ tokenEndpoint: metadata.token_endpoint,
232
+ revocationEndpoint: metadata.revocation_endpoint || null,
233
+ };
234
+ session = sessionFromTokens(base, tokens);
235
+ grant = await fetchGrantedAgent(ctx, session.accessToken);
236
+ session = {
237
+ ...session,
238
+ agentKey: grant.agentKey || null,
239
+ ownerDid: grant.ownerDid || null,
240
+ canTrade: grant.error ? String(session.scope).split(' ').includes('mcp:trade') : grant.canTrade,
241
+ };
242
+ writeOAuthSession(session);
243
+ pages.signedIn(res, { agentKey: session.agentKey, canTrade: session.canTrade, apiHost: new URL(resource).host });
244
+ } catch (error) {
245
+ pages.error(res, 'exchange_failed');
246
+ throw error;
247
+ }
248
+ } finally {
249
+ // Let the browser get its last page before the listener closes its connections.
250
+ if (browserResponse && !browserResponse.writableFinished) {
251
+ await new Promise((resolve) => {
252
+ const timer = setTimeout(resolve, 2000);
253
+ browserResponse.once('finish', () => {
254
+ clearTimeout(timer);
255
+ resolve();
256
+ });
257
+ });
258
+ }
259
+ listener.close();
260
+ }
261
+
262
+ const warnings = grant.error
263
+ ? [`Signed in, but ${resource} could not say which agent this sign-in acts as (${grant.error}). The API may not accept OAuth sign-ins yet; use an agent API key or \`login email\` meanwhile.`]
264
+ : [];
265
+ printEnvelope({
266
+ success: true,
267
+ code: 'LOGIN_OK',
268
+ message: grant.agentKey ? `Signed in with OAuth as agent ${grant.agentKey}` : 'Signed in with OAuth',
269
+ data: {
270
+ auth: 'oauth',
271
+ issuer,
272
+ agentKey: session.agentKey,
273
+ ownerDid: session.ownerDid,
274
+ scope: session.scope,
275
+ canTrade: session.canTrade,
276
+ expiresAt: session.expiresAt,
277
+ configPath: getProfileConfigPath(),
278
+ note: session.canTrade
279
+ ? `This sign-in acts as one agent on ${resource}, with read and trade access. Revoke it any time at ${issuer}/connections or with \`thirdfy-agent logout\`.`
280
+ : `This sign-in acts as one agent on ${resource}, read-only. For writes, run \`thirdfy-agent login --trade\`. Revoke it at ${issuer}/connections or with \`thirdfy-agent logout\`.`,
281
+ },
282
+ meta: { apiBase: ctx.apiBase, ...(warnings.length ? { warnings } : {}) },
283
+ });
284
+ }
@@ -293,6 +293,17 @@ export function resolveChainRpcUrl({ chainId, rpcUrl, env = process.env } = {})
293
293
  return getChainInfo(id)?.rpcUrl || '';
294
294
  }
295
295
 
296
+ /**
297
+ * An ethers JsonRpcProvider pinned to `chainId` (`staticNetwork`). Without a pinned network, ethers
298
+ * probes the RPC for its chain and, when the RPC is down, prints a retry line to stdout every second
299
+ * and keeps the process alive, which breaks `--json` output. Pinned, a dead RPC is one rejected
300
+ * call. `getNetwork()` then echoes the pin; read the RPC's real chain with `send('eth_chainId', [])`.
301
+ */
302
+ export function pinnedRpcProvider(ethers, { rpcUrl, chainId }) {
303
+ const id = positiveChainId(chainId) || DEFAULT_CHAIN_ID;
304
+ return new ethers.JsonRpcProvider(rpcUrl, id, { staticNetwork: true });
305
+ }
306
+
296
307
  /**
297
308
  * Parse a --chain-id value. Returns null when absent, a positive integer when valid,
298
309
  * and NaN when present but not a positive integer (the caller rejects it).
@@ -1,25 +1,6 @@
1
1
  export const DEFAULT_API_BASE = 'https://api.thirdfy.com';
2
2
  export const DEFAULT_TIMEOUT_MS = 30000;
3
3
  export const DEFAULT_CATALOG_CACHE_TTL_MS = 60_000;
4
- export const DEFAULT_BITFINEX_BASE_URL = 'https://api.bitfinex.com';
5
- export const BITFINEX_PERMISSION_PROFILES = {
6
- funding: {
7
- id: 'funding',
8
- credentialRef: 'bitfinex:funding-primary',
9
- required: {
10
- funding: { read: 1, write: 1 },
11
- wallets: { read: 1, write: 0 },
12
- },
13
- },
14
- spot: {
15
- id: 'spot',
16
- credentialRef: 'bitfinex:spot-primary',
17
- required: {
18
- orders: { read: 1, write: 1 },
19
- wallets: { read: 1, write: 0 },
20
- },
21
- },
22
- };
23
4
  export const RUN_MODES = ['thirdfy', 'self', 'hybrid', 'agent_wallet'];
24
5
  export const HYBRID_WALLET_MODES = ['self', 'agent_wallet'];
25
6
  export const CUSTODY_MODES = ['managed', 'external'];
@@ -4,6 +4,7 @@ import path from 'path';
4
4
  import { DEFAULT_CATALOG_CACHE_TTL_MS, PROFILE_DEFAULTS } from './constants.mjs';
5
5
  import { createCliError } from './envelope.mjs';
6
6
  import { apiGet } from './http.mjs';
7
+ import { apiBaseFor, oauthSessionForApi, oauthSessionIsAgentCredential } from './oauth/session.mjs';
7
8
  import {
8
9
  normalizeCustodyMode,
9
10
  normalizeProfileName,
@@ -121,21 +122,27 @@ export function extractActions(response) {
121
122
  }
122
123
 
123
124
  /**
124
- * The agent API key from `--agent-api-key`, then THIRDFY_AGENT_API_KEY, then config `agent.apiKey`;
125
- * empty when none is set. The same order execution uses, without its missing-key error.
125
+ * The agent credential from `--agent-api-key`, then THIRDFY_AGENT_API_KEY, then the OAuth sign-in
126
+ * for this API (`thirdfy-agent login`, its access token), then config `agent.apiKey`; empty when
127
+ * none is set.
128
+ * The same order execution uses, without its missing-key error. An OAuth token is sent as
129
+ * `Authorization: Bearer` (and refreshed) by `src/core/http.mjs`.
126
130
  */
127
131
  export function findAgentApiKey(flags = {}) {
128
132
  const explicit = String(flags.agentApiKey || '').trim();
129
133
  if (explicit) return explicit;
130
134
  const envKey = String(process.env.THIRDFY_AGENT_API_KEY || '').trim();
131
135
  if (envKey) return envKey;
132
- return String(loadProfileConfig()?.agent?.apiKey || '').trim();
136
+ const config = loadProfileConfig();
137
+ const oauth = oauthSessionForApi(apiBaseFor(flags), config);
138
+ if (oauth) return oauth.accessToken;
139
+ return String(config?.agent?.apiKey || '').trim();
133
140
  }
134
141
 
135
142
  /**
136
143
  * Whose credits a credits read is about. Same precedence as `wallet profile`: an owner credential
137
- * passed as a flag (--owner-session-token or --auth-token), then an explicit --agent-api-key, then
138
- * owner auth from env or config, then an agent key from env or config. Returns `{ ledger, agentApiKey,
144
+ * passed as a flag (--owner-session-token or --auth-token), then an explicit --agent-api-key or an
145
+ * OAuth sign-in, then owner auth from env or config, then an agent key from env or config. Returns `{ ledger, agentApiKey,
139
146
  * agentApiKeyExplicit }`; `ledger` is `agent` or `owner`.
140
147
  */
141
148
  export function resolveCreditsLedger(flags = {}) {
@@ -148,7 +155,10 @@ export function resolveCreditsLedger(flags = {}) {
148
155
  ).trim(),
149
156
  );
150
157
  const agentApiKey = findAgentApiKey(flags);
151
- const agent = !explicitOwner && (agentApiKeyExplicit || (!storedOwner && Boolean(agentApiKey)));
158
+ // An OAuth sign-in (`thirdfy-agent login`) is a deliberate choice of one agent: it comes before
159
+ // owner auth stored in env or config, as an explicit --agent-api-key does.
160
+ const oauthAgent = oauthSessionIsAgentCredential(flags, process.env, config);
161
+ const agent = !explicitOwner && (agentApiKeyExplicit || oauthAgent || (!storedOwner && Boolean(agentApiKey)));
152
162
  return { ledger: agent ? 'agent' : 'owner', agentApiKey, agentApiKeyExplicit };
153
163
  }
154
164
 
@@ -20,6 +20,8 @@ export const NEXT_ACTIONS = Object.freeze({
20
20
  fund_native_gas: "The execution wallet cannot pay this chain's gas. Fund it with the native coin first.",
21
21
  owner_reenable_agent_signer: "The wallet owner removed Thirdfy's signer. The owner must re-enable the agent.",
22
22
  use_evm_chain: 'This Thirdfy API does not run that chain family (Solana). Use an EVM chain the API lists in `chains`.',
23
+ reauthenticate: 'The sign-in expired or was revoked. Sign in again: `thirdfy-agent login`.',
24
+ reauthenticate_with_trade: 'This sign-in is read-only. Sign in again with trade access: `thirdfy-agent login --trade`.',
23
25
  });
24
26
 
25
27
  /** Credits refusal codes the API sends (HTTP 402). */
@@ -56,6 +58,10 @@ const CLIENT_CODE_FIELDS = Object.freeze({
56
58
  SOLANA_NOT_ENABLED: { retryable: false, nextAction: 'use_evm_chain' },
57
59
  BROADCAST_FAILED: { retryable: false, nextAction: 'reread_state_before_retry' },
58
60
  EFFECT_CHECK_FAILED: { retryable: false, nextAction: 'reread_state_before_retry' },
61
+ OAUTH_SESSION_EXPIRED: { retryable: false, nextAction: 'reauthenticate' },
62
+ OAUTH_ACCESS_DENIED: { retryable: false, nextAction: 'reauthenticate' },
63
+ OAUTH_LOGIN_TIMEOUT: { retryable: true, nextAction: 'retry_later' },
64
+ OAUTH_DISCOVERY_FAILED: { retryable: true, nextAction: 'retry_later' },
59
65
  });
60
66
 
61
67
  const isRecord = (value) => Boolean(value && typeof value === 'object' && !Array.isArray(value));
package/src/core/http.mjs CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  mergeExecutionContext,
4
4
  resolveExecutionContextFromInput,
5
5
  } from './execution/executionContext.mjs';
6
+ import { freshOAuthAccessToken, isOAuthAccessToken, readOAuthSession } from './oauth/session.mjs';
6
7
 
7
8
  function sleep(ms) {
8
9
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -123,7 +124,21 @@ function mergeExecutionContextHeaders(headers = {}, body = {}) {
123
124
  };
124
125
  }
125
126
 
126
- export async function apiPost(ctx, route, body, headers = {}) {
127
+ /**
128
+ * Execute payloads carry the agent credential in the body (`agentApiKey`). An OAuth sign-in never
129
+ * goes in a body: it moves to the header, where `withOAuthBearer` sends it as `Authorization: Bearer`.
130
+ */
131
+ function moveOAuthCredentialToHeader(body, headers) {
132
+ const credential = body.agentApiKey ?? body.agent_api_key;
133
+ if (!isOAuthAccessToken(credential)) return { body, headers };
134
+ const { agentApiKey: _a, agent_api_key: _b, ...rest } = body;
135
+ return { body: rest, headers: { 'x-agent-api-key': credential, ...headers } };
136
+ }
137
+
138
+ export async function apiPost(ctx, route, rawBody, rawHeaders = {}) {
139
+ const moved = moveOAuthCredentialToHeader(rawBody && typeof rawBody === 'object' ? rawBody : {}, rawHeaders);
140
+ const body = moved.body;
141
+ const headers = moved.headers;
127
142
  const normalizedBody = body && typeof body === 'object' ? body : {};
128
143
  const mergedBody = mergeExecutionContext(normalizedBody);
129
144
  return requestJson(ctx, route, {
@@ -136,7 +151,25 @@ export async function apiPost(ctx, route, body, headers = {}) {
136
151
  });
137
152
  }
138
153
 
139
- async function requestJsonOnce(ctx, route, init) {
154
+ /**
155
+ * An OAuth sign-in passed as the agent credential (`findAgentApiKey`) goes as
156
+ * `Authorization: Bearer`, refreshed first when it is about to expire. Owner auth already in
157
+ * `Authorization` wins; the OAuth token is then not sent.
158
+ */
159
+ async function withOAuthBearer(ctx, init) {
160
+ const headers = { ...(init.headers || {}) };
161
+ const credential = headers['x-agent-api-key'];
162
+ if (!isOAuthAccessToken(credential)) return { init, oauth: false };
163
+ const session = readOAuthSession();
164
+ const token = session?.accessToken === credential ? (await freshOAuthAccessToken({ timeoutMs: ctx.timeoutMs })) || credential : credential;
165
+ delete headers['x-agent-api-key'];
166
+ if (!headers.Authorization && !headers.authorization) headers.Authorization = `Bearer ${token}`;
167
+ return { init: { ...init, headers }, oauth: true };
168
+ }
169
+
170
+ async function requestJsonOnce(ctx, route, rawInit) {
171
+ const { __oauthRetried, ...plainInit } = rawInit;
172
+ const { init, oauth } = await withOAuthBearer(ctx, plainInit);
140
173
  const controller = new AbortController();
141
174
  const timeout = setTimeout(() => controller.abort(), ctx.timeoutMs);
142
175
  try {
@@ -147,6 +180,18 @@ async function requestJsonOnce(ctx, route, init) {
147
180
  const text = await response.text();
148
181
  const data = text ? safeJsonParse(text) : {};
149
182
  rememberResponseHeaders(data, response);
183
+ // A 401 to an OAuth sign-in: refresh once and send again. Nothing ran on a 401, so this is safe
184
+ // for writes too. A refresh the auth server refuses is OAUTH_SESSION_EXPIRED.
185
+ if (response.status === 401 && oauth && !__oauthRetried) {
186
+ const token = await freshOAuthAccessToken({ timeoutMs: ctx.timeoutMs, force: true });
187
+ if (token) {
188
+ return await requestJsonOnce(ctx, route, {
189
+ ...plainInit,
190
+ headers: { ...(plainInit.headers || {}), 'x-agent-api-key': token },
191
+ __oauthRetried: true,
192
+ });
193
+ }
194
+ }
150
195
  if (!response.ok) {
151
196
  const error = new Error(data?.error || data?.message || `HTTP ${response.status}`);
152
197
  error.statusCode = response.status;
@@ -0,0 +1,192 @@
1
+ import { createCliError } from '../envelope.mjs';
2
+
3
+ /**
4
+ * The auth.thirdfy.com calls `thirdfy-agent login` makes: metadata (RFC 8414), client registration
5
+ * (RFC 7591, a public client with a loopback redirect), the authorize URL, the code exchange and
6
+ * refresh (PKCE S256, `resource` = the API, RFC 8707), and revocation (RFC 7009).
7
+ */
8
+
9
+ const KNOWN_ISSUERS = new Map([
10
+ ['api.thirdfy.com', 'https://auth.thirdfy.com'],
11
+ ['api-test.thirdfy.com', 'https://auth-test.thirdfy.com'],
12
+ ]);
13
+
14
+ /** Registered once per issuer; any loopback port matches this redirect. */
15
+ export const CLI_REDIRECT_URI = 'http://127.0.0.1/callback';
16
+ export const CLI_CLIENT_NAME = 'Thirdfy Agent CLI';
17
+
18
+ const trimSlash = (value) => String(value || '').trim().replace(/\/+$/, '');
19
+
20
+ /** --issuer, then THIRDFY_AUTH_ISSUER, then the issuer paired with a known API base. */
21
+ export function resolveIssuer(apiBase, flags = {}, env = process.env) {
22
+ const explicit = trimSlash(flags.issuer || env.THIRDFY_AUTH_ISSUER);
23
+ if (explicit) return explicit;
24
+ let host = '';
25
+ try {
26
+ host = new URL(apiBase).host;
27
+ } catch {
28
+ host = '';
29
+ }
30
+ const known = KNOWN_ISSUERS.get(host);
31
+ if (known) return known;
32
+ throw createCliError(
33
+ 'OAUTH_ISSUER_REQUIRED',
34
+ `No sign-in server is known for ${apiBase}. Pass --issuer <url> (or set THIRDFY_AUTH_ISSUER), or sign in with \`thirdfy-agent login email <address>\`.`,
35
+ { blockedStage: 'auth' },
36
+ );
37
+ }
38
+
39
+ async function fetchJson(url, init, timeoutMs, failureCode) {
40
+ let response;
41
+ try {
42
+ response = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
43
+ } catch (error) {
44
+ throw createCliError(failureCode, `Could not reach ${new URL(url).origin}: ${error?.message || 'network error'}`, {
45
+ blockedStage: 'auth',
46
+ retryable: true,
47
+ });
48
+ }
49
+ const text = await response.text();
50
+ let body = {};
51
+ try {
52
+ body = text ? JSON.parse(text) : {};
53
+ } catch {
54
+ body = {};
55
+ }
56
+ return { response, body };
57
+ }
58
+
59
+ const oauthError = (code, body, fallback) =>
60
+ createCliError(code, String(body?.error_description || body?.message || body?.error || fallback), {
61
+ blockedStage: 'auth',
62
+ oauthError: body?.error || null,
63
+ });
64
+
65
+ /** RFC 8414 metadata, checked for what the CLI needs: the issuer, S256, and the API as a resource. */
66
+ export async function discoverAuthServer(issuer, resource, timeoutMs) {
67
+ const { response, body } = await fetchJson(
68
+ `${issuer}/.well-known/oauth-authorization-server`,
69
+ { headers: { accept: 'application/json' } },
70
+ timeoutMs,
71
+ 'OAUTH_DISCOVERY_FAILED',
72
+ );
73
+ if (!response.ok || trimSlash(body?.issuer) !== issuer || !body?.authorization_endpoint || !body?.token_endpoint) {
74
+ throw createCliError('OAUTH_DISCOVERY_FAILED', `${issuer} did not answer as a Thirdfy sign-in server (HTTP ${response.status}).`, {
75
+ blockedStage: 'auth',
76
+ });
77
+ }
78
+ const methods = Array.isArray(body.code_challenge_methods_supported) ? body.code_challenge_methods_supported : [];
79
+ if (!methods.includes('S256')) {
80
+ throw createCliError('OAUTH_DISCOVERY_FAILED', `${issuer} does not support PKCE S256.`, { blockedStage: 'auth' });
81
+ }
82
+ const resources = Array.isArray(body.protected_resources) ? body.protected_resources.map(trimSlash) : null;
83
+ if (resources && !resources.includes(trimSlash(resource))) {
84
+ throw createCliError(
85
+ 'OAUTH_RESOURCE_NOT_SUPPORTED',
86
+ `${issuer} does not issue sign-ins for ${resource} yet. Use \`thirdfy-agent login email <address>\` for now.`,
87
+ { blockedStage: 'auth' },
88
+ );
89
+ }
90
+ return body;
91
+ }
92
+
93
+ /** A public client (no secret) with the loopback redirect, registered once per issuer. */
94
+ export async function registerCliClient(metadata, { version, timeoutMs }) {
95
+ if (!metadata.registration_endpoint) {
96
+ throw createCliError('OAUTH_REGISTRATION_UNAVAILABLE', `${metadata.issuer} does not accept client registration.`, {
97
+ blockedStage: 'auth',
98
+ });
99
+ }
100
+ const { response, body } = await fetchJson(
101
+ metadata.registration_endpoint,
102
+ {
103
+ method: 'POST',
104
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
105
+ body: JSON.stringify({
106
+ client_name: CLI_CLIENT_NAME,
107
+ redirect_uris: [CLI_REDIRECT_URI],
108
+ token_endpoint_auth_method: 'none',
109
+ grant_types: ['authorization_code', 'refresh_token'],
110
+ response_types: ['code'],
111
+ client_uri: 'https://www.npmjs.com/package/@thirdfy/agent-cli',
112
+ software_id: 'thirdfy-agent-cli',
113
+ ...(version ? { software_version: String(version) } : {}),
114
+ }),
115
+ },
116
+ timeoutMs,
117
+ 'OAUTH_REGISTRATION_FAILED',
118
+ );
119
+ if (!response.ok || !body?.client_id) throw oauthError('OAUTH_REGISTRATION_FAILED', body, `Client registration failed (HTTP ${response.status}).`);
120
+ return String(body.client_id);
121
+ }
122
+
123
+ export function buildAuthorizeUrl(metadata, { clientId, redirectUri, challenge, state, scope, resource }) {
124
+ const url = new URL(metadata.authorization_endpoint);
125
+ url.search = new URLSearchParams({
126
+ response_type: 'code',
127
+ client_id: clientId,
128
+ redirect_uri: redirectUri,
129
+ code_challenge: challenge,
130
+ code_challenge_method: 'S256',
131
+ state,
132
+ scope,
133
+ resource,
134
+ }).toString();
135
+ return url.toString();
136
+ }
137
+
138
+ async function tokenRequest(tokenEndpoint, params, timeoutMs, failureCode) {
139
+ const { response, body } = await fetchJson(
140
+ tokenEndpoint,
141
+ {
142
+ method: 'POST',
143
+ headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
144
+ body: new URLSearchParams(params).toString(),
145
+ },
146
+ timeoutMs,
147
+ failureCode,
148
+ );
149
+ if (!response.ok || !body?.access_token) {
150
+ const code = body?.error === 'invalid_grant' ? 'OAUTH_SESSION_EXPIRED' : failureCode;
151
+ throw oauthError(code, body, `Token request failed (HTTP ${response.status}).`);
152
+ }
153
+ return body;
154
+ }
155
+
156
+ export function exchangeCode(metadata, { clientId, code, redirectUri, verifier, resource }, timeoutMs) {
157
+ return tokenRequest(
158
+ metadata.token_endpoint,
159
+ { grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: clientId, code_verifier: verifier, resource },
160
+ timeoutMs,
161
+ 'OAUTH_CODE_EXCHANGE_FAILED',
162
+ );
163
+ }
164
+
165
+ export function refreshTokens({ tokenEndpoint, clientId, refreshToken, resource }, timeoutMs) {
166
+ return tokenRequest(
167
+ tokenEndpoint,
168
+ { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: clientId, ...(resource ? { resource } : {}) },
169
+ timeoutMs,
170
+ 'OAUTH_REFRESH_FAILED',
171
+ );
172
+ }
173
+
174
+ /** RFC 7009: best effort; the answer is 200 even for an unknown token. */
175
+ export async function revokeToken({ revocationEndpoint, clientId, token }, timeoutMs) {
176
+ if (!revocationEndpoint || !token) return false;
177
+ try {
178
+ const { response } = await fetchJson(
179
+ revocationEndpoint,
180
+ {
181
+ method: 'POST',
182
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
183
+ body: new URLSearchParams({ token, client_id: clientId }).toString(),
184
+ },
185
+ timeoutMs,
186
+ 'OAUTH_REVOKE_FAILED',
187
+ );
188
+ return response.ok;
189
+ } catch {
190
+ return false;
191
+ }
192
+ }