@thirdfy/agent-cli 0.4.3 → 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 +44 -1
- package/README.md +4 -4
- package/package.json +2 -2
- package/src/cli/help.mjs +1 -0
- package/src/cli/manifest.mjs +4 -4
- package/src/cli/options/index.mjs +2 -0
- package/src/cli/options/onboarding/credentials.mjs +1 -7
- 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/onboarding/bootstrap.mjs +35 -0
- package/src/commands/onboarding/credentials.mjs +0 -10
- package/src/commands/onboarding/oauthLogin.mjs +284 -0
- 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/execution/payloads.mjs +6 -0
- package/src/runtime/handlers.mjs +1 -0
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
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'crypto';
|
|
2
|
+
|
|
3
|
+
/** RFC 7636 PKCE (S256 only, as auth.thirdfy.com requires) and the OAuth `state` value. */
|
|
4
|
+
|
|
5
|
+
export function base64url(bytes) {
|
|
6
|
+
return Buffer.from(bytes).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createPkcePair() {
|
|
10
|
+
const verifier = base64url(randomBytes(32));
|
|
11
|
+
const challenge = base64url(createHash('sha256').update(verifier).digest());
|
|
12
|
+
return { verifier, challenge, method: 'S256' };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createState() {
|
|
16
|
+
return base64url(randomBytes(16));
|
|
17
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { DEFAULT_API_BASE } from '../constants.mjs';
|
|
2
|
+
import { createCliError } from '../envelope.mjs';
|
|
3
|
+
import { loadProfileConfig, persistProfileConfig } from '../context.mjs';
|
|
4
|
+
import { refreshTokens } from './authServer.mjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The OAuth sign-in `thirdfy-agent login` stores in `~/.thirdfy/config.json` (`auth.oauth`, mode
|
|
8
|
+
* 0600): one agent's access and refresh tokens from auth.thirdfy.com. It is the agent credential
|
|
9
|
+
* when no `--agent-api-key` or `THIRDFY_AGENT_API_KEY` is set. The HTTP layer sends it as
|
|
10
|
+
* `Authorization: Bearer` and refreshes it before it expires (rotation: each refresh returns a new
|
|
11
|
+
* refresh token).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** auth.thirdfy.com access tokens: `{userId}:{grantId}:{secret}`. Agent API keys and JWTs never match. */
|
|
15
|
+
const OAUTH_TOKEN_SHAPE = /^[^\s:]+:[^\s:]+:[^\s:]+$/;
|
|
16
|
+
|
|
17
|
+
/** Refresh when fewer than this many seconds are left, so a command never starts with a dying token. */
|
|
18
|
+
const REFRESH_MARGIN_SECONDS = 120;
|
|
19
|
+
|
|
20
|
+
export function isOAuthAccessToken(value) {
|
|
21
|
+
return OAUTH_TOKEN_SHAPE.test(String(value || '').trim());
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function readOAuthSession(config = loadProfileConfig()) {
|
|
25
|
+
const session = config?.auth?.oauth;
|
|
26
|
+
if (!session || typeof session !== 'object') return null;
|
|
27
|
+
if (!isOAuthAccessToken(session.accessToken)) return null;
|
|
28
|
+
return session;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const trimBase = (value) => String(value || '').trim().replace(/\/+$/, '');
|
|
32
|
+
|
|
33
|
+
/** The API base a command talks to: --api-base, then THIRDFY_API_BASE, then the default (as runtime/context). */
|
|
34
|
+
export function apiBaseFor(flags = {}, env = process.env) {
|
|
35
|
+
return trimBase(flags.apiBase || env.THIRDFY_API_BASE || DEFAULT_API_BASE);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The sign-in for this API. A sign-in is issued for one API (its `resource`): one for api-test is
|
|
40
|
+
* never sent to production, or the reverse.
|
|
41
|
+
*/
|
|
42
|
+
export function oauthSessionForApi(apiBase, config = loadProfileConfig()) {
|
|
43
|
+
const session = readOAuthSession(config);
|
|
44
|
+
return session && trimBase(session.resource) === trimBase(apiBase) ? session : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The OAuth sign-in acts as the agent unless an agent API key is passed or set in the environment,
|
|
49
|
+
* and only on the API it was issued for.
|
|
50
|
+
*/
|
|
51
|
+
export function oauthSessionIsAgentCredential(flags = {}, env = process.env, config = loadProfileConfig()) {
|
|
52
|
+
if (String(flags.agentApiKey || '').trim()) return false;
|
|
53
|
+
if (String(env.THIRDFY_AGENT_API_KEY || '').trim()) return false;
|
|
54
|
+
return Boolean(oauthSessionForApi(apiBaseFor(flags, env), config));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function writeOAuthSession(session) {
|
|
58
|
+
const config = loadProfileConfig();
|
|
59
|
+
const auth = config.auth && typeof config.auth === 'object' ? config.auth : {};
|
|
60
|
+
persistProfileConfig({ ...config, auth: { ...auth, oauth: session } });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function clearOAuthSession() {
|
|
64
|
+
const config = loadProfileConfig();
|
|
65
|
+
if (!config.auth?.oauth) return;
|
|
66
|
+
const { oauth: _removed, ...auth } = config.auth;
|
|
67
|
+
persistProfileConfig({ ...config, auth });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The stored session after a token response (code exchange or refresh). */
|
|
71
|
+
export function sessionFromTokens(base, tokens, nowSeconds = Math.floor(Date.now() / 1000)) {
|
|
72
|
+
const expiresIn = Number(tokens.expires_in);
|
|
73
|
+
return {
|
|
74
|
+
...base,
|
|
75
|
+
accessToken: String(tokens.access_token),
|
|
76
|
+
refreshToken: tokens.refresh_token ? String(tokens.refresh_token) : base.refreshToken || null,
|
|
77
|
+
expiresAt: Number.isFinite(expiresIn) && expiresIn > 0 ? nowSeconds + expiresIn : null,
|
|
78
|
+
scope: tokens.scope ? String(tokens.scope) : base.scope || '',
|
|
79
|
+
obtainedAt: nowSeconds,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let refreshInFlight = null;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The access token to send, refreshed first when it is about to expire (or when `force`, after the
|
|
87
|
+
* API answered 401). A refresh the auth server refuses (`invalid_grant`: revoked, or the owner no
|
|
88
|
+
* longer owns the agent) is `OAUTH_SESSION_EXPIRED`: sign in again.
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
export async function freshOAuthAccessToken({ timeoutMs, force = false } = {}) {
|
|
92
|
+
// One refresh at a time: the refresh token rotates, so a second refresh with the same token
|
|
93
|
+
// would be refused. Concurrent callers share the first one's result.
|
|
94
|
+
if (refreshInFlight) return refreshInFlight;
|
|
95
|
+
const session = readOAuthSession();
|
|
96
|
+
if (!session) return null;
|
|
97
|
+
const now = Math.floor(Date.now() / 1000);
|
|
98
|
+
const expiring = Number.isFinite(Number(session.expiresAt)) && Number(session.expiresAt) - now < REFRESH_MARGIN_SECONDS;
|
|
99
|
+
if (!force && !expiring) return session.accessToken;
|
|
100
|
+
if (!session.refreshToken || !session.tokenEndpoint || !session.clientId) {
|
|
101
|
+
throw createCliError('OAUTH_SESSION_EXPIRED', 'The sign-in expired. Sign in again: thirdfy-agent login', { blockedStage: 'auth' });
|
|
102
|
+
}
|
|
103
|
+
refreshInFlight = (async () => {
|
|
104
|
+
const tokens = await refreshTokens(
|
|
105
|
+
{ tokenEndpoint: session.tokenEndpoint, clientId: session.clientId, refreshToken: session.refreshToken, resource: session.resource },
|
|
106
|
+
timeoutMs || 30_000,
|
|
107
|
+
);
|
|
108
|
+
const next = sessionFromTokens(session, tokens);
|
|
109
|
+
writeOAuthSession(next);
|
|
110
|
+
return next.accessToken;
|
|
111
|
+
})();
|
|
112
|
+
try {
|
|
113
|
+
return await refreshInFlight;
|
|
114
|
+
} finally {
|
|
115
|
+
refreshInFlight = null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -1,54 +1,153 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Credits
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Credits are Thirdfy's internal ledger unit; people see a USD balance. The public docs say so
|
|
3
|
+
* (thirdfy-docs credits page: "Your balance shows in USD; credits are the internal unit") and never
|
|
4
|
+
* state what an action costs (DOCUMENTATION_GUIDE: no credit costs, no USD-per-credit rate). Owner
|
|
5
|
+
* rule 2026-10-15; same rules as thirdfy-mcp `usdFirstCredits.ts` (thirdfy/thirdfy-mcp#279).
|
|
6
|
+
*
|
|
7
|
+
* `credits balance` and `pricing quote` keep `availableUsd`, free request counts, `wouldBePaidBy` and
|
|
8
|
+
* `displayCurrency`. Every credit count, per-credit rate and per-action price is dropped, so a reader
|
|
9
|
+
* can say "$4.20 left" but not "286 credits" or "$0.18 a request". The owner's fallback balance, which
|
|
10
|
+
* the API sends in credits only, gets an `availableUsd` at the API's own `retailUsdPerCredit` (the rate
|
|
11
|
+
* the API uses for the agent's `availableUsd`). Buying keeps its USD amount (`credits buy --usd`).
|
|
5
12
|
*/
|
|
13
|
+
|
|
14
|
+
const KEEP_USD = new Set(['availableUsd']);
|
|
15
|
+
/** Credit counts the API names without "credit" (`totalAvailable`: the owner route's balance plus free requests). */
|
|
16
|
+
const CREDIT_COUNTS = new Set(['balance', 'totalEarned', 'totalSpent', 'totalAvailable']);
|
|
17
|
+
const CREDIT_KEY = /credit/i;
|
|
6
18
|
const USD_KEY = /usd/i;
|
|
7
19
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
20
|
+
const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
21
|
+
|
|
22
|
+
const finite = (value) => {
|
|
23
|
+
const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : NaN;
|
|
24
|
+
return Number.isFinite(n) ? n : undefined;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const roundUsd = (usd) => Math.round(usd * 100) / 100;
|
|
28
|
+
|
|
29
|
+
const formatUsd = (amount) => `$${roundUsd(amount).toFixed(2)}`;
|
|
30
|
+
|
|
31
|
+
/** The API's display rate wherever the payload carries it (top level or `data`). */
|
|
32
|
+
export function retailUsdPerCreditOf(value) {
|
|
33
|
+
if (!isRecord(value)) return undefined;
|
|
34
|
+
const own = finite(value.retailUsdPerCredit);
|
|
35
|
+
if (own !== undefined && own > 0) return own;
|
|
36
|
+
return isRecord(value.data) ? retailUsdPerCreditOf(value.data) : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A credits amount in USD at the API's rate, or undefined when either is missing. */
|
|
40
|
+
export function usdFromCredits(credits, rate) {
|
|
41
|
+
const amount = finite(credits);
|
|
42
|
+
return amount === undefined || rate === undefined ? undefined : roundUsd(amount * rate);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The value with balances in USD and no credit count, per-credit rate or per-action price, at any depth. */
|
|
46
|
+
export function toUsdFirst(value, rate = retailUsdPerCreditOf(value)) {
|
|
47
|
+
if (Array.isArray(value)) return value.map((item) => toUsdFirst(item, rate));
|
|
48
|
+
if (!isRecord(value)) return value;
|
|
12
49
|
const out = {};
|
|
13
50
|
for (const [key, inner] of Object.entries(value)) {
|
|
14
|
-
|
|
15
|
-
|
|
51
|
+
const scalar = !isRecord(inner) && !Array.isArray(inner);
|
|
52
|
+
if (CREDIT_KEY.test(key) && scalar) continue;
|
|
53
|
+
if (USD_KEY.test(key) && !KEEP_USD.has(key)) continue;
|
|
54
|
+
if (CREDIT_COUNTS.has(key) && typeof inner === 'number') continue;
|
|
55
|
+
// The owner route's ledger unit (`currency: 'credits'`): the balance is shown in USD.
|
|
56
|
+
if (key === 'currency' && typeof inner === 'string' && CREDIT_KEY.test(inner)) continue;
|
|
57
|
+
out[key] = toUsdFirst(inner, rate);
|
|
58
|
+
}
|
|
59
|
+
// A balance the API sends only in credits (the owner's fallback) is shown in USD.
|
|
60
|
+
if (out.availableUsd === undefined && finite(value.balanceCredits) !== undefined) {
|
|
61
|
+
const usd = usdFromCredits(value.balanceCredits, rate);
|
|
62
|
+
if (usd !== undefined) out.availableUsd = usd;
|
|
16
63
|
}
|
|
17
64
|
return out;
|
|
18
65
|
}
|
|
19
66
|
|
|
20
67
|
const isCount = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
21
68
|
|
|
69
|
+
/** The balance route's fields: in `data` (`{ success, data }`) or at the top. */
|
|
70
|
+
function balanceFields(payload) {
|
|
71
|
+
if (!isRecord(payload)) return undefined;
|
|
72
|
+
const inner = payload.data;
|
|
73
|
+
if (isRecord(inner) && ('freeDailyLimit' in inner || 'freeAllowance' in inner || 'availableUsd' in inner || 'balanceCredits' in inner)) return inner;
|
|
74
|
+
return payload;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The balance in USD: `availableUsd`, else the credits balance at the API's rate. */
|
|
78
|
+
function balanceUsd(payload) {
|
|
79
|
+
const fields = balanceFields(payload);
|
|
80
|
+
if (!fields) return undefined;
|
|
81
|
+
if (isCount(fields.availableUsd)) return fields.availableUsd;
|
|
82
|
+
return usdFromCredits(fields.balanceCredits ?? fields.balance, retailUsdPerCreditOf(payload));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Who pays next: the agent's part (or null) and the rest, in billing order. Undefined for an older API. */
|
|
86
|
+
function freeQuotaParts(payload) {
|
|
87
|
+
const fields = balanceFields(payload);
|
|
88
|
+
if (!fields) return undefined;
|
|
89
|
+
const { ownerCreditsFallback, balanceCredits } = fields;
|
|
90
|
+
// The agent route says freeDailyLimit / freeRequestsRemainingToday; the owner route freeAllowance / freeRemainingToday.
|
|
91
|
+
const freeDailyLimit = fields.freeDailyLimit ?? fields.freeAllowance;
|
|
92
|
+
const freeRequestsRemainingToday = fields.freeRequestsRemainingToday ?? fields.freeRemainingToday;
|
|
93
|
+
if (!isCount(freeDailyLimit) || !isCount(freeRequestsRemainingToday)) return undefined;
|
|
94
|
+
const rate = retailUsdPerCreditOf(payload);
|
|
95
|
+
const agentUsd = isCount(fields.availableUsd) ? fields.availableUsd : usdFromCredits(balanceCredits, rate);
|
|
96
|
+
let agent = null;
|
|
97
|
+
if (isCount(agentUsd) && agentUsd > 0) agent = `The agent's balance (${formatUsd(agentUsd)}) pays first`;
|
|
98
|
+
else if (isCount(balanceCredits) && balanceCredits > 0) agent = "The agent's balance pays first";
|
|
99
|
+
const rest = [`${freeRequestsRemainingToday} of ${freeDailyLimit} free requests left today`];
|
|
100
|
+
if (isRecord(ownerCreditsFallback) && ownerCreditsFallback.enabled === true) {
|
|
101
|
+
const ownerUsd = usdFromCredits(ownerCreditsFallback.balanceCredits, rate);
|
|
102
|
+
rest.push(isCount(ownerUsd) ? `then the owner's balance (${formatUsd(ownerUsd)}) pays` : "then the owner's balance pays");
|
|
103
|
+
}
|
|
104
|
+
rest.push('then top up the balance');
|
|
105
|
+
return { agent, rest };
|
|
106
|
+
}
|
|
107
|
+
|
|
22
108
|
/**
|
|
23
109
|
* Who pays next for the agent's actions, from the agent credits route's fields (thirdfy-api-v2#884):
|
|
24
110
|
* `freeRequestsRemainingToday` of `freeDailyLimit` (free requests per UTC day) and
|
|
25
|
-
* `ownerCreditsFallback` (the owner's
|
|
26
|
-
* used). Billing order: agent's
|
|
27
|
-
* thirdfy-mcp `creditsFreeQuotaNote` (#
|
|
111
|
+
* `ownerCreditsFallback` (the owner's balance pays once the agent's balance and free requests are
|
|
112
|
+
* used). Billing order: agent's balance, free requests, owner's balance, refusal. Amounts in USD,
|
|
113
|
+
* never credits; same wording as thirdfy-mcp `creditsFreeQuotaNote` (#279). Undefined when an older
|
|
114
|
+
* API omits the fields.
|
|
28
115
|
*/
|
|
29
116
|
export function creditsFreeQuotaNote(payload) {
|
|
30
|
-
const
|
|
31
|
-
if (!
|
|
32
|
-
|
|
33
|
-
if (!isCount(freeDailyLimit) || !isCount(freeRequestsRemainingToday)) return undefined;
|
|
34
|
-
const parts = [];
|
|
35
|
-
if (isCount(balanceCredits) && balanceCredits > 0) parts.push(`The agent's credits (${balanceCredits}) pay first`);
|
|
36
|
-
parts.push(`${freeRequestsRemainingToday} of ${freeDailyLimit} free requests left today`);
|
|
37
|
-
if (ownerCreditsFallback && typeof ownerCreditsFallback === 'object' && ownerCreditsFallback.enabled === true) {
|
|
38
|
-
const owner = ownerCreditsFallback.balanceCredits;
|
|
39
|
-
parts.push(isCount(owner) ? `then the owner's credits (${owner}) pay` : "then the owner's credits pay");
|
|
40
|
-
}
|
|
41
|
-
parts.push('then buy credits');
|
|
42
|
-
return `${parts.join('; ')}.`;
|
|
117
|
+
const parts = freeQuotaParts(payload);
|
|
118
|
+
if (!parts) return undefined;
|
|
119
|
+
return `${[...(parts.agent ? [parts.agent] : []), ...parts.rest].join('; ')}.`;
|
|
43
120
|
}
|
|
44
121
|
|
|
45
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* The balance in dollars, never credits, with who pays next when the API says, for example
|
|
124
|
+
* "$4.20 left; 8 of 20 free requests left today; then the owner's balance ($86.22) pays; then top up the balance."
|
|
125
|
+
*/
|
|
46
126
|
export function formatCreditsBalanceSummary(response) {
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
|
|
127
|
+
const usd = balanceUsd(response);
|
|
128
|
+
const lead = usd === undefined ? 'balance in USD not reported by this API' : `${formatUsd(usd)} left`;
|
|
129
|
+
const parts = freeQuotaParts(response);
|
|
130
|
+
return `${[lead, ...(parts ? parts.rest : [])].join('; ')}.`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const PAYER_LABELS = {
|
|
134
|
+
agent_credits: "the agent's balance",
|
|
135
|
+
free_request: 'a free request',
|
|
136
|
+
owner_credits: "the owner's balance",
|
|
137
|
+
none: 'nobody: it would be refused until the balance is topped up',
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/** `wouldBePaidBy` in words, with the API's value in parentheses. Never a price. */
|
|
141
|
+
export function describeWouldBePaidBy(wouldBePaidBy) {
|
|
142
|
+
const raw = String(wouldBePaidBy || '').trim();
|
|
143
|
+
if (!raw) return undefined;
|
|
144
|
+
return PAYER_LABELS[raw] ? `${PAYER_LABELS[raw]} (${raw})` : raw;
|
|
50
145
|
}
|
|
51
146
|
|
|
147
|
+
/**
|
|
148
|
+
* `models list` / `models estimate` are owner model-pricing reports (owner auth only) and are left as
|
|
149
|
+
* they were by the USD-first rule, as thirdfy-mcp left its owner-token credits tools.
|
|
150
|
+
*/
|
|
52
151
|
export function formatCreditsModelsSummary(response) {
|
|
53
152
|
const firstModel = response?.models?.[0];
|
|
54
153
|
if (firstModel?.estimatedUsdPerTurn != null) {
|