@thirdfy/agent-cli 0.2.30 → 0.2.31

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,18 @@ All notable changes to `@thirdfy/agent-cli` are documented here. The format is b
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.31] - 2026-07-23
8
+
9
+ ### Added
10
+
11
+ - Catalog-driven local `paramsSchema` validation on `preflight`, `run`, `wallet execute`, `wallet sign`, and `self-exec` before any HTTP write. Unresolved executable actions and write actions without catalog `paramsSchema` fail closed with `PARAMS_SCHEMA_INVALID`; read-only catalog actions keep schema-skip behavior.
12
+ - `doctor certify execution` no-write certification report: CLI version, execution command availability, Hyperliquid/Lighter/Polymarket place/cancel/leverage schema matrix, supported authority input sources, and EarnClaw control-plane flags (`--no-write`, `--run-mode`, `--schema-digest`, `--required-actions`). Bare certification requires a live action catalog; embedded fixtures remain diagnostic only.
13
+
14
+ ### Changed
15
+
16
+ - Client validation strips billing hints (`estimatedAmountUsd`, `amountUsd`) from action params before schema checks (parity with API execute paths).
17
+ - Whitelist `cexCredentialRef` alongside `agentKey` when catalog `additionalProperties` is false (orchestration envelope key, not action params).
18
+
7
19
  ## [0.2.30] - 2026-07-19
8
20
 
9
21
  ### Changed
package/README.md CHANGED
@@ -40,10 +40,10 @@ Run without global install:
40
40
  npx @thirdfy/agent-cli --help
