clawdie-ai/setup/service.ts
Operator & Codex dcae5878fa Replace jail sudo path with hostd bastille-cmd
Route jail exec through a new hostd bastille-cmd operation, remove the agent-jail sudoers requirement, and fall back to repository ownership when elevation does not provide SUDO_* metadata.

---
Build: pass | Tests: n/a (Vitest not run in this Linux environment per repo policy)

---
Build: pass | Tests: pass — 2375 passed (704 files)
2026-05-10 22:48:04 +02:00

516 lines
16 KiB
TypeScript

/**
* Step: service — Build project and install the FreeBSD rc.d service.
*
* Service name derives from the platform identity, not the tenant identity.
* Root install: writes /usr/local/etc/rc.d/{SERVICE_NAME} + enables it.
* Non-root: generates start-{SERVICE_NAME}.sh / stop-{SERVICE_NAME}.sh wrappers.
*/
import { SERVICE_NAME } from '../src/platform-identity.js';
import { execFileSync, execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import {
DB_RUNTIME,
PLATFORM_RUNTIME_HOME,
} from '../src/config.js';
import { logger } from '../src/logger.js';
import {
commandExists,
getNodePath,
getNpmPath,
getPlatform,
isRoot,
} from './platform.js';
import { emitStatus } from './status.js';
export type BootMode = 'YES' | 'AUTO' | 'NONE';
function lookupUserByUid(uid: number): string | null {
try {
const user = execFileSync('id', ['-nu', String(uid)], {
encoding: 'utf-8',
}).trim();
return user || null;
} catch {
return null;
}
}
function getInvokingContext(projectRoot: string): {
user: string | null;
uid: number | null;
gid: number | null;
} {
const user = (process.env.SUDO_USER || '').trim() || null;
const uidRaw = (process.env.SUDO_UID || '').trim();
const gidRaw = (process.env.SUDO_GID || '').trim();
const uid = uidRaw ? Number(uidRaw) : null;
const gid = gidRaw ? Number(gidRaw) : null;
if (user && Number.isFinite(uid) && Number.isFinite(gid)) {
return {
user,
uid,
gid,
};
}
try {
const stat = fs.statSync(projectRoot);
return {
user: stat.uid === 0 ? null : lookupUserByUid(stat.uid),
uid: stat.uid,
gid: stat.gid,
};
} catch {
// fall through
}
return {
user: null,
uid: null,
gid: null,
};
}
function buildProject(projectRoot: string): void {
const invoking = getInvokingContext(projectRoot);
// Resolve npm's absolute path before entering the su subprocess.
// 'su -m' preserves the environment but when invoked via sudo, PATH may not
// include the versioned npm location — using the full path avoids the issue.
const npmPath = getNpmPath();
if (isRoot() && invoking.user && invoking.uid !== 0) {
execFileSync(
'su',
[
'-m',
invoking.user,
'-c',
`cd ${JSON.stringify(projectRoot)} && ${JSON.stringify(npmPath)} run build`,
],
{
stdio: ['ignore', 'pipe', 'pipe'],
encoding: 'utf-8',
},
);
return;
}
execFileSync(npmPath, ['run', 'build'], {
cwd: projectRoot,
stdio: ['ignore', 'pipe', 'pipe'],
});
}
function chownIfInvokingContext(filePath: string, projectRoot: string): void {
const invoking = getInvokingContext(projectRoot);
if (!isRoot() || invoking.uid === null || invoking.gid === null || invoking.uid === 0) {
return;
}
try {
fs.chownSync(filePath, invoking.uid, invoking.gid);
} catch {
// best-effort only
}
}
// Recursively chown a directory to the agent user.
// Used after root creates runtime dirs so the agent process (which runs as the
// named user via daemon -u) can write to them without EACCES on first startup.
function chownRuntimeDir(dirPath: string, agentName: string): void {
if (!isRoot()) return;
try {
execFileSync('chown', ['-R', `${agentName}:${agentName}`, dirPath], {
stdio: 'ignore',
});
} catch {
logger.warn(
{ dirPath, agentName },
'chown of runtime dir failed (best-effort)',
);
}
}
export interface PlatformServiceRuntime {
serviceName: string;
runtimeUser: string;
runtimeHome: string;
}
export function resolvePlatformServiceRuntime(): PlatformServiceRuntime {
return {
serviceName: SERVICE_NAME,
runtimeUser: SERVICE_NAME,
runtimeHome: PLATFORM_RUNTIME_HOME,
};
}
interface ServiceArgs {
bootMode: BootMode | null;
nonInteractive: boolean;
}
function parseArgs(args: string[]): ServiceArgs {
const result: ServiceArgs = { bootMode: null, nonInteractive: false };
for (let i = 0; i < args.length; i++) {
if (args[i] === '--non-interactive') {
result.nonInteractive = true;
continue;
}
if (args[i] === '--boot-mode' && args[i + 1]) {
const raw = (args[++i] || '').trim().toUpperCase();
if (raw === 'YES' || raw === 'AUTO' || raw === 'NONE') {
result.bootMode = raw;
}
}
}
return result;
}
function writeWrapper(filePath: string, contents: string, mode = 0o755): void {
fs.writeFileSync(filePath, contents, { mode });
chownIfInvokingContext(filePath, process.cwd());
}
export function generateRunScript(
runtime: PlatformServiceRuntime,
nodePath: string,
projectRoot: string,
): string {
const envFile = path.join(projectRoot, '.env');
return [
'#!/bin/sh',
`export HOME=${JSON.stringify(runtime.runtimeHome)}`,
`export PATH="/usr/local/bin:/usr/bin:/bin:${runtime.runtimeHome}/.local/bin"`,
'export NODE_ENV="production"',
'',
"# Locale/timezone for service runtime (don't rely on login shell env)",
`SERVICE_TZ=$(grep "^TZ=" ${JSON.stringify(envFile)} 2>/dev/null | head -1 | cut -d= -f2- | sed "s/^[\\\"']//; s/[\\\"']$//" || echo "")`,
`SERVICE_LANG=$(grep "^SYSTEM_LOCALE=" ${JSON.stringify(envFile)} 2>/dev/null | head -1 | cut -d= -f2- | sed "s/^[\\\"']//; s/[\\\"']$//" || echo "")`,
'export TZ="${SERVICE_TZ:-Europe/Ljubljana}"',
'export LANG="${SERVICE_LANG:-sl_SI.UTF-8}"',
'export LC_ALL="${SERVICE_LANG:-sl_SI.UTF-8}"',
`cd ${JSON.stringify(projectRoot)}`,
'',
'# Ensure tmux session exists for interactive access',
`tmux new-session -d -s ${JSON.stringify(runtime.serviceName)} -n main 2>/dev/null || true`,
'',
'# If PI_TUI_PROFILE=setup, open pi-tui in a separate tmux window',
`PROFILE=$(grep "^PI_TUI_PROFILE=" ${JSON.stringify(envFile)} 2>/dev/null | cut -d= -f2 | sed "s/^[\\\"']//; s/[\\\"']$//" || echo "")`,
'if [ "$PROFILE" = "setup" ]; then',
` tmux new-window -t ${JSON.stringify(runtime.serviceName)} -n setup 2>/dev/null || true`,
` tmux send-keys -t ${JSON.stringify(runtime.serviceName + ':setup')} "npm run wizard" Enter`,
'fi',
'',
'# Agent runs as foreground process — what daemon monitors',
`exec ${JSON.stringify(nodePath)} ${JSON.stringify(path.join(projectRoot, 'dist/index.js'))}`,
'',
].join('\n');
}
function escapeForDoubleQuotedShell(raw: string): string {
return raw.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
}
export function generateRcdService(
runtime: PlatformServiceRuntime,
projectRoot: string,
logPath: string,
): string {
const envFile = path.join(projectRoot, '.env');
const requireTargets =
DB_RUNTIME === 'host' ? 'NETWORKING LOGIN postgresql' : 'NETWORKING LOGIN';
return [
'#!/bin/sh',
'#',
`# PROVIDE: ${runtime.serviceName}`,
`# REQUIRE: ${requireTargets}`,
'# KEYWORD: shutdown',
'',
'. /etc/rc.subr',
'',
`name="${runtime.serviceName}"`,
`rcvar="${runtime.serviceName}_enable"`,
`pidfile="/var/run/\${name}.pid"`,
'command="/usr/sbin/daemon"',
// -u drops from root to the agent user before exec — hostd stays root (separate service)
`command_args="-u ${runtime.runtimeUser} -P \${pidfile} -r -f -o \\"${escapeForDoubleQuotedShell(logPath)}\\" \\"${escapeForDoubleQuotedShell(path.join(projectRoot, `run-${runtime.serviceName}.sh`))}\\""`,
'',
'load_rc_config $name',
`: \$\{${runtime.serviceName}_enable:="NONE"\}`,
'',
`case "\$\{${runtime.serviceName}_enable\}" in`,
' [Nn][Oo][Nn][Ee])',
' if [ "$1" = "start" ] || [ "$1" = "restart" ]; then',
' echo ""',
` echo " ${runtime.serviceName} is installed but autostart is not configured."`,
' echo " Choose how it should start at boot:"',
' echo ""',
` echo " sudo sysrc ${runtime.serviceName}_enable=AUTO # Start when ready (recommended)"`,
` echo " sudo sysrc ${runtime.serviceName}_enable=YES # Always start at boot"`,
' echo ""',
` echo " To start just this once without changing boot settings:"`,
` echo " sudo service ${runtime.serviceName} onestart"`,
' echo ""',
' fi',
' exit 0',
' ;;',
' [Aa][Uu][Tt][Oo])',
` if [ ! -f ${JSON.stringify(envFile)} ] || ! grep -q "^TELEGRAM_BOT_TOKEN=." ${JSON.stringify(envFile)}; then`,
' [ -t 1 ] && echo "AUTO: not fully configured yet, skipping start."',
' exit 0',
' fi',
` ${runtime.serviceName}_enable="YES"`,
' ;;',
'esac',
'',
'run_rc_command "$1"',
'',
].join('\n');
}
function generateStartWrapper(
serviceName: string,
nodePath: string,
projectRoot: string,
pidFile: string,
): string {
return [
'#!/bin/sh',
'set -eu',
`cd ${JSON.stringify(projectRoot)}`,
`if [ -f ${JSON.stringify(pidFile)} ]; then`,
` OLD_PID=$(cat ${JSON.stringify(pidFile)} 2>/dev/null || true)`,
' if [ -n "${OLD_PID:-}" ] && kill -0 "$OLD_PID" 2>/dev/null; then',
` echo "${serviceName} is already running" >&2`,
' exit 1',
' fi',
'fi',
`nohup ${JSON.stringify(nodePath)} ${JSON.stringify(path.join(projectRoot, 'dist/index.js'))} >> ${JSON.stringify(path.join(projectRoot, 'logs', `${serviceName}.log`))} 2>> ${JSON.stringify(path.join(projectRoot, 'logs', `${serviceName}.error.log`))} &`,
`echo $! > ${JSON.stringify(pidFile)}`,
`echo "Started ${serviceName}"`,
'',
].join('\n');
}
function generateStopWrapper(serviceName: string, pidFile: string): string {
return [
'#!/bin/sh',
'set -eu',
`if [ ! -f ${JSON.stringify(pidFile)} ]; then`,
' echo "No PID file found" >&2',
' exit 1',
'fi',
`PID=$(cat ${JSON.stringify(pidFile)})`,
'kill "$PID"',
`rm -f ${JSON.stringify(pidFile)}`,
`echo "Stopped ${serviceName}"`,
'',
].join('\n');
}
function promptBootMode(agentName: string): BootMode {
if (
!commandExists('bsddialog') ||
!process.stdin.isTTY ||
!process.stdout.isTTY
) {
return 'NONE';
}
try {
const selected = execFileSync(
'bsddialog',
[
'--stdout',
'--title',
'Autostart',
'--menu',
`Should ${agentName} start automatically when this computer boots?\n\nAUTO is recommended — it starts only when your setup is complete.\nIf you are not ready, it waits quietly.`,
'0',
'0',
'3',
'AUTO',
'Start when ready — recommended',
'YES',
'Always start at boot',
'NONE',
'Decide later',
],
{ encoding: 'utf-8', stdio: ['inherit', 'pipe', 'inherit'] },
).trim() as BootMode;
const valid: BootMode[] = ['YES', 'AUTO', 'NONE'];
return valid.includes(selected) ? selected : 'NONE';
} catch {
return 'NONE';
}
}
function printHandoff(
agentName: string,
bootMode: BootMode,
rcdPath: string,
): void {
const line = '─'.repeat(52);
console.log('');
console.log(line);
console.log(` ${agentName} is installed and ready.`);
console.log('');
const modeDesc: Record<BootMode, string> = {
AUTO: 'Smart start — wakes up when configured',
YES: 'Always starts at boot',
NONE: 'Not yet configured — start manually for now',
};
console.log(` Autostart: ${bootMode} (${modeDesc[bootMode]})`);
console.log('');
console.log(' Start now:');
console.log(` sudo service ${agentName} onestart`);
console.log('');
console.log(' Watch logs:');
console.log(` tail -f logs/${agentName}.log`);
console.log('');
console.log(' Change autostart later:');
console.log(` sudo sysrc ${agentName}_enable=AUTO # smart start`);
console.log(` sudo sysrc ${agentName}_enable=YES # always on`);
console.log(
` sudo sysrc ${agentName}_enable=NONE # reset to undecided`,
);
console.log('');
console.log(` Service installed at: ${rcdPath}`);
console.log(line);
console.log('');
}
export async function run(_args: string[]): Promise<void> {
const args = parseArgs(_args);
const projectRoot = process.cwd();
const platform = getPlatform();
const nodePath = getNodePath();
const runtime = resolvePlatformServiceRuntime();
logger.info(
{ platform, nodePath, projectRoot, runtime },
'Setting up service',
);
if (platform !== 'freebsd') {
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: 'unknown',
SERVICE_NAME: runtime.serviceName,
NODE_PATH: nodePath,
PROJECT_PATH: projectRoot,
STATUS: 'failed',
ERROR: 'unsupported_platform',
LOG: 'logs/setup.log',
});
process.exit(1);
}
logger.info('Building TypeScript');
try {
buildProject(projectRoot);
} catch {
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: 'unknown',
SERVICE_NAME: runtime.serviceName,
NODE_PATH: nodePath,
PROJECT_PATH: projectRoot,
STATUS: 'failed',
ERROR: 'build_failed',
LOG: 'logs/setup.log',
});
process.exit(1);
}
// Pre-create runtime dirs so they exist before the agent first starts.
// When running as root (installer), chown them to the agent user so the
// daemon -u privilege drop doesn't cause EACCES on first write.
const runtimeDirs = ['logs', 'data', 'groups'];
for (const dir of runtimeDirs) {
const dirPath = path.join(projectRoot, dir);
fs.mkdirSync(dirPath, { recursive: true });
chownRuntimeDir(dirPath, runtime.runtimeUser);
}
const pidFile = path.join(projectRoot, `${runtime.serviceName}.pid`);
const logPath = path.join(projectRoot, 'logs', `${runtime.serviceName}.log`);
if (isRoot()) {
// Root install: rc.d service + run wrapper
const runScriptPath = path.join(
projectRoot,
`run-${runtime.serviceName}.sh`,
);
const rcdPath = `/usr/local/etc/rc.d/${runtime.serviceName}`;
writeWrapper(
runScriptPath,
generateRunScript(runtime, nodePath, projectRoot),
);
logger.info({ runScriptPath }, 'Wrote run wrapper');
writeWrapper(rcdPath, generateRcdService(runtime, projectRoot, logPath));
logger.info({ rcdPath }, 'Wrote rc.d service');
const bootMode =
args.bootMode ??
(args.nonInteractive ? 'AUTO' : promptBootMode(runtime.serviceName));
try {
execSync(`sysrc ${runtime.serviceName}_enable=${bootMode}`, {
stdio: 'ignore',
});
} catch (err) {
logger.warn({ err }, 'sysrc enable failed');
}
let serviceRunning = false;
if (bootMode === 'YES') {
try {
execSync(`service ${runtime.serviceName} start`, { stdio: 'ignore' });
execSync(`service ${runtime.serviceName} status`, { stdio: 'ignore' });
serviceRunning = true;
} catch {
// May not be running yet
}
}
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: 'freebsd-rcd',
SERVICE_NAME: runtime.serviceName,
NODE_PATH: nodePath,
PROJECT_PATH: projectRoot,
RCD_PATH: rcdPath,
RUN_SCRIPT: runScriptPath,
SERVICE_RUNNING: serviceRunning,
BOOT_MODE: bootMode,
STATUS: 'success',
LOG: 'logs/setup.log',
});
printHandoff(runtime.serviceName, bootMode, rcdPath);
} else {
// Non-root: nohup wrappers
const startPath = path.join(projectRoot, `start-${runtime.serviceName}.sh`);
const stopPath = path.join(projectRoot, `stop-${runtime.serviceName}.sh`);
writeWrapper(
startPath,
generateStartWrapper(runtime.serviceName, nodePath, projectRoot, pidFile),
);
writeWrapper(stopPath, generateStopWrapper(runtime.serviceName, pidFile));
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: 'freebsd-wrapper',
SERVICE_NAME: runtime.serviceName,
NODE_PATH: nodePath,
PROJECT_PATH: projectRoot,
START_PATH: startPath,
STOP_PATH: stopPath,
PID_FILE: pidFile,
STATUS: 'success',
LOG: 'logs/setup.log',
});
console.log(
`Generated start-${runtime.serviceName}.sh and stop-${runtime.serviceName}.sh`,
);
}
}