@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.
- package/dist/agent-discovery.d.ts +6 -0
- package/dist/agent-discovery.js +14 -0
- package/dist/daemon.js +5 -4
- package/dist/openclaw-discovery.js +44 -16
- package/dist/provision.js +25 -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/agent-discovery.ts +21 -0
- package/src/daemon.ts +6 -5
- package/src/openclaw-discovery.ts +45 -14
- package/src/provision.ts +23 -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,
|
|
@@ -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");
|
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");
|
package/src/agent-discovery.ts
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
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 },
|