Skill: maverick-software/api-key-ui-tab (version 3.0.0)

--- name: apikeys-ui description: "Vault-backed API Keys management for OpenClaw. Secure file-based secret storage with one-click migration from plaintext config, dynamic key discovery, vault key selector for skills, manual secret creation, and plugin-registered settings tab." version: 3.0.0 author: OpenClaw Community metadata: {"openclaw":{"emoji":"🔐","requires":{"openclaw":">=2026.1.0"},"category":"settings"}} --- # Vault Enhancements w/ UI v3.0.0 Vault-backed API key management for the OpenClaw Control dashboard. Keys are stored in a secure file (`~/.openclaw/secrets.json`, mode 0600) and referenced via OpenClaw's built-in Secrets System. The AI agent never sees your keys. ## Status: ✅ Active | Component | Status | |-----------|--------| | Vault File Storage | ✅ Working | | Secret References (SecretRef) | ✅ Working | | Dynamic Key Discovery | ✅ Working | | One-Click Migration | ✅ Working | | Plugin-Registered Tab | ✅ Working | | Vault Status Banner | ✅ Working | | Key Status Badges | ✅ Working | | Vault-Only Keys Section | ✅ Working | | Manual "+ Add Secret" Form | ✅ Working | | Restart Notification Banner | ✅ Working | | Skills Vault Key Selector | ✅ Working | | Skills Inline Key Creation | ✅ Working | | Auth Profiles Display | ✅ Working | ## Features ### 1. Vault-Backed Storage Keys are stored in `~/.openclaw/secrets.json` (file permissions `0600`). When you save a key, the UI: 1. Writes the value to the vault file 2. Configures the file secret provider in `openclaw.json` (if not already present) 3. Replaces the plaintext config value with a `SecretRef` object 4. Shows a restart notification — user must restart gateway for changes to take effect **Config before migration:** ```json { "env": { "OPENAI_API_KEY": "sk-proj-abc123..." } } ``` **Config after migration:** ```json { "env": { "OPENAI_API_KEY": { "source": "file", "provider": "default", "id": "/OPENAI_API_KEY" } }, "secrets": { "providers": { "default": { "source": "file", "path": "~/.openclaw/secrets.json", "mode": "json" } }, "defaults": { "file": "default" } } } ``` **Vault file (`~/.openclaw/secrets.json`):** ```json { "OPENAI_API_KEY": "sk-proj-abc123..." } ``` ### 2. One-Click Migration The "🔒 Migrate to Vault" button appears when plaintext keys are detected. It: - Scans `openclaw.json` for all plaintext API key values - Moves each to the vault file - Replaces with `SecretRef` objects in config - Auto-configures the file provider if needed - Reports what was migrated ### 3. Dynamic Key Discovery The UI automatically scans the entire config for API keys — no hardcoded list. **Detection patterns:** `apiKey`, `api_key`, `token`, `secret`, `*_KEY`, `*_TOKEN`, `*_SECRET` **Where it looks:** - `env.*` — Environment variables - `skills.entries.*.apiKey` — Skill-specific keys - `messages.tts.*.apiKey` — TTS provider keys - Any nested config path **Known providers** get friendly names, descriptions, and "Get key ↗" links: | Provider | Env Key | |----------|---------| | Anthropic | `ANTHROPIC_API_KEY` | | OpenAI | `OPENAI_API_KEY` | | Google / Gemini | `GOOGLE_API_KEY` / `GEMINI_API_KEY` | | Brave Search | `BRAVE_API_KEY` | | ElevenLabs | `ELEVENLABS_API_KEY` | | Deepgram | `DEEPGRAM_API_KEY` | | OpenRouter | `OPENROUTER_API_KEY` | | Groq | `GROQ_API_KEY` | | Fireworks | `FIREWORKS_API_KEY` | | Mistral | `MISTRAL_API_KEY` | | xAI (Grok) | `XAI_API_KEY` | | Perplexity | `PERPLEXITY_API_KEY` | | GitHub | `GITHUB_TOKEN` | | Hume AI | `HUME_API_KEY` / `HUME_SECRET_KEY` | ### 4. Vault Status Banner Top of the page shows: - **🔒 Vault Active** (green) — All keys in vault, provider configured - **⚠️ X Plaintext Keys Detected** (yellow) — Migration recommended ### 5. Key Status Badges Each key row shows: - **VAULT** (green) — Stored in secure vault file - **PLAINTEXT** (yellow) — Still in config as raw string - **NOT SET** (grey) — Not configured ### 6. Vault-Only Keys Section Keys stored in the vault that aren't referenced by any config path are displayed in a dedicated "Vault-Only Keys" card. These are keys created manually or by skills that don't have a corresponding env/config entry. Each shows: - 🔒 icon with the key name (monospace) - Masked value preview - Delete button ### 7. Manual "+ Add Secret" Form The Vault tab header includes a "+ Add Secret" button that expands an inline form: - **KEY_NAME** input (monospace, UPPER_SNAKE_CASE recommended) - **Secret value** input (password field) - **Save** / **Cancel** buttons - Writes to vault with `envEntry: false` — no config entry created, no restart triggered - Key appears immediately in the "Vault-Only Keys" section ### 8. Restart Notification Banner When a vault write triggers a config change, a yellow warning banner appears: > ⚠ New secrets require a gateway restart to take effect. > [Restart Now] The banner persists until the user clicks "Restart Now" or refreshes. This replaces the previous auto-reload behavior that caused unexpected gateway restarts. ### 9. Skills Vault Key Selector On the Skills tab, skills that declare a `primaryEnv` get a vault key selector instead of a raw password input: **When unlinked:** - Dropdown shows all vault keys with 🔒 icons - "Select vault key for ENV_NAME…" placeholder - Selecting a key writes a `SecretRef` to `skills.entries.<key>.apiKey` in config - "＋ Add new vault key…" option opens inline creation form **When linked:** - Shows `🔒 KEY_NAME` with an "Unlink" button - Unlink removes the SecretRef from config **Inline key creation:** - KEY_NAME + Secret value fields - "Save & Link" creates the vault key and links it to the skill in one step - Key also appears in the Vault tab's vault-only keys section ### 10. Skills Expanded by Default All skill groups (workspace, built-in, managed) render expanded (`<details open>`) for better discoverability. Previously workspace and built-in were collapsed by default. ### 11. Auth Profiles Display Auth profile keys (from `auth-profiles.json`) that are stored in the vault are listed with their status. Backend RPCs support listing, error reset, and deletion. ## Architecture ### Security Model ``` ┌──────────────────────────────────────────────────────────┐ │ Browser │ │ ┌──────────────────────────────────────────────────┐ │ │ │ Vault Tab │ │ │ │ ┌──────────────────────────────────────────┐ │ │ │ │ │ OpenAI: [••••••••••] [Save] [✕] 🟢VAULT │ │ │ │ │ │ Anthropic: [ ] [Save] ⚪NOT SET│ │ │ │ │ │ + Add Secret [KEY_NAME] [value] [Save] │ │ │ │ │ └──────────────────────────────────────────┘ │ │ │ │ │ │ │ │ Skills Tab │ │ │ │ ┌──────────────────────────────────────────┐ │ │ │ │ │ whisper-api: 🔒 OPENAI_API_KEY [Unlink] │ │ │ │ │ │ sag: [Select vault key ▾] │ │ │ │ │ └──────────────────────────────────────────┘ │ │ │ └──────────────────────────────────────────────────┘ │ │ │ │ │ ▼ (direct RPC, not via agent) │ └───────────────────────────┼───────────────────────────────┘ │ ┌───────▼───────┐ │ Gateway │ │ secrets.write │ │ skills.update │ └──┬─────────┬──┘ │ │ ┌────────▼──┐ ┌──▼────────────┐ │ secrets. │ │ openclaw.json │ │ json │ │ (SecretRef │ │ (0600) │ │ objects only) │ └────────────┘ └────────────────┘ ``` ### Backend RPCs | Method | Description | |--------|-------------| | `secrets.status` | Vault file status, key count, plaintext count | | `secrets.list` | List secret IDs with masked values | | `secrets.write` | Store key in vault + optionally update config with SecretRef. `envEntry` param (default `true`) controls whether an env block entry is created. Returns `restartNeeded` flag instead of auto-reloading. | | `secrets.delete` | Remove from vault + config | | `secrets.migrate` | Batch-migrate all plaintext keys to vault | | `secrets.authProfiles.list` | List auth profile keys with vault status | | `secrets.authProfiles.resetErrors` | Reset auth profile error state | | `secrets.authProfiles.delete` | Delete an auth profile | | `skills.update` | Updated with `vaultKeyId` param — writes a SecretRef to `skills.entries.<key>.apiKey` or unlinks (empty string) | ### Skills-Status Integration `SkillStatusEntry` includes a `vaultKeyId` field that reads the **raw** config JSON (not the runtime-resolved config where SecretRefs are replaced with resolved strings). This is done via `extractVaultKeyIdFromConfig()` which reads and caches `openclaw.json` directly, checking for SecretRef objects in `skills.entries.<key>.apiKey`. ### Restart Behavior **No auto-restart on vault save.** Previously, `secrets.write` called `reloadSecrets()` which could trigger a gateway restart. Now: - Vault-only saves (`envEntry: false`) don't touch config — no restart needed - Config-touching saves return `restartNeeded: true` — UI shows a restart banner - Skills vault linking writes to config via `writeConfigFile()` — triggers the config file watcher which causes a gateway restart (inherent to the config watcher system) ### OpenClaw Secrets System Integration This skill uses OpenClaw's built-in Secrets System (`src/secrets/`): - **File provider:** `{ source: "file", path: "~/.openclaw/secrets.json", mode: "json" }` - **SecretRef format:** `{ source: "file", provider: "default", id: "/<KEY_NAME>" }` - **Runtime resolution:** `prepareSecretsRuntimeSnapshot()` resolves all refs at gateway startup - **Security:** File permissions checked (0600), ownership verified, path guards The secrets system also supports `env` and `exec` providers for advanced setups (e.g., environment variables, external vault commands). The file provider is the default for this UI. ### Files Modified (Source Locations in OpenClaw Repo) | File | Purpose | |------|---------| | `src/gateway/server-methods/secrets.ts` | Vault RPCs (status, list, write, delete, migrate, authProfiles) | | `src/gateway/server-methods/skills.ts` | Skills update with `vaultKeyId` param | | `src/gateway/server-methods/plugins-ui.ts` | Plugin view registration | | `src/gateway/protocol/schema/agents-models-skills.ts` | `vaultKeyId` in SkillsUpdateParamsSchema | | `src/agents/skills-status.ts` | `vaultKeyId` field on SkillStatusEntry, raw config reader | | `ui/src/ui/controllers/apikeys.ts` | Vault-aware state, addVaultSecret, loadVaultOnlyKeys | | `ui/src/ui/controllers/skills.ts` | VaultKeyEntry type, loadVaultKeys, linkSkillToVaultKey, addVaultKeyAndLink | | `ui/src/ui/views/apikeys.ts` | Vault UI (banners, badges, migration, add form, vault-only keys, restart banner) | | `ui/src/ui/views/skills.ts` | Vault key selector dropdown, inline creation, expanded groups | | `ui/src/ui/app.ts` | State properties (vault, restart, skill key management) | | `ui/src/ui/app-render.ts` | Prop wiring for vault and skills | | `ui/src/ui/app-settings.ts` | Tab load triggers for vault keys | | `ui/src/ui/types.ts` | `vaultKeyId` on SkillStatusEntry | | `ui/src/ui/navigation.ts` | Vault tab (lock icon), removed 1password/discord standalone tabs | ### Reference Files ``` apikeys-ui/ ├── SKILL.md # This file ├── INSTALL_INSTRUCTIONS.md # Step-by-step installation (legacy) └── reference/ ├── apikeys-controller.ts # UI controller (vault tab) ├── apikeys-views.ts # UI view (vault tab) ├── secrets-rpc.ts # Backend vault RPCs ├── skills-controller.ts # UI controller (skills vault integration) ├── skills-views.ts # UI view (vault key selector) ├── skills-status.ts # Backend skill status with vaultKeyId └── skills-rpc.ts # Backend skills update RPC ``` ## Key Design Decisions 1. **No auto-restart on save** — `secrets.write` no longer calls `reloadSecrets()`. A restart banner with "Restart Now" button lets the user decide when to restart. 2. **Vault-only keys** — Keys created with `envEntry: false` don't appear in config. A separate `secrets.list` call finds all vault entries and shows orphan keys in a dedicated section. 3. **Raw config reading for vaultKeyId** — The runtime resolves SecretRefs to strings, so `loadConfig()` returns resolved values. `extractVaultKeyIdFromConfig()` reads the raw JSON file directly (with mtime caching) to detect SecretRef objects. 4. **Skills expanded by default** — All `<details>` groups render open for better UX. Collapsed-by-default hid skills that needed configuration. 5. **Skills use vault references, not plaintext** — Skills with `primaryEnv` get a vault key selector dropdown instead of a password input. Linking writes a SecretRef to `skills.entries.<key>.apiKey` in config. 6. **Inline key creation from skills** — "＋ Add new vault key…" in the skills dropdown creates a vault entry and links it in one step, reducing friction. ## Changelog ### v3.0.0 - **Manual "+ Add Secret" form** in Vault tab header — create vault keys without config entries - **Vault-Only Keys section** — shows keys in vault not referenced by config - **Restart notification banner** — yellow warning with "Restart Now" button replaces auto-reload - **Skills vault key selector** — dropdown replaces raw password input for skills with `primaryEnv` - **Skills inline key creation** — "＋ Add new vault key…" creates and links in one step - **Skills expanded by default** — all groups open, no more collapsed-by-default - **vaultKeyId on SkillStatusEntry** — reads raw config JSON to detect SecretRef objects - **`envEntry` param on secrets.write** — controls whether an env block entry is created - **`vaultKeyId` param on skills.update** — writes SecretRef or unlinks skill from vault key ### v2.0.0 - **Vault-backed storage:** Keys stored in `~/.openclaw/secrets.json` (mode 0600) instead of plaintext config - **SecretRef integration:** Config values replaced with OpenClaw SecretRef objects pointing to vault - **One-click migration:** "Migrate to Vault" batch-migrates all plaintext keys - **Vault status banner:** Green (all secure) or yellow warning (plaintext detected) - **Key badges:** VAULT / PLAINTEXT / NOT SET per key - **New RPCs:** `secrets.status`, `secrets.list`, `secrets.write`, `secrets.delete`, `secrets.migrate` - **Plugin UI registration:** Moved from old nav to plugin architecture (settings group, position 1) - **Auto-provider config:** Saving a key auto-configures the file secret provider if not present ### v1.1.0 - Dynamic key discovery — scans entire config instead of hardcoded list - Auto-grouping by category (Environment / Skills / Other) - Common env keys shown even if not configured ### v1.0.0 - Initial release with hardcoded provider slots - Save/Clear functionality via `config.patch`

