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) <noreply@anthropic.com>
This commit is contained in:
@@ -72,6 +72,7 @@ import {
|
|||||||
vaultSet,
|
vaultSet,
|
||||||
vaultList,
|
vaultList,
|
||||||
vaultDelete,
|
vaultDelete,
|
||||||
|
vaultGetEntries,
|
||||||
upsertService,
|
upsertService,
|
||||||
updateServiceStatus,
|
updateServiceStatus,
|
||||||
updateServiceScope,
|
updateServiceScope,
|
||||||
@@ -3153,6 +3154,15 @@ function handleConnection(ws: WebSocket): void {
|
|||||||
break;
|
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 ---
|
// --- MCP Deploy/Undeploy ---
|
||||||
case "mcp_deploy": {
|
case "mcp_deploy": {
|
||||||
const md = msg as any;
|
const md = msg as any;
|
||||||
|
|||||||
@@ -1101,6 +1101,8 @@ export interface WSVaultSetMessage { type: "vault_set"; key: string; ciphertext:
|
|||||||
export interface WSVaultListMessage { type: "vault_list"; _reqId?: string; }
|
export interface WSVaultListMessage { type: "vault_list"; _reqId?: string; }
|
||||||
/** Client → broker: delete vault entry. */
|
/** Client → broker: delete vault entry. */
|
||||||
export interface WSVaultDeleteMessage { type: "vault_delete"; key: string; _reqId?: string; }
|
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 =
|
export type WSClientMessage =
|
||||||
| WSHelloMessage
|
| WSHelloMessage
|
||||||
@@ -1182,7 +1184,8 @@ export type WSClientMessage =
|
|||||||
| WSSkillDeployMessage
|
| WSSkillDeployMessage
|
||||||
| WSVaultSetMessage
|
| WSVaultSetMessage
|
||||||
| WSVaultListMessage
|
| WSVaultListMessage
|
||||||
| WSVaultDeleteMessage;
|
| WSVaultDeleteMessage
|
||||||
|
| WSVaultGetMessage;
|
||||||
|
|
||||||
// --- Skill messages ---
|
// --- 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; }
|
export interface WSVaultAckMessage { type: "vault_ack"; key: string; action: "stored" | "deleted" | "not_found"; _reqId?: string; }
|
||||||
/** Broker → client: vault entry listing. */
|
/** 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; }
|
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 =
|
export type WSServerMessage =
|
||||||
| WSHelloAckMessage
|
| WSHelloAckMessage
|
||||||
@@ -1327,4 +1332,5 @@ export type WSServerMessage =
|
|||||||
| WSSkillDeployAckMessage
|
| WSSkillDeployAckMessage
|
||||||
| WSVaultAckMessage
|
| WSVaultAckMessage
|
||||||
| WSVaultListResultMessage
|
| WSVaultListResultMessage
|
||||||
|
| WSVaultGetResultMessage
|
||||||
| WSErrorMessage;
|
| WSErrorMessage;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudemesh-cli",
|
"name": "claudemesh-cli",
|
||||||
"version": "0.8.4",
|
"version": "0.8.5",
|
||||||
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
|
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude-code",
|
"claude-code",
|
||||||
|
|||||||
@@ -1412,14 +1412,73 @@ Your message mode is "${messageMode}".
|
|||||||
const source = file_id
|
const source = file_id
|
||||||
? { type: "zip" as const, file_id }
|
? { type: "zip" as const, file_id }
|
||||||
: { type: "git" as const, url: git_url!, branch: git_branch };
|
: { type: "git" as const, url: git_url!, branch: git_branch };
|
||||||
|
|
||||||
|
// Resolve $vault: references in env vars — decrypt client-side
|
||||||
|
const resolvedEnv: Record<string, string> = {};
|
||||||
|
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<string, unknown> = {};
|
const config: Record<string, unknown> = {};
|
||||||
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 (runtime) config.runtime = runtime;
|
||||||
if (memory_mb) config.memory_mb = memory_mb;
|
if (memory_mb) config.memory_mb = memory_mb;
|
||||||
if (network_allow) config.network_allow = network_allow;
|
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 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)";
|
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": {
|
case "mesh_mcp_undeploy": {
|
||||||
const { server_name } = (args ?? {}) as { server_name?: string };
|
const { server_name } = (args ?? {}) as { server_name?: string };
|
||||||
|
|||||||
@@ -1261,6 +1261,15 @@ export class BrokerClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async vaultGet(keys: string[]): Promise<Array<{ key: string; ciphertext: string; nonce: string; sealed_key: string; entry_type: string; mount_path?: string }>> {
|
||||||
|
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 ---
|
// --- MCP Deploy ---
|
||||||
|
|
||||||
async mcpDeploy(serverName: string, source: any, config?: any, scope?: any): Promise<any> {
|
async mcpDeploy(serverName: string, source: any, config?: any, scope?: any): Promise<any> {
|
||||||
@@ -1921,6 +1930,15 @@ export class BrokerClient {
|
|||||||
r.resolve((msg as any).entries ?? []);
|
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") {
|
if (msg.type === "mcp_deploy_status") {
|
||||||
const reqId = (msg as any)._reqId;
|
const reqId = (msg as any)._reqId;
|
||||||
if (reqId && this.mcpDeployResolvers.has(reqId)) {
|
if (reqId && this.mcpDeployResolvers.has(reqId)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user