Merge d838ef3754694683b8ddd05b8cad748faeac1b68 into 9fb78453e088cd7b553d7779faa0de5c83708e70

This commit is contained in:
happy dog 2026-03-21 05:07:20 +00:00 committed by GitHub
commit 59ed36bc01
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 439 additions and 15 deletions

View File

@ -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,

View File

@ -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")
}

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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)

150
src/cli/env-cli.test.ts Normal file
View File

@ -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);
},
);
});

264
src/cli/env-cli.ts Normal file
View File

@ -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 keyvalue map.
* Supports:
* KEY=VALUE
* KEY="VALUE WITH SPACES"
* KEY='VALUE WITH SPACES'
* # comment lines
* blank lines
*/
function parseDotEnv(content: string): Map<string, string> {
const map = new Map<string, string>();
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 keyvalue 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, string>): 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<string, string> {
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<string, string>): 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<string, string> = {};
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 <assignments...>")
.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 <keys...>")
.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());
});
}

View File

@ -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)",