@chrysb/alphaclaw 0.6.0-beta.1 → 0.6.0-beta.3

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 (28) hide show
  1. package/lib/public/js/components/agents-tab/agent-bindings-section/channel-item-trailing.js +203 -0
  2. package/lib/public/js/components/agents-tab/agent-bindings-section/helpers.js +10 -12
  3. package/lib/public/js/components/agents-tab/agent-bindings-section/index.js +19 -287
  4. package/lib/public/js/components/agents-tab/agent-bindings-section/use-agent-bindings.js +1 -1
  5. package/lib/public/js/components/agents-tab/agent-bindings-section/use-channel-items.js +211 -0
  6. package/lib/public/js/components/agents-tab/agent-pairing-section.js +17 -4
  7. package/lib/public/js/components/agents-tab/create-channel-modal.js +29 -6
  8. package/lib/public/js/components/channels.js +19 -14
  9. package/lib/public/js/components/models-tab/provider-auth-card.js +18 -1
  10. package/lib/public/js/components/models-tab/use-models.js +15 -8
  11. package/lib/public/js/lib/channel-accounts.js +20 -0
  12. package/lib/public/js/lib/model-config.js +8 -4
  13. package/lib/server/agents/agents.js +207 -0
  14. package/lib/server/agents/bindings.js +74 -0
  15. package/lib/server/agents/channels.js +674 -0
  16. package/lib/server/agents/service.js +28 -1458
  17. package/lib/server/agents/shared.js +631 -0
  18. package/lib/server/constants.js +6 -0
  19. package/lib/server/db/usage/pricing.js +1 -0
  20. package/lib/server/openclaw-config.js +13 -0
  21. package/lib/server/routes/models.js +12 -1
  22. package/lib/server/routes/pairings.js +29 -3
  23. package/lib/server/routes/system.js +1 -6
  24. package/lib/server/routes/telegram.js +34 -16
  25. package/lib/server/telegram-workspace.js +22 -7
  26. package/lib/server/topic-registry.js +1 -4
  27. package/lib/server/utils/channels.js +13 -0
  28. package/package.json +1 -1
