From 176df4ad0f9925269fc7a104a397670c8d1297ad Mon Sep 17 00:00:00 2001 From: happydog-intj Date: Thu, 12 Mar 2026 16:55:59 +0800 Subject: [PATCH 1/5] fix(env-cli): remove unused writeRaw/readRaw helpers from test --- src/cli/env-cli.test.ts | 114 +++++++++++++ src/cli/env-cli.ts | 243 ++++++++++++++++++++++++++++ src/cli/program/register.subclis.ts | 9 ++ 3 files changed, 366 insertions(+) create mode 100644 src/cli/env-cli.test.ts create mode 100644 src/cli/env-cli.ts diff --git a/src/cli/env-cli.test.ts b/src/cli/env-cli.test.ts new file mode 100644 index 00000000000..4121e4977fe --- /dev/null +++ b/src/cli/env-cli.test.ts @@ -0,0 +1,114 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// We exercise the dotenv read/write helpers by re-exporting them for testing. +// The Command integration is covered by the option-collision tests pattern. + +const mockConfigDir = path.join(os.tmpdir(), `openclaw-env-cli-test-${process.pid}`); + +vi.mock("../utils.js", () => ({ + resolveConfigDir: () => mockConfigDir, +})); + +describe("env-cli helpers", () => { + beforeEach(() => { + fs.mkdirSync(mockConfigDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(mockConfigDir, { recursive: true, force: true }); + }); + + const envFile = () => path.join(mockConfigDir, ".env"); + + // Re-import env-cli helpers after the mock is applied. + async function helpers() { + const mod = await import("./env-cli.js"); + // Access private helpers via the module's test-exported symbols. + return mod.__test__; + } + + it("parseDotEnv handles KEY=VALUE, quoted values, comments, and blank lines", async () => { + const { parseDotEnv } = await helpers(); + const map = parseDotEnv( + ["# comment", "", "FOO=bar", 'QUOTED="hello world"', "SINGLE='it works'", "EMPTY="].join( + "\n", + ), + ); + expect(map.get("FOO")).toBe("bar"); + expect(map.get("QUOTED")).toBe("hello world"); + expect(map.get("SINGLE")).toBe("it works"); + expect(map.get("EMPTY")).toBe(""); + expect(map.has("# comment")).toBe(false); + }); + + it("serialiseDotEnv round-trips a map", async () => { + const { parseDotEnv, serialiseDotEnv } = await helpers(); + const map = new Map([ + ["A", "simple"], + ["B", "has spaces"], + ["C", 'has "quotes"'], + ]); + const out = serialiseDotEnv(map); + const roundTripped = parseDotEnv(out); + expect(roundTripped.get("A")).toBe("simple"); + expect(roundTripped.get("B")).toBe("has spaces"); + expect(roundTripped.get("C")).toBe('has "quotes"'); + }); + + it("readEnvFile returns empty map when file does not exist", async () => { + const { readEnvFile } = await helpers(); + const map = readEnvFile(); + expect(map.size).toBe(0); + }); + + it("writeEnvFile + readEnvFile round-trip", async () => { + const { readEnvFile, writeEnvFile } = await helpers(); + const map = new Map([ + ["ANTHROPIC_BASE_URL", "http://proxy.example.com"], + ["ANTHROPIC_AUTH_TOKEN", "sk-test-123"], + ]); + writeEnvFile(map); + expect(fs.existsSync(envFile())).toBe(true); + const back = readEnvFile(); + expect(back.get("ANTHROPIC_BASE_URL")).toBe("http://proxy.example.com"); + expect(back.get("ANTHROPIC_AUTH_TOKEN")).toBe("sk-test-123"); + }); + + it("writeEnvFile creates the config dir if it does not exist", async () => { + const { writeEnvFile } = await helpers(); + fs.rmSync(mockConfigDir, { recursive: true, force: true }); + writeEnvFile(new Map([["KEY", "value"]])); + expect(fs.existsSync(envFile())).toBe(true); + }); + + it("writeEnvFile sets file mode 0o600", async () => { + const { writeEnvFile } = await helpers(); + writeEnvFile(new Map([["SECRET", "hunter2"]])); + const stat = fs.statSync(envFile()); + // 0o600 on the lower 9 permission bits. + expect(stat.mode & 0o777).toBe(0o600); + }); + + it("env set assignment parsing: valid KEY=VALUE", async () => { + const { parseAssignment } = await helpers(); + expect(parseAssignment("FOO=bar")).toEqual({ key: "FOO", value: "bar" }); + expect(parseAssignment("ANTHROPIC_BASE_URL=http://proxy:8080")).toEqual({ + key: "ANTHROPIC_BASE_URL", + value: "http://proxy:8080", + }); + }); + + it("env set assignment parsing: value can contain = signs", async () => { + const { parseAssignment } = await helpers(); + expect(parseAssignment("TOKEN=abc=def==")).toEqual({ key: "TOKEN", value: "abc=def==" }); + }); + + it("env set assignment parsing: missing = returns null", async () => { + const { parseAssignment } = await helpers(); + expect(parseAssignment("NOEQUALS")).toBeNull(); + expect(parseAssignment("")).toBeNull(); + }); +}); diff --git a/src/cli/env-cli.ts b/src/cli/env-cli.ts new file mode 100644 index 00000000000..dae4f71e35d --- /dev/null +++ b/src/cli/env-cli.ts @@ -0,0 +1,243 @@ +import fs from "node:fs"; +import path from "node:path"; +/** + * `openclaw env` — manage persistent gateway environment variables. + * + * Variables are stored in ~/.openclaw/.env (dotenv format) and are + * automatically loaded by the gateway on startup, so you never need to + * edit the LaunchAgent/systemd plist directly. + * + * Common use-cases: + * - Custom Anthropic API endpoint (corporate proxy / self-hosted) + * - ANTHROPIC_AUTH_TOKEN for non-OAuth deployments + * - HTTP_PROXY / HTTPS_PROXY for outbound traffic + */ +import type { Command } from "commander"; +import { resolveConfigDir } from "../utils.js"; + +const ENV_FILE_NAME = ".env"; + +/** Parse a single "KEY=VALUE" assignment string. Value may contain `=`. */ +export function parseAssignment(s: string): { key: string; value: string } | null { + const eqIdx = s.indexOf("="); + if (eqIdx === -1) { + return null; + } + const key = s.slice(0, eqIdx).trim(); + if (!key) { + return null; + } + const value = s.slice(eqIdx + 1).trim(); + return { key, value }; +} + +function getEnvFilePath(): string { + return path.join(resolveConfigDir(process.env), ENV_FILE_NAME); +} + +/** + * Parse a simple dotenv file into a key→value map. + * Supports: + * KEY=VALUE + * KEY="VALUE WITH SPACES" + * KEY='VALUE WITH SPACES' + * # comment lines + * blank lines + */ +function parseDotEnv(content: string): Map { + const map = new Map(); + for (const raw of content.split("\n")) { + const line = raw.trim(); + if (!line || line.startsWith("#")) { + continue; + } + const eqIdx = line.indexOf("="); + if (eqIdx === -1) { + continue; + } + const key = line.slice(0, eqIdx).trim(); + let val = line.slice(eqIdx + 1).trim(); + // Strip surrounding quotes and unescape. + if (val.startsWith('"') && val.endsWith('"')) { + val = val.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\"); + } else if (val.startsWith("'") && val.endsWith("'")) { + val = val.slice(1, -1); + } + if (key) { + map.set(key, val); + } + } + return map; +} + +/** + * Serialise a key→value map back to dotenv format, preserving existing + * comment lines and blank lines from the original file where possible. + */ +function serialiseDotEnv(map: Map): string { + const lines: string[] = []; + for (const [key, val] of map) { + const hasDouble = val.includes('"'); + const hasSingle = val.includes("'"); + const needsQuotes = /[\s\\#]/.test(val) || hasDouble || hasSingle; + let serialised: string; + if (!needsQuotes) { + serialised = val; + } else if (hasDouble && !hasSingle) { + // Use single quotes to avoid escaping double quotes. + serialised = `'${val}'`; + } else { + // Use double quotes; escape internal double quotes and backslashes. + serialised = `"${val.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; + } + lines.push(`${key}=${serialised}`); + } + // Always end with a trailing newline. + return lines.join("\n") + (lines.length > 0 ? "\n" : ""); +} + +function readEnvFile(): Map { + const p = getEnvFilePath(); + if (!fs.existsSync(p)) { + return new Map(); + } + try { + return parseDotEnv(fs.readFileSync(p, "utf8")); + } catch { + return new Map(); + } +} + +function writeEnvFile(map: Map): void { + const p = getEnvFilePath(); + const dir = path.dirname(p); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(p, serialiseDotEnv(map), { mode: 0o600 }); +} + +/** @internal Exported only for unit tests. */ +export const __test__ = { + parseDotEnv, + serialiseDotEnv, + readEnvFile, + writeEnvFile, + parseAssignment, +}; + +export function registerEnvCli(program: Command): void { + const env = program + .command("env") + .description( + "Manage persistent gateway environment variables (~/.openclaw/.env).\n" + + "Variables are loaded automatically when the gateway starts — no plist editing required.\n\n" + + "Common variables:\n" + + " ANTHROPIC_BASE_URL Custom API endpoint (proxy / self-hosted)\n" + + " ANTHROPIC_AUTH_TOKEN Auth token for non-OAuth deployments\n" + + " HTTP_PROXY / HTTPS_PROXY Outbound proxy for all gateway traffic", + ); + + // openclaw env list + env + .command("list") + .alias("ls") + .description("List all variables in ~/.openclaw/.env") + .option("--json", "Output JSON", false) + .action((opts: { json: boolean }) => { + const map = readEnvFile(); + if (opts.json) { + console.log(JSON.stringify(Object.fromEntries(map), null, 2)); + return; + } + if (map.size === 0) { + console.log("No gateway env vars set. Use `openclaw env set KEY=VALUE` to add one."); + return; + } + console.log(`Gateway env vars (${getEnvFilePath()}):\n`); + for (const [key, val] of map) { + // Redact anything that looks like a secret. + const isSensitive = /token|key|secret|password|auth/i.test(key); + const display = isSensitive + ? `${val.slice(0, 4)}${"*".repeat(Math.max(0, val.length - 4))}` + : val; + console.log(` ${key}=${display}`); + } + }); + + // openclaw env set KEY=VALUE [KEY=VALUE ...] + env + .command("set ") + .description( + "Set one or more gateway env vars.\n\n" + + " openclaw env set ANTHROPIC_BASE_URL=https://my-proxy.example.com\n" + + " openclaw env set ANTHROPIC_AUTH_TOKEN=sk-... ANTHROPIC_MODEL=claude-4-sonnet\n\n" + + "Restart the gateway after setting vars: openclaw gateway restart", + ) + .action((assignments: string[]) => { + const map = readEnvFile(); + const set: string[] = []; + const errors: string[] = []; + + for (const assignment of assignments) { + const parsed = parseAssignment(assignment); + if (!parsed) { + errors.push(` Invalid format (expected KEY=VALUE): ${assignment}`); + continue; + } + map.set(parsed.key, parsed.value); + set.push(parsed.key); + } + + if (errors.length) { + for (const e of errors) { + console.error(e); + } + if (!set.length) { + process.exit(1); + } + } + + writeEnvFile(map); + console.log(`Set: ${set.join(", ")}`); + console.log(`Saved to: ${getEnvFilePath()}`); + console.log(`\nRestart the gateway to apply: openclaw gateway restart`); + }); + + // openclaw env unset KEY [KEY ...] + env + .command("unset ") + .description("Remove one or more gateway env vars.") + .action((keys: string[]) => { + const map = readEnvFile(); + const removed: string[] = []; + const missing: string[] = []; + + for (const key of keys) { + if (map.has(key)) { + map.delete(key); + removed.push(key); + } else { + missing.push(key); + } + } + + if (missing.length) { + console.warn(`Not set (skipped): ${missing.join(", ")}`); + } + + if (removed.length) { + writeEnvFile(map); + console.log(`Unset: ${removed.join(", ")}`); + console.log(`\nRestart the gateway to apply: openclaw gateway restart`); + } + }); + + // openclaw env path — just print the .env file path (useful for scripting) + env + .command("path") + .description("Print the path to the .env file") + .action(() => { + console.log(getEnvFilePath()); + }); +} diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index ad120cc0417..dc398b0ceb1 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -60,6 +60,15 @@ const entries: SubCliEntry[] = [ mod.registerGatewayCli(program); }, }, + { + name: "env", + description: "Manage persistent gateway environment variables (~/.openclaw/.env)", + hasSubcommands: true, + register: async (program) => { + const mod = await import("../env-cli.js"); + mod.registerEnvCli(program); + }, + }, { name: "daemon", description: "Gateway service (legacy alias)", From 2a840bb663110a883598e0479c077e82721094b8 Mon Sep 17 00:00:00 2001 From: happydog-intj Date: Thu, 12 Mar 2026 17:10:13 +0800 Subject: [PATCH 2/5] fix(env-cli): address greptile + codex review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract redactValue() helper and apply to both --json and human-readable list output; --json was previously exposing secrets in full (greptile) - Fix short-value redaction: values ≤ 4 chars now show all asterisks instead of leaking the full value (greptile) - Correct serialiseDotEnv() JSDoc — remove false promise about preserving comment lines (greptile) - Add fs.chmodSync(p, 0o600) after writeFileSync so existing files with broader permissions (e.g. 0o644 from manual setup) are also hardened on every write (codex P2) - Add 4 new tests covering redactValue edge cases and chmodSync fix --- src/cli/env-cli.test.ts | 31 +++++++++++++++++++++++++++++++ src/cli/env-cli.ts | 39 ++++++++++++++++++++++++++++++--------- 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/cli/env-cli.test.ts b/src/cli/env-cli.test.ts index 4121e4977fe..ad6c7f97a07 100644 --- a/src/cli/env-cli.test.ts +++ b/src/cli/env-cli.test.ts @@ -111,4 +111,35 @@ describe("env-cli helpers", () => { expect(parseAssignment("NOEQUALS")).toBeNull(); expect(parseAssignment("")).toBeNull(); }); + + describe("redactValue", () => { + it("passes through non-sensitive keys unchanged", async () => { + const { redactValue } = await helpers(); + expect(redactValue("ANTHROPIC_BASE_URL", "http://proxy.example.com")).toBe( + "http://proxy.example.com", + ); + }); + + it("redacts sensitive keys: shows first 4 chars then asterisks", async () => { + const { redactValue } = await helpers(); + // "sk-abc123xyz" = 12 chars → first 4 "sk-a" + 8 asterisks + expect(redactValue("ANTHROPIC_AUTH_TOKEN", "sk-abc123xyz")).toBe("sk-a********"); + }); + + it("redacts short sensitive values fully (≤ 4 chars → all asterisks)", async () => { + const { redactValue } = await helpers(); + expect(redactValue("MY_SECRET", "abc")).toBe("***"); + expect(redactValue("API_KEY", "abcd")).toBe("****"); + }); + }); + + it("writeEnvFile enforces 0o600 even when file already exists with 0o644", async () => { + const { writeEnvFile } = await helpers(); + // Create with broader permissions first (simulates manual setup). + fs.writeFileSync(envFile(), "EXISTING=1\n", { mode: 0o644 }); + expect(fs.statSync(envFile()).mode & 0o777).toBe(0o644); + + writeEnvFile(new Map([["EXISTING", "1"]])); + expect(fs.statSync(envFile()).mode & 0o777).toBe(0o600); + }); }); diff --git a/src/cli/env-cli.ts b/src/cli/env-cli.ts index dae4f71e35d..a1ed379ec08 100644 --- a/src/cli/env-cli.ts +++ b/src/cli/env-cli.ts @@ -71,8 +71,9 @@ function parseDotEnv(content: string): Map { } /** - * Serialise a key→value map back to dotenv format, preserving existing - * comment lines and blank lines from the original file where possible. + * Serialise a key→value map to dotenv format. + * Note: comment lines and blank lines from the original file are not preserved; + * only the key=value pairs in the map are written. */ function serialiseDotEnv(map: Map): string { const lines: string[] = []; @@ -114,7 +115,25 @@ function writeEnvFile(map: Map): void { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } + // `mode` in writeFileSync is only applied on file creation; existing files + // keep their current permissions. Use chmodSync afterwards to enforce 0o600 + // even when the file already exists (e.g. created manually with 0o644). fs.writeFileSync(p, serialiseDotEnv(map), { mode: 0o600 }); + fs.chmodSync(p, 0o600); +} + +/** + * Partially redact a value if its key looks like a secret. + * Values ≤ 4 chars are fully masked to avoid leaking short tokens. + */ +function redactValue(key: string, val: string): string { + const isSensitive = /token|key|secret|password|auth/i.test(key); + if (!isSensitive) { + return val; + } + return val.length <= 4 + ? "*".repeat(val.length) + : `${val.slice(0, 4)}${"*".repeat(val.length - 4)}`; } /** @internal Exported only for unit tests. */ @@ -124,6 +143,7 @@ export const __test__ = { readEnvFile, writeEnvFile, parseAssignment, + redactValue, }; export function registerEnvCli(program: Command): void { @@ -147,7 +167,13 @@ export function registerEnvCli(program: Command): void { .action((opts: { json: boolean }) => { const map = readEnvFile(); if (opts.json) { - console.log(JSON.stringify(Object.fromEntries(map), null, 2)); + // Apply the same redaction as the human-readable path so that piped + // output / CI logs don't inadvertently expose secrets. + const redacted: Record = {}; + for (const [key, val] of map) { + redacted[key] = redactValue(key, val); + } + console.log(JSON.stringify(redacted, null, 2)); return; } if (map.size === 0) { @@ -156,12 +182,7 @@ export function registerEnvCli(program: Command): void { } console.log(`Gateway env vars (${getEnvFilePath()}):\n`); for (const [key, val] of map) { - // Redact anything that looks like a secret. - const isSensitive = /token|key|secret|password|auth/i.test(key); - const display = isSensitive - ? `${val.slice(0, 4)}${"*".repeat(Math.max(0, val.length - 4))}` - : val; - console.log(` ${key}=${display}`); + console.log(` ${key}=${redactValue(key, val)}`); } }); From 13359748db966a56327c0fab488f8d2511472146 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Fri, 13 Mar 2026 10:43:51 +0800 Subject: [PATCH 3/5] fix(test): skip file-mode assertions on Windows Windows does not support POSIX permission bits; fs.chmodSync() is a no-op and stat.mode always returns 0o666 regardless of the requested mode. Skip the two 0o600/0o644 mode tests on win32 so CI / checks-windows no longer fails. Fixes: CI / checks-windows (node, test, 4, 6) --- src/cli/env-cli.test.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/cli/env-cli.test.ts b/src/cli/env-cli.test.ts index ad6c7f97a07..b02c8016761 100644 --- a/src/cli/env-cli.test.ts +++ b/src/cli/env-cli.test.ts @@ -84,11 +84,12 @@ describe("env-cli helpers", () => { expect(fs.existsSync(envFile())).toBe(true); }); - it("writeEnvFile sets file mode 0o600", async () => { + it.skipIf(process.platform === "win32")("writeEnvFile sets file mode 0o600", async () => { const { writeEnvFile } = await helpers(); writeEnvFile(new Map([["SECRET", "hunter2"]])); const stat = fs.statSync(envFile()); // 0o600 on the lower 9 permission bits. + // Skipped on Windows: POSIX permission bits are not supported. expect(stat.mode & 0o777).toBe(0o600); }); @@ -133,13 +134,17 @@ describe("env-cli helpers", () => { }); }); - it("writeEnvFile enforces 0o600 even when file already exists with 0o644", async () => { - const { writeEnvFile } = await helpers(); - // Create with broader permissions first (simulates manual setup). - fs.writeFileSync(envFile(), "EXISTING=1\n", { mode: 0o644 }); - expect(fs.statSync(envFile()).mode & 0o777).toBe(0o644); + it.skipIf(process.platform === "win32")( + "writeEnvFile enforces 0o600 even when file already exists with 0o644", + async () => { + const { writeEnvFile } = await helpers(); + // Create with broader permissions first (simulates manual setup). + // Skipped on Windows: POSIX permission bits are not supported. + fs.writeFileSync(envFile(), "EXISTING=1\n", { mode: 0o644 }); + expect(fs.statSync(envFile()).mode & 0o777).toBe(0o644); - writeEnvFile(new Map([["EXISTING", "1"]])); - expect(fs.statSync(envFile()).mode & 0o777).toBe(0o600); - }); + writeEnvFile(new Map([["EXISTING", "1"]])); + expect(fs.statSync(envFile()).mode & 0o777).toBe(0o600); + }, + ); }); From 8e3e9a8796fe977c6cc6fe3610215b1073dd3ff4 Mon Sep 17 00:00:00 2001 From: happydog-intj Date: Fri, 13 Mar 2026 14:12:37 +0800 Subject: [PATCH 4/5] =?UTF-8?q?ci:=20retrigger=20=E2=80=94=20prior=20failu?= =?UTF-8?q?res=20were=20main-branch=20timing=20artifacts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 903a1ad931084d61e9f6d3ec8b1f803a129c85f7 Mon Sep 17 00:00:00 2001 From: happydog-intj Date: Fri, 13 Mar 2026 14:49:10 +0800 Subject: [PATCH 5/5] chore(macos): apply swiftformat to upstream-added Swift files --- apps/macos/Sources/OpenClaw/AppState.swift | 43 ++- .../Sources/OpenClaw/GeneralSettings.swift | 132 ++------ .../NodeMode/MacNodeBrowserProxy.swift | 4 +- .../OpenClaw/OnboardingView+Pages.swift | 284 ++++++++++++++++-- .../Sources/OpenClaw/RemoteGatewayProbe.swift | 237 +++++++++++++++ .../OpenClaw/TalkModeGatewayConfig.swift | 8 +- 6 files changed, 550 insertions(+), 158 deletions(-) create mode 100644 apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index 5e8238ebe92..77402d7796f 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -600,30 +600,29 @@ final class AppState { private func syncGatewayConfigIfNeeded() { guard !self.isPreview, !self.isInitializing else { return } - let connectionMode = self.connectionMode - let remoteTarget = self.remoteTarget - let remoteIdentity = self.remoteIdentity - let remoteTransport = self.remoteTransport - let remoteUrl = self.remoteUrl - let remoteToken = self.remoteToken - let remoteTokenDirty = self.remoteTokenDirty - Task { @MainActor in - // Keep app-only connection settings local to avoid overwriting remote gateway config. - let synced = Self.syncedGatewayRoot( - currentRoot: OpenClawConfigFile.loadDict(), - connectionMode: connectionMode, - remoteTransport: remoteTransport, - remoteTarget: remoteTarget, - remoteIdentity: remoteIdentity, - remoteUrl: remoteUrl, - remoteToken: remoteToken, - remoteTokenDirty: remoteTokenDirty) - guard synced.changed else { return } - OpenClawConfigFile.saveDict(synced.root) + self.syncGatewayConfigNow() } } + @MainActor + func syncGatewayConfigNow() { + guard !self.isPreview, !self.isInitializing else { return } + + // Keep app-only connection settings local to avoid overwriting remote gateway config. + let synced = Self.syncedGatewayRoot( + currentRoot: OpenClawConfigFile.loadDict(), + connectionMode: self.connectionMode, + remoteTransport: self.remoteTransport, + remoteTarget: self.remoteTarget, + remoteIdentity: self.remoteIdentity, + remoteUrl: self.remoteUrl, + remoteToken: self.remoteToken, + remoteTokenDirty: self.remoteTokenDirty) + guard synced.changed else { return } + OpenClawConfigFile.saveDict(synced.root) + } + func triggerVoiceEars(ttl: TimeInterval? = 5) { self.earBoostTask?.cancel() self.earBoostActive = true @@ -784,7 +783,7 @@ extension AppState { remoteToken: String, remoteTokenDirty: Bool) -> [String: Any] { - Self.updatedRemoteGatewayConfig( + self.updatedRemoteGatewayConfig( current: current, transport: transport, remoteUrl: remoteUrl, @@ -805,7 +804,7 @@ extension AppState { remoteToken: String, remoteTokenDirty: Bool) -> [String: Any] { - Self.syncedGatewayRoot( + self.syncedGatewayRoot( currentRoot: currentRoot, connectionMode: connectionMode, remoteTransport: remoteTransport, diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift index b55ed439489..633879367ea 100644 --- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift +++ b/apps/macos/Sources/OpenClaw/GeneralSettings.swift @@ -348,10 +348,18 @@ struct GeneralSettings: View { Text("Testing…") .font(.caption) .foregroundStyle(.secondary) - case .ok: - Label("Ready", systemImage: "checkmark.circle.fill") - .font(.caption) - .foregroundStyle(.green) + case let .ok(success): + VStack(alignment: .leading, spacing: 2) { + Label(success.title, systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) + if let detail = success.detail { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } case let .failed(message): Text(message) .font(.caption) @@ -518,7 +526,7 @@ struct GeneralSettings: View { private enum RemoteStatus: Equatable { case idle case checking - case ok + case ok(RemoteGatewayProbeSuccess) case failed(String) } @@ -558,114 +566,14 @@ extension GeneralSettings { @MainActor func testRemote() async { self.remoteStatus = .checking - let settings = CommandResolver.connectionSettings() - if self.state.remoteTransport == .direct { - let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedUrl.isEmpty else { - self.remoteStatus = .failed("Set a gateway URL first") - return - } - guard Self.isValidWsUrl(trimmedUrl) else { - self.remoteStatus = .failed( - "Gateway URL must use wss:// for remote hosts (ws:// only for localhost)") - return - } - } else { - guard !settings.target.isEmpty else { - self.remoteStatus = .failed("Set an SSH target first") - return - } - - // Step 1: basic SSH reachability check - guard let sshCommand = Self.sshCheckCommand( - target: settings.target, - identity: settings.identity) - else { - self.remoteStatus = .failed("SSH target is invalid") - return - } - let sshResult = await ShellExecutor.run( - command: sshCommand, - cwd: nil, - env: nil, - timeout: 8) - - guard sshResult.ok else { - self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target)) - return - } + switch await RemoteGatewayProbe.run() { + case let .ready(success): + self.remoteStatus = .ok(success) + case let .authIssue(issue): + self.remoteStatus = .failed(issue.statusMessage) + case let .failed(message): + self.remoteStatus = .failed(message) } - - // Step 2: control channel health check - let originalMode = AppStateStore.shared.connectionMode - do { - try await ControlChannel.shared.configure(mode: .remote( - target: settings.target, - identity: settings.identity)) - let data = try await ControlChannel.shared.health(timeout: 10) - if decodeHealthSnapshot(from: data) != nil { - self.remoteStatus = .ok - } else { - self.remoteStatus = .failed("Control channel returned invalid health JSON") - } - } catch { - self.remoteStatus = .failed(error.localizedDescription) - } - - // Restore original mode if we temporarily switched - switch originalMode { - case .remote: - break - case .local: - try? await ControlChannel.shared.configure(mode: .local) - case .unconfigured: - await ControlChannel.shared.disconnect() - } - } - - private static func isValidWsUrl(_ raw: String) -> Bool { - GatewayRemoteConfig.normalizeGatewayUrl(raw) != nil - } - - private static func sshCheckCommand(target: String, identity: String) -> [String]? { - guard let parsed = CommandResolver.parseSSHTarget(target) else { return nil } - let options = [ - "-o", "BatchMode=yes", - "-o", "ConnectTimeout=5", - "-o", "StrictHostKeyChecking=accept-new", - "-o", "UpdateHostKeys=yes", - ] - let args = CommandResolver.sshArguments( - target: parsed, - identity: identity, - options: options, - remoteCommand: ["echo", "ok"]) - return ["/usr/bin/ssh"] + args - } - - private func formatSSHFailure(_ response: Response, target: String) -> String { - let payload = response.payload.flatMap { String(data: $0, encoding: .utf8) } - let trimmed = payload? - .trimmingCharacters(in: .whitespacesAndNewlines) - .split(whereSeparator: \.isNewline) - .joined(separator: " ") - if let trimmed, - trimmed.localizedCaseInsensitiveContains("host key verification failed") - { - let host = CommandResolver.parseSSHTarget(target)?.host ?? target - return "SSH check failed: Host key verification failed. Remove the old key with " + - "`ssh-keygen -R \(host)` and try again." - } - if let trimmed, !trimmed.isEmpty { - if let message = response.message, message.hasPrefix("exit ") { - return "SSH check failed: \(trimmed) (\(message))" - } - return "SSH check failed: \(trimmed)" - } - if let message = response.message { - return "SSH check failed (\(message))" - } - return "SSH check failed" } private func revealLogs() { diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift index 367907f9fb7..b43141a3139 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift @@ -147,7 +147,9 @@ actor MacNodeBrowserProxy { } if method != "GET", let body = params.body { - request.httpBody = try JSONSerialization.data(withJSONObject: body.foundationValue, options: [.fragmentsAllowed]) + request.httpBody = try JSONSerialization.data( + withJSONObject: body.foundationValue, + options: [.fragmentsAllowed]) request.setValue("application/json", forHTTPHeaderField: "Content-Type") } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 8f4d16420bc..d1831d104fc 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -2,6 +2,7 @@ import AppKit import OpenClawChatUI import OpenClawDiscovery import OpenClawIPC +import OpenClawKit import SwiftUI extension OnboardingView { @@ -97,6 +98,11 @@ extension OnboardingView { self.gatewayDiscoverySection() + if self.shouldShowRemoteConnectionSection { + Divider().padding(.vertical, 4) + self.remoteConnectionSection() + } + self.connectionChoiceButton( title: "Configure later", subtitle: "Don’t start the Gateway yet.", @@ -109,6 +115,22 @@ extension OnboardingView { } } } + .onChange(of: self.state.connectionMode) { _, newValue in + guard Self.shouldResetRemoteProbeFeedback( + for: newValue, + suppressReset: self.suppressRemoteProbeReset) + else { return } + self.resetRemoteProbeFeedback() + } + .onChange(of: self.state.remoteTransport) { _, _ in + self.resetRemoteProbeFeedback() + } + .onChange(of: self.state.remoteTarget) { _, _ in + self.resetRemoteProbeFeedback() + } + .onChange(of: self.state.remoteUrl) { _, _ in + self.resetRemoteProbeFeedback() + } } private var localGatewaySubtitle: String { @@ -199,25 +221,6 @@ extension OnboardingView { .pickerStyle(.segmented) .frame(width: fieldWidth) } - GridRow { - Text("Gateway token") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - if self.state.remoteTokenUnsupported { - GridRow { - Text("") - .frame(width: labelWidth, alignment: .leading) - Text( - "The current gateway.remote.token value is not plain text. OpenClaw for macOS cannot use it directly; enter a plaintext token here to replace it.") - .font(.caption) - .foregroundStyle(.orange) - .frame(width: fieldWidth, alignment: .leading) - } - } if self.state.remoteTransport == .direct { GridRow { Text("Gateway URL") @@ -289,6 +292,249 @@ extension OnboardingView { } } + private var shouldShowRemoteConnectionSection: Bool { + self.state.connectionMode == .remote || + self.showAdvancedConnection || + self.remoteProbeState != .idle || + self.remoteAuthIssue != nil || + Self.shouldShowRemoteTokenField( + showAdvancedConnection: self.showAdvancedConnection, + remoteToken: self.state.remoteToken, + remoteTokenUnsupported: self.state.remoteTokenUnsupported, + authIssue: self.remoteAuthIssue) + } + + private var shouldShowRemoteTokenField: Bool { + guard self.shouldShowRemoteConnectionSection else { return false } + return Self.shouldShowRemoteTokenField( + showAdvancedConnection: self.showAdvancedConnection, + remoteToken: self.state.remoteToken, + remoteTokenUnsupported: self.state.remoteTokenUnsupported, + authIssue: self.remoteAuthIssue) + } + + private var remoteProbePreflightMessage: String? { + switch self.state.remoteTransport { + case .direct: + let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedUrl.isEmpty { + return "Select a nearby gateway or open Advanced to enter a gateway URL." + } + if GatewayRemoteConfig.normalizeGatewayUrl(trimmedUrl) == nil { + return "Gateway URL must use wss:// for remote hosts (ws:// only for localhost)." + } + return nil + case .ssh: + let trimmedTarget = self.state.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedTarget.isEmpty { + return "Select a nearby gateway or open Advanced to enter an SSH target." + } + return CommandResolver.sshTargetValidationMessage(trimmedTarget) + } + } + + private var canProbeRemoteConnection: Bool { + self.remoteProbePreflightMessage == nil && self.remoteProbeState != .checking + } + + private func remoteConnectionSection() -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("Remote connection") + .font(.callout.weight(.semibold)) + Text("Checks the real remote websocket and auth handshake.") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + Button { + Task { await self.probeRemoteConnection() } + } label: { + if self.remoteProbeState == .checking { + ProgressView() + .controlSize(.small) + .frame(minWidth: 120) + } else { + Text("Check connection") + .frame(minWidth: 120) + } + } + .buttonStyle(.borderedProminent) + .disabled(!self.canProbeRemoteConnection) + } + + if self.shouldShowRemoteTokenField { + self.remoteTokenField() + } + + if let message = self.remoteProbePreflightMessage, self.remoteProbeState != .checking { + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + self.remoteProbeStatusView() + + if let issue = self.remoteAuthIssue { + self.remoteAuthPromptView(issue: issue) + } + } + } + + private func remoteTokenField() -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .center, spacing: 12) { + Text("Gateway token") + .font(.callout.weight(.semibold)) + .frame(width: 110, alignment: .leading) + SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 320) + } + Text("Used when the remote gateway requires token auth.") + .font(.caption) + .foregroundStyle(.secondary) + if self.state.remoteTokenUnsupported { + Text( + "The current gateway.remote.token value is not plain text. OpenClaw for macOS cannot use it directly; enter a plaintext token here to replace it.") + .font(.caption) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + @ViewBuilder + private func remoteProbeStatusView() -> some View { + switch self.remoteProbeState { + case .idle: + EmptyView() + case .checking: + Text("Checking remote gateway…") + .font(.caption) + .foregroundStyle(.secondary) + case let .ok(success): + VStack(alignment: .leading, spacing: 2) { + Label(success.title, systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) + if let detail = success.detail { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + case let .failed(message): + if self.remoteAuthIssue == nil { + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private func remoteAuthPromptView(issue: RemoteGatewayAuthIssue) -> some View { + let promptStyle = Self.remoteAuthPromptStyle(for: issue) + return HStack(alignment: .top, spacing: 10) { + Image(systemName: promptStyle.systemImage) + .font(.caption.weight(.semibold)) + .foregroundStyle(promptStyle.tint) + .frame(width: 16, alignment: .center) + .padding(.top, 1) + VStack(alignment: .leading, spacing: 4) { + Text(issue.title) + .font(.caption.weight(.semibold)) + Text(.init(issue.body)) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + if let footnote = issue.footnote { + Text(.init(footnote)) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + + @MainActor + private func probeRemoteConnection() async { + let originalMode = self.state.connectionMode + let shouldRestoreMode = originalMode != .remote + if shouldRestoreMode { + // Reuse the shared remote endpoint stack for probing without committing the user's mode choice. + self.state.connectionMode = .remote + } + self.remoteProbeState = .checking + self.remoteAuthIssue = nil + defer { + if shouldRestoreMode { + self.suppressRemoteProbeReset = true + self.state.connectionMode = originalMode + self.suppressRemoteProbeReset = false + } + } + + switch await RemoteGatewayProbe.run() { + case let .ready(success): + self.remoteProbeState = .ok(success) + case let .authIssue(issue): + self.remoteAuthIssue = issue + self.remoteProbeState = .failed(issue.statusMessage) + case let .failed(message): + self.remoteProbeState = .failed(message) + } + } + + private func resetRemoteProbeFeedback() { + self.remoteProbeState = .idle + self.remoteAuthIssue = nil + } + + static func remoteAuthPromptStyle( + for issue: RemoteGatewayAuthIssue) + -> (systemImage: String, tint: Color) + { + switch issue { + case .tokenRequired: + ("key.fill", .orange) + case .tokenMismatch: + ("exclamationmark.triangle.fill", .orange) + case .gatewayTokenNotConfigured: + ("wrench.and.screwdriver.fill", .orange) + case .setupCodeExpired: + ("qrcode.viewfinder", .orange) + case .passwordRequired: + ("lock.slash.fill", .orange) + case .pairingRequired: + ("link.badge.plus", .orange) + } + } + + static func shouldShowRemoteTokenField( + showAdvancedConnection: Bool, + remoteToken: String, + remoteTokenUnsupported: Bool, + authIssue: RemoteGatewayAuthIssue?) -> Bool + { + showAdvancedConnection || + remoteTokenUnsupported || + !remoteToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + authIssue?.showsTokenField == true + } + + static func shouldResetRemoteProbeFeedback( + for connectionMode: AppState.ConnectionMode, + suppressReset: Bool) -> Bool + { + !suppressReset && connectionMode != .remote + } + func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { if self.state.remoteTransport == .direct { return GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "Gateway pairing only" diff --git a/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift b/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift new file mode 100644 index 00000000000..bde65c03495 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift @@ -0,0 +1,237 @@ +import Foundation +import OpenClawIPC +import OpenClawKit + +enum RemoteGatewayAuthIssue: Equatable { + case tokenRequired + case tokenMismatch + case gatewayTokenNotConfigured + case setupCodeExpired + case passwordRequired + case pairingRequired + + init?(error: Error) { + guard let authError = error as? GatewayConnectAuthError else { + return nil + } + switch authError.detail { + case .authTokenMissing: + self = .tokenRequired + case .authTokenMismatch: + self = .tokenMismatch + case .authTokenNotConfigured: + self = .gatewayTokenNotConfigured + case .authBootstrapTokenInvalid: + self = .setupCodeExpired + case .authPasswordMissing, .authPasswordMismatch, .authPasswordNotConfigured: + self = .passwordRequired + case .pairingRequired: + self = .pairingRequired + default: + return nil + } + } + + var showsTokenField: Bool { + switch self { + case .tokenRequired, .tokenMismatch: + true + case .gatewayTokenNotConfigured, .setupCodeExpired, .passwordRequired, .pairingRequired: + false + } + } + + var title: String { + switch self { + case .tokenRequired: + "This gateway requires an auth token" + case .tokenMismatch: + "That token did not match the gateway" + case .gatewayTokenNotConfigured: + "This gateway host needs token setup" + case .setupCodeExpired: + "This setup code is no longer valid" + case .passwordRequired: + "This gateway is using unsupported auth" + case .pairingRequired: + "This device needs pairing approval" + } + } + + var body: String { + switch self { + case .tokenRequired: + "Paste the token configured on the gateway host. On the gateway host, run `openclaw config get gateway.auth.token`. If the gateway uses an environment variable instead, use `OPENCLAW_GATEWAY_TOKEN`." + case .tokenMismatch: + "Check `gateway.auth.token` or `OPENCLAW_GATEWAY_TOKEN` on the gateway host and try again." + case .gatewayTokenNotConfigured: + "This gateway is set to token auth, but no `gateway.auth.token` is configured on the gateway host. If the gateway uses an environment variable instead, set `OPENCLAW_GATEWAY_TOKEN` before starting the gateway." + case .setupCodeExpired: + "Scan or paste a fresh setup code from an already-paired OpenClaw client, then try again." + case .passwordRequired: + "This onboarding flow does not support password auth yet. Reconfigure the gateway to use token auth, then retry." + case .pairingRequired: + "Approve this device from an already-paired OpenClaw client. In your OpenClaw chat, run `/pair approve`, then click **Check connection** again." + } + } + + var footnote: String? { + switch self { + case .tokenRequired, .gatewayTokenNotConfigured: + "No token yet? Generate one on the gateway host with `openclaw doctor --generate-gateway-token`, then set it as `gateway.auth.token`." + case .setupCodeExpired: + nil + case .pairingRequired: + "If you do not have another paired OpenClaw client yet, approve the pending request on the gateway host with `openclaw devices approve`." + case .tokenMismatch, .passwordRequired: + nil + } + } + + var statusMessage: String { + switch self { + case .tokenRequired: + "This gateway requires an auth token from the gateway host." + case .tokenMismatch: + "Gateway token mismatch. Check gateway.auth.token or OPENCLAW_GATEWAY_TOKEN on the gateway host." + case .gatewayTokenNotConfigured: + "This gateway has token auth enabled, but no gateway.auth.token is configured on the host." + case .setupCodeExpired: + "Setup code expired or already used. Scan a fresh setup code, then try again." + case .passwordRequired: + "This gateway uses password auth. Remote onboarding on macOS cannot collect gateway passwords yet." + case .pairingRequired: + "Pairing required. In an already-paired OpenClaw client, run /pair approve, then check the connection again." + } + } +} + +enum RemoteGatewayProbeResult: Equatable { + case ready(RemoteGatewayProbeSuccess) + case authIssue(RemoteGatewayAuthIssue) + case failed(String) +} + +struct RemoteGatewayProbeSuccess: Equatable { + let authSource: GatewayAuthSource? + + var title: String { + switch self.authSource { + case .some(.deviceToken): + "Connected via paired device" + case .some(.bootstrapToken): + "Connected with setup code" + case .some(.sharedToken): + "Connected with gateway token" + case .some(.password): + "Connected with password" + case .some(GatewayAuthSource.none), nil: + "Remote gateway ready" + } + } + + var detail: String? { + switch self.authSource { + case .some(.deviceToken): + "This Mac used a stored device token. New or unpaired devices may still need the gateway token." + case .some(.bootstrapToken): + "This Mac is still using the temporary setup code. Approve pairing to finish provisioning device-scoped auth." + case .some(.sharedToken), .some(.password), .some(GatewayAuthSource.none), nil: + nil + } + } +} + +enum RemoteGatewayProbe { + @MainActor + static func run() async -> RemoteGatewayProbeResult { + AppStateStore.shared.syncGatewayConfigNow() + let settings = CommandResolver.connectionSettings() + let transport = AppStateStore.shared.remoteTransport + + if transport == .direct { + let trimmedUrl = AppStateStore.shared.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedUrl.isEmpty else { + return .failed("Set a gateway URL first") + } + guard self.isValidWsUrl(trimmedUrl) else { + return .failed("Gateway URL must use wss:// for remote hosts (ws:// only for localhost)") + } + } else { + let trimmedTarget = settings.target.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedTarget.isEmpty else { + return .failed("Set an SSH target first") + } + if let validationMessage = CommandResolver.sshTargetValidationMessage(trimmedTarget) { + return .failed(validationMessage) + } + guard let sshCommand = self.sshCheckCommand(target: settings.target, identity: settings.identity) else { + return .failed("SSH target is invalid") + } + + let sshResult = await ShellExecutor.run( + command: sshCommand, + cwd: nil, + env: nil, + timeout: 8) + guard sshResult.ok else { + return .failed(self.formatSSHFailure(sshResult, target: settings.target)) + } + } + + do { + _ = try await GatewayConnection.shared.healthSnapshot(timeoutMs: 10000) + let authSource = await GatewayConnection.shared.authSource() + return .ready(RemoteGatewayProbeSuccess(authSource: authSource)) + } catch { + if let authIssue = RemoteGatewayAuthIssue(error: error) { + return .authIssue(authIssue) + } + return .failed(error.localizedDescription) + } + } + + private static func isValidWsUrl(_ raw: String) -> Bool { + GatewayRemoteConfig.normalizeGatewayUrl(raw) != nil + } + + private static func sshCheckCommand(target: String, identity: String) -> [String]? { + guard let parsed = CommandResolver.parseSSHTarget(target) else { return nil } + let options = [ + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=5", + "-o", "StrictHostKeyChecking=accept-new", + "-o", "UpdateHostKeys=yes", + ] + let args = CommandResolver.sshArguments( + target: parsed, + identity: identity, + options: options, + remoteCommand: ["echo", "ok"]) + return ["/usr/bin/ssh"] + args + } + + private static func formatSSHFailure(_ response: Response, target: String) -> String { + let payload = response.payload.flatMap { String(data: $0, encoding: .utf8) } + let trimmed = payload? + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(whereSeparator: \.isNewline) + .joined(separator: " ") + if let trimmed, + trimmed.localizedCaseInsensitiveContains("host key verification failed") + { + let host = CommandResolver.parseSSHTarget(target)?.host ?? target + return "SSH check failed: Host key verification failed. Remove the old key with ssh-keygen -R \(host) and try again." + } + if let trimmed, !trimmed.isEmpty { + if let message = response.message, message.hasPrefix("exit ") { + return "SSH check failed: \(trimmed) (\(message))" + } + return "SSH check failed: \(trimmed)" + } + if let message = response.message { + return "SSH check failed (\(message))" + } + return "SSH check failed" + } +} diff --git a/apps/macos/Sources/OpenClaw/TalkModeGatewayConfig.swift b/apps/macos/Sources/OpenClaw/TalkModeGatewayConfig.swift index 15600b5ea0e..0f6026cd40e 100644 --- a/apps/macos/Sources/OpenClaw/TalkModeGatewayConfig.swift +++ b/apps/macos/Sources/OpenClaw/TalkModeGatewayConfig.swift @@ -23,8 +23,8 @@ enum TalkModeGatewayConfigParser { defaultSilenceTimeoutMs: Int, envVoice: String?, sagVoice: String?, - envApiKey: String? - ) -> TalkModeGatewayConfigState { + envApiKey: String?) -> TalkModeGatewayConfigState + { let talk = snapshot.config?["talk"]?.dictionaryValue let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: defaultProvider) let activeProvider = selection?.provider ?? defaultProvider @@ -81,8 +81,8 @@ enum TalkModeGatewayConfigParser { defaultSilenceTimeoutMs: Int, envVoice: String?, sagVoice: String?, - envApiKey: String? - ) -> TalkModeGatewayConfigState { + envApiKey: String?) -> TalkModeGatewayConfigState + { let resolvedVoice = (envVoice?.isEmpty == false ? envVoice : nil) ?? (sagVoice?.isEmpty == false ? sagVoice : nil)