mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-17 21:15:40 +02:00
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
This commit is contained in:
@@ -0,0 +1,44 @@
|
|||||||
|
ARG NODE_IMAGE=node:22-bookworm-slim@sha256:6c74791e557ce11fc957704f6d4fe134a7bc8d6f5ca4403205b2966bd488f6b3
|
||||||
|
ARG OS_IMAGE=node:22-bookworm-slim@sha256:6c74791e557ce11fc957704f6d4fe134a7bc8d6f5ca4403205b2966bd488f6b3
|
||||||
|
|
||||||
|
FROM ${NODE_IMAGE} AS node-runtime
|
||||||
|
FROM ${OS_IMAGE}
|
||||||
|
|
||||||
|
ARG DISTRO=debian
|
||||||
|
ARG CLAUDE_CODE_VERSION=2.1.220
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install --yes --no-install-recommends \
|
||||||
|
bash \
|
||||||
|
ca-certificates \
|
||||||
|
git \
|
||||||
|
libatomic1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=node-runtime /usr/local/ /usr/local/
|
||||||
|
|
||||||
|
RUN getent passwd 1000 >/dev/null \
|
||||||
|
&& getent group 1000 >/dev/null
|
||||||
|
|
||||||
|
RUN npm install --global --include=optional --ignore-scripts \
|
||||||
|
"@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
|
||||||
|
"@iarna/toml@2.2.5" \
|
||||||
|
"ajv@8.20.0" \
|
||||||
|
"sql.js@1.14.1" \
|
||||||
|
&& global_node_modules="$(npm root --global)" \
|
||||||
|
&& node "${global_node_modules}/@anthropic-ai/claude-code/install.cjs" \
|
||||||
|
&& npm cache clean --force \
|
||||||
|
&& claude --version
|
||||||
|
|
||||||
|
RUN mkdir -p /workspace \
|
||||||
|
&& chown 1000:1000 /workspace
|
||||||
|
|
||||||
|
ENV CLAUDE_CONFIG_DIR=/tmp/ecc-claude-config
|
||||||
|
ENV DISABLE_AUTOUPDATER=1
|
||||||
|
ENV HOME=/tmp/ecc-home
|
||||||
|
ENV NODE_PATH=/usr/local/lib/node_modules
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
USER 1000:1000
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.title="ECC plugin setup test (${DISTRO})"
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
name: ecc-plugin-setup-test
|
||||||
|
|
||||||
|
x-node-image: &node-image node:22-bookworm-slim@sha256:6c74791e557ce11fc957704f6d4fe134a7bc8d6f5ca4403205b2966bd488f6b3
|
||||||
|
|
||||||
|
x-real-cli: &real-cli
|
||||||
|
working_dir: /workspace
|
||||||
|
network_mode: none
|
||||||
|
read_only: true
|
||||||
|
pids_limit: 256
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:rw,nosuid,nodev,exec,size=${ECC_TMPFS_SIZE:-2g},uid=1000,gid=1000,mode=0700
|
||||||
|
- /workspace:rw,nosuid,nodev,noexec,size=${ECC_WORKSPACE_SIZE:-1g},uid=1000,gid=1000,mode=0700
|
||||||
|
environment:
|
||||||
|
CLAUDE_CONFIG_DIR: /tmp/ecc-claude-config
|
||||||
|
DISABLE_AUTOUPDATER: "1"
|
||||||
|
HOME: /tmp/ecc-home
|
||||||
|
NPM_CONFIG_CACHE: /tmp/npm-cache
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ../..
|
||||||
|
target: /ecc
|
||||||
|
read_only: true
|
||||||
|
- type: bind
|
||||||
|
source: "${TEST_PROJECT:-../../tests/fixtures/docker-plugin-project}"
|
||||||
|
target: /source-project
|
||||||
|
read_only: true
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
entrypoint:
|
||||||
|
- /bin/bash
|
||||||
|
- /ecc/docker/plugin-setup/run-real-cli.sh
|
||||||
|
command:
|
||||||
|
- dry-run
|
||||||
|
|
||||||
|
services:
|
||||||
|
fixture-tests:
|
||||||
|
image: *node-image
|
||||||
|
working_dir: /ecc
|
||||||
|
user: "1000:1000"
|
||||||
|
network_mode: none
|
||||||
|
read_only: true
|
||||||
|
pids_limit: 256
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:rw,nosuid,nodev,exec,size=256m
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ../..
|
||||||
|
target: /ecc
|
||||||
|
read_only: true
|
||||||
|
entrypoint:
|
||||||
|
- /bin/bash
|
||||||
|
- /ecc/docker/plugin-setup/run-fixture-tests.sh
|
||||||
|
|
||||||
|
real-cli:
|
||||||
|
<<: *real-cli
|
||||||
|
image: ecc-plugin-setup:debian
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
NODE_IMAGE: *node-image
|
||||||
|
OS_IMAGE: *node-image
|
||||||
|
DISTRO: debian
|
||||||
|
CLAUDE_CODE_VERSION: 2.1.220
|
||||||
|
|
||||||
|
real-cli-networked:
|
||||||
|
<<: *real-cli
|
||||||
|
profiles:
|
||||||
|
- networked
|
||||||
|
network_mode: default
|
||||||
|
image: ecc-plugin-setup:debian
|
||||||
|
|
||||||
|
real-cli-ubuntu:
|
||||||
|
<<: *real-cli
|
||||||
|
image: ecc-plugin-setup:ubuntu
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
NODE_IMAGE: *node-image
|
||||||
|
OS_IMAGE: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90
|
||||||
|
DISTRO: ubuntu
|
||||||
|
CLAUDE_CODE_VERSION: 2.1.220
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const usage = `Usage: node docker/plugin-setup/interactive-plan.js [options] [-- command ...]
|
||||||
|
|
||||||
|
Emit the Docker side of the terminal-opener executable-plus-argv contract.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--container <name> Named running container (default: ecc-plugin-shell).
|
||||||
|
--workdir <path> Absolute container working directory (default: /workspace/project).
|
||||||
|
--json Emit compact JSON.
|
||||||
|
--help, -h Show this help.
|
||||||
|
-- command ... Interactive command (default: bash).
|
||||||
|
`;
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.exitCode = 2;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readValue(argv, index, option) {
|
||||||
|
const value = argv[index + 1];
|
||||||
|
if (!value || value === '--') {
|
||||||
|
fail(`Invalid ${option}: expected a value.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
let container = 'ecc-plugin-shell';
|
||||||
|
let workdir = '/workspace/project';
|
||||||
|
let json = false;
|
||||||
|
let command = ['bash'];
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const argument = argv[index];
|
||||||
|
if (argument === '--') {
|
||||||
|
command = argv.slice(index + 1);
|
||||||
|
if (command.length === 0) {
|
||||||
|
fail('Invalid command: expected at least one argv entry after --.');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (argument === '--container') {
|
||||||
|
container = readValue(argv, index, '--container');
|
||||||
|
index += 1;
|
||||||
|
} else if (argument === '--workdir') {
|
||||||
|
workdir = readValue(argv, index, '--workdir');
|
||||||
|
index += 1;
|
||||||
|
} else if (argument === '--json') {
|
||||||
|
json = true;
|
||||||
|
} else if (argument === '--help' || argument === '-h') {
|
||||||
|
return { help: true };
|
||||||
|
} else {
|
||||||
|
fail(`Invalid option: ${argument}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (container.length > 128 || !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(container)) {
|
||||||
|
fail('Invalid container name. Use Docker name characters only.');
|
||||||
|
}
|
||||||
|
const normalizedWorkdir = path.posix.normalize(workdir);
|
||||||
|
if (
|
||||||
|
!path.posix.isAbsolute(workdir)
|
||||||
|
|| /[\r\n\0]/.test(workdir)
|
||||||
|
|| (
|
||||||
|
normalizedWorkdir !== '/workspace'
|
||||||
|
&& !normalizedWorkdir.startsWith('/workspace/')
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
fail('Invalid workdir. Use an absolute path within /workspace.');
|
||||||
|
}
|
||||||
|
if (command.some((entry) => entry.length === 0 || /\0/.test(entry))) {
|
||||||
|
fail('Invalid command argv entry.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { command, container, help: false, json, workdir };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlan(options) {
|
||||||
|
return {
|
||||||
|
contractVersion: 1,
|
||||||
|
executable: 'docker',
|
||||||
|
argv: [
|
||||||
|
'exec',
|
||||||
|
'-it',
|
||||||
|
'-w',
|
||||||
|
options.workdir,
|
||||||
|
options.container,
|
||||||
|
...options.command,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
try {
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
if (options.help) {
|
||||||
|
process.stdout.write(usage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const spacing = options.json ? 0 : 2;
|
||||||
|
process.stdout.write(`${JSON.stringify(buildPlan(options), null, spacing)}\n`);
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(`Error: ${error.message}\n`);
|
||||||
|
process.exitCode = error.exitCode || 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { buildPlan, parseArgs };
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const EXPECTED_NAME = 'ecc-universal';
|
||||||
|
const EXPECTED_BIN = 'scripts/ecc.js';
|
||||||
|
const CHILD_PROCESS_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
const REQUIRED_FILES = Object.freeze([
|
||||||
|
'scripts/ecc.js',
|
||||||
|
'manifests/install-components.json',
|
||||||
|
'manifests/install-modules.json',
|
||||||
|
'manifests/install-profiles.json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithin(root, candidate) {
|
||||||
|
const relative = path.relative(root, candidate);
|
||||||
|
return relative === '' || (
|
||||||
|
relative !== '..'
|
||||||
|
&& !relative.startsWith(`..${path.sep}`)
|
||||||
|
&& !path.isAbsolute(relative)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireRegularFile(packageRoot, relativePath) {
|
||||||
|
const resolvedPath = path.resolve(packageRoot, relativePath);
|
||||||
|
if (!isWithin(packageRoot, resolvedPath)) {
|
||||||
|
fail(`Package path escapes the extracted root: ${relativePath}`);
|
||||||
|
}
|
||||||
|
let file;
|
||||||
|
try {
|
||||||
|
file = fs.lstatSync(resolvedPath);
|
||||||
|
} catch {
|
||||||
|
fail(`Packed package is missing ${relativePath}.`);
|
||||||
|
}
|
||||||
|
if (!file.isFile() || file.isSymbolicLink()) {
|
||||||
|
fail(`Packed package path is not a regular file: ${relativePath}`);
|
||||||
|
}
|
||||||
|
return resolvedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePackedPackage(packageRoot) {
|
||||||
|
const resolvedRoot = path.resolve(packageRoot);
|
||||||
|
const packageJsonPath = requireRegularFile(resolvedRoot, 'package.json');
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||||
|
|
||||||
|
if (manifest.name !== EXPECTED_NAME) {
|
||||||
|
fail(`Unexpected packed package name: ${manifest.name || '<missing>'}.`);
|
||||||
|
}
|
||||||
|
if (typeof manifest.version !== 'string' || manifest.version.length === 0) {
|
||||||
|
fail('Packed package version is missing.');
|
||||||
|
}
|
||||||
|
if (!manifest.bin || manifest.bin.ecc !== EXPECTED_BIN) {
|
||||||
|
fail(`Packed package bin.ecc must map to ${EXPECTED_BIN}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const requiredFile of REQUIRED_FILES) {
|
||||||
|
requireRegularFile(resolvedRoot, requiredFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
const binTarget = path.resolve(resolvedRoot, manifest.bin.ecc);
|
||||||
|
if (!isWithin(resolvedRoot, binTarget)) {
|
||||||
|
fail('Packed package bin.ecc escapes the extracted package root.');
|
||||||
|
}
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
fs.accessSync(binTarget, fs.constants.X_OK);
|
||||||
|
}
|
||||||
|
return binTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(executable, argv, options = {}) {
|
||||||
|
const result = spawnSync(executable, argv, {
|
||||||
|
...options,
|
||||||
|
encoding: 'utf8',
|
||||||
|
shell: false,
|
||||||
|
timeout: CHILD_PROCESS_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
fail(`Unable to run ${executable}: ${result.error.message}`);
|
||||||
|
}
|
||||||
|
if (result.status !== 0) {
|
||||||
|
const detail = (result.stderr || result.stdout || '').trim();
|
||||||
|
fail(`${executable} exited with status ${result.status}${detail ? `: ${detail}` : ''}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function preparePackedCli(sourceRoot, outputRoot) {
|
||||||
|
const resolvedSource = path.resolve(sourceRoot);
|
||||||
|
const resolvedOutput = path.resolve(outputRoot);
|
||||||
|
if (resolvedSource !== '/ecc') {
|
||||||
|
fail('Package source must be the read-only /ecc checkout.');
|
||||||
|
}
|
||||||
|
if (resolvedOutput !== '/tmp' && !resolvedOutput.startsWith('/tmp/')) {
|
||||||
|
fail('Packed CLI output must remain under /tmp.');
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(resolvedOutput, { recursive: true, mode: 0o700 });
|
||||||
|
const workRoot = fs.mkdtempSync(path.join(resolvedOutput, 'artifact-'));
|
||||||
|
const childEnv = {
|
||||||
|
...process.env,
|
||||||
|
NPM_CONFIG_CACHE: '/tmp/npm-cache',
|
||||||
|
npm_config_audit: 'false',
|
||||||
|
npm_config_fund: 'false',
|
||||||
|
npm_config_ignore_scripts: 'true',
|
||||||
|
npm_config_offline: 'true',
|
||||||
|
};
|
||||||
|
const packed = run('npm', [
|
||||||
|
'pack',
|
||||||
|
resolvedSource,
|
||||||
|
'--ignore-scripts',
|
||||||
|
'--pack-destination',
|
||||||
|
workRoot,
|
||||||
|
'--json',
|
||||||
|
], { env: childEnv });
|
||||||
|
|
||||||
|
let metadata;
|
||||||
|
try {
|
||||||
|
metadata = JSON.parse(packed.stdout);
|
||||||
|
} catch (error) {
|
||||||
|
fail(`npm pack returned invalid JSON: ${error.message}`);
|
||||||
|
}
|
||||||
|
const filename = metadata?.[0]?.filename;
|
||||||
|
if (
|
||||||
|
typeof filename !== 'string'
|
||||||
|
|| path.basename(filename) !== filename
|
||||||
|
|| !filename.endsWith('.tgz')
|
||||||
|
) {
|
||||||
|
fail('npm pack did not return a confined tarball filename.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const archivePath = path.resolve(workRoot, filename);
|
||||||
|
if (!isWithin(workRoot, archivePath)) {
|
||||||
|
fail('npm pack tarball escaped the artifact directory.');
|
||||||
|
}
|
||||||
|
const extractRoot = path.join(workRoot, 'extracted');
|
||||||
|
fs.mkdirSync(extractRoot, { mode: 0o700 });
|
||||||
|
run('tar', ['-xzf', archivePath, '-C', extractRoot]);
|
||||||
|
|
||||||
|
const binTarget = validatePackedPackage(path.join(extractRoot, 'package'));
|
||||||
|
const binRoot = path.join(workRoot, 'bin');
|
||||||
|
fs.mkdirSync(binRoot, { mode: 0o700 });
|
||||||
|
const publicBin = path.join(binRoot, 'ecc');
|
||||||
|
fs.symlinkSync(binTarget, publicBin);
|
||||||
|
return publicBin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
try {
|
||||||
|
const publicBin = preparePackedCli(process.argv[2], process.argv[3]);
|
||||||
|
process.stdout.write(`${publicBin}\n`);
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(`Error: ${error.message}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) main();
|
||||||
|
|
||||||
|
module.exports = { isWithin, preparePackedCli, validatePackedPackage };
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const WORKSPACE_ROOT = '/workspace';
|
||||||
|
|
||||||
|
function resolveProjectDir(candidate) {
|
||||||
|
if (
|
||||||
|
typeof candidate !== 'string'
|
||||||
|
|| !path.posix.isAbsolute(candidate)
|
||||||
|
|| /[\0\r\n]/.test(candidate)
|
||||||
|
) {
|
||||||
|
throw new Error('ECC_PROJECT_DIR must be an absolute path within /workspace.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = path.posix.resolve(candidate);
|
||||||
|
if (resolved === WORKSPACE_ROOT || !resolved.startsWith(`${WORKSPACE_ROOT}/`)) {
|
||||||
|
throw new Error('ECC_PROJECT_DIR must be a child path within /workspace.');
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
try {
|
||||||
|
process.stdout.write(`${resolveProjectDir(process.argv[2])}\n`);
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(`Error: ${error.message}\n`);
|
||||||
|
process.exitCode = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) main();
|
||||||
|
|
||||||
|
module.exports = { resolveProjectDir };
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
readonly ECC_ROOT=/ecc
|
||||||
|
|
||||||
|
fixture_uid="$(id -u)"
|
||||||
|
readonly fixture_uid
|
||||||
|
fixture_gid="$(id -g)"
|
||||||
|
readonly fixture_gid
|
||||||
|
if [[ "$fixture_uid" != 1000 || "$fixture_gid" != 1000 ]]; then
|
||||||
|
printf 'Fixture tests must run as uid/gid 1000:1000 (got %s:%s)\n' \
|
||||||
|
"$fixture_uid" "$fixture_gid" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$ECC_ROOT"
|
||||||
|
|
||||||
|
exec node docker/plugin-setup/run-platform-tests.js
|
||||||
Executable
+46
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||||
|
const CHILD_PROCESS_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
const testFiles = [
|
||||||
|
'tests/lib/install-manifests.test.js',
|
||||||
|
'tests/lib/install-targets.test.js',
|
||||||
|
'tests/lib/install-executor.test.js',
|
||||||
|
];
|
||||||
|
const excludedGitEnvKeys = new Set([
|
||||||
|
'GIT_DIR',
|
||||||
|
'GIT_WORK_TREE',
|
||||||
|
'GIT_INDEX_FILE',
|
||||||
|
'GIT_COMMON_DIR',
|
||||||
|
'GIT_PREFIX',
|
||||||
|
]);
|
||||||
|
const childEnv = Object.fromEntries(
|
||||||
|
Object.entries(process.env).filter(([key]) => !excludedGitEnvKeys.has(key))
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Running ECC install tests on ${process.platform}/${process.arch}`);
|
||||||
|
|
||||||
|
for (const testFile of testFiles) {
|
||||||
|
const result = spawnSync(process.execPath, [path.join(repoRoot, testFile)], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
env: childEnv,
|
||||||
|
shell: false,
|
||||||
|
stdio: 'inherit',
|
||||||
|
timeout: CHILD_PROCESS_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
console.error(`Unable to run ${testFile}: ${result.error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
console.error(`${testFile} exited with status ${result.status}`);
|
||||||
|
process.exit(result.status ?? 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+126
@@ -0,0 +1,126 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
readonly ECC_ROOT=/ecc
|
||||||
|
readonly SOURCE_PROJECT=/source-project
|
||||||
|
readonly MODE="${1:-dry-run}"
|
||||||
|
readonly requested_project_dir="${ECC_PROJECT_DIR:-/workspace/project}"
|
||||||
|
|
||||||
|
NPM_CONFIG_CACHE=/tmp/npm-cache
|
||||||
|
export NPM_CONFIG_CACHE
|
||||||
|
readonly NPM_CONFIG_CACHE
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
printf '%s\n' \
|
||||||
|
'Usage: docker compose run --rm real-cli <mode>' \
|
||||||
|
'' \
|
||||||
|
'Modes:' \
|
||||||
|
' dry-run Inspect a project-local ECC install without mutation (default).' \
|
||||||
|
' install Install ECC into the isolated project copy.' \
|
||||||
|
' plugin Launch Claude with the local ECC checkout via --plugin-dir.' \
|
||||||
|
' shell Open a shell in the isolated project copy.'
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
dry-run|install|plugin|shell)
|
||||||
|
;;
|
||||||
|
help|--help|-h)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf 'Unknown mode: %s\n\n' "$MODE" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ ! -f "$ECC_ROOT/package.json" ]]; then
|
||||||
|
printf 'ECC checkout is not mounted at %s\n' "$ECC_ROOT" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ ! -d "$SOURCE_PROJECT" ]]; then
|
||||||
|
printf 'Source project is not mounted at %s\n' "$SOURCE_PROJECT" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
project_dir="$(
|
||||||
|
node "$ECC_ROOT/docker/plugin-setup/resolve-project-dir.js" \
|
||||||
|
"$requested_project_dir"
|
||||||
|
)"
|
||||||
|
readonly project_dir
|
||||||
|
|
||||||
|
mkdir -p "$HOME" "$CLAUDE_CONFIG_DIR" "$NPM_CONFIG_CACHE"
|
||||||
|
chmod 0700 "$HOME" "$CLAUDE_CONFIG_DIR" "$NPM_CONFIG_CACHE"
|
||||||
|
|
||||||
|
if [[ ! -e "$project_dir" ]]; then
|
||||||
|
mkdir -m 0700 "$project_dir"
|
||||||
|
cp -a "$SOURCE_PROJECT/." "$project_dir/"
|
||||||
|
elif [[ ! -d "$project_dir" ]]; then
|
||||||
|
printf 'ECC project path is not a directory: %s\n' "$project_dir" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
cd "$project_dir"
|
||||||
|
|
||||||
|
if [[ ! -d .git ]]; then
|
||||||
|
git init --quiet
|
||||||
|
fi
|
||||||
|
|
||||||
|
packed_cli=''
|
||||||
|
if [[ "$MODE" == dry-run || "$MODE" == install ]]; then
|
||||||
|
packed_cli="$(
|
||||||
|
node "$ECC_ROOT/docker/plugin-setup/prepare-packed-cli.js" \
|
||||||
|
"$ECC_ROOT" \
|
||||||
|
/tmp/ecc-packed-cli
|
||||||
|
)"
|
||||||
|
fi
|
||||||
|
readonly packed_cli
|
||||||
|
|
||||||
|
run_ecc() {
|
||||||
|
if [[ ! -x "$packed_cli" ]]; then
|
||||||
|
printf 'Packed ECC public executable is unavailable\n' >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
"$packed_cli" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_install() {
|
||||||
|
run_ecc install \
|
||||||
|
--profile core \
|
||||||
|
--target claude-project \
|
||||||
|
"$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
claude --version
|
||||||
|
printf 'Isolated project: %s\n' "$project_dir"
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
dry-run)
|
||||||
|
plan_file="$(mktemp /tmp/ecc-install-plan.XXXXXX.json)"
|
||||||
|
run_install \
|
||||||
|
--dry-run \
|
||||||
|
--json > "$plan_file"
|
||||||
|
if [[ -e "$project_dir/.claude" ]]; then
|
||||||
|
printf 'Dry run unexpectedly mutated %s/.claude\n' "$project_dir" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
node "$ECC_ROOT/docker/plugin-setup/verify-install-plan.js" "$project_dir" --dry-run < "$plan_file"
|
||||||
|
cat "$plan_file"
|
||||||
|
;;
|
||||||
|
install)
|
||||||
|
run_install --json
|
||||||
|
if [[ ! -f "$project_dir/.claude/ecc/install-state.json" ]]; then
|
||||||
|
printf 'Install did not create confined install state\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
run_install --json
|
||||||
|
run_ecc list-installed --json
|
||||||
|
run_ecc doctor --target claude-project
|
||||||
|
;;
|
||||||
|
plugin)
|
||||||
|
exec claude --plugin-dir "$ECC_ROOT"
|
||||||
|
;;
|
||||||
|
shell)
|
||||||
|
exec /bin/bash
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithin(root, candidate) {
|
||||||
|
const relative = path.relative(root, candidate);
|
||||||
|
return relative === '' || (
|
||||||
|
relative !== '..'
|
||||||
|
&& !relative.startsWith(`..${path.sep}`)
|
||||||
|
&& !path.isAbsolute(relative)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePlan(payload, projectDir, requireDryRun) {
|
||||||
|
const expectedRoot = path.resolve(projectDir, '.claude');
|
||||||
|
if (!payload || typeof payload !== 'object' || !payload.plan) {
|
||||||
|
fail('Install output is missing a plan.');
|
||||||
|
}
|
||||||
|
if (requireDryRun && payload.dryRun !== true) {
|
||||||
|
fail('Install plan did not report dryRun=true.');
|
||||||
|
}
|
||||||
|
if (payload.plan.target !== 'claude-project') {
|
||||||
|
fail('Install plan target is not claude-project.');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof payload.plan.installRoot !== 'string'
|
||||||
|
|| path.resolve(payload.plan.installRoot) !== expectedRoot
|
||||||
|
) {
|
||||||
|
fail('Install root is not confined to the isolated project.');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(payload.plan.operations) || payload.plan.operations.length === 0) {
|
||||||
|
fail('Install plan has no operations.');
|
||||||
|
}
|
||||||
|
for (const operation of payload.plan.operations) {
|
||||||
|
if (
|
||||||
|
!operation
|
||||||
|
|| typeof operation.destinationPath !== 'string'
|
||||||
|
|| !isWithin(expectedRoot, path.resolve(operation.destinationPath))
|
||||||
|
) {
|
||||||
|
fail('Install plan contains an operation outside the isolated project root.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
try {
|
||||||
|
const projectDir = process.argv[2];
|
||||||
|
if (!projectDir || !path.isAbsolute(projectDir)) {
|
||||||
|
fail('Expected an absolute isolated project path.');
|
||||||
|
}
|
||||||
|
const requireDryRun = process.argv.includes('--dry-run');
|
||||||
|
const payload = JSON.parse(fs.readFileSync(0, 'utf8'));
|
||||||
|
validatePlan(payload, projectDir, requireDryRun);
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(`Error: ${error.message}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { isWithin, validatePlan };
|
||||||
@@ -450,6 +450,7 @@
|
|||||||
"discussion:audit": "node scripts/discussion-audit.js",
|
"discussion:audit": "node scripts/discussion-audit.js",
|
||||||
"security:ioc-scan": "node scripts/ci/scan-supply-chain-iocs.js",
|
"security:ioc-scan": "node scripts/ci/scan-supply-chain-iocs.js",
|
||||||
"security:advisory-sources": "node scripts/ci/supply-chain-advisory-sources.js",
|
"security:advisory-sources": "node scripts/ci/supply-chain-advisory-sources.js",
|
||||||
|
"test:plugin-setup-platform": "node docker/plugin-setup/run-platform-tests.js",
|
||||||
"claw": "node scripts/claw.js",
|
"claw": "node scripts/claw.js",
|
||||||
"orchestrate:status": "node scripts/orchestration-status.js",
|
"orchestrate:status": "node scripts/orchestration-status.js",
|
||||||
"orchestrate:worker": "bash scripts/orchestrate-codex-worker.sh",
|
"orchestrate:worker": "bash scripts/orchestrate-codex-worker.sh",
|
||||||
|
|||||||
+166
-11
@@ -1,22 +1,12 @@
|
|||||||
---
|
---
|
||||||
name: docker-patterns
|
name: docker-patterns
|
||||||
description: Docker and Docker Compose patterns for local development, container security, networking, volume strategies, and multi-service orchestration.
|
description: Docker and Docker Compose patterns for local development, hardened CLI installer harnesses, container security, networking, volumes, and multi-service orchestration. Use when creating or reviewing Dockerfiles and Compose services, testing installers across Linux distributions, or planning accurate native macOS and Windows validation.
|
||||||
metadata:
|
|
||||||
origin: ECC
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Docker Patterns
|
# Docker Patterns
|
||||||
|
|
||||||
Docker and Docker Compose best practices for containerized development.
|
Docker and Docker Compose best practices for containerized development.
|
||||||
|
|
||||||
## When to Activate
|
|
||||||
|
|
||||||
- Setting up Docker Compose for local development
|
|
||||||
- Designing multi-container architectures
|
|
||||||
- Troubleshooting container networking or volume issues
|
|
||||||
- Reviewing Dockerfiles for security and size
|
|
||||||
- Migrating from local dev to containerized workflow
|
|
||||||
|
|
||||||
## Docker Compose for Local Development
|
## Docker Compose for Local Development
|
||||||
|
|
||||||
### Standard Web App Stack
|
### Standard Web App Stack
|
||||||
@@ -282,6 +272,171 @@ services:
|
|||||||
# ENV API_KEY=sk-proj-xxxxx # NEVER DO THIS
|
# ENV API_KEY=sk-proj-xxxxx # NEVER DO THIS
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Hardened CLI Installer Harnesses
|
||||||
|
|
||||||
|
Use containers to test installer behavior against disposable project copies without allowing the test to mutate the source checkout.
|
||||||
|
|
||||||
|
### Respect the Platform Boundary
|
||||||
|
|
||||||
|
- Run real containers for Linux distributions such as Debian and Ubuntu.
|
||||||
|
- macOS cannot run as a Docker container because Docker shares a Linux kernel. Run the same shell-free test entry point natively on macOS.
|
||||||
|
- Windows containers require a Windows Docker engine. Run platform-independent logic on a native Windows CI runner and reserve Windows containers for a Windows host.
|
||||||
|
- Keep a native Ubuntu/macOS/Windows CI matrix for host-specific paths, command shims, quoting, and filesystem behavior.
|
||||||
|
|
||||||
|
Do not claim that a Linux container validates macOS or Windows behavior.
|
||||||
|
|
||||||
|
### Enforce the Isolation Contract
|
||||||
|
|
||||||
|
- Pin base images by immutable digest and pin installed CLI versions.
|
||||||
|
- Run as a non-root numeric UID/GID when distro account names differ.
|
||||||
|
- Mount the repository and source project read-only.
|
||||||
|
- Copy the source project into a writable `tmpfs` workspace before any mutation.
|
||||||
|
- Mount `/workspace` with `noexec`, UID/GID 1000, and `mode=0700` so only the
|
||||||
|
container user can inspect project data.
|
||||||
|
- Keep npm and npx's executable cache at `NPM_CONFIG_CACHE=/tmp/npm-cache` on
|
||||||
|
the executable `/tmp` mount. Its default size is 2 GiB and can be adjusted
|
||||||
|
with `ECC_TMPFS_SIZE`; `ECC_WORKSPACE_SIZE` separately controls the private
|
||||||
|
workspace mount.
|
||||||
|
- Set `read_only: true`, `no-new-privileges:true`, `cap_drop: [ALL]`, and a finite `pids_limit`.
|
||||||
|
- Keep the default real-CLI services on `network_mode: none`. Add network access
|
||||||
|
only through a visibly named opt-in service for an authenticated provider
|
||||||
|
session; never make it an accidental environment-driven default.
|
||||||
|
- Create only the writable temporary paths the tool needs.
|
||||||
|
- Do not pass host credentials into the container by default.
|
||||||
|
- Default to a dry run and whitelist only the explicit `dry-run`, `install`,
|
||||||
|
`plugin`, and `shell` modes.
|
||||||
|
- Use argument arrays or `spawnSync(..., { shell: false })` for cross-platform runners. Never interpolate project paths into a shell command.
|
||||||
|
|
||||||
|
### Exercise the ECC Plugin Setup Harness
|
||||||
|
|
||||||
|
Use `docker/plugin-setup/compose.yaml` as the reference implementation. It provides:
|
||||||
|
|
||||||
|
- `fixture-tests` for the focused install manifest, target, and executor suite.
|
||||||
|
- `real-cli` for the pinned Debian-based generic Linux image.
|
||||||
|
- `real-cli-ubuntu` for the pinned Ubuntu image.
|
||||||
|
|
||||||
|
Validate the Compose model before building:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker/plugin-setup/compose.yaml config --quiet
|
||||||
|
```
|
||||||
|
|
||||||
|
Build both real Linux images:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker/plugin-setup/compose.yaml \
|
||||||
|
build real-cli real-cli-ubuntu
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the safe default flow in each image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -p ecc-plugin-debian-test \
|
||||||
|
-f docker/plugin-setup/compose.yaml \
|
||||||
|
run --rm -T real-cli dry-run
|
||||||
|
|
||||||
|
docker compose -p ecc-plugin-ubuntu-test \
|
||||||
|
-f docker/plugin-setup/compose.yaml \
|
||||||
|
run --rm -T real-cli-ubuntu dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
The dry run executes the current public command contract:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ecc install --profile core --target claude-project --dry-run --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Before that command runs, the container creates a locally packed npm artifact
|
||||||
|
from the read-only checkout with `npm pack --ignore-scripts`. It extracts the
|
||||||
|
self-created tarball under `/tmp`, validates the `ecc-universal` package name,
|
||||||
|
required install manifests, and the confined `package.json` `bin.ecc` mapping,
|
||||||
|
then invokes the extracted `ecc` executable. The runtime stays on
|
||||||
|
`network_mode: none`, does not execute package lifecycle scripts, and does not
|
||||||
|
rely on host `node_modules`; its exact pinned production dependencies are
|
||||||
|
already present in the image.
|
||||||
|
|
||||||
|
The harness rejects an empty plan, a non-`claude-project` target, any operation
|
||||||
|
outside `/workspace/project/.claude`, or any dry run that creates the target
|
||||||
|
directory. `install` performs the isolated apply twice, checks its managed
|
||||||
|
install state, lists the installed target, and runs `doctor`.
|
||||||
|
|
||||||
|
### Start, Open, Reconnect, and Clean Up a Named Session
|
||||||
|
|
||||||
|
Start a detached container without `--rm` so leaving a terminal does not remove
|
||||||
|
the session:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -p ecc-plugin-session \
|
||||||
|
-f docker/plugin-setup/compose.yaml \
|
||||||
|
run --detach --name ecc-plugin-shell real-cli shell
|
||||||
|
```
|
||||||
|
|
||||||
|
The container copies the read-only fixture to the stable private directory
|
||||||
|
`/workspace/project`. Confirm it is running, then emit the Docker side of the
|
||||||
|
terminal-opener v1 data contract:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker inspect --format '{{.State.Running}}' ecc-plugin-shell
|
||||||
|
node docker/plugin-setup/interactive-plan.js \
|
||||||
|
--container ecc-plugin-shell \
|
||||||
|
--workdir /workspace/project \
|
||||||
|
--json \
|
||||||
|
-- bash
|
||||||
|
```
|
||||||
|
|
||||||
|
The JSON result has exactly an `executable` and `argv` boundary (plus
|
||||||
|
`contractVersion: 1`): the executable is `docker`, and argv begins with
|
||||||
|
`exec`, `-it`, and `-w`. Pass that data to the separate terminal-opener skill
|
||||||
|
when it is installed. This Docker harness deliberately does not import a
|
||||||
|
terminal adapter, interpolate a shell command, or manage a host GUI process.
|
||||||
|
Until then, open the same PTY in the current host terminal directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it -w /workspace/project ecc-plugin-shell bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit the shell without stopping the detached container. Reconnect with the
|
||||||
|
same `docker exec -it` command. When finished, remove the exact named container
|
||||||
|
and its Compose project resources:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker rm --force ecc-plugin-shell
|
||||||
|
docker compose -p ecc-plugin-session \
|
||||||
|
-f docker/plugin-setup/compose.yaml \
|
||||||
|
down --remove-orphans
|
||||||
|
```
|
||||||
|
|
||||||
|
Host credentials are absent by default and credential directories are never
|
||||||
|
mounted. The default service also has no network access. When an authenticated
|
||||||
|
provider session genuinely needs a network, build `real-cli` first and then opt
|
||||||
|
in visibly with `docker compose --profile networked run real-cli-networked
|
||||||
|
shell`. Prefer authenticating inside that disposable session. If a CI run must
|
||||||
|
inherit a host environment credential, make that opt-in at invocation with an
|
||||||
|
explicit Compose `--env NAME` flag, understand that the value is inspectable
|
||||||
|
and can be exfiltrated for the container lifetime, and remove the exact named
|
||||||
|
container immediately after.
|
||||||
|
|
||||||
|
Run the same focused suite natively on the host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:plugin-setup-platform
|
||||||
|
```
|
||||||
|
|
||||||
|
Inspect the produced identity and environment before trusting the image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker image inspect ecc-plugin-setup:debian ecc-plugin-setup:ubuntu
|
||||||
|
```
|
||||||
|
|
||||||
|
Clean each named test project without deleting unrelated volumes or images:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -p ecc-plugin-debian-test \
|
||||||
|
-f docker/plugin-setup/compose.yaml down --remove-orphans
|
||||||
|
docker compose -p ecc-plugin-ubuntu-test \
|
||||||
|
-f docker/plugin-setup/compose.yaml down --remove-orphans
|
||||||
|
```
|
||||||
|
|
||||||
## .dockerignore
|
## .dockerignore
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
'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);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "ecc-docker-plugin-test-project",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||||
|
const skillPath = path.join(repoRoot, 'skills', 'docker-patterns', 'SKILL.md');
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const skill = fs.readFileSync(skillPath, 'utf8');
|
||||||
|
|
||||||
|
console.log('\n=== Docker patterns skill tests ===\n');
|
||||||
|
|
||||||
|
test('triggers for hardened installer and cross-platform harness work', () => {
|
||||||
|
const frontmatter = skill.match(/^---\n([\s\S]*?)\n---/);
|
||||||
|
assert.ok(frontmatter, 'SKILL.md frontmatter is missing');
|
||||||
|
assert.match(frontmatter[1], /description:.*installer/i);
|
||||||
|
assert.match(frontmatter[1], /description:.*macOS.*Windows/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('documents the ECC plugin setup harness and safe operating modes', () => {
|
||||||
|
assert.match(skill, /docker\/plugin-setup\/compose\.yaml/);
|
||||||
|
assert.match(skill, /\breal-cli\b/);
|
||||||
|
assert.match(skill, /\breal-cli-ubuntu\b/);
|
||||||
|
assert.match(skill, /\bfixture-tests\b/);
|
||||||
|
assert.match(skill, /dry-run.*install.*plugin.*shell/is);
|
||||||
|
assert.doesNotMatch(skill, /explicit modes such as.*migrate/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requires hardened ephemeral installer execution', () => {
|
||||||
|
for (const pattern of [
|
||||||
|
/read[_ -]only/i,
|
||||||
|
/tmpfs/i,
|
||||||
|
/no-new-privileges/i,
|
||||||
|
/cap_drop/i,
|
||||||
|
/pids_limit/i,
|
||||||
|
/non-root/i,
|
||||||
|
/digest/i,
|
||||||
|
/credential/i,
|
||||||
|
]) {
|
||||||
|
assert.match(skill, pattern);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('states the honest macOS and Windows validation boundary', () => {
|
||||||
|
assert.match(skill, /macOS cannot run as a Docker container/i);
|
||||||
|
assert.match(skill, /Windows containers require a Windows Docker engine/i);
|
||||||
|
assert.match(skill, /native.*ubuntu.*macOS.*Windows.*CI/is);
|
||||||
|
assert.doesNotMatch(skill, /macOS container image|simulate Windows/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('provides a repeatable build, run, inspect, and cleanup sequence', () => {
|
||||||
|
assert.match(skill, /docker compose.*build.*real-cli.*real-cli-ubuntu/is);
|
||||||
|
assert.match(skill, /docker compose.*run.*real-cli.*dry-run/is);
|
||||||
|
assert.match(skill, /docker image inspect/is);
|
||||||
|
assert.match(skill, /down --remove-orphans/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('documents the private named-container lifecycle and terminal boundary', () => {
|
||||||
|
assert.match(skill, /ECC_TMPFS_SIZE/);
|
||||||
|
assert.match(skill, /\/workspace.*mode=0700/is);
|
||||||
|
assert.match(skill, /NPM_CONFIG_CACHE.*\/tmp\/npm-cache/is);
|
||||||
|
assert.match(skill, /docker compose.*run.*--detach.*--name/is);
|
||||||
|
assert.match(skill, /interactive-plan\.js/);
|
||||||
|
assert.match(skill, /executable.*argv/is);
|
||||||
|
assert.match(skill, /docker exec -it/);
|
||||||
|
assert.match(skill, /reconnect/i);
|
||||||
|
assert.match(skill, /docker rm.*ecc-plugin-shell/is);
|
||||||
|
assert.match(skill, /host credentials.*opt-in/is);
|
||||||
|
assert.doesNotMatch(skill, /skills\/docker-patterns\/scripts\/open-interactive\.js/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requires the offline smoke to execute the locally packed public bin', () => {
|
||||||
|
assert.match(skill, /npm pack.*--ignore-scripts/is);
|
||||||
|
assert.match(skill, /package\.json.*bin\.ecc/is);
|
||||||
|
assert.match(skill, /locally packed/i);
|
||||||
|
assert.match(skill, /network_mode:\s*none/);
|
||||||
|
assert.match(skill, /does not\s+rely on.*host `node_modules`/is);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
Reference in New Issue
Block a user