@botcord/daemon 0.2.16 → 0.2.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-discovery.d.ts +6 -0
- package/dist/agent-discovery.js +14 -0
- package/dist/daemon.js +5 -4
- package/dist/gateway/channels/botcord.js +40 -10
- package/dist/index.js +11 -5
- package/dist/openclaw-discovery.js +44 -16
- package/dist/provision.js +53 -3
- package/dist/start-auth.d.ts +7 -0
- package/dist/start-auth.js +7 -0
- package/package.json +1 -1
- package/src/__tests__/agent-discovery.test.ts +38 -0
- package/src/__tests__/openclaw-discovery.test.ts +95 -0
- package/src/__tests__/provision.test.ts +50 -0
- package/src/__tests__/runtime-discovery.test.ts +103 -1
- package/src/__tests__/start-auth.test.ts +46 -0
- package/src/agent-discovery.ts +21 -0
- package/src/daemon.ts +6 -5
- package/src/gateway/__tests__/botcord-channel.test.ts +52 -0
- package/src/gateway/channels/botcord.ts +44 -9
- package/src/index.ts +11 -5
- package/src/openclaw-discovery.ts +45 -14
- package/src/provision.ts +52 -3
- package/src/start-auth.ts +13 -0
|
@@ -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 {
|
|
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
|
+
});
|
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,
|
|
@@ -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
|
|
313
|
+
const startedAt = Date.now();
|
|
314
|
+
const resp = await client.pollInbox({ limit: INBOX_POLL_LIMIT, ack: false });
|
|
310
315
|
const msgs = resp.messages ?? [];
|
|
311
|
-
|
|
312
|
-
|
|
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)
|
|
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
|
|
421
|
-
*
|
|
422
|
-
*
|
|
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 (
|
|
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
|
-
|
|
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 },
|
|
@@ -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 = {
|
|
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)
|
|
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
|
-
|
|
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
|
+
}
|