path: INSTALL_INSTRUCTIONS.md
content: # Installation

As of v2.0.0, the API Keys UI is built into OpenClaw as a core feature. No manual installation needed.

The tab is registered automatically via the Plugin UI architecture and appears under **Settings → API Keys** in the dashboard.

## What Changed

Previously, this skill required manually copying view/controller files and editing navigation. Now:
- Backend RPCs are in `src/gateway/server-methods/secrets.ts`
- UI files are at `ui/src/ui/controllers/apikeys.ts` and `ui/src/ui/views/apikeys.ts`
- Tab registration is in `src/gateway/server-methods/plugins-ui.ts` (builtin view)

## First-Time Vault Setup

No setup required. On first key save, the system automatically:
1. Creates `~/.openclaw/secrets.json` (mode 0600)
2. Configures the file secret provider in `openclaw.json`
3. Stores the key and creates a SecretRef

To migrate existing plaintext keys, click "🔒 Migrate to Vault" on the API Keys tab.


path: reference/apikeys-controller.ts
content: import type { GatewayBrowserClient } from "../gateway";

export type DiscoveredKey = {
  id: string;
  path: string[];
  pathStr: string;
  name: string;
  description: string;
  configured: boolean;
  maskedValue?: string;
  docsUrl?: string;
  provider?: string;
  /** Whether this key is stored in the vault (vs plaintext config) */
  inVault?: boolean;
};

export type VaultStatus = {
  vaultExists: boolean;
  vaultPath: string;
  keyCount: number;
  providerConfigured: boolean;
  plaintextCount: number;
};

export type AuthProfile = {
  id: string;
  type: "api_key" | "token" | "oauth";
  provider: string;
  email: string | null;
  inVault: boolean;
  hasCredential: boolean;
  status: "active" | "error" | "cooldown" | "unused";
  isLastGood: boolean;
  lastUsed: number | null;
  errorCount: number;
  lastFailureAt: number | null;
  cooldownUntil: number | null;
};

export type ApiKeysState = {
  client: GatewayBrowserClient | null;
  connected: boolean;
  apikeysLoading?: boolean;
  apikeysError?: string | null;
  apikeysKeys?: DiscoveredKey[];
  apikeysEdits?: Record<string, string>;
  apikeysBusyKey?: string | null;
  apikeysMessage?: { kind: "success" | "error"; text: string } | null;
  apikeysConfigHash?: string | null;
  apikeysRefreshSuccess?: boolean;
  apikeysVaultStatus?: VaultStatus | null;
  apikeysMigrating?: boolean;
  authProfiles?: AuthProfile[];
  authProfilesLoading?: boolean;
};

/**
 * Known provider metadata for better UX on recognized keys
 */
const KNOWN_PROVIDERS: Record<string, { name: string; description: string; docsUrl?: string }> = {
  // env keys
  ANTHROPIC_API_KEY: {
    name: "Anthropic",
    description: "Claude models",
    docsUrl: "https://console.anthropic.com/settings/keys",
  },
  OPENAI_API_KEY: {
    name: "OpenAI",
    description: "GPT, Whisper, DALL-E, embeddings",
    docsUrl: "https://platform.openai.com/api-keys",
  },
  BRAVE_API_KEY: {
    name: "Brave Search",
    description: "Web search API",
    docsUrl: "https://brave.com/search/api/",
  },
  DEEPGRAM_API_KEY: {
    name: "Deepgram",
    description: "Speech-to-text",
    docsUrl: "https://console.deepgram.com/",
  },
  GOOGLE_API_KEY: {
    name: "Google",
    description: "Gemini models, Google APIs",
    docsUrl: "https://aistudio.google.com/apikey",
  },
  GEMINI_API_KEY: {
    name: "Google Gemini",
    description: "Gemini models",
    docsUrl: "https://aistudio.google.com/apikey",
  },
  ELEVENLABS_API_KEY: {
    name: "ElevenLabs",
    description: "Text-to-speech",
    docsUrl: "https://elevenlabs.io/app/settings/api-keys",
  },
  OPENROUTER_API_KEY: {
    name: "OpenRouter",
    description: "Multi-provider LLM access",
    docsUrl: "https://openrouter.ai/settings/keys",
  },
  GROQ_API_KEY: {
    name: "Groq",
    description: "Fast Llama/Mixtral inference",
    docsUrl: "https://console.groq.com/keys",
  },
  FIREWORKS_API_KEY: {
    name: "Fireworks AI",
    description: "Open-source model inference",
    docsUrl: "https://fireworks.ai/account/api-keys",
  },
  MISTRAL_API_KEY: {
    name: "Mistral AI",
    description: "Mistral models",
    docsUrl: "https://console.mistral.ai/api-keys/",
  },
  XAI_API_KEY: { name: "xAI (Grok)", description: "Grok models", docsUrl: "https://console.x.ai/" },
  PERPLEXITY_API_KEY: {
    name: "Perplexity",
    description: "AI-powered search",
    docsUrl: "https://www.perplexity.ai/settings/api",
  },
  GITHUB_TOKEN: {
    name: "GitHub",
    description: "GitHub API access",
    docsUrl: "https://github.com/settings/tokens",
  },
  HUME_API_KEY: {
    name: "Hume AI",
    description: "Emotion AI",
    docsUrl: "https://platform.hume.ai/",
  },
  HUME_SECRET_KEY: {
    name: "Hume AI Secret",
    description: "Hume AI secret key",
    docsUrl: "https://platform.hume.ai/",
  },
  MATON_API_KEY: {
    name: "Maton",
    description: "Maton API",
  },
  // Common nested paths
  elevenlabs: {
    name: "ElevenLabs",
    description: "Text-to-speech",
    docsUrl: "https://elevenlabs.io/app/settings/api-keys",
  },
  openai: {
    name: "OpenAI",
    description: "GPT, Whisper, DALL-E",
    docsUrl: "https://platform.openai.com/api-keys",
  },
  anthropic: {
    name: "Anthropic",
    description: "Claude models",
    docsUrl: "https://console.anthropic.com/settings/keys",
  },
  deepgram: {
    name: "Deepgram",
    description: "Speech-to-text",
    docsUrl: "https://console.deepgram.com/",
  },
  sag: {
    name: "ElevenLabs (sag)",
    description: "TTS skill",
    docsUrl: "https://elevenlabs.io/app/settings/api-keys",
  },
  "openai-whisper-api": {
    name: "OpenAI Whisper",
    description: "Transcription skill",
    docsUrl: "https://platform.openai.com/api-keys",
  },
  "openai-image-gen": {
    name: "OpenAI Images",
    description: "DALL-E skill",
    docsUrl: "https://platform.openai.com/api-keys",
  },
  "deepgram-streaming": {
    name: "Deepgram Streaming",
    description: "Live transcription",
    docsUrl: "https://console.deepgram.com/",
  },
};

/**
 * Patterns that indicate an API key field
 */
const KEY_PATTERNS = [
  /apiKey$/i,
  /api_key$/i,
  /^token$/i,
  /secret$/i,
  /_KEY$/,
  /_TOKEN$/,
  /_SECRET$/,
];

/**
 * Paths to skip when scanning (not API keys)
 */
const SKIP_PATHS = [
  "meta",
  "wizard",
  "update",
  "browser",
  "auth.profiles",
  "gateway.auth.token",
  "channels.telegram.botToken",
  "channels.discord.token",
];

function isKeyField(fieldName: string): boolean {
  return KEY_PATTERNS.some((pattern) => pattern.test(fieldName));
}

function shouldSkipPath(path: string[]): boolean {
  const pathStr = path.join(".");
  return SKIP_PATHS.some((skip) => pathStr.startsWith(skip));
}

function maskKey(key: string): string {
  if (!key || key.length <= 8) return "••••••••";
  return `${key.slice(0, 4)}...${key.slice(-4)}`;
}

const REDACTED_SENTINEL = "__OPENCLAW_REDACTED__";

function isSecretRef(value: unknown): boolean {
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
  const v = value as Record<string, unknown>;
  return (v.source === "env" || v.source === "file" || v.source === "exec") &&
    typeof v.provider === "string" && typeof v.id === "string";
}

function isRedacted(value: unknown): boolean {
  return value === REDACTED_SENTINEL;
}

function generateName(path: string[]): string {
  for (const segment of path) {
    const known = KNOWN_PROVIDERS[segment];
    if (known) return known.name;
  }
  if (path[0] === "env" && path[1]) {
    const known = KNOWN_PROVIDERS[path[1]];
    if (known) return known.name;
    return path[1]
      .replace(/_API_KEY$|_KEY$|_TOKEN$|_SECRET$/i, "")
      .split("_")
      .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
      .join(" ");
  }
  if (path[0] === "skills" && path[1] === "entries" && path[2]) {
    const known = KNOWN_PROVIDERS[path[2]];
    if (known) return known.name;
    return `Skill: ${path[2]}`;
  }
  const secondToLast = path[path.length - 2];
  if (secondToLast) {
    const known = KNOWN_PROVIDERS[secondToLast];
    if (known) return known.name;
    return secondToLast.charAt(0).toUpperCase() + secondToLast.slice(1);
  }
  return path.join(".");
}

function generateDescription(path: string[]): string {
  for (const segment of path) {
    const known = KNOWN_PROVIDERS[segment];
    if (known) return known.description;
  }
  if (path[0] === "env" && path[1]) {
    const known = KNOWN_PROVIDERS[path[1]];
    if (known) return known.description;
  }
  if (path[0] === "skills" && path[1] === "entries") {
    return `API key for ${path[2]} skill`;
  }
  if (path[0] === "env") return `Environment variable`;
  return `Config: ${path.slice(0, -1).join(".")}`;
}

