switchroom 0.18.11 → 0.18.12

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.
Files changed (122) hide show
  1. package/dist/agent-scheduler/index.js +29 -5
  2. package/dist/auth-broker/index.js +53 -13
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/notion-write-pretool.mjs +29 -5
  5. package/dist/cli/switchroom.js +2453 -1293
  6. package/dist/cli/ui/index.html +163 -17
  7. package/dist/host-control/main.js +504 -100
  8. package/dist/vault/approvals/kernel-server.js +53 -13
  9. package/dist/vault/broker/server.js +162 -114
  10. package/package.json +3 -4
  11. package/profiles/_base/start.sh.hbs +65 -0
  12. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  13. package/profiles/coding/CLAUDE.md.hbs +1 -1
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/telegram-plugin/bridge/bridge.ts +37 -0
  18. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  19. package/telegram-plugin/dist/bridge/bridge.js +73 -1
  20. package/telegram-plugin/dist/gateway/gateway.js +3602 -1007
  21. package/telegram-plugin/dist/server.js +74 -2
  22. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  23. package/telegram-plugin/gateway/approval-hold.ts +583 -0
  24. package/telegram-plugin/gateway/auth-command.ts +92 -2
  25. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  26. package/telegram-plugin/gateway/boot-card.ts +12 -5
  27. package/telegram-plugin/gateway/callback-query-handlers.ts +76 -1
  28. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  29. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  30. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  31. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  32. package/telegram-plugin/gateway/gateway.ts +1482 -165
  33. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  34. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  35. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  36. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  37. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  38. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  39. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  40. package/telegram-plugin/gateway/model-command.ts +9 -3
  41. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  42. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  43. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  44. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  45. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  46. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  47. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  48. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  49. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  50. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  51. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  52. package/telegram-plugin/hooks/hooks.json +9 -0
  53. package/telegram-plugin/inline-keyboard-callbacks.ts +209 -2
  54. package/telegram-plugin/operator-events.ts +23 -0
  55. package/telegram-plugin/package.json +0 -1
  56. package/telegram-plugin/permission-rule.ts +1 -0
  57. package/telegram-plugin/permission-title.ts +1 -0
  58. package/telegram-plugin/retry-api-call.ts +212 -2
  59. package/telegram-plugin/send-gate-degraded.test.ts +443 -0
  60. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  61. package/telegram-plugin/send-gate-observability.ts +355 -0
  62. package/telegram-plugin/send-gate.test.ts +698 -0
  63. package/telegram-plugin/send-gate.ts +982 -0
  64. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  65. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  66. package/telegram-plugin/status-pin-driver.ts +52 -7
  67. package/telegram-plugin/status-pin.ts +81 -0
  68. package/telegram-plugin/subagent-watcher.ts +102 -2
  69. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  70. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  71. package/telegram-plugin/tests/approval-hold-outcome.test.ts +296 -0
  72. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  73. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  74. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  75. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  76. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  77. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  78. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  79. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  80. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  81. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  82. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  83. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  84. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  85. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  86. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +284 -0
  87. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  88. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  89. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  90. package/telegram-plugin/tests/model-command.test.ts +14 -0
  91. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  92. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  93. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  94. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  95. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  96. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  97. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  98. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  99. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  100. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  101. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  102. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  103. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  104. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  105. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  106. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  107. package/telegram-plugin/typing-emitter.ts +224 -0
  108. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  109. package/telegram-plugin/welcome-text.ts +42 -0
  110. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  111. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  112. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  113. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  114. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  115. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  116. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  117. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  118. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  119. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  120. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  121. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  122. package/telegram-plugin/channel-envelope-safety.ts +0 -56
