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

@@ -0,0 +1,315 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
const callGateway = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway,
}));
const { resolveCommandSecretRefsViaGateway } = await import("./command-secret-gateway.js");
describe("resolveCommandSecretRefsViaGateway", () => {
it("returns config unchanged when no target SecretRefs are configured", async () => {
const config = {
talk: {
apiKey: "plain",
},
} as OpenClawConfig;
const result = await resolveCommandSecretRefsViaGateway({
config,
commandName: "memory status",
targetIds: new Set(["talk.apiKey"]),
});
expect(result.resolvedConfig).toEqual(config);
expect(callGateway).not.toHaveBeenCalled();
});
it("skips gateway resolution when all configured target refs are inactive", async () => {
const config = {
agents: {
list: [
{
id: "main",
memorySearch: {
enabled: false,
remote: {
apiKey: { source: "env", provider: "default", id: "AGENT_MEMORY_API_KEY" },
},
},
},
],
},
} as unknown as OpenClawConfig;
const result = await resolveCommandSecretRefsViaGateway({
config,
commandName: "status",
targetIds: new Set(["agents.list[].memorySearch.remote.apiKey"]),
});
expect(callGateway).not.toHaveBeenCalled();
expect(result.resolvedConfig).toEqual(config);
expect(result.diagnostics).toEqual([
"agents.list.0.memorySearch.remote.apiKey: agent or memorySearch override is disabled.",
]);
});
it("hydrates requested SecretRef targets from gateway snapshot assignments", async () => {
callGateway.mockResolvedValueOnce({
assignments: [
{
path: "talk.apiKey",
pathSegments: ["talk", "apiKey"],
value: "sk-live",
},
],
diagnostics: [],
});
const config = {
talk: {
apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" },
},
} as OpenClawConfig;
const result = await resolveCommandSecretRefsViaGateway({
config,
commandName: "memory status",
targetIds: new Set(["talk.apiKey"]),
});
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "secrets.resolve",
requiredMethods: ["secrets.resolve"],
params: {
commandName: "memory status",
targetIds: ["talk.apiKey"],
},
}),
);
expect(result.resolvedConfig.talk?.apiKey).toBe("sk-live");
});
it("fails fast when gateway-backed resolution is unavailable", async () => {
callGateway.mockRejectedValueOnce(new Error("gateway closed"));
await expect(
resolveCommandSecretRefsViaGateway({
config: {
talk: {
apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" },
},
} as OpenClawConfig,
commandName: "memory status",
targetIds: new Set(["talk.apiKey"]),
}),
).rejects.toThrow(/failed to resolve secrets from the active gateway snapshot/i);
});
it("falls back to local resolution when gateway secrets.resolve is unavailable", async () => {
process.env.TALK_API_KEY = "local-fallback-key";
callGateway.mockRejectedValueOnce(new Error("gateway closed"));
const result = await resolveCommandSecretRefsViaGateway({
config: {
talk: {
apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" },
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as OpenClawConfig,
commandName: "memory status",
targetIds: new Set(["talk.apiKey"]),
});
delete process.env.TALK_API_KEY;
expect(result.resolvedConfig.talk?.apiKey).toBe("local-fallback-key");
expect(
result.diagnostics.some((entry) => entry.includes("gateway secrets.resolve unavailable")),
).toBe(true);
});
it("returns a version-skew hint when gateway does not support secrets.resolve", async () => {
callGateway.mockRejectedValueOnce(new Error("unknown method: secrets.resolve"));
await expect(
resolveCommandSecretRefsViaGateway({
config: {
talk: {
apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" },
},
} as OpenClawConfig,
commandName: "memory status",
targetIds: new Set(["talk.apiKey"]),
}),
).rejects.toThrow(/does not support secrets\.resolve/i);
});
it("returns a version-skew hint when required-method capability check fails", async () => {
callGateway.mockRejectedValueOnce(
new Error(
'active gateway does not support required method "secrets.resolve" for "secrets.resolve".',
),
);
await expect(
resolveCommandSecretRefsViaGateway({
config: {
talk: {
apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" },
},
} as OpenClawConfig,
commandName: "memory status",
targetIds: new Set(["talk.apiKey"]),
}),
).rejects.toThrow(/does not support secrets\.resolve/i);
});
it("fails when gateway returns an invalid secrets.resolve payload", async () => {
callGateway.mockResolvedValueOnce({
assignments: "not-an-array",
diagnostics: [],
});
await expect(
resolveCommandSecretRefsViaGateway({
config: {
talk: {
apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" },
},
} as OpenClawConfig,
commandName: "memory status",
targetIds: new Set(["talk.apiKey"]),
}),
).rejects.toThrow(/invalid secrets\.resolve payload/i);
});
it("fails when gateway assignment path does not exist in local config", async () => {
callGateway.mockResolvedValueOnce({
assignments: [
{
path: "talk.providers.elevenlabs.apiKey",
pathSegments: ["talk", "providers", "elevenlabs", "apiKey"],
value: "sk-live",
},
],
diagnostics: [],
});
await expect(
resolveCommandSecretRefsViaGateway({
config: {
talk: {
apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" },
},
} as OpenClawConfig,
commandName: "memory status",
targetIds: new Set(["talk.apiKey"]),
}),
).rejects.toThrow(/Path segment does not exist/i);
});
it("fails when configured refs remain unresolved after gateway assignments are applied", async () => {
callGateway.mockResolvedValueOnce({
assignments: [],
diagnostics: [],
});
await expect(
resolveCommandSecretRefsViaGateway({
config: {
talk: {
apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" },
},
} as OpenClawConfig,
commandName: "memory status",
targetIds: new Set(["talk.apiKey"]),
}),
).rejects.toThrow(/talk\.apiKey is unresolved in the active runtime snapshot/i);
});
it("allows unresolved refs when gateway diagnostics mark the target as inactive", async () => {
callGateway.mockResolvedValueOnce({
assignments: [],
diagnostics: [
"talk.apiKey: secret ref is configured on an inactive surface; skipping command-time assignment.",
],
});
const result = await resolveCommandSecretRefsViaGateway({
config: {
talk: {
apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" },
},
} as OpenClawConfig,
commandName: "memory status",
targetIds: new Set(["talk.apiKey"]),
});
expect(result.resolvedConfig.talk?.apiKey).toEqual({
source: "env",
provider: "default",
id: "TALK_API_KEY",
});
expect(result.diagnostics).toEqual([
"talk.apiKey: secret ref is configured on an inactive surface; skipping command-time assignment.",
]);
});
it("uses inactiveRefPaths from structured response without parsing diagnostic text", async () => {
callGateway.mockResolvedValueOnce({
assignments: [],
diagnostics: ["talk api key inactive"],
inactiveRefPaths: ["talk.apiKey"],
});
const result = await resolveCommandSecretRefsViaGateway({
config: {
talk: {
apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" },
},
} as OpenClawConfig,
commandName: "memory status",
targetIds: new Set(["talk.apiKey"]),
});
expect(result.resolvedConfig.talk?.apiKey).toEqual({
source: "env",
provider: "default",
id: "TALK_API_KEY",
});
expect(result.diagnostics).toEqual(["talk api key inactive"]);
});
it("allows unresolved array-index refs when gateway marks concrete paths inactive", async () => {
callGateway.mockResolvedValueOnce({
assignments: [],
diagnostics: ["memory search ref inactive"],
inactiveRefPaths: ["agents.list.0.memorySearch.remote.apiKey"],
});
const config = {
agents: {
list: [
{
id: "main",
memorySearch: {
remote: {
apiKey: { source: "env", provider: "default", id: "MISSING_MEMORY_API_KEY" },
},
},
},
],
},
} as unknown as OpenClawConfig;
const result = await resolveCommandSecretRefsViaGateway({
config,
commandName: "memory status",
targetIds: new Set(["agents.list[].memorySearch.remote.apiKey"]),
});
expect(result.resolvedConfig.agents?.list?.[0]?.memorySearch?.remote?.apiKey).toEqual({
source: "env",
provider: "default",
id: "MISSING_MEMORY_API_KEY",
});
expect(result.diagnostics).toEqual(["memory search ref inactive"]);
});
});

