deepdebug-local-agent 1.0.23 → 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 (168) 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/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/graphify-packaging-plan.md +133 -0
  31. package/docs/graphify-validation-run-2026-10-28.md +243 -0
  32. package/docs/releasing.md +43 -0
  33. package/docs/signed-commands.md +163 -0
  34. package/env.example +30 -0
  35. package/helm/Chart.yaml +2 -2
  36. package/helm/templates/deployment.yaml +13 -3
  37. package/helm/templates/networkpolicy.yaml +22 -0
  38. package/helm/templates/secret.yaml +1 -0
  39. package/helm/values.yaml +13 -14
  40. package/mcp-server.js +4 -1
  41. package/package.json +9 -4
  42. package/scripts/upgrade-observability-push.sh +227 -0
  43. package/src/ai-engine.js +22 -22
  44. package/src/config/trusted-signing-keys.json +3 -0
  45. package/src/detectors/build-tool-detector.js +50 -0
  46. package/src/detectors/multi-project-detector.js +22 -22
  47. package/src/detectors/service-detector.js +24 -24
  48. package/src/exec-utils.js +26 -4
  49. package/src/git/base-git-provider.js +117 -117
  50. package/src/git/bitbucket-provider.js +2 -2
  51. package/src/git/git-provider-registry.js +18 -18
  52. package/src/git/github-provider.js +13 -13
  53. package/src/git/multi-repo-pr.js +161 -0
  54. package/src/mcp-http-server.js +32 -16
  55. package/src/observability/bundle-files.js +418 -0
  56. package/src/observability/detect.js +151 -0
  57. package/src/observability/provision.js +212 -0
  58. package/src/observability/render-cli.js +59 -0
  59. package/src/observability/routes.js +88 -0
  60. package/src/patch/patch-engine.js +28 -28
  61. package/src/patch-manager.js +85 -85
  62. package/src/patch_bkp.js +14 -14
  63. package/src/ports.js +4 -4
  64. package/src/routes/workspace.route.js +47 -16
  65. package/src/runtimes/base-runtime.js +75 -75
  66. package/src/runtimes/java/gradle-runtime.js +17 -17
  67. package/src/runtimes/java/java-integrations.js +25 -25
  68. package/src/runtimes/java/maven-runtime.js +34 -34
  69. package/src/runtimes/node/node-integrations.js +1 -1
  70. package/src/runtimes/node/npm-runtime.js +14 -14
  71. package/src/runtimes/node/yarn-runtime.js +4 -4
  72. package/src/runtimes/runtime-registry.js +25 -25
  73. package/src/server-local.js +4 -4
  74. package/src/server.js +1366 -333
  75. package/src/siblings/burst-suppressor.js +89 -0
  76. package/src/siblings/capabilities.js +273 -0
  77. package/src/siblings/env-compat.cjs +58 -0
  78. package/src/siblings/incident-forwarder.js +320 -0
  79. package/src/siblings/logger.js +15 -0
  80. package/src/siblings/metrics.js +94 -0
  81. package/src/utils/agent-auth.js +184 -0
  82. package/src/utils/approval-token.js +56 -0
  83. package/src/utils/bind-host.js +29 -0
  84. package/src/utils/branch-refspec.js +98 -0
  85. package/src/utils/child-env.js +34 -0
  86. package/src/utils/environment-diagnostics.js +55 -55
  87. package/src/utils/exec-policy.js +962 -0
  88. package/src/utils/exec-utils.js +15 -15
  89. package/src/utils/file-count.js +46 -0
  90. package/src/utils/fs-utils.js +10 -10
  91. package/src/utils/multi-repo-open.js +132 -0
  92. package/src/utils/open-gate.js +183 -0
  93. package/src/utils/ranged-read.js +65 -0
  94. package/src/utils/request-guards.js +77 -0
  95. package/src/utils/request-signing.js +342 -0
  96. package/src/utils/root-recovery.js +244 -0
  97. package/src/utils/safe-exec-args.js +58 -0
  98. package/src/utils/sandbox.js +182 -0
  99. package/src/utils/search-outcome.js +56 -0
  100. package/src/utils/workspace-root.js +80 -0
  101. package/src/validators/syntax-validator.js +149 -0
  102. package/src/workspace/detect-port.js +16 -16
  103. package/src/workspace/file-reader.js +1 -1
  104. package/src/workspace/process-manager.js +46 -46
  105. package/src/workspace-manager.js +86 -24
  106. package/test/adapters/registry.test.js +9 -2
  107. package/test/agent-auth.test.js +194 -0
  108. package/test/bind-host.test.js +26 -0
  109. package/test/branch-refspec.test.js +224 -0
  110. package/test/burst-suppressor.test.js +60 -0
  111. package/test/capabilities-access.test.js +47 -0
  112. package/test/capabilities.test.js +87 -0
  113. package/test/child-env.test.js +23 -0
  114. package/test/env-compat.test.js +71 -0
  115. package/test/exec-policy.test.js +463 -0
  116. package/test/file-count.test.js +64 -0
  117. package/test/incident-forwarder.test.js +231 -0
  118. package/test/multi-repo-open.test.js +180 -0
  119. package/test/multi-repo-pr.test.js +129 -0
  120. package/test/observability-detect.test.js +199 -0
  121. package/test/open-gate.test.js +341 -0
  122. package/test/ranged-read.test.js +60 -0
  123. package/test/request-guards.test.js +91 -0
  124. package/test/request-signing.test.js +284 -0
  125. package/test/root-recovery-sidecar.test.js +171 -0
  126. package/test/root-recovery.test.js +274 -0
  127. package/test/safe-exec-args.test.js +58 -0
  128. package/test/sandbox.test.js +98 -0
  129. package/test/search-outcome.test.js +50 -0
  130. package/test/workspace-root.test.js +64 -0
  131. package/tools/graphify-validation/README.md +92 -0
  132. package/tools/graphify-validation/config/questions.json +31 -0
  133. package/tools/graphify-validation/config/repos.json +9 -0
  134. package/tools/graphify-validation/config/symbols.json +6 -0
  135. package/tools/graphify-validation/fixtures/spring/expected.json +34 -0
  136. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AppConfig.java +29 -0
  137. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AuditLogger.java +15 -0
  138. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/Cache.java +7 -0
  139. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/CheckoutService.java +25 -0
  140. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/InMemoryCache.java +18 -0
  141. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/OrderController.java +22 -0
  142. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaymentGateway.java +5 -0
  143. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaypalGateway.java +11 -0
  144. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/RefundService.java +14 -0
  145. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/StripeGateway.java +11 -0
  146. package/tools/graphify-validation/lib/affected_vs_rg.py +81 -0
  147. package/tools/graphify-validation/lib/audit_hook/sitecustomize.py +22 -0
  148. package/tools/graphify-validation/lib/graph_metrics.py +96 -0
  149. package/tools/graphify-validation/lib/gv_common.py +130 -0
  150. package/tools/graphify-validation/lib/licenses.py +47 -0
  151. package/tools/graphify-validation/lib/pip_audit_summary.py +18 -0
  152. package/tools/graphify-validation/lib/recall.py +115 -0
  153. package/tools/graphify-validation/lib/report.py +226 -0
  154. package/tools/graphify-validation/lib/run_all.py +237 -0
  155. package/tools/graphify-validation/lib/run_graphify.py +144 -0
  156. package/tools/graphify-validation/lib/rusage_helper.py +17 -0
  157. package/tools/graphify-validation/lib/sample_edges.py +246 -0
  158. package/tools/graphify-validation/lib/spring_fixture.py +81 -0
  159. package/tools/graphify-validation/requirements-py311.lock +508 -0
  160. package/tools/graphify-validation/requirements-tools.in +1 -0
  161. package/tools/graphify-validation/requirements-tools.lock +371 -0
  162. package/tools/graphify-validation/requirements.in +1 -0
  163. package/tools/graphify-validation/requirements.lock +502 -0
  164. package/tools/graphify-validation/run.sh +43 -0
  165. package/tools/graphify-validation/tests/test_harness.py +151 -0
  166. package/cloudbuild-agent-qa.yaml +0 -43
  167. package/cloudbuild-agent-staging.yaml +0 -40
  168. package/cloudbuild.yaml.deprecated +0 -42
