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