@clawling/clawchat-plugin-openclaw 2026.9.9-1 → 2026.9.11-1

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.
@@ -378,6 +378,28 @@ export async function startTunnelAgent(opts) {
378
378
  await waitForOutput(child, (acc) => parseAgentReady(acc), opts.timeoutMs ?? TUNNEL_START_TIMEOUT_MS, "liveware agent start");
379
379
  return { child };
380
380
  }
381
+ /** Where the liveware CLI keeps its login state, relative to its `HOME`. */
382
+ const LIVEWARE_CREDENTIAL_DIRNAME = ".clawling";
383
+ /**
384
+ * True when the liveware CLI has usable cached credentials under `cliHome`.
385
+ *
386
+ * Tolerates either shape the CLI might use: a credential file (non-empty) or a
387
+ * directory (non-empty). Anything unreadable answers "no" — the caller's only
388
+ * use is deciding whether it is safe to skip a login, and guessing "yes" there
389
+ * is exactly the failure this check exists to prevent.
390
+ */
391
+ export async function hasCachedLivewareCredentials(cliHome) {
392
+ const target = path.join(cliHome, LIVEWARE_CREDENTIAL_DIRNAME);
393
+ try {
394
+ const stat = await fs.promises.stat(target);
395
+ if (stat.isDirectory())
396
+ return (await fs.promises.readdir(target)).length > 0;
397
+ return stat.size > 0;
398
+ }
399
+ catch {
400
+ return false;
401
+ }
402
+ }
381
403
  export async function livewareLogin(opts) {
382
404
  const execFileFn = opts.execFileFn ?? nodeExecFile;
383
405
  await new Promise((resolve, reject) => {
@@ -833,12 +855,14 @@ export class LivewareSampleSupervisor {
833
855
  // lifetime. `login` is idempotent; best-effort, so a stale token still falls
834
856
  // through to whatever credentials the CLI already has.
835
857
  const token = deps.resolveToken();
858
+ let loggedIn = false;
836
859
  if (token) {
837
860
  try {
838
861
  await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn, env: cliEnv });
862
+ loggedIn = true;
839
863
  }
840
864
  catch (err) {
841
- deps.log?.warn?.(`liveware-sample: relaunch re-login failed; continuing with cached CLI credentials: ${String(err)}`);
865
+ deps.log?.warn?.(`liveware-sample: relaunch re-login failed; continuing only if the CLI has cached credentials: ${String(err)}`);
842
866
  }
843
867
  if (this.bailIfStopped())
844
868
  return;
@@ -846,6 +870,20 @@ export class LivewareSampleSupervisor {
846
870
  else {
847
871
  deps.log?.debug?.("liveware-sample: no token; relaunch without re-login");
848
872
  }
873
+ // Falling through without a fresh login is only safe when the CLI actually
874
+ // has credentials cached. Since the private-HOME redirect those live in
875
+ // `<cliHome>/.clawling`, and a first relaunch after upgrading finds that
876
+ // home EMPTY — the old ones are still under the ambient `$HOME`. Continuing
877
+ // there makes every tunnelBind/agent call below fail auth, which surfaces as
878
+ // a confusing tunnel error rather than the credential problem it is, and
879
+ // burns the whole retry ladder on a state that cannot recover on its own.
880
+ // Throwing hands it to `startAttempt`'s ladder instead, so the worst case is
881
+ // a delayed sample rather than a silent auth failure.
882
+ if (!loggedIn && cliEnv && deps.cliHome) {
883
+ if (!(await hasCachedLivewareCredentials(deps.cliHome))) {
884
+ throw new Error(`liveware CLI has no cached credentials under ${deps.cliHome} and no token was available to log in`);
885
+ }
886
+ }
849
887
  // Kept after the re-login above so the lookup runs against a CLI that is
850
888
  // actually authenticated: a pending row's app id came straight from
851
889
  // `app create`'s parser and was never confirmed by the liveware side, so
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { resolveStateDirFromEnv } from "./state-paths.js";
3
4
  function envEnabled(value) {
4
5
  return ["1", "true", "yes", "on"].includes(String(value ?? "").trim().toLowerCase());
5
6
  }
@@ -10,7 +11,12 @@ function safeId(value) {
10
11
  export function createOpenclawLlmContextDebug(params = {}) {
11
12
  const env = params.env ?? process.env;
12
13
  const enabled = envEnabled(env.CLAWCHAT_LLM_CONTEXT_DEBUG);
13
- const snapshotRoot = path.resolve(env.CLAWCHAT_LLM_CONTEXT_SNAPSHOT_DIR || params.defaultSnapshotRoot || ".clawchat-llm-context-debug");
14
+ // Default under the per-instance OpenClaw state dir, never the process cwd:
15
+ // two profiles launched from one directory must not interleave runs and
16
+ // overwrite each other's `latest.json`.
17
+ const snapshotRoot = path.resolve(env.CLAWCHAT_LLM_CONTEXT_SNAPSHOT_DIR ||
18
+ params.defaultSnapshotRoot ||
19
+ path.join(resolveStateDirFromEnv(env), "clawchat", "llm-context-debug"));
14
20
  return {
15
21
  enabled,
16
22
  captureFullInput: envEnabled(env.CLAWCHAT_LLM_CONTEXT_CAPTURE_FULL_INPUT),
@@ -20,16 +20,42 @@
20
20
  * The discriminator is `payload.metadata.kind === "permission_result"`.
21
21
  */
22
22
  import { EVENT } from "./protocol-types.js";
23
- // Module-level dedup set — `request_id` values are stable backend identifiers.
24
- // A duplicate receipt (e.g. from WS replay on reconnect) must not re-trigger
25
- // an agent turn.
26
- const _seenRequestIds = new Set();
27
23
  /**
28
- * @internal Test helper — reset the dedup set between isolated test runs.
24
+ * Upper bound on retained `request_id`s per account (FIFO eviction). Mirrors
25
+ * `createNotifySignalObserver` in `src/ws-alignment.ts`: permission receipts
26
+ * are rare (one per owner decision), so 512 covers any realistic replay window
27
+ * while keeping a long-running gateway's memory flat.
28
+ */
29
+ export const PERMISSION_RESULT_DEDUP_MAX_SEEN = 512;
30
+ // Dedup state partitioned by accountId — `request_id` values are stable backend
31
+ // identifiers scoped to the agent identity that raised the request, so one
32
+ // account's history must never suppress another account's receipt with the
33
+ // same id. A duplicate receipt within one account (e.g. from WS replay on
34
+ // reconnect) must not re-trigger an agent turn.
35
+ const _seenRequestIdsByAccount = new Map();
36
+ function seenRequestIdsFor(accountId) {
37
+ let entry = _seenRequestIdsByAccount.get(accountId);
38
+ if (!entry) {
39
+ entry = { seen: new Set(), order: [] };
40
+ _seenRequestIdsByAccount.set(accountId, entry);
41
+ }
42
+ return entry;
43
+ }
44
+ function markRequestIdSeen(entry, requestId) {
45
+ entry.seen.add(requestId);
46
+ entry.order.push(requestId);
47
+ while (entry.order.length > PERMISSION_RESULT_DEDUP_MAX_SEEN) {
48
+ const evicted = entry.order.shift();
49
+ if (evicted !== undefined)
50
+ entry.seen.delete(evicted);
51
+ }
52
+ }
53
+ /**
54
+ * @internal Test helper — reset the dedup state between isolated test runs.
29
55
  * Never call this in production code.
30
56
  */
31
57
  export function _resetPermissionResultDedup() {
32
- _seenRequestIds.clear();
58
+ _seenRequestIdsByAccount.clear();
33
59
  }
34
60
  function buildOutcomeNote(metadata) {
35
61
  const { operation, outcome, reason } = metadata;
@@ -107,8 +133,10 @@ export function buildPermissionResultEnvelope(params) {
107
133
  };
108
134
  }
109
135
  /**
110
- * Handles a `permission_result` receipt. Deduplicates by `request_id`
111
- * (idempotent — WS replay on reconnect may deliver the same receipt again).
136
+ * Handles a `permission_result` receipt. Deduplicates by `request_id` within
137
+ * the receiving account (idempotent — WS replay on reconnect may deliver the
138
+ * same receipt again), retaining at most {@link PERMISSION_RESULT_DEDUP_MAX_SEEN}
139
+ * ids per account (oldest evicted first).
112
140
  *
113
141
  * An empty/falsy `request_id` is never added to the dedup set — two receipts
114
142
  * can legitimately share an empty id, and both must still be rendered — but
@@ -127,9 +155,11 @@ export function buildPermissionResultEnvelope(params) {
127
155
  export function handlePermissionResult(metadata, account, ownerConversationId) {
128
156
  if (!ownerConversationId)
129
157
  return null;
130
- if (metadata.request_id && _seenRequestIds.has(metadata.request_id))
131
- return null;
132
- if (metadata.request_id)
133
- _seenRequestIds.add(metadata.request_id);
158
+ if (metadata.request_id) {
159
+ const entry = seenRequestIdsFor(account.accountId);
160
+ if (entry.seen.has(metadata.request_id))
161
+ return null;
162
+ markRequestIdSeen(entry, metadata.request_id);
163
+ }
134
164
  return buildPermissionResultEnvelope({ account, metadata, ownerConversationId });
135
165
  }
@@ -12,7 +12,7 @@ import { LivewareSampleSupervisor, } from "./liveware-sample.js";
12
12
  import { ClawlingApiError } from "./api-types.js";
13
13
  import { RefreshManager } from "./refresh-manager.js";
14
14
  import { runOpenclawClawlingLogin, } from "./login.runtime.js";
15
- import { CHANNEL_ID, effectiveOutputVisibility, effectiveGroupCommandMode, effectiveGroupMode, hasOpenclawClawlingConnectCredentials, resolveOpenclawClawlingAccount, } from "./config.js";
15
+ import { CHANNEL_ID, effectiveOutputVisibility, effectiveGroupCommandMode, effectiveGroupMode, hasOpenclawClawlingConnectCredentials, resolveOpenclawClawlingAccount, CLAWCHAT_REFRESH_TOKEN_ENV, } from "./config.js";
16
16
  import { isValidChatId } from "./ws-client.js";
17
17
  import { dispatchOpenclawClawlingInbound } from "./inbound.js";
18
18
  import { PendingConsentStore, cleanupTombstonedManagedSkills, handleOwnerConsentReply, managedSkillExists, readLocalSkillState, resolveBundledSkillsDir, resolveManagedSkillsDir, runSkillUpdateCheck, } from "./skill-update.js";
@@ -131,11 +131,25 @@ export function classifyHelloFailReason(reason) {
131
131
  return "generic";
132
132
  }
133
133
  /** Read `channels.<CHANNEL_ID>.refreshToken` from a live config, or null. */
134
- function readConfigRefreshToken(cfg) {
134
+ /**
135
+ * The refresh token paired with the configured access token. Channel config
136
+ * `refreshToken` wins. `CLAWCHAT_REFRESH_TOKEN` (declared in the manifest's
137
+ * `channelEnvVars`) is honoured ONLY when the channel config carries no `token`
138
+ * of its own — i.e. when the access token itself came from `CLAWCHAT_TOKEN` —
139
+ * so an env-provisioned pair can rotate, while a login-written config token is
140
+ * never paired with a stray refresh token left in the environment.
141
+ */
142
+ function readConfigRefreshToken(cfg, env = process.env) {
135
143
  const channels = cfg.channels;
136
144
  const channel = isRecord(channels) ? channels[CHANNEL_ID] : undefined;
137
145
  const refreshToken = isRecord(channel) ? channel.refreshToken : undefined;
138
- return typeof refreshToken === "string" && refreshToken.trim() ? refreshToken.trim() : null;
146
+ if (typeof refreshToken === "string" && refreshToken.trim())
147
+ return refreshToken.trim();
148
+ const configToken = isRecord(channel) ? channel.token : undefined;
149
+ if (typeof configToken === "string" && configToken.trim())
150
+ return null;
151
+ const envRefreshToken = env[CLAWCHAT_REFRESH_TOKEN_ENV];
152
+ return typeof envRefreshToken === "string" && envRefreshToken.trim() ? envRefreshToken.trim() : null;
139
153
  }
140
154
  function withFullVerboseDispatchConfig(cfg, agentId) {
141
155
  const cfgRecord = cfg;
@@ -445,12 +459,17 @@ export async function maybeAutoActivateFromEnv(params) {
445
459
  return params.account;
446
460
  if (accountHasConnectCredentials(params.account))
447
461
  return params.account;
462
+ const accountId = params.account.accountId;
463
+ const stored = params.store?.getActivationCredentials?.({ platform: "openclaw", accountId });
464
+ if (stored) {
465
+ params.log?.info?.(`[${accountId}] clawchat-plugin-openclaw ${CONNECT_CODE_ENV} present but a sqlite activation row already holds credentials; not redeeming`);
466
+ return params.account;
467
+ }
448
468
  if (consumedAutoActivationCodes.has(code))
449
469
  return params.account;
450
470
  consumedAutoActivationCodes.add(code);
451
471
  const runLogin = params.runLogin ?? runOpenclawClawlingLogin;
452
472
  const resolveAccount = params.resolveAccount ?? resolveOpenclawClawlingAccount;
453
- const accountId = params.account.accountId;
454
473
  params.log?.info?.(`[${accountId}] clawchat-plugin-openclaw ${CONNECT_CODE_ENV} present; attempting non-interactive activation`);
455
474
  try {
456
475
  await runLogin({
@@ -722,6 +741,7 @@ export async function startOpenclawClawlingGateway(params) {
722
741
  account,
723
742
  accountId,
724
743
  connectCode: envConnectCode,
744
+ store,
725
745
  log,
726
746
  });
727
747
  }
@@ -8,13 +8,28 @@ function key(accountId, chatId, scopeId) {
8
8
  export function runWithTerminalClawChatSendScope(scopeId, fn) {
9
9
  return terminalSendScope.run(scopeId, fn);
10
10
  }
11
+ /**
12
+ * Drop every marker whose TTL has lapsed. A marker is only otherwise deleted
13
+ * when the same dispatch scope consumes it, and a scope that never produces a
14
+ * follow-up reply for that chat (mention sent to another chat, `<clawchat:no-reply/>`,
15
+ * dispatch failure) leaves it behind; without this sweep the map would grow by
16
+ * one entry per such turn for the lifetime of the gateway.
17
+ */
18
+ function sweepExpiredTerminalSends(nowMs) {
19
+ for (const [recordKey, record] of terminalSends) {
20
+ if (record.expiresAt <= nowMs)
21
+ terminalSends.delete(recordKey);
22
+ }
23
+ }
11
24
  export function markTerminalClawChatSend(params) {
12
25
  const scopeId = params.scopeId ?? terminalSendScope.getStore();
13
26
  if (!scopeId)
14
27
  return;
28
+ const nowMs = params.nowMs ?? Date.now();
29
+ sweepExpiredTerminalSends(nowMs);
15
30
  terminalSends.set(key(params.accountId, params.chatId, scopeId), {
16
31
  messageId: params.messageId,
17
- expiresAt: (params.nowMs ?? Date.now()) + (params.ttlMs ?? DEFAULT_TTL_MS),
32
+ expiresAt: nowMs + (params.ttlMs ?? DEFAULT_TTL_MS),
18
33
  scopeId,
19
34
  });
20
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.9.9-1",
3
+ "version": "2026.9.11-1",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "author": "CLAWLING PTE. LTD.",
@@ -467,6 +467,28 @@ export async function startTunnelAgent(opts: {
467
467
  return { child };
468
468
  }
469
469
 
470
+ /** Where the liveware CLI keeps its login state, relative to its `HOME`. */
471
+ const LIVEWARE_CREDENTIAL_DIRNAME = ".clawling";
472
+
473
+ /**
474
+ * True when the liveware CLI has usable cached credentials under `cliHome`.
475
+ *
476
+ * Tolerates either shape the CLI might use: a credential file (non-empty) or a
477
+ * directory (non-empty). Anything unreadable answers "no" — the caller's only
478
+ * use is deciding whether it is safe to skip a login, and guessing "yes" there
479
+ * is exactly the failure this check exists to prevent.
480
+ */
481
+ export async function hasCachedLivewareCredentials(cliHome: string): Promise<boolean> {
482
+ const target = path.join(cliHome, LIVEWARE_CREDENTIAL_DIRNAME);
483
+ try {
484
+ const stat = await fs.promises.stat(target);
485
+ if (stat.isDirectory()) return (await fs.promises.readdir(target)).length > 0;
486
+ return stat.size > 0;
487
+ } catch {
488
+ return false;
489
+ }
490
+ }
491
+
470
492
  export async function livewareLogin(opts: {
471
493
  livewarePath: string;
472
494
  token: string;
@@ -980,18 +1002,36 @@ export class LivewareSampleSupervisor {
980
1002
  // lifetime. `login` is idempotent; best-effort, so a stale token still falls
981
1003
  // through to whatever credentials the CLI already has.
982
1004
  const token = deps.resolveToken();
1005
+ let loggedIn = false;
983
1006
  if (token) {
984
1007
  try {
985
1008
  await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn, env: cliEnv });
1009
+ loggedIn = true;
986
1010
  } catch (err) {
987
1011
  deps.log?.warn?.(
988
- `liveware-sample: relaunch re-login failed; continuing with cached CLI credentials: ${String(err)}`,
1012
+ `liveware-sample: relaunch re-login failed; continuing only if the CLI has cached credentials: ${String(err)}`,
989
1013
  );
990
1014
  }
991
1015
  if (this.bailIfStopped()) return;
992
1016
  } else {
993
1017
  deps.log?.debug?.("liveware-sample: no token; relaunch without re-login");
994
1018
  }
1019
+ // Falling through without a fresh login is only safe when the CLI actually
1020
+ // has credentials cached. Since the private-HOME redirect those live in
1021
+ // `<cliHome>/.clawling`, and a first relaunch after upgrading finds that
1022
+ // home EMPTY — the old ones are still under the ambient `$HOME`. Continuing
1023
+ // there makes every tunnelBind/agent call below fail auth, which surfaces as
1024
+ // a confusing tunnel error rather than the credential problem it is, and
1025
+ // burns the whole retry ladder on a state that cannot recover on its own.
1026
+ // Throwing hands it to `startAttempt`'s ladder instead, so the worst case is
1027
+ // a delayed sample rather than a silent auth failure.
1028
+ if (!loggedIn && cliEnv && deps.cliHome) {
1029
+ if (!(await hasCachedLivewareCredentials(deps.cliHome))) {
1030
+ throw new Error(
1031
+ `liveware CLI has no cached credentials under ${deps.cliHome} and no token was available to log in`,
1032
+ );
1033
+ }
1034
+ }
995
1035
  // Kept after the re-login above so the lookup runs against a CLI that is
996
1036
  // actually authenticated: a pending row's app id came straight from
997
1037
  // `app create`'s parser and was never confirmed by the liveware side, so
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { resolveStateDirFromEnv } from "./state-paths.ts";
3
4
 
4
5
  export type OpenclawLlmContextVisibility =
5
6
  | "full_llm_input"
@@ -60,8 +61,13 @@ export function createOpenclawLlmContextDebug(params: {
60
61
  } = {}): OpenclawLlmContextDebug {
61
62
  const env = params.env ?? process.env;
62
63
  const enabled = envEnabled(env.CLAWCHAT_LLM_CONTEXT_DEBUG);
64
+ // Default under the per-instance OpenClaw state dir, never the process cwd:
65
+ // two profiles launched from one directory must not interleave runs and
66
+ // overwrite each other's `latest.json`.
63
67
  const snapshotRoot = path.resolve(
64
- env.CLAWCHAT_LLM_CONTEXT_SNAPSHOT_DIR || params.defaultSnapshotRoot || ".clawchat-llm-context-debug",
68
+ env.CLAWCHAT_LLM_CONTEXT_SNAPSHOT_DIR ||
69
+ params.defaultSnapshotRoot ||
70
+ path.join(resolveStateDirFromEnv(env as NodeJS.ProcessEnv), "clawchat", "llm-context-debug"),
65
71
  );
66
72
 
67
73
  return {
@@ -30,17 +30,47 @@ export interface PermissionResultMetadata {
30
30
  request_id: string;
31
31
  }
32
32
 
33
- // Module-level dedup set — `request_id` values are stable backend identifiers.
34
- // A duplicate receipt (e.g. from WS replay on reconnect) must not re-trigger
35
- // an agent turn.
36
- const _seenRequestIds = new Set<string>();
33
+ /**
34
+ * Upper bound on retained `request_id`s per account (FIFO eviction). Mirrors
35
+ * `createNotifySignalObserver` in `src/ws-alignment.ts`: permission receipts
36
+ * are rare (one per owner decision), so 512 covers any realistic replay window
37
+ * while keeping a long-running gateway's memory flat.
38
+ */
39
+ export const PERMISSION_RESULT_DEDUP_MAX_SEEN = 512;
40
+
41
+ type SeenRequestIds = { seen: Set<string>; order: string[] };
42
+
43
+ // Dedup state partitioned by accountId — `request_id` values are stable backend
44
+ // identifiers scoped to the agent identity that raised the request, so one
45
+ // account's history must never suppress another account's receipt with the
46
+ // same id. A duplicate receipt within one account (e.g. from WS replay on
47
+ // reconnect) must not re-trigger an agent turn.
48
+ const _seenRequestIdsByAccount = new Map<string, SeenRequestIds>();
49
+
50
+ function seenRequestIdsFor(accountId: string): SeenRequestIds {
51
+ let entry = _seenRequestIdsByAccount.get(accountId);
52
+ if (!entry) {
53
+ entry = { seen: new Set<string>(), order: [] };
54
+ _seenRequestIdsByAccount.set(accountId, entry);
55
+ }
56
+ return entry;
57
+ }
58
+
59
+ function markRequestIdSeen(entry: SeenRequestIds, requestId: string): void {
60
+ entry.seen.add(requestId);
61
+ entry.order.push(requestId);
62
+ while (entry.order.length > PERMISSION_RESULT_DEDUP_MAX_SEEN) {
63
+ const evicted = entry.order.shift();
64
+ if (evicted !== undefined) entry.seen.delete(evicted);
65
+ }
66
+ }
37
67
 
38
68
  /**
39
- * @internal Test helper — reset the dedup set between isolated test runs.
69
+ * @internal Test helper — reset the dedup state between isolated test runs.
40
70
  * Never call this in production code.
41
71
  */
42
72
  export function _resetPermissionResultDedup(): void {
43
- _seenRequestIds.clear();
73
+ _seenRequestIdsByAccount.clear();
44
74
  }
45
75
 
46
76
  function buildOutcomeNote(metadata: PermissionResultMetadata): string {
@@ -127,8 +157,10 @@ export function buildPermissionResultEnvelope(params: {
127
157
  }
128
158
 
129
159
  /**
130
- * Handles a `permission_result` receipt. Deduplicates by `request_id`
131
- * (idempotent — WS replay on reconnect may deliver the same receipt again).
160
+ * Handles a `permission_result` receipt. Deduplicates by `request_id` within
161
+ * the receiving account (idempotent — WS replay on reconnect may deliver the
162
+ * same receipt again), retaining at most {@link PERMISSION_RESULT_DEDUP_MAX_SEEN}
163
+ * ids per account (oldest evicted first).
132
164
  *
133
165
  * An empty/falsy `request_id` is never added to the dedup set — two receipts
134
166
  * can legitimately share an empty id, and both must still be rendered — but
@@ -146,11 +178,14 @@ export function buildPermissionResultEnvelope(params: {
146
178
  */
147
179
  export function handlePermissionResult(
148
180
  metadata: PermissionResultMetadata,
149
- account: Pick<ResolvedOpenclawClawlingAccount, "userId" | "ownerUserId">,
181
+ account: Pick<ResolvedOpenclawClawlingAccount, "accountId" | "userId" | "ownerUserId">,
150
182
  ownerConversationId: string | null,
151
183
  ): Envelope | null {
152
184
  if (!ownerConversationId) return null;
153
- if (metadata.request_id && _seenRequestIds.has(metadata.request_id)) return null;
154
- if (metadata.request_id) _seenRequestIds.add(metadata.request_id);
185
+ if (metadata.request_id) {
186
+ const entry = seenRequestIdsFor(account.accountId);
187
+ if (entry.seen.has(metadata.request_id)) return null;
188
+ markRequestIdSeen(entry, metadata.request_id);
189
+ }
155
190
  return buildPermissionResultEnvelope({ account, metadata, ownerConversationId });
156
191
  }
package/src/runtime.ts CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  hasOpenclawClawlingConnectCredentials,
41
41
  resolveOpenclawClawlingAccount,
42
42
  type ResolvedOpenclawClawlingAccount,
43
+ CLAWCHAT_REFRESH_TOKEN_ENV,
43
44
  } from "./config.ts";
44
45
  import { isValidChatId, type ClawlingChatClient } from "./ws-client.ts";
45
46
  import { dispatchOpenclawClawlingInbound, type IngestTurnParams } from "./inbound.ts";
@@ -234,11 +235,23 @@ export function classifyHelloFailReason(
234
235
  }
235
236
 
236
237
  /** Read `channels.<CHANNEL_ID>.refreshToken` from a live config, or null. */
237
- function readConfigRefreshToken(cfg: OpenClawConfig): string | null {
238
+ /**
239
+ * The refresh token paired with the configured access token. Channel config
240
+ * `refreshToken` wins. `CLAWCHAT_REFRESH_TOKEN` (declared in the manifest's
241
+ * `channelEnvVars`) is honoured ONLY when the channel config carries no `token`
242
+ * of its own — i.e. when the access token itself came from `CLAWCHAT_TOKEN` —
243
+ * so an env-provisioned pair can rotate, while a login-written config token is
244
+ * never paired with a stray refresh token left in the environment.
245
+ */
246
+ function readConfigRefreshToken(cfg: OpenClawConfig, env: NodeJS.ProcessEnv = process.env): string | null {
238
247
  const channels = (cfg as { channels?: Record<string, unknown> }).channels;
239
248
  const channel = isRecord(channels) ? channels[CHANNEL_ID] : undefined;
240
249
  const refreshToken = isRecord(channel) ? channel.refreshToken : undefined;
241
- return typeof refreshToken === "string" && refreshToken.trim() ? refreshToken.trim() : null;
250
+ if (typeof refreshToken === "string" && refreshToken.trim()) return refreshToken.trim();
251
+ const configToken = isRecord(channel) ? channel.token : undefined;
252
+ if (typeof configToken === "string" && configToken.trim()) return null;
253
+ const envRefreshToken = env[CLAWCHAT_REFRESH_TOKEN_ENV];
254
+ return typeof envRefreshToken === "string" && envRefreshToken.trim() ? envRefreshToken.trim() : null;
242
255
  }
243
256
 
244
257
  function withFullVerboseDispatchConfig(cfg: OpenClawConfig, agentId: string): OpenClawConfig {
@@ -673,6 +686,13 @@ export interface AutoActivateFromEnvParams {
673
686
  accountId: string | null;
674
687
  /** The connect code (production: `process.env[CONNECT_CODE_ENV]`). */
675
688
  connectCode: string | undefined;
689
+ /**
690
+ * Plugin SQLite store. A complete activation row is the durable credential
691
+ * source of truth (`waitForActivationCredentials` resolves SQLite-first), so
692
+ * when one exists the env code is skipped — NOT consumed — rather than
693
+ * re-redeemed on every restart of a container that still carries the code.
694
+ */
695
+ store?: Pick<RuntimeConnectionStore, "getActivationCredentials"> | null;
676
696
  log?: Log;
677
697
  /** Test seam — login implementation. */
678
698
  runLogin?: (params: LoginParams) => Promise<void>;
@@ -695,12 +715,19 @@ export async function maybeAutoActivateFromEnv(
695
715
  const code = (params.connectCode ?? "").trim();
696
716
  if (!code) return params.account;
697
717
  if (accountHasConnectCredentials(params.account)) return params.account;
718
+ const accountId = params.account.accountId;
719
+ const stored = params.store?.getActivationCredentials?.({ platform: "openclaw", accountId });
720
+ if (stored) {
721
+ params.log?.info?.(
722
+ `[${accountId}] clawchat-plugin-openclaw ${CONNECT_CODE_ENV} present but a sqlite activation row already holds credentials; not redeeming`,
723
+ );
724
+ return params.account;
725
+ }
698
726
  if (consumedAutoActivationCodes.has(code)) return params.account;
699
727
  consumedAutoActivationCodes.add(code);
700
728
 
701
729
  const runLogin = params.runLogin ?? runOpenclawClawlingLogin;
702
730
  const resolveAccount = params.resolveAccount ?? resolveOpenclawClawlingAccount;
703
- const accountId = params.account.accountId;
704
731
  params.log?.info?.(
705
732
  `[${accountId}] clawchat-plugin-openclaw ${CONNECT_CODE_ENV} present; attempting non-interactive activation`,
706
733
  );
@@ -1044,6 +1071,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1044
1071
  account,
1045
1072
  accountId,
1046
1073
  connectCode: envConnectCode,
1074
+ store,
1047
1075
  log,
1048
1076
  });
1049
1077
  }
@@ -18,6 +18,19 @@ export function runWithTerminalClawChatSendScope<T>(scopeId: string, fn: () => T
18
18
  return terminalSendScope.run(scopeId, fn);
19
19
  }
20
20
 
21
+ /**
22
+ * Drop every marker whose TTL has lapsed. A marker is only otherwise deleted
23
+ * when the same dispatch scope consumes it, and a scope that never produces a
24
+ * follow-up reply for that chat (mention sent to another chat, `<clawchat:no-reply/>`,
25
+ * dispatch failure) leaves it behind; without this sweep the map would grow by
26
+ * one entry per such turn for the lifetime of the gateway.
27
+ */
28
+ function sweepExpiredTerminalSends(nowMs: number): void {
29
+ for (const [recordKey, record] of terminalSends) {
30
+ if (record.expiresAt <= nowMs) terminalSends.delete(recordKey);
31
+ }
32
+ }
33
+
21
34
  export function markTerminalClawChatSend(params: {
22
35
  accountId: string;
23
36
  chatId: string;
@@ -28,9 +41,11 @@ export function markTerminalClawChatSend(params: {
28
41
  }): void {
29
42
  const scopeId = params.scopeId ?? terminalSendScope.getStore();
30
43
  if (!scopeId) return;
44
+ const nowMs = params.nowMs ?? Date.now();
45
+ sweepExpiredTerminalSends(nowMs);
31
46
  terminalSends.set(key(params.accountId, params.chatId, scopeId), {
32
47
  messageId: params.messageId,
33
- expiresAt: (params.nowMs ?? Date.now()) + (params.ttlMs ?? DEFAULT_TTL_MS),
48
+ expiresAt: nowMs + (params.ttlMs ?? DEFAULT_TTL_MS),
34
49
  scopeId,
35
50
  });
36
51
  }