@clawling/clawchat-plugin-openclaw 2026.9.14-2 → 2026.9.17-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 +84 -9
- package/dist/src/config.js +3 -0
- package/dist/src/connect-check.js +74 -0
- package/dist/src/friend-greeting.js +80 -0
- package/dist/src/login.runtime.js +84 -39
- package/dist/src/onboarding-context.js +70 -0
- package/dist/src/onboarding-report.js +50 -0
- package/dist/src/plugin-report.js +1 -0
- package/dist/src/protocol-types.js +1 -0
- package/dist/src/refresh-manager.js +77 -10
- package/dist/src/runtime.js +117 -2
- package/dist/src/skill-update.js +1 -1
- package/dist/src/tools-schema.js +6 -0
- package/dist/src/tools.js +32 -1
- package/dist/src/ws-client.js +5 -0
- package/openclaw.plugin.json +5 -0
- package/package.json +1 -1
- package/skills/clawchat-core/SKILL.md +32 -3
- package/skills/clawchat-set-greeting/SKILL.md +23 -4
- package/skills/manifest.json +12 -12
- package/src/api-client.ts +116 -23
- package/src/api-types.ts +18 -0
- package/src/config.ts +7 -0
- package/src/connect-check.ts +98 -0
- package/src/friend-greeting.ts +91 -0
- package/src/login.runtime.ts +105 -44
- package/src/onboarding-context.ts +77 -0
- package/src/onboarding-report.ts +60 -0
- package/src/plugin-report.ts +4 -0
- package/src/protocol-types.ts +1 -0
- package/src/refresh-manager.ts +89 -10
- package/src/runtime.ts +135 -3
- package/src/skill-update.ts +1 -1
- package/src/tools-schema.ts +8 -0
- package/src/tools.ts +37 -0
- package/src/ws-client.ts +4 -0
package/src/login.runtime.ts
CHANGED
|
@@ -14,24 +14,16 @@ import {
|
|
|
14
14
|
resolveOpenclawClawlingAccount,
|
|
15
15
|
} from "./config.ts";
|
|
16
16
|
import { getClawChatStore, type ClawChatStore } from "./storage.ts";
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
export const AGENTS_CONNECT_TYPE = "clawbot" as const;
|
|
28
|
-
/**
|
|
29
|
-
* `/v1/agents/connect` envelope code for "the supplied user_id matches no
|
|
30
|
-
* agent". Servers that predate the server-side stale-user_id fallback return
|
|
31
|
-
* this instead of degrading to a fresh pairing, so the client sheds the id and
|
|
32
|
-
* retries once.
|
|
33
|
-
*/
|
|
34
|
-
export const AGENT_NOT_FOUND_CODE = 16001 as const;
|
|
17
|
+
import { buildOnboardingContext, RECONNECT_GUIDE_URL } from "./onboarding-context.ts";
|
|
18
|
+
import { boundCodeNewIdentityMessage, preCheckConnectCode, unpairableMessage } from "./connect-check.ts";
|
|
19
|
+
// Re-exported: `src/login.runtime.test.ts` and `src/commands.ts` import these
|
|
20
|
+
// from here, so the historical import path keeps working after the move.
|
|
21
|
+
import {
|
|
22
|
+
AGENTS_CONNECT_PLATFORM,
|
|
23
|
+
AGENTS_CONNECT_TYPE,
|
|
24
|
+
AGENT_NOT_FOUND_CODE,
|
|
25
|
+
} from "./onboarding-context.ts";
|
|
26
|
+
export { AGENTS_CONNECT_PLATFORM, AGENTS_CONNECT_TYPE, AGENT_NOT_FOUND_CODE };
|
|
35
27
|
|
|
36
28
|
export type OpenclawClawchatMutateConfigFile = <T = void>(params: {
|
|
37
29
|
afterWrite: { mode: "auto" } | { mode: "none" | "restart"; reason: string };
|
|
@@ -103,9 +95,11 @@ export class ExistingActivationError extends Error {
|
|
|
103
95
|
"spent. Re-run stating the intent:\n" +
|
|
104
96
|
" - Pair as a BRAND-NEW agent (this instance stops using the identity " +
|
|
105
97
|
"above): /clawchat-activate <CODE> --new-account\n" +
|
|
106
|
-
" - RESTORE the identity above
|
|
107
|
-
"
|
|
108
|
-
"
|
|
98
|
+
" - RESTORE the identity above: do not spend a fresh code on it. Ask your " +
|
|
99
|
+
"owner to send you the reconnect prompt from the ClawChat app — its code is " +
|
|
100
|
+
"bound to this identity and usually restores it on its own; if activation " +
|
|
101
|
+
"still reports it as already paired, run it again with --repair " +
|
|
102
|
+
`(/clawchat-activate <CODE> --repair) — and follow ${RECONNECT_GUIDE_URL}\n` +
|
|
109
103
|
" - To run a SECOND agent alongside this one instead, activate it as a " +
|
|
110
104
|
"named account: /clawchat-activate <CODE> --account <name> (or: openclaw " +
|
|
111
105
|
"channels add --channel clawchat-plugin-openclaw --account <name> --token <CODE>)",
|
|
@@ -289,7 +283,63 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
|
|
|
289
283
|
// overridden them, so login works without a prior `openclaw channels setup --channel clawchat-plugin-openclaw`.
|
|
290
284
|
const account = resolveOpenclawClawlingAccount(cfg, accountId);
|
|
291
285
|
|
|
292
|
-
|
|
286
|
+
const inviteCode = (
|
|
287
|
+
await (params.readInviteCode ?? (() => promptInviteCodeFromStdin(runtime)))()
|
|
288
|
+
).trim();
|
|
289
|
+
if (!inviteCode) {
|
|
290
|
+
throw new Error("Login aborted: invite code is required.");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const apiClient = (params.apiClientFactory ?? createOpenclawClawlingApiClient)({
|
|
294
|
+
baseUrl: account.baseUrl,
|
|
295
|
+
mediaBaseUrl: account.mediaBaseUrl,
|
|
296
|
+
// Pre-login we may not have a token yet. Send the current one (or empty)
|
|
297
|
+
// — the server should accept an unauthenticated invite-code exchange.
|
|
298
|
+
token: account.token || "",
|
|
299
|
+
pluginVersion: resolvePluginVersion(),
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Telemetry the backend joins with wiki field reports; ignored by older servers.
|
|
303
|
+
const onboarding = buildOnboardingContext();
|
|
304
|
+
|
|
305
|
+
// Non-consuming pre-check. `null` = endpoint unavailable, carry on. A bound
|
|
306
|
+
// code (the owner's reconnect prompt) is the owner's own proof of intent, so
|
|
307
|
+
// it settles the "new agent or restore?" question below without a flag.
|
|
308
|
+
//
|
|
309
|
+
// The pre-check only carries the `user_id` that `/connect` would replay. An
|
|
310
|
+
// explicit `newAccount` never replays it, so it must not be judged against
|
|
311
|
+
// it either: the server marks `pairable:false` for an `owner_mismatch` /
|
|
312
|
+
// `invalid` id, and that would block the very escape the refusal recommends.
|
|
313
|
+
const storedUserId = account.userId.trim();
|
|
314
|
+
const precheckUserId = params.newAccount ? "" : storedUserId;
|
|
315
|
+
runtime.log("Checking the invite code …");
|
|
316
|
+
let precheck = await preCheckConnectCode(
|
|
317
|
+
apiClient,
|
|
318
|
+
{ code: inviteCode, userId: precheckUserId || undefined, context: onboarding },
|
|
319
|
+
runtime.log,
|
|
320
|
+
);
|
|
321
|
+
// Whether the operator will be asked "new agent or restore?" below. When
|
|
322
|
+
// they will, a refusal caused only by the stored identity (not by the code)
|
|
323
|
+
// is deferred: choosing a brand-new identity drops that id, so the verdict
|
|
324
|
+
// on it no longer applies.
|
|
325
|
+
const identityUnsettled =
|
|
326
|
+
storedUserId !== "" && account.token.trim() !== "" && !params.newAccount && !params.repair;
|
|
327
|
+
const refusedForIdentity =
|
|
328
|
+
precheck !== null &&
|
|
329
|
+
!precheck.pairable &&
|
|
330
|
+
precheckUserId !== "" &&
|
|
331
|
+
// Only a live code: a dead one (expired / paired / invalid) refuses on its
|
|
332
|
+
// own account whatever identity is chosen, so there is nothing to defer.
|
|
333
|
+
precheck.status === "pending" &&
|
|
334
|
+
(precheck.userIdStatus === "owner_mismatch" || precheck.userIdStatus === "invalid");
|
|
335
|
+
const deferredRefusal = identityUnsettled && refusedForIdentity ? precheck : null;
|
|
336
|
+
if (precheck && !precheck.pairable && !deferredRefusal) {
|
|
337
|
+
throw new Error(unpairableMessage(precheck));
|
|
338
|
+
}
|
|
339
|
+
const boundToIncumbent =
|
|
340
|
+
precheck?.pairable === true && precheck.boundAgent === true && precheckUserId !== "";
|
|
341
|
+
|
|
342
|
+
// Fork before the invite code is spent, so neither branch spends the code
|
|
293
343
|
// before the outcome is settled. "Live" is decided by whether a usable token
|
|
294
344
|
// remains: auto-logout (§C) blanks the tokens but preserves the identity, so a
|
|
295
345
|
// logged-out instance still re-pairs with no flag at all, which is the flow
|
|
@@ -299,50 +349,60 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
|
|
|
299
349
|
// ambiguous, because redeeming a code here can either mint a new agent or
|
|
300
350
|
// re-pair the incumbent one. Ask which, and only refuse when nobody answers.
|
|
301
351
|
// An explicit `newAccount` / `repair` from the caller has already settled it,
|
|
302
|
-
// so no prompt.
|
|
352
|
+
// so no prompt. Nor is one needed when the pre-check proved the code is the
|
|
353
|
+
// owner's own reconnect prompt for this very agent (`boundToIncumbent`) — a
|
|
354
|
+
// bound code can only ever restore the incumbent identity.
|
|
303
355
|
let newAccount = params.newAccount;
|
|
304
|
-
if (
|
|
356
|
+
if (identityUnsettled && !boundToIncumbent) {
|
|
305
357
|
const identity = account.agentId.trim()
|
|
306
|
-
? `agent ${account.agentId.trim()} (shadow user ${
|
|
307
|
-
: `agent ${
|
|
358
|
+
? `agent ${account.agentId.trim()} (shadow user ${storedUserId})`
|
|
359
|
+
: `agent ${storedUserId}`;
|
|
308
360
|
const intent = await (params.readActivationIntent ??
|
|
309
361
|
(() => promptActivationIntentFromStdin(runtime, identity)))();
|
|
310
362
|
// Only "new-account" changes what gets sent. Restoring needs no flag:
|
|
311
363
|
// replaying the stored `user_id` IS the re-pair, and that is already the
|
|
312
364
|
// default below — so choosing it simply means "don't refuse".
|
|
313
|
-
if (intent === "new-account")
|
|
314
|
-
|
|
315
|
-
|
|
365
|
+
if (intent === "new-account") {
|
|
366
|
+
newAccount = true;
|
|
367
|
+
if (deferredRefusal) {
|
|
368
|
+
// The first verdict judged the id we are now dropping; re-check the
|
|
369
|
+
// code on its own before spending it.
|
|
370
|
+
precheck = await preCheckConnectCode(
|
|
371
|
+
apiClient,
|
|
372
|
+
{ code: inviteCode, context: onboarding },
|
|
373
|
+
runtime.log,
|
|
374
|
+
);
|
|
375
|
+
if (precheck && !precheck.pairable) {
|
|
376
|
+
throw new Error(unpairableMessage(precheck));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
} else if (deferredRefusal) {
|
|
380
|
+
// Restoring (or nobody to ask) replays the id the server already refused.
|
|
381
|
+
throw new Error(unpairableMessage(deferredRefusal));
|
|
382
|
+
} else if (intent === null) {
|
|
383
|
+
throw new ExistingActivationError(storedUserId, account.agentId.trim());
|
|
316
384
|
}
|
|
317
385
|
}
|
|
318
386
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
if (
|
|
323
|
-
throw new Error(
|
|
387
|
+
// A new identity (flag or interactive choice) sends no user_id, and /connect
|
|
388
|
+
// without one on a bound code silently RESTORES the bound agent and spends
|
|
389
|
+
// the code. A reconnect code can never mint an agent, so refuse first.
|
|
390
|
+
if (newAccount && precheck?.boundAgent === true) {
|
|
391
|
+
throw new Error(boundCodeNewIdentityMessage());
|
|
324
392
|
}
|
|
325
393
|
|
|
326
|
-
const apiClient = (params.apiClientFactory ?? createOpenclawClawlingApiClient)({
|
|
327
|
-
baseUrl: account.baseUrl,
|
|
328
|
-
mediaBaseUrl: account.mediaBaseUrl,
|
|
329
|
-
// Pre-login we may not have a token yet. Send the current one (or empty)
|
|
330
|
-
// — the server should accept an unauthenticated invite-code exchange.
|
|
331
|
-
token: account.token || "",
|
|
332
|
-
pluginVersion: resolvePluginVersion(),
|
|
333
|
-
});
|
|
334
|
-
|
|
335
394
|
runtime.log("Verifying invite code …");
|
|
336
395
|
let result;
|
|
337
396
|
// A brand-new identity is precisely "do not replay" — the replay is the only
|
|
338
397
|
// thing that would bind this code to the incumbent agent.
|
|
339
|
-
const existingUserId = newAccount ? "" :
|
|
398
|
+
const existingUserId = newAccount ? "" : storedUserId;
|
|
340
399
|
try {
|
|
341
400
|
result = await apiClient.agentsConnect({
|
|
342
401
|
code: inviteCode,
|
|
343
402
|
platform: AGENTS_CONNECT_PLATFORM,
|
|
344
403
|
type: AGENTS_CONNECT_TYPE,
|
|
345
404
|
...(existingUserId ? { user_id: existingUserId } : {}),
|
|
405
|
+
context: onboarding,
|
|
346
406
|
});
|
|
347
407
|
} catch (err) {
|
|
348
408
|
if (err instanceof ClawlingApiError) {
|
|
@@ -363,6 +423,7 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
|
|
|
363
423
|
code: inviteCode,
|
|
364
424
|
platform: AGENTS_CONNECT_PLATFORM,
|
|
365
425
|
type: AGENTS_CONNECT_TYPE,
|
|
426
|
+
context: onboarding,
|
|
366
427
|
});
|
|
367
428
|
} catch (retryErr) {
|
|
368
429
|
if (retryErr instanceof ClawlingApiError) {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Onboarding telemetry the plugin attaches to `/v1/agents/connect` and
|
|
3
|
+
* `/v1/agents/connect/check` (all optional, all ignored by older backends).
|
|
4
|
+
*
|
|
5
|
+
* Values mirror the connection wiki's manifest vocabulary so the backend can
|
|
6
|
+
* join plugin activations with wiki field reports without an alias table:
|
|
7
|
+
* - `agent_kind` = the wiki `match.agent` name for this host
|
|
8
|
+
* - `lane` = "self": this plugin holds its own WebSocket + token
|
|
9
|
+
* - `matched_install` = the wiki page slug that routes to this plugin
|
|
10
|
+
* Nothing here is a credential and nothing here gates activation.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const AGENT_KIND = "openclaw" as const;
|
|
14
|
+
export const LANE = "self" as const;
|
|
15
|
+
export const MATCHED_INSTALL = "install/official-openclaw" as const;
|
|
16
|
+
|
|
17
|
+
/** Public connection wiki. `/start.md` is the entry page; `/reconnect.md` the re-pair page. */
|
|
18
|
+
export const START_GUIDE_URL = "https://agent-connection.clawling.com/start.md" as const;
|
|
19
|
+
export const RECONNECT_GUIDE_URL = "https://agent-connection.clawling.com/reconnect.md" as const;
|
|
20
|
+
|
|
21
|
+
const WIKI_VERSION_MAX = 40;
|
|
22
|
+
|
|
23
|
+
/** Wiki `os` enum (windows | macos | linux); "" for anything else so callers omit it. */
|
|
24
|
+
export function hostOs(plat: string = process.platform): "windows" | "macos" | "linux" | "" {
|
|
25
|
+
switch (plat) {
|
|
26
|
+
case "win32":
|
|
27
|
+
return "windows";
|
|
28
|
+
case "darwin":
|
|
29
|
+
return "macos";
|
|
30
|
+
case "linux":
|
|
31
|
+
return "linux";
|
|
32
|
+
default:
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The wiki build stamp the agent saw on the install page. The official install
|
|
39
|
+
* page tells the agent to export `CLAWCHAT_WIKI_VERSION` before activating; a
|
|
40
|
+
* hand-run `openclaw channels add` simply has none.
|
|
41
|
+
*/
|
|
42
|
+
export function wikiVersion(env: NodeJS.ProcessEnv = process.env): string {
|
|
43
|
+
return (env.CLAWCHAT_WIKI_VERSION ?? "").trim().slice(0, WIKI_VERSION_MAX);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function buildOnboardingContext(
|
|
47
|
+
opts: { platform?: string; env?: NodeJS.ProcessEnv } = {},
|
|
48
|
+
): Record<string, string> {
|
|
49
|
+
const out: Record<string, string> = {
|
|
50
|
+
agent_kind: AGENT_KIND,
|
|
51
|
+
lane: LANE,
|
|
52
|
+
matched_install: MATCHED_INSTALL,
|
|
53
|
+
};
|
|
54
|
+
const os = hostOs(opts.platform);
|
|
55
|
+
if (os) out.os = os;
|
|
56
|
+
const wiki = wikiVersion(opts.env);
|
|
57
|
+
if (wiki) out.wiki_version = wiki;
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Platform tag sent to `/v1/agents/connect`. Identifies the host of this
|
|
63
|
+
* agent runtime — openclaw's bundled clawchat channel.
|
|
64
|
+
*/
|
|
65
|
+
export const AGENTS_CONNECT_PLATFORM = "openclaw" as const;
|
|
66
|
+
/**
|
|
67
|
+
* Agent type tag sent to `/v1/agents/connect`. The clawchat channel is
|
|
68
|
+
* always a bot; humans don't log in through this flow.
|
|
69
|
+
*/
|
|
70
|
+
export const AGENTS_CONNECT_TYPE = "clawbot" as const;
|
|
71
|
+
/**
|
|
72
|
+
* `/v1/agents/connect` envelope code for "the supplied user_id matches no
|
|
73
|
+
* agent". Servers that predate the server-side stale-user_id fallback return
|
|
74
|
+
* this instead of degrading to a fresh pairing, so the client sheds the id and
|
|
75
|
+
* retries once.
|
|
76
|
+
*/
|
|
77
|
+
export const AGENT_NOT_FOUND_CODE = 16001 as const;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Optional onboarding facts the AGENT writes after following the connection
|
|
7
|
+
* wiki: the id of the field report it filed there, and its self-assessed
|
|
8
|
+
* capability tier. The plugin only relays them on the plugin-report call; it
|
|
9
|
+
* never files a wiki report itself. Same directory as the greeting override
|
|
10
|
+
* (`~/clawchat/`), so the agent already knows the place.
|
|
11
|
+
*/
|
|
12
|
+
export interface OnboardingReportFields {
|
|
13
|
+
wiki_report_id?: string;
|
|
14
|
+
capability_tier?: number;
|
|
15
|
+
capability_ceiling?: number;
|
|
16
|
+
capabilities?: { headless?: boolean; mcp?: boolean; permission_hook?: boolean; session_line?: boolean };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const REPORT_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
20
|
+
const CAP_KEYS = ["headless", "mcp", "permission_hook", "session_line"] as const;
|
|
21
|
+
|
|
22
|
+
function tier(v: unknown): number | undefined {
|
|
23
|
+
return Number.isInteger(v) && (v as number) >= 1 && (v as number) <= 4 ? (v as number) : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read + validate `~/clawchat/onboarding.json`. Never throws: an unresolvable
|
|
28
|
+
* home directory, an absent/unreadable file, or malformed JSON all fall
|
|
29
|
+
* through to `null`, same as a file with no valid fields — this must be safe
|
|
30
|
+
* to call unconditionally from a best-effort report path.
|
|
31
|
+
*/
|
|
32
|
+
export function readOnboardingReport(homeDir?: string): OnboardingReportFields | null {
|
|
33
|
+
try {
|
|
34
|
+
const base = homeDir ?? os.homedir();
|
|
35
|
+
const file = path.join(base, "clawchat", "onboarding.json");
|
|
36
|
+
const raw: unknown = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
37
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
38
|
+
const o = raw as Record<string, unknown>;
|
|
39
|
+
const out: OnboardingReportFields = {};
|
|
40
|
+
if (typeof o.wiki_report_id === "string" && REPORT_ID_RE.test(o.wiki_report_id)) out.wiki_report_id = o.wiki_report_id;
|
|
41
|
+
const t = tier(o.capability_tier);
|
|
42
|
+
if (t !== undefined) out.capability_tier = t;
|
|
43
|
+
const c = tier(o.capability_ceiling);
|
|
44
|
+
// The backend rejects the WHOLE report (22004) when ceiling < tier, which
|
|
45
|
+
// would silently stop the version row updating. Keep the tier, drop the
|
|
46
|
+
// inconsistent ceiling.
|
|
47
|
+
if (c !== undefined && (t === undefined || c >= t)) out.capability_ceiling = c;
|
|
48
|
+
if (o.capabilities && typeof o.capabilities === "object") {
|
|
49
|
+
const caps: NonNullable<OnboardingReportFields["capabilities"]> = {};
|
|
50
|
+
for (const k of CAP_KEYS) {
|
|
51
|
+
const v = (o.capabilities as Record<string, unknown>)[k];
|
|
52
|
+
if (typeof v === "boolean") caps[k] = v;
|
|
53
|
+
}
|
|
54
|
+
if (Object.keys(caps).length > 0) out.capabilities = caps;
|
|
55
|
+
}
|
|
56
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/plugin-report.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { createRequire } from "node:module";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { createOpenclawClawlingApiClient } from "./api-client.ts";
|
|
5
|
+
import type { OnboardingReportFields } from "./onboarding-report.ts";
|
|
5
6
|
|
|
6
7
|
const PACKAGE_NAME = "@clawling/clawchat-plugin-openclaw";
|
|
7
8
|
|
|
@@ -48,6 +49,8 @@ export interface ReportParams {
|
|
|
48
49
|
pluginVersion: string;
|
|
49
50
|
agentVersion: string;
|
|
50
51
|
authenticated: boolean;
|
|
52
|
+
/** Agent-written onboarding facts (`~/clawchat/onboarding.json`); optional. */
|
|
53
|
+
onboarding?: OnboardingReportFields | null;
|
|
51
54
|
log?: { debug?: (msg: string) => void };
|
|
52
55
|
}
|
|
53
56
|
|
|
@@ -70,6 +73,7 @@ export async function reportPluginVersionSafe(p: ReportParams): Promise<void> {
|
|
|
70
73
|
agentVersion: p.agentVersion,
|
|
71
74
|
runtimeName: "node",
|
|
72
75
|
runtimeVersion: process.version,
|
|
76
|
+
onboarding: p.onboarding ?? null,
|
|
73
77
|
},
|
|
74
78
|
{ authenticated: p.authenticated },
|
|
75
79
|
);
|
package/src/protocol-types.ts
CHANGED
|
@@ -17,6 +17,7 @@ export const EVENT = {
|
|
|
17
17
|
CHAT_METADATA_INVALIDATED: "chat.metadata.invalidated",
|
|
18
18
|
NOTIFY_SIGNAL: "notify.signal",
|
|
19
19
|
REPLAY_DONE: "replay.done",
|
|
20
|
+
HISTORY_TRUNCATED: "history.truncated",
|
|
20
21
|
OFFLINE_BATCH: "offline.batch",
|
|
21
22
|
OFFLINE_ACK: "offline.ack",
|
|
22
23
|
OFFLINE_DONE: "offline.done",
|
package/src/refresh-manager.ts
CHANGED
|
@@ -30,6 +30,15 @@ const HOUR_MS = 60 * MINUTE_MS;
|
|
|
30
30
|
export const MIN_REFRESH_INTERVAL_MS = 30_000;
|
|
31
31
|
/** §A.1 — proactive jitter (±5min). */
|
|
32
32
|
export const PROACTIVE_JITTER_MS = 5 * MINUTE_MS;
|
|
33
|
+
/**
|
|
34
|
+
* §B retry within the grace window — jitter bounds for the one-shot retry after
|
|
35
|
+
* a transient proactive refresh. The retry is due `MIN_REFRESH_INTERVAL_MS` +
|
|
36
|
+
* [1s, 5s] after the failed attempt BEGAN (31–35s): far above the backend's
|
|
37
|
+
* minimum replay age, inside its 90s replay window, and never faster than the
|
|
38
|
+
* min-interval floor (which would skip it).
|
|
39
|
+
*/
|
|
40
|
+
export const PROACTIVE_RETRY_JITTER_MIN_MS = 1_000;
|
|
41
|
+
export const PROACTIVE_RETRY_JITTER_MAX_MS = 5_000;
|
|
33
42
|
/** §A.0 — fallback access-token TTL when `exp` is unparseable. */
|
|
34
43
|
export const ACCESS_TOKEN_TTL_MS = 24 * HOUR_MS;
|
|
35
44
|
/**
|
|
@@ -86,6 +95,8 @@ export interface RefreshManagerPorts {
|
|
|
86
95
|
clearTimer?: (handle: TimerHandle) => void;
|
|
87
96
|
/** Test override for jitter in [-PROACTIVE_JITTER_MS, +PROACTIVE_JITTER_MS]. */
|
|
88
97
|
jitter?: () => number;
|
|
98
|
+
/** Test override for the proactive-retry jitter (clamped to [1s, 5s]). */
|
|
99
|
+
proactiveRetryJitter?: () => number;
|
|
89
100
|
log?: { debug?: (m: string) => void; info?: (m: string) => void; error?: (m: string) => void };
|
|
90
101
|
}
|
|
91
102
|
|
|
@@ -136,6 +147,8 @@ export class RefreshManager {
|
|
|
136
147
|
/** §A.3 — epoch-ms of the last refresh attempt (any token). */
|
|
137
148
|
private lastAttemptAt = 0;
|
|
138
149
|
private proactiveTimer: TimerHandle | null = null;
|
|
150
|
+
/** §B — the pending one-shot retry after a transient proactive refresh. */
|
|
151
|
+
private proactiveRetryTimer: TimerHandle | null = null;
|
|
139
152
|
private stopped = false;
|
|
140
153
|
|
|
141
154
|
constructor(private readonly ports: RefreshManagerPorts) {}
|
|
@@ -214,9 +227,10 @@ export class RefreshManager {
|
|
|
214
227
|
// sqlite-sourced agent must not keep a now-dead refresh token in its row
|
|
215
228
|
// while running on the rotated token. Treat as transient so the WS stays
|
|
216
229
|
// in backoff with the CURRENT tokens and the next attempt retries. The
|
|
217
|
-
// server already rotated
|
|
218
|
-
//
|
|
219
|
-
//
|
|
230
|
+
// server already rotated: inside the backend grace window the retry
|
|
231
|
+
// redeems the old token again; after it the retry returns `code:10003`
|
|
232
|
+
// (which escalates to permanent per §B) — the accepted hazard, not a
|
|
233
|
+
// silent brick.
|
|
220
234
|
try {
|
|
221
235
|
await this.ports.persistRotatedTokens({
|
|
222
236
|
accessToken: result.accessToken,
|
|
@@ -243,8 +257,9 @@ export class RefreshManager {
|
|
|
243
257
|
}
|
|
244
258
|
|
|
245
259
|
if (result.kind === "permanent") {
|
|
246
|
-
// §B race — a `code:10003` is also returned for a
|
|
247
|
-
//
|
|
260
|
+
// §B race — a `code:10003` is also returned for a refresh token already
|
|
261
|
+
// CONSUMED by a prior successful rotation (once the backend grace window
|
|
262
|
+
// no longer covers it). Before auto-logging-out
|
|
248
263
|
// (which wipes credentials and bricks the agent), distinguish that race
|
|
249
264
|
// from a genuine revocation. It is a race when EITHER the submitted token
|
|
250
265
|
// is one we already rotated away from, OR the live store refresh token has
|
|
@@ -331,12 +346,24 @@ export class RefreshManager {
|
|
|
331
346
|
* success, hands the rotated token to the runtime's `onProactiveRefreshed` port
|
|
332
347
|
* so the live WS is closed and reconnected with the new token (the in-memory
|
|
333
348
|
* swap alone does NOT reach the running socket, which captured the old token at
|
|
334
|
-
* `connect` time). Transient/skipped outcomes leave the WS untouched
|
|
335
|
-
*
|
|
349
|
+
* `connect` time). Transient/skipped outcomes leave the WS untouched.
|
|
350
|
+
*
|
|
351
|
+
* §B retry within the grace window — a TRANSIENT outcome (e.g. the attempt
|
|
352
|
+
* deadline hit after the server may already have rotated) arms ONE retry, due
|
|
353
|
+
* 31–35s after that attempt began, so a replay of the same refresh token lands
|
|
354
|
+
* inside the backend grace window instead of waiting for the next arm /
|
|
355
|
+
* hello-fail / 401, possibly hours later. The retry itself never arms another.
|
|
356
|
+
* Success, permanent and skipped outcomes arm nothing (skipped means another
|
|
357
|
+
* in-flight attempt or a latch already owns the token).
|
|
336
358
|
*/
|
|
337
|
-
private async runProactiveRefresh(): Promise<void> {
|
|
338
|
-
const
|
|
359
|
+
private async runProactiveRefresh(isRetry = false): Promise<void> {
|
|
360
|
+
const accessTokenAtAttempt = this.ports.getAccessToken();
|
|
361
|
+
const outcome = await this.refresh(isRetry ? "proactive-retry" : "proactive-timer");
|
|
339
362
|
if (this.stopped) return;
|
|
363
|
+
if (outcome.kind === "transient") {
|
|
364
|
+
if (!isRetry) this.armProactiveRetry(accessTokenAtAttempt);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
340
367
|
if (outcome.kind !== "success") return;
|
|
341
368
|
if (this.ports.onProactiveRefreshed) {
|
|
342
369
|
try {
|
|
@@ -352,6 +379,50 @@ export class RefreshManager {
|
|
|
352
379
|
}
|
|
353
380
|
}
|
|
354
381
|
|
|
382
|
+
/**
|
|
383
|
+
* §B — arm the one-shot retry, measured from the failed attempt's start
|
|
384
|
+
* (`lastAttemptAt`). It runs through `refresh()`, so single-flight dedupe, the
|
|
385
|
+
* rejected-token latch and the min-interval floor all still apply. It is
|
|
386
|
+
* skipped when the access token changed meanwhile (someone else rotated).
|
|
387
|
+
*/
|
|
388
|
+
private armProactiveRetry(accessTokenAtAttempt: string): void {
|
|
389
|
+
this.clearProactiveRetry();
|
|
390
|
+
const rawJitter = (this.ports.proactiveRetryJitter ?? defaultProactiveRetryJitter)();
|
|
391
|
+
const jitterMs = Math.min(
|
|
392
|
+
PROACTIVE_RETRY_JITTER_MAX_MS,
|
|
393
|
+
Math.max(PROACTIVE_RETRY_JITTER_MIN_MS, rawJitter),
|
|
394
|
+
);
|
|
395
|
+
const dueAtMs = this.lastAttemptAt + MIN_REFRESH_INTERVAL_MS + jitterMs;
|
|
396
|
+
const delayMs = Math.max(0, dueAtMs - this.now());
|
|
397
|
+
this.ports.log?.info?.(
|
|
398
|
+
`clawchat-plugin-openclaw proactive refresh transient; one-shot retry in ${delayMs}ms`,
|
|
399
|
+
);
|
|
400
|
+
this.proactiveRetryTimer = this.setTimer(() => {
|
|
401
|
+
this.proactiveRetryTimer = null;
|
|
402
|
+
if (this.stopped) return;
|
|
403
|
+
if (this.ports.getAccessToken() !== accessTokenAtAttempt) {
|
|
404
|
+
this.ports.log?.debug?.(
|
|
405
|
+
"clawchat-plugin-openclaw proactive retry skipped (access token already changed)",
|
|
406
|
+
);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
void this.runProactiveRefresh(true);
|
|
410
|
+
}, delayMs);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private clearProactiveRetry(): void {
|
|
414
|
+
if (this.proactiveRetryTimer != null) {
|
|
415
|
+
this.clearTimer(this.proactiveRetryTimer);
|
|
416
|
+
this.proactiveRetryTimer = null;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Clear the proactive `refresh_at` timer (called on WS disconnect). The §B
|
|
422
|
+
* one-shot retry is deliberately NOT cleared here: a refresh timeout usually
|
|
423
|
+
* coincides with the network drop that also closes the socket, and refresh
|
|
424
|
+
* does not need the socket. `stop()` clears both.
|
|
425
|
+
*/
|
|
355
426
|
disarmProactiveTimer(): void {
|
|
356
427
|
if (this.proactiveTimer != null) {
|
|
357
428
|
this.clearTimer(this.proactiveTimer);
|
|
@@ -377,10 +448,11 @@ export class RefreshManager {
|
|
|
377
448
|
return this.now() >= refreshAtMs;
|
|
378
449
|
}
|
|
379
450
|
|
|
380
|
-
/** Stop the manager — clears the proactive
|
|
451
|
+
/** Stop the manager — clears the proactive + retry timers; nothing further arms. */
|
|
381
452
|
stop(): void {
|
|
382
453
|
this.stopped = true;
|
|
383
454
|
this.disarmProactiveTimer();
|
|
455
|
+
this.clearProactiveRetry();
|
|
384
456
|
}
|
|
385
457
|
|
|
386
458
|
/** Test/inspection seam — the latched (rejected) access token, if any. */
|
|
@@ -435,3 +507,10 @@ function decodeJwtIat(token: string): number | null {
|
|
|
435
507
|
function defaultJitter(): number {
|
|
436
508
|
return (Math.random() * 2 - 1) * PROACTIVE_JITTER_MS;
|
|
437
509
|
}
|
|
510
|
+
|
|
511
|
+
function defaultProactiveRetryJitter(): number {
|
|
512
|
+
return (
|
|
513
|
+
PROACTIVE_RETRY_JITTER_MIN_MS +
|
|
514
|
+
Math.random() * (PROACTIVE_RETRY_JITTER_MAX_MS - PROACTIVE_RETRY_JITTER_MIN_MS)
|
|
515
|
+
);
|
|
516
|
+
}
|