diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c8f520e5..e7410741 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -149,9 +149,24 @@ Kimi nibblets use the host's own Kimi Code CLI login, like OpenCode ones. The ho | `heartbeat_set` / `heartbeat_remove` / `heartbeat_list` | The nibblet's own heartbeats: as many as it needs, each named, with its own interval, active hours and checklist. The UI shows them but doesn't edit them | | `spawn_subagent` | Starts a child nibblet on a task; its final answer comes back as a `subagent` turn | | `subagent_status` | Lists children and their status | +| `card_request` | A virtual card or funded wallet within its spending limit, single or multi use. The details go to a file in its container, not into the result | +| `card_list` | Its cards, what's been spent on each, where each open card's file is, and what's left of its limits | +| `card_kill` | Kills one of its cards for good; a wallet's balance goes back to the treasury | + +The card tools are only offered when a bank is set up. See below. A turn the user didn't start (a heartbeat, a schedule, a sub-agent's result, carrying on after a restart) whose reply is `NO_REPLY` is stored with `quiet: true` and collapsed in the UI. The reply itself is never shown, not even while it streams in. +## Bank: cards and wallets + +A nibblet can be given money to spend, as virtual cards or as crypto wallets that work like them. Where the money comes from is a bank plugin (`packages/bank-*`) implementing `BankPlugin` from `@nibblet/bank`: it issues a card with a lifetime limit, gives its details again (`reveal`), reports what's been spent on it (`inspect`), and closes it (`close`). A plugin turns itself on from the server's environment (`apps/server/src/banks.ts` lists them), and holds its provider's credentials, which never leave the server. Settings shows each plugin, its balance, and any whose settings are wrong. + +- **Limits:** the person sets each nibblet's `spendingLimits`, one per asset (`USD`, `BTC`, `ETH`), per day, week or month (UTC) or in total. A nibblet has none to begin with, so it can't have a card until it's given one, and it can't change its own. Every card counts against its period for its whole limit while it's open, since it could all still be spent, and for what was spent once it's closed. One request or kill at a time per nibblet, so two at once can't both fit the same budget. +- **The card file:** `card_request` asks the plugin for a card, stores its record (`cards`: never the number or key), and sends its details over the runner socket (`card_put`). The runner writes them to `$TMPDIR/nibblet-cards/.json`, `0600` in a `0700` folder, as the agent's user, and answers `card_done` with the path, which is all the tool returns. So a card's number reaches the model only if it reads the file, and never the thread, the database or a snapshot. A card that can't be written is closed again. The folder goes with the container; when a runner reconnects, the server asks the plugins for its open cards again and rewrites them. +- **Card states:** a card's row goes in as `pending` before the plugin is asked for it, so one the provider made before something failed (a timeout after it was sent) is never lost: every minute the server asks the plugin to `find` pending cards it isn't still making, closes any that were made, and forgets ones that haven't shown up in an hour. A plugin throws a `BankError` only for a refusal, where nothing was made; its message is the only kind passed on, since other errors can carry credentials (an RPC URL with its key), so they're logged on the server instead. +- **Closing:** `card_kill` (the nibblet, its own cards), Kill in the UI (the person, any), and deleting a nibblet close cards at the provider and delete their files. A card the provider can't settle yet (a wallet whose funding isn't seen, so there's nothing to sweep) stays `closing`, counting in full, and the server tries again every minute; after a day it's recorded as closed with a warning in the log. A nibblet with a card that can't be closed isn't deleted, so the card's record (and a wallet's id, from which its key comes) isn't lost. Every minute the server also checks open cards with their providers: it records what's been spent, notices cards the provider closed, and closes single-use cards that have been used where the provider doesn't do that itself. +- **Wallets:** a crypto plugin funds a fresh wallet per card from its treasury with exactly the card's limit, so the limit holds on chain. The wallet's key is derived from the treasury's secret and the card's id (`deriveWalletKey`), so none is stored, and killing the card sweeps what's left back to the treasury. + ## API The typed contract is `contract` in `packages/protocol/src/contract.ts`. Calls are `POST /api/rpc/`. Live updates are a WebSocket at `/api/live` pushing `LiveMessage`s. Browsers authenticate with `Authorization: Bearer `; WebSockets take `?ticket=` from `POST /api/ticket`, which lasts 30 seconds, so long-lived tokens never go in URLs. diff --git a/apps/runner/src/cards.test.ts b/apps/runner/src/cards.test.ts new file mode 100644 index 00000000..2f7d806a --- /dev/null +++ b/apps/runner/src/cards.test.ts @@ -0,0 +1,64 @@ +import type { CardFile } from "@nibblet/protocol"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { CardFiles } from "./cards.ts"; + +const file: CardFile = { + id: "card_abc-1", + provider: "fake", + asset: "USD", + kind: "multi_use", + limit: "25.00", + purpose: "Domain renewal", + details: { + type: "card", + pan: "4242424242424242", + cvv: "123", + expMonth: "01", + expYear: "2031", + billingAddress: null, + }, +}; + +describe("CardFiles", () => { + let root: string; + let cards: CardFiles; + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "cards-test-")); + cards = new CardFiles(join(root, "nibblet-cards")); + }); + afterEach(() => rm(root, { recursive: true, force: true })); + + it("writes the card where only its user can read it", async () => { + const path = await cards.put(file); + expect(path).toBe(join(root, "nibblet-cards", "card_abc-1.json")); + expect(JSON.parse(await readFile(path, "utf8"))).toEqual(file); + expect((await stat(path)).mode & 0o777).toBe(0o600); + expect((await stat(join(root, "nibblet-cards"))).mode & 0o777).toBe(0o700); + }); + + it("replaces a card's file, and removes it (twice is fine)", async () => { + await cards.put(file); + const path = await cards.put({ ...file, limit: "30.00" }); + expect(JSON.parse(await readFile(path, "utf8")).limit).toBe("30.00"); + await cards.remove(file.id); + await cards.remove(file.id); + await expect(stat(path)).rejects.toThrow(); + }); + + it("removes a card asked to go while it's still being written", async () => { + // The server can time out on a put and kill the card before the put lands. + const writing = cards.put(file); + await cards.remove(file.id); + await writing; + await expect(stat(cards.pathOf(file.id))).rejects.toThrow(); + await expect(stat(`${cards.pathOf(file.id)}.partial`)).rejects.toThrow(); + }); + + it("refuses an id that could leave its folder", async () => { + await expect(cards.put({ ...file, id: "../escape" })).rejects.toThrow(/isn't a card id/); + await expect(cards.remove("a/b")).rejects.toThrow(/isn't a card id/); + }); +}); diff --git a/apps/runner/src/cards.ts b/apps/runner/src/cards.ts new file mode 100644 index 00000000..49356058 --- /dev/null +++ b/apps/runner/src/cards.ts @@ -0,0 +1,62 @@ +import type { CardFile } from "@nibblet/protocol"; +import { chmod, mkdir, rename, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +const SAFE_ID = /^[A-Za-z0-9_-]{1,80}$/; + +/** + * The cards the server hands the agent, one JSON file each, readable only by + * the agent's user. They live outside the home folder, so they're never in a + * snapshot, and go with the container; the server writes them again when it + * reconnects. The card's number goes nowhere else in the container. + */ +export class CardFiles { + private readonly dir: string; + /** + * Each card's writes and removals, one at a time, so a removal sent while + * a put is still landing (a server that timed out on it) can't run first. + */ + private readonly queues = new Map>(); + + constructor(dir: string) { + this.dir = dir; + } + + /** Writes the card's file (whole, or not at all) and says where it is. */ + async put(file: CardFile): Promise { + const path = this.pathOf(file.id); + return this.serially(file.id, async () => { + await mkdir(this.dir, { recursive: true, mode: 0o700 }); + // A folder left by another umask would otherwise keep its mode. + await chmod(this.dir, 0o700); + const partial = `${path}.partial`; + await writeFile(partial, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 }); + await rename(partial, path); + return path; + }); + } + + async remove(cardId: string): Promise { + const path = this.pathOf(cardId); + return this.serially(cardId, async () => { + await rm(path, { force: true }); + await rm(`${path}.partial`, { force: true }); + }); + } + + private serially(cardId: string, work: () => Promise): Promise { + const next = (this.queues.get(cardId) ?? Promise.resolve()).then(work, work); + const settled = next.catch(() => undefined); + this.queues.set(cardId, settled); + // Forget the card once nothing's waiting on it. + void settled.then(() => { + if (this.queues.get(cardId) === settled) this.queues.delete(cardId); + }); + return next; + } + + pathOf(cardId: string): string { + if (!SAFE_ID.test(cardId)) throw new Error(`"${cardId}" isn't a card id`); + return join(this.dir, `${cardId}.json`); + } +} diff --git a/apps/runner/src/main.ts b/apps/runner/src/main.ts index 1e32e660..90748c3e 100644 --- a/apps/runner/src/main.ts +++ b/apps/runner/src/main.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import type { McpServerSpec } from "@nibblet/harness"; import { GATE_PORT, RunnerEnv } from "@nibblet/protocol"; import { DesktopApps } from "./apps.ts"; +import { CardFiles } from "./cards.ts"; import type { Computer } from "./computer.ts"; import { Connection } from "./connection.ts"; import { DESKTOP_VIEW_URL, joinDesktop, startDesktop } from "./desktop.ts"; @@ -86,6 +87,18 @@ const desktopReady = env.NIBBLET_DESKTOP_TOKEN ) : Promise.resolve(); +// Cards the server gives the agent to spend with; see CardFiles. +const cards = new CardFiles(join(tmpdir(), "nibblet-cards")); +async function handleCard(requestId: string, work: () => Promise): Promise { + try { + const path = await work(); + connection?.send({ type: "card_done", requestId, path, error: null }); + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + connection?.send({ type: "card_done", requestId, path: null, error }); + } +} + const terminals = new Terminals({ workdir, send: (msg) => connection?.send(msg), @@ -142,6 +155,15 @@ connection = new Connection({ case "apps_scan": scanApps(); return; + case "card_put": + void handleCard(msg.requestId, () => cards.put(msg.file)); + return; + case "card_remove": + void handleCard(msg.requestId, async () => { + await cards.remove(msg.cardId); + return null; + }); + return; default: runner.handle(msg); } diff --git a/apps/server/package.json b/apps/server/package.json index 4bd92fd5..1d853cad 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -13,6 +13,7 @@ "@hono/node-server": "^2.1.5", "@huggingface/transformers": "^4.3.1", "@modelcontextprotocol/sdk": "^1.32.1", + "@nibblet/bank": "workspace:*", "@nibblet/db": "workspace:*", "@nibblet/protocol": "workspace:*", "croner": "^10.0.1", diff --git a/apps/server/src/bank.test.ts b/apps/server/src/bank.test.ts new file mode 100644 index 00000000..c3d3cbbe --- /dev/null +++ b/apps/server/src/bank.test.ts @@ -0,0 +1,466 @@ +import { + BankError, + defineBankPlugin, + type BankPlugin, + type CardRef, + type IssueRequest, +} from "@nibblet/bank"; +import { + defaultHamsterLook, + RpcError, + type CardFile, + type LiveMessage, + type Nibblet, + type SpendingLimit, +} from "@nibblet/protocol"; +import { beforeEach, describe, expect, it } from "vitest"; +import { Bank, type BankStore } from "./bank.ts"; +import type { CardRow } from "./store.ts"; + +function nibbletWith(id: string, spendingLimits: SpendingLimit[]): Nibblet { + return { + id, + name: "Pip", + promptName: null, + plain: false, + look: defaultHamsterLook, + harness: "claude", + model: "claude-opus-5-5", + effort: "medium", + instructions: "", + parentId: null, + status: "idle", + statusDetail: null, + resources: { cpus: null, memoryMb: null }, + monthlyBudgetUsd: null, + snapshotPolicy: { everyMinutes: 1440, keep: 7 }, + heartbeats: [], + browser: true, + hostTools: [], + cliCredentials: [], + mcpServers: [], + toolApprovals: [], + tailscale: null, + tailnetNode: null, + spendingLimits, + statusText: null, + statusEmoji: null, + statusUpdatedAt: null, + awaitingApproval: false, + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + lastActivityAt: null, + }; +} + +/** The parts of the store the bank uses, in memory. */ +class FakeStore implements BankStore { + readonly rows = new Map(); + readonly nibblets = new Map(); + private clock = 0; + now = () => new Date("2026-10-21T12:00:00Z"); + + async getNibblet(id: string) { + const n = this.nibblets.get(id); + if (!n) throw new RpcError("not_found", `No nibblet ${id}`); + return n; + } + async cardsOf(nibbletId: string) { + return [...this.rows.values()].filter((r) => r.nibbletId === nibbletId).reverse(); + } + async liveCards() { + return [...this.rows.values()].filter((r) => r.state !== "closed"); + } + async deleteCard(id: string) { + this.rows.delete(id); + } + async cardRow(id: string) { + const row = this.rows.get(id); + if (!row) throw new RpcError("not_found", `No card ${id}`); + return row; + } + async createCard(input: Parameters[0]) { + const row: CardRow = { + spentMinor: "0", + state: "pending", + closedBy: null, + closedAt: null, + checkedAt: null, + ...input, + createdAt: new Date(this.now().getTime() + this.clock++), + }; + this.rows.set(row.id, row); + return row; + } + async updateCard(id: string, patch: Parameters[1]) { + const row = { ...(await this.cardRow(id)), ...patch }; + this.rows.set(id, row); + return row; + } +} + +/** A card issuer that keeps its cards in memory. */ +class FakeIssuer { + readonly cards = new Map(); + /** The next issue is refused outright: nothing is made. */ + refuseNext = false; + /** The next issue makes the card, then fails as if it timed out. */ + loseNext = false; + /** The next issue fails as if it timed out, without making anything. */ + dropNext = false; + /** Closing can't settle until this is false (a wallet whose funding isn't seen yet). */ + unsettled = false; + /** Closing fails with an error that carries a credential. */ + leaky = false; + + plugin(): BankPlugin { + return defineBankPlugin({ + id: "fake", + label: "Fake issuer", + asset: { code: "USD", decimals: 2 }, + balance: async () => 100_000n, + issue: async (request) => { + if (this.refuseNext) throw new BankError("the account is frozen"); + if (this.dropNext) throw new Error("timed out"); + const ref = `tok_${this.cards.size + 1}`; + this.cards.set(ref, { open: true, spent: 0n, request }); + if (this.loseNext) throw new Error("timed out"); + return { + ref, + display: "•••• 4242", + secret: { + type: "card", + pan: "4242424242424242", + cvv: "123", + expMonth: "01", + expYear: "2031", + billingAddress: null, + }, + }; + }, + reveal: async () => ({ + type: "card", + pan: "4242424242424242", + cvv: "123", + expMonth: "01", + expYear: "2031", + billingAddress: null, + }), + inspect: async (card: CardRef) => { + const c = this.get(card); + return { open: c.open, spent: c.spent, used: c.spent > 0n }; + }, + find: async (cardId) => { + for (const [ref, c] of this.cards) { + if (c.request.cardId === cardId) return { ref, display: "•••• 4242" }; + } + return null; + }, + close: async (card: CardRef) => { + if (this.leaky) throw new Error("POST https://rpc.example/v2/SECRETKEY failed"); + const c = this.get(card); + c.open = false; + return { open: false, spent: c.spent, used: c.spent > 0n, settled: !this.unsettled }; + }, + }); + } + + private get(card: CardRef) { + const c = this.cards.get(card.ref); + if (!c) throw new Error(`no card ${card.ref}`); + return c; + } +} + +describe("Bank", () => { + let store: FakeStore; + let issuer: FakeIssuer; + let files: Map; + let removed: string[]; + let containerUp: boolean; + let published: LiveMessage[]; + let logs: string[]; + let clock: Date; + let bank: Bank; + + beforeEach(() => { + store = new FakeStore(); + store.nibblets.set( + "nib_1", + nibbletWith("nib_1", [{ asset: "USD", amount: "5000", period: "month" }]), + ); + store.nibblets.set("nib_2", nibbletWith("nib_2", [])); + issuer = new FakeIssuer(); + files = new Map(); + removed = []; + containerUp = true; + published = []; + logs = []; + clock = new Date("2026-10-21T12:30:00Z"); + store.now = () => clock; + bank = new Bank({ + store, + plugins: new Map([["fake", issuer.plugin()]]), + files: { + put: async (_nibbletId, file) => { + if (!containerUp) throw new Error("Its container isn't running"); + files.set(file.id, file); + return `/tmp/nibblet-cards/${file.id}.json`; + }, + remove: async (_nibbletId, cardId) => { + removed.push(cardId); + }, + }, + publish: (m) => published.push(m), + log: (m) => logs.push(m), + now: () => clock, + }); + }); + + const request = (limit: string, kind: "single_use" | "multi_use" = "single_use") => + bank.request("nib_1", { provider: "fake", limit, kind, purpose: "A domain" }); + + it("makes a card within the limit and writes its file", async () => { + const { card, path, details } = await request("20.00"); + expect(card).toMatchObject({ limit: "2000", state: "open", display: "•••• 4242" }); + expect(details).toBe("card"); + expect(path).toBe(`/tmp/nibblet-cards/${card.id}.json`); + expect(files.get(card.id)).toMatchObject({ + limit: "20.00", + details: { pan: "4242424242424242" }, + }); + expect(bank.pathOf(card.id)).toBe(path); + expect(published).toContainEqual({ type: "cards_changed", nibbletId: "nib_1" }); + // The card's number is never stored. + expect(JSON.stringify([...store.rows.values()])).not.toContain("4242424242424242"); + }); + + it("refuses a card over what's left, counting open cards in full", async () => { + await request("30.00"); + await expect(request("20.01")).rejects.toThrow(/over your limit: 20.00 USD is left/); + await expect(request("20.00")).resolves.toBeTruthy(); + }); + + it("refuses a nibblet without a limit in the asset", async () => { + await expect( + bank.request("nib_2", { provider: "fake", limit: "1", kind: "single_use", purpose: "x" }), + ).rejects.toThrow(/no spending limit in USD/); + expect(issuer.cards.size).toBe(0); + }); + + it("refuses an unknown bank and a bad amount", async () => { + await expect( + bank.request("nib_1", { provider: "nope", limit: "1", kind: "single_use", purpose: "x" }), + ).rejects.toThrow(/No bank called nope/); + await expect(request("1.001")).rejects.toThrow(/decimal places/); + }); + + it("doesn't let two requests at once both spend the same budget", async () => { + const results = await Promise.allSettled([request("40.00"), request("40.00")]); + expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1); + expect(issuer.cards.size).toBe(1); + }); + + it("frees what wasn't spent when a card is killed", async () => { + const { card } = await request("50.00", "multi_use"); + await expect(request("1.00")).rejects.toThrow(/over your limit/); + const c = issuer.cards.get("tok_1"); + if (c) c.spent = 1250n; + const killed = await bank.kill(card.id, "nibblet", "nib_1"); + expect(killed).toMatchObject({ state: "closed", closedBy: "nibblet", spent: "1250" }); + expect(issuer.cards.get("tok_1")?.open).toBe(false); + expect(removed).toEqual([card.id]); + expect(bank.pathOf(card.id)).toBeNull(); + await expect(request("37.50")).resolves.toBeTruthy(); + }); + + it("only lets a nibblet kill its own cards", async () => { + const { card } = await request("5.00"); + await expect(bank.kill(card.id, "nibblet", "nib_2")).rejects.toThrow(/No card .* of yours/); + await expect(bank.kill(card.id, "user")).resolves.toMatchObject({ closedBy: "user" }); + // Killing it again is harmless. + await expect(bank.kill(card.id, "user")).resolves.toMatchObject({ closedBy: "user" }); + }); + + it("closes a card that couldn't reach its container", async () => { + containerUp = false; + await expect(request("5.00")).rejects.toThrow(/couldn't be put in your container/); + const [row] = store.rows.values(); + expect(row).toMatchObject({ state: "closed", closedBy: "server" }); + expect(issuer.cards.get("tok_1")?.open).toBe(false); + }); + + it("keeps nothing when the issuer refuses", async () => { + issuer.refuseNext = true; + await expect(request("5.00")).rejects.toThrow("Fake issuer refused: the account is frozen"); + expect(store.rows.size).toBe(0); + }); + + it("finds and closes a card the issuer made before failing", async () => { + issuer.loseNext = true; + await expect(request("30.00")).rejects.toThrow(/held as pending/); + const [row] = store.rows.values(); + expect(row).toMatchObject({ state: "pending", ref: "" }); + // It counts against the limit while it's unknown. + await expect(request("20.01")).rejects.toThrow(/over your limit/); + // Too soon to look. + await bank.sync(); + expect(issuer.cards.get("tok_1")?.open).toBe(true); + clock = new Date(clock.getTime() + 3 * 60_000); + await bank.sync(); + expect(store.rows.get(row?.id ?? "")).toMatchObject({ + state: "closed", + closedBy: "server", + ref: "tok_1", + }); + expect(issuer.cards.get("tok_1")?.open).toBe(false); + }); + + it("forgets a pending card that was never made, after a while", async () => { + issuer.dropNext = true; + await expect(request("5.00")).rejects.toThrow(/held as pending/); + clock = new Date(clock.getTime() + 5 * 60_000); + await bank.sync(); + expect(store.rows.size).toBe(1); + clock = new Date(clock.getTime() + 60 * 60_000); + await bank.sync(); + expect(store.rows.size).toBe(0); + }); + + it("won't kill a card that's still pending", async () => { + issuer.dropNext = true; + await expect(request("5.00")).rejects.toThrow(); + const [row] = store.rows.values(); + await expect(bank.kill(row?.id ?? "", "user")).rejects.toThrow(/still being made/); + }); + + it("keeps a card closing until the provider settles it", async () => { + const { card } = await request("20.00", "multi_use"); + issuer.unsettled = true; + expect(await bank.kill(card.id, "nibblet", "nib_1")).toMatchObject({ + state: "closing", + closedBy: "nibblet", + }); + // Its file is gone at once, and it still counts in full. + expect(removed).toContain(card.id); + await expect(request("30.01")).rejects.toThrow(/over your limit/); + await bank.sync(); + expect((await store.cardRow(card.id)).state).toBe("closing"); + issuer.unsettled = false; + await bank.sync(); + expect(await store.cardRow(card.id)).toMatchObject({ state: "closed", closedBy: "nibblet" }); + }); + + it("records a card closed anyway when it hasn't settled in a day", async () => { + const { card } = await request("20.00", "multi_use"); + issuer.unsettled = true; + await bank.kill(card.id, "user"); + clock = new Date(clock.getTime() + 25 * 60 * 60_000); + await bank.sync(); + expect((await store.cardRow(card.id)).state).toBe("closed"); + expect(logs.join("\n")).toMatch(/never settled/); + }); + + it("gives an old card killed now a day from its killing to settle", async () => { + const { card } = await request("20.00", "multi_use"); + clock = new Date(clock.getTime() + 2 * 24 * 60 * 60_000); + issuer.unsettled = true; + expect(await bank.kill(card.id, "user")).toMatchObject({ state: "closing" }); + await bank.sync(); + expect((await store.cardRow(card.id)).state).toBe("closing"); + clock = new Date(clock.getTime() + 25 * 60 * 60_000); + await bank.sync(); + expect((await store.cardRow(card.id)).state).toBe("closed"); + }); + + it("doesn't pass on a plugin's own error, which could carry a credential", async () => { + const { card } = await request("5.00", "multi_use"); + issuer.leaky = true; + const err = await bank.kill(card.id, "user").catch((e: unknown) => e); + expect(err).toBeInstanceOf(RpcError); + expect(String(err)).not.toContain("SECRETKEY"); + expect(String(err)).toMatch(/server's log has the details/); + expect(logs.join("\n")).toContain("SECRETKEY"); + }); + + it("won't let a nibblet be deleted while a card can't be closed", async () => { + await request("5.00", "multi_use"); + issuer.leaky = true; + await expect(bank.killAll("nib_1")).rejects.toThrow(/couldn't be closed/); + issuer.leaky = false; + await expect(bank.killAll("nib_1")).resolves.toBeUndefined(); + }); + + it("syncs spending, closes used single-use cards and notices ones the provider closed", async () => { + const single = (await request("5.00", "single_use")).card; + const multi = (await request("5.00", "multi_use")).card; + const gone = (await request("5.00", "multi_use")).card; + const at = (ref: string) => { + const c = issuer.cards.get(ref); + if (!c) throw new Error(ref); + return c; + }; + at("tok_1").spent = 499n; + at("tok_2").spent = 100n; + at("tok_3").open = false; + await bank.sync(); + expect(await store.cardRow(single.id)).toMatchObject({ state: "closed", closedBy: "provider" }); + expect(at("tok_1").open).toBe(false); + expect(await store.cardRow(multi.id)).toMatchObject({ state: "open", spentMinor: "100" }); + expect(await store.cardRow(gone.id)).toMatchObject({ state: "closed", closedBy: "provider" }); + expect(removed).toEqual(expect.arrayContaining([single.id, gone.id])); + }); + + it("writes open cards again for a container that's come back", async () => { + const { card } = await request("5.00", "multi_use"); + const closed = (await request("5.00")).card; + await bank.kill(closed.id, "user"); + files.clear(); + await bank.restore("nib_1"); + expect([...files.keys()]).toEqual([card.id]); + }); + + it("kills every open card of a nibblet that's being deleted", async () => { + await request("5.00", "multi_use"); + await request("5.00", "multi_use"); + await bank.killAll("nib_1"); + expect([...store.rows.values()].every((r) => r.state === "closed")).toBe(true); + expect([...issuer.cards.values()].every((c) => !c.open)).toBe(true); + }); + + it("says where each limit stands", async () => { + await request("12.34", "multi_use"); + expect(await bank.allowances("nib_1")).toEqual([ + { + limit: { asset: "USD", amount: "5000", period: "month" }, + used: "1234", + remaining: "3766", + since: "2026-10-01T00:00:00.000Z", + }, + ]); + }); + + it("reports each bank's balance, and banks that aren't set up right", async () => { + const broken = new Bank({ + store, + plugins: new Map([["fake", issuer.plugin()]]), + errors: new Map([["slash", "SLASH_API_KEY isn't set"]]), + files: { put: async () => "", remove: async () => {} }, + publish: () => {}, + log: () => {}, + }); + expect(await broken.status()).toEqual({ + providers: [ + { + id: "fake", + label: "Fake issuer", + asset: { code: "USD", decimals: 2 }, + state: "ready", + detail: null, + balance: "100000", + }, + ], + problems: [{ id: "slash", detail: "SLASH_API_KEY isn't set" }], + }); + }); +}); diff --git a/apps/server/src/bank.ts b/apps/server/src/bank.ts new file mode 100644 index 00000000..e6f019b0 --- /dev/null +++ b/apps/server/src/bank.ts @@ -0,0 +1,527 @@ +import { + allowance, + BankError, + checkCard, + type BankPlugin, + type BudgetCard, + type CardRef, + type CardUsage, + type IssuedCard, +} from "@nibblet/bank"; +import { + formatAmount, + parseAmount, + RpcError, + type AssetInfo, + type BankProvider, + type BankProviderId, + type BankStatus, + type Card, + type CardClosedBy, + type CardFile, + type CardSecret, + type CardKind, + type LiveMessage, + type SpendingAllowance, +} from "@nibblet/protocol"; +import type { CardFiles } from "./card-files.ts"; +import { newId, toCard, type CardRow, type Store } from "./store.ts"; + +/** How often open cards are checked with their providers. */ +const SYNC_EVERY_MS = 60_000; +/** How long a card whose issuing didn't answer clearly gets to show up before it's looked for. */ +const RECOVER_AFTER_MS = 2 * 60_000; +/** How long one that hasn't shown up is looked for before it's taken as never made. */ +const GIVE_UP_AFTER_MS = 60 * 60_000; +/** How long a killed card can stay unsettled before it's recorded as closed anyway. */ +const UNSETTLED_AFTER_MS = 24 * 60 * 60_000; + +export type BankStore = Pick< + Store, + "getNibblet" | "cardsOf" | "cardRow" | "createCard" | "updateCard" | "deleteCard" | "liveCards" +>; + +export interface BankDeps { + store: BankStore; + plugins: ReadonlyMap; + /** Plugins whose settings are there but wrong, with why. */ + errors?: ReadonlyMap; + files: Pick; + publish(message: LiveMessage): void; + log(message: string): void; + now?(): Date; +} + +export interface CardRequest { + provider: string; + /** In the asset's own units, e.g. `25.00`. */ + limit: string; + kind: CardKind; + purpose: string; +} + +/** + * Nibblets' cards: makes them within each nibblet's spending limits, puts + * their details in its container, keeps track of what's spent on them, and + * kills them. The plugins do the talking to providers. + */ +export class Bank { + private readonly deps: BankDeps; + /** One card request or kill at a time per nibblet, so two can't both fit the same budget. */ + private readonly queues = new Map>(); + /** Cards whose `issue` is still running, so `sync` doesn't take them for lost. */ + private readonly issuing = new Set(); + /** Where each open card's file is in its container, as its runner last said. */ + private readonly paths = new Map(); + private timer: ReturnType | null = null; + private syncing: Promise | null = null; + + constructor(deps: BankDeps) { + this.deps = deps; + } + + /** Whether any bank is set up, so the tools are worth offering. */ + get enabled(): boolean { + return this.deps.plugins.size > 0; + } + + /** The banks nibblets can ask for cards from. */ + providers(): { id: string; label: string; asset: AssetInfo }[] { + return [...this.deps.plugins.values()].map((p) => ({ + id: p.id, + label: p.label, + asset: p.asset, + })); + } + + async status(): Promise { + const providers = await Promise.all( + [...this.deps.plugins.values()].map(async (plugin): Promise => { + const base = { id: plugin.id, label: plugin.label, asset: plugin.asset }; + try { + const balance = await plugin.balance(); + return { ...base, state: "ready", detail: null, balance: balance?.toString() ?? null }; + } catch (err) { + if (!(err instanceof BankError)) this.deps.log(`${plugin.id}: ${message(err)}`); + const detail = + err instanceof BankError ? err.message : "It didn't answer; the server's log has why."; + return { ...base, state: "error", detail, balance: null }; + } + }), + ); + const problems = [...(this.deps.errors ?? [])].map(([id, detail]) => ({ id, detail })); + return { providers, problems }; + } + + async cards(nibbletId: string): Promise { + return (await this.deps.store.cardsOf(nibbletId)).map(toCard); + } + + /** Where an open card's file is in its container, if it's been written since the server started. */ + pathOf(cardId: string): string | null { + return this.paths.get(cardId) ?? null; + } + + async allowances(nibbletId: string): Promise { + const nibblet = await this.deps.store.getNibblet(nibbletId); + const cards = (await this.deps.store.cardsOf(nibbletId)).map(budgetCard); + const now = this.now(); + return nibblet.spendingLimits.flatMap((limit) => { + const left = allowance([limit], cards, limit.asset, now); + return left + ? [ + { + limit, + used: left.used.toString(), + remaining: left.remaining.toString(), + since: left.since.toISOString(), + }, + ] + : []; + }); + } + + /** + * Makes a card for a nibblet and writes it in its container. Its row goes + * in first, as `pending`, so a card the provider made before something + * failed is never lost track of: `sync` finds and closes it. A card that + * can't be written in the container is closed again rather than left open + * unseen. + */ + request( + nibbletId: string, + request: CardRequest, + ): Promise<{ card: Card; path: string; details: CardSecret["type"] }> { + return this.serially(nibbletId, async () => { + const plugin = this.plugin(request.provider); + const { decimals, code } = plugin.asset; + let amount: bigint; + try { + amount = parseAmount(request.limit, decimals); + } catch (err) { + throw new RpcError("bad_request", message(err)); + } + const nibblet = await this.deps.store.getNibblet(nibbletId); + const existing = (await this.deps.store.cardsOf(nibbletId)).map(budgetCard); + const check = checkCard(nibblet.spendingLimits, existing, code, amount, this.now(), (m) => + formatAmount(m, decimals), + ); + if (!check.ok) throw new RpcError("conflict", check.reason); + + const id = newId("card"); + const purpose = request.purpose.trim(); + await this.deps.store.createCard({ + id, + nibbletId, + provider: plugin.id, + ref: "", + asset: code, + decimals, + kind: request.kind, + limitMinor: amount.toString(), + purpose, + display: "", + state: "pending", + }); + this.issuing.add(id); + let issued: IssuedCard; + try { + issued = await plugin.issue({ + cardId: id, + nibbletId, + label: `${nibblet.name}: ${purpose}`.slice(0, 120), + kind: request.kind, + limit: amount, + }); + } catch (err) { + if (err instanceof BankError) { + // A refusal: nothing was made, so there's nothing to keep. + await this.deps.store.deleteCard(id); + throw new RpcError("unavailable", `${plugin.label} refused: ${err.message}`); + } + this.deps.log(`issuing card ${id} at ${plugin.id} failed: ${message(err)}`); + throw new RpcError( + "unavailable", + `${plugin.label} didn't answer clearly, so card ${id} is held as pending (counting against your limit) until the server checks whether it was made; if it was, it's closed. Try again in a few minutes.`, + ); + } finally { + this.issuing.delete(id); + } + const row = await this.deps.store.updateCard(id, { + ref: issued.ref, + display: issued.display, + state: "open", + }); + const file: CardFile = { + id, + provider: plugin.id, + asset: code, + kind: request.kind, + limit: formatAmount(amount, decimals), + purpose, + details: issued.secret, + }; + let path: string; + try { + path = await this.deps.files.put(nibbletId, file); + } catch (err) { + await this.close(row, plugin, "server").catch((closeErr: unknown) => + this.deps.log( + `couldn't close card ${id} that never reached its container: ${message(closeErr)}`, + ), + ); + throw new RpcError( + "unavailable", + `The card couldn't be put in your container (${message(err)}), so it was closed again.`, + ); + } + this.paths.set(id, path); + this.changed(nibbletId); + return { card: toCard(row), path, details: issued.secret.type }; + }); + } + + /** + * Kills a card for good. `nibbletId` is the nibblet asking, which may only + * kill its own; the person (null) may kill any. A card that can't be + * settled yet (a wallet whose funding isn't seen) is left `closing`, and + * `sync` finishes it. + */ + async kill(cardId: string, by: CardClosedBy, nibbletId: string | null = null): Promise { + const first = await this.deps.store.cardRow(cardId).catch(() => null); + if (!first || (nibbletId !== null && first.nibbletId !== nibbletId)) { + throw new RpcError("not_found", `No card ${cardId}${nibbletId ? " of yours" : ""}`); + } + return this.serially(first.nibbletId, async () => { + const row = await this.deps.store.cardRow(cardId); + if (row.state === "closed") return toCard(row); + if (row.state === "pending") { + throw new RpcError( + "conflict", + "That card is still being made, or checked on. It'll be closed if it was made; try again in a few minutes.", + ); + } + const plugin = this.deps.plugins.get(row.provider); + if (!plugin) { + throw new RpcError( + "unavailable", + `${row.provider} isn't set up on this server any more, so its card can't be killed from here`, + ); + } + return toCard(await this.close(row, plugin, by)); + }); + } + + /** + * Kills every card a nibblet has, before it's deleted. Throws if any isn't + * closed afterwards, since deleting the nibblet would lose the only record + * of a card that can still be spent, or of a wallet's funds. + */ + async killAll(nibbletId: string): Promise { + for (const row of await this.deps.store.cardsOf(nibbletId)) { + if (row.state === "closed" || row.state === "pending") continue; + await this.kill(row.id, "user").catch((err: unknown) => + this.deps.log(`couldn't kill card ${row.id}: ${message(err)}`), + ); + } + const left = (await this.deps.store.cardsOf(nibbletId)).filter((r) => r.state !== "closed"); + if (left.length > 0) { + throw new RpcError( + "conflict", + `It still has ${left.length === 1 ? "a card" : `${left.length} cards`} that couldn't be closed (${left.map((r) => r.id).join(", ")}). Kill ${left.length === 1 ? "it" : "them"} in its settings, or wait a few minutes, before deleting it.`, + ); + } + } + + /** Writes a nibblet's open cards into its container again, once its runner is back. */ + async restore(nibbletId: string): Promise { + for (const { id } of await this.deps.store.cardsOf(nibbletId)) { + await this.serially(nibbletId, async () => { + // Checked in turn with kills, so a card killed meanwhile isn't written back. + const row = await this.deps.store.cardRow(id); + if (row.state !== "open") return; + const plugin = this.deps.plugins.get(row.provider); + if (!plugin) return; + const details = await plugin.reveal(cardRef(row)); + const path = await this.deps.files.put(nibbletId, { + id: row.id, + provider: row.provider, + asset: row.asset, + kind: row.kind, + limit: formatAmount(row.limitMinor, row.decimals), + purpose: row.purpose, + details, + }); + this.paths.set(row.id, path); + }).catch((err: unknown) => + this.deps.log(`couldn't put card ${id} back in ${nibbletId}'s container: ${message(err)}`), + ); + } + } + + /** + * Checks every card that isn't closed with its provider: what's been + * spent, whether the provider closed it, closes single-use cards that have + * been used, finishes closing ones that weren't settled, and finds out + * whether pending ones were made. + */ + sync(): Promise { + this.syncing ??= this.syncOnce().finally(() => { + this.syncing = null; + }); + return this.syncing; + } + + start(): void { + if (this.timer || !this.enabled) return; + this.timer = setInterval(() => void this.sync(), SYNC_EVERY_MS); + this.timer.unref?.(); + void this.sync(); + } + + stop(): void { + if (this.timer) clearInterval(this.timer); + this.timer = null; + } + + private async syncOnce(): Promise { + for (const row of await this.deps.store.liveCards()) { + const plugin = this.deps.plugins.get(row.provider); + if (!plugin) continue; + try { + await this.serially(row.nibbletId, async () => { + const current = await this.deps.store.cardRow(row.id); + if (current.state === "pending") return this.recover(current, plugin); + if (current.state === "closing") { + await this.close(current, plugin, current.closedBy ?? "server"); + return; + } + if (current.state !== "open") return; + const usage = await plugin.inspect(cardRef(current)); + if (!usage.open) { + await this.record(current, usage, "provider"); + } else if (current.kind === "single_use" && usage.used) { + await this.close(current, plugin, "provider"); + } else { + const spent = usage.spent.toString(); + await this.deps.store.updateCard(current.id, { + spentMinor: spent, + checkedAt: this.now(), + }); + if (spent !== current.spentMinor) this.changed(current.nibbletId); + } + }); + } catch (err) { + this.deps.log(`couldn't check card ${row.id}: ${message(err)}`); + } + } + } + + /** + * A card whose issuing didn't answer clearly: once it's had time to show + * up, either it was made, and is closed (its nibblet was told it failed), + * or it wasn't, and the row goes. + */ + private async recover(row: CardRow, plugin: BankPlugin): Promise { + if (this.issuing.has(row.id)) return; + const age = this.now().getTime() - row.createdAt.getTime(); + if (age < RECOVER_AFTER_MS) return; + const found = await plugin.find(row.id); + if (found) { + const made = await this.deps.store.updateCard(row.id, { + ref: found.ref, + display: found.display, + state: "closing", + closedBy: "server", + }); + this.deps.log(`card ${row.id} was made after all; closing it`); + await this.close(made, plugin, "server"); + } else if (age >= GIVE_UP_AFTER_MS) { + this.deps.log(`card ${row.id} was never made; forgetting it`); + await this.deps.store.deleteCard(row.id); + this.changed(row.nibbletId); + } + } + + /** + * Closes a card at its provider. Until the provider says it's settled, it + * stays `closing` (and counts in full), and `sync` tries again; a day + * after it was killed, it's recorded as closed anyway, with a warning, + * since its id (and so a wallet's key) is still in its row. + */ + private async close(row: CardRow, plugin: BankPlugin, by: CardClosedBy): Promise { + if (row.state !== "closing") { + // Its `closedAt` is when it was killed until it's closed. + row = await this.deps.store.updateCard(row.id, { + state: "closing", + closedBy: by, + closedAt: this.now(), + }); + this.paths.delete(row.id); + await this.deps.files.remove(row.nibbletId, row.id).catch(() => undefined); + this.changed(row.nibbletId); + } + const result = await this.guard(plugin, "close the card", () => plugin.close(cardRef(row))); + const killed = row.closedAt ?? row.createdAt; + const stale = this.now().getTime() - killed.getTime() >= UNSETTLED_AFTER_MS; + if (!result.settled && !stale) { + return this.deps.store.updateCard(row.id, { + spentMinor: result.spent.toString(), + checkedAt: this.now(), + }); + } + if (!result.settled) { + this.deps.log(`card ${row.id} at ${plugin.id} never settled; recording it as closed anyway`); + } + return this.record(row, result, by); + } + + /** Stores a card as closed and takes its file out of its container. */ + private async record(row: CardRow, usage: CardUsage, by: CardClosedBy): Promise { + const closed = await this.deps.store.updateCard(row.id, { + state: "closed", + closedBy: by, + closedAt: this.now(), + checkedAt: this.now(), + spentMinor: usage.spent.toString(), + }); + this.paths.delete(row.id); + // It's dead either way; a container that's asleep has no file to remove. + // One that was closing has had its file removed already. + if (row.state !== "closing") { + await this.deps.files.remove(row.nibbletId, row.id).catch(() => undefined); + } + this.changed(row.nibbletId); + return closed; + } + + /** + * Runs a plugin call, passing on only errors meant to be shown: anything + * else could carry a credential (an RPC URL with its key, say), so it's + * logged here and reported plainly. + */ + private async guard(plugin: BankPlugin, doing: string, work: () => Promise): Promise { + try { + return await work(); + } catch (err) { + if (err instanceof RpcError) throw err; + if (err instanceof BankError) + throw new RpcError("unavailable", `${plugin.label}: ${err.message}`); + this.deps.log(`${plugin.id} couldn't ${doing}: ${message(err)}`); + throw new RpcError( + "unavailable", + `${plugin.label} couldn't ${doing} just now; the server's log has the details.`, + ); + } + } + + private plugin(id: string): BankPlugin { + const plugin = this.deps.plugins.get(id); + if (plugin) return plugin; + const known = [...this.deps.plugins.keys()]; + throw new RpcError( + "bad_request", + known.length + ? `No bank called ${id}. This server has: ${known.join(", ")}.` + : "No bank is set up on this server.", + ); + } + + private serially(nibbletId: string, work: () => Promise): Promise { + const before = this.queues.get(nibbletId) ?? Promise.resolve(); + const next = before.then(work, work); + const settled = next.catch(() => undefined); + this.queues.set(nibbletId, settled); + void settled.then(() => { + if (this.queues.get(nibbletId) === settled) this.queues.delete(nibbletId); + }); + return next; + } + + private changed(nibbletId: string): void { + this.deps.publish({ type: "cards_changed", nibbletId }); + } + + private now(): Date { + return this.deps.now?.() ?? new Date(); + } +} + +function cardRef(row: CardRow): CardRef { + return { id: row.id, ref: row.ref, kind: row.kind, limit: BigInt(row.limitMinor) }; +} + +function budgetCard(row: CardRow): BudgetCard { + return { + asset: row.asset, + limit: BigInt(row.limitMinor), + spent: BigInt(row.spentMinor), + // Pending and closing cards could still be spent, or still hold funds. + open: row.state !== "closed", + createdAt: row.createdAt, + }; +} + +function message(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/apps/server/src/banks.ts b/apps/server/src/banks.ts new file mode 100644 index 00000000..12766608 --- /dev/null +++ b/apps/server/src/banks.ts @@ -0,0 +1,17 @@ +import { loadBankPlugins, type BankPluginFactory, type LoadedBanks } from "@nibblet/bank"; + +/** + * Every bank plugin the server knows. Each turns itself on when its settings + * are in the environment (see `docker/.env.example`), and stays off otherwise. + */ +export const BANK_PLUGINS: readonly BankPluginFactory[] = []; + +export function loadBanks(env: NodeJS.ProcessEnv = process.env): LoadedBanks { + const loaded = loadBankPlugins(BANK_PLUGINS, env, { + fetch: globalThis.fetch, + log: (message) => console.warn(`bank: ${message}`), + }); + for (const plugin of loaded.plugins.values()) console.log(`bank: ${plugin.label} is set up`); + for (const [id, problem] of loaded.errors) console.warn(`bank: ${id} isn't set up: ${problem}`); + return loaded; +} diff --git a/apps/server/src/card-files.test.ts b/apps/server/src/card-files.test.ts new file mode 100644 index 00000000..c3fc26c6 --- /dev/null +++ b/apps/server/src/card-files.test.ts @@ -0,0 +1,81 @@ +import type { CardFile, ServerToRunner } from "@nibblet/protocol"; +import { describe, expect, it } from "vitest"; +import { CardFiles } from "./card-files.ts"; + +const file: CardFile = { + id: "card_1", + provider: "fake", + asset: "USD", + kind: "single_use", + limit: "5.00", + purpose: "x", + details: { + type: "card", + pan: "4242424242424242", + cvv: "123", + expMonth: "01", + expYear: "2031", + billingAddress: null, + }, +}; + +function harness(connected = true) { + const sent: { nibbletId: string; message: ServerToRunner }[] = []; + const files = new CardFiles((nibbletId, message) => { + if (connected) sent.push({ nibbletId, message }); + return connected; + }, 50); + const requestId = () => { + const last = sent.at(-1)?.message; + if (!last || !("requestId" in last)) throw new Error("nothing sent"); + return last.requestId; + }; + return { files, sent, requestId }; +} + +describe("CardFiles", () => { + it("sends the card and resolves with where the runner put it", async () => { + const { files, sent, requestId } = harness(); + const put = files.put("nib_1", file); + expect(sent[0]).toMatchObject({ nibbletId: "nib_1", message: { type: "card_put", file } }); + files.fromRunner("nib_1", { + type: "card_done", + requestId: requestId(), + path: "/tmp/nibblet-cards/card_1.json", + error: null, + }); + await expect(put).resolves.toBe("/tmp/nibblet-cards/card_1.json"); + }); + + it("ignores an answer from another nibblet's runner", async () => { + const { files, requestId } = harness(); + const put = files.put("nib_1", file); + files.fromRunner("nib_2", { + type: "card_done", + requestId: requestId(), + path: "/x", + error: null, + }); + await expect(put).rejects.toThrow(/didn't answer in time/); + }); + + it("passes on the runner's error", async () => { + const { files, requestId } = harness(); + const removing = files.remove("nib_1", "card_1"); + files.fromRunner("nib_1", { + type: "card_done", + requestId: requestId(), + path: null, + error: "EACCES", + }); + await expect(removing).rejects.toThrow("EACCES"); + }); + + it("fails at once when there's no runner, and when it goes away", async () => { + await expect(harness(false).files.put("nib_1", file)).rejects.toThrow(/isn't running/); + const { files } = harness(); + const put = files.put("nib_1", file); + files.runnerGone("nib_1"); + await expect(put).rejects.toThrow(/Lost touch/); + }); +}); diff --git a/apps/server/src/card-files.ts b/apps/server/src/card-files.ts new file mode 100644 index 00000000..52fca63b --- /dev/null +++ b/apps/server/src/card-files.ts @@ -0,0 +1,78 @@ +import type { CardFile, RunnerToServer, ServerToRunner } from "@nibblet/protocol"; +import { newId } from "./store.ts"; + +type Done = Extract; + +interface Pending { + nibbletId: string; + resolve(done: Done): void; + reject(err: Error): void; + timer: ReturnType; +} + +/** + * Card files in nibblets' containers, written by their runners over the + * runner socket: the only way a card's details reach a container. Each + * request waits for the runner to say it's done. + */ +export class CardFiles { + private readonly pending = new Map(); + private readonly send: (nibbletId: string, message: ServerToRunner) => boolean; + private readonly timeoutMs: number; + + /** `send` says whether the nibblet's runner is connected to send to. */ + constructor(send: (nibbletId: string, message: ServerToRunner) => boolean, timeoutMs = 30_000) { + this.send = send; + this.timeoutMs = timeoutMs; + } + + /** Writes a card's file in its container and returns the path. */ + async put(nibbletId: string, file: CardFile): Promise { + const done = await this.request(nibbletId, (requestId) => ({ + type: "card_put", + requestId, + file, + })); + if (!done.path) throw new Error("Its container didn't say where it put the card"); + return done.path; + } + + async remove(nibbletId: string, cardId: string): Promise { + await this.request(nibbletId, (requestId) => ({ type: "card_remove", requestId, cardId })); + } + + fromRunner(nibbletId: string, done: Done): void { + const pending = this.pending.get(done.requestId); + if (!pending || pending.nibbletId !== nibbletId) return; + this.pending.delete(done.requestId); + clearTimeout(pending.timer); + if (done.error) pending.reject(new Error(done.error)); + else pending.resolve(done); + } + + /** Its runner went away: nothing it was asked will be answered. */ + runnerGone(nibbletId: string): void { + for (const [id, pending] of this.pending) { + if (pending.nibbletId !== nibbletId) continue; + this.pending.delete(id); + clearTimeout(pending.timer); + pending.reject(new Error("Lost touch with its container")); + } + } + + private request(nibbletId: string, message: (requestId: string) => ServerToRunner) { + const requestId = newId("cardreq"); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(requestId); + reject(new Error("Its container didn't answer in time")); + }, this.timeoutMs); + this.pending.set(requestId, { nibbletId, resolve, reject, timer }); + if (!this.send(nibbletId, message(requestId))) { + this.pending.delete(requestId); + clearTimeout(timer); + reject(new Error("Its container isn't running")); + } + }); + } +} diff --git a/apps/server/src/conductor.ts b/apps/server/src/conductor.ts index 3bedde21..9b65cecc 100644 --- a/apps/server/src/conductor.ts +++ b/apps/server/src/conductor.ts @@ -43,6 +43,8 @@ import { keyId, mintAuthKey, readTailnetOAuth } from "./tailnet-keys.ts"; import { hostLoginFor } from "./host-login.ts"; import { runnerName, type Orchestrator } from "./docker.ts"; import { TerminalHub } from "./terminals.ts"; +import type { Bank } from "./bank.ts"; +import { CardFiles } from "./card-files.ts"; import { desktopToken, guardToken, hashToken, newId, viewToken, type Store } from "./store.ts"; export interface RunnerLink { @@ -131,6 +133,14 @@ export class Conductor { link?.send(message); return link !== undefined; }); + /** Card files in the containers, written over the runner socket. */ + readonly cardFiles = new CardFiles((nibbletId, message) => { + const link = this.links.get(nibbletId); + link?.send(message); + return link !== undefined; + }); + /** Nibblets' cards, once the bank is set up; it writes their files through `cardFiles`. */ + bank: Bank | null = null; serverName = "nibblet"; constructor(store: Store, docker: Orchestrator, config: Config) { @@ -543,6 +553,7 @@ export class Conductor { this.links.get(nibbletId)?.close(); this.links.delete(nibbletId); this.terminals.runnerGone(nibbletId); + this.cardFiles.runnerGone(nibbletId); const host = await this.hostOf(nibblet).catch(() => null); if (host && !(await this.docker.runnerRunning(host).catch(() => false))) { // It went down with its parent's container. @@ -605,6 +616,7 @@ export class Conductor { this.links.get(nibbletId)?.close(); this.links.delete(nibbletId); this.terminals.runnerGone(nibbletId); + this.cardFiles.runnerGone(nibbletId); // Mark it stopped first, so the container's exit event reads as expected. const stopped = await this.store.updateNibblet(nibbletId, { status: "stopped", @@ -626,6 +638,9 @@ export class Conductor { async delete(nibbletId: string): Promise { // At the same time: each waits for its own processes to stop. + // Its cards die with it, at their providers too, not just in the database; + // if any can't be closed, it isn't deleted, so their records aren't lost. + await this.bank?.killAll(nibbletId); await Promise.all((await this.store.children(nibbletId)).map((child) => this.delete(child.id))); await this.stop(nibbletId).catch(() => undefined); this.queues.delete(nibbletId); @@ -791,7 +806,10 @@ export class Conductor { async attach(nibbletId: string, link: RunnerLink): Promise { const previous = this.links.get(nibbletId); previous?.close(); - if (previous && previous !== link) this.terminals.runnerGone(nibbletId); + if (previous && previous !== link) { + this.terminals.runnerGone(nibbletId); + this.cardFiles.runnerGone(nibbletId); + } this.links.set(nibbletId, link); const row = await this.store.nibbletRow(nibbletId); const onePassword = await onePasswordLogin(this.config, this.store, nibbletId, (m) => @@ -918,6 +936,7 @@ export class Conductor { this.links.delete(nibbletId); this.views.delete(nibbletId); this.terminals.runnerGone(nibbletId); + this.cardFiles.runnerGone(nibbletId); this.failActive(nibbletId, "failed", "Lost touch with its container"); } @@ -938,6 +957,10 @@ export class Conductor { } await this.store.setStatus(nibbletId, this.active.has(nibbletId) ? "working" : "idle"); await this.dispatch(nibbletId); + // A fresh container has lost its card files; a reconnect just rewrites them. + void this.bank + ?.restore(nibbletId) + .catch((err: unknown) => console.warn(`[${nibbletId}] couldn't restore its cards`, err)); return; } case "item": { @@ -978,6 +1001,9 @@ export class Conductor { .catch((err: unknown) => console.error(`approval for ${nibbletId} failed`, err)); return; } + case "card_done": + this.cardFiles.fromRunner(nibbletId, message); + return; case "terminal_output": case "terminal_exit": case "tasks": @@ -1079,6 +1105,7 @@ export class Conductor { parent, now: new Date(), onePasswordVaults: this.onePasswordVaults.get(nibbletId), + banks: this.bank?.providers() ?? [], // Its container's, which comes with the host's browser. desktop: host?.browser ?? false, tailnet: @@ -1241,6 +1268,7 @@ export class Conductor { if (!nibblet || nibblet.status === "stopped") return; this.links.delete(nibbletId); this.terminals.runnerGone(nibbletId); + this.cardFiles.runnerGone(nibbletId); this.failActive(nibbletId, "failed", "Its container stopped"); await this.subagentsGone(nibbletId, "Its parent's container stopped"); const logs = await this.docker.runnerLogs(nibbletId, 20); diff --git a/apps/server/src/context.test.ts b/apps/server/src/context.test.ts index e2df87b2..e4f338d5 100644 --- a/apps/server/src/context.test.ts +++ b/apps/server/src/context.test.ts @@ -95,6 +95,48 @@ describe("withinActiveHours", () => { }); describe("systemPrompt", () => { + const base = { + serverName: "home", + memories: [], + history: null, + parent: null, + now: new Date("2026-10-21T12:00:00Z"), + }; + const banks = [ + { id: "slash", label: "Slash", asset: { code: "USD", decimals: 2 } }, + { id: "bitcoin", label: "Bitcoin", asset: { code: "BTC", decimals: 8 } }, + ]; + + it("tells a nibblet what it may spend, and where", () => { + const prompt = systemPrompt({ + ...base, + nibblet: { + ...nibblet, + spendingLimits: [ + { asset: "USD", amount: "5000", period: "month" }, + { asset: "BTC", amount: "100000", period: "total" }, + // No bank deals in this one, so it isn't mentioned. + { asset: "ETH", amount: "1", period: "day" }, + ], + }, + banks, + }); + expect(prompt).toContain("## Spending"); + expect(prompt).toContain("- 50.00 USD a month (UTC), from `slash`"); + expect(prompt).toContain("- 0.001 BTC in total, from `bitcoin`"); + expect(prompt).not.toContain("ETH"); + expect(prompt).toContain("`card_kill`"); + }); + + it("says nothing about spending without a limit or a bank", () => { + expect(systemPrompt({ ...base, nibblet, banks })).not.toContain("## Spending"); + const limited = { + ...nibblet, + spendingLimits: [{ asset: "USD", amount: "1", period: "day" as const }], + }; + expect(systemPrompt({ ...base, nibblet: limited, banks: [] })).not.toContain("## Spending"); + }); + it("includes instructions, memories, recap, and the browser rule", () => { const prompt = systemPrompt({ nibblet, diff --git a/apps/server/src/context.ts b/apps/server/src/context.ts index cd9313cb..879f13ea 100644 --- a/apps/server/src/context.ts +++ b/apps/server/src/context.ts @@ -1,6 +1,8 @@ import { + formatAmount, nameInPrompt, TAILSCALE_PROXY_PORT, + type AssetInfo, type Heartbeat, type Memory, type Nibblet, @@ -93,6 +95,8 @@ export interface SystemPromptInput { now: Date; /** 1Password vaults shared with it, if any. */ onePasswordVaults?: readonly SharedVault[]; + /** The banks it could get cards from, if its limits allow. */ + banks?: readonly { id: string; label: string; asset: AssetInfo }[]; /** Whether its container has a desktop for apps other than the browser. */ desktop?: boolean; /** Its container's machine on the user's tailnet, once it has joined. */ @@ -178,6 +182,9 @@ export function systemPrompt(input: SystemPromptInput): string { ); } + const spending = spendingSection(nibblet, input.banks ?? []); + if (spending) parts.push(spending); + if (input.tailnet) { parts.push(tailnetSection(input.tailnet.node, input.tailnet.ports)); } @@ -208,3 +215,21 @@ function tailnetSection(node: TailnetNode, ports: readonly number[]): string { : "Nothing in your container is reachable from the tailnet: if the user wants to open something you run from their other machines, they add its port in your settings."; return `## Tailscale\n\nYour container is a machine on the user's tailnet${node.tailnet ? ` (${node.tailnet})` : ""}: ${where}. ${shared}\n\nTo reach machines on the tailnet, go through Tailscale's proxy at \`127.0.0.1:${TAILSCALE_PROXY_PORT}\`, which speaks HTTP and SOCKS5 and resolves tailnet names: \`curl -x http://127.0.0.1:${TAILSCALE_PROXY_PORT} http://some-machine:8080\`, \`ALL_PROXY=socks5h://127.0.0.1:${TAILSCALE_PROXY_PORT}\` for tools that take it, or for SSH \`-o ProxyCommand='nc -X 5 -x 127.0.0.1:${TAILSCALE_PROXY_PORT} %h %p'\`. Without it, tailnet names don't resolve and tailnet addresses aren't routed. The \`tailscale\` command isn't yours to use.`; } + +/** What it may spend and how, when it has limits and a bank to spend them at. */ +function spendingSection( + nibblet: Nibblet, + banks: readonly { id: string; label: string; asset: AssetInfo }[], +): string | null { + const lines = nibblet.spendingLimits.flatMap((limit) => { + const at = banks.filter((b) => b.asset.code === limit.asset); + const first = at[0]; + if (!first) return []; + const per = limit.period === "total" ? "in total" : `a ${limit.period} (UTC)`; + return [ + `- ${formatAmount(limit.amount, first.asset.decimals)} ${limit.asset} ${per}, from ${at.map((b) => `\`${b.id}\``).join(" or ")}`, + ]; + }); + if (lines.length === 0) return null; + return `## Spending\n\nYour person lets you spend money, up to:\n${lines.join("\n")}\n\nFor a purchase, get a card with \`card_request\` for what it needs (single use where one payment will do), pay with the details from the file it names, then kill it with \`card_kill\` as soon as you're done; \`card_list\` shows your cards and what's left. Only spend on what your person asked for or would clearly want, and tell them what you bought. Card numbers, codes and keys stay in their files: never put them in the chat, your memory, other files or your status, and never give them to a site you didn't choose to pay.`; +} diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 9d75567d..d5016b01 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -14,6 +14,7 @@ import { z } from "zod"; import { cliStatuses } from "./clilogins.ts"; import { readTailnetOAuth, setTailnetKeys, tailnetKeys } from "./tailnet-keys.ts"; import { forgetGrants, onePasswordStatus, readGrants, setGrants } from "./onepassword.ts"; +import type { Bank } from "./bank.ts"; import type { Conductor } from "./conductor.ts"; import type { HarnessModelLists } from "./opencode.ts"; import { @@ -48,6 +49,7 @@ export interface Services { notifier: Notifier; harnessUpdates: HarnessUpdater; appUpdates: AppUpdater; + bank: Bank; } const ok = { ok: true } as const; @@ -267,6 +269,12 @@ export function createHandlers(s: Services): Handlers s.bank.status(), + cards: ({ nibbletId }) => s.bank.cards(nibbletId), + allowances: ({ nibbletId }) => s.bank.allowances(nibbletId), + kill: ({ id }) => s.bank.kill(id, "user"), + }, snapshots: { list: ({ nibbletId }) => s.snapshots.list(nibbletId), create: ({ nibbletId, label }) => s.snapshots.create(nibbletId, label ?? null), diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index adc2cbe6..3cbb1ff5 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -23,6 +23,8 @@ import { Connect } from "./connect.ts"; import { AppUpdater, HarnessUpdater } from "./updates.ts"; import { Notifier } from "./notifier.ts"; import { tailscaleStatus } from "./hostmcp.ts"; +import { Bank } from "./bank.ts"; +import { loadBanks } from "./banks.ts"; // The repository's version, which releases are tagged with. const VERSION = z @@ -55,6 +57,16 @@ async function main(): Promise { conductor.terminals.onTasks = (nibbletId, tasks) => bus.publish({ type: "tasks", nibbletId, tasks }); conductor.terminals.onApps = (nibbletId, apps) => bus.publish({ type: "apps", nibbletId, apps }); + const banks = loadBanks(); + const bank = new Bank({ + store, + plugins: banks.plugins, + errors: banks.errors, + files: conductor.cardFiles, + publish: (message) => bus.publish(message), + log: (message) => console.warn(`bank: ${message}`), + }); + conductor.bank = bank; const scheduler = new Scheduler(store, conductor); const snapshots = new Snapshots({ store, @@ -102,6 +114,7 @@ async function main(): Promise { snapshots, harnessUpdates, appUpdates, + bank, }, bus, ); @@ -125,6 +138,7 @@ async function main(): Promise { await scheduler.start(); snapshots.start(); harnessUpdates.start(); + bank.start(); // An update leaves the last release's images behind; reconcile has just // moved running nibblets off them. Again daily, for those that were stopped. const prune = () => @@ -146,6 +160,7 @@ async function main(): Promise { scheduler.stop(); snapshots.stop(); harnessUpdates.stop(); + bank.stop(); clearInterval(pruning); clearInterval(budgetSweep); central.stop(); diff --git a/apps/server/src/mcp.ts b/apps/server/src/mcp.ts index e49b8bb3..534c8424 100644 --- a/apps/server/src/mcp.ts +++ b/apps/server/src/mcp.ts @@ -2,14 +2,19 @@ import { StreamableHTTPTransport } from "@hono/mcp"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ActiveHours, + CardKind, + formatAmount, HeartbeatName, MAX_HEARTBEATS, nameInPrompt, + RpcError, + type Card, type Nibblet, } from "@nibblet/protocol"; import { Cron } from "croner"; import type { Context } from "hono"; import { z } from "zod"; +import type { Bank } from "./bank.ts"; import type { Conductor } from "./conductor.ts"; import type { Connect } from "./connect.ts"; import type { Notifier } from "./notifier.ts"; @@ -23,6 +28,7 @@ interface ToolDeps { scheduler: Scheduler; notifier: Pick; connect: Pick; + bank: Bank; } const text = (value: string) => ({ content: [{ type: "text" as const, text: value }] }); @@ -39,7 +45,7 @@ function validCron(pattern: string, timezone: string): string | null { /** The nibblet tools, scoped to the nibblet whose token made the call. */ function buildServer(nibblet: Nibblet, deps: ToolDeps): McpServer { - const { store, conductor, scheduler, notifier, connect } = deps; + const { store, conductor, scheduler, notifier, connect, bank } = deps; const server = new McpServer({ name: "nibblet", version: "0.1.0" }); // A plain one isn't told it's a nibblet. const kind = nibblet.plain ? "agent" : "nibblet"; @@ -366,9 +372,130 @@ function buildServer(nibblet: Nibblet, deps: ToolDeps): McpServer { }, ); + if (bank.enabled) registerCardTools(server, nibblet, bank); + return server; } +/** A card as a tool shows it: never what it can be spent with. */ +function describeCard(card: Card, path: string | null) { + const { decimals, code } = card.asset; + return { + id: card.id, + bank: card.provider, + card: card.display, + kind: card.kind, + limit: `${formatAmount(card.limit, decimals)} ${code}`, + spent: `${formatAmount(card.spent, decimals)} ${code}`, + purpose: card.purpose, + state: card.state, + ...(card.closedBy ? { closedBy: card.closedBy } : {}), + ...(path && card.state === "open" ? { file: path } : {}), + createdAt: card.createdAt, + }; +} + +/** A tool's error as text the agent can act on, rather than a failed call. */ +/** A card tool's result, or its refusal as a tool error the agent can tell from success. */ +async function answer(work: () => Promise>) { + try { + return await work(); + } catch (err) { + if (err instanceof RpcError) return { ...text(err.message), isError: true }; + throw err; + } +} + +function registerCardTools(server: McpServer, nibblet: Nibblet, bank: Bank): void { + const banks = bank.providers(); + server.registerTool( + "card_request", + { + description: `Get a virtual card (or, for crypto, a funded wallet) to pay for something, within the spending limit your person set you. Its details are written to a file in your container, never shown here: read the file when you fill in the payment, and don't copy them anywhere else. A single_use card closes after one purchase; a multi_use one stays open until you kill it with card_kill, which you should do as soon as you're finished with it. Ask for what the purchase needs, not your whole limit: an open card counts against your limit in full. Banks here: ${banks.map((b) => `${b.id} (${b.asset.code}, ${b.label})`).join("; ")}.`, + inputSchema: { + bank: z.string().describe("Which bank, by id"), + limit: z + .string() + .regex(/^\d+(\.\d+)?$/) + .describe("The most it can ever spend, in the bank's asset, e.g. 25.00"), + kind: CardKind, + purpose: z + .string() + .min(1) + .max(200) + .describe("What it's for, e.g. 'Domain renewal at Namecheap'"), + }, + }, + ({ bank: provider, limit, kind, purpose }) => + answer(async () => { + const { card, path, details } = await bank.request(nibblet.id, { + provider, + limit, + kind, + purpose, + }); + const { decimals, code } = card.asset; + const fields = + details === "card" + ? "pan, cvv, expMonth, expYear and maybe billingAddress" + : "chain, network, address and privateKey, funded with the limit; network fees come out of it"; + return text( + `Card ${card.id} is ready: ${card.display}, ${kind === "single_use" ? "single use" : "multi use"}, up to ${formatAmount(card.limit, decimals)} ${code}. Its details are in ${path} (JSON; "details" has ${fields}). Read them from there when you pay, and never paste them into the chat, your memory, other files or your status. Kill it with card_kill when you're done.`, + ); + }), + ); + + server.registerTool( + "card_list", + { + description: + "List your cards, open and closed, with what's been spent on each and where each open card's file is, and how much of your spending limits is left.", + inputSchema: {}, + }, + () => + answer(async () => { + const cards = await bank.cards(nibblet.id); + const allowances = await bank.allowances(nibblet.id); + return json({ + limits: allowances.map((a) => { + const decimals = banks.find((b) => b.asset.code === a.limit.asset)?.asset.decimals; + const show = (m: string) => + decimals === undefined ? `${m} (minor units)` : formatAmount(m, decimals); + return { + asset: a.limit.asset, + per: a.limit.period, + limit: show(a.limit.amount), + left: show(a.remaining), + since: a.since, + }; + }), + cards: cards.map((c) => describeCard(c, bank.pathOf(c.id))), + }); + }), + ); + + server.registerTool( + "card_kill", + { + description: + "Kill one of your cards for good, as soon as you're done with it. A wallet's unspent balance goes back to the treasury, and what wasn't spent is freed up in your limit. Its file is deleted.", + inputSchema: { id: z.string().describe("The card's id, e.g. card_…") }, + }, + ({ id }) => + answer(async () => { + const card = await bank.kill(id, "nibblet", nibblet.id); + if (card.state !== "closed") { + return text( + `Killed ${card.id} (${card.display}): its file is gone and it can't be used again, but ${card.provider} hasn't settled it yet, so it counts against your limit in full until the server finishes closing it.`, + ); + } + return text( + `Killed ${card.id} (${card.display}). ${formatAmount(card.spent, card.asset.decimals)} ${card.asset.code} was spent on it.`, + ); + }), + ); +} + /** Handles `/mcp`: authenticates the nibblet by token and serves its tools statelessly. */ export function mcpHandler(deps: ToolDeps) { return async (c: Context) => { diff --git a/apps/server/src/store.ts b/apps/server/src/store.ts index 812a28dc..86bd8069 100644 --- a/apps/server/src/store.ts +++ b/apps/server/src/store.ts @@ -28,7 +28,20 @@ import { type StoredItem, type ThreadItem, } from "@nibblet/protocol"; -import { and, asc, cosineDistance, desc, eq, gt, inArray, isNull, lt, or, sql } from "drizzle-orm"; +import { + and, + asc, + cosineDistance, + desc, + eq, + gt, + inArray, + isNull, + lt, + ne, + or, + sql, +} from "drizzle-orm"; import { createHash, createHmac, randomBytes } from "node:crypto"; import type { Bus } from "./bus.ts"; import type { Embedder } from "./embeddings.ts"; @@ -446,9 +459,14 @@ export class Store { .orderBy(desc(cards.createdAt)); } - /** Every card that's still open, on any nibblet. */ - async openCards(): Promise { - return this.db.select().from(cards).where(eq(cards.state, "open")); + /** Every card that isn't closed yet, on any nibblet: open, pending or closing. */ + async liveCards(): Promise { + return this.db.select().from(cards).where(ne(cards.state, "closed")); + } + + /** Only for a card that was never made. */ + async deleteCard(id: string): Promise { + await this.db.delete(cards).where(eq(cards.id, id)); } async updateCard( diff --git a/apps/web/src/lib/mock.ts b/apps/web/src/lib/mock.ts index 92ce91ec..20ef075a 100644 --- a/apps/web/src/lib/mock.ts +++ b/apps/web/src/lib/mock.ts @@ -952,6 +952,14 @@ export function createMockServer(): ServerConnection { return { ok: true }; }, }, + bank: { + status: async () => ({ providers: [], problems: [] }), + cards: async () => [], + allowances: async () => [], + kill: async ({ id }) => { + throw new RpcError("not_found", `No card ${id}`); + }, + }, snapshots: { list: async ({ nibbletId }) => { const position = positions.get(nibbletId); diff --git a/packages/protocol/src/bank.ts b/packages/protocol/src/bank.ts index f0f3c527..35aced8d 100644 --- a/packages/protocol/src/bank.ts +++ b/packages/protocol/src/bank.ts @@ -70,8 +70,11 @@ export type CardKind = z.infer; export const CardState = z.enum(["pending", "open", "closing", "closed"]); export type CardState = z.infer; -/** Who closed a card: the nibblet, the person, or the provider (a single-use card that's been used). */ -export const CardClosedBy = z.enum(["nibblet", "user", "provider"]); +/** + * Who closed a card: the nibblet, the person, the provider (a single-use card + * that's been used), or the server (one that never reached its container). + */ +export const CardClosedBy = z.enum(["nibblet", "user", "provider", "server"]); export type CardClosedBy = z.infer; /** A card as the API and tools show it: nothing here can be spent with. */ @@ -91,6 +94,7 @@ export const Card = z.object({ state: CardState, closedBy: CardClosedBy.nullable(), createdAt: Timestamp, + /** When it closed; while it's `closing`, when it was killed. */ closedAt: Timestamp.nullable(), }); export type Card = z.infer; @@ -154,9 +158,24 @@ export const BankProvider = z.object({ }); export type BankProvider = z.infer; -export const BankStatus = z.object({ providers: z.array(BankProvider) }); +export const BankStatus = z.object({ + providers: z.array(BankProvider), + /** Plugins whose settings are there but wrong, with why, so they aren't silently missing. */ + problems: z.array(z.object({ id: BankProviderId, detail: z.string() })), +}); export type BankStatus = z.infer; +/** Where a nibblet stands against its limit in one asset. */ +export const SpendingAllowance = z.object({ + limit: SpendingLimit, + /** Taken by the cards made this period. */ + used: MinorAmount, + remaining: MinorAmount, + /** When the period began. */ + since: Timestamp, +}); +export type SpendingAllowance = z.infer; + /** Parses `12.5` into minor units (`1250` for two decimals). Throws on anything else. */ export function parseAmount(text: string, decimals: number): bigint { const match = /^(\d+)(?:\.(\d+))?$/.exec(text.trim()); diff --git a/packages/protocol/src/contract.ts b/packages/protocol/src/contract.ts index 24d19364..d098d95c 100644 --- a/packages/protocol/src/contract.ts +++ b/packages/protocol/src/contract.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { BankStatus, Card, SpendingAllowance } from "./bank.ts"; import { CliStatuses, HostTools } from "./mcp.ts"; import { HarnessId, HarnessModels } from "./models.ts"; import { ConnectStatus, TailscaleStatus } from "./connect.ts"; @@ -166,6 +167,16 @@ export const contract = { OnePasswordGrants, ), }, + bank: { + /** The bank plugins this server has set up, and what each has left to fund cards from. */ + status: procedure(z.object({}), BankStatus), + /** A nibblet's cards, newest first, open and closed. */ + cards: procedure(z.object({ nibbletId: z.string() }), z.array(Card)), + /** Where it stands against each of its spending limits. */ + allowances: procedure(z.object({ nibbletId: z.string() }), z.array(SpendingAllowance)), + /** Kills a card for good. */ + kill: procedure(Id, Card), + }, snapshots: { /** A nibblet's snapshots, newest first, and where it stands among them. */ list: procedure(z.object({ nibbletId: z.string() }), SnapshotTimeline), @@ -205,6 +216,8 @@ export const LiveMessage = z.discriminatedUnion("type", [ z.object({ type: z.literal("apps"), nibbletId: z.string(), apps: z.array(DesktopApp) }), /** The thread was replaced (a snapshot was restored): drop it and fetch again from the start. */ z.object({ type: z.literal("thread_reset"), nibbletId: z.string() }), + /** One of its cards was made, killed, or spent on. */ + z.object({ type: z.literal("cards_changed"), nibbletId: z.string() }), z.object({ type: z.literal("snapshots_changed"), nibbletId: z.string(), diff --git a/packages/protocol/src/runner.ts b/packages/protocol/src/runner.ts index 62fdce7b..002a681e 100644 --- a/packages/protocol/src/runner.ts +++ b/packages/protocol/src/runner.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { CardFile } from "./bank.ts"; import { Effort, HarnessId } from "./models.ts"; import { CliLogin, McpServerDef, ToolRule } from "./mcp.ts"; import { SharedVault } from "./onepassword.ts"; @@ -172,6 +173,13 @@ export const ServerToRunner = z.discriminatedUnion("type", [ z.object({ type: z.literal("task_stop"), taskId: z.string() }), /** Look again for desktop apps, and send them in an `apps`. */ z.object({ type: z.literal("apps_scan") }), + /** + * Write a card's file where the agent can read it, replacing any it had. + * The runner answers with `card_done`. + */ + z.object({ type: z.literal("card_put"), requestId: z.string(), file: CardFile }), + /** Delete a card's file, once it's been killed. Answered with `card_done`. */ + z.object({ type: z.literal("card_remove"), requestId: z.string(), cardId: z.string() }), ]); export type ServerToRunner = z.infer; @@ -215,6 +223,13 @@ export const RunnerToServer = z.discriminatedUnion("type", [ z.object({ type: z.literal("tasks"), tasks: z.array(BackgroundTask) }), /** The desktop apps installed in its container, on every connect and whenever they change. */ z.object({ type: z.literal("apps"), apps: z.array(DesktopApp) }), + /** A `card_put` or `card_remove` done: where the file is (for a put), or why it couldn't be. */ + z.object({ + type: z.literal("card_done"), + requestId: z.string(), + path: z.string().nullable(), + error: z.string().nullable(), + }), z.object({ type: z.literal("log"), level: z.enum(["debug", "info", "warn", "error"]), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fa75d0b..9c837cd0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -318,6 +318,9 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.32.1 version: 1.32.1(supports-color@7.2.0)(zod@4.6.7) + '@nibblet/bank': + specifier: workspace:* + version: link:../../packages/bank '@nibblet/db': specifier: workspace:* version: link:../../packages/db