@thirdfy/agent-cli 0.2.33 → 0.2.35

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,27 @@ All notable changes to `@thirdfy/agent-cli` are documented here. The format is b
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.35] - 2026-07-25
8
+
9
+ ### Fixed
10
+
11
+ - Read-only detection now matches the Thirdfy API action manifest instead of a narrower client-side guess. `fetch_*`, `show_*`, `dogeos_get_*`, `*_info`, `dogeos_barkswap_get_quote`, `dogeos_laika_read_contract`, and `dogeos_laika_token_state` were previously classified as writes, so under `--run-mode agent_wallet` they went to the execute rail and came back as execution envelopes rather than data. A test now pins the two lists together so they cannot drift apart again.
12
+ - `--run-mode self` and `--run-mode hybrid` serve read-only actions from the read rail. Previously a read under `self` either failed with `SELF_MODE_REQUIRES_BUILD_TX` or, when catalog metadata was missing, asked `/api/v1/agent/build-tx` to build a transaction for a market-data query. Under `hybrid` the payload was nested beneath `managed`, where callers reading `results` could not find it, and a mirror failure could mark a successful read as failed.
13
+ - `normalizeIntentResponse` no longer drops top-level `data` and `raw`. It is a whitelist, and read responses carry their payload in `data`, so a caller could receive a well-formed envelope with nothing in it.
14
+
15
+ ### Changed
16
+
17
+ - Bitfinex reads under `--run-mode hybrid` are served from the delegated rail instead of returning `HYBRID_MODE_REQUIRES_BUILD_TX` with a hint to rerun using `--run-mode thirdfy`. The guard still applies to Bitfinex writes.
18
+
19
+ ## [0.2.34] - 2026-07-25
20
+
21
+ ### Fixed
22
+
23
+ - Read-only actions run under `--run-mode agent_wallet` no longer go through the managed-wallet execute rail. `run`, `preflight`, and pack callers that request `get_*`, `list_*`, or `search_*` actions (or any catalog action with `supportsExecute: false`) are routed to the read rail up front, so the provider payload is returned as-is instead of being reshaped into an execution intent envelope (`intentId`, `blocked`, `executed`).
24
+ - Reads on `agent_wallet` are no longer gated on execution-wallet funding. The execution wallet address is still resolved and reported for response parity, but an unfunded wallet does not block a read. Every other preflight failure (`MISSING_USER_DID`, `EXECUTION_WALLET_MISMATCH`, and other routing checks) still fails closed with `PREFLIGHT_BLOCKED`, exactly as it does for a write.
25
+
26
+ Operator impact: `get_hyperliquid_perps_meta` and `get_hyperliquid_all_mids` previously returned an envelope with the market universe buried under `data.raw`, which consumers could not parse. Hyperliquid agents fell back to a stale cached universe and reported no trading edge. Upgrade to 0.2.34 on every runtime that executes with `--run-mode agent_wallet`.
27
+
7
28
  ## [0.2.33] - 2026-07-24
8
29
 
9
30
  ### Fixed
package/README.md CHANGED
@@ -40,9 +40,11 @@ Run without global install:
40
40
  npx @thirdfy/agent-cli --help
