deepdebug-local-agent 1.0.23 → 1.2.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 (178) 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 +76 -8
  19. package/AGENTS.md +21 -0
  20. package/CHANGELOG.md +76 -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/bin/install.js +16 -5
  26. package/cloudbuild-full.yaml +57 -0
  27. package/docker-compose.yml +5 -3
  28. package/docs/OBSERVABILITY.md +164 -0
  29. package/docs/byoc-agent-image.md +70 -0
  30. package/docs/dlp.md +78 -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/releasing.md +37 -0
  34. package/docs/signed-commands.md +163 -0
  35. package/env.example +34 -0
  36. package/helm/Chart.yaml +2 -2
  37. package/helm/templates/deployment.yaml +13 -3
  38. package/helm/templates/networkpolicy.yaml +22 -0
  39. package/helm/templates/secret.yaml +1 -0
  40. package/helm/values.yaml +13 -14
  41. package/mcp-server.js +4 -1
  42. package/package.json +9 -4
  43. package/scripts/next-version.mjs +75 -0
  44. package/scripts/upgrade-observability-push.sh +227 -0
  45. package/src/ai-engine.js +22 -22
  46. package/src/config/trusted-signing-keys.json +3 -0
  47. package/src/detectors/build-tool-detector.js +50 -0
  48. package/src/detectors/multi-project-detector.js +22 -22
  49. package/src/detectors/service-detector.js +24 -24
  50. package/src/dlp/engine.js +111 -0
  51. package/src/dlp/index.js +137 -0
  52. package/src/dlp/rules.js +97 -0
  53. package/src/dlp/validators.js +123 -0
  54. package/src/exec-utils.js +26 -4
  55. package/src/git/base-git-provider.js +117 -117
  56. package/src/git/bitbucket-provider.js +2 -2
  57. package/src/git/git-provider-registry.js +18 -18
  58. package/src/git/github-provider.js +13 -13
  59. package/src/git/multi-repo-pr.js +161 -0
  60. package/src/mcp-http-server.js +32 -16
  61. package/src/observability/bundle-files.js +418 -0
  62. package/src/observability/detect.js +151 -0
  63. package/src/observability/provision.js +212 -0
  64. package/src/observability/render-cli.js +59 -0
  65. package/src/observability/routes.js +88 -0
  66. package/src/patch/patch-engine.js +28 -28
  67. package/src/patch-manager.js +85 -85
  68. package/src/patch_bkp.js +14 -14
  69. package/src/ports.js +4 -4
  70. package/src/routes/workspace.route.js +47 -16
  71. package/src/runtimes/base-runtime.js +75 -75
  72. package/src/runtimes/java/gradle-runtime.js +17 -17
  73. package/src/runtimes/java/java-integrations.js +25 -25
  74. package/src/runtimes/java/maven-runtime.js +34 -34
  75. package/src/runtimes/node/node-integrations.js +1 -1
  76. package/src/runtimes/node/npm-runtime.js +14 -14
  77. package/src/runtimes/node/yarn-runtime.js +4 -4
  78. package/src/runtimes/runtime-registry.js +25 -25
  79. package/src/server-local.js +4 -4
  80. package/src/server.js +1366 -333
  81. package/src/siblings/burst-suppressor.js +89 -0
  82. package/src/siblings/capabilities.js +273 -0
  83. package/src/siblings/env-compat.cjs +58 -0
  84. package/src/siblings/incident-forwarder.js +322 -0
  85. package/src/siblings/logger.js +15 -0
  86. package/src/siblings/metrics.js +94 -0
  87. package/src/utils/agent-auth.js +184 -0
  88. package/src/utils/approval-token.js +56 -0
  89. package/src/utils/bind-host.js +29 -0
  90. package/src/utils/branch-refspec.js +98 -0
  91. package/src/utils/child-env.js +34 -0
  92. package/src/utils/environment-diagnostics.js +55 -55
  93. package/src/utils/exec-policy.js +962 -0
  94. package/src/utils/exec-utils.js +15 -15
  95. package/src/utils/file-count.js +46 -0
  96. package/src/utils/fs-utils.js +10 -10
  97. package/src/utils/multi-repo-open.js +132 -0
  98. package/src/utils/open-gate.js +183 -0
  99. package/src/utils/ranged-read.js +65 -0
  100. package/src/utils/request-guards.js +77 -0
  101. package/src/utils/request-signing.js +342 -0
  102. package/src/utils/root-recovery.js +244 -0
  103. package/src/utils/safe-exec-args.js +58 -0
  104. package/src/utils/sandbox.js +182 -0
  105. package/src/utils/search-outcome.js +56 -0
  106. package/src/utils/workspace-root.js +80 -0
  107. package/src/validators/syntax-validator.js +149 -0
  108. package/src/workspace/detect-port.js +16 -16
  109. package/src/workspace/file-reader.js +1 -1
  110. package/src/workspace/process-manager.js +46 -46
  111. package/src/workspace-manager.js +86 -24
  112. package/test/adapters/registry.test.js +9 -2
  113. package/test/agent-auth.test.js +194 -0
  114. package/test/bind-host.test.js +26 -0
  115. package/test/branch-refspec.test.js +224 -0
  116. package/test/burst-suppressor.test.js +60 -0
  117. package/test/capabilities-access.test.js +47 -0
  118. package/test/capabilities.test.js +87 -0
  119. package/test/child-env.test.js +23 -0
  120. package/test/dlp/engine.test.js +173 -0
  121. package/test/dlp/fixtures.js +126 -0
  122. package/test/dlp/index.test.js +166 -0
  123. package/test/env-compat.test.js +71 -0
  124. package/test/exec-policy.test.js +463 -0
  125. package/test/file-count.test.js +64 -0
  126. package/test/incident-forwarder.test.js +231 -0
  127. package/test/multi-repo-open.test.js +180 -0
  128. package/test/multi-repo-pr.test.js +129 -0
  129. package/test/next-version.test.js +48 -0
  130. package/test/observability-detect.test.js +199 -0
  131. package/test/open-gate.test.js +341 -0
  132. package/test/ranged-read.test.js +60 -0
  133. package/test/request-guards.test.js +91 -0
  134. package/test/request-signing.test.js +284 -0
  135. package/test/root-recovery-sidecar.test.js +171 -0
  136. package/test/root-recovery.test.js +274 -0
  137. package/test/safe-exec-args.test.js +58 -0
  138. package/test/sandbox.test.js +98 -0
  139. package/test/search-outcome.test.js +50 -0
  140. package/test/workspace-root.test.js +64 -0
  141. package/tools/graphify-validation/README.md +92 -0
  142. package/tools/graphify-validation/config/questions.json +31 -0
  143. package/tools/graphify-validation/config/repos.json +9 -0
  144. package/tools/graphify-validation/config/symbols.json +6 -0
  145. package/tools/graphify-validation/fixtures/spring/expected.json +34 -0
  146. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AppConfig.java +29 -0
  147. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AuditLogger.java +15 -0
  148. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/Cache.java +7 -0
  149. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/CheckoutService.java +25 -0
  150. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/InMemoryCache.java +18 -0
  151. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/OrderController.java +22 -0
  152. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaymentGateway.java +5 -0
  153. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaypalGateway.java +11 -0
  154. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/RefundService.java +14 -0
  155. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/StripeGateway.java +11 -0
  156. package/tools/graphify-validation/lib/affected_vs_rg.py +81 -0
  157. package/tools/graphify-validation/lib/audit_hook/sitecustomize.py +22 -0
  158. package/tools/graphify-validation/lib/graph_metrics.py +96 -0
  159. package/tools/graphify-validation/lib/gv_common.py +130 -0
  160. package/tools/graphify-validation/lib/licenses.py +47 -0
  161. package/tools/graphify-validation/lib/pip_audit_summary.py +18 -0
  162. package/tools/graphify-validation/lib/recall.py +115 -0
  163. package/tools/graphify-validation/lib/report.py +226 -0
  164. package/tools/graphify-validation/lib/run_all.py +237 -0
  165. package/tools/graphify-validation/lib/run_graphify.py +144 -0
  166. package/tools/graphify-validation/lib/rusage_helper.py +17 -0
  167. package/tools/graphify-validation/lib/sample_edges.py +246 -0
  168. package/tools/graphify-validation/lib/spring_fixture.py +81 -0
  169. package/tools/graphify-validation/requirements-py311.lock +508 -0
  170. package/tools/graphify-validation/requirements-tools.in +1 -0
  171. package/tools/graphify-validation/requirements-tools.lock +371 -0
  172. package/tools/graphify-validation/requirements.in +1 -0
  173. package/tools/graphify-validation/requirements.lock +502 -0
  174. package/tools/graphify-validation/run.sh +43 -0
  175. package/tools/graphify-validation/tests/test_harness.py +151 -0
  176. package/cloudbuild-agent-qa.yaml +0 -43
  177. package/cloudbuild-agent-staging.yaml +0 -40
  178. package/cloudbuild.yaml.deprecated +0 -42
