/** * Gateway server configuration — loads and validates gateway.config.json. */ import type { McpServerEntry } from '../lib/gateway.js'; export interface SlackChannelConfig { enabled: boolean; botToken: string; appToken: string; signingSecret: string; } export interface DiscordChannelConfig { enabled: boolean; botToken: string; } export interface ChannelsConfig { slack?: SlackChannelConfig; discord?: DiscordChannelConfig; } export interface VapidConfig { publicKey: string; privateKey: string; email: string; } export interface HooksConfig { email?: { enabled: boolean; provider: 'resend'; signingSecret?: string; threadMapping: string; defaultTemplate?: string; }; form?: { enabled: boolean; threadMapping: string; defaultTemplate?: string; }; webhooks?: Record; } export interface ResendConfig { apiKey: string; defaultFrom: string; replyTo?: string; /** Max emails per hour per thread (default: 10) */ rateLimit?: number; } export interface FederationConfig { enabled: boolean; role: 'hub' | 'spoke'; /** Hub: list of allowed spoke names. Spoke: ignored. */ allowedSpokes?: string[]; /** Hub: offline queue settings */ offlineQueue?: { maxMessages: number; ttlHours: number; }; /** Spoke: WebSocket URL to hub (e.g., "wss://dev.soapko.com/federation") */ hub?: string; /** Spoke: API key for authenticating with hub */ apiKey?: string; /** Spoke: device name (e.g., "mac-karl") */ name?: string; } export interface GatewayConfig { apiKeys: string[]; port: number; /** Optional: path to claude binary override */ claudePath?: string; /** Optional: shared MCP server port (0 = disabled) */ sharedMcpPort?: number; /** Optional: log file path for daemon mode */ logFile?: string; /** Optional: PID file path */ pidFile?: string; /** Optional: channel adapter configurations */ channels?: ChannelsConfig; /** Optional: root directory for project folders (e.g., /home/user/projects) */ projectRoot?: string; /** Optional: default thread mode — "local" or "remote" (default: "remote") */ defaultMode?: 'local' | 'remote'; /** Optional: VAPID config for web push notifications */ vapid?: VapidConfig; /** Optional: inbound webhook configurations */ hooks?: HooksConfig; /** Optional: Resend email API configuration */ resend?: ResendConfig; /** Optional: HuggingFace API key for non-Claude models */ hfApiKey?: string; /** Optional: OpenAI API key for provider "openai" model entries (task 118) */ openaiApiKey?: string; /** * Optional: user-registered OpenAI-compatible endpoints (task 147). * Each carries its OWN credential — Vultr, Together, Groq, vLLM, Ollama, … * Referenced by a model entry as `provider: "custom:"`. */ customProviders?: CustomProviderEntry[]; /** Optional: available models for per-thread selection */ models?: ModelEntry[]; /** Optional: Claude CLI model variants for the sub-model picker (id passed to --model) */ claudeModels?: ClaudeModelEntry[]; /** Optional: default model ID for new threads (e.g., "claude" or "zai-org/GLM-5") */ model?: string; /** Optional: model used for VOICE MODE turns, gateway-wide (task 152). An id from either * `models[]` or `claudeModels[]`; anything else (or absent) means voice turns run on the * thread's own model. Resolved by `resolveVoiceModel`. */ voiceModel?: string; /** Optional: model used by the stall-detection classifier (task 164; any model per task 165). * An id from either `models[]` or `claudeModels[]`; anything else (or absent) means NOT * CONFIGURED — the classifier does not run at all and no thread is ever nudged (task 184). * Resolved by `resolveGatewayModel`. */ stallClassifierModel?: string; /** Optional: model used by the gateway's background workers (task 179 — the `/tasks` * Refresh button and whatever follows it). An id from either `models[]` or * `claudeModels[]`; anything else (or absent) means NOT CONFIGURED — Refresh refuses * with a message naming this setting rather than running on a guessed model (task 184). * Kept separate from `stallClassifierModel` because the jobs differ in difficulty: that one * is a yes/no read of three sentences, this one rewrites a whole file and judges every * task's state, and the cheap model that is plenty for the first may not be for the * second. Resolved by `resolveGatewayModel`. */ workerModel?: string; /** Optional: public base URL for the gateway (e.g., "https://dev.soapko.com"). Used for media URLs and links. */ baseUrl?: string; /** Isolated preview listener and public DNS namespace; applied on reload. */ previewDelivery?: import('./preview/router.js').PreviewRouterConfig; /** Optional: federation config for hub-and-spoke cross-gateway messaging */ federation?: FederationConfig; /** Optional: agent-native app bridge — mounts /bridge WS routing to live browser tabs (task 097) */ bridge?: BridgeConfig; /** Optional: thread namespaces — prefix-scoped visibility for app/test threads (task 097 P2) */ namespaces?: NamespaceConfig[]; /** * Optional: Lucky Draw licence key (task 127). Absent = demo mode: each * namespace may mint at most DEMO_VISITOR_THREAD_CAP visitor threads. * Non-namespaced threads are never gated. Verified offline (Ed25519). */ licenseKey?: string; } /** * Thread namespace (task 097 P2). A namespace name is a thread-name prefix: * namespace `pursuit` covers every `pursuit-*` thread but NOT bare `pursuit`, * which stays the owner's own work thread. Namespaced threads are hidden from the * default thread list; a request bearing one of the namespace's `apiKeys` sees only * that namespace's threads. Visibility only — not a hard access boundary * (inject/history by exact name still works). */ export interface NamespaceConfig { /** Namespace name = thread-name prefix (e.g. "pursuit"). */ name: string; /** API keys scoped to this namespace. A request bearing one sees only these threads. */ apiKeys?: string[]; /** Optional display label for the folder UI (visibility-first; folders are a fast-follow). */ label?: string; /** * Optional generic executor pass-through (task 097 P3). Reverse-proxies the * listed path prefixes to `origin`, so the app tab talks to a single origin. * Replaces the fork's baked-in /state + /journal routes (Rule #8). Presence * alone activates the proxy — independent of bridge.enabled. */ executorProxy?: NamespaceExecutorProxy; /** * Optional extra MCP servers included in agent turns on this namespace's * threads (name -> {command, args, env}), alongside cumulus-history and * gateway-agents (task 097 P5). String values in `args` and `env` may carry a * `{thread}` placeholder, resolved to the calling thread's name. Scoped like * `executorProxy`: only `${name}-*` threads get these servers, so one app's * tools never leak into unrelated threads. */ extraMcpServers?: Record; } /** Generic executor reverse-proxy for a namespace (task 097 P3). */ export interface NamespaceExecutorProxy { /** Upstream origin the prefixes forward to, e.g. "http://127.0.0.1:8097". */ origin: string; /** Path prefixes forwarded verbatim (matched exact or as `${prefix}/…`), e.g. ["/state", "/journal"]. */ pathPrefixes: string[]; } /** Agent-native app bridge (gateway <-> live browser tab). Default off. */ export interface BridgeConfig { /** When true, mounts the /bridge WS + /bridge/call + /bridge/manifest routes. */ enabled: boolean; /** * Base URL of the app's headless executor. Used to hydrate selection refs in * the CONTEXT injection block (soft-fails if unreachable). Default * http://127.0.0.1:8091. The /state+/journal proxy is a separate concern * (task 097 P3) and is not mounted here. */ executorUrl?: string; } /** * A user-registered OpenAI-compatible endpoint (task 147). * * One mechanism for every third-party inference host that speaks * `chat/completions` — Vultr Serverless Inference, Together, Groq, Fireworks, * vLLM, LM Studio, Ollama. Each carries its OWN credential: the global * `hfApiKey`/`openaiApiKey` belong to those two hosts and must never be sent * to someone else's endpoint. */ export interface CustomProviderEntry { /** Slug referenced by a model entry as `custom:`. Lowercase [a-z0-9][a-z0-9_-]* */ id: string; /** Display label for the settings card and model dropdown */ label: string; /** * Endpoint. Accepts the base the provider's docs give you * (`https://api.example.com/v1`) or the full POST target; normalized to the * full `chat/completions` URL at resolve time. */ baseUrl: string; /** This provider's own API key. */ apiKey?: string; } /** Model entry for per-thread selection */ export interface ModelEntry { /** Model ID — "claude" for CLI, a HuggingFace model ID like "zai-org/GLM-5", or an OpenAI model ID like "gpt-5.6-sol" */ id: string; /** Display label for the widget dropdown */ label: string; /** * Provider — decides endpoint + credential for non-Claude entries (task 118). * `custom:` references a `customProviders[]` entry (task 147). * `codex-cli` runs the id through the OpenAI Codex CLI, the way `claude-cli` * runs the Claude CLI: no credential here, the CLI owns its login (task 188). */ provider: 'claude-cli' | 'codex-cli' | 'huggingface' | 'openai' | `custom:${string}`; /** Whether this is the default model */ default?: boolean; /** Context window size in tokens (non-Claude entries). When set, overrides the built-in per-model map — the only way to size models the map doesn't know. */ contextWindow?: number; } /** Claude CLI model variant for the sub-model picker (only used when provider is Claude) */ export interface ClaudeModelEntry { /** Model id passed verbatim to `claude --print --model` — e.g. "claude-opus-5[1m]" */ id: string; /** Display label for the widget dropdown — e.g. "Opus 4.8" */ label: string; /** Whether this is the default Claude model */ default?: boolean; } /** The port the gateway listens on when config says nothing. One source of truth. */ export declare const DEFAULT_PORT = 8080; /** * Load gateway config from ~/.cumulus/gateway.config.json. * Creates a default config with a generated API key if none exists. */ export declare function loadGatewayConfig(configPath?: string): Promise; /** * Validate the custom-provider registry (task 147). * * Lenient in the same style as validateModels — a malformed entry is dropped * rather than throwing, so one bad hand-edit cannot take the gateway down. A * dropped provider fails CLOSED at resolve time (its models fall back to the * Claude path) rather than leaking traffic to the HF router. * * `id` is lowercased and slug-checked because it is embedded in the * `custom:` provider string and in DOM test ids. `baseUrl` must parse as * http(s) — anything else is a configuration mistake, not an endpoint. * Duplicate ids keep the first: last-wins would make which credential is used * depend on array order. */ export declare function validateCustomProviders(raw: unknown): CustomProviderEntry[] | undefined; export declare function getConfigPath(): string; /** Mask a secret for read: bullets + last 4 chars (or just bullets if short). * Empty/undefined → "". Never returns enough to reconstruct the key. */ export declare function maskSecret(secret?: string): string; /** The live-mutable subset of gateway config with credentials MASKED. Served by * GET /api/config so the in-app editor renders current state without ever * exposing a full provider key. */ export interface MaskedConfigView { models: ModelEntry[]; claudeModels: ClaudeModelEntry[]; model?: string; /** Gateway-wide voice-mode model (task 152). Not a credential, so served verbatim. */ voiceModel?: string; /** Stall-classifier sub-model (task 164). Not a credential, so served verbatim. */ stallClassifierModel?: string; /** Background-worker model (task 179). Not a credential, so served verbatim. */ workerModel?: string; /** Masked (e.g. "••••abcd"); "" when unset. */ hfApiKey: string; /** Masked (e.g. "••••abcd"); "" when unset. */ openaiApiKey: string; /** Masked; "" when unset. A licence key is a bearer artifact (task 129). */ licenseKey: string; /** Custom OpenAI-compatible providers with their keys MASKED (task 147). */ customProviders: MaskedCustomProvider[]; } /** A custom provider as served to the admin settings UI — key masked. */ export interface MaskedCustomProvider { id: string; label: string; baseUrl: string; /** Masked (e.g. "••••abcd"); "" when unset. */ apiKey: string; } export declare function maskedConfigView(config: { models?: ModelEntry[]; claudeModels?: ClaudeModelEntry[]; model?: string; voiceModel?: string; stallClassifierModel?: string; workerModel?: string; hfApiKey?: string; openaiApiKey?: string; licenseKey?: string; customProviders?: CustomProviderEntry[]; }): MaskedConfigView; /** Partial config mutation applied live (task 104). Only the model catalog, * default model, provider credentials, and the licence key are mutable in-app. * `null` clears a field; an empty or masked credential string is ignored (left * unchanged). The licence key is PERSIST-ONLY: it lands on disk but enforcement * re-verifies only at startup/reload (tasks 127/129). */ export interface GatewayConfigPatch { models?: ModelEntry[]; claudeModels?: ClaudeModelEntry[]; model?: string | null; /** Gateway-wide voice-mode model (task 152); `null` clears it ("same as thread"). */ voiceModel?: string | null; /** Stall-classifier sub-model (task 164); `null` clears it (not configured — off). */ stallClassifierModel?: string | null; /** Background-worker model (task 179); `null` clears it (not configured — off). */ workerModel?: string | null; hfApiKey?: string | null; openaiApiKey?: string | null; licenseKey?: string | null; /** Replaces the registry wholesale (like `models`); `null` clears it. */ customProviders?: CustomProviderPatchEntry[] | null; } /** * One custom provider in a PUT /api/config body. `apiKey` follows the same rule * as the scalar credentials (Rule #8): `null` clears, a real value sets, and an * absent / empty / masked value leaves the stored key unchanged — matched by * `id`, so the editor can round-trip a masked view without losing keys. */ export interface CustomProviderPatchEntry { id: string; label?: string; baseUrl: string; apiKey?: string | null; } /** Sanitized real (UNMASKED) values after a patch — the server applies these in * memory to serve turns. Never send this to a client; use maskedConfigView. */ export interface AppliedConfig { models?: ModelEntry[]; claudeModels?: ClaudeModelEntry[]; model?: string; voiceModel?: string; stallClassifierModel?: string; workerModel?: string; hfApiKey?: string; openaiApiKey?: string; licenseKey?: string; customProviders?: CustomProviderEntry[]; } /** * Merge a patch into gateway.config.json and persist atomically, returning the * sanitized mutable subset. Mutates the RAW on-disk object so fields that * validateConfig does not echo (baseUrl, basePath, …) are preserved * verbatim — a round-trip through loadGatewayConfig would silently drop them * (data loss). In-memory application (so a change takes effect with no restart) * is the caller's job — this owns disk + sanitization only. */ export declare function applyConfigPatch(patch: GatewayConfigPatch, configPath?: string): Promise; //# sourceMappingURL=config.d.ts.map