@botcord/daemon 0.2.90 → 0.2.92
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/gateway/channels/botcord.js +35 -22
- package/dist/gateway-control.js +80 -40
- package/dist/provision.js +20 -2
- package/dist/self-restart.d.ts +29 -0
- package/dist/self-restart.js +172 -0
- package/dist/skill-index.js +33 -12
- package/package.json +3 -3
- package/src/__tests__/gateway-control.test.ts +372 -0
- package/src/__tests__/provision.test.ts +23 -0
- package/src/__tests__/self-restart.test.ts +57 -0
- package/src/__tests__/skill-index.test.ts +41 -0
- package/src/gateway/__tests__/botcord-channel.test.ts +38 -0
- package/src/gateway/channels/botcord.ts +41 -22
- package/src/gateway-control.ts +91 -43
- package/src/provision.ts +21 -2
- package/src/self-restart.ts +218 -0
- package/src/skill-index.ts +34 -13
|
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
|
|
6
6
|
import type { DaemonConfig } from "../config.js";
|
|
7
|
+
import { saveGatewaySecret } from "../gateway/channels/secret-store.js";
|
|
7
8
|
import { LoginSessionStore } from "../gateway/channels/login-session.js";
|
|
8
9
|
import { createGatewayControl } from "../gateway-control.js";
|
|
9
10
|
|
|
@@ -324,6 +325,129 @@ describe("gateway_login_start / status", () => {
|
|
|
324
325
|
const secretPath = trackSecret(gwId);
|
|
325
326
|
const secret = JSON.parse(readFileSync(secretPath, "utf8")) as { appSecret?: string };
|
|
326
327
|
expect(secret.appSecret).toBe("feishu-secret-1234567890");
|
|
328
|
+
|
|
329
|
+
const updateAck = await ctrl.handleUpsert({
|
|
330
|
+
id: gwId,
|
|
331
|
+
type: "feishu",
|
|
332
|
+
accountId: "ag_alice",
|
|
333
|
+
enabled: true,
|
|
334
|
+
settings: {
|
|
335
|
+
allowedSenderIds: ["ou_bob"],
|
|
336
|
+
allowedChatIds: ["oc_team"],
|
|
337
|
+
domain: "feishu",
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
expect(updateAck.ok).toBe(true);
|
|
341
|
+
expect(state.cfg.thirdPartyGateways?.[0]).toMatchObject({
|
|
342
|
+
id: gwId,
|
|
343
|
+
type: "feishu",
|
|
344
|
+
appId: "cli_feishu_123",
|
|
345
|
+
allowedSenderIds: ["ou_bob"],
|
|
346
|
+
allowedChatIds: ["oc_team"],
|
|
347
|
+
});
|
|
348
|
+
expect(gw.addChannel).toHaveBeenLastCalledWith(
|
|
349
|
+
expect.objectContaining({
|
|
350
|
+
id: gwId,
|
|
351
|
+
type: "feishu",
|
|
352
|
+
appId: "cli_feishu_123",
|
|
353
|
+
allowedChatIds: ["oc_team"],
|
|
354
|
+
}),
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("rejects changing Feishu domain without a fresh loginId", async () => {
|
|
359
|
+
const gw = makeFakeGateway();
|
|
360
|
+
const { io } = makeConfigIO(baseCfg());
|
|
361
|
+
const sessions = new LoginSessionStore();
|
|
362
|
+
const ctrl = createGatewayControl({
|
|
363
|
+
gateway: gw as any,
|
|
364
|
+
configIO: io,
|
|
365
|
+
loginSessions: sessions,
|
|
366
|
+
});
|
|
367
|
+
const gwId = uniqId("fs-domain");
|
|
368
|
+
trackSecret(gwId);
|
|
369
|
+
|
|
370
|
+
sessions.create({
|
|
371
|
+
loginId: "fsl_domain_seed",
|
|
372
|
+
accountId: "ag_alice",
|
|
373
|
+
provider: "feishu",
|
|
374
|
+
appId: "cli_domain_seed",
|
|
375
|
+
appSecret: "feishu-domain-secret-seed",
|
|
376
|
+
domain: "feishu",
|
|
377
|
+
userOpenId: "ou_domain_seed",
|
|
378
|
+
});
|
|
379
|
+
const installAck = await ctrl.handleUpsert({
|
|
380
|
+
id: gwId,
|
|
381
|
+
type: "feishu",
|
|
382
|
+
accountId: "ag_alice",
|
|
383
|
+
enabled: true,
|
|
384
|
+
loginId: "fsl_domain_seed",
|
|
385
|
+
settings: { domain: "feishu" },
|
|
386
|
+
});
|
|
387
|
+
expect(installAck.ok).toBe(true);
|
|
388
|
+
|
|
389
|
+
const updateAck = await ctrl.handleUpsert({
|
|
390
|
+
id: gwId,
|
|
391
|
+
type: "feishu",
|
|
392
|
+
accountId: "ag_alice",
|
|
393
|
+
enabled: true,
|
|
394
|
+
settings: { domain: "lark" },
|
|
395
|
+
});
|
|
396
|
+
expect(updateAck.ok).toBe(false);
|
|
397
|
+
expect(updateAck.error?.code).toBe("bad_params");
|
|
398
|
+
expect(updateAck.error?.message).toContain("domain change requires a fresh loginId");
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("rejects changing Feishu domain without loginId when saved profile omits domain (implicit feishu)", async () => {
|
|
402
|
+
const gw = makeFakeGateway();
|
|
403
|
+
const { state, io } = makeConfigIO(baseCfg());
|
|
404
|
+
const sessions = new LoginSessionStore();
|
|
405
|
+
const ctrl = createGatewayControl({
|
|
406
|
+
gateway: gw as any,
|
|
407
|
+
configIO: io,
|
|
408
|
+
loginSessions: sessions,
|
|
409
|
+
});
|
|
410
|
+
const gwId = uniqId("fs-domain-implicit");
|
|
411
|
+
trackSecret(gwId);
|
|
412
|
+
|
|
413
|
+
sessions.create({
|
|
414
|
+
loginId: "fsl_domain_implicit_seed",
|
|
415
|
+
accountId: "ag_alice",
|
|
416
|
+
provider: "feishu",
|
|
417
|
+
appId: "cli_domain_implicit_seed",
|
|
418
|
+
appSecret: "feishu-domain-implicit-secret-seed",
|
|
419
|
+
domain: "feishu",
|
|
420
|
+
userOpenId: "ou_domain_implicit_seed",
|
|
421
|
+
});
|
|
422
|
+
const installAck = await ctrl.handleUpsert({
|
|
423
|
+
id: gwId,
|
|
424
|
+
type: "feishu",
|
|
425
|
+
accountId: "ag_alice",
|
|
426
|
+
enabled: true,
|
|
427
|
+
loginId: "fsl_domain_implicit_seed",
|
|
428
|
+
settings: { domain: "feishu" },
|
|
429
|
+
});
|
|
430
|
+
expect(installAck.ok).toBe(true);
|
|
431
|
+
|
|
432
|
+
// Simulate legacy/missing persisted domain. The daemon should treat this
|
|
433
|
+
// as implicit "feishu" when validating no-login domain changes.
|
|
434
|
+
const saved = state.cfg.thirdPartyGateways?.find((g) => g.id === gwId);
|
|
435
|
+
expect(saved).toBeDefined();
|
|
436
|
+
if (saved) {
|
|
437
|
+
delete saved.domain;
|
|
438
|
+
io.save(state.cfg);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const updateAck = await ctrl.handleUpsert({
|
|
442
|
+
id: gwId,
|
|
443
|
+
type: "feishu",
|
|
444
|
+
accountId: "ag_alice",
|
|
445
|
+
enabled: true,
|
|
446
|
+
settings: { domain: "lark" },
|
|
447
|
+
});
|
|
448
|
+
expect(updateAck.ok).toBe(false);
|
|
449
|
+
expect(updateAck.error?.code).toBe("bad_params");
|
|
450
|
+
expect(updateAck.error?.message).toContain("domain change requires a fresh loginId");
|
|
327
451
|
});
|
|
328
452
|
|
|
329
453
|
it("discovers recent WeChat senders from a confirmed login session", async () => {
|
|
@@ -584,6 +708,170 @@ describe("W6: UPDATE rollback on addChannel failure", () => {
|
|
|
584
708
|
expect(onDisk.botToken).toBe("old-token:123456789012345");
|
|
585
709
|
}
|
|
586
710
|
});
|
|
711
|
+
|
|
712
|
+
it("restores previous Feishu secret/config and re-adds old channel when update addChannel fails", async () => {
|
|
713
|
+
const gw = makeFakeGateway();
|
|
714
|
+
const { state, io } = makeConfigIO({ ...baseCfg(), agents: ["ag_alice", "ag_bob"] });
|
|
715
|
+
const sessions = new LoginSessionStore();
|
|
716
|
+
const ctrl = createGatewayControl({
|
|
717
|
+
gateway: gw as any,
|
|
718
|
+
configIO: io,
|
|
719
|
+
loginSessions: sessions,
|
|
720
|
+
});
|
|
721
|
+
const gwId = uniqId("w6fs");
|
|
722
|
+
trackSecret(gwId);
|
|
723
|
+
|
|
724
|
+
sessions.create({
|
|
725
|
+
loginId: "fsl_w6_old",
|
|
726
|
+
accountId: "ag_alice",
|
|
727
|
+
provider: "feishu",
|
|
728
|
+
appId: "cli_old",
|
|
729
|
+
appSecret: "old-feishu-secret-12345",
|
|
730
|
+
userOpenId: "ou_old",
|
|
731
|
+
domain: "feishu",
|
|
732
|
+
});
|
|
733
|
+
const firstAck = await ctrl.handleUpsert({
|
|
734
|
+
id: gwId,
|
|
735
|
+
type: "feishu",
|
|
736
|
+
accountId: "ag_alice",
|
|
737
|
+
enabled: true,
|
|
738
|
+
loginId: "fsl_w6_old",
|
|
739
|
+
settings: { allowedChatIds: ["oc_old"], domain: "feishu" },
|
|
740
|
+
});
|
|
741
|
+
expect(firstAck.ok).toBe(true);
|
|
742
|
+
|
|
743
|
+
sessions.create({
|
|
744
|
+
loginId: "fsl_w6_new",
|
|
745
|
+
accountId: "ag_bob",
|
|
746
|
+
provider: "feishu",
|
|
747
|
+
appId: "cli_new",
|
|
748
|
+
appSecret: "new-feishu-secret-ABCDE",
|
|
749
|
+
userOpenId: "ou_new",
|
|
750
|
+
domain: "lark",
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
let addCallCount = 0;
|
|
754
|
+
gw.addChannel = vi.fn(async (cfg: { id: string; accountId: string }) => {
|
|
755
|
+
addCallCount += 1;
|
|
756
|
+
if (addCallCount === 1) throw new Error("simulated feishu update failure");
|
|
757
|
+
gw.channels.set(cfg.id, {
|
|
758
|
+
id: cfg.id,
|
|
759
|
+
status: { channel: cfg.id, accountId: cfg.accountId, running: true, connected: true, authorized: true, lastPollAt: Date.now() },
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
const updateAck = await ctrl.handleUpsert({
|
|
764
|
+
id: gwId,
|
|
765
|
+
type: "feishu",
|
|
766
|
+
accountId: "ag_bob",
|
|
767
|
+
enabled: true,
|
|
768
|
+
loginId: "fsl_w6_new",
|
|
769
|
+
settings: { allowedChatIds: ["oc_new"], domain: "lark" },
|
|
770
|
+
});
|
|
771
|
+
expect(updateAck.ok).toBe(false);
|
|
772
|
+
expect(updateAck.error?.code).toBe("addChannel_failed");
|
|
773
|
+
expect(addCallCount).toBe(2);
|
|
774
|
+
|
|
775
|
+
const profile = state.cfg.thirdPartyGateways?.find((g) => g.id === gwId);
|
|
776
|
+
expect(profile).toMatchObject({
|
|
777
|
+
id: gwId,
|
|
778
|
+
type: "feishu",
|
|
779
|
+
appId: "cli_old",
|
|
780
|
+
domain: "feishu",
|
|
781
|
+
userOpenId: "ou_old",
|
|
782
|
+
allowedChatIds: ["oc_old"],
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
const secretPath = trackSecret(gwId);
|
|
786
|
+
const onDisk = JSON.parse(readFileSync(secretPath, "utf8")) as { appSecret?: string };
|
|
787
|
+
expect(onDisk.appSecret).toBe("old-feishu-secret-12345");
|
|
788
|
+
expect(gw.addChannel).toHaveBeenLastCalledWith(
|
|
789
|
+
expect.objectContaining({
|
|
790
|
+
id: gwId,
|
|
791
|
+
type: "feishu",
|
|
792
|
+
accountId: "ag_alice",
|
|
793
|
+
appId: "cli_old",
|
|
794
|
+
domain: "feishu",
|
|
795
|
+
allowedChatIds: ["oc_old"],
|
|
796
|
+
}),
|
|
797
|
+
);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it("replaces previous Feishu profile on rollback so failed update fields are removed", async () => {
|
|
801
|
+
const gw = makeFakeGateway();
|
|
802
|
+
const gwId = uniqId("w6fs-replace");
|
|
803
|
+
trackSecret(gwId);
|
|
804
|
+
saveGatewaySecret(gwId, { appSecret: "old-feishu-secret-replace" });
|
|
805
|
+
const { state, io } = makeConfigIO({
|
|
806
|
+
...baseCfg(),
|
|
807
|
+
agents: ["ag_alice", "ag_bob"],
|
|
808
|
+
thirdPartyGateways: [
|
|
809
|
+
{
|
|
810
|
+
id: gwId,
|
|
811
|
+
type: "feishu",
|
|
812
|
+
accountId: "ag_alice",
|
|
813
|
+
enabled: true,
|
|
814
|
+
appId: "cli_old_replace",
|
|
815
|
+
},
|
|
816
|
+
],
|
|
817
|
+
});
|
|
818
|
+
const sessions = new LoginSessionStore();
|
|
819
|
+
const ctrl = createGatewayControl({
|
|
820
|
+
gateway: gw as any,
|
|
821
|
+
configIO: io,
|
|
822
|
+
loginSessions: sessions,
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
sessions.create({
|
|
826
|
+
loginId: "fsl_w6_replace_new",
|
|
827
|
+
accountId: "ag_bob",
|
|
828
|
+
provider: "feishu",
|
|
829
|
+
appId: "cli_new_replace",
|
|
830
|
+
appSecret: "new-feishu-secret-replace",
|
|
831
|
+
userOpenId: "ou_new_replace",
|
|
832
|
+
domain: "lark",
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
let addCallCount = 0;
|
|
836
|
+
gw.addChannel = vi.fn(async (cfg: { id: string; accountId: string }) => {
|
|
837
|
+
addCallCount += 1;
|
|
838
|
+
if (addCallCount === 1) throw new Error("simulated feishu update failure");
|
|
839
|
+
gw.channels.set(cfg.id, {
|
|
840
|
+
id: cfg.id,
|
|
841
|
+
status: { channel: cfg.id, accountId: cfg.accountId, running: true, connected: true, authorized: true, lastPollAt: Date.now() },
|
|
842
|
+
});
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
const updateAck = await ctrl.handleUpsert({
|
|
846
|
+
id: gwId,
|
|
847
|
+
type: "feishu",
|
|
848
|
+
accountId: "ag_bob",
|
|
849
|
+
enabled: true,
|
|
850
|
+
loginId: "fsl_w6_replace_new",
|
|
851
|
+
settings: {
|
|
852
|
+
allowedSenderIds: ["ou_new_replace"],
|
|
853
|
+
allowedChatIds: ["oc_new_replace"],
|
|
854
|
+
splitAt: 2000,
|
|
855
|
+
domain: "lark",
|
|
856
|
+
},
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
expect(updateAck.ok).toBe(false);
|
|
860
|
+
expect(updateAck.error?.code).toBe("addChannel_failed");
|
|
861
|
+
expect(addCallCount).toBe(2);
|
|
862
|
+
expect(state.cfg.thirdPartyGateways).toEqual([
|
|
863
|
+
{
|
|
864
|
+
id: gwId,
|
|
865
|
+
type: "feishu",
|
|
866
|
+
accountId: "ag_alice",
|
|
867
|
+
enabled: true,
|
|
868
|
+
appId: "cli_old_replace",
|
|
869
|
+
},
|
|
870
|
+
]);
|
|
871
|
+
|
|
872
|
+
const onDisk = JSON.parse(readFileSync(trackSecret(gwId), "utf8")) as { appSecret?: string };
|
|
873
|
+
expect(onDisk.appSecret).toBe("old-feishu-secret-replace");
|
|
874
|
+
});
|
|
587
875
|
});
|
|
588
876
|
|
|
589
877
|
describe("list_gateways", () => {
|
|
@@ -686,6 +974,90 @@ describe("gateway_send", () => {
|
|
|
686
974
|
expect(ack.error?.code).toBe("conversation_not_allowed");
|
|
687
975
|
expect(gw.sendOutbound).not.toHaveBeenCalled();
|
|
688
976
|
});
|
|
977
|
+
|
|
978
|
+
it("allows Feishu outbound when allowedChatIds is empty", async () => {
|
|
979
|
+
const gw = makeFakeGateway();
|
|
980
|
+
const gwId = uniqId("send-fs-allow-all");
|
|
981
|
+
const { io } = makeConfigIO({
|
|
982
|
+
...baseCfg(),
|
|
983
|
+
thirdPartyGateways: [
|
|
984
|
+
{
|
|
985
|
+
id: gwId,
|
|
986
|
+
type: "feishu",
|
|
987
|
+
accountId: "ag_alice",
|
|
988
|
+
enabled: true,
|
|
989
|
+
allowedChatIds: [],
|
|
990
|
+
},
|
|
991
|
+
],
|
|
992
|
+
});
|
|
993
|
+
const ctrl = createGatewayControl({ gateway: gw as any, configIO: io });
|
|
994
|
+
|
|
995
|
+
const ack = await ctrl.handleSend({
|
|
996
|
+
agentId: "ag_alice",
|
|
997
|
+
gatewayId: gwId,
|
|
998
|
+
conversationId: "feishu:chat:oc_any",
|
|
999
|
+
text: "hello",
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
expect(ack.ok).toBe(true);
|
|
1003
|
+
expect(gw.sendOutbound).toHaveBeenCalledOnce();
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
it("allows Feishu outbound when allowedChatIds is omitted", async () => {
|
|
1007
|
+
const gw = makeFakeGateway();
|
|
1008
|
+
const gwId = uniqId("send-fs-allow-omitted");
|
|
1009
|
+
const { io } = makeConfigIO({
|
|
1010
|
+
...baseCfg(),
|
|
1011
|
+
thirdPartyGateways: [
|
|
1012
|
+
{
|
|
1013
|
+
id: gwId,
|
|
1014
|
+
type: "feishu",
|
|
1015
|
+
accountId: "ag_alice",
|
|
1016
|
+
enabled: true,
|
|
1017
|
+
},
|
|
1018
|
+
],
|
|
1019
|
+
});
|
|
1020
|
+
const ctrl = createGatewayControl({ gateway: gw as any, configIO: io });
|
|
1021
|
+
|
|
1022
|
+
const ack = await ctrl.handleSend({
|
|
1023
|
+
agentId: "ag_alice",
|
|
1024
|
+
gatewayId: gwId,
|
|
1025
|
+
conversationId: "feishu:chat:oc_any",
|
|
1026
|
+
text: "hello",
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
expect(ack.ok).toBe(true);
|
|
1030
|
+
expect(gw.sendOutbound).toHaveBeenCalledOnce();
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
it("denies Telegram outbound when allowedChatIds is empty", async () => {
|
|
1034
|
+
const gw = makeFakeGateway();
|
|
1035
|
+
const gwId = uniqId("send-tg-deny-empty");
|
|
1036
|
+
const { io } = makeConfigIO({
|
|
1037
|
+
...baseCfg(),
|
|
1038
|
+
thirdPartyGateways: [
|
|
1039
|
+
{
|
|
1040
|
+
id: gwId,
|
|
1041
|
+
type: "telegram",
|
|
1042
|
+
accountId: "ag_alice",
|
|
1043
|
+
enabled: true,
|
|
1044
|
+
allowedChatIds: [],
|
|
1045
|
+
},
|
|
1046
|
+
],
|
|
1047
|
+
});
|
|
1048
|
+
const ctrl = createGatewayControl({ gateway: gw as any, configIO: io });
|
|
1049
|
+
|
|
1050
|
+
const ack = await ctrl.handleSend({
|
|
1051
|
+
agentId: "ag_alice",
|
|
1052
|
+
gatewayId: gwId,
|
|
1053
|
+
conversationId: "telegram:group:-100123",
|
|
1054
|
+
text: "hello",
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
expect(ack.ok).toBe(false);
|
|
1058
|
+
expect(ack.error?.code).toBe("conversation_not_allowed");
|
|
1059
|
+
expect(gw.sendOutbound).not.toHaveBeenCalled();
|
|
1060
|
+
});
|
|
689
1061
|
});
|
|
690
1062
|
|
|
691
1063
|
describe("W4: handleLoginStatus accountId ownership check", () => {
|
|
@@ -336,6 +336,29 @@ describe("wake_agent handler", () => {
|
|
|
336
336
|
});
|
|
337
337
|
});
|
|
338
338
|
|
|
339
|
+
it("acks wake_agent after queueing instead of waiting for the turn to finish", async () => {
|
|
340
|
+
const gw = makeFakeGateway(["ag_wake"]);
|
|
341
|
+
gw.injectInbound.mockImplementation(() => new Promise(() => undefined));
|
|
342
|
+
const handler = createProvisioner({ gateway: gw as any });
|
|
343
|
+
|
|
344
|
+
const res = await Promise.race([
|
|
345
|
+
handler({
|
|
346
|
+
id: "req_wake_pending",
|
|
347
|
+
type: "wake_agent",
|
|
348
|
+
params: {
|
|
349
|
+
agent_id: "ag_wake",
|
|
350
|
+
message: "tick",
|
|
351
|
+
run_id: "sr_pending",
|
|
352
|
+
},
|
|
353
|
+
}),
|
|
354
|
+
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 20)),
|
|
355
|
+
]);
|
|
356
|
+
|
|
357
|
+
expect(res).not.toBe("timeout");
|
|
358
|
+
expect(res).toMatchObject({ ok: true, result: { agent_id: "ag_wake", queued: true } });
|
|
359
|
+
expect(gw.injectInbound).toHaveBeenCalledTimes(1);
|
|
360
|
+
});
|
|
361
|
+
|
|
339
362
|
it("rejects wake_agent for an unloaded agent", async () => {
|
|
340
363
|
const gw = makeFakeGateway(["ag_loaded"]);
|
|
341
364
|
const handler = createProvisioner({ gateway: gw as any });
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
mkdtempSync,
|
|
4
|
+
realpathSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
symlinkSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
12
|
+
import { findDaemonInstallPrefix } from "../self-restart.js";
|
|
13
|
+
|
|
14
|
+
describe("self restart install prefix detection", () => {
|
|
15
|
+
let tmpDir: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), "botcord-self-restart-"));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("finds the npm install prefix for a managed @botcord/daemon entrypoint", () => {
|
|
26
|
+
const prefix = path.join(tmpDir, ".botcord", "daemon");
|
|
27
|
+
const packageRoot = path.join(prefix, "node_modules", "@botcord", "daemon");
|
|
28
|
+
const entrypoint = path.join(packageRoot, "dist", "index.js");
|
|
29
|
+
mkdirSync(path.dirname(entrypoint), { recursive: true });
|
|
30
|
+
writeFileSync(path.join(packageRoot, "package.json"), '{"name":"@botcord/daemon"}');
|
|
31
|
+
writeFileSync(entrypoint, "");
|
|
32
|
+
|
|
33
|
+
expect(findDaemonInstallPrefix(entrypoint)).toBe(prefix);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("resolves npm .bin symlinks before looking for the package root", () => {
|
|
37
|
+
const prefix = path.join(tmpDir, ".botcord", "daemon");
|
|
38
|
+
const packageRoot = path.join(prefix, "node_modules", "@botcord", "daemon");
|
|
39
|
+
const entrypoint = path.join(packageRoot, "dist", "index.js");
|
|
40
|
+
const bin = path.join(prefix, "node_modules", ".bin", "botcord-daemon");
|
|
41
|
+
mkdirSync(path.dirname(entrypoint), { recursive: true });
|
|
42
|
+
mkdirSync(path.dirname(bin), { recursive: true });
|
|
43
|
+
writeFileSync(path.join(packageRoot, "package.json"), '{"name":"@botcord/daemon"}');
|
|
44
|
+
writeFileSync(entrypoint, "");
|
|
45
|
+
symlinkSync(entrypoint, bin);
|
|
46
|
+
|
|
47
|
+
expect(findDaemonInstallPrefix(bin)).toBe(realpathSync(prefix));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("does not treat a monorepo development entrypoint as self-updatable", () => {
|
|
51
|
+
const entrypoint = path.join(tmpDir, "packages", "daemon", "dist", "index.js");
|
|
52
|
+
mkdirSync(path.dirname(entrypoint), { recursive: true });
|
|
53
|
+
writeFileSync(entrypoint, "");
|
|
54
|
+
|
|
55
|
+
expect(findDaemonInstallPrefix(entrypoint)).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -149,6 +149,47 @@ describe("skill snapshots", () => {
|
|
|
149
149
|
});
|
|
150
150
|
});
|
|
151
151
|
|
|
152
|
+
it("scans Gemini workspace and user skill roots without mixing Claude or Codex dirs", () => {
|
|
153
|
+
const agentId = "ag_gemini_skills";
|
|
154
|
+
const workspaceGemini = path.join(agentWorkspaceDir(agentId), ".gemini", "skills");
|
|
155
|
+
const workspaceAgents = path.join(agentWorkspaceDir(agentId), ".agents", "skills");
|
|
156
|
+
writeSkill(workspaceGemini, "workspace-gemini", "Workspace Gemini skill");
|
|
157
|
+
writeSkill(workspaceAgents, "workspace-agent", "Workspace shared-agent skill");
|
|
158
|
+
writeSkill(path.join(tmpDir, ".gemini", "skills"), "global-gemini", "Global Gemini skill");
|
|
159
|
+
writeSkill(path.join(tmpDir, ".agents", "skills"), "global-agent", "Global shared-agent skill");
|
|
160
|
+
writeSkill(path.join(agentWorkspaceDir(agentId), ".claude", "skills"), "claude-only", "Claude only");
|
|
161
|
+
writeSkill(path.join(agentCodexHomeDir(agentId), "skills"), "codex-only", "Codex only");
|
|
162
|
+
|
|
163
|
+
const geminiScanned = scanSoftSkills(agentId, { runtime: "gemini" });
|
|
164
|
+
expect(geminiScanned.map((s) => s.name)).toEqual([
|
|
165
|
+
"global-agent",
|
|
166
|
+
"global-gemini",
|
|
167
|
+
"workspace-agent",
|
|
168
|
+
"workspace-gemini",
|
|
169
|
+
]);
|
|
170
|
+
expect(geminiScanned.every((s) => s.runtime === "gemini")).toBe(true);
|
|
171
|
+
|
|
172
|
+
const snapshot = collectAgentSkillSnapshot(agentId, { runtime: "gemini" });
|
|
173
|
+
expect(snapshot.runtime).toBe("gemini");
|
|
174
|
+
expect(snapshot.skills.find((s) => s.name === "workspace-gemini"))
|
|
175
|
+
.toMatchObject({
|
|
176
|
+
source: "workspace",
|
|
177
|
+
sourceDetail: "agent-gemini",
|
|
178
|
+
runtime: "gemini",
|
|
179
|
+
path: path.join(workspaceGemini, "workspace-gemini", "SKILL.md"),
|
|
180
|
+
});
|
|
181
|
+
expect(snapshot.skills.find((s) => s.name === "workspace-agent"))
|
|
182
|
+
.toMatchObject({
|
|
183
|
+
source: "workspace",
|
|
184
|
+
sourceDetail: "agent-agents",
|
|
185
|
+
});
|
|
186
|
+
expect(snapshot.skills.find((s) => s.name === "global-agent"))
|
|
187
|
+
.toMatchObject({
|
|
188
|
+
source: "runtime-global",
|
|
189
|
+
sourceDetail: "global-agents",
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
152
193
|
it("keeps same-device workspace skills scoped by agent id", () => {
|
|
153
194
|
writeSkill(
|
|
154
195
|
path.join(agentWorkspaceDir("ag_workspace_a"), ".claude", "skills"),
|
|
@@ -1347,6 +1347,44 @@ describe("createBotCordChannel — typing()", () => {
|
|
|
1347
1347
|
globalThis.fetch = realFetch;
|
|
1348
1348
|
}
|
|
1349
1349
|
});
|
|
1350
|
+
|
|
1351
|
+
it("refreshes the token and retries once on 401 (stale-token recovery)", async () => {
|
|
1352
|
+
const fetchSpy = vi
|
|
1353
|
+
.fn()
|
|
1354
|
+
.mockResolvedValueOnce(new Response('{"code":"invalid_token"}', { status: 401 }))
|
|
1355
|
+
.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
|
1356
|
+
const realFetch = globalThis.fetch;
|
|
1357
|
+
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
|
1358
|
+
try {
|
|
1359
|
+
const client = makeClient({
|
|
1360
|
+
getHubUrl: vi.fn().mockReturnValue("https://hub.example.com"),
|
|
1361
|
+
});
|
|
1362
|
+
const channel = createBotCordChannel({
|
|
1363
|
+
id: "botcord-main",
|
|
1364
|
+
accountId: "ag_self",
|
|
1365
|
+
agentId: "ag_self",
|
|
1366
|
+
client,
|
|
1367
|
+
hubBaseUrl: "https://hub.example.com",
|
|
1368
|
+
});
|
|
1369
|
+
await channel.typing!({
|
|
1370
|
+
traceId: "trace_401",
|
|
1371
|
+
accountId: "ag_self",
|
|
1372
|
+
conversationId: "rm_oc_42",
|
|
1373
|
+
log: silentLog,
|
|
1374
|
+
});
|
|
1375
|
+
expect(client.refreshToken).toHaveBeenCalledTimes(1);
|
|
1376
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
1377
|
+
// First attempt uses the stale token; the retry uses the refreshed one.
|
|
1378
|
+
expect((fetchSpy.mock.calls[0][1].headers as Record<string, string>).Authorization).toBe(
|
|
1379
|
+
"Bearer test-token",
|
|
1380
|
+
);
|
|
1381
|
+
expect((fetchSpy.mock.calls[1][1].headers as Record<string, string>).Authorization).toBe(
|
|
1382
|
+
"Bearer test-token-2",
|
|
1383
|
+
);
|
|
1384
|
+
} finally {
|
|
1385
|
+
globalThis.fetch = realFetch;
|
|
1386
|
+
}
|
|
1387
|
+
});
|
|
1350
1388
|
});
|
|
1351
1389
|
|
|
1352
1390
|
describe("createBotCordChannel — websocket logging", () => {
|
|
@@ -308,6 +308,41 @@ function normalizeInboxBatch(
|
|
|
308
308
|
};
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Fire-and-forget authenticated POST for presence/streaming control requests
|
|
313
|
+
* (`/hub/typing`, `/hub/stream-block`). Mirrors `BotCordClient.hubFetch`'s 401
|
|
314
|
+
* handling: a stale-but-unexpired token (e.g. after a Hub JWT secret rotation,
|
|
315
|
+
* which `ensureToken()` won't refresh because it only refreshes near expiry) is
|
|
316
|
+
* refreshed once and the request retried. Without this, typing/stream-block
|
|
317
|
+
* silently 401 in a loop until the next actual message send happens to refresh
|
|
318
|
+
* the token — leaving the conversation with no typing indicator or live stream.
|
|
319
|
+
*/
|
|
320
|
+
async function postControlWithRefresh(
|
|
321
|
+
client: BotCordChannelClient,
|
|
322
|
+
hubUrl: string,
|
|
323
|
+
path: string,
|
|
324
|
+
body: unknown,
|
|
325
|
+
): Promise<Response> {
|
|
326
|
+
let token = await client.ensureToken();
|
|
327
|
+
for (let attempt = 0; attempt <= 1; attempt++) {
|
|
328
|
+
const resp = await fetch(`${hubUrl}${path}`, {
|
|
329
|
+
method: "POST",
|
|
330
|
+
headers: {
|
|
331
|
+
"Content-Type": "application/json",
|
|
332
|
+
Authorization: `Bearer ${token}`,
|
|
333
|
+
},
|
|
334
|
+
body: JSON.stringify(body),
|
|
335
|
+
signal: AbortSignal.timeout(10_000),
|
|
336
|
+
});
|
|
337
|
+
if (resp.status === 401 && attempt === 0) {
|
|
338
|
+
token = await client.refreshToken();
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
return resp;
|
|
342
|
+
}
|
|
343
|
+
throw new Error("postControlWithRefresh: exhausted retries");
|
|
344
|
+
}
|
|
345
|
+
|
|
311
346
|
/**
|
|
312
347
|
* Construct a BotCord channel adapter.
|
|
313
348
|
*
|
|
@@ -895,21 +930,12 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
895
930
|
const client = ensureClient();
|
|
896
931
|
const hubUrl = options.hubBaseUrl ?? client.getHubUrl();
|
|
897
932
|
try {
|
|
898
|
-
const token = await client.ensureToken();
|
|
899
933
|
const block = ctx.block as { raw?: unknown; kind?: string; seq?: number } | undefined;
|
|
900
934
|
const seq = typeof block?.seq === "number" ? block.seq : 0;
|
|
901
|
-
const resp = await
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
Authorization: `Bearer ${token}`,
|
|
906
|
-
},
|
|
907
|
-
body: JSON.stringify({
|
|
908
|
-
trace_id: ctx.traceId,
|
|
909
|
-
seq,
|
|
910
|
-
block: normalizeBlockForHub(block, seq),
|
|
911
|
-
}),
|
|
912
|
-
signal: AbortSignal.timeout(10_000),
|
|
935
|
+
const resp = await postControlWithRefresh(client, hubUrl, "/hub/stream-block", {
|
|
936
|
+
trace_id: ctx.traceId,
|
|
937
|
+
seq,
|
|
938
|
+
block: normalizeBlockForHub(block, seq),
|
|
913
939
|
});
|
|
914
940
|
if (!resp.ok && resp.status !== 204) {
|
|
915
941
|
const body = await resp.text().catch(() => "");
|
|
@@ -927,15 +953,8 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
927
953
|
const client = ensureClient();
|
|
928
954
|
const hubUrl = options.hubBaseUrl ?? client.getHubUrl();
|
|
929
955
|
try {
|
|
930
|
-
const
|
|
931
|
-
|
|
932
|
-
method: "POST",
|
|
933
|
-
headers: {
|
|
934
|
-
"Content-Type": "application/json",
|
|
935
|
-
Authorization: `Bearer ${token}`,
|
|
936
|
-
},
|
|
937
|
-
body: JSON.stringify({ room_id: ctx.conversationId }),
|
|
938
|
-
signal: AbortSignal.timeout(10_000),
|
|
956
|
+
const resp = await postControlWithRefresh(client, hubUrl, "/hub/typing", {
|
|
957
|
+
room_id: ctx.conversationId,
|
|
939
958
|
});
|
|
940
959
|
if (!resp.ok && resp.status !== 204) {
|
|
941
960
|
const body = await resp.text().catch(() => "");
|