@clawling/clawchat-plugin-openclaw 2026.7.25-1 → 2026.7.27-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.
@@ -1,7 +1,28 @@
1
1
  import { CHANNEL_ID } from "./config.js";
2
+ /** Activation intent flags — the two escape hatches out of the live-activation guard. */
3
+ const ACTIVATION_FLAGS = ["--new-account", "--repair"];
4
+ function stripActivationFlags(raw) {
5
+ return ACTIVATION_FLAGS.reduce((acc, flag) => acc.split(flag).join(" "), raw);
6
+ }
2
7
  function extractInviteCode(value) {
3
8
  const raw = typeof value === "string" ? value.trim() : "";
4
- return raw.match(/\b[A-Z0-9]{6}\b/u)?.[0] ?? "";
9
+ return stripActivationFlags(raw).match(/\b[A-Z0-9]{6}\b/u)?.[0] ?? "";
10
+ }
11
+ /**
12
+ * Read the activation intent from the command args.
13
+ *
14
+ * Redeeming a code while this instance holds a live activation is refused by
15
+ * `runOpenclawClawlingLogin` (it would re-bind the code to the incumbent agent
16
+ * rather than add a second one). `/clawchat-activate` is the only activation
17
+ * surface whose arguments this plugin parses, so it carries the two intents:
18
+ * `--new-account` replaces the identity, `--repair` re-pairs the current one.
19
+ */
20
+ function extractActivationIntent(value) {
21
+ const raw = typeof value === "string" ? value : "";
22
+ return {
23
+ newAccount: raw.includes("--new-account"),
24
+ repair: raw.includes("--repair"),
25
+ };
5
26
  }
