deepdebug-local-agent 1.3.0 → 1.4.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 (108) hide show
  1. package/.github/workflows/deploy-gcp.yml +58 -26
  2. package/CHANGELOG.md +6 -0
  3. package/docs/la-protocol-v2.md +68 -0
  4. package/package.json +2 -1
  5. package/scripts/sync-la-protocol-v2-contract.mjs +58 -0
  6. package/src/la-protocol-v2/chunks.js +45 -0
  7. package/src/la-protocol-v2/config.js +27 -0
  8. package/src/la-protocol-v2/content.js +57 -0
  9. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +81 -0
  10. package/src/la-protocol-v2/contract/README.md +103 -0
  11. package/src/la-protocol-v2/contract/ack.schema.json +51 -0
  12. package/src/la-protocol-v2/contract/alert.schema.json +62 -0
  13. package/src/la-protocol-v2/contract/approval.decision.schema.json +57 -0
  14. package/src/la-protocol-v2/contract/audit.event.schema.json +86 -0
  15. package/src/la-protocol-v2/contract/config.update.schema.json +73 -0
  16. package/src/la-protocol-v2/contract/content.request.schema.json +110 -0
  17. package/src/la-protocol-v2/contract/content.response.schema.json +90 -0
  18. package/src/la-protocol-v2/contract/defs.schema.json +191 -0
  19. package/src/la-protocol-v2/contract/envelope.schema.json +89 -0
  20. package/src/la-protocol-v2/contract/error.schema.json +59 -0
  21. package/src/la-protocol-v2/contract/heartbeat.schema.json +100 -0
  22. package/src/la-protocol-v2/contract/hello.schema.json +111 -0
  23. package/src/la-protocol-v2/contract/incident.candidate.schema.json +102 -0
  24. package/src/la-protocol-v2/contract/index.json +166 -0
  25. package/src/la-protocol-v2/contract/job.assign.schema.json +212 -0
  26. package/src/la-protocol-v2/contract/job.cancel.schema.json +52 -0
  27. package/src/la-protocol-v2/contract/job.event.schema.json +115 -0
  28. package/src/la-protocol-v2/contract/job.result.schema.json +100 -0
  29. package/src/la-protocol-v2/contract/log.stats.schema.json +79 -0
  30. package/src/la-protocol-v2/contract/ready.schema.json +106 -0
  31. package/src/la-protocol-v2/contract/register.schema.json +92 -0
  32. package/src/la-protocol-v2/contract/scan.upload.schema.json +59 -0
  33. package/src/la-protocol-v2/contract/scan.uploaded.schema.json +51 -0
  34. package/src/la-protocol-v2/contract/webhook.forward.schema.json +69 -0
  35. package/src/la-protocol-v2/contract.js +90 -0
  36. package/src/la-protocol-v2/errors.js +34 -0
  37. package/src/la-protocol-v2/index.js +8 -0
  38. package/src/la-protocol-v2/link.js +93 -0
  39. package/src/la-protocol-v2/runtime.js +72 -0
  40. package/src/la-protocol-v2/session.js +650 -0
  41. package/src/la-protocol-v2/strict-json.js +47 -0
  42. package/src/server.js +22 -4
  43. package/test/contract/la-protocol-v2/vectors/ack.vectors.json +149 -0
  44. package/test/contract/la-protocol-v2/vectors/alert.vectors.json +101 -0
  45. package/test/contract/la-protocol-v2/vectors/approval.decision.vectors.json +165 -0
  46. package/test/contract/la-protocol-v2/vectors/audit.event.vectors.json +156 -0
  47. package/test/contract/la-protocol-v2/vectors/config.update.vectors.json +189 -0
  48. package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +325 -0
  49. package/test/contract/la-protocol-v2/vectors/content.response.vectors.json +413 -0
  50. package/test/contract/la-protocol-v2/vectors/cross-type.vectors.json +260 -0
  51. package/test/contract/la-protocol-v2/vectors/error.vectors.json +179 -0
  52. package/test/contract/la-protocol-v2/vectors/heartbeat.vectors.json +109 -0
  53. package/test/contract/la-protocol-v2/vectors/hello.vectors.json +224 -0
  54. package/test/contract/la-protocol-v2/vectors/incident.candidate.vectors.json +186 -0
  55. package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +631 -0
  56. package/test/contract/la-protocol-v2/vectors/job.cancel.vectors.json +133 -0
  57. package/test/contract/la-protocol-v2/vectors/job.event.vectors.json +233 -0
  58. package/test/contract/la-protocol-v2/vectors/job.result.vectors.json +137 -0
  59. package/test/contract/la-protocol-v2/vectors/log.stats.vectors.json +137 -0
  60. package/test/contract/la-protocol-v2/vectors/ready.vectors.json +177 -0
  61. package/test/contract/la-protocol-v2/vectors/register.vectors.json +399 -0
  62. package/test/contract/la-protocol-v2/vectors/scan.upload.vectors.json +137 -0
  63. package/test/contract/la-protocol-v2/vectors/scan.uploaded.vectors.json +101 -0
  64. package/test/contract/la-protocol-v2/vectors/traces/invalid-agent-mismatch.json +140 -0
  65. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-after-last.json +200 -0
  66. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-byte-length-mismatch.json +174 -0
  67. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-last-flag.json +200 -0
  68. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-out-of-order.json +200 -0
  69. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-total-changes.json +200 -0
  70. package/test/contract/la-protocol-v2/vectors/traces/invalid-clock-skew.json +46 -0
  71. package/test/contract/la-protocol-v2/vectors/traces/invalid-event-before-ready.json +72 -0
  72. package/test/contract/la-protocol-v2/vectors/traces/invalid-first-frame-not-register.json +48 -0
  73. package/test/contract/la-protocol-v2/vectors/traces/invalid-frame-over-48kib.json +141 -0
  74. package/test/contract/la-protocol-v2/vectors/traces/invalid-la-sends-job-assign.json +173 -0
  75. package/test/contract/la-protocol-v2/vectors/traces/invalid-params-raw-differs-from-payload.json +172 -0
  76. package/test/contract/la-protocol-v2/vectors/traces/invalid-payload-above-limit.json +174 -0
  77. package/test/contract/la-protocol-v2/vectors/traces/invalid-resume-restarts-seq.json +144 -0
  78. package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-gap.json +166 -0
  79. package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-repeated.json +166 -0
  80. package/test/contract/la-protocol-v2/vectors/traces/invalid-sig-tenant-mismatch.json +172 -0
  81. package/test/contract/la-protocol-v2/vectors/traces/invalid-signature-warn-with-enforce-required.json +77 -0
  82. package/test/contract/la-protocol-v2/vectors/traces/invalid-tenant-mismatch.json +140 -0
  83. package/test/contract/la-protocol-v2/vectors/traces/invalid-type-outside-allowlist.json +140 -0
  84. package/test/contract/la-protocol-v2/vectors/traces/valid-content-chunks-in-order.json +224 -0
  85. package/test/contract/la-protocol-v2/vectors/traces/valid-handshake-and-job.json +266 -0
  86. package/test/contract/la-protocol-v2/vectors/traces/valid-resume-continues-seq.json +168 -0
  87. package/test/contract/la-protocol-v2/vectors/traces/valid-retransmit-is-idempotent.json +193 -0
  88. package/test/contract/la-protocol-v2/vectors/webhook.forward.vectors.json +170 -0
  89. package/test/la-protocol-v2/contract.test.js +131 -0
  90. package/test/la-protocol-v2/helpers/la-rig.js +83 -0
  91. package/test/la-protocol-v2/helpers/reference-cp.js +349 -0
  92. package/test/la-protocol-v2/helpers/spawn-agent.js +35 -0
  93. package/test/la-protocol-v2/link.test.js +141 -0
  94. package/test/la-protocol-v2/session-inbound.test.js +279 -0
  95. package/test/la-protocol-v2/session-outbound.test.js +382 -0
  96. package/test/la-protocol-v2/traces.test.js +270 -0
  97. package/test/la-protocol-v2/tunnel-e2e.test.js +124 -0
  98. package/tools/graphify-validation/extras/README.md +19 -0
  99. package/tools/graphify-validation/extras/bymethod.py +35 -0
  100. package/tools/graphify-validation/extras/docker/Dockerfile +16 -0
  101. package/tools/graphify-validation/extras/docker/inner.sh +7 -0
  102. package/tools/graphify-validation/extras/g10.py +59 -0
  103. package/tools/graphify-validation/extras/g3aug.py +49 -0
  104. package/tools/graphify-validation/extras/hybrid.py +34 -0
  105. package/tools/graphify-validation/extras/truth2.py +44 -0
  106. package/tools/interop/LaV2NodeInteropTest.java +266 -0
  107. package/tools/interop/README.md +21 -0
  108. package/tools/interop/la-interop-client.mjs +97 -0
