deepdebug-local-agent 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/.claude/context.md +2 -1
  2. package/AGENTS.md +1 -1
  3. package/CLAUDE.md +1 -1
  4. package/docs/la-jobs-and-llm.md +56 -0
  5. package/docs/la-protocol-v2.md +19 -2
  6. package/env.example +11 -0
  7. package/package.json +1 -1
  8. package/src/incident-detector/README.md +2 -1
  9. package/src/incident-detector/aggregator.js +8 -3
  10. package/src/incident-detector/detector.js +2 -1
  11. package/src/incident-detector/emitter.js +16 -6
  12. package/src/incident-detector/index.js +3 -3
  13. package/src/incident-detector/sources/webhook-source.js +109 -0
  14. package/src/la-protocol-v2/content-handlers/install.js +27 -1
  15. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +26 -18
  16. package/src/la-protocol-v2/contract/README.md +127 -3
  17. package/src/la-protocol-v2/contract/config.update.schema.json +85 -1
  18. package/src/la-protocol-v2/contract/hello.schema.json +26 -1
  19. package/src/la-protocol-v2/contract/incident.candidate.schema.json +11 -0
  20. package/src/la-protocol-v2/contract/index.json +9 -2
  21. package/src/la-protocol-v2/contract/job.assign.schema.json +31 -0
  22. package/src/la-protocol-v2/contract/ready.schema.json +8 -0
  23. package/src/la-protocol-v2/contract/register.schema.json +26 -1
  24. package/src/la-protocol-v2/contract/webhook.forward.schema.json +72 -3
  25. package/src/la-protocol-v2/contract.js +45 -7
  26. package/src/la-protocol-v2/jobs/config.js +60 -0
  27. package/src/la-protocol-v2/jobs/events.js +106 -0
  28. package/src/la-protocol-v2/jobs/install.js +37 -0
  29. package/src/la-protocol-v2/jobs/manager.js +276 -0
  30. package/src/la-protocol-v2/jobs/options.js +56 -0
  31. package/src/la-protocol-v2/jobs/smoke-runner.js +39 -0
  32. package/src/la-protocol-v2/llm/client.js +311 -0
  33. package/src/la-protocol-v2/llm/config.js +61 -0
  34. package/src/la-protocol-v2/llm/install.js +17 -0
  35. package/src/la-protocol-v2/llm-key/config.js +29 -0
  36. package/src/la-protocol-v2/llm-key/handler.js +33 -0
  37. package/src/la-protocol-v2/llm-key/install.js +21 -0
  38. package/src/la-protocol-v2/llm-key/store.js +183 -0
  39. package/src/la-protocol-v2/runtime.js +71 -4
  40. package/src/la-protocol-v2/session.js +47 -13
  41. package/src/la-protocol-v2/webhooks/assembler.js +85 -0
  42. package/src/la-protocol-v2/webhooks/config.js +51 -0
  43. package/src/la-protocol-v2/webhooks/handler.js +125 -0
  44. package/src/la-protocol-v2/webhooks/install.js +22 -0
  45. package/test/contract/la-protocol-v2/vectors/ack.vectors.json +91 -0
  46. package/test/contract/la-protocol-v2/vectors/config.update.vectors.json +1339 -0
  47. package/test/contract/la-protocol-v2/vectors/hello.vectors.json +261 -0
  48. package/test/contract/la-protocol-v2/vectors/incident.candidate.vectors.json +623 -0
  49. package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +1538 -0
  50. package/test/contract/la-protocol-v2/vectors/ready.vectors.json +599 -0
  51. package/test/contract/la-protocol-v2/vectors/register.vectors.json +275 -0
  52. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-after-last.json +186 -0
  53. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-body-hash-changes.json +186 -0
  54. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-last-flag-wrong.json +186 -0
  55. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-non-last-too-short.json +152 -0
  56. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-out-of-order.json +152 -0
  57. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-sha-mismatch.json +186 -0
  58. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-total-changes.json +186 -0
  59. package/test/contract/la-protocol-v2/vectors/traces/valid-webhook-chunks-in-order.json +218 -0
  60. package/test/contract/la-protocol-v2/vectors/webhook.forward.vectors.json +1263 -0
  61. package/test/incident-detector/size-model.test.js +127 -0
  62. package/test/la-protocol-v2/contract-v21.test.js +107 -0
  63. package/test/la-protocol-v2/contract.test.js +6 -4
  64. package/test/la-protocol-v2/helpers/fake-llm.js +64 -0
  65. package/test/la-protocol-v2/helpers/reference-cp.js +8 -4
  66. package/test/la-protocol-v2/jobs-e2e.test.js +405 -0
  67. package/test/la-protocol-v2/jobs-manager.test.js +370 -0
  68. package/test/la-protocol-v2/jobs-options.test.js +288 -0
  69. package/test/la-protocol-v2/llm-client.test.js +380 -0
  70. package/test/la-protocol-v2/llm-key.test.js +509 -0
  71. package/test/la-protocol-v2/traces.test.js +3 -3
  72. package/test/la-protocol-v2/webhook-receiver.test.js +702 -0
@@ -5,13 +5,16 @@ sides. Design: vault `10 Library/Plans/Fase 3 Desenho Tecnico 2026-10-28.md` sec
5
5
  implements it behind `deepdebug.agent-ws.protocol-v2.enabled` (default off, F3-05a); the LA side (F3-05b) does not
6
6
  exist yet. v1 is untouched. The gateway packages `*.schema.json` + `index.json` from this folder as classpath
