diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index d503686ba57..77402d7796f 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -783,7 +783,7 @@ extension AppState { remoteToken: String, remoteTokenDirty: Bool) -> [String: Any] { - Self.updatedRemoteGatewayConfig( + self.updatedRemoteGatewayConfig( current: current, transport: transport, remoteUrl: remoteUrl, @@ -804,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/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 f35e4e4c4ec..d1831d104fc 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -337,7 +337,6 @@ extension OnboardingView { self.remoteProbePreflightMessage == nil && self.remoteProbeState != .checking } - @ViewBuilder private func remoteConnectionSection() -> some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .top, spacing: 12) { @@ -503,17 +502,17 @@ extension OnboardingView { { switch issue { case .tokenRequired: - return ("key.fill", .orange) + ("key.fill", .orange) case .tokenMismatch: - return ("exclamationmark.triangle.fill", .orange) + ("exclamationmark.triangle.fill", .orange) case .gatewayTokenNotConfigured: - return ("wrench.and.screwdriver.fill", .orange) + ("wrench.and.screwdriver.fill", .orange) case .setupCodeExpired: - return ("qrcode.viewfinder", .orange) + ("qrcode.viewfinder", .orange) case .passwordRequired: - return ("lock.slash.fill", .orange) + ("lock.slash.fill", .orange) case .pairingRequired: - return ("link.badge.plus", .orange) + ("link.badge.plus", .orange) } } diff --git a/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift b/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift index 7073ad81de7..bde65c03495 100644 --- a/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift +++ b/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift @@ -180,7 +180,7 @@ enum RemoteGatewayProbe { } do { - _ = try await GatewayConnection.shared.healthSnapshot(timeoutMs: 10_000) + _ = try await GatewayConnection.shared.healthSnapshot(timeoutMs: 10000) let authSource = await GatewayConnection.shared.authSource() return .ready(RemoteGatewayProbeSuccess(authSource: authSource)) } catch { 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) diff --git a/src/cli/env-cli.test.ts b/src/cli/env-cli.test.ts new file mode 100644 index 00000000000..b02c8016761 --- /dev/null +++ b/src/cli/env-cli.test.ts @@ -0,0 +1,150 @@ +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.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); + }); + + 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(); + }); + + 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.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); + }, + ); +}); diff --git a/src/cli/env-cli.ts b/src/cli/env-cli.ts new file mode 100644 index 00000000000..a1ed379ec08 --- /dev/null +++ b/src/cli/env-cli.ts @@ -0,0 +1,264 @@ +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 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[] = []; + 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 }); + } + // `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. */ +export const __test__ = { + parseDotEnv, + serialiseDotEnv, + readEnvFile, + writeEnvFile, + parseAssignment, + redactValue, +}; + +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) { + // 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) { + 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) { + console.log(` ${key}=${redactValue(key, val)}`); + } + }); + + // 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 5ace8c10441..d6d42f96d21 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -64,6 +64,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)",