@byollm/protocol 0.1.0-alpha.4 → 0.1.0-alpha.41
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 +104 -2
- package/dist/index.d.ts +359 -26
- package/dist/index.js +708 -116
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { z as z2 } from "zod";
|
|
3
3
|
|
|
4
4
|
// src/backends.ts
|
|
5
|
+
import { isIP } from "net";
|
|
5
6
|
import { z } from "zod";
|
|
6
7
|
var BackendClass = z.enum(["http", "process"]);
|
|
7
8
|
var BackendCost = z.enum(["free", "metered", "subscription"]);
|
|
@@ -177,13 +178,16 @@ function backendDescriptor(id) {
|
|
|
177
178
|
function isLocalHost(hostname) {
|
|
178
179
|
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
179
180
|
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
180
|
-
|
|
181
|
+
const version = isIP(host);
|
|
182
|
+
if (version === 0) return false;
|
|
183
|
+
if (version === 6) {
|
|
184
|
+
if (host === "::1") return true;
|
|
185
|
+
return /^f[cd]/.test(host);
|
|
186
|
+
}
|
|
181
187
|
if (host.startsWith("127.")) return true;
|
|
182
188
|
if (host.startsWith("10.")) return true;
|
|
183
189
|
if (host.startsWith("192.168.")) return true;
|
|
184
|
-
|
|
185
|
-
if (/^f[cd]/.test(host)) return true;
|
|
186
|
-
return false;
|
|
190
|
+
return /^172\.(1[6-9]|2\d|3[01])\./.test(host);
|
|
187
191
|
}
|
|
188
192
|
function resolveCost(id, baseUrl) {
|
|
189
193
|
const declared = BACKENDS[id].cost;
|
|
@@ -290,11 +294,21 @@ var ChatMessage = z3.object({
|
|
|
290
294
|
var GeneratePayload = z3.object({
|
|
291
295
|
prompt: z3.string().min(1).max(PAYLOAD_LIMITS.maxTextChars),
|
|
292
296
|
system: z3.string().max(PAYLOAD_LIMITS.maxTextChars).optional()
|
|
293
|
-
}).strict()
|
|
297
|
+
}).strict().refine(
|
|
298
|
+
(payload) => payload.prompt.length + (payload.system?.length ?? 0) <= PAYLOAD_LIMITS.maxTotalChars,
|
|
299
|
+
{
|
|
300
|
+
message: `payload exceeds ${String(PAYLOAD_LIMITS.maxTotalChars)} characters`
|
|
301
|
+
}
|
|
302
|
+
);
|
|
294
303
|
var ChatPayload = z3.object({
|
|
295
304
|
messages: z3.array(ChatMessage).min(1).max(PAYLOAD_LIMITS.maxMessages),
|
|
296
305
|
system: z3.string().max(PAYLOAD_LIMITS.maxTextChars).optional()
|
|
297
|
-
}).strict()
|
|
306
|
+
}).strict().refine(
|
|
307
|
+
(payload) => payload.messages.reduce((sum, m) => sum + m.content.length, 0) + (payload.system?.length ?? 0) <= PAYLOAD_LIMITS.maxTotalChars,
|
|
308
|
+
{
|
|
309
|
+
message: `payload exceeds ${String(PAYLOAD_LIMITS.maxTotalChars)} characters`
|
|
310
|
+
}
|
|
311
|
+
);
|
|
298
312
|
var JobKind = z3.enum(["llm.generate", "llm.chat"]);
|
|
299
313
|
var JOB_KINDS = Object.freeze(JobKind.options);
|
|
300
314
|
var KindedPayload = z3.discriminatedUnion("kind", [
|
|
@@ -378,8 +392,19 @@ var ClaimedJob = z4.object({
|
|
|
378
392
|
audience: Audience,
|
|
379
393
|
/** The app's id for the user who enqueued it. */
|
|
380
394
|
owner: z4.string().min(1),
|
|
381
|
-
/**
|
|
382
|
-
|
|
395
|
+
/**
|
|
396
|
+
* Which site's job — V1-3.
|
|
397
|
+
*
|
|
398
|
+
* The stub has always carried it; the opened job did not, so everything
|
|
399
|
+
* downstream of the payload — the ingress line above all — recorded a job
|
|
400
|
+
* id that belongs to a site without saying which. Two sites can choose
|
|
401
|
+
* the same id, and the meter is the product.
|
|
402
|
+
*
|
|
403
|
+
* Optional so a caller assembling a job by hand is not forced to invent
|
|
404
|
+
* one, and so this reads as what it is: a fact about where the work came
|
|
405
|
+
* from, not a second copy of the routing key.
|
|
406
|
+
*/
|
|
407
|
+
site: z4.string().min(1).optional(),
|
|
383
408
|
lease: Lease
|
|
384
409
|
}).strict();
|
|
385
410
|
var ResultProvenance = z4.object({
|
|
@@ -409,6 +434,13 @@ function provenanceFor(input) {
|
|
|
409
434
|
untrusted: input.audience !== "self"
|
|
410
435
|
};
|
|
411
436
|
}
|
|
437
|
+
var RunMetadata = z4.object({
|
|
438
|
+
/** Which model actually served it. */
|
|
439
|
+
model: z4.string().min(1),
|
|
440
|
+
backendClass: BackendClass,
|
|
441
|
+
/** Wall-clock milliseconds the backend call took. */
|
|
442
|
+
durationMs: z4.number().int().nonnegative()
|
|
443
|
+
}).strict();
|
|
412
444
|
var JobResultOk = z4.object({
|
|
413
445
|
outcome: z4.literal("ok"),
|
|
414
446
|
text: z4.string(),
|
|
@@ -430,11 +462,27 @@ var JobOutcome = z4.discriminatedUnion("outcome", [
|
|
|
430
462
|
JobResultError,
|
|
431
463
|
JobResultCanceled
|
|
432
464
|
]);
|
|
465
|
+
var SealedOutcome = z4.object({ outcome: JobOutcome, ran: RunMetadata }).strict();
|
|
433
466
|
var DeliveredResult = z4.object({
|
|
434
467
|
jobId: z4.string().min(1),
|
|
435
468
|
state: JobState,
|
|
436
469
|
outcome: JobOutcome.optional(),
|
|
437
|
-
provenance: ResultProvenance.optional()
|
|
470
|
+
provenance: ResultProvenance.optional(),
|
|
471
|
+
/**
|
|
472
|
+
* Present, and always `true`, when this did not come from a runner —
|
|
473
|
+
* {@link MUSTS.FALLBACK_LABELED}.
|
|
474
|
+
*
|
|
475
|
+
* The app's own `onNoRunner` value produced it: a hosted model, a cached
|
|
476
|
+
* answer, an apology. It never travels on the wire, because nothing on
|
|
477
|
+
* the wire produced it; it exists so that a result which did not come
|
|
478
|
+
* from the user's own compute cannot be reported as though it did.
|
|
479
|
+
*
|
|
480
|
+
* A literal rather than a boolean, so `fallback: false` is not a
|
|
481
|
+
* spelling anybody can reach for. The absence of this field means a
|
|
482
|
+
* runner ran the job, and the *server* stamps it — an app cannot supply
|
|
483
|
+
* a substitute that hides what it is.
|
|
484
|
+
*/
|
|
485
|
+
fallback: z4.literal(true).optional()
|
|
438
486
|
}).strict();
|
|
439
487
|
var SizeClass = z4.enum(["small", "medium", "large", "unbounded"]);
|
|
440
488
|
var SIZE_CLASS_LIMITS = Object.freeze({
|
|
@@ -456,8 +504,51 @@ var JobStub = z4.object({
|
|
|
456
504
|
kind: JobKind,
|
|
457
505
|
/** The app's id for the user who enqueued it. */
|
|
458
506
|
owner: z4.string().min(1),
|
|
507
|
+
/**
|
|
508
|
+
* Which site this job belongs to — byollm_009 Amendment A §A.3.
|
|
509
|
+
*
|
|
510
|
+
* **The site's identity key id**, not an id somebody assigned it. §6 has
|
|
511
|
+
* listed `site` since this spec was frozen; the schema never carried it,
|
|
512
|
+
* which is the drift the amendment closes.
|
|
513
|
+
*
|
|
514
|
+
* A key id rather than an opaque handle for one reason above the others:
|
|
515
|
+
* it makes the stub *self-describing* instead of a pointer into somebody
|
|
516
|
+
* else's table. A daemon holds this key id already, from pinning, so it
|
|
517
|
+
* can check `stub.site` against the payload envelope's `senderKeyId`
|
|
518
|
+
* without a lookup and without trusting the party that routed it. An
|
|
519
|
+
* opaque id can only be believed.
|
|
520
|
+
*
|
|
521
|
+
* It also avoids inventing a second namespace for a thing that has a
|
|
522
|
+
* canonical one — the shape of finding 41 (two owner namespaces compared
|
|
523
|
+
* for equality) and of finding fourteen before it.
|
|
524
|
+
*
|
|
525
|
+
* Rotation is a designed transition rather than a cost: a site publishes a
|
|
526
|
+
* new identity signed by the outgoing one, both are valid through an
|
|
527
|
+
* overlap window, and a daemon re-keys its own map by verifying that
|
|
528
|
+
* signature against the key it already pinned (§A.3.1).
|
|
529
|
+
*/
|
|
530
|
+
site: z4.string().min(1),
|
|
459
531
|
audience: Audience,
|
|
460
|
-
audienceAllow
|
|
532
|
+
// `audienceAllow` is **not** here, and its absence is the enforcement —
|
|
533
|
+
// cloud_008 §0.2.
|
|
534
|
+
//
|
|
535
|
+
// It was a list of the people who may run a job, travelling to every
|
|
536
|
+
// routing party on every `named` job. byollm_001 Rev 1 §B settled who
|
|
537
|
+
// decides that long before this schema existed: *the daemon's own list
|
|
538
|
+
// decides, not the server's*, and `allowlist.predicateFor(origin)` is the
|
|
539
|
+
// enforcement in both lanes. So this was a second answer to a question the
|
|
540
|
+
// daemon already owned — able only to agree, in which case it was
|
|
541
|
+
// redundant, or to disagree, in which case nothing said which wins.
|
|
542
|
+
//
|
|
543
|
+
// The rule it leaves behind, which decides the next field too: **a class
|
|
544
|
+
// the router acts on may travel; membership never does.** `audience` stays
|
|
545
|
+
// for exactly that reason — the relay narrows on it. A roster does not
|
|
546
|
+
// travel, so `ROSTER_NOT_DISCLOSED` holds here by absence, which is the
|
|
547
|
+
// strongest way for a MUST to hold.
|
|
548
|
+
//
|
|
549
|
+
// The site keeps its own copy on `JobRecord` and still filters candidates
|
|
550
|
+
// with it before offering. That is server-internal, where the party
|
|
551
|
+
// holding the list authored it.
|
|
461
552
|
sizeClass: SizeClass,
|
|
462
553
|
/** Reserved for byollm_006. False until streaming exists. */
|
|
463
554
|
streaming: z4.boolean(),
|
|
@@ -748,6 +839,21 @@ function signRequest(keys, input) {
|
|
|
748
839
|
signature: signWith(keys, canonicalRequest(input))
|
|
749
840
|
};
|
|
750
841
|
}
|
|
842
|
+
function signSiteRequest(keys, input) {
|
|
843
|
+
return signRequest(keys, {
|
|
844
|
+
endpoint: siteEndpoint(input.endpoint),
|
|
845
|
+
runnerId: input.siteId,
|
|
846
|
+
issuedAt: input.issuedAt,
|
|
847
|
+
body: input.body
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
function verifySiteRequest(input) {
|
|
851
|
+
return verifyRequest({
|
|
852
|
+
...input,
|
|
853
|
+
endpoint: siteEndpoint(input.endpoint)
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
var siteEndpoint = (endpoint) => `site/${endpoint}`;
|
|
751
857
|
function verifyRequest(input) {
|
|
752
858
|
const skew = input.maxSkewMs ?? MAX_CLOCK_SKEW_MS;
|
|
753
859
|
if (Math.abs(input.now - input.signature.issuedAt) > skew) return "stale";
|
|
@@ -764,7 +870,69 @@ function verifyRequest(input) {
|
|
|
764
870
|
return ok ? null : "bad-signature";
|
|
765
871
|
}
|
|
766
872
|
|
|
873
|
+
// src/succession.ts
|
|
874
|
+
import { z as z8 } from "zod";
|
|
875
|
+
var SUCCESSION_CONTEXT = "byollm/v1/site-succession";
|
|
876
|
+
var RETIREMENT_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
877
|
+
var MAX_SUCCESSION_CHAIN = 64;
|
|
878
|
+
var Succession = z8.object({
|
|
879
|
+
/**
|
|
880
|
+
* The predecessor's public identity — K1, in full.
|
|
881
|
+
*
|
|
882
|
+
* The whole identity rather than the key id, because a daemon meeting a
|
|
883
|
+
* chain it has not seen before has to *verify* each link, and a key id is
|
|
884
|
+
* a fingerprint: enough to compare, never enough to check a signature.
|
|
885
|
+
*/
|
|
886
|
+
identity: PublicIdentity,
|
|
887
|
+
/** K1's signature over the statement naming K1 and its successor. */
|
|
888
|
+
signature: z8.string().min(1)
|
|
889
|
+
}).strict();
|
|
890
|
+
function successionStatement(fromKeyId, toKeyId) {
|
|
891
|
+
return Buffer.from(`${SUCCESSION_CONTEXT}:${fromKeyId}:${toKeyId}`);
|
|
892
|
+
}
|
|
893
|
+
function signSuccession(previous, next) {
|
|
894
|
+
return {
|
|
895
|
+
identity: {
|
|
896
|
+
identity: previous.identityPublic,
|
|
897
|
+
encryption: previous.encryptionPublic,
|
|
898
|
+
encryptionSig: previous.encryptionSig
|
|
899
|
+
},
|
|
900
|
+
signature: signWith(
|
|
901
|
+
previous,
|
|
902
|
+
successionStatement(keyId(previous.identityPublic), keyId(next.identity))
|
|
903
|
+
)
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
function verifyLink(link, toKeyId) {
|
|
907
|
+
if (!verifyPublicIdentity(link.identity)) return false;
|
|
908
|
+
return verifyWith(
|
|
909
|
+
link.identity.identity,
|
|
910
|
+
successionStatement(keyId(link.identity.identity), toKeyId),
|
|
911
|
+
link.signature
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
function walkSuccession(input) {
|
|
915
|
+
const { current, chain, approved } = input;
|
|
916
|
+
if (chain.length === 0) return { path: [current], failure: "no-chain" };
|
|
917
|
+
if (chain.length > MAX_SUCCESSION_CHAIN)
|
|
918
|
+
return { path: [current], failure: "too-long" };
|
|
919
|
+
const steps = [...chain].reverse();
|
|
920
|
+
const path = [current];
|
|
921
|
+
let succeeding = current;
|
|
922
|
+
for (const link of steps) {
|
|
923
|
+
if (!verifyLink(link, succeeding)) return { path, failure: "broken-link" };
|
|
924
|
+
const previous = keyId(link.identity.identity);
|
|
925
|
+
path.unshift(previous);
|
|
926
|
+
if (approved(previous)) return { path, from: previous };
|
|
927
|
+
succeeding = previous;
|
|
928
|
+
}
|
|
929
|
+
return { path, failure: "unknown-origin" };
|
|
930
|
+
}
|
|
931
|
+
|
|
767
932
|
// src/musts.ts
|
|
933
|
+
function kindsOf(must2) {
|
|
934
|
+
return typeof must2.verifiedBy === "string" ? [must2.verifiedBy] : must2.verifiedBy;
|
|
935
|
+
}
|
|
768
936
|
var must = (m) => Object.freeze(m);
|
|
769
937
|
var MUSTS = Object.freeze({
|
|
770
938
|
// ---- Pairing and identity -------------------------------------------
|
|
@@ -797,6 +965,48 @@ var MUSTS = Object.freeze({
|
|
|
797
965
|
verifiedBy: "conformance",
|
|
798
966
|
source: "byollm_009 \xA74"
|
|
799
967
|
}),
|
|
968
|
+
SITE_KEY_BY_STUB: must({
|
|
969
|
+
id: "SITE_KEY_BY_STUB",
|
|
970
|
+
statement: "A daemon MUST verify a job's payload against the pinned key of the site the stub names, and MUST refuse a job naming a site it has not pinned. It MUST NOT fall back to another pinned key, and MUST refuse an envelope whose declared sender disagrees with the stub's site.",
|
|
971
|
+
enforcedBy: "daemon",
|
|
972
|
+
// Adversarial, and the reason is the finding that produced it: the
|
|
973
|
+
// honest paths pass with every site check deleted, because `open`
|
|
974
|
+
// refuses a signature from the wrong key anyway. What distinguishes an
|
|
975
|
+
// enforced rule from a coincidence here is a hostile pairing of stub and
|
|
976
|
+
// envelope, which no conformance client would ever send.
|
|
977
|
+
verifiedBy: "adversarial",
|
|
978
|
+
source: "byollm_009 \xA7A.3"
|
|
979
|
+
}),
|
|
980
|
+
SITES_LOCALLY_APPROVED: must({
|
|
981
|
+
id: "SITES_LOCALLY_APPROVED",
|
|
982
|
+
statement: "A daemon MUST NOT run work for a site it has not approved on the machine itself. An upstream may propose a site set; a site the daemon has never approved MUST be offered to its owner and served nothing until they approve it. A key that has changed for an already-approved id MUST be refused for the life of the pairing, including after that id has left the set and returned. A **verified succession** is not a changed key: a new key id carrying a signature, by a key this daemon has already approved, over a statement naming both key ids MUST be accepted without a new local approval \u2014 provided the control plane projects the same successor \u2014 and MUST be announced rather than applied silently.",
|
|
983
|
+
enforcedBy: "daemon",
|
|
984
|
+
// Two kinds, and the second is the one that matters — V1-1.
|
|
985
|
+
//
|
|
986
|
+
// `construction`: the daemon cannot serve a site that is not in its
|
|
987
|
+
// pinned map, and admission refuses before a payload is fetched, so the
|
|
988
|
+
// ordinary path cannot reach a site nobody approved.
|
|
989
|
+
//
|
|
990
|
+
// `adversarial`: the property that survives is about a *sequence* —
|
|
991
|
+
// remove the id, re-offer it under a different key — which no honest
|
|
992
|
+
// upstream sends and which the fence above does not see. That was the
|
|
993
|
+
// bypass: the pin was deleted with the id, so the comparison had nothing
|
|
994
|
+
// to compare against and the substitution arrived as a stranger.
|
|
995
|
+
// **Not `conformance`, and that is a live gap rather than a judgement.**
|
|
996
|
+
// Amendment C's succession clause is a rule about two implementations
|
|
997
|
+
// agreeing, which is what a conformance check is for — but rotating a
|
|
998
|
+
// site's key is not something `ConformanceTarget` can express, and adding
|
|
999
|
+
// an optional hook that most targets omit would produce a check reporting
|
|
1000
|
+
// success for a reason unrelated to the property it claims. That is this
|
|
1001
|
+
// project's most-repeated bug, and it is not worth reintroducing for a
|
|
1002
|
+
// stronger-sounding word in a table. The rotation path is verified by
|
|
1003
|
+
// `site-rotation.test.ts` (both directions, against the shipped runner)
|
|
1004
|
+
// and `relay/test/rotation.test.ts` (both planes, against the reference
|
|
1005
|
+
// relay); the missing piece is a second *independent* implementation to
|
|
1006
|
+
// check them against, and there is not one yet.
|
|
1007
|
+
verifiedBy: ["construction", "adversarial"],
|
|
1008
|
+
source: "byollm_009 \xA7B.2, Amendment C"
|
|
1009
|
+
}),
|
|
800
1010
|
KEYS_EXCHANGED_AT_CONSENT: must({
|
|
801
1011
|
id: "KEYS_EXCHANGED_AT_CONSENT",
|
|
802
1012
|
statement: "Pairing MUST exchange both parties' public identities; each side MUST verify that the encryption key is signed by the identity presenting it, and MUST pin the identity. Keys MUST NOT be delivered before approval.",
|
|
@@ -984,12 +1194,12 @@ var MUSTS = Object.freeze({
|
|
|
984
1194
|
verifiedBy: "conformance",
|
|
985
1195
|
source: "byollm_001 \xA7Endpoints.4"
|
|
986
1196
|
}),
|
|
987
|
-
|
|
988
|
-
id: "
|
|
989
|
-
statement: "A result
|
|
1197
|
+
PROVENANCE_NAMES_DEVICE: must({
|
|
1198
|
+
id: "PROVENANCE_NAMES_DEVICE",
|
|
1199
|
+
statement: "A result MUST carry the claiming device's key id and its relationship to the requester, to the delivery seam, so an app never treats volunteer output as first-party. The key id MUST be the device the upstream granted the lease to, and a result whose signature does not verify against that device MUST be refused rather than recorded.",
|
|
990
1200
|
enforcedBy: "server",
|
|
991
1201
|
verifiedBy: "conformance",
|
|
992
|
-
source: "
|
|
1202
|
+
source: "byollm_009 \xA711"
|
|
993
1203
|
}),
|
|
994
1204
|
// ---- The trust surface -------------------------------------------------
|
|
995
1205
|
INGRESS_LOGGED_BEFORE_EXECUTION: must({
|
|
@@ -1041,26 +1251,117 @@ var MUSTS = Object.freeze({
|
|
|
1041
1251
|
enforcedBy: "daemon",
|
|
1042
1252
|
verifiedBy: "adversarial",
|
|
1043
1253
|
source: "byollm_004 \xA74"
|
|
1254
|
+
}),
|
|
1255
|
+
REVOCATION_IMMEDIATE: must({
|
|
1256
|
+
id: "REVOCATION_IMMEDIATE",
|
|
1257
|
+
statement: "Revocation MUST take effect at the upstream at once \u2014 a revoked runner MUST NOT be granted further work from the moment the record changes \u2014 and MUST reach the daemon by its next heartbeat.",
|
|
1258
|
+
// Both, and stated as one sentence with two obligations rather than
|
|
1259
|
+
// folded into REVOCATION_HONORED. That one binds the *daemon*: a revoked
|
|
1260
|
+
// daemon stops claiming and abandons in-flight work. This binds the
|
|
1261
|
+
// *upstream*. byollm_009 §5 is explicit that the pair is the point — "a
|
|
1262
|
+
// revocation enforced at one end survives a compromise of that end" — and
|
|
1263
|
+
// one entry covering both would make a compromised daemon look compliant.
|
|
1264
|
+
enforcedBy: "both",
|
|
1265
|
+
verifiedBy: "conformance",
|
|
1266
|
+
source: "byollm_009 \xA711"
|
|
1267
|
+
}),
|
|
1268
|
+
CONSENT_BEFORE_ROUTE: must({
|
|
1269
|
+
id: "CONSENT_BEFORE_ROUTE",
|
|
1270
|
+
statement: "An upstream MUST NOT route a job to a device without a record binding that user, that site and that scope. There MUST be no discovery path by which a device receives work it was never granted.",
|
|
1271
|
+
enforcedBy: "server",
|
|
1272
|
+
verifiedBy: "conformance",
|
|
1273
|
+
source: "byollm_009 \xA711"
|
|
1274
|
+
}),
|
|
1275
|
+
ROSTER_NOT_DISCLOSED: must({
|
|
1276
|
+
id: "ROSTER_NOT_DISCLOSED",
|
|
1277
|
+
statement: "A site MUST NOT learn the membership of a group whose compute it uses, and MUST NOT publish membership to a routing party. No wire message may carry a list of who may run a job.",
|
|
1278
|
+
// Checkable since cloud_008 §0.2 took `audienceAllow` off the stub: the
|
|
1279
|
+
// property now holds by *absence*, and absence is exactly what a strict
|
|
1280
|
+
// schema and a serialised stub can be asked about. Before that it was a
|
|
1281
|
+
// sentence — and one this project cited in code comments, tests and two
|
|
1282
|
+
// specs as though it were enforced data, which is why it is worth
|
|
1283
|
+
// stating precisely rather than generously.
|
|
1284
|
+
enforcedBy: "both",
|
|
1285
|
+
verifiedBy: "conformance",
|
|
1286
|
+
source: "byollm_009 \xA711"
|
|
1287
|
+
}),
|
|
1288
|
+
EFFECTIVE_OFFER_ONLY: must({
|
|
1289
|
+
id: "EFFECTIVE_OFFER_ONLY",
|
|
1290
|
+
statement: "A daemon MUST declare effective offers only. An upstream MUST NOT receive raw config, allowlists, or capacity the owner has not shared, and MUST act on the declared offer rather than on what was asked for.",
|
|
1291
|
+
enforcedBy: "both",
|
|
1292
|
+
verifiedBy: "conformance",
|
|
1293
|
+
source: "byollm_009 \xA711"
|
|
1294
|
+
}),
|
|
1295
|
+
FALLBACK_LABELED: must({
|
|
1296
|
+
id: "FALLBACK_LABELED",
|
|
1297
|
+
statement: "Work served by anything other than the user's own compute MUST be labelled as such wherever it is reported, and MUST NOT be silently substituted.",
|
|
1298
|
+
// `construction` today, and deliberately not `conformance`. Nothing on
|
|
1299
|
+
// the wire yet distinguishes a fallback from any other community job —
|
|
1300
|
+
// the ledger that would give it a surface is unbuilt — so a check would
|
|
1301
|
+
// have to assert something it cannot observe. Promoted the day that
|
|
1302
|
+
// surface exists. Marking it `conformance` now would put "verified"
|
|
1303
|
+
// beside a property no third party can see, which is the one thing the
|
|
1304
|
+
// kinds exist to prevent.
|
|
1305
|
+
enforcedBy: "both",
|
|
1306
|
+
verifiedBy: "construction",
|
|
1307
|
+
source: "byollm_009 \xA711"
|
|
1308
|
+
}),
|
|
1309
|
+
RELAY_BLIND: must({
|
|
1310
|
+
id: "RELAY_BLIND",
|
|
1311
|
+
statement: "A relay MUST NOT hold any key capable of decrypting a payload, a result, or a delta frame.",
|
|
1312
|
+
// Operator: a third party can read the relay's types and see there is
|
|
1313
|
+
// nowhere to put such a key, but the kit certifies a *server* and cannot
|
|
1314
|
+
// reach inside somebody's deployment to prove what it holds.
|
|
1315
|
+
enforcedBy: "server",
|
|
1316
|
+
verifiedBy: "operator",
|
|
1317
|
+
source: "byollm_009 \xA711"
|
|
1318
|
+
}),
|
|
1319
|
+
SHARED_COMPUTE_DISCLOSED: must({
|
|
1320
|
+
id: "SHARED_COMPUTE_DISCLOSED",
|
|
1321
|
+
statement: "Before a user's work first runs on compute they do not own, they MUST be told in plain language that the machine's owner can see it.",
|
|
1322
|
+
// Operator, and cloud_008 §0.3 is why the classification now comes with a
|
|
1323
|
+
// standing answer rather than a standing question. The screen is not
|
|
1324
|
+
// wire-observable, but the *string the server composes* is, and it is
|
|
1325
|
+
// now unit-tested with the two false sentences forbidden by name. The
|
|
1326
|
+
// kind stays `operator` because a third-party site can still render
|
|
1327
|
+
// whatever it likes; what changed is that the part inside our own
|
|
1328
|
+
// boundary stopped depending on somebody remembering to audit it.
|
|
1329
|
+
enforcedBy: "server",
|
|
1330
|
+
verifiedBy: "operator",
|
|
1331
|
+
source: "byollm_009 \xA711"
|
|
1044
1332
|
})
|
|
1045
1333
|
});
|
|
1334
|
+
var RETIRED_MUSTS = Object.freeze({
|
|
1335
|
+
RESULT_PROVENANCE: {
|
|
1336
|
+
supersededBy: "PROVENANCE_NAMES_DEVICE",
|
|
1337
|
+
note: "Strengthened, not renamed: attribution is now by proof of possession \u2014 the result's signature must verify against the device the upstream granted the lease to \u2014 rather than by a provenance label travelling beside it. byollm_009 \xA711 states the stronger form."
|
|
1338
|
+
}
|
|
1339
|
+
});
|
|
1046
1340
|
var MUST_IDS = Object.freeze(Object.keys(MUSTS));
|
|
1047
1341
|
function mustsVerifiedBy(kind) {
|
|
1048
|
-
return MUST_IDS.filter((id) => MUSTS[id].
|
|
1342
|
+
return MUST_IDS.filter((id) => kindsOf(MUSTS[id]).includes(kind));
|
|
1049
1343
|
}
|
|
1050
1344
|
|
|
1051
1345
|
// src/wire.ts
|
|
1052
|
-
import { z as
|
|
1346
|
+
import { z as z9 } from "zod";
|
|
1053
1347
|
var PROTOCOL_VERSION = "0";
|
|
1054
1348
|
var SUPPORTED_PROTOCOL_VERSIONS = Object.freeze([
|
|
1055
1349
|
PROTOCOL_VERSION
|
|
1056
1350
|
]);
|
|
1057
1351
|
var MIN_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0] ?? PROTOCOL_VERSION;
|
|
1352
|
+
function declaredVersion(input) {
|
|
1353
|
+
const { body, query } = input;
|
|
1354
|
+
if (typeof body === "object" && body !== null && Object.hasOwn(body, "protocolVersion")) {
|
|
1355
|
+
return body.protocolVersion;
|
|
1356
|
+
}
|
|
1357
|
+
return query?.get("protocolVersion") ?? void 0;
|
|
1358
|
+
}
|
|
1058
1359
|
function checkProtocolVersion(body) {
|
|
1059
1360
|
const declared = typeof body === "object" && body !== null && Object.hasOwn(body, "protocolVersion") ? body.protocolVersion : void 0;
|
|
1060
1361
|
if (typeof declared !== "string" || declared.length === 0) {
|
|
1061
1362
|
return {
|
|
1062
1363
|
error: "unsupported-protocol-version",
|
|
1063
|
-
message:
|
|
1364
|
+
message: `this request declared no protocol version. Upgrade the daemon: \`${UPGRADE_COMMAND}\`.`,
|
|
1064
1365
|
supported: SUPPORTED_PROTOCOL_VERSIONS,
|
|
1065
1366
|
minimum: MIN_PROTOCOL_VERSION
|
|
1066
1367
|
};
|
|
@@ -1068,13 +1369,14 @@ function checkProtocolVersion(body) {
|
|
|
1068
1369
|
if (!SUPPORTED_PROTOCOL_VERSIONS.includes(declared)) {
|
|
1069
1370
|
return {
|
|
1070
1371
|
error: "unsupported-protocol-version",
|
|
1071
|
-
message: `this server speaks protocol ${SUPPORTED_PROTOCOL_VERSIONS.join(", ")} and the daemon asked for ${declared}. ` + (declared < MIN_PROTOCOL_VERSION ?
|
|
1372
|
+
message: `this server speaks protocol ${SUPPORTED_PROTOCOL_VERSIONS.join(", ")} and the daemon asked for ${declared}. ` + (declared < MIN_PROTOCOL_VERSION ? `Upgrade the daemon: \`${UPGRADE_COMMAND}\`.` : "This daemon is newer than the server; the server needs upgrading."),
|
|
1072
1373
|
supported: SUPPORTED_PROTOCOL_VERSIONS,
|
|
1073
1374
|
minimum: MIN_PROTOCOL_VERSION
|
|
1074
1375
|
};
|
|
1075
1376
|
}
|
|
1076
1377
|
return null;
|
|
1077
1378
|
}
|
|
1379
|
+
var UPGRADE_COMMAND = "npm i -g byollm@latest";
|
|
1078
1380
|
var PROTOCOL_PREFIX = "/byollm";
|
|
1079
1381
|
var ENDPOINTS = Object.freeze([
|
|
1080
1382
|
"pair",
|
|
@@ -1084,22 +1386,22 @@ var ENDPOINTS = Object.freeze([
|
|
|
1084
1386
|
"result",
|
|
1085
1387
|
"release"
|
|
1086
1388
|
]);
|
|
1087
|
-
var Capability =
|
|
1389
|
+
var Capability = z9.object({
|
|
1088
1390
|
kind: JobKind,
|
|
1089
1391
|
backendId: BackendIdSchema,
|
|
1090
1392
|
backendClass: BackendClass,
|
|
1091
|
-
model:
|
|
1393
|
+
model: z9.string().min(1),
|
|
1092
1394
|
offerScope: OfferScope
|
|
1093
1395
|
}).strict();
|
|
1094
|
-
var CapabilityMatrix =
|
|
1095
|
-
var PairStartRequest =
|
|
1096
|
-
protocolVersion:
|
|
1097
|
-
action:
|
|
1098
|
-
daemon:
|
|
1099
|
-
version:
|
|
1396
|
+
var CapabilityMatrix = z9.array(Capability);
|
|
1397
|
+
var PairStartRequest = z9.object({
|
|
1398
|
+
protocolVersion: z9.literal(PROTOCOL_VERSION),
|
|
1399
|
+
action: z9.literal("start"),
|
|
1400
|
+
daemon: z9.object({
|
|
1401
|
+
version: z9.string().min(1),
|
|
1100
1402
|
/** Shown in the app's runner list so a user can tell their machines apart. */
|
|
1101
|
-
label:
|
|
1102
|
-
platform:
|
|
1403
|
+
label: z9.string().min(1).max(120),
|
|
1404
|
+
platform: z9.enum(["darwin", "linux", "win32"])
|
|
1103
1405
|
}),
|
|
1104
1406
|
/**
|
|
1105
1407
|
* This machine's public keys (byollm_009 §5).
|
|
@@ -1111,70 +1413,96 @@ var PairStartRequest = z8.object({
|
|
|
1111
1413
|
device: PublicIdentity,
|
|
1112
1414
|
capabilities: CapabilityMatrix
|
|
1113
1415
|
}).strict();
|
|
1114
|
-
var PairStartResponse =
|
|
1416
|
+
var PairStartResponse = z9.object({
|
|
1115
1417
|
/** Secret the daemon polls with. Never shown to the user. */
|
|
1116
|
-
deviceCode:
|
|
1418
|
+
deviceCode: z9.string().min(20),
|
|
1117
1419
|
/** Short code the user reads and confirms in the browser. */
|
|
1118
|
-
userCode:
|
|
1420
|
+
userCode: z9.string().min(4).max(16),
|
|
1119
1421
|
/** Where the user approves. Must be on the server's own origin. */
|
|
1120
|
-
verificationUrl:
|
|
1422
|
+
verificationUrl: z9.url(),
|
|
1121
1423
|
/** Epoch ms after which the code is dead ({@link MUSTS.PAIR_CODE_EXPIRES}). */
|
|
1122
|
-
expiresAt:
|
|
1424
|
+
expiresAt: z9.number().int().positive(),
|
|
1123
1425
|
/** How often the daemon may poll. */
|
|
1124
|
-
pollIntervalMs:
|
|
1426
|
+
pollIntervalMs: z9.number().int().min(500).max(6e4)
|
|
1125
1427
|
}).strict();
|
|
1126
|
-
var PairPollRequest =
|
|
1127
|
-
protocolVersion:
|
|
1128
|
-
action:
|
|
1129
|
-
deviceCode:
|
|
1428
|
+
var PairPollRequest = z9.object({
|
|
1429
|
+
protocolVersion: z9.literal(PROTOCOL_VERSION),
|
|
1430
|
+
action: z9.literal("poll"),
|
|
1431
|
+
deviceCode: z9.string().min(20)
|
|
1130
1432
|
}).strict();
|
|
1131
|
-
var PairPollResponse =
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
status:
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1433
|
+
var PairPollResponse = z9.discriminatedUnion("status", [
|
|
1434
|
+
z9.object({ status: z9.literal("pending") }).strict(),
|
|
1435
|
+
z9.object({ status: z9.literal("denied") }).strict(),
|
|
1436
|
+
z9.object({ status: z9.literal("expired") }).strict(),
|
|
1437
|
+
z9.object({
|
|
1438
|
+
status: z9.literal("approved"),
|
|
1439
|
+
// `runnerToken` is gone — cloud_008 §2.4, finding 37.
|
|
1440
|
+
//
|
|
1441
|
+
// It was minted here, hashed into `RunnerRecord.tokenHash`, written to
|
|
1442
|
+
// the daemon's pairings file, and then **never sent, never looked up
|
|
1443
|
+
// and never compared**. `getRunnerByTokenHash` existed on both stores
|
|
1444
|
+
// and was called by nothing but a test asserting it returns null.
|
|
1445
|
+
//
|
|
1446
|
+
// Not merely dead wire, which is what `audienceAllow` and
|
|
1447
|
+
// `HeartbeatResponse.leases` were. This was a *secret*: minted,
|
|
1448
|
+
// transmitted, and written to two disks at rest, for nothing. A
|
|
1449
|
+
// credential with no purpose is a liability rather than clutter,
|
|
1450
|
+
// because the only thing it can ever do is leak.
|
|
1451
|
+
//
|
|
1452
|
+
// `REQUESTS_SIGNED_NOT_BEARER` was already the rule and was already
|
|
1453
|
+
// enforced — every authenticated call is signed by the device's pinned
|
|
1454
|
+
// identity key. This removes the thing the MUST is named after.
|
|
1455
|
+
runnerId: z9.string().min(1),
|
|
1140
1456
|
/** The app's id for the approving user — this daemon's owner forever. */
|
|
1141
|
-
owner:
|
|
1457
|
+
owner: z9.string().min(1),
|
|
1142
1458
|
/** Display name for the trust UI, if the app offers one. */
|
|
1143
|
-
ownerLabel:
|
|
1459
|
+
ownerLabel: z9.string().optional(),
|
|
1144
1460
|
/**
|
|
1145
|
-
* The
|
|
1461
|
+
* The sites this pairing covers, for the daemon to pin (byollm_009 §5),
|
|
1462
|
+
* keyed by each site's identity key id — cloud_009 §5.
|
|
1146
1463
|
*
|
|
1147
|
-
* Returned only on approval
|
|
1464
|
+
* Returned only on approval: a pending or denied poll learns nothing,
|
|
1148
1465
|
* so an unapproved code cannot be used to enumerate a site's keys.
|
|
1466
|
+
*
|
|
1467
|
+
* **One pairing per upstream, not one per site.** A user who connects a
|
|
1468
|
+
* site on a web dashboard has no reason to go back to a laptop and run
|
|
1469
|
+
* a command, so which sites a pairing covers is a projection of consent
|
|
1470
|
+
* — refreshed on the heartbeat — rather than something frozen at
|
|
1471
|
+
* pairing. A direct site answers with exactly one entry, which is the
|
|
1472
|
+
* same shape and not a special case.
|
|
1473
|
+
*
|
|
1474
|
+
* Keyed by the id `stub.site` carries (Amendment A §A.3), so the
|
|
1475
|
+
* runner's lookup is a map read rather than a join across two
|
|
1476
|
+
* namespaces.
|
|
1149
1477
|
*/
|
|
1150
|
-
|
|
1478
|
+
sites: z9.record(z9.string().min(1), PublicIdentity)
|
|
1151
1479
|
}).strict()
|
|
1152
1480
|
]);
|
|
1153
|
-
var PairRequest =
|
|
1481
|
+
var PairRequest = z9.discriminatedUnion("action", [
|
|
1154
1482
|
PairStartRequest,
|
|
1155
1483
|
PairPollRequest
|
|
1156
1484
|
]);
|
|
1157
|
-
var ClaimRequest =
|
|
1158
|
-
protocolVersion:
|
|
1159
|
-
runnerId:
|
|
1485
|
+
var ClaimRequest = z9.object({
|
|
1486
|
+
protocolVersion: z9.literal(PROTOCOL_VERSION),
|
|
1487
|
+
runnerId: z9.string().min(1),
|
|
1160
1488
|
/** Re-sent on every claim so a server never matches against a stale matrix. */
|
|
1161
1489
|
capabilities: CapabilityMatrix,
|
|
1162
1490
|
/** Upper bound on jobs to return; the server may return fewer. */
|
|
1163
|
-
max:
|
|
1491
|
+
max: z9.number().int().min(1).max(64)
|
|
1164
1492
|
}).strict();
|
|
1165
|
-
var ClaimResponse =
|
|
1493
|
+
var ClaimResponse = z9.object({
|
|
1166
1494
|
/**
|
|
1167
1495
|
* Stubs, not jobs. The payload arrives from `fetch`, sealed to whichever
|
|
1168
1496
|
* device claimed — see {@link JobStub} for the exhaustive metadata list.
|
|
1169
1497
|
*/
|
|
1170
|
-
jobs:
|
|
1498
|
+
jobs: z9.array(ClaimedStub),
|
|
1171
1499
|
/** Lease duration granted, so the daemon knows its renewal deadline. */
|
|
1172
|
-
leaseMs:
|
|
1500
|
+
leaseMs: z9.number().int().positive()
|
|
1173
1501
|
}).strict();
|
|
1174
|
-
var HeartbeatRequest =
|
|
1175
|
-
protocolVersion:
|
|
1176
|
-
runnerId:
|
|
1177
|
-
daemonVersion:
|
|
1502
|
+
var HeartbeatRequest = z9.object({
|
|
1503
|
+
protocolVersion: z9.literal(PROTOCOL_VERSION),
|
|
1504
|
+
runnerId: z9.string().min(1),
|
|
1505
|
+
daemonVersion: z9.string().min(1),
|
|
1178
1506
|
capabilities: CapabilityMatrix,
|
|
1179
1507
|
/**
|
|
1180
1508
|
* Leases this daemon believes it holds; the server renews exactly these.
|
|
@@ -1182,40 +1510,154 @@ var HeartbeatRequest = z8.object({
|
|
|
1182
1510
|
* Lease ids rather than job ids, so a replayed heartbeat cannot renew a
|
|
1183
1511
|
* grant the runner no longer holds — see {@link Lease.id}.
|
|
1184
1512
|
*/
|
|
1185
|
-
activeLeases:
|
|
1186
|
-
|
|
1513
|
+
activeLeases: z9.array(
|
|
1514
|
+
z9.object({ jobId: z9.string().min(1), leaseId: z9.string().min(1) })
|
|
1187
1515
|
),
|
|
1188
1516
|
/** True while the owner has the daemon paused; the server stops offering work. */
|
|
1189
|
-
paused:
|
|
1517
|
+
paused: z9.boolean()
|
|
1190
1518
|
}).strict();
|
|
1191
|
-
var HeartbeatResponse =
|
|
1192
|
-
/**
|
|
1193
|
-
|
|
1519
|
+
var HeartbeatResponse = z9.object({
|
|
1520
|
+
/**
|
|
1521
|
+
* The sites this daemon may serve, right now — cloud_008 finding 59.
|
|
1522
|
+
*
|
|
1523
|
+
* Revocation used to be a boolean, and it was device-wide: the daemon
|
|
1524
|
+
* plane refused every call when the (owner, hub-site) consent was gone,
|
|
1525
|
+
* heartbeat answered `revoked: true` with `lost: all`, and the daemon
|
|
1526
|
+
* dropped its whole pairing by origin. Under a hub that is one site's
|
|
1527
|
+
* revocation ending a machine's relationship with every other site it
|
|
1528
|
+
* served — the amplification finding 48 warned about, arriving through
|
|
1529
|
+
* the one field nobody thought of as tenancy.
|
|
1530
|
+
*
|
|
1531
|
+
* So the answer is the set. A site that leaves it is revoked *for that
|
|
1532
|
+
* site*: the daemon drops that pin and keeps the rest. An empty set is
|
|
1533
|
+
* what "revoked" used to mean, and the daemon can see that for itself
|
|
1534
|
+
* rather than being told a second time — two fields for one fact is how
|
|
1535
|
+
* they drift.
|
|
1536
|
+
*/
|
|
1537
|
+
sites: z9.record(z9.string().min(1), PublicIdentity),
|
|
1538
|
+
/**
|
|
1539
|
+
* How a site's current key traces back to one this daemon already holds —
|
|
1540
|
+
* byollm_009 Amendment C.
|
|
1541
|
+
*
|
|
1542
|
+
* Keyed by the same id as `sites`, and **additive on purpose**: `sites`
|
|
1543
|
+
* remains the one statement of which key is current, and this says only
|
|
1544
|
+
* how that key got there. Two fields for one fact is how they drift; this
|
|
1545
|
+
* is two facts, and the second is evidence about the first.
|
|
1546
|
+
*
|
|
1547
|
+
* Optional because a site that has never rotated has no chain, which is
|
|
1548
|
+
* every site today. A daemon that receives one for an id it already holds
|
|
1549
|
+
* ignores it: the pin it has is the pin it approved.
|
|
1550
|
+
*
|
|
1551
|
+
* §12 carries what this adds to the metadata surface — a site's rotation
|
|
1552
|
+
* history is public by construction, because a daemon that cannot read it
|
|
1553
|
+
* cannot verify it.
|
|
1554
|
+
*/
|
|
1555
|
+
successions: z9.record(
|
|
1556
|
+
z9.string().min(1),
|
|
1557
|
+
z9.object({
|
|
1558
|
+
/** Oldest last, as the projection carries it. */
|
|
1559
|
+
succeeds: z9.array(Succession).max(MAX_SUCCESSION_CHAIN),
|
|
1560
|
+
/**
|
|
1561
|
+
* Until when the superseded key may still sign work — epoch ms.
|
|
1562
|
+
*
|
|
1563
|
+
* The daemon holds its own clock against this, for the reason it
|
|
1564
|
+
* holds its own allowlist: a projection that could extend the
|
|
1565
|
+
* window indefinitely would be a two-key site forever, decided by
|
|
1566
|
+
* the party this design does not trust.
|
|
1567
|
+
*/
|
|
1568
|
+
retiringUntil: z9.number().int().positive().optional()
|
|
1569
|
+
}).strict()
|
|
1570
|
+
).optional(),
|
|
1194
1571
|
/**
|
|
1195
1572
|
* Per-job cancel (byollm_001 Rev 1 §C). The daemon aborts these jobs'
|
|
1196
1573
|
* in-flight backend calls and reports them `canceled`.
|
|
1574
|
+
*
|
|
1575
|
+
* **The grant, not the id** — V1-3. Job ids are chosen per site, so two
|
|
1576
|
+
* sites may pick the same one, and a bare id told a daemon holding both
|
|
1577
|
+
* to abort whichever it happened to have filed under that name. The lease
|
|
1578
|
+
* is the unique grant and the daemon already keys its work by it; this is
|
|
1579
|
+
* the same shape `activeLeases` sends in the other direction.
|
|
1197
1580
|
*/
|
|
1198
|
-
cancel:
|
|
1199
|
-
|
|
1200
|
-
leases: z8.array(
|
|
1201
|
-
z8.object({
|
|
1202
|
-
jobId: z8.string().min(1),
|
|
1203
|
-
expiresAt: z8.number().int().positive()
|
|
1204
|
-
}).strict()
|
|
1581
|
+
cancel: z9.array(
|
|
1582
|
+
z9.object({ jobId: z9.string().min(1), leaseId: z9.string().min(1) }).strict()
|
|
1205
1583
|
),
|
|
1584
|
+
// `leases` is deliberately absent — cloud_008 §1.4b, finding 16.
|
|
1585
|
+
//
|
|
1586
|
+
// It carried "these leases were renewed, and here is the new expiry", and
|
|
1587
|
+
// **no daemon ever read it.** A mutation returning an empty list while
|
|
1588
|
+
// renewing correctly survived every test, which is what made it visible.
|
|
1589
|
+
//
|
|
1590
|
+
// It is neither a class nor membership, so Amendment A's rule does not
|
|
1591
|
+
// decide it — the older test does: nothing reads it, so it is dead wire.
|
|
1592
|
+
// §6's exhaustiveness is a commitment about what an upstream can see, and
|
|
1593
|
+
// it applies to every message rather than only to the stub.
|
|
1594
|
+
//
|
|
1595
|
+
// `lost` is the actionable signal and always was: a daemon stops work on
|
|
1596
|
+
// a lease it no longer holds. "Renewed" was the same question answered a
|
|
1597
|
+
// second time, and a second answer can only agree or contradict.
|
|
1598
|
+
//
|
|
1599
|
+
// Renewal itself is untouched — the upstream still extends the grants a
|
|
1600
|
+
// heartbeat names, which is what §0.6 fixed. What ended is telling the
|
|
1601
|
+
// daemon about it in a field it ignored. If an upstream ever needs to
|
|
1602
|
+
// push lease decisions, that is a new field with a reader, added on
|
|
1603
|
+
// purpose.
|
|
1206
1604
|
/**
|
|
1207
1605
|
* Jobs the daemon thinks it holds but the server has reassigned or
|
|
1208
1606
|
* expired. The daemon must stop work on these and not report results.
|
|
1607
|
+
*
|
|
1608
|
+
* Named by grant rather than by id, for V1-3's reason: a bare id is
|
|
1609
|
+
* ambiguous across sites, and "the lease you no longer hold" is exactly
|
|
1610
|
+
* what this field means anyway.
|
|
1209
1611
|
*/
|
|
1210
|
-
lost:
|
|
1612
|
+
lost: z9.array(
|
|
1613
|
+
z9.object({ jobId: z9.string().min(1), leaseId: z9.string().min(1) }).strict()
|
|
1614
|
+
),
|
|
1211
1615
|
/** Server clock, so a daemon with a skewed clock still honors leases. */
|
|
1212
|
-
serverTime:
|
|
1616
|
+
serverTime: z9.number().int().positive(),
|
|
1617
|
+
/**
|
|
1618
|
+
* Sites whose disclosure the user must read again before work moves —
|
|
1619
|
+
* cloud_008 finding 48, named rather than counted.
|
|
1620
|
+
*
|
|
1621
|
+
* A **subset of `sites`**, deliberately: a paused site keeps its pin, so
|
|
1622
|
+
* re-consenting never costs a re-pair. The daemon can say which site is
|
|
1623
|
+
* waiting and the user can go and read it, which is the difference
|
|
1624
|
+
* between a machine that is quietly idle and one that says why.
|
|
1625
|
+
*
|
|
1626
|
+
* Not `revoked`, which is a human ending a relationship, and not
|
|
1627
|
+
* `paused`, which on the request side already means "this daemon's
|
|
1628
|
+
* operator stopped it" — one word with two subjects on two halves of one
|
|
1629
|
+
* exchange is a confusion nobody untangles from a log.
|
|
1630
|
+
*/
|
|
1631
|
+
awaitingConsent: z9.array(z9.string().min(1))
|
|
1213
1632
|
}).strict();
|
|
1214
|
-
var ResultDisposition =
|
|
1215
|
-
var ResultRequest =
|
|
1216
|
-
protocolVersion:
|
|
1217
|
-
runnerId:
|
|
1218
|
-
jobId:
|
|
1633
|
+
var ResultDisposition = z9.enum(["ok", "error", "canceled"]);
|
|
1634
|
+
var ResultRequest = z9.object({
|
|
1635
|
+
protocolVersion: z9.literal(PROTOCOL_VERSION),
|
|
1636
|
+
runnerId: z9.string().min(1),
|
|
1637
|
+
jobId: z9.string().min(1),
|
|
1638
|
+
/**
|
|
1639
|
+
* The grant this result was produced under — cloud_008 §1.4a.
|
|
1640
|
+
*
|
|
1641
|
+
* `fetch` has always named its lease, with the reasoning written beside
|
|
1642
|
+
* it: a request that names only the job would be answerable for whatever
|
|
1643
|
+
* lease exists when it arrives. **The operation that writes the result did
|
|
1644
|
+
* not**, on either plane, and checked only the runner id — which survives
|
|
1645
|
+
* a claim-release-reclaim cycle, so a device whose grant had been swept
|
|
1646
|
+
* and reissued could still land a result for a job it no longer held.
|
|
1647
|
+
*
|
|
1648
|
+
* Found by tracing a mutation that survived in §0.6: the lease lapsed, the
|
|
1649
|
+
* sweep requeued, the daemon re-claimed under a new grant, and the
|
|
1650
|
+
* original run finished and posted anyway. The relay marked the job done
|
|
1651
|
+
* with a result the site cannot open — it verifies the envelope against
|
|
1652
|
+
* the *current* holder's device, so the crypto contains the substitution —
|
|
1653
|
+
* and then refused the real holder's result as a replay. A lost job, in
|
|
1654
|
+
* silence.
|
|
1655
|
+
*
|
|
1656
|
+
* `LEASE_HONORED` is a statement about a lease *instance*. That was
|
|
1657
|
+
* learned once already, when a replayed release yanked a later grant, and
|
|
1658
|
+
* it applies here for the same reason.
|
|
1659
|
+
*/
|
|
1660
|
+
leaseId: z9.string().min(1),
|
|
1219
1661
|
/**
|
|
1220
1662
|
* The outcome, sealed to the site and signed by the device.
|
|
1221
1663
|
*
|
|
@@ -1231,26 +1673,48 @@ var ResultRequest = z8.object({
|
|
|
1231
1673
|
* fact: believing it unverified would let a daemon mark a job `ok` while
|
|
1232
1674
|
* sealing an error, and only the app would ever find out.
|
|
1233
1675
|
*/
|
|
1234
|
-
disposition: ResultDisposition
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1676
|
+
disposition: ResultDisposition
|
|
1677
|
+
// `model`, `backendClass` and `durationMs` are **inside the envelope** —
|
|
1678
|
+
// cloud_008 §2.5. See {@link RunMetadata}.
|
|
1679
|
+
//
|
|
1680
|
+
// They were here, in the clear, and that was two problems wearing one
|
|
1681
|
+
// coat. On the direct plane the site recorded unauthenticated fields
|
|
1682
|
+
// beside an authenticated answer: a daemon could seal one result and
|
|
1683
|
+
// declare a different model, and only the unsigned half would reach the
|
|
1684
|
+
// app. Through a relay they reached a third party that acts on none of
|
|
1685
|
+
// them — `model` in particular being the sort of detail Amendment A's
|
|
1686
|
+
// rule keeps off the wire.
|
|
1687
|
+
//
|
|
1688
|
+
// `disposition` stays, and the difference is the test: a relay *routes*
|
|
1689
|
+
// on it, so it is a class a routing party consumes. Nobody between the
|
|
1690
|
+
// two ends consumes these.
|
|
1240
1691
|
}).strict();
|
|
1241
|
-
var ResultResponse =
|
|
1692
|
+
var ResultResponse = z9.object({
|
|
1242
1693
|
/**
|
|
1243
|
-
* False when
|
|
1244
|
-
*
|
|
1245
|
-
|
|
1694
|
+
* False when this submission wrote nothing — the daemon should discard,
|
|
1695
|
+
* not retry ({@link MUSTS.RESULT_IDEMPOTENT}).
|
|
1696
|
+
*/
|
|
1697
|
+
accepted: z9.boolean(),
|
|
1698
|
+
/**
|
|
1699
|
+
* True when this device had already recorded this job's result.
|
|
1700
|
+
*
|
|
1701
|
+
* The difference between "already recorded" and "you no longer hold this"
|
|
1702
|
+
* — cloud_008 §3.6. A daemon whose acknowledgment was lost is in the first
|
|
1703
|
+
* case and needs to hear it: its answer is safely on disk. Reporting a
|
|
1704
|
+
* stale lease instead invents a worry about a result that is already
|
|
1705
|
+
* stored, and sends its owner looking for a routing problem.
|
|
1706
|
+
*
|
|
1707
|
+
* Set only for the device that finished the job. A different device gets
|
|
1708
|
+
* the same refusal it would get for a job that is *not* terminal, so a job
|
|
1709
|
+
* id cannot be used as a terminality probe.
|
|
1246
1710
|
*/
|
|
1247
|
-
|
|
1711
|
+
duplicate: z9.boolean().optional(),
|
|
1248
1712
|
/** The job's state after this submission. */
|
|
1249
|
-
state:
|
|
1713
|
+
state: z9.string().min(1)
|
|
1250
1714
|
}).strict();
|
|
1251
|
-
var ReleaseRequest =
|
|
1252
|
-
protocolVersion:
|
|
1253
|
-
runnerId:
|
|
1715
|
+
var ReleaseRequest = z9.object({
|
|
1716
|
+
protocolVersion: z9.literal(PROTOCOL_VERSION),
|
|
1717
|
+
runnerId: z9.string().min(1),
|
|
1254
1718
|
/**
|
|
1255
1719
|
* Which leases to release — the grant, not just the job.
|
|
1256
1720
|
*
|
|
@@ -1258,8 +1722,8 @@ var ReleaseRequest = z8.object({
|
|
|
1258
1722
|
* moment it arrives, which for a replayed request is not the lease the
|
|
1259
1723
|
* daemon meant. See {@link Lease.id}.
|
|
1260
1724
|
*/
|
|
1261
|
-
leases:
|
|
1262
|
-
|
|
1725
|
+
leases: z9.array(
|
|
1726
|
+
z9.object({ jobId: z9.string().min(1), leaseId: z9.string().min(1) })
|
|
1263
1727
|
),
|
|
1264
1728
|
/**
|
|
1265
1729
|
* Why, so the app's runner list can say something true.
|
|
@@ -1270,39 +1734,152 @@ var ReleaseRequest = z8.object({
|
|
|
1270
1734
|
* stop offering that job to that runner, or the pair would spin between
|
|
1271
1735
|
* claim and release forever.
|
|
1272
1736
|
*/
|
|
1273
|
-
reason:
|
|
1737
|
+
reason: z9.enum(["shutdown", "pause", "revoked", "backend-down", "refused"])
|
|
1274
1738
|
}).strict();
|
|
1275
|
-
var ReleaseResponse =
|
|
1276
|
-
released:
|
|
1739
|
+
var ReleaseResponse = z9.object({
|
|
1740
|
+
released: z9.array(z9.string().min(1))
|
|
1277
1741
|
}).strict();
|
|
1278
|
-
var WireErrorCode =
|
|
1742
|
+
var WireErrorCode = z9.enum([
|
|
1279
1743
|
"bad-request",
|
|
1280
1744
|
"unsupported-protocol-version",
|
|
1745
|
+
// "We do not know who you are." Exactly 401, and only that — cloud_008
|
|
1746
|
+
// §1.4d.
|
|
1281
1747
|
"unauthorized",
|
|
1748
|
+
/**
|
|
1749
|
+
* "We know exactly who you are, and the answer is no." Exactly 403.
|
|
1750
|
+
*
|
|
1751
|
+
* Five refusals across both planes served 403 with `unauthorized`, whose
|
|
1752
|
+
* table entry is 401: a revoked device, a site claiming another site's
|
|
1753
|
+
* stub, a job you do not hold, a device belonging to another owner, a
|
|
1754
|
+
* relay that does not route for you. Every one of them is an *identified*
|
|
1755
|
+
* caller being refused.
|
|
1756
|
+
*
|
|
1757
|
+
* Collapsing the two loses a distinction that matters everywhere it is
|
|
1758
|
+
* read: a revoked daemon would look like an unsigned one in every log and
|
|
1759
|
+
* every client branch, and "check your keys" is the wrong advice for both
|
|
1760
|
+
* of them in opposite directions.
|
|
1761
|
+
*/
|
|
1762
|
+
"forbidden",
|
|
1282
1763
|
"revoked",
|
|
1283
1764
|
"not-found",
|
|
1765
|
+
// Claimed, but the site has not sealed the payload yet — cloud_008 §1.4.
|
|
1766
|
+
//
|
|
1767
|
+
// A daemon must retry rather than abandon: the job is legitimately still
|
|
1768
|
+
// its own until the lease or the awaiting-payload clock says otherwise.
|
|
1769
|
+
// That is why it cannot be `not-found` or `server-error`, and why it was
|
|
1770
|
+
// the protocol gap that produced a bare 409 in the first place.
|
|
1771
|
+
"not-ready",
|
|
1772
|
+
/**
|
|
1773
|
+
* The job is over, and this call is about a job — V1-6, and the code the
|
|
1774
|
+
* site plane has been serving without one (V1-13).
|
|
1775
|
+
*
|
|
1776
|
+
* Distinct from `not-found`, which says "no such job", and from
|
|
1777
|
+
* `not-ready`, which says "not yet, keep asking". This one says "yes, and
|
|
1778
|
+
* it finished" — so a daemon must stop rather than retry, and a replayed
|
|
1779
|
+
* request must not be able to reopen it.
|
|
1780
|
+
*/
|
|
1781
|
+
"too-late",
|
|
1782
|
+
// The caller's clock is too far from ours to judge a signature's freshness.
|
|
1783
|
+
//
|
|
1784
|
+
// Split out from `unauthorized` because the remedy is completely different
|
|
1785
|
+
// and only the server can tell them apart: a bad signature means the key is
|
|
1786
|
+
// wrong, this means the machine's time is wrong. A daemon reporting it as a
|
|
1787
|
+
// generic rejection sends its owner looking at their network.
|
|
1788
|
+
"clock-skew",
|
|
1284
1789
|
"rate-limited",
|
|
1285
1790
|
"server-error"
|
|
1286
1791
|
]);
|
|
1287
|
-
var WireError =
|
|
1792
|
+
var WireError = z9.object({
|
|
1288
1793
|
error: WireErrorCode,
|
|
1289
|
-
message:
|
|
1794
|
+
message: z9.string().min(1),
|
|
1795
|
+
/**
|
|
1796
|
+
* What this server speaks, on `unsupported-protocol-version` — §B.4.
|
|
1797
|
+
*
|
|
1798
|
+
* The refusal has carried these since the version handshake existed and
|
|
1799
|
+
* the enumeration did not model them, so the one error that exists to be
|
|
1800
|
+
* *acted on* was the one that failed to parse as a wire error. Found by
|
|
1801
|
+
* the relay's own suite the day the relay started sending it: a refusal
|
|
1802
|
+
* outside the enumerated shape is a refusal a client cannot branch on,
|
|
1803
|
+
* which is the whole reason §1.4 enumerates them.
|
|
1804
|
+
*
|
|
1805
|
+
* Modelled the way `clock-skew`'s two fields already are — code-specific
|
|
1806
|
+
* extras, refused on any other code by the refinement below.
|
|
1807
|
+
*/
|
|
1808
|
+
supported: z9.array(z9.string().min(1)).optional(),
|
|
1809
|
+
minimum: z9.string().min(1).optional(),
|
|
1290
1810
|
/** Seconds; mirrors Retry-After for `rate-limited` and `server-error`. */
|
|
1291
|
-
retryAfter:
|
|
1292
|
-
|
|
1811
|
+
retryAfter: z9.number().int().nonnegative().optional(),
|
|
1812
|
+
/**
|
|
1813
|
+
* The server's clock, and the window it allows. `clock-skew` only.
|
|
1814
|
+
*
|
|
1815
|
+
* So the far side can say *how far off* rather than *that something is
|
|
1816
|
+
* wrong* — the difference between "adjust your clock by four minutes" and
|
|
1817
|
+
* "something is wrong with your connection". Not a disclosure: the
|
|
1818
|
+
* heartbeat response returns the same value, and so does every `Date`
|
|
1819
|
+
* header.
|
|
1820
|
+
*/
|
|
1821
|
+
serverTime: z9.number().int().positive().optional(),
|
|
1822
|
+
maxSkewMs: z9.number().int().positive().optional()
|
|
1823
|
+
}).strict().superRefine((error, ctx) => {
|
|
1824
|
+
const skew = error.error === "clock-skew";
|
|
1825
|
+
const carried = error.serverTime !== void 0 || error.maxSkewMs !== void 0;
|
|
1826
|
+
if (skew && !carried) {
|
|
1827
|
+
ctx.addIssue({
|
|
1828
|
+
code: "custom",
|
|
1829
|
+
message: "clock-skew must carry serverTime and maxSkewMs"
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
1832
|
+
if (!skew && carried) {
|
|
1833
|
+
ctx.addIssue({
|
|
1834
|
+
code: "custom",
|
|
1835
|
+
message: `${error.error} must not carry serverTime or maxSkewMs`
|
|
1836
|
+
});
|
|
1837
|
+
}
|
|
1838
|
+
const version = error.error === "unsupported-protocol-version";
|
|
1839
|
+
const versionFields = error.supported !== void 0 || error.minimum !== void 0;
|
|
1840
|
+
if (version && !versionFields) {
|
|
1841
|
+
ctx.addIssue({
|
|
1842
|
+
code: "custom",
|
|
1843
|
+
message: "unsupported-protocol-version must carry supported and minimum"
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
if (!version && versionFields) {
|
|
1847
|
+
ctx.addIssue({
|
|
1848
|
+
code: "custom",
|
|
1849
|
+
message: `${error.error} must not carry supported or minimum`
|
|
1850
|
+
});
|
|
1851
|
+
}
|
|
1852
|
+
});
|
|
1293
1853
|
var ERROR_STATUS = Object.freeze({
|
|
1294
1854
|
"bad-request": 400,
|
|
1295
1855
|
"unsupported-protocol-version": 400,
|
|
1296
1856
|
unauthorized: 401,
|
|
1857
|
+
forbidden: 403,
|
|
1297
1858
|
revoked: 403,
|
|
1298
1859
|
"not-found": 404,
|
|
1860
|
+
// 409, not 404: the job exists and is yours, it is simply not ready.
|
|
1861
|
+
"not-ready": 409,
|
|
1862
|
+
// The same 409 as `not-ready` and the opposite instruction: that one says
|
|
1863
|
+
// keep asking, this one says stop. The status is the class of the
|
|
1864
|
+
// problem — a request that does not fit the resource's state — and the
|
|
1865
|
+
// code is what a caller acts on.
|
|
1866
|
+
"too-late": 409,
|
|
1867
|
+
// 401 alongside `unauthorized`, because that is what it is — the
|
|
1868
|
+
// signature could not be judged. The code is what carries the remedy.
|
|
1869
|
+
"clock-skew": 401,
|
|
1299
1870
|
"rate-limited": 429,
|
|
1300
1871
|
"server-error": 500
|
|
1301
1872
|
});
|
|
1302
|
-
var FetchRequest =
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1873
|
+
var FetchRequest = z9.object({
|
|
1874
|
+
// `literal`, like every other request — V1-17. This one said
|
|
1875
|
+
// `string().min(1)`, so a daemon speaking a version this server does not
|
|
1876
|
+
// know got past the handshake on the one endpoint that hands over a
|
|
1877
|
+
// sealed payload. The version check exists so that a mismatch is a named
|
|
1878
|
+
// refusal rather than a schema failure three fields later; here it was
|
|
1879
|
+
// neither.
|
|
1880
|
+
protocolVersion: z9.literal(PROTOCOL_VERSION),
|
|
1881
|
+
runnerId: z9.string().min(1),
|
|
1882
|
+
jobId: z9.string().min(1),
|
|
1306
1883
|
/**
|
|
1307
1884
|
* The grant this daemon holds.
|
|
1308
1885
|
*
|
|
@@ -1310,9 +1887,9 @@ var FetchRequest = z8.object({
|
|
|
1310
1887
|
* only the job would be answerable for whatever lease exists when it
|
|
1311
1888
|
* arrives ({@link Lease.id}).
|
|
1312
1889
|
*/
|
|
1313
|
-
leaseId:
|
|
1890
|
+
leaseId: z9.string().min(1)
|
|
1314
1891
|
}).strict();
|
|
1315
|
-
var FetchResponse =
|
|
1892
|
+
var FetchResponse = z9.object({
|
|
1316
1893
|
/**
|
|
1317
1894
|
* The work, sealed to the device that claimed it — byollm_009 §6.
|
|
1318
1895
|
*
|
|
@@ -1340,6 +1917,7 @@ export {
|
|
|
1340
1917
|
ClaimedJob,
|
|
1341
1918
|
ClaimedStub,
|
|
1342
1919
|
DeliveredResult,
|
|
1920
|
+
ENCRYPTION_KEY_CONTEXT,
|
|
1343
1921
|
ENDPOINTS,
|
|
1344
1922
|
ENVELOPE_MAX_AGE_MS,
|
|
1345
1923
|
ERROR_STATUS,
|
|
@@ -1361,6 +1939,7 @@ export {
|
|
|
1361
1939
|
KindedPayload,
|
|
1362
1940
|
Lease,
|
|
1363
1941
|
MAX_CLOCK_SKEW_MS,
|
|
1942
|
+
MAX_SUCCESSION_CHAIN,
|
|
1364
1943
|
MIN_PROTOCOL_VERSION,
|
|
1365
1944
|
MUSTS,
|
|
1366
1945
|
MUST_IDS,
|
|
@@ -1377,6 +1956,7 @@ export {
|
|
|
1377
1956
|
PairStartResponse,
|
|
1378
1957
|
PublicIdentity,
|
|
1379
1958
|
REFUSAL_MESSAGES,
|
|
1959
|
+
RETIREMENT_WINDOW_MS,
|
|
1380
1960
|
ReleaseRequest,
|
|
1381
1961
|
ReleaseResponse,
|
|
1382
1962
|
RequestSignature,
|
|
@@ -1384,11 +1964,15 @@ export {
|
|
|
1384
1964
|
ResultProvenance,
|
|
1385
1965
|
ResultRequest,
|
|
1386
1966
|
ResultResponse,
|
|
1967
|
+
RunMetadata,
|
|
1387
1968
|
SIZE_CLASS_LIMITS,
|
|
1969
|
+
SUCCESSION_CONTEXT,
|
|
1388
1970
|
SUPPORTED_PROTOCOL_VERSIONS,
|
|
1389
1971
|
SealedEnvelope,
|
|
1972
|
+
SealedOutcome,
|
|
1390
1973
|
SizeClass,
|
|
1391
1974
|
StoredKeys,
|
|
1975
|
+
Succession,
|
|
1392
1976
|
TERMINAL_STATES,
|
|
1393
1977
|
WireError,
|
|
1394
1978
|
WireErrorCode,
|
|
@@ -1397,6 +1981,7 @@ export {
|
|
|
1397
1981
|
canonicalRequest,
|
|
1398
1982
|
checkProtocolVersion,
|
|
1399
1983
|
cryptoReady,
|
|
1984
|
+
declaredVersion,
|
|
1400
1985
|
effectiveOfferScope,
|
|
1401
1986
|
fingerprint,
|
|
1402
1987
|
generateKeys,
|
|
@@ -1405,6 +1990,7 @@ export {
|
|
|
1405
1990
|
isLocalHost,
|
|
1406
1991
|
isTerminal,
|
|
1407
1992
|
keyId,
|
|
1993
|
+
kindsOf,
|
|
1408
1994
|
matchAudience,
|
|
1409
1995
|
mustsVerifiedBy,
|
|
1410
1996
|
open,
|
|
@@ -1414,11 +2000,17 @@ export {
|
|
|
1414
2000
|
resolveCost,
|
|
1415
2001
|
seal,
|
|
1416
2002
|
signRequest,
|
|
2003
|
+
signSiteRequest,
|
|
2004
|
+
signSuccession,
|
|
1417
2005
|
signWith,
|
|
1418
2006
|
sizeClassCeiling,
|
|
1419
2007
|
sizeClassOf,
|
|
2008
|
+
successionStatement,
|
|
2009
|
+
verifyLink,
|
|
1420
2010
|
verifyPublicIdentity,
|
|
1421
2011
|
verifyRequest,
|
|
1422
|
-
|
|
2012
|
+
verifySiteRequest,
|
|
2013
|
+
verifyWith,
|
|
2014
|
+
walkSuccession
|
|
1423
2015
|
};
|
|
1424
2016
|
//# sourceMappingURL=index.js.map
|