From 2cceb57060794b5a944221d89df30b2bd45c4e51 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Tue, 8 Sep 2026 00:33:35 +0300 Subject: [PATCH 01/14] test: add disposable Linux lifecycle acceptance workflow Signed-off-by: Tiberiu Socaci --- .github/workflows/linux-lifecycle.yml | 38 ++++++ FEATURES.md | 6 + TEST-PLAN.md | 33 ++++++ scripts/check-linux-lifecycle.sh | 163 ++++++++++++++++++++++++++ 4 files changed, 240 insertions(+) create mode 100644 .github/workflows/linux-lifecycle.yml create mode 100644 scripts/check-linux-lifecycle.sh diff --git a/.github/workflows/linux-lifecycle.yml b/.github/workflows/linux-lifecycle.yml new file mode 100644 index 0000000..4001614 --- /dev/null +++ b/.github/workflows/linux-lifecycle.yml @@ -0,0 +1,38 @@ +name: Linux lifecycle + +on: + workflow_dispatch: + pull_request: + paths: + - .github/workflows/linux-lifecycle.yml + - scripts/check-linux-lifecycle.sh + +permissions: + contents: read + +jobs: + lifecycle: + # A hosted VM with real PID-1 systemd: a job container cannot prove service installation. + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "24" + - name: Install rootless runtime prerequisites in the disposable VM + run: | + sudo apt-get update + sudo apt-get install -y podman uidmap slirp4netns fuse-overlayfs + - name: Exercise installation, restart, fixture recovery and uninstall + run: bash scripts/check-linux-lifecycle.sh "$RUNNER_TEMP/lifecycle-evidence" + - name: Upload non-secret lifecycle evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: linux-lifecycle-${{ github.sha }} + path: ${{ runner.temp }}/lifecycle-evidence/ + if-no-files-found: error diff --git a/FEATURES.md b/FEATURES.md index f2248e1..cc5857e 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -2,6 +2,12 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. +- **Disposable Linux lifecycle evidence:** the manually dispatchable Linux lifecycle workflow + exercises a fresh dedicated-account systemd install with the full rootless Podman image, + HTTP liveness after restart, encrypted fixture backup/restore and non-destructive uninstall. + Its script refuses non-hosted or occupied hosts. Actual reboot and authenticated engine update + rollback remain separate live gates. → TEST-PLAN: Disposable Linux lifecycle workflow. + - Development acceptance policy: behavior changes include reproducible Claude and Codex acceptance definitions and clearly separate automated evidence from live operator validation. Public contributors do not need access to a private QA service. → TEST-PLAN: Development acceptance policy. diff --git a/TEST-PLAN.md b/TEST-PLAN.md index c5226ee..ee02d86 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -3,6 +3,39 @@ Cumulative functional + security regression. Extended per slice. Run top-to-bottom for a full pass. Many checks are manual (require a real Slack workspace + an authenticated `claude` CLI). +## Base modes and independent options (2026-09-08) +## Disposable Linux lifecycle workflow + +- Automated setup: dispatch `.github/workflows/linux-lifecycle.yml` for the candidate ref (a PR + changing this workflow/script also runs it). GitHub-hosted Ubuntu 24.04, Node 24, real PID-1 + systemd and rootless Podman; no job container, provider credentials or chat connection. + `scripts/check-linux-lifecycle.sh` refuses non-hosted runners, occupied fixture paths, service + units and accounts before mutation. It operates only on `/opt/channelgate-lifecycle` and the + newly installed `/var/lib/channelgate-lifecycle` service identity; never an operator deployment. +- Pass evidence: `linux-lifecycle-` artifact records source revision, VM image, versions and + every PASS line. Require successful fresh install/image build, non-root container with zero + effective capabilities/no-new-privileges, enabled active service, distinct healthy instance ID + after real systemd restart, encrypted snapshot while the fixture daemon runs, restored + `before-backup` SQL/config markers, SQLite integrity `ok`, removal of stale WAL/SHM and stray + config, healthy restart and uninstall preserving the account/database/encrypted backup. + These checks are engine-independent because they issue no engine turn. Update transaction + unit evidence is uploaded separately as `update-fixture-tests.tap`; injected failures do not + count as real authenticated engine update/rollback acceptance. +- [ ] Actual reboot gate (both configured engines): on a separate disposable Linux VM, install + the candidate and create a channel that writes `LIFECYCLE-BEFORE-REBOOT` in its own work folder. + Record instance ID and engine/session identity, reboot the machine, then ask each engine in + its existing thread to read the marker. Require automatic service start without manual repair, + a new daemon instance ID, preserved marker/session and container-only engine execution. + A hosted job restart is not a reboot and cannot clear this gate. +- [ ] Authenticated update rollback gate (Claude and Codex): on that disposable deployment, + configure both engine credentials in its own service identity and a local fixture upstream. + Baseline revision A must answer the fixed update smoke response for both engines. Create + fast-forward candidate B with an intentional failing test; invoke `npm run update`. + Require a visible candidate failure, durable `rolled_back` state, restored A checkout and + database/config, new healthy A instance, passing smoke for both baseline engines and a new + ordinary turn in each existing channel. Repeat with a candidate that passes tests but fails + readiness after restart. Never perform induced-failure checks on a production deployment. + ## Base modes and independent options (2026-09-08) - Automated: `modes`, `channel-settings-modal`, `mode-command-audit`, `folders-settings`, diff --git a/scripts/check-linux-lifecycle.sh b/scripts/check-linux-lifecycle.sh new file mode 100644 index 0000000..3036896 --- /dev/null +++ b/scripts/check-linux-lifecycle.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# Destructive operations acceptance, ONLY on an empty GitHub-hosted Ubuntu VM. +# No provider credentials, chat connections or engine prompts are needed or accepted here. +set -euo pipefail +if [ "${GITHUB_ACTIONS:-}" != true ] || [ "${RUNNER_ENVIRONMENT:-}" != github-hosted ] || + [ "$(cat /proc/1/comm)" != systemd ]; then + echo "Refusing: this check requires a disposable GitHub-hosted VM with PID-1 systemd." >&2 + exit 2 +fi +SOURCE_DIR="$(cd "$(dirname "$0")/.." && pwd)" +EVIDENCE="${1:?Pass the evidence output directory}" +APP_DIR=/opt/channelgate-lifecycle +SERVICE_USER=cg-lifecycle +SERVICE_HOME=/var/lib/channelgate-lifecycle +for occupied in "$APP_DIR" "$SERVICE_HOME" /etc/systemd/system/channelgate.service /etc/systemd/system/claude-gateway.service; do + [ ! -e "$occupied" ] || { echo "Refusing occupied lifecycle fixture: $occupied" >&2; exit 2; } +done +if id "$SERVICE_USER" >/dev/null 2>&1 || id claude-gateway >/dev/null 2>&1; then + echo "Refusing existing lifecycle or legacy service account" >&2; exit 2 +fi +mkdir -p "$EVIDENCE" +exec > >(tee "$EVIDENCE/lifecycle.log") 2>&1 +printf 'revision=%s\n' "$(git -C "$SOURCE_DIR" rev-parse HEAD)" +printf 'runner=%s\n' "${ImageOS:-unknown} ${ImageVersion:-unknown}" +uname -sr +node --version +podman --version +systemctl --version | head -1 +df -h /opt /var/lib + +cleanup() { + outcome=$? + trap - EXIT + if [ -f /etc/systemd/system/channelgate.service ]; then + sudo bash "$APP_DIR/scripts/uninstall-systemd.sh" --system || true + fi + printf 'exit_code=%s\n' "$outcome" + exit "$outcome" +} +trap cleanup EXIT +pass() { printf 'PASS %s\n' "$1"; } +as_service() { + sudo runuser -u "$SERVICE_USER" -- env HOME="$SERVICE_HOME" CHANNELGATE_DIR="$SERVICE_HOME" \ + CHANNELGATE_DB="" CLAUDE_GATEWAY_DIR="" CLAUDE_GATEWAY_DB="" \ + XDG_RUNTIME_DIR="/run/user/$(id -u "$SERVICE_USER")" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$(id -u "$SERVICE_USER")/bus" \ + PATH="$PATH" "$@" +} +health() { + node --input-type=module - "$1" <<'NODE' +import assert from 'node:assert/strict'; +const previous = process.argv[2]; +for (let attempt = 0; attempt < 90; attempt += 1) { + try { + const response = await fetch('http://127.0.0.1:4747/api/health', { signal: AbortSignal.timeout(2000) }); + assert.equal(response.status, 200); + const data = await response.json(); + assert.equal(data.ok, true); + assert.ok(data.instanceId && data.instanceId !== previous); + assert.equal(data.slack.connected, false); + console.log(data.instanceId); + process.exit(0); + } catch { await new Promise((resolve) => setTimeout(resolve, 1000)); } +} +throw new Error('Fresh healthy daemon instance did not become available'); +NODE +} + +# Clone public source only; never copy the caller's home, credentials or runtime state. +sudo git clone --no-checkout "https://github.com/${GITHUB_REPOSITORY:?}.git" "$APP_DIR" +sudo git -C "$APP_DIR" fetch origin "$(git -C "$SOURCE_DIR" rev-parse HEAD)" +sudo git -C "$APP_DIR" checkout --detach "$(git -C "$SOURCE_DIR" rev-parse HEAD)" +BOOTSTRAP_ROOT="$(mktemp -d)" +sudo env PATH="$PATH" CHANNELGATE_DIR="$BOOTSTRAP_ROOT" CHANNELGATE_DB="" CLAUDE_GATEWAY_DIR="" CLAUDE_GATEWAY_DB="" \ + bash "$APP_DIR/scripts/install.sh" --without-whisper +# Public, disposable fixture password prevents a generated bootstrap secret reaching CI logs. +# The daemon listens only on loopback; no provider/chat tokens are configured. +sudo tee "$APP_DIR/.env" >/dev/null <<'ENV' +ADMIN_PASSWORD=public-disposable-lifecycle-fixture +CG_BIND_HOST=127.0.0.1 +PORT=4747 +ENV +sudo env PATH="$PATH" CG_SERVICE_USER="$SERVICE_USER" CG_SERVICE_HOME="$SERVICE_HOME" \ + bash "$APP_DIR/scripts/install-systemd.sh" +FIRST_INSTANCE="$(health '')" +systemctl is-enabled channelgate.service +systemctl is-active channelgate.service +[ "$(systemctl show channelgate.service -p User --value)" = "$SERVICE_USER" ] +[ "$(systemctl show channelgate.service -p Delegate --value)" = yes ] +[ "$(sudo stat -c %a "$SERVICE_HOME/service.env")" = 600 ] +as_service podman info --format '{{.Host.Security.Rootless}}' | grep -qx true +pass 'fresh systemd installation, dedicated identity, enablement and HTTP liveness' +as_service podman run --rm --userns=keep-id --cap-drop=all --security-opt=no-new-privileges \ + --network=bridge channelgate/runtime:latest node --input-type=module -e \ + 'import assert from "node:assert/strict"; import {readFileSync} from "node:fs"; assert.notEqual(process.getuid(),0); const s=readFileSync("/proc/self/status","utf8"); assert.match(s,/CapEff:\s+0+\n/); assert.match(s,/NoNewPrivs:\s+1\n/); console.log("container uid="+process.getuid()+" zero capabilities, no-new-privileges");' +pass 'production image runs with rootless identity and dropped capabilities (no engine prompt)' +sudo systemctl restart channelgate.service +SECOND_INSTANCE="$(health "$FIRST_INSTANCE")" +pass 'real systemd restart produces a new healthy daemon instance' + +# Use the freshly installed disposable daemon database, never an operator deployment. +sudo systemctl stop channelgate.service +as_service node --input-type=module - "$SERVICE_HOME" <<'NODE' +import { DatabaseSync } from 'node:sqlite'; +import { writeFileSync } from 'node:fs'; +const root = process.argv[2]; +const db = new DatabaseSync(`${root}/gateway.db`); +db.exec("CREATE TABLE lifecycle_proof(value TEXT); INSERT INTO lifecycle_proof VALUES ('before-backup')"); +db.close(); +writeFileSync(`${root}/config/lifecycle-proof.json`, '{"value":"before-backup"}\n'); +NODE +sudo systemctl start channelgate.service +THIRD_INSTANCE="$(health "$SECOND_INSTANCE")" +as_service env CG_BACKUP_PASSPHRASE=public-disposable-backup-fixture bash "$APP_DIR/scripts/backup-config.sh" +as_service env CG_BACKUP_PASSPHRASE=public-disposable-backup-fixture bash "$APP_DIR/scripts/restore-drill.sh" +sudo systemctl stop channelgate.service +as_service node --input-type=module - "$SERVICE_HOME" <<'NODE' +import { DatabaseSync } from 'node:sqlite'; +import { writeFileSync } from 'node:fs'; +const root = process.argv[2]; +const db = new DatabaseSync(`${root}/gateway.db`); +db.exec("UPDATE lifecycle_proof SET value='after-backup'"); +db.close(); +writeFileSync(`${root}/config/stray-lifecycle.json`, '{}'); +writeFileSync(`${root}/gateway.db-wal`, 'stale-wal'); +writeFileSync(`${root}/gateway.db-shm`, 'stale-shm'); +NODE +as_service env CG_BACKUP_PASSPHRASE=public-disposable-backup-fixture CG_RESTORE_CONFIRM=YES \ + bash "$APP_DIR/scripts/restore-config.sh" +as_service node --input-type=module - "$SERVICE_HOME" <<'NODE' +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import { existsSync, readFileSync } from 'node:fs'; +const root = process.argv[2]; +assert.equal(existsSync(`${root}/gateway.db-wal`), false); +assert.equal(existsSync(`${root}/gateway.db-shm`), false); +assert.equal(existsSync(`${root}/config/stray-lifecycle.json`), false); +assert.equal(JSON.parse(readFileSync(`${root}/config/lifecycle-proof.json`)).value, 'before-backup'); +const db = new DatabaseSync(`${root}/gateway.db`, { readOnly: true }); +assert.equal(db.prepare('SELECT value FROM lifecycle_proof').get().value, 'before-backup'); +assert.equal(db.prepare('PRAGMA integrity_check').get().integrity_check, 'ok'); +db.close(); +NODE +sudo systemctl start channelgate.service +health "$THIRD_INSTANCE" +pass 'live encrypted fixture snapshot, disposable drill, replacement restore and healthy restart' + +# Existing injected transaction tests are separately labelled: these do not claim a real update +# across authenticated engine versions or an induced failure in a production deployment. +as_service bash -c 'cd "$1" && node --test test/update-runner.test.js test/update-state.test.js test/update-smoke.test.js' bash "$APP_DIR" \ + > "$EVIDENCE/update-fixture-tests.tap" 2>&1 +pass 'injected update failure/rollback and container smoke regression tests' +sudo bash "$APP_DIR/scripts/uninstall-systemd.sh" --system +[ ! -e /etc/systemd/system/channelgate.service ] +if systemctl is-active --quiet channelgate.service; then echo 'Service remained active after uninstall'; exit 1; fi +id "$SERVICE_USER" >/dev/null +as_service test -s "$SERVICE_HOME/gateway.db" +as_service test -s "$SERVICE_HOME/backups/config.tar.gz.enc" +pass 'uninstall removes and stops system unit while preserving account, database and backup' +printf '%s\n' \ + 'NOT RUN: actual host reboot and post-reboot recovery (hosted job does not survive reboot).' \ + 'NOT RUN: authenticated Claude/Codex update, induced live candidate failure and automatic rollback.' \ + 'NOT RUN: Slack/Airtable/Composio or other external acceptance campaigns.' From 044c7c4bae629801f18696b1f43b83faca64527c Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Tue, 8 Sep 2026 00:33:53 +0300 Subject: [PATCH 02/14] docs: correct lifecycle acceptance section placement Signed-off-by: Tiberiu Socaci --- TEST-PLAN.md | 1 - 1 file changed, 1 deletion(-) diff --git a/TEST-PLAN.md b/TEST-PLAN.md index ee02d86..ed93350 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -3,7 +3,6 @@ Cumulative functional + security regression. Extended per slice. Run top-to-bottom for a full pass. Many checks are manual (require a real Slack workspace + an authenticated `claude` CLI). -## Base modes and independent options (2026-09-08) ## Disposable Linux lifecycle workflow - Automated setup: dispatch `.github/workflows/linux-lifecycle.yml` for the candidate ref (a PR From a630c89368a5c96ba372edf5fe144f4b93002e83 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Tue, 8 Sep 2026 00:35:32 +0300 Subject: [PATCH 03/14] test: isolate transaction fixtures from installed lifecycle state Signed-off-by: Tiberiu Socaci --- scripts/check-linux-lifecycle.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/check-linux-lifecycle.sh b/scripts/check-linux-lifecycle.sh index 3036896..6189aa8 100644 --- a/scripts/check-linux-lifecycle.sh +++ b/scripts/check-linux-lifecycle.sh @@ -147,7 +147,9 @@ pass 'live encrypted fixture snapshot, disposable drill, replacement restore and # Existing injected transaction tests are separately labelled: these do not claim a real update # across authenticated engine versions or an induced failure in a production deployment. -as_service bash -c 'cd "$1" && node --test test/update-runner.test.js test/update-state.test.js test/update-smoke.test.js' bash "$APP_DIR" \ +as_service env -u CHANNELGATE_DIR -u CHANNELGATE_DB -u CLAUDE_GATEWAY_DIR -u CLAUDE_GATEWAY_DB \ + -u CG_WORKSPACE_DIR -u CG_TEST_SCRATCH \ + bash -c 'cd "$1" && node --test test/update-runner.test.js test/update-state.test.js test/update-smoke.test.js' bash "$APP_DIR" \ > "$EVIDENCE/update-fixture-tests.tap" 2>&1 pass 'injected update failure/rollback and container smoke regression tests' sudo bash "$APP_DIR/scripts/uninstall-systemd.sh" --system From ae2cc59ff6c364af252a22300e9997fa41a1df90 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Tue, 8 Sep 2026 00:36:45 +0300 Subject: [PATCH 04/14] fix: isolate service image provisioning from operator environment Signed-off-by: Tiberiu Socaci --- FEATURES.md | 3 +++ TEST-PLAN.md | 2 ++ scripts/check-linux-lifecycle.sh | 2 +- scripts/install-systemd.sh | 4 +++- test/operations-readiness.test.js | 2 ++ 5 files changed, 11 insertions(+), 2 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index cc5857e..94cb7a5 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -7,6 +7,9 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. HTTP liveness after restart, encrypted fixture backup/restore and non-destructive uninstall. Its script refuses non-hosted or occupied hosts. Actual reboot and authenticated engine update rollback remain separate live gates. → TEST-PLAN: Disposable Linux lifecycle workflow. +- **Service-account image provisioning** uses the same explicit environment as the systemd + daemon so operator XDG/container storage settings cannot redirect a fresh build into another + user's private Podman store. - Development acceptance policy: behavior changes include reproducible Claude and Codex acceptance definitions and clearly separate automated evidence from live operator validation. diff --git a/TEST-PLAN.md b/TEST-PLAN.md index ed93350..dca69db 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -11,6 +11,8 @@ pass. Many checks are manual (require a real Slack workspace + an authenticated `scripts/check-linux-lifecycle.sh` refuses non-hosted runners, occupied fixture paths, service units and accounts before mutation. It operates only on `/opt/channelgate-lifecycle` and the newly installed `/var/lib/channelgate-lifecycle` service identity; never an operator deployment. + The installer image build must succeed even when the invoking runner has its own container + storage configuration; the service account must use its own HOME/store and explicit environment. - Pass evidence: `linux-lifecycle-` artifact records source revision, VM image, versions and every PASS line. Require successful fresh install/image build, non-root container with zero effective capabilities/no-new-privileges, enabled active service, distinct healthy instance ID diff --git a/scripts/check-linux-lifecycle.sh b/scripts/check-linux-lifecycle.sh index 6189aa8..b3871b9 100644 --- a/scripts/check-linux-lifecycle.sh +++ b/scripts/check-linux-lifecycle.sh @@ -40,7 +40,7 @@ cleanup() { trap cleanup EXIT pass() { printf 'PASS %s\n' "$1"; } as_service() { - sudo runuser -u "$SERVICE_USER" -- env HOME="$SERVICE_HOME" CHANNELGATE_DIR="$SERVICE_HOME" \ + sudo runuser -u "$SERVICE_USER" -- env -i HOME="$SERVICE_HOME" CHANNELGATE_DIR="$SERVICE_HOME" \ CHANNELGATE_DB="" CLAUDE_GATEWAY_DIR="" CLAUDE_GATEWAY_DB="" \ XDG_RUNTIME_DIR="/run/user/$(id -u "$SERVICE_USER")" \ DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$(id -u "$SERVICE_USER")/bus" \ diff --git a/scripts/install-systemd.sh b/scripts/install-systemd.sh index 419384d..9af6fb8 100755 --- a/scripts/install-systemd.sh +++ b/scripts/install-systemd.sh @@ -118,7 +118,9 @@ fi # Probe and build in the final daemon identity's own rootless store. The installer's store and # image UID cannot be reused by a different service account. run_as_service() { - runuser -u "$SERVICE_USER" -- env HOME="$SERVICE_HOME" CHANNELGATE_DIR="$SERVICE_HOME" \ + # Match the unit's explicit environment. Inherited operator XDG/container-storage settings + # can point Podman at another user's private store even after HOME changes. + runuser -u "$SERVICE_USER" -- env -i HOME="$SERVICE_HOME" CHANNELGATE_DIR="$SERVICE_HOME" \ XDG_RUNTIME_DIR="$SERVICE_RUNTIME_DIR" DBUS_SESSION_BUS_ADDRESS="unix:path=$SERVICE_RUNTIME_DIR/bus" \ PATH="$SERVICE_PATH" "$@" } diff --git a/test/operations-readiness.test.js b/test/operations-readiness.test.js index 9d311c0..e083da8 100644 --- a/test/operations-readiness.test.js +++ b/test/operations-readiness.test.js @@ -138,6 +138,8 @@ test("service packages pin dedicated identities and hardened runtime boundaries" assert.match(systemd, /Delegate=yes/); assert.match(systemd, /--add-subids-for-system/); assert.match(systemd, /run_as_service "\$NODE_BIN"/); + assert.match(systemd, /runuser -u "\$SERVICE_USER" -- env -i HOME=/, + "image provisioning must not inherit an operator's XDG/container storage configuration"); assert.match(systemd, /Requires=user@\$SERVICE_UID\.service/); assert.match(systemd, /After=network-online.target user@\$SERVICE_UID\.service/); assert.ok(systemd.indexOf('scripts/service-path-preflight.mjs') < systemd.indexOf('useradd --system')); From 9d35ecae79497d7bb9a130e048ebdb7a832c1d15 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Tue, 8 Sep 2026 00:39:10 +0300 Subject: [PATCH 05/14] fix: build service images from the service-owned checkout Signed-off-by: Tiberiu Socaci --- TEST-PLAN.md | 5 ++++- scripts/check-linux-lifecycle.sh | 2 +- scripts/install-systemd.sh | 7 +++++-- test/operations-readiness.test.js | 2 ++ 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/TEST-PLAN.md b/TEST-PLAN.md index dca69db..15eb2b7 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -13,6 +13,8 @@ pass. Many checks are manual (require a real Slack workspace + an authenticated newly installed `/var/lib/channelgate-lifecycle` service identity; never an operator deployment. The installer image build must succeed even when the invoking runner has its own container storage configuration; the service account must use its own HOME/store and explicit environment. + Invoke the installer by absolute path while cwd is an operator-private directory; the image + probe/build must run from the service-owned checkout rather than inherit that inaccessible cwd. - Pass evidence: `linux-lifecycle-` artifact records source revision, VM image, versions and every PASS line. Require successful fresh install/image build, non-root container with zero effective capabilities/no-new-privileges, enabled active service, distinct healthy instance ID @@ -33,7 +35,8 @@ pass. Many checks are manual (require a real Slack workspace + an authenticated Baseline revision A must answer the fixed update smoke response for both engines. Create fast-forward candidate B with an intentional failing test; invoke `npm run update`. Require a visible candidate failure, durable `rolled_back` state, restored A checkout and - database/config, new healthy A instance, passing smoke for both baseline engines and a new + an operator recovery snapshot of database/config (runtime data is not automatically rolled + back), a new healthy A instance, passing smoke for both baseline engines and a new ordinary turn in each existing channel. Repeat with a candidate that passes tests but fails readiness after restart. Never perform induced-failure checks on a production deployment. diff --git a/scripts/check-linux-lifecycle.sh b/scripts/check-linux-lifecycle.sh index b3871b9..946013d 100644 --- a/scripts/check-linux-lifecycle.sh +++ b/scripts/check-linux-lifecycle.sh @@ -44,7 +44,7 @@ as_service() { CHANNELGATE_DB="" CLAUDE_GATEWAY_DIR="" CLAUDE_GATEWAY_DB="" \ XDG_RUNTIME_DIR="/run/user/$(id -u "$SERVICE_USER")" \ DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$(id -u "$SERVICE_USER")/bus" \ - PATH="$PATH" "$@" + PATH="$PATH" /bin/sh -c 'cd "$HOME" && exec "$@"' sh "$@" } health() { node --input-type=module - "$1" <<'NODE' diff --git a/scripts/install-systemd.sh b/scripts/install-systemd.sh index 9af6fb8..b1e9b41 100755 --- a/scripts/install-systemd.sh +++ b/scripts/install-systemd.sh @@ -117,13 +117,16 @@ fi # Probe and build in the final daemon identity's own rootless store. The installer's store and # image UID cannot be reused by a different service account. -run_as_service() { +run_as_service() ( # Match the unit's explicit environment. Inherited operator XDG/container-storage settings # can point Podman at another user's private store even after HOME changes. + # Podman also re-enters cwd after its user-namespace transition; the invoking shell may be + # in an operator-private directory even when the installer itself lives under /opt. + cd "$APP_DIR" runuser -u "$SERVICE_USER" -- env -i HOME="$SERVICE_HOME" CHANNELGATE_DIR="$SERVICE_HOME" \ XDG_RUNTIME_DIR="$SERVICE_RUNTIME_DIR" DBUS_SESSION_BUS_ADDRESS="unix:path=$SERVICE_RUNTIME_DIR/bus" \ PATH="$SERVICE_PATH" "$@" -} +) run_as_service podman info --format '{{.Host.Security.Rootless}}' | grep -qx true || { echo "Rootless Podman is not usable as $SERVICE_USER"; exit 1; } diff --git a/test/operations-readiness.test.js b/test/operations-readiness.test.js index e083da8..85bf1fd 100644 --- a/test/operations-readiness.test.js +++ b/test/operations-readiness.test.js @@ -140,6 +140,8 @@ test("service packages pin dedicated identities and hardened runtime boundaries" assert.match(systemd, /run_as_service "\$NODE_BIN"/); assert.match(systemd, /runuser -u "\$SERVICE_USER" -- env -i HOME=/, "image provisioning must not inherit an operator's XDG/container storage configuration"); + assert.match(systemd, /run_as_service\(\) \([\s\S]*?cd "\$APP_DIR"[\s\S]*?runuser/, + "Podman must start from the service-owned checkout, not the caller's private cwd"); assert.match(systemd, /Requires=user@\$SERVICE_UID\.service/); assert.match(systemd, /After=network-online.target user@\$SERVICE_UID\.service/); assert.ok(systemd.indexOf('scripts/service-path-preflight.mjs') < systemd.indexOf('useradd --system')); From 5f1038190f06b56e82a1a3fdc81a70d846957429 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Tue, 8 Sep 2026 00:42:40 +0300 Subject: [PATCH 06/14] test: exercise real updater rollback with a controlled smoke fixture Signed-off-by: Tiberiu Socaci --- .github/workflows/linux-lifecycle.yml | 1 + FEATURES.md | 5 +- TEST-PLAN.md | 8 ++ scripts/check-linux-lifecycle.sh | 3 +- scripts/check-update-rollback-hosted.mjs | 119 +++++++++++++++++++++++ 5 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 scripts/check-update-rollback-hosted.mjs diff --git a/.github/workflows/linux-lifecycle.yml b/.github/workflows/linux-lifecycle.yml index 4001614..f3ae9c9 100644 --- a/.github/workflows/linux-lifecycle.yml +++ b/.github/workflows/linux-lifecycle.yml @@ -6,6 +6,7 @@ on: paths: - .github/workflows/linux-lifecycle.yml - scripts/check-linux-lifecycle.sh + - scripts/check-update-rollback-hosted.mjs permissions: contents: read diff --git a/FEATURES.md b/FEATURES.md index 94cb7a5..2c40006 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -5,11 +5,14 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. - **Disposable Linux lifecycle evidence:** the manually dispatchable Linux lifecycle workflow exercises a fresh dedicated-account systemd install with the full rootless Podman image, HTTP liveness after restart, encrypted fixture backup/restore and non-destructive uninstall. + A local fixture upstream also proves the real CLI updater restores Git and restarts systemd + after a candidate test failure or readiness failure; engine smoke is explicitly stubbed. Its script refuses non-hosted or occupied hosts. Actual reboot and authenticated engine update rollback remain separate live gates. → TEST-PLAN: Disposable Linux lifecycle workflow. - **Service-account image provisioning** uses the same explicit environment as the systemd daemon so operator XDG/container storage settings cannot redirect a fresh build into another - user's private Podman store. + user's private Podman store, and runs from the service-owned checkout so an operator-private + invocation directory cannot prevent Podman namespace setup. - Development acceptance policy: behavior changes include reproducible Claude and Codex acceptance definitions and clearly separate automated evidence from live operator validation. diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 15eb2b7..8292be5 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -24,6 +24,14 @@ pass. Many checks are manual (require a real Slack workspace + an authenticated These checks are engine-independent because they issue no engine turn. Update transaction unit evidence is uploaded separately as `update-fixture-tests.tap`; injected failures do not count as real authenticated engine update/rollback acceptance. +- Hosted updater operations: `scripts/check-update-rollback-hosted.mjs` is restricted to that + exact disposable checkout/runtime, replaces only the LOCAL fixture's engine smoke response, + and creates a local bare Git upstream. It invokes the unmodified CLI updater first against a + candidate whose test exits 42, then one whose live health reports an incorrect revision. + Require two durable `rolled_back` results, checkout/running revision A, new healthy service + instance, released update lock, private operator recovery snapshot and unchanged SQL marker. + Finally restore the original tested source revision and verify its fresh healthy instance. + Public evidence must name the smoke stub; no Claude/Codex authentication is proven here. - [ ] Actual reboot gate (both configured engines): on a separate disposable Linux VM, install the candidate and create a channel that writes `LIFECYCLE-BEFORE-REBOOT` in its own work folder. Record instance ID and engine/session identity, reboot the machine, then ask each engine in diff --git a/scripts/check-linux-lifecycle.sh b/scripts/check-linux-lifecycle.sh index 946013d..11c1d44 100644 --- a/scripts/check-linux-lifecycle.sh +++ b/scripts/check-linux-lifecycle.sh @@ -152,6 +152,7 @@ as_service env -u CHANNELGATE_DIR -u CHANNELGATE_DB -u CLAUDE_GATEWAY_DIR -u CLA bash -c 'cd "$1" && node --test test/update-runner.test.js test/update-state.test.js test/update-smoke.test.js' bash "$APP_DIR" \ > "$EVIDENCE/update-fixture-tests.tap" 2>&1 pass 'injected update failure/rollback and container smoke regression tests' +as_service env CG_DISPOSABLE_LIFECYCLE=1 node "$APP_DIR/scripts/check-update-rollback-hosted.mjs" sudo bash "$APP_DIR/scripts/uninstall-systemd.sh" --system [ ! -e /etc/systemd/system/channelgate.service ] if systemctl is-active --quiet channelgate.service; then echo 'Service remained active after uninstall'; exit 1; fi @@ -161,5 +162,5 @@ as_service test -s "$SERVICE_HOME/backups/config.tar.gz.enc" pass 'uninstall removes and stops system unit while preserving account, database and backup' printf '%s\n' \ 'NOT RUN: actual host reboot and post-reboot recovery (hosted job does not survive reboot).' \ - 'NOT RUN: authenticated Claude/Codex update, induced live candidate failure and automatic rollback.' \ + 'NOT RUN: authenticated Claude/Codex update smoke (real Git/service rollback uses a controlled smoke fixture).' \ 'NOT RUN: Slack/Airtable/Composio or other external acceptance campaigns.' diff --git a/scripts/check-update-rollback-hosted.mjs b/scripts/check-update-rollback-hosted.mjs new file mode 100644 index 0000000..233b37e --- /dev/null +++ b/scripts/check-update-rollback-hosted.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +// Real CLI updater + Git + systemd rollback in the lifecycle VM. Only the engine smoke response +// is stubbed in a LOCAL fixture commit; this is not authenticated Claude/Codex acceptance. +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { DatabaseSync } from "node:sqlite"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const root = "/var/lib/channelgate-lifecycle"; +assert.equal(repo, "/opt/channelgate-lifecycle", "only the disposable lifecycle checkout is permitted"); +assert.equal(process.env.CHANNELGATE_DIR, root); +assert.equal(process.env.CG_DISPOSABLE_LIFECYCLE, "1"); +assert.notEqual(process.getuid(), 0); +assert.equal(statSync(root).uid, process.getuid()); +assert.equal(JSON.parse(readFileSync(`${root}/config/lifecycle-proof.json`)).value, "before-backup"); +const run = (command, args) => execFileSync(command, args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); +const git = (...args) => run("git", args); +const original = git("rev-parse", "HEAD"); +const upstream = `${root}/update-fixture.git`; +assert.equal(existsSync(upstream), false); +git("config", "user.name", "Lifecycle Fixture"); +git("config", "user.email", "fixture@example.invalid"); +const appFile = `${repo}/src/web/app.js`; +const packageFile = `${repo}/package.json`; +const settingsFile = `${root}/config/settings.json`; +const settings = JSON.parse(readFileSync(settingsFile)); +writeFileSync(settingsFile, `${JSON.stringify({ ...settings, whisperEnabled: false, driveSyncEnabled: false }, null, 2)}\n`); +const appSource = readFileSync(appFile, "utf8"); +assert.equal(appSource.split("updateSmoke = runUpdateSmoke,").length, 2); +writeFileSync(appFile, appSource.replace("updateSmoke = runUpdateSmoke,", + 'updateSmoke = async () => ({ ok: true, engines: [{ engine: "fixture-only", ok: true }] }),')); +const manifest = JSON.parse(readFileSync(packageFile)); +manifest.scripts.test = 'node -e "console.log(\'controlled fixture suite passed\')"'; +delete manifest.scripts.pretest; +writeFileSync(packageFile, `${JSON.stringify(manifest, null, 2)}\n`); +git("switch", "-c", "lifecycle-update-fixture"); +git("add", "src/web/app.js", "package.json"); +git("commit", "-m", "fixture: deterministic unauthenticated smoke for updater operations"); +const baseline = git("rev-parse", "HEAD"); +git("clone", "--bare", repo, upstream); +git("remote", "set-url", "origin", upstream); +git("fetch", "origin"); +git("branch", "--set-upstream-to=origin/lifecycle-update-fixture"); + +async function health(expectedRevision, previous = "") { + for (let attempt = 0; attempt < 90; attempt += 1) { + try { + const auth = JSON.parse(readFileSync(`${root}/config/internal-auth.json`)); + const response = await fetch(`http://127.0.0.1:${auth.port}/api/health`, { + headers: { "x-cg-secret": auth.secret }, signal: AbortSignal.timeout(2000), + }); + assert.equal(response.status, 200); + const value = await response.json(); + assert.equal(value.ok, true); + assert.equal(value.revision, expectedRevision); + assert.ok(value.instanceId && value.instanceId !== previous); + return value; + } catch { await new Promise((resolve) => setTimeout(resolve, 1000)); } + } + throw new Error(`Expected a new healthy instance on ${expectedRevision}`); +} +function restart() { + const pid = Number(run("systemctl", ["show", "--property", "MainPID", "--value", "channelgate.service"])); + assert.ok(Number.isInteger(pid) && pid > 1); + process.kill(pid, "SIGUSR2"); +} + +restart(); +let before = await health(baseline); +for (const failure of ["test", "readiness"]) { + if (failure === "test") { + const candidateManifest = { ...manifest, scripts: { ...manifest.scripts, test: 'node -e "process.exit(42)"' } }; + writeFileSync(packageFile, `${JSON.stringify(candidateManifest, null, 2)}\n`); + git("add", "package.json"); + } else { + const source = readFileSync(appFile, "utf8"); + assert.equal(source.split("revision: runningRevision,").length, 2); + writeFileSync(appFile, source.replace("revision: runningRevision,", 'revision: "lifecycle-intentionally-unready",')); + git("add", "src/web/app.js"); + } + git("commit", "-m", `fixture: intentional ${failure} failure`); + const candidate = git("rev-parse", "HEAD"); + // Only a new local fixture bare repository is written; no public remote is ever pushed. + git("push", "--force", "origin", "HEAD:lifecycle-update-fixture"); + git("reset", "--hard", baseline); + const result = spawnSync(process.execPath, ["scripts/update-runner.mjs"], { + cwd: repo, env: process.env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], + timeout: 12 * 60_000, maxBuffer: 4_000_000, + }); + const state = JSON.parse(readFileSync(`${root}/update-state.json`)); + // Only the fixed fixture/status fields are evidence; full authenticated health and runtime + // snapshots remain private inside the disposable VM. + console.log(JSON.stringify({ failure, updaterExit: result.status, result: state.result, + phase: state.phase, oldRevision: state.oldRevision, targetRevision: state.targetRevision, + runningRevision: state.runningRevision, candidateError: state.candidateError, rollbackError: state.rollbackError })); + assert.equal(result.error, undefined); + assert.equal(result.status, 2); + assert.equal(state.result, "rolled_back"); + assert.equal(state.oldRevision, baseline); + assert.equal(state.targetRevision, candidate); + assert.equal(state.runningRevision, baseline); + assert.equal(git("rev-parse", "HEAD"), baseline); + assert.match(state.candidateError, failure === "test" ? /npm test.*42/ : /replacement readiness timed out/); + assert.equal(existsSync(`${root}/update-backups/${state.id}/gateway.db`), true); + assert.equal(existsSync(`${root}/update-backups/${state.id}/config/lifecycle-proof.json`), true); + assert.equal(existsSync(`${root}/update.lock`), false); + const db = new DatabaseSync(`${root}/gateway.db`, { readOnly: true }); + assert.equal(db.prepare("SELECT value FROM lifecycle_proof").get().value, "before-backup"); + db.close(); + before = await health(baseline, before.instanceId); + console.log(`PASS real ${failure} failure rolls back Git and restarts systemd; engine smoke is a controlled fixture`); +} +git("checkout", "--detach", original); +restart(); +await health(original, before.instanceId); +console.log("PASS original candidate restored after isolated updater fixture checks"); From 94927ff5d9ac98df4e718b24a003907b514a63c4 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Tue, 8 Sep 2026 00:43:35 +0300 Subject: [PATCH 07/14] test: verify actual OS reboot inside a disposable KVM guest Signed-off-by: Tiberiu Socaci --- .github/workflows/linux-reboot.yml | 34 ++++ FEATURES.md | 6 +- TEST-PLAN.md | 14 +- scripts/check-linux-reboot.sh | 266 +++++++++++++++++++++++++++++ 4 files changed, 316 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/linux-reboot.yml create mode 100644 scripts/check-linux-reboot.sh diff --git a/.github/workflows/linux-reboot.yml b/.github/workflows/linux-reboot.yml new file mode 100644 index 0000000..5322a27 --- /dev/null +++ b/.github/workflows/linux-reboot.yml @@ -0,0 +1,34 @@ +name: Linux guest reboot + +on: + workflow_dispatch: + pull_request: + paths: + - .github/workflows/linux-reboot.yml + - scripts/check-linux-reboot.sh + - scripts/install-systemd.sh + - scripts/uninstall-systemd.sh + +permissions: + contents: read + +jobs: + reboot: + # The hosted runner survives while a separate, disposable Ubuntu guest actually reboots. + # KVM availability is experimental on hosted runners: missing support fails explicitly. + runs-on: ubuntu-24.04 + timeout-minutes: 90 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + persist-credentials: false + - name: Install, reboot and uninstall in a disposable KVM guest + run: bash scripts/check-linux-reboot.sh + - name: Upload sanitized reboot acceptance evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: linux-guest-reboot-${{ github.sha }} + path: ${{ runner.temp }}/reboot-evidence/reboot.log + if-no-files-found: error diff --git a/FEATURES.md b/FEATURES.md index 2c40006..b24e44d 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -7,8 +7,10 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. HTTP liveness after restart, encrypted fixture backup/restore and non-destructive uninstall. A local fixture upstream also proves the real CLI updater restores Git and restarts systemd after a candidate test failure or readiness failure; engine smoke is explicitly stubbed. - Its script refuses non-hosted or occupied hosts. Actual reboot and authenticated engine update - rollback remain separate live gates. → TEST-PLAN: Disposable Linux lifecycle workflow. + Its script refuses non-hosted or occupied hosts. A separate KVM guest workflow exercises an + actual OS reboot, service autostart and persistent database/container-volume fixtures. + Authenticated engine update smoke and conversation/session acceptance remain separate live + gates. → TEST-PLAN: Disposable Linux lifecycle workflow. - **Service-account image provisioning** uses the same explicit environment as the systemd daemon so operator XDG/container storage settings cannot redirect a fresh build into another user's private Podman store, and runs from the service-owned checkout so an operator-private diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 8292be5..42e82c6 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -32,12 +32,22 @@ pass. Many checks are manual (require a real Slack workspace + an authenticated instance, released update lock, private operator recovery snapshot and unchanged SQL marker. Finally restore the original tested source revision and verify its fresh healthy instance. Public evidence must name the smoke stub; no Claude/Codex authentication is proven here. -- [ ] Actual reboot gate (both configured engines): on a separate disposable Linux VM, install +- Guest OS reboot: dispatch `.github/workflows/linux-reboot.yml` for the candidate ref. The + hosted runner must expose KVM; unavailable acceleration fails explicitly. The wrapper verifies + the official Ubuntu Noble image checksum, boots a separate cloud-init guest, installs Node 24 + and the real service/full image, writes `persisted-through-os-reboot` into SQLite and a rootless + named volume, and reboots the GUEST while the runner stays alive. Pass only with different OS + boot ID and daemon instance ID, enabled active service without manual post-boot start, usable + rootless runtime, intact SQL/volume markers, SQLite integrity `ok` and uninstall preserving data. + Artifact `linux-guest-reboot-` contains sanitized logs only. No keys, disk images, runtime + config or databases are uploaded. This operations case is engine-independent; it does not prove + a resumed Claude/Codex conversation. +- [ ] Post-reboot conversation gate (both configured engines): on a separate disposable Linux VM, install the candidate and create a channel that writes `LIFECYCLE-BEFORE-REBOOT` in its own work folder. Record instance ID and engine/session identity, reboot the machine, then ask each engine in its existing thread to read the marker. Require automatic service start without manual repair, a new daemon instance ID, preserved marker/session and container-only engine execution. - A hosted job restart is not a reboot and cannot clear this gate. + Engine-free guest reboot evidence cannot clear this conversation/session gate. - [ ] Authenticated update rollback gate (Claude and Codex): on that disposable deployment, configure both engine credentials in its own service identity and a local fixture upstream. Baseline revision A must answer the fixed update smoke response for both engines. Create diff --git a/scripts/check-linux-reboot.sh b/scripts/check-linux-reboot.sh new file mode 100644 index 0000000..45f47ce --- /dev/null +++ b/scripts/check-linux-reboot.sh @@ -0,0 +1,266 @@ +#!/usr/bin/env bash +# Real OS reboot acceptance in a disposable nested Ubuntu guest; NEVER reboot the runner. +# Guest setup follows the official Ubuntu/cloud-init QEMU guidance: +# https://documentation.ubuntu.com/public-images/public-images-how-to/launch-qcow-with-qemu/ +# https://docs.cloud-init.io/en/24.3/tutorial/qemu.html +# Requires KVM. Nested virtualization on GitHub-hosted runners is experimental; unavailable +# acceleration is an explicit failure, never a skipped/passing acceptance or a slow TCG fallback. +set -euo pipefail +if [ "${GITHUB_ACTIONS:-}" != true ] || [ "${RUNNER_ENVIRONMENT:-}" != github-hosted ] || + [ "$(cat /proc/1/comm)" != systemd ]; then + echo 'Refusing: requires a disposable GitHub-hosted VM with PID-1 systemd.' >&2 + exit 2 +fi +: "${RUNNER_TEMP:?}" "${GITHUB_REPOSITORY:?}" +[[ "$GITHUB_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || exit 2 +SOURCE_DIR="$(cd "$(dirname "$0")/.." && pwd)" +REVISION="$(git -C "$SOURCE_DIR" rev-parse HEAD)" +[[ "$REVISION" =~ ^[a-f0-9]{40}$ ]] || exit 2 +EVIDENCE="$RUNNER_TEMP/reboot-evidence" +FIXTURE="$RUNNER_TEMP/channelgate-reboot-guest" +[ ! -e "$FIXTURE" ] || { echo 'Refusing occupied reboot fixture.' >&2; exit 2; } +umask 077 +mkdir "$FIXTURE" +mkdir -p "$EVIDENCE" +exec > >(tee "$EVIDENCE/reboot.log") 2>&1 +QEMU_PID='' +cleanup() { + outcome=$? + trap - EXIT + if [ -n "$QEMU_PID" ]; then + kill "$QEMU_PID" 2>/dev/null || true + wait "$QEMU_PID" 2>/dev/null || true + fi + # No disks, cloud-init data, private keys, runtime files or raw guest logs are retained. + rm -rf -- "$FIXTURE" + printf 'exit_code=%s\n' "$outcome" + exit "$outcome" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +printf 'revision=%s\nrunner=%s %s\n' "$REVISION" "${ImageOS:-unknown}" "${ImageVersion:-unknown}" +if [ ! -c /dev/kvm ]; then + echo 'UNAVAILABLE: /dev/kvm is absent; actual guest reboot acceptance was NOT RUN.' + exit 1 +fi +sudo apt-get update -qq > "$FIXTURE/host-apt.log" 2>&1 +sudo apt-get install -y -qq qemu-system-x86 qemu-utils cloud-image-utils acl >> "$FIXTURE/host-apt.log" 2>&1 +sudo setfacl -m "u:$(id -u):rw" /dev/kvm +[ -r /dev/kvm ] && [ -w /dev/kvm ] || { + echo 'UNAVAILABLE: KVM is inaccessible; actual guest reboot acceptance was NOT RUN.'; exit 1; +} +# Sparse backing disk still needs real space for the full production toolchain build. +[ "$(df -Pk "$FIXTURE" | awk 'NR==2 {print $4}')" -ge 25165824 ] || { + echo 'UNAVAILABLE: fewer than 24 GiB free for the production-image guest fixture.'; exit 1; +} +echo 'CHECK downloading official Ubuntu Noble cloud image and checksum manifest' +IMAGE=noble-server-cloudimg-amd64.img +IMAGE_BASE=https://cloud-images.ubuntu.com/noble/current +curl --fail --silent --show-error --location --retry 3 "$IMAGE_BASE/SHA256SUMS" -o "$FIXTURE/SHA256SUMS" +curl --fail --silent --show-error --location --retry 3 "$IMAGE_BASE/$IMAGE" -o "$FIXTURE/$IMAGE" +( + cd "$FIXTURE" + awk -v image="$IMAGE" '$2 == image || $2 == "*" image { print }' SHA256SUMS > image.sha256 + [ "$(wc -l < image.sha256)" -eq 1 ] + sha256sum --check image.sha256 +) +printf 'image_sha256=%s\n' "$(awk '{print $1}' "$FIXTURE/image.sha256")" +qemu-img create -q -f qcow2 -F qcow2 -b "$FIXTURE/$IMAGE" "$FIXTURE/guest.qcow2" 48G +ssh-keygen -q -t ed25519 -N '' -C disposable-reboot-fixture -f "$FIXTURE/ssh-key" +cat > "$FIXTURE/user-data" < "$FIXTURE/meta-data" +cloud-localds "$FIXTURE/seed.img" "$FIXTURE/user-data" "$FIXTURE/meta-data" +# Run as the runner user. The owned child lives only for this script and is reaped by its trap. +qemu-system-x86_64 -enable-kvm -cpu host -smp 2 -m 4096 -display none -monitor none \ + -serial "file:$FIXTURE/serial.log" \ + -drive "file=$FIXTURE/guest.qcow2,format=qcow2,if=virtio" \ + -drive "file=$FIXTURE/seed.img,format=raw,if=virtio" \ + -netdev user,id=net0,hostfwd=tcp:127.0.0.1:2222-:22 -device virtio-net-pci,netdev=net0 \ + > "$FIXTURE/qemu.log" 2>&1 & +QEMU_PID=$! +SSH=(ssh -i "$FIXTURE/ssh-key" -p 2222 -o BatchMode=yes -o ConnectTimeout=5 + -o StrictHostKeyChecking=accept-new -o "UserKnownHostsFile=$FIXTURE/known_hosts" + -o ServerAliveInterval=15 -o ServerAliveCountMax=4 reboot-check@127.0.0.1) +wait_ssh() { + for attempt in $(seq 1 120); do + kill -0 "$QEMU_PID" 2>/dev/null || { + echo 'UNAVAILABLE: KVM guest exited before SSH readiness; reboot acceptance incomplete.' + tail -n 20 "$FIXTURE/qemu.log" + return 1; + } + if "${SSH[@]}" true >/dev/null 2>&1; then return 0; fi + sleep 3 + done + echo 'FAIL guest SSH readiness timed out'; return 1 +} +wait_ssh +"${SSH[@]}" sudo cloud-init status --wait > "$FIXTURE/cloud-init.log" 2>&1 +echo 'PASS disposable KVM Ubuntu guest booted' +# Arguments are restricted to an owner/repo slug and a full SHA before SSH constructs a command. +"${SSH[@]}" sudo bash -s -- "$GITHUB_REPOSITORY" "$REVISION" <<'GUEST' +set -euo pipefail +[ "$(cat /proc/1/comm)" = systemd ] +[ "$(hostname)" = channelgate-reboot-fixture ] +APP_DIR=/opt/channelgate-reboot +SERVICE_USER=cg-reboot +SERVICE_HOME=/var/lib/channelgate-reboot +for occupied in "$APP_DIR" "$SERVICE_HOME" /etc/systemd/system/channelgate.service /etc/systemd/system/claude-gateway.service; do + [ ! -e "$occupied" ] || { echo 'FAIL occupied guest fixture'; exit 2; } +done +if id "$SERVICE_USER" >/dev/null 2>&1 || id claude-gateway >/dev/null 2>&1; then exit 2; fi +phase=prerequisites +setup_failed() { + outcome=$? + printf 'FAIL guest setup phase=%s line=%s\n' "$phase" "$1" + # Only synthetic public-source installation output, bounded and scrubbed. Never print the + # daemon journal, environment/config, cloud-init seed, SSH key or runtime database. + for log in /var/tmp/reboot-prerequisites.log /var/tmp/reboot-install.log; do + if [ -f "$log" ]; then + tail -n 60 "$log" | sed -E 's/public-disposable-[A-Za-z0-9-]+/[fixture-password]/g; s/(sk-|xox[baprs]-)[A-Za-z0-9_-]+/[redacted]/g' + fi + done + exit "$outcome" +} +trap 'setup_failed "$LINENO"' ERR +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq > /var/tmp/reboot-prerequisites.log 2>&1 +apt-get install -y -qq ca-certificates curl git xz-utils podman uidmap slirp4netns fuse-overlayfs >> /var/tmp/reboot-prerequisites.log 2>&1 +phase=node +mkdir /var/tmp/reboot-node +cd /var/tmp/reboot-node +curl -fsSL --retry 3 https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt -o SHASUMS256.txt +awk '$2 ~ /^node-v24\.[0-9]+\.[0-9]+-linux-x64\.tar\.xz$/ {print}' SHASUMS256.txt > node.sha256 +[ "$(wc -l < node.sha256)" -eq 1 ] +NODE_ARCHIVE="$(awk '{print $2}' node.sha256)" +curl -fsSL --retry 3 "https://nodejs.org/dist/latest-v24.x/$NODE_ARCHIVE" -o "$NODE_ARCHIVE" +sha256sum --check node.sha256 +tar -xJf "$NODE_ARCHIVE" -C /usr/local --strip-components=1 +printf 'node_version=%s\nnode_sha256=%s\n' "$(node --version)" "$(awk '{print $1}' node.sha256)" +phase=public-source +git clone --quiet --no-checkout "https://github.com/$1.git" "$APP_DIR" +git -C "$APP_DIR" fetch --quiet origin "$2" +git -C "$APP_DIR" checkout --quiet --detach "$2" +[ "$(git -C "$APP_DIR" rev-parse HEAD)" = "$2" ] +# Public synthetic password avoids first-boot random secrets. No Slack/provider credentials. +cat > "$APP_DIR/.env" <<'ENV' +ADMIN_PASSWORD=public-disposable-reboot-fixture +CG_BIND_HOST=127.0.0.1 +PORT=4747 +ENV +phase=install-dependencies +echo 'CHECK installing daemon dependencies in guest' +CHANNELGATE_DIR=/var/tmp/reboot-bootstrap bash "$APP_DIR/scripts/install.sh" --without-whisper > /var/tmp/reboot-install.log 2>&1 +phase=install-system-service +echo 'CHECK production systemd installer is building the complete rootless image in guest' +CG_SERVICE_USER="$SERVICE_USER" CG_SERVICE_HOME="$SERVICE_HOME" bash "$APP_DIR/scripts/install-systemd.sh" >> /var/tmp/reboot-install.log 2>&1 +cat > /var/tmp/reboot-health.mjs <<'NODE' +import assert from 'node:assert/strict'; +for (let attempt = 0; attempt < 120; attempt++) { + try { + const response = await fetch('http://127.0.0.1:4747/api/health', { signal: AbortSignal.timeout(2000) }); + assert.equal(response.status, 200); + const data = await response.json(); + assert.equal(data.ok, true); + assert.equal(data.slack.connected, false); + assert.ok(data.instanceId && data.instanceId !== process.argv[2]); + console.log(data.instanceId); + process.exit(0); + } catch { await new Promise(resolve => setTimeout(resolve, 1000)); } +} +throw Error('Fresh healthy daemon instance did not become available'); +NODE +cat > /var/tmp/reboot-as-service <<'SERVICE' +#!/usr/bin/env bash +set -euo pipefail +uid="$(id -u cg-reboot)" +cd /var/lib/channelgate-reboot +exec runuser -u cg-reboot -- env -i HOME=/var/lib/channelgate-reboot CHANNELGATE_DIR=/var/lib/channelgate-reboot \ + XDG_RUNTIME_DIR="/run/user/$uid" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$uid/bus" \ + PATH=/usr/local/bin:/usr/bin:/bin "$@" +SERVICE +chmod 700 /var/tmp/reboot-as-service +phase=pre-reboot-state +node /var/tmp/reboot-health.mjs > /var/tmp/reboot-first-instance +systemctl is-enabled --quiet channelgate.service +systemctl is-active --quiet channelgate.service +[ "$(systemctl show channelgate.service -p User --value)" = "$SERVICE_USER" ] +/var/tmp/reboot-as-service podman info --format '{{.Host.Security.Rootless}}' | grep -qx true +/var/tmp/reboot-as-service node --input-type=module <<'NODE' +import { DatabaseSync } from 'node:sqlite'; +const db = new DatabaseSync('/var/lib/channelgate-reboot/gateway.db'); +db.exec("CREATE TABLE reboot_proof(value TEXT); INSERT INTO reboot_proof VALUES ('persisted-through-os-reboot')"); +db.close(); +NODE +/var/tmp/reboot-as-service podman volume create reboot-proof >/dev/null +/var/tmp/reboot-as-service podman run --rm --userns=keep-id --cap-drop=all --security-opt=no-new-privileges \ + --network=none -v reboot-proof:/proof:U channelgate/runtime:latest node -e \ + 'require("node:assert/strict").notEqual(process.getuid(), 0); require("node:fs").writeFileSync("/proof/marker", "persisted-through-os-reboot")' +cat /proc/sys/kernel/random/boot_id > /var/tmp/reboot-first-boot-id +sync +echo 'PASS real service installation, HTTP health, database and rootless volume fixture before reboot' +GUEST +FIRST_BOOT="$("${SSH[@]}" cat /proc/sys/kernel/random/boot_id)" +printf 'guest_boot_before=%s\n' "$FIRST_BOOT" +echo 'CHECK rebooting guest OS; GitHub runner stays alive' +# systemctl schedules a real guest OS reboot. It may close SSH before reporting success. +"${SSH[@]}" sudo systemctl reboot || true +REBOOTED=false +for attempt in $(seq 1 120); do + kill -0 "$QEMU_PID" 2>/dev/null || { echo 'FAIL guest process exited during reboot'; exit 1; } + CURRENT_BOOT="$("${SSH[@]}" cat /proc/sys/kernel/random/boot_id 2>/dev/null || true)" + if [[ "$CURRENT_BOOT" =~ ^[a-f0-9-]{36}$ ]] && [ "$CURRENT_BOOT" != "$FIRST_BOOT" ]; then + REBOOTED=true; break + fi + sleep 3 +done +[ "$REBOOTED" = true ] || { echo 'FAIL guest OS boot ID did not change'; exit 1; } +printf 'guest_boot_after=%s\n' "$CURRENT_BOOT" +"${SSH[@]}" sudo bash -s <<'VERIFY' +set -euo pipefail +trap 'printf "FAIL post-reboot verification line=%s\n" "$LINENO"' ERR +[ "$(cat /proc/sys/kernel/random/boot_id)" != "$(cat /var/tmp/reboot-first-boot-id)" ] +# Do not start/restart the service here: HTTP must return after automatic boot startup. +NEW_INSTANCE="$(node /var/tmp/reboot-health.mjs "$(cat /var/tmp/reboot-first-instance)")" +[ "$NEW_INSTANCE" != "$(cat /var/tmp/reboot-first-instance)" ] +systemctl is-enabled --quiet channelgate.service +systemctl is-active --quiet channelgate.service +[ "$(systemctl show channelgate.service -p User --value)" = cg-reboot ] +[ "$(loginctl show-user cg-reboot -p Linger --value)" = yes ] +/var/tmp/reboot-as-service podman info --format '{{.Host.Security.Rootless}}' | grep -qx true +printf 'daemon_instance_before=%s\ndaemon_instance_after=%s\n' "$(cat /var/tmp/reboot-first-instance)" "$NEW_INSTANCE" +echo 'PASS actual OS reboot, enabled active service autostart, new HTTP instance and rootless runtime' +/var/tmp/reboot-as-service podman run --rm --userns=keep-id --cap-drop=all --security-opt=no-new-privileges \ + --network=none -v reboot-proof:/proof channelgate/runtime:latest node -e \ + 'const a=require("node:assert/strict"); a.notEqual(process.getuid(),0); a.equal(require("node:fs").readFileSync("/proof/marker","utf8"),"persisted-through-os-reboot")' +cat > /var/tmp/reboot-db-check.mjs <<'NODE' +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +const db = new DatabaseSync('/var/lib/channelgate-reboot/gateway.db', { readOnly: true }); +assert.equal(db.prepare('SELECT value FROM reboot_proof').get().value, 'persisted-through-os-reboot'); +assert.equal(db.prepare('PRAGMA integrity_check').get().integrity_check, 'ok'); +db.close(); +NODE +chmod 644 /var/tmp/reboot-db-check.mjs +/var/tmp/reboot-as-service node /var/tmp/reboot-db-check.mjs +echo 'PASS database integrity and rootless named-volume marker survived guest OS reboot' +bash /opt/channelgate-reboot/scripts/uninstall-systemd.sh --system +[ ! -e /etc/systemd/system/channelgate.service ] +if systemctl is-active --quiet channelgate.service; then echo 'FAIL service still active after uninstall'; exit 1; fi +id cg-reboot >/dev/null +/var/tmp/reboot-as-service node /var/tmp/reboot-db-check.mjs +echo 'PASS actual uninstall stops/removes service and preserves service identity and database marker' +VERIFY +echo 'PASS completed real guest OS reboot acceptance; no authenticated engines or external chat services used' From ac0a60213245a6b0eca64710e0604ee1075c5523 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Tue, 8 Sep 2026 00:48:39 +0300 Subject: [PATCH 08/14] release: prepare follow-up candidate and verify image export Signed-off-by: Tiberiu Socaci --- .github/workflows/release.yml | 6 ++++++ CHANGELOG.md | 11 +++++++++++ docs/COMPATIBILITY.md | 2 +- docs/RELEASE-ACCEPTANCE.md | 2 +- docs/RELEASE-CHECKLIST.md | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- 7 files changed, 24 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08bfe87..6fcef0d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,10 @@ permissions: jobs: evidence: runs-on: ubuntu-latest + defaults: + run: + # Fail if docker save fails, even when the gzip process itself exits successfully. + shell: bash permissions: contents: read id-token: write @@ -19,6 +23,7 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 + persist-credentials: false - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22.13" @@ -38,6 +43,7 @@ jobs: docker image inspect channelgate/release-candidate > dist/release/runtime-image.json docker run --rm -i --network none --read-only --entrypoint node channelgate/release-candidate - < scripts/runtime-model-inventory.mjs > dist/release/runtime-models.json docker save channelgate/release-candidate | gzip > dist/release/runtime-image.tar.gz + gzip --test dist/release/runtime-image.tar.gz - name: Inventory installed OS, Python and npm packages in the image uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0 (pinned) with: diff --git a/CHANGELOG.md b/CHANGELOG.md index aa19217..00649ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,17 @@ product overview. No changes recorded after the current candidate. +## 0.6.0-rc.2 — 2026-09-08 (release candidate) + +Follow-up candidate for the installer findings from disposable Linux acceptance. Full live QA +remains deferred; stable promotion is not approved. + +- Provision rootless images with a clean service-account environment and from the installed + application directory, so operator storage settings and an inaccessible invocation folder + cannot break a dedicated-account installation. +- Export the release image with pipeline failure propagation and verify gzip integrity before + generating its checksums and attestations. + ## 0.6.0-rc.1 — 2026-09-08 (release candidate) Candidate source and build evidence for review. The GitHub Release remains a draft; the planned diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 47ab36b..642266b 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -1,6 +1,6 @@ # Compatibility matrix -Candidate: **0.6.0-rc.1**, prepared 2026-09-08. Stable promotion awaits full live QA. +Candidate: **0.6.0-rc.2**, prepared 2026-09-08. Stable promotion awaits full live QA. The component matrix below describes this candidate. ## What the GitHub checks prove diff --git a/docs/RELEASE-ACCEPTANCE.md b/docs/RELEASE-ACCEPTANCE.md index 0859797..794ba1f 100644 --- a/docs/RELEASE-ACCEPTANCE.md +++ b/docs/RELEASE-ACCEPTANCE.md @@ -1,7 +1,7 @@ # Release acceptance packet Status: **prepared; full live QA deferred to the planned campaign** (owner instruction, -2026-09-08). Candidate: **0.6.0-rc.1**. These are reproducible definitions, not claimed +2026-09-08). Candidate: **0.6.0-rc.2**. These are reproducible definitions, not claimed passes. Use disposable private fixtures only. Record the actual channel IDs, host/image revision, engine/model versions, timestamps and evidence links when executing. No live chat/provider fixture was created or used during the source remediation. A separate disposable container lifecycle test diff --git a/docs/RELEASE-CHECKLIST.md b/docs/RELEASE-CHECKLIST.md index 094a46f..019b38c 100644 --- a/docs/RELEASE-CHECKLIST.md +++ b/docs/RELEASE-CHECKLIST.md @@ -2,7 +2,7 @@ > 0.5.0 was published on 2026-09-06 by decision of the Licensor. Items still unticked below stay > tracked for the next release. -> Current candidate: **0.6.0-rc.1** (2026-09-08), draft pending the planned full live QA campaign. +> Current candidate: **0.6.0-rc.2** (2026-09-08), draft pending the planned full live QA campaign. > The deferred QA gate is not waived. - [x] Authorized owner selected and documented the Makeitfuture Sustainable Use License; the @@ -16,7 +16,7 @@ - [x] Every commit in the candidate carries a `Signed-off-by` trailer per `CLA.md`, and any contribution predating the CLA has a recorded acceptance (all authorship is the Licensor's). - [x] The published version's public-availability date is recorded in `CHANGELOG.md` (0.5.0 — 2026-09-06). -- [ ] Candidate version/tag/changelog and `docs/COMPATIBILITY.md` match `v0.6.0-rc.1`; +- [ ] Candidate version/tag/changelog and `docs/COMPATIBILITY.md` match `v0.6.0-rc.2`; record the exact tag and verified evidence before checking this item. - [x] CI, security coverage, dependency/secret scans, and real CLI nightly canaries are green (2026-09-06). - [ ] Release workflow emitted the image SBOM, model hashes, exact image archive, checksums and diff --git a/package-lock.json b/package-lock.json index ce4f280..1052ae7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "channelgate", - "version": "0.6.0-rc.1", + "version": "0.6.0-rc.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "channelgate", - "version": "0.6.0-rc.1", + "version": "0.6.0-rc.2", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@composio/core": "0.14.0", diff --git a/package.json b/package.json index ad20c4d..6c01288 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "channelgate", - "version": "0.6.0-rc.1", + "version": "0.6.0-rc.2", "private": true, "license": "SEE LICENSE IN LICENSE.md", "author": "Tiberiu Socaci (MAKEITFUTURE S.R.L.)", From cf175dc474b01baec1753af10caa2b8dff11d00e Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Tue, 8 Sep 2026 00:49:12 +0300 Subject: [PATCH 09/14] test: strengthen rollback evidence and use IPv4 guest networking Signed-off-by: Tiberiu Socaci --- TEST-PLAN.md | 4 +++- scripts/check-linux-lifecycle.sh | 2 +- scripts/check-linux-reboot.sh | 6 +++++- scripts/check-update-rollback-hosted.mjs | 15 +++++++++++---- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 42e82c6..69625f6 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -25,11 +25,13 @@ pass. Many checks are manual (require a real Slack workspace + an authenticated unit evidence is uploaded separately as `update-fixture-tests.tap`; injected failures do not count as real authenticated engine update/rollback acceptance. - Hosted updater operations: `scripts/check-update-rollback-hosted.mjs` is restricted to that - exact disposable checkout/runtime, replaces only the LOCAL fixture's engine smoke response, + exact disposable checkout/runtime, controls the LOCAL fixture's engine smoke and test commands, and creates a local bare Git upstream. It invokes the unmodified CLI updater first against a candidate whose test exits 42, then one whose live health reports an incorrect revision. Require two durable `rolled_back` results, checkout/running revision A, new healthy service instance, released update lock, private operator recovery snapshot and unchanged SQL marker. + The readiness failure must name the deliberately wrong live revision, not just any timeout; + both recovery-snapshot SQL/config markers must match and snapshot integrity must be `ok`. Finally restore the original tested source revision and verify its fresh healthy instance. Public evidence must name the smoke stub; no Claude/Codex authentication is proven here. - Guest OS reboot: dispatch `.github/workflows/linux-reboot.yml` for the candidate ref. The diff --git a/scripts/check-linux-lifecycle.sh b/scripts/check-linux-lifecycle.sh index 11c1d44..3d4f84e 100644 --- a/scripts/check-linux-lifecycle.sh +++ b/scripts/check-linux-lifecycle.sh @@ -149,7 +149,7 @@ pass 'live encrypted fixture snapshot, disposable drill, replacement restore and # across authenticated engine versions or an induced failure in a production deployment. as_service env -u CHANNELGATE_DIR -u CHANNELGATE_DB -u CLAUDE_GATEWAY_DIR -u CLAUDE_GATEWAY_DB \ -u CG_WORKSPACE_DIR -u CG_TEST_SCRATCH \ - bash -c 'cd "$1" && node --test test/update-runner.test.js test/update-state.test.js test/update-smoke.test.js' bash "$APP_DIR" \ + bash -c 'cd "$1" && node --test --test-reporter=tap test/update-runner.test.js test/update-state.test.js test/update-smoke.test.js' bash "$APP_DIR" \ > "$EVIDENCE/update-fixture-tests.tap" 2>&1 pass 'injected update failure/rollback and container smoke regression tests' as_service env CG_DISPOSABLE_LIFECYCLE=1 node "$APP_DIR/scripts/check-update-rollback-hosted.mjs" diff --git a/scripts/check-linux-reboot.sh b/scripts/check-linux-reboot.sh index 45f47ce..31e2989 100644 --- a/scripts/check-linux-reboot.sh +++ b/scripts/check-linux-reboot.sh @@ -88,7 +88,7 @@ qemu-system-x86_64 -enable-kvm -cpu host -smp 2 -m 4096 -display none -monitor n -serial "file:$FIXTURE/serial.log" \ -drive "file=$FIXTURE/guest.qcow2,format=qcow2,if=virtio" \ -drive "file=$FIXTURE/seed.img,format=raw,if=virtio" \ - -netdev user,id=net0,hostfwd=tcp:127.0.0.1:2222-:22 -device virtio-net-pci,netdev=net0 \ + -netdev user,id=net0,ipv6=off,hostfwd=tcp:127.0.0.1:2222-:22 -device virtio-net-pci,netdev=net0 \ > "$FIXTURE/qemu.log" 2>&1 & QEMU_PID=$! SSH=(ssh -i "$FIXTURE/ssh-key" -p 2222 -o BatchMode=yes -o ConnectTimeout=5 @@ -150,6 +150,10 @@ sha256sum --check node.sha256 tar -xJf "$NODE_ARCHIVE" -C /usr/local --strip-components=1 printf 'node_version=%s\nnode_sha256=%s\n' "$(node --version)" "$(awk '{print $1}' node.sha256)" phase=public-source +echo 'CHECK public browser CDN connectivity from the IPv4 guest network' +curl -4 --head --location --silent --show-error --connect-timeout 15 --max-time 45 \ + --output /dev/null --write-out 'browser_cdn_http=%{http_code} connect_seconds=%{time_connect}\n' \ + https://cdn.playwright.dev/ || true git clone --quiet --no-checkout "https://github.com/$1.git" "$APP_DIR" git -C "$APP_DIR" fetch --quiet origin "$2" git -C "$APP_DIR" checkout --quiet --detach "$2" diff --git a/scripts/check-update-rollback-hosted.mjs b/scripts/check-update-rollback-hosted.mjs index 233b37e..4cbdd23 100644 --- a/scripts/check-update-rollback-hosted.mjs +++ b/scripts/check-update-rollback-hosted.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -// Real CLI updater + Git + systemd rollback in the lifecycle VM. Only the engine smoke response -// is stubbed in a LOCAL fixture commit; this is not authenticated Claude/Codex acceptance. +// Real CLI updater + Git + systemd rollback in the lifecycle VM. Engine smoke and npm test +// commands are controlled in LOCAL fixture commits; this is not authenticated engine acceptance. import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; @@ -103,9 +103,16 @@ for (const failure of ["test", "readiness"]) { assert.equal(state.targetRevision, candidate); assert.equal(state.runningRevision, baseline); assert.equal(git("rev-parse", "HEAD"), baseline); - assert.match(state.candidateError, failure === "test" ? /npm test.*42/ : /replacement readiness timed out/); + if (failure === "test") assert.match(state.candidateError, /npm test.*42/); + else assert.ok(state.candidateError.includes( + `gateway is running revision lifecycle-intentionally-unready instead of ${candidate}`), + "readiness failure must prove the candidate actually answered with the intended wrong revision"); assert.equal(existsSync(`${root}/update-backups/${state.id}/gateway.db`), true); - assert.equal(existsSync(`${root}/update-backups/${state.id}/config/lifecycle-proof.json`), true); + assert.equal(JSON.parse(readFileSync(`${root}/update-backups/${state.id}/config/lifecycle-proof.json`)).value, "before-backup"); + const snapshot = new DatabaseSync(`${root}/update-backups/${state.id}/gateway.db`, { readOnly: true }); + assert.equal(snapshot.prepare("SELECT value FROM lifecycle_proof").get().value, "before-backup"); + assert.equal(snapshot.prepare("PRAGMA integrity_check").get().integrity_check, "ok"); + snapshot.close(); assert.equal(existsSync(`${root}/update.lock`), false); const db = new DatabaseSync(`${root}/gateway.db`, { readOnly: true }); assert.equal(db.prepare("SELECT value FROM lifecycle_proof").get().value, "before-backup"); From d44c67a82e195d09d4772f61aace39c7a4827369 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Tue, 8 Sep 2026 00:49:45 +0300 Subject: [PATCH 10/14] test: support untouched fresh-install settings in rollback fixture Signed-off-by: Tiberiu Socaci --- scripts/check-update-rollback-hosted.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check-update-rollback-hosted.mjs b/scripts/check-update-rollback-hosted.mjs index 4cbdd23..ab75a54 100644 --- a/scripts/check-update-rollback-hosted.mjs +++ b/scripts/check-update-rollback-hosted.mjs @@ -26,7 +26,7 @@ git("config", "user.email", "fixture@example.invalid"); const appFile = `${repo}/src/web/app.js`; const packageFile = `${repo}/package.json`; const settingsFile = `${root}/config/settings.json`; -const settings = JSON.parse(readFileSync(settingsFile)); +const settings = existsSync(settingsFile) ? JSON.parse(readFileSync(settingsFile)) : {}; writeFileSync(settingsFile, `${JSON.stringify({ ...settings, whisperEnabled: false, driveSyncEnabled: false }, null, 2)}\n`); const appSource = readFileSync(appFile, "utf8"); assert.equal(appSource.split("updateSmoke = runUpdateSmoke,").length, 2); From 4e8c057e2e4e0a92fa9d9c3b9242eeecff223b3b Mon Sep 17 00:00:00 2001 From: makeitfutureDev Date: Tue, 8 Sep 2026 00:58:14 +0300 Subject: [PATCH 11/14] fix: recognize exact reviewed public runtime artifact fixtures Signed-off-by: makeitfutureDev --- FEATURES.md | 5 + TEST-PLAN.md | 17 +++ docs/MAINTAINER-RELEASE.md | 8 ++ scripts/reviewed-artifact-fixtures.json | 147 ++++++++++++++++++++++++ scripts/secret-scan.mjs | 62 +++++++--- test/release-artifact-fixtures.test.js | 87 ++++++++++++++ test/release-secret-history.test.js | 1 + 7 files changed, 312 insertions(+), 15 deletions(-) create mode 100644 scripts/reviewed-artifact-fixtures.json create mode 100644 test/release-artifact-fixtures.test.js diff --git a/FEATURES.md b/FEATURES.md index f2248e1..b40b4ca 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1,5 +1,10 @@ # ChannelGate — Features +- Release artifact scanning recognizes only exact SHA-256 fingerprints of reviewed public + toolchain fixtures. PEM exceptions bind the complete key, never a header or first body line; + repository/history scans and unknown or altered artifact credentials remain strict. Public + provenance is recorded with every exception. + A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. - Development acceptance policy: behavior changes include reproducible Claude and Codex diff --git a/TEST-PLAN.md b/TEST-PLAN.md index c5226ee..ff2c5f3 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -4285,3 +4285,20 @@ acceptance gates; no production restart or external message was performed by the both files successfully and returned the exact marker CG_PERSONAL_REFERENCE_OK_7319. The fixture was removed. This verifies catalog/reference readability in an existing container; the deployed author-grant, resume/revocation and Slack cases above remain unexecuted. + + +## Reviewed public fixtures in release artifacts (2026-09-08) + +- Engine-independent automated acceptance: run `node --test test/release-artifact-fixtures.test.js + test/release-secret-history.test.js`. Disposable repositories use synthetic token/PEM fixtures + with a private test copy of the exception catalog. Pass when exact fixtures are waived only + for generated artifacts; tracked files and history still reject them; an unknown token, + incomplete key or same-prefix/different-body key fails with values redacted; gzip stream + boundaries preserve complete-key matching and report each finding once. No engine participates. +- Candidate gate: run the exact-tag Release evidence workflow. Download the actual image archive, + inventory, model hashes and checksums; verify provenance and inspect the scan's reviewed-fixture + count. All unrecognized findings must fail the job. The checked-in catalog records public + source/binary URLs and fingerprints; adding an exception requires identifying its purpose and + verifying exact upstream bytes, not merely finding the value on the internet. Do not exempt + entire paths, packages, credential patterns or repository/history scans. +- Candidate execution remains pending; this regression does not waive the separate live QA campaign. diff --git a/docs/MAINTAINER-RELEASE.md b/docs/MAINTAINER-RELEASE.md index 187a87a..56c407c 100644 --- a/docs/MAINTAINER-RELEASE.md +++ b/docs/MAINTAINER-RELEASE.md @@ -19,6 +19,14 @@ It is not evidence of a built image or signed provenance. No local run should be successful release workflow. Review upstream runtime licenses and model notices for the actual candidate before distributing its image. +Generated-artifact secret scanning has a narrow reviewed-public-fixture catalog in +`scripts/reviewed-artifact-fixtures.json`. Each entry binds an exact matched value to public +upstream evidence and an identified purpose; private keys require the complete PEM fingerprint. +Repository/history scans never apply those exceptions. On a new finding, keep the gate failed +until provenance and purpose are verified; add no directory, package or pattern-wide exclusions. +Review the new image's count and digests after a toolchain update rather than carrying an +unexplained match forward. The catalog contains hashes and public evidence links, never values. + ## Repository presentation Repository maintainers can edit the About description, homepage and topics with GitHub settings diff --git a/scripts/reviewed-artifact-fixtures.json b/scripts/reviewed-artifact-fixtures.json new file mode 100644 index 0000000..ce0a013 --- /dev/null +++ b/scripts/reviewed-artifact-fixtures.json @@ -0,0 +1,147 @@ +[ + { + "pattern": "Private key block", + "sha256": "fb278edfcf8e17967dd3a1ac5bfb5467e24c366a94e7efcee521dc750effa9ef", + "description": "Public Supabase local Kong development TLS key", + "sources": [ + "https://github.com/supabase/cli/blob/v2.116.0/apps/cli-go/pkg/config/templates/certs/kong.local.key", + "https://github.com/supabase/cli/blob/v2.116.0/apps/cli/src/legacy/commands/start/templates/kong-local-tls.ts" + ] + }, + { + "pattern": "Private key block", + "sha256": "fed7079dd4491609e4c996e232f366ac434fa4cc9df19df363391d079d470964", + "description": "Public GnuTLS cryptographic self-test key", + "sources": [ + "https://github.com/gnutls/gnutls/blob/12919dc7db736467987a3254a452b1d10e20b79e/lib/crypto-selftests-pk.c" + ] + }, + { + "pattern": "Private key block", + "sha256": "5a09eb5df3674472eda0077d83cbccef72692c3d052ab393368ac57295439c58", + "description": "Public GnuTLS cryptographic self-test key", + "sources": [ + "https://github.com/gnutls/gnutls/blob/12919dc7db736467987a3254a452b1d10e20b79e/lib/crypto-selftests-pk.c" + ] + }, + { + "pattern": "Private key block", + "sha256": "a4d138d7ef9748464117b44fb9c0a4b5b85a1599a127d02690abaa96d03c16e6", + "description": "Public GnuTLS cryptographic self-test key", + "sources": [ + "https://github.com/gnutls/gnutls/blob/12919dc7db736467987a3254a452b1d10e20b79e/lib/crypto-selftests-pk.c" + ] + }, + { + "pattern": "Private key block", + "sha256": "fa0b06a72461ec0a963dcfccb8d5b61bd88a6074fc7271573bff68ab86b8c1af", + "description": "Public GnuTLS cryptographic self-test key", + "sources": [ + "https://github.com/gnutls/gnutls/blob/12919dc7db736467987a3254a452b1d10e20b79e/lib/crypto-selftests-pk.c" + ] + }, + { + "pattern": "Private key block", + "sha256": "ef237ea8db4f2ae9ee100e8ced96d29b5dceb0e6a948443e6b8a00b1791f9ec9", + "description": "Public GnuTLS cryptographic self-test key", + "sources": [ + "https://github.com/gnutls/gnutls/blob/12919dc7db736467987a3254a452b1d10e20b79e/lib/crypto-selftests-pk.c" + ] + }, + { + "pattern": "Private key block", + "sha256": "91ea1699ff6b1a34b4a1d500a9c75a808441e47b9ea68da6fb0195e01ce1dc61", + "description": "Public GnuTLS cryptographic self-test key", + "sources": [ + "https://github.com/gnutls/gnutls/blob/12919dc7db736467987a3254a452b1d10e20b79e/lib/crypto-selftests-pk.c" + ] + }, + { + "pattern": "Private key block", + "sha256": "7c4c63ee462e0e700cd9e29c8e0f730b3f1b484c4abdd83f1e69fcd477c061fa", + "description": "Public GnuTLS cryptographic self-test key", + "sources": [ + "https://github.com/gnutls/gnutls/blob/12919dc7db736467987a3254a452b1d10e20b79e/lib/crypto-selftests-pk.c" + ] + }, + { + "pattern": "Private key block", + "sha256": "d039c8119a029ab9f9c83c04d67002d887b6bc6026c4264402ab27cdf24cf138", + "description": "Public GnuTLS cryptographic self-test key", + "sources": [ + "https://github.com/gnutls/gnutls/blob/12919dc7db736467987a3254a452b1d10e20b79e/lib/crypto-selftests-pk.c" + ] + }, + { + "pattern": "GitHub token", + "sha256": "1f06658ad88821bce08bd420b28bd928ca24fa71f6906c00905fbba0b24877c3", + "description": "Compiler-adjacent string data following a four-byte token-prefix constant; not a complete credential constant", + "sources": [ + "https://github.com/openai/codex/blob/rust-v0.153.4/codex-rs/network-proxy/src/credential_broker/providers/github.rs#L14", + "https://registry.npmjs.org/@openai/codex/-/codex-0.153.4-linux-x64.tgz" + ], + "upstreamArchiveSha256": "54818cb9fce3360cc6e44cfc5a96952cd5c1243efb43cbe488e11dda84663e08", + "upstreamMember": "vendor/x86_64-unknown-linux-musl/bin/codex", + "upstreamMemberSha256": "56ef98ab4032d317ab26e9b5e5a175650717351edb16ed9cde0cb6d1734d62da" + }, + { + "pattern": "GitHub token", + "sha256": "902a57265300171f27289680dac688d3c2354f0893c50f89ec0522e0e123c3fe", + "description": "Compiler-adjacent string data following a four-byte token-prefix constant; not a complete credential constant", + "sources": [ + "https://github.com/cli/cli/blob/v2.100.0/pkg/cmd/auth/status/status.go#L339", + "https://github.com/cli/cli/releases/download/v2.100.0/gh_2.100.0_linux_amd64.tar.gz" + ], + "upstreamArchiveSha256": "e4d4bb4498e8d007abe545b6568926793ace1b6447da598294a610018cb164be", + "upstreamMember": "bin/gh", + "upstreamMemberSha256": "553949e2efa12842771efe6012aa4de21f1d591530ec17fc435f610f10e017ee" + }, + { + "pattern": "Google API key", + "sha256": "1b3835891a6cecc54d6d28a1b3e1513cc001ad7bddbb1c1521c36f7763e1ffc5", + "description": "Vendor-distributed Chrome for Testing 153.0.8010.12 browser API configuration; not a deployment credential or a dummy key", + "sources": [ + "https://storage.googleapis.com/chrome-for-testing-public/153.0.8010.12/linux64/chrome-linux64.zip", + "https://chromium.googlesource.com/chromium/src/+/main/docs/api_keys.md" + ], + "upstreamArchiveSha256": "8aac35011c18f6e2d10696154af89a5728ac2ddd6dc6fad24ffdf243c3fcfd5a", + "upstreamMember": "chrome-linux64/chrome", + "upstreamMemberSha256": "8c599d43aec53f2460a31ae2f4af6bd863f8258b34ff519564bc5d4726bfaa1e" + }, + { + "pattern": "Google API key", + "sha256": "34d464f64a851dff50696150faadf8b8f09acc1699317da6a4985119be6d11dc", + "description": "Vendor-distributed Chrome for Testing 153.0.8010.12 browser API configuration; not a deployment credential or a dummy key", + "sources": [ + "https://storage.googleapis.com/chrome-for-testing-public/153.0.8010.12/linux64/chrome-linux64.zip", + "https://chromium.googlesource.com/chromium/src/+/main/docs/api_keys.md" + ], + "upstreamArchiveSha256": "8aac35011c18f6e2d10696154af89a5728ac2ddd6dc6fad24ffdf243c3fcfd5a", + "upstreamMember": "chrome-linux64/chrome", + "upstreamMemberSha256": "8c599d43aec53f2460a31ae2f4af6bd863f8258b34ff519564bc5d4726bfaa1e" + }, + { + "pattern": "Google API key", + "sha256": "98c0108cb5187b9e0d2fe2b058fff45e3af45ddfe72b3e6df59ab1fafa8bd722", + "description": "Vendor-distributed Chrome for Testing 153.0.8010.12 browser API configuration; not a deployment credential or a dummy key", + "sources": [ + "https://storage.googleapis.com/chrome-for-testing-public/153.0.8010.12/linux64/chrome-linux64.zip", + "https://chromium.googlesource.com/chromium/src/+/main/docs/api_keys.md" + ], + "upstreamArchiveSha256": "8aac35011c18f6e2d10696154af89a5728ac2ddd6dc6fad24ffdf243c3fcfd5a", + "upstreamMember": "chrome-linux64/chrome", + "upstreamMemberSha256": "8c599d43aec53f2460a31ae2f4af6bd863f8258b34ff519564bc5d4726bfaa1e" + }, + { + "pattern": "Google API key", + "sha256": "76d0a2783815b7cc504688d7507d922871d8bd77ed7fdaa9af78340b8549ecf1", + "description": "Vendor-distributed Chrome for Testing 153.0.8010.12 browser API configuration; not a deployment credential or a dummy key", + "sources": [ + "https://storage.googleapis.com/chrome-for-testing-public/153.0.8010.12/linux64/chrome-linux64.zip", + "https://chromium.googlesource.com/chromium/src/+/main/docs/api_keys.md" + ], + "upstreamArchiveSha256": "8aac35011c18f6e2d10696154af89a5728ac2ddd6dc6fad24ffdf243c3fcfd5a", + "upstreamMember": "chrome-linux64/chrome", + "upstreamMemberSha256": "8c599d43aec53f2460a31ae2f4af6bd863f8258b34ff519564bc5d4726bfaa1e" + } +] diff --git a/scripts/secret-scan.mjs b/scripts/secret-scan.mjs index ce77997..5bb0047 100644 --- a/scripts/secret-scan.mjs +++ b/scripts/secret-scan.mjs @@ -5,8 +5,9 @@ // like `xoxb-…`, `sk-ant-x`, or test fixtures ("xoxb-test") never trip it — a noisy scanner gets // ignored, a quiet one gets trusted. import { execFileSync, spawn } from "node:child_process"; -import { createReadStream, lstatSync, readdirSync, readlinkSync } from "node:fs"; +import { createReadStream, lstatSync, readdirSync, readlinkSync, readFileSync } from "node:fs"; import { createGunzip } from "node:zlib"; +import { createHash } from "node:crypto"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -32,6 +33,14 @@ const PATTERNS = [ { name: "Private key block", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----\s+[A-Za-z0-9+/=]{60,}/g }, ]; +// Exceptions identify complete reviewed public values, never paths, key prefixes or patterns. +// They apply ONLY to generated artifacts; repository files and history always stay strict. +const reviewedFixtures = JSON.parse(readFileSync(new URL("./reviewed-artifact-fixtures.json", import.meta.url), "utf8")); +const reviewedHashes = new Set(reviewedFixtures.map(({ pattern, sha256 }) => `${pattern}:${sha256}`)); +const LOOKAHEAD = 64 * 1024; // Larger/truncated PEMs remain findings; never waive a partial key. +const COMPLETE_PEM = /^-----BEGIN ([A-Z ]*PRIVATE KEY)-----[ \t]*\r?\n(?:[A-Za-z0-9+/=]+[ \t]*\r?\n)+-----END \1-----/; +const digest = (value) => createHash("sha256").update(value, "latin1").digest("hex"); + // Never exempt lockfiles, assets or large blobs: credentials can appear in any of them. // History means commits reachable from this release candidate, not the private archive remote. const args = process.argv.slice(2); @@ -40,32 +49,56 @@ const artifactDir = artifactsAt >= 0 ? args[artifactsAt + 1] : null; if (artifactsAt >= 0 && !artifactDir) throw new Error("--artifacts requires a directory"); let findings = 0; let checked = 0; -function scan(content, label, overlap = 0) { +let reviewed = 0; +function scan(content, label, limit = content.length, artifact = false, start = 0) { for (const { name, re } of PATTERNS) { re.lastIndex = 0; let match; while ((match = re.exec(content)) !== null) { - if (match.index + match[0].length <= overlap) continue; + if (match.index >= limit) break; + if (match.index < start) continue; + // A PEM prefix matches the detection pattern, but only a COMPLETE key can be reviewed. + // The same first base64 line with a different body must always remain a finding. + const complete = name === "Private key block" + ? COMPLETE_PEM.exec(content.slice(match.index, match.index + LOOKAHEAD))?.[0] + : match[0]; + const sha256 = digest(complete || match[0]); + if (artifact && complete && reviewedHashes.has(`${name}:${sha256}`)) { + reviewed += 1; + continue; + } findings += 1; - if (findings <= 100) console.error(`SECRET? ${safeLabel(label)} matches "${name}"`); + if (findings <= 100) console.error(`SECRET? ${safeLabel(label)} matches "${name}" sha256=${sha256}`); } } } +async function scanStream(stream, label, artifact = false) { + // Keep forward context, not just an overlap of already-scanned bytes: a complete public PEM + // must be available before deciding whether to waive its header, including at chunk edges. + let pending = ""; + let start = 0; + for await (const chunk of stream) { + pending += chunk.toString("latin1"); + const limit = Math.max(0, pending.length - LOOKAHEAD); + if (limit) { + scan(pending, label, limit, artifact, start); + // Retain one already-scanned byte so word-boundary patterns see their real left context. + pending = pending.slice(limit - 1); + start = 1; + } + } + scan(pending, label, pending.length, artifact, start); +} function safeLabel(label) { let value = String(label); for (const { re } of PATTERNS) value = value.replace(new RegExp(re.source, re.flags), "[redacted]"); return value; } -async function scanFile(file, label) { +async function scanFile(file, label, artifact = false) { // Streaming keeps image archives and other large release assets bounded in memory. let stream = createReadStream(file); if (file.endsWith(".gz")) stream = stream.pipe(createGunzip()); - let tail = ""; - for await (const chunk of stream) { - const content = tail + chunk.toString("latin1"); - scan(content, label, tail.length); - tail = content.slice(-2048); - } + await scanStream(stream, label, artifact); checked += 1; } const files = execFileSync("git", ["ls-files", "-z"], { encoding: "utf8", cwd: repoRoot }).split("\0").filter(Boolean); @@ -95,8 +128,7 @@ if (args.includes("--history")) { if (!["blob", "commit"].includes(type)) continue; const child = spawn("git", ["cat-file", type, id], { cwd: repoRoot, stdio: ["ignore", "pipe", "inherit"] }); const completion = new Promise((resolve, reject) => { child.once("error", reject); child.once("close", (code) => code === 0 ? resolve() : reject(new Error(`git cat-file failed (${code})`))); }); - let tail = ""; - for await (const chunk of child.stdout) { const content = tail + chunk.toString("latin1"); scan(content, `history:${id.slice(0, 12)}:${names.get(id)}`, tail.length); tail = content.slice(-2048); } + await scanStream(child.stdout, `history:${id.slice(0, 12)}:${names.get(id)}`); await completion; checked += 1; } @@ -105,7 +137,7 @@ async function walk(dir) { for (const entry of readdirSync(dir, { withFileTypes: true })) { const file = path.join(dir, entry.name); if (entry.isDirectory()) await walk(file); - else if (entry.isFile()) await scanFile(file, `artifact:${path.relative(repoRoot, file)}`); + else if (entry.isFile()) await scanFile(file, `artifact:${path.relative(repoRoot, file)}`, true); else if (entry.isSymbolicLink()) throw new Error(`Release artifact must not be a symlink: ${entry.name}`); } } @@ -113,4 +145,4 @@ if (artifactDir) await walk(path.resolve(repoRoot, artifactDir)); if (findings) { console.error(`Secret scan failed: ${findings} finding(s). Values are never printed. Review the reported objects before publication.`); process.exitCode = 1; -} else console.log(`Secret scan clean (${checked} files/blobs checked${args.includes("--history") ? ", including candidate history" : ""}).`); +} else console.log(`Secret scan clean (${checked} files/blobs checked${args.includes("--history") ? ", including candidate history" : ""}; ${reviewed} exact reviewed public artifact fixtures).`); diff --git a/test/release-artifact-fixtures.test.js b/test/release-artifact-fixtures.test.js new file mode 100644 index 0000000..946950a --- /dev/null +++ b/test/release-artifact-fixtures.test.js @@ -0,0 +1,87 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { copyFileSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { gzipSync } from "node:zlib"; +import { execFileSync, spawnSync } from "node:child_process"; +import path from "node:path"; +import { tempDir } from "./helpers.js"; + +const sha256 = (value) => createHash("sha256").update(value).digest("hex"); +const token = "gh" + "p_" + "A".repeat(36); +const key = ["-----BEGIN PRIVATE KEY-----", "A".repeat(64), "B".repeat(64), "-----END PRIVATE KEY-----"].join("\n"); +function fixture() { + const root = tempDir("cg-artifact-fixtures-"); + mkdirSync(path.join(root, "scripts")); + mkdirSync(path.join(root, "artifacts")); + copyFileSync(new URL("../scripts/secret-scan.mjs", import.meta.url), path.join(root, "scripts/secret-scan.mjs")); + writeFileSync(path.join(root, "scripts/reviewed-artifact-fixtures.json"), JSON.stringify([ + { pattern: "GitHub token", sha256: sha256(token) }, + { pattern: "Private key block", sha256: sha256(key) }, + ])); + const git = (...args) => execFileSync("git", ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", ...args], { cwd: root, stdio: "pipe" }); + git("init"); git("add", "scripts"); git("commit", "-m", "safe scanner fixture"); + const run = (...args) => spawnSync(process.execPath, ["scripts/secret-scan.mjs", ...args], { cwd: root, encoding: "utf8" }); + return { root, git, run }; +} + +test("reviewed public fixtures are waived only in artifacts, never tracked files or history", () => { + const { root, git, run } = fixture(); + const contents = `${token}\n${key}\n`; + writeFileSync(path.join(root, "artifacts/public.bin"), contents); + let result = run("--artifacts", "artifacts"); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /2 exact reviewed public artifact fixtures/); + writeFileSync(path.join(root, "source.txt"), contents); git("add", "source.txt"); + result = run("--artifacts", "artifacts"); + assert.equal(result.status, 1); + assert.match(result.stderr, /source.txt/); + assert.ok(!result.stderr.includes(token)); + assert.ok(!result.stderr.includes("A".repeat(64))); + git("commit", "-m", "fixture containing credential-shaped source"); + unlinkSync(path.join(root, "source.txt")); git("add", "source.txt"); git("commit", "-m", "remove fixture"); + result = run("--history", "--artifacts", "artifacts"); + assert.equal(result.status, 1); + assert.match(result.stderr, /history:/); + assert.equal((result.stderr.match(/SECRET\?/g) || []).length, 2); +}); + +test("same-prefix changed PEM, incomplete PEM and unknown token cannot inherit a public waiver", () => { + const { root, run } = fixture(); + const changed = key.replace("B".repeat(64), "C".repeat(64)); + const incomplete = key.slice(0, key.indexOf("-----END")); + const unknownToken = token.slice(0, -1) + "Z"; + writeFileSync(path.join(root, "artifacts/unknown.bin"), `${changed}\n${incomplete}\n${unknownToken}\n`); + const result = run("--artifacts", "artifacts"); + assert.equal(result.status, 1); + assert.equal((result.stderr.match(/SECRET\?/g) || []).length, 3, result.stderr); + assert.ok(!result.stderr.includes(unknownToken)); + assert.ok(!result.stderr.includes("C".repeat(64))); +}); + +test("gzip streams preserve full-key decisions and one finding across read/lookahead boundaries", () => { + const { root, run } = fixture(); + // The token crosses the 64 KiB read boundary. The PEM's known first line is in a previous + // chunk to its changed body/end: waiving its prefix before reading that body would be unsafe. + const changed = key.replace("B".repeat(64), "D".repeat(64)); + const contents = " ".repeat(65536 - 10) + token + "\n" + " ".repeat(65536 - token.length - 40) + changed + "\n" + key; + writeFileSync(path.join(root, "artifacts/boundary.bin.gz"), gzipSync(contents)); + let result = run("--artifacts", "artifacts"); + assert.equal(result.status, 1); + assert.equal((result.stderr.match(/SECRET\?/g) || []).length, 1, result.stderr); + writeFileSync(path.join(root, "artifacts/boundary.bin.gz"), gzipSync(contents.replace(changed, key))); + result = run("--artifacts", "artifacts"); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /3 exact reviewed public artifact fixtures/); +}); + + +test("word-boundary detection retains the byte before a stream cut", () => { + const { root, run } = fixture(); + const googleShaped = "AI" + "za" + "A".repeat(35); + // An adjoining word character means this is not a Google-key pattern, including when the + // scanner advances its retained window immediately before the key-shaped substring. + writeFileSync(path.join(root, "artifacts/word-boundary.bin"), " ".repeat(65535) + "x" + googleShaped + " ".repeat(131072)); + const result = run("--artifacts", "artifacts"); + assert.equal(result.status, 0, result.stderr); +}); diff --git a/test/release-secret-history.test.js b/test/release-secret-history.test.js index f22c54b..6c934c0 100644 --- a/test/release-secret-history.test.js +++ b/test/release-secret-history.test.js @@ -9,6 +9,7 @@ test("release scanning covers commit and candidate tag messages without echoing const root = tempDir("cg-scan-history-"); mkdirSync(path.join(root, "scripts")); copyFileSync(new URL("../scripts/secret-scan.mjs", import.meta.url), path.join(root, "scripts/secret-scan.mjs")); + copyFileSync(new URL("../scripts/reviewed-artifact-fixtures.json", import.meta.url), path.join(root, "scripts/reviewed-artifact-fixtures.json")); const git = (...args) => execFileSync("git", ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", ...args], { cwd: root, stdio: "pipe" }); git("init"); writeFileSync(path.join(root, "file.txt"), "safe fixture\n"); git("add", "file.txt"); const fake = "gh" + "p_" + "A".repeat(36); From 8aeab6e5de5fb6e04932691c708d65c3c92cbe20 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Tue, 8 Sep 2026 00:59:13 +0300 Subject: [PATCH 12/14] docs: record candidate scanner and lifecycle verification scope Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00649ba..147d200 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,11 @@ remains deferred; stable promotion is not approved. cannot break a dedicated-account installation. - Export the release image with pipeline failure propagation and verify gzip integrity before generating its checksums and attestations. +- Recognize exact reviewed public vendor fixtures during release-artifact scanning, including + complete self-test/development keys and compiler-adjacent constants. Unknown values still fail, + and repository files and Git history receive no exceptions. +- Add disposable Linux installation, encrypted backup/restore, real updater rollback and actual + OS reboot checks; authenticated engine and chat acceptance remains a separate live gate. ## 0.6.0-rc.1 — 2026-09-08 (release candidate) From ddb6e1cec2361a1f23dcd021d9847c5faaf11198 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Tue, 8 Sep 2026 00:59:24 +0300 Subject: [PATCH 13/14] docs: record verified Linux lifecycle reboot and rollback evidence Signed-off-by: Tiberiu Socaci --- FEATURES.md | 3 ++- TEST-PLAN.md | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/FEATURES.md b/FEATURES.md index b24e44d..6076de2 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -6,7 +6,8 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. exercises a fresh dedicated-account systemd install with the full rootless Podman image, HTTP liveness after restart, encrypted fixture backup/restore and non-destructive uninstall. A local fixture upstream also proves the real CLI updater restores Git and restarts systemd - after a candidate test failure or readiness failure; engine smoke is explicitly stubbed. + after a candidate test failure or readiness failure; engine smoke and test/pretest commands + are controlled fixture inputs. Its script refuses non-hosted or occupied hosts. A separate KVM guest workflow exercises an actual OS reboot, service autostart and persistent database/container-volume fixtures. Authenticated engine update smoke and conversation/session acceptance remain separate live diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 69625f6..c19c84c 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -5,6 +5,28 @@ pass. Many checks are manual (require a real Slack workspace + an authenticated ## Disposable Linux lifecycle workflow +- [x] Real fresh-install/restart/encrypted fixture backup/restore/uninstall acceptance passed on + [hosted run 34163913150](https://github.com/makeitfutureDev/channelgate/actions/runs/34163913150), + tested merge SHA `5d9d424f41217945c841603622cc3035a4d61def`. This used synthetic SQL/config data + and a public fixture passphrase in a new dedicated identity, never production data or keys. + The fixture backup was taken while its daemon ran; the replacement restore removed stale + sidecars/stray config and preserved the exact markers with SQLite integrity `ok`. +- [x] Full lifecycle plus real CLI updater rollback passed on + [hosted run 34164530300](https://github.com/makeitfutureDev/channelgate/actions/runs/34164530300), + tested merge SHA `547ae2454d96fc038e97ae111174cebfb3bcd998`. Both the controlled test failure + and deliberately wrong live revision produced durable `rolled_back` state, restored checkout + and healthy service, preserved recovery-snapshot SQL/config markers, and released the lock. + The original candidate was restored before uninstall. Engine smoke and test/pretest commands + were controlled LOCAL fixture inputs; the CLI updater, Git, npm install/audit/static checks, + systemd restart, snapshot and rollback operations were real. Separate unit regressions: 37/37. +- [x] Actual guest OS reboot/autostart/durable fixture/uninstall acceptance passed on + [KVM run 34164530261](https://github.com/makeitfutureDev/channelgate/actions/runs/34164530261), + tested merge SHA `547ae2454d96fc038e97ae111174cebfb3bcd998`. Both OS boot ID and daemon instance + changed, the service started without manual repair, and SQL/rootless-volume markers survived. + These are engine-independent operations results; authenticated conversation gates below remain + unexecuted. The first guest attempt timed out downloading the browser; the passing attempt used + an IPv4 guest network. No production download timeout or image contents were changed. + - Automated setup: dispatch `.github/workflows/linux-lifecycle.yml` for the candidate ref (a PR changing this workflow/script also runs it). GitHub-hosted Ubuntu 24.04, Node 24, real PID-1 systemd and rootless Podman; no job container, provider credentials or chat connection. From 6b3f6046e21be8ee3994a001c891112853b97b9c Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Tue, 8 Sep 2026 01:00:03 +0300 Subject: [PATCH 14/14] docs: record passed isolated operations release gates Signed-off-by: Tiberiu Socaci --- docs/RELEASE-CHECKLIST.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/RELEASE-CHECKLIST.md b/docs/RELEASE-CHECKLIST.md index 019b38c..a52c87c 100644 --- a/docs/RELEASE-CHECKLIST.md +++ b/docs/RELEASE-CHECKLIST.md @@ -21,8 +21,15 @@ - [x] CI, security coverage, dependency/secret scans, and real CLI nightly canaries are green (2026-09-06). - [ ] Release workflow emitted the image SBOM, model hashes, exact image archive, checksums and verifiable GitHub artifact attestations for this candidate. -- [ ] Encrypted backup completed and `npm run restore:drill` passed off production data. -- [ ] The Linux systemd service package passed install/restart/uninstall checks. +- [x] Encrypted backup and `npm run restore:drill` passed using isolated synthetic data, + including replacement restore with database/config verification (2026-09-08, + [Linux lifecycle evidence](https://github.com/makeitfutureDev/channelgate/actions/runs/34164530300)). + This does not claim a restore of this deployment's production data. +- [x] Linux systemd installation, restart, real CLI updater rollback and uninstall passed in + the disposable lifecycle VM above; actual OS reboot, automatic service startup and + database/container-volume persistence passed in the + [separate guest reboot](https://github.com/makeitfutureDev/channelgate/actions/runs/34164530261). + Rollback uses controlled engine smoke and test commands; authenticated canary remains below. - [ ] Canary passed health, Slack, engine, approval, confinement, update, and rollback checks. - [x] Project requests use GitHub; direct security/privacy/legal/support requests use `contact@makeitfuture.com`. Contracted support requires an active Makeitfuture or approved