fix(desktop): verify ACP authentication status and retain diagnostics

Handle asynchronous authentication identity notifications, reject missing credentials before prompting, and preserve sanitized adapter stderr. Document Claude gateway model mapping and add regression coverage for issue #531.
This commit is contained in:
tianma-if committed 2026-10-12 22:31:21 +08:00
1 parent e66b0d707e
commit 28a8792e1a
11 files changed
+253 -12

No files matched your search

+74 -9
View File
@@ -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 });
}
+91 -3
View File
@@ -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 {
+1
View File
@@ -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"),
@@ -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);
@@ -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")}
</p>
) : null}
{shown?.detail === "identity_status_unavailable" ? (
<p>{t("aiAssistant.agentSource.identityStatusUnavailable")}</p>
) : null}
{shown?.state === "needs_login" && adapterId === "claudeCode" ? (
<p>{t("aiAssistant.agentSource.claudeCodeLoginHint")}</p>
) : null}
{shown?.state === "needs_login" && adapterId === "piAgent" ? (
<p>{t("aiAssistant.agentSource.piAgentLoginHint")}</p>
) : null}
+30
View File
@@ -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).
+30
View File
@@ -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)。
+4
View File
@@ -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.",
+4
View File
@@ -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 を起動し、接続を確認してください。",
+4
View File
@@ -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.",
+4
View File
@@ -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,再检查连接。",