function getDocsUrl(path: string[]): string | undefined {
  for (const segment of path) {
    const known = KNOWN_PROVIDERS[segment];
    if (known?.docsUrl) return known.docsUrl;
  }
  if (path[0] === "env" && path[1]) {
    const known = KNOWN_PROVIDERS[path[1]];
    if (known?.docsUrl) return known.docsUrl;
  }
  return undefined;
}

/**
 * Recursively scan config for API keys, detecting both plaintext and SecretRef values
 */
function scanForKeys(
  obj: Record<string, unknown>,
  path: string[] = [],
  results: DiscoveredKey[] = [],
): DiscoveredKey[] {
  if (!obj || typeof obj !== "object") return results;

  for (const [key, value] of Object.entries(obj)) {
    const currentPath = [...path, key];
    if (shouldSkipPath(currentPath)) continue;

    if (isKeyField(key)) {
      const pathStr = currentPath.join(".");
      const isRef = isSecretRef(value);
      const redacted = isRedacted(value);
      const hasValue = isRef || redacted || (typeof value === "string" && value.length > 0 && value !== REDACTED_SENTINEL);

      // Redacted values mean the gateway scrubbed a real value — it's configured.
      // Could be a SecretRef that got flattened, or a plaintext key that was masked.
      results.push({
        id: pathStr,
        path: currentPath,
        pathStr,
        name: generateName(currentPath),
        description: generateDescription(currentPath),
        configured: hasValue,
        maskedValue: hasValue
          ? isRef ? "🔒 vault"
          : redacted ? "••••••••"
          : maskKey(value as string)
          : undefined,
        docsUrl: getDocsUrl(currentPath),
        inVault: isRef,
      });
    }

    if (value && typeof value === "object" && !Array.isArray(value) && !isSecretRef(value)) {
      scanForKeys(value as Record<string, unknown>, currentPath, results);
    }
  }

  return results;
}

function addMissingCommonKeys(
  keys: DiscoveredKey[],
): DiscoveredKey[] {
  const existingEnvKeys = new Set(keys.filter((k) => k.path[0] === "env").map((k) => k.path[1]));

  const commonEnvKeys = [
    "ANTHROPIC_API_KEY",
    "OPENAI_API_KEY",
    "BRAVE_API_KEY",
    "DEEPGRAM_API_KEY",
    "GOOGLE_API_KEY",
    "ELEVENLABS_API_KEY",
    "GITHUB_TOKEN",
  ];

  for (const envKey of commonEnvKeys) {
    if (!existingEnvKeys.has(envKey)) {
      const path = ["env", envKey];
      const known = KNOWN_PROVIDERS[envKey];
      keys.push({
        id: path.join("."),
        path,
        pathStr: path.join("."),
        name: known?.name || envKey,
        description: known?.description || "Not configured",
        configured: false,
        docsUrl: known?.docsUrl,
        inVault: false,
      });
    }
  }

  return keys;
}

function sortKeys(keys: DiscoveredKey[]): DiscoveredKey[] {
  return keys.toSorted((a, b) => {
    const aIsEnv = a.path[0] === "env" ? 0 : 1;
    const bIsEnv = b.path[0] === "env" ? 0 : 1;
    if (aIsEnv !== bIsEnv) return aIsEnv - bIsEnv;
    return a.name.localeCompare(b.name);
  });
}

/**
 * Merge vault keys with config-scanned keys.
 * Vault keys get masked values from the vault list response.
 */
function mergeVaultKeys(
  keys: DiscoveredKey[],
  vaultKeys: Array<{ id: string; masked: string }>,
): DiscoveredKey[] {
  const vaultMap = new Map(vaultKeys.map((k) => [k.id, k.masked]));

  for (const key of keys) {
    // Already detected as vault ref by config scan
    if (key.inVault) continue;

    // For env keys, check if they exist in the vault
    if (key.path[0] === "env" && key.path[1]) {
      const vaultMasked = vaultMap.get(key.path[1]);
      if (vaultMasked) {
        key.inVault = true;
        key.configured = true;
        key.maskedValue = vaultMasked;
      }
    }
  }

  return keys;
}

/**
 * Load API keys by scanning config + vault status
 */
export async function loadApiKeys(state: ApiKeysState): Promise<void> {
  if (!state.client || !state.connected) return;

  state.apikeysError = null;

  try {
    // Fetch config, vault status, and vault keys in parallel
    const [configRes, vaultStatusRes, vaultListRes] = await Promise.all([
      state.client.request<{ config?: Record<string, unknown>; hash?: string }>("config.get", {}),
      state.client.request<{ ok: boolean } & VaultStatus>("secrets.status", {}).catch(() => null),
      state.client.request<{ ok: boolean; keys: Array<{ id: string; masked: string }> }>("secrets.list", {}).catch(() => null),
    ]);

    const config = configRes?.config ?? {};
    state.apikeysConfigHash = configRes?.hash || null;

    // Update vault status
    if (vaultStatusRes?.ok) {
      state.apikeysVaultStatus = {
        vaultExists: vaultStatusRes.vaultExists,
        vaultPath: vaultStatusRes.vaultPath,
        keyCount: vaultStatusRes.keyCount,
        providerConfigured: vaultStatusRes.providerConfigured,
        plaintextCount: vaultStatusRes.plaintextCount,
      };
    }

    // Scan config for all API keys
    let keys = scanForKeys(config);
    keys = addMissingCommonKeys(keys);

    // Merge vault info if available
    if (vaultListRes?.ok && vaultListRes.keys) {
      keys = mergeVaultKeys(keys, vaultListRes.keys);
    }

    keys = sortKeys(keys);

    state.apikeysKeys = keys;
    state.apikeysError = null;
    state.apikeysLoading = false;
    state.apikeysRefreshSuccess = true;

    setTimeout(() => {
      state.apikeysRefreshSuccess = false;
    }, 2000);
  } catch (err) {
    state.apikeysError = err instanceof Error ? err.message : String(err);
    state.apikeysLoading = false;
  }
}

export async function loadAuthProfiles(state: ApiKeysState): Promise<void> {
  if (!state.client || !state.connected) return;
  state.authProfilesLoading = true;
  try {
    const res = await state.client.request<{ ok: boolean; profiles: AuthProfile[] }>(
      "secrets.authProfiles.list",
      {},
    );
    state.authProfiles = res.profiles ?? [];
  } catch {
    state.authProfiles = [];
  } finally {
    state.authProfilesLoading = false;
  }
}

export async function resetAuthProfileErrors(state: ApiKeysState, profileId: string): Promise<void> {
  if (!state.client || !state.connected) return;
  try {
    await state.client.request("secrets.authProfiles.resetErrors", { profileId });
    state.apikeysMessage = { kind: "success", text: `Reset errors for ${profileId}` };
    await loadAuthProfiles(state);
  } catch (err) {
    state.apikeysMessage = { kind: "error", text: `Failed: ${err instanceof Error ? err.message : String(err)}` };
  }
}

export async function deleteAuthProfile(state: ApiKeysState, profileId: string): Promise<void> {
  if (!state.client || !state.connected) return;
  try {
    await state.client.request("secrets.authProfiles.delete", { profileId });
    state.apikeysMessage = { kind: "success", text: `Removed ${profileId}` };
    await loadAuthProfiles(state);
  } catch (err) {
    state.apikeysMessage = { kind: "error", text: `Failed: ${err instanceof Error ? err.message : String(err)}` };
  }
}

export function updateApiKeyEdit(state: ApiKeysState, keyId: string, value: string): void {
  state.apikeysEdits = { ...state.apikeysEdits, [keyId]: value };
}

/**
 * Save an API key — always goes through vault now
 */
export async function saveApiKey(state: ApiKeysState, keyId: string): Promise<void> {
  if (!state.client || !state.connected) return;

  const key = state.apikeysKeys?.find((k) => k.id === keyId);
  if (!key) return;

  const value = state.apikeysEdits?.[keyId];
  if (!value || value.trim().length === 0) return;

  state.apikeysBusyKey = keyId;
  state.apikeysError = null;
  state.apikeysMessage = null;

  try {
    // Extract the secret ID (env key name like OPENAI_API_KEY)
    const secretId = key.path[0] === "env" ? key.path[1] : key.pathStr;

    await state.client.request("secrets.write", {
      id: secretId,
      value: value.trim(),
    });

    // Clear edit buffer
    const edits = { ...state.apikeysEdits };
    delete edits[keyId];
    state.apikeysEdits = edits;

    state.apikeysMessage = {
      kind: "success",
      text: `${key.name} saved to vault. Restart the gateway for changes to take effect.`,
    };
    (state as Record<string, unknown>).restartNeeded = true;

    // Reload after brief delay
    setTimeout(() => loadApiKeys(state), 1500);
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    state.apikeysError = message;
    state.apikeysMessage = {
      kind: "error",
      text: `Failed to save ${key.name}: ${message}`,
    };
  } finally {
    state.apikeysBusyKey = null;
  }
}

/**
 * Clear an API key from vault and config
 */
export async function clearApiKey(state: ApiKeysState, keyId: string): Promise<void> {
  if (!state.client || !state.connected) return;

  const key = state.apikeysKeys?.find((k) => k.id === keyId);
  if (!key) return;

  state.apikeysBusyKey = keyId;
  state.apikeysError = null;
  state.apikeysMessage = null;

  try {
    const secretId = key.path[0] === "env" ? key.path[1] : key.pathStr;

    await state.client.request("secrets.delete", { id: secretId });

    state.apikeysMessage = {
      kind: "success",
      text: `${key.name} removed.`,
    };

    setTimeout(() => loadApiKeys(state), 1500);
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    state.apikeysError = message;
    state.apikeysMessage = {
      kind: "error",
      text: `Failed to clear ${key.name}: ${message}`,
    };
  } finally {
    state.apikeysBusyKey = null;
  }
}

/**
 * Migrate all plaintext keys to vault
 */
export async function migrateToVault(state: ApiKeysState): Promise<void> {
  if (!state.client || !state.connected) return;

  state.apikeysMigrating = true;
  state.apikeysMessage = null;

  try {
    const result = await state.client.request<{
      ok: boolean;
      migrated: number;
      keys: string[];
    }>("secrets.migrate", {});

    if (result.migrated === 0) {
      state.apikeysMessage = {
        kind: "success",
        text: "All keys are already in the vault. Nothing to migrate.",
      };
    } else {
      state.apikeysMessage = {
        kind: "success",
        text: `Migrated ${result.migrated} key${result.migrated === 1 ? "" : "s"} to vault: ${result.keys.join(", ")}`,
      };
    }

    // Reload
    setTimeout(() => loadApiKeys(state), 1500);
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    state.apikeysMessage = {
      kind: "error",
      text: `Migration failed: ${message}`,
    };
  } finally {
    state.apikeysMigrating = false;
  }
}

export async function addVaultSecret(
  state: ApiKeysState,
  name: string,
  value: string,
  envEntry = false,
): Promise<boolean> {
  if (!state.client || !state.connected) return false;

  try {
    await state.client.request("secrets.write", {
      id: name.trim(),
      value: value.trim(),
      envEntry,
    });

    state.apikeysMessage = {
      kind: "success",
      text: `Secret "${name}" added to vault. Restart the gateway for the key to take effect.`,
    };
    (state as Record<string, unknown>).restartNeeded = true;

    setTimeout(() => loadApiKeys(state), 1000);
    return true;
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    state.apikeysMessage = {
      kind: "error",
      text: `Failed to add [REDACTED_SECRET]`,
    };
    return false;
  }
}

export async function loadVaultOnlyKeys(state: ApiKeysState): Promise<void> {
  if (!state.client || !state.connected) return;
  try {
    const res = await state.client.request<{ keys: { id: string; masked: string }[] }>("secrets.list", {});
    (state as Record<string, unknown>).vaultAllKeys = res?.keys ?? [];
  } catch {
    (state as Record<string, unknown>).vaultAllKeys = [];
  }
}


path: reference/apikeys-views.ts
content: import { html, nothing } from "lit";
import type { DiscoveredKey, VaultStatus, AuthProfile } from "../controllers/apikeys.js";

