switchroom 0.12.7 → 0.12.8

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.
@@ -27272,41 +27272,77 @@ function seedChain(path) {
27272
27272
  lastHash: "CORRUPT-TAIL-" + createHash6("sha256").update(tail).digest("hex").slice(0, 16)
27273
27273
  };
27274
27274
  }
27275
- function verifyAuditChain(fullText, expectedFirstPrev = CHAIN_GENESIS) {
27275
+ function verifyAuditLog(fullText) {
27276
27276
  const rawLines = fullText.split(`
27277
27277
  `);
27278
- let prev = expectedFirstPrev;
27279
- let expectedSeq = 1;
27280
- let rows = 0;
27278
+ let legacyRows = 0;
27279
+ let chainedRows = 0;
27280
+ let segments = 0;
27281
+ let seenChained = false;
27282
+ let prevSeq = 0;
27283
+ let prevHash = "";
27281
27284
  for (let i = 0;i < rawLines.length; i++) {
27282
27285
  const raw = rawLines[i];
27283
27286
  if (raw.length === 0)
27284
27287
  continue;
27285
- rows++;
27286
27288
  let obj;
27287
27289
  try {
27288
27290
  obj = JSON.parse(raw);
27289
27291
  } catch {
27290
- return { ok: false, rows, brokenAtLine: i + 1, reason: "row is not valid JSON" };
27292
+ if (!seenChained) {
27293
+ legacyRows++;
27294
+ continue;
27295
+ }
27296
+ return {
27297
+ state: "tampered",
27298
+ legacyRows,
27299
+ chainedRows,
27300
+ segments,
27301
+ brokenAtLine: i + 1,
27302
+ reason: "a non-JSON row appears inside the hash-chained region (row replaced/corrupted)"
27303
+ };
27291
27304
  }
27292
27305
  const { _seq, _prev, _hash, ...entry } = obj;
27293
- if (typeof _seq !== "number" || typeof _prev !== "string" || typeof _hash !== "string") {
27294
- return { ok: false, rows, brokenAtLine: i + 1, reason: "row missing chain fields (unchained / stripped)" };
27295
- }
27296
- if (_seq !== expectedSeq) {
27297
- return { ok: false, rows, brokenAtLine: i + 1, reason: `seq gap: expected ${expectedSeq}, got ${_seq} (row deleted or reordered)` };
27298
- }
27299
- if (_prev !== prev) {
27300
- return { ok: false, rows, brokenAtLine: i + 1, reason: "prev-hash mismatch (a prior row was altered or removed)" };
27306
+ const isChained = typeof _seq === "number" && typeof _prev === "string" && typeof _hash === "string";
27307
+ if (!isChained) {
27308
+ if (!seenChained) {
27309
+ legacyRows++;
27310
+ continue;
27311
+ }
27312
+ return {
27313
+ state: "tampered",
27314
+ legacyRows,
27315
+ chainedRows,
27316
+ segments,
27317
+ brokenAtLine: i + 1,
27318
+ reason: "a chained row had its chain fields stripped (unchained row inside the chained region)"
27319
+ };
27301
27320
  }
27302
27321
  const recomputed = rowHash(_prev, _seq, JSON.stringify(entry));
27303
27322
  if (recomputed !== _hash) {
27304
- return { ok: false, rows, brokenAtLine: i + 1, reason: "hash mismatch (this row's body was edited)" };
27323
+ return {
27324
+ state: "tampered",
27325
+ legacyRows,
27326
+ chainedRows,
27327
+ segments,
27328
+ brokenAtLine: i + 1,
27329
+ reason: "hash mismatch \u2014 this row's body/_seq/_prev was edited without recomputing its _hash"
27330
+ };
27331
+ }
27332
+ if (!seenChained) {
27333
+ seenChained = true;
27334
+ segments = 1;
27335
+ } else if (_seq !== prevSeq + 1 || _prev !== prevHash) {
27336
+ segments++;
27305
27337
  }
27306
- prev = _hash;
27307
- expectedSeq++;
27338
+ chainedRows++;
27339
+ prevSeq = _seq;
27340
+ prevHash = _hash;
27308
27341
  }
27309
- return { ok: true, rows };
27342
+ if (!seenChained) {
27343
+ return { state: "legacy", legacyRows, chainedRows: 0, segments: 0 };
27344
+ }
27345
+ return { state: "ok", legacyRows, chainedRows, segments };
27310
27346
  }
27311
27347
  var CHAIN_GENESIS = "GENESIS", SEP = "\x00";
27312
27348
  var init_audit_hashchain = () => {};
@@ -28729,20 +28765,6 @@ function rootWrittenLogs(home2) {
28729
28765
  }
28730
28766
  ];
28731
28767
  }
