import { existsSync, statSync } from 'node:fs'; import { createRequire } from 'node:module'; import path from 'node:path'; import { DATA_DIR, APP_ROOT } from './paths.ts'; import type { McpServerConfig, McpConfig, McpHttpServerConfig } from './mcp.ts'; /** Shorthand for vendor-dir MCPs (auto-resolves command/args from vendor/{name}) */ export interface McpShorthandEntry { dir?: string; command?: string; args?: string[]; env?: string[]; } /** Full MCP config (command, args, env with values) — for non-vendor MCPs */ export interface McpFullEntry { command: string; args?: string[]; env?: Record; enabled?: boolean; } /** HTTP MCP config — persistent sidecar, not spawned per-query */ export interface McpHttpEntry { type: 'http'; url: string; headers?: Record; enabled?: boolean; /** Vendor dir to auto-start as HTTP sidecar (resolved to vendor/{dir}) */ dir?: string; /** Port for the sidecar process */ port?: number; } export type McpEntry = McpShorthandEntry | McpFullEntry | McpHttpEntry; function isHttpEntry(entry: McpEntry): entry is McpHttpEntry { return (entry as any).type === 'http'; } function isFullEntry(entry: McpEntry): entry is McpFullEntry { return 'command' in entry && !Array.isArray((entry as any).env) && !isHttpEntry(entry); } export interface AgentSettings { model?: string; /** Model for scheduled (unattended) runs whose prompt names no `[model:…]`. Unset = `model`. */ scheduleModel?: string; engine?: string; systemPrompt?: string; allowedTools?: string[]; permissionMode?: string; maxTurns?: number; skillDiscovery?: boolean; thinking?: 'adaptive' | 'enabled' | 'disabled'; effort?: 'low' | 'medium' | 'high' | 'max'; /** claude-code engine: resume the SDK session across turns instead of re-sending the history * (prompt-cache savings). Default off; a session's `[resume:on|off]` directive wins. */ sdkResume?: boolean; /** * Allow an AUTOMATIC outbound reply to a low-trust inbound principal — one whose resolved role ranks * below `member` (guest, anonymous, an unverified sender). Default OFF. * * Off does NOT drop the message: the turn still runs and `escalate` still reaches owners. Only the * automatic reply back to that sender is suppressed, because a turn's prompt carries roster/skill/ * workspace context and a channel that mails the model's text back to an unverified sender is a * disclosure path. A human operator's own turns are never gated by this. * * Orthogonal to `SECURITY_ENFORCE`: this gates OUTBOUND REPLIES, not tools, so it applies whether or * not enforcement is on (shadow mode included). Channels/add-ons ask `mayReplyTo(principal)`. */ allowUntrustedReplies?: boolean; } export interface ShragaConfig { /** @deprecated Use `mcps` instead */ vendorMcps?: Record; mcps?: Record; /** Public origin this deployment is reachable at, e.g. `https://agent.example.com`. * Used to build absolute session links for out-of-band notifications (push, alerts) * that have no incoming request to derive it from. Falls back to `$PUBLIC_ORIGIN`. */ publicOrigin?: string; /** Offload heavy work (media generation/render/encode, large files) to an external pod for a low-resource box. * `gateway` is the base URL of a service implementing the contract documented in offload.ts. When set: the prompt * says so, share_file routes media/large files through the gateway, heavy local Bash commands are refused, and * agent subprocesses get SHRAGA_OFFLOAD=1 / SHRAGA_OFFLOAD_GATEWAY / SHRAGA_OFFLOAD_MAX_LOCAL_FILE_MB. * Unset = no offload (default). */ offload?: { gateway: string; maxLocalFileMB?: number }; } export interface OffloadSettings { gateway: string; maxLocalFileMB: number } export const DEFAULT_OFFLOAD_MAX_LOCAL_FILE_MB = 25; export interface HttpSidecarSpec { name: string; dir: string; port: number; url: string; } export function defineConfig(config: ShragaConfig): ShragaConfig { return config; } /** * Config filenames, in precedence order. `shraga.config.ts` is canonical; `unclaw.config.ts` is * the legacy name kept for back-compat — existing deployments have that file in their data dir, * and dropping it would silently fall back to an empty config (losing every global MCP). * Seeding is guarded on BOTH names (see seed.ts) so a fresh canonical file can never shadow a * populated legacy one. */ export const CONFIG_FILENAMES = ['shraga.config.ts', 'unclaw.config.ts'] as const; /** First config file that exists in the data dir, or null when none is present. */ export function resolveConfigPath(): string | null { for (const name of CONFIG_FILENAMES) { const p = path.join(DATA_DIR, name); if (existsSync(p)) return p; } return null; } let _cached: ShragaConfig | null = null; /** Config file the cache was built from, and its `mtimeMs:size` stamp. */ let _cachedPath: string | null = null; let _cachedStamp = ''; /** Stamp of the last version we failed to load — so a broken config logs once, not per call. */ let _failedStamp = ''; /** True once we've reported that a previously-present config file went missing (log once, not per call). */ let _missingLogged = false; const _require = createRequire(import.meta.url); function stampOf(file: string): string { try { const st = statSync(file); return `${st.mtimeMs}:${st.size}`; } catch { return ''; } } /** * Load the config module SYNCHRONOUSLY, bypassing the runtime module cache. * * Busting `_cached` alone is not enough: `await import(p)` returns the SAME module object for a * path already loaded, so an edited file is never re-evaluated (measured on Bun 1.3.10). * `require` + a `require.cache` delete does re-evaluate — and being sync, it lets the sync * consumers (`getGlobalMcpsFromConfig`, `getPublicOrigin`, …) pick up an edit with no call-site * changes anywhere. * * Throws on a broken config; callers keep the last-good value. */ function readConfigSync(configPath: string): ShragaConfig { // A 0-byte file is a TRUNCATED/FAILED WRITE, not an authored "no MCPs": `require` hands back `{}` // for it without throwing, which would silently drop every global MCP. Fail loudly instead so the // caller keeps the last-good value — and, because the stamp is not advanced, recovers by itself // the moment the real content lands. if (statSync(configPath).size === 0) { throw new Error('config file is empty (0 bytes) — refusing to load it over the last-good config'); } try { // Delete by the RESOLVED key: the cache is keyed by the realpath, and DATA_DIR is often a // symlinked path (macOS /var -> /private/var, or a symlinked data dir), so deleting the // literal path silently misses and the stale module is returned. const cache = _require.cache as Record | undefined; if (cache) { delete cache[_require.resolve(configPath)]; delete cache[configPath]; } } catch { // A runtime without a mutable require.cache: fall through — the load below may still be fresh, // and loadShragaConfig()'s async fallback covers the stale case. } const mod = _require(configPath); return (mod?.default ?? {}) as ShragaConfig; } /** * Re-read the config when the file changed on disk (mtime+size), so editing `shraga.config.ts` * takes effect without restarting the process — the way editing `data/mcps/.json` already * does. Returns the load error, or null. * * Fully synchronous, so two concurrent turns can never interleave into a torn cache. */ function refreshConfig(): unknown { const configPath = resolveConfigPath(); if (!configPath) { // Cold start with no config at all: an empty config is the honest answer. if (_cached === null) { _cached = {}; _cachedPath = null; _cachedStamp = ''; return null; } // We HAD a config and the file is now gone — deleted, renamed, or a checkout/mount blip. // Resetting the cache here would silently drop EVERY global MCP from a running process, with // no log to explain it. Keep the last-good value (same policy as a config that fails to parse), // say so once, and leave _cachedPath/_cachedStamp intact so a re-appearing file recovers. if (_cachedPath !== null && !_missingLogged) { _missingLogged = true; console.error( `[config] ${path.basename(_cachedPath)} is GONE from ${DATA_DIR} —`, 'KEEPING the last-good config in memory (global MCPs preserved)', ); } return null; } _missingLogged = false; const stamp = stampOf(configPath); if (_cached !== null && configPath === _cachedPath && stamp === _cachedStamp) return null; try { _cached = readConfigSync(configPath); _cachedPath = configPath; _cachedStamp = stamp; _failedStamp = ''; return null; } catch (e) { if (_failedStamp !== stamp) { _failedStamp = stamp; console.error( `[config] failed to load ${path.basename(configPath)}:`, e instanceof Error ? e.message : String(e), _cached === null ? '— using an EMPTY config' : '— KEEPING the last-good config in memory', ); } // Never leave the process with no config, and never poison the cache with a broken file: // keep the last-good value, and deliberately do NOT advance _cachedStamp so the next call // retries (a fixed file recovers on its own). if (_cached === null) { _cached = {}; _cachedPath = configPath; _cachedStamp = ''; } return e; } } /** Drop the cache so the next read re-evaluates the config file unconditionally. */ export function invalidateShragaConfig(): void { _cached = null; _cachedPath = null; _cachedStamp = ''; _failedStamp = ''; } export async function loadShragaConfig(): Promise { const err = refreshConfig(); if (err) { // `require` cannot load every valid ESM config (top-level await). Retry through the async // loader — cache-busted, since a plain re-import of a loaded path returns the stale module. const configPath = resolveConfigPath(); if (configPath) { try { const stamp = stampOf(configPath); const mod = await import(`${configPath}?stamp=${encodeURIComponent(stamp)}`); // We just awaited, so the world may have moved: another caller can have loaded a NEWER // config synchronously while this import was in flight. Committing unconditionally would // overwrite it with the older value AND stamp it as current — a lost update that hands the // stale config to both callers. Commit only when the file on disk is still the version we // imported and the cache has not already reached it. if (stampOf(configPath) === stamp && _cachedStamp !== stamp) { _cached = mod.default ?? {}; _cachedPath = configPath; _cachedStamp = stamp; _failedStamp = ''; } } catch { // Already logged by refreshConfig; last-good (or {}) stands. } } } return _cached ?? {}; } export function getShragaConfigSync(): ShragaConfig { refreshConfig(); return _cached ?? {}; } /** This deployment's public origin, without a trailing slash — data-dir config first, then * `$PUBLIC_ORIGIN`. Empty when unconfigured: callers MUST omit the link rather than fall back * to a locally-derived host, which is unreachable from wherever the notification is read. */ export function getPublicOrigin(): string { const origin = getShragaConfigSync().publicOrigin ?? process.env.PUBLIC_ORIGIN ?? ''; return origin.trim().replace(/\/+$/, ''); } /** Resolved offload settings, or undefined when this deployment does not offload (no config / blank gateway). */ export function getOffload(): OffloadSettings | undefined { return resolveOffload(getShragaConfigSync()); } export function resolveOffload(config: ShragaConfig): OffloadSettings | undefined { const o = config.offload; const gateway = o?.gateway?.trim().replace(/\/+$/, ''); if (!gateway) return undefined; const mb = Number(o!.maxLocalFileMB); return { gateway, maxLocalFileMB: Number.isFinite(mb) && mb > 0 ? mb : DEFAULT_OFFLOAD_MAX_LOCAL_FILE_MB }; } /** Absolute link to a session in the web UI, or `undefined` when no public origin is configured. */ export function getSessionUrl(sessionId: string | undefined): string | undefined { const origin = getPublicOrigin(); if (!origin || !sessionId) return undefined; return `${origin}/?session=${encodeURIComponent(sessionId)}`; } /** Resolve global MCPs from the data-dir config (both shorthand vendor entries and full entries) */ export function getGlobalMcpsFromConfig(): McpConfig { const ucConfig = getShragaConfigSync(); const entries = ucConfig.mcps ?? ucConfig.vendorMcps ?? {}; const result: McpConfig = {}; for (const [name, entry] of Object.entries(entries)) { if (isHttpEntry(entry)) { const { dir: _dir, port: _port, ...httpConfig } = entry; result[name] = httpConfig satisfies McpHttpServerConfig; } else if (isFullEntry(entry)) { const full = entry; result[name] = { type: 'stdio', ...full } satisfies McpServerConfig; } else { const shorthand = entry as McpShorthandEntry; const vendorDir = path.join(APP_ROOT, 'vendor', shorthand.dir ?? name); const command = shorthand.command ?? 'bun'; const args = shorthand.args ?? ['run', path.join(vendorDir, 'src/mcp/cli.ts'), '--stdio']; const env: Record = {}; if (Array.isArray(shorthand.env)) { for (const key of shorthand.env) env[key] = ''; } else if (shorthand.env && typeof shorthand.env === 'object') { // object form: explicit name->value (or $PLACEHOLDER resolved by env-resolve). A malformed // env must never crash MCP setup for ALL chat turns — degrade gracefully. for (const [k, v] of Object.entries(shorthand.env)) env[k] = typeof v === 'string' ? v : ''; } result[name] = { type: 'stdio', command, args, env } satisfies McpServerConfig; } } return result; } /** Extract HTTP sidecar specs that need to be auto-started */ export function getHttpSidecarSpecs(): HttpSidecarSpec[] { const ucConfig = getShragaConfigSync(); const entries = ucConfig.mcps ?? {}; const specs: HttpSidecarSpec[] = []; for (const [name, entry] of Object.entries(entries)) { if (!isHttpEntry(entry) || !entry.dir) continue; if (entry.enabled === false) continue; const urlPort = new URL(entry.url).port; const port = entry.port ?? (urlPort ? parseInt(urlPort) : 3846); specs.push({ name, dir: entry.dir, port, url: entry.url }); } return specs; }