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.
- package/.github/workflows/deploy-gcp.yml +58 -26
- package/CHANGELOG.md +6 -0
- package/docs/la-protocol-v2.md +68 -0
- package/package.json +2 -1
- package/scripts/sync-la-protocol-v2-contract.mjs +58 -0
- package/src/la-protocol-v2/chunks.js +45 -0
- package/src/la-protocol-v2/config.js +27 -0
- package/src/la-protocol-v2/content.js +57 -0
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +81 -0
- package/src/la-protocol-v2/contract/README.md +103 -0
- package/src/la-protocol-v2/contract/ack.schema.json +51 -0
- package/src/la-protocol-v2/contract/alert.schema.json +62 -0
- package/src/la-protocol-v2/contract/approval.decision.schema.json +57 -0
- package/src/la-protocol-v2/contract/audit.event.schema.json +86 -0
- package/src/la-protocol-v2/contract/config.update.schema.json +73 -0
- package/src/la-protocol-v2/contract/content.request.schema.json +110 -0
- package/src/la-protocol-v2/contract/content.response.schema.json +90 -0
- package/src/la-protocol-v2/contract/defs.schema.json +191 -0
- package/src/la-protocol-v2/contract/envelope.schema.json +89 -0
- package/src/la-protocol-v2/contract/error.schema.json +59 -0
- package/src/la-protocol-v2/contract/heartbeat.schema.json +100 -0
- package/src/la-protocol-v2/contract/hello.schema.json +111 -0
- package/src/la-protocol-v2/contract/incident.candidate.schema.json +102 -0
- package/src/la-protocol-v2/contract/index.json +166 -0
- package/src/la-protocol-v2/contract/job.assign.schema.json +212 -0
- package/src/la-protocol-v2/contract/job.cancel.schema.json +52 -0
- package/src/la-protocol-v2/contract/job.event.schema.json +115 -0
- package/src/la-protocol-v2/contract/job.result.schema.json +100 -0
- package/src/la-protocol-v2/contract/log.stats.schema.json +79 -0
- package/src/la-protocol-v2/contract/ready.schema.json +106 -0
- package/src/la-protocol-v2/contract/register.schema.json +92 -0
- package/src/la-protocol-v2/contract/scan.upload.schema.json +59 -0
- package/src/la-protocol-v2/contract/scan.uploaded.schema.json +51 -0
- package/src/la-protocol-v2/contract/webhook.forward.schema.json +69 -0
- package/src/la-protocol-v2/contract.js +90 -0
- package/src/la-protocol-v2/errors.js +34 -0
- package/src/la-protocol-v2/index.js +8 -0
- package/src/la-protocol-v2/link.js +93 -0
- package/src/la-protocol-v2/runtime.js +72 -0
- package/src/la-protocol-v2/session.js +650 -0
- package/src/la-protocol-v2/strict-json.js +47 -0
- package/src/server.js +22 -4
- package/test/contract/la-protocol-v2/vectors/ack.vectors.json +149 -0
- package/test/contract/la-protocol-v2/vectors/alert.vectors.json +101 -0
- package/test/contract/la-protocol-v2/vectors/approval.decision.vectors.json +165 -0
- package/test/contract/la-protocol-v2/vectors/audit.event.vectors.json +156 -0
- package/test/contract/la-protocol-v2/vectors/config.update.vectors.json +189 -0
- package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +325 -0
- package/test/contract/la-protocol-v2/vectors/content.response.vectors.json +413 -0
- package/test/contract/la-protocol-v2/vectors/cross-type.vectors.json +260 -0
- package/test/contract/la-protocol-v2/vectors/error.vectors.json +179 -0
- package/test/contract/la-protocol-v2/vectors/heartbeat.vectors.json +109 -0
- package/test/contract/la-protocol-v2/vectors/hello.vectors.json +224 -0
- package/test/contract/la-protocol-v2/vectors/incident.candidate.vectors.json +186 -0
- package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +631 -0
- package/test/contract/la-protocol-v2/vectors/job.cancel.vectors.json +133 -0
- package/test/contract/la-protocol-v2/vectors/job.event.vectors.json +233 -0
- package/test/contract/la-protocol-v2/vectors/job.result.vectors.json +137 -0
- package/test/contract/la-protocol-v2/vectors/log.stats.vectors.json +137 -0
- package/test/contract/la-protocol-v2/vectors/ready.vectors.json +177 -0
- package/test/contract/la-protocol-v2/vectors/register.vectors.json +399 -0
- package/test/contract/la-protocol-v2/vectors/scan.upload.vectors.json +137 -0
- package/test/contract/la-protocol-v2/vectors/scan.uploaded.vectors.json +101 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-agent-mismatch.json +140 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-after-last.json +200 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-byte-length-mismatch.json +174 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-last-flag.json +200 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-out-of-order.json +200 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-total-changes.json +200 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-clock-skew.json +46 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-event-before-ready.json +72 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-first-frame-not-register.json +48 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-frame-over-48kib.json +141 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-la-sends-job-assign.json +173 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-params-raw-differs-from-payload.json +172 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-payload-above-limit.json +174 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-resume-restarts-seq.json +144 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-gap.json +166 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-repeated.json +166 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-sig-tenant-mismatch.json +172 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-signature-warn-with-enforce-required.json +77 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-tenant-mismatch.json +140 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-type-outside-allowlist.json +140 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-content-chunks-in-order.json +224 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-handshake-and-job.json +266 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-resume-continues-seq.json +168 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-retransmit-is-idempotent.json +193 -0
- package/test/contract/la-protocol-v2/vectors/webhook.forward.vectors.json +170 -0
- package/test/la-protocol-v2/contract.test.js +131 -0
- package/test/la-protocol-v2/helpers/la-rig.js +83 -0
- package/test/la-protocol-v2/helpers/reference-cp.js +349 -0
- package/test/la-protocol-v2/helpers/spawn-agent.js +35 -0
- package/test/la-protocol-v2/link.test.js +141 -0
- package/test/la-protocol-v2/session-inbound.test.js +279 -0
- package/test/la-protocol-v2/session-outbound.test.js +382 -0
- package/test/la-protocol-v2/traces.test.js +270 -0
- package/test/la-protocol-v2/tunnel-e2e.test.js +124 -0
- package/tools/graphify-validation/extras/README.md +19 -0
- package/tools/graphify-validation/extras/bymethod.py +35 -0
- package/tools/graphify-validation/extras/docker/Dockerfile +16 -0
- package/tools/graphify-validation/extras/docker/inner.sh +7 -0
- package/tools/graphify-validation/extras/g10.py +59 -0
- package/tools/graphify-validation/extras/g3aug.py +49 -0
- package/tools/graphify-validation/extras/hybrid.py +34 -0
- package/tools/graphify-validation/extras/truth2.py +44 -0
- package/tools/interop/LaV2NodeInteropTest.java +266 -0
- package/tools/interop/README.md +21 -0
- package/tools/interop/la-interop-client.mjs +97 -0
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
# Deploy local-agent (Node) to GCP Cloud Run
|
|
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
|
-
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
81
|
+
run: gcloud auth configure-docker "$AR_HOST" --quiet
|
|
72
82
|
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
docker build --platform=linux/amd64 -t "$
|
|
77
|
-
docker push "$
|
|
78
|
-
|
|
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
|
|
97
|
+
- name: Deploy to Cloud Run (QA, image only by digest, live)
|
|
98
|
+
if: inputs.environment != 'prod'
|
|
81
99
|
run: |
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
$
|
|
129
|
+
--no-traffic --tag "${{ steps.vars.outputs.tag }}" \
|
|
99
130
|
--quiet
|
|
100
131
|
|
|
101
132
|
- name: Summary
|
|
102
|
-
run:
|
|
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
|
+
"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
|
+
|