diff --git a/CHANGELOG.md b/CHANGELOG.md index e56617806..970e61a18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ Docs: https://docs.openclaw.ai - Cron/job snapshot persistence: skip backup during normalization persistence in `ensureLoaded` so `jobs.json.bak` keeps the pre-edit snapshot for recovery, while preserving backup creation on explicit user-driven writes. (#35234) Thanks @0xsline. - TTS/OpenAI-compatible endpoints: add `messages.tts.openai.baseUrl` config support with config-over-env precedence, endpoint-aware directive validation, and OpenAI TTS request routing to the resolved base URL. (#34321) thanks @RealKai42. - Plugins/before_prompt_build system-context fields: add `prependSystemContext` and `appendSystemContext` so static plugin guidance can be placed in system prompt space for provider caching and lower repeated prompt token cost. (#35177) thanks @maweibin. +- Gateway: add SecretRef support for gateway.auth.token with auth-mode guardrails. (#35094) Thanks @joshavant. + +### Breaking + +- **BREAKING:** Gateway auth now requires explicit `gateway.auth.mode` when both `gateway.auth.token` and `gateway.auth.password` are configured (including SecretRefs). Set `gateway.auth.mode` to `token` or `password` before upgrade to avoid startup/pairing/TUI failures. (#35094) Thanks @joshavant. ### Fixes diff --git a/docs/cli/configure.md b/docs/cli/configure.md index 0055abec7..c12b717fc 100644 --- a/docs/cli/configure.md +++ b/docs/cli/configure.md @@ -24,6 +24,9 @@ Notes: - Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need. - Channel-oriented services (Slack/Discord/Matrix/Microsoft Teams) prompt for channel/room allowlists during setup. You can enter names or IDs; the wizard resolves names to IDs when possible. +- If you run the daemon install step, token auth requires a token, and `gateway.auth.token` is SecretRef-managed, configure 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, configure blocks daemon install with actionable remediation guidance. +- If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, configure blocks daemon install until mode is set explicitly. ## Examples diff --git a/docs/cli/daemon.md b/docs/cli/daemon.md index 4b5ebf45d..5a5db7feb 100644 --- a/docs/cli/daemon.md +++ b/docs/cli/daemon.md @@ -38,6 +38,13 @@ openclaw daemon uninstall - `install`: `--port`, `--runtime `, `--token`, `--force`, `--json` - lifecycle (`uninstall|start|stop|restart`): `--json` +Notes: + +- `status` resolves configured auth SecretRefs for probe auth when possible. +- 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. + ## Prefer Use [`openclaw gateway`](/cli/gateway) for current docs and examples. diff --git a/docs/cli/dashboard.md b/docs/cli/dashboard.md index f49c1be2a..2ac818593 100644 --- a/docs/cli/dashboard.md +++ b/docs/cli/dashboard.md @@ -14,3 +14,9 @@ Open the Control UI using your current auth. openclaw dashboard openclaw dashboard --no-open ``` + +Notes: + +- `dashboard` resolves configured `gateway.auth.token` SecretRefs when possible. +- For SecretRef-managed tokens (resolved or unresolved), `dashboard` prints/copies/opens a non-tokenized URL to avoid exposing external secrets in terminal output, clipboard history, or browser-launch arguments. +- If `gateway.auth.token` is SecretRef-managed but unresolved in this command path, the command prints a non-tokenized URL and explicit remediation guidance instead of embedding an invalid token placeholder. diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 69082c5f1..371e73070 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -105,6 +105,11 @@ Options: - `--no-probe`: skip the RPC probe (service-only view). - `--deep`: scan system-level services too. +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. + ### `gateway probe` `gateway probe` is the “debug everything” command. It always probes: @@ -162,6 +167,10 @@ openclaw gateway uninstall Notes: - `gateway install` supports `--port`, `--runtime`, `--token`, `--force`, `--json`. +- When token auth requires a token and `gateway.auth.token` is SecretRef-managed, `gateway 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 instead of persisting fallback plaintext. +- In inferred auth mode, shell-only `OPENCLAW_GATEWAY_PASSWORD`/`CLAWDBOT_GATEWAY_PASSWORD` does not relax install token requirements; use durable config (`gateway.auth.password` or config `env`) when installing a managed service. +- 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. - Lifecycle commands accept `--json` for scripting. ## Discover gateways (Bonjour) diff --git a/docs/cli/index.md b/docs/cli/index.md index b35d880c6..cddd2a7d6 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -359,6 +359,7 @@ Options: - `--gateway-bind ` - `--gateway-auth ` - `--gateway-token ` +- `--gateway-token-ref-env ` (non-interactive; store `gateway.auth.token` as an env SecretRef; requires that env var to be set; cannot be combined with `--gateway-token`) - `--gateway-password ` - `--remote-url ` - `--remote-token ` diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index 069c89082..36629a3bb 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -61,6 +61,28 @@ Non-interactive `ref` mode contract: - Do not pass inline key flags (for example `--openai-api-key`) unless that env var is also set. - If an inline key flag is passed without the required env var, onboarding fails fast with guidance. +Gateway token options in non-interactive mode: + +- `--gateway-auth token --gateway-token ` stores a plaintext token. +- `--gateway-auth token --gateway-token-ref-env ` stores `gateway.auth.token` as an env SecretRef. +- `--gateway-token` and `--gateway-token-ref-env` are mutually exclusive. +- `--gateway-token-ref-env` requires a non-empty env var in the onboarding process environment. +- With `--install-daemon`, when token auth requires a token, SecretRef-managed gateway tokens are validated but not persisted as resolved plaintext in supervisor service environment metadata. +- With `--install-daemon`, if token mode requires a token and the configured token SecretRef is unresolved, onboarding fails closed with remediation guidance. +- With `--install-daemon`, if both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, onboarding blocks install until mode is set explicitly. + +Example: + +```bash +export OPENCLAW_GATEWAY_TOKEN="your-token" +openclaw onboard --non-interactive \ + --mode local \ + --auth-choice skip \ + --gateway-auth token \ + --gateway-token-ref-env OPENCLAW_GATEWAY_TOKEN \ + --accept-risk +``` + Interactive onboarding behavior with reference mode: - Choose **Use secret reference** when prompted. diff --git a/docs/cli/qr.md b/docs/cli/qr.md index 98fbbcacf..2fc070ca1 100644 --- a/docs/cli/qr.md +++ b/docs/cli/qr.md @@ -35,7 +35,10 @@ openclaw qr --url wss://gateway.example/ws --token '' - `--token` and `--password` are mutually exclusive. - With `--remote`, if effectively active remote credentials are configured as SecretRefs and you do not pass `--token` or `--password`, the command resolves them from the active gateway snapshot. If gateway is unavailable, the command fails fast. -- Without `--remote`, local `gateway.auth.password` SecretRefs are resolved when password auth can win (explicit `gateway.auth.mode="password"` or inferred password mode with no winning token from auth/env), and no CLI auth override is passed. +- Without `--remote`, local gateway auth SecretRefs are resolved when no CLI auth override is passed: + - `gateway.auth.token` resolves when token auth can win (explicit `gateway.auth.mode="token"` or inferred mode where no password source wins). + - `gateway.auth.password` resolves when password auth can win (explicit `gateway.auth.mode="password"` or inferred mode with no winning token from auth/env). +- If both `gateway.auth.token` and `gateway.auth.password` are configured (including SecretRefs) and `gateway.auth.mode` is unset, setup-code resolution fails until mode is set explicitly. - Gateway version skew note: this command path requires a gateway that supports `secrets.resolve`; older gateways return an unknown-method error. - After scanning, approve device pairing with: - `openclaw devices list` diff --git a/docs/cli/tui.md b/docs/cli/tui.md index 2b6d9f45e..de84ae08d 100644 --- a/docs/cli/tui.md +++ b/docs/cli/tui.md @@ -14,6 +14,10 @@ Related: - TUI guide: [TUI](/web/tui) +Notes: + +- `tui` resolves configured gateway auth SecretRefs for token/password auth when possible (`env`/`file`/`exec` providers). + ## Examples ```bash diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 3e9eeb7db..8ef6bce12 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2431,6 +2431,7 @@ See [Plugins](/tools/plugin). - **Legacy bind aliases**: use bind mode values in `gateway.bind` (`auto`, `loopback`, `lan`, `tailnet`, `custom`), not host aliases (`0.0.0.0`, `127.0.0.1`, `localhost`, `::`, `::1`). - **Docker note**: the default `loopback` bind listens on `127.0.0.1` inside the container. With Docker bridge networking (`-p 18789:18789`), traffic arrives on `eth0`, so the gateway is unreachable. Use `--network host`, or set `bind: "lan"` (or `bind: "custom"` with `customBindHost: "0.0.0.0"`) to listen on all interfaces. - **Auth**: required by default. Non-loopback binds require a shared token/password. Onboarding wizard generates a token by default. +- If both `gateway.auth.token` and `gateway.auth.password` are configured (including SecretRefs), set `gateway.auth.mode` explicitly to `token` or `password`. Startup and service install/repair flows fail when both are configured and mode is unset. - `gateway.auth.mode: "none"`: explicit no-auth mode. Use only for trusted local loopback setups; this is intentionally not offered by onboarding prompts. - `gateway.auth.mode: "trusted-proxy"`: delegate auth to an identity-aware reverse proxy and trust identity headers from `gateway.trustedProxies` (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)). - `gateway.auth.allowTailscale`: when `true`, Tailscale Serve identity headers can satisfy Control UI/WebSocket auth (verified via `tailscale whois`); HTTP API endpoints still require token/password auth. This tokenless flow assumes the gateway host is trusted. Defaults to `true` when `tailscale.mode = "serve"`. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 3718b01b2..73264b255 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -77,7 +77,7 @@ cat ~/.openclaw/openclaw.json - Gateway runtime best-practice checks (Node vs Bun, version-manager paths). - Gateway port collision diagnostics (default `18789`). - Security warnings for open DM policies. -- Gateway auth warnings when no `gateway.auth.token` is set (local mode; offers token generation). +- Gateway auth checks for local token mode (offers token generation when no token source exists; does not overwrite token SecretRef configs). - systemd linger check on Linux. - Source install checks (pnpm workspace mismatch, missing UI assets, missing tsx binary). - Writes updated config + wizard metadata. @@ -238,9 +238,11 @@ workspace. ### 12) Gateway auth checks (local token) -Doctor warns when `gateway.auth` is missing on a local gateway and offers to -generate a token. Use `openclaw doctor --generate-gateway-token` to force token -creation in automation. +Doctor checks local gateway token auth readiness. + +- If token mode needs a token and no token source exists, doctor offers to generate one. +- If `gateway.auth.token` is SecretRef-managed but unavailable, doctor warns and does not overwrite it with plaintext. +- `openclaw doctor --generate-gateway-token` forces generation only when no token SecretRef is configured. ### 13) Gateway health check + restart @@ -265,6 +267,9 @@ Notes: - `openclaw doctor --yes` accepts the default repair prompts. - `openclaw doctor --repair` applies recommended fixes without prompts. - `openclaw doctor --repair --force` overwrites custom supervisor configs. +- 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. - You can always force a full rewrite via `openclaw gateway install --force`. ### 16) Gateway runtime + port diagnostics diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 066da56d3..4c286f67e 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -46,11 +46,13 @@ Examples of inactive surfaces: In local mode without those remote surfaces: - `gateway.remote.token` is active when token auth can win and no env/auth token is configured. - `gateway.remote.password` is active only when password auth can win and no env/auth password is configured. +- `gateway.auth.token` SecretRef is inactive for startup auth resolution when `OPENCLAW_GATEWAY_TOKEN` (or `CLAWDBOT_GATEWAY_TOKEN`) is set, because env token input wins for that runtime. ## Gateway auth surface diagnostics -When a SecretRef is configured on `gateway.auth.password`, `gateway.remote.token`, or -`gateway.remote.password`, gateway startup/reload logs the surface state explicitly: +When a SecretRef is configured on `gateway.auth.token`, `gateway.auth.password`, +`gateway.remote.token`, or `gateway.remote.password`, gateway startup/reload logs the +surface state explicitly: - `active`: the SecretRef is part of the effective auth surface and must resolve. - `inactive`: the SecretRef is ignored for this runtime because another auth surface wins, or @@ -65,6 +67,7 @@ When onboarding runs in interactive mode and you choose SecretRef storage, OpenC - Env refs: validates env var name and confirms a non-empty value is visible during onboarding. - Provider refs (`file` or `exec`): validates provider selection, resolves `id`, and checks resolved value type. +- Quickstart reuse path: when `gateway.auth.token` is already a SecretRef, onboarding resolves it before probe/dashboard bootstrap (for `env`, `file`, and `exec` refs) using the same fail-fast gate. If validation fails, onboarding shows the error and lets you retry. diff --git a/docs/reference/secretref-credential-surface.md b/docs/reference/secretref-credential-surface.md index 5b54e552f..d356e4f80 100644 --- a/docs/reference/secretref-credential-surface.md +++ b/docs/reference/secretref-credential-surface.md @@ -36,6 +36,7 @@ Scope intent: - `tools.web.search.kimi.apiKey` - `tools.web.search.perplexity.apiKey` - `gateway.auth.password` +- `gateway.auth.token` - `gateway.remote.token` - `gateway.remote.password` - `cron.webhookToken` @@ -107,7 +108,6 @@ Out-of-scope credentials include: [//]: # "secretref-unsupported-list-start" -- `gateway.auth.token` - `commands.ownerDisplaySecret` - `channels.matrix.accessToken` - `channels.matrix.accounts.*.accessToken` diff --git a/docs/reference/secretref-user-supplied-credentials-matrix.json b/docs/reference/secretref-user-supplied-credentials-matrix.json index 67f00caf4..ac454a605 100644 --- a/docs/reference/secretref-user-supplied-credentials-matrix.json +++ b/docs/reference/secretref-user-supplied-credentials-matrix.json @@ -7,7 +7,6 @@ "commands.ownerDisplaySecret", "channels.matrix.accessToken", "channels.matrix.accounts.*.accessToken", - "gateway.auth.token", "hooks.token", "hooks.gmail.pushToken", "hooks.mappings[].sessionKey", @@ -385,6 +384,13 @@ "secretShape": "secret_input", "optIn": true }, + { + "id": "gateway.auth.token", + "configFile": "openclaw.json", + "path": "gateway.auth.token", + "secretShape": "secret_input", + "optIn": true + }, { "id": "gateway.remote.password", "configFile": "openclaw.json", diff --git a/docs/reference/wizard.md b/docs/reference/wizard.md index 1f7d561b6..328063a01 100644 --- a/docs/reference/wizard.md +++ b/docs/reference/wizard.md @@ -71,6 +71,15 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard). - Port, bind, auth mode, tailscale exposure. - Auth recommendation: keep **Token** even for loopback so local WS clients must authenticate. + - In token mode, interactive onboarding offers: + - **Generate/store plaintext token** (default) + - **Use SecretRef** (opt-in) + - Quickstart reuses existing `gateway.auth.token` SecretRefs across `env`, `file`, and `exec` providers for onboarding probe/dashboard bootstrap. + - If that SecretRef is configured but cannot be resolved, onboarding fails early with a clear fix message instead of silently degrading runtime auth. + - In password mode, interactive onboarding also supports plaintext or SecretRef storage. + - Non-interactive token SecretRef path: `--gateway-token-ref-env `. + - Requires a non-empty env var in the onboarding process environment. + - Cannot be combined with `--gateway-token`. - Disable auth only if you fully trust every local process. - Non‑loopback binds still require auth. @@ -92,6 +101,9 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard). - Wizard attempts to enable lingering via `loginctl enable-linger ` so the Gateway stays up after logout. - May prompt for sudo (writes `/var/lib/systemd/linger`); it tries without sudo first. - **Runtime selection:** Node (recommended; required for WhatsApp/Telegram). Bun is **not recommended**. + - If token auth requires a token and `gateway.auth.token` is SecretRef-managed, daemon install validates it 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, daemon install is blocked with actionable guidance. + - If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, daemon install is blocked until mode is set explicitly. - Starts the Gateway (if needed) and runs `openclaw health`. @@ -130,6 +142,19 @@ openclaw onboard --non-interactive \ Add `--json` for a machine‑readable summary. +Gateway token SecretRef in non-interactive mode: + +```bash +export OPENCLAW_GATEWAY_TOKEN="your-token" +openclaw onboard --non-interactive \ + --mode local \ + --auth-choice skip \ + --gateway-auth token \ + --gateway-token-ref-env OPENCLAW_GATEWAY_TOKEN +``` + +`--gateway-token` and `--gateway-token-ref-env` are mutually exclusive. + `--json` does **not** imply non-interactive mode. Use `--non-interactive` (and `--workspace`) for scripts. diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index 237b7f716..df2149897 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -51,6 +51,13 @@ It does not install or modify anything on the remote host. - Prompts for port, bind, auth mode, and tailscale exposure. - Recommended: keep token auth enabled even for loopback so local WS clients must authenticate. + - In token mode, interactive onboarding offers: + - **Generate/store plaintext token** (default) + - **Use SecretRef** (opt-in) + - In password mode, interactive onboarding also supports plaintext or SecretRef storage. + - Non-interactive token SecretRef path: `--gateway-token-ref-env `. + - Requires a non-empty env var in the onboarding process environment. + - Cannot be combined with `--gateway-token`. - Disable auth only if you fully trust every local process. - Non-loopback binds still require auth. @@ -206,7 +213,7 @@ Credential and profile paths: - OAuth credentials: `~/.openclaw/credentials/oauth.json` - Auth profiles (API keys + OAuth): `~/.openclaw/agents//agent/auth-profiles.json` -API key storage mode: +Credential storage mode: - Default onboarding behavior persists API keys as plaintext values in auth profiles. - `--secret-input-mode ref` enables reference mode instead of plaintext key storage. @@ -222,6 +229,10 @@ API key storage mode: - Inline key flags (for example `--openai-api-key`) require that env var to be set; otherwise onboarding fails fast. - For custom providers, non-interactive `ref` mode stores `models.providers..apiKey` as `{ source: "env", provider: "default", id: "CUSTOM_API_KEY" }`. - In that custom-provider case, `--custom-api-key` requires `CUSTOM_API_KEY` to be set; otherwise onboarding fails fast. +- Gateway auth credentials support plaintext and SecretRef choices in interactive onboarding: + - Token mode: **Generate/store plaintext token** (default) or **Use SecretRef**. + - Password mode: plaintext or SecretRef. +- Non-interactive token SecretRef path: `--gateway-token-ref-env `. - Existing plaintext setups continue to work unchanged. diff --git a/docs/start/wizard.md b/docs/start/wizard.md index 15b6eda82..5a7ddcd40 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -72,8 +72,13 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control). In interactive runs, choosing secret reference mode lets you point at either an environment variable or a configured provider ref (`file` or `exec`), with a fast preflight validation before saving. 2. **Workspace** — Location for agent files (default `~/.openclaw/workspace`). Seeds bootstrap files. 3. **Gateway** — Port, bind address, auth mode, Tailscale exposure. + In interactive token mode, choose default plaintext token storage or opt into SecretRef. + Non-interactive token SecretRef path: `--gateway-token-ref-env `. 4. **Channels** — WhatsApp, Telegram, Discord, Google Chat, Mattermost, Signal, BlueBubbles, or iMessage. 5. **Daemon** — Installs a LaunchAgent (macOS) or systemd user unit (Linux/WSL2). + If token auth requires a token and `gateway.auth.token` is SecretRef-managed, daemon install validates it but does not persist the resolved token into supervisor service environment metadata. + If token auth requires a token and the configured token SecretRef is unresolved, daemon install is blocked with actionable guidance. + If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, daemon install is blocked until mode is set explicitly. 6. **Health check** — Starts the Gateway and verifies it's running. 7. **Skills** — Installs recommended skills and optional dependencies. diff --git a/docs/web/dashboard.md b/docs/web/dashboard.md index 0aed38b2c..02e084ffd 100644 --- a/docs/web/dashboard.md +++ b/docs/web/dashboard.md @@ -37,10 +37,15 @@ Prefer localhost, Tailscale Serve, or an SSH tunnel. - **Localhost**: open `http://127.0.0.1:18789/`. - **Token source**: `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`); the UI stores a copy in localStorage after you connect. +- If `gateway.auth.token` is SecretRef-managed, `openclaw dashboard` prints/copies/opens a non-tokenized URL by design. This avoids exposing externally managed tokens in shell logs, clipboard history, or browser-launch arguments. +- If `gateway.auth.token` is configured as a SecretRef and is unresolved in your current shell, `openclaw dashboard` still prints a non-tokenized URL plus actionable auth setup guidance. - **Not localhost**: use Tailscale Serve (tokenless for Control UI/WebSocket if `gateway.auth.allowTailscale: true`, assumes trusted gateway host; HTTP APIs still need token/password), tailnet bind with a token, or an SSH tunnel. See [Web surfaces](/web). ## If you see “unauthorized” / 1008 - Ensure the gateway is reachable (local: `openclaw status`; remote: SSH tunnel `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/`). -- Retrieve the token from the gateway host: `openclaw config get gateway.auth.token` (or generate one: `openclaw doctor --generate-gateway-token`). +- Retrieve or supply the token from the gateway host: + - Plaintext config: `openclaw config get gateway.auth.token` + - SecretRef-managed config: resolve the external secret provider or export `OPENCLAW_GATEWAY_TOKEN` in this shell, then rerun `openclaw dashboard` + - No token configured: `openclaw doctor --generate-gateway-token` - In the dashboard settings, paste the token into the auth field, then connect. diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index 6b8fdc396..4aba038b4 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -99,11 +99,13 @@ export async function downloadImageFeishu(params: { throw new Error(`Feishu account "${account.accountId}" not configured`); } - const client = createFeishuClient(account); + const client = createFeishuClient({ + ...account, + httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS, + }); const response = await client.im.image.get({ path: { image_key: normalizedImageKey }, - timeout: FEISHU_MEDIA_HTTP_TIMEOUT_MS, }); const buffer = await readFeishuResponseBuffer({ @@ -135,12 +137,14 @@ export async function downloadMessageResourceFeishu(params: { throw new Error(`Feishu account "${account.accountId}" not configured`); } - const client = createFeishuClient(account); + const client = createFeishuClient({ + ...account, + httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS, + }); const response = await client.im.messageResource.get({ path: { message_id: messageId, file_key: normalizedFileKey }, params: { type }, - timeout: FEISHU_MEDIA_HTTP_TIMEOUT_MS, }); const buffer = await readFeishuResponseBuffer({ @@ -180,7 +184,10 @@ export async function uploadImageFeishu(params: { throw new Error(`Feishu account "${account.accountId}" not configured`); } - const client = createFeishuClient(account); + const client = createFeishuClient({ + ...account, + httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS, + }); // SDK accepts Buffer directly or fs.ReadStream for file paths // Using Readable.from(buffer) causes issues with form-data library @@ -193,7 +200,6 @@ export async function uploadImageFeishu(params: { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK accepts Buffer or ReadStream image: imageData as any, }, - timeout: FEISHU_MEDIA_HTTP_TIMEOUT_MS, }); // SDK v1.30+ returns data directly without code wrapper on success @@ -248,7 +254,10 @@ export async function uploadFileFeishu(params: { throw new Error(`Feishu account "${account.accountId}" not configured`); } - const client = createFeishuClient(account); + const client = createFeishuClient({ + ...account, + httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS, + }); // SDK accepts Buffer directly or fs.ReadStream for file paths // Using Readable.from(buffer) causes issues with form-data library @@ -265,7 +274,6 @@ export async function uploadFileFeishu(params: { file: fileData as any, ...(duration !== undefined && { duration }), }, - timeout: FEISHU_MEDIA_HTTP_TIMEOUT_MS, }); // SDK v1.30+ returns data directly without code wrapper on success diff --git a/src/agents/tools/gateway.test.ts b/src/agents/tools/gateway.test.ts index 5faeaba54..5f7687754 100644 --- a/src/agents/tools/gateway.test.ts +++ b/src/agents/tools/gateway.test.ts @@ -107,6 +107,27 @@ describe("gateway tool defaults", () => { expect(opts.token).toBeUndefined(); }); + it("ignores unresolved local token SecretRef for strict remote overrides", () => { + configState.value = { + gateway: { + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" }, + }, + remote: { + url: "wss://gateway.example", + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + const opts = resolveGatewayOptions({ gatewayUrl: "wss://gateway.example" }); + expect(opts.token).toBeUndefined(); + }); + it("explicit gatewayToken overrides fallback token resolution", () => { process.env.OPENCLAW_GATEWAY_TOKEN = "local-env-token"; configState.value = { diff --git a/src/browser/control-auth.auto-token.test.ts b/src/browser/control-auth.auto-token.test.ts index 85fc32f8a..9882768cc 100644 --- a/src/browser/control-auth.auto-token.test.ts +++ b/src/browser/control-auth.auto-token.test.ts @@ -132,4 +132,29 @@ describe("ensureBrowserControlAuth", () => { expect(result).toEqual({ auth: { token: "latest-token" } }); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }); + + it("fails when gateway.auth.token SecretRef is unresolved", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_GW_TOKEN" }, + }, + }, + browser: { + enabled: true, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + mocks.loadConfig.mockReturnValue(cfg); + + await expect(ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow( + /MISSING_GW_TOKEN/i, + ); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); }); diff --git a/src/browser/control-auth.ts b/src/browser/control-auth.ts index abbafc8d0..be7c66ab4 100644 --- a/src/browser/control-auth.ts +++ b/src/browser/control-auth.ts @@ -87,7 +87,10 @@ export async function ensureBrowserControlAuth(params: { env, persist: true, }); - const ensuredAuth = resolveBrowserControlAuth(ensured.cfg, env); + const ensuredAuth = { + token: ensured.auth.token, + password: ensured.auth.password, + }; return { auth: ensuredAuth, generatedToken: ensured.generatedToken, diff --git a/src/browser/extension-relay-auth.secretref.test.ts b/src/browser/extension-relay-auth.secretref.test.ts new file mode 100644 index 000000000..7976064f3 --- /dev/null +++ b/src/browser/extension-relay-auth.secretref.test.ts @@ -0,0 +1,117 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const loadConfigMock = vi.hoisted(() => vi.fn()); + +vi.mock("../config/config.js", () => ({ + loadConfig: loadConfigMock, +})); + +const { resolveRelayAcceptedTokensForPort } = await import("./extension-relay-auth.js"); + +describe("extension-relay-auth SecretRef handling", () => { + const ENV_KEYS = ["OPENCLAW_GATEWAY_TOKEN", "CLAWDBOT_GATEWAY_TOKEN", "CUSTOM_GATEWAY_TOKEN"]; + const envSnapshot = new Map(); + + beforeEach(() => { + for (const key of ENV_KEYS) { + envSnapshot.set(key, process.env[key]); + delete process.env[key]; + } + loadConfigMock.mockReset(); + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + const previous = envSnapshot.get(key); + if (previous === undefined) { + delete process.env[key]; + } else { + process.env[key] = previous; + } + } + }); + + it("resolves env-template gateway.auth.token from its referenced env var", async () => { + loadConfigMock.mockReturnValue({ + gateway: { auth: { token: "${CUSTOM_GATEWAY_TOKEN}" } }, + secrets: { providers: { default: { source: "env" } } }, + }); + process.env.CUSTOM_GATEWAY_TOKEN = "resolved-gateway-token"; + + const tokens = await resolveRelayAcceptedTokensForPort(18790); + + expect(tokens).toContain("resolved-gateway-token"); + expect(tokens[0]).not.toBe("resolved-gateway-token"); + }); + + it("fails closed when env-template gateway.auth.token is unresolved", async () => { + loadConfigMock.mockReturnValue({ + gateway: { auth: { token: "${CUSTOM_GATEWAY_TOKEN}" } }, + secrets: { providers: { default: { source: "env" } } }, + }); + + await expect(resolveRelayAcceptedTokensForPort(18790)).rejects.toThrow( + "gateway.auth.token SecretRef is unavailable", + ); + }); + + it("resolves file-backed gateway.auth.token SecretRef", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-relay-file-secret-")); + const secretFile = path.join(tempDir, "relay-secrets.json"); + await fs.writeFile(secretFile, JSON.stringify({ relayToken: "resolved-file-relay-token" })); + await fs.chmod(secretFile, 0o600); + + loadConfigMock.mockReturnValue({ + secrets: { + providers: { + fileProvider: { source: "file", path: secretFile, mode: "json" }, + }, + }, + gateway: { + auth: { + token: { source: "file", provider: "fileProvider", id: "/relayToken" }, + }, + }, + }); + + try { + const tokens = await resolveRelayAcceptedTokensForPort(18790); + expect(tokens.length).toBeGreaterThan(0); + expect(tokens).toContain("resolved-file-relay-token"); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("resolves exec-backed gateway.auth.token SecretRef", async () => { + const execProgram = [ + "process.stdout.write(", + "JSON.stringify({ protocolVersion: 1, values: { RELAY_TOKEN: 'resolved-exec-relay-token' } })", + ");", + ].join(""); + loadConfigMock.mockReturnValue({ + secrets: { + providers: { + execProvider: { + source: "exec", + command: process.execPath, + args: ["-e", execProgram], + allowInsecurePath: true, + }, + }, + }, + gateway: { + auth: { + token: { source: "exec", provider: "execProvider", id: "RELAY_TOKEN" }, + }, + }, + }); + + const tokens = await resolveRelayAcceptedTokensForPort(18790); + expect(tokens.length).toBeGreaterThan(0); + expect(tokens).toContain("resolved-exec-relay-token"); + }); +}); diff --git a/src/browser/extension-relay-auth.test.ts b/src/browser/extension-relay-auth.test.ts index 068f82b10..c052e31a2 100644 --- a/src/browser/extension-relay-auth.test.ts +++ b/src/browser/extension-relay-auth.test.ts @@ -60,20 +60,20 @@ describe("extension-relay-auth", () => { } }); - it("derives deterministic relay tokens per port", () => { - const tokenA1 = resolveRelayAuthTokenForPort(18790); - const tokenA2 = resolveRelayAuthTokenForPort(18790); - const tokenB = resolveRelayAuthTokenForPort(18791); + it("derives deterministic relay tokens per port", async () => { + const tokenA1 = await resolveRelayAuthTokenForPort(18790); + const tokenA2 = await resolveRelayAuthTokenForPort(18790); + const tokenB = await resolveRelayAuthTokenForPort(18791); expect(tokenA1).toBe(tokenA2); expect(tokenA1).not.toBe(tokenB); expect(tokenA1).not.toBe(TEST_GATEWAY_TOKEN); }); - it("accepts both relay-scoped and raw gateway tokens for compatibility", () => { - const tokens = resolveRelayAcceptedTokensForPort(18790); + it("accepts both relay-scoped and raw gateway tokens for compatibility", async () => { + const tokens = await resolveRelayAcceptedTokensForPort(18790); expect(tokens).toContain(TEST_GATEWAY_TOKEN); expect(tokens[0]).not.toBe(TEST_GATEWAY_TOKEN); - expect(tokens[0]).toBe(resolveRelayAuthTokenForPort(18790)); + expect(tokens[0]).toBe(await resolveRelayAuthTokenForPort(18790)); }); it("accepts authenticated openclaw relay probe responses", async () => { @@ -89,7 +89,7 @@ describe("extension-relay-auth", () => { res.end(JSON.stringify({ Browser: "OpenClaw/extension-relay" })); }, async ({ port }) => { - const token = resolveRelayAuthTokenForPort(port); + const token = await resolveRelayAuthTokenForPort(port); const ok = await probeRelay(`http://127.0.0.1:${port}`, token); expect(ok).toBe(true); expect(seenToken).toBe(token); diff --git a/src/browser/extension-relay-auth.ts b/src/browser/extension-relay-auth.ts index 86b79a5e9..7143a6c71 100644 --- a/src/browser/extension-relay-auth.ts +++ b/src/browser/extension-relay-auth.ts @@ -1,11 +1,26 @@ import { createHmac } from "node:crypto"; import { loadConfig } from "../config/config.js"; +import { normalizeSecretInputString, resolveSecretInputRef } from "../config/types.secrets.js"; +import { secretRefKey } from "../secrets/ref-contract.js"; +import { resolveSecretRefValues } from "../secrets/resolve.js"; const RELAY_TOKEN_CONTEXT = "openclaw-extension-relay-v1"; const DEFAULT_RELAY_PROBE_TIMEOUT_MS = 500; const OPENCLAW_RELAY_BROWSER = "OpenClaw/extension-relay"; -function resolveGatewayAuthToken(): string | null { +class SecretRefUnavailableError extends Error { + readonly isSecretRefUnavailable = true; +} + +function trimToUndefined(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +async function resolveGatewayAuthToken(): Promise { const envToken = process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || process.env.CLAWDBOT_GATEWAY_TOKEN?.trim(); if (envToken) { @@ -13,11 +28,36 @@ function resolveGatewayAuthToken(): string | null { } try { const cfg = loadConfig(); - const configToken = cfg.gateway?.auth?.token?.trim(); + const tokenRef = resolveSecretInputRef({ + value: cfg.gateway?.auth?.token, + defaults: cfg.secrets?.defaults, + }).ref; + if (tokenRef) { + const refLabel = `${tokenRef.source}:${tokenRef.provider}:${tokenRef.id}`; + try { + const resolved = await resolveSecretRefValues([tokenRef], { + config: cfg, + env: process.env, + }); + const resolvedToken = trimToUndefined(resolved.get(secretRefKey(tokenRef))); + if (resolvedToken) { + return resolvedToken; + } + } catch { + // handled below + } + throw new SecretRefUnavailableError( + `extension relay requires a resolved gateway token, but gateway.auth.token SecretRef is unavailable (${refLabel}). Set OPENCLAW_GATEWAY_TOKEN or resolve your secret provider.`, + ); + } + const configToken = normalizeSecretInputString(cfg.gateway?.auth?.token); if (configToken) { return configToken; } - } catch { + } catch (err) { + if (err instanceof SecretRefUnavailableError) { + throw err; + } // ignore config read failures; caller can fallback to per-process random token } return null; @@ -27,8 +67,8 @@ function deriveRelayAuthToken(gatewayToken: string, port: number): string { return createHmac("sha256", gatewayToken).update(`${RELAY_TOKEN_CONTEXT}:${port}`).digest("hex"); } -export function resolveRelayAcceptedTokensForPort(port: number): string[] { - const gatewayToken = resolveGatewayAuthToken(); +export async function resolveRelayAcceptedTokensForPort(port: number): Promise { + const gatewayToken = await resolveGatewayAuthToken(); if (!gatewayToken) { throw new Error( "extension relay requires gateway auth token (set gateway.auth.token or OPENCLAW_GATEWAY_TOKEN)", @@ -41,8 +81,8 @@ export function resolveRelayAcceptedTokensForPort(port: number): string[] { return [relayToken, gatewayToken]; } -export function resolveRelayAuthTokenForPort(port: number): string { - return resolveRelayAcceptedTokensForPort(port)[0]; +export async function resolveRelayAuthTokenForPort(port: number): Promise { + return (await resolveRelayAcceptedTokensForPort(port))[0]; } export async function probeAuthenticatedOpenClawRelay(params: { diff --git a/src/browser/extension-relay.ts b/src/browser/extension-relay.ts index b6b788c96..126bfc8f6 100644 --- a/src/browser/extension-relay.ts +++ b/src/browser/extension-relay.ts @@ -249,8 +249,8 @@ export async function ensureChromeExtensionRelayServer(opts: { ); const initPromise = (async (): Promise => { - const relayAuthToken = resolveRelayAuthTokenForPort(info.port); - const relayAuthTokens = new Set(resolveRelayAcceptedTokensForPort(info.port)); + const relayAuthToken = await resolveRelayAuthTokenForPort(info.port); + const relayAuthTokens = new Set(await resolveRelayAcceptedTokensForPort(info.port)); let extensionWs: WebSocket | null = null; const cdpClients = new Set(); diff --git a/src/cli/daemon-cli/install.integration.test.ts b/src/cli/daemon-cli/install.integration.test.ts new file mode 100644 index 000000000..00d602546 --- /dev/null +++ b/src/cli/daemon-cli/install.integration.test.ts @@ -0,0 +1,147 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { makeTempWorkspace } from "../../test-helpers/workspace.js"; +import { captureEnv } from "../../test-utils/env.js"; + +const runtimeLogs: string[] = []; +const runtimeErrors: string[] = []; + +const serviceMock = vi.hoisted(() => ({ + label: "Gateway", + loadedText: "loaded", + notLoadedText: "not loaded", + install: vi.fn(async (_opts?: { environment?: Record }) => {}), + uninstall: vi.fn(async () => {}), + stop: vi.fn(async () => {}), + restart: vi.fn(async () => {}), + isLoaded: vi.fn(async () => false), + readCommand: vi.fn(async () => null), + readRuntime: vi.fn(async () => ({ status: "stopped" as const })), +})); + +vi.mock("../../daemon/service.js", () => ({ + resolveGatewayService: () => serviceMock, +})); + +vi.mock("../../runtime.js", () => ({ + defaultRuntime: { + log: (message: string) => runtimeLogs.push(message), + error: (message: string) => runtimeErrors.push(message), + exit: (code: number) => { + throw new Error(`__exit__:${code}`); + }, + }, +})); + +const { runDaemonInstall } = await import("./install.js"); +const { clearConfigCache } = await import("../../config/config.js"); + +async function readJson(filePath: string): Promise> { + return JSON.parse(await fs.readFile(filePath, "utf8")) as Record; +} + +describe("runDaemonInstall integration", () => { + let envSnapshot: ReturnType; + let tempHome: string; + let configPath: string; + + beforeAll(async () => { + envSnapshot = captureEnv([ + "HOME", + "OPENCLAW_STATE_DIR", + "OPENCLAW_CONFIG_PATH", + "OPENCLAW_GATEWAY_TOKEN", + "CLAWDBOT_GATEWAY_TOKEN", + "OPENCLAW_GATEWAY_PASSWORD", + "CLAWDBOT_GATEWAY_PASSWORD", + ]); + tempHome = await makeTempWorkspace("openclaw-daemon-install-int-"); + configPath = path.join(tempHome, "openclaw.json"); + process.env.HOME = tempHome; + process.env.OPENCLAW_STATE_DIR = tempHome; + process.env.OPENCLAW_CONFIG_PATH = configPath; + }); + + afterAll(async () => { + envSnapshot.restore(); + await fs.rm(tempHome, { recursive: true, force: true }); + }); + + beforeEach(async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + vi.clearAllMocks(); + delete process.env.OPENCLAW_GATEWAY_TOKEN; + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + delete process.env.OPENCLAW_GATEWAY_PASSWORD; + delete process.env.CLAWDBOT_GATEWAY_PASSWORD; + serviceMock.isLoaded.mockResolvedValue(false); + await fs.writeFile(configPath, JSON.stringify({}, null, 2)); + clearConfigCache(); + }); + + it("fails closed when token SecretRef is required but unresolved", async () => { + await fs.writeFile( + configPath, + JSON.stringify( + { + secrets: { + providers: { + default: { source: "env" }, + }, + }, + gateway: { + auth: { + mode: "token", + token: { + source: "env", + provider: "default", + id: "MISSING_GATEWAY_TOKEN", + }, + }, + }, + }, + null, + 2, + ), + ); + clearConfigCache(); + + await expect(runDaemonInstall({ json: true })).rejects.toThrow("__exit__:1"); + expect(serviceMock.install).not.toHaveBeenCalled(); + const joined = runtimeLogs.join("\n"); + expect(joined).toContain("SecretRef is configured but unresolved"); + expect(joined).toContain("MISSING_GATEWAY_TOKEN"); + }); + + it("auto-mints token when no source exists and persists the same token used for install env", async () => { + await fs.writeFile( + configPath, + JSON.stringify( + { + gateway: { + auth: { + mode: "token", + }, + }, + }, + null, + 2, + ), + ); + clearConfigCache(); + + await runDaemonInstall({ json: true }); + + expect(serviceMock.install).toHaveBeenCalledTimes(1); + const updated = await readJson(configPath); + const gateway = (updated.gateway ?? {}) as { auth?: { token?: string } }; + const persistedToken = gateway.auth?.token; + expect(typeof persistedToken).toBe("string"); + expect((persistedToken ?? "").length).toBeGreaterThan(0); + + const installEnv = serviceMock.install.mock.calls[0]?.[0]?.environment; + expect(installEnv?.OPENCLAW_GATEWAY_TOKEN).toBe(persistedToken); + }); +}); diff --git a/src/cli/daemon-cli/install.test.ts b/src/cli/daemon-cli/install.test.ts new file mode 100644 index 000000000..bc488c3ac --- /dev/null +++ b/src/cli/daemon-cli/install.test.ts @@ -0,0 +1,249 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { DaemonActionResponse } from "./response.js"; + +const loadConfigMock = vi.hoisted(() => vi.fn()); +const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); +const resolveGatewayPortMock = vi.hoisted(() => vi.fn(() => 18789)); +const writeConfigFileMock = vi.hoisted(() => vi.fn()); +const resolveIsNixModeMock = vi.hoisted(() => vi.fn(() => false)); +const resolveSecretInputRefMock = vi.hoisted(() => + vi.fn((): { ref: unknown } => ({ ref: undefined })), +); +const resolveGatewayAuthMock = vi.hoisted(() => + vi.fn(() => ({ + mode: "token", + token: undefined, + password: undefined, + allowTailscale: false, + })), +); +const resolveSecretRefValuesMock = vi.hoisted(() => vi.fn()); +const randomTokenMock = vi.hoisted(() => vi.fn(() => "generated-token")); +const buildGatewayInstallPlanMock = vi.hoisted(() => + vi.fn(async () => ({ + programArguments: ["openclaw", "gateway", "run"], + workingDirectory: "/tmp", + environment: {}, + })), +); +const parsePortMock = vi.hoisted(() => vi.fn(() => null)); +const isGatewayDaemonRuntimeMock = vi.hoisted(() => vi.fn(() => true)); +const installDaemonServiceAndEmitMock = vi.hoisted(() => vi.fn(async () => {})); + +const actionState = vi.hoisted(() => ({ + warnings: [] as string[], + emitted: [] as DaemonActionResponse[], + failed: [] as Array<{ message: string; hints?: string[] }>, +})); + +const service = vi.hoisted(() => ({ + label: "Gateway", + loadedText: "loaded", + notLoadedText: "not loaded", + isLoaded: vi.fn(async () => false), + install: vi.fn(async () => {}), + uninstall: vi.fn(async () => {}), + restart: vi.fn(async () => {}), + stop: vi.fn(async () => {}), + readCommand: vi.fn(async () => null), + readRuntime: vi.fn(async () => ({ status: "stopped" as const })), +})); + +vi.mock("../../config/config.js", () => ({ + loadConfig: loadConfigMock, + readConfigFileSnapshot: readConfigFileSnapshotMock, + resolveGatewayPort: resolveGatewayPortMock, + writeConfigFile: writeConfigFileMock, +})); + +vi.mock("../../config/paths.js", () => ({ + resolveIsNixMode: resolveIsNixModeMock, +})); + +vi.mock("../../config/types.secrets.js", () => ({ + resolveSecretInputRef: resolveSecretInputRefMock, +})); + +vi.mock("../../gateway/auth.js", () => ({ + resolveGatewayAuth: resolveGatewayAuthMock, +})); + +vi.mock("../../secrets/resolve.js", () => ({ + resolveSecretRefValues: resolveSecretRefValuesMock, +})); + +vi.mock("../../commands/onboard-helpers.js", () => ({ + randomToken: randomTokenMock, +})); + +vi.mock("../../commands/daemon-install-helpers.js", () => ({ + buildGatewayInstallPlan: buildGatewayInstallPlanMock, +})); + +vi.mock("./shared.js", () => ({ + parsePort: parsePortMock, +})); + +vi.mock("../../commands/daemon-runtime.js", () => ({ + DEFAULT_GATEWAY_DAEMON_RUNTIME: "node", + isGatewayDaemonRuntime: isGatewayDaemonRuntimeMock, +})); + +vi.mock("../../daemon/service.js", () => ({ + resolveGatewayService: () => service, +})); + +vi.mock("./response.js", () => ({ + buildDaemonServiceSnapshot: vi.fn(), + createDaemonActionContext: vi.fn(() => ({ + stdout: process.stdout, + warnings: actionState.warnings, + emit: (payload: DaemonActionResponse) => { + actionState.emitted.push(payload); + }, + fail: (message: string, hints?: string[]) => { + actionState.failed.push({ message, hints }); + }, + })), + installDaemonServiceAndEmit: installDaemonServiceAndEmitMock, +})); + +const runtimeLogs: string[] = []; +vi.mock("../../runtime.js", () => ({ + defaultRuntime: { + log: (message: string) => runtimeLogs.push(message), + error: vi.fn(), + exit: vi.fn(), + }, +})); + +const { runDaemonInstall } = await import("./install.js"); + +describe("runDaemonInstall", () => { + beforeEach(() => { + loadConfigMock.mockReset(); + readConfigFileSnapshotMock.mockReset(); + resolveGatewayPortMock.mockClear(); + writeConfigFileMock.mockReset(); + resolveIsNixModeMock.mockReset(); + resolveSecretInputRefMock.mockReset(); + resolveGatewayAuthMock.mockReset(); + resolveSecretRefValuesMock.mockReset(); + randomTokenMock.mockReset(); + buildGatewayInstallPlanMock.mockReset(); + parsePortMock.mockReset(); + isGatewayDaemonRuntimeMock.mockReset(); + installDaemonServiceAndEmitMock.mockReset(); + service.isLoaded.mockReset(); + runtimeLogs.length = 0; + actionState.warnings.length = 0; + actionState.emitted.length = 0; + actionState.failed.length = 0; + + loadConfigMock.mockReturnValue({ gateway: { auth: { mode: "token" } } }); + readConfigFileSnapshotMock.mockResolvedValue({ exists: false, valid: true, config: {} }); + resolveGatewayPortMock.mockReturnValue(18789); + resolveIsNixModeMock.mockReturnValue(false); + resolveSecretInputRefMock.mockReturnValue({ ref: undefined }); + resolveGatewayAuthMock.mockReturnValue({ + mode: "token", + token: undefined, + password: undefined, + allowTailscale: false, + }); + resolveSecretRefValuesMock.mockResolvedValue(new Map()); + randomTokenMock.mockReturnValue("generated-token"); + buildGatewayInstallPlanMock.mockResolvedValue({ + programArguments: ["openclaw", "gateway", "run"], + workingDirectory: "/tmp", + environment: {}, + }); + parsePortMock.mockReturnValue(null); + isGatewayDaemonRuntimeMock.mockReturnValue(true); + installDaemonServiceAndEmitMock.mockResolvedValue(undefined); + service.isLoaded.mockResolvedValue(false); + }); + + it("fails install when token auth requires an unresolved token SecretRef", async () => { + resolveSecretInputRefMock.mockReturnValue({ + ref: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" }, + }); + resolveSecretRefValuesMock.mockRejectedValue(new Error("secret unavailable")); + + await runDaemonInstall({ json: true }); + + expect(actionState.failed[0]?.message).toContain("gateway.auth.token SecretRef is configured"); + expect(actionState.failed[0]?.message).toContain("unresolved"); + expect(buildGatewayInstallPlanMock).not.toHaveBeenCalled(); + expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); + }); + + it("validates token SecretRef but does not serialize resolved token into service env", async () => { + resolveSecretInputRefMock.mockReturnValue({ + ref: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" }, + }); + resolveSecretRefValuesMock.mockResolvedValue( + new Map([["env:default:OPENCLAW_GATEWAY_TOKEN", "resolved-from-secretref"]]), + ); + + await runDaemonInstall({ json: true }); + + expect(actionState.failed).toEqual([]); + expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith( + expect.objectContaining({ + token: undefined, + }), + ); + expect(writeConfigFileMock).not.toHaveBeenCalled(); + expect( + actionState.warnings.some((warning) => + warning.includes("gateway.auth.token is SecretRef-managed"), + ), + ).toBe(true); + }); + + it("does not treat env-template gateway.auth.token as plaintext during install", async () => { + loadConfigMock.mockReturnValue({ + gateway: { auth: { mode: "token", token: "${OPENCLAW_GATEWAY_TOKEN}" } }, + }); + resolveSecretInputRefMock.mockReturnValue({ + ref: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" }, + }); + resolveSecretRefValuesMock.mockResolvedValue( + new Map([["env:default:OPENCLAW_GATEWAY_TOKEN", "resolved-from-secretref"]]), + ); + + await runDaemonInstall({ json: true }); + + expect(actionState.failed).toEqual([]); + expect(resolveSecretRefValuesMock).toHaveBeenCalledTimes(1); + expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith( + expect.objectContaining({ + token: undefined, + }), + ); + }); + + it("auto-mints and persists token when no source exists", async () => { + randomTokenMock.mockReturnValue("minted-token"); + readConfigFileSnapshotMock.mockResolvedValue({ + exists: true, + valid: true, + config: { gateway: { auth: { mode: "token" } } }, + }); + + await runDaemonInstall({ json: true }); + + expect(actionState.failed).toEqual([]); + expect(writeConfigFileMock).toHaveBeenCalledTimes(1); + const writtenConfig = writeConfigFileMock.mock.calls[0]?.[0] as { + gateway?: { auth?: { token?: string } }; + }; + expect(writtenConfig.gateway?.auth?.token).toBe("minted-token"); + expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith( + expect.objectContaining({ token: "minted-token", port: 18789 }), + ); + expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1); + expect(actionState.warnings.some((warning) => warning.includes("Auto-generated"))).toBe(true); + }); +}); diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index d6d75823b..864f0a93f 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -3,16 +3,10 @@ import { DEFAULT_GATEWAY_DAEMON_RUNTIME, isGatewayDaemonRuntime, } from "../../commands/daemon-runtime.js"; -import { randomToken } from "../../commands/onboard-helpers.js"; -import { - loadConfig, - readConfigFileSnapshot, - resolveGatewayPort, - writeConfigFile, -} from "../../config/config.js"; +import { resolveGatewayInstallToken } from "../../commands/gateway-install-token.js"; +import { loadConfig, resolveGatewayPort } from "../../config/config.js"; import { resolveIsNixMode } from "../../config/paths.js"; import { resolveGatewayService } from "../../daemon/service.js"; -import { resolveGatewayAuth } from "../../gateway/auth.js"; import { defaultRuntime } from "../../runtime.js"; import { formatCliCommand } from "../command-format.js"; import { @@ -75,78 +69,29 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { } } - // Resolve effective auth mode to determine if token auto-generation is needed. - // Password-mode and Tailscale-only installs do not need a token. - const resolvedAuth = resolveGatewayAuth({ - authConfig: cfg.gateway?.auth, - tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off", + const tokenResolution = await resolveGatewayInstallToken({ + config: cfg, + env: process.env, + explicitToken: opts.token, + autoGenerateWhenMissing: true, + persistGeneratedToken: true, }); - const needsToken = - resolvedAuth.mode === "token" && !resolvedAuth.token && !resolvedAuth.allowTailscale; - - let token: string | undefined = - opts.token || - cfg.gateway?.auth?.token || - process.env.OPENCLAW_GATEWAY_TOKEN || - process.env.CLAWDBOT_GATEWAY_TOKEN; - - if (!token && needsToken) { - token = randomToken(); - const warnMsg = "No gateway token found. Auto-generated one and saving to config."; + if (tokenResolution.unavailableReason) { + fail(`Gateway install blocked: ${tokenResolution.unavailableReason}`); + return; + } + for (const warning of tokenResolution.warnings) { if (json) { - warnings.push(warnMsg); + warnings.push(warning); } else { - defaultRuntime.log(warnMsg); - } - - // Persist to config file so the gateway reads it at runtime - // (launchd does not inherit shell env vars, and CLI tools also - // read gateway.auth.token from config for gateway calls). - try { - const snapshot = await readConfigFileSnapshot(); - if (snapshot.exists && !snapshot.valid) { - // Config file exists but is corrupt/unparseable — don't risk overwriting. - // Token is still embedded in the plist EnvironmentVariables. - const msg = "Warning: config file exists but is invalid; skipping token persistence."; - if (json) { - warnings.push(msg); - } else { - defaultRuntime.log(msg); - } - } else { - const baseConfig = snapshot.exists ? snapshot.config : {}; - if (!baseConfig.gateway?.auth?.token) { - await writeConfigFile({ - ...baseConfig, - gateway: { - ...baseConfig.gateway, - auth: { - ...baseConfig.gateway?.auth, - mode: baseConfig.gateway?.auth?.mode ?? "token", - token, - }, - }, - }); - } else { - // Another process wrote a token between loadConfig() and now. - token = baseConfig.gateway.auth.token; - } - } - } catch (err) { - // Non-fatal: token is still embedded in the plist EnvironmentVariables. - const msg = `Warning: could not persist token to config: ${String(err)}`; - if (json) { - warnings.push(msg); - } else { - defaultRuntime.log(msg); - } + defaultRuntime.log(warning); } } const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({ env: process.env, port, - token, + token: tokenResolution.token, runtime: runtimeRaw, warn: (message) => { if (json) { diff --git a/src/cli/daemon-cli/lifecycle-core.ts b/src/cli/daemon-cli/lifecycle-core.ts index fe5c8e516..6b8c7ee68 100644 --- a/src/cli/daemon-cli/lifecycle-core.ts +++ b/src/cli/daemon-cli/lifecycle-core.ts @@ -5,7 +5,10 @@ import { checkTokenDrift } from "../../daemon/service-audit.js"; import type { GatewayService } from "../../daemon/service.js"; import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js"; import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js"; -import { resolveGatewayCredentialsFromConfig } from "../../gateway/credentials.js"; +import { + isGatewaySecretRefUnavailableError, + resolveGatewayCredentialsFromConfig, +} from "../../gateway/credentials.js"; import { isWSL } from "../../infra/wsl.js"; import { defaultRuntime } from "../../runtime.js"; import { @@ -299,8 +302,15 @@ export async function runServiceRestart(params: { } } } - } catch { - // Non-fatal: token drift check is best-effort + } catch (err) { + if (isGatewaySecretRefUnavailableError(err, "gateway.auth.token")) { + const warning = + "Unable to verify gateway token drift: gateway.auth.token SecretRef is configured but unavailable in this command path."; + warnings.push(warning); + if (!json) { + defaultRuntime.log(`\n⚠️ ${warning}\n`); + } + } } } diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index 05a91bf6c..fceff73f0 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -123,12 +123,14 @@ describe("gatherDaemonStatus", () => { "OPENCLAW_CONFIG_PATH", "OPENCLAW_GATEWAY_TOKEN", "OPENCLAW_GATEWAY_PASSWORD", + "DAEMON_GATEWAY_TOKEN", "DAEMON_GATEWAY_PASSWORD", ]); process.env.OPENCLAW_STATE_DIR = "/tmp/openclaw-cli"; process.env.OPENCLAW_CONFIG_PATH = "/tmp/openclaw-cli/openclaw.json"; delete process.env.OPENCLAW_GATEWAY_TOKEN; delete process.env.OPENCLAW_GATEWAY_PASSWORD; + delete process.env.DAEMON_GATEWAY_TOKEN; delete process.env.DAEMON_GATEWAY_PASSWORD; callGatewayStatusProbe.mockClear(); loadGatewayTlsRuntime.mockClear(); @@ -218,6 +220,37 @@ describe("gatherDaemonStatus", () => { ); }); + it("resolves daemon gateway auth token SecretRef values before probing", async () => { + daemonLoadedConfig = { + gateway: { + bind: "lan", + tls: { enabled: true }, + auth: { + mode: "token", + token: "${DAEMON_GATEWAY_TOKEN}", + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + process.env.DAEMON_GATEWAY_TOKEN = "daemon-secretref-token"; + + await gatherDaemonStatus({ + rpc: {}, + probe: true, + deep: false, + }); + + expect(callGatewayStatusProbe).toHaveBeenCalledWith( + expect.objectContaining({ + token: "daemon-secretref-token", + }), + ); + }); + it("does not resolve daemon password SecretRef when token auth is configured", async () => { daemonLoadedConfig = { gateway: { diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index fc91e6f3c..8cefcd952 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -9,7 +9,11 @@ import type { GatewayBindMode, GatewayControlUiConfig, } from "../../config/types.js"; -import { normalizeSecretInputString, resolveSecretInputRef } from "../../config/types.secrets.js"; +import { + hasConfiguredSecretInput, + normalizeSecretInputString, + resolveSecretInputRef, +} from "../../config/types.secrets.js"; import { readLastGatewayErrorLine } from "../../daemon/diagnostics.js"; import type { FindExtraGatewayServicesOptions } from "../../daemon/inspect.js"; import { findExtraGatewayServices } from "../../daemon/inspect.js"; @@ -114,6 +118,61 @@ function readGatewayTokenEnv(env: Record): string | return trimToUndefined(env.OPENCLAW_GATEWAY_TOKEN) ?? trimToUndefined(env.CLAWDBOT_GATEWAY_TOKEN); } +function readGatewayPasswordEnv(env: Record): string | undefined { + return ( + trimToUndefined(env.OPENCLAW_GATEWAY_PASSWORD) ?? trimToUndefined(env.CLAWDBOT_GATEWAY_PASSWORD) + ); +} + +async function resolveDaemonProbeToken(params: { + daemonCfg: OpenClawConfig; + mergedDaemonEnv: Record; + explicitToken?: string; + explicitPassword?: string; +}): Promise { + const explicitToken = trimToUndefined(params.explicitToken); + if (explicitToken) { + return explicitToken; + } + const envToken = readGatewayTokenEnv(params.mergedDaemonEnv); + if (envToken) { + return envToken; + } + const defaults = params.daemonCfg.secrets?.defaults; + const configured = params.daemonCfg.gateway?.auth?.token; + const { ref } = resolveSecretInputRef({ + value: configured, + defaults, + }); + if (!ref) { + return normalizeSecretInputString(configured); + } + const authMode = params.daemonCfg.gateway?.auth?.mode; + if (authMode === "password" || authMode === "none" || authMode === "trusted-proxy") { + return undefined; + } + if (authMode !== "token") { + const passwordCandidate = + trimToUndefined(params.explicitPassword) || + readGatewayPasswordEnv(params.mergedDaemonEnv) || + (hasConfiguredSecretInput(params.daemonCfg.gateway?.auth?.password, defaults) + ? "__configured__" + : undefined); + if (passwordCandidate) { + return undefined; + } + } + const resolved = await resolveSecretRefValues([ref], { + config: params.daemonCfg, + env: params.mergedDaemonEnv as NodeJS.ProcessEnv, + }); + const token = trimToUndefined(resolved.get(secretRefKey(ref))); + if (!token) { + throw new Error("gateway.auth.token resolved to an empty or non-string value."); + } + return token; +} + async function resolveDaemonProbePassword(params: { daemonCfg: OpenClawConfig; mergedDaemonEnv: Record; @@ -124,7 +183,7 @@ async function resolveDaemonProbePassword(params: { if (explicitPassword) { return explicitPassword; } - const envPassword = trimToUndefined(params.mergedDaemonEnv.OPENCLAW_GATEWAY_PASSWORD); + const envPassword = readGatewayPasswordEnv(params.mergedDaemonEnv); if (envPassword) { return envPassword; } @@ -145,7 +204,9 @@ async function resolveDaemonProbePassword(params: { const tokenCandidate = trimToUndefined(params.explicitToken) || readGatewayTokenEnv(params.mergedDaemonEnv) || - trimToUndefined(params.daemonCfg.gateway?.auth?.token); + (hasConfiguredSecretInput(params.daemonCfg.gateway?.auth?.token, defaults) + ? "__configured__" + : undefined); if (tokenCandidate) { return undefined; } @@ -290,14 +351,19 @@ export async function gatherDaemonStatus( explicitPassword: opts.rpc.password, }) : undefined; + const daemonProbeToken = opts.probe + ? await resolveDaemonProbeToken({ + daemonCfg, + mergedDaemonEnv, + explicitToken: opts.rpc.token, + explicitPassword: opts.rpc.password, + }) + : undefined; const rpc = opts.probe ? await probeGatewayStatus({ url: probeUrl, - token: - opts.rpc.token || - mergedDaemonEnv.OPENCLAW_GATEWAY_TOKEN || - daemonCfg.gateway?.auth?.token, + token: daemonProbeToken, password: daemonProbePassword, tlsFingerprint: shouldUseLocalTlsRuntime && tlsRuntime?.enabled diff --git a/src/cli/gateway-cli/run.option-collisions.test.ts b/src/cli/gateway-cli/run.option-collisions.test.ts index b26b4c86e..47d24049e 100644 --- a/src/cli/gateway-cli/run.option-collisions.test.ts +++ b/src/cli/gateway-cli/run.option-collisions.test.ts @@ -17,24 +17,45 @@ const ensureDevGatewayConfig = vi.fn(async (_opts?: unknown) => {}); const runGatewayLoop = vi.fn(async ({ start }: { start: () => Promise }) => { await start(); }); +const configState = vi.hoisted(() => ({ + cfg: {} as Record, + snapshot: { exists: false } as Record, +})); const { runtimeErrors, defaultRuntime, resetRuntimeCapture } = createCliRuntimeCapture(); vi.mock("../../config/config.js", () => ({ getConfigPath: () => "/tmp/openclaw-test-missing-config.json", - loadConfig: () => ({}), - readConfigFileSnapshot: async () => ({ exists: false }), + loadConfig: () => configState.cfg, + readConfigFileSnapshot: async () => configState.snapshot, resolveStateDir: () => "/tmp", resolveGatewayPort: () => 18789, })); vi.mock("../../gateway/auth.js", () => ({ - resolveGatewayAuth: (params: { authConfig?: { token?: string }; env?: NodeJS.ProcessEnv }) => ({ - mode: "token", - token: params.authConfig?.token ?? params.env?.OPENCLAW_GATEWAY_TOKEN, - password: undefined, - allowTailscale: false, - }), + resolveGatewayAuth: (params: { + authConfig?: { mode?: string; token?: unknown; password?: unknown }; + authOverride?: { mode?: string; token?: unknown; password?: unknown }; + env?: NodeJS.ProcessEnv; + }) => { + const mode = params.authOverride?.mode ?? params.authConfig?.mode ?? "token"; + const token = + (typeof params.authOverride?.token === "string" ? params.authOverride.token : undefined) ?? + (typeof params.authConfig?.token === "string" ? params.authConfig.token : undefined) ?? + params.env?.OPENCLAW_GATEWAY_TOKEN; + const password = + (typeof params.authOverride?.password === "string" + ? params.authOverride.password + : undefined) ?? + (typeof params.authConfig?.password === "string" ? params.authConfig.password : undefined) ?? + params.env?.OPENCLAW_GATEWAY_PASSWORD; + return { + mode, + token, + password, + allowTailscale: false, + }; + }, })); vi.mock("../../gateway/server.js", () => ({ @@ -106,6 +127,8 @@ describe("gateway run option collisions", () => { beforeEach(() => { resetRuntimeCapture(); + configState.cfg = {}; + configState.snapshot = { exists: false }; startGatewayServer.mockClear(); setGatewayWsLogStyle.mockClear(); setVerbose.mockClear(); @@ -190,4 +213,30 @@ describe("gateway run option collisions", () => { 'Invalid --auth (use "none", "token", "password", or "trusted-proxy")', ); }); + + it("allows password mode preflight when password is configured via SecretRef", async () => { + configState.cfg = { + gateway: { + auth: { + mode: "password", + password: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_PASSWORD" }, + }, + }, + secrets: { + defaults: { + env: "default", + }, + }, + }; + configState.snapshot = { exists: true, parsed: configState.cfg }; + + await runGatewayCli(["gateway", "run", "--allow-unconfigured"]); + + expect(startGatewayServer).toHaveBeenCalledWith( + 18789, + expect.objectContaining({ + bind: "loopback", + }), + ); + }); }); diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 666adc289..ece545e3d 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -9,6 +9,7 @@ import { resolveStateDir, resolveGatewayPort, } from "../../config/config.js"; +import { hasConfiguredSecretInput } from "../../config/types.secrets.js"; import { resolveGatewayAuth } from "../../gateway/auth.js"; import { startGatewayServer } from "../../gateway/server.js"; import type { GatewayWsLogStyle } from "../../gateway/ws-logging.js"; @@ -308,9 +309,22 @@ async function runGatewayCommand(opts: GatewayRunOpts) { const passwordValue = resolvedAuth.password; const hasToken = typeof tokenValue === "string" && tokenValue.trim().length > 0; const hasPassword = typeof passwordValue === "string" && passwordValue.trim().length > 0; + const tokenConfigured = + hasToken || + hasConfiguredSecretInput( + authOverride?.token ?? cfg.gateway?.auth?.token, + cfg.secrets?.defaults, + ); + const passwordConfigured = + hasPassword || + hasConfiguredSecretInput( + authOverride?.password ?? cfg.gateway?.auth?.password, + cfg.secrets?.defaults, + ); const hasSharedSecret = - (resolvedAuthMode === "token" && hasToken) || (resolvedAuthMode === "password" && hasPassword); - const canBootstrapToken = resolvedAuthMode === "token" && !hasToken; + (resolvedAuthMode === "token" && tokenConfigured) || + (resolvedAuthMode === "password" && passwordConfigured); + const canBootstrapToken = resolvedAuthMode === "token" && !tokenConfigured; const authHints: string[] = []; if (miskeys.hasGatewayToken) { authHints.push('Found "gateway.token" in config. Use "gateway.auth.token" instead.'); @@ -320,7 +334,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) { '"gateway.remote.token" is for remote CLI calls; it does not enable local gateway auth.', ); } - if (resolvedAuthMode === "password" && !hasPassword) { + if (resolvedAuthMode === "password" && !passwordConfigured) { defaultRuntime.error( [ "Gateway auth is set to password, but no password is configured.", diff --git a/src/cli/program/register.onboard.test.ts b/src/cli/program/register.onboard.test.ts index 2c923bb70..b1cf84781 100644 --- a/src/cli/program/register.onboard.test.ts +++ b/src/cli/program/register.onboard.test.ts @@ -129,6 +129,16 @@ describe("registerOnboardCommand", () => { ); }); + it("forwards --gateway-token-ref-env", async () => { + await runCli(["onboard", "--gateway-token-ref-env", "OPENCLAW_GATEWAY_TOKEN"]); + expect(onboardCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ + gatewayTokenRefEnv: "OPENCLAW_GATEWAY_TOKEN", + }), + runtime, + ); + }); + it("reports errors via runtime on onboard command failures", async () => { onboardCommandMock.mockRejectedValueOnce(new Error("onboard failed")); diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index b039b2e83..7555b5c6b 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -104,6 +104,10 @@ export function registerOnboardCommand(program: Command) { .option("--gateway-bind ", "Gateway bind: loopback|tailnet|lan|auto|custom") .option("--gateway-auth ", "Gateway auth: token|password") .option("--gateway-token ", "Gateway token (token auth)") + .option( + "--gateway-token-ref-env ", + "Gateway token SecretRef env var name (token auth; e.g. OPENCLAW_GATEWAY_TOKEN)", + ) .option("--gateway-password ", "Gateway password (password auth)") .option("--remote-url ", "Remote Gateway WebSocket URL") .option("--remote-token ", "Remote Gateway token (optional)") @@ -177,6 +181,7 @@ export function registerOnboardCommand(program: Command) { gatewayBind: opts.gatewayBind as GatewayBind | undefined, gatewayAuth: opts.gatewayAuth as GatewayAuthChoice | undefined, gatewayToken: opts.gatewayToken as string | undefined, + gatewayTokenRefEnv: opts.gatewayTokenRefEnv as string | undefined, gatewayPassword: opts.gatewayPassword as string | undefined, remoteUrl: opts.remoteUrl as string | undefined, remoteToken: opts.remoteToken as string | undefined, diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index 9fe430184..97e5c1c01 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -293,6 +293,30 @@ describe("registerQrCli", () => { expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); }); + it("fails when token and password SecretRefs are both configured with inferred mode", async () => { + vi.stubEnv("QR_INFERRED_GATEWAY_TOKEN", "inferred-token"); + loadConfig.mockReturnValue({ + secrets: { + providers: { + default: { source: "env" }, + }, + }, + gateway: { + bind: "custom", + customBindHost: "gateway.local", + auth: { + token: { source: "env", provider: "default", id: "QR_INFERRED_GATEWAY_TOKEN" }, + password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" }, + }, + }, + }); + + await expectQrExit(["--setup-code-only"]); + const output = runtime.error.mock.calls.map((call) => String(call[0] ?? "")).join("\n"); + expect(output).toContain("gateway.auth.mode is unset"); + expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); + }); + it("exits with error when gateway config is not pairable", async () => { loadConfig.mockReturnValue({ gateway: { diff --git a/src/cli/qr-cli.ts b/src/cli/qr-cli.ts index ee3269432..a08d2a102 100644 --- a/src/cli/qr-cli.ts +++ b/src/cli/qr-cli.ts @@ -1,7 +1,7 @@ import type { Command } from "commander"; import qrcode from "qrcode-terminal"; import { loadConfig } from "../config/config.js"; -import { resolveSecretInputRef } from "../config/types.secrets.js"; +import { hasConfiguredSecretInput, resolveSecretInputRef } from "../config/types.secrets.js"; import { resolvePairingSetupFromConfig, encodePairingSetupCode } from "../pairing/setup-code.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { defaultRuntime } from "../runtime.js"; @@ -81,11 +81,11 @@ function shouldResolveLocalGatewayPasswordSecret( return false; } const envToken = readGatewayTokenEnv(env); - const configToken = - typeof cfg.gateway?.auth?.token === "string" && cfg.gateway.auth.token.trim().length > 0 - ? cfg.gateway.auth.token.trim() - : undefined; - return !envToken && !configToken; + const configTokenConfigured = hasConfiguredSecretInput( + cfg.gateway?.auth?.token, + cfg.secrets?.defaults, + ); + return !envToken && !configTokenConfigured; } async function resolveLocalGatewayPasswordSecretIfNeeded( diff --git a/src/cli/qr-dashboard.integration.test.ts b/src/cli/qr-dashboard.integration.test.ts new file mode 100644 index 000000000..5db9bb43d --- /dev/null +++ b/src/cli/qr-dashboard.integration.test.ts @@ -0,0 +1,168 @@ +import { Command } from "commander"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; + +const loadConfigMock = vi.hoisted(() => vi.fn()); +const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); +const resolveGatewayPortMock = vi.hoisted(() => vi.fn(() => 18789)); +const copyToClipboardMock = vi.hoisted(() => vi.fn(async () => false)); + +const runtimeLogs: string[] = []; +const runtimeErrors: string[] = []; +const runtime = vi.hoisted(() => ({ + log: (message: string) => runtimeLogs.push(message), + error: (message: string) => runtimeErrors.push(message), + exit: (code: number) => { + throw new Error(`__exit__:${code}`); + }, +})); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: loadConfigMock, + readConfigFileSnapshot: readConfigFileSnapshotMock, + resolveGatewayPort: resolveGatewayPortMock, + }; +}); + +vi.mock("../infra/clipboard.js", () => ({ + copyToClipboard: copyToClipboardMock, +})); + +vi.mock("../runtime.js", () => ({ + defaultRuntime: runtime, +})); + +const { registerQrCli } = await import("./qr-cli.js"); +const { registerMaintenanceCommands } = await import("./program/register.maintenance.js"); + +function createGatewayTokenRefFixture() { + return { + secrets: { + providers: { + default: { + source: "env", + }, + }, + defaults: { + env: "default", + }, + }, + gateway: { + bind: "custom", + customBindHost: "gateway.local", + port: 18789, + auth: { + mode: "token", + token: { + source: "env", + provider: "default", + id: "SHARED_GATEWAY_TOKEN", + }, + }, + }, + }; +} + +function decodeSetupCode(setupCode: string): { url?: string; token?: string; password?: string } { + const padded = setupCode.replace(/-/g, "+").replace(/_/g, "/"); + const padLength = (4 - (padded.length % 4)) % 4; + const normalized = padded + "=".repeat(padLength); + const json = Buffer.from(normalized, "base64").toString("utf8"); + return JSON.parse(json) as { url?: string; token?: string; password?: string }; +} + +async function runCli(args: string[]): Promise { + const program = new Command(); + registerQrCli(program); + registerMaintenanceCommands(program); + await program.parseAsync(args, { from: "user" }); +} + +describe("cli integration: qr + dashboard token SecretRef", () => { + let envSnapshot: ReturnType; + + beforeAll(() => { + envSnapshot = captureEnv([ + "SHARED_GATEWAY_TOKEN", + "OPENCLAW_GATEWAY_TOKEN", + "CLAWDBOT_GATEWAY_TOKEN", + "OPENCLAW_GATEWAY_PASSWORD", + "CLAWDBOT_GATEWAY_PASSWORD", + ]); + }); + + afterAll(() => { + envSnapshot.restore(); + }); + + beforeEach(() => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + vi.clearAllMocks(); + delete process.env.OPENCLAW_GATEWAY_TOKEN; + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + delete process.env.OPENCLAW_GATEWAY_PASSWORD; + delete process.env.CLAWDBOT_GATEWAY_PASSWORD; + delete process.env.SHARED_GATEWAY_TOKEN; + }); + + it("uses the same resolved token SecretRef for both qr and dashboard commands", async () => { + const fixture = createGatewayTokenRefFixture(); + process.env.SHARED_GATEWAY_TOKEN = "shared-token-123"; + loadConfigMock.mockReturnValue(fixture); + readConfigFileSnapshotMock.mockResolvedValue({ + path: "/tmp/openclaw.json", + exists: true, + valid: true, + issues: [], + config: fixture, + }); + + await runCli(["qr", "--setup-code-only"]); + const setupCode = runtimeLogs.at(-1); + expect(setupCode).toBeTruthy(); + const payload = decodeSetupCode(setupCode ?? ""); + expect(payload.url).toBe("ws://gateway.local:18789"); + expect(payload.token).toBe("shared-token-123"); + expect(runtimeErrors).toEqual([]); + + runtimeLogs.length = 0; + runtimeErrors.length = 0; + await runCli(["dashboard", "--no-open"]); + const joined = runtimeLogs.join("\n"); + expect(joined).toContain("Dashboard URL: http://127.0.0.1:18789/"); + expect(joined).not.toContain("#token="); + expect(joined).toContain( + "Token auto-auth is disabled for SecretRef-managed gateway.auth.token", + ); + expect(joined).not.toContain("Token auto-auth unavailable"); + expect(runtimeErrors).toEqual([]); + }); + + it("fails qr but keeps dashboard actionable when the shared token SecretRef is unresolved", async () => { + const fixture = createGatewayTokenRefFixture(); + loadConfigMock.mockReturnValue(fixture); + readConfigFileSnapshotMock.mockResolvedValue({ + path: "/tmp/openclaw.json", + exists: true, + valid: true, + issues: [], + config: fixture, + }); + + await expect(runCli(["qr", "--setup-code-only"])).rejects.toThrow("__exit__:1"); + expect(runtimeErrors.join("\n")).toMatch(/SHARED_GATEWAY_TOKEN/); + + runtimeLogs.length = 0; + runtimeErrors.length = 0; + await runCli(["dashboard", "--no-open"]); + const joined = runtimeLogs.join("\n"); + expect(joined).toContain("Dashboard URL: http://127.0.0.1:18789/"); + expect(joined).not.toContain("#token="); + expect(joined).toContain("Token auto-auth unavailable"); + expect(joined).toContain("Set OPENCLAW_GATEWAY_TOKEN"); + }); +}); diff --git a/src/commands/auth-choice.apply-helpers.ts b/src/commands/auth-choice.apply-helpers.ts index b8ff75f78..f753aa557 100644 --- a/src/commands/auth-choice.apply-helpers.ts +++ b/src/commands/auth-choice.apply-helpers.ts @@ -1,6 +1,10 @@ import { resolveEnvApiKey } from "../agents/model-auth.js"; import type { OpenClawConfig } from "../config/types.js"; -import { type SecretInput, type SecretRef } from "../config/types.secrets.js"; +import { + isValidEnvSecretRefId, + type SecretInput, + type SecretRef, +} from "../config/types.secrets.js"; import { encodeJsonPointerToken } from "../secrets/json-pointer.js"; import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js"; import { @@ -15,7 +19,6 @@ import { applyDefaultModelChoice } from "./auth-choice.default-model.js"; import type { SecretInputMode } from "./onboard-types.js"; const ENV_SOURCE_LABEL_RE = /(?:^|:\s)([A-Z][A-Z0-9_]*)$/; -const ENV_SECRET_REF_ID_RE = /^[A-Z][A-Z0-9_]{0,127}$/; type SecretRefChoice = "env" | "provider"; @@ -127,7 +130,7 @@ export async function promptSecretRefForOnboarding(params: { placeholder: params.copy?.envVarPlaceholder ?? "OPENAI_API_KEY", validate: (value) => { const candidate = value.trim(); - if (!ENV_SECRET_REF_ID_RE.test(candidate)) { + if (!isValidEnvSecretRefId(candidate)) { return ( params.copy?.envVarFormatError ?? 'Use an env var name like "OPENAI_API_KEY" (uppercase letters, numbers, underscores).' @@ -144,7 +147,7 @@ export async function promptSecretRefForOnboarding(params: { }); const envCandidate = String(envVarRaw ?? "").trim(); const envVar = - envCandidate && ENV_SECRET_REF_ID_RE.test(envCandidate) ? envCandidate : defaultEnvVar; + envCandidate && isValidEnvSecretRefId(envCandidate) ? envCandidate : defaultEnvVar; if (!envVar) { throw new Error( `No valid environment variable name provided for provider "${params.provider}".`, diff --git a/src/commands/configure.daemon.test.ts b/src/commands/configure.daemon.test.ts new file mode 100644 index 000000000..28c602736 --- /dev/null +++ b/src/commands/configure.daemon.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const withProgress = vi.hoisted(() => vi.fn(async (_opts, run) => run({ setLabel: vi.fn() }))); +const loadConfig = vi.hoisted(() => vi.fn()); +const resolveGatewayInstallToken = vi.hoisted(() => vi.fn()); +const buildGatewayInstallPlan = vi.hoisted(() => vi.fn()); +const note = vi.hoisted(() => vi.fn()); +const serviceInstall = vi.hoisted(() => vi.fn(async () => {})); +const ensureSystemdUserLingerInteractive = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock("../cli/progress.js", () => ({ + withProgress, +})); + +vi.mock("../config/config.js", () => ({ + loadConfig, +})); + +vi.mock("./gateway-install-token.js", () => ({ + resolveGatewayInstallToken, +})); + +vi.mock("./daemon-install-helpers.js", () => ({ + buildGatewayInstallPlan, + gatewayInstallErrorHint: vi.fn(() => "hint"), +})); + +vi.mock("../terminal/note.js", () => ({ + note, +})); + +vi.mock("./configure.shared.js", () => ({ + confirm: vi.fn(async () => true), + select: vi.fn(async () => "node"), +})); + +vi.mock("./daemon-runtime.js", () => ({ + DEFAULT_GATEWAY_DAEMON_RUNTIME: "node", + GATEWAY_DAEMON_RUNTIME_OPTIONS: [{ value: "node", label: "Node" }], +})); + +vi.mock("../daemon/service.js", () => ({ + resolveGatewayService: vi.fn(() => ({ + isLoaded: vi.fn(async () => false), + install: serviceInstall, + })), +})); + +vi.mock("./onboard-helpers.js", () => ({ + guardCancel: (value: unknown) => value, +})); + +vi.mock("./systemd-linger.js", () => ({ + ensureSystemdUserLingerInteractive, +})); + +const { maybeInstallDaemon } = await import("./configure.daemon.js"); + +describe("maybeInstallDaemon", () => { + beforeEach(() => { + vi.clearAllMocks(); + loadConfig.mockReturnValue({}); + resolveGatewayInstallToken.mockResolvedValue({ + token: undefined, + tokenRefConfigured: true, + warnings: [], + }); + buildGatewayInstallPlan.mockResolvedValue({ + programArguments: ["openclaw", "gateway", "run"], + workingDirectory: "/tmp", + environment: {}, + }); + }); + + it("does not serialize SecretRef token into service environment", async () => { + await maybeInstallDaemon({ + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + port: 18789, + }); + + expect(resolveGatewayInstallToken).toHaveBeenCalledTimes(1); + expect(buildGatewayInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ + token: undefined, + }), + ); + expect(serviceInstall).toHaveBeenCalledTimes(1); + }); + + it("blocks install when token SecretRef is unresolved", async () => { + resolveGatewayInstallToken.mockResolvedValue({ + token: undefined, + tokenRefConfigured: true, + unavailableReason: "gateway.auth.token SecretRef is configured but unresolved (boom).", + warnings: [], + }); + + await maybeInstallDaemon({ + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + port: 18789, + }); + + expect(note).toHaveBeenCalledWith( + expect.stringContaining("Gateway install blocked"), + "Gateway", + ); + expect(buildGatewayInstallPlan).not.toHaveBeenCalled(); + expect(serviceInstall).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/configure.daemon.ts b/src/commands/configure.daemon.ts index 1e4c634aa..f282cfc85 100644 --- a/src/commands/configure.daemon.ts +++ b/src/commands/configure.daemon.ts @@ -10,13 +10,13 @@ import { GATEWAY_DAEMON_RUNTIME_OPTIONS, type GatewayDaemonRuntime, } from "./daemon-runtime.js"; +import { resolveGatewayInstallToken } from "./gateway-install-token.js"; import { guardCancel } from "./onboard-helpers.js"; import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js"; export async function maybeInstallDaemon(params: { runtime: RuntimeEnv; port: number; - gatewayToken?: string; daemonRuntime?: GatewayDaemonRuntime; }) { const service = resolveGatewayService(); @@ -88,10 +88,26 @@ export async function maybeInstallDaemon(params: { progress.setLabel("Preparing Gateway service…"); const cfg = loadConfig(); + const tokenResolution = await resolveGatewayInstallToken({ + config: cfg, + env: process.env, + }); + for (const warning of tokenResolution.warnings) { + note(warning, "Gateway"); + } + if (tokenResolution.unavailableReason) { + installError = [ + "Gateway install blocked:", + tokenResolution.unavailableReason, + "Fix gateway auth config/token input and rerun configure.", + ].join(" "); + progress.setLabel("Gateway service install blocked."); + return; + } const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({ env: process.env, port: params.port, - token: params.gatewayToken, + token: tokenResolution.token, runtime: daemonRuntime, warn: (message, title) => note(message, title), config: cfg, diff --git a/src/commands/configure.gateway-auth.test.ts b/src/commands/configure.gateway-auth.test.ts index 575195450..8ea0722f2 100644 --- a/src/commands/configure.gateway-auth.test.ts +++ b/src/commands/configure.gateway-auth.test.ts @@ -10,7 +10,10 @@ function expectGeneratedTokenFromInput(token: string | undefined, literalToAvoid expect(result?.token).toBeDefined(); expect(result?.token).not.toBe(literalToAvoid); expect(typeof result?.token).toBe("string"); - expect(result?.token?.length).toBeGreaterThan(0); + if (typeof result?.token !== "string") { + throw new Error("Expected generated token to be a string."); + } + expect(result.token.length).toBeGreaterThan(0); } describe("buildGatewayAuthConfig", () => { @@ -73,6 +76,23 @@ describe("buildGatewayAuthConfig", () => { expectGeneratedTokenFromInput("null", "null"); }); + it("preserves SecretRef tokens when token mode is selected", () => { + const tokenRef = { + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_TOKEN", + } as const; + const result = buildGatewayAuthConfig({ + mode: "token", + token: tokenRef, + }); + + expect(result).toEqual({ + mode: "token", + token: tokenRef, + }); + }); + it("builds trusted-proxy config with all options", () => { const result = buildGatewayAuthConfig({ mode: "trusted-proxy", diff --git a/src/commands/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts index d39f6ef62..40cb26bf4 100644 --- a/src/commands/configure.gateway-auth.ts +++ b/src/commands/configure.gateway-auth.ts @@ -1,5 +1,6 @@ import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; import type { OpenClawConfig, GatewayAuthConfig } from "../config/config.js"; +import { isSecretRef, type SecretInput } from "../config/types.secrets.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { promptAuthChoiceGrouped } from "./auth-choice-prompt.js"; @@ -17,7 +18,7 @@ import { randomToken } from "./onboard-helpers.js"; type GatewayAuthChoice = "token" | "password" | "trusted-proxy"; /** Reject undefined, empty, and common JS string-coercion artifacts for token auth. */ -function sanitizeTokenValue(value: string | undefined): string | undefined { +function sanitizeTokenValue(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; } @@ -39,7 +40,7 @@ const ANTHROPIC_OAUTH_MODEL_KEYS = [ export function buildGatewayAuthConfig(params: { existing?: GatewayAuthConfig; mode: GatewayAuthChoice; - token?: string; + token?: SecretInput; password?: string; trustedProxy?: { userHeader: string; @@ -54,6 +55,9 @@ export function buildGatewayAuthConfig(params: { } if (params.mode === "token") { + if (isSecretRef(params.token)) { + return { ...base, mode: "token", token: params.token }; + } // Keep token mode always valid: treat empty/undefined/"undefined"/"null" as missing and generate a token. const token = sanitizeTokenValue(params.token) ?? randomToken(); return { ...base, mode: "token", token }; diff --git a/src/commands/configure.gateway.test.ts b/src/commands/configure.gateway.test.ts index d23cfafad..1a8144fc8 100644 --- a/src/commands/configure.gateway.test.ts +++ b/src/commands/configure.gateway.test.ts @@ -68,7 +68,13 @@ async function runGatewayPrompt(params: { }) { vi.clearAllMocks(); mocks.resolveGatewayPort.mockReturnValue(18789); - mocks.select.mockImplementation(async () => params.selectQueue.shift()); + mocks.select.mockImplementation(async (input) => { + const next = params.selectQueue.shift(); + if (next !== undefined) { + return next; + } + return input.initialValue ?? input.options[0]?.value; + }); mocks.text.mockImplementation(async () => params.textQueue.shift()); mocks.randomToken.mockReturnValue(params.randomToken ?? "generated-token"); mocks.confirm.mockResolvedValue(params.confirmResult ?? true); @@ -95,7 +101,7 @@ async function runTrustedProxyPrompt(params: { describe("promptGatewayConfig", () => { it("generates a token when the prompt returns undefined", async () => { const { result } = await runGatewayPrompt({ - selectQueue: ["loopback", "token", "off"], + selectQueue: ["loopback", "token", "off", "plaintext"], textQueue: ["18789", undefined], randomToken: "generated-token", authConfigFactory: ({ mode, token, password }) => ({ mode, token, password }), @@ -163,7 +169,7 @@ describe("promptGatewayConfig", () => { mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net"); const { result } = await runGatewayPrompt({ // bind=loopback, auth=token, tailscale=serve - selectQueue: ["loopback", "token", "serve"], + selectQueue: ["loopback", "token", "serve", "plaintext"], textQueue: ["18789", "my-token"], confirmResult: true, authConfigFactory: ({ mode, token }) => ({ mode, token }), @@ -190,7 +196,7 @@ describe("promptGatewayConfig", () => { it("does not add Tailscale origin when getTailnetHostname fails", async () => { mocks.getTailnetHostname.mockRejectedValue(new Error("not found")); const { result } = await runGatewayPrompt({ - selectQueue: ["loopback", "token", "serve"], + selectQueue: ["loopback", "token", "serve", "plaintext"], textQueue: ["18789", "my-token"], confirmResult: true, authConfigFactory: ({ mode, token }) => ({ mode, token }), @@ -208,7 +214,7 @@ describe("promptGatewayConfig", () => { }, }, }, - selectQueue: ["loopback", "token", "serve"], + selectQueue: ["loopback", "token", "serve", "plaintext"], textQueue: ["18789", "my-token"], confirmResult: true, authConfigFactory: ({ mode, token }) => ({ mode, token }), @@ -223,7 +229,7 @@ describe("promptGatewayConfig", () => { it("formats IPv6 Tailscale fallback addresses as valid HTTPS origins", async () => { mocks.getTailnetHostname.mockResolvedValue("fd7a:115c:a1e0::12"); const { result } = await runGatewayPrompt({ - selectQueue: ["loopback", "token", "serve"], + selectQueue: ["loopback", "token", "serve", "plaintext"], textQueue: ["18789", "my-token"], confirmResult: true, authConfigFactory: ({ mode, token }) => ({ mode, token }), @@ -232,4 +238,29 @@ describe("promptGatewayConfig", () => { "https://[fd7a:115c:a1e0::12]", ); }); + + it("stores gateway token as SecretRef when token source is ref", async () => { + const previous = process.env.OPENCLAW_GATEWAY_TOKEN; + process.env.OPENCLAW_GATEWAY_TOKEN = "env-gateway-token"; + try { + const { call, result } = await runGatewayPrompt({ + selectQueue: ["loopback", "token", "off", "ref"], + textQueue: ["18789", "OPENCLAW_GATEWAY_TOKEN"], + authConfigFactory: ({ mode, token }) => ({ mode, token }), + }); + + expect(call?.token).toEqual({ + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_TOKEN", + }); + expect(result.token).toBeUndefined(); + } finally { + if (previous === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = previous; + } + } + }); }); diff --git a/src/commands/configure.gateway.ts b/src/commands/configure.gateway.ts index 117a0e070..eba6614e5 100644 --- a/src/commands/configure.gateway.ts +++ b/src/commands/configure.gateway.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveGatewayPort } from "../config/config.js"; +import { isValidEnvSecretRefId, type SecretInput } from "../config/types.secrets.js"; import { maybeAddTailnetOriginToControlUiAllowedOrigins, TAILSCALE_DOCS_LINES, @@ -8,6 +9,7 @@ import { } from "../gateway/gateway-config-prompts.shared.js"; import { findTailscaleBinary } from "../infra/tailscale.js"; import type { RuntimeEnv } from "../runtime.js"; +import { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js"; import { validateIPv4AddressInput } from "../shared/net/ipv4.js"; import { note } from "../terminal/note.js"; import { buildGatewayAuthConfig } from "./configure.gateway-auth.js"; @@ -20,6 +22,7 @@ import { } from "./onboard-helpers.js"; type GatewayAuthChoice = "token" | "password" | "trusted-proxy"; +type GatewayTokenInputMode = "plaintext" | "ref"; export async function promptGatewayConfig( cfg: OpenClawConfig, @@ -156,7 +159,8 @@ export async function promptGatewayConfig( tailscaleResetOnExit = false; } - let gatewayToken: string | undefined; + let gatewayToken: SecretInput | undefined; + let gatewayTokenForCalls: string | undefined; let gatewayPassword: string | undefined; let trustedProxyConfig: | { userHeader: string; requiredHeaders?: string[]; allowUsers?: string[] } @@ -165,14 +169,65 @@ export async function promptGatewayConfig( let next = cfg; if (authMode === "token") { - const tokenInput = guardCancel( - await text({ - message: "Gateway token (blank to generate)", - initialValue: randomToken(), + const tokenInputMode = guardCancel( + await select({ + message: "Gateway token source", + options: [ + { + value: "plaintext", + label: "Generate/store plaintext token", + hint: "Default", + }, + { + value: "ref", + label: "Use SecretRef", + hint: "Store an env-backed reference instead of plaintext", + }, + ], + initialValue: "plaintext", }), runtime, ); - gatewayToken = normalizeGatewayTokenInput(tokenInput) || randomToken(); + if (tokenInputMode === "ref") { + const envVar = guardCancel( + await text({ + message: "Gateway token env var", + initialValue: "OPENCLAW_GATEWAY_TOKEN", + placeholder: "OPENCLAW_GATEWAY_TOKEN", + validate: (value) => { + const candidate = String(value ?? "").trim(); + if (!isValidEnvSecretRefId(candidate)) { + return "Use an env var name like OPENCLAW_GATEWAY_TOKEN."; + } + const resolved = process.env[candidate]?.trim(); + if (!resolved) { + return `Environment variable "${candidate}" is missing or empty in this session.`; + } + return undefined; + }, + }), + runtime, + ); + const envVarName = String(envVar ?? "").trim(); + gatewayToken = { + source: "env", + provider: resolveDefaultSecretProviderAlias(cfg, "env", { + preferFirstProviderForSource: true, + }), + id: envVarName, + }; + note(`Validated ${envVarName}. OpenClaw will store a token SecretRef.`, "Gateway token"); + } else { + const tokenInput = guardCancel( + await text({ + message: "Gateway token (blank to generate)", + initialValue: randomToken(), + }), + runtime, + ); + gatewayTokenForCalls = normalizeGatewayTokenInput(tokenInput) || randomToken(); + gatewayToken = gatewayTokenForCalls; + } } if (authMode === "password") { @@ -294,5 +349,5 @@ export async function promptGatewayConfig( tailscaleBin, }); - return { config: next, port, token: gatewayToken }; + return { config: next, port, token: gatewayTokenForCalls }; } diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 4753317f8..38fedf8db 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -4,13 +4,13 @@ import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/config.js"; import { readConfigFileSnapshot, resolveGatewayPort, writeConfigFile } from "../config/config.js"; import { logConfigUpdated } from "../config/logging.js"; -import { normalizeSecretInputString } from "../config/types.secrets.js"; import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { note } from "../terminal/note.js"; import { resolveUserPath } from "../utils.js"; import { createClackPrompter } from "../wizard/clack-prompter.js"; +import { resolveOnboardingSecretInputString } from "../wizard/onboarding.secret-input.js"; import { WizardCancelledError } from "../wizard/prompts.js"; import { removeChannelConfigWizard } from "./configure.channels.js"; import { maybeInstallDaemon } from "./configure.daemon.js"; @@ -48,6 +48,23 @@ import { setupSkills } from "./onboard-skills.js"; type ConfigureSectionChoice = WizardSection | "__continue"; +async function resolveGatewaySecretInputForWizard(params: { + cfg: OpenClawConfig; + value: unknown; + path: string; +}): Promise { + try { + return await resolveOnboardingSecretInputString({ + config: params.cfg, + value: params.value, + path: params.path, + env: process.env, + }); + } catch { + return undefined; + } +} + async function runGatewayHealthCheck(params: { cfg: OpenClawConfig; runtime: RuntimeEnv; @@ -61,10 +78,22 @@ async function runGatewayHealthCheck(params: { }); const remoteUrl = params.cfg.gateway?.remote?.url?.trim(); const wsUrl = params.cfg.gateway?.mode === "remote" && remoteUrl ? remoteUrl : localLinks.wsUrl; - const token = params.cfg.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN; + const configuredToken = await resolveGatewaySecretInputForWizard({ + cfg: params.cfg, + value: params.cfg.gateway?.auth?.token, + path: "gateway.auth.token", + }); + const configuredPassword = await resolveGatewaySecretInputForWizard({ + cfg: params.cfg, + value: params.cfg.gateway?.auth?.password, + path: "gateway.auth.password", + }); + const token = + process.env.OPENCLAW_GATEWAY_TOKEN ?? process.env.CLAWDBOT_GATEWAY_TOKEN ?? configuredToken; const password = - normalizeSecretInputString(params.cfg.gateway?.auth?.password) ?? - process.env.OPENCLAW_GATEWAY_PASSWORD; + process.env.OPENCLAW_GATEWAY_PASSWORD ?? + process.env.CLAWDBOT_GATEWAY_PASSWORD ?? + configuredPassword; await waitForGatewayReachable({ url: wsUrl, @@ -305,18 +334,37 @@ export async function runConfigureWizard( } const localUrl = "ws://127.0.0.1:18789"; + const baseLocalProbeToken = await resolveGatewaySecretInputForWizard({ + cfg: baseConfig, + value: baseConfig.gateway?.auth?.token, + path: "gateway.auth.token", + }); + const baseLocalProbePassword = await resolveGatewaySecretInputForWizard({ + cfg: baseConfig, + value: baseConfig.gateway?.auth?.password, + path: "gateway.auth.password", + }); const localProbe = await probeGatewayReachable({ url: localUrl, - token: baseConfig.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN, + token: + process.env.OPENCLAW_GATEWAY_TOKEN ?? + process.env.CLAWDBOT_GATEWAY_TOKEN ?? + baseLocalProbeToken, password: - normalizeSecretInputString(baseConfig.gateway?.auth?.password) ?? - process.env.OPENCLAW_GATEWAY_PASSWORD, + process.env.OPENCLAW_GATEWAY_PASSWORD ?? + process.env.CLAWDBOT_GATEWAY_PASSWORD ?? + baseLocalProbePassword, }); const remoteUrl = baseConfig.gateway?.remote?.url?.trim() ?? ""; + const baseRemoteProbeToken = await resolveGatewaySecretInputForWizard({ + cfg: baseConfig, + value: baseConfig.gateway?.remote?.token, + path: "gateway.remote.token", + }); const remoteProbe = remoteUrl ? await probeGatewayReachable({ url: remoteUrl, - token: normalizeSecretInputString(baseConfig.gateway?.remote?.token), + token: baseRemoteProbeToken, }) : null; @@ -374,10 +422,6 @@ export async function runConfigureWizard( baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE; let gatewayPort = resolveGatewayPort(baseConfig); - let gatewayToken: string | undefined = - normalizeSecretInputString(nextConfig.gateway?.auth?.token) ?? - normalizeSecretInputString(baseConfig.gateway?.auth?.token) ?? - process.env.OPENCLAW_GATEWAY_TOKEN; const persistConfig = async () => { nextConfig = applyWizardMetadata(nextConfig, { @@ -486,7 +530,6 @@ export async function runConfigureWizard( const gateway = await promptGatewayConfig(nextConfig, runtime); nextConfig = gateway.config; gatewayPort = gateway.port; - gatewayToken = gateway.token; } if (selected.includes("channels")) { @@ -505,7 +548,7 @@ export async function runConfigureWizard( await promptDaemonPort(); } - await maybeInstallDaemon({ runtime, port: gatewayPort, gatewayToken }); + await maybeInstallDaemon({ runtime, port: gatewayPort }); } if (selected.includes("health")) { @@ -541,7 +584,6 @@ export async function runConfigureWizard( const gateway = await promptGatewayConfig(nextConfig, runtime); nextConfig = gateway.config; gatewayPort = gateway.port; - gatewayToken = gateway.token; didConfigureGateway = true; await persistConfig(); } @@ -564,7 +606,6 @@ export async function runConfigureWizard( await maybeInstallDaemon({ runtime, port: gatewayPort, - gatewayToken, }); } @@ -598,12 +639,29 @@ export async function runConfigureWizard( }); // Try both new and old passwords since gateway may still have old config. const newPassword = - normalizeSecretInputString(nextConfig.gateway?.auth?.password) ?? - process.env.OPENCLAW_GATEWAY_PASSWORD; + process.env.OPENCLAW_GATEWAY_PASSWORD ?? + process.env.CLAWDBOT_GATEWAY_PASSWORD ?? + (await resolveGatewaySecretInputForWizard({ + cfg: nextConfig, + value: nextConfig.gateway?.auth?.password, + path: "gateway.auth.password", + })); const oldPassword = - normalizeSecretInputString(baseConfig.gateway?.auth?.password) ?? - process.env.OPENCLAW_GATEWAY_PASSWORD; - const token = nextConfig.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN; + process.env.OPENCLAW_GATEWAY_PASSWORD ?? + process.env.CLAWDBOT_GATEWAY_PASSWORD ?? + (await resolveGatewaySecretInputForWizard({ + cfg: baseConfig, + value: baseConfig.gateway?.auth?.password, + path: "gateway.auth.password", + })); + const token = + process.env.OPENCLAW_GATEWAY_TOKEN ?? + process.env.CLAWDBOT_GATEWAY_TOKEN ?? + (await resolveGatewaySecretInputForWizard({ + cfg: nextConfig, + value: nextConfig.gateway?.auth?.token, + path: "gateway.auth.token", + })); let gatewayProbe = await probeGatewayReachable({ url: links.wsUrl, diff --git a/src/commands/dashboard.links.test.ts b/src/commands/dashboard.links.test.ts index 224fa9e42..40eac3199 100644 --- a/src/commands/dashboard.links.test.ts +++ b/src/commands/dashboard.links.test.ts @@ -8,6 +8,7 @@ const detectBrowserOpenSupportMock = vi.hoisted(() => vi.fn()); const openUrlMock = vi.hoisted(() => vi.fn()); const formatControlUiSshHintMock = vi.hoisted(() => vi.fn()); const copyToClipboardMock = vi.hoisted(() => vi.fn()); +const resolveSecretRefValuesMock = vi.hoisted(() => vi.fn()); vi.mock("../config/config.js", () => ({ readConfigFileSnapshot: readConfigFileSnapshotMock, @@ -25,6 +26,10 @@ vi.mock("../infra/clipboard.js", () => ({ copyToClipboard: copyToClipboardMock, })); +vi.mock("../secrets/resolve.js", () => ({ + resolveSecretRefValues: resolveSecretRefValuesMock, +})); + const runtime = { log: vi.fn(), error: vi.fn(), @@ -37,7 +42,7 @@ function resetRuntime() { runtime.exit.mockClear(); } -function mockSnapshot(token = "abc") { +function mockSnapshot(token: unknown = "abc") { readConfigFileSnapshotMock.mockResolvedValue({ path: "/tmp/openclaw.json", exists: true, @@ -53,6 +58,7 @@ function mockSnapshot(token = "abc") { httpUrl: "http://127.0.0.1:18789/", wsUrl: "ws://127.0.0.1:18789", }); + resolveSecretRefValuesMock.mockReset(); } describe("dashboardCommand", () => { @@ -65,6 +71,8 @@ describe("dashboardCommand", () => { openUrlMock.mockClear(); formatControlUiSshHintMock.mockClear(); copyToClipboardMock.mockClear(); + delete process.env.OPENCLAW_GATEWAY_TOKEN; + delete process.env.CLAWDBOT_GATEWAY_TOKEN; }); it("opens and copies the dashboard link by default", async () => { @@ -115,4 +123,71 @@ describe("dashboardCommand", () => { "Browser launch disabled (--no-open). Use the URL above.", ); }); + + it("prints non-tokenized URL with guidance when token SecretRef is unresolved", async () => { + mockSnapshot({ + source: "env", + provider: "default", + id: "MISSING_GATEWAY_TOKEN", + }); + copyToClipboardMock.mockResolvedValue(true); + detectBrowserOpenSupportMock.mockResolvedValue({ ok: true }); + openUrlMock.mockResolvedValue(true); + resolveSecretRefValuesMock.mockRejectedValue(new Error("missing env var")); + + await dashboardCommand(runtime); + + expect(copyToClipboardMock).toHaveBeenCalledWith("http://127.0.0.1:18789/"); + expect(runtime.log).toHaveBeenCalledWith( + expect.stringContaining("Token auto-auth unavailable"), + ); + expect(runtime.log).toHaveBeenCalledWith( + expect.stringContaining( + "gateway.auth.token SecretRef is unresolved (env:default:MISSING_GATEWAY_TOKEN).", + ), + ); + expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining("missing env var")); + }); + + it("keeps URL non-tokenized when token SecretRef is unresolved but env fallback exists", async () => { + mockSnapshot({ + source: "env", + provider: "default", + id: "MISSING_GATEWAY_TOKEN", + }); + process.env.OPENCLAW_GATEWAY_TOKEN = "fallback-token"; + copyToClipboardMock.mockResolvedValue(true); + detectBrowserOpenSupportMock.mockResolvedValue({ ok: true }); + openUrlMock.mockResolvedValue(true); + resolveSecretRefValuesMock.mockRejectedValue(new Error("missing env var")); + + await dashboardCommand(runtime); + + expect(copyToClipboardMock).toHaveBeenCalledWith("http://127.0.0.1:18789/"); + expect(openUrlMock).toHaveBeenCalledWith("http://127.0.0.1:18789/"); + expect(runtime.log).toHaveBeenCalledWith( + expect.stringContaining("Token auto-auth is disabled for SecretRef-managed"), + ); + expect(runtime.log).not.toHaveBeenCalledWith( + expect.stringContaining("Token auto-auth unavailable"), + ); + }); + + it("resolves env-template gateway.auth.token before building dashboard URL", async () => { + mockSnapshot("${CUSTOM_GATEWAY_TOKEN}"); + copyToClipboardMock.mockResolvedValue(true); + detectBrowserOpenSupportMock.mockResolvedValue({ ok: true }); + openUrlMock.mockResolvedValue(true); + resolveSecretRefValuesMock.mockResolvedValue( + new Map([["env:default:CUSTOM_GATEWAY_TOKEN", "resolved-secret-token"]]), + ); + + await dashboardCommand(runtime); + + expect(copyToClipboardMock).toHaveBeenCalledWith("http://127.0.0.1:18789/"); + expect(openUrlMock).toHaveBeenCalledWith("http://127.0.0.1:18789/"); + expect(runtime.log).toHaveBeenCalledWith( + expect.stringContaining("Token auto-auth is disabled for SecretRef-managed"), + ); + }); }); diff --git a/src/commands/dashboard.ts b/src/commands/dashboard.ts index 8b95b540c..02bf23e58 100644 --- a/src/commands/dashboard.ts +++ b/src/commands/dashboard.ts @@ -1,7 +1,11 @@ import { readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js"; +import type { OpenClawConfig } from "../config/types.js"; +import { resolveSecretInputRef } from "../config/types.secrets.js"; import { copyToClipboard } from "../infra/clipboard.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; +import { secretRefKey } from "../secrets/ref-contract.js"; +import { resolveSecretRefValues } from "../secrets/resolve.js"; import { detectBrowserOpenSupport, formatControlUiSshHint, @@ -13,6 +17,69 @@ type DashboardOptions = { noOpen?: boolean; }; +function readGatewayTokenEnv(env: NodeJS.ProcessEnv): string | undefined { + const primary = env.OPENCLAW_GATEWAY_TOKEN?.trim(); + if (primary) { + return primary; + } + const legacy = env.CLAWDBOT_GATEWAY_TOKEN?.trim(); + return legacy || undefined; +} + +async function resolveDashboardToken( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv = process.env, +): Promise<{ + token?: string; + source?: "config" | "env" | "secretRef"; + unresolvedRefReason?: string; + tokenSecretRefConfigured: boolean; +}> { + const { ref } = resolveSecretInputRef({ + value: cfg.gateway?.auth?.token, + defaults: cfg.secrets?.defaults, + }); + const configToken = + ref || typeof cfg.gateway?.auth?.token !== "string" + ? undefined + : cfg.gateway.auth.token.trim() || undefined; + if (configToken) { + return { token: configToken, source: "config", tokenSecretRefConfigured: false }; + } + if (!ref) { + const envToken = readGatewayTokenEnv(env); + return envToken + ? { token: envToken, source: "env", tokenSecretRefConfigured: false } + : { tokenSecretRefConfigured: false }; + } + const refLabel = `${ref.source}:${ref.provider}:${ref.id}`; + try { + const resolved = await resolveSecretRefValues([ref], { + config: cfg, + env, + }); + const value = resolved.get(secretRefKey(ref)); + if (typeof value === "string" && value.trim().length > 0) { + return { token: value.trim(), source: "secretRef", tokenSecretRefConfigured: true }; + } + const envToken = readGatewayTokenEnv(env); + return envToken + ? { token: envToken, source: "env", tokenSecretRefConfigured: true } + : { + unresolvedRefReason: `gateway.auth.token SecretRef is unresolved (${refLabel}).`, + tokenSecretRefConfigured: true, + }; + } catch { + const envToken = readGatewayTokenEnv(env); + return envToken + ? { token: envToken, source: "env", tokenSecretRefConfigured: true } + : { + unresolvedRefReason: `gateway.auth.token SecretRef is unresolved (${refLabel}).`, + tokenSecretRefConfigured: true, + }; + } +} + export async function dashboardCommand( runtime: RuntimeEnv = defaultRuntime, options: DashboardOptions = {}, @@ -23,7 +90,8 @@ export async function dashboardCommand( const bind = cfg.gateway?.bind ?? "loopback"; const basePath = cfg.gateway?.controlUi?.basePath; const customBindHost = cfg.gateway?.customBindHost; - const token = cfg.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN ?? ""; + const resolvedToken = await resolveDashboardToken(cfg, process.env); + const token = resolvedToken.token ?? ""; // LAN URLs fail secure-context checks in browsers. // Coerce only lan->loopback and preserve other bind modes. @@ -33,12 +101,25 @@ export async function dashboardCommand( customBindHost, basePath, }); + // Avoid embedding externally managed SecretRef tokens in terminal/clipboard/browser args. + const includeTokenInUrl = token.length > 0 && !resolvedToken.tokenSecretRefConfigured; // Prefer URL fragment to avoid leaking auth tokens via query params. - const dashboardUrl = token + const dashboardUrl = includeTokenInUrl ? `${links.httpUrl}#token=${encodeURIComponent(token)}` : links.httpUrl; runtime.log(`Dashboard URL: ${dashboardUrl}`); + if (resolvedToken.tokenSecretRefConfigured && token) { + runtime.log( + "Token auto-auth is disabled for SecretRef-managed gateway.auth.token; use your external token source if prompted.", + ); + } + if (resolvedToken.unresolvedRefReason) { + runtime.log(`Token auto-auth unavailable: ${resolvedToken.unresolvedRefReason}`); + runtime.log( + "Set OPENCLAW_GATEWAY_TOKEN in this shell or resolve your secret provider, then rerun `openclaw dashboard`.", + ); + } const copied = await copyToClipboard(dashboardUrl).catch(() => false); runtime.log(copied ? "Copied to clipboard." : "Copy to clipboard unavailable."); @@ -54,7 +135,7 @@ export async function dashboardCommand( hint = formatControlUiSshHint({ port, basePath, - token: token || undefined, + token: includeTokenInUrl ? token || undefined : undefined, }); } } else { diff --git a/src/commands/doctor-gateway-auth-token.test.ts b/src/commands/doctor-gateway-auth-token.test.ts new file mode 100644 index 000000000..eac815ac0 --- /dev/null +++ b/src/commands/doctor-gateway-auth-token.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { withEnvAsync } from "../test-utils/env.js"; +import { + resolveGatewayAuthTokenForService, + shouldRequireGatewayTokenForInstall, +} from "./doctor-gateway-auth-token.js"; + +describe("resolveGatewayAuthTokenForService", () => { + it("returns plaintext gateway.auth.token when configured", async () => { + const resolved = await resolveGatewayAuthTokenForService( + { + gateway: { + auth: { + token: "config-token", + }, + }, + } as OpenClawConfig, + {} as NodeJS.ProcessEnv, + ); + + expect(resolved).toEqual({ token: "config-token" }); + }); + + it("resolves SecretRef-backed gateway.auth.token", async () => { + const resolved = await resolveGatewayAuthTokenForService( + { + gateway: { + auth: { + token: { source: "env", provider: "default", id: "CUSTOM_GATEWAY_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as OpenClawConfig, + { + CUSTOM_GATEWAY_TOKEN: "resolved-token", + } as NodeJS.ProcessEnv, + ); + + expect(resolved).toEqual({ token: "resolved-token" }); + }); + + it("resolves env-template gateway.auth.token via SecretRef resolution", async () => { + const resolved = await resolveGatewayAuthTokenForService( + { + gateway: { + auth: { + token: "${CUSTOM_GATEWAY_TOKEN}", + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as OpenClawConfig, + { + CUSTOM_GATEWAY_TOKEN: "resolved-token", + } as NodeJS.ProcessEnv, + ); + + expect(resolved).toEqual({ token: "resolved-token" }); + }); + + it("falls back to OPENCLAW_GATEWAY_TOKEN when SecretRef is unresolved", async () => { + const resolved = await resolveGatewayAuthTokenForService( + { + gateway: { + auth: { + token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as OpenClawConfig, + { + OPENCLAW_GATEWAY_TOKEN: "env-fallback-token", + } as NodeJS.ProcessEnv, + ); + + expect(resolved).toEqual({ token: "env-fallback-token" }); + }); + + it("falls back to OPENCLAW_GATEWAY_TOKEN when SecretRef resolves to empty", async () => { + const resolved = await resolveGatewayAuthTokenForService( + { + gateway: { + auth: { + token: { source: "env", provider: "default", id: "CUSTOM_GATEWAY_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as OpenClawConfig, + { + CUSTOM_GATEWAY_TOKEN: " ", + OPENCLAW_GATEWAY_TOKEN: "env-fallback-token", + } as NodeJS.ProcessEnv, + ); + + expect(resolved).toEqual({ token: "env-fallback-token" }); + }); + + it("returns unavailableReason when SecretRef is unresolved without env fallback", async () => { + const resolved = await resolveGatewayAuthTokenForService( + { + gateway: { + auth: { + token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as OpenClawConfig, + {} as NodeJS.ProcessEnv, + ); + + expect(resolved.token).toBeUndefined(); + expect(resolved.unavailableReason).toContain("gateway.auth.token SecretRef is configured"); + }); +}); + +describe("shouldRequireGatewayTokenForInstall", () => { + it("requires token when auth mode is token", () => { + const required = shouldRequireGatewayTokenForInstall( + { + gateway: { + auth: { + mode: "token", + }, + }, + } as OpenClawConfig, + {} as NodeJS.ProcessEnv, + ); + expect(required).toBe(true); + }); + + it("does not require token when auth mode is password", () => { + const required = shouldRequireGatewayTokenForInstall( + { + gateway: { + auth: { + mode: "password", + }, + }, + } as OpenClawConfig, + {} as NodeJS.ProcessEnv, + ); + expect(required).toBe(false); + }); + + it("requires token in inferred mode when password env exists only in shell", async () => { + await withEnvAsync({ OPENCLAW_GATEWAY_PASSWORD: "password-from-env" }, async () => { + const required = shouldRequireGatewayTokenForInstall( + { + gateway: { + auth: {}, + }, + } as OpenClawConfig, + process.env, + ); + expect(required).toBe(true); + }); + }); + + it("does not require token in inferred mode when password is configured", () => { + const required = shouldRequireGatewayTokenForInstall( + { + gateway: { + auth: { + password: { source: "env", provider: "default", id: "CUSTOM_GATEWAY_PASSWORD" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as OpenClawConfig, + {} as NodeJS.ProcessEnv, + ); + expect(required).toBe(false); + }); + + it("does not require token in inferred mode when password env is configured in config", () => { + const required = shouldRequireGatewayTokenForInstall( + { + gateway: { + auth: {}, + }, + env: { + vars: { + OPENCLAW_GATEWAY_PASSWORD: "configured-password", + }, + }, + } as OpenClawConfig, + {} as NodeJS.ProcessEnv, + ); + expect(required).toBe(false); + }); + + it("requires token in inferred mode when no password candidate exists", () => { + const required = shouldRequireGatewayTokenForInstall( + { + gateway: { + auth: {}, + }, + } as OpenClawConfig, + {} as NodeJS.ProcessEnv, + ); + expect(required).toBe(true); + }); +}); diff --git a/src/commands/doctor-gateway-auth-token.ts b/src/commands/doctor-gateway-auth-token.ts new file mode 100644 index 000000000..dbb69c84d --- /dev/null +++ b/src/commands/doctor-gateway-auth-token.ts @@ -0,0 +1,54 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { resolveSecretInputRef } from "../config/types.secrets.js"; +export { shouldRequireGatewayTokenForInstall } from "../gateway/auth-install-policy.js"; +import { secretRefKey } from "../secrets/ref-contract.js"; +import { resolveSecretRefValues } from "../secrets/resolve.js"; + +function readGatewayTokenEnv(env: NodeJS.ProcessEnv): string | undefined { + const value = env.OPENCLAW_GATEWAY_TOKEN ?? env.CLAWDBOT_GATEWAY_TOKEN; + const trimmed = value?.trim(); + return trimmed || undefined; +} + +export async function resolveGatewayAuthTokenForService( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv, +): Promise<{ token?: string; unavailableReason?: string }> { + const { ref } = resolveSecretInputRef({ + value: cfg.gateway?.auth?.token, + defaults: cfg.secrets?.defaults, + }); + const configToken = + ref || typeof cfg.gateway?.auth?.token !== "string" + ? undefined + : cfg.gateway.auth.token.trim() || undefined; + if (configToken) { + return { token: configToken }; + } + if (ref) { + try { + const resolved = await resolveSecretRefValues([ref], { + config: cfg, + env, + }); + const value = resolved.get(secretRefKey(ref)); + if (typeof value === "string" && value.trim().length > 0) { + return { token: value.trim() }; + } + const envToken = readGatewayTokenEnv(env); + if (envToken) { + return { token: envToken }; + } + return { unavailableReason: "gateway.auth.token SecretRef resolved to an empty value." }; + } catch (err) { + const envToken = readGatewayTokenEnv(env); + if (envToken) { + return { token: envToken }; + } + return { + unavailableReason: `gateway.auth.token SecretRef is configured but unresolved (${String(err)}).`, + }; + } + } + return { token: readGatewayTokenEnv(env) }; +} diff --git a/src/commands/doctor-gateway-daemon-flow.ts b/src/commands/doctor-gateway-daemon-flow.ts index 49f0e48e9..d3ac55073 100644 --- a/src/commands/doctor-gateway-daemon-flow.ts +++ b/src/commands/doctor-gateway-daemon-flow.ts @@ -28,6 +28,7 @@ import { } from "./daemon-runtime.js"; import { buildGatewayRuntimeHints, formatGatewayRuntimeSummary } from "./doctor-format.js"; import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js"; +import { resolveGatewayInstallToken } from "./gateway-install-token.js"; import { formatHealthCheckFailure } from "./health-format.js"; import { healthCommand } from "./health.js"; @@ -171,11 +172,29 @@ export async function maybeRepairGatewayDaemon(params: { }, DEFAULT_GATEWAY_DAEMON_RUNTIME, ); + const tokenResolution = await resolveGatewayInstallToken({ + config: params.cfg, + env: process.env, + }); + for (const warning of tokenResolution.warnings) { + note(warning, "Gateway"); + } + if (tokenResolution.unavailableReason) { + note( + [ + "Gateway service install aborted.", + tokenResolution.unavailableReason, + "Fix gateway auth config/token input and rerun doctor.", + ].join("\n"), + "Gateway", + ); + return; + } const port = resolveGatewayPort(params.cfg, process.env); const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({ env: process.env, port, - token: params.cfg.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN, + token: tokenResolution.token, runtime: daemonRuntime, warn: (message, title) => note(message, title), config: params.cfg, diff --git a/src/commands/doctor-gateway-services.test.ts b/src/commands/doctor-gateway-services.test.ts index 359a304f8..2d81eb26f 100644 --- a/src/commands/doctor-gateway-services.test.ts +++ b/src/commands/doctor-gateway-services.test.ts @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ install: vi.fn(), auditGatewayServiceConfig: vi.fn(), buildGatewayInstallPlan: vi.fn(), + resolveGatewayInstallToken: vi.fn(), resolveGatewayPort: vi.fn(() => 18789), resolveIsNixMode: vi.fn(() => false), findExtraGatewayServices: vi.fn().mockResolvedValue([]), @@ -57,6 +58,10 @@ vi.mock("./daemon-install-helpers.js", () => ({ buildGatewayInstallPlan: mocks.buildGatewayInstallPlan, })); +vi.mock("./gateway-install-token.js", () => ({ + resolveGatewayInstallToken: mocks.resolveGatewayInstallToken, +})); + import { maybeRepairGatewayServiceConfig, maybeScanExtraGatewayServices, @@ -114,6 +119,11 @@ function setupGatewayTokenRepairScenario(expectedToken: string) { OPENCLAW_GATEWAY_TOKEN: expectedToken, }, }); + mocks.resolveGatewayInstallToken.mockResolvedValue({ + token: expectedToken, + tokenRefConfigured: false, + warnings: [], + }); mocks.install.mockResolvedValue(undefined); } @@ -172,6 +182,57 @@ describe("maybeRepairGatewayServiceConfig", () => { expect(mocks.install).toHaveBeenCalledTimes(1); }); }); + + it("treats SecretRef-managed gateway token as non-persisted service state", async () => { + mocks.readCommand.mockResolvedValue({ + programArguments: gatewayProgramArguments, + environment: { + OPENCLAW_GATEWAY_TOKEN: "stale-token", + }, + }); + mocks.resolveGatewayInstallToken.mockResolvedValue({ + token: undefined, + tokenRefConfigured: true, + warnings: [], + }); + mocks.auditGatewayServiceConfig.mockResolvedValue({ + ok: false, + issues: [], + }); + mocks.buildGatewayInstallPlan.mockResolvedValue({ + programArguments: gatewayProgramArguments, + workingDirectory: "/tmp", + environment: {}, + }); + mocks.install.mockResolvedValue(undefined); + + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "token", + token: { + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_TOKEN", + }, + }, + }, + }; + + await runRepair(cfg); + + expect(mocks.auditGatewayServiceConfig).toHaveBeenCalledWith( + expect.objectContaining({ + expectedGatewayToken: undefined, + }), + ); + expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ + token: undefined, + }), + ); + expect(mocks.install).toHaveBeenCalledTimes(1); + }); }); describe("maybeScanExtraGatewayServices", () => { diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index 04a0b1eed..f4416b49d 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { promisify } from "node:util"; import type { OpenClawConfig } from "../config/config.js"; import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js"; +import { resolveSecretInputRef } from "../config/types.secrets.js"; import { findExtraGatewayServices, renderGatewayServiceCleanupHints, @@ -22,7 +23,9 @@ import type { RuntimeEnv } from "../runtime.js"; import { note } from "../terminal/note.js"; import { buildGatewayInstallPlan } from "./daemon-install-helpers.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME, type GatewayDaemonRuntime } from "./daemon-runtime.js"; +import { resolveGatewayAuthTokenForService } from "./doctor-gateway-auth-token.js"; import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js"; +import { resolveGatewayInstallToken } from "./gateway-install-token.js"; const execFileAsync = promisify(execFile); @@ -55,16 +58,6 @@ function normalizeExecutablePath(value: string): string { return path.resolve(value); } -function resolveGatewayAuthToken(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): string | undefined { - const configToken = cfg.gateway?.auth?.token?.trim(); - if (configToken) { - return configToken; - } - const envToken = env.OPENCLAW_GATEWAY_TOKEN ?? env.CLAWDBOT_GATEWAY_TOKEN; - const trimmedEnvToken = envToken?.trim(); - return trimmedEnvToken || undefined; -} - function extractDetailPath(detail: string, prefix: string): string | null { if (!detail.startsWith(prefix)) { return null; @@ -219,12 +212,35 @@ export async function maybeRepairGatewayServiceConfig( return; } - const expectedGatewayToken = resolveGatewayAuthToken(cfg, process.env); + const tokenRefConfigured = Boolean( + resolveSecretInputRef({ + value: cfg.gateway?.auth?.token, + defaults: cfg.secrets?.defaults, + }).ref, + ); + const gatewayTokenResolution = await resolveGatewayAuthTokenForService(cfg, process.env); + if (gatewayTokenResolution.unavailableReason) { + note( + `Unable to verify gateway service token drift: ${gatewayTokenResolution.unavailableReason}`, + "Gateway service config", + ); + } + const expectedGatewayToken = tokenRefConfigured ? undefined : gatewayTokenResolution.token; const audit = await auditGatewayServiceConfig({ env: process.env, command, expectedGatewayToken, }); + const serviceToken = command.environment?.OPENCLAW_GATEWAY_TOKEN?.trim(); + if (tokenRefConfigured && serviceToken) { + audit.issues.push({ + code: SERVICE_AUDIT_CODES.gatewayTokenMismatch, + message: + "Gateway service OPENCLAW_GATEWAY_TOKEN should be unset when gateway.auth.token is SecretRef-managed", + detail: "service token is stale", + level: "recommended", + }); + } const needsNodeRuntime = needsNodeRuntimeMigration(audit.issues); const systemNodeInfo = needsNodeRuntime ? await resolveSystemNodeInfo({ env: process.env }) @@ -243,10 +259,24 @@ export async function maybeRepairGatewayServiceConfig( const port = resolveGatewayPort(cfg, process.env); const runtimeChoice = detectGatewayRuntime(command.programArguments); + const installTokenResolution = await resolveGatewayInstallToken({ + config: cfg, + env: process.env, + }); + for (const warning of installTokenResolution.warnings) { + note(warning, "Gateway service config"); + } + if (installTokenResolution.unavailableReason) { + note( + `Unable to verify gateway service token drift: ${installTokenResolution.unavailableReason}`, + "Gateway service config", + ); + return; + } const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({ env: process.env, port, - token: expectedGatewayToken, + token: installTokenResolution.token, runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice, nodePath: systemNodePath ?? undefined, warn: (message, title) => note(message, title), diff --git a/src/commands/doctor-platform-notes.ts b/src/commands/doctor-platform-notes.ts index f23346fe3..b3d381f27 100644 --- a/src/commands/doctor-platform-notes.ts +++ b/src/commands/doctor-platform-notes.ts @@ -45,13 +45,11 @@ async function launchctlGetenv(name: string): Promise { } function hasConfigGatewayCreds(cfg: OpenClawConfig): boolean { - const localToken = - typeof cfg.gateway?.auth?.token === "string" ? cfg.gateway.auth.token : undefined; const localPassword = cfg.gateway?.auth?.password; const remoteToken = cfg.gateway?.remote?.token; const remotePassword = cfg.gateway?.remote?.password; return Boolean( - hasConfiguredSecretInput(localToken) || + hasConfiguredSecretInput(cfg.gateway?.auth?.token, cfg.secrets?.defaults) || hasConfiguredSecretInput(localPassword, cfg.secrets?.defaults) || hasConfiguredSecretInput(remoteToken, cfg.secrets?.defaults) || hasConfiguredSecretInput(remotePassword, cfg.secrets?.defaults), diff --git a/src/commands/doctor-security.test.ts b/src/commands/doctor-security.test.ts index 1a0866dfc..064f3ce1f 100644 --- a/src/commands/doctor-security.test.ts +++ b/src/commands/doctor-security.test.ts @@ -61,6 +61,22 @@ describe("noteSecurityWarnings gateway exposure", () => { expect(message).not.toContain("CRITICAL"); }); + it("treats SecretRef token config as authenticated for exposure warning level", async () => { + const cfg = { + gateway: { + bind: "lan", + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" }, + }, + }, + } as OpenClawConfig; + await noteSecurityWarnings(cfg); + const message = lastMessage(); + expect(message).toContain("WARNING"); + expect(message).not.toContain("CRITICAL"); + }); + it("treats whitespace token as missing", async () => { const cfg = { gateway: { bind: "lan", auth: { mode: "token", token: " " } }, diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index d1672c2ea..ab1b46056 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -2,6 +2,7 @@ import { listChannelPlugins } from "../channels/plugins/index.js"; import type { ChannelId } from "../channels/plugins/types.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig, GatewayBindMode } from "../config/config.js"; +import { hasConfiguredSecretInput } from "../config/types.secrets.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import { isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js"; import { resolveDmAllowState } from "../security/dm-policy-shared.js"; @@ -44,8 +45,12 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) { }); const authToken = resolvedAuth.token?.trim() ?? ""; const authPassword = resolvedAuth.password?.trim() ?? ""; - const hasToken = authToken.length > 0; - const hasPassword = authPassword.length > 0; + const hasToken = + authToken.length > 0 || + hasConfiguredSecretInput(cfg.gateway?.auth?.token, cfg.secrets?.defaults); + const hasPassword = + authPassword.length > 0 || + hasConfiguredSecretInput(cfg.gateway?.auth?.password, cfg.secrets?.defaults); const hasSharedSecret = (resolvedAuth.mode === "token" && hasToken) || (resolvedAuth.mode === "password" && hasPassword); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 6335c6750..2688774b8 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -12,7 +12,9 @@ import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/config.js"; import { CONFIG_PATH, readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; import { logConfigUpdated } from "../config/logging.js"; +import { resolveSecretInputRef } from "../config/types.secrets.js"; import { resolveGatewayService } from "../daemon/service.js"; +import { hasAmbiguousGatewayAuthModeConfig } from "../gateway/auth-mode-policy.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import { buildGatewayConnectionDetails } from "../gateway/call.js"; import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js"; @@ -117,6 +119,17 @@ export async function doctorCommand( } note(lines.join("\n"), "Gateway"); } + if (resolveMode(cfg) === "local" && hasAmbiguousGatewayAuthModeConfig(cfg)) { + note( + [ + "gateway.auth.token and gateway.auth.password are both configured while gateway.auth.mode is unset.", + "Set an explicit mode to avoid ambiguous auth selection and startup/runtime failures.", + `Set token mode: ${formatCliCommand("openclaw config set gateway.auth.mode token")}`, + `Set password mode: ${formatCliCommand("openclaw config set gateway.auth.mode password")}`, + ].join("\n"), + "Gateway auth", + ); + } cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter); cfg = await maybeRemoveDeprecatedCliAuthProfiles(cfg, prompter); @@ -130,39 +143,54 @@ export async function doctorCommand( note(gatewayDetails.remoteFallbackNote, "Gateway"); } if (resolveMode(cfg) === "local" && sourceConfigValid) { + const gatewayTokenRef = resolveSecretInputRef({ + value: cfg.gateway?.auth?.token, + defaults: cfg.secrets?.defaults, + }).ref; const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off", }); const needsToken = auth.mode !== "password" && (auth.mode !== "token" || !auth.token); if (needsToken) { - note( - "Gateway auth is off or missing a token. Token auth is now the recommended default (including loopback).", - "Gateway auth", - ); - const shouldSetToken = - options.generateGatewayToken === true - ? true - : options.nonInteractive === true - ? false - : await prompter.confirmRepair({ - message: "Generate and configure a gateway token now?", - initialValue: true, - }); - if (shouldSetToken) { - const nextToken = randomToken(); - cfg = { - ...cfg, - gateway: { - ...cfg.gateway, - auth: { - ...cfg.gateway?.auth, - mode: "token", - token: nextToken, + if (gatewayTokenRef) { + note( + [ + "Gateway token is managed via SecretRef and is currently unavailable.", + "Doctor will not overwrite gateway.auth.token with a plaintext value.", + "Resolve/rotate the external secret source, then rerun doctor.", + ].join("\n"), + "Gateway auth", + ); + } else { + note( + "Gateway auth is off or missing a token. Token auth is now the recommended default (including loopback).", + "Gateway auth", + ); + const shouldSetToken = + options.generateGatewayToken === true + ? true + : options.nonInteractive === true + ? false + : await prompter.confirmRepair({ + message: "Generate and configure a gateway token now?", + initialValue: true, + }); + if (shouldSetToken) { + const nextToken = randomToken(); + cfg = { + ...cfg, + gateway: { + ...cfg.gateway, + auth: { + ...cfg.gateway?.auth, + mode: "token", + token: nextToken, + }, }, - }, - }; - note("Gateway token configured.", "Gateway auth"); + }; + note("Gateway token configured.", "Gateway auth"); + } } } } diff --git a/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts index 00453e2e1..ac6483081 100644 --- a/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts +++ b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts @@ -87,4 +87,33 @@ describe("doctor command", () => { ); expect(warned).toBe(false); }); + + it("warns when token and password are both configured and gateway.auth.mode is unset", async () => { + mockDoctorConfigSnapshot({ + config: { + gateway: { + mode: "local", + auth: { + token: "token-value", + password: "password-value", + }, + }, + }, + }); + + note.mockClear(); + + await doctorCommand(createDoctorRuntime(), { + nonInteractive: true, + workspaceSuggestions: false, + }); + + const gatewayAuthNote = note.mock.calls.find((call) => call[1] === "Gateway auth"); + expect(gatewayAuthNote).toBeTruthy(); + expect(String(gatewayAuthNote?.[0])).toContain("gateway.auth.mode is unset"); + expect(String(gatewayAuthNote?.[0])).toContain("openclaw config set gateway.auth.mode token"); + expect(String(gatewayAuthNote?.[0])).toContain( + "openclaw config set gateway.auth.mode password", + ); + }); }); diff --git a/src/commands/gateway-install-token.test.ts b/src/commands/gateway-install-token.test.ts new file mode 100644 index 000000000..1e864851d --- /dev/null +++ b/src/commands/gateway-install-token.test.ts @@ -0,0 +1,283 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; + +const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); +const writeConfigFileMock = vi.hoisted(() => vi.fn()); +const resolveSecretInputRefMock = vi.hoisted(() => + vi.fn((): { ref: unknown } => ({ ref: undefined })), +); +const hasConfiguredSecretInputMock = vi.hoisted(() => + vi.fn((value: unknown) => { + if (typeof value === "string") { + return value.trim().length > 0; + } + return value != null; + }), +); +const resolveGatewayAuthMock = vi.hoisted(() => + vi.fn(() => ({ + mode: "token", + token: undefined, + password: undefined, + allowTailscale: false, + })), +); +const shouldRequireGatewayTokenForInstallMock = vi.hoisted(() => vi.fn(() => true)); +const resolveSecretRefValuesMock = vi.hoisted(() => vi.fn()); +const secretRefKeyMock = vi.hoisted(() => vi.fn(() => "env:default:OPENCLAW_GATEWAY_TOKEN")); +const randomTokenMock = vi.hoisted(() => vi.fn(() => "generated-token")); + +vi.mock("../config/config.js", () => ({ + readConfigFileSnapshot: readConfigFileSnapshotMock, + writeConfigFile: writeConfigFileMock, +})); + +vi.mock("../config/types.secrets.js", () => ({ + resolveSecretInputRef: resolveSecretInputRefMock, + hasConfiguredSecretInput: hasConfiguredSecretInputMock, +})); + +vi.mock("../gateway/auth.js", () => ({ + resolveGatewayAuth: resolveGatewayAuthMock, +})); + +vi.mock("../gateway/auth-install-policy.js", () => ({ + shouldRequireGatewayTokenForInstall: shouldRequireGatewayTokenForInstallMock, +})); + +vi.mock("../secrets/ref-contract.js", () => ({ + secretRefKey: secretRefKeyMock, +})); + +vi.mock("../secrets/resolve.js", () => ({ + resolveSecretRefValues: resolveSecretRefValuesMock, +})); + +vi.mock("./onboard-helpers.js", () => ({ + randomToken: randomTokenMock, +})); + +const { resolveGatewayInstallToken } = await import("./gateway-install-token.js"); + +describe("resolveGatewayInstallToken", () => { + beforeEach(() => { + vi.clearAllMocks(); + readConfigFileSnapshotMock.mockResolvedValue({ exists: false, valid: true, config: {} }); + resolveSecretInputRefMock.mockReturnValue({ ref: undefined }); + hasConfiguredSecretInputMock.mockImplementation((value: unknown) => { + if (typeof value === "string") { + return value.trim().length > 0; + } + return value != null; + }); + resolveSecretRefValuesMock.mockResolvedValue(new Map()); + shouldRequireGatewayTokenForInstallMock.mockReturnValue(true); + resolveGatewayAuthMock.mockReturnValue({ + mode: "token", + token: undefined, + password: undefined, + allowTailscale: false, + }); + randomTokenMock.mockReturnValue("generated-token"); + }); + + it("uses plaintext gateway.auth.token when configured", async () => { + const result = await resolveGatewayInstallToken({ + config: { + gateway: { auth: { token: "config-token" } }, + } as OpenClawConfig, + env: {} as NodeJS.ProcessEnv, + }); + + expect(result).toEqual({ + token: "config-token", + tokenRefConfigured: false, + unavailableReason: undefined, + warnings: [], + }); + }); + + it("validates SecretRef token but does not persist resolved plaintext", async () => { + const tokenRef = { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" }; + resolveSecretInputRefMock.mockReturnValue({ ref: tokenRef }); + resolveSecretRefValuesMock.mockResolvedValue( + new Map([["env:default:OPENCLAW_GATEWAY_TOKEN", "resolved-token"]]), + ); + + const result = await resolveGatewayInstallToken({ + config: { + gateway: { auth: { mode: "token", token: tokenRef } }, + } as OpenClawConfig, + env: { OPENCLAW_GATEWAY_TOKEN: "resolved-token" } as NodeJS.ProcessEnv, + }); + + expect(result.token).toBeUndefined(); + expect(result.tokenRefConfigured).toBe(true); + expect(result.unavailableReason).toBeUndefined(); + expect(result.warnings.some((message) => message.includes("SecretRef-managed"))).toBeTruthy(); + }); + + it("returns unavailable reason when token SecretRef is unresolved in token mode", async () => { + resolveSecretInputRefMock.mockReturnValue({ + ref: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" }, + }); + resolveSecretRefValuesMock.mockRejectedValue(new Error("missing env var")); + + const result = await resolveGatewayInstallToken({ + config: { + gateway: { auth: { mode: "token", token: "${MISSING_GATEWAY_TOKEN}" } }, + } as OpenClawConfig, + env: {} as NodeJS.ProcessEnv, + }); + + expect(result.token).toBeUndefined(); + expect(result.unavailableReason).toContain("gateway.auth.token SecretRef is configured"); + }); + + it("returns unavailable reason when token and password are both configured and mode is unset", async () => { + const result = await resolveGatewayInstallToken({ + config: { + gateway: { + auth: { + token: "token-value", + password: "password-value", + }, + }, + } as OpenClawConfig, + env: {} as NodeJS.ProcessEnv, + autoGenerateWhenMissing: true, + persistGeneratedToken: true, + }); + + expect(result.token).toBeUndefined(); + expect(result.unavailableReason).toContain("gateway.auth.mode is unset"); + expect(result.unavailableReason).toContain("openclaw config set gateway.auth.mode token"); + expect(result.unavailableReason).toContain("openclaw config set gateway.auth.mode password"); + expect(writeConfigFileMock).not.toHaveBeenCalled(); + expect(resolveSecretRefValuesMock).not.toHaveBeenCalled(); + }); + + it("auto-generates token when no source exists and auto-generation is enabled", async () => { + const result = await resolveGatewayInstallToken({ + config: { + gateway: { auth: { mode: "token" } }, + } as OpenClawConfig, + env: {} as NodeJS.ProcessEnv, + autoGenerateWhenMissing: true, + }); + + expect(result.token).toBe("generated-token"); + expect(result.unavailableReason).toBeUndefined(); + expect( + result.warnings.some((message) => message.includes("without saving to config")), + ).toBeTruthy(); + expect(writeConfigFileMock).not.toHaveBeenCalled(); + }); + + it("persists auto-generated token when requested", async () => { + const result = await resolveGatewayInstallToken({ + config: { + gateway: { auth: { mode: "token" } }, + } as OpenClawConfig, + env: {} as NodeJS.ProcessEnv, + autoGenerateWhenMissing: true, + persistGeneratedToken: true, + }); + + expect(result.warnings.some((message) => message.includes("saving to config"))).toBeTruthy(); + expect(writeConfigFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + gateway: { + auth: { + mode: "token", + token: "generated-token", + }, + }, + }), + ); + }); + + it("drops generated plaintext when config changes to SecretRef before persist", async () => { + readConfigFileSnapshotMock.mockResolvedValue({ + exists: true, + valid: true, + config: { + gateway: { + auth: { + token: "${OPENCLAW_GATEWAY_TOKEN}", + }, + }, + }, + issues: [], + }); + resolveSecretInputRefMock.mockReturnValueOnce({ ref: undefined }).mockReturnValueOnce({ + ref: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" }, + }); + + const result = await resolveGatewayInstallToken({ + config: { + gateway: { auth: { mode: "token" } }, + } as OpenClawConfig, + env: {} as NodeJS.ProcessEnv, + autoGenerateWhenMissing: true, + persistGeneratedToken: true, + }); + + expect(result.token).toBeUndefined(); + expect( + result.warnings.some((message) => message.includes("skipping plaintext token persistence")), + ).toBeTruthy(); + expect(writeConfigFileMock).not.toHaveBeenCalled(); + }); + + it("does not auto-generate when inferred mode has password SecretRef configured", async () => { + shouldRequireGatewayTokenForInstallMock.mockReturnValue(false); + + const result = await resolveGatewayInstallToken({ + config: { + gateway: { + auth: { + password: { source: "env", provider: "default", id: "GATEWAY_PASSWORD" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as OpenClawConfig, + env: {} as NodeJS.ProcessEnv, + autoGenerateWhenMissing: true, + persistGeneratedToken: true, + }); + + expect(result.token).toBeUndefined(); + expect(result.unavailableReason).toBeUndefined(); + expect(result.warnings.some((message) => message.includes("Auto-generated"))).toBe(false); + expect(writeConfigFileMock).not.toHaveBeenCalled(); + }); + + it("skips token SecretRef resolution when token auth is not required", async () => { + const tokenRef = { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" }; + resolveSecretInputRefMock.mockReturnValue({ ref: tokenRef }); + shouldRequireGatewayTokenForInstallMock.mockReturnValue(false); + + const result = await resolveGatewayInstallToken({ + config: { + gateway: { + auth: { + mode: "password", + token: tokenRef, + }, + }, + } as OpenClawConfig, + env: {} as NodeJS.ProcessEnv, + }); + + expect(resolveSecretRefValuesMock).not.toHaveBeenCalled(); + expect(result.unavailableReason).toBeUndefined(); + expect(result.warnings).toEqual([]); + expect(result.token).toBeUndefined(); + expect(result.tokenRefConfigured).toBe(true); + }); +}); diff --git a/src/commands/gateway-install-token.ts b/src/commands/gateway-install-token.ts new file mode 100644 index 000000000..a7293a7bc --- /dev/null +++ b/src/commands/gateway-install-token.ts @@ -0,0 +1,147 @@ +import { formatCliCommand } from "../cli/command-format.js"; +import { readConfigFileSnapshot, writeConfigFile, type OpenClawConfig } from "../config/config.js"; +import { resolveSecretInputRef } from "../config/types.secrets.js"; +import { shouldRequireGatewayTokenForInstall } from "../gateway/auth-install-policy.js"; +import { hasAmbiguousGatewayAuthModeConfig } from "../gateway/auth-mode-policy.js"; +import { resolveGatewayAuth } from "../gateway/auth.js"; +import { secretRefKey } from "../secrets/ref-contract.js"; +import { resolveSecretRefValues } from "../secrets/resolve.js"; +import { randomToken } from "./onboard-helpers.js"; + +type GatewayInstallTokenOptions = { + config: OpenClawConfig; + env: NodeJS.ProcessEnv; + explicitToken?: string; + autoGenerateWhenMissing?: boolean; + persistGeneratedToken?: boolean; +}; + +export type GatewayInstallTokenResolution = { + token?: string; + tokenRefConfigured: boolean; + unavailableReason?: string; + warnings: string[]; +}; + +function formatAmbiguousGatewayAuthModeReason(): string { + return [ + "gateway.auth.token and gateway.auth.password are both configured while gateway.auth.mode is unset.", + `Set ${formatCliCommand("openclaw config set gateway.auth.mode token")} or ${formatCliCommand("openclaw config set gateway.auth.mode password")}.`, + ].join(" "); +} + +export async function resolveGatewayInstallToken( + options: GatewayInstallTokenOptions, +): Promise { + const cfg = options.config; + const warnings: string[] = []; + const tokenRef = resolveSecretInputRef({ + value: cfg.gateway?.auth?.token, + defaults: cfg.secrets?.defaults, + }).ref; + const tokenRefConfigured = Boolean(tokenRef); + const configToken = + tokenRef || typeof cfg.gateway?.auth?.token !== "string" + ? undefined + : cfg.gateway.auth.token.trim() || undefined; + const explicitToken = options.explicitToken?.trim() || undefined; + const envToken = + options.env.OPENCLAW_GATEWAY_TOKEN?.trim() || options.env.CLAWDBOT_GATEWAY_TOKEN?.trim(); + + if (hasAmbiguousGatewayAuthModeConfig(cfg)) { + return { + token: undefined, + tokenRefConfigured, + unavailableReason: formatAmbiguousGatewayAuthModeReason(), + warnings, + }; + } + + const resolvedAuth = resolveGatewayAuth({ + authConfig: cfg.gateway?.auth, + tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off", + }); + const needsToken = + shouldRequireGatewayTokenForInstall(cfg, options.env) && !resolvedAuth.allowTailscale; + + let token: string | undefined = explicitToken || configToken || (tokenRef ? undefined : envToken); + let unavailableReason: string | undefined; + + if (tokenRef && !token && needsToken) { + try { + const resolved = await resolveSecretRefValues([tokenRef], { + config: cfg, + env: options.env, + }); + const value = resolved.get(secretRefKey(tokenRef)); + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error("gateway.auth.token resolved to an empty or non-string value."); + } + warnings.push( + "gateway.auth.token is SecretRef-managed; install will not persist a resolved token in service environment. Ensure the SecretRef is resolvable in the daemon runtime context.", + ); + } catch (err) { + unavailableReason = `gateway.auth.token SecretRef is configured but unresolved (${String(err)}).`; + } + } + + const allowAutoGenerate = options.autoGenerateWhenMissing ?? false; + const persistGeneratedToken = options.persistGeneratedToken ?? false; + if (!token && needsToken && !tokenRef && allowAutoGenerate) { + token = randomToken(); + warnings.push( + persistGeneratedToken + ? "No gateway token found. Auto-generated one and saving to config." + : "No gateway token found. Auto-generated one for this run without saving to config.", + ); + + if (persistGeneratedToken) { + // Persist token in config so daemon and CLI share a stable credential source. + try { + const snapshot = await readConfigFileSnapshot(); + if (snapshot.exists && !snapshot.valid) { + warnings.push("Warning: config file exists but is invalid; skipping token persistence."); + } else { + const baseConfig = snapshot.exists ? snapshot.config : {}; + const existingTokenRef = resolveSecretInputRef({ + value: baseConfig.gateway?.auth?.token, + defaults: baseConfig.secrets?.defaults, + }).ref; + const baseConfigToken = + existingTokenRef || typeof baseConfig.gateway?.auth?.token !== "string" + ? undefined + : baseConfig.gateway.auth.token.trim() || undefined; + if (!existingTokenRef && !baseConfigToken) { + await writeConfigFile({ + ...baseConfig, + gateway: { + ...baseConfig.gateway, + auth: { + ...baseConfig.gateway?.auth, + mode: baseConfig.gateway?.auth?.mode ?? "token", + token, + }, + }, + }); + } else if (baseConfigToken) { + token = baseConfigToken; + } else { + token = undefined; + warnings.push( + "Warning: gateway.auth.token is SecretRef-managed; skipping plaintext token persistence.", + ); + } + } + } catch (err) { + warnings.push(`Warning: could not persist token to config: ${String(err)}`); + } + } + } + + return { + token, + tokenRefConfigured, + unavailableReason, + warnings, + }; +} diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index 559bec14e..466612686 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -184,6 +184,268 @@ describe("gateway-status command", () => { expect(targets[0]?.summary).toBeTruthy(); }); + it("surfaces unresolved SecretRef auth diagnostics in warnings", async () => { + const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); + await withEnvAsync({ MISSING_GATEWAY_TOKEN: undefined }, async () => { + loadConfig.mockReturnValueOnce({ + secrets: { + providers: { + default: { source: "env" }, + }, + }, + gateway: { + mode: "local", + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" }, + }, + }, + } as unknown as ReturnType); + + await runGatewayStatus(runtime, { timeout: "1000", json: true }); + }); + + expect(runtimeErrors).toHaveLength(0); + const parsed = JSON.parse(runtimeLogs.join("\n")) as { + warnings?: Array<{ code?: string; message?: string; targetIds?: string[] }>; + }; + const unresolvedWarning = parsed.warnings?.find( + (warning) => + warning.code === "auth_secretref_unresolved" && + warning.message?.includes("gateway.auth.token SecretRef is unresolved"), + ); + expect(unresolvedWarning).toBeTruthy(); + expect(unresolvedWarning?.targetIds).toContain("localLoopback"); + expect(unresolvedWarning?.message).toContain("env:default:MISSING_GATEWAY_TOKEN"); + expect(unresolvedWarning?.message).not.toContain("missing or empty"); + }); + + it("does not resolve local token SecretRef when OPENCLAW_GATEWAY_TOKEN is set", async () => { + const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); + await withEnvAsync( + { + OPENCLAW_GATEWAY_TOKEN: "env-token", + MISSING_GATEWAY_TOKEN: undefined, + }, + async () => { + loadConfig.mockReturnValueOnce({ + secrets: { + providers: { + default: { source: "env" }, + }, + }, + gateway: { + mode: "local", + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" }, + }, + }, + } as unknown as ReturnType); + + await runGatewayStatus(runtime, { timeout: "1000", json: true }); + }, + ); + + expect(runtimeErrors).toHaveLength(0); + expect(probeGateway).toHaveBeenCalledWith( + expect.objectContaining({ + auth: expect.objectContaining({ + token: "env-token", + }), + }), + ); + const parsed = JSON.parse(runtimeLogs.join("\n")) as { + warnings?: Array<{ code?: string; message?: string }>; + }; + const unresolvedWarning = parsed.warnings?.find( + (warning) => + warning.code === "auth_secretref_unresolved" && + warning.message?.includes("gateway.auth.token SecretRef is unresolved"), + ); + expect(unresolvedWarning).toBeUndefined(); + }); + + it("does not resolve local password SecretRef in token mode", async () => { + const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); + await withEnvAsync( + { + OPENCLAW_GATEWAY_TOKEN: "env-token", + MISSING_GATEWAY_PASSWORD: undefined, + }, + async () => { + loadConfig.mockReturnValueOnce({ + secrets: { + providers: { + default: { source: "env" }, + }, + }, + gateway: { + mode: "local", + auth: { + mode: "token", + token: "config-token", + password: { source: "env", provider: "default", id: "MISSING_GATEWAY_PASSWORD" }, + }, + }, + } as unknown as ReturnType); + + await runGatewayStatus(runtime, { timeout: "1000", json: true }); + }, + ); + + expect(runtimeErrors).toHaveLength(0); + const parsed = JSON.parse(runtimeLogs.join("\n")) as { + warnings?: Array<{ code?: string; message?: string }>; + }; + const unresolvedPasswordWarning = parsed.warnings?.find( + (warning) => + warning.code === "auth_secretref_unresolved" && + warning.message?.includes("gateway.auth.password SecretRef is unresolved"), + ); + expect(unresolvedPasswordWarning).toBeUndefined(); + }); + + it("resolves env-template gateway.auth.token before probing targets", async () => { + const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); + await withEnvAsync( + { + CUSTOM_GATEWAY_TOKEN: "resolved-gateway-token", + OPENCLAW_GATEWAY_TOKEN: undefined, + CLAWDBOT_GATEWAY_TOKEN: undefined, + }, + async () => { + loadConfig.mockReturnValueOnce({ + secrets: { + providers: { + default: { source: "env" }, + }, + }, + gateway: { + mode: "local", + auth: { + mode: "token", + token: "${CUSTOM_GATEWAY_TOKEN}", + }, + }, + } as unknown as ReturnType); + + await runGatewayStatus(runtime, { timeout: "1000", json: true }); + }, + ); + + expect(runtimeErrors).toHaveLength(0); + expect(probeGateway).toHaveBeenCalledWith( + expect.objectContaining({ + auth: expect.objectContaining({ + token: "resolved-gateway-token", + }), + }), + ); + const parsed = JSON.parse(runtimeLogs.join("\n")) as { + warnings?: Array<{ code?: string }>; + }; + const unresolvedWarning = parsed.warnings?.find( + (warning) => warning.code === "auth_secretref_unresolved", + ); + expect(unresolvedWarning).toBeUndefined(); + }); + + it("emits stable SecretRef auth configuration booleans in --json output", async () => { + const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); + const previousProbeImpl = probeGateway.getMockImplementation(); + probeGateway.mockImplementation(async (opts: { url: string }) => ({ + ok: true, + url: opts.url, + connectLatencyMs: 20, + error: null, + close: null, + health: { ok: true }, + status: { + linkChannel: { + id: "whatsapp", + label: "WhatsApp", + linked: true, + authAgeMs: 1_000, + }, + sessions: { count: 1 }, + }, + presence: [{ mode: "gateway", reason: "self", host: "remote", ip: "100.64.0.2" }], + configSnapshot: { + path: "/tmp/secretref-config.json", + exists: true, + valid: true, + config: { + secrets: { + defaults: { + env: "default", + }, + }, + gateway: { + mode: "remote", + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" }, + password: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_PASSWORD" }, + }, + remote: { + url: "wss://remote.example:18789", + token: { source: "env", provider: "default", id: "REMOTE_GATEWAY_TOKEN" }, + password: { source: "env", provider: "default", id: "REMOTE_GATEWAY_PASSWORD" }, + }, + }, + discovery: { + wideArea: { enabled: true }, + }, + }, + issues: [], + legacyIssues: [], + }, + })); + + try { + await runGatewayStatus(runtime, { timeout: "1000", json: true }); + } finally { + if (previousProbeImpl) { + probeGateway.mockImplementation(previousProbeImpl); + } else { + probeGateway.mockReset(); + } + } + + expect(runtimeErrors).toHaveLength(0); + const parsed = JSON.parse(runtimeLogs.join("\n")) as { + targets?: Array>; + }; + const configRemoteTarget = parsed.targets?.find((target) => target.kind === "configRemote"); + expect(configRemoteTarget?.config).toMatchInlineSnapshot(` + { + "discovery": { + "wideAreaEnabled": true, + }, + "exists": true, + "gateway": { + "authMode": "token", + "authPasswordConfigured": true, + "authTokenConfigured": true, + "bind": null, + "controlUiBasePath": null, + "controlUiEnabled": null, + "mode": "remote", + "port": null, + "remotePasswordConfigured": true, + "remoteTokenConfigured": true, + "remoteUrl": "wss://remote.example:18789", + "tailscaleMode": null, + }, + "issues": [], + "legacyIssues": [], + "path": "/tmp/secretref-config.json", + "valid": true, + } + `); + }); + it("supports SSH tunnel targets", async () => { const { runtime, runtimeLogs } = createRuntimeCapture(); diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index 0e5efe4a7..2b7155820 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -152,10 +152,14 @@ export async function gatewayStatusCommand( try { const probed = await Promise.all( targets.map(async (target) => { - const auth = resolveAuthForTarget(cfg, target, { + const authResolution = await resolveAuthForTarget(cfg, target, { token: typeof opts.token === "string" ? opts.token : undefined, password: typeof opts.password === "string" ? opts.password : undefined, }); + const auth = { + token: authResolution.token, + password: authResolution.password, + }; const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target.kind); const probe = await probeGateway({ url: target.url, @@ -166,7 +170,13 @@ export async function gatewayStatusCommand( ? extractConfigSummary(probe.configSnapshot) : null; const self = pickGatewaySelfPresence(probe.presence); - return { target, probe, configSummary, self }; + return { + target, + probe, + configSummary, + self, + authDiagnostics: authResolution.diagnostics ?? [], + }; }), ); @@ -214,6 +224,18 @@ export async function gatewayStatusCommand( targetIds: reachable.map((p) => p.target.id), }); } + for (const result of probed) { + if (result.authDiagnostics.length === 0) { + continue; + } + for (const diagnostic of result.authDiagnostics) { + warnings.push({ + code: "auth_secretref_unresolved", + message: diagnostic, + targetIds: [result.target.id], + }); + } + } if (opts.json) { runtime.log( diff --git a/src/commands/gateway-status/helpers.test.ts b/src/commands/gateway-status/helpers.test.ts new file mode 100644 index 000000000..ca508fb2a --- /dev/null +++ b/src/commands/gateway-status/helpers.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from "vitest"; +import { withEnvAsync } from "../../test-utils/env.js"; +import { extractConfigSummary, resolveAuthForTarget } from "./helpers.js"; + +describe("extractConfigSummary", () => { + it("marks SecretRef-backed gateway auth credentials as configured", () => { + const summary = extractConfigSummary({ + path: "/tmp/openclaw.json", + exists: true, + valid: true, + issues: [], + legacyIssues: [], + config: { + secrets: { + defaults: { + env: "default", + }, + }, + gateway: { + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" }, + password: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_PASSWORD" }, + }, + remote: { + url: "wss://remote.example:18789", + token: { source: "env", provider: "default", id: "REMOTE_GATEWAY_TOKEN" }, + password: { source: "env", provider: "default", id: "REMOTE_GATEWAY_PASSWORD" }, + }, + }, + }, + }); + + expect(summary.gateway.authTokenConfigured).toBe(true); + expect(summary.gateway.authPasswordConfigured).toBe(true); + expect(summary.gateway.remoteTokenConfigured).toBe(true); + expect(summary.gateway.remotePasswordConfigured).toBe(true); + }); + + it("still treats empty plaintext auth values as not configured", () => { + const summary = extractConfigSummary({ + path: "/tmp/openclaw.json", + exists: true, + valid: true, + issues: [], + legacyIssues: [], + config: { + gateway: { + auth: { + mode: "token", + token: " ", + password: "", + }, + remote: { + token: " ", + password: "", + }, + }, + }, + }); + + expect(summary.gateway.authTokenConfigured).toBe(false); + expect(summary.gateway.authPasswordConfigured).toBe(false); + expect(summary.gateway.remoteTokenConfigured).toBe(false); + expect(summary.gateway.remotePasswordConfigured).toBe(false); + }); +}); + +describe("resolveAuthForTarget", () => { + it("resolves local auth token SecretRef before probing local targets", async () => { + await withEnvAsync( + { + OPENCLAW_GATEWAY_TOKEN: undefined, + OPENCLAW_GATEWAY_PASSWORD: undefined, + LOCAL_GATEWAY_TOKEN: "resolved-local-token", + }, + async () => { + const auth = await resolveAuthForTarget( + { + secrets: { + providers: { + default: { source: "env" }, + }, + }, + gateway: { + auth: { + token: { source: "env", provider: "default", id: "LOCAL_GATEWAY_TOKEN" }, + }, + }, + }, + { + id: "localLoopback", + kind: "localLoopback", + url: "ws://127.0.0.1:18789", + active: true, + }, + {}, + ); + + expect(auth).toEqual({ token: "resolved-local-token", password: undefined }); + }, + ); + }); + + it("resolves remote auth token SecretRef before probing remote targets", async () => { + await withEnvAsync( + { + REMOTE_GATEWAY_TOKEN: "resolved-remote-token", + }, + async () => { + const auth = await resolveAuthForTarget( + { + secrets: { + providers: { + default: { source: "env" }, + }, + }, + gateway: { + remote: { + token: { source: "env", provider: "default", id: "REMOTE_GATEWAY_TOKEN" }, + }, + }, + }, + { + id: "configRemote", + kind: "configRemote", + url: "wss://remote.example:18789", + active: true, + }, + {}, + ); + + expect(auth).toEqual({ token: "resolved-remote-token", password: undefined }); + }, + ); + }); + + it("resolves remote auth even when local auth mode is none", async () => { + await withEnvAsync( + { + REMOTE_GATEWAY_TOKEN: "resolved-remote-token", + }, + async () => { + const auth = await resolveAuthForTarget( + { + secrets: { + providers: { + default: { source: "env" }, + }, + }, + gateway: { + auth: { + mode: "none", + }, + remote: { + token: { source: "env", provider: "default", id: "REMOTE_GATEWAY_TOKEN" }, + }, + }, + }, + { + id: "configRemote", + kind: "configRemote", + url: "wss://remote.example:18789", + active: true, + }, + {}, + ); + + expect(auth).toEqual({ token: "resolved-remote-token", password: undefined }); + }, + ); + }); + + it("does not force remote auth type from local auth mode", async () => { + const auth = await resolveAuthForTarget( + { + gateway: { + auth: { + mode: "password", + }, + remote: { + token: "remote-token", + password: "remote-password", + }, + }, + }, + { + id: "configRemote", + kind: "configRemote", + url: "wss://remote.example:18789", + active: true, + }, + {}, + ); + + expect(auth).toEqual({ token: "remote-token", password: undefined }); + }); + + it("redacts resolver internals from unresolved SecretRef diagnostics", async () => { + await withEnvAsync( + { + MISSING_GATEWAY_TOKEN: undefined, + }, + async () => { + const auth = await resolveAuthForTarget( + { + secrets: { + providers: { + default: { source: "env" }, + }, + }, + gateway: { + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" }, + }, + }, + }, + { + id: "localLoopback", + kind: "localLoopback", + url: "ws://127.0.0.1:18789", + active: true, + }, + {}, + ); + + expect(auth.diagnostics).toContain( + "gateway.auth.token SecretRef is unresolved (env:default:MISSING_GATEWAY_TOKEN).", + ); + expect(auth.diagnostics?.join("\n")).not.toContain("missing or empty"); + }, + ); + }); +}); diff --git a/src/commands/gateway-status/helpers.ts b/src/commands/gateway-status/helpers.ts index bd8c772bc..2386870be 100644 --- a/src/commands/gateway-status/helpers.ts +++ b/src/commands/gateway-status/helpers.ts @@ -1,6 +1,8 @@ import { resolveGatewayPort } from "../../config/config.js"; import type { OpenClawConfig, ConfigFileSnapshot } from "../../config/types.js"; +import { hasConfiguredSecretInput } from "../../config/types.secrets.js"; import type { GatewayProbeResult } from "../../gateway/probe.js"; +import { resolveConfiguredSecretInputString } from "../../gateway/resolve-configured-secret-input-string.js"; import { pickPrimaryTailnetIPv4 } from "../../infra/tailnet.js"; import { colorize, theme } from "../../terminal/theme.js"; import { pickGatewaySelfPresence } from "../gateway-presence.js"; @@ -144,38 +146,124 @@ export function sanitizeSshTarget(value: unknown): string | null { return trimmed.replace(/^ssh\\s+/, ""); } -export function resolveAuthForTarget( +function readGatewayTokenEnv(env: NodeJS.ProcessEnv = process.env): string | undefined { + const token = env.OPENCLAW_GATEWAY_TOKEN?.trim() || env.CLAWDBOT_GATEWAY_TOKEN?.trim(); + return token || undefined; +} + +function readGatewayPasswordEnv(env: NodeJS.ProcessEnv = process.env): string | undefined { + const password = env.OPENCLAW_GATEWAY_PASSWORD?.trim() || env.CLAWDBOT_GATEWAY_PASSWORD?.trim(); + return password || undefined; +} + +export async function resolveAuthForTarget( cfg: OpenClawConfig, target: GatewayStatusTarget, overrides: { token?: string; password?: string }, -): { token?: string; password?: string } { +): Promise<{ token?: string; password?: string; diagnostics?: string[] }> { const tokenOverride = overrides.token?.trim() ? overrides.token.trim() : undefined; const passwordOverride = overrides.password?.trim() ? overrides.password.trim() : undefined; if (tokenOverride || passwordOverride) { return { token: tokenOverride, password: passwordOverride }; } + const diagnostics: string[] = []; + const authMode = cfg.gateway?.auth?.mode; + const tokenOnly = authMode === "token"; + const passwordOnly = authMode === "password"; + + const resolveToken = async (value: unknown, path: string): Promise => { + const tokenResolution = await resolveConfiguredSecretInputString({ + config: cfg, + env: process.env, + value, + path, + unresolvedReasonStyle: "detailed", + }); + if (tokenResolution.unresolvedRefReason) { + diagnostics.push(tokenResolution.unresolvedRefReason); + } + return tokenResolution.value; + }; + const resolvePassword = async (value: unknown, path: string): Promise => { + const passwordResolution = await resolveConfiguredSecretInputString({ + config: cfg, + env: process.env, + value, + path, + unresolvedReasonStyle: "detailed", + }); + if (passwordResolution.unresolvedRefReason) { + diagnostics.push(passwordResolution.unresolvedRefReason); + } + return passwordResolution.value; + }; + if (target.kind === "configRemote" || target.kind === "sshTunnel") { - const token = - typeof cfg.gateway?.remote?.token === "string" ? cfg.gateway.remote.token.trim() : ""; - const remotePassword = (cfg.gateway?.remote as { password?: unknown } | undefined)?.password; - const password = typeof remotePassword === "string" ? remotePassword.trim() : ""; + const remoteTokenValue = cfg.gateway?.remote?.token; + const remotePasswordValue = (cfg.gateway?.remote as { password?: unknown } | undefined) + ?.password; + const token = await resolveToken(remoteTokenValue, "gateway.remote.token"); + const password = token + ? undefined + : await resolvePassword(remotePasswordValue, "gateway.remote.password"); return { - token: token.length > 0 ? token : undefined, - password: password.length > 0 ? password : undefined, + token, + password, + ...(diagnostics.length > 0 ? { diagnostics } : {}), }; } - const envToken = process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || ""; - const envPassword = process.env.OPENCLAW_GATEWAY_PASSWORD?.trim() || ""; - const cfgToken = - typeof cfg.gateway?.auth?.token === "string" ? cfg.gateway.auth.token.trim() : ""; - const cfgPassword = - typeof cfg.gateway?.auth?.password === "string" ? cfg.gateway.auth.password.trim() : ""; + const authDisabled = authMode === "none" || authMode === "trusted-proxy"; + if (authDisabled) { + return {}; + } + + const envToken = readGatewayTokenEnv(); + const envPassword = readGatewayPasswordEnv(); + if (tokenOnly) { + if (envToken) { + return { token: envToken }; + } + const token = await resolveToken(cfg.gateway?.auth?.token, "gateway.auth.token"); + return { + token, + ...(diagnostics.length > 0 ? { diagnostics } : {}), + }; + } + if (passwordOnly) { + if (envPassword) { + return { password: envPassword }; + } + const password = await resolvePassword(cfg.gateway?.auth?.password, "gateway.auth.password"); + return { + password, + ...(diagnostics.length > 0 ? { diagnostics } : {}), + }; + } + + if (envToken) { + return { token: envToken }; + } + const token = await resolveToken(cfg.gateway?.auth?.token, "gateway.auth.token"); + if (token) { + return { + token, + ...(diagnostics.length > 0 ? { diagnostics } : {}), + }; + } + if (envPassword) { + return { + password: envPassword, + ...(diagnostics.length > 0 ? { diagnostics } : {}), + }; + } + const password = await resolvePassword(cfg.gateway?.auth?.password, "gateway.auth.password"); return { - token: envToken || cfgToken || undefined, - password: envPassword || cfgPassword || undefined, + token, + password, + ...(diagnostics.length > 0 ? { diagnostics } : {}), }; } @@ -191,6 +279,10 @@ export function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSum const cfg = (snap?.config ?? {}) as Record; const gateway = (cfg.gateway ?? {}) as Record; + const secrets = (cfg.secrets ?? {}) as Record; + const secretDefaults = (secrets.defaults ?? undefined) as + | { env?: string; file?: string; exec?: string } + | undefined; const discovery = (cfg.discovery ?? {}) as Record; const wideArea = (discovery.wideArea ?? {}) as Record; @@ -200,15 +292,12 @@ export function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSum const tailscale = (gateway.tailscale ?? {}) as Record; const authMode = typeof auth.mode === "string" ? auth.mode : null; - const authTokenConfigured = typeof auth.token === "string" ? auth.token.trim().length > 0 : false; - const authPasswordConfigured = - typeof auth.password === "string" ? auth.password.trim().length > 0 : false; + const authTokenConfigured = hasConfiguredSecretInput(auth.token, secretDefaults); + const authPasswordConfigured = hasConfiguredSecretInput(auth.password, secretDefaults); const remoteUrl = typeof remote.url === "string" ? normalizeWsUrl(remote.url) : null; - const remoteTokenConfigured = - typeof remote.token === "string" ? remote.token.trim().length > 0 : false; - const remotePasswordConfigured = - typeof remote.password === "string" ? String(remote.password).trim().length > 0 : false; + const remoteTokenConfigured = hasConfiguredSecretInput(remote.token, secretDefaults); + const remotePasswordConfigured = hasConfiguredSecretInput(remote.password, secretDefaults); const wideAreaEnabled = typeof wideArea.enabled === "boolean" ? wideArea.enabled : null; diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index eaf6b2f7a..1d9e8bc58 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -9,7 +9,7 @@ const gatewayClientCalls: Array<{ url?: string; token?: string; password?: string; - onHelloOk?: () => void; + onHelloOk?: (hello: { features?: { methods?: string[] } }) => void; onClose?: (code: number, reason: string) => void; }> = []; const ensureWorkspaceAndSessionsMock = vi.fn(async (..._args: unknown[]) => {}); @@ -20,13 +20,13 @@ vi.mock("../gateway/client.js", () => ({ url?: string; token?: string; password?: string; - onHelloOk?: () => void; + onHelloOk?: (hello: { features?: { methods?: string[] } }) => void; }; constructor(params: { url?: string; token?: string; password?: string; - onHelloOk?: () => void; + onHelloOk?: (hello: { features?: { methods?: string[] } }) => void; }) { this.params = params; gatewayClientCalls.push(params); @@ -35,7 +35,7 @@ vi.mock("../gateway/client.js", () => ({ return { ok: true }; } start() { - queueMicrotask(() => this.params.onHelloOk?.()); + queueMicrotask(() => this.params.onHelloOk?.({ features: { methods: ["health"] } })); } stop() {} }, @@ -191,6 +191,84 @@ describe("onboard (non-interactive): gateway and remote auth", () => { }); }, 60_000); + it("writes gateway token SecretRef from --gateway-token-ref-env", async () => { + await withStateDir("state-env-token-ref-", async (stateDir) => { + const envToken = "tok_env_ref_123"; + const workspace = path.join(stateDir, "openclaw"); + const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; + process.env.OPENCLAW_GATEWAY_TOKEN = envToken; + + try { + await runNonInteractiveOnboarding( + { + nonInteractive: true, + mode: "local", + workspace, + authChoice: "skip", + skipSkills: true, + skipHealth: true, + installDaemon: false, + gatewayBind: "loopback", + gatewayAuth: "token", + gatewayTokenRefEnv: "OPENCLAW_GATEWAY_TOKEN", + }, + runtime, + ); + + const configPath = resolveStateConfigPath(process.env, stateDir); + const cfg = await readJsonFile<{ + gateway?: { auth?: { mode?: string; token?: unknown } }; + }>(configPath); + + expect(cfg?.gateway?.auth?.mode).toBe("token"); + expect(cfg?.gateway?.auth?.token).toEqual({ + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_TOKEN", + }); + } finally { + if (prevToken === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; + } + } + }); + }, 60_000); + + it("fails when --gateway-token-ref-env points to a missing env var", async () => { + await withStateDir("state-env-token-ref-missing-", async (stateDir) => { + const workspace = path.join(stateDir, "openclaw"); + const previous = process.env.MISSING_GATEWAY_TOKEN_ENV; + delete process.env.MISSING_GATEWAY_TOKEN_ENV; + try { + await expect( + runNonInteractiveOnboarding( + { + nonInteractive: true, + mode: "local", + workspace, + authChoice: "skip", + skipSkills: true, + skipHealth: true, + installDaemon: false, + gatewayBind: "loopback", + gatewayAuth: "token", + gatewayTokenRefEnv: "MISSING_GATEWAY_TOKEN_ENV", + }, + runtime, + ), + ).rejects.toThrow(/MISSING_GATEWAY_TOKEN_ENV/); + } finally { + if (previous === undefined) { + delete process.env.MISSING_GATEWAY_TOKEN_ENV; + } else { + process.env.MISSING_GATEWAY_TOKEN_ENV = previous; + } + } + }); + }, 60_000); + it("writes gateway.remote url/token and callGateway uses them", async () => { await withStateDir("state-remote-", async () => { const port = getPseudoPort(30_000); diff --git a/src/commands/onboard-non-interactive/local.ts b/src/commands/onboard-non-interactive/local.ts index c709bd460..4e0482ae2 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -92,7 +92,6 @@ export async function runNonInteractiveOnboardingLocal(params: { opts, runtime, port: gatewayResult.port, - gatewayToken: gatewayResult.gatewayToken, }); } diff --git a/src/commands/onboard-non-interactive/local/daemon-install.test.ts b/src/commands/onboard-non-interactive/local/daemon-install.test.ts new file mode 100644 index 000000000..b8021cf48 --- /dev/null +++ b/src/commands/onboard-non-interactive/local/daemon-install.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../../config/config.js"; + +const buildGatewayInstallPlan = vi.hoisted(() => vi.fn()); +const gatewayInstallErrorHint = vi.hoisted(() => vi.fn(() => "hint")); +const resolveGatewayInstallToken = vi.hoisted(() => vi.fn()); +const serviceInstall = vi.hoisted(() => vi.fn(async () => {})); +const ensureSystemdUserLingerNonInteractive = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock("../../daemon-install-helpers.js", () => ({ + buildGatewayInstallPlan, + gatewayInstallErrorHint, +})); + +vi.mock("../../gateway-install-token.js", () => ({ + resolveGatewayInstallToken, +})); + +vi.mock("../../../daemon/service.js", () => ({ + resolveGatewayService: vi.fn(() => ({ + install: serviceInstall, + })), +})); + +vi.mock("../../../daemon/systemd.js", () => ({ + isSystemdUserServiceAvailable: vi.fn(async () => true), +})); + +vi.mock("../../daemon-runtime.js", () => ({ + DEFAULT_GATEWAY_DAEMON_RUNTIME: "node", + isGatewayDaemonRuntime: vi.fn(() => true), +})); + +vi.mock("../../systemd-linger.js", () => ({ + ensureSystemdUserLingerNonInteractive, +})); + +const { installGatewayDaemonNonInteractive } = await import("./daemon-install.js"); + +describe("installGatewayDaemonNonInteractive", () => { + beforeEach(() => { + vi.clearAllMocks(); + resolveGatewayInstallToken.mockResolvedValue({ + token: undefined, + tokenRefConfigured: true, + warnings: [], + }); + buildGatewayInstallPlan.mockResolvedValue({ + programArguments: ["openclaw", "gateway", "run"], + workingDirectory: "/tmp", + environment: {}, + }); + }); + + it("does not pass plaintext token for SecretRef-managed install", async () => { + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + + await installGatewayDaemonNonInteractive({ + nextConfig: { + gateway: { + auth: { + mode: "token", + token: { + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_TOKEN", + }, + }, + }, + } as OpenClawConfig, + opts: { installDaemon: true }, + runtime, + port: 18789, + }); + + expect(resolveGatewayInstallToken).toHaveBeenCalledTimes(1); + expect(buildGatewayInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ + token: undefined, + }), + ); + expect(serviceInstall).toHaveBeenCalledTimes(1); + }); + + it("aborts with actionable error when SecretRef is unresolved", async () => { + resolveGatewayInstallToken.mockResolvedValue({ + token: undefined, + tokenRefConfigured: true, + unavailableReason: "gateway.auth.token SecretRef is configured but unresolved (boom).", + warnings: [], + }); + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + + await installGatewayDaemonNonInteractive({ + nextConfig: {} as OpenClawConfig, + opts: { installDaemon: true }, + runtime, + port: 18789, + }); + + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("Gateway install blocked")); + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(buildGatewayInstallPlan).not.toHaveBeenCalled(); + expect(serviceInstall).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/onboard-non-interactive/local/daemon-install.ts b/src/commands/onboard-non-interactive/local/daemon-install.ts index 3e4de7cc5..c2e488800 100644 --- a/src/commands/onboard-non-interactive/local/daemon-install.ts +++ b/src/commands/onboard-non-interactive/local/daemon-install.ts @@ -4,6 +4,7 @@ import { isSystemdUserServiceAvailable } from "../../../daemon/systemd.js"; import type { RuntimeEnv } from "../../../runtime.js"; import { buildGatewayInstallPlan, gatewayInstallErrorHint } from "../../daemon-install-helpers.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME, isGatewayDaemonRuntime } from "../../daemon-runtime.js"; +import { resolveGatewayInstallToken } from "../../gateway-install-token.js"; import type { OnboardOptions } from "../../onboard-types.js"; import { ensureSystemdUserLingerNonInteractive } from "../../systemd-linger.js"; @@ -12,9 +13,8 @@ export async function installGatewayDaemonNonInteractive(params: { opts: OnboardOptions; runtime: RuntimeEnv; port: number; - gatewayToken?: string; }) { - const { opts, runtime, port, gatewayToken } = params; + const { opts, runtime, port } = params; if (!opts.installDaemon) { return; } @@ -34,10 +34,28 @@ export async function installGatewayDaemonNonInteractive(params: { } const service = resolveGatewayService(); + const tokenResolution = await resolveGatewayInstallToken({ + config: params.nextConfig, + env: process.env, + }); + for (const warning of tokenResolution.warnings) { + runtime.log(warning); + } + if (tokenResolution.unavailableReason) { + runtime.error( + [ + "Gateway install blocked:", + tokenResolution.unavailableReason, + "Fix gateway auth config/token input and rerun onboarding.", + ].join(" "), + ); + runtime.exit(1); + return; + } const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({ env: process.env, port, - token: gatewayToken, + token: tokenResolution.token, runtime: daemonRuntimeRaw, warn: (message) => runtime.log(message), config: params.nextConfig, diff --git a/src/commands/onboard-non-interactive/local/gateway-config.ts b/src/commands/onboard-non-interactive/local/gateway-config.ts index 0195fd620..470c9d72e 100644 --- a/src/commands/onboard-non-interactive/local/gateway-config.ts +++ b/src/commands/onboard-non-interactive/local/gateway-config.ts @@ -1,5 +1,7 @@ import type { OpenClawConfig } from "../../../config/config.js"; +import { isValidEnvSecretRefId } from "../../../config/types.secrets.js"; import type { RuntimeEnv } from "../../../runtime.js"; +import { resolveDefaultSecretProviderAlias } from "../../../secrets/ref-contract.js"; import { normalizeGatewayTokenInput, randomToken } from "../../onboard-helpers.js"; import type { OnboardOptions } from "../../onboard-types.js"; @@ -49,26 +51,65 @@ export function applyNonInteractiveGatewayConfig(params: { } let nextConfig = params.nextConfig; - let gatewayToken = - normalizeGatewayTokenInput(opts.gatewayToken) || - normalizeGatewayTokenInput(process.env.OPENCLAW_GATEWAY_TOKEN) || - undefined; + const explicitGatewayToken = normalizeGatewayTokenInput(opts.gatewayToken); + const envGatewayToken = normalizeGatewayTokenInput(process.env.OPENCLAW_GATEWAY_TOKEN); + let gatewayToken = explicitGatewayToken || envGatewayToken || undefined; + const gatewayTokenRefEnv = String(opts.gatewayTokenRefEnv ?? "").trim(); if (authMode === "token") { - if (!gatewayToken) { - gatewayToken = randomToken(); - } - nextConfig = { - ...nextConfig, - gateway: { - ...nextConfig.gateway, - auth: { - ...nextConfig.gateway?.auth, - mode: "token", - token: gatewayToken, + if (gatewayTokenRefEnv) { + if (!isValidEnvSecretRefId(gatewayTokenRefEnv)) { + runtime.error( + "Invalid --gateway-token-ref-env (use env var name like OPENCLAW_GATEWAY_TOKEN).", + ); + runtime.exit(1); + return null; + } + if (explicitGatewayToken) { + runtime.error("Use either --gateway-token or --gateway-token-ref-env, not both."); + runtime.exit(1); + return null; + } + const resolvedFromEnv = process.env[gatewayTokenRefEnv]?.trim(); + if (!resolvedFromEnv) { + runtime.error(`Environment variable "${gatewayTokenRefEnv}" is missing or empty.`); + runtime.exit(1); + return null; + } + gatewayToken = resolvedFromEnv; + nextConfig = { + ...nextConfig, + gateway: { + ...nextConfig.gateway, + auth: { + ...nextConfig.gateway?.auth, + mode: "token", + token: { + source: "env", + provider: resolveDefaultSecretProviderAlias(nextConfig, "env", { + preferFirstProviderForSource: true, + }), + id: gatewayTokenRefEnv, + }, + }, }, - }, - }; + }; + } else { + if (!gatewayToken) { + gatewayToken = randomToken(); + } + nextConfig = { + ...nextConfig, + gateway: { + ...nextConfig.gateway, + auth: { + ...nextConfig.gateway?.auth, + mode: "token", + token: gatewayToken, + }, + }, + }; + } } if (authMode === "password") { diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index fee12d392..fcb823f96 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -144,6 +144,7 @@ export type OnboardOptions = { gatewayBind?: GatewayBind; gatewayAuth?: GatewayAuthChoice; gatewayToken?: string; + gatewayTokenRefEnv?: string; gatewayPassword?: string; tailscale?: TailscaleMode; tailscaleResetOnExit?: boolean; diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index 5fe975abf..53e0c3af5 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -10,7 +10,7 @@ import type { GatewayService } from "../daemon/service.js"; import { resolveGatewayService } from "../daemon/service.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js"; -import { resolveGatewayProbeAuth } from "../gateway/probe-auth.js"; +import { resolveGatewayProbeAuthSafe } from "../gateway/probe-auth.js"; import { probeGateway } from "../gateway/probe.js"; import { collectChannelStatusIssues } from "../infra/channels-status-issues.js"; import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js"; @@ -116,9 +116,11 @@ export async function statusAllCommand( const remoteUrlMissing = isRemoteMode && !remoteUrlRaw; const gatewayMode = isRemoteMode ? "remote" : "local"; - const localFallbackAuth = resolveGatewayProbeAuth({ cfg, mode: "local" }); - const remoteAuth = resolveGatewayProbeAuth({ cfg, mode: "remote" }); - const probeAuth = isRemoteMode && !remoteUrlMissing ? remoteAuth : localFallbackAuth; + const localProbeAuthResolution = resolveGatewayProbeAuthSafe({ cfg, mode: "local" }); + const remoteProbeAuthResolution = resolveGatewayProbeAuthSafe({ cfg, mode: "remote" }); + const probeAuthResolution = + isRemoteMode && !remoteUrlMissing ? remoteProbeAuthResolution : localProbeAuthResolution; + const probeAuth = probeAuthResolution.auth; const gatewayProbe = await probeGateway({ url: connection.url, @@ -179,8 +181,8 @@ export async function statusAllCommand( const callOverrides = remoteUrlMissing ? { url: connection.url, - token: localFallbackAuth.token, - password: localFallbackAuth.password, + token: localProbeAuthResolution.auth.token, + password: localProbeAuthResolution.auth.password, } : {}; @@ -292,6 +294,9 @@ export async function statusAllCommand( Item: "Gateway", Value: `${gatewayMode}${remoteUrlMissing ? " (remote.url missing)" : ""} · ${gatewayTarget} (${connection.urlSource}) · ${gatewayStatus}${gatewayAuth}`, }, + ...(probeAuthResolution.warning + ? [{ Item: "Gateway auth warning", Value: probeAuthResolution.warning }] + : []), { Item: "Security", Value: `Run: ${formatCliCommand("openclaw security audit --deep")}` }, gatewaySelfLine ? { Item: "Gateway self", Value: gatewaySelfLine } diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 4fbb54f98..eee7949b7 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -30,7 +30,6 @@ import { formatTokensCompact, shortenText, } from "./status.format.js"; -import { resolveGatewayProbeAuth } from "./status.gateway-probe.js"; import { scanStatus } from "./status.scan.js"; import { formatUpdateAvailableHint, @@ -118,6 +117,8 @@ export async function statusCommand( gatewayConnection, remoteUrlMissing, gatewayMode, + gatewayProbeAuth, + gatewayProbeAuthWarning, gatewayProbe, gatewayReachable, gatewaySelf, @@ -195,6 +196,7 @@ export async function statusCommand( connectLatencyMs: gatewayProbe?.connectLatencyMs ?? null, self: gatewaySelf, error: gatewayProbe?.error ?? null, + authWarning: gatewayProbeAuthWarning ?? null, }, gatewayService: daemon, nodeService: nodeDaemon, @@ -250,7 +252,7 @@ export async function statusCommand( : warn(gatewayProbe?.error ? `unreachable (${gatewayProbe.error})` : "unreachable"); const auth = gatewayReachable && !remoteUrlMissing - ? ` · auth ${formatGatewayAuthUsed(resolveGatewayProbeAuth(cfg))}` + ? ` · auth ${formatGatewayAuthUsed(gatewayProbeAuth)}` : ""; const self = gatewaySelf?.host || gatewaySelf?.version || gatewaySelf?.platform @@ -411,6 +413,9 @@ export async function statusCommand( Value: updateAvailability.available ? warn(`available · ${updateLine}`) : updateLine, }, { Item: "Gateway", Value: gatewayValue }, + ...(gatewayProbeAuthWarning + ? [{ Item: "Gateway auth warning", Value: warn(gatewayProbeAuthWarning) }] + : []), { Item: "Gateway service", Value: daemonValue }, { Item: "Node service", Value: nodeDaemonValue }, { Item: "Agents", Value: agentsValue }, diff --git a/src/commands/status.gateway-probe.ts b/src/commands/status.gateway-probe.ts index f7b7425f4..552119c37 100644 --- a/src/commands/status.gateway-probe.ts +++ b/src/commands/status.gateway-probe.ts @@ -1,14 +1,24 @@ import type { loadConfig } from "../config/config.js"; -import { resolveGatewayProbeAuth as resolveGatewayProbeAuthByMode } from "../gateway/probe-auth.js"; +import { resolveGatewayProbeAuthSafe } from "../gateway/probe-auth.js"; export { pickGatewaySelfPresence } from "./gateway-presence.js"; -export function resolveGatewayProbeAuth(cfg: ReturnType): { - token?: string; - password?: string; +export function resolveGatewayProbeAuthResolution(cfg: ReturnType): { + auth: { + token?: string; + password?: string; + }; + warning?: string; } { - return resolveGatewayProbeAuthByMode({ + return resolveGatewayProbeAuthSafe({ cfg, mode: cfg.gateway?.mode === "remote" ? "remote" : "local", env: process.env, }); } + +export function resolveGatewayProbeAuth(cfg: ReturnType): { + token?: string; + password?: string; +} { + return resolveGatewayProbeAuthResolution(cfg).auth; +} diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 568a920db..4fb161b74 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -14,7 +14,10 @@ import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { buildChannelsTable } from "./status-all/channels.js"; import { getAgentLocalStatuses } from "./status.agent-local.js"; -import { pickGatewaySelfPresence, resolveGatewayProbeAuth } from "./status.gateway-probe.js"; +import { + pickGatewaySelfPresence, + resolveGatewayProbeAuthResolution, +} from "./status.gateway-probe.js"; import { getStatusSummary } from "./status.summary.js"; import { getUpdateCheckResult } from "./status.update.js"; @@ -34,6 +37,11 @@ type GatewayProbeSnapshot = { gatewayConnection: ReturnType; remoteUrlMissing: boolean; gatewayMode: "local" | "remote"; + gatewayProbeAuth: { + token?: string; + password?: string; + }; + gatewayProbeAuthWarning?: string; gatewayProbe: Awaited> | null; }; @@ -73,14 +81,29 @@ async function resolveGatewayProbeSnapshot(params: { typeof params.cfg.gateway?.remote?.url === "string" ? params.cfg.gateway.remote.url : ""; const remoteUrlMissing = isRemoteMode && !remoteUrlRaw.trim(); const gatewayMode = isRemoteMode ? "remote" : "local"; + const gatewayProbeAuthResolution = resolveGatewayProbeAuthResolution(params.cfg); + let gatewayProbeAuthWarning = gatewayProbeAuthResolution.warning; const gatewayProbe = remoteUrlMissing ? null : await probeGateway({ url: gatewayConnection.url, - auth: resolveGatewayProbeAuth(params.cfg), + auth: gatewayProbeAuthResolution.auth, timeoutMs: Math.min(params.opts.all ? 5000 : 2500, params.opts.timeoutMs ?? 10_000), }).catch(() => null); - return { gatewayConnection, remoteUrlMissing, gatewayMode, gatewayProbe }; + if (gatewayProbeAuthWarning && gatewayProbe?.ok === false) { + gatewayProbe.error = gatewayProbe.error + ? `${gatewayProbe.error}; ${gatewayProbeAuthWarning}` + : gatewayProbeAuthWarning; + gatewayProbeAuthWarning = undefined; + } + return { + gatewayConnection, + remoteUrlMissing, + gatewayMode, + gatewayProbeAuth: gatewayProbeAuthResolution.auth, + gatewayProbeAuthWarning, + gatewayProbe, + }; } async function resolveChannelsStatus(params: { @@ -110,6 +133,11 @@ export type StatusScanResult = { gatewayConnection: ReturnType; remoteUrlMissing: boolean; gatewayMode: "local" | "remote"; + gatewayProbeAuth: { + token?: string; + password?: string; + }; + gatewayProbeAuthWarning?: string; gatewayProbe: Awaited> | null; gatewayReachable: boolean; gatewaySelf: ReturnType; @@ -188,7 +216,14 @@ async function scanStatusJsonFast(opts: { ? `https://${tailscaleDns}${normalizeControlUiBasePath(cfg.gateway?.controlUi?.basePath)}` : null; - const { gatewayConnection, remoteUrlMissing, gatewayMode, gatewayProbe } = gatewaySnapshot; + const { + gatewayConnection, + remoteUrlMissing, + gatewayMode, + gatewayProbeAuth, + gatewayProbeAuthWarning, + gatewayProbe, + } = gatewaySnapshot; const gatewayReachable = gatewayProbe?.ok === true; const gatewaySelf = gatewayProbe?.presence ? pickGatewaySelfPresence(gatewayProbe.presence) @@ -209,6 +244,8 @@ async function scanStatusJsonFast(opts: { gatewayConnection, remoteUrlMissing, gatewayMode, + gatewayProbeAuth, + gatewayProbeAuthWarning, gatewayProbe, gatewayReachable, gatewaySelf, @@ -283,8 +320,14 @@ export async function scanStatus( progress.tick(); progress.setLabel("Probing gateway…"); - const { gatewayConnection, remoteUrlMissing, gatewayMode, gatewayProbe } = - await resolveGatewayProbeSnapshot({ cfg, opts }); + const { + gatewayConnection, + remoteUrlMissing, + gatewayMode, + gatewayProbeAuth, + gatewayProbeAuthWarning, + gatewayProbe, + } = await resolveGatewayProbeSnapshot({ cfg, opts }); const gatewayReachable = gatewayProbe?.ok === true; const gatewaySelf = gatewayProbe?.presence ? pickGatewaySelfPresence(gatewayProbe.presence) @@ -326,6 +369,8 @@ export async function scanStatus( gatewayConnection, remoteUrlMissing, gatewayMode, + gatewayProbeAuth, + gatewayProbeAuthWarning, gatewayProbe, gatewayReachable, gatewaySelf, diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 5ecb6d1ef..66f3f7bf0 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -1,5 +1,5 @@ import type { Mock } from "vitest"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { captureEnv } from "../test-utils/env.js"; let envSnapshot: ReturnType; @@ -146,6 +146,7 @@ async function withEnvVar(key: string, value: string, run: () => Promise): } const mocks = vi.hoisted(() => ({ + loadConfig: vi.fn().mockReturnValue({ session: {} }), loadSessionStore: vi.fn().mockReturnValue({ "+1000": createDefaultSessionStoreEntry(), }), @@ -345,7 +346,7 @@ vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - loadConfig: () => ({ session: {} }), + loadConfig: mocks.loadConfig, }; }); vi.mock("../daemon/service.js", () => ({ @@ -389,6 +390,11 @@ const runtime = { const runtimeLogMock = runtime.log as Mock<(...args: unknown[]) => void>; describe("statusCommand", () => { + afterEach(() => { + mocks.loadConfig.mockReset(); + mocks.loadConfig.mockReturnValue({ session: {} }); + }); + it("prints JSON when requested", async () => { await statusCommand({ json: true }, runtime as never); const payload = JSON.parse(String(runtimeLogMock.mock.calls[0]?.[0])); @@ -481,6 +487,28 @@ describe("statusCommand", () => { }); }); + it("warns instead of crashing when gateway auth SecretRef is unresolved for probe auth", async () => { + mocks.loadConfig.mockReturnValue({ + session: {}, + gateway: { + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }); + + await statusCommand({ json: true }, runtime as never); + const payload = JSON.parse(String(runtimeLogMock.mock.calls.at(-1)?.[0])); + expect(payload.gateway.error).toContain("gateway.auth.token"); + expect(payload.gateway.error).toContain("SecretRef"); + }); + it("surfaces channel runtime errors from the gateway", async () => { mockProbeGatewayResult({ ok: true, diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 71d964f6c..421a1f187 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -136,8 +136,8 @@ export type GatewayTrustedProxyConfig = { export type GatewayAuthConfig = { /** Authentication mode for Gateway connections. Defaults to token when unset. */ mode?: GatewayAuthMode; - /** Shared token for token mode (stored locally for CLI auth). */ - token?: string; + /** Shared token for token mode (plaintext or SecretRef). */ + token?: SecretInput; /** Shared password for password mode (consider env instead). */ password?: SecretInput; /** Allow Tailscale identity headers when serve mode is enabled. */ diff --git a/src/config/types.secrets.ts b/src/config/types.secrets.ts index fb042bf3b..40a6963f2 100644 --- a/src/config/types.secrets.ts +++ b/src/config/types.secrets.ts @@ -15,6 +15,7 @@ export type SecretRef = { export type SecretInput = string | SecretRef; export const DEFAULT_SECRET_PROVIDER_ALIAS = "default"; +export const ENV_SECRET_REF_ID_RE = /^[A-Z][A-Z0-9_]{0,127}$/; const ENV_SECRET_TEMPLATE_RE = /^\$\{([A-Z][A-Z0-9_]{0,127})\}$/; type SecretDefaults = { env?: string; @@ -22,6 +23,10 @@ type SecretDefaults = { exec?: string; }; +export function isValidEnvSecretRefId(value: string): boolean { + return ENV_SECRET_REF_ID_RE.test(value); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 600603cab..14d416344 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -620,7 +620,7 @@ export const OpenClawSchema = z z.literal("trusted-proxy"), ]) .optional(), - token: z.string().optional().register(sensitive), + token: SecretInputSchema.optional().register(sensitive), password: SecretInputSchema.optional().register(sensitive), allowTailscale: z.boolean().optional(), rateLimit: z diff --git a/src/gateway/auth-install-policy.ts b/src/gateway/auth-install-policy.ts new file mode 100644 index 000000000..9e3360f43 --- /dev/null +++ b/src/gateway/auth-install-policy.ts @@ -0,0 +1,37 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { collectConfigServiceEnvVars } from "../config/env-vars.js"; +import { hasConfiguredSecretInput } from "../config/types.secrets.js"; + +export function shouldRequireGatewayTokenForInstall( + cfg: OpenClawConfig, + _env: NodeJS.ProcessEnv, +): boolean { + const mode = cfg.gateway?.auth?.mode; + if (mode === "token") { + return true; + } + if (mode === "password" || mode === "none" || mode === "trusted-proxy") { + return false; + } + + const hasConfiguredPassword = hasConfiguredSecretInput( + cfg.gateway?.auth?.password, + cfg.secrets?.defaults, + ); + if (hasConfiguredPassword) { + return false; + } + + // Service install should only infer password mode from durable sources that + // survive outside the invoking shell. + const configServiceEnv = collectConfigServiceEnvVars(cfg); + const hasConfiguredPasswordEnvCandidate = Boolean( + configServiceEnv.OPENCLAW_GATEWAY_PASSWORD?.trim() || + configServiceEnv.CLAWDBOT_GATEWAY_PASSWORD?.trim(), + ); + if (hasConfiguredPasswordEnvCandidate) { + return false; + } + + return true; +} diff --git a/src/gateway/auth-mode-policy.test.ts b/src/gateway/auth-mode-policy.test.ts new file mode 100644 index 000000000..50b62f6bc --- /dev/null +++ b/src/gateway/auth-mode-policy.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + assertExplicitGatewayAuthModeWhenBothConfigured, + EXPLICIT_GATEWAY_AUTH_MODE_REQUIRED_ERROR, + hasAmbiguousGatewayAuthModeConfig, +} from "./auth-mode-policy.js"; + +describe("gateway auth mode policy", () => { + it("does not flag config when auth mode is explicit", () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "token", + token: "token-value", + password: "password-value", + }, + }, + }; + expect(hasAmbiguousGatewayAuthModeConfig(cfg)).toBe(false); + }); + + it("does not flag config when only one auth credential is configured", () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + token: "token-value", + }, + }, + }; + expect(hasAmbiguousGatewayAuthModeConfig(cfg)).toBe(false); + }); + + it("flags config when both token and password are configured and mode is unset", () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + token: "token-value", + password: "password-value", + }, + }, + }; + expect(hasAmbiguousGatewayAuthModeConfig(cfg)).toBe(true); + }); + + it("flags config when both token/password SecretRefs are configured and mode is unset", () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + token: { source: "env", provider: "default", id: "GW_TOKEN" }, + password: { source: "env", provider: "default", id: "GW_PASSWORD" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + expect(hasAmbiguousGatewayAuthModeConfig(cfg)).toBe(true); + }); + + it("throws the shared explicit-mode error for ambiguous dual auth config", () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + token: "token-value", + password: "password-value", + }, + }, + }; + expect(() => assertExplicitGatewayAuthModeWhenBothConfigured(cfg)).toThrow( + EXPLICIT_GATEWAY_AUTH_MODE_REQUIRED_ERROR, + ); + }); +}); diff --git a/src/gateway/auth-mode-policy.ts b/src/gateway/auth-mode-policy.ts new file mode 100644 index 000000000..57abef40c --- /dev/null +++ b/src/gateway/auth-mode-policy.ts @@ -0,0 +1,26 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { hasConfiguredSecretInput } from "../config/types.secrets.js"; + +export const EXPLICIT_GATEWAY_AUTH_MODE_REQUIRED_ERROR = + "Invalid config: gateway.auth.token and gateway.auth.password are both configured, but gateway.auth.mode is unset. Set gateway.auth.mode to token or password."; + +export function hasAmbiguousGatewayAuthModeConfig(cfg: OpenClawConfig): boolean { + const auth = cfg.gateway?.auth; + if (!auth) { + return false; + } + if (typeof auth.mode === "string" && auth.mode.trim().length > 0) { + return false; + } + const defaults = cfg.secrets?.defaults; + const tokenConfigured = hasConfiguredSecretInput(auth.token, defaults); + const passwordConfigured = hasConfiguredSecretInput(auth.password, defaults); + return tokenConfigured && passwordConfigured; +} + +export function assertExplicitGatewayAuthModeWhenBothConfigured(cfg: OpenClawConfig): void { + if (!hasAmbiguousGatewayAuthModeConfig(cfg)) { + return; + } + throw new Error(EXPLICIT_GATEWAY_AUTH_MODE_REQUIRED_ERROR); +} diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 07d90d2d1..81b0dbcae 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -138,6 +138,25 @@ describe("gateway auth", () => { }); }); + it("treats env-template auth secrets as SecretRefs instead of plaintext", () => { + expect( + resolveGatewayAuth({ + authConfig: { + token: "${OPENCLAW_GATEWAY_TOKEN}", + password: "${OPENCLAW_GATEWAY_PASSWORD}", + }, + env: { + OPENCLAW_GATEWAY_TOKEN: "env-token", + OPENCLAW_GATEWAY_PASSWORD: "env-password", + } as NodeJS.ProcessEnv, + }), + ).toMatchObject({ + token: "env-token", + password: "env-password", + mode: "password", + }); + }); + it("resolves explicit auth mode none from config", () => { expect( resolveGatewayAuth({ diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 6315a899e..b55482b30 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -4,6 +4,7 @@ import type { GatewayTailscaleMode, GatewayTrustedProxyConfig, } from "../config/config.js"; +import { resolveSecretInputRef } from "../config/types.secrets.js"; import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js"; import { safeEqualSecret } from "../security/secret-equal.js"; import { @@ -243,9 +244,11 @@ export function resolveGatewayAuth(params: { } } const env = params.env ?? process.env; + const tokenRef = resolveSecretInputRef({ value: authConfig.token }).ref; + const passwordRef = resolveSecretInputRef({ value: authConfig.password }).ref; const resolvedCredentials = resolveGatewayCredentialsFromValues({ - configToken: authConfig.token, - configPassword: authConfig.password, + configToken: tokenRef ? undefined : authConfig.token, + configPassword: passwordRef ? undefined : authConfig.password, env, includeLegacyEnv: false, tokenPrecedence: "config-first", diff --git a/src/gateway/credentials.test.ts b/src/gateway/credentials.test.ts index a89e9af07..67e2b4dac 100644 --- a/src/gateway/credentials.test.ts +++ b/src/gateway/credentials.test.ts @@ -140,6 +140,47 @@ describe("resolveGatewayCredentialsFromConfig", () => { ).toThrow("gateway.auth.password"); }); + it("treats env-template local tokens as SecretRefs instead of plaintext", () => { + const resolved = resolveGatewayCredentialsFromConfig({ + cfg: cfg({ + gateway: { + mode: "local", + auth: { + mode: "token", + token: "${OPENCLAW_GATEWAY_TOKEN}", + }, + }, + }), + env: { + OPENCLAW_GATEWAY_TOKEN: "env-token", + } as NodeJS.ProcessEnv, + includeLegacyEnv: false, + }); + + expect(resolved).toEqual({ + token: "env-token", + password: undefined, + }); + }); + + it("throws when env-template local token SecretRef is unresolved in token mode", () => { + expect(() => + resolveGatewayCredentialsFromConfig({ + cfg: cfg({ + gateway: { + mode: "local", + auth: { + mode: "token", + token: "${OPENCLAW_GATEWAY_TOKEN}", + }, + }, + }), + env: {} as NodeJS.ProcessEnv, + includeLegacyEnv: false, + }), + ).toThrow("gateway.auth.token"); + }); + it("ignores unresolved local password ref when local auth mode is none", () => { const resolved = resolveGatewayCredentialsFromConfig({ cfg: { @@ -305,6 +346,64 @@ describe("resolveGatewayCredentialsFromConfig", () => { ).toThrow("gateway.remote.token"); }); + it("ignores unresolved local token ref in remote-only mode when local auth mode is token", () => { + const resolved = resolveGatewayCredentialsFromConfig({ + cfg: { + gateway: { + mode: "remote", + remote: { + url: "wss://gateway.example", + }, + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as unknown as OpenClawConfig, + env: {} as NodeJS.ProcessEnv, + includeLegacyEnv: false, + remoteTokenFallback: "remote-only", + remotePasswordFallback: "remote-only", + }); + expect(resolved).toEqual({ + token: undefined, + password: undefined, + }); + }); + + it("throws for unresolved local token ref in remote mode when local fallback is enabled", () => { + expect(() => + resolveGatewayCredentialsFromConfig({ + cfg: { + gateway: { + mode: "remote", + remote: { + url: "wss://gateway.example", + }, + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as unknown as OpenClawConfig, + env: {} as NodeJS.ProcessEnv, + includeLegacyEnv: false, + remoteTokenFallback: "remote-env-local", + remotePasswordFallback: "remote-only", + }), + ).toThrow("gateway.auth.token"); + }); + it("does not throw for unresolved remote token ref when password is available", () => { const resolved = resolveGatewayCredentialsFromConfig({ cfg: { diff --git a/src/gateway/credentials.ts b/src/gateway/credentials.ts index 69cad97ee..c1172a090 100644 --- a/src/gateway/credentials.ts +++ b/src/gateway/credentials.ts @@ -16,6 +16,38 @@ export type GatewayCredentialPrecedence = "env-first" | "config-first"; export type GatewayRemoteCredentialPrecedence = "remote-first" | "env-first"; export type GatewayRemoteCredentialFallback = "remote-env-local" | "remote-only"; +const GATEWAY_SECRET_REF_UNAVAILABLE_ERROR_CODE = "GATEWAY_SECRET_REF_UNAVAILABLE"; + +export class GatewaySecretRefUnavailableError extends Error { + readonly code = GATEWAY_SECRET_REF_UNAVAILABLE_ERROR_CODE; + readonly path: string; + + constructor(path: string) { + super( + [ + `${path} is configured as a secret reference but is unavailable in this command path.`, + "Fix: set OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD, pass explicit --token/--password,", + "or run a gateway command path that resolves secret references before credential selection.", + ].join("\n"), + ); + this.name = "GatewaySecretRefUnavailableError"; + this.path = path; + } +} + +export function isGatewaySecretRefUnavailableError( + error: unknown, + expectedPath?: string, +): error is GatewaySecretRefUnavailableError { + if (!(error instanceof GatewaySecretRefUnavailableError)) { + return false; + } + if (!expectedPath) { + return true; + } + return error.path === expectedPath; +} + export function trimToUndefined(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; @@ -34,13 +66,7 @@ function firstDefined(values: Array): string | undefined { } function throwUnresolvedGatewaySecretInput(path: string): never { - throw new Error( - [ - `${path} is configured as a secret reference but is unavailable in this command path.`, - "Fix: set OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD, pass explicit --token/--password,", - "or run a gateway command path that resolves secret references before credential selection.", - ].join("\n"), - ); + throw new GatewaySecretRefUnavailableError(path); } function readGatewayTokenEnv( @@ -144,10 +170,28 @@ export function resolveGatewayCredentialsFromConfig(params: { const envToken = readGatewayTokenEnv(env, includeLegacyEnv); const envPassword = readGatewayPasswordEnv(env, includeLegacyEnv); - const remoteToken = trimToUndefined(remote?.token); - const remotePassword = trimToUndefined(remote?.password); - const localToken = trimToUndefined(params.cfg.gateway?.auth?.token); - const localPassword = trimToUndefined(params.cfg.gateway?.auth?.password); + const localTokenRef = resolveSecretInputRef({ + value: params.cfg.gateway?.auth?.token, + defaults, + }).ref; + const localPasswordRef = resolveSecretInputRef({ + value: params.cfg.gateway?.auth?.password, + defaults, + }).ref; + const remoteTokenRef = resolveSecretInputRef({ + value: remote?.token, + defaults, + }).ref; + const remotePasswordRef = resolveSecretInputRef({ + value: remote?.password, + defaults, + }).ref; + const remoteToken = remoteTokenRef ? undefined : trimToUndefined(remote?.token); + const remotePassword = remotePasswordRef ? undefined : trimToUndefined(remote?.password); + const localToken = localTokenRef ? undefined : trimToUndefined(params.cfg.gateway?.auth?.token); + const localPassword = localPasswordRef + ? undefined + : trimToUndefined(params.cfg.gateway?.auth?.password); const localTokenPrecedence = params.localTokenPrecedence ?? "env-first"; const localPasswordPrecedence = params.localPasswordPrecedence ?? "env-first"; @@ -172,10 +216,15 @@ export function resolveGatewayCredentialsFromConfig(params: { authMode !== "none" && authMode !== "trusted-proxy" && !localResolved.token); - const localPasswordRef = resolveSecretInputRef({ - value: params.cfg.gateway?.auth?.password, - defaults, - }).ref; + const localTokenCanWin = + authMode === "token" || + (authMode !== "password" && + authMode !== "none" && + authMode !== "trusted-proxy" && + !localResolved.password); + if (localTokenRef && !localResolved.token && !envToken && localTokenCanWin) { + throwUnresolvedGatewaySecretInput("gateway.auth.token"); + } if (localPasswordRef && !localResolved.password && !envPassword && localPasswordCanWin) { throwUnresolvedGatewaySecretInput("gateway.auth.password"); } @@ -200,14 +249,10 @@ export function resolveGatewayCredentialsFromConfig(params: { ? firstDefined([envPassword, remotePassword, localPassword]) : firstDefined([remotePassword, envPassword, localPassword]); - const remoteTokenRef = resolveSecretInputRef({ - value: remote?.token, - defaults, - }).ref; - const remotePasswordRef = resolveSecretInputRef({ - value: remote?.password, - defaults, - }).ref; + const localTokenCanWin = + authMode === "token" || + (authMode !== "password" && authMode !== "none" && authMode !== "trusted-proxy"); + const localTokenFallbackEnabled = remoteTokenFallback !== "remote-only"; const localTokenFallback = remoteTokenFallback === "remote-only" ? undefined : localToken; const localPasswordFallback = remotePasswordFallback === "remote-only" ? undefined : localPassword; @@ -217,6 +262,17 @@ export function resolveGatewayCredentialsFromConfig(params: { if (remotePasswordRef && !password && !envPassword && !localPasswordFallback && !token) { throwUnresolvedGatewaySecretInput("gateway.remote.password"); } + if ( + localTokenRef && + localTokenFallbackEnabled && + !token && + !password && + !envToken && + !remoteToken && + localTokenCanWin + ) { + throwUnresolvedGatewaySecretInput("gateway.auth.token"); + } return { token, password }; } diff --git a/src/gateway/probe-auth.test.ts b/src/gateway/probe-auth.test.ts new file mode 100644 index 000000000..3ff1fb991 --- /dev/null +++ b/src/gateway/probe-auth.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveGatewayProbeAuthSafe } from "./probe-auth.js"; + +describe("resolveGatewayProbeAuthSafe", () => { + it("returns probe auth credentials when available", () => { + const result = resolveGatewayProbeAuthSafe({ + cfg: { + gateway: { + auth: { + token: "token-value", + }, + }, + } as OpenClawConfig, + mode: "local", + env: {} as NodeJS.ProcessEnv, + }); + + expect(result).toEqual({ + auth: { + token: "token-value", + password: undefined, + }, + }); + }); + + it("returns warning and empty auth when token SecretRef is unresolved", () => { + const result = resolveGatewayProbeAuthSafe({ + cfg: { + gateway: { + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as OpenClawConfig, + mode: "local", + env: {} as NodeJS.ProcessEnv, + }); + + expect(result.auth).toEqual({}); + expect(result.warning).toContain("gateway.auth.token"); + expect(result.warning).toContain("unresolved"); + }); + + it("ignores unresolved local token SecretRef in remote mode when remote-only auth is requested", () => { + const result = resolveGatewayProbeAuthSafe({ + cfg: { + gateway: { + mode: "remote", + remote: { + url: "wss://gateway.example", + }, + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as OpenClawConfig, + mode: "remote", + env: {} as NodeJS.ProcessEnv, + }); + + expect(result).toEqual({ + auth: { + token: undefined, + password: undefined, + }, + }); + }); +}); diff --git a/src/gateway/probe-auth.ts b/src/gateway/probe-auth.ts index d73f63ed8..a6f6e6f8e 100644 --- a/src/gateway/probe-auth.ts +++ b/src/gateway/probe-auth.ts @@ -1,5 +1,8 @@ import type { OpenClawConfig } from "../config/config.js"; -import { resolveGatewayCredentialsFromConfig } from "./credentials.js"; +import { + isGatewaySecretRefUnavailableError, + resolveGatewayCredentialsFromConfig, +} from "./credentials.js"; export function resolveGatewayProbeAuth(params: { cfg: OpenClawConfig; @@ -14,3 +17,24 @@ export function resolveGatewayProbeAuth(params: { remoteTokenFallback: "remote-only", }); } + +export function resolveGatewayProbeAuthSafe(params: { + cfg: OpenClawConfig; + mode: "local" | "remote"; + env?: NodeJS.ProcessEnv; +}): { + auth: { token?: string; password?: string }; + warning?: string; +} { + try { + return { auth: resolveGatewayProbeAuth(params) }; + } catch (error) { + if (!isGatewaySecretRefUnavailableError(error)) { + throw error; + } + return { + auth: {}, + warning: `${error.path} SecretRef is unresolved in this command path; probing without configured auth credentials.`, + }; + } +} diff --git a/src/gateway/resolve-configured-secret-input-string.ts b/src/gateway/resolve-configured-secret-input-string.ts new file mode 100644 index 000000000..c83354aa9 --- /dev/null +++ b/src/gateway/resolve-configured-secret-input-string.ts @@ -0,0 +1,89 @@ +import type { OpenClawConfig } from "../config/types.js"; +import { resolveSecretInputRef } from "../config/types.secrets.js"; +import { secretRefKey } from "../secrets/ref-contract.js"; +import { resolveSecretRefValues } from "../secrets/resolve.js"; + +export type SecretInputUnresolvedReasonStyle = "generic" | "detailed"; + +function trimToUndefined(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function buildUnresolvedReason(params: { + path: string; + style: SecretInputUnresolvedReasonStyle; + kind: "unresolved" | "non-string" | "empty"; + refLabel: string; +}): string { + if (params.style === "generic") { + return `${params.path} SecretRef is unresolved (${params.refLabel}).`; + } + if (params.kind === "non-string") { + return `${params.path} SecretRef resolved to a non-string value.`; + } + if (params.kind === "empty") { + return `${params.path} SecretRef resolved to an empty value.`; + } + return `${params.path} SecretRef is unresolved (${params.refLabel}).`; +} + +export async function resolveConfiguredSecretInputString(params: { + config: OpenClawConfig; + env: NodeJS.ProcessEnv; + value: unknown; + path: string; + unresolvedReasonStyle?: SecretInputUnresolvedReasonStyle; +}): Promise<{ value?: string; unresolvedRefReason?: string }> { + const style = params.unresolvedReasonStyle ?? "generic"; + const { ref } = resolveSecretInputRef({ + value: params.value, + defaults: params.config.secrets?.defaults, + }); + if (!ref) { + return { value: trimToUndefined(params.value) }; + } + + const refLabel = `${ref.source}:${ref.provider}:${ref.id}`; + try { + const resolved = await resolveSecretRefValues([ref], { + config: params.config, + env: params.env, + }); + const resolvedValue = resolved.get(secretRefKey(ref)); + if (typeof resolvedValue !== "string") { + return { + unresolvedRefReason: buildUnresolvedReason({ + path: params.path, + style, + kind: "non-string", + refLabel, + }), + }; + } + const trimmed = resolvedValue.trim(); + if (trimmed.length === 0) { + return { + unresolvedRefReason: buildUnresolvedReason({ + path: params.path, + style, + kind: "empty", + refLabel, + }), + }; + } + return { value: trimmed }; + } catch { + return { + unresolvedRefReason: buildUnresolvedReason({ + path: params.path, + style, + kind: "unresolved", + refLabel, + }), + }; + } +} diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index bd4ae5078..1e08eb0c7 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -107,7 +107,11 @@ import { refreshGatewayHealthSnapshot, } from "./server/health-state.js"; import { loadGatewayTlsRuntime } from "./server/tls.js"; -import { ensureGatewayStartupAuth } from "./startup-auth.js"; +import { + ensureGatewayStartupAuth, + mergeGatewayAuthConfig, + mergeGatewayTailscaleConfig, +} from "./startup-auth.js"; import { maybeSeedControlUiAllowedOriginsAtStartup } from "./startup-control-ui-origins.js"; export { __resetModelCatalogCacheForTest } from "./server-model-catalog.js"; @@ -174,6 +178,23 @@ function logGatewayAuthSurfaceDiagnostics(prepared: { } } +function applyGatewayAuthOverridesForStartupPreflight( + config: OpenClawConfig, + overrides: Pick, +): OpenClawConfig { + if (!overrides.auth && !overrides.tailscale) { + return config; + } + return { + ...config, + gateway: { + ...config.gateway, + auth: mergeGatewayAuthConfig(config.gateway?.auth, overrides.auth), + tailscale: mergeGatewayTailscaleConfig(config.gateway?.tailscale, overrides.tailscale), + }, + }; +} + export type GatewayServer = { close: (opts?: { reason?: string; restartExpectedMs?: number | null }) => Promise; }; @@ -373,7 +394,14 @@ export async function startGatewayServer( : "Unknown validation issue."; throw new Error(`Invalid config at ${freshSnapshot.path}.\n${issues}`); } - await activateRuntimeSecrets(freshSnapshot.config, { + const startupPreflightConfig = applyGatewayAuthOverridesForStartupPreflight( + freshSnapshot.config, + { + auth: opts.auth, + tailscale: opts.tailscale, + }, + ); + await activateRuntimeSecrets(startupPreflightConfig, { reason: "startup", activate: false, }); diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index 0e6b97275..a6fa53276 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -270,6 +270,34 @@ describe("gateway hot reload", () => { ); } + async function writeGatewayTokenRefConfig() { + const configPath = process.env.OPENCLAW_CONFIG_PATH; + if (!configPath) { + throw new Error("OPENCLAW_CONFIG_PATH is not set"); + } + await fs.writeFile( + configPath, + `${JSON.stringify( + { + secrets: { + providers: { + default: { source: "env" }, + }, + }, + gateway: { + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_STARTUP_GW_TOKEN" }, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + } + async function writeAuthProfileEnvRefStore() { const stateDir = process.env.OPENCLAW_STATE_DIR; if (!stateDir) { @@ -429,6 +457,21 @@ describe("gateway hot reload", () => { await expect(withGatewayServer(async () => {})).resolves.toBeUndefined(); }); + it("honors startup auth overrides before secret preflight gating", async () => { + await writeGatewayTokenRefConfig(); + delete process.env.MISSING_STARTUP_GW_TOKEN; + await expect( + withGatewayServer(async () => {}, { + serverOptions: { + auth: { + mode: "password", + password: "override-password", + }, + }, + }), + ).resolves.toBeUndefined(); + }); + it("fails startup when auth-profile secret refs are unresolved", async () => { await writeAuthProfileEnvRefStore(); delete process.env.MISSING_OPENCLAW_AUTH_REF; diff --git a/src/gateway/startup-auth.test.ts b/src/gateway/startup-auth.test.ts index a9572d24e..b5c4e19bd 100644 --- a/src/gateway/startup-auth.test.ts +++ b/src/gateway/startup-auth.test.ts @@ -130,6 +130,137 @@ describe("ensureGatewayStartupAuth", () => { expect(result.generatedToken).toBeUndefined(); expect(result.auth.mode).toBe("password"); expect(result.auth.password).toBe("resolved-password"); + expect(result.cfg.gateway?.auth?.password).toEqual({ + source: "env", + provider: "default", + id: "GW_PASSWORD", + }); + }); + + it("resolves gateway.auth.token SecretRef before startup auth checks", async () => { + const result = await ensureGatewayStartupAuth({ + cfg: { + gateway: { + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "GW_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }, + env: { + GW_TOKEN: "resolved-token", + } as NodeJS.ProcessEnv, + persist: true, + }); + + expect(result.generatedToken).toBeUndefined(); + expect(result.persistedGeneratedToken).toBe(false); + expect(result.auth.mode).toBe("token"); + expect(result.auth.token).toBe("resolved-token"); + expect(result.cfg.gateway?.auth?.token).toEqual({ + source: "env", + provider: "default", + id: "GW_TOKEN", + }); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("resolves env-template gateway.auth.token before env-token short-circuiting", async () => { + const result = await ensureGatewayStartupAuth({ + cfg: { + gateway: { + auth: { + mode: "token", + token: "${OPENCLAW_GATEWAY_TOKEN}", + }, + }, + }, + env: { + OPENCLAW_GATEWAY_TOKEN: "resolved-token", + } as NodeJS.ProcessEnv, + persist: true, + }); + + expect(result.generatedToken).toBeUndefined(); + expect(result.persistedGeneratedToken).toBe(false); + expect(result.auth.mode).toBe("token"); + expect(result.auth.token).toBe("resolved-token"); + expect(result.cfg.gateway?.auth?.token).toBe("${OPENCLAW_GATEWAY_TOKEN}"); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("uses OPENCLAW_GATEWAY_TOKEN without resolving configured token SecretRef", async () => { + const result = await ensureGatewayStartupAuth({ + cfg: { + gateway: { + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_GW_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }, + env: { + OPENCLAW_GATEWAY_TOKEN: "token-from-env", + } as NodeJS.ProcessEnv, + persist: true, + }); + + expect(result.generatedToken).toBeUndefined(); + expect(result.persistedGeneratedToken).toBe(false); + expect(result.auth.mode).toBe("token"); + expect(result.auth.token).toBe("token-from-env"); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("fails when gateway.auth.token SecretRef is active and unresolved", async () => { + await expect( + ensureGatewayStartupAuth({ + cfg: { + gateway: { + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_GW_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }, + env: {} as NodeJS.ProcessEnv, + persist: true, + }), + ).rejects.toThrow(/MISSING_GW_TOKEN/i); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("requires explicit gateway.auth.mode when token and password are both configured", async () => { + await expect( + ensureGatewayStartupAuth({ + cfg: { + gateway: { + auth: { + token: "configured-token", + password: "configured-password", + }, + }, + }, + env: {} as NodeJS.ProcessEnv, + persist: true, + }), + ).rejects.toThrow(/gateway\.auth\.mode is unset/i); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }); it("uses OPENCLAW_GATEWAY_PASSWORD without resolving configured password SecretRef", async () => { diff --git a/src/gateway/startup-auth.ts b/src/gateway/startup-auth.ts index e8caf3d70..74cf0480e 100644 --- a/src/gateway/startup-auth.ts +++ b/src/gateway/startup-auth.ts @@ -5,9 +5,10 @@ import type { OpenClawConfig, } from "../config/config.js"; import { writeConfigFile } from "../config/config.js"; -import { resolveSecretInputRef } from "../config/types.secrets.js"; +import { hasConfiguredSecretInput, resolveSecretInputRef } from "../config/types.secrets.js"; import { secretRefKey } from "../secrets/ref-contract.js"; import { resolveSecretRefValues } from "../secrets/resolve.js"; +import { assertExplicitGatewayAuthModeWhenBothConfigured } from "./auth-mode-policy.js"; import { resolveGatewayAuth, type ResolvedGatewayAuth } from "./auth.js"; export function mergeGatewayAuthConfig( @@ -107,12 +108,19 @@ function hasGatewayTokenCandidate(params: { ) { return true; } - return ( - typeof params.cfg.gateway?.auth?.token === "string" && - params.cfg.gateway.auth.token.trim().length > 0 + return hasConfiguredSecretInput(params.cfg.gateway?.auth?.token, params.cfg.secrets?.defaults); +} + +function hasGatewayTokenOverrideCandidate(params: { authOverride?: GatewayAuthConfig }): boolean { + return Boolean( + typeof params.authOverride?.token === "string" && params.authOverride.token.trim().length > 0, ); } +function hasGatewayTokenEnvCandidate(env: NodeJS.ProcessEnv): boolean { + return Boolean(env.OPENCLAW_GATEWAY_TOKEN?.trim() || env.CLAWDBOT_GATEWAY_TOKEN?.trim()); +} + function hasGatewayPasswordEnvCandidate(env: NodeJS.ProcessEnv): boolean { return Boolean(env.OPENCLAW_GATEWAY_PASSWORD?.trim() || env.CLAWDBOT_GATEWAY_PASSWORD?.trim()); } @@ -130,6 +138,61 @@ function hasGatewayPasswordOverrideCandidate(params: { ); } +function shouldResolveGatewayTokenSecretRef(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + authOverride?: GatewayAuthConfig; +}): boolean { + if (hasGatewayTokenOverrideCandidate({ authOverride: params.authOverride })) { + return false; + } + if (hasGatewayTokenEnvCandidate(params.env)) { + return false; + } + const explicitMode = params.authOverride?.mode ?? params.cfg.gateway?.auth?.mode; + if (explicitMode === "token") { + return true; + } + if (explicitMode === "password" || explicitMode === "none" || explicitMode === "trusted-proxy") { + return false; + } + + if (hasGatewayPasswordOverrideCandidate(params)) { + return false; + } + return !hasConfiguredSecretInput( + params.cfg.gateway?.auth?.password, + params.cfg.secrets?.defaults, + ); +} + +async function resolveGatewayTokenSecretRef( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv, + authOverride?: GatewayAuthConfig, +): Promise { + const authToken = cfg.gateway?.auth?.token; + const { ref } = resolveSecretInputRef({ + value: authToken, + defaults: cfg.secrets?.defaults, + }); + if (!ref) { + return undefined; + } + if (!shouldResolveGatewayTokenSecretRef({ cfg, env, authOverride })) { + return undefined; + } + const resolved = await resolveSecretRefValues([ref], { + config: cfg, + env, + }); + const value = resolved.get(secretRefKey(ref)); + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error("gateway.auth.token resolved to an empty or non-string value."); + } + return value.trim(); +} + function shouldResolveGatewayPasswordSecretRef(params: { cfg: OpenClawConfig; env: NodeJS.ProcessEnv; @@ -156,17 +219,17 @@ async function resolveGatewayPasswordSecretRef( cfg: OpenClawConfig, env: NodeJS.ProcessEnv, authOverride?: GatewayAuthConfig, -): Promise { +): Promise { const authPassword = cfg.gateway?.auth?.password; const { ref } = resolveSecretInputRef({ value: authPassword, defaults: cfg.secrets?.defaults, }); if (!ref) { - return cfg; + return undefined; } if (!shouldResolveGatewayPasswordSecretRef({ cfg, env, authOverride })) { - return cfg; + return undefined; } const resolved = await resolveSecretRefValues([ref], { config: cfg, @@ -176,16 +239,7 @@ async function resolveGatewayPasswordSecretRef( if (typeof value !== "string" || value.trim().length === 0) { throw new Error("gateway.auth.password resolved to an empty or non-string value."); } - return { - ...cfg, - gateway: { - ...cfg.gateway, - auth: { - ...cfg.gateway?.auth, - password: value.trim(), - }, - }, - }; + return value.trim(); } export async function ensureGatewayStartupAuth(params: { @@ -200,27 +254,39 @@ export async function ensureGatewayStartupAuth(params: { generatedToken?: string; persistedGeneratedToken: boolean; }> { + assertExplicitGatewayAuthModeWhenBothConfigured(params.cfg); const env = params.env ?? process.env; const persistRequested = params.persist === true; - const cfgForAuth = await resolveGatewayPasswordSecretRef(params.cfg, env, params.authOverride); + const [resolvedTokenRefValue, resolvedPasswordRefValue] = await Promise.all([ + resolveGatewayTokenSecretRef(params.cfg, env, params.authOverride), + resolveGatewayPasswordSecretRef(params.cfg, env, params.authOverride), + ]); + const authOverride: GatewayAuthConfig | undefined = + params.authOverride || resolvedTokenRefValue || resolvedPasswordRefValue + ? { + ...params.authOverride, + ...(resolvedTokenRefValue ? { token: resolvedTokenRefValue } : {}), + ...(resolvedPasswordRefValue ? { password: resolvedPasswordRefValue } : {}), + } + : undefined; const resolved = resolveGatewayAuthFromConfig({ - cfg: cfgForAuth, + cfg: params.cfg, env, - authOverride: params.authOverride, + authOverride, tailscaleOverride: params.tailscaleOverride, }); if (resolved.mode !== "token" || (resolved.token?.trim().length ?? 0) > 0) { - assertHooksTokenSeparateFromGatewayAuth({ cfg: cfgForAuth, auth: resolved }); - return { cfg: cfgForAuth, auth: resolved, persistedGeneratedToken: false }; + assertHooksTokenSeparateFromGatewayAuth({ cfg: params.cfg, auth: resolved }); + return { cfg: params.cfg, auth: resolved, persistedGeneratedToken: false }; } const generatedToken = crypto.randomBytes(24).toString("hex"); const nextCfg: OpenClawConfig = { - ...cfgForAuth, + ...params.cfg, gateway: { - ...cfgForAuth.gateway, + ...params.cfg.gateway, auth: { - ...cfgForAuth.gateway?.auth, + ...params.cfg.gateway?.auth, mode: "token", token: generatedToken, }, diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index 6084f2b09..19bd1f592 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -147,6 +147,181 @@ describe("pairing setup code", () => { expect(resolved.payload.token).toBe("tok_123"); }); + it("resolves gateway.auth.token SecretRef for pairing payload", async () => { + const resolved = await resolvePairingSetupFromConfig( + { + gateway: { + bind: "custom", + customBindHost: "gateway.local", + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "GW_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }, + { + env: { + GW_TOKEN: "resolved-token", + }, + }, + ); + + expect(resolved.ok).toBe(true); + if (!resolved.ok) { + throw new Error("expected setup resolution to succeed"); + } + expect(resolved.authLabel).toBe("token"); + expect(resolved.payload.token).toBe("resolved-token"); + }); + + it("errors when gateway.auth.token SecretRef is unresolved in token mode", async () => { + await expect( + resolvePairingSetupFromConfig( + { + gateway: { + bind: "custom", + customBindHost: "gateway.local", + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_GW_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }, + { + env: {}, + }, + ), + ).rejects.toThrow(/MISSING_GW_TOKEN/i); + }); + + it("uses password env in inferred mode without resolving token SecretRef", async () => { + const resolved = await resolvePairingSetupFromConfig( + { + gateway: { + bind: "custom", + customBindHost: "gateway.local", + auth: { + token: { source: "env", provider: "default", id: "MISSING_GW_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }, + { + env: { + OPENCLAW_GATEWAY_PASSWORD: "password-from-env", + }, + }, + ); + + expect(resolved.ok).toBe(true); + if (!resolved.ok) { + throw new Error("expected setup resolution to succeed"); + } + expect(resolved.authLabel).toBe("password"); + expect(resolved.payload.password).toBe("password-from-env"); + }); + + it("does not treat env-template token as plaintext in inferred mode", async () => { + const resolved = await resolvePairingSetupFromConfig( + { + gateway: { + bind: "custom", + customBindHost: "gateway.local", + auth: { + token: "${MISSING_GW_TOKEN}", + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }, + { + env: { + OPENCLAW_GATEWAY_PASSWORD: "password-from-env", + }, + }, + ); + + expect(resolved.ok).toBe(true); + if (!resolved.ok) { + throw new Error("expected setup resolution to succeed"); + } + expect(resolved.authLabel).toBe("password"); + expect(resolved.payload.token).toBeUndefined(); + expect(resolved.payload.password).toBe("password-from-env"); + }); + + it("requires explicit auth mode when token and password are both configured", async () => { + await expect( + resolvePairingSetupFromConfig( + { + gateway: { + bind: "custom", + customBindHost: "gateway.local", + auth: { + token: { source: "env", provider: "default", id: "GW_TOKEN" }, + password: { source: "env", provider: "default", id: "GW_PASSWORD" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }, + { + env: { + GW_TOKEN: "resolved-token", + GW_PASSWORD: "resolved-password", + }, + }, + ), + ).rejects.toThrow(/gateway\.auth\.mode is unset/i); + }); + + it("errors when token and password SecretRefs are both configured with inferred mode", async () => { + await expect( + resolvePairingSetupFromConfig( + { + gateway: { + bind: "custom", + customBindHost: "gateway.local", + auth: { + token: { source: "env", provider: "default", id: "MISSING_GW_TOKEN" }, + password: { source: "env", provider: "default", id: "GW_PASSWORD" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }, + { + env: { + GW_PASSWORD: "resolved-password", + }, + }, + ), + ).rejects.toThrow(/gateway\.auth\.mode is unset/i); + }); + it("honors env token override", async () => { const resolved = await resolvePairingSetupFromConfig( { diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts index dbacd0e53..247abd38c 100644 --- a/src/pairing/setup-code.ts +++ b/src/pairing/setup-code.ts @@ -1,7 +1,12 @@ import os from "node:os"; import { resolveGatewayPort } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.js"; -import { normalizeSecretInputString, resolveSecretInputRef } from "../config/types.secrets.js"; +import { + hasConfiguredSecretInput, + normalizeSecretInputString, + resolveSecretInputRef, +} from "../config/types.secrets.js"; +import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js"; import { secretRefKey } from "../secrets/ref-contract.js"; import { resolveSecretRefValues } from "../secrets/resolve.js"; import { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js"; @@ -152,14 +157,23 @@ function pickTailnetIPv4( function resolveAuth(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): ResolveAuthResult { const mode = cfg.gateway?.auth?.mode; + const defaults = cfg.secrets?.defaults; + const tokenRef = resolveSecretInputRef({ + value: cfg.gateway?.auth?.token, + defaults, + }).ref; + const passwordRef = resolveSecretInputRef({ + value: cfg.gateway?.auth?.password, + defaults, + }).ref; const token = env.OPENCLAW_GATEWAY_TOKEN?.trim() || env.CLAWDBOT_GATEWAY_TOKEN?.trim() || - cfg.gateway?.auth?.token?.trim(); + (tokenRef ? undefined : normalizeSecretInputString(cfg.gateway?.auth?.token)); const password = env.OPENCLAW_GATEWAY_PASSWORD?.trim() || env.CLAWDBOT_GATEWAY_PASSWORD?.trim() || - normalizeSecretInputString(cfg.gateway?.auth?.password); + (passwordRef ? undefined : normalizeSecretInputString(cfg.gateway?.auth?.password)); if (mode === "password") { if (!password) { @@ -182,6 +196,56 @@ function resolveAuth(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): ResolveAuthRe return { error: "Gateway auth is not configured (no token or password)." }; } +async function resolveGatewayTokenSecretRef( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv, +): Promise { + const authToken = cfg.gateway?.auth?.token; + const { ref } = resolveSecretInputRef({ + value: authToken, + defaults: cfg.secrets?.defaults, + }); + if (!ref) { + return cfg; + } + const hasTokenEnvCandidate = Boolean( + env.OPENCLAW_GATEWAY_TOKEN?.trim() || env.CLAWDBOT_GATEWAY_TOKEN?.trim(), + ); + if (hasTokenEnvCandidate) { + return cfg; + } + const mode = cfg.gateway?.auth?.mode; + if (mode === "password" || mode === "none" || mode === "trusted-proxy") { + return cfg; + } + if (mode !== "token") { + const hasPasswordEnvCandidate = Boolean( + env.OPENCLAW_GATEWAY_PASSWORD?.trim() || env.CLAWDBOT_GATEWAY_PASSWORD?.trim(), + ); + if (hasPasswordEnvCandidate) { + return cfg; + } + } + const resolved = await resolveSecretRefValues([ref], { + config: cfg, + env, + }); + const value = resolved.get(secretRefKey(ref)); + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error("gateway.auth.token resolved to an empty or non-string value."); + } + return { + ...cfg, + gateway: { + ...cfg.gateway, + auth: { + ...cfg.gateway?.auth, + token: value.trim(), + }, + }, + }; +} + async function resolveGatewayPasswordSecretRef( cfg: OpenClawConfig, env: NodeJS.ProcessEnv, @@ -207,7 +271,7 @@ async function resolveGatewayPasswordSecretRef( if (mode !== "password") { const hasTokenCandidate = Boolean(env.OPENCLAW_GATEWAY_TOKEN?.trim() || env.CLAWDBOT_GATEWAY_TOKEN?.trim()) || - Boolean(cfg.gateway?.auth?.token?.trim()); + hasConfiguredSecretInput(cfg.gateway?.auth?.token, cfg.secrets?.defaults); if (hasTokenCandidate) { return cfg; } @@ -304,8 +368,10 @@ export async function resolvePairingSetupFromConfig( cfg: OpenClawConfig, options: ResolvePairingSetupOptions = {}, ): Promise { + assertExplicitGatewayAuthModeWhenBothConfigured(cfg); const env = options.env ?? process.env; - const cfgForAuth = await resolveGatewayPasswordSecretRef(cfg, env); + const cfgWithToken = await resolveGatewayTokenSecretRef(cfg, env); + const cfgForAuth = await resolveGatewayPasswordSecretRef(cfgWithToken, env); const auth = resolveAuth(cfgForAuth, env); if (auth.error) { return { ok: false, error: auth.error }; diff --git a/src/secrets/credential-matrix.ts b/src/secrets/credential-matrix.ts index 0dc0ceaed..a3c44e34f 100644 --- a/src/secrets/credential-matrix.ts +++ b/src/secrets/credential-matrix.ts @@ -24,7 +24,6 @@ const EXCLUDED_MUTABLE_OR_RUNTIME_MANAGED = [ "commands.ownerDisplaySecret", "channels.matrix.accessToken", "channels.matrix.accounts.*.accessToken", - "gateway.auth.token", "hooks.token", "hooks.gmail.pushToken", "hooks.mappings[].sessionKey", diff --git a/src/secrets/runtime-config-collectors-core.ts b/src/secrets/runtime-config-collectors-core.ts index 4cc34a27e..085573173 100644 --- a/src/secrets/runtime-config-collectors-core.ts +++ b/src/secrets/runtime-config-collectors-core.ts @@ -202,6 +202,18 @@ function collectGatewayAssignments(params: { defaults: params.defaults, }); if (auth) { + collectSecretInputAssignment({ + value: auth.token, + path: "gateway.auth.token", + expected: "string", + defaults: params.defaults, + context: params.context, + active: gatewaySurfaceStates["gateway.auth.token"].active, + inactiveReason: gatewaySurfaceStates["gateway.auth.token"].reason, + apply: (value) => { + auth.token = value; + }, + }); collectSecretInputAssignment({ value: auth.password, path: "gateway.auth.password", diff --git a/src/secrets/runtime-gateway-auth-surfaces.test.ts b/src/secrets/runtime-gateway-auth-surfaces.test.ts index 3942c720c..f84728b30 100644 --- a/src/secrets/runtime-gateway-auth-surfaces.test.ts +++ b/src/secrets/runtime-gateway-auth-surfaces.test.ts @@ -16,6 +16,60 @@ function evaluate(config: OpenClawConfig, env: NodeJS.ProcessEnv = EMPTY_ENV) { } describe("evaluateGatewayAuthSurfaceStates", () => { + it("marks gateway.auth.token active when token mode is explicit", () => { + const states = evaluate({ + gateway: { + auth: { + mode: "token", + token: envRef("GW_AUTH_TOKEN"), + }, + }, + } as OpenClawConfig); + + expect(states["gateway.auth.token"]).toMatchObject({ + hasSecretRef: true, + active: true, + reason: 'gateway.auth.mode is "token".', + }); + }); + + it("marks gateway.auth.token inactive when env token is configured", () => { + const states = evaluate( + { + gateway: { + auth: { + mode: "token", + token: envRef("GW_AUTH_TOKEN"), + }, + }, + } as OpenClawConfig, + { OPENCLAW_GATEWAY_TOKEN: "env-token" } as NodeJS.ProcessEnv, + ); + + expect(states["gateway.auth.token"]).toMatchObject({ + hasSecretRef: true, + active: false, + reason: "gateway token env var is configured.", + }); + }); + + it("marks gateway.auth.token inactive when password mode is explicit", () => { + const states = evaluate({ + gateway: { + auth: { + mode: "password", + token: envRef("GW_AUTH_TOKEN"), + }, + }, + } as OpenClawConfig); + + expect(states["gateway.auth.token"]).toMatchObject({ + hasSecretRef: true, + active: false, + reason: 'gateway.auth.mode is "password".', + }); + }); + it("marks gateway.auth.password active when password mode is explicit", () => { const states = evaluate({ gateway: { diff --git a/src/secrets/runtime-gateway-auth-surfaces.ts b/src/secrets/runtime-gateway-auth-surfaces.ts index 1a82ff2c9..7fa730967 100644 --- a/src/secrets/runtime-gateway-auth-surfaces.ts +++ b/src/secrets/runtime-gateway-auth-surfaces.ts @@ -10,6 +10,7 @@ const GATEWAY_PASSWORD_ENV_KEYS = [ ] as const; export const GATEWAY_AUTH_SURFACE_PATHS = [ + "gateway.auth.token", "gateway.auth.password", "gateway.remote.token", "gateway.remote.password", @@ -85,6 +86,12 @@ export function evaluateGatewayAuthSurfaceStates(params: { const gateway = params.config.gateway as Record | undefined; if (!isRecord(gateway)) { return { + "gateway.auth.token": createState({ + path: "gateway.auth.token", + active: false, + reason: "gateway configuration is not set.", + hasSecretRef: false, + }), "gateway.auth.password": createState({ path: "gateway.auth.password", active: false, @@ -109,6 +116,7 @@ export function evaluateGatewayAuthSurfaceStates(params: { const remote = isRecord(gateway?.remote) ? gateway.remote : undefined; const authMode = auth && typeof auth.mode === "string" ? auth.mode : undefined; + const hasAuthTokenRef = coerceSecretRef(auth?.token, defaults) !== null; const hasAuthPasswordRef = coerceSecretRef(auth?.password, defaults) !== null; const hasRemoteTokenRef = coerceSecretRef(remote?.token, defaults) !== null; const hasRemotePasswordRef = coerceSecretRef(remote?.password, defaults) !== null; @@ -118,9 +126,14 @@ export function evaluateGatewayAuthSurfaceStates(params: { const localTokenConfigured = hasConfiguredSecretInput(auth?.token, defaults); const localPasswordConfigured = hasConfiguredSecretInput(auth?.password, defaults); const remoteTokenConfigured = hasConfiguredSecretInput(remote?.token, defaults); + const passwordSourceConfigured = Boolean(envPassword || localPasswordConfigured); const localTokenCanWin = authMode !== "password" && authMode !== "none" && authMode !== "trusted-proxy"; + const localTokenSurfaceActive = + localTokenCanWin && + !envToken && + (authMode === "token" || (authMode === undefined && !passwordSourceConfigured)); const tokenCanWin = Boolean(envToken || localTokenConfigured || remoteTokenConfigured); const passwordCanWin = authMode === "password" || @@ -165,6 +178,28 @@ export function evaluateGatewayAuthSurfaceStates(params: { return "token auth can win."; })(); + const authTokenReason = (() => { + if (!auth) { + return "gateway.auth is not configured."; + } + if (authMode === "token") { + return envToken ? "gateway token env var is configured." : 'gateway.auth.mode is "token".'; + } + if (authMode === "password" || authMode === "none" || authMode === "trusted-proxy") { + return `gateway.auth.mode is "${authMode}".`; + } + if (envToken) { + return "gateway token env var is configured."; + } + if (envPassword) { + return "gateway password env var is configured."; + } + if (localPasswordConfigured) { + return "gateway.auth.password is configured."; + } + return "token auth can win (mode is unset and no password source is configured)."; + })(); + const remoteSurfaceReason = describeRemoteConfiguredSurface({ remoteMode, remoteUrlConfigured, @@ -225,6 +260,12 @@ export function evaluateGatewayAuthSurfaceStates(params: { })(); return { + "gateway.auth.token": createState({ + path: "gateway.auth.token", + active: localTokenSurfaceActive, + reason: authTokenReason, + hasSecretRef: hasAuthTokenRef, + }), "gateway.auth.password": createState({ path: "gateway.auth.password", active: passwordCanWin, diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 61d4d75a6..40e766179 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -652,6 +652,71 @@ describe("secrets runtime snapshot", () => { expect(snapshot.warnings.map((warning) => warning.path)).not.toContain("gateway.auth.password"); }); + it("treats gateway.auth.token ref as active when token mode is explicit", async () => { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + gateway: { + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "GATEWAY_TOKEN_REF" }, + }, + }, + }), + env: { + GATEWAY_TOKEN_REF: "resolved-gateway-token", + }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => ({ version: 1, profiles: {} }), + }); + + expect(snapshot.config.gateway?.auth?.token).toBe("resolved-gateway-token"); + expect(snapshot.warnings.map((warning) => warning.path)).not.toContain("gateway.auth.token"); + }); + + it("treats gateway.auth.token ref as inactive when password mode is explicit", async () => { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + gateway: { + auth: { + mode: "password", + token: { source: "env", provider: "default", id: "GATEWAY_TOKEN_REF" }, + password: "password-123", + }, + }, + }), + env: { + GATEWAY_TOKEN_REF: "resolved-gateway-token", + }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => ({ version: 1, profiles: {} }), + }); + + expect(snapshot.config.gateway?.auth?.token).toEqual({ + source: "env", + provider: "default", + id: "GATEWAY_TOKEN_REF", + }); + expect(snapshot.warnings.map((warning) => warning.path)).toContain("gateway.auth.token"); + }); + + it("fails when gateway.auth.token ref is active and unresolved", async () => { + await expect( + prepareSecretsRuntimeSnapshot({ + config: asConfig({ + gateway: { + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN_REF" }, + }, + }, + }), + env: {}, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => ({ version: 1, profiles: {} }), + }), + ).rejects.toThrow(/MISSING_GATEWAY_TOKEN_REF/i); + }); + it("treats gateway.auth.password ref as inactive when auth mode is trusted-proxy", async () => { const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ diff --git a/src/secrets/target-registry-data.ts b/src/secrets/target-registry-data.ts index a1a2c63ac..53eb43077 100644 --- a/src/secrets/target-registry-data.ts +++ b/src/secrets/target-registry-data.ts @@ -559,6 +559,17 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ includeInConfigure: true, includeInAudit: true, }, + { + id: "gateway.auth.token", + targetType: "gateway.auth.token", + configFile: "openclaw.json", + pathPattern: "gateway.auth.token", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, { id: "gateway.auth.password", targetType: "gateway.auth.password", diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 618de6832..a681273be 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -3256,5 +3256,35 @@ description: test skill }), ); }); + + it("adds warning finding when probe auth SecretRef is unavailable", async () => { + const cfg: OpenClawConfig = { + gateway: { + mode: "local", + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + + const res = await audit(cfg, { + deep: true, + deepTimeoutMs: 50, + probeGatewayFn: async (opts) => successfulProbeResult(opts.url), + env: {}, + }); + + const warning = res.findings.find( + (finding) => finding.checkId === "gateway.probe_auth_secretref_unavailable", + ); + expect(warning?.severity).toBe("warn"); + expect(warning?.detail).toContain("gateway.auth.token"); + }); }); }); diff --git a/src/security/audit.ts b/src/security/audit.ts index 4a5c70d56..e39066698 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -11,7 +11,7 @@ import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; import { hasConfiguredSecretInput } from "../config/types.secrets.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import { buildGatewayConnectionDetails } from "../gateway/call.js"; -import { resolveGatewayProbeAuth } from "../gateway/probe-auth.js"; +import { resolveGatewayProbeAuthSafe } from "../gateway/probe-auth.js"; import { probeGateway } from "../gateway/probe.js"; import { listInterpreterLikeSafeBins, @@ -1041,7 +1041,10 @@ async function maybeProbeGateway(params: { env: NodeJS.ProcessEnv; timeoutMs: number; probe: typeof probeGateway; -}): Promise { +}): Promise<{ + deep: SecurityAuditReport["deep"]; + authWarning?: string; +}> { const connection = buildGatewayConnectionDetails({ config: params.cfg }); const url = connection.url; const isRemoteMode = params.cfg.gateway?.mode === "remote"; @@ -1049,30 +1052,39 @@ async function maybeProbeGateway(params: { typeof params.cfg.gateway?.remote?.url === "string" ? params.cfg.gateway.remote.url.trim() : ""; const remoteUrlMissing = isRemoteMode && !remoteUrlRaw; - const auth = + const authResolution = !isRemoteMode || remoteUrlMissing - ? resolveGatewayProbeAuth({ cfg: params.cfg, env: params.env, mode: "local" }) - : resolveGatewayProbeAuth({ cfg: params.cfg, env: params.env, mode: "remote" }); - const res = await params.probe({ url, auth, timeoutMs: params.timeoutMs }).catch((err) => ({ - ok: false, - url, - connectLatencyMs: null, - error: String(err), - close: null, - health: null, - status: null, - presence: null, - configSnapshot: null, - })); + ? resolveGatewayProbeAuthSafe({ cfg: params.cfg, env: params.env, mode: "local" }) + : resolveGatewayProbeAuthSafe({ cfg: params.cfg, env: params.env, mode: "remote" }); + const res = await params + .probe({ url, auth: authResolution.auth, timeoutMs: params.timeoutMs }) + .catch((err) => ({ + ok: false, + url, + connectLatencyMs: null, + error: String(err), + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + })); + + if (authResolution.warning && !res.ok) { + res.error = res.error ? `${res.error}; ${authResolution.warning}` : authResolution.warning; + } return { - gateway: { - attempted: true, - url, - ok: res.ok, - error: res.ok ? null : res.error, - close: res.close ? { code: res.close.code, reason: res.close.reason } : null, + deep: { + gateway: { + attempted: true, + url, + ok: res.ok, + error: res.ok ? null : res.error, + close: res.close ? { code: res.close.code, reason: res.close.reason } : null, + }, }, + authWarning: authResolution.warning, }; } @@ -1197,7 +1209,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + describe("resolveGatewayConnection", () => { let envSnapshot: ReturnType; @@ -29,10 +41,10 @@ describe("resolveGatewayConnection", () => { envSnapshot.restore(); }); - it("throws when url override is missing explicit credentials", () => { + it("throws when url override is missing explicit credentials", async () => { loadConfig.mockReturnValue({ gateway: { mode: "local" } }); - expect(() => resolveGatewayConnection({ url: "wss://override.example/ws" })).toThrow( + await expect(resolveGatewayConnection({ url: "wss://override.example/ws" })).rejects.toThrow( "explicit credentials", ); }); @@ -48,10 +60,10 @@ describe("resolveGatewayConnection", () => { auth: { password: "explicit-password" }, expected: { token: undefined, password: "explicit-password" }, }, - ])("uses explicit $label when url override is set", ({ auth, expected }) => { + ])("uses explicit $label when url override is set", async ({ auth, expected }) => { loadConfig.mockReturnValue({ gateway: { mode: "local" } }); - const result = resolveGatewayConnection({ + const result = await resolveGatewayConnection({ url: "wss://override.example/ws", ...auth, }); @@ -73,33 +85,98 @@ describe("resolveGatewayConnection", () => { bind: "lan", setup: () => pickPrimaryLanIPv4.mockReturnValue("192.168.1.42"), }, - ])("uses loopback host when local bind is $label", ({ bind, setup }) => { + ])("uses loopback host when local bind is $label", async ({ bind, setup }) => { loadConfig.mockReturnValue({ gateway: { mode: "local", bind } }); resolveGatewayPort.mockReturnValue(18800); setup(); - const result = resolveGatewayConnection({}); + const result = await withEnvAsync({ OPENCLAW_GATEWAY_TOKEN: "env-token" }, async () => { + return await resolveGatewayConnection({}); + }); expect(result.url).toBe("ws://127.0.0.1:18800"); }); - it("uses OPENCLAW_GATEWAY_TOKEN for local mode", () => { + it("uses OPENCLAW_GATEWAY_TOKEN for local mode", async () => { loadConfig.mockReturnValue({ gateway: { mode: "local" } }); - withEnv({ OPENCLAW_GATEWAY_TOKEN: "env-token" }, () => { - const result = resolveGatewayConnection({}); + await withEnvAsync({ OPENCLAW_GATEWAY_TOKEN: "env-token" }, async () => { + const result = await resolveGatewayConnection({}); expect(result.token).toBe("env-token"); }); }); - it("falls back to config auth token when env token is missing", () => { + it("falls back to config auth token when env token is missing", async () => { loadConfig.mockReturnValue({ gateway: { mode: "local", auth: { token: "config-token" } } }); - const result = resolveGatewayConnection({}); + const result = await resolveGatewayConnection({}); expect(result.token).toBe("config-token"); }); - it("prefers OPENCLAW_GATEWAY_PASSWORD over remote password fallback", () => { + it("uses local password auth when gateway.auth.mode is unset and password-only is configured", async () => { + loadConfig.mockReturnValue({ + gateway: { + mode: "local", + auth: { + password: "config-password", + }, + }, + }); + + const result = await resolveGatewayConnection({}); + expect(result.password).toBe("config-password"); + expect(result.token).toBeUndefined(); + }); + + it("fails when both local token and password are configured but gateway.auth.mode is unset", async () => { + loadConfig.mockReturnValue({ + gateway: { + mode: "local", + auth: { + token: "config-token", + password: "config-password", + }, + }, + }); + + await expect(resolveGatewayConnection({})).rejects.toThrow( + "gateway.auth.mode is unset. Set gateway.auth.mode to token or password.", + ); + }); + + it("resolves env-template config auth token from referenced env var", async () => { + loadConfig.mockReturnValue({ + secrets: { + providers: { + default: { source: "env" }, + }, + }, + gateway: { + mode: "local", + auth: { token: "${CUSTOM_GATEWAY_TOKEN}" }, + }, + }); + + await withEnvAsync({ CUSTOM_GATEWAY_TOKEN: "custom-token" }, async () => { + const result = await resolveGatewayConnection({}); + expect(result.token).toBe("custom-token"); + }); + }); + + it("fails with guidance when env-template config auth token is unresolved", async () => { + loadConfig.mockReturnValue({ + gateway: { + mode: "local", + auth: { token: "${MISSING_GATEWAY_TOKEN}" }, + }, + }); + + await expect(resolveGatewayConnection({})).rejects.toThrow( + "gateway.auth.token SecretRef is unresolved", + ); + }); + + it("prefers OPENCLAW_GATEWAY_PASSWORD over remote password fallback", async () => { loadConfig.mockReturnValue({ gateway: { mode: "remote", @@ -107,9 +184,181 @@ describe("resolveGatewayConnection", () => { }, }); - withEnv({ OPENCLAW_GATEWAY_PASSWORD: "env-pass" }, () => { - const result = resolveGatewayConnection({}); + await withEnvAsync({ OPENCLAW_GATEWAY_PASSWORD: "env-pass" }, async () => { + const result = await resolveGatewayConnection({}); expect(result.password).toBe("env-pass"); }); }); + + it.runIf(process.platform !== "win32")( + "resolves file-backed SecretRef token for local mode", + async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tui-file-secret-")); + const secretFile = path.join(tempDir, "secrets.json"); + await fs.writeFile(secretFile, JSON.stringify({ gatewayToken: "file-secret-token" }), "utf8"); + await fs.chmod(secretFile, 0o600); + + loadConfig.mockReturnValue({ + secrets: { + providers: { + fileProvider: { + source: "file", + path: secretFile, + mode: "json", + allowInsecurePath: true, + }, + }, + }, + gateway: { + mode: "local", + auth: { + token: { source: "file", provider: "fileProvider", id: "/gatewayToken" }, + }, + }, + }); + + try { + const result = await resolveGatewayConnection({}); + expect(result.token).toBe("file-secret-token"); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }, + ); + + it("resolves exec-backed SecretRef token for local mode", async () => { + const execProgram = [ + "process.stdout.write(", + "JSON.stringify({ protocolVersion: 1, values: { EXEC_GATEWAY_TOKEN: 'exec-secret-token' } })", + ");", + ].join(""); + + loadConfig.mockReturnValue({ + secrets: { + providers: { + execProvider: { + source: "exec", + command: process.execPath, + args: ["-e", execProgram], + allowInsecurePath: true, + }, + }, + }, + gateway: { + mode: "local", + auth: { + token: { source: "exec", provider: "execProvider", id: "EXEC_GATEWAY_TOKEN" }, + }, + }, + }); + + const result = await resolveGatewayConnection({}); + expect(result.token).toBe("exec-secret-token"); + }); + + it("resolves only token SecretRef when gateway.auth.mode is token", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tui-mode-token-")); + const tokenMarker = path.join(tempDir, "token-provider-ran"); + const passwordMarker = path.join(tempDir, "password-provider-ran"); + const tokenExecProgram = [ + "const fs=require('node:fs');", + `fs.writeFileSync(${JSON.stringify(tokenMarker)},'1');`, + "process.stdout.write(JSON.stringify({ protocolVersion: 1, values: { TOKEN_SECRET: 'token-from-exec' } }));", + ].join(""); + const passwordExecProgram = [ + "const fs=require('node:fs');", + `fs.writeFileSync(${JSON.stringify(passwordMarker)},'1');`, + "process.stdout.write(JSON.stringify({ protocolVersion: 1, values: { PASSWORD_SECRET: 'password-from-exec' } }));", + ].join(""); + + loadConfig.mockReturnValue({ + secrets: { + providers: { + tokenProvider: { + source: "exec", + command: process.execPath, + args: ["-e", tokenExecProgram], + allowInsecurePath: true, + }, + passwordProvider: { + source: "exec", + command: process.execPath, + args: ["-e", passwordExecProgram], + allowInsecurePath: true, + }, + }, + }, + gateway: { + mode: "local", + auth: { + mode: "token", + token: { source: "exec", provider: "tokenProvider", id: "TOKEN_SECRET" }, + password: { source: "exec", provider: "passwordProvider", id: "PASSWORD_SECRET" }, + }, + }, + }); + + try { + const result = await resolveGatewayConnection({}); + expect(result.token).toBe("token-from-exec"); + expect(result.password).toBeUndefined(); + expect(await fileExists(tokenMarker)).toBe(true); + expect(await fileExists(passwordMarker)).toBe(false); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("resolves only password SecretRef when gateway.auth.mode is password", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tui-mode-password-")); + const tokenMarker = path.join(tempDir, "token-provider-ran"); + const passwordMarker = path.join(tempDir, "password-provider-ran"); + const tokenExecProgram = [ + "const fs=require('node:fs');", + `fs.writeFileSync(${JSON.stringify(tokenMarker)},'1');`, + "process.stdout.write(JSON.stringify({ protocolVersion: 1, values: { TOKEN_SECRET: 'token-from-exec' } }));", + ].join(""); + const passwordExecProgram = [ + "const fs=require('node:fs');", + `fs.writeFileSync(${JSON.stringify(passwordMarker)},'1');`, + "process.stdout.write(JSON.stringify({ protocolVersion: 1, values: { PASSWORD_SECRET: 'password-from-exec' } }));", + ].join(""); + + loadConfig.mockReturnValue({ + secrets: { + providers: { + tokenProvider: { + source: "exec", + command: process.execPath, + args: ["-e", tokenExecProgram], + allowInsecurePath: true, + }, + passwordProvider: { + source: "exec", + command: process.execPath, + args: ["-e", passwordExecProgram], + allowInsecurePath: true, + }, + }, + }, + gateway: { + mode: "local", + auth: { + mode: "password", + token: { source: "exec", provider: "tokenProvider", id: "TOKEN_SECRET" }, + password: { source: "exec", provider: "passwordProvider", id: "PASSWORD_SECRET" }, + }, + }, + }); + + try { + const result = await resolveGatewayConnection({}); + expect(result.password).toBe("password-from-exec"); + expect(result.token).toBeUndefined(); + expect(await fileExists(tokenMarker)).toBe(false); + expect(await fileExists(passwordMarker)).toBe(true); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 357488655..a595cd7a7 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -1,5 +1,7 @@ import { randomUUID } from "node:crypto"; import { loadConfig } from "../config/config.js"; +import { hasConfiguredSecretInput } from "../config/types.secrets.js"; +import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js"; import { buildGatewayConnectionDetails, ensureExplicitGatewayAuth, @@ -14,6 +16,7 @@ import { type SessionsPatchResult, type SessionsPatchParams, } from "../gateway/protocol/index.js"; +import { resolveConfiguredSecretInputString } from "../gateway/resolve-configured-secret-input-string.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { VERSION } from "../version.js"; import type { ResponseUsageMode, SessionInfo, SessionScope } from "./tui-types.js"; @@ -39,6 +42,30 @@ export type GatewayEvent = { seq?: number; }; +type ResolvedGatewayConnection = { + url: string; + token?: string; + password?: string; +}; + +function trimToUndefined(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function throwGatewayAuthResolutionError(reason: string): never { + throw new Error( + [ + reason, + "Fix: set OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD, pass --token/--password,", + "or resolve the configured secret provider for this credential.", + ].join("\n"), + ); +} + export type GatewaySessionList = { ts: number; path: string; @@ -112,18 +139,17 @@ export class GatewayChatClient { onDisconnected?: (reason: string) => void; onGap?: (info: { expected: number; received: number }) => void; - constructor(opts: GatewayConnectionOptions) { - const resolved = resolveGatewayConnection(opts); - this.connection = resolved; + constructor(connection: ResolvedGatewayConnection) { + this.connection = connection; this.readyPromise = new Promise((resolve) => { this.resolveReady = resolve; }); this.client = new GatewayClient({ - url: resolved.url, - token: resolved.token, - password: resolved.password, + url: connection.url, + token: connection.token, + password: connection.password, clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, clientDisplayName: "openclaw-tui", clientVersion: VERSION, @@ -158,6 +184,11 @@ export class GatewayChatClient { }); } + static async connect(opts: GatewayConnectionOptions): Promise { + const connection = await resolveGatewayConnection(opts); + return new GatewayChatClient(connection); + } + start() { this.client.start(); } @@ -234,11 +265,16 @@ export class GatewayChatClient { } } -export function resolveGatewayConnection(opts: GatewayConnectionOptions) { +export async function resolveGatewayConnection( + opts: GatewayConnectionOptions, +): Promise { const config = loadConfig(); + const env = process.env; + const gatewayAuthMode = config.gateway?.auth?.mode; const isRemoteMode = config.gateway?.mode === "remote"; - const remote = isRemoteMode ? config.gateway?.remote : undefined; - const authToken = config.gateway?.auth?.token; + const remote = config.gateway?.remote; + const envToken = trimToUndefined(env.OPENCLAW_GATEWAY_TOKEN); + const envPassword = trimToUndefined(env.OPENCLAW_GATEWAY_PASSWORD); const urlOverride = typeof opts.url === "string" && opts.url.trim().length > 0 ? opts.url.trim() : undefined; @@ -254,27 +290,152 @@ export function resolveGatewayConnection(opts: GatewayConnectionOptions) { ...(urlOverride ? { url: urlOverride } : {}), }).url; - const token = - explicitAuth.token || - (!urlOverride - ? isRemoteMode - ? typeof remote?.token === "string" && remote.token.trim().length > 0 - ? remote.token.trim() - : undefined - : process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || - (typeof authToken === "string" && authToken.trim().length > 0 - ? authToken.trim() - : undefined) - : undefined); + if (urlOverride) { + return { + url, + token: explicitAuth.token, + password: explicitAuth.password, + }; + } - const password = - explicitAuth.password || - (!urlOverride - ? process.env.OPENCLAW_GATEWAY_PASSWORD?.trim() || - (typeof remote?.password === "string" && remote.password.trim().length > 0 - ? remote.password.trim() - : undefined) - : undefined); + if (isRemoteMode) { + const remoteToken = explicitAuth.token + ? { value: explicitAuth.token } + : await resolveConfiguredSecretInputString({ + value: remote?.token, + path: "gateway.remote.token", + env, + config, + }); + const remotePassword = + explicitAuth.password || envPassword + ? { value: explicitAuth.password ?? envPassword } + : await resolveConfiguredSecretInputString({ + value: remote?.password, + path: "gateway.remote.password", + env, + config, + }); - return { url, token, password }; + const token = explicitAuth.token ?? remoteToken.value; + const password = explicitAuth.password ?? envPassword ?? remotePassword.value; + if (!token && !password) { + throwGatewayAuthResolutionError( + remoteToken.unresolvedRefReason ?? + remotePassword.unresolvedRefReason ?? + "Missing gateway auth credentials.", + ); + } + return { url, token, password }; + } + + if (gatewayAuthMode === "none" || gatewayAuthMode === "trusted-proxy") { + return { + url, + token: explicitAuth.token ?? envToken, + password: explicitAuth.password ?? envPassword, + }; + } + + try { + assertExplicitGatewayAuthModeWhenBothConfigured(config); + } catch (err) { + throwGatewayAuthResolutionError(err instanceof Error ? err.message : String(err)); + } + + const defaults = config.secrets?.defaults; + const hasConfiguredToken = hasConfiguredSecretInput(config.gateway?.auth?.token, defaults); + const hasConfiguredPassword = hasConfiguredSecretInput(config.gateway?.auth?.password, defaults); + if (gatewayAuthMode === "password") { + const localPassword = + explicitAuth.password || envPassword + ? { value: explicitAuth.password ?? envPassword } + : await resolveConfiguredSecretInputString({ + value: config.gateway?.auth?.password, + path: "gateway.auth.password", + env, + config, + }); + const password = explicitAuth.password ?? envPassword ?? localPassword.value; + if (!password) { + throwGatewayAuthResolutionError( + localPassword.unresolvedRefReason ?? "Missing gateway auth password.", + ); + } + return { + url, + token: explicitAuth.token ?? envToken, + password, + }; + } + + if (gatewayAuthMode === "token") { + const localToken = + explicitAuth.token || envToken + ? { value: explicitAuth.token ?? envToken } + : await resolveConfiguredSecretInputString({ + value: config.gateway?.auth?.token, + path: "gateway.auth.token", + env, + config, + }); + const token = explicitAuth.token ?? envToken ?? localToken.value; + if (!token) { + throwGatewayAuthResolutionError( + localToken.unresolvedRefReason ?? "Missing gateway auth token.", + ); + } + return { + url, + token, + password: explicitAuth.password ?? envPassword, + }; + } + + const passwordCandidate = explicitAuth.password ?? envPassword; + const shouldUsePassword = + Boolean(passwordCandidate) || (hasConfiguredPassword && !hasConfiguredToken); + + if (shouldUsePassword) { + const localPassword = passwordCandidate + ? { value: passwordCandidate } + : await resolveConfiguredSecretInputString({ + value: config.gateway?.auth?.password, + path: "gateway.auth.password", + env, + config, + }); + const password = passwordCandidate ?? localPassword.value; + if (!password) { + throwGatewayAuthResolutionError( + localPassword.unresolvedRefReason ?? "Missing gateway auth password.", + ); + } + return { + url, + token: explicitAuth.token ?? envToken, + password, + }; + } + + const localToken = + explicitAuth.token || envToken + ? { value: explicitAuth.token ?? envToken } + : await resolveConfiguredSecretInputString({ + value: config.gateway?.auth?.token, + path: "gateway.auth.token", + env, + config, + }); + const token = explicitAuth.token ?? envToken ?? localToken.value; + if (!token) { + throwGatewayAuthResolutionError( + localToken.unresolvedRefReason ?? "Missing gateway auth token.", + ); + } + return { + url, + token, + password: explicitAuth.password ?? envPassword, + }; } diff --git a/src/tui/tui.ts b/src/tui/tui.ts index fe365477d..0dd24a95a 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -471,7 +471,7 @@ export async function runTui(opts: TuiOptions) { localRunIds.clear(); }; - const client = new GatewayChatClient({ + const client = await GatewayChatClient.connect({ url: opts.url, token: opts.token, password: opts.password, diff --git a/src/wizard/onboarding.finalize.test.ts b/src/wizard/onboarding.finalize.test.ts index 92ff9e1dd..ea7f6ce23 100644 --- a/src/wizard/onboarding.finalize.test.ts +++ b/src/wizard/onboarding.finalize.test.ts @@ -5,6 +5,22 @@ import type { RuntimeEnv } from "../runtime.js"; const runTui = vi.hoisted(() => vi.fn(async () => {})); const probeGatewayReachable = vi.hoisted(() => vi.fn(async () => ({ ok: true }))); const setupOnboardingShellCompletion = vi.hoisted(() => vi.fn(async () => {})); +const buildGatewayInstallPlan = vi.hoisted(() => + vi.fn(async () => ({ + programArguments: [], + workingDirectory: "/tmp", + environment: {}, + })), +); +const gatewayServiceInstall = vi.hoisted(() => vi.fn(async () => {})); +const resolveGatewayInstallToken = vi.hoisted(() => + vi.fn(async () => ({ + token: undefined, + tokenRefConfigured: true, + warnings: [], + })), +); +const isSystemdUserServiceAvailable = vi.hoisted(() => vi.fn(async () => true)); vi.mock("../commands/onboard-helpers.js", () => ({ detectBrowserOpenSupport: vi.fn(async () => ({ ok: false })), @@ -19,14 +35,14 @@ vi.mock("../commands/onboard-helpers.js", () => ({ })); vi.mock("../commands/daemon-install-helpers.js", () => ({ - buildGatewayInstallPlan: vi.fn(async () => ({ - programArguments: [], - workingDirectory: "/tmp", - environment: {}, - })), + buildGatewayInstallPlan, gatewayInstallErrorHint: vi.fn(() => "hint"), })); +vi.mock("../commands/gateway-install-token.js", () => ({ + resolveGatewayInstallToken, +})); + vi.mock("../commands/daemon-runtime.js", () => ({ DEFAULT_GATEWAY_DAEMON_RUNTIME: "node", GATEWAY_DAEMON_RUNTIME_OPTIONS: [{ value: "node", label: "Node" }], @@ -45,13 +61,17 @@ vi.mock("../daemon/service.js", () => ({ isLoaded: vi.fn(async () => false), restart: vi.fn(async () => {}), uninstall: vi.fn(async () => {}), - install: vi.fn(async () => {}), + install: gatewayServiceInstall, })), })); -vi.mock("../daemon/systemd.js", () => ({ - isSystemdUserServiceAvailable: vi.fn(async () => false), -})); +vi.mock("../daemon/systemd.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isSystemdUserServiceAvailable, + }; +}); vi.mock("../infra/control-ui-assets.js", () => ({ ensureControlUiAssetsBuilt: vi.fn(async () => ({ ok: true })), @@ -84,6 +104,11 @@ describe("finalizeOnboardingWizard", () => { runTui.mockClear(); probeGatewayReachable.mockClear(); setupOnboardingShellCompletion.mockClear(); + buildGatewayInstallPlan.mockClear(); + gatewayServiceInstall.mockClear(); + resolveGatewayInstallToken.mockClear(); + isSystemdUserServiceAvailable.mockReset(); + isSystemdUserServiceAvailable.mockResolvedValue(true); }); it("resolves gateway password SecretRef for probe and TUI", async () => { @@ -164,4 +189,55 @@ describe("finalizeOnboardingWizard", () => { }), ); }); + + it("does not persist resolved SecretRef token in daemon install plan", async () => { + const prompter = buildWizardPrompter({ + select: vi.fn(async () => "later") as never, + confirm: vi.fn(async () => false), + }); + const runtime = createRuntime(); + + await finalizeOnboardingWizard({ + flow: "advanced", + opts: { + acceptRisk: true, + authChoice: "skip", + installDaemon: true, + skipHealth: true, + skipUi: true, + }, + baseConfig: {}, + nextConfig: { + gateway: { + auth: { + mode: "token", + token: { + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_TOKEN", + }, + }, + }, + }, + workspaceDir: "/tmp", + settings: { + port: 18789, + bind: "loopback", + authMode: "token", + gatewayToken: "session-token", + tailscaleMode: "off", + tailscaleResetOnExit: false, + }, + prompter, + runtime, + }); + + expect(resolveGatewayInstallToken).toHaveBeenCalledTimes(1); + expect(buildGatewayInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ + token: undefined, + }), + ); + expect(gatewayServiceInstall).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts index fb2711052..62f452de3 100644 --- a/src/wizard/onboarding.finalize.ts +++ b/src/wizard/onboarding.finalize.ts @@ -10,6 +10,7 @@ import { DEFAULT_GATEWAY_DAEMON_RUNTIME, GATEWAY_DAEMON_RUNTIME_OPTIONS, } from "../commands/daemon-runtime.js"; +import { resolveGatewayInstallToken } from "../commands/gateway-install-token.js"; import { formatHealthCheckFailure } from "../commands/health-format.js"; import { healthCommand } from "../commands/health.js"; import { @@ -165,23 +166,40 @@ export async function finalizeOnboardingWizard( let installError: string | null = null; try { progress.update("Preparing Gateway service…"); - const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({ - env: process.env, - port: settings.port, - token: settings.gatewayToken, - runtime: daemonRuntime, - warn: (message, title) => prompter.note(message, title), + const tokenResolution = await resolveGatewayInstallToken({ config: nextConfig, - }); - - progress.update("Installing Gateway service…"); - await service.install({ env: process.env, - stdout: process.stdout, - programArguments, - workingDirectory, - environment, }); + for (const warning of tokenResolution.warnings) { + await prompter.note(warning, "Gateway service"); + } + if (tokenResolution.unavailableReason) { + installError = [ + "Gateway install blocked:", + tokenResolution.unavailableReason, + "Fix gateway auth config/token input and rerun onboarding.", + ].join(" "); + } else { + const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan( + { + env: process.env, + port: settings.port, + token: tokenResolution.token, + runtime: daemonRuntime, + warn: (message, title) => prompter.note(message, title), + config: nextConfig, + }, + ); + + progress.update("Installing Gateway service…"); + await service.install({ + env: process.env, + stdout: process.stdout, + programArguments, + workingDirectory, + environment, + }); + } } catch (err) { installError = err instanceof Error ? err.message : String(err); } finally { diff --git a/src/wizard/onboarding.gateway-config.test.ts b/src/wizard/onboarding.gateway-config.test.ts index 35635d4af..bdde68f1c 100644 --- a/src/wizard/onboarding.gateway-config.test.ts +++ b/src/wizard/onboarding.gateway-config.test.ts @@ -28,9 +28,13 @@ describe("configureGatewayForOnboarding", () => { function createPrompter(params: { selectQueue: string[]; textQueue: Array }) { const selectQueue = [...params.selectQueue]; const textQueue = [...params.textQueue]; - const select = vi.fn( - async (_params: WizardSelectParams) => selectQueue.shift() as unknown, - ) as unknown as WizardPrompter["select"]; + const select = vi.fn(async (params: WizardSelectParams) => { + const next = selectQueue.shift(); + if (next !== undefined) { + return next; + } + return params.initialValue ?? params.options[0]?.value; + }) as unknown as WizardPrompter["select"]; return buildWizardPrompter({ select, @@ -174,4 +178,85 @@ describe("configureGatewayForOnboarding", () => { } } }); + + it("stores gateway token as SecretRef when secretInputMode=ref", async () => { + const previous = process.env.OPENCLAW_GATEWAY_TOKEN; + process.env.OPENCLAW_GATEWAY_TOKEN = "token-from-env"; + try { + const prompter = createPrompter({ + selectQueue: ["loopback", "token", "off", "env"], + textQueue: ["18789", "OPENCLAW_GATEWAY_TOKEN"], + }); + const runtime = createRuntime(); + + const result = await configureGatewayForOnboarding({ + flow: "advanced", + baseConfig: {}, + nextConfig: {}, + localPort: 18789, + quickstartGateway: createQuickstartGateway("token"), + secretInputMode: "ref", + prompter, + runtime, + }); + + expect(result.nextConfig.gateway?.auth?.mode).toBe("token"); + expect(result.nextConfig.gateway?.auth?.token).toEqual({ + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_TOKEN", + }); + expect(result.settings.gatewayToken).toBe("token-from-env"); + } finally { + if (previous === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = previous; + } + } + }); + + it("resolves quickstart exec SecretRefs for gateway token bootstrap", async () => { + const quickstartGateway = { + ...createQuickstartGateway("token"), + token: { + source: "exec" as const, + provider: "gatewayTokens", + id: "gateway/auth/token", + }, + }; + const runtime = createRuntime(); + const prompter = createPrompter({ + selectQueue: [], + textQueue: [], + }); + + const result = await configureGatewayForOnboarding({ + flow: "quickstart", + baseConfig: {}, + nextConfig: { + secrets: { + providers: { + gatewayTokens: { + source: "exec", + command: process.execPath, + allowInsecurePath: true, + allowSymlinkCommand: true, + args: [ + "-e", + "let input='';process.stdin.setEncoding('utf8');process.stdin.on('data',d=>input+=d);process.stdin.on('end',()=>{const req=JSON.parse(input||'{}');const values={};for(const id of req.ids||[]){values[id]='token-from-exec';}process.stdout.write(JSON.stringify({protocolVersion:1,values}));});", + ], + }, + }, + }, + }, + localPort: 18789, + quickstartGateway, + prompter, + runtime, + }); + + expect(result.nextConfig.gateway?.auth?.token).toEqual(quickstartGateway.token); + expect(result.settings.gatewayToken).toBe("token-from-exec"); + }); }); diff --git a/src/wizard/onboarding.gateway-config.ts b/src/wizard/onboarding.gateway-config.ts index 50bf8d361..a1f5dfee6 100644 --- a/src/wizard/onboarding.gateway-config.ts +++ b/src/wizard/onboarding.gateway-config.ts @@ -10,7 +10,11 @@ import { import type { GatewayAuthChoice, SecretInputMode } from "../commands/onboard-types.js"; import type { GatewayBindMode, GatewayTailscaleMode, OpenClawConfig } from "../config/config.js"; import { ensureControlUiAllowedOriginsForNonLoopbackBind } from "../config/gateway-control-ui-origins.js"; -import type { SecretInput } from "../config/types.secrets.js"; +import { + normalizeSecretInputString, + resolveSecretInputRef, + type SecretInput, +} from "../config/types.secrets.js"; import { maybeAddTailnetOriginToControlUiAllowedOrigins, TAILSCALE_DOCS_LINES, @@ -21,6 +25,7 @@ import { DEFAULT_DANGEROUS_NODE_COMMANDS } from "../gateway/node-command-policy. import { findTailscaleBinary } from "../infra/tailscale.js"; import type { RuntimeEnv } from "../runtime.js"; import { validateIPv4AddressInput } from "../shared/net/ipv4.js"; +import { resolveOnboardingSecretInputString } from "./onboarding.secret-input.js"; import type { GatewayWizardSettings, QuickstartGatewayDefaults, @@ -152,22 +157,68 @@ export async function configureGatewayForOnboarding( } let gatewayToken: string | undefined; + let gatewayTokenInput: SecretInput | undefined; if (authMode === "token") { - if (flow === "quickstart") { + const quickstartTokenString = normalizeSecretInputString(quickstartGateway.token); + const quickstartTokenRef = resolveSecretInputRef({ + value: quickstartGateway.token, + defaults: nextConfig.secrets?.defaults, + }).ref; + const tokenMode = + flow === "quickstart" && opts.secretInputMode !== "ref" + ? quickstartTokenRef + ? "ref" + : "plaintext" + : await resolveSecretInputModeForEnvSelection({ + prompter, + explicitMode: opts.secretInputMode, + copy: { + modeMessage: "How do you want to provide the gateway token?", + plaintextLabel: "Generate/store plaintext token", + plaintextHint: "Default", + refLabel: "Use SecretRef", + refHint: "Store a reference instead of plaintext", + }, + }); + if (tokenMode === "ref") { + if (flow === "quickstart" && quickstartTokenRef) { + gatewayTokenInput = quickstartTokenRef; + gatewayToken = await resolveOnboardingSecretInputString({ + config: nextConfig, + value: quickstartTokenRef, + path: "gateway.auth.token", + env: process.env, + }); + } else { + const resolved = await promptSecretRefForOnboarding({ + provider: "gateway-auth-token", + config: nextConfig, + prompter, + preferredEnvVar: "OPENCLAW_GATEWAY_TOKEN", + copy: { + sourceMessage: "Where is this gateway token stored?", + envVarPlaceholder: "OPENCLAW_GATEWAY_TOKEN", + }, + }); + gatewayTokenInput = resolved.ref; + gatewayToken = resolved.resolvedValue; + } + } else if (flow === "quickstart") { gatewayToken = - (quickstartGateway.token ?? - normalizeGatewayTokenInput(process.env.OPENCLAW_GATEWAY_TOKEN)) || + (quickstartTokenString ?? normalizeGatewayTokenInput(process.env.OPENCLAW_GATEWAY_TOKEN)) || randomToken(); + gatewayTokenInput = gatewayToken; } else { const tokenInput = await prompter.text({ message: "Gateway token (blank to generate)", placeholder: "Needed for multi-machine or non-loopback access", initialValue: - quickstartGateway.token ?? + quickstartTokenString ?? normalizeGatewayTokenInput(process.env.OPENCLAW_GATEWAY_TOKEN) ?? "", }); gatewayToken = normalizeGatewayTokenInput(tokenInput) || randomToken(); + gatewayTokenInput = gatewayToken; } } @@ -224,7 +275,7 @@ export async function configureGatewayForOnboarding( auth: { ...nextConfig.gateway?.auth, mode: "token", - token: gatewayToken, + token: gatewayTokenInput, }, }, }; diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 58e0615a6..923bc5d7d 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -281,9 +281,28 @@ export async function runOnboardingWizard( const localPort = resolveGatewayPort(baseConfig); const localUrl = `ws://127.0.0.1:${localPort}`; + let localGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN ?? process.env.CLAWDBOT_GATEWAY_TOKEN; + try { + const resolvedGatewayToken = await resolveOnboardingSecretInputString({ + config: baseConfig, + value: baseConfig.gateway?.auth?.token, + path: "gateway.auth.token", + env: process.env, + }); + if (resolvedGatewayToken) { + localGatewayToken = resolvedGatewayToken; + } + } catch (error) { + await prompter.note( + [ + "Could not resolve gateway.auth.token SecretRef for onboarding probe.", + error instanceof Error ? error.message : String(error), + ].join("\n"), + "Gateway auth", + ); + } let localGatewayPassword = - process.env.OPENCLAW_GATEWAY_PASSWORD ?? - normalizeSecretInputString(baseConfig.gateway?.auth?.password); + process.env.OPENCLAW_GATEWAY_PASSWORD ?? process.env.CLAWDBOT_GATEWAY_PASSWORD; try { const resolvedGatewayPassword = await resolveOnboardingSecretInputString({ config: baseConfig, @@ -306,14 +325,34 @@ export async function runOnboardingWizard( const localProbe = await onboardHelpers.probeGatewayReachable({ url: localUrl, - token: baseConfig.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN, + token: localGatewayToken, password: localGatewayPassword, }); const remoteUrl = baseConfig.gateway?.remote?.url?.trim() ?? ""; + let remoteGatewayToken = normalizeSecretInputString(baseConfig.gateway?.remote?.token); + try { + const resolvedRemoteGatewayToken = await resolveOnboardingSecretInputString({ + config: baseConfig, + value: baseConfig.gateway?.remote?.token, + path: "gateway.remote.token", + env: process.env, + }); + if (resolvedRemoteGatewayToken) { + remoteGatewayToken = resolvedRemoteGatewayToken; + } + } catch (error) { + await prompter.note( + [ + "Could not resolve gateway.remote.token SecretRef for onboarding probe.", + error instanceof Error ? error.message : String(error), + ].join("\n"), + "Gateway auth", + ); + } const remoteProbe = remoteUrl ? await onboardHelpers.probeGatewayReachable({ url: remoteUrl, - token: normalizeSecretInputString(baseConfig.gateway?.remote?.token), + token: remoteGatewayToken, }) : null; diff --git a/src/wizard/onboarding.types.ts b/src/wizard/onboarding.types.ts index 3ab4575d1..85fba7c53 100644 --- a/src/wizard/onboarding.types.ts +++ b/src/wizard/onboarding.types.ts @@ -9,7 +9,7 @@ export type QuickstartGatewayDefaults = { bind: "loopback" | "lan" | "auto" | "custom" | "tailnet"; authMode: GatewayAuthChoice; tailscaleMode: "off" | "serve" | "funnel"; - token?: string; + token?: SecretInput; password?: SecretInput; customBindHost?: string; tailscaleResetOnExit: boolean;