@clawling/clawchat-plugin-openclaw 2026.10.11-2 → 2026.10.13-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.
- package/dist/src/api-client.js +1 -0
- package/dist/src/inbound.js +33 -0
- package/dist/src/refresh-manager.js +23 -22
- package/dist/src/reply-dispatcher.js +78 -4
- package/dist/src/runtime.js +217 -69
- package/dist/src/storage.js +38 -0
- package/dist/src/tools.js +26 -1
- package/package.json +1 -1
- package/prompts/platform.md +2 -0
- package/src/api-client.ts +1 -0
- package/src/inbound.ts +39 -0
- package/src/refresh-manager.ts +30 -25
- package/src/reply-dispatcher.ts +94 -1
- package/src/runtime.ts +227 -71
- package/src/storage.ts +42 -0
- package/src/tools.ts +28 -1
package/src/runtime.ts
CHANGED
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
type LivewareSampleSupervisorDeps,
|
|
37
37
|
} from "./liveware-sample.ts";
|
|
38
38
|
import { ClawlingApiError } from "./api-types.ts";
|
|
39
|
-
import { RefreshManager } from "./refresh-manager.ts";
|
|
39
|
+
import { RefreshManager, fingerprintRefreshToken, type RevokedRefreshMarker } from "./refresh-manager.ts";
|
|
40
40
|
import { RECONNECT_GUIDE_URL } from "./onboarding-context.ts";
|
|
41
41
|
import {
|
|
42
42
|
runOpenclawClawlingLogin,
|
|
@@ -143,6 +143,9 @@ type RuntimeConnectionStore = Pick<
|
|
|
143
143
|
| "upsertActivation"
|
|
144
144
|
| "rotateActivationTokens"
|
|
145
145
|
| "clearActivationCredentials"
|
|
146
|
+
| "getRevokedRefreshMarker"
|
|
147
|
+
| "setRevokedRefreshMarker"
|
|
148
|
+
| "clearRevokedRefreshMarker"
|
|
146
149
|
| "insertMessage"
|
|
147
150
|
| "claimMessageOnce"
|
|
148
151
|
| "markMessageAcknowledged"
|
|
@@ -279,20 +282,27 @@ export function classifyHelloFailReason(
|
|
|
279
282
|
* carries no `token` of its own (i.e. the access token itself came from
|
|
280
283
|
* `CLAWCHAT_TOKEN`) — an env-provisioned pair can rotate, while a login-written
|
|
281
284
|
* token is never paired with a stray refresh token left in the environment.
|
|
285
|
+
* A revoked marker also excludes a matching refresh fingerprint from either source.
|
|
282
286
|
*/
|
|
283
287
|
function readConfigRefreshToken(
|
|
284
288
|
cfg: OpenClawConfig,
|
|
285
289
|
accountId: string,
|
|
290
|
+
revokedMarker?: RevokedRefreshMarker | null,
|
|
286
291
|
env: NodeJS.ProcessEnv = process.env,
|
|
287
292
|
): string | null {
|
|
288
293
|
const section = readOpenclawClawlingAccountSection(cfg, accountId);
|
|
289
294
|
const refreshToken = section.refreshToken;
|
|
290
|
-
|
|
295
|
+
const usable = (token: unknown): string | null => {
|
|
296
|
+
const value = typeof token === "string" ? token.trim() : "";
|
|
297
|
+
return value && (!revokedMarker || fingerprintRefreshToken(value) !== revokedMarker.fingerprint)
|
|
298
|
+
? value : null;
|
|
299
|
+
};
|
|
300
|
+
if (typeof refreshToken === "string" && refreshToken.trim()) return usable(refreshToken);
|
|
291
301
|
if (normalizeOpenclawClawlingAccountId(accountId) !== DEFAULT_ACCOUNT_ID) return null;
|
|
292
302
|
const configToken = section.token;
|
|
293
303
|
if (typeof configToken === "string" && configToken.trim()) return null;
|
|
294
304
|
const envRefreshToken = env[CLAWCHAT_REFRESH_TOKEN_ENV];
|
|
295
|
-
return
|
|
305
|
+
return usable(envRefreshToken);
|
|
296
306
|
}
|
|
297
307
|
|
|
298
308
|
function withFullVerboseDispatchConfig(cfg: OpenClawConfig, agentId: string): OpenClawConfig {
|
|
@@ -367,6 +377,24 @@ function isOpenClawConfirmSlashCommand(rawBody: string): boolean {
|
|
|
367
377
|
return Boolean(name && OPENCLAW_CONFIRM_SLASH_COMMANDS.has(name));
|
|
368
378
|
}
|
|
369
379
|
|
|
380
|
+
// Group commands that start an agent turn (OpenClaw 2026.9.8 command
|
|
381
|
+
// handlers): their output is the conversation itself, so it stays in the group
|
|
382
|
+
// with no ✅. `/new <text>` and `/reset <text>` run the text as a prompt.
|
|
383
|
+
const AGENT_TURN_GROUP_COMMANDS = new Set(["btw", "dashboard", "goal", "learn", "loop", "steer", "tell"]);
|
|
384
|
+
|
|
385
|
+
function opensAgentTurn(commandText: string): boolean {
|
|
386
|
+
const name = parseSlashCommandName(commandText);
|
|
387
|
+
if (!name) return false;
|
|
388
|
+
if (AGENT_TURN_GROUP_COMMANDS.has(name)) return true;
|
|
389
|
+
const hasArgs = /\s\S/.test(commandText.trim());
|
|
390
|
+
return (name === "new" || name === "reset") && hasArgs;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** First line of the owner's direct-chat receipt for a group command. */
|
|
394
|
+
export function ownerCommandReceiptHeader(groupTitle: string, commandText: string): string {
|
|
395
|
+
return `From group "${groupTitle}": ${commandText.trim()}`;
|
|
396
|
+
}
|
|
397
|
+
|
|
370
398
|
function isOpenClawTextSlashCommand(rawBody: string, cfg: OpenClawConfig): boolean {
|
|
371
399
|
return Boolean(parseSlashCommandName(rawBody) && (
|
|
372
400
|
hasControlCommand(rawBody, cfg)
|
|
@@ -637,7 +665,9 @@ export interface StartGatewayParams {
|
|
|
637
665
|
* min-interval floor) actually constrain a cross-reconnect refresh loop instead
|
|
638
666
|
* of resetting on every fresh `RefreshManager`.
|
|
639
667
|
*/
|
|
640
|
-
refreshManagerState?:
|
|
668
|
+
refreshManagerState?: Parameters<RefreshManager["restoreState"]>[0];
|
|
669
|
+
/** Internal guard when durable marker writes fail; never contains credentials. */
|
|
670
|
+
revokedRefreshMarker?: RevokedRefreshMarker;
|
|
641
671
|
/**
|
|
642
672
|
* Internal — the live (possibly rotated) refresh token, carried across a
|
|
643
673
|
* §D/§B gateway re-enter. A rotation persists config with `afterWrite:{mode:
|
|
@@ -972,6 +1002,7 @@ function waitForActivationPoll(abortSignal: AbortSignal, ms: number): Promise<bo
|
|
|
972
1002
|
}
|
|
973
1003
|
|
|
974
1004
|
async function waitForActivationCredentials(params: {
|
|
1005
|
+
cfg: OpenClawConfig;
|
|
975
1006
|
account: ResolvedOpenclawClawlingAccount;
|
|
976
1007
|
abortSignal: AbortSignal;
|
|
977
1008
|
getStatus: () => ChannelAccountSnapshot;
|
|
@@ -980,16 +1011,23 @@ async function waitForActivationCredentials(params: {
|
|
|
980
1011
|
log?: Log;
|
|
981
1012
|
pollIntervalMs: number;
|
|
982
1013
|
rejectedActivationToken?: string;
|
|
1014
|
+
revokedRefreshMarker?: RevokedRefreshMarker | null;
|
|
983
1015
|
}): Promise<{ account: ResolvedOpenclawClawlingAccount; source: "configured" | "sqlite" } | null> {
|
|
984
1016
|
const { abortSignal, getStatus, setStatus, store, log } = params;
|
|
985
1017
|
const accountId = params.account.accountId;
|
|
1018
|
+
const revoked = params.revokedRefreshMarker;
|
|
1019
|
+
const usable = (credentials: { accessToken: string; refreshToken: string | null }): boolean =>
|
|
1020
|
+
credentials.accessToken !== params.rejectedActivationToken &&
|
|
1021
|
+
(!revoked || (!!credentials.refreshToken &&
|
|
1022
|
+
fingerprintRefreshToken(credentials.refreshToken) !== revoked.fingerprint));
|
|
986
1023
|
// SQLite-first: activation and token rotation persist to the activations
|
|
987
1024
|
// row, so a complete row is the durable credential source of truth even
|
|
988
1025
|
// when channel config/env also carries credentials. Config/env credentials
|
|
989
|
-
//
|
|
1026
|
+
// bootstrap the no-row case, or replace a revoked pair with a different
|
|
1027
|
+
// refresh fingerprint (the caller seeds a row from the adopted pair).
|
|
990
1028
|
if (store?.getActivationCredentials) {
|
|
991
1029
|
const stored = store.getActivationCredentials({ platform: "openclaw", accountId });
|
|
992
|
-
if (stored && stored
|
|
1030
|
+
if (stored && usable(stored)) {
|
|
993
1031
|
log?.info?.(
|
|
994
1032
|
`[${accountId}] clawchat-plugin-openclaw loaded activation credentials from sqlite`,
|
|
995
1033
|
);
|
|
@@ -1005,10 +1043,19 @@ async function waitForActivationCredentials(params: {
|
|
|
1005
1043
|
};
|
|
1006
1044
|
}
|
|
1007
1045
|
}
|
|
1008
|
-
|
|
1009
|
-
|
|
1046
|
+
const configuredCredentials = (account: ResolvedOpenclawClawlingAccount) => {
|
|
1047
|
+
const refreshToken = readConfigRefreshToken(params.cfg, accountId, revoked);
|
|
1048
|
+
return accountHasConnectCredentials(account) && usable({ accessToken: account.token, refreshToken })
|
|
1049
|
+
? { account, source: "configured" as const } : null;
|
|
1050
|
+
};
|
|
1051
|
+
const configured = configuredCredentials(params.account);
|
|
1052
|
+
if (configured) return configured;
|
|
1053
|
+
if (revoked) {
|
|
1054
|
+
log?.error?.(`[${accountId}] clawchat-plugin-openclaw ${CLAWCHAT_TOKEN_EXPIRED_MESSAGE}`);
|
|
1055
|
+
setStatus({ ...getStatus(), connected: false, configured: false, running: false,
|
|
1056
|
+
lastError: CLAWCHAT_TOKEN_EXPIRED_LAST_ERROR });
|
|
1010
1057
|
}
|
|
1011
|
-
if (!store?.getActivationCredentials) {
|
|
1058
|
+
if (!revoked && !store?.getActivationCredentials) {
|
|
1012
1059
|
const message = "Clawling Chat token is missing and activation database is unavailable";
|
|
1013
1060
|
log?.error?.(`[${accountId}] clawchat-plugin-openclaw ${message}`);
|
|
1014
1061
|
setStatus({
|
|
@@ -1024,7 +1071,7 @@ async function waitForActivationCredentials(params: {
|
|
|
1024
1071
|
log?.info?.(
|
|
1025
1072
|
`[${accountId}] clawchat-plugin-openclaw waiting for activation credentials in sqlite`,
|
|
1026
1073
|
);
|
|
1027
|
-
setStatus({
|
|
1074
|
+
if (!revoked) setStatus({
|
|
1028
1075
|
...getStatus(),
|
|
1029
1076
|
connected: false,
|
|
1030
1077
|
configured: true,
|
|
@@ -1033,22 +1080,9 @@ async function waitForActivationCredentials(params: {
|
|
|
1033
1080
|
});
|
|
1034
1081
|
|
|
1035
1082
|
while (!abortSignal.aborted) {
|
|
1036
|
-
const credentials = store
|
|
1037
|
-
|
|
1038
|
-
accountId
|
|
1039
|
-
});
|
|
1040
|
-
if (credentials) {
|
|
1041
|
-
if (credentials.accessToken === params.rejectedActivationToken) {
|
|
1042
|
-
log?.debug?.(
|
|
1043
|
-
`[${accountId}] clawchat-plugin-openclaw activation credential poll heartbeat status=rejected_token_unchanged intervalMs=${params.pollIntervalMs}`,
|
|
1044
|
-
);
|
|
1045
|
-
const shouldContinue = await waitForActivationPoll(abortSignal, params.pollIntervalMs);
|
|
1046
|
-
if (!shouldContinue) break;
|
|
1047
|
-
continue;
|
|
1048
|
-
}
|
|
1049
|
-
log?.info?.(
|
|
1050
|
-
`[${accountId}] clawchat-plugin-openclaw loaded activation credentials from sqlite`,
|
|
1051
|
-
);
|
|
1083
|
+
const credentials = store?.getActivationCredentials?.({ platform: "openclaw", accountId });
|
|
1084
|
+
if (credentials && usable(credentials)) {
|
|
1085
|
+
log?.info?.(`[${accountId}] clawchat-plugin-openclaw loaded activation credentials from sqlite`);
|
|
1052
1086
|
return {
|
|
1053
1087
|
source: "sqlite",
|
|
1054
1088
|
account: {
|
|
@@ -1060,8 +1094,14 @@ async function waitForActivationCredentials(params: {
|
|
|
1060
1094
|
},
|
|
1061
1095
|
};
|
|
1062
1096
|
}
|
|
1097
|
+
if (revoked) {
|
|
1098
|
+
// Re-read env credentials while parked so a new pair can recover without
|
|
1099
|
+
// another gateway invocation. Config credentials use the supplied snapshot.
|
|
1100
|
+
const replacement = configuredCredentials(resolveOpenclawClawlingAccount(params.cfg, accountId));
|
|
1101
|
+
if (replacement) return replacement;
|
|
1102
|
+
}
|
|
1063
1103
|
log?.debug?.(
|
|
1064
|
-
`[${accountId}] clawchat-plugin-openclaw activation credential poll heartbeat status
|
|
1104
|
+
`[${accountId}] clawchat-plugin-openclaw activation credential poll heartbeat status=${credentials ? "rejected_token_unchanged" : "waiting"} intervalMs=${params.pollIntervalMs}`,
|
|
1065
1105
|
);
|
|
1066
1106
|
const shouldContinue = await waitForActivationPoll(abortSignal, params.pollIntervalMs);
|
|
1067
1107
|
if (!shouldContinue) break;
|
|
@@ -1079,7 +1119,25 @@ async function waitForActivationCredentials(params: {
|
|
|
1079
1119
|
let warnedBelowHostFloor = false;
|
|
1080
1120
|
let loggedHostToolSearch = false;
|
|
1081
1121
|
|
|
1122
|
+
/** Re-pair lifecycle owned by one gateway invocation (see performAutoLogout). */
|
|
1123
|
+
type GatewayLifecycle = { rePairWait?: Promise<void> };
|
|
1124
|
+
|
|
1082
1125
|
export async function startOpenclawClawlingGateway(params: StartGatewayParams): Promise<void> {
|
|
1126
|
+
// Every exit, including an interrupted connect or abort during logout, must
|
|
1127
|
+
// wait for the one re-pair lifecycle this invocation started: settling the
|
|
1128
|
+
// host's startAccount task early would make the host restart the channel.
|
|
1129
|
+
const lifecycle: GatewayLifecycle = {};
|
|
1130
|
+
try {
|
|
1131
|
+
await runOpenclawClawlingGateway(params, lifecycle);
|
|
1132
|
+
} finally {
|
|
1133
|
+
await lifecycle.rePairWait;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
async function runOpenclawClawlingGateway(
|
|
1138
|
+
params: StartGatewayParams,
|
|
1139
|
+
lifecycle: GatewayLifecycle,
|
|
1140
|
+
): Promise<void> {
|
|
1083
1141
|
const { cfg, abortSignal, setStatus, getStatus, log } = params;
|
|
1084
1142
|
let account = params.account;
|
|
1085
1143
|
// Obtain PluginRuntime from the stored runtime set via setOpenclawClawlingRuntime.
|
|
@@ -1144,7 +1202,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1144
1202
|
log,
|
|
1145
1203
|
});
|
|
1146
1204
|
}
|
|
1205
|
+
const revokedRefreshMarker = params.revokedRefreshMarker ??
|
|
1206
|
+
store?.getRevokedRefreshMarker?.({ platform: "openclaw", accountId });
|
|
1147
1207
|
const activationAccount = await waitForActivationCredentials({
|
|
1208
|
+
cfg,
|
|
1148
1209
|
account,
|
|
1149
1210
|
abortSignal,
|
|
1150
1211
|
getStatus,
|
|
@@ -1153,9 +1214,15 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1153
1214
|
log,
|
|
1154
1215
|
pollIntervalMs: params.activationPollIntervalMs ?? ACTIVATION_CREDENTIAL_POLL_INTERVAL_MS,
|
|
1155
1216
|
rejectedActivationToken: params.rejectedActivationToken,
|
|
1217
|
+
revokedRefreshMarker,
|
|
1156
1218
|
});
|
|
1157
1219
|
if (!activationAccount) return;
|
|
1158
1220
|
account = activationAccount.account;
|
|
1221
|
+
// Reset in-memory refresh guards only after adopting a different pair.
|
|
1222
|
+
if (revokedRefreshMarker) {
|
|
1223
|
+
params = { ...params, revokedRefreshMarker: undefined, refreshTokenOverride: undefined,
|
|
1224
|
+
refreshManagerState: undefined, rejectedActivationToken: undefined };
|
|
1225
|
+
}
|
|
1159
1226
|
// Config/env bootstrap seeding: converge on SQLite as the single durable
|
|
1160
1227
|
// credential store, so token rotation always has a row to update and the
|
|
1161
1228
|
// next startup resolves SQLite-first to the same credentials.
|
|
@@ -1167,7 +1234,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1167
1234
|
userId: account.userId,
|
|
1168
1235
|
ownerUserId: account.ownerUserId,
|
|
1169
1236
|
accessToken: account.token,
|
|
1170
|
-
refreshToken: readConfigRefreshToken(cfg, accountId),
|
|
1237
|
+
refreshToken: readConfigRefreshToken(cfg, accountId, revokedRefreshMarker),
|
|
1171
1238
|
loginMethod: "config-seed",
|
|
1172
1239
|
deviceId: CHANNEL_ID,
|
|
1173
1240
|
});
|
|
@@ -1177,6 +1244,11 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1177
1244
|
);
|
|
1178
1245
|
}
|
|
1179
1246
|
}
|
|
1247
|
+
if (revokedRefreshMarker && store) {
|
|
1248
|
+
if (store.clearRevokedRefreshMarker?.({ platform: "openclaw", accountId }) !== true) {
|
|
1249
|
+
log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to clear sqlite revoked marker after activation`);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1180
1252
|
// Paired: link the report row via the authenticated endpoint, reusing the
|
|
1181
1253
|
// SAME frozen device_id so the backend upserts the existing unpaired row.
|
|
1182
1254
|
void reportPluginVersionSafe({
|
|
@@ -1373,13 +1445,13 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1373
1445
|
// blanks them (auto-logout); otherwise persists the rotated pair.
|
|
1374
1446
|
const persistConfigTokens = async (
|
|
1375
1447
|
tokens: { accessToken: string; refreshToken: string } | null,
|
|
1376
|
-
): Promise<
|
|
1448
|
+
): Promise<boolean> => {
|
|
1377
1449
|
const mutateConfigFile = resolveMutateConfigFile();
|
|
1378
1450
|
if (!mutateConfigFile) {
|
|
1379
1451
|
log?.error?.(
|
|
1380
1452
|
`[${accountId}] clawchat-plugin-openclaw config persistence unavailable; cannot ${tokens ? "rotate" : "clear"} tokens in config`,
|
|
1381
1453
|
);
|
|
1382
|
-
return;
|
|
1454
|
+
return false;
|
|
1383
1455
|
}
|
|
1384
1456
|
await mutateConfigFile({
|
|
1385
1457
|
afterWrite: { mode: "none", reason: "clawchat-plugin-openclaw token refresh" },
|
|
@@ -1393,6 +1465,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1393
1465
|
);
|
|
1394
1466
|
},
|
|
1395
1467
|
});
|
|
1468
|
+
return true;
|
|
1396
1469
|
};
|
|
1397
1470
|
|
|
1398
1471
|
let lastHelloFailTraceId = "-";
|
|
@@ -1414,6 +1487,8 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1414
1487
|
let reconnectWithRefreshedToken = false;
|
|
1415
1488
|
// §C — set once auto-logout has fired so we don't double-emit or reconnect.
|
|
1416
1489
|
let autoLoggedOut = false;
|
|
1490
|
+
let refreshClient: ClawlingChatClient | undefined;
|
|
1491
|
+
let autoLogoutCleanup: Promise<void> | undefined;
|
|
1417
1492
|
|
|
1418
1493
|
// §E — connect-time device id for `X-Device-Id` on refresh. Prefer the value
|
|
1419
1494
|
// recorded in SQLite at connect; backfill legacy rows (no column) to the
|
|
@@ -1426,37 +1501,62 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1426
1501
|
// §C — auto-logout on permanent refresh failure. Blank creds in BOTH stores
|
|
1427
1502
|
// (KEEP identity), flip not-configured via the auth-failure status path, and
|
|
1428
1503
|
// emit the user-visible message. Idempotent.
|
|
1429
|
-
const performAutoLogout =
|
|
1430
|
-
|
|
1504
|
+
const performAutoLogout = (
|
|
1505
|
+
info: { code: number; message: string }, marker: RevokedRefreshMarker,
|
|
1506
|
+
): Promise<void> => {
|
|
1507
|
+
if (autoLogoutCleanup) return autoLogoutCleanup;
|
|
1431
1508
|
autoLoggedOut = true;
|
|
1432
1509
|
log?.error?.(
|
|
1433
1510
|
`[${accountId}] clawchat-plugin-openclaw auto-logout (token permanently expired) code=${info.code}: ${info.message}`,
|
|
1434
1511
|
);
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1512
|
+
refreshManager.stop();
|
|
1513
|
+
activeClients.delete(accountId);
|
|
1514
|
+
try { refreshClient?.close(); } catch { /* Best effort shutdown. */ }
|
|
1515
|
+
// Write the marker independently BEFORE clearing, so a failed clear cannot
|
|
1516
|
+
// resurrect this rejected credential at the next process start.
|
|
1517
|
+
autoLogoutCleanup = (async () => {
|
|
1518
|
+
const markerInput = { platform: "openclaw", accountId, ...marker };
|
|
1519
|
+
try {
|
|
1520
|
+
if (store?.setRevokedRefreshMarker?.(markerInput) !== true) {
|
|
1521
|
+
log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to persist sqlite revoked refresh marker`);
|
|
1522
|
+
}
|
|
1523
|
+
} catch {
|
|
1524
|
+
log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to persist sqlite revoked refresh marker`);
|
|
1525
|
+
}
|
|
1526
|
+
try {
|
|
1527
|
+
if (store?.clearActivationCredentials?.({ platform: "openclaw", accountId }) !== true) {
|
|
1528
|
+
log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to clear sqlite credentials on auto-logout`);
|
|
1529
|
+
}
|
|
1530
|
+
} catch {
|
|
1531
|
+
log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to clear sqlite credentials on auto-logout`);
|
|
1532
|
+
}
|
|
1533
|
+
try {
|
|
1534
|
+
if (!await persistConfigTokens(null)) throw new Error("config mutation unavailable");
|
|
1535
|
+
} catch {
|
|
1536
|
+
log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to clear config credentials on auto-logout`);
|
|
1537
|
+
}
|
|
1538
|
+
// Flip not-configured (existing auth-failure status path) with the re-pair
|
|
1539
|
+
// hint as `lastError`.
|
|
1540
|
+
setStatus({
|
|
1541
|
+
...getStatus(),
|
|
1542
|
+
connected: false,
|
|
1543
|
+
configured: false,
|
|
1544
|
+
running: false,
|
|
1545
|
+
lastError: CLAWCHAT_TOKEN_EXPIRED_LAST_ERROR,
|
|
1546
|
+
});
|
|
1547
|
+
// User-visible notification (in addition to logs). Best-effort; never throws.
|
|
1548
|
+
emitUserVisibleAuthLogout();
|
|
1549
|
+
})();
|
|
1550
|
+
// Assign the lifetime before yielding, including when config clearing is
|
|
1551
|
+
// still pending and closing the old socket interrupts client.connect().
|
|
1552
|
+
lifecycle.rePairWait = autoLogoutCleanup.then(() => startOpenclawClawlingGateway({
|
|
1553
|
+
...params, boundaryWarningIssued: true,
|
|
1554
|
+
account: { ...account, configured: false, token: "" },
|
|
1555
|
+
revokedRefreshMarker: marker, refreshTokenOverride: undefined,
|
|
1556
|
+
refreshManagerState: undefined, rejectedActivationToken: undefined,
|
|
1557
|
+
refreshReconnectDepth: undefined, refreshReconnectWindowStartedAt: undefined,
|
|
1558
|
+
}));
|
|
1559
|
+
return autoLogoutCleanup;
|
|
1460
1560
|
};
|
|
1461
1561
|
|
|
1462
1562
|
// §C.1.4 — surface the permanent-expiry message to the user/operator. The
|
|
@@ -1491,7 +1591,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1491
1591
|
(activationAccount.source === "sqlite" && store?.getActivationCredentials
|
|
1492
1592
|
? store.getActivationCredentials({ platform: "openclaw", accountId })?.refreshToken
|
|
1493
1593
|
: null) ??
|
|
1494
|
-
readConfigRefreshToken(cfg, accountId);
|
|
1594
|
+
readConfigRefreshToken(cfg, accountId, revokedRefreshMarker);
|
|
1495
1595
|
|
|
1496
1596
|
const refreshManager = new RefreshManager({
|
|
1497
1597
|
baseUrl: account.baseUrl,
|
|
@@ -1551,7 +1651,9 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1551
1651
|
// No SQLite store available — channel config is the only durable
|
|
1552
1652
|
// store, so its write failure must reject and keep the refresh
|
|
1553
1653
|
// transient (pre-existing behavior for store-less test transports).
|
|
1554
|
-
await persistConfigTokens(tokens)
|
|
1654
|
+
if (!await persistConfigTokens(tokens)) {
|
|
1655
|
+
throw new Error("clawchat-plugin-openclaw config persistence unavailable");
|
|
1656
|
+
}
|
|
1555
1657
|
},
|
|
1556
1658
|
swapInMemoryTokens: (tokens) => {
|
|
1557
1659
|
account = { ...account, token: tokens.accessToken, configured: true };
|
|
@@ -1830,7 +1932,8 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1830
1932
|
const startupOutcome = await refreshManager.refresh("startup-near-expiry");
|
|
1831
1933
|
if (abortSignal.aborted) return;
|
|
1832
1934
|
if (startupOutcome.kind === "permanent") {
|
|
1833
|
-
//
|
|
1935
|
+
// Wait for re-pair or abort rather than settling the host channel task.
|
|
1936
|
+
await lifecycle.rePairWait;
|
|
1834
1937
|
return;
|
|
1835
1938
|
}
|
|
1836
1939
|
// success swaps the in-memory token in place; transient/skipped just connect
|
|
@@ -1878,6 +1981,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1878
1981
|
},
|
|
1879
1982
|
},
|
|
1880
1983
|
});
|
|
1984
|
+
refreshClient = client;
|
|
1881
1985
|
log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime client created`);
|
|
1882
1986
|
|
|
1883
1987
|
// §A.3/§A.4 — recompute the refresh-driven reconnect streak (depth + window).
|
|
@@ -2018,7 +2122,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
2018
2122
|
}
|
|
2019
2123
|
const outcome = await refreshManager.refresh(reason);
|
|
2020
2124
|
if (abortSignal.aborted) return "fallthrough";
|
|
2021
|
-
if (autoLoggedOut)
|
|
2125
|
+
if (autoLoggedOut) {
|
|
2126
|
+
await lifecycle.rePairWait;
|
|
2127
|
+
return "handled";
|
|
2128
|
+
}
|
|
2022
2129
|
if (outcome.kind === "success") {
|
|
2023
2130
|
await closeAndReconnectWithRefreshedToken(reason);
|
|
2024
2131
|
return "handled";
|
|
@@ -3443,7 +3550,24 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3443
3550
|
return "claimed";
|
|
3444
3551
|
};
|
|
3445
3552
|
|
|
3446
|
-
|
|
3553
|
+
// The group's title from its metadata file, which the turn's profile sync
|
|
3554
|
+
// has just refreshed (so a group's first command already has it); the
|
|
3555
|
+
// group id when none is on record.
|
|
3556
|
+
const groupTitleForReceipt = async (groupId: string, memoryRoot: string): Promise<string> => {
|
|
3557
|
+
try {
|
|
3558
|
+
const file = await readClawChatMemoryFile(memoryRoot, { targetType: "group", targetId: groupId });
|
|
3559
|
+
const title = file.exists ? file.metadata.group_title : undefined;
|
|
3560
|
+
if (typeof title === "string" && title.trim()) return title.trim();
|
|
3561
|
+
} catch {
|
|
3562
|
+
// Fall back to the id below.
|
|
3563
|
+
}
|
|
3564
|
+
return groupId;
|
|
3565
|
+
};
|
|
3566
|
+
|
|
3567
|
+
const dispatchTurnToAgent = async (
|
|
3568
|
+
turn: IngestTurnParams,
|
|
3569
|
+
options: { ownerGroupCommand?: string } = {},
|
|
3570
|
+
): Promise<IngestTurnResult> => {
|
|
3447
3571
|
const rt = runtime.channel;
|
|
3448
3572
|
const routeCfg = withClawChatSessionScope(cfg);
|
|
3449
3573
|
const route = rt.routing.resolveAgentRoute({
|
|
@@ -3475,6 +3599,15 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3475
3599
|
);
|
|
3476
3600
|
}
|
|
3477
3601
|
turn = await withCachedGroupSenderNames(turn, memoryRoot);
|
|
3602
|
+
const ownerCommandReceipt = options.ownerGroupCommand && turn.peer.kind === "group"
|
|
3603
|
+
? {
|
|
3604
|
+
header: ownerCommandReceiptHeader(
|
|
3605
|
+
await groupTitleForReceipt(turn.peer.id, memoryRoot),
|
|
3606
|
+
options.ownerGroupCommand,
|
|
3607
|
+
),
|
|
3608
|
+
commandMessageId: turn.messageId,
|
|
3609
|
+
}
|
|
3610
|
+
: undefined;
|
|
3478
3611
|
const conversationTarget = `${CHANNEL_ID}:${formatConversationSubject(turn.peer)}`;
|
|
3479
3612
|
const senderIsOwner = turn.senderIsOwner === true || turn.senderId === account.ownerUserId;
|
|
3480
3613
|
// Owner turn <=> the owner's activation direct chat. Resolved per turn;
|
|
@@ -3669,6 +3802,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3669
3802
|
// member-backend). Passed as a thunk so it is resolved at send time.
|
|
3670
3803
|
resolveOwnerConversationId: resolveOwnerDirectConversationId,
|
|
3671
3804
|
nonOwnerTurn: turnClass === "non-owner",
|
|
3805
|
+
...(ownerCommandReceipt ? { ownerCommandReceipt } : {}),
|
|
3672
3806
|
log,
|
|
3673
3807
|
});
|
|
3674
3808
|
|
|
@@ -3758,8 +3892,9 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3758
3892
|
// return on abort, but only AFTER it has already taken the idempotency claim
|
|
3759
3893
|
// (`claimInboundTurn` runs BEFORE the gate wait there, so the frame is durably
|
|
3760
3894
|
// persisted before the interruptible wait — required by legacy advance-on-
|
|
3761
|
-
// write delivery, where a frame written before a disconnect is not
|
|
3762
|
-
// on reconnect
|
|
3895
|
+
// write delivery, where a frame written before a disconnect is not reliably
|
|
3896
|
+
// re-delivered on reconnect (legacy may lose OR replay; a replay is deduped by
|
|
3897
|
+
// the claim); see docs/client-integration.md §11.7). So even an ingest-time
|
|
3763
3898
|
// abort keeps the frame; it just skips dispatch. Here at FLUSH the batch was
|
|
3764
3899
|
// likewise already claimed at ingest AND `flush()` has already removed it from
|
|
3765
3900
|
// the coalescer's pending set. An abort early-return would therefore silently
|
|
@@ -3883,7 +4018,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3883
4018
|
const senderBatchIdentity = rawTurn.peer.kind === "group"
|
|
3884
4019
|
? resolveSenderBatchIdentityForTurn(rawTurn)
|
|
3885
4020
|
: {};
|
|
3886
|
-
|
|
4021
|
+
let turn: IngestTurnParams = {
|
|
3887
4022
|
...rawTurn,
|
|
3888
4023
|
senderNickName: resolveSenderNickNameForTurn(rawTurn),
|
|
3889
4024
|
...senderBatchIdentity,
|
|
@@ -3895,11 +4030,14 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3895
4030
|
// can interrupt. This client does NOT advertise `reliable_delivery`
|
|
3896
4031
|
// (src/ws-client.ts), so under legacy delivery the server's replay cursor
|
|
3897
4032
|
// advances on socket-WRITE (docs/client-integration.md §11.7): a frame
|
|
3898
|
-
// already written before a disconnect is NOT re-delivered on
|
|
4033
|
+
// already written before a disconnect is NOT reliably re-delivered on
|
|
4034
|
+
// reconnect. Legacy is not at-most-once either — a multi-replica server
|
|
4035
|
+
// (relayed frames do not advance the cursor) or a failed cursor write can
|
|
4036
|
+
// replay frames already received — and the claim below dedupes those.
|
|
3899
4037
|
// `claimMessageOnce` is the ONLY durability an inbound has, and the store
|
|
3900
4038
|
// never reprocesses undispatched inbounds. So if we claimed only AFTER the
|
|
3901
|
-
// wait, a shutdown-mid-wait would leave the frame
|
|
3902
|
-
//
|
|
4039
|
+
// wait, a shutdown-mid-wait would leave the frame unpersisted with no
|
|
4040
|
+
// guaranteed replay — lost. Claiming first makes it durable; the abort
|
|
3903
4041
|
// check below then returns without dispatching, but the frame is kept.
|
|
3904
4042
|
// `claimInboundTurn` is INSERT OR IGNORE, so a genuine duplicate (same
|
|
3905
4043
|
// message_id) is still deduped here exactly once. The claim fails CLOSED:
|
|
@@ -3934,7 +4072,14 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3934
4072
|
);
|
|
3935
4073
|
return "stored_muted";
|
|
3936
4074
|
}
|
|
3937
|
-
|
|
4075
|
+
// `@agent /cmd` counts as `/cmd` once the leading mentions are off.
|
|
4076
|
+
const commandText = isKnownOpenClawGroupSlashCommand(turn.rawBody, cfg)
|
|
4077
|
+
? turn.rawBody
|
|
4078
|
+
: turn.addressedCommandBody && isKnownOpenClawGroupSlashCommand(turn.addressedCommandBody, cfg)
|
|
4079
|
+
? turn.addressedCommandBody
|
|
4080
|
+
: null;
|
|
4081
|
+
if (commandText) {
|
|
4082
|
+
turn = { ...turn, rawBody: commandText };
|
|
3938
4083
|
if (isOpenClawConfirmSlashCommand(turn.rawBody)) {
|
|
3939
4084
|
return forwardGroupApprovalSlashToOwner(turn);
|
|
3940
4085
|
}
|
|
@@ -3947,10 +4092,13 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3947
4092
|
);
|
|
3948
4093
|
return "skipped";
|
|
3949
4094
|
}
|
|
4095
|
+
// The owner's command answers in the owner's direct chat; a command
|
|
4096
|
+
// that starts an agent turn, or anyone else's, answers in the group.
|
|
4097
|
+
const receiptToOwner = turn.senderIsOwner === true && !opensAgentTurn(commandText);
|
|
3950
4098
|
log?.info?.(
|
|
3951
|
-
`[${accountId}] clawchat-plugin-openclaw dispatching group command chat_id=${turn.peer.id} msg=${turn.messageId} mode=${commandMode}`,
|
|
4099
|
+
`[${accountId}] clawchat-plugin-openclaw dispatching group command chat_id=${turn.peer.id} msg=${turn.messageId} mode=${commandMode} receipt=${receiptToOwner ? "owner" : "group"}`,
|
|
3952
4100
|
);
|
|
3953
|
-
return dispatchTurnToAgent(turn);
|
|
4101
|
+
return dispatchTurnToAgent(turn, receiptToOwner ? { ownerGroupCommand: commandText } : {});
|
|
3954
4102
|
}
|
|
3955
4103
|
if (eff.replyMode === "mention" && !turn.wasMentioned) {
|
|
3956
4104
|
log?.info?.(
|
|
@@ -4226,6 +4374,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
4226
4374
|
await client.connect();
|
|
4227
4375
|
log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime client.connect() resolved`);
|
|
4228
4376
|
} catch (err) {
|
|
4377
|
+
if (autoLoggedOut) {
|
|
4378
|
+
await lifecycle.rePairWait;
|
|
4379
|
+
return;
|
|
4380
|
+
}
|
|
4229
4381
|
const classified = classifyClawlingClientError(err);
|
|
4230
4382
|
if (classified.kind === "auth") {
|
|
4231
4383
|
// §A.2/§B — initial-connect hello-fail(auth). Do NOT pre-flip
|
|
@@ -4301,6 +4453,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
4301
4453
|
);
|
|
4302
4454
|
return;
|
|
4303
4455
|
}
|
|
4456
|
+
if (autoLoggedOut) {
|
|
4457
|
+
await lifecycle.rePairWait;
|
|
4458
|
+
return;
|
|
4459
|
+
}
|
|
4304
4460
|
activeClients.set(accountId, client);
|
|
4305
4461
|
// Text "@name" autolinking on outbound group sends reads the same cached
|
|
4306
4462
|
// roster (participant ids + nicknames) the group prompt shows the model.
|
package/src/storage.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RevokedRefreshMarker } from "./refresh-manager.ts";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { DatabaseSync } from "node:sqlite";
|
|
@@ -427,6 +428,19 @@ CREATE INDEX IF NOT EXISTS idx_clawchat_messages_protocol_message_id_nocase
|
|
|
427
428
|
sql: `
|
|
428
429
|
CREATE INDEX IF NOT EXISTS idx_clawchat_messages_chat_id_nocase
|
|
429
430
|
ON clawchat_messages(chat_id COLLATE NOCASE);
|
|
431
|
+
`,
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
version: 13,
|
|
435
|
+
name: "revoked_refresh_tokens",
|
|
436
|
+
sql: `
|
|
437
|
+
CREATE TABLE IF NOT EXISTS revoked_refresh_tokens (
|
|
438
|
+
platform TEXT NOT NULL,
|
|
439
|
+
account_id TEXT NOT NULL,
|
|
440
|
+
fingerprint TEXT NOT NULL,
|
|
441
|
+
revoked_at INTEGER NOT NULL,
|
|
442
|
+
PRIMARY KEY (platform, account_id)
|
|
443
|
+
);
|
|
430
444
|
`,
|
|
431
445
|
},
|
|
432
446
|
];
|
|
@@ -600,6 +614,34 @@ export class ClawChatStore {
|
|
|
600
614
|
});
|
|
601
615
|
}
|
|
602
616
|
|
|
617
|
+
setRevokedRefreshMarker(input: ClearActivationCredentialsInput & RevokedRefreshMarker): boolean | null {
|
|
618
|
+
return this.write(() => {
|
|
619
|
+
this.requireDb().prepare(`INSERT INTO revoked_refresh_tokens
|
|
620
|
+
(platform, account_id, fingerprint, revoked_at) VALUES (?, ?, ?, ?)
|
|
621
|
+
ON CONFLICT(platform, account_id) DO UPDATE SET
|
|
622
|
+
fingerprint = excluded.fingerprint, revoked_at = excluded.revoked_at`)
|
|
623
|
+
.run(input.platform, input.accountId, input.fingerprint, input.revokedAt);
|
|
624
|
+
return true;
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
getRevokedRefreshMarker(input: ClearActivationCredentialsInput): RevokedRefreshMarker | null {
|
|
629
|
+
return this.read(() => {
|
|
630
|
+
const row = this.requireDb().prepare(`SELECT fingerprint, revoked_at AS revokedAt
|
|
631
|
+
FROM revoked_refresh_tokens WHERE platform = ? AND account_id = ?`)
|
|
632
|
+
.get(input.platform, input.accountId) as RevokedRefreshMarker | undefined;
|
|
633
|
+
return row ?? null;
|
|
634
|
+
}) ?? null;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
clearRevokedRefreshMarker(input: ClearActivationCredentialsInput): boolean | null {
|
|
638
|
+
return this.write(() => {
|
|
639
|
+
this.requireDb().prepare(`DELETE FROM revoked_refresh_tokens WHERE platform = ? AND account_id = ?`)
|
|
640
|
+
.run(input.platform, input.accountId);
|
|
641
|
+
return true;
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
603
645
|
getActivationCredentials(input: ConversationAccountInput): ActivationCredentials | null {
|
|
604
646
|
return this.read(() => {
|
|
605
647
|
const row = this.requireDb()
|