clawdie-ai/setup/controlplane.ts
Operator & Codex 8414953776 Harden controlplane agent API key lookup
---
Build: pass | Tests: FAIL — 26 failed
2026-05-03 18:10:46 +02:00

270 lines
9.7 KiB
TypeScript

/**
* setup/controlplane.ts
*
* Initialises the Clawdie control plane:
* 1. Creates PostgreSQL tables (idempotent — IF NOT EXISTS)
* 2. Provisions 4 default agents (CEO, Sysadmin, DBA, Git Admin)
* 3. Creates the operator account
* 4. Seeds per-agent budgets
* 5. Copies operational skills → data/skills/
*
* Safe to re-run: all writes use ON CONFLICT DO NOTHING / DO UPDATE.
*/
import fs from 'fs';
import path from 'path';
import pg from 'pg';
import {
ensureControlplaneAgentApiKey,
syncControlplaneAgentApiKeysFromDisk,
} from '../src/controlplane-agent-keys.js';
import {
BETTER_AUTH_SECRET,
BETTER_AUTH_URL,
CONTROLPLANE_API_PORT,
CONTROLPLANE_DASHBOARD_DIR,
CONTROLPLANE_HOST_LABEL,
CONTROLPLANE_INTERNAL_DOMAIN,
MEMORY_DB_URL,
PLATFORM_INTERNAL_BASE,
PLATFORM_PUBLIC_BASE,
TENANT_ID,
} from '../src/config.js';
import {
getDefaultAgents,
copySkills,
generatePassword,
getAgents,
getOperator,
runSchemaMigration,
upsertAgent,
upsertBudget,
upsertOperator,
} from '../src/controlplane-db.js';
import {
createControlplaneBootstrapAuth,
deriveControlplaneOperatorEmail,
ensureControlplaneBootstrapOperator,
runControlplaneBootstrapMigrations,
} from '../src/controlplane-auth-bootstrap.js';
import { readEnvFile } from '../src/env.js';
import { logger } from '../src/logger.js';
import { emitStatus } from './status.js';
const LOG = 'logs/setup.log';
function appendSetupLog(line: string): void {
try {
fs.mkdirSync('logs', { recursive: true });
fs.appendFileSync(LOG, `[${new Date().toISOString()}] ${line}\n`);
} catch {
// log file is best-effort; never block setup on it
}
}
export async function run(_args: string[]): Promise<void> {
appendSetupLog('=== controlplane setup start ===');
const dbUrl = MEMORY_DB_URL;
if (!dbUrl) {
emitStatus('SETUP_CONTROLPLANE', {
STATUS: 'failed',
ERROR: 'MEMORY_DB_URL not set — cannot connect to PostgreSQL',
LOG,
});
process.exit(1);
}
const pool = new pg.Pool({ connectionString: dbUrl, max: 3 });
const envSecrets = readEnvFile([
'OPERATOR_PASSWORD',
'CONTROLPLANE_OPERATOR_EMAIL',
]);
try {
// ── 1. Schema migration ──────────────────────────────────────────
logger.info('Running control plane schema migration...');
appendSetupLog('schema migration: starting');
await runSchemaMigration(pool);
logger.info('Schema ready');
appendSetupLog('schema migration: ready');
await syncControlplaneAgentApiKeysFromDisk(pool);
// ── 2. Provision default agents ──────────────────────────────────
const defaultAgents = getDefaultAgents(TENANT_ID);
logger.info('Provisioning default agents...');
for (const agent of defaultAgents) {
await upsertAgent(pool, agent);
logger.info({ id: agent.id, role: agent.role }, 'Agent ready');
appendSetupLog(`agent ready: ${agent.id} (${agent.role})`);
}
// ── 3. Create operator account ───────────────────────────────────
const operatorName = (
process.env.CONTROLPLANE_NAME || CONTROLPLANE_HOST_LABEL
).trim();
const existing = await getOperator(pool, operatorName);
let operatorPassword: string;
if (existing) {
logger.info({ username: operatorName }, 'Operator already exists');
operatorPassword = '(existing — check OPERATOR_PASSWORD in .env)';
appendSetupLog(`operator exists: ${operatorName}`);
} else {
operatorPassword = generatePassword(32);
await upsertOperator(pool, operatorName, operatorPassword);
logger.info({ username: operatorName }, 'Operator created');
appendSetupLog(
`operator created: ${operatorName} (password written to .env, not logged)`,
);
// Write password to .env if present
const envPath = path.join(process.cwd(), '.env');
if (fs.existsSync(envPath)) {
let envContent = fs.readFileSync(envPath, 'utf-8');
if (envContent.includes('OPERATOR_PASSWORD=')) {
envContent = envContent.replace(
/^OPERATOR_PASSWORD=.*$/m,
`OPERATOR_PASSWORD=${operatorPassword}`,
);
} else {
envContent += `\nOPERATOR_PASSWORD=${operatorPassword}\n`;
}
fs.writeFileSync(envPath, envContent, { mode: 0o600 });
logger.info('Wrote OPERATOR_PASSWORD to .env');
}
}
const operatorEmail = deriveControlplaneOperatorEmail({
operatorName,
explicitEmail:
process.env.CONTROLPLANE_OPERATOR_EMAIL ||
envSecrets.CONTROLPLANE_OPERATOR_EMAIL,
internalDomain: CONTROLPLANE_INTERNAL_DOMAIN,
publicBase: PLATFORM_PUBLIC_BASE || undefined,
});
const bootstrapPassword = existing
? (process.env.OPERATOR_PASSWORD || envSecrets.OPERATOR_PASSWORD || '').trim()
: operatorPassword;
let betterAuthStatus: 'created' | 'existing' | 'skipped' = 'skipped';
let betterAuthReason = '';
if (!BETTER_AUTH_SECRET.trim()) {
betterAuthReason =
'BETTER_AUTH_SECRET is unset; skipping Better Auth bootstrap.';
logger.info(betterAuthReason);
appendSetupLog(`better-auth bootstrap skipped: ${betterAuthReason}`);
} else if (!bootstrapPassword) {
betterAuthReason =
'OPERATOR_PASSWORD is unavailable for the existing operator; skipping Better Auth bootstrap.';
logger.warn(betterAuthReason);
appendSetupLog(`better-auth bootstrap skipped: ${betterAuthReason}`);
} else {
const bootstrapAuth = createControlplaneBootstrapAuth(pool, {
secret: BETTER_AUTH_SECRET,
baseURL: BETTER_AUTH_URL,
controlplaneApiPort: CONTROLPLANE_API_PORT,
internalBase: PLATFORM_INTERNAL_BASE,
internalDomain: CONTROLPLANE_INTERNAL_DOMAIN,
publicBase: PLATFORM_PUBLIC_BASE || undefined,
});
await runControlplaneBootstrapMigrations(bootstrapAuth);
betterAuthStatus = await ensureControlplaneBootstrapOperator(
bootstrapAuth,
{
name: operatorName,
email: operatorEmail,
password: bootstrapPassword,
},
);
appendSetupLog(
`better-auth bootstrap ${betterAuthStatus}: ${operatorEmail}`,
);
}
// ── 4. Seed budgets ──────────────────────────────────────────────
const dailyTokens = parseInt(
process.env.CONTROLPLANE_DAILY_TOKENS || '100000',
10,
);
for (const agent of defaultAgents) {
const agentTokens = Math.floor(
(dailyTokens * agent.budget_allocation_pct) / 100,
);
await upsertBudget(pool, agent.id, agentTokens);
logger.info({ agent: agent.id, tokens: agentTokens }, 'Budget seeded');
}
// ── 5. Copy skills ───────────────────────────────────────────────
const skillsSrc = path.join(process.cwd(), '.agent', 'skills');
const skillsDest = path.join(process.cwd(), 'data', 'skills');
copySkills(skillsSrc, skillsDest);
const skillCount = fs
.readdirSync(skillsDest, { withFileTypes: true })
.filter(
(e) =>
e.isDirectory() &&
fs.existsSync(path.join(skillsDest, e.name, 'SKILL.md')),
).length;
logger.info({ count: skillCount, dest: skillsDest }, 'Skills copied');
// ── Summary ──────────────────────────────────────────────────────
const agents = await getAgents(pool);
for (const agent of agents) {
await ensureControlplaneAgentApiKey(pool, agent.id);
}
const dashboardDir = CONTROLPLANE_DASHBOARD_DIR;
const dashboardUrl = `http://${CONTROLPLANE_INTERNAL_DOMAIN}:${process.env.CONTROLPLANE_PORT || 3100}/dashboard/`;
emitStatus('SETUP_CONTROLPLANE', {
STATUS: 'success',
AGENTS: agents.map((a) => a.id).join(', '),
OPERATOR: operatorName,
OPERATOR_EMAIL: operatorEmail,
OPERATOR_PASSWORD: existing
? '(unchanged)'
: '(printed to terminal — see console output, not logged)',
BETTER_AUTH_BOOTSTRAP: betterAuthStatus,
BETTER_AUTH_BOOTSTRAP_REASON: betterAuthReason || '(none)',
DAILY_TOKENS: dailyTokens,
SKILLS_COPIED: skillCount,
DASHBOARD: dashboardUrl,
DASHBOARD_DIR: dashboardDir,
LOG,
});
appendSetupLog(
`success: agents=${agents.length} operator=${operatorName} skills=${skillCount}`,
);
console.log('\n✓ Control plane ready');
console.log(` Agents: ${agents.map((a) => a.id).join(', ')}`);
console.log(` Operator: ${operatorName}`);
console.log(` Email: ${operatorEmail}`);
if (!existing) {
console.log(
` Password: ${operatorPassword} ← save this! (also stored in .env)`,
);
console.log(
` ⚠ do not redirect/tee this output — password is on stdout only`,
);
}
console.log(` Web auth: ${betterAuthStatus}`);
if (betterAuthReason) {
console.log(` Note: ${betterAuthReason}`);
}
console.log(` Skills: ${skillCount} copied to data/skills/`);
console.log(` Dashboard: ${dashboardUrl}`);
console.log(` Dash dir: ${dashboardDir}\n`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
emitStatus('SETUP_CONTROLPLANE', { STATUS: 'failed', ERROR: message, LOG });
throw error;
} finally {
await pool.end();
}
}