New test files (83 tests): - src/agent-identity.test.ts — resolveAgentIdentity across locales/genders - src/env.test.ts — readEnvFile parsing, quoting, edge cases - src/jail-registry.test.ts — getJailIp with/without env override - src/local-hosts.test.ts — block markers, entries, render, upsert - src/mount-security.test.ts — validateMount allowlist enforcement - src/transcription.test.ts — initTranscription + transcribeAudio with mocked OpenAI setup/ TypeScript audit (tsconfig.setup.json): - agent-jails: JAILS value serialised to JSON string for emitStatus - environment.test: use import type for pg.Pool type cast - onboarding: wrap showProfileMenu in normalizePiTuiProfile - preflight.test: fix process.exit mock type + typed call array casts - sanoid: execSync → spawnSync for multi-arg zfs invocation - skills-memory: bracket access for legacy chunking_version field - upstream: pass process.cwd() to isGitRepo() - verify: import StripeKeyMode type, annotate stripeKeyMode variable Full suite: 69 files, 1162 tests passing; tsc --noEmit clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- Build: pass | Tests: pass — Tests 1162 passed (1162) --- Build: pass | Tests: pass — Tests 1162 passed (1162)
286 lines
7.7 KiB
TypeScript
286 lines
7.7 KiB
TypeScript
/**
|
|
* Step: skills-memory — bootstrap built-in knowledge into the skills DB.
|
|
*
|
|
* Goal: reduce install/onboarding LLM calls by importing a checked-in artifact
|
|
* instead of generating knowledge live during first setup.
|
|
*/
|
|
import { execFileSync } from 'child_process';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import pg from 'pg';
|
|
|
|
import {
|
|
AGENT_NAME,
|
|
SKILLS_DB_NAME,
|
|
SKILLS_DB_URL,
|
|
SKILLS_DB_USER,
|
|
} from '../src/config.js';
|
|
import {
|
|
BUILTIN_KNOWLEDGE_ARTIFACT_SQL as ARTIFACT_SQL,
|
|
BUILTIN_KNOWLEDGE_BOOTSTRAP_DIR as BOOTSTRAP_DIR,
|
|
BUILTIN_KNOWLEDGE_METADATA_JSON as METADATA_JSON,
|
|
readBuiltinKnowledgeMetadata as readMetadata,
|
|
} from '../src/builtin-knowledge-artifact.js';
|
|
import { logger } from '../src/logger.js';
|
|
import { readEnvFile } from '../src/env.js';
|
|
import { commandExists, getPlatform } from './platform.js';
|
|
import { emitStatus } from './status.js';
|
|
import { jailExists, jailRoot } from './bastille-helpers.js';
|
|
|
|
function getSkillsDbUrl(): string {
|
|
return SKILLS_DB_URL;
|
|
}
|
|
|
|
function statusReport(
|
|
status: string,
|
|
extra: Record<string, string | number | boolean> = {},
|
|
): void {
|
|
const metadata = readMetadata();
|
|
emitStatus('SETUP_SKILLS_MEMORY', {
|
|
STATUS: status,
|
|
PSQL: commandExists('psql'),
|
|
ARTIFACT_SQL: fs.existsSync(ARTIFACT_SQL),
|
|
METADATA_JSON: fs.existsSync(METADATA_JSON),
|
|
BOOTSTRAP_DIR: BOOTSTRAP_DIR,
|
|
EMBEDDING_MODEL: String(metadata?.embedding_model ?? 'unknown'),
|
|
ARTIFACT_VERSION: String(metadata?.artifact_version ?? 'unknown'),
|
|
CHUNKING_VERSION: String(
|
|
metadata?.chunker_version ?? (metadata as Record<string, unknown> | undefined)?.['chunking_version'] ?? 'unknown',
|
|
),
|
|
...extra,
|
|
LOG: 'logs/setup.log',
|
|
});
|
|
}
|
|
|
|
function jailRootLocal(jailName: string): string {
|
|
return `/usr/local/bastille/jails/${jailName}/root`;
|
|
}
|
|
|
|
function detectDbJailName(): string {
|
|
const envOverrides = readEnvFile(['DB_JAIL_NAME']);
|
|
const explicit = (
|
|
process.env.DB_JAIL_NAME ||
|
|
envOverrides.DB_JAIL_NAME ||
|
|
''
|
|
).trim();
|
|
if (explicit) return explicit;
|
|
|
|
const safeAgentName = AGENT_NAME.replace(/[-_]/g, '');
|
|
const preferred = `${safeAgentName}db`;
|
|
const legacyHyphen = `${AGENT_NAME}-db`;
|
|
const legacy = 'db';
|
|
|
|
if (jailExists(preferred)) return preferred;
|
|
if (jailExists(legacyHyphen)) return legacyHyphen;
|
|
if (jailExists(legacy)) return legacy;
|
|
return preferred;
|
|
}
|
|
|
|
function importArtifactViaBastille(jailName: string, dbName: string): void {
|
|
const projectRoot = process.cwd();
|
|
const tmpDir = path.join(projectRoot, 'tmp');
|
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
|
|
const stamp = Date.now();
|
|
const hostTmp = path.join(tmpDir, `skills-artifact-${stamp}.sql`);
|
|
fs.copyFileSync(ARTIFACT_SQL, hostTmp);
|
|
|
|
const jailTmpRel = path.join('var', 'tmp', `skills-artifact-${stamp}.sql`);
|
|
const jailTmpHostPath = path.join(jailRootLocal(jailName), jailTmpRel);
|
|
fs.mkdirSync(path.dirname(jailTmpHostPath), { recursive: true });
|
|
fs.copyFileSync(hostTmp, jailTmpHostPath);
|
|
|
|
try {
|
|
logger.info(
|
|
{ artifact: ARTIFACT_SQL, jailName },
|
|
'Importing built-in knowledge artifact',
|
|
);
|
|
execFileSync(
|
|
'bastille',
|
|
[
|
|
'cmd',
|
|
jailName,
|
|
'su',
|
|
'-m',
|
|
'postgres',
|
|
'-c',
|
|
`psql -v ON_ERROR_STOP=1 -d ${dbName} -f /${jailTmpRel}`,
|
|
],
|
|
{ stdio: 'inherit' },
|
|
);
|
|
} finally {
|
|
fs.rmSync(hostTmp, { force: true });
|
|
fs.rmSync(jailTmpHostPath, { force: true });
|
|
}
|
|
}
|
|
|
|
function grantSkillsReadAccess(
|
|
jailName: string,
|
|
dbName: string,
|
|
dbUser: string,
|
|
): void {
|
|
const grantSql = [
|
|
`GRANT USAGE ON SCHEMA public TO ${dbUser};`,
|
|
`GRANT SELECT ON ALL TABLES IN SCHEMA public TO ${dbUser};`,
|
|
`GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO ${dbUser};`,
|
|
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO ${dbUser};`,
|
|
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON SEQUENCES TO ${dbUser};`,
|
|
].join(' ');
|
|
|
|
execFileSync(
|
|
'bastille',
|
|
[
|
|
'cmd',
|
|
jailName,
|
|
'su',
|
|
'-m',
|
|
'postgres',
|
|
'-c',
|
|
`psql -v ON_ERROR_STOP=1 -d ${dbName} -c ${JSON.stringify(grantSql)}`,
|
|
],
|
|
{ stdio: 'inherit' },
|
|
);
|
|
}
|
|
|
|
function applyPermissionsSql(
|
|
jailName: string,
|
|
dbName: string,
|
|
dbUser: string,
|
|
): void {
|
|
const projectRoot = process.cwd();
|
|
const permissionsSql = path.join(
|
|
projectRoot,
|
|
'docs',
|
|
'internal',
|
|
'sql',
|
|
'permissions.sql',
|
|
);
|
|
|
|
if (!fs.existsSync(permissionsSql)) {
|
|
logger.warn(
|
|
{ permissions: permissionsSql },
|
|
'permissions.sql not found, skipping',
|
|
);
|
|
return;
|
|
}
|
|
|
|
const tmpDir = path.join(projectRoot, 'tmp');
|
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
|
|
const stamp = Date.now();
|
|
const hostTmp = path.join(tmpDir, `permissions-${stamp}.sql`);
|
|
fs.copyFileSync(permissionsSql, hostTmp);
|
|
|
|
const jailTmpRel = path.join('var', 'tmp', `permissions-${stamp}.sql`);
|
|
const jailTmpHostPath = path.join(jailRootLocal(jailName), jailTmpRel);
|
|
fs.mkdirSync(path.dirname(jailTmpHostPath), { recursive: true });
|
|
fs.copyFileSync(hostTmp, jailTmpHostPath);
|
|
|
|
try {
|
|
logger.info(
|
|
{ permissions: permissionsSql, jailName, dbUser },
|
|
'Applying permissions.sql',
|
|
);
|
|
execFileSync(
|
|
'bastille',
|
|
[
|
|
'cmd',
|
|
jailName,
|
|
'su',
|
|
'-m',
|
|
'postgres',
|
|
'-c',
|
|
`psql -v db_user=${dbUser} -d ${dbName} -f /${jailTmpRel}`,
|
|
],
|
|
{ stdio: 'inherit' },
|
|
);
|
|
} finally {
|
|
fs.rmSync(hostTmp, { force: true });
|
|
fs.rmSync(jailTmpHostPath, { force: true });
|
|
}
|
|
}
|
|
|
|
export function importArtifact(): void {
|
|
if (!fs.existsSync(ARTIFACT_SQL)) {
|
|
throw new Error('missing_skills_memory_artifact');
|
|
}
|
|
|
|
if (getPlatform() === 'freebsd' && commandExists('bastille')) {
|
|
const jailName = detectDbJailName();
|
|
importArtifactViaBastille(jailName, SKILLS_DB_NAME);
|
|
grantSkillsReadAccess(jailName, SKILLS_DB_NAME, SKILLS_DB_USER);
|
|
return;
|
|
}
|
|
|
|
if (!commandExists('psql')) {
|
|
throw new Error('missing_psql');
|
|
}
|
|
|
|
const dbUrl = getSkillsDbUrl();
|
|
logger.info(
|
|
{ artifact: ARTIFACT_SQL },
|
|
'Importing built-in knowledge artifact',
|
|
);
|
|
execFileSync('psql', ['-v', 'ON_ERROR_STOP=1', dbUrl, '-f', ARTIFACT_SQL], {
|
|
stdio: 'inherit',
|
|
});
|
|
}
|
|
|
|
async function verifyImport(): Promise<Record<string, number>> {
|
|
const { Client } = pg;
|
|
const client = new Client({
|
|
connectionString: getSkillsDbUrl(),
|
|
connectionTimeoutMillis: 5000,
|
|
});
|
|
await client.connect();
|
|
try {
|
|
const [artifacts, documents, chunks] = await Promise.all([
|
|
client.query<{ count: string }>(
|
|
'SELECT count(*) FROM builtin_knowledge_artifacts',
|
|
),
|
|
client.query<{ count: string }>(
|
|
'SELECT count(*) FROM builtin_knowledge_documents',
|
|
),
|
|
client.query<{ count: string }>(
|
|
'SELECT count(*) FROM builtin_knowledge_chunks',
|
|
),
|
|
]);
|
|
|
|
return {
|
|
artifacts: Number(artifacts.rows[0]?.count ?? 0),
|
|
documents: Number(documents.rows[0]?.count ?? 0),
|
|
chunks: Number(chunks.rows[0]?.count ?? 0),
|
|
};
|
|
} finally {
|
|
await client.end();
|
|
}
|
|
}
|
|
|
|
export async function run(args: string[]): Promise<void> {
|
|
const doImport = args.includes('--import');
|
|
const doStatus = args.includes('--status') || !doImport;
|
|
|
|
if (doStatus && !doImport) {
|
|
let counts: Record<string, number> = {};
|
|
try {
|
|
counts = await verifyImport();
|
|
} catch {
|
|
counts = {};
|
|
}
|
|
statusReport('ready', {
|
|
ARTIFACT_ROWS: counts.artifacts ?? 0,
|
|
DOCUMENT_ROWS: counts.documents ?? 0,
|
|
CHUNK_ROWS: counts.chunks ?? 0,
|
|
});
|
|
return;
|
|
}
|
|
|
|
importArtifact();
|
|
const counts = await verifyImport();
|
|
statusReport('imported', {
|
|
DB_URL_TARGET: getSkillsDbUrl(),
|
|
ARTIFACT_ROWS: counts.artifacts,
|
|
DOCUMENT_ROWS: counts.documents,
|
|
CHUNK_ROWS: counts.chunks,
|
|
});
|
|
}
|