@@ -0,0 +1,631 @@
1
+ const path = require("path");
2
+ const {
3
+ readOpenclawConfig,
4
+ writeOpenclawConfig,
5
+ } = require("../openclaw-config");
6
+
7
+ const kDefaultAgentId = "main";
8
+ const kAgentIdPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
9
+ const kChannelAccountIdPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
10
+ const kDefaultWorkspaceBasename = "workspace";
11
+ const kWorkspaceFolderPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
12
+ const kDefaultAgentFiles = ["SOUL.md", "AGENTS.md", "USER.md", "IDENTITY.md"];
13
+ const kChannelEnvKeys = {
14
+ telegram: "TELEGRAM_BOT_TOKEN",
15
+ discord: "DISCORD_BOT_TOKEN",
16
+ };
17
+ const kChannelTokenFields = {
18
+ telegram: "botToken",
19
+ discord: "token",
20
+ };
21
+ const kChannelLabels = {
22
+ telegram: "Telegram",
23
+ discord: "Discord",
24
+ };
25
+ const kMaskedChannelToken = "********";
26
+
27
+ const shellEscapeArg = (value) =>
28
+ `'${String(value || "").replace(/'/g, `'\\''`)}'`;
29
+
30
+ const resolveCredentialsDirPath = ({ OPENCLAW_DIR }) =>
31
+ path.join(OPENCLAW_DIR, "credentials");
32
+
33
+ const resolveAgentWorkspacePath = ({ OPENCLAW_DIR, agentId }) =>
34
+ path.join(
35
+ OPENCLAW_DIR,
36
+ agentId === kDefaultAgentId
37
+ ? kDefaultWorkspaceBasename
38
+ : `${kDefaultWorkspaceBasename}-${agentId}`,
39
+ );
40
+
41
+ const resolveAgentDirPath = ({ OPENCLAW_DIR, agentId }) =>
42
+ path.join(OPENCLAW_DIR, "agents", agentId, "agent");
43
+
44
+ const loadConfig = ({ fsImpl, OPENCLAW_DIR }) =>
45
+ readOpenclawConfig({
46
+ fsModule: fsImpl,
47
+ openclawDir: OPENCLAW_DIR,
48
+ fallback: {},
49
+ });
50
+
51
+ const saveConfig = ({ fsImpl, OPENCLAW_DIR, config }) => {
52
+ writeOpenclawConfig({
53
+ fsModule: fsImpl,
54
+ openclawDir: OPENCLAW_DIR,
55
+ config,
56
+ spacing: 2,
57
+ });
58
+ };
59
+
60
+ const ensurePluginAllowed = ({ cfg, pluginKey }) => {
61
+ if (!cfg.plugins || typeof cfg.plugins !== "object") cfg.plugins = {};
62
+ if (!Array.isArray(cfg.plugins.allow)) cfg.plugins.allow = [];
63
+ if (!cfg.plugins.entries || typeof cfg.plugins.entries !== "object") {
64
+ cfg.plugins.entries = {};
65
+ }
66
+ if (!cfg.plugins.allow.includes(pluginKey)) {
67
+ cfg.plugins.allow.push(pluginKey);
68
+ }
69
+ cfg.plugins.entries[pluginKey] = {
70
+ ...(cfg.plugins.entries[pluginKey] &&
71
+ typeof cfg.plugins.entries[pluginKey] === "object"
72
+ ? cfg.plugins.entries[pluginKey]
73
+ : {}),
74
+ enabled: true,
75
+ };
76
+ };
77
+
78
+ const normalizeAgentsList = ({ list }) =>
79
+ (Array.isArray(list) ? list : [])
80
+ .filter((entry) => entry && typeof entry === "object")
81
+ .map((entry) => ({ ...entry }));
82
+
83
+ const normalizeAgentDefaults = ({ cfg }) => ({
84
+ model: cfg?.agents?.defaults?.model || {},
85
+ });
86
+
87
+ const cloneJson = (value) => JSON.parse(JSON.stringify(value));
88
+ const isEnvRef = (value) =>
89
+ /^\$\{[A-Z_][A-Z0-9_]*\}$/.test(String(value || "").trim());
90
+
91
+ const normalizePeerMatch = (value) => {
92
+ if (!value || typeof value !== "object") return undefined;
93
+ const kind = String(value.kind || "").trim();
94
+ const id = String(value.id || "").trim();
95
+ if (!kind || !id) return undefined;
96
+ return { kind, id };
97
+ };
98
+
99
+ const normalizeBindingMatch = (input = {}) => {
100
+ const channel = String(input.channel || "").trim();
101
+ if (!channel) {
102
+ throw new Error("Binding channel is required");
103
+ }
104
+ const accountId = String(input.accountId || "").trim();
105
+ const guildId = String(input.guildId || "").trim();
106
+ const teamId = String(input.teamId || "").trim();
107
+ const peer = normalizePeerMatch(input.peer);
108
+ const parentPeer = normalizePeerMatch(input.parentPeer);
109
+ const roles = Array.isArray(input.roles)
110
+ ? input.roles.map((entry) => String(entry || "").trim()).filter(Boolean)
111
+ : [];
112
+ return {
113
+ channel,
114
+ ...(accountId ? { accountId } : {}),
115
+ ...(guildId ? { guildId } : {}),
116
+ ...(teamId ? { teamId } : {}),
117
+ ...(peer ? { peer } : {}),
118
+ ...(parentPeer ? { parentPeer } : {}),
119
+ ...(roles.length > 0 ? { roles } : {}),
120
+ };
121
+ };
122
+
123
+ const toComparableBindingMatch = (input = {}) => {
124
+ const match = normalizeBindingMatch(input);
125
+ return {
126
+ ...match,
127
+ ...(match.accountId ? {} : { accountId: "default" }),
128
+ };
129
+ };
130
+
131
+ const matchesBinding = (left, right) =>
132
+ JSON.stringify(toComparableBindingMatch(left)) ===
133
+ JSON.stringify(toComparableBindingMatch(right));
134
+
135
+ const isValidChannelAccountId = (value) =>
136
+ kChannelAccountIdPattern.test(String(value || "").trim());
137
+
138
+ const normalizeChannelProvider = (value) => {
139
+ const provider = String(value || "")
140
+ .trim()
141
+ .toLowerCase();
142
+ if (!provider || !kChannelEnvKeys[provider]) {
143
+ throw new Error("Unsupported channel provider");
144
+ }
145
+ return provider;
146
+ };
147
+
148
+ const deriveChannelEnvKey = ({ provider, accountId }) => {
149
+ const envKey = kChannelEnvKeys[normalizeChannelProvider(provider)];
150
+ const normalizedAccountId = String(accountId || "").trim();
151
+ if (!normalizedAccountId || normalizedAccountId === "default") return envKey;
152
+ return `${envKey}_${normalizedAccountId.replace(/-/g, "_").toUpperCase()}`;
153
+ };
154
+
155
+ const getConfiguredChannelEnvKeys = (cfg) => {
156
+ const keys = new Set();
157
+ const channels =
158
+ cfg?.channels && typeof cfg.channels === "object" ? cfg.channels : {};
159
+ for (const [provider, providerConfig] of Object.entries(channels)) {
160
+ if (!kChannelEnvKeys[provider]) continue;
161
+ const accounts =
162
+ providerConfig?.accounts && typeof providerConfig.accounts === "object"
163
+ ? providerConfig.accounts
164
+ : {};
165
+ for (const accountId of Object.keys(accounts)) {
166
+ keys.add(deriveChannelEnvKey({ provider, accountId }));
167
+ }
168
+ if (Object.keys(accounts).length === 0 && providerConfig?.enabled) {
169
+ keys.add(kChannelEnvKeys[provider]);
170
+ }
171
+ }
172
+ return keys;
173
+ };
174
+
175
+ const assertActiveChannelTokenEnvVars = ({ cfg, envVars }) => {
176
+ const envMap = new Map(
177
+ (Array.isArray(envVars) ? envVars : [])
178
+ .map((entry) => [
179
+ String(entry?.key || "").trim(),
180
+ String(entry?.value || "").trim(),
181
+ ])
182
+ .filter(([key]) => key),
183
+ );
184
+ const channels =
185
+ cfg?.channels && typeof cfg.channels === "object" ? cfg.channels : {};
186
+ for (const [provider, providerConfig] of Object.entries(channels)) {
187
+ if (!kChannelEnvKeys[provider]) continue;
188
+ if (providerConfig?.enabled === false) continue;
189
+ const normalizedProviderConfig = normalizeChannelConfig({
190
+ provider,
191
+ channelConfig: providerConfig,
192
+ });
193
+ const accounts =
194
+ normalizedProviderConfig.accounts &&
195
+ typeof normalizedProviderConfig.accounts === "object"
196
+ ? normalizedProviderConfig.accounts
197
+ : {};
198
+ const accountEntries =
199
+ Object.keys(accounts).length > 0
200
+ ? Object.entries(accounts)
201
+ : [["default", {}]];
202
+ for (const [accountId, accountConfig] of accountEntries) {
203
+ if (accountConfig?.enabled === false) continue;
204
+ const envKey = deriveChannelEnvKey({ provider, accountId });
205
+ const envValue = String(envMap.get(envKey) || "").trim();
206
+ if (!envValue) {
207
+ throw new Error(
208
+ `Missing required channel token env var ${envKey} for active channel ${provider}/${accountId}`,
209
+ );
210
+ }
211
+ }
212
+ }
213
+ };
214
+
215
+ const normalizeChannelConfig = ({ provider, channelConfig }) => {
216
+ const normalizedProvider = normalizeChannelProvider(provider);
217
+ const nextConfig =
218
+ channelConfig && typeof channelConfig === "object"
219
+ ? cloneJson(channelConfig)
220
+ : {};
221
+ const existingAccounts =
222
+ nextConfig.accounts && typeof nextConfig.accounts === "object"
223
+ ? { ...nextConfig.accounts }
224
+ : {};
225
+ const tokenField = kChannelTokenFields[normalizedProvider];
226
+ if (Object.keys(existingAccounts).length > 0) {
227
+ if (tokenField) {
228
+ for (const [accountId, accountConfig] of Object.entries(
229
+ existingAccounts,
230
+ )) {
231
+ if (!accountConfig || typeof accountConfig !== "object") continue;
232
+ const nextAccountConfig = { ...accountConfig };
233
+ const rawTokenFieldValue = String(
234
+ nextAccountConfig[tokenField] || "",
235
+ ).trim();
236
+ if (rawTokenFieldValue && !isEnvRef(rawTokenFieldValue)) {
237
+ nextAccountConfig[tokenField] = `\${${deriveChannelEnvKey({
238
+ provider: normalizedProvider,
239
+ accountId,
240
+ })}}`;
241
+ }
242
+ existingAccounts[accountId] = nextAccountConfig;
243
+ }
244
+ }
245
+ nextConfig.accounts = existingAccounts;
246
+ return nextConfig;
247
+ }
248
+
249
+ const defaultAccountConfig = {};
250
+ for (const [key, value] of Object.entries(nextConfig)) {
251
+ if (key === "enabled" || key === "accounts" || key === "defaultAccount")
252
+ continue;
253
+ defaultAccountConfig[key] = cloneJson(value);
254
+ delete nextConfig[key];
255
+ }
256
+
257
+ const defaultTokenEnvRef = `\${${deriveChannelEnvKey({
258
+ provider: normalizedProvider,
259
+ accountId: "default",
260
+ })}}`;
261
+ if (tokenField && defaultAccountConfig[tokenField]) {
262
+ const rawTokenFieldValue = String(
263
+ defaultAccountConfig[tokenField] || "",
264
+ ).trim();
265
+ if (rawTokenFieldValue && !isEnvRef(rawTokenFieldValue)) {
266
+ defaultAccountConfig[tokenField] = defaultTokenEnvRef;
267
+ }
268
+ }
269
+ if (
270
+ Object.keys(defaultAccountConfig).length > 0 ||
271
+ defaultAccountConfig[tokenField]
272
+ ) {
273
+ nextConfig.accounts = { default: defaultAccountConfig };
274
+ if (!String(nextConfig.defaultAccount || "").trim()) {
275
+ nextConfig.defaultAccount = "default";
276
+ }
277
+ } else {
278
+ nextConfig.accounts = {};
279
+ }
280
+ return nextConfig;
281
+ };
282
+
283
+ const appendBindingToConfig = ({ cfg, agentId, match }) => {
284
+ const normalizedAgentId = String(agentId || "").trim();
285
+ const existingBindings = Array.isArray(cfg.bindings) ? cfg.bindings : [];
286
+ const conflictingBinding = existingBindings.find((binding) =>
287
+ matchesBinding(binding?.match || {}, match),
288
+ );
289
+ if (conflictingBinding) {
290
+ const conflictingAgentId = String(conflictingBinding.agentId || "").trim();
291
+ if (conflictingAgentId === normalizedAgentId) {
292
+ return cloneJson(conflictingBinding);
293
+ }
294
+ throw new Error(
295
+ `Binding already assigned to agent "${conflictingAgentId}"`,
296
+ );
297
+ }
298
+ const nextBinding = {
299
+ agentId: normalizedAgentId,
300
+ match,
301
+ };
302
+ cfg.bindings = [...existingBindings, nextBinding];
303
+ return cloneJson(nextBinding);
304
+ };
305
+
306
+ const buildBindingSpec = ({ provider, accountId }) => {
307
+ const channel = normalizeChannelProvider(provider);
308
+ const normalizedAccountId = String(accountId || "").trim();
309
+ return normalizedAccountId ? `${channel}:${normalizedAccountId}` : channel;
310
+ };
311
+
312
+ const hasLegacyDefaultChannelAccount = ({ config }) =>
313
+ Object.keys(config || {}).some(
314
+ (entry) =>
315
+ entry !== "accounts" && entry !== "defaultAccount" && entry !== "enabled",
316
+ );
317
+
318
+ const normalizeChannelAccountId = (value) =>
319
+ String(value || "").trim() || "default";
320
+
321
+ const resolveCredentialPairingAccountId = ({ channelId, fileName }) => {
322
+ const prefix = `${String(channelId || "").trim()}-`;
323
+ const suffix = "-allowFrom.json";
324
+ const rawFileName = String(fileName || "").trim();
325
+ if (!rawFileName.startsWith(prefix) || !rawFileName.endsWith(suffix)) {
326
+ return "";
327
+ }
328
+ const rawAccountId = rawFileName.slice(prefix.length, -suffix.length);
329
+ return normalizeChannelAccountId(rawAccountId);
330
+ };
331
+
332
+ const readPairedCountsByAccount = ({
333
+ fsImpl,
334
+ OPENCLAW_DIR,
335
+ channelId,
336
+ accountIds,
337
+ config,
338
+ }) => {
339
+ const counts = new Map(
340
+ (Array.isArray(accountIds) ? accountIds : []).map((accountId) => [
341
+ normalizeChannelAccountId(accountId),
342
+ 0,
343
+ ]),
344
+ );
345
+ const credentialsDir = resolveCredentialsDirPath({ OPENCLAW_DIR });
346
+ try {
347
+ const files = fsImpl
348
+ .readdirSync(credentialsDir)
349
+ .filter(
350
+ (fileName) =>
351
+ String(fileName || "").startsWith(
352
+ `${String(channelId || "").trim()}-`,
353
+ ) && String(fileName || "").endsWith("-allowFrom.json"),
354
+ );
355
+ for (const fileName of files) {
356
+ const accountId = resolveCredentialPairingAccountId({
357
+ channelId,
358
+ fileName,
359
+ });
360
+ if (!accountId || !counts.has(accountId)) continue;
361
+ const filePath = path.join(credentialsDir, fileName);
362
+ const parsed = JSON.parse(fsImpl.readFileSync(filePath, "utf8"));
363
+ const pairedCount = Array.isArray(parsed?.allowFrom)
364
+ ? parsed.allowFrom.length
365
+ : 0;
366
+ counts.set(accountId, Number(counts.get(accountId) || 0) + pairedCount);
367
+ }
368
+ } catch {}
369
+
370
+ for (const accountId of counts.keys()) {
371
+ const accountConfig =
372
+ accountId === "default" &&
373
+ !(config.accounts && typeof config.accounts === "object")
374
+ ? config
375
+ : config.accounts?.[accountId] || {};
376
+ const inlineAllowFrom = accountConfig?.allowFrom;
377
+ if (!Array.isArray(inlineAllowFrom)) continue;
378
+ counts.set(
379
+ accountId,
380
+ Number(counts.get(accountId) || 0) + inlineAllowFrom.length,
381
+ );
382
+ }
383
+
384
+ return counts;
385
+ };
386
+
387
+ const listConfiguredChannelAccounts = ({ fsImpl, OPENCLAW_DIR, cfg }) => {
388
+ const bindings = Array.isArray(cfg?.bindings) ? cfg.bindings : [];
389
+ const boundAccountMap = new Map();
390
+ for (const binding of bindings) {
391
+ const match = binding?.match || {};
392
+ const hasScopedFields =
393
+ !!match.peer ||
394
+ !!match.parentPeer ||
395
+ !!String(match.guildId || "").trim() ||
396
+ !!String(match.teamId || "").trim() ||
397
+ (Array.isArray(match.roles) && match.roles.length > 0);
398
+ if (hasScopedFields) continue;
399
+ const channel = String(match.channel || "").trim();
400
+ if (!channel) continue;
401
+ const accountId = String(match.accountId || "").trim() || "default";
402
+ const agentId = String(binding?.agentId || "").trim();
403
+ if (!agentId) continue;
404
+ const key = `${channel}:${accountId}`;
405
+ if (!boundAccountMap.has(key)) {
406
+ boundAccountMap.set(key, agentId);
407
+ }
408
+ }
409
+ const channels =
410
+ cfg?.channels && typeof cfg.channels === "object" ? cfg.channels : {};
411
+ return Object.entries(channels)
412
+ .map(([channelId, channelConfig]) => {
413
+ if (!kChannelEnvKeys[String(channelId || "").trim()]) return null;
414
+ const config =
415
+ channelConfig && typeof channelConfig === "object" ? channelConfig : {};
416
+ const accountsConfig =
417
+ config.accounts && typeof config.accounts === "object"
418
+ ? config.accounts
419
+ : {};
420
+ const accountIds = Object.keys(accountsConfig)
421
+ .map((entry) => String(entry || "").trim())
422
+ .filter(Boolean);
423
+ const topLevelKeys = Object.keys(config).filter(
424
+ (entry) =>
425
+ entry !== "accounts" &&
426
+ entry !== "defaultAccount" &&
427
+ entry !== "enabled",
428
+ );
429
+ if (accountIds.length === 0 && topLevelKeys.length === 0) return null;
430
+ const normalizedAccountIds = accountIds.includes("default")
431
+ ? accountIds
432
+ : topLevelKeys.length > 0
433
+ ? ["default", ...accountIds]
434
+ : accountIds;
435
+ const pairedCounts = readPairedCountsByAccount({
436
+ fsImpl,
437
+ OPENCLAW_DIR,
438
+ channelId,
439
+ accountIds: normalizedAccountIds,
440
+ config,
441
+ });
442
+ return {
443
+ channel: String(channelId || "").trim(),
444
+ accounts: normalizedAccountIds
445
+ .map((accountId) => {
446
+ const accountConfig =
447
+ accountId === "default" && accountIds.length === 0
448
+ ? config
449
+ : accountsConfig?.[accountId] || {};
450
+ return {
451
+ id: accountId,
452
+ name: String(accountConfig?.name || "").trim(),
453
+ envKey: deriveChannelEnvKey({ provider: channelId, accountId }),
454
+ boundAgentId:
455
+ boundAccountMap.get(
456
+ `${String(channelId || "").trim()}:${accountId}`,
457
+ ) || "",
458
+ paired: Number(pairedCounts.get(accountId) || 0),
459
+ status:
460
+ Number(pairedCounts.get(accountId) || 0) > 0
461
+ ? "paired"
462
+ : "configured",
463
+ };
464
+ }),
465
+ };
466
+ })
467
+ .filter(Boolean);
468
+ };
469
+
470
+ const getSafeStat = ({ fsImpl, targetPath }) => {
471
+ try {
472
+ if (typeof fsImpl.lstatSync === "function") {
473
+ return fsImpl.lstatSync(targetPath);
474
+ }
475
+ if (typeof fsImpl.statSync === "function") {
476
+ return fsImpl.statSync(targetPath);
477
+ }
478
+ } catch {}
479
+ return null;
480
+ };
481
+
482
+ const calculatePathSizeBytes = ({ fsImpl, targetPath }) => {
483
+ const stat = getSafeStat({ fsImpl, targetPath });
484
+ if (!stat) return 0;
485
+ if (typeof stat.isSymbolicLink === "function" && stat.isSymbolicLink())
486
+ return 0;
487
+ if (typeof stat.isFile === "function" && stat.isFile()) {
488
+ return Number(stat.size || 0);
489
+ }
490
+ if (!(typeof stat.isDirectory === "function" && stat.isDirectory())) {
491
+ return 0;
492
+ }
493
+ let entries = [];
494
+ try {
495
+ entries = fsImpl.readdirSync(targetPath) || [];
496
+ } catch {
497
+ return 0;
498
+ }
499
+ return entries.reduce(
500
+ (total, entry) =>
501
+ total +
502
+ calculatePathSizeBytes({
503
+ fsImpl,
504
+ targetPath: path.join(targetPath, String(entry || "")),
505
+ }),
506
+ 0,
507
+ );
508
+ };
509
+
510
+ const getImplicitMainAgent = ({ OPENCLAW_DIR, cfg }) => {
511
+ const defaults = normalizeAgentDefaults({ cfg });
512
+ const defaultPrimaryModel = String(defaults?.model?.primary || "").trim();
513
+ return {
514
+ id: kDefaultAgentId,
515
+ default: true,
516
+ name: "Main Agent",
517
+ workspace: resolveAgentWorkspacePath({
518
+ OPENCLAW_DIR,
519
+ agentId: kDefaultAgentId,
520
+ }),
521
+ agentDir: resolveAgentDirPath({ OPENCLAW_DIR, agentId: kDefaultAgentId }),
522
+ ...(defaultPrimaryModel ? { model: { primary: defaultPrimaryModel } } : {}),
523
+ };
524
+ };
525
+
526
+ const withNormalizedAgentsConfig = ({ OPENCLAW_DIR, cfg }) => {
527
+ const nextCfg = cfg && typeof cfg === "object" ? { ...cfg } : {};
528
+ const existingAgents =
529
+ nextCfg.agents && typeof nextCfg.agents === "object" ? nextCfg.agents : {};
530
+ const existingList = normalizeAgentsList({ list: existingAgents.list });
531
+ const hasMain = existingList.some(
532
+ (entry) => String(entry.id || "").trim() === kDefaultAgentId,
533
+ );
534
+ const nextList = hasMain
535
+ ? existingList
536
+ : [getImplicitMainAgent({ OPENCLAW_DIR, cfg: nextCfg }), ...existingList];
537
+
538
+ let hasDefault = false;
539
+ const listWithSingleDefault = nextList.map((entry) => {
540
+ if (!entry.default) return entry;
541
+ if (hasDefault) return { ...entry, default: false };
542
+ hasDefault = true;
543
+ return { ...entry, default: true };
544
+ });
545
+ if (!hasDefault && listWithSingleDefault.length > 0) {
546
+ listWithSingleDefault[0] = { ...listWithSingleDefault[0], default: true };
547
+ }
548
+
549
+ nextCfg.agents = {
550
+ ...existingAgents,
551
+ list: listWithSingleDefault,
552
+ };
553
+ return nextCfg;
554
+ };
555
+
556
+ const isValidAgentId = (value) =>
557
+ kAgentIdPattern.test(String(value || "").trim());
558
+
559
+ const isValidWorkspaceFolder = (value) =>
560
+ kWorkspaceFolderPattern.test(String(value || "").trim());
561
+
562
+ const resolveRequestedWorkspacePath = ({
563
+ OPENCLAW_DIR,
564
+ agentId,
565
+ workspaceFolder,
566
+ }) => {
567
+ const normalizedFolder = String(workspaceFolder || "").trim();
568
+ if (!normalizedFolder)
569
+ return resolveAgentWorkspacePath({ OPENCLAW_DIR, agentId });
570
+ if (!isValidWorkspaceFolder(normalizedFolder)) {
571
+ throw new Error(
572
+ "Workspace folder must be lowercase letters, numbers, and hyphens only",
573
+ );
574
+ }
575
+ return path.join(OPENCLAW_DIR, normalizedFolder);
576
+ };
577
+
578
+ const ensureAgentScaffold = ({
579
+ fsImpl,
580
+ agentId,
581
+ workspacePath,
582
+ OPENCLAW_DIR,
583
+ }) => {
584
+ const agentDirPath = resolveAgentDirPath({ OPENCLAW_DIR, agentId });
585
+ fsImpl.mkdirSync(workspacePath, { recursive: true });
586
+ fsImpl.mkdirSync(agentDirPath, { recursive: true });
587
+ for (const fileName of kDefaultAgentFiles) {
588
+ const targetPath = path.join(workspacePath, fileName);
589
+ if (fsImpl.existsSync(targetPath)) continue;
590
+ fsImpl.writeFileSync(
591
+ targetPath,
592
+ `# ${fileName}\n\nCreated for agent "${agentId}".\n`,
593
+ );
594
+ }
595
+ return {
596
+ workspacePath,
597
+ agentDirPath,
598
+ };
599
+ };
600
+
601
+ module.exports = {
602
+ kDefaultAgentId,
603
+ kChannelTokenFields,
604
+ kChannelLabels,
605
+ kMaskedChannelToken,
606
+ shellEscapeArg,
607
+ resolveCredentialsDirPath,
608
+ resolveAgentWorkspacePath,
609
+ loadConfig,
610
+ saveConfig,
611
+ ensurePluginAllowed,
612
+ cloneJson,
613
+ normalizeBindingMatch,
614
+ matchesBinding,
615
+ isValidChannelAccountId,
616
+ normalizeChannelProvider,
617
+ deriveChannelEnvKey,
618
+ getConfiguredChannelEnvKeys,
619
+ assertActiveChannelTokenEnvVars,
620
+ normalizeChannelConfig,
621
+ appendBindingToConfig,
622
+ buildBindingSpec,
623
+ hasLegacyDefaultChannelAccount,
624
+ listConfiguredChannelAccounts,
625
+ getSafeStat,
626
+ calculatePathSizeBytes,
627
+ withNormalizedAgentsConfig,
628
+ isValidAgentId,
629
+ resolveRequestedWorkspacePath,
630
+ ensureAgentScaffold,
631
+ };
@@ -106,6 +106,11 @@ const kFallbackOnboardingModels = [
106
106
  provider: "anthropic",
107
107
  label: "Claude Haiku 4.6",
108
108
  },
