@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,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) {
|
|
@@ -222,7 +222,6 @@ function inferActionProvider(action) {
|
|
|
222
222
|
if (key.includes('lighter')) return 'lighter';
|
|
223
223
|
if (key.includes('avantis') && key.includes('vault')) return 'avantis-vault';
|
|
224
224
|
if (key.includes('avantis')) return 'avantis';
|
|
225
|
-
if (key.includes('bitfinex')) return 'bitfinex';
|
|
226
225
|
if (key.includes('polymarket') || key.includes('prediction')) return 'polymarket';
|
|
227
226
|
if (key.includes('vaults-fyi')) return 'vaults-fyi';
|
|
228
227
|
if (key.includes('yield-xyz')) return 'yield-xyz';
|
|
@@ -3,6 +3,7 @@ import { wrapWriteIdempotencyKeyForHostedRuntime } from '../../core/execution/ho
|
|
|
3
3
|
import { resolveExecutionContextFromEnv } from '../../core/execution/executionContext.mjs';
|
|
4
4
|
import { requireFlag, parseJsonFlag } from '../../core/args.mjs';
|
|
5
5
|
import { findAgentApiKey, loadProfileConfig } from '../../core/context.mjs';
|
|
6
|
+
import { apiBaseFor, oauthSessionForApi, oauthSessionIsAgentCredential } from '../../core/oauth/session.mjs';
|
|
6
7
|
import { createCliError } from '../../core/envelope.mjs';
|
|
7
8
|
import { toLegacyVenueActionKey } from '../../core/venueBrand.mjs';
|
|
8
9
|
import { normalizeRunMode, normalizeManagedRunMode, normalizeHybridWalletMode } from '../../core/runMode.mjs';
|
|
@@ -62,6 +63,11 @@ function buildBuildTxPayload(flags, resolvedAction, apiAction) {
|
|
|
62
63
|
function resolveAgentKey(flags) {
|
|
63
64
|
const explicit = String(flags.agentKey || '').trim();
|
|
64
65
|
if (explicit) return explicit.toLowerCase();
|
|
66
|
+
// With an OAuth sign-in as the agent credential, the agent is the one the owner picked for it.
|
|
67
|
+
if (oauthSessionIsAgentCredential(flags)) {
|
|
68
|
+
const oauthAgent = String(oauthSessionForApi(apiBaseFor(flags))?.agentKey || '').trim();
|
|
69
|
+
if (oauthAgent) return oauthAgent.toLowerCase();
|
|
70
|
+
}
|
|
65
71
|
const envKey = String(process.env.THIRDFY_AGENT_KEY || process.env.AGENT_KEY || '').trim();
|
|
66
72
|
if (envKey) return envKey.toLowerCase();
|
|
67
73
|
const config = loadProfileConfig();
|
|
@@ -3,7 +3,6 @@ import { normalizeHybridWalletMode } from '../../../core/runMode.mjs';
|
|
|
3
3
|
import { apiPost } from '../../../core/http.mjs';
|
|
4
4
|
import { actionNameForApi } from '../../actions/selection.mjs';
|
|
5
5
|
import { isReadOnlyAction } from './managedResponse.mjs';
|
|
6
|
-
import { createFallbackHints } from './fallbackHints.mjs';
|
|
7
6
|
import { createManagedPreflight } from './managedPreflight.mjs';
|
|
8
7
|
import { createThirdfyLane } from './thirdfyLane.mjs';
|
|
9
8
|
import { createSelfLane } from './selfLane.mjs';
|
|
@@ -20,7 +19,6 @@ export function createExecutionRunners(deps) {
|
|
|
20
19
|
buildIntentPayload,
|
|
21
20
|
shouldUseBuildTxPreflight,
|
|
22
21
|
} = deps;
|
|
23
|
-
const { applyExecutionFallbackHints } = createFallbackHints(deps);
|
|
24
22
|
const { shouldUseManagedReadFastPath, resolveManagedExecutionPreflight } = createManagedPreflight(deps);
|
|
25
23
|
const { executeThirdfyRun, runReadOnlyActionViaIntentRail } = createThirdfyLane(deps);
|
|
26
24
|
const { executeSelfRun, performSelfBroadcast, resolveEffectCheckResult, submitSignedTx } = createSelfLane(deps, { runReadOnlyActionViaIntentRail });
|
|
@@ -300,7 +298,6 @@ async function runExecutionByMode(runMode, ctx, flags, resolved, options) {
|
|
|
300
298
|
}
|
|
301
299
|
|
|
302
300
|
return {
|
|
303
|
-
applyExecutionFallbackHints,
|
|
304
301
|
runPreflightByMode,
|
|
305
302
|
runExecutionByMode,
|
|
306
303
|
executeThirdfyRun,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createCliError } from '../../../core/envelope.mjs';
|
|
2
2
|
import { normalizeEffectCheckMode } from '../../../core/runMode.mjs';
|
|
3
3
|
import { apiPost } from '../../../core/http.mjs';
|
|
4
|
-
import { resolveChainRpcUrl } from '../../../core/chainRegistry.mjs';
|
|
4
|
+
import { resolveChainRpcUrl, pinnedRpcProvider } from '../../../core/chainRegistry.mjs';
|
|
5
5
|
import { actionNameForApi } from '../../actions/selection.mjs';
|
|
6
6
|
import { isReadOnlyAction } from './managedResponse.mjs';
|
|
7
7
|
import { createRequire } from 'module';
|
|
@@ -103,7 +103,7 @@ async function performSelfBroadcast(ctx, flags, resolvedAction, selfResult) {
|
|
|
103
103
|
|
|
104
104
|
let balancesBefore = null;
|
|
105
105
|
if (effectMode !== 'off' && effectTokenIn && effectTokenOut) {
|
|
106
|
-
balancesBefore = await readEffectBalances(rpcUrl, walletAddress, effectTokenIn, effectTokenOut);
|
|
106
|
+
balancesBefore = await readEffectBalances(rpcUrl, walletAddress, effectTokenIn, effectTokenOut, chainId);
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
let submission;
|
|
@@ -136,6 +136,7 @@ async function performSelfBroadcast(ctx, flags, resolvedAction, selfResult) {
|
|
|
136
136
|
const effectCheck = await resolveEffectCheckResult({
|
|
137
137
|
mode: effectMode,
|
|
138
138
|
rpcUrl,
|
|
139
|
+
chainId,
|
|
139
140
|
walletAddress,
|
|
140
141
|
tokenIn: effectTokenIn,
|
|
141
142
|
tokenOut: effectTokenOut,
|
|
@@ -168,6 +169,7 @@ async function performSelfBroadcast(ctx, flags, resolvedAction, selfResult) {
|
|
|
168
169
|
async function resolveEffectCheckResult({
|
|
169
170
|
mode,
|
|
170
171
|
rpcUrl,
|
|
172
|
+
chainId,
|
|
171
173
|
walletAddress,
|
|
172
174
|
tokenIn,
|
|
173
175
|
tokenOut,
|
|
@@ -193,7 +195,7 @@ async function resolveEffectCheckResult({
|
|
|
193
195
|
const maxAttempts = Math.max(1, Number(retries || 0) + 1);
|
|
194
196
|
let last = null;
|
|
195
197
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
196
|
-
const balancesAfter = await readEffectBalances(rpcUrl, walletAddress, tokenIn, tokenOut);
|
|
198
|
+
const balancesAfter = await readEffectBalances(rpcUrl, walletAddress, tokenIn, tokenOut, chainId);
|
|
197
199
|
const inDeltaRaw = (BigInt(balancesBefore.tokenIn) - BigInt(balancesAfter.tokenIn)).toString();
|
|
198
200
|
const outDeltaRaw = (BigInt(balancesAfter.tokenOut) - BigInt(balancesBefore.tokenOut)).toString();
|
|
199
201
|
const hasEffect = BigInt(inDeltaRaw) > 0n || BigInt(outDeltaRaw) > 0n;
|
|
@@ -217,8 +219,7 @@ async function resolveEffectCheckResult({
|
|
|
217
219
|
|
|
218
220
|
async function submitSignedTx({ rpcUrl, chainId, signedTxHex }) {
|
|
219
221
|
const ethers = await loadEthers();
|
|
220
|
-
const {
|
|
221
|
-
const provider = new JsonRpcProvider(rpcUrl, chainId);
|
|
222
|
+
const provider = pinnedRpcProvider(ethers, { rpcUrl, chainId });
|
|
222
223
|
const sent = await provider.broadcastTransaction(String(signedTxHex));
|
|
223
224
|
return sent?.hash || null;
|
|
224
225
|
}
|
package/src/runtime/handlers.mjs
CHANGED
|
@@ -35,7 +35,6 @@ export function createRuntimeHandlers(deps) {
|
|
|
35
35
|
maskSecret,
|
|
36
36
|
getAuthToken,
|
|
37
37
|
buildOwnerAuthHeaders,
|
|
38
|
-
probeBitfinexPermissions,
|
|
39
38
|
applyActionFilters,
|
|
40
39
|
getProviderHintForActions,
|
|
41
40
|
resolveChainSupport,
|
|
@@ -51,7 +50,6 @@ export function createRuntimeHandlers(deps) {
|
|
|
51
50
|
submitSignedTx,
|
|
52
51
|
runPreflightByMode,
|
|
53
52
|
runExecutionByMode,
|
|
54
|
-
applyExecutionFallbackHints,
|
|
55
53
|
performSelfBroadcast,
|
|
56
54
|
resolveManagedExecutionPreflight,
|
|
57
55
|
resolveEffectiveUserDid,
|
|
@@ -97,7 +95,6 @@ export function createRuntimeHandlers(deps) {
|
|
|
97
95
|
printEnvelope,
|
|
98
96
|
getAuthToken,
|
|
99
97
|
buildOwnerAuthHeaders,
|
|
100
|
-
probeBitfinexPermissions,
|
|
101
98
|
});
|
|
102
99
|
|
|
103
100
|
const __walletControl = createWalletControlCommands({ printEnvelope, buildOwnerAuthHeaders });
|
|
@@ -173,7 +170,6 @@ export function createRuntimeHandlers(deps) {
|
|
|
173
170
|
runPreflightByMode,
|
|
174
171
|
runExecutionByMode,
|
|
175
172
|
resolveManagedExecutionPreflight,
|
|
176
|
-
applyExecutionFallbackHints,
|
|
177
173
|
performSelfBroadcast,
|
|
178
174
|
executeManagedWalletRun,
|
|
179
175
|
executeSelfRun,
|
|
@@ -222,6 +218,7 @@ export function createRuntimeHandlers(deps) {
|
|
|
222
218
|
resolveEffectiveUserDid,
|
|
223
219
|
negotiateCapabilities,
|
|
224
220
|
commandAgentRegister: __agent.commandAgentRegister,
|
|
221
|
+
CLI_VERSION,
|
|
225
222
|
});
|
|
226
223
|
|
|
227
224
|
return {
|
package/src/runtime/main.mjs
CHANGED
|
@@ -16,7 +16,6 @@ import { createInitialRuntimeContext, applyFlagsToRuntimeContext } from './conte
|
|
|
16
16
|
import { createProviderHints } from './providerHints/dispatch.mjs';
|
|
17
17
|
import { createRuntimeHandlers } from './handlers.mjs';
|
|
18
18
|
import { isCommanderCliError } from './errors.mjs';
|
|
19
|
-
import { createOnboardingHints } from './onboardingHints.mjs';
|
|
20
19
|
import { walletControlErrorFields } from './execution/walletControl.mjs';
|
|
21
20
|
import { createAgentExtract } from './agentExtract.mjs';
|
|
22
21
|
import { createAuthHelpers } from './authHelpers.mjs';
|
|
@@ -25,7 +24,6 @@ import { createExecutionLanes } from './execution/lanes.mjs';
|
|
|
25
24
|
import { createExecutionPayloads } from './execution/payloads.mjs';
|
|
26
25
|
import { createExecutionRunners } from './execution/runners/createExecutionRunners.mjs';
|
|
27
26
|
import { createActionSelection } from './actions/selection.mjs';
|
|
28
|
-
import { createBitfinexProbe } from './bitfinexProbe.mjs';
|
|
29
27
|
import { createOwsSigning } from './owsSigning.mjs';
|
|
30
28
|
import { enforceCatalogActionParams } from './validation/actionParamsGate.mjs';
|
|
31
29
|
|
|
@@ -66,7 +64,6 @@ export function createThirdfyAgentRuntime() {
|
|
|
66
64
|
providerHints;
|
|
67
65
|
|
|
68
66
|
const { extractAgentApiKey, extractAgentKey, maskSecret } = createAgentExtract();
|
|
69
|
-
const onboarding = createOnboardingHints();
|
|
70
67
|
const amounts = createExecutionAmounts();
|
|
71
68
|
const lanes = createExecutionLanes();
|
|
72
69
|
const auth = createAuthHelpers();
|
|
@@ -82,7 +79,6 @@ export function createThirdfyAgentRuntime() {
|
|
|
82
79
|
const catalogActionParamsGate = (resolved, flags) =>
|
|
83
80
|
enforceCatalogActionParams(resolved, flags, amounts.getPreparedParams);
|
|
84
81
|
const ows = createOwsSigning();
|
|
85
|
-
const bitfinex = createBitfinexProbe();
|
|
86
82
|
const runners = createExecutionRunners({
|
|
87
83
|
buildIntentPayload: payloads.buildIntentPayload,
|
|
88
84
|
buildBuildTxPayload: payloads.buildBuildTxPayload,
|
|
@@ -92,8 +88,6 @@ export function createThirdfyAgentRuntime() {
|
|
|
92
88
|
resolveTokenInForFunding: payloads.resolveTokenInForFunding,
|
|
93
89
|
shouldUseBuildTxPreflight: lanes.shouldUseBuildTxPreflight,
|
|
94
90
|
shouldUseBuildTxExecution: lanes.shouldUseBuildTxExecution,
|
|
95
|
-
buildBitfinexOnboardingCommand: onboarding.buildBitfinexOnboardingCommand,
|
|
96
|
-
inferBitfinexPermissionProfileFromAction: onboarding.inferBitfinexPermissionProfileFromAction,
|
|
97
91
|
resolveAgentApiKey: payloads.resolveAgentApiKey,
|
|
98
92
|
resolveAgentKey: payloads.resolveAgentKey,
|
|
99
93
|
loadEthers: ows.loadEthers,
|
|
@@ -111,7 +105,6 @@ export function createThirdfyAgentRuntime() {
|
|
|
111
105
|
maskSecret,
|
|
112
106
|
getAuthToken: auth.getAuthToken,
|
|
113
107
|
buildOwnerAuthHeaders: auth.buildOwnerAuthHeaders,
|
|
114
|
-
probeBitfinexPermissions: bitfinex.probeBitfinexPermissions,
|
|
115
108
|
applyActionFilters: actions.applyActionFilters,
|
|
116
109
|
getProviderHintForActions,
|
|
117
110
|
resolveChainSupport: lanes.resolveChainSupport,
|
|
@@ -127,7 +120,6 @@ export function createThirdfyAgentRuntime() {
|
|
|
127
120
|
submitSignedTx: runners.submitSignedTx,
|
|
128
121
|
runPreflightByMode: runners.runPreflightByMode,
|
|
129
122
|
runExecutionByMode: runners.runExecutionByMode,
|
|
130
|
-
applyExecutionFallbackHints: runners.applyExecutionFallbackHints,
|
|
131
123
|
performSelfBroadcast: runners.performSelfBroadcast,
|
|
132
124
|
resolveManagedExecutionPreflight: runners.resolveManagedExecutionPreflight,
|
|
133
125
|
resolveEffectiveUserDid: payloads.resolveEffectiveUserDid,
|
|
@@ -195,7 +187,6 @@ export function createThirdfyAgentRuntime() {
|
|
|
195
187
|
const blockedReason = String(payload.blockedReason || payload.error || '').trim() || undefined;
|
|
196
188
|
const blockedStage = String(payload.blockedStage || '').trim() || undefined;
|
|
197
189
|
const clientCode = String(payload.error || payload.blockedReason || '').trim() || undefined;
|
|
198
|
-
const onboardingHint = onboarding.buildTopLevelOnboardingHint(payload);
|
|
199
190
|
printEnvelope({
|
|
200
191
|
success: false,
|
|
201
192
|
// A missing or malformed flag is INVALID_ARGUMENTS at the top too, not an unhandled error.
|
|
@@ -209,7 +200,6 @@ export function createThirdfyAgentRuntime() {
|
|
|
209
200
|
field: payload.field || null,
|
|
210
201
|
resolvedAction: payload.resolvedAction || null,
|
|
211
202
|
requestedAction: payload.requestedAction || null,
|
|
212
|
-
onboardingHint,
|
|
213
203
|
// errorCode, retryable, retryAfterSeconds, nextAction, hint: as thirdfy-mcp's error envelope.
|
|
214
204
|
...describeCliError(error),
|
|
215
205
|
// API 4.0 wallet-control refusals: walletControl, nextAction, userMessage, retryable, hint.
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
import { spawnSync } from 'child_process';
|
|
2
2
|
import fs from 'fs';
|
|
3
|
+
import { createRequire } from 'module';
|
|
3
4
|
import os from 'os';
|
|
4
5
|
import path from 'path';
|
|
6
|
+
import { pinnedRpcProvider } from '../core/chainRegistry.mjs';
|
|
5
7
|
import { createCliError } from '../core/envelope.mjs';
|
|
8
|
+
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
6
10
|
export function createOwsSigning() {
|
|
11
|
+
// ethers is a dependency of this package; resolve it from here, never from the caller's directory.
|
|
7
12
|
async function loadEthers() {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} catch {
|
|
13
|
-
// continue
|
|
14
|
-
}
|
|
13
|
+
try {
|
|
14
|
+
return require('ethers');
|
|
15
|
+
} catch {
|
|
16
|
+
throw new Error('Could not load the ethers dependency of @thirdfy/agent-cli; reinstall the package');
|
|
15
17
|
}
|
|
16
|
-
throw new Error('Could not load ethers dependency for self-exec');
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
function resolveOwsBinary() {
|
|
@@ -62,8 +63,8 @@ function signUnsignedTxWithOws(unsignedHex, walletName, chainId) {
|
|
|
62
63
|
|
|
63
64
|
async function signAndBroadcastWithOws({ txd, chainId, walletAddress, owsWalletName, rpcUrl }) {
|
|
64
65
|
const ethers = await loadEthers();
|
|
65
|
-
const {
|
|
66
|
-
const provider =
|
|
66
|
+
const { Transaction, Signature } = ethers;
|
|
67
|
+
const provider = pinnedRpcProvider(ethers, { rpcUrl, chainId });
|
|
67
68
|
const nonce = await provider.getTransactionCount(walletAddress, 'pending');
|
|
68
69
|
const fee = await provider.getFeeData();
|
|
69
70
|
const txFields = {
|
|
@@ -86,10 +87,10 @@ async function signAndBroadcastWithOws({ txd, chainId, walletAddress, owsWalletN
|
|
|
86
87
|
return { sent };
|
|
87
88
|
}
|
|
88
89
|
|
|
89
|
-
async function readTokenBalanceRaw(rpcUrl, walletAddress, tokenAddress) {
|
|
90
|
+
async function readTokenBalanceRaw(rpcUrl, walletAddress, tokenAddress, chainId) {
|
|
90
91
|
const ethers = await loadEthers();
|
|
91
|
-
const {
|
|
92
|
-
const provider =
|
|
92
|
+
const { Interface } = ethers;
|
|
93
|
+
const provider = pinnedRpcProvider(ethers, { rpcUrl, chainId });
|
|
93
94
|
const erc20 = new Interface(['function balanceOf(address owner) view returns (uint256)']);
|
|
94
95
|
const response = await provider.call({
|
|
95
96
|
to: tokenAddress,
|
|
@@ -99,10 +100,10 @@ async function readTokenBalanceRaw(rpcUrl, walletAddress, tokenAddress) {
|
|
|
99
100
|
return BigInt(decoded[0].toString()).toString();
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
async function readEffectBalances(rpcUrl, walletAddress, tokenIn, tokenOut) {
|
|
103
|
+
async function readEffectBalances(rpcUrl, walletAddress, tokenIn, tokenOut, chainId) {
|
|
103
104
|
const [inBal, outBal] = await Promise.all([
|
|
104
|
-
readTokenBalanceRaw(rpcUrl, walletAddress, tokenIn),
|
|
105
|
-
readTokenBalanceRaw(rpcUrl, walletAddress, tokenOut),
|
|
105
|
+
readTokenBalanceRaw(rpcUrl, walletAddress, tokenIn, chainId),
|
|
106
|
+
readTokenBalanceRaw(rpcUrl, walletAddress, tokenOut, chainId),
|
|
106
107
|
]);
|
|
107
108
|
return { tokenIn: inBal, tokenOut: outBal };
|
|
108
109
|
}
|