@@ -1,4 +1,8 @@
1
- # Deploy local-agent (Node) to GCP Cloud Run (visionhammer) via WIF.
1
+ # Deploy local-agent (Node) to GCP Cloud Run via WIF.
2
+ # QA: project visionhammer (europe-west1 since Plano QA Alvo EU 2026-10-29, same
3
+ # conventions as PROD: deploy-local-agent@ SA of the nonprod project, EU registry,
4
+ # immutable sha-<commit> tag, digest-pinned image-only deploy). PROD: project
5
+ # visionhammer-prod (europe-west1), own WIF pool/deploy SA, digest-pinned deploy.
2
6
  # Manual-only: this repo has no develop branch and several active feature
3
7
  # branches, so deploys are triggered on demand from the Actions tab. Choose the
4
8
  # environment input. Replaces the insptechai Cloud Build path.
@@ -23,14 +27,18 @@ permissions:
23
27
  id-token: write
24
28
 
25
29
  env:
26
- GCP_PROJECT: visionhammer
27
- # EU migration (PLANO-MIGRACAO-EU-PROD.md): PROD moves to europe-west1. The
28
- # region comes from repo variables (PROD_REGION / QA_REGION); today's value
29
- # stays the default, so nothing changes until the variable is set.
30
- GCP_REGION: ${{ inputs.environment == 'prod' && (vars.PROD_REGION || 'us-central1') || (vars.QA_REGION || 'us-central1') }}
31
- WIF_PROVIDER: projects/1009366917257/locations/global/workloadIdentityPools/github-pool/providers/github-provider
32
- DEPLOYER_SA: github-deployer@visionhammer.iam.gserviceaccount.com
33
- AR_IMAGE: us-central1-docker.pkg.dev/visionhammer/visionhammer-docker/local-agent
30
+ # PROD lives in visionhammer-prod / europe-west1 (hardcoded: no repo variable
31
+ # can silently repoint PROD). QA lives in visionhammer / europe-west1 (QA_REGION
32
+ # only as an escape hatch) with its own deploy SA and the EU registry. The QA
33
+ # WIF provider is a repo variable so the pool/provider adopted by vh-infra
34
+ # (live/nonprod/_project) can be flipped without a commit:
35
+ # gh variable set QA_WIF_PROVIDER --body projects/1009366917257/locations/global/workloadIdentityPools/<pool>/providers/<provider>
36
+ GCP_PROJECT: ${{ inputs.environment == 'prod' && 'visionhammer-prod' || 'visionhammer' }}
37
+ GCP_REGION: ${{ inputs.environment == 'prod' && 'europe-west1' || (vars.QA_REGION || 'europe-west1') }}
38
+ WIF_PROVIDER: ${{ inputs.environment == 'prod' && 'projects/616585643354/locations/global/workloadIdentityPools/github/providers/github-actions' || (vars.QA_WIF_PROVIDER || 'projects/1009366917257/locations/global/workloadIdentityPools/vh-infra/providers/github-actions') }}
39
+ DEPLOYER_SA: ${{ inputs.environment == 'prod' && 'deploy-local-agent@visionhammer-prod.iam.gserviceaccount.com' || 'deploy-local-agent@visionhammer.iam.gserviceaccount.com' }}
40
+ AR_HOST: europe-west1-docker.pkg.dev
41
+ AR_IMAGE: ${{ inputs.environment == 'prod' && 'europe-west1-docker.pkg.dev/visionhammer-prod/vh-docker/local-agent' || 'europe-west1-docker.pkg.dev/visionhammer/visionhammer-docker-eu/local-agent' }}
34
42
 
35
43
  jobs:
36
44
  deploy:
@@ -51,10 +59,12 @@ jobs:
51
59
  fi
52
60
  echo "service=deepdebug-local-agent" >> "$GITHUB_OUTPUT"
53
61
  echo "tag=prod-${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
62
+ echo "image_tag=sha-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
54
63
  echo "traffic=canary" >> "$GITHUB_OUTPUT"
55
64
  else
56
65
  echo "service=deepdebug-local-agent-qa" >> "$GITHUB_OUTPUT"
57
66
  echo "tag=qa-${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
67
+ echo "image_tag=sha-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
58
68
  echo "traffic=live" >> "$GITHUB_OUTPUT"
59
69
  fi
60
70
 
@@ -68,35 +78,57 @@ jobs:
68
78
  uses: google-github-actions/setup-gcloud@v2
69
79
 
70
80
  - name: Configure Docker for Artifact Registry
71
- run: gcloud auth configure-docker us-central1-docker.pkg.dev --quiet
81
+ run: gcloud auth configure-docker "$AR_HOST" --quiet
72
82
 
73
- - name: Build & push image (multi-stage Node Dockerfile)
83
+ # QA and PROD: immutable tag sha-<full SHA> (a re-run of the same SHA fails
84
+ # the push by design); the deploy references the pushed DIGEST, not the tag.
85
+ - name: Build & push image (immutable sha tag, resolve digest)
74
86
  run: |
75
- IMG="${AR_IMAGE}:${{ steps.vars.outputs.tag }}"
76
- docker build --platform=linux/amd64 -t "$IMG" .
77
- docker push "$IMG"
78
- echo "IMG=$IMG" >> "$GITHUB_ENV"
87
+ TAGGED="${AR_IMAGE}:${{ steps.vars.outputs.image_tag }}"
88
+ docker build --platform=linux/amd64 -t "$TAGGED" .
89
+ docker push "$TAGGED"
90
+ DIGEST="$(docker inspect --format='{{index .RepoDigests 0}}' "$TAGGED")"
91
+ case "$DIGEST" in
92
+ "${AR_IMAGE}@sha256:"*) ;;
93
+ *) echo "::error::Unexpected digest reference: $DIGEST"; exit 1 ;;
94
+ esac
95
+ echo "IMG=$DIGEST" >> "$GITHUB_ENV"
79
96
 
80
- - name: Deploy to Cloud Run (image only; preserves env/secrets)
97
+ - name: Deploy to Cloud Run (QA, image only by digest, live)
98
+ if: inputs.environment != 'prod'
81
99
  run: |
