Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 83 additions & 9 deletions src/main/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import {
providerPreset,
type AgentProvider
} from '../shared/agentProvider';
import { defaultMcpDefaults } from '../shared/mcpCatalog';
import {
defaultMcpDefaults, isPlainObject, mcpCatalogEntry, mcpConsentGrant,
type McpDefaultsMap, type McpConsentStateResult
} from '../shared/mcpCatalog';
import { MAX_AGENT_TOKEN_CAP } from '../shared/tokenCaps';
import { expandTilde, normalizeHiveHome } from './fs';
import type { IntegrationRecord } from '../shared/integrations';
Expand Down Expand Up @@ -214,7 +217,7 @@ export interface HarnessConfig {
/** Per-server consent state for the default MCP bundle, keyed by catalog id.
* Seeded from MCP_CATALOG (safe-readonly ON, write/secret OFF); the user flips
* these in Settings. A server is wired into an agent only when enabled here. */
mcpDefaults?: { [id: string]: { enabled: boolean } };
mcpDefaults?: McpDefaultsMap;
/** Enable semantic memory (MemPalace CLI). No-op if mempalace isn't installed. */
semanticMemory: boolean;
/** Embedding model for the palace: lightweight 'minilm' or multilingual 'embeddinggemma'. */
Expand Down Expand Up @@ -509,6 +512,8 @@ function configPath(): string {
function withTriggerDefaults(cfg: HarnessConfig): HarnessConfig {
return {
...cfg,
// Never hand out the process-wide consent seed by mutable reference.
mcpDefaults: cfg.mcpDefaults === DEFAULTS.mcpDefaults ? defaultMcpDefaults() : cfg.mcpDefaults,
contextTrigger: {
compact: { ...DEFAULT_CONTEXT_TRIGGER.compact, ...cfg.contextTrigger?.compact },
clear: { ...DEFAULT_CONTEXT_TRIGGER.clear, ...cfg.contextTrigger?.clear }
Expand Down Expand Up @@ -585,15 +590,43 @@ function migrateTriggersV1(cfg: HarnessConfig): HarnessConfig {
}
}

/** How config.json bytes become text. Both strip a UTF-8 BOM (Windows editors).
* - 'strict' : any invalid byte is a load failure. Consent reads use this, so a
* damaged file is reported to the user instead of being granted from.
* - 'lenient' : invalid bytes become U+FFFD, the long-standing readConfig behaviour.
* A stray ANSI/GBK byte in an unrelated string (a hand-edited name)
* must not reset every setting on the next unrelated write. U+FFFD can
* only land inside a JSON string, so it can never form an
* `enabled:true` boolean or an integer revision — it cannot widen consent. */
type ConfigDecoding = 'strict' | 'lenient';

/** undefined = no file yet (first run). Every other failure throws with context:
* I/O errors keep their Node error (errno code intact), parse/shape get a message. */
function readStoredConfig(decoding: ConfigDecoding): Record<string, unknown> | undefined {
let raw: Buffer;
try {
raw = readFileSync(configPath());
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw error;
}
let parsed: unknown;
try {
parsed = JSON.parse(new TextDecoder('utf-8', { fatal: decoding === 'strict' }).decode(raw));
} catch {
throw new Error(decoding === 'strict'
? 'Cannot load config.json: expected complete, valid UTF-8 JSON'
: 'Cannot load config.json: expected complete JSON');
}
if (!isPlainObject(parsed)) throw new Error('Cannot load config.json: expected an object');
return parsed;
}

export function readConfig(): HarnessConfig {
const p = configPath();
// No file yet = a first run with nothing to migrate; the defaults ARE the
// post-migration shape. Deliberately does not persist — a bare read must not
// conjure a config.json before onboarding has written one.
if (!existsSync(p)) return withTriggerDefaults({ ...DEFAULTS });
try {
const raw = readFileSync(p, 'utf8');
const parsed = JSON.parse(raw);
const parsed = readStoredConfig('lenient');
// First run: no file and no migration write before onboarding.
if (parsed === undefined) return withTriggerDefaults({ ...DEFAULTS });
return normalizeStoredHomes(migrateTriggersV1(withTriggerDefaults({ ...DEFAULTS, ...parsed })));
} catch {
return withTriggerDefaults({ ...DEFAULTS });
Expand Down Expand Up @@ -667,6 +700,10 @@ function persistConfig(next: HarnessConfig): HarnessConfig {
}

export function writeConfig(patch: Partial<HarnessConfig>): HarnessConfig {
if (!isPlainObject(patch)) throw new Error('invalid config patch');
if (Object.hasOwn(patch, 'mcpDefaults')) {
throw new Error('MCP consent must be updated with setMcpServerEnabled');
}
const current = readConfig();
const next: HarnessConfig = { ...current, ...patch };
// Project INGESTION — a registered repo is typed by hand ("~/dev/foo") as often
Expand Down Expand Up @@ -697,6 +734,43 @@ export function writeConfig(patch: Partial<HarnessConfig>): HarnessConfig {
return persistConfig(next);
}

/** Consent reads cannot use readConfig's forgiving fallback or run migrations.
* Keep corrupt files intact and distinguish first install from other I/O errors. */
function readMcpConsentConfig(): HarnessConfig {
const parsed = readStoredConfig('strict');
if (parsed === undefined) return withTriggerDefaults({ ...DEFAULTS });
if (Object.hasOwn(parsed, 'mcpDefaults') && !isPlainObject(parsed.mcpDefaults)) {
throw new Error('Cannot load MCP consent: mcpDefaults must be an object');
}
return withTriggerDefaults({ ...DEFAULTS, ...parsed });
}

/** Only the MCP records cross this read boundary, never unrelated credentials.
* Never throws: a failure comes back as `{ ok:false, error }` for the UI to show. */
export function getMcpConsentState(): McpConsentStateResult {
try {
return { ok: true, records: readMcpConsentConfig().mcpDefaults ?? defaultMcpDefaults() };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}

/** Synchronous main-process RMW serializes updates from windows in this process.
* Revision is chosen here, never supplied by a renderer snapshot. */
export function setMcpServerEnabled(catalogId: unknown, enabled: unknown): HarnessConfig {
if (typeof catalogId !== 'string' || typeof enabled !== 'boolean') throw new Error('invalid MCP consent update');
const entry = mcpCatalogEntry(catalogId);
if (!entry) throw new Error('invalid MCP server id');
const record = enabled ? mcpConsentGrant(entry) : { enabled: false };
if (!record) throw new Error('invalid MCP catalog consent policy');
// Throws on an unreadable/corrupt file, so the IPC promise rejects and nothing is written.
const current = readMcpConsentConfig();
return persistConfig({
...current,
mcpDefaults: { ...current.mcpDefaults, [catalogId]: record }
});
}

/** Set or clear one agent's token ceiling against the latest config on disk.
*
* Renderer config objects are snapshots. Replacing `agentTokenCaps` from one of
Expand Down
35 changes: 15 additions & 20 deletions src/main/hive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,13 @@ import {
bridgeOf,
type AgentProvider
} from '../shared/agentProvider';
import { MCP_CATALOG } from '../shared/mcpCatalog';
import { MCP_CATALOG, mcpConsentRecord, resolveMcpEnablement, type McpConsentInput } from '../shared/mcpCatalog';
import { selectBroadcastTargets } from '../shared/broadcast';
import { preferredAgentRole } from '../shared/agentRole';
import { mergeTaskLedger } from '../shared/taskLedger';
import { expandTilde } from './fs';
import { resolveGodName } from '../shared/godIdentity';

/** The subset of HarnessConfig the hive consumes for the default-MCP merge.
* Kept as a local shape so hive.ts never imports the foundation-owned config
* module just for a type. */
type McpDefaultsMap = { [id: string]: { enabled: boolean } } | undefined;

// ─── Types ──────────────────────────────────────────────────────────────────

export type MessageAct = 'request' | 'inform' | 'propose' | 'query' | 'agree' | 'refuse' | 'done';
Expand Down Expand Up @@ -722,9 +717,12 @@ export class HiveManager {
* old env-var spelling. */
kgCliPath?: string;
theme?: 'light' | 'dark';
/** Consent state for the default-MCP bundle (W3). Threaded from the live
* HarnessConfig by the caller; undefined → catalog defaults apply. */
mcpDefaults?: { [id: string]: { enabled: boolean } };
/** Stored consent records for the default-MCP bundle (W3), keyed by catalog id —
* the `records` of `getMcpConsentState()`, never the whole HarnessConfig.
* undefined → no stored records: catalog defaults apply (safe ON, write/secret OFF).
* null → consent state could not be read: fail closed, no default server.
* Values stay untrusted JSON; buildDefaultMcpServers validates each one. */
mcpDefaults?: McpConsentInput;
/** App-resources `skills/` source dir (W3). The bundled read-only skills are
* copied into the agent's `.claude/skills/` per spawn; undefined or missing
* is a no-op (tolerated until Kevin populates the resource dir). */
Expand Down Expand Up @@ -1242,7 +1240,7 @@ export class HiveManager {
return Array.from(new Set(out));
}

private hookSettings(shim: string, cwd: string, cfg: McpDefaultsMap, theme?: 'light' | 'dark', writableDirs: string[] = []): unknown {
private hookSettings(shim: string, cwd: string, cfg: McpConsentInput, theme?: 'light' | 'dark', writableDirs: string[] = []): unknown {
// Bundled node, NOT bare `node` — see nodeLauncherPath(). Claude runs each of
// these through `sh -c` with a stripped PATH, where `node` is often absent.
const cmd = this.nodeRun(shim);
Expand Down Expand Up @@ -1312,29 +1310,26 @@ export class HiveManager {
* server only when it's enabled (catalog ∩ consent), scopes filesystem/git to the
* agent cwd (never whole-disk), and namespaces every id `munder-<id>` so a server
* of the same name in the user's own ~/.claude is never clobbered. A write/secret
* server is included ONLY on an explicit `enabled:true` consent — never via a
* server is included ONLY on valid versioned consent — never via a
* default — so a malformed/partial config can't silently arm a keyed server.
* The static type keeps callers honest; the record values are still untrusted
* JSON, so mcpConsentRecord/resolveMcpEnablement validate each one at run time
* and fail closed even if a caller's static type is wrong.
*/
private buildDefaultMcpServers(
cwd: string,
cfg: McpDefaultsMap
cfg: McpConsentInput
): Record<string, { command: string; args: string[]; env?: Record<string, string> }> {
const out: Record<string, { command: string; args: string[]; env?: Record<string, string> }> = {};
for (const e of MCP_CATALOG) {
const consented = cfg?.[e.id]?.enabled;
const enabled = consented ?? e.defaultEnabled;
if (!enabled) continue;
// Defense-in-depth: a write/secret server requires an EXPLICIT opt-in; it can
// never ride in on a default (the catalog already ships these OFF, but this
// guards a hand-edited/partial mcpDefaults map too).
if (e.tier !== 'safe-readonly' && consented !== true) continue;
if (!resolveMcpEnablement(e, mcpConsentRecord(cfg, e.id))) continue;
// Replace the `<cwd>` placeholder (filesystem/git) with the agent cwd at merge
// time so these stay strictly workspace-scoped.
const args = e.spec.args.map((a) => (a === '<cwd>' ? cwd : a));
out[`munder-${e.id}`] = {
command: e.spec.command,
args,
...(e.spec.env ? { env: e.spec.env } : {})
...(e.spec.env ? { env: { ...e.spec.env } } : {})
};
}
return out;
Expand Down
33 changes: 31 additions & 2 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { resolveCommand as resolveCliCommand, isSafeCommandName } from './shellE
import { initAutoUpdater, abortPendingRestart } from './updater';
import { RealtimeFloorWatcher } from './realtimeFloorWatcher';
import {
readConfig, writeConfig, setAgentTokenCap, resetConfig, onConfigWritten, ensureHarnessHome, ensureClaudePermissionsAccepted,
readConfig, writeConfig, setAgentTokenCap, setMcpServerEnabled, getMcpConsentState, resetConfig, onConfigWritten, ensureHarnessHome, ensureClaudePermissionsAccepted,
modelForRole, OPS_STANDUP_MISSION, HEARTBEAT_MISSION, COMPACT_MAINTENANCE_MISSION, type HarnessConfig, type ScheduledMission
} from './config';
import { listDir, readFileText, readFileBinary, writeFileText, statAbs, expandTilde } from './fs';
Expand Down Expand Up @@ -1191,6 +1191,31 @@ function breakerToast(title: string, body: string): void {
catch { /* unsupported platform */ }
}

/** The consent-read failure last told to the user, so spawning a whole team against
* one broken config.json raises one toast rather than one per agent. Cleared by the
* next good read, so a later, separate breakage is announced again. */
let surfacedMcpConsentFailure: string | undefined;

/** Default-MCP consent for a new session. An unreadable config.json fails closed —
* no default server is wired, safe ones included — but never silently: the agent
* is missing tools the user expects, so log it and tell them how to recover. */
function mcpDefaultsForSpawn(): Record<string, unknown> | null {
const consent = getMcpConsentState();
if (consent.ok) {
surfacedMcpConsentFailure = undefined;
return consent.records;
}
console.warn('[mcp] consent state unreadable; spawning without default MCP servers:', consent.error);
if (surfacedMcpConsentFailure !== consent.error) {
surfacedMcpConsentFailure = consent.error;
breakerToast(
'Default MCP servers disabled',
`${consent.error}. New agents start without default MCP servers until config.json is repaired; then refresh Settings → Default MCP servers.`
);
}
return null;
}

/** One circuit-breaker beat: pull a fresh usage sample per active agent, append
* it to the durable cost ledger (the SOLE durable cost store), tick the breaker,
* emit each BreakerState on control:breakerState (Seam 2), and enforce any
Expand Down Expand Up @@ -2789,7 +2814,7 @@ async function spawnAgentCore(opts: AgentSpawnOptions, owner: Electron.WebConten
kgCliPath: knowledge.env().KG_CLI,
theme: readConfig().terminalTheme ?? 'light',
// W3 — default-MCP consent state + the bundled skills source dir.
mcpDefaults: readConfig().mcpDefaults,
mcpDefaults: mcpDefaultsForSpawn(),
skillsDir: skillsResourceDir(),
// The shared palace is mutated by the agent's own `mempalace` calls, so
// the OS sandbox must let it through (empty when memory is off).
Expand Down Expand Up @@ -3198,6 +3223,10 @@ ipcMain.handle('integrations:test', async (_evt, payload: unknown) => {

// ─── IPC: config ────────────────────────────────────────────────────────────
ipcMain.handle('config:get', (): HarnessConfig => readConfig());
ipcMain.handle('config:getMcpConsentState', () => getMcpConsentState());
ipcMain.handle('config:setMcpServerEnabled', (_evt, catalogId: unknown, enabled: unknown) =>
setMcpServerEnabled(catalogId, enabled)
);
ipcMain.handle('config:update', (_evt, patch: Partial<HarnessConfig>) => {
// FIRST RUN: every hive-bound service is started by bootstrapHiveServices(),
// which runs once at app-ready and early-returns on `!hive.enabled()` — i.e.
Expand Down
7 changes: 6 additions & 1 deletion src/preload/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { contextBridge, ipcRenderer, webUtils, type IpcRendererEvent } from 'electron';
import type { AgentProvider } from '../shared/agentProvider';
import type { McpDefaultsMap, McpConsentStateResult } from '../shared/mcpCatalog';
import type { HireManifest } from '../shared/hire';
export type { HireManifest } from '../shared/hire';
import type { IntegrationRecord, IntegrationTemplate } from '../shared/integrations';
Expand Down Expand Up @@ -275,7 +276,7 @@ export interface HarnessConfig {
godModel?: string;
/** Per-server consent for the default MCP bundle, keyed by catalog id. Mirrors
* src/main/config.ts. */
mcpDefaults?: { [id: string]: { enabled: boolean } };
mcpDefaults?: McpDefaultsMap;
semanticMemory: boolean;
embeddingModel: 'minilm' | 'embeddinggemma';
missions?: ScheduledMission[];
Expand Down Expand Up @@ -659,6 +660,10 @@ const api = {
ipcRenderer.invoke('config:get'),
updateConfig: (patch: Partial<HarnessConfig>): Promise<HarnessConfig> =>
ipcRenderer.invoke('config:update', patch),
getMcpConsentState: (): Promise<McpConsentStateResult> =>
ipcRenderer.invoke('config:getMcpConsentState'),
setMcpServerEnabled: (catalogId: string, enabled: boolean): Promise<HarnessConfig> =>
ipcRenderer.invoke('config:setMcpServerEnabled', catalogId, enabled),
/** Set or clear one per-agent token ceiling against main's latest config. */
setAgentTokenCap: (agentId: string, tokenCap?: number): Promise<HarnessConfig> =>
ipcRenderer.invoke('config:setAgentTokenCap', agentId, tokenCap),
Expand Down
Loading
Loading