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
15 changes: 15 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<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.
- **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/<dotted.path>`. Live updates are a WebSocket at `/api/live` pushing `LiveMessage`s. Browsers authenticate with `Authorization: Bearer <token>`; WebSockets take `?ticket=` from `POST /api/ticket`, which lasts 30 seconds, so long-lived tokens never go in URLs.
Expand Down
64 changes: 64 additions & 0 deletions apps/runner/src/cards.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
62 changes: 62 additions & 0 deletions apps/runner/src/cards.ts
Original file line number Diff line number Diff line change
@@ -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<string, Promise<unknown>>();

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<string> {
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<void> {
const path = this.pathOf(cardId);
return this.serially(cardId, async () => {
await rm(path, { force: true });
await rm(`${path}.partial`, { force: true });
});
}

private serially<T>(cardId: string, work: () => Promise<T>): Promise<T> {
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`);
}
}
22 changes: 22 additions & 0 deletions apps/runner/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string | null>): Promise<void> {
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),
Expand Down Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading