feat(secrets): expand SecretRef coverage across user-supplied credentials (#29580)

* feat(secrets): expand secret target coverage and gateway tooling

* docs(secrets): align gateway and CLI secret docs

* chore(protocol): regenerate swift gateway models for secrets methods

* fix(config): restore talk apiKey fallback and stabilize runner test

* ci(windows): reduce test worker count for shard stability

* ci(windows): raise node heap for test shard stability

* test(feishu): make proxy env precedence assertion windows-safe

* fix(gateway): resolve auth password SecretInput refs for clients

* fix(gateway): resolve remote SecretInput credentials for clients

* fix(secrets): skip inactive refs in command snapshot assignments

* fix(secrets): scope gateway.remote refs to effective auth surfaces

* fix(secrets): ignore memory defaults when enabled agents disable search

* fix(secrets): honor Google Chat serviceAccountRef inheritance

* fix(secrets): address tsgo errors in command and gateway collectors

* fix(secrets): avoid auth-store load in providers-only configure

* fix(gateway): defer local password ref resolution by precedence

* fix(secrets): gate telegram webhook secret refs by webhook mode

* fix(secrets): gate slack signing secret refs to http mode

* fix(secrets): skip telegram botToken refs when tokenFile is set

* fix(secrets): gate discord pluralkit refs by enabled flag

* fix(secrets): gate discord voice tts refs by voice enabled

* test(secrets): make runtime fixture modes explicit

* fix(cli): resolve local qr password secret refs

* fix(cli): fail when gateway leaves command refs unresolved

* fix(gateway): fail when local password SecretRef is unresolved

* fix(gateway): fail when required remote SecretRefs are unresolved

* fix(gateway): resolve local password refs only when password can win

* fix(cli): skip local password SecretRef resolution on qr token override

* test(gateway): cast SecretRef fixtures to OpenClawConfig

* test(secrets): activate mode-gated targets in runtime coverage fixture

* fix(cron): support SecretInput webhook tokens safely

* fix(bluebubbles): support SecretInput passwords across config paths

* fix(msteams): make appPassword SecretInput-safe in onboarding/token paths

* fix(bluebubbles): align SecretInput schema helper typing

* fix(cli): clarify secrets.resolve version-skew errors

* refactor(secrets): return structured inactive paths from secrets.resolve

* refactor(gateway): type onboarding secret writes as SecretInput

* chore(protocol): regenerate swift models for secrets.resolve

* feat(secrets): expand extension credential secretref support

* fix(secrets): gate web-search refs by active provider

* fix(onboarding): detect SecretRef credentials in extension status

* fix(onboarding): allow keeping existing ref in secret prompt

* fix(onboarding): resolve gateway password SecretRefs for probe and tui

* fix(onboarding): honor secret-input-mode for local gateway auth

* fix(acp): resolve gateway SecretInput credentials

* fix(secrets): gate gateway.remote refs to remote surfaces

* test(secrets): cover pattern matching and inactive array refs

* docs(secrets): clarify secrets.resolve and remote active surfaces

* fix(bluebubbles): keep existing SecretRef during onboarding

* fix(tests): resolve CI type errors in new SecretRef coverage

* fix(extensions): replace raw fetch with SSRF-guarded fetch

* test(secrets): mark gateway remote targets active in runtime coverage

* test(infra): normalize home-prefix expectation across platforms

* fix(cli): only resolve local qr password refs in password mode

* test(cli): cover local qr token mode with unresolved password ref

* docs(cli): clarify local qr password ref resolution behavior

* refactor(extensions): reuse sdk SecretInput helpers

* fix(wizard): resolve onboarding env-template secrets before plaintext

* fix(cli): surface secrets.resolve diagnostics in memory and qr

* test(secrets): repair post-rebase runtime and fixtures

* fix(gateway): skip remote password ref resolution when token wins

* fix(secrets): treat tailscale remote gateway refs as active

* fix(gateway): allow remote password fallback when token ref is unresolved

* fix(gateway): ignore stale local password refs for none and trusted-proxy

* fix(gateway): skip remote secret ref resolution on local call paths

* test(cli): cover qr remote tailscale secret ref resolution

* fix(secrets): align gateway password active-surface with auth inference

* fix(cli): resolve inferred local gateway password refs in qr

* fix(gateway): prefer resolvable remote password over token ref pre-resolution

* test(gateway): cover none and trusted-proxy stale password refs

* docs(secrets): sync qr and gateway active-surface behavior

* fix: restore stability blockers from pre-release audit

* Secrets: fix collector/runtime precedence contradictions

* docs: align secrets and web credential docs

* fix(rebase): resolve integration regressions after main rebase

* fix(node-host): resolve gateway secret refs for auth

* fix(secrets): harden secretinput runtime readers

* gateway: skip inactive auth secretref resolution

* cli: avoid gateway preflight for inactive secret refs

* extensions: allow unresolved refs in onboarding status

* tests: fix qr-cli module mock hoist ordering

* Security: align audit checks with SecretInput resolution

* Gateway: resolve local-mode remote fallback secret refs

* Node host: avoid resolving inactive password secret refs

* Secrets runtime: mark Slack appToken inactive for HTTP mode

* secrets: keep inactive gateway remote refs non-blocking

* cli: include agent memory secret targets in runtime resolution

* docs(secrets): sync docs with active-surface and web search behavior

* fix(secrets): keep telegram top-level token refs active for blank account tokens

* fix(daemon): resolve gateway password secret refs for probe auth

* fix(secrets): skip IRC NickServ ref resolution when NickServ is disabled

* fix(secrets): align token inheritance and exec timeout defaults

* docs(secrets): clarify active-surface notes in cli docs

* cli: require secrets.resolve gateway capability

* gateway: log auth secret surface diagnostics

* secrets: remove dead provider resolver module

* fix(secrets): restore gateway auth precedence and fallback resolution

* fix(tests): align plugin runtime mock typings

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Josh Avant
2026-03-02 20:58:20 -06:00
committed by GitHub
parent f212351aed
commit 806803b7ef
236 changed files with 16810 additions and 2861 deletions

View File

@@ -47,6 +47,8 @@ import {
type VerboseLevel,
} from "../auto-reply/thinking.js";
import { formatCliCommand } from "../cli/command-format.js";
import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js";
import { getAgentRuntimeCommandSecretTargetIds } from "../cli/command-secret-targets.js";
import { type CliDeps, createDefaultDeps } from "../cli/deps.js";
import { loadConfig } from "../config/config.js";
import {
@@ -332,7 +334,15 @@ async function agentCommandInternal(
throw new Error("Pass --to <E.164>, --session-id, or --agent to choose a session");
}
const cfg = loadConfig();
const loadedRaw = loadConfig();
const { resolvedConfig: cfg, diagnostics } = await resolveCommandSecretRefsViaGateway({
config: loadedRaw,
commandName: "agent",
targetIds: getAgentRuntimeCommandSecretTargetIds(),
});
for (const entry of diagnostics) {
runtime.log(`[secrets] ${entry}`);
}
const agentIdOverrideRaw = opts.agentId?.trim();
const agentIdOverride = agentIdOverrideRaw ? normalizeAgentId(agentIdOverrideRaw) : undefined;
if (agentIdOverride) {

View File

@@ -19,6 +19,25 @@ const ENV_SECRET_REF_ID_RE = /^[A-Z][A-Z0-9_]{0,127}$/;
type SecretRefChoice = "env" | "provider";
export type SecretInputModePromptCopy = {
modeMessage?: string;
plaintextLabel?: string;
plaintextHint?: string;
refLabel?: string;
refHint?: string;
};
export type SecretRefOnboardingPromptCopy = {
sourceMessage?: string;
envVarMessage?: string;
envVarPlaceholder?: string;
envVarFormatError?: string;
envVarMissingError?: (envVar: string) => string;
noProvidersMessage?: string;
envValidatedMessage?: (envVar: string) => string;
providerValidatedMessage?: (provider: string, id: string, source: "file" | "exec") => string;
};
function formatErrorMessage(error: unknown): string {
if (error instanceof Error && typeof error.message === "string" && error.message.trim()) {
return error.message;
@@ -69,11 +88,12 @@ function resolveRefFallbackInput(params: {
};
}
async function resolveApiKeyRefForOnboarding(params: {
export async function promptSecretRefForOnboarding(params: {
provider: string;
config: OpenClawConfig;
prompter: WizardPrompter;
preferredEnvVar?: string;
copy?: SecretRefOnboardingPromptCopy;
}): Promise<{ ref: SecretRef; resolvedValue: string }> {
const defaultEnvVar =
params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider) ?? "";
@@ -82,7 +102,7 @@ async function resolveApiKeyRefForOnboarding(params: {
while (true) {
const sourceRaw: SecretRefChoice = await params.prompter.select<SecretRefChoice>({
message: "Where is this API key stored?",
message: params.copy?.sourceMessage ?? "Where is this API key stored?",
initialValue: sourceChoice,
options: [
{
@@ -102,16 +122,22 @@ async function resolveApiKeyRefForOnboarding(params: {
if (source === "env") {
const envVarRaw = await params.prompter.text({
message: "Environment variable name",
message: params.copy?.envVarMessage ?? "Environment variable name",
initialValue: defaultEnvVar || undefined,
placeholder: "OPENAI_API_KEY",
placeholder: params.copy?.envVarPlaceholder ?? "OPENAI_API_KEY",
validate: (value) => {
const candidate = value.trim();
if (!ENV_SECRET_REF_ID_RE.test(candidate)) {
return 'Use an env var name like "OPENAI_API_KEY" (uppercase letters, numbers, underscores).';
return (
params.copy?.envVarFormatError ??
'Use an env var name like "OPENAI_API_KEY" (uppercase letters, numbers, underscores).'
);
}
if (!process.env[candidate]?.trim()) {
return `Environment variable "${candidate}" is missing or empty in this session.`;
return (
params.copy?.envVarMissingError?.(candidate) ??
`Environment variable "${candidate}" is missing or empty in this session.`
);
}
return undefined;
},
@@ -136,7 +162,8 @@ async function resolveApiKeyRefForOnboarding(params: {
env: process.env,
});
await params.prompter.note(
`Validated environment variable ${envVar}. OpenClaw will store a reference, not the key value.`,
params.copy?.envValidatedMessage?.(envVar) ??
`Validated environment variable ${envVar}. OpenClaw will store a reference, not the key value.`,
"Reference validated",
);
return { ref, resolvedValue };
@@ -147,7 +174,8 @@ async function resolveApiKeyRefForOnboarding(params: {
);
if (externalProviders.length === 0) {
await params.prompter.note(
"No file/exec secret providers are configured yet. Add one under secrets.providers, or select Environment variable.",
params.copy?.noProvidersMessage ??
"No file/exec secret providers are configured yet. Add one under secrets.providers, or select Environment variable.",
"No providers configured",
);
continue;
@@ -222,7 +250,8 @@ async function resolveApiKeyRefForOnboarding(params: {
env: process.env,
});
await params.prompter.note(
`Validated ${providerEntry.source} reference ${selectedProvider}:${id}. OpenClaw will store a reference, not the key value.`,
params.copy?.providerValidatedMessage?.(selectedProvider, id, providerEntry.source) ??
`Validated ${providerEntry.source} reference ${selectedProvider}:${id}. OpenClaw will store a reference, not the key value.`,
"Reference validated",
);
return { ref, resolvedValue };
@@ -346,6 +375,7 @@ export function normalizeSecretInputModeInput(
export async function resolveSecretInputModeForEnvSelection(params: {
prompter: WizardPrompter;
explicitMode?: SecretInputMode;
copy?: SecretInputModePromptCopy;
}): Promise<SecretInputMode> {
if (params.explicitMode) {
return params.explicitMode;
@@ -356,18 +386,20 @@ export async function resolveSecretInputModeForEnvSelection(params: {
return "plaintext";
}
const selected = await params.prompter.select<SecretInputMode>({
message: "How do you want to provide this API key?",
message: params.copy?.modeMessage ?? "How do you want to provide this API key?",
initialValue: "plaintext",
options: [
{
value: "plaintext",
label: "Paste API key now",
hint: "Stores the key directly in OpenClaw config",
label: params.copy?.plaintextLabel ?? "Paste API key now",
hint: params.copy?.plaintextHint ?? "Stores the key directly in OpenClaw config",
},
{
value: "ref",
label: "Use secret reference",
hint: "Stores a reference to env or configured external secret providers",
label: params.copy?.refLabel ?? "Use external secret provider",
hint:
params.copy?.refHint ??
"Stores a reference to env or configured external secret providers",
},
],
});
@@ -466,7 +498,7 @@ export async function ensureApiKeyFromEnvOrPrompt(params: {
await params.setCredential(fallback.ref, selectedMode);
return fallback.resolvedValue;
}
const resolved = await resolveApiKeyRefForOnboarding({
const resolved = await promptSecretRefForOnboarding({
provider: params.provider,
config: params.config,
prompter: params.prompter,

View File

@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it } from "vitest";
import { applyAuthChoiceAnthropic } from "./auth-choice.apply.anthropic.js";
import { ANTHROPIC_SETUP_TOKEN_PREFIX } from "./auth-token.js";
import {
createAuthTestLifecycle,
createExitThrowingRuntime,
createWizardPrompter,
readAuthProfilesForAgent,
setupAuthTestEnv,
} from "./test-wizard-helpers.js";
describe("applyAuthChoiceAnthropic", () => {
const lifecycle = createAuthTestLifecycle([
"OPENCLAW_STATE_DIR",
"OPENCLAW_AGENT_DIR",
"PI_CODING_AGENT_DIR",
"ANTHROPIC_SETUP_TOKEN",
]);
async function setupTempState() {
const env = await setupAuthTestEnv("openclaw-anthropic-");
lifecycle.setStateDir(env.stateDir);
return env.agentDir;
}
afterEach(async () => {
await lifecycle.cleanup();
});
it("persists setup-token ref without plaintext token in auth-profiles store", async () => {
const agentDir = await setupTempState();
process.env.ANTHROPIC_SETUP_TOKEN = `${ANTHROPIC_SETUP_TOKEN_PREFIX}${"x".repeat(100)}`;
const prompter = createWizardPrompter({}, { defaultSelect: "ref" });
const runtime = createExitThrowingRuntime();
const result = await applyAuthChoiceAnthropic({
authChoice: "setup-token",
config: {},
prompter,
runtime,
setDefaultModel: true,
});
expect(result).not.toBeNull();
expect(result?.config.auth?.profiles?.["anthropic:default"]).toMatchObject({
provider: "anthropic",
mode: "token",
});
const parsed = await readAuthProfilesForAgent<{
profiles?: Record<string, { token?: string; tokenRef?: unknown }>;
}>(agentDir);
expect(parsed.profiles?.["anthropic:default"]?.token).toBeUndefined();
expect(parsed.profiles?.["anthropic:default"]?.tokenRef).toMatchObject({
source: "env",
provider: "default",
id: "ANTHROPIC_SETUP_TOKEN",
});
});
});

View File

@@ -3,6 +3,8 @@ import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key
import {
normalizeSecretInputModeInput,
ensureApiKeyFromOptionEnvOrPrompt,
promptSecretRefForOnboarding,
resolveSecretInputModeForEnvSelection,
} from "./auth-choice.apply-helpers.js";
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { buildTokenProfileId, validateAnthropicSetupToken } from "./auth-token.js";
@@ -28,11 +30,41 @@ export async function applyAuthChoiceAnthropic(
"Anthropic setup-token",
);
const tokenRaw = await params.prompter.text({
message: "Paste Anthropic setup-token",
validate: (value) => validateAnthropicSetupToken(String(value ?? "")),
const selectedMode = await resolveSecretInputModeForEnvSelection({
prompter: params.prompter,
explicitMode: requestedSecretInputMode,
copy: {
modeMessage: "How do you want to provide this setup token?",
plaintextLabel: "Paste setup token now",
plaintextHint: "Stores the token directly in the auth profile",
},
});
const token = String(tokenRaw ?? "").trim();
let token = "";
let tokenRef: { source: "env" | "file" | "exec"; provider: string; id: string } | undefined;
if (selectedMode === "ref") {
const resolved = await promptSecretRefForOnboarding({
provider: "anthropic-setup-token",
config: params.config,
prompter: params.prompter,
preferredEnvVar: "ANTHROPIC_SETUP_TOKEN",
copy: {
sourceMessage: "Where is this Anthropic setup token stored?",
envVarPlaceholder: "ANTHROPIC_SETUP_TOKEN",
},
});
token = resolved.resolvedValue.trim();
tokenRef = resolved.ref;
} else {
const tokenRaw = await params.prompter.text({
message: "Paste Anthropic setup-token",
validate: (value) => validateAnthropicSetupToken(String(value ?? "")),
});
token = String(tokenRaw ?? "").trim();
}
const tokenValidationError = validateAnthropicSetupToken(token);
if (tokenValidationError) {
throw new Error(tokenValidationError);
}
const profileNameRaw = await params.prompter.text({
message: "Token name (blank = default)",
@@ -51,6 +83,7 @@ export async function applyAuthChoiceAnthropic(
type: "token",
provider,
token,
...(tokenRef ? { tokenRef } : {}),
},
});

View File

@@ -1,5 +1,7 @@
import { getChannelPlugin } from "../../channels/plugins/index.js";
import type { ChannelResolveKind, ChannelResolveResult } from "../../channels/plugins/types.js";
import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js";
import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
import { loadConfig } from "../../config/config.js";
import { danger } from "../../globals.js";
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
@@ -68,7 +70,15 @@ function formatResolveResult(result: ResolveResult): string {
}
export async function channelsResolveCommand(opts: ChannelsResolveOptions, runtime: RuntimeEnv) {
const cfg = loadConfig();
const loadedRaw = loadConfig();
const { resolvedConfig: cfg, diagnostics } = await resolveCommandSecretRefsViaGateway({
config: loadedRaw,
commandName: "channels resolve",
targetIds: getChannelsCommandSecretTargetIds(),
});
for (const entry of diagnostics) {
runtime.log(`[secrets] ${entry}`);
}
const entries = (opts.entries ?? []).map((entry) => entry.trim()).filter(Boolean);
if (entries.length === 0) {
throw new Error("At least one entry is required.");

View File

@@ -1,4 +1,6 @@
import { type ChannelId, getChannelPlugin } from "../../channels/plugins/index.js";
import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js";
import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
import type { OpenClawConfig } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
@@ -9,7 +11,19 @@ export type ChatChannel = ChannelId;
export async function requireValidConfig(
runtime: RuntimeEnv = defaultRuntime,
): Promise<OpenClawConfig | null> {
return await requireValidConfigSnapshot(runtime);
const cfg = await requireValidConfigSnapshot(runtime);
if (!cfg) {
return null;
}
const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({
config: cfg,
commandName: "channels",
targetIds: getChannelsCommandSecretTargetIds(),
});
for (const entry of diagnostics) {
runtime.log(`[secrets] ${entry}`);
}
return resolvedConfig;
}
export function formatAccountLabel(params: { accountId: string; name?: string }) {

View File

@@ -4,6 +4,7 @@ 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";
@@ -61,7 +62,9 @@ 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 password = params.cfg.gateway?.auth?.password ?? process.env.OPENCLAW_GATEWAY_PASSWORD;
const password =
normalizeSecretInputString(params.cfg.gateway?.auth?.password) ??
process.env.OPENCLAW_GATEWAY_PASSWORD;
await waitForGatewayReachable({
url: wsUrl,
@@ -247,13 +250,15 @@ export async function runConfigureWizard(
const localProbe = await probeGatewayReachable({
url: localUrl,
token: baseConfig.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN,
password: baseConfig.gateway?.auth?.password ?? process.env.OPENCLAW_GATEWAY_PASSWORD,
password:
normalizeSecretInputString(baseConfig.gateway?.auth?.password) ??
process.env.OPENCLAW_GATEWAY_PASSWORD,
});
const remoteUrl = baseConfig.gateway?.remote?.url?.trim() ?? "";
const remoteProbe = remoteUrl
? await probeGatewayReachable({
url: remoteUrl,
token: baseConfig.gateway?.remote?.token,
token: normalizeSecretInputString(baseConfig.gateway?.remote?.token),
})
: null;
@@ -312,8 +317,8 @@ export async function runConfigureWizard(
DEFAULT_WORKSPACE;
let gatewayPort = resolveGatewayPort(baseConfig);
let gatewayToken: string | undefined =
nextConfig.gateway?.auth?.token ??
baseConfig.gateway?.auth?.token ??
normalizeSecretInputString(nextConfig.gateway?.auth?.token) ??
normalizeSecretInputString(baseConfig.gateway?.auth?.token) ??
process.env.OPENCLAW_GATEWAY_TOKEN;
const persistConfig = async () => {
@@ -534,8 +539,12 @@ export async function runConfigureWizard(
basePath: nextConfig.gateway?.controlUi?.basePath,
});
// Try both new and old passwords since gateway may still have old config.
const newPassword = nextConfig.gateway?.auth?.password ?? process.env.OPENCLAW_GATEWAY_PASSWORD;
const oldPassword = baseConfig.gateway?.auth?.password ?? process.env.OPENCLAW_GATEWAY_PASSWORD;
const newPassword =
normalizeSecretInputString(nextConfig.gateway?.auth?.password) ??
process.env.OPENCLAW_GATEWAY_PASSWORD;
const oldPassword =
normalizeSecretInputString(baseConfig.gateway?.auth?.password) ??
process.env.OPENCLAW_GATEWAY_PASSWORD;
const token = nextConfig.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN;
let gatewayProbe = await probeGatewayReachable({

View File

@@ -40,6 +40,31 @@ describe("noteMacLaunchctlGatewayEnvOverrides", () => {
expect(noteFn).not.toHaveBeenCalled();
});
it("treats SecretRef-backed credentials as configured", async () => {
const noteFn = vi.fn();
const getenv = vi.fn(async (name: string) =>
name === "OPENCLAW_GATEWAY_PASSWORD" ? "launchctl-password" : undefined,
);
const cfg = {
gateway: {
auth: {
password: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_PASSWORD" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as OpenClawConfig;
await noteMacLaunchctlGatewayEnvOverrides(cfg, { platform: "darwin", getenv, noteFn });
expect(noteFn).toHaveBeenCalledTimes(1);
const [message] = noteFn.mock.calls[0] ?? [];
expect(message).toContain("OPENCLAW_GATEWAY_PASSWORD");
});
it("does nothing on non-darwin platforms", async () => {
const noteFn = vi.fn();
const getenv = vi.fn(async () => "launchctl-token");

View File

@@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import type { OpenClawConfig } from "../config/config.js";
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
import { note } from "../terminal/note.js";
import { shortenHomePath } from "../utils.js";
@@ -45,14 +46,16 @@ 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.trim() : "";
const localPassword =
typeof cfg.gateway?.auth?.password === "string" ? cfg.gateway?.auth?.password.trim() : "";
const remoteToken =
typeof cfg.gateway?.remote?.token === "string" ? cfg.gateway?.remote?.token.trim() : "";
const remotePassword =
typeof cfg.gateway?.remote?.password === "string" ? cfg.gateway?.remote?.password.trim() : "";
return Boolean(localToken || localPassword || remoteToken || remotePassword);
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(localPassword, cfg.secrets?.defaults) ||
hasConfiguredSecretInput(remoteToken, cfg.secrets?.defaults) ||
hasConfiguredSecretInput(remotePassword, cfg.secrets?.defaults),
);
}
export async function noteMacLaunchctlGatewayEnvOverrides(

View File

@@ -18,6 +18,14 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
const resolveCommandSecretRefsViaGateway = vi.fn(async ({ config }: { config: unknown }) => ({
resolvedConfig: config,
diagnostics: [] as string[],
}));
vi.mock("../cli/command-secret-gateway.js", () => ({
resolveCommandSecretRefsViaGateway,
}));
const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: callGatewayMock,
@@ -69,6 +77,7 @@ beforeEach(async () => {
handleSlackAction.mockClear();
handleTelegramAction.mockClear();
handleWhatsAppAction.mockClear();
resolveCommandSecretRefsViaGateway.mockClear();
});
afterEach(() => {

View File

@@ -2,6 +2,8 @@ import {
CHANNEL_MESSAGE_ACTION_NAMES,
type ChannelMessageActionName,
} from "../channels/plugins/types.js";
import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js";
import { getChannelsCommandSecretTargetIds } from "../cli/command-secret-targets.js";
import { createOutboundSendDeps, type CliDeps } from "../cli/outbound-send-deps.js";
import { withProgress } from "../cli/progress.js";
import { loadConfig } from "../config/config.js";
@@ -16,7 +18,15 @@ export async function messageCommand(
deps: CliDeps,
runtime: RuntimeEnv,
) {
const cfg = loadConfig();
const loadedRaw = loadConfig();
const { resolvedConfig: cfg, diagnostics } = await resolveCommandSecretRefsViaGateway({
config: loadedRaw,
commandName: "message",
targetIds: getChannelsCommandSecretTargetIds(),
});
for (const entry of diagnostics) {
runtime.log(`[secrets] ${entry}`);
}
const rawAction = typeof opts.action === "string" ? opts.action.trim() : "";
const actionInput = rawAction || "send";
const actionMatch = (CHANNEL_MESSAGE_ACTION_NAMES as readonly string[]).find(

View File

@@ -1,6 +1,6 @@
import { loadConfig } from "../../config/config.js";
import { logConfigUpdated } from "../../config/logging.js";
import type { RuntimeEnv } from "../../runtime.js";
import { loadModelsConfig } from "./load-config.js";
import {
ensureFlagCompatibility,
normalizeAlias,
@@ -13,7 +13,7 @@ export async function modelsAliasesListCommand(
runtime: RuntimeEnv,
) {
ensureFlagCompatibility(opts);
const cfg = loadConfig();
const cfg = await loadModelsConfig({ commandName: "models aliases list", runtime });
const models = cfg.agents?.defaults?.models ?? {};
const aliases = Object.entries(models).reduce<Record<string, string>>(
(acc, [modelKey, entry]) => {
@@ -53,7 +53,8 @@ export async function modelsAliasesAddCommand(
runtime: RuntimeEnv,
) {
const alias = normalizeAlias(aliasRaw);
const resolved = resolveModelTarget({ raw: modelRaw, cfg: loadConfig() });
const cfg = await loadModelsConfig({ commandName: "models aliases add", runtime });
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
const _updated = await updateConfig((cfg) => {
const modelKey = `${resolved.provider}/${resolved.model}`;
const nextModels = { ...cfg.agents?.defaults?.models };

View File

@@ -5,13 +5,13 @@ import {
setAuthProfileOrder,
} from "../../agents/auth-profiles.js";
import { normalizeProviderId } from "../../agents/model-selection.js";
import { loadConfig } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
import { shortenHomePath } from "../../utils.js";
import { loadModelsConfig } from "./load-config.js";
import { resolveKnownAgentId } from "./shared.js";
function resolveTargetAgent(
cfg: ReturnType<typeof loadConfig>,
cfg: Awaited<ReturnType<typeof loadModelsConfig>>,
raw?: string,
): {
agentId: string;
@@ -28,13 +28,16 @@ function describeOrder(store: AuthProfileStore, provider: string): string[] {
return Array.isArray(order) ? order : [];
}
function resolveAuthOrderContext(opts: { provider: string; agent?: string }) {
async function resolveAuthOrderContext(
opts: { provider: string; agent?: string },
runtime: RuntimeEnv,
) {
const rawProvider = opts.provider?.trim();
if (!rawProvider) {
throw new Error("Missing --provider.");
}
const provider = normalizeProviderId(rawProvider);
const cfg = loadConfig();
const cfg = await loadModelsConfig({ commandName: "models auth-order", runtime });
const { agentId, agentDir } = resolveTargetAgent(cfg, opts.agent);
return { cfg, agentId, agentDir, provider };
}
@@ -43,7 +46,7 @@ export async function modelsAuthOrderGetCommand(
opts: { provider: string; agent?: string; json?: boolean },
runtime: RuntimeEnv,
) {
const { agentId, agentDir, provider } = resolveAuthOrderContext(opts);
const { agentId, agentDir, provider } = await resolveAuthOrderContext(opts, runtime);
const store = ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,
});
@@ -76,7 +79,7 @@ export async function modelsAuthOrderClearCommand(
opts: { provider: string; agent?: string },
runtime: RuntimeEnv,
) {
const { agentId, agentDir, provider } = resolveAuthOrderContext(opts);
const { agentId, agentDir, provider } = await resolveAuthOrderContext(opts, runtime);
const updated = await setAuthProfileOrder({
agentDir,
provider,
@@ -95,7 +98,7 @@ export async function modelsAuthOrderSetCommand(
opts: { provider: string; agent?: string; order: string[] },
runtime: RuntimeEnv,
) {
const { agentId, agentDir, provider } = resolveAuthOrderContext(opts);
const { agentId, agentDir, provider } = await resolveAuthOrderContext(opts, runtime);
const store = ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,

View File

@@ -1,9 +1,9 @@
import { buildModelAliasIndex, resolveModelRefFromString } from "../../agents/model-selection.js";
import type { OpenClawConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import { logConfigUpdated } from "../../config/logging.js";
import { resolveAgentModelFallbackValues, toAgentModelListLike } from "../../config/model-input.js";
import type { RuntimeEnv } from "../../runtime.js";
import { loadModelsConfig } from "./load-config.js";
import {
DEFAULT_PROVIDER,
ensureFlagCompatibility,
@@ -44,7 +44,7 @@ export async function listFallbacksCommand(
runtime: RuntimeEnv,
) {
ensureFlagCompatibility(opts);
const cfg = loadConfig();
const cfg = await loadModelsConfig({ commandName: `models ${params.key} list`, runtime });
const fallbacks = getFallbacks(cfg, params.key);
if (opts.json) {

View File

@@ -8,6 +8,7 @@ import { formatErrorWithStack } from "./list.errors.js";
import { loadModelRegistry, toModelRow } from "./list.registry.js";
import { printModelTable } from "./list.table.js";
import type { ModelRow } from "./list.types.js";
import { loadModelsConfig } from "./load-config.js";
import { DEFAULT_PROVIDER, ensureFlagCompatibility, isLocalBaseUrl, modelKey } from "./shared.js";
export async function modelsListCommand(
@@ -21,9 +22,8 @@ export async function modelsListCommand(
runtime: RuntimeEnv,
) {
ensureFlagCompatibility(opts);
const { loadConfig } = await import("../../config/config.js");
const { ensureAuthProfileStore } = await import("../../agents/auth-profiles.js");
const cfg = loadConfig();
const cfg = await loadModelsConfig({ commandName: "models list", runtime });
const authStore = ensureAuthProfileStore();
const providerFilter = (() => {
const raw = opts.provider?.trim();

View File

@@ -25,7 +25,7 @@ import {
} from "../../agents/model-selection.js";
import { formatCliCommand } from "../../cli/command-format.js";
import { withProgressTotals } from "../../cli/progress.js";
import { CONFIG_PATH, loadConfig } from "../../config/config.js";
import { CONFIG_PATH } from "../../config/config.js";
import {
resolveAgentModelFallbackValues,
resolveAgentModelPrimaryValue,
@@ -50,6 +50,7 @@ import {
sortProbeResults,
type AuthProbeSummary,
} from "./list.probe.js";
import { loadModelsConfig } from "./load-config.js";
import {
DEFAULT_MODEL,
DEFAULT_PROVIDER,
@@ -76,7 +77,7 @@ export async function modelsStatusCommand(
if (opts.plain && opts.probe) {
throw new Error("--probe cannot be used with --plain output.");
}
const cfg = loadConfig();
const cfg = await loadModelsConfig({ commandName: "models status", runtime });
const agentId = resolveKnownAgentId({ cfg, rawAgentId: opts.agent });
const agentDir = agentId ? resolveAgentDir(cfg, agentId) : resolveOpenClawAgentDir();
const agentModelPrimary = agentId ? resolveAgentExplicitModelPrimary(cfg, agentId) : undefined;

View File

@@ -0,0 +1,22 @@
import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js";
import { getModelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
import { loadConfig, type OpenClawConfig } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
export async function loadModelsConfig(params: {
commandName: string;
runtime?: RuntimeEnv;
}): Promise<OpenClawConfig> {
const loadedRaw = loadConfig();
const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({
config: loadedRaw,
commandName: params.commandName,
targetIds: getModelsCommandSecretTargetIds(),
});
if (params.runtime) {
for (const entry of diagnostics) {
params.runtime.log(`[secrets] ${entry}`);
}
}
return resolvedConfig;
}

View File

@@ -2,7 +2,6 @@ import { cancel, multiselect as clackMultiselect, isCancel } from "@clack/prompt
import { resolveApiKeyForProvider } from "../../agents/model-auth.js";
import { type ModelScanResult, scanOpenRouterModels } from "../../agents/model-scan.js";
import { withProgressTotals } from "../../cli/progress.js";
import { loadConfig } from "../../config/config.js";
import { logConfigUpdated } from "../../config/logging.js";
import { toAgentModelListLike } from "../../config/model-input.js";
import type { RuntimeEnv } from "../../runtime.js";
@@ -12,6 +11,7 @@ import {
stylePromptTitle,
} from "../../terminal/prompt-style.js";
import { pad, truncate } from "./list.format.js";
import { loadModelsConfig } from "./load-config.js";
import { formatMs, formatTokenK, updateConfig } from "./shared.js";
const MODEL_PAD = 42;
@@ -167,7 +167,7 @@ export async function modelsScanCommand(
throw new Error("--concurrency must be > 0");
}
const cfg = loadConfig();
const cfg = await loadModelsConfig({ commandName: "models scan", runtime });
const probe = opts.probe ?? true;
let storedKey: string | undefined;
if (probe) {

View File

@@ -132,23 +132,46 @@ describe("promptRemoteGatewayConfig", () => {
expect(next.gateway?.remote?.token).toBeUndefined();
});
it("allows private ws:// only when OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1", async () => {
process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS = "1";
it("supports storing remote auth as an external env secret ref", async () => {
process.env.OPENCLAW_GATEWAY_TOKEN = "remote-token-value";
const text: WizardPrompter["text"] = vi.fn(async (params) => {
if (params.message === "Gateway WebSocket URL") {
expect(params.validate?.("ws://10.0.0.8:18789")).toBeUndefined();
return "ws://10.0.0.8:18789";
return "wss://remote.example.com:18789";
}
if (params.message === "Environment variable name") {
return "OPENCLAW_GATEWAY_TOKEN";
}
return "";
}) as WizardPrompter["text"];
const { next } = await runRemotePrompt({
text,
confirm: false,
selectResponses: { "Gateway auth": "off" },
const select: WizardPrompter["select"] = vi.fn(async (params) => {
if (params.message === "Gateway auth") {
return "token" as never;
}
if (params.message === "How do you want to provide this gateway token?") {
return "ref" as never;
}
if (params.message === "Where is this gateway token stored?") {
return "env" as never;
}
return (params.options[0]?.value ?? "") as never;
});
expect(next.gateway?.remote?.url).toBe("ws://10.0.0.8:18789");
const cfg = {} as OpenClawConfig;
const prompter = createPrompter({
confirm: vi.fn(async () => false),
select,
text,
});
const next = await promptRemoteGatewayConfig(cfg, prompter);
expect(next.gateway?.mode).toBe("remote");
expect(next.gateway?.remote?.url).toBe("wss://remote.example.com:18789");
expect(next.gateway?.remote?.token).toEqual({
source: "env",
provider: "default",
id: "OPENCLAW_GATEWAY_TOKEN",
});
});
});

View File

@@ -1,10 +1,16 @@
import type { OpenClawConfig } from "../config/config.js";
import type { SecretInput } from "../config/types.secrets.js";
import { isSecureWebSocketUrl } from "../gateway/net.js";
import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js";
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
import { resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import {
promptSecretRefForOnboarding,
resolveSecretInputModeForEnvSelection,
} from "./auth-choice.apply-helpers.js";
import { detectBinary } from "./onboard-helpers.js";
import type { SecretInputMode } from "./onboard-types.js";
const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789";
@@ -51,6 +57,7 @@ function validateGatewayWebSocketUrl(value: string): string | undefined {
export async function promptRemoteGatewayConfig(
cfg: OpenClawConfig,
prompter: WizardPrompter,
options?: { secretInputMode?: SecretInputMode },
): Promise<OpenClawConfig> {
let selectedBeacon: GatewayBonjourBeacon | null = null;
let suggestedUrl = cfg.gateway?.remote?.url ?? DEFAULT_GATEWAY_URL;
@@ -150,21 +157,80 @@ export async function promptRemoteGatewayConfig(
message: "Gateway auth",
options: [
{ value: "token", label: "Token (recommended)" },
{ value: "password", label: "Password" },
{ value: "off", label: "No auth" },
],
});
let token = cfg.gateway?.remote?.token ?? "";
let token: SecretInput | undefined = cfg.gateway?.remote?.token;
let password: SecretInput | undefined = cfg.gateway?.remote?.password;
if (authChoice === "token") {
token = String(
await prompter.text({
message: "Gateway token",
initialValue: token,
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
const selectedMode = await resolveSecretInputModeForEnvSelection({
prompter,
explicitMode: options?.secretInputMode,
copy: {
modeMessage: "How do you want to provide this gateway token?",
plaintextLabel: "Enter token now",
plaintextHint: "Stores the token directly in OpenClaw config",
},
});
if (selectedMode === "ref") {
const resolved = await promptSecretRefForOnboarding({
provider: "gateway-remote-token",
config: cfg,
prompter,
preferredEnvVar: "OPENCLAW_GATEWAY_TOKEN",
copy: {
sourceMessage: "Where is this gateway token stored?",
envVarPlaceholder: "OPENCLAW_GATEWAY_TOKEN",
},
});
token = resolved.ref;
} else {
token = String(
await prompter.text({
message: "Gateway token",
initialValue: typeof token === "string" ? token : undefined,
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
password = undefined;
} else if (authChoice === "password") {
const selectedMode = await resolveSecretInputModeForEnvSelection({
prompter,
explicitMode: options?.secretInputMode,
copy: {
modeMessage: "How do you want to provide this gateway password?",
plaintextLabel: "Enter password now",
plaintextHint: "Stores the password directly in OpenClaw config",
},
});
if (selectedMode === "ref") {
const resolved = await promptSecretRefForOnboarding({
provider: "gateway-remote-password",
config: cfg,
prompter,
preferredEnvVar: "OPENCLAW_GATEWAY_PASSWORD",
copy: {
sourceMessage: "Where is this gateway password stored?",
envVarPlaceholder: "OPENCLAW_GATEWAY_PASSWORD",
},
});
password = resolved.ref;
} else {
password = String(
await prompter.text({
message: "Gateway password",
initialValue: typeof password === "string" ? password : undefined,
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
token = undefined;
} else {
token = "";
token = undefined;
password = undefined;
}
return {
@@ -174,7 +240,8 @@ export async function promptRemoteGatewayConfig(
mode: "remote",
remote: {
url,
token: token || undefined,
...(token !== undefined ? { token } : {}),
...(password !== undefined ? { password } : {}),
},
},
};

View File

@@ -1,5 +1,7 @@
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import { formatCliCommand } from "../cli/command-format.js";
import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js";
import { getStatusCommandSecretTargetIds } from "../cli/command-secret-targets.js";
import { withProgress } from "../cli/progress.js";
import { loadConfig, readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
@@ -36,7 +38,12 @@ export async function statusAllCommand(
): Promise<void> {
await withProgress({ label: "Scanning status --all…", total: 11 }, async (progress) => {
progress.setLabel("Loading config…");
const cfg = loadConfig();
const loadedRaw = loadConfig();
const { resolvedConfig: cfg } = await resolveCommandSecretRefsViaGateway({
config: loadedRaw,
commandName: "status --all",
targetIds: getStatusCommandSecretTargetIds(),
});
const osSummary = resolveOsSummary();
const snap = await readConfigFileSnapshot().catch(() => null);
progress.tick();

View File

@@ -1,3 +1,5 @@
import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js";
import { getStatusCommandSecretTargetIds } from "../cli/command-secret-targets.js";
import { withProgress } from "../cli/progress.js";
import { loadConfig } from "../config/config.js";
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
@@ -148,7 +150,12 @@ async function scanStatusJsonFast(opts: {
timeoutMs?: number;
all?: boolean;
}): Promise<StatusScanResult> {
const cfg = loadConfig();
const loadedRaw = loadConfig();
const { resolvedConfig: cfg } = await resolveCommandSecretRefsViaGateway({
config: loadedRaw,
commandName: "status --json",
targetIds: getStatusCommandSecretTargetIds(),
});
const osSummary = resolveOsSummary();
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
const updateTimeoutMs = opts.all ? 6500 : 2500;
@@ -158,7 +165,7 @@ async function scanStatusJsonFast(opts: {
includeRegistry: true,
});
const agentStatusPromise = getAgentLocalStatuses();
const summaryPromise = getStatusSummary();
const summaryPromise = getStatusSummary({ config: cfg });
const tailscaleDnsPromise =
tailscaleMode === "off"
@@ -233,7 +240,12 @@ export async function scanStatus(
},
async (progress) => {
progress.setLabel("Loading config…");
const cfg = loadConfig();
const loadedRaw = loadConfig();
const { resolvedConfig: cfg } = await resolveCommandSecretRefsViaGateway({
config: loadedRaw,
commandName: "status",
targetIds: getStatusCommandSecretTargetIds(),
});
const osSummary = resolveOsSummary();
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
const tailscaleDnsPromise =
@@ -251,7 +263,7 @@ export async function scanStatus(
}),
);
const agentStatusPromise = deferResult(getAgentLocalStatuses());
const summaryPromise = deferResult(getStatusSummary());
const summaryPromise = deferResult(getStatusSummary({ config: cfg }));
progress.tick();
progress.setLabel("Checking Tailscale…");

View File

@@ -1,6 +1,7 @@
import { resolveContextTokensForModel } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import type { OpenClawConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import {
loadSessionStore,
@@ -76,10 +77,10 @@ export function redactSensitiveStatusSummary(summary: StatusSummary): StatusSumm
}
export async function getStatusSummary(
options: { includeSensitive?: boolean } = {},
options: { includeSensitive?: boolean; config?: OpenClawConfig } = {},
): Promise<StatusSummary> {
const { includeSensitive = true } = options;
const cfg = loadConfig();
const cfg = options.config ?? loadConfig();
const linkContext = await resolveLinkChannelContext(cfg);
const agentList = listAgentsForGateway(cfg);
const heartbeatAgents: HeartbeatStatus[] = agentList.agents.map((agent) => {