82
- # PROD lands at 0% traffic behind a tag (canary); verify the tagged
83
- # URL, then promote with `gcloud run services update-traffic`.
84
- # QA keeps deploying live. Mirrors vh-api-gateway deploy-gcp.yml.
100
+ # QA (europe-west1) keeps deploying live. The service is Terraform-owned
101
+ # (vh-infra live/nonprod/qa/20-services: env, secrets, Filestore mount,
102
+ # min=max=1), so this is an image-only `services update` on the `app`
103
+ # container: never --set-env-vars / --update-env-vars / scaling flags.
104
+ # Service-level flags BEFORE --container.
85
105
  #
86
106
  # --vpc-egress is pinned: gcloud 572 flipped the template to
87
107
  # all-traffic on an image-only update; with no Cloud NAT that cuts
88
108
  # the service's internet egress.
89
- EXTRA=""
90
- if [ "${{ steps.vars.outputs.traffic }}" = "canary" ]; then
91
- EXTRA="--no-traffic --tag ${{ steps.vars.outputs.tag }}"
92
- fi
93
- gcloud run deploy "${{ steps.vars.outputs.service }}" \
109
+ gcloud run services update "${{ steps.vars.outputs.service }}" \
110
+ --project "$GCP_PROJECT" \
111
+ --region "$GCP_REGION" \
112
+ --vpc-egress=private-ranges-only \
113
+ --quiet \
114
+ --container=app \
115
+ --image="$IMG"
116
+
117
+ # PROD: `services update` with image only (Terraform owns env/secrets, so
118
+ # no --set-env-vars). Singleton service (min=max=1): the revision lands at
119
+ # 0% behind a tag; promote with `gcloud run services update-traffic`
120
+ # (--to-revisions) once the tagged revision is verified.
121
+ - name: Deploy to Cloud Run (PROD, digest, canary)
122
+ if: inputs.environment == 'prod'
123
+ run: |
124
+ gcloud run services update "${{ steps.vars.outputs.service }}" \
94
125
  --image "$IMG" \
95
126
  --project "$GCP_PROJECT" \
96
127
  --region "$GCP_REGION" \
97
128
  --vpc-egress=private-ranges-only \
98
- $EXTRA \
129
+ --no-traffic --tag "${{ steps.vars.outputs.tag }}" \
99
130
  --quiet
100
131
 
101
132
  - name: Summary
102
- run: echo "Deployed **${{ steps.vars.outputs.service }}** with \`$IMG\`" >> "$GITHUB_STEP_SUMMARY"
133
+ run: |
134
+ echo "Deployed **${{ steps.vars.outputs.service }}** (${GCP_PROJECT}/${GCP_REGION}) with \`$IMG\`" >> "$GITHUB_STEP_SUMMARY"
package/CHANGELOG.md CHANGED
@@ -8,6 +8,12 @@ platform side does not sign commands yet.
8
8
 
9
9
  ### Added (opt-in, off by default)
10
10
 
11
+ - **`LOCAL_AGENT_PROTOCOL_V2=true`** (F3-05b, default off). The Local Agent side of the CP <-> LA protocol v2:
12
+ register/hello/ready with resume, seq/ack/retransmission, chunking of `content.response`, allowlist, every
13
+ frame validated against the gateway's JSON Schemas, Ed25519 check of signed CP frames, `signatureMode` in
14
+ `hello`. Falls back to the v1 register on the same connection when the gateway does not speak v2; v1 commands
15
+ keep working. Transport only: no content handlers yet. See `docs/la-protocol-v2.md`. No behaviour change when off.
16
+
11
17
  - **`LOCAL_AGENT_WS_METADATA_ONLY=off|enforce`** (T4-10, D17, D19). `enforce`
12
18
  restricts the outbound WS channel to allowlisted metadata (ids, hashes,
13
19
  counts, state, cost), refuses legacy content commands and denies
@@ -0,0 +1,68 @@
1
+ # Protocol v2 on the Local Agent (F3-05b)
2
+
3
+ The Local Agent side of the CP <-> LA WebSocket protocol v2. The contract is owned by the gateway
4
+ (`docs/contracts/la-protocol-v2` of vh-api-gateway, F3-22); this repo keeps a verified copy in
5
+ `src/la-protocol-v2/contract/` (`CONTRACT_SOURCE.json` records the gateway commit and the sha256 of every
6
+ file; `npm test` fails if a copy was edited). Update it with `node scripts/sync-la-protocol-v2-contract.mjs
7
+ <gateway contract dir> --commit <sha>`; never edit the copy.
8
+
9
+ ## Flag
10
+
11
+ `LOCAL_AGENT_PROTOCOL_V2=true` (default **off**; unset, empty or unrecognised = off). With it off the tunnel in
12
+ `src/server.js` is the v1 client, byte for byte: no v2 module (nor Ajv, nor the schemas) is loaded.
13
+
14
+ It also needs `TENANT_ID` to be a UUID and the agent id (`authorizationId` of `~/.deepdebug/config.json`, or
15
+ `LOCAL_AGENT_ID`); without them v2 stays off with a warning. Tuning (clamped): `LOCAL_AGENT_V2_MAX_IN_FLIGHT`
16
+ (frames unacked, default 8), `LOCAL_AGENT_V2_HANDSHAKE_MS` (wait for `ready`, default 10000).
17
+
18
+ ## What it does
19
+
20
+ - **Handshake.** On each socket: `register` (`protocol:2`, version, capabilities, `signatureMode`, workspaces as
21
+ `{workspaceId, pathSha256}`, `resume{sessionId,lastRecvSeq}` when it has a session) then `hello`. The gateway
22
+ answers a signed `ready` (`sessionId`, `resumed`, `peerLastSeq`, limits). Paths never leave the machine, only hashes.
23
+ - **Fallback to v1** on the same connection: no `ready` in 10 s, or `error protocol_unsupported`. The v1
24
+ register is then sent, and v2 is not offered again for 10 min. A refusal that v1 would not fix
25
+ (`signature_mode_insufficient`, `clock_skew`, ...) does not fall back.
26
+ - **Signatures.** Every signed CP->LA frame (`ready` included) is checked like a v1 command: `paramsRaw` must equal
27
+ `payload` (duplicate keys refused), `sig` must be bound to the frame's tenant and workspace, and the Ed25519
28
+ signature must verify with the trusted keys (`VH-AGENT-SIG-V1`, method `WS`, path = type). `enforce` refuses,
29
+ `warn` logs and goes on, `off` does not verify. `hello.signatureMode` reports the real mode; the gateway
30
+ refuses `hybrid`/`client` tenants that are not in `enforce`.
31
+ - **Validation of everything.** Each received and each sent frame is validated with Ajv (2020-12, strict) against
32
+ the closed schemas, after the allowlist (21 types; direction checked) and the size limit (48 KiB). A refusal is an
33
+ `error` frame with the wire code only (never an echo of the content) and, for fatal codes, a WS close
34
+ `4400 + position of the code` (`timeout` = 4420).
35
+ - **Numbering and acks.** `seq` per direction from 1, `ack` piggybacked, an `ack` frame per accepted data frame
36
+ (`duplicate` for a retransmission, never executed twice: the last 512 `seq`->`msgId` pairs are kept).
37
+ A frame with no handler is acked `rejected protocol_unsupported`.
38
+ - **Sending.** At most N frames unacked (the rest wait in a bounded queue, 256), retransmitted after
39
+ `ackTimeoutMs` (same bytes: same seq and msgId), at most 3 times, then `timeout` to the caller and the socket
40
+ is closed 4420 (the frame is kept for the resume). A `heartbeat` frame every `heartbeatSeconds`; the text `ping`
41
+ of v1 stays as liveness.
42
+ - **Resume.** The session survives a dropped socket for 120 s (the gateway keeps it 90 s, same instance only).
43
+ `resumed:false` restarts both numberings at 1 and fails the frames that were in flight (`agent_offline`).
44
+ - **Chunking** (`src/la-protocol-v2/content.js`): `sendContent(session, {requestId, source})` splits a `Buffer` (or
45
+ `{size, read}`, read lazily) into `content.response` chunks of at most 32 KiB raw (43692 base64 chars, frame
46
+ under 48 KiB), at most 64 per response (2 MiB), `chunk{index,total,last}`, `byteLength` = decoded size. The
47
+ chunk rules of the contract are enforced on what is sent; a stream cannot continue across a reset.
48
+ - **v1 commands on a v2 connection** (decision of 2026-10-29: transition, v1 is decommissioned later). A frame
49
+ without `schemaVersion` is not a v2 frame: it goes to the existing v1 handler (`handleWsCommand`), which
50
+ answers in the v1 shape. **The gateway (F3-05a) still refuses to send v1 commands to a v2 agent
51
+ (`protocol_mismatch`)**: this is ready on the LA side, but it will only be exercised once the gateway sends them.
52
+
53
+ ## What it does NOT do
54
+
55
+ No handler of `content.request` (F3-07), no DLP on what is sent (F3-09, must run before `sendContent` is
56
+ called), no log/incident detector (F3-11b), no `job.assign` runtime (F3-08). Those frames are acked
57
+ `rejected protocol_unsupported`. `sha256` of `content.response` is not sent (the contract does not say what it covers).
58
+
59
+ ## Tests
60
+
61
+ `npm test` (node:test), all in `test/la-protocol-v2/`:
62
+ `contract.test.js` (all 134 vectors of F3-22, valid and invalid, plus the drift check), `traces.test.js` (the 24
63
+ traces), `session-inbound.test.js`, `session-outbound.test.js`, `link.test.js`, `tunnel-e2e.test.js` (the real
64
+ `src/server.js` against a reference CP; flag off = v1 frames). The reference CP (`test/la-protocol-v2/helpers/reference-cp.js`)
65
+ is a Node model of the gateway rules, not the gateway.
66
+
67
+ **Interoperability with the real gateway session** (`LaV2Session`, Java, real Reactor Netty socket on loopback, real
68
+ Ed25519 signer): `tools/interop/` (`README.md`). Not part of `npm test` (needs a gateway checkout, JDK 21, Maven).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepdebug-local-agent",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "DeepDebug Local Agent - AI-powered code debugging assistant",
5
5
  "private": false,
