deepdebug-local-agent 1.0.22 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/README.md +81 -0
- 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/legacy-connectors.md +268 -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 +12 -4
- package/scripts/sync-fixtures.mjs +246 -0
- package/scripts/upgrade-observability-push.sh +227 -0
- package/src/adapters/as400/as400-parser.js +12 -0
- package/src/adapters/as400/index.js +134 -0
- package/src/adapters/cobol/cobol-parser.js +37 -0
- package/src/adapters/cobol/index.js +151 -0
- package/src/adapters/ibmmq/ibmmq-parser.js +23 -0
- package/src/adapters/ibmmq/index.js +137 -0
- package/src/adapters/index.js +79 -0
- package/src/adapters/oracle-forms/index.js +145 -0
- package/src/adapters/oracle-forms/oracle-forms-parser.js +11 -0
- package/src/adapters/sap/index.js +146 -0
- package/src/adapters/sap/sap-parser.js +41 -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 +1412 -335
- 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/as400/as400.test.js +121 -0
- package/test/adapters/cobol/cobol.test.js +217 -0
- package/test/adapters/ibmmq/ibmmq.test.js +122 -0
- package/test/adapters/oracle-forms/oracle-forms.test.js +180 -0
- package/test/adapters/registry.test.js +108 -0
- package/test/adapters/sap/sap.test.js +142 -0
- 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/local-agent-source.tar.gz +0 -0
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,13 +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";
|
|
58
|
+
import { loadAdapters, stopAll } from "./adapters/index.js";
|
|
28
59
|
|
|
29
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
|
+
}
|
|
30
88
|
|
|
31
89
|
// ============================================
|
|
32
90
|
// AI VIBE CODING ENGINE
|
|
33
|
-
//
|
|
34
|
-
//
|
|
91
|
+
// Universal auto-healing system that uses AI
|
|
92
|
+
// to resolve ANY error automatically
|
|
35
93
|
// ============================================
|
|
36
94
|
|
|
37
95
|
class AIVibeCodingEngine extends EventEmitter {
|
|
@@ -95,7 +153,7 @@ class AIVibeCodingEngine extends EventEmitter {
|
|
|
95
153
|
/datasource.*failed/i
|
|
96
154
|
];
|
|
97
155
|
|
|
98
|
-
//
|
|
156
|
+
// Exclude false positives (normal lines that contain error words)
|
|
99
157
|
const falsePositives = [
|
|
100
158
|
/hikaripool.*start/i, // "HikariPool-1 - Starting..." normal
|
|
101
159
|
/hikaripool.*completed/i, // "HikariPool-1 - Start completed" normal
|
|
@@ -105,7 +163,7 @@ class AIVibeCodingEngine extends EventEmitter {
|
|
|
105
163
|
/tomcat started/i // "Tomcat started on port" sucesso!
|
|
106
164
|
];
|
|
107
165
|
|
|
108
|
-
//
|
|
166
|
+
// If it matches a false positive, it is not an error
|
|
109
167
|
if (falsePositives.some(p => p.test(message))) {
|
|
110
168
|
return false;
|
|
111
169
|
}
|
|
@@ -292,7 +350,7 @@ class AIVibeCodingEngine extends EventEmitter {
|
|
|
292
350
|
clearTimeout(timeout);
|
|
293
351
|
};
|
|
294
352
|
|
|
295
|
-
//
|
|
353
|
+
// Detect Spring Boot success in the logs
|
|
296
354
|
const isStartupSuccess = (message) => {
|
|
297
355
|
if (!message) return false;
|
|
298
356
|
return /started.*in.*seconds/i.test(message) ||
|
|
@@ -304,7 +362,7 @@ class AIVibeCodingEngine extends EventEmitter {
|
|
|
304
362
|
if (serviceId === 'test-local' && message) {
|
|
305
363
|
logs.push(message);
|
|
306
364
|
|
|
307
|
-
//
|
|
365
|
+
// Detect success in the Spring Boot logs
|
|
308
366
|
if (isStartupSuccess(message) && !resolved) {
|
|
309
367
|
console.log(`[AI-Engine] Detected startup success: ${message.substring(0, 80)}...`);
|
|
310
368
|
resolved = true;
|
|
@@ -541,64 +599,46 @@ class AIVibeCodingEngine extends EventEmitter {
|
|
|
541
599
|
// Instncia global do AI Engine
|
|
542
600
|
let aiEngine = null;
|
|
543
601
|
|
|
602
|
+
// Phase 2 — Legacy connector adapters (lifecycle owned by SIGTERM/SIGINT handlers below).
|
|
603
|
+
// Populated at boot from LEGACY_STACKS env var; empty when env var unset (zero-regression).
|
|
604
|
+
let _legacyAdapters = [];
|
|
605
|
+
|
|
544
606
|
const app = express();
|
|
545
607
|
|
|
546
608
|
// FIXED: Support Cloud Run PORT environment variable (GCP uses PORT)
|
|
547
609
|
const PORT = process.env.PORT || process.env.LOCAL_AGENT_PORT || 5055;
|
|
548
610
|
|
|
549
|
-
//
|
|
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();
|
|
550
617
|
app.use(cors({
|
|
551
|
-
origin:
|
|
552
|
-
// Allow requests with no origin (curl, mobile apps, server-to-server)
|
|
553
|
-
if (!origin) return callback(null, true);
|
|
554
|
-
|
|
555
|
-
const allowed = [
|
|
556
|
-
// Local development
|
|
557
|
-
"http://localhost:3010",
|
|
558
|
-
"http://localhost:3000",
|
|
559
|
-
"http://localhost:8085",
|
|
560
|
-
"http://127.0.0.1:3010",
|
|
561
|
-
"http://127.0.0.1:3000",
|
|
562
|
-
"http://127.0.0.1:8085",
|
|
563
|
-
];
|
|
564
|
-
|
|
565
|
-
const allowedPatterns = [
|
|
566
|
-
/https:\/\/.*\.run\.app$/, // Cloud Run
|
|
567
|
-
/https:\/\/.*\.web\.app$/, // Firebase hosting
|
|
568
|
-
/https:\/\/.*\.lovable\.app$/, // Lovable preview
|
|
569
|
-
/https:\/\/.*\.lovableproject\.com$/, // Lovable project domains
|
|
570
|
-
/https:\/\/.*\.netlify\.app$/, // Netlify
|
|
571
|
-
/https:\/\/.*\.vercel\.app$/, // Vercel
|
|
572
|
-
/https:\/\/deepdebug\.ai$/, // Production
|
|
573
|
-
/https:\/\/.*\.deepdebug\.ai$/, // Production subdomains
|
|
574
|
-
];
|
|
575
|
-
|
|
576
|
-
if (allowed.includes(origin) || allowedPatterns.some(p => p.test(origin))) {
|
|
577
|
-
return callback(null, true);
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
// In development/QA, log and allow unknown origins instead of blocking
|
|
581
|
-
const env = process.env.NODE_ENV || 'development';
|
|
582
|
-
if (env !== 'production') {
|
|
583
|
-
console.warn(`CORS: allowing unknown origin in non-prod: ${origin}`);
|
|
584
|
-
return callback(null, true);
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
console.warn(`CORS: blocked origin: ${origin}`);
|
|
588
|
-
return callback(new Error(`CORS: origin ${origin} not allowed`));
|
|
589
|
-
},
|
|
618
|
+
origin: corsOriginCallback(AGENT_ORIGINS),
|
|
590
619
|
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
591
|
-
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"],
|
|
592
621
|
credentials: true
|
|
593
622
|
}));
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
//
|
|
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
|
|
602
642
|
const DEFAULT_WORKSPACE = process.env.WORKSPACE_ROOT
|
|
603
643
|
|| process.env.DEFAULT_WORKSPACE
|
|
604
644
|
|| '/Users/macintosh/IdeaProjects/pure-core-ms';
|
|
@@ -612,6 +652,24 @@ let DETECTED_SERVICES = [];
|
|
|
612
652
|
const processManager = new ProcessManager();
|
|
613
653
|
const wsManager = new WorkspaceManager();
|
|
614
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
|
+
|
|
615
673
|
function resolveWorkspaceRoot(req) {
|
|
616
674
|
// TENANT ISOLATION: stateless, never mutates global state.
|
|
617
675
|
// Returns the workspace path for THIS specific request only.
|
|
@@ -627,7 +685,46 @@ function resolveWorkspaceRoot(req) {
|
|
|
627
685
|
// 1. Tenant-isolated NFS path from Gateway
|
|
628
686
|
const secureRoot = req.headers['x-workspace-root'];
|
|
629
687
|
if (secureRoot && secureRoot.trim()) {
|
|
630
|
-
|
|
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;
|
|
631
728
|
}
|
|
632
729
|
|
|
633
730
|
// 2. WorkspaceId (dev/local mode only)
|
|
@@ -644,6 +741,42 @@ const MCP_PORT = process.env.MCP_PORT || 5056;
|
|
|
644
741
|
// Inicializar AI Vibe Coding Engine
|
|
645
742
|
aiEngine = new AIVibeCodingEngine(processManager, () => WORKSPACE_ROOT);
|
|
646
743
|
|
|
744
|
+
// Phase 2 — bootstrap legacy connectors from LEGACY_STACKS env var.
|
|
745
|
+
// Adapters are loaded UNSTARTED by the registry (AD-1); we start them inline so that
|
|
746
|
+
// the existing local-agent flow is byte-for-byte unchanged when LEGACY_STACKS is unset.
|
|
747
|
+
// Errors during start surface via the adapter's own 'error' event; the loop never throws.
|
|
748
|
+
try {
|
|
749
|
+
const stacks = (process.env.LEGACY_STACKS || '')
|
|
750
|
+
.split(',')
|
|
751
|
+
.map((s) => s.trim())
|
|
752
|
+
.filter(Boolean);
|
|
753
|
+
if (stacks.length > 0) {
|
|
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`));
|
|
766
|
+
for (const a of _legacyAdapters) {
|
|
767
|
+
// Start failures are surfaced via the adapter's 'error' event; do not crash boot.
|
|
768
|
+
Promise.resolve()
|
|
769
|
+
.then(() => a.start())
|
|
770
|
+
.catch((err) => {
|
|
771
|
+
process.stderr.write(`legacy-connectors: ${a.constructor.name}.start() failed: ${err.message}\n`);
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
} catch (err) {
|
|
776
|
+
// Bad LEGACY_STACKS = programmer/operator error; emit to stderr without crashing boot.
|
|
777
|
+
process.stderr.write(`legacy-connectors: ${err.message}\n`);
|
|
778
|
+
}
|
|
779
|
+
|
|
647
780
|
// ============================================
|
|
648
781
|
// BACKUP STORAGE (Sprint 1.3)
|
|
649
782
|
// In-memory backup storage with configurable max size
|
|
@@ -722,6 +855,24 @@ function loadBackupIndex() {
|
|
|
722
855
|
// Load backups on startup
|
|
723
856
|
loadBackupIndex();
|
|
724
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
|
+
|
|
725
876
|
// Event listeners do ProcessManager
|
|
726
877
|
processManager.on("started", ({ serviceId }) => {
|
|
727
878
|
console.log(`Service ${serviceId} started successfully`);
|
|
@@ -784,14 +935,246 @@ app.get("/health", (req, res) => {
|
|
|
784
935
|
});
|
|
785
936
|
|
|
786
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
|
+
|
|
787
1008
|
app.post("/workspace/open", async (req, res) => {
|
|
788
|
-
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
|
+
}
|
|
789
1172
|
|
|
790
1173
|
// ==========================================
|
|
791
1174
|
// CLOUD MODE: Git clone (when Gateway sends repoUrl)
|
|
792
1175
|
// ==========================================
|
|
793
1176
|
if (repoUrl) {
|
|
794
|
-
console.log(`[/workspace/open] CLOUD MODE cloning ${repoUrl} (branch: ${branch})`);
|
|
1177
|
+
console.log(`[/workspace/open] CLOUD MODE cloning ${maskUrlCreds(repoUrl)} (branch: ${branch})`);
|
|
795
1178
|
|
|
796
1179
|
try {
|
|
797
1180
|
// Extract repo name: https://github.com/org/repo org_repo
|
|
@@ -808,6 +1191,32 @@ app.post("/workspace/open", async (req, res) => {
|
|
|
808
1191
|
// Ensure cache directory exists
|
|
809
1192
|
await fsPromises.mkdir(cacheDir, { recursive: true });
|
|
810
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
|
+
|
|
811
1220
|
// Build authenticated URL
|
|
812
1221
|
// Token formats:
|
|
813
1222
|
// GitHub: TOKEN (uses x-access-token:TOKEN@)
|
|
@@ -836,16 +1245,57 @@ app.post("/workspace/open", async (req, res) => {
|
|
|
836
1245
|
// Update remote URL with fresh token
|
|
837
1246
|
await execAsync(`git remote set-url origin "${authUrl}"`, { cwd: clonePath }).catch(() => {});
|
|
838
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
|
+
}
|
|
839
1272
|
// Checkout correct branch and reset to remote (discard previous patches)
|
|
840
|
-
await execAsync(`git checkout ${
|
|
841
|
-
await execAsync(`git reset --hard origin/${
|
|
842
|
-
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}`);
|
|
843
1276
|
} else {
|
|
844
|
-
console.log(`[cloud] Cloning ${repoUrl} (branch: ${branch})...`);
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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
|
+
}
|
|
849
1299
|
// Configure git user for future commits
|
|
850
1300
|
await execAsync(`git config user.email "deepdebug-ai@deepdebug.ai"`, { cwd: clonePath });
|
|
851
1301
|
await execAsync(`git config user.name "DeepDebug AI"`, { cwd: clonePath });
|
|
@@ -858,30 +1308,46 @@ app.post("/workspace/open", async (req, res) => {
|
|
|
858
1308
|
WORKSPACE_ROOT = clonePath;
|
|
859
1309
|
}
|
|
860
1310
|
const wsId = workspaceId || "default";
|
|
861
|
-
try { await wsManager.open(wsId, clonePath); } catch (err) {
|
|
1311
|
+
try { await wsManager.open(wsId, clonePath, { tenantId: openTenantId }); } catch (err) {
|
|
862
1312
|
console.warn(`WorkspaceManager.open failed (non-fatal): ${err.message}`);
|
|
863
1313
|
}
|
|
864
1314
|
|
|
865
1315
|
const meta = await detectProject(clonePath);
|
|
866
1316
|
const port = await detectPort(clonePath);
|
|
867
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
|
+
|
|
868
1326
|
return res.json({
|
|
869
1327
|
ok: true,
|
|
870
1328
|
root: clonePath,
|
|
871
1329
|
workspaceId: wsId,
|
|
872
1330
|
mode: "cloud-clone",
|
|
873
1331
|
meta,
|
|
874
|
-
port
|
|
1332
|
+
port,
|
|
1333
|
+
fileCount,
|
|
1334
|
+
...(fileCount === 0 ? { empty: true } : {})
|
|
875
1335
|
});
|
|
876
1336
|
|
|
877
1337
|
} catch (gitErr) {
|
|
878
|
-
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
|
+
}
|
|
879
1344
|
const hint = gitErr.message.includes('Authentication') || gitErr.message.includes('could not read')
|
|
880
1345
|
? "Authentication failed. Check token and repo URL."
|
|
881
1346
|
: gitErr.message.includes('not found') || gitErr.message.includes('does not exist')
|
|
882
1347
|
? "Repository not found. Check the URL."
|
|
883
1348
|
: "Clone failed. Ensure the URL is accessible.";
|
|
884
|
-
|
|
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 });
|
|
885
1351
|
}
|
|
886
1352
|
}
|
|
887
1353
|
|
|
@@ -900,7 +1366,7 @@ app.post("/workspace/open", async (req, res) => {
|
|
|
900
1366
|
WORKSPACE_ROOT = abs;
|
|
901
1367
|
}
|
|
902
1368
|
try {
|
|
903
|
-
await wsManager.open(wsId, abs);
|
|
1369
|
+
await wsManager.open(wsId, abs, { tenantId: openTenantId });
|
|
904
1370
|
} catch (err) {
|
|
905
1371
|
console.warn(`WorkspaceManager open failed (non-fatal): ${err.message}`);
|
|
906
1372
|
}
|
|
@@ -931,8 +1397,20 @@ app.post("/workspace/clone", async (req, res) => {
|
|
|
931
1397
|
|
|
932
1398
|
if (!gitUrl) return res.status(400).json({ ok: false, error: "gitUrl is required" });
|
|
933
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
|
+
|
|
934
1409
|
// Strip ALL trailing .git suffixes (handles accidental double .git.git from frontend)
|
|
935
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
|
+
}
|
|
936
1414
|
|
|
937
1415
|
const NFS_MOUNT = '/mnt/workspaces';
|
|
938
1416
|
let absTarget;
|
|
@@ -962,7 +1440,7 @@ app.post("/workspace/clone", async (req, res) => {
|
|
|
962
1440
|
authenticatedUrl = cleanGitUrl.replace('https://', `https://x-access-token:${gitToken}@`);
|
|
963
1441
|
}
|
|
964
1442
|
}
|
|
965
|
-
console.log(`Clone request: ${cleanGitUrl} -> ${absTarget}`);
|
|
1443
|
+
console.log(`Clone request: ${maskUrlCreds(cleanGitUrl)} -> ${absTarget}`);
|
|
966
1444
|
|
|
967
1445
|
try {
|
|
968
1446
|
// Ensure parent directory exists
|
|
@@ -978,18 +1456,18 @@ app.post("/workspace/clone", async (req, res) => {
|
|
|
978
1456
|
console.log(`Repo already exists at ${absTarget}, updating branch: ${targetBranch}...`);
|
|
979
1457
|
await execAsync('git fetch --all', { cwd: absTarget, timeout: 60000 });
|
|
980
1458
|
try {
|
|
981
|
-
await
|
|
982
|
-
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 });
|
|
983
1461
|
console.log(`git pull: ${stdout.trim()}`);
|
|
984
1462
|
} catch (checkoutErr) {
|
|
985
1463
|
console.warn(`Checkout ${targetBranch} failed, staying on current branch: ${checkoutErr.message}`);
|
|
986
1464
|
}
|
|
987
1465
|
} else {
|
|
988
1466
|
const safeLog = authenticatedUrl.replace(/x-token-auth:[^@]+@/g, 'x-token-auth:***@').replace(/oauth2:[^@]+@/g, 'oauth2:***@').replace(/x-access-token:[^@]+@/g, 'x-access-token:***@');
|
|
989
|
-
const
|
|
1467
|
+
const cloneArgs = branch ? ['clone', '-b', branch, '--', authenticatedUrl, absTarget] : ['clone', '--', authenticatedUrl, absTarget];
|
|
990
1468
|
console.log(`Running: git clone${branch ? ' -b ' + branch : ''} "${safeLog}" "${absTarget}"`);
|
|
991
1469
|
const cloneEnvWorkspace = { ...process.env, GIT_TERMINAL_PROMPT: "0", HOME: process.env.HOME || "/tmp" };
|
|
992
|
-
await
|
|
1470
|
+
await execFileAsync('git', cloneArgs, { timeout: 300000, env: cloneEnvWorkspace });
|
|
993
1471
|
console.log(`Clone complete: ${absTarget}`);
|
|
994
1472
|
}
|
|
995
1473
|
|
|
@@ -999,7 +1477,9 @@ app.post("/workspace/clone", async (req, res) => {
|
|
|
999
1477
|
WORKSPACE_ROOT = absTarget;
|
|
1000
1478
|
}
|
|
1001
1479
|
try {
|
|
1002
|
-
|
|
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 });
|
|
1003
1483
|
} catch (err) {
|
|
1004
1484
|
console.warn(`WorkspaceManager.open failed (non-fatal): ${err.message}`);
|
|
1005
1485
|
}
|
|
@@ -1017,17 +1497,17 @@ app.post("/workspace/clone", async (req, res) => {
|
|
|
1017
1497
|
});
|
|
1018
1498
|
|
|
1019
1499
|
} catch (err) {
|
|
1020
|
-
console.error(`Clone failed: ${err.message}`);
|
|
1500
|
+
console.error(`Clone failed: ${maskUrlCreds(err.message)}`);
|
|
1021
1501
|
const hint = err.message.includes('Authentication') || err.message.includes('could not read')
|
|
1022
1502
|
? "Authentication failed. Ensure the repo is public or GitHub integration is configured."
|
|
1023
1503
|
: err.message.includes('not found') || err.message.includes('does not exist')
|
|
1024
1504
|
? "Repository not found. Check the URL is correct."
|
|
1025
1505
|
: "Clone failed. Ensure git is installed and the URL is accessible.";
|
|
1026
|
-
res.status(500).json({ ok: false, error: err.message, hint });
|
|
1506
|
+
res.status(500).json({ ok: false, error: maskUrlCreds(err.message), hint });
|
|
1027
1507
|
}
|
|
1028
1508
|
});
|
|
1029
1509
|
|
|
1030
|
-
/**
|
|
1510
|
+
/** Workspace info */
|
|
1031
1511
|
app.get("/workspace/info", async (req, res) => {
|
|
1032
1512
|
const wsRoot = resolveWorkspaceRoot(req);
|
|
1033
1513
|
if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
|
|
@@ -1036,7 +1516,7 @@ app.get("/workspace/info", async (req, res) => {
|
|
|
1036
1516
|
res.json({ root: wsRoot, meta, port });
|
|
1037
1517
|
});
|
|
1038
1518
|
|
|
1039
|
-
/**
|
|
1519
|
+
/** Full workspace scan */
|
|
1040
1520
|
app.get("/workspace/scan", async (req, res) => {
|
|
1041
1521
|
const wsRoot = resolveWorkspaceRoot(req);
|
|
1042
1522
|
if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
|
|
@@ -1082,17 +1562,42 @@ app.get("/workspace/analyze", async (req, res) => {
|
|
|
1082
1562
|
}
|
|
1083
1563
|
});
|
|
1084
1564
|
|
|
1085
|
-
/**
|
|
1565
|
+
/** Reads the content of a specific file */
|
|
1086
1566
|
app.get("/workspace/file-content", async (req, res) => {
|
|
1087
1567
|
const wsRoot = resolveWorkspaceRoot(req);
|
|
1088
1568
|
if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
|
|
1089
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
|
+
|
|
1090
1582
|
const { path: relativePath } = req.query;
|
|
1091
1583
|
if (!relativePath) return res.status(400).json({ error: "path query param required" });
|
|
1092
1584
|
|
|
1093
1585
|
try {
|
|
1094
1586
|
const reader = new FileReader(wsRoot);
|
|
1095
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
|
+
|
|
1096
1601
|
res.json(file);
|
|
1097
1602
|
} catch (err) {
|
|
1098
1603
|
res.status(404).json({ error: "file not found", details: err.message });
|
|
@@ -1122,6 +1627,10 @@ app.post("/workspace/build-push", async (req, res) => {
|
|
|
1122
1627
|
};
|
|
1123
1628
|
|
|
1124
1629
|
try {
|
|
1630
|
+
if (!isSafeTenantId(workspaceId)) {
|
|
1631
|
+
emit("error", { error: "invalid workspaceId" });
|
|
1632
|
+
return res.end();
|
|
1633
|
+
}
|
|
1125
1634
|
const nfsBase = process.env.WORKSPACES_MOUNT || "/mnt/workspaces";
|
|
1126
1635
|
const wsPath = path.join(nfsBase, workspaceId);
|
|
1127
1636
|
|
|
@@ -1169,15 +1678,11 @@ app.post("/workspace/build-push", async (req, res) => {
|
|
|
1169
1678
|
|
|
1170
1679
|
// Step 2: Compress workspace to tar.gz
|
|
1171
1680
|
emit("progress", "Compressing source code...");
|
|
1172
|
-
const {
|
|
1681
|
+
const { execFileSync } = await import("child_process");
|
|
1173
1682
|
const tarPath = `/tmp/source-${workspaceId}-${Date.now()}.tar.gz`;
|
|
1174
1683
|
try {
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
`--exclude="target" --exclude="*.class" --exclude=".gradle" ` +
|
|
1178
|
-
`-C "${wsPath}" . 2>&1`,
|
|
1179
|
-
{ stdio: "pipe", timeout: 120000 }
|
|
1180
|
-
);
|
|
1684
|
+
// No shell: workspaceId is validated above and paths are argv entries.
|
|
1685
|
+
execFileSync("tar", buildTarArgs({ tarPath, wsPath }), { stdio: "pipe", timeout: 120000 });
|
|
1181
1686
|
const sizeKB = Math.round(fs.statSync(tarPath).size / 1024);
|
|
1182
1687
|
emit("progress", `Source compressed: ${sizeKB}KB`);
|
|
1183
1688
|
} catch (e) {
|
|
@@ -1308,6 +1813,12 @@ app.post("/workspace/setup", async (req, res) => {
|
|
|
1308
1813
|
if (!workspaceId || !repoUrl) {
|
|
1309
1814
|
return res.status(400).json({ error: "workspaceId and repoUrl required" });
|
|
1310
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 });
|
|
1311
1822
|
|
|
1312
1823
|
res.setHeader("Content-Type", "text/event-stream");
|
|
1313
1824
|
res.setHeader("Cache-Control", "no-cache");
|
|
@@ -1345,30 +1856,33 @@ app.post("/workspace/setup", async (req, res) => {
|
|
|
1345
1856
|
authUrl = urlObj.toString();
|
|
1346
1857
|
emit("progress", `Using authenticated clone for ${urlObj.hostname} (auth=${authUser})`);
|
|
1347
1858
|
} catch {
|
|
1348
|
-
|
|
1349
|
-
|
|
1859
|
+
emit("error", "repoUrl must be an https URL when a gitToken is supplied");
|
|
1860
|
+
return res.end();
|
|
1350
1861
|
}
|
|
1351
1862
|
}
|
|
1352
1863
|
|
|
1353
|
-
const {
|
|
1864
|
+
const { execFileSync } = await import("child_process");
|
|
1354
1865
|
const cloneEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0", HOME: process.env.HOME || "/tmp" };
|
|
1355
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);
|
|
1356
1869
|
let cloneBranch = "main";
|
|
1357
1870
|
|
|
1358
1871
|
// Configure safe directory
|
|
1359
|
-
try { execSync(
|
|
1872
|
+
try { execSync(["config", "--global", "--add", "safe.directory", wsPath]); } catch {}
|
|
1360
1873
|
|
|
1361
1874
|
if (fs.existsSync(path.join(wsPath, ".git"))) {
|
|
1362
1875
|
emit("progress", "Workspace exists, updating...");
|
|
1363
1876
|
try {
|
|
1364
1877
|
if (gitToken && gitToken.length > 5) {
|
|
1365
|
-
execSync(
|
|
1878
|
+
execSync(["remote", "set-url", "origin", authUrl], { ...execOpts, cwd: wsPath });
|
|
1366
1879
|
}
|
|
1367
|
-
execSync("
|
|
1368
|
-
cloneBranch = execSync("
|
|
1369
|
-
|
|
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 });
|
|
1370
1884
|
} catch (e) {
|
|
1371
|
-
const safeMsg =
|
|
1885
|
+
const safeMsg = maskUrlCreds(e.message);
|
|
1372
1886
|
emit("progress", `Update failed (${safeMsg.substring(0, 100)}), re-cloning...`);
|
|
1373
1887
|
fs.rmSync(wsPath, { recursive: true, force: true });
|
|
1374
1888
|
}
|
|
@@ -1384,13 +1898,16 @@ app.post("/workspace/setup", async (req, res) => {
|
|
|
1384
1898
|
for (const b of ["main", "master", "develop"]) {
|
|
1385
1899
|
try {
|
|
1386
1900
|
emit("progress", `Trying branch: ${b}...`);
|
|
1387
|
-
execSync(
|
|
1901
|
+
execSync(["clone", "--depth=1", "--branch", b, "--", authUrl, wsPath]);
|
|
1388
1902
|
cloneBranch = b;
|
|
1389
1903
|
emit("progress", `Cloned on branch: ${b}`);
|
|
1390
1904
|
break;
|
|
1391
1905
|
} catch (e) {
|
|
1392
1906
|
const stderr = (e.stderr || e.stdout || e.message || "").toString();
|
|
1393
|
-
|
|
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);
|
|
1394
1911
|
console.warn(`[Clone] Branch ${b} failed: ${safeMsg.substring(0, 500)}`);
|
|
1395
1912
|
emit("progress", `Branch ${b} failed: ${safeMsg.substring(0, 200)}`);
|
|
1396
1913
|
// Clean up partial clone
|
|
@@ -1402,12 +1919,15 @@ app.post("/workspace/setup", async (req, res) => {
|
|
|
1402
1919
|
if (!fs.existsSync(path.join(wsPath, ".git"))) {
|
|
1403
1920
|
try {
|
|
1404
1921
|
emit("progress", "Trying default branch...");
|
|
1405
|
-
execSync(
|
|
1406
|
-
cloneBranch = execSync("
|
|
1922
|
+
execSync(["clone", "--depth=1", "--", authUrl, wsPath]);
|
|
1923
|
+
cloneBranch = execSync(["branch", "--show-current"], { ...execOpts, cwd: wsPath }).trim() || "main";
|
|
1407
1924
|
emit("progress", `Cloned on default branch: ${cloneBranch}`);
|
|
1408
1925
|
} catch (e) {
|
|
1409
1926
|
const stderr = (e.stderr || e.stdout || e.message || "").toString();
|
|
1410
|
-
|
|
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);
|
|
1411
1931
|
console.error(`[Clone] Default branch failed: ${safeMsg.substring(0, 500)}`);
|
|
1412
1932
|
emit("progress", `Default branch failed: ${safeMsg.substring(0, 200)}`);
|
|
1413
1933
|
if (fs.existsSync(wsPath)) fs.rmSync(wsPath, { recursive: true, force: true });
|
|
@@ -1491,7 +2011,7 @@ app.post("/workspace/detect", async (req, res) => {
|
|
|
1491
2011
|
}
|
|
1492
2012
|
});
|
|
1493
2013
|
|
|
1494
|
-
/**
|
|
2014
|
+
/** Writes/saves file content to the workspace */
|
|
1495
2015
|
app.post("/workspace/write-file", async (req, res) => {
|
|
1496
2016
|
const workspaceRoot = resolveWorkspaceRoot(req);
|
|
1497
2017
|
if (!workspaceRoot) return res.status(400).json({ error: "workspace not set" });
|
|
@@ -1501,11 +2021,17 @@ app.post("/workspace/write-file", async (req, res) => {
|
|
|
1501
2021
|
if (fileContent === undefined || fileContent === null) return res.status(400).json({ error: "content is required" });
|
|
1502
2022
|
|
|
1503
2023
|
const pathMod = await import('path');
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
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;
|
|
1509
2035
|
}
|
|
1510
2036
|
|
|
1511
2037
|
try {
|
|
@@ -1520,7 +2046,7 @@ app.post("/workspace/write-file", async (req, res) => {
|
|
|
1520
2046
|
|
|
1521
2047
|
|
|
1522
2048
|
|
|
1523
|
-
/**
|
|
2049
|
+
/** Reads multiple files */
|
|
1524
2050
|
app.post("/workspace/batch-read", async (req, res) => {
|
|
1525
2051
|
const wsRoot = resolveWorkspaceRoot(req);
|
|
1526
2052
|
if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
|
|
@@ -1559,7 +2085,7 @@ app.get("/workspace/file-exists", async (req, res) => {
|
|
|
1559
2085
|
}
|
|
1560
2086
|
|
|
1561
2087
|
try {
|
|
1562
|
-
const fullPath =
|
|
2088
|
+
const fullPath = confineToRoot(wsRoot, relativePath); // SEG-3
|
|
1563
2089
|
const fileExists = await exists(fullPath);
|
|
1564
2090
|
|
|
1565
2091
|
console.log(`[file-exists] ${relativePath} -> ${fileExists ? 'EXISTS' : 'NOT FOUND'}`);
|
|
@@ -1572,6 +2098,9 @@ app.get("/workspace/file-exists", async (req, res) => {
|
|
|
1572
2098
|
});
|
|
1573
2099
|
} catch (err) {
|
|
1574
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
|
+
}
|
|
1575
2104
|
res.status(500).json({ ok: false, error: err.message });
|
|
1576
2105
|
}
|
|
1577
2106
|
});
|
|
@@ -1594,7 +2123,7 @@ app.post("/workspace/validate-paths", async (req, res) => {
|
|
|
1594
2123
|
try {
|
|
1595
2124
|
const results = await Promise.all(
|
|
1596
2125
|
pathList.map(async (relativePath) => {
|
|
1597
|
-
const fullPath =
|
|
2126
|
+
const fullPath = confineToRoot(wsRoot, relativePath); // SEG-3
|
|
1598
2127
|
const fileExists = await exists(fullPath);
|
|
1599
2128
|
return { path: relativePath, exists: fileExists };
|
|
1600
2129
|
})
|
|
@@ -1618,6 +2147,50 @@ app.post("/workspace/validate-paths", async (req, res) => {
|
|
|
1618
2147
|
}
|
|
1619
2148
|
});
|
|
1620
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
|
+
|
|
1621
2194
|
/**
|
|
1622
2195
|
* POST /workspace/search-file
|
|
1623
2196
|
* Searches for a file by name in the workspace
|
|
@@ -1742,7 +2315,7 @@ app.post("/workspace/search-by-content", async (req, res) => {
|
|
|
1742
2315
|
if (results.length >= maxResults * 2) break;
|
|
1743
2316
|
|
|
1744
2317
|
try {
|
|
1745
|
-
const fullPath =
|
|
2318
|
+
const fullPath = confineToRoot(wsRoot, filePath); // SEG-3
|
|
1746
2319
|
const content = await readFile(fullPath, 'utf8');
|
|
1747
2320
|
const lines = content.split('\n');
|
|
1748
2321
|
|
|
@@ -1838,7 +2411,7 @@ app.post("/workspace/find-field-definition", async (req, res) => {
|
|
|
1838
2411
|
|
|
1839
2412
|
for (const filePath of targetFiles) {
|
|
1840
2413
|
try {
|
|
1841
|
-
const fullPath =
|
|
2414
|
+
const fullPath = confineToRoot(wsRoot, filePath); // SEG-3
|
|
1842
2415
|
const content = await readFile(fullPath, 'utf8');
|
|
1843
2416
|
const lines = content.split('\n');
|
|
1844
2417
|
|
|
@@ -1910,7 +2483,7 @@ app.post("/workspace/find-field-definition", async (req, res) => {
|
|
|
1910
2483
|
// RUNTIME MANAGEMENT ENDPOINTS
|
|
1911
2484
|
// ============================================
|
|
1912
2485
|
|
|
1913
|
-
/**
|
|
2486
|
+
/** Detects services in the workspace */
|
|
1914
2487
|
app.get("/workspace/services/detect", async (req, res) => {
|
|
1915
2488
|
const wsRoot = resolveWorkspaceRoot(req);
|
|
1916
2489
|
if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
|
|
@@ -1947,12 +2520,12 @@ app.get("/workspace/services/detect", async (req, res) => {
|
|
|
1947
2520
|
}
|
|
1948
2521
|
});
|
|
1949
2522
|
|
|
1950
|
-
/**
|
|
2523
|
+
/** Lists all services */
|
|
1951
2524
|
app.get("/workspace/services", (req, res) => {
|
|
1952
2525
|
const wsRoot = resolveWorkspaceRoot(req);
|
|
1953
2526
|
if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
|
|
1954
2527
|
|
|
1955
|
-
//
|
|
2528
|
+
// Update the service statuses with info from the ProcessManager
|
|
1956
2529
|
const servicesWithStatus = DETECTED_SERVICES.map(service => {
|
|
1957
2530
|
const status = processManager.getStatus(service.id);
|
|
1958
2531
|
return {
|
|
@@ -1964,7 +2537,7 @@ app.get("/workspace/services", (req, res) => {
|
|
|
1964
2537
|
res.json({ services: servicesWithStatus });
|
|
1965
2538
|
});
|
|
1966
2539
|
|
|
1967
|
-
/**
|
|
2540
|
+
/** Starts a service */
|
|
1968
2541
|
app.post("/workspace/services/:serviceId/start", async (req, res) => {
|
|
1969
2542
|
const wsRoot = resolveWorkspaceRoot(req);
|
|
1970
2543
|
if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
|
|
@@ -1991,7 +2564,7 @@ app.post("/workspace/services/:serviceId/start", async (req, res) => {
|
|
|
1991
2564
|
}
|
|
1992
2565
|
});
|
|
1993
2566
|
|
|
1994
|
-
/**
|
|
2567
|
+
/** Stops a service */
|
|
1995
2568
|
app.post("/workspace/services/:serviceId/stop", async (req, res) => {
|
|
1996
2569
|
const wsRoot = resolveWorkspaceRoot(req);
|
|
1997
2570
|
if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
|
|
@@ -2006,7 +2579,7 @@ app.post("/workspace/services/:serviceId/stop", async (req, res) => {
|
|
|
2006
2579
|
}
|
|
2007
2580
|
});
|
|
2008
2581
|
|
|
2009
|
-
/**
|
|
2582
|
+
/** Returns the status of a service */
|
|
2010
2583
|
app.get("/workspace/services/:serviceId/status", (req, res) => {
|
|
2011
2584
|
const wsRoot = resolveWorkspaceRoot(req);
|
|
2012
2585
|
if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
|
|
@@ -2017,7 +2590,7 @@ app.get("/workspace/services/:serviceId/status", (req, res) => {
|
|
|
2017
2590
|
res.json({ serviceId, ...status });
|
|
2018
2591
|
});
|
|
2019
2592
|
|
|
2020
|
-
/**
|
|
2593
|
+
/** Returns the logs of a service */
|
|
2021
2594
|
app.get("/workspace/services/:serviceId/logs", (req, res) => {
|
|
2022
2595
|
const wsRoot = resolveWorkspaceRoot(req);
|
|
2023
2596
|
if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
|
|
@@ -2045,7 +2618,7 @@ app.get("/workspace/services/:serviceId/logs/stream", (req, res) => {
|
|
|
2045
2618
|
res.setHeader("Cache-Control", "no-cache");
|
|
2046
2619
|
res.setHeader("Connection", "keep-alive");
|
|
2047
2620
|
|
|
2048
|
-
//
|
|
2621
|
+
// Send existing logs
|
|
2049
2622
|
const existingLogs = processManager.getLogs(serviceId);
|
|
2050
2623
|
existingLogs.forEach(log => {
|
|
2051
2624
|
res.write(`data: ${JSON.stringify(log)}\n\n`);
|
|
@@ -2060,7 +2633,7 @@ app.get("/workspace/services/:serviceId/logs/stream", (req, res) => {
|
|
|
2060
2633
|
|
|
2061
2634
|
processManager.on("log", logHandler);
|
|
2062
2635
|
|
|
2063
|
-
// Cleanup
|
|
2636
|
+
// Cleanup when the connection closes
|
|
2064
2637
|
req.on("close", () => {
|
|
2065
2638
|
processManager.off("log", logHandler);
|
|
2066
2639
|
});
|
|
@@ -2073,9 +2646,28 @@ app.get("/workspace/services/:serviceId/logs/stream", (req, res) => {
|
|
|
2073
2646
|
app.get("/workspace/files", async (req, res) => {
|
|
2074
2647
|
const wsRoot = resolveWorkspaceRoot(req);
|
|
2075
2648
|
if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
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
|
+
}
|
|
2079
2671
|
});
|
|
2080
2672
|
|
|
2081
2673
|
app.get("/workspace/file", async (req, res) => {
|
|
@@ -2097,7 +2689,7 @@ app.post("/workspace/write", async (req, res) => {
|
|
|
2097
2689
|
const { path: rel, content } = req.body || {};
|
|
2098
2690
|
if (!rel) return res.status(400).json({ error: "path is required" });
|
|
2099
2691
|
try {
|
|
2100
|
-
const fullPath =
|
|
2692
|
+
const fullPath = confineToRoot(wsRoot, rel); // SEG-3
|
|
2101
2693
|
await fsPromises.mkdir(path.dirname(fullPath), { recursive: true });
|
|
2102
2694
|
await writeFile(fullPath, content ?? "", "utf8");
|
|
2103
2695
|
res.json({ ok: true, path: rel, bytes: Buffer.byteLength(content ?? "", "utf8") });
|
|
@@ -2158,11 +2750,64 @@ app.post("/workspace/test", async (req, res) => {
|
|
|
2158
2750
|
res.json({ root: wsRoot, meta, result });
|
|
2159
2751
|
});
|
|
2160
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
|
+
|
|
2161
2791
|
app.post("/workspace/run", async (req, res) => {
|
|
2162
2792
|
const wsRoot = resolveWorkspaceRoot(req);
|
|
2163
2793
|
if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
|
|
2164
2794
|
const { cmd, args = [] } = req.body || {};
|
|
2165
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
|
+
}
|
|
2166
2811
|
const out = await run(cmd, args, wsRoot, 5 * 60 * 1000);
|
|
2167
2812
|
res.json(out);
|
|
2168
2813
|
});
|
|
@@ -2171,16 +2816,9 @@ app.post("/workspace/run", async (req, res) => {
|
|
|
2171
2816
|
// TEST LOCAL ENDPOINTS
|
|
2172
2817
|
// ============================================
|
|
2173
2818
|
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
compilationResult: null,
|
|
2178
|
-
serverProcess: null,
|
|
2179
|
-
endpoints: [],
|
|
2180
|
-
config: null,
|
|
2181
|
-
testResults: [],
|
|
2182
|
-
serverLogs: [] // Buffer circular de logs do servidor (ltimos 1000)
|
|
2183
|
-
};
|
|
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.
|
|
2184
2822
|
|
|
2185
2823
|
// ============================================
|
|
2186
2824
|
// TEST LOCAL STATE ENDPOINTS (ADDED)
|
|
@@ -2239,19 +2877,19 @@ app.post("/workspace/git/prepare", async (req, res) => {
|
|
|
2239
2877
|
console.log(`[git-prepare] Starting workspace preparation: wsRoot=${wsRoot} repoUrl=${repoUrl || 'none'}`);
|
|
2240
2878
|
|
|
2241
2879
|
try {
|
|
2242
|
-
const {
|
|
2880
|
+
const { execFileSync } = await import("child_process");
|
|
2243
2881
|
const execOpts = { encoding: "utf8", timeout: 120000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } };
|
|
2244
|
-
|
|
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) => {
|
|
2245
2885
|
try {
|
|
2246
|
-
return { ok: true, output: execSync(
|
|
2247
|
-
cwd: wsRoot, ...execOpts
|
|
2248
|
-
}).trim() };
|
|
2886
|
+
return { ok: true, output: execSync(argv, { cwd: wsRoot, ...execOpts }).trim() };
|
|
2249
2887
|
} catch (err) {
|
|
2250
2888
|
return { ok: false, output: (err.stderr || err.stdout || err.message || "unknown").toString().trim() };
|
|
2251
2889
|
}
|
|
2252
2890
|
};
|
|
2253
2891
|
|
|
2254
|
-
execGit(
|
|
2892
|
+
execGit(["config", "--global", "--add", "safe.directory", wsRoot]);
|
|
2255
2893
|
|
|
2256
2894
|
// If workspace is empty (no .git), clone first before attempting fetch/reset
|
|
2257
2895
|
const gitDir = path.join(wsRoot, ".git");
|
|
@@ -2259,9 +2897,13 @@ app.post("/workspace/git/prepare", async (req, res) => {
|
|
|
2259
2897
|
if (!repoUrl) {
|
|
2260
2898
|
return res.json({ ok: false, error: "Workspace is empty and no repoUrl provided for clone" });
|
|
2261
2899
|
}
|
|
2262
|
-
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)}`);
|
|
2263
2901
|
fs.mkdirSync(wsRoot, { recursive: true });
|
|
2264
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" });
|
|
2265
2907
|
// Build authenticated URL
|
|
2266
2908
|
let authUrl = repoUrl;
|
|
2267
2909
|
if (gitToken) {
|
|
@@ -2283,24 +2925,24 @@ app.post("/workspace/git/prepare", async (req, res) => {
|
|
|
2283
2925
|
try {
|
|
2284
2926
|
fs.rmSync(wsRoot, { recursive: true, force: true });
|
|
2285
2927
|
fs.mkdirSync(wsRoot, { recursive: true });
|
|
2286
|
-
execSync(
|
|
2928
|
+
execSync(["clone", "--depth", "50", "--branch", b, "--", authUrl, wsRoot]);
|
|
2287
2929
|
clonedBranch = b;
|
|
2288
2930
|
cloned = true;
|
|
2289
2931
|
console.log(`[git-prepare] Cloned branch ${b} successfully`);
|
|
2290
2932
|
break;
|
|
2291
2933
|
} catch (cloneErr) {
|
|
2292
|
-
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)}`);
|
|
2293
2935
|
}
|
|
2294
2936
|
}
|
|
2295
2937
|
if (!cloned) {
|
|
2296
2938
|
try {
|
|
2297
2939
|
fs.rmSync(wsRoot, { recursive: true, force: true });
|
|
2298
2940
|
fs.mkdirSync(wsRoot, { recursive: true });
|
|
2299
|
-
execSync(
|
|
2941
|
+
execSync(["clone", "--depth", "50", "--", authUrl, wsRoot]);
|
|
2300
2942
|
cloned = true;
|
|
2301
2943
|
clonedBranch = "default";
|
|
2302
2944
|
} catch (e) {
|
|
2303
|
-
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) });
|
|
2304
2946
|
}
|
|
2305
2947
|
}
|
|
2306
2948
|
|
|
@@ -2308,9 +2950,17 @@ app.post("/workspace/git/prepare", async (req, res) => {
|
|
|
2308
2950
|
WORKSPACE_ROOT = wsRoot;
|
|
2309
2951
|
try { await wsManager.open(workspaceId || path.basename(wsRoot), wsRoot); } catch (_) {}
|
|
2310
2952
|
|
|
2311
|
-
const commit = execGit("
|
|
2312
|
-
|
|
2313
|
-
|
|
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
|
+
}
|
|
2314
2964
|
return res.json({ ok: true, cloned: true, mainBranch: clonedBranch,
|
|
2315
2965
|
currentCommit: commit.ok ? commit.output : "unknown", fileCount,
|
|
2316
2966
|
message: `Repository cloned: ${clonedBranch} (${fileCount} files)` });
|
|
@@ -2326,48 +2976,56 @@ app.post("/workspace/git/prepare", async (req, res) => {
|
|
|
2326
2976
|
} else {
|
|
2327
2977
|
authUrl = repoUrl.replace(/https:\/\/([^@]*@)?/, `https://x-access-token:${gitToken}@`);
|
|
2328
2978
|
}
|
|
2329
|
-
execGit(
|
|
2979
|
+
execGit(["remote", "set-url", "origin", authUrl]);
|
|
2330
2980
|
}
|
|
2331
2981
|
|
|
2332
2982
|
console.log("[git-prepare] Fetching origin...");
|
|
2333
|
-
execGit("
|
|
2983
|
+
execGit(["fetch", "origin", "--prune"]);
|
|
2334
2984
|
|
|
2335
2985
|
let mainBranch = null;
|
|
2336
2986
|
for (const branch of ["main", "master", "develop", "dev"]) {
|
|
2337
|
-
if (execGit(
|
|
2987
|
+
if (execGit(["rev-parse", "--verify", `origin/${branch}`]).ok) {
|
|
2338
2988
|
mainBranch = branch;
|
|
2339
2989
|
break;
|
|
2340
2990
|
}
|
|
2341
2991
|
}
|
|
2342
2992
|
if (!mainBranch) {
|
|
2343
|
-
const cur = execGit("
|
|
2344
|
-
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";
|
|
2345
2995
|
}
|
|
2346
2996
|
|
|
2347
|
-
const prevBranch = (execGit("
|
|
2348
|
-
const prevCommit = (execGit("
|
|
2997
|
+
const prevBranch = (execGit(["rev-parse", "--abbrev-ref", "HEAD"]).output || "unknown");
|
|
2998
|
+
const prevCommit = (execGit(["rev-parse", "--short", "HEAD"]).output || "unknown");
|
|
2349
2999
|
|
|
2350
3000
|
console.log(`[git-prepare] ${prevBranch}@${prevCommit} -> ${mainBranch}`);
|
|
2351
3001
|
|
|
2352
|
-
let checkout = execGit(
|
|
2353
|
-
if (!checkout.ok) checkout = execGit(
|
|
3002
|
+
let checkout = execGit(["checkout", mainBranch]);
|
|
3003
|
+
if (!checkout.ok) checkout = execGit(["checkout", "-f", mainBranch]);
|
|
2354
3004
|
if (!checkout.ok) {
|
|
2355
3005
|
return res.json({ ok: false, error: `Could not checkout ${mainBranch}: ${checkout.output}`,
|
|
2356
3006
|
mainBranch, previousBranch: prevBranch, previousCommit: prevCommit });
|
|
2357
3007
|
}
|
|
2358
3008
|
|
|
2359
|
-
execGit(
|
|
2360
|
-
execGit(
|
|
3009
|
+
execGit(["reset", "--hard", `origin/${mainBranch}`]);
|
|
3010
|
+
execGit(["clean", "-fd", "--exclude=*.env", "--exclude=.env*"]);
|
|
2361
3011
|
|
|
2362
|
-
const newCommit = (execGit("
|
|
2363
|
-
|
|
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)`);
|
|
2364
3022
|
|
|
2365
|
-
res.json({ ok: true, mainBranch, currentCommit: newCommit,
|
|
3023
|
+
res.json({ ok: true, mainBranch, currentCommit: newCommit, fileCount,
|
|
2366
3024
|
previousBranch: prevBranch, previousCommit: prevCommit,
|
|
2367
3025
|
message: `Workspace prepared: on ${mainBranch} at ${newCommit}` });
|
|
2368
3026
|
} catch (err) {
|
|
2369
|
-
console.error("[git-prepare] Error:", err.message);
|
|
2370
|
-
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) });
|
|
2371
3029
|
}
|
|
2372
3030
|
});
|
|
2373
3031
|
|
|
@@ -2475,7 +3133,7 @@ app.post("/workspace/test-local/start", async (req, res) => {
|
|
|
2475
3133
|
// Detect port if not provided
|
|
2476
3134
|
let serverPort = port || 8080;
|
|
2477
3135
|
|
|
2478
|
-
//
|
|
3136
|
+
// For Java, find the JAR and run it directly
|
|
2479
3137
|
if (meta.language === 'java') {
|
|
2480
3138
|
const targetDir = path.join(wsRoot, 'target');
|
|
2481
3139
|
|
|
@@ -2497,19 +3155,19 @@ app.post("/workspace/test-local/start", async (req, res) => {
|
|
|
2497
3155
|
|
|
2498
3156
|
const jarPath = path.join(targetDir, jarFiles[0]);
|
|
2499
3157
|
|
|
2500
|
-
//
|
|
3158
|
+
// EXACT command as in the terminal (no profiles!)
|
|
2501
3159
|
const command = 'java';
|
|
2502
3160
|
const args = ['-jar', jarPath, `--server.port=${serverPort}`];
|
|
2503
3161
|
|
|
2504
3162
|
console.log(`Command: ${command} ${args.join(' ')}`);
|
|
2505
3163
|
|
|
2506
|
-
//
|
|
3164
|
+
// CLEAN env - remove ALL Spring variables that could interfere
|
|
2507
3165
|
const cleanEnv = { ...process.env };
|
|
2508
3166
|
delete cleanEnv.SPRING_PROFILES_ACTIVE;
|
|
2509
3167
|
delete cleanEnv.SPRING_DATASOURCE_URL;
|
|
2510
3168
|
delete cleanEnv.SPRING_DATASOURCE_USERNAME;
|
|
2511
3169
|
delete cleanEnv.SPRING_DATASOURCE_PASSWORD;
|
|
2512
|
-
//
|
|
3170
|
+
// Remove any variable that starts with SPRING_
|
|
2513
3171
|
Object.keys(cleanEnv).forEach(key => {
|
|
2514
3172
|
if (key.startsWith('SPRING_')) {
|
|
2515
3173
|
delete cleanEnv[key];
|
|
@@ -2668,7 +3326,13 @@ app.post("/workspace/test-local/execute", async (req, res) => {
|
|
|
2668
3326
|
|
|
2669
3327
|
try {
|
|
2670
3328
|
const serverPort = port || TEST_LOCAL_STATE.config?.server?.port || 8080;
|
|
2671
|
-
|
|
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;
|
|
2672
3336
|
|
|
2673
3337
|
console.log(`[TEST-LOCAL] Executing: ${method} ${url}`);
|
|
2674
3338
|
|
|
@@ -2686,7 +3350,7 @@ app.post("/workspace/test-local/execute", async (req, res) => {
|
|
|
2686
3350
|
fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
|
|
2687
3351
|
}
|
|
2688
3352
|
|
|
2689
|
-
const response = await fetch(url, fetchOptions);
|
|
3353
|
+
const response = await fetch(url, { ...fetchOptions, redirect: "manual" });
|
|
2690
3354
|
const duration = Date.now() - startTime;
|
|
2691
3355
|
|
|
2692
3356
|
let responseBody;
|
|
@@ -2953,7 +3617,7 @@ app.post("/workspace/safe-patch", async (req, res) => {
|
|
|
2953
3617
|
const backupFiles = [];
|
|
2954
3618
|
|
|
2955
3619
|
for (const relPath of targetFiles) {
|
|
2956
|
-
const fullPath =
|
|
3620
|
+
const fullPath = confineToRoot(wsRoot, relPath); // SEG-3
|
|
2957
3621
|
if (await exists(fullPath)) {
|
|
2958
3622
|
const content = await readFile(fullPath, 'utf8');
|
|
2959
3623
|
backupFiles.push({ path: relPath, content });
|
|
@@ -3008,7 +3672,7 @@ app.post("/workspace/safe-patch", async (req, res) => {
|
|
|
3008
3672
|
console.error(`Patch failed, rolling back: ${patchError.message}`);
|
|
3009
3673
|
|
|
3010
3674
|
for (const file of backupFiles) {
|
|
3011
|
-
const fullPath =
|
|
3675
|
+
const fullPath = confineToRoot(wsRoot, file.path); // SEG-3
|
|
3012
3676
|
await writeFile(fullPath, file.content, 'utf8');
|
|
3013
3677
|
}
|
|
3014
3678
|
|
|
@@ -3065,7 +3729,7 @@ app.post("/workspace/patch/dry-run", async (req, res) => {
|
|
|
3065
3729
|
// 3. Check if files exist
|
|
3066
3730
|
const fileChecks = await Promise.all(
|
|
3067
3731
|
targetFiles.map(async (relPath) => {
|
|
3068
|
-
const fullPath =
|
|
3732
|
+
const fullPath = confineToRoot(wsRoot, relPath); // SEG-3
|
|
3069
3733
|
const fileExists = await exists(fullPath);
|
|
3070
3734
|
let currentContent = null;
|
|
3071
3735
|
let lineCount = 0;
|
|
@@ -3154,7 +3818,7 @@ app.post("/workspace/backup", async (req, res) => {
|
|
|
3154
3818
|
const backupFiles = [];
|
|
3155
3819
|
|
|
3156
3820
|
for (const relPath of files) {
|
|
3157
|
-
const fullPath =
|
|
3821
|
+
const fullPath = confineToRoot(wsRoot, relPath); // SEG-3
|
|
3158
3822
|
if (await exists(fullPath)) {
|
|
3159
3823
|
const content = await readFile(fullPath, 'utf8');
|
|
3160
3824
|
backupFiles.push({ path: relPath, content });
|
|
@@ -3219,7 +3883,7 @@ app.post("/workspace/rollback", async (req, res) => {
|
|
|
3219
3883
|
console.log(`Rolling back to backup: ${backupId}`);
|
|
3220
3884
|
|
|
3221
3885
|
for (const file of backup.files) {
|
|
3222
|
-
const fullPath =
|
|
3886
|
+
const fullPath = confineToRoot(wsRoot, file.path); // SEG-3
|
|
3223
3887
|
await writeFile(fullPath, file.content, 'utf8');
|
|
3224
3888
|
}
|
|
3225
3889
|
|
|
@@ -3346,7 +4010,7 @@ app.get("/workspace/diff/by-incident/:incidentId", async (req, res) => {
|
|
|
3346
4010
|
try {
|
|
3347
4011
|
const diffs = [];
|
|
3348
4012
|
for (const file of matchedBackup.files) {
|
|
3349
|
-
const fullPath =
|
|
4013
|
+
const fullPath = confineToRoot(wsRoot, file.path); // SEG-3
|
|
3350
4014
|
let currentContent = '';
|
|
3351
4015
|
try {
|
|
3352
4016
|
currentContent = await readFile(fullPath, 'utf8');
|
|
@@ -3425,7 +4089,7 @@ app.get("/workspace/diff/:backupId", async (req, res) => {
|
|
|
3425
4089
|
const diffs = [];
|
|
3426
4090
|
|
|
3427
4091
|
for (const file of backup.files) {
|
|
3428
|
-
const fullPath =
|
|
4092
|
+
const fullPath = confineToRoot(wsRoot, file.path); // SEG-3
|
|
3429
4093
|
let currentContent = '';
|
|
3430
4094
|
|
|
3431
4095
|
try {
|
|
@@ -3493,7 +4157,7 @@ app.get("/workspace/detect-port", async (req, res) => {
|
|
|
3493
4157
|
const { servicePath = '', language, framework } = req.query;
|
|
3494
4158
|
|
|
3495
4159
|
try {
|
|
3496
|
-
const fullPath =
|
|
4160
|
+
const fullPath = confineToRoot(wsRoot, servicePath); // SEG-3
|
|
3497
4161
|
console.log(`Detecting port for service at: ${fullPath}`);
|
|
3498
4162
|
|
|
3499
4163
|
let port = null;
|
|
@@ -3998,17 +4662,20 @@ app.get("/workspace/test-local/logs/stream", async (req, res) => {
|
|
|
3998
4662
|
|
|
3999
4663
|
// ============================================
|
|
4000
4664
|
// AUTO-TRAINING ENDPOINTS
|
|
4001
|
-
//
|
|
4665
|
+
// Scan and read files for AI training
|
|
4002
4666
|
// ============================================
|
|
4003
4667
|
|
|
4004
4668
|
/**
|
|
4005
4669
|
* POST /workspace/scan-files
|
|
4006
|
-
*
|
|
4670
|
+
* Scans all workspace files for auto-training
|
|
4007
4671
|
*/
|
|
4008
4672
|
app.post("/workspace/scan-files", async (req, res) => {
|
|
4009
4673
|
const { root, excludePatterns = [], includeExtensions = [], maxDepth = 10, maxFiles = 1000 } = req.body;
|
|
4010
4674
|
|
|
4011
|
-
|
|
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;
|
|
4012
4679
|
if (!workspaceRoot) {
|
|
4013
4680
|
return res.status(400).json({
|
|
4014
4681
|
error: "workspace not set",
|
|
@@ -4025,7 +4692,7 @@ app.post("/workspace/scan-files", async (req, res) => {
|
|
|
4025
4692
|
const fs = await import("fs/promises");
|
|
4026
4693
|
const pathModule = await import("path");
|
|
4027
4694
|
|
|
4028
|
-
//
|
|
4695
|
+
// Default patterns to exclude
|
|
4029
4696
|
const defaultExcludes = [
|
|
4030
4697
|
"node_modules", "target", "build", "dist", ".git", ".idea",
|
|
4031
4698
|
".vscode", "__pycache__", ".gradle", "bin", "obj",
|
|
@@ -4033,7 +4700,7 @@ app.post("/workspace/scan-files", async (req, res) => {
|
|
|
4033
4700
|
];
|
|
4034
4701
|
const allExcludes = [...defaultExcludes, ...excludePatterns];
|
|
4035
4702
|
|
|
4036
|
-
//
|
|
4703
|
+
// Recursive function to scan
|
|
4037
4704
|
async function scanDir(dir, depth = 0) {
|
|
4038
4705
|
if (depth > maxDepth || files.length >= maxFiles) return;
|
|
4039
4706
|
|
|
@@ -4046,7 +4713,7 @@ app.post("/workspace/scan-files", async (req, res) => {
|
|
|
4046
4713
|
const fullPath = pathModule.join(dir, entry.name);
|
|
4047
4714
|
const relativePath = pathModule.relative(workspaceRoot, fullPath);
|
|
4048
4715
|
|
|
4049
|
-
//
|
|
4716
|
+
// Check whether it should be excluded
|
|
4050
4717
|
const shouldExclude = allExcludes.some(pattern => {
|
|
4051
4718
|
if (pattern.includes("*")) {
|
|
4052
4719
|
const regex = new RegExp(pattern.replace(/\*/g, ".*"));
|
|
@@ -4060,7 +4727,7 @@ app.post("/workspace/scan-files", async (req, res) => {
|
|
|
4060
4727
|
if (entry.isDirectory()) {
|
|
4061
4728
|
await scanDir(fullPath, depth + 1);
|
|
4062
4729
|
} else if (entry.isFile()) {
|
|
4063
|
-
//
|
|
4730
|
+
// Check the extension
|
|
4064
4731
|
const ext = pathModule.extname(entry.name).toLowerCase();
|
|
4065
4732
|
if (includeExtensions.length === 0 || includeExtensions.includes(ext)) {
|
|
4066
4733
|
files.push(relativePath);
|
|
@@ -4095,12 +4762,14 @@ app.post("/workspace/scan-files", async (req, res) => {
|
|
|
4095
4762
|
|
|
4096
4763
|
/**
|
|
4097
4764
|
* POST /workspace/read-file
|
|
4098
|
-
*
|
|
4765
|
+
* Reads the content of a specific file
|
|
4099
4766
|
*/
|
|
4100
4767
|
app.post("/workspace/read-file", async (req, res) => {
|
|
4101
|
-
const {
|
|
4768
|
+
const { filePath } = req.body;
|
|
4102
4769
|
|
|
4103
|
-
|
|
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);
|
|
4104
4773
|
if (!workspaceRoot) {
|
|
4105
4774
|
return res.status(400).json({
|
|
4106
4775
|
error: "workspace not set"
|
|
@@ -4117,11 +4786,19 @@ app.post("/workspace/read-file", async (req, res) => {
|
|
|
4117
4786
|
|
|
4118
4787
|
try {
|
|
4119
4788
|
const fs = await import("fs/promises");
|
|
4120
|
-
const pathModule = await import("path");
|
|
4121
4789
|
|
|
4122
|
-
|
|
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
|
+
}
|
|
4123
4800
|
|
|
4124
|
-
//
|
|
4801
|
+
// Check whether the file exists
|
|
4125
4802
|
const stats = await fs.stat(fullPath);
|
|
4126
4803
|
|
|
4127
4804
|
// Limitar tamanho (100KB)
|
|
@@ -4155,12 +4832,13 @@ app.post("/workspace/read-file", async (req, res) => {
|
|
|
4155
4832
|
|
|
4156
4833
|
/**
|
|
4157
4834
|
* POST /workspace/read-files
|
|
4158
|
-
*
|
|
4835
|
+
* Reads multiple files at once (batch)
|
|
4159
4836
|
*/
|
|
4160
4837
|
app.post("/workspace/read-files", async (req, res) => {
|
|
4161
|
-
const {
|
|
4838
|
+
const { filePaths } = req.body;
|
|
4162
4839
|
|
|
4163
|
-
|
|
4840
|
+
// SEG-3: root only from the tenant-isolated request context.
|
|
4841
|
+
const workspaceRoot = resolveWorkspaceRoot(req);
|
|
4164
4842
|
if (!workspaceRoot) {
|
|
4165
4843
|
return res.status(400).json({
|
|
4166
4844
|
error: "workspace not set"
|
|
@@ -4177,17 +4855,25 @@ app.post("/workspace/read-files", async (req, res) => {
|
|
|
4177
4855
|
|
|
4178
4856
|
try {
|
|
4179
4857
|
const fs = await import("fs/promises");
|
|
4180
|
-
const pathModule = await import("path");
|
|
4181
4858
|
|
|
4182
4859
|
const files = {};
|
|
4183
4860
|
const errors = [];
|
|
4184
4861
|
|
|
4185
4862
|
for (const filePath of filePaths) {
|
|
4186
4863
|
try {
|
|
4187
|
-
|
|
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
|
+
}
|
|
4188
4874
|
const stats = await fs.stat(fullPath);
|
|
4189
4875
|
|
|
4190
|
-
//
|
|
4876
|
+
// Skip files that are too large
|
|
4191
4877
|
if (stats.size > 100 * 1024) {
|
|
4192
4878
|
errors.push({ filePath, error: "File too large" });
|
|
4193
4879
|
continue;
|
|
@@ -4225,7 +4911,7 @@ app.post("/workspace/read-files", async (req, res) => {
|
|
|
4225
4911
|
|
|
4226
4912
|
/**
|
|
4227
4913
|
* GET /system/folder-picker
|
|
4228
|
-
*
|
|
4914
|
+
* Opens the native operating system file picker
|
|
4229
4915
|
*/
|
|
4230
4916
|
app.get("/system/folder-picker", async (req, res) => {
|
|
4231
4917
|
console.log(`[FOLDER-PICKER] Opening native folder picker...`);
|
|
@@ -4241,7 +4927,7 @@ app.get("/system/folder-picker", async (req, res) => {
|
|
|
4241
4927
|
const { stdout } = await execAsync(script);
|
|
4242
4928
|
selectedPath = stdout.trim();
|
|
4243
4929
|
} catch (error) {
|
|
4244
|
-
//
|
|
4930
|
+
// User cancelled
|
|
4245
4931
|
if (error.code === 1) {
|
|
4246
4932
|
return res.json({
|
|
4247
4933
|
success: true,
|
|
@@ -4270,7 +4956,7 @@ app.get("/system/folder-picker", async (req, res) => {
|
|
|
4270
4956
|
}
|
|
4271
4957
|
|
|
4272
4958
|
} else if (platform === "linux") {
|
|
4273
|
-
// Linux -
|
|
4959
|
+
// Linux - try zenity, kdialog or yad
|
|
4274
4960
|
const commands = [
|
|
4275
4961
|
`zenity --file-selection --directory --title="Select your project folder"`,
|
|
4276
4962
|
`kdialog --getexistingdirectory ~ --title "Select your project folder"`,
|
|
@@ -4306,7 +4992,7 @@ app.get("/system/folder-picker", async (req, res) => {
|
|
|
4306
4992
|
});
|
|
4307
4993
|
}
|
|
4308
4994
|
|
|
4309
|
-
//
|
|
4995
|
+
// Remove trailing slash if present
|
|
4310
4996
|
if (selectedPath && selectedPath.endsWith("/")) {
|
|
4311
4997
|
selectedPath = selectedPath.slice(0, -1);
|
|
4312
4998
|
}
|
|
@@ -4332,7 +5018,7 @@ app.get("/system/folder-picker", async (req, res) => {
|
|
|
4332
5018
|
|
|
4333
5019
|
/**
|
|
4334
5020
|
* GET /system/info
|
|
4335
|
-
*
|
|
5021
|
+
* Returns system information
|
|
4336
5022
|
*/
|
|
4337
5023
|
app.get("/system/info", (req, res) => {
|
|
4338
5024
|
res.json({
|
|
@@ -4346,12 +5032,12 @@ app.get("/system/info", (req, res) => {
|
|
|
4346
5032
|
|
|
4347
5033
|
// ============================================
|
|
4348
5034
|
// API DOCS ENDPOINT
|
|
4349
|
-
//
|
|
5035
|
+
// Returns endpoints detected from the controllers
|
|
4350
5036
|
// ============================================
|
|
4351
5037
|
|
|
4352
5038
|
/**
|
|
4353
5039
|
* GET /workspace/api-docs
|
|
4354
|
-
*
|
|
5040
|
+
* Analyzes controllers and returns all API endpoints
|
|
4355
5041
|
*/
|
|
4356
5042
|
app.get("/workspace/api-docs", async (req, res) => {
|
|
4357
5043
|
const wsRoot = resolveWorkspaceRoot(req);
|
|
@@ -4365,7 +5051,7 @@ app.get("/workspace/api-docs", async (req, res) => {
|
|
|
4365
5051
|
console.log(`[API-DOCS] Analyzing controllers in ${wsRoot}`);
|
|
4366
5052
|
|
|
4367
5053
|
try {
|
|
4368
|
-
//
|
|
5054
|
+
// First, scan the workspace files
|
|
4369
5055
|
const fs = await import("fs/promises");
|
|
4370
5056
|
const pathModule = await import("path");
|
|
4371
5057
|
|
|
@@ -4403,7 +5089,7 @@ app.get("/workspace/api-docs", async (req, res) => {
|
|
|
4403
5089
|
|
|
4404
5090
|
console.log(`[API-DOCS] Found ${files.length} Java files`);
|
|
4405
5091
|
|
|
4406
|
-
//
|
|
5092
|
+
// Now analyze the controllers
|
|
4407
5093
|
const controllerAnalyzer = new ControllerAnalyzer(wsRoot);
|
|
4408
5094
|
const apiDocs = await controllerAnalyzer.generateApiDocs(files);
|
|
4409
5095
|
|
|
@@ -4434,7 +5120,7 @@ app.get("/workspace/api-docs", async (req, res) => {
|
|
|
4434
5120
|
|
|
4435
5121
|
/**
|
|
4436
5122
|
* GET /workspace/api-docs/:controller
|
|
4437
|
-
*
|
|
5123
|
+
* Returns the endpoints of a specific controller
|
|
4438
5124
|
*/
|
|
4439
5125
|
app.get("/workspace/api-docs/:controller", async (req, res) => {
|
|
4440
5126
|
const wsRoot = resolveWorkspaceRoot(req);
|
|
@@ -4449,7 +5135,7 @@ app.get("/workspace/api-docs/:controller", async (req, res) => {
|
|
|
4449
5135
|
console.log(`[API-DOCS] Getting endpoints for controller: ${controller}`);
|
|
4450
5136
|
|
|
4451
5137
|
try {
|
|
4452
|
-
//
|
|
5138
|
+
// First, scan the workspace files
|
|
4453
5139
|
const fs = await import("fs/promises");
|
|
4454
5140
|
const pathModule = await import("path");
|
|
4455
5141
|
|
|
@@ -4484,7 +5170,7 @@ app.get("/workspace/api-docs/:controller", async (req, res) => {
|
|
|
4484
5170
|
|
|
4485
5171
|
await scanDir(wsRoot);
|
|
4486
5172
|
|
|
4487
|
-
//
|
|
5173
|
+
// Analyze the controllers
|
|
4488
5174
|
const controllerAnalyzer = new ControllerAnalyzer(wsRoot);
|
|
4489
5175
|
const apiDocs = await controllerAnalyzer.generateApiDocs(files);
|
|
4490
5176
|
|
|
@@ -4535,7 +5221,7 @@ app.get("/workspace/api-docs/:controller", async (req, res) => {
|
|
|
4535
5221
|
|
|
4536
5222
|
/**
|
|
4537
5223
|
* GET /ai-engine/status
|
|
4538
|
-
*
|
|
5224
|
+
* Returns the state of the AI Vibe Coding Engine
|
|
4539
5225
|
*/
|
|
4540
5226
|
app.get("/ai-engine/status", (req, res) => {
|
|
4541
5227
|
res.json(aiEngine ? aiEngine.getStatus() : { active: false, error: 'AI Engine not initialized' });
|
|
@@ -4570,7 +5256,7 @@ app.post("/ai-engine/clear-history", (req, res) => {
|
|
|
4570
5256
|
|
|
4571
5257
|
/**
|
|
4572
5258
|
* GET /ai-engine/errors
|
|
4573
|
-
*
|
|
5259
|
+
* Returns the error history
|
|
4574
5260
|
*/
|
|
4575
5261
|
app.get("/ai-engine/errors", (req, res) => {
|
|
4576
5262
|
const { limit = 50 } = req.query;
|
|
@@ -4587,7 +5273,7 @@ app.get("/ai-engine/errors", (req, res) => {
|
|
|
4587
5273
|
|
|
4588
5274
|
/**
|
|
4589
5275
|
* GET /ai-engine/fixes
|
|
4590
|
-
*
|
|
5276
|
+
* Returns the fix history
|
|
4591
5277
|
*/
|
|
4592
5278
|
app.get("/ai-engine/fixes", (req, res) => {
|
|
4593
5279
|
if (aiEngine) {
|
|
@@ -4603,7 +5289,7 @@ app.get("/ai-engine/fixes", (req, res) => {
|
|
|
4603
5289
|
|
|
4604
5290
|
/**
|
|
4605
5291
|
* GET /workspace/smart-config
|
|
4606
|
-
*
|
|
5292
|
+
* Collects configuration files for AI analysis
|
|
4607
5293
|
*/
|
|
4608
5294
|
app.get("/workspace/smart-config", async (req, res) => {
|
|
4609
5295
|
const wsRoot = resolveWorkspaceRoot(req);
|
|
@@ -4652,7 +5338,7 @@ app.get("/workspace/smart-config", async (req, res) => {
|
|
|
4652
5338
|
}
|
|
4653
5339
|
}
|
|
4654
5340
|
|
|
4655
|
-
//
|
|
5341
|
+
// Detect available profiles
|
|
4656
5342
|
const availableProfiles = [];
|
|
4657
5343
|
const resourcesDir = path.join(wsRoot, 'src/main/resources');
|
|
4658
5344
|
if (fs.existsSync(resourcesDir)) {
|
|
@@ -4767,40 +5453,40 @@ app.post("/workspace/search", async (req, res) => {
|
|
|
4767
5453
|
return res.status(400).json({ ok: false, error: "pattern is required" });
|
|
4768
5454
|
}
|
|
4769
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
|
+
|
|
4770
5469
|
console.log(`[workspace/search] pattern="${pattern}" filter="${fileFilter || '*'}"`);
|
|
4771
5470
|
|
|
4772
5471
|
try {
|
|
4773
|
-
const {
|
|
4774
|
-
|
|
4775
|
-
// Build grep command with safety limits
|
|
4776
|
-
let cmd = `grep -rn `;
|
|
4777
|
-
|
|
4778
|
-
// Apply file filter if provided
|
|
4779
|
-
if (fileFilter && fileFilter.trim()) {
|
|
4780
|
-
cmd += `--include="${fileFilter}" `;
|
|
4781
|
-
}
|
|
4782
|
-
|
|
4783
|
-
// Exclude common non-source directories
|
|
4784
|
-
cmd += `--exclude-dir=node_modules --exclude-dir=.git --exclude-dir=target `;
|
|
4785
|
-
cmd += `--exclude-dir=build --exclude-dir=dist --exclude-dir=.idea `;
|
|
4786
|
-
cmd += `--exclude-dir=__pycache__ --exclude-dir=.next --exclude-dir=vendor `;
|
|
4787
|
-
cmd += `--exclude-dir=bin --exclude-dir=obj --exclude-dir=.gradle `;
|
|
4788
|
-
cmd += `--exclude-dir=.mvn --exclude-dir=.venv --exclude-dir=coverage `;
|
|
5472
|
+
const { execFileSync } = await import('child_process');
|
|
4789
5473
|
|
|
4790
|
-
//
|
|
4791
|
-
const
|
|
4792
|
-
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 });
|
|
4793
5476
|
|
|
5477
|
+
const relativize = (line) => line.replace(wsRoot + '/', '');
|
|
5478
|
+
const SEARCH_TIMEOUT_MS = parseInt(process.env.WORKSPACE_SEARCH_TIMEOUT_MS || "15000", 10);
|
|
4794
5479
|
let stdout = '';
|
|
4795
5480
|
try {
|
|
4796
|
-
stdout =
|
|
5481
|
+
stdout = execFileSync('grep', grepArgs, {
|
|
4797
5482
|
encoding: 'utf8',
|
|
4798
|
-
timeout:
|
|
5483
|
+
timeout: SEARCH_TIMEOUT_MS,
|
|
4799
5484
|
maxBuffer: 1024 * 1024
|
|
4800
|
-
});
|
|
5485
|
+
}).split('\n').slice(0, 100).join('\n');
|
|
4801
5486
|
} catch (grepErr) {
|
|
5487
|
+
const outcome = classifyGrepFailure(grepErr, { timeoutMs: SEARCH_TIMEOUT_MS });
|
|
4802
5488
|
// grep returns exit code 1 when no matches found
|
|
4803
|
-
if (
|
|
5489
|
+
if (outcome.kind === 'no_match') {
|
|
4804
5490
|
return res.json({
|
|
4805
5491
|
ok: true,
|
|
4806
5492
|
results: `No matches found for: ${pattern}`,
|
|
@@ -4808,12 +5494,18 @@ app.post("/workspace/search", async (req, res) => {
|
|
|
4808
5494
|
});
|
|
4809
5495
|
}
|
|
4810
5496
|
// Exit code 2 = error in grep
|
|
4811
|
-
if (
|
|
5497
|
+
if (outcome.kind === 'bad_pattern') {
|
|
4812
5498
|
return res.status(400).json({
|
|
4813
5499
|
ok: false,
|
|
4814
|
-
error: "Invalid search pattern: " +
|
|
5500
|
+
error: "Invalid search pattern: " + outcome.message
|
|
4815
5501
|
});
|
|
4816
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
|
+
}
|
|
4817
5509
|
throw grepErr;
|
|
4818
5510
|
}
|
|
4819
5511
|
|
|
@@ -4821,7 +5513,7 @@ app.post("/workspace/search", async (req, res) => {
|
|
|
4821
5513
|
const results = stdout
|
|
4822
5514
|
.split('\n')
|
|
4823
5515
|
.filter(line => line.trim())
|
|
4824
|
-
.map(
|
|
5516
|
+
.map(relativize)
|
|
4825
5517
|
.join('\n');
|
|
4826
5518
|
|
|
4827
5519
|
const count = results.split('\n').filter(l => l.trim()).length;
|
|
@@ -4865,19 +5557,26 @@ app.post("/workspace/exec", async (req, res) => {
|
|
|
4865
5557
|
return res.status(400).json({ ok: false, error: "command is required" });
|
|
4866
5558
|
}
|
|
4867
5559
|
|
|
4868
|
-
// Security:
|
|
4869
|
-
|
|
4870
|
-
|
|
4871
|
-
|
|
4872
|
-
|
|
4873
|
-
|
|
4874
|
-
|
|
4875
|
-
if (
|
|
4876
|
-
console.warn(`[workspace/exec]
|
|
4877
|
-
return res.status(403).json(
|
|
4878
|
-
|
|
4879
|
-
|
|
4880
|
-
|
|
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 });
|
|
4881
5580
|
}
|
|
4882
5581
|
|
|
4883
5582
|
console.log(`[workspace/exec] Running: ${command.substring(0, 120)}...`);
|
|
@@ -4891,7 +5590,9 @@ app.post("/workspace/exec", async (req, res) => {
|
|
|
4891
5590
|
cwd: wsRoot,
|
|
4892
5591
|
timeout: 60000,
|
|
4893
5592
|
maxBuffer: 5 * 1024 * 1024,
|
|
4894
|
-
|
|
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' })
|
|
4895
5596
|
});
|
|
4896
5597
|
|
|
4897
5598
|
console.log(`Command completed (stdout: ${result.stdout.length} chars)`);
|
|
@@ -4931,9 +5632,10 @@ app.post("/workspace/test-endpoint", async (req, res) => {
|
|
|
4931
5632
|
return res.status(400).json({ error: "url is required" });
|
|
4932
5633
|
}
|
|
4933
5634
|
|
|
4934
|
-
// Security: only
|
|
4935
|
-
|
|
4936
|
-
|
|
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}` });
|
|
4937
5639
|
}
|
|
4938
5640
|
|
|
4939
5641
|
console.log(`Testing: ${method} ${url} (${testName || 'unnamed'})`);
|
|
@@ -4954,7 +5656,7 @@ app.post("/workspace/test-endpoint", async (req, res) => {
|
|
|
4954
5656
|
fetchOptions.body = reqBody;
|
|
4955
5657
|
}
|
|
4956
5658
|
|
|
4957
|
-
const response = await fetch(url, fetchOptions);
|
|
5659
|
+
const response = await fetch(urlCheck.url, { ...fetchOptions, redirect: 'manual' });
|
|
4958
5660
|
const durationMs = Date.now() - startTime;
|
|
4959
5661
|
|
|
4960
5662
|
// Capture response
|
|
@@ -5012,6 +5714,198 @@ app.post("/workspace/test-endpoint", async (req, res) => {
|
|
|
5012
5714
|
// Create branch, commit, push for auto-fix PRs
|
|
5013
5715
|
// ============================================
|
|
5014
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
|
+
|
|
5015
5909
|
/**
|
|
5016
5910
|
* POST /workspace/git/create-fix-branch
|
|
5017
5911
|
* Creates a branch, commits changes, and pushes to remote.
|
|
@@ -5028,9 +5922,33 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
|
|
|
5028
5922
|
if (!branchName || !commitMessage) {
|
|
5029
5923
|
return res.status(400).json({ error: "branchName and commitMessage required" });
|
|
5030
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
|
+
}
|
|
5031
5949
|
|
|
5032
5950
|
try {
|
|
5033
|
-
const { execSync } = await import('child_process');
|
|
5951
|
+
const { execSync, execFileSync } = await import('child_process');
|
|
5034
5952
|
const opts = {
|
|
5035
5953
|
cwd: wsRoot,
|
|
5036
5954
|
encoding: 'utf8',
|
|
@@ -5039,9 +5957,11 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
|
|
|
5039
5957
|
...process.env,
|
|
5040
5958
|
GIT_TERMINAL_PROMPT: '0', // Never prompt for credentials
|
|
5041
5959
|
GIT_ASKPASS: 'echo', // Return empty if asked
|
|
5042
|
-
GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=
|
|
5960
|
+
GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=5'
|
|
5043
5961
|
}
|
|
5044
5962
|
};
|
|
5963
|
+
// argv form (no shell) for every call that carries request data.
|
|
5964
|
+
const git = (args, o = {}) => execFileSync('git', args, { ...opts, ...o }).toString();
|
|
5045
5965
|
|
|
5046
5966
|
// 1. Check if git repo
|
|
5047
5967
|
try {
|
|
@@ -5068,12 +5988,12 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
|
|
|
5068
5988
|
// 4. Create and checkout new branch (with unique name if exists)
|
|
5069
5989
|
let finalBranchName = branchName;
|
|
5070
5990
|
try {
|
|
5071
|
-
|
|
5991
|
+
git(['checkout', '-b', finalBranchName]);
|
|
5072
5992
|
} catch (e) {
|
|
5073
5993
|
// Branch already exists add timestamp suffix
|
|
5074
5994
|
finalBranchName = `${branchName}-${Date.now() % 100000}`;
|
|
5075
5995
|
try {
|
|
5076
|
-
|
|
5996
|
+
git(['checkout', '-b', finalBranchName]);
|
|
5077
5997
|
} catch (e2) {
|
|
5078
5998
|
// Restore stash and bail
|
|
5079
5999
|
if (hadStash) try { execSync('git stash pop', opts); } catch {}
|
|
@@ -5094,7 +6014,7 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
|
|
|
5094
6014
|
// 6. Stage changes
|
|
5095
6015
|
if (files && files.length > 0) {
|
|
5096
6016
|
for (const file of files) {
|
|
5097
|
-
|
|
6017
|
+
git(['add', '--', file]);
|
|
5098
6018
|
}
|
|
5099
6019
|
} else {
|
|
5100
6020
|
execSync('git add -A', opts);
|
|
@@ -5103,7 +6023,7 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
|
|
|
5103
6023
|
// 7. Check for changes
|
|
5104
6024
|
const status = execSync('git status --porcelain', opts).trim();
|
|
5105
6025
|
if (!status) {
|
|
5106
|
-
try {
|
|
6026
|
+
try { git(['checkout', currentBranch]); } catch {}
|
|
5107
6027
|
return res.json({ ok: false, error: "No changes to commit" });
|
|
5108
6028
|
}
|
|
5109
6029
|
|
|
@@ -5115,8 +6035,7 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
|
|
|
5115
6035
|
} catch (e) {
|
|
5116
6036
|
console.warn('Could not set git config:', e.message);
|
|
5117
6037
|
}
|
|
5118
|
-
|
|
5119
|
-
execSync(`git commit -m "${safeMsg}"`, opts);
|
|
6038
|
+
git(['commit', '-m', String(commitMessage)]);
|
|
5120
6039
|
|
|
5121
6040
|
// 9. Push (use token if available for authentication)
|
|
5122
6041
|
let pushResult = '';
|
|
@@ -5134,11 +6053,11 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
|
|
|
5134
6053
|
// No remote configured use repoUrl from integration if available
|
|
5135
6054
|
if (repoUrl) {
|
|
5136
6055
|
remoteUrlRaw = repoUrl;
|
|
5137
|
-
console.log(`No git remote, using repoUrl from integration: ${repoUrl}`);
|
|
6056
|
+
console.log(`No git remote, using repoUrl from integration: ${maskUrlCreds(repoUrl)}`);
|
|
5138
6057
|
}
|
|
5139
6058
|
}
|
|
5140
6059
|
|
|
5141
|
-
console.log(`Remote URL: ${remoteUrlRaw
|
|
6060
|
+
console.log(`Remote URL: ${maskUrlCreds(remoteUrlRaw)}`);
|
|
5142
6061
|
let authenticatedUrl = '';
|
|
5143
6062
|
|
|
5144
6063
|
if (remoteUrlRaw.includes('github.com')) {
|
|
@@ -5162,7 +6081,7 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
|
|
|
5162
6081
|
.replace(/^git@bitbucket\.org:/, '')
|
|
5163
6082
|
.replace(/\.git$/, '');
|
|
5164
6083
|
// FIXED: Bitbucket App Password auth token j est no formato "username:appPassword"
|
|
5165
|
-
// x-token-auth
|
|
6084
|
+
// x-token-auth does not work; Basic Auth via URL is the correct method
|
|
5166
6085
|
authenticatedUrl = `https://${gitToken}@bitbucket.org/${repoPath}.git`;
|
|
5167
6086
|
} else if (remoteUrlRaw) {
|
|
5168
6087
|
// Unknown provider try generic https with token
|
|
@@ -5176,42 +6095,46 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
|
|
|
5176
6095
|
console.log(`Pushing ${finalBranchName} with token auth...`);
|
|
5177
6096
|
try {
|
|
5178
6097
|
// Don't use 2>&1 let execSync capture stderr separately
|
|
5179
|
-
pushResult =
|
|
5180
|
-
...opts,
|
|
6098
|
+
pushResult = git(['push', authenticatedUrl, finalBranchName], {
|
|
5181
6099
|
timeout: 60000, // 60s for slow connections
|
|
5182
6100
|
stdio: ['pipe', 'pipe', 'pipe'] // capture stdin, stdout, stderr separately
|
|
5183
|
-
})
|
|
6101
|
+
});
|
|
5184
6102
|
pushed = true;
|
|
5185
6103
|
console.log(`Pushed with token authentication`);
|
|
5186
6104
|
} catch (innerErr) {
|
|
5187
|
-
// Mask token in error messages before logging
|
|
5188
|
-
|
|
5189
|
-
|
|
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 || ''));
|
|
5190
6111
|
console.warn(`Authenticated push failed: ${errMsg}`);
|
|
5191
6112
|
pushResult = errMsg;
|
|
5192
6113
|
}
|
|
5193
6114
|
} else {
|
|
5194
|
-
console.warn(`Could not construct authenticated URL from: ${remoteUrlRaw}`);
|
|
6115
|
+
console.warn(`Could not construct authenticated URL from: ${maskUrlCreds(remoteUrlRaw)}`);
|
|
5195
6116
|
}
|
|
5196
6117
|
} catch (pushErr) {
|
|
5197
|
-
const maskToken = (str) => str ? str.
|
|
5198
|
-
|
|
5199
|
-
|
|
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';
|
|
5200
6122
|
}
|
|
5201
6123
|
}
|
|
5202
6124
|
|
|
5203
6125
|
// Fallback: try push without token (uses local git credentials)
|
|
5204
6126
|
if (!pushed) {
|
|
5205
6127
|
try {
|
|
5206
|
-
pushResult =
|
|
6128
|
+
pushResult = git(['push', '-u', 'origin', finalBranchName], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
5207
6129
|
pushed = true;
|
|
5208
6130
|
} catch (pushErr) {
|
|
5209
6131
|
try {
|
|
5210
|
-
pushResult =
|
|
6132
|
+
pushResult = git(['push', 'origin', finalBranchName], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
5211
6133
|
pushed = true;
|
|
5212
6134
|
} catch (pushErr2) {
|
|
5213
|
-
|
|
5214
|
-
|
|
6135
|
+
const safe = maskUrlCreds(pushErr2.message);
|
|
6136
|
+
console.warn(`Git push failed: ${safe}`);
|
|
6137
|
+
pushResult = safe || 'Push failed no remote configured or auth required';
|
|
5215
6138
|
}
|
|
5216
6139
|
}
|
|
5217
6140
|
}
|
|
@@ -5274,11 +6197,11 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
|
|
|
5274
6197
|
|
|
5275
6198
|
// 10. Switch back to original branch and merge fix to keep changes on disk
|
|
5276
6199
|
try {
|
|
5277
|
-
|
|
6200
|
+
git(['checkout', currentBranch]);
|
|
5278
6201
|
// Merge the fix branch so the working directory has the patched files
|
|
5279
6202
|
// This keeps the diff viewer working (disk has patched version)
|
|
5280
6203
|
try {
|
|
5281
|
-
|
|
6204
|
+
git(['merge', finalBranchName, '--no-edit']);
|
|
5282
6205
|
console.log(`Merged ${finalBranchName} into ${currentBranch}`);
|
|
5283
6206
|
} catch (mergeErr) {
|
|
5284
6207
|
// If merge fails (conflicts), just cherry-pick the changes without committing
|
|
@@ -5290,7 +6213,7 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
|
|
|
5290
6213
|
const changedFiles = status.split('\n').map(l => l.trim().split(/\s+/).pop()).filter(Boolean);
|
|
5291
6214
|
for (const f of changedFiles) {
|
|
5292
6215
|
try {
|
|
5293
|
-
|
|
6216
|
+
git(['checkout', finalBranchName, '--', f]);
|
|
5294
6217
|
} catch {}
|
|
5295
6218
|
}
|
|
5296
6219
|
console.log(`Cherry-picked ${changedFiles.length} file(s) from ${finalBranchName}`);
|
|
@@ -5299,17 +6222,17 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
|
|
|
5299
6222
|
} catch {}
|
|
5300
6223
|
|
|
5301
6224
|
// 11. Auto-create Pull Request if pushed and token available
|
|
5302
|
-
// FIXED: suporta GitHub, GitLab e Bitbucket; branding
|
|
6225
|
+
// FIXED: suporta GitHub, GitLab e Bitbucket; branding VisionHammer
|
|
5303
6226
|
let prUrl = '';
|
|
5304
6227
|
const prBodyText =
|
|
5305
|
-
`## Auto-fix by
|
|
6228
|
+
`## Auto-fix by VisionHammer\n\n` +
|
|
5306
6229
|
`**Branch:** \`${finalBranchName}\`\n` +
|
|
5307
6230
|
`**Files changed:** ${status.split('\n').length}\n` +
|
|
5308
6231
|
`**Commit:** ${commitSha.substring(0, 8)}\n\n` +
|
|
5309
|
-
`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` +
|
|
5310
6233
|
`### Changes\n` +
|
|
5311
6234
|
status.split('\n').map(l => `- \`${l.trim()}\``).join('\n') + '\n\n' +
|
|
5312
|
-
`---\n*Generated by [
|
|
6235
|
+
`---\n*Generated by [VisionHammer](https://visionhammer.io) Autonomous Debugging Platform*`;
|
|
5313
6236
|
|
|
5314
6237
|
if (pushed && gitToken && remoteUrl) {
|
|
5315
6238
|
|
|
@@ -5871,7 +6794,7 @@ async function _getGitProvider() {
|
|
|
5871
6794
|
const providerId = registry.detectProviderFromUrl(remoteUrl);
|
|
5872
6795
|
|
|
5873
6796
|
if (!providerId) {
|
|
5874
|
-
console.warn(`Unknown git provider for URL: ${remoteUrl}`);
|
|
6797
|
+
console.warn(`Unknown git provider for URL: ${maskUrlCreds(remoteUrl)}`);
|
|
5875
6798
|
return null;
|
|
5876
6799
|
}
|
|
5877
6800
|
|
|
@@ -5914,7 +6837,7 @@ async function _getGitProvider() {
|
|
|
5914
6837
|
// MULTI-WORKSPACE ENDPOINTS
|
|
5915
6838
|
// ============================================
|
|
5916
6839
|
|
|
5917
|
-
/**
|
|
6840
|
+
/** Lists all open workspaces */
|
|
5918
6841
|
app.get("/workspaces", (req, res) => {
|
|
5919
6842
|
res.json({
|
|
5920
6843
|
workspaces: wsManager.list(),
|
|
@@ -5948,7 +6871,7 @@ app.post("/workspace/:workspaceId/open", async (req, res) => {
|
|
|
5948
6871
|
}
|
|
5949
6872
|
});
|
|
5950
6873
|
|
|
5951
|
-
/**
|
|
6874
|
+
/** Applies a patch to a specific workspace */
|
|
5952
6875
|
app.post("/workspace/:workspaceId/patch", async (req, res) => {
|
|
5953
6876
|
const { workspaceId } = req.params;
|
|
5954
6877
|
const { diff } = req.body || {};
|
|
@@ -5962,7 +6885,7 @@ app.post("/workspace/:workspaceId/patch", async (req, res) => {
|
|
|
5962
6885
|
}
|
|
5963
6886
|
});
|
|
5964
6887
|
|
|
5965
|
-
/**
|
|
6888
|
+
/** Compiles and tests a specific workspace */
|
|
5966
6889
|
app.post("/workspace/:workspaceId/test", async (req, res) => {
|
|
5967
6890
|
const { workspaceId } = req.params;
|
|
5968
6891
|
try {
|
|
@@ -5975,13 +6898,19 @@ app.post("/workspace/:workspaceId/test", async (req, res) => {
|
|
|
5975
6898
|
}
|
|
5976
6899
|
});
|
|
5977
6900
|
|
|
5978
|
-
/**
|
|
6901
|
+
/** Runs a command in a specific workspace */
|
|
5979
6902
|
app.post("/workspace/:workspaceId/run", async (req, res) => {
|
|
5980
6903
|
const { workspaceId } = req.params;
|
|
5981
6904
|
const { cmd, args = [] } = req.body || {};
|
|
5982
6905
|
if (!cmd) return res.status(400).json({ error: "cmd is required" });
|
|
5983
6906
|
try {
|
|
5984
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
|
+
}
|
|
5985
6914
|
const out = await run(cmd, args, root, 5 * 60 * 1000);
|
|
5986
6915
|
res.json({ workspaceId, ...out });
|
|
5987
6916
|
} catch (e) {
|
|
@@ -5994,14 +6923,32 @@ app.post("/workspace/:workspaceId/run", async (req, res) => {
|
|
|
5994
6923
|
// WEBSOCKET REVERSE TUNNEL
|
|
5995
6924
|
// Connects Local Agent to Gateway no public URL needed
|
|
5996
6925
|
// ============================================
|
|
5997
|
-
async function startWebSocketTunnel(port, gatewayUrl,
|
|
6926
|
+
async function startWebSocketTunnel(port, gatewayUrl, authToken, tenantId) {
|
|
5998
6927
|
if (!gatewayUrl || !tenantId) {
|
|
5999
6928
|
console.log('WebSocket tunnel skipped: missing gatewayUrl or tenantId');
|
|
6000
6929
|
return;
|
|
6001
6930
|
}
|
|
6002
6931
|
|
|
6003
6932
|
const wsUrl = gatewayUrl.replace(/^https:\/\//, 'wss://').replace(/^http:\/\//, 'ws://');
|
|
6004
|
-
|
|
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}` } : {};
|
|
6005
6952
|
|
|
6006
6953
|
let reconnectDelay = 2000;
|
|
6007
6954
|
let isShuttingDown = false;
|
|
@@ -6011,7 +6958,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
|
|
|
6011
6958
|
if (isShuttingDown) return;
|
|
6012
6959
|
try {
|
|
6013
6960
|
const { default: WebSocket } = await import('ws');
|
|
6014
|
-
ws = new WebSocket(fullUrl);
|
|
6961
|
+
ws = new WebSocket(fullUrl, { headers: wsHeaders });
|
|
6015
6962
|
|
|
6016
6963
|
ws.on('open', () => {
|
|
6017
6964
|
console.log(`WebSocket tunnel connected to Gateway`);
|
|
@@ -6028,11 +6975,18 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
|
|
|
6028
6975
|
if (text === 'pong') return;
|
|
6029
6976
|
let request;
|
|
6030
6977
|
try { request = JSON.parse(text); } catch { return; }
|
|
6031
|
-
const { requestId, command
|
|
6978
|
+
const { requestId, command } = request;
|
|
6032
6979
|
if (!requestId || !command) return;
|
|
6033
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
|
+
}
|
|
6034
6988
|
let result;
|
|
6035
|
-
try { result = await handleWsCommand(command, params || {}); }
|
|
6989
|
+
try { result = await handleWsCommand(command, envelope.params || {}, envelope); }
|
|
6036
6990
|
catch (err) { result = { error: err.message }; }
|
|
6037
6991
|
ws.send(JSON.stringify({ requestId, command, ...result }));
|
|
6038
6992
|
});
|
|
@@ -6058,8 +7012,18 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
|
|
|
6058
7012
|
}
|
|
6059
7013
|
}
|
|
6060
7014
|
|
|
6061
|
-
async function handleWsCommand(command, params) {
|
|
7015
|
+
async function handleWsCommand(command, params, sigCtx = {}) {
|
|
6062
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
|
+
});
|
|
6063
7027
|
switch (command) {
|
|
6064
7028
|
case 'health':
|
|
6065
7029
|
return { status: 'ok', workspace: WORKSPACE_ROOT, openWorkspaces: wsManager ? wsManager.list().length : 0 };
|
|
@@ -6073,10 +7037,23 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
|
|
|
6073
7037
|
}
|
|
6074
7038
|
|
|
6075
7039
|
case 'workspace.file-content': {
|
|
6076
|
-
const { path: filePath } = params;
|
|
7040
|
+
const { path: filePath, workspacePath: wsRootParam } = params;
|
|
6077
7041
|
if (!filePath) return { error: 'path is required' };
|
|
6078
|
-
|
|
6079
|
-
|
|
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
|
+
}
|
|
6080
7057
|
try {
|
|
6081
7058
|
const content = await fsPromises.readFile(absPath, 'utf-8');
|
|
6082
7059
|
return { content, path: filePath, found: true };
|
|
@@ -6101,7 +7078,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
|
|
|
6101
7078
|
const method = command === 'workspace.backups' ? 'GET' : 'POST';
|
|
6102
7079
|
const res = await fetch(`${localBase}${endpoint}`, {
|
|
6103
7080
|
method,
|
|
6104
|
-
headers: { 'Content-Type': 'application/json' },
|
|
7081
|
+
headers: { 'Content-Type': 'application/json', ...hopHeaders() },
|
|
6105
7082
|
...(method === 'POST' ? { body: JSON.stringify(params) } : {})
|
|
6106
7083
|
});
|
|
6107
7084
|
return await res.json();
|
|
@@ -6125,7 +7102,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
|
|
|
6125
7102
|
console.log(`WS workspace.diff ${endpoint}`);
|
|
6126
7103
|
const res = await fetch(`${localBase}${endpoint}`, {
|
|
6127
7104
|
method: 'GET',
|
|
6128
|
-
headers: { 'Content-Type': 'application/json' }
|
|
7105
|
+
headers: { 'Content-Type': 'application/json', ...hopHeaders() }
|
|
6129
7106
|
});
|
|
6130
7107
|
return await res.json();
|
|
6131
7108
|
}
|
|
@@ -6137,7 +7114,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
|
|
|
6137
7114
|
const mcpBase = `http://localhost:5056`;
|
|
6138
7115
|
const res = await fetch(`${mcpBase}/mcp/read-file`, {
|
|
6139
7116
|
method: 'POST',
|
|
6140
|
-
headers: { 'Content-Type': 'application/json' },
|
|
7117
|
+
headers: { 'Content-Type': 'application/json', ...hopHeaders() },
|
|
6141
7118
|
body: JSON.stringify({
|
|
6142
7119
|
workspaceId: params.workspaceId || 'default',
|
|
6143
7120
|
path: params.path
|
|
@@ -6150,7 +7127,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
|
|
|
6150
7127
|
const mcpBase = `http://localhost:5056`;
|
|
6151
7128
|
const res = await fetch(`${mcpBase}/mcp/read-files`, {
|
|
6152
7129
|
method: 'POST',
|
|
6153
|
-
headers: { 'Content-Type': 'application/json' },
|
|
7130
|
+
headers: { 'Content-Type': 'application/json', ...hopHeaders() },
|
|
6154
7131
|
body: JSON.stringify({
|
|
6155
7132
|
workspaceId: params.workspaceId || 'default',
|
|
6156
7133
|
paths: params.paths
|
|
@@ -6163,7 +7140,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
|
|
|
6163
7140
|
const mcpBase = `http://localhost:5056`;
|
|
6164
7141
|
const res = await fetch(`${mcpBase}/mcp/list-directory`, {
|
|
6165
7142
|
method: 'POST',
|
|
6166
|
-
headers: { 'Content-Type': 'application/json' },
|
|
7143
|
+
headers: { 'Content-Type': 'application/json', ...hopHeaders() },
|
|
6167
7144
|
body: JSON.stringify({
|
|
6168
7145
|
workspaceId: params.workspaceId || 'default',
|
|
6169
7146
|
path: params.path || '.',
|
|
@@ -6177,7 +7154,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
|
|
|
6177
7154
|
const mcpBase = `http://localhost:5056`;
|
|
6178
7155
|
const res = await fetch(`${mcpBase}/mcp/search-code`, {
|
|
6179
7156
|
method: 'POST',
|
|
6180
|
-
headers: { 'Content-Type': 'application/json' },
|
|
7157
|
+
headers: { 'Content-Type': 'application/json', ...hopHeaders() },
|
|
6181
7158
|
body: JSON.stringify({
|
|
6182
7159
|
workspaceId: params.workspaceId || 'default',
|
|
6183
7160
|
query: params.query,
|
|
@@ -6192,11 +7169,14 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
|
|
|
6192
7169
|
const mcpBase = `http://localhost:5056`;
|
|
6193
7170
|
const res = await fetch(`${mcpBase}/mcp/execute-command`, {
|
|
6194
7171
|
method: 'POST',
|
|
6195
|
-
headers: { 'Content-Type': 'application/json' },
|
|
7172
|
+
headers: { 'Content-Type': 'application/json', ...hopHeaders() },
|
|
6196
7173
|
body: JSON.stringify({
|
|
6197
7174
|
workspaceId: params.workspaceId || 'default',
|
|
6198
7175
|
command: params.command,
|
|
6199
|
-
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 } : {}),
|
|
6200
7180
|
})
|
|
6201
7181
|
});
|
|
6202
7182
|
return await res.json();
|
|
@@ -6206,7 +7186,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
|
|
|
6206
7186
|
const mcpBase = `http://localhost:5056`;
|
|
6207
7187
|
const res = await fetch(`${mcpBase}/mcp/project-info`, {
|
|
6208
7188
|
method: 'POST',
|
|
6209
|
-
headers: { 'Content-Type': 'application/json' },
|
|
7189
|
+
headers: { 'Content-Type': 'application/json', ...hopHeaders() },
|
|
6210
7190
|
body: JSON.stringify({
|
|
6211
7191
|
workspaceId: params.workspaceId || 'default'
|
|
6212
7192
|
})
|
|
@@ -6218,7 +7198,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
|
|
|
6218
7198
|
const mcpBase = `http://localhost:5056`;
|
|
6219
7199
|
const res = await fetch(`${mcpBase}/mcp/apply-patch`, {
|
|
6220
7200
|
method: 'POST',
|
|
6221
|
-
headers: { 'Content-Type': 'application/json' },
|
|
7201
|
+
headers: { 'Content-Type': 'application/json', ...hopHeaders() },
|
|
6222
7202
|
body: JSON.stringify({
|
|
6223
7203
|
workspaceId: params.workspaceId || 'default',
|
|
6224
7204
|
diff: params.diff
|
|
@@ -6236,7 +7216,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
|
|
|
6236
7216
|
}
|
|
6237
7217
|
const res = await fetch(`${mcpBase}/mcp/open`, {
|
|
6238
7218
|
method: 'POST',
|
|
6239
|
-
headers: { 'Content-Type': 'application/json' },
|
|
7219
|
+
headers: { 'Content-Type': 'application/json', ...hopHeaders() },
|
|
6240
7220
|
body: JSON.stringify({
|
|
6241
7221
|
workspaceId: params.workspaceId || 'default',
|
|
6242
7222
|
root: params.root
|
|
@@ -6250,8 +7230,22 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
|
|
|
6250
7230
|
}
|
|
6251
7231
|
}
|
|
6252
7232
|
|
|
6253
|
-
process.on('SIGINT', () => {
|
|
6254
|
-
|
|
7233
|
+
process.on('SIGINT', async () => {
|
|
7234
|
+
isShuttingDown = true;
|
|
7235
|
+
if (ws) ws.close();
|
|
7236
|
+
// Phase 2: graceful shutdown of legacy connectors (AC-11; bounded to 5 s).
|
|
7237
|
+
try { await stopAll(_legacyAdapters, 5000); } catch (_) { /* never block exit */ }
|
|
7238
|
+
// Preserve existing process model: signal trap does NOT call process.exit();
|
|
7239
|
+
// Node exits naturally once the event loop drains.
|
|
7240
|
+
});
|
|
7241
|
+
process.on('SIGTERM', async () => {
|
|
7242
|
+
isShuttingDown = true;
|
|
7243
|
+
if (ws) ws.close();
|
|
7244
|
+
// Phase 2: graceful shutdown of legacy connectors (AC-11; bounded to 5 s).
|
|
7245
|
+
try { await stopAll(_legacyAdapters, 5000); } catch (_) { /* never block exit */ }
|
|
7246
|
+
// Preserve existing process model: signal trap does NOT call process.exit();
|
|
7247
|
+
// Node exits naturally once the event loop drains.
|
|
7248
|
+
});
|
|
6255
7249
|
connect();
|
|
6256
7250
|
}
|
|
6257
7251
|
|
|
@@ -6264,13 +7258,27 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
|
|
|
6264
7258
|
* POST /setup/run-command
|
|
6265
7259
|
* Run any shell command on the user's machine.
|
|
6266
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
|
+
|
|
6267
7271
|
app.post("/setup/run-command", async (req, res) => {
|
|
7272
|
+
if (!setupCommandsEnabled(res)) return;
|
|
6268
7273
|
const { command, description, workingDir } = req.body || {};
|
|
6269
7274
|
if (!command) return res.status(400).json({ ok: false, error: "command is required" });
|
|
6270
7275
|
|
|
6271
|
-
const
|
|
6272
|
-
|
|
6273
|
-
|
|
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));
|
|
6274
7282
|
}
|
|
6275
7283
|
|
|
6276
7284
|
console.log(`[SetupAgent] Running: ${command.substring(0, 100)}`);
|
|
@@ -6282,7 +7290,7 @@ app.post("/setup/run-command", async (req, res) => {
|
|
|
6282
7290
|
const cwd = workingDir || WORKSPACE_ROOT || process.env.HOME || process.cwd();
|
|
6283
7291
|
const result = await execAsync(command, {
|
|
6284
7292
|
cwd, timeout: 60000, maxBuffer: 2 * 1024 * 1024,
|
|
6285
|
-
env:
|
|
7293
|
+
env: sanitizedChildEnv(process.env, { FORCE_COLOR: '0' }), shell: true
|
|
6286
7294
|
});
|
|
6287
7295
|
res.json({ ok: true, stdout: result.stdout, stderr: result.stderr, exitCode: 0, command });
|
|
6288
7296
|
} catch (err) {
|
|
@@ -6318,6 +7326,8 @@ app.post("/setup/check-ports", async (req, res) => {
|
|
|
6318
7326
|
app.get("/setup/check-tool", async (req, res) => {
|
|
6319
7327
|
const { name } = req.query;
|
|
6320
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" });
|
|
6321
7331
|
|
|
6322
7332
|
const { exec: execCb } = await import('child_process');
|
|
6323
7333
|
const { promisify } = await import('util');
|
|
@@ -6340,8 +7350,10 @@ app.get("/setup/check-tool", async (req, res) => {
|
|
|
6340
7350
|
* Auto-install a CLI tool using the available package manager.
|
|
6341
7351
|
*/
|
|
6342
7352
|
app.post("/setup/install-tool", async (req, res) => {
|
|
7353
|
+
if (!setupCommandsEnabled(res)) return;
|
|
6343
7354
|
const { tool } = req.body || {};
|
|
6344
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" });
|
|
6345
7357
|
|
|
6346
7358
|
const { exec: execCb } = await import('child_process');
|
|
6347
7359
|
const { promisify } = await import('util');
|
|
@@ -6366,16 +7378,23 @@ app.post("/setup/install-tool", async (req, res) => {
|
|
|
6366
7378
|
* Run an auth command (gcloud, az login, gh auth) opening browser on user's machine.
|
|
6367
7379
|
*/
|
|
6368
7380
|
app.post("/setup/run-auth-command", async (req, res) => {
|
|
7381
|
+
if (!setupCommandsEnabled(res)) return;
|
|
6369
7382
|
const { command, session_id } = req.body || {};
|
|
6370
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 });
|
|
6371
7387
|
|
|
6372
|
-
|
|
6373
|
-
|
|
6374
|
-
|
|
6375
|
-
|
|
6376
|
-
|
|
6377
|
-
|
|
6378
|
-
|
|
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
|
+
}
|
|
6379
7398
|
|
|
6380
7399
|
res.json({ ok: true, status: "started", session_id, message: "Auth command started, browser should open" });
|
|
6381
7400
|
});
|
|
@@ -6855,6 +7874,14 @@ app.delete("/workspace/vibe/tunnel/:sessionId", async (req, res) => {
|
|
|
6855
7874
|
// ============================================
|
|
6856
7875
|
registerVercelRoutes(app);
|
|
6857
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
|
+
|
|
6858
7885
|
// ============================================
|
|
6859
7886
|
// LIVE PREVIEW — iOS / Android / Web
|
|
6860
7887
|
// ============================================
|
|
@@ -7039,8 +8066,27 @@ function buildPhoneFrame(body, platform) {
|
|
|
7039
8066
|
// START SERVER
|
|
7040
8067
|
|
|
7041
8068
|
// ============================================
|
|
7042
|
-
|
|
7043
|
-
|
|
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}`);
|
|
7044
8090
|
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
|
7045
8091
|
console.log(`Process Manager initialized`);
|
|
7046
8092
|
console.log(`Backup system ready (max: ${MAX_BACKUPS} backups)`);
|
|
@@ -7059,6 +8105,13 @@ app.listen(PORT, '0.0.0.0', async () => {
|
|
|
7059
8105
|
|
|
7060
8106
|
const gwUrl = cfg.gatewayUrl || process.env.GATEWAY_URL;
|
|
7061
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;
|
|
7062
8115
|
let tenantId = cfg.tenantId || process.env.TENANT_ID;
|
|
7063
8116
|
if (!tenantId && apiKey) {
|
|
7064
8117
|
try {
|
|
@@ -7068,8 +8121,32 @@ app.listen(PORT, '0.0.0.0', async () => {
|
|
|
7068
8121
|
}
|
|
7069
8122
|
|
|
7070
8123
|
if (process.env.DISABLE_WS_TUNNEL !== 'true') {
|
|
7071
|
-
startWebSocketTunnel(PORT, gwUrl,
|
|
8124
|
+
startWebSocketTunnel(PORT, gwUrl, wsToken, tenantId);
|
|
7072
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
|
+
})();
|
|
7073
8150
|
});
|
|
7074
8151
|
|
|
7075
8152
|
// Start MCP HTTP Bridge em paralelo (port 5056)
|