deepdebug-local-agent 1.0.22 → 1.1.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 (188) hide show
  1. package/.claude/context.md +76 -0
  2. package/.claude/skills/README.md +25 -0
  3. package/.claude/skills/tdd-debugging/SKILL.md +53 -0
  4. package/.claude/skills/tdd-debugging/references/anti-patterns.md +43 -0
  5. package/.claude/skills/tdd-debugging/references/command-reference.md +46 -0
  6. package/.claude/standards/README.md +51 -0
  7. package/.claude/standards/clean-code.md +75 -0
  8. package/.claude/standards/code-review-checklist.md +61 -0
  9. package/.claude/standards/layered-architecture.md +163 -0
  10. package/.claude/standards/node.md +45 -0
  11. package/.claude/standards/security-owasp.md +114 -0
  12. package/.claude/standards/simplicity.md +42 -0
  13. package/.claude/standards/solid.md +64 -0
  14. package/.claude/standards/testing.md +59 -0
  15. package/.cursor/rules/standards.mdc +17 -0
  16. package/.github/workflows/build-byoc-image.yml +315 -0
  17. package/.github/workflows/deploy-gcp.yml +102 -0
  18. package/.github/workflows/npm-publish.yml +69 -8
  19. package/AGENTS.md +21 -0
  20. package/CHANGELOG.md +69 -0
  21. package/CLAUDE.md +13 -0
  22. package/CLAUDE_CODE_AGENT_INFINITE.md +5 -3
  23. package/Dockerfile +4 -0
  24. package/Dockerfile.full +55 -3
  25. package/README.md +81 -0
  26. package/bin/install.js +16 -5
  27. package/cloudbuild-full.yaml +57 -0
  28. package/docker-compose.yml +5 -3
  29. package/docs/OBSERVABILITY.md +164 -0
  30. package/docs/byoc-agent-image.md +70 -0
  31. package/docs/graphify-packaging-plan.md +133 -0
  32. package/docs/graphify-validation-run-2026-10-28.md +243 -0
  33. package/docs/legacy-connectors.md +268 -0
  34. package/docs/releasing.md +43 -0
  35. package/docs/signed-commands.md +163 -0
  36. package/env.example +30 -0
  37. package/helm/Chart.yaml +2 -2
  38. package/helm/templates/deployment.yaml +13 -3
  39. package/helm/templates/networkpolicy.yaml +22 -0
  40. package/helm/templates/secret.yaml +1 -0
  41. package/helm/values.yaml +13 -14
  42. package/mcp-server.js +4 -1
  43. package/package.json +12 -4
  44. package/scripts/sync-fixtures.mjs +246 -0
  45. package/scripts/upgrade-observability-push.sh +227 -0
  46. package/src/adapters/as400/as400-parser.js +12 -0
  47. package/src/adapters/as400/index.js +134 -0
  48. package/src/adapters/cobol/cobol-parser.js +37 -0
  49. package/src/adapters/cobol/index.js +151 -0
  50. package/src/adapters/ibmmq/ibmmq-parser.js +23 -0
  51. package/src/adapters/ibmmq/index.js +137 -0
  52. package/src/adapters/index.js +79 -0
  53. package/src/adapters/oracle-forms/index.js +145 -0
  54. package/src/adapters/oracle-forms/oracle-forms-parser.js +11 -0
  55. package/src/adapters/sap/index.js +146 -0
  56. package/src/adapters/sap/sap-parser.js +41 -0
  57. package/src/ai-engine.js +22 -22
  58. package/src/config/trusted-signing-keys.json +3 -0
  59. package/src/detectors/build-tool-detector.js +50 -0
  60. package/src/detectors/multi-project-detector.js +22 -22
  61. package/src/detectors/service-detector.js +24 -24
  62. package/src/exec-utils.js +26 -4
  63. package/src/git/base-git-provider.js +117 -117
  64. package/src/git/bitbucket-provider.js +2 -2
  65. package/src/git/git-provider-registry.js +18 -18
  66. package/src/git/github-provider.js +13 -13
  67. package/src/git/multi-repo-pr.js +161 -0
  68. package/src/mcp-http-server.js +32 -16
  69. package/src/observability/bundle-files.js +418 -0
  70. package/src/observability/detect.js +151 -0
  71. package/src/observability/provision.js +212 -0
  72. package/src/observability/render-cli.js +59 -0
  73. package/src/observability/routes.js +88 -0
  74. package/src/patch/patch-engine.js +28 -28
  75. package/src/patch-manager.js +85 -85
  76. package/src/patch_bkp.js +14 -14
  77. package/src/ports.js +4 -4
  78. package/src/routes/workspace.route.js +47 -16
  79. package/src/runtimes/base-runtime.js +75 -75
  80. package/src/runtimes/java/gradle-runtime.js +17 -17
  81. package/src/runtimes/java/java-integrations.js +25 -25
  82. package/src/runtimes/java/maven-runtime.js +34 -34
  83. package/src/runtimes/node/node-integrations.js +1 -1
  84. package/src/runtimes/node/npm-runtime.js +14 -14
  85. package/src/runtimes/node/yarn-runtime.js +4 -4
  86. package/src/runtimes/runtime-registry.js +25 -25
  87. package/src/server-local.js +4 -4
  88. package/src/server.js +1412 -335
  89. package/src/siblings/burst-suppressor.js +89 -0
  90. package/src/siblings/capabilities.js +273 -0
  91. package/src/siblings/env-compat.cjs +58 -0
  92. package/src/siblings/incident-forwarder.js +320 -0
  93. package/src/siblings/logger.js +15 -0
  94. package/src/siblings/metrics.js +94 -0
  95. package/src/utils/agent-auth.js +184 -0
  96. package/src/utils/approval-token.js +56 -0
  97. package/src/utils/bind-host.js +29 -0
  98. package/src/utils/branch-refspec.js +98 -0
  99. package/src/utils/child-env.js +34 -0
  100. package/src/utils/environment-diagnostics.js +55 -55
  101. package/src/utils/exec-policy.js +962 -0
  102. package/src/utils/exec-utils.js +15 -15
  103. package/src/utils/file-count.js +46 -0
  104. package/src/utils/fs-utils.js +10 -10
  105. package/src/utils/multi-repo-open.js +132 -0
  106. package/src/utils/open-gate.js +183 -0
  107. package/src/utils/ranged-read.js +65 -0
  108. package/src/utils/request-guards.js +77 -0
  109. package/src/utils/request-signing.js +342 -0
  110. package/src/utils/root-recovery.js +244 -0
  111. package/src/utils/safe-exec-args.js +58 -0
  112. package/src/utils/sandbox.js +182 -0
  113. package/src/utils/search-outcome.js +56 -0
  114. package/src/utils/workspace-root.js +80 -0
  115. package/src/validators/syntax-validator.js +149 -0
  116. package/src/workspace/detect-port.js +16 -16
  117. package/src/workspace/file-reader.js +1 -1
  118. package/src/workspace/process-manager.js +46 -46
  119. package/src/workspace-manager.js +86 -24
  120. package/test/adapters/as400/as400.test.js +121 -0
  121. package/test/adapters/cobol/cobol.test.js +217 -0
  122. package/test/adapters/ibmmq/ibmmq.test.js +122 -0
  123. package/test/adapters/oracle-forms/oracle-forms.test.js +180 -0
  124. package/test/adapters/registry.test.js +108 -0
  125. package/test/adapters/sap/sap.test.js +142 -0
  126. package/test/agent-auth.test.js +194 -0
  127. package/test/bind-host.test.js +26 -0
  128. package/test/branch-refspec.test.js +224 -0
  129. package/test/burst-suppressor.test.js +60 -0
  130. package/test/capabilities-access.test.js +47 -0
  131. package/test/capabilities.test.js +87 -0
  132. package/test/child-env.test.js +23 -0
  133. package/test/env-compat.test.js +71 -0
  134. package/test/exec-policy.test.js +463 -0
  135. package/test/file-count.test.js +64 -0
  136. package/test/incident-forwarder.test.js +231 -0
  137. package/test/multi-repo-open.test.js +180 -0
  138. package/test/multi-repo-pr.test.js +129 -0
  139. package/test/observability-detect.test.js +199 -0
  140. package/test/open-gate.test.js +341 -0
  141. package/test/ranged-read.test.js +60 -0
  142. package/test/request-guards.test.js +91 -0
  143. package/test/request-signing.test.js +284 -0
  144. package/test/root-recovery-sidecar.test.js +171 -0
  145. package/test/root-recovery.test.js +274 -0
  146. package/test/safe-exec-args.test.js +58 -0
  147. package/test/sandbox.test.js +98 -0
  148. package/test/search-outcome.test.js +50 -0
  149. package/test/workspace-root.test.js +64 -0
  150. package/tools/graphify-validation/README.md +92 -0
  151. package/tools/graphify-validation/config/questions.json +31 -0
  152. package/tools/graphify-validation/config/repos.json +9 -0
  153. package/tools/graphify-validation/config/symbols.json +6 -0
  154. package/tools/graphify-validation/fixtures/spring/expected.json +34 -0
  155. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AppConfig.java +29 -0
  156. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AuditLogger.java +15 -0
  157. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/Cache.java +7 -0
  158. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/CheckoutService.java +25 -0
  159. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/InMemoryCache.java +18 -0
  160. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/OrderController.java +22 -0
  161. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaymentGateway.java +5 -0
  162. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaypalGateway.java +11 -0
  163. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/RefundService.java +14 -0
  164. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/StripeGateway.java +11 -0
  165. package/tools/graphify-validation/lib/affected_vs_rg.py +81 -0
  166. package/tools/graphify-validation/lib/audit_hook/sitecustomize.py +22 -0
  167. package/tools/graphify-validation/lib/graph_metrics.py +96 -0
  168. package/tools/graphify-validation/lib/gv_common.py +130 -0
  169. package/tools/graphify-validation/lib/licenses.py +47 -0
  170. package/tools/graphify-validation/lib/pip_audit_summary.py +18 -0
  171. package/tools/graphify-validation/lib/recall.py +115 -0
  172. package/tools/graphify-validation/lib/report.py +226 -0
  173. package/tools/graphify-validation/lib/run_all.py +237 -0
  174. package/tools/graphify-validation/lib/run_graphify.py +144 -0
  175. package/tools/graphify-validation/lib/rusage_helper.py +17 -0
  176. package/tools/graphify-validation/lib/sample_edges.py +246 -0
  177. package/tools/graphify-validation/lib/spring_fixture.py +81 -0
  178. package/tools/graphify-validation/requirements-py311.lock +508 -0
  179. package/tools/graphify-validation/requirements-tools.in +1 -0
  180. package/tools/graphify-validation/requirements-tools.lock +371 -0
  181. package/tools/graphify-validation/requirements.in +1 -0
  182. package/tools/graphify-validation/requirements.lock +502 -0
  183. package/tools/graphify-validation/run.sh +43 -0
  184. package/tools/graphify-validation/tests/test_harness.py +151 -0
  185. package/cloudbuild-agent-qa.yaml +0 -43
  186. package/cloudbuild-agent-staging.yaml +0 -40
  187. package/cloudbuild.yaml.deprecated +0 -42
  188. package/src/local-agent-source.tar.gz +0 -0
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Adapter consumer / incident forwarder (sibling module).
3
+ *
4
+ * The legacy log-source adapters (src/adapters/*) emit a frozen 6-key 'error'
5
+ * event { adapter, errorCode, file, message, source, timestamp } but nothing was
6
+ * listening, so ingested errors went nowhere. This module is the missing
7
+ * consumer: server.js attaches `adapter.on('error', e => incidentForwarder
8
+ * .handleAdapterEvent(e, name))` in the existing boot loop, and configures the
9
+ * forwarder (gateway url + credentials) from the app.listen callback.
10
+ *
11
+ * Robustness contract (never silently drop):
12
+ * - malformed event -> validated + rejected, counted (forward_failure
13
+ * reason=malformed) and logged; NOT forwarded.
14
+ * - gateway unreachable -> retried with backoff; on final failure counted
15
+ * (reason=gateway_unreachable) and logged at error
16
+ * with the event; NOT thrown (boot/runtime safe).
17
+ * - backpressure -> bounded in-memory queue with a concurrency cap.
18
+ * When full, the OLDEST event is evicted, counted
19
+ * (dropped) and logged with a warning. Events that
20
+ * arrive before the forwarder is configured are
21
+ * buffered, not lost.
22
+ *
23
+ * The class is exported for tests (fresh instances, injectable fetch/metrics);
24
+ * server.js uses the shared singleton `incidentForwarder`.
25
+ */
26
+ import { logger as defaultLogger } from './logger.js';
27
+ import { BurstSuppressor } from './burst-suppressor.js';
28
+
29
+ const SIX_KEYS = ['adapter', 'errorCode', 'file', 'message', 'source', 'timestamp'];
30
+
31
+ // Contract errors: the endpoint answered, but rejected us structurally. Retrying
32
+ // cannot help (bad token, forbidden, wrong path) — it only burns budget and hides
33
+ // a misconfiguration. Fail loudly instead. Everything else (network, timeout, 5xx)
34
+ // is transport and stays on the retry/backoff path.
35
+ const CONTRACT_STATUSES = new Set([401, 403, 404]);
36
+
37
+ export class IncidentForwarder {
38
+ constructor(opts = {}) {
39
+ this.configured = false;
40
+ this.gatewayUrl = null;
41
+ this.apiKey = null;
42
+ this.tenantId = null;
43
+ this.incidentPath = opts.incidentPath
44
+ || process.env.DEEPDEBUG_ADAPTER_INCIDENT_PATH
45
+ || '/api/v1/internal/incident/create';
46
+ this.maxQueue = opts.maxQueue || 1000;
47
+ this.maxInflight = opts.maxInflight || 4;
48
+ this.maxRetries = opts.maxRetries ?? 2;
49
+ this.timeoutMs = opts.timeoutMs || 8000;
50
+ this.fetchImpl = opts.fetchImpl || globalThis.fetch;
51
+ this.logger = opts.logger || defaultLogger;
52
+ this.metrics = opts.metrics || null;
53
+
54
+ this.queue = [];
55
+ this.inflight = 0;
56
+ // Independent counters so tests/health can assert without the metrics module.
57
+ this.stats = { received: 0, forwarded: 0, failed: 0, dropped: 0, malformed: 0, contract: 0, suppressed: 0 };
58
+ // Queue-admission burst limiter (NOT dedup; see burst-suppressor.js).
59
+ this.suppressor = opts.suppressor || new BurstSuppressor({ ttlMs: opts.suppressTtlMs });
60
+ // Sticky contract-failure state, surfaced on /capabilities.
61
+ this.contractError = null; // { status, body, at }
62
+ this.contractPreflightResult = null; // { ok, status, reason, at }
63
+ }
64
+
65
+ /**
66
+ * Enable forwarding. Buffered events (received before config) start draining.
67
+ */
68
+ configure({ gatewayUrl, apiKey, tenantId, metrics, incidentPath, fetchImpl } = {}) {
69
+ this.gatewayUrl = gatewayUrl || this.gatewayUrl;
70
+ this.apiKey = apiKey || this.apiKey;
71
+ this.tenantId = tenantId || this.tenantId;
72
+ if (metrics) this.metrics = metrics;
73
+ if (incidentPath) this.incidentPath = incidentPath;
74
+ if (fetchImpl) this.fetchImpl = fetchImpl;
75
+ this.configured = Boolean(this.gatewayUrl);
76
+ if (!this.configured) {
77
+ this.logger.warn('[incident-forwarder] no gateway url; events will buffer until configured');
78
+ } else {
79
+ this.logger.info({ url: this.gatewayUrl + this.incidentPath, queued: this.queue.length },
80
+ '[incident-forwarder] configured');
81
+ }
82
+ this._drain();
83
+ return this;
84
+ }
85
+
86
+ /**
87
+ * Adapter 'error' handler. Validates, enqueues, and kicks the drain loop.
88
+ * Returns the outcome string (useful for tests): received | malformed | dropped.
89
+ */
90
+ handleAdapterEvent(evt, adapterName) {
91
+ this.stats.received++;
92
+ this.metrics?.incReceived?.(adapterName || (evt && evt.adapter) || 'unknown');
93
+
94
+ const norm = this._validate(evt);
95
+ if (!norm) {
96
+ this.stats.malformed++;
97
+ this.stats.failed++;
98
+ this.metrics?.incFailure?.('malformed');
99
+ this.logger.warn({ adapter: adapterName, evt: this._safe(evt) },
100
+ '[incident-forwarder] malformed adapter event rejected (not forwarded)');
101
+ return 'malformed';
102
+ }
103
+
104
+ // Burst control at queue ADMISSION (before the bounded queue). Recurrence
105
+ // across the TTL window still passes — it is the signal the gateway needs.
106
+ if (this.suppressor && this.suppressor.shouldSuppress(norm)) {
107
+ this.stats.suppressed++;
108
+ this.metrics?.incSuppressed?.(adapterName || norm.adapter || 'unknown');
109
+ return 'suppressed';
110
+ }
111
+
112
+ let outcome = 'received';
113
+ if (this.queue.length >= this.maxQueue) {
114
+ // Backpressure: never silently drop — evict oldest, count, log.
115
+ const victim = this.queue.shift();
116
+ this.stats.dropped++;
117
+ this.metrics?.incDropped?.();
118
+ this.logger.warn(
119
+ { adapter: adapterName, queue: this.maxQueue, droppedFingerprint: victim && victim.fingerprint },
120
+ '[incident-forwarder] queue full, evicted oldest event');
121
+ outcome = 'dropped';
122
+ }
123
+ this.queue.push(norm);
124
+ this._drain();
125
+ return outcome;
126
+ }
127
+
128
+ /** Wait until the queue is empty and all in-flight sends have settled (tests). */
129
+ async flush() {
130
+ while (this.queue.length > 0 || this.inflight > 0) {
131
+ await new Promise((r) => setTimeout(r, 5));
132
+ }
133
+ }
134
+
135
+ _drain() {
136
+ if (!this.configured) return; // buffer until configured
137
+ while (this.inflight < this.maxInflight && this.queue.length > 0) {
138
+ const evt = this.queue.shift();
139
+ this.inflight++;
140
+ this._send(evt, 0).finally(() => {
141
+ this.inflight--;
142
+ this._drain();
143
+ });
144
+ }
145
+ }
146
+
147
+ _headers() {
148
+ return {
149
+ 'Content-Type': 'application/json',
150
+ ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}),
151
+ ...(this.tenantId ? { 'X-Tenant-Id': this.tenantId } : {}),
152
+ 'X-Internal-Request': 'true',
153
+ };
154
+ }
155
+
156
+ async _post(url, payload) {
157
+ const ctrl = new AbortController();
158
+ const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
159
+ try {
160
+ return await this.fetchImpl(url, {
161
+ method: 'POST',
162
+ headers: this._headers(),
163
+ body: JSON.stringify(payload),
164
+ signal: ctrl.signal,
165
+ });
166
+ } finally {
167
+ clearTimeout(timer);
168
+ }
169
+ }
170
+
171
+ async _readBody(res) {
172
+ try {
173
+ const t = res && typeof res.text === 'function' ? await res.text() : '';
174
+ return (t || '').slice(0, 500);
175
+ } catch {
176
+ return '';
177
+ }
178
+ }
179
+
180
+ async _send(evt, attempt) {
181
+ const url = this.gatewayUrl.replace(/\/+$/, '') + this.incidentPath;
182
+ const body = this._toIncident(evt);
183
+ let res;
184
+ try {
185
+ res = await this._post(url, body);
186
+ } catch (err) {
187
+ // Transport (network / timeout / abort): retry.
188
+ return this._retryOrFail(evt, attempt, err.message, 'network');
189
+ }
190
+
191
+ if (res && res.ok) {
192
+ this.stats.forwarded++;
193
+ this.metrics?.incForwarded?.();
194
+ return true;
195
+ }
196
+
197
+ const status = res ? res.status : 0;
198
+ if (CONTRACT_STATUSES.has(status)) {
199
+ // CONTRACT error: do NOT retry. Fail loudly with the response body.
200
+ const respBody = await this._readBody(res);
201
+ this.stats.failed++;
202
+ this.stats.contract++;
203
+ this.contractError = { status, body: respBody, at: new Date().toISOString() };
204
+ this.metrics?.incFailure?.('contract', String(status));
205
+ this.logger.error(
206
+ { status, respBody, incident: body, url },
207
+ '[incident-forwarder] CONTRACT error from incident endpoint — not retrying (check AGENT_INTERNAL_TOKEN / path)');
208
+ return false;
209
+ }
210
+
211
+ // 5xx / 429 / other: transport-class, retry. (429 is rate limiting, not a
212
+ // contract error — it must keep backoff.)
213
+ return this._retryOrFail(evt, attempt, `gateway responded ${status}`, String(status));
214
+ }
215
+
216
+ async _retryOrFail(evt, attempt, reason, code) {
217
+ if (attempt < this.maxRetries) {
218
+ const backoff = Math.min(2000, 200 * 2 ** attempt);
219
+ await new Promise((r) => setTimeout(r, backoff));
220
+ return this._send(evt, attempt + 1);
221
+ }
222
+ this.stats.failed++;
223
+ this.metrics?.incFailure?.('transport', code || 'network');
224
+ this.logger.error({ reason, code, incident: this._toIncident(evt), attempts: attempt + 1 },
225
+ '[incident-forwarder] forward failed after retries (transport)');
226
+ return false;
227
+ }
228
+
229
+ /**
230
+ * One-shot boot probe of the incident endpoint. Sends a body WITHOUT tenantId
231
+ * so a correctly-authenticated call returns 400 ("tenantId required") and never
232
+ * creates an incident; 401/403 means the shared token is wrong, 404 the path.
233
+ * Records + logs the result. Never throws, never blocks startup.
234
+ */
235
+ async contractPreflight() {
236
+ if (!this.configured) {
237
+ this.contractPreflightResult = { ok: false, status: 0, reason: 'not_configured', at: new Date().toISOString() };
238
+ return this.contractPreflightResult;
239
+ }
240
+ const url = this.gatewayUrl.replace(/\/+$/, '') + this.incidentPath;
241
+ let result;
242
+ try {
243
+ const res = await this._post(url, { __preflight: true }); // no tenantId on purpose
244
+ const status = res ? res.status : 0;
245
+ if (status === 401 || status === 403) result = { ok: false, status, reason: 'auth_rejected' };
246
+ else if (status === 404) result = { ok: false, status, reason: 'path_not_found' };
247
+ else if (status === 400) result = { ok: true, status, reason: 'reachable_auth_ok' };
248
+ else if (status >= 200 && status < 300) result = { ok: true, status, reason: 'unexpected_2xx' };
249
+ else result = { ok: false, status, reason: 'transport_or_5xx' };
250
+ } catch (err) {
251
+ result = { ok: false, status: 0, reason: 'unreachable', error: err.message };
252
+ }
253
+ this.contractPreflightResult = { ...result, at: new Date().toISOString() };
254
+ const level = this.contractPreflightResult.ok ? 'info' : 'error';
255
+ this.logger[level]({ ...this.contractPreflightResult, url },
256
+ '[incident-forwarder] contract preflight');
257
+ return this.contractPreflightResult;
258
+ }
259
+
260
+ /** Live status for /capabilities surfacing. */
261
+ getStatus() {
262
+ return {
263
+ configured: this.configured,
264
+ stats: { ...this.stats },
265
+ queued: this.queue.length,
266
+ contract_error: this.contractError,
267
+ contract_preflight: this.contractPreflightResult,
268
+ contract_ok: !this.contractError
269
+ && (this.contractPreflightResult ? this.contractPreflightResult.ok : null),
270
+ };
271
+ }
272
+
273
+ /** Validate the frozen 6-key shape; return a normalized copy or null. */
274
+ _validate(evt) {
275
+ if (!evt || typeof evt !== 'object') return null;
276
+ for (const k of SIX_KEYS) {
277
+ if (!(k in evt)) return null;
278
+ }
279
+ if (typeof evt.message !== 'string' || evt.message.trim() === '') return null;
280
+ if (typeof evt.source !== 'string' || evt.source.trim() === '') return null;
281
+ return {
282
+ adapter: String(evt.adapter),
283
+ errorCode: evt.errorCode == null ? '' : String(evt.errorCode),
284
+ file: evt.file == null ? '' : String(evt.file),
285
+ message: String(evt.message),
286
+ source: String(evt.source),
287
+ timestamp: evt.timestamp,
288
+ };
289
+ }
290
+
291
+ /** Map the 6-key adapter event to the gateway incident-create payload. */
292
+ _toIncident(evt) {
293
+ return {
294
+ tenantId: this.tenantId || undefined,
295
+ service: evt.source,
296
+ message: evt.message,
297
+ severity: 'error',
298
+ environment: 'production',
299
+ detectedBy: 'local-agent-adapter',
300
+ logSource: 'legacy-adapter',
301
+ adapter: evt.adapter,
302
+ errorCode: evt.errorCode,
303
+ file: evt.file,
304
+ timestamp: evt.timestamp,
305
+ };
306
+ }
307
+
308
+ _safe(evt) {
309
+ try {
310
+ return JSON.parse(JSON.stringify(evt));
311
+ } catch {
312
+ return String(evt);
313
+ }
314
+ }
315
+ }
316
+
317
+ /** Shared singleton used by server.js wiring. */
318
+ export const incidentForwarder = new IncidentForwarder();
319
+
320
+ export default incidentForwarder;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Structured logger for the sibling modules (adapter consumer, capability
3
+ * preflight, metrics). server.js itself stays on console.* (frozen); the new
4
+ * sibling plane logs structured JSON via pino so operators get parseable output
5
+ * and honours the manifest-declared LOG_LEVEL (see docker-compose/helm), which
6
+ * the legacy console.* path never read.
7
+ */
8
+ import pino from 'pino';
9
+
10
+ export const logger = pino({
11
+ level: process.env.LOG_LEVEL || 'info',
12
+ base: { svc: 'local-agent' },
13
+ });
14
+
15
+ export default logger;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Self-metrics (sibling module). Exposes GET /metrics on the MAIN app (port
3
+ * 5055) because Helm already scrapes 5055/metrics (helm/values.yaml). Uses a
4
+ * dedicated prom-client Registry so it never collides with anything else.
5
+ *
6
+ * Minimum series (per task):
7
+ * agent_adapter_events_received_total{adapter} counter
8
+ * agent_adapter_events_forwarded_total counter
9
+ * agent_adapter_events_forward_failures_total{reason,code} counter
10
+ * reason is class-based: "contract" | "transport" | "malformed" so a
11
+ * Prometheus rule can alert on each class differently; code carries the
12
+ * HTTP status / "network" for detail.
13
+ * agent_mode{mode} gauge (1 = active)
14
+ * plus agent_adapter_events_dropped_total (backpressure), agent_adapter_events_
15
+ * suppressed_total{adapter} (burst suppression) and default runtime metrics.
16
+ */
17
+ import client from 'prom-client';
18
+ import { logger } from './logger.js';
19
+
20
+ const MODES = ['FULL_LOCAL', 'SPLIT_PLANE', 'DETECT_ONLY'];
21
+
22
+ export function startMetrics(app, { register } = {}) {
23
+ const registry = register || new client.Registry();
24
+ registry.setDefaultLabels({ service: 'local-agent' });
25
+ try {
26
+ client.collectDefaultMetrics({ register: registry });
27
+ } catch {
28
+ // default metrics already collected on this registry — ignore
29
+ }
30
+
31
+ const eventsReceived = new client.Counter({
32
+ name: 'agent_adapter_events_received_total',
33
+ help: 'Adapter error events received by the consumer',
34
+ labelNames: ['adapter'],
35
+ registers: [registry],
36
+ });
37
+ const eventsForwarded = new client.Counter({
38
+ name: 'agent_adapter_events_forwarded_total',
39
+ help: 'Adapter events successfully forwarded to the gateway',
40
+ registers: [registry],
41
+ });
42
+ const forwardFailures = new client.Counter({
43
+ name: 'agent_adapter_events_forward_failures_total',
44
+ help: 'Adapter events that failed to forward, by class (reason) + status (code)',
45
+ labelNames: ['reason', 'code'],
46
+ registers: [registry],
47
+ });
48
+ const eventsDropped = new client.Counter({
49
+ name: 'agent_adapter_events_dropped_total',
50
+ help: 'Adapter events evicted under backpressure',
51
+ registers: [registry],
52
+ });
53
+ const eventsSuppressed = new client.Counter({
54
+ name: 'agent_adapter_events_suppressed_total',
55
+ help: 'Adapter events suppressed by the local burst limiter (not forwarded)',
56
+ labelNames: ['adapter'],
57
+ registers: [registry],
58
+ });
59
+ const modeGauge = new client.Gauge({
60
+ name: 'agent_mode',
61
+ help: 'Derived operating mode (1 = active) — FULL_LOCAL|SPLIT_PLANE|DETECT_ONLY',
62
+ labelNames: ['mode'],
63
+ registers: [registry],
64
+ });
65
+ // Initialise every mode label to 0 so the series exist before the preflight runs.
66
+ for (const m of MODES) modeGauge.set({ mode: m }, 0);
67
+
68
+ if (app && typeof app.get === 'function') {
69
+ app.get('/metrics', async (_req, res) => {
70
+ try {
71
+ res.set('Content-Type', registry.contentType);
72
+ res.end(await registry.metrics());
73
+ } catch (err) {
74
+ res.status(500).end(`# metrics error: ${err.message}`);
75
+ }
76
+ });
77
+ logger.info('[metrics] serving GET /metrics on the main app (5055)');
78
+ }
79
+
80
+ return {
81
+ registry,
82
+ incReceived: (adapter) => eventsReceived.inc({ adapter: adapter || 'unknown' }),
83
+ incForwarded: () => eventsForwarded.inc(),
84
+ incFailure: (reason, code) => forwardFailures.inc({ reason: reason || 'unknown', code: code || 'none' }),
85
+ incDropped: () => eventsDropped.inc(),
86
+ incSuppressed: (adapter) => eventsSuppressed.inc({ adapter: adapter || 'unknown' }),
87
+ setMode: (mode) => {
88
+ for (const m of MODES) modeGauge.set({ mode: m }, m === mode ? 1 : 0);
89
+ if (!MODES.includes(mode)) modeGauge.set({ mode: mode || 'UNKNOWN' }, 1);
90
+ },
91
+ };
92
+ }
93
+
94
+ export default startMetrics;
@@ -0,0 +1,184 @@
1
+ /**
2
+ * agent-auth.js — authentication perimeter for the local agent's HTTP API.
3
+ *
4
+ * WHY. The agent exposes ~100 routes that read/write the workspace and run
5
+ * shell commands (/workspace/exec, /setup/run-command, /mcp/execute-command).
6
+ * It runs INSIDE customer environments, and before this module every one of
7
+ * those routes was unauthenticated on 0.0.0.0 — anyone on the customer LAN
8
+ * (or any web page, via the permissive CORS) had remote code execution.
9
+ *
10
+ * MODES (resolved once at boot from env):
11
+ * token LOCAL_AGENT_TOKEN is set: every non-public route needs
12
+ * `X-Agent-Token: <secret>` (constant-time compare), from any
13
+ * source. The Authorization header is NOT used, because on Cloud
14
+ * Run it carries the Google ID token the gateway mints.
15
+ * token (also) AGENT_SHARED_SECRET is set and LOCAL_AGENT_TOKEN is not:
16
+ * the gateway sets that secret on BYOC ACI agents and its
17
+ * AgentSharedSecretFilter sends it as X-Agent-Token on every
18
+ * call, so it is enforced (security review 2026-10-11: before,
19
+ * it was only forwarded on in-process calls).
20
+ * compat Cloud Run only (K_SERVICE set) when no token is configured:
21
+ * Cloud Run IAM (run.invoker) is the perimeter, the service must
22
+ * NOT be allUsers-invokable. Also reachable by an explicit
23
+ * LOCAL_AGENT_AUTH=compat anywhere (loud opt-out for callers that
24
+ * still do not send X-Agent-Token). A foreign browser Origin is
25
+ * refused in every mode but off.
26
+ * loopback Default outside Cloud Run when no token is configured
27
+ * (docker-compose / installer publish 127.0.0.1 and the gateway
28
+ * reaches the agent through the reverse WS tunnel, whose hop
29
+ * calls are loopback): only loopback socket + loopback Host (DNS
30
+ * rebinding) + no foreign Origin. Previously the default here was
31
+ * compat, i.e. unauthenticated shell exec for anyone who could
32
+ * reach the port (Helm ClusterIP, BYOC VMs). Also LOCAL_AGENT_AUTH=loopback.
33
+ * off LOCAL_AGENT_AUTH=off: explicit, loud opt-out.
34
+ *
35
+ * Public in every mode: GET /health and GET /metrics (probes / scrapes).
36
+ */
37
+ import crypto from "node:crypto";
38
+
39
+ export const AGENT_TOKEN_HEADER = "x-agent-token";
40
+
41
+ const PUBLIC_PATHS = new Set(["/health", "/metrics"]);
42
+ const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
43
+
44
+ /** Browser origins allowed to call the agent (dev tooling only). */
45
+ export const DEFAULT_ALLOWED_ORIGINS = Object.freeze([
46
+ "http://localhost:3010",
47
+ "http://localhost:3000",
48
+ "http://localhost:8085",
49
+ "http://127.0.0.1:3010",
50
+ "http://127.0.0.1:3000",
51
+ "http://127.0.0.1:8085",
52
+ "https://deepdebug.ai",
53
+ "https://app.deepdebug.ai",
54
+ ]);
55
+
56
+ export function resolveAuthConfig(env = process.env) {
57
+ const requested = (env.LOCAL_AGENT_AUTH || "").toLowerCase();
58
+ if (requested === "off") return { mode: "off", token: null };
59
+ const enforced = (env.LOCAL_AGENT_TOKEN || "").trim();
60
+ if (enforced) return { mode: "token", token: enforced };
61
+ if (requested === "loopback") return { mode: "loopback", token: null };
62
+ const shared = (env.AGENT_SHARED_SECRET || "").trim();
63
+ const cloudRun = Boolean(env.K_SERVICE);
64
+ if (requested === "compat") return { mode: "compat", token: shared || null, cloudRun, explicit: true };
65
+ if (shared) return { mode: "token", token: shared, source: "AGENT_SHARED_SECRET" };
66
+ if (cloudRun) return { mode: "compat", token: null, cloudRun: true };
67
+ return { mode: "loopback", token: null, implicit: true };
68
+ }
69
+
70
+ export function allowedOrigins(env = process.env) {
71
+ const extra = (env.LOCAL_AGENT_CORS_ORIGINS || "")
72
+ .split(",").map(s => s.trim()).filter(Boolean);
73
+ return new Set([...DEFAULT_ALLOWED_ORIGINS, ...extra]);
74
+ }
75
+
76
+ export function isLoopbackAddress(addr) {
77
+ if (!addr || typeof addr !== "string") return false;
78
+ const a = addr.startsWith("::ffff:") ? addr.slice(7) : addr;
79
+ return a === "::1" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(a);
80
+ }
81
+
82
+ export function isLoopbackHostHeader(host) {
83
+ if (!host || typeof host !== "string") return false;
84
+ let h = host.trim().toLowerCase();
85
+ if (h.startsWith("[")) {
86
+ const end = h.indexOf("]");
87
+ h = end > 0 ? h.slice(0, end + 1) : h;
88
+ } else {
89
+ h = h.split(":")[0];
90
+ }
91
+ return LOOPBACK_HOSTNAMES.has(h);
92
+ }
93
+
94
+ export function tokensEqual(a, b) {
95
+ if (typeof a !== "string" || typeof b !== "string") return false;
96
+ const ha = crypto.createHash("sha256").update(a).digest();
97
+ const hb = crypto.createHash("sha256").update(b).digest();
98
+ return crypto.timingSafeEqual(ha, hb) && a.length === b.length;
99
+ }
100
+
101
+ /**
102
+ * Pure decision function (unit-tested). Returns { ok, status?, reason? }.
103
+ * req-like: { method, path, headers, remoteAddress }
104
+ */
105
+ export function authorize(reqLike, cfg, origins = allowedOrigins()) {
106
+ const method = (reqLike.method || "GET").toUpperCase();
107
+ const p = reqLike.path || "/";
108
+ if ((method === "GET" || method === "HEAD") && PUBLIC_PATHS.has(p)) return { ok: true };
109
+ if (method === "OPTIONS") return { ok: true }; // CORS preflight; CORS layer decides
110
+
111
+ const headers = reqLike.headers || {};
112
+ if (cfg.mode !== "off" && headers.origin && !origins.has(headers.origin)) {
113
+ // No browser page needs the agent directly; a foreign Origin is a
114
+ // cross-site request (CSRF / drive-by) — refuse before any side effect.
115
+ return { ok: false, status: 403, reason: "browser origin not allowed" };
116
+ }
117
+ switch (cfg.mode) {
118
+ case "off":
119
+ case "compat":
120
+ return { ok: true };
121
+ case "token": {
122
+ const presented = headers[AGENT_TOKEN_HEADER];
123
+ if (tokensEqual(typeof presented === "string" ? presented : "", cfg.token)) return { ok: true };
124
+ return { ok: false, status: 401, reason: "missing or invalid X-Agent-Token" };
125
+ }
126
+ case "loopback":
127
+ default: {
128
+ if (!isLoopbackAddress(reqLike.remoteAddress)) {
129
+ return { ok: false, status: 401, reason: "remote access requires AGENT_SHARED_SECRET/LOCAL_AGENT_TOKEN" };
130
+ }
131
+ if (!isLoopbackHostHeader(headers.host)) {
132
+ return { ok: false, status: 403, reason: "non-loopback Host header rejected" };
133
+ }
134
+ return { ok: true };
135
+ }
136
+ }
137
+ }
138
+
139
+ /** Express middleware. Mount BEFORE any route. */
140
+ export function createAuthMiddleware(cfg = resolveAuthConfig(), origins = allowedOrigins()) {
141
+ return function agentAuth(req, res, next) {
142
+ const decision = authorize({
143
+ method: req.method,
144
+ path: req.path,
145
+ headers: req.headers,
146
+ remoteAddress: req.socket?.remoteAddress,
147
+ }, cfg, origins);
148
+ if (decision.ok) return next();
149
+ console.warn(`[agent-auth] ${decision.status} ${req.method} ${req.path} from ${req.socket?.remoteAddress}: ${decision.reason}`);
150
+ return res.status(decision.status).json({ ok: false, error: "unauthorized", reason: decision.reason });
151
+ };
152
+ }
153
+
154
+ /** Headers the agent's own in-process callers (WS tunnel) must send. */
155
+ export function internalAuthHeaders(cfg = resolveAuthConfig()) {
156
+ return cfg.token ? { "X-Agent-Token": cfg.token } : {};
157
+ }
158
+
159
+ /** CORS origin callback: exact allowlist only, no wildcard hosting domains. */
160
+ export function corsOriginCallback(origins = allowedOrigins()) {
161
+ return function (origin, callback) {
162
+ if (!origin) return callback(null, true); // server-to-server
163
+ if (origins.has(origin)) return callback(null, true);
164
+ console.warn(`CORS: blocked origin: ${origin}`);
165
+ return callback(null, false);
166
+ };
167
+ }
168
+
169
+ export function describeAuthMode(cfg) {
170
+ switch (cfg.mode) {
171
+ case "token": return cfg.source === "AGENT_SHARED_SECRET"
172
+ ? "token (X-Agent-Token required; enforcing AGENT_SHARED_SECRET)"
173
+ : "token (X-Agent-Token required)";
174
+ case "compat":
175
+ return "compat — token not enforced: remote callers are unauthenticated "
176
+ + (cfg.explicit ? "(LOCAL_AGENT_AUTH=compat opt-out; set LOCAL_AGENT_TOKEN instead)"
177
+ : "(Cloud Run: relying on IAM — ensure the service is NOT allUsers-invokable)");
178
+ case "loopback": return cfg.implicit
179
+ ? "loopback-only (default: no LOCAL_AGENT_TOKEN/AGENT_SHARED_SECRET; remote callers refused)"
180
+ : "loopback-only (LOCAL_AGENT_AUTH=loopback)";
181
+ case "off": return "OFF — LOCAL_AGENT_AUTH=off, every route is unauthenticated";
182
+ default: return cfg.mode;
183
+ }
184
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * approval-token.js — PolicyGate human-approval tokens (REQUIRE_HUMAN flow).
3
+ *
4
+ * A command outside the allowlist runs only when a human approved THAT exact
5
+ * command in PolicyGate. The gateway mints (HumanApprovalTokens.java):
6
+ *
7
+ * vhapr1.<keyId>.<base64url(payload JSON)>.<base64url(Ed25519 signature)>
8
+ *
9
+ * signed with the same platform key as request signatures, over
10
+ * "VH-HUMAN-APPROVAL-V1\n<keyId>\n<payload segment>". Payload:
11
+ * { typ: "vh-human-approval", tid, wid, sha256, iat, exp, jti, by, decision }
12
+ * where sha256 = hex SHA-256 of the exact command string (or of JSON.stringify
13
+ * (argv) for /workspace/run). Lifetime is capped at 15 minutes and each jti is
14
+ * single-use (shared nonce cache).
15
+ */
16
+ import crypto from "node:crypto";
17
+ import { sha256Hex } from "./request-signing.js";
18
+
19
+ export const APPROVAL_PREFIX = "vhapr1";
20
+ export const APPROVAL_SIG_PREFIX = "VH-HUMAN-APPROVAL-V1";
21
+ export const MAX_APPROVAL_LIFETIME_SECONDS = 15 * 60;
22
+
23
+ const b64urlDecode = (s) => Buffer.from(String(s).replace(/-/g, "+").replace(/_/g, "/"), "base64");
24
+
25
+ /**
26
+ * Returns { ok: true, approvedBy, decision } or { ok: false, reason }.
27
+ * opts: { subject, keys: Map, identity, requestTenantId, nonces, now }
28
+ */
29
+ export function verifyApprovalToken(token, { subject, keys, identity = {}, requestTenantId, nonces, now = () => Date.now() } = {}) {
30
+ if (typeof token !== "string" || token.length > 4096) return { ok: false, reason: "malformed approval token" };
31
+ const parts = token.split(".");
32
+ if (parts.length !== 4 || parts[0] !== APPROVAL_PREFIX) return { ok: false, reason: "malformed approval token" };
33
+ const [, keyId, payloadSeg, sigSeg] = parts;
34
+ const key = keys && keys.get(keyId);
35
+ if (!key) return { ok: false, reason: `unknown approval key id '${keyId.slice(0, 64)}'` };
36
+ const data = Buffer.from(`${APPROVAL_SIG_PREFIX}\n${keyId}\n${payloadSeg}`, "utf-8");
37
+ let good = false;
38
+ try { good = crypto.verify(null, data, key, b64urlDecode(sigSeg)); } catch { good = false; }
39
+ if (!good) return { ok: false, reason: "bad approval signature" };
40
+ let p;
41
+ try { p = JSON.parse(b64urlDecode(payloadSeg).toString("utf-8")); } catch { return { ok: false, reason: "malformed approval payload" }; }
42
+ if (p.typ !== "vh-human-approval") return { ok: false, reason: "not a human-approval token" };
43
+ const t = Math.floor(now() / 1000);
44
+ if (!Number.isInteger(p.exp) || !Number.isInteger(p.iat)) return { ok: false, reason: "approval token without iat/exp" };
45
+ if (p.exp - p.iat > MAX_APPROVAL_LIFETIME_SECONDS) return { ok: false, reason: "approval token lifetime too long" };
46
+ if (t > p.exp) return { ok: false, reason: "approval token expired" };
47
+ if (p.iat > t + 60) return { ok: false, reason: "approval token issued in the future" };
48
+ if (typeof p.sha256 !== "string" || p.sha256 !== sha256Hex(Buffer.from(String(subject ?? ""), "utf-8")))
49
+ return { ok: false, reason: "approval token is for a different command" };
50
+ if (identity.tenantId && p.tid !== identity.tenantId) return { ok: false, reason: "approval token tenant mismatch" };
51
+ if (identity.workspaceId && p.wid && p.wid !== identity.workspaceId) return { ok: false, reason: "approval token workspace mismatch" };
52
+ if (requestTenantId && p.tid !== requestTenantId) return { ok: false, reason: "approval token tenant differs from the signed request" };
53
+ if (!p.jti || !/^[A-Za-z0-9_-]{8,128}$/.test(p.jti)) return { ok: false, reason: "approval token without jti" };
54
+ if (nonces && !nonces.checkAndRemember(`apr:${p.jti}`)) return { ok: false, reason: "approval token already used" };
55
+ return { ok: true, approvedBy: p.by || null, decision: p.decision || null };
56
+ }