@thirdfy/agent-cli 0.5.0 → 0.6.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 +32 -0
- package/README.md +10 -5
- package/package.json +2 -2
- package/scripts/validate-cli-contract-schemas.mjs +1 -0
- package/src/cli/manifest.mjs +37 -0
- package/src/cli/options/index.mjs +2 -0
- package/src/cli/options/trackRecord.mjs +9 -0
- package/src/commands/doctor.mjs +7 -2
- package/src/commands/telemetry.mjs +30 -4
- package/src/commands/trackRecord/command.mjs +187 -0
- package/src/commands/trackRecord/refusals.mjs +128 -0
- package/src/commands/trackRecord/scoreSummary.mjs +57 -0
- package/src/contracts/cli/schemas/track-record.response.schema.json +83 -0
- package/src/core/context.mjs +14 -1
- package/src/core/errorEnvelope.mjs +15 -1
- package/src/runtime/certification/executionCertify.mjs +18 -1
- package/src/runtime/handlers.mjs +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@thirdfy/agent-cli` are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
4
4
|
|
|
5
|
+
## [0.6.0] - 2026-10-18
|
|
6
|
+
|
|
7
|
+
`thirdfy-agent track-record` proves an agent's trading track record, the CLI side of thirdfy-mcp's `prove_track_record` (thirdfy-mcp 0.1.47). `track actions` and `track events` now send the agent's key, which the API requires. A minor version for the new command.
|
|
8
|
+
|
|
9
|
+
### Security
|
|
10
|
+
|
|
11
|
+
- **`track actions` and `track events` send the agent's credential** (THI-292; MCP THI-148, thirdfy/thirdfy-mcp#292). They called `GET /api/v1/agent/telemetry/{actions,events}` with no credential, and the API used to answer with every agent's rows, user DIDs included. The API now refuses keyless reads (THI-149) and gives an agent key its own rows only. The CLI sends the agent key (`--agent-api-key`, `THIRDFY_AGENT_API_KEY` or config) in `x-agent-api-key`, never in the URL. The API does not let an OAuth sign-in read these routes, so a sign-in is skipped for the key in config. `--agent-key` is now optional (the key names its agent). With no credential the command refuses `AGENT_API_KEY_REQUIRED` (`check_credentials`) before any call. Scripts that passed only `--agent-key` need the agent's key.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`track-record challenge | verify | score | status | public <agentKey>`** (THI-291; thirdfy-api-v2 4.7.0, THI-255/THI-280; MCP thirdfy/thirdfy-mcp#293 and thirdfy/thirdfy-mcp#294). An agent proves it controls a Hyperliquid or Polymarket wallet that Thirdfy never funded and gets a score of the wallet's public venue history, signed by Thirdfy.
|
|
16
|
+
- **Steps:** `challenge --venue --wallet` returns a one-time message; you sign it with the wallet; `verify --challenge-id --signature` binds the wallet to the agent; `score [--venue --wallet]` scores the proven wallets; `status` lists them with their latest scores; `public <agentKey>` reads any agent's published record (no key).
|
|
17
|
+
- **Output:** the API's body plus `step`, `nextStep` (the command to run next), and for scores `summary` (venue, wallet, `eligible`, `reasons` and `reasonNotes`, `windowDays`, `pnlUsd`, `maxDrawdownUsd`, `trades`, `signer`, `digest`) and `verifyNote`. Schema: `track-record.response.schema.json`.
|
|
18
|
+
- **Checking a score:** `verifyNote` gives the 4.7.0 rule: the digest is keccak256 of `thirdfy-track-record/v1`, a newline, then the canonical JSON of `score`; compare the recovered EIP-191 signer with Thirdfy's signer (`GET /api/v1/track-record/signer`), never the `signer` field in the score.
|
|
19
|
+
- **Refusals:** every API code gets the MCP's `nextAction`, `retryable` and a hint with CLI commands: `AGENT_API_KEY_REQUIRED`, `AGENT_API_KEY_INVALID`, `TRACK_RECORD_UNAVAILABLE`, `TRACK_RECORD_DISABLED`, `SCORE_SIGNER_UNAVAILABLE`, `VENUE_HISTORY_UNAVAILABLE`, `VENUE_UNSUPPORTED`, `WALLET_INVALID`, `SIGNATURE_INVALID`, `SIGNATURE_MISMATCH`, `CHALLENGE_NOT_FOUND`, `CHALLENGE_USED`, `CHALLENGE_EXPIRED`, `WALLET_NOT_PROVEN`, `WALLET_BOUND_TO_OTHER_AGENT`, `TRACK_RECORD_NOT_PUBLISHED`. Also the 4.7.0 codes `AGENT_REGISTRY_UNAVAILABLE`, `RPC_UNAVAILABLE` and `NO_WALLET_SCORED` (503: call again or pass `--wallet`; 502: see each wallet's error in `results`).
|
|
20
|
+
- New next actions: `start_new_challenge`, `prove_wallet`, `owner_publish`.
|
|
21
|
+
- A timed-out `challenge` or `score` is sent once more (neither moves anything); `verify` spends its one-use challenge, so a timeout there is `WRITE_OUTCOME_UNKNOWN`: check `track-record status`.
|
|
22
|
+
- **Credential:** the agent API key (`--agent-api-key`, `THIRDFY_AGENT_API_KEY` or config). The track-record routes take only the agent key (they are not on the API's OAuth allowlist), so an OAuth sign-in is skipped for the key in config, and with no key the command refuses `AGENT_API_KEY_REQUIRED` before any call.
|
|
23
|
+
- Never asks for a venue key, never holds the wallet key, moves no funds and places no orders. Publishing stays the owner's step, signed in to Thirdfy.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- `mcp-tool-map.json` maps `prove_track_record` to `track-record challenge` (68 MCP tools, 66 with a CLI command). `validate:provider-parity` resolves an MCP tool whose `name:` is a string constant in its own file.
|
|
28
|
+
|
|
29
|
+
## [0.5.1] - 2026-10-16
|
|
30
|
+
|
|
31
|
+
A patch release: `doctor certify execution` works for agents with a narrow allowlist.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- **`doctor certify execution` no longer fails on venue writes outside the agent's allowlist** (THI-201). With an agent key, the CLI reads the agent's own action list, so bare certification flagged every venue write the agent may not call (for example `place_hyperliquid_tp_sl` or `update_lighter_leverage`) as `CATALOG_SCHEMA_UNAVAILABLE` and failed. It now checks only the venue writes the agent may call and lists the rest in `skippedVenueActions`. An allowed action with a broken schema, a `--required-actions` entry the agent can't call, and a public catalog missing a venue write all still fail.
|
|
36
|
+
|
|
5
37
|
## [0.5.0] - 2026-10-15
|
|
6
38
|
|
|
7
39
|
`thirdfy-agent login` signs in with OAuth in the browser (auth.thirdfy.com, the same sign-in the MCP uses), as one agent with read access, plus trade with `--trade`. A minor version because `login` without a token flag now starts that sign-in instead of failing, and `credits balance` / `pricing quote` drop their credit counts for USD. `login email`, token flags and agent API keys work as before.
|
package/README.md
CHANGED
|
@@ -39,11 +39,15 @@ Run without global install:
|
|
|
39
39
|
npx @thirdfy/agent-cli --help
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
## What's new in v0.
|
|
43
|
-
|
|
44
|
-
- **
|
|
45
|
-
-
|
|
46
|
-
-
|
|
42
|
+
## What's new in v0.6.0
|
|
43
|
+
|
|
44
|
+
- **Prove a trading track record: `thirdfy-agent track-record`.** Your agent proves it controls a Hyperliquid or Polymarket wallet and gets a score of that wallet's public history (PnL, drawdown, trades, eligibility reasons), signed by Thirdfy. Same steps and answers as the MCP tool `prove_track_record`.
|
|
45
|
+
- `track-record challenge --venue hyperliquid --wallet 0x…` returns a message; sign it with the wallet yourself.
|
|
46
|
+
- `track-record verify --challenge-id <id> --signature 0x…` binds the wallet to the agent.
|
|
47
|
+
- `track-record score`, `track-record status`, and `track-record public <agentKey>` for any agent's published record.
|
|
48
|
+
- **Nothing moves:** no funds, no orders, no venue key, and the CLI never holds the wallet key. Scores stay private until the agent's owner publishes them.
|
|
49
|
+
- **Clear next steps on every refusal:** each one carries `errorCode`, `retryable`, `nextAction` and a `hint`. See [`docs/command-reference.md`](./docs/command-reference.md#track-records-track-record), including how to check a score's signature.
|
|
50
|
+
- **`track actions` and `track events` need the agent's key** (`--agent-api-key`, env or config; an OAuth sign-in cannot read them) and return only that agent's rows. `--agent-key` alone no longer works.
|
|
47
51
|
- Older versions: see [CHANGELOG.md](./CHANGELOG.md).
|
|
48
52
|
|
|
49
53
|
## Quick start
|
|
@@ -352,6 +356,7 @@ Provider and venue guides are kept outside `README` so this page stays stable as
|
|
|
352
356
|
- Discovery: `catalogs list`, `signals` (data provider apps and their signals: fields with units, freshness, roles, credits, attribution; `--provider`, `--role`), `actions` (optional `--provider`, `--chain-id`, `--data-category`; see [`docs/command-reference.md`](./docs/command-reference.md) for trading vs earn vs prediction providers and case-insensitive matching)
|
|
353
357
|
- Discovery: `actions`, `chains` (chain ids, CAIP-2 and lanes from the API)
|
|
354
358
|
- Readiness and credits: `readiness` (can the agent act on a chain), `credits balance`, `credits buy --usd <n>`
|
|
359
|
+
- Track records: `track-record challenge`, `track-record verify`, `track-record score`, `track-record status`, `track-record public <agentKey>` (prove a trading wallet, get a Thirdfy-signed score)
|
|
355
360
|
- Execution: `preflight`, `preview`, `run`, `intent-status`
|
|
356
361
|
- Polymarket: `polymarket status`, `polymarket setup`, `polymarket dry-run`
|
|
357
362
|
- Venue tooling: `venue readiness`, `venue setup`, `venue fund` (`polymarket`, `hyperliquid`, `lighter`, `veranta`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thirdfy/agent-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Thirdfy Agent CLI for onboarding, governance preflight, execute-intent, and status polling.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
],
|
|
24
24
|
"scripts": {
|
|
25
25
|
"test": "node --test test/*/*.test.cjs test/*/*/*.test.cjs",
|
|
26
|
-
"lint": "node --check ./bin/thirdfy-agent.mjs && node --check ./src/core/*.mjs && node --check ./src/core/execution/*.mjs && node --check ./src/core/oauth/*.mjs && node --check ./src/commands/*.mjs && node --check ./src/commands/venues/*.mjs && node --check ./src/commands/onboarding/*.mjs && node --check ./src/commands/account/*.mjs && node --check ./src/commands/signals/*.mjs && node --check ./src/cli/*.mjs && node --check ./src/cli/options/*.mjs && node --check ./src/cli/options/venues/*.mjs && node --check ./src/cli/options/onboarding/*.mjs && node --check ./src/cli/options/account/*.mjs && node --check ./src/runtime/*.mjs && node --check ./src/runtime/execution/*.mjs && node --check ./src/runtime/execution/runners/*.mjs && node --check ./src/runtime/actions/*.mjs && node --check ./src/runtime/validation/*.mjs && node --check ./src/runtime/certification/*.mjs && node --check ./src/runtime/providerHints/*.mjs && node --check ./src/runtime/risk/*.mjs",
|
|
26
|
+
"lint": "node --check ./bin/thirdfy-agent.mjs && node --check ./src/core/*.mjs && node --check ./src/core/execution/*.mjs && node --check ./src/core/oauth/*.mjs && node --check ./src/commands/*.mjs && node --check ./src/commands/venues/*.mjs && node --check ./src/commands/onboarding/*.mjs && node --check ./src/commands/account/*.mjs && node --check ./src/commands/signals/*.mjs && node --check ./src/commands/trackRecord/*.mjs && node --check ./src/cli/*.mjs && node --check ./src/cli/options/*.mjs && node --check ./src/cli/options/venues/*.mjs && node --check ./src/cli/options/onboarding/*.mjs && node --check ./src/cli/options/account/*.mjs && node --check ./src/runtime/*.mjs && node --check ./src/runtime/execution/*.mjs && node --check ./src/runtime/execution/runners/*.mjs && node --check ./src/runtime/actions/*.mjs && node --check ./src/runtime/validation/*.mjs && node --check ./src/runtime/certification/*.mjs && node --check ./src/runtime/providerHints/*.mjs && node --check ./src/runtime/risk/*.mjs",
|
|
27
27
|
"validate:cli-manifest-handlers": "node ./scripts/validate-cli-manifest-handlers.mjs",
|
|
28
28
|
"validate:runtime-boundaries": "node ./scripts/validate-runtime-boundaries.mjs",
|
|
29
29
|
"validate:npm-pack": "node ./scripts/validate-npm-pack.mjs",
|
package/src/cli/manifest.mjs
CHANGED
|
@@ -647,6 +647,43 @@ export const CLI_MANIFEST = [
|
|
|
647
647
|
options: ['signals'],
|
|
648
648
|
description: 'List data provider apps and the signals they publish (fields, units, freshness, roles, credits)',
|
|
649
649
|
},
|
|
650
|
+
// track records (thirdfy-mcp prove_track_record): prove a trading wallet, get a Thirdfy-signed score
|
|
651
|
+
{
|
|
652
|
+
path: ['track-record', 'challenge'],
|
|
653
|
+
handler: 'commandTrackRecordChallenge',
|
|
654
|
+
tier: 'offline',
|
|
655
|
+
options: ['trackRecord'],
|
|
656
|
+
description: 'Get a one-time message to sign with a trading wallet: --venue hyperliquid|polymarket --wallet <0x>',
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
path: ['track-record', 'verify'],
|
|
660
|
+
handler: 'commandTrackRecordVerify',
|
|
661
|
+
tier: 'offline',
|
|
662
|
+
options: ['trackRecord'],
|
|
663
|
+
description: "Prove the wallet: --challenge-id <id> --signature <the wallet's signature of the message>",
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
path: ['track-record', 'score'],
|
|
667
|
+
handler: 'commandTrackRecordScore',
|
|
668
|
+
tier: 'offline',
|
|
669
|
+
options: ['trackRecord'],
|
|
670
|
+
description: 'Score the proven wallets from public venue history, signed by Thirdfy (optional --venue, --wallet)',
|
|
671
|
+
},
|
|
672
|
+
{
|
|
673
|
+
path: ['track-record', 'status'],
|
|
674
|
+
handler: 'commandTrackRecordStatus',
|
|
675
|
+
tier: 'offline',
|
|
676
|
+
options: ['trackRecord'],
|
|
677
|
+
description: "This agent's proven wallets and latest scores, published or not",
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
path: ['track-record', 'public'],
|
|
681
|
+
handler: 'commandTrackRecordPublic',
|
|
682
|
+
tier: 'offline',
|
|
683
|
+
options: ['trackRecord'],
|
|
684
|
+
description: "An agent's published track record (no key needed)",
|
|
685
|
+
args: { query: '<agentKey>' },
|
|
686
|
+
},
|
|
650
687
|
{
|
|
651
688
|
path: ['readiness'],
|
|
652
689
|
handler: 'commandReadiness',
|
|
@@ -20,6 +20,7 @@ import { attachBrainOptions } from './brain.mjs';
|
|
|
20
20
|
import { attachCertificationOptions } from './certification.mjs';
|
|
21
21
|
import { attachActionListOptions, attachDataCategoryOptions, attachSignalsOptions } from './data.mjs';
|
|
22
22
|
import { attachWalletControlOptions } from './account/walletControl.mjs';
|
|
23
|
+
import { attachTrackRecordOptions } from './trackRecord.mjs';
|
|
23
24
|
|
|
24
25
|
const BUNDLES = {
|
|
25
26
|
auth: attachAuthOptions,
|
|
@@ -48,6 +49,7 @@ const BUNDLES = {
|
|
|
48
49
|
dataCategory: attachDataCategoryOptions,
|
|
49
50
|
actionList: attachActionListOptions,
|
|
50
51
|
walletControl: attachWalletControlOptions,
|
|
52
|
+
trackRecord: attachTrackRecordOptions,
|
|
51
53
|
};
|
|
52
54
|
|
|
53
55
|
/** @param {import('commander').Command} cmd */
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** `track-record` steps: the agent API key, and the step's own fields. No venue key and no wallet key. */
|
|
2
|
+
export function attachTrackRecordOptions(cmd) {
|
|
3
|
+
return cmd
|
|
4
|
+
.option('--agent-api-key <key>', 'agent API key (every step but public)')
|
|
5
|
+
.option('--venue <venue>', 'challenge (required) and score (optional filter): the venue the wallet trades on, for example hyperliquid or polymarket')
|
|
6
|
+
.option('--wallet <address>', 'challenge (required) and score (optional filter): the 0x wallet; for Polymarket, the proxy wallet trades settle to')
|
|
7
|
+
.option('--challenge-id <id>', 'verify (required): the challengeId the challenge step returned')
|
|
8
|
+
.option('--signature <hex>', "verify (required): the wallet's signature of the challenge message, exactly as returned");
|
|
9
|
+
}
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire } from 'module';
|
|
2
2
|
import { requireFlag } from '../core/args.mjs';
|
|
3
3
|
import { apiGet, requestWithRouteFallback } from '../core/http.mjs';
|
|
4
|
-
import { loadProfileConfig, getProfileConfigPath,
|
|
4
|
+
import { loadProfileConfig, getProfileConfigPath, getCachedActionsCatalogSnapshot } from '../core/context.mjs';
|
|
5
5
|
import { DEFAULT_API_BASE, PROFILE_DEFAULTS } from '../core/constants.mjs';
|
|
6
6
|
import { DEFAULT_CHAIN_ID, describeChainGas, parseChainIdFlag, pinnedRpcProvider, resolveChainRpcUrl } from '../core/chainRegistry.mjs';
|
|
7
7
|
import { exitAfterOutput } from '../core/envelope.mjs';
|
|
@@ -333,9 +333,12 @@ async function commandManagedSetup(ctx, flags, capabilities) {
|
|
|
333
333
|
|
|
334
334
|
async function commandDoctorCertifyExecution(ctx, flags) {
|
|
335
335
|
let liveActions = null;
|
|
336
|
+
let catalogPolicyScoped = false;
|
|
336
337
|
let catalogError = null;
|
|
337
338
|
try {
|
|
338
|
-
|
|
339
|
+
const snapshot = await getCachedActionsCatalogSnapshot(ctx, flags);
|
|
340
|
+
liveActions = snapshot.actions;
|
|
341
|
+
catalogPolicyScoped = snapshot.policyScoped === true;
|
|
339
342
|
} catch (error) {
|
|
340
343
|
catalogError = {
|
|
341
344
|
message: error?.message || 'Actions catalog fetch failed',
|
|
@@ -356,6 +359,7 @@ async function commandDoctorCertifyExecution(ctx, flags) {
|
|
|
356
359
|
runMode,
|
|
357
360
|
expectedSchemaDigest,
|
|
358
361
|
requiredActions,
|
|
362
|
+
catalogPolicyScoped,
|
|
359
363
|
});
|
|
360
364
|
|
|
361
365
|
printEnvelope({
|
|
@@ -369,6 +373,7 @@ async function commandDoctorCertifyExecution(ctx, flags) {
|
|
|
369
373
|
catalog: {
|
|
370
374
|
fetched: Array.isArray(liveActions),
|
|
371
375
|
actionCount: Array.isArray(liveActions) ? liveActions.length : 0,
|
|
376
|
+
policyScoped: catalogPolicyScoped,
|
|
372
377
|
error: catalogError,
|
|
373
378
|
},
|
|
374
379
|
},
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { requireFlag, parseJsonFlag } from '../core/args.mjs';
|
|
2
2
|
import { apiGet, apiPost } from '../core/http.mjs';
|
|
3
|
+
import { findAgentApiKeyWithoutOAuth } from '../core/context.mjs';
|
|
4
|
+
import { createCliError } from '../core/envelope.mjs';
|
|
3
5
|
import { hasVerantaToken, normalizeVenueProviderId } from '../core/venueBrand.mjs';
|
|
4
6
|
export function createTelemetryCommands({ printEnvelope, resolveAgentApiKey }) {
|
|
5
7
|
function applySharedPortfolioListingFiltersToQuery(query, flags) {
|
|
@@ -80,11 +82,35 @@ async function commandAnalyticsLeaderboard(ctx, flags, capabilities) {
|
|
|
80
82
|
});
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
/**
|
|
86
|
+
* `track actions` / `track events`: the agent's own tracked rows. The API answers only an agent
|
|
87
|
+
* key (that agent's rows, without user DIDs) or an operator key (THI-149), so the agent key goes in
|
|
88
|
+
* `x-agent-api-key`, never the URL, as thirdfy-mcp getAgentTrackActions / getAgentTrackEvents
|
|
89
|
+
* (THI-148). The API's OAuth allowlist does not open these routes, so an OAuth sign-in is skipped
|
|
90
|
+
* for the key in config (`findAgentApiKeyWithoutOAuth`). The key names its agent;
|
|
91
|
+
* `--agent-key` is optional and must be that agent. No credential: refused before any call.
|
|
92
|
+
*/
|
|
83
93
|
async function commandTrackList(ctx, flags, capabilities, kind) {
|
|
84
|
-
const
|
|
85
|
-
|
|
94
|
+
const agentApiKey = findAgentApiKeyWithoutOAuth(flags);
|
|
95
|
+
if (!agentApiKey) {
|
|
96
|
+
const error = createCliError(
|
|
97
|
+
'AGENT_API_KEY_REQUIRED',
|
|
98
|
+
`track ${kind} reads the agent's own rows and needs its API key (--agent-api-key, THIRDFY_AGENT_API_KEY or config; an OAuth sign-in cannot read them).`,
|
|
99
|
+
{ blockedStage: 'auth' },
|
|
100
|
+
);
|
|
101
|
+
error.errorFields = {
|
|
102
|
+
nextAction: 'check_credentials',
|
|
103
|
+
retryable: false,
|
|
104
|
+
hint: "Tracked actions and events belong to an agent: pass --agent-api-key, set THIRDFY_AGENT_API_KEY, or save the key with `thirdfy-agent login --agent-api-key <key>`. An OAuth sign-in cannot read them.",
|
|
105
|
+
};
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
const agentKey = String(flags.agentKey || '').trim();
|
|
109
|
+
const query = new URLSearchParams();
|
|
110
|
+
if (agentKey) query.set('agentKey', agentKey);
|
|
86
111
|
if (flags.limit) query.set('limit', String(flags.limit));
|
|
87
|
-
const
|
|
112
|
+
const search = query.toString();
|
|
113
|
+
const response = await apiGet(ctx, `/api/v1/agent/telemetry/${kind}${search ? `?${search}` : ''}`, { 'x-agent-api-key': agentApiKey });
|
|
88
114
|
printEnvelope({
|
|
89
115
|
success: true,
|
|
90
116
|
code: kind === 'events' ? 'TRACK_EVENTS_OK' : 'TRACK_ACTIONS_OK',
|
|
@@ -92,7 +118,7 @@ async function commandTrackList(ctx, flags, capabilities, kind) {
|
|
|
92
118
|
data: response,
|
|
93
119
|
meta: {
|
|
94
120
|
apiBase: ctx.apiBase,
|
|
95
|
-
agentKey,
|
|
121
|
+
agentKey: agentKey || null,
|
|
96
122
|
kind,
|
|
97
123
|
},
|
|
98
124
|
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { usageError } from '../../core/args.mjs';
|
|
2
|
+
import { findAgentApiKeyWithoutOAuth } from '../../core/context.mjs';
|
|
3
|
+
import { createCliError } from '../../core/envelope.mjs';
|
|
4
|
+
import { apiGet, apiPost } from '../../core/http.mjs';
|
|
5
|
+
import { TRACK_RECORD_REFUSALS, trackRecordRefusal } from './refusals.mjs';
|
|
6
|
+
import { VERIFY_NOTE, formatScoreLines, scoreSummaries } from './scoreSummary.mjs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `thirdfy-agent track-record <step>`: the CLI side of thirdfy-mcp's prove_track_record (THI-256,
|
|
10
|
+
* THI-291). An agent proves it controls a trading wallet and gets a score of that wallet's public
|
|
11
|
+
* venue history, signed by Thirdfy. It calls the Thirdfy API's track-record routes (THI-255,
|
|
12
|
+
* thirdfy-api-v2 `docs-dev/agents/track-record.md`):
|
|
13
|
+
*
|
|
14
|
+
* - `challenge --venue --wallet` -> `POST /api/v1/track-record/challenge`: a one-time message. The
|
|
15
|
+
* agent signs it with the wallet itself; the CLI never holds or asks for the wallet key.
|
|
16
|
+
* - `verify --challenge-id --signature` -> `POST /verify`: the wallet is bound to the agent.
|
|
17
|
+
* - `score [--venue] [--wallet]` -> `POST /score`: signed scores of the proven wallets.
|
|
18
|
+
* - `status` -> `GET /me`: the agent's proven wallets and latest scores, published or not.
|
|
19
|
+
* - `public <agentKey>` -> `GET /public/:agentKey`: what anyone sees once the owner has published.
|
|
20
|
+
*
|
|
21
|
+
* Every step but `public` sends the agent API key (`x-agent-api-key`); the API takes the agent from
|
|
22
|
+
* the key, never from the body. These routes read only that header, so an OAuth sign-in (sent as
|
|
23
|
+
* `Authorization: Bearer`, which the API's OAuth allowlist does not open here) is skipped for the
|
|
24
|
+
* agent key saved in config (`findAgentApiKeyWithoutOAuth`), or the CLI refuses with
|
|
25
|
+
* AGENT_API_KEY_REQUIRED before any call. Publishing is the owner's step (signed in to
|
|
26
|
+
* Thirdfy) and is not offered. Nothing moves funds, places orders or asks for a venue key.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const BASE = '/api/v1/track-record';
|
|
30
|
+
|
|
31
|
+
function agentApiKeyRequired() {
|
|
32
|
+
const error = createCliError('AGENT_API_KEY_REQUIRED', 'track-record needs the agent API key (every step but public).', {
|
|
33
|
+
blockedStage: 'auth',
|
|
34
|
+
});
|
|
35
|
+
error.errorFields = TRACK_RECORD_REFUSALS.AGENT_API_KEY_REQUIRED;
|
|
36
|
+
return error;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function missingArguments(step, names) {
|
|
40
|
+
return usageError(`track-record ${step} needs ${names.map((name) => `--${name}`).join(' and ')}.`, names[0]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A track-record refusal gets its next step (`errorFields`); anything else is left as thrown. */
|
|
44
|
+
function withRefusalFields(error) {
|
|
45
|
+
const code = String(error?.payload?.error || '').trim();
|
|
46
|
+
const refusal = error?.statusCode ? trackRecordRefusal(code, Number(error.statusCode)) : undefined;
|
|
47
|
+
if (refusal) error.errorFields = refusal;
|
|
48
|
+
return error;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What the agent does after a successful step. */
|
|
52
|
+
function nextStepOf(step, data) {
|
|
53
|
+
if (step === 'challenge') {
|
|
54
|
+
const challenge = data.challenge && typeof data.challenge === 'object' ? data.challenge : {};
|
|
55
|
+
return {
|
|
56
|
+
step: 'verify',
|
|
57
|
+
instruction:
|
|
58
|
+
'Sign `challenge.message` exactly as returned with the wallet (personal_sign) before `expiresAt`, then run `thirdfy-agent track-record verify --challenge-id <challengeId> --signature <signature>`. Thirdfy never sees the wallet key.',
|
|
59
|
+
challengeId: challenge.challengeId,
|
|
60
|
+
expiresAt: challenge.expiresAt,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (step === 'verify') {
|
|
64
|
+
return { step: 'score', instruction: 'Run `thirdfy-agent track-record score` to score the proven wallet.' };
|
|
65
|
+
}
|
|
66
|
+
if (step === 'score' || step === 'status') {
|
|
67
|
+
return {
|
|
68
|
+
step: 'owner_publish',
|
|
69
|
+
instruction:
|
|
70
|
+
"Scores stay private until the agent's owner publishes them, signed in to Thirdfy. After that, `thirdfy-agent track-record public <agentKey>` shows them to anyone.",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const MESSAGES = {
|
|
77
|
+
challenge: 'Challenge created: sign the message with the wallet',
|
|
78
|
+
verify: 'Wallet proven',
|
|
79
|
+
score: 'Scores signed',
|
|
80
|
+
status: 'Track record loaded',
|
|
81
|
+
public: 'Published track record loaded',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export function createTrackRecordCommands({ printEnvelope }) {
|
|
85
|
+
async function runStep(ctx, step, request) {
|
|
86
|
+
let data;
|
|
87
|
+
try {
|
|
88
|
+
// challenge and score move nothing and are safe to send again, so a timeout there is retried
|
|
89
|
+
// and never WRITE_OUTCOME_UNKNOWN; verify spends its one-use challenge and stays a write.
|
|
90
|
+
const callCtx = request.resendable ? { ...ctx, writeAction: false } : ctx;
|
|
91
|
+
const response =
|
|
92
|
+
request.method === 'GET'
|
|
93
|
+
? await apiGet(callCtx, request.route, request.headers)
|
|
94
|
+
: await apiPost(callCtx, request.route, request.body, request.headers);
|
|
95
|
+
data = response && typeof response === 'object' && !Array.isArray(response) ? response : {};
|
|
96
|
+
} catch (error) {
|
|
97
|
+
throw withRefusalFields(error);
|
|
98
|
+
}
|
|
99
|
+
const scored = step === 'score' || step === 'status' || step === 'public';
|
|
100
|
+
const summary = scored ? scoreSummaries(data.scores) : [];
|
|
101
|
+
const nextStep = nextStepOf(step, data);
|
|
102
|
+
const lead = [
|
|
103
|
+
scored ? formatScoreLines(summary) : '',
|
|
104
|
+
step === 'challenge' && data.challenge?.message ? `Message to sign:\n${data.challenge.message}` : '',
|
|
105
|
+
nextStep ? `Next: ${nextStep.instruction}` : '',
|
|
106
|
+
]
|
|
107
|
+
.filter(Boolean)
|
|
108
|
+
.join('\n');
|
|
109
|
+
printEnvelope(
|
|
110
|
+
{
|
|
111
|
+
success: true,
|
|
112
|
+
code: 'OK',
|
|
113
|
+
message: MESSAGES[step],
|
|
114
|
+
data: {
|
|
115
|
+
...data,
|
|
116
|
+
step,
|
|
117
|
+
...(scored ? { summary, verifyNote: VERIFY_NOTE } : {}),
|
|
118
|
+
...(nextStep ? { nextStep } : {}),
|
|
119
|
+
},
|
|
120
|
+
meta: { apiBase: ctx.apiBase, step },
|
|
121
|
+
},
|
|
122
|
+
{ humanLead: lead },
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function agentHeaders(flags) {
|
|
127
|
+
const agentApiKey = findAgentApiKeyWithoutOAuth(flags);
|
|
128
|
+
if (!agentApiKey) throw agentApiKeyRequired();
|
|
129
|
+
return { 'x-agent-api-key': agentApiKey };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function commandTrackRecordChallenge(ctx, flags) {
|
|
133
|
+
const venue = String(flags.venue || '').trim();
|
|
134
|
+
const wallet = String(flags.wallet || '').trim();
|
|
135
|
+
if (!venue || !wallet) throw missingArguments('challenge', ['venue', 'wallet']);
|
|
136
|
+
await runStep(ctx, 'challenge', {
|
|
137
|
+
method: 'POST',
|
|
138
|
+
route: `${BASE}/challenge`,
|
|
139
|
+
resendable: true,
|
|
140
|
+
body: { venue, wallet },
|
|
141
|
+
headers: agentHeaders(flags),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function commandTrackRecordVerify(ctx, flags) {
|
|
146
|
+
const challengeId = String(flags.challengeId || '').trim();
|
|
147
|
+
const signature = String(flags.signature || '').trim();
|
|
148
|
+
if (!challengeId || !signature) throw missingArguments('verify', ['challenge-id', 'signature']);
|
|
149
|
+
await runStep(ctx, 'verify', {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
route: `${BASE}/verify`,
|
|
152
|
+
body: { challengeId, signature },
|
|
153
|
+
headers: agentHeaders(flags),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function commandTrackRecordScore(ctx, flags) {
|
|
158
|
+
const venue = String(flags.venue || '').trim();
|
|
159
|
+
const wallet = String(flags.wallet || '').trim();
|
|
160
|
+
await runStep(ctx, 'score', {
|
|
161
|
+
method: 'POST',
|
|
162
|
+
route: `${BASE}/score`,
|
|
163
|
+
resendable: true,
|
|
164
|
+
body: { ...(venue ? { venue } : {}), ...(wallet ? { wallet } : {}) },
|
|
165
|
+
headers: agentHeaders(flags),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function commandTrackRecordStatus(ctx, flags) {
|
|
170
|
+
await runStep(ctx, 'status', { method: 'GET', route: `${BASE}/me`, headers: agentHeaders(flags) });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function commandTrackRecordPublic(ctx, flags) {
|
|
174
|
+
// Commander requires <agentKey>; it arrives as the positional `query`.
|
|
175
|
+
const positional = Array.isArray(flags.query) ? flags.query[0] : flags.query;
|
|
176
|
+
const agentKey = String(positional || '').trim().toLowerCase();
|
|
177
|
+
await runStep(ctx, 'public', { method: 'GET', route: `${BASE}/public/${encodeURIComponent(agentKey)}`, headers: {} });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
commandTrackRecordChallenge,
|
|
182
|
+
commandTrackRecordVerify,
|
|
183
|
+
commandTrackRecordScore,
|
|
184
|
+
commandTrackRecordStatus,
|
|
185
|
+
commandTrackRecordPublic,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an agent does after each refusal of the Thirdfy API's track-record routes (THI-255,
|
|
3
|
+
* `/api/v1/track-record/*`). The API sends only `{ success: false, error, message }`; the CLI adds
|
|
4
|
+
* `nextAction`, `retryable` and a `hint` (as `error.errorFields`, read by `describeCliError`), the
|
|
5
|
+
* same as thirdfy-mcp's prove_track_record (`src/tools/trackRecord/trackRecordRefusals.ts`), with
|
|
6
|
+
* CLI commands in the hints instead of the MCP tool's steps.
|
|
7
|
+
* Codes: thirdfy-api-v2 `docs-dev/agents/track-record.md`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const startOver = (why) => ({
|
|
11
|
+
nextAction: 'start_new_challenge',
|
|
12
|
+
retryable: false,
|
|
13
|
+
hint: `${why} Run \`thirdfy-agent track-record challenge\` again and sign the new message.`,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const TRACK_RECORD_REFUSALS = Object.freeze({
|
|
17
|
+
AGENT_API_KEY_REQUIRED: {
|
|
18
|
+
nextAction: 'check_credentials',
|
|
19
|
+
retryable: false,
|
|
20
|
+
hint: "Track records belong to an agent: pass --agent-api-key, set THIRDFY_AGENT_API_KEY, or save the key with `thirdfy-agent login --agent-api-key <key>`. An OAuth sign-in cannot prove a track record from the CLI; use the agent's API key.",
|
|
21
|
+
},
|
|
22
|
+
AGENT_API_KEY_INVALID: {
|
|
23
|
+
nextAction: 'check_credentials',
|
|
24
|
+
retryable: false,
|
|
25
|
+
hint: 'Thirdfy did not accept this agent API key. Use the key of the agent that trades the wallet.',
|
|
26
|
+
},
|
|
27
|
+
AGENT_REGISTRY_UNAVAILABLE: {
|
|
28
|
+
nextAction: 'retry_later',
|
|
29
|
+
retryable: true,
|
|
30
|
+
hint: 'Thirdfy could not check the agent key right now. This is an outage, not a bad key: retry shortly.',
|
|
31
|
+
},
|
|
32
|
+
TRACK_RECORD_UNAVAILABLE: {
|
|
33
|
+
nextAction: 'retry_later',
|
|
34
|
+
retryable: true,
|
|
35
|
+
hint: 'Thirdfy could not check the key or reach its store. This is an outage, not a bad key: retry shortly.',
|
|
36
|
+
},
|
|
37
|
+
TRACK_RECORD_DISABLED: {
|
|
38
|
+
nextAction: 'contact_support',
|
|
39
|
+
retryable: false,
|
|
40
|
+
hint: 'Track records are switched off on this Thirdfy environment.',
|
|
41
|
+
},
|
|
42
|
+
SCORE_SIGNER_UNAVAILABLE: {
|
|
43
|
+
nextAction: 'contact_support',
|
|
44
|
+
retryable: false,
|
|
45
|
+
hint: 'This Thirdfy environment has no score signing key, so it stores no score. The wallet proof is kept.',
|
|
46
|
+
},
|
|
47
|
+
RPC_UNAVAILABLE: {
|
|
48
|
+
nextAction: 'retry_later',
|
|
49
|
+
retryable: true,
|
|
50
|
+
hint: "Thirdfy could not reach the venue chain to check a contract wallet's signature. Nothing was refused: retry the verify shortly with the same challenge, before it expires.",
|
|
51
|
+
},
|
|
52
|
+
VENUE_HISTORY_UNAVAILABLE: {
|
|
53
|
+
nextAction: 'retry_later',
|
|
54
|
+
retryable: true,
|
|
55
|
+
hint: "The venue's public history API did not answer. Retry the score shortly.",
|
|
56
|
+
},
|
|
57
|
+
VENUE_UNSUPPORTED: {
|
|
58
|
+
nextAction: 'fix_request',
|
|
59
|
+
retryable: false,
|
|
60
|
+
hint: 'Thirdfy does not score this venue yet. Use a venue the API supports (the refusal message says which).',
|
|
61
|
+
},
|
|
62
|
+
WALLET_INVALID: {
|
|
63
|
+
nextAction: 'fix_request',
|
|
64
|
+
retryable: false,
|
|
65
|
+
hint: '--wallet must be a 0x EVM address: the Hyperliquid account, or the Polymarket proxy wallet trades settle to.',
|
|
66
|
+
},
|
|
67
|
+
SIGNATURE_INVALID: {
|
|
68
|
+
nextAction: 'fix_request',
|
|
69
|
+
retryable: false,
|
|
70
|
+
hint: '--signature must be the 0x hex signature of the challenge message, signed with the wallet (personal_sign / EIP-191, or EIP-1271 for a contract wallet).',
|
|
71
|
+
},
|
|
72
|
+
SIGNATURE_MISMATCH: {
|
|
73
|
+
nextAction: 'fix_request',
|
|
74
|
+
retryable: false,
|
|
75
|
+
hint: 'The signature is not from the challenged wallet. Sign the exact challenge message, unchanged, with that wallet.',
|
|
76
|
+
},
|
|
77
|
+
CHALLENGE_NOT_FOUND: startOver('This challenge does not exist for this agent.'),
|
|
78
|
+
CHALLENGE_USED: startOver('This challenge was already used.'),
|
|
79
|
+
CHALLENGE_EXPIRED: startOver('The challenge expired (past its expiresAt).'),
|
|
80
|
+
WALLET_NOT_PROVEN: {
|
|
81
|
+
nextAction: 'prove_wallet',
|
|
82
|
+
retryable: false,
|
|
83
|
+
hint: 'This agent has not proven that wallet. Run `thirdfy-agent track-record challenge`, sign the message with the wallet, then `thirdfy-agent track-record verify`.',
|
|
84
|
+
},
|
|
85
|
+
WALLET_BOUND_TO_OTHER_AGENT: {
|
|
86
|
+
nextAction: 'contact_support',
|
|
87
|
+
retryable: false,
|
|
88
|
+
hint: 'Another agent already proved this wallet. A wallet belongs to one agent.',
|
|
89
|
+
},
|
|
90
|
+
TRACK_RECORD_NOT_PUBLISHED: {
|
|
91
|
+
nextAction: 'owner_publish',
|
|
92
|
+
retryable: false,
|
|
93
|
+
hint: "The agent's owner has not published its track record. The owner opts in, signed in to Thirdfy; an agent key cannot publish.",
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `/score` when no wallet scored (thirdfy-api-v2 4.7.0, THI-280): 503 when every wallet only ran out
|
|
99
|
+
* of time or hit the per-call cap, else 502. Both carry the per-wallet `results`.
|
|
100
|
+
*/
|
|
101
|
+
const NO_WALLET_SCORED = Object.freeze({
|
|
102
|
+
503: {
|
|
103
|
+
nextAction: 'retry_later',
|
|
104
|
+
retryable: true,
|
|
105
|
+
hint: 'No wallet scored in this call: each one ran out of time or passed the per-call cap. Call `thirdfy-agent track-record score` again, or pass --venue and --wallet to score one.',
|
|
106
|
+
},
|
|
107
|
+
502: {
|
|
108
|
+
nextAction: 'retry_later',
|
|
109
|
+
retryable: true,
|
|
110
|
+
hint: "No wallet could be scored; each wallet's error is in `results` (VENUE_HISTORY_UNAVAILABLE: the venue's public API did not answer). Retry the score shortly.",
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/** The next step for a track-record refusal, or undefined for a code this table does not know. */
|
|
115
|
+
export function trackRecordRefusal(code, status) {
|
|
116
|
+
if (code === 'NO_WALLET_SCORED') return NO_WALLET_SCORED[status === 503 ? 503 : 502];
|
|
117
|
+
return TRACK_RECORD_REFUSALS[code];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The line a score adds for each reason it is not eligible. */
|
|
121
|
+
export const INELIGIBLE_REASON_NOTES = Object.freeze({
|
|
122
|
+
SHORT_HISTORY: "SHORT_HISTORY: the account history is shorter than the policy's minimum days.",
|
|
123
|
+
FEW_TRADES: "FEW_TRADES: fewer trades in the window than the policy's minimum.",
|
|
124
|
+
SHORT_HOLDS: "SHORT_HOLDS: too many round trips were held for less than the policy's minimum time.",
|
|
125
|
+
SELF_TRADES: 'SELF_TRADES: too many trades were self-trades.',
|
|
126
|
+
HISTORY_TRUNCATED:
|
|
127
|
+
'HISTORY_TRUNCATED: the venue had more history pages than Thirdfy reads, so trade counts and PnL are lower bounds. The score is signed but not eligible.',
|
|
128
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { INELIGIBLE_REASON_NOTES } from './refusals.mjs';
|
|
2
|
+
|
|
3
|
+
const isRecord = (value) => Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
4
|
+
|
|
5
|
+
const stringList = (value) => (Array.isArray(value) ? value.filter((item) => typeof item === 'string') : []);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One line per signed score: venue, wallet, eligible or why not, and the signer and digest. The
|
|
9
|
+
* same fields as thirdfy-mcp prove_track_record's `summary`.
|
|
10
|
+
*/
|
|
11
|
+
export function scoreSummaries(scores) {
|
|
12
|
+
return (Array.isArray(scores) ? scores : []).filter(isRecord).map((signed) => {
|
|
13
|
+
const score = isRecord(signed.score) ? signed.score : {};
|
|
14
|
+
const metrics = isRecord(score.metrics) ? score.metrics : {};
|
|
15
|
+
const reasons = stringList(score.reasons);
|
|
16
|
+
return {
|
|
17
|
+
venue: score.venue,
|
|
18
|
+
wallet: score.wallet,
|
|
19
|
+
eligible: score.eligible,
|
|
20
|
+
reasons,
|
|
21
|
+
...(reasons.length ? { reasonNotes: reasons.map((reason) => INELIGIBLE_REASON_NOTES[reason] ?? reason) } : {}),
|
|
22
|
+
...(isRecord(score.window) ? { windowDays: score.window.days } : {}),
|
|
23
|
+
pnlUsd: metrics.pnlUsd,
|
|
24
|
+
maxDrawdownUsd: metrics.maxDrawdownUsd,
|
|
25
|
+
trades: metrics.trades,
|
|
26
|
+
signer: signed.signer,
|
|
27
|
+
digest: signed.digest,
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* How anyone checks a score. Since thirdfy-api-v2 4.7.0 (THI-280) the digest carries a domain tag,
|
|
34
|
+
* and the signer to trust is Thirdfy's (`GET /api/v1/track-record/signer`), never the `signer`
|
|
35
|
+
* field inside the score, which anyone can set.
|
|
36
|
+
*/
|
|
37
|
+
export const VERIFY_NOTE =
|
|
38
|
+
'Anyone can check a score: the digest is keccak256 of `thirdfy-track-record/v1`, a newline, then the canonical JSON of `score` (keys sorted). Recover the EIP-191 signer from `digest` and `signature` and compare it with Thirdfy\'s signer from `GET /api/v1/track-record/signer`, not with the `signer` field in the score. `score.evidence` lists the public venue requests it read.';
|
|
39
|
+
|
|
40
|
+
const usd = (value) => {
|
|
41
|
+
const n = Number(value);
|
|
42
|
+
if (value === null || value === undefined || !Number.isFinite(n)) return 'n/a';
|
|
43
|
+
const sign = n < 0 ? '-' : '';
|
|
44
|
+
return `${sign}$${Math.abs(n).toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Human mode: one line per score, then the next step. */
|
|
48
|
+
export function formatScoreLines(summaries) {
|
|
49
|
+
if (!summaries.length) return 'No scores yet.';
|
|
50
|
+
return summaries
|
|
51
|
+
.map((row) => {
|
|
52
|
+
const status = row.eligible ? 'eligible' : `not eligible (${row.reasons.join(', ') || 'no reason given'})`;
|
|
53
|
+
const window = row.windowDays ? ` over ${row.windowDays}d` : '';
|
|
54
|
+
return `${row.venue} ${row.wallet}: ${status}; PnL ${usd(row.pnlUsd)}${window}, max drawdown ${usd(row.maxDrawdownUsd)}, ${row.trades ?? 'n/a'} trades`;
|
|
55
|
+
})
|
|
56
|
+
.join('\n');
|
|
57
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "CLI Endpoint: track-record (challenge, verify, score, status, public)",
|
|
4
|
+
"description": "The API's track-record body (thirdfy-api-v2 /api/v1/track-record/*) plus the fields thirdfy-mcp prove_track_record adds: step, summary and verifyNote (score, status, public), nextStep (all but public).",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["step"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"step": { "enum": ["challenge", "verify", "score", "status", "public"] },
|
|
9
|
+
"challenge": {
|
|
10
|
+
"type": "object",
|
|
11
|
+
"required": ["challengeId", "message", "expiresAt"],
|
|
12
|
+
"properties": {
|
|
13
|
+
"challengeId": { "type": "string" },
|
|
14
|
+
"venue": { "type": "string" },
|
|
15
|
+
"wallet": { "type": "string" },
|
|
16
|
+
"message": { "type": "string" },
|
|
17
|
+
"expiresAt": { "type": "string" }
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"wallet": { "type": "object" },
|
|
21
|
+
"wallets": { "type": "array" },
|
|
22
|
+
"scores": {
|
|
23
|
+
"type": "array",
|
|
24
|
+
"items": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"required": ["score", "digest", "signature"],
|
|
27
|
+
"properties": {
|
|
28
|
+
"score": { "type": "object" },
|
|
29
|
+
"digest": { "type": "string" },
|
|
30
|
+
"signature": { "type": "string" },
|
|
31
|
+
"signer": { "type": "string" }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"results": {
|
|
36
|
+
"type": "array",
|
|
37
|
+
"items": {
|
|
38
|
+
"type": "object",
|
|
39
|
+
"required": ["venue", "wallet", "ok"],
|
|
40
|
+
"properties": {
|
|
41
|
+
"venue": { "type": "string" },
|
|
42
|
+
"wallet": { "type": "string" },
|
|
43
|
+
"ok": { "type": "boolean" },
|
|
44
|
+
"reused": { "type": "boolean" },
|
|
45
|
+
"score": { "type": "object" },
|
|
46
|
+
"error": { "type": "string" },
|
|
47
|
+
"message": { "type": "string" }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"summary": {
|
|
52
|
+
"type": "array",
|
|
53
|
+
"items": {
|
|
54
|
+
"type": "object",
|
|
55
|
+
"required": ["reasons"],
|
|
56
|
+
"properties": {
|
|
57
|
+
"venue": { "type": ["string", "null"] },
|
|
58
|
+
"wallet": { "type": ["string", "null"] },
|
|
59
|
+
"eligible": { "type": ["boolean", "null"] },
|
|
60
|
+
"reasons": { "type": "array", "items": { "type": "string" } },
|
|
61
|
+
"reasonNotes": { "type": "array", "items": { "type": "string" } },
|
|
62
|
+
"windowDays": { "type": ["number", "null"] },
|
|
63
|
+
"pnlUsd": { "type": ["number", "null"] },
|
|
64
|
+
"maxDrawdownUsd": { "type": ["number", "null"] },
|
|
65
|
+
"trades": { "type": ["number", "null"] },
|
|
66
|
+
"signer": { "type": ["string", "null"] },
|
|
67
|
+
"digest": { "type": ["string", "null"] }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"verifyNote": { "type": "string" },
|
|
72
|
+
"nextStep": {
|
|
73
|
+
"type": "object",
|
|
74
|
+
"required": ["step", "instruction"],
|
|
75
|
+
"properties": {
|
|
76
|
+
"step": { "enum": ["verify", "score", "owner_publish"] },
|
|
77
|
+
"instruction": { "type": "string" },
|
|
78
|
+
"challengeId": { "type": "string" },
|
|
79
|
+
"expiresAt": { "type": "string" }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/core/context.mjs
CHANGED
|
@@ -4,7 +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
|
+
import { apiBaseFor, isOAuthAccessToken, oauthSessionForApi, oauthSessionIsAgentCredential } from './oauth/session.mjs';
|
|
8
8
|
import {
|
|
9
9
|
normalizeCustodyMode,
|
|
10
10
|
normalizeProfileName,
|
|
@@ -139,6 +139,19 @@ export function findAgentApiKey(flags = {}) {
|
|
|
139
139
|
return String(config?.agent?.apiKey || '').trim();
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* The agent API key for a route an OAuth sign-in may not call. The API lets an OAuth bearer reach
|
|
144
|
+
* only the routes in its allowlist (thirdfy-api-v2 `auth/oauth/oauthRouteAllowlist.ts`): not
|
|
145
|
+
* `/track-record/*` or `/telemetry/{actions,events}`. So an OAuth access token from
|
|
146
|
+
* `findAgentApiKey` is skipped for config `agent.apiKey`; empty when there is no real agent key.
|
|
147
|
+
*/
|
|
148
|
+
export function findAgentApiKeyWithoutOAuth(flags = {}) {
|
|
149
|
+
const key = findAgentApiKey(flags);
|
|
150
|
+
if (key && !isOAuthAccessToken(key)) return key;
|
|
151
|
+
const saved = String(loadProfileConfig()?.agent?.apiKey || '').trim();
|
|
152
|
+
return saved && !isOAuthAccessToken(saved) ? saved : '';
|
|
153
|
+
}
|
|
154
|
+
|
|
142
155
|
/**
|
|
143
156
|
* Whose credits a credits read is about. Same precedence as `wallet profile`: an owner credential
|
|
144
157
|
* passed as a flag (--owner-session-token or --auth-token), then an explicit --agent-api-key or an
|
|
@@ -22,6 +22,9 @@ export const NEXT_ACTIONS = Object.freeze({
|
|
|
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
23
|
reauthenticate: 'The sign-in expired or was revoked. Sign in again: `thirdfy-agent login`.',
|
|
24
24
|
reauthenticate_with_trade: 'This sign-in is read-only. Sign in again with trade access: `thirdfy-agent login --trade`.',
|
|
25
|
+
start_new_challenge: 'The wallet challenge cannot be used. Run `thirdfy-agent track-record challenge` again and sign the new message.',
|
|
26
|
+
prove_wallet: 'The agent has not proven this wallet. Run `thirdfy-agent track-record challenge`, sign it with the wallet, then `track-record verify`.',
|
|
27
|
+
owner_publish: "The agent's owner has not published its track record. The owner publishes it, signed in to Thirdfy.",
|
|
25
28
|
});
|
|
26
29
|
|
|
27
30
|
/** Credits refusal codes the API sends (HTTP 402). */
|
|
@@ -138,9 +141,20 @@ function withoutUndefined(fields) {
|
|
|
138
141
|
/**
|
|
139
142
|
* `{ errorCode, retryable, retryAfterSeconds?, nextAction, hint? }` for a thrown CLI error.
|
|
140
143
|
* It reads what `src/core/http.mjs` attaches (`statusCode`, `payload`, `write`, `retryAfterSeconds`)
|
|
141
|
-
* and what `createCliError` attaches (`payload.error`, `payload.blockedStage`).
|
|
144
|
+
* and what `createCliError` attaches (`payload.error`, `payload.blockedStage`). A command that knows
|
|
145
|
+
* the next step for a refusal sets `error.errorFields` (`{ retryable, nextAction, hint }`), and those
|
|
146
|
+
* win: `track-record` does, as thirdfy-mcp's prove_track_record does for the same codes.
|
|
142
147
|
*/
|
|
143
148
|
export function describeCliError(error) {
|
|
149
|
+
const fields = describeCliErrorFromResponse(error);
|
|
150
|
+
if (!isRecord(error?.errorFields)) return fields;
|
|
151
|
+
const merged = { ...fields, ...withoutUndefined(error.errorFields) };
|
|
152
|
+
// retryAfterSeconds goes with a retryable answer only.
|
|
153
|
+
if (merged.retryable === false) delete merged.retryAfterSeconds;
|
|
154
|
+
return merged;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function describeCliErrorFromResponse(error) {
|
|
144
158
|
const payload = isRecord(error?.payload) ? error.payload : {};
|
|
145
159
|
const status = Number(error?.statusCode || 0);
|
|
146
160
|
const data = isRecord(payload.data) ? payload.data : {};
|
|
@@ -227,6 +227,7 @@ export function buildExecutionCertificationReport({
|
|
|
227
227
|
runMode = null,
|
|
228
228
|
expectedSchemaDigest = null,
|
|
229
229
|
requiredActions = [],
|
|
230
|
+
catalogPolicyScoped = false,
|
|
230
231
|
} = {}) {
|
|
231
232
|
const version = String(cliVersion || PACKAGE_VERSION).trim();
|
|
232
233
|
const normalizedRequiredActions = parseRequiredActions(requiredActions);
|
|
@@ -259,10 +260,25 @@ export function buildExecutionCertificationReport({
|
|
|
259
260
|
}
|
|
260
261
|
}
|
|
261
262
|
|
|
263
|
+
// A keyed catalog lists only the agent's allowlist. Bare certification then checks the venue
|
|
264
|
+
// writes this agent may call; the rest are reported as skipped, not as missing schemas.
|
|
265
|
+
// An unscoped (public) catalog that lacks a venue action still fails closed.
|
|
266
|
+
const scopeToAllowlist = bareCertification && catalogFetched && catalogPolicyScoped === true;
|
|
267
|
+
const liveActionKeys = new Set();
|
|
268
|
+
for (const row of liveActions || []) {
|
|
269
|
+
for (const name of [row?.action, ...(Array.isArray(row?.aliases) ? row.aliases : [])]) {
|
|
270
|
+
if (name) liveActionKeys.add(normalizeActionKey(String(name)));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const skippedVenueActions = [];
|
|
262
274
|
const venueActionNames =
|
|
263
275
|
normalizedRequiredActions.length > 0
|
|
264
276
|
? normalizedRequiredActions.filter((action) => isWriteAction(action))
|
|
265
|
-
: VENUE_WRITE_ACTION_FIXTURES.map((fixture) => fixture.action)
|
|
277
|
+
: VENUE_WRITE_ACTION_FIXTURES.map((fixture) => fixture.action).filter((action) => {
|
|
278
|
+
if (!scopeToAllowlist || liveActionKeys.has(normalizeActionKey(action))) return true;
|
|
279
|
+
skippedVenueActions.push({ action, reason: 'NOT_ON_AGENT_ALLOWLIST' });
|
|
280
|
+
return false;
|
|
281
|
+
});
|
|
266
282
|
|
|
267
283
|
const venueActions = runSchemaCompatibilityChecks(catalogByAction, venueActionNames, {
|
|
268
284
|
requireLiveCatalog: bareCertification,
|
|
@@ -380,6 +396,7 @@ export function buildExecutionCertificationReport({
|
|
|
380
396
|
commandAvailability,
|
|
381
397
|
authorityInputs,
|
|
382
398
|
venueActions,
|
|
399
|
+
skippedVenueActions,
|
|
383
400
|
checks,
|
|
384
401
|
};
|
|
385
402
|
}
|
package/src/runtime/handlers.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { createConsoleMandateCommands } from '../commands/onboarding/consoleMand
|
|
|
12
12
|
import { createBootstrapCommands } from '../commands/onboarding/bootstrap.mjs';
|
|
13
13
|
import { createDiscoveryCommands } from '../commands/discovery.mjs';
|
|
14
14
|
import { createSignalsCommands } from '../commands/signals/command.mjs';
|
|
15
|
+
import { createTrackRecordCommands } from '../commands/trackRecord/command.mjs';
|
|
15
16
|
import { createCreditsCommands } from '../commands/account/credits.mjs';
|
|
16
17
|
import { createChatCommands } from '../commands/chat.mjs';
|
|
17
18
|
import { createBrainCommands } from '../commands/brain.mjs';
|
|
@@ -70,6 +71,8 @@ export function createRuntimeHandlers(deps) {
|
|
|
70
71
|
|
|
71
72
|
const __signals = createSignalsCommands({ printEnvelope });
|
|
72
73
|
|
|
74
|
+
const __trackRecord = createTrackRecordCommands({ printEnvelope });
|
|
75
|
+
|
|
73
76
|
const __credits = createCreditsCommands({
|
|
74
77
|
printEnvelope,
|
|
75
78
|
getAuthToken,
|
|
@@ -244,6 +247,11 @@ export function createRuntimeHandlers(deps) {
|
|
|
244
247
|
commandCatalogsList: __discovery.commandCatalogsList,
|
|
245
248
|
commandActions: __discovery.commandActions,
|
|
246
249
|
commandSignals: __signals.commandSignals,
|
|
250
|
+
commandTrackRecordChallenge: __trackRecord.commandTrackRecordChallenge,
|
|
251
|
+
commandTrackRecordVerify: __trackRecord.commandTrackRecordVerify,
|
|
252
|
+
commandTrackRecordScore: __trackRecord.commandTrackRecordScore,
|
|
253
|
+
commandTrackRecordStatus: __trackRecord.commandTrackRecordStatus,
|
|
254
|
+
commandTrackRecordPublic: __trackRecord.commandTrackRecordPublic,
|
|
247
255
|
commandCreditsBalance: __discovery.commandCreditsBalance,
|
|
248
256
|
commandCreditsBuy: __credits.commandCreditsBuy,
|
|
249
257
|
commandCreditsTransactions: __credits.commandCreditsTransactions,
|