7
7
  `la-protocol-v2/` (pom resource; both Dockerfiles copy this folder), so this folder stays the only source.
8
+ **Revision 2.1** (`index.json` `revision`, Fase 3, last section): optional LLM key delivery, chunked webhooks, size/model in `incident.candidate`, `ready.capabilities`, `job.assign.options` and the capabilities `job.assign` / `job.options`.
8
9
 
9
10
  ## Files
10
11
  - `envelope.schema.json`: common frame, closed (`additionalProperties:false`). `<type>.schema.json` (22):
11
12
  one per allowlisted type, each `$ref`s the envelope and closes `payload`. `defs.schema.json`: shared types.
12
13
  - `file.list.body.schema.json` (F3-07c): not a frame type; the schema of the DECODED body of a `content.response` for kind `file.list`
13
14
  (below). `vectors/file.list.body.vectors.json` holds its vectors (same format, `schema` names that file).
14
- - `index.json`: allowlist, direction, signed/control flags, content class per type, and the limits below.
15
+ - `index.json`: allowlist, direction, signed/control flags, content class per type (`none`, `relay`, `transit`, `secret`), the limits below and the contract `revision`.
16
+ - Vector files of a BODY that travels inside a frame (not a frame) are named `*.body.vectors.json` and their schema `*.body.schema.json`; the frame
17
+ suites skip them (`ProtocolV2ContractTest` checks them against their own schema, `SchemaLaV2ContractTest` does not read them as frames).
15
18
  - `vectors/<type>.vectors.json` (+ `cross-type`): valid and invalid messages with `expect` (`valid`, error `code`,
16
19
  optional JSON Schema `keyword`, optional session `rule`). `vectors/traces/*.json`: whole sessions for the stateful rules.
17
20
 
@@ -30,7 +33,8 @@ ack timeout 5 s, <= 3 retransmissions; handshake steps 10 s; `ping`/`heartbeat`
30
33
  (= Redis `la:session` TTL); clock skew <= 60 s. Duplicate detection (gateway, F3-05a): the last 512 `seq`->`msgId` pairs
31
34
  per session, in memory (`dedupe-entries`); the 10 min Redis window of the design (`dedupeWindowSeconds` in `index.json`)
32
35
  is NOT implemented, a replay older than those 512 frames is `seq_replayed`. CP->LA frames repeat the payload in
33
- `paramsRaw`, so their effective payload budget is about 20 KiB; larger inputs travel by `ref`.
36
+ `paramsRaw`, so their effective payload budget is about 20 KiB; larger inputs travel by `ref`. Webhook chunking (2.1) has its own limits
37
+ (`webhook*` in `index.json`: 16 KiB raw per chunk, 64 chunks, 1 MiB per webhook, see the last section).
34
38
 
35
39
  ## State machine
36
40
  `AWAIT_REGISTER -> AWAIT_HELLO -> AWAIT_READY -> READY`, after the WS upgrade authenticates (Bearer, as v1).
