@thirdfy/agent-cli 0.3.0 → 0.4.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 +110 -0
- package/README.md +60 -18
- package/package.json +2 -2
- package/scripts/validate-cli-contract-schemas.mjs +5 -0
- package/src/cli/errors.mjs +58 -8
- package/src/cli/manifest.mjs +54 -5
- package/src/cli/options/bootstrap.mjs +15 -3
- package/src/cli/options/chains.mjs +5 -0
- package/src/cli/options/credits.mjs +9 -0
- package/src/cli/options/data.mjs +13 -1
- package/src/cli/options/index.mjs +10 -4
- package/src/cli/options/walletControl.mjs +8 -0
- package/src/commands/bootstrap.mjs +44 -5
- package/src/commands/chat.mjs +3 -0
- package/src/commands/credits.mjs +39 -4
- package/src/commands/delegation.mjs +3 -1
- package/src/commands/discovery.mjs +156 -13
- package/src/commands/doctor.mjs +5 -2
- package/src/commands/execute.mjs +96 -3
- package/src/commands/prompt.mjs +5 -3
- package/src/commands/wallet.mjs +119 -3
- package/src/commands/walletControl.mjs +66 -0
- package/src/contracts/cli/schemas/actions.response.schema.json +45 -0
- package/src/contracts/cli/schemas/chains-non-evm.response.schema.json +76 -0
- package/src/contracts/cli/schemas/chains.response.schema.json +151 -0
- package/src/contracts/cli/schemas/error.response.schema.json +61 -0
- package/src/contracts/cli/schemas/preview.response.schema.json +179 -0
- package/src/contracts/cli/schemas/readiness.response.schema.json +94 -0
- package/src/core/args.mjs +10 -2
- package/src/core/chainRegistry.mjs +223 -1
- package/src/core/context.mjs +37 -14
- package/src/core/errorEnvelope.mjs +209 -0
- package/src/core/fundingTokenIn.mjs +24 -1
- package/src/core/http.mjs +6 -1
- package/src/core/readOnlyActions.mjs +10 -0
- package/src/core/walletExecuteChainId.mjs +25 -0
- package/src/lib/creditsUsdSummary.mjs +48 -4
- package/src/runtime/actions/actionList.mjs +87 -0
- package/src/runtime/actions/selection.mjs +17 -22
- package/src/runtime/execution/actionPreflight.mjs +50 -14
- package/src/runtime/execution/payloads.mjs +36 -28
- package/src/runtime/execution/runners.mjs +75 -21
- package/src/runtime/execution/svmLane.mjs +106 -0
- package/src/runtime/execution/walletControl.mjs +104 -0
- package/src/runtime/handlers.mjs +16 -2
- package/src/runtime/index.mjs +1 -1
- package/src/runtime/main.mjs +21 -5
- package/src/runtime/providerHints/apps.mjs +36 -0
- package/src/runtime/providerHints/catalogHint.mjs +55 -0
- package/src/runtime/providerHints/cex.mjs +130 -0
- package/src/runtime/providerHints/data.mjs +144 -0
- package/src/runtime/providerHints/dispatch.mjs +177 -0
- package/src/runtime/providerHints/dogeos.mjs +38 -0
- package/src/runtime/providerHints/earn.mjs +201 -0
- package/src/runtime/providerHints/perps.mjs +346 -0
- package/src/runtime/providerHints/prediction.mjs +128 -0
- package/src/runtime/providerHints/solana.mjs +85 -0
- package/src/runtime/providerHints/trading.mjs +78 -0
- package/src/runtime/providerHints/walletControl.mjs +39 -0
- package/src/runtime/risk/notional.mjs +59 -0
- package/src/runtime/risk/orderPreview.mjs +154 -0
- package/src/runtime/risk/riskRefusal.mjs +74 -0
- package/src/runtime/validation/actionParamsGate.mjs +2 -0
- package/src/runtime/providerHints.mjs +0 -1017
|
@@ -1,8 +1,52 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Credits balances and action costs are shown in credits, never as a dollar price per credit or per
|
|
3
|
+
* request (owner decision 2026-10-14; thirdfy-mcp #252 `stripUsdFields`). Buying credits keeps its USD
|
|
4
|
+
* amount (`credits buy --usd`): that is a purchase, not a request price.
|
|
5
|
+
*/
|
|
6
|
+
const USD_KEY = /usd/i;
|
|
7
|
+
|
|
8
|
+
/** The value with every key that names USD (and displayCurrency / pricingCurrency) removed, at any depth. */
|
|
9
|
+
export function stripUsdFields(value) {
|
|
10
|
+
if (Array.isArray(value)) return value.map(stripUsdFields);
|
|
11
|
+
if (!value || typeof value !== 'object') return value;
|
|
12
|
+
const out = {};
|
|
13
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
14
|
+
if (USD_KEY.test(key) || key === 'displayCurrency' || key === 'pricingCurrency') continue;
|
|
15
|
+
out[key] = stripUsdFields(inner);
|
|
4
16
|
}
|
|
5
|
-
return
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const isCount = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Who pays next for the agent's actions, from the agent credits route's fields (thirdfy-api-v2#884):
|
|
24
|
+
* `freeRequestsRemainingToday` of `freeDailyLimit` (free requests per UTC day) and
|
|
25
|
+
* `ownerCreditsFallback` (the owner's credits pay once the agent's credits and free requests are
|
|
26
|
+
* used). Billing order: agent's credits, free requests, owner's credits, refusal. Same wording as
|
|
27
|
+
* thirdfy-mcp `creditsFreeQuotaNote` (#253). Undefined when an older API omits the fields.
|
|
28
|
+
*/
|
|
29
|
+
export function creditsFreeQuotaNote(payload) {
|
|
30
|
+
const fields = payload?.data && typeof payload.data === 'object' && 'freeDailyLimit' in payload.data ? payload.data : payload;
|
|
31
|
+
if (!fields || typeof fields !== 'object') return undefined;
|
|
32
|
+
const { freeDailyLimit, freeRequestsRemainingToday, ownerCreditsFallback, balanceCredits } = fields;
|
|
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('; ')}.`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The balance in credits only, with who pays next when the API says. */
|
|
46
|
+
export function formatCreditsBalanceSummary(response) {
|
|
47
|
+
const credits = `${response?.balance ?? response?.balanceCredits ?? 0} credits`;
|
|
48
|
+
const note = creditsFreeQuotaNote(response);
|
|
49
|
+
return note ? `${credits}. ${note}` : credits;
|
|
6
50
|
}
|
|
7
51
|
|
|
8
52
|
export function formatCreditsModelsSummary(response) {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { usageError } from '../../core/args.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Search, paging and views for `actions`, as thirdfy-mcp `getActionsCatalog` / `getAgentActions` page
|
|
5
|
+
* the list (`src/catalog/actionListFilter.ts`, `src/catalog/actionListView.ts`). The catalog is
|
|
6
|
+
* hundreds of actions with schemas; `--search`, `--limit`, `--offset` and `--view` return one page.
|
|
7
|
+
* Without any of them, `actions` prints the whole list as before.
|
|
8
|
+
* `--provider` and `--chain-id` stay the CLI's own filters (src/runtime/actions/selection.mjs).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_PAGE_SIZE = Object.freeze({ concise: 50, detailed: 10 });
|
|
12
|
+
/** The largest page each view returns, whatever `--limit` asks (thirdfy-mcp MAX_PAGE_SIZE). */
|
|
13
|
+
export const MAX_PAGE_SIZE = Object.freeze({ concise: 100, detailed: 25 });
|
|
14
|
+
|
|
15
|
+
const CONCISE_FIELDS = ['action', 'providerId', 'category', 'description', 'supportedChains', 'requiredParams', 'riskLevel', 'maxAmountUsd', 'enabled', 'aliases'];
|
|
16
|
+
|
|
17
|
+
const isRecord = (value) => Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
18
|
+
const isSet = (value) => value !== undefined && value !== null && String(value).trim() !== '';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The paging flags, checked: `{ search, limit, offset, view }`, or null when none is set (the
|
|
22
|
+
* list stays whole). A bad value is INVALID_ARGUMENTS.
|
|
23
|
+
*/
|
|
24
|
+
export function readActionListPaging(flags = {}) {
|
|
25
|
+
if (![flags.search, flags.limit, flags.offset, flags.view].some(isSet)) return null;
|
|
26
|
+
const view = isSet(flags.view) ? String(flags.view).trim().toLowerCase() : 'concise';
|
|
27
|
+
if (view !== 'concise' && view !== 'detailed') throw usageError('--view must be concise or detailed', 'view');
|
|
28
|
+
const count = (key, min, max) => {
|
|
29
|
+
if (!isSet(flags[key])) return undefined;
|
|
30
|
+
const text = String(flags[key]).trim();
|
|
31
|
+
const value = Number(text);
|
|
32
|
+
if (!/^\d+$/.test(text) || value < min || value > max) throw usageError(`--${key} must be an integer from ${min} to ${max}`, key);
|
|
33
|
+
return value;
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
search: isSet(flags.search) ? String(flags.search).trim() : undefined,
|
|
37
|
+
limit: count('limit', 1, 500),
|
|
38
|
+
offset: count('offset', 0, Number.MAX_SAFE_INTEGER),
|
|
39
|
+
view,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const lower = (value) => (typeof value === 'string' ? value.toLowerCase() : '');
|
|
44
|
+
|
|
45
|
+
/** True when the action's name, title, description or aliases contain `search` (case-insensitive). */
|
|
46
|
+
function matchesSearch(entry, search) {
|
|
47
|
+
if (!search) return true;
|
|
48
|
+
const record = isRecord(entry) ? entry : {};
|
|
49
|
+
const aliases = Array.isArray(record.aliases) ? record.aliases : [];
|
|
50
|
+
return [record.action, record.name, record.title, record.description, ...aliases].map(lower).join(' ').includes(search.toLowerCase());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function conciseEntry(entry) {
|
|
54
|
+
if (!isRecord(entry)) return entry;
|
|
55
|
+
const out = {};
|
|
56
|
+
for (const field of CONCISE_FIELDS) {
|
|
57
|
+
const value = entry[field];
|
|
58
|
+
if (value === undefined || value === null || (Array.isArray(value) && value.length === 0)) continue;
|
|
59
|
+
out[field] = value;
|
|
60
|
+
}
|
|
61
|
+
if (typeof entry.behavior?.readOnly === 'boolean') out.readOnly = entry.behavior.readOnly;
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One page of `actions` (already filtered by provider and chain): `{ actions, pagination, view, hint }`.
|
|
67
|
+
* `pagination` is `{ total, matched, offset, limit, returned, hasMore }`, as thirdfy-mcp returns it.
|
|
68
|
+
*/
|
|
69
|
+
export function pageActionList(actions, paging) {
|
|
70
|
+
const rows = Array.isArray(actions) ? actions : [];
|
|
71
|
+
const view = paging.view;
|
|
72
|
+
const limit = Math.min(paging.limit ?? DEFAULT_PAGE_SIZE[view], MAX_PAGE_SIZE[view]);
|
|
73
|
+
const offset = paging.offset ?? 0;
|
|
74
|
+
const matched = rows.filter((entry) => matchesSearch(entry, paging.search));
|
|
75
|
+
const page = matched.slice(offset, offset + limit);
|
|
76
|
+
const hasMore = offset + page.length < matched.length;
|
|
77
|
+
return {
|
|
78
|
+
actions: view === 'concise' ? page.map(conciseEntry) : page,
|
|
79
|
+
pagination: { total: rows.length, matched: matched.length, offset, limit, returned: page.length, hasMore },
|
|
80
|
+
view,
|
|
81
|
+
hint: hasMore
|
|
82
|
+
? `More actions match: run again with --offset ${offset + page.length}, or narrow with --provider or --search.`
|
|
83
|
+
: view === 'concise'
|
|
84
|
+
? '--view detailed (with --provider or --search) returns paramsSchema for each action.'
|
|
85
|
+
: undefined,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -7,6 +7,15 @@ import {
|
|
|
7
7
|
toAvantisSpelling,
|
|
8
8
|
toLegacyVenueActionKey,
|
|
9
9
|
} from '../../core/venueBrand.mjs';
|
|
10
|
+
/**
|
|
11
|
+
* The action name to send to the API: the one the caller asked for, as thirdfy-mcp sends it
|
|
12
|
+
* (`gateManagedWriteActionParams` `apiAction`). The catalog key is sent instead only when the request
|
|
13
|
+
* matched through its Veranta/Avantis twin, the name the API in hand does not know.
|
|
14
|
+
*/
|
|
15
|
+
export function actionNameForApi(resolved) {
|
|
16
|
+
return String(resolved?.apiAction || resolved?.resolvedAction || '').trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
10
19
|
export function createActionSelection({ getTradingProviderHint, formatProviderAmbiguousActionHint, formatProviderUnknownActionHint }) {
|
|
11
20
|
function getActionKey(action) {
|
|
12
21
|
return String(action?.action || action?.id || action?.key || action?.name || '')
|
|
@@ -368,6 +377,9 @@ function getActionProvider(action) {
|
|
|
368
377
|
|
|
369
378
|
async function resolveActionSelection(ctx, flags) {
|
|
370
379
|
const requestedAction = requireFlag(flags, 'action', 'Missing --action');
|
|
380
|
+
// The agent's own policy list whenever execution will run with an agent key (flag, env or config),
|
|
381
|
+
// as thirdfy-mcp resolves: an action the agent may not run stops here as UNKNOWN_ACTION with
|
|
382
|
+
// suggestions, not at the API as ACTION_NOT_ALLOWED.
|
|
371
383
|
const actions = applyActionFilters(await getCachedActionsCatalog(ctx, flags), flags);
|
|
372
384
|
return resolveActionFromRows(requestedAction, actions, flags);
|
|
373
385
|
}
|
|
@@ -463,7 +475,6 @@ function matchCatalogEntry(requestedAction, keyed, flags = {}) {
|
|
|
463
475
|
}
|
|
464
476
|
|
|
465
477
|
function resolveActionFromRows(requestedAction, actions, flags = {}) {
|
|
466
|
-
const requestedLower = String(requestedAction || '').toLowerCase();
|
|
467
478
|
const keyed = actions
|
|
468
479
|
.map((action) => ({
|
|
469
480
|
key: getActionKey(action),
|
|
@@ -484,34 +495,18 @@ function resolveActionFromRows(requestedAction, actions, flags = {}) {
|
|
|
484
495
|
}
|
|
485
496
|
|
|
486
497
|
const direct = matchCatalogEntry(requestedAction, keyed, flags);
|
|
487
|
-
if (direct) return direct;
|
|
498
|
+
if (direct) return { ...direct, apiAction: String(requestedAction).trim() };
|
|
488
499
|
|
|
489
500
|
// Veranta (formerly Avantis): retry with the other spelling so `place_veranta_perps_order` finds the
|
|
490
501
|
// `place_avantis_perps_order` row on an API that has not shipped Veranta names, and the reverse.
|
|
491
502
|
const alternate = alternateVenueActionSpelling(requestedAction);
|
|
492
503
|
if (alternate) {
|
|
493
504
|
const viaAlternate = matchCatalogEntry(alternate, keyed, flags);
|
|
494
|
-
if (viaAlternate) return { ...viaAlternate, requestedAction };
|
|
495
|
-
}
|
|
496
|
-
const alternateLower = alternate ? alternate.toLowerCase() : null;
|
|
497
|
-
|
|
498
|
-
const fuzzyNeedles = alternateLower ? [requestedLower, alternateLower] : [requestedLower];
|
|
499
|
-
const fuzzy = keyed.filter(
|
|
500
|
-
(entry) =>
|
|
501
|
-
fuzzyNeedles.some((needle) => entry.key.toLowerCase().includes(needle)) ||
|
|
502
|
-
entry.aliases.some((a) => fuzzyNeedles.some((needle) => a.toLowerCase().includes(needle)))
|
|
503
|
-
);
|
|
504
|
-
if (fuzzy.length === 1) {
|
|
505
|
-
return { requestedAction, resolvedAction: fuzzy[0].key, resolvedActionMeta: fuzzy[0].action || null };
|
|
506
|
-
}
|
|
507
|
-
if (fuzzy.length > 1) {
|
|
508
|
-
const providerHint = flags.provider ? getTradingProviderHint(flags.provider) : null;
|
|
509
|
-
throw new Error(
|
|
510
|
-
`Ambiguous --action "${requestedAction}". Did you mean one of: ${formatActionList(
|
|
511
|
-
fuzzy.slice(0, 10).map((v) => v.key)
|
|
512
|
-
)}?${formatProviderAmbiguousActionHint(providerHint)}`
|
|
513
|
-
);
|
|
505
|
+
if (viaAlternate) return { ...viaAlternate, requestedAction, apiAction: viaAlternate.resolvedAction };
|
|
514
506
|
}
|
|
507
|
+
// No substring match: a name that is not an action, alias or Veranta/Avantis twin is unknown, as in
|
|
508
|
+
// thirdfy-mcp. Picking the one row that contains it ran a different action than the one asked for
|
|
509
|
+
// (`swap` → `execute_optimal_swap` on a list without `swap`); the near names come back as suggestions.
|
|
515
510
|
return {
|
|
516
511
|
requestedAction,
|
|
517
512
|
resolvedAction: requestedAction,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { toLegacyVenueActionKey } from '../../core/venueBrand.mjs';
|
|
2
2
|
import { resolveExecuteEstimatedAmountUsd } from './payloads.mjs';
|
|
3
|
+
import { describeCliError } from '../../core/errorEnvelope.mjs';
|
|
4
|
+
import { findRiskRefusal } from '../risk/riskRefusal.mjs';
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Action-aware `agent_wallet` preflight (Thirdfy API: `GET /agent/execution-wallet?action=…`).
|
|
@@ -31,8 +33,12 @@ export function resolveActionVenue(action) {
|
|
|
31
33
|
|
|
32
34
|
/**
|
|
33
35
|
* The venue an action executes on, next to the chain the managed wallet was looked up on. The
|
|
34
|
-
* managed wallet is one
|
|
35
|
-
* default chain; this says the order itself does not run there.
|
|
36
|
+
* managed EVM wallet is one address on every EVM chain, so a Hyperliquid order still resolves it on
|
|
37
|
+
* the default chain; this says the order itself does not run there. Only the four EVM-settled venues
|
|
38
|
+
* above match. Most Solana actions (Jupiter, Kamino, Meteora, ...) resolve no venue here; the Solana
|
|
39
|
+
* funding actions `deposit_polymarket_from_solana` and `deposit_hyperliquid_from_solana` name a venue
|
|
40
|
+
* and so get `executionVenue` in the output. Either way they sign with the separate base58 Solana
|
|
41
|
+
* agent wallet on chain 101/103, and the API decides venue readiness.
|
|
36
42
|
*/
|
|
37
43
|
export function describeExecutionVenue(action, walletLookupChainId) {
|
|
38
44
|
const venue = resolveActionVenue(action);
|
|
@@ -41,10 +47,11 @@ export function describeExecutionVenue(action, walletLookupChainId) {
|
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
/** Adds `action`, `params` and `estimatedAmountUsd` to an `/execution-wallet` query. */
|
|
44
|
-
export function appendActionPreflightQuery(query, { action, params, flags }) {
|
|
50
|
+
export function appendActionPreflightQuery(query, { action, apiAction, params, flags }) {
|
|
45
51
|
const actionId = String(action || '').trim();
|
|
46
52
|
if (!actionId) return query;
|
|
47
|
-
|
|
53
|
+
// The API gets the requested name (as thirdfy-mcp sends it); the catalog key sizes the estimate.
|
|
54
|
+
query.set('action', String(apiAction || actionId).trim());
|
|
48
55
|
if (params && typeof params === 'object' && !Array.isArray(params) && Object.keys(params).length) {
|
|
49
56
|
query.set('params', JSON.stringify(params));
|
|
50
57
|
}
|
|
@@ -53,11 +60,22 @@ export function appendActionPreflightQuery(query, { action, params, flags }) {
|
|
|
53
60
|
return query;
|
|
54
61
|
}
|
|
55
62
|
|
|
56
|
-
/**
|
|
57
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Venue and order fields an action preflight carries onto the CLI envelope: venue readiness, the
|
|
65
|
+
* order preview (`orderPreview`), a risk-policy refusal (`riskRefusal`) and a venue setup that runs
|
|
66
|
+
* with the first order (`venueSetup`), as thirdfy-mcp `previewOrder` reads them.
|
|
67
|
+
*/
|
|
68
|
+
export const ACTION_PREFLIGHT_VENUE_FIELDS = Object.freeze([
|
|
69
|
+
'venue',
|
|
70
|
+
'venueReadiness',
|
|
71
|
+
'blockingReasons',
|
|
72
|
+
'orderPreview',
|
|
73
|
+
'riskRefusal',
|
|
74
|
+
'venueSetup',
|
|
75
|
+
]);
|
|
58
76
|
|
|
59
77
|
/** Stages at which the API refused the action itself (as opposed to a CLI-local check). */
|
|
60
|
-
const API_BLOCK_STAGES = new Set(['catalog', 'policy', 'venue_readiness']);
|
|
78
|
+
const API_BLOCK_STAGES = new Set(['catalog', 'policy', 'venue_readiness', 'venue_rules', 'risk_policy']);
|
|
61
79
|
|
|
62
80
|
/** True when the API blocked the action (policy, catalog, venue readiness). */
|
|
63
81
|
export function isApiActionBlock(preflight) {
|
|
@@ -70,24 +88,42 @@ export function pickActionPreflightFields(response) {
|
|
|
70
88
|
for (const key of [...ACTION_PREFLIGHT_VENUE_FIELDS, 'actionPreflight']) {
|
|
71
89
|
if (response?.[key] != null) out[key] = response[key];
|
|
72
90
|
}
|
|
91
|
+
// A risk-policy refusal may sit under `data`; carry it at the top level.
|
|
92
|
+
const riskRefusal = out.riskRefusal || findRiskRefusal(response);
|
|
93
|
+
if (riskRefusal) out.riskRefusal = riskRefusal;
|
|
73
94
|
return out;
|
|
74
95
|
}
|
|
75
96
|
|
|
76
97
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
98
|
+
* The API answered the action preflight but refused the action (`success: false`, from a non-2xx
|
|
99
|
+
* `preflightBlocked` answer or a 2xx one). `preflightBlocked: true` tells it from a CLI-local check.
|
|
79
100
|
*/
|
|
80
|
-
export function
|
|
81
|
-
const payload = error?.payload;
|
|
82
|
-
if (!payload || typeof payload !== 'object' || payload.preflightBlocked !== true) return null;
|
|
101
|
+
export function blockedActionPreflight(payload, { statusCode = null, fallbackError = 'Preflight blocked', errorFields = {} } = {}) {
|
|
83
102
|
return {
|
|
84
103
|
success: false,
|
|
104
|
+
preflightBlocked: true,
|
|
85
105
|
blockedReason: String(payload.blockedReason || 'PREFLIGHT_BLOCKED'),
|
|
86
106
|
blockedStage: String(payload.blockedStage || 'policy'),
|
|
87
|
-
error: String(payload.error || payload.message ||
|
|
88
|
-
statusCode
|
|
107
|
+
error: String(payload.error || payload.message || fallbackError),
|
|
108
|
+
statusCode,
|
|
89
109
|
signerMethod: payload.signerMethod || 'managed_wallet_server',
|
|
90
110
|
...pickActionPreflightFields(payload),
|
|
91
111
|
...(payload.data ? { data: payload.data } : {}),
|
|
112
|
+
...errorFields,
|
|
92
113
|
};
|
|
93
114
|
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A blocked action preflight arrives as a non-2xx answer (409 `VENUE_NOT_READY`, 403 policy, 429
|
|
118
|
+
* limits) with `preflightBlocked: true`. Turn it into a blocked preflight result; null otherwise.
|
|
119
|
+
*/
|
|
120
|
+
export function blockedActionPreflightFromError(error) {
|
|
121
|
+
const payload = error?.payload;
|
|
122
|
+
if (!payload || typeof payload !== 'object' || payload.preflightBlocked !== true) return null;
|
|
123
|
+
return blockedActionPreflight(payload, {
|
|
124
|
+
statusCode: Number(error?.statusCode) || null,
|
|
125
|
+
fallbackError: error?.message || 'Preflight blocked',
|
|
126
|
+
// errorCode, retryable, retryAfterSeconds, nextAction, hint: as thirdfy-mcp's error envelope.
|
|
127
|
+
errorFields: describeCliError(error),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'crypto';
|
|
|
2
2
|
import { wrapWriteIdempotencyKeyForHostedRuntime } from '../../core/hostedRuntimeIdempotency.mjs';
|
|
3
3
|
import { resolveExecutionContextFromEnv } from '../../core/executionContext.mjs';
|
|
4
4
|
import { requireFlag, parseJsonFlag } from '../../core/args.mjs';
|
|
5
|
-
import { loadProfileConfig } from '../../core/context.mjs';
|
|
5
|
+
import { findAgentApiKey, loadProfileConfig } from '../../core/context.mjs';
|
|
6
6
|
import { createCliError } from '../../core/envelope.mjs';
|
|
7
7
|
import { toLegacyVenueActionKey } from '../../core/venueBrand.mjs';
|
|
8
8
|
import { normalizeRunMode, normalizeManagedRunMode, normalizeHybridWalletMode } from '../../core/runMode.mjs';
|
|
@@ -14,15 +14,25 @@ function isTruthyFlag(value) {
|
|
|
14
14
|
return value === true || String(value || '').trim().toLowerCase() === 'true';
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
const SCHEMA_CONFIRMATION_KEYS = ['confirmWrites', 'confirm'];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `--confirm-writes` (or `--confirm`) copied into each confirmation key the action's catalog
|
|
21
|
+
* `paramsSchema` lists, and only those, as thirdfy-mcp `injectSchemaConfirmationIntoParams`
|
|
22
|
+
* (`src/catalog/actionParamsGate.ts`): `params.confirmWrites` on Veranta writes, `params.confirm` on
|
|
23
|
+
* the Hyperliquid and Polymarket bridge and capital hops. A schema with `additionalProperties: false`
|
|
24
|
+
* refuses any other key, so a key it does not list is never added. A value already in params wins.
|
|
25
|
+
* The schema defaults to the one the catalog params gate kept on `flags`.
|
|
26
|
+
*/
|
|
27
|
+
export function mergeConfirmWritesIntoParams(params, flags, schema = flags?.__actionParamsSchema) {
|
|
18
28
|
const next = { ...(params && typeof params === 'object' ? params : {}) };
|
|
19
|
-
if (
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
next
|
|
29
|
+
if (!isTruthyFlag(flags?.confirmWrites) && !isTruthyFlag(flags?.confirm)) return next;
|
|
30
|
+
const properties = schema && typeof schema === 'object' ? schema.properties : null;
|
|
31
|
+
if (!properties || typeof properties !== 'object') return next;
|
|
32
|
+
for (const key of SCHEMA_CONFIRMATION_KEYS) {
|
|
33
|
+
if (!Object.prototype.hasOwnProperty.call(properties, key)) continue;
|
|
34
|
+
if (next[key] !== undefined) continue;
|
|
35
|
+
next[key] = true;
|
|
26
36
|
}
|
|
27
37
|
return next;
|
|
28
38
|
}
|
|
@@ -37,11 +47,11 @@ export function resolveExecuteEstimatedAmountUsd(action, flags) {
|
|
|
37
47
|
}
|
|
38
48
|
|
|
39
49
|
export function createExecutionPayloads({ getPreparedParams, rawUnitsToDecimalString }) {
|
|
40
|
-
function buildBuildTxPayload(flags, resolvedAction) {
|
|
50
|
+
function buildBuildTxPayload(flags, resolvedAction, apiAction) {
|
|
41
51
|
const runMode = String(flags.runMode || '').trim().toLowerCase() || 'self';
|
|
42
52
|
const payload = {
|
|
43
53
|
agentApiKey: resolveAgentApiKey(flags, runMode),
|
|
44
|
-
action: String(resolvedAction || requireFlag(flags, 'action', 'Missing --action')).trim(),
|
|
54
|
+
action: String(apiAction || resolvedAction || requireFlag(flags, 'action', 'Missing --action')).trim(),
|
|
45
55
|
params: getPreparedParams(flags),
|
|
46
56
|
};
|
|
47
57
|
if (flags.chainId) payload.chainId = Number(flags.chainId);
|
|
@@ -129,10 +139,11 @@ function buildManagedExecutePayload(flags, options) {
|
|
|
129
139
|
delete managedParams.tokenInDecimals;
|
|
130
140
|
managedParams.amountIn = flags.__swapInput.amountInHuman;
|
|
131
141
|
}
|
|
142
|
+
// `action` (the catalog key) drives the CLI's own rules; the API gets the requested name.
|
|
132
143
|
const action = String(options.resolvedAction || requireFlag(flags, 'action', 'Missing --action')).trim();
|
|
133
144
|
const payload = {
|
|
134
145
|
agentApiKey: resolveAgentApiKey(flags, runMode),
|
|
135
|
-
action,
|
|
146
|
+
action: String(options.apiAction || action).trim(),
|
|
136
147
|
userDid: resolveEffectiveUserDid(flags, {
|
|
137
148
|
runMode,
|
|
138
149
|
hybridWalletMode: options.hybridWalletMode,
|
|
@@ -155,11 +166,11 @@ function buildManagedExecutePayload(flags, options) {
|
|
|
155
166
|
'Managed wallet execution requires --user-did, --agent-key (solo did:wallet identity), THIRDFY_USER_DID, or a saved userDid from `thirdfy-agent login email`.'
|
|
156
167
|
);
|
|
157
168
|
}
|
|
158
|
-
const estimatedAmountUsd = resolveExecuteEstimatedAmountUsd(
|
|
169
|
+
const estimatedAmountUsd = resolveExecuteEstimatedAmountUsd(action, flags);
|
|
159
170
|
if (estimatedAmountUsd != null) payload.estimatedAmountUsd = estimatedAmountUsd;
|
|
160
171
|
if (options.forceIdempotency) {
|
|
161
172
|
payload.executionIdempotencyKey = String(
|
|
162
|
-
flags.idempotencyKey || `cli:walletExecute:${
|
|
173
|
+
flags.idempotencyKey || `cli:walletExecute:${action}:${Date.now()}:${randomUUID()}`
|
|
163
174
|
);
|
|
164
175
|
} else if (flags.idempotencyKey) {
|
|
165
176
|
payload.executionIdempotencyKey = String(flags.idempotencyKey);
|
|
@@ -168,11 +179,11 @@ function buildManagedExecutePayload(flags, options) {
|
|
|
168
179
|
payload.executionIdempotencyKey = wrapWriteIdempotencyKeyForHostedRuntime(
|
|
169
180
|
payload.executionIdempotencyKey,
|
|
170
181
|
resolveExecutionContextFromEnv().runtimeId,
|
|
171
|
-
|
|
182
|
+
action,
|
|
172
183
|
);
|
|
173
184
|
}
|
|
174
185
|
assertHostedRuntimePolymarketWriteIdempotency({
|
|
175
|
-
action
|
|
186
|
+
action,
|
|
176
187
|
idempotencyKey: payload.executionIdempotencyKey,
|
|
177
188
|
paramsIdempotencyKey: managedParams?.idempotencyKey,
|
|
178
189
|
});
|
|
@@ -181,14 +192,16 @@ function buildManagedExecutePayload(flags, options) {
|
|
|
181
192
|
|
|
182
193
|
function buildIntentPayload(flags, options) {
|
|
183
194
|
const runMode = String(options.runMode || flags.runMode || '').trim().toLowerCase() || 'thirdfy';
|
|
195
|
+
// `action` (the catalog key) drives the CLI's own rules; the API gets the requested name.
|
|
196
|
+
const action = String(options.resolvedAction || requireFlag(flags, 'action', 'Missing --action')).trim();
|
|
184
197
|
const payload = {
|
|
185
198
|
agentApiKey: resolveAgentApiKey(flags, runMode),
|
|
186
|
-
action: String(options.
|
|
199
|
+
action: String(options.apiAction || action).trim(),
|
|
187
200
|
params: mergeConfirmWritesIntoParams(getPreparedParams(flags), flags),
|
|
188
201
|
};
|
|
189
202
|
if (flags.catalog) payload.catalog = String(flags.catalog).trim();
|
|
190
203
|
if (flags.chainId) payload.chainId = Number(flags.chainId);
|
|
191
|
-
const estimatedAmountUsd = resolveExecuteEstimatedAmountUsd(
|
|
204
|
+
const estimatedAmountUsd = resolveExecuteEstimatedAmountUsd(action, flags);
|
|
192
205
|
if (estimatedAmountUsd != null) payload.estimatedAmountUsd = estimatedAmountUsd;
|
|
193
206
|
const intentRunMode = String(options.runMode || flags.runMode || '').trim().toLowerCase();
|
|
194
207
|
const userDid = resolveEffectiveUserDid(flags, {
|
|
@@ -213,7 +226,7 @@ function buildIntentPayload(flags, options) {
|
|
|
213
226
|
};
|
|
214
227
|
}
|
|
215
228
|
if (options.forceIdempotency) {
|
|
216
|
-
payload.idempotencyKey = String(flags.idempotencyKey || `cli:${
|
|
229
|
+
payload.idempotencyKey = String(flags.idempotencyKey || `cli:${action}:${Date.now()}:${randomUUID()}`);
|
|
217
230
|
} else if (flags.idempotencyKey) {
|
|
218
231
|
payload.idempotencyKey = String(flags.idempotencyKey);
|
|
219
232
|
}
|
|
@@ -221,12 +234,12 @@ function buildIntentPayload(flags, options) {
|
|
|
221
234
|
payload.idempotencyKey = wrapWriteIdempotencyKeyForHostedRuntime(
|
|
222
235
|
payload.idempotencyKey,
|
|
223
236
|
resolveExecutionContextFromEnv().runtimeId,
|
|
224
|
-
|
|
237
|
+
action,
|
|
225
238
|
);
|
|
226
239
|
}
|
|
227
240
|
if (!options.validationOnly) {
|
|
228
241
|
assertHostedRuntimePolymarketWriteIdempotency({
|
|
229
|
-
action
|
|
242
|
+
action,
|
|
230
243
|
idempotencyKey: payload.idempotencyKey,
|
|
231
244
|
paramsIdempotencyKey: payload.params?.idempotencyKey,
|
|
232
245
|
});
|
|
@@ -235,13 +248,8 @@ function buildIntentPayload(flags, options) {
|
|
|
235
248
|
}
|
|
236
249
|
|
|
237
250
|
function resolveAgentApiKey(flags, runMode) {
|
|
238
|
-
const
|
|
239
|
-
if (
|
|
240
|
-
const envKey = String(process.env.THIRDFY_AGENT_API_KEY || '').trim();
|
|
241
|
-
if (envKey) return envKey;
|
|
242
|
-
const config = loadProfileConfig();
|
|
243
|
-
const configKey = String(config?.agent?.apiKey || '').trim();
|
|
244
|
-
if (configKey) return configKey;
|
|
251
|
+
const key = findAgentApiKey(flags);
|
|
252
|
+
if (key) return key;
|
|
245
253
|
if (runMode === 'self') {
|
|
246
254
|
throw new Error(
|
|
247
255
|
'SELF_OPEN_DISABLED: self mode without agent identity is not enabled on this API yet. Set --agent-api-key, THIRDFY_AGENT_API_KEY, or login/config default.'
|