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
This commit is contained in:
diegosouzapw
2026-05-19 11:26:28 -03:00
parent e7eb777969
commit 3b9cb7d568
6 changed files with 370 additions and 27 deletions

View File

@@ -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 (<provider>)`.
## 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 <management-token>
{
"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` |

View File

@@ -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() {
</div>
{providerId === "zed" && (
<Card>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold flex items-center gap-2">
<span className="material-symbols-outlined text-[20px]">download</span>
Import from Zed Keychain
</h2>
<p className="text-sm text-text-muted mt-1">
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.
</p>
<>
<Card>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold flex items-center gap-2">
<span className="material-symbols-outlined text-[20px]">download</span>
Import from Zed Keychain
</h2>
<p className="text-sm text-text-muted mt-1">
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.
</p>
</div>
<Button
size="sm"
variant="secondary"
icon={importingZed ? "sync" : "download"}
onClick={handleZedImport}
disabled={importingZed}
>
{importingZed ? "Importing…" : "Import from Zed"}
</Button>
</div>
<Button
size="sm"
variant="secondary"
icon={importingZed ? "sync" : "download"}
onClick={handleZedImport}
disabled={importingZed}
>
{importingZed ? "Importing…" : "Import from Zed"}
</Button>
</div>
</Card>
</Card>
<Card>
<div className="flex flex-col gap-3">
<button
className="flex items-center justify-between w-full text-left"
onClick={() => setShowZedManual((v) => !v)}
>
<h2 className="text-lg font-semibold flex items-center gap-2">
<span className="material-symbols-outlined text-[20px]">edit</span>
Manual Token Import
</h2>
<span className="material-symbols-outlined text-[18px] text-text-muted">
{showZedManual ? "expand_less" : "expand_more"}
</span>
</button>
{showZedManual && (
<div className="flex flex-col gap-3 mt-1">
<p className="text-sm text-text-muted">
Use this when OmniRoute runs in Docker or the keychain is unavailable. Paste the
API key that Zed stored under{" "}
<code className="font-mono text-xs">~/.config/zed/settings.json</code> or copy it
from the Zed AI settings panel.
</p>
<div className="flex gap-2 flex-col sm:flex-row">
<select
className="input input-sm"
value={zedManualProvider}
onChange={(e) => setZedManualProvider(e.target.value)}
>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="google">Google</option>
<option value="mistral">Mistral</option>
<option value="xai">xAI</option>
<option value="openrouter">OpenRouter</option>
<option value="deepseek">DeepSeek</option>
</select>
<input
type="password"
className="input input-sm flex-1"
placeholder="Paste API key…"
value={zedManualToken}
onChange={(e) => setZedManualToken(e.target.value)}
/>
<Button
size="sm"
variant="secondary"
icon={importingZedManual ? "sync" : "upload"}
onClick={handleZedManualImport}
disabled={importingZedManual || !zedManualToken.trim()}
>
{importingZedManual ? "Saving…" : "Import"}
</Button>
</div>
</div>
)}
</div>
</Card>
</>
)}
{isCompatible && providerNode && (

View File

@@ -37,7 +37,24 @@ export async function POST(request: Request): Promise<NextResponse<ImportRespons
if (authError) return authError;
try {
// Check if Zed is installed
// Docker environments cannot access the host OS keychain or filesystem.
// Surface a clear error directing users to the manual import tab.
const inDocker = isRunningInDocker();
if (inDocker) {
return NextResponse.json(
{
success: false,
error:
"OmniRoute is running inside Docker and cannot access the host keychain. " +
"Use the Manual Token Import tab to paste your API key directly.",
zedInstalled: false,
zedDockerEnvironment: true,
},
{ status: 422 }
);
}
// Check if Zed is installed (non-Docker path)
const zedInstalled = await isZedInstalled();
if (!zedInstalled) {
@@ -46,6 +63,7 @@ export async function POST(request: Request): Promise<NextResponse<ImportRespons
success: false,
error: "Zed IDE does not appear to be installed on this system.",
zedInstalled: false,
zedDockerEnvironment: false,
},
{ status: 404 }
);

View File

@@ -0,0 +1,57 @@
/**
* POST /api/providers/zed/manual-import
*
* Accepts a manually-pasted Zed API token for a specific provider.
* Intended for Docker/headless deployments where keychain access is unavailable.
*
* Security: protected by requireManagementAuth.
*/
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { createProviderConnection } from "@/lib/db/providers";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
const manualImportSchema = z.object({
provider: z.string().min(1).max(64),
token: z.string().min(1).max(512),
label: z.string().max(128).optional(),
});
export async function POST(request: Request): Promise<NextResponse> {
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 });
}
}

View File

@@ -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

View File

@@ -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);
});