diff --git a/docker/plugin-setup/Dockerfile b/docker/plugin-setup/Dockerfile new file mode 100644 index 000000000..bb8a6501a --- /dev/null +++ b/docker/plugin-setup/Dockerfile @@ -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})" diff --git a/docker/plugin-setup/compose.yaml b/docker/plugin-setup/compose.yaml new file mode 100644 index 000000000..ef19064e5 --- /dev/null +++ b/docker/plugin-setup/compose.yaml @@ -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 diff --git a/docker/plugin-setup/interactive-plan.js b/docker/plugin-setup/interactive-plan.js new file mode 100644 index 000000000..27470016f --- /dev/null +++ b/docker/plugin-setup/interactive-plan.js @@ -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 Named running container (default: ecc-plugin-shell). + --workdir 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 }; diff --git a/docker/plugin-setup/prepare-packed-cli.js b/docker/plugin-setup/prepare-packed-cli.js new file mode 100644 index 000000000..4af1c0053 --- /dev/null +++ b/docker/plugin-setup/prepare-packed-cli.js @@ -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 || ''}.`); + } + 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 }; diff --git a/docker/plugin-setup/resolve-project-dir.js b/docker/plugin-setup/resolve-project-dir.js new file mode 100644 index 000000000..96ea412cd --- /dev/null +++ b/docker/plugin-setup/resolve-project-dir.js @@ -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 }; diff --git a/docker/plugin-setup/run-fixture-tests.sh b/docker/plugin-setup/run-fixture-tests.sh new file mode 100755 index 000000000..4031abd86 --- /dev/null +++ b/docker/plugin-setup/run-fixture-tests.sh @@ -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 diff --git a/docker/plugin-setup/run-platform-tests.js b/docker/plugin-setup/run-platform-tests.js new file mode 100755 index 000000000..530683c54 --- /dev/null +++ b/docker/plugin-setup/run-platform-tests.js @@ -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); + } +} diff --git a/docker/plugin-setup/run-real-cli.sh b/docker/plugin-setup/run-real-cli.sh new file mode 100755 index 000000000..e1291836e --- /dev/null +++ b/docker/plugin-setup/run-real-cli.sh @@ -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 ' \ + '' \ + '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 diff --git a/docker/plugin-setup/verify-install-plan.js b/docker/plugin-setup/verify-install-plan.js new file mode 100644 index 000000000..f66558ded --- /dev/null +++ b/docker/plugin-setup/verify-install-plan.js @@ -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 }; diff --git a/package.json b/package.json index 5470d60f1..f12f080c9 100644 --- a/package.json +++ b/package.json @@ -450,6 +450,7 @@ "discussion:audit": "node scripts/discussion-audit.js", "security:ioc-scan": "node scripts/ci/scan-supply-chain-iocs.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", "orchestrate:status": "node scripts/orchestration-status.js", "orchestrate:worker": "bash scripts/orchestrate-codex-worker.sh", diff --git a/skills/docker-patterns/SKILL.md b/skills/docker-patterns/SKILL.md index 00bf3fd5b..e60c1d20f 100644 --- a/skills/docker-patterns/SKILL.md +++ b/skills/docker-patterns/SKILL.md @@ -1,22 +1,12 @@ --- name: docker-patterns -description: Docker and Docker Compose patterns for local development, container security, networking, volume strategies, and multi-service orchestration. -metadata: - origin: ECC +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. --- # Docker Patterns 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 ### Standard Web App Stack @@ -282,6 +272,171 @@ services: # 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 ``` diff --git a/tests/docker/plugin-setup-harness.test.js b/tests/docker/plugin-setup-harness.test.js new file mode 100644 index 000000000..3e7ee7221 --- /dev/null +++ b/tests/docker/plugin-setup-harness.test.js @@ -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); diff --git a/tests/fixtures/docker-plugin-project/package.json b/tests/fixtures/docker-plugin-project/package.json new file mode 100644 index 000000000..aa71ff72d --- /dev/null +++ b/tests/fixtures/docker-plugin-project/package.json @@ -0,0 +1,5 @@ +{ + "name": "ecc-docker-plugin-test-project", + "version": "0.0.0", + "private": true +} diff --git a/tests/skills/docker-patterns.test.js b/tests/skills/docker-patterns.test.js new file mode 100644 index 000000000..870af65f9 --- /dev/null +++ b/tests/skills/docker-patterns.test.js @@ -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);