41
41
  ```
42
42
 
43
- ## What's new in v0.2.33
43
+ ## What's new in v0.2.35
44
44
 
45
- - `login email` always surfaces `executionWallets` (and `executionWalletsError` when present) and fails closed for `agent_wallet` when execution wallets are missing. Fund the printed execution address, not the owner login wallet.
45
+ - Read-only actions return the provider payload directly in every run mode. Market data reads such as `get_hyperliquid_perps_meta` no longer come back wrapped as an execution intent, and `--run-mode self` no longer refuses them.
46
+ - Read detection now matches the Thirdfy action catalog, so `fetch_*`, `show_*`, `dogeos_get_*`, and `*_info` actions are recognized as reads.
47
+ - Reads are not gated on execution-wallet funding. The execution address is still reported, but an unfunded wallet does not block a read. Routing and identity checks still apply.
46
48
 
47
49
  Older versions: see [CHANGELOG.md](./CHANGELOG.md) and [GitHub Releases](https://github.com/thirdfy/agent-cli/releases).
48
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thirdfy/agent-cli",
3
- "version": "0.2.33",
3
+ "version": "0.2.35",
4
4
  "description": "Thirdfy Agent CLI for onboarding, governance preflight, execute-intent, and status polling.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Read-only action classification.
3
+ *
4
+ * This mirrors `isReadOnlyAction` in the Thirdfy API
5
+ * (`src/services/agents/execution/catalog/actionContractManifest.ts`). Keep the two in sync: when the
6
+ * client and the server disagree about what counts as a read, reads get sent to the managed-wallet
7
+ * execute rail and come back shaped as an execution envelope instead of provider data.
8
+ */
9
+
10
+ const READ_ONLY_PREFIXES = ['get_', 'list_', 'fetch_', 'search_', 'show_', 'dogeos_get_'];
11
+
12
+ const READ_ONLY_EXACT = new Set([
13
+ 'dogeos_barkswap_get_quote',
14
+ 'dogeos_laika_read_contract',
15
+ 'dogeos_laika_token_state',
16
+ ]);
17
+
18
+ export function normalizeActionKey(actionKey) {
19
+ return String(actionKey || '')
20
+ .trim()
21
+ .toLowerCase()
22
+ .replace(/-/g, '_');
23
+ }
24
+
25
+ export function isReadOnlyActionName(actionKey) {
26
+ const key = normalizeActionKey(actionKey);
27
+ if (!key) return false;
28
+ if (READ_ONLY_EXACT.has(key)) return true;
29
+ if (key.endsWith('_info')) return true;
30
+ return READ_ONLY_PREFIXES.some((prefix) => key.startsWith(prefix));
31
+ }
32
+
33
+ /**
34
+ * Catalog metadata can mark an action read-only even when the name does not match. Production
35
+ * defaults set `supportsExecute: true` on nearly everything, so this rarely fires on its own.
36
+ */
37
+ export function isReadOnlyActionMeta(meta) {
38
+ if (!meta || typeof meta !== 'object') return false;
39
+ return meta.supportsExecute === false && meta.supportsExecuteIntent !== false;
40
+ }
41
+
42
+ export function isReadOnlyResolvedAction(resolved, options = {}) {
43
+ const meta = resolved?.resolvedActionMeta;
44
+ if (options.requireMeta && (!meta || typeof meta !== 'object')) return false;
45
+ return isReadOnlyActionName(resolved?.resolvedAction) || isReadOnlyActionMeta(meta);
46
+ }
@@ -11,6 +11,7 @@ import {
11
11
  } from '../../core/runMode.mjs';
12
12
  import { apiGet, apiPost } from '../../core/http.mjs';
13
13
  import { withActionTimeout } from '../../core/actionTimeouts.mjs';
14
+ import { isReadOnlyResolvedAction } from '../../core/readOnlyActions.mjs';
14
15
  import { createRequire } from 'module';
15
16
 
16
17
  const require = createRequire(import.meta.url);
@@ -79,15 +80,21 @@ function applyExecutionFallbackHints(normalized, { flags, runMode, resolvedActio
79
80
  return next;
80
81
  }
81
82
 
83
+ function isReadOnlyAction(resolved) {
84
+ return isReadOnlyResolvedAction(resolved);
85
+ }
86
+
87
+ // Reads carry no transaction, so wallet balance never blocks them. Every other preflight failure
88
+ // (identity, routing, wallet mismatch) still fails closed, same as a write.
89
+ const FUNDING_ONLY_PREFLIGHT_BLOCKS = new Set(['INSUFFICIENT_FUNDS', 'INVALID_FUNDING_BALANCE']);
90
+
91
+ function isFundingOnlyPreflightBlock(preflight) {
92
+ const reason = String(preflight?.blockedReason || '').trim().toUpperCase();
93
+ return FUNDING_ONLY_PREFLIGHT_BLOCKS.has(reason);
94
+ }
95
+
82
96
  function shouldFallbackAgentWalletToExecuteIntent(resolved, response) {
83
- const meta = resolved?.resolvedActionMeta || {};
84
- const action = String(resolved?.resolvedAction || '')
85
- .trim()
86
- .toLowerCase()
87
- .replace(/-/g, '_');
88
- const readOnlyByName = action.startsWith('get_') || action.startsWith('search_');
89
- const readOnlyByMeta = meta.supportsExecute === false && meta.supportsExecuteIntent !== false;
90
- if (!readOnlyByName && !readOnlyByMeta) return false;
97
+ if (!isReadOnlyAction(resolved)) return false;
91
98
  const err = String(response?.error || response?.message || '').toLowerCase();
92
99
  return err.includes('does not support execute rail');
93
100
  }
@@ -97,7 +104,8 @@ async function runPreflightByMode(runMode, ctx, flags, resolved, options = {}) {
97
104
  if (runMode === 'agent_wallet') {
98
105
  const preflight = await resolveManagedExecutionPreflight(ctx, flags, resolved, {
99
106
  runMode,
100
- requireFundingCheck: true,
107
+ // Reads carry no transaction; keep funding optional so preflight matches run.
108
+ requireFundingCheck: !isReadOnlyAction(resolved),
101
109
  preflightSeed: options.managedPreflight,
102
110
  });
103
111
  const normalized = normalizeIntentResponse({
@@ -124,6 +132,34 @@ async function runPreflightByMode(runMode, ctx, flags, resolved, options = {}) {
124
132
  };
125
133
  }
126
134
  if (runMode === 'self') {
135
+ // Reads have no unsigned tx to prepare. Keep preflight on validation-only execute-intent so
136
+ // operators dry-running self mode do not trigger a real catalog read (run still uses the read rail).
137
+ if (isReadOnlyAction(resolved)) {
138
+ const payload = buildIntentPayload(flags, {
139
+ validationOnly: true,
140
+ forceIdempotency: false,
141
+ resolvedAction,
142
+ runMode: 'self',
143
+ mirrorOnly: false,
144
+ });
145
+ const response = await apiPost(ctx, '/api/v1/agent/execute-intent', payload);
146
+ const normalized = normalizeIntentResponse(response);
147
+ const blockedCount = normalized.blocked || 0;
148
+ if (!normalized.success || blockedCount > 0) {
149
+ normalized.success = false;
150
+ normalized.error = normalized.error || 'Preflight blocked self execution';
151
+ normalized.preflightBlocked = true;
152
+ }
153
+ return {
154
+ message: normalized.success
155
+ ? 'Self preflight completed'
156
+ : normalized.preflightBlocked
157
+ ? 'Self preflight blocked by governance'
158
+ : 'Self preflight failed',
159
+ route: 'execute_intent_validation',
160
+ normalized,
161
+ };
162
+ }
127
163
  const route = shouldUseBuildTxPreflight(runMode, resolved) ? 'build_tx' : 'execute_intent_validation';
128
164
  const normalized = await executeSelfRun(
129
165
  ctx,
@@ -142,43 +178,74 @@ async function runPreflightByMode(runMode, ctx, flags, resolved, options = {}) {
142
178
  normalized,
143
179
  };
144
180
  }
145
- if (runMode === 'hybrid' && options.hybridWalletMode === 'agent_wallet') {
146
- const managedPayload = buildIntentPayload(flags, {
147
- validationOnly: true,
148
- forceIdempotency: false,
149
- resolvedAction,
150
- runMode: 'agent_wallet',
151
- mirrorOnly: false,
152
- hybridWalletMode: 'agent_wallet',
153
- });
154
- const managedResponse = await apiPost(ctx, '/api/v1/agent/execute-intent', managedPayload);
155
- const managedNormalized = normalizeIntentResponse(managedResponse);
156
- const mirrorPayload = buildIntentPayload(flags, {
157
- validationOnly: true,
158
- forceIdempotency: false,
159
- resolvedAction,
160
- runMode: 'hybrid',
161
- mirrorOnly: true,
162
- hybridWalletMode: 'agent_wallet',
163
- });
164
- const mirrorResponse = await apiPost(ctx, '/api/v1/agent/execute-intent', mirrorPayload);
165
- const mirrorNormalized = normalizeIntentResponse(mirrorResponse);
166
- return {
167
- message:
168
- managedNormalized.success && mirrorNormalized.success
169
- ? 'Hybrid preflight completed (agent_wallet + thirdfy mirror)'
170
- : 'Hybrid preflight failed (agent_wallet + thirdfy mirror)',
171
- route: 'execute_intent_validation_dual',
172
- normalized: normalizeIntentResponse({
173
- success: Boolean(managedNormalized.success && mirrorNormalized.success),
174
- status: mirrorNormalized.status || managedNormalized.status || 'failed',
175
- mode: 'hybrid',
181
+ if (runMode === 'hybrid') {
182
+ // Reads have nothing to mirror and need no wallet. Keep preflight on validation-only
183
+ // execute-intent so dry-runs do not trigger a real catalog read (run still uses the read rail).
184
+ if (isReadOnlyAction(resolved)) {
185
+ const payload = buildIntentPayload(flags, {
186
+ validationOnly: true,
187
+ forceIdempotency: false,
188
+ resolvedAction,
189
+ runMode: 'hybrid',
190
+ mirrorOnly: false,
191
+ hybridWalletMode: options.hybridWalletMode,
192
+ });
193
+ const response = await apiPost(ctx, '/api/v1/agent/execute-intent', payload);
194
+ const normalized = normalizeIntentResponse(response);
195
+ const blockedCount = normalized.blocked || 0;
196
+ if (!normalized.success || blockedCount > 0) {
197
+ normalized.success = false;
198
+ normalized.error = normalized.error || 'Preflight blocked hybrid execution';
199
+ normalized.preflightBlocked = true;
200
+ }
201
+ return {
202
+ message: normalized.success
203
+ ? 'Hybrid preflight completed'
204
+ : normalized.preflightBlocked
205
+ ? 'Hybrid preflight blocked by governance'
206
+ : 'Hybrid preflight failed',
207
+ route: 'execute_intent_validation',
208
+ normalized,
209
+ };
210
+ }
211
+ if (options.hybridWalletMode === 'agent_wallet') {
212
+ const managedPayload = buildIntentPayload(flags, {
213
+ validationOnly: true,
214
+ forceIdempotency: false,
215
+ resolvedAction,
216
+ runMode: 'agent_wallet',
217
+ mirrorOnly: false,
176
218
  hybridWalletMode: 'agent_wallet',
177
- managed: managedNormalized,
178
- mirror: mirrorNormalized,
179
- error: managedNormalized.error || mirrorNormalized.error || null,
180
- }),
181
- };
219
+ });
220
+ const managedResponse = await apiPost(ctx, '/api/v1/agent/execute-intent', managedPayload);
221
+ const managedNormalized = normalizeIntentResponse(managedResponse);
222
+ const mirrorPayload = buildIntentPayload(flags, {
223
+ validationOnly: true,
224
+ forceIdempotency: false,
225
+ resolvedAction,
226
+ runMode: 'hybrid',
227
+ mirrorOnly: true,
228
+ hybridWalletMode: 'agent_wallet',
229
+ });
230
+ const mirrorResponse = await apiPost(ctx, '/api/v1/agent/execute-intent', mirrorPayload);
231
+ const mirrorNormalized = normalizeIntentResponse(mirrorResponse);
232
+ return {
233
+ message:
234
+ managedNormalized.success && mirrorNormalized.success
235
+ ? 'Hybrid preflight completed (agent_wallet + thirdfy mirror)'
236
+ : 'Hybrid preflight failed (agent_wallet + thirdfy mirror)',
237
+ route: 'execute_intent_validation_dual',
238
+ normalized: normalizeIntentResponse({
239
+ success: Boolean(managedNormalized.success && mirrorNormalized.success),
240
+ status: mirrorNormalized.status || managedNormalized.status || 'failed',
241
+ mode: 'hybrid',
242
+ hybridWalletMode: 'agent_wallet',
243
+ managed: managedNormalized,
244
+ mirror: mirrorNormalized,
245
+ error: managedNormalized.error || mirrorNormalized.error || null,
246
+ }),
247
+ };
248
+ }
182
249
  }
183
250
  const payload = buildIntentPayload(flags, {
184
251
  validationOnly: true,
@@ -234,14 +301,20 @@ async function runExecutionByMode(runMode, ctx, flags, resolved, options) {
234
301
  if (runMode === 'self') {
235
302
  const normalized = await executeSelfRun(ctx, flags, resolved, options);
236
303
  const preflightBlocked = !normalized.success && Boolean(normalized.preflightBlocked);
304
+ const readOnlySuccess =
305
+ normalized.success && normalized.routeFallback === 'execute_intent_read_only';
237
306
  return {
238
307
  code: normalized.success
239
- ? 'SELF_UNSIGNED_TX_READY'
308
+ ? readOnlySuccess
309
+ ? 'SELF_READ_COMPLETED'
310
+ : 'SELF_UNSIGNED_TX_READY'
240
311
  : preflightBlocked
241
312
  ? 'PREFLIGHT_BLOCKED'
242
313
  : 'SELF_EXECUTION_FAILED',
243
314
  message: normalized.success
244
- ? 'Unsigned transaction prepared for self-custody execution'
315
+ ? readOnlySuccess
316
+ ? 'Read-only action completed via delegated execute-intent'
317
+ : 'Unsigned transaction prepared for self-custody execution'
245
318
  : preflightBlocked
246
319
  ? 'Preflight blocked self execution'
247
320
  : 'Self-custody execution failed',
@@ -254,12 +327,22 @@ async function runExecutionByMode(runMode, ctx, flags, resolved, options) {
254
327
  const hybridWalletMode = normalizeHybridWalletMode(
255
328
  normalized.hybridWalletMode || options?.hybridWalletMode || flags.hybridWalletMode || 'self',
256
329
  );
330
+ const readOnlySuccess =
331
+ normalized.success && normalized.routeFallback === 'execute_intent_read_only';
257
332
  return {
258
- code: normalized.success ? 'HYBRID_READY' : preflightBlocked ? 'PREFLIGHT_BLOCKED' : 'HYBRID_FAILED',
333
+ code: normalized.success
334
+ ? readOnlySuccess
335
+ ? 'HYBRID_READ_COMPLETED'
336
+ : 'HYBRID_READY'
337
+ : preflightBlocked
338
+ ? 'PREFLIGHT_BLOCKED'
339
+ : 'HYBRID_FAILED',
259
340
  message: normalized.success
260
- ? hybridWalletMode === 'agent_wallet'
261
- ? 'Hybrid execution prepared (agent wallet + mirror preflight)'
262
- : 'Hybrid execution prepared (self tx + mirror preflight)'
341
+ ? readOnlySuccess
342
+ ? 'Read-only action completed via delegated execute-intent'
343
+ : hybridWalletMode === 'agent_wallet'
344
+ ? 'Hybrid execution prepared (agent wallet + mirror preflight)'
345
+ : 'Hybrid execution prepared (self tx + mirror preflight)'
263
346
  : preflightBlocked
264
347
  ? 'Preflight blocked hybrid execution'
265
348
  : 'Hybrid execution failed',
@@ -304,7 +387,68 @@ async function executeThirdfyRun(ctx, flags, resolved, options, runMode = 'third
304
387
  return normalized;
305
388
  }
306
389
 
390
+ async function runReadOnlyActionViaIntentRail(ctx, flags, resolved, options, preflight, reportedMode = 'agent_wallet') {
391
+ const userDid = resolveEffectiveUserDid(flags, {
392
+ runMode: 'agent_wallet',
393
+ hybridWalletMode: options?.hybridWalletMode,
394
+ preferDelegationIdentity: options?.preferDelegationIdentity,
395
+ });
396
+ const intentResult = await executeThirdfyRun(
397
+ ctx,
398
+ {
399
+ ...flags,
400
+ chainId: Number(flags.chainId || 8453),
401
+ runMode: 'thirdfy',
402
+ userDid: userDid || flags.userDid,
403
+ executionScope: flags.executionScope || 'solo_owner_mirror',
404
+ },
405
+ resolved,
406
+ { ...options, skipPreflight: true },
407
+ 'thirdfy'
408
+ );
409
+ intentResult.mode = reportedMode;
410
+ intentResult.routeFallback = 'execute_intent_read_only';
411
+ if (preflight) {
412
+ intentResult.executionWalletPreflight = preflight;
413
+ intentResult.executionWalletAddress = preflight.executionWalletAddress || null;
414
+ }
415
+ intentResult.signerMethod = 'managed_wallet_server';
416
+ return intentResult;
417
+ }
418
+
307
419
  async function executeManagedWalletRun(ctx, flags, resolved, options) {
420
+ // Read-only actions carry no transaction. Routing them through the managed-wallet execute rail
421
+ // returns an execution envelope instead of the queried data, which silently starves callers that
422
+ // asked for market state (see earnclaw-api HL universe probe regression, 2026-07-25).
423
+ // The wallet address is still resolved for response parity, but a read is never gated on funding.
424
+ if (isReadOnlyAction(resolved)) {
425
+ // Do not swallow lookup/routing errors: writes let them propagate, and reads must fail closed
426
+ // the same way (wallet mismatch, missing identity, failed /execution-wallet).
427
+ const readPreflight = await resolveManagedExecutionPreflight(ctx, flags, resolved, {
428
+ runMode: 'agent_wallet',
429
+ requireFundingCheck: false,
430
+ preflightSeed: options?.managedPreflight,
431
+ });
432
+ // Funding is intentionally skipped for reads (`requireFundingCheck: false`), but routing
433
+ // failures (wallet mismatch, missing identity, failed address seed) must still fail closed.
434
+ if (readPreflight && readPreflight.success === false && !isFundingOnlyPreflightBlock(readPreflight)) {
435
+ const normalized = normalizeIntentResponse({
436
+ success: false,
437
+ status: 'failed',
438
+ mode: 'agent_wallet',
439
+ blockedReason: readPreflight.blockedReason || 'PRECHECK_FAILED',
440
+ blockedStage: readPreflight.blockedStage || 'routing',
441
+ error: readPreflight.error || 'Managed wallet preflight failed',
442
+ preflightBlocked: true,
443
+ executionWalletAddress: readPreflight.executionWalletAddress || null,
444
+ executionWalletPreflight: readPreflight,
445
+ });
446
+ normalized.executionWalletAddress = readPreflight.executionWalletAddress || null;
447
+ normalized.executionWalletPreflight = readPreflight;
448
+ return normalized;
449
+ }
450
+ return runReadOnlyActionViaIntentRail(ctx, flags, resolved, options, readPreflight);
451
+ }
308
452
  const skipPreflight = Boolean(options?.skipPreflight);
309
453
  let preflight = null;
310
454
  if (!skipPreflight) {
@@ -338,34 +482,12 @@ async function executeManagedWalletRun(ctx, flags, resolved, options) {
338
482
  const actionCtx = withActionTimeout(ctx, resolved.resolvedAction);
339
483
  const response = await apiPost(actionCtx, '/api/v1/agent/execute', payload);
340
484
  if (shouldFallbackAgentWalletToExecuteIntent(resolved, response)) {
341
- const userDid = resolveEffectiveUserDid(flags, {
342
- runMode: 'agent_wallet',
343
- hybridWalletMode: options.hybridWalletMode,
344
- preferDelegationIdentity: options.preferDelegationIdentity,
345
- });
346
- const chainId = Number(flags.chainId || 8453);
347
- const intentResult = await executeThirdfyRun(
348
- ctx,
349
- {
350
- ...flags,
351
- chainId,
352
- runMode: 'thirdfy',
353
- userDid: userDid || flags.userDid,
354
- executionScope: flags.executionScope || 'solo_owner_mirror',
355
- },
356
- resolved,
357
- { ...options, skipPreflight: true },
358
- 'thirdfy'
359
- );
360
- intentResult.mode = 'agent_wallet';
361
- intentResult.routeFallback = 'execute_intent_read_only';
362
- intentResult.executionWalletPreflight = preflight;
485
+ const intentResult = await runReadOnlyActionViaIntentRail(ctx, flags, resolved, options, preflight);
363
486
  intentResult.executionWalletAddress =
364
487
  preflight?.executionWalletAddress ||
365
488
  response?.executionWalletAddress ||
366
489
  response?.data?.executionWalletAddress ||
367
490
  null;
368
- intentResult.signerMethod = 'managed_wallet_server';
369
491
  return intentResult;
370
492
  }
371
493
  const normalized = normalizeIntentResponse({
@@ -395,6 +517,12 @@ async function executeManagedWalletRun(ctx, flags, resolved, options) {
395
517
  async function executeSelfRun(ctx, flags, resolved, options) {
396
518
  const skipPreflight = Boolean(options?.skipPreflight);
397
519
  const blockedReasonCode = String(options?.blockedReasonCode || 'SELF_MODE_REQUIRES_BUILD_TX');
520
+ // A read has no transaction to build or sign, so the self lane has nothing to do with it. Without
521
+ // this it either fails with SELF_MODE_REQUIRES_BUILD_TX or, when catalog metadata is missing, asks
522
+ // /build-tx to produce a transaction for a market-data query.
523
+ if (isReadOnlyAction(resolved)) {
524
+ return runReadOnlyActionViaIntentRail(ctx, flags, resolved, options, null, options?.runMode || 'self');
525
+ }
398
526
  if (!skipPreflight) {
399
527
  const preflightPayload = buildIntentPayload(flags, {
400
528
  validationOnly: true,
@@ -437,6 +565,14 @@ async function executeSelfRun(ctx, flags, resolved, options) {
437
565
 
438
566
  async function executeHybridRun(ctx, flags, resolved, options) {
439
567
  const hybridWalletMode = normalizeHybridWalletMode(options?.hybridWalletMode || flags.hybridWalletMode || 'self');
568
+ // Reads have nothing to mirror and need no wallet, whichever hybrid wallet mode is selected.
569
+ // Answering from the read rail also keeps the payload at the top level, instead of nesting it under
570
+ // `managed` where callers that read `results` cannot find it.
571
+ if (isReadOnlyAction(resolved)) {
572
+ const readResult = await runReadOnlyActionViaIntentRail(ctx, flags, resolved, options, null, 'hybrid');
573
+ readResult.hybridWalletMode = hybridWalletMode;
574
+ return readResult;
575
+ }
440
576
  if (hybridWalletMode === 'agent_wallet') {
441
577
  const managedResult = await executeManagedWalletRun(ctx, { ...flags, runMode: 'agent_wallet' }, resolved, options);
442
578
  if (!managedResult.success) {
@@ -1,4 +1,5 @@
1
1
  import { createCliError } from '../../core/envelope.mjs';
2
+ import { isReadOnlyResolvedAction } from '../../core/readOnlyActions.mjs';
2
3
  import {
3
4
  sanitizeParamsForSchemaValidation,
4
5
  validateParamsSchema,
@@ -12,15 +13,7 @@ export function resolveActionParamsSchema(resolved) {
12
13
  }
13
14
 
14
15
  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;
16
+ return isReadOnlyResolvedAction(resolved, { requireMeta: true });
24
17
  }
25
18
 
26
19
  export function validateResolvedActionParams(resolved, params) {
@@ -34,7 +34,14 @@ function normalizeIntentResponse(response) {
34
34
  const topLevelDelegationSignal = String(response?.delegationSignal || '').trim() || null;
35
35
  const policyEvaluated = response?.policyEvaluated === true;
36
36
  const preflightBlocked = response?.preflightBlocked === true;
37
+ // This function is a whitelist, so any provider payload outside the known keys is dropped. Read
38
+ // actions return their data in `data` (and sometimes `raw`) rather than in `results`, so those two
39
+ // must survive or the caller gets a well-formed envelope with nothing in it.
40
+ const passthrough = {};
41
+ if (response?.data !== undefined) passthrough.data = response.data;
42
+ if (response?.raw !== undefined) passthrough.raw = response.raw;
37
43
  return {
44
+ ...passthrough,
38
45
  success,
39
46
  status: response?.status || (success ? 'queued' : 'failed'),
40
47
  intentId: response?.intentId || null,