diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..bc4d6c1 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,8 @@ +self-hosted-runner: + labels: + - feature-004-release + - debian-12 + - debian-13 + - amd64 + - rootful + - rootless diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28b4cd8..fa73201 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,8 @@ jobs: contract: runs-on: ubuntu-24.04 + env: + DOCKER_HOST: unix:///tmp/skillwire-contract-no-docker.sock steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -140,6 +142,45 @@ jobs: run: pnpm advisory:verify --release-id launch-catalog-v1 - run: git diff --check && git diff --exit-code + feature-004-offline: + runs-on: ubuntu-24.04 + services: + postgres: + image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193 + env: + POSTGRES_DB: skillwire_feature004 + POSTGRES_USER: postgres + POSTGRES_PASSWORD: skillwire-feature004-only + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d skillwire_feature004" + --health-interval 5s --health-timeout 3s --health-retries 20 + env: + DATABASE_URL: postgresql://postgres:skillwire-feature004-only@127.0.0.1:5432/skillwire_feature004 + TEST_DATABASE_URL: postgresql://postgres:skillwire-feature004-only@127.0.0.1:5432/skillwire_feature004 + SKILLWIRE_API_KEY_PEPPER: feature004-ci-only-pepper-with-at-least-thirty-two-bytes + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24.18.0 + package-manager-cache: false + - run: + corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate + - run: pnpm install --frozen-lockfile + - run: pnpm db:migrate && pnpm db:migrate + - run: pnpm test:feature-004 + - run: pnpm catalog:verify --release-id launch-catalog-v1 + - run: pnpm advisory:verify --release-id launch-catalog-v1 + - run: >- + pnpm tsx scripts/codex-adapter-package.ts validate --plugin-root + integrations/codex/skillwire-autonomous-activation + - run: git diff --check && git diff --exit-code + container: runs-on: ubuntu-24.04 steps: @@ -203,6 +244,8 @@ jobs: --key-id "$key_id" \ --token-output /run/skillwire-private/token > "$metadata_file" & admin_pid=$! + # $1/$2 belong to the bounded child sh. + # shellcheck disable=SC2016 if ! timeout 30s sh -c 'cat "$1" > "$2"' _ "$token_fifo" .secrets/api-key; then kill "$admin_pid" 2>/dev/null || true wait "$admin_pid" 2>/dev/null || true diff --git a/.github/workflows/self-hosted-release.yml b/.github/workflows/self-hosted-release.yml index 11b7627..bc27775 100644 --- a/.github/workflows/self-hosted-release.yml +++ b/.github/workflows/self-hosted-release.yml @@ -1,6 +1,7 @@ name: Self-hosted release on: + workflow_dispatch: push: tags: - "self-hosted-v*" @@ -13,7 +14,174 @@ env: NODE_VERSION: 24.18.0 jobs: + certified-matrix: + if: + startsWith(github.ref, 'refs/tags/self-hosted-v') || github.event_name == + 'workflow_dispatch' + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, debian-12, debian-13] + arch: [amd64, arm64] + docker-mode: [rootful, rootless] + runs-on: + - self-hosted + - feature-004-release + - ${{ matrix.os }} + - ${{ matrix.arch }} + - ${{ matrix.docker-mode }} + env: + PNPM_VERSION: 11.21.0 + MATRIX_OS: ${{ matrix.os }} + MATRIX_ARCH: ${{ matrix.arch }} + MATRIX_DOCKER_MODE: ${{ matrix.docker-mode }} + SKILLWIRE_GITHUB_INGESTION_ENABLED: "false" + SKILLWIRE_BLOCK_GITHUB_NETWORK: "true" + SKILLWIRE_RUN_COMPOSE_INTEGRATION: "1" + SKILLWIRE_RUN_SECRET_SERVICE_INTEGRATION: "1" + SKILLWIRE_RUN_POSTGRES_BACKUP_INTEGRATION: "1" + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 24.18.0 + cache: pnpm + - run: + corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate + - run: pnpm install --frozen-lockfile + - name: Require pinned clients and real Secret Service tooling + shell: bash + run: | + set -euo pipefail + command -v dbus-daemon + command -v dbus-send + command -v gdbus + command -v gnome-keyring-daemon + command -v secret-tool + test "$(pnpm exec codex --version)" = "codex-cli 0.147.0" + test "$(pnpm exec claude --version)" = "2.1.229 (Claude Code)" + - name: Create disposable Compose inputs + shell: bash + run: | + set -euo pipefail + umask 077 + matrix_os="${MATRIX_OS//./-}" + project="skillwire-matrix-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${matrix_os}-${MATRIX_ARCH}-${MATRIX_DOCKER_MODE}" + [[ "${project}" =~ ^[a-z0-9][a-z0-9-]+$ ]] + docker_context="$(docker context show)" + docker_host="$(docker context inspect "${docker_context}" --format '{{.Endpoints.docker.Host}}')" + [[ "${docker_host}" == unix://* ]] + resource_root="${RUNNER_TEMP}/${project}" + test ! -e "${resource_root}" + install -d -m 0700 "${resource_root}/secrets" "${resource_root}/runtime" + openssl rand -hex 32 > "${resource_root}/secrets/postgres-password" + openssl rand -hex 32 > "${resource_root}/secrets/api-key-pepper" + : > "${resource_root}/secrets/github-token" + password="$(tr -d '\n' < "${resource_root}/secrets/postgres-password")" + printf 'postgresql://skillwire:%s@postgres:5432/skillwire\n' "${password}" \ + > "${resource_root}/secrets/database-url" + { + echo "SKILLWIRE_JOB_RESOURCE_ROOT=${resource_root}" + echo "DOCKER_HOST=${docker_host}" + echo "SKILLWIRE_COMPOSE_PROJECT=${project}" + echo "SKILLWIRE_POSTGRES_VOLUME=${project}_postgres_data" + echo "SKILLWIRE_IMAGE=ghcr.io/lucenx9/skillwire@sha256:${{ '1111111111111111111111111111111111111111111111111111111111111111' }}" + echo "SKILLWIRE_POSTGRES_IMAGE=docker.io/library/postgres@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193" + echo "SKILLWIRE_DATABASE_URL_SECRET_FILE=${resource_root}/secrets/database-url" + echo "SKILLWIRE_POSTGRES_PASSWORD_SECRET_FILE=${resource_root}/secrets/postgres-password" + echo "SKILLWIRE_API_KEY_PEPPER_SECRET_FILE=${resource_root}/secrets/api-key-pepper" + echo "SKILLWIRE_GITHUB_TOKEN_SECRET_FILE=${resource_root}/secrets/github-token" + echo "SKILLWIRE_DATABASE_PASSWORD_SECRET_FILE=${resource_root}/secrets/postgres-password" + echo "SKILLWIRE_APPLICATION_PEPPER_SECRET_FILE=${resource_root}/secrets/api-key-pepper" + echo "SKILLWIRE_RUNTIME_SOCKET_DIRECTORY=${resource_root}/runtime" + echo "SKILLWIRE_RUNTIME_UID=$(id -u)" + echo "SKILLWIRE_RUNTIME_GID=$(id -g)" + } >> "${GITHUB_ENV}" + - name: Assert certified runner identity + shell: bash + run: | + set -euo pipefail + test "$(uname -m)" = "${{ matrix.arch == 'amd64' && 'x86_64' || 'aarch64' }}" + . /etc/os-release + case "${{ matrix.os }}" in + ubuntu-24.04) test "${ID}:${VERSION_ID}" = "ubuntu:24.04" ;; + debian-12) test "${ID}:${VERSION_ID}" = "debian:12" ;; + debian-13) test "${ID}:${VERSION_ID}" = "debian:13" ;; + *) exit 2 ;; + esac + docker info >/dev/null + docker_version="$(docker version --format '{{.Server.Version}}')" + compose_version="$(docker compose version --short)" + test "${docker_version}" = "29.7.2" + test "${compose_version}" = "5.4.0" + test "$(docker info --format '{{json .SecurityOptions}}' | grep -c rootless || true)" \ + ${{ matrix.docker-mode == 'rootless' && '-ge 1' || '-eq 0' }} + - name: Complete Feature 004 and unchanged Feature 001-003 gates + run: | + pnpm format:check + pnpm lint + pnpm typecheck + pnpm build + pnpm test + pnpm test:feature-004 + pnpm test:activation + pnpm test:activation-adapter + pnpm catalog:verify --release-id launch-catalog-v1 + pnpm advisory:verify --release-id launch-catalog-v1 + - name: + Compose, PostgreSQL restore, Secret Service, and lifecycle journeys + run: | + docker compose --project-name "${SKILLWIRE_COMPOSE_PROJECT}" config --quiet + docker compose --project-name "${SKILLWIRE_COMPOSE_PROJECT}" \ + -f compose.yaml -f compose.test.yaml config --quiet + docker compose --project-name "${SKILLWIRE_COMPOSE_PROJECT}" \ + -f distribution/self-hosted/compose.yaml config --quiet + pnpm vitest run tests/integration/onboarding/backup-restore-validation.test.ts --maxWorkers=1 + pnpm vitest run tests/integration/onboarding/secret-service-session.test.ts --maxWorkers=1 + pnpm vitest run tests/integration/onboarding/production-setup.test.ts --maxWorkers=1 + pnpm vitest run \ + tests/integration/onboarding/upgrade-compatible.test.ts \ + tests/integration/onboarding/upgrade-forward-only-010.test.ts \ + tests/integration/onboarding/upgrade-interruption.test.ts \ + --maxWorkers=1 + pnpm vitest run tests/e2e/self-hosted-onboarding/acceptance-scenarios.test.ts --maxWorkers=1 + - name: Verify no persistent disposable resources + if: always() + shell: bash + run: | + set -uo pipefail + cleanup_failed=0 + if [[ -n "${SKILLWIRE_COMPOSE_PROJECT:-}" ]]; then + docker compose --project-name "${SKILLWIRE_COMPOSE_PROJECT}" \ + -f distribution/self-hosted/compose.yaml \ + down --volumes || cleanup_failed=1 + if [[ -n "$(docker container ls --all --quiet --filter \ + "label=com.docker.compose.project=${SKILLWIRE_COMPOSE_PROJECT}")" ]]; then + echo "Exact disposable Compose project remains" >&2 + cleanup_failed=1 + fi + fi + if [[ -n "${SKILLWIRE_POSTGRES_VOLUME:-}" ]] && \ + docker volume inspect "${SKILLWIRE_POSTGRES_VOLUME}" >/dev/null 2>&1; then + echo "Exact disposable PostgreSQL volume remains" >&2 + cleanup_failed=1 + fi + if [[ -n "${SKILLWIRE_JOB_RESOURCE_ROOT:-}" && \ + "${SKILLWIRE_JOB_RESOURCE_ROOT}" == "${RUNNER_TEMP}/skillwire-matrix-"* ]]; then + rm -rf -- "${SKILLWIRE_JOB_RESOURCE_ROOT}" + else + echo "Disposable resource root identity is unavailable" >&2 + cleanup_failed=1 + fi + git diff --check || cleanup_failed=1 + git diff --exit-code || cleanup_failed=1 + exit "${cleanup_failed}" + build-test-sign: + needs: certified-matrix if: startsWith(github.ref, 'refs/tags/self-hosted-v') strategy: fail-fast: true @@ -182,8 +350,107 @@ jobs: if-no-files-found: error retention-days: 1 - publish: + signed-asset-matrix: needs: build-test-sign + if: startsWith(github.ref, 'refs/tags/self-hosted-v') + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, debian-12, debian-13] + arch: [amd64, arm64] + docker-mode: [rootful, rootless] + runs-on: + - self-hosted + - feature-004-release + - ${{ matrix.os }} + - ${{ matrix.arch }} + - ${{ matrix.docker-mode }} + permissions: + contents: read + env: + PNPM_VERSION: 11.21.0 + MATRIX_OS: ${{ matrix.os }} + MATRIX_ARCH: ${{ matrix.arch }} + MATRIX_DOCKER_MODE: ${{ matrix.docker-mode }} + VERSION: ${{ needs.build-test-sign.outputs.version }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + fetch-depth: 0 + persist-credentials: false + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 + with: + version: 11.21.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 24.18.0 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Assert signed-asset runner identity + shell: bash + run: | + set -euo pipefail + test "$(uname -m)" = "${{ matrix.arch == 'amd64' && 'x86_64' || 'aarch64' }}" + . /etc/os-release + case "${MATRIX_OS}" in + ubuntu-24.04) test "${ID}:${VERSION_ID}" = "ubuntu:24.04" ;; + debian-12) test "${ID}:${VERSION_ID}" = "debian:12" ;; + debian-13) test "${ID}:${VERSION_ID}" = "debian:13" ;; + *) exit 2 ;; + esac + docker_context="$(docker context show)" + docker_host="$(docker context inspect "${docker_context}" --format '{{.Endpoints.docker.Host}}')" + [[ "${docker_host}" == unix://* ]] + echo "DOCKER_HOST=${docker_host}" >> "${GITHUB_ENV}" + docker info >/dev/null + docker_version="$(docker version --format '{{.Server.Version}}')" + compose_version="$(docker compose version --short)" + test "${docker_version}" = "29.7.2" + test "${compose_version}" = "5.4.0" + test "$(docker info --format '{{json .SecurityOptions}}' | grep -c rootless || true)" \ + ${{ matrix.docker-mode == 'rootless' && '-ge 1' || '-eq 0' }} + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: self-hosted-release-${{ matrix.arch }} + path: ${{ runner.temp }}/signed-assets + - name: Acquire the independent pinned Cosign verifier + shell: bash + run: | + set -euo pipefail + case "${MATRIX_ARCH}" in + amd64) cosign_sha=4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71 ;; + arm64) cosign_sha=c5d324e091826b0d7a78eb16fef316450b4eb9aaec045611c08ba06f5e73220a ;; + *) exit 2 ;; + esac + curl --fail --location --proto '=https' --tlsv1.2 \ + --output "${RUNNER_TEMP}/cosign-independent" \ + "https://github.com/sigstore/cosign/releases/download/v${COSIGN_VERSION}/cosign-linux-${MATRIX_ARCH}" + echo "${cosign_sha} ${RUNNER_TEMP}/cosign-independent" \ + | sha256sum --check --strict + chmod 0700 "${RUNNER_TEMP}/cosign-independent" + - name: Verify and execute the exact signed quickstart + shell: bash + run: | + set -euo pipefail + base="${RUNNER_TEMP}/signed-assets/skillwire-${VERSION}-linux-${MATRIX_ARCH}" + result="$(pnpm exec tsx scripts/validate-self-hosted-quickstart.ts \ + --manifest "${base}.release.json" \ + --bundle "${base}.release.sigstore.json" \ + --archive "${base}.tar.zst" \ + --policy "${RUNNER_TEMP}/signed-assets/skillwire-trust-policy-v1.json" \ + --trusted-root "${PWD}/distribution/self-hosted/trusted-root.v1.json" \ + --cosign "${RUNNER_TEMP}/cosign-independent" \ + --architecture "${MATRIX_ARCH}" --execute)" + project="$(jq -er '.cleanupProject' <<<"${result}")" + volume="$(jq -er '.cleanupVolume' <<<"${result}")" + [[ "${project}" =~ ^skillwire-[0-9a-f]{32}$ ]] + test "${volume}" = "${project}_postgres_data" + test -z "$(docker container ls --all --quiet --filter \ + "label=com.docker.compose.project=${project}")" + ! docker volume inspect "${volume}" >/dev/null 2>&1 + + publish: + needs: [build-test-sign, signed-asset-matrix] if: github.ref == format('refs/tags/self-hosted-v{0}', needs.build-test-sign.outputs.version) @@ -210,5 +477,25 @@ jobs: test -s "${base}.release.sigstore.json" done test -s release-assets/skillwire-trust-policy-v1.json - gh release create "${GITHUB_REF_NAME}" release-assets/* \ + { + for arch in amd64 arm64; do + echo "skillwire-${VERSION}-linux-${arch}.release.json" + echo "skillwire-${VERSION}-linux-${arch}.release.sigstore.json" + echo "skillwire-${VERSION}-linux-${arch}.tar.zst" + done + echo "skillwire-trust-policy-v1.json" + } | sort > "${RUNNER_TEMP}/expected-release-assets.txt" + find release-assets -maxdepth 1 -type f -printf '%f\n' | sort \ + > "${RUNNER_TEMP}/actual-release-assets.txt" + cmp "${RUNNER_TEMP}/expected-release-assets.txt" \ + "${RUNNER_TEMP}/actual-release-assets.txt" + if gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then + echo "Release asset namespace already exists" >&2 + exit 1 + fi + mapfile -t assets < "${RUNNER_TEMP}/expected-release-assets.txt" + for index in "${!assets[@]}"; do + assets[index]="release-assets/${assets[index]}" + done + gh release create "${GITHUB_REF_NAME}" "${assets[@]}" \ --verify-tag --title "SkillWire self-hosted ${VERSION}" diff --git a/README.md b/README.md index a8267f6..c36b1c9 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,43 @@ pnpm test:evaluation PostgreSQL-backed tests use `TEST_DATABASE_URL`. CI and `compose.test.yaml` create disposable databases automatically. +## Guided self-hosted lifecycle + +The signed self-hosted distribution installs one user-owned Compose service and +optionally integrates the ordinary `codex` and `claude` executables in their +normal user profiles. It creates no wrapper command, alternate client home, +shell-startup edit, repository configuration, or local catalog-skill copy. +Verify the four sibling assets first using +[the bootstrap guide](distribution/self-hosted/README.md), then run: + +```bash +skillwire setup --clients codex,claude --preview-only --output json +skillwire setup --clients codex,claude --confirm-preview EXACT_SHA256 +skillwire status +skillwire doctor +skillwire repair --component COMPONENT --preview-only +skillwire backup --preview-only +skillwire upgrade --release /absolute/verified/release --preview-only +skillwire clients uninstall codex --preview-only +skillwire uninstall --preview-only +skillwire purge --preview-only +``` + +Every mutation requires the exact current preview hash. Default uninstall stops +owned services and removes only proven owned client deltas while retaining the +PostgreSQL volume, backups, releases, service secrets, and recovery state. +`purge` is a distinct destructive operation with an installation-bound asset +inventory and separate confirmation. See [operations](docs/operations.md) and +[release evidence](docs/self-hosted-release-evidence.md). + +The ten first-party skills are verified from the signed release and need no +GitHub token or GitHub access. `--source mattpocock/skills` and +`--source obra/superpowers` are explicit post-readiness choices. For a first +registration, pipe one separate read-only GitHub token on stdin from a protected +credential command; never place it in argv or an environment value. Imported +content remains inert, provenance-bound, classified, and quarantined until the +existing source pipeline makes it eligible. + ## Start with Docker Compose Copy the non-secret configuration and create local secret files: diff --git a/distribution/self-hosted/README.md b/distribution/self-hosted/README.md index 75b7074..8fe8a13 100644 --- a/distribution/self-hosted/README.md +++ b/distribution/self-hosted/README.md @@ -33,6 +33,15 @@ skillwire-VERSION-linux-ARCH.release.sigstore.json skillwire-trust-policy-v1.json ``` +These are the four normal sibling assets: archive, canonical manifest, one +Sigstore bundle, and versioned trust policy. Do not accept a checksum pasted in +release prose or a manifest embedded only inside the archive. A policy-rotation +release is the sole exception: when the currently trusted policy requires an +overlap quorum of two, a second sibling bundle named +`skillwire-VERSION-linux-ARCH.release.SIGNER.sigstore.json` is mandatory. The +manifest names both signer IDs and both exact bundle paths; an extra bundle is +never accepted by convention alone. + Disconnect outbound networking (or run inside an already network-isolated namespace) and invoke the independently verified Cosign directly: @@ -84,3 +93,49 @@ Only after this command reports `"verified":true` may the release directory's the first installation mutation. If the policy is stale, revoked, unknown, or has no surviving trusted signer, stop and obtain a separately authenticated policy/root update; the bootstrap never silently refreshes trust material. + +## 4. Extract and start without widening trust + +The verifier rejects absolute paths, `..`, duplicate or unlisted payload bytes, +links, devices, FIFOs, sockets, unsafe modes, mutable image references, unsafe +Compose privileges, catalog/advisory drift, and client-package drift before the +launcher is trusted. Extract only the already verified archive into a new +owner-only directory; never extract over an existing installation. Pin the +opened archive inode, recheck the signed manifest's size and SHA-256 through +that descriptor, and extract through the same descriptor so a pathname +substitution cannot change the bytes between verification and extraction: + +```sh +umask 077 +install -d -m 0700 /absolute/private/skillwire-VERSION-linux-ARCH +exec 3< skillwire-VERSION-linux-ARCH.tar.zst +test "$(stat -Lc %s /proc/self/fd/3)" = \ + "$(jq -r '.archive.size' skillwire-VERSION-linux-ARCH.release.json)" +test "$(sha256sum /proc/self/fd/3 | awk '{print $1}')" = \ + "$(jq -r '.archive.sha256' skillwire-VERSION-linux-ARCH.release.json)" +tar --use-compress-program=/usr/bin/zstd \ + --no-same-owner --no-same-permissions \ + -xf /proc/self/fd/3 \ + -C /absolute/private/skillwire-VERSION-linux-ARCH +exec 3<&- +``` + +Run the bundled launcher from that exact directory and confirm the SHA-256 +preview. Do not pipe network output to a shell, do not use `curl | sh`, and do +not substitute an unpacked file from another candidate. + +## 5. Trust refresh, rotation, and revocation + +An active policy may be replaced only by a higher policy sequence whose +transition is signed by the complete current quorum and the required distinct +new quorum. The accepted installation state records the highest policy and +release sequences. A lower sequence, an equal sequence with different bytes, a +denied signer or manifest, an expired validity window, a missing overlap bundle, +or a policy that lowers the minimum accepted release is a hard stop. + +Refresh the trusted root only from Sigstore's signed TUF metadata, compare its +media type and SHA-256 to the candidate policy, then repeat offline +verification. Revocation is deny-first: once a signer or manifest digest is +denied, neither a cached bundle nor an older policy can restore it. Keep the +preceding accepted policy and its audit identity for recovery; never edit it in +place. diff --git a/distribution/self-hosted/supported-matrix.json b/distribution/self-hosted/supported-matrix.json new file mode 100644 index 0000000..c11a643 --- /dev/null +++ b/distribution/self-hosted/supported-matrix.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": "skillwire.supported-matrix/v1", + "operatingSystems": [ + { "id": "ubuntu", "version": "24.04" }, + { "id": "debian", "version": "12" }, + { "id": "debian", "version": "13" } + ], + "architectures": ["amd64", "arm64"], + "docker": { "minimum": "29.7.2", "tested": "29.7.2" }, + "compose": { "minimum": "5.4.0", "tested": "5.4.0" }, + "postgresql": "17.10-alpine", + "node": "24.18.0", + "codex": "0.147.0", + "claude": "2.1.229", + "cosign": "3.1.3" +} diff --git a/docs/operations.md b/docs/operations.md index f33ae18..625d10a 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1,5 +1,38 @@ # Deployment and operations +## Self-hosted administrative commands + +`skillwire status` and `skillwire doctor` are read-only. `doctor` classifies +release, filesystem, Docker, PostgreSQL, migration, catalog/advisory, service +secret, credential, bridge, normal-client, MCP/plugin, source, backup, lock, and +journal state with bounded redacted evidence. `repair --component ID` consumes +the per-effect journal and mutates only an identity-matching owned component. An +ambiguous, external, symlinked, or concurrently changed target is blocked. + +Client API-key rotation is independent per client: create and verify the +replacement through the protected bridge, activate it, then revoke the old key. +Database-password and application-pepper rotation are separate maintenance +commands with no-clobber retained generations and explicit recovery boundaries. +No credential value belongs in argv, environment values, Compose output, logs, +diagnostics, backups, or reports. + +`backup` creates a PostgreSQL custom-format archive, hashes it, restores it into +a disposable PostgreSQL instance, and verifies migrations/checksums, +constraints/triggers, catalog/advisory identity, account/key state, +repository-memory counts, and readiness. An upgrade verifies the signed, +digest-pinned target before draining writers. Same-schema failure may restore +application/configuration; after forward-only migration 010, image-only rollback +is prohibited and writers remain stopped until a compatible release or the named +restore-validated backup is selected. + +Selective `clients uninstall codex|claude` removes only exact owned MCP, plugin, +marketplace, credential, and API-key state for that client. It does not affect +the other client or shared repository memory. Default `uninstall` retains +PostgreSQL, backups, service secrets, releases, trust and ownership state for a +duplicate-free reinstall. `purge` separately enumerates exact owned paths and +volumes, requires its own preview confirmation, quarantines filesystem targets +by inode before deletion, and refuses drift or ambiguous ownership. + ## Deployment sequence Migration 010 is a coordinated maintenance upgrade. It is not safe to run it diff --git a/docs/privacy.md b/docs/privacy.md index de78c10..6bf22b7 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -1,5 +1,24 @@ # Privacy, erasure, and backup boundaries +## Self-hosted credentials and local state + +The service has four distinct secret classes: PostgreSQL password, application +API-key pepper, per-client bearer keys, and an optional read-only GitHub source +token. Service secrets are independent 256-bit values in owner-only files. +Client keys prefer Linux Secret Service and otherwise use only a separately +confirmed `0600` protected-file fallback. The GitHub token has a separate +`github-source-read-only` credential identity and is never a client key. Raw +values cross process boundaries only through stdin, a private descriptor/FIFO, +or a restrictive mounted secret file—not argv or environment values. + +Installation state contains references, hashes, versions, ownership identities, +and categorical health only. Operation journals, previews, terminal output, +logs, snapshots, backup manifests, release evidence, and diagnostics must not +contain raw tokens, Authorization headers, task text, repository names or paths, +or skill/resource bodies. Normal Codex and Claude profiles keep only the +credential-free launcher command and installation/client identifiers; the bridge +resolves the protected client credential at request time. + ## Repository memory SkillWire stores only the authenticated account ID, an opaque 64-character diff --git a/docs/self-hosted-release-evidence.md b/docs/self-hosted-release-evidence.md new file mode 100644 index 0000000..2c4f1cd --- /dev/null +++ b/docs/self-hosted-release-evidence.md @@ -0,0 +1,123 @@ +# Self-hosted release evidence + +This document defines the evidence required before a public self-hosted release. +It is not itself proof that a candidate passed. A candidate is releasable only +when every required row names an immutable source commit, release manifest +SHA-256, architecture, workflow run/job URL, command, outcome, cleanup result, +and retained redacted artifact identity. + +## Certified matrix + +The claimed matrix is exactly Ubuntu 24.04, Debian 12, and Debian 13 on Linux +`amd64` and `arm64`, each with configured rootful and rootless Docker. Tool +versions are defined by `distribution/self-hosted/supported-matrix.json`: Node +24.18.0, pnpm 11.21.0, PostgreSQL 17.10, Codex 0.147.0, Claude Code 2.1.229, +Cosign 3.1.3, Docker minimum 29.7.2, and Compose minimum 5.4.0. A missing runner +or skipped real boundary is `not-certified`, never an inferred pass. + +Each matrix cell must record signed-asset verification, safe extraction, +first-party offline setup, normal-profile client lifecycle, fail-open startup, +real Secret Service, PostgreSQL backup/restore, same-schema and forward-only +upgrade, selective uninstall, retained reinstall, purge, resource cleanup, and +the 28-scenario gate. CI fixtures may prove deterministic logic but do not +replace a real Docker, PostgreSQL, client-manager, D-Bus/keyring, or +architecture boundary named by an acceptance contract. + +## Required deterministic gates + +- formatting, ESLint, strict TypeScript, build, migrations and idempotent rerun; +- unit, contract, integration, E2E, evaluation and security projects; +- `test:feature-004`, all 28 numbered scenarios, and FR-001–FR-092 traceability; +- catalog and advisory verification, Feature 003 package/integrity and + activation; +- default, test-overlay and self-hosted Compose validation; +- canonical manifest, archive inventory/extraction, Sigstore/Cosign trust, + rotation/revocation/downgrade and digest-pinned image gates; +- secret canary, symlink/containment/ownership and zero-unrelated-write scans; +- disposable quickstart cleanup and `git diff --check`. + +Automatic activation is a separate experimental evidence claim. Deterministic +client setup succeeds on the exact six-tool and scripted search/load/resource +journey even when a fresh-client automatic diagnostic observes no invocation. Do +not turn a deterministic setup pass into an autonomous-activation claim. + +## Duration and moderated usability + +The participant target of 15 minutes is informational and never a CI timeout or +pass threshold. A duration record contains only environment ID, start/end +monotonic duration, result category, source commit and manifest hash. A +moderated result uses this redacted format: + +```json +{ + "schemaVersion": "skillwire.moderated-usability/v1", + "participantId": "opaque-session-id", + "matrixCell": "ubuntu-24.04-amd64-rootless", + "sourceCommit": "40-lowercase-hex", + "manifestSha256": "64-lowercase-hex", + "completed": true, + "elapsedMilliseconds": 0, + "assistanceCategories": [], + "failureCode": null, + "credentialOrContentCaptured": false, + "cleanupVerified": true +} +``` + +No participant run is recorded for the current uncommitted candidate. Release +readiness therefore remains blocked until immutable commit-bound artifacts and +all required matrix/usability evidence exist. Residual risk must list every +gated skip, unsupported runner, environment dependency, and separately deferred +automatic-activation claim; absence of evidence is never reported as success. + +## Local uncommitted pre-release record — 2026-08-14 + +This record is diagnostic evidence for the working tree, not release +certification. The implementation has no immutable source commit, external +canonical manifest, archive digest, Sigstore bundle, or tag yet. The host is +CachyOS `x86_64`, outside the supported matrix; results MUST NOT be generalized +to Ubuntu, Debian, `arm64`, or rootless Docker. + +- Tooling: Node 24.18.0, pnpm 11.21.0, Docker Engine 29.7.2, Compose 5.4.0, + PostgreSQL 17.10-alpine, and Actionlint 1.7.12. +- Full bounded offline suite: 164 files passed, 3 files skipped; 848 tests + passed and 9 expected environment-gated tests skipped. The extra full-suite + skip was the separately gated live-GitHub smoke; GitHub remained disabled. +- Feature 004 aggregate: 72 files passed, 2 files skipped; 350 tests passed and + 8 expected environment-gated tests skipped. All 28 numbered scenarios and + FR-001 through FR-092 remained mapped to executable passing suites. +- Feature 003: activation 98/98; activation adapter 65/65; package validation + retained version 0.1.1, source commit + `7d9fd5fd130c9e66dfb739c599fd84ad9d962d5a`, and package SHA-256 + `f4e2e1cca7b4c99d41d585d2816b44b4203297ad15809e3c1b87bedb8b6e805e`. +- Real disposable boundaries: GNOME Keyring/Secret Service 4/4, including the + separate GitHub source token; PostgreSQL backup and isolated restore 9/9. A + preliminary restore rerun stopped before database creation because the first + disposable daemon exhausted its IPAM pool; the unchanged test passed on a + fresh daemon with an explicit private address pool. +- Migrations: two consecutive runs reported current; the disposable database + contained exactly 10 registered migrations through `010`. +- Static and integrity gates: Prettier, ESLint, strict TypeScript, build, + catalog, advisory chain, Feature 003 package, Actionlint, and + `git diff --check` passed. Default, test-overlay, benchmark-overlay, and + self-hosted Compose rendering passed. +- Cleanup: the disposable PostgreSQL databases, custom Docker daemons, + containers, volumes, images, networks, bridge, D-Bus/keyring provider, + sockets, profiles, and test roots were removed. No normal client profile, + desktop keyring, or shell configuration was used. During the final local suite + rerun, restoring a missing host Docker bridge restarted a pre-existing + `skillwire` Compose project for approximately two minutes. Tests continued to + use isolated Testcontainers databases and did not target that project, but + PostgreSQL startup/shutdown means byte-for-byte non-mutation of its persistent + volume cannot be certified. Docker was returned to its initial inactive, + socket-free state without deleting or inspecting that volume. + +The real signed-asset quickstart, all 12 certified matrix cells, moderated +participant target, and automatic-activation release claim were not run or +claimed. Those missing immutable and external boundaries keep T161 and public +release readiness open. Because the canonical manifest is an external release +asset, no repository metadata commit is needed after signing: first commit the +implementation, tag that exact immutable commit, and let the protected workflow +build the archive and external manifest from that tag commit before signing the +manifest. Committing generated manifest bytes back into the source tree would +create the circular identity this workflow deliberately avoids. diff --git a/package.json b/package.json index 8f1b06e..4737db6 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "test:evaluation": "vitest run --project evaluation --passWithNoTests", "test:security": "vitest run --project security --passWithNoTests", "test:activation": "vitest run tests/unit/evaluation/activation-baseline.test.ts tests/unit/evaluation/activation-corpus.test.ts tests/unit/evaluation/manual-evidence.test.ts tests/unit/transport/activation-policy.test.ts tests/unit/domain/ranking.test.ts tests/contract/cli/activation-evidence.test.ts tests/contract/mcp/activation-metadata.test.ts tests/contract/mcp/search-skills.test.ts tests/contract/mcp/load-skill.test.ts tests/integration/service/activation-memory-attribution.test.ts tests/e2e/autonomous-activation-transport.test.ts tests/evaluation/autonomous-activation.test.ts tests/security/transport/autonomous-activation-boundaries.test.ts", - "test:activation-adapter": "vitest run tests/unit/evaluation/codex-adapter-package.test.ts tests/unit/evaluation/codex-marketplace.test.ts tests/unit/evaluation/activation-corpus.test.ts tests/unit/evaluation/manual-evidence.test.ts tests/contract/cli/codex-activation-plugin.test.ts tests/contract/cli/activation-evidence.test.ts" + "test:activation-adapter": "vitest run tests/unit/evaluation/codex-adapter-package.test.ts tests/unit/evaluation/codex-marketplace.test.ts tests/unit/evaluation/activation-corpus.test.ts tests/unit/evaluation/manual-evidence.test.ts tests/contract/cli/codex-activation-plugin.test.ts tests/contract/cli/activation-evidence.test.ts", + "test:feature-004": "vitest run tests/unit/onboarding tests/contract/clients tests/contract/credential-bridge tests/contract/release tests/integration/onboarding tests/e2e/self-hosted-onboarding tests/security/onboarding" }, "dependencies": { "@hono/node-server": "2.1.0", diff --git a/scripts/validate-self-hosted-quickstart.ts b/scripts/validate-self-hosted-quickstart.ts new file mode 100644 index 0000000..4766fc2 --- /dev/null +++ b/scripts/validate-self-hosted-quickstart.ts @@ -0,0 +1,570 @@ +import { constants } from "node:fs"; +import { mkdir, mkdtemp, open, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { isAbsolute, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { z } from "zod"; + +import { + verifyManifestPayload, + verifySignedReleaseEnvelope, +} from "../src/onboarding/adapters/filesystem/release-verifier.js"; +import { + assertLocalDockerContext, + dockerProcessEnvironment, + pinLocalDockerEndpoint, +} from "../src/onboarding/adapters/docker/environment.js"; +import { + runCommand, + type CommandOptions, + type CommandResult, +} from "../src/onboarding/adapters/process/command-runner.js"; +import { clientComponentIdentity } from "../src/onboarding/adapters/clients/client-state.js"; +import { verifyOwnershipRecord } from "../src/onboarding/domain/ownership.js"; +import { + pinVerifiedArchive, + validateArchiveListings, + verifyProductionComposeText, + verifySelfHostedReleasePolicy, +} from "./verify-self-hosted-release.js"; + +interface QuickstartArguments { + readonly manifest: string; + readonly bundles: readonly string[]; + readonly archive: string; + readonly policy: string; + readonly trustedRoot: string; + readonly cosign: string; + readonly architecture: "amd64" | "arm64"; + readonly execute: boolean; +} + +function parseArguments(argv: readonly string[]): QuickstartArguments { + const values = new Map(); + let execute = false; + for (let index = 0; index < argv.length; index += 1) { + const name = argv[index]; + if (name === "--execute") { + execute = true; + continue; + } + const value = argv[index + 1]; + if (name === undefined || value === undefined || !name.startsWith("--")) + throw new Error("Quickstart arguments are invalid"); + values.set(name, [...(values.get(name) ?? []), value]); + index += 1; + } + const one = (name: string): string => { + const entries = values.get(name); + if (entries?.length !== 1) + throw new Error(`${name} is required exactly once`); + const path = entries.at(0); + if (path === undefined) throw new Error(`${name} is unavailable`); + if (!isAbsolute(path)) throw new Error(`${name} must be absolute`); + return resolve(path); + }; + const architecture = values.get("--architecture")?.[0]; + if (architecture !== "amd64" && architecture !== "arm64") + throw new Error("--architecture must be amd64 or arm64"); + const bundleValues = values.get("--bundle") ?? []; + if (bundleValues.some((path) => !isAbsolute(path))) + throw new Error("--bundle must be absolute"); + const bundles = bundleValues.map((path) => resolve(path)); + if (bundles.length < 1 || bundles.length > 2) + throw new Error("One or two exact release bundles are required"); + const allowed = new Set([ + "--manifest", + "--bundle", + "--archive", + "--policy", + "--trusted-root", + "--cosign", + "--architecture", + ]); + if ([...values.keys()].some((name) => !allowed.has(name))) + throw new Error("Quickstart option is unsupported"); + return { + manifest: one("--manifest"), + bundles, + archive: one("--archive"), + policy: one("--policy"), + trustedRoot: one("--trusted-root"), + cosign: one("--cosign"), + architecture, + execute, + }; +} + +const DeploymentSchema = z.looseObject({ + schemaVersion: z.literal("skillwire.deployment/v1"), + installationId: z.uuid(), + composePath: z.string().startsWith("/"), + projectName: z.string().regex(/^skillwire-[a-f0-9]{32}$/), + volumeName: z.string().regex(/^skillwire-[a-f0-9]{32}_postgres_data$/), + skillwireImage: z.string().regex(/@sha256:[0-9a-f]{64}$/), + postgresImage: z.string().regex(/@sha256:[0-9a-f]{64}$/), + databasePasswordFile: z.string().startsWith("/"), + applicationPepperFile: z.string().startsWith("/"), + runtimeSocketDirectory: z.string().startsWith("/"), +}); + +export function quickstartCleanupPlan( + value: unknown, + ownershipValue: unknown, +): { + readonly deployment: z.infer; + readonly args: readonly string[]; +} { + const deployment = DeploymentSchema.parse(value); + const installationProject = `skillwire-${deployment.installationId.replaceAll("-", "")}`; + if ( + deployment.projectName !== installationProject || + deployment.volumeName !== `${deployment.projectName}_postgres_data` + ) + throw new Error("Quickstart cleanup identity is inconsistent"); + const ownership = verifyOwnershipRecord(ownershipValue); + if (ownership.installationId !== deployment.installationId) + throw new Error( + "Quickstart cleanup ownership belongs to another installation", + ); + const required = [ + { + kind: "compose-project", + locator: deployment.projectName, + identity: clientComponentIdentity({ + projectName: deployment.projectName, + }), + }, + ...(["skillwire", "postgres"] as const).map((service) => ({ + kind: "container", + locator: `${deployment.projectName}:${service}`, + identity: clientComponentIdentity({ + projectName: deployment.projectName, + service, + }), + })), + { + kind: "volume", + locator: deployment.volumeName, + identity: clientComponentIdentity({ volumeName: deployment.volumeName }), + }, + ]; + for (const expected of required) { + const matches = ownership.assets.filter( + ({ kind, client, locator, disposition, expectedIdentitySha256 }) => + kind === expected.kind && + client === null && + locator === expected.locator && + disposition === "present" && + expectedIdentitySha256 === expected.identity, + ); + if (matches.length !== 1) + throw new Error("Quickstart cleanup ownership is missing or ambiguous"); + } + return { + deployment, + args: [ + "compose", + "--project-name", + deployment.projectName, + "--file", + "-", + "down", + "--volumes", + ], + }; +} + +export async function cleanupQuickstartDeployment( + value: unknown, + ownershipValue: unknown, + environment: NodeJS.ProcessEnv, + run: (options: CommandOptions) => Promise = runCommand, +): Promise { + const { deployment, args } = quickstartCleanupPlan(value, ownershipValue); + const composeText = await readProtectedQuickstartCompose( + deployment.composePath, + ); + verifyProductionComposeText(composeText); + const commandEnvironment = dockerProcessEnvironment(environment, { + SKILLWIRE_COMPOSE_PROJECT: deployment.projectName, + SKILLWIRE_POSTGRES_VOLUME: deployment.volumeName, + SKILLWIRE_IMAGE: deployment.skillwireImage, + SKILLWIRE_POSTGRES_IMAGE: deployment.postgresImage, + SKILLWIRE_DATABASE_PASSWORD_SECRET_FILE: deployment.databasePasswordFile, + SKILLWIRE_APPLICATION_PEPPER_SECRET_FILE: deployment.applicationPepperFile, + SKILLWIRE_RUNTIME_SOCKET_DIRECTORY: deployment.runtimeSocketDirectory, + SKILLWIRE_RUNTIME_UID: String(process.getuid?.() ?? 10001), + SKILLWIRE_RUNTIME_GID: String(process.getgid?.() ?? 10001), + }); + const invoke = async ( + commandArgs: readonly string[], + stdin?: string, + ): Promise => { + const result = await run({ + executable: "/usr/bin/docker", + args: commandArgs, + environment: commandEnvironment, + deadlineMilliseconds: 120_000, + maximumOutputBytes: 256 * 1024, + ...(stdin === undefined ? {} : { stdin }), + }); + if (result.code !== 0) + throw new Error("Quickstart Docker ownership verification failed"); + return result; + }; + const listed = await invoke([ + "container", + "ls", + "--all", + "--no-trunc", + "--quiet", + "--filter", + `label=com.docker.compose.project=${deployment.projectName}`, + ]); + const identities = listed.stdout.trim().split("\n").filter(Boolean); + if ( + identities.length < 2 || + identities.length > 3 || + identities.some((identity) => !/^[0-9a-f]{64}$/.test(identity)) + ) { + throw new Error("Quickstart Compose project ownership is ambiguous"); + } + const expectedImages = new Map([ + ["skillwire", deployment.skillwireImage], + ["postgres", deployment.postgresImage], + ["migrate", deployment.skillwireImage], + ]); + const observedServices = new Set(); + for (const identity of identities) { + const inspected = await invoke([ + "container", + "inspect", + identity, + "--format", + '{{index .Config.Labels "com.docker.compose.project"}}|{{index .Config.Labels "com.docker.compose.service"}}|{{.Config.Image}}', + ]); + const [project, service, image] = inspected.stdout.trim().split("|"); + const expectedImage = + service === undefined ? undefined : expectedImages.get(service); + if ( + project !== deployment.projectName || + service === undefined || + expectedImage === undefined || + image !== expectedImage || + observedServices.has(service) + ) + throw new Error( + "Quickstart found an unrecorded or drifted Compose service", + ); + observedServices.add(service); + } + if (!observedServices.has("skillwire") || !observedServices.has("postgres")) + throw new Error("Quickstart Compose project is incomplete"); + const volume = await invoke([ + "volume", + "inspect", + deployment.volumeName, + "--format", + '{{.Name}}|{{index .Labels "com.docker.compose.project"}}|{{index .Labels "com.docker.compose.volume"}}', + ]); + if ( + volume.stdout.trim() !== + `${deployment.volumeName}|${deployment.projectName}|postgres_data` + ) { + throw new Error("Quickstart PostgreSQL volume identity drifted"); + } + await invoke(args, composeText); +} + +export async function runQuickstartPostSetupChecks(options: { + readonly launcher: string; + readonly environment: NodeJS.ProcessEnv; + readonly run?: typeof runCommand | undefined; + readonly cleanup: () => Promise; +}): Promise { + const run = options.run ?? runCommand; + let operationFailure: unknown; + try { + for (const command of ["status", "doctor"] as const) { + await run({ + executable: options.launcher, + args: [command, "--output", "json"], + environment: options.environment, + deadlineMilliseconds: 120_000, + maximumOutputBytes: 256 * 1024, + }); + } + } catch (error) { + operationFailure = error; + } + await options.cleanup(); + if (operationFailure instanceof Error) throw operationFailure; + if (operationFailure !== undefined) + throw new Error("Quickstart post-setup verification failed", { + cause: operationFailure, + }); +} + +async function readProtectedQuickstartJson(path: string): Promise { + const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const stats = await handle.stat(); + if ( + !stats.isFile() || + stats.nlink !== 1 || + stats.uid !== process.getuid?.() || + (stats.mode & 0o777) !== 0o600 || + stats.size > 1024 * 1024 + ) { + throw new Error("Quickstart state is unsafe"); + } + return JSON.parse(await handle.readFile("utf8")) as unknown; + } finally { + await handle.close(); + } +} + +async function readProtectedQuickstartCompose(path: string): Promise { + const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const stats = await handle.stat(); + if ( + !stats.isFile() || + stats.nlink !== 1 || + stats.uid !== process.getuid?.() || + (stats.mode & 0o022) !== 0 || + stats.size < 1 || + stats.size > 256 * 1024 + ) { + throw new Error("Quickstart Compose policy is unsafe"); + } + return await handle.readFile("utf8"); + } finally { + await handle.close(); + } +} + +export async function validateSelfHostedQuickstart( + options: QuickstartArguments, +): Promise>> { + const primaryBundle = options.bundles.at(0); + if (primaryBundle === undefined) throw new Error("Release bundle is missing"); + const verified = await verifySignedReleaseEnvelope({ + manifestPath: options.manifest, + bundlePath: primaryBundle, + bundlePaths: options.bundles, + archive: options.archive, + policyPath: options.policy, + trustedRootPath: options.trustedRoot, + cosign: options.cosign, + architecture: options.architecture, + currentReleaseSequence: 0, + currentTrustSequence: 0, + }); + const privateRoot = await mkdtemp(resolve(tmpdir(), "skillwire-quickstart-")); + const releaseRoot = resolve( + privateRoot, + `skillwire-${verified.releaseVersion}-linux-${options.architecture}`, + ); + const pinnedArchive = resolve(privateRoot, "candidate.tar.zst"); + let cleanupComplete = true; + let pendingCleanup: unknown; + let pendingOwnership: unknown; + let pendingEnvironment: NodeJS.ProcessEnv | undefined; + try { + await mkdir(releaseRoot, { mode: 0o700 }); + await pinVerifiedArchive( + options.archive, + pinnedArchive, + verified.manifest.archive.size, + verified.archiveSha256, + ); + const listing = await runCommand({ + executable: "/usr/bin/tar", + args: ["--use-compress-program=/usr/bin/zstd", "-tf", pinnedArchive], + environment: { PATH: "/usr/bin:/bin", LANG: "C" }, + deadlineMilliseconds: 30_000, + maximumOutputBytes: 512 * 1024, + }); + const verbose = await runCommand({ + executable: "/usr/bin/tar", + args: ["--use-compress-program=/usr/bin/zstd", "-tvf", pinnedArchive], + environment: { PATH: "/usr/bin:/bin", LANG: "C" }, + deadlineMilliseconds: 30_000, + maximumOutputBytes: 512 * 1024, + }); + validateArchiveListings(listing.stdout, verbose.stdout); + await runCommand({ + executable: "/usr/bin/tar", + args: [ + "--use-compress-program=/usr/bin/zstd", + "--no-same-owner", + "--no-same-permissions", + "-xf", + pinnedArchive, + "-C", + releaseRoot, + ], + environment: { PATH: "/usr/bin:/bin", LANG: "C" }, + deadlineMilliseconds: 60_000, + maximumOutputBytes: 64 * 1024, + }); + await verifyManifestPayload(verified.manifest, releaseRoot); + await verifySelfHostedReleasePolicy(verified.manifest, releaseRoot); + if (!options.execute) { + cleanupComplete = true; + return { + verified: true, + executed: false, + manifestSha256: verified.manifestSha256, + archiveSha256: verified.archiveSha256, + }; + } + + const home = resolve(privateRoot, "home"); + const data = resolve(privateRoot, "xdg/data"); + const state = resolve(privateRoot, "xdg/state"); + const runtime = resolve(privateRoot, "xdg/runtime"); + await Promise.all( + [home, data, state, runtime].map((path) => + mkdir(path, { recursive: true, mode: 0o700 }), + ), + ); + const dockerEndpoint = await assertLocalDockerContext({ + dockerExecutable: "/usr/bin/docker", + environment: process.env, + signal: new AbortController().signal, + }); + const environment: NodeJS.ProcessEnv = pinLocalDockerEndpoint( + { + HOME: home, + XDG_DATA_HOME: data, + XDG_STATE_HOME: state, + XDG_RUNTIME_DIR: runtime, + PATH: "/usr/local/bin:/usr/bin:/bin", + LANG: "C.UTF-8", + SKILLWIRE_RELEASE_ROOT: releaseRoot, + }, + dockerEndpoint, + ); + const launcher = resolve(releaseRoot, "bin/skillwire"); + const preview = await runCommand({ + executable: launcher, + args: [ + "setup", + "--clients", + "none", + "--preview-only", + "--output", + "json", + ], + environment, + deadlineMilliseconds: 120_000, + maximumOutputBytes: 256 * 1024, + }); + const previewResult = z + .looseObject({ previewHash: z.string().regex(/^[0-9a-f]{64}$/) }) + .parse(JSON.parse(preview.stdout) as unknown); + cleanupComplete = false; + const setup = await runCommand({ + executable: launcher, + args: [ + "setup", + "--clients", + "none", + "--confirm-preview", + previewResult.previewHash, + "--output", + "json", + ], + environment, + deadlineMilliseconds: 600_000, + maximumOutputBytes: 256 * 1024, + }); + const setupResult = z + .looseObject({ status: z.literal("success") }) + .parse(JSON.parse(setup.stdout) as unknown); + const deploymentValue = await readProtectedQuickstartJson( + resolve(state, "skillwire/deployment.json"), + ); + const ownershipValue = await readProtectedQuickstartJson( + resolve(state, "skillwire/ownership.json"), + ); + const cleanup = quickstartCleanupPlan(deploymentValue, ownershipValue); + const { deployment } = cleanup; + pendingCleanup = deployment; + pendingOwnership = ownershipValue; + pendingEnvironment = environment; + process.stderr.write( + `Quickstart confirmed exact owned cleanup targets: project=${deployment.projectName} volume=${deployment.volumeName}\n`, + ); + await runQuickstartPostSetupChecks({ + launcher, + environment, + cleanup: async () => { + await cleanupQuickstartDeployment( + deployment, + ownershipValue, + environment, + ); + pendingCleanup = undefined; + pendingOwnership = undefined; + cleanupComplete = true; + }, + }); + return { + verified: true, + executed: true, + setupStatus: setupResult.status, + cleanupProject: deployment.projectName, + cleanupVolume: deployment.volumeName, + }; + } finally { + if ( + !cleanupComplete && + pendingCleanup !== undefined && + pendingOwnership !== undefined && + pendingEnvironment !== undefined + ) { + try { + await cleanupQuickstartDeployment( + pendingCleanup, + pendingOwnership, + pendingEnvironment, + ); + pendingCleanup = undefined; + pendingOwnership = undefined; + cleanupComplete = true; + } catch { + // Preserve the private root only when exact named cleanup itself fails. + } + } + if (cleanupComplete) + await rm(privateRoot, { recursive: true, force: true }); + else + process.stderr.write( + `Quickstart stopped; inspect the retained private recovery root: ${privateRoot}\n`, + ); + } +} + +async function main(): Promise { + const result = await validateSelfHostedQuickstart( + parseArguments(process.argv.slice(2)), + ); + process.stdout.write(`${JSON.stringify(result)}\n`); +} + +if ( + process.argv[1] !== undefined && + resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)) +) { + main().catch((error: unknown) => { + process.stderr.write( + `${error instanceof Error ? error.message : "Quickstart validation failed"}\n`, + ); + process.exitCode = 1; + }); +} diff --git a/scripts/verify-self-hosted-release.ts b/scripts/verify-self-hosted-release.ts index 5c65449..3d9e8a0 100644 --- a/scripts/verify-self-hosted-release.ts +++ b/scripts/verify-self-hosted-release.ts @@ -1,9 +1,13 @@ import { createHash } from "node:crypto"; import { constants } from "node:fs"; -import { mkdir, mkdtemp, open, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, open, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { isDeepStrictEqual } from "node:util"; + +import { parse as parseYaml } from "yaml"; +import { z } from "zod"; import { verifyManifestPayload, @@ -11,6 +15,9 @@ import { } from "../src/onboarding/adapters/filesystem/release-verifier.js"; import { runCommand } from "../src/onboarding/adapters/process/command-runner.js"; import { redactText } from "../src/onboarding/cli/output.js"; +import type { ReleaseManifest } from "../src/onboarding/domain/release-manifest.js"; +import { validateCodexAdapterIntegrityManifest } from "../src/evaluation/codex-adapter-package.js"; +import { verifyBundledFirstPartyCatalog } from "../src/onboarding/application/first-party-catalog.js"; function argument(name: string): string | undefined { const index = process.argv.indexOf(name); @@ -24,7 +31,7 @@ function argumentsFor(name: string): readonly string[] { }); } -async function pinVerifiedArchive( +export async function pinVerifiedArchive( sourcePath: string, targetPath: string, expectedSize: number, @@ -75,7 +82,7 @@ async function pinVerifiedArchive( } } -function validateArchiveListings(names: string, verbose: string): void { +export function validateArchiveListings(names: string, verbose: string): void { const entries = names.split("\n").filter(Boolean); const verboseEntries = verbose.split("\n").filter(Boolean); if ( @@ -87,8 +94,7 @@ function validateArchiveListings(names: string, verbose: string): void { "Release archive inventory is empty, inconsistent, or too large", ); } - entries.forEach((raw, index) => { - const type = verboseEntries[index]?.[0]; + const normalize = (raw: string, type: string | undefined): string => { if (type !== "-" && type !== "d") throw new Error("Release archive contains a link or special entry"); if (!/^[A-Za-z0-9@+_,=./-]+\/?$/.test(raw)) @@ -97,15 +103,285 @@ function validateArchiveListings(names: string, verbose: string): void { const path = unprefixed.endsWith("/") ? unprefixed.slice(0, -1) : unprefixed; - if ((path === "" || path === ".") && type === "d") return; + if ((path === "" || path === ".") && type === "d") return "."; if ( path.startsWith("/") || - path.split("/").some((segment) => segment === "" || segment === "..") || + path + .split("/") + .some( + (segment) => segment === "" || segment === "." || segment === "..", + ) || path.includes("\0") ) { throw new Error("Release archive contains an unsafe path"); } + return path; + }; + const normalized = entries.map((raw, index) => { + const type = verboseEntries[index]?.[0]; + const path = normalize(raw, type); + const verbosePath = verboseEntries[index]?.trim().split(/\s+/).at(-1); + if (verbosePath === undefined || normalize(verbosePath, type) !== path) + throw new Error("Release archive listings disagree"); + return path; + }); + if (new Set(normalized).size !== normalized.length) + throw new Error("Release archive contains duplicate paths"); +} + +const CertifiedMatrixSchema = z + .object({ + schemaVersion: z.literal("skillwire.supported-matrix/v1"), + operatingSystems: z.tuple([ + z + .object({ id: z.literal("ubuntu"), version: z.literal("24.04") }) + .strict(), + z.object({ id: z.literal("debian"), version: z.literal("12") }).strict(), + z.object({ id: z.literal("debian"), version: z.literal("13") }).strict(), + ]), + architectures: z.tuple([z.literal("amd64"), z.literal("arm64")]), + docker: z + .object({ + minimum: z.literal("29.7.2"), + tested: z.string().regex(/^\d+\.\d+\.\d+$/), + }) + .strict(), + compose: z + .object({ + minimum: z.literal("5.4.0"), + tested: z.string().regex(/^\d+\.\d+\.\d+$/), + }) + .strict(), + postgresql: z.literal("17.10-alpine"), + node: z.literal("24.18.0"), + codex: z.literal("0.147.0"), + claude: z.literal("2.1.229"), + cosign: z.literal("3.1.3"), + }) + .strict(); + +const EXPECTED_PRODUCTION_COMPOSE = { + name: "${SKILLWIRE_COMPOSE_PROJECT:?compose project is required}", + services: { + postgres: { + image: + "${SKILLWIRE_POSTGRES_IMAGE:?digest-pinned PostgreSQL image is required}", + environment: { + POSTGRES_DB: "skillwire", + POSTGRES_USER: "skillwire", + POSTGRES_PASSWORD_FILE: "/run/secrets/database_password", + }, + secrets: [ + { + source: "postgres_password", + target: "database_password", + mode: 400, + }, + ], + volumes: ["postgres_data:/var/lib/postgresql/data"], + healthcheck: { + test: ["CMD-SHELL", "pg_isready -U skillwire -d skillwire"], + interval: "5s", + timeout: "3s", + retries: 20, + }, + restart: "unless-stopped", + stop_grace_period: "15s", + cap_drop: ["ALL"], + cap_add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"], + security_opt: ["no-new-privileges:true"], + }, + migrate: { + image: "${SKILLWIRE_IMAGE:?digest-pinned SkillWire image is required}", + entrypoint: ["/usr/local/bin/skillwire-secret-entrypoint"], + command: [ + "database", + "node", + "dist/src/persistence/postgres/migration-runner.js", + ], + user: "0:0", + environment: { + SKILLWIRE_DATABASE_PASSWORD_FILE: "/run/secrets/database_password", + SKILLWIRE_DATABASE_HOST: "postgres", + }, + secrets: [ + { + source: "postgres_password", + target: "database_password", + mode: 400, + }, + ], + depends_on: { postgres: { condition: "service_healthy" } }, + read_only: true, + tmpfs: ["/tmp:rw,noexec,nosuid,size=16m"], + cap_drop: ["ALL"], + cap_add: ["CHOWN", "DAC_OVERRIDE", "SETGID", "SETUID"], + security_opt: ["no-new-privileges:true"], + restart: "no", + }, + skillwire: { + image: "${SKILLWIRE_IMAGE:?digest-pinned SkillWire image is required}", + entrypoint: ["/usr/local/bin/skillwire-secret-entrypoint"], + command: ["application", "node", "dist/src/main.js"], + user: "0:0", + environment: { + SKILLWIRE_DATABASE_PASSWORD_FILE: "/run/secrets/database_password", + SKILLWIRE_DATABASE_HOST: "postgres", + SKILLWIRE_API_KEY_PEPPER_FILE: "/run/secrets/application_pepper", + SKILLWIRE_BIND_HOST: "localhost", + SKILLWIRE_UNIX_SOCKET_PATH: "/run/skillwire/mcp.sock", + SKILLWIRE_RUNTIME_UID: + "${SKILLWIRE_RUNTIME_UID:?runtime uid is required}", + SKILLWIRE_RUNTIME_GID: + "${SKILLWIRE_RUNTIME_GID:?runtime gid is required}", + SKILLWIRE_ALLOWED_HOSTS: "localhost,127.0.0.1", + SKILLWIRE_CATALOG_ROOT: "/app", + SKILLWIRE_CATALOG_RELEASE: "launch-catalog-v1", + SKILLWIRE_AUTHENTICATION_REQUESTS_PER_MINUTE: + "${SKILLWIRE_AUTHENTICATION_REQUESTS_PER_MINUTE:-600}", + SKILLWIRE_AUTHENTICATION_RATE_LIMIT_BURST: + "${SKILLWIRE_AUTHENTICATION_RATE_LIMIT_BURST:-60}", + SKILLWIRE_GITHUB_INGESTION_ENABLED: "false", + }, + secrets: [ + { + source: "postgres_password", + target: "database_password", + mode: 400, + }, + { + source: "api_key_pepper", + target: "application_pepper", + mode: 400, + }, + ], + volumes: [ + "${SKILLWIRE_RUNTIME_SOCKET_DIRECTORY:?runtime socket directory is required}:/run/skillwire:rw", + ], + depends_on: { + postgres: { condition: "service_healthy" }, + migrate: { condition: "service_completed_successfully" }, + }, + read_only: true, + tmpfs: ["/tmp:rw,noexec,nosuid,size=16m"], + cap_drop: ["ALL"], + cap_add: ["CHOWN", "DAC_OVERRIDE", "SETGID", "SETUID"], + security_opt: ["no-new-privileges:true"], + restart: "unless-stopped", + stop_grace_period: "15s", + healthcheck: { + test: [ + "CMD", + "node", + "-e", + "require('node:http').request({socketPath:'/run/skillwire/mcp.sock',path:'/health/ready',headers:{host:'localhost'}},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1)).end()", + ], + interval: "10s", + timeout: "3s", + start_period: "15s", + retries: 6, + }, + }, + admin: { + profiles: ["admin"], + image: "${SKILLWIRE_IMAGE:?digest-pinned SkillWire image is required}", + entrypoint: ["node", "dist/src/authentication/admin-cli.js"], + environment: { + SKILLWIRE_DATABASE_PASSWORD_FILE: "/run/secrets/database_password", + SKILLWIRE_DATABASE_HOST: "postgres", + SKILLWIRE_API_KEY_PEPPER_FILE: "/run/secrets/application_pepper", + }, + secrets: [ + { + source: "postgres_password", + target: "database_password", + mode: 400, + }, + { + source: "api_key_pepper", + target: "application_pepper", + mode: 400, + }, + ], + read_only: true, + tmpfs: ["/tmp:rw,noexec,nosuid,size=16m"], + cap_drop: ["ALL"], + security_opt: ["no-new-privileges:true"], + logging: { driver: "none" }, + restart: "no", + }, + }, + secrets: { + postgres_password: { + file: "${SKILLWIRE_DATABASE_PASSWORD_SECRET_FILE:?database password file is required}", + }, + api_key_pepper: { + file: "${SKILLWIRE_APPLICATION_PEPPER_SECRET_FILE:?application pepper file is required}", + }, + }, + volumes: { + postgres_data: { + name: "${SKILLWIRE_POSTGRES_VOLUME:?owned PostgreSQL volume is required}", + }, + }, +} as const; + +export function verifyProductionComposeText(composeText: string): void { + if ( + !isDeepStrictEqual( + parseYaml(composeText) as unknown, + EXPECTED_PRODUCTION_COMPOSE, + ) + ) { + throw new Error("Production Compose policy is unsafe"); + } +} + +export async function verifySelfHostedReleasePolicy( + manifest: ReleaseManifest, + releaseRoot: string, +): Promise<{ + readonly feature003PackageSha256: string; + readonly firstPartyRevisionCount: 10; + readonly matrix: z.infer; +}> { + const composeText = await readFile( + resolve(releaseRoot, "distribution/self-hosted/compose.yaml"), + "utf8", + ); + verifyProductionComposeText(composeText); + const matrixResult = CertifiedMatrixSchema.safeParse( + JSON.parse( + await readFile( + resolve(releaseRoot, "distribution/self-hosted/supported-matrix.json"), + "utf8", + ), + ) as unknown, + ); + if (!matrixResult.success) + throw new Error("Certified release matrix is invalid or overclaimed"); + const matrix = matrixResult.data; + const integrity = validateCodexAdapterIntegrityManifest( + JSON.parse( + await readFile( + resolve( + releaseRoot, + "distribution/codex-marketplace/release-integrity.json", + ), + "utf8", + ), + ) as unknown, + resolve(releaseRoot, "integrations/codex/skillwire-autonomous-activation"), + ); + const catalog = await verifyBundledFirstPartyCatalog({ + releaseRoot, + release: manifest, }); + return { + feature003PackageSha256: integrity.packageSha256, + firstPartyRevisionCount: catalog.revisions.length as 10, + matrix, + }; } export async function verifyCandidateFromCommandLine(): Promise { @@ -198,6 +474,7 @@ export async function verifyCandidateFromCommandLine(): Promise { maximumOutputBytes: 64 * 1024, }); await verifyManifestPayload(verified.manifest, extractionRoot); + await verifySelfHostedReleasePolicy(verified.manifest, extractionRoot); } finally { await rm(temporaryRoot, { recursive: true, force: true }); } diff --git a/specs/004-self-hosted-onboarding/tasks.md b/specs/004-self-hosted-onboarding/tasks.md index 048b181..7803f88 100644 --- a/specs/004-self-hosted-onboarding/tasks.md +++ b/specs/004-self-hosted-onboarding/tasks.md @@ -283,19 +283,19 @@ description: "Dependency-ordered implementation tasks for Feature 004" ### Tests for User Story 7 — write and observe failure first -- [ ] T139 [P] [US7] Add failing no-GitHub/no-token first-party setup, exact ten-skill identity, advisory, and smoke tests in `tests/e2e/self-hosted-onboarding/first-party-catalog.test.ts` -- [ ] T140 [P] [US7] Add failing unselected/selected `mattpocock/skills` and `obra/superpowers` registration/quarantine tests in `tests/integration/onboarding/source-bootstrap.test.ts` -- [ ] T141 [P] [US7] Add failing rate-limit, unavailable, revoked, quarantined, and integrity-failure degraded-source tests in `tests/integration/onboarding/source-degradation.test.ts` -- [ ] T142 [P] [US7] Add failing imported-text non-execution, zero client/repository installation, and separate GitHub/client credential tests in `tests/security/onboarding/source-boundaries.test.ts` +- [x] T139 [P] [US7] Add failing no-GitHub/no-token first-party setup, exact ten-skill identity, advisory, and smoke tests in `tests/e2e/self-hosted-onboarding/first-party-catalog.test.ts` +- [x] T140 [P] [US7] Add failing unselected/selected `mattpocock/skills` and `obra/superpowers` registration/quarantine tests in `tests/integration/onboarding/source-bootstrap.test.ts` +- [x] T141 [P] [US7] Add failing rate-limit, unavailable, revoked, quarantined, and integrity-failure degraded-source tests in `tests/integration/onboarding/source-degradation.test.ts` +- [x] T142 [P] [US7] Add failing imported-text non-execution, zero client/repository installation, and separate GitHub/client credential tests in `tests/security/onboarding/source-boundaries.test.ts` ### Implementation for User Story 7 -- [ ] T143 [P] [US7] Implement explicit bootstrap-source choice and sync-state validation in `src/onboarding/domain/source-choice.ts` -- [ ] T144 [P] [US7] Implement bundled ten-skill catalog/advisory identity verification with GitHub disabled in `src/onboarding/application/first-party-catalog.ts` -- [ ] T145 [P] [US7] Implement separate read-only GitHub credential persistence/reference handling in `src/onboarding/adapters/credentials/github-token.ts` -- [ ] T146 [US7] Orchestrate fixed-origin registration and the existing ingestion/quarantine pipeline without changing first-party readiness in `src/onboarding/application/source-bootstrap.ts` -- [ ] T147 [US7] Add explicit source previews/options and post-readiness bootstrap in `src/onboarding/application/setup.ts` -- [ ] T148 [US7] Emit bounded degraded-source findings while preserving eligible cached content in `src/onboarding/application/diagnostic-probes.ts` +- [x] T143 [P] [US7] Implement explicit bootstrap-source choice and sync-state validation in `src/onboarding/domain/source-choice.ts` +- [x] T144 [P] [US7] Implement bundled ten-skill catalog/advisory identity verification with GitHub disabled in `src/onboarding/application/first-party-catalog.ts` +- [x] T145 [P] [US7] Implement separate read-only GitHub credential persistence/reference handling in `src/onboarding/adapters/credentials/github-token.ts` +- [x] T146 [US7] Orchestrate fixed-origin registration and the existing ingestion/quarantine pipeline without changing first-party readiness in `src/onboarding/application/source-bootstrap.ts` +- [x] T147 [US7] Add explicit source previews/options and post-readiness bootstrap in `src/onboarding/application/setup.ts` +- [x] T148 [US7] Emit bounded degraded-source findings while preserving eligible cached content in `src/onboarding/application/diagnostic-probes.ts` **Checkpoint**: Baseline catalog use is offline; optional imported content remains explicit, inert, provenance-bound, and isolated. @@ -307,21 +307,21 @@ description: "Dependency-ordered implementation tasks for Feature 004" ### Release-gate tests — add before their implementation/evidence tasks -- [ ] T149 [P] Add failing canary/no-telemetry scans across argv, environment, `/proc`, logs, terminal captures, configs, diffs, snapshots, journals, backups, reports, release artifacts, and repository files in `tests/security/onboarding/secret-containment.test.ts` -- [ ] T150 [P] Add a failing table-driven traceability suite mapping all 28 numbered scenarios, FR-001 through FR-092, and buildable SC gates to concrete evidence in `tests/e2e/self-hosted-onboarding/acceptance-scenarios.test.ts` -- [ ] T151 [P] Add failing archive extraction, canonicalization, signature/transparency/claim, overlap/revocation/downgrade, unlisted-byte, mutable-image, unsafe-Compose, and matrix-overclaim tests in `tests/security/onboarding/release-integrity.test.ts` -- [ ] T152 [P] Add failing compatibility tests that recompute and validate the unchanged Feature 003 Codex package inventory and `distribution/codex-marketplace/release-integrity.json` in `tests/contract/release/feature-003-integrity-compatibility.test.ts` -- [ ] T153 [P] Add a non-gating clean-host setup-duration recorder that reports elapsed time without treating the 15-minute participant target as a CI threshold in `tests/e2e/self-hosted-onboarding/setup-duration-evidence.test.ts` +- [x] T149 [P] Add failing canary/no-telemetry scans across argv, environment, `/proc`, logs, terminal captures, configs, diffs, snapshots, journals, backups, reports, release artifacts, and repository files in `tests/security/onboarding/secret-containment.test.ts` +- [x] T150 [P] Add a failing table-driven traceability suite mapping all 28 numbered scenarios, FR-001 through FR-092, and buildable SC gates to concrete evidence in `tests/e2e/self-hosted-onboarding/acceptance-scenarios.test.ts` +- [x] T151 [P] Add failing archive extraction, canonicalization, signature/transparency/claim, overlap/revocation/downgrade, unlisted-byte, mutable-image, unsafe-Compose, and matrix-overclaim tests in `tests/security/onboarding/release-integrity.test.ts` +- [x] T152 [P] Add failing compatibility tests that recompute and validate the unchanged Feature 003 Codex package inventory and `distribution/codex-marketplace/release-integrity.json` in `tests/contract/release/feature-003-integrity-compatibility.test.ts` +- [x] T153 [P] Add a non-gating clean-host setup-duration recorder that reports elapsed time without treating the 15-minute participant target as a CI threshold in `tests/e2e/self-hosted-onboarding/setup-duration-evidence.test.ts` ### Release-gate implementation and evidence -- [ ] T154 Refresh candidate verification to enforce T151-T152 and refuse publication on any Feature 001-003 integrity/regression failure in `scripts/verify-self-hosted-release.ts` -- [ ] T155 Add the aggregate Feature 004 test command while retaining all existing Feature 001-003 commands unchanged in `package.json` -- [ ] T156 Write exact Cosign bootstrap verification, four normal sibling assets, overlap-bundle exception, offline `verify-blob`, trust refresh/rotation/revocation, extraction, and no-`curl | sh` instructions in `distribution/self-hosted/README.md` -- [ ] T157 [P] Document setup/status/doctor/repair/backup/upgrade/uninstall/purge, credential/service-secret lifecycle, no-wrapper profiles, privacy, and support boundaries in `README.md`, `docs/operations.md`, and `docs/privacy.md` -- [ ] T158 [P] Define exact release evidence, certified matrix, automatic-activation claim separation, informational setup duration, and moderated usability result format in `docs/self-hosted-release-evidence.md` -- [ ] T159 Implement Ubuntu 24.04 and Debian 12/13 `amd64`/`arm64`, rootless/rootful, pinned-client, real Secret Service, backup/upgrade, 28-scenario, and Feature 001-003 release jobs in `.github/workflows/ci.yml` and `.github/workflows/self-hosted-release.yml` -- [ ] T160 Implement the disposable-profile quickstart runner with exact signed-asset verification and safe named-resource cleanup in `scripts/validate-self-hosted-quickstart.ts` +- [x] T154 Refresh candidate verification to enforce T151-T152 and refuse publication on any Feature 001-003 integrity/regression failure in `scripts/verify-self-hosted-release.ts` +- [x] T155 Add the aggregate Feature 004 test command while retaining all existing Feature 001-003 commands unchanged in `package.json` +- [x] T156 Write exact Cosign bootstrap verification, four normal sibling assets, overlap-bundle exception, offline `verify-blob`, trust refresh/rotation/revocation, extraction, and no-`curl | sh` instructions in `distribution/self-hosted/README.md` +- [x] T157 [P] Document setup/status/doctor/repair/backup/upgrade/uninstall/purge, credential/service-secret lifecycle, no-wrapper profiles, privacy, and support boundaries in `README.md`, `docs/operations.md`, and `docs/privacy.md` +- [x] T158 [P] Define exact release evidence, certified matrix, automatic-activation claim separation, informational setup duration, and moderated usability result format in `docs/self-hosted-release-evidence.md` +- [x] T159 Implement Ubuntu 24.04 and Debian 12/13 `amd64`/`arm64`, rootless/rootful, pinned-client, real Secret Service, backup/upgrade, 28-scenario, and Feature 001-003 release jobs in `.github/workflows/ci.yml` and `.github/workflows/self-hosted-release.yml` +- [x] T160 Implement the disposable-profile quickstart runner with exact signed-asset verification and safe named-resource cleanup in `scripts/validate-self-hosted-quickstart.ts` - [ ] T161 Run Prettier, diff-check, lint, typecheck, build, all test projects, unchanged Feature 001-003 regressions, Feature 004 aggregate, quickstart, and supported matrix; record exact outcomes in `docs/self-hosted-release-evidence.md` **Checkpoint**: Publication is blocked until all 28 scenarios, signing/trust gates, disclosure scans, profile preservation, and unchanged Feature 001-003 invariants pass. diff --git a/src/ingestion/bootstrap-cli.ts b/src/ingestion/bootstrap-cli.ts new file mode 100644 index 0000000..506adf7 --- /dev/null +++ b/src/ingestion/bootstrap-cli.ts @@ -0,0 +1,80 @@ +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { SourceRegistrationService } from "../application/services/source-registration-service.js"; +import { SourceSynchronizationService } from "../application/services/source-synchronization-service.js"; +import { readDatabaseConfiguration } from "../config.js"; +import { assertGitHubCoordinate } from "../domain/external-catalog/types.js"; +import { GitHubCommitTreeBlobReader } from "./github/commit-tree-blob-reader.js"; +import { GitHubRestClient } from "./github/rest-client.js"; +import { createPostgresPool } from "../persistence/postgres/client.js"; +import { PostgresExternalCatalogStore } from "../persistence/postgres/external-catalog-store.js"; +import { readBoundedGitHubToken } from "../onboarding/adapters/credentials/github-token.js"; + +export async function runSourceBootstrapCli( + args: readonly string[], + environment: NodeJS.ProcessEnv, + input: AsyncIterable, + fetchImplementation?: typeof fetch, +): Promise { + if (args.length !== 2) throw new Error("INVALID_INPUT"); + const coordinate = assertGitHubCoordinate({ + owner: args[0] ?? "", + repository: args[1] ?? "", + }); + const token = await readBoundedGitHubToken(input); + const pool = createPostgresPool(readDatabaseConfiguration(environment)); + try { + const store = new PostgresExternalCatalogStore(pool); + const provider = new GitHubCommitTreeBlobReader( + new GitHubRestClient({ + token, + fetchImplementation, + requestTimeoutMs: 30_000, + maximumAttempts: 3, + maximumResponseBytes: 8 * 1024 * 1024, + }), + ); + const registration = await new SourceRegistrationService( + provider, + store, + ).add(coordinate, "self-hosted-onboarding", { + deadline: Date.now() + 300_000, + }); + const snapshot = await new SourceSynchronizationService( + provider, + store, + ).sync(registration.sourceId, { deadline: Date.now() + 300_000 }); + return { + schemaVersion: "skillwire.source-bootstrap-result/v1", + sourceId: registration.sourceId, + registrationCreated: registration.created, + snapshotCreated: snapshot.created, + classifications: snapshot.candidateTraces.map( + ({ classification }) => classification, + ), + }; + } finally { + await pool.end(); + } +} + +if ( + process.argv[1] !== undefined && + pathToFileURL(resolve(process.argv[1])).href === import.meta.url +) { + runSourceBootstrapCli(process.argv.slice(2), process.env, process.stdin) + .then((result) => process.stdout.write(`${JSON.stringify(result)}\n`)) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : "INTERNAL"; + const code = message.includes("RATE_LIMITED") + ? "RATE_LIMITED" + : /GITHUB|SOURCE/.test(message) + ? "SOURCE_UNAVAILABLE" + : "INTERNAL"; + process.stdout.write( + `${JSON.stringify({ ok: false, errorCode: code })}\n`, + ); + process.exitCode = 1; + }); +} diff --git a/src/onboarding/adapters/credentials/github-token.ts b/src/onboarding/adapters/credentials/github-token.ts new file mode 100644 index 0000000..bdce4ca --- /dev/null +++ b/src/onboarding/adapters/credentials/github-token.ts @@ -0,0 +1,166 @@ +import { randomUUID, timingSafeEqual } from "node:crypto"; +import { isAbsolute, resolve } from "node:path"; + +import { z } from "zod"; + +import { runCommand } from "../process/command-runner.js"; + +export const GitHubTokenSchema = z + .string() + .min(20) + .max(512) + .regex(/^(?:gh[pousr]_[A-Za-z0-9_]{16,}|github_pat_[A-Za-z0-9_]{16,})$/); + +export async function readBoundedGitHubToken( + input: AsyncIterable, + signal?: AbortSignal, +): Promise { + if (signal?.aborted === true) + throw new Error("GitHub source credential input cancelled"); + const chunks: Buffer[] = []; + let size = 0; + const iterator = input[Symbol.asyncIterator](); + let complete = false; + let rejectCancellation: ((error: Error) => void) | undefined; + const cancellation = new Promise((_resolve, reject) => { + rejectCancellation = reject; + }); + const cancel = (): void => { + rejectCancellation?.(new Error("GitHub source credential input cancelled")); + }; + signal?.addEventListener("abort", cancel, { once: true }); + const deadline = setTimeout(() => { + rejectCancellation?.( + new Error("GitHub source credential input deadline exceeded"), + ); + }, 30_000); + deadline.unref(); + try { + do { + const next = await Promise.race([iterator.next(), cancellation]); + if (next.done) { + complete = true; + continue; + } + const bytes = Buffer.from(next.value); + size += bytes.byteLength; + if (size > 514) throw new Error("GitHub source credential is invalid"); + chunks.push(bytes); + } while (!complete); + } finally { + clearTimeout(deadline); + signal?.removeEventListener("abort", cancel); + if (!complete) { + const returned = iterator.return?.(); + if (returned !== undefined) void returned.catch(() => undefined); + } + } + let token = Buffer.concat(chunks).toString("utf8"); + if (token.endsWith("\r\n")) token = token.slice(0, -2); + else if (token.endsWith("\n")) token = token.slice(0, -1); + return GitHubTokenSchema.parse(token); +} + +function referenceId(reference: string): string { + const match = /^secret-service:github:([0-9a-f-]{36})$/.exec(reference); + if (match?.[1] === undefined || !z.uuid().safeParse(match[1]).success) + throw new Error("GitHub credential reference is invalid"); + return match[1]; +} + +function attributes(id: string): readonly string[] { + z.uuid().parse(id); + return [ + "application", + "skillwire", + "schema", + "1", + "purpose", + "github-source-read-only", + "credential-ref", + id, + ]; +} + +export class GitHubTokenCredentialStore { + public constructor( + private readonly executable = "/usr/bin/secret-tool", + private readonly environment: NodeJS.ProcessEnv = { + PATH: "/usr/bin:/bin", + LANG: "C.UTF-8", + DBUS_SESSION_BUS_ADDRESS: process.env["DBUS_SESSION_BUS_ADDRESS"], + XDG_RUNTIME_DIR: process.env["XDG_RUNTIME_DIR"], + }, + ) { + if (!isAbsolute(executable)) + throw new Error("secret-tool executable must be absolute"); + } + + async store( + token: string, + signal?: AbortSignal, + ): Promise<{ readonly reference: string; readonly referenceId: string }> { + GitHubTokenSchema.parse(token); + const id = randomUUID(); + await runCommand({ + executable: resolve(this.executable), + args: [ + "store", + "--label", + "SkillWire read-only GitHub source token", + ...attributes(id), + ], + environment: this.environment, + stdin: token, + deadlineMilliseconds: 5_000, + maximumOutputBytes: 16 * 1024, + signal, + }); + const reference = `secret-service:github:${id}`; + try { + const persisted = await this.lookup(reference, signal); + const expectedBytes = Buffer.from(token); + const persistedBytes = Buffer.from(persisted); + if ( + expectedBytes.byteLength !== persistedBytes.byteLength || + !timingSafeEqual(expectedBytes, persistedBytes) + ) { + throw new Error("GitHub credential readback differs"); + } + } catch (error) { + await this.clear(reference).catch(() => undefined); + throw new Error("GitHub credential persistence verification failed", { + cause: error, + }); + } + return { reference, referenceId: id }; + } + + async lookup(reference: string, signal?: AbortSignal): Promise { + const result = await runCommand({ + executable: resolve(this.executable), + args: ["lookup", ...attributes(referenceId(reference))], + environment: this.environment, + deadlineMilliseconds: 3_000, + maximumOutputBytes: 1024, + allowSensitiveStdout: true, + signal, + }); + const token = result.stdout.endsWith("\n") + ? result.stdout.slice(0, -1) + : result.stdout; + return GitHubTokenSchema.parse(token); + } + + async clear(reference: string, signal?: AbortSignal): Promise { + await runCommand({ + executable: resolve(this.executable), + args: ["clear", ...attributes(referenceId(reference))], + environment: this.environment, + acceptExitCodes: [0, 1], + deadlineMilliseconds: 3_000, + maximumOutputBytes: 16 * 1024, + signal, + }); + } +} diff --git a/src/onboarding/adapters/docker/deployment.ts b/src/onboarding/adapters/docker/deployment.ts index bdd7f70..de5e2b6 100644 --- a/src/onboarding/adapters/docker/deployment.ts +++ b/src/onboarding/adapters/docker/deployment.ts @@ -289,6 +289,44 @@ export class DeploymentAdapter { ); } + async assertDeploymentTargetsAbsent(signal: AbortSignal): Promise { + const containers = await this.command( + [ + "container", + "ls", + "--all", + "--quiet", + "--filter", + `label=com.docker.compose.project=${this.options.projectName}`, + ], + signal, + ); + if (containers.stdout.trim() !== "") + throw new Error( + "Compose project target already exists; collision refused", + ); + const volumes = await this.command( + [ + "volume", + "ls", + "--quiet", + "--filter", + `name=^${this.options.volumeName}$`, + ], + signal, + ); + if ( + volumes.stdout + .split("\n") + .map((name) => name.trim()) + .includes(this.options.volumeName) + ) { + throw new Error( + "PostgreSQL volume target already exists; collision refused", + ); + } + } + async observeOwnedService( service: "skillwire" | "postgres", signal: AbortSignal, diff --git a/src/onboarding/adapters/process/command-runner.ts b/src/onboarding/adapters/process/command-runner.ts index 58b0b30..c5ccd8d 100644 --- a/src/onboarding/adapters/process/command-runner.ts +++ b/src/onboarding/adapters/process/command-runner.ts @@ -37,7 +37,7 @@ export interface CommandResult { } const SECRET_PATTERN = - /(?:swk\.[A-Za-z0-9_-]{16}\.[A-Za-z0-9_-]{43}|bearer\s+\S+)/gi; + /(?:swk\.[A-Za-z0-9_-]{16}\.[A-Za-z0-9_-]{43}|bearer\s+\S+|gh[pousr]_[A-Za-z0-9_]{16,}|github_pat_[A-Za-z0-9_]{16,})/gi; const PROCESS_INJECTION_ENVIRONMENT = /^(?:LD_|DYLD_|NODE_OPTIONS$|NODE_PATH$|BASH_ENV$|ENV$|CDPATH$|PYTHON(?:HOME|PATH)$|RUBYOPT$|PERL5OPT$|GIT_CONFIG|SSH_ASKPASS$)/i; diff --git a/src/onboarding/application/diagnostic-probes.ts b/src/onboarding/application/diagnostic-probes.ts index ddc3c87..059f2e6 100644 --- a/src/onboarding/application/diagnostic-probes.ts +++ b/src/onboarding/application/diagnostic-probes.ts @@ -3,6 +3,7 @@ import { type DiagnosticFinding, } from "../domain/diagnostics.js"; import { redactOutput } from "../cli/output.js"; +import type { SourceChoice } from "../domain/source-choice.js"; export type DiagnosticCondition = | "service-stopped" @@ -282,3 +283,23 @@ export async function runDiagnosticProbes( } return findings; } + +export function degradedSourceProbe(choice: SourceChoice): DiagnosticProbe { + if ( + !choice.selected || + (choice.syncState !== "degraded" && choice.syncState !== "failed") + ) { + return { + id: `source:${choice.source}`, + run: () => Promise.resolve(null), + }; + } + return { + ...diagnosticProbe("source-degraded", { + source: choice.source, + syncState: choice.syncState, + registered: choice.registrationIdentity !== null, + }), + id: `source:${choice.source}`, + }; +} diff --git a/src/onboarding/application/first-party-catalog.ts b/src/onboarding/application/first-party-catalog.ts new file mode 100644 index 0000000..5bf2c50 --- /dev/null +++ b/src/onboarding/application/first-party-catalog.ts @@ -0,0 +1,126 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { verifyCatalog } from "../../catalog/catalog-verifier.js"; +import { loadVerifiedCatalogProvider } from "../../catalog/version-controlled-provider.js"; +import type { SkillCatalogProvider } from "../../application/ports/skill-catalog-provider.js"; +import type { ReleaseManifest } from "../domain/release-manifest.js"; + +const RELEASE_ID = "launch-catalog-v1"; + +export interface BundledCatalogReleaseIdentity { + readonly payload: ReleaseManifest["payload"]; + readonly components: Pick; +} + +export interface FirstPartyCatalogVerification { + readonly releaseId: typeof RELEASE_ID; + readonly provider: SkillCatalogProvider; + readonly revisions: readonly { + readonly skillId: string; + readonly revision: string; + readonly bundleSha256: string; + readonly trustAtPublication: "trusted"; + readonly advisoryStatus: "available" | "unavailable" | "revoked"; + }[]; + readonly advisoryHeadSha256: string; +} + +function aggregateCatalogPayload(payload: ReleaseManifest["payload"]): string { + const entries = payload.filter(({ path }) => path.startsWith("catalog/")); + if (entries.length === 0) + throw new Error("Bundled catalog inventory is empty"); + const lines = entries + .toSorted((left, right) => left.path.localeCompare(right.path)) + .map( + ({ path, size, sha256, mode }) => + `${path}\t${String(size)}\t${sha256}\t${mode}\n`, + ) + .join(""); + return createHash("sha256").update(lines).digest("hex"); +} + +export async function verifyBundledFirstPartyCatalog(options: { + readonly releaseRoot: string; + readonly release: BundledCatalogReleaseIdentity; + readonly fetchImplementation?: typeof fetch | undefined; +}): Promise { + const { catalog } = options.release.components; + if (aggregateCatalogPayload(options.release.payload) !== catalog.sha256) { + throw new Error( + "Bundled catalog release identity does not match the manifest", + ); + } + const advisoryEntry = options.release.payload.find( + ({ path }) => path === "catalog/advisories.jsonl", + ); + if (advisoryEntry?.sha256 !== catalog.advisorySha256) { + throw new Error("Bundled advisory identity does not match the manifest"); + } + const advisoryBytes = await readFile( + resolve(options.releaseRoot, advisoryEntry.path), + ); + if ( + createHash("sha256").update(advisoryBytes).digest("hex") !== + catalog.advisorySha256 + ) { + throw new Error("Bundled advisory bytes failed release verification"); + } + + const result = await verifyCatalog(options.releaseRoot, RELEASE_ID, { + requireGitHubBaseline: false, + fetchImplementation: options.fetchImplementation, + }); + if ( + !result.valid || + result.revisions.length !== 10 || + result.revisions.some(({ valid }) => !valid) || + !Object.values(result.checks).every( + (value) => typeof value !== "boolean" || value, + ) + ) { + throw new Error("Bundled catalog or advisory verification failed"); + } + + const provider = loadVerifiedCatalogProvider(options.releaseRoot, RELEASE_ID); + const metadata = provider.listMetadata(); + if ( + metadata.length !== 10 || + new Set(metadata.map(({ id }) => id)).size !== 10 || + metadata.some( + ({ currentAdvisoryStatus }) => + currentAdvisoryStatus === "revoked" || + currentAdvisoryStatus === "unavailable", + ) + ) { + throw new Error("First-party catalog eligibility is invalid"); + } + const resultById = new Map( + result.revisions.map((revision) => [revision.skillId, revision]), + ); + return Object.freeze({ + releaseId: RELEASE_ID, + provider, + revisions: Object.freeze( + metadata.map((entry) => { + const verified = resultById.get(entry.id); + if ( + verified?.revision !== entry.revision || + verified.bundleSha256 !== + provider.findRevision(entry.id, entry.revision)?.bundleSha256 + ) { + throw new Error("First-party revision identity is inconsistent"); + } + return Object.freeze({ + skillId: entry.id, + revision: entry.revision, + bundleSha256: verified.bundleSha256, + trustAtPublication: "trusted" as const, + advisoryStatus: entry.currentAdvisoryStatus, + }); + }), + ), + advisoryHeadSha256: catalog.advisorySha256, + }); +} diff --git a/src/onboarding/application/production-lifecycle.ts b/src/onboarding/application/production-lifecycle.ts index 58dd829..40ee7bc 100644 --- a/src/onboarding/application/production-lifecycle.ts +++ b/src/onboarding/application/production-lifecycle.ts @@ -30,6 +30,7 @@ import { import { CodexClientAdapter } from "../adapters/clients/codex.js"; import { ClaudeClientAdapter } from "../adapters/clients/claude.js"; import { SecretToolCredentialStore } from "../adapters/credentials/secret-tool.js"; +import { GitHubTokenCredentialStore } from "../adapters/credentials/github-token.js"; import { RestrictiveFileCredentialStore, type RestrictiveFileReference, @@ -80,10 +81,12 @@ import { previewProductionSetup, } from "./production-setup.js"; import { + degradedSourceProbe, diagnosticProbe, runDiagnosticProbes, type DiagnosticProbe, } from "./diagnostic-probes.js"; +import { readProtectedSourceChoices } from "./source-bootstrap.js"; import { runDoctor } from "./doctor.js"; import { inspectInstalledStatus } from "./status.js"; import { planRepair, runRepair, type RepairAsset } from "./repair.js"; @@ -917,6 +920,23 @@ async function doctorOperation( diagnosticProbe("journal-recovery-required", { state: "invalid" }), ); } + try { + const sources = await readProtectedSourceChoices( + resolve(roots.stateRoot, "source-choices.json"), + ); + if ( + sources !== undefined && + sources.installationId !== installation.installationId + ) { + throw new Error("Source choice installation identity differs"); + } + if (sources !== undefined) + probes.push(...sources.choices.map(degradedSourceProbe)); + } catch { + probes.push( + diagnosticProbe("source-degraded", { state: "invalid-or-unsafe" }), + ); + } } const findings = await runDoctor(await runDiagnosticProbes(probes, signal)); return result({ @@ -3712,6 +3732,10 @@ async function purgeOperation( "/usr/bin/secret-tool", environment, ); + const sourceCredentialStore = new GitHubTokenCredentialStore( + "/usr/bin/secret-tool", + environment, + ); const fallback = new RestrictiveFileCredentialStore( roots.dataRoot, roots.dataRoot, @@ -3822,6 +3846,14 @@ async function purgeOperation( throw new Error("PostgreSQL volume ownership is ambiguous"); return clientComponentIdentity({ volumeName: asset.locator }); } + if ( + asset.kind === "credential" && + asset.client === null && + asset.locator.startsWith("secret-service:github:") + ) { + await sourceCredentialStore.lookup(asset.locator, signal); + return clientComponentIdentity({ reference: asset.locator }); + } if (asset.kind === "credential" && asset.client !== null) { const entry = bridge.clients.find( ({ client, credentialReference }) => @@ -3860,6 +3892,14 @@ async function purgeOperation( }); return; } + if ( + asset.kind === "credential" && + asset.client === null && + asset.locator.startsWith("secret-service:github:") + ) { + await sourceCredentialStore.clear(asset.locator, signal); + return; + } if (asset.kind === "credential" && asset.client !== null) { if (asset.locator.startsWith("secret-service:")) await secretService.clear( diff --git a/src/onboarding/application/production-setup.ts b/src/onboarding/application/production-setup.ts index 1729582..4a17831 100644 --- a/src/onboarding/application/production-setup.ts +++ b/src/onboarding/application/production-setup.ts @@ -72,6 +72,7 @@ import { import { verifyClientIntegration } from "./client-verification.js"; import { inspectInstalledStatus } from "./status.js"; import { continueProductionSetup } from "./production-continuation.js"; +import { verifyBundledFirstPartyCatalog } from "./first-party-catalog.js"; import type { GuidedSetupOptions, GuidedSetupResult, @@ -546,7 +547,8 @@ export interface ProductionSetupPreview { readonly components: readonly string[]; readonly volumes: readonly string[]; readonly retainedOnFailure: readonly string[]; - readonly catalogChoice: "deferred"; + readonly catalogChoice: "bundled-first-party"; + readonly sources: readonly ("mattpocock/skills" | "obra/superpowers")[]; } interface CandidatePaths { @@ -833,7 +835,8 @@ function setupPreviewScope( components: ["service", "postgres", "credential-bridge", ...clients], volumes: ["skillwire-_postgres_data"], retainedOnFailure: ["verified release", "service data", "service secrets"], - catalogChoice: "deferred", + catalogChoice: "bundled-first-party", + sources: options.sources ?? [], }; } @@ -932,7 +935,7 @@ async function runProductionSetupUnlocked( } const { manifest } = verified; const installationId = randomUUID(); - const projectName = `skillwire-${installationId.replaceAll("-", "").slice(0, 16)}`; + const projectName = `skillwire-${installationId.replaceAll("-", "")}`; const volumeName = `${projectName}_postgres_data`; const runEffect = async (effectOptions: { readonly step: string; @@ -967,6 +970,10 @@ async function runProductionSetupUnlocked( launcherInstalled: value.launcherPath === setupRoots.launcherPath, }), }); + await verifyBundledFirstPartyCatalog({ + releaseRoot: installed.releaseRoot, + release: manifest, + }); const installationRoot = resolve( setupRoots.dataRoot, "installations", @@ -1038,6 +1045,7 @@ async function runProductionSetupUnlocked( intent: { projectName, volumeName }, action: async () => { await deployment.probe(signal); + await deployment.assertDeploymentTargetsAbsent(signal); await deployment.deploy(signal); }, verification: () => ({ composeReady: true }), diff --git a/src/onboarding/application/setup-duration.ts b/src/onboarding/application/setup-duration.ts new file mode 100644 index 0000000..bdc3692 --- /dev/null +++ b/src/onboarding/application/setup-duration.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; + +const SetupDurationEvidenceSchema = z + .object({ + schemaVersion: z.literal("skillwire.setup-duration/v1"), + gating: z.literal(false), + environment: z.string().min(1).max(160), + result: z.enum(["completed", "incomplete", "cancelled"]), + elapsedMilliseconds: z.number().nonnegative(), + sourceCommit: z.string().regex(/^[0-9a-f]{40}$/), + manifestSha256: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict(); + +export type SetupDurationEvidence = z.infer; + +export function recordSetupDuration(options: { + readonly startedMilliseconds: number; + readonly endedMilliseconds: number; + readonly environment: string; + readonly result: SetupDurationEvidence["result"]; + readonly sourceCommit: string; + readonly manifestSha256: string; +}): SetupDurationEvidence { + if ( + !Number.isFinite(options.startedMilliseconds) || + !Number.isFinite(options.endedMilliseconds) || + options.endedMilliseconds < options.startedMilliseconds + ) { + throw new Error("Setup duration observation is invalid"); + } + return SetupDurationEvidenceSchema.parse({ + schemaVersion: "skillwire.setup-duration/v1", + gating: false, + environment: options.environment, + result: options.result, + sourceCommit: options.sourceCommit, + manifestSha256: options.manifestSha256, + elapsedMilliseconds: + options.endedMilliseconds - options.startedMilliseconds, + }); +} diff --git a/src/onboarding/application/setup.ts b/src/onboarding/application/setup.ts index 75489b2..5a5d75d 100644 --- a/src/onboarding/application/setup.ts +++ b/src/onboarding/application/setup.ts @@ -1,8 +1,11 @@ import type { ClientName } from "../cli/main.js"; +import type { BootstrapSource } from "../domain/source-choice.js"; +import type { SourceChoice } from "../domain/source-choice.js"; import type { ClientConflictFinding } from "./client-lifecycle.js"; export interface GuidedSetupOptions { readonly clients: "none" | "codex" | "claude" | "codex,claude"; + readonly sources?: readonly BootstrapSource[] | undefined; } export interface SetupClientResult { @@ -38,6 +41,10 @@ export interface GuidedSetupDependencies { client: ClientName, installationId: string, ): Promise; + bootstrapSources?(selected: readonly BootstrapSource[]): Promise<{ + readonly choices: readonly SourceChoice[]; + readonly changed: boolean; + }>; } export interface RetainedSetupState { @@ -50,9 +57,39 @@ export interface GuidedSetupResult { readonly installationId: string; readonly serviceReady: boolean; readonly clients: readonly SetupClientResult[]; + readonly sources?: readonly SourceChoice[] | undefined; readonly changed?: boolean | undefined; } +async function withSources( + options: GuidedSetupOptions, + dependencies: GuidedSetupDependencies, + result: GuidedSetupResult, +): Promise { + const selected = options.sources ?? []; + if (selected.length === 0) return result; + if (dependencies.bootstrapSources === undefined) + throw new Error("Explicit source bootstrap is unavailable"); + if (!result.serviceReady) + throw new Error("Sources cannot bootstrap before service readiness"); + const bootstrapped = await dependencies.bootstrapSources(selected); + const degraded = bootstrapped.choices.some( + ({ selected: chosen, syncState }) => + chosen && (syncState === "degraded" || syncState === "failed"), + ); + return { + ...result, + status: + result.status === "recovery-required" + ? result.status + : degraded + ? "incomplete" + : result.status, + sources: bootstrapped.choices, + changed: result.changed !== false || bootstrapped.changed, + }; +} + function selectedClients( selection: GuidedSetupOptions["clients"], ): readonly ClientName[] { @@ -66,7 +103,8 @@ export async function runGuidedSetup( dependencies: GuidedSetupDependencies, ): Promise { const existing = await dependencies.inspectExisting?.(options); - if (existing !== undefined) return existing; + if (existing !== undefined) + return withSources(options, dependencies, existing); const release = await dependencies.verifyRelease(); const retained = await dependencies.discoverRetained?.(options); if (retained !== undefined) { @@ -104,12 +142,12 @@ export async function runGuidedSetup( ) ? "incomplete" : "success"; - return { + return withSources(options, dependencies, { status, installationId: retained.installationId, serviceReady: true, clients, - }; + }); } const service = await dependencies.installService(release); if (!service.ready) @@ -130,10 +168,10 @@ export async function runGuidedSetup( ) ? "incomplete" : "success"; - return { + return withSources(options, dependencies, { status, installationId: service.installationId, serviceReady: true, clients, - }; + }); } diff --git a/src/onboarding/application/source-bootstrap.ts b/src/onboarding/application/source-bootstrap.ts new file mode 100644 index 0000000..f1cf15c --- /dev/null +++ b/src/onboarding/application/source-bootstrap.ts @@ -0,0 +1,795 @@ +import { randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { open } from "node:fs/promises"; +import { isAbsolute, resolve } from "node:path"; + +import { z } from "zod"; + +import type { CandidateClassification } from "../../domain/external-catalog/types.js"; +import { SourceRegistrationService } from "../../application/services/source-registration-service.js"; +import { SourceSynchronizationService } from "../../application/services/source-synchronization-service.js"; +import type { ExternalCatalogStore } from "../../application/ports/external-catalog-store.js"; +import type { GitHubSourceProvider } from "../../application/ports/github-source-provider.js"; +import { + runCommand, + type CommandOptions, +} from "../adapters/process/command-runner.js"; +import { clientComponentIdentity } from "../adapters/clients/client-state.js"; +import { + assertLocalDockerContext, + dockerProcessEnvironment, + pinLocalDockerEndpoint, +} from "../adapters/docker/environment.js"; +import { atomicWriteJson } from "../adapters/filesystem/atomic-state.js"; +import { GitHubTokenCredentialStore } from "../adapters/credentials/github-token.js"; +import { + BOOTSTRAP_SOURCES, + SourceChoiceSchema, + sourceCoordinate, + type BootstrapSource, + type SourceChoice, +} from "../domain/source-choice.js"; +import { + currentProcessIdentity, + InstallationLock, +} from "../domain/operation-journal.js"; +import { + recordOwnedAsset, + verifyOwnershipRecord, +} from "../domain/ownership.js"; + +export interface SelectedBootstrapSource { + readonly source: BootstrapSource; + readonly credentialReferenceId: string; +} + +export interface ExistingSourceRegistration { + readonly sourceId: string; + readonly owner: string; + readonly repository: string; + readonly syncState?: SourceChoice["syncState"] | undefined; +} + +export interface SourceBootstrapSyncResult { + readonly sourceId: string; + readonly classifications: readonly (CandidateClassification | "revoked")[]; + readonly created: boolean; + readonly evidence?: Readonly> | undefined; +} + +export interface SourceBootstrapDependencies { + listRegistrations( + signal: AbortSignal, + ): Promise; + register( + coordinate: { readonly owner: string; readonly repository: string }, + credentialReferenceId: string, + signal: AbortSignal, + ): Promise<{ readonly sourceId: string; readonly created: boolean }>; + synchronize( + sourceId: string, + credentialReferenceId: string, + signal: AbortSignal, + ): Promise; +} + +export class ProductionSourceBootstrapError extends Error { + public constructor( + message: string, + readonly changed: boolean, + options?: ErrorOptions, + ) { + super(message, options); + this.name = "ProductionSourceBootstrapError"; + } +} + +function unselected(source: BootstrapSource): SourceChoice { + return SourceChoiceSchema.parse({ + schemaVersion: "skillwire.source-choice/v1", + sourceChoiceId: randomUUID(), + source, + selected: false, + credentialReferenceId: null, + registrationIdentity: null, + syncState: "not-selected", + }); +} + +export function sourceChoices( + selected: readonly SelectedBootstrapSource[], +): readonly SourceChoice[] { + const selections = new Map(selected.map((choice) => [choice.source, choice])); + if (selections.size !== selected.length) + throw new Error("Bootstrap source selection contains duplicates"); + return BOOTSTRAP_SOURCES.map((source) => { + const choice = selections.get(source); + if (choice === undefined) return unselected(source); + return SourceChoiceSchema.parse({ + schemaVersion: "skillwire.source-choice/v1", + sourceChoiceId: randomUUID(), + source, + selected: true, + credentialReferenceId: choice.credentialReferenceId, + registrationIdentity: null, + syncState: "failed", + }); + }); +} + +function syncState( + classifications: SourceBootstrapSyncResult["classifications"], +): SourceChoice["syncState"] { + if ( + classifications.some( + (classification) => + classification === "quarantined" || classification === "revoked", + ) + ) + return "quarantined"; + if ( + classifications.length > 0 && + classifications.every( + (classification) => + classification === "verified" || classification === "curated", + ) + ) + return "eligible"; + return "verifying"; +} + +export async function bootstrapSources( + selected: readonly SelectedBootstrapSource[], + dependencies: SourceBootstrapDependencies, + signal: AbortSignal = new AbortController().signal, +): Promise { + const choices = sourceChoices(selected); + if (selected.length === 0) return choices; + signal.throwIfAborted(); + const registrations = await dependencies.listRegistrations(signal); + const results: SourceChoice[] = []; + for (const choice of choices) { + if (!choice.selected) { + results.push(choice); + continue; + } + if (choice.credentialReferenceId === null) + throw new Error("Selected source credential reference is missing"); + const credentialReferenceId = choice.credentialReferenceId; + signal.throwIfAborted(); + const coordinate = sourceCoordinate(choice.source); + const existing = registrations.find( + ({ owner, repository }) => + owner.toLowerCase() === coordinate.owner && + repository.toLowerCase() === coordinate.repository, + ); + let registrationIdentity = existing?.sourceId ?? null; + try { + if ( + registrationIdentity !== null && + existing?.syncState !== undefined && + ["eligible", "quarantined", "verifying"].includes(existing.syncState) + ) { + results.push( + SourceChoiceSchema.parse({ + ...choice, + registrationIdentity, + syncState: existing.syncState, + }), + ); + continue; + } + registrationIdentity ??= ( + await dependencies.register(coordinate, credentialReferenceId, signal) + ).sourceId; + const synchronized = await dependencies.synchronize( + registrationIdentity, + credentialReferenceId, + signal, + ); + if (synchronized.sourceId !== registrationIdentity) + throw new Error("SOURCE_IDENTITY_MISMATCH"); + results.push( + SourceChoiceSchema.parse({ + ...choice, + registrationIdentity, + syncState: syncState(synchronized.classifications), + }), + ); + } catch (error) { + if (signal.aborted) throw error; + results.push( + SourceChoiceSchema.parse({ + ...choice, + registrationIdentity, + syncState: registrationIdentity === null ? "failed" : "degraded", + }), + ); + } + } + return Object.freeze(results); +} + +export function existingIngestionSourceDependencies(options: { + readonly store: ExternalCatalogStore; + readonly credentialStore: { + lookup(reference: string, signal?: AbortSignal): Promise; + }; + readonly provider: (token: string) => GitHubSourceProvider; + readonly actorId: string; +}): SourceBootstrapDependencies { + const credential = ( + referenceId: string, + signal: AbortSignal, + ): Promise => + options.credentialStore.lookup( + `secret-service:github:${referenceId}`, + signal, + ); + return { + listRegistrations: async (signal) => { + signal.throwIfAborted(); + const [registrations, administrative] = await Promise.all([ + options.store.listSources({ signal }), + options.store.listAdministrativeSources(undefined, { signal }), + ]); + return registrations.map(({ sourceId, repository }) => { + const classification = administrative.find( + (source) => source.sourceId === sourceId, + )?.classification; + return { + sourceId, + owner: repository.owner, + repository: repository.repository, + ...(classification === undefined + ? {} + : { syncState: syncState([classification]) }), + }; + }); + }, + register: async (coordinate, credentialReferenceId, signal) => { + const token = await credential(credentialReferenceId, signal); + const service = new SourceRegistrationService( + options.provider(token), + options.store, + ); + const registration = await service.add(coordinate, options.actorId, { + signal, + }); + return { + sourceId: registration.sourceId, + created: registration.created, + }; + }, + synchronize: async (sourceId, credentialReferenceId, signal) => { + const token = await credential(credentialReferenceId, signal); + const service = new SourceSynchronizationService( + options.provider(token), + options.store, + ); + const snapshot = await service.sync(sourceId, { signal }); + return { + sourceId: snapshot.sourceId, + classifications: snapshot.candidateTraces.map( + ({ classification }) => classification, + ), + created: snapshot.created, + }; + }, + }; +} + +const ContainerBootstrapResultSchema = z + .object({ + schemaVersion: z.literal("skillwire.source-bootstrap-result/v1"), + sourceId: z.uuid(), + registrationCreated: z.boolean(), + snapshotCreated: z.boolean(), + classifications: z.array( + z.enum(["discovered", "verified", "quarantined", "curated"]), + ), + }) + .strict(); + +export async function bootstrapSourceInAdminContainer(options: { + readonly source: BootstrapSource; + readonly token: string; + readonly dockerExecutable: string; + readonly composePath: string; + readonly projectName: string; + readonly databasePasswordFile: string; + readonly applicationPepperFile: string; + readonly runtimeSocketDirectory: string; + readonly volumeName: string; + readonly skillwireImage: string; + readonly postgresImage: string; + readonly environment: NodeJS.ProcessEnv; + readonly signal?: AbortSignal | undefined; + readonly run?: + | ((options: CommandOptions) => Promise<{ + readonly stdout: string; + readonly stderr: string; + readonly code: number; + }>) + | undefined; +}): Promise { + if ( + !isAbsolute(options.dockerExecutable) || + !isAbsolute(options.composePath) || + !isAbsolute(options.databasePasswordFile) || + !isAbsolute(options.applicationPepperFile) || + !isAbsolute(options.runtimeSocketDirectory) || + options.databasePasswordFile.includes(":") || + !/^skillwire-[a-z0-9-]+$/.test(options.projectName) || + options.volumeName !== `${options.projectName}_postgres_data` || + !/^[a-z0-9./:_-]+@sha256:[0-9a-f]{64}$/.test(options.skillwireImage) || + !/^[a-z0-9./:_-]+@sha256:[0-9a-f]{64}$/.test(options.postgresImage) + ) { + throw new Error("Source bootstrap deployment identity is invalid"); + } + const coordinate = sourceCoordinate(options.source); + const run = options.run ?? runCommand; + const result = await run({ + executable: resolve(options.dockerExecutable), + args: [ + "compose", + "--project-name", + options.projectName, + "--file", + options.composePath, + "run", + "--rm", + "--no-TTY", + "--no-deps", + "--user", + `${String(process.getuid?.() ?? 10001)}:${String(process.getgid?.() ?? 10001)}`, + "--entrypoint", + "node", + "--volume", + `${options.databasePasswordFile}:/run/skillwire-source/database-password:ro`, + "--env", + "SKILLWIRE_DATABASE_PASSWORD_FILE=/run/skillwire-source/database-password", + "admin", + "dist/src/ingestion/bootstrap-cli.js", + coordinate.owner, + coordinate.repository, + ], + environment: dockerProcessEnvironment(options.environment, { + SKILLWIRE_COMPOSE_PROJECT: options.projectName, + SKILLWIRE_POSTGRES_VOLUME: options.volumeName, + SKILLWIRE_IMAGE: options.skillwireImage, + SKILLWIRE_POSTGRES_IMAGE: options.postgresImage, + SKILLWIRE_DATABASE_PASSWORD_SECRET_FILE: options.databasePasswordFile, + SKILLWIRE_APPLICATION_PEPPER_SECRET_FILE: options.applicationPepperFile, + SKILLWIRE_RUNTIME_SOCKET_DIRECTORY: options.runtimeSocketDirectory, + SKILLWIRE_RUNTIME_UID: String(process.getuid?.() ?? 10001), + SKILLWIRE_RUNTIME_GID: String(process.getgid?.() ?? 10001), + }), + stdin: options.token, + deadlineMilliseconds: 320_000, + maximumOutputBytes: 64 * 1024, + signal: options.signal, + }); + const parsed = ContainerBootstrapResultSchema.parse( + JSON.parse(result.stdout) as unknown, + ); + return { + sourceId: parsed.sourceId, + classifications: parsed.classifications, + created: parsed.registrationCreated || parsed.snapshotCreated, + }; +} + +const SourceChoiceStateSchema = z + .object({ + schemaVersion: z.literal("skillwire.source-choices/v1"), + installationId: z.uuid(), + choices: z.array(SourceChoiceSchema).length(2), + }) + .strict(); + +const ProductionSourceDeploymentSchema = z.looseObject({ + schemaVersion: z.literal("skillwire.deployment/v1"), + installationId: z.uuid(), + composePath: z.string().refine(isAbsolute), + projectName: z.string().regex(/^skillwire-[a-z0-9-]+$/), + databasePasswordFile: z.string().refine(isAbsolute), + applicationPepperFile: z.string().refine(isAbsolute), + runtimeSocketDirectory: z.string().refine(isAbsolute), + volumeName: z.string().regex(/^skillwire-[a-z0-9-]+_postgres_data$/), + skillwireImage: z.string().regex(/^[a-z0-9./:_-]+@sha256:[0-9a-f]{64}$/), + postgresImage: z.string().regex(/^[a-z0-9./:_-]+@sha256:[0-9a-f]{64}$/), +}); + +async function readProtectedJson(path: string): Promise { + const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const stats = await handle.stat(); + if ( + !stats.isFile() || + stats.nlink !== 1 || + stats.uid !== process.getuid?.() || + (stats.mode & 0o777) !== 0o600 || + stats.size > 128 * 1024 + ) { + throw new Error("Source bootstrap state is unsafe"); + } + return JSON.parse(await handle.readFile("utf8")) as unknown; + } finally { + await handle.close(); + } +} + +export async function readProductionSourceDeployment( + stateRoot: string, +): Promise { + const parsed = ProductionSourceDeploymentSchema.parse( + await readProtectedJson(resolve(stateRoot, "deployment.json")), + ); + return { + installationId: parsed.installationId, + composePath: parsed.composePath, + projectName: parsed.projectName, + databasePasswordFile: parsed.databasePasswordFile, + applicationPepperFile: parsed.applicationPepperFile, + runtimeSocketDirectory: parsed.runtimeSocketDirectory, + volumeName: parsed.volumeName, + skillwireImage: parsed.skillwireImage, + postgresImage: parsed.postgresImage, + }; +} + +export async function readProtectedSourceChoices( + path: string, +): Promise | undefined> { + let handle; + try { + handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") + return undefined; + throw error; + } + try { + const stats = await handle.stat(); + if ( + !stats.isFile() || + stats.nlink !== 1 || + stats.uid !== process.getuid?.() || + (stats.mode & 0o777) !== 0o600 || + stats.size > 128 * 1024 + ) { + throw new Error("Source choice state is unsafe"); + } + return SourceChoiceStateSchema.parse( + JSON.parse(await handle.readFile("utf8")) as unknown, + ); + } finally { + await handle.close(); + } +} + +export interface ProductionSourceDeployment { + readonly installationId: string; + readonly composePath: string; + readonly projectName: string; + readonly databasePasswordFile: string; + readonly applicationPepperFile: string; + readonly runtimeSocketDirectory: string; + readonly volumeName: string; + readonly skillwireImage: string; + readonly postgresImage: string; +} + +interface ProductionSourceBootstrapOptions { + readonly selected: readonly BootstrapSource[]; + readonly deployment: ProductionSourceDeployment; + readonly stateRoot: string; + readonly runtimeRoot: string; + readonly environment: NodeJS.ProcessEnv; + readonly token?: string | undefined; + readonly signal: AbortSignal; + readonly operationId?: string | undefined; + readonly credentialStore?: + Pick | undefined; + readonly bootstrap?: typeof bootstrapSourceInAdminContainer | undefined; + readonly resolveDockerEnvironment?: + | (( + environment: NodeJS.ProcessEnv, + signal: AbortSignal, + ) => Promise) + | undefined; +} + +async function bootstrapProductionSourcesUnlocked( + options: ProductionSourceBootstrapOptions, +): Promise<{ + readonly choices: readonly SourceChoice[]; + readonly changed: boolean; +}> { + if (options.selected.length === 0) + return { choices: sourceChoices([]), changed: false }; + if (new Set(options.selected).size !== options.selected.length) + throw new Error("Bootstrap source selection contains duplicates"); + const statePath = resolve(options.stateRoot, "source-choices.json"); + const ownershipPath = resolve(options.stateRoot, "ownership.json"); + const previous = await readProtectedSourceChoices(statePath); + if ( + previous !== undefined && + previous.installationId !== options.deployment.installationId + ) { + throw new Error("Source choices belong to another installation"); + } + const priorBySource = new Map( + previous?.choices.map((choice) => [choice.source, choice]) ?? [], + ); + let ownership = verifyOwnershipRecord(await readProtectedJson(ownershipPath)); + if (ownership.installationId !== options.deployment.installationId) + throw new Error( + "Source credential ownership belongs to another installation", + ); + const ownedSourceCredentials = ownership.assets.filter( + ({ kind, client, locator, disposition }) => + kind === "credential" && + client === null && + locator.startsWith("secret-service:github:") && + (disposition === "present" || disposition === "retained"), + ); + if (ownedSourceCredentials.length > 1) + throw new Error("Source credential ownership is ambiguous"); + const ownedSourceCredential = ownedSourceCredentials.at(0); + if ( + ownedSourceCredential !== undefined && + ownedSourceCredential.expectedIdentitySha256 !== + clientComponentIdentity({ reference: ownedSourceCredential.locator }) + ) { + throw new Error("Source credential ownership identity is invalid"); + } + const ownedReferenceId = ownedSourceCredential?.locator.split(":").at(-1); + if ( + ownedReferenceId !== undefined && + !z.uuid().safeParse(ownedReferenceId).success + ) + throw new Error("Source credential ownership reference is invalid"); + for (const source of options.selected) { + const priorReferenceId = priorBySource.get(source)?.credentialReferenceId; + if ( + priorReferenceId !== null && + priorReferenceId !== undefined && + priorReferenceId !== ownedReferenceId + ) { + throw new Error("Source state is not bound to owned credential metadata"); + } + } + const unchanged = options.selected.every((source) => { + const prior = priorBySource.get(source); + return ( + prior?.selected === true && + prior.credentialReferenceId !== null && + ["eligible", "quarantined", "verifying"].includes(prior.syncState) + ); + }); + if (unchanged && previous !== undefined) + return { choices: previous.choices, changed: false }; + + const credentialStore = + options.credentialStore ?? + new GitHubTokenCredentialStore("/usr/bin/secret-tool", options.environment); + let stored: + { readonly reference: string; readonly referenceId: string } | undefined; + if (ownedSourceCredential !== undefined && ownedReferenceId !== undefined) { + stored = { + reference: ownedSourceCredential.locator, + referenceId: ownedReferenceId, + }; + } + let changed = false; + const completed = new Map(); + const choiceIds = new Map( + BOOTSTRAP_SOURCES.map((source) => [ + source, + priorBySource.get(source)?.sourceChoiceId ?? randomUUID(), + ]), + ); + const failedChoice = ( + source: BootstrapSource, + credentialReferenceId: string | null, + ): SourceChoice => { + const prior = priorBySource.get(source); + return SourceChoiceSchema.parse({ + schemaVersion: "skillwire.source-choice/v1", + sourceChoiceId: choiceIds.get(source), + source, + selected: true, + credentialReferenceId, + registrationIdentity: prior?.registrationIdentity ?? null, + syncState: + prior?.registrationIdentity === undefined || + prior.registrationIdentity === null + ? "failed" + : "degraded", + }); + }; + const currentChoices = ( + sharedCredentialReferenceId: string | null = null, + ): readonly SourceChoice[] => + BOOTSTRAP_SOURCES.map((source) => { + const result = completed.get(source); + if (result !== undefined) return result; + const prior = priorBySource.get(source); + if (!options.selected.includes(source)) + return prior ?? unselected(source); + return prior?.selected === true + ? prior + : failedChoice(source, sharedCredentialReferenceId); + }); + const publish = async ( + sharedCredentialReferenceId: string | null = null, + ): Promise => { + const choices = currentChoices(sharedCredentialReferenceId); + await atomicWriteJson( + statePath, + { + schemaVersion: "skillwire.source-choices/v1", + installationId: options.deployment.installationId, + choices, + }, + options.stateRoot, + ); + changed = true; + return choices; + }; + let choices = currentChoices(); + for (const source of BOOTSTRAP_SOURCES) { + if (!options.selected.includes(source)) { + continue; + } + const prior = priorBySource.get(source); + if ( + prior?.selected === true && + prior.credentialReferenceId !== null && + ["eligible", "quarantined", "verifying"].includes(prior.syncState) + ) { + completed.set(source, prior); + continue; + } + let credentialReferenceId = prior?.credentialReferenceId ?? null; + let token: string | undefined; + try { + if (credentialReferenceId !== null) { + token = await credentialStore.lookup( + `secret-service:github:${credentialReferenceId}`, + options.signal, + ); + } else { + if (stored === undefined) { + if (options.token === undefined) + throw new Error("GitHub source credential is unavailable"); + const candidate = await credentialStore.store( + options.token, + options.signal, + ); + const next = recordOwnedAsset( + { record: ownership, externalIntegrations: [] }, + { + assetId: candidate.referenceId, + kind: "credential", + client: null, + locator: candidate.reference, + expectedIdentitySha256: clientComponentIdentity({ + reference: candidate.reference, + }), + createdByOperation: options.operationId ?? randomUUID(), + retention: "remove-only-on-purge", + disposition: "present", + }, + ).record; + try { + await atomicWriteJson(ownershipPath, next, options.stateRoot); + ownership = next; + } catch (error) { + const published = await readProtectedJson(ownershipPath) + .then((value) => + verifyOwnershipRecord(value).assets.some( + ({ locator }) => locator === candidate.reference, + ), + ) + .catch(() => false); + if (!published) + await credentialStore + .clear(candidate.reference) + .catch(() => undefined); + throw error; + } + stored = candidate; + changed = true; + } + credentialReferenceId = stored.referenceId; + token = + options.token ?? + (await credentialStore.lookup(stored.reference, options.signal)); + } + completed.set(source, failedChoice(source, credentialReferenceId)); + choices = await publish(stored?.referenceId ?? credentialReferenceId); + const synchronized = await ( + options.bootstrap ?? bootstrapSourceInAdminContainer + )({ + source, + token, + dockerExecutable: "/usr/bin/docker", + composePath: options.deployment.composePath, + projectName: options.deployment.projectName, + databasePasswordFile: options.deployment.databasePasswordFile, + applicationPepperFile: options.deployment.applicationPepperFile, + runtimeSocketDirectory: options.deployment.runtimeSocketDirectory, + volumeName: options.deployment.volumeName, + skillwireImage: options.deployment.skillwireImage, + postgresImage: options.deployment.postgresImage, + environment: options.environment, + signal: options.signal, + }); + completed.set( + source, + SourceChoiceSchema.parse({ + schemaVersion: "skillwire.source-choice/v1", + sourceChoiceId: choiceIds.get(source), + source, + selected: true, + credentialReferenceId, + registrationIdentity: synchronized.sourceId, + syncState: syncState(synchronized.classifications), + }), + ); + choices = await publish(stored?.referenceId ?? credentialReferenceId); + } catch (error) { + completed.set(source, failedChoice(source, credentialReferenceId)); + choices = await publish(stored?.referenceId ?? credentialReferenceId); + if (options.signal.aborted) { + throw new ProductionSourceBootstrapError( + "Source bootstrap cancelled at a persisted safe retry boundary", + changed, + { cause: error }, + ); + } + } + } + return { choices: Object.freeze(choices), changed: true }; +} + +export async function bootstrapProductionSources( + options: ProductionSourceBootstrapOptions, +): Promise<{ + readonly choices: readonly SourceChoice[]; + readonly changed: boolean; +}> { + options.signal.throwIfAborted(); + const lock = await InstallationLock.acquire( + resolve(options.runtimeRoot, "locks"), + "installation", + await currentProcessIdentity(), + ); + try { + options.signal.throwIfAborted(); + const resolveDockerEnvironment = + options.resolveDockerEnvironment ?? + (async (environment: NodeJS.ProcessEnv, signal: AbortSignal) => + pinLocalDockerEndpoint( + environment, + await assertLocalDockerContext({ + dockerExecutable: "/usr/bin/docker", + environment, + signal, + }), + )); + const environment = await resolveDockerEnvironment( + options.environment, + options.signal, + ); + return await bootstrapProductionSourcesUnlocked({ + ...options, + environment, + }); + } finally { + await lock.release(); + } +} diff --git a/src/onboarding/cli/command-router.ts b/src/onboarding/cli/command-router.ts index 684103b..9218909 100644 --- a/src/onboarding/cli/command-router.ts +++ b/src/onboarding/cli/command-router.ts @@ -13,6 +13,12 @@ import { runProductionSetup, } from "../application/production-setup.js"; import { inspectInstalledStatus } from "../application/status.js"; +import { + bootstrapProductionSources, + ProductionSourceBootstrapError, + readProductionSourceDeployment, +} from "../application/source-bootstrap.js"; +import { readBoundedGitHubToken } from "../adapters/credentials/github-token.js"; import { JournaledOperationFailure } from "../domain/operation-journal.js"; import type { AdminResult, ExitClass } from "./output.js"; @@ -77,8 +83,10 @@ export function setupFailureEnvelope(options: { readonly operationId: string; readonly previewHash: string | null; readonly cancelled: boolean; + readonly changed?: boolean | undefined; }): AdminResult { const mutated = options.error instanceof ProductionSetupMutationError; + const changed = options.changed === true || mutated; const exitClass = failureClass(options.error); return AdminResultSchema.parse({ schemaVersion: "skillwire.admin-result/v1", @@ -95,9 +103,11 @@ export function setupFailureEnvelope(options: { ? "rollback-required" : exitClass, previewHash: options.previewHash, - changed: mutated, - summary: mutated - ? "Setup stopped after an owned installation mutation began" + changed, + summary: changed + ? mutated + ? "Setup stopped after an owned installation mutation began" + : "Setup stopped after reaching a persisted safe boundary" : "Setup stopped before a successful final state", components: [], findings: [ @@ -133,42 +143,11 @@ async function routeSetup( ): Promise { const operationId = randomUUID(); let previewHash: string | null = null; - if ((command.sources?.length ?? 0) > 0) { - return emit( - AdminResultSchema.parse({ - schemaVersion: "skillwire.admin-result/v1", - command: "setup", - operationId, - status: "failure", - exitClass: "unsupported-prerequisite", - previewHash: null, - changed: false, - summary: - "External source bootstrap is outside the Feature 004 technical MVP", - components: [], - findings: [ - { - code: "SOURCE_BOOTSTRAP_NOT_AVAILABLE", - severity: "error", - component: "source", - summary: "No source registration was attempted", - nextAction: "Run setup without --source", - }, - ], - recovery: { - rollbackBoundary: "none", - backupId: null, - instructions: [], - }, - }), - command, - io, - ); - } + let setupChanged = false; try { const selection = command.clients ?? "none"; const scope = await previewProductionSetup( - { clients: selection }, + { clients: selection, sources: command.sources ?? [] }, process.env, {}, signal, @@ -220,14 +199,63 @@ async function routeSetup( ); } confirmPreview(preview, command.confirmPreview); - const result = await runProductionSetup( + const setupResult = await runProductionSetup( { clients: selection, + sources: command.sources ?? [], credentialBackend: scope.credentialBackend, previewHash: preview.hash, }, signal, ); + setupChanged = setupResult.changed !== false; + let sourceChoices = setupResult.sources ?? []; + let sourceChanged = false; + if ((command.sources?.length ?? 0) > 0 && setupResult.serviceReady) { + const stateHome = + process.env["XDG_STATE_HOME"] ?? + `${process.env["HOME"] ?? ""}/.local/state`; + const stateRoot = `${stateHome}/skillwire`; + const runtimeRoot = `${process.env["XDG_RUNTIME_DIR"] ?? `/run/user/${String(process.getuid?.() ?? 0)}`}/skillwire`; + let token: string | undefined; + if (!process.stdin.isTTY) { + try { + token = await readBoundedGitHubToken(process.stdin, signal); + } catch (error) { + if (signal.aborted) throw error; + } + } + const bootstrapped = await bootstrapProductionSources({ + selected: command.sources ?? [], + deployment: await readProductionSourceDeployment(stateRoot), + stateRoot, + runtimeRoot, + environment: process.env, + ...(token === undefined ? {} : { token }), + signal, + operationId, + }); + sourceChoices = bootstrapped.choices; + sourceChanged = bootstrapped.changed; + } + const sourceIncomplete = sourceChoices.some( + ({ selected, syncState }) => + selected && (syncState === "degraded" || syncState === "failed"), + ); + const clientIncomplete = setupResult.clients.some( + ({ status }) => status !== "verified" && status !== "external-verified", + ); + const result = { + ...setupResult, + status: + setupResult.status === "recovery-required" + ? setupResult.status + : sourceIncomplete + ? ("incomplete" as const) + : setupResult.status, + sources: sourceChoices, + changed: setupResult.changed !== false || sourceChanged, + }; const exitClass: ExitClass = result.status === "success" ? "success" @@ -242,16 +270,17 @@ async function routeSetup( status: result.status, exitClass, previewHash: preview.hash, - changed: result.changed !== false, - summary: - selection === "none" && result.status === "success" + changed: result.changed, + summary: sourceIncomplete + ? "Self-hosted service is ready; an optional source remains degraded" + : selection === "none" && result.status === "success" ? "Self-hosted service is ready; client integration remains pending" : `Self-hosted setup finished with status ${result.status}`, components: [ { component: "service", state: result.serviceReady ? "ready" : "failed", - changed: result.changed !== false, + changed: setupResult.changed !== false, owned: true, identity: { installationId: result.installationId }, }, @@ -265,38 +294,73 @@ async function routeSetup( external: client.status === "external-verified", }, })), + ...result.sources + .filter(({ selected }) => selected) + .map((source) => ({ + component: "source", + state: source.syncState, + changed: sourceChanged, + owned: false, + identity: { + source: source.source, + registered: source.registrationIdentity !== null, + }, + })), + ], + findings: [ + ...result.clients + .filter( + (client) => + client.status !== "verified" && + client.status !== "external-verified", + ) + .map((client) => + client.conflict === undefined + ? { + code: `${client.client.toUpperCase()}_INSTALLATION_INCOMPLETE`, + severity: + client.status === "recovery-required" + ? ("recovery-required" as const) + : ("error" as const), + component: client.client, + summary: `${client.client} deterministic verification did not complete`, + nextAction: + "Review the client-specific recovery summary and retry after resolution", + } + : { + code: client.conflict.code, + severity: "error" as const, + component: client.client, + summary: `${client.client} ${client.conflict.component} is ${client.conflict.classification} at ${client.conflict.scope} scope (${client.conflict.identitySha256})`, + nextAction: + "Resolve the external client or managed-policy conflict outside SkillWire, then retry", + }, + ), + ...result.sources + .filter( + ({ selected, syncState }) => + selected && + (syncState === "degraded" || syncState === "failed"), + ) + .map((source) => ({ + code: + source.syncState === "degraded" + ? "SOURCE_SYNCHRONIZATION_DEGRADED" + : "SOURCE_BOOTSTRAP_FAILED", + severity: "warning" as const, + component: "source", + summary: `${source.source} did not become eligible; first-party service remains ready`, + nextAction: + source.credentialReferenceId === null + ? "Pipe one separate read-only GitHub token on stdin and retry the same confirmed setup" + : "Keep eligible cached content and retry source synchronization later", + })), ], - findings: result.clients - .filter( - (client) => - client.status !== "verified" && - client.status !== "external-verified", - ) - .map((client) => - client.conflict === undefined - ? { - code: `${client.client.toUpperCase()}_INSTALLATION_INCOMPLETE`, - severity: - client.status === "recovery-required" - ? ("recovery-required" as const) - : ("error" as const), - component: client.client, - summary: `${client.client} deterministic verification did not complete`, - nextAction: - "Review the client-specific recovery summary and retry after resolution", - } - : { - code: client.conflict.code, - severity: "error" as const, - component: client.client, - summary: `${client.client} ${client.conflict.component} is ${client.conflict.classification} at ${client.conflict.scope} scope (${client.conflict.identitySha256})`, - nextAction: - "Resolve the external client or managed-policy conflict outside SkillWire, then retry", - }, - ), recovery: { rollbackBoundary: - result.status === "success" ? "none" : "client-only", + result.status === "success" || !clientIncomplete + ? "none" + : "client-only", backupId: null, instructions: [], }, @@ -311,6 +375,9 @@ async function routeSetup( operationId, previewHash, cancelled: signal.aborted, + changed: + setupChanged || + (error instanceof ProductionSourceBootstrapError && error.changed), }), command, io, diff --git a/src/onboarding/cli/output.ts b/src/onboarding/cli/output.ts index a69ad81..e89662a 100644 --- a/src/onboarding/cli/output.ts +++ b/src/onboarding/cli/output.ts @@ -1,7 +1,7 @@ import { z } from "zod"; const SecretValuePattern = - /(?:swk\.[A-Za-z0-9_-]{16}\.[A-Za-z0-9_-]{43}|bearer\s+\S+|password\s*[=:]\s*\S+|pepper\s*[=:]\s*\S+)/gi; + /(?:swk\.[A-Za-z0-9_-]{16}\.[A-Za-z0-9_-]{43}|bearer\s+\S+|gh[pousr]_[A-Za-z0-9_]{16,}|github_pat_[A-Za-z0-9_]{16,}|password\s*[=:]\s*\S+|pepper\s*[=:]\s*\S+)/gi; export function redactText(value: string): string { return value.replace(SecretValuePattern, "[REDACTED]"); @@ -74,7 +74,8 @@ const SetupPreviewScopeSchema = z components: z.array(z.string().min(1).max(64)).min(3).max(5), volumes: z.array(z.string().min(1).max(128)).length(1), retainedOnFailure: z.array(z.string().min(1).max(128)).min(1).max(8), - catalogChoice: z.literal("deferred"), + catalogChoice: z.literal("bundled-first-party"), + sources: z.array(z.enum(["mattpocock/skills", "obra/superpowers"])).max(2), }) .strict(); @@ -86,7 +87,7 @@ const LifecyclePreviewScopeSchema = z ) .refine( (value) => - !/(?:swk\.[A-Za-z0-9_-]{16}\.[A-Za-z0-9_-]{43}|bearer\s+\S+|password\s*[=:]\s*\S+|pepper\s*[=:]\s*\S+)/i.test( + !/(?:swk\.[A-Za-z0-9_-]{16}\.[A-Za-z0-9_-]{43}|bearer\s+\S+|gh[pousr]_[A-Za-z0-9_]{16,}|github_pat_[A-Za-z0-9_]{16,}|password\s*[=:]\s*\S+|pepper\s*[=:]\s*\S+)/i.test( JSON.stringify(value), ), "preview scope contains secret material", diff --git a/src/onboarding/domain/installation.ts b/src/onboarding/domain/installation.ts index 1ff7ada..df771be 100644 --- a/src/onboarding/domain/installation.ts +++ b/src/onboarding/domain/installation.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +export { SourceChoiceSchema } from "./source-choice.js"; + const Sha256Schema = z.string().regex(/^[0-9a-f]{64}$/); const TimestampSchema = z.iso.datetime({ offset: true }); const RelativeLocatorSchema = z @@ -254,30 +256,6 @@ export const VerificationRecordSchema = z "passing verification requires provenance and advisory checks", ); -export const SourceChoiceSchema = z - .object({ - schemaVersion: z.literal("skillwire.source-choice/v1"), - sourceChoiceId: z.uuid(), - source: z.enum(["mattpocock/skills", "obra/superpowers"]), - selected: z.boolean(), - credentialReferenceId: z.uuid().nullable(), - registrationIdentity: z.string().min(1).max(128).nullable(), - syncState: z.enum([ - "not-selected", - "registered", - "verifying", - "eligible", - "quarantined", - "degraded", - "failed", - ]), - }) - .strict() - .refine( - ({ selected, syncState }) => selected || syncState === "not-selected", - "an unselected source cannot have lifecycle state", - ); - export type Installation = z.infer; export type ClientIntegration = z.infer; diff --git a/src/onboarding/domain/source-choice.ts b/src/onboarding/domain/source-choice.ts new file mode 100644 index 0000000..ede7d92 --- /dev/null +++ b/src/onboarding/domain/source-choice.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; + +export const BootstrapSourceSchema = z.enum([ + "mattpocock/skills", + "obra/superpowers", +]); + +export type BootstrapSource = z.infer; + +export const SourceChoiceSchema = z + .object({ + schemaVersion: z.literal("skillwire.source-choice/v1"), + sourceChoiceId: z.uuid(), + source: BootstrapSourceSchema, + selected: z.boolean(), + credentialReferenceId: z.uuid().nullable(), + registrationIdentity: z.string().min(1).max(128).nullable(), + syncState: z.enum([ + "not-selected", + "registered", + "verifying", + "eligible", + "quarantined", + "degraded", + "failed", + ]), + }) + .strict() + .superRefine((choice, context) => { + if (!choice.selected) { + if ( + choice.syncState !== "not-selected" || + choice.credentialReferenceId !== null || + choice.registrationIdentity !== null + ) { + context.addIssue({ + code: "custom", + message: "an unselected source cannot have lifecycle state", + }); + } + return; + } + if ( + choice.credentialReferenceId === null && + choice.syncState !== "failed" + ) { + context.addIssue({ + code: "custom", + path: ["credentialReferenceId"], + message: "a selected source requires its separate credential reference", + }); + } + if ( + [ + "registered", + "verifying", + "eligible", + "quarantined", + "degraded", + ].includes(choice.syncState) && + choice.registrationIdentity === null + ) { + context.addIssue({ + code: "custom", + path: ["registrationIdentity"], + message: "source lifecycle state requires a registration identity", + }); + } + }); + +export type SourceChoice = z.infer; + +export const BOOTSTRAP_SOURCES: readonly BootstrapSource[] = Object.freeze([ + "mattpocock/skills", + "obra/superpowers", +]); + +export function sourceCoordinate(source: BootstrapSource): { + readonly owner: string; + readonly repository: string; +} { + const [owner, repository] = source.split("/"); + if (owner === undefined || repository === undefined) + throw new Error("Bootstrap source identity is invalid"); + return { owner, repository }; +} diff --git a/tests/contract/cli/setup-route.test.ts b/tests/contract/cli/setup-route.test.ts index 4937bc3..a1cb804 100644 --- a/tests/contract/cli/setup-route.test.ts +++ b/tests/contract/cli/setup-route.test.ts @@ -124,6 +124,26 @@ describe("compiled guided setup route", () => { expect(preview.json).not.toMatch( /swk\.|Bearer|password\s*[=:]|pepper\s*[=:]/i, ); + const sourceScope = await previewProductionSetup( + { + clients: "none", + sources: ["mattpocock/skills", "obra/superpowers"], + }, + { + ...fixture.environment, + SKILLWIRE_RELEASE_ROOT: releaseRoot, + }, + { pinnedInitialPolicySha256: sha256(policyBytes) }, + ); + const sourcePreview = canonicalPreview("setup", sourceScope); + expect(sourceScope).toMatchObject({ + catalogChoice: "bundled-first-party", + sources: ["mattpocock/skills", "obra/superpowers"], + }); + expect(sourcePreview.hash).not.toBe(preview.hash); + expect(sourcePreview.json).not.toMatch( + /github_pat_|ghp_|Bearer|password\s*[=:]|pepper\s*[=:]/i, + ); await expect( runProductionSetup( { diff --git a/tests/contract/release/feature-003-integrity-compatibility.test.ts b/tests/contract/release/feature-003-integrity-compatibility.test.ts new file mode 100644 index 0000000..d4bcdf9 --- /dev/null +++ b/tests/contract/release/feature-003-integrity-compatibility.test.ts @@ -0,0 +1,32 @@ +import { readFile } from "node:fs/promises"; + +import { describe, expect, it } from "vitest"; + +import { + CODEX_ADAPTER_SOURCE_COMMIT, + validateCodexAdapterIntegrityManifest, +} from "../../../src/evaluation/codex-adapter-package.js"; + +describe("unchanged Feature 003 package identity", () => { + it("recomputes the 0.1.1 package against its immutable source identity", async () => { + const manifest = JSON.parse( + await readFile( + "distribution/codex-marketplace/release-integrity.json", + "utf8", + ), + ) as unknown; + const validated = validateCodexAdapterIntegrityManifest( + manifest, + "integrations/codex/skillwire-autonomous-activation", + ); + expect(validated).toMatchObject({ + pluginVersion: "0.1.1", + packageSha256: + "f4e2e1cca7b4c99d41d585d2816b44b4203297ad15809e3c1b87bedb8b6e805e", + source: { + commit: "7d9fd5fd130c9e66dfb739c599fd84ad9d962d5a", + }, + }); + expect(validated.source.commit).toBe(CODEX_ADAPTER_SOURCE_COMMIT); + }); +}); diff --git a/tests/contract/release/self-hosted-matrix.test.ts b/tests/contract/release/self-hosted-matrix.test.ts new file mode 100644 index 0000000..23b0349 --- /dev/null +++ b/tests/contract/release/self-hosted-matrix.test.ts @@ -0,0 +1,158 @@ +import { readFile } from "node:fs/promises"; + +import { parse as parseYaml } from "yaml"; +import { describe, expect, it } from "vitest"; + +describe("Feature 004 certified release matrix", () => { + it("binds every OS/architecture/root-mode cell to a blocking pre-sign job", async () => { + const workflowSource = await readFile( + ".github/workflows/self-hosted-release.yml", + "utf8", + ); + const workflow = parseYaml(workflowSource) as { + jobs: Record>; + }; + const matrixJob = workflow.jobs["certified-matrix"]; + if (matrixJob === undefined) + throw new Error("Certified matrix job is missing"); + const strategy = matrixJob["strategy"] as { + matrix: Record; + }; + expect(strategy.matrix).toEqual({ + os: ["ubuntu-24.04", "debian-12", "debian-13"], + arch: ["amd64", "arm64"], + "docker-mode": ["rootful", "rootless"], + }); + const operatingSystems = strategy.matrix["os"]; + const architectures = strategy.matrix["arch"]; + const dockerModes = strategy.matrix["docker-mode"]; + if ( + operatingSystems === undefined || + architectures === undefined || + dockerModes === undefined + ) { + throw new Error("Certified matrix axes are incomplete"); + } + expect( + operatingSystems.length * architectures.length * dockerModes.length, + ).toBe(12); + expect(workflow.jobs["build-test-sign"]?.["needs"]).toBe( + "certified-matrix", + ); + expect(matrixJob["env"]).toMatchObject({ + SKILLWIRE_RUN_COMPOSE_INTEGRATION: "1", + SKILLWIRE_RUN_SECRET_SERVICE_INTEGRATION: "1", + SKILLWIRE_RUN_POSTGRES_BACKUP_INTEGRATION: "1", + }); + const serialized = JSON.stringify(matrixJob); + expect(serialized).toContain("gnome-keyring-daemon"); + expect(serialized).toContain("secret-tool"); + expect(serialized).toContain("codex --version"); + expect(serialized).toContain("claude --version"); + expect(workflowSource).toContain('test "${docker_version}" = "29.7.2"'); + expect(workflowSource).toContain('test "${compose_version}" = "5.4.0"'); + }); + + it("keeps the distribution matrix and workflow claims exact", async () => { + const matrix = JSON.parse( + await readFile("distribution/self-hosted/supported-matrix.json", "utf8"), + ) as { + operatingSystems: unknown[]; + architectures: unknown[]; + docker: { minimum: string; tested: string }; + compose: { minimum: string; tested: string }; + node: string; + codex: string; + claude: string; + cosign: string; + }; + expect(matrix).toMatchObject({ + operatingSystems: [ + { id: "ubuntu", version: "24.04" }, + { id: "debian", version: "12" }, + { id: "debian", version: "13" }, + ], + architectures: ["amd64", "arm64"], + docker: { minimum: "29.7.2", tested: "29.7.2" }, + compose: { minimum: "5.4.0", tested: "5.4.0" }, + node: "24.18.0", + codex: "0.147.0", + claude: "2.1.229", + cosign: "3.1.3", + }); + }); + + it("isolates every matrix cell and cleans only its exact Compose project", async () => { + const workflowSource = await readFile( + ".github/workflows/self-hosted-release.yml", + "utf8", + ); + expect(workflowSource).toContain("MATRIX_OS: ${{ matrix.os }}"); + expect(workflowSource).toContain("MATRIX_ARCH: ${{ matrix.arch }}"); + expect(workflowSource).toContain( + "MATRIX_DOCKER_MODE: ${{ matrix.docker-mode }}", + ); + expect(workflowSource).toContain( + "${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${matrix_os}-${MATRIX_ARCH}-${MATRIX_DOCKER_MODE}", + ); + expect(workflowSource).toContain( + 'docker compose --project-name "${SKILLWIRE_COMPOSE_PROJECT}"', + ); + expect(workflowSource).toContain("down --volumes"); + expect(workflowSource).not.toContain("down --volumes --remove-orphans"); + expect(workflowSource).not.toContain("comm -13"); + expect(workflowSource).not.toContain("docker rm --force"); + expect(workflowSource).not.toContain("docker volume rm --force"); + expect(workflowSource).not.toContain("docker image rm --force"); + expect(workflowSource).not.toContain("docker network rm --"); + }); + + it("keeps signed assets behind a post-sign certified-matrix gate", async () => { + const workflowSource = await readFile( + ".github/workflows/self-hosted-release.yml", + "utf8", + ); + const workflow = parseYaml(workflowSource) as { + jobs: Record>; + }; + const signedMatrix = workflow.jobs["signed-asset-matrix"]; + expect(signedMatrix?.["needs"]).toBe("build-test-sign"); + const serializedSignedMatrix = JSON.stringify(signedMatrix); + expect(serializedSignedMatrix).toContain( + "validate-self-hosted-quickstart.ts", + ); + expect(serializedSignedMatrix).toContain("download-artifact"); + expect(signedMatrix?.["env"]).toMatchObject({ + MATRIX_OS: "${{ matrix.os }}", + MATRIX_ARCH: "${{ matrix.arch }}", + MATRIX_DOCKER_MODE: "${{ matrix.docker-mode }}", + }); + expect(serializedSignedMatrix).toContain( + "Assert signed-asset runner identity", + ); + expect(serializedSignedMatrix).toContain("DOCKER_HOST=${docker_host}"); + expect(serializedSignedMatrix).toContain("docker version --format"); + expect(serializedSignedMatrix).toContain("docker compose version --short"); + expect(serializedSignedMatrix).toContain("SecurityOptions"); + expect(workflow.jobs["publish"]?.["needs"]).toEqual([ + "build-test-sign", + "signed-asset-matrix", + ]); + expect(workflowSource).toContain("expected-release-assets.txt"); + expect(workflowSource).toContain("actual-release-assets.txt"); + }); + + it("pins every action and exposes no privileged pull-request release event", async () => { + const workflowSource = await readFile( + ".github/workflows/self-hosted-release.yml", + "utf8", + ); + const uses = [...workflowSource.matchAll(/^\s*- uses:\s+(\S+)/gmu)].map( + (match) => match[1] ?? "", + ); + expect(uses.length).toBeGreaterThan(0); + expect(uses.every((use) => /@[0-9a-f]{40}$/.test(use))).toBe(true); + expect(workflowSource).not.toMatch(/^\s+pull_request(?:_target)?:/mu); + expect(workflowSource).not.toContain("pull-requests: write"); + }); +}); diff --git a/tests/e2e/self-hosted-onboarding/acceptance-scenarios.test.ts b/tests/e2e/self-hosted-onboarding/acceptance-scenarios.test.ts new file mode 100644 index 0000000..f6881d1 --- /dev/null +++ b/tests/e2e/self-hosted-onboarding/acceptance-scenarios.test.ts @@ -0,0 +1,148 @@ +import { access, readFile } from "node:fs/promises"; + +import { describe, expect, it } from "vitest"; + +const ACCEPTANCE_EVIDENCE = [ + ["US1-1", "tests/e2e/self-hosted-onboarding/setup-matrix.test.ts"], + ["US1-2", "tests/e2e/self-hosted-onboarding/setup-matrix.test.ts"], + ["US1-3", "tests/e2e/self-hosted-onboarding/setup-matrix.test.ts"], + ["US1-4", "tests/integration/onboarding/production-setup.test.ts"], + [ + "US1-5", + "tests/e2e/self-hosted-onboarding/client-conflict-partial-success.test.ts", + ], + ["US2-1", "tests/e2e/self-hosted-onboarding/profile-safety.test.ts"], + ["US2-2", "tests/e2e/self-hosted-onboarding/profile-safety.test.ts"], + [ + "US2-3", + "tests/e2e/self-hosted-onboarding/client-conflict-partial-success.test.ts", + ], + [ + "US2-4", + "tests/e2e/self-hosted-onboarding/external-integration-reuse.test.ts", + ], + ["US2-5", "tests/e2e/self-hosted-onboarding/profile-safety.test.ts"], + ["US3-1", "tests/e2e/self-hosted-onboarding/fail-open-clients.test.ts"], + ["US3-2", "tests/e2e/self-hosted-onboarding/fail-open-clients.test.ts"], + ["US3-3", "tests/e2e/self-hosted-onboarding/fail-open-clients.test.ts"], + [ + "US3-4", + "tests/e2e/self-hosted-onboarding/explicit-skillwire-request.test.ts", + ], + ["US4-1", "tests/e2e/self-hosted-onboarding/repeated-setup.test.ts"], + ["US4-2", "tests/integration/onboarding/interruption-recovery.test.ts"], + ["US4-3", "tests/integration/onboarding/doctor-classification.test.ts"], + ["US4-4", "tests/integration/onboarding/repair.test.ts"], + ["US5-1", "tests/integration/onboarding/upgrade-compatible.test.ts"], + ["US5-2", "tests/integration/onboarding/upgrade-forward-only-010.test.ts"], + ["US5-3", "tests/integration/onboarding/upgrade-interruption.test.ts"], + ["US6-1", "tests/e2e/self-hosted-onboarding/default-uninstall.test.ts"], + ["US6-2", "tests/e2e/self-hosted-onboarding/reinstall-retained-data.test.ts"], + ["US6-3", "tests/e2e/self-hosted-onboarding/permanent-removal.test.ts"], + ["US7-1", "tests/e2e/self-hosted-onboarding/first-party-catalog.test.ts"], + ["US7-2", "tests/integration/onboarding/source-bootstrap.test.ts"], + ["US7-3", "tests/security/onboarding/source-boundaries.test.ts"], + ["US7-4", "tests/integration/onboarding/source-degradation.test.ts"], +] as const; + +const FR_EVIDENCE_GROUPS = [ + [1, 16, "tests/e2e/self-hosted-onboarding/setup-matrix.test.ts"], + [17, 23, "tests/contract/clients/codex-onboarding.test.ts"], + [24, 29, "tests/contract/clients/claude-onboarding.test.ts"], + [30, 36, "tests/e2e/self-hosted-onboarding/profile-safety.test.ts"], + [37, 44, "tests/security/onboarding/secret-containment.test.ts"], + [45, 50, "tests/e2e/self-hosted-onboarding/fail-open-clients.test.ts"], + [51, 57, "tests/e2e/self-hosted-onboarding/first-party-catalog.test.ts"], + [58, 65, "tests/contract/cli/lifecycle-operations.test.ts"], + [66, 73, "tests/e2e/self-hosted-onboarding/upgrade-preservation.test.ts"], + [74, 77, "tests/e2e/self-hosted-onboarding/default-uninstall.test.ts"], + [78, 85, "tests/security/onboarding/source-boundaries.test.ts"], + [86, 91, "tests/e2e/self-hosted-onboarding/acceptance-scenarios.test.ts"], + [92, 92, "tests/contract/release/self-hosted-matrix.test.ts"], +] as const; + +const FR_EVIDENCE = FR_EVIDENCE_GROUPS.flatMap(([first, last, path]) => + Array.from({ length: last - first + 1 }, (_, index) => ({ + id: `FR-${String(first + index).padStart(3, "0")}`, + path, + })), +); + +const BUILDABLE_SC_EVIDENCE = [ + [2, "tests/e2e/self-hosted-onboarding/client-verification.test.ts"], + [3, "tests/e2e/self-hosted-onboarding/fail-open-clients.test.ts"], + [4, "tests/e2e/self-hosted-onboarding/profile-safety.test.ts"], + [5, "tests/e2e/self-hosted-onboarding/repeated-setup.test.ts"], + [6, "tests/integration/onboarding/interruption-recovery.test.ts"], + [7, "tests/integration/onboarding/doctor-classification.test.ts"], + [8, "tests/security/onboarding/secret-containment.test.ts"], + [9, "tests/e2e/self-hosted-onboarding/first-party-catalog.test.ts"], + [10, "tests/e2e/self-hosted-onboarding/default-uninstall.test.ts"], + [11, "tests/e2e/self-hosted-onboarding/permanent-removal.test.ts"], + [12, "tests/e2e/self-hosted-onboarding/upgrade-preservation.test.ts"], + [13, "tests/e2e/self-hosted-onboarding/acceptance-scenarios.test.ts"], + [15, "tests/security/onboarding/bounded-activation.test.ts"], + [ + 16, + "tests/e2e/self-hosted-onboarding/client-conflict-partial-success.test.ts", + ], + [17, "tests/e2e/self-hosted-onboarding/external-integration-reuse.test.ts"], +] as const; + +async function requireExecutableEvidence( + entries: readonly { readonly id: string; readonly path: string }[], +): Promise { + for (const { id, path } of entries) { + await expect(access(path), `${id}: ${path}`).resolves.toBeUndefined(); + expect(await readFile(path, "utf8"), id).toMatch( + /\b(?:it|test)(?:\.skipIf|\.each)?/, + ); + } +} + +describe("Feature 004 acceptance traceability", () => { + it("maps all 28 numbered scenarios to executable evidence", async () => { + expect(ACCEPTANCE_EVIDENCE).toHaveLength(28); + expect(new Set(ACCEPTANCE_EVIDENCE.map(([id]) => id)).size).toBe(28); + for (const [id, path] of ACCEPTANCE_EVIDENCE) { + await expect(access(path), `${id}: ${path}`).resolves.toBeUndefined(); + expect(await readFile(path, "utf8"), id).toMatch( + /\b(?:it|test)(?:\.skipIf|\.each)?/, + ); + } + }); + + it("keeps FR-001 through FR-092 and every buildable success criterion represented", async () => { + const specification = await readFile( + "specs/004-self-hosted-onboarding/spec.md", + "utf8", + ); + const requirements = [...specification.matchAll(/\*\*FR-(\d{3})\*\*/g)].map( + (match) => Number(match[1]), + ); + expect(requirements).toEqual( + Array.from({ length: 92 }, (_, index) => index + 1), + ); + expect(FR_EVIDENCE.map(({ id }) => id)).toEqual( + requirements.map((id) => `FR-${String(id).padStart(3, "0")}`), + ); + await requireExecutableEvidence(FR_EVIDENCE); + const successCriteria = [ + ...specification.matchAll(/\*\*SC-(\d{3})\*\*/g), + ].map((match) => Number(match[1])); + expect(successCriteria).toEqual( + Array.from({ length: successCriteria.length }, (_, index) => index + 1), + ); + expect(BUILDABLE_SC_EVIDENCE.map(([id]) => id)).toEqual( + successCriteria.filter((id) => id !== 1 && id !== 14), + ); + await requireExecutableEvidence( + BUILDABLE_SC_EVIDENCE.map(([id, path]) => ({ + id: `SC-${String(id).padStart(3, "0")}`, + path, + })), + ); + }); +}); + +export { ACCEPTANCE_EVIDENCE, BUILDABLE_SC_EVIDENCE, FR_EVIDENCE }; diff --git a/tests/e2e/self-hosted-onboarding/first-party-catalog.test.ts b/tests/e2e/self-hosted-onboarding/first-party-catalog.test.ts new file mode 100644 index 0000000..ae08159 --- /dev/null +++ b/tests/e2e/self-hosted-onboarding/first-party-catalog.test.ts @@ -0,0 +1,184 @@ +import { createHash } from "node:crypto"; +import { readFile, readdir } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { verifyBundledFirstPartyCatalog } from "../../../src/onboarding/application/first-party-catalog.js"; +import { deriveReleaseComponents } from "../../../src/onboarding/domain/release-components.js"; +import type { ReleaseManifest } from "../../../src/onboarding/domain/release-manifest.js"; +import { createTestApplication } from "../../../src/composition.js"; +import { + loadSkillOutputSchema, + readSkillResourceOutputSchema, + searchSkillsOutputSchema, +} from "../../../src/transport/mcp/schemas.js"; +import { + createTestMcpClient, + type TestMcpClient, +} from "../../helpers/mcp-client.js"; + +async function catalogPaths( + root: string, + relative = "catalog", +): Promise { + const entries = await readdir(resolve(root, relative), { + withFileTypes: true, + }); + const paths: string[] = []; + for (const entry of entries) { + const path = `${relative}/${entry.name}`; + if (entry.isDirectory()) paths.push(...(await catalogPaths(root, path))); + else if (entry.isFile()) paths.push(path); + } + return paths.toSorted(); +} + +async function catalogRelease(root: string): Promise<{ + readonly payload: ReleaseManifest["payload"]; + readonly components: Pick; +}> { + const paths = await catalogPaths(root); + const payload = await Promise.all( + paths.map(async (path) => { + const bytes = await readFile(resolve(root, path)); + return { + path, + size: bytes.byteLength, + sha256: createHash("sha256").update(bytes).digest("hex"), + mode: "0644" as const, + }; + }), + ); + const compose = { + path: "distribution/self-hosted/compose.yaml", + size: 1, + sha256: "0".repeat(64), + mode: "0644" as const, + }; + const migrations = Array.from({ length: 10 }, (_value, index) => ({ + path: `migrations/${String(index + 1).padStart(3, "0")}_fixture.sql`, + size: 1, + sha256: String(index).padStart(64, "0"), + mode: "0644" as const, + })); + const adapters = ["codex", "claude"].map((client) => ({ + path: `integrations/${client}/fixture`, + size: 1, + sha256: "f".repeat(64), + mode: "0644" as const, + })); + const completePayload = [compose, ...migrations, ...payload, ...adapters]; + return { + payload: completePayload, + components: { catalog: deriveReleaseComponents(completePayload).catalog }, + }; +} + +describe("offline first-party onboarding catalog", () => { + let client: TestMcpClient | undefined; + afterEach(async () => client?.close()); + + it("verifies and serves the exact ten immutable launch skills without GitHub", async () => { + const root = process.cwd(); + const fetchImplementation = vi.fn(() => { + throw new Error("GitHub access is forbidden for first-party setup"); + }); + + const verified = await verifyBundledFirstPartyCatalog({ + releaseRoot: root, + release: await catalogRelease(root), + fetchImplementation, + }); + + expect(fetchImplementation).not.toHaveBeenCalled(); + expect(verified.releaseId).toBe("launch-catalog-v1"); + expect(verified.revisions).toHaveLength(10); + expect(new Set(verified.revisions.map(({ skillId }) => skillId)).size).toBe( + 10, + ); + expect( + verified.revisions.every( + ({ bundleSha256, advisoryStatus }) => + /^[0-9a-f]{64}$/.test(bundleSha256) && + advisoryStatus !== "revoked" && + advisoryStatus !== "unavailable", + ), + ).toBe(true); + + const selected = verified.provider + .listMetadata() + .find(({ id }) => id === "typescript-code-review"); + expect(selected).toBeDefined(); + if (selected === undefined) throw new Error("Smoke skill is missing"); + const loaded = verified.provider.findRevision( + selected.id, + selected.revision, + ); + const identity = verified.revisions.find( + ({ skillId }) => skillId === selected.id, + ); + if (identity === undefined) throw new Error("Smoke identity is missing"); + expect(loaded).toMatchObject({ + skillId: selected.id, + revision: selected.revision, + bundleSha256: identity.bundleSha256, + }); + expect(loaded?.publishedProvenance).toBeDefined(); + + const { app } = createTestApplication(); + client = await createTestMcpClient( + new URL("http://localhost/mcp"), + async (input, init) => { + const request = new Request(input, init); + const headers = new Headers(request.headers); + headers.set("host", "localhost"); + return await app.fetch(new Request(request, { headers })); + }, + ); + const search = searchSkillsOutputSchema.parse( + ( + await client.client.callTool({ + name: "search_skills", + arguments: { + task: "Review strict TypeScript narrowing and type safety", + invocationContext: "user-requested", + limit: 1, + }, + }) + ).structuredContent, + ); + const preview = search.skills.at(0); + expect(preview).toMatchObject({ + skillId: "typescript-code-review", + currentAdvisoryStatus: "available", + trustAtPublication: "trusted", + }); + const skill = loadSkillOutputSchema.parse( + ( + await client.client.callTool({ + name: "load_skill", + arguments: { + skillId: preview?.skillId, + revision: preview?.revision, + }, + }) + ).structuredContent, + ); + const resourceIdentity = skill.resourceManifest.at(0); + const resource = readSkillResourceOutputSchema.parse( + ( + await client.client.callTool({ + name: "read_skill_resource", + arguments: { + skillId: skill.skillId, + revision: skill.revision, + path: resourceIdentity?.path, + }, + }) + ).structuredContent, + ); + expect(resource.sha256).toBe(resourceIdentity?.sha256); + expect(fetchImplementation).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/e2e/self-hosted-onboarding/setup-duration-evidence.test.ts b/tests/e2e/self-hosted-onboarding/setup-duration-evidence.test.ts new file mode 100644 index 0000000..a3d9676 --- /dev/null +++ b/tests/e2e/self-hosted-onboarding/setup-duration-evidence.test.ts @@ -0,0 +1,30 @@ +import { performance } from "node:perf_hooks"; + +import { describe, expect, it } from "vitest"; + +import { recordSetupDuration } from "../../../src/onboarding/application/setup-duration.js"; + +describe("informational clean-host setup duration", () => { + it("records elapsed time and environment identity without a pass/fail threshold", async () => { + const started = performance.now(); + await Promise.resolve(); + const evidence = recordSetupDuration({ + startedMilliseconds: started, + endedMilliseconds: performance.now(), + environment: "disposable-simulated-clean-host", + result: "completed", + sourceCommit: "1".repeat(40), + manifestSha256: "2".repeat(64), + }); + expect(evidence).toMatchObject({ + schemaVersion: "skillwire.setup-duration/v1", + gating: false, + environment: "disposable-simulated-clean-host", + result: "completed", + sourceCommit: "1".repeat(40), + manifestSha256: "2".repeat(64), + }); + expect(evidence.elapsedMilliseconds).toBeGreaterThanOrEqual(0); + expect(JSON.stringify(evidence)).not.toMatch(/threshold|passed|failed/i); + }); +}); diff --git a/tests/helpers/onboarding-environment.ts b/tests/helpers/onboarding-environment.ts index 8333d42..30ba58e 100644 --- a/tests/helpers/onboarding-environment.ts +++ b/tests/helpers/onboarding-environment.ts @@ -89,6 +89,7 @@ export async function createOnboardingEnvironment(): Promise> { + return Object.fromEntries( + readdirSync(resolve(process.cwd(), directory), { withFileTypes: true }) + .flatMap((entry): [string, string][] => { + const path = `${directory}/${entry.name}`; + if (entry.isDirectory()) + return Object.entries(catalogFixtureFiles(path)); + if (!entry.isFile()) return []; + return [[path, readFileSync(resolve(process.cwd(), path), "utf8")]]; + }) + .toSorted(([left], [right]) => left.localeCompare(right)), + ); +} + export const RELEASE_PAYLOAD_FILES: Readonly> = { "bin/skillwire": "#!/bin/sh\nexit 0\n", "runtime/node": "fixture-node-runtime", "app/skillwire.mjs": "fixture-main", "distribution/self-hosted/compose.yaml": "compose", + "distribution/self-hosted/supported-matrix.json": readFileSync( + resolve(process.cwd(), "distribution/self-hosted/supported-matrix.json"), + "utf8", + ), "distribution/codex-marketplace/release-integrity.json": "integrity", "distribution/codex-marketplace/marketplace.json": "codex-marketplace", "distribution/codex-release-marketplace/.agents/plugins/marketplace.json": @@ -36,19 +58,13 @@ export const RELEASE_PAYLOAD_FILES: Readonly> = { "claude-release-skill", "integrations/claude/skillwire-autonomous-activation/.claude-plugin/plugin.json": "claude-plugin", - "catalog/advisories.jsonl": "advisory-head", + ...catalogFixtureFiles(), ...Object.fromEntries( Array.from({ length: 10 }, (_value, index) => { const version = String(index + 1).padStart(3, "0"); return [`migrations/${version}_fixture.sql`, `migration-${version}`]; }), ), - ...Object.fromEntries( - Array.from({ length: 10 }, (_value, index) => [ - `catalog/releases/launch-catalog-v1/revisions/skill-${String(index + 1).padStart(2, "0")}.json`, - `revision-${String(index + 1)}`, - ]), - ), }; export function releasePayloadMode(path: string): 0o644 | 0o755 { diff --git a/tests/integration/onboarding/production-setup.test.ts b/tests/integration/onboarding/production-setup.test.ts index cb9afc3..4fb7342 100644 --- a/tests/integration/onboarding/production-setup.test.ts +++ b/tests/integration/onboarding/production-setup.test.ts @@ -17,6 +17,7 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { buildSelfHostedRelease } from "../../../scripts/build-self-hosted-release.js"; import { buildSelfHostedApplication } from "../../../scripts/build-self-hosted-app.js"; +import { cleanupQuickstartDeployment } from "../../../scripts/validate-self-hosted-quickstart.js"; import { previewProductionSetup, runProductionSetup, @@ -59,26 +60,10 @@ async function freePort(): Promise { }); } -async function dockerInventory( - kind: "container" | "volume", -): Promise> { - const result = await exec("/usr/bin/docker", [ - kind, - "ls", - ...(kind === "container" ? ["--all"] : []), - "--quiet", - ]); - return new Set(result.stdout.split("\n").filter(Boolean)); -} - describe("real disposable production setup", () => { let fixture: OnboardingEnvironment | undefined; - const createdContainers = new Set(); - const createdVolumes = new Set(); let registryName: string | undefined; let pushedImage: string | undefined; - let baselineContainers = new Set(); - let baselineVolumes = new Set(); beforeAll(async () => { if (process.env["SKILLWIRE_RUN_COMPOSE_INTEGRATION"] === "1") { @@ -87,33 +72,40 @@ describe("real disposable production setup", () => { }); afterEach(async () => { - const currentContainers = await dockerInventory("container").catch( - () => new Set(), - ); - const currentVolumes = await dockerInventory("volume").catch( - () => new Set(), - ); - for (const id of currentContainers) { - if (!baselineContainers.has(id)) createdContainers.add(id); - } - for (const name of currentVolumes) { - if (!baselineVolumes.has(name)) createdVolumes.add(name); - } - for (const id of createdContainers) { - await exec("/usr/bin/docker", ["container", "rm", "--force", id]).catch( - () => undefined, - ); - } - for (const name of createdVolumes) { - await exec("/usr/bin/docker", ["volume", "rm", name]).catch( - () => undefined, - ); + let cleanupFailure: unknown; + if (fixture !== undefined) { + try { + const [deployment, ownership] = await Promise.all([ + readFile( + resolve(fixture.xdgStateHome, "skillwire/deployment.json"), + "utf8", + ), + readFile( + resolve(fixture.xdgStateHome, "skillwire/ownership.json"), + "utf8", + ), + ]); + await cleanupQuickstartDeployment( + JSON.parse(deployment) as unknown, + JSON.parse(ownership) as unknown, + fixture.environment, + ); + } catch (error) { + if (!( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + )) { + cleanupFailure = error; + } + } } if (registryName !== undefined) { await exec("/usr/bin/docker", [ "container", "rm", "--force", + "--volumes", registryName, ]).catch(() => undefined); } @@ -124,20 +116,19 @@ describe("real disposable production setup", () => { } await fixture?.close(); fixture = undefined; - createdContainers.clear(); - createdVolumes.clear(); - baselineContainers = new Set(); - baselineVolumes = new Set(); registryName = undefined; pushedImage = undefined; + if (cleanupFailure instanceof Error) throw cleanupFailure; + if (cleanupFailure !== undefined) + throw new Error("Disposable Compose cleanup failed", { + cause: cleanupFailure, + }); }); it.skipIf(process.env["SKILLWIRE_RUN_COMPOSE_INTEGRATION"] !== "1")( "installs without clients, then verifies both native clients from simulated normal profiles", async () => { fixture = await createOnboardingEnvironment(); - baselineContainers = await dockerInventory("container"); - baselineVolumes = await dockerInventory("volume"); const registryPort = await freePort(); registryName = `${fixture.composeProject}-registry`; await exec( @@ -384,7 +375,19 @@ describe("real disposable production setup", () => { const claudeProfilePath = resolve(fixture.home, ".claude.json"); const claudeProfile = await readFile(claudeProfilePath); - await writeFile(claudeProfilePath, "{}", { mode: 0o600 }); + await writeFile( + claudeProfilePath, + JSON.stringify({ + mcpServers: { + skillwire: { + type: "stdio", + command: "/bin/false", + args: [], + }, + }, + }), + { mode: 0o600 }, + ); await expect( runProductionSetup( { clients: "none", credentialBackend: "not-selected" }, diff --git a/tests/integration/onboarding/secret-service-session.test.ts b/tests/integration/onboarding/secret-service-session.test.ts index af31dea..ef9f048 100644 --- a/tests/integration/onboarding/secret-service-session.test.ts +++ b/tests/integration/onboarding/secret-service-session.test.ts @@ -8,6 +8,7 @@ import { SecretToolCredentialStore, SecretToolError, } from "../../../src/onboarding/adapters/credentials/secret-tool.js"; +import { GitHubTokenCredentialStore } from "../../../src/onboarding/adapters/credentials/github-token.js"; import { RestrictiveFileCredentialStore } from "../../../src/onboarding/adapters/credentials/restrictive-file.js"; import { selectCredentialBackend } from "../../../src/onboarding/application/production-setup.js"; @@ -78,6 +79,45 @@ describe("real isolated Secret Service session", async () => { 30_000, ); + it.skipIf(!available)( + "keeps a read-only GitHub source token separate from client credentials across processes", + async () => { + session = await createSecretServiceSession(); + const clientStore = new SecretToolCredentialStore( + "/usr/bin/secret-tool", + session.environment, + ); + const sourceStore = new GitHubTokenCredentialStore( + "/usr/bin/secret-tool", + session.environment, + ); + const clientToken = createApiKeyToken().token; + const sourceToken = "github_pat_disposable_source_read_token"; + const client = await clientStore.store( + installationId, + "codex", + clientToken, + ); + const source = await sourceStore.store(sourceToken); + + const freshSourceProcess = new GitHubTokenCredentialStore( + "/usr/bin/secret-tool", + session.environment, + ); + expect(await freshSourceProcess.lookup(source.reference)).toBe( + sourceToken, + ); + await freshSourceProcess.clear(source.reference); + await expect( + freshSourceProcess.lookup(source.reference), + ).rejects.toThrow(); + expect( + await clientStore.lookup(installationId, "codex", client.reference), + ).toBe(clientToken); + }, + 30_000, + ); + it.skipIf(!available)( "classifies a locked collection and an unavailable provider without disclosure", async () => { diff --git a/tests/integration/onboarding/service-setup.test.ts b/tests/integration/onboarding/service-setup.test.ts index 3c88dda..0efd97b 100644 --- a/tests/integration/onboarding/service-setup.test.ts +++ b/tests/integration/onboarding/service-setup.test.ts @@ -235,6 +235,29 @@ describe("service-only deployment boundary", () => { ).toBe(false); }); + it("rejects a pre-existing exact Compose project or volume before deployment", async () => { + const calls: CommandOptions[] = []; + const run = vi.fn((options: CommandOptions) => { + calls.push(options); + const joined = options.args.join(" "); + if (joined.startsWith("container ls")) + return Promise.resolve(result("existing-container\n")); + if (joined.startsWith("volume ls")) + return Promise.resolve( + result("skillwire-test-0123456789abcdef_postgres_data\n"), + ); + return Promise.resolve(result("")); + }); + const adapter = deployment(run); + + await expect( + adapter.assertDeploymentTargetsAbsent(new AbortController().signal), + ).rejects.toThrow(/already exists|collision/i); + expect( + calls.some(({ args }) => args.includes("up") || args.includes("down")), + ).toBe(false); + }); + it("uses an already resolved local endpoint without re-reading another context", async () => { const run = vi.fn(async (options: CommandOptions) => { await Promise.resolve(); diff --git a/tests/integration/onboarding/source-bootstrap.test.ts b/tests/integration/onboarding/source-bootstrap.test.ts new file mode 100644 index 0000000..978b91a --- /dev/null +++ b/tests/integration/onboarding/source-bootstrap.test.ts @@ -0,0 +1,607 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + bootstrapSources, + bootstrapProductionSources, + readProtectedSourceChoices, + sourceChoices, +} from "../../../src/onboarding/application/source-bootstrap.js"; +import { + createOnboardingEnvironment, + type OnboardingEnvironment, +} from "../../helpers/onboarding-environment.js"; +import { runGuidedSetup } from "../../../src/onboarding/application/setup.js"; +import { + currentProcessIdentity, + InstallationLock, +} from "../../../src/onboarding/domain/operation-journal.js"; +import { atomicWriteJson } from "../../../src/onboarding/adapters/filesystem/atomic-state.js"; +import { + createOwnershipLedger, + verifyOwnershipRecord, +} from "../../../src/onboarding/domain/ownership.js"; +import { previewPurge } from "../../../src/onboarding/application/purge.js"; + +async function seedOwnership( + stateRoot: string, + installationId: string, +): Promise { + await atomicWriteJson( + resolve(stateRoot, "ownership.json"), + createOwnershipLedger(installationId).record, + stateRoot, + ); +} + +describe("explicit source bootstrap", () => { + let fixture: OnboardingEnvironment | undefined; + afterEach(async () => { + await fixture?.close(); + }); + it("does nothing for the two offered sources unless explicitly selected", async () => { + const register = vi.fn(); + const synchronize = vi.fn(); + const choices = sourceChoices([]); + + expect( + choices.map(({ source, selected, syncState }) => ({ + source, + selected, + syncState, + })), + ).toEqual([ + { + source: "mattpocock/skills", + selected: false, + syncState: "not-selected", + }, + { + source: "obra/superpowers", + selected: false, + syncState: "not-selected", + }, + ]); + expect( + ( + await bootstrapSources([], { + listRegistrations: () => Promise.resolve([]), + register, + synchronize, + }) + ).map(({ source, selected, syncState }) => ({ + source, + selected, + syncState, + })), + ).toEqual( + choices.map(({ source, selected, syncState }) => ({ + source, + selected, + syncState, + })), + ); + expect(register).not.toHaveBeenCalled(); + expect(synchronize).not.toHaveBeenCalled(); + }); + + it.each(["mattpocock/skills", "obra/superpowers"] as const)( + "registers %s once and never promotes quarantined content", + async (source) => { + const sourceId = randomUUID(); + const credentialReferenceId = randomUUID(); + const register = vi.fn().mockResolvedValue({ sourceId, created: true }); + const synchronize = vi.fn().mockResolvedValue({ + sourceId, + classifications: ["quarantined", "verified"], + created: true, + }); + const dependencies = { + listRegistrations: () => Promise.resolve([]), + register, + synchronize, + }; + + const first = await bootstrapSources( + [{ source, credentialReferenceId }], + dependencies, + ); + + expect(register).toHaveBeenCalledWith( + source === "mattpocock/skills" + ? { owner: "mattpocock", repository: "skills" } + : { owner: "obra", repository: "superpowers" }, + credentialReferenceId, + expect.any(AbortSignal), + ); + expect(synchronize).toHaveBeenCalledWith( + sourceId, + credentialReferenceId, + expect.any(AbortSignal), + ); + expect(first.find((choice) => choice.source === source)).toMatchObject({ + selected: true, + registrationIdentity: sourceId, + syncState: "quarantined", + }); + expect(first.find((choice) => choice.source !== source)).toMatchObject({ + selected: false, + syncState: "not-selected", + }); + + register.mockClear(); + synchronize.mockClear(); + const [owner, repository] = source.split("/"); + if (owner === undefined || repository === undefined) + throw new Error("Invalid source fixture"); + await bootstrapSources([{ source, credentialReferenceId }], { + ...dependencies, + listRegistrations: () => + Promise.resolve([ + { + sourceId, + owner, + repository, + }, + ]), + }); + expect(register).not.toHaveBeenCalled(); + expect(synchronize).toHaveBeenCalledTimes(1); + + synchronize.mockClear(); + const unchanged = await bootstrapSources( + [{ source, credentialReferenceId }], + { + ...dependencies, + listRegistrations: () => + Promise.resolve([ + { + sourceId, + owner, + repository, + syncState: "eligible" as const, + }, + ]), + }, + ); + expect(register).not.toHaveBeenCalled(); + expect(synchronize).not.toHaveBeenCalled(); + expect( + unchanged.find((choice) => choice.source === source), + ).toMatchObject({ + registrationIdentity: sourceId, + syncState: "eligible", + }); + }, + ); + + it("reports an all-verified imported snapshot as eligible", async () => { + const sourceId = randomUUID(); + const [choice] = await bootstrapSources( + [ + { + source: "mattpocock/skills", + credentialReferenceId: randomUUID(), + }, + ], + { + listRegistrations: () => Promise.resolve([]), + register: () => Promise.resolve({ sourceId, created: true }), + synchronize: () => + Promise.resolve({ + sourceId, + classifications: ["verified", "verified"], + created: true, + }), + }, + ); + + expect(choice).toMatchObject({ + source: "mattpocock/skills", + syncState: "eligible", + }); + }); + + it("runs explicit sources only after first-party service readiness", async () => { + const order: string[] = []; + const credentialReferenceId = randomUUID(); + const result = await runGuidedSetup( + { + clients: "none", + sources: ["mattpocock/skills"], + }, + { + verifyRelease: () => { + order.push("release"); + return Promise.resolve({ releaseSequence: 1 }); + }, + installService: () => { + order.push("service-ready"); + return Promise.resolve({ installationId: randomUUID(), ready: true }); + }, + installClient: () => Promise.reject(new Error("no client selected")), + bootstrapSources: (selected) => { + order.push("source-bootstrap"); + return Promise.resolve({ + choices: sourceChoices( + selected.map((source) => ({ source, credentialReferenceId })), + ).map((choice) => + choice.selected + ? { + ...choice, + registrationIdentity: randomUUID(), + syncState: "eligible" as const, + } + : choice, + ), + changed: true, + }); + }, + }, + ); + + expect(order).toEqual(["release", "service-ready", "source-bootstrap"]); + expect(result.status).toBe("success"); + expect(result.sources?.find(({ selected }) => selected)).toMatchObject({ + source: "mattpocock/skills", + syncState: "eligible", + }); + }); + + it("keeps a ready service but reports incomplete when an optional source degrades", async () => { + const result = await runGuidedSetup( + { + clients: "none", + sources: ["obra/superpowers"], + }, + { + inspectExisting: () => + Promise.resolve({ + status: "success" as const, + installationId: randomUUID(), + serviceReady: true, + clients: [], + changed: false, + }), + verifyRelease: () => + Promise.reject(new Error("unchanged setup must not reinstall")), + installService: () => + Promise.reject(new Error("unchanged setup must not redeploy")), + installClient: () => Promise.reject(new Error("no client selected")), + bootstrapSources: (selected) => + Promise.resolve({ + choices: sourceChoices( + selected.map((source) => ({ + source, + credentialReferenceId: randomUUID(), + })), + ).map((choice) => + choice.selected + ? { + ...choice, + registrationIdentity: randomUUID(), + syncState: "degraded" as const, + } + : choice, + ), + changed: true, + }), + }, + ); + expect(result).toMatchObject({ + status: "incomplete", + serviceReady: true, + changed: true, + }); + }); + + it("preserves an unchanged setup result when source bootstrap is already converged", async () => { + const installationId = randomUUID(); + const credentialReferenceId = randomUUID(); + const choices = sourceChoices([ + { source: "mattpocock/skills", credentialReferenceId }, + ]).map((choice) => + choice.selected + ? { + ...choice, + registrationIdentity: randomUUID(), + syncState: "eligible" as const, + } + : choice, + ); + const result = await runGuidedSetup( + { clients: "none", sources: ["mattpocock/skills"] }, + { + inspectExisting: () => + Promise.resolve({ + status: "success" as const, + installationId, + serviceReady: true, + clients: [], + changed: false, + }), + verifyRelease: () => Promise.reject(new Error("must not verify")), + installService: () => Promise.reject(new Error("must not deploy")), + installClient: () => Promise.reject(new Error("must not install")), + bootstrapSources: () => Promise.resolve({ choices, changed: false }), + }, + ); + expect(result).toMatchObject({ + installationId, + status: "success", + changed: false, + sources: choices, + }); + }); + + it("persists a post-readiness result and repeats without credentials, registration, or sync writes", async () => { + fixture = await createOnboardingEnvironment(); + const stateRoot = `${fixture.xdgStateHome}/skillwire`; + await mkdir(stateRoot, { recursive: true, mode: 0o700 }); + const sourceId = randomUUID(); + const installationId = randomUUID(); + const credentialReferenceId = randomUUID(); + await seedOwnership(stateRoot, installationId); + const store = { + store: vi.fn().mockResolvedValue({ + reference: `secret-service:github:${credentialReferenceId}`, + referenceId: credentialReferenceId, + }), + lookup: vi.fn(), + clear: vi.fn(), + }; + const bootstrap = vi.fn().mockResolvedValue({ + sourceId, + classifications: ["curated"], + created: true, + }); + const options = { + selected: ["mattpocock/skills"] as const, + deployment: { + installationId, + composePath: "/release/compose.yaml", + projectName: "skillwire-1234567890abcdef", + databasePasswordFile: "/state/database-password", + applicationPepperFile: "/state/application-pepper", + runtimeSocketDirectory: "/runtime/skillwire", + volumeName: "skillwire-1234567890abcdef_postgres_data", + skillwireImage: `ghcr.io/lucenx9/skillwire@sha256:${"1".repeat(64)}`, + postgresImage: `docker.io/library/postgres@sha256:${"2".repeat(64)}`, + }, + stateRoot, + runtimeRoot: resolve(fixture.runtimeRoot, "skillwire"), + environment: fixture.environment, + token: "github_pat_source_only_read_token", + signal: new AbortController().signal, + credentialStore: store, + bootstrap, + resolveDockerEnvironment: (environment: NodeJS.ProcessEnv) => + Promise.resolve(environment), + }; + + const first = await bootstrapProductionSources(options); + expect(first).toMatchObject({ changed: true }); + expect(first.choices.find(({ selected }) => selected)).toMatchObject({ + registrationIdentity: sourceId, + syncState: "eligible", + }); + expect(store.store).toHaveBeenCalledTimes(1); + expect(bootstrap).toHaveBeenCalledTimes(1); + const ownership = verifyOwnershipRecord( + JSON.parse( + await readFile(resolve(stateRoot, "ownership.json"), "utf8"), + ) as unknown, + ); + expect(ownership.assets).toContainEqual( + expect.objectContaining({ + kind: "credential", + client: null, + locator: `secret-service:github:${credentialReferenceId}`, + retention: "remove-only-on-purge", + }), + ); + expect(previewPurge(ownership).unrecoverable).toContainEqual( + expect.objectContaining({ + locator: `secret-service:github:${credentialReferenceId}`, + }), + ); + + store.store.mockClear(); + bootstrap.mockClear(); + const repeated = await bootstrapProductionSources({ + ...options, + token: undefined, + }); + expect(repeated.changed).toBe(false); + expect(store.store).not.toHaveBeenCalled(); + expect(store.lookup).not.toHaveBeenCalled(); + expect(bootstrap).not.toHaveBeenCalled(); + }); + + it("rejects a concurrent source mutation before storing a credential or starting ingestion", async () => { + fixture = await createOnboardingEnvironment(); + const stateRoot = resolve(fixture.xdgStateHome, "skillwire"); + const runtimeRoot = resolve(fixture.runtimeRoot, "skillwire"); + await Promise.all([ + mkdir(stateRoot, { recursive: true, mode: 0o700 }), + mkdir(runtimeRoot, { recursive: true, mode: 0o700 }), + ]); + const lock = await InstallationLock.acquire( + resolve(runtimeRoot, "locks"), + "installation", + await currentProcessIdentity(), + ); + const store = { + store: vi.fn().mockResolvedValue({ + reference: `secret-service:github:${randomUUID()}`, + referenceId: randomUUID(), + }), + lookup: vi.fn(), + clear: vi.fn(), + }; + const bootstrap = vi.fn(); + try { + await expect( + bootstrapProductionSources({ + selected: ["obra/superpowers"], + deployment: { + installationId: randomUUID(), + composePath: "/release/compose.yaml", + projectName: "skillwire-1234567890abcdef", + databasePasswordFile: "/state/database-password", + applicationPepperFile: "/state/application-pepper", + runtimeSocketDirectory: "/runtime/skillwire", + volumeName: "skillwire-1234567890abcdef_postgres_data", + skillwireImage: `ghcr.io/lucenx9/skillwire@sha256:${"1".repeat(64)}`, + postgresImage: `docker.io/library/postgres@sha256:${"2".repeat(64)}`, + }, + stateRoot, + runtimeRoot, + environment: fixture.environment, + token: "github_pat_source_only_read_token", + signal: new AbortController().signal, + credentialStore: store, + bootstrap, + resolveDockerEnvironment: (environment: NodeJS.ProcessEnv) => + Promise.resolve(environment), + }), + ).rejects.toThrow(/locked/i); + expect(store.store).not.toHaveBeenCalled(); + expect(store.lookup).not.toHaveBeenCalled(); + expect(bootstrap).not.toHaveBeenCalled(); + } finally { + await lock.release(); + } + }); + + it("rejects a remote Docker context before credential or source effects", async () => { + fixture = await createOnboardingEnvironment(); + const stateRoot = resolve(fixture.xdgStateHome, "skillwire"); + const runtimeRoot = resolve(fixture.runtimeRoot, "skillwire"); + await Promise.all([ + mkdir(stateRoot, { recursive: true, mode: 0o700 }), + mkdir(runtimeRoot, { recursive: true, mode: 0o700 }), + ]); + const store = { store: vi.fn(), lookup: vi.fn(), clear: vi.fn() }; + const bootstrap = vi.fn(); + await expect( + bootstrapProductionSources({ + selected: ["mattpocock/skills"], + deployment: { + installationId: randomUUID(), + composePath: "/release/compose.yaml", + projectName: "skillwire-1234567890abcdef", + databasePasswordFile: "/state/database-password", + applicationPepperFile: "/state/application-pepper", + runtimeSocketDirectory: "/runtime/skillwire", + volumeName: "skillwire-1234567890abcdef_postgres_data", + skillwireImage: `ghcr.io/lucenx9/skillwire@sha256:${"1".repeat(64)}`, + postgresImage: `docker.io/library/postgres@sha256:${"2".repeat(64)}`, + }, + stateRoot, + runtimeRoot, + environment: { ...fixture.environment, DOCKER_CONTEXT: "remote" }, + token: "github_pat_source_only_read_token", + signal: new AbortController().signal, + credentialStore: store, + bootstrap, + resolveDockerEnvironment: () => + Promise.reject(new Error("A local Docker context is required")), + }), + ).rejects.toThrow(/local Docker context/i); + expect(store.store).not.toHaveBeenCalled(); + expect(store.lookup).not.toHaveBeenCalled(); + expect(bootstrap).not.toHaveBeenCalled(); + }); + + it("persists a cancellation-safe source boundary and resumes without duplicating the credential", async () => { + fixture = await createOnboardingEnvironment(); + const stateRoot = resolve(fixture.xdgStateHome, "skillwire"); + const runtimeRoot = resolve(fixture.runtimeRoot, "skillwire"); + await Promise.all([ + mkdir(stateRoot, { recursive: true, mode: 0o700 }), + mkdir(runtimeRoot, { recursive: true, mode: 0o700 }), + ]); + const installationId = randomUUID(); + const credentialReferenceId = randomUUID(); + await seedOwnership(stateRoot, installationId); + const store = { + store: vi.fn().mockResolvedValue({ + reference: `secret-service:github:${credentialReferenceId}`, + referenceId: credentialReferenceId, + }), + lookup: vi.fn().mockResolvedValue("github_pat_source_only_read_token"), + clear: vi.fn(), + }; + const controller = new AbortController(); + const bootstrap = vi + .fn() + .mockResolvedValueOnce({ + sourceId: randomUUID(), + classifications: ["curated"], + created: true, + }) + .mockImplementationOnce(() => { + controller.abort(); + return Promise.reject(new Error("source synchronization cancelled")); + }); + const base = { + selected: ["mattpocock/skills", "obra/superpowers"] as const, + deployment: { + installationId, + composePath: "/release/compose.yaml", + projectName: "skillwire-1234567890abcdef", + databasePasswordFile: "/state/database-password", + applicationPepperFile: "/state/application-pepper", + runtimeSocketDirectory: "/runtime/skillwire", + volumeName: "skillwire-1234567890abcdef_postgres_data", + skillwireImage: `ghcr.io/lucenx9/skillwire@sha256:${"1".repeat(64)}`, + postgresImage: `docker.io/library/postgres@sha256:${"2".repeat(64)}`, + }, + stateRoot, + runtimeRoot, + environment: fixture.environment, + token: "github_pat_source_only_read_token", + credentialStore: store, + bootstrap, + resolveDockerEnvironment: (environment: NodeJS.ProcessEnv) => + Promise.resolve(environment), + }; + + await expect( + bootstrapProductionSources({ ...base, signal: controller.signal }), + ).rejects.toThrow(/cancel/i); + const persisted = await readProtectedSourceChoices( + resolve(stateRoot, "source-choices.json"), + ); + expect(persisted?.choices).toMatchObject([ + { source: "mattpocock/skills", syncState: "eligible" }, + { + source: "obra/superpowers", + syncState: "failed", + credentialReferenceId, + }, + ]); + + bootstrap.mockReset().mockResolvedValue({ + sourceId: randomUUID(), + classifications: ["quarantined"], + created: true, + }); + const resumed = await bootstrapProductionSources({ + ...base, + token: undefined, + signal: new AbortController().signal, + }); + expect(resumed.choices).toMatchObject([ + { source: "mattpocock/skills", syncState: "eligible" }, + { source: "obra/superpowers", syncState: "quarantined" }, + ]); + expect(store.store).toHaveBeenCalledTimes(1); + expect(store.lookup).toHaveBeenCalledTimes(1); + expect(bootstrap).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/integration/onboarding/source-degradation.test.ts b/tests/integration/onboarding/source-degradation.test.ts new file mode 100644 index 0000000..c49aa1b --- /dev/null +++ b/tests/integration/onboarding/source-degradation.test.ts @@ -0,0 +1,109 @@ +import { randomUUID } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { + bootstrapSources, + sourceChoices, +} from "../../../src/onboarding/application/source-bootstrap.js"; +import { degradedSourceProbe } from "../../../src/onboarding/application/diagnostic-probes.js"; + +describe("optional source degradation", () => { + it.each([ + "GITHUB_RATE_LIMITED", + "GITHUB_TRANSIENT", + "SOURCE_NOT_PUBLIC", + "SOURCE_REVOKED", + "HASH_MISMATCH", + ])( + "isolates %s from first-party readiness and eligible cache", + async (code) => { + const sourceId = randomUUID(); + const results = await bootstrapSources( + [ + { + source: "mattpocock/skills", + credentialReferenceId: randomUUID(), + }, + ], + { + listRegistrations: () => + Promise.resolve([ + { sourceId, owner: "mattpocock", repository: "skills" }, + ]), + register: () => + Promise.reject(new Error("registration must be reused")), + synchronize: () => Promise.reject(new Error(code)), + }, + ); + + const result = results.at(0); + expect(result).toMatchObject({ + syncState: "degraded", + registrationIdentity: sourceId, + }); + if (result === undefined) throw new Error("Missing source result"); + const finding = await degradedSourceProbe(result).run( + new AbortController().signal, + ); + expect(finding).toMatchObject({ + code: "SOURCE_SYNCHRONIZATION_DEGRADED", + severity: "warning", + component: "source", + }); + }, + ); + + it.each(["revoked", "quarantined", "verified", "curated"] as const)( + "maps %s content through the existing eligibility boundary", + async (classification) => { + const sourceId = randomUUID(); + const choices = await bootstrapSources( + [ + { + source: "obra/superpowers", + credentialReferenceId: randomUUID(), + }, + ], + { + listRegistrations: () => + Promise.resolve([ + { sourceId, owner: "obra", repository: "superpowers" }, + ]), + register: () => Promise.resolve({ sourceId, created: false }), + synchronize: () => + Promise.resolve({ + sourceId, + classifications: [classification], + created: false, + }), + }, + ); + const choice = choices.find( + ({ source }) => source === "obra/superpowers", + ); + expect(choice?.syncState).toBe( + classification === "verified" || classification === "curated" + ? "eligible" + : "quarantined", + ); + }, + ); + + it("reports a selected registration failure as a bounded source finding", async () => { + const failed = sourceChoices([ + { + source: "mattpocock/skills", + credentialReferenceId: randomUUID(), + }, + ]).find(({ selected }) => selected); + if (failed === undefined) throw new Error("Missing failed source choice"); + await expect( + degradedSourceProbe(failed).run(new AbortController().signal), + ).resolves.toMatchObject({ + code: "SOURCE_SYNCHRONIZATION_DEGRADED", + component: "source", + severity: "warning", + }); + }); +}); diff --git a/tests/security/onboarding/quickstart-boundaries.test.ts b/tests/security/onboarding/quickstart-boundaries.test.ts new file mode 100644 index 0000000..4eff2d5 --- /dev/null +++ b/tests/security/onboarding/quickstart-boundaries.test.ts @@ -0,0 +1,462 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + chmod, + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import type * as ReleaseVerifierModule from "../../../src/onboarding/adapters/filesystem/release-verifier.js"; +import { verifySignedReleaseEnvelope } from "../../../src/onboarding/adapters/filesystem/release-verifier.js"; + +vi.mock( + "../../../src/onboarding/adapters/filesystem/release-verifier.js", + async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + verifySignedReleaseEnvelope: vi.fn(original.verifySignedReleaseEnvelope), + }; + }, +); + +import { + cleanupQuickstartDeployment, + quickstartCleanupPlan, + runQuickstartPostSetupChecks, + validateSelfHostedQuickstart, +} from "../../../scripts/validate-self-hosted-quickstart.js"; +import { clientComponentIdentity } from "../../../src/onboarding/adapters/clients/client-state.js"; +import { + createOwnershipLedger, + recordOwnedAsset, +} from "../../../src/onboarding/domain/ownership.js"; +import { createFakeExecutables } from "../../helpers/onboarding-executables.js"; +import { createOnboardingEnvironment } from "../../helpers/onboarding-environment.js"; +import { + bundleV03Fixture, + canonicalJson, + FIXTURE_ARCHIVE, + releaseManifestFixture, + sha256, + trustPolicyFixture, + trustedRootFixture, +} from "../../helpers/self-hosted-release-fixtures.js"; + +const INSTALLATION_ID = "01234567-89ab-4def-8123-456789abcdef"; +const PROJECT = "skillwire-0123456789ab4def8123456789abcdef"; +const VOLUME = `${PROJECT}_postgres_data`; + +function deployment(overrides: Record = {}): unknown { + return { + schemaVersion: "skillwire.deployment/v1", + installationId: INSTALLATION_ID, + composePath: resolve("distribution/self-hosted/compose.yaml"), + projectName: PROJECT, + volumeName: VOLUME, + skillwireImage: `ghcr.io/lucenx9/skillwire@sha256:${"1".repeat(64)}`, + postgresImage: `docker.io/library/postgres@sha256:${"2".repeat(64)}`, + databasePasswordFile: "/tmp/disposable/database-password", + applicationPepperFile: "/tmp/disposable/application-pepper", + runtimeSocketDirectory: "/tmp/disposable/runtime", + ...overrides, + }; +} + +function ownership(overrides: { installationId?: string } = {}): unknown { + let ledger = createOwnershipLedger( + overrides.installationId ?? INSTALLATION_ID, + ); + for (const asset of [ + { + kind: "compose-project" as const, + locator: PROJECT, + identity: clientComponentIdentity({ projectName: PROJECT }), + retention: "remove-on-uninstall" as const, + }, + { + kind: "container" as const, + locator: `${PROJECT}:skillwire`, + identity: clientComponentIdentity({ + projectName: PROJECT, + service: "skillwire", + }), + retention: "remove-on-uninstall" as const, + }, + { + kind: "container" as const, + locator: `${PROJECT}:postgres`, + identity: clientComponentIdentity({ + projectName: PROJECT, + service: "postgres", + }), + retention: "remove-on-uninstall" as const, + }, + { + kind: "volume" as const, + locator: VOLUME, + identity: clientComponentIdentity({ volumeName: VOLUME }), + retention: "retain-by-default" as const, + }, + ]) { + ledger = recordOwnedAsset(ledger, { + assetId: randomUUID(), + kind: asset.kind, + client: null, + locator: asset.locator, + expectedIdentitySha256: asset.identity, + createdByOperation: randomUUID(), + retention: asset.retention, + disposition: "present", + }); + } + return ledger.record; +} + +describe("disposable quickstart cleanup boundary", () => { + it("binds the real Compose fixture cleanup to recorded ownership instead of daemon-wide inventory differences", async () => { + const source = await readFile( + "tests/integration/onboarding/production-setup.test.ts", + "utf8", + ); + expect(source).toContain("cleanupQuickstartDeployment"); + expect(source).not.toContain("dockerInventory"); + expect(source).not.toContain("baselineContainers"); + expect(source).not.toContain("baselineVolumes"); + }); + + it("names one exact Compose project and its matching volume", () => { + const plan = quickstartCleanupPlan(deployment(), ownership()); + expect(plan.args).toEqual([ + "compose", + "--project-name", + PROJECT, + "--file", + "-", + "down", + "--volumes", + ]); + expect(plan.deployment.volumeName).toBe(VOLUME); + }); + + it.each([ + { projectName: "skillwire-*" }, + { projectName: "other", volumeName: "other_postgres_data" }, + { volumeName: "skillwire-ffffffffffffffff_postgres_data" }, + { composePath: "relative/compose.yaml" }, + ])("rejects unresolved or mismatching destructive targets", (shape) => { + expect(() => + quickstartCleanupPlan(deployment(shape), ownership()), + ).toThrow(); + }); + + it("rejects cleanup without an installation-bound ownership record", () => { + expect(() => + quickstartCleanupPlan( + deployment(), + ownership({ installationId: randomUUID() }), + ), + ).toThrow(/ownership|installation/i); + }); + + it("executes only the validated project cleanup with a minimal environment", async () => { + const calls: { + args: readonly string[]; + environment: NodeJS.ProcessEnv | undefined; + stdin: string | Uint8Array | undefined; + }[] = []; + await cleanupQuickstartDeployment( + deployment(), + ownership(), + { + HOME: "/tmp/disposable/home", + PATH: "/usr/bin:/bin", + GH_TOKEN: "ambient-must-not-propagate", + }, + (options) => { + calls.push({ + args: options.args, + environment: options.environment, + stdin: options.stdin, + }); + const joined = options.args.join(" "); + if (joined.startsWith("container ls")) + return Promise.resolve({ + code: 0, + stdout: `${"a".repeat(64)}\n${"b".repeat(64)}\n`, + stderr: "", + durationMilliseconds: 1, + }); + if (joined.includes(" ps ") && joined.endsWith(" skillwire")) + return Promise.resolve({ + code: 0, + stdout: `${"a".repeat(64)}\n`, + stderr: "", + durationMilliseconds: 1, + }); + if (joined.includes(" ps ") && joined.endsWith(" postgres")) + return Promise.resolve({ + code: 0, + stdout: `${"b".repeat(64)}\n`, + stderr: "", + durationMilliseconds: 1, + }); + if ( + joined.startsWith("container inspect") && + joined.includes("a".repeat(64)) + ) + return Promise.resolve({ + code: 0, + stdout: `${PROJECT}|skillwire|ghcr.io/lucenx9/skillwire@sha256:${"1".repeat(64)}\n`, + stderr: "", + durationMilliseconds: 1, + }); + if (joined.startsWith("container inspect")) + return Promise.resolve({ + code: 0, + stdout: `${PROJECT}|postgres|docker.io/library/postgres@sha256:${"2".repeat(64)}\n`, + stderr: "", + durationMilliseconds: 1, + }); + if (joined.startsWith("volume inspect")) + return Promise.resolve({ + code: 0, + stdout: `${VOLUME}|${PROJECT}|postgres_data\n`, + stderr: "", + durationMilliseconds: 1, + }); + return Promise.resolve({ + code: 0, + stdout: "", + stderr: "", + durationMilliseconds: 1, + }); + }, + ); + expect(calls.at(-1)?.args).toEqual( + quickstartCleanupPlan(deployment(), ownership()).args, + ); + expect(calls.at(-1)?.environment).toMatchObject({ + HOME: "/tmp/disposable/home", + PATH: "/usr/bin:/bin", + SKILLWIRE_COMPOSE_PROJECT: PROJECT, + SKILLWIRE_POSTGRES_VOLUME: VOLUME, + }); + expect(calls.at(-1)?.environment?.["GH_TOKEN"]).toBeUndefined(); + expect(calls.at(-1)?.stdin).toBe( + await readFile("distribution/self-hosted/compose.yaml", "utf8"), + ); + expect( + calls.find(({ args }) => args[0] === "container" && args[1] === "ls") + ?.args, + ).toContain("--no-trunc"); + }); + + it("rejects mutable Compose bytes before invoking destructive cleanup", async () => { + const root = await mkdtemp( + resolve(tmpdir(), "skillwire-quickstart-compose-"), + ); + try { + const composePath = resolve(root, "compose.yaml"); + const { parse, stringify } = await import("yaml"); + const compose = parse( + await readFile("distribution/self-hosted/compose.yaml", "utf8"), + ) as { + secrets: Record; + services: { skillwire: Record }; + }; + compose.secrets["host_key"] = { + file: "/home/operator/.ssh/id_rsa", + }; + compose.services.skillwire["command"] = ["cat /run/secrets/host_key"]; + await writeFile(composePath, stringify(compose), { mode: 0o600 }); + const run = vi.fn().mockResolvedValue({ + code: 0, + stdout: "", + stderr: "", + durationMilliseconds: 1, + }); + + await expect( + cleanupQuickstartDeployment( + deployment({ composePath }), + ownership(), + { PATH: "/usr/bin:/bin" }, + run, + ), + ).rejects.toThrow(/Compose|policy/i); + expect(run).not.toHaveBeenCalled(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("runs exact cleanup even when a post-setup diagnostic fails", async () => { + const cleanup = vi.fn().mockResolvedValue(undefined); + await expect( + runQuickstartPostSetupChecks({ + launcher: "/tmp/disposable/bin/skillwire", + environment: { PATH: "/usr/bin:/bin" }, + run: () => Promise.reject(new Error("doctor failed")), + cleanup, + }), + ).rejects.toThrow(/doctor failed/); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + it("refuses an unrecorded container in the exact Compose namespace", async () => { + const calls: string[][] = []; + await expect( + cleanupQuickstartDeployment( + deployment(), + ownership(), + { PATH: "/usr/bin:/bin" }, + (options) => { + calls.push([...options.args]); + const joined = options.args.join(" "); + if (joined.startsWith("container ls")) + return Promise.resolve({ + code: 0, + stdout: `${"c".repeat(64)}\n`, + stderr: "", + durationMilliseconds: 1, + }); + if (joined.includes(" ps ") && joined.endsWith(" skillwire")) + return Promise.resolve({ + code: 0, + stdout: `${"a".repeat(64)}\n`, + stderr: "", + durationMilliseconds: 1, + }); + if (joined.includes(" ps ") && joined.endsWith(" postgres")) + return Promise.resolve({ + code: 0, + stdout: `${"b".repeat(64)}\n`, + stderr: "", + durationMilliseconds: 1, + }); + if (joined.startsWith("container inspect")) + return Promise.resolve({ + code: 0, + stdout: joined.includes("a".repeat(64)) + ? `${PROJECT}|skillwire|ghcr.io/lucenx9/skillwire@sha256:${"1".repeat(64)}\n` + : joined.includes("b".repeat(64)) + ? `${PROJECT}|postgres|docker.io/library/postgres@sha256:${"2".repeat(64)}\n` + : `${PROJECT}|unrecorded|attacker.example/image:latest\n`, + stderr: "", + durationMilliseconds: 1, + }); + if (joined.startsWith("volume inspect")) + return Promise.resolve({ + code: 0, + stdout: `${VOLUME}|${PROJECT}|postgres_data\n`, + stderr: "", + durationMilliseconds: 1, + }); + return Promise.resolve({ + code: 0, + stdout: "", + stderr: "", + durationMilliseconds: 1, + }); + }, + ), + ).rejects.toThrow(/unrecorded|unexpected|ownership/i); + expect(calls.some((args) => args.includes("down"))).toBe(false); + }); + + it("removes its private root when verified bytes fail after the signature boundary but before setup mutation", async () => { + const fixture = await createOnboardingEnvironment(); + try { + const binaries = await createFakeExecutables(fixture.root); + await chmod(binaries.cosign, 0o700); + const cosignBytes = await readFile(binaries.cosign); + const trustedRootPath = resolve(fixture.root, "trusted-root.v1.json"); + const policyPath = resolve( + fixture.root, + "skillwire-trust-policy-v1.json", + ); + const manifestPath = resolve(fixture.root, "release.json"); + const bundlePath = resolve( + fixture.root, + "skillwire-0.1.0-test.1-linux-amd64.release.sigstore.json", + ); + const archivePath = resolve( + fixture.root, + "skillwire-0.1.0-test.1-linux-amd64.tar.zst", + ); + const trustedRootBytes = canonicalJson(trustedRootFixture()); + const policy = trustPolicyFixture({ + trustedRoot: { + path: "trusted-root.v1.json", + sha256: sha256(trustedRootBytes), + mediaType: + "application/vnd.dev.sigstore.trustedroot+json;version=0.1", + }, + cosign: { + version: "3.1.3", + binaries: { + amd64: createHash("sha256").update(cosignBytes).digest("hex"), + arm64: "a".repeat(64), + }, + }, + }); + const policyBytes = canonicalJson(policy); + const manifest = releaseManifestFixture({ + trustPolicy: { + path: "skillwire-trust-policy-v1.json", + size: Buffer.byteLength(policyBytes), + sha256: sha256(policyBytes), + }, + }); + await Promise.all([ + writeFile(trustedRootPath, trustedRootBytes, { mode: 0o600 }), + writeFile(policyPath, policyBytes, { mode: 0o600 }), + writeFile(manifestPath, canonicalJson(manifest), { mode: 0o600 }), + writeFile(bundlePath, canonicalJson(bundleV03Fixture(manifest)), { + mode: 0o600, + }), + writeFile(archivePath, FIXTURE_ARCHIVE, { mode: 0o600 }), + ]); + vi.mocked(verifySignedReleaseEnvelope).mockResolvedValueOnce({ + releaseVersion: manifest.releaseVersion, + releaseSequence: manifest.releaseSequence, + trustPolicySequence: manifest.trustPolicySequence, + manifestSha256: sha256(canonicalJson(manifest)), + archiveSha256: manifest.archive.sha256, + cosignArguments: [], + cosignInvocations: [[]], + manifest, + }); + const before = new Set( + (await readdir(tmpdir())).filter((name) => + name.startsWith("skillwire-quickstart-"), + ), + ); + await expect( + validateSelfHostedQuickstart({ + manifest: manifestPath, + bundles: [bundlePath], + archive: archivePath, + policy: policyPath, + trustedRoot: trustedRootPath, + cosign: binaries.cosign, + architecture: "amd64", + execute: false, + }), + ).rejects.toThrow(/tar|zstd|archive|Command failed/i); + const after = (await readdir(tmpdir())).filter((name) => + name.startsWith("skillwire-quickstart-"), + ); + expect(after.filter((name) => !before.has(name))).toEqual([]); + } finally { + await fixture.close(); + } + }); +}); diff --git a/tests/security/onboarding/release-integrity.test.ts b/tests/security/onboarding/release-integrity.test.ts new file mode 100644 index 0000000..1365708 --- /dev/null +++ b/tests/security/onboarding/release-integrity.test.ts @@ -0,0 +1,324 @@ +import { + chmod, + cp, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + validateArchiveListings, + verifySelfHostedReleasePolicy, +} from "../../../scripts/verify-self-hosted-release.js"; +import { verifyManifestPayload } from "../../../src/onboarding/adapters/filesystem/release-verifier.js"; +import { + RELEASE_PAYLOAD_FILES, + releaseManifestFixture, + releasePayloadMode, +} from "../../helpers/self-hosted-release-fixtures.js"; + +const RELEASE_BOUNDARY_EVIDENCE = [ + [ + "canonical manifest", + "tests/security/onboarding/trust-policy-lifecycle.test.ts", + /canonical manifests/, + ], + [ + "signature and claims", + "tests/security/onboarding/trust-policy-lifecycle.test.ts", + /exact claim policy/, + ], + [ + "transparency", + "tests/security/onboarding/trust-policy-lifecycle.test.ts", + /transparency entries/, + ], + [ + "overlap", + "tests/security/onboarding/trust-policy-lifecycle.test.ts", + /overlap policy/, + ], + [ + "revocation", + "tests/security/onboarding/trust-policy-lifecycle.test.ts", + /emergency deny sets/, + ], + [ + "downgrade", + "tests/security/onboarding/upgrade-trust-downgrade.test.ts", + /downgrade boundary/, + ], + [ + "mutable image", + "tests/integration/onboarding/service-setup.test.ts", + /mutable image tags/, + ], +] as const; + +interface MutableComposeFixture { + readonly services: Record< + "admin" | "migrate" | "postgres" | "skillwire", + Record + >; + readonly secrets: Record; +} + +describe("self-hosted release integrity policy", () => { + const roots: string[] = []; + afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); + }); + + it("accepts the pinned package, safe Compose, catalog, advisory, and exact matrix", async () => { + await expect( + verifySelfHostedReleasePolicy(releaseManifestFixture(), process.cwd()), + ).resolves.toMatchObject({ + feature003PackageSha256: + "f4e2e1cca7b4c99d41d585d2816b44b4203297ad15809e3c1b87bedb8b6e805e", + firstPartyRevisionCount: 10, + matrix: { architectures: ["amd64", "arm64"] }, + }); + }); + + it("rejects traversal, links, special files, and inconsistent archive listings", () => { + expect(() => { + validateArchiveListings("../escape\n", "- escape\n"); + }).toThrow(); + expect(() => { + validateArchiveListings("safe\n", "l safe -> target\n"); + }).toThrow(); + expect(() => { + validateArchiveListings("safe\n", "p safe\n"); + }).toThrow(); + expect(() => { + validateArchiveListings("safe\nextra\n", "- safe\n"); + }).toThrow(); + expect(() => { + validateArchiveListings("safe\n", "- other\n"); + }).toThrow(); + expect(() => { + validateArchiveListings("safe\nsafe\n", "- safe\n- safe\n"); + }).toThrow(); + expect(() => { + validateArchiveListings("safe/./entry\n", "- safe/./entry\n"); + }).toThrow(); + }); + + it("rejects unsafe Compose and certified-matrix overclaims", async () => { + const root = await mkdtemp(resolve(tmpdir(), "skillwire-release-policy-")); + roots.push(root); + await cp("distribution", resolve(root, "distribution"), { + recursive: true, + }); + await cp("integrations", resolve(root, "integrations"), { + recursive: true, + }); + await cp("catalog", resolve(root, "catalog"), { recursive: true }); + await writeFile( + resolve(root, "distribution/self-hosted/compose.yaml"), + "services:\n skillwire:\n image: skillwire:latest\n privileged: true\n", + ); + await expect( + verifySelfHostedReleasePolicy(releaseManifestFixture(), root), + ).rejects.toThrow(/Compose|policy/i); + + await writeFile( + resolve(root, "distribution/self-hosted/compose.yaml"), + await readFile("distribution/self-hosted/compose.yaml"), + ); + const matrixPath = resolve( + root, + "distribution/self-hosted/supported-matrix.json", + ); + const matrix = JSON.parse(await readFile(matrixPath, "utf8")) as { + operatingSystems: unknown[]; + }; + matrix.operatingSystems.push({ id: "ubuntu", version: "26.04" }); + await writeFile(matrixPath, JSON.stringify(matrix)); + await expect( + verifySelfHostedReleasePolicy(releaseManifestFixture(), root), + ).rejects.toThrow(/matrix/i); + }); + + it.each([ + [ + "a host bind mount", + (compose: MutableComposeFixture) => { + const volumes = compose.services.skillwire["volumes"]; + if (!Array.isArray(volumes)) + throw new Error("Fixture volumes are missing"); + volumes.push("/etc:/host:ro"); + }, + ], + [ + "a host device", + (compose: MutableComposeFixture) => { + compose.services.skillwire["devices"] = ["/dev/kmsg:/dev/kmsg"]; + }, + ], + [ + "host PID sharing", + (compose: MutableComposeFixture) => { + compose.services.skillwire["pid"] = "host"; + }, + ], + [ + "an unrestricted capability", + (compose: MutableComposeFixture) => { + compose.services.skillwire["cap_add"] = ["ALL"]; + }, + ], + [ + "a build directive", + (compose: MutableComposeFixture) => { + compose.services.skillwire["build"] = "."; + }, + ], + [ + "a mutable PostgreSQL image", + (compose: MutableComposeFixture) => { + compose.services.postgres["image"] = "postgres:latest"; + }, + ], + [ + "an undeclared host-file secret", + (compose: MutableComposeFixture) => { + compose.secrets["host_key"] = { + file: "/home/operator/.ssh/id_rsa", + }; + const secrets = compose.services.skillwire["secrets"]; + if (!Array.isArray(secrets)) + throw new Error("Fixture secrets are missing"); + secrets.push({ source: "host_key", target: "host_key", mode: 0o400 }); + }, + ], + [ + "an entrypoint override", + (compose: MutableComposeFixture) => { + compose.services.skillwire["entrypoint"] = ["/bin/sh", "-c"]; + }, + ], + [ + "a command override", + (compose: MutableComposeFixture) => { + compose.services.skillwire["command"] = ["cat /run/secrets/host_key"]; + }, + ], + [ + "a user override", + (compose: MutableComposeFixture) => { + compose.services.skillwire["user"] = "1000:1000"; + }, + ], + [ + "an environment-file override", + (compose: MutableComposeFixture) => { + compose.services.skillwire["env_file"] = ["/tmp/attacker.env"]; + }, + ], + [ + "an environment override", + (compose: MutableComposeFixture) => { + const environment = compose.services.skillwire["environment"]; + if (environment === null || typeof environment !== "object") + throw new Error("Fixture environment is missing"); + (environment as Record)["SKILLWIRE_BIND_HOST"] = + "0.0.0.0"; + }, + ], + [ + "a healthcheck override", + (compose: MutableComposeFixture) => { + compose.services.skillwire["healthcheck"] = { + test: ["CMD-SHELL", "exit 0"], + }; + }, + ], + [ + "a dependency override", + (compose: MutableComposeFixture) => { + compose.services.skillwire["depends_on"] = {}; + }, + ], + [ + "a writable temporary filesystem override", + (compose: MutableComposeFixture) => { + compose.services.skillwire["tmpfs"] = ["/tmp:rw,size=1g"]; + }, + ], + [ + "a restart-policy override", + (compose: MutableComposeFixture) => { + compose.services.admin["restart"] = "always"; + }, + ], + [ + "a logging override", + (compose: MutableComposeFixture) => { + compose.services.admin["logging"] = { driver: "json-file" }; + }, + ], + [ + "a profile override", + (compose: MutableComposeFixture) => { + compose.services.admin["profiles"] = ["default"]; + }, + ], + ] as const)("rejects production Compose with %s", async (_label, mutate) => { + const root = await mkdtemp(resolve(tmpdir(), "skillwire-compose-policy-")); + roots.push(root); + await cp("distribution", resolve(root, "distribution"), { + recursive: true, + }); + await cp("integrations", resolve(root, "integrations"), { + recursive: true, + }); + await cp("catalog", resolve(root, "catalog"), { recursive: true }); + const composePath = resolve(root, "distribution/self-hosted/compose.yaml"); + const { parse, stringify } = await import("yaml"); + const compose = parse( + await readFile(composePath, "utf8"), + ) as unknown as MutableComposeFixture; + mutate(compose); + await writeFile(composePath, stringify(compose)); + + await expect( + verifySelfHostedReleasePolicy(releaseManifestFixture(), root), + ).rejects.toThrow(/Compose|policy/i); + }); + + it("rejects unlisted payload bytes and unsafe filesystem entries", async () => { + const root = await mkdtemp(resolve(tmpdir(), "skillwire-release-payload-")); + roots.push(root); + for (const [path, contents] of Object.entries(RELEASE_PAYLOAD_FILES)) { + const target = resolve(root, path); + await mkdir(dirname(target), { recursive: true, mode: 0o700 }); + await writeFile(target, contents, { mode: releasePayloadMode(path) }); + await chmod(target, releasePayloadMode(path)); + } + const manifest = releaseManifestFixture(); + await expect( + verifyManifestPayload(manifest, root), + ).resolves.toBeUndefined(); + await writeFile(resolve(root, "unlisted-byte"), "x", { mode: 0o600 }); + await expect(verifyManifestPayload(manifest, root)).rejects.toThrow( + /undeclared|inventory/i, + ); + }); + + it("keeps every signed-release trust boundary in the executable aggregate", async () => { + for (const [boundary, path, pattern] of RELEASE_BOUNDARY_EVIDENCE) { + const suite = await readFile(path, "utf8"); + expect(suite, `${boundary}: ${path}`).toMatch(pattern); + expect(suite, `${boundary}: ${path}`).toMatch(/\bit(?:\.each)?\(/); + } + }); +}); diff --git a/tests/security/onboarding/secret-containment.test.ts b/tests/security/onboarding/secret-containment.test.ts new file mode 100644 index 0000000..cbc844f --- /dev/null +++ b/tests/security/onboarding/secret-containment.test.ts @@ -0,0 +1,129 @@ +import { randomUUID } from "node:crypto"; +import { execFile } from "node:child_process"; +import { open, readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { promisify } from "node:util"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { OperationJournal } from "../../../src/onboarding/domain/operation-journal.js"; +import { + redactOutput, + redactText, +} from "../../../src/onboarding/cli/output.js"; +import { + createOnboardingEnvironment, + type OnboardingEnvironment, +} from "../../helpers/onboarding-environment.js"; + +describe("Feature 004 secret containment release gate", () => { + const execute = promisify(execFile); + let fixture: OnboardingEnvironment | undefined; + afterEach(async () => fixture?.close()); + + it("keeps one canary out of every persistent or diagnostic surface", async () => { + fixture = await createOnboardingEnvironment(); + const canary = `swk.${"a".repeat(16)}.${"b".repeat(43)}`; + const journal = await OperationJournal.create( + fixture.root, + randomUUID(), + "setup", + ); + await journal.intent("credential", { + client: "codex", + reference: `secret-service:codex:${randomUUID()}`, + }); + await journal.effect("credential", { stored: true }); + await journal.verify("credential", { persisted: true }); + await journal.commit({ outcome: "verified" }); + + const surfaces = { + argv: process.argv.join("\0"), + environment: JSON.stringify(process.env), + procArgv: await readFile("/proc/self/cmdline", "utf8"), + procEnvironment: await readFile("/proc/self/environ", "utf8"), + log: redactText(`authorization: Bearer ${canary}`), + terminal: JSON.stringify(redactOutput({ apiKey: canary })), + config: JSON.stringify({ + credentialReference: `secret-service:codex:${randomUUID()}`, + }), + diff: "credential bridge uses protected reference only", + snapshot: JSON.stringify({ profileIdentitySha256: "a".repeat(64) }), + journal: await readFile( + resolve(fixture.root, `${journal.operationId}.jsonl`), + "utf8", + ), + backup: JSON.stringify({ + serviceSecretReference: "secrets/database-password", + }), + report: JSON.stringify({ result: "credential-unavailable" }), + release: JSON.stringify({ componentSha256: "c".repeat(64) }), + repository: await readFile("package.json", "utf8"), + repositoryDiff: ( + await execute("/usr/bin/git", [ + "-c", + `safe.directory=${process.cwd()}`, + "diff", + "--binary", + "--no-ext-diff", + "--", + ]) + ).stdout, + }; + for (const [surface, contents] of Object.entries(surfaces)) { + expect(contents, surface).not.toContain(canary); + } + }); + + it("keeps onboarding free of a telemetry transport or telemetry SDK", async () => { + const { stdout } = await execute("/usr/bin/git", [ + "-c", + `safe.directory=${process.cwd()}`, + "ls-files", + "-z", + "--", + "src/onboarding", + "package.json", + ]); + const paths = stdout.split("\0").filter(Boolean); + const contents = await Promise.all( + paths.map((path) => readFile(path, "utf8")), + ); + expect(contents.join("\n")).not.toMatch( + /(?:posthog|segment\.com|sentry\.io|telemetry\.track|analytics\.track)/i, + ); + }); + + it("redacts a source-specific GitHub token from generic terminal and log values", () => { + const token = `github_pat_${"source_read_only_".repeat(3)}`; + expect(redactText(`source credential ${token}`)).not.toContain(token); + expect( + JSON.stringify(redactOutput({ message: `source credential ${token}` })), + ).not.toContain(token); + }); + + it("does not place a credential in proc identity when handed over by descriptor", async () => { + fixture = await createOnboardingEnvironment(); + const token = `swk.${"c".repeat(16)}.${"d".repeat(43)}`; + const privatePath = resolve(fixture.root, "private-token"); + const handle = await open(privatePath, "wx", 0o600); + try { + await handle.writeFile(token); + await handle.sync(); + } finally { + await handle.close(); + } + expect(process.argv.join("\0")).not.toContain(token); + expect(JSON.stringify(process.env)).not.toContain(token); + await writeFile( + resolve(fixture.root, "report.json"), + '{"delivery":"private-fd"}', + { + mode: 0o600, + }, + ); + expect( + await readFile(resolve(fixture.root, "report.json"), "utf8"), + ).not.toContain(token); + }); +}); diff --git a/tests/security/onboarding/source-boundaries.test.ts b/tests/security/onboarding/source-boundaries.test.ts new file mode 100644 index 0000000..1e17a25 --- /dev/null +++ b/tests/security/onboarding/source-boundaries.test.ts @@ -0,0 +1,219 @@ +import { randomUUID } from "node:crypto"; +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { GitHubTokenCredentialStore } from "../../../src/onboarding/adapters/credentials/github-token.js"; +import { readBoundedGitHubToken } from "../../../src/onboarding/adapters/credentials/github-token.js"; +import { bootstrapSources } from "../../../src/onboarding/application/source-bootstrap.js"; +import { bootstrapSourceInAdminContainer } from "../../../src/onboarding/application/source-bootstrap.js"; +import { + createOnboardingEnvironment, + type OnboardingEnvironment, +} from "../../helpers/onboarding-environment.js"; + +describe("source bootstrap boundaries", () => { + let fixture: OnboardingEnvironment | undefined; + afterEach(async () => { + await fixture?.close(); + }); + + it("keeps imported text inert and performs zero client or repository writes", async () => { + fixture = await createOnboardingEnvironment(); + const repository = resolve(fixture.root, "repository"); + const codex = resolve(fixture.home, ".codex"); + const claude = resolve(fixture.home, ".claude"); + await Promise.all([ + mkdir(repository, { recursive: true }), + mkdir(codex, { recursive: true }), + mkdir(claude, { recursive: true }), + ]); + const canary = resolve(fixture.root, "executed"); + const hostile = `#!/bin/sh\nprintf owned > ${canary}\n`; + const before = await Promise.all([ + readFile(resolve(fixture.home, ".codex/config.toml")).catch(() => null), + readFile(resolve(fixture.home, ".claude.json")).catch(() => null), + ]); + await bootstrapSources( + [ + { + source: "mattpocock/skills", + credentialReferenceId: randomUUID(), + }, + ], + { + listRegistrations: () => Promise.resolve([]), + register: () => + Promise.resolve({ sourceId: randomUUID(), created: true }), + synchronize: (sourceId) => + Promise.resolve({ + sourceId, + classifications: ["quarantined"], + created: true, + evidence: { inertTextSha256: hostile.length.toString(16) }, + }), + }, + ); + await expect(readFile(canary)).rejects.toMatchObject({ code: "ENOENT" }); + expect( + await Promise.all([ + readFile(resolve(fixture.home, ".codex/config.toml")).catch(() => null), + readFile(resolve(fixture.home, ".claude.json")).catch(() => null), + ]), + ).toEqual(before); + await expect( + readFile(resolve(repository, "SKILL.md")), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("stores a GitHub token under a source-only Secret Service identity", async () => { + fixture = await createOnboardingEnvironment(); + const binRoot = resolve(fixture.root, "bin"); + await mkdir(binRoot, { mode: 0o700 }); + const executable = resolve(binRoot, "secret-tool"); + const argvLog = resolve(fixture.root, "argv.jsonl"); + const stdinLog = resolve(fixture.root, "stdin"); + await writeFile( + executable, + `#!/usr/bin/env node\nconst fs=require('node:fs');\nfs.appendFileSync(${JSON.stringify(argvLog)}, JSON.stringify(process.argv.slice(2))+'\\n');\nlet input=''; process.stdin.on('data', c=>input+=c); process.stdin.on('end',()=>{ if(process.argv[2]==='store') fs.writeFileSync(${JSON.stringify(stdinLog)}, input,{mode:0o600}); if(process.argv[2]==='lookup') process.stdout.write(fs.readFileSync(${JSON.stringify(stdinLog)},'utf8')+'\\n'); });\n`, + { mode: 0o700 }, + ); + await chmod(executable, 0o700); + const store = new GitHubTokenCredentialStore(executable, { + PATH: process.env["PATH"], + }); + const token = "github_pat_source_only_read_token"; + const saved = await store.store(token, new AbortController().signal); + expect(saved.reference).toMatch(/^secret-service:github:[0-9a-f-]{36}$/); + expect(await store.lookup(saved.reference)).toBe(token); + const argv = await readFile(argvLog, "utf8"); + expect(argv).not.toContain(token); + expect(argv).toContain('"purpose","github-source-read-only"'); + expect(argv).not.toContain('"client"'); + expect(Object.values(process.env)).not.toContain(token); + }); + + it("rejects and clears a GitHub credential whose Secret Service readback differs", async () => { + fixture = await createOnboardingEnvironment(); + const binRoot = resolve(fixture.root, "bin-mismatch"); + await mkdir(binRoot, { mode: 0o700 }); + const executable = resolve(binRoot, "secret-tool"); + const operations = resolve(fixture.root, "operations.jsonl"); + await writeFile( + executable, + `#!/usr/bin/env node\nconst fs=require('node:fs'); const operation=process.argv[2]; fs.appendFileSync(${JSON.stringify(operations)}, JSON.stringify(process.argv.slice(2))+'\\n'); if(operation==='lookup') process.stdout.write('github_pat_different_readback_token\\n'); process.stdin.resume();\n`, + { mode: 0o700 }, + ); + await chmod(executable, 0o700); + const store = new GitHubTokenCredentialStore(executable, { + PATH: process.env["PATH"], + }); + const token = "github_pat_source_only_read_token"; + await expect(store.store(token)).rejects.toThrow( + /persistence|verification/i, + ); + const calls = await readFile(operations, "utf8"); + expect(calls).toContain('"store"'); + expect(calls).toContain('"lookup"'); + expect(calls).toContain('"clear"'); + expect(calls).not.toContain(token); + }); + + it("redacts a GitHub token reflected by a failing credential provider", async () => { + fixture = await createOnboardingEnvironment(); + const binRoot = resolve(fixture.root, "bin-reflection"); + await mkdir(binRoot, { mode: 0o700 }); + const executable = resolve(binRoot, "secret-tool"); + await writeFile( + executable, + "#!/usr/bin/env node\nlet input=''; process.stdin.on('data', c=>input+=c); process.stdin.on('end',()=>{ process.stderr.write(input); process.exit(1); });\n", + { mode: 0o700 }, + ); + await chmod(executable, 0o700); + const store = new GitHubTokenCredentialStore(executable, { + PATH: process.env["PATH"], + }); + const token = "github_pat_source_only_read_token"; + let failure: unknown; + try { + await store.store(token); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect(failure instanceof Error ? failure.message : "").not.toContain( + token, + ); + }); + + it("passes a source credential only on container stdin, never argv or environment", async () => { + const token = "github_pat_source_only_read_token"; + let observed: + | Parameters< + NonNullable< + Parameters[0]["run"] + > + >[0] + | undefined; + await bootstrapSourceInAdminContainer({ + source: "obra/superpowers", + token, + dockerExecutable: "/usr/bin/docker", + composePath: "/release/compose.yaml", + projectName: "skillwire-1234567890abcdef", + databasePasswordFile: "/state/database-password", + applicationPepperFile: "/state/application-pepper", + runtimeSocketDirectory: "/runtime/skillwire", + volumeName: "skillwire-1234567890abcdef_postgres_data", + skillwireImage: `ghcr.io/lucenx9/skillwire@sha256:${"1".repeat(64)}`, + postgresImage: `docker.io/library/postgres@sha256:${"2".repeat(64)}`, + environment: { + PATH: "/usr/bin:/bin", + LANG: "C", + GH_TOKEN: "ambient-must-not-propagate", + }, + run: (options) => { + observed = options; + return Promise.resolve({ + code: 0, + stderr: "", + stdout: JSON.stringify({ + schemaVersion: "skillwire.source-bootstrap-result/v1", + sourceId: randomUUID(), + registrationCreated: true, + snapshotCreated: true, + classifications: ["quarantined"], + }), + }); + }, + }); + expect(observed?.stdin).toBe(token); + expect(observed?.args.join(" ")).not.toContain(token); + expect(JSON.stringify(observed?.environment)).not.toContain(token); + expect(observed?.environment).toMatchObject({ + SKILLWIRE_COMPOSE_PROJECT: "skillwire-1234567890abcdef", + SKILLWIRE_POSTGRES_VOLUME: "skillwire-1234567890abcdef_postgres_data", + SKILLWIRE_IMAGE: `ghcr.io/lucenx9/skillwire@sha256:${"1".repeat(64)}`, + SKILLWIRE_POSTGRES_IMAGE: `docker.io/library/postgres@sha256:${"2".repeat(64)}`, + SKILLWIRE_APPLICATION_PEPPER_SECRET_FILE: "/state/application-pepper", + SKILLWIRE_RUNTIME_SOCKET_DIRECTORY: "/runtime/skillwire", + }); + expect(observed?.environment?.["GH_TOKEN"]).toBeUndefined(); + expect(observed?.args).toContain("obra"); + expect(observed?.args).toContain("superpowers"); + }); + + it("cancels bounded source credential input without waiting for another byte", async () => { + const controller = new AbortController(); + async function* delayedInput(): AsyncGenerator { + await new Promise((done) => setTimeout(done, 75)); + yield "github_pat_source_only_read_token"; + } + const reading = readBoundedGitHubToken(delayedInput(), controller.signal); + setTimeout(() => { + controller.abort(); + }, 5); + await expect(reading).rejects.toThrow(/cancel/i); + }); +}); diff --git a/tests/unit/onboarding/setup-error-envelope.test.ts b/tests/unit/onboarding/setup-error-envelope.test.ts index 2f79fdb..f6f99a0 100644 --- a/tests/unit/onboarding/setup-error-envelope.test.ts +++ b/tests/unit/onboarding/setup-error-envelope.test.ts @@ -37,4 +37,20 @@ describe("setup failure envelope mutation truthfulness", () => { recovery: { rollbackBoundary: "none" }, }); }); + + it("reports a committed healthy service as changed when optional-source input is cancelled", () => { + const result = setupFailureEnvelope({ + error: new Error("GitHub source credential input cancelled"), + operationId: randomUUID(), + previewHash: "b".repeat(64), + cancelled: true, + changed: true, + }); + expect(result).toMatchObject({ + status: "cancelled", + exitClass: "user-cancellation", + changed: true, + recovery: { rollbackBoundary: "none" }, + }); + }); }); diff --git a/tests/unit/onboarding/test-infrastructure.test.ts b/tests/unit/onboarding/test-infrastructure.test.ts index d02333b..8d12fc1 100644 --- a/tests/unit/onboarding/test-infrastructure.test.ts +++ b/tests/unit/onboarding/test-infrastructure.test.ts @@ -16,6 +16,9 @@ describe("disposable onboarding infrastructure", () => { try { expect(fixture.home).toContain(fixture.root); expect(fixture.environment["HOME"]).toBe(fixture.home); + expect(fixture.environment["DOCKER_HOST"]).toBe( + process.env["DOCKER_HOST"], + ); expect(fixture.composeProject).toMatch(/^skillwire-test-[0-9a-f]{16}$/); expect(() => { fixture.assertMutablePath(process.cwd());