refactor(identity): remove PLATFORM_ID/SERVICE_NAME/RUNTIME_USER env vars
Step 5 of system-namespace cutover: complete the env-var removal that
step 4 set up. All consumers now import SERVICE_NAME from
src/platform-identity.ts directly; the deprecated PLATFORM_*
re-exports in src/config.ts are gone.
src/config.ts:
- PLATFORM_ID, PLATFORM_SERVICE_NAME, PLATFORM_RUNTIME_USER exports
removed.
- PLATFORM_RUNTIME_HOME stays (derived from SERVICE_NAME, used by
~10 consumers for path construction).
- Env-var allowlist drops PLATFORM_ID / PLATFORM_SERVICE_NAME /
PLATFORM_RUNTIME_USER / PLATFORM_RUNTIME_HOME entries.
- CONTROLPLANE_AIDER_TMUX_SESSION uses SERVICE_NAME directly.
setup/onboarding.ts:
- writeIdentity() simplified to write only ASSISTANT_NAME (display).
PLATFORM_ID / PLATFORM_SERVICE_NAME / PLATFORM_RUNTIME_USER are no
longer written to .env. Fresh installs have no PLATFORM_* keys.
- Status emission switched from PLATFORM_ID to SERVICE_NAME.
setup/env-audit.ts:
- Audit lists SERVICE_NAME instead of PLATFORM_ID; the env-file
PLATFORM_ID read is gone.
24 source files (src/*.ts, setup/*.ts, scripts/dashboard.ts):
- Bare PLATFORM_ID / PLATFORM_SERVICE_NAME / PLATFORM_RUNTIME_USER
references replaced with SERVICE_NAME.
- Imports rewired: SERVICE_NAME comes from
../{src/}platform-identity.js, not from config.js.
- Imports deduped where the sed sweep produced collisions.
Shell scripts (scripts/bhyve-evidence.sh, glass.sh, inspect-system.sh):
- Hardcoded SERVICE_NAME='clawdie' and SERVICE_USER='clawdie'.
No more grep-the-.env fallbacks; the constants are the source.
Tests (middle path):
- Mechanical fixes (import path, renamed assertion text):
src/hostd/privileged-commands.test.ts, src/startup-report.test.ts,
setup/env-audit.test.ts, setup/install-mode.test.ts.
- Skipped with `// system-namespace:` markers (pinned removed
env-driven override behavior; Codex rewrites once the bootstrap-
config service-user override path lands):
setup/verify.test.ts > 'uses the platform service name for PID candidates'
setup/service.test.ts > 'resolves a platform runtime separately from the tenant'
Test files still containing PLATFORM_* strings in vi.mock contents,
ENV_KEYS arrays, or comments are left untouched — they are test
artifacts that don't affect runtime; mock contents resolve to
'clawdie' which still equals SERVICE_NAME.
tsc clean. 2095 tests pass, 4 skipped, 0 fail.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---
Build: pass | Tests: pass — Tests 2095 passed | 4 skipped (2099)
2026-05-02 14:49:19 +02:00
|
|
|
import { SERVICE_NAME } from '../src/platform-identity.js';
|
2026-04-05 06:29:19 +00:00
|
|
|
import { execSync, spawnSync } from 'child_process';
|
2026-03-14 22:51:55 +01:00
|
|
|
import fs from 'fs';
|
|
|
|
|
import path from 'path';
|
2026-03-15 00:37:43 +01:00
|
|
|
import os from 'os';
|
2026-03-14 22:51:55 +01:00
|
|
|
|
2026-04-23 20:15:59 +02:00
|
|
|
import {
|
|
|
|
|
AGENT_INTERNAL_DOMAIN,
|
2026-04-24 15:44:52 +02:00
|
|
|
PLATFORM_INTERNAL_BASE,
|
|
|
|
|
PLATFORM_PUBLIC_BASE,
|
2026-04-23 20:15:59 +02:00
|
|
|
} from '../src/config.js';
|
2026-04-03 08:49:07 +00:00
|
|
|
import { formatDisplayDate } from '../src/display-date.js';
|
2026-03-14 22:51:55 +01:00
|
|
|
import { logger } from '../src/logger.js';
|
|
|
|
|
import { emitStatus } from './status.js';
|
|
|
|
|
|
|
|
|
|
interface PreflightArgs {
|
|
|
|
|
withOnboarding: boolean;
|
|
|
|
|
capturePasswordStep: boolean;
|
|
|
|
|
failFast: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface StepDefinition {
|
|
|
|
|
id: string;
|
|
|
|
|
label: string;
|
|
|
|
|
command: string;
|
|
|
|
|
args: string[];
|
2026-03-14 23:04:03 +01:00
|
|
|
interactive?: boolean;
|
2026-03-15 00:37:43 +01:00
|
|
|
requiresRoot?: boolean;
|
2026-04-02 06:48:17 +00:00
|
|
|
/** If true, a non-zero exit is logged as 'warning' and does not fail the overall run. */
|
|
|
|
|
softFail?: boolean;
|
2026-03-14 22:51:55 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface StepResult {
|
|
|
|
|
id: string;
|
|
|
|
|
label: string;
|
|
|
|
|
commandLine: string;
|
|
|
|
|
exitCode: number;
|
2026-04-02 06:48:17 +00:00
|
|
|
status: 'success' | 'failed' | 'skipped' | 'warning';
|
2026-03-14 22:51:55 +01:00
|
|
|
startedAt: string;
|
|
|
|
|
finishedAt: string;
|
|
|
|
|
logFile: string;
|
|
|
|
|
fields: Record<string, string>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface PasswordCaptureResult {
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
captured: boolean;
|
|
|
|
|
published: boolean;
|
|
|
|
|
captureDir: string;
|
|
|
|
|
publishDir: string;
|
|
|
|
|
logFile: string;
|
|
|
|
|
error?: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 20:15:59 +02:00
|
|
|
export function getPreflightTmuxSessionName(): string {
|
refactor(identity): remove PLATFORM_ID/SERVICE_NAME/RUNTIME_USER env vars
Step 5 of system-namespace cutover: complete the env-var removal that
step 4 set up. All consumers now import SERVICE_NAME from
src/platform-identity.ts directly; the deprecated PLATFORM_*
re-exports in src/config.ts are gone.
src/config.ts:
- PLATFORM_ID, PLATFORM_SERVICE_NAME, PLATFORM_RUNTIME_USER exports
removed.
- PLATFORM_RUNTIME_HOME stays (derived from SERVICE_NAME, used by
~10 consumers for path construction).
- Env-var allowlist drops PLATFORM_ID / PLATFORM_SERVICE_NAME /
PLATFORM_RUNTIME_USER / PLATFORM_RUNTIME_HOME entries.
- CONTROLPLANE_AIDER_TMUX_SESSION uses SERVICE_NAME directly.
setup/onboarding.ts:
- writeIdentity() simplified to write only ASSISTANT_NAME (display).
PLATFORM_ID / PLATFORM_SERVICE_NAME / PLATFORM_RUNTIME_USER are no
longer written to .env. Fresh installs have no PLATFORM_* keys.
- Status emission switched from PLATFORM_ID to SERVICE_NAME.
setup/env-audit.ts:
- Audit lists SERVICE_NAME instead of PLATFORM_ID; the env-file
PLATFORM_ID read is gone.
24 source files (src/*.ts, setup/*.ts, scripts/dashboard.ts):
- Bare PLATFORM_ID / PLATFORM_SERVICE_NAME / PLATFORM_RUNTIME_USER
references replaced with SERVICE_NAME.
- Imports rewired: SERVICE_NAME comes from
../{src/}platform-identity.js, not from config.js.
- Imports deduped where the sed sweep produced collisions.
Shell scripts (scripts/bhyve-evidence.sh, glass.sh, inspect-system.sh):
- Hardcoded SERVICE_NAME='clawdie' and SERVICE_USER='clawdie'.
No more grep-the-.env fallbacks; the constants are the source.
Tests (middle path):
- Mechanical fixes (import path, renamed assertion text):
src/hostd/privileged-commands.test.ts, src/startup-report.test.ts,
setup/env-audit.test.ts, setup/install-mode.test.ts.
- Skipped with `// system-namespace:` markers (pinned removed
env-driven override behavior; Codex rewrites once the bootstrap-
config service-user override path lands):
setup/verify.test.ts > 'uses the platform service name for PID candidates'
setup/service.test.ts > 'resolves a platform runtime separately from the tenant'
Test files still containing PLATFORM_* strings in vi.mock contents,
ENV_KEYS arrays, or comments are left untouched — they are test
artifacts that don't affect runtime; mock contents resolve to
'clawdie' which still equals SERVICE_NAME.
tsc clean. 2095 tests pass, 4 skipped, 0 fail.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---
Build: pass | Tests: pass — Tests 2095 passed | 4 skipped (2099)
2026-05-02 14:49:19 +02:00
|
|
|
return SERVICE_NAME;
|
2026-04-23 20:15:59 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-14 22:51:55 +01:00
|
|
|
function parseArgs(args: string[]): PreflightArgs {
|
|
|
|
|
const result: PreflightArgs = {
|
|
|
|
|
withOnboarding: false,
|
|
|
|
|
capturePasswordStep: false,
|
|
|
|
|
failFast: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for (const arg of args) {
|
|
|
|
|
if (arg === '--with-onboarding') result.withOnboarding = true;
|
|
|
|
|
if (arg === '--capture-password-step') result.capturePasswordStep = true;
|
|
|
|
|
if (arg === '--fail-fast') result.failFast = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (result.capturePasswordStep) {
|
|
|
|
|
result.withOnboarding = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatDisplayTimestamp(date: Date): string {
|
2026-04-03 08:49:07 +00:00
|
|
|
return formatDisplayDate(date, { includeTime: true, includeSeconds: true });
|
2026-03-14 22:51:55 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatRunStamp(date: Date): string {
|
|
|
|
|
const pad = (value: number) => String(value).padStart(2, '0');
|
|
|
|
|
return [
|
|
|
|
|
date.getFullYear(),
|
|
|
|
|
pad(date.getMonth() + 1),
|
|
|
|
|
pad(date.getDate()),
|
|
|
|
|
'-',
|
|
|
|
|
pad(date.getHours()),
|
|
|
|
|
pad(date.getMinutes()),
|
|
|
|
|
pad(date.getSeconds()),
|
|
|
|
|
].join('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function quoteShell(arg: string): string {
|
|
|
|
|
if (/^[A-Za-z0-9_./:@%+=,-]+$/u.test(arg)) {
|
|
|
|
|
return arg;
|
|
|
|
|
}
|
|
|
|
|
return JSON.stringify(arg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseStatusFields(output: string): Record<string, string> {
|
|
|
|
|
const matches = output.match(/=== CLAWDIE SETUP: [\s\S]*?=== END ===/gu) || [];
|
|
|
|
|
const block = matches.at(-1);
|
|
|
|
|
if (!block) {
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fields: Record<string, string> = {};
|
|
|
|
|
for (const line of block.split('\n')) {
|
|
|
|
|
if (line.startsWith('=== ')) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const separator = line.indexOf(':');
|
|
|
|
|
if (separator === -1) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const key = line.slice(0, separator).trim();
|
|
|
|
|
const value = line.slice(separator + 1).trim();
|
|
|
|
|
if (key) {
|
|
|
|
|
fields[key] = value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return fields;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readEnvValue(projectRoot: string, key: string): string | null {
|
|
|
|
|
const envFile = path.join(projectRoot, '.env');
|
|
|
|
|
if (!fs.existsSync(envFile)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const content = fs.readFileSync(envFile, 'utf-8');
|
|
|
|
|
const match = content.match(new RegExp(`^${key}=(.+)$`, 'm'));
|
|
|
|
|
return match ? match[1].trim().replace(/^['"]|['"]$/gu, '') : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildSteps(opts: PreflightArgs): StepDefinition[] {
|
|
|
|
|
const steps: StepDefinition[] = [];
|
|
|
|
|
|
|
|
|
|
if (opts.withOnboarding) {
|
|
|
|
|
steps.push({
|
|
|
|
|
id: 'onboarding',
|
|
|
|
|
label: 'Onboarding',
|
|
|
|
|
command: 'npm',
|
|
|
|
|
args: ['run', 'wizard'],
|
2026-03-14 23:04:03 +01:00
|
|
|
interactive: true,
|
2026-03-14 22:51:55 +01:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
steps.push(
|
|
|
|
|
{
|
|
|
|
|
id: 'environment',
|
|
|
|
|
label: 'Environment',
|
|
|
|
|
command: 'npm',
|
|
|
|
|
args: ['run', 'setup', '--', '--step', 'environment'],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'pi-config',
|
|
|
|
|
label: 'PI Config',
|
|
|
|
|
command: 'npm',
|
|
|
|
|
args: ['run', 'setup', '--', '--step', 'pi-config'],
|
|
|
|
|
},
|
release: v0.7.1 — Control Plane Refactoring
0.7.0 — Privileged Host Daemon + PF Web Edge:
- clawdie-hostd: root daemon on /var/run/clawdie-hostd.sock with whitelisted
Zod-validated op handlers (bastille, zfs, pf, service, pkg, sysrc, sanoid)
- setup/pf.ts: writes /etc/pf.conf with NAT egress + rdr pass 80/443 → cms jail
- src/controlplane.ts: self-healing checks hostd, service jails, PF; repairs via
hostd; process.exit(1) if db jail still down after fix
- setup/hostd.ts: installs rc.d script, sets clawdie_hostd_enable=YES
- setup/service.ts: tmux session with setup window when PI_PROFILE=setup
- Preflight: 15 steps (pf at position 3, hostd at position 12)
- Removed infra/ansible/playbooks/host-nginx.yaml
0.7.1 — Control Plane Refactoring:
- src/infra.ts → src/controlplane.ts (runControlPlaneChecks,
CONTROLPLANE_CHECK_INTERVAL_MS, ControlPlaneReport, ControlPlaneCheckResult)
- Watchdog stores lastControlPlaneReport; exposed as controlplane in IPC status
- doctor now queries watchdog IPC: prints WATCHDOG_* and CONTROLPLANE_* lines
- Docs: README simplified, MONITORING rewritten with watchdog/controlplane
layers, SECURITY updated with hostd in architecture diagram,
HOST-OPERATOR-MODEL gets privilege delegation section, CMS-DEPLOYMENT-PLAN
updated with implementation status
- Skills: nginx fully rewritten to cms-jail model; setup + freebsd-admin updated
- All stale src/infra.ts references eliminated
---
Build: pass | Tests: pass — 414 passed (45 files)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
Build: pass | Tests: pass — Tests 414 passed | 10 skipped (424)
---
Build: pass | Tests: pass — Tests 414 passed | 10 skipped (424)
2026-03-15 08:50:08 +00:00
|
|
|
{
|
|
|
|
|
id: 'pf',
|
|
|
|
|
label: 'PF Firewall',
|
|
|
|
|
command: 'npm',
|
2026-04-08 19:22:46 +00:00
|
|
|
args: ['run', 'install', '--', '--step', 'pf'],
|
release: v0.7.1 — Control Plane Refactoring
0.7.0 — Privileged Host Daemon + PF Web Edge:
- clawdie-hostd: root daemon on /var/run/clawdie-hostd.sock with whitelisted
Zod-validated op handlers (bastille, zfs, pf, service, pkg, sysrc, sanoid)
- setup/pf.ts: writes /etc/pf.conf with NAT egress + rdr pass 80/443 → cms jail
- src/controlplane.ts: self-healing checks hostd, service jails, PF; repairs via
hostd; process.exit(1) if db jail still down after fix
- setup/hostd.ts: installs rc.d script, sets clawdie_hostd_enable=YES
- setup/service.ts: tmux session with setup window when PI_PROFILE=setup
- Preflight: 15 steps (pf at position 3, hostd at position 12)
- Removed infra/ansible/playbooks/host-nginx.yaml
0.7.1 — Control Plane Refactoring:
- src/infra.ts → src/controlplane.ts (runControlPlaneChecks,
CONTROLPLANE_CHECK_INTERVAL_MS, ControlPlaneReport, ControlPlaneCheckResult)
- Watchdog stores lastControlPlaneReport; exposed as controlplane in IPC status
- doctor now queries watchdog IPC: prints WATCHDOG_* and CONTROLPLANE_* lines
- Docs: README simplified, MONITORING rewritten with watchdog/controlplane
layers, SECURITY updated with hostd in architecture diagram,
HOST-OPERATOR-MODEL gets privilege delegation section, CMS-DEPLOYMENT-PLAN
updated with implementation status
- Skills: nginx fully rewritten to cms-jail model; setup + freebsd-admin updated
- All stale src/infra.ts references eliminated
---
Build: pass | Tests: pass — 414 passed (45 files)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
Build: pass | Tests: pass — Tests 414 passed | 10 skipped (424)
---
Build: pass | Tests: pass — Tests 414 passed | 10 skipped (424)
2026-03-15 08:50:08 +00:00
|
|
|
requiresRoot: true,
|
|
|
|
|
},
|
2026-03-14 22:51:55 +01:00
|
|
|
{
|
|
|
|
|
id: 'jails',
|
|
|
|
|
label: 'Jails',
|
|
|
|
|
command: 'npm',
|
2026-04-08 19:22:46 +00:00
|
|
|
args: ['run', 'install', '--', '--step', 'jails'],
|
2026-03-15 00:37:43 +01:00
|
|
|
requiresRoot: true,
|
2026-03-14 22:51:55 +01:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'db',
|
|
|
|
|
label: 'DB',
|
|
|
|
|
command: 'npm',
|
2026-04-08 19:22:46 +00:00
|
|
|
args: ['run', 'install', '--', '--step', 'db'],
|
2026-03-15 00:37:43 +01:00
|
|
|
requiresRoot: true,
|
2026-03-14 22:51:55 +01:00
|
|
|
},
|
2026-04-08 19:22:46 +00:00
|
|
|
{
|
|
|
|
|
id: 'controlplane',
|
|
|
|
|
label: 'Control Plane',
|
|
|
|
|
command: 'npm',
|
|
|
|
|
args: ['run', 'install', '--', '--step', 'controlplane'],
|
|
|
|
|
},
|
2026-03-14 22:51:55 +01:00
|
|
|
{
|
|
|
|
|
id: 'git',
|
|
|
|
|
label: 'Git',
|
|
|
|
|
command: 'npm',
|
2026-04-08 19:22:46 +00:00
|
|
|
args: ['run', 'install', '--', '--step', 'git'],
|
2026-03-15 00:37:43 +01:00
|
|
|
requiresRoot: true,
|
2026-03-14 22:51:55 +01:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'cms',
|
|
|
|
|
label: 'CMS',
|
|
|
|
|
command: 'npm',
|
2026-04-08 19:22:46 +00:00
|
|
|
args: ['run', 'install', '--', '--step', 'cms'],
|
2026-03-15 00:37:43 +01:00
|
|
|
requiresRoot: true,
|
2026-03-14 22:51:55 +01:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'hosts',
|
|
|
|
|
label: 'Hosts',
|
|
|
|
|
command: 'npm',
|
2026-04-08 19:22:46 +00:00
|
|
|
args: ['run', 'install', '--', '--step', 'hosts'],
|
2026-03-15 00:37:43 +01:00
|
|
|
requiresRoot: true,
|
2026-03-14 22:51:55 +01:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'mounts',
|
|
|
|
|
label: 'Mounts',
|
|
|
|
|
command: 'npm',
|
2026-04-08 19:22:46 +00:00
|
|
|
args: ['run', 'install', '--', '--step', 'mounts'],
|
2026-03-14 22:51:55 +01:00
|
|
|
},
|
2026-03-14 23:04:03 +01:00
|
|
|
{
|
|
|
|
|
id: 'telegram-auth',
|
|
|
|
|
label: 'Telegram Auth',
|
|
|
|
|
command: 'npm',
|
|
|
|
|
args: ['run', 'setup', '--', '--step', 'telegram-auth'],
|
|
|
|
|
},
|
2026-03-14 22:51:55 +01:00
|
|
|
{
|
|
|
|
|
id: 'service',
|
|
|
|
|
label: 'Service',
|
|
|
|
|
command: 'npm',
|
2026-04-08 19:22:46 +00:00
|
|
|
args: ['run', 'install', '--', '--step', 'service'],
|
2026-03-14 22:51:55 +01:00
|
|
|
},
|
release: v0.7.1 — Control Plane Refactoring
0.7.0 — Privileged Host Daemon + PF Web Edge:
- clawdie-hostd: root daemon on /var/run/clawdie-hostd.sock with whitelisted
Zod-validated op handlers (bastille, zfs, pf, service, pkg, sysrc, sanoid)
- setup/pf.ts: writes /etc/pf.conf with NAT egress + rdr pass 80/443 → cms jail
- src/controlplane.ts: self-healing checks hostd, service jails, PF; repairs via
hostd; process.exit(1) if db jail still down after fix
- setup/hostd.ts: installs rc.d script, sets clawdie_hostd_enable=YES
- setup/service.ts: tmux session with setup window when PI_PROFILE=setup
- Preflight: 15 steps (pf at position 3, hostd at position 12)
- Removed infra/ansible/playbooks/host-nginx.yaml
0.7.1 — Control Plane Refactoring:
- src/infra.ts → src/controlplane.ts (runControlPlaneChecks,
CONTROLPLANE_CHECK_INTERVAL_MS, ControlPlaneReport, ControlPlaneCheckResult)
- Watchdog stores lastControlPlaneReport; exposed as controlplane in IPC status
- doctor now queries watchdog IPC: prints WATCHDOG_* and CONTROLPLANE_* lines
- Docs: README simplified, MONITORING rewritten with watchdog/controlplane
layers, SECURITY updated with hostd in architecture diagram,
HOST-OPERATOR-MODEL gets privilege delegation section, CMS-DEPLOYMENT-PLAN
updated with implementation status
- Skills: nginx fully rewritten to cms-jail model; setup + freebsd-admin updated
- All stale src/infra.ts references eliminated
---
Build: pass | Tests: pass — 414 passed (45 files)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
Build: pass | Tests: pass — Tests 414 passed | 10 skipped (424)
---
Build: pass | Tests: pass — Tests 414 passed | 10 skipped (424)
2026-03-15 08:50:08 +00:00
|
|
|
{
|
|
|
|
|
id: 'hostd',
|
|
|
|
|
label: 'Host Daemon',
|
|
|
|
|
command: 'npm',
|
2026-04-08 19:22:46 +00:00
|
|
|
args: ['run', 'install', '--', '--step', 'hostd'],
|
release: v0.7.1 — Control Plane Refactoring
0.7.0 — Privileged Host Daemon + PF Web Edge:
- clawdie-hostd: root daemon on /var/run/clawdie-hostd.sock with whitelisted
Zod-validated op handlers (bastille, zfs, pf, service, pkg, sysrc, sanoid)
- setup/pf.ts: writes /etc/pf.conf with NAT egress + rdr pass 80/443 → cms jail
- src/controlplane.ts: self-healing checks hostd, service jails, PF; repairs via
hostd; process.exit(1) if db jail still down after fix
- setup/hostd.ts: installs rc.d script, sets clawdie_hostd_enable=YES
- setup/service.ts: tmux session with setup window when PI_PROFILE=setup
- Preflight: 15 steps (pf at position 3, hostd at position 12)
- Removed infra/ansible/playbooks/host-nginx.yaml
0.7.1 — Control Plane Refactoring:
- src/infra.ts → src/controlplane.ts (runControlPlaneChecks,
CONTROLPLANE_CHECK_INTERVAL_MS, ControlPlaneReport, ControlPlaneCheckResult)
- Watchdog stores lastControlPlaneReport; exposed as controlplane in IPC status
- doctor now queries watchdog IPC: prints WATCHDOG_* and CONTROLPLANE_* lines
- Docs: README simplified, MONITORING rewritten with watchdog/controlplane
layers, SECURITY updated with hostd in architecture diagram,
HOST-OPERATOR-MODEL gets privilege delegation section, CMS-DEPLOYMENT-PLAN
updated with implementation status
- Skills: nginx fully rewritten to cms-jail model; setup + freebsd-admin updated
- All stale src/infra.ts references eliminated
---
Build: pass | Tests: pass — 414 passed (45 files)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
Build: pass | Tests: pass — Tests 414 passed | 10 skipped (424)
---
Build: pass | Tests: pass — Tests 414 passed | 10 skipped (424)
2026-03-15 08:50:08 +00:00
|
|
|
requiresRoot: true,
|
|
|
|
|
},
|
2026-03-15 04:58:05 +00:00
|
|
|
{
|
|
|
|
|
id: 'sanoid',
|
|
|
|
|
label: 'Sanoid',
|
|
|
|
|
command: 'npm',
|
2026-04-08 19:22:46 +00:00
|
|
|
args: ['run', 'install', '--', '--step', 'sanoid'],
|
2026-03-15 04:58:05 +00:00
|
|
|
requiresRoot: true,
|
|
|
|
|
},
|
2026-03-14 22:51:55 +01:00
|
|
|
{
|
|
|
|
|
id: 'verify',
|
|
|
|
|
label: 'Verify',
|
|
|
|
|
command: 'npm',
|
2026-04-08 19:22:46 +00:00
|
|
|
args: ['run', 'install', '--', '--step', 'verify'],
|
2026-03-15 00:37:43 +01:00
|
|
|
requiresRoot: true,
|
2026-03-14 22:51:55 +01:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'doctor',
|
2026-04-02 06:48:17 +00:00
|
|
|
label: 'Doctor (runtime health)',
|
2026-03-14 22:51:55 +01:00
|
|
|
command: 'npm',
|
|
|
|
|
args: ['run', 'doctor'],
|
2026-04-02 06:48:17 +00:00
|
|
|
softFail: true,
|
2026-03-14 22:51:55 +01:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return steps;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function runStep(
|
|
|
|
|
projectRoot: string,
|
|
|
|
|
runDir: string,
|
|
|
|
|
step: StepDefinition,
|
|
|
|
|
): StepResult {
|
|
|
|
|
const started = new Date();
|
|
|
|
|
const commandLine = [step.command, ...step.args].map(quoteShell).join(' ');
|
2026-03-14 23:04:03 +01:00
|
|
|
const logFile = path.join(runDir, `${step.id}.log`);
|
|
|
|
|
const interactive = step.interactive === true;
|
2026-03-15 00:37:43 +01:00
|
|
|
const freebsdNeedsRoot = os.platform() === 'freebsd' && process.getuid?.() !== 0;
|
|
|
|
|
|
|
|
|
|
if (step.requiresRoot && freebsdNeedsRoot) {
|
|
|
|
|
const output = 'root_required\n';
|
|
|
|
|
fs.writeFileSync(logFile, output);
|
|
|
|
|
const finished = new Date();
|
|
|
|
|
return {
|
|
|
|
|
id: step.id,
|
|
|
|
|
label: step.label,
|
|
|
|
|
commandLine,
|
|
|
|
|
exitCode: 1,
|
|
|
|
|
status: 'failed',
|
|
|
|
|
startedAt: started.toISOString(),
|
|
|
|
|
finishedAt: finished.toISOString(),
|
|
|
|
|
logFile,
|
|
|
|
|
fields: {
|
|
|
|
|
ERROR: 'root_required',
|
|
|
|
|
HINT: 'rerun_root_required_steps_as_root',
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-03-14 23:04:03 +01:00
|
|
|
|
|
|
|
|
if (interactive && (!process.stdin.isTTY || !process.stdout.isTTY)) {
|
|
|
|
|
const output = 'interactive_tty_required\n';
|
|
|
|
|
fs.writeFileSync(logFile, output);
|
2026-03-14 23:23:04 +01:00
|
|
|
const finished = new Date();
|
2026-03-14 23:04:03 +01:00
|
|
|
return {
|
|
|
|
|
id: step.id,
|
|
|
|
|
label: step.label,
|
|
|
|
|
commandLine,
|
|
|
|
|
exitCode: 1,
|
|
|
|
|
status: 'failed',
|
|
|
|
|
startedAt: started.toISOString(),
|
|
|
|
|
finishedAt: finished.toISOString(),
|
|
|
|
|
logFile,
|
|
|
|
|
fields: { ERROR: 'interactive_tty_required' },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (interactive) {
|
|
|
|
|
const child = spawnSync(step.command, step.args, {
|
|
|
|
|
cwd: projectRoot,
|
|
|
|
|
env: process.env,
|
|
|
|
|
stdio: 'inherit',
|
|
|
|
|
});
|
|
|
|
|
fs.writeFileSync(logFile, '[interactive output inherited to terminal]\n');
|
2026-03-14 23:23:04 +01:00
|
|
|
const finished = new Date();
|
2026-03-14 23:04:03 +01:00
|
|
|
return {
|
|
|
|
|
id: step.id,
|
|
|
|
|
label: step.label,
|
|
|
|
|
commandLine,
|
|
|
|
|
exitCode: child.status ?? 1,
|
|
|
|
|
status: (child.status ?? 1) === 0 ? 'success' : 'failed',
|
|
|
|
|
startedAt: started.toISOString(),
|
|
|
|
|
finishedAt: finished.toISOString(),
|
|
|
|
|
logFile,
|
|
|
|
|
fields: {},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 22:51:55 +01:00
|
|
|
const child = spawnSync(step.command, step.args, {
|
|
|
|
|
cwd: projectRoot,
|
|
|
|
|
encoding: 'utf-8',
|
|
|
|
|
env: process.env,
|
|
|
|
|
});
|
|
|
|
|
const output = [
|
|
|
|
|
child.stdout || '',
|
|
|
|
|
child.stderr || '',
|
|
|
|
|
child.error ? String(child.error) : '',
|
|
|
|
|
]
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.join('\n');
|
|
|
|
|
fs.writeFileSync(logFile, output || '\n');
|
2026-03-14 23:23:04 +01:00
|
|
|
const finished = new Date();
|
2026-03-14 22:51:55 +01:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: step.id,
|
|
|
|
|
label: step.label,
|
|
|
|
|
commandLine,
|
|
|
|
|
exitCode: child.status ?? 1,
|
2026-04-02 06:48:17 +00:00
|
|
|
status: (child.status ?? 1) === 0
|
|
|
|
|
? 'success'
|
|
|
|
|
: step.softFail
|
|
|
|
|
? 'warning'
|
|
|
|
|
: 'failed',
|
2026-03-14 22:51:55 +01:00
|
|
|
startedAt: started.toISOString(),
|
|
|
|
|
finishedAt: finished.toISOString(),
|
|
|
|
|
logFile,
|
|
|
|
|
fields: parseStatusFields(output),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function capturePasswordStep(
|
|
|
|
|
projectRoot: string,
|
|
|
|
|
runDir: string,
|
|
|
|
|
): PasswordCaptureResult {
|
|
|
|
|
const captureDir = path.join(runDir, 'password-step-screenshot');
|
|
|
|
|
const logFile = path.join(runDir, 'password-step-screenshot.log');
|
|
|
|
|
const child = spawnSync(
|
|
|
|
|
'python3',
|
|
|
|
|
[
|
|
|
|
|
path.join(
|
|
|
|
|
projectRoot,
|
|
|
|
|
'.agent',
|
|
|
|
|
'skills',
|
|
|
|
|
'tmux-screenshot',
|
|
|
|
|
'tmux-screenshot.py',
|
|
|
|
|
),
|
|
|
|
|
'--session',
|
2026-04-23 20:15:59 +02:00
|
|
|
getPreflightTmuxSessionName(),
|
2026-03-14 22:51:55 +01:00
|
|
|
'--window',
|
|
|
|
|
'main',
|
|
|
|
|
'--outdir',
|
|
|
|
|
captureDir,
|
|
|
|
|
],
|
|
|
|
|
{
|
|
|
|
|
cwd: projectRoot,
|
|
|
|
|
encoding: 'utf-8',
|
|
|
|
|
env: process.env,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
const output = [child.stdout || '', child.stderr || ''].filter(Boolean).join('\n');
|
|
|
|
|
const fullOutput = [
|
|
|
|
|
output,
|
|
|
|
|
child.error ? String(child.error) : '',
|
|
|
|
|
]
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.join('\n');
|
|
|
|
|
fs.writeFileSync(logFile, fullOutput || '\n');
|
|
|
|
|
|
|
|
|
|
if ((child.status ?? 1) !== 0) {
|
|
|
|
|
return {
|
|
|
|
|
enabled: true,
|
|
|
|
|
captured: false,
|
|
|
|
|
published: false,
|
|
|
|
|
captureDir,
|
|
|
|
|
publishDir: '',
|
|
|
|
|
logFile,
|
|
|
|
|
error: 'capture_failed',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
enabled: true,
|
|
|
|
|
captured: true,
|
|
|
|
|
published: false,
|
|
|
|
|
captureDir,
|
|
|
|
|
publishDir: '',
|
|
|
|
|
logFile,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function publishPasswordCapture(
|
|
|
|
|
projectRoot: string,
|
|
|
|
|
passwordCapture: PasswordCaptureResult,
|
|
|
|
|
): PasswordCaptureResult {
|
2026-04-05 06:29:19 +00:00
|
|
|
const resolvedCmsJail = resolveCmsJailName(projectRoot);
|
2026-03-14 22:51:55 +01:00
|
|
|
const publishDir = path.join(
|
|
|
|
|
'/usr/local/bastille/jails',
|
2026-04-05 06:29:19 +00:00
|
|
|
resolvedCmsJail,
|
2026-03-14 22:51:55 +01:00
|
|
|
'root',
|
|
|
|
|
'srv',
|
|
|
|
|
'www',
|
|
|
|
|
'screenshots',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
fs.mkdirSync(publishDir, { recursive: true });
|
|
|
|
|
for (const entry of fs.readdirSync(passwordCapture.captureDir)) {
|
|
|
|
|
fs.cpSync(path.join(passwordCapture.captureDir, entry), path.join(publishDir, entry), {
|
|
|
|
|
recursive: true,
|
|
|
|
|
force: true,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
...passwordCapture,
|
|
|
|
|
published: true,
|
|
|
|
|
publishDir,
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return {
|
|
|
|
|
...passwordCapture,
|
|
|
|
|
published: false,
|
|
|
|
|
publishDir,
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 06:29:19 +00:00
|
|
|
function resolveCmsJailName(projectRoot: string): string {
|
|
|
|
|
const explicit = readEnvValue(projectRoot, 'CMS_JAIL_NAME')?.trim();
|
|
|
|
|
if (explicit) return explicit;
|
|
|
|
|
try {
|
|
|
|
|
const output = execSync('jls -N name', {
|
|
|
|
|
encoding: 'utf-8',
|
|
|
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
|
|
|
});
|
|
|
|
|
const names = output
|
|
|
|
|
.split('\n')
|
|
|
|
|
.map((line) => line.trim())
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
if (names.includes('cms')) return 'cms';
|
|
|
|
|
} catch {
|
|
|
|
|
// Default to cms when jail discovery is unavailable.
|
|
|
|
|
}
|
|
|
|
|
return 'cms';
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 22:51:55 +01:00
|
|
|
function writeSummaryFiles(
|
|
|
|
|
runDir: string,
|
|
|
|
|
args: PreflightArgs,
|
|
|
|
|
results: StepResult[],
|
|
|
|
|
passwordCapture: PasswordCaptureResult | null,
|
|
|
|
|
): void {
|
2026-04-02 06:48:17 +00:00
|
|
|
const hasFailures = results.some((result) => result.status === 'failed');
|
|
|
|
|
const hasWarnings = results.some((result) => result.status === 'warning');
|
|
|
|
|
const passwordOk = !passwordCapture ||
|
|
|
|
|
!passwordCapture.enabled ||
|
|
|
|
|
(passwordCapture.captured && passwordCapture.published);
|
|
|
|
|
const overallStatus = hasFailures || !passwordOk
|
|
|
|
|
? 'failed'
|
|
|
|
|
: hasWarnings
|
|
|
|
|
? 'warning'
|
|
|
|
|
: 'success';
|
2026-03-14 22:51:55 +01:00
|
|
|
|
|
|
|
|
const summaryJson = {
|
|
|
|
|
generatedAt: new Date().toISOString(),
|
|
|
|
|
generatedAtDisplay: formatDisplayTimestamp(new Date()),
|
|
|
|
|
overallStatus,
|
|
|
|
|
args,
|
|
|
|
|
passwordCapture,
|
|
|
|
|
results: results.map((result) => ({
|
|
|
|
|
...result,
|
|
|
|
|
logFile: path.relative(runDir, result.logFile),
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
fs.writeFileSync(
|
|
|
|
|
path.join(runDir, 'summary.json'),
|
|
|
|
|
`${JSON.stringify(summaryJson, null, 2)}\n`,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const envLines = [
|
|
|
|
|
`OVERALL_STATUS=${overallStatus}`,
|
|
|
|
|
`WITH_ONBOARDING=${args.withOnboarding}`,
|
|
|
|
|
`CAPTURE_PASSWORD_STEP=${args.capturePasswordStep}`,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if (passwordCapture) {
|
|
|
|
|
envLines.push(`PASSWORD_STEP_CAPTURE_ENABLED=${passwordCapture.enabled}`);
|
|
|
|
|
envLines.push(`PASSWORD_STEP_CAPTURED=${passwordCapture.captured}`);
|
|
|
|
|
envLines.push(`PASSWORD_STEP_PUBLISHED=${passwordCapture.published}`);
|
|
|
|
|
envLines.push(`PASSWORD_STEP_CAPTURE_DIR=${passwordCapture.captureDir}`);
|
|
|
|
|
envLines.push(`PASSWORD_STEP_PUBLISH_DIR=${passwordCapture.publishDir}`);
|
|
|
|
|
envLines.push(`PASSWORD_STEP_LOG=${passwordCapture.logFile}`);
|
|
|
|
|
if (passwordCapture.error) {
|
|
|
|
|
envLines.push(`PASSWORD_STEP_ERROR=${JSON.stringify(passwordCapture.error)}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const result of results) {
|
|
|
|
|
const prefix = result.id.toUpperCase().replace(/[^A-Z0-9]+/gu, '_');
|
|
|
|
|
envLines.push(`${prefix}_STATUS=${result.status}`);
|
|
|
|
|
envLines.push(`${prefix}_EXIT_CODE=${result.exitCode}`);
|
|
|
|
|
envLines.push(`${prefix}_LOG=${result.logFile}`);
|
|
|
|
|
for (const [key, value] of Object.entries(result.fields)) {
|
|
|
|
|
const envKey = `${prefix}_${key.replace(/[^A-Z0-9]+/giu, '_').toUpperCase()}`;
|
|
|
|
|
envLines.push(`${envKey}=${JSON.stringify(value)}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fs.writeFileSync(path.join(runDir, 'summary.env'), `${envLines.join('\n')}\n`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function run(args: string[]): Promise<void> {
|
|
|
|
|
const projectRoot = process.cwd();
|
|
|
|
|
const opts = parseArgs(args);
|
|
|
|
|
const started = new Date();
|
|
|
|
|
const runDir = path.join(projectRoot, 'tmp', 'preflight', formatRunStamp(started));
|
|
|
|
|
const steps = buildSteps(opts);
|
|
|
|
|
const results: StepResult[] = [];
|
|
|
|
|
let passwordCapture: PasswordCaptureResult | null = null;
|
|
|
|
|
|
|
|
|
|
fs.mkdirSync(runDir, { recursive: true });
|
|
|
|
|
|
|
|
|
|
console.log(`Preflight started: ${formatDisplayTimestamp(started)}`);
|
|
|
|
|
console.log(`Run directory: ${runDir}`);
|
2026-04-24 15:44:52 +02:00
|
|
|
console.log(`Internal base: ${PLATFORM_INTERNAL_BASE}`);
|
|
|
|
|
console.log(`Tenant home: ${AGENT_INTERNAL_DOMAIN}`);
|
|
|
|
|
console.log(`Public base: ${PLATFORM_PUBLIC_BASE || '(disabled)'}`);
|
2026-03-14 22:51:55 +01:00
|
|
|
console.log('');
|
|
|
|
|
|
|
|
|
|
for (const step of steps) {
|
|
|
|
|
console.log(`[preflight] ${step.label} -> ${[step.command, ...step.args].join(' ')}`);
|
|
|
|
|
const result = runStep(projectRoot, runDir, step);
|
|
|
|
|
results.push(result);
|
|
|
|
|
|
2026-03-14 23:23:04 +01:00
|
|
|
if (step.id === 'onboarding' && opts.capturePasswordStep && result.status === 'success') {
|
2026-03-14 22:51:55 +01:00
|
|
|
passwordCapture = capturePasswordStep(projectRoot, runDir);
|
|
|
|
|
}
|
|
|
|
|
if (step.id === 'cms' && passwordCapture?.captured && !passwordCapture.published) {
|
|
|
|
|
passwordCapture = publishPasswordCapture(projectRoot, passwordCapture);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (result.status === 'success') {
|
|
|
|
|
console.log(` ok ${step.label} (${path.relative(projectRoot, result.logFile)})`);
|
2026-04-02 06:48:17 +00:00
|
|
|
} else if (result.status === 'warning') {
|
|
|
|
|
console.log(` warn ${step.label} (${path.relative(projectRoot, result.logFile)})`);
|
2026-03-14 22:51:55 +01:00
|
|
|
} else {
|
|
|
|
|
console.log(` fail ${step.label} (${path.relative(projectRoot, result.logFile)})`);
|
|
|
|
|
if (opts.failFast) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeSummaryFiles(runDir, opts, results, passwordCapture);
|
|
|
|
|
|
2026-04-02 06:48:17 +00:00
|
|
|
const hasFailures = results.some((result) => result.status === 'failed');
|
|
|
|
|
const hasWarnings = results.some((result) => result.status === 'warning');
|
|
|
|
|
const passwordOk = !passwordCapture ||
|
|
|
|
|
!passwordCapture.enabled ||
|
|
|
|
|
(passwordCapture.captured && passwordCapture.published);
|
|
|
|
|
const overallStatus = hasFailures || !passwordOk
|
|
|
|
|
? 'failed'
|
|
|
|
|
: hasWarnings
|
|
|
|
|
? 'warning'
|
|
|
|
|
: 'success';
|
2026-03-14 22:51:55 +01:00
|
|
|
|
|
|
|
|
logger.info({ runDir, overallStatus }, 'Preflight check complete');
|
|
|
|
|
emitStatus('PREFLIGHT', {
|
|
|
|
|
RUN_DIR: path.relative(projectRoot, runDir),
|
|
|
|
|
OVERALL_STATUS: overallStatus,
|
|
|
|
|
WITH_ONBOARDING: opts.withOnboarding,
|
|
|
|
|
CAPTURE_PASSWORD_STEP: opts.capturePasswordStep,
|
|
|
|
|
SUMMARY_JSON: path.relative(projectRoot, path.join(runDir, 'summary.json')),
|
|
|
|
|
SUMMARY_ENV: path.relative(projectRoot, path.join(runDir, 'summary.env')),
|
|
|
|
|
STATUS: overallStatus,
|
|
|
|
|
LOG: path.relative(projectRoot, runDir),
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-02 06:48:17 +00:00
|
|
|
if (overallStatus === 'failed') {
|
2026-03-14 22:51:55 +01:00
|
|
|
process.exit(1);
|
|
|
|
|
}
|
|
|
|
|
}
|