clawdie-ai/setup/first-boot.ts
Operator & Codex 8145830ebf Align first boot provider setup with Codex recommendation
---
Build: pass | Tests: pass — 2441 passed (181 files)
2026-05-12 09:56:02 +02:00

549 lines
16 KiB
TypeScript

import fs from 'fs';
import path from 'path';
import { normalizeTimeZone } from '../src/locale-profile.js';
import { normalizeResourceId } from '../src/platform-layout.js';
export type FirstBootProfile = 'economy' | 'balanced' | 'quality';
export type FirstBootInstallMode = 'auto' | 'fresh' | 'upgrade' | 'rescue';
export type FirstBootZfsLayout = 'single' | 'mirror' | 'raidz1' | 'raidz2';
export type FirstBootProviderKeyName =
| 'ANTHROPIC_API_KEY'
| 'OPENAI_API_KEY'
| 'OPENROUTER_API_KEY'
| 'ZAI_API_KEY'
| 'CLAUDE_CODE_OAUTH_TOKEN';
export interface FirstBootConfig {
setupSchemaVersion: number;
isoRelease: string;
isoGitCommit: string | null;
providerApiKeys: Partial<Record<FirstBootProviderKeyName, string>>;
openRouterApiKey: string | null;
telegramBotToken: string | null;
telegramAdminId: number | null;
needsPostInstallProviderSetup: boolean;
needsPostInstallTelegramSetup: boolean;
installMode: FirstBootInstallMode;
assistantName: string;
profile: FirstBootProfile;
timeZone: string;
hostname: string;
agentDomain: string;
zfsPool: string;
zfsLayout: FirstBootZfsLayout;
zfsDataDisks: number;
zfsHotSpares: number;
zfsPrefix: string;
operatorEmail: string | null;
operatorPassword: string | null;
sshAuthorizedKey: string | null;
clawdieUserPassword: string | null;
plaintextCredentialWarning: string | null;
}
export interface FirstBootRuntimeBundle {
chat: {
provider: string;
model: string;
};
fallback: {
provider: string;
model: string;
};
compaction: {
provider: string;
model: string;
};
}
export interface FirstBootStorageLayout {
pool: string;
prefix: string;
rootDataset: string;
jailsDataset: string;
pgDataDataset: string;
pgWalDataset: string;
skillsDataset: string;
tmpDataset: string;
}
const DEFAULT_ASSISTANT_NAME = 'Clawdie';
const DEFAULT_HOSTNAME = 'clawdie';
const DEFAULT_TIMEZONE = 'UTC';
const DEFAULT_PROFILE: FirstBootProfile = 'balanced';
const DEFAULT_INSTALL_MODE: FirstBootInstallMode = 'auto';
const DEFAULT_ZFS_POOL = 'zroot';
const DEFAULT_ZFS_LAYOUT: FirstBootZfsLayout = 'single';
const DEFAULT_ZFS_DATA_DISKS = 1;
const DEFAULT_ZFS_HOT_SPARES = 0;
const DEFAULT_ZFS_PREFIX = 'clawdie-runtime';
export const FIRST_BOOT_SETUP_SCHEMA_VERSION = 1;
const INVISIBLE_CHARS_RE = /[\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/gu;
const FIRST_BOOT_KEYS = [
'SETUP_SCHEMA_VERSION',
'ISO_RELEASE',
'ISO_GIT_COMMIT',
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
'OPENROUTER_API_KEY',
'ZAI_API_KEY',
'CLAUDE_CODE_OAUTH_TOKEN',
'TELEGRAM_BOT_TOKEN',
'TELEGRAM_ADMIN_ID',
'INSTALL_MODE',
'ASSISTANT_NAME',
'PROFILE',
'TIMEZONE',
'HOSTNAME',
'AGENT_DOMAIN',
'ZFS_POOL',
'ZFS_LAYOUT',
'ZFS_DATA_DISKS',
'ZFS_HOT_SPARES',
'ZFS_PREFIX',
'OPERATOR_EMAIL',
'OPERATOR_PASSWORD',
'SSH_AUTHORIZED_KEY',
'CLAWDIE_USER_PASSWORD',
'ROOT_PASSWORD',
] as const;
type FirstBootKey = (typeof FIRST_BOOT_KEYS)[number];
function normalizeSetupValue(value: string): string {
const cleaned = value.replace(INVISIBLE_CHARS_RE, '').trim();
if (
(cleaned.startsWith('"') && cleaned.endsWith('"')) ||
(cleaned.startsWith("'") && cleaned.endsWith("'"))
) {
return cleaned.slice(1, -1).replace(INVISIBLE_CHARS_RE, '').trim();
}
return cleaned;
}
function parseSetupLines(content: string): Partial<Record<FirstBootKey, string>> {
const values: Partial<Record<FirstBootKey, string>> = {};
const knownKeys = new Set<string>(FIRST_BOOT_KEYS);
for (const rawLine of content.replace(/\r\n/gu, '\n').split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const eqIndex = line.indexOf('=');
if (eqIndex === -1) continue;
const key = line.slice(0, eqIndex).trim();
if (!knownKeys.has(key)) continue;
const value = normalizeSetupValue(line.slice(eqIndex + 1));
values[key as FirstBootKey] = value;
}
return values;
}
function currentIsoRelease(): string {
try {
const packageJson = JSON.parse(
fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8'),
) as { version?: string };
const version = String(packageJson.version || '').trim();
if (version) {
return `v${version.replace(/^v/u, '')}`;
}
} catch {
// ignore
}
return 'v0.0.0';
}
function parseSchemaVersion(value?: string): number {
const normalized = (value || '').trim();
if (!normalized) return FIRST_BOOT_SETUP_SCHEMA_VERSION;
if (!/^\d+$/u.test(normalized)) {
throw new Error(
`setup.txt has invalid SETUP_SCHEMA_VERSION=${value}. Expected a non-negative integer.`,
);
}
const parsed = Number.parseInt(normalized, 10);
if (parsed !== FIRST_BOOT_SETUP_SCHEMA_VERSION) {
throw new Error(
`setup.txt SETUP_SCHEMA_VERSION=${parsed} is not supported by this installer. Expected ${FIRST_BOOT_SETUP_SCHEMA_VERSION}.`,
);
}
return parsed;
}
function parseIsoRelease(value?: string): string {
const normalized = (value || '').trim();
return normalized || currentIsoRelease();
}
function parseIsoGitCommit(value?: string): string | null {
const normalized = (value || '').trim();
if (!normalized) return null;
if (!/^[0-9a-f]{7,40}$/iu.test(normalized)) {
throw new Error(
`setup.txt has invalid ISO_GIT_COMMIT=${value}. Expected a git commit hash.`,
);
}
return normalized.toLowerCase();
}
function parseProfile(value?: string): FirstBootProfile {
const normalized = (value || '').trim().toLowerCase();
if (!normalized) return DEFAULT_PROFILE;
if (
normalized === 'economy' ||
normalized === 'balanced' ||
normalized === 'quality'
) {
return normalized;
}
throw new Error(
`setup.txt has invalid PROFILE=${value}. Expected economy, balanced, or quality.`,
);
}
function parseInstallMode(value?: string): FirstBootInstallMode {
const normalized = (value || '').trim().toLowerCase();
if (!normalized) return DEFAULT_INSTALL_MODE;
if (
normalized === 'auto' ||
normalized === 'fresh' ||
normalized === 'upgrade' ||
normalized === 'rescue'
) {
return normalized;
}
throw new Error(
`setup.txt has invalid INSTALL_MODE=${value}. Expected auto, fresh, upgrade, or rescue.`,
);
}
function parseZfsLayout(value?: string): FirstBootZfsLayout {
const normalized = (value || '').trim().toLowerCase();
if (!normalized) return DEFAULT_ZFS_LAYOUT;
if (
normalized === 'single' ||
normalized === 'mirror' ||
normalized === 'raidz1' ||
normalized === 'raidz2'
) {
return normalized;
}
throw new Error(
`setup.txt has invalid ZFS_LAYOUT=${value}. Expected single, mirror, raidz1, or raidz2.`,
);
}
function parseNonNegativeInteger(
key: string,
value: string | undefined,
fallback: number,
): number {
const normalized = (value || '').trim();
if (!normalized) return fallback;
if (!/^\d+$/u.test(normalized)) {
throw new Error(`setup.txt has invalid ${key}=${value}. Expected a non-negative integer.`);
}
return Number.parseInt(normalized, 10);
}
function normalizeAgentDomain(value: string | undefined, hostname: string): string {
const domain = (value || `${hostname}.home.arpa`).trim().toLowerCase().replace(/\.$/u, '');
if (!domain || domain.length > 253) {
throw new Error('setup.txt AGENT_DOMAIN resolved to an empty or too-long domain.');
}
if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/u.test(domain)) {
throw new Error(
`setup.txt has invalid AGENT_DOMAIN=${value}. Expected a DNS name such as clawdie.home.arpa.`,
);
}
return domain;
}
function normalizeZfsPool(value?: string): string {
const pool = (value || '').trim();
if (!pool) return DEFAULT_ZFS_POOL;
if (!/^[A-Za-z0-9_.-]{1,64}$/u.test(pool)) {
throw new Error(
`setup.txt has invalid ZFS_POOL=${value}. Expected a simple pool name like zroot.`,
);
}
return pool;
}
function normalizeZfsPrefix(value?: string): string {
const prefix = normalizeResourceId((value || '').trim() || DEFAULT_ZFS_PREFIX);
if (!prefix) {
throw new Error('setup.txt ZFS_PREFIX resolved to an empty dataset name.');
}
return prefix;
}
function validateZfsShape(
layout: FirstBootZfsLayout,
dataDisks: number,
hotSpares: number,
): void {
if (layout === 'single') {
if (dataDisks !== 1) {
throw new Error(
'setup.txt ZFS_LAYOUT=single requires ZFS_DATA_DISKS=1.',
);
}
if (hotSpares !== 0) {
throw new Error(
'setup.txt ZFS_LAYOUT=single requires ZFS_HOT_SPARES=0.',
);
}
return;
}
if (layout === 'mirror' && dataDisks < 2) {
throw new Error(
'setup.txt ZFS_LAYOUT=mirror requires ZFS_DATA_DISKS>=2.',
);
}
if (layout === 'raidz1' && dataDisks < 3) {
throw new Error(
'setup.txt ZFS_LAYOUT=raidz1 requires ZFS_DATA_DISKS>=3.',
);
}
if (layout === 'raidz2' && dataDisks < 4) {
throw new Error(
'setup.txt ZFS_LAYOUT=raidz2 requires ZFS_DATA_DISKS>=4.',
);
}
}
function parseTelegramAdminId(value?: string): number | null {
if (!value) return null;
if (!/^\d+$/u.test(value)) {
throw new Error(
`setup.txt has invalid TELEGRAM_ADMIN_ID=${value}. Expected a numeric Telegram user ID.`,
);
}
return Number.parseInt(value, 10);
}
function normalizeDashboardCredentials(
email?: string,
password?: string,
): { operatorEmail: string | null; operatorPassword: string | null } {
const operatorEmail = email || null;
const operatorPassword = password || null;
if ((operatorEmail && !operatorPassword) || (!operatorEmail && operatorPassword)) {
throw new Error(
'setup.txt must set both OPERATOR_EMAIL and OPERATOR_PASSWORD, or leave both blank.',
);
}
return { operatorEmail, operatorPassword };
}
function normalizeSshAuthorizedKey(value?: string): string | null {
const key = value || null;
if (!key) return null;
if (key.includes('\n') || key.includes('\r')) {
throw new Error(
'setup.txt SSH_AUTHORIZED_KEY must be a single-line SSH public key.',
);
}
if (
!/^(ssh-(ed25519|rsa)|ecdsa-sha2-nistp(256|384|521)) [A-Za-z0-9+/=]+(?: .*)?$/u.test(
key,
)
) {
throw new Error(
'setup.txt has invalid SSH_AUTHORIZED_KEY. Expected one SSH public key line.',
);
}
return key;
}
function normalizeClawdieUserPassword(value?: string): string | null {
const password = value || null;
return password ? password : null;
}
export const PLAINTEXT_CREDENTIAL_WARNING =
'setup.txt contains plaintext passwords. The installer will not delete it (per Round 2 resolution). Reformat the install media before storing it or handing it to anyone else.';
export function deriveFirstBootIdentity(input: {
assistantName?: string | null;
hostname?: string | null;
}): { assistantName: string; hostname: string } {
const assistantName = (input.assistantName || '').trim() || DEFAULT_ASSISTANT_NAME;
const hostname = normalizeResourceId(input.hostname || DEFAULT_HOSTNAME);
return { assistantName, hostname: hostname || DEFAULT_HOSTNAME };
}
export function deriveFirstBootStorageLayout(input: {
zfsPool?: string | null;
zfsPrefix?: string | null;
}): FirstBootStorageLayout {
const pool = normalizeZfsPool(input.zfsPool || undefined);
const prefix = normalizeZfsPrefix(input.zfsPrefix || undefined);
const rootDataset = `${pool}/${prefix}`;
return {
pool,
prefix,
rootDataset,
jailsDataset: `${rootDataset}/jails`,
pgDataDataset: `${rootDataset}/pgdata`,
pgWalDataset: `${rootDataset}/pgwal`,
skillsDataset: `${rootDataset}/skills`,
tmpDataset: `${rootDataset}/tmp`,
};
}
const FIRST_BOOT_PROVIDER_KEY_NAMES: FirstBootProviderKeyName[] = [
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
'OPENROUTER_API_KEY',
'ZAI_API_KEY',
'CLAUDE_CODE_OAUTH_TOKEN',
];
function collectProviderApiKeys(
values: Partial<Record<FirstBootKey, string>>,
): Partial<Record<FirstBootProviderKeyName, string>> {
const keys: Partial<Record<FirstBootProviderKeyName, string>> = {};
for (const key of FIRST_BOOT_PROVIDER_KEY_NAMES) {
const value = values[key];
if (value) keys[key] = value;
}
return keys;
}
export function parseFirstBootConfig(content: string): FirstBootConfig {
const values = parseSetupLines(content);
if (values.ROOT_PASSWORD) {
throw new Error(
'setup.txt ROOT_PASSWORD is not supported. Root login stays locked by default; see install docs.',
);
}
const setupSchemaVersion = parseSchemaVersion(values.SETUP_SCHEMA_VERSION);
const isoRelease = parseIsoRelease(values.ISO_RELEASE);
const isoGitCommit = parseIsoGitCommit(values.ISO_GIT_COMMIT);
const providerApiKeys = collectProviderApiKeys(values);
const openRouterApiKey = values.OPENROUTER_API_KEY || null;
const telegramBotToken = values.TELEGRAM_BOT_TOKEN || null;
const telegramAdminId = parseTelegramAdminId(values.TELEGRAM_ADMIN_ID);
const needsPostInstallProviderSetup =
Object.keys(providerApiKeys).length === 0;
const needsPostInstallTelegramSetup =
!telegramBotToken || telegramAdminId === null;
const installMode = parseInstallMode(values.INSTALL_MODE);
const profile = parseProfile(values.PROFILE);
const { assistantName, hostname } = deriveFirstBootIdentity({
assistantName: values.ASSISTANT_NAME,
hostname: values.HOSTNAME,
});
const timeZone = normalizeTimeZone(values.TIMEZONE, DEFAULT_TIMEZONE);
const agentDomain = normalizeAgentDomain(values.AGENT_DOMAIN, hostname);
const zfsPool = normalizeZfsPool(values.ZFS_POOL);
const zfsLayout = parseZfsLayout(values.ZFS_LAYOUT);
const zfsDataDisks = parseNonNegativeInteger(
'ZFS_DATA_DISKS',
values.ZFS_DATA_DISKS,
DEFAULT_ZFS_DATA_DISKS,
);
const zfsHotSpares = parseNonNegativeInteger(
'ZFS_HOT_SPARES',
values.ZFS_HOT_SPARES,
DEFAULT_ZFS_HOT_SPARES,
);
const zfsPrefix = normalizeZfsPrefix(values.ZFS_PREFIX);
validateZfsShape(zfsLayout, zfsDataDisks, zfsHotSpares);
const { operatorEmail, operatorPassword } = normalizeDashboardCredentials(
values.OPERATOR_EMAIL,
values.OPERATOR_PASSWORD,
);
const sshAuthorizedKey = normalizeSshAuthorizedKey(values.SSH_AUTHORIZED_KEY);
const clawdieUserPassword = normalizeClawdieUserPassword(
values.CLAWDIE_USER_PASSWORD,
);
const plaintextCredentialWarning =
operatorPassword || clawdieUserPassword ? PLAINTEXT_CREDENTIAL_WARNING : null;
return {
setupSchemaVersion,
isoRelease,
isoGitCommit,
providerApiKeys,
openRouterApiKey,
telegramBotToken,
telegramAdminId,
needsPostInstallProviderSetup,
needsPostInstallTelegramSetup,
installMode,
assistantName,
profile,
timeZone,
hostname,
agentDomain,
zfsPool,
zfsLayout,
zfsDataDisks,
zfsHotSpares,
zfsPrefix,
operatorEmail,
operatorPassword,
sshAuthorizedKey,
clawdieUserPassword,
plaintextCredentialWarning,
};
}
const FIRST_BOOT_PROFILE_BUNDLES: Record<FirstBootProfile, FirstBootRuntimeBundle> = {
economy: {
chat: {
provider: 'openai-codex',
model: 'gpt-5.5',
},
fallback: {
provider: 'openai-codex',
model: 'gpt-5.5',
},
compaction: {
provider: 'openai-codex',
model: 'gpt-5.5',
},
},
balanced: {
chat: {
provider: 'openai-codex',
model: 'gpt-5.5',
},
fallback: {
provider: 'openai-codex',
model: 'gpt-5.5',
},
compaction: {
provider: 'openai-codex',
model: 'gpt-5.5',
},
},
quality: {
chat: {
provider: 'openai-codex',
model: 'gpt-5.5',
},
fallback: {
provider: 'openai-codex',
model: 'gpt-5.5',
},
compaction: {
provider: 'openai-codex',
model: 'gpt-5.5',
},
},
};
export function profileToRuntimeEnv(
profile: FirstBootProfile | string | null | undefined,
): FirstBootRuntimeBundle {
return FIRST_BOOT_PROFILE_BUNDLES[parseProfile(profile || undefined)];
}