@newbase-clawchat/openclaw-clawchat 2026.4.24 → 2026.4.30

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.
Files changed (56) hide show
  1. package/README.md +66 -16
  2. package/dist/index.js +27 -0
  3. package/dist/src/api-client.js +156 -0
  4. package/dist/src/api-types.js +17 -0
  5. package/dist/src/buffered-stream.js +177 -0
  6. package/dist/src/channel.js +191 -0
  7. package/dist/src/client.js +176 -0
  8. package/dist/src/commands.js +35 -0
  9. package/dist/src/config.js +214 -0
  10. package/dist/src/inbound.js +133 -0
  11. package/dist/src/login.runtime.js +130 -0
  12. package/dist/src/media-runtime.js +85 -0
  13. package/dist/src/message-mapper.js +82 -0
  14. package/dist/src/outbound.js +181 -0
  15. package/dist/src/protocol.js +38 -0
  16. package/dist/src/reply-dispatcher.js +440 -0
  17. package/dist/src/runtime.js +288 -0
  18. package/dist/src/streaming.js +65 -0
  19. package/dist/src/tools-schema.js +38 -0
  20. package/dist/src/tools.js +287 -0
  21. package/index.ts +2 -1
  22. package/openclaw.plugin.json +81 -1
  23. package/package.json +21 -9
  24. package/skills/clawchat-account-tools/SKILL.md +26 -0
  25. package/skills/clawchat-activate/SKILL.md +47 -0
  26. package/src/api-client.test.ts +6 -5
  27. package/src/api-client.ts +8 -3
  28. package/src/buffered-stream.test.ts +14 -4
  29. package/src/buffered-stream.ts +19 -11
  30. package/src/channel.outbound.test.ts +49 -35
  31. package/src/channel.test.ts +45 -10
  32. package/src/channel.ts +26 -17
  33. package/src/client.test.ts +9 -1
  34. package/src/client.ts +48 -21
  35. package/src/commands.test.ts +39 -0
  36. package/src/commands.ts +41 -0
  37. package/src/config.test.ts +40 -3
  38. package/src/config.ts +60 -4
  39. package/src/inbound.test.ts +9 -6
  40. package/src/inbound.ts +51 -16
  41. package/src/login.runtime.test.ts +142 -3
  42. package/src/login.runtime.ts +59 -26
  43. package/src/manifest.test.ts +183 -5
  44. package/src/outbound.test.ts +10 -7
  45. package/src/outbound.ts +8 -7
  46. package/src/plugin-entry.test.ts +27 -0
  47. package/src/protocol.ts +5 -0
  48. package/src/reply-dispatcher.test.ts +420 -3
  49. package/src/reply-dispatcher.ts +137 -12
  50. package/src/runtime.test.ts +23 -7
  51. package/src/runtime.ts +13 -1
  52. package/src/streaming.test.ts +12 -9
  53. package/src/streaming.ts +22 -12
  54. package/src/tools-schema.ts +28 -19
  55. package/src/tools.test.ts +181 -40
  56. package/src/tools.ts +107 -95
package/src/config.ts CHANGED
@@ -8,10 +8,9 @@ export const CHANNEL_ID = "openclaw-clawchat" as const;
8
8
  * login` works out of the box without requiring a prior `openclaw channel
