Merge d838ef3754694683b8ddd05b8cad748faeac1b68 into 9fb78453e088cd7b553d7779faa0de5c83708e70
This commit is contained in:
commit
59ed36bc01
@ -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,
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
150
src/cli/env-cli.test.ts
Normal 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
264
src/cli/env-cli.ts
Normal 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 key→value 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 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, 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());
|
||||
});
|
||||
}
|
||||
@ -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)",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user