@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.
@@ -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
@@ -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 resp = await client.pollInbox({ limit: 50, ack: false });
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
- log.info("botcord inbox drained", { count: msgs.length });
206
- if (msgs.length === 0)
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, no `--relogin`, no `--install-token` → return existing record.
333
- * 2. `--install-token` (overrides existing creds they may be stale or
334
- * belong to a different account) redeem the one-time dashboard ticket.
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
- if (!relogin && !installToken && existing) {
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
- const envUrl = env.OPENCLAW_ACP_URL;
15
- if (envUrl) {
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 = { name: g.name, url: g.url, reachable: res.ok };
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
- error: err.message,
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;
@@ -0,0 +1,7 @@
1
+ export function resolveStartAuthAction(opts) {
2
+ if (opts.existing && !opts.relogin)
3
+ return "reuse-existing";
4
+ if (opts.installToken)
5
+ return "install-token";
6
+ return "device-code";
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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");