@botcord/daemon 0.2.16 → 0.2.17

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,
@@ -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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.16",
3
+ "version": "0.2.17",
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");
@@ -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,
@@ -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 },