deepdebug-local-agent 1.3.0 → 1.5.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 +102 -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-handlers/audit.js +36 -0
- package/src/la-protocol-v2/content-handlers/config.js +48 -0
- package/src/la-protocol-v2/content-handlers/egress.js +65 -0
- package/src/la-protocol-v2/content-handlers/git-diff.js +92 -0
- package/src/la-protocol-v2/content-handlers/handler.js +170 -0
- package/src/la-protocol-v2/content-handlers/index.js +6 -0
- package/src/la-protocol-v2/content-handlers/install.js +50 -0
- package/src/la-protocol-v2/content-handlers/path-guard.js +142 -0
- package/src/la-protocol-v2/content-handlers/ref-registry.js +87 -0
- package/src/la-protocol-v2/content.js +63 -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 +81 -0
- package/src/la-protocol-v2/session.js +650 -0
- package/src/la-protocol-v2/strict-json.js +47 -0
- package/src/server.js +25 -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/content-handlers.test.js +633 -0
- package/test/la-protocol-v2/content-path-guard.test.js +214 -0
- package/test/la-protocol-v2/content-wiring.test.js +225 -0
- package/test/la-protocol-v2/contract.test.js +131 -0
- package/test/la-protocol-v2/helpers/content-rig.js +94 -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 +36 -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/LaV2NodeContentInteropTest.java +248 -0
- package/tools/interop/LaV2NodeInteropTest.java +266 -0
- package/tools/interop/README.md +38 -0
- package/tools/interop/la-interop-client.mjs +97 -0
- package/tools/interop/la-interop-content-client.mjs +91 -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,102 @@
|
|
|
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
|
+
## Content handlers (F3-07, `LOCAL_AGENT_CONTENT_HANDLERS`, default false)
|
|
54
|
+
|
|
55
|
+
Needs `LOCAL_AGENT_PROTOCOL_V2=true` too. Off (unset, empty, `false` or any unknown value): `content.request` is acked
|
|
56
|
+
`rejected protocol_unsupported` exactly as before and the register announces no `content.*` capability. On: kinds
|
|
57
|
+
`file` and `diff` are served (capabilities `content.file`, `content.diff`); any other kind of the allowlist answers
|
|
58
|
+
`content.response` `status:"error"` + `errorCode:"protocol_unsupported"`.
|
|
59
|
+
|
|
60
|
+
- **Opaque ids.** The `ref` is an id (`cr1_` + 128 random bits, base64url) minted by the agent, never derived from a path.
|
|
61
|
+
It maps in memory to (tenant, workspace, root, kind, relative path), lives `LOCAL_AGENT_CONTENT_REF_TTL_MS` (15 min) and
|
|
62
|
+
is forgotten when the agent restarts. Anything that is not a live id for this tenant+workspace+root+kind (unknown,
|
|
63
|
+
expired, another workspace, a path, `../x`) is `not_found` and is never echoed. The only other ref is the symbolic
|
|
64
|
+
`worktree` for kind `diff` (the work tree of the envelope's workspace). Whoever reports metadata to the control plane
|
|
65
|
+
gets an id with `link.content.mintRef({workspaceId, kind, rel})` (no caller yet: see the F3-07 note).
|
|
66
|
+
- **The agent is the authority on paths.** Workspace must be registered here and belong to this tenant; the real path
|
|
67
|
+
must stay inside the real root (a link that leaves it is `forbidden`), must not be sensitive under its real name,
|
|
68
|
+
must be a regular file opened without following links. Sensitive by default: `.env*`, `*.pem`, `*.key`, `*.p12`,
|
|
69
|
+
`id_rsa*`..., `.npmrc`, `.netrc`, `*.tfstate`, `.git/`, `.ssh/`, `.aws/`, `.docker/`... (`LOCAL_AGENT_CONTENT_DENY_GLOBS`
|
|
70
|
+
adds to the list). No id is ever minted for them.
|
|
71
|
+
- **DLP before anything leaves** (`LOCAL_AGENT_DLP_MODE`, the module `src/dlp`): `enforce` masks the whole text before it is
|
|
72
|
+
chunked (a secret cannot straddle chunks), withholds a file with a hit in `LOCAL_AGENT_DLP_BLOCK_CLASSES`, and fails
|
|
73
|
+
closed for binary files, inputs above `LOCAL_AGENT_DLP_MAX_BYTES` and DLP errors. `warn` and `off` send the content as
|
|
74
|
+
read (warn only counts). The agent warns at start-up when handlers are on and the mode is not `enforce`.
|
|
75
|
+
- **Limits.** `LOCAL_AGENT_CONTENT_MAX_BYTES` per response (default 1 MiB, ceiling 2 MiB = 64 chunks; a file above it is
|
|
76
|
+
`payload_too_large` without being read), `LOCAL_AGENT_CONTENT_MAX_CONCURRENT` (4) and `..._MAX_INFLIGHT_BYTES`
|
|
77
|
+
(`rate_limited` above), `LOCAL_AGENT_CONTENT_TOTAL_MS` (25 s), the session window (8 chunks in flight, backpressure).
|
|
78
|
+
Cancellation: `handler.cancel(requestId)`, the deadline, closing the link, or loss of the session.
|
|
79
|
+
- **Diff.** `git diff HEAD` of the work tree, no shell, without external diff/textconv/fsmonitor, without any `GIT_*` of
|
|
80
|
+
the agent's env, only for files that pass the same deny list, at most `LOCAL_AGENT_CONTENT_MAX_DIFF_FILES` (500).
|
|
81
|
+
- **Audit.** One local record per request (kind, status, code, sizes, DLP mode and per-rule counts, a short hash of the
|
|
82
|
+
ref): in memory and optionally `LOCAL_AGENT_CONTENT_AUDIT_FILE` (JSONL, 0600, rotates at 5 MiB). Never content,
|
|
83
|
+
paths or the ref.
|
|
84
|
+
|
|
85
|
+
## What it does NOT do
|
|
86
|
+
|
|
87
|
+
No handler for the other kinds (`incident.*`, `log.*`, `chat.session`, `wiki.page`, `job.*`, `approval.detail`, `report`),
|
|
88
|
+
no inventory frame that tells the control plane which ids exist (the contract has none), no log/incident detector
|
|
89
|
+
(F3-11b), no `job.assign` runtime (F3-08). Those frames are acked `rejected protocol_unsupported`. `sha256` of
|
|
90
|
+
`content.response` is not sent (the contract does not say what it covers).
|
|
91
|
+
|
|
92
|
+
## Tests
|
|
93
|
+
|
|
94
|
+
`npm test` (node:test), all in `test/la-protocol-v2/`:
|
|
95
|
+
`contract.test.js` (all 134 vectors of F3-22, valid and invalid, plus the drift check), `traces.test.js` (the 24
|
|
96
|
+
traces), `session-inbound.test.js`, `session-outbound.test.js`, `link.test.js`, `tunnel-e2e.test.js` (the real
|
|
97
|
+
`src/server.js` against a reference CP; flag off = v1 frames), and for F3-07 `content-path-guard.test.js`,
|
|
98
|
+
`content-handlers.test.js`, `content-wiring.test.js` (flag, runtime wiring, real `src/server.js`). The reference CP (`test/la-protocol-v2/helpers/reference-cp.js`)
|
|
99
|
+
is a Node model of the gateway rules, not the gateway.
|
|
100
|
+
|
|
101
|
+
**Interoperability with the real gateway session** (`LaV2Session`, Java, real Reactor Netty socket on loopback, real
|
|
102
|
+
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.5.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,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* audit.js - local audit of served content (F3-07). One record per content.request, NEVER the content, a path or
|
|
3
|
+
* the ref: kind, status, sizes, DLP mode and per-rule counts, timing, a short hash of the ref and of the user id.
|
|
4
|
+
*
|
|
5
|
+
* ring the last `ringSize` records in memory (tests, support, future heartbeat summary)
|
|
6
|
+
* file optional JSONL (LOCAL_AGENT_CONTENT_AUDIT_FILE), mode 0600; at `maxFileBytes` it rotates to <file>.1
|
|
7
|
+
*/
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
|
|
11
|
+
export const short = (v) => crypto.createHash("sha256").update(String(v ?? "")).digest("hex").slice(0, 16);
|
|
12
|
+
|
|
13
|
+
export function createAudit({ file = null, ringSize = 500, maxFileBytes = 5 * 1024 * 1024, now = Date.now } = {}) {
|
|
14
|
+
const ring = [];
|
|
15
|
+
let written = 0;
|
|
16
|
+
let broken = false;
|
|
17
|
+
if (file) { try { written = fs.statSync(file).size; } catch { written = 0; } }
|
|
18
|
+
return {
|
|
19
|
+
record(entry) {
|
|
20
|
+
const rec = { ts: new Date(now()).toISOString(), ...entry };
|
|
21
|
+
ring.push(rec);
|
|
22
|
+
if (ring.length > ringSize) ring.shift();
|
|
23
|
+
if (file && !broken) {
|
|
24
|
+
try {
|
|
25
|
+
const line = JSON.stringify(rec) + "\n";
|
|
26
|
+
if (written + line.length > maxFileBytes) { fs.renameSync(file, `${file}.1`); written = 0; }
|
|
27
|
+
fs.appendFileSync(file, line, { mode: 0o600 });
|
|
28
|
+
written += line.length;
|
|
29
|
+
} catch { broken = true; /* auditing to disk must never break the data path; the ring still has it */ }
|
|
30
|
+
}
|
|
31
|
+
return rec;
|
|
32
|
+
},
|
|
33
|
+
tail(n = 50) { return ring.slice(-n); },
|
|
34
|
+
get brokenFile() { return broken; },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config.js - LOCAL_AGENT_CONTENT_HANDLERS flag and limits of the content.request handlers (F3-07).
|
|
3
|
+
*
|
|
4
|
+
* false (default) `content.request` keeps being acked `rejected protocol_unsupported`: nothing of this folder is loaded.
|
|
5
|
+
* true handlers for `file` and `diff` are registered. Needs LOCAL_AGENT_PROTOCOL_V2=true as well
|
|
6
|
+
* (without it there is no v2 session and the flag does nothing).
|
|
7
|
+
*
|
|
8
|
+
* Unset/empty/unrecognised = false (an unknown value must never open the door to client content).
|
|
9
|
+
* Every number is clamped: a bad value falls back to the default, never to "unlimited".
|
|
10
|
+
*/
|
|
11
|
+
export const CONTENT_HANDLERS_FLAG = "LOCAL_AGENT_CONTENT_HANDLERS";
|
|
12
|
+
|
|
13
|
+
const TRUE = new Set(["true", "1", "yes", "on"]);
|
|
14
|
+
const FALSE = new Set(["", "false", "0", "no", "off"]);
|
|
15
|
+
|
|
16
|
+
const MIB = 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
function int(raw, def, min, max) {
|
|
19
|
+
const n = Number.parseInt(String(raw ?? ""), 10);
|
|
20
|
+
return Number.isFinite(n) && n >= min && n <= max ? n : def;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveContentHandlersConfig(env = process.env, warn = console.warn) {
|
|
24
|
+
const raw = String(env[CONTENT_HANDLERS_FLAG] ?? "").trim().toLowerCase();
|
|
25
|
+
if (!TRUE.has(raw)) {
|
|
26
|
+
if (!FALSE.has(raw)) warn(`[la-content] unrecognised ${CONTENT_HANDLERS_FLAG} value; content handlers stay off`);
|
|
27
|
+
return { enabled: false };
|
|
28
|
+
}
|
|
29
|
+
const maxConcurrent = int(env.LOCAL_AGENT_CONTENT_MAX_CONCURRENT, 4, 1, 16);
|
|
30
|
+
const maxBytes = int(env.LOCAL_AGENT_CONTENT_MAX_BYTES, 1 * MIB, 1, 2 * MIB); // per response (the contract caps it at 2 MiB)
|
|
31
|
+
return {
|
|
32
|
+
enabled: true,
|
|
33
|
+
maxBytes,
|
|
34
|
+
maxConcurrent,
|
|
35
|
+
// total bytes held in memory by requests in flight (a response is built whole, masked, then streamed)
|
|
36
|
+
// (each request reserves 2 x maxBytes: the bytes read and the masked copy)
|
|
37
|
+
maxInFlightBytes: int(env.LOCAL_AGENT_CONTENT_MAX_INFLIGHT_BYTES, 2 * maxBytes * maxConcurrent, 2 * maxBytes, 256 * MIB),
|
|
38
|
+
refTtlMs: int(env.LOCAL_AGENT_CONTENT_REF_TTL_MS, 15 * 60_000, 10_000, 24 * 3_600_000),
|
|
39
|
+
maxRefs: int(env.LOCAL_AGENT_CONTENT_MAX_REFS, 20_000, 100, 200_000),
|
|
40
|
+
// the gateway gives a request 30 s in total (contract: contentTotalMs)
|
|
41
|
+
totalMs: int(env.LOCAL_AGENT_CONTENT_TOTAL_MS, 25_000, 1_000, 30_000),
|
|
42
|
+
gitTimeoutMs: int(env.LOCAL_AGENT_CONTENT_GIT_TIMEOUT_MS, 8_000, 500, 20_000),
|
|
43
|
+
maxDiffFiles: int(env.LOCAL_AGENT_CONTENT_MAX_DIFF_FILES, 500, 1, 5_000),
|
|
44
|
+
// additive to the built-in list (path-guard.js); comma separated globs
|
|
45
|
+
extraDenyGlobs: String(env.LOCAL_AGENT_CONTENT_DENY_GLOBS ?? "").split(",").map((s) => s.trim()).filter(Boolean).slice(0, 200),
|
|
46
|
+
auditFile: String(env.LOCAL_AGENT_CONTENT_AUDIT_FILE ?? "").trim() || null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* egress.js - everything between "bytes read on this machine" and "bytes handed to sendContent" (F3-07).
|
|
3
|
+
*
|
|
4
|
+
* The DLP is the one of the agent (src/dlp, LOCAL_AGENT_DLP_MODE=off|warn|enforce, same rules, same counters):
|
|
5
|
+
* off content goes as read.
|
|
6
|
+
* warn content goes as read; the DLP only counts what enforce WOULD have masked (the existing "warn" meaning).
|
|
7
|
+
* enforce every hit is replaced by [REDACTED:<rule>]; a hit in LOCAL_AGENT_DLP_BLOCK_CLASSES withholds the whole
|
|
8
|
+
* content (`forbidden`); an input above LOCAL_AGENT_DLP_MAX_BYTES, a DLP failure, or a binary file (it cannot
|
|
9
|
+
* be inspected) never goes out (payload_too_large | internal | forbidden). Fail closed, as in the DLP module.
|
|
10
|
+
* The WHOLE text is masked before it is split into chunks, so a secret that would straddle two chunks is still found.
|
|
11
|
+
* A `range` is applied to the bytes AS SERVED (after masking): the offsets are stable between requests.
|
|
12
|
+
*/
|
|
13
|
+
import { dlpConfigFromEnv, protectEgress, DENIED } from "../../dlp/index.js";
|
|
14
|
+
import { OVERSIZE } from "../../dlp/engine.js";
|
|
15
|
+
|
|
16
|
+
const utf8 = new TextDecoder("utf-8", { fatal: true });
|
|
17
|
+
|
|
18
|
+
export function decodeText(buffer) {
|
|
19
|
+
const probe = buffer.subarray(0, 8000);
|
|
20
|
+
if (probe.includes(0)) return null;
|
|
21
|
+
try { return utf8.decode(buffer); } catch { return null; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const EXT_TYPES = new Map([
|
|
25
|
+
[".md", "text/markdown"], [".markdown", "text/markdown"], [".json", "application/json"],
|
|
26
|
+
[".diff", "text/x-diff"], [".patch", "text/x-diff"], [".zip", "application/zip"],
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export function contentTypeOf(rel, isText) {
|
|
30
|
+
const m = /(\.[A-Za-z0-9]+)$/.exec(rel || "");
|
|
31
|
+
const t = m ? EXT_TYPES.get(m[1].toLowerCase()) : undefined;
|
|
32
|
+
if (isText) return t && t !== "application/zip" ? t : "text/plain";
|
|
33
|
+
return t === "application/zip" ? t : "application/octet-stream";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {{buffer:Buffer, contentType?:string, range?:{offset:number,length:number}, maxBytes:number, env?:object, source:string}} o
|
|
38
|
+
* @returns {{ok:true, buffer:Buffer, contentType:string, dlp:{mode:string,total:number,redactions:object}} |
|
|
39
|
+
* {ok:false, status:"forbidden"|"error", errorCode?:string, dlp:{mode:string,total:number,redactions:object}}}
|
|
40
|
+
*/
|
|
41
|
+
export function prepareOutgoing({ buffer, contentType, range, maxBytes, env = process.env, source }) {
|
|
42
|
+
const cfg = dlpConfigFromEnv(env);
|
|
43
|
+
const dlp = { mode: cfg.mode, total: 0, redactions: {} };
|
|
44
|
+
let out = buffer;
|
|
45
|
+
let type = contentType;
|
|
46
|
+
const text = decodeText(buffer);
|
|
47
|
+
if (text === null) {
|
|
48
|
+
if (cfg.mode === "enforce") return { ok: false, status: "forbidden", dlp }; // cannot be inspected: fail closed
|
|
49
|
+
type = type && !type.startsWith("text/") ? type : "application/octet-stream";
|
|
50
|
+
} else if (cfg.mode !== "off") {
|
|
51
|
+
if (cfg.mode === "enforce" && text.length > cfg.maxBytes) return { ok: false, status: "error", errorCode: "payload_too_large", dlp };
|
|
52
|
+
const res = protectEgress(text, { env, source });
|
|
53
|
+
dlp.total = res.total;
|
|
54
|
+
dlp.redactions = res.redactions;
|
|
55
|
+
if (res.denied || res.text === DENIED) return { ok: false, status: "error", errorCode: "internal", dlp };
|
|
56
|
+
if (cfg.mode === "enforce") {
|
|
57
|
+
if (res.blocked) return { ok: false, status: "forbidden", dlp };
|
|
58
|
+
if (res.text === OVERSIZE) return { ok: false, status: "error", errorCode: "payload_too_large", dlp };
|
|
59
|
+
out = Buffer.from(res.text, "utf8");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (range) out = out.subarray(Math.min(range.offset, out.length), Math.min(range.offset + range.length, out.length));
|
|
63
|
+
if (out.length > maxBytes) return { ok: false, status: "error", errorCode: "payload_too_large", dlp };
|
|
64
|
+
return { ok: true, buffer: out, contentType: type || "text/plain", dlp };
|
|
65
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-diff.js - `diff` kind: the work tree against HEAD, for the files that may be shown (F3-07).
|
|
3
|
+
*
|
|
4
|
+
* No shell. git runs with the agent's env minus secret-looking variables and minus every GIT_* variable (a stray
|
|
5
|
+
* GIT_DIR must not point it at another repository), without external diff drivers, textconv filters or fsmonitor
|
|
6
|
+
* hooks (a malicious repo config could name a command for each), with literal pathspecs. The file list is taken
|
|
7
|
+
* first and filtered (sensitive and out-of-root names never reach the second call), then diffed with that explicit list
|
|
8
|
+
* (at most `maxFiles` names and 128 KiB of arguments). Output is capped while it streams (the process is killed above the cap).
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import { sanitizedChildEnv } from "../../utils/child-env.js";
|
|
13
|
+
import { isSensitivePath, normalizeRel } from "./path-guard.js";
|
|
14
|
+
|
|
15
|
+
const GIT_SAFE = ["--literal-pathspecs", "-c", "core.quotepath=off", "-c", "core.fsmonitor=false", "-c", "core.pager=cat", "-c", "diff.external="];
|
|
16
|
+
|
|
17
|
+
function gitEnv() {
|
|
18
|
+
const env = sanitizedChildEnv(process.env);
|
|
19
|
+
for (const k of Object.keys(env)) if (k.startsWith("GIT_")) delete env[k];
|
|
20
|
+
return { ...env, GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0", GIT_PAGER: "cat" };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @returns {Promise<{code:number|null, out:Buffer, overflow:boolean, timedOut:boolean, aborted:boolean}>} */
|
|
24
|
+
function runGit(args, { cwd, input, maxBytes, timeoutMs, signal }) {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
let child;
|
|
27
|
+
try {
|
|
28
|
+
child = spawn("git", args, { cwd, env: gitEnv(), shell: false, stdio: ["pipe", "pipe", "ignore"], windowsHide: true });
|
|
29
|
+
} catch {
|
|
30
|
+
resolve({ code: null, out: Buffer.alloc(0), overflow: false, timedOut: false, aborted: false });
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const chunks = [];
|
|
34
|
+
let size = 0;
|
|
35
|
+
const st = { overflow: false, timedOut: false, aborted: false };
|
|
36
|
+
const kill = () => { try { child.kill("SIGKILL"); } catch { /* gone */ } };
|
|
37
|
+
const timer = setTimeout(() => { st.timedOut = true; kill(); }, timeoutMs);
|
|
38
|
+
const onAbort = () => { st.aborted = true; kill(); };
|
|
39
|
+
if (signal) { if (signal.aborted) onAbort(); else signal.addEventListener("abort", onAbort, { once: true }); }
|
|
40
|
+
child.stdout.on("data", (d) => {
|
|
41
|
+
size += d.length;
|
|
42
|
+
if (size > maxBytes) { st.overflow = true; kill(); return; }
|
|
43
|
+
chunks.push(d);
|
|
44
|
+
});
|
|
45
|
+
child.stdin.on("error", () => {});
|
|
46
|
+
child.stdin.end(input || "");
|
|
47
|
+
child.on("error", () => { clearTimeout(timer); resolve({ code: null, out: Buffer.alloc(0), ...st }); });
|
|
48
|
+
child.on("close", (code) => {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
51
|
+
resolve({ code, out: Buffer.concat(chunks), ...st });
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {{root:string, rel?:string|null, maxBytes:number, maxFiles:number, timeoutMs:number, denyGlobs?:string[], signal?:AbortSignal}} o
|
|
58
|
+
* @returns {Promise<{ok:true, text:string} | {ok:false, status:"not_found"|"forbidden"|"error", errorCode?:string}>}
|
|
59
|
+
*/
|
|
60
|
+
export async function worktreeDiff({ root, rel = null, maxBytes, maxFiles, timeoutMs, denyGlobs = [], signal }) {
|
|
61
|
+
let cwd;
|
|
62
|
+
try { cwd = await fs.promises.realpath(root); } catch { return { ok: false, status: "not_found" }; }
|
|
63
|
+
let only = null;
|
|
64
|
+
if (rel !== null) {
|
|
65
|
+
only = normalizeRel(rel);
|
|
66
|
+
if (!only) return { ok: false, status: "not_found" };
|
|
67
|
+
if (isSensitivePath(only, denyGlobs)) return { ok: false, status: "forbidden" };
|
|
68
|
+
}
|
|
69
|
+
const listArgs = [...GIT_SAFE, "diff", "--name-only", "-z", "--no-renames", "--no-ext-diff", "--relative", "HEAD", "--", ...(only ? [only] : ["."])];
|
|
70
|
+
const listed = await runGit(listArgs, { cwd, maxBytes: 4 * 1024 * 1024, timeoutMs, signal });
|
|
71
|
+
if (listed.aborted) return { ok: false, status: "error", errorCode: "agent_offline" };
|
|
72
|
+
if (listed.timedOut) return { ok: false, status: "error", errorCode: "timeout" };
|
|
73
|
+
if (listed.overflow) return { ok: false, status: "error", errorCode: "payload_too_large" };
|
|
74
|
+
if (listed.code !== 0) return { ok: false, status: "not_found" }; // not a repository, no HEAD yet, unsafe directory...
|
|
75
|
+
const names = [];
|
|
76
|
+
let argBytes = 0;
|
|
77
|
+
for (const n of listed.out.toString("utf8").split("\0")) {
|
|
78
|
+
const norm = n ? normalizeRel(n) : null;
|
|
79
|
+
if (!norm || isSensitivePath(norm, denyGlobs)) continue;
|
|
80
|
+
argBytes += Buffer.byteLength(norm) + 1;
|
|
81
|
+
if (names.length >= maxFiles || argBytes > 128 * 1024) break;
|
|
82
|
+
names.push(norm);
|
|
83
|
+
}
|
|
84
|
+
if (names.length === 0) return { ok: true, text: "" };
|
|
85
|
+
const diffArgs = [...GIT_SAFE, "diff", "--no-color", "--no-ext-diff", "--no-textconv", "--no-renames", "--relative", "-U3", "HEAD", "--", ...names];
|
|
86
|
+
const diff = await runGit(diffArgs, { cwd, maxBytes: maxBytes + 1, timeoutMs, signal });
|
|
87
|
+
if (diff.aborted) return { ok: false, status: "error", errorCode: "agent_offline" };
|
|
88
|
+
if (diff.timedOut) return { ok: false, status: "error", errorCode: "timeout" };
|
|
89
|
+
if (diff.overflow) return { ok: false, status: "error", errorCode: "payload_too_large" };
|
|
90
|
+
if (diff.code !== 0) return { ok: false, status: "error", errorCode: "internal" };
|
|
91
|
+
return { ok: true, text: diff.out.toString("utf8") };
|
|
92
|
+
}
|