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
package/src/server.js CHANGED
@@ -5,13 +5,35 @@ import path from "path";
5
5
  import os from "os";
6
6
  import fs from "fs";
7
7
  const fsPromises = fs.promises;
8
- import { exec, spawn } from "child_process";
8
+ import { exec, execFile, spawn } from "child_process";
9
9
  import { promisify } from "util";
10
10
  import { EventEmitter } from "events";
11
11
  import { exists, listRecursive, readFile, writeFile } from "./fs-utils.js";
12
12
  import { detectProject, readText } from "./detectors.js";
13
+ import { detectBuildTool } from "./detectors/build-tool-detector.js";
14
+ import { validateFiles } from "./validators/syntax-validator.js";
13
15
  import { detectPort } from "./ports.js";
14
16
  import { compileAndTest, run } from "./exec-utils.js";
17
+ import { confineToRoot, checkArgsWithinRoot, scanRawCommand, SandboxViolation } from "./utils/sandbox.js";
18
+ import { getSigningConfig, createSignatureMiddleware, captureRawBody, describeSigningConfig, verifyWsEnvelope, internalSignatureHeaders } from "./utils/request-signing.js";
19
+ import { decideExec, rejectionBody, resolveExecPolicyConfig } from "./utils/exec-policy.js";
20
+ import { resolveEffectiveHeaderRoot } from "./utils/root-recovery.js";
21
+ import { classifyGrepFailure, partialSearchBody } from "./utils/search-outcome.js";
22
+ import { classifyOpenDestination, extractOpenTenantId, isSafeToRemove } from "./utils/open-gate.js";
23
+ import { fetchRequestedRef } from "./utils/branch-refspec.js";
24
+ import { sliceFileContent } from "./utils/ranged-read.js";
25
+ import { countFilesExcludingGit } from "./utils/file-count.js";
26
+ import { hydrateRepos, multiRepoAllowPartial, planRepos } from "./utils/multi-repo-open.js";
27
+ import {
28
+ multiRepoPrEnabled,
29
+ isMultiRepoRoot,
30
+ listRepoSubdirs,
31
+ parseAuthedRemote,
32
+ stripRemoteCreds,
33
+ buildCompareUrl,
34
+ buildCrossLinkNote,
35
+ buildMultiRepoPrBody,
36
+ } from "./git/multi-repo-pr.js";
15
37
  import { applyUnifiedDiff } from "./patch.js";
16
38
  import { WorkspaceScanner } from "./workspace/scanner.js";
17
39
  import { FileReader } from "./workspace/file-reader.js";
@@ -25,14 +47,49 @@ import { ConfigAnalyzer } from "./analyzers/config-analyzer.js";
25
47
  import { WorkspaceManager } from "./workspace-manager.js";
26
48
  import { startMCPHttpServer } from "./mcp-http-server.js";
27
49
  import { registerVercelRoutes } from "./vercel-proxy.js";
50
+ import { registerObservabilityRoutes } from "./observability/routes.js";
51
+ import { resolveAuthConfig, allowedOrigins, corsOriginCallback, createAuthMiddleware, internalAuthHeaders, describeAuthMode } from "./utils/agent-auth.js";
52
+ import { isSafeBranchName } from "./utils/branch-refspec.js";
53
+ import { resolveBindHost } from "./utils/bind-host.js";
54
+ import { sanitizedChildEnv } from "./utils/child-env.js";
55
+ import { validateHeaderRoot } from "./utils/workspace-root.js";
56
+ import { buildGrepArgs, buildTarArgs, parseAuthCommand } from "./utils/safe-exec-args.js";
57
+ import { validateLocalTestUrl, buildLocalTestUrl, isSafeTenantId, validateGitUrl, isSafeToolName } from "./utils/request-guards.js";
28
58
  import { loadAdapters, stopAll } from "./adapters/index.js";
29
59
 
30
60
  const execAsync = promisify(exec);
