@botcord/daemon 0.2.33 → 0.2.34
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/channel-manager.js +14 -2
- package/dist/gateway/channels/botcord.d.ts +3 -1
- package/dist/gateway/channels/botcord.js +95 -8
- package/dist/gateway/log.d.ts +1 -1
- package/dist/gateway/log.js +3 -7
- package/dist/log.d.ts +3 -0
- package/dist/log.js +25 -6
- package/dist/provision.d.ts +4 -1
- package/dist/provision.js +1 -1
- package/package.json +1 -1
- package/src/__tests__/log.test.ts +30 -0
- package/src/gateway/__tests__/botcord-channel.test.ts +101 -0
- package/src/gateway/__tests__/channel-manager.test.ts +26 -0
- package/src/gateway/channel-manager.ts +14 -2
- package/src/gateway/channels/botcord.ts +102 -8
- package/src/gateway/log.ts +4 -7
- package/src/log.ts +27 -6
- package/src/provision.ts +1 -1
|
@@ -2,6 +2,7 @@ const DEFAULT_INITIAL_BACKOFF = 1000;
|
|
|
2
2
|
const DEFAULT_MAX_BACKOFF = 60_000;
|
|
3
3
|
const DEFAULT_FACTOR = 2;
|
|
4
4
|
const LONG_RUN_THRESHOLD_MS = 30_000;
|
|
5
|
+
const CHANNEL_PERMANENT_STOP = "channel_permanent_stop";
|
|
5
6
|
/** Supervises channel adapters: lifecycle, status tracking, and crash restart with backoff. */
|
|
6
7
|
export class ChannelManager {
|
|
7
8
|
config;
|
|
@@ -214,17 +215,28 @@ export class ChannelManager {
|
|
|
214
215
|
const ranForMs = Date.now() - entry.currentStartAt;
|
|
215
216
|
const channelId = entry.adapter.id;
|
|
216
217
|
const crashed = err !== null && err !== undefined;
|
|
218
|
+
const permanentStop = typeof err === "object" &&
|
|
219
|
+
err !== null &&
|
|
220
|
+
err.code === CHANNEL_PERMANENT_STOP;
|
|
217
221
|
entry.snapshot = {
|
|
218
222
|
...entry.snapshot,
|
|
219
223
|
running: false,
|
|
220
224
|
lastStopAt: Date.now(),
|
|
221
|
-
lastError: crashed
|
|
225
|
+
lastError: crashed && !permanentStop
|
|
222
226
|
? err instanceof Error
|
|
223
227
|
? err.message
|
|
224
228
|
: String(err)
|
|
225
229
|
: entry.snapshot.lastError ?? null,
|
|
226
230
|
};
|
|
227
|
-
if (
|
|
231
|
+
if (permanentStop) {
|
|
232
|
+
this.log.info("channel stopped permanently", {
|
|
233
|
+
channel: channelId,
|
|
234
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
235
|
+
});
|
|
236
|
+
entry.state = "idle";
|
|
237
|
+
entry.stopRequested = true;
|
|
238
|
+
}
|
|
239
|
+
else if (crashed) {
|
|
228
240
|
this.log.warn("channel crashed", {
|
|
229
241
|
channel: channelId,
|
|
230
242
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
2
|
import { type InboxMessage } from "@botcord/protocol-core";
|
|
3
|
-
import type { ChannelAdapter, GatewayInboundMessage } from "../index.js";
|
|
3
|
+
import type { ChannelAdapter, GatewayInboundMessage, GatewayLogger } from "../index.js";
|
|
4
4
|
/** Minimal surface the adapter needs from `BotCordClient`. Matches the subset used at runtime. */
|
|
5
5
|
export interface BotCordChannelClient {
|
|
6
6
|
ensureToken(): Promise<string>;
|
|
@@ -55,6 +55,8 @@ export interface BotCordChannelOptions {
|
|
|
55
55
|
* can't spin up a real WS server.
|
|
56
56
|
*/
|
|
57
57
|
webSocketCtor?: typeof WebSocket;
|
|
58
|
+
/** Test hook: override local cleanup after Hub says the agent is unclaimed. */
|
|
59
|
+
localRevokeAgent?: (agentId: string, log: GatewayLogger) => Promise<unknown>;
|
|
58
60
|
}
|
|
59
61
|
/**
|
|
60
62
|
* Map `InboxMessage` → `GatewayInboundMessage`. Field origins:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
2
|
import { BotCordClient, buildHubWebSocketUrl, defaultCredentialsFile, loadStoredCredentials, updateCredentialsToken, } from "@botcord/protocol-core";
|
|
3
3
|
import { sanitizeUntrustedContent } from "./sanitize.js";
|
|
4
|
+
import { revokeAgent } from "../../provision.js";
|
|
4
5
|
const RECONNECT_BACKOFF = [1000, 2000, 4000, 8000, 16000, 30000];
|
|
5
6
|
const KEEPALIVE_INTERVAL = 20_000;
|
|
6
7
|
const MAX_AUTH_FAILURES = 5;
|
|
@@ -8,6 +9,17 @@ const SEEN_MESSAGES_CAP = 500;
|
|
|
8
9
|
const OWNER_CHAT_PREFIX = "rm_oc_";
|
|
9
10
|
const DM_ROOM_PREFIX = "rm_dm_";
|
|
10
11
|
const INBOX_POLL_LIMIT = 50;
|
|
12
|
+
const CHANNEL_PERMANENT_STOP = "channel_permanent_stop";
|
|
13
|
+
function isUnclaimedAgentError(err) {
|
|
14
|
+
const status = err?.status;
|
|
15
|
+
if (status !== 403)
|
|
16
|
+
return false;
|
|
17
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
18
|
+
return (message.includes('"code":"agent_not_claimed_generic"') ||
|
|
19
|
+
message.includes('"code":"agent_not_claimed"') ||
|
|
20
|
+
message.includes("agent_not_claimed_generic") ||
|
|
21
|
+
message.includes("agent_not_claimed"));
|
|
22
|
+
}
|
|
11
23
|
/** Default factory: wrap `loadStoredCredentials` + `new BotCordClient`. */
|
|
12
24
|
function defaultClientFactory(input) {
|
|
13
25
|
const credFile = input.credentialsPath ?? defaultCredentialsFile(input.agentId);
|
|
@@ -334,12 +346,15 @@ export function createBotCordChannel(options) {
|
|
|
334
346
|
let reconnectAttempt = 0;
|
|
335
347
|
let consecutiveAuthFailures = 0;
|
|
336
348
|
let running = true;
|
|
349
|
+
let permanentStopping = false;
|
|
337
350
|
let processing = false;
|
|
338
351
|
let pendingUpdate = false;
|
|
339
352
|
let pendingRefresh = null;
|
|
340
353
|
let resolveLoop = null;
|
|
341
|
-
|
|
354
|
+
let rejectLoop = null;
|
|
355
|
+
const done = new Promise((resolve, reject) => {
|
|
342
356
|
resolveLoop = resolve;
|
|
357
|
+
rejectLoop = reject;
|
|
343
358
|
});
|
|
344
359
|
function clearTimers() {
|
|
345
360
|
if (reconnectTimer) {
|
|
@@ -355,6 +370,68 @@ export function createBotCordChannel(options) {
|
|
|
355
370
|
statusSnapshot = { ...statusSnapshot, ...patch };
|
|
356
371
|
setStatus(patch);
|
|
357
372
|
}
|
|
373
|
+
async function revokeLocalUnclaimedAgent(err) {
|
|
374
|
+
if (!isUnclaimedAgentError(err))
|
|
375
|
+
return false;
|
|
376
|
+
running = false;
|
|
377
|
+
permanentStopping = true;
|
|
378
|
+
clearTimers();
|
|
379
|
+
try {
|
|
380
|
+
ws?.close();
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
// ignore
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
const result = options.localRevokeAgent
|
|
387
|
+
? await options.localRevokeAgent(options.agentId, log)
|
|
388
|
+
: await revokeAgent({
|
|
389
|
+
agentId: options.agentId,
|
|
390
|
+
deleteCredentials: true,
|
|
391
|
+
deleteState: true,
|
|
392
|
+
deleteWorkspace: false,
|
|
393
|
+
}, {
|
|
394
|
+
gateway: {
|
|
395
|
+
removeChannel: async () => undefined,
|
|
396
|
+
removeManagedRoute: () => undefined,
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
log.warn("botcord agent unclaimed; revoked local binding", {
|
|
400
|
+
agentId: options.agentId,
|
|
401
|
+
result,
|
|
402
|
+
});
|
|
403
|
+
markStatus({
|
|
404
|
+
running: false,
|
|
405
|
+
connected: false,
|
|
406
|
+
restartPending: false,
|
|
407
|
+
lastStopAt: Date.now(),
|
|
408
|
+
lastError: "agent not claimed; local binding revoked",
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
catch (cleanupErr) {
|
|
412
|
+
log.error("botcord unclaimed local revoke failed", {
|
|
413
|
+
agentId: options.agentId,
|
|
414
|
+
err: String(cleanupErr),
|
|
415
|
+
});
|
|
416
|
+
markStatus({
|
|
417
|
+
running: false,
|
|
418
|
+
connected: false,
|
|
419
|
+
restartPending: false,
|
|
420
|
+
lastStopAt: Date.now(),
|
|
421
|
+
lastError: String(cleanupErr),
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
permanentStopping = false;
|
|
425
|
+
if (rejectLoop) {
|
|
426
|
+
const r = rejectLoop;
|
|
427
|
+
rejectLoop = null;
|
|
428
|
+
resolveLoop = null;
|
|
429
|
+
const stopErr = new Error("agent not claimed; local binding revoked");
|
|
430
|
+
stopErr.code = CHANNEL_PERMANENT_STOP;
|
|
431
|
+
r(stopErr);
|
|
432
|
+
}
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
358
435
|
async function fireInbox(trigger) {
|
|
359
436
|
if (processing) {
|
|
360
437
|
pendingUpdate = true;
|
|
@@ -376,6 +453,9 @@ export function createBotCordChannel(options) {
|
|
|
376
453
|
} while ((pendingUpdate || hasMore) && running);
|
|
377
454
|
}
|
|
378
455
|
catch (err) {
|
|
456
|
+
if (await revokeLocalUnclaimedAgent(err)) {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
379
459
|
log.error("botcord inbox drain failed", { err: String(err) });
|
|
380
460
|
}
|
|
381
461
|
finally {
|
|
@@ -401,6 +481,7 @@ export function createBotCordChannel(options) {
|
|
|
401
481
|
async function connect() {
|
|
402
482
|
if (!running)
|
|
403
483
|
return;
|
|
484
|
+
const agentId = options.agentId;
|
|
404
485
|
markStatus({ connected: false, restartPending: false });
|
|
405
486
|
if (pendingRefresh) {
|
|
406
487
|
try {
|
|
@@ -418,18 +499,18 @@ export function createBotCordChannel(options) {
|
|
|
418
499
|
token = await client.ensureToken();
|
|
419
500
|
}
|
|
420
501
|
catch (err) {
|
|
421
|
-
log.error("botcord ws token refresh failed", { err: String(err) });
|
|
502
|
+
log.error("botcord ws token refresh failed", { agentId, err: String(err) });
|
|
422
503
|
markStatus({ lastError: String(err) });
|
|
423
504
|
scheduleReconnect();
|
|
424
505
|
return;
|
|
425
506
|
}
|
|
426
507
|
const url = buildHubWebSocketUrl(hubUrl);
|
|
427
|
-
log.info("botcord ws connecting", { url, agentId
|
|
508
|
+
log.info("botcord ws connecting", { url, agentId });
|
|
428
509
|
try {
|
|
429
510
|
ws = new wsCtor(url);
|
|
430
511
|
}
|
|
431
512
|
catch (err) {
|
|
432
|
-
log.error("botcord ws construct failed", { err: String(err) });
|
|
513
|
+
log.error("botcord ws construct failed", { agentId, err: String(err) });
|
|
433
514
|
markStatus({ lastError: String(err) });
|
|
434
515
|
scheduleReconnect();
|
|
435
516
|
return;
|
|
@@ -478,18 +559,21 @@ export function createBotCordChannel(options) {
|
|
|
478
559
|
// no-op
|
|
479
560
|
}
|
|
480
561
|
else if (msg.type === "error" || msg.type === "auth_failed") {
|
|
481
|
-
log.warn("botcord ws server error", { msg });
|
|
562
|
+
log.warn("botcord ws server error", { agentId, msg });
|
|
482
563
|
}
|
|
483
564
|
});
|
|
484
565
|
ws.on("close", (code, reason) => {
|
|
485
566
|
const reasonStr = reason?.toString() || "";
|
|
486
|
-
log.info("botcord ws closed", { code, reason: reasonStr });
|
|
567
|
+
log.info("botcord ws closed", { agentId, code, reason: reasonStr });
|
|
487
568
|
clearTimers();
|
|
488
569
|
markStatus({ connected: false });
|
|
489
570
|
if (!running) {
|
|
571
|
+
if (permanentStopping)
|
|
572
|
+
return;
|
|
490
573
|
if (resolveLoop) {
|
|
491
574
|
const r = resolveLoop;
|
|
492
575
|
resolveLoop = null;
|
|
576
|
+
rejectLoop = null;
|
|
493
577
|
r();
|
|
494
578
|
}
|
|
495
579
|
return;
|
|
@@ -498,6 +582,7 @@ export function createBotCordChannel(options) {
|
|
|
498
582
|
consecutiveAuthFailures += 1;
|
|
499
583
|
if (consecutiveAuthFailures >= MAX_AUTH_FAILURES) {
|
|
500
584
|
log.error("botcord ws auth failing persistently — giving up reconnects", {
|
|
585
|
+
agentId,
|
|
501
586
|
failures: consecutiveAuthFailures,
|
|
502
587
|
});
|
|
503
588
|
running = false;
|
|
@@ -510,18 +595,19 @@ export function createBotCordChannel(options) {
|
|
|
510
595
|
if (resolveLoop) {
|
|
511
596
|
const r = resolveLoop;
|
|
512
597
|
resolveLoop = null;
|
|
598
|
+
rejectLoop = null;
|
|
513
599
|
r();
|
|
514
600
|
}
|
|
515
601
|
return;
|
|
516
602
|
}
|
|
517
603
|
pendingRefresh = client
|
|
518
604
|
.refreshToken()
|
|
519
|
-
.catch((err) => log.error("botcord ws forced refresh failed", { err: String(err) }));
|
|
605
|
+
.catch((err) => log.error("botcord ws forced refresh failed", { agentId, err: String(err) }));
|
|
520
606
|
}
|
|
521
607
|
scheduleReconnect();
|
|
522
608
|
});
|
|
523
609
|
ws.on("error", (err) => {
|
|
524
|
-
log.warn("botcord ws error", { err: String(err) });
|
|
610
|
+
log.warn("botcord ws error", { agentId, err: String(err) });
|
|
525
611
|
markStatus({ lastError: String(err) });
|
|
526
612
|
});
|
|
527
613
|
}
|
|
@@ -547,6 +633,7 @@ export function createBotCordChannel(options) {
|
|
|
547
633
|
if (resolveLoop) {
|
|
548
634
|
const r = resolveLoop;
|
|
549
635
|
resolveLoop = null;
|
|
636
|
+
rejectLoop = null;
|
|
550
637
|
r();
|
|
551
638
|
}
|
|
552
639
|
}
|
package/dist/gateway/log.d.ts
CHANGED
|
@@ -5,5 +5,5 @@ export interface GatewayLogger {
|
|
|
5
5
|
error(msg: string, meta?: Record<string, unknown>): void;
|
|
6
6
|
debug(msg: string, meta?: Record<string, unknown>): void;
|
|
7
7
|
}
|
|
8
|
-
/** Default logger that writes
|
|
8
|
+
/** Default logger that writes compact text lines to stderr; debug lines gated by BOTCORD_GATEWAY_DEBUG. */
|
|
9
9
|
export declare const consoleLogger: GatewayLogger;
|
package/dist/gateway/log.js
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
|
+
import { formatLogLine } from "../log.js";
|
|
1
2
|
function write(level, msg, meta) {
|
|
2
|
-
const line =
|
|
3
|
-
ts: new Date().toISOString(),
|
|
4
|
-
level,
|
|
5
|
-
msg,
|
|
6
|
-
...(meta ?? {}),
|
|
7
|
-
});
|
|
3
|
+
const line = formatLogLine(level, msg, meta);
|
|
8
4
|
// Always write to stderr so stdout stays clean for NDJSON-style channel output.
|
|
9
5
|
process.stderr.write(line + "\n");
|
|
10
6
|
}
|
|
11
|
-
/** Default logger that writes
|
|
7
|
+
/** Default logger that writes compact text lines to stderr; debug lines gated by BOTCORD_GATEWAY_DEBUG. */
|
|
12
8
|
export const consoleLogger = {
|
|
13
9
|
info: (msg, meta) => write("info", msg, meta),
|
|
14
10
|
warn: (msg, meta) => write("warn", msg, meta),
|
package/dist/log.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
type Level = "info" | "warn" | "error" | "debug";
|
|
2
|
+
export declare function formatLogLine(level: Level, msg: string, fields: Record<string, unknown> | undefined, date?: Date): string;
|
|
1
3
|
export declare const log: {
|
|
2
4
|
info: (msg: string, fields?: Record<string, unknown>) => void;
|
|
3
5
|
warn: (msg: string, fields?: Record<string, unknown>) => void;
|
|
@@ -5,3 +7,4 @@ export declare const log: {
|
|
|
5
7
|
debug: (msg: string, fields?: Record<string, unknown>) => void;
|
|
6
8
|
};
|
|
7
9
|
export declare const LOG_FILE_PATH: string;
|
|
10
|
+
export {};
|
package/dist/log.js
CHANGED
|
@@ -15,14 +15,33 @@ function ensureDir() {
|
|
|
15
15
|
}
|
|
16
16
|
inited = true;
|
|
17
17
|
}
|
|
18
|
+
function formatValue(value) {
|
|
19
|
+
if (value instanceof Error)
|
|
20
|
+
return JSON.stringify(value.stack ?? value.message);
|
|
21
|
+
if (typeof value === "string")
|
|
22
|
+
return JSON.stringify(value);
|
|
23
|
+
if (typeof value === "number" || typeof value === "boolean" || value === null)
|
|
24
|
+
return String(value);
|
|
25
|
+
if (value === undefined)
|
|
26
|
+
return "undefined";
|
|
27
|
+
try {
|
|
28
|
+
return JSON.stringify(value);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return JSON.stringify(String(value));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function formatLogLine(level, msg, fields, date = new Date()) {
|
|
35
|
+
const detail = Object.entries(fields ?? {})
|
|
36
|
+
.map(([key, value]) => `${key}=${formatValue(value)}`)
|
|
37
|
+
.join(" ");
|
|
38
|
+
const prefix = `[${level.toUpperCase()}] ${msg}`;
|
|
39
|
+
const suffix = `ts=${date.toISOString()}`;
|
|
40
|
+
return detail ? `${prefix} ${detail} ${suffix}` : `${prefix} ${suffix}`;
|
|
41
|
+
}
|
|
18
42
|
function write(level, msg, fields) {
|
|
19
43
|
ensureDir();
|
|
20
|
-
const line =
|
|
21
|
-
ts: new Date().toISOString(),
|
|
22
|
-
level,
|
|
23
|
-
msg,
|
|
24
|
-
...(fields ?? {}),
|
|
25
|
-
});
|
|
44
|
+
const line = formatLogLine(level, msg, fields);
|
|
26
45
|
try {
|
|
27
46
|
appendFileSync(LOG_FILE, line + "\n", { mode: 0o600 });
|
|
28
47
|
}
|
package/dist/provision.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BotCordClient, type AgentIdentitySnapshot, type ControlAck, type ControlFrame, type ListRuntimesResult } from "@botcord/protocol-core";
|
|
1
|
+
import { BotCordClient, type AgentIdentitySnapshot, type ControlAck, type ControlFrame, type ListRuntimesResult, type RevokeAgentParams, type RevokeAgentResult } from "@botcord/protocol-core";
|
|
2
2
|
import type { Gateway } from "./gateway/index.js";
|
|
3
3
|
import type { PolicyResolverLike } from "./gateway/policy-resolver.js";
|
|
4
4
|
import { type DaemonConfig } from "./config.js";
|
|
@@ -80,6 +80,9 @@ export declare function adoptDiscoveredOpenclawAgents(ctx: {
|
|
|
80
80
|
probe?: WsEndpointProbeFn;
|
|
81
81
|
onAgentInstalled?: OnAgentInstalledHook;
|
|
82
82
|
}): Promise<AdoptDiscoveredOpenclawAgentsResult>;
|
|
83
|
+
export declare function revokeAgent(params: RevokeAgentParams, ctx: {
|
|
84
|
+
gateway: Gateway;
|
|
85
|
+
}): Promise<RevokeAgentResult>;
|
|
83
86
|
/**
|
|
84
87
|
* Append `agentId` to the daemon config if not already present. Returns a
|
|
85
88
|
* new config object or `null` if nothing changed (so callers can skip the
|
package/dist/provision.js
CHANGED
|
@@ -860,7 +860,7 @@ function localOpenclawAcpDisabled(rawUrl) {
|
|
|
860
860
|
return false;
|
|
861
861
|
}
|
|
862
862
|
}
|
|
863
|
-
async function revokeAgent(params, ctx) {
|
|
863
|
+
export async function revokeAgent(params, ctx) {
|
|
864
864
|
if (!params.agentId) {
|
|
865
865
|
throw new Error("revoke_agent requires params.agentId");
|
|
866
866
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { formatLogLine } from "../log.js";
|
|
3
|
+
|
|
4
|
+
describe("formatLogLine", () => {
|
|
5
|
+
it("renders compact text with level, message, details, and trailing timestamp", () => {
|
|
6
|
+
const line = formatLogLine(
|
|
7
|
+
"warn",
|
|
8
|
+
"botcord ws error",
|
|
9
|
+
{ err: "Error: Unexpected server response: 503" },
|
|
10
|
+
new Date("2026-05-01T00:22:07.131Z"),
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
expect(line).toBe(
|
|
14
|
+
'[WARN] botcord ws error err="Error: Unexpected server response: 503" ts=2026-05-01T00:22:07.131Z',
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("keeps object details readable without replacing the primary message", () => {
|
|
19
|
+
const line = formatLogLine(
|
|
20
|
+
"info",
|
|
21
|
+
"botcord ws server error",
|
|
22
|
+
{ msg: { type: "error", code: 503 } },
|
|
23
|
+
new Date("2026-05-01T00:22:07.131Z"),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
expect(line).toBe(
|
|
27
|
+
'[INFO] botcord ws server error msg={"type":"error","code":503} ts=2026-05-01T00:22:07.131Z',
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -510,6 +510,61 @@ describe("createBotCordChannel — ack + dedup", () => {
|
|
|
510
510
|
await server.close();
|
|
511
511
|
}
|
|
512
512
|
});
|
|
513
|
+
|
|
514
|
+
it("locally revokes the channel when Hub reports the agent is unclaimed", async () => {
|
|
515
|
+
const server = await startAuthOkServer();
|
|
516
|
+
try {
|
|
517
|
+
const err = new Error(
|
|
518
|
+
'BotCord /hub/inbox?limit=50 failed: 403 {"code":"agent_not_claimed_generic","retryable":false}',
|
|
519
|
+
) as Error & { status?: number };
|
|
520
|
+
err.status = 403;
|
|
521
|
+
const client = makeClient({
|
|
522
|
+
getHubUrl: vi.fn().mockReturnValue(server.url),
|
|
523
|
+
pollInbox: vi.fn().mockRejectedValue(err),
|
|
524
|
+
});
|
|
525
|
+
const localRevokeAgent = vi.fn().mockResolvedValue({
|
|
526
|
+
agentId: "ag_self",
|
|
527
|
+
credentialsDeleted: true,
|
|
528
|
+
stateDeleted: true,
|
|
529
|
+
workspaceDeleted: false,
|
|
530
|
+
});
|
|
531
|
+
const statuses: Record<string, unknown>[] = [];
|
|
532
|
+
const logs: Array<{ msg: string; meta?: Record<string, unknown> }> = [];
|
|
533
|
+
const log: GatewayLogger = {
|
|
534
|
+
...silentLog,
|
|
535
|
+
warn: (msg, meta) => logs.push({ msg, meta }),
|
|
536
|
+
};
|
|
537
|
+
const channel = createBotCordChannel({
|
|
538
|
+
id: "botcord-main",
|
|
539
|
+
accountId: "ag_self",
|
|
540
|
+
agentId: "ag_self",
|
|
541
|
+
client,
|
|
542
|
+
hubBaseUrl: server.url,
|
|
543
|
+
localRevokeAgent,
|
|
544
|
+
});
|
|
545
|
+
const startP = channel.start({
|
|
546
|
+
config: stubConfig,
|
|
547
|
+
accountId: "ag_self",
|
|
548
|
+
abortSignal: new AbortController().signal,
|
|
549
|
+
log,
|
|
550
|
+
emit: async () => undefined,
|
|
551
|
+
setStatus: (patch) => statuses.push(patch),
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
await expect(startP).rejects.toMatchObject({ code: "channel_permanent_stop" });
|
|
555
|
+
expect(localRevokeAgent).toHaveBeenCalledWith("ag_self", log);
|
|
556
|
+
expect(logs.some((entry) => entry.msg === "botcord agent unclaimed; revoked local binding"))
|
|
557
|
+
.toBe(true);
|
|
558
|
+
expect(statuses.at(-1)).toMatchObject({
|
|
559
|
+
running: false,
|
|
560
|
+
connected: false,
|
|
561
|
+
restartPending: false,
|
|
562
|
+
lastError: "agent not claimed; local binding revoked",
|
|
563
|
+
});
|
|
564
|
+
} finally {
|
|
565
|
+
await server.close();
|
|
566
|
+
}
|
|
567
|
+
});
|
|
513
568
|
});
|
|
514
569
|
|
|
515
570
|
// ---------------------------------------------------------------------------
|
|
@@ -661,6 +716,52 @@ describe("createBotCordChannel — typing()", () => {
|
|
|
661
716
|
});
|
|
662
717
|
});
|
|
663
718
|
|
|
719
|
+
describe("createBotCordChannel — websocket logging", () => {
|
|
720
|
+
it("includes the agent id on websocket server errors", async () => {
|
|
721
|
+
const server = await startAuthOkServer();
|
|
722
|
+
const client = makeClient({
|
|
723
|
+
getHubUrl: vi.fn().mockReturnValue(server.url),
|
|
724
|
+
});
|
|
725
|
+
const channel = createBotCordChannel({
|
|
726
|
+
id: "botcord-main",
|
|
727
|
+
accountId: "ag_self",
|
|
728
|
+
agentId: "ag_self",
|
|
729
|
+
client,
|
|
730
|
+
hubBaseUrl: server.url,
|
|
731
|
+
});
|
|
732
|
+
const abort = new AbortController();
|
|
733
|
+
const log: GatewayLogger = {
|
|
734
|
+
...silentLog,
|
|
735
|
+
warn: vi.fn(),
|
|
736
|
+
};
|
|
737
|
+
const startPromise = channel.start({
|
|
738
|
+
config: stubConfig,
|
|
739
|
+
accountId: "ag_self",
|
|
740
|
+
abortSignal: abort.signal,
|
|
741
|
+
log,
|
|
742
|
+
emit: async () => {},
|
|
743
|
+
setStatus: () => {},
|
|
744
|
+
});
|
|
745
|
+
try {
|
|
746
|
+
await vi.waitFor(() => expect(server.connections.length).toBe(1));
|
|
747
|
+
server.connections[0].send(JSON.stringify({ type: "error", code: 503 }));
|
|
748
|
+
await vi.waitFor(() => {
|
|
749
|
+
expect(log.warn).toHaveBeenCalledWith(
|
|
750
|
+
"botcord ws server error",
|
|
751
|
+
expect.objectContaining({
|
|
752
|
+
agentId: "ag_self",
|
|
753
|
+
msg: expect.objectContaining({ type: "error", code: 503 }),
|
|
754
|
+
}),
|
|
755
|
+
);
|
|
756
|
+
});
|
|
757
|
+
} finally {
|
|
758
|
+
abort.abort();
|
|
759
|
+
await startPromise;
|
|
760
|
+
await server.close();
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
});
|
|
764
|
+
|
|
664
765
|
// ---------------------------------------------------------------------------
|
|
665
766
|
// Shared: a tiny WS server that acks every `auth` with `auth_ok`.
|
|
666
767
|
// ---------------------------------------------------------------------------
|
|
@@ -270,6 +270,32 @@ describe("ChannelManager", () => {
|
|
|
270
270
|
await mgr.stopAll();
|
|
271
271
|
});
|
|
272
272
|
|
|
273
|
+
it("does not restart after permanent channel stop", async () => {
|
|
274
|
+
const c1 = new FakeChannel("c1");
|
|
275
|
+
const log = makeLogger();
|
|
276
|
+
const mgr = new ChannelManager({
|
|
277
|
+
config: makeConfig(["c1"]),
|
|
278
|
+
channels: [c1],
|
|
279
|
+
log,
|
|
280
|
+
emit: async () => {},
|
|
281
|
+
backoffMs: { initial: 1000, max: 60_000, factor: 2 },
|
|
282
|
+
});
|
|
283
|
+
await mgr.startAll();
|
|
284
|
+
await flush();
|
|
285
|
+
const err = new Error("agent not claimed; local binding revoked") as Error & {
|
|
286
|
+
code?: string;
|
|
287
|
+
};
|
|
288
|
+
err.code = "channel_permanent_stop";
|
|
289
|
+
c1.latest().reject(err);
|
|
290
|
+
await flush();
|
|
291
|
+
expect(mgr.status()["c1"].restartPending).toBeFalsy();
|
|
292
|
+
expect(c1.starts).toHaveLength(1);
|
|
293
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
294
|
+
await flush();
|
|
295
|
+
expect(c1.starts).toHaveLength(1);
|
|
296
|
+
expect(log.infos.some((entry) => entry[0] === "channel stopped permanently")).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
|
|
273
299
|
it("restarts when channel resolves (graceful) without stopAll", async () => {
|
|
274
300
|
const c1 = new FakeChannel("c1");
|
|
275
301
|
const mgr = new ChannelManager({
|
|
@@ -43,6 +43,7 @@ const DEFAULT_INITIAL_BACKOFF = 1000;
|
|
|
43
43
|
const DEFAULT_MAX_BACKOFF = 60_000;
|
|
44
44
|
const DEFAULT_FACTOR = 2;
|
|
45
45
|
const LONG_RUN_THRESHOLD_MS = 30_000;
|
|
46
|
+
const CHANNEL_PERMANENT_STOP = "channel_permanent_stop";
|
|
46
47
|
|
|
47
48
|
/** Supervises channel adapters: lifecycle, status tracking, and crash restart with backoff. */
|
|
48
49
|
export class ChannelManager {
|
|
@@ -266,19 +267,30 @@ export class ChannelManager {
|
|
|
266
267
|
const ranForMs = Date.now() - entry.currentStartAt;
|
|
267
268
|
const channelId = entry.adapter.id;
|
|
268
269
|
const crashed = err !== null && err !== undefined;
|
|
270
|
+
const permanentStop =
|
|
271
|
+
typeof err === "object" &&
|
|
272
|
+
err !== null &&
|
|
273
|
+
(err as { code?: unknown }).code === CHANNEL_PERMANENT_STOP;
|
|
269
274
|
|
|
270
275
|
entry.snapshot = {
|
|
271
276
|
...entry.snapshot,
|
|
272
277
|
running: false,
|
|
273
278
|
lastStopAt: Date.now(),
|
|
274
|
-
lastError: crashed
|
|
279
|
+
lastError: crashed && !permanentStop
|
|
275
280
|
? err instanceof Error
|
|
276
281
|
? err.message
|
|
277
282
|
: String(err)
|
|
278
283
|
: entry.snapshot.lastError ?? null,
|
|
279
284
|
};
|
|
280
285
|
|
|
281
|
-
if (
|
|
286
|
+
if (permanentStop) {
|
|
287
|
+
this.log.info("channel stopped permanently", {
|
|
288
|
+
channel: channelId,
|
|
289
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
290
|
+
});
|
|
291
|
+
entry.state = "idle";
|
|
292
|
+
entry.stopRequested = true;
|
|
293
|
+
} else if (crashed) {
|
|
282
294
|
this.log.warn("channel crashed", {
|
|
283
295
|
channel: channelId,
|
|
284
296
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -20,7 +20,9 @@ import type {
|
|
|
20
20
|
GatewayInboundMessage,
|
|
21
21
|
GatewayLogger,
|
|
22
22
|
} from "../index.js";
|
|
23
|
+
import type { Gateway } from "../gateway.js";
|
|
23
24
|
import { sanitizeUntrustedContent } from "./sanitize.js";
|
|
25
|
+
import { revokeAgent } from "../../provision.js";
|
|
24
26
|
|
|
25
27
|
const RECONNECT_BACKOFF = [1000, 2000, 4000, 8000, 16000, 30000];
|
|
26
28
|
const KEEPALIVE_INTERVAL = 20_000;
|
|
@@ -29,6 +31,7 @@ const SEEN_MESSAGES_CAP = 500;
|
|
|
29
31
|
const OWNER_CHAT_PREFIX = "rm_oc_";
|
|
30
32
|
const DM_ROOM_PREFIX = "rm_dm_";
|
|
31
33
|
const INBOX_POLL_LIMIT = 50;
|
|
34
|
+
const CHANNEL_PERMANENT_STOP = "channel_permanent_stop";
|
|
32
35
|
|
|
33
36
|
type InboxDrainTrigger =
|
|
34
37
|
| "ws_auth_ok"
|
|
@@ -86,6 +89,20 @@ export interface BotCordChannelOptions {
|
|
|
86
89
|
* can't spin up a real WS server.
|
|
87
90
|
*/
|
|
88
91
|
webSocketCtor?: typeof WebSocket;
|
|
92
|
+
/** Test hook: override local cleanup after Hub says the agent is unclaimed. */
|
|
93
|
+
localRevokeAgent?: (agentId: string, log: GatewayLogger) => Promise<unknown>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isUnclaimedAgentError(err: unknown): boolean {
|
|
97
|
+
const status = (err as { status?: unknown } | null)?.status;
|
|
98
|
+
if (status !== 403) return false;
|
|
99
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
100
|
+
return (
|
|
101
|
+
message.includes('"code":"agent_not_claimed_generic"') ||
|
|
102
|
+
message.includes('"code":"agent_not_claimed"') ||
|
|
103
|
+
message.includes("agent_not_claimed_generic") ||
|
|
104
|
+
message.includes("agent_not_claimed")
|
|
105
|
+
);
|
|
89
106
|
}
|
|
90
107
|
|
|
91
108
|
/** Default factory: wrap `loadStoredCredentials` + `new BotCordClient`. */
|
|
@@ -456,13 +473,16 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
456
473
|
let reconnectAttempt = 0;
|
|
457
474
|
let consecutiveAuthFailures = 0;
|
|
458
475
|
let running = true;
|
|
476
|
+
let permanentStopping = false;
|
|
459
477
|
let processing = false;
|
|
460
478
|
let pendingUpdate = false;
|
|
461
479
|
let pendingRefresh: Promise<unknown> | null = null;
|
|
462
480
|
let resolveLoop: (() => void) | null = null;
|
|
481
|
+
let rejectLoop: ((err: Error) => void) | null = null;
|
|
463
482
|
|
|
464
|
-
const done = new Promise<void>((resolve) => {
|
|
483
|
+
const done = new Promise<void>((resolve, reject) => {
|
|
465
484
|
resolveLoop = resolve;
|
|
485
|
+
rejectLoop = reject;
|
|
466
486
|
});
|
|
467
487
|
|
|
468
488
|
function clearTimers() {
|
|
@@ -481,6 +501,71 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
481
501
|
setStatus(patch);
|
|
482
502
|
}
|
|
483
503
|
|
|
504
|
+
async function revokeLocalUnclaimedAgent(err: unknown) {
|
|
505
|
+
if (!isUnclaimedAgentError(err)) return false;
|
|
506
|
+
running = false;
|
|
507
|
+
permanentStopping = true;
|
|
508
|
+
clearTimers();
|
|
509
|
+
try {
|
|
510
|
+
ws?.close();
|
|
511
|
+
} catch {
|
|
512
|
+
// ignore
|
|
513
|
+
}
|
|
514
|
+
try {
|
|
515
|
+
const result = options.localRevokeAgent
|
|
516
|
+
? await options.localRevokeAgent(options.agentId, log)
|
|
517
|
+
: await revokeAgent(
|
|
518
|
+
{
|
|
519
|
+
agentId: options.agentId,
|
|
520
|
+
deleteCredentials: true,
|
|
521
|
+
deleteState: true,
|
|
522
|
+
deleteWorkspace: false,
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
gateway: {
|
|
526
|
+
removeChannel: async () => undefined,
|
|
527
|
+
removeManagedRoute: () => undefined,
|
|
528
|
+
} as unknown as Gateway,
|
|
529
|
+
},
|
|
530
|
+
);
|
|
531
|
+
log.warn("botcord agent unclaimed; revoked local binding", {
|
|
532
|
+
agentId: options.agentId,
|
|
533
|
+
result,
|
|
534
|
+
});
|
|
535
|
+
markStatus({
|
|
536
|
+
running: false,
|
|
537
|
+
connected: false,
|
|
538
|
+
restartPending: false,
|
|
539
|
+
lastStopAt: Date.now(),
|
|
540
|
+
lastError: "agent not claimed; local binding revoked",
|
|
541
|
+
});
|
|
542
|
+
} catch (cleanupErr) {
|
|
543
|
+
log.error("botcord unclaimed local revoke failed", {
|
|
544
|
+
agentId: options.agentId,
|
|
545
|
+
err: String(cleanupErr),
|
|
546
|
+
});
|
|
547
|
+
markStatus({
|
|
548
|
+
running: false,
|
|
549
|
+
connected: false,
|
|
550
|
+
restartPending: false,
|
|
551
|
+
lastStopAt: Date.now(),
|
|
552
|
+
lastError: String(cleanupErr),
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
permanentStopping = false;
|
|
556
|
+
if (rejectLoop) {
|
|
557
|
+
const r = rejectLoop;
|
|
558
|
+
rejectLoop = null;
|
|
559
|
+
resolveLoop = null;
|
|
560
|
+
const stopErr = new Error("agent not claimed; local binding revoked") as Error & {
|
|
561
|
+
code?: string;
|
|
562
|
+
};
|
|
563
|
+
stopErr.code = CHANNEL_PERMANENT_STOP;
|
|
564
|
+
r(stopErr);
|
|
565
|
+
}
|
|
566
|
+
return true;
|
|
567
|
+
}
|
|
568
|
+
|
|
484
569
|
async function fireInbox(trigger: InboxDrainTrigger) {
|
|
485
570
|
if (processing) {
|
|
486
571
|
pendingUpdate = true;
|
|
@@ -501,6 +586,9 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
501
586
|
currentTrigger = hasMore ? "has_more_continue" : "coalesced_inbox_update";
|
|
502
587
|
} while ((pendingUpdate || hasMore) && running);
|
|
503
588
|
} catch (err) {
|
|
589
|
+
if (await revokeLocalUnclaimedAgent(err)) {
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
504
592
|
log.error("botcord inbox drain failed", { err: String(err) });
|
|
505
593
|
} finally {
|
|
506
594
|
processing = false;
|
|
@@ -526,6 +614,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
526
614
|
|
|
527
615
|
async function connect() {
|
|
528
616
|
if (!running) return;
|
|
617
|
+
const agentId = options.agentId;
|
|
529
618
|
markStatus({ connected: false, restartPending: false });
|
|
530
619
|
if (pendingRefresh) {
|
|
531
620
|
try {
|
|
@@ -540,19 +629,19 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
540
629
|
try {
|
|
541
630
|
token = await client.ensureToken();
|
|
542
631
|
} catch (err) {
|
|
543
|
-
log.error("botcord ws token refresh failed", { err: String(err) });
|
|
632
|
+
log.error("botcord ws token refresh failed", { agentId, err: String(err) });
|
|
544
633
|
markStatus({ lastError: String(err) });
|
|
545
634
|
scheduleReconnect();
|
|
546
635
|
return;
|
|
547
636
|
}
|
|
548
637
|
|
|
549
638
|
const url = buildHubWebSocketUrl(hubUrl);
|
|
550
|
-
log.info("botcord ws connecting", { url, agentId
|
|
639
|
+
log.info("botcord ws connecting", { url, agentId });
|
|
551
640
|
|
|
552
641
|
try {
|
|
553
642
|
ws = new wsCtor(url);
|
|
554
643
|
} catch (err) {
|
|
555
|
-
log.error("botcord ws construct failed", { err: String(err) });
|
|
644
|
+
log.error("botcord ws construct failed", { agentId, err: String(err) });
|
|
556
645
|
markStatus({ lastError: String(err) });
|
|
557
646
|
scheduleReconnect();
|
|
558
647
|
return;
|
|
@@ -597,19 +686,21 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
597
686
|
} else if (msg.type === "heartbeat" || msg.type === "pong") {
|
|
598
687
|
// no-op
|
|
599
688
|
} else if (msg.type === "error" || msg.type === "auth_failed") {
|
|
600
|
-
log.warn("botcord ws server error", { msg });
|
|
689
|
+
log.warn("botcord ws server error", { agentId, msg });
|
|
601
690
|
}
|
|
602
691
|
});
|
|
603
692
|
|
|
604
693
|
ws.on("close", (code: number, reason: Buffer) => {
|
|
605
694
|
const reasonStr = reason?.toString() || "";
|
|
606
|
-
log.info("botcord ws closed", { code, reason: reasonStr });
|
|
695
|
+
log.info("botcord ws closed", { agentId, code, reason: reasonStr });
|
|
607
696
|
clearTimers();
|
|
608
697
|
markStatus({ connected: false });
|
|
609
698
|
if (!running) {
|
|
699
|
+
if (permanentStopping) return;
|
|
610
700
|
if (resolveLoop) {
|
|
611
701
|
const r = resolveLoop;
|
|
612
702
|
resolveLoop = null;
|
|
703
|
+
rejectLoop = null;
|
|
613
704
|
r();
|
|
614
705
|
}
|
|
615
706
|
return;
|
|
@@ -618,6 +709,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
618
709
|
consecutiveAuthFailures += 1;
|
|
619
710
|
if (consecutiveAuthFailures >= MAX_AUTH_FAILURES) {
|
|
620
711
|
log.error("botcord ws auth failing persistently — giving up reconnects", {
|
|
712
|
+
agentId,
|
|
621
713
|
failures: consecutiveAuthFailures,
|
|
622
714
|
});
|
|
623
715
|
running = false;
|
|
@@ -630,19 +722,20 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
630
722
|
if (resolveLoop) {
|
|
631
723
|
const r = resolveLoop;
|
|
632
724
|
resolveLoop = null;
|
|
725
|
+
rejectLoop = null;
|
|
633
726
|
r();
|
|
634
727
|
}
|
|
635
728
|
return;
|
|
636
729
|
}
|
|
637
730
|
pendingRefresh = client
|
|
638
731
|
.refreshToken()
|
|
639
|
-
.catch((err) => log.error("botcord ws forced refresh failed", { err: String(err) }));
|
|
732
|
+
.catch((err) => log.error("botcord ws forced refresh failed", { agentId, err: String(err) }));
|
|
640
733
|
}
|
|
641
734
|
scheduleReconnect();
|
|
642
735
|
});
|
|
643
736
|
|
|
644
737
|
ws.on("error", (err: Error) => {
|
|
645
|
-
log.warn("botcord ws error", { err: String(err) });
|
|
738
|
+
log.warn("botcord ws error", { agentId, err: String(err) });
|
|
646
739
|
markStatus({ lastError: String(err) });
|
|
647
740
|
});
|
|
648
741
|
}
|
|
@@ -667,6 +760,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
667
760
|
if (resolveLoop) {
|
|
668
761
|
const r = resolveLoop;
|
|
669
762
|
resolveLoop = null;
|
|
763
|
+
rejectLoop = null;
|
|
670
764
|
r();
|
|
671
765
|
}
|
|
672
766
|
}
|
package/src/gateway/log.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { formatLogLine } from "../log.js";
|
|
2
|
+
|
|
1
3
|
/** Structured logger interface used across the gateway core and adapters. */
|
|
2
4
|
export interface GatewayLogger {
|
|
3
5
|
info(msg: string, meta?: Record<string, unknown>): void;
|
|
@@ -9,17 +11,12 @@ export interface GatewayLogger {
|
|
|
9
11
|
type Level = "info" | "warn" | "error" | "debug";
|
|
10
12
|
|
|
11
13
|
function write(level: Level, msg: string, meta?: Record<string, unknown>): void {
|
|
12
|
-
const line =
|
|
13
|
-
ts: new Date().toISOString(),
|
|
14
|
-
level,
|
|
15
|
-
msg,
|
|
16
|
-
...(meta ?? {}),
|
|
17
|
-
});
|
|
14
|
+
const line = formatLogLine(level, msg, meta);
|
|
18
15
|
// Always write to stderr so stdout stays clean for NDJSON-style channel output.
|
|
19
16
|
process.stderr.write(line + "\n");
|
|
20
17
|
}
|
|
21
18
|
|
|
22
|
-
/** Default logger that writes
|
|
19
|
+
/** Default logger that writes compact text lines to stderr; debug lines gated by BOTCORD_GATEWAY_DEBUG. */
|
|
23
20
|
export const consoleLogger: GatewayLogger = {
|
|
24
21
|
info: (msg, meta) => write("info", msg, meta),
|
|
25
22
|
warn: (msg, meta) => write("warn", msg, meta),
|
package/src/log.ts
CHANGED
|
@@ -18,14 +18,35 @@ function ensureDir(): void {
|
|
|
18
18
|
|
|
19
19
|
type Level = "info" | "warn" | "error" | "debug";
|
|
20
20
|
|
|
21
|
+
function formatValue(value: unknown): string {
|
|
22
|
+
if (value instanceof Error) return JSON.stringify(value.stack ?? value.message);
|
|
23
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
24
|
+
if (typeof value === "number" || typeof value === "boolean" || value === null) return String(value);
|
|
25
|
+
if (value === undefined) return "undefined";
|
|
26
|
+
try {
|
|
27
|
+
return JSON.stringify(value);
|
|
28
|
+
} catch {
|
|
29
|
+
return JSON.stringify(String(value));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function formatLogLine(
|
|
34
|
+
level: Level,
|
|
35
|
+
msg: string,
|
|
36
|
+
fields: Record<string, unknown> | undefined,
|
|
37
|
+
date = new Date(),
|
|
38
|
+
): string {
|
|
39
|
+
const detail = Object.entries(fields ?? {})
|
|
40
|
+
.map(([key, value]) => `${key}=${formatValue(value)}`)
|
|
41
|
+
.join(" ");
|
|
42
|
+
const prefix = `[${level.toUpperCase()}] ${msg}`;
|
|
43
|
+
const suffix = `ts=${date.toISOString()}`;
|
|
44
|
+
return detail ? `${prefix} ${detail} ${suffix}` : `${prefix} ${suffix}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
21
47
|
function write(level: Level, msg: string, fields?: Record<string, unknown>): void {
|
|
22
48
|
ensureDir();
|
|
23
|
-
const line =
|
|
24
|
-
ts: new Date().toISOString(),
|
|
25
|
-
level,
|
|
26
|
-
msg,
|
|
27
|
-
...(fields ?? {}),
|
|
28
|
-
});
|
|
49
|
+
const line = formatLogLine(level, msg, fields);
|
|
29
50
|
try {
|
|
30
51
|
appendFileSync(LOG_FILE, line + "\n", { mode: 0o600 });
|
|
31
52
|
} catch {
|
package/src/provision.ts
CHANGED
|
@@ -1061,7 +1061,7 @@ function localOpenclawAcpDisabled(rawUrl: string): boolean {
|
|
|
1061
1061
|
}
|
|
1062
1062
|
}
|
|
1063
1063
|
|
|
1064
|
-
async function revokeAgent(
|
|
1064
|
+
export async function revokeAgent(
|
|
1065
1065
|
params: RevokeAgentParams,
|
|
1066
1066
|
ctx: { gateway: Gateway },
|
|
1067
1067
|
): Promise<RevokeAgentResult> {
|