549 lines
16 KiB
TypeScript
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)];
|
|
}
|