61
+ const execFileAsync = promisify(execFile);
62
+
63
+ /**
64
+ * Resolve a repo's real default branch by asking the remote directly.
65
+ *
66
+ * `git symbolic-ref refs/remotes/origin/HEAD` is unreliable: a `--single-branch`
67
+ * clone (our fallback when the requested branch is absent) never creates the
68
+ * origin/HEAD ref, so on the next update the lookup returns nothing and we wrongly
69
+ * keep assuming 'main'. `git ls-remote --symref origin HEAD` queries the remote and
70
+ * always reports the true default (master/main/anything), independent of how the
71
+ * local clone was made.
72
+ *
73
+ * @returns {Promise<string|null>} branch name (e.g. "master") or null if undeterminable.
74
+ */
75
+ async function resolveDefaultBranch(cwd) {
76
+ try {
77
+ const { stdout } = await execAsync(`git ls-remote --symref origin HEAD`, { cwd, timeout: 30000 });
78
+ // First line looks like: "ref: refs/heads/master\tHEAD"
79
+ const m = /ref:\s+refs\/heads\/(\S+)\s+HEAD/.exec(stdout || "");
80
+ if (m) return m[1];
81
+ } catch {
82
+ // Network/token failure — fall through to the local symbolic-ref probe.
83
+ }
84
+ const head = await execAsync(`git symbolic-ref refs/remotes/origin/HEAD --short`, { cwd })
85
+ .catch(() => null);
86
+ return head && head.stdout ? head.stdout.trim().replace(/^origin\//, "") : null;
87
+ }
31
88
 
32
89
  // ============================================
33
90
  // AI VIBE CODING ENGINE
34
- // Sistema universal de auto-healing que usa AI
35
- // para resolver QUALQUER erro automaticamente
91
+ // Universal auto-healing system that uses AI
92
+ // to resolve ANY error automatically
36
93
  // ============================================
37
94
 
38
95
  class AIVibeCodingEngine extends EventEmitter {
@@ -96,7 +153,7 @@ class AIVibeCodingEngine extends EventEmitter {
96
153
  /datasource.*failed/i
97
154
  ];
98
155
 
99
- // Excluir falsos positivos (linhas normais que contm palavras de erro)
156
+ // Exclude false positives (normal lines that contain error words)
100
157
  const falsePositives = [
101
158
  /hikaripool.*start/i, // "HikariPool-1 - Starting..." normal
102
159
  /hikaripool.*completed/i, // "HikariPool-1 - Start completed" normal
@@ -106,7 +163,7 @@ class AIVibeCodingEngine extends EventEmitter {
106
163
  /tomcat started/i // "Tomcat started on port" sucesso!
107
164
  ];
108
165
 
109
- // Se match com falso positivo, no erro
166
+ // If it matches a false positive, it is not an error
110
167
  if (falsePositives.some(p => p.test(message))) {
111
168
  return false;
112
169
  }
@@ -293,7 +350,7 @@ class AIVibeCodingEngine extends EventEmitter {
293
350
  clearTimeout(timeout);
294
351
  };
295
352
 
296
- // Detectar sucesso do Spring Boot nos logs
353
+ // Detect Spring Boot success in the logs
297
354
  const isStartupSuccess = (message) => {
298
355
  if (!message) return false;
299
356
  return /started.*in.*seconds/i.test(message) ||
@@ -305,7 +362,7 @@ class AIVibeCodingEngine extends EventEmitter {
305
362
  if (serviceId === 'test-local' && message) {
306
363
  logs.push(message);
307
364
 
308
- // Detectar sucesso nos logs do Spring Boot
365
+ // Detect success in the Spring Boot logs
309
366
  if (isStartupSuccess(message) && !resolved) {
310
367
  console.log(`[AI-Engine] Detected startup success: ${message.substring(0, 80)}...`);
311
368
  resolved = true;
@@ -551,59 +608,37 @@ const app = express();
551
608
  // FIXED: Support Cloud Run PORT environment variable (GCP uses PORT)
552
609
  const PORT = process.env.PORT || process.env.LOCAL_AGENT_PORT || 5055;
553
610
 
554
- // CORS - Allow from Cloud Run, local dev, and Lovable frontend domains
611
+ // SECURITY (audit 2026-09): CORS is an exact allowlist (no wildcard shared-
612
+ // hosting domains such as *.vercel.app / *.run.app, no allow-all in non-prod)
613
+ // and every non-public route goes through the auth perimeter in
614
+ // utils/agent-auth.js. No browser page calls the agent directly.
615
+ const AGENT_AUTH = resolveAuthConfig();
616
+ const AGENT_ORIGINS = allowedOrigins();
555
617
  app.use(cors({
556
- origin: function(origin, callback) {
557
- // Allow requests with no origin (curl, mobile apps, server-to-server)
558
- if (!origin) return callback(null, true);
559
-
560
- const allowed = [
561
- // Local development
562
- "http://localhost:3010",
563
- "http://localhost:3000",
564
- "http://localhost:8085",
565
- "http://127.0.0.1:3010",
566
- "http://127.0.0.1:3000",
567
- "http://127.0.0.1:8085",
568
- ];
569
-
570
- const allowedPatterns = [
571
- /https:\/\/.*\.run\.app$/, // Cloud Run
572
- /https:\/\/.*\.web\.app$/, // Firebase hosting
573
- /https:\/\/.*\.lovable\.app$/, // Lovable preview
574
- /https:\/\/.*\.lovableproject\.com$/, // Lovable project domains
575
- /https:\/\/.*\.netlify\.app$/, // Netlify
576
- /https:\/\/.*\.vercel\.app$/, // Vercel
577
- /https:\/\/deepdebug\.ai$/, // Production
578
- /https:\/\/.*\.deepdebug\.ai$/, // Production subdomains
579
- ];
580
-
581
- if (allowed.includes(origin) || allowedPatterns.some(p => p.test(origin))) {
582
- return callback(null, true);
583
- }
584
-
585
- // In development/QA, log and allow unknown origins instead of blocking
586
- const env = process.env.NODE_ENV || 'development';
587
- if (env !== 'production') {
588
- console.warn(`CORS: allowing unknown origin in non-prod: ${origin}`);
589
- return callback(null, true);
590
- }
591
-
592
- console.warn(`CORS: blocked origin: ${origin}`);
593
- return callback(new Error(`CORS: origin ${origin} not allowed`));
594
- },
618
+ origin: corsOriginCallback(AGENT_ORIGINS),
595
619
  methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
596
- allowedHeaders: ["Content-Type", "Authorization", "X-Tenant-ID", "X-User-ID"],
620
+ allowedHeaders: ["Content-Type", "Authorization", "X-Tenant-ID", "X-User-ID", "X-Agent-Token", "X-Workspace-Id", "X-Workspace-Root"],
597
621
  credentials: true
598
622
  }));
599
-
600
- // Handle preflight requests explicitly
601
- app.options('*', cors());
602
-
603
- app.use(bodyParser.json({ limit: "50mb" }));
604
-
605
- // DEFAULT WORKSPACE - Define o workspace padro
606
- // Pode ser sobrescrito via varivel de ambiente ou POST /workspace/open
623
+ app.options('*', cors({ origin: corsOriginCallback(AGENT_ORIGINS) }));
624
+ app.use(createAuthMiddleware(AGENT_AUTH, AGENT_ORIGINS));
625
+ (AGENT_AUTH.mode === 'token' || AGENT_AUTH.mode === 'loopback' ? console.log : console.warn)(`[agent-auth] mode: ${describeAuthMode(AGENT_AUTH)}`);
626
+
627
+ // captureRawBody keeps the exact bytes the platform signed (X-VH-Signature).
628
+ app.use(bodyParser.json({ limit: "50mb", verify: captureRawBody }));
629
+
630
+ // SIGNED COMMANDS (utils/request-signing.js): every platform request carries an
631
+ // Ed25519 signature over method/path/body/tenant/workspace/timestamp/nonce.
632
+ // LOCAL_AGENT_SIGNATURE_MODE=off|warn|enforce (default enforce since 1.1.0; warn = verify + log only).
633
+ // X-Agent-Token (agent-auth above) stays as the second factor.
634
+ const SIGNING = getSigningConfig();
635
+ const EXEC_POLICY = resolveExecPolicyConfig();
636
+ app.use(createSignatureMiddleware(SIGNING));
637
+ (SIGNING.mode === "enforce" ? console.log : console.warn)(`[agent-sig] ${describeSigningConfig(SIGNING)}`);
638
+ console.log(`[exec-policy] mode=${EXEC_POLICY.mode} (allowlist; LOCAL_AGENT_EXEC_POLICY_MODE=off|warn|enforce)`);
639
+
640
+ // DEFAULT WORKSPACE - Sets the default workspace
641
+ // Can be overridden via an environment variable or POST /workspace/open
607
642
  const DEFAULT_WORKSPACE = process.env.WORKSPACE_ROOT
608
643
  || process.env.DEFAULT_WORKSPACE
609
644
  || '/Users/macintosh/IdeaProjects/pure-core-ms';
@@ -617,6 +652,24 @@ let DETECTED_SERVICES = [];
617
652
  const processManager = new ProcessManager();
618
653
  const wsManager = new WorkspaceManager();
619
654
 
655
+ // SEG-1: strip credentials from any URL before it reaches a log line or an
656
+ // error returned to the caller. Masks the userinfo segment of every http(s)
657
+ // URL in the string (user:pass, x-token-auth:token, oauth2:token,
658
+ // x-access-token:token) regardless of token format, so a client access token
659
+ // never appears in Cloud Logging. Token-agnostic (unlike a literal token
660
+ // replace, which misses repeated occurrences and breaks on regex chars) and
661
+ // never throws. Returns non-strings coerced to string; null/undefined pass
662
+ // through unchanged.
663
+ function maskUrlCreds(input) {
664
+ if (input === null || input === undefined) return input;
665
+ const s = typeof input === "string" ? input : String(input);
666
+ // Mask the entire userinfo (everything from "://" up to the delimiting "@").
667
+ // [^\s@]+ allows ":" and "/" so tokens that embed a slash (base64, some
668
+ // PATs) or carry no colon ("https://<token>@host") are fully masked, not
669
+ // just up to the first slash.
670
+ return s.replace(/(https?:\/\/)[^\s@]+@/gi, "$1***@");
671
+ }
672
+
620
673
  function resolveWorkspaceRoot(req) {
621
674
  // TENANT ISOLATION: stateless, never mutates global state.
622
675
  // Returns the workspace path for THIS specific request only.
@@ -632,7 +685,46 @@ function resolveWorkspaceRoot(req) {
632
685
  // 1. Tenant-isolated NFS path from Gateway
633
686
  const secureRoot = req.headers['x-workspace-root'];
634
687
  if (secureRoot && secureRoot.trim()) {
635
- return secureRoot.trim();
688
+ // SECURITY (2026-10-11): the header is not covered by the request
689
+ // signature, so it is validated against the workspace bases / the
690
+ // registry (and the signed tenant) before it becomes the sandbox root.
691
+ const check = validateHeaderRoot(secureRoot, {
692
+ registry: wsManager.list(),
693
+ tenantId: req.vhSignature && req.vhSignature.verified ? (req.vhSignature.tenantId || null) : undefined,
694
+ });
695
+ if (!check.ok) {
696
+ console.warn(`[workspace-root] REJECTED X-Workspace-Root '${secureRoot.trim().slice(0, 200)}': ${check.reason}`);
697
+ return null;
698
+ }
699
+ // ROOT-RECOVERY (transversal, 2026-07-19): on a stateless agent the
700
+ // header often carries a DEAD persisted path ("/mnt/workspaces/...")
701
+ // while the real clone this agent made lives under WORKSPACE_MOUNT
702
+ // ("/tmp/workspaces/<owner>_<repo>"). If the header root exists on
703
+ // disk it is returned UNCHANGED (byte-identical for every working
704
+ // caller); only a dead root triggers conservative recovery via the
705
+ // workspace registry + clone-mount scan. Fixes the v1/v2/indexer
706
+ // split-brain (prepare clones to one path, reads hit another) at the
707
+ // single point every caller already goes through.
708
+ const wsIdHint = req.headers['x-workspace-id']
709
+ || (req.body && req.body.workspaceId)
710
+ || (req.query && req.query.workspaceId)
711
+ || null;
712
+ // TENANT GATE: tenant of the dead root is derived from the
713
+ // "/mnt/workspaces/<tenant>/..." path inside the recovery module;
714
+ // the request payload tenantId is only a fallback hint.
715
+ const tenantHint = (req.body && req.body.tenantId)
716
+ || (req.query && req.query.tenantId)
717
+ || null;
718
+ const eff = resolveEffectiveHeaderRoot(secureRoot, {
719
+ wsIdHint,
720
+ tenantHint,
721
+ registry: wsManager.list(),
722
+ mountDir: process.env.WORKSPACE_MOUNT || process.env.WORKSPACES_MOUNT || "/tmp/workspaces"
723
+ });
724
+ if (eff.recovered) {
725
+ console.log(`[ROOT-RECOVERY] dead root '${secureRoot.trim()}' -> live root '${eff.root}'`);
726
+ }
727
+ return eff.root;
636
728
  }
637
729
 
638
730
  // 2. WorkspaceId (dev/local mode only)
@@ -660,6 +752,17 @@ try {
660
752
  .filter(Boolean);
661
753
  if (stacks.length > 0) {
662
754
  _legacyAdapters = loadAdapters({ legacyStacks: stacks });
755
+ // Wire the adapter consumer (sibling module): forward each adapter's 6-key
756
+ // 'error' event to the gateway incident endpoint. Attached here in the
757
+ // existing boot loop; the forwarder buffers until it is configured with the
758
+ // gateway credentials from the app.listen callback below.
759
+ import('./siblings/incident-forwarder.js')
760
+ .then(({ incidentForwarder }) => {
761
+ for (const a of _legacyAdapters) {
762
+ a.on('error', (evt) => incidentForwarder.handleAdapterEvent(evt, a?.constructor?.name));
763
+ }
764
+ })
765
+ .catch((err) => process.stderr.write(`adapter-consumer wiring failed: ${err.message}\n`));
663
766
  for (const a of _legacyAdapters) {
664
767
  // Start failures are surfaced via the adapter's 'error' event; do not crash boot.
665
768
  Promise.resolve()
@@ -752,6 +855,24 @@ function loadBackupIndex() {
752
855
  // Load backups on startup
753
856
  loadBackupIndex();
754
857
 
858
+ // ============================================
859
+ // TEST LOCAL STATE (EARLY INITIALIZATION)
860
+ // Must be initialized BEFORE event listeners that call addServerLog()
861
+ // ============================================
862
+ // Single source of truth for test-local state. Declared early (before the
863
+ // ProcessManager event listeners that call addServerLog) and reused by the
864
+ // test-local endpoints below. Full shape so every field the endpoints read
865
+ // (status/config/testResults/endpoints/...) exists from the start.
866
+ const TEST_LOCAL_STATE = {
867
+ status: "idle", // idle, compiling, compiled, starting, running, stopped, error
868
+ compilationResult: null,
869
+ serverProcess: null,
870
+ endpoints: [],
871
+ config: null,
872
+ testResults: [],
873
+ serverLogs: [] // circular buffer of server logs (last 1000)
874
+ };
875
+
755
876
  // Event listeners do ProcessManager
756
877
  processManager.on("started", ({ serviceId }) => {
757
878
  console.log(`Service ${serviceId} started successfully`);
@@ -814,14 +935,246 @@ app.get("/health", (req, res) => {
814
935
  });
815
936
 
816
937
  /** Define/abre o workspace local */
938
+ /**
939
+ * Clone or update ONE repo into `destDir`. Extracted so both the single-repo
940
+ * and multi-repo branches share identical auth/checkout/reset semantics.
941
+ */
942
+ async function cloneOrUpdateRepo({ repoUrl, token, branch = "main", destDir }) {
943
+ let authUrl;
944
+ if (token) {
945
+ const cleanUrl = repoUrl.replace(/https:\/\/[^@]+@/, 'https://');
946
+ authUrl = token.includes(':')
947
+ ? cleanUrl.replace('https://', `https://${token}@`)
948
+ : cleanUrl.replace('https://', `https://x-access-token:${token}@`);
949
+ } else {
950
+ authUrl = repoUrl;
951
+ }
952
+ const alreadyCloned = await exists(path.join(destDir, '.git'));
953
+ if (alreadyCloned) {
954
+ await execAsync(`git remote set-url origin "${authUrl}"`, { cwd: destDir }).catch(() => {});
955
+ await execAsync(`git fetch origin`, { cwd: destDir, timeout: 120000 });
956
+ // REFSPEC FIX (QA 2026-07-20) composed with main's default-branch
957
+ // fallback (PR #4): the original clone was --single-branch, so the
958
+ // `git fetch origin` above only refreshes THAT one branch. Fetch the
959
+ // REQUESTED ref explicitly so branches created after the clone (e.g.
960
+ // a PR branch minutes old) exist before checkout/reset. If the branch
961
+ // does not exist on the remote (caller assumed main, repo uses
962
+ // master), fall back to the repo's real default branch. Shell-unsafe
963
+ // branch names still throw tagged INVALID_BRANCH -> clear response.
964
+ let effBranch = branch;
965
+ try {
966
+ await fetchRequestedRef(execAsync, branch, destDir);
967
+ } catch (refErr) {
968
+ if (refErr && refErr.code === 'BRANCH_NOT_FOUND') {
969
+ const defBranch = await resolveDefaultBranch(destDir);
970
+ if (defBranch) {
971
+ console.warn(`[cloud] Branch '${branch}' not on remote — using default '${defBranch}'`);
972
+ effBranch = defBranch;
973
+ } else {
974
+ throw refErr;
975
+ }
976
+ } else {
977
+ throw refErr;
978
+ }
979
+ }
980
+ await execAsync(`git checkout ${effBranch} 2>/dev/null || git checkout -b ${effBranch} origin/${effBranch}`, { cwd: destDir }).catch(() => {});
981
+ await execAsync(`git reset --hard origin/${effBranch}`, { cwd: destDir });
982
+ } else {
983
+ try {
984
+ await execAsync(
985
+ `git clone --branch ${branch} --single-branch --depth 50 "${authUrl}" "${destDir}"`,
986
+ { timeout: 300000 }
987
+ );
988
+ } catch (cloneErr) {
989
+ // Requested branch absent (e.g. repo uses master). Git phrases this as
990
+ // "Remote branch <x> not found in upstream origin" or "could not find
991
+ // remote branch" — match both, then clone the repo's default branch.
992
+ if (/(remote branch .* not found|could not find remote branch)/i.test(cloneErr.message || '')) {
993
+ console.warn(`[cloud] Branch '${branch}' not found — falling back to the repo default branch`);
994
+ await execAsync(`rm -rf "${destDir}"`).catch(() => {});
995
+ await execAsync(
996
+ `git clone --single-branch --depth 50 "${authUrl}" "${destDir}"`,
997
+ { timeout: 300000 }
998
+ );
999
+ } else {
1000
+ throw cloneErr;
1001
+ }
1002
+ }
1003
+ await execAsync(`git config user.email "deepdebug-ai@deepdebug.ai"`, { cwd: destDir });
1004
+ await execAsync(`git config user.name "DeepDebug AI"`, { cwd: destDir });
1005
+ }
1006
+ }
1007
+
817
1008
  app.post("/workspace/open", async (req, res) => {
818
- const { root, workspaceId, repoUrl, token, branch = "main" } = req.body || {};
1009
+ const { root, workspaceId, repoUrl, token, branch = "main", repos } = req.body || {};
1010
+ // TENANT GATE (ROOT-RECOVERY): remember which tenant this clone is
1011
+ // hydrated for. Best-effort and additive - null means unknown, and
1012
+ // unknown NEVER cross-recovers by basename/scan (fail-closed).
1013
+ // OPEN GATE (2026-07-20): body.tenantId wins, X-Tenant-ID header is the
1014
+ // fallback, so EVERY branch below registers the tenant when the caller
1015
+ // sends it in either place.
1016
+ const openTenantId = extractOpenTenantId(req.body, req.headers);
1017
+
1018
+ // ==========================================
1019
+ // MULTI-REPO CLOUD MODE (SEG-3 + FEATURE):
1020
+ // Gateway sends repos:[{repoUrl,token?,branch?,dir?}] for a workspace.
1021
+ // All N repos are cloned UNDER ONE per-workspace dir, and that dir — the
1022
+ // PARENT of the repos — becomes the sandbox root. The agent's confineToRoot
1023
+ // then allows every repo of THIS workspace and denies anything outside it
1024
+ // (another workspace of the same tenant lives under a different <wsId> dir).
1025
+ // ==========================================
1026
+ if (Array.isArray(repos) && repos.length > 0) {
1027
+ const wsId = (workspaceId && workspaceId.trim()) ? workspaceId.trim() : null;
1028
+ if (!wsId) return res.status(400).json({ ok: false, error: "workspaceId is required for multi-repo open" });
1029
+ // Reject a workspaceId that would escape the cache dir (e.g. "../x").
1030
+ if (!/^[A-Za-z0-9._-]+$/.test(wsId)) {
1031
+ return res.status(400).json({ ok: false, error: "invalid workspaceId" });
1032
+ }
1033
+ // PATH CONVENTION (Phase C, reconciled 2026-09): multi-repo and single-repo
1034
+ // BOTH root under the same WORKSPACE_CACHE base (the NFS mount
1035
+ // /mnt/workspaces in QA/PROD; /tmp/workspaces only as the local-dev
1036
+ // fallback). Single-repo keys by <org_repo>; multi-repo keys by <wsId>
1037
+ // (a globally-unique workspace UUID) because it needs ONE parent dir to
1038
+ // hold N sibling repos. Keying by wsId - not tenantId/repoName - is the
1039
+ // isolation boundary the rest of this handler already enforces:
1040
+ // classifyOpenDestination() denies a dir REGISTERED to another tenant
1041
+ // (requestTenantId gate), and confineToRoot() below keeps every repo
1042
+ // inside this wsId dir, so a second workspace of the same tenant lives
1043
+ // under a different <wsId> and can never be reached. Nesting under
1044
+ // tenantId would add nothing (the UUID is already unique + tenant-gated)
1045
+ // while breaking isSafeToRemove()'s cacheDir/workspaceDir depth check.
1046
+ const cacheDir = process.env.WORKSPACE_CACHE || '/tmp/workspaces';
1047
+ const workspaceDir = path.join(cacheDir, wsId);
1048
+
1049
+ // OPEN GATE (2026-07-20): an open with repos[] is a HYDRATION request.
1050
+ // Never die on a stale dir a previous revision left behind (in-memory
1051
+ // registry lost on restart => orphan): remove the orphan and clone
1052
+ // fresh. A dir REGISTERED for another tenant is never touched - the
1053
+ // caller gets a clear 409 instead of a generic 500.
1054
+ const multiGate = classifyOpenDestination(workspaceDir, {
1055
+ registry: wsManager.list(),
1056
+ requestTenantId: openTenantId
1057
+ });
1058
+ if (multiGate.action === "deny") {
1059
+ console.warn(`[open-gate] DENY multi-repo open: ${workspaceDir} is registered for another tenant (ws '${multiGate.ownerWorkspaceId}') - request tenant '${openTenantId}'`);
1060
+ return res.status(409).json({
1061
+ ok: false,
1062
+ error: "workspace_dir_owned_by_another_tenant",
1063
+ hint: "The clone destination on this agent is registered for a different tenant. Use a distinct workspaceId."
1064
+ });
1065
+ }
1066
+ if (multiGate.action === "clean_and_fresh" && isSafeToRemove(cacheDir, workspaceDir)) {
1067
+ console.warn(`[open-gate] ORPHAN dir at ${workspaceDir} (no registered workspace) - removing stale clone and hydrating fresh`);
1068
+ await fsPromises.rm(workspaceDir, { recursive: true, force: true });
1069
+ }
1070
+ if (multiGate.action === "reuse" && multiGate.registration && multiGate.registration.source === "sidecar") {
1071
+ console.log(`[open-gate] REHYDRATED registration for ${workspaceDir} from sidecar (ws '${multiGate.ownerWorkspaceId}', tenant '${multiGate.ownerTenantId || "unknown"}') - reusing existing clones`);
1072
+ }
1073
+
1074
+ // Validate EVERY repo entry before touching the filesystem (400, nothing cloned).
1075
+ let plan;
1076
+ try {
1077
+ plan = planRepos(repos, { workspaceDir, confineToRoot });
1078
+ } catch (planErr) {
1079
+ return res.status(planErr.httpStatus || 400).json({ ok: false, error: planErr.message });
1080
+ }
1081
+
1082
+ await fsPromises.mkdir(workspaceDir, { recursive: true });
1083
+
1084
+ // REGISTRATION-FIRST (QA 2026-09-22): register the workspace dir (and
1085
+ // write its sidecar) BEFORE cloning. Previously registration happened
1086
+ // only after a fully successful loop, so any failed hydration left an
1087
+ // UNREGISTERED dir on disk; the next open classified it as an orphan,
1088
+ // rm -rf'd the N repos already cloned and re-cloned everything just to
1089
+ // fail on the same repo again (self-perpetuating, 10 clones per scan).
1090
+ try { await wsManager.open(wsId, workspaceDir, { tenantId: openTenantId }); } catch (err) {
1091
+ console.warn(`WorkspaceManager.open failed (non-fatal): ${err.message}`);
1092
+ }
1093
+
1094
+ // PARTIAL TOLERANCE (QA 2026-09-22): one repo the token cannot see
1095
+ // ("Repository not found") must not discard the other N-1 clones.
1096
+ // MULTI_REPO_ALLOW_PARTIAL=false restores the pre-fix fail-all loop.
1097
+ const allowPartial = multiRepoAllowPartial(process.env);
1098
+ let cloned, failed, firstError;
1099
+ try {
1100
+ ({ cloned, failed, firstError } = await hydrateRepos(plan, {
1101
+ cloneRepo: cloneOrUpdateRepo,
1102
+ detectProject,
1103
+ mkdir: (d) => fsPromises.mkdir(d, { recursive: true }),
1104
+ // a failed clone must not leave an empty per-repo dir behind
1105
+ // (an existing clone that failed to REFRESH keeps its .git).
1106
+ cleanupFailed: async (d) => {
1107
+ if (!(await exists(path.join(d, '.git')))) await fsPromises.rm(d, { recursive: true, force: true });
1108
+ },
1109
+ maskUrlCreds,
1110
+ token, branch, allowPartial, wsId
1111
+ }));
1112
+ } catch (gitErr) {
1113
+ // allowPartial=false: first failure aborts the loop (old behavior).
1114
+ cloned = []; failed = []; firstError = gitErr;
1115
+ }
1116
+
1117
+ if (cloned.length === 0) {
1118
+ const gitErr = firstError || new Error("no repo could be cloned");
1119
+ console.error(`[multi-repo] clone failed (0 of ${plan.length} cloned):`, maskUrlCreds(gitErr.message));
1120
+ // Nothing usable was hydrated. If THIS open created the dir
1121
+ // (fresh / cleaned orphan): unregister + remove it so it is neither
1122
+ // a registered-but-empty workspace nor an orphan on disk. On a
1123
+ // REUSE all-fail (e.g. transient network during refresh) the
1124
+ // previously hydrated clones stay registered and are retried next open.
1125
+ if (multiGate.action !== "reuse") {
1126
+ wsManager.close(wsId);
1127
+ if (isSafeToRemove(cacheDir, workspaceDir)) {
1128
+ await fsPromises.rm(workspaceDir, { recursive: true, force: true }).catch(() => {});
1129
+ }
1130
+ }
1131
+ // REFSPEC FIX: a missing/invalid REQUESTED branch is a clear client
1132
+ // error, not a generic 500 (fetchRequestedRef tags it).
1133
+ if (gitErr && (gitErr.code === "BRANCH_NOT_FOUND" || gitErr.code === "INVALID_BRANCH")) {
1134
+ return res.status(gitErr.httpStatus || 500).json({ ok: false, error: gitErr.message, ...(failed.length ? { failed } : {}) });
1135
+ }
1136
+ // SEG-1: the message embeds the authenticated clone URL — mask the
1137
+ // credential before it reaches the response body.
1138
+ return res.status(500).json({
1139
+ ok: false,
1140
+ error: maskUrlCreds(gitErr.message),
1141
+ hint: "Clone failed. Check repo URLs and tokens.",
1142
+ ...(failed.length ? { failed } : {})
1143
+ });
1144
+ }
1145
+
1146
+ // ENG-1: total files across ALL cloned repo dirs (excl. .git). 0 => every
1147
+ // repo of this workspace cloned empty => structural empty clone. We keep
1148
+ // ok:true and surface fileCount/empty so the GATEWAY hard-stops the run
1149
+ // (single decision point) instead of silently proceeding with 0 files.
1150
+ let fileCount = 0;
1151
+ try {
1152
+ for (const c of cloned) fileCount += countFilesExcludingGit(c.path);
1153
+ } catch (err) {
1154
+ console.warn(`countFilesExcludingGit failed (non-fatal): ${err.message}`);
1155
+ }
1156
+ if (fileCount === 0) {
1157
+ console.error(`[multi-repo] EMPTY CLONE: 0 files across ${cloned.length} repo(s) under ${wsId}`);
1158
+ }
1159
+ return res.json({
1160
+ ok: true,
1161
+ root: workspaceDir, // the sandbox root = the workspace dir (parent of the repos)
1162
+ workspaceId: wsId,
1163
+ mode: "cloud-multi-clone",
1164
+ repos: cloned,
1165
+ partial: failed.length > 0,
1166
+ failed, // [{dir, repoUrl (masked), error (masked)}]
1167
+ port: null,
1168
+ fileCount,
1169
+ ...(fileCount === 0 ? { empty: true } : {})
1170
+ });
1171
+ }
819
1172
 
820
1173
  // ==========================================
821
1174
  // CLOUD MODE: Git clone (when Gateway sends repoUrl)
822
1175
  // ==========================================
823
1176
  if (repoUrl) {
824
- console.log(`[/workspace/open] CLOUD MODE cloning ${repoUrl} (branch: ${branch})`);
1177
+ console.log(`[/workspace/open] CLOUD MODE cloning ${maskUrlCreds(repoUrl)} (branch: ${branch})`);
825
1178
 
826
1179
  try {
827
1180
  // Extract repo name: https://github.com/org/repo org_repo
@@ -838,6 +1191,32 @@ app.post("/workspace/open", async (req, res) => {
838
1191
  // Ensure cache directory exists
839
1192
  await fsPromises.mkdir(cacheDir, { recursive: true });
840
1193
 
1194
+ // OPEN GATE (2026-07-20, QA-proven): an open with repoUrl is a
1195
+ // HYDRATION request - it must never 500 on a stale clone dir left
1196
+ // by a previous revision (registry lost on restart => the dir is
1197
+ // an ORPHAN; its git state may be broken, e.g. "ambiguous
1198
+ // argument 'origin/main'" on the reuse path). Orphans are removed
1199
+ // and the repo is cloned fresh, then registered with the request
1200
+ // tenant. A dir REGISTERED for ANOTHER tenant is never touched:
1201
+ // clear 409 instead of a generic 500. Registered same/unknown
1202
+ // tenant keeps the byte-identical reuse/refresh path below.
1203
+ const openGate = classifyOpenDestination(clonePath, {
1204
+ registry: wsManager.list(),
1205
+ requestTenantId: openTenantId
1206
+ });
1207
+ if (openGate.action === "deny") {
1208
+ console.warn(`[open-gate] DENY open: ${clonePath} is registered for another tenant (ws '${openGate.ownerWorkspaceId}') - request tenant '${openTenantId}'`);
1209
+ return res.status(409).json({
1210
+ ok: false,
1211
+ error: "workspace_dir_owned_by_another_tenant",
1212
+ hint: "The clone destination on this agent is registered for a different tenant. Use a distinct workspaceId."
1213
+ });
1214
+ }
1215
+ if (openGate.action === "clean_and_fresh" && isSafeToRemove(cacheDir, clonePath)) {
1216
+ console.warn(`[open-gate] ORPHAN dir at ${clonePath} (no registered workspace) - removing stale clone and cloning fresh`);
1217
+ await fsPromises.rm(clonePath, { recursive: true, force: true });
1218
+ }
1219
+
841
1220
  // Build authenticated URL
842
1221
  // Token formats:
843
1222
  // GitHub: TOKEN (uses x-access-token:TOKEN@)
@@ -866,16 +1245,57 @@ app.post("/workspace/open", async (req, res) => {
866
1245
  // Update remote URL with fresh token
867
1246
  await execAsync(`git remote set-url origin "${authUrl}"`, { cwd: clonePath }).catch(() => {});
868
1247
  await execAsync(`git fetch origin`, { cwd: clonePath, timeout: 120000 });
1248
+ // REFSPEC FIX (QA 2026-07-20, PR #13 review FAILED) composed
1249
+ // with main's default-branch fallback (PR #4): the clone below
1250
+ // is --single-branch, so on reuse `git fetch origin` never
1251
+ // brings branches born AFTER the clone; the checkout error was
1252
+ // swallowed and `git reset --hard origin/<branch>` died with
1253
+ // "ambiguous argument ... unknown revision" -> 500. Fetch the
1254
+ // requested ref explicitly first; if the branch is absent on
1255
+ // the remote, fall back to the repo's real default branch.
1256
+ let effBranch = branch;
1257
+ try {
1258
+ await fetchRequestedRef(execAsync, branch, clonePath);
1259
+ } catch (refErr) {
1260
+ if (refErr && refErr.code === 'BRANCH_NOT_FOUND') {
1261
+ const defBranch = await resolveDefaultBranch(clonePath);
1262
+ if (defBranch) {
1263
+ console.warn(`[cloud] Branch '${branch}' not on remote — using default '${defBranch}'`);
1264
+ effBranch = defBranch;
1265
+ } else {
1266
+ throw refErr;
1267
+ }
1268
+ } else {
1269
+ throw refErr;
1270
+ }
1271
+ }
869
1272
  // Checkout correct branch and reset to remote (discard previous patches)
870
- await execAsync(`git checkout ${branch} 2>/dev/null || git checkout -b ${branch} origin/${branch}`, { cwd: clonePath }).catch(() => {});
871
- await execAsync(`git reset --hard origin/${branch}`, { cwd: clonePath });
872
- console.log(`[cloud] Updated to latest ${branch}`);
1273
+ await execAsync(`git checkout ${effBranch} 2>/dev/null || git checkout -b ${effBranch} origin/${effBranch}`, { cwd: clonePath }).catch(() => {});
1274
+ await execAsync(`git reset --hard origin/${effBranch}`, { cwd: clonePath });
1275
+ console.log(`[cloud] Updated to latest ${effBranch}`);
873
1276
  } else {
874
- console.log(`[cloud] Cloning ${repoUrl} (branch: ${branch})...`);
875
- await execAsync(
876
- `git clone --branch ${branch} --single-branch --depth 50 "${authUrl}" "${clonePath}"`,
877
- { timeout: 300000 }
878
- );
1277
+ console.log(`[cloud] Cloning ${maskUrlCreds(repoUrl)} (branch: ${branch})...`);
1278
+ try {
1279
+ await execAsync(
1280
+ `git clone --branch ${branch} --single-branch --depth 50 "${authUrl}" "${clonePath}"`,
1281
+ { timeout: 300000 }
1282
+ );
1283
+ } catch (cloneErr) {
1284
+ // Requested branch may not exist (e.g. repo uses master, caller assumed main).
1285
+ // Git phrases this as "Remote branch <x> not found in upstream origin" or
1286
+ // "could not find remote branch" depending on version — match both.
1287
+ // Fall back to the repo's default branch instead of failing the session.
1288
+ if (/(remote branch .* not found|could not find remote branch)/i.test(cloneErr.message || '')) {
1289
+ console.warn(`[cloud] Branch '${branch}' not found — falling back to the repo default branch`);
1290
+ await execAsync(`rm -rf "${clonePath}"`).catch(() => {});
1291
+ await execAsync(
1292
+ `git clone --single-branch --depth 50 "${authUrl}" "${clonePath}"`,
1293
+ { timeout: 300000 }
1294
+ );
1295
+ } else {
1296
+ throw cloneErr;
1297
+ }
1298
+ }
879
1299
  // Configure git user for future commits
880
1300
  await execAsync(`git config user.email "deepdebug-ai@deepdebug.ai"`, { cwd: clonePath });
881
1301
  await execAsync(`git config user.name "DeepDebug AI"`, { cwd: clonePath });
@@ -888,30 +1308,46 @@ app.post("/workspace/open", async (req, res) => {
888
1308
  WORKSPACE_ROOT = clonePath;
889
1309
  }
890
1310
  const wsId = workspaceId || "default";
891
- try { await wsManager.open(wsId, clonePath); } catch (err) {
1311
+ try { await wsManager.open(wsId, clonePath, { tenantId: openTenantId }); } catch (err) {
892
1312
  console.warn(`WorkspaceManager.open failed (non-fatal): ${err.message}`);
893
1313
  }
894
1314
 
895
1315
  const meta = await detectProject(clonePath);
896
1316
  const port = await detectPort(clonePath);
897
1317
 
1318
+ // ENG-1: files under the clone (excl. .git). 0 => empty repo / wrong
1319
+ // branch. Keep ok:true and report fileCount/empty; the GATEWAY makes
1320
+ // the hard-stop decision (avoids silent "NEEDS_REVIEW, 0 files read").
1321
+ const fileCount = countFilesExcludingGit(clonePath);
1322
+ if (fileCount === 0) {
1323
+ console.error(`[cloud] EMPTY CLONE: 0 files at ${clonePath} for ${maskUrlCreds(repoUrl)} (branch ${branch})`);
1324
+ }
1325
+
898
1326
  return res.json({
899
1327
  ok: true,
900
1328
  root: clonePath,
901
1329
  workspaceId: wsId,
902
1330
  mode: "cloud-clone",
903
1331
  meta,
904
- port
1332
+ port,
1333
+ fileCount,
1334
+ ...(fileCount === 0 ? { empty: true } : {})
905
1335
  });
906
1336
 
907
1337
  } catch (gitErr) {
908
- console.error(`[cloud] Git clone failed:`, gitErr.message);
1338
+ console.error(`[cloud] Git clone failed:`, maskUrlCreds(gitErr.message));
1339
+ // REFSPEC FIX: a missing/invalid REQUESTED branch is a clear client
1340
+ // error, not a generic 500 (fetchRequestedRef tags it).
1341
+ if (gitErr && (gitErr.code === "BRANCH_NOT_FOUND" || gitErr.code === "INVALID_BRANCH")) {
1342
+ return res.status(gitErr.httpStatus || 500).json({ ok: false, error: gitErr.message });
1343
+ }
909
1344
  const hint = gitErr.message.includes('Authentication') || gitErr.message.includes('could not read')
910
1345
  ? "Authentication failed. Check token and repo URL."
911
1346
  : gitErr.message.includes('not found') || gitErr.message.includes('does not exist')
912
1347
  ? "Repository not found. Check the URL."
913
1348
  : "Clone failed. Ensure the URL is accessible.";
914
- return res.status(500).json({ ok: false, error: gitErr.message, hint });
1349
+ // SEG-1: mask the authenticated clone URL in the response body too.
1350
+ return res.status(500).json({ ok: false, error: maskUrlCreds(gitErr.message), hint });
915
1351
  }
916
1352
  }
917
1353
 
@@ -930,7 +1366,7 @@ app.post("/workspace/open", async (req, res) => {
930
1366
  WORKSPACE_ROOT = abs;
931
1367
  }
932
1368
  try {
933
- await wsManager.open(wsId, abs);
1369
+ await wsManager.open(wsId, abs, { tenantId: openTenantId });
934
1370
  } catch (err) {
935
1371
  console.warn(`WorkspaceManager open failed (non-fatal): ${err.message}`);
936
1372
  }
@@ -961,8 +1397,20 @@ app.post("/workspace/clone", async (req, res) => {
961
1397
 
962
1398
  if (!gitUrl) return res.status(400).json({ ok: false, error: "gitUrl is required" });
963
1399
 
1400
+ // SECURITY: gitUrl/branch were interpolated into a shell string (command
1401
+ // injection via "$(...)" or ";"), tenantId into a filesystem path (../
1402
+ // traversal), and file:// / local paths were clonable (reads arbitrary
1403
+ // host repos). Validate, then run git via execFile (no shell).
1404
+ const gitUrlCheck = validateGitUrl(gitUrl);
1405
+ if (!gitUrlCheck.ok) return res.status(400).json({ ok: false, error: gitUrlCheck.reason });
1406
+ if (branch && !isSafeBranchName(branch)) return res.status(400).json({ ok: false, error: "invalid branch name" });
1407
+ if (tenantId && !isSafeTenantId(tenantId)) return res.status(400).json({ ok: false, error: "invalid tenantId" });
1408
+
964
1409
  // Strip ALL trailing .git suffixes (handles accidental double .git.git from frontend)
965
1410
  const repoName = gitUrl.split('/').pop().replace(/\.git$/i, '').replace(/\.git$/i, '');
1411
+ if (!repoName || repoName === '.' || repoName === '..' || /[\\/]/.test(repoName)) {
1412
+ return res.status(400).json({ ok: false, error: "cannot derive repository name from gitUrl" });
1413
+ }
966
1414
 
967
1415
  const NFS_MOUNT = '/mnt/workspaces';
968
1416
  let absTarget;
@@ -992,7 +1440,7 @@ app.post("/workspace/clone", async (req, res) => {
992
1440
  authenticatedUrl = cleanGitUrl.replace('https://', `https://x-access-token:${gitToken}@`);
993
1441
  }
994
1442
  }
995
- console.log(`Clone request: ${cleanGitUrl} -> ${absTarget}`);
1443
+ console.log(`Clone request: ${maskUrlCreds(cleanGitUrl)} -> ${absTarget}`);
996
1444
 
997
1445
  try {
998
1446
  // Ensure parent directory exists
@@ -1008,18 +1456,18 @@ app.post("/workspace/clone", async (req, res) => {
1008
1456
  console.log(`Repo already exists at ${absTarget}, updating branch: ${targetBranch}...`);
1009
1457
  await execAsync('git fetch --all', { cwd: absTarget, timeout: 60000 });
1010
1458
  try {
1011
- await execAsync(`git checkout ${targetBranch}`, { cwd: absTarget, timeout: 30000 });
1012
- const { stdout } = await execAsync(`git pull origin ${targetBranch}`, { cwd: absTarget, timeout: 120000 });
1459
+ await execFileAsync('git', ['checkout', targetBranch], { cwd: absTarget, timeout: 30000 });
1460
+ const { stdout } = await execFileAsync('git', ['pull', 'origin', targetBranch], { cwd: absTarget, timeout: 120000 });
1013
1461
  console.log(`git pull: ${stdout.trim()}`);
1014
1462
  } catch (checkoutErr) {
1015
1463
  console.warn(`Checkout ${targetBranch} failed, staying on current branch: ${checkoutErr.message}`);
1016
1464
  }
1017
1465
  } else {
1018
1466
  const safeLog = authenticatedUrl.replace(/x-token-auth:[^@]+@/g, 'x-token-auth:***@').replace(/oauth2:[^@]+@/g, 'oauth2:***@').replace(/x-access-token:[^@]+@/g, 'x-access-token:***@');
1019
- const cloneCmd = branch ? `git clone -b ${branch} "${authenticatedUrl}" "${absTarget}"` : `git clone "${authenticatedUrl}" "${absTarget}"`;
1467
+ const cloneArgs = branch ? ['clone', '-b', branch, '--', authenticatedUrl, absTarget] : ['clone', '--', authenticatedUrl, absTarget];
1020
1468
  console.log(`Running: git clone${branch ? ' -b ' + branch : ''} "${safeLog}" "${absTarget}"`);
1021
1469
  const cloneEnvWorkspace = { ...process.env, GIT_TERMINAL_PROMPT: "0", HOME: process.env.HOME || "/tmp" };
1022
- await execAsync(cloneCmd, { timeout: 300000, env: cloneEnvWorkspace });
1470
+ await execFileAsync('git', cloneArgs, { timeout: 300000, env: cloneEnvWorkspace });
1023
1471
  console.log(`Clone complete: ${absTarget}`);
1024
1472
  }
1025
1473
 
@@ -1029,7 +1477,9 @@ app.post("/workspace/clone", async (req, res) => {
1029
1477
  WORKSPACE_ROOT = absTarget;
1030
1478
  }
1031
1479
  try {
1032
- await wsManager.open(wsId, absTarget);
1480
+ // TENANT GATE (ROOT-RECOVERY): register the tenant this clone is
1481
+ // hydrated for so recovery can refuse cross-tenant basename hits.
1482
+ await wsManager.open(wsId, absTarget, { tenantId: tenantId || null });
1033
1483
  } catch (err) {
1034
1484
  console.warn(`WorkspaceManager.open failed (non-fatal): ${err.message}`);
1035
1485
  }
@@ -1047,17 +1497,17 @@ app.post("/workspace/clone", async (req, res) => {
1047
1497
  });
1048
1498
 
1049
1499
  } catch (err) {
1050
- console.error(`Clone failed: ${err.message}`);
1500
+ console.error(`Clone failed: ${maskUrlCreds(err.message)}`);
1051
1501
  const hint = err.message.includes('Authentication') || err.message.includes('could not read')
1052
1502
  ? "Authentication failed. Ensure the repo is public or GitHub integration is configured."
1053
1503
  : err.message.includes('not found') || err.message.includes('does not exist')
1054
1504
  ? "Repository not found. Check the URL is correct."
1055
1505
  : "Clone failed. Ensure git is installed and the URL is accessible.";
1056
- res.status(500).json({ ok: false, error: err.message, hint });
1506
+ res.status(500).json({ ok: false, error: maskUrlCreds(err.message), hint });
1057
1507
  }
1058
1508
  });
1059
1509
 
1060
- /** Info do workspace */
1510
+ /** Workspace info */
1061
1511
  app.get("/workspace/info", async (req, res) => {
1062
1512
  const wsRoot = resolveWorkspaceRoot(req);
1063
1513
  if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
@@ -1066,7 +1516,7 @@ app.get("/workspace/info", async (req, res) => {
1066
1516
  res.json({ root: wsRoot, meta, port });
1067
1517
  });
1068
1518
 
1069
- /** Scan completo do workspace */
1519
+ /** Full workspace scan */
1070
1520
  app.get("/workspace/scan", async (req, res) => {
1071
1521
  const wsRoot = resolveWorkspaceRoot(req);
1072
1522
  if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
@@ -1112,17 +1562,42 @@ app.get("/workspace/analyze", async (req, res) => {
1112
1562
  }
1113
1563
  });
1114
1564
 
1115
- /** L contedo de arquivo especfico */
1565
+ /** Reads the content of a specific file */
1116
1566
  app.get("/workspace/file-content", async (req, res) => {
1117
1567
  const wsRoot = resolveWorkspaceRoot(req);
1118
1568
  if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
1119
1569
 
1570
+ // ROOT-RECOVERY fail-fast: the effective root does not exist on this agent
1571
+ // at all (dead persisted path, no live clone to recover). Tell the caller
1572
+ // EXACTLY what to do instead of a generic 404 the AI retries forever.
1573
+ if (!fs.existsSync(wsRoot)) {
1574
+ return res.status(404).json({
1575
+ error: "workspace_root_not_hydrated",
1576
+ root: wsRoot,
1577
+ hint: "This agent has no clone at that path. Run git_prepare_workspace "
1578
+ + "(POST /workspace/clone) for this workspace first, then retry."
1579
+ });
1580
+ }
1581
+
1120
1582
  const { path: relativePath } = req.query;
1121
1583
  if (!relativePath) return res.status(400).json({ error: "path query param required" });
1122
1584
 
1123
1585
  try {
1124
1586
  const reader = new FileReader(wsRoot);
1125
1587
  const file = await reader.read(relativePath);
1588
+ // RANGED READS (Claude Code model, 2026-07-19): huge files (this repo's
1589
+ // own server.js is 7500+ lines) used to be returned WHOLE, blowing the
1590
+ // AI's context/tool-result budget - the model then burned its whole
1591
+ // iteration budget extracting chunks via `sed` in run_command. Pure
1592
+ // slicing logic in utils/ranged-read.js (guard-tested); small files
1593
+ // without a range keep the byte-identical original response.
1594
+ const sliced = sliceFileContent(file, {
1595
+ startLine: parseInt(req.query.startLine, 10),
1596
+ endLine: parseInt(req.query.endLine, 10),
1597
+ lineCap: parseInt(process.env.FILE_CONTENT_LINE_CAP || "2000", 10)
1598
+ });
1599
+ if (sliced) return res.json(sliced);
1600
+
1126
1601
  res.json(file);
1127
1602
  } catch (err) {
1128
1603
  res.status(404).json({ error: "file not found", details: err.message });
@@ -1152,6 +1627,10 @@ app.post("/workspace/build-push", async (req, res) => {
1152
1627
  };
1153
1628
 
1154
1629
  try {
1630
+ if (!isSafeTenantId(workspaceId)) {
1631
+ emit("error", { error: "invalid workspaceId" });
1632
+ return res.end();
1633
+ }
1155
1634
  const nfsBase = process.env.WORKSPACES_MOUNT || "/mnt/workspaces";
1156
1635
  const wsPath = path.join(nfsBase, workspaceId);
1157
1636
 
@@ -1199,15 +1678,11 @@ app.post("/workspace/build-push", async (req, res) => {
1199
1678
 
1200
1679
  // Step 2: Compress workspace to tar.gz
1201
1680
  emit("progress", "Compressing source code...");
1202
- const { execSync } = await import("child_process");
1681
+ const { execFileSync } = await import("child_process");
1203
1682
  const tarPath = `/tmp/source-${workspaceId}-${Date.now()}.tar.gz`;
1204
1683
  try {
1205
- execSync(
1206
- `tar -czf "${tarPath}" --exclude=".git" --exclude="node_modules" ` +
1207
- `--exclude="target" --exclude="*.class" --exclude=".gradle" ` +
1208
- `-C "${wsPath}" . 2>&1`,
1209
- { stdio: "pipe", timeout: 120000 }
1210
- );
1684
+ // No shell: workspaceId is validated above and paths are argv entries.
1685
+ execFileSync("tar", buildTarArgs({ tarPath, wsPath }), { stdio: "pipe", timeout: 120000 });
1211
1686
  const sizeKB = Math.round(fs.statSync(tarPath).size / 1024);
1212
1687
  emit("progress", `Source compressed: ${sizeKB}KB`);
1213
1688
  } catch (e) {
@@ -1338,6 +1813,12 @@ app.post("/workspace/setup", async (req, res) => {
1338
1813
  if (!workspaceId || !repoUrl) {
1339
1814
  return res.status(400).json({ error: "workspaceId and repoUrl required" });
1340
1815
  }
1816
+ // SECURITY (2026-10-11): workspaceId became a path segment under the NFS
1817
+ // base (../ traversal + rmSync of the escaped dir) and repoUrl was
1818
+ // interpolated into a shell string. Validate, then run git via execFile.
1819
+ if (!isSafeTenantId(workspaceId)) return res.status(400).json({ error: "invalid workspaceId" });
1820
+ const repoUrlCheck = validateGitUrl(repoUrl);
1821
+ if (!repoUrlCheck.ok) return res.status(400).json({ error: repoUrlCheck.reason });
1341
1822
 
1342
1823
  res.setHeader("Content-Type", "text/event-stream");
1343
1824
  res.setHeader("Cache-Control", "no-cache");
@@ -1375,30 +1856,33 @@ app.post("/workspace/setup", async (req, res) => {
1375
1856
  authUrl = urlObj.toString();
1376
1857
  emit("progress", `Using authenticated clone for ${urlObj.hostname} (auth=${authUser})`);
1377
1858
  } catch {
1378
- authUrl = repoUrl.replace("https://", `https://${authUser}:${gitToken}@`);
1379
- emit("progress", `Using authenticated clone (fallback, auth=${authUser})`);
1859
+ emit("error", "repoUrl must be an https URL when a gitToken is supplied");
1860
+ return res.end();
1380
1861
  }
1381
1862
  }
1382
1863
 
1383
- const { execSync } = await import("child_process");
1864
+ const { execFileSync } = await import("child_process");
1384
1865
  const cloneEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0", HOME: process.env.HOME || "/tmp" };
1385
1866
  const execOpts = { stdio: "pipe", timeout: 300000, encoding: "utf8", env: cloneEnv };
1867
+ // No shell: arguments are passed as an argv array.
1868
+ const execSync = (argv, o = execOpts) => execFileSync("git", argv, o);
1386
1869
  let cloneBranch = "main";
1387
1870
 
1388
1871
  // Configure safe directory
1389
- try { execSync(`git config --global --add safe.directory "${wsPath}"`, execOpts); } catch {}
1872
+ try { execSync(["config", "--global", "--add", "safe.directory", wsPath]); } catch {}
1390
1873
 
1391
1874
  if (fs.existsSync(path.join(wsPath, ".git"))) {
1392
1875
  emit("progress", "Workspace exists, updating...");
1393
1876
  try {
1394
1877
  if (gitToken && gitToken.length > 5) {
1395
- execSync(`git remote set-url origin "${authUrl}"`, { ...execOpts, cwd: wsPath });
1878
+ execSync(["remote", "set-url", "origin", authUrl], { ...execOpts, cwd: wsPath });
1396
1879
  }
1397
- execSync("git fetch origin", { ...execOpts, cwd: wsPath });
1398
- cloneBranch = execSync("git branch --show-current", { ...execOpts, cwd: wsPath }).trim() || "main";
1399
- execSync(`git reset --hard origin/${cloneBranch}`, { ...execOpts, cwd: wsPath });
1880
+ execSync(["fetch", "origin"], { ...execOpts, cwd: wsPath });
1881
+ cloneBranch = execSync(["branch", "--show-current"], { ...execOpts, cwd: wsPath }).trim() || "main";
1882
+ if (!isSafeBranchName(cloneBranch)) throw new Error("unsafe current branch name");
1883
+ execSync(["reset", "--hard", `origin/${cloneBranch}`], { ...execOpts, cwd: wsPath });
1400
1884
  } catch (e) {
1401
- const safeMsg = gitToken ? e.message.replace(gitToken, "***") : e.message;
1885
+ const safeMsg = maskUrlCreds(e.message);
1402
1886
  emit("progress", `Update failed (${safeMsg.substring(0, 100)}), re-cloning...`);
1403
1887
  fs.rmSync(wsPath, { recursive: true, force: true });
1404
1888
  }
@@ -1414,13 +1898,16 @@ app.post("/workspace/setup", async (req, res) => {
1414
1898
  for (const b of ["main", "master", "develop"]) {
1415
1899
  try {
1416
1900
  emit("progress", `Trying branch: ${b}...`);
1417
- execSync(`git clone --depth=1 --branch ${b} "${authUrl}" "${wsPath}"`, execOpts);
1901
+ execSync(["clone", "--depth=1", "--branch", b, "--", authUrl, wsPath]);
1418
1902
  cloneBranch = b;
1419
1903
  emit("progress", `Cloned on branch: ${b}`);
1420
1904
  break;
1421
1905
  } catch (e) {
1422
1906
  const stderr = (e.stderr || e.stdout || e.message || "").toString();
1423
- const safeMsg = gitToken ? stderr.replace(new RegExp(gitToken, 'g'), "***") : stderr;
1907
+ // maskUrlCreds is token-agnostic and cannot throw; the previous
1908
+ // new RegExp(gitToken) both missed non-URL repeats and could throw
1909
+ // on regex-special chars in the token.
1910
+ const safeMsg = maskUrlCreds(stderr);
1424
1911
  console.warn(`[Clone] Branch ${b} failed: ${safeMsg.substring(0, 500)}`);
1425
1912
  emit("progress", `Branch ${b} failed: ${safeMsg.substring(0, 200)}`);
1426
1913
  // Clean up partial clone
@@ -1432,12 +1919,15 @@ app.post("/workspace/setup", async (req, res) => {
1432
1919
  if (!fs.existsSync(path.join(wsPath, ".git"))) {
1433
1920
  try {
1434
1921
  emit("progress", "Trying default branch...");
1435
- execSync(`git clone --depth=1 "${authUrl}" "${wsPath}" 2>&1`, execOpts);
1436
- cloneBranch = execSync("git branch --show-current", { ...execOpts, cwd: wsPath }).trim() || "main";
1922
+ execSync(["clone", "--depth=1", "--", authUrl, wsPath]);
1923
+ cloneBranch = execSync(["branch", "--show-current"], { ...execOpts, cwd: wsPath }).trim() || "main";
1437
1924
  emit("progress", `Cloned on default branch: ${cloneBranch}`);
1438
1925
  } catch (e) {
1439
1926
  const stderr = (e.stderr || e.stdout || e.message || "").toString();
1440
- const safeMsg = gitToken ? stderr.replace(new RegExp(gitToken, 'g'), "***") : stderr;
1927
+ // maskUrlCreds is token-agnostic and cannot throw; the previous
1928
+ // new RegExp(gitToken) both missed non-URL repeats and could throw
1929
+ // on regex-special chars in the token.
1930
+ const safeMsg = maskUrlCreds(stderr);
1441
1931
  console.error(`[Clone] Default branch failed: ${safeMsg.substring(0, 500)}`);
1442
1932
  emit("progress", `Default branch failed: ${safeMsg.substring(0, 200)}`);
1443
1933
  if (fs.existsSync(wsPath)) fs.rmSync(wsPath, { recursive: true, force: true });
@@ -1521,7 +2011,7 @@ app.post("/workspace/detect", async (req, res) => {
1521
2011
  }
1522
2012
  });
1523
2013
 
1524
- /** Escreve/salva conteudo de arquivo no workspace */
2014
+ /** Writes/saves file content to the workspace */
1525
2015
  app.post("/workspace/write-file", async (req, res) => {
1526
2016
  const workspaceRoot = resolveWorkspaceRoot(req);
1527
2017
  if (!workspaceRoot) return res.status(400).json({ error: "workspace not set" });
@@ -1531,11 +2021,17 @@ app.post("/workspace/write-file", async (req, res) => {
1531
2021
  if (fileContent === undefined || fileContent === null) return res.status(400).json({ error: "content is required" });
1532
2022
 
1533
2023
  const pathMod = await import('path');
1534
- const fullPath = pathMod.default.resolve(workspaceRoot, relativePath);
1535
-
1536
- // Security: prevent path traversal outside workspace root
1537
- if (!fullPath.startsWith(workspaceRoot)) {
1538
- return res.status(403).json({ error: "Path traversal not allowed" });
2024
+ // SEG-3: realpath + separator-aware containment (the old `startsWith(root)`
2025
+ // let siblings like `<root>-2` and `..`-traversal through).
2026
+ let fullPath;
2027
+ try {
2028
+ fullPath = confineToRoot(workspaceRoot, relativePath);
2029
+ } catch (e) {
2030
+ if (e instanceof SandboxViolation) {
2031
+ console.warn(`[WRITE-FILE] SANDBOX DENIED: ${relativePath} (${e.details?.resolved})`);
2032
+ return res.status(403).json({ error: "path escapes the workspace sandbox" });
2033
+ }
2034
+ throw e;
1539
2035
  }
1540
2036
 
1541
2037
  try {
@@ -1550,7 +2046,7 @@ app.post("/workspace/write-file", async (req, res) => {
1550
2046
 
1551
2047
 
1552
2048
 
1553
- /** L mltiplos arquivos */
2049
+ /** Reads multiple files */
1554
2050
  app.post("/workspace/batch-read", async (req, res) => {
1555
2051
  const wsRoot = resolveWorkspaceRoot(req);
1556
2052
  if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
@@ -1589,7 +2085,7 @@ app.get("/workspace/file-exists", async (req, res) => {
1589
2085
  }
1590
2086
 
1591
2087
  try {
1592
- const fullPath = path.join(wsRoot, relativePath);
2088
+ const fullPath = confineToRoot(wsRoot, relativePath); // SEG-3
1593
2089
  const fileExists = await exists(fullPath);
1594
2090
 
1595
2091
  console.log(`[file-exists] ${relativePath} -> ${fileExists ? 'EXISTS' : 'NOT FOUND'}`);
@@ -1602,6 +2098,9 @@ app.get("/workspace/file-exists", async (req, res) => {
1602
2098
  });
1603
2099
  } catch (err) {
1604
2100
  console.error(`[file-exists] Error:`, err.message);
2101
+ if (err instanceof SandboxViolation) {
2102
+ return res.status(403).json({ ok: false, error: "path escapes the workspace sandbox" });
2103
+ }
1605
2104
  res.status(500).json({ ok: false, error: err.message });
1606
2105
  }
1607
2106
  });
@@ -1624,7 +2123,7 @@ app.post("/workspace/validate-paths", async (req, res) => {
1624
2123
  try {
1625
2124
  const results = await Promise.all(
1626
2125
  pathList.map(async (relativePath) => {
1627
- const fullPath = path.join(wsRoot, relativePath);
2126
+ const fullPath = confineToRoot(wsRoot, relativePath); // SEG-3
1628
2127
  const fileExists = await exists(fullPath);
1629
2128
  return { path: relativePath, exists: fileExists };
1630
2129
  })
@@ -1648,6 +2147,50 @@ app.post("/workspace/validate-paths", async (req, res) => {
1648
2147
  }
1649
2148
  });
1650
2149
 
2150
+ /**
2151
+ * GET /workspace/detect-stack
2152
+ * PASSO 0 of the fix validation ladder: resolve {stack, tier, language,
2153
+ * buildTool} for this workspace so the engine can pick validators without
2154
+ * any language logic of its own.
2155
+ */
2156
+ app.get("/workspace/detect-stack", async (req, res) => {
2157
+ const wsRoot = resolveWorkspaceRoot(req);
2158
+ if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
2159
+ try {
2160
+ const result = await detectBuildTool(wsRoot);
2161
+ console.log(`[detect-stack] ${wsRoot} -> stack=${result.stack} tier=${result.tier}`);
2162
+ res.json({ ok: true, ...result });
2163
+ } catch (err) {
2164
+ console.error("[detect-stack] Error:", err.message);
2165
+ res.status(500).json({ ok: false, error: err.message });
2166
+ }
2167
+ });
2168
+
2169
+ /**
2170
+ * POST /workspace/validate { paths: [relativePath...] }
2171
+ * Camada 2 (SyntaxValidator) of the fix validation ladder: parse-only
2172
+ * validation of the given files. Per-extension resolution (typescript for
2173
+ * JS/TS, tree-sitter for Swift); files without a validator come back as
2174
+ * {validator:"none", ok:true} — the tier degrades, it never blocks.
2175
+ */
2176
+ app.post("/workspace/validate", async (req, res) => {
2177
+ const wsRoot = resolveWorkspaceRoot(req);
2178
+ if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
2179
+ const { paths: pathList } = req.body || {};
2180
+ if (!pathList || !Array.isArray(pathList)) {
2181
+ return res.status(400).json({ error: "paths array required in body" });
2182
+ }
2183
+ try {
2184
+ const results = await validateFiles(wsRoot, pathList.slice(0, 50));
2185
+ const failed = results.filter(r => !r.ok);
2186
+ console.log(`[validate] ${results.length} file(s), ${failed.length} with syntax problems`);
2187
+ res.json({ ok: true, allValid: failed.length === 0, results });
2188
+ } catch (err) {
2189
+ console.error("[validate] Error:", err.message);
2190
+ res.status(500).json({ ok: false, error: err.message });
2191
+ }
2192
+ });
2193
+
1651
2194
  /**
1652
2195
  * POST /workspace/search-file
1653
2196
  * Searches for a file by name in the workspace
@@ -1772,7 +2315,7 @@ app.post("/workspace/search-by-content", async (req, res) => {
1772
2315
  if (results.length >= maxResults * 2) break;
1773
2316
 
1774
2317
  try {
1775
- const fullPath = path.join(wsRoot, filePath);
2318
+ const fullPath = confineToRoot(wsRoot, filePath); // SEG-3
1776
2319
  const content = await readFile(fullPath, 'utf8');
1777
2320
  const lines = content.split('\n');
1778
2321
 
@@ -1868,7 +2411,7 @@ app.post("/workspace/find-field-definition", async (req, res) => {
1868
2411
 
1869
2412
  for (const filePath of targetFiles) {
1870
2413
  try {
1871
- const fullPath = path.join(wsRoot, filePath);
2414
+ const fullPath = confineToRoot(wsRoot, filePath); // SEG-3
1872
2415
  const content = await readFile(fullPath, 'utf8');
1873
2416
  const lines = content.split('\n');
1874
2417
 
@@ -1940,7 +2483,7 @@ app.post("/workspace/find-field-definition", async (req, res) => {
1940
2483
  // RUNTIME MANAGEMENT ENDPOINTS
1941
2484
  // ============================================
1942
2485
 
1943
- /** Detecta servios no workspace */
2486
+ /** Detects services in the workspace */
1944
2487
  app.get("/workspace/services/detect", async (req, res) => {
1945
2488
  const wsRoot = resolveWorkspaceRoot(req);
1946
2489
  if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
@@ -1977,12 +2520,12 @@ app.get("/workspace/services/detect", async (req, res) => {
1977
2520
  }
1978
2521
  });
1979
2522
 
1980
- /** Lista todos os servios */
2523
+ /** Lists all services */
1981
2524
  app.get("/workspace/services", (req, res) => {
1982
2525
  const wsRoot = resolveWorkspaceRoot(req);
1983
2526
  if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
1984
2527
 
1985
- // Atualizar status dos servios com info do ProcessManager
2528
+ // Update the service statuses with info from the ProcessManager
1986
2529
  const servicesWithStatus = DETECTED_SERVICES.map(service => {
1987
2530
  const status = processManager.getStatus(service.id);
1988
2531
  return {
@@ -1994,7 +2537,7 @@ app.get("/workspace/services", (req, res) => {
1994
2537
  res.json({ services: servicesWithStatus });
1995
2538
  });
1996
2539
 
1997
- /** Inicia um servio */
2540
+ /** Starts a service */
1998
2541
  app.post("/workspace/services/:serviceId/start", async (req, res) => {
1999
2542
  const wsRoot = resolveWorkspaceRoot(req);
2000
2543
  if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
@@ -2021,7 +2564,7 @@ app.post("/workspace/services/:serviceId/start", async (req, res) => {
2021
2564
  }
2022
2565
  });
2023
2566
 
2024
- /** Para um servio */
2567
+ /** Stops a service */
2025
2568
  app.post("/workspace/services/:serviceId/stop", async (req, res) => {
2026
2569
  const wsRoot = resolveWorkspaceRoot(req);
2027
2570
  if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
@@ -2036,7 +2579,7 @@ app.post("/workspace/services/:serviceId/stop", async (req, res) => {
2036
2579
  }
2037
2580
  });
2038
2581
 
2039
- /** Retorna status de um servio */
2582
+ /** Returns the status of a service */
2040
2583
  app.get("/workspace/services/:serviceId/status", (req, res) => {
2041
2584
  const wsRoot = resolveWorkspaceRoot(req);
2042
2585
  if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
@@ -2047,7 +2590,7 @@ app.get("/workspace/services/:serviceId/status", (req, res) => {
2047
2590
  res.json({ serviceId, ...status });
2048
2591
  });
2049
2592
 
2050
- /** Retorna logs de um servio */
2593
+ /** Returns the logs of a service */
2051
2594
  app.get("/workspace/services/:serviceId/logs", (req, res) => {
2052
2595
  const wsRoot = resolveWorkspaceRoot(req);
2053
2596
  if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
@@ -2075,7 +2618,7 @@ app.get("/workspace/services/:serviceId/logs/stream", (req, res) => {
2075
2618
  res.setHeader("Cache-Control", "no-cache");
2076
2619
  res.setHeader("Connection", "keep-alive");
2077
2620
 
2078
- // Enviar logs existentes
2621
+ // Send existing logs
2079
2622
  const existingLogs = processManager.getLogs(serviceId);
2080
2623
  existingLogs.forEach(log => {
2081
2624
  res.write(`data: ${JSON.stringify(log)}\n\n`);
@@ -2090,7 +2633,7 @@ app.get("/workspace/services/:serviceId/logs/stream", (req, res) => {
2090
2633
 
2091
2634
  processManager.on("log", logHandler);
2092
2635
 
2093
- // Cleanup ao fechar conexo
2636
+ // Cleanup when the connection closes
2094
2637
  req.on("close", () => {
2095
2638
  processManager.off("log", logHandler);
2096
2639
  });
@@ -2103,9 +2646,28 @@ app.get("/workspace/services/:serviceId/logs/stream", (req, res) => {
2103
2646
  app.get("/workspace/files", async (req, res) => {
2104
2647
  const wsRoot = resolveWorkspaceRoot(req);
2105
2648
  if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
2106
- const { max = 5000 } = req.query;
2107
- const tree = await listRecursive(wsRoot, { maxFiles: Number(max) });
2108
- res.json({ root: wsRoot, count: tree.length, tree });
2649
+ // CRASH-LOOP FIX (2026-07-20): this handler awaited listRecursive with NO
2650
+ // try/catch. A dead root (indexer sending the NFS-era /mnt path) made the
2651
+ // scandir ENOENT an UNHANDLED promise rejection - Node 20 kills the whole
2652
+ // process, Cloud Run restarts it, the indexer retries, and the single QA
2653
+ // agent instance crash-looped 503s for every tenant. Fail fast with the
2654
+ // structured hint instead; and any other walk error answers 500 JSON -
2655
+ // never a dead process.
2656
+ if (!fs.existsSync(wsRoot)) {
2657
+ return res.status(404).json({
2658
+ error: "workspace_root_not_hydrated",
2659
+ root: wsRoot,
2660
+ hint: "This agent has no clone at that path. Run git_prepare_workspace "
2661
+ + "(POST /workspace/clone) for this workspace first, then retry."
2662
+ });
2663
+ }
2664
+ try {
2665
+ const { max = 5000 } = req.query;
2666
+ const tree = await listRecursive(wsRoot, { maxFiles: Number(max) });
2667
+ res.json({ root: wsRoot, count: tree.length, tree });
2668
+ } catch (err) {
2669
+ res.status(500).json({ error: "workspace walk failed", details: String(err && err.message) });
2670
+ }
2109
2671
  });
2110
2672
 
2111
2673
  app.get("/workspace/file", async (req, res) => {
@@ -2127,7 +2689,7 @@ app.post("/workspace/write", async (req, res) => {
2127
2689
  const { path: rel, content } = req.body || {};
2128
2690
  if (!rel) return res.status(400).json({ error: "path is required" });
2129
2691
  try {
2130
- const fullPath = path.join(wsRoot, rel);
2692
+ const fullPath = confineToRoot(wsRoot, rel); // SEG-3
2131
2693
  await fsPromises.mkdir(path.dirname(fullPath), { recursive: true });
2132
2694
  await writeFile(fullPath, content ?? "", "utf8");
2133
2695
  res.json({ ok: true, path: rel, bytes: Buffer.byteLength(content ?? "", "utf8") });
@@ -2188,11 +2750,64 @@ app.post("/workspace/test", async (req, res) => {
2188
2750
  res.json({ root: wsRoot, meta, result });
2189
2751
  });
2190
2752
 
2753
+ /**
2754
+ * POST /workspace/compile (v2 — added 2026-06-03 for BYOC Phase 7.5+8)
2755
+ *
2756
+ * Compile-only path. Sibling of /workspace/test but with skipTests=true so
2757
+ * the BYOC gateway can validate the build without paying the test runtime.
2758
+ *
2759
+ * The gateway's LocalAgentClient.compileAt + the operator-side compile path
2760
+ * both target this endpoint. local-agent-full:v1 did NOT expose it, so
2761
+ * 75-04 added an adapter that routed compile -> /workspace/test instead;
2762
+ * once :v2 ships everywhere we can flip compileAt back to this URL.
2763
+ *
2764
+ * Response shape matches /workspace/test:
2765
+ * { root, meta, result: { code, stdout, stderr, duration } }
2766
+ * plus top-level { ok, message, output, durationMs } convenience fields
2767
+ * for callers that consume the simple shape.
2768
+ */
2769
+ app.post("/workspace/compile", async (req, res) => {
2770
+ const wsRoot = resolveWorkspaceRoot(req);
2771
+ if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
2772
+ const meta = await detectProject(wsRoot);
2773
+ const result = await compileAndTest({
2774
+ language: meta.language,
2775
+ buildTool: meta.buildTool,
2776
+ cwd: wsRoot,
2777
+ skipTests: true,
2778
+ });
2779
+ const ok = result?.code === 0;
2780
+ res.json({
2781
+ ok,
2782
+ message: ok ? "compile succeeded" : "compile failed",
2783
+ output: result?.stdout || result?.stderr || "",
2784
+ durationMs: result?.duration || 0,
2785
+ root: wsRoot,
2786
+ meta,
2787
+ result,
2788
+ });
2789
+ });
2790
+
2191
2791
  app.post("/workspace/run", async (req, res) => {
2192
2792
  const wsRoot = resolveWorkspaceRoot(req);
2193
2793
  if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
2194
2794
  const { cmd, args = [] } = req.body || {};
2195
2795
  if (!cmd) return res.status(400).json({ error: "cmd is required" });
2796
+ // SEG-3: cwd is pinned to the workspace root; reject any path argument that
2797
+ // resolves outside it (e.g. `cat ../<other-workspace>/x`).
2798
+ const argCheck = checkArgsWithinRoot(wsRoot, [cmd, ...args]);
2799
+ if (!argCheck.ok) {
2800
+ console.warn(`[workspace/run] SANDBOX DENIED arg: ${argCheck.arg} (${argCheck.resolved})`);
2801
+ return res.status(403).json({ error: "command argument escapes the workspace sandbox", arg: argCheck.arg });
2802
+ }
2803
+ const runDecision = decideExec({
2804
+ argv: [cmd, ...args].map(String), root: wsRoot, headers: req.headers, body: req.body || {},
2805
+ vhSignature: req.vhSignature, signingCfg: SIGNING,
2806
+ }, EXEC_POLICY);
2807
+ if (!runDecision.allow) {
2808
+ console.warn(`[workspace/run] POLICY DENIED: ${runDecision.reason}`);
2809
+ return res.status(runDecision.status || 403).json(rejectionBody(runDecision));
2810
+ }
2196
2811
  const out = await run(cmd, args, wsRoot, 5 * 60 * 1000);
2197
2812
  res.json(out);
2198
2813
  });
@@ -2201,16 +2816,9 @@ app.post("/workspace/run", async (req, res) => {
2201
2816
  // TEST LOCAL ENDPOINTS
2202
2817
  // ============================================
2203
2818
 
2204
- /** Store test local state */
2205
- let TEST_LOCAL_STATE = {
2206
- status: "idle", // idle, compiling, compiled, starting, running, stopped, error
2207
- compilationResult: null,
2208
- serverProcess: null,
2209
- endpoints: [],
2210
- config: null,
2211
- testResults: [],
2212
- serverLogs: [] // Buffer circular de logs do servidor (ltimos 1000)
2213
- };
2819
+ // TEST_LOCAL_STATE is declared once, early (see above) - it must exist before
2820
+ // the ProcessManager event listeners run. The endpoints below mutate that same
2821
+ // single object.
2214
2822
 
2215
2823
  // ============================================
2216
2824
  // TEST LOCAL STATE ENDPOINTS (ADDED)
@@ -2269,19 +2877,19 @@ app.post("/workspace/git/prepare", async (req, res) => {
2269
2877
  console.log(`[git-prepare] Starting workspace preparation: wsRoot=${wsRoot} repoUrl=${repoUrl || 'none'}`);
2270
2878
 
2271
2879
  try {
2272
- const { execSync } = await import("child_process");
2880
+ const { execFileSync } = await import("child_process");
2273
2881
  const execOpts = { encoding: "utf8", timeout: 120000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } };
2274
- const execGit = (cmd) => {
2882
+ // No shell: `cmd` is an argv array (git is the executable).
2883
+ const execSync = (argv, o = execOpts) => execFileSync("git", argv, o);
2884
+ const execGit = (argv) => {
2275
2885
  try {
2276
- return { ok: true, output: execSync(cmd, {
2277
- cwd: wsRoot, ...execOpts
2278
- }).trim() };
2886
+ return { ok: true, output: execSync(argv, { cwd: wsRoot, ...execOpts }).trim() };
2279
2887
  } catch (err) {
2280
2888
  return { ok: false, output: (err.stderr || err.stdout || err.message || "unknown").toString().trim() };
2281
2889
  }
2282
2890
  };
2283
2891
 
2284
- execGit(`git config --global --add safe.directory "${wsRoot}"`);
2892
+ execGit(["config", "--global", "--add", "safe.directory", wsRoot]);
2285
2893
 
2286
2894
  // If workspace is empty (no .git), clone first before attempting fetch/reset
2287
2895
  const gitDir = path.join(wsRoot, ".git");
@@ -2289,9 +2897,13 @@ app.post("/workspace/git/prepare", async (req, res) => {
2289
2897
  if (!repoUrl) {
2290
2898
  return res.json({ ok: false, error: "Workspace is empty and no repoUrl provided for clone" });
2291
2899
  }
2292
- console.log(`[git-prepare] No .git found at ${wsRoot}, cloning from ${repoUrl}`);
2900
+ console.log(`[git-prepare] No .git found at ${wsRoot}, cloning from ${maskUrlCreds(repoUrl)}`);
2293
2901
  fs.mkdirSync(wsRoot, { recursive: true });
2294
2902
 
2903
+ // SECURITY (2026-10-11): repoUrl/branch reached a shell string.
2904
+ const prepUrlCheck = validateGitUrl(repoUrl);
2905
+ if (!prepUrlCheck.ok) return res.status(400).json({ ok: false, error: prepUrlCheck.reason });
2906
+ if (reqBranch && !isSafeBranchName(reqBranch)) return res.status(400).json({ ok: false, error: "invalid branch name" });
2295
2907
  // Build authenticated URL
2296
2908
  let authUrl = repoUrl;
2297
2909
  if (gitToken) {
@@ -2313,24 +2925,24 @@ app.post("/workspace/git/prepare", async (req, res) => {
2313
2925
  try {
2314
2926
  fs.rmSync(wsRoot, { recursive: true, force: true });
2315
2927
  fs.mkdirSync(wsRoot, { recursive: true });
2316
- execSync(`git clone --depth 50 --branch "${b}" "${authUrl}" "${wsRoot}"`, execOpts);
2928
+ execSync(["clone", "--depth", "50", "--branch", b, "--", authUrl, wsRoot]);
2317
2929
  clonedBranch = b;
2318
2930
  cloned = true;
2319
2931
  console.log(`[git-prepare] Cloned branch ${b} successfully`);
2320
2932
  break;
2321
2933
  } catch (cloneErr) {
2322
- console.warn(`[git-prepare] Clone branch ${b} failed: ${(cloneErr.message || "").slice(0, 100)}`);
2934
+ console.warn(`[git-prepare] Clone branch ${b} failed: ${maskUrlCreds(cloneErr.message || "").slice(0, 100)}`);
2323
2935
  }
2324
2936
  }
2325
2937
  if (!cloned) {
2326
2938
  try {
2327
2939
  fs.rmSync(wsRoot, { recursive: true, force: true });
2328
2940
  fs.mkdirSync(wsRoot, { recursive: true });
2329
- execSync(`git clone --depth 50 "${authUrl}" "${wsRoot}"`, execOpts);
2941
+ execSync(["clone", "--depth", "50", "--", authUrl, wsRoot]);
2330
2942
  cloned = true;
2331
2943
  clonedBranch = "default";
2332
2944
  } catch (e) {
2333
- return res.json({ ok: false, error: "Clone failed: " + (e.message || "").slice(0, 200) });
2945
+ return res.json({ ok: false, error: "Clone failed: " + maskUrlCreds(e.message || "").slice(0, 200) });
2334
2946
  }
2335
2947
  }
2336
2948
 
@@ -2338,9 +2950,17 @@ app.post("/workspace/git/prepare", async (req, res) => {
2338
2950
  WORKSPACE_ROOT = wsRoot;
2339
2951
  try { await wsManager.open(workspaceId || path.basename(wsRoot), wsRoot); } catch (_) {}
2340
2952
 
2341
- const commit = execGit("git rev-parse --short HEAD");
2342
- let fileCount = 0;
2343
- try { function walk(d) { for (const e of fs.readdirSync(d, {withFileTypes:true})) { if (e.name==='.git') continue; if (e.isDirectory()) walk(path.join(d,e.name)); else fileCount++; } } walk(wsRoot); } catch (_) {}
2953
+ const commit = execGit(["rev-parse", "--short", "HEAD"]);
2954
+ const fileCount = countFilesExcludingGit(wsRoot);
2955
+ // ENG-1 HARD-STOP: a clone that yields 0 files (excl. .git) is a
2956
+ // structural failure (empty repo, wrong/empty branch, bad path).
2957
+ // Fail closed instead of returning ok:true — the previous behaviour
2958
+ // let the run continue and die later as "NEEDS_REVIEW, 0 files read".
2959
+ if (fileCount === 0) {
2960
+ console.error(`[git-prepare] EMPTY CLONE: 0 files after cloning branch ${clonedBranch} from ${maskUrlCreds(repoUrl)}`);
2961
+ return res.json({ ok: false, empty: true, fileCount: 0, reason: "empty_clone",
2962
+ error: "Cloned repository has no files (empty repo or wrong branch)" });
2963
+ }
2344
2964
  return res.json({ ok: true, cloned: true, mainBranch: clonedBranch,
2345
2965
  currentCommit: commit.ok ? commit.output : "unknown", fileCount,
2346
2966
  message: `Repository cloned: ${clonedBranch} (${fileCount} files)` });
@@ -2356,48 +2976,56 @@ app.post("/workspace/git/prepare", async (req, res) => {
2356
2976
  } else {
2357
2977
  authUrl = repoUrl.replace(/https:\/\/([^@]*@)?/, `https://x-access-token:${gitToken}@`);
2358
2978
  }
2359
- execGit(`git remote set-url origin "${authUrl}"`);
2979
+ execGit(["remote", "set-url", "origin", authUrl]);
2360
2980
  }
2361
2981
 
2362
2982
  console.log("[git-prepare] Fetching origin...");
2363
- execGit("git fetch origin --prune");
2983
+ execGit(["fetch", "origin", "--prune"]);
2364
2984
 
2365
2985
  let mainBranch = null;
2366
2986
  for (const branch of ["main", "master", "develop", "dev"]) {
2367
- if (execGit(`git rev-parse --verify origin/${branch}`).ok) {
2987
+ if (execGit(["rev-parse", "--verify", `origin/${branch}`]).ok) {
2368
2988
  mainBranch = branch;
2369
2989
  break;
2370
2990
  }
2371
2991
  }
2372
2992
  if (!mainBranch) {
2373
- const cur = execGit("git rev-parse --abbrev-ref HEAD");
2374
- mainBranch = cur.ok ? cur.output : "main";
2993
+ const cur = execGit(["rev-parse", "--abbrev-ref", "HEAD"]);
2994
+ mainBranch = cur.ok && isSafeBranchName(cur.output) ? cur.output : "main";
2375
2995
  }
2376
2996
 
2377
- const prevBranch = (execGit("git rev-parse --abbrev-ref HEAD").output || "unknown");
2378
- const prevCommit = (execGit("git rev-parse --short HEAD").output || "unknown");
2997
+ const prevBranch = (execGit(["rev-parse", "--abbrev-ref", "HEAD"]).output || "unknown");
2998
+ const prevCommit = (execGit(["rev-parse", "--short", "HEAD"]).output || "unknown");
2379
2999
 
2380
3000
  console.log(`[git-prepare] ${prevBranch}@${prevCommit} -> ${mainBranch}`);
2381
3001
 
2382
- let checkout = execGit(`git checkout ${mainBranch}`);
2383
- if (!checkout.ok) checkout = execGit(`git checkout -f ${mainBranch}`);
3002
+ let checkout = execGit(["checkout", mainBranch]);
3003
+ if (!checkout.ok) checkout = execGit(["checkout", "-f", mainBranch]);
2384
3004
  if (!checkout.ok) {
2385
3005
  return res.json({ ok: false, error: `Could not checkout ${mainBranch}: ${checkout.output}`,
2386
3006
  mainBranch, previousBranch: prevBranch, previousCommit: prevCommit });
2387
3007
  }
2388
3008
 
2389
- execGit(`git reset --hard origin/${mainBranch}`);
2390
- execGit('git clean -fd --exclude="*.env" --exclude=".env*"');
3009
+ execGit(["reset", "--hard", `origin/${mainBranch}`]);
3010
+ execGit(["clean", "-fd", "--exclude=*.env", "--exclude=.env*"]);
2391
3011
 
2392
- const newCommit = (execGit("git rev-parse --short HEAD").output || "unknown");
2393
- console.log(`[git-prepare] Ready: ${mainBranch}@${newCommit}`);
3012
+ const newCommit = (execGit(["rev-parse", "--short", "HEAD"]).output || "unknown");
3013
+ // ENG-1 HARD-STOP: same structural check on the fetch/reset path — an
3014
+ // already-cloned workspace that holds 0 files (excl. .git) is empty.
3015
+ const fileCount = countFilesExcludingGit(wsRoot);
3016
+ if (fileCount === 0) {
3017
+ console.error(`[git-prepare] EMPTY WORKSPACE: 0 files after prepare on ${mainBranch}@${newCommit}`);
3018
+ return res.json({ ok: false, empty: true, fileCount: 0, reason: "empty_clone",
3019
+ error: "Prepared workspace has no files (empty repo or wrong branch)" });
3020
+ }
3021
+ console.log(`[git-prepare] Ready: ${mainBranch}@${newCommit} (${fileCount} files)`);
2394
3022
 
2395
- res.json({ ok: true, mainBranch, currentCommit: newCommit,
3023
+ res.json({ ok: true, mainBranch, currentCommit: newCommit, fileCount,
2396
3024
  previousBranch: prevBranch, previousCommit: prevCommit,
2397
3025
  message: `Workspace prepared: on ${mainBranch} at ${newCommit}` });
2398
3026
  } catch (err) {
2399
- console.error("[git-prepare] Error:", err.message);
2400
- res.status(500).json({ ok: false, error: err.message });
3027
+ console.error("[git-prepare] Error:", maskUrlCreds(err.message));
3028
+ res.status(500).json({ ok: false, error: maskUrlCreds(err.message) });
2401
3029
  }
2402
3030
  });
2403
3031
 
@@ -2505,7 +3133,7 @@ app.post("/workspace/test-local/start", async (req, res) => {
2505
3133
  // Detect port if not provided
2506
3134
  let serverPort = port || 8080;
2507
3135
 
2508
- // Para Java, encontrar o JAR e correr directamente
3136
+ // For Java, find the JAR and run it directly
2509
3137
  if (meta.language === 'java') {
2510
3138
  const targetDir = path.join(wsRoot, 'target');
2511
3139
 
@@ -2527,19 +3155,19 @@ app.post("/workspace/test-local/start", async (req, res) => {
2527
3155
 
2528
3156
  const jarPath = path.join(targetDir, jarFiles[0]);
2529
3157
 
2530
- // Comando EXACTO como no terminal (sem profiles!)
3158
+ // EXACT command as in the terminal (no profiles!)
2531
3159
  const command = 'java';
2532
3160
  const args = ['-jar', jarPath, `--server.port=${serverPort}`];
2533
3161
 
2534
3162
  console.log(`Command: ${command} ${args.join(' ')}`);
2535
3163
 
2536
- // Env LIMPO - remover TODAS as variveis Spring que podem interferir
3164
+ // CLEAN env - remove ALL Spring variables that could interfere
2537
3165
  const cleanEnv = { ...process.env };
2538
3166
  delete cleanEnv.SPRING_PROFILES_ACTIVE;
2539
3167
  delete cleanEnv.SPRING_DATASOURCE_URL;
2540
3168
  delete cleanEnv.SPRING_DATASOURCE_USERNAME;
2541
3169
  delete cleanEnv.SPRING_DATASOURCE_PASSWORD;
2542
- // Remover qualquer varivel que comece com SPRING_
3170
+ // Remove any variable that starts with SPRING_
2543
3171
  Object.keys(cleanEnv).forEach(key => {
2544
3172
  if (key.startsWith('SPRING_')) {
2545
3173
  delete cleanEnv[key];
@@ -2698,7 +3326,13 @@ app.post("/workspace/test-local/execute", async (req, res) => {
2698
3326
 
2699
3327
  try {
2700
3328
  const serverPort = port || TEST_LOCAL_STATE.config?.server?.port || 8080;
2701
- const url = `http://localhost:${serverPort}${reqPath}`;
3329
+ // Security: `${port}${path}` concatenation let path "@host/x" retarget the
3330
+ // request to any host (SSRF). Build + validate a loopback URL instead.
3331
+ const built = buildLocalTestUrl(serverPort, reqPath);
3332
+ if (!built.ok) {
3333
+ return res.status(400).json({ ok: false, error: built.reason });
3334
+ }
3335
+ const url = built.url;
2702
3336
 
2703
3337
  console.log(`[TEST-LOCAL] Executing: ${method} ${url}`);
2704
3338
 
@@ -2716,7 +3350,7 @@ app.post("/workspace/test-local/execute", async (req, res) => {
2716
3350
  fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
2717
3351
  }
2718
3352
 
2719
- const response = await fetch(url, fetchOptions);
3353
+ const response = await fetch(url, { ...fetchOptions, redirect: "manual" });
2720
3354
  const duration = Date.now() - startTime;
2721
3355
 
2722
3356
  let responseBody;
@@ -2983,7 +3617,7 @@ app.post("/workspace/safe-patch", async (req, res) => {
2983
3617
  const backupFiles = [];
2984
3618
 
2985
3619
  for (const relPath of targetFiles) {
2986
- const fullPath = path.join(wsRoot, relPath);
3620
+ const fullPath = confineToRoot(wsRoot, relPath); // SEG-3
2987
3621
  if (await exists(fullPath)) {
2988
3622
  const content = await readFile(fullPath, 'utf8');
2989
3623
  backupFiles.push({ path: relPath, content });
@@ -3038,7 +3672,7 @@ app.post("/workspace/safe-patch", async (req, res) => {
3038
3672
  console.error(`Patch failed, rolling back: ${patchError.message}`);
3039
3673
 
3040
3674
  for (const file of backupFiles) {
3041
- const fullPath = path.join(wsRoot, file.path);
3675
+ const fullPath = confineToRoot(wsRoot, file.path); // SEG-3
3042
3676
  await writeFile(fullPath, file.content, 'utf8');
3043
3677
  }
3044
3678
 
@@ -3095,7 +3729,7 @@ app.post("/workspace/patch/dry-run", async (req, res) => {
3095
3729
  // 3. Check if files exist
3096
3730
  const fileChecks = await Promise.all(
3097
3731
  targetFiles.map(async (relPath) => {
3098
- const fullPath = path.join(wsRoot, relPath);
3732
+ const fullPath = confineToRoot(wsRoot, relPath); // SEG-3
3099
3733
  const fileExists = await exists(fullPath);
3100
3734
  let currentContent = null;
3101
3735
  let lineCount = 0;
@@ -3184,7 +3818,7 @@ app.post("/workspace/backup", async (req, res) => {
3184
3818
  const backupFiles = [];
3185
3819
 
3186
3820
  for (const relPath of files) {
3187
- const fullPath = path.join(wsRoot, relPath);
3821
+ const fullPath = confineToRoot(wsRoot, relPath); // SEG-3
3188
3822
  if (await exists(fullPath)) {
3189
3823
  const content = await readFile(fullPath, 'utf8');
3190
3824
  backupFiles.push({ path: relPath, content });
@@ -3249,7 +3883,7 @@ app.post("/workspace/rollback", async (req, res) => {
3249
3883
  console.log(`Rolling back to backup: ${backupId}`);
3250
3884
 
3251
3885
  for (const file of backup.files) {
3252
- const fullPath = path.join(wsRoot, file.path);
3886
+ const fullPath = confineToRoot(wsRoot, file.path); // SEG-3
3253
3887
  await writeFile(fullPath, file.content, 'utf8');
3254
3888
  }
3255
3889
 
@@ -3376,7 +4010,7 @@ app.get("/workspace/diff/by-incident/:incidentId", async (req, res) => {
3376
4010
  try {
3377
4011
  const diffs = [];
3378
4012
  for (const file of matchedBackup.files) {
3379
- const fullPath = path.join(wsRoot, file.path);
4013
+ const fullPath = confineToRoot(wsRoot, file.path); // SEG-3
3380
4014
  let currentContent = '';
3381
4015
  try {
3382
4016
  currentContent = await readFile(fullPath, 'utf8');
@@ -3455,7 +4089,7 @@ app.get("/workspace/diff/:backupId", async (req, res) => {
3455
4089
  const diffs = [];
3456
4090
 
3457
4091
  for (const file of backup.files) {
3458
- const fullPath = path.join(wsRoot, file.path);
4092
+ const fullPath = confineToRoot(wsRoot, file.path); // SEG-3
3459
4093
  let currentContent = '';
3460
4094
 
3461
4095
  try {
@@ -3523,7 +4157,7 @@ app.get("/workspace/detect-port", async (req, res) => {
3523
4157
  const { servicePath = '', language, framework } = req.query;
3524
4158
 
3525
4159
  try {
3526
- const fullPath = path.join(wsRoot, servicePath);
4160
+ const fullPath = confineToRoot(wsRoot, servicePath); // SEG-3
3527
4161
  console.log(`Detecting port for service at: ${fullPath}`);
3528
4162
 
3529
4163
  let port = null;
@@ -4028,17 +4662,20 @@ app.get("/workspace/test-local/logs/stream", async (req, res) => {
4028
4662
 
4029
4663
  // ============================================
4030
4664
  // AUTO-TRAINING ENDPOINTS
4031
- // Escanear e ler arquivos para treinamento AI
4665
+ // Scan and read files for AI training
4032
4666
  // ============================================
4033
4667
 
4034
4668
  /**
4035
4669
  * POST /workspace/scan-files
4036
- * Escaneia todos os arquivos do workspace para auto-training
4670
+ * Scans all workspace files for auto-training
4037
4671
  */
4038
4672
  app.post("/workspace/scan-files", async (req, res) => {
4039
4673
  const { root, excludePatterns = [], includeExtensions = [], maxDepth = 10, maxFiles = 1000 } = req.body;
4040
4674
 
4041
- const workspaceRoot = root || WORKSPACE_ROOT;
4675
+ // SEG-3: when the gateway sets the tenant-isolated root (header), it wins and
4676
+ // a body `root` can never override it. Body root is honoured only in dev
4677
+ // (no header present).
4678
+ const workspaceRoot = resolveWorkspaceRoot(req) || root || WORKSPACE_ROOT;
4042
4679
  if (!workspaceRoot) {
4043
4680
  return res.status(400).json({
4044
4681
  error: "workspace not set",
@@ -4055,7 +4692,7 @@ app.post("/workspace/scan-files", async (req, res) => {
4055
4692
  const fs = await import("fs/promises");
4056
4693
  const pathModule = await import("path");
4057
4694
 
4058
- // Padres padro para excluir
4695
+ // Default patterns to exclude
4059
4696
  const defaultExcludes = [
4060
4697
  "node_modules", "target", "build", "dist", ".git", ".idea",
4061
4698
  ".vscode", "__pycache__", ".gradle", "bin", "obj",
@@ -4063,7 +4700,7 @@ app.post("/workspace/scan-files", async (req, res) => {
4063
4700
  ];
4064
4701
  const allExcludes = [...defaultExcludes, ...excludePatterns];
4065
4702
 
4066
- // Funo recursiva para escanear
4703
+ // Recursive function to scan
4067
4704
  async function scanDir(dir, depth = 0) {
4068
4705
  if (depth > maxDepth || files.length >= maxFiles) return;
4069
4706
 
@@ -4076,7 +4713,7 @@ app.post("/workspace/scan-files", async (req, res) => {
4076
4713
  const fullPath = pathModule.join(dir, entry.name);
4077
4714
  const relativePath = pathModule.relative(workspaceRoot, fullPath);
4078
4715
 
4079
- // Verificar se deve excluir
4716
+ // Check whether it should be excluded
4080
4717
  const shouldExclude = allExcludes.some(pattern => {
4081
4718
  if (pattern.includes("*")) {
4082
4719
  const regex = new RegExp(pattern.replace(/\*/g, ".*"));
@@ -4090,7 +4727,7 @@ app.post("/workspace/scan-files", async (req, res) => {
4090
4727
  if (entry.isDirectory()) {
4091
4728
  await scanDir(fullPath, depth + 1);
4092
4729
  } else if (entry.isFile()) {
4093
- // Verificar extenso
4730
+ // Check the extension
4094
4731
  const ext = pathModule.extname(entry.name).toLowerCase();
4095
4732
  if (includeExtensions.length === 0 || includeExtensions.includes(ext)) {
4096
4733
  files.push(relativePath);
@@ -4125,12 +4762,14 @@ app.post("/workspace/scan-files", async (req, res) => {
4125
4762
 
4126
4763
  /**
4127
4764
  * POST /workspace/read-file
4128
- * L contedo de um arquivo especfico
4765
+ * Reads the content of a specific file
4129
4766
  */
4130
4767
  app.post("/workspace/read-file", async (req, res) => {
4131
- const { root, filePath } = req.body;
4768
+ const { filePath } = req.body;
4132
4769
 
4133
- const workspaceRoot = root || WORKSPACE_ROOT;
4770
+ // SEG-3: the root comes ONLY from the tenant-isolated request context,
4771
+ // never from the body (a body `root` could point at another workspace).
4772
+ const workspaceRoot = resolveWorkspaceRoot(req);
4134
4773
  if (!workspaceRoot) {
4135
4774
  return res.status(400).json({
4136
4775
  error: "workspace not set"
@@ -4147,11 +4786,19 @@ app.post("/workspace/read-file", async (req, res) => {
4147
4786
 
4148
4787
  try {
4149
4788
  const fs = await import("fs/promises");
4150
- const pathModule = await import("path");
4151
4789
 
4152
- const fullPath = pathModule.join(workspaceRoot, filePath);
4790
+ let fullPath;
4791
+ try {
4792
+ fullPath = confineToRoot(workspaceRoot, filePath);
4793
+ } catch (e) {
4794
+ if (e instanceof SandboxViolation) {
4795
+ console.warn(`[READ-FILE] SANDBOX DENIED: ${filePath} (${e.details?.resolved})`);
4796
+ return res.status(403).json({ success: false, error: "path escapes the workspace sandbox", filePath });
4797
+ }
4798
+ throw e;
4799
+ }
4153
4800
 
4154
- // Verificar se arquivo existe
4801
+ // Check whether the file exists
4155
4802
  const stats = await fs.stat(fullPath);
4156
4803
 
4157
4804
  // Limitar tamanho (100KB)
@@ -4185,12 +4832,13 @@ app.post("/workspace/read-file", async (req, res) => {
4185
4832
 
4186
4833
  /**
4187
4834
  * POST /workspace/read-files
4188
- * L mltiplos arquivos de uma vez (batch)
4835
+ * Reads multiple files at once (batch)
4189
4836
  */
4190
4837
  app.post("/workspace/read-files", async (req, res) => {
4191
- const { root, filePaths } = req.body;
4838
+ const { filePaths } = req.body;
4192
4839
 
4193
- const workspaceRoot = root || WORKSPACE_ROOT;
4840
+ // SEG-3: root only from the tenant-isolated request context.
4841
+ const workspaceRoot = resolveWorkspaceRoot(req);
4194
4842
  if (!workspaceRoot) {
4195
4843
  return res.status(400).json({
4196
4844
  error: "workspace not set"
@@ -4207,17 +4855,25 @@ app.post("/workspace/read-files", async (req, res) => {
4207
4855
 
4208
4856
  try {
4209
4857
  const fs = await import("fs/promises");
4210
- const pathModule = await import("path");
4211
4858
 
4212
4859
  const files = {};
4213
4860
  const errors = [];
4214
4861
 
4215
4862
  for (const filePath of filePaths) {
4216
4863
  try {
4217
- const fullPath = pathModule.join(workspaceRoot, filePath);
4864
+ let fullPath;
4865
+ try {
4866
+ fullPath = confineToRoot(workspaceRoot, filePath);
4867
+ } catch (e) {
4868
+ if (e instanceof SandboxViolation) {
4869
+ errors.push({ filePath, error: "path escapes the workspace sandbox" });
4870
+ continue;
4871
+ }
4872
+ throw e;
4873
+ }
4218
4874
  const stats = await fs.stat(fullPath);
4219
4875
 
4220
- // Pular arquivos muito grandes
4876
+ // Skip files that are too large
4221
4877
  if (stats.size > 100 * 1024) {
4222
4878
  errors.push({ filePath, error: "File too large" });
4223
4879
  continue;
@@ -4255,7 +4911,7 @@ app.post("/workspace/read-files", async (req, res) => {
4255
4911
 
4256
4912
  /**
4257
4913
  * GET /system/folder-picker
4258
- * Abre o file picker nativo do sistema operacional
4914
+ * Opens the native operating system file picker
4259
4915
  */
4260
4916
  app.get("/system/folder-picker", async (req, res) => {
4261
4917
  console.log(`[FOLDER-PICKER] Opening native folder picker...`);
@@ -4271,7 +4927,7 @@ app.get("/system/folder-picker", async (req, res) => {
4271
4927
  const { stdout } = await execAsync(script);
4272
4928
  selectedPath = stdout.trim();
4273
4929
  } catch (error) {
4274
- // Usurio cancelou
4930
+ // User cancelled
4275
4931
  if (error.code === 1) {
4276
4932
  return res.json({
4277
4933
  success: true,
@@ -4300,7 +4956,7 @@ app.get("/system/folder-picker", async (req, res) => {
4300
4956
  }
4301
4957
 
4302
4958
  } else if (platform === "linux") {
4303
- // Linux - tentar zenity, kdialog ou yad
4959
+ // Linux - try zenity, kdialog or yad
4304
4960
  const commands = [
4305
4961
  `zenity --file-selection --directory --title="Select your project folder"`,
4306
4962
  `kdialog --getexistingdirectory ~ --title "Select your project folder"`,
@@ -4336,7 +4992,7 @@ app.get("/system/folder-picker", async (req, res) => {
4336
4992
  });
4337
4993
  }
4338
4994
 
4339
- // Remover trailing slash se houver
4995
+ // Remove trailing slash if present
4340
4996
  if (selectedPath && selectedPath.endsWith("/")) {
4341
4997
  selectedPath = selectedPath.slice(0, -1);
4342
4998
  }
@@ -4362,7 +5018,7 @@ app.get("/system/folder-picker", async (req, res) => {
4362
5018
 
4363
5019
  /**
4364
5020
  * GET /system/info
4365
- * Retorna informaes do sistema
5021
+ * Returns system information
4366
5022
  */
4367
5023
  app.get("/system/info", (req, res) => {
4368
5024
  res.json({
@@ -4376,12 +5032,12 @@ app.get("/system/info", (req, res) => {
4376
5032
 
4377
5033
  // ============================================
4378
5034
  // API DOCS ENDPOINT
4379
- // Retorna endpoints detectados das controllers
5035
+ // Returns endpoints detected from the controllers
4380
5036
  // ============================================
4381
5037
 
4382
5038
  /**
4383
5039
  * GET /workspace/api-docs
4384
- * Analisa controllers e retorna todos os endpoints da API
5040
+ * Analyzes controllers and returns all API endpoints
4385
5041
  */
4386
5042
  app.get("/workspace/api-docs", async (req, res) => {
4387
5043
  const wsRoot = resolveWorkspaceRoot(req);
@@ -4395,7 +5051,7 @@ app.get("/workspace/api-docs", async (req, res) => {
4395
5051
  console.log(`[API-DOCS] Analyzing controllers in ${wsRoot}`);
4396
5052
 
4397
5053
  try {
4398
- // Primeiro, escanear arquivos do workspace
5054
+ // First, scan the workspace files
4399
5055
  const fs = await import("fs/promises");
4400
5056
  const pathModule = await import("path");
4401
5057
 
@@ -4433,7 +5089,7 @@ app.get("/workspace/api-docs", async (req, res) => {
4433
5089
 
4434
5090
  console.log(`[API-DOCS] Found ${files.length} Java files`);
4435
5091
 
4436
- // Agora analisar os controllers
5092
+ // Now analyze the controllers
4437
5093
  const controllerAnalyzer = new ControllerAnalyzer(wsRoot);
4438
5094
  const apiDocs = await controllerAnalyzer.generateApiDocs(files);
4439
5095
 
@@ -4464,7 +5120,7 @@ app.get("/workspace/api-docs", async (req, res) => {
4464
5120
 
4465
5121
  /**
4466
5122
  * GET /workspace/api-docs/:controller
4467
- * Retorna endpoints de uma controller especfica
5123
+ * Returns the endpoints of a specific controller
4468
5124
  */
4469
5125
  app.get("/workspace/api-docs/:controller", async (req, res) => {
4470
5126
  const wsRoot = resolveWorkspaceRoot(req);
@@ -4479,7 +5135,7 @@ app.get("/workspace/api-docs/:controller", async (req, res) => {
4479
5135
  console.log(`[API-DOCS] Getting endpoints for controller: ${controller}`);
4480
5136
 
4481
5137
  try {
4482
- // Primeiro, escanear arquivos do workspace
5138
+ // First, scan the workspace files
4483
5139
  const fs = await import("fs/promises");
4484
5140
  const pathModule = await import("path");
4485
5141
 
@@ -4514,7 +5170,7 @@ app.get("/workspace/api-docs/:controller", async (req, res) => {
4514
5170
 
4515
5171
  await scanDir(wsRoot);
4516
5172
 
4517
- // Analisar os controllers
5173
+ // Analyze the controllers
4518
5174
  const controllerAnalyzer = new ControllerAnalyzer(wsRoot);
4519
5175
  const apiDocs = await controllerAnalyzer.generateApiDocs(files);
4520
5176
 
@@ -4565,7 +5221,7 @@ app.get("/workspace/api-docs/:controller", async (req, res) => {
4565
5221
 
4566
5222
  /**
4567
5223
  * GET /ai-engine/status
4568
- * Retorna estado do AI Vibe Coding Engine
5224
+ * Returns the state of the AI Vibe Coding Engine
4569
5225
  */
4570
5226
  app.get("/ai-engine/status", (req, res) => {
4571
5227
  res.json(aiEngine ? aiEngine.getStatus() : { active: false, error: 'AI Engine not initialized' });
@@ -4600,7 +5256,7 @@ app.post("/ai-engine/clear-history", (req, res) => {
4600
5256
 
4601
5257
  /**
4602
5258
  * GET /ai-engine/errors
4603
- * Retorna histrico de erros
5259
+ * Returns the error history
4604
5260
  */
4605
5261
  app.get("/ai-engine/errors", (req, res) => {
4606
5262
  const { limit = 50 } = req.query;
@@ -4617,7 +5273,7 @@ app.get("/ai-engine/errors", (req, res) => {
4617
5273
 
4618
5274
  /**
4619
5275
  * GET /ai-engine/fixes
4620
- * Retorna histrico de fixes
5276
+ * Returns the fix history
4621
5277
  */
4622
5278
  app.get("/ai-engine/fixes", (req, res) => {
4623
5279
  if (aiEngine) {
@@ -4633,7 +5289,7 @@ app.get("/ai-engine/fixes", (req, res) => {
4633
5289
 
4634
5290
  /**
4635
5291
  * GET /workspace/smart-config
4636
- * Recolhe ficheiros de configurao para anlise AI
5292
+ * Collects configuration files for AI analysis
4637
5293
  */
4638
5294
  app.get("/workspace/smart-config", async (req, res) => {
4639
5295
  const wsRoot = resolveWorkspaceRoot(req);
@@ -4682,7 +5338,7 @@ app.get("/workspace/smart-config", async (req, res) => {
4682
5338
  }
4683
5339
  }
4684
5340
 
4685
- // Detectar profiles disponveis
5341
+ // Detect available profiles
4686
5342
  const availableProfiles = [];
4687
5343
  const resourcesDir = path.join(wsRoot, 'src/main/resources');
4688
5344
  if (fs.existsSync(resourcesDir)) {
@@ -4797,40 +5453,40 @@ app.post("/workspace/search", async (req, res) => {
4797
5453
  return res.status(400).json({ ok: false, error: "pattern is required" });
4798
5454
  }
4799
5455
 
5456
+ // ROOT-RECOVERY fail-fast: dead root with no recoverable clone - answer
5457
+ // with an actionable error instead of an empty result set the AI cannot
5458
+ // distinguish from "no matches".
5459
+ if (!fs.existsSync(wsRoot)) {
5460
+ return res.status(200).json({
5461
+ ok: false,
5462
+ error: "workspace_root_not_hydrated",
5463
+ root: wsRoot,
5464
+ hint: "This agent has no clone at that path. Run git_prepare_workspace "
5465
+ + "(POST /workspace/clone) for this workspace first, then retry."
5466
+ });
5467
+ }
5468
+
4800
5469
  console.log(`[workspace/search] pattern="${pattern}" filter="${fileFilter || '*'}"`);
4801
5470
 
4802
5471
  try {
4803
- const { execSync } = await import('child_process');
5472
+ const { execFileSync } = await import('child_process');
4804
5473
 
4805
- // Build grep command with safety limits
4806
- let cmd = `grep -rn `;
4807
-
4808
- // Apply file filter if provided
4809
- if (fileFilter && fileFilter.trim()) {
4810
- cmd += `--include="${fileFilter}" `;
4811
- }
4812
-
4813
- // Exclude common non-source directories
4814
- cmd += `--exclude-dir=node_modules --exclude-dir=.git --exclude-dir=target `;
4815
- cmd += `--exclude-dir=build --exclude-dir=dist --exclude-dir=.idea `;
4816
- cmd += `--exclude-dir=__pycache__ --exclude-dir=.next --exclude-dir=vendor `;
4817
- cmd += `--exclude-dir=bin --exclude-dir=obj --exclude-dir=.gradle `;
4818
- cmd += `--exclude-dir=.mvn --exclude-dir=.venv --exclude-dir=coverage `;
4819
-
4820
- // Escape pattern for shell safety and limit results
4821
- const safePattern = pattern.replace(/"/g, '\\"').replace(/`/g, '\\`').replace(/\$/g, '\\$');
4822
- cmd += `"${safePattern}" "${wsRoot}" | head -100`;
5474
+ // No shell: pattern and fileFilter are argv entries (H4), never interpolated.
5475
+ const grepArgs = buildGrepArgs({ pattern: String(pattern), fileFilter, root: wsRoot });
4823
5476
 
5477
+ const relativize = (line) => line.replace(wsRoot + '/', '');
5478
+ const SEARCH_TIMEOUT_MS = parseInt(process.env.WORKSPACE_SEARCH_TIMEOUT_MS || "15000", 10);
4824
5479
  let stdout = '';
4825
5480
  try {
4826
- stdout = execSync(cmd, {
5481
+ stdout = execFileSync('grep', grepArgs, {
4827
5482
  encoding: 'utf8',
4828
- timeout: 15000,
5483
+ timeout: SEARCH_TIMEOUT_MS,
4829
5484
  maxBuffer: 1024 * 1024
4830
- });
5485
+ }).split('\n').slice(0, 100).join('\n');
4831
5486
  } catch (grepErr) {
5487
+ const outcome = classifyGrepFailure(grepErr, { timeoutMs: SEARCH_TIMEOUT_MS });
4832
5488
  // grep returns exit code 1 when no matches found
4833
- if (grepErr.status === 1) {
5489
+ if (outcome.kind === 'no_match') {
4834
5490
  return res.json({
4835
5491
  ok: true,
4836
5492
  results: `No matches found for: ${pattern}`,
@@ -4838,12 +5494,18 @@ app.post("/workspace/search", async (req, res) => {
4838
5494
  });
4839
5495
  }
4840
5496
  // Exit code 2 = error in grep
4841
- if (grepErr.status === 2) {
5497
+ if (outcome.kind === 'bad_pattern') {
4842
5498
  return res.status(400).json({
4843
5499
  ok: false,
4844
- error: "Invalid search pattern: " + grepErr.message
5500
+ error: "Invalid search pattern: " + outcome.message
4845
5501
  });
4846
5502
  }
5503
+ // Timeout / output cap: a 15 s grep over a multi-repo parent used to end
5504
+ // here as HTTP 500 (QA incident b83215a8). Answer with what grep found.
5505
+ if (outcome.kind === 'partial') {
5506
+ console.warn(`[workspace/search] ${outcome.reason} pattern="${pattern}" root=${wsRoot}`);
5507
+ return res.status(200).json(partialSearchBody(pattern, outcome, relativize));
5508
+ }
4847
5509
  throw grepErr;
4848
5510
  }
4849
5511
 
@@ -4851,7 +5513,7 @@ app.post("/workspace/search", async (req, res) => {
4851
5513
  const results = stdout
4852
5514
  .split('\n')
4853
5515
  .filter(line => line.trim())
4854
- .map(line => line.replace(wsRoot + '/', ''))
5516
+ .map(relativize)
4855
5517
  .join('\n');
4856
5518
 
4857
5519
  const count = results.split('\n').filter(l => l.trim()).length;
@@ -4895,19 +5557,26 @@ app.post("/workspace/exec", async (req, res) => {
4895
5557
  return res.status(400).json({ ok: false, error: "command is required" });
4896
5558
  }
4897
5559
 
4898
- // Security: block dangerous commands
4899
- const dangerous = [
4900
- 'rm -rf /', 'rm -rf /*', 'mkfs', 'dd if=', 'shutdown', 'reboot',
4901
- 'init 0', '> /dev/', 'chmod -R 777 /', 'curl | sh', 'wget | sh',
4902
- 'curl | bash', 'wget | bash', 'format c:', 'del /f /s /q'
4903
- ];
4904
- const lowerCmd = command.toLowerCase().trim();
4905
- if (dangerous.some(d => lowerCmd.includes(d))) {
4906
- console.warn(`[workspace/exec] BLOCKED dangerous command: ${command}`);
4907
- return res.status(403).json({
4908
- ok: false,
4909
- error: "Command blocked for security reasons"
4910
- });
5560
+ // Security: ALLOWLIST (utils/exec-policy.js) instead of the old substring
5561
+ // blocklist. Outside the allowlist a command needs a PolicyGate human
5562
+ // approval token, or must be a signed platform-authored tool command.
5563
+ const execDecision = decideExec({
5564
+ command, root: wsRoot, headers: req.headers, body: req.body || {},
5565
+ vhSignature: req.vhSignature, signingCfg: SIGNING,
5566
+ }, EXEC_POLICY);
5567
+ if (!execDecision.allow) {
5568
+ console.warn(`[workspace/exec] POLICY DENIED: ${execDecision.reason}`);
5569
+ return res.status(execDecision.status || 403).json(rejectionBody(execDecision));
5570
+ }
5571
+
5572
+ // SEG-3: cwd is pinned to the workspace root below; additionally scan the
5573
+ // raw command for out-of-root escapes (`..` traversal, absolute paths into
5574
+ // other workspaces / sensitive dirs). Best-effort — full shell containment
5575
+ // needs OS namespaces — but it blocks the obvious `cat ../neighbor/x` case.
5576
+ const rawScan = scanRawCommand(wsRoot, command);
5577
+ if (!rawScan.ok) {
5578
+ console.warn(`[workspace/exec] SANDBOX DENIED: ${rawScan.reason} (${rawScan.token})`);
5579
+ return res.status(403).json({ ok: false, error: "command escapes the workspace sandbox", detail: rawScan.reason });
4911
5580
  }
4912
5581
 
4913
5582
  console.log(`[workspace/exec] Running: ${command.substring(0, 120)}...`);
@@ -4921,7 +5590,9 @@ app.post("/workspace/exec", async (req, res) => {
4921
5590
  cwd: wsRoot,
4922
5591
  timeout: 60000,
4923
5592
  maxBuffer: 5 * 1024 * 1024,
4924
- env: { ...process.env, FORCE_COLOR: '0' }
5593
+ // SECURITY (2026-10-11): never hand the agent's own credentials to
5594
+ // a model-authored command (`env`, /proc/self/environ).
5595
+ env: sanitizedChildEnv(process.env, { FORCE_COLOR: '0' })
4925
5596
  });
4926
5597
 
4927
5598
  console.log(`Command completed (stdout: ${result.stdout.length} chars)`);
@@ -4961,9 +5632,10 @@ app.post("/workspace/test-endpoint", async (req, res) => {
4961
5632
  return res.status(400).json({ error: "url is required" });
4962
5633
  }
4963
5634
 
4964
- // Security: only localhost
4965
- if (!url.startsWith('http://localhost') && !url.startsWith('http://127.0.0.1')) {
4966
- return res.status(403).json({ error: "Only localhost URLs allowed for security" });
5635
+ // Security: only loopback, parsed (not prefix-matched), never the agent's own ports.
5636
+ const urlCheck = validateLocalTestUrl(url);
5637
+ if (!urlCheck.ok) {
5638
+ return res.status(403).json({ error: `Only localhost URLs allowed for security: ${urlCheck.reason}` });
4967
5639
  }
4968
5640
 
4969
5641
  console.log(`Testing: ${method} ${url} (${testName || 'unnamed'})`);
@@ -4984,7 +5656,7 @@ app.post("/workspace/test-endpoint", async (req, res) => {
4984
5656
  fetchOptions.body = reqBody;
4985
5657
  }
4986
5658
 
4987
- const response = await fetch(url, fetchOptions);
5659
+ const response = await fetch(urlCheck.url, { ...fetchOptions, redirect: 'manual' });
4988
5660
  const durationMs = Date.now() - startTime;
4989
5661
 
4990
5662
  // Capture response
@@ -5042,6 +5714,198 @@ app.post("/workspace/test-endpoint", async (req, res) => {
5042
5714
  // Create branch, commit, push for auto-fix PRs
5043
5715
  // ============================================
5044
5716
 
5717
+ /**
5718
+ * Coordinated multi-repo branch + PR (Workbench Option B).
5719
+ *
5720
+ * wsRoot is the per-workspace PARENT dir; each immediate git sub-repo that has
5721
+ * uncommitted changes gets the SAME `branchName`, a commit, a push, and ONE PR.
5722
+ * After all PRs exist, each PR body is updated (two-pass) to cross-link the
5723
+ * sibling PRs. Returns a LIST of per-repo PR results; single-repo callers never
5724
+ * reach here (server routes only when isMultiRepoRoot()).
5725
+ *
5726
+ * PR creation currently uses the GitHub REST API (primary provider). Non-GitHub
5727
+ * sub-repos still get branch+commit+push and a "compare/new MR" URL, but no API
5728
+ * PR/cross-link (tracked as follow-up).
5729
+ */
5730
+ async function createMultiRepoFixBranch({ wsRoot, branchName, commitMessage, gitToken, files }) {
5731
+ if (!isSafeBranchName(branchName)) {
5732
+ const err = new Error("invalid branch name"); err.httpStatus = 400; throw err;
5733
+ }
5734
+ const { execSync, execFileSync } = await import('child_process');
5735
+ const gitEnv = {
5736
+ ...process.env,
5737
+ GIT_TERMINAL_PROMPT: '0',
5738
+ GIT_ASKPASS: 'echo',
5739
+ GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=5',
5740
+ };
5741
+ const repos = listRepoSubdirs(wsRoot);
5742
+ const prs = [];
5743
+ let totalFiles = 0;
5744
+
5745
+ for (const repo of repos) {
5746
+ const opts = { cwd: repo.path, encoding: 'utf8', timeout: 60000, env: gitEnv };
5747
+ const runQuiet = (cmd, o = {}) => {
5748
+ try { return execSync(cmd, { ...opts, ...o }).toString().trim(); }
5749
+ catch { return ''; }
5750
+ };
5751
+ // No shell for anything that carries caller-supplied text (branch,
5752
+ // commit message, file names): argv array via execFile.
5753
+ const git = (args, o = {}) => execFileSync('git', args, { ...opts, ...o }).toString().trim();
5754
+ const gitQuiet = (args, o = {}) => { try { return git(args, o); } catch { return ''; } };
5755
+
5756
+ // Only repos with actual changes get a PR (one-PR-per-CHANGED-repo).
5757
+ let status = '';
5758
+ try { status = execSync('git status --porcelain', opts).toString().trim(); }
5759
+ catch { status = ''; }
5760
+ if (!status) {
5761
+ prs.push({ dir: repo.dir, changed: false, prCreated: false, prUrl: '' });
5762
+ continue;
5763
+ }
5764
+ const filesChanged = status.split('\n').filter(Boolean).length;
5765
+ totalFiles += filesChanged;
5766
+
5767
+ // git identity (Cloud Run has none by default).
5768
+ runQuiet('git config user.email "ai@deepdebug.ai"');
5769
+ runQuiet('git config user.name "DeepDebug AI"');
5770
+
5771
+ // Shared branch across every repo. Reuse if it already exists in this repo.
5772
+ try { git(['checkout', '-b', branchName]); }
5773
+ catch { gitQuiet(['checkout', branchName]); }
5774
+
5775
+ // Stage + commit.
5776
+ if (Array.isArray(files) && files.length > 0) {
5777
+ for (const f of files) gitQuiet(['add', '--', String(f)]);
5778
+ } else {
5779
+ runQuiet('git add -A');
5780
+ }
5781
+ try { git(['commit', '-m', String(commitMessage)]); }
5782
+ catch (e) { /* nothing staged after all */ }
5783
+ const commitSha = runQuiet('git rev-parse HEAD');
5784
+
5785
+ // Resolve authed remote for push + API.
5786
+ const remoteRaw = runQuiet('git remote get-url origin 2>/dev/null');
5787
+ const parsed = parseAuthedRemote(remoteRaw);
5788
+ const cleanBase = stripRemoteCreds(remoteRaw);
5789
+
5790
+ // Determine PR base: repo default branch (origin/HEAD) else main.
5791
+ let targetBranch = 'main';
5792
+ const head = runQuiet('git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null');
5793
+ if (head) {
5794
+ const parts = head.split('/');
5795
+ if (parts.length) targetBranch = parts[parts.length - 1];
5796
+ }
5797
+
5798
+ // Push the shared branch (origin already carries the per-repo token from clone).
5799
+ let pushed = false;
5800
+ try {
5801
+ git(['push', '-u', 'origin', branchName], { timeout: 120000 });
5802
+ pushed = true;
5803
+ } catch (e) {
5804
+ console.warn(`[multi-repo-pr] push failed for ${repo.dir}: ${maskUrlCreds(e.message || '')}`);
5805
+ }
5806
+
5807
+ const entry = {
5808
+ dir: repo.dir,
5809
+ changed: true,
5810
+ filesChanged,
5811
+ commitSha,
5812
+ branch: branchName,
5813
+ targetBranch,
5814
+ pushed,
5815
+ repoUrl: cleanBase,
5816
+ provider: parsed ? parsed.provider : 'generic',
5817
+ prCreated: false,
5818
+ prUrl: '',
5819
+ prNumber: null,
5820
+ };
5821
+
5822
+ // Open the PR (GitHub API). Others: fall back to a compare/new-MR URL.
5823
+ if (pushed && parsed && parsed.provider === 'github' && parsed.apiToken) {
5824
+ const body = buildMultiRepoPrBody({
5825
+ dir: repo.dir, branch: branchName, commitMessage, filesChanged, commitSha,
5826
+ });
5827
+ try {
5828
+ const resp = await fetch(`https://api.github.com/repos/${parsed.ownerRepo}/pulls`, {
5829
+ method: 'POST',
5830
+ headers: {
5831
+ 'Authorization': `Bearer ${parsed.apiToken}`,
5832
+ 'Accept': 'application/vnd.github+json',
5833
+ 'Content-Type': 'application/json',
5834
+ 'X-GitHub-Api-Version': '2022-11-28',
5835
+ 'User-Agent': 'DeepDebug-AI',
5836
+ },
5837
+ body: JSON.stringify({ title: commitMessage, head: branchName, base: targetBranch, body }),
5838
+ });
5839
+ if (resp.ok) {
5840
+ const data = await resp.json();
5841
+ entry.prUrl = data.html_url;
5842
+ entry.prNumber = data.number;
5843
+ entry.prCreated = true;
5844
+ console.log(`[multi-repo-pr] GitHub PR created for ${repo.dir}: ${entry.prUrl}`);
5845
+ } else {
5846
+ const errText = (await resp.text()).substring(0, 200);
5847
+ console.warn(`[multi-repo-pr] GitHub PR failed (${resp.status}) for ${repo.dir}: ${errText}`);
5848
+ entry.prUrl = buildCompareUrl(cleanBase, 'github', branchName, targetBranch);
5849
+ }
5850
+ } catch (e) {
5851
+ console.warn(`[multi-repo-pr] GitHub PR error for ${repo.dir}: ${e.message}`);
5852
+ entry.prUrl = buildCompareUrl(cleanBase, 'github', branchName, targetBranch);
5853
+ }
5854
+ } else if (pushed) {
5855
+ entry.prUrl = buildCompareUrl(cleanBase, entry.provider, branchName, targetBranch);
5856
+ }
5857
+ prs.push(entry);
5858
+ }
5859
+
5860
+ // Second pass: cross-link sibling PRs in each real GitHub PR body.
5861
+ const realPrs = prs.filter((p) => p.prCreated && p.prNumber != null);
5862
+ if (realPrs.length > 1) {
5863
+ for (const p of realPrs) {
5864
+ const note = buildCrossLinkNote(p.dir, realPrs);
5865
+ if (!note) continue;
5866
+ const repo = repos.find((r) => r.dir === p.dir);
5867
+ const remoteRaw = execSync('git remote get-url origin 2>/dev/null',
5868
+ { cwd: repo.path, encoding: 'utf8', env: gitEnv }).toString().trim();
5869
+ const parsed = parseAuthedRemote(remoteRaw);
5870
+ if (!parsed || parsed.provider !== 'github' || !parsed.apiToken) continue;
5871
+ const baseBody = buildMultiRepoPrBody({
5872
+ dir: p.dir, branch: branchName, commitMessage,
5873
+ filesChanged: p.filesChanged, commitSha: p.commitSha,
5874
+ });
5875
+ try {
5876
+ await fetch(`https://api.github.com/repos/${parsed.ownerRepo}/pulls/${p.prNumber}`, {
5877
+ method: 'PATCH',
5878
+ headers: {
5879
+ 'Authorization': `Bearer ${parsed.apiToken}`,
5880
+ 'Accept': 'application/vnd.github+json',
5881
+ 'Content-Type': 'application/json',
5882
+ 'X-GitHub-Api-Version': '2022-11-28',
5883
+ 'User-Agent': 'DeepDebug-AI',
5884
+ },
5885
+ body: JSON.stringify({ body: baseBody + note }),
5886
+ });
5887
+ } catch (e) {
5888
+ console.warn(`[multi-repo-pr] cross-link update failed for ${p.dir}: ${e.message}`);
5889
+ }
5890
+ }
5891
+ }
5892
+
5893
+ const changedPrs = prs.filter((p) => p.changed);
5894
+ const firstUrl = (changedPrs.find((p) => p.prUrl) || {}).prUrl || '';
5895
+ return {
5896
+ ok: true,
5897
+ multiRepo: true,
5898
+ branch: branchName,
5899
+ prs,
5900
+ prUrls: changedPrs.filter((p) => p.prUrl).map((p) => p.prUrl),
5901
+ mrUrl: firstUrl,
5902
+ prCreated: changedPrs.some((p) => p.prCreated),
5903
+ filesChanged: totalFiles,
5904
+ reposChanged: changedPrs.length,
5905
+ reposTotal: repos.length,
5906
+ };
5907
+ }
5908
+
5045
5909
  /**
5046
5910
  * POST /workspace/git/create-fix-branch
5047
5911
  * Creates a branch, commits changes, and pushes to remote.
@@ -5058,9 +5922,33 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
5058
5922
  if (!branchName || !commitMessage) {
5059
5923
  return res.status(400).json({ error: "branchName and commitMessage required" });
5060
5924
  }
5925
+ // SECURITY (2026-10-11): branchName / commitMessage / files were interpolated
5926
+ // into shell strings (`git commit -m "${msg}"` only escaped quotes, so
5927
+ // backticks and $() ran). Validate the branch, run git via execFile below.
5928
+ if (!isSafeBranchName(branchName)) return res.status(400).json({ error: "invalid branch name" });
5929
+ if (files !== undefined && (!Array.isArray(files) || files.some(f => typeof f !== "string" || !f || f.startsWith("-")))) {
5930
+ return res.status(400).json({ error: "files must be an array of relative paths" });
5931
+ }
5932
+
5933
+ // MULTI-REPO (Workbench Option B): when the session root is a per-workspace
5934
+ // PARENT dir with N git sub-repos (cloud-multi-clone), open ONE PR per CHANGED
5935
+ // repo on the SAME shared branch and cross-link the siblings. Single-repo
5936
+ // roots (root itself is a git repo) fall through to the legacy path below,
5937
+ // unchanged. Gated by MULTI_REPO_PR_ENABLED (default on).
5938
+ if (multiRepoPrEnabled() && isMultiRepoRoot(wsRoot)) {
5939
+ try {
5940
+ const result = await createMultiRepoFixBranch({
5941
+ wsRoot, branchName, commitMessage, gitToken, files,
5942
+ });
5943
+ return res.json(result);
5944
+ } catch (err) {
5945
+ console.error(`[multi-repo-pr] failed: ${maskUrlCreds(err.message)}`);
5946
+ return res.status(500).json({ ok: false, error: maskUrlCreds(err.message) });
5947
+ }
5948
+ }
5061
5949
 
5062
5950
  try {
5063
- const { execSync } = await import('child_process');
5951
+ const { execSync, execFileSync } = await import('child_process');
5064
5952
  const opts = {
5065
5953
  cwd: wsRoot,
5066
5954
  encoding: 'utf8',
@@ -5069,9 +5957,11 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
5069
5957
  ...process.env,
5070
5958
  GIT_TERMINAL_PROMPT: '0', // Never prompt for credentials
5071
5959
  GIT_ASKPASS: 'echo', // Return empty if asked
5072
- GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=5'
5960
+ GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=5'
5073
5961
  }
5074
5962
  };
5963
+ // argv form (no shell) for every call that carries request data.
5964
+ const git = (args, o = {}) => execFileSync('git', args, { ...opts, ...o }).toString();
5075
5965
 
5076
5966
  // 1. Check if git repo
5077
5967
  try {
@@ -5098,12 +5988,12 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
5098
5988
  // 4. Create and checkout new branch (with unique name if exists)
5099
5989
  let finalBranchName = branchName;
5100
5990
  try {
5101
- execSync(`git checkout -b ${finalBranchName}`, opts);
5991
+ git(['checkout', '-b', finalBranchName]);
5102
5992
  } catch (e) {
5103
5993
  // Branch already exists add timestamp suffix
5104
5994
  finalBranchName = `${branchName}-${Date.now() % 100000}`;
5105
5995
  try {
5106
- execSync(`git checkout -b ${finalBranchName}`, opts);
5996
+ git(['checkout', '-b', finalBranchName]);
5107
5997
  } catch (e2) {
5108
5998
  // Restore stash and bail
5109
5999
  if (hadStash) try { execSync('git stash pop', opts); } catch {}
@@ -5124,7 +6014,7 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
5124
6014
  // 6. Stage changes
5125
6015
  if (files && files.length > 0) {
5126
6016
  for (const file of files) {
5127
- execSync(`git add "${file}"`, opts);
6017
+ git(['add', '--', file]);
5128
6018
  }
5129
6019
  } else {
5130
6020
  execSync('git add -A', opts);
@@ -5133,7 +6023,7 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
5133
6023
  // 7. Check for changes
5134
6024
  const status = execSync('git status --porcelain', opts).trim();
5135
6025
  if (!status) {
5136
- try { execSync(`git checkout ${currentBranch}`, opts); } catch {}
6026
+ try { git(['checkout', currentBranch]); } catch {}
5137
6027
  return res.json({ ok: false, error: "No changes to commit" });
5138
6028
  }
5139
6029
 
@@ -5145,8 +6035,7 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
5145
6035
  } catch (e) {
5146
6036
  console.warn('Could not set git config:', e.message);
5147
6037
  }
5148
- const safeMsg = commitMessage.replace(/"/g, '\\"').replace(/\n/g, '\\n');
5149
- execSync(`git commit -m "${safeMsg}"`, opts);
6038
+ git(['commit', '-m', String(commitMessage)]);
5150
6039
 
5151
6040
  // 9. Push (use token if available for authentication)
5152
6041
  let pushResult = '';
@@ -5164,11 +6053,11 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
5164
6053
  // No remote configured use repoUrl from integration if available
5165
6054
  if (repoUrl) {
5166
6055
  remoteUrlRaw = repoUrl;
5167
- console.log(`No git remote, using repoUrl from integration: ${repoUrl}`);
6056
+ console.log(`No git remote, using repoUrl from integration: ${maskUrlCreds(repoUrl)}`);
5168
6057
  }
5169
6058
  }
5170
6059
 
5171
- console.log(`Remote URL: ${remoteUrlRaw.replace(gitToken, '***')}`);
6060
+ console.log(`Remote URL: ${maskUrlCreds(remoteUrlRaw)}`);
5172
6061
  let authenticatedUrl = '';
5173
6062
 
5174
6063
  if (remoteUrlRaw.includes('github.com')) {
@@ -5192,7 +6081,7 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
5192
6081
  .replace(/^git@bitbucket\.org:/, '')
5193
6082
  .replace(/\.git$/, '');
5194
6083
  // FIXED: Bitbucket App Password auth token j est no formato "username:appPassword"
5195
- // x-token-auth no funciona; Basic Auth via URL o mtodo correcto
6084
+ // x-token-auth does not work; Basic Auth via URL is the correct method
5196
6085
  authenticatedUrl = `https://${gitToken}@bitbucket.org/${repoPath}.git`;
5197
6086
  } else if (remoteUrlRaw) {
5198
6087
  // Unknown provider try generic https with token
@@ -5206,42 +6095,46 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
5206
6095
  console.log(`Pushing ${finalBranchName} with token auth...`);
5207
6096
  try {
5208
6097
  // Don't use 2>&1 let execSync capture stderr separately
5209
- pushResult = execSync(`git push ${authenticatedUrl} ${finalBranchName}`, {
5210
- ...opts,
6098
+ pushResult = git(['push', authenticatedUrl, finalBranchName], {
5211
6099
  timeout: 60000, // 60s for slow connections
5212
6100
  stdio: ['pipe', 'pipe', 'pipe'] // capture stdin, stdout, stderr separately
5213
- }).toString();
6101
+ });
5214
6102
  pushed = true;
5215
6103
  console.log(`Pushed with token authentication`);
5216
6104
  } catch (innerErr) {
5217
- // Mask token in error messages before logging
5218
- const maskToken = (str) => str ? str.replace(gitToken, '***TOKEN***') : '';
5219
- const errMsg = maskToken(innerErr.stderr?.toString() || innerErr.stdout?.toString() || innerErr.message || '');
6105
+ // Mask token in error messages before logging. maskUrlCreds
6106
+ // strips URL userinfo (all occurrences, token-agnostic); the
6107
+ // literal maskToken is kept as a second layer for any bare
6108
+ // token echo outside a URL.
6109
+ const maskToken = (str) => str ? str.split(gitToken).join('***TOKEN***') : '';
6110
+ const errMsg = maskUrlCreds(maskToken(innerErr.stderr?.toString() || innerErr.stdout?.toString() || innerErr.message || ''));
5220
6111
  console.warn(`Authenticated push failed: ${errMsg}`);
5221
6112
  pushResult = errMsg;
5222
6113
  }
5223
6114
  } else {
5224
- console.warn(`Could not construct authenticated URL from: ${remoteUrlRaw}`);
6115
+ console.warn(`Could not construct authenticated URL from: ${maskUrlCreds(remoteUrlRaw)}`);
5225
6116
  }
5226
6117
  } catch (pushErr) {
5227
- const maskToken = (str) => str ? str.replace(gitToken, '***TOKEN***') : '';
5228
- console.warn(`Git auth setup failed: ${maskToken(pushErr.message)}`);
5229
- pushResult = maskToken(pushErr.message) || 'Push setup failed';
6118
+ const maskToken = (str) => str ? str.split(gitToken).join('***TOKEN***') : '';
6119
+ const safe = maskUrlCreds(maskToken(pushErr.message));
6120
+ console.warn(`Git auth setup failed: ${safe}`);
6121
+ pushResult = safe || 'Push setup failed';
5230
6122
  }
5231
6123
  }
5232
6124
 
5233
6125
  // Fallback: try push without token (uses local git credentials)
5234
6126
  if (!pushed) {
5235
6127
  try {
5236
- pushResult = execSync(`git push -u origin ${finalBranchName} 2>&1`, opts).toString();
6128
+ pushResult = git(['push', '-u', 'origin', finalBranchName], { stdio: ['pipe', 'pipe', 'pipe'] });
5237
6129
  pushed = true;
5238
6130
  } catch (pushErr) {
5239
6131
  try {
5240
- pushResult = execSync(`git push origin ${finalBranchName} 2>&1`, opts).toString();
6132
+ pushResult = git(['push', 'origin', finalBranchName], { stdio: ['pipe', 'pipe', 'pipe'] });
5241
6133
  pushed = true;
5242
6134
  } catch (pushErr2) {
5243
- console.warn(`Git push failed: ${pushErr2.message}`);
5244
- pushResult = pushErr2.message || 'Push failed no remote configured or auth required';
6135
+ const safe = maskUrlCreds(pushErr2.message);
6136
+ console.warn(`Git push failed: ${safe}`);
6137
+ pushResult = safe || 'Push failed no remote configured or auth required';
5245
6138
  }
5246
6139
  }
5247
6140
  }
@@ -5304,11 +6197,11 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
5304
6197
 
5305
6198
  // 10. Switch back to original branch and merge fix to keep changes on disk
5306
6199
  try {
5307
- execSync(`git checkout ${currentBranch}`, opts);
6200
+ git(['checkout', currentBranch]);
5308
6201
  // Merge the fix branch so the working directory has the patched files
5309
6202
  // This keeps the diff viewer working (disk has patched version)
5310
6203
  try {
5311
- execSync(`git merge ${finalBranchName} --no-edit`, opts);
6204
+ git(['merge', finalBranchName, '--no-edit']);
5312
6205
  console.log(`Merged ${finalBranchName} into ${currentBranch}`);
5313
6206
  } catch (mergeErr) {
5314
6207
  // If merge fails (conflicts), just cherry-pick the changes without committing
@@ -5320,7 +6213,7 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
5320
6213
  const changedFiles = status.split('\n').map(l => l.trim().split(/\s+/).pop()).filter(Boolean);
5321
6214
  for (const f of changedFiles) {
5322
6215
  try {
5323
- execSync(`git checkout ${finalBranchName} -- ${f}`, opts);
6216
+ git(['checkout', finalBranchName, '--', f]);
5324
6217
  } catch {}
5325
6218
  }
5326
6219
  console.log(`Cherry-picked ${changedFiles.length} file(s) from ${finalBranchName}`);
@@ -5329,17 +6222,17 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
5329
6222
  } catch {}
5330
6223
 
5331
6224
  // 11. Auto-create Pull Request if pushed and token available
5332
- // FIXED: suporta GitHub, GitLab e Bitbucket; branding Insptech AI
6225
+ // FIXED: suporta GitHub, GitLab e Bitbucket; branding VisionHammer
5333
6226
  let prUrl = '';
5334
6227
  const prBodyText =
5335
- `## Auto-fix by Insptech AI\n\n` +
6228
+ `## Auto-fix by VisionHammer\n\n` +
5336
6229
  `**Branch:** \`${finalBranchName}\`\n` +
5337
6230
  `**Files changed:** ${status.split('\n').length}\n` +
5338
6231
  `**Commit:** ${commitSha.substring(0, 8)}\n\n` +
5339
- `This pull request was automatically created by Insptech AI after detecting and fixing a production error.\n\n` +
6232
+ `This pull request was automatically created by VisionHammer after detecting and fixing a production error.\n\n` +
5340
6233
  `### Changes\n` +
5341
6234
  status.split('\n').map(l => `- \`${l.trim()}\``).join('\n') + '\n\n' +
5342
- `---\n*Generated by [Insptech AI](https://app.insptech.pt) Autonomous Debugging Platform*`;
6235
+ `---\n*Generated by [VisionHammer](https://visionhammer.io) Autonomous Debugging Platform*`;
5343
6236
 
5344
6237
  if (pushed && gitToken && remoteUrl) {
5345
6238
 
@@ -5901,7 +6794,7 @@ async function _getGitProvider() {
5901
6794
  const providerId = registry.detectProviderFromUrl(remoteUrl);
5902
6795
 
5903
6796
  if (!providerId) {
5904
- console.warn(`Unknown git provider for URL: ${remoteUrl}`);
6797
+ console.warn(`Unknown git provider for URL: ${maskUrlCreds(remoteUrl)}`);
5905
6798
  return null;
5906
6799
  }
5907
6800
 
@@ -5944,7 +6837,7 @@ async function _getGitProvider() {
5944
6837
  // MULTI-WORKSPACE ENDPOINTS
5945
6838
  // ============================================
5946
6839
 
5947
- /** Lista todos os workspaces abertos */
6840
+ /** Lists all open workspaces */
5948
6841
  app.get("/workspaces", (req, res) => {
5949
6842
  res.json({
5950
6843
  workspaces: wsManager.list(),
@@ -5978,7 +6871,7 @@ app.post("/workspace/:workspaceId/open", async (req, res) => {
5978
6871
  }
5979
6872
  });
5980
6873
 
5981
- /** Aplica patch num workspace especfico */
6874
+ /** Applies a patch to a specific workspace */
5982
6875
  app.post("/workspace/:workspaceId/patch", async (req, res) => {
5983
6876
  const { workspaceId } = req.params;
5984
6877
  const { diff } = req.body || {};
@@ -5992,7 +6885,7 @@ app.post("/workspace/:workspaceId/patch", async (req, res) => {
5992
6885
  }
5993
6886
  });
5994
6887
 
5995
- /** Compila e testa num workspace especfico */
6888
+ /** Compiles and tests a specific workspace */
5996
6889
  app.post("/workspace/:workspaceId/test", async (req, res) => {
5997
6890
  const { workspaceId } = req.params;
5998
6891
  try {
@@ -6005,13 +6898,19 @@ app.post("/workspace/:workspaceId/test", async (req, res) => {
6005
6898
  }
6006
6899
  });
6007
6900
 
6008
- /** Executa comando num workspace especfico */
6901
+ /** Runs a command in a specific workspace */
6009
6902
  app.post("/workspace/:workspaceId/run", async (req, res) => {
6010
6903
  const { workspaceId } = req.params;
6011
6904
  const { cmd, args = [] } = req.body || {};
6012
6905
  if (!cmd) return res.status(400).json({ error: "cmd is required" });
6013
6906
  try {
6014
6907
  const root = wsManager.resolveRoot(workspaceId);
6908
+ // SEG-3: reject path arguments that resolve outside this workspace root.
6909
+ const argCheck = checkArgsWithinRoot(root, [cmd, ...args]);
6910
+ if (!argCheck.ok) {
6911
+ console.warn(`[workspace/:id/run] SANDBOX DENIED arg: ${argCheck.arg}`);
6912
+ return res.status(403).json({ error: "command argument escapes the workspace sandbox", arg: argCheck.arg });
6913
+ }
6015
6914
  const out = await run(cmd, args, root, 5 * 60 * 1000);
6016
6915
  res.json({ workspaceId, ...out });
6017
6916
  } catch (e) {
@@ -6024,14 +6923,32 @@ app.post("/workspace/:workspaceId/run", async (req, res) => {
6024
6923
  // WEBSOCKET REVERSE TUNNEL
6025
6924
  // Connects Local Agent to Gateway no public URL needed
6026
6925
  // ============================================
6027
- async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6926
+ async function startWebSocketTunnel(port, gatewayUrl, authToken, tenantId) {
6028
6927
  if (!gatewayUrl || !tenantId) {
6029
6928
  console.log('WebSocket tunnel skipped: missing gatewayUrl or tenantId');
6030
6929
  return;
6031
6930
  }
6032
6931
 
6033
6932
  const wsUrl = gatewayUrl.replace(/^https:\/\//, 'wss://').replace(/^http:\/\//, 'ws://');
6034
- const fullUrl = `${wsUrl}/api/v1/agent/ws?tenantId=${encodeURIComponent(tenantId)}${apiKey ? '&apiKey=' + encodeURIComponent(apiKey) : ''}`;
6933
+ // SECURITY: the tunnel carries the machine credential and receives commands
6934
+ // the agent executes; never run it in cleartext to a remote host (MITM =
6935
+ // command injection into the customer network). Loopback ws:// stays for dev.
6936
+ try {
6937
+ const u = new URL(wsUrl);
6938
+ const loop = ['localhost', '127.0.0.1', '[::1]', '::1'].includes(u.hostname);
6939
+ if (u.protocol !== 'wss:' && !(u.protocol === 'ws:' && loop)) {
6940
+ console.error(`WebSocket tunnel refused: gateway URL must be https:// (got ${u.protocol}//${u.hostname})`);
6941
+ return;
6942
+ }
6943
+ } catch {
6944
+ console.error('WebSocket tunnel refused: invalid gateway URL');
6945
+ return;
6946
+ }
6947
+ // Credential (the per-machine authorizationToken) goes in the Authorization
6948
+ // header, NOT the URL query (query strings leak to access logs / proxies /
6949
+ // referrers — issue #207). tenantId is not a secret and stays in the query.
6950
+ const fullUrl = `${wsUrl}/api/v1/agent/ws?tenantId=${encodeURIComponent(tenantId)}`;
6951
+ const wsHeaders = authToken ? { Authorization: `Bearer ${authToken}` } : {};
6035
6952
 
6036
6953
  let reconnectDelay = 2000;
6037
6954
  let isShuttingDown = false;
@@ -6041,7 +6958,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6041
6958
  if (isShuttingDown) return;
6042
6959
  try {
6043
6960
  const { default: WebSocket } = await import('ws');
6044
- ws = new WebSocket(fullUrl);
6961
+ ws = new WebSocket(fullUrl, { headers: wsHeaders });
6045
6962
 
6046
6963
  ws.on('open', () => {
6047
6964
  console.log(`WebSocket tunnel connected to Gateway`);
@@ -6058,11 +6975,18 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6058
6975
  if (text === 'pong') return;
6059
6976
  let request;
6060
6977
  try { request = JSON.parse(text); } catch { return; }
6061
- const { requestId, command, params } = request;
6978
+ const { requestId, command } = request;
6062
6979
  if (!requestId || !command) return;
6063
6980
  console.log(`WS command: ${command}`);
6981
+ // Signed envelope (utils/request-signing.js): a verified command runs
6982
+ // with the signed paramsRaw, never the unsigned params copy.
6983
+ const envelope = verifyWsEnvelope(request, SIGNING);
6984
+ if (!envelope.ok) {
6985
+ ws.send(JSON.stringify({ requestId, command, ok: false, error: "invalid command signature", reason: envelope.reason }));
6986
+ return;
6987
+ }
6064
6988
  let result;
6065
- try { result = await handleWsCommand(command, params || {}); }
6989
+ try { result = await handleWsCommand(command, envelope.params || {}, envelope); }
6066
6990
  catch (err) { result = { error: err.message }; }
6067
6991
  ws.send(JSON.stringify({ requestId, command, ...result }));
6068
6992
  });
@@ -6088,8 +7012,18 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6088
7012
  }
6089
7013
  }
6090
7014
 
6091
- async function handleWsCommand(command, params) {
7015
+ async function handleWsCommand(command, params, sigCtx = {}) {
6092
7016
  const localBase = `http://localhost:${port}`;
7017
+ // In-process hop to the HTTP API: X-Agent-Token plus, when the envelope
7018
+ // verified, the per-process internal marker carrying the signed identity.
7019
+ const hopHeaders = () => ({
7020
+ ...internalAuthHeaders(AGENT_AUTH),
7021
+ ...(sigCtx.verified ? {
7022
+ ...internalSignatureHeaders(),
7023
+ 'X-VH-Tenant-Id': sigCtx.tenantId || '',
7024
+ 'X-VH-Workspace-Id': sigCtx.workspaceId || '',
7025
+ } : {}),
7026
+ });
6093
7027
  switch (command) {
6094
7028
  case 'health':
6095
7029
  return { status: 'ok', workspace: WORKSPACE_ROOT, openWorkspaces: wsManager ? wsManager.list().length : 0 };
@@ -6103,10 +7037,23 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6103
7037
  }
6104
7038
 
6105
7039
  case 'workspace.file-content': {
6106
- const { path: filePath } = params;
7040
+ const { path: filePath, workspacePath: wsRootParam } = params;
6107
7041
  if (!filePath) return { error: 'path is required' };
6108
- if (!WORKSPACE_ROOT) return { error: 'No workspace open' };
6109
- const absPath = path.isAbsolute(filePath) ? filePath : path.join(WORKSPACE_ROOT, filePath);
7042
+ // SEG-3: confine to the tenant root the gateway sent (params.workspacePath),
7043
+ // never to the shared mutable WORKSPACE_ROOT global. Absolute paths are
7044
+ // treated as workspace-relative and denied if they escape.
7045
+ const wsRoot = (wsRootParam && wsRootParam.trim()) ? wsRootParam.trim() : WORKSPACE_ROOT;
7046
+ if (!wsRoot) return { error: 'No workspace open' };
7047
+ let absPath;
7048
+ try {
7049
+ absPath = confineToRoot(wsRoot, filePath);
7050
+ } catch (e) {
7051
+ if (e instanceof SandboxViolation) {
7052
+ console.warn(`[ws:file-content] SANDBOX DENIED: ${filePath}`);
7053
+ return { content: null, found: false, error: 'path escapes the workspace sandbox' };
7054
+ }
7055
+ throw e;
7056
+ }
6110
7057
  try {
6111
7058
  const content = await fsPromises.readFile(absPath, 'utf-8');
6112
7059
  return { content, path: filePath, found: true };
@@ -6131,7 +7078,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6131
7078
  const method = command === 'workspace.backups' ? 'GET' : 'POST';
6132
7079
  const res = await fetch(`${localBase}${endpoint}`, {
6133
7080
  method,
6134
- headers: { 'Content-Type': 'application/json' },
7081
+ headers: { 'Content-Type': 'application/json', ...hopHeaders() },
6135
7082
  ...(method === 'POST' ? { body: JSON.stringify(params) } : {})
6136
7083
  });
6137
7084
  return await res.json();
@@ -6155,7 +7102,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6155
7102
  console.log(`WS workspace.diff ${endpoint}`);
6156
7103
  const res = await fetch(`${localBase}${endpoint}`, {
6157
7104
  method: 'GET',
6158
- headers: { 'Content-Type': 'application/json' }
7105
+ headers: { 'Content-Type': 'application/json', ...hopHeaders() }
6159
7106
  });
6160
7107
  return await res.json();
6161
7108
  }
@@ -6167,7 +7114,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6167
7114
  const mcpBase = `http://localhost:5056`;
6168
7115
  const res = await fetch(`${mcpBase}/mcp/read-file`, {
6169
7116
  method: 'POST',
6170
- headers: { 'Content-Type': 'application/json' },
7117
+ headers: { 'Content-Type': 'application/json', ...hopHeaders() },
6171
7118
  body: JSON.stringify({
6172
7119
  workspaceId: params.workspaceId || 'default',
6173
7120
  path: params.path
@@ -6180,7 +7127,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6180
7127
  const mcpBase = `http://localhost:5056`;
6181
7128
  const res = await fetch(`${mcpBase}/mcp/read-files`, {
6182
7129
  method: 'POST',
6183
- headers: { 'Content-Type': 'application/json' },
7130
+ headers: { 'Content-Type': 'application/json', ...hopHeaders() },
6184
7131
  body: JSON.stringify({
6185
7132
  workspaceId: params.workspaceId || 'default',
6186
7133
  paths: params.paths
@@ -6193,7 +7140,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6193
7140
  const mcpBase = `http://localhost:5056`;
6194
7141
  const res = await fetch(`${mcpBase}/mcp/list-directory`, {
6195
7142
  method: 'POST',
6196
- headers: { 'Content-Type': 'application/json' },
7143
+ headers: { 'Content-Type': 'application/json', ...hopHeaders() },
6197
7144
  body: JSON.stringify({
6198
7145
  workspaceId: params.workspaceId || 'default',
6199
7146
  path: params.path || '.',
@@ -6207,7 +7154,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6207
7154
  const mcpBase = `http://localhost:5056`;
6208
7155
  const res = await fetch(`${mcpBase}/mcp/search-code`, {
6209
7156
  method: 'POST',
6210
- headers: { 'Content-Type': 'application/json' },
7157
+ headers: { 'Content-Type': 'application/json', ...hopHeaders() },
6211
7158
  body: JSON.stringify({
6212
7159
  workspaceId: params.workspaceId || 'default',
6213
7160
  query: params.query,
@@ -6222,11 +7169,14 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6222
7169
  const mcpBase = `http://localhost:5056`;
6223
7170
  const res = await fetch(`${mcpBase}/mcp/execute-command`, {
6224
7171
  method: 'POST',
6225
- headers: { 'Content-Type': 'application/json' },
7172
+ headers: { 'Content-Type': 'application/json', ...hopHeaders() },
6226
7173
  body: JSON.stringify({
6227
7174
  workspaceId: params.workspaceId || 'default',
6228
7175
  command: params.command,
6229
- timeoutMs: params.timeoutMs || 120000
7176
+ timeoutMs: params.timeoutMs || 120000,
7177
+ // exec-policy inputs (allowlist exceptions) travel with the command
7178
+ ...(params.approvalToken ? { approvalToken: params.approvalToken } : {}),
7179
+ ...(params.commandOrigin ? { commandOrigin: params.commandOrigin, tool: params.tool } : {}),
6230
7180
  })
6231
7181
  });
6232
7182
  return await res.json();
@@ -6236,7 +7186,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6236
7186
  const mcpBase = `http://localhost:5056`;
6237
7187
  const res = await fetch(`${mcpBase}/mcp/project-info`, {
6238
7188
  method: 'POST',
6239
- headers: { 'Content-Type': 'application/json' },
7189
+ headers: { 'Content-Type': 'application/json', ...hopHeaders() },
6240
7190
  body: JSON.stringify({
6241
7191
  workspaceId: params.workspaceId || 'default'
6242
7192
  })
@@ -6248,7 +7198,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6248
7198
  const mcpBase = `http://localhost:5056`;
6249
7199
  const res = await fetch(`${mcpBase}/mcp/apply-patch`, {
6250
7200
  method: 'POST',
6251
- headers: { 'Content-Type': 'application/json' },
7201
+ headers: { 'Content-Type': 'application/json', ...hopHeaders() },
6252
7202
  body: JSON.stringify({
6253
7203
  workspaceId: params.workspaceId || 'default',
6254
7204
  diff: params.diff
@@ -6266,7 +7216,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6266
7216
  }
6267
7217
  const res = await fetch(`${mcpBase}/mcp/open`, {
6268
7218
  method: 'POST',
6269
- headers: { 'Content-Type': 'application/json' },
7219
+ headers: { 'Content-Type': 'application/json', ...hopHeaders() },
6270
7220
  body: JSON.stringify({
6271
7221
  workspaceId: params.workspaceId || 'default',
6272
7222
  root: params.root
@@ -6308,13 +7258,27 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6308
7258
  * POST /setup/run-command
6309
7259
  * Run any shell command on the user's machine.
6310
7260
  */
7261
+ // SECURITY: /setup/run-command, /setup/install-tool and /setup/run-auth-command
7262
+ // run arbitrary host commands outside any workspace sandbox (the denylist is
7263
+ // trivially bypassable). No platform component calls them today, so they are
7264
+ // disabled unless the operator explicitly opts in.
7265
+ function setupCommandsEnabled(res) {
7266
+ if (process.env.LOCAL_AGENT_ENABLE_SETUP_COMMANDS === 'true') return true;
7267
+ res.status(403).json({ ok: false, error: "setup commands are disabled on this agent (set LOCAL_AGENT_ENABLE_SETUP_COMMANDS=true to enable)" });
7268
+ return false;
7269
+ }
7270
+
6311
7271
  app.post("/setup/run-command", async (req, res) => {
7272
+ if (!setupCommandsEnabled(res)) return;
6312
7273
  const { command, description, workingDir } = req.body || {};
6313
7274
  if (!command) return res.status(400).json({ ok: false, error: "command is required" });
6314
7275
 
6315
- const dangerous = ['rm -rf /', 'mkfs', 'dd if=', 'shutdown', 'reboot', 'format c:', 'del /f /s /q'];
6316
- if (dangerous.some(d => command.toLowerCase().includes(d))) {
6317
- return res.status(403).json({ ok: false, error: "Command blocked for safety" });
7276
+ const setupDecision = decideExec({
7277
+ command, root: workingDir || WORKSPACE_ROOT || process.cwd(), headers: req.headers, body: req.body || {},
7278
+ vhSignature: req.vhSignature, signingCfg: SIGNING,
7279
+ }, EXEC_POLICY);
7280
+ if (!setupDecision.allow) {
7281
+ return res.status(setupDecision.status || 403).json(rejectionBody(setupDecision));
6318
7282
  }
6319
7283
 
6320
7284
  console.log(`[SetupAgent] Running: ${command.substring(0, 100)}`);
@@ -6326,7 +7290,7 @@ app.post("/setup/run-command", async (req, res) => {
6326
7290
  const cwd = workingDir || WORKSPACE_ROOT || process.env.HOME || process.cwd();
6327
7291
  const result = await execAsync(command, {
6328
7292
  cwd, timeout: 60000, maxBuffer: 2 * 1024 * 1024,
6329
- env: { ...process.env, FORCE_COLOR: '0' }, shell: true
7293
+ env: sanitizedChildEnv(process.env, { FORCE_COLOR: '0' }), shell: true
6330
7294
  });
6331
7295
  res.json({ ok: true, stdout: result.stdout, stderr: result.stderr, exitCode: 0, command });
6332
7296
  } catch (err) {
@@ -6362,6 +7326,8 @@ app.post("/setup/check-ports", async (req, res) => {
6362
7326
  app.get("/setup/check-tool", async (req, res) => {
6363
7327
  const { name } = req.query;
6364
7328
  if (!name) return res.status(400).json({ error: "name is required" });
7329
+ // SECURITY: name is interpolated into a shell command.
7330
+ if (!isSafeToolName(name)) return res.status(400).json({ error: "invalid tool name" });
6365
7331
 
6366
7332
  const { exec: execCb } = await import('child_process');
6367
7333
  const { promisify } = await import('util');
@@ -6384,8 +7350,10 @@ app.get("/setup/check-tool", async (req, res) => {
6384
7350
  * Auto-install a CLI tool using the available package manager.
6385
7351
  */
6386
7352
  app.post("/setup/install-tool", async (req, res) => {
7353
+ if (!setupCommandsEnabled(res)) return;
6387
7354
  const { tool } = req.body || {};
6388
7355
  if (!tool) return res.status(400).json({ error: "tool is required" });
7356
+ if (!isSafeToolName(tool)) return res.status(400).json({ error: "invalid tool name" });
6389
7357
 
6390
7358
  const { exec: execCb } = await import('child_process');
6391
7359
  const { promisify } = await import('util');
@@ -6410,16 +7378,23 @@ app.post("/setup/install-tool", async (req, res) => {
6410
7378
  * Run an auth command (gcloud, az login, gh auth) opening browser on user's machine.
6411
7379
  */
6412
7380
  app.post("/setup/run-auth-command", async (req, res) => {
7381
+ if (!setupCommandsEnabled(res)) return;
6413
7382
  const { command, session_id } = req.body || {};
6414
7383
  if (!command) return res.status(400).json({ error: "command is required" });
7384
+ // H4: allowlisted interactive logins only, spawned without a shell.
7385
+ const parsed = parseAuthCommand(command);
7386
+ if (!parsed.ok) return res.status(400).json({ ok: false, error: parsed.reason });
6415
7387
 
6416
- const { exec: execCb } = await import('child_process');
6417
- const bgCmd = process.platform === 'win32' ? `start cmd /c "${command}"` : `${command} &`;
6418
- console.log(`[SetupAgent] Starting auth: ${command.substring(0, 80)}`);
6419
-
6420
- execCb(bgCmd, { shell: true }, (err) => {
6421
- if (err) console.warn(`Auth command warning: ${err.message}`);
6422
- });
7388
+ console.log(`[SetupAgent] Starting auth: ${parsed.argv.slice(0, 3).join(" ")}`);
7389
+ try {
7390
+ const child = spawn(parsed.argv[0], parsed.argv.slice(1), {
7391
+ detached: true, stdio: "ignore", env: sanitizedChildEnv(process.env),
7392
+ });
7393
+ child.on("error", (err) => console.warn(`Auth command warning: ${err.message}`));
7394
+ child.unref();
7395
+ } catch (err) {
7396
+ console.warn(`Auth command warning: ${err.message}`);
7397
+ }
6423
7398
 
6424
7399
  res.json({ ok: true, status: "started", session_id, message: "Auth command started, browser should open" });
6425
7400
  });
@@ -6899,6 +7874,14 @@ app.delete("/workspace/vibe/tunnel/:sessionId", async (req, res) => {
6899
7874
  // ============================================
6900
7875
  registerVercelRoutes(app);
6901
7876
 
7877
+ // ============================================
7878
+ // OBSERVABILITY PROVISIONING ROUTES
7879
+ // (zero-observability detection -> deploy the client observability bundle).
7880
+ // Registered after createAuthMiddleware, so they need the agent token like every
7881
+ // other route; provisioning runs docker compose on the host.
7882
+ // ============================================
7883
+ registerObservabilityRoutes(app);
7884
+
6902
7885
  // ============================================
6903
7886
  // LIVE PREVIEW — iOS / Android / Web
6904
7887
  // ============================================
@@ -7083,8 +8066,27 @@ function buildPhoneFrame(body, platform) {
7083
8066
  // START SERVER
7084
8067
 
7085
8068
  // ============================================
7086
- app.listen(PORT, '0.0.0.0', async () => {
7087
- console.log(`\n DeepDebug Local Agent listening on port ${PORT}`);
8069
+ // ============================================
8070
+ // PROCESS SAFETY NET (2026-07-20)
8071
+ // A multi-tenant agent must NEVER die because one handler leaked a promise
8072
+ // rejection (Node 20 default = kill the process). The 2026-07-20 QA
8073
+ // crash-loop was exactly that: an unhandled scandir ENOENT in
8074
+ // /workspace/files killed the single instance on every indexer retry,
8075
+ // 503-ing every tenant. Log loudly, keep serving. Per-handler try/catch
8076
+ // remains the first line of defense - this is the last one.
8077
+ // ============================================
8078
+ process.on("unhandledRejection", (reason) => {
8079
+ const msg = reason && reason.stack ? reason.stack : String(reason);
8080
+ console.error(`[SAFETY-NET] Unhandled promise rejection (process kept alive): ${msg}`);
8081
+ });
8082
+ process.on("uncaughtException", (err) => {
8083
+ const msg = err && err.stack ? err.stack : String(err);
8084
+ console.error(`[SAFETY-NET] Uncaught exception (process kept alive): ${msg}`);
8085
+ });
8086
+
8087
+ const BIND_HOST = resolveBindHost(process.env, AGENT_AUTH);
8088
+ app.listen(PORT, BIND_HOST, async () => {
8089
+ console.log(`\n DeepDebug Local Agent listening on ${BIND_HOST}:${PORT}`);
7088
8090
  console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
7089
8091
  console.log(`Process Manager initialized`);
7090
8092
  console.log(`Backup system ready (max: ${MAX_BACKUPS} backups)`);
@@ -7103,6 +8105,13 @@ app.listen(PORT, '0.0.0.0', async () => {
7103
8105
 
7104
8106
  const gwUrl = cfg.gatewayUrl || process.env.GATEWAY_URL;
7105
8107
  const apiKey = cfg.apiKey || process.env.DEEPDEBUG_API_KEY;
8108
+ // The WS tunnel credential is the per-machine authorizationToken minted by
8109
+ // /api/v1/agent/authorize (install.js saves it to config.json and passes it
8110
+ // to the container as AUTH_TOKEN). The gateway authenticates the handshake
8111
+ // with findByAuthorizationToken — NOT the user apiKey. Sending apiKey (the
8112
+ // old behaviour) was rejected 100% of the time (0 connects / 1000+ rejections
8113
+ // in QA). Fall back to apiKey only if no authorizationToken is present.
8114
+ const wsToken = cfg.authorizationToken || process.env.AUTH_TOKEN || apiKey;
7106
8115
  let tenantId = cfg.tenantId || process.env.TENANT_ID;
7107
8116
  if (!tenantId && apiKey) {
7108
8117
  try {
@@ -7112,8 +8121,32 @@ app.listen(PORT, '0.0.0.0', async () => {
7112
8121
  }
7113
8122
 
7114
8123
  if (process.env.DISABLE_WS_TUNNEL !== 'true') {
7115
- startWebSocketTunnel(PORT, gwUrl, apiKey, tenantId);
8124
+ startWebSocketTunnel(PORT, gwUrl, wsToken, tenantId);
7116
8125
  }
8126
+
8127
+ // Sibling plane (started here so the frozen monolith is untouched): metrics
8128
+ // on 5055, the adapter->incident forwarder configured with gateway creds +
8129
+ // metrics, and the capability preflight (logs mode, serves /capabilities).
8130
+ (async () => {
8131
+ try {
8132
+ const [{ startMetrics }, { incidentForwarder }, { startCapabilityPreflight }] = await Promise.all([
8133
+ import('./siblings/metrics.js'),
8134
+ import('./siblings/incident-forwarder.js'),
8135
+ import('./siblings/capabilities.js'),
8136
+ ]);
8137
+ const metrics = startMetrics(app);
8138
+ incidentForwarder.configure({ gatewayUrl: gwUrl, apiKey, tenantId, metrics });
8139
+ // One-shot contract preflight (must not block/prevent startup).
8140
+ incidentForwarder.contractPreflight().catch(() => {});
8141
+ // authToken = the agent API key: unlocks the full inventory on
8142
+ // /capabilities. Unauthenticated callers get health-only.
8143
+ await startCapabilityPreflight(app, {
8144
+ metrics, workspaceRoot: WORKSPACE_ROOT, gatewayUrl: gwUrl, authToken: apiKey,
8145
+ });
8146
+ } catch (err) {
8147
+ console.warn(`[siblings] startup failed: ${err.message}`);
8148
+ }
8149
+ })();
7117
8150
  });
7118
8151
 
7119
8152
  // Start MCP HTTP Bridge em paralelo (port 5056)