41
41
  ```
42
42
 
43
- ## What's new in v0.2.30
43
+ ## What's new in v0.2.31
44
44
 
45
- - Sends `x-client: agent-cli` on API requests so Thirdfy trajectory events attribute the CLI surface.
46
- - Keeps runtime/org execution context headers for hosted EarnClaw attribution.
45
+ - Validates action `--params` against catalog `paramsSchema` locally on `preflight`, `run`, and `wallet execute` before HTTP writes. Failures return `PARAMS_SCHEMA_INVALID` with field-level errors.
46
+ - Adds `doctor certify execution` for a no-write certification report (CLI version, command availability, venue schema matrix, authority inputs). EarnClaw passes `--no-write`, `--run-mode`, `--schema-digest`, and `--required-actions` for fail-closed pack certification.
47
47
 
48
48
  Older versions: see [CHANGELOG.md](./CHANGELOG.md) and [GitHub Releases](https://github.com/thirdfy/agent-cli/releases).
49
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thirdfy/agent-cli",
3
- "version": "0.2.30",
3
+ "version": "0.2.31",
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\"",
26
- "lint": "node --check ./bin/thirdfy-agent.mjs && node --check ./src/core/*.mjs && node --check ./src/commands/*.mjs && node --check ./src/cli/*.mjs && node --check ./src/cli/options/*.mjs && node --check ./src/runtime/*.mjs && node --check ./src/runtime/execution/*.mjs && node --check ./src/runtime/actions/*.mjs",
26
+ "lint": "node --check ./bin/thirdfy-agent.mjs && node --check ./src/core/*.mjs && node --check ./src/commands/*.mjs && node --check ./src/cli/*.mjs && node --check ./src/cli/options/*.mjs && node --check ./src/runtime/*.mjs && node --check ./src/runtime/execution/*.mjs && node --check ./src/runtime/actions/*.mjs && node --check ./src/runtime/validation/*.mjs && node --check ./src/runtime/certification/*.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/help.mjs CHANGED
@@ -62,6 +62,7 @@ Core commands:
62
62
  thirdfy-agent self-exec --agent-api-key <key> --action <id> --params <json> --wallet-address <addr> --ows-wallet-name <name> [--rpc-url <url>] [--effect-check strict|relaxed|off] [--json]
63
63
  thirdfy-agent doctor self [--wallet-address <addr>] [--ows-wallet-name <name>] [--rpc-url <url>] [--json]
64
64
  thirdfy-agent doctor auth [--json]
65
+ thirdfy-agent doctor certify execution [--no-write] [--run-mode <mode>] [--schema-digest <digest>] [--required-actions <csv>] [--json]
65
66
  thirdfy-agent jeff preflight --agent-api-key <key> --action <id> --params <json> [--run-mode <mode>] [--json]
66
67
  thirdfy-agent jeff trade --agent-api-key <key> --action <id> --params <json> [--run-mode <mode>] [--json]
67
68
  thirdfy-agent jeff status --intent-id <id> [--json]
@@ -62,6 +62,13 @@ export const CLI_MANIFEST = [
62
62
  { path: ['whoami'], handler: 'commandWhoami', description: 'Show masked local identity' },
63
63
  { path: ['doctor', 'self'], handler: 'commandDoctorSelf', options: ['wallet'], description: 'Self-mode wallet diagnostics' },
64
64
  { path: ['doctor', 'auth'], handler: 'commandDoctorAuth', description: 'Auth and onboarding diagnostics' },
65
+ {
66
+ path: ['doctor', 'certify', 'execution'],
67
+ handler: 'commandDoctorCertifyExecution',
68
+ tier: 'offline',
69
+ options: ['certification'],
70
+ description: 'No-write execution certification (CLI version, commands, venue schema matrix)',
71
+ },
65
72
  {
66
73
  path: ['help', 'onboarding'],
67
74
  handler: 'commandHelpOnboarding',
@@ -0,0 +1,8 @@
1
+ /** Options for `doctor certify execution` (no-write control-plane certification). */
2
+ export function attachCertificationOptions(cmd) {
3
+ cmd
4
+ .option('--no-write', 'assert certification performs no HTTP writes (always true for this command)')
5
+ .option('--schema-digest <digest>', 'expected sha256 digest for required action params schemas')
6
+ .option('--required-actions <csv>', 'comma-separated Thirdfy action ids required by the runtime pack');
7
+ return cmd;
8
+ }
@@ -15,6 +15,7 @@ import { attachCreditsOptions } from './credits.mjs';
15
15
  import { attachChatOptions } from './chat.mjs';
16
16
  import { attachVenueOptions } from './venue.mjs';
17
17
  import { attachBrainOptions } from './brain.mjs';
18
+ import { attachCertificationOptions } from './certification.mjs';
18
19
 
19
20
  const BUNDLES = {
20
21
  auth: attachAuthOptions,
@@ -33,6 +34,7 @@ const BUNDLES = {
33
34
  chat: attachChatOptions,
34
35
  venue: attachVenueOptions,
35
36
  brain: attachBrainOptions,
37
+ certification: attachCertificationOptions,
36
38
  };
37
39
 
38
40
  /** @param {import('commander').Command} cmd */
@@ -48,6 +48,7 @@ function buildChatRequestHeaders(flags, modelKey) {
48
48
  const headers = {
49
49
  'content-type': 'application/json',
50
50
  Accept: 'text/plain; charset=utf-8',
51
+ 'x-client': 'agent-cli',
51
52
  ...buildChatAuthHeaders(flags),
52
53
  };
53
54
  if (modelKey) {
@@ -1,9 +1,10 @@
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 } from '../core/context.mjs';
4
+ import { loadProfileConfig, getProfileConfigPath, getCachedActionsCatalog } from '../core/context.mjs';
5
5
  import { DEFAULT_API_BASE, PROFILE_DEFAULTS } from '../core/constants.mjs';
6
6
  import { normalizeRunMode, normalizeManagedRunMode } from '../core/runMode.mjs';
7
+ import { buildExecutionCertificationReport } from '../runtime/certification/executionCertify.mjs';
7
8
 
8
9
  const require = createRequire(import.meta.url);
9
10
  const { normalizeDelegationStatus } = require('../utils/cli/delegationNormalization.cjs');
@@ -18,6 +19,7 @@ export function createDoctorCommands({
18
19
  resolveEffectiveUserDid,
19
20
  loadEthers,
20
21
  resolveOwsBinary,
22
+ CLI_VERSION,
21
23
  }) {
22
24
  async function commandDoctorSelf(ctx, flags) {
23
25
  const walletAddress = String(flags.walletAddress || process.env.MARKET_MAKER_WALLET_ADDRESS || '').trim();
@@ -311,10 +313,62 @@ async function commandManagedSetup(ctx, flags, capabilities) {
311
313
  if (!success) process.exit(1);
312
314
  }
313
315
 
316
+ async function commandDoctorCertifyExecution(ctx, flags) {
317
+ let liveActions = null;
318
+ let catalogError = null;
319
+ try {
320
+ liveActions = await getCachedActionsCatalog(ctx, flags);
321
+ } catch (error) {
322
+ catalogError = {
323
+ message: error?.message || 'Actions catalog fetch failed',
324
+ statusCode: Number(error?.statusCode || 0) || undefined,
325
+ };
326
+ }
327
+
328
+ const requiredActions = String(flags.requiredActions || '')
329
+ .split(',')
330
+ .map((item) => item.trim())
331
+ .filter(Boolean);
332
+ const runMode = String(flags.runMode || '').trim() || null;
333
+ const expectedSchemaDigest = String(flags.schemaDigest || '').trim() || null;
334
+
335
+ const report = buildExecutionCertificationReport({
336
+ cliVersion: CLI_VERSION,
337
+ liveActions,
338
+ runMode,
339
+ expectedSchemaDigest,
340
+ requiredActions,
341
+ });
342
+
343
+ printEnvelope({
344
+ success: report.ok,
345
+ code: report.ok ? 'CERTIFY_EXECUTION_OK' : 'CERTIFY_EXECUTION_FAILED',
346
+ message: report.ok
347
+ ? 'Execution certification passed (no HTTP writes performed)'
348
+ : 'Execution certification found blocking issues',
349
+ data: {
350
+ ...report,
351
+ catalog: {
352
+ fetched: Array.isArray(liveActions),
353
+ actionCount: Array.isArray(liveActions) ? liveActions.length : 0,
354
+ error: catalogError,
355
+ },
356
+ },
357
+ meta: {
358
+ apiBase: ctx.apiBase,
359
+ writeFree: true,
360
+ noWrite: true,
361
+ cliVersion: CLI_VERSION,
362
+ },
363
+ });
364
+ if (!report.ok) process.exit(1);
365
+ }
366
+
314
367
  return {
315
368
  commandDoctorSelf,
316
369
  buildOnboardingHelp,
317
370
  commandDoctorAuth,
318
371
  commandManagedSetup,
372
+ commandDoctorCertifyExecution,
319
373
  };
320
374
  }
@@ -16,6 +16,7 @@ export function createExecuteCommands(deps) {
16
16
  const {
17
17
  resolveActionSelection,
18
18
  prepareActionParamsForFlags,
19
+ enforceCatalogActionParams,
19
20
  enforceOffchainVenueLaneCompatibility,
20
21
  enforceChainCompatibility,
21
22
  applyHyperliquidAgentWalletAddressDefault,
@@ -35,6 +36,7 @@ export function createExecuteCommands(deps) {
35
36
  async function commandPreflight(ctx, flags, capabilities) {
36
37
  const resolved = await resolveActionSelection(ctx, flags);
37
38
  prepareActionParamsForFlags(flags, resolved.resolvedAction);
39
+ enforceCatalogActionParams(resolved, flags);
38
40
  const runMode = getEffectiveRunMode(ctx, flags);
39
41
  enforceOffchainVenueLaneCompatibility(resolved.resolvedAction, runMode);
40
42
  const hybridWalletMode = runMode === 'hybrid' ? normalizeHybridWalletMode(flags.hybridWalletMode) : null;
@@ -87,6 +89,7 @@ async function commandPreflight(ctx, flags, capabilities) {
87
89
  async function commandRun(ctx, flags, capabilities) {
88
90
  const resolved = await resolveActionSelection(ctx, flags);
89
91
  prepareActionParamsForFlags(flags, resolved.resolvedAction);
92
+ enforceCatalogActionParams(resolved, flags);
90
93
  const runMode = getEffectiveRunMode(ctx, flags);
91
94
  enforceOffchainVenueLaneCompatibility(resolved.resolvedAction, runMode);
92
95
  const hybridWalletMode = runMode === 'hybrid' ? normalizeHybridWalletMode(flags.hybridWalletMode) : null;
@@ -212,6 +215,7 @@ async function commandIntentStatus(ctx, flags, capabilities) {
212
215
  async function commandSelfExec(ctx, flags, capabilities) {
213
216
  const resolved = await resolveActionSelection(ctx, flags);
214
217
  prepareActionParamsForFlags(flags, resolved.resolvedAction);
218
+ enforceCatalogActionParams(resolved, flags);
215
219
  enforceOffchainVenueLaneCompatibility(resolved.resolvedAction, 'self');
216
220
  enforceChainCompatibility(capabilities, flags, 'self');
217
221
  const selfResult = await executeSelfRun(ctx, flags, resolved, {
@@ -6,6 +6,7 @@ export function createWalletCommands(deps) {
6
6
  const {
7
7
  resolveActionSelection,
8
8
  prepareActionParamsForFlags,
9
+ enforceCatalogActionParams,
9
10
  enforceOffchainVenueLaneCompatibility,
10
11
  enforceChainCompatibility,
11
12
  applyHyperliquidAgentWalletAddressDefault,
@@ -21,6 +22,7 @@ async function commandWalletExecute(ctx, flags, capabilities) {
21
22
  const normalizedFlags = { ...flags, runMode: 'agent_wallet' };
22
23
  const resolved = await resolveActionSelection(ctx, normalizedFlags);
23
24
  prepareActionParamsForFlags(normalizedFlags, resolved.resolvedAction);
25
+ enforceCatalogActionParams(resolved, normalizedFlags);
24
26
  enforceChainCompatibility(capabilities, normalizedFlags, 'agent_wallet');
25
27
  const managedPreflight =
26
28
  (await applyHyperliquidAgentWalletAddressDefault(ctx, normalizedFlags, resolved, 'agent_wallet'))
@@ -51,6 +53,7 @@ async function commandWalletSign(ctx, flags, capabilities) {
51
53
  const normalizedFlags = { ...flags, runMode: 'self' };
52
54
  const resolved = await resolveActionSelection(ctx, normalizedFlags);
53
55
  prepareActionParamsForFlags(normalizedFlags, resolved.resolvedAction);
56
+ enforceCatalogActionParams(resolved, normalizedFlags);
54
57
  enforceOffchainVenueLaneCompatibility(resolved.resolvedAction, 'self');
55
58
  enforceChainCompatibility(capabilities, normalizedFlags, 'self');
56
59
  const selfResult = await executeSelfRun(ctx, normalizedFlags, resolved, {
@@ -354,12 +354,7 @@ async function resolveActionSelection(ctx, flags) {
354
354
  )}?${formatProviderAmbiguousActionHint(providerHint)}`
