@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/dist/src/api-client.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ClawlingApiError, } from "./api-types.js";
|
|
2
2
|
import { CHANNEL_ID } from "./config.js";
|
|
3
3
|
export function buildPluginReportBody(input) {
|
|
4
|
-
|
|
4
|
+
const body = {
|
|
5
5
|
device_id: input.deviceId,
|
|
6
6
|
platform: input.platform,
|
|
7
7
|
plugin_version: input.pluginVersion,
|
|
@@ -9,6 +9,16 @@ export function buildPluginReportBody(input) {
|
|
|
9
9
|
runtime_name: input.runtimeName,
|
|
10
10
|
runtime_version: input.runtimeVersion,
|
|
11
11
|
};
|
|
12
|
+
const ob = input.onboarding;
|
|
13
|
+
if (ob?.wiki_report_id !== undefined)
|
|
14
|
+
body.wiki_report_id = ob.wiki_report_id;
|
|
15
|
+
if (ob?.capability_tier !== undefined)
|
|
16
|
+
body.capability_tier = ob.capability_tier;
|
|
17
|
+
if (ob?.capability_ceiling !== undefined)
|
|
18
|
+
body.capability_ceiling = ob.capability_ceiling;
|
|
19
|
+
if (ob?.capabilities !== undefined)
|
|
20
|
+
body.capabilities = ob.capabilities;
|
|
21
|
+
return body;
|
|
12
22
|
}
|
|
13
23
|
/**
|
|
14
24
|
* §A.0 — decode the access token's `exp` claim locally (base64url-decode the
|
|
@@ -42,13 +52,23 @@ const CODE_INTERNAL = 1; // CodeInternal — transient.
|
|
|
42
52
|
const CODE_BAD_REQUEST = 400; // bad body / device id — permanent (client bug).
|
|
43
53
|
/**
|
|
44
54
|
* CodeInvalidRefresh — returned for BOTH a genuinely revoked/invalid refresh
|
|
45
|
-
* token AND a
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* `
|
|
49
|
-
*
|
|
55
|
+
* token AND a refresh token that was already CONSUMED by a prior successful
|
|
56
|
+
* rotation (a duplicate-supervisor / concurrent-refresh / stale-store race), once
|
|
57
|
+
* the backend's short replay grace window no longer covers it. The stateless
|
|
58
|
+
* `authRefresh` cannot tell the two apart, so it reports `permanent`; the
|
|
59
|
+
* stateful `RefreshManager` re-classifies a consumed-rotation race back to
|
|
60
|
+
* transient (see §B race) before any auto-logout.
|
|
50
61
|
*/
|
|
51
62
|
export const CODE_INVALID_REFRESH = 10003;
|
|
63
|
+
/**
|
|
64
|
+
* §B attempt deadline — total wall clock for one refresh attempt (request AND
|
|
65
|
+
* response body). A hit aborts the request and is TRANSIENT. Kept below
|
|
66
|
+
* `MIN_REFRESH_INTERVAL_MS` (30s) so the min-interval floor, not the deadline,
|
|
67
|
+
* sets the retry cadence: a retry of the same refresh token then lands inside
|
|
68
|
+
* the backend refresh grace window if the server rotated but the response was
|
|
69
|
+
* lost.
|
|
70
|
+
*/
|
|
71
|
+
export const REFRESH_REQUEST_TIMEOUT_MS = 20_000;
|
|
52
72
|
/**
|
|
53
73
|
* §0/§B — call `POST /v1/auth/refresh` to rotate the access+refresh token.
|
|
54
74
|
*
|
|
@@ -58,6 +78,11 @@ export const CODE_INVALID_REFRESH = 10003;
|
|
|
58
78
|
* always HTTP 200 — branch on the envelope `code`, NOT on HTTP status. This is
|
|
59
79
|
* a standalone function (not a method on the token-bearing client) precisely
|
|
60
80
|
* because no bearer token participates.
|
|
81
|
+
*
|
|
82
|
+
* The whole attempt is bounded by `REFRESH_REQUEST_TIMEOUT_MS` (§B attempt
|
|
83
|
+
* deadline): the request is aborted via its signal, and the deadline is also
|
|
84
|
+
* raced against the attempt so a fetch implementation that ignores the signal
|
|
85
|
+
* cannot hang the caller.
|
|
61
86
|
*/
|
|
62
87
|
export async function authRefresh(opts, params) {
|
|
63
88
|
const baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
@@ -65,6 +90,26 @@ export async function authRefresh(opts, params) {
|
|
|
65
90
|
if (!params.refreshToken?.trim()) {
|
|
66
91
|
return { kind: "permanent", code: CODE_INVALID_REFRESH, message: "missing refresh token" };
|
|
67
92
|
}
|
|
93
|
+
const timeoutMs = opts.timeoutMs ?? REFRESH_REQUEST_TIMEOUT_MS;
|
|
94
|
+
const controller = new AbortController();
|
|
95
|
+
let timer;
|
|
96
|
+
const deadline = new Promise((resolve) => {
|
|
97
|
+
timer = setTimeout(() => {
|
|
98
|
+
controller.abort(new Error("refresh request timed out"));
|
|
99
|
+
resolve({ kind: "transient", message: `refresh request timed out after ${timeoutMs}ms` });
|
|
100
|
+
}, timeoutMs);
|
|
101
|
+
});
|
|
102
|
+
try {
|
|
103
|
+
return await Promise.race([
|
|
104
|
+
authRefreshAttempt(baseUrl, fetchImpl, params, controller.signal),
|
|
105
|
+
deadline,
|
|
106
|
+
]);
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function authRefreshAttempt(baseUrl, fetchImpl, params, signal) {
|
|
68
113
|
let res;
|
|
69
114
|
try {
|
|
70
115
|
res = await fetchImpl(`${baseUrl}/v1/auth/refresh`, {
|
|
@@ -75,6 +120,8 @@ export async function authRefresh(opts, params) {
|
|
|
75
120
|
"x-device-id": params.deviceId,
|
|
76
121
|
},
|
|
77
122
|
body: JSON.stringify({ refresh_token: params.refreshToken.trim() }),
|
|
123
|
+
// Still in effect while the body is read below.
|
|
124
|
+
signal,
|
|
78
125
|
});
|
|
79
126
|
}
|
|
80
127
|
catch (err) {
|
|
@@ -117,8 +164,9 @@ export async function authRefresh(opts, params) {
|
|
|
117
164
|
const refreshToken = typeof data.refresh_token === "string" ? data.refresh_token : "";
|
|
118
165
|
if (!accessToken || !refreshToken) {
|
|
119
166
|
// Rotation succeeded server-side but the body is malformed — transient so
|
|
120
|
-
// we retry; the
|
|
121
|
-
//
|
|
167
|
+
// we retry; inside the backend grace window the retry redeems the old
|
|
168
|
+
// token again, after it the retry returns 10003 and escalates to
|
|
169
|
+
// permanent (§B transient→permanent).
|
|
122
170
|
return { kind: "transient", status: 200, message: "refresh: rotation body incomplete" };
|
|
123
171
|
}
|
|
124
172
|
return { kind: "success", accessToken, refreshToken };
|
|
@@ -396,6 +444,13 @@ export function createOpenclawClawlingApiClient(opts) {
|
|
|
396
444
|
async deleteMomentComment(params) {
|
|
397
445
|
return await call("DELETE", `/v1/moments/${encodeURIComponent(String(params.momentId))}/comments/${encodeURIComponent(String(params.commentId))}`);
|
|
398
446
|
},
|
|
447
|
+
async getDirectConversation(peerId) {
|
|
448
|
+
assertNonBlankId(peerId, "getDirectConversation: peerId");
|
|
449
|
+
return await call("POST", "/v1/conversations/direct", {
|
|
450
|
+
body: JSON.stringify({ peer_id: peerId.trim() }),
|
|
451
|
+
headers: { "content-type": "application/json" },
|
|
452
|
+
});
|
|
453
|
+
},
|
|
399
454
|
async getConversation(conversationId) {
|
|
400
455
|
return await call("GET", `/v1/conversations/${encodeURIComponent(conversationId)}`);
|
|
401
456
|
},
|
|
@@ -427,7 +482,24 @@ export function createOpenclawClawlingApiClient(opts) {
|
|
|
427
482
|
headers: { "content-type": "application/json" },
|
|
428
483
|
});
|
|
429
484
|
},
|
|
430
|
-
async
|
|
485
|
+
async agentsConnectCheck({ code, platform, user_id: userId, context }) {
|
|
486
|
+
if (!code?.trim()) {
|
|
487
|
+
throw new ClawlingApiError("validation", "agentsConnectCheck: code is required");
|
|
488
|
+
}
|
|
489
|
+
const body = { code: code.trim(), platform: platform.trim() };
|
|
490
|
+
if (userId?.trim())
|
|
491
|
+
body.user_id = userId.trim();
|
|
492
|
+
if (opts.pluginVersion?.trim())
|
|
493
|
+
body.plugin_version = opts.pluginVersion.trim();
|
|
494
|
+
for (const [k, v] of Object.entries(context ?? {}))
|
|
495
|
+
if (v)
|
|
496
|
+
body[k] = v;
|
|
497
|
+
return await call("POST", "/v1/agents/connect/check", {
|
|
498
|
+
headers: { "content-type": "application/json" },
|
|
499
|
+
body: JSON.stringify(body),
|
|
500
|
+
});
|
|
501
|
+
},
|
|
502
|
+
async agentsConnect({ code: inviteCode, platform, type, user_id: userId, context }) {
|
|
431
503
|
if (!inviteCode?.trim()) {
|
|
432
504
|
throw new ClawlingApiError("validation", "agentsConnect: inviteCode is required");
|
|
433
505
|
}
|
|
@@ -448,6 +520,9 @@ export function createOpenclawClawlingApiClient(opts) {
|
|
|
448
520
|
if (opts.pluginVersion?.trim()) {
|
|
449
521
|
body.plugin_version = opts.pluginVersion.trim();
|
|
450
522
|
}
|
|
523
|
+
for (const [k, v] of Object.entries(context ?? {}))
|
|
524
|
+
if (v)
|
|
525
|
+
body[k] = v;
|
|
451
526
|
return await call("POST", "/v1/agents/connect", {
|
|
452
527
|
// `X-Device-Id` is added globally via `authHeaders` on every request.
|
|
453
528
|
headers: { "content-type": "application/json" },
|
package/dist/src/config.js
CHANGED
|
@@ -83,6 +83,7 @@ export const openclawClawlingAccountConfigSchema = {
|
|
|
83
83
|
forwardToolCalls: { type: "boolean" },
|
|
84
84
|
richInteractions: { type: "boolean" },
|
|
85
85
|
awarenessNote: { type: "boolean" },
|
|
86
|
+
friendGreeting: { type: "boolean" },
|
|
86
87
|
livewareSample: { type: "boolean" },
|
|
87
88
|
reconnect: {
|
|
88
89
|
type: "object",
|
|
@@ -448,6 +449,7 @@ export function resolveOpenclawClawlingAccount(cfg, accountId, env = process.env
|
|
|
448
449
|
const forwardToolCalls = typeof channel.forwardToolCalls === "boolean" ? channel.forwardToolCalls : false;
|
|
449
450
|
const richInteractions = typeof channel.richInteractions === "boolean" ? channel.richInteractions : false;
|
|
450
451
|
const awarenessNote = typeof channel.awarenessNote === "boolean" ? channel.awarenessNote : false;
|
|
452
|
+
const friendGreeting = typeof channel.friendGreeting === "boolean" ? channel.friendGreeting : true;
|
|
451
453
|
const livewareSample = typeof channel.livewareSample === "boolean" ? channel.livewareSample : true;
|
|
452
454
|
return {
|
|
453
455
|
accountId: resolvedAccountId,
|
|
@@ -475,6 +477,7 @@ export function resolveOpenclawClawlingAccount(cfg, accountId, env = process.env
|
|
|
475
477
|
forwardToolCalls,
|
|
476
478
|
richInteractions,
|
|
477
479
|
awarenessNote,
|
|
480
|
+
friendGreeting,
|
|
478
481
|
livewareSample,
|
|
479
482
|
allowFrom: [],
|
|
480
483
|
reconnect: readReconnect(channel.reconnect),
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { ClawlingApiError } from "./api-types.js";
|
|
2
|
+
import { AGENTS_CONNECT_PLATFORM, RECONNECT_GUIDE_URL } from "./onboarding-context.js";
|
|
3
|
+
/**
|
|
4
|
+
* Non-consuming pre-check of a connect code. Records the "checked" funnel
|
|
5
|
+
* stage server-side and tells us whether the code is bound to an existing
|
|
6
|
+
* agent (the reconnect prompt). Any transport-level failure — older backend,
|
|
7
|
+
* rate limit, network — degrades to `null`: the pre-check is telemetry plus a
|
|
8
|
+
* courtesy, never a gate in front of `/connect`.
|
|
9
|
+
*/
|
|
10
|
+
export async function preCheckConnectCode(client, input, log) {
|
|
11
|
+
try {
|
|
12
|
+
const res = await client.agentsConnectCheck({
|
|
13
|
+
code: input.code,
|
|
14
|
+
platform: AGENTS_CONNECT_PLATFORM,
|
|
15
|
+
...(input.userId ? { user_id: input.userId } : {}),
|
|
16
|
+
...(input.context ? { context: input.context } : {}),
|
|
17
|
+
});
|
|
18
|
+
// A 200 with no data says nothing about the code: degrade exactly like an
|
|
19
|
+
// unreachable endpoint (Hermes' `evaluate_precheck({})` does the same).
|
|
20
|
+
if (!res || typeof res !== "object" || Object.keys(res).length === 0) {
|
|
21
|
+
log("Connect-code pre-check returned no data; continuing without it.");
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
pairable: res.pairable === true,
|
|
26
|
+
status: typeof res.status === "string" ? res.status : "",
|
|
27
|
+
boundAgent: res.bound_agent === true,
|
|
28
|
+
userIdStatus: typeof res.user_id_status === "string" ? res.user_id_status : "",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
const kind = err instanceof ClawlingApiError ? err.kind : "error";
|
|
33
|
+
log(`Connect-code pre-check unavailable (${kind}); continuing without it.`);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Owner-facing explanation for `pairable: false`. No flags, no minutes, one URL. */
|
|
38
|
+
export function unpairableMessage(pre) {
|
|
39
|
+
if (pre.userIdStatus === "owner_mismatch" && pre.boundAgent) {
|
|
40
|
+
// The server cannot tell us whether the bound agent shares this identity's
|
|
41
|
+
// owner, only that it is a different agent — so say exactly that.
|
|
42
|
+
return ("This connect code is the reconnect prompt for a different agent than the identity stored here. " +
|
|
43
|
+
"Ask your owner to send the reconnect prompt from THIS agent's chat in the ClawChat app.");
|
|
44
|
+
}
|
|
45
|
+
if (pre.userIdStatus === "invalid") {
|
|
46
|
+
return ("The identity stored here is not a valid ClawChat user id, so it cannot be restored. " +
|
|
47
|
+
"Activate as a brand-new agent with --new-account.");
|
|
48
|
+
}
|
|
49
|
+
if (pre.userIdStatus === "owner_mismatch") {
|
|
50
|
+
return ("This connect code belongs to a different ClawChat account than the identity stored here. " +
|
|
51
|
+
"Ask the owner of THIS agent for a code, or activate as a brand-new agent with --new-account.");
|
|
52
|
+
}
|
|
53
|
+
switch (pre.status) {
|
|
54
|
+
case "paired":
|
|
55
|
+
return ("This connect code was already redeemed. If this agent lost its connection, ask your owner " +
|
|
56
|
+
`to send you the reconnect prompt from the ClawChat app and follow ${RECONNECT_GUIDE_URL}; ` +
|
|
57
|
+
"otherwise ask for a fresh code.");
|
|
58
|
+
case "expired":
|
|
59
|
+
case "invalid":
|
|
60
|
+
return `This connect code is ${pre.status}. Ask your owner for a fresh code from the ClawChat app.`;
|
|
61
|
+
default:
|
|
62
|
+
return `This connect code is not pairable (status=${pre.status || "unknown"}). Ask your owner for a fresh code from the ClawChat app.`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Refusal for a new-identity intent (`--new-account` or the interactive
|
|
67
|
+
* choice) on a bound code: without a user_id, `/connect` restores the bound
|
|
68
|
+
* agent instead of creating one. No flags, no minutes, no URL.
|
|
69
|
+
*/
|
|
70
|
+
export function boundCodeNewIdentityMessage() {
|
|
71
|
+
return ("This is a reconnect code bound to an existing agent, so it cannot create a new agent. " +
|
|
72
|
+
"Ask your owner for a normal connect code from the ClawChat app, or use this reconnect prompt " +
|
|
73
|
+
"on the agent it belongs to.");
|
|
74
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First message to a newly added NON-owner friend.
|
|
3
|
+
*
|
|
4
|
+
* `friend.added` used to be a pure awareness event. The server creates the
|
|
5
|
+
* direct conversation inside the friend-accept transaction, but the signal
|
|
6
|
+
* only carries the counterparty `usr_…` — so the runtime resolves the
|
|
7
|
+
* conversation through `POST /v1/conversations/direct` and then feeds ONE
|
|
8
|
+
* synthetic inbound turn (built here) into the normal dispatch path, the same
|
|
9
|
+
* way the activation bootstrap greets the owner.
|
|
10
|
+
*
|
|
11
|
+
* The prompt is deliberately distinct from the owner activation prompt: this
|
|
12
|
+
* reader is a stranger, so "you are connected and ready" makes no sense and
|
|
13
|
+
* the agent must say whose agent it is instead.
|
|
14
|
+
*/
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { EVENT } from "./protocol-types.js";
|
|
19
|
+
export const FRIEND_GREETING_FALLBACK = [
|
|
20
|
+
"A ClawChat user has just become your friend. You are now in a direct conversation with them; they are not your owner.",
|
|
21
|
+
"Reply now with one short, friendly greeting message in this conversation: introduce yourself by name, say you are an AI agent acting on behalf of your owner, and invite them to tell you what they need.",
|
|
22
|
+
"Send it as a normal chat reply. Do not write or create any files or notes, and do not call tools just to greet.",
|
|
23
|
+
"Do not share your owner's private information, and do not ask the user for personal information.",
|
|
24
|
+
].join("\n");
|
|
25
|
+
// Cross-plugin, user-editable override read lazily so edits apply on the next
|
|
26
|
+
// friend without a restart. Any read failure falls back to the built-in text.
|
|
27
|
+
// Mirrors `buildActivationBootstrapText` (`~/clawchat/greeting.md`).
|
|
28
|
+
export function buildFriendGreetingText(homeDir = os.homedir()) {
|
|
29
|
+
const greetingPath = path.join(homeDir, "clawchat", "friend-greeting.md");
|
|
30
|
+
try {
|
|
31
|
+
const raw = fs.readFileSync(greetingPath);
|
|
32
|
+
const override = new TextDecoder("utf-8", { fatal: true }).decode(raw).trim();
|
|
33
|
+
if (override.length > 0) {
|
|
34
|
+
return override;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
const code = error.code;
|
|
39
|
+
if (code !== "ENOENT") {
|
|
40
|
+
console.warn(`clawchat.friend-greeting failed to read override ${greetingPath}:`, error);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return FRIEND_GREETING_FALLBACK;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Synthetic inbound envelope for the friend greeting turn. Same invariant as
|
|
47
|
+
* `buildActivationBootstrapEnvelope`: `conversationId` MUST be a conversation
|
|
48
|
+
* idcode — the agent's reply inherits this chat_id, and anything else is
|
|
49
|
+
* refused at the outbound boundary after a full LLM turn has been spent.
|
|
50
|
+
*/
|
|
51
|
+
export function buildFriendGreetingEnvelope(params) {
|
|
52
|
+
const { account, conversationId, friendUserId } = params;
|
|
53
|
+
const text = buildFriendGreetingText();
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
return {
|
|
56
|
+
version: "2",
|
|
57
|
+
event: EVENT.MESSAGE_SEND,
|
|
58
|
+
trace_id: `clawchat-plugin-openclaw-friend-greeting-${now}`,
|
|
59
|
+
emitted_at: now,
|
|
60
|
+
chat_id: conversationId,
|
|
61
|
+
chat_type: "direct",
|
|
62
|
+
to: { id: account.userId, type: "direct" },
|
|
63
|
+
sender: { id: friendUserId, type: "direct", nick_name: "" },
|
|
64
|
+
payload: {
|
|
65
|
+
message_id: `clawchat-plugin-openclaw-friend-greeting-${conversationId}-${now}`,
|
|
66
|
+
message_mode: "normal",
|
|
67
|
+
message: {
|
|
68
|
+
body: { fragments: [{ kind: "text", text }] },
|
|
69
|
+
context: { mentions: [], reply: null },
|
|
70
|
+
streaming: {
|
|
71
|
+
status: "static",
|
|
72
|
+
sequence: 0,
|
|
73
|
+
mutation_policy: "sealed",
|
|
74
|
+
started_at: null,
|
|
75
|
+
completed_at: null,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -6,23 +6,12 @@ import { openclawClawlingAccountSlotPath, patchOpenclawClawlingAccountSection }
|
|
|
6
6
|
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
|
|
7
7
|
import { CHANNEL_ID, mergeOpenclawClawchatRuntimePluginActivation, mergeOpenclawClawchatToolAllow, normalizeOpenclawClawlingAccountId, readOpenclawClawlingAccountSection, resolveOpenclawClawlingAccount, } from "./config.js";
|
|
8
8
|
import { getClawChatStore } from "./storage.js";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
* Agent type tag sent to `/v1/agents/connect`. The clawchat channel is
|
|
16
|
-
* always a bot; humans don't log in through this flow.
|
|
17
|
-
*/
|
|
18
|
-
export const AGENTS_CONNECT_TYPE = "clawbot";
|
|
19
|
-
/**
|
|
20
|
-
* `/v1/agents/connect` envelope code for "the supplied user_id matches no
|
|
21
|
-
* agent". Servers that predate the server-side stale-user_id fallback return
|
|
22
|
-
* this instead of degrading to a fresh pairing, so the client sheds the id and
|
|
23
|
-
* retries once.
|
|
24
|
-
*/
|
|
25
|
-
export const AGENT_NOT_FOUND_CODE = 16001;
|
|
9
|
+
import { buildOnboardingContext, RECONNECT_GUIDE_URL } from "./onboarding-context.js";
|
|
10
|
+
import { boundCodeNewIdentityMessage, preCheckConnectCode, unpairableMessage } from "./connect-check.js";
|
|
11
|
+
// Re-exported: `src/login.runtime.test.ts` and `src/commands.ts` import these
|
|
12
|
+
// from here, so the historical import path keeps working after the move.
|
|
13
|
+
import { AGENTS_CONNECT_PLATFORM, AGENTS_CONNECT_TYPE, AGENT_NOT_FOUND_CODE, } from "./onboarding-context.js";
|
|
14
|
+
export { AGENTS_CONNECT_PLATFORM, AGENTS_CONNECT_TYPE, AGENT_NOT_FOUND_CODE };
|
|
26
15
|
/**
|
|
27
16
|
* Thrown when redeeming an invite code would silently replace a live activation.
|
|
28
17
|
*
|
|
@@ -43,9 +32,11 @@ export class ExistingActivationError extends Error {
|
|
|
43
32
|
"spent. Re-run stating the intent:\n" +
|
|
44
33
|
" - Pair as a BRAND-NEW agent (this instance stops using the identity " +
|
|
45
34
|
"above): /clawchat-activate <CODE> --new-account\n" +
|
|
46
|
-
" - RESTORE the identity above
|
|
47
|
-
"
|
|
48
|
-
"
|
|
35
|
+
" - RESTORE the identity above: do not spend a fresh code on it. Ask your " +
|
|
36
|
+
"owner to send you the reconnect prompt from the ClawChat app — its code is " +
|
|
37
|
+
"bound to this identity and usually restores it on its own; if activation " +
|
|
38
|
+
"still reports it as already paired, run it again with --repair " +
|
|
39
|
+
`(/clawchat-activate <CODE> --repair) — and follow ${RECONNECT_GUIDE_URL}\n` +
|
|
49
40
|
" - To run a SECOND agent alongside this one instead, activate it as a " +
|
|
50
41
|
"named account: /clawchat-activate <CODE> --account <name> (or: openclaw " +
|
|
51
42
|
"channels add --channel clawchat-plugin-openclaw --account <name> --token <CODE>)");
|
|
@@ -197,7 +188,50 @@ export async function runOpenclawClawlingLogin(params) {
|
|
|
197
188
|
// `DEFAULT_BASE_URL` / `DEFAULT_WEBSOCKET_URL` when the operator has not
|
|
198
189
|
// overridden them, so login works without a prior `openclaw channels setup --channel clawchat-plugin-openclaw`.
|
|
199
190
|
const account = resolveOpenclawClawlingAccount(cfg, accountId);
|
|
200
|
-
|
|
191
|
+
const inviteCode = (await (params.readInviteCode ?? (() => promptInviteCodeFromStdin(runtime)))()).trim();
|
|
192
|
+
if (!inviteCode) {
|
|
193
|
+
throw new Error("Login aborted: invite code is required.");
|
|
194
|
+
}
|
|
195
|
+
const apiClient = (params.apiClientFactory ?? createOpenclawClawlingApiClient)({
|
|
196
|
+
baseUrl: account.baseUrl,
|
|
197
|
+
mediaBaseUrl: account.mediaBaseUrl,
|
|
198
|
+
// Pre-login we may not have a token yet. Send the current one (or empty)
|
|
199
|
+
// — the server should accept an unauthenticated invite-code exchange.
|
|
200
|
+
token: account.token || "",
|
|
201
|
+
pluginVersion: resolvePluginVersion(),
|
|
202
|
+
});
|
|
203
|
+
// Telemetry the backend joins with wiki field reports; ignored by older servers.
|
|
204
|
+
const onboarding = buildOnboardingContext();
|
|
205
|
+
// Non-consuming pre-check. `null` = endpoint unavailable, carry on. A bound
|
|
206
|
+
// code (the owner's reconnect prompt) is the owner's own proof of intent, so
|
|
207
|
+
// it settles the "new agent or restore?" question below without a flag.
|
|
208
|
+
//
|
|
209
|
+
// The pre-check only carries the `user_id` that `/connect` would replay. An
|
|
210
|
+
// explicit `newAccount` never replays it, so it must not be judged against
|
|
211
|
+
// it either: the server marks `pairable:false` for an `owner_mismatch` /
|
|
212
|
+
// `invalid` id, and that would block the very escape the refusal recommends.
|
|
213
|
+
const storedUserId = account.userId.trim();
|
|
214
|
+
const precheckUserId = params.newAccount ? "" : storedUserId;
|
|
215
|
+
runtime.log("Checking the invite code …");
|
|
216
|
+
let precheck = await preCheckConnectCode(apiClient, { code: inviteCode, userId: precheckUserId || undefined, context: onboarding }, runtime.log);
|
|
217
|
+
// Whether the operator will be asked "new agent or restore?" below. When
|
|
218
|
+
// they will, a refusal caused only by the stored identity (not by the code)
|
|
219
|
+
// is deferred: choosing a brand-new identity drops that id, so the verdict
|
|
220
|
+
// on it no longer applies.
|
|
221
|
+
const identityUnsettled = storedUserId !== "" && account.token.trim() !== "" && !params.newAccount && !params.repair;
|
|
222
|
+
const refusedForIdentity = precheck !== null &&
|
|
223
|
+
!precheck.pairable &&
|
|
224
|
+
precheckUserId !== "" &&
|
|
225
|
+
// Only a live code: a dead one (expired / paired / invalid) refuses on its
|
|
226
|
+
// own account whatever identity is chosen, so there is nothing to defer.
|
|
227
|
+
precheck.status === "pending" &&
|
|
228
|
+
(precheck.userIdStatus === "owner_mismatch" || precheck.userIdStatus === "invalid");
|
|
229
|
+
const deferredRefusal = identityUnsettled && refusedForIdentity ? precheck : null;
|
|
230
|
+
if (precheck && !precheck.pairable && !deferredRefusal) {
|
|
231
|
+
throw new Error(unpairableMessage(precheck));
|
|
232
|
+
}
|
|
233
|
+
const boundToIncumbent = precheck?.pairable === true && precheck.boundAgent === true && precheckUserId !== "";
|
|
234
|
+
// Fork before the invite code is spent, so neither branch spends the code
|
|
201
235
|
// before the outcome is settled. "Live" is decided by whether a usable token
|
|
202
236
|
// remains: auto-logout (§C) blanks the tokens but preserves the identity, so a
|
|
203
237
|
// logged-out instance still re-pairs with no flag at all, which is the flow
|
|
@@ -207,46 +241,56 @@ export async function runOpenclawClawlingLogin(params) {
|
|
|
207
241
|
// ambiguous, because redeeming a code here can either mint a new agent or
|
|
208
242
|
// re-pair the incumbent one. Ask which, and only refuse when nobody answers.
|
|
209
243
|
// An explicit `newAccount` / `repair` from the caller has already settled it,
|
|
210
|
-
// so no prompt.
|
|
244
|
+
// so no prompt. Nor is one needed when the pre-check proved the code is the
|
|
245
|
+
// owner's own reconnect prompt for this very agent (`boundToIncumbent`) — a
|
|
246
|
+
// bound code can only ever restore the incumbent identity.
|
|
211
247
|
let newAccount = params.newAccount;
|
|
212
|
-
if (
|
|
248
|
+
if (identityUnsettled && !boundToIncumbent) {
|
|
213
249
|
const identity = account.agentId.trim()
|
|
214
|
-
? `agent ${account.agentId.trim()} (shadow user ${
|
|
215
|
-
: `agent ${
|
|
250
|
+
? `agent ${account.agentId.trim()} (shadow user ${storedUserId})`
|
|
251
|
+
: `agent ${storedUserId}`;
|
|
216
252
|
const intent = await (params.readActivationIntent ??
|
|
217
253
|
(() => promptActivationIntentFromStdin(runtime, identity)))();
|
|
218
254
|
// Only "new-account" changes what gets sent. Restoring needs no flag:
|
|
219
255
|
// replaying the stored `user_id` IS the re-pair, and that is already the
|
|
220
256
|
// default below — so choosing it simply means "don't refuse".
|
|
221
|
-
if (intent === "new-account")
|
|
257
|
+
if (intent === "new-account") {
|
|
222
258
|
newAccount = true;
|
|
259
|
+
if (deferredRefusal) {
|
|
260
|
+
// The first verdict judged the id we are now dropping; re-check the
|
|
261
|
+
// code on its own before spending it.
|
|
262
|
+
precheck = await preCheckConnectCode(apiClient, { code: inviteCode, context: onboarding }, runtime.log);
|
|
263
|
+
if (precheck && !precheck.pairable) {
|
|
264
|
+
throw new Error(unpairableMessage(precheck));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
else if (deferredRefusal) {
|
|
269
|
+
// Restoring (or nobody to ask) replays the id the server already refused.
|
|
270
|
+
throw new Error(unpairableMessage(deferredRefusal));
|
|
271
|
+
}
|
|
223
272
|
else if (intent === null) {
|
|
224
|
-
throw new ExistingActivationError(
|
|
273
|
+
throw new ExistingActivationError(storedUserId, account.agentId.trim());
|
|
225
274
|
}
|
|
226
275
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
276
|
+
// A new identity (flag or interactive choice) sends no user_id, and /connect
|
|
277
|
+
// without one on a bound code silently RESTORES the bound agent and spends
|
|
278
|
+
// the code. A reconnect code can never mint an agent, so refuse first.
|
|
279
|
+
if (newAccount && precheck?.boundAgent === true) {
|
|
280
|
+
throw new Error(boundCodeNewIdentityMessage());
|
|
230
281
|
}
|
|
231
|
-
const apiClient = (params.apiClientFactory ?? createOpenclawClawlingApiClient)({
|
|
232
|
-
baseUrl: account.baseUrl,
|
|
233
|
-
mediaBaseUrl: account.mediaBaseUrl,
|
|
234
|
-
// Pre-login we may not have a token yet. Send the current one (or empty)
|
|
235
|
-
// — the server should accept an unauthenticated invite-code exchange.
|
|
236
|
-
token: account.token || "",
|
|
237
|
-
pluginVersion: resolvePluginVersion(),
|
|
238
|
-
});
|
|
239
282
|
runtime.log("Verifying invite code …");
|
|
240
283
|
let result;
|
|
241
284
|
// A brand-new identity is precisely "do not replay" — the replay is the only
|
|
242
285
|
// thing that would bind this code to the incumbent agent.
|
|
243
|
-
const existingUserId = newAccount ? "" :
|
|
286
|
+
const existingUserId = newAccount ? "" : storedUserId;
|
|
244
287
|
try {
|
|
245
288
|
result = await apiClient.agentsConnect({
|
|
246
289
|
code: inviteCode,
|
|
247
290
|
platform: AGENTS_CONNECT_PLATFORM,
|
|
248
291
|
type: AGENTS_CONNECT_TYPE,
|
|
249
292
|
...(existingUserId ? { user_id: existingUserId } : {}),
|
|
293
|
+
context: onboarding,
|
|
250
294
|
});
|
|
251
295
|
}
|
|
252
296
|
catch (err) {
|
|
@@ -266,6 +310,7 @@ export async function runOpenclawClawlingLogin(params) {
|
|
|
266
310
|
code: inviteCode,
|
|
267
311
|
platform: AGENTS_CONNECT_PLATFORM,
|
|
268
312
|
type: AGENTS_CONNECT_TYPE,
|
|
313
|
+
context: onboarding,
|
|
269
314
|
});
|
|
270
315
|
}
|
|
271
316
|
catch (retryErr) {
|
|
@@ -0,0 +1,70 @@
|
|
|
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
|
+
export const AGENT_KIND = "openclaw";
|
|
13
|
+
export const LANE = "self";
|
|
14
|
+
export const MATCHED_INSTALL = "install/official-openclaw";
|
|
15
|
+
/** Public connection wiki. `/start.md` is the entry page; `/reconnect.md` the re-pair page. */
|
|
16
|
+
export const START_GUIDE_URL = "https://agent-connection.clawling.com/start.md";
|
|
17
|
+
export const RECONNECT_GUIDE_URL = "https://agent-connection.clawling.com/reconnect.md";
|
|
18
|
+
const WIKI_VERSION_MAX = 40;
|
|
19
|
+
/** Wiki `os` enum (windows | macos | linux); "" for anything else so callers omit it. */
|
|
20
|
+
export function hostOs(plat = process.platform) {
|
|
21
|
+
switch (plat) {
|
|
22
|
+
case "win32":
|
|
23
|
+
return "windows";
|
|
24
|
+
case "darwin":
|
|
25
|
+
return "macos";
|
|
26
|
+
case "linux":
|
|
27
|
+
return "linux";
|
|
28
|
+
default:
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The wiki build stamp the agent saw on the install page. The official install
|
|
34
|
+
* page tells the agent to export `CLAWCHAT_WIKI_VERSION` before activating; a
|
|
35
|
+
* hand-run `openclaw channels add` simply has none.
|
|
36
|
+
*/
|
|
37
|
+
export function wikiVersion(env = process.env) {
|
|
38
|
+
return (env.CLAWCHAT_WIKI_VERSION ?? "").trim().slice(0, WIKI_VERSION_MAX);
|
|
39
|
+
}
|
|
40
|
+
export function buildOnboardingContext(opts = {}) {
|
|
41
|
+
const out = {
|
|
42
|
+
agent_kind: AGENT_KIND,
|
|
43
|
+
lane: LANE,
|
|
44
|
+
matched_install: MATCHED_INSTALL,
|
|
45
|
+
};
|
|
46
|
+
const os = hostOs(opts.platform);
|
|
47
|
+
if (os)
|
|
48
|
+
out.os = os;
|
|
49
|
+
const wiki = wikiVersion(opts.env);
|
|
50
|
+
if (wiki)
|
|
51
|
+
out.wiki_version = wiki;
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Platform tag sent to `/v1/agents/connect`. Identifies the host of this
|
|
56
|
+
* agent runtime — openclaw's bundled clawchat channel.
|
|
57
|
+
*/
|
|
58
|
+
export const AGENTS_CONNECT_PLATFORM = "openclaw";
|
|
59
|
+
/**
|
|
60
|
+
* Agent type tag sent to `/v1/agents/connect`. The clawchat channel is
|
|
61
|
+
* always a bot; humans don't log in through this flow.
|
|
62
|
+
*/
|
|
63
|
+
export const AGENTS_CONNECT_TYPE = "clawbot";
|
|
64
|
+
/**
|
|
65
|
+
* `/v1/agents/connect` envelope code for "the supplied user_id matches no
|
|
66
|
+
* agent". Servers that predate the server-side stale-user_id fallback return
|
|
67
|
+
* this instead of degrading to a fresh pairing, so the client sheds the id and
|
|
68
|
+
* retries once.
|
|
69
|
+
*/
|
|
70
|
+
export const AGENT_NOT_FOUND_CODE = 16001;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const REPORT_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
5
|
+
const CAP_KEYS = ["headless", "mcp", "permission_hook", "session_line"];
|
|
6
|
+
function tier(v) {
|
|
7
|
+
return Number.isInteger(v) && v >= 1 && v <= 4 ? v : undefined;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Read + validate `~/clawchat/onboarding.json`. Never throws: an unresolvable
|
|
11
|
+
* home directory, an absent/unreadable file, or malformed JSON all fall
|
|
12
|
+
* through to `null`, same as a file with no valid fields — this must be safe
|
|
13
|
+
* to call unconditionally from a best-effort report path.
|
|
14
|
+
*/
|
|
15
|
+
export function readOnboardingReport(homeDir) {
|
|
16
|
+
try {
|
|
17
|
+
const base = homeDir ?? os.homedir();
|
|
18
|
+
const file = path.join(base, "clawchat", "onboarding.json");
|
|
19
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
20
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
21
|
+
return null;
|
|
22
|
+
const o = raw;
|
|
23
|
+
const out = {};
|
|
24
|
+
if (typeof o.wiki_report_id === "string" && REPORT_ID_RE.test(o.wiki_report_id))
|
|
25
|
+
out.wiki_report_id = o.wiki_report_id;
|
|
26
|
+
const t = tier(o.capability_tier);
|
|
27
|
+
if (t !== undefined)
|
|
28
|
+
out.capability_tier = t;
|
|
29
|
+
const c = tier(o.capability_ceiling);
|
|
30
|
+
// The backend rejects the WHOLE report (22004) when ceiling < tier, which
|
|
31
|
+
// would silently stop the version row updating. Keep the tier, drop the
|
|
32
|
+
// inconsistent ceiling.
|
|
33
|
+
if (c !== undefined && (t === undefined || c >= t))
|
|
34
|
+
out.capability_ceiling = c;
|
|
35
|
+
if (o.capabilities && typeof o.capabilities === "object") {
|
|
36
|
+
const caps = {};
|
|
37
|
+
for (const k of CAP_KEYS) {
|
|
38
|
+
const v = o.capabilities[k];
|
|
39
|
+
if (typeof v === "boolean")
|
|
40
|
+
caps[k] = v;
|
|
41
|
+
}
|
|
42
|
+
if (Object.keys(caps).length > 0)
|
|
43
|
+
out.capabilities = caps;
|
|
44
|
+
}
|
|
45
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|