28732
- function looksChained(text) {
28733
- for (const raw of text.split(`
28734
- `)) {
28735
- if (raw.length === 0)
28736
- continue;
28737
- try {
28738
- const o = JSON.parse(raw);
28739
- return typeof o._seq === "number";
28740
- } catch {
28741
- return false;
28742
- }
28743
- }
28744
- return false;
28745
- }
28746
28768
  function runAuditIntegrityChecks(deps = {}) {
28747
28769
  const home2 = deps.homeDir ?? homedir21();
28748
28770
  const read = deps.readFileSync ?? ((p) => fsReadFileSync2(p, "utf8"));
@@ -28769,28 +28791,28 @@ function runAuditIntegrityChecks(deps = {}) {
28769
28791
  });
28770
28792
  continue;
28771
28793
  }
28772
- if (!looksChained(text)) {
28794
+ const v = verifyAuditLog(text);
28795
+ const preamble = v.legacyRows > 0 ? `${v.legacyRows} pre-#1433 legacy preamble row(s) + ` : ``;
28796
+ const restarts = v.segments > 1 ? ` across ${v.segments} segment(s) (${v.segments - 1} broker restart re-anchor(s))` : ``;
28797
+ if (v.state === "legacy") {
28773
28798
  results.push({
28774
28799
  name: `${label} audit tamper-evidence`,
28775
28800
  status: "warn",
28776
- detail: `${path4} is present but its rows are NOT hash-chained \u2014 this is a pre-#1433 legacy log; tamper-evidence is inactive until the post-#1433 ${label} image is deployed`,
28801
+ detail: `${path4} is present but NO rows are hash-chained \u2014 this is a pre-#1433 legacy log; tamper-evidence is inactive until the post-#1433 ${label} image is deployed`,
28777
28802
  fix: `Run \`switchroom update\` to roll the ${label} image forward (#1433 added the chain). Expected during the rollout window; not itself a tamper signal.`
28778
28803
  });
28779
- continue;
28780
- }
28781
- const v = verifyAuditChain(text, CHAIN_GENESIS);
28782
- if (v.ok) {
28804
+ } else if (v.state === "ok") {
28783
28805
  results.push({
28784
28806
  name: `${label} audit chain valid`,
28785
28807
  status: "ok",
28786
- detail: `${v.rows} rows, hash chain intact from genesis`
28808
+ detail: `${preamble}${v.chainedRows} hash-chained row(s)${restarts}, every row self-consistent (tamper-evidence active)`
28787
28809
  });
28788
28810
  } else {
28789
28811
  results.push({
28790
28812
  name: `${label} audit chain BROKEN`,
28791
28813
  status: "fail",
28792
- detail: `${path4}: chain breaks at row ${v.brokenAtLine} \u2014 ${v.reason}. A row was edited, deleted, reordered, or the head was truncated (WS10-F2 tamper signal).`,
28793
- fix: `Treat the ${label} audit trail as compromised from row ${v.brokenAtLine} onward. Preserve the file for forensics; investigate host/broker compromise before trusting subsequent rows.`
28814
+ detail: `${path4}: tamper detected at file line ${v.brokenAtLine} \u2014 ${v.reason} (WS10-F2: a row was forensically rewritten). ${preamble}${v.chainedRows} prior chained row(s) verified.`,
28815
+ fix: `Treat the ${label} audit trail as compromised at/after file line ${v.brokenAtLine}. Preserve the file for forensics; investigate host/broker compromise before trusting subsequent rows.`
28794
28816
  });
28795
28817
  }
28796
28818
  }
