@botcord/daemon 0.2.77 → 0.2.79
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/agent-discovery.d.ts +6 -0
- package/dist/agent-discovery.js +6 -0
- package/dist/attention-policy-fetcher.d.ts +14 -0
- package/dist/attention-policy-fetcher.js +59 -0
- package/dist/cloud-daemon.js +8 -0
- package/dist/cloud-gateway-runtime.d.ts +29 -0
- package/dist/cloud-gateway-runtime.js +122 -0
- package/dist/daemon-config-map.d.ts +6 -0
- package/dist/daemon-config-map.js +5 -4
- package/dist/daemon.d.ts +3 -0
- package/dist/daemon.js +32 -7
- package/dist/gateway/channels/botcord.js +29 -9
- package/dist/gateway/channels/login-session.d.ts +12 -0
- package/dist/gateway/channels/login-session.js +20 -2
- package/dist/gateway/channels/sanitize.d.ts +5 -18
- package/dist/gateway/channels/sanitize.js +5 -54
- package/dist/gateway/channels/text-split.d.ts +5 -11
- package/dist/gateway/channels/text-split.js +5 -31
- package/dist/gateway/dispatcher.d.ts +7 -1
- package/dist/gateway/dispatcher.js +88 -8
- package/dist/gateway/gateway.d.ts +16 -1
- package/dist/gateway/gateway.js +21 -0
- package/dist/gateway/policy-resolver.js +17 -9
- package/dist/gateway/runtimes/deepseek-tui.js +86 -19
- package/dist/gateway/types.d.ts +12 -57
- package/dist/gateway-control.js +18 -9
- package/dist/provision.d.ts +9 -3
- package/dist/provision.js +181 -9
- package/dist/room-recovery-context.d.ts +11 -0
- package/dist/room-recovery-context.js +97 -0
- package/dist/runtime-models.d.ts +17 -0
- package/dist/runtime-models.js +953 -0
- package/dist/runtime-route-options.d.ts +7 -0
- package/dist/runtime-route-options.js +45 -0
- package/package.json +2 -2
- package/src/__tests__/attention-policy-fetcher.test.ts +67 -0
- package/src/__tests__/cloud-gateway-runtime.test.ts +127 -0
- package/src/__tests__/daemon-config-map.test.ts +26 -1
- package/src/__tests__/gateway-control.test.ts +136 -0
- package/src/__tests__/policy-resolver.test.ts +20 -0
- package/src/__tests__/provision.test.ts +124 -0
- package/src/__tests__/runtime-discovery.test.ts +68 -9
- package/src/__tests__/runtime-models.test.ts +333 -0
- package/src/agent-discovery.ts +9 -0
- package/src/attention-policy-fetcher.ts +87 -0
- package/src/cloud-daemon.ts +8 -0
- package/src/cloud-gateway-runtime.ts +171 -0
- package/src/daemon-config-map.ts +17 -4
- package/src/daemon.ts +38 -9
- package/src/gateway/__tests__/botcord-channel.test.ts +97 -0
- package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +207 -1
- package/src/gateway/__tests__/dispatcher.test.ts +56 -0
- package/src/gateway/channels/botcord.ts +32 -8
- package/src/gateway/channels/login-session.ts +20 -2
- package/src/gateway/channels/sanitize.ts +8 -66
- package/src/gateway/channels/text-split.ts +5 -27
- package/src/gateway/dispatcher.ts +123 -27
- package/src/gateway/gateway.ts +29 -0
- package/src/gateway/policy-resolver.ts +20 -9
- package/src/gateway/runtimes/deepseek-tui.ts +86 -19
- package/src/gateway/types.ts +31 -59
- package/src/gateway-control.ts +21 -9
- package/src/provision.ts +202 -11
- package/src/room-recovery-context.ts +131 -0
- package/src/runtime-models.ts +972 -0
- package/src/runtime-route-options.ts +52 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface RuntimeSelectionOptions {
|
|
2
|
+
runtimeModel?: string;
|
|
3
|
+
reasoningEffort?: string;
|
|
4
|
+
thinking?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function buildRuntimeSelectionExtraArgs(runtime: string | undefined, selection: RuntimeSelectionOptions): string[];
|
|
7
|
+
export declare function mergeRuntimeExtraArgs(inherited: string[] | undefined, selected: string[]): string[] | undefined;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export function buildRuntimeSelectionExtraArgs(runtime, selection) {
|
|
2
|
+
if (!runtime)
|
|
3
|
+
return [];
|
|
4
|
+
const out = [];
|
|
5
|
+
const model = cleanString(selection.runtimeModel);
|
|
6
|
+
const reasoningEffort = cleanString(selection.reasoningEffort);
|
|
7
|
+
if (runtime === "claude-code") {
|
|
8
|
+
if (model)
|
|
9
|
+
out.push("--model", model);
|
|
10
|
+
if (reasoningEffort)
|
|
11
|
+
out.push("--effort", reasoningEffort);
|
|
12
|
+
}
|
|
13
|
+
else if (runtime === "codex") {
|
|
14
|
+
if (model)
|
|
15
|
+
out.push("--model", model);
|
|
16
|
+
if (reasoningEffort) {
|
|
17
|
+
out.push("-c", `model_reasoning_effort=${quoteCodexConfigValue(reasoningEffort)}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
else if (runtime === "deepseek-tui") {
|
|
21
|
+
if (model)
|
|
22
|
+
out.push("--model", model);
|
|
23
|
+
if (reasoningEffort)
|
|
24
|
+
out.push("--reasoning-effort", reasoningEffort);
|
|
25
|
+
}
|
|
26
|
+
else if (runtime === "kimi-cli") {
|
|
27
|
+
if (model)
|
|
28
|
+
out.push("--model", model);
|
|
29
|
+
if (typeof selection.thinking === "boolean") {
|
|
30
|
+
out.push(selection.thinking ? "--thinking" : "--no-thinking");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
export function mergeRuntimeExtraArgs(inherited, selected) {
|
|
36
|
+
const out = [...(inherited ?? []), ...selected];
|
|
37
|
+
return out.length ? out : undefined;
|
|
38
|
+
}
|
|
39
|
+
function cleanString(value) {
|
|
40
|
+
const trimmed = value?.trim();
|
|
41
|
+
return trimmed ? trimmed : undefined;
|
|
42
|
+
}
|
|
43
|
+
function quoteCodexConfigValue(value) {
|
|
44
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
45
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botcord/daemon",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.79",
|
|
4
4
|
"description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@botcord/cli": "^0.1.7",
|
|
31
|
-
"@botcord/protocol-core": "^0.2.
|
|
31
|
+
"@botcord/protocol-core": "^0.2.10",
|
|
32
32
|
"@larksuiteoapi/node-sdk": "^1.63.1",
|
|
33
33
|
"ws": "^8.20.1"
|
|
34
34
|
},
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { generateKeypair } from "@botcord/protocol-core";
|
|
6
|
+
import { createAttentionPolicyFetcher } from "../attention-policy-fetcher.js";
|
|
7
|
+
|
|
8
|
+
function writeCredentials(agentId: string): string {
|
|
9
|
+
const dir = mkdtempSync(path.join(tmpdir(), "botcord-policy-"));
|
|
10
|
+
const file = path.join(dir, `${agentId}.json`);
|
|
11
|
+
const keys = generateKeypair();
|
|
12
|
+
writeFileSync(
|
|
13
|
+
file,
|
|
14
|
+
JSON.stringify({
|
|
15
|
+
version: 1,
|
|
16
|
+
hubUrl: "https://hub.test",
|
|
17
|
+
agentId,
|
|
18
|
+
keyId: "key_1",
|
|
19
|
+
privateKey: keys.privateKey,
|
|
20
|
+
publicKey: keys.publicKey,
|
|
21
|
+
savedAt: new Date().toISOString(),
|
|
22
|
+
token: "jwt",
|
|
23
|
+
tokenExpiresAt: Math.floor(Date.now() / 1000) + 3600,
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
return file;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("createAttentionPolicyFetcher", () => {
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
vi.unstubAllGlobals();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("fetches effective room attention policy with agent credentials", async () => {
|
|
35
|
+
const credentialsPath = writeCredentials("ag_policy");
|
|
36
|
+
const fetchMock = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
37
|
+
expect(String(url)).toBe(
|
|
38
|
+
"https://hub.test/hub/attention-policy?room_id=rm_1",
|
|
39
|
+
);
|
|
40
|
+
expect((init?.headers as Record<string, string>).Authorization).toBe(
|
|
41
|
+
"Bearer jwt",
|
|
42
|
+
);
|
|
43
|
+
return new Response(
|
|
44
|
+
JSON.stringify({
|
|
45
|
+
mode: "mention_only",
|
|
46
|
+
keywords: [],
|
|
47
|
+
allowedSenderIds: [],
|
|
48
|
+
}),
|
|
49
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
53
|
+
|
|
54
|
+
const fetchPolicy = createAttentionPolicyFetcher({
|
|
55
|
+
credentialPathByAgentId: new Map([["ag_policy", credentialsPath]]),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await expect(
|
|
59
|
+
fetchPolicy({ agentId: "ag_policy", roomId: "rm_1" }),
|
|
60
|
+
).resolves.toEqual({
|
|
61
|
+
mode: "mention_only",
|
|
62
|
+
keywords: [],
|
|
63
|
+
allowedSenderIds: [],
|
|
64
|
+
});
|
|
65
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { RUNTIME_FRAME_TYPES, type GatewayInboundFrame } from "@botcord/protocol-core";
|
|
6
|
+
|
|
7
|
+
import { handleCloudGatewayRuntimeInbound } from "../cloud-gateway-runtime.js";
|
|
8
|
+
import { Gateway, type ChannelAdapter } from "../gateway/index.js";
|
|
9
|
+
|
|
10
|
+
describe("cloud gateway runtime inbound", () => {
|
|
11
|
+
let tmpDir: string;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), "cloud-gateway-runtime-"));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("injects a gateway_inbound frame and captures the runtime reply", async () => {
|
|
22
|
+
const gateway = new Gateway({
|
|
23
|
+
config: {
|
|
24
|
+
channels: [],
|
|
25
|
+
defaultRoute: { runtime: "fake", cwd: tmpDir },
|
|
26
|
+
},
|
|
27
|
+
sessionStorePath: path.join(tmpDir, "sessions.json"),
|
|
28
|
+
createChannel: (cfg) => stubChannel(cfg.id, cfg.type, cfg.accountId),
|
|
29
|
+
createRuntime: () => ({
|
|
30
|
+
id: "fake",
|
|
31
|
+
async run() {
|
|
32
|
+
return { text: "hello from runtime", newSessionId: "sess_1" };
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
transcriptEnabled: false,
|
|
36
|
+
});
|
|
37
|
+
await gateway.start();
|
|
38
|
+
|
|
39
|
+
const frame: GatewayInboundFrame = {
|
|
40
|
+
type: RUNTIME_FRAME_TYPES.GATEWAY_INBOUND,
|
|
41
|
+
event_id: "evt_1",
|
|
42
|
+
gateway_id: "gw_tg_1",
|
|
43
|
+
agent_id: "ag_1",
|
|
44
|
+
provider: "telegram",
|
|
45
|
+
message: {
|
|
46
|
+
id: "telegram:1:2",
|
|
47
|
+
channel: "gw_tg_1",
|
|
48
|
+
accountId: "ag_1",
|
|
49
|
+
conversation: { id: "telegram:user:1", kind: "direct" },
|
|
50
|
+
sender: { id: "telegram:user:1", kind: "user" },
|
|
51
|
+
text: "hi",
|
|
52
|
+
replyTo: null,
|
|
53
|
+
mentioned: true,
|
|
54
|
+
receivedAt: Date.now(),
|
|
55
|
+
trace: { id: "telegram:1:2", streamable: false },
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const result = await handleCloudGatewayRuntimeInbound(gateway, frame);
|
|
60
|
+
await gateway.stop("test");
|
|
61
|
+
|
|
62
|
+
expect(result.accepted).toBe(true);
|
|
63
|
+
expect(result.eventId).toBe("evt_1");
|
|
64
|
+
expect(result.gatewayId).toBe("gw_tg_1");
|
|
65
|
+
expect(result.conversationId).toBe("telegram:user:1");
|
|
66
|
+
expect(result.outbound?.finalText).toBe("hello from runtime");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("rejects frames outside the token scope", async () => {
|
|
70
|
+
const gateway = new Gateway({
|
|
71
|
+
config: {
|
|
72
|
+
channels: [],
|
|
73
|
+
defaultRoute: { runtime: "fake", cwd: tmpDir },
|
|
74
|
+
},
|
|
75
|
+
sessionStorePath: path.join(tmpDir, "sessions.json"),
|
|
76
|
+
createChannel: (cfg) => stubChannel(cfg.id, cfg.type, cfg.accountId),
|
|
77
|
+
createRuntime: () => ({
|
|
78
|
+
id: "fake",
|
|
79
|
+
async run() {
|
|
80
|
+
return { text: "unused", newSessionId: "sess_1" };
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
transcriptEnabled: false,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const result = await handleCloudGatewayRuntimeInbound(gateway, {
|
|
87
|
+
type: RUNTIME_FRAME_TYPES.GATEWAY_INBOUND,
|
|
88
|
+
event_id: "evt_bad",
|
|
89
|
+
gateway_id: "gw_tg_1",
|
|
90
|
+
agent_id: "ag_1",
|
|
91
|
+
provider: "telegram",
|
|
92
|
+
message: {
|
|
93
|
+
id: "telegram:1:2",
|
|
94
|
+
channel: "gw_other",
|
|
95
|
+
accountId: "ag_1",
|
|
96
|
+
conversation: { id: "telegram:user:1", kind: "direct" },
|
|
97
|
+
sender: { id: "telegram:user:1", kind: "user" },
|
|
98
|
+
text: "hi",
|
|
99
|
+
replyTo: null,
|
|
100
|
+
mentioned: true,
|
|
101
|
+
receivedAt: Date.now(),
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(result.accepted).toBe(false);
|
|
106
|
+
expect(result.error?.code).toBe("channel_mismatch");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
function stubChannel(id: string, type: string, accountId: string): ChannelAdapter {
|
|
111
|
+
return {
|
|
112
|
+
id,
|
|
113
|
+
type,
|
|
114
|
+
async start() {
|
|
115
|
+
return undefined;
|
|
116
|
+
},
|
|
117
|
+
async stop() {
|
|
118
|
+
return undefined;
|
|
119
|
+
},
|
|
120
|
+
async send() {
|
|
121
|
+
return {};
|
|
122
|
+
},
|
|
123
|
+
status() {
|
|
124
|
+
return { channel: id, accountId, running: true };
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -416,6 +416,32 @@ describe("buildManagedRoutes", () => {
|
|
|
416
416
|
expect(map.get("ag_one")?.extraArgs).not.toBe(withExtraArgs.extraArgs);
|
|
417
417
|
});
|
|
418
418
|
|
|
419
|
+
it("appends per-agent model and reasoning selections to inherited extraArgs", () => {
|
|
420
|
+
const withExtraArgs: GatewayRoute = {
|
|
421
|
+
runtime: "codex",
|
|
422
|
+
cwd: "/home/default",
|
|
423
|
+
extraArgs: ["--skip-git-repo-check"],
|
|
424
|
+
};
|
|
425
|
+
const map = buildManagedRoutes(
|
|
426
|
+
["ag_one"],
|
|
427
|
+
{
|
|
428
|
+
ag_one: {
|
|
429
|
+
runtime: "codex",
|
|
430
|
+
runtimeModel: "gpt-5.2",
|
|
431
|
+
reasoningEffort: "high",
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
withExtraArgs,
|
|
435
|
+
);
|
|
436
|
+
expect(map.get("ag_one")?.extraArgs).toEqual([
|
|
437
|
+
"--skip-git-repo-check",
|
|
438
|
+
"--model",
|
|
439
|
+
"gpt-5.2",
|
|
440
|
+
"-c",
|
|
441
|
+
'model_reasoning_effort="high"',
|
|
442
|
+
]);
|
|
443
|
+
});
|
|
444
|
+
|
|
419
445
|
it("omits extraArgs when defaultRoute has none", () => {
|
|
420
446
|
const map = buildManagedRoutes(["ag_one"], {}, defaultRoute);
|
|
421
447
|
expect(map.get("ag_one")).not.toHaveProperty("extraArgs");
|
|
@@ -492,4 +518,3 @@ describe("openclawGateways resolution", () => {
|
|
|
492
518
|
expect(gw.managedRoutes).toEqual([]);
|
|
493
519
|
});
|
|
494
520
|
});
|
|
495
|
-
|
|
@@ -733,3 +733,139 @@ describe("W4: handleLoginStatus accountId ownership check", () => {
|
|
|
733
733
|
expect(ack.error?.code).toBe("bad_params");
|
|
734
734
|
});
|
|
735
735
|
});
|
|
736
|
+
|
|
737
|
+
describe("login_missing vs login_expired", () => {
|
|
738
|
+
it("wechat upsert with an unknown loginId returns login_missing", async () => {
|
|
739
|
+
const gw = makeFakeGateway();
|
|
740
|
+
const { io } = makeConfigIO(baseCfg());
|
|
741
|
+
const sessions = new LoginSessionStore();
|
|
742
|
+
const ctrl = createGatewayControl({
|
|
743
|
+
gateway: gw as any,
|
|
744
|
+
configIO: io,
|
|
745
|
+
loginSessions: sessions,
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
const ack = await ctrl.handleUpsert({
|
|
749
|
+
id: uniqId("wx_missing"),
|
|
750
|
+
type: "wechat",
|
|
751
|
+
accountId: "ag_alice",
|
|
752
|
+
enabled: true,
|
|
753
|
+
loginId: "wxl_never_created",
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
expect(ack.ok).toBe(false);
|
|
757
|
+
expect(ack.error?.code).toBe("login_missing");
|
|
758
|
+
expect(gw.addChannel).not.toHaveBeenCalled();
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it("feishu upsert with an unknown loginId returns login_missing", async () => {
|
|
762
|
+
const gw = makeFakeGateway();
|
|
763
|
+
const { io } = makeConfigIO(baseCfg());
|
|
764
|
+
const sessions = new LoginSessionStore();
|
|
765
|
+
const ctrl = createGatewayControl({
|
|
766
|
+
gateway: gw as any,
|
|
767
|
+
configIO: io,
|
|
768
|
+
loginSessions: sessions,
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
const ack = await ctrl.handleUpsert({
|
|
772
|
+
id: uniqId("fs_missing"),
|
|
773
|
+
type: "feishu",
|
|
774
|
+
accountId: "ag_alice",
|
|
775
|
+
enabled: true,
|
|
776
|
+
loginId: "fsl_never_created",
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
expect(ack.ok).toBe(false);
|
|
780
|
+
expect(ack.error?.code).toBe("login_missing");
|
|
781
|
+
expect(gw.addChannel).not.toHaveBeenCalled();
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it("recent_senders with an unknown loginId returns login_missing", async () => {
|
|
785
|
+
const gw = makeFakeGateway();
|
|
786
|
+
const { io } = makeConfigIO(baseCfg());
|
|
787
|
+
const sessions = new LoginSessionStore();
|
|
788
|
+
const ctrl = createGatewayControl({
|
|
789
|
+
gateway: gw as any,
|
|
790
|
+
configIO: io,
|
|
791
|
+
loginSessions: sessions,
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
const ack = await ctrl.handleRecentSenders({
|
|
795
|
+
provider: "wechat",
|
|
796
|
+
loginId: "wxl_never_created",
|
|
797
|
+
accountId: "ag_alice",
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
expect(ack.ok).toBe(false);
|
|
801
|
+
expect(ack.error?.code).toBe("login_missing");
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it("wechat upsert with a TTL-expired loginId returns login_expired", async () => {
|
|
805
|
+
const gw = makeFakeGateway();
|
|
806
|
+
const { io } = makeConfigIO(baseCfg());
|
|
807
|
+
let nowMs = 1_000_000;
|
|
808
|
+
const sessions = new LoginSessionStore({ now: () => nowMs, ttlMs: 60_000 });
|
|
809
|
+
sessions.create({
|
|
810
|
+
loginId: "wxl_aged",
|
|
811
|
+
accountId: "ag_alice",
|
|
812
|
+
provider: "wechat",
|
|
813
|
+
qrcode: "QR",
|
|
814
|
+
baseUrl: "https://ilinkai.weixin.qq.com",
|
|
815
|
+
botToken: "wechat-bot-token-aged",
|
|
816
|
+
});
|
|
817
|
+
nowMs += 120_000;
|
|
818
|
+
|
|
819
|
+
const ctrl = createGatewayControl({
|
|
820
|
+
gateway: gw as any,
|
|
821
|
+
configIO: io,
|
|
822
|
+
loginSessions: sessions,
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
const ack = await ctrl.handleUpsert({
|
|
826
|
+
id: uniqId("wx_expired"),
|
|
827
|
+
type: "wechat",
|
|
828
|
+
accountId: "ag_alice",
|
|
829
|
+
enabled: true,
|
|
830
|
+
loginId: "wxl_aged",
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
expect(ack.ok).toBe(false);
|
|
834
|
+
expect(ack.error?.code).toBe("login_expired");
|
|
835
|
+
// resolve() also evicts — a follow-up call should now report missing.
|
|
836
|
+
expect(sessions.resolve("wxl_aged").state).toBe("missing");
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it("feishu upsert with a TTL-expired loginId returns login_expired", async () => {
|
|
840
|
+
const gw = makeFakeGateway();
|
|
841
|
+
const { io } = makeConfigIO(baseCfg());
|
|
842
|
+
let nowMs = 2_000_000;
|
|
843
|
+
const sessions = new LoginSessionStore({ now: () => nowMs, ttlMs: 60_000 });
|
|
844
|
+
sessions.create({
|
|
845
|
+
loginId: "fsl_aged",
|
|
846
|
+
accountId: "ag_alice",
|
|
847
|
+
provider: "feishu",
|
|
848
|
+
appId: "cli_xxx",
|
|
849
|
+
appSecret: "feishu-secret-aged",
|
|
850
|
+
domain: "feishu",
|
|
851
|
+
});
|
|
852
|
+
nowMs += 120_000;
|
|
853
|
+
|
|
854
|
+
const ctrl = createGatewayControl({
|
|
855
|
+
gateway: gw as any,
|
|
856
|
+
configIO: io,
|
|
857
|
+
loginSessions: sessions,
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
const ack = await ctrl.handleUpsert({
|
|
861
|
+
id: uniqId("fs_expired"),
|
|
862
|
+
type: "feishu",
|
|
863
|
+
accountId: "ag_alice",
|
|
864
|
+
enabled: true,
|
|
865
|
+
loginId: "fsl_aged",
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
expect(ack.ok).toBe(false);
|
|
869
|
+
expect(ack.error?.code).toBe("login_expired");
|
|
870
|
+
});
|
|
871
|
+
});
|
|
@@ -65,6 +65,26 @@ describe("PolicyResolver", () => {
|
|
|
65
65
|
expect(p.mode).toBe("always");
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
it.each(["telegram:user:42", "wechat:user:alice", "feishu:user:ou_alice"])(
|
|
69
|
+
"forces third-party direct chat %s to mode=always",
|
|
70
|
+
async (conversationId) => {
|
|
71
|
+
const resolver = new PolicyResolver({ fetchGlobal: async () => undefined });
|
|
72
|
+
resolver.put("ag_a", null, { mode: "mention_only", keywords: [] });
|
|
73
|
+
const p = await resolver.resolve("ag_a", conversationId);
|
|
74
|
+
expect(p.mode).toBe("always");
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
it.each(["telegram:group:-1001", "feishu:chat:oc_team"])(
|
|
79
|
+
"does not force third-party group chat %s to mode=always",
|
|
80
|
+
async (conversationId) => {
|
|
81
|
+
const resolver = new PolicyResolver({ fetchGlobal: async () => undefined });
|
|
82
|
+
resolver.put("ag_a", null, { mode: "mention_only", keywords: [] });
|
|
83
|
+
const p = await resolver.resolve("ag_a", conversationId);
|
|
84
|
+
expect(p.mode).toBe("mention_only");
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
68
88
|
it("falls back to defaults when fetch throws", async () => {
|
|
69
89
|
const resolver = new PolicyResolver({
|
|
70
90
|
fetchGlobal: async () => {
|
|
@@ -660,6 +660,65 @@ describe("provision_agent handler writes runtime + cwd", () => {
|
|
|
660
660
|
}
|
|
661
661
|
});
|
|
662
662
|
|
|
663
|
+
it("persists model options and hot-adds them to the managed route", async () => {
|
|
664
|
+
const os = await import("node:os");
|
|
665
|
+
const fs = await import("node:fs");
|
|
666
|
+
const nodePath = await import("node:path");
|
|
667
|
+
|
|
668
|
+
const tmp = fs.mkdtempSync(nodePath.join(os.tmpdir(), "daemon-provision-"));
|
|
669
|
+
const prevHome = process.env.HOME;
|
|
670
|
+
process.env.HOME = tmp;
|
|
671
|
+
try {
|
|
672
|
+
mockState.cfg = {
|
|
673
|
+
defaultRoute: {
|
|
674
|
+
adapter: "codex",
|
|
675
|
+
cwd: tmp,
|
|
676
|
+
extraArgs: ["--skip-git-repo-check"],
|
|
677
|
+
},
|
|
678
|
+
routes: [],
|
|
679
|
+
streamBlocks: true,
|
|
680
|
+
};
|
|
681
|
+
const gw = makeFakeGateway();
|
|
682
|
+
const provisioner = createProvisioner({
|
|
683
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
684
|
+
});
|
|
685
|
+
const privateKey = Buffer.alloc(32, 8).toString("base64");
|
|
686
|
+
|
|
687
|
+
const ack = await provisioner({
|
|
688
|
+
id: "req_model",
|
|
689
|
+
type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
|
|
690
|
+
params: {
|
|
691
|
+
runtime: "codex",
|
|
692
|
+
runtimeModel: "gpt-5.2",
|
|
693
|
+
reasoningEffort: "high",
|
|
694
|
+
credentials: {
|
|
695
|
+
agentId: "ag_model",
|
|
696
|
+
keyId: "k_model",
|
|
697
|
+
privateKey,
|
|
698
|
+
hubUrl: "https://hub.example",
|
|
699
|
+
},
|
|
700
|
+
},
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
expect(ack.ok).toBe(true);
|
|
704
|
+
const credFile = nodePath.join(tmp, ".botcord", "credentials", "ag_model.json");
|
|
705
|
+
const saved = JSON.parse(fs.readFileSync(credFile, "utf8")) as Record<string, unknown>;
|
|
706
|
+
expect(saved.runtimeModel).toBe("gpt-5.2");
|
|
707
|
+
expect(saved.reasoningEffort).toBe("high");
|
|
708
|
+
expect(gw.listManagedRoutes()[0].extraArgs).toEqual([
|
|
709
|
+
"--skip-git-repo-check",
|
|
710
|
+
"--model",
|
|
711
|
+
"gpt-5.2",
|
|
712
|
+
"-c",
|
|
713
|
+
'model_reasoning_effort="high"',
|
|
714
|
+
]);
|
|
715
|
+
} finally {
|
|
716
|
+
if (prevHome === undefined) delete process.env.HOME;
|
|
717
|
+
else process.env.HOME = prevHome;
|
|
718
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
|
|
663
722
|
it("rejects unknown runtime ids before touching disk", async () => {
|
|
664
723
|
const gw = makeFakeGateway();
|
|
665
724
|
const provisioner = createProvisioner({
|
|
@@ -1677,6 +1736,71 @@ describe("update_agent handler", () => {
|
|
|
1677
1736
|
});
|
|
1678
1737
|
});
|
|
1679
1738
|
|
|
1739
|
+
it("updates runtime selectors in credentials and managed route", async () => {
|
|
1740
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
1741
|
+
mockState.cfg = {
|
|
1742
|
+
defaultRoute: {
|
|
1743
|
+
adapter: "codex",
|
|
1744
|
+
cwd: tmp,
|
|
1745
|
+
extraArgs: ["--skip-git-repo-check"],
|
|
1746
|
+
},
|
|
1747
|
+
routes: [],
|
|
1748
|
+
streamBlocks: true,
|
|
1749
|
+
};
|
|
1750
|
+
const credDir = nodePath.join(tmp, ".botcord", "credentials");
|
|
1751
|
+
fs.mkdirSync(credDir, { recursive: true });
|
|
1752
|
+
fs.writeFileSync(
|
|
1753
|
+
nodePath.join(credDir, "ag_runtime.json"),
|
|
1754
|
+
JSON.stringify({
|
|
1755
|
+
version: 1,
|
|
1756
|
+
hubUrl: "https://hub.example",
|
|
1757
|
+
agentId: "ag_runtime",
|
|
1758
|
+
keyId: "k_runtime",
|
|
1759
|
+
privateKey: Buffer.alloc(32, 24).toString("base64"),
|
|
1760
|
+
savedAt: new Date().toISOString(),
|
|
1761
|
+
}),
|
|
1762
|
+
);
|
|
1763
|
+
|
|
1764
|
+
const gw = makeFakeGateway();
|
|
1765
|
+
const provisioner = createProvisioner({
|
|
1766
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
1767
|
+
});
|
|
1768
|
+
const ack = await provisioner({
|
|
1769
|
+
id: "req_update_runtime",
|
|
1770
|
+
type: CONTROL_FRAME_TYPES.UPDATE_AGENT,
|
|
1771
|
+
params: {
|
|
1772
|
+
agentId: "ag_runtime",
|
|
1773
|
+
runtime: "codex",
|
|
1774
|
+
runtimeModel: "gpt-5.2",
|
|
1775
|
+
reasoningEffort: "high",
|
|
1776
|
+
thinking: true,
|
|
1777
|
+
},
|
|
1778
|
+
});
|
|
1779
|
+
|
|
1780
|
+
expect(ack.ok).toBe(true);
|
|
1781
|
+
expect((ack.result as { changed: boolean }).changed).toBe(true);
|
|
1782
|
+
|
|
1783
|
+
const saved = JSON.parse(
|
|
1784
|
+
fs.readFileSync(nodePath.join(credDir, "ag_runtime.json"), "utf8"),
|
|
1785
|
+
) as Record<string, unknown>;
|
|
1786
|
+
expect(saved.runtime).toBe("codex");
|
|
1787
|
+
expect(saved.runtimeModel).toBe("gpt-5.2");
|
|
1788
|
+
expect(saved.reasoningEffort).toBe("high");
|
|
1789
|
+
expect(saved.thinking).toBe(true);
|
|
1790
|
+
expect(gw.listManagedRoutes()[0]).toMatchObject({
|
|
1791
|
+
match: { accountId: "ag_runtime" },
|
|
1792
|
+
runtime: "codex",
|
|
1793
|
+
extraArgs: [
|
|
1794
|
+
"--skip-git-repo-check",
|
|
1795
|
+
"--model",
|
|
1796
|
+
"gpt-5.2",
|
|
1797
|
+
"-c",
|
|
1798
|
+
'model_reasoning_effort="high"',
|
|
1799
|
+
],
|
|
1800
|
+
});
|
|
1801
|
+
});
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1680
1804
|
it("rejects update_agent without agentId", async () => {
|
|
1681
1805
|
const gw = makeFakeGateway();
|
|
1682
1806
|
const provisioner = createProvisioner({
|
|
@@ -76,15 +76,74 @@ describe("collectRuntimeSnapshot", () => {
|
|
|
76
76
|
},
|
|
77
77
|
]);
|
|
78
78
|
const snap = collectRuntimeSnapshot();
|
|
79
|
-
expect(snap.runtimes).
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
79
|
+
expect(snap.runtimes[0]).toMatchObject({
|
|
80
|
+
id: "claude-code",
|
|
81
|
+
available: true,
|
|
82
|
+
version: "1.2.3",
|
|
83
|
+
path: "/usr/local/bin/claude",
|
|
84
|
+
});
|
|
85
|
+
const claudeModels = (snap.runtimes[0] as { models?: Array<{ id: string }> }).models;
|
|
86
|
+
expect(claudeModels?.map((m) => m.id)).toContain("sonnet");
|
|
87
|
+
expect(claudeModels?.map((m) => m.id)).toContain("opus");
|
|
88
|
+
expect(snap.runtimes[1]).toEqual({ id: "codex", available: false });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("adds Kimi models from the local config file", () => {
|
|
92
|
+
const tmp = mkdtempSync(path.join(tmpdir(), "daemon-runtime-kimi-"));
|
|
93
|
+
const prevHome = process.env.HOME;
|
|
94
|
+
process.env.HOME = tmp;
|
|
95
|
+
try {
|
|
96
|
+
mkdirSync(path.join(tmp, ".kimi"), { recursive: true });
|
|
97
|
+
writeFileSync(
|
|
98
|
+
path.join(tmp, ".kimi", "config.toml"),
|
|
99
|
+
[
|
|
100
|
+
'default_model = "kimi-code/kimi-for-coding"',
|
|
101
|
+
"",
|
|
102
|
+
'[models."kimi-code/kimi-for-coding"]',
|
|
103
|
+
'provider = "managed:kimi-code"',
|
|
104
|
+
'model = "kimi-for-coding"',
|
|
105
|
+
"max_context_size = 262144",
|
|
106
|
+
'capabilities = ["thinking", "image_in"]',
|
|
107
|
+
'display_name = "Kimi-k2.6"',
|
|
108
|
+
"",
|
|
109
|
+
].join("\n"),
|
|
110
|
+
);
|
|
111
|
+
setRuntimes([
|
|
112
|
+
{
|
|
113
|
+
id: "kimi-cli",
|
|
114
|
+
displayName: "Kimi",
|
|
115
|
+
binary: "kimi",
|
|
116
|
+
supportsRun: true,
|
|
117
|
+
result: { available: true },
|
|
118
|
+
},
|
|
119
|
+
]);
|
|
120
|
+
const [runtime] = collectRuntimeSnapshot().runtimes;
|
|
121
|
+
expect((runtime as { models?: unknown[] }).models).toEqual([
|
|
122
|
+
{
|
|
123
|
+
id: "kimi-code/kimi-for-coding",
|
|
124
|
+
source: "config",
|
|
125
|
+
isDefault: true,
|
|
126
|
+
provider: "managed:kimi-code",
|
|
127
|
+
displayName: "Kimi-k2.6",
|
|
128
|
+
contextLength: 262144,
|
|
129
|
+
capabilities: ["thinking", "image_in"],
|
|
130
|
+
metadata: { model: "kimi-for-coding" },
|
|
131
|
+
parameters: [
|
|
132
|
+
{
|
|
133
|
+
id: "thinking",
|
|
134
|
+
displayName: "Thinking",
|
|
135
|
+
type: "boolean",
|
|
136
|
+
flag: "--thinking/--no-thinking",
|
|
137
|
+
source: "cli",
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
]);
|
|
142
|
+
} finally {
|
|
143
|
+
if (prevHome === undefined) delete process.env.HOME;
|
|
144
|
+
else process.env.HOME = prevHome;
|
|
145
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
146
|
+
}
|
|
88
147
|
});
|
|
89
148
|
|
|
90
149
|
it("omits optional fields rather than emitting explicit undefineds", () => {
|