@@ -45,7 +49,9 @@ is NOT implemented, a replay older than those 512 frames is `seq_replayed`. CP->
45
49
  handshake state, `seq` (same seq + same `msgId` = idempotent duplicate, re-ack; same seq + other `msgId` =
46
50
  `seq_replayed`; skip = `seq_gap`), chunks (index 0.., `total` constant, `last` iff last index, none after last; then `payload.byteLength` must equal the size of
47
51
  the base64-DECODED `payload.data`, else `chunk_inconsistent`: JSON Schema cannot state it, so the vector files carry
48
- `expect.rule:"byteLength-decoded"` and both validators apply it).
52
+ `expect.rule:"byteLength-decoded"` and both validators apply it). Since 2.1 two more stateless rules of the same kind:
53
+ `webhook-chunk-size` and `llmKey-id-sha256` (last section); `webhook.forward` chunks are also grouped by `payload.webhookId` and the reassembled
54
+ body must hash to `bodySha256` (`chunk_inconsistent` otherwise).
49
55
  - **Errors** (`error{code,retryable}`, then WS close 4400-4499 when fatal): `bad_frame unknown_type schema_violation
50
56
  frame_too_large payload_too_large wrong_direction handshake_violation not_ready seq_gap seq_replayed
51
57
  chunk_out_of_order chunk_inconsistent tenant_mismatch agent_mismatch params_mismatch signature_mode_insufficient
@@ -64,6 +70,7 @@ Everything is closed: no free-text fields, no extra properties, free strings <=
64
70
  hashes, counts). Client content may exist only in three declared fields (`x-content-class`, pinned by the test):
65
71
  `content.response.data` (relay, LA->CP, never persisted or logged by the CP), `job.assign.inputs[].inline` and
66
72
  `webhook.forward.bodyB64` (transit, CP->LA, `transient:true`, not persisted; `paramsRaw` echoes them).
73
+ Since 2.1 there is one more classified field and it is the only SECRET: `config.update.llmKey.key` (`x-content-class: secret`, see the last section).
67
74
  Long non-content strings are marked `x-large-metadata` (`paramsRaw`, `signedPutUrl`, `vhapr1` token). `error.detail` <= 200 chars
68
75
  and `audit.event` have no summary text; stack traces, diffs, logs and paths go only through `content.*`.
69
76
 
@@ -186,3 +193,120 @@ existing kinds, `ref` convention, ids, envelope and chunking are reused; the cha
186
193
  answer is HTTP 501 `not_supported`. That announcement is also how the front/CP discovers that listing exists on a given agent.
187
194
  - **Compatibility.** Additive: the kind is added to the `content.request` and `content.index` enums, nothing else changes there. The
188
195
  `content.request` payload gained no field. With `deepdebug.content-relay.enabled` off nothing is mapped, as before.
196
+
197
+ ## Revision 2.1 (Fase 3, contract v2.1): LLM key, chunked webhooks, size and model of a candidate
198
+ Decisions of Edu 2026-11-03 (ADR-0017, adenda (c)). **No new frame type** (still 22), every change is additive and optional, and each is gated by a
199
+ capability so a 2.0 peer keeps working unchanged. The gateway code that uses these fields is a later task (G); here only the contract moved.
200
+
201
+ **Capabilities (negotiation).** The LA announces, in `register` and `hello` `capabilities`: `contract.v2.1` (it has the 2.1 schemas, so it can read the
202
+ new optional `ready.payload.capabilities`), `llm.key` (it handles `config.update.llmKey`) and `webhook.chunked` (it reassembles chunked `webhook.forward`;
203
+ it announces `webhook.forward` too). The CP announces, in `ready.payload.capabilities` (array, <= 32, same name pattern): `incident.candidate.size-model` (it
204
+ accepts `stackBytes` and `model`). Rules: the CP sends `llmKey` only to a session that announced `llm.key`, a chunked webhook only to one that announced
205
+ `webhook.chunked`, and `ready.capabilities` only to one that announced `contract.v2.1` (a 2.0 LA's closed `ready` schema does not know the field, so without
206
+ `contract.v2.1` the `ready` is exactly the 2.0 one); an LA sends `stackBytes`/`model` only if `ready` listed `incident.candidate.size-model` (a 2.0 CP would
207
+ answer the closed schema with `schema_violation`). A name that is absent means 2.0 behaviour.
208
+ One more LA capability, `llm.key.held` (section (e)), tells the CP the LA holds a valid key right now; it is optional and only informs the CP.
209
+
210
+ ### (a) LLM key delivery: `config.update.payload.llmKey`
211
+ `config.update` already carried only flags, a DLP hash, a minimum version, a retention and the data-plane mode: it had no way to hand the LA the virtual
212
+ LiteLLM key (F3-09). `llmKey` is a closed `oneOf`:
213
+ - `{op:"set", keyId, endpoint, key, models[], expiresAt}`, all required. `keyId` is the 64-hex lower-case sha256 of the UTF-8 bytes of `key` (it is
214
+ LiteLLM's token id and the CP's `la_llm_keys.key_id`; semantic rule `llmKey-id-sha256`: the LA recomputes it and refuses a mismatch). `endpoint` is the AI
215
+ gateway base URL, `https://` only, host[:port][/path], no credentials, no query, no fragment, <= 256 chars. `key` is 16..256 chars of
216
+ `[A-Za-z0-9._~+/=-]` (no whitespace, no control character). `models` is 1..32 unique aliases (the scope of the key). `expiresAt` is UTC.
217
+ - `{op:"revoke", keyId}`: names the key by id, never repeats it.
218
+ - A `config.update` that carries `llmKey` carries NOTHING else (`maxProperties: 1`), so the frame can be recognised and redacted as a whole.
219
+ - **The key is a secret.** `x-content-class: secret` on `llmKey.key`; `index.json` marks `config.update` `contentClass: "secret"`. It travels only on the v2
220
+ channel (TLS plus the Ed25519 signature) and, between gateway instances, only inside the AEAD-sealed relay (F3-04). It appears twice in the frame, in
221
+ `payload` and in the `paramsRaw` copy: BOTH are secret. The CP never writes it to a log, a metric, a trace, the audit trail, a table or an unsealed Redis
222
+ value (it keeps it only in the in-memory unacked frame until acked or the session ends). The LA keeps it **only in memory or in a file mode `0600`
223
+ (directory `0700`) outside every workspace and outside what the content handlers can serve**, never logs it, never puts it in `hello`, `alert`,
224
+ `audit.event`, `job.event`, `content.response` or an error, and never passes it on a command line.
225
+ - **The ack never returns it.** The answer is the plain `ack` (its payload is only `ackSeq`, `status`, `errorCode`; the contract test pins that no LA->CP
226
+ frame has a field named `key`, `llmKey` or `keyId`): `accepted`, or `rejected` + `schema_violation` when the LA refuses the key (expired
227
+ `expiresAt`, `keyId` not the sha256 of `key`).
228
+ - **Rotation.** A `set` with another `keyId` replaces the key the LA holds, atomically (one key per LA); the same `keyId` again is idempotent. The CP should
229
+ revoke the old key at the AI gateway only after the `accepted` ack of the new one.
230
+ - **Revocation.** The key revoked at the AI gateway is the authoritative revocation; the `revoke` frame only makes the LA drop its copy (delete from memory,
231
+ unlink and overwrite the file) and stop using it. A `revoke` of an id the LA does not hold is a no-op and is acked `accepted` (it never tells which key it
232
+ has). The LA also drops the key at `expiresAt`.
233
+
234
+ ### (b) Chunking of `webhook.forward` (CP -> LA)
235
+ 2.0 allowed one frame with `bodyB64` <= 12288 chars (9216 raw bytes). That stays: **a body of at most 9216 bytes is sent as ONE frame without `chunk`, exactly
236
+ as in 2.0** (a 2.0 LA reads it). A bigger body (up to **1 MiB**) is chunked, reusing the `content.response` pattern but CP -> LA:
237
+ - Each chunk is a signed `webhook.forward` frame with the envelope `chunk{index,total,last}` (index from 0), its own `seq` and `msgId`, and the same
238
+ `webhookId`, `source`, `contentType`, **`bodyBytes`** (size of the WHOLE decoded body, 0..1048576) and **`bodySha256`** (sha256 of the whole decoded body,
239
+ lower-case hex). `bodyBytes` and `bodySha256` are required in a chunked frame and forbidden in an unchunked one (schema `if/then/else` on `chunk`).
240
+ - **Fixed chunk size**: 16384 raw bytes (`bodyB64` <= 21848 chars; twice in the frame with `paramsRaw` it still fits 48 KiB: a vector pins it).
241
+ `total = max(1, ceil(bodyBytes / 16384))` (<= 64); every non-last chunk has exactly 16384 bytes; the last has `bodyBytes - 16384 * (total - 1)`. That is the
242
+ stateless rule `webhook-chunk-size` (`chunk_inconsistent` when a frame breaks it), so the LA can size the buffer from the first chunk. A body above
243
+ 9216 bytes is never sent unchunked; a chunked frame with a small body (`total` 1) is valid.
244
+ - **Order and integrity** (reference model, same codes as `content.response`): chunks in index order, `total` constant, `last` iff the last index, none after
245
+ the last (`chunk_out_of_order` / `chunk_inconsistent`); `bodyBytes`, `bodySha256`, `source`, `contentType` must not change between chunks; on the last
246
+ chunk the reassembled body must have `bodyBytes` bytes and hash to `bodySha256`, else `chunk_inconsistent`. Each chunk is also authentic on its own (signature).
247
+ - **Acks.** Every chunk is a normal v2 frame: the LA acks each by `seq` (piggyback or `ack{ackSeq,status}`), `accepted` = stored. The `accepted` ack of the
248
+ LAST chunk means the body was reassembled and its hash verified: that is the ack the CP waits for before answering the webhook (decision: ack AFTER the
249
+ forward). The LA answers `rejected` + `chunk_inconsistent` / `chunk_out_of_order` / `payload_too_large` / `rate_limited` and drops the assembly; the CP then
250
+ stops sending chunks of that `webhookId` and treats the webhook as not delivered.
251
+ - **LA limits** (`index.json`): an assembly older than 30 s (`webhookAssemblySeconds`) without its last chunk is dropped; at most 4 open assemblies per session
252
+ (`webhookMaxOpenAssemblies`; the CP sends one chunked webhook at a time per session) and 4 MiB buffered; beyond that `rejected` + `rate_limited`. The body
253
+ stays in memory, is never written or logged by the CP, and the LA applies its DLP to it as to any client content.
254
+ - `webhookId` is the group key; a retransmitted chunk (same `seq` and `msgId`) is the existing idempotent duplicate; the same `webhookId` redelivered as a
255
+ whole is the application's dedup (a deterministic `webhookId` is the consumer's job).
256
+
257
+ ### (c) `incident.candidate`: `stackBytes` and `model`
258
+ Two OPTIONAL fields, no required field added: `stackBytes` (integer 0..16777216: size in bytes of the stack the LA saw; the LA saturates at the cap) and
259
+ `model` (string 1..48, `^[A-Za-z0-9][A-Za-z0-9._:+-]{0,47}$`: detector/parser version, e.g. `stack-detector-1.8.0`; no space, no slash, so no name, path or
260
+ error text fits). The frame still carries no stack, no message and no name. Sent only after `ready` announced `incident.candidate.size-model`.
261
+
262
+ ### (d) `ready.payload.capabilities`
263
+ Optional array (<= 32 capability names). It is what lets (c) be sent safely; it carries no limit and no content. A CP that does not send it is a 2.0 CP, or the
264
+ session did not announce `contract.v2.1`.
265
+
266
+ ### (e) `llm.key.held`: the LA says whether it still has its key
267
+ Problem it solves: an LA that keeps the key only in memory (the default) loses it on restart, but the CP's table still says ACTIVE, and the CP only
268
+ delivers again when the key is close to expiry (< 14 days), so the LA would stay without a key. Rather than the CP guessing, the LA announces it.
269
+ - **Name and place.** The capability string `llm.key.held` (it already matches the `capability` pattern, so no schema enum changes), announced in
270
+ `register.payload.capabilities` and in `hello.payload.capabilities`, never in `ready.payload.capabilities` (that list is the CP's). It is optional and
271
+ needs no `contract.v2.1` decision of its own: a CP that does not know it ignores an unknown capability name, as it ignores any other.
272
+ - **Rule for the LA.** It announces `llm.key.held` ONLY when it holds a key that is still valid (`expiresAt` in the future, accepted and
273
+ `keyId`-verified through `config.update.llmKey`) and that it is IN USE (it is the key its LLM calls would use). No key, an expired key, a revoked key
274
+ or a key it refused: it does not announce it. The announcement is a snapshot at connect time: `register` and `hello` of one session carry the same
275
+ answer, and the LA never announces it without also announcing `llm.key` (an LA that cannot receive a key cannot hold one). That dependency is in the
276
+ schema (`if capabilities contains llm.key.held then it must contain llm.key`, `register` and `hello`), so `llm.key.held` alone is `schema_violation`.
277
+ - **Rule for the CP.** At connect, if the capability is NOT announced (in `register` and `hello`; the CP should treat the key as held only when both
278
+ say so), the CP delivers a key (mint, then `config.update.llmKey set`) even when an ACTIVE row exists for that agent; the previous key follows the
279
+ rotation rule of (a) (revoked at the AI gateway only after the `accepted` ack of the new one). If it IS announced, the CP keeps the existing rule:
280
+ nothing to deliver until the key is within 14 days of `expiresAt`. A 2.0 LA, or a 2.1 LA that does not know the capability, never announces it, so
281
+ the CP treats it as "holds no key" and delivers one if the channel is on (`llm.key` announced): the safe direction.
282
+ - **Compatible.** Optional and additive: a register/hello without it is valid (2.0, or 2.1 after a restart), and one with it is valid under 2.1 only
283
+ together with `llm.key`. No frame type, no new field and no new limit. The CP side of this rule is gateway runtime code (a later task, not this contract).
284
+
285
+ ### (f) Jobs: `job.assign`, `job.cancel`, `job.event`, `job.result` and the capabilities `job.assign` and `job.options` (F3-10)
286
+ The four job frames exist since 2.0 (their schemas did not change, except `options` below). What F3-10 adds is the negotiation and one optional field.
287
+ - **Capability `job.assign`.** An LA that runs jobs announces `job.assign` in `register.payload.capabilities` and in `hello.payload.capabilities`. It covers BOTH
288
+ `job.assign` and `job.cancel` (one name, because an LA that can take a job can stop it): the CP sends neither to a session that did not announce it, and a CP
289
+ that has no agent with it answers its caller `503 AGENT_UNSUPPORTED`. A LA announces it only when it has a runner to run the job (no runner, no capability).
290
+ An LA that announced it but still answers `job.assign` with `rejected protocol_unsupported` (its jobs feature is off) did NOT take the job: the CP may offer
291
+ it to another agent of the tenant.
292
+ - **Capability `job.options`.** An LA that understands `job.assign.payload.options` announces `job.options` (it implies `job.assign`). The CP sends `options` ONLY to
293
+ such a session. Both names match the `capability` pattern, so no enum changes.
294
+ - **`job.assign.payload.options` (optional, closed object, `minProperties` 1).** How to run the job; absent means the LA's own defaults, exactly as in 2.0.
295
+ Fields (all optional): `ticketKey` (a `shortId`: the ticket of the request), `branchName` (<= 255, `^(?!.*\.\.)(?!.*//)(?!.*/\.)(?!.*[./]$)(?!.*\.lock(/|$))[A-Za-z0-9_][A-Za-z0-9_./-]{0,254}$`:
296
+ no `..`, no `//`, no component starting with `.`, no trailing `.` or `/`, no `.lock` suffix, no space or shell character, no leading `-`), `createBranch` (bool),
297
+ `createPullRequest` (bool), `dryRun` (bool), `maxIterations` (integer 1..100). None of them is content (an id, a ref name, flags, a counter), so none is a
298
+ `transit` field; the request text itself still travels only in `inputs[].inline`. There is no field for a token or a path (the object is closed).
299
+ - **What an LA must do with it.** `dryRun:true` means: run the whole loop but push nothing, open no pull request and notify nobody. `createPullRequest:false`
300
+ means: do not open one (the branch may be pushed when `createBranch` is not false). `createBranch:false` means: work on `repoRef.branch` without creating a branch.
301
+ `maxIterations` caps the agent loop below the LA's own cap (never above it). `branchName`/`ticketKey` name the branch to create. An LA never widens its own policy because
302
+ of an option: the budget and the policy of the same payload still bound the job.
303
+ - **Rule for the CP.** Options that change external effects (`dryRun:true`, `createPullRequest:false`) are sent only to an LA that announced `job.options`; if none
304
+ did, the CP REFUSES the job (`OPTION_NOT_SUPPORTED`) instead of running one with a real pull request. The other options (ticket, branch, `createBranch`,
305
+ `maxIterations`) are an optimisation of the same default behaviour: for an LA without `job.options` the CP sends the job without `options`.
306
+ - **Compatible.** A `job.assign` without `options` is valid under 2.0 and 2.1 and means what it always meant. The CP gateway side is `deepdebug.jobs.dispatch-to-la.enabled`
307
+ (default off); the vectors are `vectors/job.assign.vectors.json` (`options-*`).
308
+
309
+ ### Compatibility and roll-out order
310
+ Everything is additive and optional: a 2.0 frame is valid under the 2.1 schemas (the vectors of 2.0 are unchanged and still pass), and a 2.1 peer talking to a
311
+ 2.0 peer sends nothing the 2.0 peer cannot read, because each new field is gated by a capability the other side announced (above). Roll out the gateway that
312
+ has the 2.1 schemas first (it accepts both shapes), then the LAs. The files changed by 2.1 are listed with their sha256 in the PR; the LA re-syncs exactly those.
@@ -52,9 +52,93 @@
52
52
  },