@@ -46336,7 +46358,7 @@ function decodeResponse3(line) {
46336
46358
  const obj = JSON.parse(line);
46337
46359
  return ResponseSchema3.parse(obj);
46338
46360
  }
46339
- var MAX_FRAME_BYTES3, RequestEnvelope, AgentRestartRequestSchema, UpgradeStatusRequestSchema, GetStatusRequestSchema, AgentNameSchema, UpdateCheckRequestSchema, UpdateApplyRequestSchema, ApplyRequestSchema, AgentStartRequestSchema, AgentStopRequestSchema, AgentLogsRequestSchema, AgentExecRequestSchema, RequestSchema4, ResultSchema2, ResponseEnvelope, ResponseSchema3;
46361
+ var MAX_FRAME_BYTES3, RequestEnvelope, AgentRestartRequestSchema, UpgradeStatusRequestSchema, GetStatusRequestSchema, AgentNameSchema, UpdateCheckRequestSchema, UpdateApplyRequestSchema, ApplyRequestSchema, AgentStartRequestSchema, AgentStopRequestSchema, AgentLogsRequestSchema, AgentExecRequestSchema, DoctorRequestSchema, RequestSchema4, ResultSchema2, ResponseEnvelope, ResponseSchema3;
46340
46362
  var init_protocol4 = __esm(() => {
46341
46363
  init_zod();
46342
46364
  MAX_FRAME_BYTES3 = 64 * 1024;
@@ -46417,6 +46439,11 @@ var init_protocol4 = __esm(() => {
46417
46439
  argv: exports_external.array(exports_external.string().min(1)).min(1).max(32)
46418
46440
  })
46419
46441
  });
46442
+ DoctorRequestSchema = exports_external.object({
46443
+ ...RequestEnvelope,
46444
+ op: exports_external.literal("doctor"),
46445
+ args: exports_external.object({}).optional()
46446
+ });
46420
46447
  RequestSchema4 = exports_external.discriminatedUnion("op", [
46421
46448
  AgentRestartRequestSchema,
46422
46449
  UpgradeStatusRequestSchema,
@@ -46427,7 +46454,8 @@ var init_protocol4 = __esm(() => {
46427
46454
  AgentStartRequestSchema,
46428
46455
  AgentStopRequestSchema,
46429
46456
  AgentLogsRequestSchema,
46430
- AgentExecRequestSchema
46457
+ AgentExecRequestSchema,
46458
+ DoctorRequestSchema
46431
46459
  ]);
46432
46460
  ResultSchema2 = exports_external.enum(["started", "completed", "denied", "error"]);
46433
46461
  ResponseEnvelope = {
@@ -46886,8 +46914,8 @@ var {
46886
46914
  } = import__.default;
46887
46915
 
46888
46916
  // src/build-info.ts
46889
- var VERSION = "0.12.7";
46890
- var COMMIT_SHA = "0e4c146b";
46917
+ var VERSION = "0.12.8";
46918
+ var COMMIT_SHA = "e77841fe";
46891
46919
 
46892
46920
  // src/cli/agent.ts
46893
46921
  init_source();
@@ -58152,6 +58180,18 @@ class VaultBroker {
58152
58180
  });
58153
58181
  });
58154
58182
  }
