Files
ECC/tests/docker/plugin-setup-harness.test.js
T
haelyraandGitHub d791457aca feat(docker): add hardened CLI test harness (#2625)
* feat(install): add hardened Docker test harness

* feat(docker): complete isolated CLI session lifecycle

* fix(docker): exercise packed public CLI offline

* fix(docker): close hardened harness review gaps

* test(docker): bound harness subprocesses
2026-08-06 16:52:39 -04:00

421 lines
14 KiB
JavaScript

'use strict';
const assert = require('assert');
const { spawnSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const repoRoot = path.join(__dirname, '..', '..');
const harnessRoot = path.join(repoRoot, 'docker', 'plugin-setup');
const SUBPROCESS_TIMEOUT_MS = 30_000;
const files = {
ci: path.join(repoRoot, '.github', 'workflows', 'ci.yml'),
compose: path.join(harnessRoot, 'compose.yaml'),
dockerfile: path.join(harnessRoot, 'Dockerfile'),
fixtureProject: path.join(
repoRoot,
'tests',
'fixtures',
'docker-plugin-project',
'package.json'
),
fixtureRunner: path.join(harnessRoot, 'run-fixture-tests.sh'),
interactivePlan: path.join(harnessRoot, 'interactive-plan.js'),
packageJson: path.join(repoRoot, 'package.json'),
packedCliPreparer: path.join(harnessRoot, 'prepare-packed-cli.js'),
platformRunner: path.join(harnessRoot, 'run-platform-tests.js'),
planValidator: path.join(harnessRoot, 'verify-install-plan.js'),
projectDirResolver: path.join(harnessRoot, 'resolve-project-dir.js'),
realRunner: path.join(harnessRoot, 'run-real-cli.sh'),
};
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(` ✓ ${name}`);
passed += 1;
} catch (error) {
console.log(` ✗ ${name}`);
console.log(` Error: ${error.message}`);
failed += 1;
}
}
function read(filePath) {
return fs.readFileSync(filePath, 'utf8');
}
function runNode(argv, options = {}) {
const result = spawnSync(process.execPath, argv, {
...options,
shell: false,
timeout: SUBPROCESS_TIMEOUT_MS,
});
assert.ifError(result.error);
return result;
}
console.log('\n=== Docker plugin setup harness tests ===\n');
test('ships the focused Docker harness and default fixture project', () => {
for (const filePath of Object.values(files)) {
assert.ok(
fs.existsSync(filePath),
`Missing ${path.relative(repoRoot, filePath)}`
);
}
});
test('builds pinned Debian and Ubuntu images as a non-root user', () => {
const dockerfile = read(files.dockerfile);
const compose = read(files.compose);
assert.match(dockerfile, /node:22-bookworm-slim@sha256:[a-f0-9]{64}/);
assert.match(dockerfile, /ARG OS_IMAGE=/);
assert.match(dockerfile, /FROM \$\{NODE_IMAGE\} AS node-runtime/);
assert.match(dockerfile, /FROM \$\{OS_IMAGE\}/);
assert.match(dockerfile, /COPY --from=node-runtime \/usr\/local\/ \/usr\/local\//);
assert.match(dockerfile, /ARG CLAUDE_CODE_VERSION=\d+\.\d+\.\d+/);
assert.match(dockerfile, /@anthropic-ai\/claude-code@\$\{CLAUDE_CODE_VERSION\}/);
assert.match(dockerfile, /@iarna\/toml@2\.2\.5/);
assert.match(dockerfile, /ajv@8\.20\.0/);
assert.match(dockerfile, /sql\.js@1\.14\.1/);
assert.match(dockerfile, /--ignore-scripts/);
assert.match(
dockerfile,
/@anthropic-ai\/claude-code\/install\.cjs/
);
assert.match(dockerfile, /ENV DISABLE_AUTOUPDATER=1/);
assert.match(dockerfile, /ENV HOME=\/tmp\/ecc-home/);
assert.match(dockerfile, /ENV NODE_PATH=\/usr\/local\/lib\/node_modules/);
assert.match(dockerfile, /chown 1000:1000 \/workspace/);
assert.match(dockerfile, /USER 1000:1000/);
assert.doesNotMatch(dockerfile, /:latest/);
assert.match(compose, /image:\s*ecc-plugin-setup:debian/);
assert.match(compose, /image:\s*ecc-plugin-setup:ubuntu/);
assert.match(compose, /ubuntu:24\.04@sha256:[a-f0-9]{64}/);
assert.match(compose, /real-cli-ubuntu:/);
assert.match(
compose,
/fixture-tests:[\s\S]*?user:\s*["']1000:1000["']/
);
assert.strictEqual(
(compose.match(/node:22-bookworm-slim@sha256:[a-f0-9]{64}/g) || []).length,
1,
'The pinned Node image must have one source of truth in Compose'
);
assert.match(compose, /x-node-image:\s*&node-image/);
assert.match(compose, /image:\s*\*node-image/);
assert.match(compose, /NODE_IMAGE:\s*\*node-image/);
assert.match(compose, /OS_IMAGE:\s*\*node-image/);
});
test('keeps checkout and source project read-only with hardened defaults', () => {
const compose = read(files.compose);
assert.match(compose, /network_mode:\s*none/);
assert.match(compose, /x-real-cli:[\s\S]*?network_mode:\s*none[\s\S]*?services:/);
assert.match(
compose,
/real-cli-networked:[\s\S]*?profiles:[\s\S]*?-\s*networked[\s\S]*?network_mode:\s*default/
);
assert.match(compose, /read_only:\s*true/);
assert.match(compose, /no-new-privileges:true/);
assert.match(compose, /cap_drop:\s*\n\s*-\s*ALL/);
assert.match(compose, /pids_limit:\s*256/);
assert.match(compose, /target:\s*\/ecc\s*\n\s*read_only:\s*true/);
assert.match(compose, /target:\s*\/source-project\s*\n\s*read_only:\s*true/);
assert.match(compose, /CLAUDE_CONFIG_DIR:\s*\/tmp\/ecc-claude-config/);
assert.match(
compose,
/\/tmp:rw,nosuid,nodev,exec,size=\$\{ECC_TMPFS_SIZE:-2g\},uid=1000,gid=1000,mode=0700/
);
assert.match(
compose,
/\/workspace:rw,nosuid,nodev,noexec,size=\$\{ECC_WORKSPACE_SIZE:-1g\},uid=1000,gid=1000,mode=0700/
);
assert.match(compose, /NPM_CONFIG_CACHE:\s*\/tmp\/npm-cache/);
assert.doesNotMatch(
compose,
/ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN|env_file:/
);
});
test('real runner copies into tmpfs and exposes only explicit safe modes', () => {
const runner = read(files.realRunner);
assert.match(runner, /ECC_PROJECT_DIR:-\/workspace\/project/);
assert.match(runner, /mkdir -p "\$HOME" "\$CLAUDE_CONFIG_DIR" "\$NPM_CONFIG_CACHE"/);
assert.match(runner, /dry-run\|install\|plugin\|shell/);
assert.match(runner, /--target claude-project/);
assert.match(runner, /--dry-run/);
assert.match(runner, /verify-install-plan\.js.*--dry-run/);
assert.match(runner, /resolve-project-dir\.js/);
assert.match(
runner,
/project_dir="\$\([\s\S]*?resolve-project-dir\.js[\s\S]*?\)"\s*\nreadonly project_dir/
);
assert.doesNotMatch(runner, /readonly project_dir="\$\(/);
assert.match(runner, /prepare-packed-cli\.js/);
assert.match(runner, /run_ecc install/);
assert.match(runner, /run_ecc list-installed --json/);
assert.match(runner, /run_ecc doctor --target claude-project/);
assert.match(runner, /\[\[ -e "\$project_dir\/\.claude" \]\]/);
assert.doesNotMatch(
runner,
/scripts\/ecc\.js" setup|--move-scope|\bmigrate\b/
);
assert.doesNotMatch(runner, /scripts\/ecc\.js" install/);
assert.doesNotMatch(runner, /\beval\b|rm\s+-rf/);
});
test('prepares a local npm artifact through the confined public bin contract', () => {
const preparer = read(files.packedCliPreparer);
assert.match(preparer, /spawnSync\(executable, argv/);
assert.match(preparer, /run\(['"]npm['"]/);
assert.match(preparer, /['"]pack['"]/);
assert.match(preparer, /['"]--ignore-scripts['"]/);
assert.match(preparer, /npm_config_offline:\s*['"]true['"]/);
assert.match(preparer, /run\(['"]tar['"]/);
assert.match(preparer, /shell:\s*false/g);
assert.match(
preparer,
/const CHILD_PROCESS_TIMEOUT_MS\s*=\s*5 \* 60 \* 1000;/
);
assert.match(preparer, /timeout:\s*CHILD_PROCESS_TIMEOUT_MS/);
assert.doesNotMatch(preparer, /execSync\(|\beval\b/);
const { validatePackedPackage } = require(files.packedCliPreparer);
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-packed-cli-'));
function createFixture(name, options = {}) {
const packageRoot = path.join(fixtureRoot, name);
fs.mkdirSync(path.join(packageRoot, 'scripts'), { recursive: true });
fs.mkdirSync(path.join(packageRoot, 'manifests'), { recursive: true });
fs.writeFileSync(
path.join(packageRoot, 'package.json'),
JSON.stringify({
name: options.packageName || 'ecc-universal',
version: '2.1.0',
bin: options.bin === undefined ? { ecc: 'scripts/ecc.js' } : options.bin,
})
);
fs.writeFileSync(path.join(packageRoot, 'scripts', 'ecc.js'), '#!/usr/bin/env node\n');
fs.chmodSync(path.join(packageRoot, 'scripts', 'ecc.js'), 0o755);
for (const manifest of [
'install-components.json',
'install-modules.json',
'install-profiles.json',
]) {
if (manifest !== options.omitManifest) {
fs.writeFileSync(path.join(packageRoot, 'manifests', manifest), '{}\n');
}
}
return packageRoot;
}
try {
const validRoot = createFixture('valid');
assert.strictEqual(
validatePackedPackage(validRoot),
path.join(validRoot, 'scripts', 'ecc.js')
);
for (const [name, options, pattern] of [
['wrong-name', { packageName: 'not-ecc' }, /package name/i],
['missing-bin', { bin: {} }, /bin\.ecc/i],
['escaping-bin', { bin: { ecc: '../escape.js' } }, /bin\.ecc/i],
['missing-manifest', { omitManifest: 'install-profiles.json' }, /missing/i],
]) {
assert.throws(() => validatePackedPackage(createFixture(name, options)), pattern);
}
} finally {
fs.rmSync(fixtureRoot, { recursive: true, force: true });
}
});
test('normalizes the isolated project path before enforcing workspace containment', () => {
const valid = runNode([
files.projectDirResolver,
'/workspace/nested/../project',
], { encoding: 'utf8' });
assert.strictEqual(valid.status, 0, valid.stderr);
assert.strictEqual(valid.stdout.trim(), '/workspace/project');
for (const candidate of [
'/workspace',
'/workspace/../tmp/project',
'/tmp/project',
'workspace/project',
]) {
const invalid = runNode([
files.projectDirResolver,
candidate,
], { encoding: 'utf8' });
assert.strictEqual(invalid.status, 2, `${candidate}: ${invalid.stderr}`);
assert.match(invalid.stderr, /within \/workspace/i);
}
});
test('fixture runner delegates to the cross-platform test entry point', () => {
const runner = read(files.fixtureRunner);
assert.match(runner, /id -u/);
assert.match(runner, /id -g/);
assert.match(runner, /must run as uid\/gid 1000:1000/i);
assert.match(
runner,
/exec node docker\/plugin-setup\/run-platform-tests\.js/
);
});
test('uses one shell-free focused runner across Linux, macOS, and Windows', () => {
const ci = read(files.ci);
const packageJson = read(files.packageJson);
const platformRunner = read(files.platformRunner);
assert.match(
ci,
/os:\s*\[ubuntu-latest,\s*windows-latest,\s*macos-latest\]/
);
assert.match(
packageJson,
/"test:plugin-setup-platform":\s*"node docker\/plugin-setup\/run-platform-tests\.js"/
);
assert.match(platformRunner, /spawnSync\(/);
assert.match(platformRunner, /shell:\s*false/);
assert.match(
platformRunner,
/const CHILD_PROCESS_TIMEOUT_MS\s*=\s*5 \* 60 \* 1000;/
);
assert.match(platformRunner, /timeout:\s*CHILD_PROCESS_TIMEOUT_MS/);
assert.match(platformRunner, /Object\.fromEntries\(/);
assert.match(platformRunner, /Object\.entries\(process\.env\)\.filter/);
assert.doesNotMatch(platformRunner, /delete childEnv\[/);
assert.match(platformRunner, /tests\/lib\/install-manifests\.test\.js/);
assert.match(platformRunner, /tests\/lib\/install-targets\.test\.js/);
assert.match(platformRunner, /tests\/lib\/install-executor\.test\.js/);
assert.doesNotMatch(platformRunner, /\beval\b|execSync\(/);
});
test('emits docker exec as an executable plus argv integration contract', () => {
const result = runNode([
files.interactivePlan,
'--container', 'ecc-plugin-shell',
'--workdir', '/workspace/project',
'--json',
'--',
'node',
'-p',
'process.stdin.isTTY',
], {
cwd: repoRoot,
encoding: 'utf8',
});
assert.strictEqual(result.status, 0, result.stderr);
assert.deepStrictEqual(JSON.parse(result.stdout), {
contractVersion: 1,
executable: 'docker',
argv: [
'exec',
'-it',
'-w',
'/workspace/project',
'ecc-plugin-shell',
'node',
'-p',
'process.stdin.isTTY',
],
});
});
test('keeps Docker session values as argv entries and validates boundaries', () => {
const literalArgument = '$(touch should-not-run)';
const result = runNode([
files.interactivePlan,
'--container', 'ecc.plugin-shell_1',
'--workdir', '/workspace/project with spaces',
'--json',
'--',
'printf',
'%s',
literalArgument,
], {
cwd: repoRoot,
encoding: 'utf8',
});
assert.strictEqual(result.status, 0, result.stderr);
assert.deepStrictEqual(JSON.parse(result.stdout).argv.slice(-3), [
'printf',
'%s',
literalArgument,
]);
for (const args of [
['--container', '../escape', '--json'],
['--container', 'valid-name', '--workdir', 'relative/path', '--json'],
['--container', 'valid-name', '--workdir', '/workspace/../tmp', '--json'],
]) {
const invalid = runNode([files.interactivePlan, ...args], {
cwd: repoRoot,
encoding: 'utf8',
});
assert.strictEqual(invalid.status, 2);
assert.match(invalid.stderr, /invalid/i);
}
});
test('validates dry-run target confinement and nonempty operations', () => {
const projectDir = path.join(repoRoot, 'workspace-project');
const installRoot = path.join(projectDir, '.claude');
const safePlan = {
dryRun: true,
plan: {
target: 'claude-project',
installRoot,
operations: [
{ destinationPath: path.join(installRoot, 'rules', 'ecc', 'base.md') },
],
},
};
const safe = runNode(
[files.planValidator, projectDir, '--dry-run'],
{ encoding: 'utf8', input: JSON.stringify(safePlan) }
);
assert.strictEqual(safe.status, 0, safe.stderr);
const unsafePlan = {
...safePlan,
plan: {
...safePlan.plan,
operations: [{ destinationPath: '/tmp/escape.md' }],
},
};
const unsafe = runNode(
[files.planValidator, projectDir, '--dry-run'],
{ encoding: 'utf8', input: JSON.stringify(unsafePlan) }
);
assert.strictEqual(unsafe.status, 1);
assert.match(unsafe.stderr, /outside/i);
for (const installRootValue of [undefined, 42, { path: installRoot }]) {
const invalidRootPlan = {
...safePlan,
plan: {
...safePlan.plan,
installRoot: installRootValue,
},
};
const invalidRoot = runNode(
[files.planValidator, projectDir, '--dry-run'],
{ encoding: 'utf8', input: JSON.stringify(invalidRootPlan) }
);
assert.strictEqual(invalidRoot.status, 1);
assert.match(invalidRoot.stderr, /install root is not confined/i);
assert.doesNotMatch(invalidRoot.stderr, /ERR_INVALID_ARG_TYPE|TypeError/);
}
});
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);