-
Notifications
You must be signed in to change notification settings - Fork 3.1k
Expand file tree
/
Copy pathpolicy-channel.ts
More file actions
1238 lines (1148 loc) · 48 KB
/
Copy pathpolicy-channel.ts
File metadata and controls
1238 lines (1148 loc) · 48 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import fs from "node:fs";
import path from "node:path";
import { loadAgent, type AgentDefinition } from "../../agent/defs";
import { CLI_DISPLAY_NAME, CLI_NAME } from "../../cli/branding";
import { hashCredential } from "../../security/credential-hash";
import { getCredential, prompt as askPrompt } from "../../credentials/store";
import { recoverNamedGatewayRuntime } from "../../gateway-runtime-action";
const { isNonInteractive } = require("../../onboard") as { isNonInteractive: () => boolean };
const onboardProviders = require("../../onboard/providers");
import * as policies from "../../policy";
// Lazy-required: keeps qrcode-terminal + the iLink HTTP client out of the
// import graph for non-host-qr channels-add calls.
const { HOST_QR_LOGIN_HANDLERS } = require("../../host-qr-handlers") as typeof import("../../host-qr-handlers");
const onboardSession = require("../../state/onboard-session") as typeof import("../../state/onboard-session");
import {
parsePolicyAddOptions,
type PolicyAddOptions,
type PolicyRemoveOptions,
} from "../../domain/policy-channel";
import * as registry from "../../state/registry";
import { runOpenshell } from "../../adapters/openshell/runtime";
import { shellQuote } from "../../runner";
import { executeSandboxCommand, executeSandboxExecCommand } from "./process-recovery";
import { rebuildSandbox } from "./rebuild";
import { printTelegramDirectMessageAllowlistWarning } from "./telegram-channel-bridge-verification";
import {
type ChannelDef,
KNOWN_CHANNELS,
channelUsesInSandboxQrPairing,
clearChannelTokens,
getChannelDef,
getChannelTokenKeys,
knownChannelNames,
persistChannelTokens,
} from "../../sandbox/channels";
import type { HostQrLoginResult } from "../../host-qr-handlers";
type ChannelMutationOptions = {
channel?: string;
dryRun?: boolean;
};
const useColor = !process.env.NO_COLOR && !!process.stdout.isTTY;
const trueColor =
useColor && (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit");
const G = useColor ? (trueColor ? "\x1b[38;2;118;185;0m" : "\x1b[38;5;148m") : "";
const R = useColor ? "\x1b[0m" : "";
const YW = useColor ? "\x1b[1;33m" : "";
/**
* Handle `nemoclaw <sandbox> policy-add [flags]`. Supports three mutually
* exclusive modes: interactive preset picker (default), `--from-file <path>`
* for a single custom preset YAML, and `--from-dir <path>` for every
* `.yaml`/`.yml` file in a directory. `--dry-run` previews without applying,
* `--yes`/`-y`/`--force` (or `NEMOCLAW_NON_INTERACTIVE=1`) skips the
* confirmation prompt. `--from-dir` applies non-hidden files in lexicographic
* order and aborts at the first failure (already-applied presets are not
* rolled back).
*/
export async function addSandboxPolicy(
sandboxName: string,
options: PolicyAddOptions = {},
): Promise<void> {
const { dryRun, skipConfirm, source, presetArg } = parsePolicyAddOptions(options);
if (source.kind === "error") {
console.error(` ${source.message}`);
process.exit(1);
}
if (source.kind === "file") {
const ok = await applyExternalPreset(sandboxName, source.path, { dryRun, yes: skipConfirm });
if (!ok) process.exit(1);
return;
}
if (source.kind === "dir") {
const dirPath = source.path;
const absDir = path.resolve(dirPath);
if (!fs.existsSync(absDir) || !fs.statSync(absDir).isDirectory()) {
console.error(` Directory not found: ${dirPath}`);
process.exit(1);
}
const files = fs
.readdirSync(absDir, { withFileTypes: true })
.filter(
(ent: { name: string; isFile(): boolean }) =>
ent.isFile() && !ent.name.startsWith(".") && /\.ya?ml$/i.test(ent.name),
)
.map((ent: { name: string }) => path.join(absDir, ent.name))
.sort();
if (files.length === 0) {
console.error(` No .yaml/.yml preset files in ${dirPath}`);
process.exit(1);
}
for (const f of files) {
const ok = await applyExternalPreset(sandboxName, f, { dryRun, yes: skipConfirm });
if (!ok) {
console.error(` Aborting --from-dir: ${f} failed. Remaining presets not applied.`);
process.exit(1);
}
}
return;
}
const allPresets = policies.listPresets();
const applied = policies.getAppliedPresets(sandboxName);
let answer = null;
if (presetArg) {
const normalized = presetArg.trim().toLowerCase();
const preset = allPresets.find((item: { name: string }) => item.name === normalized);
if (!preset) {
console.error(` Unknown preset '${presetArg}'.`);
console.error(
` Valid presets: ${allPresets.map((item: { name: string }) => item.name).join(", ")}`,
);
process.exit(1);
}
if (applied.includes(preset.name)) {
console.error(` Preset '${preset.name}' is already applied.`);
process.exit(1);
}
answer = preset.name;
} else {
if (process.env.NEMOCLAW_NON_INTERACTIVE === "1") {
console.error(" Non-interactive mode requires a preset name.");
console.error(` Usage: ${CLI_NAME} <sandbox> policy-add <preset> [--yes] [--dry-run]`);
process.exit(1);
}
answer = await policies.selectFromList(allPresets, { applied });
}
if (!answer) return;
const presetContent = policies.loadPreset(answer);
if (!presetContent) return;
const endpoints = policies.getPresetEndpoints(presetContent);
if (endpoints.length > 0) {
console.log(` Endpoints that would be opened: ${endpoints.join(", ")}`);
}
const presetWarning = policies.getPresetValidationWarning(answer);
if (presetWarning) {
console.log("");
console.log(` ${presetWarning}`);
console.log("");
}
if (dryRun) {
console.log(" --dry-run: no changes applied.");
return;
}
if (!skipConfirm) {
const confirm = await askPrompt(` Apply '${answer}' to sandbox '${sandboxName}'? [Y/n]: `);
if (confirm.trim().toLowerCase().startsWith("n")) return;
}
if (!policies.applyPreset(sandboxName, answer)) {
process.exit(1);
}
syncSessionPolicyPresetsWithRegistry(sandboxName, answer, "add");
}
/**
* Apply one custom preset file (`--from-file`, or one entry of `--from-dir`)
* to a sandbox. Loads and validates the file via `policies.loadPresetFromFile`,
* prints the egress endpoints with a warning that custom targets are not
* vetted, honors `dryRun` and `yes`, and delegates to
* `policies.applyPresetContent`. Returns `true` on success, `false` on any
* load/apply failure so the caller can decide whether to abort.
*/
async function applyExternalPreset(
sandboxName: string,
filePath: string,
{ dryRun, yes }: { dryRun: boolean; yes: boolean },
): Promise<boolean> {
let loaded;
try {
loaded = policies.loadPresetFromFile(filePath);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error(` Failed to load preset ${filePath}: ${message}`);
return false;
}
if (!loaded) return false;
const endpoints = policies.getPresetEndpoints(loaded.content);
if (endpoints.length > 0) {
console.log(` [${loaded.presetName}] Endpoints that would be opened: ${endpoints.join(", ")}`);
console.log(
` ${YW}Warning: custom preset targets are not vetted. Review hosts before applying.${R}`,
);
}
if (dryRun) {
console.log(` --dry-run: '${loaded.presetName}' not applied.`);
return true;
}
if (!yes) {
const confirm = await askPrompt(
` Apply '${loaded.presetName}' from ${filePath} to sandbox '${sandboxName}'? [Y/n]: `,
);
if (confirm.trim().toLowerCase().startsWith("n")) return true; // user-cancel counts as success (no abort)
}
try {
const result = policies.applyPresetContent(sandboxName, loaded.presetName, loaded.content, {
custom: { sourcePath: path.resolve(filePath) },
});
if (result !== false) {
// Custom presets share the registry slot with built-ins (customPolicies
// in policy/index.ts:684), so they need the same session-sync.
syncSessionPolicyPresetsWithRegistry(sandboxName, loaded.presetName, "add");
}
return result !== false;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error(` Failed to apply preset '${loaded.presetName}': ${message}`);
return false;
}
}
export function listSandboxPolicies(sandboxName: string) {
const builtin = policies.listPresets();
const custom = policies.listCustomPresets(sandboxName);
const allPresets = [...builtin, ...custom];
const registryPresets = policies.getAppliedPresets(sandboxName);
// getGatewayPresets returns null when gateway is unreachable, or an
// array of matched preset names when reachable (possibly empty).
const gatewayPresets = policies.getGatewayPresets(sandboxName);
console.log("");
console.log(` Policy presets for sandbox '${sandboxName}':`);
allPresets.forEach((p: { name: string; description: string }) => {
const inRegistry = registryPresets.includes(p.name);
const inGateway = gatewayPresets ? gatewayPresets.includes(p.name) : null;
let marker;
let suffix = "";
if (inGateway === null) {
// Gateway unreachable — fall back to registry-only display
marker = inRegistry ? "●" : "○";
} else if (inRegistry && inGateway) {
marker = "●";
} else if (!inRegistry && !inGateway) {
marker = "○";
} else if (inGateway && !inRegistry) {
marker = "●";
suffix = " (active on gateway, missing from local state)";
} else {
// inRegistry && !inGateway
marker = "○";
suffix = " (recorded locally, not active on gateway)";
}
console.log(` ${marker} ${p.name} — ${p.description}${suffix}`);
});
if (gatewayPresets === null) {
console.log("");
console.log(" ⚠ Could not query gateway — showing local state only.");
}
console.log("");
}
// ── Messaging channels ───────────────────────────────────────────
function resolveAgentForSandbox(sandboxName: string): AgentDefinition {
const entry = registry.getSandbox(sandboxName);
const agentName = entry?.agent || "openclaw";
return loadAgent(agentName);
}
function channelSupportedByAgent(channelName: string, agent: AgentDefinition): boolean {
const supported = agent.messagingPlatforms;
return !Array.isArray(supported) || supported.length === 0 || supported.includes(channelName);
}
export function listSandboxChannels(sandboxName: string) {
const agent = resolveAgentForSandbox(sandboxName);
console.log("");
console.log(` Known messaging channels for sandbox '${sandboxName}':`);
for (const [name, channel] of Object.entries(KNOWN_CHANNELS)) {
if (!channelSupportedByAgent(name, agent)) continue;
console.log(` ${name} — ${channel.description}`);
}
console.log("");
}
// Map a channel + token-env-key to the OpenShell provider name onboarding
// uses for it. Mirrors the names in src/lib/onboard.ts:3201-3221 so a
// channels-add upsert collides with (i.e. updates) the same provider that
// a later rebuild would have created from scratch.
function bridgeProviderName(sandboxName: string, channelName: string, envKey: string): string {
if (channelName === "slack" && envKey === "SLACK_APP_TOKEN") {
return `${sandboxName}-slack-app`;
}
return `${sandboxName}-${channelName}-bridge`;
}
// Push channel tokens to the OpenShell gateway and add the channel to the
// sandbox registry's messagingChannels list. Done eagerly at `channels
// add` time (not deferred to rebuild) because the host-side credential
// helpers are env-only after the fix — without an immediate gateway
// upsert plus registry update, a "rebuild later" answer would drop the
// queued change since process.env disappears when the CLI exits.
async function applyChannelAddToGatewayAndRegistry(
sandboxName: string,
channelName: string,
acquired: Record<string, string>,
): Promise<void> {
const tokenDefs = Object.entries(acquired).map(([envKey, token]) => ({
name: bridgeProviderName(sandboxName, channelName, envKey),
envKey,
token,
}));
if (tokenDefs.length > 0) {
const recovery = await recoverNamedGatewayRuntime();
if (!recovery.recovered) {
console.error(
` Could not reach the ${CLI_DISPLAY_NAME} OpenShell gateway. Tokens were staged`,
);
console.error(" in env for this run only — re-run after starting the gateway, or run");
console.error(" 'openshell gateway start --name nemoclaw' manually.");
process.exit(1);
}
// upsertMessagingProviders handles create-or-update and process.exits on
// failure, so reaching the next line means every entry is registered.
onboardProviders.upsertMessagingProviders(tokenDefs, runOpenshell);
}
// Persist the enabled-channels list in the registry so a deferred
// `nemoclaw <sandbox> rebuild` knows the channel set without needing
// tokens on disk.
const entry = registry.getSandbox(sandboxName);
if (entry) {
const enabled = new Set(entry.messagingChannels || []);
enabled.add(channelName);
const disabled = (entry.disabledChannels || []).filter((c: string) => c !== channelName);
const providerCredentialHashes = { ...(entry.providerCredentialHashes || {}) };
for (const [envKey, token] of Object.entries(acquired)) {
const hash = hashCredential(token);
if (hash) providerCredentialHashes[envKey] = hash;
}
registry.updateSandbox(sandboxName, {
messagingChannels: Array.from(enabled).sort(),
disabledChannels: disabled,
providerCredentialHashes:
Object.keys(providerCredentialHashes).length > 0 ? providerCredentialHashes : undefined,
});
}
}
// Remove a channel's bridge providers from the gateway and drop it from the
// registry's messagingChannels list. Mirrors applyChannelAddToGatewayAndRegistry.
async function applyChannelRemoveToGatewayAndRegistry(
sandboxName: string,
channelName: string,
channelTokenKeys: string[],
): Promise<void> {
if (channelTokenKeys.length > 0) {
const recovery = await recoverNamedGatewayRuntime();
if (!recovery.recovered) {
console.error(
` Could not reach the ${CLI_DISPLAY_NAME} OpenShell gateway to delete the bridge.`,
);
console.error(
" Re-run after starting the gateway, or run 'openshell gateway start --name nemoclaw'.",
);
process.exit(1);
}
}
// Detach providers from the sandbox before deletion. openshell rejects
// `provider delete` with FailedPrecondition when the provider is still
// attached to a sandbox; the sandbox image itself only stops referencing
// the bridge after the next rebuild, so without an explicit detach the
// delete will fail on any sandbox that is still alive at remove-time.
// NotFound / NotAttached are treated as success-equivalent because a
// previous run may have already detached, or the channel may have been
// configured for a sandbox that is no longer alive.
const detachFailures: Array<{ name: string; output: string }> = [];
for (const envKey of channelTokenKeys) {
const name = bridgeProviderName(sandboxName, channelName, envKey);
const result = runOpenshell(["sandbox", "provider", "detach", sandboxName, name], {
ignoreError: true,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
const output = `${result.stdout || ""}${result.stderr || ""}`;
if (!/\bNotFound\b|not found|not attached/i.test(output)) {
detachFailures.push({ name, output: output.trim() });
}
}
}
if (detachFailures.length > 0) {
console.error(
` Failed to detach bridge provider(s) from sandbox '${sandboxName}': ${detachFailures.map((f) => f.name).join(", ")}.`,
);
for (const f of detachFailures) {
console.error(` [${f.name}] ${f.output.split("\n").join("\n ")}`);
}
console.error(" Registry not updated; re-run after resolving the gateway error.");
process.exit(1);
}
// Capture each delete's outcome. If any non-NotFound failure surfaces
// we must NOT update the registry — otherwise NemoClaw would record
// the channel as removed locally while the bridge is still live in
// the gateway, which produces a half-configured sandbox the user
// can't easily recover. Surface the underlying openshell output so the
// operator can see exactly why the delete was rejected.
const deleteFailures: Array<{ name: string; output: string }> = [];
for (const envKey of channelTokenKeys) {
const name = bridgeProviderName(sandboxName, channelName, envKey);
const result = runOpenshell(["provider", "delete", name], {
ignoreError: true,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
const output = `${result.stdout || ""}${result.stderr || ""}`;
// Treat "not found" as success-equivalent — a previous run may
// have already deleted the provider.
if (!/\bNotFound\b|not found/i.test(output)) {
deleteFailures.push({ name, output: output.trim() });
}
}
}
if (deleteFailures.length > 0) {
console.error(
` Failed to delete bridge provider(s) from the OpenShell gateway: ${deleteFailures.map((f) => f.name).join(", ")}.`,
);
for (const f of deleteFailures) {
console.error(` [${f.name}] ${f.output.split("\n").join("\n ")}`);
}
console.error(" Registry not updated; re-run after resolving the gateway error.");
process.exit(1);
}
const entry = registry.getSandbox(sandboxName);
if (entry) {
const enabled = (entry.messagingChannels || []).filter((c: string) => c !== channelName);
const providerCredentialHashes = { ...(entry.providerCredentialHashes || {}) };
for (const envKey of channelTokenKeys) {
delete providerCredentialHashes[envKey];
}
registry.updateSandbox(sandboxName, {
messagingChannels: enabled,
providerCredentialHashes:
Object.keys(providerCredentialHashes).length > 0 ? providerCredentialHashes : undefined,
});
}
}
async function promptAndRebuild(sandboxName: string, actionDesc: string): Promise<boolean> {
if (isNonInteractive()) {
console.log("");
console.log(
` Change queued. Run '${CLI_NAME} ${sandboxName} rebuild' to apply (${actionDesc}).`,
);
return false;
}
const answer = (await askPrompt(` Rebuild '${sandboxName}' now to apply? [Y/n]: `))
.trim()
.toLowerCase();
if (answer === "n" || answer === "no") {
console.log(
` Run '${CLI_NAME} ${sandboxName} rebuild' when you are ready to apply (${actionDesc}).`,
);
return false;
}
await rebuildSandbox(sandboxName, ["--yes"]);
return true;
}
// Channels that share the canonical OpenClaw `channels.<name>.enabled` shape
// and emit `[<name>] [default]` startup breadcrumbs in /tmp/gateway.log.
// WhatsApp is QR-only (no host-side bridge process at this point), and WeChat
// is recorded under the `openclaw-weixin` channel id with its own per-account
// metadata flow seeded by seed-wechat-accounts.py — neither match the probe
// shape and would produce false-negative warnings here.
const OPENCLAW_BRIDGE_VERIFIABLE_CHANNELS = new Set(["telegram", "discord", "slack"]);
// Probe OpenClaw runtime state for a freshly added messaging channel. Runs
// after `channels add <channel>` triggers a successful rebuild. Reads the
// baked openclaw.json and tails the gateway log to confirm the bridge module
// is enabled and emitted a startup breadcrumb. Failures here are best-effort
// warnings — the rebuild has already succeeded; the goal is to surface
// "bridge did not spawn" so the user does not discover it from radio silence
// hours later (#4314, #4390). Restricted to the OpenClaw agent because Hermes
// sandboxes use /sandbox/.hermes with a different config layout.
function verifyChannelBridgeAfterRebuild(sandboxName: string, channelName: string): void {
if (!OPENCLAW_BRIDGE_VERIFIABLE_CHANNELS.has(channelName)) return;
const agent = resolveAgentForSandbox(sandboxName);
if (agent.name !== "openclaw") return;
const configProbe = executeSandboxExecCommand(
sandboxName,
"cat /sandbox/.openclaw/openclaw.json 2>/dev/null || true",
10000,
);
if (!configProbe || configProbe.status !== 0 || !configProbe.stdout) {
console.log(
` ${YW}⚠${R} Could not read /sandbox/.openclaw/openclaw.json to verify '${channelName}' bridge startup.`,
);
console.log(
` Run '${CLI_NAME} ${sandboxName} status' to inspect the sandbox once it is fully running.`,
);
return;
}
let channelEnabled = false;
let channelBlock: any = null;
try {
const cfg = JSON.parse(configProbe.stdout);
channelBlock = cfg?.channels?.[channelName];
channelEnabled = Boolean(channelBlock?.enabled);
} catch {
// Malformed config — fall through to the log probe to capture context.
}
if (!channelEnabled) {
console.log(
` ${YW}⚠${R} '${channelName}' channel was not marked enabled in baked openclaw.json after rebuild.`,
);
console.log(
` The bridge will not start. Re-run '${CLI_NAME} ${sandboxName} rebuild' or 'channels remove ${channelName}' and add again.`,
);
return;
}
// Match both the channel module's own breadcrumbs (`[<channel>] [default]`)
// and the channel-guard preloads' aggregated form (`[channels] [<channel>]`).
// The Slack guard writes "[channels] [slack] provider failed to start..."
// when a token is rejected; ignoring that line here would leave the user
// with a generic "no breadcrumb" warning instead of the actionable cause.
const logProbe = executeSandboxExecCommand(
sandboxName,
`tail -n 400 /tmp/gateway.log 2>/dev/null | grep -E "^\\[${channelName}\\] |^\\[channels\\] \\[${channelName}\\]" || true`,
10000,
);
const lines = (logProbe?.stdout || "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) {
console.log(
` ${YW}⚠${R} '${channelName}' bridge did not log a startup breadcrumb in /tmp/gateway.log yet.`,
);
console.log(
` Tail it with 'openshell sandbox exec --name ${sandboxName} -- tail -f /tmp/gateway.log' if the channel stays silent.`,
);
return;
}
const credentialWarnings = lines.filter((line) =>
/credential placeholder|Bot API rejected|startup probe (?:failed|returned)|provider failed to start|bridge did not start within|invalid_auth|token_revoked|token_expired/i.test(
line,
),
);
if (credentialWarnings.length > 0) {
console.log(
` ${YW}⚠${R} '${channelName}' bridge logged credential/startup warnings:`,
);
for (const line of credentialWarnings.slice(0, 3)) {
console.log(` ${line}`);
}
console.log(
` Verify the OpenShell provider for ${channelName} holds a valid credential and re-run '${CLI_NAME} ${sandboxName} rebuild' if needed.`,
);
return;
}
// Treat the channel as observably started only when we see a positive
// startup signal from the bridge module itself ("starting provider" /
// "provider ready"). Otherwise the grep above matched a tangential
// breadcrumb (e.g. a stale "no startup detected" line) and a green
// "startup detected" message would be misleading.
const positiveStartup = lines.some((line) =>
/\bstarting provider\b|\bprovider ready\b/.test(line),
);
if (positiveStartup) {
console.log(
` ${G}✓${R} '${channelName}' bridge startup detected in sandbox runtime log.`,
);
if (channelName === "telegram") {
printTelegramDirectMessageAllowlistWarning(channelBlock, console.log, `${YW}⚠${R}`);
}
return;
}
console.log(
` ${YW}⚠${R} '${channelName}' bridge log lines found but no startup confirmation yet.`,
);
console.log(
` Tail it with 'openshell sandbox exec --name ${sandboxName} -- tail -f /tmp/gateway.log' if the channel stays silent.`,
);
}
// Paste-prompt token acquisition for Telegram / Discord / Slack — extracted
// from the original inline loop so `addSandboxChannel` can fork cleanly on
// `loginMethod`.
async function acquirePasteTokens(
channelArg: string,
channel: ChannelDef,
acquired: Record<string, string>,
): Promise<void> {
const tokenKeys = getChannelTokenKeys(channel);
for (const envKey of tokenKeys) {
const isPrimary = envKey === channel.envKey;
const help = isPrimary ? channel.help : channel.appTokenHelp;
const label = isPrimary ? channel.label : channel.appTokenLabel;
const existing = getCredential(envKey);
if (existing) {
acquired[envKey] = existing;
continue;
}
if (isNonInteractive()) {
console.error(` Missing ${envKey} for channel '${channelArg}'.`);
console.error(
` Set ${envKey} in the environment or via '${CLI_NAME} credentials' before running in non-interactive mode.`,
);
process.exit(1);
}
console.log("");
console.log(` ${help}`);
const token = (await askPrompt(` ${label}: `, { secret: true })).trim();
if (!token) {
console.error(` Aborted — no value entered for ${envKey}.`);
process.exit(1);
}
acquired[envKey] = token;
}
}
// Host-QR token acquisition for WeChat (the only channel with
// `loginMethod: "host-qr"` today). Drives the iLink QR handshake on the
// host, captures the bot token and the non-secret per-account metadata
// (accountId, baseUrl, userId), and stashes the metadata where the
// upcoming rebuild can find it:
// - `process.env` — for the in-process rebuild that fires next
// (`promptAndRebuild` → `rebuildSandbox` →
// `onboard --resume` reads WECHAT_ACCOUNT_ID
// etc. via the wechatConfig builder).
// - `session.wechatConfig` — for a deferred rebuild started from a fresh
// process. `rebuildSandbox`'s env-stash reads
// back from here.
async function acquireHostQrChannel(
sandboxName: string,
channelArg: string,
channel: ChannelDef,
acquired: Record<string, string>,
): Promise<void> {
const envKey = channel.envKey;
if (!envKey) {
console.error(` Channel '${channelArg}' does not declare a credential environment key.`);
process.exit(1);
}
// Cached-token short-circuit. A sandbox originally onboarded with this
// channel already has the bot token in OpenShell + the per-account
// metadata in session.wechatConfig. Re-running QR would invalidate the
// upstream plugin's existing iLink session; prefer the cache and let
// the rebuild's env-stash re-bake from session.
const cached = getCredential(envKey);
if (cached) {
if (channelArg === "wechat") {
// The rebuild needs accountId/baseUrl/userId to reconstruct the
// upstream plugin's account state file via seed-wechat-accounts.py.
// Restore them from session here so a deferred rebuild (started in a
// fresh process where rebuild.ts hasn't stashed yet) still finds
// them — and bail loudly if the session was cleared. Only honor the
// session entry when it belongs to THIS sandbox, otherwise we'd bake
// another sandbox's WECHAT_* into this image.
const savedSession = onboardSession.loadSession();
const savedWechat =
savedSession?.sandboxName === sandboxName ? savedSession.wechatConfig ?? null : null;
if (savedWechat?.accountId && !process.env.WECHAT_ACCOUNT_ID) {
process.env.WECHAT_ACCOUNT_ID = savedWechat.accountId;
if (savedWechat.baseUrl) process.env.WECHAT_BASE_URL = savedWechat.baseUrl;
if (savedWechat.userId) process.env.WECHAT_USER_ID = savedWechat.userId;
}
if (!process.env.WECHAT_ACCOUNT_ID) {
console.error(" Cached WeChat token found, but per-account metadata is missing.");
console.error(
` Run '${CLI_NAME} ${sandboxName} channels remove ${channelArg}' then '${CLI_NAME} ${sandboxName} channels add ${channelArg}' to capture a fresh account via QR.`,
);
process.exit(1);
}
}
acquired[envKey] = cached;
return;
}
if (isNonInteractive()) {
console.error(
` '${channelArg}' requires an interactive QR login; cannot run in non-interactive mode.`,
);
console.error(
` Run '${CLI_NAME} ${sandboxName} channels add ${channelArg}' interactively instead.`,
);
process.exit(1);
}
const handler = HOST_QR_LOGIN_HANDLERS[channelArg];
if (!handler) {
console.error(` No host-qr handler registered for '${channelArg}'.`);
process.exit(1);
}
console.log("");
console.log(` ${channel.help}`);
let result: HostQrLoginResult;
try {
result = await handler();
} catch (err: unknown) {
result = { kind: "error", message: err instanceof Error ? err.message : String(err) };
}
if (result.kind !== "ok") {
const reason =
result.kind === "timeout"
? "QR login timed out"
: result.kind === "expired"
? "QR expired too many times"
: result.kind === "aborted"
? "login aborted"
: `login failed: ${result.message ?? "unknown error"}`;
console.error(` Aborted — ${reason}.`);
process.exit(1);
}
if (!result.token) {
console.error(" Aborted — host-qr handler returned no token.");
process.exit(1);
}
acquired[envKey] = result.token;
if (result.extraEnv) {
for (const [key, value] of Object.entries(result.extraEnv)) {
process.env[key] = value;
}
}
if (channel.userIdEnvKey && result.defaultUserId && !process.env[channel.userIdEnvKey]) {
process.env[channel.userIdEnvKey] = result.defaultUserId;
}
if (channelArg === "wechat" && result.extraEnv) {
const captured = {
accountId: result.extraEnv.WECHAT_ACCOUNT_ID,
baseUrl: result.extraEnv.WECHAT_BASE_URL,
userId: result.extraEnv.WECHAT_USER_ID,
};
onboardSession.updateSession((current) => {
const prior = current.wechatConfig;
current.wechatConfig = {
accountId: captured.accountId || prior?.accountId,
baseUrl: captured.baseUrl || prior?.baseUrl,
userId: captured.userId || prior?.userId,
};
return current;
});
}
const suffix = result.summary ? ` (${result.summary})` : "";
console.log(` ${G}✓${R} ${channelArg} token saved${suffix}.`);
}
export async function addSandboxChannel(
sandboxName: string,
options: ChannelMutationOptions = {},
): Promise<void> {
const dryRun = Boolean(options.dryRun);
const rawChannelArg = options.channel;
if (!rawChannelArg) {
console.error(` Usage: ${CLI_NAME} <sandbox> channels add <channel> [--dry-run]`);
console.error(` Valid channels: ${knownChannelNames().join(", ")}`);
process.exit(1);
}
const channel = getChannelDef(rawChannelArg);
if (!channel) {
console.error(` Unknown channel '${rawChannelArg}'.`);
console.error(` Valid channels: ${knownChannelNames().join(", ")}`);
process.exit(1);
}
const canonical = rawChannelArg.trim().toLowerCase();
const agent = resolveAgentForSandbox(sandboxName);
if (!channelSupportedByAgent(canonical, agent)) {
console.error(
` Channel '${canonical}' is not supported by agent '${agent.name}' for sandbox '${sandboxName}'.`,
);
console.error(` Supported channels: ${agent.messagingPlatforms.join(", ") || "(none)"}`);
process.exit(1);
}
if (dryRun) {
console.log(` --dry-run: would enable channel '${canonical}' for '${sandboxName}'.`);
return;
}
if (policies.loadPreset(canonical) === null) {
process.exit(1);
}
// QR-paired channels that own their session inside the sandbox have no
// host-side credential to acquire; register the bridge now and let the
// operator complete pairing after rebuild.
if (channelUsesInSandboxQrPairing(channel)) {
if (!applyChannelPresetIfAvailable(sandboxName, canonical)) {
process.exit(1);
}
await applyChannelAddToGatewayAndRegistry(sandboxName, canonical, {});
console.log("");
console.log(` ${channel.help}`);
console.log(
` ${G}✓${R} Enabled ${canonical} channel. Complete QR pairing from inside the sandbox after rebuild.`,
);
// Show post-pair guidance (e.g. the channels status hint for WhatsApp)
// here because the in-sandbox QR branch returns before the shared note
// loop the non-QR branches use.
for (const line of channel.setupNotes ?? []) {
console.log(` ${line}`);
}
const rebuilt = await promptAndRebuild(sandboxName, `add '${canonical}'`);
if (rebuilt) verifyChannelBridgeAfterRebuild(sandboxName, canonical);
return;
}
const acquired: Record<string, string> = {};
if (channel.loginMethod === "host-qr") {
await acquireHostQrChannel(sandboxName, canonical, channel, acquired);
} else {
await acquirePasteTokens(canonical, channel, acquired);
}
persistChannelTokens(acquired);
// Push to the gateway and update the registry NOW so that answering
// "rebuild later" (or running non-interactively) does not silently
// discard the change. Pre-fix this was safe because saveCredential()
// wrote credentials.json; with env-only persistence, exiting before
// the rebuild used to drop the queued token.
await applyChannelAddToGatewayAndRegistry(sandboxName, canonical, acquired);
console.log(` ${G}✓${R} Registered ${canonical} bridge with the OpenShell gateway.`);
if (!applyChannelPresetIfAvailable(sandboxName, canonical)) {
console.error(
` ${YW}⚠${R} Rolling back '${canonical}' bridge registration to keep messagingChannels and policy state aligned.`,
);
await applyChannelRemoveToGatewayAndRegistry(
sandboxName,
canonical,
getChannelTokenKeys(channel),
);
clearChannelTokens(channel);
process.exit(1);
}
const rebuilt = await promptAndRebuild(sandboxName, `add '${canonical}'`);
if (rebuilt) verifyChannelBridgeAfterRebuild(sandboxName, canonical);
}
function applyChannelPresetIfAvailable(sandboxName: string, channelName: string): boolean {
try {
const applied = policies.applyPreset(sandboxName, channelName);
if (!applied) {
console.error(
` ${YW}⚠${R} Cannot enable channel '${channelName}': policy preset failed to apply.`,
);
console.error(
` Restore the preset YAML and re-run: ${CLI_NAME} ${sandboxName} channels add ${channelName}`,
);
return false;
}
syncSessionPolicyPresetsWithRegistry(sandboxName, channelName, "add");
return true;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(` ${YW}⚠${R} Failed to apply '${channelName}' policy preset: ${msg}`);
console.error(
` Restore the preset YAML and re-run: ${CLI_NAME} ${sandboxName} channels add ${channelName}`,
);
return false;
}
}
function getSandboxChannelStatePaths(agent: AgentDefinition, channelName: string): string[] {
const configDir = agent.configPaths.dir;
const stateDirs = new Set(agent.stateDirs);
if (stateDirs.has("platforms")) {
return [`${configDir}/platforms/${channelName}`];
}
if (stateDirs.has(channelName)) {
return [`${configDir}/${channelName}`];
}
return [];
}
function isSafeChannelStatePath(p: string): boolean {
if (!p.startsWith("/sandbox/.")) return false;
if (p.includes("..")) return false;
return /^\/sandbox\/\.[A-Za-z0-9_./-]+$/.test(p);
}
const CHANNEL_CLEAR_SENTINEL = "NEMOCLAW_CHANNEL_CLEAR_OK";
// Wipe the durable per-channel state inside the sandbox before rebuild so
// the state_dirs backup does not restore an auth blob the operator just
// asked NemoClaw to forget. Returns true when no cleanup was needed OR
// when the in-sandbox rm produced our success sentinel; false otherwise.
// Tries `openshell sandbox exec` first and falls back to SSH for transient
// wrapper hiccups (mirrors the pattern in process-recovery.ts:286-296).
// Fixes #3998.
function clearSandboxChannelDurableState(sandboxName: string, channelName: string): boolean {
const agent = resolveAgentForSandbox(sandboxName);
const paths = getSandboxChannelStatePaths(agent, channelName).filter(isSafeChannelStatePath);
if (paths.length === 0) return true;
const quoted = paths.map((p) => shellQuote(p)).join(" ");
const cmd = `rm -rf -- ${quoted} && printf '%s\\n' ${shellQuote(CHANNEL_CLEAR_SENTINEL)}`;
const sentinelSeen = (result: { stdout?: string | null } | null): boolean =>
!!result && typeof result.stdout === "string" && result.stdout.includes(CHANNEL_CLEAR_SENTINEL);
let result = executeSandboxExecCommand(sandboxName, cmd);
if (!sentinelSeen(result)) {
result = executeSandboxCommand(sandboxName, cmd);
}
if (!sentinelSeen(result)) {
console.error(
` ${YW}⚠${R} Could not clear in-sandbox '${channelName}' channel state at ${paths.join(", ")}.`,
);
return false;
}
console.log(` ${G}✓${R} Cleared in-sandbox '${channelName}' channel state.`);
return true;
}
// Mirror a registry-side preset add/remove into `session.policyPresets`.
// Without this, a later `rebuild` re-enters onboard resume, reads the
// stale session, and narrows the preset back away — see #3437 follow-up.
// Best-effort: registry has already succeeded; failure paths log and
// swallow so the caller's flow is never broken by a session I/O error.
function syncSessionPolicyPresetsWithRegistry(
sandboxName: string,
presetName: string,
action: "add" | "remove",
): void {
let session: ReturnType<typeof onboardSession.loadSession>;
try {
session = onboardSession.loadSession();
} catch {
return;
}
// No session = nothing to sync. Foreign sandbox = leave its intent alone.
if (!session) return;
if (session.sandboxName !== sandboxName) return;
const current = Array.isArray(session.policyPresets) ? session.policyPresets : [];
const has = current.includes(presetName);
// Skip the file write when the desired state already holds.
if (action === "add" && has) return;
if (action === "remove" && !has) return;
try {
onboardSession.updateSession((s) => {
const arr = Array.isArray(s.policyPresets) ? [...s.policyPresets] : [];
if (action === "add") {
if (!arr.includes(presetName)) arr.push(presetName);
} else {
const idx = arr.indexOf(presetName);
if (idx >= 0) arr.splice(idx, 1);
}
s.policyPresets = arr;
return s;
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(
` ${YW}⚠${R} Could not record '${presetName}' preset ${action} in onboard session: ${msg}`,
);
console.error(
` Registry is consistent; rerun '${CLI_NAME} ${sandboxName} policy-${action === "add" ? "add" : "remove"} ${presetName}' after rebuild if needed.`,
);
}
}
// Mirror of applyChannelPresetIfAvailable. When the channel-named built-in
// preset is currently applied to the sandbox, un-apply it so `policy-list`
// no longer reports it active and the L7 proxy stops allow-listing the
// channel's upstream API (defense-in-depth: bridge is gone, egress to
// api.telegram.org / discord.com / slack.com should follow). Warns but does
// not abort the remove flow — the bridge teardown has already succeeded;
// the operator can run `policy-remove <channel>` manually if cleanup falters.
function removeChannelPresetIfPresent(sandboxName: string, channelName: string): void {
const builtinPresets = new Set(policies.listPresets().map((p) => p.name));
if (!builtinPresets.has(channelName)) {
syncSessionPolicyPresetsWithRegistry(sandboxName, channelName, "remove");
return;
}
if (!policies.getAppliedPresets(sandboxName).includes(channelName)) {
syncSessionPolicyPresetsWithRegistry(sandboxName, channelName, "remove");
return;
}
try {
const removed = policies.removePreset(sandboxName, channelName);
if (!removed) {