53
53
  "dataPlaneMode": {
54
54
  "$ref": "defs.schema.json#/$defs/dataPlaneMode"
55
+ },
56
+ "llmKey": {
57
+ "oneOf": [
58
+ {
59
+ "type": "object",
60
+ "additionalProperties": false,
61
+ "properties": {
62
+ "op": {
63
+ "const": "set"
64
+ },
65
+ "keyId": {
66
+ "$ref": "defs.schema.json#/$defs/sha256"
67
+ },
68
+ "endpoint": {
69
+ "type": "string",
70
+ "minLength": 12,
71
+ "maxLength": 256,
72
+ "pattern": "^https://[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?(:[0-9]{1,5})?(/[A-Za-z0-9._~/-]{0,200})?$"
73
+ },
74
+ "key": {
75
+ "type": "string",
76
+ "minLength": 16,
77
+ "maxLength": 256,
78
+ "pattern": "^[A-Za-z0-9._~+/=-]{16,256}$",
79
+ "x-content-class": "secret"
80
+ },
81
+ "models": {
82
+ "type": "array",
83
+ "minItems": 1,
84
+ "maxItems": 32,
85
+ "uniqueItems": true,
86
+ "items": {
87
+ "type": "string",
88
+ "minLength": 1,
89
+ "maxLength": 96,
90
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,95}$"
91
+ }
92
+ },
93
+ "expiresAt": {
94
+ "$ref": "defs.schema.json#/$defs/ts"
95
+ }
96
+ },
97
+ "required": [
98
+ "op",
99
+ "keyId",
100
+ "endpoint",
101
+ "key",
102
+ "models",
103
+ "expiresAt"
104
+ ]
105
+ },
106
+ {
107
+ "type": "object",
108
+ "additionalProperties": false,
109
+ "properties": {
110
+ "op": {
111
+ "const": "revoke"
112
+ },
113
+ "keyId": {
114
+ "$ref": "defs.schema.json#/$defs/sha256"
115
+ }
116
+ },
117
+ "required": [
118
+ "op",
119
+ "keyId"
120
+ ]
121
+ }
122
+ ]
55
123
  }