6
27
  function errorMessage(err) {
7
28
  return err instanceof Error ? err.message : String(err);
@@ -85,10 +106,12 @@ export function registerOpenclawClawlingCommands(api) {
85
106
  acceptsArgs: true,
86
107
  requireAuth: true,
87
108
  async handler(ctx) {
88
- const code = extractInviteCode(ctx.args ?? ctx.commandBody);
109
+ const rawArgs = ctx.args ?? ctx.commandBody;
110
+ const code = extractInviteCode(rawArgs);
89
111
  if (!code) {
90
112
  return { text: "ClawChat invite code is required. Usage: /clawchat-activate A1B2C3" };
91
113
  }
114
+ const intent = extractActivationIntent(rawArgs);
92
115
  try {
93
116
  const { runOpenclawClawlingLogin } = await import("./login.runtime.js");
94
117
  await runOpenclawClawlingLogin({
@@ -97,6 +120,8 @@ export function registerOpenclawClawlingCommands(api) {
97
120
  runtime: { log: (message) => api.logger?.info?.(message) },
98
121
  readInviteCode: async () => code,
99
122
  mutateConfigFile: api.runtime.config.mutateConfigFile,
123
+ newAccount: intent.newAccount,
124
+ repair: intent.repair,
100
125
  });
101
126
  return { text: "✅ ClawChat activated successfully." };
102
127
  }
@@ -14,6 +14,45 @@ export const AGENTS_CONNECT_PLATFORM = "openclaw";
14
14
  * always a bot; humans don't log in through this flow.
15
15
  */
16
16
  export const AGENTS_CONNECT_TYPE = "clawbot";
17
+ /**
18
+ * `/v1/agents/connect` envelope code for "the supplied user_id matches no
19
+ * agent". Servers that predate the server-side stale-user_id fallback return
20
+ * this instead of degrading to a fresh pairing, so the client sheds the id and
21
+ * retries once.
22
+ */
23
+ export const AGENT_NOT_FOUND_CODE = 16001;
24
+ /**
25
+ * Thrown when redeeming an invite code would silently replace a live activation.
26
+ *
27
+ * This channel holds exactly one ClawChat identity — `channels.<id>` is a single
28
+ * slot and `listOpenclawClawlingAccountIds()` returns a single account. Because
29
+ * the stored `user_id` is replayed as a re-pair hint, redeeming a *new* invite
30
+ * code here cannot produce a second agent: the server binds the code to the
31
+ * existing agent, consumes it, creates no second agent and no second contact
32
+ * entry, and the local credentials are overwritten in place — losing the first
33
+ * agent with no error and no warning. Refuse instead, and name the real intents.
34
+ */
35
+ export class ExistingActivationError extends Error {
36
+ userId;
37
+ agentId;
38
+ constructor(userId, agentId = "") {
39
+ const who = agentId ? `agent ${agentId} (shadow user ${userId})` : `agent ${userId}`;
40
+ super(`this OpenClaw instance is already paired to ClawChat ${who}. ` +
41
+ "Redeeming another invite code here would re-bind that code to the SAME " +
42
+ "agent — no second agent is created, and this instance's credentials are " +
43
+ "overwritten.\n" +
44
+ " - To run a SECOND agent alongside this one, give it its own OpenClaw " +
45
+ "home:\n" +
46
+ " OPENCLAW_HOME=<path> openclaw channels login --channel clawchat-plugin-openclaw\n" +
47
+ " - To REPLACE this instance's agent with a brand-new identity: " +
48
+ "/clawchat-activate <CODE> --new-account\n" +
49
+ " - To re-pair THIS agent (e.g. after losing its token): " +
50
+ "/clawchat-activate <CODE> --repair");
51
+ this.name = "ExistingActivationError";
52
+ this.userId = userId;
53
+ this.agentId = agentId;
54
+ }
55
+ }
17
56
  /**
18
57
  * Prompt the operator for an invite code.
19
58
  *
@@ -126,6 +165,14 @@ export async function runOpenclawClawlingLogin(params) {
126
165
  // `DEFAULT_BASE_URL` / `DEFAULT_WEBSOCKET_URL` when the operator has not
127
166
  // overridden them, so login works without a prior `openclaw channels setup --channel clawchat-plugin-openclaw`.
128
167
  const account = resolveOpenclawClawlingAccount(cfg);
168
+ // Guard before the invite code is even read, so a refused activation never
169
+ // spends the code — the operator can still redeem it the right way. "Live" is
170
+ // decided by whether a usable token remains: auto-logout (§C) blanks the
171
+ // tokens but preserves the identity, so a logged-out instance still re-pairs
172
+ // with no flag at all, which is the flow the replay exists for.
173
+ if (account.userId.trim() && account.token.trim() && !params.newAccount && !params.repair) {
174
+ throw new ExistingActivationError(account.userId.trim(), account.agentId.trim());
175
+ }
129
176
  const inviteCode = (await (params.readInviteCode ?? (() => promptInviteCodeFromStdin(runtime)))()).trim();
130
177
  if (!inviteCode) {
131
178
  throw new Error("Login aborted: invite code is required.");
@@ -140,8 +187,10 @@ export async function runOpenclawClawlingLogin(params) {
140
187
  });
141
188
  runtime.log("Verifying invite code …");
142
189
  let result;
190
+ // A brand-new identity is precisely "do not replay" — the replay is the only
191
+ // thing that would bind this code to the incumbent agent.
192
+ const existingUserId = params.newAccount ? "" : account.userId.trim();
143
193
  try {
144
- const existingUserId = account.userId.trim();
145
194
  result = await apiClient.agentsConnect({
146
195
  code: inviteCode,
147
196
  platform: AGENTS_CONNECT_PLATFORM,
@@ -151,9 +200,37 @@ export async function runOpenclawClawlingLogin(params) {
151
200
  }
152
201
  catch (err) {
153
202
  if (err instanceof ClawlingApiError) {
154
- throw new Error(`agents/connect failed (${err.kind}): ${err.message}`);
203
+ // The stored userId is a re-pair hint, not a credential — the invite
204
+ // code alone decides ownership. AGENT_NOT_FOUND means it names an agent
205
+ // this server has never had (owner account deleted, or the config was
206
+ // repointed at another deployment), so replaying it would fail
207
+ // identically for every future invite code. A 16001 leaves the code
208
+ // `pending` server-side, so shedding the id and retrying once cannot
209
+ // double-spend it. Nothing else is retried: an owner mismatch is a real
210
+ // rejection, and a request that sent no id has no stale state to shed.
211
+ if (existingUserId && err.meta?.code === AGENT_NOT_FOUND_CODE) {
212
+ runtime.log(`Stored userId ${existingUserId} no longer exists on this server — retrying as a fresh pairing …`);
213
+ try {
214
+ result = await apiClient.agentsConnect({
215
+ code: inviteCode,
216
+ platform: AGENTS_CONNECT_PLATFORM,
217
+ type: AGENTS_CONNECT_TYPE,
218
+ });
219
+ }
220
+ catch (retryErr) {
221
+ if (retryErr instanceof ClawlingApiError) {
222
+ throw new Error(`agents/connect failed (${retryErr.kind}): ${retryErr.message}`);
223
+ }
224
+ throw retryErr;
225
+ }
226
+ }
227
+ else {
228
+ throw new Error(`agents/connect failed (${err.kind}): ${err.message}`);
229
+ }
230
+ }
231
+ else {
232
+ throw err;
155
233
  }
156
- throw err;
157
234
  }
158
235
  const accessToken = requireConnectString(result?.access_token, "access_token");
159
236
  const agentUserId = requireConnectString(result?.agent?.user_id, "agent.user_id");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.7.25-1",
3
+ "version": "2026.7.27-1",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "author": "CLAWLING PTE. LTD.",
package/src/commands.ts CHANGED
@@ -3,9 +3,33 @@ import type { OpenClawConfig, PluginCommandContext } from "openclaw/plugin-sdk/c
3
3
  import { CHANNEL_ID, type OutputVisibility } from "./config.ts";
4
4
  import type { OpenclawClawchatMutateConfigFile } from "./login.runtime.ts";
5
5
 
6
+ /** Activation intent flags — the two escape hatches out of the live-activation guard. */
7
+ const ACTIVATION_FLAGS = ["--new-account", "--repair"] as const;
8
+
9
+ function stripActivationFlags(raw: string): string {
10
+ return ACTIVATION_FLAGS.reduce((acc, flag) => acc.split(flag).join(" "), raw);
11
+ }
12
+
6
13
  function extractInviteCode(value: unknown): string {
7
14
  const raw = typeof value === "string" ? value.trim() : "";
8
- return raw.match(/\b[A-Z0-9]{6}\b/u)?.[0] ?? "";
15
+ return stripActivationFlags(raw).match(/\b[A-Z0-9]{6}\b/u)?.[0] ?? "";
16
+ }
17
+
18
+ /**
19
+ * Read the activation intent from the command args.
20
+ *
21
+ * Redeeming a code while this instance holds a live activation is refused by
22
+ * `runOpenclawClawlingLogin` (it would re-bind the code to the incumbent agent
23
+ * rather than add a second one). `/clawchat-activate` is the only activation
24
+ * surface whose arguments this plugin parses, so it carries the two intents:
25
+ * `--new-account` replaces the identity, `--repair` re-pairs the current one.
26
+ */
27
+ function extractActivationIntent(value: unknown): { newAccount: boolean; repair: boolean } {
28
+ const raw = typeof value === "string" ? value : "";
29
+ return {
30
+ newAccount: raw.includes("--new-account"),
31
+ repair: raw.includes("--repair"),
32
+ };
9
33
  }
10
34
 
11
35
  function errorMessage(err: unknown): string {
@@ -98,10 +122,12 @@ export function registerOpenclawClawlingCommands(api: Pick<OpenClawPluginApi, "r
98
122
  acceptsArgs: true,
99
123
  requireAuth: true,
100
124
  async handler(ctx) {
101
- const code = extractInviteCode(ctx.args ?? ctx.commandBody);
125
+ const rawArgs = ctx.args ?? ctx.commandBody;
126
+ const code = extractInviteCode(rawArgs);
102
127
  if (!code) {
103
128
  return { text: "ClawChat invite code is required. Usage: /clawchat-activate A1B2C3" };
104
129
  }
130
+ const intent = extractActivationIntent(rawArgs);
105
131
  try {
106
132
  const { runOpenclawClawlingLogin } = await import("./login.runtime.ts");
107
133
  await runOpenclawClawlingLogin({
@@ -112,6 +138,8 @@ export function registerOpenclawClawlingCommands(api: Pick<OpenClawPluginApi, "r
112
138
  mutateConfigFile: (api.runtime.config as unknown as {
113
139
  mutateConfigFile: OpenclawClawchatMutateConfigFile;
114
140
  }).mutateConfigFile,
141
+ newAccount: intent.newAccount,
142
+ repair: intent.repair,
115
143
  });
116
144
  return { text: "✅ ClawChat activated successfully." };
117
145
  } catch (err) {
@@ -21,6 +21,13 @@ export const AGENTS_CONNECT_PLATFORM = "openclaw" as const;
21
21
  * always a bot; humans don't log in through this flow.
22
22
  */
23
23
  export const AGENTS_CONNECT_TYPE = "clawbot" as const;
24
+ /**
25
+ * `/v1/agents/connect` envelope code for "the supplied user_id matches no
26
+ * agent". Servers that predate the server-side stale-user_id fallback return
27
+ * this instead of degrading to a fresh pairing, so the client sheds the id and
28
+ * retries once.
29
+ */
30
+ export const AGENT_NOT_FOUND_CODE = 16001 as const;
24
31
 
25
32
  export type OpenclawClawchatMutateConfigFile = <T = void>(params: {
26
33
  afterWrite: { mode: "auto" } | { mode: "none" | "restart"; reason: string };
@@ -49,6 +56,49 @@ export interface LoginParams {
49
56
  store?: Pick<ClawChatStore, "upsertActivation">;
50
57
  /** Optional database path resolved by the host runtime. */
51
58
  dbPath?: string;
59
+ /**
60
+ * Replace this instance's ClawChat identity with a brand-new agent: drop the
61
+ * stored `user_id` so the server mints one for this invite code.
62
+ */
63
+ newAccount?: boolean;
64
+ /** Re-pair the agent this instance already holds (keeps its identity). */
65
+ repair?: boolean;
66
+ }
67
+
68
+ /**
69
+ * Thrown when redeeming an invite code would silently replace a live activation.
70
+ *
71
+ * This channel holds exactly one ClawChat identity — `channels.<id>` is a single
72
+ * slot and `listOpenclawClawlingAccountIds()` returns a single account. Because
73
+ * the stored `user_id` is replayed as a re-pair hint, redeeming a *new* invite
74
+ * code here cannot produce a second agent: the server binds the code to the
75
+ * existing agent, consumes it, creates no second agent and no second contact
76
+ * entry, and the local credentials are overwritten in place — losing the first
77
+ * agent with no error and no warning. Refuse instead, and name the real intents.
78
+ */
79
+ export class ExistingActivationError extends Error {
80
+ readonly userId: string;
81
+ readonly agentId: string;
82
+
83
+ constructor(userId: string, agentId = "") {
84
+ const who = agentId ? `agent ${agentId} (shadow user ${userId})` : `agent ${userId}`;
85
+ super(
86
+ `this OpenClaw instance is already paired to ClawChat ${who}. ` +
87
+ "Redeeming another invite code here would re-bind that code to the SAME " +
88
+ "agent — no second agent is created, and this instance's credentials are " +
89
+ "overwritten.\n" +
90
+ " - To run a SECOND agent alongside this one, give it its own OpenClaw " +
91
+ "home:\n" +
92
+ " OPENCLAW_HOME=<path> openclaw channels login --channel clawchat-plugin-openclaw\n" +
93
+ " - To REPLACE this instance's agent with a brand-new identity: " +
94
+ "/clawchat-activate <CODE> --new-account\n" +
95
+ " - To re-pair THIS agent (e.g. after losing its token): " +
96
+ "/clawchat-activate <CODE> --repair",
97
+ );
98
+ this.name = "ExistingActivationError";
99
+ this.userId = userId;
100
+ this.agentId = agentId;
101
+ }
52
102
  }
53
103
 
54
104
  /**
@@ -185,6 +235,15 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
185
235
  // overridden them, so login works without a prior `openclaw channels setup --channel clawchat-plugin-openclaw`.
186
236
  const account = resolveOpenclawClawlingAccount(cfg);
187
237
 
238
+ // Guard before the invite code is even read, so a refused activation never
239
+ // spends the code — the operator can still redeem it the right way. "Live" is
240
+ // decided by whether a usable token remains: auto-logout (§C) blanks the
241
+ // tokens but preserves the identity, so a logged-out instance still re-pairs
242
+ // with no flag at all, which is the flow the replay exists for.
243
+ if (account.userId.trim() && account.token.trim() && !params.newAccount && !params.repair) {
244
+ throw new ExistingActivationError(account.userId.trim(), account.agentId.trim());
245
+ }
246
+
188
247
  const inviteCode = (
189
248
  await (params.readInviteCode ?? (() => promptInviteCodeFromStdin(runtime)))()
190
249
  ).trim();
@@ -203,8 +262,10 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
203
262
 
204
263
  runtime.log("Verifying invite code …");
205
264
  let result;
265
+ // A brand-new identity is precisely "do not replay" — the replay is the only
266
+ // thing that would bind this code to the incumbent agent.
267
+ const existingUserId = params.newAccount ? "" : account.userId.trim();
206
268
  try {
207
- const existingUserId = account.userId.trim();
208
269
  result = await apiClient.agentsConnect({
209
270
  code: inviteCode,
210
271
  platform: AGENTS_CONNECT_PLATFORM,
@@ -213,9 +274,36 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
213
274
  });
214
275
  } catch (err) {
215
276
  if (err instanceof ClawlingApiError) {
216
- throw new Error(`agents/connect failed (${err.kind}): ${err.message}`);
277
+ // The stored userId is a re-pair hint, not a credential — the invite
278
+ // code alone decides ownership. AGENT_NOT_FOUND means it names an agent
279
+ // this server has never had (owner account deleted, or the config was
280
+ // repointed at another deployment), so replaying it would fail
281
+ // identically for every future invite code. A 16001 leaves the code
282
+ // `pending` server-side, so shedding the id and retrying once cannot
283
+ // double-spend it. Nothing else is retried: an owner mismatch is a real
284
+ // rejection, and a request that sent no id has no stale state to shed.
285
+ if (existingUserId && err.meta?.code === AGENT_NOT_FOUND_CODE) {
286
+ runtime.log(
287
+ `Stored userId ${existingUserId} no longer exists on this server — retrying as a fresh pairing …`,
288
+ );
289
+ try {
290
+ result = await apiClient.agentsConnect({
291
+ code: inviteCode,
292
+ platform: AGENTS_CONNECT_PLATFORM,
293
+ type: AGENTS_CONNECT_TYPE,
294
+ });
295
+ } catch (retryErr) {
296
+ if (retryErr instanceof ClawlingApiError) {
297
+ throw new Error(`agents/connect failed (${retryErr.kind}): ${retryErr.message}`);
298
+ }
299
+ throw retryErr;
300
+ }
301
+ } else {
302
+ throw new Error(`agents/connect failed (${err.kind}): ${err.message}`);
303
+ }
304
+ } else {
305
+ throw err;
217
306
  }
218
- throw err;
219
307
  }
220
308
 
221
309
  const accessToken = requireConnectString(result?.access_token, "access_token");