@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.
- package/CHANGELOG.md +63 -1
- package/README.md +4 -6
- package/package.json +4 -2
- package/src/cli/help.mjs +2 -1
- package/src/cli/manifest.mjs +4 -4
- package/src/cli/options/index.mjs +2 -0
- package/src/cli/options/onboarding/credentials.mjs +0 -4
- package/src/cli/options/onboarding/oauthLogin.mjs +8 -0
- package/src/commands/account/credits.mjs +9 -7
- package/src/commands/discovery.mjs +6 -5
- package/src/commands/doctor.mjs +5 -5
- package/src/commands/execute.mjs +2 -11
- package/src/commands/onboarding/bootstrap.mjs +35 -0
- package/src/commands/onboarding/credentials.mjs +2 -25
- package/src/commands/onboarding/oauthLogin.mjs +284 -0
- package/src/core/chainRegistry.mjs +11 -0
- package/src/core/constants.mjs +0 -19
- package/src/core/context.mjs +16 -6
- package/src/core/errorEnvelope.mjs +6 -0
- package/src/core/http.mjs +47 -2
- package/src/core/oauth/authServer.mjs +192 -0
- package/src/core/oauth/pkce.mjs +17 -0
- package/src/core/oauth/session.mjs +117 -0
- package/src/lib/creditsUsdSummary.mjs +128 -29
- package/src/runtime/actions/selection.mjs +0 -1
- package/src/runtime/execution/payloads.mjs +6 -0
- package/src/runtime/execution/runners/createExecutionRunners.mjs +0 -3
- package/src/runtime/execution/runners/selfLane.mjs +6 -5
- package/src/runtime/handlers.mjs +1 -4
- package/src/runtime/main.mjs +0 -10
- package/src/runtime/owsSigning.mjs +17 -16
- package/src/runtime/providerHints/cex.mjs +1 -52
- package/src/runtime/bitfinexProbe.mjs +0 -121
- package/src/runtime/execution/runners/fallbackHints.mjs +0 -54
- 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 ? '✓' : '✕'}</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).
|
package/src/core/constants.mjs
CHANGED
|
@@ -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'];
|
package/src/core/context.mjs
CHANGED
|
@@ -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
|
|
125
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|