355
355
  );
356
356
  }
357
- const providerHint = flags.provider ? getTradingProviderHint(flags.provider) : null;
358
- throw new Error(
359
- `Unknown --action "${requestedAction}". No compatible action found in current catalog/provider scope.${formatProviderUnknownActionHint(
360
- providerHint
361
- )}`
362
- );
357
+ return { requestedAction, resolvedAction: requestedAction, resolvedActionMeta: null };
363
358
  }
364
359
 
365
360
  return {
@@ -0,0 +1,378 @@
1
+ import { createRequire } from 'module';
2
+ import { createHash } from 'node:crypto';
3
+ import { CLI_MANIFEST } from '../../cli/manifest.mjs';
4
+ import { validateParamsSchema, sanitizeParamsForSchemaValidation } from '../validation/paramsSchema.mjs';
5
+ import { VENUE_WRITE_ACTION_FIXTURES } from './venueActionSchemas.mjs';
6
+
7
+ const require = createRequire(import.meta.url);
8
+
9
+ const { version: PACKAGE_VERSION } = require('../../../package.json');
10
+
11
+ const EXECUTION_COMMAND_PATHS = [
12
+ ['preflight'],
13
+ ['run'],
14
+ ['wallet', 'execute'],
15
+ ['agent', 'run'],
16
+ ];
17
+
18
+ const AUTHORITY_INPUT_CHECKS = [
19
+ { id: 'agentApiKey', sources: ['flag:agentApiKey', 'env:THIRDFY_AGENT_API_KEY', 'config:agent.apiKey'] },
20
+ { id: 'userDid', sources: ['flag:userDid', 'env:THIRDFY_USER_DID', 'config:wallets.userDid'] },
21
+ { id: 'agentKey', sources: ['flag:agentKey', 'env:THIRDFY_AGENT_KEY', 'config:agent.agentKey'] },
22
+ { id: 'estimatedAmountUsd', sources: ['flag:estimatedAmountUsd'] },
23
+ ];
24
+
25
+ function normalizeActionKey(action) {
26
+ return String(action || '').trim().replace(/_/g, '-').toLowerCase();
27
+ }
28
+
29
+ function isWriteAction(action) {
30
+ const key = normalizeActionKey(action);
31
+ return !key.startsWith('get-') && !key.startsWith('list-');
32
+ }
33
+
34
+ function manifestHasPath(pathSegments) {
35
+ const key = pathSegments.join(' ');
36
+ return CLI_MANIFEST.some((entry) => entry.path.join(' ') === key);
37
+ }
38
+
39
+ function executionRelevantSchemaSlice(schema) {
40
+ if (!schema || typeof schema !== 'object') return {};
41
+ const properties = schema.properties;
42
+ const required = schema.required;
43
+ const additionalProperties = schema.additionalProperties;
44
+ const normalizedProperties = {};
45
+ if (properties && typeof properties === 'object' && !Array.isArray(properties)) {
46
+ for (const [key, value] of Object.entries(properties)) {
47
+ if (!value || typeof value !== 'object') {
48
+ normalizedProperties[key] = value;
49
+ continue;
50
+ }
51
+ const row = value;
52
+ normalizedProperties[key] = {
53
+ type: row.type ?? null,
54
+ enum: row.enum ?? null,
55
+ minimum: row.minimum ?? null,
56
+ maximum: row.maximum ?? null,
57
+ };
58
+ }
59
+ }
60
+ return {
61
+ required: Array.isArray(required) ? [...required].sort() : [],
62
+ additionalProperties: additionalProperties ?? null,
63
+ properties: normalizedProperties,
64
+ };
65
+ }
66
+
67
+ export function computeActionSchemaDigest(actions) {
68
+ const payload = actions
69
+ .map((row) => ({
70
+ action: normalizeActionKey(row.action),
71
+ requiredParams: [...(row.requiredParams || [])].sort(),
72
+ schema: executionRelevantSchemaSlice(row.paramsSchema),
73
+ }))
74
+ .sort((a, b) => a.action.localeCompare(b.action));
75
+ return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
76
+ }
77
+
78
+ function mergeCatalogWithFixtures(liveActions) {
79
+ const byAction = new Map();
80
+ for (const fixture of VENUE_WRITE_ACTION_FIXTURES) {
81
+ byAction.set(fixture.action, {
82
+ action: fixture.action,
83
+ aliases: fixture.aliases,
84
+ provider: fixture.provider,
85
+ paramsSchema: fixture.paramsSchema,
86
+ requiredParams: fixture.paramsSchema.required || [],
87
+ source: 'fixture',
88
+ });
89
+ }
90
+ for (const row of liveActions || []) {
91
+ const action = String(row?.action || '').trim();
92
+ if (!action) continue;
93
+ const existing = byAction.get(action);
94
+ byAction.set(action, {
95
+ action,
96
+ aliases: Array.isArray(row.aliases) ? row.aliases : existing?.aliases || [],
97
+ provider: row.provider || row.providerId || existing?.provider || null,
98
+ paramsSchema: row.paramsSchema || existing?.paramsSchema || null,
99
+ requiredParams: Array.isArray(row.requiredParams) ? row.requiredParams : existing?.requiredParams || [],
100
+ source: row.paramsSchema ? 'catalog' : existing?.source || 'catalog',
101
+ });
102
+ }
103
+ return byAction;
104
+ }
105
+
106
+ function resolveCatalogRow(catalogByAction, action) {
107
+ const direct = catalogByAction.get(action);
108
+ if (direct) return direct;
109
+ const normalized = normalizeActionKey(action);
110
+ for (const row of catalogByAction.values()) {
111
+ if (normalizeActionKey(row.action) === normalized) return row;
112
+ for (const alias of row.aliases || []) {
113
+ if (normalizeActionKey(alias) === normalized) return row;
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+
119
+ function runSchemaCompatibilityChecks(catalogByAction, actionsToCheck, { requireLiveCatalog = false } = {}) {
120
+ const fixturesByAction = new Map(VENUE_WRITE_ACTION_FIXTURES.map((fixture) => [fixture.action, fixture]));
121
+ return actionsToCheck.map((actionName) => {
122
+ const fixture = fixturesByAction.get(actionName) || null;
123
+ const catalogRow = resolveCatalogRow(catalogByAction, actionName);
124
+ const liveSchema = catalogRow?.source === 'catalog' ? catalogRow.paramsSchema : null;
125
+ const schema = requireLiveCatalog ? liveSchema : catalogRow?.paramsSchema || fixture?.paramsSchema || null;
126
+ const checks = [];
127
+
128
+ checks.push({
129
+ name: 'schema-present',
130
+ ok: requireLiveCatalog
131
+ ? Boolean(liveSchema && typeof liveSchema === 'object')
132
+ : Boolean(schema && typeof schema === 'object'),
133
+ detail: requireLiveCatalog
134
+ ? liveSchema
135
+ ? 'live catalog paramsSchema available'
136
+ : 'live catalog paramsSchema unavailable'
137
+ : schema
138
+ ? 'paramsSchema available'
139
+ : 'missing paramsSchema',
140
+ });
141
+
142
+ if (fixture?.validSample && schema) {
143
+ const valid = validateParamsSchema(sanitizeParamsForSchemaValidation(fixture.validSample), schema);
144
+ checks.push({
145
+ name: 'valid-sample',
146
+ ok: valid.valid === true,
147
+ detail: valid.valid ? 'reference sample passes validation' : valid.error || 'sample rejected',
148
+ });
149
+
150
+ const missingRequired = validateParamsSchema(sanitizeParamsForSchemaValidation({}), schema);
151
+ checks.push({
152
+ name: 'missing-required-rejected',
153
+ ok: missingRequired.valid === false,
154
+ detail: missingRequired.error || 'empty params unexpectedly accepted',
155
+ });
156
+
157
+ const badTypeSample = { ...fixture.validSample };
158
+ const firstRequired = Array.isArray(schema.required) ? schema.required[0] : null;
159
+ const firstRule = firstRequired ? schema.properties?.[firstRequired] : null;
160
+ if (firstRequired && firstRule?.type === 'boolean') {
161
+ badTypeSample[firstRequired] = 'not-a-boolean';
162
+ } else if (firstRequired && (firstRule?.type === 'number' || firstRule?.type === 'integer')) {
163
+ badTypeSample[firstRequired] = 'not-a-number';
164
+ } else if (firstRequired) {
165
+ badTypeSample[firstRequired] = 12345;
166
+ }
167
+ const badType = validateParamsSchema(sanitizeParamsForSchemaValidation(badTypeSample), schema);
168
+ checks.push({
169
+ name: 'wrong-type-rejected',
170
+ ok: badType.valid === false,
171
+ detail: badType.error || 'wrong type unexpectedly accepted',
172
+ });
173
+
174
+ const unknownMeta = validateParamsSchema(
175
+ sanitizeParamsForSchemaValidation({
176
+ ...fixture.validSample,
177
+ totallyUnknownMetadataField: 'adapter',
178
+ }),
179
+ schema
180
+ );
181
+ checks.push({
182
+ name: 'unknown-metadata-rejected',
183
+ ok: unknownMeta.valid === false,
184
+ detail: unknownMeta.error || 'unknown metadata unexpectedly accepted',
185
+ });
186
+ }
187
+
188
+ const aliasChecks = (catalogRow?.aliases || fixture?.aliases || []).map((alias) => ({
189
+ alias,
190
+ resolvesTo: catalogRow?.action || fixture?.action || actionName,
191
+ ok: true,
192
+ }));
193
+
194
+ return {
195
+ action: catalogRow?.action || fixture?.action || actionName,
196
+ provider: catalogRow?.provider || fixture?.provider || null,
197
+ catalogSource: catalogRow?.source || 'missing',
198
+ authorityInputs: fixture?.authorityInputs || [],
199
+ aliases: aliasChecks,
200
+ checks,
201
+ ok: checks.every((entry) => entry.ok),
202
+ };
203
+ });
204
+ }
205
+
206
+ function parseRequiredActions(value) {
207
+ if (!value) return [];
208
+ if (Array.isArray(value)) {
209
+ return value.map((item) => String(item || '').trim()).filter(Boolean);
210
+ }
211
+ return String(value)
212
+ .split(',')
213
+ .map((item) => item.trim())
214
+ .filter(Boolean);
215
+ }
216
+
217
+ export function buildExecutionCertificationReport({
218
+ cliVersion,
219
+ liveActions = null,
220
+ runMode = null,
221
+ expectedSchemaDigest = null,
222
+ requiredActions = [],
223
+ } = {}) {
224
+ const version = String(cliVersion || PACKAGE_VERSION).trim();
225
+ const normalizedRequiredActions = parseRequiredActions(requiredActions);
226
+ const blockers = [];
227
+
228
+ const commandAvailability = EXECUTION_COMMAND_PATHS.map((pathSegments) => ({
229
+ command: pathSegments.join(' '),
230
+ available: manifestHasPath(pathSegments),
231
+ }));
232
+
233
+ const catalogByAction = mergeCatalogWithFixtures(liveActions);
234
+ const catalogFetched = Array.isArray(liveActions);
235
+ const bareCertification = normalizedRequiredActions.length === 0;
236
+
237
+ if (bareCertification && !catalogFetched) {
238
+ blockers.push('LIVE_CATALOG_UNAVAILABLE');
239
+ }
240
+
241
+ for (const action of normalizedRequiredActions) {
242
+ const row = resolveCatalogRow(catalogByAction, action);
243
+ if (!row) {
244
+ blockers.push(`REQUIRED_ACTION_MISSING:${action}`);
245
+ continue;
246
+ }
247
+ if (isWriteAction(action) && row.source !== 'catalog') {
248
+ blockers.push(`CATALOG_SCHEMA_UNAVAILABLE:${action}`);
249
+ }
250
+ if (isWriteAction(action) && !row.paramsSchema) {
251
+ blockers.push(`CATALOG_SCHEMA_UNAVAILABLE:${action}`);
252
+ }
253
+ }
254
+
255
+ const venueActionNames =
256
+ normalizedRequiredActions.length > 0
257
+ ? normalizedRequiredActions.filter((action) => isWriteAction(action))
258
+ : VENUE_WRITE_ACTION_FIXTURES.map((fixture) => fixture.action);
259
+
260
+ const venueActions = runSchemaCompatibilityChecks(catalogByAction, venueActionNames, {
261
+ requireLiveCatalog: bareCertification,
262
+ });
263
+ for (const row of venueActions) {
264
+ if (!row.ok) {
265
+ blockers.push(`VENUE_SCHEMA_CHECK_FAILED:${row.action}`);
266
+ }
267
+ if (bareCertification && catalogFetched && row.catalogSource !== 'catalog') {
268
+ blockers.push(`CATALOG_SCHEMA_UNAVAILABLE:${row.action}`);
269
+ }
270
+ }
271
+
272
+ const digestInputs = normalizedRequiredActions.map((action) => {
273
+ const row = resolveCatalogRow(catalogByAction, action);
274
+ return {
275
+ action,
276
+ requiredParams: row?.requiredParams || [],
277
+ paramsSchema: row?.paramsSchema || null,
278
+ };
279
+ });
280
+ const actionSchemaDigest =
281
+ digestInputs.length > 0
282
+ ? computeActionSchemaDigest(digestInputs)
283
+ : computeActionSchemaDigest(
284
+ VENUE_WRITE_ACTION_FIXTURES.map((fixture) => ({
285
+ action: fixture.action,
286
+ requiredParams: fixture.paramsSchema.required || [],
287
+ paramsSchema: fixture.paramsSchema,
288
+ }))
289
+ );
290
+
291
+ const schemaCompatibility = {
292
+ ok: !expectedSchemaDigest || actionSchemaDigest === String(expectedSchemaDigest).trim(),
293
+ digest: actionSchemaDigest,
294
+ expectedDigest: expectedSchemaDigest ? String(expectedSchemaDigest).trim() : null,
295
+ matchesExpected: !expectedSchemaDigest || actionSchemaDigest === String(expectedSchemaDigest).trim(),
296
+ };
297
+ if (expectedSchemaDigest && !schemaCompatibility.ok) {
298
+ blockers.push('SCHEMA_DIGEST_MISMATCH');
299
+ }
300
+
301
+ const authorityInputs = AUTHORITY_INPUT_CHECKS.map((entry) => ({
302
+ id: entry.id,
303
+ supported: true,
304
+ sources: entry.sources,
305
+ }));
306
+
307
+ const authority = {
308
+ ok: Boolean(runMode),
309
+ mode: runMode || null,
310
+ };
311
+ if (!authority.ok && normalizedRequiredActions.length > 0) {
312
+ blockers.push('CERTIFICATION_RUN_MODE_MISSING');
313
+ }
314
+
315
+ const writePolicy = {
316
+ ok: normalizedRequiredActions.every((action) => {
317
+ if (!isWriteAction(action)) return true;
318
+ const row = resolveCatalogRow(catalogByAction, action);
319
+ return row?.source === 'catalog' && Boolean(row?.paramsSchema);
320
+ }),
321
+ };
322
+ if (!writePolicy.ok) {
323
+ blockers.push('CLI_CERTIFICATION_WRITE_POLICY_MISMATCH');
324
+ }
325
+
326
+ const readiness = {
327
+ ok: blockers.length === 0,
328
+ blockers: [...blockers],
329
+ };
330
+
331
+ const checks = [
332
+ {
333
+ name: 'cli-version',
334
+ ok: Boolean(version),
335
+ detail: version || 'missing package version',
336
+ },
337
+ ...commandAvailability.map((entry) => ({
338
+ name: `command:${entry.command}`,
339
+ ok: entry.available,
340
+ detail: entry.available ? 'manifest handler registered' : 'command missing from manifest',
341
+ })),
342
+ {
343
+ name: 'venue-action-matrix',
344
+ ok: venueActions.every((entry) => entry.ok),
345
+ detail: `${venueActions.filter((entry) => entry.ok).length}/${venueActions.length} venue write actions pass local schema checks`,
346
+ },
347
+ {
348
+ name: 'catalog-live-for-certification',
349
+ ok: catalogFetched && blockers.every((item) => !item.startsWith('LIVE_CATALOG_UNAVAILABLE')),
350
+ detail: catalogFetched
351
+ ? 'live catalog fetched'
352
+ : bareCertification
353
+ ? 'live catalog required for bare certification'
354
+ : 'fixture-only catalog blocked for required write actions',
355
+ },
356
+ ];
357
+
358
+ const ok = checks.every((entry) => entry.ok) && blockers.length === 0;
359
+
360
+ return {
361
+ ok,
362
+ cliVersion: version,
363
+ certification: 'execution',
364
+ writeFree: true,
365
+ runMode: runMode || null,
366
+ requiredActions: normalizedRequiredActions,
367
+ actionSchemaDigest,
368
+ schemaCompatibility,
369
+ authority,
370
+ writePolicy,
371
+ readiness,
372
+ blockers,
373
+ commandAvailability,
374
+ authorityInputs,
375
+ venueActions,
376
+ checks,
377
+ };
378
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Reference paramsSchema fixtures for venue write actions (catalog shape).
3
+ * Used by doctor certify execution and unit tests when live catalog is unavailable.
4
+ */
5
+ export const VENUE_WRITE_ACTION_FIXTURES = [
6
+ {
7
+ action: 'place_hyperliquid_perps_order',
8
+ aliases: ['place-perps-order', 'place-hyperliquid-perps-order'],
9
+ provider: 'hyperliquid',
10
+ authorityInputs: ['agentApiKey', 'estimatedAmountUsd'],
11
+ paramsSchema: {
12
+ type: 'object',
13
+ additionalProperties: false,
14
+ required: ['coin', 'isBuy', 'size', 'limitPx', 'orderType'],
15
+ properties: {
16
+ coin: { type: 'string', minLength: 1 },
17
+ isBuy: { type: 'boolean' },
18
+ size: { type: 'number', minimum: 0 },
19
+ limitPx: { type: 'number', minimum: 0 },
20
+ orderType: { type: 'string', enum: ['limit', 'market'] },
21
+ reduceOnly: { type: 'boolean' },
22
+ userAddress: { type: 'string' },
23
+ },
24
+ },
25
+ validSample: {
26
+ coin: 'ETH',
27
+ isBuy: true,
28
+ size: 0.01,
29
+ limitPx: 2500,
30
+ orderType: 'limit',
31
+ },
32
+ },
33
+ {
34
+ action: 'cancel_hyperliquid_perps_order',
35
+ aliases: ['cancel-perps-order', 'cancel-hyperliquid-perps-order'],
36
+ provider: 'hyperliquid',
37
+ authorityInputs: ['agentApiKey'],
38
+ paramsSchema: {
39
+ type: 'object',
40
+ additionalProperties: false,
41
+ required: ['coin'],
42
+ properties: {
43
+ coin: { type: 'string', minLength: 1 },
44
+ orderId: { type: 'integer', minimum: 0 },
45
+ cloid: { type: 'string' },
46
+ },
47
+ },
48
+ validSample: { coin: 'ETH', orderId: 1 },
49
+ },
50
+ {
51
+ action: 'update_hyperliquid_leverage',
52
+ aliases: ['update-hyperliquid-leverage'],
53
+ provider: 'hyperliquid',
54
+ authorityInputs: ['agentApiKey'],
55
+ paramsSchema: {
56
+ type: 'object',
57
+ additionalProperties: false,
58
+ required: ['coin', 'leverage'],
59
+ properties: {
60
+ coin: { type: 'string', minLength: 1 },
61
+ leverage: { type: 'integer', minimum: 1 },
62
+ isCross: { type: 'boolean' },
63
+ userAddress: { type: 'string' },
64
+ },
65
+ },
66
+ validSample: { coin: 'ETH', leverage: 5 },
67
+ },
68
+ {
69
+ action: 'place_lighter_perps_order',
70
+ aliases: ['place-lighter-perps-order'],
71
+ provider: 'lighter',
72
+ authorityInputs: ['agentApiKey', 'estimatedAmountUsd'],
73
+ paramsSchema: {
74
+ type: 'object',
75
+ additionalProperties: false,
76
+ required: ['accountIndex', 'apiKeyIndex', 'marketId', 'baseAmount', 'price', 'isAsk'],
77
+ properties: {
78
+ accountIndex: { type: 'integer', minimum: 0 },
79
+ apiKeyIndex: { type: 'integer', minimum: 0 },
80
+ marketId: { type: 'integer', minimum: 0 },
81
+ clientOrderIndex: { type: 'integer', minimum: 0 },
82
+ baseAmount: { type: 'integer', minimum: 1 },
83
+ price: { type: 'integer', minimum: 1 },
84
+ isAsk: { type: 'boolean' },
85
+ orderType: { type: 'string', enum: ['limit', 'market'] },
86
+ reduceOnly: { type: 'boolean' },
87
+ },
88
+ },
89
+ validSample: {
90
+ accountIndex: 0,
91
+ apiKeyIndex: 0,
92
+ marketId: 1,
93
+ baseAmount: 100,
94
+ price: 250000,
95
+ isAsk: false,
96
+ },
97
+ },
98
+ {
99
+ action: 'cancel_lighter_perps_order',
100
+ aliases: ['cancel-lighter-perps-order'],
101
+ provider: 'lighter',
102
+ authorityInputs: ['agentApiKey'],
103
+ paramsSchema: {
104
+ type: 'object',
105
+ additionalProperties: false,
106
+ required: ['accountIndex', 'apiKeyIndex', 'marketId'],
107
+ properties: {
108
+ accountIndex: { type: 'integer', minimum: 0 },
109
+ apiKeyIndex: { type: 'integer', minimum: 0 },
110
+ marketId: { type: 'integer', minimum: 0 },
111
+ orderIndex: { type: 'integer', minimum: 0 },
112
+ clientOrderIndex: { type: 'integer', minimum: 0 },
113
+ },
114
+ },
115
+ validSample: {
116
+ accountIndex: 0,
117
+ apiKeyIndex: 0,
118
+ marketId: 1,
119
+ orderIndex: 42,
120
+ },
121
+ },
122
+ {
123
+ action: 'update_lighter_leverage',
124
+ aliases: ['update-lighter-leverage'],
125
+ provider: 'lighter',
126
+ authorityInputs: ['agentApiKey'],
127
+ paramsSchema: {
128
+ type: 'object',
129
+ additionalProperties: false,
130
+ required: ['accountIndex', 'apiKeyIndex', 'marketId', 'leverage'],
131
+ properties: {
132
+ accountIndex: { type: 'integer', minimum: 0 },
133
+ apiKeyIndex: { type: 'integer', minimum: 0 },
134
+ marketId: { type: 'integer', minimum: 0 },
135
+ leverage: { type: 'integer', minimum: 1 },
136
+ },
137
+ },
138
+ validSample: {
139
+ accountIndex: 0,
140
+ apiKeyIndex: 0,
141
+ marketId: 1,
142
+ leverage: 3,
143
+ },
144
+ },
145
+ {
146
+ action: 'place_polymarket_order',
147
+ aliases: ['place-prediction-order', 'place-polymarket-order'],
148
+ provider: 'polymarket',
149
+ authorityInputs: ['agentApiKey', 'estimatedAmountUsd'],
150
+ paramsSchema: {
151
+ type: 'object',
152
+ additionalProperties: false,
153
+ required: ['tokenID', 'price', 'size', 'side'],
154
+ properties: {
155
+ tokenID: { type: 'string', minLength: 1 },
156
+ price: { type: 'number', minimum: 0, maximum: 1 },
157
+ size: { type: 'number', minimum: 0 },
158
+ side: { type: 'string', enum: ['BUY', 'SELL'] },
159
+ orderType: { type: 'string', enum: ['GTC', 'GTD'] },
160
+ idempotencyKey: { type: 'string', minLength: 1 },
161
+ },
162
+ },
163
+ validSample: {
164
+ tokenID: '12345',
165
+ price: 0.42,
166
+ size: 10,
167
+ side: 'BUY',
168
+ },
169
+ },
170
+ {
171
+ action: 'cancel_polymarket_order',
172
+ aliases: ['cancel-prediction-order', 'cancel-polymarket-order'],
173
+ provider: 'polymarket',
174
+ authorityInputs: ['agentApiKey'],
175
+ paramsSchema: {
176
+ type: 'object',
177
+ additionalProperties: false,
178
+ required: ['orderId'],
179
+ properties: {
180
+ orderId: { type: 'string', minLength: 1 },
181
+ },
182
+ },
183
+ validSample: { orderId: '0xabc' },
184
+ },
185
+ ];
186
+
187
+ export function fixtureCatalogActions() {
188
+ return VENUE_WRITE_ACTION_FIXTURES.map((fixture) => ({
189
+ action: fixture.action,
190
+ aliases: fixture.aliases,
191
+ provider: fixture.provider,
192
+ paramsSchema: fixture.paramsSchema,
193
+ requiredParams: fixture.paramsSchema.required || [],
194
+ }));
195
+ }
@@ -39,6 +39,7 @@ export function createRuntimeHandlers(deps) {
39
39
  getPreparedParams,
40
40
  resolveActionSelection,
41
41
  prepareActionParamsForFlags,
42
+ enforceCatalogActionParams,
42
43
  enforceOffchainVenueLaneCompatibility,
43
44
  enforceChainCompatibility,
44
45
  executeManagedWalletRun,
@@ -50,6 +51,9 @@ export function createRuntimeHandlers(deps) {
50
51
  performSelfBroadcast,
51
52
  resolveManagedExecutionPreflight,
52
53
  resolveEffectiveUserDid,
54
+ loadEthers,
55
+ resolveOwsBinary,
56
+ CLI_VERSION,
53
57
  } = deps;
54
58
 
55
59
  const __discovery = createDiscoveryCommands({
@@ -103,6 +107,7 @@ export function createRuntimeHandlers(deps) {
103
107
  resolveEffectiveUserDid,
104
108
  loadEthers: deps.loadEthers,
105
109
  resolveOwsBinary: deps.resolveOwsBinary,
110
+ CLI_VERSION,
106
111
  });
107
112
 
108
113
  const __prompt = createPromptCommands({ printEnvelope });
@@ -123,6 +128,7 @@ export function createRuntimeHandlers(deps) {
123
128
  const __wallet = createWalletCommands({
124
129
  resolveActionSelection,
125
130
  prepareActionParamsForFlags,
131
+ enforceCatalogActionParams,
126
132
  enforceOffchainVenueLaneCompatibility,
127
133
  enforceChainCompatibility,
128
134
  applyHyperliquidAgentWalletAddressDefault,
@@ -136,6 +142,7 @@ export function createRuntimeHandlers(deps) {
136
142
  const __execute = createExecuteCommands({
137
143
  resolveActionSelection,
138
144
  prepareActionParamsForFlags,
145
+ enforceCatalogActionParams,
139
146
  enforceOffchainVenueLaneCompatibility,
140
147
  enforceChainCompatibility,
141
148
  applyHyperliquidAgentWalletAddressDefault,
@@ -203,6 +210,7 @@ export function createRuntimeHandlers(deps) {
203
210
  ...__venue,
204
211
  commandDoctorSelf: __doctor.commandDoctorSelf,
205
212
  commandDoctorAuth: __doctor.commandDoctorAuth,
213
+ commandDoctorCertifyExecution: __doctor.commandDoctorCertifyExecution,
206
214
  commandManagedSetup: __doctor.commandManagedSetup,
207
215
  commandCatalogsList: __discovery.commandCatalogsList,
208
216
  commandActions: __discovery.commandActions,
@@ -21,6 +21,7 @@ import { createExecutionRunners } from './execution/runners.mjs';
21
21
  import { createActionSelection } from './actions/selection.mjs';
22
22
  import { createBitfinexProbe } from './bitfinexProbe.mjs';
23
23
  import { createOwsSigning } from './owsSigning.mjs';
24
+ import { enforceCatalogActionParams } from './validation/actionParamsGate.mjs';
24
25
 
25
26
  const require = createRequire(import.meta.url);
26
27
  const { version: CLI_VERSION } = require('../../package.json');
@@ -70,6 +71,8 @@ export function createThirdfyAgentRuntime() {
70
71
  formatProviderAmbiguousActionHint,
71
72
  formatProviderUnknownActionHint,
72
73
  });
74
+ const catalogActionParamsGate = (resolved, flags) =>
75
+ enforceCatalogActionParams(resolved, flags, amounts.getPreparedParams);
73
76
  const ows = createOwsSigning();
74
77
  const bitfinex = createBitfinexProbe();
75
78
  const runners = createExecutionRunners({
@@ -107,6 +110,7 @@ export function createThirdfyAgentRuntime() {
107
110
  getPreparedParams: amounts.getPreparedParams,
108
111
  resolveActionSelection: actions.resolveActionSelection,
109
112
  prepareActionParamsForFlags: amounts.prepareActionParamsForFlags,
113
+ enforceCatalogActionParams: catalogActionParamsGate,
110
114
  enforceOffchainVenueLaneCompatibility: lanes.enforceOffchainVenueLaneCompatibility,
111
115
  enforceChainCompatibility: lanes.enforceChainCompatibility,
112
116
  executeManagedWalletRun: runners.executeManagedWalletRun,
@@ -120,6 +124,7 @@ export function createThirdfyAgentRuntime() {
120
124
  resolveEffectiveUserDid: payloads.resolveEffectiveUserDid,
121
125
  loadEthers: ows.loadEthers,
122
126
  resolveOwsBinary: ows.resolveOwsBinary,
127
+ CLI_VERSION,
123
128
  });
124
129
 
125
130
  cliProgram = buildCliProgram({
@@ -178,15 +183,20 @@ export function createThirdfyAgentRuntime() {
178
183
  const payload = error?.payload && typeof error.payload === 'object' ? error.payload : {};
179
184
  const blockedReason = String(payload.blockedReason || payload.error || '').trim() || undefined;
180
185
  const blockedStage = String(payload.blockedStage || '').trim() || undefined;
186
+ const clientCode = String(payload.error || payload.blockedReason || '').trim() || undefined;
181
187
  const onboardingHint = onboarding.buildTopLevelOnboardingHint(payload);
182
188
  printEnvelope({
183
189
  success: false,
184
- code: statusCode ? 'API_REQUEST_FAILED' : 'CLI_UNHANDLED_ERROR',
190
+ code: statusCode ? 'API_REQUEST_FAILED' : clientCode || 'CLI_UNHANDLED_ERROR',
185
191
  message: error?.message || 'Unhandled CLI error',
186
192
  data: {
187
193
  statusCode,
188
194
  blockedReason,
189
195
  blockedStage,
196
+ validationError: payload.validationError || null,
197
+ field: payload.field || null,
198
+ resolvedAction: payload.resolvedAction || null,
199
+ requestedAction: payload.requestedAction || null,
190
200
  onboardingHint,
191
201
  payload,
192
202
  },
@@ -0,0 +1,74 @@
1
+ import { createCliError } from '../../core/envelope.mjs';
2
+ import {
3
+ sanitizeParamsForSchemaValidation,
4
+ validateParamsSchema,
5
+ formatParamsSchemaValidationResult,
6
+ } from './paramsSchema.mjs';
7
+
8
+ export function resolveActionParamsSchema(resolved) {
9
+ const meta = resolved?.resolvedActionMeta;
10
+ if (!meta || typeof meta !== 'object') return null;
11
+ return meta.paramsSchema && typeof meta.paramsSchema === 'object' ? meta.paramsSchema : null;
12
+ }
13
+
14
+ export function isReadOnlyCatalogAction(resolved) {
15
+ const meta = resolved?.resolvedActionMeta;
16
+ if (!meta || typeof meta !== 'object') return false;
17
+ const action = String(resolved?.resolvedAction || '')
18
+ .trim()
19
+ .toLowerCase()
20
+ .replace(/-/g, '_');
21
+ const readOnlyByName = action.startsWith('get_') || action.startsWith('list_') || action.startsWith('search_');
22
+ const readOnlyByMeta = meta.supportsExecute === false && meta.supportsExecuteIntent !== false;
23
+ return readOnlyByName || readOnlyByMeta;
24
+ }
25
+
26
+ export function validateResolvedActionParams(resolved, params) {
27
+ const meta = resolved?.resolvedActionMeta;
28
+ if (!meta || typeof meta !== 'object') {
29
+ return {
30
+ valid: false,
31
+ skipped: false,
32
+ error: `Action "${resolved?.requestedAction || ''}" was not found in the Thirdfy catalog`,
33
+ field: null,
34
+ code: 'PARAMS_SCHEMA_INVALID',
35
+ };
36
+ }
37
+
38
+ const schema = resolveActionParamsSchema(resolved);
39
+ if (!schema) {
40
+ if (isReadOnlyCatalogAction(resolved)) {
41
+ return { valid: true, skipped: true };
42
+ }
43
+ return {
44
+ valid: false,
45
+ skipped: false,
46
+ error: `Action "${resolved.resolvedAction}" has no paramsSchema in the Thirdfy catalog`,
47
+ field: null,
48
+ code: 'PARAMS_SCHEMA_INVALID',
49
+ };
50
+ }
51
+
52
+ const sanitized = sanitizeParamsForSchemaValidation(params);
53
+ const result = formatParamsSchemaValidationResult(validateParamsSchema(sanitized, schema));
54
+ return { ...result, skipped: false, sanitizedParams: sanitized };
55
+ }
56
+
57
+ export function enforceCatalogActionParams(resolved, flags, getPreparedParams) {
58
+ const params = typeof getPreparedParams === 'function' ? getPreparedParams(flags) : flags?.__preparedParams || {};
59
+ const result = validateResolvedActionParams(resolved, params);
60
+ if (result.valid) {
61
+ if (result.sanitizedParams && flags) {
62
+ flags.__preparedParams = result.sanitizedParams;
63
+ }
64
+ return;
65
+ }
66
+ throw createCliError('PARAMS_SCHEMA_INVALID', result.error || 'Action params failed catalog schema validation', {
67
+ blockedReason: 'PARAMS_SCHEMA_INVALID',
68
+ blockedStage: 'client_validation',
69
+ validationError: result.error || null,
70
+ field: result.field || null,
71
+ resolvedAction: resolved?.resolvedAction || null,
72
+ requestedAction: resolved?.requestedAction || null,
73
+ });
74
+ }
@@ -0,0 +1,127 @@
1
+ /** Adapter-only execute metadata tolerated when additionalProperties is false (API parity). */
2
+ export const PARAMS_SCHEMA_ADAPTER_METADATA_KEYS = new Set([
3
+ 'resolvedSizingSource',
4
+ 'requestedNotionalUsd',
5
+ 'clampedToRiskNotionalUsd',
6
+ 'clampedToPositionNotionalUsd',
7
+ 'adapterRiskNotionalUsd',
8
+ 'adapterStopDistancePct',
9
+ 'adapterStopDistancePctSource',
10
+ 'adapterRiskBudgetUsd',
11
+ 'notionalUsd',
12
+ 'marginUsd',
13
+ 'effectiveLeverage',
14
+ 'effectiveNotionalUsd',
15
+ ]);
16
+
17
+ /** Billing / sizing hints belong on the HTTP payload, not inside action params. */
18
+ export const PARAMS_SCHEMA_BILLING_KEYS = new Set([
19
+ 'estimatedAmountUsd',
20
+ 'amountUsd',
21
+ 'estimated_amount_usd',
22
+ ]);
23
+
24
+ function checkType(value, expected) {
25
+ if (expected === 'string') return typeof value === 'string';
26
+ if (expected === 'number') return typeof value === 'number' && Number.isFinite(value);
27
+ if (expected === 'integer') return typeof value === 'number' && Number.isInteger(value);
28
+ if (expected === 'boolean') return typeof value === 'boolean';
29
+ if (expected === 'object') return value !== null && typeof value === 'object' && !Array.isArray(value);
30
+ if (expected === 'array') return Array.isArray(value);
31
+ return true;
32
+ }
33
+
34
+ function extractFieldFromError(message) {
35
+ const text = String(message || '');
36
+ let match = text.match(/missing required key "([^"]+)"/);
37
+ if (match) return match[1];
38
+ match = text.match(/unknown key "([^"]+)"/);
39
+ if (match) return match[1];
40
+ match = text.match(/"([^"]+)" must be/);
41
+ if (match) return match[1];
42
+ match = text.match(/"([^"]+)" is too/);
43
+ if (match) return match[1];
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * Validate action params against catalog paramsSchema (JSON Schema subset used by Thirdfy API).
49
+ * Mirrors AgentExecutionService.validateParamsSchema for client-side fail-fast.
50
+ */
51
+ export function validateParamsSchema(params, schema) {
52
+ if (!schema || typeof schema !== 'object') return { valid: true };
53
+
54
+ const required = Array.isArray(schema.required) ? schema.required.map((k) => String(k)) : [];
55
+ const properties =
56
+ schema.properties && typeof schema.properties === 'object'
57
+ ? schema.properties
58
+ : {};
59
+ const additionalProperties = schema.additionalProperties;
60
+
61
+ for (const key of required) {
62
+ if (!(key in (params || {}))) {
63
+ const error = `Invalid paramsSchema: missing required key "${key}"`;
64
+ return { valid: false, error, field: key, code: 'PARAMS_SCHEMA_INVALID' };
65
+ }
66
+ }
67
+
68
+ for (const [key, value] of Object.entries(params || {})) {
69
+ const rule = properties[key];
70
+ if (!rule) {
71
+ if (additionalProperties === false) {
72
+ if (key === 'agentKey' || key === 'cexCredentialRef' || PARAMS_SCHEMA_ADAPTER_METADATA_KEYS.has(key)) {
73
+ continue;
74
+ }
75
+ const error = `Invalid paramsSchema: unknown key "${key}"`;
76
+ return { valid: false, error, field: key, code: 'PARAMS_SCHEMA_INVALID' };
77
+ }
78
+ continue;
79
+ }
80
+ if (rule.type && !checkType(value, String(rule.type))) {
81
+ const error = `Invalid paramsSchema: "${key}" must be ${String(rule.type)}`;
82
+ return { valid: false, error, field: key, code: 'PARAMS_SCHEMA_INVALID' };
83
+ }
84
+ if (Array.isArray(rule.enum) && !rule.enum.includes(value)) {
85
+ const error = `Invalid paramsSchema: "${key}" must be one of [${rule.enum.join(', ')}]`;
86
+ return { valid: false, error, field: key, code: 'PARAMS_SCHEMA_INVALID' };
87
+ }
88
+ if (typeof value === 'number') {
89
+ if (typeof rule.minimum === 'number' && value < rule.minimum) {
90
+ const error = `Invalid paramsSchema: "${key}" must be >= ${rule.minimum}`;
91
+ return { valid: false, error, field: key, code: 'PARAMS_SCHEMA_INVALID' };
92
+ }
93
+ if (typeof rule.maximum === 'number' && value > rule.maximum) {
94
+ const error = `Invalid paramsSchema: "${key}" must be <= ${rule.maximum}`;
95
+ return { valid: false, error, field: key, code: 'PARAMS_SCHEMA_INVALID' };
96
+ }
97
+ }
98
+ if (typeof value === 'string') {
99
+ if (typeof rule.minLength === 'number' && value.length < rule.minLength) {
100
+ const error = `Invalid paramsSchema: "${key}" is too short`;
101
+ return { valid: false, error, field: key, code: 'PARAMS_SCHEMA_INVALID' };
102
+ }
103
+ if (typeof rule.maxLength === 'number' && value.length > rule.maxLength) {
104
+ const error = `Invalid paramsSchema: "${key}" is too long`;
105
+ return { valid: false, error, field: key, code: 'PARAMS_SCHEMA_INVALID' };
106
+ }
107
+ }
108
+ }
109
+
110
+ return { valid: true };
111
+ }
112
+
113
+ export function sanitizeParamsForSchemaValidation(params) {
114
+ const next = { ...(params && typeof params === 'object' ? params : {}) };
115
+ for (const key of PARAMS_SCHEMA_BILLING_KEYS) {
116
+ delete next[key];
117
+ }
118
+ return next;
119
+ }
120
+
121
+ export function formatParamsSchemaValidationResult(result) {
122
+ if (!result || result.valid) return result;
123
+ return {
124
+ ...result,
125
+ field: result.field || extractFieldFromError(result.error),
126
+ };
127
+ }