diff --git a/scripts/setup-token.ts b/scripts/setup-token.ts index d505d37..4981fd8 100644 --- a/scripts/setup-token.ts +++ b/scripts/setup-token.ts @@ -1,8 +1,15 @@ #!/usr/bin/env tsx +import fs from 'fs'; +import path from 'path'; + import pg from 'pg'; import { OPS_DB_URL } from '../src/config.js'; -import { rotateBootstrapToken } from '../src/postinstall-setup.js'; +import { + rotateBootstrapToken, + setupTokenFilePath, + writeSetupTokenFile, +} from '../src/postinstall-setup.js'; import { runSchemaMigration } from '../src/controlplane-db.js'; function usage(): never { @@ -18,9 +25,19 @@ async function main(): Promise { try { await runSchemaMigration(pool); const result = await rotateBootstrapToken(pool); + const tokenFile = setupTokenFilePath(); + const shouldRefreshTokenFile = + Boolean(process.env.CLAWDIE_SETUP_TOKEN_FILE) || + fs.existsSync(tokenFile) || + fs.existsSync(path.dirname(tokenFile)); + const tokenFileResult = shouldRefreshTokenFile + ? writeSetupTokenFile(result.token, tokenFile) + : { ok: false, path: tokenFile }; + console.log('Clawdie setup bootstrap token:'); console.log(result.token); console.log(`Expires: ${result.expiresAt.toISOString()}`); + if (tokenFileResult.ok) console.log(`Token file updated: ${tokenFileResult.path}`); console.log('Use this at /setup. It will be invalidated when setup completes.'); } finally { await pool.end(); diff --git a/src/postinstall-setup.test.ts b/src/postinstall-setup.test.ts index ea21acc..ff687a0 100644 --- a/src/postinstall-setup.test.ts +++ b/src/postinstall-setup.test.ts @@ -4,11 +4,14 @@ import path from 'path'; import { describe, expect, it, beforeEach, vi } from 'vitest'; import { + clearSetupTokenFile, + completePostInstallSetup, configurePostInstallProvider, generateBootstrapToken, rotateBootstrapToken, upsertEnvValues, verifyBootstrapToken, + writeSetupTokenFile, type PostInstallSetupState, } from './postinstall-setup.js'; @@ -153,4 +156,50 @@ describe('post-install setup', () => { expect(content).toContain('ANTHROPIC_API_KEY=sk-ant-secret'); expect(events.some((event) => JSON.stringify(event).includes('sk-ant-secret'))).toBe(false); }); + + it('writes and clears the local setup token file without logging token contents', () => { + const tokenFile = path.join(tmpDir, 'setup-token'); + + const writeResult = writeSetupTokenFile('cleartext-token-for-operator', tokenFile); + expect(writeResult).toMatchObject({ ok: true, method: 'write' }); + expect(fs.readFileSync(tokenFile, 'utf-8')).toBe('cleartext-token-for-operator\n'); + + const clearResult = clearSetupTokenFile(tokenFile); + expect(clearResult).toMatchObject({ ok: true, method: 'unlink' }); + expect(fs.existsSync(tokenFile)).toBe(false); + }); + + it('truncates the setup token file if unlink is unavailable', () => { + const tokenFile = path.join(tmpDir, 'setup-token'); + fs.writeFileSync(tokenFile, 'secret-token\n', { mode: 0o600 }); + const unlinkSync = vi.fn(() => { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }); + }); + + const result = clearSetupTokenFile(tokenFile, { + existsSync: fs.existsSync, + mkdirSync: fs.mkdirSync, + unlinkSync, + writeFileSync: fs.writeFileSync, + chmodSync: fs.chmodSync, + }); + + expect(result).toMatchObject({ ok: true, method: 'truncate', error: 'EACCES' }); + expect(fs.readFileSync(tokenFile, 'utf-8')).toBe(''); + }); + + it('clears the setup token file when setup completes', async () => { + const { pool, events, state } = makePool(); + const tokenFile = path.join(tmpDir, 'setup-token'); + fs.writeFileSync(tokenFile, 'secret-token\n', { mode: 0o600 }); + + await completePostInstallSetup(pool, { tokenFile }); + + expect(state.status).toBe('complete'); + expect(state.setup_complete).toBe(true); + expect(fs.existsSync(tokenFile)).toBe(false); + expect(events.some((event) => event.eventType === 'setup_completed')).toBe(true); + expect(events.some((event) => event.eventType === 'setup_token_file_deleted')).toBe(true); + expect(events.some((event) => JSON.stringify(event).includes('secret-token'))).toBe(false); + }); }); diff --git a/src/postinstall-setup.ts b/src/postinstall-setup.ts index b85ef5b..f0d574c 100644 --- a/src/postinstall-setup.ts +++ b/src/postinstall-setup.ts @@ -50,6 +50,42 @@ const DEFAULT_TOKEN_BYTES = 32; const DEFAULT_BOOTSTRAP_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; const MAX_BOOTSTRAP_FAILURES = 5; const BOOTSTRAP_LOCK_MS = 5 * 60 * 1000; +const DEFAULT_SETUP_TOKEN_FILE = '/var/db/clawdie-installer/setup-token'; + +interface SetupTokenFileDeps { + existsSync: typeof fs.existsSync; + mkdirSync: typeof fs.mkdirSync; + unlinkSync: typeof fs.unlinkSync; + writeFileSync: typeof fs.writeFileSync; + chmodSync: typeof fs.chmodSync; +} + +const DEFAULT_TOKEN_FILE_DEPS: SetupTokenFileDeps = { + existsSync: fs.existsSync, + mkdirSync: fs.mkdirSync, + unlinkSync: fs.unlinkSync, + writeFileSync: fs.writeFileSync, + chmodSync: fs.chmodSync, +}; + +export interface SetupTokenFileResult { + path: string; + existed: boolean; + ok: boolean; + method?: 'unlink' | 'truncate' | 'write'; + error?: string; +} + +function safeErrorCode(err: unknown): string { + if (err && typeof err === 'object' && 'code' in err) { + return String((err as { code?: unknown }).code || 'error'); + } + return err instanceof Error ? err.name : 'error'; +} + +export function setupTokenFilePath(): string { + return process.env.CLAWDIE_SETUP_TOKEN_FILE || DEFAULT_SETUP_TOKEN_FILE; +} export const PROVIDER_KEY_BY_PROVIDER: Record = { anthropic: 'ANTHROPIC_API_KEY', @@ -110,6 +146,66 @@ export function generateBootstrapToken(bytes = DEFAULT_TOKEN_BYTES): string { return crypto.randomBytes(bytes).toString('base64url'); } +export function writeSetupTokenFile( + token: string, + tokenFile = setupTokenFilePath(), + deps: SetupTokenFileDeps = DEFAULT_TOKEN_FILE_DEPS, +): SetupTokenFileResult { + const trimmed = token.trim(); + if (!trimmed) { + return { path: tokenFile, existed: deps.existsSync(tokenFile), ok: false, error: 'empty_token' }; + } + const existed = deps.existsSync(tokenFile); + try { + if (!existed) deps.mkdirSync(path.dirname(tokenFile), { recursive: true }); + deps.writeFileSync(tokenFile, `${trimmed}\n`, { mode: 0o600 }); + try { + deps.chmodSync(tokenFile, 0o600); + } catch { + // Best effort only; some filesystems ignore chmod. + } + return { path: tokenFile, existed, ok: true, method: 'write' }; + } catch (err) { + return { path: tokenFile, existed, ok: false, error: safeErrorCode(err) }; + } +} + +export function clearSetupTokenFile( + tokenFile = setupTokenFilePath(), + deps: SetupTokenFileDeps = DEFAULT_TOKEN_FILE_DEPS, +): SetupTokenFileResult { + if (!deps.existsSync(tokenFile)) { + return { path: tokenFile, existed: false, ok: true }; + } + try { + deps.unlinkSync(tokenFile); + return { path: tokenFile, existed: true, ok: true, method: 'unlink' }; + } catch (unlinkErr) { + try { + deps.writeFileSync(tokenFile, '', { mode: 0o600 }); + try { + deps.chmodSync(tokenFile, 0o600); + } catch { + // Best effort only; some filesystems ignore chmod. + } + return { + path: tokenFile, + existed: true, + ok: true, + method: 'truncate', + error: safeErrorCode(unlinkErr), + }; + } catch (truncateErr) { + return { + path: tokenFile, + existed: true, + ok: false, + error: safeErrorCode(truncateErr), + }; + } + } +} + export async function rotateBootstrapToken( pool: Pool, opts: { ttlMs?: number; now?: Date } = {}, @@ -370,9 +466,28 @@ export async function configurePostInstallOperator( return state; } -export async function completePostInstallSetup(pool: Pool): Promise { +export async function completePostInstallSetup( + pool: Pool, + opts: { tokenFile?: string; tokenFileDeps?: SetupTokenFileDeps } = {}, +): Promise { const state = await setSetupStatus(pool, 'complete', 'completed_at'); await auditSetupEvent(pool, 'setup_completed', {}); + const cleanup = clearSetupTokenFile(opts.tokenFile, opts.tokenFileDeps); + if (cleanup.existed) { + try { + await auditSetupEvent( + pool, + cleanup.ok ? 'setup_token_file_deleted' : 'setup_token_file_delete_failed', + { + path: cleanup.path, + method: cleanup.method ?? null, + error: cleanup.error ?? null, + }, + ); + } catch { + // Setup completion must not fail if cleanup auditing is unavailable. + } + } return state; }