export type ApiKeysProps = {
  error: string | null;
  keys: DiscoveredKey[];
  edits: Record<string, string>;
  editing: Record<string, boolean>;
  busyKey: string | null;
  message: { kind: "success" | "error"; text: string } | null;
  loading?: boolean;
  vaultStatus?: VaultStatus | null;
  migrating?: boolean;
  authProfiles?: AuthProfile[];
  authProfilesLoading?: boolean;
  onEdit: (keyId: string, value: string) => void;
  onSave: (keyId: string) => void;
  onClear: (keyId: string) => void;
  onRefresh?: () => void;
  onMigrate?: () => void;
  onResetProfileErrors?: (profileId: string) => void;
  onDeleteProfile?: (profileId: string) => void;
  onToggleEdit?: (keyId: string) => void;
  onRefreshSecret?: (keyId: string) => void;
  showAddForm?: boolean;
  addName?: string;
  addValue?: string;
  addBusy?: boolean;
  onToggleAddForm?: () => void;
  onAddNameChange?: (name: string) => void;
  onAddValueChange?: (value: string) => void;
  onAddSecret?: () => void;
  restartNeeded?: boolean;
  onRestart?: () => void;
  vaultAllKeys?: { id: string; masked: string }[];
};

export function renderApiKeys(props: ApiKeysProps) {
  const envKeys = props.keys.filter((k) => k.path[0] === "env");
  const skillKeys = props.keys.filter((k) => k.path[0] === "skills");
  const otherKeys = props.keys.filter((k) => k.path[0] !== "env" && k.path[0] !== "skills");

  const plaintextCount = props.keys.filter((k) => k.configured && !k.inVault).length;

  return html`
    <div class="page-body" style="max-width:780px;">

      <!-- Info note at top -->
      <div style="margin-bottom:16px;padding:10px 14px;font-size:12px;color:var(--text-muted);background:var(--bg-surface,#0d1117);border:1px solid var(--border,#30363d);border-radius:8px;">
        🔒 Encrypted keys are stored in <code>~/.openclaw/secrets.json</code> (mode 0600).
        Config references point to the vault — the AI agent <strong>never sees your keys</strong>.
      </div>

      <!-- Header Card -->
      <section class="card" style="margin-bottom:16px;">
        <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:12px;">
          <div style="flex:1;min-width:200px;">
            <div class="card-title">🔒 Vault</div>
          </div>
          <div style="display:flex;gap:8px;">
            ${plaintextCount > 0 && props.onMigrate ? html`
              <button class="btn" ?disabled=${props.migrating} @click=${props.onMigrate}
                style="background:var(--clr-warning,#f59e0b);color:#000;border:none;font-weight:600;">
                ${props.migrating ? "Migrating…" : "🔒 Migrate to Vault"}
              </button>
            ` : nothing}
            <button class="btn" @click=${() => props.onToggleAddForm?.()} title="Add a new secret to the vault">＋ Add Secret</button>
            <button class="btn" @click=${() => props.onRefresh?.()} title="Refresh">↻</button>
          </div>
        </div>

        ${props.error ? html`<div class="callout danger" style="margin-top:12px;">${props.error}</div>` : nothing}
        ${props.message ? html`<div class="callout ${props.message.kind === "error" ? "danger" : "success"}" style="margin-top:12px;">${props.message.text}</div>` : nothing}

        ${props.restartNeeded ? html`
          <div style="margin-top:12px;padding:10px 14px;background:#f59e0b18;border:1px solid var(--clr-warning,#f59e0b);border-radius:8px;display:flex;align-items:center;justify-content:space-between;gap:10px;">
            <span style="font-size:13px;color:var(--clr-warning,#f59e0b);">⚠ New secrets require a gateway restart to take effect.</span>
            <button class="btn" style="font-size:12px;padding:4px 12px;background:var(--clr-warning,#f59e0b);color:#000;border:none;font-weight:600;"
              @click=${() => props.onRestart?.()}>Restart Now</button>
          </div>
        ` : nothing}

        ${props.showAddForm ? html`
          <div style="margin-top:14px;padding:14px;background:var(--bg-surface,#0d1117);border:1px solid var(--border,#30363d);border-radius:8px;">
            <div style="font-weight:600;font-size:13px;margin-bottom:10px;">Add New Secret</div>
            <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
              <input
                type="text"
                style="width:200px;padding:6px 10px;background:var(--bg-surface,#0d1117);border:1px solid var(--border,#30363d);border-radius:6px;color:var(--text);font-size:13px;font-family:monospace;"
                .value=${props.addName ?? ""}
                placeholder="KEY_NAME"
                @input=${(e: Event) => props.onAddNameChange?.((e.target as HTMLInputElement).value)}
              />
              <input
                type="password"
                style="flex:1;min-width:200px;padding:6px 10px;background:var(--bg-surface,#0d1117);border:1px solid var(--border,#30363d);border-radius:6px;color:var(--text);font-size:13px;"
                .value=${props.addValue ?? ""}
                placeholder="Secret value"
                @input=${(e: Event) => props.onAddValueChange?.((e.target as HTMLInputElement).value)}
              />
              <button class="btn primary" style="font-size:12px;padding:6px 14px;"
                ?disabled=${props.addBusy || !(props.addName?.trim()) || !(props.addValue?.trim())}
                @click=${() => props.onAddSecret?.()}>
                ${props.addBusy ? "Saving…" : "Save"}
              </button>
              <button class="btn" style="font-size:12px;padding:6px 10px;"
                @click=${() => props.onToggleAddForm?.()}>Cancel</button>
            </div>
            <div style="font-size:11px;color:var(--text-muted);margin-top:6px;">
              Use UPPER_SNAKE_CASE for the name. The secret will be stored encrypted in the vault and can be referenced by skills.
            </div>
          </div>
        ` : nothing}
      </section>

      <!-- Auth Profiles -->
      ${renderAuthProfiles(props)}

      ${props.loading
        ? html`<div class="card"><div class="card__body" style="padding:24px;text-align:center;opacity:0.6;">Scanning for API keys...</div></div>`
        : html`
          ${renderKeySection("Environment Keys", envKeys, props)}
          ${renderKeySection("Skill Keys", skillKeys, props)}
          ${renderKeySection("Other Keys", otherKeys, props)}
          ${renderVaultOnlyKeys(props)}
        `}
    </div>
  `;
}

function renderKeySection(title: string, keys: DiscoveredKey[], props: ApiKeysProps) {
  if (keys.length === 0) return nothing;
  return html`
    <section class="card" style="margin-bottom:16px;">
      <div class="card__header"><div class="card__title">${title}</div></div>
      <div class="card__body" style="padding:0;">
        ${keys.map((key) => renderKeyRow(key, props))}
      </div>
    </section>
  `;
}

function renderVaultOnlyKeys(props: ApiKeysProps) {
  const allVault = props.vaultAllKeys ?? [];
  if (allVault.length === 0) return nothing;

  // Find keys in vault that aren't shown in the discovered keys list
  const discoveredIds = new Set<string>();
  for (const k of props.keys) {
    // env keys: the vault id is the env key name
    if (k.path[0] === "env") discoveredIds.add(k.path[1]);
    // other keys use their pathStr
    discoveredIds.add(k.pathStr);
    // Also add the id from the name (covers various naming patterns)
    discoveredIds.add(k.name);
  }
  // Also match by vault key name patterns
  const vaultOnly = allVault.filter((vk) => {
    // Check if any discovered key references this vault key
    const found = props.keys.some((k) => {
      if (k.path[0] === "env" && k.path[1] === vk.id) return true;
      if (k.id === vk.id || k.pathStr === vk.id) return true;
      // Check for SKILL_ prefix pattern
      if (k.inVault && k.name === vk.id) return true;
      return false;
    });
    return !found;
  });

  if (vaultOnly.length === 0) return nothing;

  return html`
    <section class="card" style="margin-bottom:16px;">
      <div class="card__header">
        <div class="card__title">Vault-Only Keys</div>
      </div>
      <div class="card__body" style="padding:0;">
        ${vaultOnly.map((vk) => html`
          <div style="padding:12px 16px;border-bottom:1px solid var(--border,#30363d);display:flex;align-items:center;gap:10px;">
            <div style="width:20px;flex-shrink:0;text-align:center;">
              <span style="cursor:help;" title="Encrypted — stored in secrets.json vault">🔒</span>
            </div>
            <div style="flex:1;min-width:0;">
              <div style="font-weight:600;color:var(--text);font-size:14px;font-family:monospace;">${vk.id}</div>
              <div style="font-size:11px;color:var(--text-muted);margin-top:2px;font-family:monospace;opacity:0.6;">${vk.masked}</div>
            </div>
            <button class="btn" style="font-size:12px;padding:4px 10px;color:var(--clr-danger,#ef4444);"
              @click=${() => { if (confirm("Delete vault key " + vk.id + "?")) props.onClear(vk.id); }}
              title="Delete from vault">✕</button>
          </div>
        `)}
      </div>
    </section>
  `;
}

function renderAuthProfiles(props: ApiKeysProps) {
  const profiles = props.authProfiles ?? [];
  if (props.authProfilesLoading && profiles.length === 0) {
    return html`<section class="card" style="margin-bottom:16px;">
      <div class="card__body" style="padding:24px;text-align:center;opacity:0.6;">Loading auth profiles...</div>
    </section>`;
  }
  if (profiles.length === 0) return nothing;

  return html`
    <section class="card" style="margin-bottom:16px;">
      <div class="card__header"><div class="card__title">Auth Profiles</div></div>
      <div class="card__body" style="padding:0;">
        ${profiles.map((p) => renderAuthProfileRow(p, props))}
      </div>
    </section>
  `;
}

function renderAuthProfileRow(p: AuthProfile, props: ApiKeysProps) {
  const typeLabel: Record<string, string> = { api_key: "API Key", token: "Token", oauth: "OAuth" };
  const lastUsedStr = p.lastUsed && p.lastUsed > 0 ? timeAgo(p.lastUsed) : "never";
  const isOAuth = p.type === "oauth";

  return html`
    <div style="padding:12px 16px;border-bottom:1px solid var(--border,#30363d);display:flex;align-items:flex-start;gap:10px;">
      <!-- Lock column -->
      <div style="width:20px;flex-shrink:0;padding-top:2px;text-align:center;">
        ${storageIcon(p.inVault, p.hasCredential)}
      </div>
      <!-- Info column -->
      <div style="flex:1;min-width:0;">
        <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
          <span style="font-weight:600;color:var(--text);font-size:14px;">${p.provider}</span>
          <span style="font-size:11px;padding:1px 6px;background:var(--bg-surface,#0d1117);border:1px solid var(--border,#30363d);border-radius:4px;color:var(--text-muted);">${typeLabel[p.type] ?? p.type}</span>
        </div>
        <div style="font-size:12px;color:var(--text-muted);margin-top:3px;">
          <span style="font-family:monospace;opacity:0.7;">${p.id}</span>
          ${p.email ? html` · ${p.email}` : nothing}
          · last used ${lastUsedStr}
          ${p.errorCount > 0 ? html` · <span style="color:var(--clr-danger,#ef4444);">${p.errorCount} error${p.errorCount === 1 ? "" : "s"}</span>` : nothing}
        </div>
      </div>
      <!-- Actions column -->
      <div style="display:flex;gap:6px;flex-shrink:0;align-items:center;min-width:80px;justify-content:flex-end;">
        ${isOAuth ? html`
          <button class="btn" style="font-size:12px;padding:4px 10px;"
            @click=${() => props.onRefreshSecret?.(p.id)}
            title="Refresh OAuth token">↻</button>
        ` : nothing}
        ${p.status === "error" || p.status === "cooldown"
          ? html`<button class="btn" style="font-size:12px;padding:4px 10px;"
              @click=${() => props.onResetProfileErrors?.(p.id)}
              title="Reset error count and cooldown">⟲ Reset</button>`
          : nothing}
        <button class="btn" style="font-size:12px;padding:4px 10px;color:var(--clr-danger,#ef4444);"
          @click=${() => { if (confirm("Remove auth profile " + p.id + "?")) props.onDeleteProfile?.(p.id); }}
          title="Delete this auth profile">✕</button>
      </div>
    </div>
  `;
}