@@ -15989,7 +15989,7 @@ var require_command = __commonJS((exports2) => {
15989
15989
  var EventEmitter2 = __require("node:events").EventEmitter;
15990
15990
  var childProcess = __require("node:child_process");
15991
15991
  var path2 = __require("node:path");
15992
- var fs2 = __require("node:fs");
15992
+ var fs3 = __require("node:fs");
15993
15993
  var process2 = __require("node:process");
15994
15994
  var { Argument, humanReadableArgName } = require_argument();
15995
15995
  var { CommanderError } = require_error();
@@ -16510,7 +16510,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
16510
16510
  this.processedArgs = [];
16511
16511
  }
16512
16512
  _checkForMissingExecutable(executableFile, executableDir, subcommandName) {
16513
- if (fs2.existsSync(executableFile))
16513
+ if (fs3.existsSync(executableFile))
16514
16514
  return;
16515
16515
  const executableDirMessage = executableDir ? `searched for local subcommand relative to directory '${executableDir}'` : "no directory for search for local subcommand, use .executableDir() to supply a custom directory";
16516
16516
  const executableMissing = `'${executableFile}' does not exist
@@ -16525,11 +16525,11 @@ Expecting one of '${allowedValues.join("', '")}'`);
16525
16525
  const sourceExt = [".js", ".ts", ".tsx", ".mjs", ".cjs"];
16526
16526
  function findFile(baseDir, baseName) {
16527
16527
  const localBin = path2.resolve(baseDir, baseName);
16528
- if (fs2.existsSync(localBin))
16528
+ if (fs3.existsSync(localBin))
16529
16529
  return localBin;
16530
16530
  if (sourceExt.includes(path2.extname(baseName)))
16531
16531
  return;
16532
- const foundExt = sourceExt.find((ext) => fs2.existsSync(`${localBin}${ext}`));
16532
+ const foundExt = sourceExt.find((ext) => fs3.existsSync(`${localBin}${ext}`));
16533
16533
  if (foundExt)
16534
16534
  return `${localBin}${foundExt}`;
16535
16535
  return;
@@ -16541,7 +16541,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
16541
16541
  if (this._scriptPath) {
16542
16542
  let resolvedScriptPath;
16543
16543
  try {
16544
- resolvedScriptPath = fs2.realpathSync(this._scriptPath);
16544
+ resolvedScriptPath = fs3.realpathSync(this._scriptPath);
16545
16545
  } catch {
16546
16546
  resolvedScriptPath = this._scriptPath;
16547
16547
  }
@@ -17316,7 +17316,7 @@ var require_commander = __commonJS((exports2) => {
17316
17316
 
17317
17317
  // src/host-control/main.ts
17318
17318
  import { homedir as homedir6 } from "node:os";
17319
- import { existsSync as existsSync11, readdirSync as readdirSync4 } from "node:fs";
17319
+ import { existsSync as existsSync12, readdirSync as readdirSync4 } from "node:fs";
17320
17320
  import { join as join11, resolve as resolve9 } from "node:path";
17321
17321
 
17322
17322
  // src/config/loader.ts
@@ -21399,7 +21399,12 @@ var ScheduleEntrySchema = exports_external.object({
21399
21399
  var AgentSoulSchema = exports_external.object({
21400
21400
  name: exports_external.string().describe("Agent persona name (e.g., 'Coach', 'Sage')"),
21401
21401
  style: exports_external.string().describe("Communication style description"),
21402
- boundaries: exports_external.string().optional().describe("Behavioral boundaries and disclaimers")
21402
+ creature: exports_external.string().optional().describe("Persona creature/form (e.g., 'owl', 'octopus')"),
21403
+ vibe: exports_external.string().optional().describe("One-line personality vibe (e.g., 'calm, precise')"),
21404
+ expertise: exports_external.string().optional().describe("Domain expertise summary rendered into the persona"),
21405
+ emoji: exports_external.string().optional().describe("Signature emoji for the persona (e.g., '\uD83E\uDD89')"),
21406
+ boundaries: exports_external.string().optional().describe("Behavioral boundaries and disclaimers"),
21407
+ shape: exports_external.enum(["executive-assistant", "developer", "coach", "generalist"]).default("generalist").describe("Persona-shape discriminator the per-agent CLAUDE.md template can " + "branch on (e.g., 'you are an executive assistant' vs 'a senior " + "engineer'). No runtime behavior yet — template work lands in a " + "later issue. Cascade: override (per-agent wins over default).")
21403
21408
  }).optional();
21404
21409
  var AgentToolsSchema = exports_external.object({
21405
21410
  allow: exports_external.array(exports_external.string()).default([]).describe("Allowed tools (use ['all'] for unrestricted)"),
@@ -21564,6 +21569,11 @@ var TelegramChannelSchema = exports_external.object({
21564
21569
  safe_boundary: exports_external.boolean().optional().describe("When true (the default), a `!`-prefix interrupt that arrives while " + "the agent is mid-tool-call is DEFERRED: the SIGINT and the " + "replacement turn wait until the in-flight tool call finishes (a " + "clean boundary) instead of C-c'ing the agent mid-write/mid-bash. If " + "no tool is in flight the interrupt still fires immediately. Bounded " + "by max_wait_ms so a long tool never strands the user. Set false to " + "fire synchronously the moment `!` is received (historical " + "behaviour). Rapid repeated `!` while one is pending coalesce into a " + "single deferred interrupt carrying the latest body."),
21565
21570
  max_wait_ms: exports_external.number().int().positive().optional().describe("Upper bound (ms) the gateway waits for a safe boundary before firing " + "a deferred `!` interrupt anyway. Only consulted when safe_boundary is " + "true. Default 8000. Keep it short — the user explicitly asked to " + "interrupt, so a long in-flight tool shouldn't ghost them; the cap " + "trades a tiny risk of a mid-tool C-c for a guaranteed response.")
21566
21571
  }).optional().describe("Interrupt timing — how a `!`-prefix interrupt behaves when it lands " + "mid-tool-call. Off by default (fire immediately). Cascades from " + "defaults.channels.telegram.interrupt."),
21572
+ button_choice_confirmation: exports_external.object({
21573
+ enabled: exports_external.boolean().default(false).describe("When true, tapping an agent-emitted inline_keyboard button annotates " + "the source message body with a '✅ You chose: <label> · HH:MM' line so " + "the chat surface is self-documenting. Only applies to single-use " + "keyboards (re-tappable keyboards are never annotated). Requires the " + "agent's parseMode to be the default 'html'. Opt-in (default false). " + "Limitation: the annotated body is rebuilt from the message's plain " + "text, so any formatting entities (bold, links, code, ...) on the " + "original message are lost when the annotation is applied."),
21574
+ format: exports_external.string().default("✅ You chose: {label} · {time}").describe("Template for the annotation line. `{label}` is replaced with the " + "tapped button's text (newlines stripped, HTML-escaped, trimmed to 60 " + "chars) and `{time}` with HH:MM in the chosen timezone. Note: retap " + "dedup (replacing a prior annotation instead of appending another " + "line) only recognizes the DEFAULT format shape — a custom format " + "accumulates one line per retap."),
21575
+ timezone: exports_external.enum(["gateway", "utc"]).default("gateway").describe("Timezone for the {time} placeholder. 'gateway' uses the gateway " + "process's local time; 'utc' uses UTC. Per-user timezones are out of " + "scope for v1.")
21576
+ }).optional().describe("Auto-confirm 'You chose: X' annotation on inline_keyboard taps (#789). " + "Cascades from defaults.channels.telegram.button_choice_confirmation."),
21567
21577
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic", "linear"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token, " + "'linear' = Linear-Signature bare-hex HMAC-SHA256 of the raw body). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default — webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 — see #577.)"),
21568
21578
  webhook_dispatch: exports_external.object({
21569
21579
  github: exports_external.array(webhookDispatchRule).optional(),
@@ -21712,7 +21722,12 @@ var profileFields = {
21712
21722
  soul: exports_external.object({
21713
21723
  name: exports_external.string().optional(),
21714
21724
  style: exports_external.string().optional(),
21715
- boundaries: exports_external.string().optional()
21725
+ creature: exports_external.string().optional(),
21726
+ vibe: exports_external.string().optional(),
21727
+ expertise: exports_external.string().optional(),
21728
+ emoji: exports_external.string().optional(),
21729
+ boundaries: exports_external.string().optional(),
21730
+ shape: exports_external.enum(["executive-assistant", "developer", "coach", "generalist"]).optional()
21716
21731
  }).optional(),
21717
21732
  tools: exports_external.object({
21718
21733
  allow: exports_external.array(exports_external.string()).optional(),
@@ -21909,7 +21924,8 @@ var VaultConfigSchema = exports_external.object({
21909
21924
  autoUnlockCredentialPath: exports_external.string().default("~/.switchroom/vault-auto-unlock").describe("Path to the machine-bound auto-unlock blob (see " + "src/vault/auto-unlock.ts for the format). Default lives under " + "~/.switchroom so it can be bind-mounted into the vault-broker " + "container by docker compose. Tilde-expansion happens " + "at read time."),
21910
21925
  approvalAuth: exports_external.enum(["passphrase", "telegram-id"]).default("passphrase").describe("Posture for tap-to-Approve on vault grant cards. `passphrase` " + "(default) prompts the operator to type the vault passphrase on " + "every Approve — two-factor (Telegram ID + passphrase). " + "`telegram-id` mints immediately on Approve with no passphrase " + "prompt — single-factor (Telegram ID only); REQUIRES " + "`autoUnlock: true` so the broker already holds the passphrase. " + "Trades a factor of security for smoother UX; opt-in only."),
21911
21926
  postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` — no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored — passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config."),
21912
- adminOnlyKeys: exports_external.array(exports_external.string().min(1)).default([]).describe("Vault keys held to a higher approval bar: only the admin operator " + "(`access.allowFrom[0]`) may approve a grant for them, and they can " + "NEVER be minted via posture attestation — granting one requires the " + "operator passphrase (so an agent, even one on `postureMintAgents`, " + "cannot self-grant it). Entries are exact key names or `*` globs, " + "e.g. `stripe/*`, `*/oauth-token`, `microsoft/ken-tokens` (`*` matches " + "any run of characters incl. `/`; case-sensitive). Default `[]` — no " + "key is admin-only. Posture may RETAIN an admin-only key across a " + "union re-mint but never ADD one. Takes effect on broker + gateway " + "restart (broker has no ACL hot-reload).")
21927
+ adminOnlyKeys: exports_external.array(exports_external.string().min(1)).default([]).describe("Vault keys held to a higher approval bar: only the admin operator " + "(`access.allowFrom[0]`) may approve a grant for them, and they can " + "NEVER be minted via posture attestation — granting one requires the " + "operator passphrase (so an agent, even one on `postureMintAgents`, " + "cannot self-grant it). Entries are exact key names or `*` globs, " + "e.g. `stripe/*`, `*/oauth-token`, `microsoft/ken-tokens` (`*` matches " + "any run of characters incl. `/`; case-sensitive). Default `[]` — no " + "key is admin-only. Posture may RETAIN an admin-only key across a " + "union re-mint but never ADD one. Takes effect on broker + gateway " + "restart (broker has no ACL hot-reload)."),
21928
+ auditFailClosed: exports_external.boolean().default(false).describe("sec WS10-F3 (#1420) — fail-CLOSED on an audit-append failure. When " + "`false` (default, backward-compatible) the broker fails OPEN: if a " + "vault-audit.log append fails it logs to stderr, bumps the durable " + "fail-open counter, and STILL releases the secret. When `true`, a " + "failed audit append DENIES the secret release with " + "`AUDIT_UNAVAILABLE` — no secret leaves the broker without a durable " + "audit row. The env var `SWITCHROOM_VAULT_AUDIT_FAIL_CLOSED` " + "(`1`/`true` or `0`/`false`) overrides this at runtime. Either way " + "the fail-open counter is surfaced by `switchroom doctor`. Takes " + "effect on broker restart.")
21913
21929
  }).default({}).superRefine((broker, ctx) => {
21914
21930
  if (broker.approvalAuth === "telegram-id" && broker.autoUnlock !== true) {
21915
21931
  ctx.addIssue({
@@ -21948,7 +21964,9 @@ var FleetHealthConfigSchema = exports_external.object({
21948
21964
  });
21949
21965
  var HostdConfigSchema = exports_external.object({
21950
21966
  config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true, admin agents can propose unified-diff " + "patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
21951
- config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit §5). Default 3 cards/hour; min 1, " + "max 20. ENFORCED server-side: a caller exceeding this in a sliding " + "1-hour window is rejected with `E_RATE_LIMITED` (carrying a " + "`retry_after` fix) instead of posting another operator approval " + "card — so a looping agent is throttled rather than spamming the chat.")
21967
+ config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit §5). Default 3 cards/hour; min 1, " + "max 20. ENFORCED server-side: a caller exceeding this in a sliding " + "1-hour window is rejected with `E_RATE_LIMITED` (carrying a " + "`retry_after` fix) instead of posting another operator approval " + "card — so a looping agent is throttled rather than spamming the chat."),
21968
+ operator_attest_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the operator-passphrase 2nd factor on hostd's " + "mutating verbs (#1841, RFC host-control-daemon.md §5.4). Default " + "false — attestation is ACCEPTED-and-audited whenever present, but " + "no verb REQUIRES it (behaviour is byte-identical to today, so " + "existing fleets and the Telegram approval-card flow are unchanged). " + "When true, the verbs in `operator_attest_required_verbs` demand a " + "valid operator-passphrase attestation IN ADDITION to admin: hostd " + "forwards the passphrase to the vault broker over its own admin-client " + "connection (`/run/switchroom/broker/hostd/sock`) and treats a broker " + "DENIED as a gate failure. hostd never holds the passphrase. Leave " + "this off until BOTH the gateway passphrase-forward and the " + "cross-compose broker socket are deployed, or gated verbs will fail."),
21969
+ operator_attest_required_verbs: exports_external.array(exports_external.string().min(1)).default(["update_apply", "apply", "rollout"]).describe("Which mutating hostd verbs REQUIRE a valid operator-passphrase " + "attestation when `operator_attest_enabled` is true (#1841). Default " + "is the RFC §5.4 fleet-mutation set (`update_apply`, `apply`, " + "`rollout`). Verbs NOT in this list still accept-and-audit an " + "attestation when one is supplied, but never require it. Ignored " + "entirely when `operator_attest_enabled` is false.")
21952
21970
  });
21953
21971
  var CronEgressSchema = exports_external.object({
21954
21972
  allowed_hosts: exports_external.array(exports_external.string().min(1)).default([]).describe("Hosts a poll may reach (exact, https-only). loopback/private/IP-literal are always rejected."),
@@ -22371,6 +22389,11 @@ function mergeAgentConfig(defaultsIn, agentIn) {
22371
22389
  if (v !== undefined)
22372
22390
  combined[k] = v;
22373
22391
  }
22392
+ const baseSoul = base;
22393
+ const overrideSoul = override;
22394
+ if (baseSoul.shape !== undefined && (overrideSoul.shape === undefined || overrideSoul.shape === "generalist")) {
22395
+ combined.shape = baseSoul.shape;
22396
+ }
22374
22397
  merged.soul = combined;
22375
22398
  }
22376
22399
  if (defaults.memory || merged.memory) {
@@ -23230,18 +23253,22 @@ var tails = new Map;
23230
23253
 
23231
23254
  // src/agents/inject.ts
23232
23255
  var INJECT_COMMANDS = new Map([
23233
- ["/cost", { description: "Show session cost", expectsOutput: true, dialog: true }],
23234
- ["/status", { description: "Show session status", expectsOutput: true, dialog: true }],
23235
- ["/usage", { description: "Show plan quota", expectsOutput: true, dialog: true }],
23236
- ["/hooks", { description: "List configured hooks", expectsOutput: true, dialog: true }],
23237
- ["/memory", { description: "Open memory picker", expectsOutput: true, dialog: true }],
23238
- ["/model", { description: "Open model picker", expectsOutput: true }],
23256
+ ["/cost", { description: "Show session cost", expectsOutput: true, dialog: true, argsAllowed: false }],
23257
+ ["/status", { description: "Show session status", expectsOutput: true, dialog: true, argsAllowed: false }],
23258
+ ["/usage", { description: "Show plan quota", expectsOutput: true, dialog: true, argsAllowed: false }],
23259
+ ["/hooks", { description: "List configured hooks", expectsOutput: true, dialog: true, argsAllowed: false }],
23260
+ ["/memory", { description: "Open memory picker", expectsOutput: true, dialog: true, argsAllowed: false }],
23261
+ ["/help", { description: "Show help / command discovery", expectsOutput: true, dialog: true, argsAllowed: false }],
23262
+ ["/context", { description: "Show context window usage", expectsOutput: true, argsAllowed: false }],
23263
+ ["/release-notes", { description: "Show release-notes version list", expectsOutput: true, dialog: true, argsAllowed: false }],
23264
+ ["/model", { description: "Open model picker", expectsOutput: true, argsAllowed: true }],
23239
23265
  [
23240
23266
  "/clear",
23241
23267
  {
23242
23268
  description: "Clear session screen",
23243
23269
  expectsOutput: false,
23244
- silentNote: "context cleared — fresh slate"
23270
+ silentNote: "context cleared — fresh slate",
23271
+ argsAllowed: false
23245
23272
  }
23246
23273
  ],
23247
23274
  [
@@ -23249,7 +23276,8 @@ var INJECT_COMMANDS = new Map([
23249
23276
  {
23250
23277
  description: "Compact conversation history",
23251
23278
  expectsOutput: false,
23252
- silentNote: "compaction runs silently"
23279
+ silentNote: "compaction runs silently",
23280
+ argsAllowed: false
23253
23281
  }
23254
23282
  ]
23255
23283
  ]);
@@ -23259,6 +23287,15 @@ var INJECT_BLOCKED = new Map([
23259
23287
  ["/logout", { reason: "would terminate the agent's auth session" }],
23260
23288
  ["/exit", { reason: "would kill the agent process" }],
23261
23289
  ["/quit", { reason: "would kill the agent process" }],
23290
+ ["/upgrade", { reason: "mutates the Claude Code installation" }],
23291
+ ["/init", { reason: "generates/overwrites CLAUDE.md and runs a model turn" }],
23292
+ ["/mcp", { reason: "opens an interactive MCP server management dialog" }],
23293
+ ["/permissions", { reason: "opens an interactive permissions editor that mutates tool policy" }],
23294
+ ["/install-github-app", { reason: "runs a network/OAuth install flow" }],
23295
+ ["/add-dir", { reason: "mutates the session's working-directory set" }],
23296
+ ["/terminal-setup", { reason: "mutates terminal keybinding configuration" }],
23297
+ ["/privacy-settings", { reason: "opens an interactive privacy-settings dialog" }],
23298
+ ["/bug", { reason: "submits a bug report over the network" }],
23262
23299
  [
23263
23300
  "/effort",
23264
23301
  {
@@ -23276,6 +23313,7 @@ var HINDSIGHT_CONSUMER_NAME = "hindsight";
23276
23313
  var HINDSIGHT_IMAGE_REPO = "ghcr.io/switchroom/switchroom-hindsight";
23277
23314
  var HINDSIGHT_IMAGE = `${HINDSIGHT_IMAGE_REPO}:latest`;
23278
23315
  var HINDSIGHT_BROKER_SOCK_VOLUME = `auth-broker-${HINDSIGHT_CONSUMER_NAME}-sock`;
23316
+ var HINDSIGHT_CREDS_MIRROR_VOLUME = `consumer-creds-${HINDSIGHT_CONSUMER_NAME}`;
23279
23317
  var HINDSIGHT_HEALTHCHECK_PY = 'import urllib.request,sys; sys.exit(0 if urllib.request.urlopen("http://localhost:8888/health",timeout=4).getcode()==200 else 1)';
23280
23318
  var HINDSIGHT_HEALTHCHECK_CMD = `python3 -c '${HINDSIGHT_HEALTHCHECK_PY}'`;
23281
23319
 
@@ -23308,6 +23346,8 @@ var KNOWN_VAULT_ARTIFACT_NAMES = new Set([
23308
23346
  var SCRYPT_MAXMEM = 128 * 1024 * 1024;
23309
23347
 
23310
23348
  // src/vault/broker/client.ts
23349
+ import * as net from "node:net";
23350
+ import * as fs from "node:fs";
23311
23351
  import { homedir as homedir3 } from "node:os";
23312
23352
  import { join as join4 } from "node:path";
23313
23353
 
@@ -23520,7 +23560,8 @@ var ErrorCode = exports_external.enum([
23520
23560
  "DENIED",
23521
23561
  "UNKNOWN_KEY",
23522
23562
  "BAD_REQUEST",
23523
- "INTERNAL"
23563
+ "INTERNAL",
23564
+ "AUDIT_UNAVAILABLE"
23524
23565
  ]);
23525
23566
  var OkEntryResponseSchema = exports_external.object({
23526
23567
  ok: exports_external.literal(true),
@@ -23657,16 +23698,123 @@ var ResponseSchema = exports_external.union([
23657
23698
  OkApprovalConsumeRecordResponseSchema,
23658
23699
  ErrorResponseSchema
23659
23700
  ]);
23701
+ function encodeRequest(req) {
23702
+ const json = JSON.stringify(req);
23703
+ if (Buffer.byteLength(json, "utf8") > MAX_FRAME_BYTES) {
23704
+ throw new Error(`Request frame too large (${Buffer.byteLength(json, "utf8")} bytes; max ${MAX_FRAME_BYTES})`);
23705
+ }
23706
+ return json + `
23707
+ `;
23708
+ }
23709
+ function decodeResponse(line) {
23710
+ if (Buffer.byteLength(line, "utf8") > MAX_FRAME_BYTES) {
23711
+ throw new RangeError(`Response frame too large (${Buffer.byteLength(line, "utf8")} bytes; max ${MAX_FRAME_BYTES})`);
23712
+ }
23713
+ const obj = JSON.parse(line);
23714
+ return ResponseSchema.parse(obj);
23715
+ }
23716
+
23717
+ // src/runtime-mode.ts
23718
+ function isDockerRuntime() {
23719
+ return process.env.SWITCHROOM_RUNTIME === "docker";
23720
+ }
23660
23721
 
23661
23722
  // src/vault/broker/client.ts
23723
+ var DEFAULT_TIMEOUT_MS = 2000;
23662
23724
  var LEGACY_SOCKET_PATH = join4(homedir3(), ".switchroom", "vault-broker.sock");
23663
23725
  var OPERATOR_SOCKET_PATH = join4(homedir3(), ".switchroom", "broker-operator", "sock");
23726
+ function defaultBrokerSocketPath() {
23727
+ if (fs.existsSync(OPERATOR_SOCKET_PATH))
23728
+ return OPERATOR_SOCKET_PATH;
23729
+ if (isDockerRuntime())
23730
+ return OPERATOR_SOCKET_PATH;
23731
+ return LEGACY_SOCKET_PATH;
23732
+ }
23733
+ function resolveBrokerSocketPath(opts) {
23734
+ if (opts?.socket)
23735
+ return opts.socket;
23736
+ const env = process.env.SWITCHROOM_VAULT_BROKER_SOCK;
23737
+ if (env)
23738
+ return env;
23739
+ if (opts?.vaultBrokerSocket)
23740
+ return opts.vaultBrokerSocket;
23741
+ return defaultBrokerSocketPath();
23742
+ }
23743
+ async function rpcRaw(req, opts) {
23744
+ return rpc(req, opts);
23745
+ }
23746
+ async function rpc(req, opts) {
23747
+ const socketPath = resolveBrokerSocketPath(opts);
23748
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
23749
+ return new Promise((resolve5) => {
23750
+ let settled = false;
23751
+ const settle = (val) => {
23752
+ if (settled)
23753
+ return;
23754
+ settled = true;
23755
+ resolve5(val);
23756
+ };
23757
+ const client = new net.Socket;
23758
+ const timer = setTimeout(() => {
23759
+ client.destroy();
23760
+ settle({ kind: "unreachable", msg: `broker did not respond within ${timeoutMs}ms` });
23761
+ }, timeoutMs);
23762
+ client.on("error", (err) => {
23763
+ clearTimeout(timer);
23764
+ const code = err.code ?? "ERR";
23765
+ let msg;
23766
+ if (code === "ENOENT")
23767
+ msg = "broker socket not found (is the daemon running?)";
23768
+ else if (code === "ECONNREFUSED")
23769
+ msg = "broker socket exists but refused connection";
23770
+ else if (code === "EACCES")
23771
+ msg = "broker socket access denied (wrong UID?)";
23772
+ else
23773
+ msg = `broker connection failed: ${err.message}`;
23774
+ settle({ kind: "unreachable", msg });
23775
+ });
23776
+ let buffer = "";
23777
+ client.on("data", (chunk) => {
23778
+ buffer += chunk.toString("utf8");
23779
+ const newlineIdx = buffer.indexOf(`
23780
+ `);
23781
+ if (newlineIdx !== -1) {
23782
+ const line = buffer.slice(0, newlineIdx).trimEnd();
23783
+ clearTimeout(timer);
23784
+ client.destroy();
23785
+ try {
23786
+ const resp = decodeResponse(line);
23787
+ settle({ kind: "response", resp });
23788
+ } catch (err) {
23789
+ settle({
23790
+ kind: "unreachable",
23791
+ msg: `unparseable broker response: ${err instanceof Error ? err.message : String(err)}`
23792
+ });
23793
+ }
23794
+ }
23795
+ });
23796
+ client.on("connect", () => {
23797
+ try {
23798
+ client.write(encodeRequest(req));
23799
+ } catch (err) {
23800
+ clearTimeout(timer);
23801
+ client.destroy();
23802
+ settle({
23803
+ kind: "unreachable",
23804
+ msg: `failed to send request: ${err instanceof Error ? err.message : String(err)}`
23805
+ });
23806
+ }
23807
+ });
23808
+ client.connect({ path: socketPath });
23809
+ });
23810
+ }
23664
23811
 
23665
23812
  // src/vault/resolver.ts
23666
23813
  var materializedDirs = new Set;
23667
23814
 
23668
23815
  // src/agents/scaffold.ts
23669
23816
  var REPO_ROOT = resolve5(import.meta.dirname, "../..");
23817
+ var CLAUDE_MD_YOURS_PLACEHOLDER = "This space is yours. Add per-agent rules, exceptions, or context the " + "Switchroom template doesn't capture. Everything above the marker line is " + "regenerated on every apply; this section is preserved.";
23670
23818
  var SWITCHROOM_OWNED_SETTINGS_KEYS = new Set([
23671
23819
  "permissions",
23672
23820
  "mcpServers",
@@ -23697,8 +23845,8 @@ import { spawn as spawn2, spawnSync as spawnSync3 } from "node:child_process";
23697
23845
  import { mkdir, chmod, chown, unlink, appendFile } from "node:fs/promises";
23698
23846
  import {
23699
23847
  readdirSync as readdirSync3,
23700
- existsSync as existsSync10,
23701
- readFileSync as readFileSync8,
23848
+ existsSync as existsSync11,
23849
+ readFileSync as readFileSync9,
23702
23850
  writeFileSync as writeFileSync5,
23703
23851
  renameSync as renameSync3,
23704
23852
  mkdirSync as mkdirSync4,
@@ -23719,7 +23867,8 @@ var IDEMPOTENCY_WINDOW_MS = 15000;
23719
23867
  var RequestEnvelope = {
23720
23868
  v: exports_external.literal(1),
23721
23869
  request_id: exports_external.string().min(1).max(128),
23722
- idempotency_key: exports_external.string().min(1).max(128).optional()
23870
+ idempotency_key: exports_external.string().min(1).max(128).optional(),
23871
+ operator_passphrase: exports_external.string().min(1).max(1024).optional()
23723
23872
  };
23724
23873
  var AgentRestartRequestSchema = exports_external.object({
23725
23874
  ...RequestEnvelope,
@@ -28399,9 +28548,9 @@ class PostHog extends PostHogBackendClient {
28399
28548
 
28400
28549
  // src/analytics/posthog.ts
28401
28550
  import {
28402
- existsSync as existsSync6,
28551
+ existsSync as existsSync7,
28403
28552
  mkdirSync as mkdirSync3,
28404
- readFileSync as readFileSync5,
28553
+ readFileSync as readFileSync6,
28405
28554
  writeFileSync as writeFileSync3
28406
28555
  } from "node:fs";
28407
28556
  import { dirname as dirname2 } from "node:path";
@@ -28420,8 +28569,8 @@ function getDistinctId() {
28420
28569
  return cachedDistinctId;
28421
28570
  const path = resolveStatePath("analytics-id");
28422
28571
  try {
28423
- if (existsSync6(path)) {
28424
- const existing = readFileSync5(path, "utf-8").trim();
28572
+ if (existsSync7(path)) {
28573
+ const existing = readFileSync6(path, "utf-8").trim();
28425
28574
  if (existing) {
28426
28575
  cachedDistinctId = existing;
28427
28576
  return existing;
@@ -29040,7 +29189,7 @@ function redactedMarker(ruleId) {
29040
29189
  return `[REDACTED:${trimmed}]`;
29041
29190
  }
29042
29191
  // src/cli/install-detect.ts
29043
- import * as fs from "node:fs";
29192
+ import * as fs2 from "node:fs";
29044
29193
  import * as path from "node:path";
29045
29194
  import * as os from "node:os";
29046
29195
  var BIN_PATH = "/usr/local/bin/switchroom";
@@ -29054,12 +29203,12 @@ function detectInstallType() {
29054
29203
  try {
29055
29204
  const repoArtifact = sourceArtifactPath();
29056
29205
  const distPrefix = sourceDistPrefix();
29057
- const binExists = fs.existsSync(BIN_PATH);
29058
- const repoExists = fs.existsSync(repoArtifact);
29206
+ const binExists = fs2.existsSync(BIN_PATH);
29207
+ const repoExists = fs2.existsSync(repoArtifact);
29059
29208
  if (binExists) {
29060
- const lst = fs.lstatSync(BIN_PATH);
29209
+ const lst = fs2.lstatSync(BIN_PATH);
29061
29210
  if (lst.isSymbolicLink()) {
29062
- const target = fs.readlinkSync(BIN_PATH);
29211
+ const target = fs2.readlinkSync(BIN_PATH);
29063
29212
  const resolved = path.isAbsolute(target) ? target : path.resolve(path.dirname(BIN_PATH), target);
29064
29213
  if (resolved.startsWith(distPrefix)) {
29065
29214
  return {
@@ -29096,20 +29245,20 @@ var import_yaml3 = __toESM(require_dist(), 1);
29096
29245
  init_atomic();
29097
29246
 
29098
29247
  // src/cli/resolve-version.ts
29099
- import { existsSync as existsSync8, readFileSync as readFileSync6 } from "node:fs";
29248
+ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:fs";
29100
29249
  import { dirname as dirname4, join as join7 } from "node:path";
29101
29250
 
29102
29251
  // src/build-info.ts
29103
- var VERSION = "0.18.11";
29252
+ var VERSION = "0.18.12";
29104
29253
 
29105
29254
  // src/cli/resolve-version.ts
29106
29255
  function readPackageVersion() {
29107
29256
  let dir = import.meta.dirname;
29108
29257
  for (let i = 0;i < 12 && dir && dir !== "/"; i++) {
29109
29258
  const p = join7(dir, "package.json");
29110
- if (existsSync8(p)) {
29259
+ if (existsSync9(p)) {
29111
29260
  try {
29112
- const pkg = JSON.parse(readFileSync6(p, "utf-8"));
29261
+ const pkg = JSON.parse(readFileSync7(p, "utf-8"));
29113
29262
  if (pkg?.name === "switchroom" && typeof pkg.version === "string") {
29114
29263
  return pkg.version;
29115
29264
  }
@@ -29170,6 +29319,8 @@ function parseAuditLine(line) {
29170
29319
  entry.error = o.error;
29171
29320
  if (typeof o.phase === "string")
29172
29321
  entry.phase = o.phase;
29322
+ if (typeof o.method === "string")
29323
+ entry.method = o.method;
29173
29324
  if (typeof o.stdout_tail === "string")
29174
29325
  entry.stdout_tail = o.stdout_tail;
29175
29326
  if (typeof o.stderr_tail === "string")
@@ -29418,7 +29569,7 @@ function parseUpdateResultLine(stdout) {
29418
29569
 
29419
29570
  // src/host-control/config-edit-validator.ts
29420
29571
  var import_yaml5 = __toESM(require_dist(), 1);
29421
- import { mkdtempSync, writeFileSync as writeFileSync4, rmSync as rmSync2, existsSync as existsSync9, readFileSync as readFileSync7 } from "node:fs";
29572
+ import { mkdtempSync, writeFileSync as writeFileSync4, rmSync as rmSync2, existsSync as existsSync10, readFileSync as readFileSync8 } from "node:fs";
29422
29573
  import { tmpdir } from "node:os";
29423
29574
  import { join as join9, isAbsolute as isAbsolute2, normalize, basename as basename2 } from "node:path";
29424
29575
  import { spawnSync as spawnSync2 } from "node:child_process";
@@ -29530,17 +29681,44 @@ function validateShape(unifiedDiff, targetPath) {
29530
29681
  };
29531
29682
  }
29532
29683
  }
29684
+ {
29685
+ let inHunk = false;
29686
+ let hunkHasContext = false;
29687
+ const failZeroContext = {
29688
+ ok: false,
29689
+ code: "E_PATCH_INVALID_SHAPE",
29690
+ detail: "zero-context hunk not allowed; regenerate the diff with context " + "lines (git diff default of 3)"
29691
+ };
29692
+ const scanLines = lines.length > 0 && lines[lines.length - 1] === "" ? lines.slice(0, -1) : lines;
29693
+ for (const ln of scanLines) {
29694
+ if (ln.startsWith("@@")) {
29695
+ if (inHunk && !hunkHasContext)
29696
+ return failZeroContext;
29697
+ inHunk = true;
29698
+ hunkHasContext = false;
29699
+ } else if (ln.startsWith("--- ") || ln.startsWith("+++ ")) {
29700
+ if (inHunk && !hunkHasContext)
29701
+ return failZeroContext;
29702
+ inHunk = false;
29703
+ } else if (inHunk) {
29704
+ if (ln.startsWith(" ") || ln === "")
29705
+ hunkHasContext = true;
29706
+ }
29707
+ }
29708
+ if (inHunk && !hunkHasContext)
29709
+ return failZeroContext;
29710
+ }
29533
29711
  return null;
29534
29712
  }
29535
29713
  function applyPatch(unifiedDiff, configPath, gitBin) {
29536
- if (!existsSync9(configPath)) {
29714
+ if (!existsSync10(configPath)) {
29537
29715
  return {
29538
29716
  ok: false,
29539
29717
  code: "E_PATCH_APPLY_FAILED",
29540
29718
  detail: `target config not found at ${configPath}`
29541
29719
  };
29542
29720
  }
29543
- const liveContent = readFileSync7(configPath, "utf8");
29721
+ const liveContent = readFileSync8(configPath, "utf8");
29544
29722
  const scratchDir = mkdtempSync(join9(tmpdir(), "config-propose-edit-"));
29545
29723
  try {
29546
29724
  const targetBasename = configPath.split("/").pop() ?? "switchroom.yaml";
@@ -29553,8 +29731,7 @@ function applyPatch(unifiedDiff, configPath, gitBin) {
29553
29731
  const baseArgs = [
29554
29732
  "apply",
29555
29733
  "--whitespace=nowarn",
29556
- "--recount",
29557
- "--unidiff-zero"
29734
+ "--recount"
29558
29735
  ];
29559
29736
  const checkP1 = spawnSync2(bin, [...baseArgs.slice(0, 1), "--check", ...baseArgs.slice(1), "-p1", patchFile], { cwd: scratchDir, encoding: "utf8", timeout: 1e4 });
29560
29737
  let pStrip = "-p1";
@@ -29578,7 +29755,7 @@ function applyPatch(unifiedDiff, configPath, gitBin) {
29578
29755
  detail: `git apply failed: ${(real.stderr || "").trim().slice(0, 500)}`
29579
29756
  };
29580
29757
  }
29581
- const after = readFileSync7(scratchFile, "utf8");
29758
+ const after = readFileSync8(scratchFile, "utf8");
29582
29759
  return { ok: true, after };
29583
29760
  } finally {
29584
29761
  rmSync2(scratchDir, { recursive: true, force: true });
@@ -29776,7 +29953,7 @@ function validateConfigEdit(opts) {
29776
29953
  return schemaErr;
29777
29954
  let beforeData = {};
29778
29955
  try {
29779
- const beforeRaw = existsSync9(opts.configPath) ? readFileSync7(opts.configPath, "utf8") : "";
29956
+ const beforeRaw = existsSync10(opts.configPath) ? readFileSync8(opts.configPath, "utf8") : "";
29780
29957
  const beforeDoc = import_yaml5.parseDocument(beforeRaw, { merge: false, strict: false });
29781
29958
  beforeData = beforeDoc.toJS();
29782
29959
  } catch {
@@ -30225,12 +30402,12 @@ function collectScheduleEntries(config) {
30225
30402
  // src/agent-scheduler/replay.ts
30226
30403
  var STALE_LOOKBACK_MAX_MIN = 14 * 24 * 60;
30227
30404
  function readRecentFires(jsonlPath) {
30228
- const fs2 = __require("node:fs");
30229
- if (!fs2.existsSync(jsonlPath))
30405
+ const fs3 = __require("node:fs");
30406
+ if (!fs3.existsSync(jsonlPath))
30230
30407
  return [];
30231
30408
  let raw;
30232
30409
  try {
30233
- raw = fs2.readFileSync(jsonlPath, "utf8");
30410
+ raw = fs3.readFileSync(jsonlPath, "utf8");
30234
30411
  } catch {
30235
30412
  return [];
30236
30413
  }
@@ -30248,6 +30425,13 @@ function readRecentFires(jsonlPath) {
30248
30425
  }
30249
30426
 
30250
30427
  // src/host-control/server.ts
30428
+ var HOSTD_BROKER_SOCKET_PATH = "/run/switchroom/broker/hostd/sock";
30429
+ var DEFAULT_OPERATOR_ATTEST_VERBS = [
30430
+ "update_apply",
30431
+ "apply",
30432
+ "rollout"
30433
+ ];
30434
+ var ATTEST_AUDIT_METHOD = "passphrase-attest";
30251
30435
  function resolveDigests(imageRefs) {
30252
30436
  const out = new Map;
30253
30437
  for (const ref of imageRefs) {
@@ -30272,9 +30456,9 @@ function resolveDigests(imageRefs) {
30272
30456
  function readCachedInstallType(bindRoot) {
30273
30457
  const cacheDir = join10(bindRoot, ".switchroom");
30274
30458
  const cachePath = join10(cacheDir, "install-type.json");
30275
- if (existsSync10(cachePath)) {
30459
+ if (existsSync11(cachePath)) {
30276
30460
  try {
30277
- const raw = readFileSync8(cachePath, "utf-8");
30461
+ const raw = readFileSync9(cachePath, "utf-8");
30278
30462
  const parsed = JSON.parse(raw);
30279
30463
  if (parsed && typeof parsed.install_type === "string" && typeof parsed.detected_at === "string") {
30280
30464
  return parsed;
@@ -30323,7 +30507,7 @@ function writeFileInPlacePreservingInode(targetPath, content) {
30323
30507
  } finally {
30324
30508
  closeSync3(fd);
30325
30509
  }
30326
- const readBack = readFileSync8(targetPath);
30510
+ const readBack = readFileSync9(targetPath);
30327
30511
  if (readBack.length !== buf.length) {
30328
30512
  throw new Error(`in-place write short: wrote ${buf.length} bytes but read back ${readBack.length}`);
30329
30513
  }
@@ -30369,7 +30553,7 @@ class HostdServer {
30369
30553
  await chmod(dir, 493).catch(() => {
30370
30554
  return;
30371
30555
  });
30372
- if (existsSync10(sockPath))
30556
+ if (existsSync11(sockPath))
30373
30557
  await unlink(sockPath).catch(() => {
30374
30558
  return;
30375
30559
  });
@@ -30406,7 +30590,7 @@ class HostdServer {
30406
30590
  await chmod(dir, 493).catch(() => {
30407
30591
  return;
30408
30592
  });
30409
- if (existsSync10(sockPath))
30593
+ if (existsSync11(sockPath))
30410
30594
  await unlink(sockPath).catch(() => {
30411
30595
  return;
30412
30596
  });
@@ -30442,11 +30626,11 @@ class HostdServer {
30442
30626
  }
30443
30627
  async reconcileOrphanedFleetMutations() {
30444
30628
  const path2 = this.auditLogPath();
30445
- if (!existsSync10(path2))
30629
+ if (!existsSync11(path2))
30446
30630
  return;
30447
30631
  let raw;
30448
30632
  try {
30449
- raw = readFileSync8(path2, "utf-8");
30633
+ raw = readFileSync9(path2, "utf-8");
30450
30634
  } catch {
30451
30635
  return;
30452
30636
  }
@@ -30584,6 +30768,15 @@ class HostdServer {
30584
30768
  socket.end();
30585
30769
  return;
30586
30770
  }
30771
+ const attest = await this.checkOperatorAttest(req, caller);
30772
+ if (attest.denied !== null) {
30773
+ const resp2 = deniedResponse(req.request_id, attest.denied);
30774
+ await this.writeAudit({ caller, req, resp: resp2, method: attest.method });
30775
+ socket.write(encodeResponse(resp2));
30776
+ socket.end();
30777
+ return;
30778
+ }
30779
+ const attestMethod = attest.method;
30587
30780
  const started = Date.now();
30588
30781
  let resp;
30589
30782
  try {
@@ -30642,10 +30835,70 @@ class HostdServer {
30642
30835
  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);
30643
30836
  resp = { ...resp, error: `hostd dispatch failed: ${msg}` };
30644
30837
  }
30645
- await this.writeAudit({ caller, req, resp });
30838
+ await this.writeAudit({ caller, req, resp, method: attestMethod });
30646
30839
  socket.write(encodeResponse(resp));
30647
30840
  socket.end();
30648
30841
  }
30842
+ async checkOperatorAttest(req, caller) {
30843
+ const hostdCfg = this.opts.config.hostd;
30844
+ if (hostdCfg?.operator_attest_enabled !== true) {
30845
+ return { denied: null };
30846
+ }
30847
+ if (caller.kind === "operator")
30848
+ return { denied: null };
30849
+ const requiredVerbs = hostdCfg.operator_attest_required_verbs ?? DEFAULT_OPERATOR_ATTEST_VERBS;
30850
+ const required = requiredVerbs.includes(req.op);
30851
+ const passphrase = req.operator_passphrase;
30852
+ if (!required) {
30853
+ if (passphrase === undefined)
30854
+ return { denied: null };
30855
+ const v2 = await this.attestVerifier().verify(passphrase);
30856
+ return v2.ok ? { denied: null, method: ATTEST_AUDIT_METHOD } : {
30857
+ denied: `${req.op} operator-attest failed: ${v2.reason}`,
30858
+ method: ATTEST_AUDIT_METHOD
30859
+ };
30860
+ }
30861
+ if (passphrase === undefined) {
30862
+ return {
30863
+ denied: `${req.op} requires operator-passphrase attestation ` + `(hostd.operator_attest_enabled is on and ${req.op} is in ` + `operator_attest_required_verbs)`,
30864
+ method: ATTEST_AUDIT_METHOD
30865
+ };
30866
+ }
30867
+ const v = await this.attestVerifier().verify(passphrase);
30868
+ return v.ok ? { denied: null, method: ATTEST_AUDIT_METHOD } : {
30869
+ denied: `${req.op} operator-attest failed: ${v.reason}`,
30870
+ method: ATTEST_AUDIT_METHOD
30871
+ };
30872
+ }
30873
+ attestVerifier() {
30874
+ if (this.opts.attestVerifier)
30875
+ return this.opts.attestVerifier;
30876
+ if (!this._defaultAttestVerifier) {
30877
+ this._defaultAttestVerifier = {
30878
+ verify: (passphrase) => this.brokerAttestVerify(passphrase)
30879
+ };
30880
+ }
30881
+ return this._defaultAttestVerifier;
30882
+ }
30883
+ _defaultAttestVerifier;
30884
+ async brokerAttestVerify(passphrase) {
30885
+ const res = await rpcRaw({
30886
+ v: 1,
30887
+ op: "list_grants",
30888
+ agent: "hostd",
30889
+ passphrase
30890
+ }, { vaultBrokerSocket: HOSTD_BROKER_SOCKET_PATH, timeoutMs: 5000 });
30891
+ if (res.kind === "unreachable") {
30892
+ return { ok: false, reason: `broker unreachable: ${res.msg}` };
30893
+ }
30894
+ const resp = res.resp;
30895
+ if (resp.ok === true)
30896
+ return { ok: true };
30897
+ return {
30898
+ ok: false,
30899
+ reason: `broker denied attestation (${resp.code ?? "DENIED"})`
30900
+ };
30901
+ }
30649
30902
  checkGate(req, caller) {
30650
30903
  if (caller.kind === "operator")
30651
30904
  return null;
@@ -30779,7 +31032,7 @@ class HostdServer {
30779
31032
  join10(root, "profiles"),
30780
31033
  join10(root, "profiles", "default"),
30781
31034
  join10(root, "vendor", "hindsight-memory")
30782
- ].filter((p) => !existsSync10(p));
31035
+ ].filter((p) => !existsSync11(p));
30783
31036
  }
30784
31037
  applyAssetPreflight(request_id, started) {
30785
31038
  const missing = this.missingApplyAssets();
@@ -30889,6 +31142,26 @@ class HostdServer {
30889
31142
  const assetDenied = this.applyAssetPreflight(req.request_id, started);
30890
31143
  if (assetDenied)
30891
31144
  return assetDenied;
31145
+ if (req.args.agents !== undefined) {
31146
+ let validAgents = null;
31147
+ try {
31148
+ const cfg = loadConfig(this.opts.configPath);
31149
+ validAgents = Object.keys(cfg.agents ?? {});
31150
+ } catch {
31151
+ validAgents = null;
31152
+ }
31153
+ if (validAgents !== null) {
31154
+ const buildDenied = (human) => err("E_UNKNOWN_AGENT", human).fixBadInput("agents").op("rollout").caller(caller.kind).agentName(caller.kind === "agent" ? caller.name : undefined).asDenied().build(req.request_id, Date.now() - started);
31155
+ const requested = req.args.agents;
31156
+ if (requested.length === 0) {
31157
+ return buildDenied(`empty agents list — pass at least one agent, ` + `or omit "agents" to roll all. Valid agents: ${validAgents.join(", ")}`);
31158
+ }
31159
+ const unknown = requested.filter((a) => !validAgents.includes(a));
31160
+ if (unknown.length > 0) {
31161
+ return buildDenied(`unknown agent(s): ${unknown.join(", ")}. ` + `Valid agents: ${validAgents.join(", ")}`);
31162
+ }
31163
+ }
31164
+ }
30892
31165
  const entry = this.launchRollout(req.args, req.request_id, caller, started);
30893
31166
  return {
30894
31167
  v: 1,
@@ -30952,10 +31225,10 @@ class HostdServer {
30952
31225
  async beginSelfBump(req, caller, started) {
30953
31226
  const ownVersion = this.opts.selfVersion ?? SWITCHROOM_VERSION;
30954
31227
  const composePath = join10(this.hostdDirPath(), "docker-compose.yml");
30955
- if (!existsSync10(composePath)) {
31228
+ if (!existsSync11(composePath)) {
30956
31229
  return deniedResponse(req.request_id, `rollout: hostd's CLI (v${ownVersion}) is older than the target ` + `${req.args.pin} and needs a self-bump first, but its compose file ` + `is missing at ${composePath}. Run \`switchroom hostd install ` + `--tag ${req.args.pin}\` on the host, then re-run the roll. ` + `Nothing was changed.`, Date.now() - started);
30957
31230
  }
30958
- const before = readFileSync8(composePath, "utf8");
31231
+ const before = readFileSync9(composePath, "utf8");
30959
31232
  const bumped = bumpHostdComposeImageTag(before, req.args.pin);
30960
31233
  if (!bumped) {
30961
31234
  return deniedResponse(req.request_id, `rollout: hostd needs a self-bump to ${req.args.pin} but no ` + `switchroom-hostd image line was found in ${composePath} ` + `(hand-edited compose?). Run \`switchroom hostd install --tag ` + `${req.args.pin}\` on the host, then re-run the roll. Nothing ` + `was changed.`, Date.now() - started);
@@ -31086,11 +31359,11 @@ class HostdServer {
31086
31359
  }
31087
31360
  async resumePendingSelfBumpRollout() {
31088
31361
  const markerPath = join10(this.hostdDirPath(), SELF_BUMP_MARKER_FILENAME);
31089
- if (!existsSync10(markerPath))
31362
+ if (!existsSync11(markerPath))
31090
31363
  return;
31091
31364
  let raw;
31092
31365
  try {
31093
- raw = readFileSync8(markerPath, "utf8");
31366
+ raw = readFileSync9(markerPath, "utf8");
31094
31367
  } finally {
31095
31368
  try {
31096
31369
  unlinkSync2(markerPath);
@@ -31221,18 +31494,19 @@ class HostdServer {
31221
31494
  if (!verdict.ok) {
31222
31495
  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);
31223
31496
  }
31497
+ let beforeContent;
31498
+ try {
31499
+ beforeContent = readFileSync9(configPath, "utf-8");
31500
+ } catch {
31501
+ beforeContent = "";
31502
+ }
31224
31503
  if (caller.kind === "agent" && this.opts.config.agents[caller.name]?.admin !== true) {
31225
- let beforeContent;
31226
- try {
31227
- beforeContent = readFileSync8(configPath, "utf-8");
31228
- } catch {
31229
- beforeContent = "";
31230
- }
31231
31504
  const admission = admitSelfScopedNonAdminEdit(beforeContent, verdict.postApplyContent, caller.name);
31232
31505
  if (!admission.ok) {
31233
31506
  return err("E_NOT_SELF_SCOPED", admission.detail).why("non-admin agents may only add rules to their own " + "agents.<self>.tools.allow OR append their own " + "agents.<self>.memory.mental_models via config_propose_edit " + `(mental-model check: ${admission.mentalModelDetail})`).fixBadInput("unified_diff").op("config_propose_edit").caller("agent").agentName(caller.name).asDenied().build(req.request_id, Date.now() - started);
31234
31507
  }
31235
31508
  }
31509
+ const proposedChangedPaths = classifyBlastRadius(beforeContent, verdict.postApplyContent).changedPaths;
31236
31510
  if (!this.opts.approvalGateway) {
31237
31511
  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", [
31238
31512
  "ensure hostd was launched with --approval-gateway / telegram-plugin link"
@@ -31246,23 +31520,42 @@ class HostdServer {
31246
31520
  `);
31247
31521
  return await pending;
31248
31522
  }
31249
- const rate = this.checkConfigEditRate(callerName, Date.now());
31250
- if (!rate.ok) {
31251
- const retryAtIso = new Date(rate.retryAtMs).toISOString();
31252
- process.stderr.write(`hostd: config_propose_edit — RATE-LIMITED ${callerName} ` + `(>${rate.limit}/hour); next slot ${retryAtIso}
31523
+ const run = this.runConfigProposeRateThenApply(req, caller, callerName, configPath, started, proposedChangedPaths);
31524
+ this.inflightConfigProposals.set(dedupeKey, run);
31525
+ try {
31526
+ return await run;
31527
+ } finally {
31528
+ this.inflightConfigProposals.delete(dedupeKey);
31529
+ }
31530
+ }
31531
+ async runConfigProposeRateThenApply(req, caller, callerName, configPath, started, proposedChangedPaths) {
31532
+ let preApproved = false;
31533
+ try {
31534
+ if (this.opts.approvalGateway.checkPreApproved) {
31535
+ preApproved = await this.opts.approvalGateway.checkPreApproved(callerName, req.args.unified_diff);
31536
+ }
31537
+ } catch {
31538
+ preApproved = false;
31539
+ }
31540
+ if (!preApproved) {
31541
+ const rate = this.checkConfigEditRate(callerName, Date.now());
31542
+ if (!rate.ok) {
31543
+ const retryAtIso = new Date(rate.retryAtMs).toISOString();
31544
+ process.stderr.write(`hostd: config_propose_edit — RATE-LIMITED ${callerName} ` + `(>${rate.limit}/hour); next slot ${retryAtIso}
31253
31545
  `);
31254
- this.appendAuditRow({
31255
- ts: new Date().toISOString(),
31256
- op: "config_propose_edit",
31257
- phase: "rate_limited",
31258
- request_id: req.request_id,
31259
- caller: caller.kind === "agent" ? { kind: "agent", name: caller.name } : { kind: "operator" },
31260
- result: "denied",
31261
- exit_code: null,
31262
- duration_ms: Date.now() - started,
31263
- error: `E_RATE_LIMITED: >${rate.limit} config_propose_edit cards/hour`
31264
- });
31265
- return err("E_RATE_LIMITED", `config_propose_edit rate limit exceeded (max ${rate.limit}/hour for this agent)`).why(`next slot opens at ${retryAtIso}`).fixRetryAfter(retryAtIso).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);
31546
+ this.appendAuditRow({
31547
+ ts: new Date().toISOString(),
31548
+ op: "config_propose_edit",
31549
+ phase: "rate_limited",
31550
+ request_id: req.request_id,
31551
+ caller: caller.kind === "agent" ? { kind: "agent", name: caller.name } : { kind: "operator" },
31552
+ result: "denied",
31553
+ exit_code: null,
31554
+ duration_ms: Date.now() - started,
31555
+ error: `E_RATE_LIMITED: >${rate.limit} config_propose_edit cards/hour`
31556
+ });
31557
+ return err("E_RATE_LIMITED", `config_propose_edit rate limit exceeded (max ${rate.limit}/hour for this agent)`).why(`next slot opens at ${retryAtIso}`).fixRetryAfter(retryAtIso).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);
31558
+ }
31266
31559
  }
31267
31560
  this.appendAuditRow({
31268
31561
  ts: new Date().toISOString(),
@@ -31272,15 +31565,10 @@ class HostdServer {
31272
31565
  caller: caller.kind === "agent" ? { kind: "agent", name: caller.name } : { kind: "operator" },
31273
31566
  result: "started",
31274
31567
  exit_code: null,
31275
- duration_ms: Date.now() - started
31568
+ duration_ms: Date.now() - started,
31569
+ ...preApproved ? { pre_approved: true } : {}
31276
31570
  });
31277
- const run = this.runConfigProposeApprovalAndApply(req, caller, callerName, configPath, verdict.postApplyContent, started);
31278
- this.inflightConfigProposals.set(dedupeKey, run);
31279
- try {
31280
- return await run;
31281
- } finally {
31282
- this.inflightConfigProposals.delete(dedupeKey);
31283
- }
31571
+ return await this.runConfigProposeApprovalAndApply(req, caller, callerName, configPath, started, proposedChangedPaths);
31284
31572
  }
31285
31573
  inflightConfigProposals = new Map;
31286
31574
  configEditPostTimes = new Map;
@@ -31299,7 +31587,7 @@ class HostdServer {
31299
31587
  this.configEditPostTimes.set(callerName, prior);
31300
31588
  return { ok: true };
31301
31589
  }
31302
- async runConfigProposeApprovalAndApply(req, caller, callerName, configPath, postApply, started) {
31590
+ async runConfigProposeApprovalAndApply(req, caller, callerName, configPath, started, proposedChangedPaths) {
31303
31591
  const approvalId = (this.opts.generateApprovalId ?? defaultApprovalId)();
31304
31592
  const approval = await this.opts.approvalGateway.requestApproval({
31305
31593
  requestId: approvalId,
@@ -31328,7 +31616,7 @@ class HostdServer {
31328
31616
  try {
31329
31617
  let snapshot;
31330
31618
  try {
31331
- snapshot = readFileSync8(configPath, "utf-8");
31619
+ snapshot = readFileSync9(configPath, "utf-8");
31332
31620
  } catch (e) {
31333
31621
  await approval.finalize({
31334
31622
  outcome: "reconcile_failed_rolled_back",
@@ -31336,8 +31624,43 @@ class HostdServer {
31336
31624
  });
31337
31625
  return this.reconcileFailedRolledBack(`snapshot read failed: ${e.message}`, req, caller, started);
31338
31626
  }
31627
+ const reverdict = validateConfigEdit({
31628
+ configPath,
31629
+ targetPath: req.args.target_path,
31630
+ unifiedDiff: req.args.unified_diff
31631
+ });
31632
+ if (!reverdict.ok) {
31633
+ const why = `stored diff no longer applies against the current config` + `: ${reverdict.detail}`;
31634
+ await approval.finalize({
31635
+ outcome: "aborted_config_changed",
31636
+ detail: `config changed since proposal — re-propose (${why})`
31637
+ });
31638
+ return this.configChangedSinceProposal(why, req, caller, started);
31639
+ }
31640
+ const postApplyFresh = reverdict.postApplyContent;
31641
+ const applyChangedPaths = classifyBlastRadius(snapshot, postApplyFresh).changedPaths;
31642
+ const sameChangeSet = applyChangedPaths.length === proposedChangedPaths.length && applyChangedPaths.every((p, i) => p === proposedChangedPaths[i]);
31643
+ if (!sameChangeSet) {
31644
+ const why = `re-applied diff changes different config paths than approved ` + `(approved: [${proposedChangedPaths.join(", ")}]; ` + `would change: [${applyChangedPaths.join(", ")}])`;
31645
+ await approval.finalize({
31646
+ outcome: "aborted_config_changed",
31647
+ detail: `config changed since proposal — re-propose (${why})`
31648
+ });
31649
+ return this.configChangedSinceProposal(why, req, caller, started);
31650
+ }
31651
+ if (caller.kind === "agent" && this.opts.config.agents[caller.name]?.admin !== true) {
31652
+ const reAdmission = admitSelfScopedNonAdminEdit(snapshot, postApplyFresh, caller.name);
31653
+ if (!reAdmission.ok) {
31654
+ const why = `re-applied diff is no longer self-scoped for non-admin caller ` + `"${caller.name}": ${reAdmission.detail}`;
31655
+ await approval.finalize({
31656
+ outcome: "aborted_config_changed",
31657
+ detail: `config changed since proposal — re-propose (${why})`
31658
+ });
31659
+ return this.configChangedSinceProposal(why, req, caller, started);
31660
+ }
31661
+ }
31339
31662
  try {
31340
- writeFileInPlacePreservingInode(configPath, postApply);
31663
+ writeFileInPlacePreservingInode(configPath, postApplyFresh);
31341
31664
  } catch (e) {
31342
31665
  try {
31343
31666
  writeFileInPlacePreservingInode(configPath, snapshot);
@@ -31351,7 +31674,7 @@ class HostdServer {
31351
31674
  const runner = this.opts.runReconcile ?? (async () => this.runSwitchroom(caller.kind === "agent" ? ["apply", "--only", callerName, "--non-interactive"] : ["apply", "--non-interactive"]));
31352
31675
  const recRes = await runner({ requestId: approvalId });
31353
31676
  if (recRes.exit_code === 0) {
31354
- const blast = classifyBlastRadius(snapshot, postApply);
31677
+ const blast = classifyBlastRadius(snapshot, postApplyFresh);
31355
31678
  await approval.finalize({
31356
31679
  outcome: "applied",
31357
31680
  affectedAgents: blast.agents,
@@ -31389,6 +31712,11 @@ class HostdServer {
31389
31712
  release();
31390
31713
  }
31391
31714
  }
31715
+ configChangedSinceProposal(why, req, caller, started) {
31716
+ const legacy = `E_CONFIG_CHANGED: config changed since proposal — re-propose (${why})`;
31717
+ const built = err("E_CONFIG_CHANGED", "config changed since the proposal was validated; re-propose against the current config").why(why).fixBadInput("unified_diff").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);
31718
+ return { ...built, error: legacy };
31719
+ }
31392
31720
  reconcileFailedRolledBack(detail, req, caller, started, output) {
31393
31721
  const legacy = `E_RECONCILE_FAILED_ROLLED_BACK: ${detail}`;
31394
31722
  const built = err("E_RECONCILE_FAILED_ROLLED_BACK", "config write or reconcile failed; live file rolled back to snapshot").why(detail).fixOperatorAction("infra", [
@@ -31581,7 +31909,7 @@ ${output.recovery.stderr}` : "";
31581
31909
  return this.opts.imageRefsForDigests();
31582
31910
  try {
31583
31911
  const composePath = join10(this.opts.bindRoot ?? this.opts.homeDir, ".switchroom", "compose", "docker-compose.yml");
31584
- if (!existsSync10(composePath))
31912
+ if (!existsSync11(composePath))
31585
31913
  return [];
31586
31914
  const r = spawnSync3("docker", [
31587
31915
  "compose",
@@ -31720,10 +32048,10 @@ ${output.recovery.stderr}` : "";
31720
32048
  }
31721
32049
  rolloutRowExistsInLog(targetRequestId) {
31722
32050
  const path2 = this.auditLogPath();
31723
- if (!existsSync10(path2))
32051
+ if (!existsSync11(path2))
31724
32052
  return false;
31725
32053
  try {
31726
- const raw = readFileSync8(path2, "utf-8");
32054
+ const raw = readFileSync9(path2, "utf-8");
31727
32055
  return latestRolloutRowForRequest(raw, targetRequestId) !== null;
31728
32056
  } catch {
31729
32057
  return false;
@@ -31731,11 +32059,11 @@ ${output.recovery.stderr}` : "";
31731
32059
  }
31732
32060
  rolloutStatusFromLog(responseRequestId, targetRequestId, started) {
31733
32061
  const path2 = this.auditLogPath();
31734
- if (!existsSync10(path2))
32062
+ if (!existsSync11(path2))
31735
32063
  return null;
31736
32064
  let raw;
31737
32065
  try {
31738
- raw = readFileSync8(path2, "utf-8");
32066
+ raw = readFileSync9(path2, "utf-8");
31739
32067
  } catch {
31740
32068
  return null;
31741
32069
  }
@@ -31840,6 +32168,7 @@ ${output.recovery.stderr}` : "";
31840
32168
  await this.appendAuditRow({
31841
32169
  ts: new Date().toISOString(),
31842
32170
  op: args.req.op,
32171
+ ...args.method ? { method: args.method } : {},
31843
32172
  caller: args.caller.kind === "agent" ? { kind: "agent", name: args.caller.name } : { kind: "operator" },
31844
32173
  request_id: args.req.request_id,
31845
32174
  result: args.resp.result,
@@ -32068,12 +32397,83 @@ function isSafeExecArgvElement(s) {
32068
32397
 
32069
32398
  // src/host-control/approval-gateway.ts
32070
32399
  import { connect as connect2 } from "node:net";
32400
+ import { randomBytes as randomBytes2 } from "node:crypto";
32401
+ var PRE_APPROVED_QUERY_TIMEOUT_MS = 2000;
32071
32402
 
32072
32403
  class SocketApprovalGateway {
32073
32404
  opts;
32074
32405
  constructor(opts) {
32075
32406
  this.opts = opts;
32076
32407
  }
32408
+ async checkPreApproved(agentName, unifiedDiff) {
32409
+ const sockPath = this.opts.resolveGatewaySocket(agentName);
32410
+ if (sockPath === null)
32411
+ return false;
32412
+ const log = this.opts.log ?? (() => {});
32413
+ const correlationId = `preapp-${randomBytes2(8).toString("hex")}`;
32414
+ return await new Promise((resolve9) => {
32415
+ let settled = false;
32416
+ const client2 = connect2({ path: sockPath });
32417
+ let buffer = "";
32418
+ const done = (result) => {
32419
+ if (settled)
32420
+ return;
32421
+ settled = true;
32422
+ clearTimeout(timer);
32423
+ try {
32424
+ client2.destroy();
32425
+ } catch {}
32426
+ resolve9(result);
32427
+ };
32428
+ const timer = setTimeout(() => {
32429
+ log(`checkPreApproved timed out after ${PRE_APPROVED_QUERY_TIMEOUT_MS}ms (agent=${agentName}) — failing closed`);
32430
+ done(false);
32431
+ }, PRE_APPROVED_QUERY_TIMEOUT_MS);
32432
+ client2.on("connect", () => {
32433
+ try {
32434
+ client2.write(JSON.stringify({
32435
+ type: "check_pre_approved",
32436
+ agentName,
32437
+ correlationId,
32438
+ unifiedDiff
32439
+ }) + `
32440
+ `);
32441
+ } catch (err2) {
32442
+ log(`check_pre_approved write failed (agent=${agentName}): ${err2.message} — failing closed`);
32443
+ done(false);
32444
+ }
32445
+ });
32446
+ client2.on("data", (chunk2) => {
32447
+ buffer += chunk2.toString("utf8");
32448
+ const lines = buffer.split(`
32449
+ `);
32450
+ buffer = lines.pop() ?? "";
32451
+ for (const line of lines) {
32452
+ if (!line.trim())
32453
+ continue;
32454
+ let parsed;
32455
+ try {
32456
+ parsed = JSON.parse(line);
32457
+ } catch {
32458
+ done(false);
32459
+ return;
32460
+ }
32461
+ const obj = parsed;
32462
+ if (obj.type === "pre_approved_result" && obj.correlationId === correlationId) {
32463
+ done(obj.preApproved === true);
32464
+ return;
32465
+ }
32466
+ done(false);
32467
+ return;
32468
+ }
32469
+ });
32470
+ client2.on("error", (err2) => {
32471
+ log(`checkPreApproved socket error (agent=${agentName}): ${err2.message} — failing closed`);
32472
+ done(false);
32473
+ });
32474
+ client2.on("close", () => done(false));
32475
+ });
32476
+ }
32077
32477
  async requestApproval(req) {
32078
32478
  const sockPath = this.opts.resolveGatewaySocket(req.agentName);
32079
32479
  if (sockPath === null) {
@@ -32914,7 +33314,7 @@ async function loadConfigResilient() {
32914
33314
  try {
32915
33315
  for (const name of readdirSync4(agentsDir).sort()) {
32916
33316
  const sock = resolve9(agentsDir, name, "telegram", "gateway.sock");
32917
- if (existsSync11(sock))
33317
+ if (existsSync12(sock))
32918
33318
  out.push({ agent: name, sock });
32919
33319
  }
32920
33320
  } catch {}
@@ -32964,7 +33364,7 @@ async function main() {
32964
33364
  const agentsDir = process.env.SWITCHROOM_AGENTS_DIR ?? join11(homedir6(), ".switchroom", "agents");
32965
33365
  const resolveGatewaySocket = (agentName) => {
32966
33366
  const sock = resolve9(agentsDir, agentName, "telegram", "gateway.sock");
32967
- return existsSync11(sock) ? sock : null;
33367
+ return existsSync12(sock) ? sock : null;
32968
33368
  };
32969
33369
  const approvalGateway = new SocketApprovalGateway({
32970
33370
  resolveGatewaySocket,
@@ -32992,7 +33392,11 @@ async function main() {
32992
33392
  ])),
32993
33393
  ...config.hostd ? {
32994
33394
  hostd: {
32995
- ...config.hostd.config_edit_enabled !== undefined ? { config_edit_enabled: config.hostd.config_edit_enabled } : {}
33395
+ ...config.hostd.config_edit_enabled !== undefined ? { config_edit_enabled: config.hostd.config_edit_enabled } : {},
33396
+ ...config.hostd.operator_attest_enabled !== undefined ? { operator_attest_enabled: config.hostd.operator_attest_enabled } : {},
33397
+ ...config.hostd.operator_attest_required_verbs !== undefined ? {
33398
+ operator_attest_required_verbs: config.hostd.operator_attest_required_verbs
33399
+ } : {}
32996
33400
  }
32997
33401
  } : {}
32998
33402
  },