From 3b9cb7d5685bd55171995d84bb66fcb3d15b4dde Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 19 May 2026 11:26:28 -0300 Subject: [PATCH] feat(zed): complete Docker integration with manual import endpoint + UI panel (#2306) - Refactor dockerDetect.ts to accept optional deps for testability - Add Docker guard (HTTP 422 + zedDockerEnvironment flag) to import route - Add POST /api/providers/zed/manual-import endpoint with Zod validation - Add collapsible Manual Token Import panel to Zed provider page (auto-expands on Docker error) - Add 4 unit tests for isRunningInDocker via dependency injection - Add docs/providers/ZED-DOCKER.md Docker setup guide --- docs/providers/ZED-DOCKER.md | 122 ++++++++++++++++ .../dashboard/providers/[id]/page.tsx | 136 +++++++++++++++--- src/app/api/providers/zed/import/route.ts | 20 ++- .../api/providers/zed/manual-import/route.ts | 57 ++++++++ src/lib/zed-oauth/dockerDetect.ts | 18 ++- tests/unit/zed-docker-detect.test.ts | 44 ++++++ 6 files changed, 370 insertions(+), 27 deletions(-) create mode 100644 docs/providers/ZED-DOCKER.md create mode 100644 src/app/api/providers/zed/manual-import/route.ts create mode 100644 tests/unit/zed-docker-detect.test.ts diff --git a/docs/providers/ZED-DOCKER.md b/docs/providers/ZED-DOCKER.md new file mode 100644 index 0000000000..b5ccea499f --- /dev/null +++ b/docs/providers/ZED-DOCKER.md @@ -0,0 +1,122 @@ +# Zed IDE Integration in Docker Environments + +When OmniRoute runs inside Docker, the standard "Import from Zed Keychain" flow fails +because the container cannot reach the host OS keychain daemon (libsecret on Linux, +Keychain on macOS, Credential Manager on Windows) and the Zed config directories on the +host filesystem are not visible inside the container by default. + +## Why Keychain Import Fails in Docker + +Two blocking issues occur inside a container: + +1. **Filesystem isolation** — `isZedInstalled()` looks for `~/.config/zed` (Linux), + `~/Library/Application Support/Zed` (macOS), or the Windows equivalent. These paths + live on the host and are not available unless explicitly volume-mounted. +2. **IPC isolation** — Even when the config directory is mounted, the `keytar` native + module communicates with the OS keychain service over a Unix socket or D-Bus session. + Neither is bridged into the container by default, so credential reads always fail. + +OmniRoute detects the Docker environment via two heuristics: + +- Presence of `/.dockerenv` (written by the Docker daemon at container start). +- The string `docker` appearing in `/proc/1/cgroup` (Linux cgroup v1). + +When either heuristic triggers, the import route returns HTTP 422 with +`zedDockerEnvironment: true` and a message directing you to the Manual Token Import tab. + +## Using the Manual Token Import Tab + +1. Open **Dashboard → Providers → Zed**. +2. The **Manual Token Import** panel appears below the keychain import card. When + OmniRoute detects Docker, this panel expands automatically after the first failed + keychain import attempt. +3. Select the provider from the dropdown (OpenAI, Anthropic, Google, Mistral, xAI, + OpenRouter, or DeepSeek). +4. Paste the API key in the password field. +5. Click **Import**. + +The key is saved as a new provider connection with the name +`Zed Manual Import ()`. + +## Where Zed Stores API Keys on the Host + +Zed stores AI provider keys in the OS keychain under service names such as +`zed-openai`, `ai.zed.openai`, `zed-anthropic`, etc. To retrieve them for manual +import, look in: + +**Linux** + +``` +~/.config/zed/settings.json +``` + +The `language_models` section contains provider configurations. Keys saved to the +keychain via the Zed UI are not in plain text in `settings.json`; retrieve them through +a keychain viewer such as GNOME Keyring / Seahorse, or by running: + +```bash +secret-tool lookup service zed-openai account api-key +``` + +**macOS** + +``` +~/Library/Application Support/Zed/settings.json +``` + +Keychain entries can be found in **Keychain Access.app** by searching for `zed`. + +## Volume-Mount Option (Advanced) + +You can optionally mount the Zed config directory read-only into the container. +This does not fix the keychain issue but may be useful for future features that read +non-secret Zed config values (e.g., model preferences). + +```yaml +# docker-compose.yml snippet +services: + omniroute: + image: omniroute:latest + volumes: + # Linux host + - "${HOME}/.config/zed:/host-zed-config:ro" + # macOS host (uncomment instead) + # - "${HOME}/Library/Application Support/Zed:/host-zed-config:ro" + environment: + # Future: ZED_CONFIG_PATH=/host-zed-config + PORT: "20128" +``` + +Note: a `ZED_CONFIG_PATH` environment variable override is not yet implemented. This +snippet is provided as a reference for when that feature is added. + +## Manual Import API + +The manual import endpoint can also be called directly: + +``` +POST /api/providers/zed/manual-import +Content-Type: application/json +Authorization: Bearer + +{ + "provider": "openai", + "token": "sk-...", + "label": "My Zed OpenAI key" // optional +} +``` + +On success it returns: + +```json +{ "success": true, "connectionId": "...", "provider": "openai" } +``` + +## Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| 422 + `zedDockerEnvironment: true` | Running inside Docker | Use Manual Token Import tab | +| 404 + `zedInstalled: false` | Zed not installed on host | Install Zed or use manual import | +| 403 + keychain access denied | OS denied keychain access | Grant permission in OS prompt | +| 404 + keychain service not available | `libsecret` missing on Linux | Install `libsecret-1-dev` | diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 3ca261e16f..7d67f8606d 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -1060,6 +1060,10 @@ export default function ProviderDetailPage() { >({}); const [importingModels, setImportingModels] = useState(false); const [importingZed, setImportingZed] = useState(false); + const [showZedManual, setShowZedManual] = useState(false); + const [zedManualProvider, setZedManualProvider] = useState("openai"); + const [zedManualToken, setZedManualToken] = useState(""); + const [importingZedManual, setImportingZedManual] = useState(false); const [showImportModal, setShowImportModal] = useState(false); const [importProgress, setImportProgress] = useState({ current: 0, @@ -1340,6 +1344,9 @@ export default function ProviderDetailPage() { const res = await fetch("/api/providers/zed/import", { method: "POST" }); const data = await res.json(); if (!res.ok || !data.success) { + if (data.zedDockerEnvironment) { + setShowZedManual(true); + } notify.error(data.error || "Zed import failed"); } else if (!data.count) { const found = data.credentials?.length ?? 0; @@ -1363,6 +1370,30 @@ export default function ProviderDetailPage() { } }, [importingZed, notify, fetchConnections]); + const handleZedManualImport = useCallback(async () => { + if (importingZedManual || !zedManualToken.trim()) return; + setImportingZedManual(true); + try { + const res = await fetch("/api/providers/zed/manual-import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: zedManualProvider, token: zedManualToken.trim() }), + }); + const data = await res.json(); + if (!res.ok || !data.success) { + notify.error(data.error?.message ?? data.error ?? "Manual import failed"); + } else { + notify.success(`Imported ${zedManualProvider} token from Zed`); + setZedManualToken(""); + await fetchConnections(); + } + } catch (e: any) { + notify.error(e?.message || "Manual import failed"); + } finally { + setImportingZedManual(false); + } + }, [importingZedManual, zedManualProvider, zedManualToken, notify, fetchConnections]); + useEffect(() => { if (providerId !== "codex") return; fetch("/api/settings", { cache: "no-store" }) @@ -3333,30 +3364,89 @@ export default function ProviderDetailPage() { {providerId === "zed" && ( - -
-
-

- download - Import from Zed Keychain -

-

- Discover AI provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) that Zed - IDE stored in the OS keychain and import them as connections. Requires Zed IDE - installed on this machine. -