function storageIcon(inVault: boolean, hasCredential: boolean) {
  if (inVault) {
    return html`<span style="cursor:help;" title="Encrypted — stored in secrets.json vault">🔒</span>`;
  }
  if (hasCredential) {
    return html`<span style="cursor:help;" title="Plaintext — stored as plain text in config. Use Migrate to encrypt.">⚠️</span>`;
  }
  return html`<span style="cursor:help;opacity:0.5;" title="Not configured">—</span>`;
}

function timeAgo(ts: number): string {
  const diff = Date.now() - ts;
  if (diff < 60_000) return "just now";
  if (diff < 3_600_000) return Math.floor(diff / 60_000) + "m ago";
  if (diff < 86_400_000) return Math.floor(diff / 3_600_000) + "h ago";
  return Math.floor(diff / 86_400_000) + "d ago";
}

function renderKeyRow(key: DiscoveredKey, props: ApiKeysProps) {
  const busy = props.busyKey === key.id;
  const isEditing = props.editing[key.id] ?? false;
  const editValue = props.edits[key.id] ?? "";
  const hasEdit = editValue.length > 0;

  return html`
    <div style="padding:12px 16px;border-bottom:1px solid var(--border,#30363d);">
      <div style="display:flex;align-items:flex-start;gap:10px;">
        <!-- Lock column -->
        <div style="width:20px;flex-shrink:0;padding-top:2px;text-align:center;">
          ${storageIcon(key.inVault, key.configured)}
        </div>
        <!-- Info column -->
        <div style="flex:1;min-width:0;">
          <div style="font-weight:600;color:var(--text);font-size:14px;">${key.name}</div>
          <div style="font-size:12px;color:var(--text-muted);margin-top:2px;">${key.description}</div>
          ${key.configured ? html`
            <div style="display:flex;align-items:center;gap:6px;margin-top:4px;">
              <span style="font-size:11px;color:var(--text-muted);font-family:monospace;opacity:0.6;letter-spacing:2px;">••••••••••••</span>
              <button class="btn" style="font-size:11px;padding:2px 8px;"
                @click=${() => props.onToggleEdit?.(key.id)}
                title="${isEditing ? "Cancel editing" : "Edit this key"}"
              >${isEditing ? "Cancel" : "✎"}</button>
            </div>
          ` : html`
            <div style="margin-top:4px;">
              <button class="btn" style="font-size:11px;padding:2px 8px;"
                @click=${() => props.onToggleEdit?.(key.id)}
              >${isEditing ? "Cancel" : "＋ Set key"}</button>
            </div>
          `}
        </div>
        <!-- Actions column -->
        <div style="display:flex;gap:6px;flex-shrink:0;align-items:center;min-width:80px;justify-content:flex-end;">
          ${key.docsUrl ? html`
            <a class="btn" href=${key.docsUrl} target="_blank" rel="noopener noreferrer"
              style="font-size:12px;padding:4px 10px;white-space:nowrap;">Get key ↗</a>
          ` : nothing}
        </div>
      </div>

      ${isEditing ? html`
        <div style="display:flex;gap:8px;align-items:center;margin-top:10px;margin-left:30px;">
          <input
            type="password"
            style="flex:1;min-width:0;padding:6px 10px;background:var(--bg-surface,#0d1117);border:1px solid var(--border,#30363d);border-radius:6px;color:var(--text);font-size:13px;"
            .value=${editValue}
            placeholder=${key.configured ? "Enter new key to update..." : "Enter API key..."}
            @input=${(e: Event) => props.onEdit(key.id, (e.target as HTMLInputElement).value)}
          />
          <button class="btn primary" ?disabled=${busy || !hasEdit} @click=${() => props.onSave(key.id)}
            style="font-size:12px;padding:6px 14px;">${busy ? "…" : "Save"}</button>
          ${key.configured ? html`
            <button class="btn" ?disabled=${busy} @click=${() => props.onClear(key.id)}
              style="font-size:12px;padding:6px 10px;color:var(--clr-danger,#ef4444);" title="Remove this key">✕</button>
          ` : nothing}
        </div>
      ` : nothing}
    </div>
  `;
}


path: reference/secrets-rpc.ts
content: import fs from "node:fs/promises";
import path from "node:path";
import { ErrorCodes, errorShape } from "../protocol/index.js";
import type { GatewayRequestHandlers } from "./types.js";
import { resolveUserPath } from "../../utils.js";
import { loadAuthProfileStore, saveAuthProfileStore } from "../../agents/auth-profiles.js";
import type { AuthProfileStore } from "../../agents/auth-profiles/types.js";
import { isSecretRef } from "../../config/types.secrets.js";

// ─── Constants ───────────────────────────────────────────────────────────────

const DEFAULT_SECRETS_PATH = "~/.openclaw/secrets.json";

// ─── Helpers ─────────────────────────────────────────────────────────────────

function getSecretsFilePath(): string {
  return resolveUserPath(DEFAULT_SECRETS_PATH);
}

async function readSecretsFile(): Promise<Record<string, string>> {
  const filePath = getSecretsFilePath();
  try {
    const raw = await fs.readFile(filePath, "utf-8");
    const parsed = JSON.parse(raw) as unknown;
    if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
      return {};
    }
    return parsed as Record<string, string>;
  } catch {
    return {};
  }
}

async function writeSecretsFile(secrets: Record<string, string>): Promise<void> {
  const filePath = getSecretsFilePath();
  await fs.mkdir(path.dirname(filePath), { recursive: true });
  await fs.writeFile(filePath, JSON.stringify(secrets, null, 2) + "\n", { mode: 0o600 });
}

function maskSecret(value: string): string {
  if (!value || value.length <= 8) return "••••••••";
  return `${value.slice(0, 4)}...${value.slice(-4)}`;
}

async function fileExists(filePath: string): Promise<boolean> {
  try {
    await fs.access(filePath);
    return true;
  } catch {
    return false;
  }
}

// ─── Config helpers ──────────────────────────────────────────────────────────

/**
 * Read openclaw.json, apply a mutation, and write it back.
 */
async function patchConfig(
  mutate: (config: Record<string, unknown>) => void,
): Promise<void> {
  const configPath = resolveUserPath("~/.openclaw/openclaw.json");
  const raw = await fs.readFile(configPath, "utf-8");
  const config = JSON.parse(raw) as Record<string, unknown>;
  mutate(config);
  await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n");
}

function ensureSecretsProvider(config: Record<string, unknown>): void {
  if (!config.secrets || typeof config.secrets !== "object") {
    config.secrets = {};
  }
  const secrets = config.secrets as Record<string, unknown>;
  if (!secrets.providers || typeof secrets.providers !== "object") {
    secrets.providers = {};
  }
  const providers = secrets.providers as Record<string, unknown>;
  if (!providers.default) {
    providers.default = {
      source: "file",
      path: DEFAULT_SECRETS_PATH,
      mode: "json",
    };
  }
  if (!secrets.defaults || typeof secrets.defaults !== "object") {
    secrets.defaults = {};
  }
  const defaults = secrets.defaults as Record<string, unknown>;
  if (!defaults.file) {
    defaults.file = "default";
  }
}

function makeSecretRef(secretId: string): { source: string; provider: string; id: string } {
  return { source: "file", provider: "default", id: `/${secretId}` };
}

// ─── Key pattern detection ───────────────────────────────────────────────────

const KEY_PATTERNS = [
  /apiKey$/i, /api_key$/i, /^token$/i, /secret$/i,
  /_KEY$/, /_TOKEN$/, /_SECRET$/,
];

const SKIP_PATHS = [
  "meta", "wizard", "update", "browser", "auth.profiles",
  "gateway.auth.token", "channels.telegram.botToken", "channels.discord.token",
];

function isKeyField(name: string): boolean {
  return KEY_PATTERNS.some(p => p.test(name));
}

function shouldSkipPath(pathStr: string): boolean {
  return SKIP_PATHS.some(s => pathStr.startsWith(s));
}

interface PlaintextKey {
  path: string[];
  pathStr: string;
  value: string;
}

function scanPlaintextKeys(
  obj: Record<string, unknown>,
  currentPath: string[] = [],
  results: PlaintextKey[] = [],
): PlaintextKey[] {
  if (!obj || typeof obj !== "object") return results;
  for (const [key, value] of Object.entries(obj)) {
    const p = [...currentPath, key];
    const pathStr = p.join(".");
    if (shouldSkipPath(pathStr)) continue;
    if (isKeyField(key) && typeof value === "string" && value.length > 0) {
      results.push({ path: p, pathStr, value });
    }
    if (value && typeof value === "object" && !Array.isArray(value)) {
      scanPlaintextKeys(value as Record<string, unknown>, p, results);
    }
  }
  return results;
}

function isSecretRef(value: unknown): boolean {
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
  const v = value as Record<string, unknown>;
  return (v.source === "env" || v.source === "file" || v.source === "exec") &&
    typeof v.provider === "string" && typeof v.id === "string";
}

// ─── RPC Handlers ────────────────────────────────────────────────────────────