56
124
  },
57
- "minProperties": 1
125
+ "minProperties": 1,
126
+ "if": {
127
+ "type": "object",
128
+ "properties": {
129
+ "llmKey": true
130
+ },
131
+ "required": [
132
+ "llmKey"
133
+ ]
134
+ },
135
+ "then": {
136
+ "type": "object",
137
+ "properties": {
138
+ "llmKey": true
139
+ },
140
+ "maxProperties": 1
141
+ }
58
142
  },
59
143
  "seq": {
60
144
  "type": "integer",
@@ -94,7 +94,32 @@
94
94
  "clockEpochMs",
95
95
  "diskFreeBytes",
96
96
  "queueDepth"
97
- ]
97
+ ],
98
+ "if": {
99
+ "properties": {
100
+ "capabilities": {
101
+ "type": "array",
102
+ "maxItems": 32,
103
+ "contains": {
104
+ "const": "llm.key.held"
105
+ }
106
+ }
107
+ },
108
+ "required": [
109
+ "capabilities"
110
+ ]
111
+ },
112
+ "then": {
113
+ "properties": {
114
+ "capabilities": {
115
+ "type": "array",
116
+ "maxItems": 32,
117
+ "contains": {
118
+ "const": "llm.key"
119
+ }
120
+ }
121
+ }
122
+ }
98
123
  },
