Clear setup token file after completion

---
Build: pass | Tests: pass — 2449 passed (182 files)
This commit is contained in:
Operator & Codex 2026-05-12 16:18:08 +02:00
parent f6c268e5bd
commit f04f35eb4e
3 changed files with 183 additions and 2 deletions

View file

@ -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<void> {
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();

View file

@ -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);
});
});

View file

@ -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<string, string | null> = {
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<PostInstallSetupState> {
export async function completePostInstallSetup(
pool: Pool,
opts: { tokenFile?: string; tokenFileDeps?: SetupTokenFileDeps } = {},
): Promise<PostInstallSetupState> {
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;
}