different-ai/openwork · critical · ApiError

managed_mcp_secure_storage_unavailable

managed_mcp_secure_storage_unavailable

Error message

Secure storage for OpenWork-managed MCP credentials is unavailable. Start through OpenWork Desktop or set OPENWORK_ENCRYPTION_KEY.

What it means

resolveVaultKey builds the key used to encrypt OpenWork-managed MCP credentials. When config.localManagedMcpVaultKey is provided, it must resolve to exactly 32 bytes; any failure (callback missing/throwing or wrong length) is converted to managed_mcp_secure_storage_unavailable here. Without it the key is derived from OPENWORK_ENCRYPTION_KEY, and if neither source exists the same error is thrown, because the server refuses to store MCP credentials without secure key material.

Source

Thrown at apps/server/src/local-managed-mcp.ts:200

  return join(runtimeStorageDir(config), "local-managed-mcp-vault.json");
}

function secureVaultStorageUnavailable(): ApiError {
  return new ApiError(
    503,
    "managed_mcp_secure_storage_unavailable",
    "Secure storage for OpenWork-managed MCP credentials is unavailable. Start through OpenWork Desktop or set OPENWORK_ENCRYPTION_KEY.",
  );
}

async function resolveVaultKey(config: ServerConfig): Promise<Buffer> {
  if (config.localManagedMcpVaultKey) {
    try {
      const key = Buffer.from(await config.localManagedMcpVaultKey());
      if (key.byteLength !== 32) throw new Error("invalid vault key length");
      return key;
    } catch {
      throw secureVaultStorageUnavailable();
    }
  }
  const configured = process.env.OPENWORK_ENCRYPTION_KEY?.trim();
  if (configured) return createHash("sha256").update(configured).digest();
  throw secureVaultStorageUnavailable();
}

async function vaultKey(config: ServerConfig): Promise<Buffer> {
  let pending = vaultKeyByConfig.get(config);
  if (!pending) {
    pending = resolveVaultKey(config);
    vaultKeyByConfig.set(config, pending);
  }
  try {
    return Buffer.from(await pending);
  } catch (error) {
    vaultKeyByConfig.delete(config);
    throw error;

View on GitHub (pinned to 2b7df46e8a)

Solutions

  1. Set OPENWORK_ENCRYPTION_KEY to a strong secret in the server's environment before startup.
  2. Start the server through OpenWork Desktop so the OS keychain-backed vault key is provided.
  3. If supplying config.localManagedMcpVaultKey, return exactly 32 bytes (e.g. Buffer.from(hex, "hex") for a 64-char hex key, not the hex string as UTF-8).
  4. Persist the chosen key — credentials encrypted under one key cannot be decrypted after it changes.

Example fix

// before
localManagedMcpVaultKey: async () => Buffer.from(process.env.VAULT_KEY_HEX!) // "<64 hex chars>" as UTF-8 = 64 bytes -> unavailable
// after
localManagedMcpVaultKey: async () => Buffer.from(process.env.VAULT_KEY_HEX!, "hex") // 32 bytes
// or headless: export OPENWORK_ENCRYPTION_KEY="$(openssl rand -base64 32)" before start
Defensive patterns

Strategy: validation

Validate before calling

const raw = process.env.OPENWORK_ENCRYPTION_KEY;
if (!raw?.trim()) throw new Error("set OPENWORK_ENCRYPTION_KEY or start via OpenWork Desktop");
// custom provider:
const key = await config.localManagedMcpVaultKey?.();
if (key && key.byteLength !== 32) throw new Error(`vault key must be 32 bytes, got ${key.byteLength}`);

Type guard

function isVaultKey(v: unknown): v is Buffer {
  return v instanceof Buffer && v.byteLength === 32;
}

Try / catch

try {
  await storeManagedMcpCredential(creds);
} catch (e) {
  if ((e as Error).code === "managed_mcp_secure_storage_unavailable") {
    throw new Error("Provide OPENWORK_ENCRYPTION_KEY or run under OpenWork Desktop");
  }
  throw e;
}

Prevention

When it happens

Trigger: Server started outside OpenWork Desktop (no keychain-backed key callback) and OPENWORK_ENCRYPTION_KEY unset; or config.localManagedMcpVaultKey supplied but returns a key whose byteLength !== 32 or throws — the catch converts any such failure to this error.

Common situations: Headless/CLI/Docker/CI runs without Desktop's keychain; a custom vault-key provider returning a hex/base64 string as UTF-8 (64-char hex string as UTF-8 is 64 bytes, not 32); provider reads a secret that is unavailable at startup.

Related errors


AI-assisted analysis of different-ai/openwork@2b7df46e8a (2026-09-01). Data as JSON: /api/errors/10e09bfae70ac7b5. Report an issue: GitHub.