99
124
  "seq": {
100
125
  "const": 0
@@ -72,6 +72,17 @@
72
72
  "security",
73
73
  "other"
74
74
  ]
75
+ },
76
+ "stackBytes": {
77
+ "type": "integer",
78
+ "minimum": 0,
79
+ "maximum": 16777216
80
+ },
81
+ "model": {
82
+ "type": "string",
83
+ "minLength": 1,
84
+ "maxLength": 48,
85
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._:+-]{0,47}$"
75
86
  }
76
87
  },
77
88
  "required": [
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "protocol": 2,
3
+ "revision": "2.1",
3
4
  "baseId": "https://schemas.visionhammer.io/la-protocol-v2/",
4
5
  "limits": {
5
6
  "maxFrameBytes": 49152,
@@ -12,7 +13,13 @@
12
13
  "heartbeatSeconds": 30,
13
14
  "sessionDeadSeconds": 90,
14
15
  "dedupeWindowSeconds": 600,
15
- "clockSkewSeconds": 60
16
+ "clockSkewSeconds": 60,
17
+ "webhookChunkRawBytes": 16384,
18
+ "webhookMaxChunks": 64,
19
+ "webhookMaxBytes": 1048576,
20
+ "webhookSingleFrameRawBytes": 9216,
21
+ "webhookAssemblySeconds": 30,
22
+ "webhookMaxOpenAssemblies": 4
16
23
  },
17
24
  "types": {
18
25
  "register": {
@@ -118,7 +125,7 @@
118
125
  "direction": "cp->la",
119
126
  "control": false,
120
127
  "signed": true,
121
- "contentClass": "none"
128
+ "contentClass": "secret"
122
129
  },
123
130
  "scan.upload": {
124
131
  "schema": "scan.upload.schema.json",
@@ -184,6 +184,37 @@
184
184
  }
185
185
  ]
186
186
  }
187
+ },
188
+ "options": {
189
+ "type": "object",
190
+ "additionalProperties": false,
191
+ "description": "Optional (revision 2.1, capability job.options): how to run the job. Absent = the Local Agent's own defaults. Sent only to a Local Agent that announced job.options; none of these fields is content (a ticket id, a branch name, flags and a counter).",
192
+ "properties": {
193
+ "ticketKey": {
194
+ "$ref": "defs.schema.json#/$defs/shortId"
195
+ },
196
+ "branchName": {
197
+ "type": "string",
198
+ "minLength": 1,
199
+ "maxLength": 255,
200
+ "pattern": "^(?!.*\\.\\.)(?!.*//)(?!.*/\\.)(?!.*[./]$)(?!.*\\.lock(/|$))[A-Za-z0-9_][A-Za-z0-9_./-]{0,254}$"
201
+ },
202
+ "createBranch": {
203
+ "type": "boolean"
204
+ },
205
+ "createPullRequest": {
206
+ "type": "boolean"
207
+ },
208
+ "dryRun": {
209
+ "type": "boolean"
210
+ },
211
+ "maxIterations": {
212
+ "type": "integer",
213
+ "minimum": 1,
214
+ "maximum": 100
215
+ }
216
+ },
217
+ "minProperties": 1
187
218
  }
