Repository Public
1/**2 * runtime-core shared helpers: shell quoting, environment hygiene, workspace containment,3 * atomic writes, per-app core context.4 */5import fs from 'node:fs/promises';6import { constants as fsConstants } from 'node:fs';7import path from 'node:path';8import os from 'node:os';9import type { FastifyInstance } from 'fastify';10import type { RuntimeConfig } from './config.js';11import { RtError } from './plugins.js';12import type { EventBus } from './events.js';13import type { Tmux } from './tmux.js';1415/** State shared by the runtime-core plugins of ONE runtime instance (decorated on the root app by main.ts). */16export interface CoreContext {17 config: RuntimeConfig;18 bus: EventBus;19 tmux: Tmux;20 /** Low-privilege token accepted only on POST /hooks/claude. */21 hookToken: string;22 startedAtMs: number;23 version: string;24 /** Outcome of the agent hook / notify installation (set by the hooks plugin; see GET /health). */25 agentHooks?: AgentHooksStatus;26}2728export interface AgentHooksStatus {29 /** CR_INSTALL_AGENT_HOOKS=1 (or the claude alias) was set. */30 requested: boolean;31 status: 'not_requested' | 'installed' | 'conflict' | 'error';32 detail: string | null;33}3435declare module 'fastify' {36 interface FastifyInstance {37 crCore: CoreContext;38 }39}4041export function getCore(app: FastifyInstance): CoreContext {42 if (!app.hasDecorator('crCore')) {43 throw new Error('runtime-core plugins must be registered through buildRuntime() (crCore decorator missing)');44 }45 return app.crCore;46}4748/** POSIX single-quote escaping: the result is exactly one shell word, whatever `s` contains. */49export function shQuote(s: string): string {50 return `'${s.replace(/'/g, `'\\''`)}'`;51}5253/** Variables that must never reach a terminal session or a task. */54export const SECRET_ENV_NAMES = ['CR_RUNTIME_TOKEN', 'DATABASE_URL', 'CR_SECRET_KEY', 'CR_AGENTD_TOKEN'] as const;5556function isReservedEnvName(name: string): boolean {57 return name.startsWith('CR_') || name === 'DATABASE_URL' || name === 'TMUX' || name === 'TMUX_PANE' || name === 'PGPASSWORD';58}5960/**61 * Environment handed to every tmux invocation (and therefore to the tmux server's global62 * environment when we are the process that starts it): the runtime's own environment minus63 * every CR_* variable, database credentials and any outer tmux markers.64 */65export function sanitizedBaseEnv(env: NodeJS.ProcessEnv = process.env): Record<string, string> {66 const out: Record<string, string> = {};67 for (const [k, v] of Object.entries(env)) {68 if (v === undefined || isReservedEnvName(k)) continue;69 out[k] = v;70 }71 return out;72}7374const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;7576/** Validates caller-supplied env; reserved names are dropped, malformed entries rejected. */77export function cleanUserEnv(env: Record<string, string>): Record<string, string> {78 const out: Record<string, string> = {};79 for (const [k, v] of Object.entries(env)) {80 if (!ENV_NAME_RE.test(k)) throw new RtError(400, 'bad_request', 'invalid environment variable name');81 if (v.includes('\0')) throw new RtError(400, 'bad_request', 'environment values must not contain NUL');82 if (isReservedEnvName(k)) continue;83 out[k] = v;84 }85 return out;86}8788function isInside(parent: string, child: string): boolean {89 return child === parent || child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);90}9192/**93 * Local workspace containment check (independent of runtime-fs paths.ts): `cwd` must be an94 * existing directory whose realpath is inside the realpath of a configured workspace root.95 * Returns the canonical directory.96 */97export async function assertInsideWorkspace(config: RuntimeConfig, cwd: string): Promise<string> {98 if (typeof cwd !== 'string' || cwd.includes('\0') || !path.isAbsolute(cwd)) {99 throw new RtError(400, 'invalid_cwd', 'cwd must be an absolute path');100 }101 let real: string;102 try {103 real = await fs.realpath(cwd);104 if (!(await fs.stat(real)).isDirectory()) throw new Error('not a directory');105 } catch {106 throw new RtError(400, 'invalid_cwd', 'cwd does not exist or is not a directory');107 }108 for (const root of config.workspaceRoots) {109 let realRoot: string;110 try {111 realRoot = await fs.realpath(root.path);112 } catch {113 continue; // a configured root that does not exist contains nothing114 }115 if (isInside(realRoot, real)) return real;116 }117 throw new RtError(403, 'outside_workspace', 'cwd is outside the configured workspace roots');118}119120/** tmp + rename in the same directory. */121export async function atomicWrite(file: string, data: string | Buffer, mode = 0o600): Promise<void> {122 const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;123 await fs.writeFile(tmp, data, { mode });124 try {125 await fs.rename(tmp, file);126 } catch (err) {127 await fs.rm(tmp, { force: true });128 throw err;129 }130}131132export function sleep(ms: number): Promise<void> {133 return new Promise((resolve) => setTimeout(resolve, ms));134}135136/** Absolute path of an executable: `name` if absolute, else PATH plus the usual Homebrew/system dirs. */137export async function resolveBin(name: string, env: NodeJS.ProcessEnv = process.env): Promise<string | null> {138 const candidates: string[] = [];139 if (path.isAbsolute(name)) candidates.push(name);140 else if (name.includes('/')) candidates.push(path.resolve(name));141 else {142 const dirs = [143 ...(env.PATH ?? '').split(':').filter(Boolean),144 '/opt/homebrew/bin',145 '/usr/local/bin',146 '/usr/bin',147 '/bin',148 path.join(os.homedir(), '.local', 'bin'),149 ];150 for (const d of dirs) candidates.push(path.join(d, name));151 }152 for (const c of candidates) {153 try {154 await fs.access(c, fsConstants.X_OK);155 if ((await fs.stat(c)).isFile()) return c;156 } catch {157 /* next candidate */158 }159 }160 return null;161}162163/** Positive integer from an env knob, or the default. Used only for test/ops timing knobs. */164export function envInt(name: string, def: number): number {165 const raw = process.env[name];166 if (raw === undefined || raw === '') return def;167 const n = Number(raw);168 return Number.isFinite(n) && n > 0 ? Math.floor(n) : def;169}170