feat(builder)!: remove vector_get_address and vector_get_spend_limits, make query tools keyless, add custody boundary test

Finishes the builder-side wallet/tx family's non-custodial split (spec PR 6).
Task 4 registered the keyless build_* tools; this task deletes the last
custodial surface in vector.ts and makes every remaining query tool keyless:

- Deleted initLucid, getWalletInfo, the vector_get_address and
  vector_get_spend_limits tool registrations, and the now-unused safetyLayer
  and Lucid imports. SafetyLayer/safety.ts itself stays (still used by
  agent-network.ts and self-improvement.ts until PRs 7-8).
- vector_get_utxos, vector_get_transaction_history: address is now required,
  mnemonic param and branch deleted, provider construction routed through
  makeProvider().
- vector_dry_run: mnemonic param removed; the outputs-branch now builds via
  lucidForAddress + buildMultiOutput (keyless, UNSIGNED) instead of signing
  with a mnemonic-derived wallet.
- packages/shared/src/types.ts: removed the now-dead VectorToken,
  VectorWalletInfo, VectorAdaTransactionResult, VectorTokenTransactionResult,
  VectorBuildTransactionResult, VectorDeployContractResult and
  VectorInteractContractResult interfaces (and their re-exports from
  vector/index.ts). SpendLimits/SpendStatus/AuditEntry stay - safety.ts is
  still live for the two custodial families.
- vector_build_transaction / vector_build_deploy_contract /
  vector_build_interact_contract response copy now teaches the full
  build -> sign -> submit -> confirm flow (they previously stopped at
  submit), matching vector_build_send_apex/send_tokens.
- rate-limit-gating.test.ts: repointed its hermetic probe from the
  now-deleted vector_get_spend_limits to vector_build_send_apex with an
  invalid changeAddress - lucidForAddress rejects it in-process before any
  provider call, so the suite stays hermetic.
