@botcord/daemon 0.2.50 → 0.2.52
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 +11 -0
- package/dist/gateway/runtimes/kimi.d.ts +1 -1
- package/dist/gateway/runtimes/kimi.js +104 -3
- package/dist/openclaw-discovery.js +33 -2
- package/dist/provision.js +55 -13
- package/package.json +1 -1
- package/src/__tests__/openclaw-discovery.test.ts +37 -2
- package/src/__tests__/provision.test.ts +67 -1
- package/src/gateway/__tests__/botcord-channel.test.ts +86 -0
- package/src/gateway/__tests__/kimi-adapter.test.ts +85 -1
- package/src/gateway/channels/botcord.ts +13 -0
- package/src/gateway/runtimes/kimi.ts +109 -2
- package/src/openclaw-discovery.ts +33 -2
- package/src/provision.ts +51 -12
|
@@ -768,6 +768,8 @@ function normalizeBlockForHub(block, seq) {
|
|
|
768
768
|
if (kind === "assistant_text") {
|
|
769
769
|
// Claude Code: {type:"assistant", message:{content:[{type:"text",text}]}}
|
|
770
770
|
// Codex: {type:"item.completed", item:{type:"agent_message", text}}
|
|
771
|
+
// DeepSeek: {event:"message.delta", payload:{content}} or
|
|
772
|
+
// {event:"item.delta", payload:{payload:{kind:"agent_message", delta}}}
|
|
771
773
|
let text = "";
|
|
772
774
|
const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
|
|
773
775
|
for (const c of contents) {
|
|
@@ -776,6 +778,15 @@ function normalizeBlockForHub(block, seq) {
|
|
|
776
778
|
}
|
|
777
779
|
if (!text && typeof raw?.item?.text === "string")
|
|
778
780
|
text = raw.item.text;
|
|
781
|
+
if (!text && raw?.event === "message.delta" && typeof raw?.payload?.content === "string") {
|
|
782
|
+
text = raw.payload.content;
|
|
783
|
+
}
|
|
784
|
+
if (!text &&
|
|
785
|
+
raw?.event === "item.delta" &&
|
|
786
|
+
raw?.payload?.payload?.kind === "agent_message" &&
|
|
787
|
+
typeof raw?.payload?.payload?.delta === "string") {
|
|
788
|
+
text = raw.payload.payload.delta;
|
|
789
|
+
}
|
|
779
790
|
return { kind: "assistant", seq, payload: { text } };
|
|
780
791
|
}
|
|
781
792
|
if (kind === "tool_use") {
|
|
@@ -8,7 +8,7 @@ export declare function probeKimi(deps?: ProbeDeps): RuntimeProbeResult;
|
|
|
8
8
|
/**
|
|
9
9
|
* Kimi CLI adapter — spawns:
|
|
10
10
|
*
|
|
11
|
-
* kimi --work-dir <cwd> --print --output-format stream-json --session <sid> --prompt <text>
|
|
11
|
+
* kimi --work-dir <cwd> --print --output-format stream-json --session <sid> --afk --prompt <text>
|
|
12
12
|
*
|
|
13
13
|
* `--session <sid>` resumes an existing session or creates a new session with
|
|
14
14
|
* that id, so the adapter generates a UUID on first turn and persists it for
|
|
@@ -17,6 +17,108 @@ function isValidKimiSessionId(sessionId) {
|
|
|
17
17
|
function invalidKimiSessionIdError() {
|
|
18
18
|
return "kimi-cli: invalid sessionId (expected non-control text not starting with '-')";
|
|
19
19
|
}
|
|
20
|
+
const KIMI_EXTRA_FLAGS_WITH_VALUE = new Set([
|
|
21
|
+
"--add-dir",
|
|
22
|
+
"--agent",
|
|
23
|
+
"--agent-file",
|
|
24
|
+
"--config",
|
|
25
|
+
"--config-file",
|
|
26
|
+
"--max-ralph-iterations",
|
|
27
|
+
"--max-retries-per-step",
|
|
28
|
+
"--max-steps-per-turn",
|
|
29
|
+
"--mcp-config",
|
|
30
|
+
"--mcp-config-file",
|
|
31
|
+
"--model",
|
|
32
|
+
"--skills-dir",
|
|
33
|
+
"-m",
|
|
34
|
+
]);
|
|
35
|
+
const KIMI_EXTRA_BOOLEAN_FLAGS = new Set([
|
|
36
|
+
"--afk",
|
|
37
|
+
"--auto-approve",
|
|
38
|
+
"--debug",
|
|
39
|
+
"--no-thinking",
|
|
40
|
+
"--plan",
|
|
41
|
+
"--thinking",
|
|
42
|
+
"--verbose",
|
|
43
|
+
"--yes",
|
|
44
|
+
"--yolo",
|
|
45
|
+
"-y",
|
|
46
|
+
]);
|
|
47
|
+
// Flags owned by the adapter because BotCord depends on Kimi's non-interactive
|
|
48
|
+
// stream-json contract, cwd isolation, prompt placement, and session routing.
|
|
49
|
+
const KIMI_ADAPTER_OWNED_FLAGS = new Set([
|
|
50
|
+
"--acp",
|
|
51
|
+
"--command",
|
|
52
|
+
"--continue",
|
|
53
|
+
"--final-message-only",
|
|
54
|
+
"--help",
|
|
55
|
+
"--input-format",
|
|
56
|
+
"--output-format",
|
|
57
|
+
"--print",
|
|
58
|
+
"--prompt",
|
|
59
|
+
"--quiet",
|
|
60
|
+
"--resume",
|
|
61
|
+
"--session",
|
|
62
|
+
"--version",
|
|
63
|
+
"--wire",
|
|
64
|
+
"--work-dir",
|
|
65
|
+
"-C",
|
|
66
|
+
"-S",
|
|
67
|
+
"-V",
|
|
68
|
+
"-c",
|
|
69
|
+
"-h",
|
|
70
|
+
"-p",
|
|
71
|
+
"-r",
|
|
72
|
+
"-w",
|
|
73
|
+
]);
|
|
74
|
+
function flagName(arg) {
|
|
75
|
+
if (!arg.startsWith("-"))
|
|
76
|
+
return arg;
|
|
77
|
+
const eq = arg.indexOf("=");
|
|
78
|
+
return eq === -1 ? arg : arg.slice(0, eq);
|
|
79
|
+
}
|
|
80
|
+
function nextValue(args, index) {
|
|
81
|
+
const next = args[index + 1];
|
|
82
|
+
if (typeof next !== "string")
|
|
83
|
+
return undefined;
|
|
84
|
+
if (!next.startsWith("-"))
|
|
85
|
+
return next;
|
|
86
|
+
return /^-\d/.test(next) ? next : undefined;
|
|
87
|
+
}
|
|
88
|
+
function sanitizeKimiExtraArgs(extraArgs) {
|
|
89
|
+
if (!extraArgs?.length)
|
|
90
|
+
return [];
|
|
91
|
+
const out = [];
|
|
92
|
+
for (let i = 0; i < extraArgs.length; i += 1) {
|
|
93
|
+
const arg = extraArgs[i];
|
|
94
|
+
const name = flagName(arg);
|
|
95
|
+
if (KIMI_ADAPTER_OWNED_FLAGS.has(name)) {
|
|
96
|
+
if (!arg.includes("=") && nextValue(extraArgs, i) !== undefined)
|
|
97
|
+
i += 1;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (KIMI_EXTRA_FLAGS_WITH_VALUE.has(name)) {
|
|
101
|
+
if (arg.includes("=")) {
|
|
102
|
+
out.push(arg);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const value = nextValue(extraArgs, i);
|
|
106
|
+
if (value !== undefined) {
|
|
107
|
+
out.push(arg, value);
|
|
108
|
+
i += 1;
|
|
109
|
+
}
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (KIMI_EXTRA_BOOLEAN_FLAGS.has(name)) {
|
|
113
|
+
out.push(arg);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (arg.startsWith("-") && !arg.includes("=") && nextValue(extraArgs, i) !== undefined) {
|
|
117
|
+
i += 1;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
20
122
|
/** Resolve the Kimi CLI executable on PATH. */
|
|
21
123
|
export function resolveKimiCommand(deps = {}) {
|
|
22
124
|
return resolveCommandOnPath("kimi", deps);
|
|
@@ -35,7 +137,7 @@ export function probeKimi(deps = {}) {
|
|
|
35
137
|
/**
|
|
36
138
|
* Kimi CLI adapter — spawns:
|
|
37
139
|
*
|
|
38
|
-
* kimi --work-dir <cwd> --print --output-format stream-json --session <sid> --prompt <text>
|
|
140
|
+
* kimi --work-dir <cwd> --print --output-format stream-json --session <sid> --afk --prompt <text>
|
|
39
141
|
*
|
|
40
142
|
* `--session <sid>` resumes an existing session or creates a new session with
|
|
41
143
|
* that id, so the adapter generates a UUID on first turn and persists it for
|
|
@@ -83,8 +185,7 @@ export class KimiAdapter extends NdjsonStreamAdapter {
|
|
|
83
185
|
sessionId,
|
|
84
186
|
"--afk",
|
|
85
187
|
];
|
|
86
|
-
|
|
87
|
-
args.push(...opts.extraArgs);
|
|
188
|
+
args.push(...sanitizeKimiExtraArgs(opts.extraArgs));
|
|
88
189
|
args.push("--prompt", promptWithSystemContext(opts.text, opts.systemContext));
|
|
89
190
|
return args;
|
|
90
191
|
}
|
|
@@ -3,8 +3,8 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { log as daemonLog } from "./log.js";
|
|
5
5
|
import { probeOpenclawAgents } from "./provision.js";
|
|
6
|
-
const DEFAULT_SEARCH_PATHS = ["~/.openclaw/", "/etc/openclaw/"];
|
|
7
|
-
const DEFAULT_PORTS = [18789, 16200];
|
|
6
|
+
const DEFAULT_SEARCH_PATHS = ["~/.openclaw/", "~/.qclaw/", "/etc/openclaw/"];
|
|
7
|
+
const DEFAULT_PORTS = [18789, 16200, 28789];
|
|
8
8
|
const DEFAULT_TOKEN_FILE_PATHS = [
|
|
9
9
|
"/run/openclaw/gateway-token",
|
|
10
10
|
"/var/run/openclaw/gateway-token",
|
|
@@ -346,6 +346,9 @@ function discoverFromConfigDir(root) {
|
|
|
346
346
|
}
|
|
347
347
|
function parseJsonConfig(raw) {
|
|
348
348
|
const obj = JSON.parse(raw);
|
|
349
|
+
const qclaw = pickQclawGatewayValues(obj);
|
|
350
|
+
if (qclaw)
|
|
351
|
+
return qclaw;
|
|
349
352
|
// Prefer OpenClaw's native shape: `gateway.port` + `gateway.auth.token`.
|
|
350
353
|
// The legacy `acp.url` shape is also supported for explicit user-authored configs.
|
|
351
354
|
const native = pickOpenclawGatewayValues(obj?.gateway);
|
|
@@ -354,6 +357,34 @@ function parseJsonConfig(raw) {
|
|
|
354
357
|
const acp = obj?.acp ?? obj?.gateway?.acp ?? obj?.gateway ?? obj;
|
|
355
358
|
return pickConfigValues(acp);
|
|
356
359
|
}
|
|
360
|
+
function pickQclawGatewayValues(obj) {
|
|
361
|
+
if (!obj || typeof obj !== "object")
|
|
362
|
+
return null;
|
|
363
|
+
const port = typeof obj.port === "number" ? obj.port : undefined;
|
|
364
|
+
const configPath = typeof obj.configPath === "string" && obj.configPath.trim()
|
|
365
|
+
? obj.configPath.trim()
|
|
366
|
+
: undefined;
|
|
367
|
+
if (!port && !configPath)
|
|
368
|
+
return null;
|
|
369
|
+
const fromConfig = configPath ? readGatewayValuesFromConfigPath(configPath) : null;
|
|
370
|
+
if (fromConfig)
|
|
371
|
+
return fromConfig;
|
|
372
|
+
if (!port)
|
|
373
|
+
return null;
|
|
374
|
+
return { url: `ws://127.0.0.1:${port}` };
|
|
375
|
+
}
|
|
376
|
+
function readGatewayValuesFromConfigPath(configPath) {
|
|
377
|
+
try {
|
|
378
|
+
const raw = readFileSync(expandHome(configPath), "utf8");
|
|
379
|
+
const parsed = parseJsonConfig(raw);
|
|
380
|
+
if (parsed?.url)
|
|
381
|
+
return parsed;
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
// qclaw.json may be copied without its referenced openclaw.json.
|
|
385
|
+
}
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
357
388
|
function pickOpenclawGatewayValues(gw) {
|
|
358
389
|
if (!gw || typeof gw !== "object")
|
|
359
390
|
return null;
|
package/dist/provision.js
CHANGED
|
@@ -1075,10 +1075,10 @@ function localOpenclawAcpDisabled(rawUrl) {
|
|
|
1075
1075
|
if (!isLoopbackUrl(rawUrl))
|
|
1076
1076
|
return false;
|
|
1077
1077
|
try {
|
|
1078
|
-
const
|
|
1079
|
-
if (!
|
|
1078
|
+
const source = pickLocalOpenclawConfig(rawUrl);
|
|
1079
|
+
if (!source)
|
|
1080
1080
|
return false;
|
|
1081
|
-
const cfg = JSON.parse(readFileSync(file, "utf8"));
|
|
1081
|
+
const cfg = JSON.parse(readFileSync(source.file, "utf8"));
|
|
1082
1082
|
return cfg?.acp?.enabled === false;
|
|
1083
1083
|
}
|
|
1084
1084
|
catch {
|
|
@@ -1491,12 +1491,13 @@ export async function probeOpenclawAgents(profile, opts = {}) {
|
|
|
1491
1491
|
token: prepared.resolvedToken,
|
|
1492
1492
|
timeoutMs: opts.timeoutMs ?? 3000,
|
|
1493
1493
|
});
|
|
1494
|
-
// For loopback gateways the agent roster lives in
|
|
1494
|
+
// For loopback gateways the agent roster lives in local OpenClaw config
|
|
1495
|
+
// (`~/.openclaw/openclaw.json`, or QClaw's `~/.qclaw/openclaw.json`)
|
|
1495
1496
|
// and is the source of truth — listing it over the wire would require a
|
|
1496
1497
|
// paired device identity (operator.read scope). When the WS probe is the
|
|
1497
1498
|
// default (i.e. no test injection) we enrich the result from disk.
|
|
1498
1499
|
if (result.ok && !result.agents && !opts.probe && isLoopbackUrl(profile.url)) {
|
|
1499
|
-
const local = readLocalOpenclawAgents();
|
|
1500
|
+
const local = readLocalOpenclawAgents(profile.url);
|
|
1500
1501
|
if (local && local.length > 0)
|
|
1501
1502
|
result.agents = local;
|
|
1502
1503
|
}
|
|
@@ -1511,17 +1512,18 @@ function isLoopbackUrl(raw) {
|
|
|
1511
1512
|
return false;
|
|
1512
1513
|
}
|
|
1513
1514
|
}
|
|
1514
|
-
function readLocalOpenclawAgents() {
|
|
1515
|
+
function readLocalOpenclawAgents(rawUrl) {
|
|
1515
1516
|
try {
|
|
1516
|
-
const
|
|
1517
|
-
if (!
|
|
1518
|
-
return readLocalOpenclawAgentDirs() ?? [{ id: "default" }];
|
|
1517
|
+
const source = pickLocalOpenclawConfig(rawUrl);
|
|
1518
|
+
if (!source)
|
|
1519
|
+
return readLocalOpenclawAgentDirs(path.join(homedir(), ".openclaw")) ?? [{ id: "default" }];
|
|
1520
|
+
const { file, stateDir } = source;
|
|
1519
1521
|
const cfg = JSON.parse(readFileSync(file, "utf8"));
|
|
1520
1522
|
const list = Array.isArray(cfg?.agents?.list) ? cfg.agents.list : [];
|
|
1521
1523
|
const explicitDefaultId = typeof cfg?.agents?.defaults?.id === "string" && cfg.agents.defaults.id
|
|
1522
1524
|
? cfg.agents.defaults.id
|
|
1523
1525
|
: null;
|
|
1524
|
-
const dirAgents = readLocalOpenclawAgentDirs();
|
|
1526
|
+
const dirAgents = readLocalOpenclawAgentDirs(stateDir);
|
|
1525
1527
|
const defaultId = explicitDefaultId ?? (list.length === 0 && !dirAgents ? "default" : null);
|
|
1526
1528
|
const seen = new Set();
|
|
1527
1529
|
const out = [];
|
|
@@ -1565,16 +1567,52 @@ function readLocalOpenclawAgents() {
|
|
|
1565
1567
|
return null;
|
|
1566
1568
|
}
|
|
1567
1569
|
}
|
|
1568
|
-
function
|
|
1570
|
+
function pickLocalOpenclawConfig(rawUrl) {
|
|
1571
|
+
const candidates = [
|
|
1572
|
+
{ file: path.join(homedir(), ".openclaw", "openclaw.json"), stateDir: path.join(homedir(), ".openclaw") },
|
|
1573
|
+
{ file: path.join(homedir(), ".qclaw", "openclaw.json"), stateDir: path.join(homedir(), ".qclaw") },
|
|
1574
|
+
];
|
|
1575
|
+
const targetPort = urlPort(rawUrl);
|
|
1576
|
+
let firstExisting = null;
|
|
1577
|
+
for (const candidate of candidates) {
|
|
1578
|
+
if (!existsSync(candidate.file))
|
|
1579
|
+
continue;
|
|
1580
|
+
firstExisting ??= candidate;
|
|
1581
|
+
if (!targetPort)
|
|
1582
|
+
continue;
|
|
1583
|
+
try {
|
|
1584
|
+
const cfg = JSON.parse(readFileSync(candidate.file, "utf8"));
|
|
1585
|
+
if (Number(cfg?.gateway?.port) === targetPort)
|
|
1586
|
+
return candidate;
|
|
1587
|
+
}
|
|
1588
|
+
catch {
|
|
1589
|
+
// Try the next local config.
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
return firstExisting;
|
|
1593
|
+
}
|
|
1594
|
+
function urlPort(rawUrl) {
|
|
1595
|
+
if (!rawUrl)
|
|
1596
|
+
return null;
|
|
1569
1597
|
try {
|
|
1570
|
-
const
|
|
1598
|
+
const u = new URL(rawUrl);
|
|
1599
|
+
const port = Number(u.port || (u.protocol === "wss:" ? 443 : 80));
|
|
1600
|
+
return Number.isInteger(port) && port > 0 ? port : null;
|
|
1601
|
+
}
|
|
1602
|
+
catch {
|
|
1603
|
+
return null;
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
function readLocalOpenclawAgentDirs(stateDir) {
|
|
1607
|
+
try {
|
|
1608
|
+
const dir = path.join(stateDir, "agents");
|
|
1571
1609
|
if (!existsSync(dir))
|
|
1572
1610
|
return null;
|
|
1573
1611
|
const agents = readdirSync(dir, { withFileTypes: true })
|
|
1574
1612
|
.filter((entry) => entry.isDirectory() && entry.name.length > 0)
|
|
1575
1613
|
.map((entry) => ({
|
|
1576
1614
|
id: entry.name,
|
|
1577
|
-
workspace:
|
|
1615
|
+
workspace: resolveAgentDirWorkspace(dir, entry.name),
|
|
1578
1616
|
}));
|
|
1579
1617
|
if (agents.length === 0)
|
|
1580
1618
|
return null;
|
|
@@ -1591,6 +1629,10 @@ function readLocalOpenclawAgentDirs() {
|
|
|
1591
1629
|
return null;
|
|
1592
1630
|
}
|
|
1593
1631
|
}
|
|
1632
|
+
function resolveAgentDirWorkspace(agentsDir, agentId) {
|
|
1633
|
+
const nested = path.join(agentsDir, agentId, "agent");
|
|
1634
|
+
return existsSync(nested) ? nested : path.join(agentsDir, agentId);
|
|
1635
|
+
}
|
|
1594
1636
|
function resolveOpenclawIdentityName(agentId, workspace, cfg) {
|
|
1595
1637
|
const root = workspace ?? resolveOpenclawWorkspace(agentId, cfg);
|
|
1596
1638
|
if (!root)
|
package/package.json
CHANGED
|
@@ -128,6 +128,41 @@ describe("discoverLocalOpenclawGateways", () => {
|
|
|
128
128
|
]);
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
+
it("discovers QClaw's state config and referenced OpenClaw config", async () => {
|
|
132
|
+
const dir = tempDir();
|
|
133
|
+
const openclawConfig = path.join(dir, "openclaw.json");
|
|
134
|
+
writeFileSync(
|
|
135
|
+
openclawConfig,
|
|
136
|
+
JSON.stringify({
|
|
137
|
+
gateway: {
|
|
138
|
+
port: 28789,
|
|
139
|
+
bind: "loopback",
|
|
140
|
+
auth: { mode: "token", token: "qclaw-token" },
|
|
141
|
+
},
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
writeFileSync(
|
|
145
|
+
path.join(dir, "qclaw.json"),
|
|
146
|
+
JSON.stringify({
|
|
147
|
+
configPath: openclawConfig,
|
|
148
|
+
port: 28789,
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const found = await discoverLocalOpenclawGateways({
|
|
153
|
+
searchPaths: [dir],
|
|
154
|
+
defaultPorts: [],
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(found).toEqual([
|
|
158
|
+
expect.objectContaining({
|
|
159
|
+
url: "ws://127.0.0.1:28789",
|
|
160
|
+
token: "qclaw-token",
|
|
161
|
+
source: "config-file",
|
|
162
|
+
}),
|
|
163
|
+
]);
|
|
164
|
+
});
|
|
165
|
+
|
|
131
166
|
it("uses OPENCLAW_ACP_URL and token env vars", async () => {
|
|
132
167
|
const found = await discoverLocalOpenclawGateways({
|
|
133
168
|
searchPaths: [],
|
|
@@ -269,8 +304,8 @@ describe("discoverLocalOpenclawGateways", () => {
|
|
|
269
304
|
]);
|
|
270
305
|
});
|
|
271
306
|
|
|
272
|
-
it("includes
|
|
273
|
-
expect(defaultOpenclawDiscoveryPorts()).toEqual(expect.arrayContaining([18789, 16200]));
|
|
307
|
+
it("includes OpenClaw and QClaw ports in default discovery ports", () => {
|
|
308
|
+
expect(defaultOpenclawDiscoveryPorts()).toEqual(expect.arrayContaining([18789, 16200, 28789]));
|
|
274
309
|
});
|
|
275
310
|
|
|
276
311
|
it("adds default-port candidates only when the probe succeeds", async () => {
|
|
@@ -27,6 +27,7 @@ vi.mock("../config.js", async () => {
|
|
|
27
27
|
const {
|
|
28
28
|
addAgentToConfig,
|
|
29
29
|
adoptDiscoveredOpenclawAgents,
|
|
30
|
+
probeOpenclawAgents,
|
|
30
31
|
removeAgentFromConfig,
|
|
31
32
|
reloadConfig,
|
|
32
33
|
setRoute,
|
|
@@ -34,6 +35,7 @@ const {
|
|
|
34
35
|
} = await import("../provision.js");
|
|
35
36
|
const { CONTROL_FRAME_TYPES } = await import("@botcord/protocol-core");
|
|
36
37
|
import type { DaemonConfig } from "../config.js";
|
|
38
|
+
import type { WsEndpointProbeFn } from "../provision.js";
|
|
37
39
|
import type {
|
|
38
40
|
GatewayChannelConfig,
|
|
39
41
|
GatewayRoute,
|
|
@@ -1139,7 +1141,7 @@ describe("adoptDiscoveredOpenclawAgents", () => {
|
|
|
1139
1141
|
openclawGateways: [{ name: "local", url: "ws://127.0.0.1:18789" }],
|
|
1140
1142
|
};
|
|
1141
1143
|
const register = vi.fn();
|
|
1142
|
-
const probe = vi.fn<
|
|
1144
|
+
const probe = vi.fn<WsEndpointProbeFn>(
|
|
1143
1145
|
async () => ({ ok: true, agents: [{ id: "main" }] }),
|
|
1144
1146
|
);
|
|
1145
1147
|
|
|
@@ -1224,6 +1226,70 @@ describe("adoptDiscoveredOpenclawAgents", () => {
|
|
|
1224
1226
|
});
|
|
1225
1227
|
});
|
|
1226
1228
|
|
|
1229
|
+
describe("probeOpenclawAgents local profiles", () => {
|
|
1230
|
+
it("enriches loopback QClaw gateways from ~/.qclaw/openclaw.json", async () => {
|
|
1231
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
1232
|
+
const { WebSocketServer } = await import("ws");
|
|
1233
|
+
const qclawDir = nodePath.join(tmp, ".qclaw");
|
|
1234
|
+
fs.mkdirSync(qclawDir, { recursive: true });
|
|
1235
|
+
|
|
1236
|
+
const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
|
1237
|
+
await new Promise<void>((resolve) => wss.once("listening", resolve));
|
|
1238
|
+
const address = wss.address();
|
|
1239
|
+
if (typeof address === "string" || address === null) {
|
|
1240
|
+
throw new Error("expected tcp websocket address");
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
fs.writeFileSync(
|
|
1244
|
+
nodePath.join(qclawDir, "openclaw.json"),
|
|
1245
|
+
JSON.stringify({
|
|
1246
|
+
agents: {
|
|
1247
|
+
defaults: {
|
|
1248
|
+
workspace: nodePath.join(qclawDir, "workspace"),
|
|
1249
|
+
model: { primary: "qclaw/modelroute" },
|
|
1250
|
+
},
|
|
1251
|
+
list: [{ id: "main", name: "QClaw" }],
|
|
1252
|
+
},
|
|
1253
|
+
gateway: {
|
|
1254
|
+
port: address.port,
|
|
1255
|
+
auth: { mode: "token", token: "qclaw-token" },
|
|
1256
|
+
},
|
|
1257
|
+
}),
|
|
1258
|
+
);
|
|
1259
|
+
|
|
1260
|
+
wss.on("connection", (ws) => {
|
|
1261
|
+
ws.send(JSON.stringify({ type: "event", event: "connect.challenge", payload: { nonce: "n" } }));
|
|
1262
|
+
ws.on("message", (raw) => {
|
|
1263
|
+
const msg = JSON.parse(raw.toString("utf8"));
|
|
1264
|
+
if (msg.method === "connect") {
|
|
1265
|
+
ws.send(
|
|
1266
|
+
JSON.stringify({
|
|
1267
|
+
type: "res",
|
|
1268
|
+
id: msg.id,
|
|
1269
|
+
ok: true,
|
|
1270
|
+
payload: { type: "hello-ok", server: { version: "2026.4.21" } },
|
|
1271
|
+
}),
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
});
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
try {
|
|
1278
|
+
const res = await probeOpenclawAgents({
|
|
1279
|
+
url: `ws://127.0.0.1:${address.port}`,
|
|
1280
|
+
token: "qclaw-token",
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
expect(res.ok).toBe(true);
|
|
1284
|
+
expect(res.version).toBe("2026.4.21");
|
|
1285
|
+
expect(res.agents).toEqual([{ id: "main", name: "QClaw" }]);
|
|
1286
|
+
} finally {
|
|
1287
|
+
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
|
1288
|
+
}
|
|
1289
|
+
});
|
|
1290
|
+
});
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1227
1293
|
// ---------------------------------------------------------------------------
|
|
1228
1294
|
// revoke_agent — new flag semantics (plan §11.3)
|
|
1229
1295
|
// ---------------------------------------------------------------------------
|
|
@@ -618,6 +618,92 @@ describe("createBotCordChannel — streamBlock()", () => {
|
|
|
618
618
|
}
|
|
619
619
|
});
|
|
620
620
|
|
|
621
|
+
it("normalizes DeepSeek message.delta assistant text", async () => {
|
|
622
|
+
const fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
|
|
623
|
+
const realFetch = globalThis.fetch;
|
|
624
|
+
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
|
625
|
+
try {
|
|
626
|
+
const client = makeClient({
|
|
627
|
+
getHubUrl: vi.fn().mockReturnValue("https://hub.example.com"),
|
|
628
|
+
});
|
|
629
|
+
const channel = createBotCordChannel({
|
|
630
|
+
id: "botcord-main",
|
|
631
|
+
accountId: "ag_self",
|
|
632
|
+
agentId: "ag_self",
|
|
633
|
+
client,
|
|
634
|
+
hubBaseUrl: "https://hub.example.com",
|
|
635
|
+
});
|
|
636
|
+
await channel.streamBlock!({
|
|
637
|
+
traceId: "m_trace",
|
|
638
|
+
accountId: "ag_self",
|
|
639
|
+
conversationId: "rm_oc_1",
|
|
640
|
+
block: {
|
|
641
|
+
kind: "assistant_text",
|
|
642
|
+
seq: 4,
|
|
643
|
+
raw: {
|
|
644
|
+
event: "message.delta",
|
|
645
|
+
payload: { thread_id: "thr_1", turn_id: "turn_1", content: "hello " },
|
|
646
|
+
},
|
|
647
|
+
},
|
|
648
|
+
log: silentLog,
|
|
649
|
+
});
|
|
650
|
+
const [, init] = fetchSpy.mock.calls[0];
|
|
651
|
+
const body = JSON.parse(init.body as string);
|
|
652
|
+
expect(body.block).toEqual({
|
|
653
|
+
kind: "assistant",
|
|
654
|
+
seq: 4,
|
|
655
|
+
payload: { text: "hello " },
|
|
656
|
+
});
|
|
657
|
+
} finally {
|
|
658
|
+
globalThis.fetch = realFetch;
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it("normalizes DeepSeek item.delta assistant text", async () => {
|
|
663
|
+
const fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
|
|
664
|
+
const realFetch = globalThis.fetch;
|
|
665
|
+
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
|
666
|
+
try {
|
|
667
|
+
const client = makeClient({
|
|
668
|
+
getHubUrl: vi.fn().mockReturnValue("https://hub.example.com"),
|
|
669
|
+
});
|
|
670
|
+
const channel = createBotCordChannel({
|
|
671
|
+
id: "botcord-main",
|
|
672
|
+
accountId: "ag_self",
|
|
673
|
+
agentId: "ag_self",
|
|
674
|
+
client,
|
|
675
|
+
hubBaseUrl: "https://hub.example.com",
|
|
676
|
+
});
|
|
677
|
+
await channel.streamBlock!({
|
|
678
|
+
traceId: "m_trace",
|
|
679
|
+
accountId: "ag_self",
|
|
680
|
+
conversationId: "rm_oc_1",
|
|
681
|
+
block: {
|
|
682
|
+
kind: "assistant_text",
|
|
683
|
+
seq: 5,
|
|
684
|
+
raw: {
|
|
685
|
+
event: "item.delta",
|
|
686
|
+
payload: {
|
|
687
|
+
thread_id: "thr_1",
|
|
688
|
+
turn_id: "turn_1",
|
|
689
|
+
payload: { kind: "agent_message", delta: "deepseek" },
|
|
690
|
+
},
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
log: silentLog,
|
|
694
|
+
});
|
|
695
|
+
const [, init] = fetchSpy.mock.calls[0];
|
|
696
|
+
const body = JSON.parse(init.body as string);
|
|
697
|
+
expect(body.block).toEqual({
|
|
698
|
+
kind: "assistant",
|
|
699
|
+
seq: 5,
|
|
700
|
+
payload: { text: "deepseek" },
|
|
701
|
+
});
|
|
702
|
+
} finally {
|
|
703
|
+
globalThis.fetch = realFetch;
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
|
|
621
707
|
it("normalizes a thinking block with phase/label/source payload", async () => {
|
|
622
708
|
const fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
|
|
623
709
|
const realFetch = globalThis.fetch;
|
|
@@ -18,7 +18,7 @@ afterAll(() => {
|
|
|
18
18
|
rmSync(tmpRoot, { recursive: true, force: true });
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
function runAdapter(script: string, sessionId: string | null = null) {
|
|
21
|
+
function runAdapter(script: string, sessionId: string | null = null, extraArgs?: string[]) {
|
|
22
22
|
const adapter = new KimiAdapter({ binary: script });
|
|
23
23
|
const ctrl = new AbortController();
|
|
24
24
|
return adapter.run({
|
|
@@ -28,6 +28,7 @@ function runAdapter(script: string, sessionId: string | null = null) {
|
|
|
28
28
|
cwd: tmpRoot,
|
|
29
29
|
signal: ctrl.signal,
|
|
30
30
|
trustLevel: "owner",
|
|
31
|
+
extraArgs,
|
|
31
32
|
});
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -69,6 +70,89 @@ process.stdout.write(JSON.stringify({role:"assistant", content:JSON.stringify(ar
|
|
|
69
70
|
expect(argv).toContain("--afk");
|
|
70
71
|
});
|
|
71
72
|
|
|
73
|
+
it("drops non-Kimi inherited extraArgs and their values", async () => {
|
|
74
|
+
const script = makeScript(
|
|
75
|
+
"filter-foreign-argv.js",
|
|
76
|
+
`
|
|
77
|
+
const argv = process.argv.slice(2);
|
|
78
|
+
process.stdout.write(JSON.stringify({role:"assistant", content:JSON.stringify(argv)}) + "\\n");
|
|
79
|
+
`,
|
|
80
|
+
);
|
|
81
|
+
const res = await runAdapter(script, "sid-123", [
|
|
82
|
+
"--permission-mode",
|
|
83
|
+
"bypassPermissions",
|
|
84
|
+
"--model",
|
|
85
|
+
"kimi-k2",
|
|
86
|
+
]);
|
|
87
|
+
const argv = JSON.parse(res.text) as string[];
|
|
88
|
+
expect(argv).not.toContain("--permission-mode");
|
|
89
|
+
expect(argv).not.toContain("bypassPermissions");
|
|
90
|
+
expect(argv).toContain("--model");
|
|
91
|
+
expect(argv[argv.indexOf("--model") + 1]).toBe("kimi-k2");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("preserves Kimi value flags with negative numeric values", async () => {
|
|
95
|
+
const script = makeScript(
|
|
96
|
+
"negative-value-argv.js",
|
|
97
|
+
`
|
|
98
|
+
const argv = process.argv.slice(2);
|
|
99
|
+
process.stdout.write(JSON.stringify({role:"assistant", content:JSON.stringify(argv)}) + "\\n");
|
|
100
|
+
`,
|
|
101
|
+
);
|
|
102
|
+
const res = await runAdapter(script, "sid-123", [
|
|
103
|
+
"--max-ralph-iterations",
|
|
104
|
+
"-1",
|
|
105
|
+
"--max-steps-per-turn=3",
|
|
106
|
+
]);
|
|
107
|
+
const argv = JSON.parse(res.text) as string[];
|
|
108
|
+
expect(argv).toContain("--max-ralph-iterations");
|
|
109
|
+
expect(argv[argv.indexOf("--max-ralph-iterations") + 1]).toBe("-1");
|
|
110
|
+
expect(argv).toContain("--max-steps-per-turn=3");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("drops incomplete Kimi value flags instead of passing invalid argv", async () => {
|
|
114
|
+
const script = makeScript(
|
|
115
|
+
"incomplete-value-argv.js",
|
|
116
|
+
`
|
|
117
|
+
const argv = process.argv.slice(2);
|
|
118
|
+
process.stdout.write(JSON.stringify({role:"assistant", content:JSON.stringify(argv)}) + "\\n");
|
|
119
|
+
`,
|
|
120
|
+
);
|
|
121
|
+
const res = await runAdapter(script, "sid-123", ["--model", "--plan"]);
|
|
122
|
+
const argv = JSON.parse(res.text) as string[];
|
|
123
|
+
expect(argv).not.toContain("--model");
|
|
124
|
+
expect(argv).toContain("--plan");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("does not let extraArgs override adapter-owned stream/session/prompt flags", async () => {
|
|
128
|
+
const script = makeScript(
|
|
129
|
+
"filter-owned-argv.js",
|
|
130
|
+
`
|
|
131
|
+
const argv = process.argv.slice(2);
|
|
132
|
+
process.stdout.write(JSON.stringify({role:"assistant", content:JSON.stringify(argv)}) + "\\n");
|
|
133
|
+
`,
|
|
134
|
+
);
|
|
135
|
+
const res = await runAdapter(script, "real-session", [
|
|
136
|
+
"--output-format",
|
|
137
|
+
"text",
|
|
138
|
+
"--session",
|
|
139
|
+
"evil-session",
|
|
140
|
+
"--prompt",
|
|
141
|
+
"evil prompt",
|
|
142
|
+
"--plan",
|
|
143
|
+
]);
|
|
144
|
+
const argv = JSON.parse(res.text) as string[];
|
|
145
|
+
expect(argv.filter((a) => a === "--output-format")).toHaveLength(1);
|
|
146
|
+
expect(argv[argv.indexOf("--output-format") + 1]).toBe("stream-json");
|
|
147
|
+
expect(argv.filter((a) => a === "--session")).toHaveLength(1);
|
|
148
|
+
expect(argv[argv.indexOf("--session") + 1]).toBe("real-session");
|
|
149
|
+
expect(argv.filter((a) => a === "--prompt")).toHaveLength(1);
|
|
150
|
+
expect(argv[argv.indexOf("--prompt") + 1]).toBe("hi");
|
|
151
|
+
expect(argv).toContain("--plan");
|
|
152
|
+
expect(argv).not.toContain("evil-session");
|
|
153
|
+
expect(argv).not.toContain("evil prompt");
|
|
154
|
+
});
|
|
155
|
+
|
|
72
156
|
it("rejects session ids that could be parsed as flags", async () => {
|
|
73
157
|
const script = makeScript(
|
|
74
158
|
"should-not-spawn.js",
|
|
@@ -907,12 +907,25 @@ function normalizeBlockForHub(
|
|
|
907
907
|
if (kind === "assistant_text") {
|
|
908
908
|
// Claude Code: {type:"assistant", message:{content:[{type:"text",text}]}}
|
|
909
909
|
// Codex: {type:"item.completed", item:{type:"agent_message", text}}
|
|
910
|
+
// DeepSeek: {event:"message.delta", payload:{content}} or
|
|
911
|
+
// {event:"item.delta", payload:{payload:{kind:"agent_message", delta}}}
|
|
910
912
|
let text = "";
|
|
911
913
|
const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
|
|
912
914
|
for (const c of contents) {
|
|
913
915
|
if (c?.type === "text" && typeof c.text === "string") text += c.text;
|
|
914
916
|
}
|
|
915
917
|
if (!text && typeof raw?.item?.text === "string") text = raw.item.text;
|
|
918
|
+
if (!text && raw?.event === "message.delta" && typeof raw?.payload?.content === "string") {
|
|
919
|
+
text = raw.payload.content;
|
|
920
|
+
}
|
|
921
|
+
if (
|
|
922
|
+
!text &&
|
|
923
|
+
raw?.event === "item.delta" &&
|
|
924
|
+
raw?.payload?.payload?.kind === "agent_message" &&
|
|
925
|
+
typeof raw?.payload?.payload?.delta === "string"
|
|
926
|
+
) {
|
|
927
|
+
text = raw.payload.payload.delta;
|
|
928
|
+
}
|
|
916
929
|
return { kind: "assistant", seq, payload: { text } };
|
|
917
930
|
}
|
|
918
931
|
|
|
@@ -22,6 +22,113 @@ function invalidKimiSessionIdError(): string {
|
|
|
22
22
|
return "kimi-cli: invalid sessionId (expected non-control text not starting with '-')";
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
const KIMI_EXTRA_FLAGS_WITH_VALUE = new Set([
|
|
26
|
+
"--add-dir",
|
|
27
|
+
"--agent",
|
|
28
|
+
"--agent-file",
|
|
29
|
+
"--config",
|
|
30
|
+
"--config-file",
|
|
31
|
+
"--max-ralph-iterations",
|
|
32
|
+
"--max-retries-per-step",
|
|
33
|
+
"--max-steps-per-turn",
|
|
34
|
+
"--mcp-config",
|
|
35
|
+
"--mcp-config-file",
|
|
36
|
+
"--model",
|
|
37
|
+
"--skills-dir",
|
|
38
|
+
"-m",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const KIMI_EXTRA_BOOLEAN_FLAGS = new Set([
|
|
42
|
+
"--afk",
|
|
43
|
+
"--auto-approve",
|
|
44
|
+
"--debug",
|
|
45
|
+
"--no-thinking",
|
|
46
|
+
"--plan",
|
|
47
|
+
"--thinking",
|
|
48
|
+
"--verbose",
|
|
49
|
+
"--yes",
|
|
50
|
+
"--yolo",
|
|
51
|
+
"-y",
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
// Flags owned by the adapter because BotCord depends on Kimi's non-interactive
|
|
55
|
+
// stream-json contract, cwd isolation, prompt placement, and session routing.
|
|
56
|
+
const KIMI_ADAPTER_OWNED_FLAGS = new Set([
|
|
57
|
+
"--acp",
|
|
58
|
+
"--command",
|
|
59
|
+
"--continue",
|
|
60
|
+
"--final-message-only",
|
|
61
|
+
"--help",
|
|
62
|
+
"--input-format",
|
|
63
|
+
"--output-format",
|
|
64
|
+
"--print",
|
|
65
|
+
"--prompt",
|
|
66
|
+
"--quiet",
|
|
67
|
+
"--resume",
|
|
68
|
+
"--session",
|
|
69
|
+
"--version",
|
|
70
|
+
"--wire",
|
|
71
|
+
"--work-dir",
|
|
72
|
+
"-C",
|
|
73
|
+
"-S",
|
|
74
|
+
"-V",
|
|
75
|
+
"-c",
|
|
76
|
+
"-h",
|
|
77
|
+
"-p",
|
|
78
|
+
"-r",
|
|
79
|
+
"-w",
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
function flagName(arg: string): string {
|
|
83
|
+
if (!arg.startsWith("-")) return arg;
|
|
84
|
+
const eq = arg.indexOf("=");
|
|
85
|
+
return eq === -1 ? arg : arg.slice(0, eq);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function nextValue(args: string[], index: number): string | undefined {
|
|
89
|
+
const next = args[index + 1];
|
|
90
|
+
if (typeof next !== "string") return undefined;
|
|
91
|
+
if (!next.startsWith("-")) return next;
|
|
92
|
+
return /^-\d/.test(next) ? next : undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sanitizeKimiExtraArgs(extraArgs: string[] | undefined): string[] {
|
|
96
|
+
if (!extraArgs?.length) return [];
|
|
97
|
+
const out: string[] = [];
|
|
98
|
+
for (let i = 0; i < extraArgs.length; i += 1) {
|
|
99
|
+
const arg = extraArgs[i];
|
|
100
|
+
const name = flagName(arg);
|
|
101
|
+
|
|
102
|
+
if (KIMI_ADAPTER_OWNED_FLAGS.has(name)) {
|
|
103
|
+
if (!arg.includes("=") && nextValue(extraArgs, i) !== undefined) i += 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (KIMI_EXTRA_FLAGS_WITH_VALUE.has(name)) {
|
|
108
|
+
if (arg.includes("=")) {
|
|
109
|
+
out.push(arg);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const value = nextValue(extraArgs, i);
|
|
113
|
+
if (value !== undefined) {
|
|
114
|
+
out.push(arg, value);
|
|
115
|
+
i += 1;
|
|
116
|
+
}
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (KIMI_EXTRA_BOOLEAN_FLAGS.has(name)) {
|
|
121
|
+
out.push(arg);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (arg.startsWith("-") && !arg.includes("=") && nextValue(extraArgs, i) !== undefined) {
|
|
126
|
+
i += 1;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
25
132
|
/** Resolve the Kimi CLI executable on PATH. */
|
|
26
133
|
export function resolveKimiCommand(deps: ProbeDeps = {}): string | null {
|
|
27
134
|
return resolveCommandOnPath("kimi", deps);
|
|
@@ -41,7 +148,7 @@ export function probeKimi(deps: ProbeDeps = {}): RuntimeProbeResult {
|
|
|
41
148
|
/**
|
|
42
149
|
* Kimi CLI adapter — spawns:
|
|
43
150
|
*
|
|
44
|
-
* kimi --work-dir <cwd> --print --output-format stream-json --session <sid> --prompt <text>
|
|
151
|
+
* kimi --work-dir <cwd> --print --output-format stream-json --session <sid> --afk --prompt <text>
|
|
45
152
|
*
|
|
46
153
|
* `--session <sid>` resumes an existing session or creates a new session with
|
|
47
154
|
* that id, so the adapter generates a UUID on first turn and persists it for
|
|
@@ -93,7 +200,7 @@ export class KimiAdapter extends NdjsonStreamAdapter {
|
|
|
93
200
|
sessionId,
|
|
94
201
|
"--afk",
|
|
95
202
|
];
|
|
96
|
-
|
|
203
|
+
args.push(...sanitizeKimiExtraArgs(opts.extraArgs));
|
|
97
204
|
args.push("--prompt", promptWithSystemContext(opts.text, opts.systemContext));
|
|
98
205
|
return args;
|
|
99
206
|
}
|
|
@@ -34,8 +34,8 @@ export interface MergeOpenclawGatewayResult {
|
|
|
34
34
|
added: OpenclawGatewayProfile[];
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
const DEFAULT_SEARCH_PATHS = ["~/.openclaw/", "/etc/openclaw/"];
|
|
38
|
-
const DEFAULT_PORTS = [18789, 16200];
|
|
37
|
+
const DEFAULT_SEARCH_PATHS = ["~/.openclaw/", "~/.qclaw/", "/etc/openclaw/"];
|
|
38
|
+
const DEFAULT_PORTS = [18789, 16200, 28789];
|
|
39
39
|
const DEFAULT_TOKEN_FILE_PATHS = [
|
|
40
40
|
"/run/openclaw/gateway-token",
|
|
41
41
|
"/var/run/openclaw/gateway-token",
|
|
@@ -382,6 +382,8 @@ function discoverFromConfigDir(root: string): DiscoveredOpenclawGateway[] {
|
|
|
382
382
|
|
|
383
383
|
function parseJsonConfig(raw: string): { url?: string; token?: string; tokenFile?: string } | null {
|
|
384
384
|
const obj = JSON.parse(raw) as any;
|
|
385
|
+
const qclaw = pickQclawGatewayValues(obj);
|
|
386
|
+
if (qclaw) return qclaw;
|
|
385
387
|
// Prefer OpenClaw's native shape: `gateway.port` + `gateway.auth.token`.
|
|
386
388
|
// The legacy `acp.url` shape is also supported for explicit user-authored configs.
|
|
387
389
|
const native = pickOpenclawGatewayValues(obj?.gateway);
|
|
@@ -390,6 +392,35 @@ function parseJsonConfig(raw: string): { url?: string; token?: string; tokenFile
|
|
|
390
392
|
return pickConfigValues(acp);
|
|
391
393
|
}
|
|
392
394
|
|
|
395
|
+
function pickQclawGatewayValues(
|
|
396
|
+
obj: any,
|
|
397
|
+
): { url?: string; token?: string; tokenFile?: string } | null {
|
|
398
|
+
if (!obj || typeof obj !== "object") return null;
|
|
399
|
+
const port = typeof obj.port === "number" ? obj.port : undefined;
|
|
400
|
+
const configPath = typeof obj.configPath === "string" && obj.configPath.trim()
|
|
401
|
+
? obj.configPath.trim()
|
|
402
|
+
: undefined;
|
|
403
|
+
if (!port && !configPath) return null;
|
|
404
|
+
|
|
405
|
+
const fromConfig = configPath ? readGatewayValuesFromConfigPath(configPath) : null;
|
|
406
|
+
if (fromConfig) return fromConfig;
|
|
407
|
+
if (!port) return null;
|
|
408
|
+
return { url: `ws://127.0.0.1:${port}` };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function readGatewayValuesFromConfigPath(
|
|
412
|
+
configPath: string,
|
|
413
|
+
): { url?: string; token?: string; tokenFile?: string } | null {
|
|
414
|
+
try {
|
|
415
|
+
const raw = readFileSync(expandHome(configPath), "utf8");
|
|
416
|
+
const parsed = parseJsonConfig(raw);
|
|
417
|
+
if (parsed?.url) return parsed;
|
|
418
|
+
} catch {
|
|
419
|
+
// qclaw.json may be copied without its referenced openclaw.json.
|
|
420
|
+
}
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
|
|
393
424
|
function pickOpenclawGatewayValues(
|
|
394
425
|
gw: any,
|
|
395
426
|
): { url?: string; token?: string; tokenFile?: string } | null {
|
package/src/provision.ts
CHANGED
|
@@ -1362,9 +1362,9 @@ export async function adoptDiscoveredOpenclawAgents(ctx: {
|
|
|
1362
1362
|
function localOpenclawAcpDisabled(rawUrl: string): boolean {
|
|
1363
1363
|
if (!isLoopbackUrl(rawUrl)) return false;
|
|
1364
1364
|
try {
|
|
1365
|
-
const
|
|
1366
|
-
if (!
|
|
1367
|
-
const cfg = JSON.parse(readFileSync(file, "utf8")) as any;
|
|
1365
|
+
const source = pickLocalOpenclawConfig(rawUrl);
|
|
1366
|
+
if (!source) return false;
|
|
1367
|
+
const cfg = JSON.parse(readFileSync(source.file, "utf8")) as any;
|
|
1368
1368
|
return cfg?.acp?.enabled === false;
|
|
1369
1369
|
} catch {
|
|
1370
1370
|
return false;
|
|
@@ -1838,12 +1838,13 @@ export async function probeOpenclawAgents(
|
|
|
1838
1838
|
token: prepared.resolvedToken,
|
|
1839
1839
|
timeoutMs: opts.timeoutMs ?? 3000,
|
|
1840
1840
|
});
|
|
1841
|
-
// For loopback gateways the agent roster lives in
|
|
1841
|
+
// For loopback gateways the agent roster lives in local OpenClaw config
|
|
1842
|
+
// (`~/.openclaw/openclaw.json`, or QClaw's `~/.qclaw/openclaw.json`)
|
|
1842
1843
|
// and is the source of truth — listing it over the wire would require a
|
|
1843
1844
|
// paired device identity (operator.read scope). When the WS probe is the
|
|
1844
1845
|
// default (i.e. no test injection) we enrich the result from disk.
|
|
1845
1846
|
if (result.ok && !result.agents && !opts.probe && isLoopbackUrl(profile.url)) {
|
|
1846
|
-
const local = readLocalOpenclawAgents();
|
|
1847
|
+
const local = readLocalOpenclawAgents(profile.url);
|
|
1847
1848
|
if (local && local.length > 0) result.agents = local;
|
|
1848
1849
|
}
|
|
1849
1850
|
return result;
|
|
@@ -1858,22 +1859,23 @@ function isLoopbackUrl(raw: string): boolean {
|
|
|
1858
1859
|
}
|
|
1859
1860
|
}
|
|
1860
1861
|
|
|
1861
|
-
function readLocalOpenclawAgents(): Array<{
|
|
1862
|
+
function readLocalOpenclawAgents(rawUrl?: string): Array<{
|
|
1862
1863
|
id: string;
|
|
1863
1864
|
name?: string;
|
|
1864
1865
|
workspace?: string;
|
|
1865
1866
|
model?: { name?: string; provider?: string };
|
|
1866
1867
|
}> | null {
|
|
1867
1868
|
try {
|
|
1868
|
-
const
|
|
1869
|
-
if (!
|
|
1869
|
+
const source = pickLocalOpenclawConfig(rawUrl);
|
|
1870
|
+
if (!source) return readLocalOpenclawAgentDirs(path.join(homedir(), ".openclaw")) ?? [{ id: "default" }];
|
|
1871
|
+
const { file, stateDir } = source;
|
|
1870
1872
|
const cfg = JSON.parse(readFileSync(file, "utf8")) as any;
|
|
1871
1873
|
const list = Array.isArray(cfg?.agents?.list) ? cfg.agents.list : [];
|
|
1872
1874
|
const explicitDefaultId =
|
|
1873
1875
|
typeof cfg?.agents?.defaults?.id === "string" && cfg.agents.defaults.id
|
|
1874
1876
|
? cfg.agents.defaults.id
|
|
1875
1877
|
: null;
|
|
1876
|
-
const dirAgents = readLocalOpenclawAgentDirs();
|
|
1878
|
+
const dirAgents = readLocalOpenclawAgentDirs(stateDir);
|
|
1877
1879
|
const defaultId = explicitDefaultId ?? (list.length === 0 && !dirAgents ? "default" : null);
|
|
1878
1880
|
const seen = new Set<string>();
|
|
1879
1881
|
const out: Array<{ id: string; name?: string; workspace?: string; model?: { name?: string; provider?: string } }> = [];
|
|
@@ -1906,18 +1908,50 @@ function readLocalOpenclawAgents(): Array<{
|
|
|
1906
1908
|
}
|
|
1907
1909
|
}
|
|
1908
1910
|
|
|
1909
|
-
function
|
|
1911
|
+
function pickLocalOpenclawConfig(rawUrl?: string): { file: string; stateDir: string } | null {
|
|
1912
|
+
const candidates = [
|
|
1913
|
+
{ file: path.join(homedir(), ".openclaw", "openclaw.json"), stateDir: path.join(homedir(), ".openclaw") },
|
|
1914
|
+
{ file: path.join(homedir(), ".qclaw", "openclaw.json"), stateDir: path.join(homedir(), ".qclaw") },
|
|
1915
|
+
];
|
|
1916
|
+
const targetPort = urlPort(rawUrl);
|
|
1917
|
+
let firstExisting: { file: string; stateDir: string } | null = null;
|
|
1918
|
+
for (const candidate of candidates) {
|
|
1919
|
+
if (!existsSync(candidate.file)) continue;
|
|
1920
|
+
firstExisting ??= candidate;
|
|
1921
|
+
if (!targetPort) continue;
|
|
1922
|
+
try {
|
|
1923
|
+
const cfg = JSON.parse(readFileSync(candidate.file, "utf8")) as any;
|
|
1924
|
+
if (Number(cfg?.gateway?.port) === targetPort) return candidate;
|
|
1925
|
+
} catch {
|
|
1926
|
+
// Try the next local config.
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
return firstExisting;
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
function urlPort(rawUrl?: string): number | null {
|
|
1933
|
+
if (!rawUrl) return null;
|
|
1934
|
+
try {
|
|
1935
|
+
const u = new URL(rawUrl);
|
|
1936
|
+
const port = Number(u.port || (u.protocol === "wss:" ? 443 : 80));
|
|
1937
|
+
return Number.isInteger(port) && port > 0 ? port : null;
|
|
1938
|
+
} catch {
|
|
1939
|
+
return null;
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
function readLocalOpenclawAgentDirs(stateDir: string): Array<{
|
|
1910
1944
|
id: string;
|
|
1911
1945
|
workspace?: string;
|
|
1912
1946
|
}> | null {
|
|
1913
1947
|
try {
|
|
1914
|
-
const dir = path.join(
|
|
1948
|
+
const dir = path.join(stateDir, "agents");
|
|
1915
1949
|
if (!existsSync(dir)) return null;
|
|
1916
1950
|
const agents = readdirSync(dir, { withFileTypes: true })
|
|
1917
1951
|
.filter((entry) => entry.isDirectory() && entry.name.length > 0)
|
|
1918
1952
|
.map((entry) => ({
|
|
1919
1953
|
id: entry.name,
|
|
1920
|
-
workspace:
|
|
1954
|
+
workspace: resolveAgentDirWorkspace(dir, entry.name),
|
|
1921
1955
|
}));
|
|
1922
1956
|
if (agents.length === 0) return null;
|
|
1923
1957
|
agents.sort((a, b) => {
|
|
@@ -1931,6 +1965,11 @@ function readLocalOpenclawAgentDirs(): Array<{
|
|
|
1931
1965
|
}
|
|
1932
1966
|
}
|
|
1933
1967
|
|
|
1968
|
+
function resolveAgentDirWorkspace(agentsDir: string, agentId: string): string {
|
|
1969
|
+
const nested = path.join(agentsDir, agentId, "agent");
|
|
1970
|
+
return existsSync(nested) ? nested : path.join(agentsDir, agentId);
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1934
1973
|
function resolveOpenclawIdentityName(
|
|
1935
1974
|
agentId: string,
|
|
1936
1975
|
workspace?: string,
|