From 75ca892ea720b4c46c4ff19c51851a3a66e23743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:16:46 +0100 Subject: [PATCH] feat(cli): vault_get + deploy-time vault resolution - Add vault_get wire message to fetch encrypted entries for client-side decryption - Deploy handler resolves $vault: refs: fetches encrypted entries from broker, decrypts with mesh keypair locally, sends resolved env over TLS - File-type vault entries encoded as __vault_file__:path:base64 for runner-side extraction Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/broker/src/index.ts | 10 ++++++ apps/broker/src/types.ts | 8 ++++- apps/cli/package.json | 2 +- apps/cli/src/mcp/server.ts | 63 ++++++++++++++++++++++++++++++++++++-- apps/cli/src/ws/client.ts | 18 +++++++++++ 5 files changed, 97 insertions(+), 4 deletions(-) diff --git a/apps/broker/src/index.ts b/apps/broker/src/index.ts index fe7caed..a11328b 100644 --- a/apps/broker/src/index.ts +++ b/apps/broker/src/index.ts @@ -72,6 +72,7 @@ import { vaultSet, vaultList, vaultDelete, + vaultGetEntries, upsertService, updateServiceStatus, updateServiceScope, @@ -3153,6 +3154,15 @@ function handleConnection(ws: WebSocket): void { break; } + case "vault_get": { + const vg = msg as any; + try { + const entries = await vaultGetEntries(conn.meshId, conn.memberId, vg.keys ?? []); + sendToPeer(presenceId, { type: "vault_get_result", entries: entries.map((e: any) => ({ key: e.key, ciphertext: e.ciphertext, nonce: e.nonce, sealed_key: e.sealedKey, entry_type: e.entryType, mount_path: e.mountPath })), _reqId: vg._reqId } as any); + } catch (e) { sendError(ws, "vault_error", e instanceof Error ? e.message : String(e), undefined, vg._reqId); } + break; + } + // --- MCP Deploy/Undeploy --- case "mcp_deploy": { const md = msg as any; diff --git a/apps/broker/src/types.ts b/apps/broker/src/types.ts index 7bf6a1c..34a02b2 100644 --- a/apps/broker/src/types.ts +++ b/apps/broker/src/types.ts @@ -1101,6 +1101,8 @@ export interface WSVaultSetMessage { type: "vault_set"; key: string; ciphertext: export interface WSVaultListMessage { type: "vault_list"; _reqId?: string; } /** Client → broker: delete vault entry. */ export interface WSVaultDeleteMessage { type: "vault_delete"; key: string; _reqId?: string; } +/** Client → broker: fetch encrypted vault entries for local decryption. */ +export interface WSVaultGetMessage { type: "vault_get"; keys: string[]; _reqId?: string; } export type WSClientMessage = | WSHelloMessage @@ -1182,7 +1184,8 @@ export type WSClientMessage = | WSSkillDeployMessage | WSVaultSetMessage | WSVaultListMessage - | WSVaultDeleteMessage; + | WSVaultDeleteMessage + | WSVaultGetMessage; // --- Skill messages --- @@ -1268,6 +1271,8 @@ export interface WSSkillDeployAckMessage { type: "skill_deploy_ack"; name: strin export interface WSVaultAckMessage { type: "vault_ack"; key: string; action: "stored" | "deleted" | "not_found"; _reqId?: string; } /** Broker → client: vault entry listing. */ export interface WSVaultListResultMessage { type: "vault_list_result"; entries: Array<{ key: string; entry_type: "env" | "file"; mount_path?: string; description?: string; updated_at: string }>; _reqId?: string; } +/** Broker → client: encrypted vault entries for local decryption. */ +export interface WSVaultGetResultMessage { type: "vault_get_result"; entries: Array<{ key: string; ciphertext: string; nonce: string; sealed_key: string; entry_type: string; mount_path?: string }>; _reqId?: string; } export type WSServerMessage = | WSHelloAckMessage @@ -1327,4 +1332,5 @@ export type WSServerMessage = | WSSkillDeployAckMessage | WSVaultAckMessage | WSVaultListResultMessage + | WSVaultGetResultMessage | WSErrorMessage; diff --git a/apps/cli/package.json b/apps/cli/package.json index fd4b905..997b2cd 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "0.8.4", + "version": "0.8.5", "description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.", "keywords": [ "claude-code", diff --git a/apps/cli/src/mcp/server.ts b/apps/cli/src/mcp/server.ts index 016326b..9d6e8e5 100644 --- a/apps/cli/src/mcp/server.ts +++ b/apps/cli/src/mcp/server.ts @@ -1412,14 +1412,73 @@ Your message mode is "${messageMode}". const source = file_id ? { type: "zip" as const, file_id } : { type: "git" as const, url: git_url!, branch: git_branch }; + + // Resolve $vault: references in env vars — decrypt client-side + const resolvedEnv: Record = {}; + const vaultResolved: string[] = []; + if (deployEnv) { + // Collect vault keys needed + const vaultRefs: Array<{ envKey: string; vaultKey: string; isFile: boolean; mountPath?: string }> = []; + for (const [envKey, envVal] of Object.entries(deployEnv)) { + if (typeof envVal === "string" && envVal.startsWith("$vault:")) { + const parts = envVal.slice(7).split(":"); + const vaultKey = parts[0]!; + const isFile = parts[1] === "file"; + const mountPath = isFile ? parts.slice(2).join(":") : undefined; + vaultRefs.push({ envKey, vaultKey, isFile, mountPath }); + } else { + resolvedEnv[envKey] = envVal; + } + } + + // Fetch + decrypt vault entries client-side + if (vaultRefs.length > 0) { + const { openSealedKey, decryptFile } = await import("../crypto/file-crypto"); + const { ensureSodium } = await import("../crypto/keypair"); + const sodium = await ensureSodium(); + + const keys = vaultRefs.map(r => r.vaultKey); + const encryptedEntries = await client.vaultGet(keys); + + for (const ref of vaultRefs) { + const entry = encryptedEntries.find((e: any) => e.key === ref.vaultKey); + if (!entry) return text(`mesh_mcp_deploy: vault key "${ref.vaultKey}" not found. Use vault_set first.`, true); + + // Decrypt: open sealed key with mesh keypair, then decrypt ciphertext + const kf = await openSealedKey(entry.sealed_key, client.getMeshPubkey(), client.getMeshSecretKey()); + if (!kf) return text(`mesh_mcp_deploy: failed to decrypt vault key "${ref.vaultKey}" — wrong keypair?`, true); + + const ciphertextBytes = sodium.from_base64(entry.ciphertext, sodium.base64_variants.ORIGINAL); + const plainBytes = await decryptFile(ciphertextBytes, entry.nonce, kf); + if (!plainBytes) return text(`mesh_mcp_deploy: failed to decrypt vault entry "${ref.vaultKey}" — corrupted?`, true); + + if (ref.isFile && ref.mountPath) { + // For file-type entries: the plaintext is the file content (raw bytes). + // Encode as base64 for transport, runner writes it to mountPath. + resolvedEnv[ref.envKey] = `__vault_file__:${ref.mountPath}:${sodium.to_base64(plainBytes, sodium.base64_variants.ORIGINAL)}`; + } else { + // For env-type entries: plaintext is the secret string + resolvedEnv[ref.envKey] = new TextDecoder().decode(plainBytes); + } + vaultResolved.push(ref.vaultKey); + } + } + } + const config: Record = {}; - if (deployEnv) config.env = deployEnv; + if (Object.keys(resolvedEnv).length > 0 || (deployEnv && Object.keys(deployEnv).length > 0)) { + config.env = Object.keys(resolvedEnv).length > 0 ? resolvedEnv : deployEnv; + } if (runtime) config.runtime = runtime; if (memory_mb) config.memory_mb = memory_mb; if (network_allow) config.network_allow = network_allow; const result = await client.mcpDeploy(server_name, source, Object.keys(config).length > 0 ? config : undefined, scope); const toolList = result.tools?.map((t: any) => ` - ${t.name}: ${t.description}`).join("\n") ?? " (pending)"; - return text(`Deployed "${server_name}" (status: ${result.status}).\n\nTools:\n${toolList}\n\nDefault scope: peer (private). Use mesh_mcp_scope to share.`); + let vaultNote = ""; + if (vaultResolved.length > 0) { + vaultNote = `\n\nVault keys resolved: ${vaultResolved.join(", ")} (decrypted client-side, sent over TLS)`; + } + return text(`Deployed "${server_name}" (status: ${result.status}).\n\nTools:\n${toolList}\n\nDefault scope: peer (private). Use mesh_mcp_scope to share.${vaultNote}`); } case "mesh_mcp_undeploy": { const { server_name } = (args ?? {}) as { server_name?: string }; diff --git a/apps/cli/src/ws/client.ts b/apps/cli/src/ws/client.ts index e95254d..14305a2 100644 --- a/apps/cli/src/ws/client.ts +++ b/apps/cli/src/ws/client.ts @@ -1261,6 +1261,15 @@ export class BrokerClient { }); } + async vaultGet(keys: string[]): Promise> { + return new Promise(resolve => { + const reqId = `vget_${Date.now()}`; + const timer = setTimeout(() => { this.vaultListResolvers.delete(reqId); resolve([]); }, 10_000); + this.vaultListResolvers.set(reqId, { resolve, timer }); + this.sendRaw({ type: "vault_get", keys, _reqId: reqId } as any); + }); + } + // --- MCP Deploy --- async mcpDeploy(serverName: string, source: any, config?: any, scope?: any): Promise { @@ -1921,6 +1930,15 @@ export class BrokerClient { r.resolve((msg as any).entries ?? []); } } + if (msg.type === "vault_get_result") { + const reqId = (msg as any)._reqId; + if (reqId && this.vaultListResolvers.has(reqId)) { + const r = this.vaultListResolvers.get(reqId)!; + clearTimeout(r.timer); + this.vaultListResolvers.delete(reqId); + r.resolve((msg as any).entries ?? []); + } + } if (msg.type === "mcp_deploy_status") { const reqId = (msg as any)._reqId; if (reqId && this.mcpDeployResolvers.has(reqId)) {