openclaw-nostr 2026.9.2 → 2026.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  ## Unreleased (Cascadia Fork)
4
4
 
5
+ ### Fixes — relay AUTH regression in 2026.9.2 (ocn-a4wg)
6
+ - **Upgrade from 2026.9.2.** That release swallowed every `relay.auth()` rejection and returned `false`, on the mistaken premise that nostr-tools never handles it. It handles it in two of three call sites: `SimplePool.subscribeMap` retries a CLOSED `auth-required:` via `auth().then(re-subscribe).catch(stop)`, and `SimplePool.publish` awaits it before republishing. Returning `false` made both treat a **failed** AUTH as success, so a NIP-29 room with `auth:true` re-subscribed unauthenticated in a loop — `closed: auth-required` → reconnect → repeat — with the relay's real refusal reason suppressed, which reads exactly like `auth:true` being ignored. `auth()` now rejects again; the returned promise carries a no-op `.catch()`, which marks it handled for Node's unhandled-rejection detection without consuming it, so the unawaited call in `AbstractRelay._onmessage` still cannot crash the process (the ocn-n5g guarantee) while awaiting callers keep their retry semantics.
7
+
5
8
  ### Fixes — relay AUTH refusal crashed the process (ocn-n5g, ocn-uao2)
6
9
  - **A relay refusing NIP-42 AUTH no longer takes the gateway down (ocn-n5g)**: a relay answering our AUTH event with `["OK", <id>, false, "error: relay needs serviceUrl to be configured before AUTH can work"]` — a misconfigured relay that can never authenticate anyone — rejected the auth promise inside nostr-tools' `AbstractRelay._onmessage`, which invokes `this.auth(this.onauth)` without awaiting or catching it. The rejection escaped as an unhandled rejection and killed the process (stability bundle written), with a stack containing no frames of ours. Our prototype patch previously swallowed only `SendingOnClosedConnection` and `"auth timed out"`, so relay-side refusals sailed through. It now swallows every auth rejection and returns `false` — since the sole caller never handles the promise, rethrowing can only ever crash — and clears the memoized `authPromise` so a later challenge retries instead of replaying a cached rejection forever. The patch moved to `nostr-bus-relay.ts` and is now installed by the fleet-tasks pool too, which sets `automaticallyAuth` but never ran it and carried the same latent crash.
7
10
  - **Failing relays are now named in the logs (ocn-uao2)**: the swallowed failure was reported as a bare string, so the offending relay was unidentifiable. Failures now carry a structured report (relay URL, kind, relay-supplied reason, occurrence and suppressed counts) and are classified `closed_connection` / `timed_out` / `no_challenge` / `relay_refused`, separating self-healing races from a relay that will never authenticate. Refusals route to `onError` so they are always visible in production logs; races route to `onDebug` and are lifted to `warn`. Repeats are throttled per relay+kind (first immediately, then at most one line per minute carrying the suppressed count) because a refusing relay re-refuses on every reconnect. AUTH sends dropped by the closed-connection guard, previously swallowed in silence, are now named too. Emits `relay.auth.rejected` tagged by relay and kind.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-nostr",
3
- "version": "2026.9.2",
3
+ "version": "2026.9.3",
4
4
  "description": "Nostr channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "files": [
@@ -16,9 +16,10 @@
16
16
  "CHANGELOG.md"
17
17
  ],
18
18
  "scripts": {
19
- "build": "tsc --noEmit --noCheck --module NodeNext --moduleResolution NodeNext --target ES2023 index.ts api.ts runtime-api.ts setup-api.ts setup-entry.ts src/*.ts test/setup.ts vitest.config.ts",
19
+ "build": "tsc -p tsconfig.json --noCheck",
20
20
  "test:bootstrap": "node --test test/nostr-claw-bootstrap.test.mjs",
21
- "test:scanner": "vitest run test/runtime-security-scanner.test.ts"
21
+ "test:scanner": "vitest run test/runtime-security-scanner.test.ts",
22
+ "typecheck": "tsc -p tsconfig.json"
22
23
  },
