fix(nostr): retain SecretRef-backed accounts (#126934)

This commit is contained in:
Peter Steinberger
2026-08-20 19:14:08 -07:00
committed by GitHub
parent 718dacc46a
commit 94f042ba86
14 changed files with 382 additions and 21 deletions
@@ -119,6 +119,7 @@ The lists below are generated from the source target registry and checked agains
- `channels.nextcloud-talk.apiPassword`
- `channels.nextcloud-talk.accounts.*.botSecret`
- `channels.nextcloud-talk.accounts.*.apiPassword`
- `channels.nostr.privateKey`
- `channels.zalo.botToken`
- `channels.zalo.webhookSecret`
- `channels.zalo.accounts.*.botToken`
@@ -297,6 +297,13 @@
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.nostr.privateKey",
"configFile": "openclaw.json",
"path": "channels.nostr.privateKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.qqbot.accounts.*.clientSecret",
"configFile": "openclaw.json",
+4
View File
@@ -39,6 +39,10 @@ export default defineBundledChannelEntry({
specifier: "./channel-plugin-api.js",
exportName: "nostrPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
runtime: {
specifier: "./api.js",
exportName: "setNostrRuntime",
@@ -0,0 +1,84 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createResolverContext } from "openclaw/plugin-sdk/secret-ref-runtime";
import { describe, expect, it } from "vitest";
import {
channelSecrets,
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./secret-contract-api.js";
describe("Nostr public secret contract", () => {
it("publishes the private-key target for plan, configure, apply, and audit", () => {
expect(channelSecrets.secretTargetRegistryEntries).toBe(secretTargetRegistryEntries);
expect(channelSecrets.collectRuntimeConfigAssignments).toBe(collectRuntimeConfigAssignments);
expect(secretTargetRegistryEntries).toEqual([
expect.objectContaining({
id: "channels.nostr.privateKey",
pathPattern: "channels.nostr.privateKey",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
}),
]);
});
it.each([
{ defaultAccount: undefined, ownerId: "nostr:default" },
{ defaultAccount: "Team.A", ownerId: "nostr:team-a" },
])(
"assigns the configured private key to exact owner $ownerId",
({ defaultAccount, ownerId }) => {
const sourceConfig = {
channels: {
nostr: {
...(defaultAccount ? { defaultAccount } : {}),
relays: ["wss://relay.example"],
privateKey: { source: "env", provider: "default", id: "NOSTR_TEST_PRIVATE_KEY" },
},
},
} as OpenClawConfig;
const config = structuredClone(sourceConfig);
const context = createResolverContext({ sourceConfig, env: {} });
collectRuntimeConfigAssignments({ config, context });
expect(context.assignments).toEqual([
expect.objectContaining({
path: "channels.nostr.privateKey",
ownerKind: "account",
ownerId,
requiredForGateway: false,
disposition: "isolate",
ownerContractDigest: expect.any(String),
}),
]);
context.assignments[0]?.apply("materialized-private-key");
expect(config.channels?.nostr?.privateKey).toBe("materialized-private-key");
},
);
it.each(["file", "exec", "store"] as const)(
"does not collect an active $0 provider assignment while Nostr is disabled",
(source) => {
const sourceConfig = {
channels: {
nostr: {
enabled: false,
privateKey: { source, provider: "vault", id: "NOSTR_TEST_PRIVATE_KEY" },
},
},
} as OpenClawConfig;
const context = createResolverContext({ sourceConfig, env: {} });
collectRuntimeConfigAssignments({ config: structuredClone(sourceConfig), context });
expect(context.assignments).toEqual([]);
expect(context.warnings).toEqual([
expect.objectContaining({
code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE",
path: "channels.nostr.privateKey",
}),
]);
},
);
});
+5
View File
@@ -0,0 +1,5 @@
export {
channelSecrets,
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
+4
View File
@@ -7,4 +7,8 @@ export default defineBundledChannelSetupEntry({
specifier: "./setup-plugin-api.js",
exportName: "nostrSetupPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
});
@@ -1,5 +1,6 @@
// Nostr tests cover the lightweight setup plugin behavior.
import { nip19 } from "nostr-tools";
import { withEnv } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";
import { nostrSetupPlugin } from "./channel.setup.js";
import { TEST_HEX_PRIVATE_KEY } from "./test-fixtures.js";
@@ -16,4 +17,26 @@ describe("nostr setup plugin", () => {
} as never),
).toBeNull();
});
it("keeps an unresolved named SecretRef account configured without ambient fallback", () => {
const cfg = {
channels: {
nostr: {
defaultAccount: "Team.A",
privateKey: { source: "env" as const, provider: "default", id: "MISSING_NOSTR_KEY" },
},
},
};
withEnv({ NOSTR_PRIVATE_KEY: TEST_HEX_PRIVATE_KEY }, () => {
expect(nostrSetupPlugin.config.defaultAccountId?.(cfg)).toBe("team-a");
expect(nostrSetupPlugin.config.listAccountIds(cfg)).toEqual(["team-a"]);
expect(nostrSetupPlugin.config.resolveAccount(cfg, undefined)).toMatchObject({
accountId: "team-a",
configured: true,
privateKey: "",
});
expect(nostrSetupPlugin.config.resolveAccount(cfg, "Team.A").accountId).toBe("team-a");
});
});
});
+8 -11
View File
@@ -1,14 +1,12 @@
// Nostr plugin module implements channel.setup behavior.
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
createDelegatedSetupWizardProxy,
DEFAULT_ACCOUNT_ID,
} from "openclaw/plugin-sdk/setup-runtime";
import { createDelegatedSetupWizardProxy } from "openclaw/plugin-sdk/setup-runtime";
import { buildChannelConfigSchema, type ChannelPlugin } from "./channel-api.js";
import { NostrConfigSchema } from "./config-schema.js";
import { DEFAULT_RELAYS } from "./default-relays.js";
import { resolveNostrPrivateKey } from "./private-key.js";
import { hasConfiguredNostrPrivateKey, resolveNostrPrivateKey } from "./private-key.js";
import {
createNostrSetupAdapter,
createNostrSetupContract,
@@ -27,10 +25,7 @@ function getNostrConfig(cfg: OpenClawConfig): NostrAccountConfig | undefined {
}
function resolveDefaultSetupNostrAccountId(cfg: OpenClawConfig): string {
const configured = getNostrConfig(cfg)?.defaultAccount;
return typeof configured === "string" && configured.trim()
? configured.trim()
: DEFAULT_ACCOUNT_ID;
return normalizeAccountId(getNostrConfig(cfg)?.defaultAccount);
}
function resolveSetupNostrAccount(params: {
@@ -38,9 +33,11 @@ function resolveSetupNostrAccount(params: {
accountId?: string | null;
}): ResolvedNostrAccount {
const nostrCfg = getNostrConfig(params.cfg);
const accountId = params.accountId?.trim() || resolveDefaultSetupNostrAccountId(params.cfg);
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultSetupNostrAccountId(params.cfg),
);
const privateKey = resolveNostrPrivateKey(nostrCfg?.privateKey);
const configured = Boolean(privateKey);
const configured = hasConfiguredNostrPrivateKey(nostrCfg?.privateKey);
return {
accountId,
name: typeof nostrCfg?.name === "string" ? nostrCfg.name : undefined,
+9 -7
View File
@@ -158,10 +158,11 @@ function requireNostrResolveDmPolicy() {
return resolveDmPolicy;
}
function createUnresolvedNostrPrivateKeyCfg() {
function createUnresolvedNostrPrivateKeyCfg(defaultAccount?: string) {
return {
channels: {
nostr: {
...(defaultAccount ? { defaultAccount } : {}),
privateKey: {
source: "env" as const,
provider: "default",
@@ -176,7 +177,7 @@ const unresolvedSecretRefPrivateKeyCases = [
{
name: "listNostrAccountIds",
assert: (cfg: ReturnType<typeof createUnresolvedNostrPrivateKeyCfg>) => {
expect(listNostrAccountIds(cfg)).toStrictEqual([]);
expect(listNostrAccountIds(cfg)).toStrictEqual(["work"]);
},
},
{
@@ -184,7 +185,8 @@ const unresolvedSecretRefPrivateKeyCases = [
assert: (cfg: ReturnType<typeof createUnresolvedNostrPrivateKeyCfg>) => {
const account = resolveNostrAccount({ cfg });
expect(account.configured).toBe(false);
expect(account.accountId).toBe("work");
expect(account.configured).toBe(true);
expect(account.privateKey).toBe("");
expect(account.publicKey).toBe("");
expect(account.config.privateKey).toEqual(cfg.channels.nostr.privateKey);
@@ -447,10 +449,10 @@ describe("nostr setup wizard", () => {
describe("nostr unresolved SecretRef privateKey", () => {
it.each(unresolvedSecretRefPrivateKeyCases)(
"$name does not treat unresolved SecretRef privateKey as configured",
"$name keeps an unresolved named SecretRef account configured without using ambient credentials",
({ assert }) => {
withEnv({ NOSTR_PRIVATE_KEY: TEST_HEX_PRIVATE_KEY }, () => {
assert(createUnresolvedNostrPrivateKeyCfg());
assert(createUnresolvedNostrPrivateKeyCfg("work"));
});
},
);
@@ -597,7 +599,7 @@ describe("nostr account helpers", () => {
});
describe("setup wizard", () => {
it("keeps unresolved SecretRef privateKey visible without marking the account configured", () => {
it("keeps unresolved SecretRef privateKey configured without exposing a materialized value", () => {
const secretRef = {
source: "env" as const,
provider: "default",
@@ -618,7 +620,7 @@ describe("nostr account helpers", () => {
expect(
withoutNostrPrivateKey(() => credential.inspect({ cfg, accountId: "default" })),
).toEqual({
accountConfigured: false,
accountConfigured: true,
hasConfiguredValue: true,
resolvedValue: undefined,
envValue: undefined,
+4
View File
@@ -6,6 +6,10 @@ import {
export const NOSTR_PRIVATE_KEY_ENV_VAR = "NOSTR_PRIVATE_KEY";
export function hasConfiguredNostrPrivateKey(value: SecretInput | undefined): boolean {
return hasConfiguredSecretInput(value) || Boolean(process.env[NOSTR_PRIVATE_KEY_ENV_VAR]?.trim());
}
export function resolveNostrPrivateKey(value: SecretInput | undefined): string {
const configured = normalizeSecretInputString(value);
if (configured || hasConfiguredSecretInput(value)) {
+51
View File
@@ -0,0 +1,51 @@
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
collectSecretInputAssignment,
createChannelSecretTargetRegistryEntries,
getChannelRecord,
type ResolverContext,
type SecretDefaults,
} from "openclaw/plugin-sdk/channel-secret-basic-runtime";
export const secretTargetRegistryEntries = createChannelSecretTargetRegistryEntries({
channelKey: "nostr",
channel: ["privateKey"],
});
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults?: SecretDefaults;
context: ResolverContext;
}): void {
const nostr = getChannelRecord(params.config, "nostr");
if (!nostr) {
return;
}
const accountId = normalizeAccountId(
typeof nostr.defaultAccount === "string" ? nostr.defaultAccount : undefined,
);
collectSecretInputAssignment({
value: nostr.privateKey,
path: "channels.nostr.privateKey",
expected: "string",
defaults: params.defaults,
context: params.context,
active: nostr.enabled !== false,
inactiveReason: "Nostr channel is disabled.",
owner: {
ownerKind: "account",
ownerId: `nostr:${accountId}`,
requiredForGateway: false,
disposition: "isolate",
contract: nostr,
},
apply: (value) => {
nostr.privateKey = value;
},
});
}
export const channelSecrets = {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
};
+3 -3
View File
@@ -11,7 +11,7 @@ import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runti
import type { NostrProfile } from "./config-schema.js";
import { DEFAULT_RELAYS } from "./default-relays.js";
import { getPublicKeyFromPrivate } from "./nostr-key-utils.js";
import { resolveNostrPrivateKey } from "./private-key.js";
import { hasConfiguredNostrPrivateKey, resolveNostrPrivateKey } from "./private-key.js";
interface NostrAccountConfig {
enabled?: boolean;
@@ -43,7 +43,7 @@ const {
fallbackAccountIdWhenEmpty: false,
resolveImplicitAccountId: (cfg) => {
const account = cfg.channels?.nostr as NostrAccountConfig | undefined;
return resolveNostrPrivateKey(account?.privateKey)
return hasConfiguredNostrPrivateKey(account?.privateKey)
? (normalizeOptionalAccountId(account?.defaultAccount) ?? DEFAULT_ACCOUNT_ID)
: undefined;
},
@@ -65,7 +65,7 @@ export function resolveNostrAccount(opts: {
const baseEnabled = nostrCfg?.enabled !== false;
const privateKey = resolveNostrPrivateKey(nostrCfg?.privateKey);
const configured = Boolean(privateKey);
const configured = hasConfiguredNostrPrivateKey(nostrCfg?.privateKey);
let publicKey = "";
if (privateKey) {
+12
View File
@@ -32,6 +32,9 @@ describe("secrets configure plan helpers", () => {
telegram: {
botToken: "token", // pragma: allowlist secret
},
nostr: {
privateKey: "nostr-private-key", // pragma: allowlist secret
},
},
} as OpenClawConfig;
@@ -39,6 +42,15 @@ describe("secrets configure plan helpers", () => {
const paths = candidates.map((entry) => entry.path);
expect(paths).toContain(TALK_TEST_PROVIDER_API_KEY_PATH);
expect(paths).toContain("channels.telegram.botToken");
expect(paths).toContain("channels.nostr.privateKey");
expect(resolveConfigSecretTargetByPath(["channels", "nostr", "privateKey"])).toMatchObject({
entry: {
id: "channels.nostr.privateKey",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
});
});
it("collects provider upserts and deletes", () => {
+167
View File
@@ -0,0 +1,167 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { SecretRef } from "../config/types.secrets.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import {
assertSecretOwnerAvailable,
SecretSurfaceUnavailableError,
} from "./runtime-degraded-state.js";
import { activateSecretsRuntimeSnapshotState } from "./runtime-state.js";
import { asConfig, setupSecretsRuntimeSnapshotTestHooks } from "./runtime.test-support.ts";
import { writeSecretStoreEntry } from "./store/secret-store.js";
const { prepareSecretsRuntimeSnapshot } = setupSecretsRuntimeSnapshotTestHooks();
const tempDirs = createTempDirTracker();
const NOSTR_TEST_PRIVATE_KEY = "1".repeat(64);
afterEach(() => {
closeOpenClawStateDatabaseForTest();
tempDirs.cleanup();
});
describe("Nostr SecretRef runtime ownership", () => {
it.each(["env", "file", "exec", "store"] as const)(
"materializes a valid private key from the %s backend",
async (source) => {
if (source === "exec" && process.platform === "win32") {
return;
}
const root = tempDirs.make("openclaw-nostr-secret-");
const env = {
OPENCLAW_STATE_DIR: path.join(root, "state"),
PATH: process.env.PATH ?? "",
NOSTR_ENV_KEY: NOSTR_TEST_PRIVATE_KEY,
};
let ref: SecretRef;
let providers: Record<string, unknown> = {};
if (source === "file") {
const filePath = path.join(root, "secrets.json");
await fs.writeFile(filePath, JSON.stringify({ nostr: { key: NOSTR_TEST_PRIVATE_KEY } }), {
mode: 0o600,
});
providers = { vault: { source, path: filePath, mode: "json" } };
ref = { source, provider: "vault", id: "/nostr/key" };
} else if (source === "exec") {
const command = path.join(root, "resolve-secret.sh");
const response = JSON.stringify({
protocolVersion: 1,
values: { "nostr/key": NOSTR_TEST_PRIVATE_KEY },
});
await fs.writeFile(command, `#!/bin/sh\ncat >/dev/null\nprintf '%s' '${response}'\n`, {
mode: 0o700,
});
providers = { vault: { source, command, jsonOnly: true, passEnv: ["PATH"] } };
ref = { source, provider: "vault", id: "nostr/key" };
} else if (source === "store") {
writeSecretStoreEntry({
scope: { kind: "team" },
name: "NOSTR_STORE_KEY",
value: NOSTR_TEST_PRIVATE_KEY,
kind: "secret",
updatedBy: "test",
database: { env },
});
ref = { source, provider: "default", id: "NOSTR_STORE_KEY" };
} else {
ref = { source, provider: "default", id: "NOSTR_ENV_KEY" };
}
const snapshot = await prepareSecretsRuntimeSnapshot({
config: asConfig({
secrets: { providers },
channels: { nostr: { defaultAccount: "Team.A", privateKey: ref } },
}),
env,
includeAuthStoreRefs: false,
loadablePluginOrigins: new Map([["nostr", "bundled"]]),
});
expect(snapshot.config.channels?.nostr?.privateKey).toBe(NOSTR_TEST_PRIVATE_KEY);
expect(snapshot.secretOwners).toEqual([
expect.objectContaining({ ownerKind: "account", ownerId: "nostr:team-a" }),
]);
expect(snapshot.degradedOwners).toEqual([]);
},
);
it("keeps a missing named account cold while its healthy channel sibling remains available", async () => {
const missingRef = { source: "env", provider: "default", id: "MISSING_NOSTR_KEY" } as const;
const snapshot = await prepareSecretsRuntimeSnapshot({
config: asConfig({
channels: {
nostr: { defaultAccount: "Team.A", privateKey: missingRef },
telegram: {
botToken: { source: "env", provider: "default", id: "HEALTHY_TELEGRAM_TOKEN" },
},
},
}),
env: {
NOSTR_PRIVATE_KEY: NOSTR_TEST_PRIVATE_KEY,
HEALTHY_TELEGRAM_TOKEN: "123:healthy-token",
},
includeAuthStoreRefs: false,
allowUnavailableSecretOwners: true,
loadablePluginOrigins: new Map([
["nostr", "bundled"],
["telegram", "bundled"],
]),
});
expect(snapshot.config.channels?.nostr?.privateKey).toEqual(missingRef);
expect(snapshot.config.channels?.telegram?.botToken).toBe("123:healthy-token");
expect(snapshot.degradedOwners).toEqual([
expect.objectContaining({
ownerKind: "account",
ownerId: "nostr:team-a",
state: "unavailable",
degradationState: "cold",
paths: ["channels.nostr.privateKey"],
}),
]);
activateSecretsRuntimeSnapshotState({
snapshot,
refreshContext: null,
refreshHandler: null,
});
expect(() => assertSecretOwnerAvailable("account", "nostr:team-a")).toThrow(
SecretSurfaceUnavailableError,
);
expect(() => assertSecretOwnerAvailable("account", "telegram:default")).not.toThrow();
});
it("leaves a disabled exec SecretRef inactive without invoking its provider", async () => {
const privateKey = { source: "exec", provider: "vault", id: "nostr/key" } as const;
const snapshot = await prepareSecretsRuntimeSnapshot({
config: asConfig({
secrets: {
providers: {
vault: {
source: "exec",
command: "/definitely/missing/nostr-secret-provider",
jsonOnly: true,
},
},
},
channels: { nostr: { enabled: false, privateKey } },
}),
env: {},
includeAuthStoreRefs: false,
loadablePluginOrigins: new Map([["nostr", "bundled"]]),
});
expect(snapshot.config.channels?.nostr?.privateKey).toEqual(privateKey);
expect(snapshot.degradedOwners).toEqual([]);
expect(snapshot.warnings).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE",
path: "channels.nostr.privateKey",
}),
]),
);
});
});