188
219
  },
189
220
  "required": [
@@ -81,6 +81,14 @@
81
81
  "maxRetries",
82
82
  "heartbeatSeconds"
83
83
  ]
84
+ },
85
+ "capabilities": {
86
+ "type": "array",
87
+ "maxItems": 32,
88
+ "uniqueItems": true,
89
+ "items": {
90
+ "$ref": "defs.schema.json#/$defs/capability"
91
+ }
84
92
  }
85
93
  },
86
94
  "required": [
@@ -75,7 +75,32 @@
75
75
  "capabilities",
76
76
  "signatureMode",
77
77
  "workspaces"
78
- ]
78
+ ],
79
+ "if": {
80
+ "properties": {
81
+ "capabilities": {
82
+ "type": "array",
83
+ "maxItems": 32,
84
+ "contains": {
85
+ "const": "llm.key.held"
86
+ }
87
+ }
88
+ },
89
+ "required": [
90
+ "capabilities"
91
+ ]
92
+ },
93
+ "then": {
94
+ "properties": {
95
+ "capabilities": {
96
+ "type": "array",
97
+ "maxItems": 32,
98
+ "contains": {
99
+ "const": "llm.key"
100
+ }
101
+ }
102
+ }
103
+ }
79
104
  },
80
105
  "seq": {
81
106
  "const": 0
@@ -40,9 +40,17 @@
40
40
  "bodyB64": {
41
41
  "type": "string",
42
42
  "minLength": 0,
43
- "maxLength": 12288,
43
+ "maxLength": 21848,
44
44
  "pattern": "^[A-Za-z0-9+/]*={0,2}$",
45
45
  "x-content-class": "transit"
46
+ },
47
+ "bodyBytes": {
48
+ "type": "integer",
49
+ "minimum": 0,
50
+ "maximum": 1048576
51
+ },
52
+ "bodySha256": {
53
+ "$ref": "defs.schema.json#/$defs/sha256"
46
54
  }
47
55
  },
48
56
  "required": [
@@ -57,7 +65,7 @@
57
65
  "minimum": 1,
58
66
  "maximum": 9007199254740991
59
67
  },
60
- "chunk": false,
68
+ "chunk": true,
61
69
  "sig": true,
62
70
  "paramsRaw": true
63
71
  },
@@ -65,5 +73,66 @@
65
73
  "payload",
66
74
  "sig",
67
75
  "paramsRaw"
68
- ]
76
+ ],
77
+ "if": {
78
+ "type": "object",
79
+ "properties": {
80
+ "chunk": true
81
+ },
82
+ "required": [
83
+ "chunk"
84
+ ]
85
+ },
86
+ "then": {
87
+ "type": "object",
88
+ "properties": {
89
+ "payload": {
90
+ "type": "object",
91
+ "properties": {
92
+ "bodyBytes": true,
93
+ "bodySha256": true
94
+ },
95
+ "required": [
96
+ "bodyBytes",
97
+ "bodySha256"
98
+ ]
99
+ }
100
+ }
101
+ },
102
+ "else": {
103
+ "type": "object",
104
+ "properties": {
105
+ "payload": {
106
+ "type": "object",
107
+ "properties": {
108
+ "bodyB64": {
109
+ "type": "string",
110
+ "maxLength": 12288
111
+ }
112
+ },
113
+ "not": {
114
+ "anyOf": [
115
+ {
116
+ "type": "object",
117
+ "properties": {
118
+ "bodyBytes": true
119
+ },
120
+ "required": [
121
+ "bodyBytes"
122
+ ]
123
+ },
124
+ {
125
+ "type": "object",
126
+ "properties": {
127
+ "bodySha256": true
128
+ },
129
+ "required": [
130
+ "bodySha256"
131
+ ]
132
+ }
133
+ ]
134
+ }
135
+ }
136
+ }
137
+ }
69
138
  }
@@ -10,8 +10,10 @@
10
10
  * unknown_type `type` is not in the allowlist (index.json)
11
11
  * schema_violation the frame does not satisfy the closed schema of its type
12
12
  * payload_too_large ... because a string under /payload is above its maxLength (same rule as the gateway)
13
- * chunk_inconsistent content.response whose byteLength differs from the decoded size of data
13
+ * chunk_inconsistent content.response whose byteLength differs from the decoded size of data; webhook.forward chunk of the wrong size (2.1)
14
+ * (rule llmKey-id-sha256 of 2.1 is a schema_violation: the keyId is not the sha256 of the key)
14
15
  */
16
+ import crypto from "node:crypto";
15
17
  import fs from "node:fs";
16
18
  import path from "node:path";
17
19
  import { fileURLToPath } from "node:url";
@@ -21,16 +23,52 @@ const DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "contract");
21
23
  const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
22
24
  const readJson = (p) => JSON.parse(fs.readFileSync(p, "utf8"));
23
25
 
