mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-24 23:51:48 +00:00
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:
315
src/cli/command-secret-gateway.test.ts
Normal file
315
src/cli/command-secret-gateway.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
317
src/cli/command-secret-gateway.ts
Normal file
317
src/cli/command-secret-gateway.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
28
src/cli/command-secret-resolution.coverage.test.ts
Normal file
28
src/cli/command-secret-resolution.coverage.test.ts
Normal 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({");
|
||||
},
|
||||
);
|
||||
});
|
||||
23
src/cli/command-secret-targets.test.ts
Normal file
23
src/cli/command-secret-targets.test.ts
Normal 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",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
60
src/cli/command-secret-targets.ts
Normal file
60
src/cli/command-secret-targets.ts
Normal 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);
|
||||
}
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user