mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
122
docs/providers/ZED-DOCKER.md
Normal file
122
docs/providers/ZED-DOCKER.md
Normal 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` |
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
57
src/app/api/providers/zed/manual-import/route.ts
Normal file
57
src/app/api/providers/zed/manual-import/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
44
tests/unit/zed-docker-detect.test.ts
Normal file
44
tests/unit/zed-docker-detect.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user