+ <> + +
+
+

+ download + Import from Zed Keychain +

+

+ Discover AI provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) that Zed + IDE stored in the OS keychain and import them as connections. Requires Zed IDE + installed on this machine. +

+
+
- -
- + + +
+ + {showZedManual && ( +
+

+ Use this when OmniRoute runs in Docker or the keychain is unavailable. Paste the + API key that Zed stored under{" "} + ~/.config/zed/settings.json or copy it + from the Zed AI settings panel. +

+
+ + setZedManualToken(e.target.value)} + /> + +
+
+ )} +
+
+ )} {isCompatible && providerNode && ( diff --git a/src/app/api/providers/zed/import/route.ts b/src/app/api/providers/zed/import/route.ts index 37a438750e..bb04bcee80 100644 --- a/src/app/api/providers/zed/import/route.ts +++ b/src/app/api/providers/zed/import/route.ts @@ -37,7 +37,24 @@ export async function POST(request: Request): Promise { + const authError = await requireManagementAuth(request); + if (authError) return authError as NextResponse; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json(buildErrorBody(400, "Invalid JSON body"), { status: 400 }); + } + + const parsed = manualImportSchema.safeParse(rawBody); + if (!parsed.success) { + return NextResponse.json( + buildErrorBody(400, "Validation failed: " + parsed.error.issues.map((i) => i.message).join(", ")), + { status: 400 } + ); + } + + const { provider, token, label } = parsed.data; + + try { + const connection = await createProviderConnection({ + provider, + authType: "apikey", + apiKey: token, + name: label ?? `Zed Manual Import (${provider})`, + isActive: true, + }); + + return NextResponse.json({ success: true, connectionId: connection.id, provider }); + } catch (err: unknown) { + console.error("[Zed Manual Import] Failed to save credential:", err); + return NextResponse.json(buildErrorBody(500, "Failed to save credential"), { status: 500 }); + } +} diff --git a/src/lib/zed-oauth/dockerDetect.ts b/src/lib/zed-oauth/dockerDetect.ts index 683e85837f..b4896929a4 100644 --- a/src/lib/zed-oauth/dockerDetect.ts +++ b/src/lib/zed-oauth/dockerDetect.ts @@ -1,5 +1,15 @@ import fs from "fs"; +export interface DockerDetectDeps { + existsSync: (path: string) => boolean; + readFileSync: (path: string, encoding: string) => string; +} + +const defaultDeps: DockerDetectDeps = { + existsSync: fs.existsSync, + readFileSync: (path, encoding) => fs.readFileSync(path, encoding as BufferEncoding) as string, +}; + /** * Returns true when OmniRoute appears to be running inside a Docker container. * Uses two complementary heuristics that work on Linux-based Docker images: @@ -9,15 +19,17 @@ import fs from "fs"; * This is intentionally a best-effort check; false negatives on exotic runtimes * (e.g. podman without Docker compatibility) are acceptable — the caller degrades * gracefully and still surfaces the manual-import option. + * + * @param deps Optional dependency injection for testing. */ -export function isRunningInDocker(): boolean { +export function isRunningInDocker(deps: DockerDetectDeps = defaultDeps): boolean { try { - if (fs.existsSync("/.dockerenv")) return true; + if (deps.existsSync("/.dockerenv")) return true; } catch { // ignore — not Linux or permission denied } try { - const cgroup = fs.readFileSync("/proc/1/cgroup", "utf8"); + const cgroup = deps.readFileSync("/proc/1/cgroup", "utf8"); if (cgroup.includes("docker")) return true; } catch { // ignore — not Linux or /proc not mounted diff --git a/tests/unit/zed-docker-detect.test.ts b/tests/unit/zed-docker-detect.test.ts new file mode 100644 index 0000000000..442a2ced02 --- /dev/null +++ b/tests/unit/zed-docker-detect.test.ts @@ -0,0 +1,44 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { isRunningInDocker } from "../../src/lib/zed-oauth/dockerDetect.ts"; + +// Tests use dependency injection (dockerDetect accepts optional `deps`) +// so no module mocking is required. + +test("isRunningInDocker returns true when /.dockerenv exists", () => { + const result = isRunningInDocker({ + existsSync: (p: string) => p === "/.dockerenv", + readFileSync: (_p: string, _enc: string) => { + throw new Error("skip"); + }, + }); + assert.equal(result, true); +}); + +test("isRunningInDocker returns true when /proc/1/cgroup contains 'docker'", () => { + const result = isRunningInDocker({ + existsSync: (_p: string) => false, + readFileSync: (_p: string, _enc: string) => "12:cpuset:/docker/abc123\n", + }); + assert.equal(result, true); +}); + +test("isRunningInDocker returns false on a plain host environment", () => { + const result = isRunningInDocker({ + existsSync: (_p: string) => false, + readFileSync: (_p: string, _enc: string) => "12:cpuset:/\n", + }); + assert.equal(result, false); +}); + +test("isRunningInDocker returns false when fs throws for all checks", () => { + const result = isRunningInDocker({ + existsSync: (_p: string) => { + throw new Error("EPERM"); + }, + readFileSync: (_p: string, _enc: string) => { + throw new Error("ENOENT"); + }, + }); + assert.equal(result, false); +});