@byollm/server 0.1.0-alpha.2 → 0.1.0-alpha.22
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/README.md +151 -12
- package/bin/keygen.mjs +21 -0
- package/dist/chunk-QTRX2LSF.js +659 -0
- package/dist/chunk-QTRX2LSF.js.map +1 -0
- package/dist/{handlers-D7lWfwno.d.ts → handlers-BQsGZoRL.d.ts} +27 -4
- package/dist/index.d.ts +190 -16
- package/dist/index.js +509 -43
- package/dist/index.js.map +1 -1
- package/dist/next.d.ts +40 -8
- package/dist/next.js +8 -2
- package/dist/next.js.map +1 -1
- package/dist/store-BWG1tpqc.d.ts +431 -0
- package/dist/supabase/index.d.ts +1 -1
- package/dist/supabase/index.js +104 -38
- package/dist/supabase/index.js.map +1 -1
- package/package.json +8 -4
- package/supabase/migrations/20260809000000_byollm_runner.sql +22 -3
- package/supabase/migrations/20260819000000_drop_runner_token.sql +87 -0
- package/supabase/migrations/20260819010000_completed_by_lease_id.sql +25 -0
- package/dist/chunk-HL6EYHQ7.js +0 -422
- package/dist/chunk-HL6EYHQ7.js.map +0 -1
- package/dist/store-D23N6iiP.d.ts +0 -255
package/dist/index.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ByollmHandlers,
|
|
3
3
|
SERVED_PROTOCOL_VERSION,
|
|
4
|
-
bearerFrom,
|
|
5
4
|
createFetchHandler,
|
|
5
|
+
deadlineFor,
|
|
6
6
|
generateDeviceCode,
|
|
7
7
|
generateJobId,
|
|
8
|
+
generateLeaseId,
|
|
8
9
|
generateRunnerId,
|
|
9
|
-
generateRunnerToken,
|
|
10
10
|
generateUserCode,
|
|
11
11
|
hashSecret,
|
|
12
|
+
resealForDevice,
|
|
12
13
|
routeEndpoint,
|
|
13
|
-
secretsMatch
|
|
14
|
-
|
|
14
|
+
secretsMatch,
|
|
15
|
+
signatureFrom
|
|
16
|
+
} from "./chunk-QTRX2LSF.js";
|
|
15
17
|
import {
|
|
16
18
|
NoRunnerAvailableError,
|
|
17
19
|
PollingDelivery,
|
|
@@ -20,19 +22,297 @@ import {
|
|
|
20
22
|
|
|
21
23
|
// src/app.ts
|
|
22
24
|
import {
|
|
25
|
+
ENVELOPE_MAX_AGE_MS,
|
|
26
|
+
KindedPayload,
|
|
27
|
+
keyId as keyId2,
|
|
28
|
+
payloadTextLength,
|
|
29
|
+
publicIdentityOf as publicIdentityOf2,
|
|
30
|
+
seal,
|
|
31
|
+
sizeClassOf,
|
|
23
32
|
backendDescriptor,
|
|
24
33
|
matchAudience
|
|
25
34
|
} from "@byollm/protocol";
|
|
35
|
+
|
|
36
|
+
// src/cloud.ts
|
|
37
|
+
import {
|
|
38
|
+
SealedOutcome,
|
|
39
|
+
keyId,
|
|
40
|
+
open,
|
|
41
|
+
publicIdentityOf,
|
|
42
|
+
provenanceFor,
|
|
43
|
+
signSiteRequest
|
|
44
|
+
} from "@byollm/protocol";
|
|
45
|
+
var CloudLane = class {
|
|
46
|
+
#options;
|
|
47
|
+
#store;
|
|
48
|
+
#siteKeys;
|
|
49
|
+
#now;
|
|
50
|
+
#fetch;
|
|
51
|
+
constructor(deps) {
|
|
52
|
+
this.#options = deps.options;
|
|
53
|
+
this.#store = deps.store;
|
|
54
|
+
this.#siteKeys = deps.siteKeys;
|
|
55
|
+
this.#now = deps.now;
|
|
56
|
+
this.#fetch = deps.options.fetch ?? globalThis.fetch;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Publish a job's stub for routing.
|
|
60
|
+
*
|
|
61
|
+
* The stub and nothing else — byollm_009 §6 makes that exhaustive by
|
|
62
|
+
* construction, so this cannot leak a payload even by mistake: there is no
|
|
63
|
+
* field on `JobStub` to put one in.
|
|
64
|
+
*/
|
|
65
|
+
async publish(record) {
|
|
66
|
+
const stub = {
|
|
67
|
+
id: record.id,
|
|
68
|
+
kind: record.kind,
|
|
69
|
+
owner: record.owner,
|
|
70
|
+
// This site, by its identity key id — Amendment A §A.3. The relay
|
|
71
|
+
// already knows which site it is routing for, so this discloses nothing
|
|
72
|
+
// new to it; what it adds is that the *daemon* can check the stub
|
|
73
|
+
// against the envelope's `senderKeyId` without asking the relay.
|
|
74
|
+
site: keyId(publicIdentityOf(this.#siteKeys).identity),
|
|
75
|
+
audience: record.audience,
|
|
76
|
+
// `audienceAllow` is deliberately **not** published — cloud_008 §0.2.
|
|
77
|
+
//
|
|
78
|
+
// It is a list of the people who may run this job, and on the direct
|
|
79
|
+
// plane that is unremarkable: the site authored the list and the site is
|
|
80
|
+
// the upstream, so the party receiving it already has it. Through a
|
|
81
|
+
// relay it is a third party, and byollm_009 §6's enumerated metadata —
|
|
82
|
+
// "exhaustive and normative… what an upstream can see, stated as a
|
|
83
|
+
// commitment" — does not include it. It was reaching the relay on every
|
|
84
|
+
// named-audience job.
|
|
85
|
+
//
|
|
86
|
+
// Nothing is lost by withholding it, which is why this is a Tier 0 fix
|
|
87
|
+
// rather than a trade. `matchAudience` treats it as a *narrowing*:
|
|
88
|
+
// `job.audienceAllow !== undefined && !includes(daemon.owner)` refuses,
|
|
89
|
+
// and its absence simply falls through to the checks that actually
|
|
90
|
+
// enforce — the daemon's own allowlist (`NAMED_LOCAL_ALLOWLIST`) and the
|
|
91
|
+
// backend's offer scope. On this lane the relay narrows too, from the
|
|
92
|
+
// control plane's rosters. The enforcement was never here.
|
|
93
|
+
sizeClass: record.sizeClass,
|
|
94
|
+
streaming: false,
|
|
95
|
+
// The relay needs *a* deadline to bound routing. A job without one gets
|
|
96
|
+
// the envelope's, which is the outer bound on how long the ciphertext
|
|
97
|
+
// is worth carrying — never longer than the work could possibly matter.
|
|
98
|
+
// The same fallback the direct plane uses — cloud_008 Tier 4, finding
|
|
99
|
+
// 31. This said `createdAt + ENVELOPE_TTL_FALLBACK`, a local constant
|
|
100
|
+
// whose value happened to equal `ENVELOPE_MAX_AGE_MS`; the direct plane
|
|
101
|
+
// said `(claimableAt ?? now) + ttlMs`. One field, two meanings, and a
|
|
102
|
+
// job that was blocked on a dependency got a deadline measured from
|
|
103
|
+
// when it was *created* on one lane and from when it became *claimable*
|
|
104
|
+
// on the other.
|
|
105
|
+
deadlineAt: deadlineFor(record, this.#now())
|
|
106
|
+
};
|
|
107
|
+
await this.#post("enqueue", {
|
|
108
|
+
siteId: this.#options.siteId,
|
|
109
|
+
stub
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Withdraw a job at the relay — cloud_008 §2.2.
|
|
114
|
+
*
|
|
115
|
+
* `app.cancel()` marks the site's own row terminal, which stops the *next*
|
|
116
|
+
* seal. It cannot stop a device that is already running the work, because
|
|
117
|
+
* on this lane the site is not the upstream: only the relay talks to the
|
|
118
|
+
* daemon, and it answered `cancel: []` unconditionally.
|
|
119
|
+
*
|
|
120
|
+
* So the cancellation has to travel. The relay marks the job, stops
|
|
121
|
+
* offering it, and names it to the holding device at its next heartbeat —
|
|
122
|
+
* the same path the direct plane has always had, arriving one hop later.
|
|
123
|
+
*/
|
|
124
|
+
async cancel(jobId) {
|
|
125
|
+
await this.#post("cancel", { siteId: this.#options.siteId, jobId });
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* One cycle: seal for anything claimed, collect anything finished.
|
|
129
|
+
*
|
|
130
|
+
* Idempotent and safe to call as often as you like. Exposed as a single
|
|
131
|
+
* cycle rather than hidden behind a timer so a caller decides its own
|
|
132
|
+
* cadence — a serverless site runs it on a cron, a long-lived one on an
|
|
133
|
+
* interval, and a test runs it exactly when it means to.
|
|
134
|
+
*/
|
|
135
|
+
async pump() {
|
|
136
|
+
const sealed = [];
|
|
137
|
+
const refused = [];
|
|
138
|
+
const completed = [];
|
|
139
|
+
const pending = await this.#get("pending");
|
|
140
|
+
for (const claim of pending.jobs) {
|
|
141
|
+
const record = await this.#store.get(claim.jobId);
|
|
142
|
+
if (!record) continue;
|
|
143
|
+
const resealed = await resealForDevice({
|
|
144
|
+
siteKeys: this.#siteKeys,
|
|
145
|
+
job: {
|
|
146
|
+
id: record.id,
|
|
147
|
+
envelope: record.envelope,
|
|
148
|
+
createdAt: record.createdAt
|
|
149
|
+
},
|
|
150
|
+
device: claim.device
|
|
151
|
+
});
|
|
152
|
+
if (!resealed.ok) {
|
|
153
|
+
refused.push(claim.jobId);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const adopted = await this.#store.adopt({
|
|
157
|
+
jobId: claim.jobId,
|
|
158
|
+
leaseId: claim.leaseId,
|
|
159
|
+
expiresAt: claim.leaseExpiresAt,
|
|
160
|
+
now: this.#now()
|
|
161
|
+
});
|
|
162
|
+
if (!adopted) {
|
|
163
|
+
refused.push(claim.jobId);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
await this.#post("payload", {
|
|
167
|
+
siteId: this.#options.siteId,
|
|
168
|
+
jobId: claim.jobId,
|
|
169
|
+
envelope: resealed.envelope
|
|
170
|
+
});
|
|
171
|
+
sealed.push(claim.jobId);
|
|
172
|
+
}
|
|
173
|
+
const finished = await this.#get("results");
|
|
174
|
+
for (const done of finished.jobs) {
|
|
175
|
+
const record = await this.#store.get(done.jobId);
|
|
176
|
+
if (!record || record.state === "ok" || record.state === "error") {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const outcome = await this.#openResult(done);
|
|
180
|
+
if (!outcome) {
|
|
181
|
+
refused.push(done.jobId);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
await this.#store.complete({
|
|
185
|
+
jobId: done.jobId,
|
|
186
|
+
// The relay named the device; the signature above proved it — §3.6.
|
|
187
|
+
runnerId: done.runnerId,
|
|
188
|
+
// The grant, not the machine: this site never paired with the device
|
|
189
|
+
// that ran it, and the signature it verified above is the stronger
|
|
190
|
+
// claim about who did.
|
|
191
|
+
holder: { by: "lease", leaseId: done.leaseId },
|
|
192
|
+
outcome: outcome.outcome,
|
|
193
|
+
provenance: provenanceFor({
|
|
194
|
+
audience: record.audience,
|
|
195
|
+
runnerId: done.runnerId,
|
|
196
|
+
// The owner, from the relay's own record of who claimed it — not a
|
|
197
|
+
// key id. cloud_008 §2.5: this said `keyId(device.identity)`, which
|
|
198
|
+
// put a key id where the direct plane puts a user id, so an app
|
|
199
|
+
// comparing provenance across lanes compared two namespaces and got
|
|
200
|
+
// `false` for the same person. The device's key is still what the
|
|
201
|
+
// signature was verified against, above; that is a different
|
|
202
|
+
// question from whose machine it is.
|
|
203
|
+
runnerOwner: done.runnerOwner,
|
|
204
|
+
// From the envelope, not invented — cloud_008 §2.5. These were
|
|
205
|
+
// hardcoded `"http"` and `"unknown"` because the daemon's declared
|
|
206
|
+
// values stopped at the relay, which is right: a blind relay acts
|
|
207
|
+
// on neither. Sealing them carries them past it untouched.
|
|
208
|
+
backendClass: outcome.ran.backendClass,
|
|
209
|
+
model: outcome.ran.model
|
|
210
|
+
}),
|
|
211
|
+
now: this.#now()
|
|
212
|
+
});
|
|
213
|
+
completed.push(done.jobId);
|
|
214
|
+
}
|
|
215
|
+
return { sealed, completed, refused };
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Open a sealed result and verify it came from the device that claimed it.
|
|
219
|
+
*
|
|
220
|
+
* The relay says which device ran the job; this checks that claim against a
|
|
221
|
+
* signature the relay cannot produce. A relay that named the wrong device
|
|
222
|
+
* gets a refusal, not a stored result — which is what keeps `RELAY_BLIND`
|
|
223
|
+
* from quietly becoming `RELAY_TRUSTED`.
|
|
224
|
+
*/
|
|
225
|
+
async #openResult(done) {
|
|
226
|
+
const opened = await open({
|
|
227
|
+
envelope: done.envelope,
|
|
228
|
+
recipientKeys: this.#siteKeys,
|
|
229
|
+
senderIdentityPublic: done.device.identity,
|
|
230
|
+
expected: {
|
|
231
|
+
jobId: done.jobId,
|
|
232
|
+
senderKeyId: keyId(done.device.identity),
|
|
233
|
+
recipientKeyId: keyId(publicIdentityOf(this.#siteKeys).identity),
|
|
234
|
+
direction: "result"
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
if (!opened.ok) return null;
|
|
238
|
+
let parsed;
|
|
239
|
+
try {
|
|
240
|
+
parsed = JSON.parse(opened.plaintext);
|
|
241
|
+
} catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
const sealed = SealedOutcome.safeParse(parsed);
|
|
245
|
+
if (!sealed.success) return null;
|
|
246
|
+
if (sealed.data.outcome.outcome !== done.disposition) return null;
|
|
247
|
+
return sealed.data;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Sign a site-plane call with this site's identity key.
|
|
251
|
+
*
|
|
252
|
+
* The same scheme the daemon uses against an upstream, because the site is
|
|
253
|
+
* in the same position: an outbound caller whose key the relay already holds
|
|
254
|
+
* for other reasons. Nothing else authenticates this plane — a relay that
|
|
255
|
+
* took the `siteId` in a body at face value would let anyone enqueue work in
|
|
256
|
+
* a site's name and read who claimed it.
|
|
257
|
+
*/
|
|
258
|
+
#headers(endpoint, rawBody) {
|
|
259
|
+
const signature = signSiteRequest(this.#siteKeys, {
|
|
260
|
+
endpoint,
|
|
261
|
+
siteId: this.#options.siteId,
|
|
262
|
+
issuedAt: this.#now(),
|
|
263
|
+
body: rawBody
|
|
264
|
+
});
|
|
265
|
+
return {
|
|
266
|
+
"x-byollm-site": this.#options.siteId,
|
|
267
|
+
"x-byollm-issued-at": String(signature.issuedAt),
|
|
268
|
+
"x-byollm-signature": signature.signature
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
async #post(endpoint, body) {
|
|
272
|
+
const rawBody = JSON.stringify(body);
|
|
273
|
+
const response = await this.#fetch(
|
|
274
|
+
`${this.#options.relayOrigin}/relay/site/${endpoint}`,
|
|
275
|
+
{
|
|
276
|
+
method: "POST",
|
|
277
|
+
headers: {
|
|
278
|
+
"content-type": "application/json",
|
|
279
|
+
...this.#headers(endpoint, rawBody)
|
|
280
|
+
},
|
|
281
|
+
body: rawBody
|
|
282
|
+
}
|
|
283
|
+
);
|
|
284
|
+
return response.json();
|
|
285
|
+
}
|
|
286
|
+
async #get(endpoint) {
|
|
287
|
+
const url = `${this.#options.relayOrigin}/relay/site/${endpoint}?siteId=${encodeURIComponent(this.#options.siteId)}`;
|
|
288
|
+
const response = await this.#fetch(url, {
|
|
289
|
+
headers: this.#headers(endpoint, "")
|
|
290
|
+
});
|
|
291
|
+
return response.json();
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// src/app.ts
|
|
26
296
|
var DEFAULT_LIVENESS_MS = 35e3;
|
|
27
297
|
var ByollmApp = class {
|
|
28
298
|
#store;
|
|
299
|
+
#siteKeys;
|
|
29
300
|
#now;
|
|
30
301
|
#livenessMs;
|
|
31
302
|
#delivery;
|
|
303
|
+
/** Present only in the cloud lane; the site's side of the relay. */
|
|
304
|
+
cloud;
|
|
32
305
|
constructor(options) {
|
|
33
306
|
this.#store = options.store;
|
|
307
|
+
this.#siteKeys = options.siteKeys;
|
|
34
308
|
this.#now = options.now ?? Date.now;
|
|
35
309
|
this.#livenessMs = options.livenessMs ?? DEFAULT_LIVENESS_MS;
|
|
310
|
+
this.cloud = options.lane === void 0 ? void 0 : new CloudLane({
|
|
311
|
+
options: options.lane,
|
|
312
|
+
store: options.store,
|
|
313
|
+
siteKeys: options.siteKeys,
|
|
314
|
+
now: this.#now
|
|
315
|
+
});
|
|
36
316
|
const deps = {
|
|
37
317
|
...options.noRunnerGraceMs === void 0 ? {} : { graceMs: options.noRunnerGraceMs },
|
|
38
318
|
read: (jobId) => this.result(jobId),
|
|
@@ -66,7 +346,45 @@ var ByollmApp = class {
|
|
|
66
346
|
* the app is obliged to disclose that to whoever reads it.
|
|
67
347
|
*/
|
|
68
348
|
async enqueue(input) {
|
|
69
|
-
const
|
|
349
|
+
const parsed = KindedPayload.safeParse({
|
|
350
|
+
kind: input.kind,
|
|
351
|
+
payload: input.payload
|
|
352
|
+
});
|
|
353
|
+
if (!parsed.success) {
|
|
354
|
+
const detail = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
|
|
355
|
+
throw new Error(`invalid ${input.kind} payload \u2014 ${detail}`);
|
|
356
|
+
}
|
|
357
|
+
const createdAt = this.#now();
|
|
358
|
+
const envelopeDeadlineAt = createdAt + ENVELOPE_MAX_AGE_MS;
|
|
359
|
+
const jobId = input.id ?? generateJobId();
|
|
360
|
+
const senderKeyId = keyId2(publicIdentityOf2(this.#siteKeys).identity);
|
|
361
|
+
const envelope = await seal({
|
|
362
|
+
plaintext: JSON.stringify(parsed.data.payload),
|
|
363
|
+
senderKeys: this.#siteKeys,
|
|
364
|
+
recipientEncryptionPublic: this.#siteKeys.encryptionPublic,
|
|
365
|
+
context: {
|
|
366
|
+
jobId,
|
|
367
|
+
senderKeyId,
|
|
368
|
+
recipientKeyId: senderKeyId,
|
|
369
|
+
deadlineAt: envelopeDeadlineAt,
|
|
370
|
+
direction: "payload"
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
const record = await this.#store.create(
|
|
374
|
+
{
|
|
375
|
+
...input,
|
|
376
|
+
id: jobId,
|
|
377
|
+
envelope,
|
|
378
|
+
sizeClass: sizeClassOf(
|
|
379
|
+
payloadTextLength({
|
|
380
|
+
kind: input.kind,
|
|
381
|
+
payload: parsed.data.payload
|
|
382
|
+
})
|
|
383
|
+
)
|
|
384
|
+
},
|
|
385
|
+
createdAt
|
|
386
|
+
);
|
|
387
|
+
await this.cloud?.publish(record);
|
|
70
388
|
return {
|
|
71
389
|
id: record.id,
|
|
72
390
|
record,
|
|
@@ -87,7 +405,7 @@ var ByollmApp = class {
|
|
|
87
405
|
* Check `provenance.untrusted` before rendering. It is true for every
|
|
88
406
|
* `named`/`public` job, because that text came from someone else's machine
|
|
89
407
|
* and the app must not present it as its own AI's answer
|
|
90
|
-
* ({@link MUSTS.
|
|
408
|
+
* ({@link MUSTS.PROVENANCE_NAMES_DEVICE}).
|
|
91
409
|
*/
|
|
92
410
|
async result(jobId) {
|
|
93
411
|
const job = await this.job(jobId);
|
|
@@ -101,7 +419,11 @@ var ByollmApp = class {
|
|
|
101
419
|
}
|
|
102
420
|
/** Ask a runner to stop. Queued jobs cancel at once; held jobs at the next heartbeat. */
|
|
103
421
|
async cancel(jobId) {
|
|
104
|
-
|
|
422
|
+
const cancelled = await this.#store.cancel(jobId, this.#now());
|
|
423
|
+
if (cancelled && this.cloud) {
|
|
424
|
+
await this.cloud.cancel(jobId).catch(() => void 0);
|
|
425
|
+
}
|
|
426
|
+
return cancelled;
|
|
105
427
|
}
|
|
106
428
|
/**
|
|
107
429
|
* Is there a live runner that could take a job of this shape?
|
|
@@ -136,7 +458,18 @@ var ByollmApp = class {
|
|
|
136
458
|
{
|
|
137
459
|
owner: runner.owner,
|
|
138
460
|
offerScope: capability.offerScope,
|
|
139
|
-
|
|
461
|
+
// A generic backend's cost depends on its base URL, which the
|
|
462
|
+
// server never sees; assume the expensive reading (byollm_007 §4).
|
|
463
|
+
cost: backendDescriptor(capability.backendId).cost ?? "metered",
|
|
464
|
+
// Consent is the daemon's to hold, and it has already applied it:
|
|
465
|
+
// the offer scope arriving here is the *effective* one, so a
|
|
466
|
+
// metered backend nobody agreed to share advertises `self` and is
|
|
467
|
+
// refused by the scope rule above. Re-deriving consent from
|
|
468
|
+
// `false` here would instead refuse every backend an owner
|
|
469
|
+
// deliberately shared, because the server has no way to learn they
|
|
470
|
+
// did — the signal would be wrong in the direction that breaks
|
|
471
|
+
// working setups.
|
|
472
|
+
spend: { acknowledged: true },
|
|
140
473
|
// Same conservative assumption the claim path makes: the server
|
|
141
474
|
// cannot see a remote daemon's local allowlist (protocol §4.2).
|
|
142
475
|
locallyAllows: () => true
|
|
@@ -168,13 +501,10 @@ var ByollmApp = class {
|
|
|
168
501
|
* ({@link MUSTS.PAIR_ONE_USER}, {@link MUSTS.PAIR_INTERACTIVE}).
|
|
169
502
|
*/
|
|
170
503
|
async approvePairing(args) {
|
|
171
|
-
const token = generateRunnerToken();
|
|
172
504
|
return this.#store.approvePairing({
|
|
173
505
|
userCode: normalizeUserCode(args.userCode),
|
|
174
506
|
owner: args.owner,
|
|
175
507
|
runnerId: generateRunnerId(),
|
|
176
|
-
runnerToken: token,
|
|
177
|
-
tokenHash: hashSecret(token),
|
|
178
508
|
now: this.#now()
|
|
179
509
|
});
|
|
180
510
|
}
|
|
@@ -218,6 +548,45 @@ function normalizeUserCode(input) {
|
|
|
218
548
|
return bare.length === 8 ? `${bare.slice(0, 4)}-${bare.slice(4)}` : bare;
|
|
219
549
|
}
|
|
220
550
|
|
|
551
|
+
// src/keys.ts
|
|
552
|
+
import { StoredKeys, generateKeys, publicIdentityOf as publicIdentityOf3 } from "@byollm/protocol";
|
|
553
|
+
import { fingerprint } from "@byollm/protocol";
|
|
554
|
+
var generateSiteKeys = (now = Date.now()) => generateKeys(now);
|
|
555
|
+
function siteKeysFromEnv(variable = "BYOLLM_SITE_KEYS", env = process.env) {
|
|
556
|
+
const raw = env[variable];
|
|
557
|
+
if (raw === void 0 || raw === "") {
|
|
558
|
+
throw new Error(
|
|
559
|
+
`${variable} is not set. Generate a site identity once with \`npx @byollm/server keygen\` and set it as ${variable}. Do not generate keys at startup: every instance would get a different identity and daemons would pin one and be refused by another.`
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
let parsed;
|
|
563
|
+
try {
|
|
564
|
+
parsed = JSON.parse(Buffer.from(raw, "base64").toString("utf8"));
|
|
565
|
+
} catch {
|
|
566
|
+
throw new Error(
|
|
567
|
+
`${variable} is not base64-encoded JSON. It should be exactly what \`npx @byollm/server keygen\` printed.`
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
const result = StoredKeys.safeParse(parsed);
|
|
571
|
+
if (!result.success) {
|
|
572
|
+
throw new Error(
|
|
573
|
+
`${variable} does not contain a valid site identity. Regenerate it with \`npx @byollm/server keygen\` \u2014 and if this site has already paired daemons, they will need to pair again.`
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
return result.data;
|
|
577
|
+
}
|
|
578
|
+
function formatSiteKeys(keys) {
|
|
579
|
+
const encoded = Buffer.from(JSON.stringify(keys)).toString("base64");
|
|
580
|
+
return `# Set this as BYOLLM_SITE_KEYS. It is a secret: it is this site's
|
|
581
|
+
# identity, and anything holding it can be this site.
|
|
582
|
+
BYOLLM_SITE_KEYS=${encoded}
|
|
583
|
+
|
|
584
|
+
# Fingerprint (not secret \u2014 show it to users so they can check what
|
|
585
|
+
# their daemon pinned):
|
|
586
|
+
# ${fingerprint(publicIdentityOf3(keys).identity)}
|
|
587
|
+
`;
|
|
588
|
+
}
|
|
589
|
+
|
|
221
590
|
// src/memory.ts
|
|
222
591
|
import {
|
|
223
592
|
backendDescriptor as backendDescriptor2,
|
|
@@ -236,7 +605,7 @@ var MemoryStore = class {
|
|
|
236
605
|
}
|
|
237
606
|
// -- jobs ---------------------------------------------------------------
|
|
238
607
|
create(input, now) {
|
|
239
|
-
const id = input.id
|
|
608
|
+
const id = input.id;
|
|
240
609
|
const existing = this.#jobs.get(id);
|
|
241
610
|
if (existing) return Promise.resolve(existing);
|
|
242
611
|
const dependsOn = [...input.dependsOn ?? []];
|
|
@@ -246,13 +615,15 @@ var MemoryStore = class {
|
|
|
246
615
|
const job = {
|
|
247
616
|
id,
|
|
248
617
|
kind: input.kind,
|
|
249
|
-
|
|
618
|
+
envelope: input.envelope,
|
|
619
|
+
sizeClass: input.sizeClass,
|
|
250
620
|
audience: input.audience ?? "self",
|
|
251
621
|
owner: input.owner,
|
|
252
622
|
audienceAllow: input.audienceAllow ? [...input.audienceAllow] : void 0,
|
|
253
623
|
dependsOn,
|
|
254
624
|
state: "queued",
|
|
255
625
|
lease: null,
|
|
626
|
+
completedByLeaseId: null,
|
|
256
627
|
createdAt: now,
|
|
257
628
|
// The TTL clock starts here only if nothing blocks the job.
|
|
258
629
|
claimableAt: blocked ? null : now,
|
|
@@ -264,7 +635,7 @@ var MemoryStore = class {
|
|
|
264
635
|
provenance: null,
|
|
265
636
|
updatedAt: now
|
|
266
637
|
};
|
|
267
|
-
this.#
|
|
638
|
+
this.#write(id, job);
|
|
268
639
|
return Promise.resolve(job);
|
|
269
640
|
}
|
|
270
641
|
get(jobId) {
|
|
@@ -283,13 +654,16 @@ var MemoryStore = class {
|
|
|
283
654
|
...job,
|
|
284
655
|
state: "claimed",
|
|
285
656
|
lease: {
|
|
657
|
+
// A fresh id per grant. Two claims of the same job by the same
|
|
658
|
+
// runner are two different leases, and must be distinguishable.
|
|
659
|
+
id: generateLeaseId(),
|
|
286
660
|
runnerId: args.runnerId,
|
|
287
661
|
expiresAt: args.now + args.leaseMs
|
|
288
662
|
},
|
|
289
663
|
attempts: job.attempts + 1,
|
|
290
664
|
updatedAt: args.now
|
|
291
665
|
};
|
|
292
|
-
this.#
|
|
666
|
+
this.#write(job.id, updated);
|
|
293
667
|
claimed.push(updated);
|
|
294
668
|
}
|
|
295
669
|
return Promise.resolve(claimed);
|
|
@@ -313,9 +687,16 @@ var MemoryStore = class {
|
|
|
313
687
|
{
|
|
314
688
|
owner: args.runnerOwner,
|
|
315
689
|
offerScope: capability.offerScope,
|
|
316
|
-
// From the registry, not a local guess — the
|
|
317
|
-
//
|
|
318
|
-
|
|
690
|
+
// From the registry, not a local guess — the cost rules must mean the
|
|
691
|
+
// same thing on both sides of the wire. The server cannot see a
|
|
692
|
+
// remote daemon's base URL, so a generic backend with no declared
|
|
693
|
+
// cost is treated as metered: the expensive side, and the daemon
|
|
694
|
+
// refuses anyway if it disagrees (byollm_007 §2).
|
|
695
|
+
cost: backendDescriptor2(capability.backendId).cost ?? "metered",
|
|
696
|
+
// Nor can it see the owner's spend consent. It offers; the daemon is
|
|
697
|
+
// the enforcing side and releases with `refused` if its own rules say
|
|
698
|
+
// no — the same shape as the `named` allowlist.
|
|
699
|
+
spend: { acknowledged: true },
|
|
319
700
|
// The server cannot see a remote daemon's local allowlist and must
|
|
320
701
|
// not pretend to (protocol §4.2). It admits the job here; the daemon
|
|
321
702
|
// is the enforcing side and releases with `refused` if its own list
|
|
@@ -329,9 +710,9 @@ var MemoryStore = class {
|
|
|
329
710
|
this.#expireDueSync(args.now);
|
|
330
711
|
const renewed = [];
|
|
331
712
|
const lost = [];
|
|
332
|
-
for (const jobId of args.
|
|
713
|
+
for (const { jobId, leaseId } of args.leases) {
|
|
333
714
|
const job = this.#jobs.get(jobId);
|
|
334
|
-
if (!job || job.lease?.runnerId !== args.runnerId) {
|
|
715
|
+
if (!job || job.lease?.runnerId !== args.runnerId || job.lease.id !== leaseId) {
|
|
335
716
|
lost.push(jobId);
|
|
336
717
|
continue;
|
|
337
718
|
}
|
|
@@ -340,26 +721,56 @@ var MemoryStore = class {
|
|
|
340
721
|
continue;
|
|
341
722
|
}
|
|
342
723
|
const expiresAt = args.now + args.leaseMs;
|
|
343
|
-
this.#
|
|
724
|
+
this.#write(jobId, {
|
|
344
725
|
...job,
|
|
345
726
|
state: "running",
|
|
346
|
-
|
|
727
|
+
// Renewal extends the existing grant; it does not mint a new one.
|
|
728
|
+
lease: { ...job.lease, expiresAt },
|
|
347
729
|
updatedAt: args.now
|
|
348
730
|
});
|
|
349
731
|
renewed.push({ jobId, expiresAt });
|
|
350
732
|
}
|
|
351
733
|
return Promise.resolve({ renewed, lost });
|
|
352
734
|
}
|
|
735
|
+
adopt(args) {
|
|
736
|
+
const job = this.#jobs.get(args.jobId);
|
|
737
|
+
if (!job) return Promise.resolve(null);
|
|
738
|
+
if (job.state !== "queued" && job.state !== "claimed") {
|
|
739
|
+
return Promise.resolve(null);
|
|
740
|
+
}
|
|
741
|
+
if (job.lease && job.lease.id !== args.leaseId) {
|
|
742
|
+
return Promise.resolve(null);
|
|
743
|
+
}
|
|
744
|
+
const updated = {
|
|
745
|
+
...job,
|
|
746
|
+
state: "claimed",
|
|
747
|
+
lease: {
|
|
748
|
+
id: args.leaseId,
|
|
749
|
+
// No runner: this site never paired with the machine holding it.
|
|
750
|
+
runnerId: "",
|
|
751
|
+
expiresAt: args.expiresAt
|
|
752
|
+
},
|
|
753
|
+
updatedAt: args.now
|
|
754
|
+
};
|
|
755
|
+
this.#write(updated.id, updated);
|
|
756
|
+
return Promise.resolve(updated);
|
|
757
|
+
}
|
|
353
758
|
complete(args) {
|
|
354
759
|
const job = this.#jobs.get(args.jobId);
|
|
355
760
|
if (!job) return Promise.resolve({ accepted: false, job: null });
|
|
356
761
|
if (job.state === "ok" || job.state === "error" || job.state === "canceled") {
|
|
762
|
+
const sameDevice = job.provenance?.runnerId !== void 0 && job.provenance.runnerId === args.runnerId;
|
|
763
|
+
const sameGrant = args.holder.by === "lease" && job.completedByLeaseId !== null && job.completedByLeaseId === args.holder.leaseId;
|
|
764
|
+
if (sameDevice && sameGrant) {
|
|
765
|
+
return Promise.resolve({ accepted: false, duplicate: true, job });
|
|
766
|
+
}
|
|
357
767
|
return Promise.resolve({ accepted: false, job });
|
|
358
768
|
}
|
|
359
769
|
if (job.state === "expired") {
|
|
360
770
|
return Promise.resolve({ accepted: false, job });
|
|
361
771
|
}
|
|
362
|
-
|
|
772
|
+
const holds = args.holder.by === "runner" ? job.lease?.runnerId === args.holder.runnerId : job.lease?.id === args.holder.leaseId;
|
|
773
|
+
if (!holds) {
|
|
363
774
|
return Promise.resolve({ accepted: false, job });
|
|
364
775
|
}
|
|
365
776
|
const state = args.outcome.outcome === "ok" ? "ok" : args.outcome.outcome === "canceled" ? "canceled" : "error";
|
|
@@ -367,11 +778,13 @@ var MemoryStore = class {
|
|
|
367
778
|
...job,
|
|
368
779
|
state,
|
|
369
780
|
lease: null,
|
|
781
|
+
// The grant that recorded it, kept after the lease is dropped — §3.6.
|
|
782
|
+
completedByLeaseId: args.holder.by === "lease" ? args.holder.leaseId : job.lease?.id ?? null,
|
|
370
783
|
outcome: args.outcome,
|
|
371
784
|
provenance: args.provenance,
|
|
372
785
|
updatedAt: args.now
|
|
373
786
|
};
|
|
374
|
-
this.#
|
|
787
|
+
this.#write(job.id, updated);
|
|
375
788
|
this.#cancelRequests.delete(job.id);
|
|
376
789
|
if (state === "ok") this.#unblockDependents(job.id, args.now);
|
|
377
790
|
return Promise.resolve({ accepted: true, job: updated });
|
|
@@ -392,19 +805,70 @@ var MemoryStore = class {
|
|
|
392
805
|
(depId) => this.#jobs.get(depId)?.state === "ok"
|
|
393
806
|
);
|
|
394
807
|
if (ready) {
|
|
395
|
-
this.#
|
|
808
|
+
this.#write(job.id, { ...job, claimableAt: now, updatedAt: now });
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Watchers, by job id (byollm_009 §8.3).
|
|
814
|
+
*
|
|
815
|
+
* A `Set` per job so an unsubscribe removes exactly the handler it
|
|
816
|
+
* registered — two waiters on the same job are ordinary, and removing by
|
|
817
|
+
* job id alone would silently cancel someone else's wait.
|
|
818
|
+
*/
|
|
819
|
+
#watchers = /* @__PURE__ */ new Map();
|
|
820
|
+
subscribe(jobId, onChange) {
|
|
821
|
+
const existing = this.#watchers.get(jobId) ?? /* @__PURE__ */ new Set();
|
|
822
|
+
existing.add(onChange);
|
|
823
|
+
this.#watchers.set(jobId, existing);
|
|
824
|
+
let live = true;
|
|
825
|
+
return () => {
|
|
826
|
+
if (!live) return;
|
|
827
|
+
live = false;
|
|
828
|
+
const set = this.#watchers.get(jobId);
|
|
829
|
+
set?.delete(onChange);
|
|
830
|
+
if (set?.size === 0) this.#watchers.delete(jobId);
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* The single write path for a job.
|
|
835
|
+
*
|
|
836
|
+
* Every mutation goes through here so notification cannot be forgotten by
|
|
837
|
+
* a future one. Nine call sites existed when the push seam was added, and
|
|
838
|
+
* "remember to notify" is not a property nine call sites keep.
|
|
839
|
+
*/
|
|
840
|
+
#write(jobId, record) {
|
|
841
|
+
this.#jobs.set(jobId, record);
|
|
842
|
+
this.#notify(jobId);
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Tell anyone watching that a job changed.
|
|
846
|
+
*
|
|
847
|
+
* A throwing watcher must not corrupt the store's own bookkeeping, so each
|
|
848
|
+
* is isolated: this runs inside write paths, and one bad listener taking
|
|
849
|
+
* out an unrelated write would be a far worse failure than a missed
|
|
850
|
+
* notification.
|
|
851
|
+
*/
|
|
852
|
+
#notify(jobId) {
|
|
853
|
+
for (const watcher of this.#watchers.get(jobId) ?? []) {
|
|
854
|
+
try {
|
|
855
|
+
watcher();
|
|
856
|
+
} catch {
|
|
396
857
|
}
|
|
397
858
|
}
|
|
398
859
|
}
|
|
399
860
|
release(args) {
|
|
400
861
|
const released = [];
|
|
401
|
-
for (const jobId of args.
|
|
862
|
+
for (const { jobId, leaseId } of args.leases) {
|
|
402
863
|
const job = this.#jobs.get(jobId);
|
|
403
|
-
if (!job || job.lease?.runnerId !== args.runnerId)
|
|
404
|
-
|
|
864
|
+
if (!job || job.lease?.runnerId !== args.runnerId || job.lease.id !== leaseId) {
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
this.#write(jobId, {
|
|
405
868
|
...job,
|
|
406
869
|
state: "queued",
|
|
407
870
|
lease: null,
|
|
871
|
+
completedByLeaseId: null,
|
|
408
872
|
// Newly available again, so the TTL clock restarts here too.
|
|
409
873
|
claimableAt: args.now,
|
|
410
874
|
// A refusal is remembered, or the pair spins between claim and
|
|
@@ -434,6 +898,7 @@ var MemoryStore = class {
|
|
|
434
898
|
...job,
|
|
435
899
|
state: "queued",
|
|
436
900
|
lease: null,
|
|
901
|
+
completedByLeaseId: null,
|
|
437
902
|
// The TTL clock restarts: it measures how long a job has waited
|
|
438
903
|
// *unclaimed*, and this job has just become available again. Without
|
|
439
904
|
// this, a job whose runner died would expire for time it spent being
|
|
@@ -443,7 +908,7 @@ var MemoryStore = class {
|
|
|
443
908
|
claimableAt: now,
|
|
444
909
|
updatedAt: now
|
|
445
910
|
};
|
|
446
|
-
this.#
|
|
911
|
+
this.#write(job.id, requeued);
|
|
447
912
|
changed.push(requeued);
|
|
448
913
|
}
|
|
449
914
|
}
|
|
@@ -456,9 +921,10 @@ var MemoryStore = class {
|
|
|
456
921
|
...job,
|
|
457
922
|
state: "expired",
|
|
458
923
|
lease: null,
|
|
924
|
+
completedByLeaseId: null,
|
|
459
925
|
updatedAt: now
|
|
460
926
|
};
|
|
461
|
-
this.#
|
|
927
|
+
this.#write(job.id, expired);
|
|
462
928
|
changed.push(expired);
|
|
463
929
|
}
|
|
464
930
|
return changed;
|
|
@@ -471,9 +937,10 @@ var MemoryStore = class {
|
|
|
471
937
|
...job,
|
|
472
938
|
state: "canceled",
|
|
473
939
|
lease: null,
|
|
940
|
+
completedByLeaseId: null,
|
|
474
941
|
updatedAt: now
|
|
475
942
|
};
|
|
476
|
-
this.#
|
|
943
|
+
this.#write(jobId, canceled);
|
|
477
944
|
return Promise.resolve(canceled);
|
|
478
945
|
}
|
|
479
946
|
if (job.state === "claimed" || job.state === "running") {
|
|
@@ -524,7 +991,9 @@ var MemoryStore = class {
|
|
|
524
991
|
const runner = {
|
|
525
992
|
id: args.runnerId,
|
|
526
993
|
owner: args.owner,
|
|
527
|
-
|
|
994
|
+
// Carried from the pairing, not re-supplied at approval: the user
|
|
995
|
+
// approved a specific machine, and the runner must be that machine.
|
|
996
|
+
device: pairing.device,
|
|
528
997
|
label: pairing.label,
|
|
529
998
|
platform: pairing.platform,
|
|
530
999
|
daemonVersion: pairing.daemonVersion,
|
|
@@ -540,7 +1009,7 @@ var MemoryStore = class {
|
|
|
540
1009
|
state: "approved",
|
|
541
1010
|
owner: args.owner,
|
|
542
1011
|
runnerId: runner.id,
|
|
543
|
-
|
|
1012
|
+
collected: false
|
|
544
1013
|
});
|
|
545
1014
|
return Promise.resolve(runner);
|
|
546
1015
|
}
|
|
@@ -561,17 +1030,11 @@ var MemoryStore = class {
|
|
|
561
1030
|
if (pairing) {
|
|
562
1031
|
this.#pairings.set(deviceCodeHash, {
|
|
563
1032
|
...pairing,
|
|
564
|
-
|
|
1033
|
+
collected: true
|
|
565
1034
|
});
|
|
566
1035
|
}
|
|
567
1036
|
return Promise.resolve();
|
|
568
1037
|
}
|
|
569
|
-
getRunnerByTokenHash(hash) {
|
|
570
|
-
for (const runner of this.#runners.values()) {
|
|
571
|
-
if (runner.tokenHash === hash) return Promise.resolve(runner);
|
|
572
|
-
}
|
|
573
|
-
return Promise.resolve(null);
|
|
574
|
-
}
|
|
575
1038
|
getRunner(runnerId) {
|
|
576
1039
|
return Promise.resolve(this.#runners.get(runnerId) ?? null);
|
|
577
1040
|
}
|
|
@@ -613,22 +1076,25 @@ function capabilityFor(capabilities, kind) {
|
|
|
613
1076
|
export {
|
|
614
1077
|
ByollmApp,
|
|
615
1078
|
ByollmHandlers,
|
|
1079
|
+
CloudLane,
|
|
616
1080
|
MemoryStore,
|
|
617
1081
|
NoRunnerAvailableError,
|
|
618
1082
|
PollingDelivery,
|
|
619
1083
|
ResultTimeoutError,
|
|
620
1084
|
SERVED_PROTOCOL_VERSION,
|
|
621
|
-
bearerFrom,
|
|
622
1085
|
capabilityFor,
|
|
623
1086
|
createFetchHandler,
|
|
1087
|
+
formatSiteKeys,
|
|
624
1088
|
generateDeviceCode,
|
|
625
1089
|
generateJobId,
|
|
626
1090
|
generateRunnerId,
|
|
627
|
-
|
|
1091
|
+
generateSiteKeys,
|
|
628
1092
|
generateUserCode,
|
|
629
1093
|
hashSecret,
|
|
630
1094
|
normalizeUserCode,
|
|
631
1095
|
routeEndpoint,
|
|
632
|
-
secretsMatch
|
|
1096
|
+
secretsMatch,
|
|
1097
|
+
signatureFrom,
|
|
1098
|
+
siteKeysFromEnv
|
|
633
1099
|
};
|
|
634
1100
|
//# sourceMappingURL=index.js.map
|