24
- /** Rules JSON Schema cannot state (README of the contract): byteLength == size of the decoded `data`. */
25
- export function semanticRules(frame) {
26
+ /** The base64 alphabet with padding, canonical form (no whitespace, length multiple of 4). */
27
+ export const isCanonicalBase64 = (s) => typeof s === "string" && BASE64.test(s);
28
+ const decodedLength = (s) => (isCanonicalBase64(s) ? Buffer.from(s, "base64").length : -1);
29
+
30
+ /**
31
+ * Contract 2.1, rule `webhook-chunk-size`: a chunked webhook.forward has total == max(1, ceil(bodyBytes / chunkRaw)), a non-last chunk
32
+ * decodes to exactly chunkRaw bytes and the last one to bodyBytes - chunkRaw * (total - 1). Stateless (one frame).
33
+ * @returns {boolean} true when the frame BREAKS the rule
34
+ */
35
+ export function webhookChunkSizeViolation(payload, chunk, chunkRaw = 16384) {
36
+ if (!payload || !chunk || typeof payload.bodyB64 !== "string" || !Number.isSafeInteger(payload.bodyBytes)) return false;
37
+ const { index, total } = chunk;
38
+ const expectedTotal = Math.max(1, Math.ceil(payload.bodyBytes / chunkRaw));
39
+ const expectedSize = index < total - 1 ? chunkRaw : payload.bodyBytes - chunkRaw * (total - 1);
40
+ return total !== expectedTotal || index >= total || decodedLength(payload.bodyB64) !== expectedSize;
41
+ }
42
+
43
+ /** Contract 2.1, rule `llmKey-id-sha256`: llmKey (op set) keyId == lower-case hex sha256 of the UTF-8 bytes of key. @returns {boolean} true when it BREAKS the rule */
44
+ export function llmKeyIdMismatch(llmKey) {
45
+ if (!llmKey || llmKey.op !== "set" || typeof llmKey.key !== "string") return false;
46
+ return crypto.createHash("sha256").update(Buffer.from(llmKey.key, "utf8")).digest("hex") !== llmKey.keyId;
47
+ }
48
+
49
+ /** Rule -> wire code (the `code` the vectors expect). */
50
+ export const RULE_CODES = Object.freeze({ "byteLength-decoded": "chunk_inconsistent", "webhook-chunk-size": "chunk_inconsistent", "llmKey-id-sha256": "schema_violation" });
51
+
52
+ /**
53
+ * Rules JSON Schema cannot state (README of the contract): byteLength == size of the decoded `data` (content.response), and since 2.1
54
+ * `webhook-chunk-size` and `llmKey-id-sha256`. `skip` names rules the caller applies itself later (the session defers the two 2.1
55
+ * rules to the handlers: they are answered with a non-fatal `rejected` ack, in the order of the reference model: state first).
56
+ */
57
+ export function semanticRules(frame, { skip = [] } = {}) {
26
58
  const rules = [];
27
- if (frame && frame.type === "content.response" && frame.payload && typeof frame.payload.data === "string") {
59
+ const on = (name) => !skip.includes(name);
60
+ if (frame && frame.type === "content.response" && frame.payload && typeof frame.payload.data === "string" && on("byteLength-decoded")) {
28
61
  const d = frame.payload.data;
29
62
  if (!BASE64.test(d) || Buffer.from(d, "base64").length !== frame.payload.byteLength) rules.push("byteLength-decoded");
30
63
  }
64
+ if (frame && frame.type === "webhook.forward" && frame.chunk && on("webhook-chunk-size")
65
+ && webhookChunkSizeViolation(frame.payload, frame.chunk, loadLimits().webhookChunkRawBytes)) rules.push("webhook-chunk-size");
66
+ if (frame && frame.type === "config.update" && frame.payload && on("llmKey-id-sha256") && llmKeyIdMismatch(frame.payload.llmKey)) rules.push("llmKey-id-sha256");
31
67
  return rules;
32
68
  }
33
69
 
70
+ const loadLimits = () => loadContract().limits;
71
+
34
72
  let singleton = null;
35
73
 
36
74
  /** Loads and compiles the contract once per process (about 20 ms). */
@@ -73,7 +111,7 @@ export function loadContract() {
73
111
  if (!v) throw new Error(`contract: no schema ${schemaFile}`);
74
112
  return v(value) === true;
75
113
  },
76
- validateFrame(frame) {
114
+ validateFrame(frame, { skipRules = [] } = {}) {
77
115
  if (frame === null || typeof frame !== "object" || typeof frame.type !== "string") {
78
116
  // no `type` at all (a v1 command frame, say): the envelope says what is missing
79
117
  const envelope = ajv.getSchema(index.baseId + "envelope.schema.json");
@@ -90,8 +128,8 @@ export function loadContract() {
90
128
  const tooLong = errs.some((e) => e.keyword === "maxLength" && String(e.instancePath).startsWith("/payload"));
91
129
  return { ok: false, code: tooLong ? "payload_too_large" : "schema_violation", keywords, rules: [] };
92
130
  }
93
- const rules = semanticRules(frame);
94
- if (rules.length) return { ok: false, code: "chunk_inconsistent", keywords: [], rules };
131
+ const rules = semanticRules(frame, { skip: skipRules });
132
+ if (rules.length) return { ok: false, code: RULE_CODES[rules[0]] || "chunk_inconsistent", keywords: [], rules };
95
133
  return { ok: true };
96
134
  },
97
135
  });