diff --git a/apps/desktop/src/main/acp-host.mjs b/apps/desktop/src/main/acp-host.mjs index 52df4516..af3e7fd4 100644 --- a/apps/desktop/src/main/acp-host.mjs +++ b/apps/desktop/src/main/acp-host.mjs @@ -298,6 +298,7 @@ export function isAuthRequiredError(error) { export function sanitizeFailureDetail(error) { const message = error instanceof Error ? error.message : String(error?.message ?? error ?? ""); let text = message.replace(/\s+/g, " ").trim(); + text = text.replace(/https?:\/\/[^\s"']+/gi, "[url]"); const home = homedir(); if (home && home !== "/" && home !== "\\") text = text.split(home).join("[path]"); text = text.replace(/~\/[^\s]+/g, "[path]"); @@ -305,6 +306,8 @@ export function sanitizeFailureDetail(error) { text = text.replace(/[A-Za-z]:\\(?:[^\\/:*?"<>|\r\n]+\\)*[^\\/:*?"<>|\r\n]*/g, "[path]"); text = text.replace(/\b(?:[A-Za-z0-9_]*(?:key|token|secret|password|credential)[A-Za-z0-9_]*)=[^\s]+/gi, "[redacted]"); text = text.replace(/\b(?:bearer|authorization)\s+[^\s]+/gi, "[redacted]"); + text = text.replace(/(["']?[\w-]*(?:key|token|secret|password|credential|authorization)[\w-]*["']?\s*[:=]\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s,}]+)/gi, "$1[redacted]"); + text = text.replace(/\b(?:sk-ant-|sk-)[A-Za-z0-9_-]+/g, "[redacted]"); text = text.replace(/\s+/g, " ").trim(); if (!text || text === "[path]" || text === "[redacted]") return "connection_failed"; return text.slice(0, 160); @@ -511,7 +514,7 @@ export function createAcpSpawnPlan(input, cwd) { args: spec.args, options: { cwd: workspace, - stdio: ["pipe", "pipe", "ignore"], + stdio: ["pipe", "pipe", "pipe"], windowsHide: true, shell: false, ...(spec.env || spec.unsetEnv ? { env: (() => { @@ -525,7 +528,7 @@ export function createAcpSpawnPlan(input, cwd) { export function spawnAcpChild(spawnImpl, command, cwd) { const plan = createAcpSpawnPlan(command, cwd); - const options = { ...plan.options, shell: false, stdio: ["pipe", "pipe", "ignore"] }; + const options = { ...plan.options, shell: false, stdio: ["pipe", "pipe", "pipe"] }; if (options.shell !== false) throw new Error("ACP processes cannot use a shell"); const child = spawnImpl(plan.command, plan.args, options); trackChild(child); @@ -632,7 +635,10 @@ export function automaticAcpPermissionResponse(params) { return { outcome: option ? { outcome: "selected", optionId: option.optionId } : { outcome: "cancelled" } }; } -const createEdgeEverAcpClient = (requestId, emit, allowPermissions) => ({ +const createEdgeEverAcpClient = (requestId, emit, allowPermissions, onAuthStatus) => ({ + extNotification(method, params) { + if (method === "_auth/status_update") onAuthStatus?.(params?.authStatus); + }, requestPermission(params) { // Probe, install, and sign-in sessions do not originate from a user prompt. return allowPermissions ? automaticAcpPermissionResponse(params) : { outcome: { outcome: "cancelled" } }; @@ -735,14 +741,52 @@ export function createAcpHostRuntime(options = {}) { let child = null; let authMethods = []; let authenticationPending = false; + let authStatusKind; + let connected = false; + let resolveAuthStatus; + let authStatusReceived = new Promise((resolve) => { resolveAuthStatus = resolve; }); + const onAuthStatus = (status) => { + if (!status || !["none", "account", "api_key", "gateway", "external"].includes(status.kind)) return; + authStatusKind = status.kind; + resolveAuthStatus(); + if (connected && status.kind === "none") abort(); + }; const abort = () => stopChild(child); signal?.addEventListener("abort", abort, { once: true }); try { if (signal?.aborted) throw Object.assign(new Error("connection_timeout"), { code: "TIMEOUT" }); child = await spawnAcpChild(spawnImpl, command, cwd); if (signal?.aborted) throw Object.assign(new Error("connection_timeout"), { code: "TIMEOUT" }); + // Drain stderr even when no logger is configured. Bound each connection's + // diagnostic output and sanitize before crossing into the log callback. + const secretValues = [ + ...Object.entries({ ...process.env, ...(typeof command === "object" ? command.env : {}) }), + ...mcpServers.flatMap((server) => (server.env ?? []).map(({ name, value }) => [name, value])), + ].filter(([key, value]) => /key|token|secret|password|credential|authorization/i.test(key) && typeof value === "string" && value.length > 0) + .map(([, value]) => value).sort((a, b) => b.length - a.length); + let diagnosticBytes = 0; + let stderrBuffer = ""; + const recordStderr = (line) => { + if (!line.trim() || diagnosticBytes >= 16_384) return; + diagnosticBytes += Buffer.byteLength(line); + for (const secret of secretValues) line = line.split(secret).join("[redacted]"); + try { options.onDiagnostic?.({ requestId, message: sanitizeFailureDetail(line) }); } catch { /* diagnostics are optional */ } + }; + child.stderr?.setEncoding?.("utf8"); + child.stderr?.on("data", (chunk) => { + if (diagnosticBytes >= 16_384) return; + stderrBuffer += String(chunk); + let newline; + while ((newline = stderrBuffer.indexOf("\n")) !== -1) { + recordStderr(stderrBuffer.slice(0, newline)); + stderrBuffer = stderrBuffer.slice(newline + 1); + } + // Never log a partial line: a secret can span pipe chunks. + if (stderrBuffer.length > 16_384) { stderrBuffer = ""; diagnosticBytes = 16_384; } + }); + child.stderr?.on("end", () => { recordStderr(stderrBuffer); stderrBuffer = ""; }); const stream = acpNdJsonStream(child); - const connection = new ClientSideConnection(() => createEdgeEverAcpClient(requestId, emit, allowPermissions), stream); + const connection = new ClientSideConnection(() => createEdgeEverAcpClient(requestId, emit, allowPermissions, onAuthStatus), stream); void connection.closed?.catch(() => {}); const initialized = await connection.initialize(acpInitializeParams(version)); authMethods = publicAuthMethods(initialized); @@ -757,11 +801,30 @@ export function createAcpHostRuntime(options = {}) { if (authMethodId) { if (!authMethods.some((method) => method.id === authMethodId)) throw new Error("invalid_auth_method"); authenticationPending = true; + if (authStatusKind === "none") { + authStatusKind = undefined; + authStatusReceived = new Promise((resolve) => { resolveAuthStatus = resolve; }); + } if (workBuddyAuth) await authenticateWorkBuddy(workBuddyAuth, authMethodId, signal, { readCredential }); else await connection.authenticate({ methodId: authMethodId }); authenticationPending = false; } + if (authStatusKind === "none") throw RequestError.authRequired(); const session = await connection.newSession({ cwd, mcpServers }); + // The extension is pushed asynchronously after initialize. Only agents + // advertising it must answer; older ACP adapters keep their existing path. + if (initialized?.agentCapabilities?._meta?.authStatus != null && authStatusKind === undefined) { + let timer; + try { + await Promise.race([ + authStatusReceived, + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("identity_status_unavailable")), options.authStatusTimeoutMs ?? 5_500); }), + connection.closed.then(() => { throw new Error("connection_closed"); }), + ]); + } finally { clearTimeout(timer); } + } + if (authStatusKind === "none") throw RequestError.authRequired(); + connected = true; if (signal?.aborted) throw Object.assign(new Error("connection_timeout"), { code: "TIMEOUT" }); return { cwd, @@ -771,13 +834,15 @@ export function createAcpHostRuntime(options = {}) { authMethods, promptCapabilities: normalizePromptCapabilities(initialized), stop: abort, + authenticationRequired: () => authStatusKind === "none", }; } catch (error) { - if (authMethods.length && error && typeof error === "object") error.authMethods = authMethods; - if (authenticationPending && error && typeof error === "object") error.authenticationPending = true; + const failure = authStatusKind === "none" && !authenticationPending ? RequestError.authRequired() : error; + if (authMethods.length && failure && typeof failure === "object") failure.authMethods = authMethods; + if (authenticationPending && failure && typeof failure === "object") failure.authenticationPending = true; abort(); await removeAcpWorkspace(cwd, rmImpl); - throw error; + throw failure; } finally { signal?.removeEventListener("abort", abort); } @@ -1031,7 +1096,7 @@ export function createAcpHostRuntime(options = {}) { }; void connected.connection.prompt({ sessionId: connected.sessionId, prompt: content.blocks }).then((result) => { if (session.cancelled) return finish({ requestId, type: "done" }); - const failure = promptResultFailure(result); + const failure = connected.authenticationRequired() ? "needs_login" : promptResultFailure(result); if (failure === "needs_login") { rememberPromptStatus({ state: "needs_login", authMethods: connected.authMethods }); } @@ -1039,7 +1104,7 @@ export function createAcpHostRuntime(options = {}) { }).catch((error) => { if (session.cancelled) finish({ requestId, type: "done" }); else { - const failure = classifyAcpFailure(error); + const failure = connected.authenticationRequired() ? { state: "needs_login" } : classifyAcpFailure(error); if (failure.state === "needs_login") { rememberPromptStatus({ state: "needs_login", authMethods: connected.authMethods }); } diff --git a/apps/desktop/src/main/acp-host.test.mjs b/apps/desktop/src/main/acp-host.test.mjs index fef55b88..0eea1c6b 100644 --- a/apps/desktop/src/main/acp-host.test.mjs +++ b/apps/desktop/src/main/acp-host.test.mjs @@ -84,7 +84,7 @@ const collector = () => { }; }; -const fakeAgentSource = ({ reportPath, secretPath, allowImage, allowEmbedded, hold, requireAuth, advertiseAuth, promptRefusal, hangAuth, hangSession, workBuddyAccountApi, workBuddyLoginFailure, workBuddyUnreadable, workBuddyUnreadableAfterLogin, authMethodId = "browser" }) => `#!/usr/bin/env bun +const fakeAgentSource = ({ reportPath, secretPath, allowImage, allowEmbedded, hold, requireAuth, advertiseAuth, promptRefusal, hangAuth, hangSession, workBuddyAccountApi, workBuddyLoginFailure, workBuddyUnreadable, workBuddyUnreadableAfterLogin, authMethodId = "browser", authStatusKind, advertiseAuthStatus = false, authStatusDelay = 20, stderrText, promptAuthStatusKind }) => `#!/usr/bin/env bun import * as acp from ${JSON.stringify(sdkHref)}; import { writeFileSync, readFileSync } from "node:fs"; @@ -161,11 +161,19 @@ const stream = acp.ndJsonStream( acp.agent({ name: "edgeever-fake-agent" }) .onRequest("initialize", (ctx) => { + if (${stderrText ? "true" : "false"}) { + const diagnostic = ${JSON.stringify(stderrText ?? "")}; + process.stderr.write(diagnostic.slice(0, 12)); + setTimeout(() => process.stderr.write(diagnostic.slice(12) + "\\n"), 5); + } + if (${authStatusKind ? "true" : "false"}) setTimeout(() => { + void ctx.client.notify("_auth/status_update", { authStatus: { kind: authenticated ? "api_key" : ${JSON.stringify(authStatusKind ?? "none")}, label: "Fixture" } }); + }, ${authStatusDelay}); report.initialize = ctx.params; save(); return { protocolVersion: ctx.params.protocolVersion, - agentCapabilities: { promptCapabilities: { image: allowImage, embeddedContext: allowEmbedded } }, + agentCapabilities: { promptCapabilities: { image: allowImage, embeddedContext: allowEmbedded }, ...(${advertiseAuthStatus ? "true" : "false"} ? { _meta: { authStatus: {} } } : {}) }, authMethods: requireAuth || advertiseAuth ? [{ id: ${JSON.stringify(authMethodId)}, name: "Browser" }] : [], }; }) @@ -177,6 +185,7 @@ acp.agent({ name: "edgeever-fake-agent" }) return new Promise(() => {}); } authenticated = true; + if (${advertiseAuthStatus ? "true" : "false"}) void ctx.client.notify("_auth/status_update", { authStatus: { kind: "api_key", label: "Fixture" } }); save(); return {}; }) @@ -188,6 +197,9 @@ acp.agent({ name: "edgeever-fake-agent" }) return { sessionId: "sess-1" }; }) .onRequest("session/prompt", async (ctx) => { + if (${promptAuthStatusKind ? "true" : "false"}) { + await ctx.client.notify("_auth/status_update", { authStatus: { kind: ${JSON.stringify(promptAuthStatusKind ?? "none")}, label: "Fixture" } }); + } report.prompt = ctx.params.prompt; save(); if (promptRefusal) return promptRefusal; @@ -526,7 +538,7 @@ describe("ACP spawn and failure mapping", () => { options: expect.objectContaining({ shell: false, cwd: path.resolve(workspace) }), }); expect(captured.options.shell).toBe(false); - expect(captured.options.stdio).toEqual(["pipe", "pipe", "ignore"]); + expect(captured.options.stdio).toEqual(["pipe", "pipe", "pipe"]); await rm(workspace, { recursive: true, force: true }); }); @@ -696,6 +708,82 @@ describe("ACP stdio session", () => { } }, 15_000); + for (const kind of ["none", "account", "api_key", "gateway", "external", undefined]) { + sessionTest(`handles delayed auth identity ${kind ?? "not reported"} with successful session/new`, async () => { + const directory = await mkdtemp(path.join(tmpdir(), "edgeever-acp-identity-")); + const reportPath = path.join(directory, "report.json"); + try { + const scriptPath = await writeFakeAgent(directory, { reportPath, authStatusKind: kind, advertiseAuthStatus: true, authStatusDelay: 80 }); + const runtime = createAcpHostRuntime({ + authStatusTimeoutMs: 200, + adapterManager: { get: () => ({ command: { command: process.execPath, args: [scriptPath] } }) }, + }); + const result = await runtime.probeAdapter({ id: "claudeCode" }); + expect(result.state).toBe(kind === "none" ? "needs_login" : kind ? "available" : "failed"); + if (!kind) expect(result.detail).toBe("identity_status_unavailable"); + expect((await readReport(reportPath)).newSession).not.toBeNull(); + await waitUntilExited(Number(await readFile(`${reportPath}.pid`, "utf8"))); + if (kind === "none") { + const events = []; + await runtime.prompt({ adapterId: "claudeCode", prompt: "hello", noteAccess: false }, (event) => events.push(event)); + expect(events.at(-1)).toMatchObject({ type: "error", message: "needs_login" }); + expect((await readReport(reportPath)).prompt).toBeNull(); + await waitUntilExited(Number(await readFile(`${reportPath}.pid`, "utf8"))); + } + } finally { await rm(directory, { recursive: true, force: true }); } + }, 15_000); + } + + sessionTest("accepts new credentials after authentication and ignores the earlier logged-out identity", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "edgeever-acp-identity-login-")); + const reportPath = path.join(directory, "report.json"); + try { + const scriptPath = await writeFakeAgent(directory, { reportPath, authStatusKind: "none", advertiseAuthStatus: true, advertiseAuth: true }); + const runtime = createAcpHostRuntime({ adapterManager: { get: () => ({ command: { command: process.execPath, args: [scriptPath] } }) } }); + expect((await runtime.probeAdapter({ id: "claudeCode" })).state).toBe("needs_login"); + await waitUntilExited(Number(await readFile(`${reportPath}.pid`, "utf8"))); + expect((await runtime.authenticateAdapter({ id: "claudeCode", methodId: "browser" })).state).toBe("available"); + await waitUntilExited(Number(await readFile(`${reportPath}.pid`, "utf8"))); + } finally { await rm(directory, { recursive: true, force: true }); } + }, 15_000); + + sessionTest("reports credentials revoked during a live prompt as needs_login", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "edgeever-acp-identity-expired-")); + const reportPath = path.join(directory, "report.json"); + try { + const scriptPath = await writeFakeAgent(directory, { reportPath, authStatusKind: "api_key", advertiseAuthStatus: true, promptAuthStatusKind: "none", hold: true }); + const runtime = createAcpHostRuntime({ adapterManager: { get: () => ({ command: { command: process.execPath, args: [scriptPath] } }) } }); + const collected = collector(); + await runtime.prompt({ adapterId: "claudeCode", prompt: "hello", noteAccess: false }, collected.emit); + expect(await collected.waitFor((event) => event.type === "error")).toMatchObject({ message: "needs_login" }); + expect(runtime.listAdapters().find((adapter) => adapter.id === "claudeCode").state).toBe("needs_login"); + await waitUntilExited(Number(await readFile(`${reportPath}.pid`, "utf8"))); + } finally { await rm(directory, { recursive: true, force: true }); } + }, 15_000); + + sessionTest("drains split stderr and records only sanitized diagnostics", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "edgeever-acp-stderr-")); + const reportPath = path.join(directory, "report.json"); + try { + const diagnostics = []; + const scriptPath = await writeFakeAgent(directory, { + reportPath, advertiseAuthStatus: true, authStatusKind: "api_key", authStatusDelay: 80, + stderrText: 'Query closed before response received opaque-secret-value {"api_key":"sk-ant-secret-value"} https://gateway.test/path?key=secret', + }); + const runtime = createAcpHostRuntime({ + onDiagnostic: (details) => diagnostics.push(details), + adapterManager: { get: () => ({ command: { command: process.execPath, args: [scriptPath], env: { ANTHROPIC_API_KEY: "opaque-secret-value" } } }) }, + }); + expect((await runtime.probeAdapter({ id: "claudeCode" })).state).toBe("available"); + const logged = JSON.stringify(diagnostics); + expect(logged).toContain("Query closed before response received"); + expect(logged).not.toContain("secret-value"); + expect(logged).not.toContain("opaque-secret-value"); + expect(logged).not.toContain("gateway.test"); + await waitUntilExited(Number(await readFile(`${reportPath}.pid`, "utf8"))); + } finally { await rm(directory, { recursive: true, force: true }); } + }, 15_000); + sessionTest("a managed Codex connector is unchecked after restart and becomes available after a real probe", async () => { const directory = await mkdtemp(path.join(tmpdir(), "edgeever-acp-restart-")); try { diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index 6d48d522..f4d5eab4 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -1777,6 +1777,7 @@ const startApplication = async () => { }); const acpRuntime = registerAcpIpc(ipcMain, createAcpHostRuntime({ adapterStore: join(app.getPath("userData"), "acp-adapters"), + onDiagnostic: (details) => { void writeDiagnostic("acp.adapter-stderr", details); }, mcpScriptPath: app.isPackaged ? join(process.resourcesPath, "mcp-bridge", "edgeever-mcp-stdio.mjs") : join(projectRoot, "scripts", "edgeever-mcp-stdio.mjs"), diff --git a/apps/web/src/components/ai-sidebar/AiSidebar.tsx b/apps/web/src/components/ai-sidebar/AiSidebar.tsx index da1e0482..47b8ab25 100644 --- a/apps/web/src/components/ai-sidebar/AiSidebar.tsx +++ b/apps/web/src/components/ai-sidebar/AiSidebar.tsx @@ -999,6 +999,8 @@ function AiSidebarSession({ const noteAccessErrorKey = desktopAcpNoteAccessErrorKey(event.message); setError(noteAccessErrorKey ? t(noteAccessErrorKey) : event.message === "workbuddy_credentials_unavailable" ? t("aiAssistant.agentSource.workbuddyCredentialsUnavailable") + : event.message === "identity_status_unavailable" ? t("aiAssistant.agentSource.identityStatusUnavailable") + : event.message === "needs_login" && localAdapterId === "claudeCode" ? t("aiAssistant.agentSource.claudeCodeLoginHint") : event.message === "needs_login" ? t(localAdapterId === "workbuddyCn" || localAdapterId === "workbuddyIntl" ? "aiAssistant.sidebar.workbuddyLoginRequired" : "aiAssistant.sidebar.localLoginRequired") : event.message === "agent_refused" ? t("aiAssistant.sidebar.agentRefused") : event.message); diff --git a/apps/web/src/components/settings/DesktopAcpAgentCard.tsx b/apps/web/src/components/settings/DesktopAcpAgentCard.tsx index 35d7610a..d3d75abc 100644 --- a/apps/web/src/components/settings/DesktopAcpAgentCard.tsx +++ b/apps/web/src/components/settings/DesktopAcpAgentCard.tsx @@ -47,6 +47,8 @@ const statusKey = (adapter: DesktopAcpAdapter | undefined, probing: boolean) => if (!adapter || adapter.detail === "not_probed") return "aiAssistant.agentSource.notProbed"; if (adapter.detail === "workbuddy_credentials_unavailable") return "aiAssistant.agentSource.workbuddyCredentialsUnavailable"; if (adapter.detail === "authentication_timeout") return "aiAssistant.agentSource.authenticationTimedOut"; + if (adapter.detail === "identity_status_unavailable") return "aiAssistant.agentSource.identityStatusNotReported"; + if (adapter.id === "claudeCode" && adapter.state === "needs_login") return "aiAssistant.agentSource.claudeCodeCredentialsRequired"; if (adapter.detail === "authentication_failed") return "aiAssistant.agentSource.authenticationFailed"; if (adapter.detail === "invalid_path") return "aiAssistant.agentSource.invalidPath"; if (adapter.detail === "desktop_unavailable") return "aiAssistant.agentSource.localDisabled"; @@ -154,6 +156,7 @@ const DesktopAcpAgentCardBody = ({ bridge }: { bridge: boolean }) => { shown?.state === "needs_login" || (shown?.state === "available" && (isWorkBuddy || authMethods.length > 0)) || shown?.updateError || + shown?.detail === "identity_status_unavailable" || adapterId === "openClaw" || (adapterId === "hermesAgent" && shown?.state === "failed") ); @@ -397,6 +400,12 @@ const DesktopAcpAgentCardBody = ({ bridge }: { bridge: boolean }) => { {t(adapterId === "claudeCode" ? "aiAssistant.agentSource.claudeCodeMissingHint" : adapterId === "openClaw" ? "aiAssistant.agentSource.openClawMissingHint" : adapterId === "hermesAgent" ? "aiAssistant.agentSource.hermesAgentMissingHint" : adapterId === "grokBuild" ? "aiAssistant.agentSource.grokBuildMissingHint" : adapterId === "deepseekHarness" ? "aiAssistant.agentSource.deepseekHarnessMissingHint" : adapterId === "piAgent" ? shown.detail === "adapter_missing" ? "aiAssistant.agentSource.piAgentAdapterMissingHint" : "aiAssistant.agentSource.piAgentMissingHint" : adapterId === "workbuddyCn" || adapterId === "workbuddyIntl" ? "aiAssistant.agentSource.workbuddyMissingHint" : "aiAssistant.agentSource.installHint")}

) : null} + {shown?.detail === "identity_status_unavailable" ? ( +

{t("aiAssistant.agentSource.identityStatusUnavailable")}

+ ) : null} + {shown?.state === "needs_login" && adapterId === "claudeCode" ? ( +

{t("aiAssistant.agentSource.claudeCodeLoginHint")}

+ ) : null} {shown?.state === "needs_login" && adapterId === "piAgent" ? (

{t("aiAssistant.agentSource.piAgentLoginHint")}

) : null} diff --git a/docs/claude-code-gateway.md b/docs/claude-code-gateway.md new file mode 100644 index 00000000..17d4cf58 --- /dev/null +++ b/docs/claude-code-gateway.md @@ -0,0 +1,30 @@ +# Claude Code with a custom gateway + +The desktop Claude Code connector runs locally. Its credentials and model configuration belong to Claude Code, independently of the AI providers configured on your EdgeEver instance. + +Apps launched from Finder or Dock do not read `~/.zshrc`. Put persistent Claude Code configuration in the user-level `~/.claude/settings.json` instead of relying only on shell exports. EdgeEver creates temporary ACP workspaces, so settings in your development project's `.claude/` directory do not configure those sessions. + +Merge this example into your existing user settings; do not overwrite unrelated settings. Replace the endpoint, key, and model ID with values accepted by your gateway: + +```json +{ + "model": "opus", + "env": { + "ANTHROPIC_BASE_URL": "https://your-gateway.example", + "ANTHROPIC_API_KEY": "your-api-key", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "your-gateway-full-model-id" + } +} +``` + +This selects the `opus` family and maps it to the gateway's full model ID. If you select Sonnet or Haiku, configure the corresponding `ANTHROPIC_DEFAULT_SONNET_MODEL` or `ANTHROPIC_DEFAULT_HAIKU_MODEL` mapping as well. Follow your gateway's authentication instructions if it uses `ANTHROPIC_AUTH_TOKEN` instead of `ANTHROPIC_API_KEY`; do not set conflicting credentials. + +Keep this file private: it contains credentials. Existing shell environment variables can also affect model selection; the connector gives `ANTHROPIC_MODEL` priority over the `model` setting. If you start EdgeEver from a terminal, check for conflicting exports. + +In EdgeEver, open **Settings → Models & Agents**, select **Claude Code**, and check the connection again. Then send a short message to verify the gateway actually accepts the configured model. A successful connection check does not verify model access, gateway availability, or billing quota. + +When the adapter explicitly reports no credentials, EdgeEver displays **Not signed in / missing credentials**. When it advertises authentication reporting but does not return a status, EdgeEver reports that authentication status could not be verified. Older connectors without this extension retain their session-based connection check. + +Adapter stderr is recorded as bounded, sanitized `acp.adapter-stderr` events in `desktop.log`. On macOS the default location is `~/Library/Application Support/EdgeEver/logs/desktop.log`; a custom desktop data directory changes this location. Review logs before sharing them, and never share your settings file or credentials. + +References: [Claude Code settings](https://code.claude.com/docs/en/settings), [model configuration](https://code.claude.com/docs/en/model-config). diff --git a/docs/claude-code-gateway.zh-CN.md b/docs/claude-code-gateway.zh-CN.md new file mode 100644 index 00000000..8db9180e --- /dev/null +++ b/docs/claude-code-gateway.zh-CN.md @@ -0,0 +1,30 @@ +# Claude Code 自建网关配置 + +桌面端 Claude Code 连接组件在本机运行。凭证和模型配置属于 Claude Code,与 EdgeEver 实例中配置的 AI 服务商相互独立。 + +从 Finder 或 Dock 启动的应用不会读取 `~/.zshrc`。请将持久配置写入用户级 `~/.claude/settings.json`,不要仅依赖 shell 中的 export。EdgeEver 为 ACP 创建临时工作目录,因此开发项目 `.claude/` 目录中的配置不会配置这些会话。 + +将下面的示例合并到已有用户配置中,不要覆盖其他设置。将地址、密钥和模型 ID 替换成网关接受的实际值: + +```json +{ + "model": "opus", + "env": { + "ANTHROPIC_BASE_URL": "https://your-gateway.example", + "ANTHROPIC_API_KEY": "your-api-key", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "your-gateway-full-model-id" + } +} +``` + +该配置选择 `opus` 系列,并将其映射到网关的完整模型 ID。如果选择 Sonnet 或 Haiku,还需配置对应的 `ANTHROPIC_DEFAULT_SONNET_MODEL` 或 `ANTHROPIC_DEFAULT_HAIKU_MODEL`。如果网关要求使用 `ANTHROPIC_AUTH_TOKEN` 而非 `ANTHROPIC_API_KEY`,请遵循网关说明,避免设置相互冲突的凭证。 + +该文件包含凭证,请妥善保管。已有 shell 环境变量也可能影响模型选择;连接组件优先使用 `ANTHROPIC_MODEL`,其次使用 `model` 设置。从终端启动 EdgeEver 时,请检查是否有冲突的 export。 + +在 EdgeEver 的“设置 → 模型与代理”中选择“Claude Code”,重新检查连接,再发送一条简短消息确认网关实际接受配置的模型。连接检查成功不能验证模型权限、网关可用性或计费额度。 + +适配器明确报告没有凭证时,EdgeEver 显示“未登录 / 缺少凭证”。适配器声明支持认证状态报告但没有返回状态时,EdgeEver 提示无法验证认证状态。没有该扩展的旧连接组件保留基于会话创建的连接检查方式。 + +适配器 stderr 经过限量、脱敏后,以 `acp.adapter-stderr` 事件写入 `desktop.log`。macOS 默认位置为 `~/Library/Application Support/EdgeEver/logs/desktop.log`;自定义桌面数据目录会改变此位置。分享前请检查日志,不要分享配置文件或凭证。 + +参考:[Claude Code 配置](https://code.claude.com/docs/en/settings)、[模型配置](https://code.claude.com/docs/en/model-config)。 diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index 6eea2013..53ef1f90 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -1737,6 +1737,10 @@ export const enUS = { install: "Install connector", installHint: "When a local agent is detected, its connector downloads from the official source. This may exceed 100 MiB; updates are checked automatically.", claudeCodeNotFound: "Claude Code ACP connector not found", + claudeCodeCredentialsRequired: "Not signed in / missing credentials", + claudeCodeLoginHint: "Claude Code credentials are missing. Sign in to Claude Code in a terminal, or configure gateway credentials in ~/.claude/settings.json; apps launched from Dock do not read ~/.zshrc. For a custom gateway, configure ANTHROPIC_BASE_URL and map opus to its full model ID with ANTHROPIC_DEFAULT_OPUS_MODEL, then check the connection again.", + identityStatusNotReported: "Authentication status not reported", + identityStatusUnavailable: "The agent did not report its authentication status. Check its configuration and desktop.log, then try again.", claudeCodeMissingHint: "Click “Install connector” to let EdgeEver install it and check the connection.", openClawNotFound: "OpenClaw CLI not found", openClawMissingHint: "Install OpenClaw CLI and start its Gateway, then check the connection.", diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index 7d507a93..4ce54c18 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -1726,6 +1726,10 @@ export const ja = { install: "接続コンポーネントをインストール", installHint: "ローカルのエージェントを検出すると、公式配布元からダウンロードします。100 MiB を超える場合があり、インストール後は更新を自動確認します。", claudeCodeNotFound: "Claude Code ACP 接続コンポーネントが見つかりません", + claudeCodeCredentialsRequired: "未ログイン / 認証情報がありません", + claudeCodeLoginHint: "Claude Code の認証情報がありません。ターミナルで Claude Code にログインするか、~/.claude/settings.json にゲートウェイの認証情報を設定してください。Dock から起動したアプリは ~/.zshrc を読み込みません。カスタムゲートウェイでは ANTHROPIC_BASE_URL を設定し、ANTHROPIC_DEFAULT_OPUS_MODEL で opus を完全なモデル ID に対応させてから、接続を再確認してください。", + identityStatusNotReported: "認証状態が報告されていません", + identityStatusUnavailable: "Agent が認証状態を報告しませんでした。設定と desktop.log を確認して再試行してください。", claudeCodeMissingHint: "「接続コンポーネントをインストール」をクリックすると、EdgeEver がインストールして接続を確認します。", openClawNotFound: "OpenClaw CLI が見つかりません", openClawMissingHint: "OpenClaw CLI をインストールして Gateway を起動し、接続を確認してください。", diff --git a/packages/shared/src/i18n/pl.ts b/packages/shared/src/i18n/pl.ts index 5a31ee74..0dc06146 100644 --- a/packages/shared/src/i18n/pl.ts +++ b/packages/shared/src/i18n/pl.ts @@ -1726,6 +1726,10 @@ export const pl = { install: "Zainstaluj konektor", installHint: "Po wykryciu lokalnego agenta jego konektor jest pobierany z oficjalnego źródła. Może zajmować ponad 100 MiB; aktualizacje są sprawdzane automatycznie.", claudeCodeNotFound: "Nie znaleziono konektora Claude Code ACP", + claudeCodeCredentialsRequired: "Brak logowania / danych uwierzytelniających", + claudeCodeLoginHint: "Brak danych uwierzytelniających Claude Code. Zaloguj się do Claude Code w terminalu lub skonfiguruj dane bramy w ~/.claude/settings.json; aplikacje uruchamiane z Docka nie odczytują ~/.zshrc. Dla własnej bramy ustaw ANTHROPIC_BASE_URL i przypisz pełny identyfikator modelu do opus przez ANTHROPIC_DEFAULT_OPUS_MODEL, a następnie sprawdź połączenie ponownie.", + identityStatusNotReported: "Nie zgłoszono stanu uwierzytelnienia", + identityStatusUnavailable: "Agent nie zgłosił stanu uwierzytelnienia. Sprawdź konfigurację i desktop.log, a następnie spróbuj ponownie.", claudeCodeMissingHint: "Kliknij „Zainstaluj konektor”, aby EdgeEver zainstalował go i sprawdził połączenie.", openClawNotFound: "Nie znaleziono OpenClaw CLI", openClawMissingHint: "Zainstaluj OpenClaw CLI i uruchom Gateway, a następnie sprawdź połączenie.", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index c55a8e37..ececd957 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -1737,6 +1737,10 @@ export const zhCN = { install: "安装连接组件", installHint: "检测到本机 Agent 后会从官方来源下载,可能超过 100 MiB;安装后会自动检查更新。", claudeCodeNotFound: "未找到 Claude Code ACP 连接组件", + claudeCodeCredentialsRequired: "未登录 / 缺少凭证", + claudeCodeLoginHint: "Claude Code 缺少凭证。请在终端完成 Claude Code 登录,或在 ~/.claude/settings.json 配置网关凭证;从 Dock 启动的应用不会读取 ~/.zshrc。自建网关还需配置 ANTHROPIC_BASE_URL,并用 ANTHROPIC_DEFAULT_OPUS_MODEL 将 opus 映射到完整模型 ID,然后重新检查连接。", + identityStatusNotReported: "未报告认证状态", + identityStatusUnavailable: "Agent 未报告认证状态。请检查配置和 desktop.log 后重试。", claudeCodeMissingHint: "点击“安装连接组件”,EdgeEver 会自动安装并检查连接。", openClawNotFound: "未找到 OpenClaw CLI", openClawMissingHint: "请先安装 OpenClaw CLI 并启动 Gateway,再检查连接。",