6
6
  "type": "module",
@@ -34,6 +34,7 @@
34
34
  "dependencies": {
35
35
  "@anthropic-ai/sdk": "^0.39.0",
36
36
  "@modelcontextprotocol/sdk": "^1.0.4",
37
+ "ajv": "^8.20.0",
37
38
  "body-parser": "^1.20.3",
38
39
  "chokidar": "^3.6.0",
39
40
  "cors": "^2.8.5",
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ // scripts/sync-la-protocol-v2-contract.mjs - copies the CP <-> LA protocol v2 contract (F3-22) from a
3
+ // vh-api-gateway checkout into this repo and records where it came from.
4
+ //
5
+ // node scripts/sync-la-protocol-v2-contract.mjs <gateway-contract-dir> --commit <sha> [--tip <sha>]
6
+ //
7
+ // <gateway-contract-dir> is docs/contracts/la-protocol-v2 of vh-api-gateway (a worktree of release/qa, or
8
+ // the output of `git archive`). The gateway folder stays the only source of truth: never edit the copies.
9
+ // src/la-protocol-v2/contract/ *.schema.json + index.json (shipped: the runtime validates against them)
10
+ // test/contract/la-protocol-v2/ vectors/ (valid + invalid messages, traces) used by the tests
11
+ // src/la-protocol-v2/contract/CONTRACT_SOURCE.json commit + sha256 of every copied file (a test fails on drift)
12
+ import crypto from "node:crypto";
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
18
+ const args = process.argv.slice(2);
19
+ const src = args.find((a) => !a.startsWith("--"));
20
+ const opt = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : undefined; };
21
+ const commit = opt("commit");
22
+ if (!src || !commit || !/^[0-9a-f]{40}$/.test(commit)) {
23
+ console.error("usage: sync-la-protocol-v2-contract.mjs <gateway docs/contracts/la-protocol-v2> --commit <40-hex sha> [--tip <sha>]");
24
+ process.exit(2);
25
+ }
26
+ const sha256 = (buf) => crypto.createHash("sha256").update(buf).digest("hex");
27
+ const runtimeDir = path.join(ROOT, "src/la-protocol-v2/contract");
28
+ const vectorsDir = path.join(ROOT, "test/contract/la-protocol-v2");
29
+ fs.rmSync(vectorsDir, { recursive: true, force: true });
30
+ fs.mkdirSync(runtimeDir, { recursive: true });
31
+ fs.mkdirSync(vectorsDir, { recursive: true });
32
+
33
+ const files = {};
34
+ for (const name of fs.readdirSync(src).filter((n) => n.endsWith(".schema.json") || n === "index.json" || n === "README.md")) {
35
+ const buf = fs.readFileSync(path.join(src, name));
36
+ fs.writeFileSync(path.join(runtimeDir, name), buf);
37
+ files[name] = sha256(buf);
38
+ }
39
+ function copyTree(from, to, rel) {
40
+ fs.mkdirSync(to, { recursive: true });
41
+ for (const e of fs.readdirSync(from, { withFileTypes: true })) {
42
+ if (e.isDirectory()) { copyTree(path.join(from, e.name), path.join(to, e.name), `${rel}/${e.name}`); continue; }
43
+ const buf = fs.readFileSync(path.join(from, e.name));
44
+ fs.writeFileSync(path.join(to, e.name), buf);
45
+ files[`${rel}/${e.name}`] = sha256(buf);
46
+ }
47
+ }
48
+ copyTree(path.join(src, "vectors"), path.join(vectorsDir, "vectors"), "vectors");
49
+ fs.writeFileSync(path.join(runtimeDir, "CONTRACT_SOURCE.json"), JSON.stringify({
50
+ repo: "VISIONHAMMER/vh-api-gateway",
51
+ path: "docs/contracts/la-protocol-v2",
52
+ branch: "release/qa",
53
+ commit,
54
+ ...(opt("tip") ? { branchTip: opt("tip") } : {}),
55
+ note: "Copied by scripts/sync-la-protocol-v2-contract.mjs. Do not edit: change the gateway folder and sync again.",
56
+ files,
57
+ }, null, 2) + "\n");
58
+ console.log(`synced ${Object.keys(files).length} files from ${commit.slice(0, 8)}`);
@@ -0,0 +1,45 @@
1
+ /**
2
+ * chunks.js - the chunk rules of protocol v2 (README of the contract: "chunks") for ONE direction.
3
+ *
4
+ * A `content.response` is a stream of frames with the same requestId: chunk.index 0.., chunk.total constant,
5
+ * chunk.last true only on the last index, nothing after last, every payload.data at most chunkRawBytes
6
+ * decoded. The Local Agent applies them to what it SENDS (so it can never emit a stream the gateway would
7
+ * refuse with a fatal error); the same class is how the tests check the traces.
8
+ */
9
+ const MAX_STREAMS = 256;
10
+
11
+ /** How many chunks a payload of `size` bytes needs. An empty payload is still one (empty) chunk. */
12
+ export function chunkCount(size, chunkRawBytes) {
13
+ return Math.max(1, Math.ceil(size / chunkRawBytes));
14
+ }
15
+
16
+ export class ChunkStreamTracker {
17
+ constructor({ chunkRawBytes = 32768, maxChunks = 64 } = {}) {
18
+ this.chunkRawBytes = chunkRawBytes;
19
+ this.maxChunks = maxChunks;
20
+ this.streams = new Map(); // requestId -> { total, next, done }
21
+ }
22
+
23
+ /**
24
+ * Checks one chunk against the stream state and, when it is fine, records it.
25
+ * @returns {null | "chunk_out_of_order" | "chunk_inconsistent"}
26
+ */
27
+ accept(requestId, chunk, decodedBytes) {
28
+ const { index, total, last } = chunk;
29
+ const st = this.streams.get(requestId);
30
+ if (st && st.done) return "chunk_out_of_order";
31
+ if (st && st.total !== total) return "chunk_inconsistent";
32
+ if (total > this.maxChunks) return "chunk_inconsistent";
33
+ const expected = st ? st.next : 0;
34
+ if (index !== expected) return "chunk_out_of_order";
35
+ if (last !== (index === total - 1)) return "chunk_inconsistent";
36
+ if (decodedBytes > this.chunkRawBytes) return "chunk_inconsistent";
37
+ this.streams.delete(requestId);
38
+ this.streams.set(requestId, { total, next: index + 1, done: last });
39
+ while (this.streams.size > MAX_STREAMS) this.streams.delete(this.streams.keys().next().value);
40
+ return null;
41
+ }
42
+
43
+ forget(requestId) { this.streams.delete(requestId); }
44
+ clear() { this.streams.clear(); }
45
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * config.js - LOCAL_AGENT_PROTOCOL_V2 flag (F3-05b).
3
+ *
4
+ * false (default) the WS tunnel is the v1 client, byte for byte: no v2 module is loaded.
5
+ * true the tunnel offers protocol v2 first (register/hello/ready, see session.js) and
6
+ * falls back to the v1 register on the SAME connection when the gateway does not
7
+ * answer `ready` or answers `protocol_unsupported`.
8
+ *
9
+ * Unset/empty/unrecognised = false (an unknown value must never switch the transport).
10
+ */
11
+ export const PROTOCOL_V2_FLAG = "LOCAL_AGENT_PROTOCOL_V2";
12
+
13
+ const TRUE = new Set(["true", "1", "yes", "on"]);
14
+ const FALSE = new Set(["", "false", "0", "no", "off"]);
15
+
16
+ export function resolveProtocolV2Config(env = process.env, warn = console.warn) {
17
+ const raw = String(env[PROTOCOL_V2_FLAG] ?? "").trim().toLowerCase();
18
+ if (TRUE.has(raw)) return { enabled: true };
19
+ if (!FALSE.has(raw)) warn(`[la-v2] unrecognised ${PROTOCOL_V2_FLAG} value; protocol v2 stays off`);
20
+ return { enabled: false };
21
+ }
22
+
23
+ /** The per-machine authorization id the gateway checks as `agentId` (config.json `authorizationId`). */
24
+ export function resolveAgentId(env = process.env, fileCfg = {}) {
25
+ const raw = String(env.LOCAL_AGENT_ID || fileCfg.authorizationId || "").trim().toLowerCase();
26
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(raw) ? raw : null;
27
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * content.js - sends content to the control plane as a stream of `content.response` chunks (the relay of
3
+ * the design: never persisted or logged by the CP). TRANSPORT ONLY: no handler of `content.request` lives
4
+ * here (F3-07) and nothing here decides what may leave the machine (DLP is F3-09, before this is called).
5
+ *
6
+ * Rules (contract): <= 32 KiB raw per chunk (43692 base64 chars, frame <= 48 KiB), <= 64 chunks (2 MiB per
7
+ * response), chunk.index from 0, total constant, last only on the final one, byteLength = decoded size.
8
+ * Each chunk is its own data frame: numbered, acked, retransmitted. At most `maxInFlight` chunks are in
9
+ * flight and the next one is not even read until a slot opens (bounded memory for any size).
10
+ */
11
+ import { ProtocolError } from "./errors.js";
12
+ import { chunkCount } from "./chunks.js";
13
+
14
+ /**
15
+ * @param {import("./session.js").V2Session} session
16
+ * @param {object} o
17
+ * @param {string} o.requestId the content.request being answered (lower-case UUID)
18
+ * @param {Buffer | {size:number, read:(offset:number, length:number)=>Promise<Buffer>}} o.source
19
+ * @param {string} [o.contentType] one of the contract's content types
20
+ * @param {string} [o.workspaceId]
21
+ * @param {AbortSignal} [o.signal] abort stops reading and drops the chunks still waiting for a slot
22
+ * @returns {Promise<{chunks:number, bytes:number}>} resolves when the last chunk was acked
23
+ */
24
+ export async function sendContent(session, { requestId, source, contentType = "application/octet-stream", workspaceId, signal }) {
25
+ const size = Buffer.isBuffer(source) ? source.length : source.size;
26
+ if (!Number.isSafeInteger(size) || size < 0) throw new ProtocolError("internal", "source size");
27
+ const { chunkRawBytes, maxChunks } = session.limits;
28
+ const total = chunkCount(size, chunkRawBytes);
29
+ if (total > maxChunks) throw new ProtocolError("payload_too_large", "response above the chunk limit");
30
+ const acks = [];
31
+ try {
32
+ for (let index = 0; index < total; index++) {
33
+ if (signal && signal.aborted) throw new ProtocolError("agent_offline", "cancelled");
34
+ const offset = index * chunkRawBytes;
35
+ const length = Math.min(chunkRawBytes, size - offset);
36
+ const bytes = Buffer.isBuffer(source) ? source.subarray(offset, offset + length) : await source.read(offset, length);
37
+ if (!Buffer.isBuffer(bytes) || bytes.length !== length) throw new ProtocolError("internal", "source returned a wrong length");
38
+ const h = await session.enqueue("content.response", {
39
+ status: "ok", contentType, encoding: "base64", data: bytes.toString("base64"), byteLength: length, totalBytes: size,
40
+ }, { requestId, workspaceId, chunk: { index, total, last: index === total - 1 } });
41
+ acks.push(h.acked);
42
+ }
43
+ } catch (e) {
44
+ session.cancelWaiting(requestId);
45
+ for (const a of acks) a.catch(() => {});
46
+ throw e;
47
+ }
48
+ await Promise.all(acks);
49
+ return { chunks: total, bytes: size };
50
+ }
51
+
52
+ /** A failed answer: one empty chunk with the status (error | not_found | forbidden) and a wire error code. */
53
+ export async function sendContentFailure(session, { requestId, status = "error", errorCode = "internal", workspaceId }) {
54
+ await session.send("content.response", {
55
+ status, errorCode, encoding: "base64", data: "", byteLength: 0,
56
+ }, { requestId, workspaceId, chunk: { index: 0, total: 1, last: true } });
57
+ }
@@ -0,0 +1,81 @@
1
+ {
2
+ "repo": "VISIONHAMMER/vh-api-gateway",
3
+ "path": "docs/contracts/la-protocol-v2",
4
+ "branch": "release/qa",
5
+ "commit": "b090144e19b1da0182554553b18f321452f03bd6",
6
+ "branchTip": "7043f75e3ed1b201e2c958fd52f721b5a1177efe",
7
+ "note": "Copied by scripts/sync-la-protocol-v2-contract.mjs. Do not edit: change the gateway folder and sync again.",
8
+ "files": {
9
+ "README.md": "41dd3cbceccb487ca30da5838841f04d8abffcc81c31424da833114ab646b291",
10
+ "ack.schema.json": "d6dfdaa8b1ec2dd47db9e858cd047ca860196ab6b0ad350f8ccb5d2596441ccf",
11
+ "alert.schema.json": "01a8a77840be8ab7901ed7d07c3d68a2295bfaeb638694564e19e60be1747054",
12
+ "approval.decision.schema.json": "a9c7bc74d146465afde1d5830839165a02f232b830b5f6cfd8c8e91fb6da1563",
13
+ "audit.event.schema.json": "e9ceb58ea00077151373955957b8319324de488c80da4063f6d0dcb40a8b3ed0",
14
+ "config.update.schema.json": "f5318f24230f542c76a3cb996d08f33210c0b2cc2fc55ad73c4f95648582033f",
15
+ "content.request.schema.json": "9966b55ec051832ac93ee43e9cd226e4f0efaeb49741947bfc48193e40e00a32",
16
+ "content.response.schema.json": "c9a66ac9cbbcaec7fc182b3e3fa23b34877bd342592521c7e393779230332231",
17
+ "defs.schema.json": "f19fd27fd103d8da02de4506c086263c6236250c5c127aa3aab41833a90b743a",
18
+ "envelope.schema.json": "670fb26c7d532475da144185e5100b3a2ffe0d267451b1ef9debadf31b33ab79",
19
+ "error.schema.json": "b1fd663d4a1ea73d88cb575422f15f8c7279001f9385ebb2d5e1889b5d5ef878",
20
+ "heartbeat.schema.json": "8aff84a1ef3ca56bdff8819abb3b0ab01a2547814c2ea2f9ee893cb08f7a08f1",
21
+ "hello.schema.json": "bd500320f01ff10da49ded6f99c7e8081252019984afb3c178e27003df20b963",
22
+ "incident.candidate.schema.json": "b292a85917b5958fc6de10a2895732e30a35420385623930c78e47341862dc62",
23
+ "index.json": "55543ecf492b1ffe2b9a78075890ee05518d3539fbcaeb04e8b2da8ff37c9130",
24
+ "job.assign.schema.json": "fdebb4ae98bf38a2c723281c1cf9477f71d877171df9a55b0baeb0b65244f60c",
25
+ "job.cancel.schema.json": "40ef64373283e17306d857c9a40081689786015294b0b88ab8f68f063bd2cfb8",
26
+ "job.event.schema.json": "493d08f9db4f7ffd4a97403ee33ca4a73a65dc8f774d8f18ef0a137fdb5fc470",
27
+ "job.result.schema.json": "9cbc61211482557283b8f4df44e79011ead1abbacdb2d986138199c747edc043",
28
+ "log.stats.schema.json": "4b33b111946a19793a60756793294e90911b06f7050e48821759f2497ec17641",
29
+ "ready.schema.json": "6e8bd8e1a5ce1b840d35af911ae09b8f58ad9de59eb33cd2b7777859892813f4",
30
+ "register.schema.json": "e75a4aa9d5d750e1473f71ab2f37d8e9a82fcfab3e8ff1c35118d0c08916e795",
31
+ "scan.upload.schema.json": "b1bba86ee362f73afc94c8c216977c12f67f770490e81002d14789397692f23b",
32
+ "scan.uploaded.schema.json": "f4c6aed7b9363b3ce1a9e14f9c4f4fb003bcf104b60acf74421dd0c75fc5d229",
33
+ "webhook.forward.schema.json": "33d4ee238855339850ff198d1078087f3b2f48c8f290b71def93cc02dd32c985",
34
+ "vectors/ack.vectors.json": "242ec2b8ff0c748eaaedce63e0015cf5bd9a7092d1055b0670ce6b95403b54b2",
35
+ "vectors/alert.vectors.json": "0bf179ff390bbecf07a117b80237cb7ac8778864e5780f36d68c8c9a64db8f04",
36
+ "vectors/approval.decision.vectors.json": "338115973bf2c4390d2bb33ce8f4079f3132cd739cd5505e1e873fdc252d2271",
37
+ "vectors/audit.event.vectors.json": "d05c1820103eef041a84eb8b13d9a67a8a5b54b9b54ee3b6e43fd4ef15ed7000",
38
+ "vectors/config.update.vectors.json": "8a5088d547e3eb4e460b25a5e413caaa02d4402a601340e279166d8c17758e1c",
39
+ "vectors/content.request.vectors.json": "6922748edfc630ce881a1b79d24827de03f849e05c441ef7eecc684bf2238e58",
40
+ "vectors/content.response.vectors.json": "35ef18edbcec24c029781e97d60dcb0b9111697fa3a5a2a8dfd159744e7f8258",
41
+ "vectors/cross-type.vectors.json": "f7ed403d3e3f53150b715a5423fde1bed708e8c6245709ed4ad74389dc8c3307",
42
+ "vectors/error.vectors.json": "f5fd7954f2c1736ee0943eb330c2f85b7fdd7a8959e4f043f9cc9e9bcda39aa5",
43
+ "vectors/heartbeat.vectors.json": "cfd2e048139c67aa925f1fa94c05326e75248839645c1a1e4d6d3bb60cafa624",
44
+ "vectors/hello.vectors.json": "51067dc645a2e9d8116997b36e6f3fe36401d27f3ee3c18de32ec30f8a052c5f",
45
+ "vectors/incident.candidate.vectors.json": "0dd75ef396bf7b7911d20b3ab220469701f07866bb032ee31af58523c3633ab6",
46
+ "vectors/job.assign.vectors.json": "e510c2cc6d17238d477429c255d828c0339b504e39a3a50d4b6ca980e262d9ef",
47
+ "vectors/job.cancel.vectors.json": "0fc9f99c8662435e9425389068adf418273489dc73928a21eae1b283256b9bc5",
48
+ "vectors/job.event.vectors.json": "03a2a2435918ba561821916bf4e435f2780210900139ecf68091acac6abf200c",
49
+ "vectors/job.result.vectors.json": "05995fbe65e5357705d75e9b660b517f54c6902069d36b62190b1b79f283e7a5",
50
+ "vectors/log.stats.vectors.json": "a8a67e09ef0f87b81f4028b77427ed7afd809c3f8534b8423a712b2ceef9a27f",
51
+ "vectors/ready.vectors.json": "a584da17e714a13c3583256773ef0be007660c33c62a456e27568def391e7cef",
52
+ "vectors/register.vectors.json": "e2a696efc547aa2f7242128d1a860238954f9e9a1330f10b567122d7b3da0365",
53
+ "vectors/scan.upload.vectors.json": "e8a6f73fe91ac58a3222b68cf28124c1254aa4f00c3ef8f472deb7de31cab76e",
54
+ "vectors/scan.uploaded.vectors.json": "7314ea15b1afa7b9625048fb7e245492d8910bc0dae8a52ea62afe2a1e240382",
55
+ "vectors/traces/invalid-agent-mismatch.json": "5f17b867de71c1bcc6c42723acb53a50338c0e491193499ea25c2220ea4fa558",
56
+ "vectors/traces/invalid-chunk-after-last.json": "0dcc3f3cce605a490d65e96592668381fa9a191d908a26ede998717494eb2f2e",
57
+ "vectors/traces/invalid-chunk-byte-length-mismatch.json": "d328ad05b22d3abaf0497ee8482a07a08ac90a4ede94edf754e8962ca8ea201e",
58
+ "vectors/traces/invalid-chunk-last-flag.json": "6c59a361313e966999ec25c80733758d86befb5f3607528634a148291844d212",
59
+ "vectors/traces/invalid-chunk-out-of-order.json": "c6560290755d86b524316823d32fcbd1420c170b13e198813c3b7e7b7f32ecb6",
60
+ "vectors/traces/invalid-chunk-total-changes.json": "9b7ade197252275725bc0754e40eadc12a289c063fed13a2905c55583a27a767",
61
+ "vectors/traces/invalid-clock-skew.json": "de16e0bd72e08bb03d44d5f2f04a238dcd38447ff1690f8acfe712ca3fe2028b",
62
+ "vectors/traces/invalid-event-before-ready.json": "17081f197fab5f4490b15a088f2bce620921007b5fdd7ed63bd988899d674883",
63
+ "vectors/traces/invalid-first-frame-not-register.json": "cf41c2d0e67f6df382790dab13bb9e49db99a34a88c1f9d108214be5df1e4ea3",
64
+ "vectors/traces/invalid-frame-over-48kib.json": "912e37c9a326ad92284ba22af752269cb5dd52ed06cdc22b17fd3b74a8423734",
65
+ "vectors/traces/invalid-la-sends-job-assign.json": "4ce5313e125e2afa920e923c2b5b524f627f47d06961a250a8dc642b79225252",
66
+ "vectors/traces/invalid-params-raw-differs-from-payload.json": "1220630372c78a41d77b2653b8f9025cb87d601c199adb03edbf007e6e727ad1",
67
+ "vectors/traces/invalid-payload-above-limit.json": "99f3834cfea69bc6ffa169bd6a2ed915377331205f8e991ceacbf21b3525fe96",
68
+ "vectors/traces/invalid-resume-restarts-seq.json": "795964093051511c00538f88774956b471a35338b92a0a180bedb33d460f2d83",
69
+ "vectors/traces/invalid-seq-gap.json": "9c85719b07cb7309752ffaa33b6c6cb1b278c45cd72e21dbc788c56af5e4502f",
70
+ "vectors/traces/invalid-seq-repeated.json": "4a4dd18d5b9de61a6cec735229b7991d6de4f3598acd7b16541eaca44be4556d",
71
+ "vectors/traces/invalid-sig-tenant-mismatch.json": "5d8895cbc9c1b91be31a851d009d1f2b832f9735c3713b131ac9837c90b4a8e7",
72
+ "vectors/traces/invalid-signature-warn-with-enforce-required.json": "71b5b3dc5dce52417173934db1e0de6542ecae5798e87124d54a8017493312e1",
73
+ "vectors/traces/invalid-tenant-mismatch.json": "dcc04ea423bddef20524ead7bfd146a5d9c42fe474c9216dc38c90544d291151",
74
+ "vectors/traces/invalid-type-outside-allowlist.json": "e492d326cfa044d3fc9f7e48dee63a52b715a62ba4867fc5ce21f1d86e3c435f",
75
+ "vectors/traces/valid-content-chunks-in-order.json": "c5fc301527b062b30c625da2798c7cfab3e3debac29a36f6ec7c699705267ab3",
76
+ "vectors/traces/valid-handshake-and-job.json": "9aa3c423c496403ca77d63965f1805f0a57ea9f6f8a01351310515b265aeb9cc",
77
+ "vectors/traces/valid-resume-continues-seq.json": "8aa90f24598f8f2d87a5355ec58c3531d16fd11ad41c782f53eca063372b704a",
78
+ "vectors/traces/valid-retransmit-is-idempotent.json": "924e9d296a67f0006c289e7cc978b9529ae1aa03bec2cd331a62f31354a90257",
79
+ "vectors/webhook.forward.vectors.json": "38d5b53b8fb4ecdb051e2c594afc52eb8b2044e4e6aae6ffb0057fbda33bcd46"
80
+ }
81
+ }
@@ -0,0 +1,103 @@
1
+ # Gateway <-> Local Agent WebSocket protocol v2 (contract, F3-22)
2
+
3
+ Machine-readable contract for the control-plane (CP, gateway) <-> Local Agent (LA) channel, shared by both
4
+ sides. Design: vault `10 Library/Plans/Fase 3 Desenho Tecnico 2026-10-28.md` sec. 4.2. Status: the gateway
5
+ implements it behind `deepdebug.agent-ws.protocol-v2.enabled` (default off, F3-05a); the LA side (F3-05b) does not
6
+ exist yet. v1 is untouched. The gateway packages `*.schema.json` + `index.json` from this folder as classpath
7
+ `la-protocol-v2/` (pom resource; both Dockerfiles copy this folder), so this folder stays the only source.
8
+
9
+ ## Files
10
+ - `envelope.schema.json`: common frame, closed (`additionalProperties:false`). `<type>.schema.json` (21):
11
+ one per allowlisted type, each `$ref`s the envelope and closes `payload`. `defs.schema.json`: shared types.
12
+ - `index.json`: allowlist, direction, signed/control flags, content class per type, and the limits below.
13
+ - `vectors/<type>.vectors.json` (+ `cross-type`): valid and invalid messages with `expect` (`valid`, error `code`,
14
+ optional JSON Schema `keyword`, optional session `rule`). `vectors/traces/*.json`: whole sessions for the stateful rules.
15
+
16
+ ## Envelope
17
+ `{schemaVersion:2, type, msgId(uuid), seq, ack?, requestId?, tenantId, agentId?, workspaceId, ts, chunk?, paramsRaw?, sig?, payload}`.
18
+ CP->LA frames carry `paramsRaw` (the exact signed JSON, which must equal `payload`) and `sig` (Ed25519, same
19
+ `VH-AGENT-SIG-V1` canonical as v1); LA->CP frames carry `agentId` (required in EVERY LA->CP frame, `ack` and `error` included: the gateway refuses one without it as
20
+ `agent_mismatch`; the per-type schemas of `ack`/`error` require it for both directions, and the gateway always sets it on its own)
21
+ and never a signature (F3-X3 may add one).
22
+ `ack` = highest contiguous `seq` received from the peer, piggybacked on any frame. The design's flat fields
23
+ live under `payload`; its `reqId` is `requestId`; `chunkNo,last` is `chunk{index,total,last}` (index from 0).
24
+
25
+ ## Limits (index.json)
26
+ Frame <= 48 KiB UTF-8; chunk <= 32 KiB raw (43692 base64 chars); <= 64 chunks (2 MiB per response);
27
+ ack timeout 5 s, <= 3 retransmissions; handshake steps 10 s; `ping`/`heartbeat` 30 s, session dead after 90 s
28
+ (= Redis `la:session` TTL); clock skew <= 60 s. Duplicate detection (gateway, F3-05a): the last 512 `seq`->`msgId` pairs
29
+ per session, in memory (`dedupe-entries`); the 10 min Redis window of the design (`dedupeWindowSeconds` in `index.json`)
30
+ is NOT implemented, a replay older than those 512 frames is `seq_replayed`. CP->LA frames repeat the payload in
31
+ `paramsRaw`, so their effective payload budget is about 20 KiB; larger inputs travel by `ref`.
32
+
33
+ ## State machine
34
+ `AWAIT_REGISTER -> AWAIT_HELLO -> AWAIT_READY -> READY`, after the WS upgrade authenticates (Bearer, as v1).
35
+ 1. LA sends `register` (`protocol:2`, version, capabilities, `signatureMode`, `workspaces[]` with `pathSha256`,
36
+ optional `resume{sessionId,lastRecvSeq}`). 2. LA sends `hello` (state: `signatureMode`, Graphify, disk, queue, clock).
37
+ 3. CP validates (tenant, agent, skew, `signatureMode=enforce` when the tenant is `hybrid`/`client`) and answers
38
+ `ready{sessionId, resumed, peerLastSeq, limits}`. Handshake and control frames (`register hello ready ack error`)
39
+ use `seq 0`; every other frame has `seq` 1.. consecutive per direction. Text `ping`/`pong` stays as liveness.
40
+ - **Resume:** with `resume` and a live Redis session the CP answers `resumed:true`; the LA continues at
41
+ `peerLastSeq+1` and re-sends unacked frames; otherwise `resumed:false`, both restart at 1 and in-flight requests fail.
42
+ - **Rules (reference model in `ProtocolV2ContractTest`, same order):** size, allowlist, schema, tenant/agent, direction,
43
+ handshake state, `seq` (same seq + same `msgId` = idempotent duplicate, re-ack; same seq + other `msgId` =
44
+ `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
45
+ the base64-DECODED `payload.data`, else `chunk_inconsistent`: JSON Schema cannot state it, so the vector files carry
46
+ `expect.rule:"byteLength-decoded"` and both validators apply it).
47
+ - **Errors** (`error{code,retryable}`, then WS close 4400-4499 when fatal): `bad_frame unknown_type schema_violation
48
+ frame_too_large payload_too_large wrong_direction handshake_violation not_ready seq_gap seq_replayed
49
+ chunk_out_of_order chunk_inconsistent tenant_mismatch agent_mismatch params_mismatch signature_mode_insufficient
50
+ clock_skew protocol_unsupported unauthorized agent_offline timeout rate_limited internal`.
51
+ - **Timeouts:** `content.request` 10 s to first chunk, 30 s total, then `timeout`; v1-style commands keep 120 s.
52
+
53
+ ## Compatibility with v1
54
+ Same endpoint, auth and signature. A v2 LA sends the `register` above; a gateway with v2 off (the default) ignores
55
+ it (it has no `requestId`/`workspacePath`) and the LA, with no `ready` in 10 s, falls back to the legacy
56
+ `{type:"register",tenantId,port,workspacePath}` and v1 commands. A v2 gateway treats a frame without `schemaVersion`
57
+ as v1 for that session. v1 command names are not v2 types (`mcp.execute-command` is rejected). v2 needs a CP
58
+ signing key: without one the gateway does not negotiate v2.
59
+
60
+ ## What never goes in the envelope
61
+ Everything is closed: no free-text fields, no extra properties, free strings <= 512 chars (identifiers, enums,
62
+ hashes, counts). Client content may exist only in three declared fields (`x-content-class`, pinned by the test):
63
+ `content.response.data` (relay, LA->CP, never persisted or logged by the CP), `job.assign.inputs[].inline` and
64
+ `webhook.forward.bodyB64` (transit, CP->LA, `transient:true`, not persisted; `paramsRaw` echoes them).
65
+ Long non-content strings are marked `x-large-metadata` (`paramsRaw`, `signedPutUrl`, `vhapr1` token). `error.detail` <= 200 chars
66
+ and `audit.event` have no summary text; stack traces, diffs, logs and paths go only through `content.*`.
67
+
68
+ ## Verify
69
+ - Java (gateway, runs in `mvn test`): `mvn -o test -Dtest=ProtocolV2ContractTest` (JDK 21).
70
+ - Node (LA, F3-05b; Ajv 2020, no LA code needed): `cd docs/contracts/la-protocol-v2 && npm i --no-save ajv@8 &&
71
+ node tools/validate-vectors.cjs`. Traces are language-neutral; the LA must reproduce each `expect.error`.
72
+ - Changing a schema = change the vectors in the same PR; adding a type = update `index.json` and the envelope enum.
73
+
74
+ ## Gateway behaviour (F3-05a) and gaps the code revealed
75
+ Each frame is checked in the order of the Rules (size, parse with duplicate keys refused, allowlist, schema, tenant,
76
+ agent, direction, state, seq, chunks); `error` frames carry the wire code only, never the content. CP->LA frames are
77
+ built from the unsigned payload and validated against these schemas before they are written, so the CP cannot send a field the
78
+ schema does not know. That runtime check covers the data frames sent through the API (`job.*`, `content.request`, ...); the
79
+ handshake and control frames (`ready`, `ack`, `error`) are assembled by fixed code and are NOT schema-checked at runtime: the
80
+ test transport validates every frame the session writes, and the tests fail on a violation.
81
+ A fatal error closes the socket with 4400 + the position of its code in the `errorCode` enum (a test pins the order; `timeout` = 4420). Resume works only on the same gateway instance (state is in memory, 90 s); elsewhere `resumed:false`. Not done in
82
+ F3-05a: relaying a v2 stream between gateway instances, the HTTP relay (F3-06), LA handlers (F3-05b/07). Gaps, for
83
+ F3-05b to confirm (none changes a schema):
84
+ 1. **`byteLength` is the decoded size of `data`**; the gateway refuses a chunk where it differs (`chunk_inconsistent`).
85
+ The F3-22 vectors had 27 for a 26-byte payload and a `max-size-chunk` that decoded to 32769 bytes (fixed in F3-05a); the
86
+ above-limit vectors still disagreed and a non-canonical 43693-char `data` was in use: all fixed, and
87
+ `ProtocolV2ContractTest` now fails on any vector or trace whose `byteLength` differs from the decoded size.
88
+ 2. **`content.request` is acked implicitly by its first `content.response`**, also when the request timed out or was
89
+ cancelled meanwhile. The ack timeout is 5 s but the first chunk may take 10 s: an LA that is slow to start must
90
+ send an explicit `ack` (`accepted`) first, else the request is sent again (same seq and msgId) up to 3 times.
91
+ 3. **Retransmissions are re-signed**: same `seq` and `msgId`, new `sig.nonce`/`timestamp` (the LA keeps a nonce LRU
92
+ and a 60 s skew). The LA must dedupe by `seq`+`msgId` and re-ack, never execute twice.
93
+ 4. **No window in `ready.limits` and no `content.cancel` type.** The gateway bounds each request with a queue of
94
+ `maxChunksInFlight` (16): a consumer that falls further behind loses that request (`rate_limited`) while the
95
+ chunks still in flight are validated, acked and dropped. Cancelling a request cannot stop the LA; a cancel
96
+ message (or a window in `ready.limits`) is a contract change for later.
97
+ 5. `content.response.sha256` has no stated scope (chunk or whole content); the gateway passes it through unchecked.
98
+ 6. After 3 unanswered retransmissions the gateway closes with 4420 (`timeout`) and keeps the frame for a resume; the
99
+ LA must reconnect with `resume` to receive it. A silent connection (no frame and no text `ping` for 90 s) is closed
100
+ the same way.
101
+ 7. v1 commands are refused for an LA that speaks v2 (`protocol_mismatch`): until F3-06/07 move the callers, a v2 LA
102
+ cannot serve `sendCommand`, so the LA must stay on v1 for tenants whose flows still use v1 commands.
103
+