@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.
@@ -1,4 +1,7 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
2
5
 
3
6
  // Hoisted mock for `../adapters/runtimes.js` so each suite can stub
4
7
  // `detectRuntimes()` independently — we want coverage of the "empty
@@ -24,7 +27,12 @@ vi.mock("../adapters/runtimes.js", async () => {
24
27
  };
25
28
  });
26
29
 
27
- const { collectRuntimeSnapshot, clearRuntimeProbeCache, createProvisioner } = await import("../provision.js");
30
+ const {
31
+ collectRuntimeSnapshot,
32
+ collectRuntimeSnapshotAsync,
33
+ clearRuntimeProbeCache,
34
+ createProvisioner,
35
+ } = await import("../provision.js");
28
36
 
29
37
  beforeEach(() => {
30
38
  // The L1 probe is memoized for 30s in production; tests rotate the
@@ -94,6 +102,100 @@ describe("collectRuntimeSnapshot", () => {
94
102
  });
95
103
  });
96
104
 
105
+ describe("collectRuntimeSnapshotAsync", () => {
106
+ it("adds OpenClaw endpoint status and diagnostics", async () => {
107
+ setRuntimes([
108
+ {
109
+ id: "openclaw-acp",
110
+ displayName: "OpenClaw",
111
+ binary: "openclaw",
112
+ supportsRun: true,
113
+ result: { available: true, version: "0.1.0" },
114
+ },
115
+ ]);
116
+
117
+ const snap = await collectRuntimeSnapshotAsync({
118
+ cfg: {
119
+ openclawGateways: [
120
+ { name: "ok", url: "ws://127.0.0.1:18789" },
121
+ { name: "bad", url: "ws://127.0.0.1:16200" },
122
+ ],
123
+ },
124
+ wsProbe: async ({ url }) =>
125
+ url.includes("18789")
126
+ ? { ok: true, version: "gw-1", agents: [{ id: "main" }] }
127
+ : { ok: false, error: "connect rejected" },
128
+ });
129
+
130
+ const runtime = snap.runtimes.find((r) => r.id === "openclaw-acp");
131
+ expect(runtime?.endpoints).toEqual([
132
+ expect.objectContaining({
133
+ name: "ok",
134
+ reachable: true,
135
+ status: "reachable",
136
+ version: "gw-1",
137
+ agents: [{ id: "main" }],
138
+ }),
139
+ expect.objectContaining({
140
+ name: "bad",
141
+ reachable: false,
142
+ status: "unreachable",
143
+ error: "connect rejected",
144
+ diagnostics: [{ code: "gateway_unreachable", message: "connect rejected" }],
145
+ }),
146
+ ]);
147
+ });
148
+
149
+ it("reports acp_disabled without probing the gateway", async () => {
150
+ const tmp = mkdtempSync(path.join(tmpdir(), "daemon-runtime-openclaw-"));
151
+ const prevHome = process.env.HOME;
152
+ process.env.HOME = tmp;
153
+ try {
154
+ mkdirSync(path.join(tmp, ".openclaw"), { recursive: true });
155
+ writeFileSync(
156
+ path.join(tmp, ".openclaw", "openclaw.json"),
157
+ JSON.stringify({ acp: { enabled: false } }),
158
+ );
159
+ setRuntimes([
160
+ {
161
+ id: "openclaw-acp",
162
+ displayName: "OpenClaw",
163
+ binary: "openclaw",
164
+ supportsRun: true,
165
+ result: { available: true },
166
+ },
167
+ ]);
168
+ const wsProbe = vi.fn(async () => ({ ok: true }));
169
+
170
+ const snap = await collectRuntimeSnapshotAsync({
171
+ cfg: { openclawGateways: [{ name: "local", url: "ws://127.0.0.1:18789" }] },
172
+ wsProbe,
173
+ });
174
+
175
+ expect(wsProbe).not.toHaveBeenCalled();
176
+ const runtime = snap.runtimes.find((r) => r.id === "openclaw-acp");
177
+ expect(runtime?.endpoints).toEqual([
178
+ expect.objectContaining({
179
+ name: "local",
180
+ reachable: false,
181
+ status: "acp_disabled",
182
+ error: "OpenClaw ACP runtime disabled",
183
+ diagnostics: [
184
+ {
185
+ code: "acp_disabled",
186
+ message: "OpenClaw config explicitly disables the ACP runtime",
187
+ },
188
+ ],
189
+ }),
190
+ ]);
191
+ } finally {
192
+ if (prevHome === undefined) delete process.env.HOME;
193
+ else process.env.HOME = prevHome;
194
+ rmSync(tmp, { recursive: true, force: true });
195
+ }
196
+ });
197
+ });
198
+
97
199
  interface FakeGateway {
98
200
  addChannel: ReturnType<typeof vi.fn>;
99
201
  removeChannel: ReturnType<typeof vi.fn>;
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveStartAuthAction } from "../start-auth.js";
3
+ import type { UserAuthRecord } from "../user-auth.js";
4
+
5
+ const existingAuth: UserAuthRecord = {
6
+ version: 1,
7
+ userId: "usr_1",
8
+ daemonInstanceId: "dm_1",
9
+ hubUrl: "https://hub.example",
10
+ accessToken: "at",
11
+ refreshToken: "rt",
12
+ expiresAt: Date.now() + 60_000,
13
+ loggedInAt: new Date().toISOString(),
14
+ };
15
+
16
+ describe("resolveStartAuthAction", () => {
17
+ it("reuses existing auth even when a one-time install token is present", () => {
18
+ expect(
19
+ resolveStartAuthAction({
20
+ existing: existingAuth,
21
+ relogin: false,
22
+ installToken: "dit_expired",
23
+ }),
24
+ ).toBe("reuse-existing");
25
+ });
26
+
27
+ it("redeems an install token when no existing auth is available", () => {
28
+ expect(
29
+ resolveStartAuthAction({
30
+ existing: null,
31
+ relogin: false,
32
+ installToken: "dit_new",
33
+ }),
34
+ ).toBe("install-token");
35
+ });
36
+
37
+ it("allows --relogin to re-bind with an install token", () => {
38
+ expect(
39
+ resolveStartAuthAction({
40
+ existing: existingAuth,
41
+ relogin: true,
42
+ installToken: "dit_new",
43
+ }),
44
+ ).toBe("install-token");
45
+ });
46
+ });
@@ -66,6 +66,12 @@ export interface DiscoveryFs {
66
66
  export interface DiscoveryOptions extends DiscoveryFs {
67
67
  /** Directory to scan. Defaults to {@link DEFAULT_CREDENTIALS_DIR}. */
68
68
  credentialsDir?: string;
69
+ /**
70
+ * Optional daemon target Hub. When set, auto-discovered credentials whose
71
+ * hubUrl points at a different host are skipped so preview/prod identities
72
+ * are not mixed by accident.
73
+ */
74
+ expectedHubUrl?: string;
69
75
  }
70
76
 
71
77
  /**
@@ -137,6 +143,12 @@ export function discoverAgentCredentials(
137
143
  warnings.push(`credentials at ${file} missing agentId; skipped`);
138
144
  continue;
139
145
  }
146
+ if (opts.expectedHubUrl && !sameHubHost(creds.hubUrl, opts.expectedHubUrl)) {
147
+ warnings.push(
148
+ `credential skipped: hubUrl does not match daemon environment (${file})`,
149
+ );
150
+ continue;
151
+ }
140
152
 
141
153
  const existing = byAgent.get(creds.agentId);
142
154
  if (!existing) {
@@ -188,6 +200,15 @@ function errMsg(err: unknown): string {
188
200
  return err instanceof Error ? err.message : String(err);
189
201
  }
190
202
 
203
+ function sameHubHost(a: string | undefined, b: string | undefined): boolean {
204
+ if (!a || !b) return true;
205
+ try {
206
+ return new URL(a).host === new URL(b).host;
207
+ } catch {
208
+ return true;
209
+ }
210
+ }
211
+
191
212
  /** Result of composing explicit config + discovery into the final boot list. */
192
213
  export interface BootAgentsResult {
193
214
  /** Ordered list of agents the daemon should bind channels for. */
package/src/daemon.ts CHANGED
@@ -217,12 +217,17 @@ function buildDaemonLogger(): GatewayLogger {
217
217
  */
218
218
  export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHandle> {
219
219
  const logger = opts.log ?? buildDaemonLogger();
220
+ const userAuth =
221
+ opts.userAuth === undefined
222
+ ? tryLoadUserAuth(logger)
223
+ : opts.userAuth;
224
+ const expectedHubUrl = opts.hubBaseUrl ?? userAuth?.current?.hubUrl;
220
225
 
221
226
  // Resolve boot agents: explicit `agents` config wins; otherwise scan the
222
227
  // credentials directory. A zero-agent result is valid in P1 — the daemon
223
228
  // still starts with zero channels so operators can drop credentials in
224
229
  // and restart without re-running `init`.
225
- const boot = opts.bootAgents ?? resolveBootAgents(opts.config);
230
+ const boot = opts.bootAgents ?? resolveBootAgents(opts.config, { expectedHubUrl });
226
231
  for (const w of boot.warnings) {
227
232
  logger.warn("daemon.discovery.warning", { message: w });
228
233
  }
@@ -455,10 +460,6 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
455
460
  // when user-auth hasn't been set up yet. Operators can `login` later
456
461
  // without restarting, but for P0 we require a restart to pick it up.
457
462
  let controlChannel: ControlChannel | null = null;
458
- const userAuth =
459
- opts.userAuth === undefined
460
- ? tryLoadUserAuth(logger)
461
- : opts.userAuth;
462
463
  if (userAuth?.current && !opts.disableControlChannel) {
463
464
  logger.info("control-channel: enabling", {
464
465
  userId: userAuth.current.userId,
@@ -182,6 +182,58 @@ describe("createBotCordChannel — inbox normalization", () => {
182
182
  return { emits, client, server };
183
183
  }
184
184
 
185
+ it("logs why an empty inbox drain ran", async () => {
186
+ const server = await startAuthOkServer();
187
+ const client = makeClient({
188
+ pollInbox: vi.fn().mockResolvedValue({ messages: [], count: 0, has_more: false }),
189
+ getHubUrl: vi.fn().mockReturnValue(server.url),
190
+ });
191
+ const channel = createBotCordChannel({
192
+ id: "botcord-main",
193
+ accountId: "ag_self",
194
+ agentId: "ag_self",
195
+ client,
196
+ hubBaseUrl: server.url,
197
+ });
198
+ const abort = new AbortController();
199
+ const log: GatewayLogger = {
200
+ ...silentLog,
201
+ info: vi.fn(),
202
+ };
203
+ const startPromise = channel.start({
204
+ config: stubConfig,
205
+ accountId: "ag_self",
206
+ abortSignal: abort.signal,
207
+ log,
208
+ emit: async () => {},
209
+ setStatus: () => {},
210
+ });
211
+ try {
212
+ await vi.waitFor(() => {
213
+ expect(log.info).toHaveBeenCalledWith(
214
+ "botcord inbox drained",
215
+ expect.objectContaining({
216
+ trigger: "ws_auth_ok",
217
+ count: 0,
218
+ responseCount: 0,
219
+ hasMore: false,
220
+ limit: 50,
221
+ ack: false,
222
+ eligibleCount: 0,
223
+ duplicateCount: 0,
224
+ skippedCount: 0,
225
+ emittedGroups: 0,
226
+ durationMs: expect.any(Number),
227
+ }),
228
+ );
229
+ });
230
+ } finally {
231
+ abort.abort();
232
+ await startPromise;
233
+ await server.close();
234
+ }
235
+ });
236
+
185
237
  it("maps a group-room InboxMessage to a GatewayInboundMessage", async () => {
186
238
  const { emits, server } = await startWithInbox([
187
239
  makeInbox({
@@ -28,6 +28,9 @@ const MAX_AUTH_FAILURES = 5;
28
28
  const SEEN_MESSAGES_CAP = 500;
29
29
  const OWNER_CHAT_PREFIX = "rm_oc_";
30
30
  const DM_ROOM_PREFIX = "rm_dm_";
31
+ const INBOX_POLL_LIMIT = 50;
32
+
33
+ type InboxDrainTrigger = "ws_auth_ok" | "ws_inbox_update" | "coalesced_inbox_update";
31
34
 
32
35
  /** Minimal surface the adapter needs from `BotCordClient`. Matches the subset used at runtime. */
33
36
  export interface BotCordChannelClient {
@@ -305,20 +308,43 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
305
308
  client: BotCordChannelClient,
306
309
  emit: (env: GatewayInboundEnvelope) => Promise<void>,
307
310
  log: GatewayLogger,
311
+ trigger: InboxDrainTrigger,
308
312
  ): Promise<void> {
309
- const resp = await client.pollInbox({ limit: 50, ack: false });
313
+ const startedAt = Date.now();
314
+ const resp = await client.pollInbox({ limit: INBOX_POLL_LIMIT, ack: false });
310
315
  const msgs = resp.messages ?? [];
311
- log.info("botcord inbox drained", { count: msgs.length });
312
- if (msgs.length === 0) return;
316
+ let duplicateCount = 0;
317
+ let skippedCount = 0;
318
+ let emittedGroups = 0;
319
+ const logDrain = () => {
320
+ log.info("botcord inbox drained", {
321
+ trigger,
322
+ count: msgs.length,
323
+ responseCount: resp.count,
324
+ hasMore: resp.has_more,
325
+ limit: INBOX_POLL_LIMIT,
326
+ ack: false,
327
+ eligibleCount: eligible.length,
328
+ duplicateCount,
329
+ skippedCount,
330
+ emittedGroups,
331
+ durationMs: Date.now() - startedAt,
332
+ });
333
+ };
334
+ const eligible: InboxMessage[] = [];
335
+ if (msgs.length === 0) {
336
+ logDrain();
337
+ return;
338
+ }
313
339
 
314
340
  // First pass: ack duplicates/skipped messages so Hub stops requeueing,
315
341
  // and collect eligible messages preserving poll order. Grouping by
316
342
  // `(room_id, topic)` mirrors plugin's `handleInboxMessageBatch` — the
317
343
  // same conversation thread folds into one turn so the agent sees all
318
344
  // new messages at once instead of running N turns back-to-back.
319
- const eligible: InboxMessage[] = [];
320
345
  for (const msg of msgs) {
321
346
  if (!rememberSeen(msg.hub_msg_id)) {
347
+ duplicateCount += 1;
322
348
  try {
323
349
  await client.ackMessages([msg.hub_msg_id]);
324
350
  } catch (err) {
@@ -331,6 +357,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
331
357
  accountId: options.accountId,
332
358
  });
333
359
  if (!normalized) {
360
+ skippedCount += 1;
334
361
  try {
335
362
  await client.ackMessages([msg.hub_msg_id]);
336
363
  } catch (err) {
@@ -341,7 +368,10 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
341
368
  eligible.push(msg);
342
369
  }
343
370
 
344
- if (eligible.length === 0) return;
371
+ if (eligible.length === 0) {
372
+ logDrain();
373
+ return;
374
+ }
345
375
 
346
376
  // Group by `(room_id, topic)`. Insertion order is the poll order, so
347
377
  // iterating the map yields groups with the same external chronology.
@@ -381,6 +411,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
381
411
  };
382
412
  try {
383
413
  await emit(envelope);
414
+ emittedGroups += 1;
384
415
  } catch (err) {
385
416
  log.error("botcord emit threw", {
386
417
  hubMsgIds: hubIds,
@@ -388,6 +419,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
388
419
  });
389
420
  }
390
421
  }
422
+ logDrain();
391
423
  }
392
424
 
393
425
  function startWsLoop(
@@ -429,16 +461,19 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
429
461
  setStatus(patch);
430
462
  }
431
463
 
432
- async function fireInbox() {
464
+ async function fireInbox(trigger: InboxDrainTrigger) {
433
465
  if (processing) {
434
466
  pendingUpdate = true;
467
+ log.debug("botcord inbox drain queued while previous drain is running", { trigger });
435
468
  return;
436
469
  }
437
470
  processing = true;
438
471
  try {
472
+ let currentTrigger = trigger;
439
473
  do {
440
474
  pendingUpdate = false;
441
- await drainInbox(client, emit, log);
475
+ await drainInbox(client, emit, log, currentTrigger);
476
+ currentTrigger = "coalesced_inbox_update";
442
477
  } while (pendingUpdate && running);
443
478
  } catch (err) {
444
479
  log.error("botcord inbox drain failed", { err: String(err) });
@@ -521,7 +556,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
521
556
  lastError: null,
522
557
  });
523
558
  log.info("botcord ws authenticated", { agentId: msg.agent_id });
524
- void fireInbox();
559
+ void fireInbox("ws_auth_ok");
525
560
  keepaliveTimer = setInterval(() => {
526
561
  if (ws && ws.readyState === WebSocket.OPEN) {
527
562
  try {
@@ -533,7 +568,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
533
568
  }, KEEPALIVE_INTERVAL);
534
569
  } else if (msg.type === "inbox_update") {
535
570
  log.info("botcord ws inbox_update received");
536
- void fireInbox();
571
+ void fireInbox("ws_inbox_update");
537
572
  } else if (msg.type === "heartbeat" || msg.type === "pong") {
538
573
  // no-op
539
574
  } else if (msg.type === "error" || msg.type === "auth_failed") {
package/src/index.ts CHANGED
@@ -57,6 +57,7 @@ import {
57
57
  updateWorkingMemory,
58
58
  DEFAULT_SECTION,
59
59
  } from "./working-memory.js";
60
+ import { resolveStartAuthAction } from "./start-auth.js";
60
61
  import {
61
62
  discoverLocalOpenclawGateways,
62
63
  mergeOpenclawGateways,
@@ -417,9 +418,10 @@ async function runDeviceCodeFlow(opts: {
417
418
  * plane (legacy P0 behavior — caller may still log a warning).
418
419
  *
419
420
  * Decision tree (plan §4.4 + §6.4):
420
- * 1. Have existing creds, no `--relogin`, no `--install-token` → return existing record.
421
- * 2. `--install-token` (overrides existing creds they may be stale or
422
- * belong to a different account) redeem the one-time dashboard ticket.
421
+ * 1. Have existing creds and no `--relogin` → return existing record, even
422
+ * when a dashboard `--install-token` is present. The token is one-time and
423
+ * the generated install command should be safe to re-run after first login.
424
+ * 2. No existing creds + `--install-token` → redeem the one-time dashboard ticket.
423
425
  * 3. `--relogin` → device-code login.
424
426
  * 4. No creds + TTY → device-code login.
425
427
  * 5. No creds + no TTY → exit 1 with the §6.4 hint.
@@ -432,8 +434,9 @@ async function ensureUserAuthForStart(args: ParsedArgs): Promise<UserAuthRecord
432
434
  const relogin = args.flags.relogin === true;
433
435
 
434
436
  const existing = safeLoadUserAuth();
437
+ const authAction = resolveStartAuthAction({ existing, relogin, installToken });
435
438
 
436
- if (!relogin && !installToken && existing) {
439
+ if (authAction === "reuse-existing" && existing) {
437
440
  // A previously-set auth-expired flag is stale by definition once the
438
441
  // operator runs `start` again — if creds genuinely don't work, the
439
442
  // control channel will re-write the flag on the next 4401/4403.
@@ -449,6 +452,9 @@ async function ensureUserAuthForStart(args: ParsedArgs): Promise<UserAuthRecord
449
452
  `note: --label "${labelFlag}" ignored (already logged in as "${existing.label ?? "<unset>"}"); pass --relogin to change it`,
450
453
  );
451
454
  }
455
+ if (installToken) {
456
+ console.error("note: --install-token ignored because daemon is already logged in; pass --relogin to re-bind");
457
+ }
452
458
  return existing;
453
459
  }
454
460
 
@@ -456,7 +462,7 @@ async function ensureUserAuthForStart(args: ParsedArgs): Promise<UserAuthRecord
456
462
  const hubUrl = hubFlag ?? existing?.hubUrl ?? DEFAULT_HUB;
457
463
  const label = labelFlag ?? defaultLoginLabel();
458
464
 
459
- if (installToken) {
465
+ if (authAction === "install-token" && installToken) {
460
466
  const tok = await redeemInstallToken({ hubUrl, installToken, label });
461
467
  const record = userAuthFromTokenResponse(tok, { label });
462
468
  saveUserAuth(record);
@@ -30,7 +30,7 @@ export interface MergeOpenclawGatewayResult {
30
30
  }
31
31
 
32
32
  const DEFAULT_SEARCH_PATHS = ["~/.openclaw/", "/etc/openclaw/"];
33
- const DEFAULT_PORTS = [18789];
33
+ const DEFAULT_PORTS = [18789, 16200];
34
34
 
35
35
  export async function discoverLocalOpenclawGateways(
36
36
  opts: OpenclawGatewayDiscoveryOptions = {},
@@ -41,17 +41,8 @@ export async function discoverLocalOpenclawGateways(
41
41
  }
42
42
 
43
43
  const env = opts.env ?? process.env;
44
- const envUrl = env.OPENCLAW_ACP_URL;
45
- if (envUrl) {
46
- const item: DiscoveredOpenclawGateway = {
47
- name: nameFromUrl(envUrl),
48
- url: envUrl,
49
- source: "env",
50
- };
51
- if (env.OPENCLAW_ACP_TOKEN) item.token = env.OPENCLAW_ACP_TOKEN;
52
- else if (env.OPENCLAW_ACP_TOKEN_FILE) item.tokenFile = env.OPENCLAW_ACP_TOKEN_FILE;
53
- found.push(item);
54
- }
44
+ found.push(...discoverFromEnv(env));
45
+ const envAuth = pickOpenclawEnvAuth(env);
55
46
 
56
47
  const ports = opts.defaultPorts ?? DEFAULT_PORTS;
57
48
  if (ports.length > 0) {
@@ -60,11 +51,11 @@ export async function discoverLocalOpenclawGateways(
60
51
  const url = `ws://127.0.0.1:${port}`;
61
52
  try {
62
53
  const res = await probeOpenclawAgents(
63
- { url },
54
+ { url, ...envAuth },
64
55
  { probe: opts.probe, timeoutMs: opts.timeoutMs },
65
56
  );
66
57
  if (res.ok) {
67
- found.push({ name: nameFromUrl(url), url, source: "default-port" });
58
+ found.push({ name: nameFromUrl(url), url, source: "default-port", ...envAuth });
68
59
  }
69
60
  } catch (err) {
70
61
  daemonLog.debug("openclaw discovery default-port probe failed", {
@@ -79,6 +70,46 @@ export async function discoverLocalOpenclawGateways(
79
70
  return dedupeDiscovered(found);
80
71
  }
81
72
 
73
+ function discoverFromEnv(env: NodeJS.ProcessEnv): DiscoveredOpenclawGateway[] {
74
+ const url =
75
+ pickEnv(env, "OPENCLAW_ACP_URL") ??
76
+ pickEnv(env, "OPENCLAW_GATEWAY_URL") ??
77
+ urlFromGatewayPort(env);
78
+ if (!url) return [];
79
+
80
+ return [
81
+ {
82
+ name: nameFromUrl(url),
83
+ url,
84
+ source: "env",
85
+ ...pickOpenclawEnvAuth(env),
86
+ },
87
+ ];
88
+ }
89
+
90
+ function pickOpenclawEnvAuth(env: NodeJS.ProcessEnv): { token?: string; tokenFile?: string } {
91
+ const token = pickEnv(env, "OPENCLAW_ACP_TOKEN") ?? pickEnv(env, "OPENCLAW_GATEWAY_TOKEN");
92
+ if (token) return { token };
93
+ const tokenFile =
94
+ pickEnv(env, "OPENCLAW_ACP_TOKEN_FILE") ?? pickEnv(env, "OPENCLAW_GATEWAY_TOKEN_FILE");
95
+ if (tokenFile) return { tokenFile };
96
+ return {};
97
+ }
98
+
99
+ function urlFromGatewayPort(env: NodeJS.ProcessEnv): string | undefined {
100
+ const raw = pickEnv(env, "OPENCLAW_GATEWAY_PORT");
101
+ if (!raw) return undefined;
102
+ const port = Number(raw);
103
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) return undefined;
104
+ return `ws://127.0.0.1:${port}`;
105
+ }
106
+
107
+ function pickEnv(env: NodeJS.ProcessEnv, key: string): string | undefined {
108
+ const value = env[key];
109
+ if (typeof value === "string" && value.trim()) return value.trim();
110
+ return undefined;
111
+ }
112
+
82
113
  export function mergeOpenclawGateways(
83
114
  cfg: DaemonConfig,
84
115
  found: DiscoveredOpenclawGateway[],
package/src/provision.ts CHANGED
@@ -667,6 +667,17 @@ export async function adoptDiscoveredOpenclawAgents(ctx: {
667
667
  failed: [],
668
668
  };
669
669
  for (const gw of cfg.openclawGateways ?? []) {
670
+ if (localOpenclawAcpDisabled(gw.url)) {
671
+ result.skipped.push({
672
+ gateway: gw.name,
673
+ reason: "acp_disabled",
674
+ });
675
+ daemonLog.warn("openclaw discovery: gateway found but ACP runtime disabled", {
676
+ gateway: gw.name,
677
+ url: gw.url,
678
+ });
679
+ continue;
680
+ }
670
681
  let probeResult: Awaited<ReturnType<typeof probeOpenclawAgents>>;
671
682
  try {
672
683
  probeResult = await probeOpenclawAgents(gw, {
@@ -743,6 +754,18 @@ export async function adoptDiscoveredOpenclawAgents(ctx: {
743
754
  return result;
744
755
  }
745
756
 
757
+ function localOpenclawAcpDisabled(rawUrl: string): boolean {
758
+ if (!isLoopbackUrl(rawUrl)) return false;
759
+ try {
760
+ const file = path.join(homedir(), ".openclaw", "openclaw.json");
761
+ if (!existsSync(file)) return false;
762
+ const cfg = JSON.parse(readFileSync(file, "utf8")) as any;
763
+ return cfg?.acp?.enabled === false;
764
+ } catch {
765
+ return false;
766
+ }
767
+ }
768
+
746
769
  async function revokeAgent(
747
770
  params: RevokeAgentParams,
748
771
  ctx: { gateway: Gateway },
@@ -1301,22 +1324,48 @@ export async function collectRuntimeSnapshotAsync(opts: {
1301
1324
  const capped = gateways.slice(0, RUNTIME_ENDPOINTS_CAP);
1302
1325
  const endpoints = await Promise.all(
1303
1326
  capped.map(async (g) => {
1327
+ if (localOpenclawAcpDisabled(g.url)) {
1328
+ return {
1329
+ name: g.name,
1330
+ url: g.url,
1331
+ reachable: false,
1332
+ status: "acp_disabled",
1333
+ error: "OpenClaw ACP runtime disabled",
1334
+ diagnostics: [
1335
+ {
1336
+ code: "acp_disabled",
1337
+ message: "OpenClaw config explicitly disables the ACP runtime",
1338
+ },
1339
+ ],
1340
+ };
1341
+ }
1304
1342
  try {
1305
1343
  const res = await probeOpenclawAgents(g, {
1306
1344
  probe: opts.wsProbe,
1307
1345
  timeoutMs,
1308
1346
  });
1309
- const entry: any = { name: g.name, url: g.url, reachable: res.ok };
1347
+ const entry: any = {
1348
+ name: g.name,
1349
+ url: g.url,
1350
+ reachable: res.ok,
1351
+ status: res.ok ? "reachable" : "unreachable",
1352
+ };
1310
1353
  if (res.version) entry.version = res.version;
1311
- if (res.error) entry.error = res.error;
1354
+ if (res.error) {
1355
+ entry.error = res.error;
1356
+ entry.diagnostics = [{ code: "gateway_unreachable", message: res.error }];
1357
+ }
1312
1358
  if (res.agents) entry.agents = res.agents;
1313
1359
  return entry;
1314
1360
  } catch (err) {
1361
+ const message = err instanceof Error ? err.message : String(err);
1315
1362
  return {
1316
1363
  name: g.name,
1317
1364
  url: g.url,
1318
1365
  reachable: false,
1319
- error: (err as Error).message,
1366
+ status: "unreachable",
1367
+ error: message,
1368
+ diagnostics: [{ code: "probe_failed", message }],
1320
1369
  };
1321
1370
  }
1322
1371
  }),
@@ -0,0 +1,13 @@
1
+ import type { UserAuthRecord } from "./user-auth.js";
2
+
3
+ export type StartAuthAction = "reuse-existing" | "install-token" | "device-code";
4
+
5
+ export function resolveStartAuthAction(opts: {
6
+ existing: UserAuthRecord | null;
7
+ relogin: boolean;
8
+ installToken?: string;
9
+ }): StartAuthAction {
10
+ if (opts.existing && !opts.relogin) return "reuse-existing";
11
+ if (opts.installToken) return "install-token";
12
+ return "device-code";
13
+ }