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.
- package/.claude/context.md +76 -0
- package/.claude/skills/README.md +25 -0
- package/.claude/skills/tdd-debugging/SKILL.md +53 -0
- package/.claude/skills/tdd-debugging/references/anti-patterns.md +43 -0
- package/.claude/skills/tdd-debugging/references/command-reference.md +46 -0
- package/.claude/standards/README.md +51 -0
- package/.claude/standards/clean-code.md +75 -0
- package/.claude/standards/code-review-checklist.md +61 -0
- package/.claude/standards/layered-architecture.md +163 -0
- package/.claude/standards/node.md +45 -0
- package/.claude/standards/security-owasp.md +114 -0
- package/.claude/standards/simplicity.md +42 -0
- package/.claude/standards/solid.md +64 -0
- package/.claude/standards/testing.md +59 -0
- package/.cursor/rules/standards.mdc +17 -0
- package/.github/workflows/build-byoc-image.yml +315 -0
- package/.github/workflows/deploy-gcp.yml +102 -0
- package/.github/workflows/npm-publish.yml +69 -8
- package/AGENTS.md +21 -0
- package/CHANGELOG.md +69 -0
- package/CLAUDE.md +13 -0
- package/CLAUDE_CODE_AGENT_INFINITE.md +5 -3
- package/Dockerfile +4 -0
- package/Dockerfile.full +55 -3
- package/bin/install.js +16 -5
- package/cloudbuild-full.yaml +57 -0
- package/docker-compose.yml +5 -3
- package/docs/OBSERVABILITY.md +164 -0
- package/docs/byoc-agent-image.md +70 -0
- package/docs/graphify-packaging-plan.md +133 -0
- package/docs/graphify-validation-run-2026-10-28.md +243 -0
- package/docs/releasing.md +43 -0
- package/docs/signed-commands.md +163 -0
- package/env.example +30 -0
- package/helm/Chart.yaml +2 -2
- package/helm/templates/deployment.yaml +13 -3
- package/helm/templates/networkpolicy.yaml +22 -0
- package/helm/templates/secret.yaml +1 -0
- package/helm/values.yaml +13 -14
- package/mcp-server.js +4 -1
- package/package.json +9 -4
- package/scripts/upgrade-observability-push.sh +227 -0
- package/src/ai-engine.js +22 -22
- package/src/config/trusted-signing-keys.json +3 -0
- package/src/detectors/build-tool-detector.js +50 -0
- package/src/detectors/multi-project-detector.js +22 -22
- package/src/detectors/service-detector.js +24 -24
- package/src/exec-utils.js +26 -4
- package/src/git/base-git-provider.js +117 -117
- package/src/git/bitbucket-provider.js +2 -2
- package/src/git/git-provider-registry.js +18 -18
- package/src/git/github-provider.js +13 -13
- package/src/git/multi-repo-pr.js +161 -0
- package/src/mcp-http-server.js +32 -16
- package/src/observability/bundle-files.js +418 -0
- package/src/observability/detect.js +151 -0
- package/src/observability/provision.js +212 -0
- package/src/observability/render-cli.js +59 -0
- package/src/observability/routes.js +88 -0
- package/src/patch/patch-engine.js +28 -28
- package/src/patch-manager.js +85 -85
- package/src/patch_bkp.js +14 -14
- package/src/ports.js +4 -4
- package/src/routes/workspace.route.js +47 -16
- package/src/runtimes/base-runtime.js +75 -75
- package/src/runtimes/java/gradle-runtime.js +17 -17
- package/src/runtimes/java/java-integrations.js +25 -25
- package/src/runtimes/java/maven-runtime.js +34 -34
- package/src/runtimes/node/node-integrations.js +1 -1
- package/src/runtimes/node/npm-runtime.js +14 -14
- package/src/runtimes/node/yarn-runtime.js +4 -4
- package/src/runtimes/runtime-registry.js +25 -25
- package/src/server-local.js +4 -4
- package/src/server.js +1366 -333
- package/src/siblings/burst-suppressor.js +89 -0
- package/src/siblings/capabilities.js +273 -0
- package/src/siblings/env-compat.cjs +58 -0
- package/src/siblings/incident-forwarder.js +320 -0
- package/src/siblings/logger.js +15 -0
- package/src/siblings/metrics.js +94 -0
- package/src/utils/agent-auth.js +184 -0
- package/src/utils/approval-token.js +56 -0
- package/src/utils/bind-host.js +29 -0
- package/src/utils/branch-refspec.js +98 -0
- package/src/utils/child-env.js +34 -0
- package/src/utils/environment-diagnostics.js +55 -55
- package/src/utils/exec-policy.js +962 -0
- package/src/utils/exec-utils.js +15 -15
- package/src/utils/file-count.js +46 -0
- package/src/utils/fs-utils.js +10 -10
- package/src/utils/multi-repo-open.js +132 -0
- package/src/utils/open-gate.js +183 -0
- package/src/utils/ranged-read.js +65 -0
- package/src/utils/request-guards.js +77 -0
- package/src/utils/request-signing.js +342 -0
- package/src/utils/root-recovery.js +244 -0
- package/src/utils/safe-exec-args.js +58 -0
- package/src/utils/sandbox.js +182 -0
- package/src/utils/search-outcome.js +56 -0
- package/src/utils/workspace-root.js +80 -0
- package/src/validators/syntax-validator.js +149 -0
- package/src/workspace/detect-port.js +16 -16
- package/src/workspace/file-reader.js +1 -1
- package/src/workspace/process-manager.js +46 -46
- package/src/workspace-manager.js +86 -24
- package/test/adapters/registry.test.js +9 -2
- package/test/agent-auth.test.js +194 -0
- package/test/bind-host.test.js +26 -0
- package/test/branch-refspec.test.js +224 -0
- package/test/burst-suppressor.test.js +60 -0
- package/test/capabilities-access.test.js +47 -0
- package/test/capabilities.test.js +87 -0
- package/test/child-env.test.js +23 -0
- package/test/env-compat.test.js +71 -0
- package/test/exec-policy.test.js +463 -0
- package/test/file-count.test.js +64 -0
- package/test/incident-forwarder.test.js +231 -0
- package/test/multi-repo-open.test.js +180 -0
- package/test/multi-repo-pr.test.js +129 -0
- package/test/observability-detect.test.js +199 -0
- package/test/open-gate.test.js +341 -0
- package/test/ranged-read.test.js +60 -0
- package/test/request-guards.test.js +91 -0
- package/test/request-signing.test.js +284 -0
- package/test/root-recovery-sidecar.test.js +171 -0
- package/test/root-recovery.test.js +274 -0
- package/test/safe-exec-args.test.js +58 -0
- package/test/sandbox.test.js +98 -0
- package/test/search-outcome.test.js +50 -0
- package/test/workspace-root.test.js +64 -0
- package/tools/graphify-validation/README.md +92 -0
- package/tools/graphify-validation/config/questions.json +31 -0
- package/tools/graphify-validation/config/repos.json +9 -0
- package/tools/graphify-validation/config/symbols.json +6 -0
- package/tools/graphify-validation/fixtures/spring/expected.json +34 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AppConfig.java +29 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AuditLogger.java +15 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/Cache.java +7 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/CheckoutService.java +25 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/InMemoryCache.java +18 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/OrderController.java +22 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaymentGateway.java +5 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaypalGateway.java +11 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/RefundService.java +14 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/StripeGateway.java +11 -0
- package/tools/graphify-validation/lib/affected_vs_rg.py +81 -0
- package/tools/graphify-validation/lib/audit_hook/sitecustomize.py +22 -0
- package/tools/graphify-validation/lib/graph_metrics.py +96 -0
- package/tools/graphify-validation/lib/gv_common.py +130 -0
- package/tools/graphify-validation/lib/licenses.py +47 -0
- package/tools/graphify-validation/lib/pip_audit_summary.py +18 -0
- package/tools/graphify-validation/lib/recall.py +115 -0
- package/tools/graphify-validation/lib/report.py +226 -0
- package/tools/graphify-validation/lib/run_all.py +237 -0
- package/tools/graphify-validation/lib/run_graphify.py +144 -0
- package/tools/graphify-validation/lib/rusage_helper.py +17 -0
- package/tools/graphify-validation/lib/sample_edges.py +246 -0
- package/tools/graphify-validation/lib/spring_fixture.py +81 -0
- package/tools/graphify-validation/requirements-py311.lock +508 -0
- package/tools/graphify-validation/requirements-tools.in +1 -0
- package/tools/graphify-validation/requirements-tools.lock +371 -0
- package/tools/graphify-validation/requirements.in +1 -0
- package/tools/graphify-validation/requirements.lock +502 -0
- package/tools/graphify-validation/run.sh +43 -0
- package/tools/graphify-validation/tests/test_harness.py +151 -0
- package/cloudbuild-agent-qa.yaml +0 -43
- package/cloudbuild-agent-staging.yaml +0 -40
- 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
|
-
//
|
|
35
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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:
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
//
|
|
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
|
-
|
|
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 ${
|
|
871
|
-
await execAsync(`git reset --hard origin/${
|
|
872
|
-
console.log(`[cloud] Updated to latest ${
|
|
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
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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
|
-
|
|
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
|
|
1012
|
-
const { stdout } = await
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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 {
|
|
1681
|
+
const { execFileSync } = await import("child_process");
|
|
1203
1682
|
const tarPath = `/tmp/source-${workspaceId}-${Date.now()}.tar.gz`;
|
|
1204
1683
|
try {
|
|
1205
|
-
|
|
1206
|
-
|
|
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
|
-
|
|
1379
|
-
|
|
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 {
|
|
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(
|
|
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(
|
|
1878
|
+
execSync(["remote", "set-url", "origin", authUrl], { ...execOpts, cwd: wsPath });
|
|
1396
1879
|
}
|
|
1397
|
-
execSync("
|
|
1398
|
-
cloneBranch = execSync("
|
|
1399
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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(
|
|
1436
|
-
cloneBranch = execSync("
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
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
|
-
/**
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
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 =
|
|
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
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
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 {
|
|
2880
|
+
const { execFileSync } = await import("child_process");
|
|
2273
2881
|
const execOpts = { encoding: "utf8", timeout: 120000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } };
|
|
2274
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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("
|
|
2342
|
-
|
|
2343
|
-
|
|
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(
|
|
2979
|
+
execGit(["remote", "set-url", "origin", authUrl]);
|
|
2360
2980
|
}
|
|
2361
2981
|
|
|
2362
2982
|
console.log("[git-prepare] Fetching origin...");
|
|
2363
|
-
execGit("
|
|
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(
|
|
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("
|
|
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("
|
|
2378
|
-
const prevCommit = (execGit("
|
|
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(
|
|
2383
|
-
if (!checkout.ok) checkout = execGit(
|
|
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(
|
|
2390
|
-
execGit(
|
|
3009
|
+
execGit(["reset", "--hard", `origin/${mainBranch}`]);
|
|
3010
|
+
execGit(["clean", "-fd", "--exclude=*.env", "--exclude=.env*"]);
|
|
2391
3011
|
|
|
2392
|
-
const newCommit = (execGit("
|
|
2393
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
//
|
|
4665
|
+
// Scan and read files for AI training
|
|
4032
4666
|
// ============================================
|
|
4033
4667
|
|
|
4034
4668
|
/**
|
|
4035
4669
|
* POST /workspace/scan-files
|
|
4036
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
*
|
|
4765
|
+
* Reads the content of a specific file
|
|
4129
4766
|
*/
|
|
4130
4767
|
app.post("/workspace/read-file", async (req, res) => {
|
|
4131
|
-
const {
|
|
4768
|
+
const { filePath } = req.body;
|
|
4132
4769
|
|
|
4133
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
*
|
|
4835
|
+
* Reads multiple files at once (batch)
|
|
4189
4836
|
*/
|
|
4190
4837
|
app.post("/workspace/read-files", async (req, res) => {
|
|
4191
|
-
const {
|
|
4838
|
+
const { filePaths } = req.body;
|
|
4192
4839
|
|
|
4193
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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 -
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
//
|
|
5035
|
+
// Returns endpoints detected from the controllers
|
|
4380
5036
|
// ============================================
|
|
4381
5037
|
|
|
4382
5038
|
/**
|
|
4383
5039
|
* GET /workspace/api-docs
|
|
4384
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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 {
|
|
5472
|
+
const { execFileSync } = await import('child_process');
|
|
4804
5473
|
|
|
4805
|
-
//
|
|
4806
|
-
|
|
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 =
|
|
5481
|
+
stdout = execFileSync('grep', grepArgs, {
|
|
4827
5482
|
encoding: 'utf8',
|
|
4828
|
-
timeout:
|
|
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 (
|
|
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 (
|
|
5497
|
+
if (outcome.kind === 'bad_pattern') {
|
|
4842
5498
|
return res.status(400).json({
|
|
4843
5499
|
ok: false,
|
|
4844
|
-
error: "Invalid search pattern: " +
|
|
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(
|
|
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:
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
|
|
4902
|
-
|
|
4903
|
-
|
|
4904
|
-
|
|
4905
|
-
if (
|
|
4906
|
-
console.warn(`[workspace/exec]
|
|
4907
|
-
return res.status(403).json(
|
|
4908
|
-
|
|
4909
|
-
|
|
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
|
-
|
|
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
|
|
4965
|
-
|
|
4966
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
})
|
|
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
|
-
|
|
5219
|
-
|
|
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.
|
|
5228
|
-
|
|
5229
|
-
|
|
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 =
|
|
6128
|
+
pushResult = git(['push', '-u', 'origin', finalBranchName], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
5237
6129
|
pushed = true;
|
|
5238
6130
|
} catch (pushErr) {
|
|
5239
6131
|
try {
|
|
5240
|
-
pushResult =
|
|
6132
|
+
pushResult = git(['push', 'origin', finalBranchName], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
5241
6133
|
pushed = true;
|
|
5242
6134
|
} catch (pushErr2) {
|
|
5243
|
-
|
|
5244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
6225
|
+
// FIXED: suporta GitHub, GitLab e Bitbucket; branding VisionHammer
|
|
5333
6226
|
let prUrl = '';
|
|
5334
6227
|
const prBodyText =
|
|
5335
|
-
`## Auto-fix by
|
|
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
|
|
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 [
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
6109
|
-
|
|
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
|
|
6316
|
-
|
|
6317
|
-
|
|
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:
|
|
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
|
-
|
|
6417
|
-
|
|
6418
|
-
|
|
6419
|
-
|
|
6420
|
-
|
|
6421
|
-
|
|
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
|
-
|
|
7087
|
-
|
|
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,
|
|
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)
|