@@ -0,0 +1,199 @@
1
+ // test/observability-detect.test.js
2
+ // The zero-observability decision must be conservative: recommend 'provision'
3
+ // ONLY when nothing already ships logs/metrics, and never count OUR own stack
4
+ // as the client's pre-existing observability.
5
+
6
+ import { test, describe } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import {
9
+ classifyObservability,
10
+ ownStackRunning,
11
+ OBSERVABILITY_SIGNALS,
12
+ } from '../src/observability/detect.js';
13
+ import { validateProvisionInput } from '../src/observability/provision.js';
14
+ import { renderBundle, COMPOSE_PROJECT } from '../src/observability/bundle-files.js';
15
+
16
+ describe('classifyObservability', () => {
17
+ test('empty host => provision', () => {
18
+ const r = classifyObservability({ containers: [], processes: [] });
19
+ assert.equal(r.hasObservability, false);
20
+ assert.equal(r.recommendation, 'provision');
21
+ assert.deepEqual(r.found, []);
22
+ });
23
+
24
+ test('a running Grafana/Loki (client-owned) => skip, not provision', () => {
25
+ const r = classifyObservability({
26
+ containers: [
27
+ { name: 'grafana', image: 'grafana/grafana:10.0.0', project: 'monitoring' },
28
+ { name: 'loki', image: 'grafana/loki:2.9.0', project: 'monitoring' },
29
+ ],
30
+ });
31
+ assert.equal(r.hasObservability, true);
32
+ assert.equal(r.recommendation, 'skip');
33
+ assert.ok(r.found.includes('grafana'));
34
+ assert.ok(r.found.includes('loki'));
35
+ });
36
+
37
+ test('a Datadog agent process (no docker) => skip', () => {
38
+ const r = classifyObservability({
39
+ containers: [],
40
+ processes: ['/opt/datadog-agent/bin/agent/agent run'],
41
+ });
42
+ assert.equal(r.hasObservability, true);
43
+ assert.equal(r.recommendation, 'skip');
44
+ assert.ok(r.found.includes('datadog'));
45
+ });
46
+
47
+ test('OUR OWN stack does not count as the client already having observability', () => {
48
+ const r = classifyObservability({
49
+ containers: [
50
+ { name: 'deepdebug-observability-grafana-1', image: 'grafana/grafana:11.1.0', project: COMPOSE_PROJECT },
51
+ { name: 'deepdebug-observability-loki-1', image: 'grafana/loki:3.1.1', project: COMPOSE_PROJECT },
52
+ { name: 'app', image: 'mycompany/app:latest', project: 'app' },
53
+ ],
54
+ });
55
+ assert.equal(r.hasObservability, false, 'only our stack is present => client has none');
56
+ assert.equal(r.recommendation, 'provision');
57
+ assert.equal(r.ours.length, 2);
58
+ });
59
+
60
+ test('client app containers alone => provision', () => {
61
+ const r = classifyObservability({
62
+ containers: [
63
+ { name: 'nginx', image: 'nginx:1.25', project: 'web' },
64
+ { name: 'api', image: 'mycompany/api:2.0', project: 'web' },
65
+ { name: 'postgres', image: 'postgres:16', project: 'web' },
66
+ ],
67
+ });
68
+ assert.equal(r.hasObservability, false);
69
+ assert.equal(r.recommendation, 'provision');
70
+ });
71
+
72
+ test('ownStackRunning reflects the compose project label', () => {
73
+ assert.equal(ownStackRunning([{ name: 'x', project: COMPOSE_PROJECT }]), true);
74
+ assert.equal(ownStackRunning([{ name: 'x', project: 'other' }]), false);
75
+ assert.equal(ownStackRunning([]), false);
76
+ });
77
+
78
+ test('signal list is non-trivial and lowercase', () => {
79
+ assert.ok(OBSERVABILITY_SIGNALS.length >= 10);
80
+ for (const s of OBSERVABILITY_SIGNALS) assert.equal(s, s.toLowerCase());
81
+ });
82
+ });
83
+
84
+ describe('renderBundle', () => {
85
+ const rendered = renderBundle({
86
+ gatewayUrl: 'https://api.visionhammer.io/',
87
+ triggerSecret: 'vht_deadbeef.secretpart',
88
+ environment: 'production',
89
+ });
90
+
91
+ test('produces the full file set', () => {
92
+ for (const p of [
93
+ 'docker-compose.yml',
94
+ 'loki/loki-config.yaml',
95
+ 'loki/rules/fake/client.yaml',
96
+ 'alloy/config.alloy',
97
+ 'alertmanager/alertmanager.yml',
98
+ 'grafana/provisioning/datasources/loki.yaml',
99
+ 'grafana/dashboards/ddo-logs.json',
100
+ 'caddy/Caddyfile',
101
+ 'docker-compose.tunnel.yml',
102
+ ]) {
103
+ assert.ok(rendered[p] && rendered[p].length > 0, `missing ${p}`);
104
+ }
105
+ });
106
+
107
+ test('tunnel overlay bakes the bearer token into the caddy gate', () => {
108
+ const withToken = renderBundle({
109
+ gatewayUrl: 'https://api.visionhammer.io',
110
+ triggerSecret: 'vht_deadbeef.secretpart',
111
+ lokiTunnelToken: 'tok_123abc',
112
+ });
113
+ const caddy = withToken['caddy/Caddyfile'];
114
+ assert.ok(caddy.includes('Bearer tok_123abc'), 'caddy must require the exact bearer token');
115
+ assert.ok(!caddy.includes('__LOKI_TUNNEL_TOKEN__'), 'token placeholder must be resolved');
116
+ // The overlay exposes the auth gate, never Loki directly.
117
+ assert.ok(withToken['docker-compose.tunnel.yml'].includes('http://loki-auth:8080'));
118
+ });
119
+
120
+ test('trailing slash on gatewayUrl is stripped in the webhook URL', () => {
121
+ const am = rendered['alertmanager/alertmanager.yml'];
122
+ assert.ok(am.includes("url: 'https://api.visionhammer.io/api/v1/webhooks/alertmanager'"));
123
+ assert.ok(!am.includes('.io//api'), 'double slash must not appear');
124
+ });
125
+
126
+ test('alertmanager sends the secret as a bearer header, never in the URL', () => {
127
+ const am = rendered['alertmanager/alertmanager.yml'];
128
+ assert.ok(!am.includes('?s='), 'secret must not be in the webhook URL (Cloud Run logs URLs)');
129
+ assert.ok(am.includes("credentials: 'vht_deadbeef.secretpart'"));
130
+ assert.ok(am.includes('type: Bearer'));
131
+ });
132
+
133
+ test('the error rule never alerts on our own containers on the host', () => {
134
+ const rule = rendered['loki/rules/fake/client.yaml'];
135
+ assert.ok(rule.includes('service!~"deepdebug-observability-.*|vh-obs-.*|visionhammer-agent"'));
136
+ });
137
+
138
+ test('alloy pushes the docker stream to the gateway as OTLP JSON with the tenant secret', () => {
139
+ const alloy = rendered['alloy/config.alloy'];
140
+ assert.ok(alloy.includes('otelcol.receiver.loki.visionhammer.receiver'), 'loki stream must fan out to the push');
141
+ assert.ok(alloy.includes('logs_endpoint = "https://api.visionhammer.io/api/v1/webhooks/logs/otlp"'));
142
+ assert.ok(alloy.includes('encoding = "json"'), 'gateway accepts JSON only');
143
+ assert.ok(alloy.includes('"Authorization" = "Bearer vht_deadbeef.secretpart"'));
144
+ });
145
+
146
+ test('no placeholders survive rendering', () => {
147
+ for (const [p, content] of Object.entries(rendered)) {
148
+ assert.ok(!content.includes('__GATEWAY_URL__'), `__GATEWAY_URL__ left in ${p}`);
149
+ assert.ok(!content.includes('__TRIGGER_SECRET__'), `__TRIGGER_SECRET__ left in ${p}`);
150
+ assert.ok(!content.includes('__ENVIRONMENT__'), `__ENVIRONMENT__ left in ${p}`);
151
+ }
152
+ });
153
+
154
+ test('the dashboard data link and static env label carry the tenant secret + env', () => {
155
+ const dash = rendered['grafana/dashboards/ddo-logs.json'];
156
+ assert.ok(dash.includes('/api/v1/webhooks/from-log?s=vht_deadbeef.secretpart'));
157
+ const alloy = rendered['alloy/config.alloy'];
158
+ assert.ok(alloy.includes('environment = "production"'));
159
+ });
160
+
161
+ test('loki ruler is wired for delivery (v2 API) and accepts the docker backlog', () => {
162
+ const loki = rendered['loki/loki-config.yaml'];
163
+ // Alertmanager v0.27 removed the v1 push API the ruler defaults to.
164
+ assert.ok(loki.includes('enable_alertmanager_v2: true'),
165
+ 'ruler must POST alerts on the v2 endpoint or they never arrive');
166
+ // The rule lives under the "fake" tenant dir (auth_enabled=false) or it is
167
+ // never loaded.
168
+ assert.ok(rendered['loki/rules/fake/client.yaml'],
169
+ 'rule must be under rules/<tenant>/ to be loaded by the local ruler');
170
+ // A tight reject window drops the on-boot docker log backlog.
171
+ assert.ok(loki.includes('reject_old_samples_max_age: 720h'),
172
+ 'reject window must clear the existing docker log backlog on first start');
173
+ });
174
+
175
+ test('missing inputs throw', () => {
176
+ assert.throws(() => renderBundle({ gatewayUrl: 'x' }));
177
+ assert.throws(() => renderBundle({ triggerSecret: 'y' }));
178
+ });
179
+ });
180
+
181
+ describe('validateProvisionInput', () => {
182
+ const ok = { gatewayUrl: 'https://api.visionhammer.io', triggerSecret: 'vht_deadbeef.secretpart', environment: 'production' };
183
+
184
+ test('accepts a plain https origin, a vht_ secret and a simple environment', () => {
185
+ assert.equal(validateProvisionInput(ok), null);
186
+ });
187
+
188
+ test('rejects values that would break out of the rendered YAML/Alloy/.env', () => {
189
+ assert.ok(validateProvisionInput({ ...ok, triggerSecret: "vht_a'\n url: http://evil" }));
190
+ assert.ok(validateProvisionInput({ ...ok, environment: 'prod"}' }));
191
+ assert.ok(validateProvisionInput({ ...ok, gatewayUrl: 'https://api.visionhammer.io/?x=1' }));
192
+ assert.ok(validateProvisionInput({ ...ok, grafanaPassword: 'a\nGF_X=1' }));
193
+ });
194
+
195
+ test('requires https except for local gateways', () => {
196
+ assert.ok(validateProvisionInput({ ...ok, gatewayUrl: 'http://api.visionhammer.io' }));
197
+ assert.equal(validateProvisionInput({ ...ok, gatewayUrl: 'http://localhost:8080' }), null);
198
+ });
199
+ });
@@ -0,0 +1,341 @@
1
+ // test/open-gate.test.js
2
+ // OPEN GATE: /workspace/open with repoUrl is a HYDRATION request and must
3
+ // never 500 on a stale clone dir left by a previous revision (in-memory
4
+ // registry lost on restart => the dir is an ORPHAN with possibly broken git
5
+ // state - QA 2026-07-20 00:17Z, "git reset --hard origin/main: ambiguous
6
+ // argument"). Orphans are cleaned and re-cloned fresh; a dir REGISTERED for
7
+ // another tenant is NEVER touched (clear 409, not a generic 500); a dir
8
+ // registered for the same (or unknown) tenant keeps the byte-identical
9
+ // reuse/refresh path.
10
+
11
+ import { test, describe, before, after } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import os from 'node:os';
16
+ import {
17
+ WORKSPACE_SIDECAR,
18
+ classifyOpenDestination,
19
+ extractOpenTenantId,
20
+ findRegistrationForDir,
21
+ isSafeToRemove,
22
+ readWorkspaceSidecar,
23
+ } from '../src/utils/open-gate.js';
24
+ import { WorkspaceManager } from '../src/workspace-manager.js';
25
+ import { countFilesExcludingGit } from '../src/utils/file-count.js';
26
+
27
+ let cache, orphanDir, registeredDir, multiParent, multiRepoDir;
28
+
29
+ before(() => {
30
+ cache = fs.mkdtempSync(path.join(os.tmpdir(), 'open-gate-'));
31
+ // single-repo cloud-clone layout: <owner>_<repo>
32
+ orphanDir = path.join(cache, 'VISIONHAMMER_vh-e2e-sap-abap');
33
+ registeredDir = path.join(cache, 'VISIONHAMMER_vh-e2e-legacy-java8');
34
+ // multi-repo layout: <wsId>/<owner>_<repo>
35
+ multiParent = path.join(cache, 'ws-multi-1');
36
+ multiRepoDir = path.join(multiParent, 'ACME_catalog-api');
37
+ for (const d of [orphanDir, registeredDir, multiRepoDir]) {
38
+ fs.mkdirSync(d, { recursive: true });
39
+ }
40
+ });
41
+
42
+ after(() => {
43
+ try { fs.rmSync(cache, { recursive: true, force: true }); } catch (_) {}
44
+ });
45
+
46
+ describe('classifyOpenDestination', () => {
47
+ test('missing destination -> fresh clone (unchanged behavior)', () => {
48
+ const gate = classifyOpenDestination(path.join(cache, 'not-there-yet'), {
49
+ registry: [],
50
+ requestTenantId: 'tenant-a',
51
+ });
52
+ assert.equal(gate.action, 'fresh');
53
+ });
54
+
55
+ test('QA failure mode: orphan dir on disk (registry lost) -> clean_and_fresh, never reuse', () => {
56
+ // The exact scenario behind the /workspace/open 500: the dir exists
57
+ // from an older revision, no registry entry survives the restart.
58
+ const gate = classifyOpenDestination(orphanDir, {
59
+ registry: [],
60
+ requestTenantId: '0d0de93f-5537-423c-b5a2-c01789723ce6',
61
+ });
62
+ assert.equal(gate.action, 'clean_and_fresh');
63
+ assert.equal(gate.reason, 'orphan_dir_no_registration');
64
+ });
65
+
66
+ test('orphan classification holds even when the request tenant is unknown', () => {
67
+ // Today the gateway sends no tenantId on /workspace/open - the orphan
68
+ // cleanup must still engage (a null tenant registers as unknown, which
69
+ // is exactly the pre-restart state being replaced).
70
+ const gate = classifyOpenDestination(orphanDir, {
71
+ registry: [],
72
+ requestTenantId: null,
73
+ });
74
+ assert.equal(gate.action, 'clean_and_fresh');
75
+ });
76
+
77
+ test('destination registered for ANOTHER tenant (both known) -> deny, dir untouched', () => {
78
+ const gate = classifyOpenDestination(registeredDir, {
79
+ registry: [{ id: 'ws-1', root: registeredDir, tenantId: 'tenant-b' }],
80
+ requestTenantId: 'tenant-a',
81
+ });
82
+ assert.equal(gate.action, 'deny');
83
+ assert.equal(gate.reason, 'owned_by_another_tenant');
84
+ assert.equal(gate.ownerWorkspaceId, 'ws-1');
85
+ assert.equal(gate.ownerTenantId, 'tenant-b');
86
+ });
87
+
88
+ test('destination registered for the SAME tenant -> reuse (current refresh path)', () => {
89
+ const gate = classifyOpenDestination(registeredDir, {
90
+ registry: [{ id: 'ws-1', root: registeredDir, tenantId: 'tenant-a' }],
91
+ requestTenantId: 'tenant-a',
92
+ });
93
+ assert.equal(gate.action, 'reuse');
94
+ });
95
+
96
+ test('registered dir with UNKNOWN tenant on either side -> reuse (never destructive without proof)', () => {
97
+ // Registered tenant unknown (pre-gate registration):
98
+ assert.equal(classifyOpenDestination(registeredDir, {
99
+ registry: [{ id: 'ws-1', root: registeredDir }],
100
+ requestTenantId: 'tenant-a',
101
+ }).action, 'reuse');
102
+ // Request tenant unknown (today's gateway payload):
103
+ assert.equal(classifyOpenDestination(registeredDir, {
104
+ registry: [{ id: 'ws-1', root: registeredDir, tenantId: 'tenant-b' }],
105
+ requestTenantId: null,
106
+ }).action, 'reuse');
107
+ });
108
+
109
+ test('multi-repo: a per-repo sub-dir belongs to its registered PARENT workspace dir', () => {
110
+ const gateSame = classifyOpenDestination(multiRepoDir, {
111
+ registry: [{ id: 'ws-multi-1', root: multiParent, tenantId: 'tenant-a' }],
112
+ requestTenantId: 'tenant-a',
113
+ });
114
+ assert.equal(gateSame.action, 'reuse');
115
+ const gateOther = classifyOpenDestination(multiRepoDir, {
116
+ registry: [{ id: 'ws-multi-1', root: multiParent, tenantId: 'tenant-b' }],
117
+ requestTenantId: 'tenant-a',
118
+ });
119
+ assert.equal(gateOther.action, 'deny');
120
+ });
121
+
122
+ test('never throws on broken injections (fail-safe to fresh)', () => {
123
+ const gate = classifyOpenDestination(orphanDir, {
124
+ registry: [{ id: 'x', root: 42 }],
125
+ requestTenantId: 'tenant-a',
126
+ existsSync: () => { throw new Error('boom'); },
127
+ });
128
+ assert.equal(gate.action, 'fresh');
129
+ });
130
+ });
131
+
132
+ describe('findRegistrationForDir', () => {
133
+ test('exact root match wins', () => {
134
+ const reg = [{ id: 'ws-1', root: registeredDir, tenantId: 't' }];
135
+ assert.equal(findRegistrationForDir(registeredDir, reg), reg[0]);
136
+ });
137
+
138
+ test('ancestor root owns nested dirs, siblings stay orphan', () => {
139
+ const reg = [{ id: 'ws-multi-1', root: multiParent, tenantId: 't' }];
140
+ assert.equal(findRegistrationForDir(multiRepoDir, reg), reg[0]);
141
+ assert.equal(findRegistrationForDir(orphanDir, reg), null);
142
+ // prefix that is NOT a path boundary must not match ("/a/bc" vs "/a/b")
143
+ assert.equal(findRegistrationForDir(multiParent + '-other', reg), null);
144
+ });
145
+
146
+ test('degenerate inputs -> null (orphan handling stays conservative upstream)', () => {
147
+ assert.equal(findRegistrationForDir('', []), null);
148
+ assert.equal(findRegistrationForDir(null, null), null);
149
+ assert.equal(findRegistrationForDir(registeredDir, [null, {}, { root: '' }]), null);
150
+ });
151
+ });
152
+
153
+ describe('isSafeToRemove', () => {
154
+ test('only dirs STRICTLY inside the cache base are removable', () => {
155
+ assert.equal(isSafeToRemove(cache, orphanDir), true);
156
+ assert.equal(isSafeToRemove(cache, multiRepoDir), true);
157
+ });
158
+
159
+ test('the base itself, "/", traversal escapes and junk are never removable', () => {
160
+ assert.equal(isSafeToRemove(cache, cache), false);
161
+ assert.equal(isSafeToRemove(cache, '/'), false);
162
+ assert.equal(isSafeToRemove('/', '/etc'), false);
163
+ assert.equal(isSafeToRemove(cache, path.join(cache, '..', 'escape')), false);
164
+ assert.equal(isSafeToRemove(cache, ''), false);
165
+ assert.equal(isSafeToRemove('', orphanDir), false);
166
+ assert.equal(isSafeToRemove(cache, null), false);
167
+ });
168
+ });
169
+
170
+ describe('extractOpenTenantId', () => {
171
+ test('body.tenantId wins over the X-Tenant-ID header', () => {
172
+ assert.equal(extractOpenTenantId({ tenantId: 't-body' }, { 'x-tenant-id': 't-header' }), 't-body');
173
+ });
174
+
175
+ test('X-Tenant-ID header is the fallback', () => {
176
+ assert.equal(extractOpenTenantId({}, { 'x-tenant-id': 't-header' }), 't-header');
177
+ assert.equal(extractOpenTenantId(null, { 'x-tenant-id': ' t-header ' }), 't-header');
178
+ });
179
+
180
+ test('absent/blank everywhere -> null (unknown, gate stays non-destructive on registered dirs)', () => {
181
+ assert.equal(extractOpenTenantId({}, {}), null);
182
+ assert.equal(extractOpenTenantId({ tenantId: ' ' }, {}), null);
183
+ assert.equal(extractOpenTenantId(null, null), null);
184
+ assert.equal(extractOpenTenantId({ tenantId: 42 }, {}), null);
185
+ });
186
+ });
187
+
188
+ describe('WorkspaceManager tenant registration (open path)', () => {
189
+ test('open() registers the tenant and a later open without meta KEEPS it', async () => {
190
+ const mgr = new WorkspaceManager();
191
+ await mgr.open('ws-t', registeredDir, { tenantId: 'tenant-a' });
192
+ assert.equal(mgr.list().find((w) => w.id === 'ws-t').tenantId, 'tenant-a');
193
+ // Re-open (refresh) without tenant meta must not erase the registration.
194
+ await mgr.open('ws-t', registeredDir, {});
195
+ assert.equal(mgr.list().find((w) => w.id === 'ws-t').tenantId, 'tenant-a');
196
+ });
197
+
198
+ test('fresh-clone-over-orphan flow: cleanup + re-open registers the request tenant', async () => {
199
+ // Simulates the fixed /workspace/open path end-to-end at the module
200
+ // level: orphan classified -> removed -> "cloned" fresh -> registered.
201
+ const flowDir = path.join(cache, 'FLOW_org_repo');
202
+ fs.mkdirSync(path.join(flowDir, '.git'), { recursive: true });
203
+ const mgr = new WorkspaceManager();
204
+
205
+ const gate = classifyOpenDestination(flowDir, {
206
+ registry: mgr.list(),
207
+ requestTenantId: 'tenant-a',
208
+ });
209
+ assert.equal(gate.action, 'clean_and_fresh');
210
+ assert.equal(isSafeToRemove(cache, flowDir), true);
211
+ fs.rmSync(flowDir, { recursive: true, force: true });
212
+ assert.equal(fs.existsSync(flowDir), false);
213
+
214
+ // fresh clone lands, then the open registers it WITH the tenant
215
+ fs.mkdirSync(flowDir, { recursive: true });
216
+ await mgr.open('ws-flow', flowDir, { tenantId: 'tenant-a' });
217
+ const w = mgr.list().find((x) => x.id === 'ws-flow');
218
+ assert.equal(w.tenantId, 'tenant-a');
219
+ assert.equal(w.root, flowDir);
220
+
221
+ // and a SECOND open of the now-registered dir reuses (no cleanup)
222
+ assert.equal(classifyOpenDestination(flowDir, {
223
+ registry: mgr.list(),
224
+ requestTenantId: 'tenant-a',
225
+ }).action, 'reuse');
226
+ });
227
+ });
228
+
229
+ // SIDECAR (QA 2026-09-22): the registry is an in-memory Map; every Cloud Run
230
+ // cold start / new revision / second instance forgets it and each clone dir
231
+ // on disk becomes an "orphan" -> rm -rf + re-clone of all N repos per scan.
232
+ // open() now persists `<root>/.vh-workspace.json`; a valid sidecar counts as
233
+ // the registration and the SAME tenant rules apply to it.
234
+ describe('registration sidecar (.vh-workspace.json)', () => {
235
+ function writeSidecar(dir, data) {
236
+ fs.mkdirSync(dir, { recursive: true });
237
+ fs.writeFileSync(path.join(dir, WORKSPACE_SIDECAR), JSON.stringify(data));
238
+ }
239
+
240
+ test('WorkspaceManager.open() writes the sidecar {id, tenantId, root, openedAt} - never a token', async () => {
241
+ const dir = path.join(cache, 'SIDE_write');
242
+ fs.mkdirSync(dir, { recursive: true });
243
+ const mgr = new WorkspaceManager();
244
+ await mgr.open('ws-side', dir, { tenantId: 'tenant-a', token: 'ghp_NEVER' });
245
+ const raw = fs.readFileSync(path.join(dir, WORKSPACE_SIDECAR), 'utf8');
246
+ const data = JSON.parse(raw);
247
+ assert.deepEqual(Object.keys(data).sort(), ['id', 'openedAt', 'root', 'tenantId']);
248
+ assert.equal(data.id, 'ws-side');
249
+ assert.equal(data.tenantId, 'tenant-a');
250
+ assert.equal(data.root, dir);
251
+ assert.doesNotMatch(raw, /ghp_NEVER|token/i);
252
+ });
253
+
254
+ test('QA loop breaker: registry LOST (new Map) + sidecar for the SAME tenant -> reuse, not orphan', () => {
255
+ const dir = path.join(cache, 'ws-side-same');
256
+ writeSidecar(dir, { id: 'ws-side-same', tenantId: 'tenant-a', root: dir, openedAt: 'x' });
257
+ const gate = classifyOpenDestination(dir, { registry: new WorkspaceManager().list(), requestTenantId: 'tenant-a' });
258
+ assert.equal(gate.action, 'reuse');
259
+ assert.equal(gate.ownerWorkspaceId, 'ws-side-same');
260
+ assert.equal(gate.registration.source, 'sidecar');
261
+ });
262
+
263
+ test('sidecar of ANOTHER tenant keeps the registered-dir rule: deny (never touched, never reused)', () => {
264
+ const dir = path.join(cache, 'ws-side-other');
265
+ writeSidecar(dir, { id: 'ws-side-other', tenantId: 'tenant-b', root: dir });
266
+ const gate = classifyOpenDestination(dir, { registry: [], requestTenantId: 'tenant-a' });
267
+ assert.equal(gate.action, 'deny');
268
+ assert.equal(gate.reason, 'owned_by_another_tenant');
269
+ assert.equal(gate.ownerTenantId, 'tenant-b');
270
+ });
271
+
272
+ test('sidecar with unknown tenant on either side -> reuse (non-destructive without proof)', () => {
273
+ const dir = path.join(cache, 'ws-side-unknown');
274
+ writeSidecar(dir, { id: 'ws-side-unknown', tenantId: null, root: dir });
275
+ assert.equal(classifyOpenDestination(dir, { registry: [], requestTenantId: 'tenant-a' }).action, 'reuse');
276
+ const dir2 = path.join(cache, 'ws-side-unknown-req');
277
+ writeSidecar(dir2, { id: 'ws-side-unknown-req', tenantId: 'tenant-b', root: dir2 });
278
+ assert.equal(classifyOpenDestination(dir2, { registry: [], requestTenantId: null }).action, 'reuse');
279
+ });
280
+
281
+ test('multi-repo: a per-repo sub-dir is owned by the PARENT sidecar', () => {
282
+ const parent = path.join(cache, 'ws-side-multi');
283
+ const sub = path.join(parent, 'ACME_api');
284
+ fs.mkdirSync(sub, { recursive: true });
285
+ writeSidecar(parent, { id: 'ws-side-multi', tenantId: 'tenant-a', root: parent });
286
+ assert.equal(findRegistrationForDir(sub, []).id, 'ws-side-multi');
287
+ assert.equal(classifyOpenDestination(sub, { registry: [], requestTenantId: 'tenant-b' }).action, 'deny');
288
+ });
289
+
290
+ test('invalid sidecars are ignored (still orphan): junk JSON, missing id, root of a DIFFERENT dir', () => {
291
+ const junk = path.join(cache, 'ws-side-junk');
292
+ fs.mkdirSync(junk, { recursive: true });
293
+ fs.writeFileSync(path.join(junk, WORKSPACE_SIDECAR), '{not json');
294
+ assert.equal(readWorkspaceSidecar(junk), null);
295
+ const noId = path.join(cache, 'ws-side-noid');
296
+ writeSidecar(noId, { tenantId: 't', root: noId });
297
+ assert.equal(readWorkspaceSidecar(noId), null);
298
+ const moved = path.join(cache, 'ws-side-moved');
299
+ writeSidecar(moved, { id: 'ws-x', tenantId: 't', root: path.join(cache, 'somewhere-else') });
300
+ assert.equal(readWorkspaceSidecar(moved), null);
301
+ for (const d of [junk, noId, moved]) {
302
+ assert.equal(classifyOpenDestination(d, { registry: [], requestTenantId: 'tenant-a' }).action, 'clean_and_fresh');
303
+ }
304
+ assert.equal(readWorkspaceSidecar(path.join(cache, 'does-not-exist')), null);
305
+ });
306
+
307
+ test('in-memory registry still wins over a sidecar; readSidecar:null disables the fallback', () => {
308
+ const dir = path.join(cache, 'ws-side-prec');
309
+ writeSidecar(dir, { id: 'ws-from-disk', tenantId: 'tenant-b', root: dir });
310
+ const reg = [{ id: 'ws-from-mem', root: dir, tenantId: 'tenant-a' }];
311
+ assert.equal(findRegistrationForDir(dir, reg).id, 'ws-from-mem');
312
+ assert.equal(findRegistrationForDir(dir, [], { readSidecar: null }), null);
313
+ });
314
+
315
+ test('open() without tenant meta REHYDRATES the tenant from the sidecar; close() removes it', async () => {
316
+ const dir = path.join(cache, 'ws-side-rehydrate');
317
+ writeSidecar(dir, { id: 'ws-side-rehydrate', tenantId: 'tenant-a', root: dir });
318
+ const mgr = new WorkspaceManager();
319
+ await mgr.open('ws-side-rehydrate', dir, {});
320
+ assert.equal(mgr.get('ws-side-rehydrate').tenantId, 'tenant-a');
321
+ // a DIFFERENT workspaceId re-registering the same dir never inherits it
322
+ const mgr2 = new WorkspaceManager();
323
+ await mgr2.open('ws-someone-else', dir, {});
324
+ assert.equal(mgr2.get('ws-someone-else').tenantId, null);
325
+ mgr2.close('ws-someone-else');
326
+ assert.equal(fs.existsSync(path.join(dir, WORKSPACE_SIDECAR)), false);
327
+ assert.equal(classifyOpenDestination(dir, { registry: [], requestTenantId: 'tenant-a' }).action, 'clean_and_fresh');
328
+ });
329
+
330
+ test('sidecar is invisible to ENG-1 file count and excluded from git (.git/info/exclude)', async () => {
331
+ const dir = path.join(cache, 'SIDE_gitrepo');
332
+ fs.mkdirSync(path.join(dir, '.git', 'info'), { recursive: true });
333
+ const mgr = new WorkspaceManager();
334
+ await mgr.open('ws-side-git', dir, { tenantId: 'tenant-a' });
335
+ assert.equal(countFilesExcludingGit(dir), 0, 'empty clone must still count 0');
336
+ const exclude = fs.readFileSync(path.join(dir, '.git', 'info', 'exclude'), 'utf8');
337
+ assert.equal(exclude.split('\n').filter((l) => l === WORKSPACE_SIDECAR).length, 1);
338
+ await mgr.open('ws-side-git', dir, { tenantId: 'tenant-a' }); // idempotent
339
+ assert.equal(fs.readFileSync(path.join(dir, '.git', 'info', 'exclude'), 'utf8').split('\n').filter((l) => l === WORKSPACE_SIDECAR).length, 1);
340
+ });
341
+ });
@@ -0,0 +1,60 @@
1
+ // test/ranged-read.test.js
2
+ // RANGED READS guard: /workspace/file-content pagination logic. A 7500-line
3
+ // file must never be returned whole by default (context blowout -> the AI
4
+ // falls back to sed-chunking and burns its iteration budget); explicit
5
+ // ranges must slice exactly; small files stay byte-identical (null = caller
6
+ // returns the original response).
7
+
8
+ import { test, describe } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { sliceFileContent } from '../src/utils/ranged-read.js';
11
+
12
+ function fileOf(lines) {
13
+ const content = Array.from({ length: lines }, (_, i) => 'L' + (i + 1)).join('\n');
14
+ return { path: 'big.js', content, lines, size: content.length };
15
+ }
16
+
17
+ describe('sliceFileContent', () => {
18
+ test('small file without range returns null (original response unchanged)', () => {
19
+ assert.equal(sliceFileContent(fileOf(50), { lineCap: 2000 }), null);
20
+ assert.equal(sliceFileContent(fileOf(2000), { lineCap: 2000 }), null);
21
+ });
22
+
23
+ test('oversized file without range returns first page + truncated + hint', () => {
24
+ const r = sliceFileContent(fileOf(7501), { lineCap: 2000 });
25
+ assert.equal(r.truncated, true);
26
+ assert.equal(r.startLine, 1);
27
+ assert.equal(r.endLine, 2000);
28
+ assert.equal(r.totalLines, 7501);
29
+ assert.ok(r.content.startsWith('L1\n'));
30
+ assert.ok(r.content.endsWith('\nL2000'));
31
+ assert.match(r.hint, /startLine=2001&endLine=4000/);
32
+ });
33
+
34
+ test('explicit range slices exactly (1-based, inclusive)', () => {
35
+ const r = sliceFileContent(fileOf(7501), { startLine: 901, endLine: 903, lineCap: 2000 });
36
+ assert.equal(r.content, 'L901\nL902\nL903');
37
+ assert.equal(r.lines, 3);
38
+ assert.equal(r.totalLines, 7501);
39
+ assert.equal(r.truncated, undefined);
40
+ });
41
+
42
+ test('endLine beyond EOF clamps; startLine without endLine pages one cap', () => {
43
+ const eof = sliceFileContent(fileOf(100), { startLine: 95, endLine: 500, lineCap: 2000 });
44
+ assert.equal(eof.endLine, 100);
45
+ assert.equal(eof.content, 'L95\nL96\nL97\nL98\nL99\nL100');
46
+
47
+ const page = sliceFileContent(fileOf(7501), { startLine: 2001, lineCap: 2000 });
48
+ assert.equal(page.startLine, 2001);
49
+ assert.equal(page.endLine, 4000);
50
+ });
51
+
52
+ test('degenerate inputs never throw', () => {
53
+ assert.equal(sliceFileContent({ path: 'x', content: '' }, {}), null);
54
+ const r = sliceFileContent(fileOf(10), { startLine: 99, lineCap: 5 });
55
+ // startLine past EOF clamps to the last line.
56
+ assert.equal(r.startLine, 10);
57
+ assert.equal(r.content, 'L10');
58
+ assert.equal(sliceFileContent(fileOf(10), { startLine: NaN, endLine: NaN, lineCap: NaN }), null);
59
+ });
60
+ });