- Added packages/builder/test/boundary.test.ts: a mechanical grep-based
  custody boundary (design-spec success criterion 1). Verified failing before
  the rewrite (vector.ts's mnemonic vocabulary) and passing after; also
  verified the guard actually fails by injecting `// mnemonic` into build.ts
  and watching it name the exact file:line before reverting.
- Tool surface is now 23 (25 - 2 deletions). tool-inventory.test.ts updated
  accordingly.

Snapshot shape widened (this once, deliberately): tool-schemas.snapshot.json
entries are now { description, inputSchema } instead of a bare inputSchema,
so the regression guard also covers the security-teaching copy in tool
descriptions (the sign/submit/confirm flow callers learn from them), not just
JSON Schema shape. tool-inventory.test.ts's per-tool test now deep-compares
the whole entry and is relabeled "schema+description unchanged". This is why
the snapshot diff touches every entry, not just the 10 that actually changed.
Committed the regen script at packages/builder/scripts/regen-tool-schemas.mjs
(previously scratchpad-only) with this shape change applied.

Verified against the feat/builder-auth-and-submit baseline: the snapshot key
delta is exactly 4 renames (send_apex/send_tokens/deploy_contract/
interact_contract -> build_*), 4 changed-in-place (build_transaction,
dry_run, get_utxos, get_transaction_history), and 2 removals (get_address,
get_spend_limits) - confirmed with a structural JSON diff, since the uniform
shape-widening makes raw line-based git diff noisy (e.g. it shows
vector_message_agent's key line as touched even though its inputSchema is
byte-identical old vs new - a Myers-diff line-matching artifact, not real
agent-network.ts drift).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
FilipBl4gojevic
2026-07-29 12:54:40 +02:00
parent 6884fcc244
commit 6bbeb6ca7c
8 changed files with 1009 additions and 1143 deletions

View File

@@ -0,0 +1,64 @@
// packages/builder/scripts/regen-tool-schemas.mjs
// Boots the BUILT server, lists tools over SSE, rewrites the smoke snapshot
// sorted by name. Run from the repo root: npm run build && node packages/builder/scripts/regen-tool-schemas.mjs
//
// Deviation from the original brief version: the brief used a flat 2000ms
// sleep before connecting. On this machine that raced the server's actual
// startup (cold module load of lucid-evolution/libsodium takes longer),
// producing ECONNREFUSED. Swapped for the same "wait for the 'listening on
// port' stderr line" readiness signal that test/setup.ts's startServer()
// already uses elsewhere in this repo, with spawn error/exit handling so a
// real startup crash fails loudly instead of hanging.
//
// PR 6 (Task 5) change: each snapshot entry is now { description, inputSchema }
// instead of a bare inputSchema, so the fixture also guards the
// security-teaching copy in tool descriptions (the sign/submit/confirm flow
// callers learn from them), not just the JSON Schema shape. See the docblock
// above the schema-snapshot describe() in
// packages/builder/test/smoke/tool-inventory.test.ts for the full rationale.
import { spawn } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
const PORT = 3939;
const server = spawn('node', ['packages/builder/build/index.js'], {
env: { ...process.env, PORT: String(PORT) },
stdio: ['ignore', 'ignore', 'pipe'],
});
let stderrBuf = '';
server.stderr.on('data', (chunk) => { stderrBuf += chunk.toString(); });
try {
await new Promise((resolvePromise, reject) => {
const timeout = setTimeout(() => reject(new Error(`Server start timeout (20s). stderr so far:\n${stderrBuf}`)), 20_000);
const onData = () => {
if (stderrBuf.includes('listening on port')) {
clearTimeout(timeout);
server.stderr.off('data', onData);
resolvePromise();
}
};
server.stderr.on('data', onData);
server.on('error', (err) => { clearTimeout(timeout); reject(err); });
server.on('exit', (code) => { clearTimeout(timeout); reject(new Error(`Server exited with code ${code}. stderr so far:\n${stderrBuf}`)); });
});
const client = new Client({ name: 'regen', version: '0.0.0' });
await client.connect(new SSEClientTransport(new URL(`http://localhost:${PORT}/sse`)));
const { tools } = await client.listTools();
const snapshot = Object.fromEntries(
tools
.map((t) => [t.name, { description: t.description, inputSchema: t.inputSchema }])
.sort(([a], [b]) => a.localeCompare(b)),
);
writeFileSync(
'packages/builder/test/smoke/tool-schemas.snapshot.json',
JSON.stringify(snapshot, null, 2) + '\n',
);
console.log(`wrote ${tools.length} tool schemas`);
await client.close();
} finally {
server.kill();
}

View File

@@ -1,14 +1,7 @@
export { registerVectorTools } from './vector.js';
export type {
VectorWalletInfo,
VectorToken,
VectorAdaTransactionResult,
VectorTokenTransactionResult,
TxOutput,
VectorBuildTransactionResult,
VectorDryRunResult,
VectorTransactionSummary,
VectorTransactionHistoryResult,
VectorDeployContractResult,
VectorInteractContractResult,
} from '@apexfusion/vector-mcp-shared/types';

View File

@@ -1,6 +1,5 @@
import { z } from "zod/v4";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Lucid } from '@lucid-evolution/lucid';
import * as dotenv from 'dotenv';
import { resolve } from 'path';
@@ -11,14 +10,11 @@ import { lovelaceToAda, formatAssetName } from '@apexfusion/vector-mcp-shared/tx
import {
VECTOR_OGMIOS_URL, VECTOR_SUBMIT_URL, VECTOR_KOIOS_URL, VECTOR_EXPLORER_URL, explorerTxLink,
} from '@apexfusion/vector-mcp-shared/config';
import { safetyLayer } from './safety.js';
import { limiterFor } from './rate-limiter.js';
import { pollUntilConfirmed, buildConfirmationCheck } from './poll.js';
import { registerAgentNetworkTools } from './agent-network.js';
import { registerSelfImprovementTools } from './self-improvement.js';
import type {
VectorToken, VectorWalletInfo, VectorDryRunResult,
} from '@apexfusion/vector-mcp-shared/types';
import type { VectorDryRunResult } from '@apexfusion/vector-mcp-shared/types';
import {
lucidForAddress, buildSendApex, buildSendTokens, buildMultiOutput,
buildDeployContract, buildInteractContract,
@@ -38,96 +34,6 @@ if (existsSync(envPath)) {
}
}
// Initialize Lucid instance with Ogmios provider
async function initLucid(mnemonic: string, accountIndex: number = 0) {
const provider = new OgmiosProvider({
ogmiosUrl: VECTOR_OGMIOS_URL,
submitUrl: VECTOR_SUBMIT_URL,
koiosUrl: VECTOR_KOIOS_URL,
});
// Vector uses --mainnet flag, so addresses are addr1... format
const lucid = await Lucid(provider, 'Mainnet');
if (!mnemonic) {
throw new Error('mnemonic is required');
}
const trimmedMnemonic = mnemonic.trim();
const words = trimmedMnemonic.split(/\s+/);
const validLengths = [12, 15, 18, 21, 24];
if (!validLengths.includes(words.length)) {
throw new Error(`Invalid mnemonic: Expected 12, 15, 18, 21 or 24 words, got ${words.length}`);
}
lucid.selectWallet.fromSeed(trimmedMnemonic, { accountIndex });
const address = await lucid.wallet().address();
if (!address) {
throw new Error('Failed to derive address from mnemonic');
}
return lucid;
}
// Get wallet info
export async function getWalletInfo(mnemonic: string, accountIndex: number = 0): Promise<VectorWalletInfo> {
console.error('[getWalletInfo] Initializing Lucid...');
const lucid = await initLucid(mnemonic, accountIndex);
console.error('[getWalletInfo] Getting address...');
const address = await lucid.wallet().address();
console.error('[getWalletInfo] Querying UTxOs...');
const utxos = await lucid.utxosAt(address);
console.error(`[getWalletInfo] Found ${utxos.length} UTxOs`);
let adaBalance = '0';
let tokenBalances: VectorToken[] = [];
if (utxos.length > 0) {
// Aggregate all UTxO assets
const aggregated: Record<string, bigint> = {};
for (const utxo of utxos) {
for (const [unit, qty] of Object.entries(utxo.assets)) {
aggregated[unit] = (aggregated[unit] || 0n) + BigInt(qty);
}
}
adaBalance = aggregated['lovelace'] ? lovelaceToAda(aggregated['lovelace']) : '0';
for (const [unit, quantity] of Object.entries(aggregated)) {
if (unit === 'lovelace') continue;
try {
const policyId = unit.slice(0, 56);
const assetNameHex = unit.slice(56);
const displayName = assetNameHex
? formatAssetName(assetNameHex)
: `${policyId.substring(0, 8)}...`;
tokenBalances.push({
unit,
name: displayName,
quantity: quantity.toString(),
});
} catch {
tokenBalances.push({
unit,
name: unit,
quantity: quantity.toString(),
});
}
}
}
return {
address,
utxoCount: utxos.length,
ada: adaBalance,
tokens: tokenBalances,
};
}
// Shared provider for the keyless build_* tools (Task 5 reuses this).
function makeProvider(): OgmiosProvider {
return new OgmiosProvider({
@@ -214,91 +120,26 @@ ${tokens.length > 0 ? `Token Holdings (${tokens.length}):\n${tokenList}` : 'No t
}
);
// vector_get_address - Get the agent's wallet address and balance
server.tool(
"vector_get_address",
"Get the Vector wallet address, balance, and token holdings derived from a mnemonic",
{
mnemonic: z.string().describe("15 or 24-word BIP39 mnemonic for the wallet"),
},
async ({ mnemonic }) => {
const rateCheck = rateLimiter.check();
if (!rateCheck.allowed) {
return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] };
}
try {
const walletInfo = await getWalletInfo(mnemonic);
const tokenList = walletInfo.tokens.length > 0
? walletInfo.tokens.map((t: VectorToken) => `${t.quantity} ${t.name}`).join('\n')
: 'No tokens found';
return {
content: [{
type: "text",
text: `# Vector Wallet Information
Address: ${walletInfo.address}
AP3X Balance: ${walletInfo.ada} AP3X
UTXO Count: ${walletInfo.utxoCount}
${walletInfo.tokens.length > 0 ? `## Token Holdings (${walletInfo.tokens.length}):\n${tokenList}` : 'No token holdings found'}`,
}],
};
} catch (err) {
const error = err as Error;
return {
content: [{
type: "text",
text: `Failed to get wallet information: ${error.message}
**Troubleshooting Tips:**
1. Make sure you have a valid 15 or 24-word BIP39 mnemonic
2. Verify the Ogmios endpoint is reachable: ${VECTOR_OGMIOS_URL}
3. Check the console logs for detailed error information`,
}],
};
}
}
);
// vector_get_utxos - List UTxOs for an address or the wallet
// vector_get_utxos - List UTxOs for an address
server.tool(
"vector_get_utxos",
"List unspent transaction outputs (UTxOs) for a Vector address or a wallet derived from a mnemonic",
"List unspent transaction outputs (UTxOs) for a Vector address",
{
address: z.string().optional().describe("Vector address to query UTxOs for. If omitted, mnemonic is required."),
mnemonic: z.string().optional().describe("15 or 24-word BIP39 mnemonic (required if address is omitted)"),
address: z.string().describe("Vector address to query UTxOs for (addr1...)"),
},
async ({ address, mnemonic }) => {
async ({ address }) => {
const rateCheck = rateLimiter.check();
if (!rateCheck.allowed) {
return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] };
}
try {
let utxos;
let queryAddress: string;
if (address) {
const provider = new OgmiosProvider({
ogmiosUrl: VECTOR_OGMIOS_URL,
submitUrl: VECTOR_SUBMIT_URL,
koiosUrl: VECTOR_KOIOS_URL,
});
utxos = await provider.getUtxos(address);
queryAddress = address;
} else {
if (!mnemonic) throw new Error('Provide either address or mnemonic');
const lucid = await initLucid(mnemonic);
queryAddress = await lucid.wallet().address();
utxos = await lucid.utxosAt(queryAddress);
}
const utxos = await makeProvider().getUtxos(address);
if (utxos.length === 0) {
return {
content: [{
type: "text",
text: `No UTxOs found for ${queryAddress}`,
text: `No UTxOs found for ${address}`,
}],
};
}
@@ -312,7 +153,7 @@ ${walletInfo.tokens.length > 0 ? `## Token Holdings (${walletInfo.tokens.length}
return {
content: [{
type: "text",
text: `# UTxOs for ${queryAddress}
text: `# UTxOs for ${address}
Total: ${utxos.length} UTxO(s)
@@ -441,50 +282,6 @@ Non-custodial flow: 1) sign locally with vector_signer_sign { txCbor }; 2) broad
}
);
// vector_get_spend_limits - Check safety layer status
server.tool(
"vector_get_spend_limits",
"Check current spend limits and remaining daily budget",
{},
async () => {
const rateCheck = rateLimiter.check();
if (!rateCheck.allowed) {
return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] };
}
try {
const status = safetyLayer.getSpendStatus();
const log = safetyLayer.getAuditLog();
const recentTxs = log.slice(-5).reverse().map((entry) =>
`- ${entry.timestamp}: ${lovelaceToAda(entry.amountLovelace)} AP3X → ${entry.recipient.substring(0, 20)}... (${entry.txHash.substring(0, 16)}...)`
).join('\n');
return {
content: [{
type: "text",
text: `# Vector Spend Limits
Per-Transaction Limit: ${lovelaceToAda(status.perTransactionLimit)} AP3X
Daily Limit: ${lovelaceToAda(status.dailyLimit)} AP3X
Daily Spent: ${lovelaceToAda(status.dailySpent)} AP3X
Daily Remaining: ${lovelaceToAda(status.dailyRemaining)} AP3X
Resets At: ${status.resetTime}
${log.length > 0 ? `## Recent Transactions (last ${Math.min(5, log.length)}):\n${recentTxs}` : 'No transactions recorded yet.'}`,
}],
};
} catch (err) {
const error = err as Error;
return {
content: [{
type: "text",
text: `Failed to get spend limits: ${error.message}`,
}],
};
}
}
);
// vector_build_transaction - build an UNSIGNED multi-output transaction (keyless)
server.tool(
"vector_build_transaction",
@@ -522,7 +319,7 @@ ${log.length > 0 ? `## Recent Transactions (last ${Math.min(5, log.length)}):\n$
${result.txCbor}
\`\`\`
Use vector_dry_run with this CBOR to simulate it, sign it locally with vector_signer_sign, then broadcast with vector_submit_transaction.`,
Use vector_dry_run with this CBOR to simulate it, sign it locally with vector_signer_sign, broadcast with vector_submit_transaction, then confirm with vector_await_transaction { txHash }.`,
}],
};
} catch (err) {
@@ -544,18 +341,18 @@ Use vector_dry_run with this CBOR to simulate it, sign it locally with vector_si
// vector_dry_run - Simulate a transaction without submitting
server.tool(
"vector_dry_run",
"Simulate a transaction without submitting - returns fee estimate and validation result",
"Simulate a transaction without submitting - returns fee estimate and validation result. Accepts either built CBOR or outputs to build from (keyless).",
{
txCbor: z.string().optional().describe("Hex-encoded CBOR of a built transaction to evaluate"),
outputs: z.array(z.object({
address: z.string().describe("Recipient Vector address"),
lovelace: z.number().describe("Amount in lovelace"),
assets: z.record(z.string(), z.string()).optional(),
})).optional().describe("If no txCbor provided, build a TX from these outputs and evaluate it"),
mnemonic: z.string().optional().describe("15 or 24-word BIP39 mnemonic (required when outputs is provided)"),
})).optional().describe("If no txCbor provided, build an unsigned TX from these outputs and evaluate it"),
changeAddress: z.string().optional().describe("Your wallet address (required when building from outputs)"),
metadata: z.string().optional().describe("Optional JSON metadata when building from outputs"),
},
async ({ txCbor, outputs, mnemonic, metadata }) => {
async ({ txCbor, outputs, changeAddress, metadata }) => {
const rateCheck = rateLimiter.check();
if (!rateCheck.allowed) {
return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] };
@@ -565,42 +362,20 @@ Use vector_dry_run with this CBOR to simulate it, sign it locally with vector_si
let feeFromBuild: string | null = null;
if (!cborToEvaluate && outputs && outputs.length > 0) {
// Build the transaction first
if (!mnemonic) throw new Error('mnemonic is required when building from outputs');
const lucid = await initLucid(mnemonic);
let tx = lucid.newTx();
for (const output of outputs) {
const assets: Record<string, bigint> = { lovelace: BigInt(output.lovelace) };
if (output.assets) {
for (const [unit, qty] of Object.entries(output.assets)) {
assets[unit] = BigInt(qty);
}
}
tx = tx.pay.ToAddress(output.address, assets);
}
if (metadata) {
const parsedMetadata = typeof metadata === 'string' ? JSON.parse(metadata) : metadata;
tx = tx.attachMetadata(674, parsedMetadata);
}
const completedTx = await tx.complete();
const txJson = (completedTx as any).toJSON?.() ?? {};
feeFromBuild = String(txJson?.body?.fee ?? '0');
const signedTx = await completedTx.sign.withWallet().complete();
cborToEvaluate = signedTx.toCBOR();
if (!changeAddress) throw new Error('changeAddress is required when building from outputs');
const lucid = await lucidForAddress(makeProvider(), changeAddress);
const built = await buildMultiOutput(lucid, { outputs, metadataJson: metadata });
feeFromBuild = built.fee;
// Ogmios evaluates scripts against the UNSIGNED transaction -
// signatures are not required for evaluation.
cborToEvaluate = built.txCbor;
}
if (!cborToEvaluate) {
throw new Error('Provide either txCbor or outputs to evaluate');
}
const provider = new OgmiosProvider({
ogmiosUrl: VECTOR_OGMIOS_URL,
submitUrl: VECTOR_SUBMIT_URL,
koiosUrl: VECTOR_KOIOS_URL,
});
const provider = makeProvider();
let evalResult: VectorDryRunResult;
try {
@@ -626,7 +401,6 @@ Use vector_dry_run with this CBOR to simulate it, sign it locally with vector_si
executionUnits: (totalMemory > 0 || totalCpu > 0) ? { memory: totalMemory, cpu: totalCpu } : undefined,
};
} catch (evalErr) {
// evaluateTransaction failed - still return fee if we built the tx
if (feeFromBuild) {
evalResult = {
valid: true,
@@ -768,37 +542,25 @@ Transaction Hash: ${txHash}
"vector_get_transaction_history",
"Get transaction history for a Vector address via Koios indexed queries",
{
address: z.string().optional().describe("Vector address to query. If omitted, mnemonic is required."),
mnemonic: z.string().optional().describe("15 or 24-word BIP39 mnemonic (required if address is omitted)"),
address: z.string().describe("Vector address to query (addr1...)"),
limit: z.number().min(1).max(50).optional().describe("Number of transactions to return (default: 20, max: 50)"),
offset: z.number().min(0).optional().describe("Offset for pagination (default: 0)"),
},
async ({ address, mnemonic, limit, offset }) => {
async ({ address, limit, offset }) => {
const rateCheck = rateLimiter.check();
if (!rateCheck.allowed) {
return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] };
}
try {
let queryAddress = address;
if (!queryAddress) {
if (!mnemonic) throw new Error('Provide either address or mnemonic');
const lucid = await initLucid(mnemonic);
queryAddress = await lucid.wallet().address();
}
const provider = makeProvider();
const provider = new OgmiosProvider({
ogmiosUrl: VECTOR_OGMIOS_URL,
submitUrl: VECTOR_SUBMIT_URL,
koiosUrl: VECTOR_KOIOS_URL,
});
const txs = await provider.getTransactionHistory(queryAddress, offset || 0, limit || 20);
const txs = await provider.getTransactionHistory(address, offset || 0, limit || 20);
if (txs.length === 0) {
return {
content: [{
type: "text",
text: `No transactions found for ${queryAddress}`,
text: `No transactions found for ${address}`,
}],
};
}
@@ -811,13 +573,13 @@ Transaction Hash: ${txHash}
return {
content: [{
type: "text",
text: `# Transaction History for ${queryAddress}
text: `# Transaction History for ${address}
Showing ${txs.length} transaction(s) (offset: ${offset || 0}):
${txList}
[View on Explorer](${VECTOR_EXPLORER_URL}/address/${queryAddress})`,
[View on Explorer](${VECTOR_EXPLORER_URL}/address/${address})`,
}],
};
} catch (err) {
@@ -872,7 +634,7 @@ ${txList}
${result.txCbor}
\`\`\`
Sign locally with vector_signer_sign, broadcast with vector_submit_transaction, then use vector_build_interact_contract to interact with the deployed contract.`,
Sign locally with vector_signer_sign, broadcast with vector_submit_transaction, then confirm with vector_await_transaction { txHash }. Once confirmed, use vector_build_interact_contract to interact with the deployed contract.`,
}],
};
} catch (err) {
@@ -936,7 +698,7 @@ Sign locally with vector_signer_sign, broadcast with vector_submit_transaction,
${result.txCbor}
\`\`\`
Sign locally with vector_signer_sign { txCbor }, then broadcast with vector_submit_transaction { signedTxCbor }.`,
Sign locally with vector_signer_sign { txCbor }, then broadcast with vector_submit_transaction { signedTxCbor }, then confirm with vector_await_transaction { txHash }.`,
}],
};
} catch (err) {

View File

@@ -0,0 +1,54 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { resolve, join, relative } from 'node:path';
/**
* The builder-side custody guarantee, made mechanical (design-spec success
* criterion 1: "grep-able: zero mnemonic params in the builder").
*
* Files still on the custodial path are explicitly allowlisted with the PR
* that removes them. Shrinking this list is the definition of done for the
* non-custodial split; growing it should be impossible to do accidentally.
*/
const SRC_ROOT = resolve(import.meta.dirname!, '../src');
const CUSTODIAL_UNTIL_LATER_PR = new Set([
'vector/agent-network.ts', // family 2 — goes keyless in spec PR 7
'vector/self-improvement.ts', // family 3 — goes keyless in spec PR 8
]);
const FORBIDDEN = /\b(mnemonic|seed|bip39|fromSeed|walletFromSeed|selectWallet\.fromSeed)\b/i;
function sourceFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) out.push(...sourceFiles(full));
else if (/\.(ts|js|mjs|cjs|mts|cts)$/.test(entry)) out.push(full);
}
return out;
}
describe('builder custody boundary', () => {
test('no key-material vocabulary outside the allowlisted custodial files', () => {
const offenders: string[] = [];
for (const file of sourceFiles(SRC_ROOT)) {
const rel = relative(SRC_ROOT, file).replace(/\\/g, '/');
if (CUSTODIAL_UNTIL_LATER_PR.has(rel)) continue;
const lines = readFileSync(file, 'utf-8').split('\n');
lines.forEach((line, i) => {
if (FORBIDDEN.test(line)) offenders.push(`${rel}:${i + 1}: ${line.trim()}`);
});
}
assert.deepEqual(offenders, [], `key-material vocabulary found in keyless builder source:\n${offenders.join('\n')}`);
});
test('the allowlist matches reality (files removed from the custodial path must leave the list)', () => {
for (const rel of CUSTODIAL_UNTIL_LATER_PR) {
const content = readFileSync(join(SRC_ROOT, rel), 'utf-8');
assert.match(
content, FORBIDDEN,
`${rel} is allowlisted as custodial but contains no key-material vocabulary - remove it from CUSTODIAL_UNTIL_LATER_PR`,
);
}
});
});

View File

@@ -14,8 +14,14 @@ import type { RawServerHandle } from '../setup.ts';
// .superpowers/sdd/pr5-integration-tests-report.md for the mutation-testing
// evidence.
//
// Tool choice: vector_get_spend_limits (vector.ts) reads only in-process
// SafetyLayer state - confirmed by reading safety.ts, no network/wallet.
// Tool choice: vector_build_send_apex (vector.ts) with a deliberately invalid
// changeAddress. lucidForAddress (packages/builder/src/vector/build.ts)
// validates the address with getAddressDetails() and throws BEFORE any
// provider call - proven by Task 2's booby-trapped-provider test. Handlers
// return caught errors as normal content, so isError stays false and this
// probe is just as hermetic as the vector_get_spend_limits tool it replaces
// (which vector.ts no longer has - Task 5 deleted it, since SafetyLayer state
// is no longer exposed to keyless callers).
// vector_get_agent_profile (agent-network.ts) and
// vector_self_improvement_analyze_metrics (self-improvement.ts) DO reach
// Ogmios/Koios in their normal bodies, but only after their own
@@ -78,11 +84,13 @@ describe('per-identity rate limiting across real registered tools', () => {
});
test('alice exhausts her budget on a vector.ts tool; bob is unaffected (isolation via the real tools, not just limiterFor)', async () => {
const first = await call(alice, 'vector_get_spend_limits');
const PROBE_ARGS = { changeAddress: 'not-an-address', recipientAddress: 'not-an-address', amount: 1 };
const first = await call(alice, 'vector_build_send_apex', PROBE_ARGS);
assert.equal(first.isError, false, 'first call must not be a tool error');
assert.ok(!RATE_LIMIT_PATTERN.test(first.text), 'first call must not be rate-limited yet');
const second = await call(alice, 'vector_get_spend_limits');
const second = await call(alice, 'vector_build_send_apex', PROBE_ARGS);
assert.equal(second.isError, false, 'a rate-limit refusal is a normal, well-formed result - not a thrown tool error');
assert.match(second.text, RATE_LIMIT_PATTERN, 'alice must now be rate-limited');
@@ -91,7 +99,7 @@ describe('per-identity rate limiting across real registered tools', () => {
// inference from limiterFor() in isolation (rate-limiter.test.ts already
// covers that in isolation; this covers the wiring rate-limiter.test.ts
// cannot see).
const bobFirst = await call(bob, 'vector_get_spend_limits');
const bobFirst = await call(bob, 'vector_build_send_apex', PROBE_ARGS);
assert.equal(bobFirst.isError, false);
assert.ok(!RATE_LIMIT_PATTERN.test(bobFirst.text), "bob must be unaffected by alice's exhausted budget");
});

View File

@@ -5,13 +5,17 @@ import { resolve } from 'node:path';
import { startServer, stopServer, ServerContext } from '../setup.ts';
/**
* Snapshot of the pre-migration tool surface (23 tools), captured from a real
* `listTools()` call against a built server on the `ci/harness-and-gitignore`
* branch. This is the baseline for the non-custodial split series
* (see docs/architecture/non-custodial-split.md) — PRs 2, 6, 7 and 8
* deliberately restructure and rename tool families, and each of those PRs is
* expected to update this list deliberately, not accidentally. No wallet,
* no network beyond localhost — this never needs a mnemonic.
* Snapshot of the current tool surface (23 tools) for the non-custodial split
* series (see docs/architecture/non-custodial-split.md). PR 6 (Task 5) is the
* PR that took this list from 25 to 23: it deleted `vector_get_address` and
* `vector_get_spend_limits` outright (the safety-layer/wallet-info tools have
* no keyless equivalent — spend limits are gone because the builder no longer
* holds keys to limit) as part of making the remaining query tools
* (`vector_get_utxos`, `vector_get_transaction_history`, `vector_dry_run`)
* keyless. PRs 2, 6, 7 and 8 each deliberately restructure and rename tool
* families, and each of those PRs is expected to update this list
* deliberately, not accidentally. No wallet, no network beyond localhost —
* this never needs a mnemonic.
*/
const EXPECTED_TOOLS = [
'vector_await_transaction',
@@ -23,10 +27,8 @@ const EXPECTED_TOOLS = [
'vector_deregister_agent',
'vector_discover_agents',
'vector_dry_run',
'vector_get_address',
'vector_get_agent_profile',
'vector_get_balance',
'vector_get_spend_limits',
'vector_get_transaction_history',
'vector_get_utxos',
'vector_message_agent',
@@ -42,33 +44,40 @@ const EXPECTED_TOOLS = [
];
/**
* Full-schema snapshot: the exact `inputSchema` (JSON Schema) the live server
* advertises for every tool, keyed by tool name. This is a change detector,
* not a freeze — PRs 6, 7 and 8 deliberately restructure tool schemas as part
* of the non-custodial split, and each of those PRs is expected to
* regenerate this fixture to match its intentional changes (boot the built
* server, call listTools(), and dump { [name]: inputSchema } sorted by
* name — do not hand-edit the JSON).
* Full-schema snapshot: the exact `description` and `inputSchema` (JSON
* Schema) the live server advertises for every tool, keyed by tool name.
* This is a change detector, not a freeze — PRs 6, 7 and 8 deliberately
* restructure tool schemas as part of the non-custodial split, and each of
* those PRs is expected to regenerate this fixture to match its intentional
* changes: run `npm run build && node packages/builder/scripts/regen-tool-schemas.mjs`
* (boots the built server, calls listTools(), and dumps
* { [name]: { description, inputSchema } } sorted by name — do not hand-edit
* the JSON).
*
* What this guards against is an *unintended* wire-format change slipping
* through unnoticed: the name/count checks above only catch a tool being
* added, removed, or renamed — they say nothing about a tool's argument
* schema quietly changing shape. That happened once already: converting
* vector.ts's Zod import from "zod" to "zod/v4" (for the MCP SDK's Zod 4
* type inference) also switched which JSON Schema serializer the SDK uses
* for that file's tools — Zod v3 schemas go through the vendored
* zod-to-json-schema, Zod v4 schemas go through Zod's own native
* What this guards against is an *unintended* wire-format or copy change
* slipping through unnoticed: the name/count checks above only catch a tool
* being added, removed, or renamed — they say nothing about a tool's argument
* schema or description quietly changing shape. The inputSchema half happened
* once already: converting vector.ts's Zod import from "zod" to "zod/v4"
* (for the MCP SDK's Zod 4 type inference) also switched which JSON Schema
* serializer the SDK uses for that file's tools — Zod v3 schemas go through
* the vendored zod-to-json-schema, Zod v4 schemas go through Zod's own native
* toJSONSchema — and the two disagree on whether object schemas get
* `additionalProperties: false`. Nothing in the original name/count smoke
* test could see that; this fixture exists so the next silent schema drift
* fails loudly instead.
* test could see that. `description` was folded into this same fixture in
* PR 6 (Task 5) because several tool descriptions are the only place a
* caller learns the non-custodial build → sign → submit → confirm flow
* (vector_signer_sign / vector_submit_transaction / vector_await_transaction)
* — a silently edited or dropped sentence there is a real regression this
* fixture should catch too, not just a JSON Schema shape change.
*/
const SNAPSHOT_PATH = resolve(import.meta.dirname!, 'tool-schemas.snapshot.json');
const schemaSnapshot: Record<string, unknown> = JSON.parse(readFileSync(SNAPSHOT_PATH, 'utf-8'));
const schemaSnapshot: Record<string, { description?: string; inputSchema: unknown }> =
JSON.parse(readFileSync(SNAPSHOT_PATH, 'utf-8'));
let ctx: ServerContext;
let actualNames: string[];
let actualToolsByName: Map<string, { name: string; inputSchema: unknown }>;
let actualToolsByName: Map<string, { name: string; description?: string; inputSchema: unknown }>;
before(async () => {
ctx = await startServer();
@@ -82,11 +91,11 @@ after(async () => {
});
describe('tool inventory smoke test', () => {
test('exposes exactly 25 tools', () => {
test('exposes exactly 23 tools', () => {
assert.equal(
actualNames.length,
25,
`expected 25 tools, got ${actualNames.length}: ${JSON.stringify(actualNames)}`
23,
`expected 23 tools, got ${actualNames.length}: ${JSON.stringify(actualNames)}`
);
});
@@ -117,7 +126,7 @@ describe('tool schema snapshot', () => {
'test/smoke/tool-schemas.snapshot.json keys do not match the live tool set.\n' +
` live tools missing from snapshot: ${missingFromSnapshot.length ? JSON.stringify(missingFromSnapshot) : '(none)'}\n` +
` snapshot entries with no live tool: ${staleInSnapshot.length ? JSON.stringify(staleInSnapshot) : '(none)'}\n` +
'Regenerate the snapshot from a live built server if this drift is intentional.'
'Regenerate the snapshot from a live built server (packages/builder/scripts/regen-tool-schemas.mjs) if this drift is intentional.'
);
});
@@ -126,18 +135,19 @@ describe('tool schema snapshot', () => {
// diff of just that tool's schema, instead of "objects differ" over an
// unreadable combined structure.
for (const toolName of Object.keys(schemaSnapshot).sort()) {
test(`inputSchema unchanged: ${toolName}`, () => {
test(`schema+description unchanged: ${toolName}`, () => {
const live = actualToolsByName.get(toolName);
assert.ok(
live,
`Tool "${toolName}" is in the snapshot but was not returned by the live server's listTools().`
);
assert.deepStrictEqual(
live.inputSchema,
{ description: live.description, inputSchema: live.inputSchema },
schemaSnapshot[toolName],
`inputSchema for "${toolName}" differs from the checked-in snapshot in test/smoke/tool-schemas.snapshot.json.\n` +
'If this is a deliberate schema change (e.g. PRs 6-8 restructuring tool families), regenerate the snapshot ' +
'from a live built server. If not, this is the exact class of silent wire-format regression this test exists to catch.'
`description/inputSchema for "${toolName}" differs from the checked-in snapshot in test/smoke/tool-schemas.snapshot.json.\n` +
'If this is a deliberate schema or copy change (e.g. PRs 6-8 restructuring tool families), regenerate the snapshot ' +
'from a live built server (packages/builder/scripts/regen-tool-schemas.mjs). If not, this is the exact class of silent ' +
'wire-format/copy regression this test exists to catch.'
);
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,43 +1,5 @@
// Vector-specific type definitions
export interface VectorToken {
unit: string;
name: string;
quantity: string;
}
export interface VectorWalletInfo {
address: string;
utxoCount: number;
ada: string;
tokens: VectorToken[];
}
export interface VectorAdaTransactionResult {
txHash: string;
senderAddress: string;
recipientAddress: string;
amount: number;
links: {
explorer: string;
};
}
export interface VectorTokenTransactionResult {
txHash: string;
senderAddress: string;
recipientAddress: string;
token: {
policyId: string;
name: string;
amount: string;
};
ada: string;
links: {
explorer: string;
};
}
export interface SpendLimits {
perTransaction: number; // lovelace
daily: number; // lovelace
@@ -67,19 +29,6 @@ export interface TxOutput {
assets?: Record<string, string>; // unit -> quantity
}
export interface VectorBuildTransactionResult {
txCbor: string;
txHash: string;
fee: string;
feeAda: string;
outputCount: number;
totalAda: string;
submitted: boolean;
links?: {
explorer: string;
};
}
// --- Dry Run ---
export interface VectorDryRunResult {
@@ -108,29 +57,6 @@ export interface VectorTransactionHistoryResult {
total: number;
}
// --- Deploy Contract ---
export interface VectorDeployContractResult {
txHash: string;
scriptAddress: string;
scriptHash: string;
scriptType: string;
links: {
explorer: string;
};
}
// --- Interact Contract ---
export interface VectorInteractContractResult {
txHash: string;
scriptAddress: string;
action: 'spend' | 'lock';
links: {
explorer: string;
};
}
// --- Keyless build results (non-custodial split, family 1) ---
// Every build_* tool returns unsigned CBOR; signing happens on the local
// signer, submission via vector_submit_transaction.