export function createSecretsHandlers(params: {
  reloadSecrets: () => Promise<{ warningCount: number }>;
}): GatewayRequestHandlers {
  return {
    "secrets.reload": async ({ respond }) => {
      try {
        const result = await params.reloadSecrets();
        respond(true, { ok: true, warningCount: result.warningCount });
      } catch (err) {
        respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
      }
    },

    "secrets.status": async ({ respond }) => {
      try {
        const filePath = getSecretsFilePath();
        const exists = await fileExists(filePath);
        const secrets = exists ? await readSecretsFile() : {};
        const keyCount = Object.keys(secrets).length;

        // Check if config has secrets provider configured
        const configPath = resolveUserPath("~/.openclaw/openclaw.json");
        let providerConfigured = false;
        try {
          const raw = await fs.readFile(configPath, "utf-8");
          const config = JSON.parse(raw) as Record<string, unknown>;
          const sec = config.secrets as Record<string, unknown> | undefined;
          const providers = sec?.providers as Record<string, unknown> | undefined;
          providerConfigured = !!providers?.default;
        } catch { /* */ }

        // Count plaintext keys in config
        let plaintextCount = 0;
        try {
          const raw = await fs.readFile(configPath, "utf-8");
          const config = JSON.parse(raw) as Record<string, unknown>;
          plaintextCount = scanPlaintextKeys(config).length;
        } catch { /* */ }

        respond(true, {
          ok: true,
          vaultExists: exists,
          vaultPath: filePath,
          keyCount,
          providerConfigured,
          plaintextCount,
        });
      } catch (err) {
        respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
      }
    },

    "secrets.list": async ({ respond }) => {
      try {
        const secrets = await readSecretsFile();
        const keys = Object.entries(secrets).map(([id, value]) => ({
          id,
          masked: maskSecret(typeof value === "string" ? value : ""),
        }));
        respond(true, { ok: true, keys });
      } catch (err) {
        respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
      }
    },

    "secrets.write": async ({ params: p, respond }) => {
      try {
        const id = (p as { id?: string })?.id?.trim();
        const value = (p as { value?: string })?.value?.trim();
        const envEntry = (p as { envEntry?: boolean })?.envEntry !== false; // default true
        if (!id || !value) {
          respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "id and value required"));
          return;
        }

        // 1. Write to secrets file
        const secrets = await readSecretsFile();
        secrets[id] = value;
        await writeSecretsFile(secrets);

        // 2. Update config: ensure provider + optionally add env entry
        let configChanged = false;
        await patchConfig((config) => {
          ensureSecretsProvider(config);

          if (envEntry) {
            const env = config.env as Record<string, unknown> | undefined;
            if (env && id in env) {
              env[id] = makeSecretRef(id);
            } else if (env) {
              env[id] = makeSecretRef(id);
            } else {
              config.env = { [id]: makeSecretRef(id) };
            }
            configChanged = true;
          }
        });

        // Don't auto-reload — let the UI prompt the user to restart
        // so we don't crash mid-session
        respond(true, { ok: true, id, masked: maskSecret(value), restartNeeded: true, configChanged });
      } catch (err) {
        respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
      }
    },

    "secrets.delete": async ({ params: p, respond }) => {
      try {
        const id = (p as { id?: string })?.id?.trim();
        if (!id) {
          respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "id required"));
          return;
        }

        // 1. Remove from secrets file
        const secrets = await readSecretsFile();
        delete secrets[id];
        await writeSecretsFile(secrets);

        // 2. Remove/null the config ref
        await patchConfig((config) => {
          const env = config.env as Record<string, unknown> | undefined;
          if (env && id in env) {
            delete env[id];
          }
        });

        // 3. Reload
        try {
          await params.reloadSecrets();
        } catch { /* non-fatal */ }

        respond(true, { ok: true, id });
      } catch (err) {
        respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
      }
    },

    "secrets.migrate": async ({ respond }) => {
      try {
        const configPath = resolveUserPath("~/.openclaw/openclaw.json");
        const raw = await fs.readFile(configPath, "utf-8");
        const config = JSON.parse(raw) as Record<string, unknown>;

        // Find all plaintext keys in env block
        const env = config.env as Record<string, unknown> | undefined;
        if (!env) {
          respond(true, { ok: true, migrated: 0, keys: [] });
          return;
        }

        const migratedKeys: string[] = [];
        const secrets = await readSecretsFile();

        for (const [key, value] of Object.entries(env)) {
          // Skip if already a SecretRef
          if (isSecretRef(value)) continue;
          // Skip if not a string
          if (typeof value !== "string" || !value.trim()) continue;
          // Only migrate things that look like API keys
          if (!isKeyField(key)) continue;

          // Move to secrets file
          secrets[key] = value;
          // Replace with ref in config
          env[key] = makeSecretRef(key);
          migratedKeys.push(key);
        }

        if (migratedKeys.length === 0) {
          respond(true, { ok: true, migrated: 0, keys: [] });
          return;
        }

        // Write secrets file
        await writeSecretsFile(secrets);

        // Ensure provider in config and write back
        ensureSecretsProvider(config);
        await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n");

        // Reload
        try {
          await params.reloadSecrets();
        } catch { /* non-fatal */ }

        respond(true, { ok: true, migrated: migratedKeys.length, keys: migratedKeys });
      } catch (err) {
        respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
      }
    },

    "secrets.authProfiles.list": async ({ respond }) => {
      try {
        const agentDir = resolveUserPath("~/.openclaw/agents/main/agent");
        const store = loadAuthProfileStore(agentDir) as AuthProfileStore;
        const profiles = Object.entries(store.profiles ?? {}).map(([id, cred]) => {
          const stats = store.usageStats?.[id];
          const lastGoodProvider = Object.entries(store.lastGood ?? {}).find(
            ([, v]) => v === id,
          )?.[0];

          let status: "active" | "error" | "cooldown" | "unused" = "unused";
          if (stats?.cooldownUntil && stats.cooldownUntil > Date.now()) {
            status = "cooldown";
          } else if (stats?.errorCount && stats.errorCount > 0) {
            status = "error";
          } else if (stats?.lastUsed && stats.lastUsed > 0) {
            status = "active";
          }

          const inVault =
            ("keyRef" in cred && isSecretRef(cred.keyRef)) ||
            ("tokenRef" in cred && isSecretRef((cred as any).tokenRef));

          return {
            id,
            type: cred.type,
            provider: cred.provider,
            email: (cred as any).email ?? null,
            inVault,
            hasCredential:
              inVault ||
              !!("key" in cred && (cred as any).key) ||
              !!("token" in cred && (cred as any).token),
            status,
            isLastGood: !!lastGoodProvider,
            lastUsed: stats?.lastUsed ?? null,
            errorCount: stats?.errorCount ?? 0,
            lastFailureAt: stats?.lastFailureAt ?? null,
            cooldownUntil: stats?.cooldownUntil ?? null,
          };
        });

        respond(true, { ok: true, profiles });
      } catch (err) {
        respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
      }
    },

    "secrets.authProfiles.resetErrors": async ({ params: p, respond }) => {
      try {
        const profileId = (p as { profileId?: string })?.profileId?.trim();
        if (!profileId) {
          respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "profileId required"));
          return;
        }
        const agentDir = resolveUserPath("~/.openclaw/agents/main/agent");
        const store = loadAuthProfileStore(agentDir) as AuthProfileStore;
        if (store.usageStats?.[profileId]) {
          store.usageStats[profileId].errorCount = 0;
          store.usageStats[profileId].lastFailureAt = undefined;
          store.usageStats[profileId].cooldownUntil = undefined;
          store.usageStats[profileId].disabledUntil = undefined;
          store.usageStats[profileId].disabledReason = undefined;
          saveAuthProfileStore(agentDir, store);
        }
        respond(true, { ok: true, profileId });
      } catch (err) {
        respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
      }
    },

    "secrets.authProfiles.delete": async ({ params: p, respond }) => {
      try {
        const profileId = (p as { profileId?: string })?.profileId?.trim();
        if (!profileId) {
          respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "profileId required"));
          return;
        }
        const agentDir = resolveUserPath("~/.openclaw/agents/main/agent");
        const store = loadAuthProfileStore(agentDir) as AuthProfileStore;
        delete store.profiles[profileId];
        if (store.usageStats) delete store.usageStats[profileId];
        // Clean from order arrays
        if (store.order) {
          for (const [provider, ids] of Object.entries(store.order)) {
            store.order[provider] = ids.filter((id: string) => id !== profileId);
          }
        }
        // Clean from lastGood
        if (store.lastGood) {
          for (const [provider, id] of Object.entries(store.lastGood)) {
            if (id === profileId) delete store.lastGood[provider];
          }
        }
        saveAuthProfileStore(agentDir, store);
        respond(true, { ok: true, profileId });
      } catch (err) {
        respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
      }
    },
  };
}


path: reference/skills-controller.ts
content: import type { GatewayBrowserClient } from "../gateway.ts";
import type { SkillStatusReport } from "../types.ts";

export type VaultKeyEntry = {
  id: string;
  masked: string;
};

export type SkillsState = {
  client: GatewayBrowserClient | null;
  connected: boolean;
  skillsLoading: boolean;
  skillsReport: SkillStatusReport | null;
  skillsError: string | null;
  skillsBusyKey: string | null;
  skillEdits: Record<string, string>;
  skillMessages: SkillMessageMap;
  vaultKeys: VaultKeyEntry[];
  vaultKeysLoading: boolean;
};

export type SkillMessage = {
  kind: "success" | "error";
  message: string;
};

export type SkillMessageMap = Record<string, SkillMessage>;

type LoadSkillsOptions = {
  clearMessages?: boolean;
};

function setSkillMessage(state: SkillsState, key: string, message?: SkillMessage) {
  if (!key.trim()) {
    return;
  }
  const next = { ...state.skillMessages };
  if (message) {
    next[key] = message;
  } else {
    delete next[key];
  }
  state.skillMessages = next;
}

function getErrorMessage(err: unknown) {
  if (err instanceof Error) {
    return err.message;
  }
  return String(err);
}

export async function loadSkills(state: SkillsState, options?: LoadSkillsOptions) {
  if (options?.clearMessages && Object.keys(state.skillMessages).length > 0) {
    state.skillMessages = {};
  }
  if (!state.client || !state.connected) {
    return;
  }
  if (state.skillsLoading) {
    return;
  }
  state.skillsLoading = true;
  state.skillsError = null;
  try {
    const res = await state.client.request<SkillStatusReport | undefined>("skills.status", {});
    if (res) {
      state.skillsReport = res;
    }
  } catch (err) {
    state.skillsError = getErrorMessage(err);
  } finally {
    state.skillsLoading = false;
  }
}

export function updateSkillEdit(state: SkillsState, skillKey: string, value: string) {
  state.skillEdits = { ...state.skillEdits, [skillKey]: value };
}

export async function updateSkillEnabled(state: SkillsState, skillKey: string, enabled: boolean) {
  if (!state.client || !state.connected) {
    return;
  }
  state.skillsBusyKey = skillKey;
  state.skillsError = null;
  try {
    await state.client.request("skills.update", { skillKey, enabled });
    await loadSkills(state);
    setSkillMessage(state, skillKey, {
      kind: "success",
      message: enabled ? "Skill enabled" : "Skill disabled",
    });
  } catch (err) {
    const message = getErrorMessage(err);
    state.skillsError = message;
    setSkillMessage(state, skillKey, {
      kind: "error",
      message,
    });
  } finally {
    state.skillsBusyKey = null;
  }
}

export async function saveSkillApiKey(state: SkillsState, skillKey: string) {
  if (!state.client || !state.connected) {
    return;
  }
  state.skillsBusyKey = skillKey;
  state.skillsError = null;
  try {
    const [REDACTED_SECRET] ?? "";
    await state.client.request("skills.update", { skillKey, apiKey });
    await loadSkills(state);
    setSkillMessage(state, skillKey, {
      kind: "success",
      message: "API key saved",
    });
  } catch (err) {
    const message = getErrorMessage(err);
    state.skillsError = message;
    setSkillMessage(state, skillKey, {
      kind: "error",
      message,
    });
  } finally {
    state.skillsBusyKey = null;
  }
}

export async function installSkill(
  state: SkillsState,
  skillKey: string,
  name: string,
  installId: string,
) {
  if (!state.client || !state.connected) {
    return;
  }
  state.skillsBusyKey = skillKey;
  state.skillsError = null;
  try {
    const result = await state.client.request<{ message?: string }>("skills.install", {
      name,
      installId,
      timeoutMs: 120000,
    });
    await loadSkills(state);
    setSkillMessage(state, skillKey, {
      kind: "success",
      message: result?.message ?? "Installed",
    });
  } catch (err) {
    const message = getErrorMessage(err);
    state.skillsError = message;
    setSkillMessage(state, skillKey, {
      kind: "error",
      message,
    });
  } finally {
    state.skillsBusyKey = null;
  }
}

export async function loadVaultKeys(state: SkillsState) {
  if (!state.client || !state.connected) return;
  state.vaultKeysLoading = true;
  try {
    const res = await state.client.request<{ keys: VaultKeyEntry[] }>("secrets.list", {});
    state.vaultKeys = res?.keys ?? [];
  } catch {
    state.vaultKeys = [];
  } finally {
    state.vaultKeysLoading = false;
  }
}

export async function linkSkillToVaultKey(state: SkillsState, skillKey: string, vaultKeyId: string) {
  if (!state.client || !state.connected) return;
  state.skillsBusyKey = skillKey;
  try {
    await state.client.request("skills.update", { skillKey, vaultKeyId });
    await loadSkills(state);
    setSkillMessage(state, skillKey, {
      kind: "success",
      message: vaultKeyId ? "Linked to vault key: " + vaultKeyId : "Unlinked from vault key",
    });
  } catch (err) {
    setSkillMessage(state, skillKey, { kind: "error", message: getErrorMessage(err) });
  } finally {
    state.skillsBusyKey = null;
  }
}

export async function addVaultKeyAndLink(
  state: SkillsState,
  skillKey: string,
  keyName: string,
  keyValue: string,
) {
  if (!state.client || !state.connected) return;
  state.skillsBusyKey = skillKey;
  try {
    await state.client.request("secrets.write", {
      id: keyName.trim(),
      value: keyValue.trim(),
      envEntry: false,
    });
    await state.client.request("skills.update", { skillKey, vaultKeyId: keyName.trim() });
    await loadSkills(state);
    await loadVaultKeys(state);
    setSkillMessage(state, skillKey, {
      kind: "success",
      message: "Created and linked vault key: " + keyName,
    });
  } catch (err) {
    setSkillMessage(state, skillKey, { kind: "error", message: getErrorMessage(err) });
  } finally {
    state.skillsBusyKey = null;
  }
}


path: reference/skills-rpc.ts
content: import {
  listAgentIds,
  resolveAgentWorkspaceDir,
  resolveDefaultAgentId,
} from "../../agents/agent-scope.js";
import { installSkill } from "../../agents/skills-install.js";
import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js";
import { loadWorkspaceSkillEntries, type SkillEntry } from "../../agents/skills.js";
import { listAgentWorkspaceDirs } from "../../agents/workspace-dirs.js";
import type { OpenClawConfig } from "../../config/config.js";
import { loadConfig, writeConfigFile } from "../../config/config.js";
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
import {
  ErrorCodes,
  errorShape,
  formatValidationErrors,
  validateSkillsBinsParams,
  validateSkillsInstallParams,
  validateSkillsStatusParams,
  validateSkillsUpdateParams,
} from "../protocol/index.js";
import type { GatewayRequestHandlers } from "./types.js";

