@xmoxmo/bncr 0.6.5 → 0.6.7

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.
@@ -11,6 +11,7 @@ import {
11
11
  import { dispatchOpenClawReplyWithBufferedBlockDispatcher } from '../../openclaw/reply-runtime.ts';
12
12
  import { resolveOpenClawAgentRoute } from '../../openclaw/routing-runtime.ts';
13
13
  import { performBncrGatewaySessionReset } from '../../openclaw/session-reset-runtime.ts';
14
+ import { addNativeElevation, removeNativeElevation } from '../../plugin/config.ts';
14
15
  import { mergeBncrOwnerAllowFromIntoConfig } from './command-owner.ts';
15
16
  import type {
16
17
  BncrEnqueueFromReply,
@@ -120,8 +121,49 @@ export async function handleBncrNativeCommand(params: {
120
121
 
121
122
  // Stop always goes through the unified stop fast path in dispatch.ts.
122
123
  // It is not a bncr native command and must never be processed here.
123
- if (isBncrStopCommandText(extracted.text)) {
124
- return { handled: false };
124
+ // Only exact `/stop` is accepted; any other `/stop*` form is rejected.
125
+ const rawStopText = String(extracted.text || '')
126
+ .trim()
127
+ .toLowerCase();
128
+ if (rawStopText.startsWith('/stop')) {
129
+ if (isBncrStopCommandText(extracted.text)) {
130
+ return { handled: false };
131
+ }
132
+ // Non-exact `/stop` forms (extra args, @bot suffix, or other extension)
133
+ // are rejected immediately so they never reach the agent.
134
+ const rejectedRoute = buildBncrNativeCommandResolvedRoute({
135
+ api,
136
+ cfg,
137
+ channelId,
138
+ accountId,
139
+ peer,
140
+ resolvedAgentId,
141
+ });
142
+ const { baseSessionKey, sessionKey } = buildBncrNativeCommandSessionState({
143
+ parsed,
144
+ sessionAgentId: rejectedRoute.agentId || canonicalAgentId,
145
+ resolvedRoute: rejectedRoute,
146
+ });
147
+ logBncrNativeCommandSummary(
148
+ buildBncrNativeCommandSummary({
149
+ kind: 'stop',
150
+ command: rawStopText,
151
+ accountId,
152
+ to: formatDisplayScope(route),
153
+ msgId: msgId || null,
154
+ result: 'rejected',
155
+ }),
156
+ );
157
+ await enqueueFromReply({
158
+ accountId,
159
+ sessionKey: baseSessionKey,
160
+ route,
161
+ payload: {
162
+ text: 'Only exact /stop is supported. Send /stop to stop.',
163
+ replyToId: msgId || undefined,
164
+ },
165
+ });
166
+ return { handled: true, command: 'stop', sessionKey };
125
167
  }
126
168
 
127
169
  const whitelistBareCommand =
@@ -733,74 +775,83 @@ export async function handleBncrNativeCommand(params: {
733
775
  },
734
776
  { debugOnly: true, debugEnabled: nativeCommandDebugEnabled },
735
777
  );
736
- await resolveBncrChannelInboundRuntime(api).run({
737
- channel: channelId,
738
- accountId,
739
- raw: parsed,
740
- adapter: {
741
- ingest: () => ({
742
- id: msgId ?? `${displayTo}:${Date.now()}`,
743
- timestamp: Date.now(),
744
- rawText: body,
745
- textForAgent: ctxPayload.BodyForAgent,
746
- textForCommands: ctxPayload.CommandBody,
747
- raw: parsed,
748
- }),
749
- resolveTurn: () => {
750
- const sessionMetaBarrier = createBncrSessionMetaTaskBarrier();
751
- return {
752
- channel: channelId,
753
- accountId,
754
- routeSessionKey: resolvedRoute.sessionKey,
755
- storePath,
756
- ctxPayload,
757
- recordInboundSession: wrapBncrInboundRecordSessionLabelCorrection({
758
- recordInboundSession: recordBncrInboundSession as (
759
- ...args: unknown[]
760
- ) => Promise<unknown> | unknown,
761
- expectedPatch: sessionIdentityPatch,
762
- }),
763
- record: {
764
- updateLastRoute,
765
- onRecordError: (err: unknown) => {
766
- buildNativeCommandRecordErrorLogger(err);
767
- },
768
- trackSessionMetaTask: (task: Promise<unknown>) => {
769
- sessionMetaBarrier.track(task);
770
- },
771
- },
772
- runDispatch: async () => {
773
- await sessionMetaBarrier.wait();
774
- return dispatchOpenClawReplyWithBufferedBlockDispatcher(api, {
775
- ctx: ctxPayload,
776
- cfg: effectiveReply.replyCfg,
777
- dispatcherOptions: {
778
- deliver: createNativeCommandReplyDeliverer({
779
- command: command.command,
780
- accountId,
781
- sessionKey,
782
- to: displayTo,
783
- msgId: msgId || undefined,
784
- effectiveReply,
785
- route,
786
- enqueueFromReply,
787
- nativeCommandDebugEnabled,
788
- onResponded: () => responded,
789
- markResponded: () => {
790
- responded = true;
791
- },
792
- }),
778
+ if (dispatchToOpenClaw && senderIdForContext) {
779
+ addNativeElevation(senderIdForContext);
780
+ }
781
+ try {
782
+ await resolveBncrChannelInboundRuntime(api).run({
783
+ channel: channelId,
784
+ accountId,
785
+ raw: parsed,
786
+ adapter: {
787
+ ingest: () => ({
788
+ id: msgId ?? `${displayTo}:${Date.now()}`,
789
+ timestamp: Date.now(),
790
+ rawText: body,
791
+ textForAgent: ctxPayload.BodyForAgent,
792
+ textForCommands: ctxPayload.CommandBody,
793
+ raw: parsed,
794
+ }),
795
+ resolveTurn: () => {
796
+ const sessionMetaBarrier = createBncrSessionMetaTaskBarrier();
797
+ return {
798
+ channel: channelId,
799
+ accountId,
800
+ routeSessionKey: resolvedRoute.sessionKey,
801
+ storePath,
802
+ ctxPayload,
803
+ recordInboundSession: wrapBncrInboundRecordSessionLabelCorrection({
804
+ recordInboundSession: recordBncrInboundSession as (
805
+ ...args: unknown[]
806
+ ) => Promise<unknown> | unknown,
807
+ expectedPatch: sessionIdentityPatch,
808
+ }),
809
+ record: {
810
+ updateLastRoute,
811
+ onRecordError: (err: unknown) => {
812
+ buildNativeCommandRecordErrorLogger(err);
793
813
  },
794
- replyOptions: {
795
- disableBlockStreaming: !effectiveReply.blockStreaming,
796
- shouldEmitToolResult: effectiveReply.allowTool ? () => true : undefined,
814
+ trackSessionMetaTask: (task: Promise<unknown>) => {
815
+ sessionMetaBarrier.track(task);
797
816
  },
798
- });
799
- },
800
- };
817
+ },
818
+ runDispatch: async () => {
819
+ await sessionMetaBarrier.wait();
820
+ return dispatchOpenClawReplyWithBufferedBlockDispatcher(api, {
821
+ ctx: ctxPayload,
822
+ cfg: effectiveReply.replyCfg,
823
+ dispatcherOptions: {
824
+ deliver: createNativeCommandReplyDeliverer({
825
+ command: command.command,
826
+ accountId,
827
+ sessionKey,
828
+ to: displayTo,
829
+ msgId: msgId || undefined,
830
+ effectiveReply,
831
+ route,
832
+ enqueueFromReply,
833
+ nativeCommandDebugEnabled,
834
+ onResponded: () => responded,
835
+ markResponded: () => {
836
+ responded = true;
837
+ },
838
+ }),
839
+ },
840
+ replyOptions: {
841
+ disableBlockStreaming: !effectiveReply.blockStreaming,
842
+ shouldEmitToolResult: effectiveReply.allowTool ? () => true : undefined,
843
+ },
844
+ });
845
+ },
846
+ };
847
+ },
801
848
  },
802
- },
803
- });
849
+ });
850
+ } finally {
851
+ if (dispatchToOpenClaw && senderIdForContext && !parsed.isAdmin) {
852
+ removeNativeElevation(senderIdForContext);
853
+ }
854
+ }
804
855
 
805
856
  if (!responded) {
806
857
  // Self-service commands must never fall back to the agent — doing so
@@ -5,6 +5,7 @@ import {
5
5
  type HistoryEntry,
6
6
  type HistoryMediaEntry,
7
7
  } from 'openclaw/plugin-sdk/reply-history';
8
+ import { BNCR_DEFAULT_ACCOUNT_ID, normalizeAccountId } from '../../core/accounts.ts';
8
9
  import { formatOpenClawAgentEnvelope } from '../../openclaw/reply-runtime.ts';
9
10
  import type { BncrInboundApi } from './contracts.ts';
10
11
  import type { ParsedInbound } from './dispatch-prep.ts';
@@ -87,33 +88,80 @@ export function resolveBncrHistoryLimit(historyLimit?: number): number {
87
88
  return resolved >= 2 ? resolved : DEFAULT_HISTORY_LIMIT;
88
89
  }
89
90
 
90
- export function buildBncrConversationHistoryKey(parsed: ParsedInbound): string | null {
91
+ export function buildBncrConversationHistoryKey(
92
+ parsed: ParsedInbound,
93
+ options?: { accountId?: string | null },
94
+ ): string | null {
91
95
  const platform = String(parsed.platform || '').trim();
92
96
  if (!platform) return null;
97
+ const accountId = normalizeAccountId(options?.accountId ?? parsed.accountId);
93
98
  if (parsed.peer.kind === 'group') {
94
99
  const groupId = String(parsed.groupId || '').trim();
95
100
  if (!groupId || groupId === '0') return null;
96
- return `${platform}:${groupId}`;
101
+ return `${accountId}:${platform}:${groupId}`;
97
102
  }
98
103
  const userId = String(parsed.userId || '').trim();
99
104
  if (!userId || userId === '0') return null;
100
- return `${platform}:${userId}`;
105
+ return `${accountId}:${platform}:${userId}`;
101
106
  }
102
107
 
103
108
  export function buildBncrConversationHistoryKeyFromRoute(args: {
109
+ accountId?: string | null;
104
110
  platform?: string;
105
111
  groupId?: string;
106
112
  userId?: string;
107
113
  }): string | null {
108
114
  const platform = String(args.platform || '').trim();
109
115
  if (!platform) return null;
116
+ const accountId = normalizeAccountId(args.accountId);
110
117
  const groupId = String(args.groupId || '0').trim() || '0';
111
118
  const userId = String(args.userId || '0').trim() || '0';
112
- if (groupId !== '0') return `${platform}:${groupId}`;
113
- if (userId !== '0') return `${platform}:${userId}`;
119
+ if (groupId !== '0') return `${accountId}:${platform}:${groupId}`;
120
+ if (userId !== '0') return `${accountId}:${platform}:${userId}`;
114
121
  return null;
115
122
  }
116
123
 
124
+ /**
125
+ * Migrate legacy scene-only history keys (`tgBot:10001`) to the current
126
+ * account-scoped format (`Primary:tgBot:10001`). Keys that already carry an
127
+ * accountId prefix are returned unchanged.
128
+ */
129
+ export function normalizePersistedHistoryKey(key: string): string {
130
+ const raw = String(key || '').trim();
131
+ if (!raw) return raw;
132
+ const firstColon = raw.indexOf(':');
133
+ const secondColon = raw.indexOf(':', firstColon + 1);
134
+ if (firstColon <= 0 || secondColon < 0) return `${BNCR_DEFAULT_ACCOUNT_ID}:${raw}`;
135
+ return `${normalizeAccountId(raw.slice(0, firstColon))}:${raw.slice(firstColon + 1)}`;
136
+ }
137
+
138
+ export function resolvePersistedHistoryAccountId(key: string): string {
139
+ const normalized = normalizePersistedHistoryKey(key);
140
+ const separator = normalized.indexOf(':');
141
+ return normalizeAccountId(separator > 0 ? normalized.slice(0, separator) : undefined);
142
+ }
143
+
144
+ export function resolvePersistedHistoryKey(key: string, accountId: string): string {
145
+ const normalized = normalizePersistedHistoryKey(key);
146
+ const separator = normalized.indexOf(':');
147
+ const sceneKey = separator > 0 ? normalized.slice(separator + 1) : normalized;
148
+ return `${normalizeAccountId(accountId)}:${sceneKey}`;
149
+ }
150
+
151
+ /**
152
+ * Extract the scene key (platform:userId or platform:groupId) from a history
153
+ * key that may contain an accountId prefix. Legacy scene-only keys pass
154
+ * through unchanged.
155
+ */
156
+ export function resolveBncrSceneKeyFromHistoryKey(historyKey: string): string {
157
+ const key = String(historyKey || '').trim();
158
+ const firstColon = key.indexOf(':');
159
+ if (firstColon > 0 && key.indexOf(':', firstColon + 1) !== -1) {
160
+ return key.slice(firstColon + 1);
161
+ }
162
+ return key;
163
+ }
164
+
117
165
  export function resolveBncrConversationHistoryMessageId(parsed: ParsedInbound): string | undefined {
118
166
  const msgId = String(parsed.msgId || '').trim();
119
167
  return msgId || undefined;
@@ -57,9 +57,9 @@ export type NativeSessionResetCommand = {
57
57
  * the same code path as an admin caller. This whitelist is shared
58
58
  * across permission checks so admin and non-admin logic stay in sync.
59
59
  *
60
- * `stop` stays in this whitelist for semantic consistency, but it is
61
- * routed through the unified stop fast path in dispatch.ts rather than
62
- * the native command handler, so it is never processed here.
60
+ * `stop` is intentionally absent: it is handled before this set by the
61
+ * unified stop fast path in dispatch.ts, and `isBncrStopCommandText` only
62
+ * accepts the exact `/stop` form.
63
63
  */
64
64
  export const BNCR_SELF_SERVICE_COMMANDS: ReadonlySet<string> = new Set([
65
65
  'whoami',
@@ -68,7 +68,6 @@ export const BNCR_SELF_SERVICE_COMMANDS: ReadonlySet<string> = new Set([
68
68
  'model',
69
69
  'new',
70
70
  'reset',
71
- 'stop',
72
71
  ]);
73
72
 
74
73
  /**
@@ -136,13 +135,7 @@ export const BNCR_NATIVE_COMMANDS = new Set([
136
135
  'download-media',
137
136
  ]);
138
137
 
139
- export const BNCR_DIRECT_ALLOWED_BARE_COMMANDS = new Set([
140
- 'whoami',
141
- 'status',
142
- 'new',
143
- 'reset',
144
- 'stop',
145
- ]);
138
+ export const BNCR_DIRECT_ALLOWED_BARE_COMMANDS = new Set(['whoami', 'status', 'new', 'reset']);
146
139
 
147
140
  export const BNCR_DIRECT_ALLOWED_SUBCOMMANDS = new Set([
148
141
  'help',
@@ -340,6 +333,9 @@ export function resolveBncrNativeSessionResetCommand(args: {
340
333
  peerKind: 'direct' | 'group';
341
334
  }): NativeSessionResetCommand | null {
342
335
  if (args.command.command !== 'new' && args.command.command !== 'reset') return null;
336
+ // new/reset are exact session-reset commands. Extra arguments are not
337
+ // valid and must not silently reset or create a session.
338
+ if (String(args.command.argsText || '').trim()) return null;
343
339
  const reason = args.command.command === 'new' ? 'new' : 'reset';
344
340
  return {
345
341
  handled: true,
@@ -1,3 +1,4 @@
1
+ import { normalizeAccountId } from '../../core/accounts.ts';
1
2
  import type {
2
3
  BncrOutboundReplayEntry,
3
4
  BncrOutboundReplayMediaEntry,
@@ -66,7 +67,7 @@ export function buildBncrOutboundReplayKeyFromRoute(args: {
66
67
  groupId?: string;
67
68
  userId?: string;
68
69
  }): string | null {
69
- const accountId = String(args.accountId || '').trim() || 'default';
70
+ const accountId = normalizeAccountId(args.accountId);
70
71
  const platform = String(args.platform || '').trim();
71
72
  const groupId = String(args.groupId || '0').trim() || '0';
72
73
  const userId = String(args.userId || '0').trim() || '0';
@@ -167,6 +168,18 @@ export type BncrOutboundReplayRecordResult = {
167
168
  historyVersion?: number;
168
169
  };
169
170
 
171
+ /**
172
+ * Record an outbound bot reply into both the conversation-history window and
173
+ * the outbound-replay cache.
174
+ *
175
+ * Dual-write strategy: the conversation-history window is the canonical
176
+ * ordered source for structured context (conversation_context). The
177
+ * outbound-replay cache is a lighter, indexed copy used by bridge-facing
178
+ * queries such as `listRecentOutbound`. Both are kept in sync for the
179
+ * duration of the runtime session. This is a compatibility design; the
180
+ * cache may be retired once all read paths are migrated to the history
181
+ * window.
182
+ */
170
183
  export function recordBncrOutboundReplay(args: {
171
184
  cache: BncrOutboundReplayCache;
172
185
  conversationHistories?: BncrConversationHistoryMap;
@@ -200,6 +213,7 @@ export function recordBncrOutboundReplay(args: {
200
213
  if (!body) return { recorded: false, historyOverflow: false };
201
214
  const recordTimestamp = args.entry.lastPushAt ?? Date.now();
202
215
  const historyKey = buildBncrConversationHistoryKeyFromRoute({
216
+ accountId: args.entry.accountId,
203
217
  platform: args.entry.route?.platform,
204
218
  groupId: args.entry.route?.groupId,
205
219
  userId: args.entry.route?.userId,
@@ -347,7 +361,9 @@ export function readBncrOutboundReplaySnapshot(args: {
347
361
  : [];
348
362
  if (!args.conversationHistories) return legacyEntries;
349
363
 
350
- const historyKey = buildBncrConversationHistoryKey(args.parsed);
364
+ const historyKey = buildBncrConversationHistoryKey(args.parsed, {
365
+ accountId: args.accountId,
366
+ });
351
367
  const historyEntries = historyKey
352
368
  ? (args.conversationHistories.get(historyKey) || [])
353
369
  .filter((entry) => entry.role === 'assistant')
@@ -247,6 +247,14 @@ export async function runBncrInboundReplyDispatch(args: {
247
247
  sessionMetaBarrier.track(task);
248
248
  },
249
249
  },
250
+ // OpenClaw ≥8.1 requires prepared turns to carry a runDispatchLifecycle
251
+ // shim. Bncr dispatches directly via runDispatch and does not participate
252
+ // in the adoption lifecycle, so turnAdoptionLifecycle is intentionally
253
+ // left as undefined; onDispatchSkipped is a safe no-op here.
254
+ runDispatchLifecycle: {
255
+ turnAdoptionLifecycle: undefined,
256
+ onDispatchSkipped: async () => {},
257
+ },
250
258
  runDispatch: async () => {
251
259
  await sessionMetaBarrier.wait();
252
260
  await correctBncrInboundSessionLabel({
@@ -1,4 +1,5 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
2
3
  import { dirname, join } from 'node:path';
3
4
  import { fileURLToPath, pathToFileURL } from 'node:url';
4
5
 
@@ -12,7 +13,7 @@ type SessionResetParams = {
12
13
  type SessionResetFn = (params: SessionResetParams) => Promise<SessionResetResult>;
13
14
 
14
15
  let testOverride: SessionResetFn | null = null;
15
- let resolvedSessionRuntimeUrlCache: string | null = null;
16
+ let resolvedSessionResetRuntimeCache: SessionResetFn | null = null;
16
17
 
17
18
  export function setBncrSessionResetRuntimeForTest(fn: SessionResetFn | null): () => void {
18
19
  const previous = testOverride;
@@ -22,56 +23,103 @@ export function setBncrSessionResetRuntimeForTest(fn: SessionResetFn | null): ()
22
23
  };
23
24
  }
24
25
 
25
- function resolveOpenClawSessionsRuntimeUrl(): string {
26
- if (resolvedSessionRuntimeUrlCache) return resolvedSessionRuntimeUrlCache;
27
- let current = dirname(fileURLToPath(import.meta.url));
28
- let pluginRoot: string | null = null;
26
+ function findPackageRoot(startPath: string, packageName: string): string | null {
27
+ let current = dirname(startPath);
29
28
  while (true) {
30
29
  const pkgPath = join(current, 'package.json');
31
30
  try {
32
31
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { name?: string };
33
- if (pkg.name === '@xmoxmo/bncr') {
34
- pluginRoot = current;
35
- break;
36
- }
32
+ if (pkg.name === packageName) return current;
37
33
  } catch {
38
- // Keep walking up toward the plugin root.
34
+ // Keep walking toward the package root.
39
35
  }
40
36
  const parent = dirname(current);
41
- if (parent === current) break;
37
+ if (parent === current) return null;
42
38
  current = parent;
43
39
  }
44
- if (!pluginRoot) {
45
- throw new Error('bncr plugin root not found for session reset runtime');
40
+ }
41
+
42
+ function resolveOpenClawDistDir(): string {
43
+ let current = dirname(fileURLToPath(import.meta.url));
44
+ let resolvedOpenClawEntry: string | null = null;
45
+ try {
46
+ resolvedOpenClawEntry = createRequire(import.meta.url).resolve('openclaw');
47
+ } catch {
48
+ // Fall back to the plugin-local dependency path below.
46
49
  }
47
50
 
48
- // Try candidate paths for the sessions runtime re-export file.
49
- // The hash-stamped bundle name may change across OpenClaw releases, so we
50
- // first try the stable re-export entry and fall back to the known hash.
51
- const candidates = [
52
- join(pluginRoot, 'node_modules', 'openclaw', 'dist', 'sessions.runtime.js'),
53
- join(pluginRoot, 'node_modules', 'openclaw', 'dist', 'sessions.runtime-BQwwkuFq.js'),
54
- ];
55
- for (const target of candidates) {
56
- if (existsSync(target)) {
57
- resolvedSessionRuntimeUrlCache = pathToFileURL(target).href;
58
- return resolvedSessionRuntimeUrlCache;
51
+ const openClawRoot = resolvedOpenClawEntry
52
+ ? findPackageRoot(resolvedOpenClawEntry, 'openclaw')
53
+ : null;
54
+ if (openClawRoot) {
55
+ const distDir = join(openClawRoot, 'dist');
56
+ if (existsSync(distDir)) return distDir;
57
+ }
58
+
59
+ while (true) {
60
+ const pluginRoot = findPackageRoot(current, '@xmoxmo/bncr');
61
+ if (pluginRoot) {
62
+ const distDir = join(pluginRoot, 'node_modules', 'openclaw', 'dist');
63
+ if (existsSync(distDir)) return distDir;
59
64
  }
65
+ const parent = dirname(current);
66
+ if (parent === current) break;
67
+ current = parent;
60
68
  }
69
+
70
+ const resolvedPath = resolvedOpenClawEntry || '(unresolved)';
71
+ const pluginPath = join(
72
+ dirname(fileURLToPath(import.meta.url)),
73
+ 'node_modules',
74
+ 'openclaw',
75
+ 'dist',
76
+ );
61
77
  throw new Error(
62
- `OpenClaw sessions runtime not found under ${join(pluginRoot, 'node_modules', 'openclaw', 'dist')}`,
78
+ `OpenClaw dist directory not found (resolved entry: ${resolvedPath}; plugin fallback: ${pluginPath})`,
63
79
  );
64
80
  }
65
81
 
82
+ function resolveOpenClawSessionsRuntimeUrls(): string[] {
83
+ const distDir = resolveOpenClawDistDir();
84
+ if (!existsSync(distDir)) {
85
+ throw new Error(`OpenClaw dist directory not found: ${distDir}`);
86
+ }
87
+
88
+ // Scan dist for sessions.runtime*.js, preferring the stable re-export.
89
+ const candidates = readdirSync(distDir)
90
+ .filter((name) => /^sessions\.runtime(?:-[A-Za-z0-9_-]+)?\.js$/.test(name))
91
+ .sort(
92
+ (a, b) =>
93
+ Number(b === 'sessions.runtime.js') - Number(a === 'sessions.runtime.js') ||
94
+ a.localeCompare(b),
95
+ );
96
+ return candidates.map((name) => pathToFileURL(join(distDir, name)).href);
97
+ }
98
+
66
99
  export async function performBncrGatewaySessionReset(
67
100
  params: SessionResetParams,
68
101
  ): Promise<SessionResetResult> {
69
102
  if (testOverride) return testOverride(params);
70
- const mod = (await import(resolveOpenClawSessionsRuntimeUrl())) as {
71
- performGatewaySessionReset?: SessionResetFn;
72
- };
73
- if (typeof mod.performGatewaySessionReset !== 'function') {
74
- throw new Error('OpenClaw performGatewaySessionReset is unavailable');
103
+ if (!resolvedSessionResetRuntimeCache) {
104
+ const candidates = resolveOpenClawSessionsRuntimeUrls();
105
+ const importErrors: string[] = [];
106
+ for (const url of candidates) {
107
+ try {
108
+ const mod = (await import(url)) as { performGatewaySessionReset?: SessionResetFn };
109
+ if (typeof mod.performGatewaySessionReset === 'function') {
110
+ resolvedSessionResetRuntimeCache = mod.performGatewaySessionReset;
111
+ break;
112
+ }
113
+ importErrors.push(`${url}: export unavailable`);
114
+ } catch (error) {
115
+ importErrors.push(`${url}: ${error instanceof Error ? error.message : String(error)}`);
116
+ }
117
+ }
118
+ if (!resolvedSessionResetRuntimeCache) {
119
+ throw new Error(
120
+ `OpenClaw performGatewaySessionReset is unavailable; candidates: ${importErrors.join('; ') || '(none)'}`,
121
+ );
122
+ }
75
123
  }
76
- return mod.performGatewaySessionReset(params);
124
+ return resolvedSessionResetRuntimeCache(params);
77
125
  }
@@ -86,7 +86,7 @@ export type BncrPersistedConversationHistoryEntry = {
86
86
  export type BncrPersistedConversationHistoryMediaEntry = {
87
87
  path?: string;
88
88
  contentType?: string;
89
- kind?: 'image' | 'video' | 'audio' | 'document' | 'unknown';
89
+ kind?: 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'unknown';
90
90
  messageId?: string;
91
91
  };
92
92
 
@@ -7,6 +7,17 @@ import {
7
7
  import { resolveBncrChannelPolicy } from '../core/policy.ts';
8
8
  import { setOpenClawAccountEnabledInConfigSection } from '../openclaw/sdk-helpers.ts';
9
9
 
10
+ // 临时提权集合:非管理员用户执行原生命令时动态注入,命令完成后移除
11
+ const pendingNativeElevation = new Set<string>();
12
+
13
+ export function addNativeElevation(senderId: string): void {
14
+ if (senderId) pendingNativeElevation.add(senderId);
15
+ }
16
+
17
+ export function removeNativeElevation(senderId: string): void {
18
+ if (senderId) pendingNativeElevation.delete(senderId);
19
+ }
20
+
10
21
  type BncrConfigSurfaceRoot = {
11
22
  channels?: {
12
23
  [CHANNEL_ID]?: {
@@ -53,4 +64,11 @@ export const BNCR_CONFIG_SURFACE = {
53
64
  configured: true,
54
65
  };
55
66
  },
67
+ resolveAllowFrom: ({ cfg }: { cfg: BncrConfigSurfaceRoot; accountId?: string | null }) => {
68
+ const policy = resolveBncrChannelPolicy(cfg?.channels?.[CHANNEL_ID] || {});
69
+ const base = policy.allowFrom;
70
+ if (pendingNativeElevation.size === 0) return base.length ? base : undefined;
71
+ const merged = [...base, ...pendingNativeElevation];
72
+ return merged.length ? merged : undefined;
73
+ },
56
74
  };
@@ -1,4 +1,5 @@
1
1
  import type { GatewayRequestHandlerOptions } from 'openclaw/plugin-sdk/core';
2
+ import { runDetachedWebhookWork } from 'openclaw/plugin-sdk/webhook-request-guards';
2
3
  import { buildCanonicalBncrSessionKey } from '../core/targets.ts';
3
4
  import type { BncrConnection, BncrRoute } from '../core/types.ts';
4
5
  import type { BncrConversationHistoryMap } from '../messaging/inbound/conversation-history.ts';
@@ -171,6 +172,7 @@ export type BncrInboundHandlersRuntime = {
171
172
  defaultPublicAgentId: string;
172
173
  now: () => number;
173
174
  }) => Promise<unknown>;
175
+ runInboundDetached?: (run: () => Promise<unknown>) => Promise<unknown>;
174
176
  };
175
177
 
176
178
  export function createBncrInboundHandlers(runtime: Omit<BncrInboundHandlersRuntime, 'respond'>) {
@@ -326,8 +328,12 @@ export function createBncrInboundHandlers(runtime: Omit<BncrInboundHandlersRunti
326
328
  );
327
329
  runtime.flushOnInboundAccepted(accountId);
328
330
 
329
- void runtime
330
- .dispatchInbound({
331
+ const runInboundDetached = runtime.runInboundDetached ?? runDetachedWebhookWork;
332
+ // Ack-first handlers must run follow-up work on an independent admitted
333
+ // root. Otherwise the host releases this request admission after respond,
334
+ // and later native/agent queue submissions are mistaken for draining.
335
+ void runInboundDetached(async () => {
336
+ await runtime.dispatchInbound({
331
337
  cfg,
332
338
  parsed,
333
339
  canonicalAgentId,
@@ -340,10 +346,10 @@ export function createBncrInboundHandlers(runtime: Omit<BncrInboundHandlersRunti
340
346
  defaultAdminAgentId,
341
347
  defaultPublicAgentId: runtime.defaultPublicAgentId(),
342
348
  now: runtime.now,
343
- })
344
- .catch((err) => {
345
- runtime.logError('inbound', `process failed: ${String(err)}`, { debugOnly: true });
346
349
  });
350
+ }).catch((err) => {
351
+ runtime.logError('inbound', `process failed: ${String(err)}`, { debugOnly: true });
352
+ });
347
353
  },
348
354
  };
349
355
  }