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.
package/dist/cli/switchroom.js
CHANGED
|
@@ -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
|
|
27275
|
+
function verifyAuditLog(fullText) {
|
|
27276
27276
|
const rawLines = fullText.split(`
|
|
27277
27277
|
`);
|
|
27278
|
-
let
|
|
27279
|
-
let
|
|
27280
|
-
let
|
|
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
|
-
|
|
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
|
-
|
|
27294
|
-
|
|
27295
|
-
|
|
27296
|
-
|
|
27297
|
-
|
|
27298
|
-
|
|
27299
|
-
|
|
27300
|
-
|
|
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 {
|
|
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
|
-
|
|
27307
|
-
|
|
27338
|
+
chainedRows++;
|
|
27339
|
+
prevSeq = _seq;
|
|
27340
|
+
prevHash = _hash;
|
|
27308
27341
|
}
|
|
27309
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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}:
|
|
28793
|
-
fix: `Treat the ${label} audit trail as compromised
|
|
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.
|
|
46890
|
-
var COMMIT_SHA = "
|
|
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
|
@@ -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.
|
|
46610
|
-
var COMMIT_SHA = "
|
|
46611
|
-
var COMMIT_DATE = "2026-05-
|
|
46612
|
-
var LATEST_PR =
|
|
46613
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
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
|
-
|
|
54030
|
-
|
|
54031
|
-
|
|
54032
|
-
|
|
54033
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11659
|
-
|
|
11660
|
-
|
|
11661
|
-
|
|
11662
|
-
|
|
11663
|
-
|
|
11664
|
-
|
|
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
|