From 307af2c0d36491c29bd75fe1b5a11d280b5ae110 Mon Sep 17 00:00:00 2001 From: Rodaddy Date: Tue, 9 Jun 2026 11:57:00 -0400 Subject: [PATCH 1/4] feat: per-identity credential management with groups, RBAC, and CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a credential mapping system so different users/agents get their own API keys when calling backend MCP services. Resolution chain: user-specific → group → defaults → services.json fallback. - credentials.json schema with Zod validation (groups, per-identity, defaults) - CredentialManager with CRUD, async write mutex, disk persistence (0600 perms) - Per-user connection pooling (service::userId keys) with baseServiceNames - Credential merge into service configs at /call, /list-tools, /schema time - Pool invalidation on credential changes via closeServicePattern() - 12 daemon API routes extracted to src/daemon/routes/credentials.ts - CLI: credentials set/remove/resolve/group/reload subcommands - RBAC: credentials-read (agent+), credentials-write (admin only) - Security: redacted GET responses, IDOR protection on resolve, header/env injection prevention, malformed JSON handling - 61 new tests across 4 test files Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/commands/credentials.ts | 265 ++++++++++++++ src/cli/help.ts | 35 ++ src/cli/index.ts | 2 + src/credentials/credential-manager.ts | 347 +++++++++++++++++++ src/credentials/index.ts | 14 + src/credentials/schema.ts | 68 ++++ src/daemon/auth-provider.ts | 4 +- src/daemon/auth.ts | 12 + src/daemon/credential-merge.ts | 53 +++ src/daemon/index.ts | 8 +- src/daemon/pool.ts | 72 ++-- src/daemon/routes/credentials.ts | 209 +++++++++++ src/daemon/server.ts | 66 +++- src/process/client.ts | 29 ++ src/process/index.ts | 1 + tests/credentials/credential-manager.test.ts | 324 +++++++++++++++++ tests/credentials/credential-merge.test.ts | 131 +++++++ tests/credentials/rbac.test.ts | 32 ++ tests/credentials/schema.test.ts | 126 +++++++ 19 files changed, 1766 insertions(+), 32 deletions(-) create mode 100644 src/cli/commands/credentials.ts create mode 100644 src/credentials/credential-manager.ts create mode 100644 src/credentials/index.ts create mode 100644 src/credentials/schema.ts create mode 100644 src/daemon/credential-merge.ts create mode 100644 src/daemon/routes/credentials.ts create mode 100644 tests/credentials/credential-manager.test.ts create mode 100644 tests/credentials/credential-merge.test.ts create mode 100644 tests/credentials/rbac.test.ts create mode 100644 tests/credentials/schema.test.ts diff --git a/src/cli/commands/credentials.ts b/src/cli/commands/credentials.ts new file mode 100644 index 0000000..d4dbd0a --- /dev/null +++ b/src/cli/commands/credentials.ts @@ -0,0 +1,265 @@ +/** + * CLI commands for managing per-identity credential mappings. + * Routes through daemon API for all operations. + */ +import { fetchDaemonApi } from "../../process/index.ts"; + +/** + * Dispatch credential subcommands. + * Usage: + * mcp2cli credentials list [identity] + * mcp2cli credentials set --header "Key: Value" [--env "KEY=VALUE"] + * mcp2cli credentials set-default --header "Key: Value" [--env "KEY=VALUE"] + * mcp2cli credentials remove + * mcp2cli credentials remove-default + * mcp2cli credentials resolve + * mcp2cli credentials group list + * mcp2cli credentials group add [member2...] + * mcp2cli credentials group add-members [member2...] + * mcp2cli credentials group remove + * mcp2cli credentials group remove-members [member2...] + * mcp2cli credentials reload + */ +export async function handleCredentials(args: string[]): Promise { + const subcommand = args[0]; + + switch (subcommand) { + case "list": + await handleList(args.slice(1)); + break; + case "set": + await handleSet(args.slice(1)); + break; + case "set-default": + await handleSetDefault(args.slice(1)); + break; + case "remove": + await handleRemove(args.slice(1)); + break; + case "remove-default": + await handleRemoveDefault(args.slice(1)); + break; + case "resolve": + await handleResolve(args.slice(1)); + break; + case "group": + await handleGroup(args.slice(1)); + break; + case "reload": + await handleReload(); + break; + default: + console.log( + "Usage: mcp2cli credentials ", + ); + break; + } +} + +async function handleList(args: string[]): Promise { + const result = await fetchDaemonApi("GET", "/api/credentials"); + if (args[0]) { + // Filter to specific identity + const identity = args[0]; + const creds = result as { credentials?: Record }; + const filtered = creds.credentials?.[identity] ?? null; + console.log(JSON.stringify({ identity, credentials: filtered })); + } else { + console.log(JSON.stringify(result)); + } +} + +async function handleSet(args: string[]): Promise { + const identity = args[0]; + const service = args[1]; + if (!identity || !service) { + console.log("Usage: mcp2cli credentials set --header 'Key: Value' [--env 'KEY=VALUE']"); + return; + } + const credential = parseCredentialFlags(args.slice(2)); + if (!credential) return; + + const result = await fetchDaemonApi("POST", "/api/credentials", { + identity, + service, + credential, + }); + console.log(JSON.stringify(result)); +} + +async function handleSetDefault(args: string[]): Promise { + const service = args[0]; + if (!service) { + console.log("Usage: mcp2cli credentials set-default --header 'Key: Value' [--env 'KEY=VALUE']"); + return; + } + const credential = parseCredentialFlags(args.slice(1)); + if (!credential) return; + + const result = await fetchDaemonApi("POST", "/api/credentials/defaults", { + service, + credential, + }); + console.log(JSON.stringify(result)); +} + +async function handleRemove(args: string[]): Promise { + const identity = args[0]; + const service = args[1]; + if (!identity || !service) { + console.log("Usage: mcp2cli credentials remove "); + return; + } + const result = await fetchDaemonApi("DELETE", "/api/credentials", { + identity, + service, + }); + console.log(JSON.stringify(result)); +} + +async function handleRemoveDefault(args: string[]): Promise { + const service = args[0]; + if (!service) { + console.log("Usage: mcp2cli credentials remove-default "); + return; + } + const result = await fetchDaemonApi("DELETE", "/api/credentials/defaults", { + service, + }); + console.log(JSON.stringify(result)); +} + +async function handleResolve(args: string[]): Promise { + const userId = args[0]; + const service = args[1]; + if (!userId || !service) { + console.log("Usage: mcp2cli credentials resolve "); + return; + } + const result = await fetchDaemonApi( + "GET", + `/api/credentials/resolve?userId=${encodeURIComponent(userId)}&service=${encodeURIComponent(service)}`, + ); + console.log(JSON.stringify(result)); +} + +async function handleGroup(args: string[]): Promise { + const subcommand = args[0]; + + switch (subcommand) { + case "list": { + const result = await fetchDaemonApi("GET", "/api/credentials/groups"); + console.log(JSON.stringify(result)); + break; + } + case "add": { + const name = args[1]; + const members = args.slice(2); + if (!name || members.length === 0) { + console.log("Usage: mcp2cli credentials group add [member2...]"); + return; + } + const result = await fetchDaemonApi("POST", "/api/credentials/groups", { + name, + members, + }); + console.log(JSON.stringify(result)); + break; + } + case "add-members": { + const name = args[1]; + const members = args.slice(2); + if (!name || members.length === 0) { + console.log("Usage: mcp2cli credentials group add-members [member2...]"); + return; + } + const result = await fetchDaemonApi( + "PUT", + `/api/credentials/groups/${encodeURIComponent(name)}`, + { members }, + ); + console.log(JSON.stringify(result)); + break; + } + case "remove": { + const name = args[1]; + if (!name) { + console.log("Usage: mcp2cli credentials group remove "); + return; + } + const result = await fetchDaemonApi( + "DELETE", + `/api/credentials/groups/${encodeURIComponent(name)}`, + ); + console.log(JSON.stringify(result)); + break; + } + case "remove-members": { + const name = args[1]; + const members = args.slice(2); + if (!name || members.length === 0) { + console.log("Usage: mcp2cli credentials group remove-members [member2...]"); + return; + } + const result = await fetchDaemonApi( + "DELETE", + `/api/credentials/groups/${encodeURIComponent(name)}`, + { members }, + ); + console.log(JSON.stringify(result)); + break; + } + default: + console.log( + "Usage: mcp2cli credentials group ", + ); + break; + } +} + +async function handleReload(): Promise { + const result = await fetchDaemonApi("POST", "/api/credentials/reload"); + console.log(JSON.stringify(result)); +} + +/** + * Parse --header and --env flags into a ServiceCredential object. + * --header "Authorization: Bearer xxx" -> { headers: { Authorization: "Bearer xxx" } } + * --env "API_KEY=xxx" -> { env: { API_KEY: "xxx" } } + */ +function parseCredentialFlags( + args: string[], +): { headers?: Record; env?: Record } | null { + const headers: Record = {}; + const env: Record = {}; + + for (let i = 0; i < args.length; i++) { + if (args[i] === "--header" && args[i + 1]) { + const val = args[++i]!; + const colonIdx = val.indexOf(":"); + if (colonIdx === -1) { + console.log(`Invalid header format: "${val}". Expected "Key: Value".`); + return null; + } + headers[val.slice(0, colonIdx).trim()] = val.slice(colonIdx + 1).trim(); + } else if (args[i] === "--env" && args[i + 1]) { + const val = args[++i]!; + const eqIdx = val.indexOf("="); + if (eqIdx === -1) { + console.log(`Invalid env format: "${val}". Expected "KEY=VALUE".`); + return null; + } + env[val.slice(0, eqIdx)] = val.slice(eqIdx + 1); + } + } + + if (Object.keys(headers).length === 0 && Object.keys(env).length === 0) { + console.log("Must provide at least one --header or --env flag."); + return null; + } + + const result: { headers?: Record; env?: Record } = {}; + if (Object.keys(headers).length > 0) result.headers = headers; + if (Object.keys(env).length > 0) result.env = env; + return result; +} diff --git a/src/cli/help.ts b/src/cli/help.ts index bf077a6..ffa57c8 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -82,6 +82,25 @@ export function printHelp(args?: string[]): void { description: "Execute multiple tool calls from NDJSON stdin", usage: "echo '{\"service\":\"n8n\",\"tool\":\"n8n_list_workflows\",\"params\":{}}' | mcp2cli batch [--parallel]", }, + { + name: "credentials", + description: "Manage per-identity credential mappings for backend services", + usage: "mcp2cli credentials ", + subcommands: [ + { name: "list [identity]", description: "List all credentials or filter by identity" }, + { name: "set --header 'K: V' [--env 'K=V']", description: "Set credentials for an identity on a service" }, + { name: "set-default --header 'K: V' [--env 'K=V']", description: "Set default credentials for a service" }, + { name: "remove ", description: "Remove credentials for an identity on a service" }, + { name: "remove-default ", description: "Remove default credentials for a service" }, + { name: "resolve ", description: "Show effective credential for a user on a service" }, + { name: "group list", description: "List all credential groups" }, + { name: "group add ", description: "Create a credential group" }, + { name: "group add-members ", description: "Add members to an existing group" }, + { name: "group remove ", description: "Remove a credential group" }, + { name: "group remove-members ", description: "Remove members from a group" }, + { name: "reload", description: "Reload credentials from disk" }, + ], + }, ], examples: [ "mcp2cli services", @@ -112,6 +131,22 @@ export function printHelp(args?: string[]): void { " audit View and manage tool call audit logs", " skills Manage service skill bundles (list, get, install)", " batch Execute multiple tool calls from NDJSON stdin", + " credentials Manage per-identity credential mappings", + " daemon Manage the daemon process (stop, status)", + "", + "CREDENTIAL MANAGEMENT:", + " credentials list [identity] List all or per-identity", + " credentials set --header/--env Set identity credential", + " credentials set-default --header/--env Set default credential", + " credentials remove Remove identity credential", + " credentials remove-default Remove default credential", + " credentials resolve Show effective credential", + " credentials group list List groups", + " credentials group add Create group", + " credentials group add-members Add to group", + " credentials group remove Remove group", + " credentials group remove-members Remove from group", + " credentials reload Reload from disk", "", "EXAMPLES:", " mcp2cli services", diff --git a/src/cli/index.ts b/src/cli/index.ts index bf3fc90..7cd2b42 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -14,6 +14,7 @@ import { handleGrep } from "./commands/grep.ts"; import { handleSkills } from "./commands/skills.ts"; import { handleAudit } from "./commands/audit.ts"; import { handleBatch } from "./commands/batch.ts"; +import { handleCredentials } from "./commands/credentials.ts"; import { ConfigError } from "../config/index.ts"; import { ConnectionError } from "../connection/index.ts"; import { ToolError } from "../invocation/errors.ts"; @@ -51,6 +52,7 @@ const COMMANDS: Record = { audit: handleAudit, skills: handleSkills, batch: handleBatch, + credentials: handleCredentials, daemon: handleDaemonDispatch, bootstrap: handleBootstrap, "generate-skills": handleGenerateSkills, diff --git a/src/credentials/credential-manager.ts b/src/credentials/credential-manager.ts new file mode 100644 index 0000000..3a5d49e --- /dev/null +++ b/src/credentials/credential-manager.ts @@ -0,0 +1,347 @@ +/** + * Runtime credential management with per-identity resolution, group support, + * and disk persistence. Matches ConfigManager patterns. + * + * Resolution order: user-specific → first matching group → defaults → null + */ +import { chmod } from "node:fs/promises"; +import { CredentialsConfigSchema, ServiceCredentialSchema } from "./schema.ts"; +import type { CredentialsConfig, ServiceCredential } from "./schema.ts"; +import { createLogger } from "../logger/index.ts"; +import type { ConnectionPool } from "../daemon/pool.ts"; + +const log = createLogger("credential-manager"); + +export class CredentialManager { + private config: CredentialsConfig; + private configPath: string; + private writeQueue: Promise = Promise.resolve(); + private pool: ConnectionPool | null = null; + + constructor(initialConfig: CredentialsConfig, configPath: string) { + this.config = initialConfig; + this.configPath = configPath; + } + + /** + * Load credentials from disk. Returns empty config if file doesn't exist. + */ + static async load(configPath?: string): Promise { + const path = configPath ?? getCredentialsPath(); + const file = Bun.file(path); + const exists = await file.exists(); + + if (!exists) { + log.info("no_credentials_file", { path }); + return new CredentialManager( + { groups: {}, credentials: {}, defaults: {} }, + path, + ); + } + + let raw: unknown; + try { + raw = await file.json(); + } catch (err) { + if (err instanceof SyntaxError) { + throw new CredentialManagerError(`Malformed JSON in credentials file ${path}: ${err.message}`); + } + throw err; + } + const result = CredentialsConfigSchema.safeParse(raw); + if (!result.success) { + const issues = result.error.issues + .map((i) => `${i.path.join(".")}: ${i.message}`) + .join(", "); + throw new CredentialManagerError(`Credentials validation failed: ${issues}`); + } + + log.info("credentials_loaded", { + path, + identities: Object.keys(result.data.credentials).length, + groups: Object.keys(result.data.groups).length, + defaults: Object.keys(result.data.defaults).length, + }); + return new CredentialManager(result.data, path); + } + + /** + * Resolve credentials for a given userId and service. + * Returns the highest-priority credential match (user-specific > group > defaults), + * or null if no credentials are configured for this combination. + */ + resolve(userId: string, serviceName: string): ServiceCredential | null { + // 1. Direct user match + const userCreds = this.config.credentials[userId]?.[serviceName]; + if (userCreds) return userCreds; + + // 2. Group match (first matching group wins) + for (const [groupName, members] of Object.entries(this.config.groups)) { + if (members.includes(userId)) { + const groupCreds = this.config.credentials[groupName]?.[serviceName]; + if (groupCreds) return groupCreds; + } + } + + // 3. Defaults + const defaultCreds = this.config.defaults[serviceName]; + if (defaultCreds) return defaultCreds; + + return null; + } + + /** Get the full config snapshot (read-only). */ + getConfig(): CredentialsConfig { + return structuredClone(this.config); + } + + /** Get config with header/env values redacted (first 4 chars + "***"). */ + getRedactedConfig(): CredentialsConfig { + const clone = structuredClone(this.config); + const redact = (val: string): string => + val.length <= 4 ? "***" : val.slice(0, 4) + "***"; + const redactCred = (cred: ServiceCredential): void => { + if (cred.headers) { + for (const key of Object.keys(cred.headers)) { + cred.headers[key] = redact(cred.headers[key]!); + } + } + if (cred.env) { + for (const key of Object.keys(cred.env)) { + cred.env[key] = redact(cred.env[key]!); + } + } + }; + for (const identity of Object.values(clone.credentials)) { + for (const cred of Object.values(identity)) { + redactCred(cred); + } + } + for (const cred of Object.values(clone.defaults)) { + redactCred(cred); + } + return clone; + } + + /** Attach pool reference for credential-change-driven connection eviction. */ + setPool(pool: ConnectionPool): void { + this.pool = pool; + } + + /** List all identity/group names that have credentials. */ + get identityNames(): string[] { + return Object.keys(this.config.credentials); + } + + /** List all group names. */ + get groupNames(): string[] { + return Object.keys(this.config.groups); + } + + /** Get members of a group. */ + getGroupMembers(groupName: string): string[] | null { + return this.config.groups[groupName] ?? null; + } + + /** List groups a userId belongs to. */ + getGroupsForUser(userId: string): string[] { + return Object.entries(this.config.groups) + .filter(([, members]) => members.includes(userId)) + .map(([name]) => name); + } + + // --- Write operations --- + + /** + * Set credentials for a specific identity and service. + */ + async setCredential( + identity: string, + serviceName: string, + credential: unknown, + ): Promise { + const validated = this.validateCredential(credential); + if (!this.config.credentials[identity]) { + this.config.credentials[identity] = {}; + } + this.config.credentials[identity]![serviceName] = validated; + await this.writeToDisk(); + if (this.pool) { + await this.pool.closeServicePattern(serviceName); + } + log.info("credential_set", { identity, service: serviceName }); + } + + /** + * Set a default credential for a service. + */ + async setDefault(serviceName: string, credential: unknown): Promise { + const validated = this.validateCredential(credential); + this.config.defaults[serviceName] = validated; + await this.writeToDisk(); + if (this.pool) { + await this.pool.closeServicePattern(serviceName); + } + log.info("default_set", { service: serviceName }); + } + + /** + * Remove credentials for a specific identity and service. + */ + async removeCredential(identity: string, serviceName: string): Promise { + const identityCreds = this.config.credentials[identity]; + if (!identityCreds?.[serviceName]) { + throw new CredentialManagerError( + `No credential found for identity '${identity}' on service '${serviceName}'`, + ); + } + delete identityCreds[serviceName]; + if (Object.keys(identityCreds).length === 0) { + delete this.config.credentials[identity]; + } + await this.writeToDisk(); + if (this.pool) { + await this.pool.closeServicePattern(serviceName); + } + log.info("credential_removed", { identity, service: serviceName }); + } + + /** + * Remove a default credential for a service. + */ + async removeDefault(serviceName: string): Promise { + if (!this.config.defaults[serviceName]) { + throw new CredentialManagerError( + `No default credential found for service '${serviceName}'`, + ); + } + delete this.config.defaults[serviceName]; + await this.writeToDisk(); + if (this.pool) { + await this.pool.closeServicePattern(serviceName); + } + log.info("default_removed", { service: serviceName }); + } + + /** + * Add a group with initial members. + */ + async addGroup(groupName: string, members: string[]): Promise { + if (this.config.groups[groupName]) { + throw new CredentialManagerError(`Group already exists: ${groupName}`); + } + this.config.groups[groupName] = members; + await this.writeToDisk(); + log.info("group_added", { group: groupName, members }); + } + + /** + * Add members to an existing group. + */ + async addGroupMembers(groupName: string, members: string[]): Promise { + const group = this.config.groups[groupName]; + if (!group) { + throw new CredentialManagerError(`Group not found: ${groupName}`); + } + const newMembers = members.filter((m) => !group.includes(m)); + group.push(...newMembers); + await this.writeToDisk(); + log.info("group_members_added", { group: groupName, added: newMembers }); + } + + /** + * Remove members from a group. + */ + async removeGroupMembers(groupName: string, members: string[]): Promise { + const group = this.config.groups[groupName]; + if (!group) { + throw new CredentialManagerError(`Group not found: ${groupName}`); + } + this.config.groups[groupName] = group.filter((m) => !members.includes(m)); + await this.writeToDisk(); + log.info("group_members_removed", { group: groupName, removed: members }); + } + + /** + * Remove an entire group. Does NOT remove credentials assigned to that group name. + */ + async removeGroup(groupName: string): Promise { + if (!this.config.groups[groupName]) { + throw new CredentialManagerError(`Group not found: ${groupName}`); + } + delete this.config.groups[groupName]; + await this.writeToDisk(); + log.info("group_removed", { group: groupName }); + } + + /** + * Reload config from disk. + */ + async reloadFromDisk(): Promise { + const file = Bun.file(this.configPath); + const exists = await file.exists(); + if (!exists) { + throw new CredentialManagerError(`Credentials file not found: ${this.configPath}`); + } + let raw: unknown; + try { + raw = await file.json(); + } catch (err) { + if (err instanceof SyntaxError) { + throw new CredentialManagerError(`Malformed JSON in credentials file ${this.configPath}: ${err.message}`); + } + throw err; + } + const result = CredentialsConfigSchema.safeParse(raw); + if (!result.success) { + const issues = result.error.issues + .map((i) => `${i.path.join(".")}: ${i.message}`) + .join(", "); + throw new CredentialManagerError(`Credentials validation failed: ${issues}`); + } + this.config = result.data; + log.info("credentials_reloaded", { path: this.configPath }); + } + + // --- Private helpers --- + + private validateCredential(raw: unknown): ServiceCredential { + const result = ServiceCredentialSchema.safeParse(raw); + if (!result.success) { + const issues = result.error.issues + .map((i) => `${i.path.join(".")}: ${i.message}`) + .join(", "); + throw new CredentialManagerError(`Invalid credential: ${issues}`); + } + return result.data; + } + + private writeToDisk(): Promise { + this.writeQueue = this.writeQueue.then(() => this.doWrite()); + return this.writeQueue; + } + + private async doWrite(): Promise { + await Bun.write( + this.configPath, + JSON.stringify(this.config, null, 2) + "\n", + ); + await chmod(this.configPath, 0o600); + log.debug("credentials_written", { path: this.configPath }); + } +} + +export class CredentialManagerError extends Error { + constructor(message: string) { + super(message); + this.name = "CredentialManagerError"; + } +} + +function getCredentialsPath(): string { + if (process.env.MCP2CLI_CREDENTIALS_FILE) { + return process.env.MCP2CLI_CREDENTIALS_FILE; + } + const home = process.env.HOME ?? ""; + return `${home}/.config/mcp2cli/credentials.json`; +} diff --git a/src/credentials/index.ts b/src/credentials/index.ts new file mode 100644 index 0000000..3f5f0eb --- /dev/null +++ b/src/credentials/index.ts @@ -0,0 +1,14 @@ +export { + CredentialsConfigSchema, + ServiceCredentialSchema, +} from "./schema.ts"; + +export type { + CredentialsConfig, + ServiceCredential, +} from "./schema.ts"; + +export { + CredentialManager, + CredentialManagerError, +} from "./credential-manager.ts"; diff --git a/src/credentials/schema.ts b/src/credentials/schema.ts new file mode 100644 index 0000000..8e04b83 --- /dev/null +++ b/src/credentials/schema.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; + +const DANGEROUS_HEADERS = new Set([ + "host", + "transfer-encoding", + "content-length", + "connection", +]); + +const DANGEROUS_ENV_VARS = new Set([ + "PATH", + "LD_PRELOAD", + "LD_LIBRARY_PATH", + "DYLD_LIBRARY_PATH", + "NODE_OPTIONS", + "BUN_FLAGS", +]); + +/** + * Per-service credential overrides. + * For http/websocket: override headers sent to the backend. + * For stdio: override env vars passed to the spawned process. + */ +export const ServiceCredentialSchema = z.object({ + headers: z.record( + z.string().refine( + (name) => !DANGEROUS_HEADERS.has(name.toLowerCase()), + { message: "Dangerous header name (Host, Transfer-Encoding, Content-Length, Connection)" }, + ), + z.string().refine( + (val) => !/[\r\n]/.test(val), + { message: "Header values must not contain \\r or \\n" }, + ), + ).optional(), + env: z.record( + z.string().refine( + (name) => !DANGEROUS_ENV_VARS.has(name), + { message: "Dangerous env var name (PATH, LD_PRELOAD, LD_LIBRARY_PATH, DYLD_LIBRARY_PATH, NODE_OPTIONS, BUN_FLAGS)" }, + ), + z.string(), + ).optional(), +}).refine( + (data) => data.headers !== undefined || data.env !== undefined, + { message: "Credential must have at least one of 'headers' or 'env'" }, +); + +/** + * Credential mapping: identity/group name -> service name -> credential overrides. + */ +const CredentialMapSchema = z.record( + z.string(), + z.record(z.string(), ServiceCredentialSchema), +); + +/** + * Root credentials.json schema. + * - groups: named sets of user/agent IDs for shared credential assignment + * - credentials: per-identity or per-group credential overrides + * - defaults: fallback credentials when no identity/group match exists + */ +export const CredentialsConfigSchema = z.object({ + groups: z.record(z.string(), z.array(z.string())).optional().default({}), + credentials: CredentialMapSchema.optional().default({}), + defaults: z.record(z.string(), ServiceCredentialSchema).optional().default({}), +}); + +export type ServiceCredential = z.infer; +export type CredentialsConfig = z.infer; diff --git a/src/daemon/auth-provider.ts b/src/daemon/auth-provider.ts index a6f9f2a..0f230c5 100644 --- a/src/daemon/auth-provider.ts +++ b/src/daemon/auth-provider.ts @@ -32,8 +32,8 @@ export interface AuthProvider { /** Permissions by role. Higher roles inherit lower permissions. */ const ROLE_PERMISSIONS: Record> = { viewer: new Set(["list", "status"]), - agent: new Set(["list", "status", "call", "list-tools", "schema"]), - admin: new Set(["list", "status", "call", "list-tools", "schema", "add", "update", "remove", "reload", "import", "shutdown"]), + agent: new Set(["list", "status", "call", "list-tools", "schema", "credentials-read"]), + admin: new Set(["list", "status", "call", "list-tools", "schema", "add", "update", "remove", "reload", "import", "shutdown", "credentials-read", "credentials-write"]), }; /** Check if a role has a specific permission. */ diff --git a/src/daemon/auth.ts b/src/daemon/auth.ts index 158f5aa..caabeec 100644 --- a/src/daemon/auth.ts +++ b/src/daemon/auth.ts @@ -92,6 +92,18 @@ const PATH_PERMISSIONS: Array<{ pattern: RegExp; method: string; permission: str { pattern: /^\/api\/services\/[^/]+$/, method: "PUT", permission: "update" }, { pattern: /^\/api\/services\/[^/]+$/, method: "DELETE", permission: "remove" }, { pattern: /^\/api\/services\/[^/]+\/status$/, method: "GET", permission: "status" }, + // Credential management API + { pattern: /^\/api\/credentials$/, method: "GET", permission: "credentials-read" }, + { pattern: /^\/api\/credentials\/resolve$/, method: "GET", permission: "credentials-read" }, + { pattern: /^\/api\/credentials\/groups$/, method: "GET", permission: "credentials-read" }, + { pattern: /^\/api\/credentials$/, method: "POST", permission: "credentials-write" }, + { pattern: /^\/api\/credentials$/, method: "DELETE", permission: "credentials-write" }, + { pattern: /^\/api\/credentials\/defaults$/, method: "POST", permission: "credentials-write" }, + { pattern: /^\/api\/credentials\/defaults$/, method: "DELETE", permission: "credentials-write" }, + { pattern: /^\/api\/credentials\/groups$/, method: "POST", permission: "credentials-write" }, + { pattern: /^\/api\/credentials\/groups\/[^/]+$/, method: "PUT", permission: "credentials-write" }, + { pattern: /^\/api\/credentials\/groups\/[^/]+$/, method: "DELETE", permission: "credentials-write" }, + { pattern: /^\/api\/credentials\/reload$/, method: "POST", permission: "credentials-write" }, ]; /** diff --git a/src/daemon/credential-merge.ts b/src/daemon/credential-merge.ts new file mode 100644 index 0000000..09291bb --- /dev/null +++ b/src/daemon/credential-merge.ts @@ -0,0 +1,53 @@ +/** + * Merge per-user credential overrides into a service config. + * Creates a new config object — never mutates the original. + */ +import type { ServiceConfig } from "../config/index.ts"; +import type { ServiceCredential } from "../credentials/index.ts"; +import { createLogger } from "../logger/index.ts"; + +const log = createLogger("credential-merge"); + +/** + * Apply credential overrides to a service config. + * - For http/websocket: merges headers (user headers override service defaults) + * - For stdio: merges env vars (user env overrides service defaults) + * Returns a new ServiceConfig with credentials applied. + */ +export function mergeCredentials( + serviceConfig: ServiceConfig, + credential: ServiceCredential, +): ServiceConfig { + const merged = structuredClone(serviceConfig); + + if (credential.headers && "headers" in merged) { + merged.headers = { ...merged.headers, ...credential.headers }; + } else if (credential.headers) { + log.warn("credential_field_mismatch", { + field: "headers", + backend: merged.backend, + message: "Credential has headers but service config has no headers field", + }); + } + + if (credential.env && "env" in merged) { + merged.env = { ...merged.env, ...credential.env }; + } else if (credential.env) { + log.warn("credential_field_mismatch", { + field: "env", + backend: merged.backend, + message: "Credential has env but service config has no env field", + }); + } + + return merged; +} + +/** + * Build a pool key that includes the userId when per-user credentials exist. + * Standard connections use just the service name; per-user connections + * include the userId to maintain separate transports with different credentials. + */ +export function userPoolKey(serviceName: string, userId?: string): string { + return userId ? `${serviceName}::${userId}` : serviceName; +} diff --git a/src/daemon/index.ts b/src/daemon/index.ts index d4a9e9a..7944f93 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -15,6 +15,7 @@ import { createLogger } from "../logger/index.ts"; import { MetricsCollector } from "./metrics.ts"; import { ConfigManager } from "./config-manager.ts"; import { TokenAuthProvider } from "./auth-provider.ts"; +import { CredentialManager } from "../credentials/index.ts"; const log = createLogger("daemon"); @@ -68,12 +69,16 @@ export async function startDaemon(): Promise { }); } + // Load credential manager (per-identity credential mapping) + const credentialManager = await CredentialManager.load(); + // Create connection pool and metrics collector const pool = new ConnectionPool(); const metrics = new MetricsCollector(); - // Wire pool into config manager for connection lifecycle + // Wire pool into config manager and credential manager for connection lifecycle configManager.setPool(pool); + credentialManager.setPool(pool); // Parse idle timeout from env (seconds -> ms) // TCP mode: default to 0 (disabled) since it's a long-running network service @@ -128,6 +133,7 @@ export async function startDaemon(): Promise { pool, config, configManager, + credentialManager, idleTimer, onShutdown: () => { void gracefulShutdown(); diff --git a/src/daemon/pool.ts b/src/daemon/pool.ts index 63d503c..99ea415 100644 --- a/src/daemon/pool.ts +++ b/src/daemon/pool.ts @@ -60,38 +60,44 @@ export class ConnectionPool { * Concurrent calls for the same unconnected service share one connection attempt. * MEM-05: Cached connections are health-checked before reuse. * MEM-04: New connections are rejected if pool is at capacity. + * + * @param poolKey - The cache key (service name, or "service::userId" for per-user connections) + * @param config - Full services config (used to look up service by name) + * @param serviceConfigOverride - Optional pre-merged service config (skips config lookup when provided) */ async getConnection( - serviceName: string, + poolKey: string, config: ServicesConfig, + serviceConfigOverride?: import("../config/index.ts").ServiceConfig, ): Promise { // Check cached connection with health validation - const cached = this.connections.get(serviceName); + const cached = this.connections.get(poolKey); if (cached) { const healthy = await this.isHealthy(cached.connection); if (healthy) return cached.connection; // Stale connection -- remove and reconnect - log.warn("stale_connection", { service: serviceName }); - this.connections.delete(serviceName); + log.warn("stale_connection", { service: poolKey }); + this.connections.delete(poolKey); await cached.connection.close().catch(() => {}); // Fall through to create new connection } // Check if someone else is already connecting - const pendingPromise = this.pending.get(serviceName); + const pendingPromise = this.pending.get(poolKey); if (pendingPromise) return pendingPromise; // MEM-04: Enforce pool size limit for genuinely new connections - if (this.connections.size >= this._maxSize && !this.connections.has(serviceName)) { + if (this.connections.size >= this._maxSize && !this.connections.has(poolKey)) { throw new ConnectionError( `Connection pool limit reached (${this._maxSize}). Close unused connections first.`, "pool_limit_reached", ); } - // Look up service config - const serviceConfig = config.services[serviceName]; + // Use override if provided, otherwise look up by service name + const serviceName = poolKey.split("::")[0]!; + const serviceConfig = serviceConfigOverride ?? config.services[serviceName]; if (!serviceConfig) { throw new ConnectionError( `Service not found in config: ${serviceName}`, @@ -99,29 +105,29 @@ export class ConnectionPool { ); } // Create connection promise and store in pending map - log.info("connecting", { service: serviceName }); + log.info("connecting", { service: poolKey }); let connectFn: () => Promise; if (serviceConfig.backend === "http") { - connectFn = () => this.connectHttpWithFallback(serviceName, serviceConfig); + connectFn = () => this.connectHttpWithFallback(poolKey, serviceConfig); } else if (serviceConfig.backend === "websocket") { - connectFn = () => this.connectWebSocketWithFallback(serviceName, serviceConfig); + connectFn = () => this.connectWebSocketWithFallback(poolKey, serviceConfig); } else if (serviceConfig.backend === "stdio") { connectFn = () => connectToService(serviceConfig); } else { const backend = (serviceConfig as { backend: string }).backend; throw new ConnectionError( - `Unsupported backend for service ${serviceName}: ${backend}`, + `Unsupported backend for service ${poolKey}: ${backend}`, "unsupported_backend", ); } const connectPromise = connectFn().then( (connection) => { - this.connections.set(serviceName, { + this.connections.set(poolKey, { connection, connectedAt: Date.now(), }); - this.pending.delete(serviceName); - log.info("connected", { service: serviceName }); + this.pending.delete(poolKey); + log.info("connected", { service: poolKey }); // ADV-02: Fire-and-forget drift check on new connection // ADV-06: Pass access policy for skill auto-regeneration filtering const policy = extractPolicy(serviceConfig); @@ -129,18 +135,18 @@ export class ConnectionPool { return connection; }, (err) => { - this.pending.delete(serviceName); + this.pending.delete(poolKey); const message = err instanceof Error ? err.message : String(err); - log.error("connect_failed", { service: serviceName, error: message }); + log.error("connect_failed", { service: poolKey, error: message }); throw err; }, ); - this.pending.set(serviceName, connectPromise); + this.pending.set(poolKey, connectPromise); return connectPromise; } - /** Close and remove a single service connection. */ + /** Close and remove a service connection by exact key, plus any per-user connections. */ async closeService(serviceName: string): Promise { const entry = this.connections.get(serviceName); if (entry) { @@ -148,6 +154,23 @@ export class ConnectionPool { this.connections.delete(serviceName); await entry.connection.close(); } + await this.closeServicePattern(serviceName); + } + + /** Close all per-user connections for a service (keys matching "serviceName::*"). */ + async closeServicePattern(serviceName: string): Promise { + const prefix = serviceName + "::"; + const toClose: Array<[string, PoolEntry]> = []; + for (const [key, entry] of this.connections) { + if (key.startsWith(prefix)) { + toClose.push([key, entry]); + } + } + for (const [key, entry] of toClose) { + log.info("disconnecting", { service: key }); + this.connections.delete(key); + await entry.connection.close().catch(() => {}); + } } /** Close all connections. Best-effort -- won't throw on individual failures. */ @@ -163,11 +186,20 @@ export class ConnectionPool { return this.connections.size; } - /** Names of all connected services. */ + /** Names of all connected services (raw pool keys, may include ::userId suffixes). */ get serviceNames(): string[] { return Array.from(this.connections.keys()); } + /** Base service names with ::userId suffixes stripped. Deduplicated. */ + get baseServiceNames(): string[] { + const names = new Set(); + for (const key of this.connections.keys()) { + names.add(key.split("::")[0]!); + } + return Array.from(names); + } + /** * Pre-connect to all configured services at startup. * Connections are batched (4 at a time) with a 15s per-service timeout. diff --git a/src/daemon/routes/credentials.ts b/src/daemon/routes/credentials.ts new file mode 100644 index 0000000..b91c749 --- /dev/null +++ b/src/daemon/routes/credentials.ts @@ -0,0 +1,209 @@ +import type { CredentialManager } from "../../credentials/index.ts"; +import { CredentialManagerError } from "../../credentials/index.ts"; +import type { AuthContext } from "../auth-provider.ts"; +import type { ErrorCode } from "../../types/index.ts"; +import type { DaemonErrorResponse } from "../types.ts"; + +function errorResponse( + code: ErrorCode, + message: string, + reason?: string, + status = 500, +): Response { + const body: DaemonErrorResponse = { + success: false, + error: { code, message, reason }, + }; + return Response.json(body, { status }); +} + +export async function handleCredentialRoutes( + req: Request, + url: URL, + path: string, + credentialManager: CredentialManager, + authCtx: AuthContext | null, +): Promise { + // GET /api/credentials -- list all credentials (redacted) + if (path === "/api/credentials" && req.method === "GET") { + const cfg = credentialManager.getRedactedConfig(); + return Response.json({ success: true, ...cfg }); + } + + // GET /api/credentials/resolve?userId=X&service=Y -- resolve effective credential + if (path === "/api/credentials/resolve" && req.method === "GET") { + const userId = url.searchParams.get("userId"); + const service = url.searchParams.get("service"); + if (!userId || !service) { + return errorResponse("INPUT_VALIDATION_ERROR", "Missing 'userId' or 'service' query param", undefined, 400); + } + if (authCtx && authCtx.userId !== userId && authCtx.role !== "admin") { + return errorResponse("AUTH_ERROR", "Agents can only resolve their own credentials", undefined, 403); + } + const resolved = credentialManager.resolve(userId, service); + return Response.json({ success: true, userId, service, credential: resolved }); + } + + // POST /api/credentials -- set credential { identity, service, credential } + if (path === "/api/credentials" && req.method === "POST") { + try { + const body = await req.json() as { identity: string; service: string; credential: unknown }; + if (!body.identity || !body.service || !body.credential) { + return errorResponse("INPUT_VALIDATION_ERROR", "Missing 'identity', 'service', or 'credential' field", undefined, 400); + } + await credentialManager.setCredential(body.identity, body.service, body.credential); + return Response.json({ success: true, message: `Credential set for '${body.identity}' on '${body.service}'` }, { status: 201 }); + } catch (err) { + if (err instanceof CredentialManagerError) { + return errorResponse("INPUT_VALIDATION_ERROR", err.message, undefined, 400); + } + return errorResponse("INTERNAL_ERROR", err instanceof Error ? err.message : String(err)); + } + } + + // DELETE /api/credentials -- remove credential { identity, service } + if (path === "/api/credentials" && req.method === "DELETE") { + try { + let body: { identity?: string; service?: string }; + try { + body = await req.json() as { identity: string; service: string }; + } catch { + return errorResponse("INPUT_VALIDATION_ERROR", "Malformed JSON body", undefined, 400); + } + if (!body.identity || !body.service) { + return errorResponse("INPUT_VALIDATION_ERROR", "Missing 'identity' or 'service' field", undefined, 400); + } + await credentialManager.removeCredential(body.identity, body.service); + return Response.json({ success: true, message: `Credential removed for '${body.identity}' on '${body.service}'` }); + } catch (err) { + if (err instanceof CredentialManagerError) { + return errorResponse("INPUT_VALIDATION_ERROR", err.message, undefined, 400); + } + return errorResponse("INTERNAL_ERROR", err instanceof Error ? err.message : String(err)); + } + } + + // POST /api/credentials/defaults -- set default credential { service, credential } + if (path === "/api/credentials/defaults" && req.method === "POST") { + try { + const body = await req.json() as { service: string; credential: unknown }; + if (!body.service || !body.credential) { + return errorResponse("INPUT_VALIDATION_ERROR", "Missing 'service' or 'credential' field", undefined, 400); + } + await credentialManager.setDefault(body.service, body.credential); + return Response.json({ success: true, message: `Default credential set for '${body.service}'` }, { status: 201 }); + } catch (err) { + if (err instanceof CredentialManagerError) { + return errorResponse("INPUT_VALIDATION_ERROR", err.message, undefined, 400); + } + return errorResponse("INTERNAL_ERROR", err instanceof Error ? err.message : String(err)); + } + } + + // DELETE /api/credentials/defaults -- remove default { service } + if (path === "/api/credentials/defaults" && req.method === "DELETE") { + try { + let body: { service?: string }; + try { + body = await req.json() as { service: string }; + } catch { + return errorResponse("INPUT_VALIDATION_ERROR", "Malformed JSON body", undefined, 400); + } + if (!body.service) { + return errorResponse("INPUT_VALIDATION_ERROR", "Missing 'service' field", undefined, 400); + } + await credentialManager.removeDefault(body.service); + return Response.json({ success: true, message: `Default credential removed for '${body.service}'` }); + } catch (err) { + if (err instanceof CredentialManagerError) { + return errorResponse("INPUT_VALIDATION_ERROR", err.message, undefined, 400); + } + return errorResponse("INTERNAL_ERROR", err instanceof Error ? err.message : String(err)); + } + } + + // GET /api/credentials/groups -- list all groups + if (path === "/api/credentials/groups" && req.method === "GET") { + const cfg = credentialManager.getRedactedConfig(); + return Response.json({ success: true, groups: cfg.groups }); + } + + // POST /api/credentials/groups -- create group { name, members } + if (path === "/api/credentials/groups" && req.method === "POST") { + try { + const body = await req.json() as { name: string; members: string[] }; + if (!body.name || !Array.isArray(body.members)) { + return errorResponse("INPUT_VALIDATION_ERROR", "Missing 'name' or 'members' field", undefined, 400); + } + await credentialManager.addGroup(body.name, body.members); + return Response.json({ success: true, message: `Group '${body.name}' created` }, { status: 201 }); + } catch (err) { + if (err instanceof CredentialManagerError) { + return errorResponse("INPUT_VALIDATION_ERROR", err.message, undefined, 400); + } + return errorResponse("INTERNAL_ERROR", err instanceof Error ? err.message : String(err)); + } + } + + // PUT /api/credentials/groups/:name -- add members { members } + const groupPutMatch = path.match(/^\/api\/credentials\/groups\/([^/]+)$/); + if (groupPutMatch && req.method === "PUT") { + try { + const name = decodeURIComponent(groupPutMatch[1]!); + const body = await req.json() as { members: string[] }; + if (!Array.isArray(body.members)) { + return errorResponse("INPUT_VALIDATION_ERROR", "Missing 'members' field", undefined, 400); + } + await credentialManager.addGroupMembers(name, body.members); + return Response.json({ success: true, message: `Members added to group '${name}'` }); + } catch (err) { + if (err instanceof CredentialManagerError) { + return errorResponse("INPUT_VALIDATION_ERROR", err.message, undefined, 400); + } + return errorResponse("INTERNAL_ERROR", err instanceof Error ? err.message : String(err)); + } + } + + // DELETE /api/credentials/groups/:name -- remove group or members + const groupDeleteMatch = path.match(/^\/api\/credentials\/groups\/([^/]+)$/); + if (groupDeleteMatch && req.method === "DELETE") { + try { + const name = decodeURIComponent(groupDeleteMatch[1]!); + let body: { members?: string[] } = {}; + try { + body = await req.json() as { members?: string[] }; + } catch { + const contentLength = req.headers.get("content-length"); + if (contentLength && contentLength !== "0") { + return errorResponse("INPUT_VALIDATION_ERROR", "Malformed JSON body", undefined, 400); + } + } + if (body.members && Array.isArray(body.members)) { + await credentialManager.removeGroupMembers(name, body.members); + return Response.json({ success: true, message: `Members removed from group '${name}'` }); + } + await credentialManager.removeGroup(name); + return Response.json({ success: true, message: `Group '${name}' removed` }); + } catch (err) { + if (err instanceof CredentialManagerError) { + return errorResponse("INPUT_VALIDATION_ERROR", err.message, undefined, 400); + } + return errorResponse("INTERNAL_ERROR", err instanceof Error ? err.message : String(err)); + } + } + + // POST /api/credentials/reload -- reload from disk + if (path === "/api/credentials/reload" && req.method === "POST") { + try { + await credentialManager.reloadFromDisk(); + return Response.json({ success: true, message: "Credentials reloaded" }); + } catch (err) { + if (err instanceof CredentialManagerError) { + return errorResponse("INPUT_VALIDATION_ERROR", err.message, undefined, 400); + } + return errorResponse("INTERNAL_ERROR", err instanceof Error ? err.message : String(err)); + } + } + + return null; +} diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 56f371f..a6d3123 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -28,6 +28,9 @@ import { TokenAuthProvider } from "./auth-provider.ts"; import type { AuthProvider, AuthContext } from "./auth-provider.ts"; import type { MetricsCollector } from "./metrics.ts"; import { ConfigManager, ConfigManagerError } from "./config-manager.ts"; +import type { CredentialManager } from "../credentials/index.ts"; +import { mergeCredentials, userPoolKey } from "./credential-merge.ts"; +import { handleCredentialRoutes } from "./routes/credentials.ts"; import { renderUI } from "./ui.ts"; import pkg from "../../package.json" with { type: "json" }; @@ -39,6 +42,7 @@ interface DaemonServerOptions { pool: ConnectionPool; config: ServicesConfig; configManager?: ConfigManager; + credentialManager?: CredentialManager; idleTimer: IdleTimer; onShutdown: () => void; authProvider: AuthProvider; @@ -63,7 +67,7 @@ function errorResponse( * Returns the Bun.serve() server instance. */ export function createDaemonServer(opts: DaemonServerOptions) { - const { listenConfig, pool, config, configManager, idleTimer, onShutdown, authProvider, metrics } = opts; + const { listenConfig, pool, config, configManager, credentialManager, idleTimer, onShutdown, authProvider, metrics } = opts; // Use configManager's live config for pool lookups when available const getConfig = (): ServicesConfig => configManager ? configManager.getServices() : config; @@ -122,11 +126,25 @@ export function createDaemonServer(opts: DaemonServerOptions) { params: sanitizeParams(body.params ?? {}), }); - const conn = await pool.getConnection(body.service, getConfig()); + // Resolve per-user credentials and determine pool key + let poolKey = body.service; + let serviceConfigOverride: import("../config/index.ts").ServiceConfig | undefined; + if (credentialManager && authCtx) { + const cred = credentialManager.resolve(authCtx.userId, body.service); + if (cred) { + const baseCfg = getConfig().services[body.service]; + if (baseCfg) { + serviceConfigOverride = mergeCredentials(baseCfg, cred); + poolKey = userPoolKey(body.service, authCtx.userId); + } + } + } + + const conn = await pool.getConnection(poolKey, getConfig(), serviceConfigOverride); // MEM-02: AbortSignal timeout on tool calls // Priority: per-service config > MCP2CLI_TOOL_TIMEOUT env > 30s default - const serviceConfig = getConfig().services[body.service]; + const serviceConfig = serviceConfigOverride ?? getConfig().services[body.service]; const perServiceTimeout = serviceConfig && "timeout" in serviceConfig ? serviceConfig.timeout : undefined; const timeout = perServiceTimeout ?? parseInt(process.env.MCP2CLI_TOOL_TIMEOUT ?? "30000", 10); const controller = new AbortController(); @@ -253,7 +271,19 @@ export function createDaemonServer(opts: DaemonServerOptions) { idleTimer.onRequestStart(); try { const body = (await req.json()) as DaemonListToolsRequest; - const conn = await pool.getConnection(body.service, getConfig()); + let listPoolKey = body.service; + let listServiceOverride: import("../config/index.ts").ServiceConfig | undefined; + if (credentialManager && authCtx) { + const cred = credentialManager.resolve(authCtx.userId, body.service); + if (cred) { + const baseCfg = getConfig().services[body.service]; + if (baseCfg) { + listServiceOverride = mergeCredentials(baseCfg, cred); + listPoolKey = userPoolKey(body.service, authCtx.userId); + } + } + } + const conn = await pool.getConnection(listPoolKey, getConfig(), listServiceOverride); const tools = await listToolsCached(conn.client, body.service); return Response.json({ success: true, result: tools }); } catch (err) { @@ -268,7 +298,19 @@ export function createDaemonServer(opts: DaemonServerOptions) { idleTimer.onRequestStart(); try { const body = (await req.json()) as DaemonSchemaRequest; - const conn = await pool.getConnection(body.service, getConfig()); + let schemaPoolKey = body.service; + let schemaServiceOverride: import("../config/index.ts").ServiceConfig | undefined; + if (credentialManager && authCtx) { + const cred = credentialManager.resolve(authCtx.userId, body.service); + if (cred) { + const baseCfg = getConfig().services[body.service]; + if (baseCfg) { + schemaServiceOverride = mergeCredentials(baseCfg, cred); + schemaPoolKey = userPoolKey(body.service, authCtx.userId); + } + } + } + const conn = await pool.getConnection(schemaPoolKey, getConfig(), schemaServiceOverride); const result = await getToolSchemaCached( conn.client, body.tool, @@ -295,7 +337,7 @@ export function createDaemonServer(opts: DaemonServerOptions) { const mem = process.memoryUsage(); const currentConfig = getConfig(); const configuredServices = Object.keys(currentConfig.services); - const connectedServices = pool.serviceNames; + const connectedServices = pool.baseServiceNames; return Response.json({ status: "ok", version: pkg.version, @@ -313,7 +355,7 @@ export function createDaemonServer(opts: DaemonServerOptions) { // GET /metrics -- Prometheus metrics (auth-exempt) if (path === "/metrics" && req.method === "GET") { - const body = metrics.render(pool.size, pool.serviceNames); + const body = metrics.render(pool.size, pool.baseServiceNames); return new Response(body, { headers: { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" }, }); @@ -376,7 +418,7 @@ export function createDaemonServer(opts: DaemonServerOptions) { const services = Object.entries(cfg.services).map(([name, svc]) => ({ name, backend: svc.backend, - connected: pool.serviceNames.includes(name), + connected: pool.baseServiceNames.includes(name), ...(svc.backend !== "stdio" && "url" in svc ? { url: svc.url } : {}), })); return Response.json({ success: true, services }); @@ -442,7 +484,7 @@ export function createDaemonServer(opts: DaemonServerOptions) { if (!svc) { return errorResponse("UNKNOWN_COMMAND", `Service not found: ${name}`, undefined, 404); } - const connected = pool.serviceNames.includes(name); + const connected = pool.baseServiceNames.includes(name); let toolCount = 0; if (connected) { try { @@ -508,6 +550,12 @@ export function createDaemonServer(opts: DaemonServerOptions) { } } + // --- Credential management API routes (require credentialManager) --- + if (credentialManager) { + const credResponse = await handleCredentialRoutes(req, url, path, credentialManager, authCtx); + if (credResponse) return credResponse; + } + // Default: 404 return Response.json({ error: "not_found" }, { status: 404 }); }, diff --git a/src/process/client.ts b/src/process/client.ts index 69ea23e..f94d451 100644 --- a/src/process/client.ts +++ b/src/process/client.ts @@ -371,3 +371,32 @@ export async function getSchemaViaDaemon( ): Promise { return fetchDaemon("/schema", request); } + +/** + * Generic daemon API call supporting any HTTP method and path. + * Used by CLI commands for management endpoints (credentials, etc.). + * Always routes to local daemon (management APIs are local-only). + */ +export async function fetchDaemonApi( + method: string, + path: string, + body?: unknown, +): Promise { + const paths = getDaemonPaths(); + await ensureDaemon(paths); + const headers: Record = { + "Content-Type": "application/json", + }; + const localToken = await getLocalToken(); + if (localToken) { + headers["Authorization"] = `Bearer ${localToken}`; + } + const response = await fetch(`http://localhost${path}`, { + unix: paths.socketPath, + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + return await response.json(); +} diff --git a/src/process/index.ts b/src/process/index.ts index 314fcd5..1ec7ccb 100644 --- a/src/process/index.ts +++ b/src/process/index.ts @@ -2,6 +2,7 @@ export { callViaDaemon, listToolsViaDaemon, getSchemaViaDaemon, + fetchDaemonApi, } from "./client.ts"; export { getDaemonStatus, diff --git a/tests/credentials/credential-manager.test.ts b/tests/credentials/credential-manager.test.ts new file mode 100644 index 0000000..fdf326b --- /dev/null +++ b/tests/credentials/credential-manager.test.ts @@ -0,0 +1,324 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + CredentialManager, + CredentialManagerError, +} from "../../src/credentials/index.ts"; +import type { CredentialsConfig } from "../../src/credentials/index.ts"; + +const SAMPLE_CONFIG: CredentialsConfig = { + groups: { + ai_agents: ["skippy", "bilby", "nagatha"], + admins: ["rico"], + }, + credentials: { + rico: { + "open-brain": { headers: { Authorization: "Bearer rico-ob" } }, + n8n: { env: { N8N_API_KEY: "rico-n8n" } }, + }, + ai_agents: { + "open-brain": { headers: { Authorization: "Bearer agent-ob" } }, + n8n: { env: { N8N_API_KEY: "agent-n8n" } }, + }, + }, + defaults: { + proxmox: { headers: { Authorization: "PVEAPIToken=shared" } }, + n8n: { env: { N8N_API_KEY: "default-n8n" } }, + }, +}; + +describe("CredentialManager", () => { + let tmpDir: string; + let configPath: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "mcp2cli-cred-test-")); + configPath = join(tmpDir, "credentials.json"); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + function makeManager(config: CredentialsConfig = SAMPLE_CONFIG): CredentialManager { + return new CredentialManager(structuredClone(config), configPath); + } + + describe("load", () => { + test("loads valid config from disk", async () => { + await Bun.write(configPath, JSON.stringify(SAMPLE_CONFIG)); + const mgr = await CredentialManager.load(configPath); + expect(mgr.identityNames).toContain("rico"); + expect(mgr.groupNames).toContain("ai_agents"); + }); + + test("returns empty config when file does not exist", async () => { + const mgr = await CredentialManager.load(join(tmpDir, "nonexistent.json")); + expect(mgr.identityNames).toEqual([]); + expect(mgr.groupNames).toEqual([]); + }); + + test("throws on invalid config", async () => { + await Bun.write(configPath, JSON.stringify({ groups: { bad: "notarray" } })); + expect(CredentialManager.load(configPath)).rejects.toThrow(CredentialManagerError); + }); + }); + + describe("resolve", () => { + test("returns direct user credential when it exists", () => { + const mgr = makeManager(); + const cred = mgr.resolve("rico", "open-brain"); + expect(cred).not.toBeNull(); + expect(cred!.headers!.Authorization).toBe("Bearer rico-ob"); + }); + + test("falls back to group credential when no direct match", () => { + const mgr = makeManager(); + const cred = mgr.resolve("skippy", "open-brain"); + expect(cred).not.toBeNull(); + expect(cred!.headers!.Authorization).toBe("Bearer agent-ob"); + }); + + test("falls back to defaults when no user or group match", () => { + const mgr = makeManager(); + const cred = mgr.resolve("unknown-user", "proxmox"); + expect(cred).not.toBeNull(); + expect(cred!.headers!.Authorization).toBe("PVEAPIToken=shared"); + }); + + test("returns null when no credential at any level", () => { + const mgr = makeManager(); + const cred = mgr.resolve("unknown-user", "unknown-service"); + expect(cred).toBeNull(); + }); + + test("user-specific overrides group for the same service", () => { + const config: CredentialsConfig = { + groups: { team: ["alice"] }, + credentials: { + alice: { svc: { headers: { X: "user" } } }, + team: { svc: { headers: { X: "group" } } }, + }, + defaults: {}, + }; + const mgr = makeManager(config); + expect(mgr.resolve("alice", "svc")!.headers!.X).toBe("user"); + }); + + test("group overrides defaults for the same service", () => { + const mgr = makeManager(); + // skippy is in ai_agents which has n8n credentials + // defaults also has n8n credentials + const cred = mgr.resolve("skippy", "n8n"); + expect(cred!.env!.N8N_API_KEY).toBe("agent-n8n"); + }); + + test("first matching group wins when user is in multiple groups", () => { + const config: CredentialsConfig = { + groups: { + group_a: ["bob"], + group_b: ["bob"], + }, + credentials: { + group_a: { svc: { headers: { X: "group-a" } } }, + group_b: { svc: { headers: { X: "group-b" } } }, + }, + defaults: {}, + }; + const mgr = makeManager(config); + expect(mgr.resolve("bob", "svc")!.headers!.X).toBe("group-a"); + }); + }); + + describe("setCredential", () => { + test("sets and persists a new credential", async () => { + const mgr = makeManager({ groups: {}, credentials: {}, defaults: {} }); + await Bun.write(configPath, "{}"); + await mgr.setCredential("rico", "obv2", { headers: { Authorization: "Bearer new" } }); + + const cred = mgr.resolve("rico", "obv2"); + expect(cred!.headers!.Authorization).toBe("Bearer new"); + + // Verify disk + const disk = await Bun.file(configPath).json(); + expect(disk.credentials.rico.obv2.headers.Authorization).toBe("Bearer new"); + }); + + test("updates existing credential", async () => { + await Bun.write(configPath, JSON.stringify(SAMPLE_CONFIG)); + const mgr = makeManager(); + await mgr.setCredential("rico", "open-brain", { headers: { Authorization: "Bearer updated" } }); + expect(mgr.resolve("rico", "open-brain")!.headers!.Authorization).toBe("Bearer updated"); + }); + + test("rejects credential with neither headers nor env", async () => { + await Bun.write(configPath, "{}"); + const mgr = makeManager({ groups: {}, credentials: {}, defaults: {} }); + expect(mgr.setCredential("rico", "svc", {})).rejects.toThrow(CredentialManagerError); + }); + + test("rejects invalid credential structure", async () => { + await Bun.write(configPath, "{}"); + const mgr = makeManager({ groups: {}, credentials: {}, defaults: {} }); + expect(mgr.setCredential("rico", "svc", { headers: { bad: 123 } })).rejects.toThrow(CredentialManagerError); + }); + }); + + describe("setDefault", () => { + test("sets and persists a default credential", async () => { + await Bun.write(configPath, "{}"); + const mgr = makeManager({ groups: {}, credentials: {}, defaults: {} }); + await mgr.setDefault("shared-svc", { env: { KEY: "value" } }); + + const cred = mgr.resolve("anyone", "shared-svc"); + expect(cred!.env!.KEY).toBe("value"); + }); + }); + + describe("removeCredential", () => { + test("removes an existing credential", async () => { + await Bun.write(configPath, JSON.stringify(SAMPLE_CONFIG)); + const mgr = makeManager(); + await mgr.removeCredential("rico", "open-brain"); + expect(mgr.resolve("rico", "open-brain")).toBeNull(); + }); + + test("removes identity key when last credential removed", async () => { + const config: CredentialsConfig = { + groups: {}, + credentials: { solo: { svc: { headers: { X: "1" } } } }, + defaults: {}, + }; + await Bun.write(configPath, JSON.stringify(config)); + const mgr = makeManager(config); + await mgr.removeCredential("solo", "svc"); + expect(mgr.identityNames).not.toContain("solo"); + }); + + test("throws when credential does not exist", async () => { + await Bun.write(configPath, "{}"); + const mgr = makeManager({ groups: {}, credentials: {}, defaults: {} }); + expect(mgr.removeCredential("nobody", "svc")).rejects.toThrow(CredentialManagerError); + }); + }); + + describe("removeDefault", () => { + test("removes an existing default", async () => { + await Bun.write(configPath, JSON.stringify(SAMPLE_CONFIG)); + const mgr = makeManager(); + await mgr.removeDefault("proxmox"); + expect(mgr.resolve("anyone", "proxmox")).toBeNull(); + }); + + test("throws when default does not exist", async () => { + await Bun.write(configPath, "{}"); + const mgr = makeManager({ groups: {}, credentials: {}, defaults: {} }); + expect(mgr.removeDefault("nonexistent")).rejects.toThrow(CredentialManagerError); + }); + }); + + describe("groups", () => { + test("addGroup creates a new group", async () => { + await Bun.write(configPath, "{}"); + const mgr = makeManager({ groups: {}, credentials: {}, defaults: {} }); + await mgr.addGroup("team", ["alice", "bob"]); + expect(mgr.getGroupMembers("team")).toEqual(["alice", "bob"]); + }); + + test("addGroup throws when group already exists", async () => { + await Bun.write(configPath, JSON.stringify(SAMPLE_CONFIG)); + const mgr = makeManager(); + expect(mgr.addGroup("ai_agents", ["test"])).rejects.toThrow(CredentialManagerError); + }); + + test("addGroupMembers adds new members", async () => { + await Bun.write(configPath, JSON.stringify(SAMPLE_CONFIG)); + const mgr = makeManager(); + await mgr.addGroupMembers("ai_agents", ["claude", "skippy"]); + const members = mgr.getGroupMembers("ai_agents")!; + expect(members).toContain("claude"); + // Should not duplicate skippy + expect(members.filter((m: string) => m === "skippy").length).toBe(1); + }); + + test("addGroupMembers throws for nonexistent group", async () => { + await Bun.write(configPath, "{}"); + const mgr = makeManager({ groups: {}, credentials: {}, defaults: {} }); + expect(mgr.addGroupMembers("nope", ["x"])).rejects.toThrow(CredentialManagerError); + }); + + test("removeGroupMembers removes specified members", async () => { + await Bun.write(configPath, JSON.stringify(SAMPLE_CONFIG)); + const mgr = makeManager(); + await mgr.removeGroupMembers("ai_agents", ["bilby"]); + const members = mgr.getGroupMembers("ai_agents")!; + expect(members).not.toContain("bilby"); + expect(members).toContain("skippy"); + }); + + test("removeGroup removes the group", async () => { + await Bun.write(configPath, JSON.stringify(SAMPLE_CONFIG)); + const mgr = makeManager(); + await mgr.removeGroup("admins"); + expect(mgr.getGroupMembers("admins")).toBeNull(); + }); + + test("removeGroup throws for nonexistent group", async () => { + await Bun.write(configPath, "{}"); + const mgr = makeManager({ groups: {}, credentials: {}, defaults: {} }); + expect(mgr.removeGroup("nope")).rejects.toThrow(CredentialManagerError); + }); + + test("getGroupsForUser returns all groups containing the user", () => { + const config: CredentialsConfig = { + groups: { + group_a: ["alice", "bob"], + group_b: ["bob", "charlie"], + group_c: ["alice"], + }, + credentials: {}, + defaults: {}, + }; + const mgr = makeManager(config); + expect(mgr.getGroupsForUser("bob").sort()).toEqual(["group_a", "group_b"]); + expect(mgr.getGroupsForUser("nobody")).toEqual([]); + }); + }); + + describe("getConfig", () => { + test("returns a deep clone", () => { + const mgr = makeManager(); + const cfg = mgr.getConfig(); + cfg.groups.ai_agents!.push("hacked"); + expect(mgr.getGroupMembers("ai_agents")!.length).toBe(3); + }); + }); + + describe("reloadFromDisk", () => { + test("reloads updated config from disk", async () => { + await Bun.write(configPath, JSON.stringify(SAMPLE_CONFIG)); + const mgr = makeManager(); + + // Modify file on disk + const updated = structuredClone(SAMPLE_CONFIG); + updated.credentials.newUser = { svc: { headers: { X: "1" } } }; + await Bun.write(configPath, JSON.stringify(updated)); + + await mgr.reloadFromDisk(); + expect(mgr.identityNames).toContain("newUser"); + }); + + test("throws when file does not exist", async () => { + const mgr = makeManager(); + expect(mgr.reloadFromDisk()).rejects.toThrow(CredentialManagerError); + }); + + test("throws on invalid config", async () => { + await Bun.write(configPath, '{"groups": "bad"}'); + const mgr = makeManager(); + expect(mgr.reloadFromDisk()).rejects.toThrow(CredentialManagerError); + }); + }); +}); diff --git a/tests/credentials/credential-merge.test.ts b/tests/credentials/credential-merge.test.ts new file mode 100644 index 0000000..3ca7073 --- /dev/null +++ b/tests/credentials/credential-merge.test.ts @@ -0,0 +1,131 @@ +import { describe, test, expect } from "bun:test"; +import { mergeCredentials, userPoolKey } from "../../src/daemon/credential-merge.ts"; +import type { ServiceConfig } from "../../src/config/index.ts"; +import type { ServiceCredential } from "../../src/credentials/index.ts"; + +describe("mergeCredentials", () => { + test("merges headers into http service config", () => { + const service: ServiceConfig = { + backend: "http", + url: "http://localhost:3000/mcp", + headers: { "X-Original": "keep" }, + }; + const cred: ServiceCredential = { + headers: { Authorization: "Bearer user-key" }, + }; + const result = mergeCredentials(service, cred); + expect(result).toEqual({ + backend: "http", + url: "http://localhost:3000/mcp", + headers: { + "X-Original": "keep", + Authorization: "Bearer user-key", + }, + }); + }); + + test("user headers override service headers", () => { + const service: ServiceConfig = { + backend: "http", + url: "http://localhost:3000/mcp", + headers: { Authorization: "Bearer default" }, + }; + const cred: ServiceCredential = { + headers: { Authorization: "Bearer override" }, + }; + const result = mergeCredentials(service, cred); + expect((result as { headers: Record }).headers.Authorization).toBe("Bearer override"); + }); + + test("merges env into stdio service config", () => { + const service: ServiceConfig = { + backend: "stdio", + command: "npx", + args: ["-y", "some-mcp"], + env: { EXISTING: "keep" }, + }; + const cred: ServiceCredential = { + env: { API_KEY: "user-key" }, + }; + const result = mergeCredentials(service, cred); + expect((result as { env: Record }).env).toEqual({ + EXISTING: "keep", + API_KEY: "user-key", + }); + }); + + test("user env overrides service env", () => { + const service: ServiceConfig = { + backend: "stdio", + command: "npx", + args: [], + env: { API_KEY: "default" }, + }; + const cred: ServiceCredential = { + env: { API_KEY: "override" }, + }; + const result = mergeCredentials(service, cred); + expect((result as { env: Record }).env.API_KEY).toBe("override"); + }); + + test("does not mutate the original service config", () => { + const service: ServiceConfig = { + backend: "http", + url: "http://localhost:3000/mcp", + headers: { Original: "value" }, + }; + const cred: ServiceCredential = { + headers: { Added: "new" }, + }; + mergeCredentials(service, cred); + expect((service as { headers: Record }).headers).toEqual({ Original: "value" }); + }); + + test("handles credential with no matching fields", () => { + const service: ServiceConfig = { + backend: "http", + url: "http://localhost:3000/mcp", + headers: { Original: "value" }, + }; + const cred: ServiceCredential = { + env: { KEY: "val" }, + }; + const result = mergeCredentials(service, cred); + // env doesn't exist on http service, so it's a no-op + expect(result).toEqual({ + backend: "http", + url: "http://localhost:3000/mcp", + headers: { Original: "value" }, + }); + }); + + test("handles websocket service", () => { + const service: ServiceConfig = { + backend: "websocket", + url: "ws://localhost:3000/mcp", + headers: { "X-Base": "1" }, + }; + const cred: ServiceCredential = { + headers: { Authorization: "Bearer ws-key" }, + }; + const result = mergeCredentials(service, cred); + expect((result as { headers: Record }).headers).toEqual({ + "X-Base": "1", + Authorization: "Bearer ws-key", + }); + }); +}); + +describe("userPoolKey", () => { + test("returns service name when no userId", () => { + expect(userPoolKey("open-brain")).toBe("open-brain"); + }); + + test("returns service::userId when userId provided", () => { + expect(userPoolKey("open-brain", "rico")).toBe("open-brain::rico"); + }); + + test("returns service name when userId is undefined", () => { + expect(userPoolKey("n8n", undefined)).toBe("n8n"); + }); +}); diff --git a/tests/credentials/rbac.test.ts b/tests/credentials/rbac.test.ts new file mode 100644 index 0000000..66069cf --- /dev/null +++ b/tests/credentials/rbac.test.ts @@ -0,0 +1,32 @@ +import { describe, test, expect } from "bun:test"; +import { hasPermission } from "../../src/daemon/auth-provider.ts"; + +describe("credential RBAC permissions", () => { + describe("credentials-read", () => { + test("viewer cannot read credentials", () => { + expect(hasPermission("viewer", "credentials-read")).toBe(false); + }); + + test("agent can read credentials", () => { + expect(hasPermission("agent", "credentials-read")).toBe(true); + }); + + test("admin can read credentials", () => { + expect(hasPermission("admin", "credentials-read")).toBe(true); + }); + }); + + describe("credentials-write", () => { + test("viewer cannot write credentials", () => { + expect(hasPermission("viewer", "credentials-write")).toBe(false); + }); + + test("agent cannot write credentials", () => { + expect(hasPermission("agent", "credentials-write")).toBe(false); + }); + + test("admin can write credentials", () => { + expect(hasPermission("admin", "credentials-write")).toBe(true); + }); + }); +}); diff --git a/tests/credentials/schema.test.ts b/tests/credentials/schema.test.ts new file mode 100644 index 0000000..54e06a6 --- /dev/null +++ b/tests/credentials/schema.test.ts @@ -0,0 +1,126 @@ +import { describe, test, expect } from "bun:test"; +import { + CredentialsConfigSchema, + ServiceCredentialSchema, +} from "../../src/credentials/schema.ts"; + +describe("ServiceCredentialSchema", () => { + test("accepts headers only", () => { + const result = ServiceCredentialSchema.safeParse({ + headers: { Authorization: "Bearer abc" }, + }); + expect(result.success).toBe(true); + }); + + test("accepts env only", () => { + const result = ServiceCredentialSchema.safeParse({ + env: { API_KEY: "secret" }, + }); + expect(result.success).toBe(true); + }); + + test("accepts both headers and env", () => { + const result = ServiceCredentialSchema.safeParse({ + headers: { Authorization: "Bearer abc" }, + env: { API_KEY: "secret" }, + }); + expect(result.success).toBe(true); + }); + + test("rejects empty object (no headers or env)", () => { + const result = ServiceCredentialSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + test("rejects non-string header values", () => { + const result = ServiceCredentialSchema.safeParse({ + headers: { Authorization: 123 }, + }); + expect(result.success).toBe(false); + }); + + test("rejects non-string env values", () => { + const result = ServiceCredentialSchema.safeParse({ + env: { API_KEY: true }, + }); + expect(result.success).toBe(false); + }); +}); + +describe("CredentialsConfigSchema", () => { + test("accepts valid full config", () => { + const result = CredentialsConfigSchema.safeParse({ + groups: { + ai_agents: ["skippy", "bilby", "nagatha"], + }, + credentials: { + rico: { + "open-brain": { headers: { Authorization: "Bearer rico-key" } }, + }, + ai_agents: { + "open-brain": { headers: { Authorization: "Bearer agent-key" } }, + }, + }, + defaults: { + proxmox: { headers: { Authorization: "PVEAPIToken=shared" } }, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.groups.ai_agents).toEqual(["skippy", "bilby", "nagatha"]); + expect(result.data.credentials.rico?.["open-brain"]?.headers?.Authorization).toBe("Bearer rico-key"); + expect(result.data.defaults.proxmox?.headers?.Authorization).toBe("PVEAPIToken=shared"); + } + }); + + test("defaults missing fields to empty objects", () => { + const result = CredentialsConfigSchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.groups).toEqual({}); + expect(result.data.credentials).toEqual({}); + expect(result.data.defaults).toEqual({}); + } + }); + + test("accepts config with only credentials", () => { + const result = CredentialsConfigSchema.safeParse({ + credentials: { + skippy: { + n8n: { env: { N8N_API_KEY: "skippy-key" } }, + }, + }, + }); + expect(result.success).toBe(true); + }); + + test("accepts config with only defaults", () => { + const result = CredentialsConfigSchema.safeParse({ + defaults: { + n8n: { env: { N8N_API_KEY: "shared-key" } }, + }, + }); + expect(result.success).toBe(true); + }); + + test("accepts config with only groups", () => { + const result = CredentialsConfigSchema.safeParse({ + groups: { admins: ["rico"] }, + }); + expect(result.success).toBe(true); + }); + + test("rejects groups with non-array values", () => { + const result = CredentialsConfigSchema.safeParse({ + groups: { admins: "rico" }, + }); + expect(result.success).toBe(false); + }); + + test("rejects groups with non-string members", () => { + const result = CredentialsConfigSchema.safeParse({ + groups: { admins: [123] }, + }); + expect(result.success).toBe(false); + }); +}); From 56045fcc668ef2d44aad90fab5628e1339af6b04 Mon Sep 17 00:00:00 2001 From: Rodaddy Date: Tue, 9 Jun 2026 12:13:16 -0400 Subject: [PATCH 2/4] fix: use generic self-hosted runner label for claude review workflow The king-ng label is for King Capital repos. This is a rodaddy repo. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/claude-code-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 0be8bca..6768db5 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -11,7 +11,7 @@ jobs: if: | (github.event_name == 'pull_request') || (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) - runs-on: [self-hosted, king-ng] + runs-on: self-hosted permissions: contents: read pull-requests: write From 1976dd53ea11c250ba26757d06077f845828b369 Mon Sep 17 00:00:00 2001 From: Rodaddy Date: Tue, 9 Jun 2026 12:17:32 -0400 Subject: [PATCH 3/4] chore: remove claude-code-review and deploy workflows Neither has infra set up yet. Deploy job referenced a self-hosted runner and SSH deploy target that don't exist. Claude review workflow referenced a LiteLLM proxy that isn't reachable from the runner. Clean these out so CI only runs what actually works (typecheck + tests + build). Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 84 ------------------------ .github/workflows/claude-code-review.yml | 45 ------------- 2 files changed, 129 deletions(-) delete mode 100644 .github/workflows/claude-code-review.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24e5d96..b42a815 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,87 +38,3 @@ jobs: path: dist/mcp2cli retention-days: 7 - deploy: - needs: check - runs-on: self-hosted - if: github.ref == 'refs/heads/main' && github.event_name == 'push' - env: - DEPLOY_HOST: 10.71.20.63 - DEPLOY_USER: root - SERVICE_NAME: mcp2cli - BINARY_PATH: /usr/local/bin/mcp2cli - HEALTH_URL: http://10.71.20.63:9500/health - - steps: - - uses: actions/checkout@v4 - - - uses: oven-sh/setup-bun@v2 - with: - bun-version: ${{ env.BUN_VERSION }} - - - name: Install and build for Linux x64 - run: | - bun install --frozen-lockfile - bun build --compile --target=bun-linux-x64 src/cli/index.ts --outfile dist/mcp2cli - - - name: Backup current binary - run: | - ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ - "cp ${{ env.BINARY_PATH }} ${{ env.BINARY_PATH }}.bak 2>/dev/null || true" - - - name: Deploy new binary - run: | - scp dist/mcp2cli ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }}:/tmp/mcp2cli-new - ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ - "mv /tmp/mcp2cli-new ${{ env.BINARY_PATH }} && \ - chmod +x ${{ env.BINARY_PATH }} && \ - chown mcp2cli:mcp2cli ${{ env.BINARY_PATH }}" - - - name: Restart service - run: | - ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ - "systemctl restart ${{ env.SERVICE_NAME }}" - - - name: Health check (with retry) - run: | - for i in 1 2 3 4 5; do - sleep 2 - if curl -sf --max-time 5 ${{ env.HEALTH_URL }} > /dev/null 2>&1; then - echo "Health check passed (attempt $i)" - exit 0 - fi - echo "Health check attempt $i failed, retrying..." - done - echo "Health check failed after 5 attempts" - exit 1 - - - name: Rollback on failure - if: failure() - run: | - echo "Deployment failed -- rolling back to previous binary" - ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ - "if [ -f ${{ env.BINARY_PATH }}.bak ]; then \ - mv ${{ env.BINARY_PATH }}.bak ${{ env.BINARY_PATH }} && \ - systemctl restart ${{ env.SERVICE_NAME }} && \ - echo 'Rollback complete'; \ - else \ - echo 'No backup found -- manual intervention required'; \ - exit 1; \ - fi" - - - name: Verify rollback health - if: failure() - run: | - sleep 3 - if curl -sf --max-time 5 ${{ env.HEALTH_URL }} > /dev/null 2>&1; then - echo "Rollback health check passed -- service restored" - else - echo "WARNING: Rollback health check failed -- service may be down" - exit 1 - fi - - - name: Clean up backup - if: success() - run: | - ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ - "rm -f ${{ env.BINARY_PATH }}.bak" diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index 6768db5..0000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Claude Code Review - -on: - pull_request: - types: [opened, synchronize, reopened] - issue_comment: - types: [created] - -jobs: - claude-review: - if: | - (github.event_name == 'pull_request') || - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) - runs-on: self-hosted - permissions: - contents: read - pull-requests: write - issues: write - env: - ANTHROPIC_BASE_URL: http://10.71.20.53:4000 - ANTHROPIC_API_KEY: ${{ secrets.LITELLM_API_KEY }} - ANTHROPIC_MODEL: sonnet4.6 - ANTHROPIC_DEFAULT_SONNET_MODEL: sonnet4.6 - ANTHROPIC_DEFAULT_OPUS_MODEL: opus4.6 - ANTHROPIC_DEFAULT_HAIKU_MODEL: haiku - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Run Claude Code Review - uses: anthropics/claude-code-action@beta - with: - anthropic_api_key: ${{ secrets.LITELLM_API_KEY }} - github_token: ${{ secrets.GITHUB_TOKEN }} - model: "sonnet4.6" - direct_prompt: | - Review this PR for the mcp2cli project (CLI bridge for MCP servers). Focus on: - 1. Bugs and logic errors - 2. Security issues -- this tool handles MCP server connections and user input - 3. Resource leaks (unclosed connections, dangling readers, missing timeouts) - 4. Missing error handling at system boundaries - 5. TypeScript strictness (no any, proper null checks) - timeout_minutes: 10 From f8d08880a37697bf2224d0a0f872496aa7ec322c Mon Sep 17 00:00:00 2001 From: Rodaddy Date: Tue, 9 Jun 2026 12:39:13 -0400 Subject: [PATCH 4/4] fix: harden credential management after swarm review --- src/credentials/credential-manager.ts | 320 ++++++++++++++----- src/credentials/schema.ts | 93 ++++-- src/daemon/auth.ts | 4 +- src/daemon/credential-merge.ts | 12 +- src/daemon/pool.ts | 56 ++-- src/daemon/routes/credentials.ts | 54 +++- src/daemon/server.ts | 72 ++--- tests/credentials/credential-manager.test.ts | 77 ++++- tests/credentials/credential-merge.test.ts | 35 +- tests/credentials/rbac.test.ts | 69 ++++ tests/credentials/schema.test.ts | 61 ++++ tests/daemon/pool.test.ts | 38 +++ 12 files changed, 706 insertions(+), 185 deletions(-) diff --git a/src/credentials/credential-manager.ts b/src/credentials/credential-manager.ts index 3a5d49e..835557b 100644 --- a/src/credentials/credential-manager.ts +++ b/src/credentials/credential-manager.ts @@ -4,8 +4,10 @@ * * Resolution order: user-specific → first matching group → defaults → null */ -import { chmod } from "node:fs/promises"; -import { CredentialsConfigSchema, ServiceCredentialSchema } from "./schema.ts"; +import { chmod, mkdir, rename, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { CredentialKeySchema, CredentialsConfigSchema, ServiceCredentialSchema } from "./schema.ts"; +import { clearCache } from "../cache/index.ts"; import type { CredentialsConfig, ServiceCredential } from "./schema.ts"; import { createLogger } from "../logger/index.ts"; import type { ConnectionPool } from "../daemon/pool.ts"; @@ -69,23 +71,34 @@ export class CredentialManager { * Resolve credentials for a given userId and service. * Returns the highest-priority credential match (user-specific > group > defaults), * or null if no credentials are configured for this combination. + * + * Note: When a user belongs to multiple groups, the first matching group wins. + * Group iteration order follows Object.entries() order (insertion order in JSON). + * This is intentional -- groups defined earlier in credentials.json take precedence. */ resolve(userId: string, serviceName: string): ServiceCredential | null { + return this.resolveWithSource(userId, serviceName)?.credential ?? null; + } + + resolveWithSource( + userId: string, + serviceName: string, + ): { credential: ServiceCredential; source: "user" | "group" | "default"; identity: string } | null { // 1. Direct user match const userCreds = this.config.credentials[userId]?.[serviceName]; - if (userCreds) return userCreds; + if (userCreds) return { credential: userCreds, source: "user", identity: userId }; // 2. Group match (first matching group wins) for (const [groupName, members] of Object.entries(this.config.groups)) { if (members.includes(userId)) { const groupCreds = this.config.credentials[groupName]?.[serviceName]; - if (groupCreds) return groupCreds; + if (groupCreds) return { credential: groupCreds, source: "group", identity: groupName }; } } // 3. Defaults const defaultCreds = this.config.defaults[serviceName]; - if (defaultCreds) return defaultCreds; + if (defaultCreds) return { credential: defaultCreds, source: "default", identity: "default" }; return null; } @@ -160,12 +173,16 @@ export class CredentialManager { serviceName: string, credential: unknown, ): Promise { - const validated = this.validateCredential(credential); - if (!this.config.credentials[identity]) { - this.config.credentials[identity] = {}; - } - this.config.credentials[identity]![serviceName] = validated; - await this.writeToDisk(); + this.validateKey("identity", identity); + this.validateKey("service", serviceName); + await this.transaction(async () => { + const validated = this.validateCredential(credential); + if (!this.config.credentials[identity]) { + this.config.credentials[identity] = {}; + } + this.config.credentials[identity]![serviceName] = validated; + }); + await this.clearCredentialCaches(serviceName, identity); if (this.pool) { await this.pool.closeServicePattern(serviceName); } @@ -176,11 +193,14 @@ export class CredentialManager { * Set a default credential for a service. */ async setDefault(serviceName: string, credential: unknown): Promise { - const validated = this.validateCredential(credential); - this.config.defaults[serviceName] = validated; - await this.writeToDisk(); + this.validateKey("service", serviceName); + await this.transaction(async () => { + const validated = this.validateCredential(credential); + this.config.defaults[serviceName] = validated; + }); + await this.clearServiceCaches(serviceName); if (this.pool) { - await this.pool.closeServicePattern(serviceName); + await this.pool.closeService(serviceName); } log.info("default_set", { service: serviceName }); } @@ -189,17 +209,21 @@ export class CredentialManager { * Remove credentials for a specific identity and service. */ async removeCredential(identity: string, serviceName: string): Promise { - const identityCreds = this.config.credentials[identity]; - if (!identityCreds?.[serviceName]) { - throw new CredentialManagerError( - `No credential found for identity '${identity}' on service '${serviceName}'`, - ); - } - delete identityCreds[serviceName]; - if (Object.keys(identityCreds).length === 0) { - delete this.config.credentials[identity]; - } - await this.writeToDisk(); + this.validateKey("identity", identity); + this.validateKey("service", serviceName); + await this.transaction(async () => { + const identityCreds = this.config.credentials[identity]; + if (!identityCreds?.[serviceName]) { + throw new CredentialManagerError( + `No credential found for identity '${identity}' on service '${serviceName}'`, + ); + } + delete identityCreds[serviceName]; + if (Object.keys(identityCreds).length === 0) { + delete this.config.credentials[identity]; + } + }); + await this.clearCredentialCaches(serviceName, identity); if (this.pool) { await this.pool.closeServicePattern(serviceName); } @@ -210,15 +234,18 @@ export class CredentialManager { * Remove a default credential for a service. */ async removeDefault(serviceName: string): Promise { - if (!this.config.defaults[serviceName]) { - throw new CredentialManagerError( - `No default credential found for service '${serviceName}'`, - ); - } - delete this.config.defaults[serviceName]; - await this.writeToDisk(); + this.validateKey("service", serviceName); + await this.transaction(async () => { + if (!this.config.defaults[serviceName]) { + throw new CredentialManagerError( + `No default credential found for service '${serviceName}'`, + ); + } + delete this.config.defaults[serviceName]; + }); + await this.clearServiceCaches(serviceName); if (this.pool) { - await this.pool.closeServicePattern(serviceName); + await this.pool.closeService(serviceName); } log.info("default_removed", { service: serviceName }); } @@ -227,11 +254,16 @@ export class CredentialManager { * Add a group with initial members. */ async addGroup(groupName: string, members: string[]): Promise { - if (this.config.groups[groupName]) { - throw new CredentialManagerError(`Group already exists: ${groupName}`); - } - this.config.groups[groupName] = members; - await this.writeToDisk(); + this.validateKey("group", groupName); + this.validateStringArray("members", members); + this.validateGroupName(groupName); + await this.transaction(async () => { + if (this.config.groups[groupName]) { + throw new CredentialManagerError(`Group already exists: ${groupName}`); + } + this.config.groups[groupName] = members; + }); + await this.closeGroupCredentialConnections(groupName); log.info("group_added", { group: groupName, members }); } @@ -239,13 +271,19 @@ export class CredentialManager { * Add members to an existing group. */ async addGroupMembers(groupName: string, members: string[]): Promise { - const group = this.config.groups[groupName]; - if (!group) { - throw new CredentialManagerError(`Group not found: ${groupName}`); - } - const newMembers = members.filter((m) => !group.includes(m)); - group.push(...newMembers); - await this.writeToDisk(); + this.validateKey("group", groupName); + this.validateStringArray("members", members); + let newMembers: string[] = []; + await this.transaction(async () => { + const group = this.config.groups[groupName]; + if (!group) { + throw new CredentialManagerError(`Group not found: ${groupName}`); + } + newMembers = members.filter((m) => !group.includes(m)); + group.push(...newMembers); + }); + await this.clearGroupCredentialCaches(groupName); + await this.closeGroupCredentialConnections(groupName); log.info("group_members_added", { group: groupName, added: newMembers }); } @@ -253,24 +291,39 @@ export class CredentialManager { * Remove members from a group. */ async removeGroupMembers(groupName: string, members: string[]): Promise { - const group = this.config.groups[groupName]; - if (!group) { - throw new CredentialManagerError(`Group not found: ${groupName}`); - } - this.config.groups[groupName] = group.filter((m) => !members.includes(m)); - await this.writeToDisk(); + this.validateKey("group", groupName); + this.validateStringArray("members", members); + await this.transaction(async () => { + const group = this.config.groups[groupName]; + if (!group) { + throw new CredentialManagerError(`Group not found: ${groupName}`); + } + this.config.groups[groupName] = group.filter((m) => !members.includes(m)); + }); + await this.clearGroupCredentialCaches(groupName); + await this.closeGroupCredentialConnections(groupName); log.info("group_members_removed", { group: groupName, removed: members }); } /** - * Remove an entire group. Does NOT remove credentials assigned to that group name. + * Remove an entire group and any credentials assigned to that group name. */ async removeGroup(groupName: string): Promise { - if (!this.config.groups[groupName]) { - throw new CredentialManagerError(`Group not found: ${groupName}`); - } - delete this.config.groups[groupName]; - await this.writeToDisk(); + this.validateKey("group", groupName); + let affectedServices: string[] = []; + await this.transaction(async () => { + if (!this.config.groups[groupName]) { + throw new CredentialManagerError(`Group not found: ${groupName}`); + } + affectedServices = Object.keys(this.config.credentials[groupName] ?? {}); + delete this.config.groups[groupName]; + if (this.config.credentials[groupName]) { + delete this.config.credentials[groupName]; + log.info("group_credentials_cleaned", { group: groupName }); + } + }); + await Promise.all(affectedServices.map((serviceName) => this.clearServiceCaches(serviceName))); + await this.closeCredentialServices(affectedServices); log.info("group_removed", { group: groupName }); } @@ -278,28 +331,34 @@ export class CredentialManager { * Reload config from disk. */ async reloadFromDisk(): Promise { - const file = Bun.file(this.configPath); - const exists = await file.exists(); - if (!exists) { - throw new CredentialManagerError(`Credentials file not found: ${this.configPath}`); - } - let raw: unknown; - try { - raw = await file.json(); - } catch (err) { - if (err instanceof SyntaxError) { - throw new CredentialManagerError(`Malformed JSON in credentials file ${this.configPath}: ${err.message}`); + await this.enqueue(async () => { + const file = Bun.file(this.configPath); + const exists = await file.exists(); + if (!exists) { + throw new CredentialManagerError(`Credentials file not found: ${this.configPath}`); } - throw err; - } - const result = CredentialsConfigSchema.safeParse(raw); - if (!result.success) { - const issues = result.error.issues - .map((i) => `${i.path.join(".")}: ${i.message}`) - .join(", "); - throw new CredentialManagerError(`Credentials validation failed: ${issues}`); - } - this.config = result.data; + let raw: unknown; + try { + raw = await file.json(); + } catch (err) { + if (err instanceof SyntaxError) { + throw new CredentialManagerError(`Malformed JSON in credentials file ${this.configPath}: ${err.message}`); + } + throw err; + } + const result = CredentialsConfigSchema.safeParse(raw); + if (!result.success) { + const issues = result.error.issues + .map((i) => `${i.path.join(".")}: ${i.message}`) + .join(", "); + throw new CredentialManagerError(`Credentials validation failed: ${issues}`); + } + this.config = result.data; + await this.clearAllCredentialCaches(); + if (this.pool) { + await this.pool.closeAll(); + } + }); log.info("credentials_reloaded", { path: this.configPath }); } @@ -316,21 +375,108 @@ export class CredentialManager { return result.data; } - private writeToDisk(): Promise { - this.writeQueue = this.writeQueue.then(() => this.doWrite()); - return this.writeQueue; + private validateStringArray(fieldName: string, value: unknown): asserts value is string[] { + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { + throw new CredentialManagerError(`${fieldName} must be an array of strings`); + } + for (const item of value) { + this.validateKey(fieldName, item); + } + } + + private validateKey(fieldName: string, value: unknown): asserts value is string { + const result = CredentialKeySchema.safeParse(value); + if (!result.success) { + const issues = result.error.issues.map((i) => i.message).join(", "); + throw new CredentialManagerError(`Invalid ${fieldName}: ${issues}`); + } + } + + private validateGroupName(groupName: string): void { + if (this.config.credentials[groupName]) { + throw new CredentialManagerError( + `Group '${groupName}' conflicts with an existing credential identity`, + ); + } + } + + private async closeGroupCredentialConnections(groupName: string): Promise { + await this.closeCredentialServices(Object.keys(this.config.credentials[groupName] ?? {})); + } + + private async closeCredentialServices(serviceNames: string[]): Promise { + if (!this.pool) return; + for (const serviceName of serviceNames) { + await this.pool.closeService(serviceName); + } + } + + private transaction(mutator: () => void | Promise): Promise { + return this.enqueue(async () => { + const snapshot = structuredClone(this.config); + try { + await mutator(); + await this.doWrite(); + } catch (err) { + this.config = snapshot; + throw err; + } + }); + } + + private enqueue(opFn: () => void | Promise): Promise { + const op = this.writeQueue.then(opFn); + this.writeQueue = op.catch(() => {}); + return op; + } + + private async clearCredentialCaches(serviceName: string, identity: string): Promise { + await clearCache(userPoolCacheKey(serviceName, identity)).catch(() => {}); + await this.clearServiceCaches(serviceName); + } + + private async clearGroupCredentialCaches(groupName: string): Promise { + const services = Object.keys(this.config.credentials[groupName] ?? {}); + await Promise.all(services.map((serviceName) => this.clearServiceCaches(serviceName))); + } + + private async clearServiceCaches(serviceName: string): Promise { + await clearCache(serviceName).catch(() => {}); + for (const userId of Object.keys(this.config.credentials)) { + await clearCache(userPoolCacheKey(serviceName, userId)).catch(() => {}); + } + for (const members of Object.values(this.config.groups)) { + await Promise.all(members.map((userId) => + clearCache(userPoolCacheKey(serviceName, userId)).catch(() => {}), + )); + } + } + + private async clearAllCredentialCaches(): Promise { + const services = new Set(Object.keys(this.config.defaults)); + for (const serviceMap of Object.values(this.config.credentials)) { + for (const serviceName of Object.keys(serviceMap)) { + services.add(serviceName); + } + } + await Promise.all(Array.from(services).map((serviceName) => this.clearServiceCaches(serviceName))); } private async doWrite(): Promise { - await Bun.write( - this.configPath, - JSON.stringify(this.config, null, 2) + "\n", - ); - await chmod(this.configPath, 0o600); + const tmpPath = this.configPath + ".tmp"; + await mkdir(dirname(this.configPath), { recursive: true, mode: 0o700 }); + await writeFile(tmpPath, JSON.stringify(this.config, null, 2) + "\n", { mode: 0o600 }); + await chmod(tmpPath, 0o600); + await rename(tmpPath, this.configPath); log.debug("credentials_written", { path: this.configPath }); } } +function userPoolCacheKey(serviceName: string, userId: string): string { + const encoded = Buffer.from(JSON.stringify([serviceName, userId])).toString("base64url"); + return `credential:${encoded}`; +} + export class CredentialManagerError extends Error { constructor(message: string) { super(message); diff --git a/src/credentials/schema.ts b/src/credentials/schema.ts index 8e04b83..bbc861b 100644 --- a/src/credentials/schema.ts +++ b/src/credentials/schema.ts @@ -12,32 +12,92 @@ const DANGEROUS_ENV_VARS = new Set([ "LD_PRELOAD", "LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", + "DYLD_INSERT_LIBRARIES", "NODE_OPTIONS", + "NODE_PATH", + "PYTHONPATH", + "HOME", "BUN_FLAGS", + "MCP2CLI_AUTH_TOKEN", + "MCP2CLI_CREDENTIALS_FILE", + "MCP2CLI_TOKENS_FILE", ]); +const RESERVED_OBJECT_KEYS = new Set(["__proto__", "constructor", "prototype"]); + +export const CredentialKeySchema = z.string() + .min(1) + .max(256) + .refine((name) => !RESERVED_OBJECT_KEYS.has(name), { + message: "Reserved object key is not allowed", + }) + .refine((name) => !/[\u0000-\u001f\u007f]/.test(name), { + message: "Control characters are not allowed", + }) + .refine((name) => !/[/?#\\]/.test(name), { + message: "Path, query, and fragment separators are not allowed", + }); + +function credentialRecord( + valueSchema: Value, +) { + return z.unknown() + .superRefine((value, ctx) => { + if (!value || typeof value !== "object" || Array.isArray(value)) return; + for (const key of Object.keys(value)) { + const result = CredentialKeySchema.safeParse(key); + if (!result.success) { + ctx.addIssue({ + code: "custom", + path: [key], + message: result.error.issues.map((i) => i.message).join(", "), + }); + } + } + }) + .pipe(z.record(z.string(), valueSchema)); +} + /** * Per-service credential overrides. * For http/websocket: override headers sent to the backend. * For stdio: override env vars passed to the spawned process. */ export const ServiceCredentialSchema = z.object({ - headers: z.record( - z.string().refine( - (name) => !DANGEROUS_HEADERS.has(name.toLowerCase()), - { message: "Dangerous header name (Host, Transfer-Encoding, Content-Length, Connection)" }, - ), - z.string().refine( + headers: credentialRecord( + z.string().max(8192).refine( (val) => !/[\r\n]/.test(val), { message: "Header values must not contain \\r or \\n" }, ), + ).superRefine((headers, ctx) => { + for (const name of Object.keys(headers)) { + if (DANGEROUS_HEADERS.has(name.toLowerCase())) { + ctx.addIssue({ + code: "custom", + path: [name], + message: "Dangerous header name (Host, Transfer-Encoding, Content-Length, Connection)", + }); + } + } + }).refine( + (r) => Object.keys(r).length <= 50, + { message: "Too many headers (max 50)" }, ).optional(), - env: z.record( - z.string().refine( - (name) => !DANGEROUS_ENV_VARS.has(name), - { message: "Dangerous env var name (PATH, LD_PRELOAD, LD_LIBRARY_PATH, DYLD_LIBRARY_PATH, NODE_OPTIONS, BUN_FLAGS)" }, - ), - z.string(), + env: credentialRecord( + z.string().max(8192), + ).superRefine((env, ctx) => { + for (const name of Object.keys(env)) { + if (DANGEROUS_ENV_VARS.has(name)) { + ctx.addIssue({ + code: "custom", + path: [name], + message: "Dangerous env var name", + }); + } + } + }).refine( + (r) => Object.keys(r).length <= 50, + { message: "Too many env vars (max 50)" }, ).optional(), }).refine( (data) => data.headers !== undefined || data.env !== undefined, @@ -47,10 +107,7 @@ export const ServiceCredentialSchema = z.object({ /** * Credential mapping: identity/group name -> service name -> credential overrides. */ -const CredentialMapSchema = z.record( - z.string(), - z.record(z.string(), ServiceCredentialSchema), -); +const CredentialMapSchema = credentialRecord(credentialRecord(ServiceCredentialSchema)); /** * Root credentials.json schema. @@ -59,9 +116,9 @@ const CredentialMapSchema = z.record( * - defaults: fallback credentials when no identity/group match exists */ export const CredentialsConfigSchema = z.object({ - groups: z.record(z.string(), z.array(z.string())).optional().default({}), + groups: credentialRecord(z.array(CredentialKeySchema)).optional().default({}), credentials: CredentialMapSchema.optional().default({}), - defaults: z.record(z.string(), ServiceCredentialSchema).optional().default({}), + defaults: credentialRecord(ServiceCredentialSchema).optional().default({}), }); export type ServiceCredential = z.infer; diff --git a/src/daemon/auth.ts b/src/daemon/auth.ts index caabeec..1a6547d 100644 --- a/src/daemon/auth.ts +++ b/src/daemon/auth.ts @@ -93,9 +93,9 @@ const PATH_PERMISSIONS: Array<{ pattern: RegExp; method: string; permission: str { pattern: /^\/api\/services\/[^/]+$/, method: "DELETE", permission: "remove" }, { pattern: /^\/api\/services\/[^/]+\/status$/, method: "GET", permission: "status" }, // Credential management API - { pattern: /^\/api\/credentials$/, method: "GET", permission: "credentials-read" }, + { pattern: /^\/api\/credentials$/, method: "GET", permission: "credentials-write" }, { pattern: /^\/api\/credentials\/resolve$/, method: "GET", permission: "credentials-read" }, - { pattern: /^\/api\/credentials\/groups$/, method: "GET", permission: "credentials-read" }, + { pattern: /^\/api\/credentials\/groups$/, method: "GET", permission: "credentials-write" }, { pattern: /^\/api\/credentials$/, method: "POST", permission: "credentials-write" }, { pattern: /^\/api\/credentials$/, method: "DELETE", permission: "credentials-write" }, { pattern: /^\/api\/credentials\/defaults$/, method: "POST", permission: "credentials-write" }, diff --git a/src/daemon/credential-merge.ts b/src/daemon/credential-merge.ts index 09291bb..826e5aa 100644 --- a/src/daemon/credential-merge.ts +++ b/src/daemon/credential-merge.ts @@ -20,8 +20,8 @@ export function mergeCredentials( ): ServiceConfig { const merged = structuredClone(serviceConfig); - if (credential.headers && "headers" in merged) { - merged.headers = { ...merged.headers, ...credential.headers }; + if (credential.headers && (merged.backend === "http" || merged.backend === "websocket")) { + merged.headers = { ...(merged.headers ?? {}), ...credential.headers }; } else if (credential.headers) { log.warn("credential_field_mismatch", { field: "headers", @@ -30,8 +30,8 @@ export function mergeCredentials( }); } - if (credential.env && "env" in merged) { - merged.env = { ...merged.env, ...credential.env }; + if (credential.env && merged.backend === "stdio") { + merged.env = { ...(merged.env ?? {}), ...credential.env }; } else if (credential.env) { log.warn("credential_field_mismatch", { field: "env", @@ -49,5 +49,7 @@ export function mergeCredentials( * include the userId to maintain separate transports with different credentials. */ export function userPoolKey(serviceName: string, userId?: string): string { - return userId ? `${serviceName}::${userId}` : serviceName; + if (!userId) return serviceName; + const encoded = Buffer.from(JSON.stringify([serviceName, userId])).toString("base64url"); + return `credential:${encoded}`; } diff --git a/src/daemon/pool.ts b/src/daemon/pool.ts index 99ea415..a5042ed 100644 --- a/src/daemon/pool.ts +++ b/src/daemon/pool.ts @@ -26,6 +26,7 @@ const DEFAULT_HEALTH_CHECK_TIMEOUT_MS = 5000; interface PoolEntry { connection: McpConnection; connectedAt: number; + baseServiceName: string; } interface PoolOptions { @@ -64,11 +65,13 @@ export class ConnectionPool { * @param poolKey - The cache key (service name, or "service::userId" for per-user connections) * @param config - Full services config (used to look up service by name) * @param serviceConfigOverride - Optional pre-merged service config (skips config lookup when provided) + * @param baseServiceName - The configured service name when poolKey is credential scoped */ async getConnection( poolKey: string, config: ServicesConfig, serviceConfigOverride?: import("../config/index.ts").ServiceConfig, + baseServiceName: string = poolKey, ): Promise { // Check cached connection with health validation const cached = this.connections.get(poolKey); @@ -88,19 +91,28 @@ export class ConnectionPool { if (pendingPromise) return pendingPromise; // MEM-04: Enforce pool size limit for genuinely new connections - if (this.connections.size >= this._maxSize && !this.connections.has(poolKey)) { - throw new ConnectionError( - `Connection pool limit reached (${this._maxSize}). Close unused connections first.`, - "pool_limit_reached", - ); + if (!this.connections.has(poolKey)) { + const usage = this.connections.size / this._maxSize; + if (usage >= 0.8 && usage < 1) { + log.warn("pool_nearing_limit", { + size: this.connections.size, + max: this._maxSize, + message: "Per-user credential connections may be contributing to pool usage", + }); + } + if (this.connections.size >= this._maxSize) { + throw new ConnectionError( + `Connection pool limit reached (${this._maxSize}). Per-user credential connections may be contributing -- close unused connections or increase MCP2CLI_POOL_MAX.`, + "pool_limit_reached", + ); + } } // Use override if provided, otherwise look up by service name - const serviceName = poolKey.split("::")[0]!; - const serviceConfig = serviceConfigOverride ?? config.services[serviceName]; + const serviceConfig = serviceConfigOverride ?? config.services[baseServiceName]; if (!serviceConfig) { throw new ConnectionError( - `Service not found in config: ${serviceName}`, + `Service not found in config: ${baseServiceName}`, "service_not_configured", ); } @@ -108,9 +120,9 @@ export class ConnectionPool { log.info("connecting", { service: poolKey }); let connectFn: () => Promise; if (serviceConfig.backend === "http") { - connectFn = () => this.connectHttpWithFallback(poolKey, serviceConfig); + connectFn = () => this.connectHttpWithFallback(poolKey, baseServiceName, serviceConfig); } else if (serviceConfig.backend === "websocket") { - connectFn = () => this.connectWebSocketWithFallback(poolKey, serviceConfig); + connectFn = () => this.connectWebSocketWithFallback(poolKey, baseServiceName, serviceConfig); } else if (serviceConfig.backend === "stdio") { connectFn = () => connectToService(serviceConfig); } else { @@ -125,13 +137,14 @@ export class ConnectionPool { this.connections.set(poolKey, { connection, connectedAt: Date.now(), + baseServiceName, }); this.pending.delete(poolKey); log.info("connected", { service: poolKey }); // ADV-02: Fire-and-forget drift check on new connection // ADV-06: Pass access policy for skill auto-regeneration filtering const policy = extractPolicy(serviceConfig); - checkDriftOnConnect(serviceName, connection, policy).catch(() => {}); + checkDriftOnConnect(baseServiceName, connection, policy).catch(() => {}); return connection; }, (err) => { @@ -159,10 +172,9 @@ export class ConnectionPool { /** Close all per-user connections for a service (keys matching "serviceName::*"). */ async closeServicePattern(serviceName: string): Promise { - const prefix = serviceName + "::"; const toClose: Array<[string, PoolEntry]> = []; for (const [key, entry] of this.connections) { - if (key.startsWith(prefix)) { + if (entry.baseServiceName === serviceName && key !== serviceName) { toClose.push([key, entry]); } } @@ -194,8 +206,8 @@ export class ConnectionPool { /** Base service names with ::userId suffixes stripped. Deduplicated. */ get baseServiceNames(): string[] { const names = new Set(); - for (const key of this.connections.keys()) { - names.add(key.split("::")[0]!); + for (const entry of this.connections.values()) { + names.add(entry.baseServiceName); } return Array.from(names); } @@ -245,10 +257,11 @@ export class ConnectionPool { */ private async connectWebSocketWithFallback( serviceName: string, + baseService: string, serviceConfig: WebSocketService, ): Promise { const hasFallback = !!serviceConfig.fallback; - const attemptWs = await shouldAttemptHttp(serviceName); + const attemptWs = await shouldAttemptHttp(baseService); if (!attemptWs) { if (hasFallback) { @@ -266,10 +279,10 @@ export class ConnectionPool { try { const connection = await connectToWebSocketService(serviceConfig); - await recordSuccess(serviceName); + await recordSuccess(baseService); return connection; } catch (err) { - await recordFailure(serviceName); + await recordFailure(baseService); const message = err instanceof Error ? err.message : String(err); if (hasFallback) { @@ -314,10 +327,11 @@ export class ConnectionPool { */ private async connectHttpWithFallback( serviceName: string, + baseService: string, serviceConfig: HttpService, ): Promise { const hasFallback = !!serviceConfig.fallback; - const attemptHttp = await shouldAttemptHttp(serviceName); + const attemptHttp = await shouldAttemptHttp(baseService); // Circuit is open -- skip HTTP entirely if (!attemptHttp) { @@ -338,10 +352,10 @@ export class ConnectionPool { // Attempt HTTP connection try { const connection = await connectToHttpService(serviceConfig); - await recordSuccess(serviceName); + await recordSuccess(baseService); return connection; } catch (err) { - await recordFailure(serviceName); + await recordFailure(baseService); const message = err instanceof Error ? err.message : String(err); if (hasFallback) { diff --git a/src/daemon/routes/credentials.ts b/src/daemon/routes/credentials.ts index b91c749..9b61708 100644 --- a/src/daemon/routes/credentials.ts +++ b/src/daemon/routes/credentials.ts @@ -26,6 +26,9 @@ export async function handleCredentialRoutes( ): Promise { // GET /api/credentials -- list all credentials (redacted) if (path === "/api/credentials" && req.method === "GET") { + if (authCtx?.role !== "admin") { + return errorResponse("AUTH_ERROR", "Only admins can list credential inventory", undefined, 403); + } const cfg = credentialManager.getRedactedConfig(); return Response.json({ success: true, ...cfg }); } @@ -41,13 +44,29 @@ export async function handleCredentialRoutes( return errorResponse("AUTH_ERROR", "Agents can only resolve their own credentials", undefined, 403); } const resolved = credentialManager.resolve(userId, service); - return Response.json({ success: true, userId, service, credential: resolved }); + if (resolved) { + const cfg = credentialManager.getConfig(); + const userCreds = cfg.credentials[userId]?.[service]; + let source: string; + if (userCreds) { + source = "user"; + } else if (credentialManager.getGroupsForUser(userId).some(g => cfg.credentials[g]?.[service])) { + source = "group"; + } else { + source = "default"; + } + return Response.json({ success: true, userId, service, exists: true, source }); + } + return Response.json({ success: true, userId, service, exists: false, source: null }); } // POST /api/credentials -- set credential { identity, service, credential } if (path === "/api/credentials" && req.method === "POST") { try { - const body = await req.json() as { identity: string; service: string; credential: unknown }; + let body: { identity: string; service: string; credential: unknown }; + try { body = await req.json() as { identity: string; service: string; credential: unknown }; } catch { + return errorResponse("INPUT_VALIDATION_ERROR", "Malformed JSON body", undefined, 400); + } if (!body.identity || !body.service || !body.credential) { return errorResponse("INPUT_VALIDATION_ERROR", "Missing 'identity', 'service', or 'credential' field", undefined, 400); } @@ -86,7 +105,10 @@ export async function handleCredentialRoutes( // POST /api/credentials/defaults -- set default credential { service, credential } if (path === "/api/credentials/defaults" && req.method === "POST") { try { - const body = await req.json() as { service: string; credential: unknown }; + let body: { service: string; credential: unknown }; + try { body = await req.json() as { service: string; credential: unknown }; } catch { + return errorResponse("INPUT_VALIDATION_ERROR", "Malformed JSON body", undefined, 400); + } if (!body.service || !body.credential) { return errorResponse("INPUT_VALIDATION_ERROR", "Missing 'service' or 'credential' field", undefined, 400); } @@ -124,6 +146,9 @@ export async function handleCredentialRoutes( // GET /api/credentials/groups -- list all groups if (path === "/api/credentials/groups" && req.method === "GET") { + if (authCtx?.role !== "admin") { + return errorResponse("AUTH_ERROR", "Only admins can list credential groups", undefined, 403); + } const cfg = credentialManager.getRedactedConfig(); return Response.json({ success: true, groups: cfg.groups }); } @@ -131,8 +156,11 @@ export async function handleCredentialRoutes( // POST /api/credentials/groups -- create group { name, members } if (path === "/api/credentials/groups" && req.method === "POST") { try { - const body = await req.json() as { name: string; members: string[] }; - if (!body.name || !Array.isArray(body.members)) { + let body: { name: string; members: string[] }; + try { body = await req.json() as { name: string; members: string[] }; } catch { + return errorResponse("INPUT_VALIDATION_ERROR", "Malformed JSON body", undefined, 400); + } + if (!body.name || !isStringArray(body.members)) { return errorResponse("INPUT_VALIDATION_ERROR", "Missing 'name' or 'members' field", undefined, 400); } await credentialManager.addGroup(body.name, body.members); @@ -150,8 +178,11 @@ export async function handleCredentialRoutes( if (groupPutMatch && req.method === "PUT") { try { const name = decodeURIComponent(groupPutMatch[1]!); - const body = await req.json() as { members: string[] }; - if (!Array.isArray(body.members)) { + let body: { members: string[] }; + try { body = await req.json() as { members: string[] }; } catch { + return errorResponse("INPUT_VALIDATION_ERROR", "Malformed JSON body", undefined, 400); + } + if (!isStringArray(body.members)) { return errorResponse("INPUT_VALIDATION_ERROR", "Missing 'members' field", undefined, 400); } await credentialManager.addGroupMembers(name, body.members); @@ -178,7 +209,10 @@ export async function handleCredentialRoutes( return errorResponse("INPUT_VALIDATION_ERROR", "Malformed JSON body", undefined, 400); } } - if (body.members && Array.isArray(body.members)) { + if (body.members && !isStringArray(body.members)) { + return errorResponse("INPUT_VALIDATION_ERROR", "'members' must be an array of strings", undefined, 400); + } + if (body.members) { await credentialManager.removeGroupMembers(name, body.members); return Response.json({ success: true, message: `Members removed from group '${name}'` }); } @@ -207,3 +241,7 @@ export async function handleCredentialRoutes( return null; } + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} diff --git a/src/daemon/server.ts b/src/daemon/server.ts index a6d3123..c01cd58 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -72,6 +72,27 @@ export function createDaemonServer(opts: DaemonServerOptions) { // Use configManager's live config for pool lookups when available const getConfig = (): ServicesConfig => configManager ? configManager.getServices() : config; + /** Resolve per-user credential pool key and optional service config override. */ + function resolveCredentialPool( + service: string, + cm: CredentialManager | undefined, + authContext: AuthContext | null, + currentConfig: ServicesConfig, + ): { poolKey: string; serviceConfigOverride?: import("../config/index.ts").ServiceConfig } { + if (!cm || !authContext) return { poolKey: service }; + const resolved = cm.resolveWithSource(authContext.userId, service); + if (!resolved) return { poolKey: service }; + const baseCfg = currentConfig.services[service]; + if (!baseCfg) return { poolKey: service }; + const poolKey = resolved.source === "default" + ? service + : userPoolKey(service, `${resolved.source}:${resolved.identity}`); + return { + poolKey, + serviceConfigOverride: mergeCredentials(baseCfg, resolved.credential), + }; + } + // Build listen options based on mode const listenOpts = listenConfig.mode === "unix" ? { unix: listenConfig.socketPath } @@ -127,20 +148,9 @@ export function createDaemonServer(opts: DaemonServerOptions) { }); // Resolve per-user credentials and determine pool key - let poolKey = body.service; - let serviceConfigOverride: import("../config/index.ts").ServiceConfig | undefined; - if (credentialManager && authCtx) { - const cred = credentialManager.resolve(authCtx.userId, body.service); - if (cred) { - const baseCfg = getConfig().services[body.service]; - if (baseCfg) { - serviceConfigOverride = mergeCredentials(baseCfg, cred); - poolKey = userPoolKey(body.service, authCtx.userId); - } - } - } + const { poolKey, serviceConfigOverride } = resolveCredentialPool(body.service, credentialManager, authCtx, getConfig()); - const conn = await pool.getConnection(poolKey, getConfig(), serviceConfigOverride); + const conn = await pool.getConnection(poolKey, getConfig(), serviceConfigOverride, body.service); // MEM-02: AbortSignal timeout on tool calls // Priority: per-service config > MCP2CLI_TOOL_TIMEOUT env > 30s default @@ -157,7 +167,7 @@ export function createDaemonServer(opts: DaemonServerOptions) { let resolvedTool = body.tool; try { - const { resolvedName } = await resolveToolNameCached(conn.client, body.tool, body.service); + const { resolvedName } = await resolveToolNameCached(conn.client, body.tool, poolKey); resolvedTool = resolvedName; } catch { /* cache/listTools unavailable, use original name */ } callResolvedTool = resolvedTool !== body.tool ? resolvedTool : undefined; @@ -271,20 +281,9 @@ export function createDaemonServer(opts: DaemonServerOptions) { idleTimer.onRequestStart(); try { const body = (await req.json()) as DaemonListToolsRequest; - let listPoolKey = body.service; - let listServiceOverride: import("../config/index.ts").ServiceConfig | undefined; - if (credentialManager && authCtx) { - const cred = credentialManager.resolve(authCtx.userId, body.service); - if (cred) { - const baseCfg = getConfig().services[body.service]; - if (baseCfg) { - listServiceOverride = mergeCredentials(baseCfg, cred); - listPoolKey = userPoolKey(body.service, authCtx.userId); - } - } - } - const conn = await pool.getConnection(listPoolKey, getConfig(), listServiceOverride); - const tools = await listToolsCached(conn.client, body.service); + const { poolKey: listPoolKey, serviceConfigOverride: listServiceOverride } = resolveCredentialPool(body.service, credentialManager, authCtx, getConfig()); + const conn = await pool.getConnection(listPoolKey, getConfig(), listServiceOverride, body.service); + const tools = await listToolsCached(conn.client, listPoolKey); return Response.json({ success: true, result: tools }); } catch (err) { return handleEndpointError(err, pool); @@ -298,23 +297,12 @@ export function createDaemonServer(opts: DaemonServerOptions) { idleTimer.onRequestStart(); try { const body = (await req.json()) as DaemonSchemaRequest; - let schemaPoolKey = body.service; - let schemaServiceOverride: import("../config/index.ts").ServiceConfig | undefined; - if (credentialManager && authCtx) { - const cred = credentialManager.resolve(authCtx.userId, body.service); - if (cred) { - const baseCfg = getConfig().services[body.service]; - if (baseCfg) { - schemaServiceOverride = mergeCredentials(baseCfg, cred); - schemaPoolKey = userPoolKey(body.service, authCtx.userId); - } - } - } - const conn = await pool.getConnection(schemaPoolKey, getConfig(), schemaServiceOverride); + const { poolKey: schemaPoolKey, serviceConfigOverride: schemaServiceOverride } = resolveCredentialPool(body.service, credentialManager, authCtx, getConfig()); + const conn = await pool.getConnection(schemaPoolKey, getConfig(), schemaServiceOverride, body.service); const result = await getToolSchemaCached( conn.client, body.tool, - body.service, + schemaPoolKey, ); if (result === null) { return errorResponse( diff --git a/tests/credentials/credential-manager.test.ts b/tests/credentials/credential-manager.test.ts index fdf326b..b9dff64 100644 --- a/tests/credentials/credential-manager.test.ts +++ b/tests/credentials/credential-manager.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -130,6 +130,13 @@ describe("CredentialManager", () => { const mgr = makeManager(config); expect(mgr.resolve("bob", "svc")!.headers!.X).toBe("group-a"); }); + + test("resolveWithSource reports the effective credential source", () => { + const mgr = makeManager(); + expect(mgr.resolveWithSource("rico", "open-brain")?.source).toBe("user"); + expect(mgr.resolveWithSource("skippy", "open-brain")?.identity).toBe("ai_agents"); + expect(mgr.resolveWithSource("unknown-user", "proxmox")?.source).toBe("default"); + }); }); describe("setCredential", () => { @@ -164,6 +171,19 @@ describe("CredentialManager", () => { const mgr = makeManager({ groups: {}, credentials: {}, defaults: {} }); expect(mgr.setCredential("rico", "svc", { headers: { bad: 123 } })).rejects.toThrow(CredentialManagerError); }); + + test("creates parent directory on first write", async () => { + const missingPath = join(tmpDir, "missing", "nested", "credentials.json"); + const mgr = new CredentialManager( + { groups: {}, credentials: {}, defaults: {} }, + missingPath, + ); + + await mgr.setCredential("rico", "svc", { headers: { Authorization: "Bearer new" } }); + + const disk = await Bun.file(missingPath).json(); + expect(disk.credentials.rico.svc.headers.Authorization).toBe("Bearer new"); + }); }); describe("setDefault", () => { @@ -233,6 +253,24 @@ describe("CredentialManager", () => { expect(mgr.addGroup("ai_agents", ["test"])).rejects.toThrow(CredentialManagerError); }); + test("addGroup rejects names that conflict with credential identities", async () => { + await Bun.write(configPath, JSON.stringify(SAMPLE_CONFIG)); + const mgr = makeManager(); + expect(mgr.addGroup("rico", ["alice"])).rejects.toThrow(CredentialManagerError); + }); + + test("addGroup rejects non-string members", async () => { + await Bun.write(configPath, "{}"); + const mgr = makeManager({ groups: {}, credentials: {}, defaults: {} }); + expect(mgr.addGroup("team", ["alice", 123] as unknown as string[])).rejects.toThrow(CredentialManagerError); + }); + + test("addGroup rejects reserved object keys", async () => { + await Bun.write(configPath, "{}"); + const mgr = makeManager({ groups: {}, credentials: {}, defaults: {} }); + expect(mgr.addGroup("__proto__", ["alice"])).rejects.toThrow(CredentialManagerError); + }); + test("addGroupMembers adds new members", async () => { await Bun.write(configPath, JSON.stringify(SAMPLE_CONFIG)); const mgr = makeManager(); @@ -249,6 +287,19 @@ describe("CredentialManager", () => { expect(mgr.addGroupMembers("nope", ["x"])).rejects.toThrow(CredentialManagerError); }); + test("addGroupMembers rejects non-string members", async () => { + await Bun.write(configPath, JSON.stringify(SAMPLE_CONFIG)); + const mgr = makeManager(); + expect(mgr.addGroupMembers("ai_agents", ["claude", false] as unknown as string[])).rejects.toThrow(CredentialManagerError); + }); + + test("setCredential allows credentials for existing groups", async () => { + await Bun.write(configPath, JSON.stringify(SAMPLE_CONFIG)); + const mgr = makeManager(); + await mgr.setCredential("ai_agents", "svc", { headers: { X: "1" } }); + expect(mgr.resolve("skippy", "svc")?.headers?.X).toBe("1"); + }); + test("removeGroupMembers removes specified members", async () => { await Bun.write(configPath, JSON.stringify(SAMPLE_CONFIG)); const mgr = makeManager(); @@ -258,6 +309,12 @@ describe("CredentialManager", () => { expect(members).toContain("skippy"); }); + test("removeGroupMembers rejects non-string members", async () => { + await Bun.write(configPath, JSON.stringify(SAMPLE_CONFIG)); + const mgr = makeManager(); + expect(mgr.removeGroupMembers("ai_agents", ["bilby", null] as unknown as string[])).rejects.toThrow(CredentialManagerError); + }); + test("removeGroup removes the group", async () => { await Bun.write(configPath, JSON.stringify(SAMPLE_CONFIG)); const mgr = makeManager(); @@ -310,6 +367,24 @@ describe("CredentialManager", () => { expect(mgr.identityNames).toContain("newUser"); }); + test("reload closes existing pool connections", async () => { + await Bun.write(configPath, JSON.stringify(SAMPLE_CONFIG)); + const mgr = makeManager(); + const closeAll = mock(() => Promise.resolve()); + mgr.setPool({ + closeAll, + closeService: mock(() => Promise.resolve()), + closeServicePattern: mock(() => Promise.resolve()), + } as never); + + const updated = structuredClone(SAMPLE_CONFIG); + updated.defaults.proxmox = { headers: { Authorization: "PVEAPIToken=rotated" } }; + await Bun.write(configPath, JSON.stringify(updated)); + + await mgr.reloadFromDisk(); + expect(closeAll).toHaveBeenCalledTimes(1); + }); + test("throws when file does not exist", async () => { const mgr = makeManager(); expect(mgr.reloadFromDisk()).rejects.toThrow(CredentialManagerError); diff --git a/tests/credentials/credential-merge.test.ts b/tests/credentials/credential-merge.test.ts index 3ca7073..db06cc8 100644 --- a/tests/credentials/credential-merge.test.ts +++ b/tests/credentials/credential-merge.test.ts @@ -37,6 +37,20 @@ describe("mergeCredentials", () => { expect((result as { headers: Record }).headers.Authorization).toBe("Bearer override"); }); + test("creates headers when http service has none", () => { + const service = { + backend: "http", + url: "http://localhost:3000/mcp", + } as unknown as ServiceConfig; + const cred: ServiceCredential = { + headers: { Authorization: "Bearer user-key" }, + }; + const result = mergeCredentials(service, cred); + expect((result as { headers: Record }).headers).toEqual({ + Authorization: "Bearer user-key", + }); + }); + test("merges env into stdio service config", () => { const service: ServiceConfig = { backend: "stdio", @@ -68,6 +82,21 @@ describe("mergeCredentials", () => { expect((result as { env: Record }).env.API_KEY).toBe("override"); }); + test("creates env when stdio service has none", () => { + const service = { + backend: "stdio", + command: "npx", + args: ["-y", "some-mcp"], + } as unknown as ServiceConfig; + const cred: ServiceCredential = { + env: { API_KEY: "user-key" }, + }; + const result = mergeCredentials(service, cred); + expect((result as { env: Record }).env).toEqual({ + API_KEY: "user-key", + }); + }); + test("does not mutate the original service config", () => { const service: ServiceConfig = { backend: "http", @@ -122,10 +151,14 @@ describe("userPoolKey", () => { }); test("returns service::userId when userId provided", () => { - expect(userPoolKey("open-brain", "rico")).toBe("open-brain::rico"); + expect(userPoolKey("open-brain", "rico")).toMatch(/^credential:/); }); test("returns service name when userId is undefined", () => { expect(userPoolKey("n8n", undefined)).toBe("n8n"); }); + + test("credential scoped keys do not collide when components contain delimiters", () => { + expect(userPoolKey("a", "b::c")).not.toBe(userPoolKey("a::b", "c")); + }); }); diff --git a/tests/credentials/rbac.test.ts b/tests/credentials/rbac.test.ts index 66069cf..f8a354f 100644 --- a/tests/credentials/rbac.test.ts +++ b/tests/credentials/rbac.test.ts @@ -1,5 +1,7 @@ import { describe, test, expect } from "bun:test"; import { hasPermission } from "../../src/daemon/auth-provider.ts"; +import { checkPermission } from "../../src/daemon/auth.ts"; +import type { AuthContext } from "../../src/daemon/auth-provider.ts"; describe("credential RBAC permissions", () => { describe("credentials-read", () => { @@ -29,4 +31,71 @@ describe("credential RBAC permissions", () => { expect(hasPermission("admin", "credentials-write")).toBe(true); }); }); + + describe("checkPermission path mapping", () => { + const admin: AuthContext = { userId: "admin-user", role: "admin" }; + const agent: AuthContext = { userId: "agent-user", role: "agent" }; + const viewer: AuthContext = { userId: "viewer-user", role: "viewer" }; + + function mockReq(method: string, path: string): Request { + return new Request(`http://localhost${path}`, { method }); + } + + test("GET /api/credentials maps to credentials-write", () => { + expect(checkPermission(mockReq("GET", "/api/credentials"), admin)).toBeNull(); + expect(checkPermission(mockReq("GET", "/api/credentials"), agent)).toBe("credentials-write"); + expect(checkPermission(mockReq("GET", "/api/credentials"), viewer)).toBe("credentials-write"); + }); + + test("GET /api/credentials/resolve maps to credentials-read", () => { + expect(checkPermission(mockReq("GET", "/api/credentials/resolve"), agent)).toBeNull(); + expect(checkPermission(mockReq("GET", "/api/credentials/resolve"), viewer)).toBe("credentials-read"); + }); + + test("GET /api/credentials/groups maps to credentials-write", () => { + expect(checkPermission(mockReq("GET", "/api/credentials/groups"), admin)).toBeNull(); + expect(checkPermission(mockReq("GET", "/api/credentials/groups"), agent)).toBe("credentials-write"); + expect(checkPermission(mockReq("GET", "/api/credentials/groups"), viewer)).toBe("credentials-write"); + }); + + test("POST /api/credentials maps to credentials-write", () => { + expect(checkPermission(mockReq("POST", "/api/credentials"), admin)).toBeNull(); + expect(checkPermission(mockReq("POST", "/api/credentials"), agent)).toBe("credentials-write"); + }); + + test("DELETE /api/credentials maps to credentials-write", () => { + expect(checkPermission(mockReq("DELETE", "/api/credentials"), admin)).toBeNull(); + expect(checkPermission(mockReq("DELETE", "/api/credentials"), agent)).toBe("credentials-write"); + }); + + test("POST /api/credentials/defaults maps to credentials-write", () => { + expect(checkPermission(mockReq("POST", "/api/credentials/defaults"), admin)).toBeNull(); + expect(checkPermission(mockReq("POST", "/api/credentials/defaults"), agent)).toBe("credentials-write"); + }); + + test("DELETE /api/credentials/defaults maps to credentials-write", () => { + expect(checkPermission(mockReq("DELETE", "/api/credentials/defaults"), admin)).toBeNull(); + expect(checkPermission(mockReq("DELETE", "/api/credentials/defaults"), agent)).toBe("credentials-write"); + }); + + test("POST /api/credentials/groups maps to credentials-write", () => { + expect(checkPermission(mockReq("POST", "/api/credentials/groups"), admin)).toBeNull(); + expect(checkPermission(mockReq("POST", "/api/credentials/groups"), agent)).toBe("credentials-write"); + }); + + test("PUT /api/credentials/groups/:name maps to credentials-write", () => { + expect(checkPermission(mockReq("PUT", "/api/credentials/groups/admins"), admin)).toBeNull(); + expect(checkPermission(mockReq("PUT", "/api/credentials/groups/admins"), agent)).toBe("credentials-write"); + }); + + test("DELETE /api/credentials/groups/:name maps to credentials-write", () => { + expect(checkPermission(mockReq("DELETE", "/api/credentials/groups/admins"), admin)).toBeNull(); + expect(checkPermission(mockReq("DELETE", "/api/credentials/groups/admins"), agent)).toBe("credentials-write"); + }); + + test("POST /api/credentials/reload maps to credentials-write", () => { + expect(checkPermission(mockReq("POST", "/api/credentials/reload"), admin)).toBeNull(); + expect(checkPermission(mockReq("POST", "/api/credentials/reload"), agent)).toBe("credentials-write"); + }); + }); }); diff --git a/tests/credentials/schema.test.ts b/tests/credentials/schema.test.ts index 54e06a6..35d9679 100644 --- a/tests/credentials/schema.test.ts +++ b/tests/credentials/schema.test.ts @@ -45,6 +45,47 @@ describe("ServiceCredentialSchema", () => { }); expect(result.success).toBe(false); }); + + test("rejects dangerous header names", () => { + expect(ServiceCredentialSchema.safeParse({ headers: { Host: "evil.com" } }).success).toBe(false); + expect(ServiceCredentialSchema.safeParse({ headers: { "Transfer-Encoding": "chunked" } }).success).toBe(false); + expect(ServiceCredentialSchema.safeParse({ headers: { "content-length": "0" } }).success).toBe(false); + }); + + test("rejects reserved object keys in headers and env", () => { + expect(ServiceCredentialSchema.safeParse({ + headers: Object.fromEntries([["__proto__", "bad"]]), + }).success).toBe(false); + expect(ServiceCredentialSchema.safeParse({ + env: Object.fromEntries([["constructor", "bad"]]), + }).success).toBe(false); + }); + + test("rejects CRLF in header values", () => { + expect(ServiceCredentialSchema.safeParse({ headers: { "X-Custom": "value\r\nInjected: true" } }).success).toBe(false); + expect(ServiceCredentialSchema.safeParse({ headers: { "X-Custom": "value\nInjected: true" } }).success).toBe(false); + }); + + test("rejects dangerous env var names", () => { + expect(ServiceCredentialSchema.safeParse({ env: { PATH: "/evil" } }).success).toBe(false); + expect(ServiceCredentialSchema.safeParse({ env: { LD_PRELOAD: "/evil.so" } }).success).toBe(false); + expect(ServiceCredentialSchema.safeParse({ env: { NODE_OPTIONS: "--require /evil" } }).success).toBe(false); + expect(ServiceCredentialSchema.safeParse({ env: { DYLD_INSERT_LIBRARIES: "/evil.dylib" } }).success).toBe(false); + expect(ServiceCredentialSchema.safeParse({ env: { MCP2CLI_AUTH_TOKEN: "stolen" } }).success).toBe(false); + expect(ServiceCredentialSchema.safeParse({ env: { HOME: "/tmp" } }).success).toBe(false); + }); + + test("rejects too many headers", () => { + const headers: Record = {}; + for (let i = 0; i < 51; i++) headers[`X-Header-${i}`] = "value"; + expect(ServiceCredentialSchema.safeParse({ headers }).success).toBe(false); + }); + + test("rejects too many env vars", () => { + const env: Record = {}; + for (let i = 0; i < 51; i++) env[`CUSTOM_VAR_${i}`] = "value"; + expect(ServiceCredentialSchema.safeParse({ env }).success).toBe(false); + }); }); describe("CredentialsConfigSchema", () => { @@ -123,4 +164,24 @@ describe("CredentialsConfigSchema", () => { }); expect(result.success).toBe(false); }); + + test("rejects reserved object keys in credential config maps", () => { + expect(CredentialsConfigSchema.safeParse({ + groups: Object.fromEntries([["__proto__", ["alice"]]]), + }).success).toBe(false); + expect(CredentialsConfigSchema.safeParse({ + credentials: Object.fromEntries([["constructor", { svc: { headers: { X: "1" } } }]]), + }).success).toBe(false); + expect(CredentialsConfigSchema.safeParse({ + defaults: Object.fromEntries([["prototype", { headers: { X: "1" } }]]), + }).success).toBe(false); + }); + + test("allows credentials keyed by group name", () => { + const result = CredentialsConfigSchema.safeParse({ + groups: { team: ["alice"] }, + credentials: { team: { svc: { headers: { X: "1" } } } }, + }); + expect(result.success).toBe(true); + }); }); diff --git a/tests/daemon/pool.test.ts b/tests/daemon/pool.test.ts index 74102d3..8d43027 100644 --- a/tests/daemon/pool.test.ts +++ b/tests/daemon/pool.test.ts @@ -17,6 +17,11 @@ const mockConnectToService = mock(async () => { // Apply the mock to the connection module mock.module("../../src/connection/index.ts", () => ({ connectToService: mockConnectToService, + connectToHttpService: mockConnectToService, +})); + +mock.module("../../src/connection/websocket-transport.ts", () => ({ + connectToWebSocketService: mockConnectToService, })); // Import pool AFTER mocking @@ -55,6 +60,12 @@ const testConfig: ServicesConfig = { args: ["other"], env: {}, }, + "svc::with-delimiter": { + backend: "stdio" as const, + command: "echo", + args: ["delimiter"], + env: {}, + }, }, }; @@ -105,6 +116,33 @@ describe("ConnectionPool", () => { expect(mockConnectToService).toHaveBeenCalledTimes(2); }); + test("credential scoped pool key can use service names containing delimiter", async () => { + await pool.getConnection( + "credential:test", + testConfig, + undefined, + "svc::with-delimiter", + ); + + expect(mockConnectToService).toHaveBeenCalledTimes(1); + }); + + test("closeServicePattern only closes entries for the matching base service", async () => { + const unrelated = await pool.getConnection("svc::with-delimiter", testConfig); + const credentialed = await pool.getConnection( + "credential:test", + testConfig, + undefined, + "test-svc", + ); + + await pool.closeServicePattern("test-svc"); + + expect(credentialed.close).toHaveBeenCalledTimes(1); + expect(unrelated.close).toHaveBeenCalledTimes(0); + expect(pool.serviceNames).toContain("svc::with-delimiter"); + }); + test("size and serviceNames reflect pool state", async () => { expect(pool.size).toBe(0); expect(pool.serviceNames).toEqual([]);