View File

@@ -0,0 +1,317 @@
import type { OpenClawConfig } from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { callGateway } from "../gateway/call.js";
import { validateSecretsResolveResult } from "../gateway/protocol/index.js";
import { collectCommandSecretAssignmentsFromSnapshot } from "../secrets/command-config.js";
import { setPathExistingStrict } from "../secrets/path-utils.js";
import { resolveSecretRefValues } from "../secrets/resolve.js";
import { collectConfigAssignments } from "../secrets/runtime-config-collectors.js";
import { applyResolvedAssignments, createResolverContext } from "../secrets/runtime-shared.js";
import { describeUnknownError } from "../secrets/shared.js";
import { discoverConfigSecretTargetsByIds } from "../secrets/target-registry.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
type ResolveCommandSecretsResult = {
resolvedConfig: OpenClawConfig;
diagnostics: string[];
};
type GatewaySecretsResolveResult = {
ok?: boolean;
assignments?: Array<{
path?: string;
pathSegments: string[];
value: unknown;
}>;
diagnostics?: string[];
inactiveRefPaths?: string[];
};
function dedupeDiagnostics(entries: readonly string[]): string[] {
const seen = new Set<string>();
const ordered: string[] = [];
for (const entry of entries) {
const trimmed = entry.trim();
if (!trimmed || seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
ordered.push(trimmed);
}
return ordered;
}
function collectConfiguredTargetRefPaths(params: {
config: OpenClawConfig;
targetIds: Set<string>;
}): Set<string> {
const defaults = params.config.secrets?.defaults;
const configuredTargetRefPaths = new Set<string>();
for (const target of discoverConfigSecretTargetsByIds(params.config, params.targetIds)) {
const { ref } = resolveSecretInputRef({
value: target.value,
refValue: target.refValue,
defaults,
});
if (ref) {
configuredTargetRefPaths.add(target.path);
}
}
return configuredTargetRefPaths;
}
function classifyConfiguredTargetRefs(params: {
config: OpenClawConfig;
configuredTargetRefPaths: Set<string>;
}): {
hasActiveConfiguredRef: boolean;
hasUnknownConfiguredRef: boolean;
diagnostics: string[];
} {
if (params.configuredTargetRefPaths.size === 0) {
return {
hasActiveConfiguredRef: false,
hasUnknownConfiguredRef: false,
diagnostics: [],
};
}
const context = createResolverContext({
sourceConfig: params.config,
env: process.env,
});
collectConfigAssignments({
config: structuredClone(params.config),
context,
});
const activePaths = new Set(context.assignments.map((assignment) => assignment.path));
const inactiveWarningsByPath = new Map<string, string>();
for (const warning of context.warnings) {
if (warning.code !== "SECRETS_REF_IGNORED_INACTIVE_SURFACE") {
continue;
}
inactiveWarningsByPath.set(warning.path, warning.message);
}
const diagnostics = new Set<string>();
let hasActiveConfiguredRef = false;
let hasUnknownConfiguredRef = false;
for (const path of params.configuredTargetRefPaths) {
if (activePaths.has(path)) {
hasActiveConfiguredRef = true;
continue;
}
const inactiveWarning = inactiveWarningsByPath.get(path);
if (inactiveWarning) {
diagnostics.add(inactiveWarning);
continue;
}
hasUnknownConfiguredRef = true;
}
return {
hasActiveConfiguredRef,
hasUnknownConfiguredRef,
diagnostics: [...diagnostics],
};
}
function parseGatewaySecretsResolveResult(payload: unknown): {
assignments: Array<{ path?: string; pathSegments: string[]; value: unknown }>;
diagnostics: string[];
inactiveRefPaths: string[];
} {
if (!validateSecretsResolveResult(payload)) {
throw new Error("gateway returned invalid secrets.resolve payload.");
}
const parsed = payload as GatewaySecretsResolveResult;
return {
assignments: parsed.assignments ?? [],
diagnostics: (parsed.diagnostics ?? []).filter((entry) => entry.trim().length > 0),
inactiveRefPaths: (parsed.inactiveRefPaths ?? []).filter((entry) => entry.trim().length > 0),
};
}
function collectInactiveSurfacePathsFromDiagnostics(diagnostics: string[]): Set<string> {
const paths = new Set<string>();
for (const entry of diagnostics) {
const marker = ": secret ref is configured on an inactive surface;";
const markerIndex = entry.indexOf(marker);
if (markerIndex <= 0) {
continue;
}
const path = entry.slice(0, markerIndex).trim();
if (path.length > 0) {
paths.add(path);
}
}
return paths;
}
function isUnsupportedSecretsResolveError(err: unknown): boolean {
const message = describeUnknownError(err).toLowerCase();
if (!message.includes("secrets.resolve")) {
return false;
}
return (
message.includes("does not support required method") ||
message.includes("unknown method") ||
message.includes("method not found") ||
message.includes("invalid request")
);
}
async function resolveCommandSecretRefsLocally(params: {
config: OpenClawConfig;
commandName: string;
targetIds: Set<string>;
preflightDiagnostics: string[];
}): Promise<ResolveCommandSecretsResult> {
const sourceConfig = params.config;
const resolvedConfig = structuredClone(params.config);
const context = createResolverContext({
sourceConfig,
env: process.env,
});
collectConfigAssignments({
config: resolvedConfig,
context,
});
if (context.assignments.length > 0) {
const resolved = await resolveSecretRefValues(
context.assignments.map((assignment) => assignment.ref),
{
config: sourceConfig,
env: context.env,
cache: context.cache,
},
);
applyResolvedAssignments({
assignments: context.assignments,
resolved,
});
}
const inactiveRefPaths = new Set(
context.warnings
.filter((warning) => warning.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE")
.map((warning) => warning.path),
);
const commandAssignments = collectCommandSecretAssignmentsFromSnapshot({
sourceConfig,
resolvedConfig,
commandName: params.commandName,
targetIds: params.targetIds,
inactiveRefPaths,
});
return {
resolvedConfig,
diagnostics: dedupeDiagnostics([
...params.preflightDiagnostics,
...commandAssignments.diagnostics,
]),
};
}
export async function resolveCommandSecretRefsViaGateway(params: {
config: OpenClawConfig;
commandName: string;
targetIds: Set<string>;
}): Promise<ResolveCommandSecretsResult> {
const configuredTargetRefPaths = collectConfiguredTargetRefPaths({
config: params.config,
targetIds: params.targetIds,
});
if (configuredTargetRefPaths.size === 0) {
return { resolvedConfig: params.config, diagnostics: [] };
}
const preflight = classifyConfiguredTargetRefs({
config: params.config,
configuredTargetRefPaths,
});
if (!preflight.hasActiveConfiguredRef && !preflight.hasUnknownConfiguredRef) {
return {
resolvedConfig: params.config,
diagnostics: preflight.diagnostics,
};
}
let payload: GatewaySecretsResolveResult;
try {
payload = await callGateway<GatewaySecretsResolveResult>({
method: "secrets.resolve",
requiredMethods: ["secrets.resolve"],
params: {
commandName: params.commandName,
targetIds: [...params.targetIds],
},
timeoutMs: 30_000,
clientName: GATEWAY_CLIENT_NAMES.CLI,
mode: GATEWAY_CLIENT_MODES.CLI,
});
} catch (err) {
try {
const fallback = await resolveCommandSecretRefsLocally({
config: params.config,
commandName: params.commandName,
targetIds: params.targetIds,
preflightDiagnostics: preflight.diagnostics,
});
return {
resolvedConfig: fallback.resolvedConfig,
diagnostics: dedupeDiagnostics([
...fallback.diagnostics,
`${params.commandName}: gateway secrets.resolve unavailable (${describeUnknownError(err)}); resolved command secrets locally.`,
]),
};
} catch {
// Fall through to original gateway-specific error reporting.
}
if (isUnsupportedSecretsResolveError(err)) {
throw new Error(
`${params.commandName}: active gateway does not support secrets.resolve (${describeUnknownError(err)}). Update the gateway or run without SecretRefs.`,
{ cause: err },
);
}
throw new Error(
`${params.commandName}: failed to resolve secrets from the active gateway snapshot (${describeUnknownError(err)}). Start the gateway and retry.`,
{ cause: err },
);
}
const parsed = parseGatewaySecretsResolveResult(payload);
const resolvedConfig = structuredClone(params.config);
for (const assignment of parsed.assignments) {
const pathSegments = assignment.pathSegments.filter((segment) => segment.length > 0);
if (pathSegments.length === 0) {
continue;
}
try {
setPathExistingStrict(resolvedConfig, pathSegments, assignment.value);
} catch (err) {
const path = pathSegments.join(".");
throw new Error(
`${params.commandName}: failed to apply resolved secret assignment at ${path} (${describeUnknownError(err)}).`,
{ cause: err },
);
}
}
const inactiveRefPaths =
parsed.inactiveRefPaths.length > 0
? new Set(parsed.inactiveRefPaths)
: collectInactiveSurfacePathsFromDiagnostics(parsed.diagnostics);
collectCommandSecretAssignmentsFromSnapshot({
sourceConfig: params.config,
resolvedConfig,
commandName: params.commandName,
targetIds: params.targetIds,
inactiveRefPaths,
});
return {
resolvedConfig,
diagnostics: dedupeDiagnostics(parsed.diagnostics),
};
}

View File

@@ -0,0 +1,28 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
const SECRET_TARGET_CALLSITES = [
"src/cli/memory-cli.ts",
"src/cli/qr-cli.ts",
"src/commands/agent.ts",
"src/commands/channels/resolve.ts",
"src/commands/channels/shared.ts",
"src/commands/message.ts",
"src/commands/models/load-config.ts",
"src/commands/status-all.ts",
"src/commands/status.scan.ts",
] as const;
describe("command secret resolution coverage", () => {
it.each(SECRET_TARGET_CALLSITES)(
"routes target-id command path through shared gateway resolver: %s",
async (relativePath) => {
const absolutePath = path.join(process.cwd(), relativePath);
const source = await fs.readFile(absolutePath, "utf8");
expect(source).toContain("resolveCommandSecretRefsViaGateway");
expect(source).toContain("targetIds: get");
expect(source).toContain("resolveCommandSecretRefsViaGateway({");
},
);
});

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import {
getAgentRuntimeCommandSecretTargetIds,
getMemoryCommandSecretTargetIds,
} from "./command-secret-targets.js";
describe("command secret target ids", () => {
it("includes memorySearch remote targets for agent runtime commands", () => {
const ids = getAgentRuntimeCommandSecretTargetIds();
expect(ids.has("agents.defaults.memorySearch.remote.apiKey")).toBe(true);
expect(ids.has("agents.list[].memorySearch.remote.apiKey")).toBe(true);
});
it("keeps memory command target set focused on memorySearch remote credentials", () => {
const ids = getMemoryCommandSecretTargetIds();
expect(ids).toEqual(
new Set([
"agents.defaults.memorySearch.remote.apiKey",
"agents.list[].memorySearch.remote.apiKey",
]),
);
});
});

View File

@@ -0,0 +1,60 @@
import { listSecretTargetRegistryEntries } from "../secrets/target-registry.js";
function idsByPrefix(prefixes: readonly string[]): string[] {
return listSecretTargetRegistryEntries()
.map((entry) => entry.id)
.filter((id) => prefixes.some((prefix) => id.startsWith(prefix)))
.toSorted();
}
const COMMAND_SECRET_TARGETS = {
memory: [
"agents.defaults.memorySearch.remote.apiKey",
"agents.list[].memorySearch.remote.apiKey",
],
qrRemote: ["gateway.remote.token", "gateway.remote.password"],
channels: idsByPrefix(["channels."]),
models: idsByPrefix(["models.providers."]),
agentRuntime: idsByPrefix([
"channels.",
"models.providers.",
"agents.defaults.memorySearch.remote.",
"agents.list[].memorySearch.remote.",
"skills.entries.",
"messages.tts.",
"tools.web.search",
]),
status: idsByPrefix([
"channels.",
"agents.defaults.memorySearch.remote.",
"agents.list[].memorySearch.remote.",
]),
} as const;
function toTargetIdSet(values: readonly string[]): Set<string> {
return new Set(values);
}
export function getMemoryCommandSecretTargetIds(): Set<string> {
return toTargetIdSet(COMMAND_SECRET_TARGETS.memory);
}
export function getQrRemoteCommandSecretTargetIds(): Set<string> {
return toTargetIdSet(COMMAND_SECRET_TARGETS.qrRemote);
}
export function getChannelsCommandSecretTargetIds(): Set<string> {
return toTargetIdSet(COMMAND_SECRET_TARGETS.channels);
}
export function getModelsCommandSecretTargetIds(): Set<string> {
return toTargetIdSet(COMMAND_SECRET_TARGETS.models);
}
export function getAgentRuntimeCommandSecretTargetIds(): Set<string> {
return toTargetIdSet(COMMAND_SECRET_TARGETS.agentRuntime);
}
export function getStatusCommandSecretTargetIds(): Set<string> {
return toTargetIdSet(COMMAND_SECRET_TARGETS.status);
}

View File

@@ -36,6 +36,18 @@ const resolveStateDir = vi.fn(
const resolveConfigPath = vi.fn((env: NodeJS.ProcessEnv, stateDir: string) => {
return env.OPENCLAW_CONFIG_PATH ?? `${stateDir}/openclaw.json`;
});
let daemonLoadedConfig: Record<string, unknown> = {
gateway: {
bind: "lan",
tls: { enabled: true },
auth: { token: "daemon-token" },
},
};
let cliLoadedConfig: Record<string, unknown> = {
gateway: {
bind: "loopback",
},
};
vi.mock("../../config/config.js", () => ({
createConfigIO: ({ configPath }: { configPath: string }) => {
@@ -47,20 +59,7 @@ vi.mock("../../config/config.js", () => ({
valid: true,
issues: [],
}),
loadConfig: () =>
isDaemon
? {
gateway: {
bind: "lan",
tls: { enabled: true },
auth: { token: "daemon-token" },
},
}
: {
gateway: {
bind: "loopback",
},
},
loadConfig: () => (isDaemon ? daemonLoadedConfig : cliLoadedConfig),
};
},
resolveConfigPath: (env: NodeJS.ProcessEnv, stateDir: string) => resolveConfigPath(env, stateDir),
@@ -124,13 +123,27 @@ describe("gatherDaemonStatus", () => {
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_GATEWAY_TOKEN",
"OPENCLAW_GATEWAY_PASSWORD",
"DAEMON_GATEWAY_PASSWORD",
]);
process.env.OPENCLAW_STATE_DIR = "/tmp/openclaw-cli";
process.env.OPENCLAW_CONFIG_PATH = "/tmp/openclaw-cli/openclaw.json";
delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
delete process.env.DAEMON_GATEWAY_PASSWORD;
callGatewayStatusProbe.mockClear();
loadGatewayTlsRuntime.mockClear();
daemonLoadedConfig = {
gateway: {
bind: "lan",
tls: { enabled: true },
auth: { token: "daemon-token" },
},
};
cliLoadedConfig = {
gateway: {
bind: "loopback",
},
};
});
afterEach(() => {
@@ -175,6 +188,68 @@ describe("gatherDaemonStatus", () => {
expect(status.rpc?.url).toBe("wss://override.example:18790");
});
it("resolves daemon gateway auth password SecretRef values before probing", async () => {
daemonLoadedConfig = {
gateway: {
bind: "lan",
tls: { enabled: true },
auth: {
password: { source: "env", provider: "default", id: "DAEMON_GATEWAY_PASSWORD" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
};
process.env.DAEMON_GATEWAY_PASSWORD = "daemon-secretref-password";
await gatherDaemonStatus({
rpc: {},
probe: true,
deep: false,
});
expect(callGatewayStatusProbe).toHaveBeenCalledWith(
expect.objectContaining({
password: "daemon-secretref-password",
}),
);
});
it("does not resolve daemon password SecretRef when token auth is configured", async () => {
daemonLoadedConfig = {
gateway: {
bind: "lan",
tls: { enabled: true },
auth: {
mode: "token",
token: "daemon-token",
password: { source: "env", provider: "default", id: "MISSING_DAEMON_GATEWAY_PASSWORD" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
};
await gatherDaemonStatus({
rpc: {},
probe: true,
deep: false,
});
expect(callGatewayStatusProbe).toHaveBeenCalledWith(
expect.objectContaining({
token: "daemon-token",
password: undefined,
}),
);
});
it("skips TLS runtime loading when probe is disabled", async () => {
const status = await gatherDaemonStatus({
rpc: {},

View File

@@ -4,7 +4,12 @@ import {
resolveGatewayPort,
resolveStateDir,
} from "../../config/config.js";
import type { GatewayBindMode, GatewayControlUiConfig } from "../../config/types.js";
import type {
OpenClawConfig,
GatewayBindMode,
GatewayControlUiConfig,
} from "../../config/types.js";
import { normalizeSecretInputString, resolveSecretInputRef } from "../../config/types.secrets.js";
import { readLastGatewayErrorLine } from "../../daemon/diagnostics.js";
import type { FindExtraGatewayServicesOptions } from "../../daemon/inspect.js";
import { findExtraGatewayServices } from "../../daemon/inspect.js";
@@ -21,6 +26,8 @@ import {
} from "../../infra/ports.js";
import { pickPrimaryTailnetIPv4 } from "../../infra/tailnet.js";
import { loadGatewayTlsRuntime } from "../../infra/tls/gateway.js";
import { secretRefKey } from "../../secrets/ref-contract.js";
import { resolveSecretRefValues } from "../../secrets/resolve.js";
import { probeGatewayStatus } from "./probe.js";
import { normalizeListenerAddress, parsePortFromArgs, pickProbeHostForBind } from "./shared.js";
import type { GatewayRpcOpts } from "./types.js";
@@ -95,6 +102,65 @@ function shouldReportPortUsage(status: PortUsageStatus | undefined, rpcOk?: bool
return true;
}
function trimToUndefined(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function readGatewayTokenEnv(env: Record<string, string | undefined>): string | undefined {
return trimToUndefined(env.OPENCLAW_GATEWAY_TOKEN) ?? trimToUndefined(env.CLAWDBOT_GATEWAY_TOKEN);
}
async function resolveDaemonProbePassword(params: {
daemonCfg: OpenClawConfig;
mergedDaemonEnv: Record<string, string | undefined>;
explicitToken?: string;
explicitPassword?: string;
}): Promise<string | undefined> {
const explicitPassword = trimToUndefined(params.explicitPassword);
if (explicitPassword) {
return explicitPassword;
}
const envPassword = trimToUndefined(params.mergedDaemonEnv.OPENCLAW_GATEWAY_PASSWORD);
if (envPassword) {
return envPassword;
}
const defaults = params.daemonCfg.secrets?.defaults;
const configured = params.daemonCfg.gateway?.auth?.password;
const { ref } = resolveSecretInputRef({
value: configured,
defaults,
});
if (!ref) {
return normalizeSecretInputString(configured);
}
const authMode = params.daemonCfg.gateway?.auth?.mode;
if (authMode === "token" || authMode === "none" || authMode === "trusted-proxy") {
return undefined;
}
if (authMode !== "password") {
const tokenCandidate =
trimToUndefined(params.explicitToken) ||
readGatewayTokenEnv(params.mergedDaemonEnv) ||
trimToUndefined(params.daemonCfg.gateway?.auth?.token);
if (tokenCandidate) {
return undefined;
}
}
const resolved = await resolveSecretRefValues([ref], {
config: params.daemonCfg,
env: params.mergedDaemonEnv as NodeJS.ProcessEnv,
});
const password = trimToUndefined(resolved.get(secretRefKey(ref)));
if (!password) {
throw new Error("gateway.auth.password resolved to an empty or non-string value.");
}
return password;
}
export async function gatherDaemonStatus(
opts: {
rpc: GatewayRpcOpts;
@@ -216,6 +282,14 @@ export async function gatherDaemonStatus(
const tlsRuntime = shouldUseLocalTlsRuntime
? await loadGatewayTlsRuntime(daemonCfg.gateway?.tls)
: undefined;
const daemonProbePassword = opts.probe
? await resolveDaemonProbePassword({
daemonCfg,
mergedDaemonEnv,
explicitToken: opts.rpc.token,
explicitPassword: opts.rpc.password,
})
: undefined;
const rpc = opts.probe
? await probeGatewayStatus({
@@ -224,10 +298,7 @@ export async function gatherDaemonStatus(
opts.rpc.token ||
mergedDaemonEnv.OPENCLAW_GATEWAY_TOKEN ||
daemonCfg.gateway?.auth?.token,
password:
opts.rpc.password ||
mergedDaemonEnv.OPENCLAW_GATEWAY_PASSWORD ||
daemonCfg.gateway?.auth?.password,
password: daemonProbePassword,
tlsFingerprint:
shouldUseLocalTlsRuntime && tlsRuntime?.enabled
? tlsRuntime.fingerprintSha256

View File

@@ -7,6 +7,10 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
const getMemorySearchManager = vi.fn();
const loadConfig = vi.fn(() => ({}));
const resolveDefaultAgentId = vi.fn(() => "main");
const resolveCommandSecretRefsViaGateway = vi.fn(async ({ config }: { config: unknown }) => ({
resolvedConfig: config,
diagnostics: [] as string[],
}));
vi.mock("../memory/index.js", () => ({
getMemorySearchManager,
@@ -20,6 +24,10 @@ vi.mock("../agents/agent-scope.js", () => ({
resolveDefaultAgentId,
}));
vi.mock("./command-secret-gateway.js", () => ({
resolveCommandSecretRefsViaGateway,
}));
let registerMemoryCli: typeof import("./memory-cli.js").registerMemoryCli;
let defaultRuntime: typeof import("../runtime.js").defaultRuntime;
let isVerbose: typeof import("../globals.js").isVerbose;
@@ -34,6 +42,7 @@ beforeAll(async () => {
afterEach(() => {
vi.restoreAllMocks();
getMemorySearchManager.mockClear();
resolveCommandSecretRefsViaGateway.mockClear();
process.exitCode = undefined;
setVerbose(false);
});
@@ -148,6 +157,62 @@ describe("memory cli", () => {
expect(close).toHaveBeenCalled();
});
it("resolves configured memory SecretRefs through gateway snapshot", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
memorySearch: {
remote: {
apiKey: { source: "env", provider: "default", id: "MEMORY_REMOTE_API_KEY" },
},
},
},
},
});
const close = vi.fn(async () => {});
mockManager({
probeVectorAvailability: vi.fn(async () => true),
status: () => makeMemoryStatus(),
close,
});
await runMemoryCli(["status"]);
expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith(
expect.objectContaining({
commandName: "memory status",
targetIds: new Set([
"agents.defaults.memorySearch.remote.apiKey",
"agents.list[].memorySearch.remote.apiKey",
]),
}),
);
});
it("logs gateway secret diagnostics for non-json status output", async () => {
const close = vi.fn(async () => {});
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
resolvedConfig: {},
diagnostics: ["agents.defaults.memorySearch.remote.apiKey inactive"] as string[],
});
mockManager({
probeVectorAvailability: vi.fn(async () => true),
status: () => makeMemoryStatus({ workspaceDir: undefined }),
close,
});
const log = spyRuntimeLogs();
await runMemoryCli(["status"]);
expect(
log.mock.calls.some(
(call) =>
typeof call[0] === "string" &&
call[0].includes("agents.defaults.memorySearch.remote.apiKey inactive"),
),
).toBe(true);
});
it("prints vector error when unavailable", async () => {
const close = vi.fn(async () => {});
mockManager({
@@ -343,6 +408,33 @@ describe("memory cli", () => {
expect(close).toHaveBeenCalled();
});
it("routes gateway secret diagnostics to stderr for json status output", async () => {
const close = vi.fn(async () => {});
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
resolvedConfig: {},
diagnostics: ["agents.defaults.memorySearch.remote.apiKey inactive"] as string[],
});
mockManager({
probeVectorAvailability: vi.fn(async () => true),
status: () => makeMemoryStatus({ workspaceDir: undefined }),
close,
});
const log = spyRuntimeLogs();
const error = spyRuntimeErrors();
await runMemoryCli(["status", "--json"]);
const payload = firstLoggedJson(log);
expect(Array.isArray(payload)).toBe(true);
expect(
error.mock.calls.some(
(call) =>
typeof call[0] === "string" &&
call[0].includes("agents.defaults.memorySearch.remote.apiKey inactive"),
),
).toBe(true);
});
it("logs default message when memory manager is missing", async () => {
getMemorySearchManager.mockResolvedValueOnce({ manager: null });

View File

@@ -15,6 +15,8 @@ import { formatDocsLink } from "../terminal/links.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
import { shortenHomeInString, shortenHomePath } from "../utils.js";
import { formatErrorMessage, withManager } from "./cli-utils.js";
import { resolveCommandSecretRefsViaGateway } from "./command-secret-gateway.js";
import { getMemoryCommandSecretTargetIds } from "./command-secret-targets.js";
import { formatHelpExamples } from "./help-format.js";
import { withProgress, withProgressTotals } from "./progress.js";
@@ -44,6 +46,41 @@ type MemorySourceScan = {
issues: string[];
};
type LoadedMemoryCommandConfig = {
config: ReturnType<typeof loadConfig>;
diagnostics: string[];
};
async function loadMemoryCommandConfig(commandName: string): Promise<LoadedMemoryCommandConfig> {
const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({
config: loadConfig(),
commandName,
targetIds: getMemoryCommandSecretTargetIds(),
});
return {
config: resolvedConfig,
diagnostics,
};
}
function emitMemorySecretResolveDiagnostics(
diagnostics: string[],
params?: { json?: boolean },
): void {
if (diagnostics.length === 0) {
return;
}
const toStderr = params?.json === true;
for (const entry of diagnostics) {
const message = theme.warn(`[secrets] ${entry}`);
if (toStderr) {
defaultRuntime.error(message);
} else {
defaultRuntime.log(message);
}
}
}
function formatSourceLabel(source: string, workspaceDir: string, agentId: string): string {
if (source === "memory") {
return shortenHomeInString(
@@ -297,7 +334,8 @@ async function scanMemorySources(params: {
export async function runMemoryStatus(opts: MemoryCommandOptions) {
setVerbose(Boolean(opts.verbose));
const cfg = loadConfig();
const { config: cfg, diagnostics } = await loadMemoryCommandConfig("memory status");
emitMemorySecretResolveDiagnostics(diagnostics, { json: Boolean(opts.json) });
const agentIds = resolveAgentIds(cfg, opts.agent);
const allResults: Array<{
agentId: string;
@@ -570,7 +608,8 @@ export function registerMemoryCli(program: Command) {
.option("--verbose", "Verbose logging", false)
.action(async (opts: MemoryCommandOptions) => {
setVerbose(Boolean(opts.verbose));
const cfg = loadConfig();
const { config: cfg, diagnostics } = await loadMemoryCommandConfig("memory index");
emitMemorySecretResolveDiagnostics(diagnostics);
const agentIds = resolveAgentIds(cfg, opts.agent);
for (const agentId of agentIds) {
await withMemoryManagerForAgent({
@@ -725,7 +764,8 @@ export function registerMemoryCli(program: Command) {
process.exitCode = 1;
return;
}
const cfg = loadConfig();
const { config: cfg, diagnostics } = await loadMemoryCommandConfig("memory search");
emitMemorySecretResolveDiagnostics(diagnostics, { json: Boolean(opts.json) });
const agentId = resolveAgent(cfg, opts.agent);
await withMemoryManagerForAgent({
cfg,

View File

@@ -2,29 +2,43 @@ import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { encodePairingSetupCode } from "../pairing/setup-code.js";
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(() => {
throw new Error("exit");
const mocks = vi.hoisted(() => ({
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(() => {
throw new Error("exit");
}),
},
loadConfig: vi.fn(),
runCommandWithTimeout: vi.fn(),
resolveCommandSecretRefsViaGateway: vi.fn(async ({ config }: { config: unknown }) => ({
resolvedConfig: config,
diagnostics: [] as string[],
})),
qrGenerate: vi.fn((_input: unknown, _opts: unknown, cb: (output: string) => void) => {
cb("ASCII-QR");
}),
};
}));
const loadConfig = vi.fn();
const runCommandWithTimeout = vi.fn();
const qrGenerate = vi.fn((_input, _opts, cb: (output: string) => void) => {
cb("ASCII-QR");
});
vi.mock("../runtime.js", () => ({ defaultRuntime: runtime }));
vi.mock("../config/config.js", () => ({ loadConfig }));
vi.mock("../process/exec.js", () => ({ runCommandWithTimeout }));
vi.mock("../runtime.js", () => ({ defaultRuntime: mocks.runtime }));
vi.mock("../config/config.js", () => ({ loadConfig: mocks.loadConfig }));
vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: mocks.runCommandWithTimeout }));
vi.mock("./command-secret-gateway.js", () => ({
resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway,
}));
vi.mock("qrcode-terminal", () => ({
default: {
generate: qrGenerate,
generate: mocks.qrGenerate,
},
}));
const runtime = mocks.runtime;
const loadConfig = mocks.loadConfig;
const runCommandWithTimeout = mocks.runCommandWithTimeout;
const resolveCommandSecretRefsViaGateway = mocks.resolveCommandSecretRefsViaGateway;
const qrGenerate = mocks.qrGenerate;
const { registerQrCli } = await import("./qr-cli.js");
function createRemoteQrConfig(params?: { withTailscale?: boolean }) {
@@ -46,6 +60,18 @@ function createRemoteQrConfig(params?: { withTailscale?: boolean }) {
};
}
function createTailscaleRemoteRefConfig() {
return {
gateway: {
tailscale: { mode: "serve" },
remote: {
token: { source: "env", provider: "default", id: "REMOTE_GATEWAY_TOKEN" },
},
auth: {},
},
};
}
describe("registerQrCli", () => {
function createProgram() {
const program = new Command();
@@ -91,6 +117,7 @@ describe("registerQrCli", () => {
});
expect(runtime.log).toHaveBeenCalledWith(expected);
expect(qrGenerate).not.toHaveBeenCalled();
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
});
it("renders ASCII QR by default", async () => {
@@ -129,6 +156,143 @@ describe("registerQrCli", () => {
expect(runtime.log).toHaveBeenCalledWith(expected);
});
it("skips local password SecretRef resolution when --token override is provided", async () => {
loadConfig.mockReturnValue({
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: {
mode: "password",
password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" },
},
},
});
await runQr(["--setup-code-only", "--token", "override-token"]);
const expected = encodePairingSetupCode({
url: "ws://gateway.local:18789",
token: "override-token",
});
expect(runtime.log).toHaveBeenCalledWith(expected);
});
it("resolves local gateway auth password SecretRefs before setup code generation", async () => {
vi.stubEnv("QR_LOCAL_GATEWAY_PASSWORD", "local-password-secret");
loadConfig.mockReturnValue({
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: {
mode: "password",
password: { source: "env", provider: "default", id: "QR_LOCAL_GATEWAY_PASSWORD" },
},
},
});
await runQr(["--setup-code-only"]);
const expected = encodePairingSetupCode({
url: "ws://gateway.local:18789",
password: "local-password-secret",
});
expect(runtime.log).toHaveBeenCalledWith(expected);
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
});
it("uses OPENCLAW_GATEWAY_PASSWORD without resolving local password SecretRef", async () => {
vi.stubEnv("OPENCLAW_GATEWAY_PASSWORD", "password-from-env");
loadConfig.mockReturnValue({
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: {
mode: "password",
password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" },
},
},
});
await runQr(["--setup-code-only"]);
const expected = encodePairingSetupCode({
url: "ws://gateway.local:18789",
password: "password-from-env",
});
expect(runtime.log).toHaveBeenCalledWith(expected);
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
});
it("does not resolve local password SecretRef when auth mode is token", async () => {
loadConfig.mockReturnValue({
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: {
mode: "token",
token: "token-123",
password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" },
},
},
});
await runQr(["--setup-code-only"]);
const expected = encodePairingSetupCode({
url: "ws://gateway.local:18789",
token: "token-123",
});
expect(runtime.log).toHaveBeenCalledWith(expected);
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
});
it("resolves local password SecretRef when auth mode is inferred", async () => {
vi.stubEnv("QR_INFERRED_GATEWAY_PASSWORD", "inferred-password");
loadConfig.mockReturnValue({
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: {
password: { source: "env", provider: "default", id: "QR_INFERRED_GATEWAY_PASSWORD" },
},
},
});
await runQr(["--setup-code-only"]);
const expected = encodePairingSetupCode({
url: "ws://gateway.local:18789",
password: "inferred-password",
});
expect(runtime.log).toHaveBeenCalledWith(expected);
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
});
it("exits with error when gateway config is not pairable", async () => {
loadConfig.mockReturnValue({
gateway: {
@@ -152,6 +316,49 @@ describe("registerQrCli", () => {
token: "remote-tok",
});
expect(runtime.log).toHaveBeenCalledWith(expected);
expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith(
expect.objectContaining({
commandName: "qr --remote",
targetIds: new Set(["gateway.remote.token", "gateway.remote.password"]),
}),
);
});
it("logs remote secret diagnostics in non-json output mode", async () => {
loadConfig.mockReturnValue(createRemoteQrConfig());
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
resolvedConfig: createRemoteQrConfig(),
diagnostics: ["gateway.remote.token inactive"] as string[],
});
await runQr(["--remote"]);
expect(
runtime.log.mock.calls.some((call) =>
String(call[0] ?? "").includes("gateway.remote.token inactive"),
),
).toBe(true);
});
it("routes remote secret diagnostics to stderr for setup-code-only output", async () => {
loadConfig.mockReturnValue(createRemoteQrConfig());
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
resolvedConfig: createRemoteQrConfig(),
diagnostics: ["gateway.remote.token inactive"] as string[],
});
await runQr(["--setup-code-only", "--remote"]);
expect(
runtime.error.mock.calls.some((call) =>
String(call[0] ?? "").includes("gateway.remote.token inactive"),
),
).toBe(true);
const expected = encodePairingSetupCode({
url: "wss://remote.example.com:444",
token: "remote-tok",
});
expect(runtime.log).toHaveBeenCalledWith(expected);
});
it.each([
@@ -179,6 +386,34 @@ describe("registerQrCli", () => {
expect(runCommandWithTimeout).not.toHaveBeenCalled();
});
it("routes remote secret diagnostics to stderr for json output", async () => {
loadConfig.mockReturnValue(createRemoteQrConfig());
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
resolvedConfig: createRemoteQrConfig(),
diagnostics: ["gateway.remote.password inactive"] as string[],
});
runCommandWithTimeout.mockResolvedValue({
code: 0,
stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}',
stderr: "",
});
await runQr(["--json", "--remote"]);
const payload = JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as {
setupCode?: string;
gatewayUrl?: string;
auth?: string;
urlSource?: string;
};
expect(payload.gatewayUrl).toBe("wss://remote.example.com:444");
expect(
runtime.error.mock.calls.some((call) =>
String(call[0] ?? "").includes("gateway.remote.password inactive"),
),
).toBe(true);
});
it("errors when --remote is set but no remote URL is configured", async () => {
loadConfig.mockReturnValue({
gateway: {
@@ -191,5 +426,38 @@ describe("registerQrCli", () => {
await expectQrExit(["--remote"]);
const output = runtime.error.mock.calls.map((call) => String(call[0] ?? "")).join("\n");
expect(output).toContain("qr --remote requires");
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
});
it("supports --remote with tailscale serve when remote token ref resolves", async () => {
loadConfig.mockReturnValue(createTailscaleRemoteRefConfig());
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
resolvedConfig: {
gateway: {
tailscale: { mode: "serve" },
remote: {
token: "tailscale-remote-token",
},
auth: {},
},
},
diagnostics: [],
});
runCommandWithTimeout.mockResolvedValue({
code: 0,
stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}',
stderr: "",
});
await runQr(["--json", "--remote"]);
const payload = JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as {
gatewayUrl?: string;
auth?: string;
urlSource?: string;
};
expect(payload.gatewayUrl).toBe("wss://ts-host.tailnet.ts.net");
expect(payload.auth).toBe("token");
expect(payload.urlSource).toBe("gateway.tailscale.mode=serve");
});
});

View File

@@ -1,11 +1,16 @@
import type { Command } from "commander";
import qrcode from "qrcode-terminal";
import { loadConfig } from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { resolvePairingSetupFromConfig, encodePairingSetupCode } from "../pairing/setup-code.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { defaultRuntime } from "../runtime.js";
import { secretRefKey } from "../secrets/ref-contract.js";
import { resolveSecretRefValues } from "../secrets/resolve.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { resolveCommandSecretRefsViaGateway } from "./command-secret-gateway.js";
import { getQrRemoteCommandSecretTargetIds } from "./command-secret-targets.js";
type QrCliOptions = {
json?: boolean;
@@ -35,6 +40,94 @@ function readDevicePairPublicUrlFromConfig(cfg: ReturnType<typeof loadConfig>):
return trimmed.length > 0 ? trimmed : undefined;
}
function readGatewayTokenEnv(env: NodeJS.ProcessEnv): string | undefined {
const primary = typeof env.OPENCLAW_GATEWAY_TOKEN === "string" ? env.OPENCLAW_GATEWAY_TOKEN : "";
if (primary.trim().length > 0) {
return primary.trim();
}
const legacy = typeof env.CLAWDBOT_GATEWAY_TOKEN === "string" ? env.CLAWDBOT_GATEWAY_TOKEN : "";
if (legacy.trim().length > 0) {
return legacy.trim();
}
return undefined;
}
function readGatewayPasswordEnv(env: NodeJS.ProcessEnv): string | undefined {
const primary =
typeof env.OPENCLAW_GATEWAY_PASSWORD === "string" ? env.OPENCLAW_GATEWAY_PASSWORD : "";
if (primary.trim().length > 0) {
return primary.trim();
}
const legacy =
typeof env.CLAWDBOT_GATEWAY_PASSWORD === "string" ? env.CLAWDBOT_GATEWAY_PASSWORD : "";
if (legacy.trim().length > 0) {
return legacy.trim();
}
return undefined;
}
function shouldResolveLocalGatewayPasswordSecret(
cfg: ReturnType<typeof loadConfig>,
env: NodeJS.ProcessEnv,
): boolean {
if (readGatewayPasswordEnv(env)) {
return false;
}
const authMode = cfg.gateway?.auth?.mode;
if (authMode === "password") {
return true;
}
if (authMode === "token" || authMode === "none" || authMode === "trusted-proxy") {
return false;
}
const envToken = readGatewayTokenEnv(env);
const configToken =
typeof cfg.gateway?.auth?.token === "string" && cfg.gateway.auth.token.trim().length > 0
? cfg.gateway.auth.token.trim()
: undefined;
return !envToken && !configToken;
}
async function resolveLocalGatewayPasswordSecretIfNeeded(
cfg: ReturnType<typeof loadConfig>,
): Promise<void> {
const authPassword = cfg.gateway?.auth?.password;
const { ref } = resolveSecretInputRef({
value: authPassword,
defaults: cfg.secrets?.defaults,
});
if (!ref) {
return;
}
const resolved = await resolveSecretRefValues([ref], {
config: cfg,
env: process.env,
});
const value = resolved.get(secretRefKey(ref));
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error("gateway.auth.password resolved to an empty or non-string value.");
}
if (!cfg.gateway?.auth) {
return;
}
cfg.gateway.auth.password = value.trim();
}
function emitQrSecretResolveDiagnostics(diagnostics: string[], opts: QrCliOptions): void {
if (diagnostics.length === 0) {
return;
}
const toStderr = opts.json === true || opts.setupCodeOnly === true;
for (const entry of diagnostics) {
const message = theme.warn(`[secrets] ${entry}`);
if (toStderr) {
defaultRuntime.error(message);
} else {
defaultRuntime.log(message);
}
}
}
export function registerQrCli(program: Command) {
program
.command("qr")
@@ -61,7 +154,33 @@ export function registerQrCli(program: Command) {
throw new Error("Use either --token or --password, not both.");
}
const loaded = loadConfig();
const token = typeof opts.token === "string" ? opts.token.trim() : "";
const password = typeof opts.password === "string" ? opts.password.trim() : "";
const wantsRemote = opts.remote === true;
const loadedRaw = loadConfig();
if (wantsRemote && !opts.url && !opts.publicUrl) {
const tailscaleMode = loadedRaw.gateway?.tailscale?.mode ?? "off";
const remoteUrl = loadedRaw.gateway?.remote?.url;
const hasRemoteUrl = typeof remoteUrl === "string" && remoteUrl.trim().length > 0;
const hasTailscaleServe = tailscaleMode === "serve" || tailscaleMode === "funnel";
if (!hasRemoteUrl && !hasTailscaleServe) {
throw new Error(
"qr --remote requires gateway.remote.url (or gateway.tailscale.mode=serve/funnel).",
);
}
}
let loaded = loadedRaw;
let remoteDiagnostics: string[] = [];
if (wantsRemote && !token && !password) {
const resolvedRemote = await resolveCommandSecretRefsViaGateway({
config: loadedRaw,
commandName: "qr --remote",
targetIds: getQrRemoteCommandSecretTargetIds(),
});
loaded = resolvedRemote.resolvedConfig;
remoteDiagnostics = resolvedRemote.diagnostics;
}
const cfg = {
...loaded,
gateway: {
@@ -71,17 +190,17 @@ export function registerQrCli(program: Command) {
},
},
};
emitQrSecretResolveDiagnostics(remoteDiagnostics, opts);
const token = typeof opts.token === "string" ? opts.token.trim() : "";
const password = typeof opts.password === "string" ? opts.password.trim() : "";
const wantsRemote = opts.remote === true;
if (token) {
cfg.gateway.auth.mode = "token";
cfg.gateway.auth.token = token;
cfg.gateway.auth.password = undefined;
}
if (password) {
cfg.gateway.auth.mode = "password";
cfg.gateway.auth.password = password;
cfg.gateway.auth.token = undefined;
}
if (wantsRemote && !token && !password) {
const remoteToken =
@@ -100,16 +219,13 @@ export function registerQrCli(program: Command) {
cfg.gateway.auth.token = undefined;
}
}
if (wantsRemote && !opts.url && !opts.publicUrl) {
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
const remoteUrl = cfg.gateway?.remote?.url;
const hasRemoteUrl = typeof remoteUrl === "string" && remoteUrl.trim().length > 0;
const hasTailscaleServe = tailscaleMode === "serve" || tailscaleMode === "funnel";
if (!hasRemoteUrl && !hasTailscaleServe) {
throw new Error(
"qr --remote requires gateway.remote.url (or gateway.tailscale.mode=serve/funnel).",
);
}
if (
!wantsRemote &&
!password &&
!token &&
shouldResolveLocalGatewayPasswordSecret(cfg, process.env)
) {
await resolveLocalGatewayPasswordSecretIfNeeded(cfg);
}
const explicitUrl =

View File

@@ -29,7 +29,7 @@ vi.mock("../secrets/audit.js", () => ({
}));
vi.mock("../secrets/configure.js", () => ({
runSecretsConfigureInteractive: () => runSecretsConfigureInteractive(),
runSecretsConfigureInteractive: (options: unknown) => runSecretsConfigureInteractive(options),
}));
vi.mock("../secrets/apply.js", () => ({
@@ -155,4 +155,31 @@ describe("secrets CLI", () => {
);
expect(runtimeLogs.at(-1)).toContain("Secrets applied");
});
it("forwards --agent to secrets configure", async () => {
runSecretsConfigureInteractive.mockResolvedValue({
plan: {
version: 1,
protocolVersion: 1,
generatedAt: "2026-02-26T00:00:00.000Z",
generatedBy: "openclaw secrets configure",
targets: [],
},
preflight: {
mode: "dry-run",
changed: false,
changedFiles: [],
warningCount: 0,
warnings: [],
},
});
confirm.mockResolvedValue(false);
await createProgram().parseAsync(["secrets", "configure", "--agent", "ops"], { from: "user" });
expect(runSecretsConfigureInteractive).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "ops",
}),
);
});
});

View File

@@ -22,6 +22,7 @@ type SecretsConfigureOptions = {
planOut?: string;
providersOnly?: boolean;
skipProviderSetup?: boolean;
agent?: string;
json?: boolean;
};
type SecretsApplyOptions = {
@@ -123,6 +124,10 @@ export function registerSecretsCli(program: Command) {
"Skip provider setup and only map credential fields to existing providers",
false,
)
.option(
"--agent <id>",
"Agent id for auth-profiles targets (default: configured default agent)",
)
.option("--plan-out <path>", "Write generated plan JSON to a file")
.option("--json", "Output JSON", false)
.action(async (opts: SecretsConfigureOptions) => {
@@ -130,6 +135,7 @@ export function registerSecretsCli(program: Command) {
const configured = await runSecretsConfigureInteractive({
providersOnly: Boolean(opts.providersOnly),
skipProviderSetup: Boolean(opts.skipProviderSetup),
agentId: typeof opts.agent === "string" ? opts.agent : undefined,
});
if (opts.planOut) {
fs.writeFileSync(opts.planOut, `${JSON.stringify(configured.plan, null, 2)}\n`, "utf8");