Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ A turn the user didn't start (a heartbeat, a schedule, a sub-agent's result, car

## 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`). Each plugin's factory describes its own settings form (`fields`: labels, hints, secrets, choices, keys the form can make, and fields that can't change while cards are open), so Settings → Bank sets any of them up without the web app knowing one bank from another. Saving checks the settings with the provider (`check`), keeps them in the `settings` table (`banks`, secrets never sent back), and loads the plugin without a restart (`apps/server/src/banks.ts`, which lists the plugins). A bank with no saved settings falls back to the server's environment, whose variables are named like its fields. A plugin holds its provider's credentials, which never leave the server. Settings shows each bank, its balance, where to top a treasury up (`fundAt`), and any whose settings are wrong. A bank can't be removed, or have its locked fields changed, while it has cards that aren't closed.
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`). Each plugin's factory describes its own settings form (`fields`: labels, hints, secrets, choices, keys the form can make, and fields that can't change while cards are open), so Settings → Bank sets any of them up without the web app knowing one bank from another. Saving checks the settings with the provider (`check`), keeps them in the `settings` table (`banks`, secrets never sent back), and loads the plugin without a restart (`apps/server/src/banks.ts`, which lists the plugins). There can be several banks on one plugin (two Slash accounts, or a signet and a mainnet treasury), each with its own id, settings and name; one per plugin can go without a name. The first bank on a plugin gets the plugin's own id (`slash`) and later ones one from their name (`slash-travel`), and a card keeps its bank's id, so renaming a bank never strands its cards. A plugin with no saved bank under its own id also gets one from the server's environment, whose variables are named like its fields. A plugin holds its provider's credentials, which never leave the server. Settings shows each bank, its balance, where to top a treasury up (`fundAt`), and any whose settings are wrong. A bank can't be removed, or have its locked fields changed, while it has cards that aren't closed.

- **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/<id>.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.
Expand Down
121 changes: 90 additions & 31 deletions apps/server/src/banks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,81 +89,140 @@ function setUp(env: Record<string, string> = {}) {
return { banks, settings, live, loaded, logs };
}

/** A change to the fake bank: a new one, unless `id` says which. */
const change = (settings: Record<string, string>, more: { id?: string; name?: string } = {}) => ({
id: more.id ?? null,
kind: "fake",
name: more.name ?? "",
settings,
});

describe("setting banks up", () => {
it("saves settings the provider takes, and loads the bank with them", async () => {
const { banks, settings, loaded } = setUp();
await banks.load();
expect(loaded).toEqual([[]]);
const status = await banks.configure("fake", { FAKE_KEY: "k1", FAKE_ACCOUNT: "acc" });
expect(settings.get("banks")).toEqual({ fake: { FAKE_KEY: "k1", FAKE_ACCOUNT: "acc" } });
const status = await banks.configure(change({ FAKE_KEY: "k1", FAKE_ACCOUNT: "acc" }));
expect(settings.get("banks")).toEqual([
{ id: "fake", kind: "fake", name: "", settings: { FAKE_KEY: "k1", FAKE_ACCOUNT: "acc" } },
]);
expect(loaded.at(-1)).toEqual(["fake"]);
expect(status.providers.map((p) => p.label)).toEqual(["Fake (acc)"]);
expect(status.kinds).toEqual([
expect.objectContaining({
expect(status.kinds.map((k) => k.id)).toEqual(["fake"]);
expect(status.banks).toEqual([
{
id: "fake",
name: "Fake",
kind: "fake",
name: "",
source: "saved",
settings: { FAKE_KEY: MASKED, FAKE_ACCOUNT: "acc" },
liveCards: 0,
}),
},
]);
});

it("keeps a secret sent back masked", async () => {
const { banks, settings } = setUp();
await banks.configure("fake", { FAKE_KEY: "k1" });
await banks.configure("fake", { FAKE_KEY: MASKED, FAKE_ACCOUNT: "acc" });
expect(settings.get("banks")).toEqual({ fake: { FAKE_KEY: "k1", FAKE_ACCOUNT: "acc" } });
await banks.configure(change({ FAKE_KEY: "k1" }));
await banks.configure(change({ FAKE_KEY: MASKED, FAKE_ACCOUNT: "acc" }, { id: "fake" }));
expect(settings.get("banks")).toEqual([
{ id: "fake", kind: "fake", name: "", settings: { FAKE_KEY: "k1", FAKE_ACCOUNT: "acc" } },
]);
});

it("sets up several banks on one plugin, each with a name and its own id", async () => {
const { banks, loaded } = setUp();
await banks.configure(change({ FAKE_KEY: "k1" }));
await expect(banks.configure(change({ FAKE_KEY: "k2" }))).rejects.toThrow(
"Give it a name, to tell it from your other Fake bank",
);
await banks.configure(change({ FAKE_KEY: "k2" }, { name: "Travel" }));
await expect(banks.configure(change({ FAKE_KEY: "k3" }, { name: "travel" }))).rejects.toThrow(
"There's already a Fake bank called travel",
);
const status = await banks.configure(change({ FAKE_KEY: "k3" }, { name: "Ops" }));
expect(status.banks.map((b) => [b.id, b.name])).toEqual([
["fake", ""],
["fake-travel", "Travel"],
["fake-ops", "Ops"],
]);
expect(loaded.at(-1)).toEqual(["fake", "fake-travel", "fake-ops"]);
// Renaming keeps its id, which its cards point at.
const renamed = await banks.configure(
change({ FAKE_KEY: MASKED }, { id: "fake-travel", name: "Holidays" }),
);
expect(renamed.banks.map((b) => [b.id, b.name])).toContainEqual(["fake-travel", "Holidays"]);
});

it("saves nothing the provider refuses, saying why", async () => {
const { banks, settings, logs } = setUp();
await expect(banks.configure("fake", { FAKE_KEY: "" })).rejects.toThrow("API key is needed");
await expect(banks.configure("fake", { FAKE_KEY: "short" })).rejects.toThrow(
await expect(banks.configure(change({ FAKE_KEY: "" }))).rejects.toThrow("API key is needed");
await expect(banks.configure(change({ FAKE_KEY: "short" }))).rejects.toThrow(
"API key is too short",
);
await expect(banks.configure("fake", { FAKE_KEY: "wrong" })).rejects.toThrow(
await expect(banks.configure(change({ FAKE_KEY: "wrong" }))).rejects.toThrow(
"Fake: Invalid API key",
);
// Anything but a BankError could carry a credential, so it's only logged.
const down = banks.configure("fake", { FAKE_KEY: "down" });
const down = banks.configure(change({ FAKE_KEY: "down" }));
await expect(down).rejects.toThrow("Fake didn't answer; the server's log has why");
expect(logs.join("\n")).toContain("key=down");
await expect(banks.configure("nope", {})).rejects.toThrow("No bank called nope");
await expect(banks.configure({ ...change({}), kind: "nope" })).rejects.toThrow(
"No bank plugin called nope",
);
await expect(banks.configure(change({ FAKE_KEY: "k" }, { id: "nope" }))).rejects.toThrow(
"No bank called nope",
);
expect(settings.has("banks")).toBe(false);
});

it("won't change what open cards depend on, or remove a bank they need", async () => {
const { banks, live, settings } = setUp();
await banks.configure("fake", { FAKE_KEY: "k1", FAKE_ACCOUNT: "acc" });
live.push({ provider: "fake" }, { provider: "other" });
await banks.configure(change({ FAKE_KEY: "k1", FAKE_ACCOUNT: "acc" }));
await banks.configure(change({ FAKE_KEY: "k2" }, { name: "Travel" }));
live.push({ provider: "fake-travel" }, { provider: "other" });
await expect(
banks.configure("fake", { FAKE_KEY: MASKED, FAKE_ACCOUNT: "acc2" }),
).rejects.toThrow(
"Account can't change while Fake has a card that isn't closed: kill them first.",
banks.configure(
change({ FAKE_KEY: MASKED, FAKE_ACCOUNT: "x" }, { id: "fake-travel", name: "Travel" }),
),
).rejects.toThrow("Account can't change while Travel (Fake) has a card that isn't closed");
// Its other fields still can, and the bank without cards can change anything.
await banks.configure(change({ FAKE_KEY: "k3" }, { id: "fake-travel", name: "Travel" }));
await banks.configure(change({ FAKE_KEY: MASKED, FAKE_ACCOUNT: "y" }, { id: "fake" }));
await expect(banks.remove("fake-travel")).rejects.toThrow(
"Travel (Fake) has a card that isn't closed, and closing them needs it",
);
// Fields that aren't locked still can.
await banks.configure("fake", { FAKE_KEY: "k2", FAKE_ACCOUNT: "acc" });
await expect(banks.remove("fake")).rejects.toThrow(
"Fake has a card that isn't closed, and closing them needs it",
);
live.length = 0;
await banks.remove("fake");
expect(settings.get("banks")).toEqual({});
live.length = 0;
await banks.remove("fake-travel");
expect(settings.get("banks")).toEqual([]);
});

it("falls back to the environment, and lets Settings take over from it", async () => {
const { banks, loaded } = setUp({ FAKE_KEY: "from-env" });
await banks.load();
expect(loaded).toEqual([["fake"]]);
expect((await banks.status()).kinds[0]).toMatchObject({
expect((await banks.status()).banks[0]).toMatchObject({
id: "fake",
source: "env",
settings: { FAKE_KEY: MASKED },
});
// Saving keeps the environment's key when it's sent back masked.
await banks.configure("fake", { FAKE_KEY: MASKED, FAKE_ACCOUNT: "acc" });
expect((await banks.status()).kinds[0]).toMatchObject({ source: "saved" });
await expect(banks.remove("fake")).rejects.toThrow(/server's environment/);
// A second bank needs a name, and leaves the environment's in place.
await banks.configure(change({ FAKE_KEY: "k2" }, { name: "Travel" }));
expect((await banks.status()).banks.map((b) => [b.id, b.source])).toEqual([
["fake-travel", "saved"],
["fake", "env"],
]);
// Saving the environment's keeps its key when it's sent back masked, and takes it over.
await banks.configure(change({ FAKE_KEY: MASKED, FAKE_ACCOUNT: "acc" }, { id: "fake" }));
expect((await banks.status()).banks.find((b) => b.id === "fake")).toMatchObject({
source: "saved",
settings: { FAKE_KEY: MASKED, FAKE_ACCOUNT: "acc" },
});
await banks.remove("fake");
expect((await banks.status()).kinds[0]).toMatchObject({ source: "env" });
expect((await banks.status()).banks.find((b) => b.id === "fake")).toMatchObject({
source: "env",
});
});
});
Loading
Loading