SPB Git

Repository Public

core-util.ts 6.0 KB 170 lines typescript

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
Files main
Menu

Theme