function collectSkillBins(entries: SkillEntry[]): string[] {
  const bins = new Set<string>();
  for (const entry of entries) {
    const required = entry.metadata?.requires?.bins ?? [];
    const anyBins = entry.metadata?.requires?.anyBins ?? [];
    const install = entry.metadata?.install ?? [];
    for (const bin of required) {
      const trimmed = bin.trim();
      if (trimmed) {
        bins.add(trimmed);
      }
    }
    for (const bin of anyBins) {
      const trimmed = bin.trim();
      if (trimmed) {
        bins.add(trimmed);
      }
    }
    for (const spec of install) {
      const specBins = spec?.bins ?? [];
      for (const bin of specBins) {
        const trimmed = String(bin).trim();
        if (trimmed) {
          bins.add(trimmed);
        }
      }
    }
  }
  return [...bins].toSorted();
}

export const skillsHandlers: GatewayRequestHandlers = {
  "skills.status": ({ params, respond }) => {
    if (!validateSkillsStatusParams(params)) {
      respond(
        false,
        undefined,
        errorShape(
          ErrorCodes.INVALID_REQUEST,
          `invalid skills.status params: ${formatValidationErrors(validateSkillsStatusParams.errors)}`,
        ),
      );
      return;
    }
    const cfg = loadConfig();
    const agentIdRaw = typeof params?.agentId === "string" ? params.agentId.trim() : "";
    const agentId = agentIdRaw ? normalizeAgentId(agentIdRaw) : resolveDefaultAgentId(cfg);
    if (agentIdRaw) {
      const knownAgents = listAgentIds(cfg);
      if (!knownAgents.includes(agentId)) {
        respond(
          false,
          undefined,
          errorShape(ErrorCodes.INVALID_REQUEST, `unknown agent id "${agentIdRaw}"`),
        );
        return;
      }
    }
    const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
    const report = buildWorkspaceSkillStatus(workspaceDir, {
      config: cfg,
      eligibility: { remote: getRemoteSkillEligibility() },
    });
    respond(true, report, undefined);
  },
  "skills.bins": ({ params, respond }) => {
    if (!validateSkillsBinsParams(params)) {
      respond(
        false,
        undefined,
        errorShape(
          ErrorCodes.INVALID_REQUEST,
          `invalid skills.bins params: ${formatValidationErrors(validateSkillsBinsParams.errors)}`,
        ),
      );
      return;
    }
    const cfg = loadConfig();
    const workspaceDirs = listAgentWorkspaceDirs(cfg);
    const bins = new Set<string>();
    for (const workspaceDir of workspaceDirs) {
      const entries = loadWorkspaceSkillEntries(workspaceDir, { config: cfg });
      for (const bin of collectSkillBins(entries)) {
        bins.add(bin);
      }
    }
    respond(true, { bins: [...bins].toSorted() }, undefined);
  },
  "skills.install": async ({ params, respond }) => {
    if (!validateSkillsInstallParams(params)) {
      respond(
        false,
        undefined,
        errorShape(
          ErrorCodes.INVALID_REQUEST,
          `invalid skills.install params: ${formatValidationErrors(validateSkillsInstallParams.errors)}`,
        ),
      );
      return;
    }
    const p = params as {
      name: string;
      installId: string;
      timeoutMs?: number;
    };
    const cfg = loadConfig();
    const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
    const result = await installSkill({
      workspaceDir: workspaceDirRaw,
      skillName: p.name,
      installId: p.installId,
      timeoutMs: p.timeoutMs,
      config: cfg,
    });
    respond(
      result.ok,
      result,
      result.ok ? undefined : errorShape(ErrorCodes.UNAVAILABLE, result.message),
    );
  },
  "skills.update": async ({ params, respond }) => {
    if (!validateSkillsUpdateParams(params)) {
      respond(
        false,
        undefined,
        errorShape(
          ErrorCodes.INVALID_REQUEST,
          `invalid skills.update params: ${formatValidationErrors(validateSkillsUpdateParams.errors)}`,
        ),
      );
      return;
    }
    const p = params as {
      skillKey: string;
      enabled?: boolean;
      apiKey?: string;
      vaultKeyId?: string;
      env?: Record<string, string>;
    };
    const cfg = loadConfig();
    const skills = cfg.skills ? { ...cfg.skills } : {};
    const entries = skills.entries ? { ...skills.entries } : {};
    const current = entries[p.skillKey] ? { ...entries[p.skillKey] } : {};
    if (typeof p.enabled === "boolean") {
      current.enabled = p.enabled;
    }
    if (typeof p.vaultKeyId === "string" && p.vaultKeyId.trim()) {
      // Link to a vault key via SecretRef
      const keyId = p.vaultKeyId.trim();
      current.apiKey = { source: "file", provider: "default", id: `/${keyId}` } as unknown as string;
    } else if (p.vaultKeyId === "") {
      // Unlink — clear the apiKey
      delete current.apiKey;
    } else if (typeof p.apiKey === "string") {
      const trimmed = normalizeSecretInput(p.apiKey);
      if (trimmed) {
        current.[REDACTED_SECRET];
      } else {
        delete current.apiKey;
      }
    }
    if (p.env && typeof p.env === "object") {
      const nextEnv = current.env ? { ...current.env } : {};
      for (const [key, value] of Object.entries(p.env)) {
        const trimmedKey = key.trim();
        if (!trimmedKey) {
          continue;
        }
        const trimmedVal = value.trim();
        if (!trimmedVal) {
          delete nextEnv[trimmedKey];
        } else {
          nextEnv[trimmedKey] = trimmedVal;
        }
      }
      current.env = nextEnv;
    }
    entries[p.skillKey] = current;
    skills.entries = entries;
    const nextConfig: OpenClawConfig = {
      ...cfg,
      skills,
    };
    await writeConfigFile(nextConfig);
    respond(true, { ok: true, skillKey: p.skillKey, config: current }, undefined);
  },
};


path: reference/skills-status.ts
content: import fs from "node:fs";
import path from "node:path";

function extractVaultKeyId([REDACTED_SECRET]): string | undefined {
  if (apiKey && typeof apiKey === "object" && "source" in apiKey && "id" in apiKey) {
    const ref = apiKey as { source: string; id: string };
    if (ref.source === "file" && ref.id) {
      return ref.id.startsWith("/") ? ref.id.slice(1) : ref.id;
    }
  }
  return undefined;
}

let _rawConfigCache: Record<string, unknown> | null = null;
let _rawConfigMtime = 0;

function extractVaultKeyIdFromConfig(skillKey: string): string | undefined {
  // Read the raw JSON to get the unresolved SecretRef (runtime resolves them to strings)
  const configPath = path.join(CONFIG_DIR, "openclaw.json");
  try {
    const stat = fs.statSync(configPath);
    if (!_rawConfigCache || stat.mtimeMs !== _rawConfigMtime) {
      _rawConfigCache = JSON.parse(fs.readFileSync(configPath, "utf8")) as Record<string, unknown>;
      _rawConfigMtime = stat.mtimeMs;
    }
    const skills = (_rawConfigCache?.skills as Record<string, unknown>)?.entries as Record<string, Record<string, unknown>> | undefined;
    const entry = skills?.[skillKey];
    return extractVaultKeyId(entry?.apiKey);
  } catch {
    return undefined;
  }
}
import type { OpenClawConfig } from "../config/config.js";
import { evaluateEntryRequirementsForCurrentPlatform } from "../shared/entry-status.js";
import type { RequirementConfigCheck, Requirements } from "../shared/requirements.js";
import { CONFIG_DIR } from "../utils.js";
import {
  hasBinary,
  isBundledSkillAllowed,
  isConfigPathTruthy,
  loadWorkspaceSkillEntries,
  resolveBundledAllowlist,
  resolveSkillConfig,
  resolveSkillsInstallPreferences,
  type SkillEntry,
  type SkillEligibilityContext,
  type SkillInstallSpec,
  type SkillsInstallPreferences,
} from "./skills.js";
import { resolveBundledSkillsContext } from "./skills/bundled-context.js";

export type SkillStatusConfigCheck = RequirementConfigCheck;

export type SkillInstallOption = {
  id: string;
  kind: SkillInstallSpec["kind"];
  label: string;
  bins: string[];
};

export type SkillStatusEntry = {
  name: string;
  description: string;
  source: string;
  bundled: boolean;
  filePath: string;
  baseDir: string;
  skillKey: string;
  primaryEnv?: string;
  vaultKeyId?: string;
  emoji?: string;
  homepage?: string;
  always: boolean;
  disabled: boolean;
  blockedByAllowlist: boolean;
  eligible: boolean;
  requirements: Requirements;
  missing: Requirements;
  configChecks: SkillStatusConfigCheck[];
  install: SkillInstallOption[];
};

export type SkillStatusReport = {
  workspaceDir: string;
  managedSkillsDir: string;
  skills: SkillStatusEntry[];
};

function resolveSkillKey(entry: SkillEntry): string {
  return entry.metadata?.skillKey ?? entry.skill.name;
}

function selectPreferredInstallSpec(
  install: SkillInstallSpec[],
  prefs: SkillsInstallPreferences,
): { spec: SkillInstallSpec; index: number } | undefined {
  if (install.length === 0) {
    return undefined;
  }

  const indexed = install.map((spec, index) => ({ spec, index }));
  const findKind = (kind: SkillInstallSpec["kind"]) =>
    indexed.find((item) => item.spec.kind === kind);

  const brewSpec = findKind("brew");
  const nodeSpec = findKind("node");
  const goSpec = findKind("go");
  const uvSpec = findKind("uv");
  const downloadSpec = findKind("download");
  const brewAvailable = hasBinary("brew");

  // Table-driven preference chain; first match wins.
  const pickers: Array<() => { spec: SkillInstallSpec; index: number } | undefined> = [
    () => (prefs.preferBrew && brewAvailable ? brewSpec : undefined),
    () => uvSpec,
    () => nodeSpec,
    // Only prefer brew when available to avoid guaranteed failure on Linux/Docker.
    () => (brewAvailable ? brewSpec : undefined),
    () => goSpec,
    // Prefer download over an unavailable brew spec.
    () => downloadSpec,
    // Last resort: surface descriptive brew-missing error instead of "no installer found".
    () => brewSpec,
    () => indexed[0],
  ];

  for (const pick of pickers) {
    const selected = pick();
    if (selected) {
      return selected;
    }
  }

  return undefined;
}

function normalizeInstallOptions(
  entry: SkillEntry,
  prefs: SkillsInstallPreferences,
): SkillInstallOption[] {
  // If the skill is explicitly OS-scoped, don't surface install actions on unsupported platforms.
  // (Installers run locally; remote OS eligibility is handled separately.)
  const requiredOs = entry.metadata?.os ?? [];
  if (requiredOs.length > 0 && !requiredOs.includes(process.platform)) {
    return [];
  }

  const install = entry.metadata?.install ?? [];
  if (install.length === 0) {
    return [];
  }

  const platform = process.platform;
  const filtered = install.filter((spec) => {
    const osList = spec.os ?? [];
    return osList.length === 0 || osList.includes(platform);
  });
  if (filtered.length === 0) {
    return [];
  }

  const toOption = (spec: SkillInstallSpec, index: number): SkillInstallOption => {
    const id = (spec.id ?? `${spec.kind}-${index}`).trim();
    const bins = spec.bins ?? [];
    let label = (spec.label ?? "").trim();
    if (spec.kind === "node" && spec.package) {
      label = `Install ${spec.package} (${prefs.nodeManager})`;
    }
    if (!label) {
      if (spec.kind === "brew" && spec.formula) {
        label = `Install ${spec.formula} (brew)`;
      } else if (spec.kind === "node" && spec.package) {
        label = `Install ${spec.package} (${prefs.nodeManager})`;
      } else if (spec.kind === "go" && spec.module) {
        label = `Install ${spec.module} (go)`;
      } else if (spec.kind === "uv" && spec.package) {
        label = `Install ${spec.package} (uv)`;
      } else if (spec.kind === "download" && spec.url) {
        const url = spec.url.trim();
        const last = url.split("/").pop();
        label = `Download ${last && last.length > 0 ? last : url}`;
      } else {
        label = "Run installer";
      }
    }
    return { id, kind: spec.kind, label, bins };
  };

  const allDownloads = filtered.every((spec) => spec.kind === "download");
  if (allDownloads) {
    return filtered.map((spec, index) => toOption(spec, index));
  }

  const preferred = selectPreferredInstallSpec(filtered, prefs);
  if (!preferred) {
    return [];
  }
  return [toOption(preferred.spec, preferred.index)];
}

