Gateway: add SecretRef support for gateway.auth.token with auth-mode guardrails (#35094)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -38,6 +38,13 @@ openclaw daemon uninstall
|
||||
- `install`: `--port`, `--runtime <node|bun>`, `--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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -359,6 +359,7 @@ Options:
|
||||
- `--gateway-bind <loopback|lan|tailnet|auto|custom>`
|
||||
- `--gateway-auth <token|password>`
|
||||
- `--gateway-token <token>`
|
||||
- `--gateway-token-ref-env <name>` (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 <password>`
|
||||
- `--remote-url <url>`
|
||||
- `--remote-token <token>`
|
||||
|
||||
@@ -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 <token>` stores a plaintext token.
|
||||
- `--gateway-auth token --gateway-token-ref-env <name>` 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.
|
||||
|
||||
@@ -35,7 +35,10 @@ openclaw qr --url wss://gateway.example/ws --token '<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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -71,6 +71,15 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard).
|
||||
<Step title="Gateway">
|
||||
- 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 <ENV_VAR>`.
|
||||
- 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.
|
||||
</Step>
|
||||
@@ -92,6 +101,9 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard).
|
||||
- Wizard attempts to enable lingering via `loginctl enable-linger <user>` 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.
|
||||
</Step>
|
||||
<Step title="Health check">
|
||||
- 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.
|
||||
|
||||
<Note>
|
||||
`--json` does **not** imply non-interactive mode. Use `--non-interactive` (and `--workspace`) for scripts.
|
||||
</Note>
|
||||
|
||||
@@ -51,6 +51,13 @@ It does not install or modify anything on the remote host.
|
||||
<Step title="Gateway">
|
||||
- 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 <ENV_VAR>`.
|
||||
- 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.
|
||||
</Step>
|
||||
@@ -206,7 +213,7 @@ Credential and profile paths:
|
||||
- OAuth credentials: `~/.openclaw/credentials/oauth.json`
|
||||
- Auth profiles (API keys + OAuth): `~/.openclaw/agents/<agentId>/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.<id>.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 <ENV_VAR>`.
|
||||
- Existing plaintext setups continue to work unchanged.
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -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 <ENV_VAR>`.
|
||||
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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
117
src/browser/extension-relay-auth.secretref.test.ts
Normal file
117
src/browser/extension-relay-auth.secretref.test.ts
Normal file
@@ -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<string, string | undefined>();
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<string[]> {
|
||||
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<string> {
|
||||
return (await resolveRelayAcceptedTokensForPort(port))[0];
|
||||
}
|
||||
|
||||
export async function probeAuthenticatedOpenClawRelay(params: {
|
||||
|
||||
@@ -249,8 +249,8 @@ export async function ensureChromeExtensionRelayServer(opts: {
|
||||
);
|
||||
|
||||
const initPromise = (async (): Promise<ChromeExtensionRelayServer> => {
|
||||
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<WebSocket>();
|
||||
|
||||
147
src/cli/daemon-cli/install.integration.test.ts
Normal file
147
src/cli/daemon-cli/install.integration.test.ts
Normal file
@@ -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<string, string | undefined> }) => {}),
|
||||
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<Record<string, unknown>> {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("runDaemonInstall integration", () => {
|
||||
let envSnapshot: ReturnType<typeof captureEnv>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
249
src/cli/daemon-cli/install.test.ts
Normal file
249
src/cli/daemon-cli/install.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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, string | undefined>): string |
|
||||
return trimToUndefined(env.OPENCLAW_GATEWAY_TOKEN) ?? trimToUndefined(env.CLAWDBOT_GATEWAY_TOKEN);
|
||||
}
|
||||
|
||||
function readGatewayPasswordEnv(env: Record<string, string | undefined>): string | undefined {
|
||||
return (
|
||||
trimToUndefined(env.OPENCLAW_GATEWAY_PASSWORD) ?? trimToUndefined(env.CLAWDBOT_GATEWAY_PASSWORD)
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveDaemonProbeToken(params: {
|
||||
daemonCfg: OpenClawConfig;
|
||||
mergedDaemonEnv: Record<string, string | undefined>;
|
||||
explicitToken?: string;
|
||||
explicitPassword?: string;
|
||||
}): Promise<string | undefined> {
|
||||
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<string, string | undefined>;
|
||||
@@ -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
|
||||
|
||||
@@ -17,24 +17,45 @@ const ensureDevGatewayConfig = vi.fn(async (_opts?: unknown) => {});
|
||||
const runGatewayLoop = vi.fn(async ({ start }: { start: () => Promise<unknown> }) => {
|
||||
await start();
|
||||
});
|
||||
const configState = vi.hoisted(() => ({
|
||||
cfg: {} as Record<string, unknown>,
|
||||
snapshot: { exists: false } as Record<string, unknown>,
|
||||
}));
|
||||
|
||||
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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -104,6 +104,10 @@ export function registerOnboardCommand(program: Command) {
|
||||
.option("--gateway-bind <mode>", "Gateway bind: loopback|tailnet|lan|auto|custom")
|
||||
.option("--gateway-auth <mode>", "Gateway auth: token|password")
|
||||
.option("--gateway-token <token>", "Gateway token (token auth)")
|
||||
.option(
|
||||
"--gateway-token-ref-env <name>",
|
||||
"Gateway token SecretRef env var name (token auth; e.g. OPENCLAW_GATEWAY_TOKEN)",
|
||||
)
|
||||
.option("--gateway-password <password>", "Gateway password (password auth)")
|
||||
.option("--remote-url <url>", "Remote Gateway WebSocket URL")
|
||||
.option("--remote-token <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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
168
src/cli/qr-dashboard.integration.test.ts
Normal file
168
src/cli/qr-dashboard.integration.test.ts
Normal file
@@ -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<typeof import("../config/config.js")>();
|
||||
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<void> {
|
||||
const program = new Command();
|
||||
registerQrCli(program);
|
||||
registerMaintenanceCommands(program);
|
||||
await program.parseAsync(args, { from: "user" });
|
||||
}
|
||||
|
||||
describe("cli integration: qr + dashboard token SecretRef", () => {
|
||||
let envSnapshot: ReturnType<typeof captureEnv>;
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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}".`,
|
||||
|
||||
110
src/commands/configure.daemon.test.ts
Normal file
110
src/commands/configure.daemon.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<GatewayTokenInputMode>({
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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<string | undefined> {
|
||||
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,
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
226
src/commands/doctor-gateway-auth-token.test.ts
Normal file
226
src/commands/doctor-gateway-auth-token.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
54
src/commands/doctor-gateway-auth-token.ts
Normal file
54
src/commands/doctor-gateway-auth-token.ts
Normal file
@@ -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) };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -45,13 +45,11 @@ async function launchctlGetenv(name: string): Promise<string | undefined> {
|
||||
}
|
||||
|
||||
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),
|
||||
|
||||
@@ -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: " " } },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
283
src/commands/gateway-install-token.test.ts
Normal file
283
src/commands/gateway-install-token.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
147
src/commands/gateway-install-token.ts
Normal file
147
src/commands/gateway-install-token.ts
Normal file
@@ -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<GatewayInstallTokenResolution> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<typeof loadConfig>);
|
||||
|
||||
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<typeof loadConfig>);
|
||||
|
||||
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<typeof loadConfig>);
|
||||
|
||||
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<typeof loadConfig>);
|
||||
|
||||
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<Record<string, unknown>>;
|
||||
};
|
||||
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();
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
235
src/commands/gateway-status/helpers.test.ts
Normal file
235
src/commands/gateway-status/helpers.test.ts
Normal file
@@ -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");
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string | undefined> => {
|
||||
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<string | undefined> => {
|
||||
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<string, unknown>;
|
||||
const gateway = (cfg.gateway ?? {}) as Record<string, unknown>;
|
||||
const secrets = (cfg.secrets ?? {}) as Record<string, unknown>;
|
||||
const secretDefaults = (secrets.defaults ?? undefined) as
|
||||
| { env?: string; file?: string; exec?: string }
|
||||
| undefined;
|
||||
const discovery = (cfg.discovery ?? {}) as Record<string, unknown>;
|
||||
const wideArea = (discovery.wideArea ?? {}) as Record<string, unknown>;
|
||||
|
||||
@@ -200,15 +292,12 @@ export function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSum
|
||||
const tailscale = (gateway.tailscale ?? {}) as Record<string, unknown>;
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -92,7 +92,6 @@ export async function runNonInteractiveOnboardingLocal(params: {
|
||||
opts,
|
||||
runtime,
|
||||
port: gatewayResult.port,
|
||||
gatewayToken: gatewayResult.gatewayToken,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -144,6 +144,7 @@ export type OnboardOptions = {
|
||||
gatewayBind?: GatewayBind;
|
||||
gatewayAuth?: GatewayAuthChoice;
|
||||
gatewayToken?: string;
|
||||
gatewayTokenRefEnv?: string;
|
||||
gatewayPassword?: string;
|
||||
tailscale?: TailscaleMode;
|
||||
tailscaleResetOnExit?: boolean;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<typeof loadConfig>): {
|
||||
token?: string;
|
||||
password?: string;
|
||||
export function resolveGatewayProbeAuthResolution(cfg: ReturnType<typeof loadConfig>): {
|
||||
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<typeof loadConfig>): {
|
||||
token?: string;
|
||||
password?: string;
|
||||
} {
|
||||
return resolveGatewayProbeAuthResolution(cfg).auth;
|
||||
}
|
||||
|
||||
@@ -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<typeof buildGatewayConnectionDetails>;
|
||||
remoteUrlMissing: boolean;
|
||||
gatewayMode: "local" | "remote";
|
||||
gatewayProbeAuth: {
|
||||
token?: string;
|
||||
password?: string;
|
||||
};
|
||||
gatewayProbeAuthWarning?: string;
|
||||
gatewayProbe: Awaited<ReturnType<typeof probeGateway>> | 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<typeof buildGatewayConnectionDetails>;
|
||||
remoteUrlMissing: boolean;
|
||||
gatewayMode: "local" | "remote";
|
||||
gatewayProbeAuth: {
|
||||
token?: string;
|
||||
password?: string;
|
||||
};
|
||||
gatewayProbeAuthWarning?: string;
|
||||
gatewayProbe: Awaited<ReturnType<typeof probeGateway>> | null;
|
||||
gatewayReachable: boolean;
|
||||
gatewaySelf: ReturnType<typeof pickGatewaySelfPresence>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof captureEnv>;
|
||||
@@ -146,6 +146,7 @@ async function withEnvVar<T>(key: string, value: string, run: () => Promise<T>):
|
||||
}
|
||||
|
||||
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<typeof import("../config/config.js")>();
|
||||
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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
37
src/gateway/auth-install-policy.ts
Normal file
37
src/gateway/auth-install-policy.ts
Normal file
@@ -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;
|
||||
}
|
||||
76
src/gateway/auth-mode-policy.test.ts
Normal file
76
src/gateway/auth-mode-policy.test.ts
Normal file
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
26
src/gateway/auth-mode-policy.ts
Normal file
26
src/gateway/auth-mode-policy.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>): 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 };
|
||||
}
|
||||
|
||||
81
src/gateway/probe-auth.test.ts
Normal file
81
src/gateway/probe-auth.test.ts
Normal file
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
89
src/gateway/resolve-configured-secret-input-string.ts
Normal file
89
src/gateway/resolve-configured-secret-input-string.ts
Normal file
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<GatewayServerOptions, "auth" | "tailscale">,
|
||||
): 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<void>;
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<string | undefined> {
|
||||
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<OpenClawConfig> {
|
||||
): Promise<string | undefined> {
|
||||
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,
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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<OpenClawConfig> {
|
||||
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<PairingSetupResolution> {
|
||||
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 };
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<string, unknown> | 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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user