23
24
  "dependencies": {
24
25
  "cascadia-ts": "file:../cascadia-nips/generated/typescript",
@@ -40,7 +40,7 @@ type RoomConfigLike = {
40
40
  type NostrChannelCfgLike = {
41
41
  channels?: {
42
42
  nostr?: {
43
- allowFrom?: readonly string[];
43
+ allowFrom?: readonly (string | number)[];
44
44
  profile?: { name?: string; displayName?: string };
45
45
  nostr_channels?: Record<string, RoomConfigLike | undefined>;
46
46
  };
@@ -82,8 +82,10 @@ function toNpub(pubkeyHex: string): string {
82
82
  }
83
83
  }
84
84
 
85
- function normalizeEntryPubkey(value: string): string | undefined {
86
- const trimmed = value.trim();
85
+ // See normalizeAllowEntry in nostr-taskflow-tool.ts: allowFrom entries may be
86
+ // numbers, so stringify before trimming.
87
+ function normalizeEntryPubkey(value: string | number): string | undefined {
88
+ const trimmed = String(value).trim();
87
89
  if (!trimmed || trimmed === "*") {
88
90
  return undefined;
89
91
  }
package/src/channel.ts CHANGED
@@ -356,7 +356,7 @@ type NostrGroupInboundContextParams = {
356
356
  roomConfig?: NostrRoomChannelMap[string];
357
357
  preflight?: NostrGroupPreflightResult;
358
358
  cfg?: unknown;
359
- accountAllowFrom?: readonly string[];
359
+ accountAllowFrom?: readonly (string | number)[];
360
360
  commandHelpers?: NostrGroupPreflightCommandHelpers;
361
361
  /** P3: a pending-commitment reminder injected as untrusted context for this turn. */
362
362
  commitmentReminder?: string;
@@ -373,7 +373,8 @@ type NostrChannelInboundEventContextParams = {
373
373
  messageIdFull?: string;
374
374
  timestamp?: number;
375
375
  from: string;
376
- sender: {
376
+ /** Optional: system-authored room events have no actor pubkey. */
377
+ sender?: {
377
378
  id?: string;
378
379
  name?: string;
379
380
  displayLabel?: string;
@@ -509,12 +510,12 @@ function buildChannelInboundEventContextCompat(params: NostrChannelInboundEventC
509
510
  ConversationLabel: params.conversation.label,
510
511
  GroupSubject: params.conversation.kind !== "direct" ? params.conversation.label : undefined,
511
512
  UntrustedStructuredContext: params.supplemental?.untrustedContext,
512
- SenderName: params.sender.name ?? params.sender.displayLabel,
513
- SenderId: params.sender.id,
514
- SenderIsBot: params.sender.isBot,
515
- SenderUsername: params.sender.username,
516
- SenderTag: params.sender.tag,
517
- MemberRoleIds: params.sender.roles,
513
+ SenderName: params.sender?.name ?? params.sender?.displayLabel,
514
+ SenderId: params.sender?.id,
515
+ SenderIsBot: params.sender?.isBot,
516
+ SenderUsername: params.sender?.username,
517
+ SenderTag: params.sender?.tag,
518
+ MemberRoleIds: params.sender?.roles,
518
519
  Timestamp: params.timestamp,
519
520
  Provider: params.provider ?? params.channel,
520
521
  Surface: params.surface ?? params.provider ?? params.channel,
@@ -922,8 +923,11 @@ async function assertBackedNostrRoomCommitment(params: {
922
923
  }),
923
924
  );
924
925
  const assessment = await assessCommitment(params.text, roomKey, (reference) => {
926
+ // `effective` is a FleetTaskEventSnapshot; the status lives on its `task`
927
+ // payload. Reading it off the snapshot yielded undefined, so every
928
+ // referenced task — closed ones included — counted as still active.
925
929
  const task = activeFleetTaskServices.get(params.accountId)?.getTask(reference)?.effective;
926
- if (task && task.status !== "closed") return true;
930
+ if (task && task.task.status !== "closed") return true;
927
931
  return resolveNostrTaskFlowBackingActive(
928
932
  getNostrRuntime(),
929
933
  params.accountId,
@@ -1297,8 +1301,11 @@ function localIsNostrAbortRequestText(text: string): boolean {
1297
1301
  return /^(?:abort|cancel|stop)\b/i.test(text.trim());
1298
1302
  }
1299
1303
 
1300
- function normalizeNostrAllowEntry(value: string): string | undefined {
1301
- const trimmed = value.trim();
1304
+ // allowFrom entries may be numbers (the host's shared AllowFromListSchema is
1305
+ // ZodArray<ZodUnion<[ZodString, ZodNumber]>>), so stringify before trimming —
1306
+ // .trim() on a number throws, and this path gates control-command authorization.
1307
+ function normalizeNostrAllowEntry(value: string | number): string | undefined {
1308
+ const trimmed = String(value).trim();
1302
1309
  if (!trimmed || trimmed === "*") {
1303
1310
  return undefined;
1304
1311
  }
@@ -1306,7 +1313,7 @@ function normalizeNostrAllowEntry(value: string): string | undefined {
1306
1313
  }
1307
1314
 
1308
1315
  function nostrAllowListExplicitlyIncludesSender(
1309
- allowFrom: readonly string[] | undefined,
1316
+ allowFrom: readonly (string | number)[] | undefined,
1310
1317
  senderPubkey: string,
1311
1318
  ): boolean {
1312
1319
  const normalizedSender = senderPubkey.toLowerCase();
@@ -1317,7 +1324,7 @@ function resolveNostrCommandAuthorized(params: {
1317
1324
  hasControlCommand: boolean;
1318
1325
  senderPubkey: string;
1319
1326
  roomConfig?: NostrRoomChannelMap[string];
1320
- accountAllowFrom?: readonly string[];
1327
+ accountAllowFrom?: readonly (string | number)[];
1321
1328
  }): boolean {
1322
1329
  if (!params.hasControlCommand) {
1323
1330
  return false;
@@ -1446,7 +1453,7 @@ export function resolveNostrGroupPreflight(params: {
1446
1453
  route: Pick<NostrGroupRouteFacts, "agentId">;
1447
1454
  meta: InboundGroupMessageMeta;
1448
1455
  roomConfig?: NostrRoomChannelMap[string];
1449
- accountAllowFrom?: readonly string[];
1456
+ accountAllowFrom?: readonly (string | number)[];
1450
1457
  commandHelpers?: NostrGroupPreflightCommandHelpers;
1451
1458
  /** True when the sender is a peer for implicit-mention suppression (P1); may include unknownAsPeer. */
1452
1459
  senderIsPeer?: boolean;
@@ -1612,13 +1619,17 @@ function buildNostrGroupMembershipInboundEventContext(params: {
1612
1619
  messageIdFull: params.meta.eventId,
1613
1620
  timestamp: params.meta.createdAt ? params.meta.createdAt * 1000 : Date.now(),
1614
1621
  from: params.meta.actorPubkey ? `nostr:${params.meta.actorPubkey}` : "nostr:system",
1615
- sender: params.meta.actorPubkey
1622
+ // Omit `sender` entirely when there is no actor, rather than setting it to
1623
+ // undefined: the host's param type does not accept an explicit undefined.
1624
+ ...(params.meta.actorPubkey
1616
1625
  ? {
1617
- id: params.meta.actorPubkey,
1618
- name: nip19.npubEncode(params.meta.actorPubkey),
1619
- displayLabel: nip19.npubEncode(params.meta.actorPubkey),
1626
+ sender: {
1627
+ id: params.meta.actorPubkey,
1628
+ name: nip19.npubEncode(params.meta.actorPubkey),
1629
+ displayLabel: nip19.npubEncode(params.meta.actorPubkey),
1630
+ },
1620
1631
  }
1621
- : undefined,
1632
+ : {}),
1622
1633
  conversation: {
1623
1634
  kind: "group",
1624
1635
  id: roomKey,
@@ -2868,7 +2879,7 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
2868
2879
  );
2869
2880
  }
2870
2881
  }
2871
- nip46SessionState = sessionState;
2882
+ nip46SessionState = sessionState ?? undefined;
2872
2883
 
2873
2884
  ctx.setStatus({
2874
2885
  accountId: account.accountId,
@@ -4,6 +4,7 @@ import {
4
4
  type Event,
5
5
  type EventTemplate,
6
6
  type Filter,
7
+ type VerifiedEvent,
7
8
  } from "nostr-tools";
8
9
  import { CAS_CP_STATE, NIP51_TASK_COLLECTION } from "cascadia-ts";
9
10
  import type { FleetAgentSigner } from "./fleet-agent.js";
@@ -771,8 +772,19 @@ export async function createFleetTaskService(
771
772
  ...(await buildPersistentPoolOptions()),
772
773
  enableReconnect: true,
773
774
  } as ConstructorParameters<typeof SimplePool>[0]);
775
+ // nostr-tools requires a VerifiedEvent here, not merely a signed one. The
776
+ // signer may be a remote NIP-46 bunker, so verify the returned event rather
777
+ // than asserting the brand: an unverifiable AUTH event would be rejected by
778
+ // the relay anyway, and failing here keeps a malformed bunker reply from
779
+ // being attributed to us.
774
780
  const onauth = options.auth
775
- ? async (template: EventTemplate) => options.signer.signEvent(template)
781
+ ? async (template: EventTemplate): Promise<VerifiedEvent> => {
782
+ const signed = await options.signer.signEvent(template);
783
+ if (!verifyEvent(signed)) {
784
+ throw new Error("NIP-42 auth event failed signature verification");
785
+ }
786
+ return signed;
787
+ }
776
788
  : undefined;
777
789
  if (onauth) pool.automaticallyAuth = () => onauth;
778
790
  const metrics = createNoopMetrics();
package/src/metrics.ts CHANGED
@@ -12,6 +12,7 @@ export type EventMetricName =
12
12
  | "event.processed"
13
13
  | "event.duplicate"
14
14
  | "event.skipped.service_envelope"
15
+ | "event.rejected.invalid_content"
15
16
  | "event.rejected.invalid_shape"
16
17
  | "event.rejected.wrong_kind"
17
18
  | "event.rejected.wrong_target"
@@ -143,6 +144,8 @@ export interface MetricsSnapshot {
143
144
  eventsDuplicate: number;
144
145
  /** Events rejected by reason */
145
146
  eventsRejected: {
147
+ /** Content too short to be a NIP-44 ciphertext. */
148
+ invalidContent: number;
146
149
  invalidShape: number;
147
150
  wrongKind: number;
148
151
  wrongTarget: number;
@@ -250,6 +253,7 @@ export function createMetrics(onMetric?: OnMetricCallback): NostrMetrics {
250
253
  let eventsProcessed = 0;
251
254
  let eventsDuplicate = 0;
252
255
  const eventsRejected = {
256
+ invalidContent: 0,
253
257
  invalidShape: 0,
254
258
  wrongKind: 0,
255
259
  wrongTarget: 0,
@@ -375,6 +379,9 @@ export function createMetrics(onMetric?: OnMetricCallback): NostrMetrics {
375
379
  case "event.duplicate":
376
380
  eventsDuplicate += value;
377
381
  break;
382
+ case "event.rejected.invalid_content":
383
+ eventsRejected.invalidContent += value;
384
+ break;
378
385
  case "event.rejected.invalid_shape":
379
386
  eventsRejected.invalidShape += value;
380
387
  break;
@@ -622,7 +629,8 @@ export function createMetrics(onMetric?: OnMetricCallback): NostrMetrics {
622
629
  eventsProcessed = 0;
623
630
  eventsDuplicate = 0;
624
631
  Object.assign(eventsRejected, {
625
- invalidShape: 0,
632
+ invalidContent: 0,
633
+ invalidShape: 0,
626
634
  wrongKind: 0,
627
635
  wrongTarget: 0,
628
636
  stale: 0,
@@ -672,7 +680,8 @@ export function createNoopMetrics(): NostrMetrics {
672
680
  eventsProcessed: 0,
673
681
  eventsDuplicate: 0,
674
682
  eventsRejected: {
675
- invalidShape: 0,
683
+ invalidContent: 0,
684
+ invalidShape: 0,
676
685
  wrongKind: 0,
677
686
  wrongTarget: 0,
678
687
  stale: 0,
@@ -360,7 +360,7 @@ export class SignerRequestQueue {
360
360
  }
361
361
 
362
362
  private async waitForDispatchSlot(): Promise<void> {
363
- while (this.pending.length > 0 && this.state !== "closed") {
363
+ while (this.pending.length > 0 && !this.isClosed()) {
364
364
  const dispatchIntervalMs = Math.max(this.intervalMs, this.adaptiveIntervalMs);
365
365
  const now = Date.now();
366
366
  if (dispatchIntervalMs <= 0) {
@@ -381,17 +381,31 @@ export class SignerRequestQueue {
381
381
  }
382
382
  }
383
383
 
384
+ /**
385
+ * Read `state` through a method call, not a property access.
386
+ *
387
+ * `close()` flips the state to "closed" while `drain()` is parked on an
388
+ * `await`, but TypeScript does not invalidate property narrowing across an
389
+ * await — after one `this.state === "closed"` guard it believes the field can
390
+ * only be "open" | "closing" for the rest of the method and flags every later
391
+ * check as an impossible comparison. The checks are load-bearing, so re-read
392
+ * through a call, whose result is never narrowed.
393
+ */
394
+ private isClosed(): boolean {
395
+ return this.state === "closed";
396
+ }
397
+
384
398
  private async drain(): Promise<void> {
385
- if (this.draining || this.state === "closed") return;
399
+ if (this.draining || this.isClosed()) return;
386
400
  this.draining = true;
387
401
  try {
388
402
  while (
389
403
  this.pending.length > 0
390
404
  && this.activeJobs.size < this.concurrency
391
- && this.state !== "closed"
405
+ && !this.isClosed()
392
406
  ) {
393
407
  await this.waitForDispatchSlot();
394
- if (this.state === "closed") break;
408
+ if (this.isClosed()) break;
395
409
  const job = this.pending.shift();
396
410
  if (!job) continue;
397
411
  this.activeJobs.add(job);
@@ -902,20 +916,23 @@ export async function connectNip46SignerForEnrollment(
902
916
  opts.connectionTimeoutMs ?? 60_000,
903
917
  opts.abortSignal,
904
918
  );
905
- handle = createSessionSigner(bunker, resolved.transportPubkey, {
919
+ // `handle` stays a mutable let for the catch-block cleanup check, but the
920
+ // liveness closure below needs a binding TypeScript can prove is defined.
921
+ const session = createSessionSigner(bunker, resolved.transportPubkey, {
906
922
  ...opts,
907
923
  operatorRelays: opts.signerRelayMode === "operator-only"
908
924
  ? [...resolved.relays]
909
925
  : undefined,
910
926
  });
927
+ handle = session;
911
928
  const userPubkey = await runNip46OperationWithTimeout(
912
- () => proveNip46SessionLiveness(handle.signer, opts.expectedUserPubkey),
929
+ () => proveNip46SessionLiveness(session.signer, opts.expectedUserPubkey),
913
930
  "liveness",
914
931
  opts.connectionTimeoutMs ?? 60_000,
915
932
  opts.abortSignal,
916
933
  );
917
- handle.setUserPubkey(userPubkey);
918
- return handle.signer;
934
+ session.setUserPubkey(userPubkey);
935
+ return session.signer;
919
936
  } catch (error) {
920
937
  if (handle) {
921
938
  try {
@@ -956,20 +973,23 @@ export async function resumeNip46Signer(
956
973
  });
957
974
  let handle: ReturnType<typeof createSessionSigner> | undefined;
958
975
  try {
959
- handle = createSessionSigner(bunker, transportPubkey, {
976
+ // See the enrollment path above: `handle` remains a let for cleanup, while
977
+ // the closure captures a const the compiler can prove is defined.
978
+ const session = createSessionSigner(bunker, transportPubkey, {
960
979
  ...opts,
961
980
  operatorRelays: opts.signerRelayMode === "operator-only"
962
981
  ? [...pointer.relays]
963
982
  : undefined,
964
983
  });
984
+ handle = session;
965
985
  const userPubkey = await runNip46OperationWithTimeout(
966
- () => proveNip46SessionLiveness(handle.signer, opts.pinnedUserPubkey),
986
+ () => proveNip46SessionLiveness(session.signer, opts.pinnedUserPubkey),
967
987
  "liveness",
968
988
  opts.connectionTimeoutMs ?? 60_000,
969
989
  opts.abortSignal,
970
990
  );
971
- handle.setUserPubkey(userPubkey);
972
- return handle.signer;
991
+ session.setUserPubkey(userPubkey);
992
+ return session.signer;
973
993
  } catch (error) {
974
994
  try {
975
995
  await handle?.signer.close();
@@ -14,6 +14,8 @@ import {
14
14
  type SimplePool,
15
15
  type Event,
16
16
  type EventTemplate,
17
+ type Filter,
18
+ type VerifiedEvent,
17
19
  } from "nostr-tools";
18
20
  import { Reaction as ReactionKind } from "nostr-tools/kinds";
19
21
  import { type NostrRoomState } from "./nostr-state-store.js";
@@ -807,6 +809,19 @@ export function createNip29Manager(ctx: Nip29Context): Nip29Manager {
807
809
  const doSignEvent = ctx.signEvent
808
810
  ?? ((template: EventTemplate) => Promise.resolve(finalizeEvent(template, sk)));
809
811
 
812
+ // nostr-tools' onauth contract requires a VerifiedEvent. doSignEvent may be a
813
+ // remote NIP-46 signer returning a plain Event, so verify rather than assert:
814
+ // an unverifiable AUTH event would be refused by the relay regardless.
815
+ const signAuthEventVerified = async (
816
+ authTemplate: EventTemplate,
817
+ ): Promise<VerifiedEvent> => {
818
+ const signed = await doSignEvent(authTemplate);
819
+ if (!verifyEvent(signed)) {
820
+ throw new Error("NIP-42 auth event failed signature verification");
821
+ }
822
+ return signed;
823
+ };
824
+
810
825
  async function withPublishTimeout<T>(promise: Promise<T>, relayUrl: string): Promise<T> {
811
826
  if (!publishTimeoutMs || publishTimeoutMs <= 0) {
812
827
  return promise;
@@ -872,7 +887,7 @@ export function createNip29Manager(ctx: Nip29Context): Nip29Manager {
872
887
  const event = await doSignEvent(args.template);
873
888
  const results = (args.auth
874
889
  ? roomPool.publish([args.relayUrl], event, {
875
- onauth: async (authTemplate: EventTemplate) => doSignEvent(authTemplate),
890
+ onauth: signAuthEventVerified,
876
891
  })
877
892
  : roomPool.publish([args.relayUrl], event)
878
893
  ) as unknown as Promise<string>[];
@@ -1269,12 +1284,12 @@ export function createNip29Manager(ctx: Nip29Context): Nip29Manager {
1269
1284
  ` since=${since} room=${roomKey}`,
1270
1285
  );
1271
1286
  let caughtUp = false;
1272
- const eventFilter: Record<string, unknown> = {
1287
+ const eventFilter: Filter = {
1273
1288
  kinds: [9, ReactionKind, 9000, 9001, 9005],
1274
1289
  "#h": [groupId],
1275
1290
  since,
1276
1291
  };
1277
- const metadataFilter: Record<string, unknown> = {
1292
+ const metadataFilter: Filter = {
1278
1293
  kinds: [39000, 39001, 39002, 39003],
1279
1294
  "#d": [groupId],
1280
1295
  since,
@@ -1299,9 +1314,7 @@ export function createNip29Manager(ctx: Nip29Context): Nip29Manager {
1299
1314
  // NIP-42 subscription-level auth: only included when the channel has
1300
1315
  // auth:true. When the relay sends CLOSED auth-required, nostr-tools
1301
1316
  // calls this signer, sends AUTH, and automatically re-subscribes.
1302
- ...(auth
1303
- ? { onauth: async (authTemplate: EventTemplate) => doSignEvent(authTemplate) }
1304
- : {}),
1317
+ ...(auth ? { onauth: signAuthEventVerified } : {}),
1305
1318
  onclose: (reasons: string[]) => {
1306
1319
  subscriptions.delete(sub);
1307
1320
  const normalized = normalizeCloseReasons(reasons, 1);
@@ -45,7 +45,9 @@ describe("patchNostrToolsAuthSendRace", () => {
45
45
  );
46
46
  const relay = relayRejectingAuthWith("wss://relay.example", rejection);
47
47
 
48
- await expect(relay.auth(async () => undefined)).resolves.toBe(false);
48
+ // The rejection must survive: nostr-tools' subscribeMap and publish both
49
+ // await auth() and treat a rejection as "auth failed, stop retrying".
50
+ await expect(relay.auth(async () => undefined)).rejects.toThrow(rejection);
49
51
 
50
52
  expect(relay.authPromise).toBeUndefined(); // cleared so a retry can happen
51
53
  expect(reports).toHaveLength(1);
@@ -71,8 +73,8 @@ describe("patchNostrToolsAuthSendRace", () => {
71
73
  new Error("auth timed out"),
72
74
  );
73
75
 
74
- await closed.auth(async () => undefined);
75
- await timedOut.auth(async () => undefined);
76
+ await expect(closed.auth(async () => undefined)).rejects.toThrow();
77
+ await expect(timedOut.auth(async () => undefined)).rejects.toThrow();
76
78
 
77
79
  expect(reports.map((r) => [r.relayUrl, r.kind, r.benign])).toEqual([
78
80
  ["wss://flaky.example", "closed_connection", true],
@@ -84,7 +86,7 @@ describe("patchNostrToolsAuthSendRace", () => {
84
86
  const reason = "error: relay needs serviceUrl to be configured before AUTH can work";
85
87
  for (let i = 0; i < 4; i++) {
86
88
  const relay = relayRejectingAuthWith("wss://noisy.example", new Error(reason));
87
- await relay.auth(async () => undefined);
89
+ await expect(relay.auth(async () => undefined)).rejects.toThrow();
88
90
  }
89
91
 
90
92
  // First failure reports immediately; the rest are folded into the counters.
@@ -93,7 +95,7 @@ describe("patchNostrToolsAuthSendRace", () => {
93
95
 
94
96
  // A different relay is tracked independently, not throttled by the first.
95
97
  const other = relayRejectingAuthWith("wss://other.example", new Error(reason));
96
- await other.auth(async () => undefined);
98
+ await expect(other.auth(async () => undefined)).rejects.toThrow();
97
99
  expect(reports).toHaveLength(2);
98
100
  expect(reports[1]!.relayUrl).toBe("wss://other.example");
99
101
  });
@@ -102,11 +104,32 @@ describe("patchNostrToolsAuthSendRace", () => {
102
104
  const relay = relayRejectingAuthWith("", new Error("nope"));
103
105
  relay.url = undefined;
104
106
 
105
- await relay.auth(async () => undefined);
107
+ await expect(relay.auth(async () => undefined)).rejects.toThrow();
106
108
 
107
109
  expect(reports[0]!.relayUrl).toBe("<unknown relay>");
108
110
  });
109
111
 
112
+ // Regression: the ocn-n5g crash fix originally swallowed the rejection and
113
+ // returned false. That stopped the crash but broke nostr-tools' auth-required
114
+ // retry — subscribeMap re-subscribed and publish republished as though a
115
+ // failed AUTH had succeeded, producing an endless auth-required loop. Both
116
+ // properties have to hold at once.
117
+ it("keeps an unawaited auth rejection from surfacing as unhandled", async () => {
118
+ const unhandled: unknown[] = [];
119
+ const onUnhandled = (reason: unknown) => unhandled.push(reason);
120
+ process.on("unhandledRejection", onUnhandled);
121
+ try {
122
+ const relay = relayRejectingAuthWith("wss://crash.example", new Error("refused"));
123
+ // Exactly how AbstractRelay._onmessage `case "AUTH"` calls it: no await,
124
+ // no catch.
125
+ void relay.auth(async () => undefined);
126
+ await new Promise((resolve) => setTimeout(resolve, 20));
127
+ expect(unhandled).toEqual([]);
128
+ } finally {
129
+ process.off("unhandledRejection", onUnhandled);
130
+ }
131
+ });
132
+
110
133
  it("is idempotent across repeated installs", () => {
111
134
  const before = relayBase().auth;
112
135
  patchNostrToolsAuthSendRace();
@@ -129,34 +129,47 @@ export function patchNostrToolsAuthSendRace(reporter?: RelayAuthPatchReporter):
129
129
  }
130
130
  };
131
131
  if (typeof originalAuth === "function") {
132
- relayBase.auth = async function patchedAuth(signAuthEvent: unknown): Promise<unknown> {
132
+ relayBase.auth = function patchedAuth(signAuthEvent: unknown): Promise<unknown> {
133
133
  const self = this as { url?: string; authPromise?: unknown };
134
- try {
135
- return await originalAuth.call(this, signAuthEvent);
136
- } catch (error) {
137
- // nostr-tools invokes relay.auth() from its WebSocket message handler
138
- // (`case "AUTH"`) without awaiting or catching the returned promise, so
139
- // ANY rejection here escapes as an unhandled rejection and crashes the
140
- // process. Every failure mode is non-fatal to us:
141
- // - closed_connection / timed_out: connection races, resolved by the
142
- // pool's reconnect + re-auth.
143
- // - relay_refused, surfaced as `["OK", id, false, reason]` (e.g.
144
- // "error: relay needs serviceUrl to be configured before AUTH can
145
- // work" from a misconfigured relay): we simply stay unauthenticated
146
- // on that relay.
147
- // So we report and return false rather than rethrowing.
148
- const err = error instanceof Error ? error : new Error(String(error));
149
- // Drop the memoized rejected promise so a later challenge can retry
150
- // instead of replaying this failure (and re-triggering the rejection).
151
- self.authPromise = undefined;
152
- reportRelayAuthFailure(relayUrlOf(self), err);
153
- return false;
154
- }
134
+ const promise = runPatchedAuth(originalAuth, self, signAuthEvent);
135
+ promise.catch(() => {});
136
+ // nostr-tools calls relay.auth() from three places, and they disagree
137
+ // about who owns the rejection:
138
+ // - AbstractRelay._onmessage `case "AUTH"` calls it unawaited and
139
+ // uncaught, so a rejection there crashes the process (ocn-n5g);
140
+ // - SimplePool.subscribeMap retries a CLOSED "auth-required: " by
141
+ // awaiting auth() and treating a rejection as "auth failed, stop";
142
+ // - SimplePool.publish awaits it before republishing.
143
+ // Attaching a handler here marks the promise handled for Node's
144
+ // unhandled-rejection detection WITHOUT consuming it: the two awaiting
145
+ // callers still observe the rejection and keep their retry semantics.
146
+ // Swallowing the rejection outright (returning false) instead made those
147
+ // callers believe a failed AUTH had succeeded, so they re-subscribed and
148
+ // republished into an endless auth-required loop.
149
+ return promise;
155
150
  };
156
151
  }
157
152
  relayBase.__openclawAuthSendRacePatched = true;
158
153
  }
159
154
 
155
+ async function runPatchedAuth(
156
+ originalAuth: (this: unknown, signAuthEvent: unknown) => Promise<unknown>,
157
+ self: { url?: string; authPromise?: unknown },
158
+ signAuthEvent: unknown,
159
+ ): Promise<unknown> {
160
+ try {
161
+ return await originalAuth.call(self, signAuthEvent);
162
+ } catch (error) {
163
+ const err = error instanceof Error ? error : new Error(String(error));
164
+ // Drop the memoized rejected promise so a later challenge can retry
165
+ // instead of replaying this failure forever.
166
+ self.authPromise = undefined;
167
+ reportRelayAuthFailure(relayUrlOf(self), err);
168
+ // Rethrow: nostr-tools' own auth-required retry logic depends on it.
169
+ throw err;
170
+ }
171
+ }
172
+
160
173
  // ============================================================================
161
174
  // Constants
162
175
  // ============================================================================
package/src/nostr-bus.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  nip19,
9
9
  type Event,
10
10
  type EventTemplate,
11
+ type VerifiedEvent,
11
12
  } from "nostr-tools";
12
13
  import { decrypt as nip04Decrypt, encrypt as nip04Encrypt } from "nostr-tools/nip04";
13
14
  import * as nip59 from "nostr-tools/nip59";
@@ -157,8 +158,10 @@ const AUTH_SIGN_JITTER = 0.2;
157
158
  class DeterministicNip17Error extends Error {}
158
159
 
159
160
  type RelayAuthState = {
160
- inFlight: Map<string, Promise<Event>>;
161
- signedCache: Map<string, { event: Event; expiresAt: number }>;
161
+ // nostr-tools' auth contract is VerifiedEvent, so the coalescing cache holds
162
+ // the verified event rather than re-verifying on every cache hit.
163
+ inFlight: Map<string, Promise<VerifiedEvent>>;
164
+ signedCache: Map<string, { event: VerifiedEvent; expiresAt: number }>;
162
165
  serial: Promise<void>;
163
166
  lastSignStartedAt: number;
164
167
  windowStartedAt: number;
@@ -607,7 +610,7 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
607
610
  const signRelayAuthEvent = async (
608
611
  relayURL: string,
609
612
  authTemplate: EventTemplate,
610
- ): Promise<Event> => {
613
+ ): Promise<VerifiedEvent> => {
611
614
  const state = getRelayAuthState(relayURL);
612
615
  const key = stableAuthChallengeKey(relayURL, authTemplate);
613
616
  const now = Date.now();
@@ -684,6 +687,15 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
684
687
  metrics.emit("relay.message.auth", 1, { relay: relayURL, outcome: "signed" });
685
688
  try {
686
689
  const signed = await signEvent(authTemplate);
690
+ // nostr-tools requires a VerifiedEvent here. signEvent may be a remote
691
+ // NIP-46 bunker returning a plain Event, so verify rather than assert:
692
+ // a relay would reject an unverifiable AUTH event anyway, and failing
693
+ // here keeps a malformed bunker reply from being attributed to us.
694
+ if (!verifyEvent(signed)) {
695
+ throw new Error(
696
+ `NIP-42 auth event for ${relayURL} failed signature verification`,
697
+ );
698
+ }
687
699
  state.backoffAttempt = 0;
688
700
  state.backoffUntil = 0;
689
701
  state.signedCache.set(key, {
@@ -718,7 +730,7 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
718
730
  // Pool-level handler for WebSocket AUTH challenges; publish-level handler
719
731
  // (publishAuthSigner) for OK false "auth-required" responses on EVENT.
720
732
  pool.automaticallyAuth = (relayURL: string) => {
721
- return async (unsignedAuthEvent: any) => {
733
+ return async (unsignedAuthEvent: EventTemplate) => {
722
734
  return signRelayAuthEvent(relayURL, unsignedAuthEvent);
723
735
  };
724
736
  };
@@ -598,7 +598,9 @@ function parseNip46BunkerIdentity(
598
598
  }
599
599
  const identity = value as Record<string, unknown>;
600
600
  if (identity.kind === "bunker") {
601
- const expected = `bunker://${transportPubkey}`;
601
+ // Annotated so the template literal keeps its `bunker://${string}` type
602
+ // instead of widening to string, which the return type requires.
603
+ const expected: `bunker://${string}` = `bunker://${transportPubkey}`;
602
604
  if (
603
605
  typeof identity.uri !== "string"
604
606
  || identity.uri !== expected
@@ -212,7 +212,7 @@ type ResolvedTaskFlowRoom = {
212
212
  type NostrChannelConfigLike = {
213
213
  channels?: {
214
214
  nostr?: {
215
- allowFrom?: readonly string[];
215
+ allowFrom?: readonly (string | number)[];
216
216
  nostr_channels?: Record<string, NostrRoomConfigLike | undefined>;
217
217
  };
218
218
  };
@@ -255,8 +255,12 @@ function roomTaskFlowsEnabled(roomConfig: NostrRoomConfigLike): boolean {
255
255
  return roomConfig.config?.taskFlows === true;
256
256
  }
257
257
 
258
- function normalizeAllowEntry(value: string): string | undefined {
259
- const trimmed = value.trim();
258
+ // allowFrom entries are `string | number` per the host's shared
259
+ // AllowFromListSchema (ZodArray<ZodUnion<[ZodString, ZodNumber]>>), so a numeric
260
+ // entry is valid config. Stringify before trimming — calling .trim() on a number
261
+ // throws. src/nostr-access.ts already handles the union this way.
262
+ function normalizeAllowEntry(value: string | number): string | undefined {
263
+ const trimmed = String(value).trim();
260
264
  if (!trimmed || trimmed === "*") {
261
265
  return undefined;
262
266
  }
@@ -290,8 +294,8 @@ export function resolveTaskFlowMutationPolicy(
290
294
  export function isTaskFlowMutationAllowed(params: {
291
295
  senderIsOwner?: boolean;
292
296
  requesterSenderId?: string;
293
- roomAllowFrom?: readonly string[];
294
- accountAllowFrom?: readonly string[];
297
+ roomAllowFrom?: readonly (string | number)[];
298
+ accountAllowFrom?: readonly (string | number)[];
295
299
  policy?: TaskFlowMutationPolicy;
296
300
  }): boolean {
297
301
  if (params.senderIsOwner === true) {
@@ -309,7 +313,7 @@ export function isTaskFlowMutationAllowed(params: {
309
313
  }
310
314
  if (
311
315
  params.policy === "room" &&
312
- (params.roomAllowFrom ?? []).some((entry) => entry.trim() === "*")
316
+ (params.roomAllowFrom ?? []).some((entry) => String(entry).trim() === "*")
313
317
  ) {
314
318
  return true;
315
319
  }
@@ -19,6 +19,10 @@ import { getPublicKeyFromPrivate, normalizePubkey } from "./nostr-bus.js";
19
19
  import { resolveNostrAccount } from "./types.js";
20
20
 
21
21
  const channel = "nostr" as const;
22
+
23
+ // Widened deliberately — see the UPSTREAM GAP note at the textInput below.
24
+ type WizardTextInputKey = NonNullable<ChannelSetupWizard["textInputs"]>[number]["inputKey"];
25
+ const NIP46_BUNKER_URL_INPUT_KEY = "nip46BunkerUrl" as WizardTextInputKey;
22
26
  const setNostrAllowFrom = createTopLevelChannelAllowFromSetter({
23
27
  channel,
24
28
  });
@@ -148,7 +152,26 @@ export const nostrSetupAdapter: ChannelSetupAdapter = {
148
152
  },
149
153
  };
150
154
 
151
- export const nostrSetupWizard: ChannelSetupWizard = {
155
+ /**
156
+ * Shape of the `toggles` steps the host does not implement (ocn-06u9). Declared
157
+ * locally so the entries stay type-checked instead of degrading to `any` while
158
+ * they wait to be ported onto a supported mechanism.
159
+ */
160
+ type UnsupportedWizardToggle = {
161
+ inputKey: string;
162
+ message: string;
163
+ helpTitle?: string;
164
+ helpLines?: string[];
165
+ currentValue?: (params: { cfg: OpenClawConfig }) => boolean | undefined;
166
+ applySet?: (params: {
167
+ cfg: OpenClawConfig;
168
+ value: boolean;
169
+ }) => OpenClawConfig | Promise<OpenClawConfig>;
170
+ };
171
+
172
+ export const nostrSetupWizard: ChannelSetupWizard & {
173
+ toggles?: UnsupportedWizardToggle[];
174
+ } = {
152
175
  channel,
153
176
  resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID,
154
177
  resolveShouldPromptAccountIds: () => false,
@@ -230,6 +253,12 @@ export const nostrSetupWizard: ChannelSetupWizard = {
230
253
  }),
231
254
  },
232
255
  ],
256
+ // DEAD CONFIG (ocn-06u9): the host's ChannelSetupWizard has no `toggles`
257
+ // member and its wizard runtime never reads one — it iterates `credentials`
258
+ // and `textInputs` only. This step is therefore never shown and `applySet`
259
+ // never runs; operators are never asked whether to enable NIP-46. Kept (with
260
+ // a cast, so the typecheck can be a gate) until ocn-06u9 reimplements it on a
261
+ // mechanism the host supports, rather than deleting a documented intent.
233
262
  toggles: [
234
263
  {
235
264
  inputKey: "nip46",
@@ -253,7 +282,18 @@ export const nostrSetupWizard: ChannelSetupWizard = {
253
282
  ],
254
283
  textInputs: [
255
284
  {
256
- inputKey: "nip46BunkerUrl",
285
+ // UPSTREAM GAP (ocn-ozwe): the host's `ChannelSetupInput` is a closed type
286
+ // and has no `nip46BunkerUrl` member, though it does carry other
287
+ // nostr-specific keys (relayUrls, groupChannels, dmAllowlist). The cast
288
+ // records that gap rather than hiding a local defect.
289
+ //
290
+ // Runtime is unaffected: the host treats `inputKey` as an opaque computed
291
+ // key (onboard-channels reads `credentialValues[inputKey]`, then hands the
292
+ // entered value to our own `applySet`), so the interactive step works
293
+ // end to end. The real consequence is narrower — no CLI flag is derived
294
+ // for a key the host does not know, so this value cannot be supplied
295
+ // non-interactively via `openclaw channels add`.
296
+ inputKey: NIP46_BUNKER_URL_INPUT_KEY,
257
297
  message: "NIP-46 bunker URL (bunker://... or user@domain)",
258
298
  placeholder: "bunker://pubkey?relay=wss://relay.example.com",
259
299
  required: false,
@@ -268,10 +308,13 @@ export const nostrSetupWizard: ChannelSetupWizard = {
268
308
  return account.nip46BunkerUrl ?? "";
269
309
  },
270
310
  keepPrompt: (value) => `NIP-46 bunker URL set (${value}). Keep it?`,
271
- validate: ({ value }) => {
272
- if (!value.trim()) return null;
273
- if (/^bunker:\/\/[0-9a-f]{64}/.test(value)) return null;
274
- if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return null;
311
+ // The host contract is `string | undefined` (it returns `void 0` for
312
+ // "valid" in its own adapters); `null` happened to work only because
313
+ // every consumer truthiness-checks the result.
314
+ validate: ({ value }): string | undefined => {
315
+ if (!value.trim()) return undefined;
316
+ if (/^bunker:\/\/[0-9a-f]{64}/.test(value)) return undefined;
317
+ if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return undefined;
275
318
  return "Must be a bunker:// URL or user@domain NIP-05 identifier";
276
319
  },
277
320
  applySet: async ({ cfg, value }) =>
package/src/types.test.ts CHANGED
@@ -798,3 +798,31 @@ describe("NIP-46 connect secret config resolution", () => {
798
798
  expect(account.config.nip46ReconnectPolicy).toEqual({ refreshOnStart: true });
799
799
  });
800
800
  });
801
+
802
+ describe("resolveNostrAccount watchdog timeouts", () => {
803
+ // Regression (ocn-43nd): inboundDispatchAbortSeconds is documented and
804
+ // accepted by the Zod schema, but was absent from NostrAccountConfig and
805
+ // from this resolver, so the value an operator configured never reached the
806
+ // bus and the stage-2 hang watchdog always used its default.
807
+ it("propagates both inbound dispatch watchdog stages", () => {
808
+ const cfg = {
809
+ channels: {
810
+ nostr: {
811
+ privateKey: TEST_PRIVATE_KEY,
812
+ inboundDispatchTimeoutSeconds: 120,
813
+ inboundDispatchAbortSeconds: 600,
814
+ },
815
+ },
816
+ };
817
+ const account = resolveNostrAccount({ cfg });
818
+ expect(account.config.inboundDispatchTimeoutSeconds).toBe(120);
819
+ expect(account.config.inboundDispatchAbortSeconds).toBe(600);
820
+ });
821
+
822
+ it("leaves both undefined when unset so defaults apply", () => {
823
+ const cfg = { channels: { nostr: { privateKey: TEST_PRIVATE_KEY } } };
824
+ const account = resolveNostrAccount({ cfg });
825
+ expect(account.config.inboundDispatchTimeoutSeconds).toBeUndefined();
826
+ expect(account.config.inboundDispatchAbortSeconds).toBeUndefined();
827
+ });
828
+ });
package/src/types.ts CHANGED
@@ -62,6 +62,7 @@ export interface NostrAccountConfig {
62
62
  nip17Enabled?: boolean;
63
63
  nip04Enabled?: boolean;
64
64
  inboundDispatchTimeoutSeconds?: number;
65
+ inboundDispatchAbortSeconds?: number;
65
66
  publishTimeoutSeconds?: number;
66
67
  dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
67
68
  dmContinuity?: NostrDmContinuityConfig;
@@ -570,6 +571,7 @@ export function resolveNostrAccount(opts: {
570
571
  nip17Enabled: nostrCfg?.nip17Enabled,
571
572
  nip04Enabled: nostrCfg?.nip04Enabled,
572
573
  inboundDispatchTimeoutSeconds: nostrCfg?.inboundDispatchTimeoutSeconds,
574
+ inboundDispatchAbortSeconds: nostrCfg?.inboundDispatchAbortSeconds,
573
575
  publishTimeoutSeconds: nostrCfg?.publishTimeoutSeconds,
574
576
  dmPolicy: nostrCfg?.dmPolicy,
575
577
  allowFrom: nostrCfg?.allowFrom,