function buildSkillStatus(
  entry: SkillEntry,
  config?: OpenClawConfig,
  prefs?: SkillsInstallPreferences,
  eligibility?: SkillEligibilityContext,
  bundledNames?: Set<string>,
): SkillStatusEntry {
  const skillKey = resolveSkillKey(entry);
  const skillConfig = resolveSkillConfig(config, skillKey);
  const disabled = skillConfig?.enabled === false;
  const allowBundled = resolveBundledAllowlist(config);
  const blockedByAllowlist = !isBundledSkillAllowed(entry, allowBundled);
  const always = entry.metadata?.always === true;
  const isEnvSatisfied = (envName: string) =>
    Boolean(
      process.env[envName] ||
      skillConfig?.env?.[envName] ||
      (skillConfig?.apiKey && entry.metadata?.primaryEnv === envName),
    );
  const isConfigSatisfied = (pathStr: string) => isConfigPathTruthy(config, pathStr);
  const bundled =
    bundledNames && bundledNames.size > 0
      ? bundledNames.has(entry.skill.name)
      : entry.skill.source === "openclaw-bundled";

  const { emoji, homepage, required, missing, requirementsSatisfied, configChecks } =
    evaluateEntryRequirementsForCurrentPlatform({
      always,
      entry,
      hasLocalBin: hasBinary,
      remote: eligibility?.remote,
      isEnvSatisfied,
      isConfigSatisfied,
    });
  const eligible = !disabled && !blockedByAllowlist && requirementsSatisfied;

  return {
    name: entry.skill.name,
    description: entry.skill.description,
    source: entry.skill.source,
    bundled,
    filePath: entry.skill.filePath,
    baseDir: entry.skill.baseDir,
    skillKey,
    primaryEnv: entry.metadata?.primaryEnv,
    vaultKeyId: extractVaultKeyIdFromConfig(skillKey),
    emoji,
    homepage,
    always,
    disabled,
    blockedByAllowlist,
    eligible,
    requirements: required,
    missing,
    configChecks,
    install: normalizeInstallOptions(entry, prefs ?? resolveSkillsInstallPreferences(config)),
  };
}

export function buildWorkspaceSkillStatus(
  workspaceDir: string,
  opts?: {
    config?: OpenClawConfig;
    managedSkillsDir?: string;
    entries?: SkillEntry[];
    eligibility?: SkillEligibilityContext;
  },
): SkillStatusReport {
  const managedSkillsDir = opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills");
  const bundledContext = resolveBundledSkillsContext();
  const skillEntries =
    opts?.entries ??
    loadWorkspaceSkillEntries(workspaceDir, {
      config: opts?.config,
      managedSkillsDir,
      bundledSkillsDir: bundledContext.dir,
    });
  const prefs = resolveSkillsInstallPreferences(opts?.config);
  return {
    workspaceDir,
    managedSkillsDir,
    skills: skillEntries.map((entry) =>
      buildSkillStatus(entry, opts?.config, prefs, opts?.eligibility, bundledContext.names),
    ),
  };
}


path: reference/skills-views.ts
content: import { html, nothing } from "lit";
import type { SkillMessageMap, VaultKeyEntry } from "../controllers/skills.ts";
import { clampText } from "../format.ts";
import type { SkillStatusEntry, SkillStatusReport } from "../types.ts";
import { groupSkills } from "./skills-grouping.ts";
import {
  computeSkillMissing,
  computeSkillReasons,
  renderSkillStatusChips,
} from "./skills-shared.ts";

export type SkillsProps = {
  loading: boolean;
  report: SkillStatusReport | null;
  error: string | null;
  filter: string;
  edits: Record<string, string>;
  busyKey: string | null;
  messages: SkillMessageMap;
  vaultKeys: VaultKeyEntry[];
  skillAddingKey: Record<string, boolean>;
  skillNewKeyName: Record<string, string>;
  skillNewKeyValue: Record<string, string>;
  onFilterChange: (next: string) => void;
  onRefresh: () => void;
  onToggle: (skillKey: string, enabled: boolean) => void;
  onEdit: (skillKey: string, value: string) => void;
  onSaveKey: (skillKey: string) => void;
  onInstall: (skillKey: string, name: string, installId: string) => void;
  onLinkVaultKey: (skillKey: string, vaultKeyId: string) => void;
  onToggleAddKey: (skillKey: string) => void;
  onNewKeyNameChange: (skillKey: string, name: string) => void;
  onNewKeyValueChange: (skillKey: string, value: string) => void;
  onAddKeyAndLink: (skillKey: string) => void;
};

export function renderSkills(props: SkillsProps) {
  const skills = props.report?.skills ?? [];
  const filter = props.filter.trim().toLowerCase();
  const filtered = filter
    ? skills.filter((skill) =>
        [skill.name, skill.description, skill.source].join(" ").toLowerCase().includes(filter),
      )
    : skills;
  const groups = groupSkills(filtered);

  return html`
    <section class="card">
      <div class="row" style="justify-content: space-between;">
        <div>
          <div class="card-title">Skills</div>
          <div class="card-sub">Bundled, managed, and workspace skills.</div>
        </div>
        <button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
          ${props.loading ? "Loading…" : "Refresh"}
        </button>
      </div>

      <div class="filters" style="margin-top: 14px;">
        <label class="field" style="flex: 1;">
          <span>Filter</span>
          <input
            .value=${props.filter}
            @input=${(e: Event) => props.onFilterChange((e.target as HTMLInputElement).value)}
            placeholder="Search skills"
          />
        </label>
        <div class="muted">${filtered.length} shown</div>
      </div>

      ${
        props.error
          ? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
          : nothing
      }

      ${
        filtered.length === 0
          ? html`
              <div class="muted" style="margin-top: 16px">No skills found.</div>
            `
          : html`
            <div class="agent-skills-groups" style="margin-top: 16px;">
              ${groups.map((group) => {
                const collapsedByDefault = false;
                return html`
                  <details class="agent-skills-group" ?open=${!collapsedByDefault}>
                    <summary class="agent-skills-header">
                      <span>${group.label}</span>
                      <span class="muted">${group.skills.length}</span>
                    </summary>
                    <div class="list skills-grid">
                      ${group.skills.map((skill) => renderSkill(skill, props))}
                    </div>
                  </details>
                `;
              })}
            </div>
          `
      }
    </section>
  `;
}

function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
  const busy = props.busyKey === skill.skillKey;
  const [REDACTED_SECRET] ?? "";
  const message = props.messages[skill.skillKey] ?? null;
  const canInstall = skill.install.length > 0 && skill.missing.bins.length > 0;
  const showBundledBadge = Boolean(skill.bundled && skill.source !== "openclaw-bundled");
  const missing = computeSkillMissing(skill);
  const reasons = computeSkillReasons(skill);
  return html`
    <div class="list-item">
      <div class="list-main">
        <div class="list-title">
          ${skill.emoji ? `${skill.emoji} ` : ""}${skill.name}
        </div>
        <div class="list-sub">${clampText(skill.description, 140)}</div>
        ${renderSkillStatusChips({ skill, showBundledBadge })}
        ${
          missing.length > 0
            ? html`
              <div class="muted" style="margin-top: 6px;">
                Missing: ${missing.join(", ")}
              </div>
            `
            : nothing
        }
        ${
          reasons.length > 0
            ? html`
              <div class="muted" style="margin-top: 6px;">
                Reason: ${reasons.join(", ")}
              </div>
            `
            : nothing
        }
      </div>
      <div class="list-meta">
        <div class="row" style="justify-content: flex-end; flex-wrap: wrap;">
          <button
            class="btn"
            ?disabled=${busy}
            @click=${() => props.onToggle(skill.skillKey, skill.disabled)}
          >
            ${skill.disabled ? "Enable" : "Disable"}
          </button>
          ${
            canInstall
              ? html`<button
                class="btn"
                ?disabled=${busy}
                @click=${() => props.onInstall(skill.skillKey, skill.name, skill.install[0].id)}
              >
                ${busy ? "Installing…" : skill.install[0].label}
              </button>`
              : nothing
          }
        </div>
        ${
          message
            ? html`<div
              class="muted"
              style="margin-top: 8px; color: ${
                message.kind === "error"
                  ? "var(--danger-color, #d14343)"
                  : "var(--success-color, #0a7f5a)"
              };"
            >
              ${message.message}
            </div>`
            : nothing
        }
        ${
          skill.primaryEnv
            ? renderVaultKeySelector(skill, props)
            : nothing
        }
      </div>
    </div>
  `;
}

function renderVaultKeySelector(skill: SkillStatusEntry, props: SkillsProps) {
  const busy = props.busyKey === skill.skillKey;
  const isLinked = Boolean(skill.vaultKeyId);
  const adding = props.skillAddingKey[skill.skillKey] ?? false;
  const newName = props.skillNewKeyName[skill.skillKey] ?? "";
  const newValue = props.skillNewKeyValue[skill.skillKey] ?? "";

  if (isLinked) {
    // Show current vault link
    return html`
      <div style="margin-top:10px;display:flex;align-items:center;gap:8px;">
        <span style="font-size:12px;color:var(--text-muted);">🔒</span>
        <span style="font-size:13px;font-family:monospace;color:var(--text);">${skill.vaultKeyId}</span>
        <button class="btn" style="font-size:11px;padding:2px 8px;" ?disabled=${busy}
          @click=${() => props.onLinkVaultKey(skill.skillKey, "")}
          title="Unlink vault key">Unlink</button>
      </div>
    `;
  }

  // Show dropdown selector
  return html`
    <div style="margin-top:10px;">
      <div style="display:flex;align-items:center;gap:8px;">
        <select
          style="flex:1;padding:5px 8px;background:var(--bg-surface,#0d1117);border:1px solid var(--border,#30363d);border-radius:6px;color:var(--text);font-size:13px;"
          ?disabled=${busy}
          @change=${(e: Event) => {
            const val = (e.target as HTMLSelectElement).value;
            if (val === "__add__") {
              props.onToggleAddKey(skill.skillKey);
              (e.target as HTMLSelectElement).value = "";
            } else if (val) {
              props.onLinkVaultKey(skill.skillKey, val);
            }
          }}
        >
          <option value="">Select vault key for ${skill.primaryEnv}…</option>
          ${props.vaultKeys.map((k) => html`
            <option value=${k.id}>🔒 ${k.id}</option>
          `)}
          <option disabled>──────────</option>
          <option value="__add__">＋ Add new vault key…</option>
        </select>
      </div>
      ${adding ? html`
        <div style="margin-top:8px;padding:10px;background:var(--bg-surface,#0d1117);border:1px solid var(--border,#30363d);border-radius:6px;">
          <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
            <input type="text" placeholder="KEY_NAME"
              style="width:160px;padding:5px 8px;background:var(--bg-surface,#0d1117);border:1px solid var(--border,#30363d);border-radius:4px;color:var(--text);font-size:12px;font-family:monospace;"
              .value=${newName}
              @input=${(e: Event) => props.onNewKeyNameChange(skill.skillKey, (e.target as HTMLInputElement).value)}
            />
            <input type="password" placeholder="Secret value"
              style="flex:1;min-width:120px;padding:5px 8px;background:var(--bg-surface,#0d1117);border:1px solid var(--border,#30363d);border-radius:4px;color:var(--text);font-size:12px;"
              .value=${newValue}
              @input=${(e: Event) => props.onNewKeyValueChange(skill.skillKey, (e.target as HTMLInputElement).value)}
            />
            <button class="btn primary" style="font-size:11px;padding:4px 10px;"
              ?disabled=${busy || !newName.trim() || !newValue.trim()}
              @click=${() => props.onAddKeyAndLink(skill.skillKey)}>Save & Link</button>
            <button class="btn" style="font-size:11px;padding:4px 10px;"
              @click=${() => props.onToggleAddKey(skill.skillKey)}>Cancel</button>
          </div>
        </div>
      ` : nothing}
    </div>
  `;
}


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)