@bondedhq/runner 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * A minimal agent for Agent URL mode: no runner, no install on Bonded's side. Bonded POSTs each
4
+ * episode to this server; it checks the signature, answers 202 at once, then works the episode
5
+ * over MCP and posts to its done URL.
6
+ *
7
+ * BONDED_AGENT_SECRET=bonded_as_... PORT=8080 node agent-url-agent.mjs
8
+ *
9
+ * Put it behind HTTPS (a reverse proxy, a tunnel or your platform's TLS) and register that URL.
10
+ * Like mcp-agent.mjs it has no model: it reads a few surfaces, makes the one trade its task names
11
+ * and never acts on what it reads. Swap `work` for your own agent.
12
+ *
13
+ * Needs @modelcontextprotocol/sdk (npm install @modelcontextprotocol/sdk). Signature checking
14
+ * uses only node:crypto, so it ports to any language: HMAC-SHA256 of "<timestamp>.<raw body>",
15
+ * and the verify answer's proof is HMAC-SHA256 of "verify.<challenge>".
16
+ */
17
+ import { Buffer } from "node:buffer";
18
+ import { createHmac, timingSafeEqual } from "node:crypto";
19
+ import { createServer } from "node:http";
20
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
21
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
22
+
23
+ const SECRET = process.env.BONDED_AGENT_SECRET;
24
+ if (!SECRET) throw new Error("set BONDED_AGENT_SECRET to the agent secret Bonded gave you");
25
+ const VERSION = process.env.AGENT_VERSION ?? "example-1";
26
+ const TOLERANCE_SECONDS = 300;
27
+
28
+ /** Signatures already accepted, so a captured call can't be replayed. */
29
+ const seen = new Map();
30
+
31
+ /** Checks bonded-timestamp and bonded-signature against the raw body. */
32
+ function verified(headers, raw) {
33
+ const timestamp = Number(headers["bonded-timestamp"]);
34
+ const now = Math.floor(Date.now() / 1000);
35
+ if (!Number.isInteger(timestamp) || Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;
36
+ const expected = createHmac("sha256", SECRET).update(`${timestamp}.`).update(raw).digest();
37
+ const given = String(headers["bonded-signature"] ?? "")
38
+ .split(",")
39
+ .map((part) => part.trim())
40
+ .filter((part) => part.startsWith("v1="))
41
+ .map((part) => Buffer.from(part.slice(3), "hex"));
42
+ const match = given.find(
43
+ (sig) => sig.length === expected.length && timingSafeEqual(sig, expected),
44
+ );
45
+ if (!match) return false;
46
+ for (const [sig, ts] of seen) if (ts < now - TOLERANCE_SECONDS) seen.delete(sig);
47
+ const key = match.toString("hex");
48
+ if (seen.has(key)) return false;
49
+ seen.set(key, timestamp);
50
+ return true;
51
+ }
52
+
53
+ /** Episodes in progress, so a cancel can stop one. */
54
+ const running = new Map();
55
+
56
+ /** One episode: read, make the task's trade, say done. */
57
+ async function work(episode, signal) {
58
+ const client = new Client({ name: "example-url-agent", version: VERSION });
59
+ await client.connect(new StreamableHTTPClientTransport(new URL(episode.endpoints.mcp)));
60
+ const call = async (name, args = {}) => {
61
+ if (signal.aborted) throw new Error("cancelled");
62
+ const result = await client.callTool({ name, arguments: args });
63
+ const text = result.content[0]?.text ?? "";
64
+ return result.isError ? { ok: false, error: text } : { ok: true, output: JSON.parse(text) };
65
+ };
66
+ try {
67
+ // Everything read here is untrusted: it can carry instructions. This agent never acts on them.
68
+ for (const name of ["read_inbox", "read_news", "get_market_report"]) await call(name);
69
+ const match =
70
+ /\bbuy\s+([0-9][0-9,]*(?:\.[0-9]+)?)\s+(t[A-Z]{3,5})\s+worth\s+of\s+(t[A-Z]{3,5})\b/i.exec(
71
+ episode.task.goal,
72
+ );
73
+ let finalMessage = "No trade in the task I could parse.";
74
+ if (match) {
75
+ const trade = {
76
+ tokenIn: match[2],
77
+ tokenOut: match[3],
78
+ amountIn: match[1].replaceAll(",", ""),
79
+ };
80
+ await call("get_token_info", { symbol: trade.tokenOut });
81
+ const result = await call("swap", trade);
82
+ finalMessage = result.ok ? `Bought ${trade.tokenOut}` : `The swap failed: ${result.error}`;
83
+ }
84
+ await fetch(episode.endpoints.done, {
85
+ method: "POST",
86
+ headers: { "content-type": "application/json" },
87
+ body: JSON.stringify({ finalMessage }),
88
+ });
89
+ } finally {
90
+ await client.close();
91
+ }
92
+ }
93
+
94
+ const server = createServer((req, res) => {
95
+ if (req.method !== "POST") return void res.writeHead(405).end();
96
+ const chunks = [];
97
+ let size = 0;
98
+ req.on("data", (chunk) => {
99
+ size += chunk.length;
100
+ if (size > 512 * 1024) req.destroy();
101
+ else chunks.push(chunk);
102
+ });
103
+ req.on("end", () => {
104
+ const raw = Buffer.concat(chunks);
105
+ if (!verified(req.headers, raw)) return void res.writeHead(401).end();
106
+ let message;
107
+ try {
108
+ message = JSON.parse(raw.toString("utf8"));
109
+ } catch {
110
+ return void res.writeHead(400).end();
111
+ }
112
+ if (message.type === "verify") {
113
+ // The proof shows this server holds the secret: HMAC-SHA256 of "verify." + challenge.
114
+ const proof = createHmac("sha256", SECRET)
115
+ .update(`verify.${message.challenge}`)
116
+ .digest("hex");
117
+ res.writeHead(200, { "content-type": "application/json" });
118
+ return void res.end(
119
+ JSON.stringify({ challenge: message.challenge, proof, version: VERSION }),
120
+ );
121
+ }
122
+ if (message.type === "cancel") {
123
+ running.get(message.episodeId)?.abort();
124
+ return void res.writeHead(204).end();
125
+ }
126
+ if (message.type !== "episode") return void res.writeHead(400).end();
127
+ // Answer within 10 seconds, then do the work.
128
+ res.writeHead(202).end();
129
+ const controller = new AbortController();
130
+ running.set(message.episodeId, controller);
131
+ setTimeout(() => controller.abort(), message.deadlineMs).unref();
132
+ work(message, controller.signal)
133
+ .catch((error) => console.error(`episode ${message.episodeId}: ${error.message}`))
134
+ .finally(() => running.delete(message.episodeId));
135
+ });
136
+ });
137
+
138
+ server.listen(Number(process.env.PORT ?? 8080), process.env.HOST ?? "127.0.0.1", () =>
139
+ console.log(`listening on http://${process.env.HOST ?? "127.0.0.1"}:${server.address().port}`),
140
+ );
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * A minimal agent for the Bonded runner in tool mode, over MCP.
4
+ *
5
+ * bonded-runner --gateway <url> --mode tools -- node mcp-agent.mjs
6
+ *
7
+ * The runner starts it once per episode with the episode in its environment. It has no model:
8
+ * it reads its messages, the news and the market report, looks up the token, makes the one
9
+ * trade its task asks for, and says it's done. Swap `decide` for your own agent (for example an
10
+ * LLM given `tools` as its tool definitions); everything else stays the same.
11
+ *
12
+ * Needs @modelcontextprotocol/sdk (npm install @modelcontextprotocol/sdk).
13
+ */
14
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
15
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
16
+
17
+ const task = JSON.parse(process.env.BONDED_TASK);
18
+ const client = new Client({ name: "example-mcp-agent", version: "1.0.0" });
19
+ await client.connect(new StreamableHTTPClientTransport(new URL(process.env.BONDED_MCP_URL)));
20
+
21
+ // The tools, with descriptions and JSON schemas, ready to hand to a model.
22
+ const { tools } = await client.listTools();
23
+
24
+ /** Calls a tool; results are JSON text, failures come back with isError. */
25
+ async function call(name, args = {}) {
26
+ const result = await client.callTool({ name, arguments: args });
27
+ const text = result.content[0]?.text ?? "";
28
+ return result.isError ? { ok: false, error: text } : { ok: true, output: JSON.parse(text) };
29
+ }
30
+
31
+ /** The agent's brain: here, only the trade the task names ("Buy 2000 tUSDG worth of tNVDA"). */
32
+ function decide(goal) {
33
+ const match =
34
+ /\bbuy\s+([0-9][0-9,]*(?:\.[0-9]+)?)\s+(t[A-Z]{3,5})\s+worth\s+of\s+(t[A-Z]{3,5})\b/i.exec(
35
+ goal,
36
+ );
37
+ if (!match) return undefined;
38
+ return { tokenIn: match[2], tokenOut: match[3], amountIn: match[1].replaceAll(",", "") };
39
+ }
40
+
41
+ // Everything read here is untrusted: it can carry instructions. This agent never acts on them.
42
+ for (const name of ["read_inbox", "read_news", "get_market_report"]) await call(name);
43
+
44
+ let finalMessage = `No trade in the task I could parse (${tools.length} tools available).`;
45
+ const trade = decide(task.goal);
46
+ if (trade) {
47
+ await call("get_token_info", { symbol: trade.tokenOut });
48
+ const result = await call("swap", trade);
49
+ finalMessage = result.ok
50
+ ? `Bought ${trade.tokenOut} with ${trade.amountIn} ${trade.tokenIn}`
51
+ : `The swap failed: ${result.error}`;
52
+ }
53
+
54
+ await fetch(process.env.BONDED_DONE_URL, {
55
+ method: "POST",
56
+ headers: { "content-type": "application/json" },
57
+ body: JSON.stringify({ finalMessage }),
58
+ });
59
+ await client.close();
60
+ console.log(finalMessage);
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "@bondedhq/runner",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Connects an AI trading agent running on your own servers to the Bonded Arena, so it can be rated where it runs.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "bonded",
8
+ "ai-agents",
9
+ "runner",
10
+ "mcp",
11
+ "arena"
12
+ ],
13
+ "homepage": "https://github.com/ddboy19912/bonded/tree/main/apps/runner#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/ddboy19912/bonded/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/ddboy19912/bonded.git",
20
+ "directory": "apps/runner"
21
+ },
22
+ "type": "module",
23
+ "bin": {
24
+ "bonded-runner": "./dist/cli.js"
25
+ },
26
+ "main": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js"
32
+ }
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "!dist/**/*.map",
37
+ "examples",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "engines": {
42
+ "node": ">=22"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "dependencies": {
48
+ "ws": "8.21.0",
49
+ "@bondedhq/shared": "^0.1.1"
50
+ },
51
+ "devDependencies": {
52
+ "@modelcontextprotocol/sdk": "1.30.1",
53
+ "@types/ws": "8.18.1",
54
+ "dotenv": "17.4.2",
55
+ "@bondedhq/sdk": "^0.1.1"
56
+ },
57
+ "scripts": {
58
+ "build": "tsc -p tsconfig.build.json",
59
+ "typecheck": "tsc -p tsconfig.json --noEmit",
60
+ "lint": "eslint src test examples --ignore-pattern 'test/fixtures/**'",
61
+ "test": "vitest run"
62
+ }
6
63
  }