From df2cc8f2598a2a93c194efcccfa8d49a48b489c1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 20 Aug 2026 17:46:59 -0700 Subject: [PATCH] fix(onboard): honor secret-input-mode ref for the generated gateway token (#126877) * fix(onboard): honor secret-input-mode ref for the generated gateway token `openclaw onboard --secret-input-mode ref` was silently ignored for `gateway.auth.token`: onboarding generated the token and wrote it into `openclaw.json` as a plaintext string, so `openclaw doctor` warned about `gateway.auth.token` on the install it had just created. The flag was honored for provider credentials, so an operator who explicitly opted into references still ended up with a plaintext secret and a remediation (`openclaw secrets configure`) that cannot migrate a self-generated value, because it validates a ref by resolving one that already exists. Setup mints this token itself, so reference mode now provisions it: - an ambient OPENCLAW_GATEWAY_TOKEN keeps an `env` ref to that variable, so a later rotation stays authoritative instead of being pinned by a stale copy - anything else (freshly generated, or an existing plaintext token being migrated) goes into the shared SQLite secret store as a write-only `secret` entry, with config holding only `{source:"store",...}` An existing store entry wins over a freshly generated one, so reruns never rotate a token already paired with clients. The store write precedes the config write: a ref persisted without its value would leave the gateway unauthenticatable, while an orphaned entry is reused by the next run. The interactive wizard had the same dead end and is fixed the same way. Default (plaintext) onboarding is unchanged. User impact: `--secret-input-mode ref` now keeps the gateway token out of openclaw.json, and a fresh install no longer self-reports a plaintext-secret warning. * test(onboard): split gateway onboarding suite under the max-lines gate The added gateway auth-token tests pushed onboard-non-interactive.gateway.test.ts to 1014 lines, over the max-lines limit (check-lint-core-3). Repo policy is to split, never suppress. Extract the shared vi.mock/harness preamble into onboard-non-interactive.gateway.test-mocks.ts, following the existing agent-command.test-mocks.ts pattern, and move the four gateway auth-token storage tests into their own suite. The reachability mock becomes a holder object so both suites can swap it across the module boundary, and hoisted mocks are re-exported in a separate export clause because Vitest rejects exporting a vi.hoisted binding at its declaration. Test set is unchanged: the it-declaration multiset matches the pre-split file exactly, with no duplication across the two suites. * test(onboard): give the shared gateway onboarding mocks unique export names check-export-name-collisions flagged `runtime` and `readConfigFileSnapshotMock` as colliding with program.test-mocks.ts and plugins-cli-test-helpers.ts once the gateway onboarding preamble became a shared module. Rename the exports to gatewayOnboardRuntime / gatewayOnboardConfigSnapshotMock per the repo's unique-export-name rule; suites alias them locally so the assertions read the same as before. * test(tooling): route the new gateway auth-token suite from its test helper test-projects asserts which suites a change to onboard-non-interactive.test-helpers.ts should run. The new onboard-non-interactive.gateway-auth-token.test.ts imports that helper, so it belongs in the expected routing plan. --- docs/gateway/secrets.md | 1 + docs/start/wizard-cli-automation.md | 1 + ...non-interactive.gateway-auth-token.test.ts | 233 ++++++++++++ ...oard-non-interactive.gateway.test-mocks.ts | 219 ++++++++++++ .../onboard-non-interactive.gateway.test.ts | 338 ++---------------- .../local/gateway-config.ts | 64 +++- src/gateway/auth-token-store-ref.test.ts | 78 ++++ src/gateway/auth-token-store-ref.ts | 60 ++++ src/wizard/i18n/locales/en.ts | 2 + src/wizard/i18n/locales/zh-CN.ts | 2 + src/wizard/i18n/locales/zh-TW.ts | 2 + src/wizard/setup.gateway-config.test.ts | 39 ++ src/wizard/setup.gateway-config.ts | 20 +- test/scripts/test-projects.test.ts | 5 +- 14 files changed, 740 insertions(+), 324 deletions(-) create mode 100644 src/commands/onboard-non-interactive.gateway-auth-token.test.ts create mode 100644 src/commands/onboard-non-interactive.gateway.test-mocks.ts create mode 100644 src/gateway/auth-token-store-ref.test.ts create mode 100644 src/gateway/auth-token-store-ref.ts diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 20dfce525da5..5034bac4091d 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -102,6 +102,7 @@ In interactive onboarding, choosing SecretRef storage runs preflight validation - Env refs: validates the env var name and confirms a non-empty value is visible during setup. - Provider refs (`file`, `exec`, or `store`): validates provider selection, resolves `id`, and checks the resolved value type. - Quickstart flow: when `gateway.auth.token` is already a SecretRef, onboarding resolves it before probe/dashboard bootstrap (for `env`, `file`, `exec`, and `store` refs) using the same fail-fast gate. +- Generated gateway token: setup mints `gateway.auth.token` itself, so reference mode has nothing to prompt for. With `OPENCLAW_GATEWAY_TOKEN` exported it writes an `env` ref to that variable, keeping a later rotation authoritative; otherwise it writes the token to the secret store under `OPENCLAW_GATEWAY_TOKEN` and stores a `store` ref. An existing store entry is reused rather than rotated, so re-running setup never invalidates already-paired clients. Validation failure shows the error and lets you retry. diff --git a/docs/start/wizard-cli-automation.md b/docs/start/wizard-cli-automation.md index 7cf86da9be80..b2c581009f05 100644 --- a/docs/start/wizard-cli-automation.md +++ b/docs/start/wizard-cli-automation.md @@ -35,6 +35,7 @@ Add `--json` for a machine-readable summary. - `--gateway-port` defaults to `18789`; only pass it to override. - `--skip-bootstrap` skips creating default workspace files, for automation that pre-seeds its own workspace. - `--secret-input-mode ref` stores new credentials as env-backed references (`{ source: "env", provider: "default", id: "" }`); set the provider env var when adding a credential or passing an inline key flag. Existing resolvable named profiles and their `env`, `file`, `exec`, or `store` references are reused unchanged, without a new credential write or additional provider env var. Existing plaintext is not migrated; run `openclaw secrets configure --apply`, then `openclaw secrets audit --check`. See [Secrets management](/gateway/secrets). +- The gateway token follows the same mode. Setup generates that value itself, so reference mode has no env var to point at unless you supply one: with `OPENCLAW_GATEWAY_TOKEN` exported, `gateway.auth.token` becomes an `env` ref to it; otherwise the token goes into the SQLite secret store as `OPENCLAW_GATEWAY_TOKEN` and config keeps a `store` ref. Either way `openclaw.json` holds no plaintext gateway token. Inspect the entry with `openclaw secrets store list`. ```bash openclaw onboard --non-interactive --accept-risk --skip-health \ diff --git a/src/commands/onboard-non-interactive.gateway-auth-token.test.ts b/src/commands/onboard-non-interactive.gateway-auth-token.test.ts new file mode 100644 index 000000000000..ae611ea4ce1d --- /dev/null +++ b/src/commands/onboard-non-interactive.gateway-auth-token.test.ts @@ -0,0 +1,233 @@ +// Gateway auth-token storage tests cover what onboarding persists at gateway.auth.token: +// plaintext by default, and env/store SecretRefs under --secret-input-mode ref. +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { makeTempWorkspace } from "../test-helpers/workspace.js"; +import { setTestEnvValue } from "../test-utils/env.js"; +import { + capturedReplaceConfigFileCalls, + configWritePluginLeaseDepths, + gatewayReachableState, + getPseudoPort, + loadGatewayOnboardModules, + readTestConfig, + resolveTestConfigPath, + runNonInteractiveSetup, + gatewayOnboardRuntime as runtime, + testConfigStore, +} from "./onboard-non-interactive.gateway.test-mocks.js"; +import { + createOnboardStateDirHarness, + prepareOnboardGatewayTestEnv, +} from "./onboard-non-interactive.test-helpers.js"; + +describe("onboard (non-interactive): gateway auth token storage", () => { + let envSnapshot: ReturnType; + let tempHome: string | undefined; + const { withStateDir } = createOnboardStateDirHarness(() => tempHome); + + beforeAll(async () => { + envSnapshot = prepareOnboardGatewayTestEnv(); + tempHome = await makeTempWorkspace("openclaw-onboard-auth-token-"); + setTestEnvValue("HOME", tempHome); + await loadGatewayOnboardModules(); + }); + + afterAll(async () => { + if (tempHome) { + await fs.rm(tempHome, { recursive: true, force: true }); + } + envSnapshot.restore(); + }); + + afterEach(() => { + gatewayReachableState.mock = undefined; + testConfigStore.clear(); + capturedReplaceConfigFileCalls.length = 0; + configWritePluginLeaseDepths.length = 0; + vi.clearAllMocks(); + }); + + it("writes gateway token auth into config", async () => { + await withStateDir("state-noninteractive-", async (stateDir) => { + const token = "tok_test_123"; + const workspace = path.join(stateDir, "openclaw"); + testConfigStore.set(resolveTestConfigPath(), { + gateway: { + bind: "lan", + auth: { mode: "password", password: "test-password" }, + tailscale: { mode: "serve" }, + }, + } as OpenClawConfig); + + await runNonInteractiveSetup( + { + nonInteractive: true, + mode: "local", + workspace, + authChoice: "skip", + skipSkills: true, + skipHealth: true, + installDaemon: false, + gatewayBind: "loopback", + gatewayAuth: "token", + gatewayToken: token, + tailscale: "off", + }, + runtime, + ); + + const cfg = readTestConfig() as { + gateway?: { + mode?: string; + bind?: string; + auth?: { mode?: string; token?: string }; + tailscale?: { mode?: string }; + }; + agents?: { defaults?: { workspace?: string } }; + tools?: { profile?: string }; + hooks?: { internal?: { entries?: Record } }; + }; + + expect(cfg?.agents?.defaults?.workspace).toBe(workspace); + expect(cfg?.gateway?.mode).toBe("local"); + expect(cfg?.gateway?.bind).toBe("loopback"); + expect(cfg?.tools?.profile).toBe("coding"); + expect(cfg?.gateway?.auth?.mode).toBe("token"); + expect(cfg?.gateway?.auth?.token).toBe(token); + expect(cfg?.gateway?.tailscale).toEqual({ mode: "off" }); + expect(cfg?.hooks?.internal?.entries?.["session-memory"]).toEqual({ enabled: true }); + }); + }, 60_000); + + it("auto-generates token auth when binding LAN and persists the token", async () => { + if (process.platform === "win32") { + // Windows runner occasionally drops the temp config write in this flow; skip to keep CI green. + return; + } + await withStateDir("state-lan-", async (stateDir) => { + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); + setTestEnvValue("OPENCLAW_CONFIG_PATH", path.join(stateDir, "openclaw.json")); + + const port = getPseudoPort(40_000); + const workspace = path.join(stateDir, "openclaw"); + + await runNonInteractiveSetup( + { + nonInteractive: true, + mode: "local", + workspace, + authChoice: "skip", + skipSkills: true, + skipHealth: true, + installDaemon: false, + gatewayPort: port, + gatewayBind: "lan", + }, + runtime, + ); + + const cfg = readTestConfig() as { + gateway?: { + bind?: string; + port?: number; + auth?: { mode?: string; token?: string }; + }; + }; + + expect(cfg.gateway?.bind).toBe("lan"); + expect(cfg.gateway?.port).toBe(port); + expect(cfg.gateway?.auth?.mode).toBe("token"); + expect((cfg.gateway?.auth?.token ?? "").length).toBeGreaterThan(8); + }); + }, 60_000); + + it("keeps the generated gateway token out of config under --secret-input-mode ref", async () => { + if (process.platform === "win32") { + // Matches the LAN case above: the Windows runner drops this flow's temp config write. + return; + } + await withStateDir("state-token-ref-", async (stateDir) => { + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); + setTestEnvValue("OPENCLAW_CONFIG_PATH", path.join(stateDir, "openclaw.json")); + + const port = getPseudoPort(41_000); + + await runNonInteractiveSetup( + { + nonInteractive: true, + mode: "local", + workspace: path.join(stateDir, "openclaw"), + authChoice: "skip", + skipSkills: true, + skipHealth: true, + installDaemon: false, + gatewayPort: port, + secretInputMode: "ref", + }, + runtime, + ); + + const cfg = readTestConfig() as { + gateway?: { auth?: { mode?: string; token?: unknown } }; + }; + expect(cfg.gateway?.auth?.mode).toBe("token"); + expect(cfg.gateway?.auth?.token).toEqual({ + source: "store", + provider: "default", + id: "OPENCLAW_GATEWAY_TOKEN", + }); + + // A ref persisted without its value would leave the gateway unauthenticatable. + const { readSecretStoreValue } = await import("../secrets/store/secret-store.js"); + const stored = readSecretStoreValue({ + scope: { kind: "team" }, + name: "OPENCLAW_GATEWAY_TOKEN", + }); + expect(stored.ok).toBe(true); + expect(stored.ok && stored.value.length).toBeGreaterThan(8); + }); + }, 60_000); + + it("references an ambient gateway token by env instead of copying it into the store", async () => { + if (process.platform === "win32") { + // Matches the LAN case above: the Windows runner drops this flow's temp config write. + return; + } + await withStateDir("state-token-ref-env-", async (stateDir) => { + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); + setTestEnvValue("OPENCLAW_CONFIG_PATH", path.join(stateDir, "openclaw.json")); + setTestEnvValue("OPENCLAW_GATEWAY_TOKEN", "ambient-gateway-token"); + + await runNonInteractiveSetup( + { + nonInteractive: true, + mode: "local", + workspace: path.join(stateDir, "openclaw"), + authChoice: "skip", + skipSkills: true, + skipHealth: true, + installDaemon: false, + gatewayPort: getPseudoPort(42_000), + secretInputMode: "ref", + }, + runtime, + ); + + const cfg = readTestConfig() as { gateway?: { auth?: { token?: unknown } } }; + expect(cfg.gateway?.auth?.token).toEqual({ + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_TOKEN", + }); + + // A store copy would silently outlive a later rotation of the env var. + const { readSecretStoreValue } = await import("../secrets/store/secret-store.js"); + expect( + readSecretStoreValue({ scope: { kind: "team" }, name: "OPENCLAW_GATEWAY_TOKEN" }).ok, + ).toBe(false); + }); + }, 60_000); +}); diff --git a/src/commands/onboard-non-interactive.gateway.test-mocks.ts b/src/commands/onboard-non-interactive.gateway.test-mocks.ts new file mode 100644 index 000000000000..c3d2f9bf48a1 --- /dev/null +++ b/src/commands/onboard-non-interactive.gateway.test-mocks.ts @@ -0,0 +1,219 @@ +// Shared mocks and harness for the non-interactive gateway onboarding suites. +// vi.mock calls live here so sibling suites share one config-write/daemon/health surface. +import path from "node:path"; +import { vi } from "vitest"; +import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js"; +import { + createOnboardTestConfigStore, + createThrowingRuntime, + mockOnboardingAgent, +} from "./onboard-non-interactive.test-helpers.js"; +import type { WaitForGatewayReachableMock } from "./onboard-non-interactive.test-helpers.js"; +import type { installGatewayDaemonNonInteractive } from "./onboard-non-interactive/local/daemon-install.js"; + +export const ensureWorkspaceAndSessionsMock = vi.fn(async (..._args: unknown[]) => {}); +const onboardTestConfigStore = createOnboardTestConfigStore(); +export const { + configStore: testConfigStore, + resolveConfigPath: resolveTestConfigPath, + readConfig: readTestConfig, +} = onboardTestConfigStore; +const gatewayOnboardConfigSnapshotMock = vi.hoisted(() => + vi.fn<() => Promise>(), +); +const pluginLifecycleLeaseState = vi.hoisted(() => ({ depth: 0 })); +export const configWritePluginLeaseDepths: number[] = []; +type InstallGatewayDaemonResult = Awaited>; +const installGatewayDaemonNonInteractiveMock = vi.hoisted(() => + vi.fn(async (): Promise => ({ installed: true })), +); +const healthCommandMock = vi.hoisted(() => vi.fn(async () => {})); +const gatewayServiceMock = vi.hoisted(() => ({ + label: "LaunchAgent", + loadedText: "loaded", + isLoaded: vi.fn(async () => true), + readRuntime: vi.fn(async () => ({ + status: "running", + state: "active", + pid: 4242, + })), +})); +const readLastGatewayErrorLineMock = vi.hoisted(() => + vi.fn(async () => "Gateway failed to start: required secrets are unavailable."), +); +/** Suites swap reachability behavior per test; the hoisted mock factory reads the current value. */ +export const gatewayReachableState: { mock: WaitForGatewayReachableMock } = { mock: undefined }; + +gatewayOnboardConfigSnapshotMock.mockImplementation(async () => + onboardTestConfigStore.readSnapshot(), +); + +vi.mock("../config/io.js", () => ({ + createConfigIO: () => ({ + configPath: resolveTestConfigPath(), + }), + loadConfig: () => readTestConfig(), + readConfigFileSnapshot: gatewayOnboardConfigSnapshotMock, +})); + +vi.mock("../plugins/plugin-lifecycle-lease.js", () => ({ + withPluginLifecycleLease: async ( + _options: unknown, + run: (lease: { + databasePath: string; + signal: AbortSignal; + assertOwned: () => void; + assertOwnedInTransaction: () => void; + }) => Promise, + ) => { + pluginLifecycleLeaseState.depth += 1; + try { + return await run({ + databasePath: path.join(path.dirname(resolveTestConfigPath()), "openclaw.sqlite"), + signal: new AbortController().signal, + assertOwned: () => {}, + assertOwnedInTransaction: () => {}, + }); + } finally { + pluginLifecycleLeaseState.depth -= 1; + } + }, +})); + +export const capturedReplaceConfigFileCalls: Array<{ + nextConfig: OpenClawConfig; + writeOptions?: { allowConfigSizeDrop?: boolean; unsetPaths?: string[][] }; +}> = []; + +vi.mock("../config/config.js", async (importActual) => { + const actual = await importActual(); + return { + replaceConfigFile: async ({ + nextConfig, + writeOptions, + }: { + nextConfig: OpenClawConfig; + writeOptions?: { allowConfigSizeDrop?: boolean; unsetPaths?: string[][] }; + }) => { + configWritePluginLeaseDepths.push(pluginLifecycleLeaseState.depth); + capturedReplaceConfigFileCalls.push({ + nextConfig, + ...(writeOptions ? { writeOptions } : {}), + }); + testConfigStore.set(resolveTestConfigPath(), nextConfig); + }, + resolveConfigWriteAfterWrite: actual.resolveConfigWriteAfterWrite, + resolveGatewayPort: (cfg: OpenClawConfig) => cfg.gateway?.port ?? 18789, + transformConfigFileWithRetry: async ( + params: Parameters[0], + ) => { + const snapshot = await gatewayOnboardConfigSnapshotMock(); + const previousHash = snapshot.hash ?? null; + const transformed = await params.transform(snapshot.sourceConfig, { + snapshot, + previousHash, + attempt: 0, + }); + const committed = await params.commit!({ + nextConfig: transformed.nextConfig, + snapshot, + ...(previousHash ? { baseHash: previousHash } : {}), + writeOptions: params.writeOptions, + afterWrite: { mode: "auto" }, + }); + return { nextConfig: committed.config }; + }, + }; +}); + +vi.mock("./onboard-agent.js", () => ({ ensureOnboardingAgent: mockOnboardingAgent })); + +vi.mock("./onboard-helpers.js", () => { + const normalizeGatewayTokenInput = (value: unknown): string => { + if (typeof value !== "string") { + return ""; + } + const trimmed = value.trim(); + return trimmed === "undefined" || trimmed === "null" ? "" : trimmed; + }; + return { + DEFAULT_WORKSPACE: "/tmp/openclaw-workspace", + applyWizardMetadata: (cfg: unknown) => cfg, + ensureWorkspaceAndSessions: ensureWorkspaceAndSessionsMock, + normalizeGatewayTokenInput, + randomToken: () => "tok_generated_gateway_test_token", + resolveControlUiLinks: ({ port }: { port: number }) => ({ + httpUrl: `http://127.0.0.1:${port}`, + wsUrl: `ws://127.0.0.1:${port}`, + }), + resolveLocalControlUiProbeLinks: ({ port }: { port: number }) => ({ + httpUrl: `http://127.0.0.1:${port}`, + wsUrl: `ws://127.0.0.1:${port}`, + }), + waitForGatewayReachable: (params: { + url: string; + token?: string; + password?: string; + deadlineMs?: number; + probeTimeoutMs?: number; + }) => gatewayReachableState.mock?.(params) ?? Promise.resolve({ ok: true }), + }; +}); + +vi.mock("./onboard-non-interactive/local/daemon-install.js", () => ({ + installGatewayDaemonNonInteractive: installGatewayDaemonNonInteractiveMock, +})); + +vi.mock("./health.js", () => ({ + healthCommandNonExiting: healthCommandMock, +})); + +vi.mock("../daemon/service.js", () => ({ + readGatewayServiceState: async () => { + const [loadState, runtime] = await Promise.all([ + gatewayServiceMock + .isLoaded() + .then((loaded) => + loaded ? ({ status: "loaded" } as const) : ({ status: "not-loaded" } as const), + ) + .catch((error: unknown) => ({ status: "unknown" as const, detail: String(error) })), + gatewayServiceMock.readRuntime(), + ]); + return { + installed: true, + loadState, + running: runtime.status === "running", + env: {}, + command: null, + runtime, + }; + }, + resolveGatewayService: () => gatewayServiceMock, +})); + +vi.mock("../daemon/diagnostics.js", () => ({ + readLastGatewayErrorLine: readLastGatewayErrorLineMock, +})); + +export let runNonInteractiveSetup: typeof import("./onboard-non-interactive.js").runNonInteractiveSetup; +export let resolveInstallDaemonGatewayHealthTiming: typeof import("./onboard-non-interactive/local.test-support.js").resolveInstallDaemonGatewayHealthTiming; + +export async function loadGatewayOnboardModules(): Promise { + vi.resetModules(); + ({ runNonInteractiveSetup } = await import("./onboard-non-interactive.js")); + ({ resolveInstallDaemonGatewayHealthTiming } = + await import("./onboard-non-interactive/local.test-support.js")); +} + +export const getPseudoPort = (base: number): number => base + (process.pid % 1000); + +export const gatewayOnboardRuntime = createThrowingRuntime(); + +// vi.hoisted values cannot be exported at their declaration; re-export them here. +export { + gatewayServiceMock, + healthCommandMock, + installGatewayDaemonNonInteractiveMock, + gatewayOnboardConfigSnapshotMock, + readLastGatewayErrorLineMock, +}; diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index 78091a62638b..79755cbc0bdc 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -1,20 +1,37 @@ -// Non-interactive gateway onboarding tests cover local/remote setup, auth, daemon install, and config writes. +// Non-interactive gateway onboarding tests cover local/remote setup, daemon install, and config writes. +// Gateway auth-token storage has its own suite in onboard-non-interactive.gateway-auth-token.test.ts. import fs from "node:fs/promises"; import path from "node:path"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { RuntimeEnv } from "../runtime.js"; import { makeTempWorkspace } from "../test-helpers/workspace.js"; import { setTestEnvValue } from "../test-utils/env.js"; +import { + capturedReplaceConfigFileCalls, + configWritePluginLeaseDepths, + ensureWorkspaceAndSessionsMock, + gatewayReachableState, + gatewayServiceMock, + getPseudoPort, + healthCommandMock, + installGatewayDaemonNonInteractiveMock, + loadGatewayOnboardModules, + gatewayOnboardConfigSnapshotMock as readConfigFileSnapshotMock, + readLastGatewayErrorLineMock, + readTestConfig, + resolveInstallDaemonGatewayHealthTiming, + resolveTestConfigPath, + runNonInteractiveSetup, + gatewayOnboardRuntime as runtime, + testConfigStore, +} from "./onboard-non-interactive.gateway.test-mocks.js"; import { createOnboardGatewayTimeoutCapture, createOnboardJsonCaptureRuntime, createOnboardLocalDaemonOptions, createOnboardStateDirHarness, - createOnboardTestConfigStore, - createThrowingRuntime, expectOnboardLocalJsonSetupFailure, - mockOnboardingAgent, prepareOnboardGatewayTestEnv, readOnboardFirstMockCall, runOnboardLocalDaemonSetup, @@ -23,202 +40,7 @@ import type { OnboardEnsureWorkspaceOptions, OnboardGatewayHealthCall, OnboardHealthCommandCall, - WaitForGatewayReachableMock, } from "./onboard-non-interactive.test-helpers.js"; -import type { installGatewayDaemonNonInteractive } from "./onboard-non-interactive/local/daemon-install.js"; - -const ensureWorkspaceAndSessionsMock = vi.fn(async (..._args: unknown[]) => {}); -const { - configStore: testConfigStore, - resolveConfigPath: resolveTestConfigPath, - readConfig: readTestConfig, - readSnapshot: readTestConfigSnapshot, -} = createOnboardTestConfigStore(); -const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn<() => Promise>()); -const pluginLifecycleLeaseState = vi.hoisted(() => ({ depth: 0 })); -const configWritePluginLeaseDepths: number[] = []; -type InstallGatewayDaemonResult = Awaited>; -const installGatewayDaemonNonInteractiveMock = vi.hoisted(() => - vi.fn(async (): Promise => ({ installed: true })), -); -const healthCommandMock = vi.hoisted(() => vi.fn(async () => {})); -const gatewayServiceMock = vi.hoisted(() => ({ - label: "LaunchAgent", - loadedText: "loaded", - isLoaded: vi.fn(async () => true), - readRuntime: vi.fn(async () => ({ - status: "running", - state: "active", - pid: 4242, - })), -})); -const readLastGatewayErrorLineMock = vi.hoisted(() => - vi.fn(async () => "Gateway failed to start: required secrets are unavailable."), -); -let waitForGatewayReachableMock: WaitForGatewayReachableMock; - -readConfigFileSnapshotMock.mockImplementation(async () => readTestConfigSnapshot()); - -vi.mock("../config/io.js", () => ({ - createConfigIO: () => ({ - configPath: resolveTestConfigPath(), - }), - loadConfig: () => readTestConfig(), - readConfigFileSnapshot: readConfigFileSnapshotMock, -})); - -vi.mock("../plugins/plugin-lifecycle-lease.js", () => ({ - withPluginLifecycleLease: async ( - _options: unknown, - run: (lease: { - databasePath: string; - signal: AbortSignal; - assertOwned: () => void; - assertOwnedInTransaction: () => void; - }) => Promise, - ) => { - pluginLifecycleLeaseState.depth += 1; - try { - return await run({ - databasePath: path.join(path.dirname(resolveTestConfigPath()), "openclaw.sqlite"), - signal: new AbortController().signal, - assertOwned: () => {}, - assertOwnedInTransaction: () => {}, - }); - } finally { - pluginLifecycleLeaseState.depth -= 1; - } - }, -})); - -const capturedReplaceConfigFileCalls: Array<{ - nextConfig: OpenClawConfig; - writeOptions?: { allowConfigSizeDrop?: boolean; unsetPaths?: string[][] }; -}> = []; - -vi.mock("../config/config.js", async (importActual) => { - const actual = await importActual(); - return { - replaceConfigFile: async ({ - nextConfig, - writeOptions, - }: { - nextConfig: OpenClawConfig; - writeOptions?: { allowConfigSizeDrop?: boolean; unsetPaths?: string[][] }; - }) => { - configWritePluginLeaseDepths.push(pluginLifecycleLeaseState.depth); - capturedReplaceConfigFileCalls.push({ - nextConfig, - ...(writeOptions ? { writeOptions } : {}), - }); - testConfigStore.set(resolveTestConfigPath(), nextConfig); - }, - resolveConfigWriteAfterWrite: actual.resolveConfigWriteAfterWrite, - resolveGatewayPort: (cfg: OpenClawConfig) => cfg.gateway?.port ?? 18789, - transformConfigFileWithRetry: async ( - params: Parameters[0], - ) => { - const snapshot = await readConfigFileSnapshotMock(); - const previousHash = snapshot.hash ?? null; - const transformed = await params.transform(snapshot.sourceConfig, { - snapshot, - previousHash, - attempt: 0, - }); - const committed = await params.commit!({ - nextConfig: transformed.nextConfig, - snapshot, - ...(previousHash ? { baseHash: previousHash } : {}), - writeOptions: params.writeOptions, - afterWrite: { mode: "auto" }, - }); - return { nextConfig: committed.config }; - }, - }; -}); - -vi.mock("./onboard-agent.js", () => ({ ensureOnboardingAgent: mockOnboardingAgent })); - -vi.mock("./onboard-helpers.js", () => { - const normalizeGatewayTokenInput = (value: unknown): string => { - if (typeof value !== "string") { - return ""; - } - const trimmed = value.trim(); - return trimmed === "undefined" || trimmed === "null" ? "" : trimmed; - }; - return { - DEFAULT_WORKSPACE: "/tmp/openclaw-workspace", - applyWizardMetadata: (cfg: unknown) => cfg, - ensureWorkspaceAndSessions: ensureWorkspaceAndSessionsMock, - normalizeGatewayTokenInput, - randomToken: () => "tok_generated_gateway_test_token", - resolveControlUiLinks: ({ port }: { port: number }) => ({ - httpUrl: `http://127.0.0.1:${port}`, - wsUrl: `ws://127.0.0.1:${port}`, - }), - resolveLocalControlUiProbeLinks: ({ port }: { port: number }) => ({ - httpUrl: `http://127.0.0.1:${port}`, - wsUrl: `ws://127.0.0.1:${port}`, - }), - waitForGatewayReachable: (params: { - url: string; - token?: string; - password?: string; - deadlineMs?: number; - probeTimeoutMs?: number; - }) => waitForGatewayReachableMock?.(params) ?? Promise.resolve({ ok: true }), - }; -}); - -vi.mock("./onboard-non-interactive/local/daemon-install.js", () => ({ - installGatewayDaemonNonInteractive: installGatewayDaemonNonInteractiveMock, -})); - -vi.mock("./health.js", () => ({ - healthCommandNonExiting: healthCommandMock, -})); - -vi.mock("../daemon/service.js", () => ({ - readGatewayServiceState: async () => { - const [loadState, runtime] = await Promise.all([ - gatewayServiceMock - .isLoaded() - .then((loaded) => - loaded ? ({ status: "loaded" } as const) : ({ status: "not-loaded" } as const), - ) - .catch((error: unknown) => ({ status: "unknown" as const, detail: String(error) })), - gatewayServiceMock.readRuntime(), - ]); - return { - installed: true, - loadState, - running: runtime.status === "running", - env: {}, - command: null, - runtime, - }; - }, - resolveGatewayService: () => gatewayServiceMock, -})); - -vi.mock("../daemon/diagnostics.js", () => ({ - readLastGatewayErrorLine: readLastGatewayErrorLineMock, -})); - -let runNonInteractiveSetup: typeof import("./onboard-non-interactive.js").runNonInteractiveSetup; -let resolveInstallDaemonGatewayHealthTiming: typeof import("./onboard-non-interactive/local.test-support.js").resolveInstallDaemonGatewayHealthTiming; - -async function loadGatewayOnboardModules(): Promise { - vi.resetModules(); - ({ runNonInteractiveSetup } = await import("./onboard-non-interactive.js")); - ({ resolveInstallDaemonGatewayHealthTiming } = - await import("./onboard-non-interactive/local.test-support.js")); -} - -const getPseudoPort = (base: number): number => base + (process.pid % 1000); - -const runtime = createThrowingRuntime(); describe("onboard (non-interactive): gateway and remote auth", () => { let envSnapshot: ReturnType; @@ -241,7 +63,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { }); afterEach(() => { - waitForGatewayReachableMock = undefined; + gatewayReachableState.mock = undefined; testConfigStore.clear(); capturedReplaceConfigFileCalls.length = 0; configWritePluginLeaseDepths.length = 0; @@ -428,58 +250,6 @@ describe("onboard (non-interactive): gateway and remote auth", () => { }); }, 60_000); - it("writes gateway token auth into config", async () => { - await withStateDir("state-noninteractive-", async (stateDir) => { - const token = "tok_test_123"; - const workspace = path.join(stateDir, "openclaw"); - testConfigStore.set(resolveTestConfigPath(), { - gateway: { - bind: "lan", - auth: { mode: "password", password: "test-password" }, - tailscale: { mode: "serve" }, - }, - } as OpenClawConfig); - - await runNonInteractiveSetup( - { - nonInteractive: true, - mode: "local", - workspace, - authChoice: "skip", - skipSkills: true, - skipHealth: true, - installDaemon: false, - gatewayBind: "loopback", - gatewayAuth: "token", - gatewayToken: token, - tailscale: "off", - }, - runtime, - ); - - const cfg = readTestConfig() as { - gateway?: { - mode?: string; - bind?: string; - auth?: { mode?: string; token?: string }; - tailscale?: { mode?: string }; - }; - agents?: { defaults?: { workspace?: string } }; - tools?: { profile?: string }; - hooks?: { internal?: { entries?: Record } }; - }; - - expect(cfg?.agents?.defaults?.workspace).toBe(workspace); - expect(cfg?.gateway?.mode).toBe("local"); - expect(cfg?.gateway?.bind).toBe("loopback"); - expect(cfg?.tools?.profile).toBe("coding"); - expect(cfg?.gateway?.auth?.mode).toBe("token"); - expect(cfg?.gateway?.auth?.token).toBe(token); - expect(cfg?.gateway?.tailscale).toEqual({ mode: "off" }); - expect(cfg?.hooks?.internal?.entries?.["session-memory"]).toEqual({ enabled: true }); - }); - }, 60_000); - it("does not auto-enable default hooks when skipHooks is set", async () => { await withStateDir("state-skip-hooks-", async (stateDir) => { const workspace = path.join(stateDir, "openclaw"); @@ -687,7 +457,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { it("completes explicit no-daemon setup when no gateway is listening", async () => { await withStateDir("state-local-health-hint-", async (stateDir) => { - waitForGatewayReachableMock = vi.fn(async () => ({ + gatewayReachableState.mock = vi.fn(async () => ({ ok: false, detail: "connect ECONNREFUSED 127.0.0.1:18789", })); @@ -706,7 +476,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { it("still fails when an existing gateway is expected but unreachable", async () => { await withStateDir("state-local-health-required-", async (stateDir) => { - waitForGatewayReachableMock = vi.fn(async () => ({ + gatewayReachableState.mock = vi.fn(async () => ({ ok: false, detail: "connect ECONNREFUSED 127.0.0.1:18789", })); @@ -725,7 +495,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { it("uses a longer health deadline when daemon install was requested", async () => { await withStateDir("state-local-daemon-health-", async (stateDir) => { const captured = createOnboardGatewayTimeoutCapture(); - waitForGatewayReachableMock = captured.mock; + gatewayReachableState.mock = captured.mock; await runOnboardLocalDaemonSetup({ runSetup: runNonInteractiveSetup, stateDir, runtime }); @@ -744,7 +514,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { it("passes pinned gateway auth through non-interactive health checks", async () => { await withStateDir("state-local-daemon-health-auth-", async (stateDir) => { const token = "tok_noninteractive_health"; - waitForGatewayReachableMock = vi.fn(async () => ({ ok: true })); + gatewayReachableState.mock = vi.fn(async () => ({ ok: true })); await runNonInteractiveSetup( { @@ -756,7 +526,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { ); const [gatewayHealthCall] = readOnboardFirstMockCall( - waitForGatewayReachableMock, + gatewayReachableState.mock, "waitForGatewayReachable", ) as [OnboardGatewayHealthCall]; expect(gatewayHealthCall.token).toBe(token); @@ -839,7 +609,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { it("emits structured JSON diagnostics when daemon health fails", async () => { await withStateDir("state-local-daemon-health-json-fail-", async (stateDir) => { - waitForGatewayReachableMock = vi.fn(async () => ({ + gatewayReachableState.mock = vi.fn(async () => ({ ok: false, detail: "gateway closed (1006 abnormal closure (no close frame)): no close reason", })); @@ -886,7 +656,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { it("emits structured JSON failure when a reachable gateway fails its health check", async () => { await withStateDir("state-local-daemon-health-exit-json-", async (stateDir) => { - waitForGatewayReachableMock = vi.fn(async () => ({ ok: true })); + gatewayReachableState.mock = vi.fn(async () => ({ ok: true })); healthCommandMock.mockImplementationOnce(async (...args: unknown[]) => { // healthCommand prints its reachable-gateway diagnostic before its // CLI-style exit; the capture runtime must keep it off JSON stdout. @@ -924,7 +694,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { it("routes thrown health-check errors through the onboarding failure owner", async () => { await withStateDir("state-local-health-failure-text-", async (stateDir) => { - waitForGatewayReachableMock = vi.fn(async () => ({ ok: true })); + gatewayReachableState.mock = vi.fn(async () => ({ ok: true })); healthCommandMock.mockRejectedValueOnce(new Error("health request timed out")); await expect( @@ -935,7 +705,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { it("preserves unknown service inspection in JSON diagnostics", async () => { await withStateDir("state-local-daemon-health-unknown-", async (stateDir) => { - waitForGatewayReachableMock = vi.fn(async () => ({ + gatewayReachableState.mock = vi.fn(async () => ({ ok: false, detail: "connect ECONNREFUSED 127.0.0.1:18789", })); @@ -963,7 +733,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { it("classifies daemon health ECONNREFUSED failures with a recovery command", async () => { await withStateDir("state-local-daemon-health-refused-", async (stateDir) => { - waitForGatewayReachableMock = vi.fn(async () => ({ + gatewayReachableState.mock = vi.fn(async () => ({ ok: false, detail: "connect ECONNREFUSED 127.0.0.1:18789", })); @@ -993,46 +763,4 @@ describe("onboard (non-interactive): gateway and remote auth", () => { expect(parsed.hints).toContain("Fix: run `openclaw gateway restart`."); }); }, 60_000); - - it("auto-generates token auth when binding LAN and persists the token", async () => { - if (process.platform === "win32") { - // Windows runner occasionally drops the temp config write in this flow; skip to keep CI green. - return; - } - await withStateDir("state-lan-", async (stateDir) => { - setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); - setTestEnvValue("OPENCLAW_CONFIG_PATH", path.join(stateDir, "openclaw.json")); - - const port = getPseudoPort(40_000); - const workspace = path.join(stateDir, "openclaw"); - - await runNonInteractiveSetup( - { - nonInteractive: true, - mode: "local", - workspace, - authChoice: "skip", - skipSkills: true, - skipHealth: true, - installDaemon: false, - gatewayPort: port, - gatewayBind: "lan", - }, - runtime, - ); - - const cfg = readTestConfig() as { - gateway?: { - bind?: string; - port?: number; - auth?: { mode?: string; token?: string }; - }; - }; - - expect(cfg.gateway?.bind).toBe("lan"); - expect(cfg.gateway?.port).toBe(port); - expect(cfg.gateway?.auth?.mode).toBe("token"); - expect((cfg.gateway?.auth?.token ?? "").length).toBeGreaterThan(8); - }); - }, 60_000); }); diff --git a/src/commands/onboard-non-interactive/local/gateway-config.ts b/src/commands/onboard-non-interactive/local/gateway-config.ts index e3d628668e19..d7c2be19a7c7 100644 --- a/src/commands/onboard-non-interactive/local/gateway-config.ts +++ b/src/commands/onboard-non-interactive/local/gateway-config.ts @@ -8,12 +8,46 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import { formatCliCommand } from "../../../cli/command-format.js"; import { formatInvalidPortOption } from "../../../cli/error-format.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; -import { isValidEnvSecretRefId, resolveSecretInputRef } from "../../../config/types.secrets.js"; +import { + isValidEnvSecretRefId, + resolveSecretInputRef, + type SecretRef, +} from "../../../config/types.secrets.js"; +import { provisionGatewayTokenStoreRef } from "../../../gateway/auth-token-store-ref.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"; +function gatewayEnvTokenRef(config: OpenClawConfig, envVarName: string): SecretRef { + return { + source: "env", + provider: resolveDefaultSecretProviderAlias(config, "env", { + preferFirstProviderForSource: true, + }), + id: envVarName, + }; +} + +/** Resolves what `gateway.auth.token` should hold once setup owns the token value. */ +function resolveGeneratedTokenInput(params: { + config: OpenClawConfig; + secretInputMode: OnboardOptions["secretInputMode"]; + token: string | undefined; + ambientEnvOnly: boolean; +}): SecretRef | string { + if (params.secretInputMode !== "ref") { + return params.token ?? randomToken(); + } + if (params.ambientEnvOnly) { + return gatewayEnvTokenRef(params.config, "OPENCLAW_GATEWAY_TOKEN"); + } + return provisionGatewayTokenStoreRef({ + config: params.config, + ...(params.token ? { token: params.token } : {}), + }).ref; +} + /** Applies gateway CLI options to the pending config and returns normalized runtime settings. */ export function applyNonInteractiveGatewayConfig(params: { nextConfig: OpenClawConfig; @@ -95,7 +129,8 @@ export function applyNonInteractiveGatewayConfig(params: { // plaintext > ambient OPENCLAW_GATEWAY_TOKEN > randomToken(). Ambient env // must not rotate a token already written to disk — a stale shell or // launchd env var otherwise breaks already-paired clients. - let gatewayToken = explicitGatewayToken || existingPlaintextToken || envGatewayToken || undefined; + const gatewayToken = + explicitGatewayToken || existingPlaintextToken || envGatewayToken || undefined; const gatewayTokenRefEnv = normalizeOptionalString(opts.gatewayTokenRefEnv ?? "") ?? ""; if (authMode === "token") { @@ -133,13 +168,7 @@ export function applyNonInteractiveGatewayConfig(params: { auth: { ...nextConfig.gateway?.auth, mode: "token", - token: { - source: "env", - provider: resolveDefaultSecretProviderAlias(nextConfig, "env", { - preferFirstProviderForSource: true, - }), - id: gatewayTokenRefEnv, - }, + token: gatewayEnvTokenRef(nextConfig, gatewayTokenRefEnv), }, }, }; @@ -160,9 +189,18 @@ export function applyNonInteractiveGatewayConfig(params: { }, }; } else { - if (!gatewayToken) { - gatewayToken = randomToken(); - } + // `--secret-input-mode ref` covers the gateway token too. An ambient + // OPENCLAW_GATEWAY_TOKEN keeps its env ref so a later rotation still wins; + // copying it into the store would silently pin the stale value. Anything else + // is a value setup itself holds, with nothing for an env/file/exec ref to point + // at, so the shared secret store keeps it and config keeps only the reference. + const tokenInput = resolveGeneratedTokenInput({ + config: nextConfig, + secretInputMode: opts.secretInputMode, + token: gatewayToken, + ambientEnvOnly: + !explicitGatewayToken && !existingPlaintextToken && Boolean(envGatewayToken), + }); nextConfig = { ...nextConfig, gateway: { @@ -170,7 +208,7 @@ export function applyNonInteractiveGatewayConfig(params: { auth: { ...nextConfig.gateway?.auth, mode: "token", - token: gatewayToken, + token: tokenInput, }, }, }; diff --git a/src/gateway/auth-token-store-ref.test.ts b/src/gateway/auth-token-store-ref.test.ts new file mode 100644 index 000000000000..bd18c2c29e17 --- /dev/null +++ b/src/gateway/auth-token-store-ref.test.ts @@ -0,0 +1,78 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { readSecretStoreValue, writeSecretStoreEntry } from "../secrets/store/secret-store.js"; +import { setTestEnvValue } from "../test-utils/env.js"; +import { provisionGatewayTokenStoreRef } from "./auth-token-store-ref.js"; + +const STORE_SCOPE = { kind: "team" } as const; +const STORE_NAME = "OPENCLAW_GATEWAY_TOKEN"; + +function readStored(): string | undefined { + const result = readSecretStoreValue({ scope: STORE_SCOPE, name: STORE_NAME }); + return result.ok ? result.value : undefined; +} + +describe("provisionGatewayTokenStoreRef", () => { + let stateDir: string; + + beforeEach(() => { + stateDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "gateway-token-store-"))); + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); + }); + + afterEach(() => { + fs.rmSync(stateDir, { recursive: true, force: true }); + }); + + it("mints a token into the store and returns a default-provider store ref", () => { + const result = provisionGatewayTokenStoreRef({ config: {} }); + + expect(result.ref).toEqual({ + source: "store", + provider: "default", + id: STORE_NAME, + }); + expect(result.token.length).toBeGreaterThan(8); + expect(readStored()).toBe(result.token); + }); + + it("reuses an existing entry so reruns never rotate a paired token", () => { + writeSecretStoreEntry({ + scope: STORE_SCOPE, + name: STORE_NAME, + value: "already-paired-token", + kind: "secret", + updatedBy: "test", + }); + + const result = provisionGatewayTokenStoreRef({ config: {} }); + + expect(result.token).toBe("already-paired-token"); + expect(readStored()).toBe("already-paired-token"); + }); + + it("lets an explicit token win so a persisted plaintext token migrates unchanged", () => { + writeSecretStoreEntry({ + scope: STORE_SCOPE, + name: STORE_NAME, + value: "stale-token", + kind: "secret", + updatedBy: "test", + }); + + const result = provisionGatewayTokenStoreRef({ config: {}, token: "operator-token" }); + + expect(result.token).toBe("operator-token"); + expect(readStored()).toBe("operator-token"); + }); + + it("honors a configured store provider alias", () => { + const result = provisionGatewayTokenStoreRef({ + config: { secrets: { defaults: { store: "vault" } } }, + }); + + expect(result.ref.provider).toBe("vault"); + }); +}); diff --git a/src/gateway/auth-token-store-ref.ts b/src/gateway/auth-token-store-ref.ts new file mode 100644 index 000000000000..438f157da085 --- /dev/null +++ b/src/gateway/auth-token-store-ref.ts @@ -0,0 +1,60 @@ +/** Store-backed SecretRef provisioning for gateway auth tokens setup generates itself. */ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { randomToken } from "../commands/random-token.js"; +import type { SecretRef } from "../config/types.secrets.js"; +import { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js"; +import { readSecretStoreValue, writeSecretStoreEntry } from "../secrets/store/secret-store.js"; + +/** Store entry name for the gateway token; mirrors the documented env-var contract. */ +const GATEWAY_AUTH_TOKEN_STORE_NAME = "OPENCLAW_GATEWAY_TOKEN"; + +const GATEWAY_AUTH_TOKEN_STORE_SCOPE = { kind: "team" } as const; + +/** Minimal config shape needed to pick the store provider alias. */ +type GatewayTokenStoreRefConfig = Parameters[0]; + +function readStoredGatewayToken(): string | undefined { + const existing = readSecretStoreValue({ + scope: GATEWAY_AUTH_TOKEN_STORE_SCOPE, + name: GATEWAY_AUTH_TOKEN_STORE_NAME, + }); + return existing.ok ? normalizeOptionalString(existing.value) : undefined; +} + +/** + * Provisions the gateway token in the secret store and returns the ref config points at. + * + * Omit `token` when setup has no value of its own: an existing store entry then wins so + * reruns never rotate a token already paired with clients or a running service, and a + * fresh one is minted otherwise. A supplied token always wins, which also migrates a + * previously persisted plaintext token without invalidating it. The store write stays + * ahead of the config write on purpose — a ref persisted without its value would leave + * the gateway unauthenticatable, while an entry whose config write later fails is simply + * picked up by the next run. + */ +export function provisionGatewayTokenStoreRef(params: { + config: GatewayTokenStoreRefConfig; + token?: string; +}): { ref: SecretRef; token: string } { + const stored = params.token ? undefined : readStoredGatewayToken(); + const token = params.token ?? stored ?? randomToken(); + if (token !== stored) { + writeSecretStoreEntry({ + scope: GATEWAY_AUTH_TOKEN_STORE_SCOPE, + name: GATEWAY_AUTH_TOKEN_STORE_NAME, + value: token, + kind: "secret", + updatedBy: "setup", + }); + } + return { + ref: { + source: "store", + provider: resolveDefaultSecretProviderAlias(params.config, "store", { + preferFirstProviderForSource: true, + }), + id: GATEWAY_AUTH_TOKEN_STORE_NAME, + }, + token, + }; +} diff --git a/src/wizard/i18n/locales/en.ts b/src/wizard/i18n/locales/en.ts index a68641529d43..14c2fb5215da 100644 --- a/src/wizard/i18n/locales/en.ts +++ b/src/wizard/i18n/locales/en.ts @@ -95,6 +95,8 @@ export const en = { tokenPlaceholder: "Needed for multi-machine or non-loopback access", tokenPrompt: "Gateway token", tokenPromptGenerate: "Gateway token (blank to generate)", + tokenStoreProvisioned: + "Generated a gateway token and stored it in the OpenClaw secret store as {name}. Config keeps only a reference; inspect it with `openclaw secrets store list`.", websocketUrl: "Gateway WebSocket URL", }, gatewayTailscale: { diff --git a/src/wizard/i18n/locales/zh-CN.ts b/src/wizard/i18n/locales/zh-CN.ts index f24536621237..fa32f55fe686 100644 --- a/src/wizard/i18n/locales/zh-CN.ts +++ b/src/wizard/i18n/locales/zh-CN.ts @@ -94,6 +94,8 @@ export const zh_CN = { tokenPlaceholder: "多机器或非 loopback 访问需要使用", tokenPrompt: "Gateway 令牌", tokenPromptGenerate: "Gateway 令牌(留空则生成)", + tokenStoreProvisioned: + "已生成 Gateway 令牌并以 {name} 存入 OpenClaw 密钥存储。配置中只保留引用;可用 `openclaw secrets store list` 查看。", websocketUrl: "Gateway WebSocket URL", }, gatewayTailscale: { diff --git a/src/wizard/i18n/locales/zh-TW.ts b/src/wizard/i18n/locales/zh-TW.ts index 456869a35f13..d7f7ca8df399 100644 --- a/src/wizard/i18n/locales/zh-TW.ts +++ b/src/wizard/i18n/locales/zh-TW.ts @@ -94,6 +94,8 @@ export const zh_TW = { tokenPlaceholder: "多機器或非 loopback 存取需要使用", tokenPrompt: "Gateway 權杖", tokenPromptGenerate: "Gateway 權杖(留空則產生)", + tokenStoreProvisioned: + "已產生 Gateway 權杖並以 {name} 存入 OpenClaw 祕密儲存。設定中只保留參照;可用 `openclaw secrets store list` 檢視。", websocketUrl: "Gateway WebSocket URL", }, gatewayTailscale: { diff --git a/src/wizard/setup.gateway-config.test.ts b/src/wizard/setup.gateway-config.test.ts index 3766bbcefa68..9ee49f6bab07 100644 --- a/src/wizard/setup.gateway-config.test.ts +++ b/src/wizard/setup.gateway-config.test.ts @@ -1,4 +1,7 @@ // Setup gateway config tests cover gateway prompt choices and config output. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { createWizardPrompter as buildWizardPrompter } from "../../test/helpers/wizard-prompter.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -80,6 +83,7 @@ describe("configureGatewayForSetup", () => { tailscaleChoice?: "off" | "serve"; textQueue?: Array; nextConfig?: Record; + secretInputMode?: "plaintext" | "ref"; }) { const authChoice = params?.authChoice ?? "token"; const prompter = createPrompter({ @@ -93,11 +97,46 @@ describe("configureGatewayForSetup", () => { nextConfig: params?.nextConfig ?? {}, localPort: 18789, quickstartGateway: createQuickstartGateway(authChoice), + ...(params?.secretInputMode ? { secretInputMode: params.secretInputMode } : {}), prompter, runtime, }); } + it("provisions a store ref when reference mode has no token to point at", async () => { + const stateDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "wizard-gateway-ref-"))); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + const previousToken = process.env.OPENCLAW_GATEWAY_TOKEN; + process.env.OPENCLAW_STATE_DIR = stateDir; + delete process.env.OPENCLAW_GATEWAY_TOKEN; + + try { + const result = await runGatewayConfig({ flow: "quickstart", secretInputMode: "ref" }); + + expect(result.nextConfig.gateway?.auth?.token).toEqual({ + source: "store", + provider: "default", + id: "OPENCLAW_GATEWAY_TOKEN", + }); + const { readSecretStoreValue } = await import("../secrets/store/secret-store.js"); + const stored = readSecretStoreValue({ + scope: { kind: "team" }, + name: "OPENCLAW_GATEWAY_TOKEN", + }); + expect(stored.ok && stored.value).toBe(result.settings.gatewayToken); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + if (previousToken !== undefined) { + process.env.OPENCLAW_GATEWAY_TOKEN = previousToken; + } + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + it("generates a token when the prompt returns undefined", async () => { mocks.randomToken.mockReturnValue("generated-token"); const result = await runGatewayConfig(); diff --git a/src/wizard/setup.gateway-config.ts b/src/wizard/setup.gateway-config.ts index 7b732196d4da..092004f151d2 100644 --- a/src/wizard/setup.gateway-config.ts +++ b/src/wizard/setup.gateway-config.ts @@ -15,6 +15,7 @@ import { resolveSecretInputRef, type SecretInput, } from "../config/types.secrets.js"; +import { provisionGatewayTokenStoreRef } from "../gateway/auth-token-store-ref.js"; import { maybeAddTailnetOriginToControlUiAllowedOrigins, TAILSCALE_EXPOSURE_OPTIONS, @@ -219,6 +220,7 @@ export async function configureGatewayForSetup( refHint: t("wizard.gateway.refHint"), }, }); + const ambientToken = normalizeGatewayTokenInput(process.env.OPENCLAW_GATEWAY_TOKEN); if (tokenMode === "ref") { if (quickstartTokenRef) { gatewayTokenInput = quickstartTokenRef; @@ -228,6 +230,17 @@ export async function configureGatewayForSetup( path: "gateway.auth.token", env: process.env, }); + } else if (!quickstartTokenString && !ambientToken) { + // Nothing exists for an env/file/exec ref to point at, so asking where the + // token lives has no answerable option. Setup mints it into the shared + // secret store instead and config keeps only the reference. + const provisioned = provisionGatewayTokenStoreRef({ config: nextConfig }); + gatewayTokenInput = provisioned.ref; + gatewayToken = provisioned.token; + await prompter.note( + t("wizard.gateway.tokenStoreProvisioned", { name: provisioned.ref.id }), + t("wizard.gateway.auth"), + ); } else { const resolved = await promptSecretRefForSetup({ provider: "gateway-auth-token", @@ -243,13 +256,10 @@ export async function configureGatewayForSetup( gatewayToken = resolved.resolvedValue; } } else if (flow === "quickstart") { - gatewayToken = - (quickstartTokenString ?? normalizeGatewayTokenInput(process.env.OPENCLAW_GATEWAY_TOKEN)) || - randomToken(); + gatewayToken = (quickstartTokenString ?? ambientToken) || randomToken(); gatewayTokenInput = gatewayToken; } else { - const existingToken = - quickstartTokenString ?? normalizeGatewayTokenInput(process.env.OPENCLAW_GATEWAY_TOKEN); + const existingToken = quickstartTokenString ?? ambientToken; let tokenInput: string | undefined; if (existingToken) { const keep = await prompter.confirm({ diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 022517b677d6..66a3f8e9c0a6 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -1678,7 +1678,10 @@ describe("scripts/test-projects changed-target routing", () => { buildVitestRunPlans(["src/commands/onboard-non-interactive.test-helpers.ts"]), { config: "test/vitest/vitest.commands.config.ts", - includePatterns: ["src/commands/onboard-non-interactive.gateway.test.ts"], + includePatterns: [ + "src/commands/onboard-non-interactive.gateway-auth-token.test.ts", + "src/commands/onboard-non-interactive.gateway.test.ts", + ], }, ); });