diff --git a/CHANGELOG.md b/CHANGELOG.md index 53fd1622114..68fd8dcf57f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -120,6 +120,7 @@ Docs: https://docs.openclaw.ai - iMessage/echo loop hardening: strip leaked assistant-internal scaffolding from outbound iMessage replies, drop reflected assistant-content messages before they re-enter inbound processing, extend echo-cache text retention for delayed reflections, and suppress repeated loop traffic before it amplifies into queue overflow. (#33295) Thanks @joelnishanth. - Skills/workspace boundary hardening: reject workspace and extra-dir skill roots or `SKILL.md` files whose realpath escapes the configured source root, and skip syncing those escaped skills into sandbox workspaces. - Outbound/send config threading: pass resolved SecretRef config through outbound adapters and helper send paths so send flows do not reload unresolved runtime config. (#33987) Thanks @joshavant. +- gateway: harden shared auth resolution across systemd, discord, and node host (#39241) Thanks @joshavant. - Secrets/models.json persistence hardening: keep SecretRef-managed api keys + headers from persisting in generated models.json, expand audit/apply coverage, and harden marker handling/serialization. (#38955) Thanks @joshavant. - Sessions/subagent attachments: remove `attachments[].content.maxLength` from `sessions_spawn` schema to avoid llama.cpp GBNF repetition overflow, and preflight UTF-8 byte size before buffer allocation while keeping runtime file-size enforcement unchanged. (#33648) Thanks @anisoptera. - Runtime/tool-state stability: recover from dangling Anthropic `tool_use` after compaction, serialize long-running Discord handler runs without blocking new inbound events, and prevent stale busy snapshots from suppressing stuck-channel recovery. (from #33630, #33583) Thanks @kevinWangSheng and @theotarr. diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index a4d91cced6d..6aad2e9a9ac 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -539,6 +539,7 @@ public struct AgentParams: Codable, Sendable { public let idempotencykey: String public let label: String? public let spawnedby: String? + public let workspacedir: String? public init( message: String, @@ -566,7 +567,8 @@ public struct AgentParams: Codable, Sendable { inputprovenance: [String: AnyCodable]?, idempotencykey: String, label: String?, - spawnedby: String?) + spawnedby: String?, + workspacedir: String?) { self.message = message self.agentid = agentid @@ -594,6 +596,7 @@ public struct AgentParams: Codable, Sendable { self.idempotencykey = idempotencykey self.label = label self.spawnedby = spawnedby + self.workspacedir = workspacedir } private enum CodingKeys: String, CodingKey { @@ -623,6 +626,7 @@ public struct AgentParams: Codable, Sendable { case idempotencykey = "idempotencyKey" case label case spawnedby = "spawnedBy" + case workspacedir = "workspaceDir" } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index a4d91cced6d..6aad2e9a9ac 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -539,6 +539,7 @@ public struct AgentParams: Codable, Sendable { public let idempotencykey: String public let label: String? public let spawnedby: String? + public let workspacedir: String? public init( message: String, @@ -566,7 +567,8 @@ public struct AgentParams: Codable, Sendable { inputprovenance: [String: AnyCodable]?, idempotencykey: String, label: String?, - spawnedby: String?) + spawnedby: String?, + workspacedir: String?) { self.message = message self.agentid = agentid @@ -594,6 +596,7 @@ public struct AgentParams: Codable, Sendable { self.idempotencykey = idempotencykey self.label = label self.spawnedby = spawnedby + self.workspacedir = workspacedir } private enum CodingKeys: String, CodingKey { @@ -623,6 +626,7 @@ public struct AgentParams: Codable, Sendable { case idempotencykey = "idempotencyKey" case label case spawnedby = "spawnedBy" + case workspacedir = "workspaceDir" } } diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 8266cf4c26e..994c03391ce 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -942,6 +942,13 @@ Default slash command settings: When `target` is `channel` or `both`, the approval prompt is visible in the channel. Only configured approvers can use the buttons; other users receive an ephemeral denial. Approval prompts include the command text, so only enable channel delivery in trusted channels. If the channel ID cannot be derived from the session key, OpenClaw falls back to DM delivery. + Gateway auth for this handler uses the same shared credential resolution contract as other Gateway clients: + + - env-first local auth (`OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD` then `gateway.auth.*`) + - in local mode, `gateway.remote.*` can be used as fallback when `gateway.auth.*` is unset + - remote-mode support via `gateway.remote.*` when applicable + - URL overrides are override-safe: CLI overrides do not reuse implicit credentials, and env overrides use env credentials only + If approvals fail with unknown approval IDs, verify approver list and feature enablement. Related docs: [Exec approvals](/tools/exec-approvals) diff --git a/docs/cli/acp.md b/docs/cli/acp.md index 23c6feabc52..e1fdcf6a398 100644 --- a/docs/cli/acp.md +++ b/docs/cli/acp.md @@ -179,6 +179,10 @@ Security note: - `--token` and `--password` can be visible in local process listings on some systems. - Prefer `--token-file`/`--password-file` or environment variables (`OPENCLAW_GATEWAY_TOKEN`, `OPENCLAW_GATEWAY_PASSWORD`). +- Gateway auth resolution follows the shared contract used by other Gateway clients: + - local mode: env (`OPENCLAW_GATEWAY_*`) -> `gateway.auth.*` -> `gateway.remote.*` fallback when `gateway.auth.*` is unset + - remote mode: `gateway.remote.*` with env/config fallback per remote precedence rules + - `--url` is override-safe and does not reuse implicit config/env credentials; pass explicit `--token`/`--password` (or file variants) - ACP runtime backend child processes receive `OPENCLAW_SHELL=acp`, which can be used for context-specific shell/profile rules. - `openclaw acp client` sets `OPENCLAW_SHELL=acp-client` on the spawned bridge process. diff --git a/docs/cli/daemon.md b/docs/cli/daemon.md index 5a5db7febf3..8f6042e7400 100644 --- a/docs/cli/daemon.md +++ b/docs/cli/daemon.md @@ -41,6 +41,7 @@ openclaw daemon uninstall Notes: - `status` resolves configured auth SecretRefs for probe auth when possible. +- On Linux systemd installs, `status` token-drift checks include both `Environment=` and `EnvironmentFile=` unit sources. - When token auth requires a token and `gateway.auth.token` is SecretRef-managed, `install` validates that the SecretRef is resolvable but does not persist the resolved token into service environment metadata. - If token auth requires a token and the configured token SecretRef is unresolved, install fails closed. - If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, install is blocked until mode is set explicitly. diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 371e73070a8..48157e52aef 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -109,6 +109,7 @@ Notes: - `gateway status` resolves configured auth SecretRefs for probe auth when possible. - If a required auth SecretRef is unresolved in this command path, probe auth can fail; pass `--token`/`--password` explicitly or resolve the secret source first. +- On Linux systemd installs, service auth drift checks read both `Environment=` and `EnvironmentFile=` values from the unit (including `%h`, quoted paths, multiple files, and optional `-` files). ### `gateway probe` diff --git a/docs/cli/index.md b/docs/cli/index.md index cddd2a7d634..84ded847d55 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -777,6 +777,7 @@ Notes: - `gateway status` supports `--no-probe`, `--deep`, and `--json` for scripting. - `gateway status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named OpenClaw services are treated as first-class and aren't flagged as "extra". - `gateway status` prints which config path the CLI uses vs which config the service likely uses (service env), plus the resolved probe target URL. +- On Linux systemd installs, status token-drift checks include both `Environment=` and `EnvironmentFile=` unit sources. - `gateway install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly). - `gateway install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs). - `gateway install` options: `--port`, `--runtime`, `--token`, `--force`, `--json`. @@ -1010,6 +1011,11 @@ Subcommands: - `node stop` - `node restart` +Auth notes: + +- `node` resolves gateway auth from env/config (no `--token`/`--password` flags): `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`, then `gateway.auth.*`, with remote-mode support via `gateway.remote.*`. +- Legacy `CLAWDBOT_GATEWAY_*` env vars are intentionally ignored for node-host auth resolution. + ## Nodes `nodes` talks to the Gateway and targets paired nodes. See [/nodes](/nodes). diff --git a/docs/cli/node.md b/docs/cli/node.md index af07e61ba22..95f0936065e 100644 --- a/docs/cli/node.md +++ b/docs/cli/node.md @@ -58,6 +58,16 @@ Options: - `--node-id `: Override node id (clears pairing token) - `--display-name `: Override the node display name +## Gateway auth for node host + +`openclaw node run` and `openclaw node install` resolve gateway auth from config/env (no `--token`/`--password` flags on node commands): + +- `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD` are checked first. +- Then local config fallback: `gateway.auth.token` / `gateway.auth.password`. +- In local mode, `gateway.remote.token` / `gateway.remote.password` are also eligible as fallback when `gateway.auth.*` is unset. +- In `gateway.mode=remote`, remote client fields (`gateway.remote.token` / `gateway.remote.password`) are also eligible per remote precedence rules. +- Legacy `CLAWDBOT_GATEWAY_*` env vars are ignored for node host auth resolution. + ## Service (background) Install a headless node host as a user service. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 2e7b7df68ba..2550406f4ff 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -278,6 +278,7 @@ Notes: - If token auth requires a token and `gateway.auth.token` is SecretRef-managed, doctor service install/repair validates the SecretRef but does not persist resolved plaintext token values into supervisor service environment metadata. - If token auth requires a token and the configured token SecretRef is unresolved, doctor blocks the install/repair path with actionable guidance. - If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, doctor blocks install/repair until mode is set explicitly. +- For Linux user-systemd units, doctor token drift checks now include both `Environment=` and `EnvironmentFile=` sources when comparing service auth metadata. - You can always force a full rewrite via `openclaw gateway install --force`. ### 16) Gateway runtime + port diagnostics diff --git a/docs/gateway/remote.md b/docs/gateway/remote.md index ea99f57c488..a9aadc49dd1 100644 --- a/docs/gateway/remote.md +++ b/docs/gateway/remote.md @@ -103,9 +103,12 @@ When the gateway is loopback-only, keep the URL at `ws://127.0.0.1:18789` and op ## Credential precedence -Gateway call/probe credential resolution now follows one shared contract: +Gateway credential resolution follows one shared contract across call/probe/status paths, Discord exec-approval monitoring, and node-host connections: -- Explicit credentials (`--token`, `--password`, or tool `gatewayToken`) always win. +- Explicit credentials (`--token`, `--password`, or tool `gatewayToken`) always win on call paths that accept explicit auth. +- URL override safety: + - CLI URL overrides (`--url`) never reuse implicit config/env credentials. + - Env URL overrides (`OPENCLAW_GATEWAY_URL`) may use env credentials only (`OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`). - Local mode defaults: - token: `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token` -> `gateway.remote.token` - password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.auth.password` -> `gateway.remote.password` diff --git a/docs/nodes/index.md b/docs/nodes/index.md index c58cd247a6c..37bba45953d 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -81,8 +81,10 @@ openclaw node run --host 127.0.0.1 --port 18790 --display-name "Build Node" Notes: -- The token is `gateway.auth.token` from the gateway config (`~/.openclaw/openclaw.json` on the gateway host). -- `openclaw node run` reads `OPENCLAW_GATEWAY_TOKEN` for auth. +- `openclaw node run` supports token or password auth. +- Env vars are preferred: `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`. +- Config fallback is `gateway.auth.token` / `gateway.auth.password`; in remote mode, `gateway.remote.token` / `gateway.remote.password` are also eligible. +- Legacy `CLAWDBOT_GATEWAY_*` env vars are intentionally ignored by node-host auth resolution. ### Start a node host (service) diff --git a/src/acp/server.startup.test.ts b/src/acp/server.startup.test.ts index 0c19d487ab7..2f9b96d8511 100644 --- a/src/acp/server.startup.test.ts +++ b/src/acp/server.startup.test.ts @@ -10,19 +10,17 @@ type GatewayClientAuth = { token?: string; password?: string; }; -type ResolveGatewayCredentialsWithSecretInputs = (params: unknown) => Promise; +type ResolveGatewayConnectionAuth = (params: unknown) => Promise; const mockState = { gateways: [] as MockGatewayClient[], gatewayAuth: [] as GatewayClientAuth[], agentSideConnectionCtor: vi.fn(), agentStart: vi.fn(), - resolveGatewayCredentialsWithSecretInputs: vi.fn( - async (_params) => ({ - token: undefined, - password: undefined, - }), - ), + resolveGatewayConnectionAuth: vi.fn(async (_params) => ({ + token: undefined, + password: undefined, + })), }; class MockGatewayClient { @@ -72,11 +70,22 @@ vi.mock("../gateway/auth.js", () => ({ })); vi.mock("../gateway/call.js", () => ({ - buildGatewayConnectionDetails: () => ({ - url: "ws://127.0.0.1:18789", - }), - resolveGatewayCredentialsWithSecretInputs: (params: unknown) => - mockState.resolveGatewayCredentialsWithSecretInputs(params), + buildGatewayConnectionDetails: ({ url }: { url?: string }) => { + if (typeof url === "string" && url.trim().length > 0) { + return { + url: url.trim(), + urlSource: "cli --url", + }; + } + return { + url: "ws://127.0.0.1:18789", + urlSource: "local loopback", + }; + }, +})); + +vi.mock("../gateway/connection-auth.js", () => ({ + resolveGatewayConnectionAuth: (params: unknown) => mockState.resolveGatewayConnectionAuth(params), })); vi.mock("../gateway/client.js", () => ({ @@ -129,8 +138,8 @@ describe("serveAcpGateway startup", () => { mockState.gatewayAuth.length = 0; mockState.agentSideConnectionCtor.mockReset(); mockState.agentStart.mockReset(); - mockState.resolveGatewayCredentialsWithSecretInputs.mockReset(); - mockState.resolveGatewayCredentialsWithSecretInputs.mockResolvedValue({ + mockState.resolveGatewayConnectionAuth.mockReset(); + mockState.resolveGatewayConnectionAuth.mockResolvedValue({ token: undefined, password: undefined, }); @@ -178,7 +187,7 @@ describe("serveAcpGateway startup", () => { }); it("passes resolved SecretInput gateway credentials to the ACP gateway client", async () => { - mockState.resolveGatewayCredentialsWithSecretInputs.mockResolvedValue({ + mockState.resolveGatewayConnectionAuth.mockResolvedValue({ token: undefined, password: "resolved-secret-password", // pragma: allowlist secret }); @@ -188,7 +197,7 @@ describe("serveAcpGateway startup", () => { const servePromise = serveAcpGateway({}); await Promise.resolve(); - expect(mockState.resolveGatewayCredentialsWithSecretInputs).toHaveBeenCalledWith( + expect(mockState.resolveGatewayConnectionAuth).toHaveBeenCalledWith( expect.objectContaining({ env: process.env, }), @@ -209,4 +218,33 @@ describe("serveAcpGateway startup", () => { onceSpy.mockRestore(); } }); + + it("passes CLI URL override context into shared gateway auth resolution", async () => { + const { signalHandlers, onceSpy } = captureProcessSignalHandlers(); + + try { + const servePromise = serveAcpGateway({ + gatewayUrl: "wss://override.example/ws", + }); + await Promise.resolve(); + + expect(mockState.resolveGatewayConnectionAuth).toHaveBeenCalledWith( + expect.objectContaining({ + env: process.env, + urlOverride: "wss://override.example/ws", + urlOverrideSource: "cli", + }), + ); + + const gateway = getMockGateway(); + gateway.emitHello(); + await vi.waitFor(() => { + expect(mockState.agentSideConnectionCtor).toHaveBeenCalledTimes(1); + }); + signalHandlers.get("SIGINT")?.(); + await servePromise; + } finally { + onceSpy.mockRestore(); + } + }); }); diff --git a/src/acp/server.ts b/src/acp/server.ts index 69d029b6298..c65dbad202a 100644 --- a/src/acp/server.ts +++ b/src/acp/server.ts @@ -3,11 +3,9 @@ import { Readable, Writable } from "node:stream"; import { fileURLToPath } from "node:url"; import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"; import { loadConfig } from "../config/config.js"; -import { - buildGatewayConnectionDetails, - resolveGatewayCredentialsWithSecretInputs, -} from "../gateway/call.js"; +import { buildGatewayConnectionDetails } from "../gateway/call.js"; import { GatewayClient } from "../gateway/client.js"; +import { resolveGatewayConnectionAuth } from "../gateway/connection-auth.js"; import { isMainModule } from "../infra/is-main.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { readSecretFromFile } from "./secret-file.js"; @@ -20,13 +18,21 @@ export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise { vi.unstubAllEnvs(); vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", ""); vi.stubEnv("CLAWDBOT_GATEWAY_TOKEN", ""); + vi.stubEnv("OPENCLAW_GATEWAY_URL", ""); + vi.stubEnv("CLAWDBOT_GATEWAY_URL", ""); }); it("emits drift warning when enabled", async () => { @@ -80,7 +82,9 @@ describe("runServiceRestart token drift", () => { expect(loadConfig).toHaveBeenCalledTimes(1); const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{")); const payload = JSON.parse(jsonLine ?? "{}") as { warnings?: string[] }; - expect(payload.warnings?.[0]).toContain("gateway install --force"); + expect(payload.warnings).toEqual( + expect.arrayContaining([expect.stringContaining("gateway install --force")]), + ); }); it("uses gateway.auth.token when checking drift", async () => { @@ -106,7 +110,9 @@ describe("runServiceRestart token drift", () => { const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{")); const payload = JSON.parse(jsonLine ?? "{}") as { warnings?: string[] }; - expect(payload.warnings?.[0]).toContain("gateway install --force"); + expect(payload.warnings).toEqual( + expect.arrayContaining([expect.stringContaining("gateway install --force")]), + ); }); it("skips drift warning when disabled", async () => { diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index b080302a644..f5927dab83a 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -12,6 +12,7 @@ import { parseSystemdExecStart } from "./systemd-unit.js"; import { isSystemdUserServiceAvailable, parseSystemdShow, + readSystemdServiceExecStart, restartSystemdService, resolveSystemdUserUnitPath, stopSystemdService, @@ -42,6 +43,19 @@ const createWritableStreamMock = () => { }; }; +function pathLikeToString(pathname: unknown): string { + if (typeof pathname === "string") { + return pathname; + } + if (pathname instanceof URL) { + return pathname.pathname; + } + if (pathname instanceof Uint8Array) { + return Buffer.from(pathname).toString("utf8"); + } + return ""; +} + const assertRestartSuccess = async (env: NodeJS.ProcessEnv) => { const { write, stdout } = createWritableStreamMock(); await restartSystemdService({ stdout, env }); @@ -297,6 +311,173 @@ describe("parseSystemdExecStart", () => { }); }); +describe("readSystemdServiceExecStart", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("loads OPENCLAW_GATEWAY_TOKEN from EnvironmentFile", async () => { + const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => { + const pathValue = pathLikeToString(pathname); + if (pathValue.endsWith("/openclaw-gateway.service")) { + return [ + "[Service]", + "ExecStart=/usr/bin/openclaw gateway run", + "EnvironmentFile=%h/.openclaw/.env", + ].join("\n"); + } + if (pathValue === "/home/test/.openclaw/.env") { + return "OPENCLAW_GATEWAY_TOKEN=env-file-token\n"; + } + throw new Error(`unexpected readFile path: ${pathValue}`); + }); + + const command = await readSystemdServiceExecStart({ HOME: "/home/test" }); + expect(command?.environment?.OPENCLAW_GATEWAY_TOKEN).toBe("env-file-token"); + expect(readFileSpy).toHaveBeenCalledTimes(2); + }); + + it("lets inline Environment override EnvironmentFile values", async () => { + vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => { + const pathValue = pathLikeToString(pathname); + if (pathValue.endsWith("/openclaw-gateway.service")) { + return [ + "[Service]", + "ExecStart=/usr/bin/openclaw gateway run", + "EnvironmentFile=%h/.openclaw/.env", + 'Environment="OPENCLAW_GATEWAY_TOKEN=inline-token"', + ].join("\n"); + } + if (pathValue === "/home/test/.openclaw/.env") { + return "OPENCLAW_GATEWAY_TOKEN=env-file-token\n"; + } + throw new Error(`unexpected readFile path: ${pathValue}`); + }); + + const command = await readSystemdServiceExecStart({ HOME: "/home/test" }); + expect(command?.environment?.OPENCLAW_GATEWAY_TOKEN).toBe("inline-token"); + }); + + it("ignores missing optional EnvironmentFile entries", async () => { + vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => { + const pathValue = pathLikeToString(pathname); + if (pathValue.endsWith("/openclaw-gateway.service")) { + return [ + "[Service]", + "ExecStart=/usr/bin/openclaw gateway run", + "EnvironmentFile=-%h/.openclaw/missing.env", + ].join("\n"); + } + throw new Error(`missing: ${pathValue}`); + }); + + const command = await readSystemdServiceExecStart({ HOME: "/home/test" }); + expect(command?.programArguments).toEqual(["/usr/bin/openclaw", "gateway", "run"]); + expect(command?.environment).toBeUndefined(); + }); + + it("keeps parsing when non-optional EnvironmentFile entries are missing", async () => { + vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => { + const pathValue = pathLikeToString(pathname); + if (pathValue.endsWith("/openclaw-gateway.service")) { + return [ + "[Service]", + "ExecStart=/usr/bin/openclaw gateway run", + "EnvironmentFile=%h/.openclaw/missing.env", + ].join("\n"); + } + throw new Error(`missing: ${pathValue}`); + }); + + const command = await readSystemdServiceExecStart({ HOME: "/home/test" }); + expect(command?.programArguments).toEqual(["/usr/bin/openclaw", "gateway", "run"]); + expect(command?.environment).toBeUndefined(); + }); + + it("supports multiple EnvironmentFile entries and quoted paths", async () => { + vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => { + const pathValue = pathLikeToString(pathname); + if (pathValue.endsWith("/openclaw-gateway.service")) { + return [ + "[Service]", + "ExecStart=/usr/bin/openclaw gateway run", + 'EnvironmentFile=%h/.openclaw/first.env "%h/.openclaw/second env.env"', + ].join("\n"); + } + if (pathValue === "/home/test/.openclaw/first.env") { + return "OPENCLAW_GATEWAY_TOKEN=first-token\n"; + } + if (pathValue === "/home/test/.openclaw/second env.env") { + return 'OPENCLAW_GATEWAY_PASSWORD="second password"\n'; + } + throw new Error(`unexpected readFile path: ${pathValue}`); + }); + + const command = await readSystemdServiceExecStart({ HOME: "/home/test" }); + expect(command?.environment).toEqual({ + OPENCLAW_GATEWAY_TOKEN: "first-token", + OPENCLAW_GATEWAY_PASSWORD: "second password", // pragma: allowlist secret + }); + }); + + it("resolves relative EnvironmentFile paths from the unit directory", async () => { + vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => { + const pathValue = pathLikeToString(pathname); + if (pathValue.endsWith("/openclaw-gateway.service")) { + return [ + "[Service]", + "ExecStart=/usr/bin/openclaw gateway run", + "EnvironmentFile=./gateway.env ./override.env", + ].join("\n"); + } + if (pathValue.endsWith("/.config/systemd/user/gateway.env")) { + return [ + "OPENCLAW_GATEWAY_TOKEN=relative-token", + "OPENCLAW_GATEWAY_PASSWORD=relative-password", + ].join("\n"); + } + if (pathValue.endsWith("/.config/systemd/user/override.env")) { + return "OPENCLAW_GATEWAY_TOKEN=override-token\n"; + } + throw new Error(`unexpected readFile path: ${pathValue}`); + }); + + const command = await readSystemdServiceExecStart({ HOME: "/home/test" }); + expect(command?.environment).toEqual({ + OPENCLAW_GATEWAY_TOKEN: "override-token", + OPENCLAW_GATEWAY_PASSWORD: "relative-password", // pragma: allowlist secret + }); + }); + + it("parses EnvironmentFile content with comments and quoted values", async () => { + vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => { + const pathValue = pathLikeToString(pathname); + if (pathValue.endsWith("/openclaw-gateway.service")) { + return [ + "[Service]", + "ExecStart=/usr/bin/openclaw gateway run", + "EnvironmentFile=%h/.openclaw/gateway.env", + ].join("\n"); + } + if (pathValue === "/home/test/.openclaw/gateway.env") { + return [ + "# comment", + "; another comment", + 'OPENCLAW_GATEWAY_TOKEN="quoted token"', + "OPENCLAW_GATEWAY_PASSWORD=quoted-password", + ].join("\n"); + } + throw new Error(`unexpected readFile path: ${pathValue}`); + }); + + const command = await readSystemdServiceExecStart({ HOME: "/home/test" }); + expect(command?.environment).toEqual({ + OPENCLAW_GATEWAY_TOKEN: "quoted token", + OPENCLAW_GATEWAY_PASSWORD: "quoted-password", // pragma: allowlist secret + }); + }); +}); + describe("systemd service control", () => { const assertMachineRestartArgs = (args: string[]) => { expect(args).toEqual(["--machine", "debian@", "--user", "restart", "openclaw-gateway.service"]); diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 9d8849a2ba5..eb649785084 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { splitArgsPreservingQuotes } from "./arg-split.js"; import { LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES, resolveGatewayServiceDescription, @@ -65,6 +66,7 @@ export async function readSystemdServiceExecStart( let execStart = ""; let workingDirectory = ""; const environment: Record = {}; + const environmentFileSpecs: string[] = []; for (const rawLine of content.split("\n")) { const line = rawLine.trim(); if (!line || line.startsWith("#")) { @@ -80,16 +82,30 @@ export async function readSystemdServiceExecStart( if (parsed) { environment[parsed.key] = parsed.value; } + } else if (line.startsWith("EnvironmentFile=")) { + const raw = line.slice("EnvironmentFile=".length).trim(); + if (raw) { + environmentFileSpecs.push(raw); + } } } if (!execStart) { return null; } + const environmentFromFiles = await resolveSystemdEnvironmentFiles({ + environmentFileSpecs, + env, + unitPath, + }); + const mergedEnvironment = { + ...environmentFromFiles, + ...environment, + }; const programArguments = parseSystemdExecStart(execStart); return { programArguments, ...(workingDirectory ? { workingDirectory } : {}), - ...(Object.keys(environment).length > 0 ? { environment } : {}), + ...(Object.keys(mergedEnvironment).length > 0 ? { environment: mergedEnvironment } : {}), sourcePath: unitPath, }; } catch { @@ -97,6 +113,89 @@ export async function readSystemdServiceExecStart( } } +function expandSystemdSpecifier(input: string, env: GatewayServiceEnv): string { + // Support the common unit-specifier used in user services. + return input.replaceAll("%h", toPosixPath(resolveHomeDir(env))); +} + +function parseEnvironmentFileSpecs(raw: string): string[] { + return splitArgsPreservingQuotes(raw, { escapeMode: "backslash" }) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function parseEnvironmentFileLine(rawLine: string): { key: string; value: string } | null { + const trimmed = rawLine.trim(); + if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";")) { + return null; + } + const eq = trimmed.indexOf("="); + if (eq <= 0) { + return null; + } + const key = trimmed.slice(0, eq).trim(); + if (!key) { + return null; + } + let value = trimmed.slice(eq + 1).trim(); + if ( + value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) + ) { + value = value.slice(1, -1); + } + return { key, value }; +} + +async function readSystemdEnvironmentFile(pathname: string): Promise> { + const environment: Record = {}; + const content = await fs.readFile(pathname, "utf8"); + for (const rawLine of content.split(/\r?\n/)) { + const parsed = parseEnvironmentFileLine(rawLine); + if (!parsed) { + continue; + } + environment[parsed.key] = parsed.value; + } + return environment; +} + +async function resolveSystemdEnvironmentFiles(params: { + environmentFileSpecs: string[]; + env: GatewayServiceEnv; + unitPath: string; +}): Promise> { + const resolved: Record = {}; + if (params.environmentFileSpecs.length === 0) { + return resolved; + } + const unitDir = path.posix.dirname(params.unitPath); + for (const specRaw of params.environmentFileSpecs) { + for (const token of parseEnvironmentFileSpecs(specRaw)) { + const optional = token.startsWith("-"); + const pathnameRaw = optional ? token.slice(1).trim() : token; + if (!pathnameRaw) { + continue; + } + const expanded = expandSystemdSpecifier(pathnameRaw, params.env); + const pathname = path.posix.isAbsolute(expanded) + ? expanded + : path.posix.resolve(unitDir, expanded); + try { + const fromFile = await readSystemdEnvironmentFile(pathname); + Object.assign(resolved, fromFile); + } catch { + // Keep service auditing resilient even when env files are unavailable + // in the current runtime context. Both optional and non-optional + // EnvironmentFile entries are skipped gracefully for diagnostics. + continue; + } + } + } + return resolved; +} + export type SystemdServiceInfo = { activeState?: string; subState?: string; diff --git a/src/discord/monitor/exec-approvals.test.ts b/src/discord/monitor/exec-approvals.test.ts index d331087cbf3..50a54f37c54 100644 --- a/src/discord/monitor/exec-approvals.test.ts +++ b/src/discord/monitor/exec-approvals.test.ts @@ -26,6 +26,25 @@ const writeStore = (store: Record) => { beforeEach(() => { writeStore({}); + mockGatewayClientCtor.mockClear(); + mockResolveGatewayConnectionAuth.mockReset().mockImplementation(async (params: { + config?: { + gateway?: { + auth?: { + token?: string; + password?: string; + }; + }; + }; + env: NodeJS.ProcessEnv; + }) => { + const configToken = params.config?.gateway?.auth?.token; + const configPassword = params.config?.gateway?.auth?.password; + const envToken = params.env.OPENCLAW_GATEWAY_TOKEN ?? params.env.CLAWDBOT_GATEWAY_TOKEN; + const envPassword = + params.env.OPENCLAW_GATEWAY_PASSWORD ?? params.env.CLAWDBOT_GATEWAY_PASSWORD; + return { token: envToken ?? configToken, password: envPassword ?? configPassword }; + }); }); // ─── Mocks ──────────────────────────────────────────────────────────────────── @@ -37,6 +56,8 @@ const gatewayClientStarts = vi.hoisted(() => vi.fn()); const gatewayClientStops = vi.hoisted(() => vi.fn()); const gatewayClientRequests = vi.hoisted(() => vi.fn(async () => ({ ok: true }))); const gatewayClientParams = vi.hoisted(() => [] as Array>); +const mockGatewayClientCtor = vi.hoisted(() => vi.fn()); +const mockResolveGatewayConnectionAuth = vi.hoisted(() => vi.fn()); vi.mock("../send.shared.js", async (importOriginal) => { const actual = await importOriginal(); @@ -59,6 +80,7 @@ vi.mock("../../gateway/client.js", () => ({ constructor(params: Record) { this.params = params; gatewayClientParams.push(params); + mockGatewayClientCtor(params); } start() { gatewayClientStarts(); @@ -72,6 +94,10 @@ vi.mock("../../gateway/client.js", () => ({ }, })); +vi.mock("../../gateway/connection-auth.js", () => ({ + resolveGatewayConnectionAuth: mockResolveGatewayConnectionAuth, +})); + vi.mock("../../logger.js", () => ({ logDebug: vi.fn(), logError: vi.fn(), @@ -776,3 +802,74 @@ describe("DiscordExecApprovalHandler delivery routing", () => { clearPendingTimeouts(handler); }); }); + +describe("DiscordExecApprovalHandler gateway auth resolution", () => { + it("passes CLI URL overrides to shared gateway auth resolver", async () => { + mockResolveGatewayConnectionAuth.mockResolvedValue({ + token: "resolved-token", + password: "resolved-password", // pragma: allowlist secret + }); + const handler = new DiscordExecApprovalHandler({ + token: "test-token", + accountId: "default", + gatewayUrl: "wss://override.example/ws", + config: { enabled: true, approvers: ["123"] }, + cfg: { session: { store: STORE_PATH } }, + }); + + await handler.start(); + + expect(mockResolveGatewayConnectionAuth).toHaveBeenCalledWith( + expect.objectContaining({ + env: process.env, + urlOverride: "wss://override.example/ws", + urlOverrideSource: "cli", + }), + ); + expect(mockGatewayClientCtor).toHaveBeenCalledWith( + expect.objectContaining({ + url: "wss://override.example/ws", + token: "resolved-token", + password: "resolved-password", // pragma: allowlist secret + }), + ); + + await handler.stop(); + }); + + it("passes env URL overrides to shared gateway auth resolver", async () => { + const previousGatewayUrl = process.env.OPENCLAW_GATEWAY_URL; + try { + process.env.OPENCLAW_GATEWAY_URL = "wss://gateway-from-env.example/ws"; + const handler = new DiscordExecApprovalHandler({ + token: "test-token", + accountId: "default", + config: { enabled: true, approvers: ["123"] }, + cfg: { session: { store: STORE_PATH } }, + }); + + await handler.start(); + + expect(mockResolveGatewayConnectionAuth).toHaveBeenCalledWith( + expect.objectContaining({ + env: process.env, + urlOverride: "wss://gateway-from-env.example/ws", + urlOverrideSource: "env", + }), + ); + expect(mockGatewayClientCtor).toHaveBeenCalledWith( + expect.objectContaining({ + url: "wss://gateway-from-env.example/ws", + }), + ); + + await handler.stop(); + } finally { + if (typeof previousGatewayUrl === "string") { + process.env.OPENCLAW_GATEWAY_URL = previousGatewayUrl; + } else { + delete process.env.OPENCLAW_GATEWAY_URL; + } + } + }); +}); diff --git a/src/discord/monitor/exec-approvals.ts b/src/discord/monitor/exec-approvals.ts index 27f5e822cc9..5564b126e3c 100644 --- a/src/discord/monitor/exec-approvals.ts +++ b/src/discord/monitor/exec-approvals.ts @@ -15,7 +15,7 @@ import { loadSessionStore, resolveStorePath } from "../../config/sessions.js"; import type { DiscordExecApprovalConfig } from "../../config/types.discord.js"; import { buildGatewayConnectionDetails } from "../../gateway/call.js"; import { GatewayClient } from "../../gateway/client.js"; -import { resolveGatewayCredentialsFromConfig } from "../../gateway/credentials.js"; +import { resolveGatewayConnectionAuth } from "../../gateway/connection-auth.js"; import type { EventFrame } from "../../gateway/protocol/index.js"; import type { ExecApprovalDecision, @@ -401,18 +401,27 @@ export class DiscordExecApprovalHandler { logDebug("discord exec approvals: starting handler"); - const { url: gatewayUrl } = buildGatewayConnectionDetails({ + const { url: gatewayUrl, urlSource } = buildGatewayConnectionDetails({ config: this.opts.cfg, url: this.opts.gatewayUrl, }); - const gatewayCredentials = resolveGatewayCredentialsFromConfig({ - cfg: this.opts.cfg, + const gatewayUrlOverrideSource = + urlSource === "cli --url" + ? "cli" + : urlSource === "env OPENCLAW_GATEWAY_URL" + ? "env" + : undefined; + const auth = await resolveGatewayConnectionAuth({ + config: this.opts.cfg, + env: process.env, + urlOverride: gatewayUrlOverrideSource ? gatewayUrl : undefined, + urlOverrideSource: gatewayUrlOverrideSource, }); this.gatewayClient = new GatewayClient({ url: gatewayUrl, - token: gatewayCredentials.token, - password: gatewayCredentials.password, + token: auth.token, + password: auth.password, clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, clientDisplayName: "Discord Exec Approvals", mode: GATEWAY_CLIENT_MODES.BACKEND, diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 850bf008cbd..10fc52441d1 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -789,6 +789,30 @@ describe("callGateway password resolution", () => { expect(lastClientOptions?.token).toBe("token-auth"); }); + it("resolves local password ref before unresolved local token ref can block auth", async () => { + process.env.LOCAL_FALLBACK_PASSWORD = "resolved-local-fallback-password"; // pragma: allowlist secret + loadConfig.mockReturnValue({ + gateway: { + mode: "local", + bind: "loopback", + auth: { + token: { source: "env", provider: "default", id: "MISSING_LOCAL_REF_TOKEN" }, + password: { source: "env", provider: "default", id: "LOCAL_FALLBACK_PASSWORD" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as unknown as OpenClawConfig); + + await callGateway({ method: "health" }); + + expect(lastClientOptions?.token).toBeUndefined(); + expect(lastClientOptions?.password).toBe("resolved-local-fallback-password"); // pragma: allowlist secret + }); + it.each(["none", "trusted-proxy"] as const)( "ignores unresolved local password ref when auth mode is %s", async (mode) => { diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 07d5100cf7e..31d11ac14b9 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -6,7 +6,7 @@ import { resolveGatewayPort, resolveStateDir, } from "../config/config.js"; -import { hasConfiguredSecretInput, resolveSecretInputRef } from "../config/types.secrets.js"; +import { resolveSecretInputRef } from "../config/types.secrets.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js"; import { resolveSecretInputString } from "../secrets/resolve-secret-input-string.js"; @@ -19,10 +19,13 @@ import { import { VERSION } from "../version.js"; import { GatewayClient } from "./client.js"; import { - readGatewayPasswordEnv, - readGatewayTokenEnv, + GatewaySecretRefUnavailableError, resolveGatewayCredentialsFromConfig, trimToUndefined, + type GatewayCredentialMode, + type GatewayCredentialPrecedence, + type GatewayRemoteCredentialFallback, + type GatewayRemoteCredentialPrecedence, } from "./credentials.js"; import { CLI_DEFAULT_OPERATOR_SCOPES, @@ -238,6 +241,14 @@ type ResolvedGatewayCallContext = { urlOverrideSource?: "cli" | "env"; remoteUrl?: string; explicitAuth: ExplicitGatewayAuth; + modeOverride?: GatewayCredentialMode; + includeLegacyEnv?: boolean; + localTokenPrecedence?: GatewayCredentialPrecedence; + localPasswordPrecedence?: GatewayCredentialPrecedence; + remoteTokenPrecedence?: GatewayRemoteCredentialPrecedence; + remotePasswordPrecedence?: GatewayRemoteCredentialPrecedence; + remoteTokenFallback?: GatewayRemoteCredentialFallback; + remotePasswordFallback?: GatewayRemoteCredentialFallback; }; function resolveGatewayCallTimeout(timeoutValue: unknown): { @@ -303,6 +314,12 @@ async function resolveGatewaySecretInputString(params: { value: params.value, env: params.env, normalize: trimToUndefined, + onResolveRefError: (error) => { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`${params.path} secret reference could not be resolved: ${detail}`, { + cause: error, + }); + }, }); if (!value) { throw new Error(`${params.path} resolved to an empty or non-string value.`); @@ -330,166 +347,354 @@ async function resolveGatewayCredentialsWithEnv( password: context.explicitAuth.password, }; } - if (context.urlOverride) { - return resolveGatewayCredentialsFromConfig({ - cfg: context.config, - env, - explicitAuth: context.explicitAuth, - urlOverride: context.urlOverride, - urlOverrideSource: context.urlOverrideSource, - remotePasswordPrecedence: "env-first", // pragma: allowlist secret - }); - } + return resolveGatewayCredentialsFromConfigWithSecretInputs({ context, env }); +} - let resolvedConfig = context.config; - const envToken = readGatewayTokenEnv(env); - const envPassword = readGatewayPasswordEnv(env); - const defaults = context.config.secrets?.defaults; - const auth = context.config.gateway?.auth; - const remoteConfig = context.config.gateway?.remote; - const authMode = auth?.mode; - const localToken = trimToUndefined(auth?.token); - const remoteToken = trimToUndefined(remoteConfig?.token); - const remoteTokenConfigured = hasConfiguredSecretInput(remoteConfig?.token, defaults); - const tokenCanWin = Boolean(envToken || localToken || remoteToken || remoteTokenConfigured); - const remotePasswordConfigured = - context.isRemoteMode && hasConfiguredSecretInput(remoteConfig?.password, defaults); - const localPasswordRef = resolveSecretInputRef({ value: auth?.password, defaults }).ref; - const localPasswordCanWinInLocalMode = - authMode === "password" || - (authMode !== "token" && authMode !== "none" && authMode !== "trusted-proxy" && !tokenCanWin); - const localTokenCanWinInLocalMode = - authMode !== "password" && authMode !== "none" && authMode !== "trusted-proxy"; - const localPasswordCanWinInRemoteMode = !remotePasswordConfigured && !tokenCanWin; - const shouldResolveLocalPassword = - Boolean(auth) && - !envPassword && - Boolean(localPasswordRef) && - (context.isRemoteMode ? localPasswordCanWinInRemoteMode : localPasswordCanWinInLocalMode); - if (shouldResolveLocalPassword) { - resolvedConfig = structuredClone(context.config); - const resolvedPassword = await resolveGatewaySecretInputString({ - config: resolvedConfig, - value: resolvedConfig.gateway?.auth?.password, - path: "gateway.auth.password", - env, - }); - if (resolvedConfig.gateway?.auth) { - resolvedConfig.gateway.auth.password = resolvedPassword; - } - } - const remote = context.isRemoteMode ? resolvedConfig.gateway?.remote : undefined; - const resolvedDefaults = resolvedConfig.secrets?.defaults; - if (remote) { - const localToken = trimToUndefined(resolvedConfig.gateway?.auth?.token); - const localPassword = trimToUndefined(resolvedConfig.gateway?.auth?.password); - const passwordCanWinBeforeRemoteTokenResolution = Boolean( - envPassword || localPassword || trimToUndefined(remote.password), - ); - const remoteTokenRef = resolveSecretInputRef({ - value: remote.token, - defaults: resolvedDefaults, - }).ref; - if (!passwordCanWinBeforeRemoteTokenResolution && !envToken && !localToken && remoteTokenRef) { - remote.token = await resolveGatewaySecretInputString({ - config: resolvedConfig, - value: remote.token, - path: "gateway.remote.token", - env, - }); - } +type SupportedGatewaySecretInputPath = + | "gateway.auth.token" + | "gateway.auth.password" + | "gateway.remote.token" + | "gateway.remote.password"; - const tokenCanWin = Boolean(envToken || localToken || trimToUndefined(remote.token)); - const remotePasswordRef = resolveSecretInputRef({ - value: remote.password, - defaults: resolvedDefaults, - }).ref; - if (!tokenCanWin && !envPassword && !localPassword && remotePasswordRef) { - remote.password = await resolveGatewaySecretInputString({ - config: resolvedConfig, - value: remote.password, - path: "gateway.remote.password", - env, - }); - } +const ALL_GATEWAY_SECRET_INPUT_PATHS: SupportedGatewaySecretInputPath[] = [ + "gateway.auth.token", + "gateway.auth.password", + "gateway.remote.token", + "gateway.remote.password", +]; + +function isSupportedGatewaySecretInputPath(path: string): path is SupportedGatewaySecretInputPath { + return ( + path === "gateway.auth.token" || + path === "gateway.auth.password" || + path === "gateway.remote.token" || + path === "gateway.remote.password" + ); +} + +function readGatewaySecretInputValue( + config: OpenClawConfig, + path: SupportedGatewaySecretInputPath, +): unknown { + if (path === "gateway.auth.token") { + return config.gateway?.auth?.token; } - const localModeRemote = !context.isRemoteMode ? resolvedConfig.gateway?.remote : undefined; - if (localModeRemote) { - const localToken = trimToUndefined(resolvedConfig.gateway?.auth?.token); - const localPassword = trimToUndefined(resolvedConfig.gateway?.auth?.password); - const localModePasswordSourceConfigured = Boolean( - envPassword || localPassword || trimToUndefined(localModeRemote.password), - ); - const passwordCanWinBeforeRemoteTokenResolution = - localPasswordCanWinInLocalMode && localModePasswordSourceConfigured; - const remoteTokenRef = resolveSecretInputRef({ - value: localModeRemote.token, - defaults: resolvedDefaults, - }).ref; - if ( - localTokenCanWinInLocalMode && - !passwordCanWinBeforeRemoteTokenResolution && - !envToken && - !localToken && - remoteTokenRef - ) { - localModeRemote.token = await resolveGatewaySecretInputString({ - config: resolvedConfig, - value: localModeRemote.token, - path: "gateway.remote.token", - env, - }); - } - const tokenCanWin = Boolean(envToken || localToken || trimToUndefined(localModeRemote.token)); - const remotePasswordRef = resolveSecretInputRef({ - value: localModeRemote.password, - defaults: resolvedDefaults, - }).ref; - if ( - !tokenCanWin && - !envPassword && - !localPassword && - remotePasswordRef && - localPasswordCanWinInLocalMode - ) { - localModeRemote.password = await resolveGatewaySecretInputString({ - config: resolvedConfig, - value: localModeRemote.password, - path: "gateway.remote.password", - env, - }); - } + if (path === "gateway.auth.password") { + return config.gateway?.auth?.password; } - return resolveGatewayCredentialsFromConfig({ - cfg: resolvedConfig, + if (path === "gateway.remote.token") { + return config.gateway?.remote?.token; + } + return config.gateway?.remote?.password; +} + +function hasConfiguredGatewaySecretRef( + config: OpenClawConfig, + path: SupportedGatewaySecretInputPath, +): boolean { + return Boolean( + resolveSecretInputRef({ + value: readGatewaySecretInputValue(config, path), + defaults: config.secrets?.defaults, + }).ref, + ); +} + +function resolveGatewayCredentialsFromConfigOptions(params: { + context: ResolvedGatewayCallContext; + env: NodeJS.ProcessEnv; + cfg: OpenClawConfig; +}) { + const { context, env, cfg } = params; + return { + cfg, env, explicitAuth: context.explicitAuth, urlOverride: context.urlOverride, urlOverrideSource: context.urlOverrideSource, - remotePasswordPrecedence: "env-first", // pragma: allowlist secret + modeOverride: context.modeOverride, + includeLegacyEnv: context.includeLegacyEnv, + localTokenPrecedence: context.localTokenPrecedence, + localPasswordPrecedence: context.localPasswordPrecedence, + remoteTokenPrecedence: context.remoteTokenPrecedence, + remotePasswordPrecedence: context.remotePasswordPrecedence ?? "env-first", // pragma: allowlist secret + remoteTokenFallback: context.remoteTokenFallback, + remotePasswordFallback: context.remotePasswordFallback, + } as const; +} + +function isTokenGatewaySecretInputPath(path: SupportedGatewaySecretInputPath): boolean { + return path === "gateway.auth.token" || path === "gateway.remote.token"; +} + +function localAuthModeAllowsGatewaySecretInputPath(params: { + authMode: string | undefined; + path: SupportedGatewaySecretInputPath; +}): boolean { + const { authMode, path } = params; + if (authMode === "none" || authMode === "trusted-proxy") { + return false; + } + if (authMode === "token") { + return isTokenGatewaySecretInputPath(path); + } + if (authMode === "password") { + return !isTokenGatewaySecretInputPath(path); + } + return true; +} + +function gatewaySecretInputPathCanWin(params: { + context: ResolvedGatewayCallContext; + env: NodeJS.ProcessEnv; + config: OpenClawConfig; + path: SupportedGatewaySecretInputPath; +}): boolean { + if (!hasConfiguredGatewaySecretRef(params.config, params.path)) { + return false; + } + const mode: GatewayCredentialMode = + params.context.modeOverride ?? (params.config.gateway?.mode === "remote" ? "remote" : "local"); + if ( + mode === "local" && + !localAuthModeAllowsGatewaySecretInputPath({ + authMode: params.config.gateway?.auth?.mode, + path: params.path, + }) + ) { + return false; + } + const sentinel = `__OPENCLAW_GATEWAY_SECRET_REF_PROBE_${params.path.replaceAll(".", "_")}__`; + const probeConfig = structuredClone(params.config); + for (const candidatePath of ALL_GATEWAY_SECRET_INPUT_PATHS) { + if (!hasConfiguredGatewaySecretRef(probeConfig, candidatePath)) { + continue; + } + assignResolvedGatewaySecretInput({ + config: probeConfig, + path: candidatePath, + value: undefined, + }); + } + assignResolvedGatewaySecretInput({ + config: probeConfig, + path: params.path, + value: sentinel, }); + try { + const resolved = resolveGatewayCredentialsFromConfig( + resolveGatewayCredentialsFromConfigOptions({ + context: params.context, + env: params.env, + cfg: probeConfig, + }), + ); + const tokenCanWin = resolved.token === sentinel && !resolved.password; + const passwordCanWin = resolved.password === sentinel && !resolved.token; + return tokenCanWin || passwordCanWin; + } catch { + return false; + } +} + +async function resolveConfiguredGatewaySecretInput(params: { + config: OpenClawConfig; + path: SupportedGatewaySecretInputPath; + env: NodeJS.ProcessEnv; +}): Promise { + const { config, path, env } = params; + if (path === "gateway.auth.token") { + return resolveGatewaySecretInputString({ + config, + value: config.gateway?.auth?.token, + path, + env, + }); + } + if (path === "gateway.auth.password") { + return resolveGatewaySecretInputString({ + config, + value: config.gateway?.auth?.password, + path, + env, + }); + } + if (path === "gateway.remote.token") { + return resolveGatewaySecretInputString({ + config, + value: config.gateway?.remote?.token, + path, + env, + }); + } + return resolveGatewaySecretInputString({ + config, + value: config.gateway?.remote?.password, + path, + env, + }); +} + +function assignResolvedGatewaySecretInput(params: { + config: OpenClawConfig; + path: SupportedGatewaySecretInputPath; + value: string | undefined; +}): void { + const { config, path, value } = params; + if (path === "gateway.auth.token") { + if (config.gateway?.auth) { + config.gateway.auth.token = value; + } + return; + } + if (path === "gateway.auth.password") { + if (config.gateway?.auth) { + config.gateway.auth.password = value; + } + return; + } + if (path === "gateway.remote.token") { + if (config.gateway?.remote) { + config.gateway.remote.token = value; + } + return; + } + if (config.gateway?.remote) { + config.gateway.remote.password = value; + } +} + +async function resolvePreferredGatewaySecretInputs(params: { + context: ResolvedGatewayCallContext; + env: NodeJS.ProcessEnv; + config: OpenClawConfig; +}): Promise { + let nextConfig = params.config; + for (const path of ALL_GATEWAY_SECRET_INPUT_PATHS) { + if ( + !gatewaySecretInputPathCanWin({ + context: params.context, + env: params.env, + config: nextConfig, + path, + }) + ) { + continue; + } + if (nextConfig === params.config) { + nextConfig = structuredClone(params.config); + } + try { + const resolvedValue = await resolveConfiguredGatewaySecretInput({ + config: nextConfig, + path, + env: params.env, + }); + assignResolvedGatewaySecretInput({ + config: nextConfig, + path, + value: resolvedValue, + }); + } catch { + // Keep scanning candidate paths so unresolved higher-priority refs do not + // prevent valid fallback refs from being considered. + continue; + } + } + return nextConfig; +} + +async function resolveGatewayCredentialsFromConfigWithSecretInputs(params: { + context: ResolvedGatewayCallContext; + env: NodeJS.ProcessEnv; +}): Promise<{ token?: string; password?: string }> { + let resolvedConfig = await resolvePreferredGatewaySecretInputs({ + context: params.context, + env: params.env, + config: params.context.config, + }); + const resolvedPaths = new Set(); + for (;;) { + try { + return resolveGatewayCredentialsFromConfig( + resolveGatewayCredentialsFromConfigOptions({ + context: params.context, + env: params.env, + cfg: resolvedConfig, + }), + ); + } catch (error) { + if (!(error instanceof GatewaySecretRefUnavailableError)) { + throw error; + } + const path = error.path; + if (!isSupportedGatewaySecretInputPath(path) || resolvedPaths.has(path)) { + throw error; + } + if (resolvedConfig === params.context.config) { + resolvedConfig = structuredClone(params.context.config); + } + const resolvedValue = await resolveConfiguredGatewaySecretInput({ + config: resolvedConfig, + path, + env: params.env, + }); + assignResolvedGatewaySecretInput({ + config: resolvedConfig, + path, + value: resolvedValue, + }); + resolvedPaths.add(path); + } + } } export async function resolveGatewayCredentialsWithSecretInputs(params: { config: OpenClawConfig; explicitAuth?: ExplicitGatewayAuth; urlOverride?: string; + urlOverrideSource?: "cli" | "env"; env?: NodeJS.ProcessEnv; + modeOverride?: GatewayCredentialMode; + includeLegacyEnv?: boolean; + localTokenPrecedence?: GatewayCredentialPrecedence; + localPasswordPrecedence?: GatewayCredentialPrecedence; + remoteTokenPrecedence?: GatewayRemoteCredentialPrecedence; + remotePasswordPrecedence?: GatewayRemoteCredentialPrecedence; + remoteTokenFallback?: GatewayRemoteCredentialFallback; + remotePasswordFallback?: GatewayRemoteCredentialFallback; }): Promise<{ token?: string; password?: string }> { + const modeOverride = params.modeOverride; + const isRemoteMode = modeOverride + ? modeOverride === "remote" + : params.config.gateway?.mode === "remote"; + const remoteFromConfig = + params.config.gateway?.mode === "remote" + ? (params.config.gateway?.remote as GatewayRemoteSettings | undefined) + : undefined; + const remoteFromOverride = + modeOverride === "remote" + ? (params.config.gateway?.remote as GatewayRemoteSettings | undefined) + : undefined; const context: ResolvedGatewayCallContext = { config: params.config, configPath: resolveConfigPath(process.env, resolveStateDir(process.env)), - isRemoteMode: params.config.gateway?.mode === "remote", - remote: - params.config.gateway?.mode === "remote" - ? (params.config.gateway?.remote as GatewayRemoteSettings | undefined) - : undefined, + isRemoteMode, + remote: remoteFromOverride ?? remoteFromConfig, urlOverride: trimToUndefined(params.urlOverride), - remoteUrl: - params.config.gateway?.mode === "remote" - ? trimToUndefined((params.config.gateway?.remote as GatewayRemoteSettings | undefined)?.url) - : undefined, + urlOverrideSource: params.urlOverrideSource, + remoteUrl: isRemoteMode + ? trimToUndefined((params.config.gateway?.remote as GatewayRemoteSettings | undefined)?.url) + : undefined, explicitAuth: resolveExplicitGatewayAuth(params.explicitAuth), + modeOverride, + includeLegacyEnv: params.includeLegacyEnv, + localTokenPrecedence: params.localTokenPrecedence, + localPasswordPrecedence: params.localPasswordPrecedence, + remoteTokenPrecedence: params.remoteTokenPrecedence, + remotePasswordPrecedence: params.remotePasswordPrecedence, + remoteTokenFallback: params.remoteTokenFallback, + remotePasswordFallback: params.remotePasswordFallback, }; return resolveGatewayCredentialsWithEnv(context, params.env ?? process.env); } diff --git a/src/gateway/client-callsites.guard.test.ts b/src/gateway/client-callsites.guard.test.ts new file mode 100644 index 00000000000..9563a0ea75a --- /dev/null +++ b/src/gateway/client-callsites.guard.test.ts @@ -0,0 +1,59 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const GATEWAY_CLIENT_CONSTRUCTOR_PATTERN = /new\s+GatewayClient\s*\(/; + +const ALLOWED_GATEWAY_CLIENT_CALLSITES = new Set([ + "src/acp/server.ts", + "src/discord/monitor/exec-approvals.ts", + "src/gateway/call.ts", + "src/gateway/probe.ts", + "src/node-host/runner.ts", + "src/tui/gateway-chat.ts", +]); + +async function collectSourceFiles(dir: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await collectSourceFiles(fullPath))); + continue; + } + if (!entry.isFile()) { + continue; + } + if (!entry.name.endsWith(".ts")) { + continue; + } + if ( + entry.name.endsWith(".test.ts") || + entry.name.endsWith(".e2e.ts") || + entry.name.endsWith(".e2e.test.ts") || + entry.name.endsWith(".live.test.ts") + ) { + continue; + } + files.push(fullPath); + } + return files; +} + +describe("GatewayClient production callsites", () => { + it("remain constrained to allowlisted files", async () => { + const root = process.cwd(); + const sourceFiles = await collectSourceFiles(path.join(root, "src")); + const callsites: string[] = []; + for (const fullPath of sourceFiles) { + const relativePath = path.relative(root, fullPath).replaceAll(path.sep, "/"); + const content = await fs.readFile(fullPath, "utf8"); + if (GATEWAY_CLIENT_CONSTRUCTOR_PATTERN.test(content)) { + callsites.push(relativePath); + } + } + const expected = [...ALLOWED_GATEWAY_CLIENT_CALLSITES].toSorted(); + expect(callsites.toSorted()).toEqual(expected); + }); +}); diff --git a/src/gateway/connection-auth.test.ts b/src/gateway/connection-auth.test.ts new file mode 100644 index 00000000000..70118ee0d0e --- /dev/null +++ b/src/gateway/connection-auth.test.ts @@ -0,0 +1,346 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + resolveGatewayConnectionAuth, + resolveGatewayConnectionAuthFromConfig, + type GatewayConnectionAuthOptions, +} from "./connection-auth.js"; + +type ResolvedAuth = { token?: string; password?: string }; + +type ConnectionAuthCase = { + name: string; + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + options?: Partial>; + expected: ResolvedAuth; +}; + +function cfg(input: Partial): OpenClawConfig { + return input as OpenClawConfig; +} + +const DEFAULT_ENV = { + OPENCLAW_GATEWAY_TOKEN: "env-token", + OPENCLAW_GATEWAY_PASSWORD: "env-password", // pragma: allowlist secret +} as NodeJS.ProcessEnv; + +describe("resolveGatewayConnectionAuth", () => { + const cases: ConnectionAuthCase[] = [ + { + name: "local mode defaults to env-first token/password", + cfg: cfg({ + gateway: { + mode: "local", + auth: { + token: "config-token", + password: "config-password", // pragma: allowlist secret + }, + remote: { + token: "remote-token", + password: "remote-password", // pragma: allowlist secret + }, + }, + }), + env: DEFAULT_ENV, + expected: { + token: "env-token", + password: "env-password", // pragma: allowlist secret + }, + }, + { + name: "local mode supports config-first token/password", + cfg: cfg({ + gateway: { + mode: "local", + auth: { + token: "config-token", + password: "config-password", // pragma: allowlist secret + }, + }, + }), + env: DEFAULT_ENV, + options: { + localTokenPrecedence: "config-first", + localPasswordPrecedence: "config-first", + }, + expected: { + token: "config-token", + password: "config-password", // pragma: allowlist secret + }, + }, + { + name: "local mode precedence can mix env-first token with config-first password", + cfg: cfg({ + gateway: { + mode: "local", + auth: {}, + remote: { + token: "remote-token", + password: "remote-password", // pragma: allowlist secret + }, + }, + }), + env: DEFAULT_ENV, + options: { + localTokenPrecedence: "env-first", + localPasswordPrecedence: "config-first", + }, + expected: { + token: "env-token", + password: "remote-password", // pragma: allowlist secret + }, + }, + { + name: "remote mode defaults to remote-first token and env-first password", + cfg: cfg({ + gateway: { + mode: "remote", + auth: { + token: "local-token", + password: "local-password", // pragma: allowlist secret + }, + remote: { + url: "wss://remote.example", + token: "remote-token", + password: "remote-password", // pragma: allowlist secret + }, + }, + }), + env: DEFAULT_ENV, + expected: { + token: "remote-token", + password: "env-password", // pragma: allowlist secret + }, + }, + { + name: "remote mode supports env-first token with remote-first password", + cfg: cfg({ + gateway: { + mode: "remote", + auth: { + token: "local-token", + password: "local-password", // pragma: allowlist secret + }, + remote: { + url: "wss://remote.example", + token: "remote-token", + password: "remote-password", // pragma: allowlist secret + }, + }, + }), + env: DEFAULT_ENV, + options: { + remoteTokenPrecedence: "env-first", + remotePasswordPrecedence: "remote-first", + }, + expected: { + token: "env-token", + password: "remote-password", // pragma: allowlist secret + }, + }, + { + name: "remote-only fallback can suppress env/local password fallback", + cfg: cfg({ + gateway: { + mode: "remote", + auth: { + token: "local-token", + password: "local-password", // pragma: allowlist secret + }, + remote: { + url: "wss://remote.example", + token: "remote-token", + }, + }, + }), + env: DEFAULT_ENV, + options: { + remoteTokenFallback: "remote-only", + remotePasswordFallback: "remote-only", + }, + expected: { + token: "remote-token", + password: undefined, + }, + }, + { + name: "modeOverride can force remote precedence while config gateway.mode is local", + cfg: cfg({ + gateway: { + mode: "local", + auth: { + token: "local-token", + password: "local-password", // pragma: allowlist secret + }, + remote: { + url: "wss://remote.example", + token: "remote-token", + password: "remote-password", // pragma: allowlist secret + }, + }, + }), + env: DEFAULT_ENV, + options: { + modeOverride: "remote", + remoteTokenPrecedence: "remote-first", + remotePasswordPrecedence: "remote-first", + }, + expected: { + token: "remote-token", + password: "remote-password", // pragma: allowlist secret + }, + }, + { + name: "includeLegacyEnv controls CLAWDBOT fallback", + cfg: cfg({ + gateway: { + mode: "local", + auth: {}, + }, + }), + env: { + CLAWDBOT_GATEWAY_TOKEN: "legacy-token", + CLAWDBOT_GATEWAY_PASSWORD: "legacy-password", // pragma: allowlist secret + } as NodeJS.ProcessEnv, + options: { + includeLegacyEnv: true, + }, + expected: { + token: "legacy-token", + password: "legacy-password", // pragma: allowlist secret + }, + }, + ]; + + it.each(cases)("$name", async ({ cfg, env, options, expected }) => { + const asyncResolved = await resolveGatewayConnectionAuth({ + config: cfg, + env, + ...options, + }); + const syncResolved = resolveGatewayConnectionAuthFromConfig({ + cfg, + env, + ...options, + }); + expect(asyncResolved).toEqual(expected); + expect(syncResolved).toEqual(expected); + }); + + it("can disable legacy env fallback", async () => { + const config = cfg({ + gateway: { + mode: "local", + auth: {}, + }, + }); + const env = { + CLAWDBOT_GATEWAY_TOKEN: "legacy-token", + CLAWDBOT_GATEWAY_PASSWORD: "legacy-password", // pragma: allowlist secret + } as NodeJS.ProcessEnv; + + const resolved = await resolveGatewayConnectionAuth({ + config, + env, + includeLegacyEnv: false, + }); + expect(resolved).toEqual({ + token: undefined, + password: undefined, + }); + }); + + it("resolves local SecretRef token when legacy env is disabled", async () => { + const config = cfg({ + gateway: { + mode: "local", + auth: { + token: { source: "env", provider: "default", id: "LOCAL_SECRET_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }); + const env = { + CLAWDBOT_GATEWAY_TOKEN: "legacy-token", + LOCAL_SECRET_TOKEN: "resolved-from-secretref", + } as NodeJS.ProcessEnv; + + const resolved = await resolveGatewayConnectionAuth({ + config, + env, + includeLegacyEnv: false, + }); + expect(resolved).toEqual({ + token: "resolved-from-secretref", + password: undefined, + }); + }); + + it("resolves config-first token SecretRef even when OPENCLAW env token exists", async () => { + const config = cfg({ + gateway: { + mode: "local", + auth: { + token: { source: "env", provider: "default", id: "CONFIG_FIRST_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }); + const env = { + OPENCLAW_GATEWAY_TOKEN: "env-token", + CONFIG_FIRST_TOKEN: "config-first-token", + } as NodeJS.ProcessEnv; + + const resolved = await resolveGatewayConnectionAuth({ + config, + env, + includeLegacyEnv: false, + localTokenPrecedence: "config-first", + }); + expect(resolved).toEqual({ + token: "config-first-token", + password: undefined, + }); + }); + + it("resolves config-first password SecretRef even when OPENCLAW env password exists", async () => { + const config = cfg({ + gateway: { + mode: "local", + auth: { + mode: "password", + password: { source: "env", provider: "default", id: "CONFIG_FIRST_PASSWORD" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }); + const env = { + OPENCLAW_GATEWAY_PASSWORD: "env-password", // pragma: allowlist secret + CONFIG_FIRST_PASSWORD: "config-first-password", // pragma: allowlist secret + } as NodeJS.ProcessEnv; + + const resolved = await resolveGatewayConnectionAuth({ + config, + env, + includeLegacyEnv: false, + localPasswordPrecedence: "config-first", + }); + expect(resolved).toEqual({ + token: undefined, + password: "config-first-password", // pragma: allowlist secret + }); + }); +}); diff --git a/src/gateway/connection-auth.ts b/src/gateway/connection-auth.ts new file mode 100644 index 00000000000..11c40395af6 --- /dev/null +++ b/src/gateway/connection-auth.ts @@ -0,0 +1,66 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { ExplicitGatewayAuth } from "./call.js"; +import { resolveGatewayCredentialsWithSecretInputs } from "./call.js"; +import type { + GatewayCredentialMode, + GatewayCredentialPrecedence, + GatewayRemoteCredentialFallback, + GatewayRemoteCredentialPrecedence, +} from "./credentials.js"; +import { resolveGatewayCredentialsFromConfig } from "./credentials.js"; + +export type GatewayConnectionAuthOptions = { + config: OpenClawConfig; + env?: NodeJS.ProcessEnv; + explicitAuth?: ExplicitGatewayAuth; + urlOverride?: string; + urlOverrideSource?: "cli" | "env"; + modeOverride?: GatewayCredentialMode; + includeLegacyEnv?: boolean; + localTokenPrecedence?: GatewayCredentialPrecedence; + localPasswordPrecedence?: GatewayCredentialPrecedence; + remoteTokenPrecedence?: GatewayRemoteCredentialPrecedence; + remotePasswordPrecedence?: GatewayRemoteCredentialPrecedence; + remoteTokenFallback?: GatewayRemoteCredentialFallback; + remotePasswordFallback?: GatewayRemoteCredentialFallback; +}; + +export async function resolveGatewayConnectionAuth( + params: GatewayConnectionAuthOptions, +): Promise<{ token?: string; password?: string }> { + return await resolveGatewayCredentialsWithSecretInputs({ + config: params.config, + env: params.env, + explicitAuth: params.explicitAuth, + urlOverride: params.urlOverride, + urlOverrideSource: params.urlOverrideSource, + modeOverride: params.modeOverride, + includeLegacyEnv: params.includeLegacyEnv, + localTokenPrecedence: params.localTokenPrecedence, + localPasswordPrecedence: params.localPasswordPrecedence, + remoteTokenPrecedence: params.remoteTokenPrecedence, + remotePasswordPrecedence: params.remotePasswordPrecedence, + remoteTokenFallback: params.remoteTokenFallback, + remotePasswordFallback: params.remotePasswordFallback, + }); +} + +export function resolveGatewayConnectionAuthFromConfig( + params: Omit & { cfg: OpenClawConfig }, +): { token?: string; password?: string } { + return resolveGatewayCredentialsFromConfig({ + cfg: params.cfg, + env: params.env, + explicitAuth: params.explicitAuth, + urlOverride: params.urlOverride, + urlOverrideSource: params.urlOverrideSource, + modeOverride: params.modeOverride, + includeLegacyEnv: params.includeLegacyEnv, + localTokenPrecedence: params.localTokenPrecedence, + localPasswordPrecedence: params.localPasswordPrecedence, + remoteTokenPrecedence: params.remoteTokenPrecedence, + remotePasswordPrecedence: params.remotePasswordPrecedence, + remoteTokenFallback: params.remoteTokenFallback, + remotePasswordFallback: params.remotePasswordFallback, + }); +} diff --git a/src/node-host/invoke-system-run-plan.test.ts b/src/node-host/invoke-system-run-plan.test.ts index a0d1dcbe3e4..07b60c160fe 100644 --- a/src/node-host/invoke-system-run-plan.test.ts +++ b/src/node-host/invoke-system-run-plan.test.ts @@ -24,6 +24,27 @@ type HardeningCase = { checkRawCommandMatchesArgv?: boolean; }; +function createScriptOperandFixture(tmp: string): { + command: string[]; + scriptPath: string; + initialBody: string; +} { + if (process.platform === "win32") { + const scriptPath = path.join(tmp, "run.js"); + return { + command: [process.execPath, "./run.js"], + scriptPath, + initialBody: 'console.log("SAFE");\n', + }; + } + const scriptPath = path.join(tmp, "run.sh"); + return { + command: ["/bin/sh", "./run.sh"], + scriptPath, + initialBody: "#!/bin/sh\necho SAFE\n", + }; +} + describe("hardenApprovedExecutionPaths", () => { const cases: HardeningCase[] = [ { @@ -131,12 +152,14 @@ describe("hardenApprovedExecutionPaths", () => { it("captures mutable shell script operands in approval plans", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-script-plan-")); - const script = path.join(tmp, "run.sh"); - fs.writeFileSync(script, "#!/bin/sh\necho SAFE\n"); - fs.chmodSync(script, 0o755); + const fixture = createScriptOperandFixture(tmp); + fs.writeFileSync(fixture.scriptPath, fixture.initialBody); + if (process.platform !== "win32") { + fs.chmodSync(fixture.scriptPath, 0o755); + } try { const prepared = buildSystemRunApprovalPlan({ - command: ["/bin/sh", "./run.sh"], + command: fixture.command, cwd: tmp, }); expect(prepared.ok).toBe(true); @@ -145,7 +168,7 @@ describe("hardenApprovedExecutionPaths", () => { } expect(prepared.plan.mutableFileOperand).toEqual({ argvIndex: 1, - path: fs.realpathSync(script), + path: fs.realpathSync(fixture.scriptPath), sha256: expect.any(String), }); } finally { diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 43b6148b164..9295460a23a 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -85,6 +85,30 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { }); } + function createMutableScriptOperandFixture(tmp: string): { + command: string[]; + scriptPath: string; + initialBody: string; + changedBody: string; + } { + if (process.platform === "win32") { + const scriptPath = path.join(tmp, "run.js"); + return { + command: [process.execPath, "./run.js"], + scriptPath, + initialBody: 'console.log("SAFE");\n', + changedBody: 'console.log("PWNED");\n', + }; + } + const scriptPath = path.join(tmp, "run.sh"); + return { + command: ["/bin/sh", "./run.sh"], + scriptPath, + initialBody: "#!/bin/sh\necho SAFE\n", + changedBody: "#!/bin/sh\necho PWNED\n", + }; + } + function buildNestedEnvShellCommand(params: { depth: number; payload: string }): string[] { return [...Array(params.depth).fill("/usr/bin/env"), "/bin/sh", "-c", params.payload]; } @@ -692,12 +716,14 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { it("denies approval-based execution when a script operand changes after approval", async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-script-drift-")); - const script = path.join(tmp, "run.sh"); - fs.writeFileSync(script, "#!/bin/sh\necho SAFE\n"); - fs.chmodSync(script, 0o755); + const fixture = createMutableScriptOperandFixture(tmp); + fs.writeFileSync(fixture.scriptPath, fixture.initialBody); + if (process.platform !== "win32") { + fs.chmodSync(fixture.scriptPath, 0o755); + } try { const prepared = buildSystemRunApprovalPlan({ - command: ["/bin/sh", "./run.sh"], + command: fixture.command, cwd: tmp, }); expect(prepared.ok).toBe(true); @@ -705,7 +731,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { throw new Error("unreachable"); } - fs.writeFileSync(script, "#!/bin/sh\necho PWNED\n"); + fs.writeFileSync(fixture.scriptPath, fixture.changedBody); const { runCommand, sendInvokeResult } = await runSystemInvoke({ preferMacAppExecHost: false, command: prepared.plan.argv, @@ -729,12 +755,14 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { it("keeps approved shell script execution working when the script is unchanged", async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-script-stable-")); - const script = path.join(tmp, "run.sh"); - fs.writeFileSync(script, "#!/bin/sh\necho SAFE\n"); - fs.chmodSync(script, 0o755); + const fixture = createMutableScriptOperandFixture(tmp); + fs.writeFileSync(fixture.scriptPath, fixture.initialBody); + if (process.platform !== "win32") { + fs.chmodSync(fixture.scriptPath, 0o755); + } try { const prepared = buildSystemRunApprovalPlan({ - command: ["/bin/sh", "./run.sh"], + command: fixture.command, cwd: tmp, }); expect(prepared.ok).toBe(true); diff --git a/src/node-host/runner.credentials.test.ts b/src/node-host/runner.credentials.test.ts index 543459161f5..9c17c605421 100644 --- a/src/node-host/runner.credentials.test.ts +++ b/src/node-host/runner.credentials.test.ts @@ -20,6 +20,56 @@ function createRemoteGatewayTokenRefConfig(tokenId: string): OpenClawConfig { } describe("resolveNodeHostGatewayCredentials", () => { + it("does not inherit gateway.remote token in local mode", async () => { + const config = { + gateway: { + mode: "local", + remote: { token: "remote-only-token" }, + }, + } as OpenClawConfig; + + await withEnvAsync( + { + OPENCLAW_GATEWAY_TOKEN: undefined, + OPENCLAW_GATEWAY_PASSWORD: undefined, + }, + async () => { + const credentials = await resolveNodeHostGatewayCredentials({ config }); + expect(credentials.token).toBeUndefined(); + expect(credentials.password).toBeUndefined(); + }, + ); + }); + + it("ignores unresolved gateway.remote token refs in local mode", async () => { + const config = { + secrets: { + providers: { + default: { source: "env" }, + }, + }, + gateway: { + mode: "local", + remote: { + token: { source: "env", provider: "default", id: "MISSING_REMOTE_GATEWAY_TOKEN" }, + }, + }, + } as OpenClawConfig; + + await withEnvAsync( + { + OPENCLAW_GATEWAY_TOKEN: undefined, + OPENCLAW_GATEWAY_PASSWORD: undefined, + MISSING_REMOTE_GATEWAY_TOKEN: undefined, + }, + async () => { + const credentials = await resolveNodeHostGatewayCredentials({ config }); + expect(credentials.token).toBeUndefined(); + expect(credentials.password).toBeUndefined(); + }, + ); + }); + it("resolves remote token SecretRef values", async () => { const config = createRemoteGatewayTokenRefConfig("REMOTE_GATEWAY_TOKEN"); diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index a20decb84d1..13045d6bb5d 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -1,7 +1,7 @@ import { resolveBrowserConfig } from "../browser/config.js"; import { loadConfig, type OpenClawConfig } from "../config/config.js"; -import { normalizeSecretInputString } from "../config/types.secrets.js"; import { GatewayClient } from "../gateway/client.js"; +import { resolveGatewayConnectionAuth } from "../gateway/connection-auth.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import type { SkillBinTrustEntry } from "../infra/exec-approvals.js"; import { resolveExecutableFromPathEnv } from "../infra/executable-path.js"; @@ -12,7 +12,6 @@ import { NODE_SYSTEM_RUN_COMMANDS, } from "../infra/node-commands.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; -import { resolveSecretInputString } from "../secrets/resolve-secret-input-string.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { VERSION } from "../version.js"; import { ensureNodeHostConfig, saveNodeHostConfig, type NodeHostGatewayConfig } from "./config.js"; @@ -110,73 +109,36 @@ function ensureNodePathEnv(): string { return DEFAULT_NODE_PATH; } -async function resolveNodeHostSecretInputString(params: { - config: OpenClawConfig; - value: unknown; - path: string; - env: NodeJS.ProcessEnv; -}): Promise { - const resolvedValue = await resolveSecretInputString({ - config: params.config, - value: params.value, - env: params.env, - onResolveRefError: (error) => { - const detail = error instanceof Error ? error.message : String(error); - throw new Error(`${params.path} secret reference could not be resolved: ${detail}`, { - cause: error, - }); - }, - }); - if (!resolvedValue) { - throw new Error(`${params.path} resolved to an empty or non-string value.`); - } - return resolvedValue; -} - export async function resolveNodeHostGatewayCredentials(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv; }): Promise<{ token?: string; password?: string }> { - const env = params.env ?? process.env; - const isRemoteMode = params.config.gateway?.mode === "remote"; - const authMode = params.config.gateway?.auth?.mode; - const tokenPath = isRemoteMode ? "gateway.remote.token" : "gateway.auth.token"; - const passwordPath = isRemoteMode ? "gateway.remote.password" : "gateway.auth.password"; - const configuredToken = isRemoteMode - ? params.config.gateway?.remote?.token - : params.config.gateway?.auth?.token; - const configuredPassword = isRemoteMode - ? params.config.gateway?.remote?.password - : params.config.gateway?.auth?.password; + const mode = params.config.gateway?.mode === "remote" ? "remote" : "local"; + const configForResolution = + mode === "local" ? buildNodeHostLocalAuthConfig(params.config) : params.config; + return await resolveGatewayConnectionAuth({ + config: configForResolution, + env: params.env, + includeLegacyEnv: false, + localTokenPrecedence: "env-first", + localPasswordPrecedence: "env-first", + remoteTokenPrecedence: "env-first", + remotePasswordPrecedence: "env-first", + }); +} - const token = - normalizeSecretInputString(env.OPENCLAW_GATEWAY_TOKEN) ?? - (await resolveNodeHostSecretInputString({ - config: params.config, - value: configuredToken, - path: tokenPath, - env, - })); - const tokenCanWin = Boolean(token); - const localPasswordCanWin = - authMode === "password" || - (authMode !== "token" && authMode !== "none" && authMode !== "trusted-proxy" && !tokenCanWin); - const shouldResolveConfiguredPassword = - !normalizeSecretInputString(env.OPENCLAW_GATEWAY_PASSWORD) && - !tokenCanWin && - (isRemoteMode || localPasswordCanWin); - const password = - normalizeSecretInputString(env.OPENCLAW_GATEWAY_PASSWORD) ?? - (shouldResolveConfiguredPassword - ? await resolveNodeHostSecretInputString({ - config: params.config, - value: configuredPassword, - path: passwordPath, - env, - }) - : normalizeSecretInputString(configuredPassword)); - - return { token, password }; +function buildNodeHostLocalAuthConfig(config: OpenClawConfig): OpenClawConfig { + if (!config.gateway?.remote?.token && !config.gateway?.remote?.password) { + return config; + } + const nextConfig = structuredClone(config); + if (nextConfig.gateway?.remote) { + // Local node-host must not inherit gateway.remote.* auth material, which can + // suppress GatewayClient device-token fallback and cause local token mismatches. + nextConfig.gateway.remote.token = undefined; + nextConfig.gateway.remote.password = undefined; + } + return nextConfig; } export async function runNodeHost(opts: NodeHostRunOptions): Promise {