@botcord/daemon 0.2.16 → 0.2.18
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 +14 -0
- package/dist/daemon.js +5 -4
- package/dist/gateway/channels/botcord.js +40 -10
- package/dist/index.js +11 -5
- package/dist/openclaw-discovery.js +44 -16
- package/dist/provision.js +53 -3
- package/dist/start-auth.d.ts +7 -0
- package/dist/start-auth.js +7 -0
- package/package.json +1 -1
- package/src/__tests__/agent-discovery.test.ts +38 -0
- package/src/__tests__/openclaw-discovery.test.ts +95 -0
- package/src/__tests__/provision.test.ts +50 -0
- package/src/__tests__/runtime-discovery.test.ts +103 -1
- package/src/__tests__/start-auth.test.ts +46 -0
- package/src/agent-discovery.ts +21 -0
- package/src/daemon.ts +6 -5
- package/src/gateway/__tests__/botcord-channel.test.ts +52 -0
- package/src/gateway/channels/botcord.ts +44 -9
- package/src/index.ts +11 -5
- package/src/openclaw-discovery.ts +45 -14
- package/src/provision.ts +52 -3
- package/src/start-auth.ts +13 -0
|
@@ -49,6 +49,12 @@ export interface DiscoveryFs {
|
|
|
49
49
|
export interface DiscoveryOptions extends DiscoveryFs {
|
|
50
50
|
/** Directory to scan. Defaults to {@link DEFAULT_CREDENTIALS_DIR}. */
|
|
51
51
|
credentialsDir?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Optional daemon target Hub. When set, auto-discovered credentials whose
|
|
54
|
+
* hubUrl points at a different host are skipped so preview/prod identities
|
|
55
|
+
* are not mixed by accident.
|
|
56
|
+
*/
|
|
57
|
+
expectedHubUrl?: string;
|
|
52
58
|
}
|
|
53
59
|
/**
|
|
54
60
|
* Scan the credentials directory and return one entry per valid BotCord
|
package/dist/agent-discovery.js
CHANGED
|
@@ -66,6 +66,10 @@ export function discoverAgentCredentials(opts = {}) {
|
|
|
66
66
|
warnings.push(`credentials at ${file} missing agentId; skipped`);
|
|
67
67
|
continue;
|
|
68
68
|
}
|
|
69
|
+
if (opts.expectedHubUrl && !sameHubHost(creds.hubUrl, opts.expectedHubUrl)) {
|
|
70
|
+
warnings.push(`credential skipped: hubUrl does not match daemon environment (${file})`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
69
73
|
const existing = byAgent.get(creds.agentId);
|
|
70
74
|
if (!existing) {
|
|
71
75
|
byAgent.set(creds.agentId, { creds, credentialsFile: file, mtimeMs });
|
|
@@ -116,6 +120,16 @@ export function discoverAgentCredentials(opts = {}) {
|
|
|
116
120
|
function errMsg(err) {
|
|
117
121
|
return err instanceof Error ? err.message : String(err);
|
|
118
122
|
}
|
|
123
|
+
function sameHubHost(a, b) {
|
|
124
|
+
if (!a || !b)
|
|
125
|
+
return true;
|
|
126
|
+
try {
|
|
127
|
+
return new URL(a).host === new URL(b).host;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
119
133
|
/**
|
|
120
134
|
* Resolve the list of agents the daemon should bind at boot.
|
|
121
135
|
*
|
package/dist/daemon.js
CHANGED
|
@@ -116,11 +116,15 @@ function buildDaemonLogger() {
|
|
|
116
116
|
*/
|
|
117
117
|
export async function startDaemon(opts) {
|
|
118
118
|
const logger = opts.log ?? buildDaemonLogger();
|
|
119
|
+
const userAuth = opts.userAuth === undefined
|
|
120
|
+
? tryLoadUserAuth(logger)
|
|
121
|
+
: opts.userAuth;
|
|
122
|
+
const expectedHubUrl = opts.hubBaseUrl ?? userAuth?.current?.hubUrl;
|
|
119
123
|
// Resolve boot agents: explicit `agents` config wins; otherwise scan the
|
|
120
124
|
// credentials directory. A zero-agent result is valid in P1 — the daemon
|
|
121
125
|
// still starts with zero channels so operators can drop credentials in
|
|
122
126
|
// and restart without re-running `init`.
|
|
123
|
-
const boot = opts.bootAgents ?? resolveBootAgents(opts.config);
|
|
127
|
+
const boot = opts.bootAgents ?? resolveBootAgents(opts.config, { expectedHubUrl });
|
|
124
128
|
for (const w of boot.warnings) {
|
|
125
129
|
logger.warn("daemon.discovery.warning", { message: w });
|
|
126
130
|
}
|
|
@@ -316,9 +320,6 @@ export async function startDaemon(opts) {
|
|
|
316
320
|
// when user-auth hasn't been set up yet. Operators can `login` later
|
|
317
321
|
// without restarting, but for P0 we require a restart to pick it up.
|
|
318
322
|
let controlChannel = null;
|
|
319
|
-
const userAuth = opts.userAuth === undefined
|
|
320
|
-
? tryLoadUserAuth(logger)
|
|
321
|
-
: opts.userAuth;
|
|
322
323
|
if (userAuth?.current && !opts.disableControlChannel) {
|
|
323
324
|
logger.info("control-channel: enabling", {
|
|
324
325
|
userId: userAuth.current.userId,
|
|
@@ -7,6 +7,7 @@ const MAX_AUTH_FAILURES = 5;
|
|
|
7
7
|
const SEEN_MESSAGES_CAP = 500;
|
|
8
8
|
const OWNER_CHAT_PREFIX = "rm_oc_";
|
|
9
9
|
const DM_ROOM_PREFIX = "rm_dm_";
|
|
10
|
+
const INBOX_POLL_LIMIT = 50;
|
|
10
11
|
/** Default factory: wrap `loadStoredCredentials` + `new BotCordClient`. */
|
|
11
12
|
function defaultClientFactory(input) {
|
|
12
13
|
const credFile = input.credentialsPath ?? defaultCredentialsFile(input.agentId);
|
|
@@ -199,20 +200,41 @@ export function createBotCordChannel(options) {
|
|
|
199
200
|
}
|
|
200
201
|
return clientRef;
|
|
201
202
|
}
|
|
202
|
-
async function drainInbox(client, emit, log) {
|
|
203
|
-
const
|
|
203
|
+
async function drainInbox(client, emit, log, trigger) {
|
|
204
|
+
const startedAt = Date.now();
|
|
205
|
+
const resp = await client.pollInbox({ limit: INBOX_POLL_LIMIT, ack: false });
|
|
204
206
|
const msgs = resp.messages ?? [];
|
|
205
|
-
|
|
206
|
-
|
|
207
|
+
let duplicateCount = 0;
|
|
208
|
+
let skippedCount = 0;
|
|
209
|
+
let emittedGroups = 0;
|
|
210
|
+
const logDrain = () => {
|
|
211
|
+
log.info("botcord inbox drained", {
|
|
212
|
+
trigger,
|
|
213
|
+
count: msgs.length,
|
|
214
|
+
responseCount: resp.count,
|
|
215
|
+
hasMore: resp.has_more,
|
|
216
|
+
limit: INBOX_POLL_LIMIT,
|
|
217
|
+
ack: false,
|
|
218
|
+
eligibleCount: eligible.length,
|
|
219
|
+
duplicateCount,
|
|
220
|
+
skippedCount,
|
|
221
|
+
emittedGroups,
|
|
222
|
+
durationMs: Date.now() - startedAt,
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
const eligible = [];
|
|
226
|
+
if (msgs.length === 0) {
|
|
227
|
+
logDrain();
|
|
207
228
|
return;
|
|
229
|
+
}
|
|
208
230
|
// First pass: ack duplicates/skipped messages so Hub stops requeueing,
|
|
209
231
|
// and collect eligible messages preserving poll order. Grouping by
|
|
210
232
|
// `(room_id, topic)` mirrors plugin's `handleInboxMessageBatch` — the
|
|
211
233
|
// same conversation thread folds into one turn so the agent sees all
|
|
212
234
|
// new messages at once instead of running N turns back-to-back.
|
|
213
|
-
const eligible = [];
|
|
214
235
|
for (const msg of msgs) {
|
|
215
236
|
if (!rememberSeen(msg.hub_msg_id)) {
|
|
237
|
+
duplicateCount += 1;
|
|
216
238
|
try {
|
|
217
239
|
await client.ackMessages([msg.hub_msg_id]);
|
|
218
240
|
}
|
|
@@ -226,6 +248,7 @@ export function createBotCordChannel(options) {
|
|
|
226
248
|
accountId: options.accountId,
|
|
227
249
|
});
|
|
228
250
|
if (!normalized) {
|
|
251
|
+
skippedCount += 1;
|
|
229
252
|
try {
|
|
230
253
|
await client.ackMessages([msg.hub_msg_id]);
|
|
231
254
|
}
|
|
@@ -236,8 +259,10 @@ export function createBotCordChannel(options) {
|
|
|
236
259
|
}
|
|
237
260
|
eligible.push(msg);
|
|
238
261
|
}
|
|
239
|
-
if (eligible.length === 0)
|
|
262
|
+
if (eligible.length === 0) {
|
|
263
|
+
logDrain();
|
|
240
264
|
return;
|
|
265
|
+
}
|
|
241
266
|
// Group by `(room_id, topic)`. Insertion order is the poll order, so
|
|
242
267
|
// iterating the map yields groups with the same external chronology.
|
|
243
268
|
const groups = new Map();
|
|
@@ -278,6 +303,7 @@ export function createBotCordChannel(options) {
|
|
|
278
303
|
};
|
|
279
304
|
try {
|
|
280
305
|
await emit(envelope);
|
|
306
|
+
emittedGroups += 1;
|
|
281
307
|
}
|
|
282
308
|
catch (err) {
|
|
283
309
|
log.error("botcord emit threw", {
|
|
@@ -286,6 +312,7 @@ export function createBotCordChannel(options) {
|
|
|
286
312
|
});
|
|
287
313
|
}
|
|
288
314
|
}
|
|
315
|
+
logDrain();
|
|
289
316
|
}
|
|
290
317
|
function startWsLoop(client, ctx) {
|
|
291
318
|
const { abortSignal, log, emit, setStatus } = ctx;
|
|
@@ -318,16 +345,19 @@ export function createBotCordChannel(options) {
|
|
|
318
345
|
statusSnapshot = { ...statusSnapshot, ...patch };
|
|
319
346
|
setStatus(patch);
|
|
320
347
|
}
|
|
321
|
-
async function fireInbox() {
|
|
348
|
+
async function fireInbox(trigger) {
|
|
322
349
|
if (processing) {
|
|
323
350
|
pendingUpdate = true;
|
|
351
|
+
log.debug("botcord inbox drain queued while previous drain is running", { trigger });
|
|
324
352
|
return;
|
|
325
353
|
}
|
|
326
354
|
processing = true;
|
|
327
355
|
try {
|
|
356
|
+
let currentTrigger = trigger;
|
|
328
357
|
do {
|
|
329
358
|
pendingUpdate = false;
|
|
330
|
-
await drainInbox(client, emit, log);
|
|
359
|
+
await drainInbox(client, emit, log, currentTrigger);
|
|
360
|
+
currentTrigger = "coalesced_inbox_update";
|
|
331
361
|
} while (pendingUpdate && running);
|
|
332
362
|
}
|
|
333
363
|
catch (err) {
|
|
@@ -413,7 +443,7 @@ export function createBotCordChannel(options) {
|
|
|
413
443
|
lastError: null,
|
|
414
444
|
});
|
|
415
445
|
log.info("botcord ws authenticated", { agentId: msg.agent_id });
|
|
416
|
-
void fireInbox();
|
|
446
|
+
void fireInbox("ws_auth_ok");
|
|
417
447
|
keepaliveTimer = setInterval(() => {
|
|
418
448
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
419
449
|
try {
|
|
@@ -427,7 +457,7 @@ export function createBotCordChannel(options) {
|
|
|
427
457
|
}
|
|
428
458
|
else if (msg.type === "inbox_update") {
|
|
429
459
|
log.info("botcord ws inbox_update received");
|
|
430
|
-
void fireInbox();
|
|
460
|
+
void fireInbox("ws_inbox_update");
|
|
431
461
|
}
|
|
432
462
|
else if (msg.type === "heartbeat" || msg.type === "pong") {
|
|
433
463
|
// no-op
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { renderStatus } from "./status-render.js";
|
|
|
15
15
|
import { appendNextParam } from "./url-utils.js";
|
|
16
16
|
import { channelsFromDaemonConfig, defaultHttpFetcher, renderDoctor, runDoctor, } from "./doctor.js";
|
|
17
17
|
import { clearWorkingMemory, readWorkingMemory, resolveMemoryDir, updateWorkingMemory, DEFAULT_SECTION, } from "./working-memory.js";
|
|
18
|
+
import { resolveStartAuthAction } from "./start-auth.js";
|
|
18
19
|
import { discoverLocalOpenclawGateways, mergeOpenclawGateways, openclawDiscoveryConfigEnabled, } from "./openclaw-discovery.js";
|
|
19
20
|
const ADAPTER_LIST = listAdapterIds().join("|");
|
|
20
21
|
const DEFAULT_HUB = "https://api.botcord.chat";
|
|
@@ -329,9 +330,10 @@ async function runDeviceCodeFlow(opts) {
|
|
|
329
330
|
* plane (legacy P0 behavior — caller may still log a warning).
|
|
330
331
|
*
|
|
331
332
|
* Decision tree (plan §4.4 + §6.4):
|
|
332
|
-
* 1. Have existing creds
|
|
333
|
-
*
|
|
334
|
-
*
|
|
333
|
+
* 1. Have existing creds and no `--relogin` → return existing record, even
|
|
334
|
+
* when a dashboard `--install-token` is present. The token is one-time and
|
|
335
|
+
* the generated install command should be safe to re-run after first login.
|
|
336
|
+
* 2. No existing creds + `--install-token` → redeem the one-time dashboard ticket.
|
|
335
337
|
* 3. `--relogin` → device-code login.
|
|
336
338
|
* 4. No creds + TTY → device-code login.
|
|
337
339
|
* 5. No creds + no TTY → exit 1 with the §6.4 hint.
|
|
@@ -342,7 +344,8 @@ async function ensureUserAuthForStart(args) {
|
|
|
342
344
|
const installToken = typeof args.flags["install-token"] === "string" ? args.flags["install-token"] : undefined;
|
|
343
345
|
const relogin = args.flags.relogin === true;
|
|
344
346
|
const existing = safeLoadUserAuth();
|
|
345
|
-
|
|
347
|
+
const authAction = resolveStartAuthAction({ existing, relogin, installToken });
|
|
348
|
+
if (authAction === "reuse-existing" && existing) {
|
|
346
349
|
// A previously-set auth-expired flag is stale by definition once the
|
|
347
350
|
// operator runs `start` again — if creds genuinely don't work, the
|
|
348
351
|
// control channel will re-write the flag on the next 4401/4403.
|
|
@@ -356,12 +359,15 @@ async function ensureUserAuthForStart(args) {
|
|
|
356
359
|
if (labelFlag && existing.label !== labelFlag) {
|
|
357
360
|
console.error(`note: --label "${labelFlag}" ignored (already logged in as "${existing.label ?? "<unset>"}"); pass --relogin to change it`);
|
|
358
361
|
}
|
|
362
|
+
if (installToken) {
|
|
363
|
+
console.error("note: --install-token ignored because daemon is already logged in; pass --relogin to re-bind");
|
|
364
|
+
}
|
|
359
365
|
return existing;
|
|
360
366
|
}
|
|
361
367
|
// Need a fresh login. Resolve hubUrl: explicit --hub > existing record > DEFAULT_HUB.
|
|
362
368
|
const hubUrl = hubFlag ?? existing?.hubUrl ?? DEFAULT_HUB;
|
|
363
369
|
const label = labelFlag ?? defaultLoginLabel();
|
|
364
|
-
if (installToken) {
|
|
370
|
+
if (authAction === "install-token" && installToken) {
|
|
365
371
|
const tok = await redeemInstallToken({ hubUrl, installToken, label });
|
|
366
372
|
const record = userAuthFromTokenResponse(tok, { label });
|
|
367
373
|
saveUserAuth(record);
|
|
@@ -4,34 +4,23 @@ import path from "node:path";
|
|
|
4
4
|
import { log as daemonLog } from "./log.js";
|
|
5
5
|
import { probeOpenclawAgents } from "./provision.js";
|
|
6
6
|
const DEFAULT_SEARCH_PATHS = ["~/.openclaw/", "/etc/openclaw/"];
|
|
7
|
-
const DEFAULT_PORTS = [18789];
|
|
7
|
+
const DEFAULT_PORTS = [18789, 16200];
|
|
8
8
|
export async function discoverLocalOpenclawGateways(opts = {}) {
|
|
9
9
|
const found = [];
|
|
10
10
|
for (const root of opts.searchPaths ?? DEFAULT_SEARCH_PATHS) {
|
|
11
11
|
found.push(...discoverFromConfigDir(root));
|
|
12
12
|
}
|
|
13
13
|
const env = opts.env ?? process.env;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const item = {
|
|
17
|
-
name: nameFromUrl(envUrl),
|
|
18
|
-
url: envUrl,
|
|
19
|
-
source: "env",
|
|
20
|
-
};
|
|
21
|
-
if (env.OPENCLAW_ACP_TOKEN)
|
|
22
|
-
item.token = env.OPENCLAW_ACP_TOKEN;
|
|
23
|
-
else if (env.OPENCLAW_ACP_TOKEN_FILE)
|
|
24
|
-
item.tokenFile = env.OPENCLAW_ACP_TOKEN_FILE;
|
|
25
|
-
found.push(item);
|
|
26
|
-
}
|
|
14
|
+
found.push(...discoverFromEnv(env));
|
|
15
|
+
const envAuth = pickOpenclawEnvAuth(env);
|
|
27
16
|
const ports = opts.defaultPorts ?? DEFAULT_PORTS;
|
|
28
17
|
if (ports.length > 0) {
|
|
29
18
|
await Promise.all(ports.map(async (port) => {
|
|
30
19
|
const url = `ws://127.0.0.1:${port}`;
|
|
31
20
|
try {
|
|
32
|
-
const res = await probeOpenclawAgents({ url }, { probe: opts.probe, timeoutMs: opts.timeoutMs });
|
|
21
|
+
const res = await probeOpenclawAgents({ url, ...envAuth }, { probe: opts.probe, timeoutMs: opts.timeoutMs });
|
|
33
22
|
if (res.ok) {
|
|
34
|
-
found.push({ name: nameFromUrl(url), url, source: "default-port" });
|
|
23
|
+
found.push({ name: nameFromUrl(url), url, source: "default-port", ...envAuth });
|
|
35
24
|
}
|
|
36
25
|
}
|
|
37
26
|
catch (err) {
|
|
@@ -44,6 +33,45 @@ export async function discoverLocalOpenclawGateways(opts = {}) {
|
|
|
44
33
|
}
|
|
45
34
|
return dedupeDiscovered(found);
|
|
46
35
|
}
|
|
36
|
+
function discoverFromEnv(env) {
|
|
37
|
+
const url = pickEnv(env, "OPENCLAW_ACP_URL") ??
|
|
38
|
+
pickEnv(env, "OPENCLAW_GATEWAY_URL") ??
|
|
39
|
+
urlFromGatewayPort(env);
|
|
40
|
+
if (!url)
|
|
41
|
+
return [];
|
|
42
|
+
return [
|
|
43
|
+
{
|
|
44
|
+
name: nameFromUrl(url),
|
|
45
|
+
url,
|
|
46
|
+
source: "env",
|
|
47
|
+
...pickOpenclawEnvAuth(env),
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
function pickOpenclawEnvAuth(env) {
|
|
52
|
+
const token = pickEnv(env, "OPENCLAW_ACP_TOKEN") ?? pickEnv(env, "OPENCLAW_GATEWAY_TOKEN");
|
|
53
|
+
if (token)
|
|
54
|
+
return { token };
|
|
55
|
+
const tokenFile = pickEnv(env, "OPENCLAW_ACP_TOKEN_FILE") ?? pickEnv(env, "OPENCLAW_GATEWAY_TOKEN_FILE");
|
|
56
|
+
if (tokenFile)
|
|
57
|
+
return { tokenFile };
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
function urlFromGatewayPort(env) {
|
|
61
|
+
const raw = pickEnv(env, "OPENCLAW_GATEWAY_PORT");
|
|
62
|
+
if (!raw)
|
|
63
|
+
return undefined;
|
|
64
|
+
const port = Number(raw);
|
|
65
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535)
|
|
66
|
+
return undefined;
|
|
67
|
+
return `ws://127.0.0.1:${port}`;
|
|
68
|
+
}
|
|
69
|
+
function pickEnv(env, key) {
|
|
70
|
+
const value = env[key];
|
|
71
|
+
if (typeof value === "string" && value.trim())
|
|
72
|
+
return value.trim();
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
47
75
|
export function mergeOpenclawGateways(cfg, found) {
|
|
48
76
|
const existing = cfg.openclawGateways ?? [];
|
|
49
77
|
const byUrl = new Map();
|
package/dist/provision.js
CHANGED
|
@@ -534,6 +534,17 @@ export async function adoptDiscoveredOpenclawAgents(ctx) {
|
|
|
534
534
|
failed: [],
|
|
535
535
|
};
|
|
536
536
|
for (const gw of cfg.openclawGateways ?? []) {
|
|
537
|
+
if (localOpenclawAcpDisabled(gw.url)) {
|
|
538
|
+
result.skipped.push({
|
|
539
|
+
gateway: gw.name,
|
|
540
|
+
reason: "acp_disabled",
|
|
541
|
+
});
|
|
542
|
+
daemonLog.warn("openclaw discovery: gateway found but ACP runtime disabled", {
|
|
543
|
+
gateway: gw.name,
|
|
544
|
+
url: gw.url,
|
|
545
|
+
});
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
537
548
|
let probeResult;
|
|
538
549
|
try {
|
|
539
550
|
probeResult = await probeOpenclawAgents(gw, {
|
|
@@ -611,6 +622,20 @@ export async function adoptDiscoveredOpenclawAgents(ctx) {
|
|
|
611
622
|
}
|
|
612
623
|
return result;
|
|
613
624
|
}
|
|
625
|
+
function localOpenclawAcpDisabled(rawUrl) {
|
|
626
|
+
if (!isLoopbackUrl(rawUrl))
|
|
627
|
+
return false;
|
|
628
|
+
try {
|
|
629
|
+
const file = path.join(homedir(), ".openclaw", "openclaw.json");
|
|
630
|
+
if (!existsSync(file))
|
|
631
|
+
return false;
|
|
632
|
+
const cfg = JSON.parse(readFileSync(file, "utf8"));
|
|
633
|
+
return cfg?.acp?.enabled === false;
|
|
634
|
+
}
|
|
635
|
+
catch {
|
|
636
|
+
return false;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
614
639
|
async function revokeAgent(params, ctx) {
|
|
615
640
|
if (!params.agentId) {
|
|
616
641
|
throw new Error("revoke_agent requires params.agentId");
|
|
@@ -1112,26 +1137,51 @@ export async function collectRuntimeSnapshotAsync(opts = {}) {
|
|
|
1112
1137
|
const timeoutMs = opts.timeoutMs ?? 3000;
|
|
1113
1138
|
const capped = gateways.slice(0, RUNTIME_ENDPOINTS_CAP);
|
|
1114
1139
|
const endpoints = await Promise.all(capped.map(async (g) => {
|
|
1140
|
+
if (localOpenclawAcpDisabled(g.url)) {
|
|
1141
|
+
return {
|
|
1142
|
+
name: g.name,
|
|
1143
|
+
url: g.url,
|
|
1144
|
+
reachable: false,
|
|
1145
|
+
status: "acp_disabled",
|
|
1146
|
+
error: "OpenClaw ACP runtime disabled",
|
|
1147
|
+
diagnostics: [
|
|
1148
|
+
{
|
|
1149
|
+
code: "acp_disabled",
|
|
1150
|
+
message: "OpenClaw config explicitly disables the ACP runtime",
|
|
1151
|
+
},
|
|
1152
|
+
],
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1115
1155
|
try {
|
|
1116
1156
|
const res = await probeOpenclawAgents(g, {
|
|
1117
1157
|
probe: opts.wsProbe,
|
|
1118
1158
|
timeoutMs,
|
|
1119
1159
|
});
|
|
1120
|
-
const entry = {
|
|
1160
|
+
const entry = {
|
|
1161
|
+
name: g.name,
|
|
1162
|
+
url: g.url,
|
|
1163
|
+
reachable: res.ok,
|
|
1164
|
+
status: res.ok ? "reachable" : "unreachable",
|
|
1165
|
+
};
|
|
1121
1166
|
if (res.version)
|
|
1122
1167
|
entry.version = res.version;
|
|
1123
|
-
if (res.error)
|
|
1168
|
+
if (res.error) {
|
|
1124
1169
|
entry.error = res.error;
|
|
1170
|
+
entry.diagnostics = [{ code: "gateway_unreachable", message: res.error }];
|
|
1171
|
+
}
|
|
1125
1172
|
if (res.agents)
|
|
1126
1173
|
entry.agents = res.agents;
|
|
1127
1174
|
return entry;
|
|
1128
1175
|
}
|
|
1129
1176
|
catch (err) {
|
|
1177
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1130
1178
|
return {
|
|
1131
1179
|
name: g.name,
|
|
1132
1180
|
url: g.url,
|
|
1133
1181
|
reachable: false,
|
|
1134
|
-
|
|
1182
|
+
status: "unreachable",
|
|
1183
|
+
error: message,
|
|
1184
|
+
diagnostics: [{ code: "probe_failed", message }],
|
|
1135
1185
|
};
|
|
1136
1186
|
}
|
|
1137
1187
|
}));
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { UserAuthRecord } from "./user-auth.js";
|
|
2
|
+
export type StartAuthAction = "reuse-existing" | "install-token" | "device-code";
|
|
3
|
+
export declare function resolveStartAuthAction(opts: {
|
|
4
|
+
existing: UserAuthRecord | null;
|
|
5
|
+
relogin: boolean;
|
|
6
|
+
installToken?: string;
|
|
7
|
+
}): StartAuthAction;
|
package/package.json
CHANGED
|
@@ -159,6 +159,44 @@ describe("resolveBootAgents", () => {
|
|
|
159
159
|
expect(res.agents[0].credentialsFile).toBe("/creds/x.json");
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
+
it("skips discovered credentials from a different Hub host", () => {
|
|
163
|
+
const res = resolveBootAgents(cfg(), {
|
|
164
|
+
credentialsDir: "/creds",
|
|
165
|
+
expectedHubUrl: "https://api.preview.botcord.chat",
|
|
166
|
+
readDir: () => ["prod.json", "preview.json"],
|
|
167
|
+
stat: () => fakeStat(1),
|
|
168
|
+
loadCredentials: (f) =>
|
|
169
|
+
f.endsWith("prod.json")
|
|
170
|
+
? fakeCreds("ag_prod", { hubUrl: "https://api.botcord.chat" })
|
|
171
|
+
: fakeCreds("ag_preview", { hubUrl: "https://api.preview.botcord.chat" }),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(res.source).toBe("credentials");
|
|
175
|
+
expect(res.agents.map((a) => a.agentId)).toEqual(["ag_preview"]);
|
|
176
|
+
expect(res.warnings).toEqual([
|
|
177
|
+
"credential skipped: hubUrl does not match daemon environment (/creds/prod.json)",
|
|
178
|
+
]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("filters by Hub host before resolving duplicate agentIds", () => {
|
|
182
|
+
const res = resolveBootAgents(cfg(), {
|
|
183
|
+
credentialsDir: "/creds",
|
|
184
|
+
expectedHubUrl: "https://api.preview.botcord.chat",
|
|
185
|
+
readDir: () => ["preview.json", "prod.json"],
|
|
186
|
+
stat: (p) => (p.endsWith("prod.json") ? fakeStat(200) : fakeStat(100)),
|
|
187
|
+
loadCredentials: (f) =>
|
|
188
|
+
f.endsWith("prod.json")
|
|
189
|
+
? fakeCreds("ag_same", { hubUrl: "https://api.botcord.chat" })
|
|
190
|
+
: fakeCreds("ag_same", { hubUrl: "https://api.preview.botcord.chat" }),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(res.agents.map((a) => a.agentId)).toEqual(["ag_same"]);
|
|
194
|
+
expect(res.agents[0].credentialsFile).toBe("/creds/preview.json");
|
|
195
|
+
expect(res.warnings).toEqual([
|
|
196
|
+
"credential skipped: hubUrl does not match daemon environment (/creds/prod.json)",
|
|
197
|
+
]);
|
|
198
|
+
});
|
|
199
|
+
|
|
162
200
|
it("returns an empty agent list (not a throw) when discovery finds nothing", () => {
|
|
163
201
|
const res = resolveBootAgents(cfg(), {
|
|
164
202
|
credentialsDir: "/creds",
|
|
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import {
|
|
6
|
+
defaultOpenclawDiscoveryPorts,
|
|
6
7
|
discoverLocalOpenclawGateways,
|
|
7
8
|
mergeOpenclawGateways,
|
|
8
9
|
} from "../openclaw-discovery.js";
|
|
@@ -108,6 +109,69 @@ describe("discoverLocalOpenclawGateways", () => {
|
|
|
108
109
|
]);
|
|
109
110
|
});
|
|
110
111
|
|
|
112
|
+
it("uses OPENCLAW_GATEWAY_URL and gateway token env vars", async () => {
|
|
113
|
+
const found = await discoverLocalOpenclawGateways({
|
|
114
|
+
searchPaths: [],
|
|
115
|
+
defaultPorts: [],
|
|
116
|
+
env: {
|
|
117
|
+
OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:16200",
|
|
118
|
+
OPENCLAW_GATEWAY_TOKEN: "gateway-token",
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(found).toEqual([
|
|
123
|
+
expect.objectContaining({
|
|
124
|
+
url: "ws://127.0.0.1:16200",
|
|
125
|
+
token: "gateway-token",
|
|
126
|
+
source: "env",
|
|
127
|
+
}),
|
|
128
|
+
]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("builds gateway URL from OPENCLAW_GATEWAY_PORT", async () => {
|
|
132
|
+
const found = await discoverLocalOpenclawGateways({
|
|
133
|
+
searchPaths: [],
|
|
134
|
+
defaultPorts: [],
|
|
135
|
+
env: {
|
|
136
|
+
OPENCLAW_GATEWAY_PORT: "16200",
|
|
137
|
+
OPENCLAW_GATEWAY_TOKEN: "gateway-token",
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(found).toEqual([
|
|
142
|
+
expect.objectContaining({
|
|
143
|
+
url: "ws://127.0.0.1:16200",
|
|
144
|
+
token: "gateway-token",
|
|
145
|
+
source: "env",
|
|
146
|
+
}),
|
|
147
|
+
]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("prefers OPENCLAW_ACP env vars over OPENCLAW_GATEWAY env vars", async () => {
|
|
151
|
+
const found = await discoverLocalOpenclawGateways({
|
|
152
|
+
searchPaths: [],
|
|
153
|
+
defaultPorts: [],
|
|
154
|
+
env: {
|
|
155
|
+
OPENCLAW_ACP_URL: "ws://127.0.0.1:18888",
|
|
156
|
+
OPENCLAW_ACP_TOKEN: "acp-token",
|
|
157
|
+
OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:16200",
|
|
158
|
+
OPENCLAW_GATEWAY_TOKEN: "gateway-token",
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(found).toEqual([
|
|
163
|
+
expect.objectContaining({
|
|
164
|
+
url: "ws://127.0.0.1:18888",
|
|
165
|
+
token: "acp-token",
|
|
166
|
+
source: "env",
|
|
167
|
+
}),
|
|
168
|
+
]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("includes 16200 in default discovery ports", () => {
|
|
172
|
+
expect(defaultOpenclawDiscoveryPorts()).toEqual(expect.arrayContaining([18789, 16200]));
|
|
173
|
+
});
|
|
174
|
+
|
|
111
175
|
it("adds default-port candidates only when the probe succeeds", async () => {
|
|
112
176
|
const probe = vi.fn<WsEndpointProbeFn>(async ({ url }) => ({
|
|
113
177
|
ok: url.includes("18789"),
|
|
@@ -125,6 +189,37 @@ describe("discoverLocalOpenclawGateways", () => {
|
|
|
125
189
|
expect(found.map((g) => g.url)).toEqual(["ws://127.0.0.1:18789"]);
|
|
126
190
|
});
|
|
127
191
|
|
|
192
|
+
it("attaches gateway token fallback to default-port discovery", async () => {
|
|
193
|
+
const probe = vi.fn<WsEndpointProbeFn>(async () => ({
|
|
194
|
+
ok: true,
|
|
195
|
+
agents: [],
|
|
196
|
+
}));
|
|
197
|
+
|
|
198
|
+
const found = await discoverLocalOpenclawGateways({
|
|
199
|
+
searchPaths: [],
|
|
200
|
+
defaultPorts: [16200],
|
|
201
|
+
probe,
|
|
202
|
+
timeoutMs: 10,
|
|
203
|
+
env: {
|
|
204
|
+
OPENCLAW_GATEWAY_TOKEN: "gateway-token",
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(probe).toHaveBeenCalledWith(
|
|
209
|
+
expect.objectContaining({
|
|
210
|
+
url: "ws://127.0.0.1:16200",
|
|
211
|
+
token: "gateway-token",
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
expect(found).toEqual([
|
|
215
|
+
expect.objectContaining({
|
|
216
|
+
url: "ws://127.0.0.1:16200",
|
|
217
|
+
token: "gateway-token",
|
|
218
|
+
source: "default-port",
|
|
219
|
+
}),
|
|
220
|
+
]);
|
|
221
|
+
});
|
|
222
|
+
|
|
128
223
|
it("prefers config-file auth details over lower-priority duplicate sources", async () => {
|
|
129
224
|
const dir = tempDir();
|
|
130
225
|
writeFileSync(
|
|
@@ -887,6 +887,56 @@ describe("adoptDiscoveredOpenclawAgents", () => {
|
|
|
887
887
|
});
|
|
888
888
|
});
|
|
889
889
|
|
|
890
|
+
it("skips auto-adopt when local OpenClaw ACP is explicitly disabled", async () => {
|
|
891
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
892
|
+
const credDir = nodePath.join(tmp, ".botcord", "credentials");
|
|
893
|
+
fs.mkdirSync(credDir, { recursive: true });
|
|
894
|
+
fs.writeFileSync(
|
|
895
|
+
nodePath.join(credDir, "ag_seed.json"),
|
|
896
|
+
JSON.stringify({
|
|
897
|
+
version: 1,
|
|
898
|
+
hubUrl: "https://hub.example",
|
|
899
|
+
agentId: "ag_seed",
|
|
900
|
+
keyId: "k_seed",
|
|
901
|
+
privateKey: Buffer.alloc(32, 8).toString("base64"),
|
|
902
|
+
savedAt: new Date().toISOString(),
|
|
903
|
+
}),
|
|
904
|
+
);
|
|
905
|
+
const openclawDir = nodePath.join(tmp, ".openclaw");
|
|
906
|
+
fs.mkdirSync(openclawDir, { recursive: true });
|
|
907
|
+
fs.writeFileSync(
|
|
908
|
+
nodePath.join(openclawDir, "openclaw.json"),
|
|
909
|
+
JSON.stringify({ acp: { enabled: false } }),
|
|
910
|
+
);
|
|
911
|
+
mockState.cfg = {
|
|
912
|
+
defaultRoute: { adapter: "claude-code", cwd: "/tmp" },
|
|
913
|
+
routes: [],
|
|
914
|
+
streamBlocks: true,
|
|
915
|
+
agents: ["ag_seed"],
|
|
916
|
+
openclawGateways: [{ name: "local", url: "ws://127.0.0.1:18789" }],
|
|
917
|
+
};
|
|
918
|
+
const register = vi.fn();
|
|
919
|
+
const probe = vi.fn<Parameters<typeof adoptDiscoveredOpenclawAgents>[0]["probe"]>(
|
|
920
|
+
async () => ({ ok: true, agents: [{ id: "main" }] }),
|
|
921
|
+
);
|
|
922
|
+
|
|
923
|
+
const res = await adoptDiscoveredOpenclawAgents({
|
|
924
|
+
gateway: makeFakeGateway(["ag_seed"]) as unknown as Parameters<typeof adoptDiscoveredOpenclawAgents>[0]["gateway"],
|
|
925
|
+
register: register as unknown as Parameters<typeof adoptDiscoveredOpenclawAgents>[0]["register"],
|
|
926
|
+
cfg: mockState.cfg as unknown as DaemonConfig,
|
|
927
|
+
probe,
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
expect(res).toEqual({
|
|
931
|
+
adopted: [],
|
|
932
|
+
skipped: [{ gateway: "local", reason: "acp_disabled" }],
|
|
933
|
+
failed: [],
|
|
934
|
+
});
|
|
935
|
+
expect(probe).not.toHaveBeenCalled();
|
|
936
|
+
expect(register).not.toHaveBeenCalled();
|
|
937
|
+
});
|
|
938
|
+
});
|
|
939
|
+
|
|
890
940
|
it("uses the OpenClaw workspace identity name when agents.list has no name", async () => {
|
|
891
941
|
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
892
942
|
const credDir = nodePath.join(tmp, ".botcord", "credentials");
|