Skip to content
Merged
39 changes: 39 additions & 0 deletions .github/workflows/linux-lifecycle.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Linux lifecycle

on:
workflow_dispatch:
pull_request:
paths:
- .github/workflows/linux-lifecycle.yml
- scripts/check-linux-lifecycle.sh
- scripts/check-update-rollback-hosted.mjs

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
34 changes: 34 additions & 0 deletions .github/workflows/linux-reboot.yml
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

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. 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
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.
Public contributors do not need access to a private QA service. → TEST-PLAN: Development acceptance policy.
Expand Down
57 changes: 57 additions & 0 deletions TEST-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,63 @@
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).

## 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.
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-<sha>` 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.
- Hosted updater operations: `scripts/check-update-rollback-hosted.mjs` is restricted to that
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
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-<sha>` 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.
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
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
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.

## Base modes and independent options (2026-09-08)

- Automated: `modes`, `channel-settings-modal`, `mode-command-audit`, `folders-settings`,
Expand Down
166 changes: 166 additions & 0 deletions scripts/check-linux-lifecycle.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
#!/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 -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" \
PATH="$PATH" /bin/sh -c 'cd "$HOME" && exec "$@"' sh "$@"
}
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 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-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"
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 smoke (real Git/service rollback uses a controlled smoke fixture).' \
'NOT RUN: Slack/Airtable/Composio or other external acceptance campaigns.'
Loading
Loading