9
9
  * setup` call. Operators can still override either one via config.
10
10
  *
11
- * TODO: replace these placeholders with the production URLs.
12
11
  */
13
- export const DEFAULT_BASE_URL = "https://api.clawling.chat" as const;
14
- export const DEFAULT_WEBSOCKET_URL = "wss://api.clawling.chat/ws" as const;
12
+ export const DEFAULT_BASE_URL = "http://company.newbaselab.com:10086" as const;
13
+ export const DEFAULT_WEBSOCKET_URL = "ws://company.newbaselab.com:10086/ws" as const;
15
14
 
16
15
  export type ReplyMode = "static" | "stream";
17
16
 
@@ -92,6 +91,8 @@ export type OpenclawClawlingConfig = {
92
91
  groupMode?: GroupMode;
93
92
  forwardThinking?: boolean;
94
93
  forwardToolCalls?: boolean;
94
+ /** Emit approval/action rich fragments instead of plain fallback text. */
95
+ richInteractions?: boolean;
95
96
  stream?: OpenclawClawlingStreamConfig;
96
97
  reconnect?: OpenclawClawlingReconnectConfig;
97
98
  heartbeat?: OpenclawClawlingHeartbeatConfig;
@@ -112,6 +113,7 @@ export const openclawClawlingConfigSchema = {
112
113
  groupMode: { type: "string", enum: ["mention", "all"] },
113
114
  forwardThinking: { type: "boolean" },
114
115
  forwardToolCalls: { type: "boolean" },
116
+ richInteractions: { type: "boolean" },
115
117
  stream: {
116
118
  type: "object",
117
119
  additionalProperties: false,
@@ -150,6 +152,57 @@ export const openclawClawlingConfigSchema = {
150
152
  },
151
153
  } as const;
152
154
 
155
+ function isOpenclawClawchatToolAllowEntry(entry: unknown): boolean {
156
+ return entry === CHANNEL_ID || entry === "group:plugins";
157
+ }
158
+
159
+ function hasOpenclawClawchatToolAllow(cfg: OpenClawConfig): boolean {
160
+ const currentTools = ((cfg as { tools?: Record<string, unknown> }).tools ?? {}) as Record<
161
+ string,
162
+ unknown
163
+ >;
164
+ const currentAlsoAllow = Array.isArray(currentTools.alsoAllow) ? currentTools.alsoAllow : [];
165
+ const currentAllow = Array.isArray(currentTools.allow) ? currentTools.allow : [];
166
+ return [...currentAllow, ...currentAlsoAllow].some(isOpenclawClawchatToolAllowEntry);
167
+ }
168
+
169
+ function mergeToolPolicyEntryAllow(
170
+ cfg: OpenClawConfig,
171
+ entry: string,
172
+ isAlreadyCovered: (value: unknown) => boolean,
173
+ ): OpenClawConfig {
174
+ const currentTools = ((cfg as { tools?: Record<string, unknown> }).tools ?? {}) as Record<
175
+ string,
176
+ unknown
177
+ >;
178
+ const currentAlsoAllow = Array.isArray(currentTools.alsoAllow)
179
+ ? currentTools.alsoAllow.slice()
180
+ : [];
181
+ const currentAllow = Array.isArray(currentTools.allow) ? currentTools.allow.slice() : [];
182
+ const alreadyAllowed = [...currentAllow, ...currentAlsoAllow].some(isAlreadyCovered);
183
+ if (currentAllow.length > 0) {
184
+ return {
185
+ ...cfg,
186
+ tools: {
187
+ ...currentTools,
188
+ allow: alreadyAllowed ? currentAllow : [...currentAllow, entry],
189
+ },
190
+ } as OpenClawConfig;
191
+ }
192
+ const alreadyAlsoAllowed = currentAlsoAllow.some(isAlreadyCovered);
193
+ return {
194
+ ...cfg,
195
+ tools: {
196
+ ...currentTools,
197
+ alsoAllow: alreadyAlsoAllowed ? currentAlsoAllow : [...currentAlsoAllow, entry],
198
+ },
199
+ } as OpenClawConfig;
200
+ }
201
+
202
+ export function mergeOpenclawClawchatToolAllow(cfg: OpenClawConfig): OpenClawConfig {
203
+ return mergeToolPolicyEntryAllow(cfg, CHANNEL_ID, isOpenclawClawchatToolAllowEntry);
204
+ }
205
+
153
206
  export type ResolvedOpenclawClawlingAccount = {
154
207
  accountId: string;
155
208
  name: string;
@@ -163,6 +216,7 @@ export type ResolvedOpenclawClawlingAccount = {
163
216
  groupMode: GroupMode;
164
217
  forwardThinking: boolean;
165
218
  forwardToolCalls: boolean;
219
+ richInteractions: boolean;
166
220
  allowFrom: string[];
167
221
  stream: Required<OpenclawClawlingStreamConfig>;
168
222
  reconnect: Required<OpenclawClawlingReconnectConfig>;
@@ -249,6 +303,8 @@ export function resolveOpenclawClawlingAccount(
249
303
  typeof channel.forwardThinking === "boolean" ? channel.forwardThinking : true;
250
304
  const forwardToolCalls =
251
305
  typeof channel.forwardToolCalls === "boolean" ? channel.forwardToolCalls : false;
306
+ const richInteractions =
307
+ typeof channel.richInteractions === "boolean" ? channel.richInteractions : false;
252
308
 
253
309
  return {
254
310
  accountId: DEFAULT_ACCOUNT_ID,
@@ -263,6 +319,7 @@ export function resolveOpenclawClawlingAccount(
263
319
  groupMode,
264
320
  forwardThinking,
265
321
  forwardToolCalls,
322
+ richInteractions,
266
323
  allowFrom: [],
267
324
  stream: readStream(channel.stream),
268
325
  reconnect: readReconnect(channel.reconnect),
@@ -274,4 +331,3 @@ export function resolveOpenclawClawlingAccount(
274
331
  export function listOpenclawClawlingAccountIds(): string[] {
275
332
  return [DEFAULT_ACCOUNT_ID];
276
333
  }
277
-
@@ -41,6 +41,7 @@ function buildSendEnvelope(
41
41
  mentions: string[];
42
42
  reply: unknown;
43
43
  messageId: string;
44
+ chatId: string;
44
45
  }> = {},
45
46
  ): Envelope<DownlinkMessageSendPayload> {
46
47
  return {
@@ -48,6 +49,7 @@ function buildSendEnvelope(
48
49
  event: overrides.event ?? "message.send",
49
50
  trace_id: "trace-1",
50
51
  emitted_at: 1776162600000,
52
+ chat_id: overrides.chatId,
51
53
  to: { id: "agent-1", type: overrides.senderType ?? "direct" },
52
54
  sender: {
53
55
  sender_id: "user-1",
@@ -93,7 +95,7 @@ describe("openclaw-clawchat inbound", () => {
93
95
  envelope: buildSendEnvelope({ text: "hello there" }),
94
96
  cfg: {},
95
97
  runtime: {} as never,
96
- account: baseAccount(),
98
+ account: baseAccount({ groupMode: "mention" }),
97
99
  ingest,
98
100
  });
99
101
  expect(ingest).toHaveBeenCalledTimes(1);
@@ -110,7 +112,7 @@ describe("openclaw-clawchat inbound", () => {
110
112
  envelope: buildSendEnvelope({ senderType: "direct" }),
111
113
  cfg: {},
112
114
  runtime: {} as never,
113
- account: baseAccount(),
115
+ account: baseAccount({ groupMode: "mention" }),
114
116
  ingest,
115
117
  });
116
118
  const { wasMentioned } = ingest.mock.calls[0]![0];
@@ -145,7 +147,7 @@ describe("openclaw-clawchat inbound", () => {
145
147
  envelope: buildSendEnvelope({ senderType: "group" }),
146
148
  cfg: {},
147
149
  runtime: {} as never,
148
- account: baseAccount(),
150
+ account: baseAccount({ groupMode: "mention" }),
149
151
  ingest,
150
152
  });
151
153
  expect(ingest).not.toHaveBeenCalled();
@@ -174,7 +176,7 @@ describe("openclaw-clawchat inbound", () => {
174
176
  },
175
177
  };
176
178
  await dispatchOpenclawClawlingInbound({
177
- envelope: buildSendEnvelope({ event: "message.reply", reply: replyRef }),
179
+ envelope: buildSendEnvelope({ event: "message.reply", reply: replyRef, chatId: "chat-1" }),
178
180
  cfg: {},
179
181
  runtime: {} as never,
180
182
  account: baseAccount(),
@@ -183,8 +185,9 @@ describe("openclaw-clawchat inbound", () => {
183
185
  const { replyCtx } = ingest.mock.calls[0]![0];
184
186
  expect(replyCtx).toEqual({
185
187
  replyToMessageId: "m-orig",
188
+ replyPreviewChatId: "chat-1",
186
189
  replyPreviewSenderId: "user-2",
187
- replyPreviewDisplayName: "User Two",
190
+ replyPreviewNickName: "User Two",
188
191
  replyPreviewText: "original text",
189
192
  });
190
193
  });
@@ -262,7 +265,7 @@ describe("openclaw-clawchat inbound", () => {
262
265
  expect(ingest).toHaveBeenCalledTimes(1);
263
266
  const call = ingest.mock.calls[0]![0];
264
267
  expect(call.senderId).toBe("user-1");
265
- expect(call.senderDisplayName).toBe("User One");
268
+ expect(call.senderNickName).toBe("User One");
266
269
  expect(call.peer).toEqual({ kind: "direct", id: "user-1" });
267
270
  });
268
271
 
package/src/inbound.ts CHANGED
@@ -29,6 +29,7 @@ export interface IngestTurnParams {
29
29
  mediaItems: MediaItem[];
30
30
  replyCtx?: {
31
31
  replyToMessageId: string;
32
+ replyPreviewChatId: string;
32
33
  replyPreviewSenderId: string;
33
34
  replyPreviewNickName: string;
34
35
  replyPreviewText: string;
@@ -52,6 +53,29 @@ const DEDUP_MAX = 256;
52
53
  const dedupSeen: string[] = [];
53
54
  const dedupSet = new Set<string>();
54
55
 
56
+ type SenderLike = {
57
+ id?: unknown;
58
+ nick_name?: unknown;
59
+ sender_id?: unknown;
60
+ display_name?: unknown;
61
+ type?: unknown;
62
+ };
63
+
64
+ function normalizeSender(sender: unknown): { id: string; nickName: string; type?: ChatType } | null {
65
+ if (!sender || typeof sender !== "object") return null;
66
+ const s = sender as SenderLike;
67
+ const id = typeof s.id === "string" ? s.id : typeof s.sender_id === "string" ? s.sender_id : "";
68
+ if (!id) return null;
69
+ const type = s.type === "group" || s.type === "direct" ? s.type : undefined;
70
+ const nickName =
71
+ typeof s.nick_name === "string"
72
+ ? s.nick_name
73
+ : typeof s.display_name === "string"
74
+ ? s.display_name
75
+ : id;
76
+ return { id, nickName, ...(type ? { type } : {}) };
77
+ }
78
+
55
79
  export function _resetDedupForTest(): void {
56
80
  dedupSeen.length = 0;
57
81
  dedupSet.clear();
@@ -101,20 +125,22 @@ export async function dispatchOpenclawClawlingInbound(
101
125
  reply: {
102
126
  reply_to_msg_id: string;
103
127
  reply_preview: {
104
- id: string;
105
- nick_name: string;
128
+ id?: string;
129
+ nick_name?: string;
130
+ sender_id?: string;
131
+ display_name?: string;
106
132
  fragments: Array<Record<string, unknown>>;
107
133
  };
108
134
  } | null;
109
135
  };
110
136
  /** Legacy fallback: older fixtures carried sender inside payload.message. */
111
- sender?: { id: string; nick_name: string };
137
+ sender?: SenderLike;
112
138
  };
113
139
 
114
140
  // v2 envelopes carry sender on the envelope (RoutingSender); the legacy
115
141
  // message.sender shape is accepted as a fallback for older fixtures.
116
- const sender = envelope.sender ?? message.sender;
117
- if (!sender || typeof sender.id !== "string" || !sender.id) {
142
+ const sender = normalizeSender(envelope.sender ?? message.sender);
143
+ if (!sender) {
118
144
  log?.info?.(
119
145
  `[${account.accountId}] openclaw-clawchat skip: missing sender trace=${envelope.trace_id}`,
120
146
  );
@@ -122,7 +148,10 @@ export async function dispatchOpenclawClawlingInbound(
122
148
  }
123
149
  // `chat_type` is on the envelope in the new protocol. Default to "direct"
124
150
  // if the server didn't include it (defensive; shouldn't happen in practice).
125
- const chatType: ChatType = envelope.chat_type ?? "direct";
151
+ const legacyTo = (envelope as Envelope<DownlinkMessageSendPayload> & {
152
+ to?: { type?: ChatType };
153
+ }).to;
154
+ const chatType: ChatType = envelope.chat_type ?? sender.type ?? legacyTo?.type ?? "direct";
126
155
  const isGroup = chatType === "group";
127
156
  if (payload.message_mode !== "normal") {
128
157
  log?.info?.(
@@ -163,15 +192,6 @@ export async function dispatchOpenclawClawlingInbound(
163
192
  return;
164
193
  }
165
194
 
166
- const replyCtx = message.context.reply
167
- ? {
168
- replyToMessageId: message.context.reply.reply_to_msg_id,
169
- replyPreviewSenderId: message.context.reply.reply_preview.id,
170
- replyPreviewNickName: message.context.reply.reply_preview.nick_name,
171
- replyPreviewText: fragmentsToText(message.context.reply.reply_preview.fragments as never),
172
- }
173
- : undefined;
174
-
175
195
  log?.info?.(
176
196
  `[${account.accountId}] openclaw-clawchat inbound event=${envelope.event === EVENT.MESSAGE_REPLY ? "reply" : "send"} msg=${payload.message_id} from=${sender.id} text_len=${rawBody.length} mentioned=${wasMentioned}`,
177
197
  );
@@ -181,13 +201,28 @@ export async function dispatchOpenclawClawlingInbound(
181
201
  const chatId =
182
202
  (envelope as Envelope<DownlinkMessageSendPayload> & { chat_id?: string }).chat_id ??
183
203
  sender.id;
204
+ const replyCtx = message.context.reply
205
+ ? {
206
+ replyToMessageId: message.context.reply.reply_to_msg_id,
207
+ replyPreviewChatId: chatId,
208
+ replyPreviewSenderId:
209
+ message.context.reply.reply_preview.id ??
210
+ message.context.reply.reply_preview.sender_id ??
211
+ "",
212
+ replyPreviewNickName:
213
+ message.context.reply.reply_preview.nick_name ??
214
+ message.context.reply.reply_preview.display_name ??
215
+ "",
216
+ replyPreviewText: fragmentsToText(message.context.reply.reply_preview.fragments as never),
217
+ }
218
+ : undefined;
184
219
 
185
220
  await params.ingest({
186
221
  channel: "openclaw-clawchat",
187
222
  accountId: account.accountId,
188
223
  peer: { kind: isGroup ? "group" : "direct", id: chatId },
189
224
  senderId: sender.id,
190
- senderNickName: sender.nick_name,
225
+ senderNickName: sender.nickName,
191
226
  rawBody,
192
227
  messageId: payload.message_id,
193
228
  traceId: envelope.trace_id,
@@ -95,7 +95,7 @@ describe("runOpenclawClawlingLogin", () => {
95
95
  });
96
96
 
97
97
  expect(agentsConnect).toHaveBeenCalledWith({
98
- inviteCode: "INV-ABC",
98
+ code: "INV-ABC",
99
99
  platform: "openclaw",
100
100
  type: "clawbot",
101
101
  });
@@ -112,6 +112,145 @@ describe("runOpenclawClawlingLogin", () => {
112
112
  expect(log).toHaveBeenCalledWith(expect.stringContaining("login succeeded"));
113
113
  });
114
114
 
115
+ it("uses the runtime config mutator with auto reload intent for config writes", async () => {
116
+ const cfg = buildCfg({
117
+ baseUrl: "https://api.example.com",
118
+ websocketUrl: "wss://ws.example.com/v2/client",
119
+ });
120
+ const agentsConnect = vi.fn().mockResolvedValue({
121
+ agent: { user_id: "agent-123", nickname: "Bot" },
122
+ access_token: "access-tok",
123
+ refresh_token: "refresh-tok",
124
+ });
125
+ let mutatedCfg: OpenClawConfig | undefined;
126
+ const mutateConfigFile = vi.fn(async (params) => {
127
+ expect(params.afterWrite).toEqual({ mode: "auto" });
128
+ const draft = structuredClone(cfg) as OpenClawConfig;
129
+ await params.mutate(draft, { snapshot: {} as never, previousHash: "before" });
130
+ mutatedCfg = draft;
131
+ return { nextConfig: draft } as never;
132
+ });
133
+
134
+ await runOpenclawClawlingLogin({
135
+ cfg,
136
+ runtime: { log: vi.fn() },
137
+ readInviteCode: async () => "INV-ABC",
138
+ apiClientFactory: () => makeApiClient({ agentsConnect }),
139
+ mutateConfigFile,
140
+ });
141
+
142
+ expect(mutateConfigFile).toHaveBeenCalledTimes(1);
143
+ const section = (mutatedCfg!.channels as Record<string, Record<string, unknown>>)[
144
+ CHANNEL_ID
145
+ ]!;
146
+ expect(section.token).toBe("access-tok");
147
+ expect(section.refreshToken).toBe("refresh-tok");
148
+ expect(section.userId).toBe("agent-123");
149
+ });
150
+
151
+ it("preserves other configured channels when persisting ClawChat credentials", async () => {
152
+ const cfg = {
153
+ channels: {
154
+ telegram: {
155
+ enabled: true,
156
+ token: "telegram-token",
157
+ },
158
+ [CHANNEL_ID]: {
159
+ baseUrl: "https://api.example.com",
160
+ websocketUrl: "wss://ws.example.com/v2/client",
161
+ },
162
+ },
163
+ } as unknown as OpenClawConfig;
164
+ const agentsConnect = vi.fn().mockResolvedValue({
165
+ agent: { user_id: "agent-123" },
166
+ access_token: "access-tok",
167
+ refresh_token: "refresh-tok",
168
+ });
169
+ const persistConfig = vi.fn();
170
+
171
+ await runOpenclawClawlingLogin({
172
+ cfg,
173
+ runtime: { log: vi.fn() },
174
+ readInviteCode: async () => "INV-ABC",
175
+ apiClientFactory: () => makeApiClient({ agentsConnect }),
176
+ persistConfig,
177
+ });
178
+
179
+ const savedCfg = persistConfig.mock.calls[0]![0] as OpenClawConfig;
180
+ expect(savedCfg.channels?.telegram).toEqual({
181
+ enabled: true,
182
+ token: "telegram-token",
183
+ });
184
+ expect(savedCfg.channels?.[CHANNEL_ID]).toMatchObject({
185
+ baseUrl: "https://api.example.com",
186
+ websocketUrl: "wss://ws.example.com/v2/client",
187
+ token: "access-tok",
188
+ userId: "agent-123",
189
+ refreshToken: "refresh-tok",
190
+ });
191
+ });
192
+
193
+ it("enables the channel when login fills a pre-activation disabled skeleton", async () => {
194
+ const cfg = buildCfg({
195
+ enabled: false,
196
+ baseUrl: "https://api.example.com",
197
+ });
198
+ const agentsConnect = vi.fn().mockResolvedValue({
199
+ agent: { user_id: "agent-123" },
200
+ access_token: "access-tok",
201
+ refresh_token: "refresh-tok",
202
+ });
203
+ const persistConfig = vi.fn();
204
+
205
+ await runOpenclawClawlingLogin({
206
+ cfg,
207
+ runtime: { log: vi.fn() },
208
+ readInviteCode: async () => "INV-ABC",
209
+ apiClientFactory: () => makeApiClient({ agentsConnect }),
210
+ persistConfig,
211
+ });
212
+
213
+ const savedCfg = persistConfig.mock.calls[0]![0] as OpenClawConfig;
214
+ const section = (savedCfg.channels as Record<string, Record<string, unknown>>)[CHANNEL_ID]!;
215
+ expect(section.enabled).toBe(true);
216
+ expect(section.token).toBe("access-tok");
217
+ expect(section.userId).toBe("agent-123");
218
+ });
219
+
220
+ it("allows openclaw-clawchat plugin tools after successful login without replacing policy", async () => {
221
+ const cfg = {
222
+ ...buildCfg({ baseUrl: "https://api.example.com" }),
223
+ tools: {
224
+ profile: "coding",
225
+ allow: [],
226
+ deny: ["exec"],
227
+ alsoAllow: ["browser"],
228
+ },
229
+ } as unknown as OpenClawConfig;
230
+ const agentsConnect = vi.fn().mockResolvedValue({
231
+ agent: { user_id: "agent-123" },
232
+ access_token: "access-tok",
233
+ refresh_token: "refresh-tok",
234
+ });
235
+ const persistConfig = vi.fn();
236
+
237
+ await runOpenclawClawlingLogin({
238
+ cfg,
239
+ runtime: { log: vi.fn() },
240
+ readInviteCode: async () => "INV-ABC",
241
+ apiClientFactory: () => makeApiClient({ agentsConnect }),
242
+ persistConfig,
243
+ });
244
+
245
+ const savedCfg = persistConfig.mock.calls[0]![0] as OpenClawConfig;
246
+ expect(savedCfg.tools).toMatchObject({
247
+ profile: "coding",
248
+ allow: [],
249
+ deny: ["exec"],
250
+ alsoAllow: ["browser", "openclaw-clawchat"],
251
+ });
252
+ });
253
+
115
254
  it("surfaces agents/connect API errors with the kind and message", async () => {
116
255
  const cfg = buildCfg({ baseUrl: "https://api.example.com" });
117
256
  const agentsConnect = vi.fn().mockRejectedValue(
@@ -219,7 +358,7 @@ describe("runOpenclawClawlingLogin", () => {
219
358
  persistConfig: vi.fn(),
220
359
  });
221
360
  expect(agentsConnect).toHaveBeenCalledWith({
222
- inviteCode: "INV-TRIM",
361
+ code: "INV-TRIM",
223
362
  platform: "openclaw",
224
363
  type: "clawbot",
225
364
  });
@@ -243,7 +382,7 @@ describe("runOpenclawClawlingLogin (non-interactive via readInviteCode)", () =>
243
382
  persistConfig,
244
383
  });
245
384
  expect(agentsConnect).toHaveBeenCalledWith({
246
- inviteCode: "INV-PROGRAMMATIC",
385
+ code: "INV-PROGRAMMATIC",
247
386
  platform: "openclaw",
248
387
  type: "clawbot",
249
388
  });
@@ -1,9 +1,12 @@
1
1
  import { createInterface, type Interface as ReadlineInterface } from "node:readline/promises";
2
- import { writeConfigFile } from "openclaw/plugin-sdk/config-runtime";
3
2
  import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
4
3
  import { createOpenclawClawlingApiClient } from "./api-client.ts";
5
- import { ClawlingApiError } from "./api-types.ts";
6
- import { CHANNEL_ID, resolveOpenclawClawlingAccount } from "./config.ts";
4
+ import { ClawlingApiError, type AgentConnectResult } from "./api-types.ts";
5
+ import {
6
+ CHANNEL_ID,
7
+ mergeOpenclawClawchatToolAllow,
8
+ resolveOpenclawClawlingAccount,
9
+ } from "./config.ts";
7
10
 
8
11
  /**
9
12
  * Platform tag sent to `/v1/agents/connect`. Identifies the host of this
@@ -16,6 +19,14 @@ export const AGENTS_CONNECT_PLATFORM = "openclaw" as const;
16
19
  */
17
20
  export const AGENTS_CONNECT_TYPE = "clawbot" as const;
18
21
 
22
+ export type OpenclawClawchatMutateConfigFile = <T = void>(params: {
23
+ afterWrite: { mode: "auto" } | { mode: "none" | "restart"; reason: string };
24
+ mutate: (
25
+ draft: OpenClawConfig,
26
+ context: { snapshot: unknown; previousHash: string | null },
27
+ ) => Promise<T | void> | T | void;
28
+ }) => Promise<unknown>;
29
+
19
30
  export interface LoginParams {
20
31
  cfg: OpenClawConfig;
21
32
  accountId?: string | null;
@@ -27,7 +38,9 @@ export interface LoginParams {
27
38
  readInviteCode?: () => Promise<string>;
28
39
  /** Override for the HTTP client — used by tests. */
29
40
  apiClientFactory?: typeof createOpenclawClawlingApiClient;
30
- /** Override for config persistence used by tests. */
41
+ /** Official runtime config mutator. Production callers must provide this. */
42
+ mutateConfigFile?: OpenclawClawchatMutateConfigFile;
43
+ /** Test-only config persistence override. */
31
44
  persistConfig?: (cfg: OpenClawConfig) => Promise<void> | void;
32
45
  }
33
46
 
@@ -56,6 +69,46 @@ async function promptInviteCodeFromStdin(runtime: {
56
69
  }
57
70
  }
58
71
 
72
+ function buildLoginConfig(cfg: OpenClawConfig, result: AgentConnectResult): OpenClawConfig {
73
+ const channels = (cfg.channels ?? {}) as Record<string, unknown>;
74
+ const existing = (channels[CHANNEL_ID] ?? {}) as Record<string, unknown>;
75
+ const nextSection: Record<string, unknown> = {
76
+ ...existing,
77
+ enabled: true,
78
+ token: result.access_token,
79
+ userId: result.agent.user_id,
80
+ };
81
+ if (result.refresh_token) {
82
+ nextSection.refreshToken = result.refresh_token;
83
+ }
84
+ return mergeOpenclawClawchatToolAllow({
85
+ ...cfg,
86
+ channels: { ...channels, [CHANNEL_ID]: nextSection },
87
+ });
88
+ }
89
+
90
+ async function persistLoginConfig(
91
+ params: LoginParams,
92
+ result: AgentConnectResult,
93
+ ): Promise<void> {
94
+ if (params.mutateConfigFile) {
95
+ await params.mutateConfigFile({
96
+ afterWrite: { mode: "auto" },
97
+ mutate(draft) {
98
+ Object.assign(draft, buildLoginConfig(draft, result));
99
+ },
100
+ });
101
+ return;
102
+ }
103
+
104
+ if (params.persistConfig) {
105
+ await params.persistConfig(buildLoginConfig(params.cfg, result));
106
+ return;
107
+ }
108
+
109
+ throw new Error("openclaw-clawchat: mutateConfigFile is required to persist login credentials");
110
+ }
111
+
59
112
  /**
60
113
  * Run the `openclaw channels login --channel openclaw-clawchat` flow:
61
114
  * 1. Read the existing channel section; require `baseUrl` to be set so we
@@ -63,7 +116,7 @@ async function promptInviteCodeFromStdin(runtime: {
63
116
  * 2. Prompt the user for an invite code on stdin.
64
117
  * 3. POST it to `${baseUrl}/v1/agents/connect`.
65
118
  * 4. Write the returned `websocket_url` / `token` / `user_id` back into
66
- * the config so subsequent `openclaw gateway run` picks them up.
119
+ * the config so subsequent Gateway runs pick them up.
67
120
  *
68
121
  * Errors surface with clear messages (missing baseUrl, empty invite,
69
122
  * server-side rejection) so the caller can relay them to the operator.
@@ -111,33 +164,13 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
111
164
  );
112
165
  }
113
166
 
114
- // Merge credentials into cfg.channels.openclaw-clawchat and persist
115
- // immediately so a subsequent `openclaw gateway run` picks them up
116
- // without any manual edit. `baseUrl` / `websocketUrl` stay untouched —
117
- // the built-in defaults (or operator overrides) remain authoritative
118
- // because `/v1/agents/connect` doesn't return them.
119
- const channels = (cfg.channels ?? {}) as Record<string, unknown>;
120
- const existing = (channels[CHANNEL_ID] ?? {}) as Record<string, unknown>;
121
- const nextSection: Record<string, unknown> = {
122
- ...existing,
123
- token: result.access_token,
124
- userId: result.agent.user_id,
125
- };
126
- if (result.refresh_token) {
127
- nextSection.refreshToken = result.refresh_token;
128
- }
129
- const nextCfg: OpenClawConfig = {
130
- ...cfg,
131
- channels: { ...channels, [CHANNEL_ID]: nextSection },
132
- };
133
-
134
167
  const tokenPreview = redactToken(result.access_token);
135
168
  runtime.log(
136
169
  `Updating config: channels.${CHANNEL_ID}.token=${tokenPreview} userId=${result.agent.user_id}${
137
170
  result.refresh_token ? " refreshToken=***" : ""
138
171
  } …`,
139
172
  );
140
- await (params.persistConfig ?? writeConfigFile)(nextCfg);
173
+ await persistLoginConfig(params, result);
141
174
  runtime.log(`Config file updated.`);
142
175
 
143
176
  runtime.log(