deepdebug-local-agent 1.10.0 → 1.12.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/build-byoc-image.yml +9 -11
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +5 -0
- package/CLAUDE.md +1 -1
- package/cloudbuild-full.yaml +2 -1
- package/docs/byoc-agent-image.md +8 -7
- package/docs/examples/agent2-runner/docker-compose.yml +53 -0
- package/docs/la-agent2-runner.md +115 -0
- package/docs/la-jobs-and-llm.md +14 -1
- package/docs/la-protocol-v2.md +4 -2
- package/env.example +6 -0
- package/package.json +1 -1
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +8 -4
- package/src/la-protocol-v2/contract/README.md +51 -2
- package/src/la-protocol-v2/contract/envelope.schema.json +3 -1
- package/src/la-protocol-v2/contract/index.json +18 -2
- package/src/la-protocol-v2/contract/policy.check.schema.json +97 -0
- package/src/la-protocol-v2/contract/policy.decision.schema.json +119 -0
- package/src/la-protocol-v2/jobs/agent2/api-client.js +150 -0
- package/src/la-protocol-v2/jobs/agent2/approvals.js +114 -0
- package/src/la-protocol-v2/jobs/agent2/bridge.js +299 -0
- package/src/la-protocol-v2/jobs/agent2/config.js +128 -0
- package/src/la-protocol-v2/jobs/agent2/frames.js +61 -0
- package/src/la-protocol-v2/jobs/agent2/policy-check.js +170 -0
- package/src/la-protocol-v2/jobs/agent2/runner.js +258 -0
- package/src/la-protocol-v2/jobs/agent2/supervisor.js +269 -0
- package/src/la-protocol-v2/jobs/git-exec.js +71 -0
- package/src/la-protocol-v2/jobs/install.js +14 -4
- package/src/la-protocol-v2/jobs/manager.js +30 -16
- package/src/la-protocol-v2/jobs/workspace-materializer.js +244 -0
- package/src/la-protocol-v2/llm/client.js +133 -12
- package/src/la-protocol-v2/llm/config.js +2 -0
- package/src/la-protocol-v2/runtime.js +36 -3
- package/src/server.js +4 -0
- package/src/utils/approval-token.js +3 -2
- package/test/contract/la-protocol-v2/vectors/policy.check.vectors.json +908 -0
- package/test/contract/la-protocol-v2/vectors/policy.decision.vectors.json +826 -0
- package/test/la-protocol-v2/agent2-approval-discovery.test.js +114 -0
- package/test/la-protocol-v2/agent2-config.test.js +103 -0
- package/test/la-protocol-v2/agent2-orphan.test.js +118 -0
- package/test/la-protocol-v2/agent2-policy-check.test.js +273 -0
- package/test/la-protocol-v2/agent2-real-e2e.test.js +89 -0
- package/test/la-protocol-v2/agent2-runner.test.js +510 -0
- package/test/la-protocol-v2/agent2-supervisor.test.js +185 -0
- package/test/la-protocol-v2/agent2-workspace-door.test.js +142 -0
- package/test/la-protocol-v2/agent2-workspace-real-server.test.js +137 -0
- package/test/la-protocol-v2/contract-v21.test.js +2 -2
- package/test/la-protocol-v2/contract.test.js +8 -7
- package/test/la-protocol-v2/helpers/agent2-rig.js +96 -0
- package/test/la-protocol-v2/helpers/fake-agent2-cli.js +22 -0
- package/test/la-protocol-v2/helpers/fake-agent2.js +280 -0
- package/test/la-protocol-v2/helpers/fake-la-api.js +29 -0
- package/test/la-protocol-v2/helpers/fake-llm.js +11 -0
- package/test/la-protocol-v2/helpers/reference-cp.js +6 -3
- package/test/la-protocol-v2/helpers/spawn-agent.js +4 -3
- package/test/la-protocol-v2/helpers/supervisor-exit-script.js +15 -0
- package/test/la-protocol-v2/llm-messages.test.js +132 -0
- package/test/la-protocol-v2/workspace-materializer.test.js +341 -0
|
@@ -10,11 +10,12 @@
|
|
|
10
10
|
# (ideally `:<semver>@sha256:<digest>`); this workflow never moves a floating
|
|
11
11
|
# tag (no :latest, no :v3) and refuses to overwrite an existing :<semver>.
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
13
|
+
# Trigger: workflow_dispatch from main only (version defaults to package.json).
|
|
14
|
+
# The CI identity (deploy-byoc-image@visionhammer, vh-infra live/nonprod/_project) trusts
|
|
15
|
+
# this repository at refs/heads/main only. v* tag pushes and release events carry a
|
|
16
|
+
# refs/tags/* ref and would be refused by Workload Identity Federation, so they are no
|
|
17
|
+
# longer triggers (tags pushed by npm-publish.yml's auto-bump never triggered this
|
|
18
|
+
# workflow anyway: they use GITHUB_TOKEN).
|
|
18
19
|
#
|
|
19
20
|
# Supply chain (ADR-0017 D12), OFF by default: the `supply-chain` job signs the
|
|
20
21
|
# pushed image with cosign (keyless, GitHub OIDC), attaches an SPDX SBOM (syft)
|
|
@@ -25,10 +26,6 @@
|
|
|
25
26
|
name: Build BYOC Agent Image
|
|
26
27
|
|
|
27
28
|
on:
|
|
28
|
-
push:
|
|
29
|
-
tags: ['v*']
|
|
30
|
-
release:
|
|
31
|
-
types: [published]
|
|
32
29
|
workflow_dispatch:
|
|
33
30
|
inputs:
|
|
34
31
|
version:
|
|
@@ -52,7 +49,8 @@ permissions:
|
|
|
52
49
|
env:
|
|
53
50
|
GCP_PROJECT: visionhammer
|
|
54
51
|
WIF_PROVIDER: projects/1009366917257/locations/global/workloadIdentityPools/github-pool/providers/github-provider
|
|
55
|
-
|
|
52
|
+
# Per-service identity (module cicd-identity): writer on visionhammer-docker-public-eu only.
|
|
53
|
+
DEPLOYER_SA: deploy-byoc-image@visionhammer.iam.gserviceaccount.com
|
|
56
54
|
AR_HOST: europe-west1-docker.pkg.dev
|
|
57
55
|
AR_IMAGE: europe-west1-docker.pkg.dev/visionhammer/visionhammer-docker-public-eu/local-agent-full
|
|
58
56
|
|
|
@@ -200,7 +198,7 @@ jobs:
|
|
|
200
198
|
|
|
201
199
|
# Cosign keyless signature + SBOM + provenance for the image pushed above.
|
|
202
200
|
# Flag: input `supply_chain` (dispatch) or repo variable BYOC_SUPPLY_CHAIN=='true'.
|
|
203
|
-
# Needs
|
|
201
|
+
# Needs deploy-byoc-image to write signatures next to the image (same repository,
|
|
204
202
|
# roles/artifactregistry.writer, already required to push).
|
|
205
203
|
supply-chain:
|
|
206
204
|
needs: build
|
package/AGENTS.md
CHANGED
|
@@ -15,7 +15,7 @@ Claude Code also reads `CLAUDE.md`; the same rules apply to every agent.
|
|
|
15
15
|
## Always
|
|
16
16
|
|
|
17
17
|
- PRs target `main` (publishes the npm package); Cloud Run deploy is manual.
|
|
18
|
-
- Runs inside customer infrastructure: argument-array process execution, paths confined to the workspace, no LLM calls except the opt-in F3-10 client (exception approved by Edu 2026-11-03; `LOCAL_AGENT_LLM_CLIENT`: AI-gateway virtual key only, DLP enforce, see `docs/la-jobs-and-llm.md`), no secrets in logs.
|
|
18
|
+
- Runs inside customer infrastructure: argument-array process execution, paths confined to the workspace, no LLM calls except the opt-in F3-10 client (exception approved by Edu 2026-11-03; `LOCAL_AGENT_LLM_CLIENT`: AI-gateway virtual key only, DLP enforce, see `docs/la-jobs-and-llm.md`; F3-08c: the agent2 runner reaches the model only through that client, `docs/la-agent2-runner.md`), no secrets in logs.
|
|
19
19
|
- Never print, log or commit secrets. Provider keys exist only in vh-litellm.
|
|
20
20
|
- Verify on the deployed artefact (curl, revision logs, bundle grep), not only on the diff.
|
|
21
21
|
- Small PRs with one intent each. Open PRs; a human merges them.
|
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,11 @@ platform side does not sign commands yet.
|
|
|
8
8
|
|
|
9
9
|
### Added (opt-in, off by default)
|
|
10
10
|
|
|
11
|
+
- **`LOCAL_AGENT_RUNNER=agent2`** (F3-08c, default off). The packaged agent loop (agent2, next to the agent) runs `job.assign` jobs on the customer's side: supervised process or sidecar, a loopback bridge that
|
|
12
|
+
serves its model calls (Anthropic Messages API with tools, new `ctx.llm.messages` / `LOCAL_AGENT_LLM_MESSAGES_PATH`) through the DLP and the key holder, `approval.decision` delivery with `vhapr1` token check (the approval is found through agent2's pending list of the job), PolicyGate over the v2 channel (contract 2.2 `policy.check` / `policy.decision`: the control plane
|
|
13
|
+
derives tenant, agent and origin from the session, nothing is claimed here), agent2's `/workspace` calls signed for it by the bridge (signature mode stays `enforce`), a pidfile so a restart ends an orphan agent2,
|
|
14
|
+
`job.event waiting_approval`. Needs v2, jobs, the LLM client, DLP enforce. See `docs/la-agent2-runner.md`. No behaviour change when off.
|
|
15
|
+
|
|
11
16
|
- **`LOCAL_AGENT_PROTOCOL_V2=true`** (F3-05b, default off). The Local Agent side of the CP <-> LA protocol v2:
|
|
12
17
|
register/hello/ready with resume, seq/ack/retransmission, chunking of `content.response`, allowlist, every
|
|
13
18
|
frame validated against the gateway's JSON Schemas, Ed25519 check of signed CP frames, `signatureMode` in
|
package/CLAUDE.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
layers, simplicity, testing, review checklist, `node.md`).
|
|
8
8
|
3. `README.md`: features, legacy connectors, configuration.
|
|
9
9
|
|
|
10
|
-
Non-negotiables: no LLM calls and no provider keys here (the one exception, approved by Edu 2026-11-03, is the VIRTUAL key of the AI gateway delivered by `config.update`, F3-09, `client` tenants only: flag `LOCAL_AGENT_LLM_KEY`, memory by default and a 0600 file in a 0700 dir only with `LOCAL_AGENT_LLM_KEY_FILE`, never in logs or acks. F3-10 adds the ONLY LLM client (exception approved by Edu 2026-11-03; the virtual key lives only in memory or a 0600 file and never in logs), flag `LOCAL_AGENT_LLM_CLIENT` (default off; needs v2, the key holder and `LOCAL_AGENT_DLP_MODE=enforce`; every prompt is DLP-masked before it leaves; it calls only the AI gateway endpoint the control plane delivered with the key; no prompt, answer or key in logs, audit or frames; see `docs/la-jobs-and-llm.md`); exec with argument arrays only;
|
|
10
|
+
Non-negotiables: no LLM calls and no provider keys here (the one exception, approved by Edu 2026-11-03, is the VIRTUAL key of the AI gateway delivered by `config.update`, F3-09, `client` tenants only: flag `LOCAL_AGENT_LLM_KEY`, memory by default and a 0600 file in a 0700 dir only with `LOCAL_AGENT_LLM_KEY_FILE`, never in logs or acks. F3-10 adds the ONLY LLM client (exception approved by Edu 2026-11-03; the virtual key lives only in memory or a 0600 file and never in logs), flag `LOCAL_AGENT_LLM_CLIENT` (default off; needs v2, the key holder and `LOCAL_AGENT_DLP_MODE=enforce`; every prompt is DLP-masked before it leaves; it calls only the AI gateway endpoint the control plane delivered with the key; no prompt, answer or key in logs, audit or frames; see `docs/la-jobs-and-llm.md`). F3-08c (`LOCAL_AGENT_RUNNER=agent2`, default off) lets the packaged agent loop (agent2, a supervised process or sidecar) reach the model ONLY through that client, via a loopback bridge with a per-job token (never the virtual key); see `docs/la-agent2-runner.md`; exec with argument arrays only;
|
|
11
11
|
paths confined to the workspace; never log secrets or raw customer log payloads beyond
|
|
12
12
|
what an incident needs; PRs target `main` (merging publishes the npm package).
|
|
13
13
|
Personal, uncommitted notes go in `CLAUDE.local.md` (gitignored).
|
package/cloudbuild-full.yaml
CHANGED
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
#
|
|
6
6
|
# Usage (from a clean checkout of main; both substitutions are required):
|
|
7
7
|
# gcloud builds submit --project visionhammer --config cloudbuild-full.yaml \
|
|
8
|
+
# --region=europe-west1 --gcs-source-staging-dir=gs://visionhammer-codescan-src-eu/cloudbuild-staging \
|
|
8
9
|
# --substitutions=_VERSION=1.0.31,_GIT_SHA=$(git rev-parse HEAD) .
|
|
9
10
|
#
|
|
10
11
|
# Pushes :<_VERSION> and :<_GIT_SHA> — never a floating tag. Pin the gateway to
|
|
11
12
|
# the resulting digest (deepdebug.byoc.agent-image in vh-api-gateway).
|
|
12
13
|
substitutions:
|
|
13
|
-
_IMAGE: '
|
|
14
|
+
_IMAGE: 'europe-west1-docker.pkg.dev/visionhammer/visionhammer-docker-public-eu/local-agent-full'
|
|
14
15
|
_VERSION: ''
|
|
15
16
|
_GIT_SHA: ''
|
|
16
17
|
|
package/docs/byoc-agent-image.md
CHANGED
|
@@ -14,8 +14,8 @@ built separately per tenant and does not use this image.
|
|
|
14
14
|
## Publishing a version
|
|
15
15
|
|
|
16
16
|
1. Actions → **Build BYOC Agent Image** → *Run workflow* on `main` (version
|
|
17
|
-
defaults to `package.json
|
|
18
|
-
|
|
17
|
+
defaults to `package.json`). Tag pushes and releases no longer trigger it
|
|
18
|
+
(the CI identity only trusts `refs/heads/main`).
|
|
19
19
|
2. The workflow builds `Dockerfile.full`, smoke-tests it (`/health` public,
|
|
20
20
|
API returns 401 without `X-Agent-Token` when `LOCAL_AGENT_TOKEN` is set),
|
|
21
21
|
then pushes `:<semver>` and `:<git-sha>`. It refuses to overwrite an
|
|
@@ -26,8 +26,8 @@ built separately per tenant and does not use this image.
|
|
|
26
26
|
(`...local-agent-full:<semver>@sha256:<digest>`).
|
|
27
27
|
|
|
28
28
|
4. Optional supply chain (flag, off by default): tick `supply_chain` on the
|
|
29
|
-
dispatch (or set the repository variable `BYOC_SUPPLY_CHAIN=true`
|
|
30
|
-
|
|
29
|
+
dispatch (or set the repository variable `BYOC_SUPPLY_CHAIN=true` to sign
|
|
30
|
+
every run). The `supply-chain` job signs `:<semver>@sha256:<digest>` with
|
|
31
31
|
cosign keyless (GitHub OIDC), attaches an SPDX SBOM (syft) and a SLSA v1
|
|
32
32
|
provenance attestation, and verifies all three. Consumers verify with:
|
|
33
33
|
|
|
@@ -63,8 +63,9 @@ container groups created before the TLS/token spec are recreated on re-provision
|
|
|
63
63
|
|
|
64
64
|
## One-time operator setup
|
|
65
65
|
|
|
66
|
-
- `
|
|
67
|
-
`roles/artifactregistry.writer` on the `visionhammer-docker-public-eu` repository (europe-west1
|
|
68
|
-
|
|
66
|
+
- `deploy-byoc-image@visionhammer.iam.gserviceaccount.com` (vh-infra `live/nonprod/_project`, module `cicd-identity`) has
|
|
67
|
+
`roles/artifactregistry.writer` on the `visionhammer-docker-public-eu` repository (europe-west1) and nothing else; writer
|
|
68
|
+
includes the read needed by the overwrite guard. It trusts this repository at `refs/heads/main` only, so the build is a
|
|
69
|
+
`workflow_dispatch` from main (tag and release triggers were removed for that reason).
|
|
69
70
|
- The repository must stay public-read (`allUsers` → `artifactregistry.reader`) so
|
|
70
71
|
customer projects / ACI pull without credentials.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Example only (F3-08c): the Local Agent with the agent2 runner, agent2 as a sidecar in the SAME network namespace (attach mode). NOT applied by anyone; adapt before use.
|
|
2
|
+
#
|
|
3
|
+
# export AGENT2_API_TOKEN=$(openssl rand -hex 32) # shared by both services: agent2's AGENT2_LOCAL_API_TOKEN / the LA's LOCAL_AGENT_AGENT2_TOKEN
|
|
4
|
+
# export AGENT2_CALLBACK_TOKEN=$(openssl rand -hex 32) # agent2's AGENT2_LA_CALLBACK_TOKEN / the LA's LOCAL_AGENT_AGENT2_CALLBACK_TOKEN
|
|
5
|
+
# export TENANT_ID=<tenant uuid, lower case> AGENT2_VERSION=<tag of vh/agent2-local> AGENT_VERSION=<tag of the Local Agent>
|
|
6
|
+
# docker compose -f docs/examples/agent2-runner/docker-compose.yml up -d
|
|
7
|
+
#
|
|
8
|
+
# The LA also needs what it needs anyway (registration, signing keys, the workspace): see docker-compose.yml of the repository. The virtual key of the AI gateway arrives from the control
|
|
9
|
+
# plane (config.update) and stays in the LA; agent2 only ever gets a per-job token for the loopback bridge.
|
|
10
|
+
services:
|
|
11
|
+
deepdebug-agent:
|
|
12
|
+
image: deepdebug/local-agent:${AGENT_VERSION:-latest}
|
|
13
|
+
restart: unless-stopped
|
|
14
|
+
environment:
|
|
15
|
+
LOCAL_AGENT_TENANT_ID: ${TENANT_ID:?set TENANT_ID}
|
|
16
|
+
LOCAL_AGENT_PROTOCOL_V2: "true"
|
|
17
|
+
LOCAL_AGENT_DLP_MODE: enforce
|
|
18
|
+
LOCAL_AGENT_CONTENT_HANDLERS: "true"
|
|
19
|
+
LOCAL_AGENT_LLM_KEY: "true"
|
|
20
|
+
LOCAL_AGENT_LLM_CLIENT: "true"
|
|
21
|
+
LOCAL_AGENT_LLM_MAX_PROMPT_BYTES: "1048576"
|
|
22
|
+
LOCAL_AGENT_LLM_MAX_OUTPUT_TOKENS: "16384"
|
|
23
|
+
LOCAL_AGENT_JOBS: "true"
|
|
24
|
+
LOCAL_AGENT_RUNNER: agent2
|
|
25
|
+
LOCAL_AGENT_AGENT2_URL: http://127.0.0.1:8765
|
|
26
|
+
LOCAL_AGENT_AGENT2_TOKEN: ${AGENT2_API_TOKEN:?set AGENT2_API_TOKEN}
|
|
27
|
+
LOCAL_AGENT_AGENT2_BRIDGE_PORT: "5058"
|
|
28
|
+
LOCAL_AGENT_AGENT2_CALLBACK_TOKEN: ${AGENT2_CALLBACK_TOKEN:?set AGENT2_CALLBACK_TOKEN}
|
|
29
|
+
# LOCAL_AGENT_SIGNATURE_MODE stays at its default (enforce): agent2's /workspace calls go through the bridge below, which vouches for them. Never turn it off for this.
|
|
30
|
+
# The PolicyGate needs nothing here: the question goes to the control plane as `policy.check` over the v2 channel (no platform credential on this machine).
|
|
31
|
+
# LOCAL_AGENT_TOKEN, if you set one for the LA's own API, is added by the bridge; agent2 never holds it.
|
|
32
|
+
agent2:
|
|
33
|
+
image: vh/agent2-local:${AGENT2_VERSION:?set AGENT2_VERSION}
|
|
34
|
+
restart: unless-stopped
|
|
35
|
+
network_mode: "service:deepdebug-agent" # loopback between the two; nothing is published
|
|
36
|
+
depends_on:
|
|
37
|
+
- deepdebug-agent
|
|
38
|
+
environment:
|
|
39
|
+
AGENT2_LOCAL_MODE: "true"
|
|
40
|
+
AGENT2_LOCAL_BIND: "127.0.0.1"
|
|
41
|
+
AGENT2_LOCAL_PORT: "8765"
|
|
42
|
+
AGENT2_LOCAL_API_TOKEN: ${AGENT2_API_TOKEN:?set AGENT2_API_TOKEN}
|
|
43
|
+
AGENT2_LOCAL_TENANT_ID: ${TENANT_ID:?set TENANT_ID}
|
|
44
|
+
AGENT2_LLM_BASE_URL: http://127.0.0.1:5058/v1 # the LA's bridge, not the AI gateway
|
|
45
|
+
AGENT2_LA_WORKSPACE_URL: http://127.0.0.1:5058 # the bridge (it signs for agent2 and forwards to the LA's /workspace API), NOT the LA's own port
|
|
46
|
+
AGENT2_LA_WORKSPACE_TOKEN: ${AGENT2_CALLBACK_TOKEN:?set AGENT2_CALLBACK_TOKEN} # the bridge's callback token
|
|
47
|
+
DEEPDEBUG_POLICYGATE_TIMEOUT_MS: "13000" # longer than the channel's 10 s (LOCAL_AGENT_AGENT2_POLICY_TIMEOUT_MS)
|
|
48
|
+
AGENT2_LA_CALLBACK_URL: http://127.0.0.1:5058
|
|
49
|
+
AGENT2_LA_CALLBACK_TOKEN: ${AGENT2_CALLBACK_TOKEN:?set AGENT2_CALLBACK_TOKEN}
|
|
50
|
+
read_only: true
|
|
51
|
+
tmpfs: [/tmp]
|
|
52
|
+
security_opt: ["no-new-privileges:true"]
|
|
53
|
+
cap_drop: [ALL]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# The agent2 runner (F3-08c, Local Agent half; flags default off)
|
|
2
|
+
|
|
3
|
+
Design: vault `10 Library/Plans/Fase 3 Desenho Tecnico 2026-10-28.md` (F3-08c), ADR-0017 (design A: the agent loop runs on the customer's side, the control plane
|
|
4
|
+
only assigns jobs and receives metadata). agent2 side: visionhammer-agents PR #308 (`docs/runbooks/local-agent-api.md`, profile `local-agent`).
|
|
5
|
+
Receiver, manager, `ctx`, options: `docs/la-jobs-and-llm.md`. This page is only what `LOCAL_AGENT_RUNNER=agent2` adds.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
```
|
|
9
|
+
CP --job.assign(+options)--> LA (receiver, limits, DLP, key holder)
|
|
10
|
+
| POST /local/v1/jobs (workspace path, request text, per-job token as the "LLM key")
|
|
11
|
+
v
|
|
12
|
+
agent2 (next to the LA; loopback; one job at a time)
|
|
13
|
+
| GET /local/v1/jobs/{id}/events (SSE: closed F3-08b frames) -> LA -> job.event / job.result / audit.event (metadata only)
|
|
14
|
+
| model: POST <bridge>/v1/messages -----------------------> LA bridge -> ctx.llm.messages -> DLP -> key holder -> AI gateway
|
|
15
|
+
| policy: POST <bridge>/agent2/cp-request -------------------> LA bridge -> policy.check (v2 frame) -> CP -> policy.decision (signed) -> back to agent2
|
|
16
|
+
| files: <bridge>/workspace/... ---------------------------> LA bridge signs for agent2 -> this LA's /workspace API (signature mode stays enforce)
|
|
17
|
+
CP --approval.decision--> LA --(vhapr1 token checked)--> GET /local/v1/approvals?jobId= (find it) -> POST /local/v1/approvals/{id}
|
|
18
|
+
CP --job.cancel / timeout--> LA --> POST /local/v1/jobs/{id}/cancel
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Flags (all default off; nothing of `src/la-protocol-v2/jobs/agent2/` is loaded without the first)
|
|
22
|
+
`LOCAL_AGENT_RUNNER=agent2` and, as for any runner, `LOCAL_AGENT_PROTOCOL_V2=true`, `LOCAL_AGENT_JOBS=true`, `LOCAL_AGENT_LLM_KEY=true`, `LOCAL_AGENT_LLM_CLIENT=true`,
|
|
23
|
+
`LOCAL_AGENT_DLP_MODE=enforce`. A missing prerequisite logs an ERROR and leaves `job.assign` NOT registered (never silently another runner). The runner forces
|
|
24
|
+
`LOCAL_AGENT_JOBS_MAX_CONCURRENT=1` (agent2 answers 409 to a second job). `quality` is not served (agent2 local mode refuses it): acked `protocol_unsupported`.
|
|
25
|
+
|
|
26
|
+
One way to reach agent2:
|
|
27
|
+
| | spawn (this agent supervises it) | attach (sidecar already running) |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| env | `LOCAL_AGENT_AGENT2_CMD` (JSON array or plain command, never a shell) | `LOCAL_AGENT_AGENT2_URL` (loopback http), `_TOKEN` (the sidecar's `AGENT2_LOCAL_API_TOKEN`, >= 32), `_BRIDGE_PORT` (fixed), `_CALLBACK_TOKEN` (its `AGENT2_LA_CALLBACK_TOKEN`) |
|
|
30
|
+
| token | NEW random per start | the sidecar's |
|
|
31
|
+
| lifecycle | started at the first job, health probes, killed above `_MAX_RSS_BYTES`, restarted at the next job after a crash, at most `_MAX_RESTARTS` (3) per `_RESTART_WINDOW_MS` then `unavailable` (jobs fail `infra`, no spawn loop), SIGTERM then SIGKILL on shutdown | health only |
|
|
32
|
+
|
|
33
|
+
Tuning (all clamped): `LOCAL_AGENT_AGENT2_PORT` (8765), `_LA_URL` (this agent's own API, where the bridge forwards `/workspace`; default `http://127.0.0.1:$PORT|5055`), `_POLICY_TIMEOUT_MS` (10000), `_WORKSPACE_MAX_BODY_BYTES`, `_WORKSPACE_TIMEOUT_MS`, `_PIDFILE`, `_START_TIMEOUT_MS`, `_HEALTH_INTERVAL_MS`, `_HEALTH_FAILURES`,
|
|
34
|
+
`_RESTART_BACKOFF_MS`, `_MAX_HEAP_MB` (`JAVA_TOOL_OPTIONS=-Xmx`), `_STOP_GRACE_MS`, `_REQUEST_TIMEOUT_MS`, `_STREAM_RECONNECTS`, `_MAX_BODY_BYTES`, `_MAX_REQUEST_BYTES`, `_MAX_REF_FILE_BYTES`,
|
|
35
|
+
`_APPROVAL_WAIT_SECONDS`, `_CANCEL_WAIT_MS` (keep below `LOCAL_AGENT_JOBS_CANCEL_GRACE_MS`), `_ENV_PASSTHROUGH` (extra variable NAMES for the child; a name that looks like a secret is refused).
|
|
36
|
+
For the model: raise `LOCAL_AGENT_LLM_MAX_PROMPT_BYTES` (agent2 re-sends its whole history; default 256 KiB, max 2 MiB), `LOCAL_AGENT_LLM_MAX_OUTPUT_TOKENS` (agent2 asks for 16384; the client clamps, max 32768)
|
|
37
|
+
and, if the endpoint of the key already ends in `/v1`, `LOCAL_AGENT_LLM_MESSAGES_PATH=/messages`.
|
|
38
|
+
|
|
39
|
+
## The model door (the bridge)
|
|
40
|
+
agent2's loop speaks the Anthropic Messages API with tools (`ClaudeApiClient`, `POST /v1/messages`), so the bridge does too (`ctx.llm.messages`, new in the LLM client; the OpenAI-shaped
|
|
41
|
+
`chat/completions` route is there for text-only callers). agent2's `AGENT2_LLM_BASE_URL` points at `http://127.0.0.1:<bridge>/v1`; its "key" is a random per-job token, revoked when the job
|
|
42
|
+
ends. The AI gateway's virtual key never leaves the key holder. EVERY string the model will read is masked by the DLP (enforce): system blocks, text, `tool_use` input, `tool_result`,
|
|
43
|
+
tool descriptions; a block type or field outside the known set is refused (400), a blocked DLP class is 403, the budget is the job's (402), upstream errors are codes (502/504). Tokens and cost
|
|
44
|
+
are counted HERE (agent2's own counters are ignored). `model`, `metadata` and `x-litellm-tags` of agent2 are ignored: the job's alias and this agent's tags are used. No streaming.
|
|
45
|
+
Perimeter: 127.0.0.1, loopback socket, loopback `Host`, no `Origin`, constant-time token compare, body cap.
|
|
46
|
+
|
|
47
|
+
## Approvals (ADR-0010)
|
|
48
|
+
agent2 pauses a governed call and emits `audit.event approval_requested` (actionType, toolName, requestHash) + `job.event waiting_approval`: both go to the CP (metadata only); the command
|
|
49
|
+
or file list stays in agent2 (`GET /local/v1/approvals/{id}`, local). `approval.decision` (signed frame) is delivered with `POST /local/v1/approvals/{id}` only after the `vhapr1` token
|
|
50
|
+
(`src/utils/approval-token.js`: platform Ed25519 key, tenant, workspace, <= 15 min, single use) is checked AND bound to that approval: its `sha256` is the SHA-256 of the approval's
|
|
51
|
+
`requestHash` string.
|
|
52
|
+
**How the approval is found.** The id in `approval.decision` is the control plane's and `audit.event` (closed schema) carries no id; agent2's id never leaves this machine. This agent asks agent2 for the
|
|
53
|
+
pending approvals OF THE RUNNING JOB, `GET /local/v1/approvals?jobId=<job>` (an array, or `{approvals:[{approvalId, jobId, requestHash, actionType, toolName, expiresAt}]}`; served by #308 since b4922fe),
|
|
54
|
+
drops any that is not the job's, and the token picks the one it is bound to (two pending approvals in one job are told apart by their hash). Listed and empty = nothing waits any more (decided, expired, job
|
|
55
|
+
over): acked, delivers nothing. Second source, used ONLY when agent2 has no list (404): the `approvalId` agent2 puts in its own `approval_requested` frame (`frames.js` returns it beside the payload as
|
|
56
|
+
`approvalHint`, the runner remembers it per job, forgotten when the job ends); the token still has to match, and agent2 refuses a decision on an approval that is gone. Third, for an agent2 that offers
|
|
57
|
+
neither: the CP's id as if it were agent2's own (`GET /approvals/{id}`); an id it does not know cannot be delivered and the decision is rejected `internal` (the CP is told). No decision, a bad token or a dead
|
|
58
|
+
agent2 = agent2's window expires = refusal; the tool never runs.
|
|
59
|
+
**Proven** (`test/la-protocol-v2/agent2-approval-discovery.test.js`, `agent2-runner.test.js`, and with the REAL agent2 jar in `agent2-real-e2e.test.js`: policy.check REQUIRE_HUMAN -> `approval_requested`
|
|
60
|
+
(metadata only, same `requestHash` as the policy.check) -> `approval.decision` with a `vhapr1` token -> found by `GET /approvals?jobId=` -> delivered -> the write runs; with no decision the write never runs).
|
|
61
|
+
**Still open (gateway):** the gateway does not consume `audit.event approval_requested` yet (nothing creates the `action_approvals` row or notifies) and must mint `vhapr1` tokens with `command = requestHash`
|
|
62
|
+
(`HumanApprovalTokens.mint(command)`; today it has no caller for this flow).
|
|
63
|
+
|
|
64
|
+
## PolicyGate over the v2 channel (contract 2.2)
|
|
65
|
+
agent2 (PR #308, `LocalGatewayInternalClient` + `LocalPolicyCheck`) asks: `POST <bridge>/agent2/cp-request {"type":"policy.check","payload":{actionType, requestHash, toolName?, modelAlias?, incidentId?, jobId, repoRefs?}}`
|
|
66
|
+
with the callback token. EXACTLY that shape: anything else (an extra top-level key, another `type`, the hosted `{method,path,body}` of the old guard) is `403 path_not_allowed`. The bridge does NOT forward it
|
|
67
|
+
anywhere over HTTP and NO platform credential is needed on this machine (the old `LOCAL_AGENT_AGENT2_POLICY_URL` / `_POLICY_TOKEN` pair and the injectable HTTP relay are gone; the pair is ignored with a warning).
|
|
68
|
+
`policy-check.js` (`normalizePolicyCheck`) re-checks the payload (agent2 is another process: its words are data) and builds the frame this agent sends (LA -> CP, unsigned: the authenticated session is the identity):
|
|
69
|
+
`actionType`, `requestHash` and `toolName` as agent2 sent them (a missing or malformed `requestHash` is a 400: nothing to bind a decision to), and PINNED to the running job, not to agent2: `jobId` (a different one is
|
|
70
|
+
`403 job_mismatch`), `repoRefs` = the job's repository as `{provider, org, repo}` (a repository that is not the job's is `403 repo_mismatch`; none named = the job's is used), `modelAlias` (the job's) and `incidentId`.
|
|
71
|
+
The envelope carries a fresh `requestId` and the job's `workspaceId`. **This agent claims nothing about WHO asks**: `tenantId`, `agent`, `executionOrigin`, paths, URLs and any other key are dropped (counted,
|
|
72
|
+
`stats.dropped`), the closed schema would refuse them, and the CP derives tenant, agent and `executionOrigin=local-agent` from the session. The CP's signed `policy.decision` (matched by `requestId`, its
|
|
73
|
+
`requestHash` must be the one asked, and it is checked again as a complete and consistent decision: `decisionBody`) goes back to agent2 as the decision payload of the contract, which is what
|
|
74
|
+
`LocalPolicyCheck.verdict` requires: `decision`, `reasonCode`, `shadow`, `approvalRequired`, `actionType` (the type the CP evaluated), `requestHash`, `policySnapshot.hash`, and `effectiveMode` when sent.
|
|
75
|
+
**Fail closed, everywhere**: no v2 session / the CP did not list `policy.check` in `ready` (so no frame is even sent) / a payload that is not a valid question / a job or repository that is not the running job's /
|
|
76
|
+
too many in flight / the frame refused (ack `rejected`) / no decision in `LOCAL_AGENT_AGENT2_POLICY_TIMEOUT_MS` (10 s, the contract's `policyCheckTimeoutMs`) / a decision for another request or an incomplete one = an
|
|
77
|
+
HTTP error (4xx/5xx; agent2's client turns any error into a DENY) whose body is a `DENY` with `reasonCode: gate_unavailable` and an `error` code. A late or unknown decision is dropped and counted. agent2's own wait
|
|
78
|
+
is raised to 13 s (`DEEPDEBUG_POLICYGATE_TIMEOUT_MS`; a sidecar must set it too, its default is 3 s). The capability `policy.check` is announced only with this runner (and `contract.v2.1`). Gateway side: PR
|
|
79
|
+
vh-api-gateway #633 (flag `deepdebug.policy.check-over-la.enabled`, default off; merge it BEFORE this).
|
|
80
|
+
|
|
81
|
+
## agent2's files: the `/workspace` door (signature stays on)
|
|
82
|
+
agent2 reads and writes the repository through this agent's own `/workspace` API (read_file, write_file, safe-patch, exec, tests...). That API verifies a PLATFORM signature on every request
|
|
83
|
+
(`LOCAL_AGENT_SIGNATURE_MODE=enforce`, the default) that agent2 cannot make: before this, agent2 got `401 unsigned request` and only `off` worked. Decision (Edu, 2026-11-04): **this agent signs for agent2,
|
|
84
|
+
the signature check is never turned off.** How: agent2's `AGENT2_LA_WORKSPACE_URL` is the bridge, and its `AGENT2_LA_WORKSPACE_TOKEN` is the bridge's callback token (NOT `LOCAL_AGENT_TOKEN`). The bridge forwards
|
|
85
|
+
`/workspace/...` to `LOCAL_AGENT_AGENT2_LA_URL` (default this agent's own port) and vouches for the request with the process-local internal marker (the one the WS tunnel uses for its own hops, unguessable, only
|
|
86
|
+
honoured from loopback), the tenant, the running job's workspace and, when this agent has one, the API's `X-Agent-Token` (agent2 never holds it). A request that does not come through the bridge is still `401`
|
|
87
|
+
(proved against the real server process: `test/la-protocol-v2/agent2-workspace-real-server.test.js`). The door: only the callback token opens it; only `/workspace/` paths (no `..`, no encoded characters, no other route of
|
|
88
|
+
the API); a running job is required (409 otherwise); the `X-Workspace-Root` agent2 sends must be the job's root (the job's root is added when it sends none) and `X-Workspace-Id` is the job's; only
|
|
89
|
+
`content-type`/`accept` are forwarded from agent2 (a cookie, `X-VH-*` or its own `X-Agent-Token` are not); body cap `LOCAL_AGENT_AGENT2_WORKSPACE_MAX_BODY_BYTES` (16 MiB), call timeout `_WORKSPACE_TIMEOUT_MS` (15 min).
|
|
90
|
+
**Honest limit:** the door confines the HEADER root and the paths; a root named in a request BODY is the API's own business (its sandbox and `exec` policy: `LOCAL_AGENT_EXEC_POLICY_MODE`), as for any caller. agent2 is
|
|
91
|
+
code of the VH, running with the user's rights on the user's machine: this agent narrows what it can reach, it does not sandbox it (container, `read_only`, `cap_drop`: the compose example).
|
|
92
|
+
|
|
93
|
+
## The child process
|
|
94
|
+
Environment = allowlist (PATH, HOME, JAVA_HOME, locale, proxies, `SSH_AUTH_SOCK`, `GIT_*` for the machine's own Git credentials) + what the runner sets (`AGENT2_*`) + named passthrough. Nothing else
|
|
95
|
+
of this agent's environment: no LLM key, no signing keys, **not `LOCAL_AGENT_TOKEN`** (the bridge adds it). stdout/stderr of the child are drained and dropped (they may hold customer content). Git credentials are the
|
|
96
|
+
machine's (the CP never sends one); PR opening in local mode is still a gap on the agent2 side (its provider-token path needs a database): jobs end `no_change`/`failed` until that lands.
|
|
97
|
+
**No orphan.** In spawn mode the child's pid is kept in a pidfile (`LOCAL_AGENT_AGENT2_PIDFILE`, default `~/.deepdebug/agent2-runner.pid`, mode 0600). If this agent is killed (SIGKILL) or crashes, the child would outlive
|
|
98
|
+
it, keep the API port and answer with a token nobody knows; at the next start the supervisor ends it (SIGTERM, SIGKILL after `_STOP_GRACE_MS`) **only if** the pid is alive AND its command line is the command this
|
|
99
|
+
agent starts (a reused pid of another program is never touched; where the command line cannot be read, nothing is killed and a warning says so). An orderly exit of this agent (`process.exit`, SIGTERM handlers)
|
|
100
|
+
also takes the child with it. A sidecar (attach mode) is the operator's: nothing is killed.
|
|
101
|
+
|
|
102
|
+
## What is logged, audited, counted
|
|
103
|
+
Ids hashed (`short`), counters, enums, codes. Never the request text, a file, a prompt or answer, the per-job token, the API/callback tokens, the workspace path, agent2's stdout.
|
|
104
|
+
`GET /jobs/{id}/stream` (the live content for the front, `job.stream`) and `approval.detail` through the content relay are NOT served yet (hooks: `api.approvalDetail` exists; no content kind is wired).
|
|
105
|
+
|
|
106
|
+
## What is still not done
|
|
107
|
+
- The real gateway handler of `policy.check` is PR vh-api-gateway #633 (not merged); here it is proven against the reference CP and the contract validator, and against the REAL agent2 jar (opt-in test, b4922fe), never against the real gateway (that integration is a separate harness, not committed).
|
|
108
|
+
- Gateway: consume `audit.event approval_requested` (create the `action_approvals` row, notify) and mint `vhapr1` with `command = requestHash`: until then an approval cannot be delivered end to end in QA.
|
|
109
|
+
- `policy.check` and `approval_requested` carry the SAME `requestHash` (agent2 computes one and uses it in both): proven with the real jar.
|
|
110
|
+
- `job.stream` / `approval.detail` through the content relay, PR opening in local mode (agent2), a real model, Helm (not rendered), a Docker run of the LA.
|
|
111
|
+
- The `/workspace` door does not sandbox agent2 (see above).
|
|
112
|
+
|
|
113
|
+
## Try it
|
|
114
|
+
`docs/examples/agent2-runner/docker-compose.yml` (LA + agent2 sidecar, attach mode). Tests: `npm test` (fake agent2 over the real session); opt-in with the real jar:
|
|
115
|
+
`AGENT2_JAR=/path/software-engineer.jar JAVA_HOME=<jdk21> node --test test/la-protocol-v2/agent2-real-e2e.test.js`.
|
package/docs/la-jobs-and-llm.md
CHANGED
|
@@ -39,11 +39,17 @@ Optional, closed: `ticketKey`, `branchName`, `createBranch`, `createPullRequest`
|
|
|
39
39
|
`createBranch:false` -> work on `repoRef.branch`, never push to it, no PR; `createPullRequest:false` -> no PR. A runner without `supportsOptions` gets a job that carries options refused (`protocol_unsupported`), so
|
|
40
40
|
`dryRun` is never ignored silently. Option values are not content, but they are not logged either (the audit lists the key names). The smoke runner never touches Git, so it honours them trivially; a dry run still asks its question.
|
|
41
41
|
|
|
42
|
+
## Messages API (F3-08c): `link.llm.client.messages()` / `ctx.llm.messages()`
|
|
43
|
+
One turn of the Anthropic Messages API (`POST <endpoint><LOCAL_AGENT_LLM_MESSAGES_PATH>`, default `/v1/messages`) with tools, content blocks and prompt-cache markers: what the packaged agent loop speaks. Same gates as `chat()`
|
|
44
|
+
(DLP enforce, key and alias, concurrency, retries, codes-only errors, tags, budget); EVERY string the model reads is masked (system, `text`, `tool_use` input, `tool_result`, tool descriptions); only `text`, `tool_use` and
|
|
45
|
+
`tool_result` blocks are accepted (images and other types are refused, never forwarded); tools are rebuilt field by field; no streaming. The prompt cap is `LOCAL_AGENT_LLM_MAX_PROMPT_BYTES` (raise it for an agent loop),
|
|
46
|
+
messages per call `LOCAL_AGENT_LLM_MAX_API_MESSAGES` (256). It returns the model's answer in the Messages shape as `message` (local use). See `docs/la-agent2-runner.md`.
|
|
47
|
+
|
|
42
48
|
## Runner interface (F3-08c plugs the packaged agent loop here)
|
|
43
49
|
```
|
|
44
50
|
Runner { name, kinds? (default fix|implement|quality), needsLlm? (default true), supportsOptions? (default false), start(job, ctx): Promise<Outcome>, cancel(jobId) }
|
|
45
51
|
job = { jobId, kind, workspaceId, incidentRef?, repoRef, policy, budget(clamped), llm{alias,keyRef}, inputs[], options{...} } // frozen
|
|
46
|
-
ctx = { signal, workspace{id}, llm.chat(opts), readFile(ref,{maxBytes}), progress({phase,iteration,filesRead,filesChanged}), budget() }
|
|
52
|
+
ctx = { signal, workspace{id}, llm.chat(opts), llm.messages(opts), readFile(ref,{maxBytes}), progress({phase,iteration,filesRead,filesChanged,waitingApproval}), budget() } // waitingApproval:true shows as job.event waiting_approval until it is set back to false
|
|
47
53
|
Outcome = { outcome: pr_opened|no_change|failed|cancelled, errorCode?, prUrl?, branch?, commitSha?, filesChangedCount?, diffSha256?, tests?, answer? }
|
|
48
54
|
```
|
|
49
55
|
`ctx.llm.chat` fills in the job's alias, signal, tags and remaining budget and counts tokens/cost into the job. `ctx.readFile` is the content handlers' gate (opaque id minted by this agent for this
|
|
@@ -54,3 +60,10 @@ Reference runner `LOCAL_AGENT_JOBS_RUNNER=smoke`: reads the first `inputs[].ref`
|
|
|
54
60
|
## Git credentials
|
|
55
61
|
The control plane never sends a Git token: `job.assign` is a closed schema with no field for one (a test sends one and gets `schema_violation`). A runner that opens a PR uses what the machine already has: the
|
|
56
62
|
user's git credential helper, ssh-agent, or variables of the agent's own process environment. The `repoRef` only names provider/org/repo/branch.
|
|
63
|
+
|
|
64
|
+
## Workspace materialization (LA-2, flag `LOCAL_AGENT_JOBS_AUTO_WORKSPACE`, default OFF)
|
|
65
|
+
A `job.assign` names a workspace by id plus `repoRef`. When this agent does not serve that id, it can clone it itself into `LOCAL_AGENT_WORKSPACE_CACHE` (default `~/.deepdebug/workspaces`),
|
|
66
|
+
with the customer's own credentials (helper / ssh-agent / `GH_TOKEN` of the process, or `LOCAL_AGENT_GIT_TOKEN_FILE` read by a GIT_ASKPASS script). Only for repos in the local allowlist
|
|
67
|
+
`LOCAL_AGENT_ALLOWED_REPOS` (+ optional `LOCAL_AGENT_ALLOWED_REPOS_FILE`; `host/org/repo` or `host/org/*`); empty = nothing is materialized. The allowlist is never taken from the control plane.
|
|
68
|
+
The clone runs inside the job (not in the 5 s ack); failure = job `failed`/`infra`, no git text. One clone at a time, one per workspace id, tenant = the session's. No new HTTP surface.
|
|
69
|
+
Limits: `LOCAL_AGENT_WORKSPACE_CLONE_TIMEOUT_MS`, `LOCAL_AGENT_WORKSPACE_GIT_TIMEOUT_MS`, `LOCAL_AGENT_WORKSPACE_MAX_BYTES`, `LOCAL_AGENT_WORKSPACE_CLONE_DEPTH`.
|
package/docs/la-protocol-v2.md
CHANGED
|
@@ -148,7 +148,7 @@ Needs `LOCAL_AGENT_PROTOCOL_V2=true`. Details, limits and hooks: `src/incident-d
|
|
|
148
148
|
|
|
149
149
|
## Contract 2.1 consumers (F3-09 / F3-12 / F3-11b, flags default false)
|
|
150
150
|
|
|
151
|
-
The contract copy is revision 2.1
|
|
151
|
+
The contract copy is revision 2.2 (2.1 is a subset; `src/la-protocol-v2/contract/CONTRACT_SOURCE.json` names the gateway commit and branch). With every flag off the agent announces exactly what it
|
|
152
152
|
announced before (`protocol.v2 v1.commands chunk.base64`); `contract.v2.1` is announced only when one of the three features below is on. `job.assign` / `job.options` (F3-10) are plain capabilities, announced with `LOCAL_AGENT_JOBS` (see `docs/la-jobs-and-llm.md`).
|
|
153
153
|
- **LLM key** (`LOCAL_AGENT_LLM_KEY`, capability `llm.key`; `src/la-protocol-v2/llm-key/`): `config.update.llmKey` `set` / `revoke`. The virtual key of the AI gateway is held in a
|
|
154
154
|
private field (memory) or, with `LOCAL_AGENT_LLM_KEY_FILE`, in a 0600 file outside every workspace; it is never in an ack, a log, the audit or a frame. A key whose id is not the
|
|
@@ -161,12 +161,14 @@ announced before (`protocol.v2 v1.commands chunk.base64`); `contract.v2.1` is an
|
|
|
161
161
|
same pipeline and the DLP-masked local store). The ack of the last chunk (or of the single frame) is sent only after the local delivery finished, waiting up to `LOCAL_AGENT_WEBHOOK_DELIVERY_MS`
|
|
162
162
|
(default 10 s, max 14 s; the session does not ack `accepted` after 2 s for this type); past it the ack is `rejected timeout`. Delivered ids (+ sha256 of the body) are remembered in memory for 10 min (max 1024):
|
|
163
163
|
a resend (same id and body) is acked `accepted`, chunk by chunk, without being processed again, so the detector's count is not inflated. Needs `LOCAL_AGENT_INCIDENT_DETECTOR=true`.
|
|
164
|
+
- **PolicyGate over the channel** (contract 2.2, F3-08c; only with `LOCAL_AGENT_RUNNER=agent2`, capability `policy.check`; `src/la-protocol-v2/jobs/agent2/policy-check.js`): `policy.check` (LA -> CP, unsigned, `requestId` in the envelope, no content) and the signed `policy.decision`
|
|
165
|
+
(CP -> LA). The agent says nothing about tenant, agent or origin (the CP derives them from the session); it sends a `policy.check` only after `ready.capabilities` listed `policy.check`; no answer in 10 s, a refused frame, a wrong `requestHash` or no session = DENY (fail closed). See `docs/la-agent2-runner.md`.
|
|
164
166
|
- **Candidate size and model** (no new flag, part of `LOCAL_AGENT_INCIDENT_DETECTOR`): `incident.candidate` carries `stackBytes` and `model` only if `ready.capabilities` listed `incident.candidate.size-model`.
|
|
165
167
|
|
|
166
168
|
## Tests
|
|
167
169
|
|
|
168
170
|
`npm test` (node:test). Contract 2.1: `contract-v21.test.js`, `webhook-receiver.test.js` (assembler, handler, the 8 traces, end to end), `llm-key.test.js` (including the sweep for the key), `test/incident-detector/size-model.test.js`. The incident detector (F3-11b) is in `test/incident-detector/` (`parity.test.js` against `parity.vectors.json`, `units`, `sources`, `wiring`); the rest of v2 in `test/la-protocol-v2/`:
|
|
169
|
-
`contract.test.js` (all
|
|
171
|
+
`contract.test.js` (all 363 frame vectors of F3-22 + 2.1 + 2.2, valid and invalid, the 54 `file.list` body vectors, plus the drift check), `traces.test.js` (the 34
|
|
170
172
|
traces), `session-inbound.test.js`, `session-outbound.test.js`, `link.test.js`, `tunnel-e2e.test.js` (the real
|
|
171
173
|
`src/server.js` against a reference CP; flag off = v1 frames), and for F3-07 `content-path-guard.test.js`,
|
|
172
174
|
`content-handlers.test.js`, `content-wiring.test.js` (flag, runtime wiring, real `src/server.js`), and for F3-07b
|
package/env.example
CHANGED
|
@@ -104,3 +104,9 @@ LOG_LEVEL=info
|
|
|
104
104
|
# LOCAL_AGENT_JOBS_RUNNER=smoke the reference runner (one fixed question about one allowed file; needs the LLM client). Limits: LOCAL_AGENT_JOBS_MAX_CONCURRENT (1), _MAX_QUEUED (4), _MAX_TOKENS,
|
|
105
105
|
# _MAX_SECONDS, _MAX_ITERATIONS (25; caps job.assign options.maxIterations), _MEM_PER_JOB_BYTES, _MAX_MEMORY_BYTES, _MAX_RSS_BYTES (0 = off), _EVENT_MIN_MS, _CANCEL_GRACE_MS, _AUDIT_FILE. Git credentials stay the machine's own (never sent by the control plane)
|
|
106
106
|
# LOCAL_AGENT_CONTENT_DENY_GLOBS=a/,*.x extra deny globs (they add to the built-in list: .env*, keys, credentials files, secrets/ ...)
|
|
107
|
+
# LOCAL_AGENT_RUNNER=agent2 F3-08c: the packaged agent loop (agent2, next to this agent) is the runner of job.assign. Needs everything LOCAL_AGENT_JOBS needs plus the LLM client (agent2 reaches the model ONLY
|
|
108
|
+
# through this agent: DLP, key holder). One of: LOCAL_AGENT_AGENT2_CMD='["java","-jar","/opt/agent2.jar"]' (this agent supervises it) or LOCAL_AGENT_AGENT2_URL=http://127.0.0.1:8765
|
|
109
|
+
# + LOCAL_AGENT_AGENT2_TOKEN + LOCAL_AGENT_AGENT2_BRIDGE_PORT (+ _CALLBACK_TOKEN). PolicyGate: asked over the v2 channel (policy.check, contract 2.2; no
|
|
110
|
+
# platform credential here; LOCAL_AGENT_AGENT2_POLICY_URL/_TOKEN of earlier drafts are ignored). agent2's /workspace calls go through the bridge, which signs for it:
|
|
111
|
+
# keep LOCAL_AGENT_SIGNATURE_MODE=enforce (default). _POLICY_TIMEOUT_MS (10000), _WORKSPACE_MAX_BODY_BYTES, _WORKSPACE_TIMEOUT_MS, _PIDFILE (spawn: ends an orphan agent2 at the next start).
|
|
112
|
+
# Tuning and the honest limits: docs/la-agent2-runner.md
|
package/package.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"repo": "VISIONHAMMER/vh-api-gateway",
|
|
3
3
|
"path": "docs/contracts/la-protocol-v2",
|
|
4
4
|
"branch": "release/qa",
|
|
5
|
-
"commit": "
|
|
5
|
+
"commit": "155a076c4ba8a57e8dd4ee0f4c7951b0a7dad5d1",
|
|
6
6
|
"note": "Copied by scripts/sync-la-protocol-v2-contract.mjs. Do not edit: change the gateway folder and sync again.",
|
|
7
7
|
"files": {
|
|
8
|
-
"README.md": "
|
|
8
|
+
"README.md": "7a9910594f8d2ee0795f6d8e8085f3a97f6064cdbad1f659d5952f3c934d1ba1",
|
|
9
9
|
"ack.schema.json": "d6dfdaa8b1ec2dd47db9e858cd047ca860196ab6b0ad350f8ccb5d2596441ccf",
|
|
10
10
|
"alert.schema.json": "01a8a77840be8ab7901ed7d07c3d68a2295bfaeb638694564e19e60be1747054",
|
|
11
11
|
"approval.decision.schema.json": "a9c7bc74d146465afde1d5830839165a02f232b830b5f6cfd8c8e91fb6da1563",
|
|
@@ -15,18 +15,20 @@
|
|
|
15
15
|
"content.request.schema.json": "440e3675246657e9aae23e51176168bc21c91283b5fcaf6782111a18110bf76e",
|
|
16
16
|
"content.response.schema.json": "c9a66ac9cbbcaec7fc182b3e3fa23b34877bd342592521c7e393779230332231",
|
|
17
17
|
"defs.schema.json": "f19fd27fd103d8da02de4506c086263c6236250c5c127aa3aab41833a90b743a",
|
|
18
|
-
"envelope.schema.json": "
|
|
18
|
+
"envelope.schema.json": "b95a34859b8560324d598a79ef8a5af03232c33277bd793e18c74e796b3b9faa",
|
|
19
19
|
"error.schema.json": "b1fd663d4a1ea73d88cb575422f15f8c7279001f9385ebb2d5e1889b5d5ef878",
|
|
20
20
|
"file.list.body.schema.json": "dd58ac2af91bbde53e0fd585dc55af0adb01380492d1604f21ed8e972d7ba8d1",
|
|
21
21
|
"heartbeat.schema.json": "8aff84a1ef3ca56bdff8819abb3b0ab01a2547814c2ea2f9ee893cb08f7a08f1",
|
|
22
22
|
"hello.schema.json": "a90a6bf0e9a8a20e4c6edcf2137931cf044846e7924977228275ef648a48934f",
|
|
23
23
|
"incident.candidate.schema.json": "ffec36aca6916079debc2c6060d093819ea3ea660b57c0c92ac3b541a0e99000",
|
|
24
|
-
"index.json": "
|
|
24
|
+
"index.json": "d37b59b77a9408f5d2495f616b4067f88023d610801143b4c62e9e5e0fd255e6",
|
|
25
25
|
"job.assign.schema.json": "c61bf42bfd94003ca373f94f2f7adb93026b1b96079beab97662497c629cbec1",
|
|
26
26
|
"job.cancel.schema.json": "40ef64373283e17306d857c9a40081689786015294b0b88ab8f68f063bd2cfb8",
|
|
27
27
|
"job.event.schema.json": "493d08f9db4f7ffd4a97403ee33ca4a73a65dc8f774d8f18ef0a137fdb5fc470",
|
|
28
28
|
"job.result.schema.json": "9cbc61211482557283b8f4df44e79011ead1abbacdb2d986138199c747edc043",
|
|
29
29
|
"log.stats.schema.json": "4b33b111946a19793a60756793294e90911b06f7050e48821759f2497ec17641",
|
|
30
|
+
"policy.check.schema.json": "ebd3b99628bc827784b495b239656fc918cc7ce792ee5554372dba084bbdcc09",
|
|
31
|
+
"policy.decision.schema.json": "84c1f14a1bfa698573d8cc621f4d6189f4ef09baa37e5e990fb94723e73296fe",
|
|
30
32
|
"ready.schema.json": "02e51ba5aad3357c9840c14eb45cb86b7aab0c10e349c94c720cda1a0b5b8366",
|
|
31
33
|
"register.schema.json": "bec30062cf99657a1ecb94bad004f502615901e36bc2b0ba62351ed021e059ac",
|
|
32
34
|
"scan.upload.schema.json": "b1bba86ee362f73afc94c8c216977c12f67f770490e81002d14789397692f23b",
|
|
@@ -51,6 +53,8 @@
|
|
|
51
53
|
"vectors/job.event.vectors.json": "03a2a2435918ba561821916bf4e435f2780210900139ecf68091acac6abf200c",
|
|
52
54
|
"vectors/job.result.vectors.json": "05995fbe65e5357705d75e9b660b517f54c6902069d36b62190b1b79f283e7a5",
|
|
53
55
|
"vectors/log.stats.vectors.json": "a8a67e09ef0f87b81f4028b77427ed7afd809c3f8534b8423a712b2ceef9a27f",
|
|
56
|
+
"vectors/policy.check.vectors.json": "7222d74b010e304af5733ed2802c9a907f7186351d3d4b5eedfe6ff1e1f2fc8d",
|
|
57
|
+
"vectors/policy.decision.vectors.json": "9fd61a5198568f4471dc28a2ce52eaa768f065d9cb7c76419af0c47f28295b09",
|
|
54
58
|
"vectors/ready.vectors.json": "16327f5e5eccc57a0ec635bcde96116feeeeec7ddbc4b621d4d070738cdc48d5",
|
|
55
59
|
"vectors/register.vectors.json": "b612e9462b1e2ac198442afccf450f1535daadc4c32d87f6a99b9752c343c486",
|
|
56
60
|
"vectors/scan.upload.vectors.json": "e8a6f73fe91ac58a3222b68cf28124c1254aa4f00c3ef8f472deb7de31cab76e",
|
|
@@ -5,10 +5,11 @@ sides. Design: vault `10 Library/Plans/Fase 3 Desenho Tecnico 2026-10-28.md` sec
|
|
|
5
5
|
implements it behind `deepdebug.agent-ws.protocol-v2.enabled` (default off, F3-05a); the LA side (F3-05b) does not
|
|
6
6
|
exist yet. v1 is untouched. The gateway packages `*.schema.json` + `index.json` from this folder as classpath
|
|
7
7
|
`la-protocol-v2/` (pom resource; both Dockerfiles copy this folder), so this folder stays the only source.
|
|
8
|
-
**Revision 2.
|
|
8
|
+
**Revision 2.2** (`index.json` `revision`, F3-08c, last section): two new frame types, `policy.check` / `policy.decision`, and the capability `policy.check`.
|
|
9
|
+
**Revision 2.1** (Fase 3): optional LLM key delivery, chunked webhooks, size/model in `incident.candidate`, `ready.capabilities`, `job.assign.options` and the capabilities `job.assign` / `job.options`.
|
|
9
10
|
|
|
10
11
|
## Files
|
|
11
|
-
- `envelope.schema.json`: common frame, closed (`additionalProperties:false`). `<type>.schema.json` (
|
|
12
|
+
- `envelope.schema.json`: common frame, closed (`additionalProperties:false`). `<type>.schema.json` (24 since 2.2):
|
|
12
13
|
one per allowlisted type, each `$ref`s the envelope and closes `payload`. `defs.schema.json`: shared types.
|
|
13
14
|
- `file.list.body.schema.json` (F3-07c): not a frame type; the schema of the DECODED body of a `content.response` for kind `file.list`
|
|
14
15
|
(below). `vectors/file.list.body.vectors.json` holds its vectors (same format, `schema` names that file).
|
|
@@ -310,3 +311,51 @@ The four job frames exist since 2.0 (their schemas did not change, except `optio
|
|
|
310
311
|
Everything is additive and optional: a 2.0 frame is valid under the 2.1 schemas (the vectors of 2.0 are unchanged and still pass), and a 2.1 peer talking to a
|
|
311
312
|
2.0 peer sends nothing the 2.0 peer cannot read, because each new field is gated by a capability the other side announced (above). Roll out the gateway that
|
|
312
313
|
has the 2.1 schemas first (it accepts both shapes), then the LAs. The files changed by 2.1 are listed with their sha256 in the PR; the LA re-syncs exactly those.
|
|
314
|
+
|
|
315
|
+
## Revision 2.2 (F3-08c): `policy.check` (LA -> CP) and `policy.decision` (CP -> LA)
|
|
316
|
+
Decisions of Edu 2026-11-04 (F3-08c): the agent2 that runs inside the customer's Local Agent must ask PolicyGate before a governed action (apply a patch,
|
|
317
|
+
open a pull request, run a shell command, ...), and the question goes **over this channel**, not over the platform's internal HTTP endpoint (whose bearer is a
|
|
318
|
+
platform secret that must never reach the customer). This is the only change of 2.2: **two new frame types (now 24)**, nothing else moves. A 2.1 peer is not
|
|
319
|
+
affected because each side sends the new types only after the other announced the capability (below).
|
|
320
|
+
|
|
321
|
+
**Capability `policy.check` (negotiation).** The LA announces `policy.check` in `register.payload.capabilities` and in `hello.payload.capabilities` (together
|
|
322
|
+
with `contract.v2.1`; a 2.0 CP would answer the new type with `unknown_type`). The CP announces `policy.check` in `ready.payload.capabilities` ONLY when its
|
|
323
|
+
feature is on (`deepdebug.policy.check-over-la.enabled`) AND this session announced `policy.check` and `contract.v2.1`. An LA sends a `policy.check` only after
|
|
324
|
+
`ready` listed `policy.check`; the CP sends a `policy.decision` only as the answer to a `policy.check` it received. The name matches the `capability` pattern, so
|
|
325
|
+
no enum changes. A CP with the feature off is exactly the 2.1 CP (it never lists the capability; a stray `policy.check` is acked and counted, no decision).
|
|
326
|
+
|
|
327
|
+
**`policy.check` (LA -> CP, unsigned: the authenticated session is the identity).** Envelope: `requestId` (UUID, REQUIRED, minted by the LA, the key that
|
|
328
|
+
correlates the answer), `agentId` (REQUIRED, as every LA frame) and `workspaceId` (an opaque id the LA declared; the CP checks it belongs to the session's tenant).
|
|
329
|
+
Payload (closed, no content):
|
|
330
|
+
- `actionType` (REQUIRED, `shortId`): the governed action, the PolicyGate action names (`APPLY_PATCH`, `OPEN_PR`, `RUN_SHELL_SAFE`, `RUN_SHELL_FREEFORM`, ...). An
|
|
331
|
+
unknown name is evaluated as `OTHER` (conservative), never refused at parse time.
|
|
332
|
+
- `requestHash` (REQUIRED, sha256): hash of the request the LA keeps; the same hash is the one in `audit.event approval_requested` and in the approval token, so a
|
|
333
|
+
decision (and a later approval) is bound to exactly this request.
|
|
334
|
+
- `toolName` (`shortId`), `modelAlias` (`shortId`), `incidentId` (`shortId`), `jobId` (UUID): optional labels the gate records or needs to scope the decision.
|
|
335
|
+
- `repoRefs` (1..8 of `{provider, org, repo}`): the repositories the action touches. NO branch, NO URL (a URL could carry a credential).
|
|
336
|
+
- **Not in the payload, on purpose, and refused by the closed schema:** `tenantId`, `agentId`, `executionOrigin` (the CP derives all three from the
|
|
337
|
+
authenticated session, so an LA cannot decide for another tenant or claim an origin), file paths, diffs, command text or any content.
|
|
338
|
+
|
|
339
|
+
**`policy.decision` (CP -> LA, signed, `paramsRaw` + `sig` as every CP -> LA command).** Envelope `requestId` = the check's. Payload (closed):
|
|
340
|
+
- `decision`: `ALLOW` | `DENY` | `REQUIRE_HUMAN`. `reasonCode`: a lower-case code (`^[a-z][a-z0-9_]{1,47}$`), no free text. These are the PolicyGate codes
|
|
341
|
+
(`autonomous`, `assist_mode`, `data_plane_origin`, `residency_denied`, `diff_incoherent`, `gate_error`, ...) plus three the CP produces itself:
|
|
342
|
+
`rate_limited`, `not_client_mode`, `workspace_unknown`. Every refusal is a signed DENY: the LA never has to guess.
|
|
343
|
+
- `shadow` (boolean): the verdict is advisory (the tenant is not enforcing): record it, never block on it. `approvalRequired` (boolean): true exactly when the
|
|
344
|
+
decision is an ENFORCED `REQUIRE_HUMAN` (`decision = REQUIRE_HUMAN` and `shadow = false`), else false. The schema pins that equivalence (`if/then/else`).
|
|
345
|
+
- `actionType` (the action actually evaluated: PolicyGate may reclassify), `effectiveMode` (optional), `requestHash` (the echo of the check's: the LA drops a
|
|
346
|
+
decision whose hash is not the one it asked for) and `policySnapshot.hash` (sha256 of the canonical effective policy facts the decision was taken under:
|
|
347
|
+
mode, source, ceiling, tier, shadow; it changes when the policy does, so an auditor can tell two decisions used the same policy).
|
|
348
|
+
- On `REQUIRE_HUMAN` + `approvalRequired` the LA pauses the action and follows the existing approval flow (`audit.event approval_requested`, then
|
|
349
|
+
`approval.decision`); on `DENY` the action does not run; on `ALLOW` it may.
|
|
350
|
+
|
|
351
|
+
**Limits (`index.json`).** `policyCheckPerMinute` = 120: more checks per session per minute are answered `DENY` `rate_limited`. `policyCheckTimeoutMs` =
|
|
352
|
+
10000: an LA that gets no `policy.decision` in that time treats the action as DENIED (fail closed) and does not retry the same `requestId`.
|
|
353
|
+
|
|
354
|
+
**CP rules (gateway behaviour, flag `deepdebug.policy.check-over-la.enabled`, default OFF).** The tenant, the agent and the origin `local-agent` come ONLY from the
|
|
355
|
+
authenticated session. Only a tenant in data plane mode `client` is served; `hybrid` and `vh` get `DENY` `not_client_mode`. The decision goes through the same
|
|
356
|
+
PolicyGate and is audited exactly like a decision of the internal endpoint (rule r3b included: client + origin local-agent is allowed by the normal rules).
|
|
357
|
+
Nothing of a check is logged beyond ids, the action type and the outcome.
|
|
358
|
+
|
|
359
|
+
**Compatible.** Additive: the 22 types, their schemas and their vectors are unchanged; the envelope `type` enum gained two names. Roll out the gateway first
|
|
360
|
+
(it accepts both shapes and lists the capability only for an LA that announced it), then the LAs. Vectors: `vectors/policy.check.vectors.json` (5 valid, 20 invalid)
|
|
361
|
+
and `vectors/policy.decision.vectors.json` (5 valid, 16 invalid).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"protocol": 2,
|
|
3
|
-
"revision": "2.
|
|
3
|
+
"revision": "2.2",
|
|
4
4
|
"baseId": "https://schemas.visionhammer.io/la-protocol-v2/",
|
|
5
5
|
"limits": {
|
|
6
6
|
"maxFrameBytes": 49152,
|
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
"webhookMaxBytes": 1048576,
|
|
20
20
|
"webhookSingleFrameRawBytes": 9216,
|
|
21
21
|
"webhookAssemblySeconds": 30,
|
|
22
|
-
"webhookMaxOpenAssemblies": 4
|
|
22
|
+
"webhookMaxOpenAssemblies": 4,
|
|
23
|
+
"policyCheckPerMinute": 120,
|
|
24
|
+
"policyCheckTimeoutMs": 10000
|
|
23
25
|
},
|
|
24
26
|
"types": {
|
|
25
27
|
"register": {
|
|
@@ -175,6 +177,20 @@
|
|
|
175
177
|
"control": false,
|
|
176
178
|
"signed": false,
|
|
177
179
|
"contentClass": "none"
|
|
180
|
+
},
|
|
181
|
+
"policy.check": {
|
|
182
|
+
"schema": "policy.check.schema.json",
|
|
183
|
+
"direction": "la->cp",
|
|
184
|
+
"control": false,
|
|
185
|
+
"signed": false,
|
|
186
|
+
"contentClass": "none"
|
|
187
|
+
},
|
|
188
|
+
"policy.decision": {
|
|
189
|
+
"schema": "policy.decision.schema.json",
|
|
190
|
+
"direction": "cp->la",
|
|
191
|
+
"control": false,
|
|
192
|
+
"signed": true,
|
|
193
|
+
"contentClass": "none"
|
|
178
194
|
}
|
|
179
195
|
}
|
|
180
196
|
}
|