Clear setup token file after completion
--- Build: pass | Tests: pass — 2449 passed (182 files)
This commit is contained in:
parent
f6c268e5bd
commit
f04f35eb4e
3 changed files with 183 additions and 2 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue