Ctrl K
ComunidadGuías…

src/server/shraga-config.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<string, string>;
  enabled?: boolean;
}

/** HTTP MCP config — persistent sidecar, not spawned per-query */
export interface McpHttpEntry {
  type: 'http';
  url: string;
  headers?: Record<string, string>;
  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<string, McpShorthandEntry>;
  mcps?: Record<string, McpEntry>;
  /** 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<string, unknown> | 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/<uid>.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<ShragaConfig> {
  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<string, string> = {};
      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;
}