995b9a8548
companion.cwd (created if missing; ~/$HOME expanded) sets the sidecar's working directory so the WhatsApp bridge writes its session store to a writable path (~/.neuron/whatsapp), never the read-only app bundle. Typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
92 lines
3.8 KiB
TypeScript
92 lines
3.8 KiB
TypeScript
import { readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { join, dirname } from "node:path";
|
|
|
|
// A single configured MCP server. Phase 1 supports stdio only; `http` transport
|
|
// (remote MCP + OAuth) lands in Phase 3.
|
|
export interface ServerConfig {
|
|
transport: "stdio" | "http";
|
|
enabled: boolean;
|
|
// stdio
|
|
command?: string;
|
|
args?: string[];
|
|
env?: Record<string, string>;
|
|
// Companion sidecar: a long-running process this connector's MCP server talks to (e.g. the
|
|
// WhatsApp bridge on :8080 that holds the paired session). connectd starts it as a CHILD when the
|
|
// connector connects, waits for healthUrl to respond, then spawns the MCP server; it is killed on
|
|
// disconnect/shutdown. `command` supports @bin//@node//@bundled resolution like the server itself.
|
|
companion?: {
|
|
command: string;
|
|
args?: string[];
|
|
env?: Record<string, string>;
|
|
healthUrl?: string;
|
|
// Working directory for the sidecar (created if missing). The WhatsApp bridge writes its session
|
|
// store relative to cwd, so this must be writable (e.g. ~/.neuron/whatsapp) — never the read-only
|
|
// app bundle. "~" / "$HOME" at the start are expanded to the user's home.
|
|
cwd?: string;
|
|
};
|
|
// http (Phase 3)
|
|
url?: string;
|
|
auth?: "none" | "oauth" | "token" | "google";
|
|
scope?: string; // OAuth scope string, when auth === "oauth"
|
|
// Google connectors (Drive/Gmail/Calendar): stdio servers whose Google OAuth the bridge
|
|
// performs itself (one-click), storing tokens in the Keychain. `scopes` = the Google API
|
|
// scopes to request. Requires a Neuron-owned Google OAuth client (GOOGLE_OAUTH_CLIENT_ID);
|
|
// until that's configured the connector reports needs_setup instead of failing cryptically.
|
|
scopes?: string[];
|
|
// Phase 5: per-connector opt-in to skip the soul's approval card (read-only-leaning,
|
|
// off by default). The soul reads this from connectors.json at approval time.
|
|
autoApprove?: boolean;
|
|
// Phase 5: tool-poisoning guard. Last-known hash of this server's tool schemas; if the
|
|
// server silently changes a tool description on reconnect, the bridge flags it in the UI.
|
|
schemaHash?: string;
|
|
}
|
|
|
|
export interface ConnectorsConfig {
|
|
servers: Record<string, ServerConfig>;
|
|
}
|
|
|
|
export const CONFIG_PATH = join(homedir(), ".neuron", "connectors.json");
|
|
const SANDBOX = join(homedir(), "neuron-connectd-sandbox");
|
|
|
|
// Phase-1 default: one zero-auth filesystem server scoped to the sandbox dir.
|
|
// Once ~/.neuron/connectors.json exists, it wins.
|
|
function defaultConfig(): ConnectorsConfig {
|
|
return {
|
|
servers: {
|
|
filesystem: {
|
|
transport: "stdio",
|
|
enabled: true,
|
|
command: "npx",
|
|
args: ["-y", "@modelcontextprotocol/server-filesystem", SANDBOX],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
export function loadConfig(): ConnectorsConfig {
|
|
try {
|
|
const raw = readFileSync(CONFIG_PATH, "utf8");
|
|
const parsed = JSON.parse(raw) as ConnectorsConfig;
|
|
if (!parsed.servers || typeof parsed.servers !== "object") {
|
|
return defaultConfig();
|
|
}
|
|
return parsed;
|
|
} catch {
|
|
// No config file yet — run the Phase-1 default so the bridge is useful out of the box.
|
|
return defaultConfig();
|
|
}
|
|
}
|
|
|
|
// Atomic write of connectors.json (temp + rename), 0600. The bridge owns all writes so the
|
|
// soul stays simple and El never has to manipulate JSON. UI edits flow: UI → soul route → bridge.
|
|
export function saveConfig(config: ConnectorsConfig): void {
|
|
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
const tmp = `${CONFIG_PATH}.tmp`;
|
|
writeFileSync(tmp, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
renameSync(tmp, CONFIG_PATH);
|
|
}
|
|
|
|
export const PORT = Number(process.env.NEURON_CONNECTD_PORT ?? 7771);
|
|
export const HOST = "127.0.0.1"; // loopback only — never bind 0.0.0.0
|