Files
apex-mcp-server/packages/builder/test/smoke/tool-inventory.test.ts
FilipBl4gojevic 88068816e6 feat(builder)!: self-improvement family keyless - two-step proposal tools, @ts-nocheck dropped, safety.ts deleted, custody allowlist EMPTY
Rewrites self-improvement.ts (1024 -> 556 lines): typed, no @ts-nocheck,
imports build logic from gov-build.ts (Tasks 3-4). Splits the old single-shot
vector_self_improvement_submit_proposal into two keyless build tools
(proposal_lock, proposal_spend) since the deployed module validator requires
a confirmed lock before the spend+mint step can reference it; renames
critique/endorse to vector_build_self_improvement_*. browse and
analyze_metrics stay behavior-identical read-only tools (render strings
verified byte-for-byte against the custodial originals).

Deletes safety.ts (its last importer). Removes SpendLimits/SpendStatus/
AuditEntry from shared types (grep-verified zero references first). Drops
the now-dead spend-limit/audit-log env vars. Empties the custody boundary
allowlist with a size-0 pin - every builder family is now keyless.

Tool count 23 -> 24. Snapshot delta is surgical: 3 removed, 4 added, 19 of
the other 20 byte-identical (analyze_metrics' description had to drop the
literal word "mnemonic" to satisfy the custody grep - the one disclosed
exception).

Headline: grep -rni "mnemonic|seed|bip39|fromSeed" packages/builder/src
returns zero lines. This was the last custodial family.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:43:16 +02:00

172 lines
8.2 KiB
TypeScript

import { describe, test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { startServer, stopServer, ServerContext } from '../setup.ts';
/**
* Snapshot of the current tool surface (24 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`
* outright, along with the tool that reported per-transaction/daily
* SafetyLayer spend status (the safety-layer/wallet-info tools have no
* keyless equivalent — spend status is 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. Spec PR 8 (Task 5) took it from 23 to 24: the self-improvement
* family went keyless, removing `vector_self_improvement_submit_proposal`
* and renaming `_critique`/`_endorse` to `vector_build_self_improvement_*`,
* and splitting the old single-shot submit into two build_* tools
* (`_proposal_lock` + `_proposal_spend`) since the deployed module validator
* requires a confirmed lock before the spend+mint step can reference it.
* This was the last custodial family - the builder now holds no key material
* anywhere. 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',
'vector_build_deploy_contract',
'vector_build_deregister_agent',
'vector_build_interact_contract',
'vector_build_message_agent',
'vector_build_register_agent',
'vector_build_self_improvement_critique',
'vector_build_self_improvement_endorse',
'vector_build_self_improvement_proposal_lock',
'vector_build_self_improvement_proposal_spend',
'vector_build_send_apex',
'vector_build_send_tokens',
'vector_build_transaction',
'vector_build_transfer_agent',
'vector_build_update_agent',
'vector_discover_agents',
'vector_dry_run',
'vector_get_agent_profile',
'vector_get_balance',
'vector_get_transaction_history',
'vector_get_utxos',
'vector_self_improvement_analyze_metrics',
'vector_self_improvement_browse',
'vector_submit_transaction',
];
/**
* 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 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. `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, { description?: string; inputSchema: unknown }> =
JSON.parse(readFileSync(SNAPSHOT_PATH, 'utf-8'));
let ctx: ServerContext;
let actualNames: string[];
let actualToolsByName: Map<string, { name: string; description?: string; inputSchema: unknown }>;
before(async () => {
ctx = await startServer();
const result = await ctx.client.listTools();
actualNames = result.tools.map((t) => t.name).sort();
actualToolsByName = new Map(result.tools.map((t) => [t.name, t]));
});
after(async () => {
await stopServer(ctx);
});
describe('tool inventory smoke test', () => {
test('exposes exactly 24 tools', () => {
assert.equal(
actualNames.length,
24,
`expected 24 tools, got ${actualNames.length}: ${JSON.stringify(actualNames)}`
);
});
test('tool names match the checked-in inventory exactly', () => {
const added = actualNames.filter((name) => !EXPECTED_TOOLS.includes(name));
const removed = EXPECTED_TOOLS.filter((name) => !actualNames.includes(name));
assert.deepStrictEqual(
actualNames,
EXPECTED_TOOLS,
'Tool inventory drifted from the checked-in snapshot in test/smoke/tool-inventory.test.ts.\n' +
` added: ${added.length ? JSON.stringify(added) : '(none)'}\n` +
` removed: ${removed.length ? JSON.stringify(removed) : '(none)'}\n` +
'If this drift is intentional, update EXPECTED_TOOLS in this file to match.'
);
});
});
describe('tool schema snapshot', () => {
test('snapshot fixture keys match the live tool set exactly', () => {
const snapshotNames = Object.keys(schemaSnapshot).sort();
const missingFromSnapshot = actualNames.filter((name) => !snapshotNames.includes(name));
const staleInSnapshot = snapshotNames.filter((name) => !actualNames.includes(name));
assert.deepStrictEqual(
snapshotNames,
actualNames,
'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 (packages/builder/scripts/regen-tool-schemas.mjs) if this drift is intentional.'
);
});
// One assertion per tool (rather than a single deepStrictEqual over the whole
// 24-tool map) so a failure names the specific tool and shows a readable
// 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(`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().`
);
// Named separately from the deepStrictEqual below: a regen that
// silently drops descriptions (e.g. JSON.stringify dropping an
// `undefined` description key) should fail by naming exactly that,
// not just show up as an opaque diff.
assert.ok(
'description' in (schemaSnapshot[toolName] as any),
`Snapshot entry for "${toolName}" has no "description" key in test/smoke/tool-schemas.snapshot.json.`
);
assert.deepStrictEqual(
{ description: live.description, inputSchema: live.inputSchema },
schemaSnapshot[toolName],
`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.'
);
});
}
});