@@ -5,12 +5,12 @@ import stripAnsi from 'strip-ansi';
5
5
  const execAsync = promisify(exec);
6
6
 
7
7
  /**
8
- * Executa um comando e retorna o resultado
8
+ * Runs a command and returns the result
9
9
  *
10
- * @param {string} cmd - Comando a executar
11
- * @param {string[]} args - Argumentos
12
- * @param {string} cwd - Diretório de trabalho
13
- * @param {number} timeoutMs - Timeout em ms (default: 10 min)
10
+ * @param {string} cmd - Command to run
11
+ * @param {string[]} args - Arguments
12
+ * @param {string} cwd - Working directory
13
+ * @param {number} timeoutMs - Timeout in ms (default: 10 min)
14
14
  * @returns {Promise<CommandResult>}
15
15
  */
16
16
  export function run(cmd, args = [], cwd = process.cwd(), timeoutMs = 10 * 60 * 1000) {
@@ -65,11 +65,11 @@ export function run(cmd, args = [], cwd = process.cwd(), timeoutMs = 10 * 60 * 1
65
65
  }
66
66
 
67
67
  /**
68
- * Executa comando com streaming de output
68
+ * Runs a command with output streaming
69
69
  *
70
- * @param {string} cmd - Comando
71
- * @param {string[]} args - Argumentos
72
- * @param {string} cwd - Diretório de trabalho
70
+ * @param {string} cmd - Command
71
+ * @param {string[]} args - Arguments
72
+ * @param {string} cwd - Working directory
73
73
  * @param {object} callbacks - { onStdout, onStderr, onClose }
74
74
  * @returns {ChildProcess}
75
75
  */
@@ -106,10 +106,10 @@ export function runStreaming(cmd, args = [], cwd = process.cwd(), callbacks = {}
106
106
  }
107
107
 
108
108
  /**
109
- * Executa comando simples e retorna output
109
+ * Runs a simple command and returns its output
110
110
  *
111
- * @param {string} command - Comando completo
112
- * @param {string} cwd - Diretório de trabalho
111
+ * @param {string} command - Full command
112
+ * @param {string} cwd - Working directory
113
113
  * @returns {Promise<string>}
114
114
  */
115
115
  export async function execSimple(command, cwd = process.cwd()) {
@@ -122,9 +122,9 @@ export async function execSimple(command, cwd = process.cwd()) {
122
122
  }
123
123
 
124
124
  /**
125
- * Verifica se um comando está disponível no sistema
125
+ * Checks whether a command is available on the system
126
126
  *
127
- * @param {string} cmd - Nome do comando
127
+ * @param {string} cmd - Command name
128
128
  * @returns {Promise<boolean>}
129
129
  */
130
130
  export async function commandExists(cmd) {
@@ -140,7 +140,7 @@ export async function commandExists(cmd) {
140
140
  }
141
141
 
142
142
  /**
143
- * Executa compile e test para um projeto
143
+ * Runs compile and test for a project
144
144
  *
145
145
  * @param {object} options - { language, buildTool, cwd }
146
146
  * @returns {Promise<CompileTestResult>}
@@ -0,0 +1,46 @@
1
+ // file-count.js — ENG-1 empty-clone detection.
2
+ //
3
+ // Counts the files under a workspace root, EXCLUDING the `.git` directory
4
+ // and the agent's own `.vh-workspace.json` registration sidecar.
5
+ // A freshly-cloned repo that yields 0 files (excl. .git) is an EMPTY clone:
6
+ // the remote repo is empty, the requested branch is wrong/empty, or the path
7
+ // is bad. Callers use this to HARD-STOP structurally instead of silently
8
+ // proceeding with an empty workspace (which dies later as "NEEDS_REVIEW,
9
+ // 0 files read" — a false, un-actionable terminal state).
10
+
11
+ import fs from "fs";
12
+ import path from "path";
13
+ import { WORKSPACE_SIDECAR } from "./open-gate.js";
14
+
15
+ /**
16
+ * Count regular files under `rootDir`, skipping any `.git` directory at any
17
+ * depth. Never throws — unreadable dirs are skipped and counted as 0.
18
+ *
19
+ * @param {string} rootDir absolute path to the workspace/clone root
20
+ * @returns {number} number of non-.git files found under rootDir
21
+ */
22
+ export function countFilesExcludingGit(rootDir) {
23
+ let count = 0;
24
+ function walk(dir) {
25
+ let entries;
26
+ try {
27
+ entries = fs.readdirSync(dir, { withFileTypes: true });
28
+ } catch (_) {
29
+ return;
30
+ }
31
+ for (const entry of entries) {
32
+ if (entry.name === ".git") continue;
33
+ // The registration sidecar is agent metadata, not repo content:
34
+ // an empty clone must still count as 0.
35
+ if (entry.name === WORKSPACE_SIDECAR && !entry.isDirectory()) continue;
36
+ const full = path.join(dir, entry.name);
37
+ if (entry.isDirectory()) {
38
+ walk(full);
39
+ } else {
40
+ count++;
41
+ }
42
+ }
43
+ }
44
+ try { walk(rootDir); } catch (_) {}
45
+ return count;
46
+ }
@@ -15,7 +15,7 @@ export const access = promisify(fs.access);
15
15
  export const rename = promisify(fs.rename);
16
16
 
17
17
  /**
18
- * Verifica se um arquivo/diretório existe
18
+ * Checks whether a file/directory exists
19
19
  */
20
20
  export async function exists(p) {
21
21
  try {
@@ -27,7 +27,7 @@ export async function exists(p) {
27
27
  }
28
28
 
29
29
  /**
30
- * Cria diretório recursivamente se não existir
30
+ * Creates a directory recursively if it does not exist
31
31
  */
32
32
  export async function ensureDir(dir) {
33
33
  if (!(await exists(dir))) {
@@ -36,7 +36,7 @@ export async function ensureDir(dir) {
36
36
  }
37
37
 
38
38
  /**
39
- * Lista arquivos recursivamente
39
+ * Lists files recursively
40
40
  */
41
41
  export async function listRecursive(root, options = {}) {
42
42
  const {
@@ -89,7 +89,7 @@ export async function listRecursive(root, options = {}) {
89
89
  }
90
90
 
91
91
  /**
92
- * Copia diretório recursivamente
92
+ * Copies a directory recursively
93
93
  */
94
94
  export async function copyDir(src, dest) {
95
95
  await ensureDir(dest);
@@ -108,7 +108,7 @@ export async function copyDir(src, dest) {
108
108
  }
109
109
 
110
110
  /**
111
- * Remove diretório recursivamente
111
+ * Removes a directory recursively
112
112
  */
113
113
  export async function removeDir(dir) {
114
114
  if (!(await exists(dir))) return;
@@ -129,7 +129,7 @@ export async function removeDir(dir) {
129
129
  }
130
130
 
131
131
  /**
132
- * Calcula tamanho total de um diretório
132
+ * Calculates the total size of a directory
133
133
  */
134
134
  export async function dirSize(dir) {
135
135
  let totalSize = 0;
@@ -158,7 +158,7 @@ export async function dirSize(dir) {
158
158
  }
159
159
 
160
160
  /**
161
- * Lê arquivo JSON
161
+ * Reads a JSON file
162
162
  */
163
163
  export async function readJson(filePath) {
164
164
  const content = await readFile(filePath, 'utf8');
@@ -166,7 +166,7 @@ export async function readJson(filePath) {
166
166
  }
167
167
 
168
168
  /**
169
- * Escreve arquivo JSON
169
+ * Writes a JSON file
170
170
  */
171
171
  export async function writeJson(filePath, data, pretty = true) {
172
172
  const content = pretty
@@ -176,14 +176,14 @@ export async function writeJson(filePath, data, pretty = true) {
176
176
  }
177
177
 
178
178
  /**
179
- * Obtém extensão do arquivo normalizada
179
+ * Gets the normalized file extension
180
180
  */
181
181
  export function getExtension(filePath) {
182
182
  return path.extname(filePath).toLowerCase();
183
183
  }
184
184
 
185
185
  /**
186
- * Verifica se é arquivo de código
186
+ * Checks whether it is a code file
187
187
  */
188
188
  export function isCodeFile(filePath) {
189
189
  const codeExtensions = [
@@ -0,0 +1,132 @@
1
+ /**
2
+ * MULTI-REPO OPEN - partial-tolerant hydration of N repos under ONE workspace dir.
3
+ *
4
+ * Problem (proven in QA, 2026-09-22): POST /workspace/open with repos[] ran a
5
+ * sequential loop inside ONE try/catch. The first failed `git clone` (a repo
6
+ * the token could not see: "Repository not found") aborted the loop and the
7
+ * handler answered 500 - discarding the 9 repos that had already been cloned.
8
+ * Every scan then repeated all 10 clones just to die on the same one.
9
+ *
10
+ * Rules:
11
+ * - every repo entry is validated BEFORE anything is cloned (bad entry -> 400,
12
+ * nothing touched);
13
+ * - each repo is cloned inside its OWN try/catch; a failure is recorded in
14
+ * `failed[]` ({dir, repoUrl, error} - both URL and message MASKED) and the
15
+ * loop moves on;
16
+ * - with `allowPartial: false` (env MULTI_REPO_ALLOW_PARTIAL=false) the first
17
+ * failure rethrows the raw git error - the pre-fix fail-all behavior;
18
+ * - the caller decides the HTTP outcome: >=1 cloned -> 200 (partial when
19
+ * failed.length > 0); 0 cloned -> 500 (or 400/404 for a tagged
20
+ * BRANCH_NOT_FOUND / INVALID_BRANCH on the first error).
21
+ *
22
+ * Kept pure/dependency-injected so guard tests can pin every decision without
23
+ * a running server or git (same style as utils/open-gate.js).
24
+ */
25
+
26
+ const SAFE_DIR = /^[A-Za-z0-9._-]+$/;
27
+
28
+ /** MULTI_REPO_ALLOW_PARTIAL: default ON; only the literal "false" turns it off. */
29
+ export function multiRepoAllowPartial(env = process.env) {
30
+ const raw = env && env.MULTI_REPO_ALLOW_PARTIAL;
31
+ if (typeof raw !== "string") return true;
32
+ return raw.trim().toLowerCase() !== "false";
33
+ }
34
+
35
+ /** Derive a stable on-disk dir name for a repo URL: org_repo. */
36
+ export function repoDirName(repoUrl) {
37
+ let name = String(repoUrl).split('/').pop().replace(/\.git$/, '');
38
+ const parts = String(repoUrl).replace(/\.git$/, '').split('/');
39
+ if (parts.length >= 2) name = `${parts[parts.length - 2]}_${name}`;
40
+ return name;
41
+ }
42
+
43
+ function badRequest(message) {
44
+ const err = new Error(message);
45
+ err.code = "BAD_REQUEST";
46
+ err.httpStatus = 400;
47
+ return err;
48
+ }
49
+
50
+ /**
51
+ * Validate every repo entry up-front and resolve its per-repo dir.
52
+ * Throws a tagged BAD_REQUEST (httpStatus 400) on the first invalid entry,
53
+ * BEFORE any filesystem or git work happens.
54
+ *
55
+ * @returns {Array<{repo:object, dir:string, destDir:string}>}
56
+ */
57
+ export function planRepos(repos, { workspaceDir, confineToRoot }) {
58
+ const plan = [];
59
+ for (const r of Array.isArray(repos) ? repos : []) {
60
+ if (!r || !r.repoUrl) throw badRequest("each repo needs a repoUrl");
61
+ const dir = (r.dir && SAFE_DIR.test(r.dir)) ? r.dir : repoDirName(r.repoUrl);
62
+ // Defense in depth: the per-repo dir must stay inside the workspace dir.
63
+ let destDir;
64
+ try {
65
+ destDir = confineToRoot(workspaceDir, dir);
66
+ } catch {
67
+ throw badRequest(`invalid repo dir: ${dir}`);
68
+ }
69
+ plan.push({ repo: r, dir, destDir });
70
+ }
71
+ return plan;
72
+ }
73
+
74
+ /**
75
+ * Clone/update every planned repo, tolerating individual failures.
76
+ *
77
+ * @param {Array<{repo:object, dir:string, destDir:string}>} plan from planRepos()
78
+ * @param {object} deps
79
+ * @param {(args:{repoUrl:string,token?:string,branch?:string,destDir:string})=>Promise<void>} deps.cloneRepo
80
+ * @param {(dir:string)=>Promise<object>} deps.detectProject
81
+ * @param {(dir:string)=>Promise<void>} deps.mkdir
82
+ * @param {(s:string)=>string} deps.maskUrlCreds
83
+ * @param {string|undefined} deps.token workspace-level token fallback
84
+ * @param {string} deps.branch workspace-level branch fallback
85
+ * @param {boolean} deps.allowPartial
86
+ * @param {(destDir:string)=>Promise<void>} [deps.cleanupFailed] best-effort removal of
87
+ * the per-repo dir a failed clone left behind (never throws through)
88
+ * @param {string} deps.wsId for log lines only
89
+ * @param {{log:Function,warn:Function}} [deps.logger]
90
+ * @returns {Promise<{cloned:Array, failed:Array, firstError:Error|null}>}
91
+ * `failed` entries are fully masked (safe to log/return);
92
+ * `firstError` is the RAW first failure (for tagged-code handling
93
+ * when nothing cloned) - never put it in a response unmasked.
94
+ */
95
+ export async function hydrateRepos(plan, deps) {
96
+ const {
97
+ cloneRepo, detectProject, mkdir, maskUrlCreds,
98
+ token, branch, allowPartial = true, wsId = "?",
99
+ cleanupFailed = null, logger = console
100
+ } = deps;
101
+ const mask = typeof maskUrlCreds === "function" ? maskUrlCreds : (s) => s;
102
+ const cloned = [];
103
+ const failed = [];
104
+ let firstError = null;
105
+
106
+ for (const { repo: r, dir, destDir } of plan) {
107
+ const maskedUrl = mask(r.repoUrl);
108
+ try {
109
+ await mkdir(destDir);
110
+ logger.log(`[multi-repo] cloning ${maskedUrl} → ${wsId}/${dir}`);
111
+ await cloneRepo({ repoUrl: r.repoUrl, token: r.token ?? token, branch: r.branch ?? branch, destDir });
112
+ const meta = await detectProject(destDir);
113
+ cloned.push({ dir, path: destDir, repoUrl: maskedUrl, meta });
114
+ } catch (gitErr) {
115
+ const reason = mask((gitErr && gitErr.message) || String(gitErr));
116
+ logger.warn(`[multi-repo] clone FAILED ${maskedUrl} → ${wsId}/${dir}: ${reason}`);
117
+ if (typeof cleanupFailed === "function") {
118
+ try { await cleanupFailed(destDir); } catch { /* best-effort */ }
119
+ }
120
+ if (!allowPartial) throw gitErr;
121
+ if (!firstError) firstError = gitErr;
122
+ failed.push({ dir, repoUrl: maskedUrl, error: reason });
123
+ }
124
+ }
125
+
126
+ if (failed.length > 0) {
127
+ const kind = cloned.length > 0 ? "partial" : "all failed";
128
+ logger.warn(`[multi-repo] ${kind}: ${cloned.length} cloned, ${failed.length} failed - `
129
+ + failed.map((f) => `${f.dir} (${f.error})`).join("; "));
130
+ }
131
+ return { cloned, failed, firstError };
132
+ }
@@ -0,0 +1,183 @@
1
+ import path from "path";
2
+ import fs from "fs";
3
+
4
+ /**
5
+ * OPEN GATE - ownership classification for /workspace/open clone destinations.
6
+ *
7
+ * Problem (proven in QA, 2026-07-20 00:17Z, agent rev 00020-px6): a
8
+ * POST /workspace/open with repoUrl found a STALE clone dir on disk
9
+ * ("/tmp/workspaces/VISIONHAMMER_vh-e2e-sap-abap", left by a previous
10
+ * revision; the in-memory workspace registry was lost on restart). The
11
+ * handler blindly took the "alreadyCloned" reuse path and died on
12
+ * `git reset --hard origin/main` ("ambiguous argument 'origin/main'"),
13
+ * returning HTTP 500 - instead of treating the request as what it is:
14
+ * a HYDRATION request that should clone fresh.
15
+ *
16
+ * Rules (additive, fail-safe):
17
+ * - destination does not exist -> "fresh" (clone, unchanged)
18
+ * - destination exists, NOT registered -> "clean_and_fresh" (orphan: remove stale
19
+ * dir, clone fresh, register with tenant)
20
+ * - registered for ANOTHER tenant (BOTH tenants known and different)
21
+ * -> "deny" (clear 409, dir untouched)
22
+ * - registered otherwise (same tenant, or tenant unknown on either side)
23
+ * -> "reuse" (current refresh path,
24
+ * byte-identical behavior)
25
+ * A registered directory is NEVER removed by this gate; only orphans
26
+ * (no registry entry at all) can be cleaned. The ROOT-RECOVERY tenant
27
+ * gate (utils/root-recovery.js) stays intact for dead-root RESOLUTION -
28
+ * this module only governs the open/clone hydration path.
29
+ *
30
+ * SIDECAR (QA 2026-09-22): the registry is an in-memory Map, so every Cloud
31
+ * Run cold start / new revision / second instance forgets EVERY registration
32
+ * and each existing clone dir becomes an "orphan" -> rm -rf + re-clone of all
33
+ * N repos on every scan. WorkspaceManager.open() now also writes a small
34
+ * JSON sidecar `<root>/.vh-workspace.json` {id, tenantId, root, openedAt}
35
+ * (NEVER tokens). When the registry has no entry, a VALID sidecar (its
36
+ * `root` resolves to the dir it lives in) counts as the registration and the
37
+ * SAME rules above apply to it: same/unknown tenant -> reuse, another tenant
38
+ * (both known) -> deny. A sidecar therefore never widens what an open may
39
+ * touch - it only stops a registered dir from being mistaken for an orphan.
40
+ *
41
+ * Kept pure/dependency-injected so guard tests can pin every decision
42
+ * without a running server (same style as utils/root-recovery.js).
43
+ */
44
+
45
+ /**
46
+ * Tenant id carried by an /workspace/open request: body.tenantId wins,
47
+ * X-Tenant-ID header is the fallback (CORS already allows it). Blank/absent
48
+ * stays null (= unknown; the gate then never denies and never cleans a
49
+ * registered dir - pre-fix behavior).
50
+ */
51
+ export function extractOpenTenantId(body, headers) {
52
+ const fromBody = body && typeof body.tenantId === "string" && body.tenantId.trim()
53
+ ? body.tenantId.trim() : null;
54
+ if (fromBody) return fromBody;
55
+ const raw = headers ? (headers["x-tenant-id"] || headers["X-Tenant-ID"]) : null;
56
+ return (typeof raw === "string" && raw.trim()) ? raw.trim() : null;
57
+ }
58
+
59
+ /** File name of the per-workspace registration sidecar. */
60
+ export const WORKSPACE_SIDECAR = ".vh-workspace.json";
61
+
62
+ /**
63
+ * Read + validate the registration sidecar of ONE directory. Returns a
64
+ * registry-shaped entry {id, root, tenantId, openedAt, source:"sidecar"} or
65
+ * null. Valid means: parseable JSON object, non-empty string `id`, and
66
+ * `root` resolving to the directory the file lives in (a copied/moved dir
67
+ * never carries its old registration along). Never throws.
68
+ */
69
+ export function readWorkspaceSidecar(dir, opts = {}) {
70
+ const readFileSync = opts.readFileSync || fs.readFileSync;
71
+ try {
72
+ const abs = path.resolve(String(dir || "").trim() || "/");
73
+ if (abs === "/") return null;
74
+ const raw = readFileSync(path.join(abs, WORKSPACE_SIDECAR), "utf8");
75
+ const data = JSON.parse(raw);
76
+ if (!data || typeof data !== "object") return null;
77
+ if (typeof data.id !== "string" || !data.id.trim()) return null;
78
+ if (typeof data.root !== "string" || path.resolve(data.root) !== abs) return null;
79
+ const tenantId = (typeof data.tenantId === "string" && data.tenantId.trim())
80
+ ? data.tenantId.trim() : null;
81
+ return {
82
+ id: data.id.trim(),
83
+ root: abs,
84
+ tenantId,
85
+ openedAt: typeof data.openedAt === "string" ? data.openedAt : null,
86
+ source: "sidecar"
87
+ };
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Find the registry entry that OWNS a destination dir: exact root match, or
95
+ * a registered root that is an ANCESTOR of the destination (multi-repo
96
+ * layout registers the parent workspace dir; its per-repo sub-dirs belong
97
+ * to that registration). Returns the entry or null (= orphan).
98
+ *
99
+ * Fallback (opts.readSidecar, default readWorkspaceSidecar): when the
100
+ * in-memory registry knows nothing, a valid sidecar in the destination dir
101
+ * or its parent (multi-repo sub-dir -> parent workspace dir) is the
102
+ * registration. Pass `readSidecar: null` to disable (pure registry lookup).
103
+ */
104
+ export function findRegistrationForDir(destDir, registry, opts = {}) {
105
+ const dest = path.resolve(String(destDir || "").trim() || "/");
106
+ for (const w of Array.isArray(registry) ? registry : []) {
107
+ if (!w || !w.root) continue;
108
+ let root;
109
+ try { root = path.resolve(String(w.root)); } catch { continue; }
110
+ if (root === dest) return w;
111
+ if (dest.startsWith(root + path.sep)) return w;
112
+ }
113
+ const readSidecar = opts.readSidecar === undefined ? readWorkspaceSidecar : opts.readSidecar;
114
+ if (typeof readSidecar !== "function" || dest === "/") return null;
115
+ for (const candidate of [dest, path.dirname(dest)]) {
116
+ if (candidate === "/") break;
117
+ const entry = readSidecar(candidate);
118
+ if (entry) return entry;
119
+ }
120
+ return null;
121
+ }
122
+
123
+ /**
124
+ * Classify what /workspace/open may do with its clone destination.
125
+ *
126
+ * @param {string} destDir the computed clone destination
127
+ * @param {object} opts
128
+ * @param {Array<{id:string,root:string,tenantId?:string|null}>} opts.registry
129
+ * live workspaces (wsManager.list())
130
+ * @param {string|null} opts.requestTenantId tenant of THIS open request (null = unknown)
131
+ * @param {(p:string)=>boolean} [opts.existsSync] injectable for tests
132
+ * @param {((dir:string)=>object|null)|null} [opts.readSidecar] injectable for tests
133
+ * (null disables the sidecar fallback)
134
+ * @returns {{action:"fresh"|"clean_and_fresh"|"reuse"|"deny", reason:string,
135
+ * ownerWorkspaceId?:string, ownerTenantId?:string|null,
136
+ * registration?:object}} `registration` is the owning entry
137
+ * (registry or sidecar; `source:"sidecar"` when rehydrated from disk)
138
+ */
139
+ export function classifyOpenDestination(destDir, opts = {}) {
140
+ const existsSync = opts.existsSync || fs.existsSync;
141
+ let onDisk = false;
142
+ try { onDisk = existsSync(String(destDir || "")); } catch { onDisk = false; }
143
+ if (!onDisk) return { action: "fresh", reason: "destination_missing" };
144
+
145
+ const reg = findRegistrationForDir(destDir, opts.registry, { readSidecar: opts.readSidecar });
146
+ if (!reg) return { action: "clean_and_fresh", reason: "orphan_dir_no_registration" };
147
+
148
+ const ownerTenant = reg.tenantId || null;
149
+ const reqTenant = opts.requestTenantId || null;
150
+ if (ownerTenant && reqTenant && ownerTenant !== reqTenant) {
151
+ return {
152
+ action: "deny",
153
+ reason: "owned_by_another_tenant",
154
+ ownerWorkspaceId: reg.id,
155
+ ownerTenantId: ownerTenant,
156
+ registration: reg
157
+ };
158
+ }
159
+ return {
160
+ action: "reuse",
161
+ reason: ownerTenant ? "registered_same_or_unknown_tenant" : "registered_tenant_unknown",
162
+ ownerWorkspaceId: reg.id,
163
+ ownerTenantId: ownerTenant,
164
+ registration: reg
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Removal guard for orphan cleanup: the dir must be STRICTLY inside the
170
+ * cache/mount base (never the base itself, never "/", never a traversal
171
+ * escape). Pure + total: any weird input -> false (nothing is removed).
172
+ */
173
+ export function isSafeToRemove(baseDir, dir) {
174
+ try {
175
+ const base = path.resolve(String(baseDir || "").trim());
176
+ const target = path.resolve(String(dir || "").trim());
177
+ if (!base || base === "/" || !target || target === "/") return false;
178
+ if (target === base) return false;
179
+ return target.startsWith(base + path.sep);
180
+ } catch {
181
+ return false;
182
+ }
183
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * RANGED READS (Claude Code model) - pure slicing logic for
3
+ * GET /workspace/file-content. Huge files used to be returned whole,
4
+ * blowing the AI's context budget; the model then burned its iteration
5
+ * budget extracting chunks via `sed` in run_command.
6
+ *
7
+ * Contract:
8
+ * - explicit startLine (1-based) -> that slice (endLine clamped to EOF;
9
+ * absent endLine = one cap-sized page from startLine)
10
+ * - no range and file within cap -> null (caller returns the original
11
+ * response byte-identical to before)
12
+ * - no range and file over cap -> first page + truncated:true + an
13
+ * actionable pagination hint the model can follow in ONE step
14
+ *
15
+ * Pure and dependency-free so guard tests pin it without a server.
16
+ */
17
+ export function sliceFileContent(file, { startLine, endLine, lineCap = 2000 } = {}) {
18
+ const allLines = String(file.content ?? "").split("\n");
19
+ const totalLines = allLines.length;
20
+ const cap = Number.isFinite(lineCap) && lineCap >= 1 ? Math.floor(lineCap) : 2000;
21
+
22
+ const start = Number.isFinite(startLine) ? Math.floor(startLine) : NaN;
23
+ const end = Number.isFinite(endLine) ? Math.floor(endLine) : NaN;
24
+
25
+ if (Number.isFinite(start) && start >= 1) {
26
+ const from = Math.min(start, totalLines);
27
+ const to = Number.isFinite(end) && end >= from
28
+ ? Math.min(end, totalLines)
29
+ : Math.min(from + cap - 1, totalLines);
30
+ const slice = allLines.slice(from - 1, to).join("\n");
31
+ return {
32
+ path: file.path,
33
+ content: slice,
34
+ lines: to - from + 1,
35
+ startLine: from,
36
+ endLine: to,
37
+ totalLines,
38
+ size: byteLen(slice)
39
+ };
40
+ }
41
+
42
+ if (totalLines > cap) {
43
+ const slice = allLines.slice(0, cap).join("\n");
44
+ return {
45
+ path: file.path,
46
+ content: slice,
47
+ lines: cap,
48
+ startLine: 1,
49
+ endLine: cap,
50
+ totalLines,
51
+ truncated: true,
52
+ hint: "File has " + totalLines + " lines; showing 1-" + cap + ". "
53
+ + "Request the rest with ?startLine=&endLine= (e.g. startLine="
54
+ + (cap + 1) + "&endLine=" + Math.min(cap * 2, totalLines) + ").",
55
+ size: byteLen(slice)
56
+ };
57
+ }
58
+
59
+ // Within cap, no range: caller keeps the original response unchanged.
60
+ return null;
61
+ }
62
+
63
+ function byteLen(s) {
64
+ return typeof Buffer !== "undefined" ? Buffer.byteLength(s, "utf8") : s.length;
65
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * request-guards.js — input validation for request fields that reach the
3
+ * network or a shell. Pure functions, unit-tested in test/request-guards.test.js.
4
+ */
5
+
6
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
7
+
8
+ /**
9
+ * The agent's own listeners. A "test the customer's local service" request
10
+ * must never be pointed back at the agent itself (that would turn an SSRF
11
+ * into an auth bypass of the loopback perimeter).
12
+ */
13
+ export function agentOwnPorts(env = process.env) {
14
+ const ports = [env.PORT, env.LOCAL_AGENT_PORT, env.MCP_PORT, 5055, 5056]
15
+ .map(p => parseInt(p, 10)).filter(p => Number.isInteger(p) && p > 0);
16
+ return new Set(ports);
17
+ }
18
+
19
+ /**
20
+ * Accepts only http://localhost|127.0.0.1|[::1]:<port>/<path>, with no
21
+ * userinfo, and not addressed to the agent's own ports.
22
+ * Returns { ok: true, url } or { ok: false, reason }.
23
+ *
24
+ * The previous check was `url.startsWith('http://localhost')`, which accepted
25
+ * http://localhost.attacker.tld/ and http://localhost@10.0.0.5/ — SSRF into
26
+ * the customer network.
27
+ */
28
+ export function validateLocalTestUrl(raw, ownPorts = agentOwnPorts()) {
29
+ if (typeof raw !== "string" || !raw) return { ok: false, reason: "url is required" };
30
+ let u;
31
+ try { u = new URL(raw); } catch { return { ok: false, reason: "invalid url" }; }
32
+ if (u.protocol !== "http:" && u.protocol !== "https:") return { ok: false, reason: "only http(s) allowed" };
33
+ if (u.username || u.password) return { ok: false, reason: "credentials in url not allowed" };
34
+ if (!LOOPBACK_HOSTS.has(u.hostname)) return { ok: false, reason: "only localhost URLs allowed" };
35
+ const port = u.port ? parseInt(u.port, 10) : (u.protocol === "https:" ? 443 : 80);
36
+ if (ownPorts.has(port)) return { ok: false, reason: "the agent's own ports are not testable targets" };
37
+ return { ok: true, url: u.toString() };
38
+ }
39
+
40
+ /** Builds a loopback URL from a port + path supplied by the caller. */
41
+ export function buildLocalTestUrl(port, reqPath, ownPorts = agentOwnPorts()) {
42
+ if (!/^\d{1,5}$/.test(String(port))) return { ok: false, reason: "invalid port" };
43
+ const p = parseInt(port, 10);
44
+ if (!Number.isInteger(p) || p < 1 || p > 65535) return { ok: false, reason: "invalid port" };
45
+ if (typeof reqPath !== "string" || !reqPath.startsWith("/") || reqPath.startsWith("//")) {
46
+ return { ok: false, reason: "path must start with a single '/'" };
47
+ }
48
+ if (/[\s\\]/.test(reqPath)) return { ok: false, reason: "invalid characters in path" };
49
+ return validateLocalTestUrl(`http://localhost:${p}${reqPath}`, ownPorts);
50
+ }
51
+
52
+ /** Tenant ids become path segments under /mnt/workspaces. */
53
+ export function isSafeTenantId(tenantId) {
54
+ return typeof tenantId === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(tenantId);
55
+ }
56
+
57
+ /**
58
+ * Remote git URLs the agent may clone. https:// or scp-style git@host:path.
59
+ * Rejects file://, ext::, local paths, option injection (leading '-') and
60
+ * shell metacharacters.
61
+ */
62
+ export function validateGitUrl(raw) {
63
+ if (typeof raw !== "string" || !raw.trim()) return { ok: false, reason: "gitUrl is required" };
64
+ const s = raw.trim();
65
+ if (s.startsWith("-")) return { ok: false, reason: "invalid gitUrl" };
66
+ if (/[\s"'`$;&|<>\\]/.test(s)) return { ok: false, reason: "invalid characters in gitUrl" };
67
+ if (/^git@[A-Za-z0-9.-]+:[A-Za-z0-9._\/-]+$/.test(s)) return { ok: true };
68
+ let u;
69
+ try { u = new URL(s); } catch { return { ok: false, reason: "invalid gitUrl" }; }
70
+ if (u.protocol !== "https:" && u.protocol !== "ssh:") return { ok: false, reason: "only https:// or ssh git URLs allowed" };
71
+ return { ok: true };
72
+ }
73
+
74
+ /** CLI tool names interpolated into `which`/`brew install`. */
75
+ export function isSafeToolName(name) {
76
+ return typeof name === "string" && /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/.test(name);
77
+ }