feat(cli+broker): three-tier peer removal: disconnect, kick, ban
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

Broker (apps/broker/src/index.ts)
- Unified disconnect/kick handler uses close code 1000 for disconnect
  (CLI auto-reconnects) vs 4001 for kick (CLI exits, no reconnect).
- Ban now closes with code 4002.
- Hello handler: revoked members get a specific 'revoked' error with a
  'Contact the mesh owner to rejoin' message, then ws.close(4002).
  Previously banned users saw the generic 'unauthorized' error.
- list_bans handler returns { name, pubkey, revokedAt } for each
  revoked member.

CLI (apps/cli)
- ws-client: close codes 4001 and 4002 set .closed = true and stash
  .terminalClose so callers can surface a friendly message instead of
  the low-level 'ws terminal close' error. Revoked error in hello is
  also captured as a terminal close.
- withMesh catches terminalClose and prints:
  4001 → 'Kicked from this mesh. Run claudemesh to rejoin.'
  4002 → the broker's 'Contact the mesh owner to rejoin.' message
- kick.ts now exports runDisconnect + runKick with clear hints:
  'disconnect' → 'They will auto-reconnect within seconds.'
  'kick'       → 'They can rejoin anytime by running claudemesh.'
- cli.ts adds 'disconnect' dispatch; HELP updated.

Semantics:
  disconnect: session reset, no DB state, auto-reconnects
  kick      : session ends, no DB state, user must manually rejoin
  ban       : session ends + revokedAt set, cannot rejoin until unban

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-20 09:55:05 +01:00
parent 163e1be70a
commit b49e9a9b61
8 changed files with 445 additions and 48 deletions

View File

@@ -166,6 +166,8 @@ export class BrokerClient {
private _serviceCatalog: Array<{ name: string; description: string; status: string; tools: Array<{ name: string; description: string; inputSchema: object }>; deployed_by: string }> = [];
get serviceCatalog() { return this._serviceCatalog; }
private closed = false;
/** Non-null when the broker closed us with a terminal code (4001/4002). */
public terminalClose: { code: number; reason: string } | null = null;
private reconnectAttempt = 0;
private helloTimer: NodeJS.Timeout | null = null;
private reconnectTimer: NodeJS.Timeout | null = null;
@@ -321,10 +323,23 @@ export class BrokerClient {
this.handleServerMessage(msg);
};
const onClose = (): void => {
const onClose = (code?: number, reasonBuf?: Buffer): void => {
if (this.helloTimer) clearTimeout(this.helloTimer);
this.helloTimer = null;
if (this.ws === ws) this.ws = null;
const reason = reasonBuf?.toString("utf-8") ?? "";
// Terminal close codes — broker told us to stay gone.
// 4001 = kicked (session ended, user must manually rejoin)
// 4002 = banned (member revoked, cannot rejoin until unbanned)
if (code === 4001 || code === 4002) {
this.closed = true;
this.setConnStatus("closed");
this.terminalClose = { code, reason };
if (this._status !== "open") {
reject(new Error(`ws terminal close ${code}: ${reason || "session ended"}`));
}
return;
}
if (this._status !== "open" && this._status !== "reconnecting") {
reject(new Error("ws closed before hello_ack"));
}
@@ -2158,6 +2173,11 @@ export class BrokerClient {
}
if (msg.type === "error") {
this.debug(`broker error: ${msg.code} ${msg.message}`);
// Terminal errors from hello — broker will close us next. Capture
// so the caller (launch/peers/etc.) can surface a friendly message.
if (msg.code === "revoked") {
this.terminalClose = { code: 4002, reason: String(msg.message ?? "revoked") };
}
const id = msg.id ? String(msg.id) : null;
let handledByPendingSend = false;
if (id) {