109
+ {
110
+ key: "openai-codex/gpt-5.4",
111
+ provider: "openai-codex",
112
+ label: "GPT-5.4",
113
+ },
109
114
  {
110
115
  key: "openai-codex/gpt-5.3-codex",
111
116
  provider: "openai-codex",
@@ -365,6 +370,7 @@ const SETUP_API_PREFIXES = [
365
370
  "/api/usage",
366
371
  "/api/agents",
367
372
  "/api/channels",
373
+ "/api/operations",
368
374
  ];
369
375
 
370
376
  module.exports = {
@@ -8,6 +8,7 @@ const kGlobalModelPricing = {
8
8
  "claude-sonnet-4-6": { input: 3.0, output: 15.0 },
9
9
  "claude-haiku-4-6": { input: 0.8, output: 4.0 },
10
10
  "gpt-5": { input: 1.25, output: 10.0 },
11
+ "gpt-5.4": { input: 2.5, output: 10.0 },
11
12
  "gpt-5.1-codex": { input: 2.5, output: 10.0 },
12
13
  "gpt-5.3-codex": { input: 2.5, output: 10.0 },
13
14
  "gpt-4.1": { input: 2.0, output: 8.0 },
@@ -17,7 +17,20 @@ const readOpenclawConfig = ({
17
17
  }
18
18
  };
19
19
 
20
+ const writeOpenclawConfig = ({
21
+ fsModule = fs,
22
+ openclawDir,
23
+ config = {},
24
+ spacing = 2,
25
+ } = {}) => {
26
+ const configPath = resolveOpenclawConfigPath({ openclawDir });
27
+ fsModule.mkdirSync(path.dirname(configPath), { recursive: true });
28
+ fsModule.writeFileSync(configPath, JSON.stringify(config, null, spacing));
29
+ return configPath;
30
+ };
31
+
20
32
  module.exports = {
21
33
  resolveOpenclawConfigPath,
22
34
  readOpenclawConfig,
35
+ writeOpenclawConfig,
23
36
  };
@@ -34,6 +34,11 @@ const registerModelRoutes = ({
34
34
  return next;
35
35
  };
36
36
 
37
+ const removeEnvVar = (items, key) => {
38
+ const next = Array.isArray(items) ? [...items] : [];
39
+ return next.filter((entry) => entry.key !== key);
40
+ };
41
+
37
42
  const readEnvVarMap = () => {
38
43
  if (typeof readEnvFile !== "function") return new Map();
39
44
  return new Map(
@@ -105,10 +110,16 @@ const registerModelRoutes = ({
105
110
  if (profile?.type !== "api_key") continue;
106
111
  const envKey = authProfiles.getEnvVarForApiKeyProvider?.(profile.provider);
107
112
  const envValue = String(profile?.key || "").trim();
108
- if (!envKey || !envValue) continue;
113
+ if (!envKey) continue;
109
114
  const prevValue = String(
110
115
  nextEnvVars.find((entry) => entry.key === envKey)?.value || "",
111
116
  );
117
+ if (!envValue) {
118
+ if (!prevValue) continue;
119
+ nextEnvVars = removeEnvVar(nextEnvVars, envKey);
120
+ changed = true;
121
+ continue;
122
+ }
112
123
  if (prevValue === envValue) continue;
113
124
  nextEnvVars = upsertEnvVar(nextEnvVars, envKey, envValue);
114
125
  changed = true;