@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.
@@ -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 (crashed) {
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
- const done = new Promise((resolve) => {
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: options.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
  }
@@ -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 JSON lines to stderr; debug lines gated by BOTCORD_GATEWAY_DEBUG. */
8
+ /** Default logger that writes compact text lines to stderr; debug lines gated by BOTCORD_GATEWAY_DEBUG. */
9
9
  export declare const consoleLogger: GatewayLogger;
@@ -1,14 +1,10 @@
1
+ import { formatLogLine } from "../log.js";
1
2
  function write(level, msg, meta) {
2
- const line = JSON.stringify({
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 JSON lines to stderr; debug lines gated by BOTCORD_GATEWAY_DEBUG. */
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 = JSON.stringify({
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
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.33",
3
+ "version": "0.2.34",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 (crashed) {
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: options.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
  }
@@ -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 = JSON.stringify({
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 JSON lines to stderr; debug lines gated by BOTCORD_GATEWAY_DEBUG. */
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 = JSON.stringify({
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> {