58183
+ chownVaultToOperator() {
58184
+ const uidStr = process.env.SWITCHROOM_BROKER_OPERATOR_UID;
58185
+ if (uidStr === undefined)
58186
+ return;
58187
+ const uid = parseInt(uidStr, 10);
58188
+ if (!Number.isFinite(uid) || uid <= 0)
58189
+ return;
58190
+ try {
58191
+ if (existsSync33(this.vaultPath))
58192
+ chownSync(this.vaultPath, uid, uid);
58193
+ } catch {}
58194
+ }
58155
58195
  bindOperatorListener(socketPath, operatorUid) {
58156
58196
  const abs = resolve24(socketPath);
58157
58197
  const identity = socketPathToIdentity(abs);
@@ -58721,6 +58761,7 @@ class VaultBroker {
58721
58761
  socket.write(encodeResponse(errorResponse("INTERNAL", `Failed to persist: ${err?.message ?? "unknown"}`)));
58722
58762
  return;
58723
58763
  }
58764
+ this.chownVaultToOperator();
58724
58765
  this.auditLogger.write({
58725
58766
  ts: new Date().toISOString(),
58726
58767
  op: "put",
@@ -14775,6 +14775,11 @@ var AgentExecRequestSchema = exports_external.object({
14775
14775
  argv: exports_external.array(exports_external.string().min(1)).min(1).max(32)
14776
14776
  })
14777
14777
  });
14778
+ var DoctorRequestSchema = exports_external.object({
14779
+ ...RequestEnvelope,
14780
+ op: exports_external.literal("doctor"),
14781
+ args: exports_external.object({}).optional()
14782
+ });
14778
14783
  var RequestSchema = exports_external.discriminatedUnion("op", [
14779
14784
  AgentRestartRequestSchema,
14780
14785
  UpgradeStatusRequestSchema,
@@ -14785,7 +14790,8 @@ var RequestSchema = exports_external.discriminatedUnion("op", [
14785
14790
  AgentStartRequestSchema,
14786
14791
  AgentStopRequestSchema,
14787
14792
  AgentLogsRequestSchema,
14788
- AgentExecRequestSchema
14793
+ AgentExecRequestSchema,
14794
+ DoctorRequestSchema
14789
14795
  ]);
14790
14796
  var ResultSchema = exports_external.enum(["started", "completed", "denied", "error"]);
14791
14797
  var ResponseEnvelope = {
@@ -15496,6 +15502,9 @@ class HostdServer {
15496
15502
  case "agent_exec":
15497
15503
  resp = await this.handleAgentExec(req, started);
15498
15504
  break;
15505
+ case "doctor":
15506
+ resp = await this.handleDoctor(req, started);
15507
+ break;
15499
15508
  }
15500
15509
  } catch (err) {
15501
15510
  resp = errorResponse(req.request_id, `hostd dispatch failed: ${err.message}`, Date.now() - started);
@@ -15540,6 +15549,8 @@ class HostdServer {
15540
15549
  if (req.args.name === caller.name)
15541
15550
  return null;
15542
15551
  return callerAdmin ? null : `${req.op} cross-agent requires admin: true on caller "${caller.name}"`;
15552
+ case "doctor":
15553
+ return callerAdmin ? null : `doctor requires admin: true on caller "${caller.name}"`;
15543
15554
  }
15544
15555
  }
15545
15556
  async handleAgentRestart(req, caller, started) {
@@ -15604,6 +15615,18 @@ class HostdServer {
15604
15615
  stderr_tail: tail(res.stderr)
15605
15616
  };
15606
15617
  }
15618
+ async handleDoctor(req, started) {
15619
+ const res = await this.runSwitchroom(["doctor"]);
15620
+ return {
15621
+ v: 1,
15622
+ request_id: req.request_id,
15623
+ result: "completed",
15624
+ exit_code: res.exit_code,
15625
+ duration_ms: Date.now() - started,
15626
+ stdout_tail: tail(res.stdout),
15627
+ stderr_tail: tail(res.stderr)
15628
+ };
15629
+ }
15607
15630
  missingApplyAssets() {
15608
15631
  const root = this.opts.applyAssetsRoot ?? resolve5(import.meta.dirname, "../..");
15609
15632
  return [
@@ -15893,6 +15893,18 @@ class VaultBroker {
15893
15893
  });
15894
15894
  });
15895
15895
  }
15896
+ chownVaultToOperator() {
15897
+ const uidStr = process.env.SWITCHROOM_BROKER_OPERATOR_UID;
15898
+ if (uidStr === undefined)
15899
+ return;
15900
+ const uid = parseInt(uidStr, 10);
15901
+ if (!Number.isFinite(uid) || uid <= 0)
15902
+ return;
15903
+ try {
15904
+ if (existsSync8(this.vaultPath))
15905
+ chownSync(this.vaultPath, uid, uid);
15906
+ } catch {}
15907
+ }
15896
15908
  bindOperatorListener(socketPath, operatorUid) {
15897
15909
  const abs = resolve5(socketPath);
15898
15910
  const identity2 = socketPathToIdentity(abs);
@@ -16462,6 +16474,7 @@ class VaultBroker {
16462
16474
  socket.write(encodeResponse(errorResponse("INTERNAL", `Failed to persist: ${err?.message ?? "unknown"}`)));
16463
16475
  return;
16464
16476
  }
16477
+ this.chownVaultToOperator();
16465
16478
  this.auditLogger.write({
16466
16479
  ts: new Date().toISOString(),
16467
16480
  op: "put",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.12.7",
3
+ "version": "0.12.8",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42409,6 +42409,11 @@ var AgentExecRequestSchema = exports_external.object({
42409
42409
  argv: exports_external.array(exports_external.string().min(1)).min(1).max(32)
42410
42410
  })
42411
42411
  });
42412
+ var DoctorRequestSchema = exports_external.object({
42413
+ ...RequestEnvelope,
42414
+ op: exports_external.literal("doctor"),
42415
+ args: exports_external.object({}).optional()
42416
+ });
42412
42417
  var RequestSchema3 = exports_external.discriminatedUnion("op", [
42413
42418
  AgentRestartRequestSchema,
42414
42419
  UpgradeStatusRequestSchema,
@@ -42419,7 +42424,8 @@ var RequestSchema3 = exports_external.discriminatedUnion("op", [
42419
42424
  AgentStartRequestSchema,
42420
42425
  AgentStopRequestSchema,
42421
42426
  AgentLogsRequestSchema,
42422
- AgentExecRequestSchema
42427
+ AgentExecRequestSchema,
42428
+ DoctorRequestSchema
42423
42429
  ]);
42424
42430
  var ResultSchema = exports_external.enum(["started", "completed", "denied", "error"]);
42425
42431
  var ResponseEnvelope = {
@@ -46606,11 +46612,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
46606
46612
  }
46607
46613
 
46608
46614
  // ../src/build-info.ts
46609
- var VERSION = "0.12.7";
46610
- var COMMIT_SHA = "0e4c146b";
46611
- var COMMIT_DATE = "2026-05-18T21:50:29Z";
46612
- var LATEST_PR = 1517;
46613
- var COMMITS_AHEAD_OF_TAG = 3;
46615
+ var VERSION = "0.12.8";
46616
+ var COMMIT_SHA = "e77841fe";
46617
+ var COMMIT_DATE = "2026-05-18T23:13:43Z";
46618
+ var LATEST_PR = 1521;
46619
+ var COMMITS_AHEAD_OF_TAG = 7;
46614
46620
 
46615
46621
  // gateway/boot-version.ts
46616
46622
  function formatRelativeAgo(iso) {
@@ -54022,23 +54028,56 @@ bot.command("usage", async (ctx) => {
54022
54028
  }
54023
54029
  await switchroomReply(ctx, formatQuotaBlock(result.data), { html: true });
54024
54030
  });
54031
+ function buildDoctorScopeKeyboard() {
54032
+ return new import_grammy9.InlineKeyboard().text("\uD83E\uDE7A Whole fleet", "dr:fleet").text("\uD83E\uDE7A This agent", "dr:self");
54033
+ }
54034
+ function formatDoctorReport(raw) {
54035
+ const trimmed = stripAnsi2(raw).trim();
54036
+ if (!trimmed)
54037
+ return "doctor: no output";
54038
+ const pretty = trimmed.replace(/^( *)\u2713 /gm, "$1\uD83D\uDFE2 ").replace(/^( *)\u2717 /gm, "$1\uD83D\uDD34 ").replace(/^( *)! /gm, "$1\uD83D\uDFE1 ");
54039
+ return preBlock(formatSwitchroomOutput(pretty));
54040
+ }
54041
+ async function renderSelfDoctor(ctx) {
54042
+ let output;
54043
+ try {
54044
+ output = switchroomExecCombined(["doctor"], 30000);
54045
+ } catch (err) {
54046
+ output = err.stdout ?? err.message ?? "doctor failed";
54047
+ }
54048
+ await switchroomReply(ctx, formatDoctorReport(output), { html: true });
54049
+ }
54050
+ async function renderFleetDoctor(ctx) {
54051
+ const resp = await tryHostdDispatch(getMyAgentName(), {
54052
+ v: 1,
54053
+ op: "doctor",
54054
+ request_id: hostdRequestId("gw-doctor")
54055
+ });
54056
+ if (resp === "not-configured") {
54057
+ await switchroomReply(ctx, "\uD83E\uDE7A Whole-fleet doctor needs hostd, which isn\u2019t configured here \u2014 showing this agent instead.", { html: true });
54058
+ await renderSelfDoctor(ctx);
54059
+ return;
54060
+ }
54061
+ if (resp.result === "denied") {
54062
+ await switchroomReply(ctx, `\uD83E\uDE7A <b>Whole-fleet doctor denied by hostd:</b>
54063
+ ${preBlock(formatSwitchroomOutput(resp.error ?? "admin required"))}`, { html: true });
54064
+ return;
54065
+ }
54066
+ const body = resp.stdout_tail?.trim() || resp.error || "(no output from hostd doctor)";
54067
+ await switchroomReply(ctx, formatDoctorReport(body), { html: true });
54068
+ }
54025
54069
  bot.command("doctor", async (ctx) => {
54026
54070
  if (!isAuthorizedSender(ctx))
54027
54071
  return;
54028
54072
  try {
54029
- let output;
54030
- try {
54031
- output = switchroomExecCombined(["doctor"], 30000);
54032
- } catch (err) {
54033
- output = err.stdout ?? err.message ?? "doctor failed";
54034
- }
54035
- const trimmed = stripAnsi2(output).trim();
54036
- if (!trimmed) {
54037
- await switchroomReply(ctx, "doctor: no output");
54073
+ if (AGENT_ADMIN && hostdWillBeUsed(getMyAgentName())) {
54074
+ await switchroomReply(ctx, "\uD83E\uDE7A <b>Doctor</b> \u2014 which scope?", {
54075
+ html: true,
54076
+ reply_markup: buildDoctorScopeKeyboard()
54077
+ });
54038
54078
  return;
54039
54079
  }
54040
- const pretty = trimmed.replace(/^( *)\u2713 /gm, "$1\uD83D\uDFE2 ").replace(/^( *)\u2717 /gm, "$1\uD83D\uDD34 ").replace(/^( *)! /gm, "$1\uD83D\uDFE1 ");
54041
- await switchroomReply(ctx, preBlock(formatSwitchroomOutput(pretty)), { html: true });
54080
+ await renderSelfDoctor(ctx);
54042
54081
  } catch (err) {
54043
54082
  await switchroomReply(ctx, `<b>doctor failed:</b>
54044
54083
  ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: true });
@@ -54173,6 +54212,26 @@ bot.on("callback_query:data", async (ctx) => {
54173
54212
  await handleVaultDeferCallback(ctx, data);
54174
54213
  return;
54175
54214
  }
54215
+ if (data.startsWith("dr:")) {
54216
+ if (!isAuthorizedSender(ctx)) {
54217
+ await ctx.answerCallbackQuery({ text: "Not authorized." }).catch(() => {});
54218
+ return;
54219
+ }
54220
+ const scope = data.slice(3);
54221
+ await ctx.answerCallbackQuery({
54222
+ text: scope === "fleet" ? "\uD83E\uDE7A Running fleet doctor\u2026" : "\uD83E\uDE7A Running doctor\u2026"
54223
+ }).catch(() => {});
54224
+ try {
54225
+ if (scope === "fleet")
54226
+ await renderFleetDoctor(ctx);
54227
+ else
54228
+ await renderSelfDoctor(ctx);
54229
+ } catch (err) {
54230
+ await switchroomReply(ctx, `<b>doctor failed:</b>
54231
+ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: true });
54232
+ }
54233
+ return;
54234
+ }
54176
54235
  if (data.startsWith("vg:")) {
54177
54236
  await handleVaultGrantCallback(ctx, data);
54178
54237
  return;
@@ -11652,16 +11652,81 @@ bot.command('usage', async ctx => {
11652
11652
  await switchroomReply(ctx, formatQuotaBlock(result.data), { html: true })
11653
11653
  })
11654
11654
 
11655
+ // Two-button scope picker shown to admin agents (when hostd is
11656
+ // reachable) so the operator can run doctor for the WHOLE FLEET
11657
+ // (host-side via hostd — has the docker socket) or just THIS agent
11658
+ // (in-container, degraded). callback_data is tiny (`dr:fleet` /
11659
+ // `dr:self`) — well within Telegram's 64-byte limit.
11660
+ function buildDoctorScopeKeyboard(): InlineKeyboard {
11661
+ return new InlineKeyboard()
11662
+ .text('🩺 Whole fleet', 'dr:fleet')
11663
+ .text('🩺 This agent', 'dr:self')
11664
+ }
11665
+
11666
+ // Shared report prettifier: ANSI-strip + status-glyph swap + pre block.
11667
+ // Identical rendering for the in-container and the hostd fleet report.
11668
+ function formatDoctorReport(raw: string): string {
11669
+ const trimmed = stripAnsi(raw).trim()
11670
+ if (!trimmed) return 'doctor: no output'
11671
+ const pretty = trimmed
11672
+ .replace(/^( *)✓ /gm, '$1🟢 ')
11673
+ .replace(/^( *)✗ /gm, '$1🔴 ')
11674
+ .replace(/^( *)! /gm, '$1🟡 ')
11675
+ return preBlock(formatSwitchroomOutput(pretty))
11676
+ }
11677
+
11678
+ // In-container `switchroom doctor` — this agent's own (degraded: no
11679
+ // docker socket) view. The original /doctor behaviour, unchanged.
11680
+ async function renderSelfDoctor(ctx: Context): Promise<void> {
11681
+ let output: string
11682
+ try { output = switchroomExecCombined(['doctor'], 30000) }
11683
+ catch (err: unknown) { output = (err as any).stdout ?? (err as any).message ?? 'doctor failed' }
11684
+ await switchroomReply(ctx, formatDoctorReport(output), { html: true })
11685
+ }
11686
+
11687
+ // Whole-fleet `switchroom doctor` via hostd: it runs host-side where
11688
+ // the docker socket exists, so it sees every container + singleton
11689
+ // instead of the degraded in-container reading. Read-only verb; the
11690
+ // daemon independently enforces the admin gate (path-as-identity), so
11691
+ // this is the audited boundary even though the gateway only offers
11692
+ // the button to admin agents.
11693
+ async function renderFleetDoctor(ctx: Context): Promise<void> {
11694
+ const resp = await tryHostdDispatch(getMyAgentName(), {
11695
+ v: 1,
11696
+ op: 'doctor',
11697
+ request_id: hostdRequestId('gw-doctor'),
11698
+ })
11699
+ if (resp === 'not-configured') {
11700
+ await switchroomReply(ctx, '🩺 Whole-fleet doctor needs hostd, which isn’t configured here — showing this agent instead.', { html: true })
11701
+ await renderSelfDoctor(ctx)
11702
+ return
11703
+ }
11704
+ if (resp.result === 'denied') {
11705
+ await switchroomReply(ctx, `🩺 <b>Whole-fleet doctor denied by hostd:</b>\n${preBlock(formatSwitchroomOutput(resp.error ?? 'admin required'))}`, { html: true })
11706
+ return
11707
+ }
11708
+ // `completed` (including `switchroom doctor` exit 1 = "found
11709
+ // problems", which the handler classifies as completed) or `error`:
11710
+ // the report on stdout (or the error text) is exactly what the
11711
+ // operator wants surfaced.
11712
+ const body = resp.stdout_tail?.trim() || resp.error || '(no output from hostd doctor)'
11713
+ await switchroomReply(ctx, formatDoctorReport(body), { html: true })
11714
+ }
11715
+
11655
11716
  bot.command('doctor', async ctx => {
11656
11717
  if (!isAuthorizedSender(ctx)) return
11657
11718
  try {
11658
- let output: string
11659
- try { output = switchroomExecCombined(['doctor'], 30000) }
11660
- catch (err: unknown) { output = (err as any).stdout ?? (err as any).message ?? 'doctor failed' }
11661
- const trimmed = stripAnsi(output).trim()
11662
- if (!trimmed) { await switchroomReply(ctx, 'doctor: no output'); return }
11663
- const pretty = trimmed.replace(/^( *)✓ /gm, '$1🟢 ').replace(/^( *)✗ /gm, '$1🔴 ').replace(/^( *)! /gm, '$1🟡 ')
11664
- await switchroomReply(ctx, preBlock(formatSwitchroomOutput(pretty)), { html: true })
11719
+ // Admin agents with hostd reachable choose scope (one tap, no
11720
+ // approval card doctor is read-only). Everyone else keeps the
11721
+ // original zero-extra-tap in-container behaviour.
11722
+ if (AGENT_ADMIN && hostdWillBeUsed(getMyAgentName())) {
11723
+ await switchroomReply(ctx, '🩺 <b>Doctor</b> — which scope?', {
11724
+ html: true,
11725
+ reply_markup: buildDoctorScopeKeyboard(),
11726
+ })
11727
+ return
11728
+ }
11729
+ await renderSelfDoctor(ctx)
11665
11730
  } catch (err: unknown) {
11666
11731
  await switchroomReply(ctx, `<b>doctor failed:</b>\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
11667
11732
  }
@@ -11820,6 +11885,30 @@ bot.on('callback_query:data', async ctx => {
11820
11885
  return
11821
11886
  }
11822
11887
 
11888
+ // dr:<scope> — /doctor scope picker (only shown to admin agents
11889
+ // with hostd reachable). `dr:fleet` → host-side hostd `doctor`
11890
+ // (full fleet); `dr:self` → in-container doctor. Read-only, no
11891
+ // approval card. Same sender-auth as the /doctor command itself —
11892
+ // every callback family must re-auth (the absence of this check
11893
+ // was a past vulnerability class; see apv:/drvpick: notes above).
11894
+ if (data.startsWith('dr:')) {
11895
+ if (!isAuthorizedSender(ctx)) {
11896
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
11897
+ return
11898
+ }
11899
+ const scope = data.slice(3)
11900
+ await ctx.answerCallbackQuery({
11901
+ text: scope === 'fleet' ? '🩺 Running fleet doctor…' : '🩺 Running doctor…',
11902
+ }).catch(() => {})
11903
+ try {
11904
+ if (scope === 'fleet') await renderFleetDoctor(ctx)
11905
+ else await renderSelfDoctor(ctx)
11906
+ } catch (err: unknown) {
11907
+ await switchroomReply(ctx, `<b>doctor failed:</b>\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
11908
+ }
11909
+ return
11910
+ }
11911
+
11823
11912
  // Issue #228: vault grant management callbacks.
11824
11913
  // vg:revoke:<id> — show confirmation card
11825
11914
  // vg:confirm:<id> — execute revoke