@botcord/daemon 0.2.17 → 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/gateway/channels/botcord.js +40 -10
- package/dist/index.js +11 -5
- package/dist/provision.js +28 -3
- package/dist/start-auth.d.ts +7 -0
- package/dist/start-auth.js +7 -0
- package/package.json +1 -1
- package/src/__tests__/runtime-discovery.test.ts +103 -1
- package/src/__tests__/start-auth.test.ts +46 -0
- 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/provision.ts +29 -3
- package/src/start-auth.ts +13 -0
|
@@ -7,6 +7,7 @@ const MAX_AUTH_FAILURES = 5;
|
|
|
7
7
|
const SEEN_MESSAGES_CAP = 500;
|
|
8
8
|
const OWNER_CHAT_PREFIX = "rm_oc_";
|
|
9
9
|
const DM_ROOM_PREFIX = "rm_dm_";
|
|
10
|
+
const INBOX_POLL_LIMIT = 50;
|
|
10
11
|
/** Default factory: wrap `loadStoredCredentials` + `new BotCordClient`. */
|
|
11
12
|
function defaultClientFactory(input) {
|
|
12
13
|
const credFile = input.credentialsPath ?? defaultCredentialsFile(input.agentId);
|
|
@@ -199,20 +200,41 @@ export function createBotCordChannel(options) {
|
|
|
199
200
|
}
|
|
200
201
|
return clientRef;
|
|
201
202
|
}
|
|
202
|
-
async function drainInbox(client, emit, log) {
|
|
203
|
-
const
|
|
203
|
+
async function drainInbox(client, emit, log, trigger) {
|
|
204
|
+
const startedAt = Date.now();
|
|
205
|
+
const resp = await client.pollInbox({ limit: INBOX_POLL_LIMIT, ack: false });
|
|
204
206
|
const msgs = resp.messages ?? [];
|
|
205
|
-
|
|
206
|
-
|
|
207
|
+
let duplicateCount = 0;
|
|
208
|
+
let skippedCount = 0;
|
|
209
|
+
let emittedGroups = 0;
|
|
210
|
+
const logDrain = () => {
|
|
211
|
+
log.info("botcord inbox drained", {
|
|
212
|
+
trigger,
|
|
213
|
+
count: msgs.length,
|
|
214
|
+
responseCount: resp.count,
|
|
215
|
+
hasMore: resp.has_more,
|
|
216
|
+
limit: INBOX_POLL_LIMIT,
|
|
217
|
+
ack: false,
|
|
218
|
+
eligibleCount: eligible.length,
|
|
219
|
+
duplicateCount,
|
|
220
|
+
skippedCount,
|
|
221
|
+
emittedGroups,
|
|
222
|
+
durationMs: Date.now() - startedAt,
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
const eligible = [];
|
|
226
|
+
if (msgs.length === 0) {
|
|
227
|
+
logDrain();
|
|
207
228
|
return;
|
|
229
|
+
}
|
|
208
230
|
// First pass: ack duplicates/skipped messages so Hub stops requeueing,
|
|
209
231
|
// and collect eligible messages preserving poll order. Grouping by
|
|
210
232
|
// `(room_id, topic)` mirrors plugin's `handleInboxMessageBatch` — the
|
|
211
233
|
// same conversation thread folds into one turn so the agent sees all
|
|
212
234
|
// new messages at once instead of running N turns back-to-back.
|
|
213
|
-
const eligible = [];
|
|
214
235
|
for (const msg of msgs) {
|
|
215
236
|
if (!rememberSeen(msg.hub_msg_id)) {
|
|
237
|
+
duplicateCount += 1;
|
|
216
238
|
try {
|
|
217
239
|
await client.ackMessages([msg.hub_msg_id]);
|
|
218
240
|
}
|
|
@@ -226,6 +248,7 @@ export function createBotCordChannel(options) {
|
|
|
226
248
|
accountId: options.accountId,
|
|
227
249
|
});
|
|
228
250
|
if (!normalized) {
|
|
251
|
+
skippedCount += 1;
|
|
229
252
|
try {
|
|
230
253
|
await client.ackMessages([msg.hub_msg_id]);
|
|
231
254
|
}
|
|
@@ -236,8 +259,10 @@ export function createBotCordChannel(options) {
|
|
|
236
259
|
}
|
|
237
260
|
eligible.push(msg);
|
|
238
261
|
}
|
|
239
|
-
if (eligible.length === 0)
|
|
262
|
+
if (eligible.length === 0) {
|
|
263
|
+
logDrain();
|
|
240
264
|
return;
|
|
265
|
+
}
|
|
241
266
|
// Group by `(room_id, topic)`. Insertion order is the poll order, so
|
|
242
267
|
// iterating the map yields groups with the same external chronology.
|
|
243
268
|
const groups = new Map();
|
|
@@ -278,6 +303,7 @@ export function createBotCordChannel(options) {
|
|
|
278
303
|
};
|
|
279
304
|
try {
|
|
280
305
|
await emit(envelope);
|
|
306
|
+
emittedGroups += 1;
|
|
281
307
|
}
|
|
282
308
|
catch (err) {
|
|
283
309
|
log.error("botcord emit threw", {
|
|
@@ -286,6 +312,7 @@ export function createBotCordChannel(options) {
|
|
|
286
312
|
});
|
|
287
313
|
}
|
|
288
314
|
}
|
|
315
|
+
logDrain();
|
|
289
316
|
}
|
|
290
317
|
function startWsLoop(client, ctx) {
|
|
291
318
|
const { abortSignal, log, emit, setStatus } = ctx;
|
|
@@ -318,16 +345,19 @@ export function createBotCordChannel(options) {
|
|
|
318
345
|
statusSnapshot = { ...statusSnapshot, ...patch };
|
|
319
346
|
setStatus(patch);
|
|
320
347
|
}
|
|
321
|
-
async function fireInbox() {
|
|
348
|
+
async function fireInbox(trigger) {
|
|
322
349
|
if (processing) {
|
|
323
350
|
pendingUpdate = true;
|
|
351
|
+
log.debug("botcord inbox drain queued while previous drain is running", { trigger });
|
|
324
352
|
return;
|
|
325
353
|
}
|
|
326
354
|
processing = true;
|
|
327
355
|
try {
|
|
356
|
+
let currentTrigger = trigger;
|
|
328
357
|
do {
|
|
329
358
|
pendingUpdate = false;
|
|
330
|
-
await drainInbox(client, emit, log);
|
|
359
|
+
await drainInbox(client, emit, log, currentTrigger);
|
|
360
|
+
currentTrigger = "coalesced_inbox_update";
|
|
331
361
|
} while (pendingUpdate && running);
|
|
332
362
|
}
|
|
333
363
|
catch (err) {
|
|
@@ -413,7 +443,7 @@ export function createBotCordChannel(options) {
|
|
|
413
443
|
lastError: null,
|
|
414
444
|
});
|
|
415
445
|
log.info("botcord ws authenticated", { agentId: msg.agent_id });
|
|
416
|
-
void fireInbox();
|
|
446
|
+
void fireInbox("ws_auth_ok");
|
|
417
447
|
keepaliveTimer = setInterval(() => {
|
|
418
448
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
419
449
|
try {
|
|
@@ -427,7 +457,7 @@ export function createBotCordChannel(options) {
|
|
|
427
457
|
}
|
|
428
458
|
else if (msg.type === "inbox_update") {
|
|
429
459
|
log.info("botcord ws inbox_update received");
|
|
430
|
-
void fireInbox();
|
|
460
|
+
void fireInbox("ws_inbox_update");
|
|
431
461
|
}
|
|
432
462
|
else if (msg.type === "heartbeat" || msg.type === "pong") {
|
|
433
463
|
// no-op
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { renderStatus } from "./status-render.js";
|
|
|
15
15
|
import { appendNextParam } from "./url-utils.js";
|
|
16
16
|
import { channelsFromDaemonConfig, defaultHttpFetcher, renderDoctor, runDoctor, } from "./doctor.js";
|
|
17
17
|
import { clearWorkingMemory, readWorkingMemory, resolveMemoryDir, updateWorkingMemory, DEFAULT_SECTION, } from "./working-memory.js";
|
|
18
|
+
import { resolveStartAuthAction } from "./start-auth.js";
|
|
18
19
|
import { discoverLocalOpenclawGateways, mergeOpenclawGateways, openclawDiscoveryConfigEnabled, } from "./openclaw-discovery.js";
|
|
19
20
|
const ADAPTER_LIST = listAdapterIds().join("|");
|
|
20
21
|
const DEFAULT_HUB = "https://api.botcord.chat";
|
|
@@ -329,9 +330,10 @@ async function runDeviceCodeFlow(opts) {
|
|
|
329
330
|
* plane (legacy P0 behavior — caller may still log a warning).
|
|
330
331
|
*
|
|
331
332
|
* Decision tree (plan §4.4 + §6.4):
|
|
332
|
-
* 1. Have existing creds
|
|
333
|
-
*
|
|
334
|
-
*
|
|
333
|
+
* 1. Have existing creds and no `--relogin` → return existing record, even
|
|
334
|
+
* when a dashboard `--install-token` is present. The token is one-time and
|
|
335
|
+
* the generated install command should be safe to re-run after first login.
|
|
336
|
+
* 2. No existing creds + `--install-token` → redeem the one-time dashboard ticket.
|
|
335
337
|
* 3. `--relogin` → device-code login.
|
|
336
338
|
* 4. No creds + TTY → device-code login.
|
|
337
339
|
* 5. No creds + no TTY → exit 1 with the §6.4 hint.
|
|
@@ -342,7 +344,8 @@ async function ensureUserAuthForStart(args) {
|
|
|
342
344
|
const installToken = typeof args.flags["install-token"] === "string" ? args.flags["install-token"] : undefined;
|
|
343
345
|
const relogin = args.flags.relogin === true;
|
|
344
346
|
const existing = safeLoadUserAuth();
|
|
345
|
-
|
|
347
|
+
const authAction = resolveStartAuthAction({ existing, relogin, installToken });
|
|
348
|
+
if (authAction === "reuse-existing" && existing) {
|
|
346
349
|
// A previously-set auth-expired flag is stale by definition once the
|
|
347
350
|
// operator runs `start` again — if creds genuinely don't work, the
|
|
348
351
|
// control channel will re-write the flag on the next 4401/4403.
|
|
@@ -356,12 +359,15 @@ async function ensureUserAuthForStart(args) {
|
|
|
356
359
|
if (labelFlag && existing.label !== labelFlag) {
|
|
357
360
|
console.error(`note: --label "${labelFlag}" ignored (already logged in as "${existing.label ?? "<unset>"}"); pass --relogin to change it`);
|
|
358
361
|
}
|
|
362
|
+
if (installToken) {
|
|
363
|
+
console.error("note: --install-token ignored because daemon is already logged in; pass --relogin to re-bind");
|
|
364
|
+
}
|
|
359
365
|
return existing;
|
|
360
366
|
}
|
|
361
367
|
// Need a fresh login. Resolve hubUrl: explicit --hub > existing record > DEFAULT_HUB.
|
|
362
368
|
const hubUrl = hubFlag ?? existing?.hubUrl ?? DEFAULT_HUB;
|
|
363
369
|
const label = labelFlag ?? defaultLoginLabel();
|
|
364
|
-
if (installToken) {
|
|
370
|
+
if (authAction === "install-token" && installToken) {
|
|
365
371
|
const tok = await redeemInstallToken({ hubUrl, installToken, label });
|
|
366
372
|
const record = userAuthFromTokenResponse(tok, { label });
|
|
367
373
|
saveUserAuth(record);
|
package/dist/provision.js
CHANGED
|
@@ -1137,26 +1137,51 @@ export async function collectRuntimeSnapshotAsync(opts = {}) {
|
|
|
1137
1137
|
const timeoutMs = opts.timeoutMs ?? 3000;
|
|
1138
1138
|
const capped = gateways.slice(0, RUNTIME_ENDPOINTS_CAP);
|
|
1139
1139
|
const endpoints = await Promise.all(capped.map(async (g) => {
|
|
1140
|
+
if (localOpenclawAcpDisabled(g.url)) {
|
|
1141
|
+
return {
|
|
1142
|
+
name: g.name,
|
|
1143
|
+
url: g.url,
|
|
1144
|
+
reachable: false,
|
|
1145
|
+
status: "acp_disabled",
|
|
1146
|
+
error: "OpenClaw ACP runtime disabled",
|
|
1147
|
+
diagnostics: [
|
|
1148
|
+
{
|
|
1149
|
+
code: "acp_disabled",
|
|
1150
|
+
message: "OpenClaw config explicitly disables the ACP runtime",
|
|
1151
|
+
},
|
|
1152
|
+
],
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1140
1155
|
try {
|
|
1141
1156
|
const res = await probeOpenclawAgents(g, {
|
|
1142
1157
|
probe: opts.wsProbe,
|
|
1143
1158
|
timeoutMs,
|
|
1144
1159
|
});
|
|
1145
|
-
const entry = {
|
|
1160
|
+
const entry = {
|
|
1161
|
+
name: g.name,
|
|
1162
|
+
url: g.url,
|
|
1163
|
+
reachable: res.ok,
|
|
1164
|
+
status: res.ok ? "reachable" : "unreachable",
|
|
1165
|
+
};
|
|
1146
1166
|
if (res.version)
|
|
1147
1167
|
entry.version = res.version;
|
|
1148
|
-
if (res.error)
|
|
1168
|
+
if (res.error) {
|
|
1149
1169
|
entry.error = res.error;
|
|
1170
|
+
entry.diagnostics = [{ code: "gateway_unreachable", message: res.error }];
|
|
1171
|
+
}
|
|
1150
1172
|
if (res.agents)
|
|
1151
1173
|
entry.agents = res.agents;
|
|
1152
1174
|
return entry;
|
|
1153
1175
|
}
|
|
1154
1176
|
catch (err) {
|
|
1177
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1155
1178
|
return {
|
|
1156
1179
|
name: g.name,
|
|
1157
1180
|
url: g.url,
|
|
1158
1181
|
reachable: false,
|
|
1159
|
-
|
|
1182
|
+
status: "unreachable",
|
|
1183
|
+
error: message,
|
|
1184
|
+
diagnostics: [{ code: "probe_failed", message }],
|
|
1160
1185
|
};
|
|
1161
1186
|
}
|
|
1162
1187
|
}));
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { UserAuthRecord } from "./user-auth.js";
|
|
2
|
+
export type StartAuthAction = "reuse-existing" | "install-token" | "device-code";
|
|
3
|
+
export declare function resolveStartAuthAction(opts: {
|
|
4
|
+
existing: UserAuthRecord | null;
|
|
5
|
+
relogin: boolean;
|
|
6
|
+
installToken?: string;
|
|
7
|
+
}): StartAuthAction;
|
package/package.json
CHANGED
|
@@ -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
|
+
});
|
|
@@ -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);
|
package/src/provision.ts
CHANGED
|
@@ -1324,22 +1324,48 @@ export async function collectRuntimeSnapshotAsync(opts: {
|
|
|
1324
1324
|
const capped = gateways.slice(0, RUNTIME_ENDPOINTS_CAP);
|
|
1325
1325
|
const endpoints = await Promise.all(
|
|
1326
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
|
+
}
|
|
1327
1342
|
try {
|
|
1328
1343
|
const res = await probeOpenclawAgents(g, {
|
|
1329
1344
|
probe: opts.wsProbe,
|
|
1330
1345
|
timeoutMs,
|
|
1331
1346
|
});
|
|
1332
|
-
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
|
+
};
|
|
1333
1353
|
if (res.version) entry.version = res.version;
|
|
1334
|
-
if (res.error)
|
|
1354
|
+
if (res.error) {
|
|
1355
|
+
entry.error = res.error;
|
|
1356
|
+
entry.diagnostics = [{ code: "gateway_unreachable", message: res.error }];
|
|
1357
|
+
}
|
|
1335
1358
|
if (res.agents) entry.agents = res.agents;
|
|
1336
1359
|
return entry;
|
|
1337
1360
|
} catch (err) {
|
|
1361
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1338
1362
|
return {
|
|
1339
1363
|
name: g.name,
|
|
1340
1364
|
url: g.url,
|
|
1341
1365
|
reachable: false,
|
|
1342
|
-
|
|
1366
|
+
status: "unreachable",
|
|
1367
|
+
error: message,
|
|
1368
|
+
diagnostics: [{ code: "probe_failed", message }],
|
|
1343
1369
|
};
|
|
1344
1370
|
}
|
|
1345
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
|
+
}
|