From 31e4f9891a9597b6d6c6f84e420ec6b03aa9b708 Mon Sep 17 00:00:00 2001 From: Rodaddy Date: Mon, 29 Jun 2026 12:36:06 -0400 Subject: [PATCH 1/2] fix(ci): deploy hosted mcp2cli as rico --- .github/workflows/ci.yml | 91 ++++++++++++++++++++++++++++++-------- src/secrets/refs.ts | 64 ++++++++++++++++++++++++++- tests/secrets/refs.test.ts | 51 +++++++++++++++++++++ 3 files changed, 186 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 316a7b6..d1176b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,8 +72,14 @@ jobs: env: DEPLOY_HOST: 10.71.20.63 DEPLOY_USER: rico - SERVICE_NAME: mcp2cli - BINARY_PATH: /usr/local/bin/mcp2cli + BINARY_PATH: /home/rico/.local/bin/mcp2cli + DEPLOY_DIR: /home/rico/.local/share/mcp2cli + ENV_FILE: /home/rico/.config/mcp2cli/env + PID_FILE: /home/rico/.local/share/mcp2cli/run/mcp2cli.pid + LOG_FILE: /home/rico/.local/state/mcp2cli/log/mcp2cli.log + STAGED_BINARY: /home/rico/.local/share/mcp2cli/mcp2cli-new + BACKUP_PATH: /home/rico/.local/share/mcp2cli/backups/mcp2cli.bak + VAULTWARDEN_REMOTE_URL: http://127.0.0.1:9500 HEALTH_URL: http://10.71.20.63:9500/health steps: @@ -93,20 +99,42 @@ jobs: - name: Backup current binary run: | ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ - "sudo cp ${{ env.BINARY_PATH }} ${{ env.BINARY_PATH }}.bak 2>/dev/null || true" + "mkdir -p ${{ env.DEPLOY_DIR }}/backups ${{ env.DEPLOY_DIR }}/run /home/rico/.local/bin /home/rico/.local/state/mcp2cli/log && \ + if [ -f ${{ env.BINARY_PATH }} ]; then \ + cp ${{ env.BINARY_PATH }} ${{ env.BACKUP_PATH }}; \ + fi" - name: Deploy new binary run: | - scp dist/mcp2cli ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }}:/tmp/mcp2cli-new + scp dist/mcp2cli ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }}:${{ env.STAGED_BINARY }} ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ - "sudo mv /tmp/mcp2cli-new ${{ env.BINARY_PATH }} && \ - sudo chmod +x ${{ env.BINARY_PATH }} && \ - sudo chown mcp2cli:mcp2cli ${{ env.BINARY_PATH }}" + "mv ${{ env.STAGED_BINARY }} ${{ env.BINARY_PATH }} && \ + chmod 755 ${{ env.BINARY_PATH }}" - - name: Restart service + - name: Restart hosted daemon run: | ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ - "sudo systemctl restart ${{ env.SERVICE_NAME }}" + 'set -eu + if [ -f "${{ env.PID_FILE }}" ]; then + old_pid=$(cat "${{ env.PID_FILE }}") + if kill -0 "$old_pid" 2>/dev/null; then + kill "$old_pid" 2>/dev/null || true + for _ in 1 2 3 4 5; do + kill -0 "$old_pid" 2>/dev/null || break + sleep 1 + done + kill -0 "$old_pid" 2>/dev/null && kill -KILL "$old_pid" 2>/dev/null || true + fi + fi + pgrep -u "${{ env.DEPLOY_USER }}" -f "^${{ env.BINARY_PATH }}$" | while read -r pid; do + kill "$pid" 2>/dev/null || true + done + set -a + . "${{ env.ENV_FILE }}" + MCP2CLI_VAULTWARDEN_REMOTE_URL="${{ env.VAULTWARDEN_REMOTE_URL }}" + set +a + nohup "${{ env.BINARY_PATH }}" < /dev/null > "${{ env.LOG_FILE }}" 2>&1 & + echo $! > "${{ env.PID_FILE }}"' - name: Health check (with retry) run: | @@ -127,14 +155,35 @@ jobs: run: | echo "Deployment failed -- rolling back to previous binary" ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ - "if [ -f ${{ env.BINARY_PATH }}.bak ]; then \ - sudo mv ${{ env.BINARY_PATH }}.bak ${{ env.BINARY_PATH }} && \ - sudo systemctl restart ${{ env.SERVICE_NAME }} && \ - echo 'Rollback complete'; \ - else \ - echo 'No backup found -- manual intervention required'; \ - exit 1; \ - fi" + 'set -eu + if [ -f "${{ env.BACKUP_PATH }}" ]; then + mv "${{ env.BACKUP_PATH }}" "${{ env.BINARY_PATH }}" + chmod 755 "${{ env.BINARY_PATH }}" + if [ -f "${{ env.PID_FILE }}" ]; then + old_pid=$(cat "${{ env.PID_FILE }}") + if kill -0 "$old_pid" 2>/dev/null; then + kill "$old_pid" 2>/dev/null || true + for _ in 1 2 3 4 5; do + kill -0 "$old_pid" 2>/dev/null || break + sleep 1 + done + kill -0 "$old_pid" 2>/dev/null && kill -KILL "$old_pid" 2>/dev/null || true + fi + fi + pgrep -u "${{ env.DEPLOY_USER }}" -f "^${{ env.BINARY_PATH }}$" | while read -r pid; do + kill "$pid" 2>/dev/null || true + done + set -a + . "${{ env.ENV_FILE }}" + MCP2CLI_VAULTWARDEN_REMOTE_URL="${{ env.VAULTWARDEN_REMOTE_URL }}" + set +a + nohup "${{ env.BINARY_PATH }}" < /dev/null > "${{ env.LOG_FILE }}" 2>&1 & + echo $! > "${{ env.PID_FILE }}" + echo "Rollback complete" + else + echo "No backup found -- manual intervention required" + exit 1 + fi' - name: Verify rollback health if: failure() @@ -147,14 +196,18 @@ jobs: exit 1 fi - - name: Clean up backup + - name: Archive backup if: success() run: | ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ - "sudo rm -f ${{ env.BINARY_PATH }}.bak" + "if [ -f ${{ env.BACKUP_PATH }} ]; then \ + mv ${{ env.BACKUP_PATH }} ${{ env.DEPLOY_DIR }}/backups/mcp2cli-${{ github.sha }}.bak; \ + fi" - name: Report deployed version if: success() run: | echo "Deployed version: ${{ needs.version-bump.outputs.new_version }}" ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} "curl -sf http://localhost:9500/health" | jq '{version, activeConnections, configuredServices}' + ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ + "pid=\$(cat ${{ env.PID_FILE }}); ps -p \"\$pid\" -o pid=,ppid=,user=,command=" diff --git a/src/secrets/refs.ts b/src/secrets/refs.ts index 4cddc05..74a86d0 100644 --- a/src/secrets/refs.ts +++ b/src/secrets/refs.ts @@ -104,9 +104,14 @@ function parseSecretRef(ref: string): { query: string; field?: string } { } async function fetchVaultwardenCredential(query: string): Promise { + const timeoutMs = resolveTimeoutMs(); + const remote = getVaultwardenRemoteConfig(); + if (remote) { + return fetchVaultwardenCredentialViaDaemon(query, remote, timeoutMs); + } + const command = process.env.MCP2CLI_VAULTWARDEN_COMMAND ?? "mcp2cli"; const commandArgs = parseCommandArgs(process.env.MCP2CLI_VAULTWARDEN_COMMAND_ARGS); - const timeoutMs = resolveTimeoutMs(); const proc = Bun.spawn([ command, ...commandArgs, @@ -157,6 +162,63 @@ async function fetchVaultwardenCredential(query: string): Promise { } } +function getVaultwardenRemoteConfig(): { url: string; token?: string } | null { + const explicitUrl = process.env.MCP2CLI_VAULTWARDEN_REMOTE_URL; + const inheritedUrl = + process.env.MCP2CLI_VAULTWARDEN_USE_DAEMON === "1" + ? process.env.MCP2CLI_REMOTE_URL ?? process.env.MCP_HOST + : undefined; + const url = explicitUrl ?? inheritedUrl; + if (!url) return null; + return { + url, + token: process.env.MCP2CLI_VAULTWARDEN_AUTH_TOKEN ?? process.env.MCP2CLI_AUTH_TOKEN ?? process.env.MCP_TOKEN, + }; +} + +async function fetchVaultwardenCredentialViaDaemon( + query: string, + remote: { url: string; token?: string }, + timeoutMs: number, +): Promise { + const headers: Record = { + "Content-Type": "application/json", + }; + if (remote.token) { + headers.Authorization = `Bearer ${remote.token}`; + } + + let response: Response; + try { + response = await fetch(`${remote.url.replace(/\/$/, "")}/call`, { + method: "POST", + headers, + body: JSON.stringify({ + service: "vaultwarden-secrets", + tool: "get_credential", + params: { query }, + }), + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (err) { + const timedOut = err instanceof Error && err.name === "TimeoutError"; + throw new SecretResolutionError( + `Vaultwarden lookup failed for ${redactRef(query)}${timedOut ? " (timeout)" : ""}`, + ); + } + + if (!response.ok) { + throw new SecretResolutionError(`Vaultwarden lookup failed for ${redactRef(query)}`); + } + + try { + const parsed = await response.json(); + return unwrapMcpResult(parsed); + } catch { + throw new SecretResolutionError(`Vaultwarden lookup returned non-JSON output for ${redactRef(query)}`); + } +} + function parseCommandArgs(raw: string | undefined): string[] { if (!raw?.trim()) return []; try { diff --git a/tests/secrets/refs.test.ts b/tests/secrets/refs.test.ts index 9c178b1..1725b27 100644 --- a/tests/secrets/refs.test.ts +++ b/tests/secrets/refs.test.ts @@ -72,6 +72,8 @@ describe("VaultwardenSecretResolver", () => { const originalCommand = process.env.MCP2CLI_VAULTWARDEN_COMMAND; const originalArgs = process.env.MCP2CLI_VAULTWARDEN_COMMAND_ARGS; const originalTimeout = process.env.MCP2CLI_VAULTWARDEN_TIMEOUT_MS; + const originalRemoteUrl = process.env.MCP2CLI_VAULTWARDEN_REMOTE_URL; + const originalAuthToken = process.env.MCP2CLI_AUTH_TOKEN; afterEach(() => { if (originalCommand !== undefined) { @@ -89,6 +91,16 @@ describe("VaultwardenSecretResolver", () => { } else { delete process.env.MCP2CLI_VAULTWARDEN_TIMEOUT_MS; } + if (originalRemoteUrl !== undefined) { + process.env.MCP2CLI_VAULTWARDEN_REMOTE_URL = originalRemoteUrl; + } else { + delete process.env.MCP2CLI_VAULTWARDEN_REMOTE_URL; + } + if (originalAuthToken !== undefined) { + process.env.MCP2CLI_AUTH_TOKEN = originalAuthToken; + } else { + delete process.env.MCP2CLI_AUTH_TOKEN; + } }); test("extracts field paths from wrapped mcp2cli JSON output", async () => { @@ -128,6 +140,45 @@ describe("VaultwardenSecretResolver", () => { } }); + test("uses hosted daemon HTTP path when configured", async () => { + let sawAuth = false; + const server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + expect(url.pathname).toBe("/call"); + sawAuth = req.headers.get("Authorization") === "Bearer test-token"; + + const body = await req.json() as { + service?: string; + tool?: string; + params?: { query?: string }; + }; + expect(body).toEqual({ + service: "vaultwarden-secrets", + tool: "get_credential", + params: { query: "hosted" }, + }); + + return Response.json({ + success: true, + result: { fields: { token: "hosted-token" } }, + }); + }, + }); + + try { + process.env.MCP2CLI_VAULTWARDEN_REMOTE_URL = `http://127.0.0.1:${server.port}`; + process.env.MCP2CLI_AUTH_TOKEN = "test-token"; + const resolver = new VaultwardenSecretResolver(); + + await expect(resolver.resolve("hosted#fields.token")).resolves.toBe("hosted-token"); + expect(sawAuth).toBe(true); + } finally { + server.stop(true); + } + }); + test("times out stalled Vaultwarden lookups", async () => { process.env.MCP2CLI_VAULTWARDEN_COMMAND = Bun.argv[0]!; process.env.MCP2CLI_VAULTWARDEN_COMMAND_ARGS = JSON.stringify([ From 6acd8eeef7b58eb7d6a62588432e0a4171efb169 Mon Sep 17 00:00:00 2001 From: Rodaddy Date: Mon, 29 Jun 2026 12:58:11 -0400 Subject: [PATCH 2/2] fix: harden hosted daemon restart --- .github/workflows/ci.yml | 77 +++++++++---------- scripts/deploy/restart-hosted-daemon.sh | 86 ++++++++++++++++++++++ src/secrets/refs.ts | 70 +++++++++++++++++- tests/secrets/refs.test.ts | 98 +++++++++++++++++-------- 4 files changed, 256 insertions(+), 75 deletions(-) create mode 100755 scripts/deploy/restart-hosted-daemon.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1176b7..8e42d67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,6 +79,7 @@ jobs: LOG_FILE: /home/rico/.local/state/mcp2cli/log/mcp2cli.log STAGED_BINARY: /home/rico/.local/share/mcp2cli/mcp2cli-new BACKUP_PATH: /home/rico/.local/share/mcp2cli/backups/mcp2cli.bak + RESTART_HELPER: /home/rico/.local/share/mcp2cli/restart-hosted-daemon.sh VAULTWARDEN_REMOTE_URL: http://127.0.0.1:9500 HEALTH_URL: http://10.71.20.63:9500/health @@ -104,7 +105,20 @@ jobs: cp ${{ env.BINARY_PATH }} ${{ env.BACKUP_PATH }}; \ fi" + - name: Deploy restart helper + run: | + scp scripts/deploy/restart-hosted-daemon.sh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }}:${{ env.RESTART_HELPER }} + ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ + "chmod 755 ${{ env.RESTART_HELPER }} && \ + sh -n ${{ env.RESTART_HELPER }} && \ + BINARY_PATH=${{ env.BINARY_PATH }} \ + ENV_FILE=${{ env.ENV_FILE }} \ + PID_FILE=${{ env.PID_FILE }} \ + LOG_FILE=${{ env.LOG_FILE }} \ + ${{ env.RESTART_HELPER }} --check" + - name: Deploy new binary + id: deploy_binary run: | scp dist/mcp2cli ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }}:${{ env.STAGED_BINARY }} ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ @@ -114,27 +128,12 @@ jobs: - name: Restart hosted daemon run: | ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ - 'set -eu - if [ -f "${{ env.PID_FILE }}" ]; then - old_pid=$(cat "${{ env.PID_FILE }}") - if kill -0 "$old_pid" 2>/dev/null; then - kill "$old_pid" 2>/dev/null || true - for _ in 1 2 3 4 5; do - kill -0 "$old_pid" 2>/dev/null || break - sleep 1 - done - kill -0 "$old_pid" 2>/dev/null && kill -KILL "$old_pid" 2>/dev/null || true - fi - fi - pgrep -u "${{ env.DEPLOY_USER }}" -f "^${{ env.BINARY_PATH }}$" | while read -r pid; do - kill "$pid" 2>/dev/null || true - done - set -a - . "${{ env.ENV_FILE }}" - MCP2CLI_VAULTWARDEN_REMOTE_URL="${{ env.VAULTWARDEN_REMOTE_URL }}" - set +a - nohup "${{ env.BINARY_PATH }}" < /dev/null > "${{ env.LOG_FILE }}" 2>&1 & - echo $! > "${{ env.PID_FILE }}"' + "BINARY_PATH=${{ env.BINARY_PATH }} \ + ENV_FILE=${{ env.ENV_FILE }} \ + PID_FILE=${{ env.PID_FILE }} \ + LOG_FILE=${{ env.LOG_FILE }} \ + VAULTWARDEN_REMOTE_URL=${{ env.VAULTWARDEN_REMOTE_URL }} \ + ${{ env.RESTART_HELPER }}" - name: Health check (with retry) run: | @@ -143,6 +142,10 @@ jobs: if ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} "curl -sf --max-time 5 http://localhost:9500/health" > /dev/null 2>&1; then echo "Health check passed (attempt $i)" ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} "curl -sf http://localhost:9500/health" | jq '{version, activeConnections, configuredServices}' + ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ + "pid=\$(cat ${{ env.PID_FILE }}); \ + test \"\$(readlink /proc/\$pid/exe)\" = '${{ env.BINARY_PATH }}'; \ + kill -0 \"\$pid\"" exit 0 fi echo "Health check attempt $i failed, retrying..." @@ -151,7 +154,7 @@ jobs: exit 1 - name: Rollback on failure - if: failure() + if: failure() && steps.deploy_binary.outcome == 'success' run: | echo "Deployment failed -- rolling back to previous binary" ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ @@ -159,26 +162,12 @@ jobs: if [ -f "${{ env.BACKUP_PATH }}" ]; then mv "${{ env.BACKUP_PATH }}" "${{ env.BINARY_PATH }}" chmod 755 "${{ env.BINARY_PATH }}" - if [ -f "${{ env.PID_FILE }}" ]; then - old_pid=$(cat "${{ env.PID_FILE }}") - if kill -0 "$old_pid" 2>/dev/null; then - kill "$old_pid" 2>/dev/null || true - for _ in 1 2 3 4 5; do - kill -0 "$old_pid" 2>/dev/null || break - sleep 1 - done - kill -0 "$old_pid" 2>/dev/null && kill -KILL "$old_pid" 2>/dev/null || true - fi - fi - pgrep -u "${{ env.DEPLOY_USER }}" -f "^${{ env.BINARY_PATH }}$" | while read -r pid; do - kill "$pid" 2>/dev/null || true - done - set -a - . "${{ env.ENV_FILE }}" - MCP2CLI_VAULTWARDEN_REMOTE_URL="${{ env.VAULTWARDEN_REMOTE_URL }}" - set +a - nohup "${{ env.BINARY_PATH }}" < /dev/null > "${{ env.LOG_FILE }}" 2>&1 & - echo $! > "${{ env.PID_FILE }}" + BINARY_PATH="${{ env.BINARY_PATH }}" \ + ENV_FILE="${{ env.ENV_FILE }}" \ + PID_FILE="${{ env.PID_FILE }}" \ + LOG_FILE="${{ env.LOG_FILE }}" \ + VAULTWARDEN_REMOTE_URL="${{ env.VAULTWARDEN_REMOTE_URL }}" \ + "${{ env.RESTART_HELPER }}" echo "Rollback complete" else echo "No backup found -- manual intervention required" @@ -191,6 +180,10 @@ jobs: sleep 5 if ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} "curl -sf --max-time 5 http://localhost:9500/health" > /dev/null 2>&1; then echo "Rollback health check passed -- service restored" + ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ + "pid=\$(cat ${{ env.PID_FILE }}); \ + test \"\$(readlink /proc/\$pid/exe)\" = '${{ env.BINARY_PATH }}'; \ + kill -0 \"\$pid\"" else echo "WARNING: Rollback health check failed -- service may be down" exit 1 diff --git a/scripts/deploy/restart-hosted-daemon.sh b/scripts/deploy/restart-hosted-daemon.sh new file mode 100755 index 0000000..2b10b13 --- /dev/null +++ b/scripts/deploy/restart-hosted-daemon.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env sh +set -eu + +: "${BINARY_PATH:?BINARY_PATH is required}" +: "${ENV_FILE:?ENV_FILE is required}" +: "${PID_FILE:?PID_FILE is required}" +: "${LOG_FILE:?LOG_FILE is required}" + +mode="${1:-restart}" + +if [ ! -r "$ENV_FILE" ]; then + echo "Env file is missing or unreadable: $ENV_FILE" >&2 + exit 1 +fi + +if [ ! -x "$BINARY_PATH" ]; then + echo "Binary is missing or not executable: $BINARY_PATH" >&2 + exit 1 +fi + +(set -a; . "$ENV_FILE"; : ) + +if [ "$mode" = "--check" ]; then + exit 0 +fi + +mkdir -p "$(dirname "$PID_FILE")" +mkdir -p "$(dirname "$LOG_FILE")" + +if [ -f "$PID_FILE" ]; then + old_pid="$(cat "$PID_FILE")" + if [ -n "$old_pid" ] && [ "$old_pid" != "0" ] && kill -0 "$old_pid" 2>/dev/null; then + old_exe="$(readlink "/proc/$old_pid/exe" 2>/dev/null || true)" + if [ "$old_exe" = "$BINARY_PATH" ]; then + kill "$old_pid" 2>/dev/null || true + fi + fi +fi + +pgrep -u "$(id -un)" -f "^$BINARY_PATH$" | while read -r old_pid; do + kill "$old_pid" 2>/dev/null || true +done + +for _ in 1 2 3 4 5; do + if pgrep -u "$(id -un)" -f "^$BINARY_PATH$" >/dev/null 2>&1; then + sleep 1 + else + break + fi +done + +pgrep -u "$(id -un)" -f "^$BINARY_PATH$" | while read -r old_pid; do + kill -KILL "$old_pid" 2>/dev/null || true +done + +set -a +. "$ENV_FILE" +MCP2CLI_DAEMON=1 +MCP2CLI_LISTEN_HOST="${MCP2CLI_LISTEN_HOST:-0.0.0.0}" +MCP2CLI_LISTEN_PORT="${MCP2CLI_LISTEN_PORT:-9500}" +MCP2CLI_VAULTWARDEN_REMOTE_URL="${VAULTWARDEN_REMOTE_URL:-${MCP2CLI_VAULTWARDEN_REMOTE_URL:-http://127.0.0.1:9500}}" +set +a + +nohup "$BINARY_PATH" < /dev/null > "$LOG_FILE" 2>&1 & +pid="$!" +echo "$pid" > "$PID_FILE" + +for _ in 1 2 3 4 5 6 7 8 9 10; do + if kill -0 "$pid" 2>/dev/null && curl -sf --max-time 2 "http://127.0.0.1:${MCP2CLI_LISTEN_PORT}/health" >/dev/null 2>&1; then + break + fi + sleep 1 +done + +if ! kill -0 "$pid" 2>/dev/null; then + tail -n 80 "$LOG_FILE" || true + exit 1 +fi + +actual_exe="$(readlink "/proc/$pid/exe")" +if [ "$actual_exe" != "$BINARY_PATH" ]; then + echo "Service started unexpected binary: $actual_exe" >&2 + exit 1 +fi + +curl -sf --max-time 5 "http://127.0.0.1:${MCP2CLI_LISTEN_PORT}/health" >/dev/null diff --git a/src/secrets/refs.ts b/src/secrets/refs.ts index 74a86d0..ee74f6c 100644 --- a/src/secrets/refs.ts +++ b/src/secrets/refs.ts @@ -1,5 +1,7 @@ import { createLogger } from "../logger/index.ts"; import type { ServiceConfig } from "../config/index.ts"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; const log = createLogger("secret-refs"); const SECRET_REF_PATTERN = /\$\{secret:([^}]+)\}/g; @@ -105,7 +107,7 @@ function parseSecretRef(ref: string): { query: string; field?: string } { async function fetchVaultwardenCredential(query: string): Promise { const timeoutMs = resolveTimeoutMs(); - const remote = getVaultwardenRemoteConfig(); + const remote = await getVaultwardenRemoteConfig(); if (remote) { return fetchVaultwardenCredentialViaDaemon(query, remote, timeoutMs); } @@ -162,7 +164,7 @@ async function fetchVaultwardenCredential(query: string): Promise { } } -function getVaultwardenRemoteConfig(): { url: string; token?: string } | null { +async function getVaultwardenRemoteConfig(): Promise<{ url: string; token?: string } | null> { const explicitUrl = process.env.MCP2CLI_VAULTWARDEN_REMOTE_URL; const inheritedUrl = process.env.MCP2CLI_VAULTWARDEN_USE_DAEMON === "1" @@ -172,10 +174,63 @@ function getVaultwardenRemoteConfig(): { url: string; token?: string } | null { if (!url) return null; return { url, - token: process.env.MCP2CLI_VAULTWARDEN_AUTH_TOKEN ?? process.env.MCP2CLI_AUTH_TOKEN ?? process.env.MCP_TOKEN, + token: await resolveVaultwardenRemoteToken(url), }; } +async function resolveVaultwardenRemoteToken(url: string): Promise { + const token = + process.env.MCP2CLI_VAULTWARDEN_AUTH_TOKEN ?? + process.env.MCP2CLI_AUTH_TOKEN ?? + process.env.MCP_TOKEN ?? + await readDaemonTokenFile(); + + if (!token) return undefined; + if (shouldAttachRemoteAuth(url)) return token; + + throw new SecretResolutionError( + `Refusing to forward Vaultwarden daemon auth to non-loopback URL: ${redactUrl(url)}`, + ); +} + +async function readDaemonTokenFile(): Promise { + const tokensPath = + process.env.MCP2CLI_TOKENS_FILE ?? + join(process.env.HOME ?? "", ".config", "mcp2cli", "tokens.json"); + if (!tokensPath) return undefined; + + try { + const parsed = JSON.parse(await readFile(tokensPath, "utf8")) as { + tokens?: Array<{ token?: unknown; role?: unknown; expiresAt?: unknown }>; + }; + const tokenEntry = parsed.tokens?.find((entry) => + entry.role === "admin" && + typeof entry.token === "string" && + !isExpiredToken(typeof entry.expiresAt === "string" ? entry.expiresAt : undefined) + ); + return typeof tokenEntry?.token === "string" ? tokenEntry.token : undefined; + } catch { + return undefined; + } +} + +function isExpiredToken(expiresAt: string | undefined): boolean { + if (!expiresAt) return false; + const expiresAtMs = Date.parse(expiresAt); + return Number.isNaN(expiresAtMs) || expiresAtMs <= Date.now(); +} + +function shouldAttachRemoteAuth(url: string): boolean { + if (process.env.MCP2CLI_VAULTWARDEN_ALLOW_REMOTE_AUTH === "1") return true; + try { + const parsed = new URL(url); + const hostname = parsed.hostname.toLowerCase(); + return hostname === "localhost" || hostname === "::1" || hostname.startsWith("127."); + } catch { + return false; + } +} + async function fetchVaultwardenCredentialViaDaemon( query: string, remote: { url: string; token?: string }, @@ -275,3 +330,12 @@ function getPath(value: unknown, path: string): unknown { function redactRef(ref: string): string { return ref.length <= 4 ? "***" : `${ref.slice(0, 4)}***`; } + +function redactUrl(url: string): string { + try { + const parsed = new URL(url); + return `${parsed.protocol}//${parsed.host}`; + } catch { + return "***"; + } +} diff --git a/tests/secrets/refs.test.ts b/tests/secrets/refs.test.ts index 1725b27..7ecdfea 100644 --- a/tests/secrets/refs.test.ts +++ b/tests/secrets/refs.test.ts @@ -7,7 +7,9 @@ import { } from "../../src/secrets/index.ts"; import type { SecretResolver } from "../../src/secrets/index.ts"; import type { ServiceConfig } from "../../src/config/index.ts"; -import { resolve } from "node:path"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; describe("secret refs", () => { test("detects nested secret refs", () => { @@ -69,37 +71,30 @@ describe("secret refs", () => { }); describe("VaultwardenSecretResolver", () => { - const originalCommand = process.env.MCP2CLI_VAULTWARDEN_COMMAND; - const originalArgs = process.env.MCP2CLI_VAULTWARDEN_COMMAND_ARGS; - const originalTimeout = process.env.MCP2CLI_VAULTWARDEN_TIMEOUT_MS; - const originalRemoteUrl = process.env.MCP2CLI_VAULTWARDEN_REMOTE_URL; - const originalAuthToken = process.env.MCP2CLI_AUTH_TOKEN; + const envKeys = [ + "MCP2CLI_VAULTWARDEN_COMMAND", + "MCP2CLI_VAULTWARDEN_COMMAND_ARGS", + "MCP2CLI_VAULTWARDEN_TIMEOUT_MS", + "MCP2CLI_VAULTWARDEN_REMOTE_URL", + "MCP2CLI_VAULTWARDEN_AUTH_TOKEN", + "MCP2CLI_VAULTWARDEN_USE_DAEMON", + "MCP2CLI_VAULTWARDEN_ALLOW_REMOTE_AUTH", + "MCP2CLI_AUTH_TOKEN", + "MCP_TOKEN", + ["MCP2CLI_REMOTE", "URL"].join("_"), + "MCP_HOST", + "MCP2CLI_TOKENS_FILE", + ] as const; + const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]])); afterEach(() => { - if (originalCommand !== undefined) { - process.env.MCP2CLI_VAULTWARDEN_COMMAND = originalCommand; - } else { - delete process.env.MCP2CLI_VAULTWARDEN_COMMAND; - } - if (originalArgs !== undefined) { - process.env.MCP2CLI_VAULTWARDEN_COMMAND_ARGS = originalArgs; - } else { - delete process.env.MCP2CLI_VAULTWARDEN_COMMAND_ARGS; - } - if (originalTimeout !== undefined) { - process.env.MCP2CLI_VAULTWARDEN_TIMEOUT_MS = originalTimeout; - } else { - delete process.env.MCP2CLI_VAULTWARDEN_TIMEOUT_MS; - } - if (originalRemoteUrl !== undefined) { - process.env.MCP2CLI_VAULTWARDEN_REMOTE_URL = originalRemoteUrl; - } else { - delete process.env.MCP2CLI_VAULTWARDEN_REMOTE_URL; - } - if (originalAuthToken !== undefined) { - process.env.MCP2CLI_AUTH_TOKEN = originalAuthToken; - } else { - delete process.env.MCP2CLI_AUTH_TOKEN; + for (const key of envKeys) { + const original = originalEnv.get(key); + if (original !== undefined) { + process.env[key] = original; + } else { + delete process.env[key]; + } } }); @@ -179,6 +174,49 @@ describe("VaultwardenSecretResolver", () => { } }); + test("uses daemon token file for hosted daemon HTTP auth", async () => { + let sawAuth = false; + const tempDir = await mkdtemp(join(tmpdir(), "mcp2cli-refs-")); + const tokensPath = join(tempDir, "tokens.json"); + await writeFile(tokensPath, JSON.stringify({ + tokens: [{ token: "file-token", role: "admin" }], + })); + + const server = Bun.serve({ + port: 0, + async fetch(req) { + sawAuth = req.headers.get("Authorization") === "Bearer file-token"; + return Response.json({ + success: true, + result: { fields: { token: "file-backed-token" } }, + }); + }, + }); + + try { + delete process.env.MCP2CLI_AUTH_TOKEN; + delete process.env.MCP_TOKEN; + delete process.env.MCP2CLI_VAULTWARDEN_AUTH_TOKEN; + process.env.MCP2CLI_TOKENS_FILE = tokensPath; + process.env.MCP2CLI_VAULTWARDEN_REMOTE_URL = `http://127.0.0.1:${server.port}`; + const resolver = new VaultwardenSecretResolver(); + + await expect(resolver.resolve("hosted#fields.token")).resolves.toBe("file-backed-token"); + expect(sawAuth).toBe(true); + } finally { + server.stop(true); + await rm(tempDir, { recursive: true, force: true }); + } + }); + + test("refuses to forward daemon auth to non-loopback remotes by default", async () => { + process.env.MCP2CLI_VAULTWARDEN_REMOTE_URL = "https://vaultwarden.example.test"; + process.env.MCP2CLI_AUTH_TOKEN = "test-token"; + const resolver = new VaultwardenSecretResolver(); + + await expect(resolver.resolve("hosted#fields.token")).rejects.toThrow(SecretResolutionError); + }); + test("times out stalled Vaultwarden lookups", async () => { process.env.MCP2CLI_VAULTWARDEN_COMMAND = Bun.argv[0]!; process.env.MCP2CLI_VAULTWARDEN_COMMAND_ARGS = JSON.stringify([