@thirdfy/agent-cli 0.1.9 → 0.1.11

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 CHANGED
@@ -4,6 +4,22 @@ All notable changes to `@thirdfy/agent-cli` are documented here. The format is b
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.1.11] - 2026-04-16
8
+
9
+ **npm:** [`@thirdfy/agent-cli@0.1.11`](https://www.npmjs.com/package/@thirdfy/agent-cli/v/0.1.11)
10
+ **Git tag:** `v0.1.11`
11
+
12
+ ### Added
13
+
14
+ - **Self identity bootstrap during login:** `thirdfy-agent login` can now auto-bootstrap scoped self identity when `--agent-key` is provided, and persist the returned `agentApiKey` without manual env injection.
15
+ - **Custody mode profile contract:** Added persisted `custodyMode` (`managed` or `external`) across `profile init`, `profile use`, and `whoami`/config UX.
16
+ - **Self custody validation E2E:** Added `e2e:self:managed-custody-validation` and wired it into production certification flow.
17
+
18
+ ## [0.1.9] - 2026-04-08
19
+
20
+ **npm:** [`@thirdfy/agent-cli@0.1.9`](https://www.npmjs.com/package/@thirdfy/agent-cli/v/0.1.9)
21
+ **Git tag:** [`v0.1.9`](https://github.com/thirdfy/agent-cli/releases/tag/v0.1.9)
22
+
7
23
  ### Fixed
8
24
 
9
25
  - **Self preflight routing for swap-like actions:** `preflight --run-mode self` now selects the validation route by action capability. When an action advertises `supportsExecuteIntent=false` and `supportsBuildTx=true`, preflight uses `build-tx` directly instead of forcing execute-intent validation.
package/README.md CHANGED
@@ -16,8 +16,13 @@ If you want the full developer docs, start here:
16
16
  - [API Reference home](https://docs.thirdfy.com/api)
17
17
  - [LLM docs map (llms.txt)](https://docs.thirdfy.com/llms.txt)
18
18
  - [LLM full API snapshot (llms-full.txt)](https://docs.thirdfy.com/llms-full.txt)
19
+ - [Provider docs index (repo)](./docs/providers/index.md)
20
+ - [Action inventory and compatibility](./docs/action-inventory-and-compatibility.md)
21
+ - [Command reference](./docs/command-reference.md)
19
22
  - [DogeOS quickstart (repo)](./docs/dogeos-quickstart.md)
23
+ - [Bitfinex CEX workflow (repo)](./docs/bitfinex-cex-workflow.md)
20
24
  - [Action onboarding standard](./docs/action-onboarding-standard.md)
25
+ - [Provider docs governance model](./docs/provider-docs-governance.md)
21
26
  - [Skill integration RFC](./docs/skill-integration-rfc.md)
22
27
 
23
28
  ## Install
@@ -76,9 +81,13 @@ Execution-only workflows can run with only `AGENT_API_KEY` after onboarding is c
76
81
  # Store auth defaults once
77
82
  thirdfy-agent login --auth-token "$THIRDFY_AUTH_TOKEN" --agent-api-key "$AGENT_API_KEY" --json
78
83
 
84
+ # Bootstrap scoped self identity + custody mode in one step
85
+ thirdfy-agent login --auth-token "$THIRDFY_AUTH_TOKEN" --agent-key "0xYOUR_AGENT_KEY" --run-mode self --custody-mode external --wallet-address "0xYOUR_AGENT_KEY" --json
86
+
79
87
  # Inspect config
80
88
  thirdfy-agent whoami --json
81
89
  thirdfy-agent config set --key runMode --value self --json
90
+ thirdfy-agent config set --key custodyMode --value external --json
82
91
 
83
92
  # Local-only logout (default)
84
93
  thirdfy-agent logout --json
@@ -131,6 +140,9 @@ Auth expectations by mode:
131
140
  - `self`: requires execution identity (`agentApiKey`) today; keyless self lane is blocked with deterministic `SELF_OPEN_DISABLED`.
132
141
  - `hybrid`: requires execution identity and governed mirror semantics.
133
142
  - `thirdfy`: requires execution identity and delegated governance path.
143
+ - custody mode defaults are profile-aware:
144
+ - `managed` default for `thirdfy`
145
+ - `external` default for `self` and `hybrid`
134
146
 
135
147
  Selection precedence:
136
148
 
@@ -215,6 +227,14 @@ Jeff-friendly aliases map directly to core commands:
215
227
  - `thirdfy-agent jeff status --intent-id ...` -> alias of `intent-status`
216
228
  - `thirdfy-agent prompt "<text>"` -> captures prompt intent and suggests deterministic execution commands
217
229
 
230
+ ## Provider-specific workflows
231
+
232
+ Provider and venue guides are kept outside `README` so this page stays stable as integrations grow.
233
+
234
+ - Provider index: [`docs/providers/index.md`](./docs/providers/index.md)
235
+ - Bitfinex runbook: [`docs/providers/bitfinex.md`](./docs/providers/bitfinex.md)
236
+ - DogeOS runbook: [`docs/providers/dogeos.md`](./docs/providers/dogeos.md)
237
+
218
238
  ## Command groups
219
239
 
220
240
  - Discovery: `catalogs list`, `actions`
@@ -226,6 +246,12 @@ Jeff-friendly aliases map directly to core commands:
226
246
  - Delegation execution helpers: `delegation balance`, `delegation redeem`
227
247
  - Account: `credits balance`
228
248
 
249
+ ## Where to find what is supported
250
+
251
+ - Full command groups and usage map: [`docs/command-reference.md`](./docs/command-reference.md)
252
+ - Live action inventory in your environment: [`docs/action-inventory-and-compatibility.md`](./docs/action-inventory-and-compatibility.md)
253
+ - Provider runbooks and provider status: [`docs/providers/index.md`](./docs/providers/index.md)
254
+
229
255
  ## Delegation lifecycle (canonical)
230
256
 
231
257
  Use `create` + `activate` as the default delegated execution lifecycle:
@@ -325,6 +351,7 @@ thirdfy-agent managed run --agent-api-key "$AGENT_API_KEY" --run-mode thirdfy --
325
351
  npm run validate:cli-contract-schemas
326
352
  npm test
327
353
  npm run smoke:cli
354
+ npm run e2e:self:managed-custody-validation
328
355
  npm run e2e:managed:delegation:parity
329
356
  ```
330
357
 
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { randomUUID } from 'crypto';
3
+ import { createHmac, randomUUID } from 'crypto';
4
4
  import { spawnSync } from 'child_process';
5
5
  import fs from 'fs';
6
6
  import os from 'os';
@@ -18,7 +18,13 @@ const {
18
18
  const DEFAULT_API_BASE = 'https://api.thirdfy.com';
19
19
  const DEFAULT_TIMEOUT_MS = 30000;
20
20
  const DEFAULT_CATALOG_CACHE_TTL_MS = 60_000;
21
+ const DEFAULT_BITFINEX_BASE_URL = 'https://api.bitfinex.com';
22
+ const BITFINEX_REQUIRED_SCOPE_PERMISSIONS = {
23
+ funding: { read: 1, write: 1 },
24
+ wallets: { read: 1, write: 1 },
25
+ };
21
26
  const RUN_MODES = ['thirdfy', 'self', 'hybrid'];
27
+ const CUSTODY_MODES = ['managed', 'external'];
22
28
  const EFFECT_CHECK_MODES = ['strict', 'relaxed', 'off'];
23
29
  const PROFILE_DEFAULTS = {
24
30
  personal: 'self',
@@ -52,6 +58,7 @@ main().catch((error) => {
52
58
  const payload = error?.payload && typeof error.payload === 'object' ? error.payload : {};
53
59
  const blockedReason = String(payload.blockedReason || payload.error || '').trim() || undefined;
54
60
  const blockedStage = String(payload.blockedStage || '').trim() || undefined;
61
+ const onboardingHint = buildTopLevelOnboardingHint(payload);
55
62
  printEnvelope({
56
63
  success: false,
57
64
  code: statusCode ? 'API_REQUEST_FAILED' : 'CLI_UNHANDLED_ERROR',
@@ -60,6 +67,7 @@ main().catch((error) => {
60
67
  statusCode,
61
68
  blockedReason,
62
69
  blockedStage,
70
+ onboardingHint,
63
71
  payload,
64
72
  },
65
73
  meta: { version: CLI_VERSION, apiBase: context.apiBase },
@@ -67,10 +75,27 @@ main().catch((error) => {
67
75
  process.exit(1);
68
76
  });
69
77
 
78
+ function buildTopLevelOnboardingHint(payload) {
79
+ const reason = String(payload?.blockedReason || payload?.error || '').trim();
80
+ if (!reason) return null;
81
+ if (
82
+ ['CEX_CREDENTIAL_NOT_FOUND', 'BITFINEX_CREDENTIALS_NOT_IN_CONTEXT', 'BITFINEX_ENV_CREDENTIALS_NOT_CONFIGURED'].includes(
83
+ reason
84
+ )
85
+ ) {
86
+ return {
87
+ type: 'bitfinex_credentials',
88
+ command: buildBitfinexOnboardingCommand(),
89
+ };
90
+ }
91
+ return null;
92
+ }
93
+
70
94
  async function main() {
71
95
  const profileState = resolveProfileState(globalFlags);
72
96
  context.profile = profileState.profile;
73
97
  context.runMode = profileState.runMode;
98
+ context.custodyMode = profileState.custodyMode;
74
99
  context.profileConfigPath = profileState.configPath;
75
100
 
76
101
  if (globalFlags.version) {
@@ -518,10 +543,15 @@ async function commandPreflight(ctx, flags, capabilities) {
518
543
  const runMode = getEffectiveRunMode(ctx, flags);
519
544
  enforceChainCompatibility(capabilities, flags, runMode);
520
545
  const backendResult = await runPreflightByMode(runMode, ctx, flags, resolved);
521
- const normalized = backendResult.normalized;
546
+ const normalized = applyExecutionFallbackHints(backendResult.normalized, {
547
+ flags,
548
+ runMode,
549
+ resolvedAction: resolved.resolvedAction,
550
+ });
551
+ const preflightBlocked = !normalized.success && Boolean(normalized.preflightBlocked);
522
552
  printEnvelope({
523
553
  success: normalized.success,
524
- code: normalized.success ? 'PREFLIGHT_OK' : 'PREFLIGHT_FAILED',
554
+ code: normalized.success ? 'PREFLIGHT_OK' : preflightBlocked ? 'PREFLIGHT_BLOCKED' : 'PREFLIGHT_FAILED',
525
555
  message: backendResult.message,
526
556
  data: normalized,
527
557
  meta: {
@@ -548,7 +578,11 @@ async function commandRun(ctx, flags, capabilities) {
548
578
  const backendResult = await runExecutionByMode(runMode, ctx, flags, resolved, {
549
579
  skipPreflight,
550
580
  });
551
- const normalized = backendResult.normalized;
581
+ const normalized = applyExecutionFallbackHints(backendResult.normalized, {
582
+ flags,
583
+ runMode,
584
+ resolvedAction: resolved.resolvedAction,
585
+ });
552
586
  if (runMode === 'self' && parseBooleanFlag(flags.broadcast, false) && normalized?.success) {
553
587
  const executed = await performSelfBroadcast(ctx, flags, resolved.resolvedAction, normalized);
554
588
  printEnvelope({
@@ -925,8 +959,9 @@ async function commandDelegationRedeem(ctx, flags, capabilities) {
925
959
 
926
960
  async function commandCredentialsUpsert(ctx, flags, capabilities) {
927
961
  const authToken = getAuthToken(flags, 'Missing --auth-token (or THIRDFY_AUTH_TOKEN) for credentials upsert');
962
+ const venue = requireFlag(flags, 'venue', 'Missing --venue');
928
963
  const payload = {
929
- venue: requireFlag(flags, 'venue', 'Missing --venue'),
964
+ venue,
930
965
  apiKey: requireFlag(flags, 'apiKey', 'Missing --api-key'),
931
966
  apiSecret: requireFlag(flags, 'apiSecret', 'Missing --api-secret'),
932
967
  credentialType: String(flags.credentialType || 'api_key'),
@@ -937,6 +972,19 @@ async function commandCredentialsUpsert(ctx, flags, capabilities) {
937
972
  if (flags.policy) payload.policy = parseJsonFlag(flags, 'policy');
938
973
  if (flags.agentKey) payload.agentKey = String(flags.agentKey).trim();
939
974
  if (flags.chainId) payload.chainId = Number(flags.chainId);
975
+ const probeEnabled =
976
+ String(venue).trim().toLowerCase() === 'bitfinex' &&
977
+ !parseBooleanFlag(flags.skipPermissionsProbe, false) &&
978
+ parseBooleanFlag(flags.verifyPermissions, true);
979
+ let permissionProbe = null;
980
+ if (probeEnabled) {
981
+ permissionProbe = await probeBitfinexPermissions({
982
+ apiKey: payload.apiKey,
983
+ apiSecret: payload.apiSecret,
984
+ timeoutMs: ctx.timeoutMs,
985
+ baseUrl: String(flags.bitfinexBaseUrl || process.env.BITFINEX_BASE_URL || DEFAULT_BITFINEX_BASE_URL),
986
+ });
987
+ }
940
988
 
941
989
  const response = await requestWithRouteFallback(ctx, {
942
990
  method: 'POST',
@@ -953,6 +1001,7 @@ async function commandCredentialsUpsert(ctx, flags, capabilities) {
953
1001
  data: response,
954
1002
  meta: {
955
1003
  apiBase: ctx.apiBase,
1004
+ permissionProbe,
956
1005
  capabilitiesVersion: capabilities?.contractVersion || null,
957
1006
  },
958
1007
  });
@@ -1354,13 +1403,16 @@ async function commandProfileInit(ctx, flags) {
1354
1403
  const current = loadProfileConfig();
1355
1404
  const profile = normalizeProfileName(flags.profile || flags.name || 'personal');
1356
1405
  const runMode = normalizeRunMode(flags.runMode || PROFILE_DEFAULTS[profile]);
1406
+ const custodyMode = normalizeCustodyMode(flags.custodyMode, runMode);
1357
1407
  persistProfileConfig({
1358
1408
  ...current,
1359
1409
  profile,
1360
1410
  runMode,
1411
+ custodyMode,
1361
1412
  });
1362
1413
  ctx.profile = profile;
1363
1414
  ctx.runMode = runMode;
1415
+ ctx.custodyMode = custodyMode;
1364
1416
  printEnvelope({
1365
1417
  success: true,
1366
1418
  code: 'PROFILE_INITIALIZED',
@@ -1368,6 +1420,7 @@ async function commandProfileInit(ctx, flags) {
1368
1420
  data: {
1369
1421
  profile,
1370
1422
  runMode,
1423
+ custodyMode,
1371
1424
  configPath: getProfileConfigPath(),
1372
1425
  },
1373
1426
  meta: { apiBase: ctx.apiBase },
@@ -1379,13 +1432,16 @@ async function commandProfileUse(ctx, flags) {
1379
1432
  const profile = normalizeProfileName(flags.profile || flags.name || current.profile || 'personal');
1380
1433
  // `profile use` intentionally re-applies profile defaults unless caller pins a mode.
1381
1434
  const runMode = normalizeRunMode(flags.runMode || PROFILE_DEFAULTS[profile]);
1435
+ const custodyMode = normalizeCustodyMode(flags.custodyMode || current.custodyMode, runMode);
1382
1436
  persistProfileConfig({
1383
1437
  ...current,
1384
1438
  profile,
1385
1439
  runMode,
1440
+ custodyMode,
1386
1441
  });
1387
1442
  ctx.profile = profile;
1388
1443
  ctx.runMode = runMode;
1444
+ ctx.custodyMode = custodyMode;
1389
1445
  printEnvelope({
1390
1446
  success: true,
1391
1447
  code: 'PROFILE_UPDATED',
@@ -1393,6 +1449,7 @@ async function commandProfileUse(ctx, flags) {
1393
1449
  data: {
1394
1450
  profile,
1395
1451
  runMode,
1452
+ custodyMode,
1396
1453
  defaults: PROFILE_DEFAULTS,
1397
1454
  configPath: getProfileConfigPath(),
1398
1455
  },
@@ -1409,6 +1466,7 @@ async function commandProfileShow(ctx, _flags) {
1409
1466
  data: {
1410
1467
  profile: ctx.profile || config.profile || 'personal',
1411
1468
  runMode: ctx.runMode || config.runMode || 'thirdfy',
1469
+ custodyMode: ctx.custodyMode || config.custodyMode || defaultCustodyModeForRunMode(ctx.runMode || config.runMode || 'thirdfy'),
1412
1470
  profileDefaults: PROFILE_DEFAULTS,
1413
1471
  configPath: getProfileConfigPath(),
1414
1472
  config,
@@ -1431,6 +1489,10 @@ async function commandConfigSet(ctx, flags) {
1431
1489
  if (canonicalKey === 'runmode' && hasValue) {
1432
1490
  normalizeRunMode(value);
1433
1491
  }
1492
+ if (canonicalKey === 'custodymode' && hasValue) {
1493
+ const runModeHint = normalizeRunMode(flags.runMode || loadProfileConfig().runMode || 'thirdfy');
1494
+ normalizeCustodyMode(value, runModeHint);
1495
+ }
1434
1496
 
1435
1497
  const current = loadProfileConfig();
1436
1498
  const next = setNestedConfigValue(current, key, value);
@@ -1452,6 +1514,11 @@ async function commandLogin(ctx, flags) {
1452
1514
  const current = loadProfileConfig();
1453
1515
  const authToken = String(flags.authToken || process.env.THIRDFY_AUTH_TOKEN || '').trim();
1454
1516
  let ownerSessionToken = String(flags.ownerSessionToken || process.env.THIRDFY_OWNER_SESSION_TOKEN || '').trim();
1517
+ const loginRunMode = normalizeRunMode(flags.runMode || current.runMode || process.env.THIRDFY_RUN_MODE || 'thirdfy');
1518
+ const custodyMode = normalizeCustodyMode(
1519
+ flags.custodyMode || current.custodyMode || process.env.THIRDFY_CUSTODY_MODE,
1520
+ loginRunMode
1521
+ );
1455
1522
  let loginSource = 'token';
1456
1523
 
1457
1524
  if (!authToken && !ownerSessionToken) {
@@ -1482,8 +1549,40 @@ async function commandLogin(ctx, flags) {
1482
1549
  );
1483
1550
  }
1484
1551
 
1552
+ let bootstrapAgentApiKey = '';
1553
+ let bootstrapPerformed = false;
1554
+ const requestedAgentApiKey = String(flags.agentApiKey || '').trim();
1555
+ const bootstrapAgentKey = String(flags.agentKey || '').trim();
1556
+ if (!requestedAgentApiKey && bootstrapAgentKey) {
1557
+ const bootstrapHeaders = ownerSessionToken
1558
+ ? { 'x-owner-session-token': ownerSessionToken }
1559
+ : authToken
1560
+ ? { Authorization: `Bearer ${authToken}` }
1561
+ : null;
1562
+ if (bootstrapHeaders) {
1563
+ const bootstrapResponse = await requestWithRouteFallback(ctx, {
1564
+ method: 'POST',
1565
+ routes: ['/api/v1/agent/onboarding/me/bootstrap-self'],
1566
+ headers: bootstrapHeaders,
1567
+ body: {
1568
+ agentKey: bootstrapAgentKey,
1569
+ runMode: loginRunMode,
1570
+ custodyMode,
1571
+ ...(flags.walletAddress ? { walletAddress: String(flags.walletAddress).trim() } : {}),
1572
+ rotate: parseBooleanFlag(flags.rotateAgentKey, false),
1573
+ },
1574
+ });
1575
+ bootstrapAgentApiKey = String(
1576
+ bootstrapResponse?.agentApiKey || bootstrapResponse?.data?.agentApiKey || ''
1577
+ ).trim();
1578
+ bootstrapPerformed = true;
1579
+ }
1580
+ }
1581
+
1485
1582
  const next = {
1486
1583
  ...current,
1584
+ runMode: loginRunMode,
1585
+ custodyMode,
1487
1586
  auth: {
1488
1587
  ...(current.auth && typeof current.auth === 'object' ? current.auth : {}),
1489
1588
  lastLoginAt: new Date().toISOString(),
@@ -1500,7 +1599,12 @@ async function commandLogin(ctx, flags) {
1500
1599
  if (flags.agentApiKey) {
1501
1600
  next.agent = {
1502
1601
  ...(current.agent && typeof current.agent === 'object' ? current.agent : {}),
1503
- apiKey: String(flags.agentApiKey).trim(),
1602
+ apiKey: requestedAgentApiKey,
1603
+ };
1604
+ } else if (bootstrapAgentApiKey) {
1605
+ next.agent = {
1606
+ ...(current.agent && typeof current.agent === 'object' ? current.agent : {}),
1607
+ apiKey: bootstrapAgentApiKey,
1504
1608
  };
1505
1609
  }
1506
1610
  persistProfileConfig(next);
@@ -1511,7 +1615,10 @@ async function commandLogin(ctx, flags) {
1511
1615
  data: {
1512
1616
  hasAuthToken: Boolean(authToken),
1513
1617
  hasOwnerSessionToken: Boolean(ownerSessionToken),
1514
- hasAgentApiKey: Boolean(flags.agentApiKey),
1618
+ hasAgentApiKey: Boolean(requestedAgentApiKey || bootstrapAgentApiKey),
1619
+ bootstrapPerformed,
1620
+ runMode: loginRunMode,
1621
+ custodyMode,
1515
1622
  configPath: getProfileConfigPath(),
1516
1623
  },
1517
1624
  meta: { apiBase: ctx.apiBase },
@@ -1575,9 +1682,14 @@ function resolveProfileState(flags) {
1575
1682
  PROFILE_DEFAULTS[profile] ||
1576
1683
  'thirdfy'
1577
1684
  );
1685
+ const custodyMode = normalizeCustodyMode(
1686
+ flags.custodyMode || process.env.THIRDFY_CUSTODY_MODE || persisted.custodyMode,
1687
+ runMode
1688
+ );
1578
1689
  return {
1579
1690
  profile,
1580
1691
  runMode,
1692
+ custodyMode,
1581
1693
  configPath: getProfileConfigPath(),
1582
1694
  };
1583
1695
  }
@@ -1595,6 +1707,18 @@ function normalizeRunMode(value) {
1595
1707
  throw new Error(`Unsupported --run-mode "${value}". Supported: ${RUN_MODES.join(', ')}`);
1596
1708
  }
1597
1709
 
1710
+ function defaultCustodyModeForRunMode(runMode) {
1711
+ return runMode === 'thirdfy' ? 'managed' : 'external';
1712
+ }
1713
+
1714
+ function normalizeCustodyMode(value, runModeHint = 'thirdfy') {
1715
+ const fallback = defaultCustodyModeForRunMode(runModeHint);
1716
+ const normalized = String(value || '').trim().toLowerCase();
1717
+ if (!normalized) return fallback;
1718
+ if (CUSTODY_MODES.includes(normalized)) return normalized;
1719
+ throw new Error(`Unsupported custody mode "${value}". Supported: ${CUSTODY_MODES.join(', ')}`);
1720
+ }
1721
+
1598
1722
  function normalizeManagedRunMode(value) {
1599
1723
  const mode = normalizeRunMode(value || 'thirdfy');
1600
1724
  if (mode === 'self') {
@@ -1711,6 +1835,66 @@ function enforceChainCompatibility(capabilities, flags, runMode) {
1711
1835
  }
1712
1836
  }
1713
1837
 
1838
+ function shouldUseBuildTxExecution(runMode, resolved) {
1839
+ if (runMode !== 'self' && runMode !== 'hybrid') return false;
1840
+ const meta = resolved?.resolvedActionMeta || null;
1841
+ if (!meta || typeof meta !== 'object') return true;
1842
+ if (meta.supportsBuildTx === false) return false;
1843
+ return true;
1844
+ }
1845
+
1846
+ function buildBitfinexOnboardingCommand() {
1847
+ return [
1848
+ 'thirdfy-agent credentials upsert',
1849
+ '--auth-token "$THIRDFY_AUTH_TOKEN"',
1850
+ '--venue bitfinex',
1851
+ '--api-key "<BITFINEX_API_KEY>"',
1852
+ '--api-secret "<BITFINEX_API_SECRET>"',
1853
+ '--verify-permissions',
1854
+ '--json',
1855
+ ].join(' ');
1856
+ }
1857
+
1858
+ function applyExecutionFallbackHints(normalized, { flags, runMode, resolvedAction }) {
1859
+ if (!normalized || typeof normalized !== 'object') return normalized;
1860
+ const next = { ...normalized };
1861
+ const reasons = new Set();
1862
+ const topBlockedReason = String(next.blockedReason || '').trim();
1863
+ if (topBlockedReason) reasons.add(topBlockedReason);
1864
+ const blockedByReason = next.blockedByReason && typeof next.blockedByReason === 'object' ? next.blockedByReason : {};
1865
+ for (const key of Object.keys(blockedByReason)) {
1866
+ if (String(key || '').trim()) reasons.add(String(key).trim());
1867
+ }
1868
+ const resolvedActionLower = String(resolvedAction || '').trim().toLowerCase();
1869
+ const looksBitfinexAction = resolvedActionLower.includes('bitfinex');
1870
+ const cexCredentialMissingSignals = new Set([
1871
+ 'CEX_CREDENTIAL_NOT_FOUND',
1872
+ 'BITFINEX_CREDENTIALS_NOT_IN_CONTEXT',
1873
+ 'BITFINEX_ENV_CREDENTIALS_NOT_CONFIGURED',
1874
+ ]);
1875
+ const hasMissingCredentialSignal = Array.from(reasons).some((code) => cexCredentialMissingSignals.has(code));
1876
+ if (looksBitfinexAction && hasMissingCredentialSignal) {
1877
+ next.onboardingHint = {
1878
+ type: 'bitfinex_credentials',
1879
+ message:
1880
+ 'Bitfinex credentials are missing for this delegated account. Onboard credentials first, then retry the same command.',
1881
+ command: buildBitfinexOnboardingCommand(),
1882
+ };
1883
+ }
1884
+ if (
1885
+ looksBitfinexAction &&
1886
+ runMode !== 'thirdfy' &&
1887
+ (topBlockedReason === 'SELF_MODE_REQUIRES_BUILD_TX' || topBlockedReason === 'HYBRID_MODE_REQUIRES_BUILD_TX')
1888
+ ) {
1889
+ next.onboardingHint = {
1890
+ ...(next.onboardingHint || {}),
1891
+ runMode: 'thirdfy',
1892
+ runModeMessage: 'Bitfinex actions require delegated execute-intent rail. Re-run with --run-mode thirdfy.',
1893
+ };
1894
+ }
1895
+ return next;
1896
+ }
1897
+
1714
1898
  function shouldUseBuildTxPreflight(runMode, resolved) {
1715
1899
  if (runMode !== 'self') return false;
1716
1900
  const meta = resolved?.resolvedActionMeta || null;
@@ -1725,7 +1909,7 @@ async function runPreflightByMode(runMode, ctx, flags, resolved) {
1725
1909
  const normalized = await executeSelfRun(
1726
1910
  ctx,
1727
1911
  flags,
1728
- { resolvedAction },
1912
+ resolved,
1729
1913
  { skipPreflight: route === 'build_tx' }
1730
1914
  );
1731
1915
  const blockedByGovernance = !normalized.success && Boolean(normalized.preflightBlocked);
@@ -1843,6 +2027,7 @@ async function executeThirdfyRun(ctx, flags, resolved, options) {
1843
2027
 
1844
2028
  async function executeSelfRun(ctx, flags, resolved, options) {
1845
2029
  const skipPreflight = Boolean(options?.skipPreflight);
2030
+ const blockedReasonCode = String(options?.blockedReasonCode || 'SELF_MODE_REQUIRES_BUILD_TX');
1846
2031
  if (!skipPreflight) {
1847
2032
  const preflightPayload = buildIntentPayload(flags, {
1848
2033
  validationOnly: true,
@@ -1861,6 +2046,17 @@ async function executeSelfRun(ctx, flags, resolved, options) {
1861
2046
  return preflightNormalized;
1862
2047
  }
1863
2048
  }
2049
+ if (!shouldUseBuildTxExecution(options?.runMode || 'self', resolved)) {
2050
+ const normalized = normalizeIntentResponse({
2051
+ success: false,
2052
+ status: 'failed',
2053
+ blockedReason: blockedReasonCode,
2054
+ blockedStage: 'routing',
2055
+ error: 'Selected action does not support build-tx self lane. Use --run-mode thirdfy for delegated CEX execution.',
2056
+ });
2057
+ normalized.preflightBlocked = true;
2058
+ return normalized;
2059
+ }
1864
2060
  const buildTxPayload = buildBuildTxPayload(flags, resolved.resolvedAction);
1865
2061
  const buildTx = await apiPost(ctx, '/api/v1/agent/build-tx', buildTxPayload);
1866
2062
  return normalizeIntentResponse({
@@ -1873,7 +2069,11 @@ async function executeSelfRun(ctx, flags, resolved, options) {
1873
2069
  }
1874
2070
 
1875
2071
  async function executeHybridRun(ctx, flags, resolved, options) {
1876
- const selfResult = await executeSelfRun(ctx, flags, resolved, options);
2072
+ const selfResult = await executeSelfRun(ctx, flags, resolved, {
2073
+ ...(options || {}),
2074
+ runMode: 'hybrid',
2075
+ blockedReasonCode: 'HYBRID_MODE_REQUIRES_BUILD_TX',
2076
+ });
1877
2077
  if (!selfResult.success) return selfResult;
1878
2078
 
1879
2079
  const mirrorPayload = buildIntentPayload(flags, {
@@ -2454,6 +2654,114 @@ function safeJsonParse(text) {
2454
2654
  }
2455
2655
  }
2456
2656
 
2657
+ function parseBitfinexPermissionRows(rawData) {
2658
+ const rows = Array.isArray(rawData) ? rawData : [];
2659
+ const parsed = {};
2660
+ for (const row of rows) {
2661
+ if (!Array.isArray(row) || row.length < 3) continue;
2662
+ const scope = String(row[0] || '').trim().toLowerCase();
2663
+ if (!scope) continue;
2664
+ const read = Number(row[1]) === 1 ? 1 : 0;
2665
+ const write = Number(row[2]) === 1 ? 1 : 0;
2666
+ parsed[scope] = { read, write };
2667
+ }
2668
+ return parsed;
2669
+ }
2670
+
2671
+ function getMissingBitfinexScopes(scopePermissions) {
2672
+ const missing = [];
2673
+ for (const [scope, required] of Object.entries(BITFINEX_REQUIRED_SCOPE_PERMISSIONS)) {
2674
+ const actual = scopePermissions[scope] || { read: 0, write: 0 };
2675
+ if (actual.read < required.read || actual.write < required.write) {
2676
+ missing.push({
2677
+ scope,
2678
+ required,
2679
+ actual,
2680
+ });
2681
+ }
2682
+ }
2683
+ return missing;
2684
+ }
2685
+
2686
+ async function probeBitfinexPermissions({ apiKey, apiSecret, timeoutMs, baseUrl }) {
2687
+ const normalizedBaseUrl = String(baseUrl || DEFAULT_BITFINEX_BASE_URL).trim().replace(/\/+$/, '');
2688
+ const apiPath = '/v2/auth/r/permissions';
2689
+ const nonce = Date.now().toString();
2690
+ const payload = '{}';
2691
+ const signature = createHmac('sha384', String(apiSecret || ''))
2692
+ .update(`/api${apiPath}${nonce}${payload}`)
2693
+ .digest('hex');
2694
+ const controller = new AbortController();
2695
+ const timer = setTimeout(() => controller.abort(), Number(timeoutMs || DEFAULT_TIMEOUT_MS));
2696
+ try {
2697
+ const response = await fetch(`${normalizedBaseUrl}${apiPath}`, {
2698
+ method: 'POST',
2699
+ headers: {
2700
+ 'content-type': 'application/json',
2701
+ 'bfx-apikey': String(apiKey || ''),
2702
+ 'bfx-nonce': nonce,
2703
+ 'bfx-signature': signature,
2704
+ },
2705
+ body: payload,
2706
+ signal: controller.signal,
2707
+ });
2708
+ const text = await response.text();
2709
+ const parsed = text ? safeJsonParse(text) : {};
2710
+ const bitfinexApiError =
2711
+ Array.isArray(parsed) && String(parsed[0] || '').trim().toLowerCase() === 'error'
2712
+ ? {
2713
+ code: Number(parsed[1]) || null,
2714
+ message: String(parsed[2] || 'Unknown Bitfinex API error'),
2715
+ }
2716
+ : parsed && typeof parsed === 'object' && String(parsed.event || '').trim().toLowerCase() === 'error'
2717
+ ? {
2718
+ code: Number(parsed.code) || null,
2719
+ message: String(parsed.msg || parsed.message || 'Unknown Bitfinex API error'),
2720
+ }
2721
+ : null;
2722
+ if (!response.ok) {
2723
+ throw createCliError(
2724
+ 'BITFINEX_PERMISSIONS_PROBE_FAILED',
2725
+ `Bitfinex permissions probe failed with HTTP ${response.status}. Verify API key/secret and key validity before onboarding.`,
2726
+ { statusCode: response.status }
2727
+ );
2728
+ }
2729
+ if (bitfinexApiError) {
2730
+ throw createCliError(
2731
+ 'BITFINEX_PERMISSIONS_PROBE_FAILED',
2732
+ `Bitfinex permissions probe failed: ${bitfinexApiError.message}. Verify API key/secret and key validity before onboarding.`,
2733
+ { statusCode: response.status, bitfinexApiError }
2734
+ );
2735
+ }
2736
+ const scopePermissions = parseBitfinexPermissionRows(parsed);
2737
+ const missingScopes = getMissingBitfinexScopes(scopePermissions);
2738
+ if (missingScopes.length > 0) {
2739
+ throw createCliError(
2740
+ 'BITFINEX_PERMISSION_SCOPE_MISSING',
2741
+ `Bitfinex key is missing required scopes (${missingScopes
2742
+ .map((entry) => entry.scope)
2743
+ .join(', ')}). Enable funding+wallets read/write and retry onboarding.`,
2744
+ { missingScopes, scopePermissions }
2745
+ );
2746
+ }
2747
+ return {
2748
+ venue: 'bitfinex',
2749
+ checked: true,
2750
+ scopes: scopePermissions,
2751
+ missingScopes: [],
2752
+ };
2753
+ } catch (error) {
2754
+ if (error?.payload) throw error;
2755
+ throw createCliError(
2756
+ 'BITFINEX_PERMISSIONS_PROBE_FAILED',
2757
+ `Could not verify Bitfinex key permissions. ${String(error?.message || 'Unknown probe error')}`,
2758
+ {}
2759
+ );
2760
+ } finally {
2761
+ clearTimeout(timer);
2762
+ }
2763
+ }
2764
+
2457
2765
  function printEnvelope(payload) {
2458
2766
  if (jsonMode) {
2459
2767
  process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
@@ -2469,11 +2777,11 @@ function printHelp() {
2469
2777
  thirdfy-agent ${CLI_VERSION}
2470
2778
 
2471
2779
  Core commands:
2472
- thirdfy-agent profile init [--profile personal|builder|network] [--run-mode thirdfy|self|hybrid] [--json]
2473
- thirdfy-agent profile use --profile <name> [--run-mode thirdfy|self|hybrid] [--json]
2780
+ thirdfy-agent profile init [--profile personal|builder|network] [--run-mode thirdfy|self|hybrid] [--custody-mode managed|external] [--json]
2781
+ thirdfy-agent profile use --profile <name> [--run-mode thirdfy|self|hybrid] [--custody-mode managed|external] [--json]
2474
2782
  thirdfy-agent profile show [--json]
2475
2783
  thirdfy-agent config set --key <path> --value <value> [--json]
2476
- thirdfy-agent login [--auth-token <token> | --owner-session-token <token>] [--agent-api-key <key>] [--json]
2784
+ thirdfy-agent login [--auth-token <token> | --owner-session-token <token>] [--agent-api-key <key>] [--agent-key <key>] [--run-mode <mode>] [--custody-mode managed|external] [--wallet-address <addr>] [--json]
2477
2785
  thirdfy-agent logout [--all] [--clear-agent-key] [--json]
2478
2786
  thirdfy-agent whoami [--json]
2479
2787
  thirdfy-agent catalogs list [--json]
@@ -2491,7 +2799,7 @@ Core commands:
2491
2799
  thirdfy-agent managed wallet init [--auth-token <token> | --owner-session-token <token>] [--chain-id <id>] [--json]
2492
2800
  thirdfy-agent managed wallet grant [--auth-token <token> | --owner-session-token <token>] --agent-key <key> --token-address <addr> --max-usd-per-day <usd> [--chain-id <id>] [--period-duration <sec>] [--expiry-seconds <sec>] [--json]
2493
2801
  thirdfy-agent managed run --agent-api-key <key> --action <id> --params <json> [--run-mode thirdfy|hybrid] [--idempotency-key <key>] [--json]
2494
- thirdfy-agent credentials upsert --auth-token <token> --venue <id> --api-key <key> --api-secret <secret> [--json]
2802
+ thirdfy-agent credentials upsert --auth-token <token> --venue <id> --api-key <key> --api-secret <secret> [--verify-permissions] [--skip-permissions-probe] [--bitfinex-base-url <url>] [--json]
2495
2803
  thirdfy-agent credentials status --auth-token <token> [--venue <id>] [--json]
2496
2804
  thirdfy-agent agent auth challenge --agent-key <key> [--wallet-address <addr>] [--chain-id <id>] [--json]
2497
2805
  thirdfy-agent agent auth verify --challenge-id <id> --signature <hex> --agent-key <key> [--wallet-address <addr>] [--json]
@@ -2513,6 +2821,7 @@ Core commands:
2513
2821
  Global flags:
2514
2822
  --api-base <url> default: https://api.thirdfy.com
2515
2823
  --run-mode <mode> thirdfy | self | hybrid
2824
+ --custody-mode <mode> managed | external
2516
2825
  --profile <name> personal | builder | network
2517
2826
  --env <file> load env vars from file
2518
2827
  --timeout <ms> request timeout
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thirdfy/agent-cli",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Thirdfy Agent CLI for onboarding, governance preflight, execute-intent, and status polling.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,6 +18,7 @@
18
18
  "scripts": {
19
19
  "test": "node --test \"test/**/*.test.cjs\"",
20
20
  "lint": "node --check ./bin/thirdfy-agent.mjs",
21
+ "validate:public-docs": "node ./scripts/validate-public-docs.mjs",
21
22
  "validate:cli-contract-schemas": "node ./scripts/validate-cli-contract-schemas.mjs",
22
23
  "validate:actions": "node ./scripts/e2e/validate-actions.mjs",
23
24
  "e2e:base:simulate": "node ./scripts/e2e/base-simulate.mjs",
@@ -26,11 +27,12 @@
26
27
  "e2e:delegation:lifecycle": "node ./scripts/e2e/delegation-lifecycle.mjs",
27
28
  "e2e:delegation:commands": "node ./scripts/e2e/delegation-command-parity.mjs",
28
29
  "e2e:self:delegation:parity": "node ./scripts/e2e/self-delegation-parity.mjs",
30
+ "e2e:self:managed-custody-validation": "node ./scripts/e2e/self-managed-custody-validation.mjs",
29
31
  "e2e:managed:delegation:parity": "node ./scripts/e2e/managed-custodial-lifecycle.mjs",
30
32
  "e2e:delegation:prod:certify": "node ./scripts/e2e/prod-delegation-certify.mjs",
31
33
  "e2e:report:last": "node ./scripts/e2e/report-last.mjs",
32
34
  "smoke:cli": "node ./bin/thirdfy-agent.mjs --help && node ./bin/thirdfy-agent.mjs --version --json",
33
- "prepublishOnly": "npm run validate:cli-contract-schemas && npm run test && npm run smoke:cli"
35
+ "prepublishOnly": "npm run validate:public-docs && npm run validate:cli-contract-schemas && npm run test && npm run smoke:cli"
34
36
  },
35
37
  "keywords": [
36
38
  "thirdfy",
@@ -52,6 +54,7 @@
52
54
  },
53
55
  "homepage": "https://github.com/thirdfy/agent-cli#readme",
54
56
  "dependencies": {
57
+ "@open-wallet-standard/core": "^1.3.0",
55
58
  "ethers": "^6.16.0"
56
59
  }
57
60
  }
@@ -33,6 +33,7 @@ function normalizeIntentResponse(response) {
33
33
  const topLevelGovernanceSignal = String(response?.governanceSignal || '').trim() || null;
34
34
  const topLevelDelegationSignal = String(response?.delegationSignal || '').trim() || null;
35
35
  const policyEvaluated = response?.policyEvaluated === true;
36
+ const preflightBlocked = response?.preflightBlocked === true;
36
37
  return {
37
38
  success,
38
39
  status: response?.status || (success ? 'queued' : 'failed'),
@@ -48,6 +49,7 @@ function normalizeIntentResponse(response) {
48
49
  governanceSignal: topLevelGovernanceSignal,
49
50
  delegationSignal: topLevelDelegationSignal,
50
51
  policyEvaluated,
52
+ preflightBlocked,
51
53
  error: response?.error || null,
52
54
  mode: response?.mode || null,
53
55
  unsignedTx: response?.unsignedTx || null,