switchroom 0.13.35 → 0.13.37
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 +141 -7
- package/dist/host-control/main.js +80 -32
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +158 -26
- package/telegram-plugin/gateway/config-approval-handler.test.ts +188 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +170 -15
- package/telegram-plugin/gateway/diff-preview-card.test.ts +2 -2
- package/telegram-plugin/gateway/diff-preview-card.ts +2 -2
- package/telegram-plugin/gateway/drive-write-approval.test.ts +70 -0
- package/telegram-plugin/gateway/drive-write-approval.ts +51 -2
- package/telegram-plugin/gateway/gateway.ts +42 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +10 -1
- package/telegram-plugin/gateway/oversize-card-body.test.ts +108 -0
- package/telegram-plugin/gateway/oversize-card-body.ts +114 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +118 -41
- package/telegram-plugin/hooks/silent-end-scan.mjs +190 -0
- package/telegram-plugin/pending-work-progress.ts +37 -1
- package/telegram-plugin/tests/pending-work-progress.test.ts +134 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +242 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +314 -0
- package/telegram-plugin/tests/silent-end.test.ts +122 -38
package/dist/cli/switchroom.js
CHANGED
|
@@ -23247,6 +23247,9 @@ function emitAgentService(lines, a, imageTag, buildMode, buildContext, homePrefi
|
|
|
23247
23247
|
if (existsSync12(`${hostHomeForChecks}/.switchroom/skills`)) {
|
|
23248
23248
|
lines.push(` - ${homePrefix}/.switchroom/skills:${homePrefix}/.switchroom/skills:ro`);
|
|
23249
23249
|
}
|
|
23250
|
+
if (existsSync12(`${hostHomeForChecks}/.switchroom/mcp-launchers`)) {
|
|
23251
|
+
lines.push(` - ${homePrefix}/.switchroom/mcp-launchers:${homePrefix}/.switchroom/mcp-launchers:ro`);
|
|
23252
|
+
}
|
|
23250
23253
|
if (existsSync12(`${hostHomeForChecks}/.switchroom/credentials/${a.name}`)) {
|
|
23251
23254
|
lines.push(` - ${homePrefix}/.switchroom/credentials/${a.name}:${homePrefix}/.switchroom/credentials:ro`);
|
|
23252
23255
|
}
|
|
@@ -29681,6 +29684,7 @@ __export(exports_doctor, {
|
|
|
29681
29684
|
deriveEd25519PublicKeyBytes: () => deriveEd25519PublicKeyBytes,
|
|
29682
29685
|
classifyReadError: () => classifyReadError,
|
|
29683
29686
|
checkVaultBrokerSocketPairs: () => checkVaultBrokerSocketPairs,
|
|
29687
|
+
checkUserDeclaredMcps: () => checkUserDeclaredMcps,
|
|
29684
29688
|
checkTelegram: () => checkTelegram,
|
|
29685
29689
|
checkStartShStale: () => checkStartShStale,
|
|
29686
29690
|
checkSkillsPrerequisites: () => checkSkillsPrerequisites,
|
|
@@ -29978,6 +29982,33 @@ function checkConfig(config, configPath) {
|
|
|
29978
29982
|
});
|
|
29979
29983
|
return results;
|
|
29980
29984
|
}
|
|
29985
|
+
function checkUserDeclaredMcps(name, agentConfig, config, renderedMcpServers) {
|
|
29986
|
+
const resolved = resolveAgentConfig(config.defaults, config.profiles, agentConfig);
|
|
29987
|
+
const declaredMcp = resolved.mcp_servers ?? {};
|
|
29988
|
+
const declaredKeys = Object.entries(declaredMcp).filter(([, v]) => v !== false).map(([k]) => k);
|
|
29989
|
+
const renderedKeys = Object.keys(renderedMcpServers);
|
|
29990
|
+
const missing = declaredKeys.filter((k) => !renderedKeys.includes(k));
|
|
29991
|
+
if (declaredKeys.length === 0) {
|
|
29992
|
+
return {
|
|
29993
|
+
name: `${name}: user-declared MCPs`,
|
|
29994
|
+
status: "skip",
|
|
29995
|
+
detail: "no user-declared mcp_servers in switchroom.yaml"
|
|
29996
|
+
};
|
|
29997
|
+
}
|
|
29998
|
+
if (missing.length === 0) {
|
|
29999
|
+
return {
|
|
30000
|
+
name: `${name}: user-declared MCPs`,
|
|
30001
|
+
status: "ok",
|
|
30002
|
+
detail: `${declaredKeys.length} declared, all in .mcp.json (${declaredKeys.join(", ")})`
|
|
30003
|
+
};
|
|
30004
|
+
}
|
|
30005
|
+
return {
|
|
30006
|
+
name: `${name}: user-declared MCPs`,
|
|
30007
|
+
status: "warn",
|
|
30008
|
+
detail: `${missing.length}/${declaredKeys.length} declared but missing from .mcp.json: ${missing.join(", ")}`,
|
|
30009
|
+
fix: `Run \`switchroom agent reconcile ${name} --restart\`. If the entry still doesn't appear, check switchroom.yaml shape (defaults.mcp_servers.<key> or agents.${name}.mcp_servers.<key>).`
|
|
30010
|
+
};
|
|
30011
|
+
}
|
|
29981
30012
|
function checkLegacyState() {
|
|
29982
30013
|
const results = [];
|
|
29983
30014
|
const h = process.env.HOME ?? "/root";
|
|
@@ -30739,6 +30770,7 @@ function checkAgents(config, configPath) {
|
|
|
30739
30770
|
detail: memoryEnabled ? "switchroom-telegram + hindsight" : "switchroom-telegram"
|
|
30740
30771
|
});
|
|
30741
30772
|
}
|
|
30773
|
+
results.push(checkUserDeclaredMcps(name, agentConfig, config, mcp.mcpServers ?? {}));
|
|
30742
30774
|
} catch (err) {
|
|
30743
30775
|
results.push({
|
|
30744
30776
|
name: `${name}: .mcp.json`,
|
|
@@ -31354,6 +31386,7 @@ var init_doctor = __esm(() => {
|
|
|
31354
31386
|
init_doctor_status();
|
|
31355
31387
|
init_vault();
|
|
31356
31388
|
init_loader();
|
|
31389
|
+
init_merge();
|
|
31357
31390
|
init_paths();
|
|
31358
31391
|
init_helpers();
|
|
31359
31392
|
init_lifecycle();
|
|
@@ -47711,8 +47744,8 @@ var {
|
|
|
47711
47744
|
} = import__.default;
|
|
47712
47745
|
|
|
47713
47746
|
// src/build-info.ts
|
|
47714
|
-
var VERSION = "0.13.
|
|
47715
|
-
var COMMIT_SHA = "
|
|
47747
|
+
var VERSION = "0.13.37";
|
|
47748
|
+
var COMMIT_SHA = "623c57e0";
|
|
47716
47749
|
|
|
47717
47750
|
// src/cli/agent.ts
|
|
47718
47751
|
init_source();
|
|
@@ -49460,6 +49493,14 @@ function scaffoldAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchro
|
|
|
49460
49493
|
mcpServers[gdrive.key] = gdrive.value;
|
|
49461
49494
|
}
|
|
49462
49495
|
}
|
|
49496
|
+
if (agentConfig.mcp_servers) {
|
|
49497
|
+
const filtered = filterMcpServers(agentConfig.mcp_servers);
|
|
49498
|
+
if (filtered) {
|
|
49499
|
+
for (const [key, value] of Object.entries(filtered)) {
|
|
49500
|
+
mcpServers[key] = value;
|
|
49501
|
+
}
|
|
49502
|
+
}
|
|
49503
|
+
}
|
|
49463
49504
|
writeIfChanged(mcpJsonPath, () => JSON.stringify({ mcpServers }, null, 2) + `
|
|
49464
49505
|
`, created, skipped, 384);
|
|
49465
49506
|
mcpServerKeysToTrust = Object.keys(mcpServers);
|
|
@@ -50504,6 +50545,14 @@ ${body}
|
|
|
50504
50545
|
mcpServers[gdrive.key] = gdrive.value;
|
|
50505
50546
|
}
|
|
50506
50547
|
}
|
|
50548
|
+
if (agentConfig.mcp_servers) {
|
|
50549
|
+
const filtered = filterMcpServers(agentConfig.mcp_servers);
|
|
50550
|
+
if (filtered) {
|
|
50551
|
+
for (const [key, value] of Object.entries(filtered)) {
|
|
50552
|
+
mcpServers[key] = value;
|
|
50553
|
+
}
|
|
50554
|
+
}
|
|
50555
|
+
}
|
|
50507
50556
|
const mcpJson = { mcpServers };
|
|
50508
50557
|
const after = JSON.stringify(mcpJson, null, 2) + `
|
|
50509
50558
|
`;
|
|
@@ -61580,6 +61629,23 @@ function registerVaultBackupCommand(vault, program3) {
|
|
|
61580
61629
|
});
|
|
61581
61630
|
}
|
|
61582
61631
|
|
|
61632
|
+
// src/cli/vault-denied-envelope.ts
|
|
61633
|
+
var ENVELOPE_SENTINEL = "ERROR-ENVELOPE:";
|
|
61634
|
+
function writeVaultDeniedEnvelope(vaultKey, brokerCode, human) {
|
|
61635
|
+
const envelope = {
|
|
61636
|
+
v: 1,
|
|
61637
|
+
code: "VAULT-BROKER-DENIED",
|
|
61638
|
+
human: `${brokerCode}: ${human}`,
|
|
61639
|
+
fix: {
|
|
61640
|
+
kind: "request_vault_grant",
|
|
61641
|
+
vault_key: vaultKey
|
|
61642
|
+
},
|
|
61643
|
+
request_id: `vault-cli-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
|
61644
|
+
};
|
|
61645
|
+
process.stderr.write(`${ENVELOPE_SENTINEL} ${JSON.stringify(envelope)}
|
|
61646
|
+
`);
|
|
61647
|
+
}
|
|
61648
|
+
|
|
61583
61649
|
// src/cli/vault.ts
|
|
61584
61650
|
function isSandboxContext() {
|
|
61585
61651
|
return process.env.SWITCHROOM_RUNTIME === "docker";
|
|
@@ -61805,6 +61871,7 @@ function registerVaultCommand(program3) {
|
|
|
61805
61871
|
}
|
|
61806
61872
|
process.stderr.write(`VAULT-BROKER-DENIED [${result.code}]: ${result.msg}
|
|
61807
61873
|
`);
|
|
61874
|
+
writeVaultDeniedEnvelope(key, result.code, result.msg);
|
|
61808
61875
|
process.exit(VAULT_EXIT_DENIED);
|
|
61809
61876
|
}
|
|
61810
61877
|
if (inSandbox) {
|
|
@@ -62020,6 +62087,7 @@ Push passphrase to broker for future requests? [Y/n]: `);
|
|
|
62020
62087
|
process.stderr.write(`VAULT-BROKER-DENIED [${result.code}]: ${result.msg}
|
|
62021
62088
|
` + `${recoveryHint("denied", key)}
|
|
62022
62089
|
`);
|
|
62090
|
+
writeVaultDeniedEnvelope(key, result.code, result.msg);
|
|
62023
62091
|
process.exit(2);
|
|
62024
62092
|
}
|
|
62025
62093
|
} else {
|
|
@@ -76198,7 +76266,34 @@ function denyPendingScheduleEntry(opts) {
|
|
|
76198
76266
|
|
|
76199
76267
|
// src/cli/agent-config-write.ts
|
|
76200
76268
|
import { existsSync as existsSync73, readFileSync as readFileSync59 } from "node:fs";
|
|
76269
|
+
import { randomUUID as randomUUID5 } from "node:crypto";
|
|
76201
76270
|
var MAX_ENTRIES_PER_AGENT = 20;
|
|
76271
|
+
var MIN_CRON_INTERVAL_MIN = 5;
|
|
76272
|
+
function extractCronSmallestGapMin(expr) {
|
|
76273
|
+
const fields = expr.trim().split(/\s+/);
|
|
76274
|
+
if (fields.length < 5)
|
|
76275
|
+
return 0;
|
|
76276
|
+
const min = fields[0];
|
|
76277
|
+
if (min === "*")
|
|
76278
|
+
return 1;
|
|
76279
|
+
const step = min.match(/^\*\/(\d+)$/);
|
|
76280
|
+
if (step)
|
|
76281
|
+
return Number(step[1]);
|
|
76282
|
+
if (min.includes(",")) {
|
|
76283
|
+
const parts = min.split(",").map((s) => Number(s)).filter((n) => Number.isFinite(n));
|
|
76284
|
+
if (parts.length >= 2) {
|
|
76285
|
+
const sorted = [...parts].sort((a, b) => a - b);
|
|
76286
|
+
let smallest = Infinity;
|
|
76287
|
+
for (let i = 1;i < sorted.length; i++) {
|
|
76288
|
+
const gap = sorted[i] - sorted[i - 1];
|
|
76289
|
+
if (gap > 0 && gap < smallest)
|
|
76290
|
+
smallest = gap;
|
|
76291
|
+
}
|
|
76292
|
+
return Number.isFinite(smallest) ? smallest : 0;
|
|
76293
|
+
}
|
|
76294
|
+
}
|
|
76295
|
+
return 0;
|
|
76296
|
+
}
|
|
76202
76297
|
function checkOperatorContext(verb, env2 = process.env) {
|
|
76203
76298
|
if (env2.SWITCHROOM_OPERATOR === "1")
|
|
76204
76299
|
return { ok: true };
|
|
@@ -76211,8 +76306,45 @@ function checkOperatorContext(verb, env2 = process.env) {
|
|
|
76211
76306
|
}
|
|
76212
76307
|
return { ok: true };
|
|
76213
76308
|
}
|
|
76309
|
+
function buildEnvelopeForCode(code, message, extra) {
|
|
76310
|
+
const request_id = `agent-config-${randomUUID5()}`;
|
|
76311
|
+
if (code === "E_CRON_TOO_FREQUENT") {
|
|
76312
|
+
return {
|
|
76313
|
+
v: 1,
|
|
76314
|
+
code,
|
|
76315
|
+
human: message,
|
|
76316
|
+
fix: {
|
|
76317
|
+
kind: "quota_exceeded",
|
|
76318
|
+
quota: "cron_min_interval_minutes",
|
|
76319
|
+
current: typeof extra.requested_interval_min === "number" ? extra.requested_interval_min : 0,
|
|
76320
|
+
limit: MIN_CRON_INTERVAL_MIN
|
|
76321
|
+
},
|
|
76322
|
+
request_id
|
|
76323
|
+
};
|
|
76324
|
+
}
|
|
76325
|
+
if (code === "E_QUOTA_EXCEEDED") {
|
|
76326
|
+
const current = typeof extra.current === "number" ? extra.current : MAX_ENTRIES_PER_AGENT;
|
|
76327
|
+
return {
|
|
76328
|
+
v: 1,
|
|
76329
|
+
code,
|
|
76330
|
+
human: message,
|
|
76331
|
+
fix: {
|
|
76332
|
+
kind: "quota_exceeded",
|
|
76333
|
+
quota: "schedule_entries_per_agent",
|
|
76334
|
+
current,
|
|
76335
|
+
limit: MAX_ENTRIES_PER_AGENT
|
|
76336
|
+
},
|
|
76337
|
+
request_id
|
|
76338
|
+
};
|
|
76339
|
+
}
|
|
76340
|
+
return;
|
|
76341
|
+
}
|
|
76214
76342
|
function emitError(code, message, extra = {}) {
|
|
76215
|
-
|
|
76343
|
+
const error_envelope = buildEnvelopeForCode(code, message, extra);
|
|
76344
|
+
const line = { code, message, ...extra };
|
|
76345
|
+
if (error_envelope)
|
|
76346
|
+
line.error_envelope = error_envelope;
|
|
76347
|
+
process.stderr.write(JSON.stringify(line) + `
|
|
76216
76348
|
`);
|
|
76217
76349
|
}
|
|
76218
76350
|
function exitCodeFor(code) {
|
|
@@ -76276,7 +76408,8 @@ function scheduleAdd(opts) {
|
|
|
76276
76408
|
ok: false,
|
|
76277
76409
|
code: "E_CRON_TOO_FREQUENT",
|
|
76278
76410
|
message: "cron interval is tighter than the minimum (5 minutes)",
|
|
76279
|
-
exit: 9
|
|
76411
|
+
exit: 9,
|
|
76412
|
+
meta: { requested_interval_min: extractCronSmallestGapMin(opts.cronExpr) }
|
|
76280
76413
|
};
|
|
76281
76414
|
}
|
|
76282
76415
|
const rej = filterOverlaySecrets(dry.doc, "overlay");
|
|
@@ -76294,7 +76427,8 @@ function scheduleAdd(opts) {
|
|
|
76294
76427
|
ok: false,
|
|
76295
76428
|
code: "E_QUOTA_EXCEEDED",
|
|
76296
76429
|
message: `agent already has ${existing.length} overlay entries (max ${MAX_ENTRIES_PER_AGENT})`,
|
|
76297
|
-
exit: 9
|
|
76430
|
+
exit: 9,
|
|
76431
|
+
meta: { current: existing.length }
|
|
76298
76432
|
};
|
|
76299
76433
|
}
|
|
76300
76434
|
const hash2 = cronUnitHash(opts.cronExpr, opts.prompt);
|
|
@@ -76493,7 +76627,7 @@ function registerAgentConfigWriteCommands(program3) {
|
|
|
76493
76627
|
else if (process.env.SWITCHROOM_AGENT_NAME)
|
|
76494
76628
|
resolvedAgent = process.env.SWITCHROOM_AGENT_NAME;
|
|
76495
76629
|
if (!r.ok) {
|
|
76496
|
-
emitError(r.code, r.message);
|
|
76630
|
+
emitError(r.code, r.message, r.meta ?? {});
|
|
76497
76631
|
appendAudit(resolvedAgent, "schedule.add", { cron: opts.cron, prompt: opts.prompt, name: opts.name, code: r.code, would_recreate: false }, r.exit);
|
|
76498
76632
|
process.exit(r.exit);
|
|
76499
76633
|
}
|
|
@@ -76606,7 +76740,7 @@ function registerAgentConfigWriteCommands(program3) {
|
|
|
76606
76740
|
else if (process.env.SWITCHROOM_AGENT_NAME)
|
|
76607
76741
|
resolvedAgent = process.env.SWITCHROOM_AGENT_NAME;
|
|
76608
76742
|
if (!r.ok) {
|
|
76609
|
-
emitError(r.code, r.message);
|
|
76743
|
+
emitError(r.code, r.message, r.meta ?? {});
|
|
76610
76744
|
appendAudit(resolvedAgent, "schedule.remove", { ...opts, code: r.code }, r.exit);
|
|
76611
76745
|
process.exit(r.exit);
|
|
76612
76746
|
}
|
|
@@ -14920,16 +14920,6 @@ function deniedResponse(request_id, error, duration_ms = 0) {
|
|
|
14920
14920
|
error
|
|
14921
14921
|
};
|
|
14922
14922
|
}
|
|
14923
|
-
function errorResponse(request_id, error, duration_ms = 0) {
|
|
14924
|
-
return {
|
|
14925
|
-
v: 1,
|
|
14926
|
-
request_id,
|
|
14927
|
-
result: "error",
|
|
14928
|
-
exit_code: null,
|
|
14929
|
-
duration_ms,
|
|
14930
|
-
error
|
|
14931
|
-
};
|
|
14932
|
-
}
|
|
14933
14923
|
|
|
14934
14924
|
// src/analytics/error-friction.ts
|
|
14935
14925
|
import { createHash as createHash2 } from "node:crypto";
|
|
@@ -19515,6 +19505,11 @@ class ErrorBuilder {
|
|
|
19515
19505
|
return this;
|
|
19516
19506
|
}
|
|
19517
19507
|
docs(url) {
|
|
19508
|
+
try {
|
|
19509
|
+
new URL(url);
|
|
19510
|
+
} catch {
|
|
19511
|
+
throw new TypeError(`err().docs(): invalid URL: ${JSON.stringify(url)} — ErrorEnvelopeSchema requires a fully-qualified URL`);
|
|
19512
|
+
}
|
|
19518
19513
|
this._docs = url;
|
|
19519
19514
|
return this;
|
|
19520
19515
|
}
|
|
@@ -20391,6 +20386,14 @@ var CONFIG_APPROVAL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
|
20391
20386
|
function defaultApprovalId() {
|
|
20392
20387
|
return randomBytes(4).toString("hex");
|
|
20393
20388
|
}
|
|
20389
|
+
function formatConfigApprovalDenyError(approval, approvalId) {
|
|
20390
|
+
if (approval.denySource === "dispatch_failure") {
|
|
20391
|
+
const detail = approval.reason ?? "card dispatch failed";
|
|
20392
|
+
return `E_APPROVAL_DISPATCH_FAILED: ${detail} (approval_id=${approvalId})`;
|
|
20393
|
+
}
|
|
20394
|
+
const suffix = approval.reason ? `: ${approval.reason}` : "";
|
|
20395
|
+
return `E_DENIED: operator denied config_propose_edit${suffix} (approval_id=${approvalId})`;
|
|
20396
|
+
}
|
|
20394
20397
|
function unlinkSyncBestEffort(path2) {
|
|
20395
20398
|
try {
|
|
20396
20399
|
unlinkSync(path2);
|
|
@@ -20627,8 +20630,10 @@ class HostdServer {
|
|
|
20627
20630
|
resp = await this.handleConfigProposeEdit(req, caller, started);
|
|
20628
20631
|
break;
|
|
20629
20632
|
}
|
|
20630
|
-
} catch (
|
|
20631
|
-
|
|
20633
|
+
} catch (e) {
|
|
20634
|
+
const msg = e.message;
|
|
20635
|
+
resp = err("E_DISPATCH_FAILED", "hostd dispatch failed").why(msg).op(req.op).caller(caller.kind === "agent" ? "agent" : "operator").agentName(caller.kind === "agent" ? caller.name : undefined).build(req.request_id, Date.now() - started);
|
|
20636
|
+
resp = { ...resp, error: `hostd dispatch failed: ${msg}` };
|
|
20632
20637
|
}
|
|
20633
20638
|
await this.writeAudit({ caller, req, resp });
|
|
20634
20639
|
socket.write(encodeResponse(resp));
|
|
@@ -20924,7 +20929,7 @@ class HostdServer {
|
|
|
20924
20929
|
async handleConfigProposeEdit(req, caller, started) {
|
|
20925
20930
|
const enabled = this.opts.config.hostd?.config_edit_enabled === true;
|
|
20926
20931
|
if (!enabled) {
|
|
20927
|
-
return err("E_CONFIG_EDIT_DISABLED", "config_propose_edit is disabled").why("operator opt-in per RFC §3.3").fixFlipFlag("hostd.config_edit_enabled", true).docs("https://switchroom.dev/docs/config-edit#opt-in").op("config_propose_edit").caller(caller.kind === "agent" ? "agent" : "operator").agentName(caller.kind === "agent" ? caller.name : undefined).build(req.request_id, Date.now() - started);
|
|
20932
|
+
return err("E_CONFIG_EDIT_DISABLED", "config_propose_edit is disabled").why("operator opt-in per RFC §3.3").fixFlipFlag("hostd.config_edit_enabled", true).docs("https://switchroom.dev/docs/config-edit#opt-in").op("config_propose_edit").caller(caller.kind === "agent" ? "agent" : "operator").agentName(caller.kind === "agent" ? caller.name : undefined).asDenied().build(req.request_id, Date.now() - started);
|
|
20928
20933
|
}
|
|
20929
20934
|
const configPath = this.opts.configPath ?? req.args.target_path;
|
|
20930
20935
|
const verdict = validateConfigEdit({
|
|
@@ -20933,10 +20938,12 @@ class HostdServer {
|
|
|
20933
20938
|
unifiedDiff: req.args.unified_diff
|
|
20934
20939
|
});
|
|
20935
20940
|
if (!verdict.ok) {
|
|
20936
|
-
return
|
|
20941
|
+
return err(verdict.code, verdict.detail).fixBadInput("unified_diff").op("config_propose_edit").caller(caller.kind === "agent" ? "agent" : "operator").agentName(caller.kind === "agent" ? caller.name : undefined).build(req.request_id, Date.now() - started);
|
|
20937
20942
|
}
|
|
20938
20943
|
if (!this.opts.approvalGateway) {
|
|
20939
|
-
return
|
|
20944
|
+
return err("E_NO_APPROVAL_GATEWAY", "validation passed but hostd was started without an approval-gateway wiring; the operator build is missing the telegram-plugin link").fixOperatorAction("infra", [
|
|
20945
|
+
"ensure hostd was launched with --approval-gateway / telegram-plugin link"
|
|
20946
|
+
]).op("config_propose_edit").caller(caller.kind === "agent" ? "agent" : "operator").agentName(caller.kind === "agent" ? caller.name : undefined).build(req.request_id, Date.now() - started);
|
|
20940
20947
|
}
|
|
20941
20948
|
const callerName = caller.kind === "agent" ? caller.name : "operator";
|
|
20942
20949
|
const approvalId = (this.opts.generateApprovalId ?? defaultApprovalId)();
|
|
@@ -20948,35 +20955,45 @@ class HostdServer {
|
|
|
20948
20955
|
timeoutMs: CONFIG_APPROVAL_TIMEOUT_MS
|
|
20949
20956
|
});
|
|
20950
20957
|
if (approval.verdict === "deny") {
|
|
20951
|
-
|
|
20958
|
+
const legacy = formatConfigApprovalDenyError(approval, approvalId);
|
|
20959
|
+
const isDispatchFailure = approval.denySource === "dispatch_failure";
|
|
20960
|
+
const code = isDispatchFailure ? "E_APPROVAL_DISPATCH_FAILED" : "E_DENIED";
|
|
20961
|
+
const human = isDispatchFailure ? "approval card dispatch failed before the operator could see it" : "operator denied config_propose_edit";
|
|
20962
|
+
const b = err(code, human).why(legacy).fixOperatorAction(isDispatchFailure ? "infra" : "policy_denied", isDispatchFailure ? ["check telegram-plugin gateway connectivity"] : undefined).op("config_propose_edit").caller(caller.kind === "agent" ? "agent" : "operator").agentName(caller.kind === "agent" ? caller.name : undefined);
|
|
20963
|
+
if (!isDispatchFailure)
|
|
20964
|
+
b.asDenied();
|
|
20965
|
+
const built = b.build(req.request_id, Date.now() - started);
|
|
20966
|
+
return { ...built, error: legacy };
|
|
20952
20967
|
}
|
|
20953
20968
|
if (approval.verdict === "timeout") {
|
|
20954
|
-
|
|
20969
|
+
const legacy = `E_APPROVAL_TIMEOUT: operator approval card expired without a tap (approval_id=${approvalId})`;
|
|
20970
|
+
const built = err("E_APPROVAL_TIMEOUT", "operator approval card expired without a tap").why(`approval_id=${approvalId}`).fixOperatorAction("policy_denied").op("config_propose_edit").caller(caller.kind === "agent" ? "agent" : "operator").agentName(caller.kind === "agent" ? caller.name : undefined).asDenied().build(req.request_id, Date.now() - started);
|
|
20971
|
+
return { ...built, error: legacy };
|
|
20955
20972
|
}
|
|
20956
20973
|
const release = await this.acquireConfigApplyLock();
|
|
20957
20974
|
try {
|
|
20958
20975
|
let snapshot;
|
|
20959
20976
|
try {
|
|
20960
20977
|
snapshot = readFileSync5(configPath, "utf-8");
|
|
20961
|
-
} catch (
|
|
20978
|
+
} catch (e) {
|
|
20962
20979
|
await approval.finalize({
|
|
20963
20980
|
outcome: "reconcile_failed_rolled_back",
|
|
20964
|
-
detail: `pre-write snapshot read failed: ${
|
|
20981
|
+
detail: `pre-write snapshot read failed: ${e.message}`
|
|
20965
20982
|
});
|
|
20966
|
-
return
|
|
20983
|
+
return this.reconcileFailedRolledBack(`snapshot read failed: ${e.message}`, req, caller, started);
|
|
20967
20984
|
}
|
|
20968
20985
|
const postApply = verdict.postApplyContent;
|
|
20969
20986
|
const tmp = configPath + ".tmp";
|
|
20970
20987
|
try {
|
|
20971
20988
|
writeFileSync3(tmp, postApply);
|
|
20972
20989
|
renameSync(tmp, configPath);
|
|
20973
|
-
} catch (
|
|
20990
|
+
} catch (e) {
|
|
20974
20991
|
unlinkSyncBestEffort(tmp);
|
|
20975
20992
|
await approval.finalize({
|
|
20976
20993
|
outcome: "reconcile_failed_rolled_back",
|
|
20977
|
-
detail: `atomic write failed: ${
|
|
20994
|
+
detail: `atomic write failed: ${e.message}`
|
|
20978
20995
|
});
|
|
20979
|
-
return
|
|
20996
|
+
return this.reconcileFailedRolledBack(`write failed: ${e.message}`, req, caller, started);
|
|
20980
20997
|
}
|
|
20981
20998
|
const runner = this.opts.runReconcile ?? (async () => this.runSwitchroom(["apply"]));
|
|
20982
20999
|
const recRes = await runner({ requestId: approvalId });
|
|
@@ -20996,13 +21013,13 @@ class HostdServer {
|
|
|
20996
21013
|
try {
|
|
20997
21014
|
writeFileSync3(tmp, snapshot);
|
|
20998
21015
|
renameSync(tmp, configPath);
|
|
20999
|
-
} catch (
|
|
21000
|
-
rollbackDetail = `snapshot restore failed: ${
|
|
21016
|
+
} catch (e) {
|
|
21017
|
+
rollbackDetail = `snapshot restore failed: ${e.message}`;
|
|
21001
21018
|
await approval.finalize({
|
|
21002
21019
|
outcome: "reconcile_failed_rolled_back",
|
|
21003
21020
|
detail: rollbackDetail
|
|
21004
21021
|
});
|
|
21005
|
-
return
|
|
21022
|
+
return this.reconcileFailedRolledBack(rollbackDetail, req, caller, started);
|
|
21006
21023
|
}
|
|
21007
21024
|
const recRes2 = await runner({ requestId: approvalId });
|
|
21008
21025
|
const recoveryNote = recRes2.exit_code === 0 ? "rolled back successfully" : `rolled back but recovery reconcile also failed (exit ${recRes2.exit_code})`;
|
|
@@ -21010,11 +21027,18 @@ class HostdServer {
|
|
|
21010
21027
|
outcome: "reconcile_failed_rolled_back",
|
|
21011
21028
|
detail: recoveryNote
|
|
21012
21029
|
});
|
|
21013
|
-
return
|
|
21030
|
+
return this.reconcileFailedRolledBack(`reconcile exit ${recRes.exit_code}; ${recoveryNote}`, req, caller, started);
|
|
21014
21031
|
} finally {
|
|
21015
21032
|
release();
|
|
21016
21033
|
}
|
|
21017
21034
|
}
|
|
21035
|
+
reconcileFailedRolledBack(detail, req, caller, started) {
|
|
21036
|
+
const legacy = `E_RECONCILE_FAILED_ROLLED_BACK: ${detail}`;
|
|
21037
|
+
const built = err("E_RECONCILE_FAILED_ROLLED_BACK", "config write or reconcile failed; live file rolled back to snapshot").why(detail).fixOperatorAction("infra", [
|
|
21038
|
+
"inspect hostd logs + switchroom apply output to identify the underlying failure"
|
|
21039
|
+
]).op("config_propose_edit").caller(caller.kind === "agent" ? "agent" : "operator").agentName(caller.kind === "agent" ? caller.name : undefined).build(req.request_id, Date.now() - started);
|
|
21040
|
+
return { ...built, error: legacy };
|
|
21041
|
+
}
|
|
21018
21042
|
configApplyLock = Promise.resolve();
|
|
21019
21043
|
async acquireConfigApplyLock() {
|
|
21020
21044
|
let release;
|
|
@@ -21180,7 +21204,9 @@ class HostdServer {
|
|
|
21180
21204
|
handleGetStatus(req, _caller, started) {
|
|
21181
21205
|
const entry = this.statusByRequestId.get(req.args.target_request_id);
|
|
21182
21206
|
if (!entry) {
|
|
21183
|
-
|
|
21207
|
+
const legacy = `get_status: internal: entry missing despite gate accept`;
|
|
21208
|
+
const built = err("E_INTERNAL", "entry missing despite gate accept").op("get_status").build(req.request_id, Date.now() - started);
|
|
21209
|
+
return { ...built, error: legacy };
|
|
21184
21210
|
}
|
|
21185
21211
|
return this.statusEntryToResponse(req.request_id, entry);
|
|
21186
21212
|
}
|
|
@@ -21339,6 +21365,8 @@ class SocketApprovalGateway {
|
|
|
21339
21365
|
if (sockPath === null) {
|
|
21340
21366
|
return {
|
|
21341
21367
|
verdict: "deny",
|
|
21368
|
+
reason: "no reachable telegram gateway socket for this agent",
|
|
21369
|
+
denySource: "dispatch_failure",
|
|
21342
21370
|
finalize: async () => {}
|
|
21343
21371
|
};
|
|
21344
21372
|
}
|
|
@@ -21379,7 +21407,12 @@ class SocketApprovalGateway {
|
|
|
21379
21407
|
return;
|
|
21380
21408
|
resolved = true;
|
|
21381
21409
|
log(`request_config_approval write failed (requestId=${req.requestId}): ${err2.message}`);
|
|
21382
|
-
resolve6({
|
|
21410
|
+
resolve6({
|
|
21411
|
+
verdict: "deny",
|
|
21412
|
+
reason: `request_config_approval write failed: ${err2.message}`,
|
|
21413
|
+
denySource: "dispatch_failure",
|
|
21414
|
+
finalize: async () => {}
|
|
21415
|
+
});
|
|
21383
21416
|
}
|
|
21384
21417
|
});
|
|
21385
21418
|
client2.on("data", (chunk2) => {
|
|
@@ -21400,8 +21433,13 @@ class SocketApprovalGateway {
|
|
|
21400
21433
|
const obj = parsed;
|
|
21401
21434
|
if (obj.type === "config_approval_resolved" && obj.requestId === req.requestId && (obj.verdict === "approve" || obj.verdict === "deny" || obj.verdict === "timeout") && !resolved) {
|
|
21402
21435
|
resolved = true;
|
|
21436
|
+
const verdict = obj.verdict;
|
|
21437
|
+
const reasonField = typeof obj.reason === "string" ? obj.reason : undefined;
|
|
21438
|
+
const denySource = verdict === "deny" ? obj.denySource === "dispatch_failure" ? "dispatch_failure" : "operator" : undefined;
|
|
21403
21439
|
resolve6({
|
|
21404
|
-
verdict
|
|
21440
|
+
verdict,
|
|
21441
|
+
...reasonField !== undefined ? { reason: reasonField } : {},
|
|
21442
|
+
...denySource !== undefined ? { denySource } : {},
|
|
21405
21443
|
finalize
|
|
21406
21444
|
});
|
|
21407
21445
|
}
|
|
@@ -21412,13 +21450,23 @@ class SocketApprovalGateway {
|
|
|
21412
21450
|
return;
|
|
21413
21451
|
resolved = true;
|
|
21414
21452
|
log(`gateway socket error (requestId=${req.requestId}): ${err2.message}`);
|
|
21415
|
-
resolve6({
|
|
21453
|
+
resolve6({
|
|
21454
|
+
verdict: "deny",
|
|
21455
|
+
reason: `gateway socket error: ${err2.message}`,
|
|
21456
|
+
denySource: "dispatch_failure",
|
|
21457
|
+
finalize: async () => {}
|
|
21458
|
+
});
|
|
21416
21459
|
});
|
|
21417
21460
|
client2.on("close", () => {
|
|
21418
21461
|
if (resolved)
|
|
21419
21462
|
return;
|
|
21420
21463
|
resolved = true;
|
|
21421
|
-
resolve6({
|
|
21464
|
+
resolve6({
|
|
21465
|
+
verdict: "deny",
|
|
21466
|
+
reason: "gateway socket closed before verdict",
|
|
21467
|
+
denySource: "dispatch_failure",
|
|
21468
|
+
finalize: async () => {}
|
|
21469
|
+
});
|
|
21422
21470
|
});
|
|
21423
21471
|
});
|
|
21424
21472
|
}
|
package/package.json
CHANGED