callx402 1.0.1 → 1.0.3

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.
@@ -16,8 +16,7 @@
16
16
  const { test } = require('node:test');
17
17
  const path = require('node:path');
18
18
  const { runIntent, runAction } = require('../core/index.js');
19
- const { scratchDir, withEnv } = require('./helpers');
20
-
19
+ const { scratchDir, withEnv, needsV2Tree } = require('./helpers');
21
20
  const scratch = scratchDir('pipeline');
22
21
  const hermetic = {
23
22
  CALLX402_CONFIG: path.join(scratch, 'config.json'),
@@ -169,7 +168,7 @@ test('networks/assets/providers constrain planner, router and spendguard', async
169
168
  });
170
169
  });
171
170
 
172
- test('real end-to-end run (no doubles, all flags off): honest no-route', async (t) => {
171
+ test('real end-to-end run (no doubles, all flags off): honest no-route', needsV2Tree, async (t) => {
173
172
  await withEnv(hermetic, async () => {
174
173
  const { result, exitCode } = await runIntent('summarize this for under 1 dollar', { maxBudget: 1 });
175
174
  t.assert.strictEqual(exitCode, 0);
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+ const { test } = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const { buildAuthMessage, actionRequestHash, readSignedAuthorization } = require('../core/payer-auth.js');
5
+ const { invokeRail, isRailCommand } = require('../core/rail.js');
6
+
7
+ const txHash = '0x' + '22'.repeat(32);
8
+ const wallet = '0x' + '11'.repeat(20);
9
+ const recipient = '0x' + '33'.repeat(20);
10
+ const signature = '0x' + '44'.repeat(65);
11
+ const authorization = () => ({
12
+ wallet, action: 'resolve', txHash, quote_id: 'q_test',
13
+ quote_inputs: { action: 'resolve', path: 'x402', issuedAt: 1 }, quoted_price_usd: '0.25',
14
+ request_hash: actionRequestHash('resolve', { evidence: '{}' }),
15
+ network: 'eip155:8453', recipient, nonce: 'nonce_unique_1234',
16
+ expiry: Math.floor(Date.now() / 1000) + 300, signature,
17
+ });
18
+
19
+ test('client and Rail use canonical EIP-191 signer message', () => {
20
+ const expected = [
21
+ 'Payload x402 Authorization',
22
+ 'wallet:' + wallet,
23
+ 'action:resolve',
24
+ 'txHash:' + txHash,
25
+ 'quote_id:q_test',
26
+ 'request_hash:' + authorization().request_hash,
27
+ 'network:eip155:8453',
28
+ 'recipient:' + recipient,
29
+ 'nonce:nonce_unique_1234',
30
+ 'expiry:' + authorization().expiry,
31
+ ].join('\n');
32
+ assert.equal(buildAuthMessage(authorization()), expected);
33
+ });
34
+
35
+ test('unsigned, malformed and expired authorizations are rejected before network payment', () => {
36
+ assert.throws(() => readSignedAuthorization('{}'), /wallet/);
37
+ const bad = { ...authorization(), expiry: Math.floor(Date.now()/1000) - 1 };
38
+ assert.throws(() => readSignedAuthorization(JSON.stringify(bad)), /expired/);
39
+ });
40
+
41
+ test('hosted CLI does not route local-only actions to paid Rail', () => {
42
+ assert.equal(isRailCommand('execute'), false);
43
+ assert.equal(isRailCommand('rescue'), false);
44
+ assert.equal(isRailCommand('resolve'), true);
45
+ });
46
+
47
+ test('signed noninteractive redemption sends signature, never wallet secrets', async () => {
48
+ const previous = global.fetch;
49
+ const requests = [];
50
+ global.fetch = async (url, init = {}) => {
51
+ requests.push({ url, init });
52
+ const quote = String(url).includes('/quote?');
53
+ const body = quote ? { quoted_price_usd: '0.25', quote_id: 'q_test', quote_inputs: { action: 'resolve', path: 'x402' } } :
54
+ { ok: true, invocation_id: 'invo_mock', via: 'x402', execution: { executed: true, result: { state: 'UNKNOWN' } } };
55
+ return { status: 200, text: async () => JSON.stringify(body) };
56
+ };
57
+ try {
58
+ const result = await invokeRail('resolve', {
59
+ evidence: '{}', txHash, payerAuth: JSON.stringify(authorization()),
60
+ }, { yes: true, json: true });
61
+ assert.equal(result.ok, true);
62
+ assert.equal(requests.length, 1);
63
+ const body = JSON.parse(requests[0].init.body);
64
+ assert.deepEqual(body.quote, authorization().quote_inputs);
65
+ assert.equal(body.txHash, txHash);
66
+ assert.equal(body.payer_auth.wallet, wallet);
67
+ assert.equal(body.payer_auth.signature, signature);
68
+ assert.equal(body.payer_auth.quote_id, 'q_test');
69
+ assert.equal('privateKey' in body, false);
70
+ } finally {
71
+ global.fetch = previous;
72
+ }
73
+ });
74
+
75
+ test('stale signature for a different quote fails without submitting a payment proof', async () => {
76
+ const previous = global.fetch;
77
+ const requests = [];
78
+ global.fetch = async (url, init = {}) => {
79
+ requests.push({ url, init });
80
+ return { status: 200, text: async () => JSON.stringify({ quoted_price_usd: '0.25', quote_id: 'q_changed', quote_inputs: { action: 'resolve', path: 'x402' } }) };
81
+ };
82
+ try {
83
+ const result = await invokeRail('resolve', {
84
+ txHash, payerAuth: JSON.stringify({ ...authorization(), action: 'recover' }),
85
+ }, { yes: true, json: true });
86
+ assert.equal(result.ok, false);
87
+ assert.match(result.error, /current quote/);
88
+ assert.equal(requests.length, 0);
89
+ } finally {
90
+ global.fetch = previous;
91
+ }
92
+ });
@@ -14,8 +14,7 @@ const { test } = require('node:test');
14
14
  const path = require('node:path');
15
15
  const { runAction, runIntent } = require('../core/index.js');
16
16
  const { createServer } = require('../server/index.js');
17
- const { scratchDir, cliEnv, runCLI, assertEnvelope, withEnv } = require('./helpers');
18
-
17
+ const { scratchDir, cliEnv, runCLI, assertEnvelope, withEnv, needsV2Tree } = require('./helpers');
19
18
  const scratch = scratchDir('remote');
20
19
 
21
20
  /** Start a real callx402 server on an ephemeral port. */
@@ -148,7 +147,7 @@ test('remote auth: server with token rejects missing/wrong bearer, accepts the r
148
147
  }
149
148
  });
150
149
 
151
- test('remote money-safety: settlement UNKNOWN envelope crosses the wire unchanged (exit 5)', async (t) => {
150
+ test('remote money-safety: settlement UNKNOWN envelope crosses the wire unchanged (exit 5)', needsV2Tree, async (t) => {
152
151
  // The server runs in-process, so the subsystem flag belongs to the parent
153
152
  // env; the CLI subprocess only carries the remote-mode config.
154
153
  await withEnv({ PAYLOAD_SETTLEMENT_RESOLVER: '1' }, async () => {
@@ -15,8 +15,7 @@
15
15
  const { test } = require('node:test');
16
16
  const path = require('node:path');
17
17
  const { runIntent, runAction } = require('../core/index.js');
18
- const { scratchDir, cliEnv, runCLI, assertEnvelope, withEnv } = require('./helpers');
19
-
18
+ const { scratchDir, cliEnv, runCLI, assertEnvelope, withEnv, needsV2Tree } = require('./helpers');
20
19
  const scratch = scratchDir('safety');
21
20
  const hermetic = {
22
21
  CALLX402_CONFIG: path.join(scratch, 'config.json'),
@@ -99,7 +98,7 @@ function walkPairs(o, prefix = '') {
99
98
  return out;
100
99
  }
101
100
 
102
- test('settlement UNKNOWN: exit 5, fail-closed, no retry/repay instruction', async (t) => {
101
+ test('settlement UNKNOWN: exit 5, fail-closed, no retry/repay instruction', needsV2Tree, async (t) => {
103
102
  const r = await runCLI(
104
103
  ['resolve', '--evidence', '{}', '--json'],
105
104
  cliEnv(scratch, { PAYLOAD_SETTLEMENT_RESOLVER: '1' })
@@ -164,7 +163,7 @@ test('unsafe retry refusal: stored UNKNOWN + --force-retry -> refused, exit 5',
164
163
  });
165
164
  });
166
165
 
167
- test('unsafe retry refusal via real CLI + file-backed store', async (t) => {
166
+ test('unsafe retry refusal via real CLI + file-backed store', needsV2Tree, async (t) => {
168
167
  const idemFile = path.join(scratch, 'idem-unsafe.jsonl');
169
168
  const env = cliEnv(scratch, { CALLX402_IDEMPOTENCY_FILE: idemFile });
170
169
  const seeded = {
@@ -222,7 +221,7 @@ test('timeout: slow subsystem + tiny timeoutMs -> timeout error, exit 1, no hang
222
221
  });
223
222
  });
224
223
 
225
- test('timeout via CLI --timeout: monitor --watch is cut off -> exit 1', async (t) => {
224
+ test('timeout via CLI --timeout: monitor --watch is cut off -> exit 1', needsV2Tree, async (t) => {
226
225
  // --watch streams forever; --timeout 200ms must win the race and exit 1.
227
226
  const r = await runCLI(
228
227
  ['monitor', '--watch', '--interval', '50', '--timeout', '200'],
package/test/sdk.test.js CHANGED
@@ -13,8 +13,7 @@
13
13
  const { test } = require('node:test');
14
14
  const path = require('node:path');
15
15
  const { execFile } = require('node:child_process');
16
- const { ENVELOPE_KEYS, scratchDir, withEnv } = require('./helpers');
17
-
16
+ const { ENVELOPE_KEYS, scratchDir, withEnv, needsV2Tree } = require('./helpers');
18
17
  const scratch = scratchDir('sdk');
19
18
  const hermetic = {
20
19
  CALLX402_CONFIG: path.join(scratch, 'config.json'),
@@ -45,7 +44,7 @@ test('JS SDK: callx402({intent}) returns a full envelope (in-process)', async (t
45
44
  });
46
45
  });
47
46
 
48
- test('JS SDK: version, status(), and sub-actions', async (t) => {
47
+ test('JS SDK: version, status(), and sub-actions', needsV2Tree, async (t) => {
49
48
  await withEnv(hermetic, async () => {
50
49
  t.assert.strictEqual(sdk.version, '1.0.0');
51
50
  t.assert.strictEqual(require('../sdk/js/index.js').callx402.version, '1.0.0');
@@ -74,7 +73,7 @@ test('JS SDK: invalid opts throw TypeError (fail fast, no dispatch)', async (t)
74
73
  });
75
74
  });
76
75
 
77
- test('JS SDK: action dispatch through runAction', async (t) => {
76
+ test('JS SDK: action dispatch through runAction', needsV2Tree, async (t) => {
78
77
  await withEnv(hermetic, async () => {
79
78
  const r = await sdk({ action: 'resolve', args: { evidence: '{}' } });
80
79
  // Settlement subsystem disabled without its flag: honest failure.
@@ -15,8 +15,7 @@ const { test } = require('node:test');
15
15
  const http = require('node:http');
16
16
  const path = require('node:path');
17
17
  const { createServer } = require('../server/index.js');
18
- const { ENVELOPE_KEYS, scratchDir, withEnv } = require('./helpers');
19
-
18
+ const { ENVELOPE_KEYS, scratchDir, withEnv, needsV2Tree } = require('./helpers');
20
19
  // The in-process server's core reads the parent env: keep the idempotency
21
20
  // store hermetic for the whole file (each test file is its own process).
22
21
  const serverScratch = scratchDir('server');
@@ -59,7 +58,7 @@ function assertEnvelopeShape(t, env, action) {
59
58
  if (action !== undefined) t.assert.strictEqual(env.action, action);
60
59
  }
61
60
 
62
- test('GET /health -> 200 with version and subsystem map', async (t) => {
61
+ test('GET /health -> 200 with version and subsystem map', needsV2Tree, async (t) => {
63
62
  const { server, port } = await startServer();
64
63
  try {
65
64
  const r = await request(port, 'GET', '/health');
@@ -134,7 +133,7 @@ test('unknown route -> 404', async (t) => {
134
133
  }
135
134
  });
136
135
 
137
- test('disabled subsystem over HTTP -> 503, honest envelope', async (t) => {
136
+ test('disabled subsystem over HTTP -> 503, honest envelope', needsV2Tree, async (t) => {
138
137
  const { server, port } = await startServer();
139
138
  try {
140
139
  const r = await request(port, 'POST', '/resolve', { body: { evidence: {} } });
@@ -147,7 +146,7 @@ test('disabled subsystem over HTTP -> 503, honest envelope', async (t) => {
147
146
  }
148
147
  });
149
148
 
150
- test('settlement UNKNOWN over HTTP -> 409 with fail-closed envelope', async (t) => {
149
+ test('settlement UNKNOWN over HTTP -> 409 with fail-closed envelope', needsV2Tree, async (t) => {
151
150
  await withEnv({ PAYLOAD_SETTLEMENT_RESOLVER: '1' }, async () => {
152
151
  const { server, port } = await startServer();
153
152
  try {
@@ -12,8 +12,7 @@ const { test } = require('node:test');
12
12
  const path = require('node:path');
13
13
  const { runAction } = require('../core/index.js');
14
14
  const { getSubsystem } = require('../core/subsystems.js');
15
- const { assertEnvelope, scratchDir, withEnv } = require('./helpers');
16
-
15
+ const { assertEnvelope, scratchDir, withEnv, needsV2Tree } = require('./helpers');
17
16
  const scratch = scratchDir('subsys');
18
17
  const hermetic = {
19
18
  CALLX402_CONFIG: path.join(scratch, 'config.json'),
@@ -26,7 +25,7 @@ function realModule(name) {
26
25
  return h.module;
27
26
  }
28
27
 
29
- test('rescue dispatch calls the real rescue module surface; no token -> auth_required exit 3', async (t) => {
28
+ test('rescue dispatch calls the real rescue module surface; no token -> auth_required exit 3', needsV2Tree, async (t) => {
30
29
  await withEnv(hermetic, async () => {
31
30
  const { result, exitCode } = await runAction('rescue', { incident: 'inc-9' }, { deps: { rescue: realModule('rescue') } });
32
31
  t.assert.strictEqual(exitCode, 3);
@@ -36,7 +35,7 @@ test('rescue dispatch calls the real rescue module surface; no token -> auth_req
36
35
  });
37
36
  });
38
37
 
39
- test('rescue dispatch with --auth runs real triage (read-only)', async (t) => {
38
+ test('rescue dispatch with --auth runs real triage (read-only)', needsV2Tree, async (t) => {
40
39
  await withEnv(hermetic, async () => {
41
40
  const { result, exitCode } = await runAction('rescue', { incident: 'inc-9', auth: true }, { deps: { rescue: realModule('rescue') } });
42
41
  t.assert.strictEqual(exitCode, 0);
@@ -47,7 +46,7 @@ test('rescue dispatch with --auth runs real triage (read-only)', async (t) => {
47
46
  });
48
47
  });
49
48
 
50
- test('doctor dispatch executes real runMcpDoctor and returns stages', async (t) => {
49
+ test('doctor dispatch executes real runMcpDoctor and returns stages', needsV2Tree, async (t) => {
51
50
  await withEnv(hermetic, async () => {
52
51
  const { result, exitCode } = await runAction('diagnose', {}, { deps: { doctor: realModule('doctor') } });
53
52
  t.assert.strictEqual(exitCode, 0);
@@ -62,7 +61,7 @@ test('doctor dispatch executes real runMcpDoctor and returns stages', async (t)
62
61
  });
63
62
  });
64
63
 
65
- test('router dispatch runs real selectTool/rankTools over candidates', async (t) => {
64
+ test('router dispatch runs real selectTool/rankTools over candidates', needsV2Tree, async (t) => {
66
65
  // The real smart-router asserts PAYLOAD_MCP_FABRIC=1 on rank/select.
67
66
  await withEnv({ ...hermetic, PAYLOAD_MCP_FABRIC: '1' }, async () => {
68
67
  const candidates = [
@@ -87,7 +86,7 @@ test('router dispatch runs real selectTool/rankTools over candidates', async (t)
87
86
  });
88
87
  });
89
88
 
90
- test('router dispatch honors the fast speed policy', async (t) => {
89
+ test('router dispatch honors the fast speed policy', needsV2Tree, async (t) => {
91
90
  await withEnv({ ...hermetic, PAYLOAD_MCP_FABRIC: '1' }, async () => {
92
91
  const candidates = [
93
92
  { tool: 'cheap-slow', price_usd: 0.2, expected_latency_ms: 900 },
@@ -102,7 +101,7 @@ test('router dispatch honors the fast speed policy', async (t) => {
102
101
  });
103
102
  });
104
103
 
105
- test('settlement dispatch resolves real states (DEFINITELY_PAID evidence)', async (t) => {
104
+ test('settlement dispatch resolves real states (DEFINITELY_PAID evidence)', needsV2Tree, async (t) => {
106
105
  await withEnv(hermetic, async () => {
107
106
  const settlement = realModule('settlement');
108
107
  const probe = settlement.resolve({ txHash: '0x' + 'ab'.repeat(32), confirmations: 12, expectedAmount: '1.00', actualAmount: '1.00', asset: 'USDC' });
@@ -0,0 +1,193 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * test/veyline-commands.test.js — ADDITIVE test for the Veyline Recovery
5
+ * track commands: evidence, explain, recover.
6
+ *
7
+ * Does not touch existing commands. Uses core.runAction directly with a
8
+ * temp --dir so no fixture pollutes the repo. Saves/restores env flags.
9
+ */
10
+ const { test } = require('node:test');
11
+ const assert = require('node:assert/strict');
12
+ const fs = require('node:fs');
13
+ const os = require('node:os');
14
+ const path = require('node:path');
15
+
16
+ const { runAction, ACTIONS } = require('../core/index');
17
+
18
+ const FLAGS = ['PAYLOAD_VEYLINE_LEDGER', 'PAYLOAD_VEYLINE_RECOVERY'];
19
+ let saved = {};
20
+
21
+ function envOn() {
22
+ saved = {};
23
+ for (const f of FLAGS) { saved[f] = process.env[f]; process.env[f] = '1'; }
24
+ }
25
+ function envOff() {
26
+ for (const f of FLAGS) {
27
+ if (saved[f] === undefined) delete process.env[f];
28
+ else process.env[f] = saved[f];
29
+ }
30
+ }
31
+
32
+ function tmpDir() {
33
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'callx402-veyline-'));
34
+ }
35
+
36
+ // Local-mode gate: the veyline tree is the optional advanced self-hosted
37
+ // runtime, not a requirement. On a clean machine (no tree) the tree-dependent
38
+ // tests skip — the CLI routes to the hosted rail by default instead.
39
+ const VEYLINE_ROOT = path.resolve(__dirname, '..', '..', 'veyline', 'engineering', 'recovery');
40
+ let treeAvailable = false;
41
+ try { require(path.join(VEYLINE_ROOT, 'operation-ledger.js')); treeAvailable = true; } catch { treeAvailable = false; }
42
+ const needsTree = { skip: !treeAvailable };
43
+
44
+ function seedLedger(dir) {
45
+ // Seed through the real veyline modules so recover() can hit the cache.
46
+ const root = path.resolve(__dirname, '..', '..', 'veyline', 'engineering', 'recovery');
47
+ const L = require(path.join(root, 'operation-ledger.js'));
48
+ const R = require(path.join(root, 'recovery-state.js'));
49
+ const ledger = L.createOperationLedger({ dir });
50
+ ledger.record({ operationId: 'op_cli1', kind: 'begin', protocol: 'x402', actor: 'client', summary: 'begins' });
51
+ ledger.record({ operationId: 'op_cli1', kind: 'payment', protocol: 'x402', actor: 'fac', summary: 'settled', states: { payment: 'SETTLED' } });
52
+ ledger.record({ operationId: 'op_cli1', kind: 'execution', protocol: 'MCP', actor: 'prov', summary: 'done', states: { execution: 'SUCCEEDED' } });
53
+ ledger.record({ operationId: 'op_cli1', kind: 'delivery', protocol: 'MCP', actor: 'prov', summary: 'lost', states: { delivery: 'LOST' } });
54
+ const cache = R.createFileBackedCache({ file: path.join(dir, 'recovery-cache.jsonl') });
55
+ const net = R.createRecoveryNetwork({ ledger, cache });
56
+ const stored = net.storeSettledResult({
57
+ toolName: 't', args: {}, payer: 'payer_1', paymentAuthFingerprint: 'fp1',
58
+ operationId: 'op_cli1', result: { ok: true }, settlementTx: '0x1',
59
+ });
60
+ return stored.identity;
61
+ }
62
+
63
+ test('ACTIONS includes the three new commands and no existing command was removed', () => {
64
+ for (const c of ['diagnose', 'rescue', 'route', 'resolve', 'doctor', 'execute', 'monitor', 'status', 'preflight', 'inspect',
65
+ 'evidence', 'explain', 'recover']) {
66
+ assert.ok(ACTIONS.includes(c), `ACTIONS missing ${c}`);
67
+ }
68
+ });
69
+
70
+ test('evidence: reports recorded events for a known operation', needsTree, async () => {
71
+ envOn();
72
+ const dir = tmpDir();
73
+ try {
74
+ seedLedger(dir);
75
+ const { result, exitCode } = await runAction('evidence', { operationId: 'op_cli1', dir }, {});
76
+ assert.equal(exitCode, 0);
77
+ assert.equal(result.ok, true);
78
+ assert.equal(result.action, 'evidence');
79
+ assert.equal(result.data.operationId, 'op_cli1');
80
+ assert.ok(result.data.eventCount >= 4);
81
+ assert.equal(result.data.states.payment, 'SETTLED');
82
+ } finally { envOff(); fs.rmSync(dir, { recursive: true, force: true }); }
83
+ });
84
+
85
+ test('evidence: unknown operation is honest no_basis, still exit 0', needsTree, async () => {
86
+ envOn();
87
+ const dir = tmpDir();
88
+ try {
89
+ const { result, exitCode } = await runAction('evidence', { operationId: 'op_nope', dir }, {});
90
+ assert.equal(exitCode, 0);
91
+ assert.equal(result.ok, true);
92
+ assert.match(result.data.basis, /no_basis/);
93
+ } finally { envOff(); fs.rmSync(dir, { recursive: true, force: true }); }
94
+ });
95
+
96
+ test('evidence: disabled flag -> exit 3, nothing executed', needsTree, async () => {
97
+ envOff();
98
+ const { result, exitCode } = await runAction('evidence', { operationId: 'x', dir: tmpDir() }, {});
99
+ assert.equal(exitCode, 3);
100
+ assert.equal(result.ok, false);
101
+ assert.equal(result.error.code, 'subsystem_disabled');
102
+ });
103
+
104
+ test('explain: plain-language assessment of an operation', needsTree, async () => {
105
+ envOn();
106
+ const dir = tmpDir();
107
+ try {
108
+ seedLedger(dir);
109
+ const { result, exitCode } = await runAction('explain', { operationId: 'op_cli1', dir }, {});
110
+ assert.equal(exitCode, 0);
111
+ assert.ok(result.disposition.includes('op_cli1'));
112
+ assert.ok(result.data.assessment.length > 0);
113
+ assert.ok(Array.isArray(result.data.trail));
114
+ } finally { envOff(); fs.rmSync(dir, { recursive: true, force: true }); }
115
+ });
116
+
117
+ test('explain: missing operationId -> usage error', needsTree, async () => {
118
+ envOn();
119
+ try {
120
+ const { result, exitCode } = await runAction('explain', {}, {});
121
+ assert.equal(exitCode, 2);
122
+ assert.equal(result.error.code, 'usage');
123
+ } finally { envOff(); }
124
+ });
125
+
126
+ test('recover: RECOVERABLE decision returned read-only with the prior result', needsTree, async () => {
127
+ envOn();
128
+ const dir = tmpDir();
129
+ try {
130
+ const identity = seedLedger(dir);
131
+ const { result, exitCode } = await runAction('recover', {
132
+ operationId: 'op_cli1', dir, identity,
133
+ evidence: JSON.stringify({ payer: 'payer_1', paymentAuthFingerprint: 'fp1' }),
134
+ }, {});
135
+ assert.equal(exitCode, 0);
136
+ assert.equal(result.data.decision.recoveryState, 'RECOVERABLE');
137
+ assert.equal(result.data.terminal, 'KNOWN_SAFE');
138
+ assert.deepEqual(result.data.decision.result, { ok: true });
139
+ assert.match(result.disposition, /Read-only/);
140
+ } finally { envOff(); fs.rmSync(dir, { recursive: true, force: true }); }
141
+ });
142
+
143
+ test('recover: builds the identity from evidence fields when --identity is absent', needsTree, async () => {
144
+ envOn();
145
+ const dir = tmpDir();
146
+ try {
147
+ seedLedger(dir); // seeds toolName 't', args {}, payer payer_1, fp1, no traceId
148
+ const { result, exitCode } = await runAction('recover', {
149
+ operationId: 'op_cli1', dir,
150
+ evidence: JSON.stringify({ toolName: 't', args: {}, payer: 'payer_1', paymentAuthFingerprint: 'fp1' }),
151
+ }, {});
152
+ assert.equal(exitCode, 0);
153
+ assert.equal(result.data.decision.recoveryState, 'RECOVERABLE');
154
+ } finally { envOff(); fs.rmSync(dir, { recursive: true, force: true }); }
155
+ });
156
+
157
+ test('recover: unknown identity with no evidence -> SETTLEMENT_UNKNOWN, never a repay', needsTree, async () => {
158
+ envOn();
159
+ const dir = tmpDir();
160
+ try {
161
+ const { result, exitCode } = await runAction('recover', {
162
+ operationId: 'op_cli1', dir, identity: 'opid_unknown',
163
+ evidence: JSON.stringify({ payer: 'payer_1', paymentAuthFingerprint: 'fp1' }),
164
+ }, {});
165
+ assert.equal(exitCode, 0);
166
+ assert.equal(result.data.decision.recoveryState, 'SETTLEMENT_UNKNOWN');
167
+ assert.equal(result.data.terminal, 'HUMAN_REVIEW');
168
+ assert.ok(result.data.decision.actionsForbidden.includes('blind_repay'));
169
+ } finally { envOff(); fs.rmSync(dir, { recursive: true, force: true }); }
170
+ });
171
+
172
+ test('recover: missing identity and insufficient evidence fields -> usage error', needsTree, async () => {
173
+ envOn();
174
+ try {
175
+ const { result, exitCode } = await runAction('recover', { operationId: 'op_cli1', dir: tmpDir() }, {});
176
+ assert.equal(exitCode, 2);
177
+ assert.equal(result.error.code, 'usage');
178
+ } finally { envOff(); }
179
+ });
180
+
181
+ test('recover: disabled flag -> exit 3', needsTree, async () => {
182
+ envOff();
183
+ const { result, exitCode } = await runAction('recover', { operationId: 'x', identity: 'y' }, {});
184
+ assert.equal(exitCode, 3);
185
+ assert.equal(result.error.code, 'subsystem_disabled');
186
+ });
187
+
188
+ test('existing commands still dispatch: unknown command lists the new commands too', async () => {
189
+ const { result, exitCode } = await runAction('frobnicate', {}, {});
190
+ assert.equal(exitCode, 2);
191
+ assert.ok(result.data.commands.includes('evidence'));
192
+ assert.ok(result.data.commands.includes('recover'));
193
+ });
package/mcp-publisher DELETED
Binary file