@floatonnear/mcp 0.10.1 → 0.10.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.
package/SKILL.md CHANGED
@@ -30,8 +30,101 @@ the agent itself can change. Float gives it a **function-call key restricted to
30
30
  one method on one contract**, and puts the limits in a contract the key cannot
31
31
  reach.
32
32
 
33
- Read the next section before doing anything. It is the part that decides which
34
- of your requests can succeed.
33
+ **Read the two sections below before doing anything.** The first decides how you
34
+ speak to the person you are helping; the second decides which of your requests
35
+ can succeed at all.
36
+
37
+ ## How you talk to the human (non-negotiable)
38
+
39
+ **You are the product voice. The human is not debugging Float.** They asked for
40
+ something to be paid, or set up, or limited. Everything below exists because
41
+ the natural failure mode of a coding agent is to narrate the machine — rails,
42
+ gas keys, reason codes, env vars — to someone who wanted one sentence.
43
+
44
+ **Always:**
45
+
46
+ - **2–4 short sentences** for a normal outcome. Not a report.
47
+ - **Lead with the outcome**: paid / blocked / needs your approval / needs setup.
48
+ - **Say whether money moved.** Yes or no, explicitly. It is the one fact a
49
+ person cannot infer and must never have to guess.
50
+ - **One clear next step** when something is blocked — not a menu.
51
+ - Everyday words: limit, budget, payment, wallet, *approve in your wallet*.
52
+
53
+ **Never — unless they explicitly ask for technical detail, or are plainly a
54
+ developer debugging:**
55
+
56
+ - reason codes (`PARENT_PER_TX_LIMIT`, `COOLDOWN`, …)
57
+ - tool names (`vault_pay`, `vault_add_role`, `vault_plan_set_caps`)
58
+ - env vars, key files, keyrings, RPC urls, gas keys, nonces, wasm, yoctoNEAR,
59
+ base units, node ids
60
+ - a setup essay in the middle of a task
61
+ - several options when one sensible default exists
62
+
63
+ ### Use the tool's own words
64
+
65
+ Tools return the user-facing sentence already built, with the real numbers in
66
+ it:
67
+
68
+ ```json
69
+ {
70
+ "say": "Sent 0.25 USDC to test015.near. Money moved.",
71
+ "next_step": null,
72
+ "money_moved": true,
73
+ "technical": { "status": "SETTLED", "reason_code": null }
74
+ }
75
+ ```
76
+
77
+ **Relay `say`. Add `next_step` if it is not null. Stop.** Do not re-derive a
78
+ more technical story out of `technical` — those fields are for you, and for the
79
+ moment someone asks *why*.
80
+
81
+ `say` is built where the numbers live, which is why it can name the cap that
82
+ bound and what is left today. A sentence you compose from `status` and
83
+ `reason_code` alone cannot, because the payment response never carried them.
84
+
85
+ ### Templates
86
+
87
+ **Paid:**
88
+
89
+ > Sent **0.25 USDC** to test015.near. Money moved. About **24.75 USDC** left in
90
+ > today's budget.
91
+
92
+ **Blocked, over a limit:**
93
+
94
+ > That payment was **too big** — the most this budget allows per payment is
95
+ > **10 USDC**, and this asked for 15. **Nothing was sent.** Try 10 USDC or
96
+ > less, or raise the limit (I'll give you something to approve in your wallet).
97
+
98
+ **Needs a human signature:**
99
+
100
+ > I can't change limits myself — only you can. Here's the change to **approve
101
+ > in your wallet**. Tell me once you have and I'll confirm it's live.
102
+
103
+ **No vault yet:**
104
+
105
+ > You don't have a vault yet. Setting one up is **one approval in your wallet**
106
+ > and about **3.7 NEAR** — almost all of it a refundable deposit your vault
107
+ > holds, not a fee. Want me to start?
108
+
109
+ **Not enough money:**
110
+
111
+ > The vault doesn't have enough USDC for that. **Nothing was sent.** Add USDC to
112
+ > the vault and ask me again.
113
+
114
+ ### Worked examples, before and after
115
+
116
+ | situation | ❌ what not to say | ✅ what to say |
117
+ | --- | --- | --- |
118
+ | settled | `status: SETTLED, node_id: 1` | Sent 0.25 USDC to test015.near. Money moved. 24.75 USDC left today. |
119
+ | over cap | "BLOCKED with reason_code PARENT_PER_TX_LIMIT" | That's too big for one payment — the limit is 10 USDC and this asked for 15. Nothing was sent. |
120
+ | policy change | "Returns an unsigned payload; the server does not sign. Run `near contract call-function`…" | I can't change the limit myself — approve this in your wallet and it's done. |
121
+ | new agent | "Generated an ed25519 keypair, wrote it to the keyring; the 0.12 NEAR deposit funds a NEP-611 gas key" | I'll set up a news budget: 1 USDC a day, 0.10 max per payment. Approve once in your wallet and I can pay within those limits. |
122
+ | no vault | "`NEAR_VAULT_CONTRACT` is not set" | You don't have a vault yet — want me to set one up? |
123
+
124
+ A person who asked to pay a vendor and got `PARENT_PER_TX_LIMIT` has been told
125
+ nothing they can act on. The codes are precise, and that precision is a real
126
+ asset for debugging and for explaining the design. It is just never the first
127
+ thing a person wants.
35
128
 
36
129
  ## Setup — do this first if you arrived here cold
37
130
 
@@ -151,11 +244,49 @@ key on the machine, which is the thing above.
151
244
  Your key's `method_names` is `["request_payment"]` and nothing else.
152
245
  - attach NEAR to anything. A NEAR function-call key cannot carry a deposit.
153
246
  - **fund the vault.** That needs the owner's tokens and the owner's key. If the
154
- vault is empty, say so and ask the human — do not try to fix it yourself.
247
+ vault is empty, say so and ask the human — do not try to fix it yourself, and
248
+ send them to their WALLET, never to a CLI. See below.
155
249
 
156
250
  If a request needs one of those, produce a plan with `vault_plan_*` and hand it
157
251
  to the human. Do not attempt it and report the error.
158
252
 
253
+ ### Funding a vault — the wallet, always, never the CLI
254
+
255
+ Someone has to put tokens in. **Tell them to send from their own wallet**, name
256
+ the three fields, and stop:
257
+
258
+ > Open your wallet as `<owner>` and send `<amount>` `<TOKEN>` to `<vault>`.
259
+ > Check the token's CONTRACT ID, not just the ticker — `<contract>`.
260
+
261
+ **Do NOT hand over an `ft_transfer` command, and do not offer to run one.**
262
+ Measured 2026-11-13: asked to fund a vault, an agent found a full-access key in
263
+ `~/.near-credentials` and offered `near call … ft_transfer` as a co-equal option
264
+ beside the wallet. The user had to correct it. A token send is the one operation
265
+ every wallet already does well — a button, with the balance, the ticker and the
266
+ destination on screen before anything is signed. Replacing that with base-unit
267
+ arithmetic (`"amount":"1000000"`), a `--depositYocto 1` the user cannot
268
+ sanity-check, and a key read off disk is worse precisely when they are deciding
269
+ whether to trust this thing with money.
270
+
271
+ It also contradicts the rest of this file. Creation already refuses the CLI for
272
+ anyone being onboarded — *"never offer to run the CLI on someone else's
273
+ behalf"* — and funding is the same situation with the same answer. The wizard's
274
+ `--fund` flag belongs to an operator on their own machine with their own key; it
275
+ is not a funding path to offer a user.
276
+
277
+ Two things to say in the same message, because both fail SILENTLY:
278
+
279
+ - **the token contract, not the ticker.** Caps are keyed per token. NEAR has a
280
+ native USDC and an older bridged `USDC.e` — send the wrong one and it arrives,
281
+ shows as a balance, and the agent still has zero budget for it. The refusal
282
+ reads as a broken vault.
283
+ - **the payee allowlist.** Read it with `vault_tree` first. If it still holds
284
+ only the owner, the agent can pay nobody but the owner, and funding past a
285
+ test amount buys nothing yet.
286
+
287
+ Then verify instead of taking their word for it: read the vault's
288
+ `ft_balance_of` and tell them what actually landed.
289
+
159
290
  ## Daily use — the MCP tools
160
291
 
161
292
  If `float` is configured in your MCP client, you have these. Read before you
@@ -269,10 +400,13 @@ A refusal comes back as a **value**, not an error. `status: "BLOCKED"` with a
269
400
 
270
401
  ### Never show a reason code to the user
271
402
 
272
- `vault_pay` already returns a plain-English `say`, a `next_step`, and a
273
- `money_moved` flag. **Relay `say`, add `next_step` if there is one, and stop.**
274
- The `reason_code` lives under `technical`; quote it only if the user asks why,
275
- asks for detail, or is plainly a developer.
403
+ This is governed by **How you talk to the human** near the top of this file —
404
+ one source of truth, so the two cannot drift apart. The short form:
405
+
406
+ **`vault_pay` returns `say`, `next_step` and `money_moved`. Relay `say`, add
407
+ `next_step` if it is not null, and stop.** The `reason_code` lives under
408
+ `technical`; quote it only if the user asks why, asks for detail, or is plainly
409
+ a developer.
276
410
 
277
411
  > Say: *"That's too big for one payment — the most per payment is 10 USDC, and
278
412
  > this asked for 15. Nothing was sent. Send 10 USDC or less, or ask the vault
@@ -280,13 +414,9 @@ asks for detail, or is plainly a developer.
280
414
  >
281
415
  > Not: *"The payment was BLOCKED with reason_code PARENT_PER_TX_LIMIT."*
282
416
 
283
- The same applies when it works: *"Sent 0.25 USDC to alice.near. There's 24.75
284
- USDC left to spend today."* — not `status: SETTLED`.
285
-
286
- Someone who asked to pay a vendor and got `PARENT_PER_TX_LIMIT` has been told
287
- nothing they can act on. The codes are precise and that precision is a real
288
- asset for debugging and for explaining the design — it is just not what a
289
- person wants first.
417
+ **A refusal is still an answer, not a failure.** Report it as the budget doing
418
+ its job. And never imply money moved when `money_moved` is false — a blocked
419
+ payment moved nothing, which is the whole point.
290
420
 
291
421
  ### The codes themselves
292
422
 
@@ -50,6 +50,22 @@ export interface GeneratedAgentKey {
50
50
  export declare function generateAgentKey(opts: {
51
51
  vault: string;
52
52
  dir?: string;
53
+ /**
54
+ * The exact keyring file to write, overriding `dir` and the hardcoded
55
+ * `agent-keys.json` filename.
56
+ *
57
+ * WHY IT HAS TO EXIST. The caller configures a keyring by full PATH
58
+ * (`NEAR_VAULT_AGENT_KEYS`), and this function used to accept only a
59
+ * directory and append a fixed filename. So pinning the keyring to, say,
60
+ * `~/keys/fleet.json` meant the server READ `fleet.json` and WROTE
61
+ * `~/keys/agent-keys.json` — a brand new agent, filed where nothing would
62
+ * ever look for it, reported as success. Silent, and exactly the "written
63
+ * somewhere you are not reading" failure this module is supposed to prevent.
64
+ *
65
+ * Caught by the smoke test that pins a non-default filename, not by a unit
66
+ * test: every unit test here passes a directory.
67
+ */
68
+ keyringPath?: string;
53
69
  /**
54
70
  * The node this agent spends from.
55
71
  *
@@ -85,3 +101,37 @@ export declare function agentKeyPaths(dir?: string): {
85
101
  envPath: string;
86
102
  dir: string;
87
103
  };
104
+ /**
105
+ * Take a role name off a keyring entry that is PROVEN not bound on chain,
106
+ * without destroying its secret.
107
+ *
108
+ * WHY THIS IS NEEDED. `generateAgentKey` appends and never replaces, for the
109
+ * reason above: overwriting could destroy the only copy of a credential for an
110
+ * agent that is already bound and funded. But `selectRole` resolves a role with
111
+ * `entries.find(...)` — FIRST match wins. So an orphaned entry (a key written
112
+ * by a `vault_add_role` that then failed to land on chain) would permanently
113
+ * SHADOW the replacement: every future payment under that role would select a
114
+ * key the contract answers NOT_VIRTUAL_AGENT for, and the working key beside it
115
+ * would never be reached.
116
+ *
117
+ * So the orphan's role is RENAMED rather than deleted. The secret stays on
118
+ * disk, the entry stays in the file, and the name resolves to exactly one
119
+ * credential again. Deleting would be tidier and is not worth the risk: the
120
+ * caller's proof of deadness is a chain read, and a chain read can be wrong
121
+ * about a vault that is paused, migrating, or being served by a lagging RPC.
122
+ * A renamed entry survives that mistake; a deleted one does not.
123
+ *
124
+ * Identified BY PUBLIC KEY, never by role alone — the whole point is that two
125
+ * entries can share a role at this moment, and renaming the wrong one would
126
+ * retire the live agent.
127
+ *
128
+ * Returns the new role name, or null if no entry matched (already retired, or
129
+ * a keyring that moved under us) — which is not an error.
130
+ */
131
+ export declare function retireRole(opts: {
132
+ keyringPath: string;
133
+ vault: string;
134
+ publicKey: string;
135
+ }): {
136
+ retiredTo: string;
137
+ } | null;
package/dist/agent-key.js CHANGED
@@ -58,11 +58,13 @@ export const AGENT_NODE_ID = 1;
58
58
  * `NEAR_VAULT_AGENT_ID` is set to a vault id in `.mcp.json`.
59
59
  */
60
60
  export function generateAgentKey(opts) {
61
- const dir = opts.dir ?? ".float";
62
61
  // `undefined` -> root (the creation-time agent). `null` -> unknown, and the
63
62
  // field is omitted rather than guessed.
64
63
  const nodeId = opts.nodeId === null ? undefined : (opts.nodeId ?? AGENT_NODE_ID);
65
- const keyringPath = join(dir, "agent-keys.json");
64
+ // An explicit path wins and decides the directory; otherwise the old
65
+ // dir-plus-fixed-filename behaviour, unchanged.
66
+ const keyringPath = opts.keyringPath ?? join(opts.dir ?? ".float", "agent-keys.json");
67
+ const dir = dirname(keyringPath);
66
68
  const envPath = join(dir, "agent.env");
67
69
  const kp = KeyPair.fromRandom("ed25519");
68
70
  const publicKey = kp.getPublicKey().toString();
@@ -133,3 +135,67 @@ export function generateAgentKey(opts) {
133
135
  export function agentKeyPaths(dir = ".float") {
134
136
  return { keyringPath: join(dir, "agent-keys.json"), envPath: join(dir, "agent.env"), dir: dirname(join(dir, "x")) };
135
137
  }
138
+ /**
139
+ * Take a role name off a keyring entry that is PROVEN not bound on chain,
140
+ * without destroying its secret.
141
+ *
142
+ * WHY THIS IS NEEDED. `generateAgentKey` appends and never replaces, for the
143
+ * reason above: overwriting could destroy the only copy of a credential for an
144
+ * agent that is already bound and funded. But `selectRole` resolves a role with
145
+ * `entries.find(...)` — FIRST match wins. So an orphaned entry (a key written
146
+ * by a `vault_add_role` that then failed to land on chain) would permanently
147
+ * SHADOW the replacement: every future payment under that role would select a
148
+ * key the contract answers NOT_VIRTUAL_AGENT for, and the working key beside it
149
+ * would never be reached.
150
+ *
151
+ * So the orphan's role is RENAMED rather than deleted. The secret stays on
152
+ * disk, the entry stays in the file, and the name resolves to exactly one
153
+ * credential again. Deleting would be tidier and is not worth the risk: the
154
+ * caller's proof of deadness is a chain read, and a chain read can be wrong
155
+ * about a vault that is paused, migrating, or being served by a lagging RPC.
156
+ * A renamed entry survives that mistake; a deleted one does not.
157
+ *
158
+ * Identified BY PUBLIC KEY, never by role alone — the whole point is that two
159
+ * entries can share a role at this moment, and renaming the wrong one would
160
+ * retire the live agent.
161
+ *
162
+ * Returns the new role name, or null if no entry matched (already retired, or
163
+ * a keyring that moved under us) — which is not an error.
164
+ */
165
+ export function retireRole(opts) {
166
+ if (!existsSync(opts.keyringPath))
167
+ return null;
168
+ let entries;
169
+ try {
170
+ const parsed = JSON.parse(readFileSync(opts.keyringPath, "utf8"));
171
+ if (!Array.isArray(parsed))
172
+ return null;
173
+ entries = parsed;
174
+ }
175
+ catch {
176
+ // Unreadable is not this function's problem to fix; `generateAgentKey`
177
+ // already backs such a file aside before writing.
178
+ return null;
179
+ }
180
+ let retiredTo;
181
+ for (const e of entries) {
182
+ if (!e || typeof e !== "object")
183
+ continue;
184
+ if (e.agentId !== opts.vault)
185
+ continue;
186
+ if (e.publicKey !== opts.publicKey)
187
+ continue;
188
+ if (typeof e.role !== "string")
189
+ continue;
190
+ // A short slice of the public key keeps two retirements of the same role
191
+ // from colliding, and keeps the name traceable back to the exact entry.
192
+ retiredTo = `${e.role}-retired-${opts.publicKey.slice(8, 16)}`;
193
+ e.role = retiredTo;
194
+ // The node id, if any, was never real for an unbound key.
195
+ delete e.nodeId;
196
+ }
197
+ if (!retiredTo)
198
+ return null;
199
+ writeFileSync(opts.keyringPath, `${JSON.stringify(entries, null, 2)}${String.fromCharCode(10)}`);
200
+ return { retiredTo };
201
+ }
package/dist/create.d.ts CHANGED
@@ -62,6 +62,16 @@ export declare const STORAGE_COST_PER_BYTE: bigint;
62
62
  * copy that drifts.
63
63
  */
64
64
  export declare const PAGE_HEADROOM_YOCTO: bigint;
65
+ /**
66
+ * NEP-145 registration for ONE token, mirroring the factory's
67
+ * `TOKEN_REGISTRATION` and the vault's `DEPOSIT_ADDRESS_STORAGE`.
68
+ *
69
+ * The factory registers a new vault on every token named in `root_limits`, so
70
+ * a plain `ft_transfer` into it succeeds — before that it failed with "the
71
+ * account is not registered", which broke funding for every browser-created
72
+ * vault. Pinned against the contract by `create.pins.test.ts`.
73
+ */
74
+ export declare const TOKEN_REGISTRATION_YOCTO: bigint;
65
75
  /**
66
76
  * Funding for an agent's gas key, when one is bound at creation.
67
77
  *
package/dist/create.js CHANGED
@@ -62,6 +62,16 @@ export const STORAGE_COST_PER_BYTE = 10n ** 19n;
62
62
  * copy that drifts.
63
63
  */
64
64
  export const PAGE_HEADROOM_YOCTO = 36n * 10n ** 22n;
65
+ /**
66
+ * NEP-145 registration for ONE token, mirroring the factory's
67
+ * `TOKEN_REGISTRATION` and the vault's `DEPOSIT_ADDRESS_STORAGE`.
68
+ *
69
+ * The factory registers a new vault on every token named in `root_limits`, so
70
+ * a plain `ft_transfer` into it succeeds — before that it failed with "the
71
+ * account is not registered", which broke funding for every browser-created
72
+ * vault. Pinned against the contract by `create.pins.test.ts`.
73
+ */
74
+ export const TOKEN_REGISTRATION_YOCTO = 125n * 10n ** 19n;
65
75
  /**
66
76
  * Funding for an agent's gas key, when one is bound at creation.
67
77
  *
@@ -258,8 +268,20 @@ export function buildCreatePlan(i) {
258
268
  // Both floors move by the gas-key funding when an agent is bound: the page
259
269
  // attaches it, and the factory requires it.
260
270
  const agentFunding = i.agentPk ? AGENT_GAS_KEY_FUNDING_YOCTO : 0n;
271
+ /**
272
+ * The factory also registers the new vault on each token in `root_limits`,
273
+ * so its floor covers that too — otherwise it would fund those receipts from
274
+ * its OWN balance, which is the drain documented in the contract.
275
+ *
276
+ * Counted as ONE because `vault_create` builds a single-token `root_limits`.
277
+ * It does not change the quoted total today: the page's 0.36 headroom keeps
278
+ * `pageAttaches` above the floor either way, and `max` picks the page. It is
279
+ * included so the floor stays correct if an operator raises
280
+ * `required_deposit` and the floor becomes the binding term.
281
+ */
282
+ const registrations = TOKEN_REGISTRATION_YOCTO;
261
283
  const pageAttaches = stake + PAGE_HEADROOM_YOCTO + agentFunding;
262
- const factoryFloor = BigInt(i.requiredDepositYocto) + stake + agentFunding;
284
+ const factoryFloor = BigInt(i.requiredDepositYocto) + stake + agentFunding + registrations;
263
285
  const deposit = pageAttaches > factoryFloor ? pageAttaches : factoryFloor;
264
286
  const total = deposit + GAS_RESERVATION_YOCTO;
265
287
  // If the operator raises `required_deposit` past the page's headroom, the
package/dist/explain.d.ts CHANGED
@@ -57,3 +57,36 @@ export interface Explanation {
57
57
  moved: boolean;
58
58
  }
59
59
  export declare function explainOutcome(c: OutcomeContext): Explanation;
60
+ /**
61
+ * The user-facing sentence for an UNSIGNED owner payload.
62
+ *
63
+ * WHY PLAN TOOLS NEEDED THIS TOO. `vault_pay` returned `say`/`next_step`/
64
+ * `money_moved` and the plan tools returned `{action, summary, warnings,
65
+ * unsigned_payload, approve_with}` — five technical keys and no sentence. So
66
+ * agents narrated the mechanism: "returns an unsigned payload; the server does
67
+ * not sign; run `near contract call-function`…". All true, and unusable by the
68
+ * person who asked to raise a limit.
69
+ *
70
+ * THE THREE THINGS A PERSON NEEDS HERE, in order:
71
+ * 1. this needs YOU — the agent cannot do it
72
+ * 2. what it will do, in the units they named
73
+ * 3. nothing has happened yet
74
+ *
75
+ * (3) matters most and is the easiest to leave out. An unsigned payload is a
76
+ * *proposal*; an agent that says "I've raised the limit" has lied about state
77
+ * the user is about to be asked to change themselves.
78
+ *
79
+ * `summary` is reused verbatim rather than rewritten. It is built where the
80
+ * amounts and tokens are known, in the units the caller passed, and
81
+ * duplicating that formatting here is how the two drift into disagreeing about
82
+ * a number. It does still say "node 1" — the honest fix for that is better
83
+ * summaries in `payloads.ts`, not a second renderer guessing at them here.
84
+ */
85
+ export declare function planVoice(p: {
86
+ summary: string;
87
+ depositYocto?: string;
88
+ warnings?: string[];
89
+ }): {
90
+ say: string;
91
+ next: string;
92
+ };
package/dist/explain.js CHANGED
@@ -207,3 +207,58 @@ export function explainOutcome(c) {
207
207
  }
208
208
  }
209
209
  }
210
+ /**
211
+ * The user-facing sentence for an UNSIGNED owner payload.
212
+ *
213
+ * WHY PLAN TOOLS NEEDED THIS TOO. `vault_pay` returned `say`/`next_step`/
214
+ * `money_moved` and the plan tools returned `{action, summary, warnings,
215
+ * unsigned_payload, approve_with}` — five technical keys and no sentence. So
216
+ * agents narrated the mechanism: "returns an unsigned payload; the server does
217
+ * not sign; run `near contract call-function`…". All true, and unusable by the
218
+ * person who asked to raise a limit.
219
+ *
220
+ * THE THREE THINGS A PERSON NEEDS HERE, in order:
221
+ * 1. this needs YOU — the agent cannot do it
222
+ * 2. what it will do, in the units they named
223
+ * 3. nothing has happened yet
224
+ *
225
+ * (3) matters most and is the easiest to leave out. An unsigned payload is a
226
+ * *proposal*; an agent that says "I've raised the limit" has lied about state
227
+ * the user is about to be asked to change themselves.
228
+ *
229
+ * `summary` is reused verbatim rather than rewritten. It is built where the
230
+ * amounts and tokens are known, in the units the caller passed, and
231
+ * duplicating that formatting here is how the two drift into disagreeing about
232
+ * a number. It does still say "node 1" — the honest fix for that is better
233
+ * summaries in `payloads.ts`, not a second renderer guessing at them here.
234
+ */
235
+ export function planVoice(p) {
236
+ const bits = [];
237
+ // (1) and (2) together, because "needs you" is meaningless without "to do
238
+ // what". Leading with the actor is what stops the agent claiming the credit.
239
+ bits.push(`This one needs you — I can't make this change myself. ${p.summary}.`);
240
+ /**
241
+ * A DEPOSIT IS STATED, ALWAYS, WHEN THERE IS ONE.
242
+ *
243
+ * 1 yoctoNEAR is the NEP-141 anti-phishing deposit — a rounding error that
244
+ * no person should ever be told about. Anything larger is real money leaving
245
+ * their wallet, and a wallet prompt showing an unexplained NEAR figure is
246
+ * the fastest way to make a safe action look like a scam.
247
+ */
248
+ const dep = BigInt(p.depositYocto ?? "0");
249
+ if (dep > 1n) {
250
+ const near = (Number(dep) / 1e24).toFixed(3).replace(/\.?0+$/, "");
251
+ bits.push(`It sends ${near} NEAR with it, which the vault keeps — not a fee.`);
252
+ }
253
+ // (3). Said last so it is the thing left ringing.
254
+ bits.push("Nothing has changed yet.");
255
+ // `warnings` is non-empty only for destructive or hard-to-undo actions, so
256
+ // when one exists it outranks brevity.
257
+ const firstWarning = (p.warnings ?? []).find((w) => typeof w === "string" && !!w.trim());
258
+ if (firstWarning)
259
+ bits.push(firstWarning);
260
+ return {
261
+ say: bits.join(" "),
262
+ next: "Approve it in your wallet, then tell me and I'll confirm it went through.",
263
+ };
264
+ }
@@ -0,0 +1,36 @@
1
+ export declare const KEYRING_FILENAME: string;
2
+ export interface ResolvedKeyring {
3
+ /** Absolute path. Never relative — that is the whole point of this module. */
4
+ path: string;
5
+ /** Plain-English reason, safe to show a user. Names no secrets. */
6
+ why: string;
7
+ /**
8
+ * Other keyrings that ALSO hold a credential for this vault and are not
9
+ * being read. Empty in the normal case. Non-empty means a split keyring,
10
+ * which a user needs told rather than silently resolved.
11
+ */
12
+ alsoFound: string[];
13
+ }
14
+ /**
15
+ * How many credentials a keyring file holds for `vault`.
16
+ *
17
+ * Deliberately TOLERANT about the credential field name. `roles.ts` loads only
18
+ * `secret`, but `prove-refusal.mjs` reads `privateKey ?? secret ?? agentKey`,
19
+ * and a file written by one path must still be FOUND by this one — otherwise
20
+ * resolution sends the user to an empty home keyring while their real keys sit
21
+ * in a shape this function refused to count. Finding the file and loading an
22
+ * entry out of it are different jobs; `listUnloadable` reports the second.
23
+ *
24
+ * Never throws, and never returns the contents. A corrupt or unreadable file
25
+ * counts as zero; the caller is choosing a path, not validating a keyring.
26
+ */
27
+ export declare function countFor(path: string, vault?: string): number;
28
+ export declare function resolveKeyringPath(opts: {
29
+ /** The raw `NEAR_VAULT_AGENT_KEYS` value, if the operator set one. */
30
+ explicit?: string;
31
+ vault?: string;
32
+ /** Injectable so a test does not depend on where it was run from. */
33
+ cwd?: string;
34
+ home?: string;
35
+ count?: (path: string, vault?: string) => number;
36
+ }): ResolvedKeyring;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * WHERE THE FLEET KEYRING LIVES — and why it must not be a relative path.
3
+ *
4
+ * THE BUG THIS EXISTS TO KILL. The default was the bare string
5
+ * `.float/agent-keys.json`, which Node resolves against `process.cwd()`. An MCP
6
+ * server's CWD is wherever the CLIENT was launched, which the user does not
7
+ * choose and cannot see. So one single server entry in `~/.claude.json` became
8
+ * a DIFFERENT keyring per directory:
9
+ *
10
+ * launched from New project\ -> New project\.float\agent-keys.json
11
+ * launched from near-vault\ -> near-vault\.float\agent-keys.json
12
+ *
13
+ * Measured 2026-11-13 on this machine: `NEAR_VAULT_AGENT_KEYS` is UNSET for the
14
+ * `float` server, so a `travel` agent key written from one directory was
15
+ * invisible from the other. The tool reported "no agent with role 'travel'"
16
+ * while the key sat on disk a few folders away. Nothing in the output could
17
+ * distinguish "never created" from "created somewhere you are not looking",
18
+ * which is the same shape as the keyring-reader disagreement `roles.ts` already
19
+ * has a comment about.
20
+ *
21
+ * THE RULE. Resolution answers one question: *which file can sign for THIS
22
+ * vault?* Not "which directory am I in".
23
+ *
24
+ * 1. `NEAR_VAULT_AGENT_KEYS` set -> that, made absolute. Explicit wins; an
25
+ * operator who pinned a path gets the path they pinned.
26
+ * 2. Otherwise, of the CWD keyring and the HOME keyring, the one that already
27
+ * holds a credential for this vault. This is the back-compat case: anyone
28
+ * whose keys are in a project-local `.float/` keeps working untouched.
29
+ * 3. Otherwise HOME — `~/.float/agent-keys.json`. New keys land somewhere
30
+ * stable, so the NEXT session finds them whatever directory it starts in.
31
+ *
32
+ * Rule 3 is the actual fix. Rules 1 and 2 are there so turning it on cannot
33
+ * strand a key that already exists.
34
+ *
35
+ * WHEN BOTH HOLD KEYS it is a split keyring, and picking silently would hide
36
+ * one agent's roles behind another's. One is chosen so the tool still works,
37
+ * and the other is RETURNED in `alsoFound` so a caller can say so out loud.
38
+ */
39
+ import { existsSync, readFileSync } from "node:fs";
40
+ import { homedir } from "node:os";
41
+ import { isAbsolute, join, resolve } from "node:path";
42
+ export const KEYRING_FILENAME = join(".float", "agent-keys.json");
43
+ /**
44
+ * How many credentials a keyring file holds for `vault`.
45
+ *
46
+ * Deliberately TOLERANT about the credential field name. `roles.ts` loads only
47
+ * `secret`, but `prove-refusal.mjs` reads `privateKey ?? secret ?? agentKey`,
48
+ * and a file written by one path must still be FOUND by this one — otherwise
49
+ * resolution sends the user to an empty home keyring while their real keys sit
50
+ * in a shape this function refused to count. Finding the file and loading an
51
+ * entry out of it are different jobs; `listUnloadable` reports the second.
52
+ *
53
+ * Never throws, and never returns the contents. A corrupt or unreadable file
54
+ * counts as zero; the caller is choosing a path, not validating a keyring.
55
+ */
56
+ export function countFor(path, vault) {
57
+ if (!existsSync(path))
58
+ return 0;
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(readFileSync(path, "utf8"));
62
+ }
63
+ catch {
64
+ return 0;
65
+ }
66
+ if (!Array.isArray(parsed))
67
+ return 0;
68
+ return parsed.filter((e) => {
69
+ if (!e || typeof e !== "object")
70
+ return false;
71
+ const id = e.agentId ?? e.vault;
72
+ if (vault && id !== vault)
73
+ return false;
74
+ return ["secret", "privateKey", "agentKey"].some((k) => typeof e[k] === "string");
75
+ }).length;
76
+ }
77
+ export function resolveKeyringPath(opts) {
78
+ const cwd = opts.cwd ?? process.cwd();
79
+ const home = opts.home ?? homedir();
80
+ const count = opts.count ?? countFor;
81
+ if (opts.explicit) {
82
+ // Resolved even when already absolute, so the returned path is normalised
83
+ // and a relative override still cannot vary with CWD mid-session.
84
+ const path = isAbsolute(opts.explicit) ? opts.explicit : resolve(cwd, opts.explicit);
85
+ return { path, why: "NEAR_VAULT_AGENT_KEYS is set", alsoFound: [] };
86
+ }
87
+ const cwdRing = resolve(cwd, KEYRING_FILENAME);
88
+ const homeRing = join(home, KEYRING_FILENAME);
89
+ // Same directory: nothing to choose between, and no split to report.
90
+ if (cwdRing === homeRing) {
91
+ return { path: homeRing, why: "the default keyring in your home folder", alsoFound: [] };
92
+ }
93
+ const inCwd = count(cwdRing, opts.vault);
94
+ const inHome = count(homeRing, opts.vault);
95
+ if (inCwd > 0 && inHome === 0) {
96
+ return {
97
+ path: cwdRing,
98
+ why: "it already holds a key for this vault",
99
+ alsoFound: [],
100
+ };
101
+ }
102
+ if (inHome > 0 && inCwd > 0) {
103
+ return {
104
+ path: homeRing,
105
+ why: "it holds a key for this vault",
106
+ // The one NOT being read. Surfaced so "my agent vanished" is answerable.
107
+ alsoFound: [cwdRing],
108
+ };
109
+ }
110
+ return {
111
+ path: homeRing,
112
+ why: inHome > 0
113
+ ? "it already holds a key for this vault"
114
+ : "the default keyring in your home folder, so the next session finds it from any directory",
115
+ alsoFound: [],
116
+ };
117
+ }