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.
Files changed (58) hide show
  1. package/.github/workflows/build-byoc-image.yml +9 -11
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +5 -0
  4. package/CLAUDE.md +1 -1
  5. package/cloudbuild-full.yaml +2 -1
  6. package/docs/byoc-agent-image.md +8 -7
  7. package/docs/examples/agent2-runner/docker-compose.yml +53 -0
  8. package/docs/la-agent2-runner.md +115 -0
  9. package/docs/la-jobs-and-llm.md +14 -1
  10. package/docs/la-protocol-v2.md +4 -2
  11. package/env.example +6 -0
  12. package/package.json +1 -1
  13. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +8 -4
  14. package/src/la-protocol-v2/contract/README.md +51 -2
  15. package/src/la-protocol-v2/contract/envelope.schema.json +3 -1
  16. package/src/la-protocol-v2/contract/index.json +18 -2
  17. package/src/la-protocol-v2/contract/policy.check.schema.json +97 -0
  18. package/src/la-protocol-v2/contract/policy.decision.schema.json +119 -0
  19. package/src/la-protocol-v2/jobs/agent2/api-client.js +150 -0
  20. package/src/la-protocol-v2/jobs/agent2/approvals.js +114 -0
  21. package/src/la-protocol-v2/jobs/agent2/bridge.js +299 -0
  22. package/src/la-protocol-v2/jobs/agent2/config.js +128 -0
  23. package/src/la-protocol-v2/jobs/agent2/frames.js +61 -0
  24. package/src/la-protocol-v2/jobs/agent2/policy-check.js +170 -0
  25. package/src/la-protocol-v2/jobs/agent2/runner.js +258 -0
  26. package/src/la-protocol-v2/jobs/agent2/supervisor.js +269 -0
  27. package/src/la-protocol-v2/jobs/git-exec.js +71 -0
  28. package/src/la-protocol-v2/jobs/install.js +14 -4
  29. package/src/la-protocol-v2/jobs/manager.js +30 -16
  30. package/src/la-protocol-v2/jobs/workspace-materializer.js +244 -0
  31. package/src/la-protocol-v2/llm/client.js +133 -12
  32. package/src/la-protocol-v2/llm/config.js +2 -0
  33. package/src/la-protocol-v2/runtime.js +36 -3
  34. package/src/server.js +4 -0
  35. package/src/utils/approval-token.js +3 -2
  36. package/test/contract/la-protocol-v2/vectors/policy.check.vectors.json +908 -0
  37. package/test/contract/la-protocol-v2/vectors/policy.decision.vectors.json +826 -0
  38. package/test/la-protocol-v2/agent2-approval-discovery.test.js +114 -0
  39. package/test/la-protocol-v2/agent2-config.test.js +103 -0
  40. package/test/la-protocol-v2/agent2-orphan.test.js +118 -0
  41. package/test/la-protocol-v2/agent2-policy-check.test.js +273 -0
  42. package/test/la-protocol-v2/agent2-real-e2e.test.js +89 -0
  43. package/test/la-protocol-v2/agent2-runner.test.js +510 -0
  44. package/test/la-protocol-v2/agent2-supervisor.test.js +185 -0
  45. package/test/la-protocol-v2/agent2-workspace-door.test.js +142 -0
  46. package/test/la-protocol-v2/agent2-workspace-real-server.test.js +137 -0
  47. package/test/la-protocol-v2/contract-v21.test.js +2 -2
  48. package/test/la-protocol-v2/contract.test.js +8 -7
  49. package/test/la-protocol-v2/helpers/agent2-rig.js +96 -0
  50. package/test/la-protocol-v2/helpers/fake-agent2-cli.js +22 -0
  51. package/test/la-protocol-v2/helpers/fake-agent2.js +280 -0
  52. package/test/la-protocol-v2/helpers/fake-la-api.js +29 -0
  53. package/test/la-protocol-v2/helpers/fake-llm.js +11 -0
  54. package/test/la-protocol-v2/helpers/reference-cp.js +6 -3
  55. package/test/la-protocol-v2/helpers/spawn-agent.js +4 -3
  56. package/test/la-protocol-v2/helpers/supervisor-exit-script.js +15 -0
  57. package/test/la-protocol-v2/llm-messages.test.js +132 -0
  58. 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
- # Triggers:
14
- # - push of a v* tag / published GitHub release (tag must be on main).
15
- # NOTE: tags pushed by npm-publish.yml's auto-bump use GITHUB_TOKEN and so do
16
- # NOT trigger other workflows; run this manually (workflow_dispatch) for those.
17
- # - workflow_dispatch from main (version defaults to package.json).
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
- DEPLOYER_SA: github-deployer@visionhammer.iam.gserviceaccount.com
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 github-deployer to write signatures next to the image (same repository,
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).
@@ -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: 'us-central1-docker.pkg.dev/visionhammer/visionhammer-docker-public/local-agent-full'
14
+ _IMAGE: 'europe-west1-docker.pkg.dev/visionhammer/visionhammer-docker-public-eu/local-agent-full'
14
15
  _VERSION: ''
15
16
  _GIT_SHA: ''
16
17
 
@@ -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`; or push a `v*` tag / publish a release on a
18
- commit that is on `main`).
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` for tag and
30
- release runs). The `supply-chain` job signs `:<semver>@sha256:<digest>` with
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
- - `github-deployer@visionhammer.iam.gserviceaccount.com` needs
67
- `roles/artifactregistry.writer` on the `visionhammer-docker-public-eu` repository (europe-west1; today granted at project level)
68
- (and `artifactregistry.reader` for the overwrite guard).
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`.
@@ -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`.
@@ -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 (`src/la-protocol-v2/contract/CONTRACT_SOURCE.json` names the gateway commit and branch). With every flag off the agent announces exactly what it
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 294 frame vectors of F3-22 + 2.1, valid and invalid, the 54 `file.list` body vectors, plus the drift check), `traces.test.js` (the 34
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepdebug-local-agent",
3
- "version": "1.10.0",
3
+ "version": "1.12.0",
4
4
  "description": "DeepDebug Local Agent - AI-powered code debugging assistant",
5
5
  "private": false,
6
6
  "type": "module",
@@ -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": "b9621b2a30bb38521b9caab24a79e8bdf536a2d3",
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": "9a6f2f3adc098aba38c2da793c1c3db9bb67d1ec5343f74533dff667c2c1e0fb",
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": "92b3e5b31e6235689a1c22fae1d1eab3f93d377a19032809e47ee014c0ce67e1",
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": "de79c065217e1fa8430c4266ec19fd57df35fc4182eb7c7246b6f1ea03d2630c",
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.1** (`index.json` `revision`, Fase 3, last section): optional LLM key delivery, chunked webhooks, size/model in `incident.candidate`, `ready.capabilities`, `job.assign.options` and the capabilities `job.assign` / `job.options`.
8
+ **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` (22):
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).
@@ -43,7 +43,9 @@
43
43
  "incident.candidate",
44
44
  "log.stats",
45
45
  "audit.event",
46
- "alert"
46
+ "alert",
47
+ "policy.check",
48
+ "policy.decision"
47
49
  ]
48
50
  },
49
51
  "msgId": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "protocol": 2,
3
- "revision": "2.1",
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
  }