@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.
- package/LICENSE +21 -0
- package/README.md +274 -2
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +120 -0
- package/dist/code-hash.d.ts +54 -0
- package/dist/code-hash.js +110 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/init/apply.d.ts +22 -0
- package/dist/init/apply.js +73 -0
- package/dist/init/check.d.ts +79 -0
- package/dist/init/check.js +158 -0
- package/dist/init/command.d.ts +34 -0
- package/dist/init/command.js +314 -0
- package/dist/init/detect.d.ts +34 -0
- package/dist/init/detect.js +184 -0
- package/dist/init/diff.d.ts +6 -0
- package/dist/init/diff.js +98 -0
- package/dist/init/plan.d.ts +44 -0
- package/dist/init/plan.js +652 -0
- package/dist/runner.d.ts +78 -0
- package/dist/runner.js +319 -0
- package/dist/service.d.ts +24 -0
- package/dist/service.js +169 -0
- package/dist/tail.d.ts +21 -0
- package/dist/tail.js +50 -0
- package/examples/agent-url-agent.mjs +140 -0
- package/examples/mcp-agent.mjs +60 -0
- package/package.json +60 -3
package/dist/runner.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type AgentMode } from "@bondedhq/shared";
|
|
2
|
+
import { type CodeIdentity } from "./code-hash.js";
|
|
3
|
+
/**
|
|
4
|
+
* The Bonded runner: lets the Arena rate an agent that runs on its operator's own servers.
|
|
5
|
+
*
|
|
6
|
+
* It connects out to the Arena Gateway (no inbound port needed), says which mode the agent works
|
|
7
|
+
* in, and waits. For each episode the gateway sends, it launches the operator's agent command
|
|
8
|
+
* with the episode's endpoints in its environment (`BONDED_*`, see `episodeEnv`), reports when
|
|
9
|
+
* it started and how it ended (exit code and the last 4 KB of its output), and stops it when the
|
|
10
|
+
* gateway cancels or the deadline passes. One episode at a time.
|
|
11
|
+
*
|
|
12
|
+
* The runner never sees the agent's tool calls or transactions: the agent talks to the gateway
|
|
13
|
+
* directly. The runner token stays with the runner: it is not passed to the agent.
|
|
14
|
+
*/
|
|
15
|
+
export interface RunnerOptions {
|
|
16
|
+
/** The gateway's base URL, e.g. https://arena.example.com. */
|
|
17
|
+
gateway: string;
|
|
18
|
+
token: string;
|
|
19
|
+
mode: AgentMode;
|
|
20
|
+
/** The agent's TEE: the runner fetches its evidence for each episode's nonce. */
|
|
21
|
+
teeUrl?: string;
|
|
22
|
+
/** The agent command and its arguments. */
|
|
23
|
+
command: string[];
|
|
24
|
+
/** Where the runner says what it is doing. */
|
|
25
|
+
log?: (line: string) => void;
|
|
26
|
+
/** Copy the agent's output to the runner's own stdout and stderr (default true). */
|
|
27
|
+
echo?: boolean;
|
|
28
|
+
version?: string;
|
|
29
|
+
/**
|
|
30
|
+
* The code version it launches, reported in hello. Default: worked out from the command
|
|
31
|
+
* (`codeIdentity`: BONDED_CODE_HASH, BONDED_IMAGE_DIGEST, the git commit, or unpinned).
|
|
32
|
+
*/
|
|
33
|
+
code?: CodeIdentity;
|
|
34
|
+
/** Where the agent command runs. Default: the runner's own working directory. */
|
|
35
|
+
cwd?: string;
|
|
36
|
+
/** Called each time the runner connects to the gateway (after it says hello). */
|
|
37
|
+
onConnect?: () => void;
|
|
38
|
+
heartbeatMs?: number;
|
|
39
|
+
backoff?: {
|
|
40
|
+
initialMs: number;
|
|
41
|
+
maxMs: number;
|
|
42
|
+
};
|
|
43
|
+
killGraceMs?: number;
|
|
44
|
+
}
|
|
45
|
+
/** Why the runner stopped for good. */
|
|
46
|
+
export interface RunnerStop {
|
|
47
|
+
/** 0: stopped on request; 2: the gateway refused the runner and retrying won't help. */
|
|
48
|
+
code: 0 | 2;
|
|
49
|
+
reason: string;
|
|
50
|
+
}
|
|
51
|
+
export declare class Runner {
|
|
52
|
+
private readonly options;
|
|
53
|
+
private socket;
|
|
54
|
+
private running;
|
|
55
|
+
private stopped;
|
|
56
|
+
private wake;
|
|
57
|
+
private readonly outbox;
|
|
58
|
+
private readonly log;
|
|
59
|
+
/** The code hash it reports, and where it came from. */
|
|
60
|
+
readonly code: CodeIdentity;
|
|
61
|
+
constructor(options: RunnerOptions);
|
|
62
|
+
/** Connects, and keeps reconnecting with backoff, until stopped or refused for good. */
|
|
63
|
+
start(): Promise<RunnerStop>;
|
|
64
|
+
/** Stops the agent (if one is running) and disconnects. */
|
|
65
|
+
stop(): Promise<void>;
|
|
66
|
+
private url;
|
|
67
|
+
private connectOnce;
|
|
68
|
+
/** Sends now if connected; otherwise keeps the message for the next connection. */
|
|
69
|
+
private write;
|
|
70
|
+
private runEpisode;
|
|
71
|
+
/** Runs the agent command to completion (or until it is killed). */
|
|
72
|
+
private launch;
|
|
73
|
+
/** SIGTERM to the agent's process group, then SIGKILL after the grace period. */
|
|
74
|
+
private cancelRunning;
|
|
75
|
+
/** Gets the TEE's evidence for the episode's nonce and forwards it as-is. */
|
|
76
|
+
private sendEvidence;
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=runner.d.ts.map
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { episodeEnv, parseRunnerMessage, RUNNER_CLOSE, RUNNER_CONNECT_PATH, RUNNER_HEARTBEAT_MS, RUNNER_KILL_GRACE_MS, RUNNER_MAX_MESSAGE_BYTES, RUNNER_PROTOCOL_VERSION, RUNNER_TAIL_BYTES, ServerToRunner, } from "@bondedhq/shared";
|
|
3
|
+
import { WebSocket } from "ws";
|
|
4
|
+
import { codeIdentity } from "./code-hash.js";
|
|
5
|
+
import { backoffDelay, gatewayUrlProblem, Tail } from "./tail.js";
|
|
6
|
+
/** Messages the runner keeps while disconnected, to send on reconnect (newest kept). */
|
|
7
|
+
const OUTBOX_LIMIT = 16;
|
|
8
|
+
export class Runner {
|
|
9
|
+
options;
|
|
10
|
+
socket;
|
|
11
|
+
running;
|
|
12
|
+
stopped = false;
|
|
13
|
+
wake;
|
|
14
|
+
outbox = [];
|
|
15
|
+
log;
|
|
16
|
+
/** The code hash it reports, and where it came from. */
|
|
17
|
+
code;
|
|
18
|
+
constructor(options) {
|
|
19
|
+
this.options = options;
|
|
20
|
+
if (options.command.length === 0)
|
|
21
|
+
throw new Error("no agent command given");
|
|
22
|
+
const problem = gatewayUrlProblem(options.gateway);
|
|
23
|
+
if (problem)
|
|
24
|
+
throw new Error(problem);
|
|
25
|
+
this.log = options.log ?? (() => { });
|
|
26
|
+
this.code = options.code ?? codeIdentity({ command: options.command });
|
|
27
|
+
}
|
|
28
|
+
/** Connects, and keeps reconnecting with backoff, until stopped or refused for good. */
|
|
29
|
+
async start() {
|
|
30
|
+
const backoff = this.options.backoff ?? { initialMs: 1_000, maxMs: 30_000 };
|
|
31
|
+
let attempt = 0;
|
|
32
|
+
while (!this.stopped) {
|
|
33
|
+
const end = await this.connectOnce();
|
|
34
|
+
if (end.fatal) {
|
|
35
|
+
await this.cancelRunning("the runner stopped");
|
|
36
|
+
return { code: 2, reason: end.fatal };
|
|
37
|
+
}
|
|
38
|
+
if (this.stopped)
|
|
39
|
+
break;
|
|
40
|
+
attempt = end.stable ? 1 : attempt + 1;
|
|
41
|
+
const delay = backoffDelay(attempt, backoff);
|
|
42
|
+
this.log(`disconnected; reconnecting in ${(delay / 1000).toFixed(1)} s`);
|
|
43
|
+
await new Promise((resolve) => {
|
|
44
|
+
const timer = setTimeout(resolve, delay);
|
|
45
|
+
this.wake = () => {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
resolve();
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
this.wake = undefined;
|
|
51
|
+
}
|
|
52
|
+
return { code: 0, reason: "stopped" };
|
|
53
|
+
}
|
|
54
|
+
/** Stops the agent (if one is running) and disconnects. */
|
|
55
|
+
async stop() {
|
|
56
|
+
this.stopped = true;
|
|
57
|
+
this.wake?.();
|
|
58
|
+
await this.cancelRunning("the runner stopped");
|
|
59
|
+
this.socket?.close(1000, "runner stopping");
|
|
60
|
+
}
|
|
61
|
+
url() {
|
|
62
|
+
const base = this.options.gateway.replace(/\/+$/, "").replace(/^http/, "ws");
|
|
63
|
+
return `${base}${RUNNER_CONNECT_PATH}`;
|
|
64
|
+
}
|
|
65
|
+
connectOnce() {
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
const socket = new WebSocket(this.url(), {
|
|
68
|
+
headers: {
|
|
69
|
+
authorization: `Bearer ${this.options.token}`,
|
|
70
|
+
"user-agent": `bonded-runner/${this.options.version ?? "dev"}`,
|
|
71
|
+
},
|
|
72
|
+
maxPayload: RUNNER_MAX_MESSAGE_BYTES,
|
|
73
|
+
handshakeTimeout: 15_000,
|
|
74
|
+
});
|
|
75
|
+
this.socket = socket;
|
|
76
|
+
let openedAt = 0;
|
|
77
|
+
let fatal;
|
|
78
|
+
let heartbeat;
|
|
79
|
+
let lastPong = Date.now();
|
|
80
|
+
const heartbeatMs = this.options.heartbeatMs ?? RUNNER_HEARTBEAT_MS;
|
|
81
|
+
socket.on("unexpected-response", (_, res) => {
|
|
82
|
+
if (res.statusCode === 401)
|
|
83
|
+
fatal = "the gateway refused the runner token (wrong or revoked)";
|
|
84
|
+
else
|
|
85
|
+
this.log(`the gateway answered ${res.statusCode}`);
|
|
86
|
+
socket.terminate();
|
|
87
|
+
});
|
|
88
|
+
socket.on("open", () => {
|
|
89
|
+
openedAt = Date.now();
|
|
90
|
+
lastPong = Date.now();
|
|
91
|
+
this.log(`connected to ${this.options.gateway} (${this.options.mode} mode)`);
|
|
92
|
+
this.write({
|
|
93
|
+
type: "hello",
|
|
94
|
+
runnerVersion: this.options.version ?? "dev",
|
|
95
|
+
protocol: RUNNER_PROTOCOL_VERSION,
|
|
96
|
+
mode: this.options.mode,
|
|
97
|
+
...(this.options.teeUrl ? { teeUrl: this.options.teeUrl } : {}),
|
|
98
|
+
codeHash: this.code.codeHash,
|
|
99
|
+
codeSource: this.code.codeSource,
|
|
100
|
+
codeDetail: this.code.detail.slice(0, 200),
|
|
101
|
+
});
|
|
102
|
+
for (const message of this.outbox.splice(0))
|
|
103
|
+
this.write(message);
|
|
104
|
+
this.options.onConnect?.();
|
|
105
|
+
heartbeat = setInterval(() => {
|
|
106
|
+
if (Date.now() - lastPong > 3 * heartbeatMs) {
|
|
107
|
+
this.log("no answer from the gateway; reconnecting");
|
|
108
|
+
socket.terminate();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
this.write({ type: "ping", at: Date.now() });
|
|
112
|
+
}, heartbeatMs);
|
|
113
|
+
});
|
|
114
|
+
socket.on("message", (data, isBinary) => {
|
|
115
|
+
const message = isBinary ? undefined : parseRunnerMessage(ServerToRunner, String(data));
|
|
116
|
+
if (!message) {
|
|
117
|
+
this.log("ignored a message from the gateway that didn't parse");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (message.type === "pong")
|
|
121
|
+
lastPong = Date.now();
|
|
122
|
+
else if (message.type === "episode")
|
|
123
|
+
void this.runEpisode(message);
|
|
124
|
+
else if (message.type === "cancel" && this.running?.episodeId === message.episodeId)
|
|
125
|
+
void this.cancelRunning("the gateway cancelled the episode");
|
|
126
|
+
});
|
|
127
|
+
socket.on("error", (error) => this.log(`connection error: ${error.message}`));
|
|
128
|
+
socket.on("close", (code, reason) => {
|
|
129
|
+
clearInterval(heartbeat);
|
|
130
|
+
if (this.socket === socket)
|
|
131
|
+
this.socket = undefined;
|
|
132
|
+
const why = reason.toString();
|
|
133
|
+
if (code === RUNNER_CLOSE.replaced)
|
|
134
|
+
fatal = "another runner connected with the same token; this one is stopping";
|
|
135
|
+
else if (code === RUNNER_CLOSE.revoked)
|
|
136
|
+
fatal = "the runner token was revoked";
|
|
137
|
+
else if (code === RUNNER_CLOSE.protocol)
|
|
138
|
+
fatal = `the gateway closed the connection: ${why || "protocol error"} (upgrade the runner?)`;
|
|
139
|
+
// The episode can't be finished without the gateway: stop the agent rather than leave it.
|
|
140
|
+
if (openedAt)
|
|
141
|
+
void this.cancelRunning("the runner lost its connection");
|
|
142
|
+
resolve({
|
|
143
|
+
...(fatal ? { fatal } : {}),
|
|
144
|
+
stable: openedAt > 0 && Date.now() - openedAt > 10_000,
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
/** Sends now if connected; otherwise keeps the message for the next connection. */
|
|
150
|
+
write(message) {
|
|
151
|
+
const socket = this.socket;
|
|
152
|
+
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
153
|
+
socket.send(JSON.stringify(message));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (message.type === "ping" || message.type === "hello")
|
|
157
|
+
return;
|
|
158
|
+
this.outbox.push(message);
|
|
159
|
+
if (this.outbox.length > OUTBOX_LIMIT)
|
|
160
|
+
this.outbox.shift();
|
|
161
|
+
}
|
|
162
|
+
async runEpisode(episode) {
|
|
163
|
+
const started = Date.now();
|
|
164
|
+
if (this.running) {
|
|
165
|
+
this.write({
|
|
166
|
+
type: "episode_done",
|
|
167
|
+
episodeId: episode.episodeId,
|
|
168
|
+
exitCode: null,
|
|
169
|
+
durationMs: 0,
|
|
170
|
+
error: "the runner is already running an episode",
|
|
171
|
+
});
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const running = { episodeId: episode.episodeId, cancelled: false };
|
|
175
|
+
this.running = running;
|
|
176
|
+
this.log(`episode ${episode.episodeId}: starting the agent`);
|
|
177
|
+
try {
|
|
178
|
+
if (episode.nonce)
|
|
179
|
+
await this.sendEvidence(episode);
|
|
180
|
+
if (running.cancelled) {
|
|
181
|
+
this.write({
|
|
182
|
+
type: "episode_done",
|
|
183
|
+
episodeId: episode.episodeId,
|
|
184
|
+
exitCode: null,
|
|
185
|
+
durationMs: Date.now() - started,
|
|
186
|
+
error: "cancelled before the agent started",
|
|
187
|
+
});
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const result = await this.launch(episode, running);
|
|
191
|
+
this.write({
|
|
192
|
+
type: "episode_done",
|
|
193
|
+
episodeId: episode.episodeId,
|
|
194
|
+
durationMs: Date.now() - started,
|
|
195
|
+
...result,
|
|
196
|
+
});
|
|
197
|
+
this.log(`episode ${episode.episodeId}: agent ${result.exitCode === null ? `stopped (${result.signal ?? result.error})` : `exited with code ${result.exitCode}`}`);
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
clearTimeout(running.killTimer);
|
|
201
|
+
if (this.running === running)
|
|
202
|
+
this.running = undefined;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/** Runs the agent command to completion (or until it is killed). */
|
|
206
|
+
launch(episode, running) {
|
|
207
|
+
const [command, ...args] = this.options.command;
|
|
208
|
+
const env = { ...process.env, ...episodeEnv(episode) };
|
|
209
|
+
// The runner's own credential never reaches the agent, and neither does the production
|
|
210
|
+
// vault's (bondedVaultFromEnv in @bondedhq/sdk): a rated episode can't trade real funds.
|
|
211
|
+
delete env.BONDED_RUNNER_TOKEN;
|
|
212
|
+
delete env.BONDED_AGENT_KEY;
|
|
213
|
+
delete env.BONDED_VAULT_ADDRESS;
|
|
214
|
+
const stdout = new Tail(RUNNER_TAIL_BYTES);
|
|
215
|
+
const stderr = new Tail(RUNNER_TAIL_BYTES);
|
|
216
|
+
const echo = this.options.echo ?? true;
|
|
217
|
+
return new Promise((resolve) => {
|
|
218
|
+
let child;
|
|
219
|
+
try {
|
|
220
|
+
child = spawn(command, args, {
|
|
221
|
+
env,
|
|
222
|
+
...(this.options.cwd ? { cwd: this.options.cwd } : {}),
|
|
223
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
224
|
+
// Its own process group, so stopping it stops anything it started.
|
|
225
|
+
detached: process.platform !== "win32",
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
resolve({ exitCode: null, error: `could not start the agent: ${String(error)}` });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
running.child = child;
|
|
233
|
+
child.stdout?.on("data", (chunk) => {
|
|
234
|
+
stdout.push(chunk);
|
|
235
|
+
if (echo)
|
|
236
|
+
process.stdout.write(chunk);
|
|
237
|
+
});
|
|
238
|
+
child.stderr?.on("data", (chunk) => {
|
|
239
|
+
stderr.push(chunk);
|
|
240
|
+
if (echo)
|
|
241
|
+
process.stderr.write(chunk);
|
|
242
|
+
});
|
|
243
|
+
let failed;
|
|
244
|
+
child.once("error", (error) => {
|
|
245
|
+
failed = `could not start the agent: ${error.message}`;
|
|
246
|
+
});
|
|
247
|
+
child.once("spawn", () => this.write({ type: "episode_started", episodeId: episode.episodeId }));
|
|
248
|
+
child.once("close", (code, signal) => {
|
|
249
|
+
resolve({
|
|
250
|
+
exitCode: failed ? null : code,
|
|
251
|
+
signal,
|
|
252
|
+
...(stdout.text() ? { stdoutTail: stdout.text() } : {}),
|
|
253
|
+
...(stderr.text() ? { stderrTail: stderr.text() } : {}),
|
|
254
|
+
...(failed ? { error: failed } : {}),
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
// The gateway enforces the deadline; this is the runner's own backstop.
|
|
258
|
+
running.killTimer = setTimeout(() => void this.cancelRunning("the episode's deadline passed"), episode.deadlineMs + (this.options.killGraceMs ?? RUNNER_KILL_GRACE_MS));
|
|
259
|
+
if (running.cancelled)
|
|
260
|
+
void this.cancelRunning("cancelled");
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
/** SIGTERM to the agent's process group, then SIGKILL after the grace period. */
|
|
264
|
+
async cancelRunning(why) {
|
|
265
|
+
const running = this.running;
|
|
266
|
+
if (!running)
|
|
267
|
+
return;
|
|
268
|
+
running.cancelled = true;
|
|
269
|
+
const child = running.child;
|
|
270
|
+
if (!child || child.exitCode !== null || child.signalCode !== null)
|
|
271
|
+
return;
|
|
272
|
+
this.log(`episode ${running.episodeId}: stopping the agent (${why})`);
|
|
273
|
+
const exited = new Promise((resolve) => child.once("close", () => resolve()));
|
|
274
|
+
signal(child, "SIGTERM");
|
|
275
|
+
const grace = this.options.killGraceMs ?? RUNNER_KILL_GRACE_MS;
|
|
276
|
+
const timer = setTimeout(() => signal(child, "SIGKILL"), grace);
|
|
277
|
+
await exited;
|
|
278
|
+
clearTimeout(timer);
|
|
279
|
+
}
|
|
280
|
+
/** Gets the TEE's evidence for the episode's nonce and forwards it as-is. */
|
|
281
|
+
async sendEvidence(episode) {
|
|
282
|
+
const reply = (fields) => this.write({ type: "tee_evidence", episodeId: episode.episodeId, ...fields });
|
|
283
|
+
if (!this.options.teeUrl) {
|
|
284
|
+
reply({ error: "this runner has no --tee-url" });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
const url = new URL("attestation", `${this.options.teeUrl.replace(/\/+$/, "")}/`);
|
|
289
|
+
url.searchParams.set("nonce", episode.nonce);
|
|
290
|
+
// In chain mode the TEE attests the rating's Arena key, the one it signs episodes with.
|
|
291
|
+
if (episode.teeKeyId) {
|
|
292
|
+
url.searchParams.set("purpose", "arena");
|
|
293
|
+
url.searchParams.set("key", episode.teeKeyId);
|
|
294
|
+
}
|
|
295
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(20_000) });
|
|
296
|
+
const text = await response.text();
|
|
297
|
+
if (!response.ok)
|
|
298
|
+
throw new Error(`the TEE answered ${response.status}`);
|
|
299
|
+
if (text.length > 512 * 1024)
|
|
300
|
+
throw new Error("the TEE's evidence is too large");
|
|
301
|
+
reply({ evidence: JSON.parse(text) });
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
reply({ error: error instanceof Error ? error.message : String(error) });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function signal(child, name) {
|
|
309
|
+
try {
|
|
310
|
+
if (process.platform !== "win32" && child.pid)
|
|
311
|
+
process.kill(-child.pid, name);
|
|
312
|
+
else
|
|
313
|
+
child.kill(name);
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
// Already gone.
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
//# sourceMappingURL=runner.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { AgentMode } from "@bondedhq/shared";
|
|
2
|
+
export declare const SERVICE_USAGE = "Usage: bonded-runner service --print systemd|pm2|docker --gateway <url> [options] -- <agent command...>\n\nPrints a ready config that keeps the runner running in the background, restarts it if it stops,\nand starts it at boot. Nothing is installed or written: copy what it prints. The runner token is\nnever put in the config; it is read from an environment file (systemd, Docker) or your shell (pm2).\n\n --print <kind> systemd (a unit file), pm2 (an ecosystem file) or docker (a Dockerfile and\n the docker run line, with --restart unless-stopped)\n --gateway <url> the Arena Gateway (or BONDED_GATEWAY_URL)\n --mode tools|chain default tools\n --name <name> the service's name (default bonded-runner)\n --dir <folder> the agent's folder (default: this one)\n --user <user> systemd: the user it runs as (default: you)\n\nExample:\n bonded-runner service --print systemd --gateway https://arena.example.com -- node agent.js";
|
|
3
|
+
export type ServiceKind = "systemd" | "pm2" | "docker";
|
|
4
|
+
export interface ServiceOptions {
|
|
5
|
+
kind: ServiceKind;
|
|
6
|
+
gateway: string;
|
|
7
|
+
mode: AgentMode;
|
|
8
|
+
command: string[];
|
|
9
|
+
name: string;
|
|
10
|
+
dir: string;
|
|
11
|
+
user: string;
|
|
12
|
+
/** Folder of the node binary, for systemd's PATH. */
|
|
13
|
+
nodeBin: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function serviceConfig(o: ServiceOptions): string;
|
|
16
|
+
export declare function serviceCommand(argv: string[], io: {
|
|
17
|
+
cwd: string;
|
|
18
|
+
env: Record<string, string | undefined>;
|
|
19
|
+
out: (text: string) => void;
|
|
20
|
+
err: (text: string) => void;
|
|
21
|
+
user?: string;
|
|
22
|
+
nodeBin?: string;
|
|
23
|
+
}): number;
|
|
24
|
+
//# sourceMappingURL=service.d.ts.map
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { basename, dirname, resolve } from "node:path";
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { AgentMode } from "@bondedhq/shared";
|
|
4
|
+
import { gatewayUrlProblem } from "./tail.js";
|
|
5
|
+
export const SERVICE_USAGE = `Usage: bonded-runner service --print systemd|pm2|docker --gateway <url> [options] -- <agent command...>
|
|
6
|
+
|
|
7
|
+
Prints a ready config that keeps the runner running in the background, restarts it if it stops,
|
|
8
|
+
and starts it at boot. Nothing is installed or written: copy what it prints. The runner token is
|
|
9
|
+
never put in the config; it is read from an environment file (systemd, Docker) or your shell (pm2).
|
|
10
|
+
|
|
11
|
+
--print <kind> systemd (a unit file), pm2 (an ecosystem file) or docker (a Dockerfile and
|
|
12
|
+
the docker run line, with --restart unless-stopped)
|
|
13
|
+
--gateway <url> the Arena Gateway (or BONDED_GATEWAY_URL)
|
|
14
|
+
--mode tools|chain default tools
|
|
15
|
+
--name <name> the service's name (default bonded-runner)
|
|
16
|
+
--dir <folder> the agent's folder (default: this one)
|
|
17
|
+
--user <user> systemd: the user it runs as (default: you)
|
|
18
|
+
|
|
19
|
+
Example:
|
|
20
|
+
bonded-runner service --print systemd --gateway https://arena.example.com -- node agent.js`;
|
|
21
|
+
/** A systemd ExecStart word (systemd's own quoting: double quotes, backslash escapes). */
|
|
22
|
+
function systemdWord(word) {
|
|
23
|
+
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(word)
|
|
24
|
+
? word
|
|
25
|
+
: `"${word.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "$$$$")}"`;
|
|
26
|
+
}
|
|
27
|
+
function runnerArgs(o) {
|
|
28
|
+
return [
|
|
29
|
+
"--gateway",
|
|
30
|
+
o.gateway,
|
|
31
|
+
...(o.mode === "chain" ? ["--mode", "chain"] : []),
|
|
32
|
+
"--",
|
|
33
|
+
...o.command,
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
export function serviceConfig(o) {
|
|
37
|
+
const args = runnerArgs(o);
|
|
38
|
+
const envFile = `${o.dir}/${o.name}.env`;
|
|
39
|
+
switch (o.kind) {
|
|
40
|
+
case "systemd":
|
|
41
|
+
return `# /etc/systemd/system/${o.name}.service
|
|
42
|
+
# 1. Put your runner token in ${envFile} (one line: BONDED_RUNNER_TOKEN=...), then
|
|
43
|
+
# chmod 600 ${envFile}
|
|
44
|
+
# 2. sudo cp ${o.name}.service /etc/systemd/system/ && sudo systemctl daemon-reload
|
|
45
|
+
# 3. sudo systemctl enable --now ${o.name} (logs: journalctl -u ${o.name} -f)
|
|
46
|
+
[Unit]
|
|
47
|
+
Description=Bonded runner (${basename(o.dir)})
|
|
48
|
+
After=network-online.target
|
|
49
|
+
Wants=network-online.target
|
|
50
|
+
|
|
51
|
+
[Service]
|
|
52
|
+
Type=simple
|
|
53
|
+
User=${o.user}
|
|
54
|
+
WorkingDirectory=${o.dir}
|
|
55
|
+
EnvironmentFile=${envFile}
|
|
56
|
+
Environment=PATH=${o.nodeBin}:/usr/local/bin:/usr/bin:/bin
|
|
57
|
+
ExecStart=/usr/bin/env npx --yes @bondedhq/runner ${args.map(systemdWord).join(" ")}
|
|
58
|
+
Restart=always
|
|
59
|
+
RestartSec=10
|
|
60
|
+
# Exit code 2: the token was refused or another runner took over. Restarting won't help.
|
|
61
|
+
RestartPreventExitStatus=2
|
|
62
|
+
KillMode=mixed
|
|
63
|
+
TimeoutStopSec=15
|
|
64
|
+
NoNewPrivileges=true
|
|
65
|
+
|
|
66
|
+
[Install]
|
|
67
|
+
WantedBy=multi-user.target
|
|
68
|
+
`;
|
|
69
|
+
case "pm2":
|
|
70
|
+
return `// ecosystem.config.cjs: start it with your runner token in the environment:
|
|
71
|
+
// BONDED_RUNNER_TOKEN=... pm2 start ecosystem.config.cjs
|
|
72
|
+
// pm2 save && pm2 startup (starts it again at boot; pm2 keeps the token in its dump file)
|
|
73
|
+
module.exports = {
|
|
74
|
+
apps: [
|
|
75
|
+
{
|
|
76
|
+
name: ${JSON.stringify(o.name)},
|
|
77
|
+
cwd: ${JSON.stringify(o.dir)},
|
|
78
|
+
script: "npx",
|
|
79
|
+
args: ${JSON.stringify(["--yes", "@bondedhq/runner", ...args])},
|
|
80
|
+
interpreter: "none",
|
|
81
|
+
autorestart: true,
|
|
82
|
+
restart_delay: 10000,
|
|
83
|
+
// Exit code 2: the token was refused or another runner took over. Restarting won't help.
|
|
84
|
+
stop_exit_codes: [2],
|
|
85
|
+
kill_timeout: 15000,
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
`;
|
|
90
|
+
case "docker": {
|
|
91
|
+
const cmd = JSON.stringify(args);
|
|
92
|
+
// Not "bonded-runner": that is the base image's name.
|
|
93
|
+
const image = `${o.name}-agent`;
|
|
94
|
+
return `# Dockerfile, in your agent's folder. It builds on the runner's image: build that once from a
|
|
95
|
+
# clone of the Bonded repository (docker build -f apps/runner/Dockerfile -t bonded-runner .).
|
|
96
|
+
FROM bonded-runner
|
|
97
|
+
COPY --chown=node:node . /agent
|
|
98
|
+
WORKDIR /agent
|
|
99
|
+
RUN npm ci --omit=dev # or your package manager's install
|
|
100
|
+
CMD ${cmd}
|
|
101
|
+
|
|
102
|
+
# Then, with your runner token in ${o.name}.env (one line: BONDED_RUNNER_TOKEN=...):
|
|
103
|
+
# docker build -t ${image} .
|
|
104
|
+
# docker run -d --name ${o.name} --restart unless-stopped --env-file ${o.name}.env \\
|
|
105
|
+
# -e BONDED_IMAGE_DIGEST="$(docker inspect --format '{{.Id}}' ${image})" ${image}
|
|
106
|
+
# --restart unless-stopped starts it again after a crash or a reboot, until you docker stop it.
|
|
107
|
+
# BONDED_IMAGE_DIGEST pins the code hash to the image (the image has no git).
|
|
108
|
+
`;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export function serviceCommand(argv, io) {
|
|
113
|
+
const dash = argv.indexOf("--");
|
|
114
|
+
const command = dash >= 0 ? argv.slice(dash + 1) : [];
|
|
115
|
+
let values;
|
|
116
|
+
try {
|
|
117
|
+
({ values } = parseArgs({
|
|
118
|
+
args: dash >= 0 ? argv.slice(0, dash) : argv,
|
|
119
|
+
options: {
|
|
120
|
+
print: { type: "string" },
|
|
121
|
+
gateway: { type: "string" },
|
|
122
|
+
mode: { type: "string", default: "tools" },
|
|
123
|
+
name: { type: "string", default: "bonded-runner" },
|
|
124
|
+
dir: { type: "string" },
|
|
125
|
+
user: { type: "string" },
|
|
126
|
+
help: { type: "boolean", short: "h", default: false },
|
|
127
|
+
},
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
io.err(`${error instanceof Error ? error.message : String(error)}\n\n${SERVICE_USAGE}`);
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
if (values.help) {
|
|
135
|
+
io.out(SERVICE_USAGE);
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
const kind = values.print;
|
|
139
|
+
const gateway = values.gateway ?? io.env.BONDED_GATEWAY_URL;
|
|
140
|
+
const mode = AgentMode.safeParse(values.mode);
|
|
141
|
+
const missing = [
|
|
142
|
+
!(kind === "systemd" || kind === "pm2" || kind === "docker") && "--print systemd|pm2|docker",
|
|
143
|
+
!gateway && "--gateway",
|
|
144
|
+
!mode.success && "--mode tools|chain",
|
|
145
|
+
command.length === 0 && "an agent command after --",
|
|
146
|
+
!/^[A-Za-z0-9_.-]+$/.test(values.name) && "--name (letters, digits, . _ -)",
|
|
147
|
+
].filter(Boolean);
|
|
148
|
+
if (missing.length) {
|
|
149
|
+
io.err(`${SERVICE_USAGE}\n\nMissing or invalid: ${missing.join(", ")}`);
|
|
150
|
+
return 1;
|
|
151
|
+
}
|
|
152
|
+
const problem = gatewayUrlProblem(gateway);
|
|
153
|
+
if (problem) {
|
|
154
|
+
io.err(problem);
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
io.out(serviceConfig({
|
|
158
|
+
kind: kind,
|
|
159
|
+
gateway: gateway,
|
|
160
|
+
mode: mode.data,
|
|
161
|
+
command,
|
|
162
|
+
name: values.name,
|
|
163
|
+
dir: resolve(io.cwd, values.dir ?? "."),
|
|
164
|
+
user: values.user ?? io.user ?? io.env.USER ?? "node",
|
|
165
|
+
nodeBin: io.nodeBin ?? dirname(process.execPath),
|
|
166
|
+
}));
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
//# sourceMappingURL=service.js.map
|
package/dist/tail.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Keeps the last `limit` bytes written to it: an agent's output can be any size. */
|
|
2
|
+
export declare class Tail {
|
|
3
|
+
private readonly limit;
|
|
4
|
+
private chunks;
|
|
5
|
+
private size;
|
|
6
|
+
constructor(limit: number);
|
|
7
|
+
push(chunk: Buffer): void;
|
|
8
|
+
/** The kept bytes as text (a character cut at the start shows as a replacement mark). */
|
|
9
|
+
text(): string;
|
|
10
|
+
}
|
|
11
|
+
/** Reconnect delays: doubling from `initialMs` up to `maxMs`, with up to 20% jitter either way. */
|
|
12
|
+
export declare function backoffDelay(attempt: number, options: {
|
|
13
|
+
initialMs: number;
|
|
14
|
+
maxMs: number;
|
|
15
|
+
}, random?: () => number): number;
|
|
16
|
+
/**
|
|
17
|
+
* Why a gateway URL can't be used, or undefined when it can. The runner sends its token over this
|
|
18
|
+
* connection, so it must be https; plain http is allowed only to this machine (a local Arena).
|
|
19
|
+
*/
|
|
20
|
+
export declare function gatewayUrlProblem(text: string): string | undefined;
|
|
21
|
+
//# sourceMappingURL=tail.d.ts.map
|
package/dist/tail.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Keeps the last `limit` bytes written to it: an agent's output can be any size. */
|
|
2
|
+
export class Tail {
|
|
3
|
+
limit;
|
|
4
|
+
chunks = [];
|
|
5
|
+
size = 0;
|
|
6
|
+
constructor(limit) {
|
|
7
|
+
this.limit = limit;
|
|
8
|
+
}
|
|
9
|
+
push(chunk) {
|
|
10
|
+
this.chunks.push(chunk);
|
|
11
|
+
this.size += chunk.length;
|
|
12
|
+
while (this.size - (this.chunks[0]?.length ?? 0) >= this.limit && this.chunks.length > 1) {
|
|
13
|
+
this.size -= this.chunks.shift().length;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** The kept bytes as text (a character cut at the start shows as a replacement mark). */
|
|
17
|
+
text() {
|
|
18
|
+
const all = Buffer.concat(this.chunks);
|
|
19
|
+
return all.subarray(Math.max(0, all.length - this.limit)).toString("utf8");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Reconnect delays: doubling from `initialMs` up to `maxMs`, with up to 20% jitter either way. */
|
|
23
|
+
export function backoffDelay(attempt, options, random = Math.random) {
|
|
24
|
+
const base = Math.min(options.maxMs, options.initialMs * 2 ** Math.max(0, attempt - 1));
|
|
25
|
+
return Math.round(base * (0.8 + 0.4 * random()));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Why a gateway URL can't be used, or undefined when it can. The runner sends its token over this
|
|
29
|
+
* connection, so it must be https; plain http is allowed only to this machine (a local Arena).
|
|
30
|
+
*/
|
|
31
|
+
export function gatewayUrlProblem(text) {
|
|
32
|
+
let url;
|
|
33
|
+
try {
|
|
34
|
+
url = new URL(text);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return "the gateway URL isn't a URL";
|
|
38
|
+
}
|
|
39
|
+
if (url.protocol === "https:")
|
|
40
|
+
return undefined;
|
|
41
|
+
if (url.protocol !== "http:")
|
|
42
|
+
return "the gateway URL must be https";
|
|
43
|
+
const host = url.hostname.toLowerCase();
|
|
44
|
+
const local = host === "localhost" ||
|
|
45
|
+
host.endsWith(".localhost") ||
|
|
46
|
+
host === "[::1]" ||
|
|
47
|
+
/^127(\.[0-9]{1,3}){3}$/.test(host);
|
|
48
|
+
return local ? undefined : "the gateway URL must be https (plain http only to localhost)";
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=tail.js.map
|