byollm 0.1.0-alpha.0
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 +118 -0
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +8 -0
- package/dist/bin.js.map +1 -0
- package/dist/chunk-S5WHDSUF.js +2358 -0
- package/dist/chunk-S5WHDSUF.js.map +1 -0
- package/dist/index.d.ts +913 -0
- package/dist/index.js +65 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
|
@@ -0,0 +1,2358 @@
|
|
|
1
|
+
// src/allowlist.ts
|
|
2
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
3
|
+
import { dirname } from "path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
var AllowEntry = z.object({
|
|
6
|
+
/** Origin of the server that issued the id, e.g. `https://app.example.com`. */
|
|
7
|
+
origin: z.string().min(1),
|
|
8
|
+
/** The app's id for the user. */
|
|
9
|
+
owner: z.string().min(1),
|
|
10
|
+
/** Optional human label, so the list is readable a month later. */
|
|
11
|
+
note: z.string().optional(),
|
|
12
|
+
addedAt: z.number().int().positive()
|
|
13
|
+
}).strict();
|
|
14
|
+
var AllowFile = z.object({
|
|
15
|
+
version: z.literal(1),
|
|
16
|
+
entries: z.array(AllowEntry)
|
|
17
|
+
}).strict();
|
|
18
|
+
var Allowlist = class {
|
|
19
|
+
#path;
|
|
20
|
+
#entries = [];
|
|
21
|
+
#loaded = false;
|
|
22
|
+
constructor(path) {
|
|
23
|
+
this.#path = path;
|
|
24
|
+
}
|
|
25
|
+
async load() {
|
|
26
|
+
try {
|
|
27
|
+
const raw = await readFile(this.#path, "utf8");
|
|
28
|
+
const parsed = AllowFile.safeParse(JSON.parse(raw));
|
|
29
|
+
this.#entries = parsed.success ? parsed.data.entries : [];
|
|
30
|
+
} catch {
|
|
31
|
+
this.#entries = [];
|
|
32
|
+
}
|
|
33
|
+
this.#loaded = true;
|
|
34
|
+
}
|
|
35
|
+
#assertLoaded() {
|
|
36
|
+
if (!this.#loaded) {
|
|
37
|
+
throw new Error("allowlist used before load()");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Does this list admit `owner` on `origin`? */
|
|
41
|
+
admits(origin, owner) {
|
|
42
|
+
this.#assertLoaded();
|
|
43
|
+
const normalized = normalizeOrigin(origin);
|
|
44
|
+
return this.#entries.some(
|
|
45
|
+
(entry) => normalizeOrigin(entry.origin) === normalized && entry.owner === owner
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
/** A predicate bound to one origin, for {@link matchAudience}. */
|
|
49
|
+
predicateFor(origin) {
|
|
50
|
+
return (owner) => this.admits(origin, owner);
|
|
51
|
+
}
|
|
52
|
+
list() {
|
|
53
|
+
this.#assertLoaded();
|
|
54
|
+
return [...this.#entries];
|
|
55
|
+
}
|
|
56
|
+
async add(entry, now) {
|
|
57
|
+
this.#assertLoaded();
|
|
58
|
+
const normalized = normalizeOrigin(entry.origin);
|
|
59
|
+
if (this.admits(normalized, entry.owner)) return;
|
|
60
|
+
this.#entries.push(
|
|
61
|
+
AllowEntry.parse({ ...entry, origin: normalized, addedAt: now })
|
|
62
|
+
);
|
|
63
|
+
await this.#save();
|
|
64
|
+
}
|
|
65
|
+
/** Remove an entry. Returns whether anything was removed. */
|
|
66
|
+
async remove(origin, owner) {
|
|
67
|
+
this.#assertLoaded();
|
|
68
|
+
const normalized = normalizeOrigin(origin);
|
|
69
|
+
const before = this.#entries.length;
|
|
70
|
+
this.#entries = this.#entries.filter(
|
|
71
|
+
(entry) => !(normalizeOrigin(entry.origin) === normalized && entry.owner === owner)
|
|
72
|
+
);
|
|
73
|
+
if (this.#entries.length === before) return false;
|
|
74
|
+
await this.#save();
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
async #save() {
|
|
78
|
+
await mkdir(dirname(this.#path), { recursive: true });
|
|
79
|
+
await writeFile(
|
|
80
|
+
this.#path,
|
|
81
|
+
`${JSON.stringify({ version: 1, entries: this.#entries }, null, 2)}
|
|
82
|
+
`,
|
|
83
|
+
{ mode: 384 }
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
function normalizeOrigin(input) {
|
|
88
|
+
try {
|
|
89
|
+
return new URL(input).origin;
|
|
90
|
+
} catch {
|
|
91
|
+
return input.replace(/\/+$/, "");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// src/cli.ts
|
|
96
|
+
import { mkdir as mkdir5, rm as rm2, stat, writeFile as writeFile5 } from "fs/promises";
|
|
97
|
+
import { hostname, userInfo } from "os";
|
|
98
|
+
import { createInterface } from "readline/promises";
|
|
99
|
+
|
|
100
|
+
// src/budgets.ts
|
|
101
|
+
import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
102
|
+
import { dirname as dirname2 } from "path";
|
|
103
|
+
import { z as z2 } from "zod";
|
|
104
|
+
var BudgetFile = z2.object({
|
|
105
|
+
version: z2.literal(1),
|
|
106
|
+
/** Epoch-ms timestamps of community jobs accepted, newest last. */
|
|
107
|
+
accepted: z2.array(z2.number().int().positive())
|
|
108
|
+
}).strict();
|
|
109
|
+
var Budgets = class {
|
|
110
|
+
#path;
|
|
111
|
+
#limits;
|
|
112
|
+
#accepted = [];
|
|
113
|
+
#loaded = false;
|
|
114
|
+
constructor(path, limits) {
|
|
115
|
+
this.#path = path;
|
|
116
|
+
this.#limits = limits;
|
|
117
|
+
}
|
|
118
|
+
async load(now) {
|
|
119
|
+
try {
|
|
120
|
+
const parsed = BudgetFile.safeParse(
|
|
121
|
+
JSON.parse(await readFile2(this.#path, "utf8"))
|
|
122
|
+
);
|
|
123
|
+
this.#accepted = parsed.success ? parsed.data.accepted : [];
|
|
124
|
+
} catch {
|
|
125
|
+
this.#accepted = [];
|
|
126
|
+
}
|
|
127
|
+
this.#prune(now);
|
|
128
|
+
this.#loaded = true;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* May this community job run?
|
|
132
|
+
*
|
|
133
|
+
* @param payloadChars - total payload text length, checked against the
|
|
134
|
+
* stricter community limit rather than the protocol's absolute ceiling.
|
|
135
|
+
*/
|
|
136
|
+
check(now, payloadChars) {
|
|
137
|
+
if (!this.#loaded) throw new Error("budgets used before load()");
|
|
138
|
+
this.#prune(now);
|
|
139
|
+
if (payloadChars > this.#limits.maxPayloadChars) {
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
refusal: "payload-too-large",
|
|
143
|
+
detail: `community jobs are limited to ${String(this.#limits.maxPayloadChars)} characters; this one is ${String(payloadChars)}`
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const hour = this.#countSince(now - 36e5);
|
|
147
|
+
if (hour >= this.#limits.maxJobsPerHour) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
refusal: "hourly-cap",
|
|
151
|
+
detail: `already ran ${String(hour)} community jobs in the last hour`
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const day = this.#countSince(now - 864e5);
|
|
155
|
+
if (day >= this.#limits.maxJobsPerDay) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
refusal: "daily-cap",
|
|
159
|
+
detail: `already ran ${String(day)} community jobs today`
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return { ok: true };
|
|
163
|
+
}
|
|
164
|
+
/** Count a community job as accepted. Call only after {@link check} passes. */
|
|
165
|
+
async record(now) {
|
|
166
|
+
this.#accepted.push(now);
|
|
167
|
+
this.#prune(now);
|
|
168
|
+
await mkdir2(dirname2(this.#path), { recursive: true });
|
|
169
|
+
await writeFile2(
|
|
170
|
+
this.#path,
|
|
171
|
+
JSON.stringify({ version: 1, accepted: this.#accepted }),
|
|
172
|
+
{ mode: 384 }
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
/** Current usage, for `byollm status`. */
|
|
176
|
+
usage(now) {
|
|
177
|
+
this.#prune(now);
|
|
178
|
+
return {
|
|
179
|
+
hour: this.#countSince(now - 36e5),
|
|
180
|
+
day: this.#countSince(now - 864e5),
|
|
181
|
+
limits: this.#limits
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
#countSince(since) {
|
|
185
|
+
return this.#accepted.filter((at) => at >= since).length;
|
|
186
|
+
}
|
|
187
|
+
/** Anything older than a day can never affect either window again. */
|
|
188
|
+
#prune(now) {
|
|
189
|
+
const cutoff = now - 864e5;
|
|
190
|
+
this.#accepted = this.#accepted.filter((at) => at >= cutoff);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// src/client.ts
|
|
195
|
+
import {
|
|
196
|
+
ClaimResponse,
|
|
197
|
+
HeartbeatResponse,
|
|
198
|
+
PROTOCOL_VERSION,
|
|
199
|
+
PairPollResponse,
|
|
200
|
+
PairStartResponse,
|
|
201
|
+
ReleaseResponse,
|
|
202
|
+
ResultResponse,
|
|
203
|
+
WireError
|
|
204
|
+
} from "@byollm/protocol";
|
|
205
|
+
import "zod";
|
|
206
|
+
var ClientError = class extends Error {
|
|
207
|
+
constructor(kind, message, retryAfter) {
|
|
208
|
+
super(message);
|
|
209
|
+
this.kind = kind;
|
|
210
|
+
this.retryAfter = retryAfter;
|
|
211
|
+
}
|
|
212
|
+
kind;
|
|
213
|
+
retryAfter;
|
|
214
|
+
name = "ClientError";
|
|
215
|
+
/** Is retrying this same call plausibly useful? */
|
|
216
|
+
get retryable() {
|
|
217
|
+
return this.kind === "unreachable" || this.kind === "rate-limited" || this.kind === "server-error";
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
221
|
+
var ProtocolClient = class _ProtocolClient {
|
|
222
|
+
#origin;
|
|
223
|
+
#token;
|
|
224
|
+
#timeoutMs;
|
|
225
|
+
#fetch;
|
|
226
|
+
constructor(options) {
|
|
227
|
+
this.#origin = options.origin.replace(/\/+$/, "");
|
|
228
|
+
this.#token = options.token;
|
|
229
|
+
this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
230
|
+
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
231
|
+
}
|
|
232
|
+
/** A client for the same origin carrying a token. */
|
|
233
|
+
withToken(token) {
|
|
234
|
+
return new _ProtocolClient({
|
|
235
|
+
origin: this.#origin,
|
|
236
|
+
token,
|
|
237
|
+
timeoutMs: this.#timeoutMs,
|
|
238
|
+
fetch: this.#fetch
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
get origin() {
|
|
242
|
+
return this.#origin;
|
|
243
|
+
}
|
|
244
|
+
async pairStart(input) {
|
|
245
|
+
return this.#post("pair", PairStartResponse, {
|
|
246
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
247
|
+
action: "start",
|
|
248
|
+
daemon: {
|
|
249
|
+
version: input.version,
|
|
250
|
+
label: input.label,
|
|
251
|
+
platform: input.platform
|
|
252
|
+
},
|
|
253
|
+
capabilities: input.capabilities
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
async pairPoll(deviceCode) {
|
|
257
|
+
return this.#post("pair", PairPollResponse, {
|
|
258
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
259
|
+
action: "poll",
|
|
260
|
+
deviceCode
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
async claim(input) {
|
|
264
|
+
return this.#post("claim", ClaimResponse, {
|
|
265
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
266
|
+
runnerId: input.runnerId,
|
|
267
|
+
capabilities: input.capabilities,
|
|
268
|
+
max: input.max
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
async heartbeat(input) {
|
|
272
|
+
return this.#post("heartbeat", HeartbeatResponse, {
|
|
273
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
274
|
+
runnerId: input.runnerId,
|
|
275
|
+
daemonVersion: input.daemonVersion,
|
|
276
|
+
capabilities: input.capabilities,
|
|
277
|
+
activeJobIds: input.activeJobIds,
|
|
278
|
+
paused: input.paused
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
async result(input) {
|
|
282
|
+
return this.#post("result", ResultResponse, {
|
|
283
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
284
|
+
...input
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
async release(input) {
|
|
288
|
+
return this.#post("release", ReleaseResponse, {
|
|
289
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
290
|
+
runnerId: input.runnerId,
|
|
291
|
+
jobIds: input.jobIds,
|
|
292
|
+
reason: input.reason
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
async #post(endpoint, schema, body) {
|
|
296
|
+
const headers = {
|
|
297
|
+
"content-type": "application/json",
|
|
298
|
+
accept: "application/json"
|
|
299
|
+
};
|
|
300
|
+
if (this.#token !== void 0) {
|
|
301
|
+
headers["authorization"] = `Bearer ${this.#token}`;
|
|
302
|
+
}
|
|
303
|
+
let response;
|
|
304
|
+
try {
|
|
305
|
+
response = await this.#fetch(`${this.#origin}/byollm/${endpoint}`, {
|
|
306
|
+
method: "POST",
|
|
307
|
+
headers,
|
|
308
|
+
body: JSON.stringify(body),
|
|
309
|
+
redirect: "error",
|
|
310
|
+
signal: AbortSignal.timeout(this.#timeoutMs)
|
|
311
|
+
});
|
|
312
|
+
} catch (error) {
|
|
313
|
+
throw new ClientError(
|
|
314
|
+
"unreachable",
|
|
315
|
+
`could not reach ${this.#origin} (${error instanceof Error ? error.message : "unknown error"})`
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
const text = await response.text();
|
|
319
|
+
let parsed;
|
|
320
|
+
try {
|
|
321
|
+
parsed = text === "" ? {} : JSON.parse(text);
|
|
322
|
+
} catch {
|
|
323
|
+
throw new ClientError(
|
|
324
|
+
"malformed-response",
|
|
325
|
+
`${this.#origin} returned HTTP ${String(response.status)} with a body that is not JSON`
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
if (!response.ok) {
|
|
329
|
+
throw this.#toError(response, parsed);
|
|
330
|
+
}
|
|
331
|
+
const result = schema.safeParse(parsed);
|
|
332
|
+
if (!result.success) {
|
|
333
|
+
throw new ClientError(
|
|
334
|
+
"malformed-response",
|
|
335
|
+
`${this.#origin} returned a ${endpoint} response that does not match protocol v${PROTOCOL_VERSION}`
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
return result.data;
|
|
339
|
+
}
|
|
340
|
+
#toError(response, body) {
|
|
341
|
+
const wire = WireError.safeParse(body);
|
|
342
|
+
const retryAfterHeader = response.headers.get("retry-after");
|
|
343
|
+
const retryAfter = wire.success && wire.data.retryAfter !== void 0 ? wire.data.retryAfter : retryAfterHeader !== null && /^\d+$/.test(retryAfterHeader) ? Number(retryAfterHeader) : void 0;
|
|
344
|
+
const message = wire.success ? wire.data.message : `${this.#origin} returned HTTP ${String(response.status)}`;
|
|
345
|
+
if (wire.success && wire.data.error === "revoked") {
|
|
346
|
+
return new ClientError("revoked", message, retryAfter);
|
|
347
|
+
}
|
|
348
|
+
switch (response.status) {
|
|
349
|
+
case 401:
|
|
350
|
+
return new ClientError("unauthorized", message, retryAfter);
|
|
351
|
+
case 403:
|
|
352
|
+
return new ClientError("revoked", message, retryAfter);
|
|
353
|
+
case 429:
|
|
354
|
+
return new ClientError("rate-limited", message, retryAfter);
|
|
355
|
+
case 400:
|
|
356
|
+
case 404:
|
|
357
|
+
return new ClientError("rejected", message, retryAfter);
|
|
358
|
+
default:
|
|
359
|
+
return response.status >= 500 ? new ClientError("server-error", message, retryAfter) : new ClientError("rejected", message, retryAfter);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
// src/config.ts
|
|
365
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
366
|
+
import {
|
|
367
|
+
BackendIdSchema,
|
|
368
|
+
JobKind,
|
|
369
|
+
OfferScope,
|
|
370
|
+
backendDescriptor,
|
|
371
|
+
effectiveOfferScope
|
|
372
|
+
} from "@byollm/protocol";
|
|
373
|
+
import { z as z3 } from "zod";
|
|
374
|
+
|
|
375
|
+
// src/ssrf.ts
|
|
376
|
+
import { isIP } from "net";
|
|
377
|
+
var METADATA_HOSTS = /* @__PURE__ */ new Set([
|
|
378
|
+
"metadata.google.internal",
|
|
379
|
+
"metadata.goog",
|
|
380
|
+
"instance-data",
|
|
381
|
+
"metadata"
|
|
382
|
+
]);
|
|
383
|
+
var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
|
|
384
|
+
"169.254.169.254",
|
|
385
|
+
"169.254.170.2",
|
|
386
|
+
"fd00:ec2::254"
|
|
387
|
+
]);
|
|
388
|
+
function checkBaseUrl(raw) {
|
|
389
|
+
let url;
|
|
390
|
+
try {
|
|
391
|
+
url = new URL(raw);
|
|
392
|
+
} catch {
|
|
393
|
+
return {
|
|
394
|
+
ok: false,
|
|
395
|
+
refusal: "not-a-url",
|
|
396
|
+
detail: "base URL is not a valid absolute URL"
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
400
|
+
return {
|
|
401
|
+
ok: false,
|
|
402
|
+
refusal: "bad-scheme",
|
|
403
|
+
detail: `base URL scheme ${url.protocol} is not http or https`
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
if (url.username !== "" || url.password !== "") {
|
|
407
|
+
return {
|
|
408
|
+
ok: false,
|
|
409
|
+
refusal: "credentials-in-url",
|
|
410
|
+
detail: "base URL must not embed a username or password"
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
414
|
+
if (METADATA_HOSTS.has(host) || METADATA_ADDRESSES.has(host)) {
|
|
415
|
+
return {
|
|
416
|
+
ok: false,
|
|
417
|
+
refusal: "cloud-metadata",
|
|
418
|
+
detail: `${host} is a cloud metadata endpoint`
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
if (isIP(host) === 4 && host.startsWith("169.254.")) {
|
|
422
|
+
return {
|
|
423
|
+
ok: false,
|
|
424
|
+
refusal: "link-local",
|
|
425
|
+
detail: `${host} is link-local`
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
if (isIP(host) === 6 && /^fe[89ab]/.test(host)) {
|
|
429
|
+
return {
|
|
430
|
+
ok: false,
|
|
431
|
+
refusal: "link-local",
|
|
432
|
+
detail: `${host} is link-local`
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
if (host === "0.0.0.0" || host === "::" || host === "") {
|
|
436
|
+
return {
|
|
437
|
+
ok: false,
|
|
438
|
+
refusal: "wildcard-address",
|
|
439
|
+
detail: `${host || "(empty)"} is not a destination address`
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
return { ok: true, url };
|
|
443
|
+
}
|
|
444
|
+
var BASE_URL_REFUSAL_MESSAGES = Object.freeze({
|
|
445
|
+
"not-a-url": "that base URL could not be parsed",
|
|
446
|
+
"bad-scheme": "a backend base URL must be http or https",
|
|
447
|
+
"credentials-in-url": "put credentials in the backend's auth config, not in the URL",
|
|
448
|
+
"cloud-metadata": "that address is a cloud metadata endpoint and would expose instance credentials",
|
|
449
|
+
"link-local": "link-local addresses are refused",
|
|
450
|
+
"wildcard-address": "that is an address to listen on, not one to connect to \u2014 use 127.0.0.1"
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// src/config.ts
|
|
454
|
+
var BackendConfig = z3.object({
|
|
455
|
+
backend: BackendIdSchema,
|
|
456
|
+
/** Required for HTTP-class backends; meaningless for process-class. */
|
|
457
|
+
baseUrl: z3.string().optional(),
|
|
458
|
+
/**
|
|
459
|
+
* What the owner is willing to run for others. Subscription-class
|
|
460
|
+
* backends ignore this and are locked to `self`
|
|
461
|
+
* ({@link MUSTS.SUBSCRIPTION_SELF_LOCK}).
|
|
462
|
+
*/
|
|
463
|
+
offer: OfferScope.default("self"),
|
|
464
|
+
/**
|
|
465
|
+
* Name of an environment variable holding this backend's API key, for a
|
|
466
|
+
* remote OpenAI-compatible server that needs one. The *name*, never the
|
|
467
|
+
* value — a key does not belong in a config file the owner may share.
|
|
468
|
+
*/
|
|
469
|
+
apiKeyEnv: z3.string().optional()
|
|
470
|
+
}).strict();
|
|
471
|
+
var RouteConfig = z3.object({
|
|
472
|
+
/** Key into {@link DaemonConfig.backends}. */
|
|
473
|
+
backend: z3.string().min(1),
|
|
474
|
+
/** The model name, owner-chosen. A payload can never influence this. */
|
|
475
|
+
model: z3.string().min(1)
|
|
476
|
+
}).strict();
|
|
477
|
+
var CommunityBudget = z3.object({
|
|
478
|
+
maxJobsPerHour: z3.number().int().positive().default(20),
|
|
479
|
+
maxJobsPerDay: z3.number().int().positive().default(100),
|
|
480
|
+
/** Wall-clock ceiling for one community job. */
|
|
481
|
+
maxWallClockMs: z3.number().int().positive().default(12e4),
|
|
482
|
+
/** Output-size ceiling for one community job. */
|
|
483
|
+
maxOutputBytes: z3.number().int().positive().default(256 * 1024),
|
|
484
|
+
/** Payload-size ceiling — stricter than the protocol's absolute limit. */
|
|
485
|
+
maxPayloadChars: z3.number().int().positive().default(1e5)
|
|
486
|
+
}).strict();
|
|
487
|
+
var IngressRetention = z3.object({
|
|
488
|
+
/**
|
|
489
|
+
* How long a `named`/`public` prompt is kept in full before being reduced
|
|
490
|
+
* to a hash. A volunteer must not indefinitely retain strangers' content.
|
|
491
|
+
*/
|
|
492
|
+
communityPromptDays: z3.number().int().positive().default(7),
|
|
493
|
+
/** Whether the owner's own prompts are kept in full. Their call. */
|
|
494
|
+
keepSelfPrompts: z3.boolean().default(true)
|
|
495
|
+
}).strict();
|
|
496
|
+
var Limits = z3.object({
|
|
497
|
+
maxWallClockMs: z3.number().int().positive().default(6e5),
|
|
498
|
+
maxOutputBytes: z3.number().int().positive().default(4 * 1024 * 1024)
|
|
499
|
+
}).strict();
|
|
500
|
+
var DaemonConfig = z3.object({
|
|
501
|
+
backends: z3.record(z3.string().min(1), BackendConfig),
|
|
502
|
+
// `partialRecord`, not `record`: zod 4's `record` with an enum key
|
|
503
|
+
// demands every member be present, which would force an owner who only
|
|
504
|
+
// wants `llm.generate` to also configure `llm.chat`. Routing one kind and
|
|
505
|
+
// not the other is a legitimate setup — the unrouted kind is simply never
|
|
506
|
+
// advertised.
|
|
507
|
+
routes: z3.partialRecord(JobKind, RouteConfig),
|
|
508
|
+
/** How many jobs to run at once. */
|
|
509
|
+
concurrency: z3.number().int().min(1).max(32).default(2),
|
|
510
|
+
// `prefault`, not `default`: zod 4's `.default()` takes an *output* value,
|
|
511
|
+
// which would mean restating every nested default here where it could
|
|
512
|
+
// drift. `prefault` feeds `{}` through the schema so the nested defaults
|
|
513
|
+
// stay the single source of truth.
|
|
514
|
+
community: CommunityBudget.prefault({}),
|
|
515
|
+
ingress: IngressRetention.prefault({}),
|
|
516
|
+
limits: Limits.prefault({})
|
|
517
|
+
}).strict();
|
|
518
|
+
var DEFAULT_CONFIG = DaemonConfig.parse({
|
|
519
|
+
backends: {
|
|
520
|
+
ollama: { backend: "openai-http", baseUrl: "http://127.0.0.1:11434/v1" }
|
|
521
|
+
},
|
|
522
|
+
routes: {
|
|
523
|
+
"llm.generate": { backend: "ollama", model: "llama3.2" },
|
|
524
|
+
"llm.chat": { backend: "ollama", model: "llama3.2" }
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
async function loadConfig(path) {
|
|
528
|
+
let raw;
|
|
529
|
+
try {
|
|
530
|
+
raw = await readFile3(path, "utf8");
|
|
531
|
+
} catch (error) {
|
|
532
|
+
if (isNotFound(error)) return resolveConfig(DEFAULT_CONFIG);
|
|
533
|
+
throw error;
|
|
534
|
+
}
|
|
535
|
+
let parsed;
|
|
536
|
+
try {
|
|
537
|
+
parsed = JSON.parse(raw);
|
|
538
|
+
} catch (error) {
|
|
539
|
+
throw new Error(
|
|
540
|
+
`${path} is not valid JSON: ${error instanceof Error ? error.message : "unknown error"}`,
|
|
541
|
+
{ cause: error }
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
const result = DaemonConfig.safeParse(parsed);
|
|
545
|
+
if (!result.success) {
|
|
546
|
+
const issues = result.error.issues.map((issue) => ` ${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n");
|
|
547
|
+
throw new Error(`${path} is not a valid byollm config:
|
|
548
|
+
${issues}`);
|
|
549
|
+
}
|
|
550
|
+
return resolveConfig(result.data);
|
|
551
|
+
}
|
|
552
|
+
function resolveConfig(config) {
|
|
553
|
+
const routes = [];
|
|
554
|
+
const problems = [];
|
|
555
|
+
for (const [kind, route] of Object.entries(config.routes)) {
|
|
556
|
+
const where = `routes.${kind}`;
|
|
557
|
+
const backend = config.backends[route.backend];
|
|
558
|
+
if (!backend) {
|
|
559
|
+
problems.push({
|
|
560
|
+
where,
|
|
561
|
+
message: `backend "${route.backend}" is not defined in backends`
|
|
562
|
+
});
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
const descriptor = backendDescriptor(backend.backend);
|
|
566
|
+
if (descriptor.class === "http") {
|
|
567
|
+
if (backend.baseUrl === void 0) {
|
|
568
|
+
problems.push({
|
|
569
|
+
where: `backends.${route.backend}`,
|
|
570
|
+
message: "an HTTP-class backend needs a baseUrl"
|
|
571
|
+
});
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
const check = checkBaseUrl(backend.baseUrl);
|
|
575
|
+
if (!check.ok) {
|
|
576
|
+
problems.push({
|
|
577
|
+
where: `backends.${route.backend}.baseUrl`,
|
|
578
|
+
message: check.detail
|
|
579
|
+
});
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const configured = backend.offer;
|
|
584
|
+
const offerScope = effectiveOfferScope(configured, descriptor.account);
|
|
585
|
+
if (offerScope !== configured) {
|
|
586
|
+
problems.push({
|
|
587
|
+
where: `backends.${route.backend}.offer`,
|
|
588
|
+
message: `"${configured}" was ignored: ${descriptor.label} runs on your own subscription, so it is locked to your work only`
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
routes.push({
|
|
592
|
+
kind,
|
|
593
|
+
backendKey: route.backend,
|
|
594
|
+
backendId: backend.backend,
|
|
595
|
+
backendClass: descriptor.class,
|
|
596
|
+
model: route.model,
|
|
597
|
+
offerScope,
|
|
598
|
+
baseUrl: backend.baseUrl,
|
|
599
|
+
apiKeyEnv: backend.apiKeyEnv
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
return { config, routes, problems };
|
|
603
|
+
}
|
|
604
|
+
function isNotFound(error) {
|
|
605
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// src/connect.ts
|
|
609
|
+
import { platform } from "os";
|
|
610
|
+
async function connect(options) {
|
|
611
|
+
const now = options.now ?? Date.now;
|
|
612
|
+
const sleep2 = options.sleep ?? defaultSleep;
|
|
613
|
+
const started = await options.client.pairStart({
|
|
614
|
+
version: options.daemonVersion,
|
|
615
|
+
label: options.label,
|
|
616
|
+
platform: currentPlatform(),
|
|
617
|
+
capabilities: options.capabilities
|
|
618
|
+
});
|
|
619
|
+
options.onCode({
|
|
620
|
+
userCode: started.userCode,
|
|
621
|
+
verificationUrl: started.verificationUrl,
|
|
622
|
+
expiresAt: started.expiresAt
|
|
623
|
+
});
|
|
624
|
+
for (; ; ) {
|
|
625
|
+
if (options.signal?.aborted === true) {
|
|
626
|
+
return { ok: false, reason: "aborted", message: "pairing was canceled" };
|
|
627
|
+
}
|
|
628
|
+
if (now() >= started.expiresAt) {
|
|
629
|
+
return {
|
|
630
|
+
ok: false,
|
|
631
|
+
reason: "expired",
|
|
632
|
+
message: "the pairing code expired before it was approved"
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
await sleep2(started.pollIntervalMs);
|
|
636
|
+
options.onPoll?.();
|
|
637
|
+
let polled;
|
|
638
|
+
try {
|
|
639
|
+
polled = await options.client.pairPoll(started.deviceCode);
|
|
640
|
+
} catch (error) {
|
|
641
|
+
if (error instanceof ClientError && error.retryable) continue;
|
|
642
|
+
throw error;
|
|
643
|
+
}
|
|
644
|
+
switch (polled.status) {
|
|
645
|
+
case "pending":
|
|
646
|
+
continue;
|
|
647
|
+
case "denied":
|
|
648
|
+
return {
|
|
649
|
+
ok: false,
|
|
650
|
+
reason: "denied",
|
|
651
|
+
message: "the pairing request was declined"
|
|
652
|
+
};
|
|
653
|
+
case "expired":
|
|
654
|
+
return {
|
|
655
|
+
ok: false,
|
|
656
|
+
reason: "expired",
|
|
657
|
+
message: "the pairing code expired before it was approved"
|
|
658
|
+
};
|
|
659
|
+
case "approved":
|
|
660
|
+
return {
|
|
661
|
+
ok: true,
|
|
662
|
+
pairing: {
|
|
663
|
+
origin: options.client.origin,
|
|
664
|
+
runnerId: polled.runnerId,
|
|
665
|
+
token: polled.runnerToken,
|
|
666
|
+
owner: polled.owner,
|
|
667
|
+
...polled.ownerLabel === void 0 ? {} : { ownerLabel: polled.ownerLabel },
|
|
668
|
+
pairedAt: now()
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
function currentPlatform() {
|
|
675
|
+
const current = platform();
|
|
676
|
+
if (current === "darwin" || current === "linux" || current === "win32") {
|
|
677
|
+
return current;
|
|
678
|
+
}
|
|
679
|
+
return "linux";
|
|
680
|
+
}
|
|
681
|
+
var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
682
|
+
|
|
683
|
+
// src/ingress.ts
|
|
684
|
+
import { createHash } from "crypto";
|
|
685
|
+
import { appendFile, mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
|
|
686
|
+
import { dirname as dirname3 } from "path";
|
|
687
|
+
import { z as z4 } from "zod";
|
|
688
|
+
var PromptEntry = z4.object({
|
|
689
|
+
type: z4.literal("prompt"),
|
|
690
|
+
at: z4.number().int().positive(),
|
|
691
|
+
/** Origin of the server that sent the job. */
|
|
692
|
+
origin: z4.string().min(1),
|
|
693
|
+
jobId: z4.string().min(1),
|
|
694
|
+
kind: z4.string().min(1),
|
|
695
|
+
audience: z4.string().min(1),
|
|
696
|
+
/** Who this prompt is *for* — which, for community work, is not you. */
|
|
697
|
+
owner: z4.string().min(1),
|
|
698
|
+
backendId: z4.string().min(1),
|
|
699
|
+
backendClass: z4.string().min(1),
|
|
700
|
+
model: z4.string().min(1),
|
|
701
|
+
/** Always present, including after retention drops the text. */
|
|
702
|
+
promptHash: z4.string().length(64),
|
|
703
|
+
promptChars: z4.number().int().nonnegative(),
|
|
704
|
+
/**
|
|
705
|
+
* The prompt itself. Absent means "not retained" — distinct from an empty
|
|
706
|
+
* prompt, which the protocol forbids anyway. Zero and unknown never look
|
|
707
|
+
* alike.
|
|
708
|
+
*/
|
|
709
|
+
prompt: z4.string().optional()
|
|
710
|
+
}).strict();
|
|
711
|
+
var OutcomeEntry = z4.object({
|
|
712
|
+
type: z4.literal("outcome"),
|
|
713
|
+
at: z4.number().int().positive(),
|
|
714
|
+
jobId: z4.string().min(1),
|
|
715
|
+
outcome: z4.enum(["ok", "error", "canceled", "refused"]),
|
|
716
|
+
/** Present for a job that ran; absent for one refused before execution. */
|
|
717
|
+
durationMs: z4.number().int().nonnegative().optional(),
|
|
718
|
+
outputChars: z4.number().int().nonnegative().optional(),
|
|
719
|
+
/** Why, for `error` and `refused`. */
|
|
720
|
+
detail: z4.string().optional()
|
|
721
|
+
}).strict();
|
|
722
|
+
var IngressEntry = z4.discriminatedUnion("type", [
|
|
723
|
+
PromptEntry,
|
|
724
|
+
OutcomeEntry
|
|
725
|
+
]);
|
|
726
|
+
var IngressLog = class {
|
|
727
|
+
#options;
|
|
728
|
+
constructor(options) {
|
|
729
|
+
this.#options = options;
|
|
730
|
+
}
|
|
731
|
+
/** Record a prompt. Await this before starting the backend call. */
|
|
732
|
+
async recordPrompt(input) {
|
|
733
|
+
const keepText = input.audience === "self" ? this.#options.keepSelfPrompts : true;
|
|
734
|
+
await this.#append({
|
|
735
|
+
type: "prompt",
|
|
736
|
+
at: input.at,
|
|
737
|
+
origin: input.origin,
|
|
738
|
+
jobId: input.jobId,
|
|
739
|
+
kind: input.kind,
|
|
740
|
+
audience: input.audience,
|
|
741
|
+
owner: input.owner,
|
|
742
|
+
backendId: input.backendId,
|
|
743
|
+
backendClass: input.backendClass,
|
|
744
|
+
model: input.model,
|
|
745
|
+
promptHash: hashText(input.prompt),
|
|
746
|
+
promptChars: input.prompt.length,
|
|
747
|
+
...keepText ? { prompt: input.prompt } : {}
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
/** Record how a job ended. */
|
|
751
|
+
async recordOutcome(input) {
|
|
752
|
+
await this.#append({
|
|
753
|
+
type: "outcome",
|
|
754
|
+
at: input.at,
|
|
755
|
+
jobId: input.jobId,
|
|
756
|
+
outcome: input.outcome,
|
|
757
|
+
...input.durationMs === void 0 ? {} : { durationMs: input.durationMs },
|
|
758
|
+
...input.outputChars === void 0 ? {} : { outputChars: input.outputChars },
|
|
759
|
+
...input.detail === void 0 ? {} : { detail: input.detail }
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
async #append(entry) {
|
|
763
|
+
await mkdir3(dirname3(this.#options.path), { recursive: true });
|
|
764
|
+
await appendFile(this.#options.path, `${JSON.stringify(entry)}
|
|
765
|
+
`, {
|
|
766
|
+
mode: 384
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
/** Read the log, oldest first. Malformed lines are skipped, not fatal. */
|
|
770
|
+
async read() {
|
|
771
|
+
let raw;
|
|
772
|
+
try {
|
|
773
|
+
raw = await readFile4(this.#options.path, "utf8");
|
|
774
|
+
} catch {
|
|
775
|
+
return [];
|
|
776
|
+
}
|
|
777
|
+
const entries = [];
|
|
778
|
+
for (const line of raw.split("\n")) {
|
|
779
|
+
if (line.trim() === "") continue;
|
|
780
|
+
try {
|
|
781
|
+
const parsed = IngressEntry.safeParse(JSON.parse(line));
|
|
782
|
+
if (parsed.success) entries.push(parsed.data);
|
|
783
|
+
} catch {
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return entries;
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Apply retention: drop the text of community prompts older than the
|
|
790
|
+
* window, keeping the hash, the metadata and the character count.
|
|
791
|
+
*
|
|
792
|
+
* byollm_004 Rev 1: a volunteer must not indefinitely retain strangers'
|
|
793
|
+
* content. The hash stays, so the owner can still prove what ran.
|
|
794
|
+
*
|
|
795
|
+
* @returns how many entries were reduced.
|
|
796
|
+
*/
|
|
797
|
+
async applyRetention(now) {
|
|
798
|
+
const entries = await this.read();
|
|
799
|
+
const cutoff = now - this.#options.communityPromptDays * 864e5;
|
|
800
|
+
let reduced = 0;
|
|
801
|
+
const kept = entries.map((entry) => {
|
|
802
|
+
if (entry.type !== "prompt") return entry;
|
|
803
|
+
const isCommunity = entry.audience === "named" || entry.audience === "public";
|
|
804
|
+
if (!isCommunity || entry.prompt === void 0 || entry.at >= cutoff) {
|
|
805
|
+
return entry;
|
|
806
|
+
}
|
|
807
|
+
reduced += 1;
|
|
808
|
+
const { prompt: _dropped, ...rest } = entry;
|
|
809
|
+
return rest;
|
|
810
|
+
});
|
|
811
|
+
if (reduced > 0) {
|
|
812
|
+
await writeFile3(
|
|
813
|
+
this.#options.path,
|
|
814
|
+
`${kept.map((entry) => JSON.stringify(entry)).join("\n")}
|
|
815
|
+
`,
|
|
816
|
+
{ mode: 384 }
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
return reduced;
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
function hashText(text) {
|
|
823
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
824
|
+
}
|
|
825
|
+
function stripControlChars(text) {
|
|
826
|
+
return text.replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g, "\uFFFD");
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// src/pairings.ts
|
|
830
|
+
import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
|
|
831
|
+
import { dirname as dirname4 } from "path";
|
|
832
|
+
import { z as z5 } from "zod";
|
|
833
|
+
var Pairing = z5.object({
|
|
834
|
+
origin: z5.string().min(1),
|
|
835
|
+
runnerId: z5.string().min(1),
|
|
836
|
+
/** Bearer token. This file is written 0600 for exactly this reason. */
|
|
837
|
+
token: z5.string().min(1),
|
|
838
|
+
/** The app's id for this daemon's owner. */
|
|
839
|
+
owner: z5.string().min(1),
|
|
840
|
+
ownerLabel: z5.string().optional(),
|
|
841
|
+
pairedAt: z5.number().int().positive()
|
|
842
|
+
}).strict();
|
|
843
|
+
var PairingFile = z5.object({ version: z5.literal(1), pairings: z5.array(Pairing) }).strict();
|
|
844
|
+
var Pairings = class {
|
|
845
|
+
#path;
|
|
846
|
+
#pairings = [];
|
|
847
|
+
#loaded = false;
|
|
848
|
+
constructor(path) {
|
|
849
|
+
this.#path = path;
|
|
850
|
+
}
|
|
851
|
+
async load() {
|
|
852
|
+
try {
|
|
853
|
+
const parsed = PairingFile.safeParse(
|
|
854
|
+
JSON.parse(await readFile5(this.#path, "utf8"))
|
|
855
|
+
);
|
|
856
|
+
this.#pairings = parsed.success ? parsed.data.pairings : [];
|
|
857
|
+
} catch {
|
|
858
|
+
this.#pairings = [];
|
|
859
|
+
}
|
|
860
|
+
this.#loaded = true;
|
|
861
|
+
}
|
|
862
|
+
list() {
|
|
863
|
+
this.#assertLoaded();
|
|
864
|
+
return [...this.#pairings];
|
|
865
|
+
}
|
|
866
|
+
get(origin) {
|
|
867
|
+
this.#assertLoaded();
|
|
868
|
+
const normalized = normalizeOrigin(origin);
|
|
869
|
+
return this.#pairings.find(
|
|
870
|
+
(pairing) => normalizeOrigin(pairing.origin) === normalized
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
/** Add or replace the pairing for an origin. Re-pairing supersedes. */
|
|
874
|
+
async put(pairing) {
|
|
875
|
+
this.#assertLoaded();
|
|
876
|
+
const normalized = normalizeOrigin(pairing.origin);
|
|
877
|
+
this.#pairings = this.#pairings.filter(
|
|
878
|
+
(existing) => normalizeOrigin(existing.origin) !== normalized
|
|
879
|
+
);
|
|
880
|
+
this.#pairings.push({ ...pairing, origin: normalized });
|
|
881
|
+
await this.#save();
|
|
882
|
+
}
|
|
883
|
+
/** Forget a pairing. Returns whether one was removed. */
|
|
884
|
+
async remove(origin) {
|
|
885
|
+
this.#assertLoaded();
|
|
886
|
+
const normalized = normalizeOrigin(origin);
|
|
887
|
+
const before = this.#pairings.length;
|
|
888
|
+
this.#pairings = this.#pairings.filter(
|
|
889
|
+
(pairing) => normalizeOrigin(pairing.origin) !== normalized
|
|
890
|
+
);
|
|
891
|
+
if (this.#pairings.length === before) return false;
|
|
892
|
+
await this.#save();
|
|
893
|
+
return true;
|
|
894
|
+
}
|
|
895
|
+
#assertLoaded() {
|
|
896
|
+
if (!this.#loaded) throw new Error("pairings used before load()");
|
|
897
|
+
}
|
|
898
|
+
async #save() {
|
|
899
|
+
await mkdir4(dirname4(this.#path), { recursive: true });
|
|
900
|
+
await writeFile4(
|
|
901
|
+
this.#path,
|
|
902
|
+
`${JSON.stringify({ version: 1, pairings: this.#pairings }, null, 2)}
|
|
903
|
+
`,
|
|
904
|
+
// Tokens live here. Nobody else on a shared machine gets to read them.
|
|
905
|
+
{ mode: 384 }
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
// src/paths.ts
|
|
911
|
+
import { homedir } from "os";
|
|
912
|
+
import { join } from "path";
|
|
913
|
+
function daemonPaths(root = defaultRoot()) {
|
|
914
|
+
return {
|
|
915
|
+
root,
|
|
916
|
+
config: join(root, "config.json"),
|
|
917
|
+
pairings: join(root, "pairings.json"),
|
|
918
|
+
allowlist: join(root, "allow.json"),
|
|
919
|
+
ingressLog: join(root, "ingress.log"),
|
|
920
|
+
budgets: join(root, "budgets.json"),
|
|
921
|
+
pauseFlag: join(root, "paused"),
|
|
922
|
+
scratch: join(root, "scratch")
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
function defaultRoot() {
|
|
926
|
+
return process.env["BYOLLM_HOME"] ?? join(homedir(), ".byollm");
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// src/runner.ts
|
|
930
|
+
import {
|
|
931
|
+
REFUSAL_MESSAGES,
|
|
932
|
+
backendDescriptor as backendDescriptor2,
|
|
933
|
+
matchAudience,
|
|
934
|
+
payloadTextLength
|
|
935
|
+
} from "@byollm/protocol";
|
|
936
|
+
|
|
937
|
+
// src/backends/index.ts
|
|
938
|
+
import { BACKEND_IDS } from "@byollm/protocol";
|
|
939
|
+
|
|
940
|
+
// src/backends/claude-cli.ts
|
|
941
|
+
import { execFile, spawn } from "child_process";
|
|
942
|
+
import { mkdtemp, rm } from "fs/promises";
|
|
943
|
+
import { tmpdir } from "os";
|
|
944
|
+
import { join as join2 } from "path";
|
|
945
|
+
var FIXED_ARGV = Object.freeze([
|
|
946
|
+
"--print",
|
|
947
|
+
"--output-format",
|
|
948
|
+
"text",
|
|
949
|
+
"--tools",
|
|
950
|
+
"",
|
|
951
|
+
"--strict-mcp-config",
|
|
952
|
+
"--mcp-config",
|
|
953
|
+
'{"mcpServers":{}}',
|
|
954
|
+
"--no-session-persistence"
|
|
955
|
+
]);
|
|
956
|
+
var ENV_ALLOWLIST = Object.freeze([
|
|
957
|
+
"PATH",
|
|
958
|
+
"HOME",
|
|
959
|
+
"LANG",
|
|
960
|
+
"LC_ALL",
|
|
961
|
+
"TZ",
|
|
962
|
+
"TMPDIR"
|
|
963
|
+
]);
|
|
964
|
+
function childEnv(source = process.env) {
|
|
965
|
+
const env = {};
|
|
966
|
+
for (const name of ENV_ALLOWLIST) {
|
|
967
|
+
const value = source[name];
|
|
968
|
+
if (value !== void 0) env[name] = value;
|
|
969
|
+
}
|
|
970
|
+
env["CI"] = "1";
|
|
971
|
+
return env;
|
|
972
|
+
}
|
|
973
|
+
function claudeArgv(model) {
|
|
974
|
+
return Object.freeze([...FIXED_ARGV, "--model", model]);
|
|
975
|
+
}
|
|
976
|
+
var ClaudeCliBackend = class {
|
|
977
|
+
id = "claude-cli";
|
|
978
|
+
class = "process";
|
|
979
|
+
#binary;
|
|
980
|
+
/**
|
|
981
|
+
* @param binary - which executable to run. Defaults to `claude` and is
|
|
982
|
+
* **not reachable from configuration**: {@link createBackend} constructs
|
|
983
|
+
* this with no arguments, and {@link BackendInit} has no field for it. It
|
|
984
|
+
* exists so the adversarial suite can substitute a probe that reports the
|
|
985
|
+
* argv, environment, cwd and stdin it actually received — which is the only
|
|
986
|
+
* way to *prove* byollm_004 §2 rather than assert it.
|
|
987
|
+
*/
|
|
988
|
+
constructor(binary = "claude") {
|
|
989
|
+
this.#binary = binary;
|
|
990
|
+
}
|
|
991
|
+
async health() {
|
|
992
|
+
const version = await new Promise((resolve) => {
|
|
993
|
+
execFile(
|
|
994
|
+
this.#binary,
|
|
995
|
+
["--version"],
|
|
996
|
+
{ timeout: 1e4, env: childEnv() },
|
|
997
|
+
(error, stdout) => {
|
|
998
|
+
resolve(error ? null : stdout.trim());
|
|
999
|
+
}
|
|
1000
|
+
);
|
|
1001
|
+
});
|
|
1002
|
+
if (version === null) {
|
|
1003
|
+
return {
|
|
1004
|
+
healthy: false,
|
|
1005
|
+
models: [],
|
|
1006
|
+
detail: "the `claude` CLI is not installed or not on PATH (https://claude.com/claude-code)"
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
return { healthy: true, models: [] };
|
|
1010
|
+
}
|
|
1011
|
+
async execute(request) {
|
|
1012
|
+
const started = Date.now();
|
|
1013
|
+
const scratch = await mkdtemp(join2(tmpdir(), "byollm-job-"));
|
|
1014
|
+
try {
|
|
1015
|
+
return await this.#spawn(request, scratch, started);
|
|
1016
|
+
} finally {
|
|
1017
|
+
await rm(scratch, { recursive: true, force: true });
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
async #spawn(request, scratch, started) {
|
|
1021
|
+
return new Promise((resolve) => {
|
|
1022
|
+
if (request.signal.aborted) {
|
|
1023
|
+
resolve({
|
|
1024
|
+
ok: false,
|
|
1025
|
+
code: "canceled",
|
|
1026
|
+
message: "the job was canceled before it started",
|
|
1027
|
+
retryable: false,
|
|
1028
|
+
durationMs: Date.now() - started
|
|
1029
|
+
});
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
const child = spawn(this.#binary, claudeArgv(request.model), {
|
|
1033
|
+
cwd: scratch,
|
|
1034
|
+
env: childEnv(),
|
|
1035
|
+
// Exactly the three std streams. Nothing else is inherited, so the
|
|
1036
|
+
// child cannot reach a descriptor the daemon happens to hold open.
|
|
1037
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1038
|
+
// No shell, ever. With `shell: false` the argv array is passed to
|
|
1039
|
+
// execvp verbatim and metacharacters in it are just bytes.
|
|
1040
|
+
shell: false,
|
|
1041
|
+
detached: false
|
|
1042
|
+
});
|
|
1043
|
+
let stdout = "";
|
|
1044
|
+
let stderr = "";
|
|
1045
|
+
let outputBytes = 0;
|
|
1046
|
+
let settled = false;
|
|
1047
|
+
let exited = false;
|
|
1048
|
+
let reason = null;
|
|
1049
|
+
const finish = (result) => {
|
|
1050
|
+
if (settled) return;
|
|
1051
|
+
settled = true;
|
|
1052
|
+
clearTimeout(timer);
|
|
1053
|
+
request.signal.removeEventListener("abort", onAbort);
|
|
1054
|
+
resolve(result);
|
|
1055
|
+
};
|
|
1056
|
+
const kill = (why) => {
|
|
1057
|
+
reason = why;
|
|
1058
|
+
child.kill("SIGTERM");
|
|
1059
|
+
setTimeout(() => {
|
|
1060
|
+
if (!exited) child.kill("SIGKILL");
|
|
1061
|
+
}, 2e3).unref();
|
|
1062
|
+
};
|
|
1063
|
+
const timer = setTimeout(() => {
|
|
1064
|
+
kill("timeout");
|
|
1065
|
+
}, request.timeoutMs);
|
|
1066
|
+
const onAbort = () => {
|
|
1067
|
+
kill("canceled");
|
|
1068
|
+
};
|
|
1069
|
+
request.signal.addEventListener("abort", onAbort, { once: true });
|
|
1070
|
+
child.stdout.on("data", (chunk) => {
|
|
1071
|
+
outputBytes += chunk.byteLength;
|
|
1072
|
+
if (outputBytes > request.maxOutputBytes) {
|
|
1073
|
+
kill("output-too-large");
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
stdout += chunk.toString("utf8");
|
|
1077
|
+
});
|
|
1078
|
+
child.stderr.on("data", (chunk) => {
|
|
1079
|
+
if (stderr.length < 8192) stderr += chunk.toString("utf8");
|
|
1080
|
+
});
|
|
1081
|
+
child.on("error", (error) => {
|
|
1082
|
+
finish({
|
|
1083
|
+
ok: false,
|
|
1084
|
+
code: "backend-unreachable",
|
|
1085
|
+
message: `could not start the claude CLI: ${error.message}`,
|
|
1086
|
+
retryable: false,
|
|
1087
|
+
durationMs: Date.now() - started
|
|
1088
|
+
});
|
|
1089
|
+
});
|
|
1090
|
+
child.on("close", (code) => {
|
|
1091
|
+
exited = true;
|
|
1092
|
+
const durationMs = Date.now() - started;
|
|
1093
|
+
if (reason === "canceled") {
|
|
1094
|
+
finish({
|
|
1095
|
+
ok: false,
|
|
1096
|
+
code: "canceled",
|
|
1097
|
+
message: "the job was canceled",
|
|
1098
|
+
retryable: false,
|
|
1099
|
+
durationMs
|
|
1100
|
+
});
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
if (reason === "timeout") {
|
|
1104
|
+
finish({
|
|
1105
|
+
ok: false,
|
|
1106
|
+
code: "timeout",
|
|
1107
|
+
message: `the model did not answer within ${String(request.timeoutMs)}ms`,
|
|
1108
|
+
retryable: true,
|
|
1109
|
+
durationMs
|
|
1110
|
+
});
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
if (reason === "output-too-large") {
|
|
1114
|
+
finish({
|
|
1115
|
+
ok: false,
|
|
1116
|
+
code: "output-too-large",
|
|
1117
|
+
message: `the model produced more than ${String(request.maxOutputBytes)} bytes`,
|
|
1118
|
+
retryable: false,
|
|
1119
|
+
durationMs
|
|
1120
|
+
});
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
if (code !== 0) {
|
|
1124
|
+
finish({
|
|
1125
|
+
ok: false,
|
|
1126
|
+
code: "backend-error",
|
|
1127
|
+
message: stderr.trim() === "" ? `the claude CLI exited with status ${String(code)}` : `the claude CLI failed: ${firstLine(stderr)}`,
|
|
1128
|
+
retryable: false,
|
|
1129
|
+
durationMs
|
|
1130
|
+
});
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
finish({ ok: true, text: stdout, durationMs });
|
|
1134
|
+
});
|
|
1135
|
+
child.stdin.on("error", () => {
|
|
1136
|
+
});
|
|
1137
|
+
child.stdin.end(request.prompt, "utf8");
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
};
|
|
1141
|
+
function firstLine(text) {
|
|
1142
|
+
return text.trim().split("\n")[0] ?? "";
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// src/backends/openai-http.ts
|
|
1146
|
+
var OpenAiHttpBackend = class {
|
|
1147
|
+
id = "openai-http";
|
|
1148
|
+
class = "http";
|
|
1149
|
+
#baseUrl;
|
|
1150
|
+
#apiKeyEnv;
|
|
1151
|
+
constructor(init) {
|
|
1152
|
+
if (init.baseUrl === void 0) {
|
|
1153
|
+
throw new Error("openai-http backend requires a baseUrl");
|
|
1154
|
+
}
|
|
1155
|
+
const check = checkBaseUrl(init.baseUrl);
|
|
1156
|
+
if (!check.ok) {
|
|
1157
|
+
throw new Error(`refusing base URL: ${check.detail}`);
|
|
1158
|
+
}
|
|
1159
|
+
this.#baseUrl = check.url;
|
|
1160
|
+
this.#apiKeyEnv = init.apiKeyEnv;
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Build a URL under the configured base.
|
|
1164
|
+
*
|
|
1165
|
+
* The path is a hardcoded literal from this file — never anything derived
|
|
1166
|
+
* from a job — and the result is re-checked against the base's origin so a
|
|
1167
|
+
* surprising `baseUrl` (say, one with a `..` path) cannot walk elsewhere.
|
|
1168
|
+
*/
|
|
1169
|
+
#endpoint(path) {
|
|
1170
|
+
const base = this.#baseUrl.href.endsWith("/") ? this.#baseUrl.href : `${this.#baseUrl.href}/`;
|
|
1171
|
+
const url = new URL(path, base);
|
|
1172
|
+
if (url.origin !== this.#baseUrl.origin) {
|
|
1173
|
+
throw new Error("computed endpoint left the configured origin");
|
|
1174
|
+
}
|
|
1175
|
+
return url;
|
|
1176
|
+
}
|
|
1177
|
+
#headers() {
|
|
1178
|
+
const headers = {
|
|
1179
|
+
"content-type": "application/json",
|
|
1180
|
+
accept: "application/json"
|
|
1181
|
+
};
|
|
1182
|
+
if (this.#apiKeyEnv !== void 0) {
|
|
1183
|
+
const key = process.env[this.#apiKeyEnv];
|
|
1184
|
+
if (key !== void 0 && key !== "") {
|
|
1185
|
+
headers["authorization"] = `Bearer ${key}`;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
return headers;
|
|
1189
|
+
}
|
|
1190
|
+
async health() {
|
|
1191
|
+
try {
|
|
1192
|
+
const response = await fetch(this.#endpoint("models"), {
|
|
1193
|
+
method: "GET",
|
|
1194
|
+
headers: this.#headers(),
|
|
1195
|
+
redirect: "error",
|
|
1196
|
+
signal: AbortSignal.timeout(5e3)
|
|
1197
|
+
});
|
|
1198
|
+
if (!response.ok) {
|
|
1199
|
+
return {
|
|
1200
|
+
healthy: false,
|
|
1201
|
+
models: [],
|
|
1202
|
+
detail: `model list returned HTTP ${String(response.status)}`
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
const body = await response.json();
|
|
1206
|
+
return { healthy: true, models: extractModelIds(body) };
|
|
1207
|
+
} catch (error) {
|
|
1208
|
+
return {
|
|
1209
|
+
healthy: false,
|
|
1210
|
+
models: [],
|
|
1211
|
+
detail: describeFetchError(error, this.#baseUrl.origin)
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
async execute(request) {
|
|
1216
|
+
const started = Date.now();
|
|
1217
|
+
const timeout = AbortSignal.timeout(request.timeoutMs);
|
|
1218
|
+
const signal = AbortSignal.any([request.signal, timeout]);
|
|
1219
|
+
try {
|
|
1220
|
+
const response = await fetch(this.#endpoint("chat/completions"), {
|
|
1221
|
+
method: "POST",
|
|
1222
|
+
headers: this.#headers(),
|
|
1223
|
+
// The payload is a JSON string field. Nothing about it is parsed as
|
|
1224
|
+
// configuration, and `model` comes from owner config.
|
|
1225
|
+
body: JSON.stringify({
|
|
1226
|
+
model: request.model,
|
|
1227
|
+
messages: [{ role: "user", content: request.prompt }],
|
|
1228
|
+
stream: false
|
|
1229
|
+
}),
|
|
1230
|
+
redirect: "error",
|
|
1231
|
+
signal
|
|
1232
|
+
});
|
|
1233
|
+
if (response.status === 401 || response.status === 403) {
|
|
1234
|
+
return this.#fail(
|
|
1235
|
+
"unauthorized",
|
|
1236
|
+
"the model server rejected our credentials",
|
|
1237
|
+
false,
|
|
1238
|
+
started
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
if (response.status === 404) {
|
|
1242
|
+
return this.#fail(
|
|
1243
|
+
"model-not-found",
|
|
1244
|
+
`the model server does not know "${request.model}"`,
|
|
1245
|
+
false,
|
|
1246
|
+
started
|
|
1247
|
+
);
|
|
1248
|
+
}
|
|
1249
|
+
if (!response.ok) {
|
|
1250
|
+
return this.#fail(
|
|
1251
|
+
"backend-error",
|
|
1252
|
+
`the model server returned HTTP ${String(response.status)}`,
|
|
1253
|
+
response.status >= 500,
|
|
1254
|
+
started
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
const text = await readCapped(response, request.maxOutputBytes, signal);
|
|
1258
|
+
if (text === null) {
|
|
1259
|
+
return this.#fail(
|
|
1260
|
+
"output-too-large",
|
|
1261
|
+
`the model produced more than ${String(request.maxOutputBytes)} bytes`,
|
|
1262
|
+
false,
|
|
1263
|
+
started
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
const content = extractContent(text);
|
|
1267
|
+
if (content === null) {
|
|
1268
|
+
return this.#fail(
|
|
1269
|
+
"backend-error",
|
|
1270
|
+
"the model server's response was not in OpenAI chat-completion shape",
|
|
1271
|
+
false,
|
|
1272
|
+
started
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
return { ok: true, text: content, durationMs: Date.now() - started };
|
|
1276
|
+
} catch (error) {
|
|
1277
|
+
if (request.signal.aborted) {
|
|
1278
|
+
return this.#fail("canceled", "the job was canceled", false, started);
|
|
1279
|
+
}
|
|
1280
|
+
if (isAbort(error)) {
|
|
1281
|
+
return this.#fail(
|
|
1282
|
+
"timeout",
|
|
1283
|
+
`the model did not answer within ${String(request.timeoutMs)}ms`,
|
|
1284
|
+
true,
|
|
1285
|
+
started
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
return this.#fail(
|
|
1289
|
+
"backend-unreachable",
|
|
1290
|
+
describeFetchError(error, this.#baseUrl.origin),
|
|
1291
|
+
true,
|
|
1292
|
+
started
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
#fail(code, message, retryable, started) {
|
|
1297
|
+
return {
|
|
1298
|
+
ok: false,
|
|
1299
|
+
code,
|
|
1300
|
+
message,
|
|
1301
|
+
retryable,
|
|
1302
|
+
durationMs: Date.now() - started
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
};
|
|
1306
|
+
async function readCapped(response, maxBytes, signal) {
|
|
1307
|
+
const body = response.body;
|
|
1308
|
+
if (body === null) return "";
|
|
1309
|
+
const reader = body.getReader();
|
|
1310
|
+
const chunks = [];
|
|
1311
|
+
let total = 0;
|
|
1312
|
+
try {
|
|
1313
|
+
for (; ; ) {
|
|
1314
|
+
signal.throwIfAborted();
|
|
1315
|
+
const { done, value } = await reader.read();
|
|
1316
|
+
if (done) break;
|
|
1317
|
+
total += value.byteLength;
|
|
1318
|
+
if (total > maxBytes) {
|
|
1319
|
+
await reader.cancel();
|
|
1320
|
+
return null;
|
|
1321
|
+
}
|
|
1322
|
+
chunks.push(value);
|
|
1323
|
+
}
|
|
1324
|
+
} finally {
|
|
1325
|
+
reader.releaseLock();
|
|
1326
|
+
}
|
|
1327
|
+
return new TextDecoder().decode(concat(chunks, total));
|
|
1328
|
+
}
|
|
1329
|
+
function concat(chunks, total) {
|
|
1330
|
+
const out = new Uint8Array(total);
|
|
1331
|
+
let offset = 0;
|
|
1332
|
+
for (const chunk of chunks) {
|
|
1333
|
+
out.set(chunk, offset);
|
|
1334
|
+
offset += chunk.byteLength;
|
|
1335
|
+
}
|
|
1336
|
+
return out;
|
|
1337
|
+
}
|
|
1338
|
+
function extractContent(raw) {
|
|
1339
|
+
let parsed;
|
|
1340
|
+
try {
|
|
1341
|
+
parsed = JSON.parse(raw);
|
|
1342
|
+
} catch {
|
|
1343
|
+
return null;
|
|
1344
|
+
}
|
|
1345
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
1346
|
+
const choices = parsed.choices;
|
|
1347
|
+
if (!Array.isArray(choices) || choices.length === 0) return null;
|
|
1348
|
+
const message = choices[0].message;
|
|
1349
|
+
if (typeof message !== "object" || message === null) return null;
|
|
1350
|
+
const content = message.content;
|
|
1351
|
+
return typeof content === "string" ? content : null;
|
|
1352
|
+
}
|
|
1353
|
+
function extractModelIds(body) {
|
|
1354
|
+
if (typeof body !== "object" || body === null) return [];
|
|
1355
|
+
const data = body.data;
|
|
1356
|
+
if (!Array.isArray(data)) return [];
|
|
1357
|
+
return data.map(
|
|
1358
|
+
(entry) => typeof entry === "object" && entry !== null ? entry.id : void 0
|
|
1359
|
+
).filter((id) => typeof id === "string");
|
|
1360
|
+
}
|
|
1361
|
+
function isAbort(error) {
|
|
1362
|
+
return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
|
|
1363
|
+
}
|
|
1364
|
+
function describeFetchError(error, origin) {
|
|
1365
|
+
const cause = error instanceof Error && "cause" in error && error.cause instanceof Error ? error.cause.message : error instanceof Error ? error.message : "unknown error";
|
|
1366
|
+
return `could not reach the model server at ${origin} (${cause})`;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// src/backends/index.ts
|
|
1370
|
+
function createBackend(id, init) {
|
|
1371
|
+
switch (id) {
|
|
1372
|
+
case "openai-http":
|
|
1373
|
+
return new OpenAiHttpBackend(init);
|
|
1374
|
+
case "claude-cli":
|
|
1375
|
+
return new ClaudeCliBackend();
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
var IMPLEMENTED_BACKEND_IDS = BACKEND_IDS;
|
|
1379
|
+
|
|
1380
|
+
// src/compose.ts
|
|
1381
|
+
function composePrompt(job) {
|
|
1382
|
+
if (job.kind === "llm.generate") {
|
|
1383
|
+
const payload2 = job.payload;
|
|
1384
|
+
return joinSections([systemSection(payload2.system), payload2.prompt]);
|
|
1385
|
+
}
|
|
1386
|
+
const payload = job.payload;
|
|
1387
|
+
const turns = payload.messages.map((message) => `${roleLabel(message.role)}: ${message.content}`).join("\n\n");
|
|
1388
|
+
return joinSections([systemSection(payload.system), turns]);
|
|
1389
|
+
}
|
|
1390
|
+
function systemSection(system) {
|
|
1391
|
+
if (system === void 0 || system.trim() === "") return void 0;
|
|
1392
|
+
return `System instructions:
|
|
1393
|
+
${system}`;
|
|
1394
|
+
}
|
|
1395
|
+
function roleLabel(role) {
|
|
1396
|
+
switch (role) {
|
|
1397
|
+
case "assistant":
|
|
1398
|
+
return "Assistant";
|
|
1399
|
+
case "system":
|
|
1400
|
+
return "System";
|
|
1401
|
+
default:
|
|
1402
|
+
return "User";
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
function joinSections(sections) {
|
|
1406
|
+
return sections.filter((section) => section !== void 0).join("\n\n");
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// src/runner.ts
|
|
1410
|
+
var DEFAULT_HEARTBEAT_MS = 1e4;
|
|
1411
|
+
var Runner = class {
|
|
1412
|
+
#options;
|
|
1413
|
+
#backends = /* @__PURE__ */ new Map();
|
|
1414
|
+
#active = /* @__PURE__ */ new Map();
|
|
1415
|
+
#now;
|
|
1416
|
+
#capabilities = [];
|
|
1417
|
+
#paused = false;
|
|
1418
|
+
#revoked = false;
|
|
1419
|
+
#stopped = false;
|
|
1420
|
+
#lastError;
|
|
1421
|
+
#completed = 0;
|
|
1422
|
+
#refused = 0;
|
|
1423
|
+
constructor(options) {
|
|
1424
|
+
this.#options = options;
|
|
1425
|
+
this.#now = options.now ?? Date.now;
|
|
1426
|
+
}
|
|
1427
|
+
status() {
|
|
1428
|
+
return {
|
|
1429
|
+
origin: this.#options.client.origin,
|
|
1430
|
+
owner: this.#options.owner,
|
|
1431
|
+
runnerId: this.#options.runnerId,
|
|
1432
|
+
paused: this.#paused,
|
|
1433
|
+
revoked: this.#revoked,
|
|
1434
|
+
activeJobs: this.#active.size,
|
|
1435
|
+
capabilities: [...this.#capabilities],
|
|
1436
|
+
...this.#lastError === void 0 ? {} : { lastError: this.#lastError },
|
|
1437
|
+
completed: this.#completed,
|
|
1438
|
+
refused: this.#refused
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
pause() {
|
|
1442
|
+
this.#paused = true;
|
|
1443
|
+
}
|
|
1444
|
+
resume() {
|
|
1445
|
+
this.#paused = false;
|
|
1446
|
+
}
|
|
1447
|
+
/**
|
|
1448
|
+
* Build the capability matrix: owner config intersected with what is
|
|
1449
|
+
* actually reachable and healthy right now
|
|
1450
|
+
* ({@link MUSTS.CAPABILITY_IS_DETECTED}).
|
|
1451
|
+
*
|
|
1452
|
+
* A configured route whose backend is down simply does not appear. The
|
|
1453
|
+
* daemon then receives no work for it, which is the correct outcome and one
|
|
1454
|
+
* the owner can see in `byollm status`.
|
|
1455
|
+
*/
|
|
1456
|
+
async detectCapabilities() {
|
|
1457
|
+
const capabilities = [];
|
|
1458
|
+
for (const route of this.#options.loaded.routes) {
|
|
1459
|
+
const backend = this.#backendFor(route);
|
|
1460
|
+
const health = await backend.health();
|
|
1461
|
+
if (!health.healthy) continue;
|
|
1462
|
+
if (health.models.length > 0 && !modelPresent(health.models, route.model)) {
|
|
1463
|
+
continue;
|
|
1464
|
+
}
|
|
1465
|
+
capabilities.push({
|
|
1466
|
+
kind: route.kind,
|
|
1467
|
+
backendId: route.backendId,
|
|
1468
|
+
backendClass: route.backendClass,
|
|
1469
|
+
model: route.model,
|
|
1470
|
+
offerScope: route.offerScope
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
this.#capabilities = capabilities;
|
|
1474
|
+
return capabilities;
|
|
1475
|
+
}
|
|
1476
|
+
#backendFor(route) {
|
|
1477
|
+
const key = `${route.backendKey}:${route.backendId}`;
|
|
1478
|
+
let backend = this.#backends.get(key);
|
|
1479
|
+
if (!backend) {
|
|
1480
|
+
backend = this.#options.backendFactory?.(route) ?? createBackend(route.backendId, {
|
|
1481
|
+
baseUrl: route.baseUrl,
|
|
1482
|
+
apiKeyEnv: route.apiKeyEnv
|
|
1483
|
+
});
|
|
1484
|
+
this.#backends.set(key, backend);
|
|
1485
|
+
}
|
|
1486
|
+
return backend;
|
|
1487
|
+
}
|
|
1488
|
+
#routeFor(kind) {
|
|
1489
|
+
return this.#options.loaded.routes.find((route) => route.kind === kind);
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Decide whether this machine will run a claimed job.
|
|
1493
|
+
*
|
|
1494
|
+
* The server already applied its own version of the audience rules, and
|
|
1495
|
+
* that is not what this checks. This is the daemon enforcing against the
|
|
1496
|
+
* server: the local `named` allowlist
|
|
1497
|
+
* ({@link MUSTS.NAMED_LOCAL_ALLOWLIST}), the subscription self-lock, and
|
|
1498
|
+
* the owner's community budgets. A job that fails here is released with
|
|
1499
|
+
* reason `refused`, which the server remembers so it is never offered back.
|
|
1500
|
+
*/
|
|
1501
|
+
admit(job) {
|
|
1502
|
+
const route = this.#routeFor(job.kind);
|
|
1503
|
+
if (!route) {
|
|
1504
|
+
return { ok: false, reason: REFUSAL_MESSAGES["no-capability"] };
|
|
1505
|
+
}
|
|
1506
|
+
const match = matchAudience(
|
|
1507
|
+
{
|
|
1508
|
+
owner: job.owner,
|
|
1509
|
+
audience: job.audience,
|
|
1510
|
+
audienceAllow: job.audienceAllow
|
|
1511
|
+
},
|
|
1512
|
+
{
|
|
1513
|
+
owner: this.#options.owner,
|
|
1514
|
+
offerScope: route.offerScope,
|
|
1515
|
+
account: backendDescriptor2(route.backendId).account,
|
|
1516
|
+
// The daemon's own list — the whole point of Rev 1 §B.
|
|
1517
|
+
locallyAllows: this.#options.allowlist.predicateFor(
|
|
1518
|
+
this.#options.client.origin
|
|
1519
|
+
)
|
|
1520
|
+
}
|
|
1521
|
+
);
|
|
1522
|
+
if (!match.ok) {
|
|
1523
|
+
return { ok: false, reason: REFUSAL_MESSAGES[match.refusal] };
|
|
1524
|
+
}
|
|
1525
|
+
if (job.owner !== this.#options.owner) {
|
|
1526
|
+
const decision = this.#options.budgets.check(
|
|
1527
|
+
this.#now(),
|
|
1528
|
+
payloadTextLength({
|
|
1529
|
+
kind: job.kind,
|
|
1530
|
+
payload: job.payload
|
|
1531
|
+
})
|
|
1532
|
+
);
|
|
1533
|
+
if (!decision.ok) return { ok: false, reason: decision.detail };
|
|
1534
|
+
}
|
|
1535
|
+
return { ok: true };
|
|
1536
|
+
}
|
|
1537
|
+
/**
|
|
1538
|
+
* Execute one admitted job.
|
|
1539
|
+
*
|
|
1540
|
+
* Order is load-bearing: the ingress write is awaited *before* the backend
|
|
1541
|
+
* is touched ({@link MUSTS.INGRESS_LOGGED_BEFORE_EXECUTION}), so a job that
|
|
1542
|
+
* hangs the machine still leaves a record of what it was.
|
|
1543
|
+
*/
|
|
1544
|
+
async runJob(job) {
|
|
1545
|
+
const route = this.#routeFor(job.kind);
|
|
1546
|
+
if (!route) {
|
|
1547
|
+
return {
|
|
1548
|
+
outcome: "error",
|
|
1549
|
+
code: "no-capability",
|
|
1550
|
+
message: "this machine has no route for that job kind",
|
|
1551
|
+
retryable: false
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
const controller = new AbortController();
|
|
1555
|
+
this.#active.set(job.id, controller);
|
|
1556
|
+
const prompt = composePrompt(job);
|
|
1557
|
+
const community = job.owner !== this.#options.owner;
|
|
1558
|
+
const limits = this.#options.loaded.config;
|
|
1559
|
+
await this.#options.ingress.recordPrompt({
|
|
1560
|
+
at: this.#now(),
|
|
1561
|
+
origin: this.#options.client.origin,
|
|
1562
|
+
jobId: job.id,
|
|
1563
|
+
kind: job.kind,
|
|
1564
|
+
audience: job.audience,
|
|
1565
|
+
owner: job.owner,
|
|
1566
|
+
backendId: route.backendId,
|
|
1567
|
+
backendClass: route.backendClass,
|
|
1568
|
+
model: route.model,
|
|
1569
|
+
prompt
|
|
1570
|
+
});
|
|
1571
|
+
if (community) await this.#options.budgets.record(this.#now());
|
|
1572
|
+
try {
|
|
1573
|
+
const backend = this.#backendFor(route);
|
|
1574
|
+
const result = await backend.execute({
|
|
1575
|
+
prompt,
|
|
1576
|
+
model: route.model,
|
|
1577
|
+
// Community jobs run under the owner's tighter ceiling.
|
|
1578
|
+
timeoutMs: community ? Math.min(
|
|
1579
|
+
limits.community.maxWallClockMs,
|
|
1580
|
+
limits.limits.maxWallClockMs
|
|
1581
|
+
) : limits.limits.maxWallClockMs,
|
|
1582
|
+
maxOutputBytes: community ? Math.min(
|
|
1583
|
+
limits.community.maxOutputBytes,
|
|
1584
|
+
limits.limits.maxOutputBytes
|
|
1585
|
+
) : limits.limits.maxOutputBytes,
|
|
1586
|
+
signal: controller.signal
|
|
1587
|
+
});
|
|
1588
|
+
const outcome = result.ok ? { outcome: "ok", text: result.text } : result.code === "canceled" ? { outcome: "canceled" } : {
|
|
1589
|
+
outcome: "error",
|
|
1590
|
+
code: result.code,
|
|
1591
|
+
message: result.message,
|
|
1592
|
+
retryable: result.retryable
|
|
1593
|
+
};
|
|
1594
|
+
await this.#options.ingress.recordOutcome({
|
|
1595
|
+
at: this.#now(),
|
|
1596
|
+
jobId: job.id,
|
|
1597
|
+
outcome: outcome.outcome,
|
|
1598
|
+
durationMs: result.durationMs,
|
|
1599
|
+
outputChars: result.ok ? result.text.length : 0,
|
|
1600
|
+
...result.ok ? {} : { detail: result.message }
|
|
1601
|
+
});
|
|
1602
|
+
this.#completed += 1;
|
|
1603
|
+
this.#options.onEvent?.({
|
|
1604
|
+
type: "finished",
|
|
1605
|
+
jobId: job.id,
|
|
1606
|
+
outcome: outcome.outcome,
|
|
1607
|
+
durationMs: result.durationMs
|
|
1608
|
+
});
|
|
1609
|
+
return outcome;
|
|
1610
|
+
} finally {
|
|
1611
|
+
this.#active.delete(job.id);
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
/** Abort a job's in-flight backend call ({@link MUSTS.CANCEL_HONORED}). */
|
|
1615
|
+
cancelJob(jobId) {
|
|
1616
|
+
this.#active.get(jobId)?.abort();
|
|
1617
|
+
}
|
|
1618
|
+
/** Abort everything — revocation, or shutdown. */
|
|
1619
|
+
cancelAll() {
|
|
1620
|
+
for (const controller of this.#active.values()) controller.abort();
|
|
1621
|
+
}
|
|
1622
|
+
/**
|
|
1623
|
+
* Run until stopped.
|
|
1624
|
+
*
|
|
1625
|
+
* Resumable and idempotent by job id: a daemon that dies mid-job loses
|
|
1626
|
+
* nothing, because the server reclaims the lease and offers the job again.
|
|
1627
|
+
*/
|
|
1628
|
+
async run(signal) {
|
|
1629
|
+
const heartbeatMs = this.#options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
|
|
1630
|
+
while (!signal.aborted && !this.#stopped) {
|
|
1631
|
+
try {
|
|
1632
|
+
await this.tick();
|
|
1633
|
+
this.#lastError = void 0;
|
|
1634
|
+
} catch (error) {
|
|
1635
|
+
this.#lastError = error instanceof Error ? error.message : "unknown error";
|
|
1636
|
+
this.#options.onEvent?.({
|
|
1637
|
+
type: "error",
|
|
1638
|
+
message: this.#lastError
|
|
1639
|
+
});
|
|
1640
|
+
if (error instanceof ClientError && error.kind === "revoked") {
|
|
1641
|
+
this.#revoked = true;
|
|
1642
|
+
this.cancelAll();
|
|
1643
|
+
this.#options.onEvent?.({ type: "revoked" });
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
const backoff = error instanceof ClientError && error.retryAfter !== void 0 ? error.retryAfter * 1e3 : heartbeatMs;
|
|
1647
|
+
await sleep(backoff, signal);
|
|
1648
|
+
continue;
|
|
1649
|
+
}
|
|
1650
|
+
await sleep(heartbeatMs * (0.85 + Math.random() * 0.3), signal);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
/** One heartbeat-and-claim cycle. Exposed so tests can step deterministically. */
|
|
1654
|
+
async tick() {
|
|
1655
|
+
const capabilities = await this.detectCapabilities();
|
|
1656
|
+
const heartbeat = await this.#options.client.heartbeat({
|
|
1657
|
+
runnerId: this.#options.runnerId,
|
|
1658
|
+
daemonVersion: this.#options.daemonVersion,
|
|
1659
|
+
capabilities,
|
|
1660
|
+
activeJobIds: [...this.#active.keys()],
|
|
1661
|
+
paused: this.#paused
|
|
1662
|
+
});
|
|
1663
|
+
this.#options.onEvent?.({
|
|
1664
|
+
type: "heartbeat",
|
|
1665
|
+
capabilities: capabilities.length
|
|
1666
|
+
});
|
|
1667
|
+
if (heartbeat.revoked) {
|
|
1668
|
+
this.#revoked = true;
|
|
1669
|
+
this.cancelAll();
|
|
1670
|
+
this.#options.onEvent?.({ type: "revoked" });
|
|
1671
|
+
this.#stopped = true;
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1674
|
+
for (const jobId of heartbeat.lost) this.cancelJob(jobId);
|
|
1675
|
+
for (const jobId of heartbeat.cancel) this.cancelJob(jobId);
|
|
1676
|
+
if (this.#paused || capabilities.length === 0) return;
|
|
1677
|
+
const free = this.#options.loaded.config.concurrency - this.#active.size;
|
|
1678
|
+
if (free <= 0) return;
|
|
1679
|
+
const { jobs } = await this.#options.client.claim({
|
|
1680
|
+
runnerId: this.#options.runnerId,
|
|
1681
|
+
capabilities,
|
|
1682
|
+
max: free
|
|
1683
|
+
});
|
|
1684
|
+
for (const job of jobs) {
|
|
1685
|
+
void this.#handle(job).catch((error) => {
|
|
1686
|
+
this.#lastError = error instanceof Error ? error.message : "unknown error";
|
|
1687
|
+
this.#options.onEvent?.({ type: "error", message: this.#lastError });
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
async #handle(job) {
|
|
1692
|
+
this.#options.onEvent?.({
|
|
1693
|
+
type: "claimed",
|
|
1694
|
+
jobId: job.id,
|
|
1695
|
+
kind: job.kind
|
|
1696
|
+
});
|
|
1697
|
+
const admission = this.admit(job);
|
|
1698
|
+
if (!admission.ok) {
|
|
1699
|
+
this.#refused += 1;
|
|
1700
|
+
await this.#options.ingress.recordOutcome({
|
|
1701
|
+
at: this.#now(),
|
|
1702
|
+
jobId: job.id,
|
|
1703
|
+
outcome: "refused",
|
|
1704
|
+
detail: admission.reason
|
|
1705
|
+
});
|
|
1706
|
+
this.#options.onEvent?.({
|
|
1707
|
+
type: "refused",
|
|
1708
|
+
jobId: job.id,
|
|
1709
|
+
reason: admission.reason
|
|
1710
|
+
});
|
|
1711
|
+
await this.#safely(
|
|
1712
|
+
() => this.#options.client.release({
|
|
1713
|
+
runnerId: this.#options.runnerId,
|
|
1714
|
+
jobIds: [job.id],
|
|
1715
|
+
reason: "refused"
|
|
1716
|
+
})
|
|
1717
|
+
);
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
const route = this.#routeFor(job.kind);
|
|
1721
|
+
const outcome = await this.runJob(job);
|
|
1722
|
+
await this.#safely(
|
|
1723
|
+
() => this.#options.client.result({
|
|
1724
|
+
runnerId: this.#options.runnerId,
|
|
1725
|
+
jobId: job.id,
|
|
1726
|
+
outcome,
|
|
1727
|
+
model: route?.model ?? "unknown",
|
|
1728
|
+
backendClass: route?.backendClass ?? "http",
|
|
1729
|
+
durationMs: 0
|
|
1730
|
+
})
|
|
1731
|
+
);
|
|
1732
|
+
}
|
|
1733
|
+
/**
|
|
1734
|
+
* Report-and-forget.
|
|
1735
|
+
*
|
|
1736
|
+
* A failed *report* must not crash the loop or re-run the job: the lease
|
|
1737
|
+
* will lapse and the server will offer the work again, which is exactly the
|
|
1738
|
+
* recovery the protocol is built around.
|
|
1739
|
+
*/
|
|
1740
|
+
async #safely(action) {
|
|
1741
|
+
try {
|
|
1742
|
+
await action();
|
|
1743
|
+
} catch (error) {
|
|
1744
|
+
this.#lastError = error instanceof Error ? error.message : "unknown error";
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
/** Release everything on shutdown, so nothing waits for a lease to lapse. */
|
|
1748
|
+
async shutdown(reason) {
|
|
1749
|
+
this.#stopped = true;
|
|
1750
|
+
const jobIds = [...this.#active.keys()];
|
|
1751
|
+
this.cancelAll();
|
|
1752
|
+
if (jobIds.length === 0) return;
|
|
1753
|
+
await this.#safely(
|
|
1754
|
+
() => this.#options.client.release({
|
|
1755
|
+
runnerId: this.#options.runnerId,
|
|
1756
|
+
jobIds,
|
|
1757
|
+
reason
|
|
1758
|
+
})
|
|
1759
|
+
);
|
|
1760
|
+
}
|
|
1761
|
+
};
|
|
1762
|
+
function modelPresent(models, wanted) {
|
|
1763
|
+
const target = wanted.toLowerCase();
|
|
1764
|
+
return models.some((model) => {
|
|
1765
|
+
const id = model.toLowerCase();
|
|
1766
|
+
return id === target || id === `${target}:latest` || `${id}:latest` === target;
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
function sleep(ms, signal) {
|
|
1770
|
+
return new Promise((resolve) => {
|
|
1771
|
+
const timer = setTimeout(resolve, ms);
|
|
1772
|
+
signal.addEventListener(
|
|
1773
|
+
"abort",
|
|
1774
|
+
() => {
|
|
1775
|
+
clearTimeout(timer);
|
|
1776
|
+
resolve();
|
|
1777
|
+
},
|
|
1778
|
+
{ once: true }
|
|
1779
|
+
);
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
// src/cli.ts
|
|
1784
|
+
var USAGE = `byollm \u2014 run an app's LLM jobs on your own models.
|
|
1785
|
+
|
|
1786
|
+
byollm connect <url> pair with an app and start running its jobs
|
|
1787
|
+
byollm run [url] run jobs for a paired app (or all of them)
|
|
1788
|
+
byollm status what is connected, what is running, what it cost
|
|
1789
|
+
byollm log [--full] [-n N] every prompt that has run on this machine
|
|
1790
|
+
byollm pause stop claiming new work
|
|
1791
|
+
byollm resume start claiming again
|
|
1792
|
+
byollm allow <url> <user> let someone else's jobs run here (named audience)
|
|
1793
|
+
byollm allow --list who can currently use this machine
|
|
1794
|
+
byollm disallow <url> <user>
|
|
1795
|
+
byollm forget <url> drop a pairing
|
|
1796
|
+
byollm backends what is installed, healthy, and advertised
|
|
1797
|
+
|
|
1798
|
+
Config lives in ~/.byollm/config.json. Everything this daemon has ever run is
|
|
1799
|
+
in ~/.byollm/ingress.log \u2014 it is yours to read and yours to delete.
|
|
1800
|
+
`;
|
|
1801
|
+
var defaultIo = {
|
|
1802
|
+
out: (text) => process.stdout.write(text),
|
|
1803
|
+
err: (text) => process.stderr.write(text),
|
|
1804
|
+
confirm: confirmInteractively
|
|
1805
|
+
};
|
|
1806
|
+
async function runCli(argv, options = {}) {
|
|
1807
|
+
const [command, ...rest] = argv;
|
|
1808
|
+
const paths = options.paths ?? daemonPaths();
|
|
1809
|
+
const io = { ...defaultIo, ...options.io };
|
|
1810
|
+
const signal = options.signal;
|
|
1811
|
+
switch (command) {
|
|
1812
|
+
case void 0:
|
|
1813
|
+
case "help":
|
|
1814
|
+
case "--help":
|
|
1815
|
+
case "-h":
|
|
1816
|
+
io.out(USAGE);
|
|
1817
|
+
return 0;
|
|
1818
|
+
case "--version":
|
|
1819
|
+
case "version":
|
|
1820
|
+
io.out(`${DAEMON_VERSION}
|
|
1821
|
+
`);
|
|
1822
|
+
return 0;
|
|
1823
|
+
case "connect":
|
|
1824
|
+
return commandConnect(paths, rest, io, signal);
|
|
1825
|
+
case "run":
|
|
1826
|
+
return commandRun(paths, rest, io, signal);
|
|
1827
|
+
case "status":
|
|
1828
|
+
return commandStatus(paths, io);
|
|
1829
|
+
case "log":
|
|
1830
|
+
return commandLog(paths, rest, io);
|
|
1831
|
+
case "pause":
|
|
1832
|
+
return commandPause(paths, true, io);
|
|
1833
|
+
case "resume":
|
|
1834
|
+
return commandPause(paths, false, io);
|
|
1835
|
+
case "allow":
|
|
1836
|
+
return commandAllow(paths, rest, io);
|
|
1837
|
+
case "disallow":
|
|
1838
|
+
return commandDisallow(paths, rest, io);
|
|
1839
|
+
case "forget":
|
|
1840
|
+
return commandForget(paths, rest, io);
|
|
1841
|
+
case "backends":
|
|
1842
|
+
return commandBackends(paths, io);
|
|
1843
|
+
default:
|
|
1844
|
+
io.err(`unknown command: ${command}
|
|
1845
|
+
|
|
1846
|
+
${USAGE}`);
|
|
1847
|
+
return 2;
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
async function commandConnect(paths, args, io, signal) {
|
|
1851
|
+
const target = args[0];
|
|
1852
|
+
if (target === void 0) {
|
|
1853
|
+
io.err("usage: byollm connect <url>\n");
|
|
1854
|
+
return 2;
|
|
1855
|
+
}
|
|
1856
|
+
const origin = normalizeOrigin(target);
|
|
1857
|
+
const { loaded, ingress, allowlist, budgets } = await context(paths);
|
|
1858
|
+
for (const problem of loaded.problems) {
|
|
1859
|
+
io.err(`config: ${problem.where}: ${problem.message}
|
|
1860
|
+
`);
|
|
1861
|
+
}
|
|
1862
|
+
const client = new ProtocolClient({ origin });
|
|
1863
|
+
const runner = new Runner({
|
|
1864
|
+
client,
|
|
1865
|
+
runnerId: "pending",
|
|
1866
|
+
owner: "pending",
|
|
1867
|
+
daemonVersion: DAEMON_VERSION,
|
|
1868
|
+
loaded,
|
|
1869
|
+
allowlist,
|
|
1870
|
+
budgets,
|
|
1871
|
+
ingress
|
|
1872
|
+
});
|
|
1873
|
+
const capabilities = await runner.detectCapabilities();
|
|
1874
|
+
if (capabilities.length === 0) {
|
|
1875
|
+
io.err(
|
|
1876
|
+
"No backend is reachable, so there is nothing to offer this app yet.\nRun `byollm backends` to see what is configured and what is wrong.\n"
|
|
1877
|
+
);
|
|
1878
|
+
return 1;
|
|
1879
|
+
}
|
|
1880
|
+
io.out(`
|
|
1881
|
+
Connecting to ${origin}
|
|
1882
|
+
`);
|
|
1883
|
+
const result = await connect({
|
|
1884
|
+
client,
|
|
1885
|
+
daemonVersion: DAEMON_VERSION,
|
|
1886
|
+
label: hostLabel(),
|
|
1887
|
+
capabilities,
|
|
1888
|
+
onCode: (info) => {
|
|
1889
|
+
const minutes = Math.max(
|
|
1890
|
+
1,
|
|
1891
|
+
Math.round((info.expiresAt - Date.now()) / 6e4)
|
|
1892
|
+
);
|
|
1893
|
+
io.out(
|
|
1894
|
+
`
|
|
1895
|
+
Open: ${info.verificationUrl}
|
|
1896
|
+
Code: ${info.userCode} (expires in ${String(minutes)}m)
|
|
1897
|
+
|
|
1898
|
+
waiting for approval\u2026`
|
|
1899
|
+
);
|
|
1900
|
+
},
|
|
1901
|
+
onPoll: () => {
|
|
1902
|
+
io.out(".");
|
|
1903
|
+
},
|
|
1904
|
+
...signal === void 0 ? {} : { signal }
|
|
1905
|
+
});
|
|
1906
|
+
if (!result.ok) {
|
|
1907
|
+
io.out(`
|
|
1908
|
+
|
|
1909
|
+
${result.message}
|
|
1910
|
+
`);
|
|
1911
|
+
return 1;
|
|
1912
|
+
}
|
|
1913
|
+
const pairings = new Pairings(paths.pairings);
|
|
1914
|
+
await pairings.load();
|
|
1915
|
+
await pairings.put(result.pairing);
|
|
1916
|
+
io.out(
|
|
1917
|
+
` paired as ${result.pairing.ownerLabel ?? result.pairing.owner}
|
|
1918
|
+
|
|
1919
|
+
Now running jobs for ${origin}. Ctrl-C to stop.
|
|
1920
|
+
|
|
1921
|
+
`
|
|
1922
|
+
);
|
|
1923
|
+
return runLoop(paths, [result.pairing.origin], io, signal);
|
|
1924
|
+
}
|
|
1925
|
+
async function commandRun(paths, args, io, signal) {
|
|
1926
|
+
const pairings = new Pairings(paths.pairings);
|
|
1927
|
+
await pairings.load();
|
|
1928
|
+
const target = args[0];
|
|
1929
|
+
const origins = target === void 0 ? pairings.list().map((pairing) => pairing.origin) : [normalizeOrigin(target)];
|
|
1930
|
+
if (origins.length === 0) {
|
|
1931
|
+
io.err("No apps are paired yet. Run `byollm connect <url>` first.\n");
|
|
1932
|
+
return 2;
|
|
1933
|
+
}
|
|
1934
|
+
return runLoop(paths, origins, io, signal);
|
|
1935
|
+
}
|
|
1936
|
+
async function runLoop(paths, origins, io, signal) {
|
|
1937
|
+
const { loaded, ingress, allowlist, budgets } = await context(paths);
|
|
1938
|
+
const pairings = new Pairings(paths.pairings);
|
|
1939
|
+
await pairings.load();
|
|
1940
|
+
const controller = new AbortController();
|
|
1941
|
+
signal?.addEventListener(
|
|
1942
|
+
"abort",
|
|
1943
|
+
() => {
|
|
1944
|
+
controller.abort();
|
|
1945
|
+
},
|
|
1946
|
+
{ once: true }
|
|
1947
|
+
);
|
|
1948
|
+
const runners = [];
|
|
1949
|
+
for (const origin of origins) {
|
|
1950
|
+
const pairing = pairings.get(origin);
|
|
1951
|
+
if (!pairing) {
|
|
1952
|
+
io.err(`not paired with ${origin}
|
|
1953
|
+
`);
|
|
1954
|
+
continue;
|
|
1955
|
+
}
|
|
1956
|
+
const runner = new Runner({
|
|
1957
|
+
client: new ProtocolClient({ origin, token: pairing.token }),
|
|
1958
|
+
runnerId: pairing.runnerId,
|
|
1959
|
+
owner: pairing.owner,
|
|
1960
|
+
daemonVersion: DAEMON_VERSION,
|
|
1961
|
+
loaded,
|
|
1962
|
+
allowlist,
|
|
1963
|
+
budgets,
|
|
1964
|
+
ingress,
|
|
1965
|
+
onEvent: (event) => {
|
|
1966
|
+
report(origin, event, io);
|
|
1967
|
+
}
|
|
1968
|
+
});
|
|
1969
|
+
runners.push(runner);
|
|
1970
|
+
}
|
|
1971
|
+
if (runners.length === 0) return 2;
|
|
1972
|
+
if (signal === void 0) {
|
|
1973
|
+
const stop = () => {
|
|
1974
|
+
controller.abort();
|
|
1975
|
+
void Promise.all(
|
|
1976
|
+
runners.map((runner) => runner.shutdown("shutdown"))
|
|
1977
|
+
).then(() => process.exit(0));
|
|
1978
|
+
};
|
|
1979
|
+
process.on("SIGINT", stop);
|
|
1980
|
+
process.on("SIGTERM", stop);
|
|
1981
|
+
}
|
|
1982
|
+
await ingress.applyRetention(Date.now());
|
|
1983
|
+
await Promise.all(runners.map((runner) => runner.run(controller.signal)));
|
|
1984
|
+
await Promise.all(runners.map((runner) => runner.shutdown("shutdown")));
|
|
1985
|
+
return 0;
|
|
1986
|
+
}
|
|
1987
|
+
function report(origin, event, io) {
|
|
1988
|
+
const at = (/* @__PURE__ */ new Date()).toISOString().slice(11, 19);
|
|
1989
|
+
const host = new URL(origin).host;
|
|
1990
|
+
switch (event.type) {
|
|
1991
|
+
case "claimed":
|
|
1992
|
+
io.out(`${at} ${host} claimed ${event.kind} ${event.jobId}
|
|
1993
|
+
`);
|
|
1994
|
+
break;
|
|
1995
|
+
case "finished":
|
|
1996
|
+
io.out(
|
|
1997
|
+
`${at} ${host} ${event.outcome} ${event.jobId} (${String(event.durationMs)}ms)
|
|
1998
|
+
`
|
|
1999
|
+
);
|
|
2000
|
+
break;
|
|
2001
|
+
case "refused":
|
|
2002
|
+
io.out(
|
|
2003
|
+
`${at} ${host} refused ${event.jobId}: ${stripControlChars(event.reason)}
|
|
2004
|
+
`
|
|
2005
|
+
);
|
|
2006
|
+
break;
|
|
2007
|
+
case "revoked":
|
|
2008
|
+
io.out(`${at} ${host} this runner was revoked. Stopping.
|
|
2009
|
+
`);
|
|
2010
|
+
break;
|
|
2011
|
+
case "error":
|
|
2012
|
+
io.err(`${at} ${host} ${stripControlChars(event.message)}
|
|
2013
|
+
`);
|
|
2014
|
+
break;
|
|
2015
|
+
case "heartbeat":
|
|
2016
|
+
break;
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
async function commandStatus(paths, io) {
|
|
2020
|
+
const { loaded, ingress, allowlist, budgets } = await context(paths);
|
|
2021
|
+
const pairings = new Pairings(paths.pairings);
|
|
2022
|
+
await pairings.load();
|
|
2023
|
+
const paused = await isPaused(paths);
|
|
2024
|
+
const now = Date.now();
|
|
2025
|
+
io.out(`byollm ${DAEMON_VERSION}
|
|
2026
|
+
`);
|
|
2027
|
+
io.out(`state: ${paused ? "PAUSED" : "running"}
|
|
2028
|
+
|
|
2029
|
+
`);
|
|
2030
|
+
io.out("paired apps\n");
|
|
2031
|
+
const list = pairings.list();
|
|
2032
|
+
if (list.length === 0) {
|
|
2033
|
+
io.out(" (none \u2014 run `byollm connect <url>`)\n");
|
|
2034
|
+
}
|
|
2035
|
+
for (const pairing of list) {
|
|
2036
|
+
io.out(` ${pairing.origin} as ${pairing.ownerLabel ?? pairing.owner}
|
|
2037
|
+
`);
|
|
2038
|
+
}
|
|
2039
|
+
io.out("\nroutes\n");
|
|
2040
|
+
if (loaded.routes.length === 0) {
|
|
2041
|
+
io.out(" (none configured)\n");
|
|
2042
|
+
}
|
|
2043
|
+
for (const route of loaded.routes) {
|
|
2044
|
+
io.out(
|
|
2045
|
+
` ${route.kind.padEnd(14)} ${route.backendId}:${route.model} offered to: ${route.offerScope}
|
|
2046
|
+
`
|
|
2047
|
+
);
|
|
2048
|
+
}
|
|
2049
|
+
for (const problem of loaded.problems) {
|
|
2050
|
+
io.out(` ! ${problem.where}: ${problem.message}
|
|
2051
|
+
`);
|
|
2052
|
+
}
|
|
2053
|
+
const allowed = allowlist.list();
|
|
2054
|
+
io.out("\nwho can use this machine\n");
|
|
2055
|
+
io.out(` you, always
|
|
2056
|
+
`);
|
|
2057
|
+
if (allowed.length === 0) {
|
|
2058
|
+
io.out(" nobody else\n");
|
|
2059
|
+
}
|
|
2060
|
+
for (const entry of allowed) {
|
|
2061
|
+
io.out(
|
|
2062
|
+
` ${entry.owner} on ${entry.origin}${entry.note === void 0 ? "" : ` (${stripControlChars(entry.note)})`}
|
|
2063
|
+
`
|
|
2064
|
+
);
|
|
2065
|
+
}
|
|
2066
|
+
const usage = budgets.usage(now);
|
|
2067
|
+
io.out("\ncommunity work done for others\n");
|
|
2068
|
+
io.out(
|
|
2069
|
+
` ${String(usage.hour)} in the last hour (cap ${String(usage.limits.maxJobsPerHour)}), ${String(usage.day)} today (cap ${String(usage.limits.maxJobsPerDay)})
|
|
2070
|
+
`
|
|
2071
|
+
);
|
|
2072
|
+
const entries = await ingress.read();
|
|
2073
|
+
const prompts = entries.filter((entry) => entry.type === "prompt");
|
|
2074
|
+
const outcomes = entries.filter((entry) => entry.type === "outcome");
|
|
2075
|
+
io.out("\nthis machine has run\n");
|
|
2076
|
+
io.out(
|
|
2077
|
+
` ${String(prompts.length)} prompts, ${String(outcomes.filter((o) => o.outcome === "ok").length)} ok, ${String(outcomes.filter((o) => o.outcome === "error").length)} failed, ${String(outcomes.filter((o) => o.outcome === "refused").length)} refused
|
|
2078
|
+
`
|
|
2079
|
+
);
|
|
2080
|
+
io.out(
|
|
2081
|
+
` full log: ${paths.ingressLog} (community prompts kept ${String(loaded.config.ingress.communityPromptDays)} days, then hashed)
|
|
2082
|
+
`
|
|
2083
|
+
);
|
|
2084
|
+
return 0;
|
|
2085
|
+
}
|
|
2086
|
+
async function commandLog(paths, args, io) {
|
|
2087
|
+
const full = args.includes("--full");
|
|
2088
|
+
const nIndex = args.findIndex((arg) => arg === "-n" || arg === "--lines");
|
|
2089
|
+
const limit = nIndex === -1 ? 20 : Math.max(1, Number(args[nIndex + 1] ?? "20") || 20);
|
|
2090
|
+
const { ingress } = await context(paths);
|
|
2091
|
+
const entries = await ingress.read();
|
|
2092
|
+
const shown = entries.slice(-limit);
|
|
2093
|
+
if (shown.length === 0) {
|
|
2094
|
+
io.out("nothing has run on this machine yet\n");
|
|
2095
|
+
return 0;
|
|
2096
|
+
}
|
|
2097
|
+
for (const entry of shown) {
|
|
2098
|
+
const at = new Date(entry.at).toISOString().replace("T", " ").slice(0, 19);
|
|
2099
|
+
if (entry.type === "outcome") {
|
|
2100
|
+
io.out(
|
|
2101
|
+
`${at} ${entry.outcome.padEnd(8)} ${entry.jobId}` + (entry.durationMs === void 0 ? "" : ` ${String(entry.durationMs)}ms`) + `${entry.detail === void 0 ? "" : ` ${stripControlChars(entry.detail)}`}
|
|
2102
|
+
`
|
|
2103
|
+
);
|
|
2104
|
+
continue;
|
|
2105
|
+
}
|
|
2106
|
+
io.out(
|
|
2107
|
+
`${at} ${entry.audience.padEnd(7)} ${entry.kind} via ${entry.backendId}:${entry.model} for ${entry.owner} @ ${new URL(entry.origin).host}
|
|
2108
|
+
`
|
|
2109
|
+
);
|
|
2110
|
+
if (entry.prompt === void 0) {
|
|
2111
|
+
io.out(
|
|
2112
|
+
` prompt not retained (${String(entry.promptChars)} chars, sha256 ${entry.promptHash.slice(0, 16)}\u2026)
|
|
2113
|
+
`
|
|
2114
|
+
);
|
|
2115
|
+
} else if (full) {
|
|
2116
|
+
io.out(`${indent(stripControlChars(entry.prompt))}
|
|
2117
|
+
`);
|
|
2118
|
+
} else {
|
|
2119
|
+
const firstLine2 = entry.prompt.split("\n")[0] ?? "";
|
|
2120
|
+
io.out(
|
|
2121
|
+
` ${stripControlChars(firstLine2.slice(0, 90))}${entry.prompt.length > 90 ? "\u2026" : ""}
|
|
2122
|
+
`
|
|
2123
|
+
);
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
if (!full) {
|
|
2127
|
+
io.out(`
|
|
2128
|
+
(${String(entries.length)} entries; --full for whole prompts)
|
|
2129
|
+
`);
|
|
2130
|
+
}
|
|
2131
|
+
return 0;
|
|
2132
|
+
}
|
|
2133
|
+
function indent(text) {
|
|
2134
|
+
return text.split("\n").map((line) => ` ${line}`).join("\n");
|
|
2135
|
+
}
|
|
2136
|
+
async function commandPause(paths, pause, io) {
|
|
2137
|
+
await mkdir5(paths.root, { recursive: true });
|
|
2138
|
+
if (pause) {
|
|
2139
|
+
await writeFile5(paths.pauseFlag, `${(/* @__PURE__ */ new Date()).toISOString()}
|
|
2140
|
+
`);
|
|
2141
|
+
io.out(
|
|
2142
|
+
"paused \u2014 no new work will be claimed. `byollm resume` to start again.\n"
|
|
2143
|
+
);
|
|
2144
|
+
} else {
|
|
2145
|
+
await rm2(paths.pauseFlag, { force: true });
|
|
2146
|
+
io.out("resumed\n");
|
|
2147
|
+
}
|
|
2148
|
+
return 0;
|
|
2149
|
+
}
|
|
2150
|
+
async function isPaused(paths) {
|
|
2151
|
+
try {
|
|
2152
|
+
await stat(paths.pauseFlag);
|
|
2153
|
+
return true;
|
|
2154
|
+
} catch {
|
|
2155
|
+
return false;
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
async function commandAllow(paths, args, io) {
|
|
2159
|
+
const allowlist = new Allowlist(paths.allowlist);
|
|
2160
|
+
await allowlist.load();
|
|
2161
|
+
if (args[0] === "--list" || args.length === 0) {
|
|
2162
|
+
const entries = allowlist.list();
|
|
2163
|
+
if (entries.length === 0) {
|
|
2164
|
+
io.out(
|
|
2165
|
+
"Nobody but you can run work on this machine.\n`byollm allow <app-url> <user-id>` to change that.\n"
|
|
2166
|
+
);
|
|
2167
|
+
return 0;
|
|
2168
|
+
}
|
|
2169
|
+
for (const entry of entries) {
|
|
2170
|
+
io.out(
|
|
2171
|
+
`${entry.owner} on ${entry.origin}${entry.note === void 0 ? "" : ` (${stripControlChars(entry.note)})`}
|
|
2172
|
+
`
|
|
2173
|
+
);
|
|
2174
|
+
}
|
|
2175
|
+
return 0;
|
|
2176
|
+
}
|
|
2177
|
+
const [rawOrigin, owner, ...noteParts] = args;
|
|
2178
|
+
if (rawOrigin === void 0 || owner === void 0) {
|
|
2179
|
+
io.err("usage: byollm allow <app-url> <user-id> [note]\n");
|
|
2180
|
+
return 2;
|
|
2181
|
+
}
|
|
2182
|
+
const origin = normalizeOrigin(rawOrigin);
|
|
2183
|
+
const confirmed = await io.confirm(
|
|
2184
|
+
`
|
|
2185
|
+
This lets jobs belonging to "${owner}" on ${origin} run on this machine,
|
|
2186
|
+
using your hardware and electricity, whenever your daemon is online.
|
|
2187
|
+
Your subscription-backed models are never included \u2014 those stay yours alone.
|
|
2188
|
+
|
|
2189
|
+
Allow ${owner} to use this machine?`
|
|
2190
|
+
);
|
|
2191
|
+
if (!confirmed) {
|
|
2192
|
+
io.out("nothing changed\n");
|
|
2193
|
+
return 0;
|
|
2194
|
+
}
|
|
2195
|
+
await allowlist.add(
|
|
2196
|
+
{
|
|
2197
|
+
origin,
|
|
2198
|
+
owner,
|
|
2199
|
+
...noteParts.length > 0 ? { note: noteParts.join(" ") } : {}
|
|
2200
|
+
},
|
|
2201
|
+
Date.now()
|
|
2202
|
+
);
|
|
2203
|
+
io.out(`allowed ${owner} on ${origin}
|
|
2204
|
+
`);
|
|
2205
|
+
return 0;
|
|
2206
|
+
}
|
|
2207
|
+
async function commandDisallow(paths, args, io) {
|
|
2208
|
+
const [rawOrigin, owner] = args;
|
|
2209
|
+
if (rawOrigin === void 0 || owner === void 0) {
|
|
2210
|
+
io.err("usage: byollm disallow <app-url> <user-id>\n");
|
|
2211
|
+
return 2;
|
|
2212
|
+
}
|
|
2213
|
+
const allowlist = new Allowlist(paths.allowlist);
|
|
2214
|
+
await allowlist.load();
|
|
2215
|
+
const removed = await allowlist.remove(normalizeOrigin(rawOrigin), owner);
|
|
2216
|
+
io.out(
|
|
2217
|
+
removed ? `${owner} can no longer use this machine
|
|
2218
|
+
` : `${owner} was not on the list \u2014 nothing changed
|
|
2219
|
+
`
|
|
2220
|
+
);
|
|
2221
|
+
return 0;
|
|
2222
|
+
}
|
|
2223
|
+
async function commandForget(paths, args, io) {
|
|
2224
|
+
const target = args[0];
|
|
2225
|
+
if (target === void 0) {
|
|
2226
|
+
io.err("usage: byollm forget <app-url>\n");
|
|
2227
|
+
return 2;
|
|
2228
|
+
}
|
|
2229
|
+
const pairings = new Pairings(paths.pairings);
|
|
2230
|
+
await pairings.load();
|
|
2231
|
+
const removed = await pairings.remove(normalizeOrigin(target));
|
|
2232
|
+
io.out(
|
|
2233
|
+
removed ? `forgot ${normalizeOrigin(target)} \u2014 the app may still list this runner until you revoke it there too
|
|
2234
|
+
` : `not paired with ${normalizeOrigin(target)}
|
|
2235
|
+
`
|
|
2236
|
+
);
|
|
2237
|
+
return 0;
|
|
2238
|
+
}
|
|
2239
|
+
async function commandBackends(paths, io) {
|
|
2240
|
+
const { loaded, ingress, allowlist, budgets } = await context(paths);
|
|
2241
|
+
const runner = new Runner({
|
|
2242
|
+
client: new ProtocolClient({ origin: "https://unused.invalid" }),
|
|
2243
|
+
runnerId: "local",
|
|
2244
|
+
owner: "local",
|
|
2245
|
+
daemonVersion: DAEMON_VERSION,
|
|
2246
|
+
loaded,
|
|
2247
|
+
allowlist,
|
|
2248
|
+
budgets,
|
|
2249
|
+
ingress
|
|
2250
|
+
});
|
|
2251
|
+
const advertised = await runner.detectCapabilities();
|
|
2252
|
+
const advertisedKinds = new Set(advertised.map((c) => c.kind));
|
|
2253
|
+
io.out("configured routes\n");
|
|
2254
|
+
for (const route of loaded.routes) {
|
|
2255
|
+
const ok = advertisedKinds.has(route.kind);
|
|
2256
|
+
io.out(
|
|
2257
|
+
` ${ok ? "\u2713" : "\u2717"} ${route.kind.padEnd(14)} ${route.backendId}:${route.model}${route.baseUrl === void 0 ? "" : ` @ ${route.baseUrl}`}
|
|
2258
|
+
`
|
|
2259
|
+
);
|
|
2260
|
+
}
|
|
2261
|
+
for (const problem of loaded.problems) {
|
|
2262
|
+
io.out(` ! ${problem.where}: ${problem.message}
|
|
2263
|
+
`);
|
|
2264
|
+
}
|
|
2265
|
+
io.out(
|
|
2266
|
+
`
|
|
2267
|
+
${String(advertised.length)} of ${String(loaded.routes.length)} routes are healthy and will be advertised.
|
|
2268
|
+
A route that is not healthy is never offered to an app \u2014 the daemon does
|
|
2269
|
+
not advertise what it cannot actually run.
|
|
2270
|
+
`
|
|
2271
|
+
);
|
|
2272
|
+
return advertised.length === 0 ? 1 : 0;
|
|
2273
|
+
}
|
|
2274
|
+
async function context(paths) {
|
|
2275
|
+
const loaded = await loadConfig(paths.config);
|
|
2276
|
+
const ingress = new IngressLog({
|
|
2277
|
+
path: paths.ingressLog,
|
|
2278
|
+
communityPromptDays: loaded.config.ingress.communityPromptDays,
|
|
2279
|
+
keepSelfPrompts: loaded.config.ingress.keepSelfPrompts
|
|
2280
|
+
});
|
|
2281
|
+
const allowlist = new Allowlist(paths.allowlist);
|
|
2282
|
+
await allowlist.load();
|
|
2283
|
+
const budgets = new Budgets(paths.budgets, loaded.config.community);
|
|
2284
|
+
await budgets.load(Date.now());
|
|
2285
|
+
return { loaded, ingress, allowlist, budgets };
|
|
2286
|
+
}
|
|
2287
|
+
async function confirmInteractively(question) {
|
|
2288
|
+
if (!process.stdin.isTTY) {
|
|
2289
|
+
process.stderr.write(
|
|
2290
|
+
"refusing to widen access without an interactive confirmation\n"
|
|
2291
|
+
);
|
|
2292
|
+
return false;
|
|
2293
|
+
}
|
|
2294
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2295
|
+
try {
|
|
2296
|
+
const answer = await rl.question(`${question} [y/N] `);
|
|
2297
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
2298
|
+
} finally {
|
|
2299
|
+
rl.close();
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
function hostLabel() {
|
|
2303
|
+
const override = process.env["BYOLLM_LABEL"];
|
|
2304
|
+
if (override !== void 0 && override !== "") return override.slice(0, 120);
|
|
2305
|
+
try {
|
|
2306
|
+
return `${userInfo().username}@${hostname()}`.slice(0, 120);
|
|
2307
|
+
} catch {
|
|
2308
|
+
return hostname().slice(0, 120);
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
async function main(argv) {
|
|
2312
|
+
try {
|
|
2313
|
+
return await runCli(argv);
|
|
2314
|
+
} catch (error) {
|
|
2315
|
+
process.stderr.write(
|
|
2316
|
+
`${error instanceof ClientError || error instanceof Error ? error.message : String(error)}
|
|
2317
|
+
`
|
|
2318
|
+
);
|
|
2319
|
+
return 1;
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
// src/index.ts
|
|
2324
|
+
var DAEMON_VERSION = "0.1.0-alpha.0";
|
|
2325
|
+
|
|
2326
|
+
export {
|
|
2327
|
+
Allowlist,
|
|
2328
|
+
normalizeOrigin,
|
|
2329
|
+
Budgets,
|
|
2330
|
+
ClientError,
|
|
2331
|
+
ProtocolClient,
|
|
2332
|
+
checkBaseUrl,
|
|
2333
|
+
BASE_URL_REFUSAL_MESSAGES,
|
|
2334
|
+
DaemonConfig,
|
|
2335
|
+
DEFAULT_CONFIG,
|
|
2336
|
+
loadConfig,
|
|
2337
|
+
resolveConfig,
|
|
2338
|
+
connect,
|
|
2339
|
+
currentPlatform,
|
|
2340
|
+
IngressLog,
|
|
2341
|
+
hashText,
|
|
2342
|
+
stripControlChars,
|
|
2343
|
+
Pairings,
|
|
2344
|
+
daemonPaths,
|
|
2345
|
+
defaultRoot,
|
|
2346
|
+
childEnv,
|
|
2347
|
+
claudeArgv,
|
|
2348
|
+
ClaudeCliBackend,
|
|
2349
|
+
OpenAiHttpBackend,
|
|
2350
|
+
createBackend,
|
|
2351
|
+
IMPLEMENTED_BACKEND_IDS,
|
|
2352
|
+
composePrompt,
|
|
2353
|
+
Runner,
|
|
2354
|
+
DAEMON_VERSION,
|
|
2355
|
+
runCli,
|
|
2356
|
+
main
|
|
2357
|
+
};
|
|
2358
|
+
//# sourceMappingURL=chunk-S5WHDSUF.js.map
|