switchroom 0.18.9 → 0.18.11

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 (65) hide show
  1. package/dist/agent-scheduler/index.js +1 -0
  2. package/dist/auth-broker/index.js +198 -13
  3. package/dist/cli/notion-write-pretool.mjs +1 -0
  4. package/dist/cli/switchroom.js +214 -83
  5. package/dist/cli/ui/apple-touch-icon.png +0 -0
  6. package/dist/cli/ui/favicon-32.png +0 -0
  7. package/dist/cli/ui/favicon.ico +0 -0
  8. package/dist/host-control/main.js +760 -257
  9. package/dist/vault/approvals/kernel-server.js +3 -1
  10. package/dist/vault/broker/server.js +3 -1
  11. package/package.json +1 -1
  12. package/profiles/_base/start.sh.hbs +119 -37
  13. package/profiles/_shared/dev-protocol.md.hbs +42 -0
  14. package/skills/dev-protocol/SKILL.md +131 -0
  15. package/telegram-plugin/README.md +2 -1
  16. package/telegram-plugin/admin-commands/dispatch.test.ts +40 -2
  17. package/telegram-plugin/admin-commands/index.ts +6 -1
  18. package/telegram-plugin/bridge/bridge.ts +23 -1
  19. package/telegram-plugin/bridge/crash-breadcrumb.ts +42 -0
  20. package/telegram-plugin/chat-lock.ts +13 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +24 -1
  22. package/telegram-plugin/dist/gateway/gateway.js +1832 -263
  23. package/telegram-plugin/dist/server.js +29 -2
  24. package/telegram-plugin/fallback-card-collapse.ts +131 -0
  25. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +546 -0
  26. package/telegram-plugin/gateway/effort-command.ts +47 -3
  27. package/telegram-plugin/gateway/gateway.ts +1435 -211
  28. package/telegram-plugin/gateway/model-command.ts +94 -8
  29. package/telegram-plugin/gateway/pending-session-command.ts +365 -0
  30. package/telegram-plugin/gateway/permission-timeout.ts +25 -0
  31. package/telegram-plugin/gateway/resume-inbound-builder.ts +23 -3
  32. package/telegram-plugin/gateway/session-model-file.ts +166 -23
  33. package/telegram-plugin/gateway/stop-command.ts +56 -0
  34. package/telegram-plugin/photo-precheck.ts +201 -0
  35. package/telegram-plugin/quota-watch.ts +141 -2
  36. package/telegram-plugin/registry/subagents-schema.ts +26 -3
  37. package/telegram-plugin/registry/subagents.test.ts +67 -0
  38. package/telegram-plugin/retry-api-call.ts +31 -0
  39. package/telegram-plugin/subagent-watcher.ts +392 -1
  40. package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +576 -0
  41. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +11 -5
  42. package/telegram-plugin/tests/chat-lock-unhandled-rejection.test.ts +101 -0
  43. package/telegram-plugin/tests/crash-breadcrumb.test.ts +57 -0
  44. package/telegram-plugin/tests/effort-command.test.ts +59 -2
  45. package/telegram-plugin/tests/fallback-card-collapse.test.ts +104 -0
  46. package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +124 -0
  47. package/telegram-plugin/tests/gateway-secret-detect.test.ts +7 -1
  48. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +19 -11
  49. package/telegram-plugin/tests/model-command.test.ts +46 -3
  50. package/telegram-plugin/tests/pending-session-command.test.ts +322 -0
  51. package/telegram-plugin/tests/permission-timeout.test.ts +26 -0
  52. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +16 -0
  53. package/telegram-plugin/tests/photo-dimension-fallback.test.ts +129 -0
  54. package/telegram-plugin/tests/photo-precheck.test.ts +240 -0
  55. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +85 -0
  56. package/telegram-plugin/tests/quota-watch.test.ts +225 -0
  57. package/telegram-plugin/tests/session-model-file.test.ts +101 -2
  58. package/telegram-plugin/tests/stop-command.test.ts +234 -0
  59. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +27 -9
  60. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +398 -0
  61. package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +172 -0
  62. package/telegram-plugin/tests/worker-activity-feed.test.ts +37 -0
  63. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +18 -4
  64. package/telegram-plugin/welcome-text.ts +4 -3
  65. package/telegram-plugin/worker-activity-feed.ts +27 -0
@@ -14232,6 +14232,7 @@ var init_schema = __esm(() => {
14232
14232
  uid: exports_external.number().int().nonnegative().optional().describe("Optional UID to chown the consumer socket to (defaults to 0 = root, " + "suitable for sibling containers running as root)."),
14233
14233
  mirror_dir: exports_external.string().optional().describe("Optional host-side directory path. When set, the broker actively " + "writes the consumer's effective-account `.credentials.json` mirror " + "here \u2014 in addition to serving creds on demand via `get-credentials`. " + "Use this to eliminate the pull-latency gap: without a mirror the " + "consumer only gets failover creds at its next scheduled re-fetch " + "(up to 30 min). With a mirror the broker pushes failover creds " + "immediately when it detects exhaustion (consumer-quota-sensor tick, " + "or a mark-exhausted RPC on the pinned account). The directory must " + "be accessible to the broker container (bind-mounted from the host) " + "and to the consumer container; the broker writes " + "`<mirror_dir>/.credentials.json` atomically. Chown is attempted to " + "`uid` (default 0) \u2014 swallowed when CAP_CHOWN is absent.")
14234
14234
  })).optional().describe("Non-agent peers that hold a broker socket (RFC H \u00a74.8). Each gets " + "its own `/run/switchroom/auth-broker/<name>/sock` chowned to its UID. " + "Consumers cannot be admins; a consumer name that collides with an " + "agent (whether that agent has `admin: true` or not) is a config " + "error caught at schema validation."),
14235
+ proactive_failover_pct: exports_external.number().min(1).max(99).optional().describe("Soft-avoid threshold (percent) for proactive auth failover (#3031). " + "When set (e.g. 95), an account whose fresh 7-day utilization is at/" + "above this value \u2014 or whose 5h utilization is at/above min(pct+3, " + "98) \u2014 enters the SOFT-AVOID preference tier: the broker prefers a " + "fully-eligible fallback account on the serving path, without ever " + "blocking the account (the hard wall stays 99.5). Enter/exit uses " + "hysteresis (exit below pct-5 or on window reset) so the preference " + "does not flap between probe ticks. Accounts serving past the wall " + "via `allow_overage_accounts` are never soft-avoided. UNSET (the " + "default) disables the tier entirely \u2014 behavior is identical to a " + "fleet without this field."),
14235
14236
  allow_overage_accounts: exports_external.array(exports_external.string().min(1)).optional().describe("Opt-in list of account labels (bare strings matching `auth.active` / " + "`auth.fallback_order` entries) that may be served PAST the weekly " + "utilization wall when Anthropic overage billing is available for the " + "account (`overageStatus === 'allowed'`). Overage is REAL MONEY \u2014 " + "default is empty (no account gets this). An account in this list is " + "only kept eligible when its fresh quota snapshot reports " + "`overageStatus: 'allowed'` AND `overageDisabledReason` is NOT " + "'out_of_credits' (i.e. the overage credit has not been exhausted). " + "As soon as `overageDisabledReason` becomes 'out_of_credits', the " + "account is blocked immediately regardless of this flag. Overage lifts " + "ONLY the utilization wall \u2014 it cannot lift an active exhaustion mark " + "written by a real 429 (`mark-exhausted`).")
14236
14237
  }).optional().describe("Switchroom-auth-broker configuration (RFC H). Fleet-wide active account, " + "fallback order, admin-agent ACL, and ephemeral-consumer surface. " + "Required from the v0.8+ schema onwards; pre-v0.8 fleets are migrated " + "in-place by `switchroom apply` (see src/auth/migrate-schema.ts)."),
14237
14238
  drive: GoogleWorkspaceConfigSchema.describe("RFC D legacy key \u2014 use `google_workspace:` instead. Optional Google " + "Workspace onboarding configuration. When set, supplies Google OAuth " + "client credentials, the approver allowlist for `switchroom drive " + "connect`, and the optional tier knob. Env vars " + "(SWITCHROOM_GOOGLE_CLIENT_ID, SWITCHROOM_GOOGLE_CLIENT_SECRET, " + "SWITCHROOM_APPROVER_USER_ID) take precedence over this block when " + "set, preserving back-compat with the env-only flow shipped in #766."),
@@ -17427,6 +17428,40 @@ function registerAgentConfigCommands(program2) {
17427
17428
  process.exit(1);
17428
17429
  }
17429
17430
  }));
17431
+ config.command("check").description("Validate a switchroom.yaml (YAML parse incl. duplicate keys, schema, cross-field checks) without starting anything").option("--file <path>", "YAML file to validate (defaults to the discovered switchroom.yaml)").action(async (opts) => {
17432
+ let path;
17433
+ try {
17434
+ path = opts.file ?? findConfigFile();
17435
+ } catch (err) {
17436
+ if (!(err instanceof ConfigError))
17437
+ throw err;
17438
+ process.stderr.write(`INVALID: switchroom.yaml not found
17439
+ ${err.message}
17440
+ `);
17441
+ for (const d of err.details ?? []) {
17442
+ process.stderr.write(`${d}
17443
+ `);
17444
+ }
17445
+ process.exit(1);
17446
+ }
17447
+ try {
17448
+ loadConfig(path);
17449
+ } catch (err) {
17450
+ if (err instanceof ConfigError) {
17451
+ process.stderr.write(`INVALID: ${path}
17452
+ ${err.message}
17453
+ `);
17454
+ for (const d of err.details ?? []) {
17455
+ process.stderr.write(`${d}
17456
+ `);
17457
+ }
17458
+ process.exit(1);
17459
+ }
17460
+ throw err;
17461
+ }
17462
+ process.stdout.write(`OK: ${path} is a valid switchroom config
17463
+ `);
17464
+ });
17430
17465
  config.command("whoami").description("Emit what this agent is allowed to do (tools, MCP, vault key-names, powers) as JSON \u2014 its own sandbox, computed from live enforcement").option("--agent <name>", "Target agent (defaults to $SWITCHROOM_AGENT_NAME)").action(withConfigError(async (opts) => {
17431
17466
  let agent;
17432
17467
  try {
@@ -17587,6 +17622,7 @@ function registerAgentConfigCommands(program2) {
17587
17622
  var AUDIT_ROOT;
17588
17623
  var init_agent_config = __esm(() => {
17589
17624
  init_helpers();
17625
+ init_loader();
17590
17626
  init_merge();
17591
17627
  init_acl();
17592
17628
  init_cron_introspect();
@@ -23256,6 +23292,14 @@ function renderExecutionDisciplineFragment(context = {}, profilesRoot = PROFILES
23256
23292
  const template = import_handlebars.default.compile(source, { noEscape: true });
23257
23293
  return template(context).trimEnd();
23258
23294
  }
23295
+ function renderDevProtocolFragment(context = {}, profilesRoot = PROFILES_ROOT) {
23296
+ const fragPath = join4(resolve4(profilesRoot, "_shared"), "dev-protocol.md.hbs");
23297
+ if (!existsSync6(fragPath))
23298
+ return "";
23299
+ const source = readFileSync6(fragPath, "utf-8");
23300
+ const template = import_handlebars.default.compile(source, { noEscape: true });
23301
+ return template(context).trimEnd();
23302
+ }
23259
23303
  function renderReplyDisciplineFragment(context = {}, profilesRoot = PROFILES_ROOT) {
23260
23304
  const fragPath = join4(resolve4(profilesRoot, "_shared"), "reply-discipline.md.hbs");
23261
23305
  if (!existsSync6(fragPath))
@@ -23297,7 +23341,7 @@ var init_profiles = __esm(() => {
23297
23341
  return typeof value === "number" && Number.isFinite(value);
23298
23342
  });
23299
23343
  SHARED_FRAGMENTS_DIR = resolve4(PROFILES_ROOT, "_shared");
23300
- SHARED_FRAGMENTS = ["vault-protocol", "agent-self-service", "execution-discipline", "reply-discipline"];
23344
+ SHARED_FRAGMENTS = ["vault-protocol", "agent-self-service", "execution-discipline", "reply-discipline", "dev-protocol"];
23301
23345
  for (const name of SHARED_FRAGMENTS) {
23302
23346
  const fragPath = join4(SHARED_FRAGMENTS_DIR, `${name}.md.hbs`);
23303
23347
  if (existsSync6(fragPath)) {
@@ -23383,7 +23427,8 @@ function getBuiltinDefaultSkillEntries() {
23383
23427
  "switchroom-status",
23384
23428
  "switchroom-health",
23385
23429
  "switchroom-runtime",
23386
- "mental-model-curator"
23430
+ "mental-model-curator",
23431
+ "dev-protocol"
23387
23432
  ];
23388
23433
  return [
23389
23434
  ...anthropic.map((key) => ({ key, optOutKey: key, source: "anthropic" })),
@@ -27277,6 +27322,13 @@ function scaffoldAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchro
27277
27322
  rendered = rendered.trimEnd() + `
27278
27323
 
27279
27324
  ` + executionDiscipline + `
27325
+ `;
27326
+ }
27327
+ const devProtocol = renderDevProtocolFragment(context);
27328
+ if (devProtocol) {
27329
+ rendered = rendered.trimEnd() + `
27330
+
27331
+ ` + devProtocol + `
27280
27332
  `;
27281
27333
  }
27282
27334
  }
@@ -28111,6 +28163,13 @@ ${baseAppend}` : TELEGRAM_FORMATTING_FLOOR_CARD;
28111
28163
  rendered = rendered.trimEnd() + `
28112
28164
 
28113
28165
  ` + executionDiscipline + `
28166
+ `;
28167
+ }
28168
+ const devProtocol = renderDevProtocolFragment(claudeContext);
28169
+ if (devProtocol) {
28170
+ rendered = rendered.trimEnd() + `
28171
+
28172
+ ` + devProtocol + `
28114
28173
  `;
28115
28174
  }
28116
28175
  let composed = composeWithSidecar(rendered, claudeCustomPath);
@@ -63375,9 +63434,9 @@ __export(exports_server, {
63375
63434
  dispatchTool: () => dispatchTool,
63376
63435
  TOOLS: () => TOOLS
63377
63436
  });
63378
- import { spawnSync as spawnSync17 } from "node:child_process";
63437
+ import { spawnSync as spawnSync18 } from "node:child_process";
63379
63438
  function execCli(args, stdin) {
63380
- const r = spawnSync17(CLI_BIN, args, {
63439
+ const r = spawnSync18(CLI_BIN, args, {
63381
63440
  encoding: "utf-8",
63382
63441
  env: process.env,
63383
63442
  timeout: 15000,
@@ -63881,6 +63940,7 @@ __export(exports_server2, {
63881
63940
  resolveAuditLogPath: () => resolveAuditLogPath,
63882
63941
  getLastUpdateApplyStatus: () => getLastUpdateApplyStatus,
63883
63942
  dispatchTool: () => dispatchTool2,
63943
+ UPDATE_APPLY_SEMVER_PIN_ADVISORY: () => UPDATE_APPLY_SEMVER_PIN_ADVISORY,
63884
63944
  TOOLS: () => TOOLS2
63885
63945
  });
63886
63946
  import { randomBytes as randomBytes16 } from "node:crypto";
@@ -63895,7 +63955,7 @@ function wireTimeoutForOp(op) {
63895
63955
  return WIRE_TIMEOUT_MS_BY_OP[op] ?? DEFAULT_WIRE_TIMEOUT_MS;
63896
63956
  }
63897
63957
  async function dispatchTool2(name, args) {
63898
- if (name === "get_status") {
63958
+ if (name === "get_status" && args.request_id === undefined) {
63899
63959
  return getLastUpdateApplyStatus();
63900
63960
  }
63901
63961
  if (!SELF_AGENT) {
@@ -63906,6 +63966,7 @@ async function dispatchTool2(name, args) {
63906
63966
  return errorText2(`hostd MCP: socket not bound at ${sockPath}. The host-control ` + `daemon is either not installed (run \`switchroom hostd install\`) ` + `or this agent isn't admin-flagged in switchroom.yaml. RFC C ` + `bind-mounts the per-agent socket only when host_control.enabled ` + `is true AND the agent has admin: true.`);
63907
63967
  }
63908
63968
  let req;
63969
+ let advisory;
63909
63970
  switch (name) {
63910
63971
  case "agent_restart": {
63911
63972
  if (!args.name)
@@ -63998,6 +64059,9 @@ async function dispatchTool2(name, args) {
63998
64059
  if (args.pin && !/^(sha-[0-9a-f]{7,40}|v\d+\.\d+\.\d+)$/.test(args.pin)) {
63999
64060
  return errorText2(`update_apply: pin "${args.pin}" is invalid. Expected sha-<7-40 hex> or v<semver>.`);
64000
64061
  }
64062
+ if (args.pin && ROLLOUT_SEMVER_PIN_RE.test(args.pin)) {
64063
+ advisory = UPDATE_APPLY_SEMVER_PIN_ADVISORY;
64064
+ }
64001
64065
  req = {
64002
64066
  v: 1,
64003
64067
  op: "update_apply",
@@ -64036,6 +64100,18 @@ async function dispatchTool2(name, args) {
64036
64100
  };
64037
64101
  break;
64038
64102
  }
64103
+ case "get_status": {
64104
+ if (typeof args.request_id !== "string" || !args.request_id) {
64105
+ return errorText2("get_status: request_id must be a non-empty string");
64106
+ }
64107
+ req = {
64108
+ v: 1,
64109
+ op: "get_status",
64110
+ request_id: makeRequestId("mcp-get-status"),
64111
+ args: { target_request_id: args.request_id }
64112
+ };
64113
+ break;
64114
+ }
64039
64115
  case "config_propose_edit": {
64040
64116
  if (!args.unified_diff || typeof args.unified_diff !== "string") {
64041
64117
  return errorText2("config_propose_edit: unified_diff is required (non-empty string).");
@@ -64071,7 +64147,11 @@ async function dispatchTool2(name, args) {
64071
64147
  return errorText2(`hostd wire error (request_id=${req.request_id}): ` + `${err2.message}`);
64072
64148
  }
64073
64149
  if (resp.result === "started" || resp.result === "completed") {
64074
- return jsonText2(resp);
64150
+ const out = jsonText2(resp);
64151
+ if (advisory) {
64152
+ out.content.push({ type: "text", text: advisory });
64153
+ }
64154
+ return out;
64075
64155
  }
64076
64156
  const content = [
64077
64157
  { type: "text", text: JSON.stringify(resp) }
@@ -64143,7 +64223,7 @@ async function runHostdMcpServer() {
64143
64223
  const transport = new StdioServerTransport;
64144
64224
  await server.connect(transport);
64145
64225
  }
64146
- var SELF_AGENT, DEFAULT_WIRE_TIMEOUT_MS = 1e4, WIRE_TIMEOUT_MS_BY_OP, ROLLOUT_SEMVER_PIN_RE, TOOLS2;
64226
+ var SELF_AGENT, DEFAULT_WIRE_TIMEOUT_MS = 1e4, WIRE_TIMEOUT_MS_BY_OP, ROLLOUT_SEMVER_PIN_RE, UPDATE_APPLY_SEMVER_PIN_ADVISORY, TOOLS2;
64147
64227
  var init_server4 = __esm(() => {
64148
64228
  init_server2();
64149
64229
  init_stdio2();
@@ -64155,6 +64235,7 @@ var init_server4 = __esm(() => {
64155
64235
  config_propose_edit: 61 * 60 * 1000
64156
64236
  };
64157
64237
  ROLLOUT_SEMVER_PIN_RE = /^v\d+\.\d+\.\d+$/;
64238
+ UPDATE_APPLY_SEMVER_PIN_ADVISORY = "\u26a0\ufe0f advisory: update_apply with a semver pin is the BLUNT path \u2014 all " + "containers recreate at once, with no canary, no per-agent version " + "assert, and no stop-on-mismatch. For fleet version rolls prefer the " + "`rollout` tool (staggered, canary-gated, version-asserted; also " + "refreshes the web + hindsight singletons). This request was still " + "dispatched \u2014 this is a steer, not a refusal.";
64158
64239
  TOOLS2 = [
64159
64240
  {
64160
64241
  name: "agent_restart",
@@ -64278,7 +64359,7 @@ var init_server4 = __esm(() => {
64278
64359
  },
64279
64360
  {
64280
64361
  name: "update_apply",
64281
- description: "Execute a fleet-wide update: pull images, regenerate " + "scaffolds, recreate containers. Admin-only at the wire layer. " + "Returns `started` once dispatched \u2014 the actual work runs " + "async on the host and the caller's own agent container will " + "be recreated as part of the cycle. This is operator-gated \u2014 every " + "call surfaces a Telegram approval card. ALWAYS pass a one-line " + "`reason` explaining why you're applying this update; it renders on " + "the card's `why:` line so the operator can decide in context.",
64362
+ description: "Execute a fleet-wide update: pull images, regenerate " + "scaffolds, recreate containers. This is the BLUNT all-at-once " + "path \u2014 no canary, no per-agent version assert, no stop-on-" + "mismatch. For rolling the fleet to a specific VERSION (a semver " + "pin), prefer the `rollout` tool instead: staggered, canary-gated, " + "version-asserted, and it also refreshes the web + hindsight " + "singletons. Calling update_apply with a semver pin still works " + "(the operator may want the blunt path deliberately) but the " + "result includes an advisory warning. Admin-only at the wire layer. " + "Returns `started` once dispatched \u2014 the actual work runs " + "async on the host and the caller's own agent container will " + "be recreated as part of the cycle. This is operator-gated \u2014 every " + "call surfaces a Telegram approval card. ALWAYS pass a one-line " + "`reason` explaining why you're applying this update; it renders on " + "the card's `why:` line so the operator can decide in context.",
64282
64363
  inputSchema: {
64283
64364
  type: "object",
64284
64365
  properties: {
@@ -64310,7 +64391,7 @@ var init_server4 = __esm(() => {
64310
64391
  },
64311
64392
  {
64312
64393
  name: "rollout",
64313
- description: "SAFELY roll the fleet to a pinned SEMVER version, staggered and " + "canary-gated (#2487). Unlike update_apply (a blunt all-at-once " + "recreate), this restarts agents one at a time canary-first, asserts " + "each agent's in-container `switchroom --version` matches the target, " + "and STOPS on the first mismatch \u2014 so a bad build fails on the canary " + "before touching the rest of the fleet. The durable release.pin is " + "persisted only AFTER the canary confirms (a failed canary never " + "strands a bad pin). `pin` MUST be a tagged semver (vX.Y.Z) \u2014 sha " + "pins are rejected because the version assert needs a semver to " + "compare against. When the target pin is NEWER than hostd's own CLI " + "(every freshly tagged release), hostd SELF-BUMPS first (#2645): its " + "container restarts on the target image (~30-60s blip on this MCP " + "socket), then the roll resumes AUTOMATICALLY under the SAME " + "request_id \u2014 do NOT re-issue the rollout during the blip; poll " + "get_status with the returned request_id once the socket is back. " + "The web refresh stays DEFERRED on this path; the terminal warnings " + "name exactly what is still on the prior version (web, host operator " + "CLI) and the host-side commands to finish. By default downgrade pins " + "are rejected \u2014 pass `allow_downgrade: true` for the operator-approved " + "rollback path to a known-good earlier tag; all other safety rails " + "(canary order, version-assert, stop-on-mismatch) apply unchanged. " + "Admin-only at the wire layer AND deliberately NOT pre-approved \u2014 every " + "call surfaces a Telegram approval card for the operator to tap. " + "ALWAYS pass a one-line `reason` explaining why you're rolling the " + "fleet to this pin; it renders on the card's `why:` line so the " + "operator can decide in context. " + "Returns `started`; poll get_status for the structured outcome " + "(which agents rolled / where it stopped).",
64394
+ description: "SAFELY roll the fleet to a pinned SEMVER version, staggered and " + "canary-gated (#2487). Unlike update_apply (a blunt all-at-once " + "recreate), this restarts agents one at a time canary-first, asserts " + "each agent's in-container `switchroom --version` matches the target, " + "and STOPS on the first mismatch \u2014 so a bad build fails on the canary " + "before touching the rest of the fleet. The durable release.pin is " + "persisted only AFTER the canary confirms (a failed canary never " + "strands a bad pin). `pin` MUST be a tagged semver (vX.Y.Z) \u2014 sha " + "pins are rejected because the version assert needs a semver to " + "compare against. When the target pin is NEWER than hostd's own CLI " + "(every freshly tagged release), hostd SELF-BUMPS first (#2645): its " + "container restarts on the target image (~30-60s blip on this MCP " + "socket), then the roll resumes AUTOMATICALLY under the SAME " + "request_id \u2014 do NOT re-issue the rollout during the blip; poll " + "get_status (pass the returned request_id as its `request_id` " + "argument) once the socket is back. " + "The web + hindsight singletons are refreshed in-plan on this path " + "(only the hostd template regen + host operator CLI stay host-side; " + "the terminal warnings name exactly what is still on the prior " + "version and the commands to finish). By default downgrade pins " + "are rejected \u2014 pass `allow_downgrade: true` for the operator-approved " + "rollback path to a known-good earlier tag; all other safety rails " + "(canary order, version-assert, stop-on-mismatch) apply unchanged. " + "Admin-only at the wire layer AND deliberately NOT pre-approved \u2014 every " + "call surfaces a Telegram approval card for the operator to tap. " + "ALWAYS pass a one-line `reason` explaining why you're rolling the " + "fleet to this pin; it renders on the card's `why:` line so the " + "operator can decide in context. " + "Returns `started`; poll get_status with `request_id` for the " + "structured outcome (which agents rolled / where it stopped).",
64314
64395
  inputSchema: {
64315
64396
  type: "object",
64316
64397
  required: ["pin"],
@@ -64333,11 +64414,11 @@ var init_server4 = __esm(() => {
64333
64414
  },
64334
64415
  skip_web: {
64335
64416
  type: "boolean",
64336
- description: "Skip the web + hostd refresh step. NB: on this (hostd) path " + "the hostd/web refresh is deferred regardless; this flag is " + "forwarded for parity."
64417
+ description: "Skip the web refresh step (leaves switchroom-web on the prior " + "version \u2014 the terminal warning names the host-side command to " + "finish). The hostd self-refresh is deferred on this path " + "regardless of this flag."
64337
64418
  },
64338
64419
  allow_downgrade: {
64339
64420
  type: "boolean",
64340
- description: "Operator-approved rollback to a known-good earlier tag (#2487 PR2). " + "When true, the downgrade guard is relaxed so `pin` may be older " + "than the current release.pin. All other safety rails (canary order, " + "version-assert, stop-on-mismatch, persist-after-canary, " + "hostd/web deferral) apply unchanged. Still gated by the operator " + "approval card \u2014 not pre-approved."
64421
+ description: "Operator-approved rollback to a known-good earlier tag (#2487 PR2). " + "When true, the downgrade guard is relaxed so `pin` may be older " + "than the current release.pin. All other safety rails (canary order, " + "version-assert, stop-on-mismatch, persist-after-canary, " + "hostd deferral) apply unchanged. Still gated by the operator " + "approval card \u2014 not pre-approved."
64341
64422
  }
64342
64423
  }
64343
64424
  }
@@ -64370,10 +64451,17 @@ var init_server4 = __esm(() => {
64370
64451
  },
64371
64452
  {
64372
64453
  name: "get_status",
64373
- description: "Read the most recent terminal `update_apply` audit row " + "(channel, pin, resolved_sha, install_context, result, " + "exit_code, stderr_tail). Use this after issuing an " + "`update_apply` to confirm what actually rolled out, or to " + "report the last update on demand. Returns the parsed audit " + "entry as JSON.",
64454
+ description: "Look up the status of a prior async fleet mutation. With " + "`request_id` (the id echoed by rollout / update_apply / " + "agent_restart etc.), asks the host-control daemon for that " + "specific request's live status \u2014 for an in-flight `rollout` " + "this is the ONLY way to see progress: the response payload " + "carries the current phase, n/m counters, rolled[] agents, " + "failedStep/failedAgent and pin, and it keeps working across a " + "hostd self-bump/restart via the durable audit-log fallback. " + "This is the tool the `rollout` description tells you to poll " + "with the returned request_id. Without `request_id`, falls back " + "to reading the most recent terminal `update_apply` audit row " + "(channel, pin, resolved_sha, install_context, result, " + "exit_code, stderr_tail) \u2014 use that no-arg form only to report " + "the last blunt update on demand; it CANNOT see rollouts or " + "in-flight work. Read-only; returns JSON.",
64374
64455
  inputSchema: {
64375
64456
  type: "object",
64376
- properties: {},
64457
+ properties: {
64458
+ request_id: {
64459
+ type: "string",
64460
+ minLength: 1,
64461
+ maxLength: 128,
64462
+ description: "The request_id returned by a prior mutating verb " + "(e.g. `mcp-rollout-\u2026`). When set, the daemon returns " + "that request's rich per-request status (phase, n/m, " + "rolled agents, outcome). Omit to read the last " + "terminal update_apply audit row instead."
64463
+ }
64464
+ },
64377
64465
  additionalProperties: false
64378
64466
  }
64379
64467
  }
@@ -64889,8 +64977,8 @@ import { existsSync, readFileSync } from "node:fs";
64889
64977
  import { dirname, join } from "node:path";
64890
64978
 
64891
64979
  // src/build-info.ts
64892
- var VERSION = "0.18.9";
64893
- var COMMIT_SHA = "436289c9";
64980
+ var VERSION = "0.18.11";
64981
+ var COMMIT_SHA = "8fbd9574";
64894
64982
 
64895
64983
  // src/cli/resolve-version.ts
64896
64984
  function readPackageVersion() {
@@ -85030,13 +85118,52 @@ function registerUpdateCommand(program3) {
85030
85118
 
85031
85119
  // src/cli/rollout.ts
85032
85120
  init_helpers();
85033
- import { spawnSync as spawnSync13 } from "node:child_process";
85121
+ import { spawnSync as spawnSync14 } from "node:child_process";
85034
85122
  import { readFileSync as readFileSync58, chownSync as chownSync6, statSync as statSync30 } from "node:fs";
85035
85123
  import { homedir as homedir40 } from "node:os";
85036
85124
  init_operator_uid();
85037
85125
  init_atomic();
85038
85126
  init_audit_reader();
85039
85127
  init_hindsight();
85128
+
85129
+ // src/cli/deploy-version-guard.ts
85130
+ import { spawnSync as spawnSync13 } from "node:child_process";
85131
+ var defaultRunner3 = (args) => {
85132
+ const r = spawnSync13("docker", args, { encoding: "utf8" });
85133
+ return { ok: r.status === 0, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
85134
+ };
85135
+ function deployedImageTag(container, run = defaultRunner3) {
85136
+ const r = run(["inspect", "-f", "{{.Config.Image}}", container]);
85137
+ if (!r.ok)
85138
+ return null;
85139
+ const ref = r.stdout.trim();
85140
+ if (!ref)
85141
+ return null;
85142
+ const noDigest = ref.split("@")[0];
85143
+ const colon = noDigest.lastIndexOf(":");
85144
+ if (colon < 0)
85145
+ return null;
85146
+ const tag = noDigest.slice(colon + 1);
85147
+ return tag && !tag.includes("/") ? tag : null;
85148
+ }
85149
+ function checkDowngrade(opts) {
85150
+ const running = deployedImageTag(opts.container, opts.run);
85151
+ if (opts.allowDowngrade)
85152
+ return { skip: false, running };
85153
+ const cmp = compareReleaseTags(opts.targetTag, running);
85154
+ if (cmp !== null && cmp < 0) {
85155
+ return {
85156
+ skip: true,
85157
+ running,
85158
+ message: `Skipping ${opts.container} deploy \u2014 it is already on ${running}, newer than the ` + `requested ${opts.targetTag}.
85159
+ ` + ` Refusing to downgrade: this guards against a concurrent rollout/update ` + `reverting a newer build.
85160
+ ` + ` To force the downgrade, re-run with --allow-downgrade.`
85161
+ };
85162
+ }
85163
+ return { skip: false, running };
85164
+ }
85165
+
85166
+ // src/cli/rollout.ts
85040
85167
  function normalizeVersion(v) {
85041
85168
  return v.trim().replace(/^v/, "");
85042
85169
  }
@@ -85065,6 +85192,9 @@ function planRollout(agents, opts = {}) {
85065
85192
  } else if (opts.pinToPersist) {
85066
85193
  steps.push({ kind: "persist-pin", pin: opts.pinToPersist });
85067
85194
  }
85195
+ if (!opts.skipWeb) {
85196
+ steps.push({ kind: "refresh-web" });
85197
+ }
85068
85198
  steps.push({ kind: "refresh-hindsight" });
85069
85199
  steps.push({ kind: "sweep" });
85070
85200
  return steps;
@@ -85216,15 +85346,24 @@ function executeRollout(steps, target, deps, execOpts = {}) {
85216
85346
  break;
85217
85347
  }
85218
85348
  case "refresh-web": {
85219
- deps.log(`ROLL_STEP refresh-web \u2014 webd install --tag ${target}`);
85220
- const r = deps.run(["webd", "install", "--tag", target]);
85221
- if (r.status !== 0)
85222
- warnings.push(`web refresh failed (non-fatal); agents already rolled`);
85349
+ const downgradeArgs = execOpts.allowDowngrade ? ["--allow-downgrade"] : [];
85350
+ deps.log(`ROLL_STEP refresh-web \u2014 webd install --tag ${target}` + (execOpts.allowDowngrade ? " --allow-downgrade" : ""));
85351
+ emit({ phase: "web-refresh", target });
85352
+ const r = deps.run(["webd", "install", "--tag", target, ...downgradeArgs]);
85353
+ if (r.status !== 0) {
85354
+ warnings.push(`web refresh FAILED (non-fatal \u2014 agents already rolled) so ` + `switchroom-web is still on the PRIOR version. Finish it ` + `host-side: \`switchroom webd install --tag ${target}\`.`);
85355
+ break;
85356
+ }
85357
+ const webTag = deps.webImageTag?.();
85358
+ if (webTag !== undefined && webTag !== null && isVersionAssertable(webTag) && normalizeVersion(webTag) !== targetNorm) {
85359
+ warnings.push(`web refresh completed but switchroom-web is running ${webTag}, ` + `NOT the roll target ${target} (most likely its downgrade guard ` + `skipped the install). Finish it host-side: \`switchroom webd ` + `install --tag ${target} --allow-downgrade\`.`);
85360
+ }
85223
85361
  break;
85224
85362
  }
85225
85363
  case "refresh-hostd": {
85226
- deps.log(`ROLL_STEP refresh-hostd \u2014 hostd install --tag ${target}`);
85227
- const r = deps.run(["hostd", "install", "--tag", target]);
85364
+ const downgradeArgs = execOpts.allowDowngrade ? ["--allow-downgrade"] : [];
85365
+ deps.log(`ROLL_STEP refresh-hostd \u2014 hostd install --tag ${target}` + (execOpts.allowDowngrade ? " --allow-downgrade" : ""));
85366
+ const r = deps.run(["hostd", "install", "--tag", target, ...downgradeArgs]);
85228
85367
  if (r.status !== 0)
85229
85368
  warnings.push(`hostd refresh failed (non-fatal); agents already rolled`);
85230
85369
  break;
@@ -85279,7 +85418,7 @@ function resolveRollbackTarget(auditLogPath) {
85279
85418
  }
85280
85419
  function registerRolloutCommand(program3) {
85281
85420
  const hostdCtx = isHostdContext2();
85282
- program3.command("rollout").description("Deploy a pinned version to the fleet, safely (staggered restart + " + "per-agent version assert + web/hostd refresh)." + (hostdCtx ? "" : " Run with sudo.")).option("--pin <version>", "Version to roll (e.g. v0.15.18). Defaults to release.pin from config.").option("--agents <list>", "Comma-separated subset of agents to roll (default: all configured).").option("--skip-web", "Skip the web + hostd + hindsight refresh step.").option("--allow-downgrade", "Permit rolling to an older semver tag (operator-approved rollback path). " + "When set, the downgrade guard is relaxed so --pin may be older than the " + "current release.pin. All other safety rails apply unchanged.").option("--dry-run", "Print the plan and exit without changing anything.").action(async (opts) => {
85421
+ program3.command("rollout").description("Deploy a pinned version to the fleet, safely (staggered restart + " + "per-agent version assert + web/hostd refresh)." + (hostdCtx ? "" : " Run with sudo.")).option("--pin <version>", "Version to roll (e.g. v0.15.18). Defaults to release.pin from config.").option("--agents <list>", "Comma-separated subset of agents to roll (default: all configured).").option("--skip-web", "Skip the web refresh (host-shell path: also the hostd + hindsight " + "refresh). On the hostd/agent path only the web refresh is skipped \u2014 " + "hindsight is still recreated and hostd stays deferred regardless.").option("--allow-downgrade", "Permit rolling to an older semver tag (operator-approved rollback path). " + "When set, the downgrade guard is relaxed so --pin may be older than the " + "current release.pin. All other safety rails apply unchanged.").option("--dry-run", "Print the plan and exit without changing anything.").action(async (opts) => {
85283
85422
  const config = getConfig(program3);
85284
85423
  let resolvedPin = opts.pin;
85285
85424
  if (opts.allowDowngrade && !resolvedPin) {
@@ -85363,11 +85502,11 @@ function registerRolloutCommand(program3) {
85363
85502
  const scriptPath = process.argv[1] ?? "switchroom";
85364
85503
  const deps = {
85365
85504
  run: (args) => {
85366
- const r = spawnSync13(process.execPath, [scriptPath, ...args], { stdio: "inherit" });
85505
+ const r = spawnSync14(process.execPath, [scriptPath, ...args], { stdio: "inherit" });
85367
85506
  return { status: r.status ?? 1 };
85368
85507
  },
85369
85508
  probeVersion: (agent) => {
85370
- const r = spawnSync13("docker", ["exec", `switchroom-${agent}`, "sh", "-lc", "switchroom --version"], { encoding: "utf8" });
85509
+ const r = spawnSync14("docker", ["exec", `switchroom-${agent}`, "sh", "-lc", "switchroom --version"], { encoding: "utf8" });
85371
85510
  if (r.status !== 0)
85372
85511
  return null;
85373
85512
  return (r.stdout ?? "").trim().split(`
@@ -85377,6 +85516,7 @@ function registerRolloutCommand(program3) {
85377
85516
  `),
85378
85517
  emitPhase: (phase) => process.stdout.write(encodeRolloutPhaseLine(phase) + `
85379
85518
  `),
85519
+ webImageTag: () => deployedImageTag("switchroom-web"),
85380
85520
  persistPin: (pin) => {
85381
85521
  const before = readFileSync58(configPath, "utf8");
85382
85522
  const after = setReleasePinInConfig(before, pin);
@@ -85398,9 +85538,12 @@ function registerRolloutCommand(program3) {
85398
85538
  };
85399
85539
  process.stdout.write(`${opts.allowDowngrade ? "Rolling back" : "Rolling"} ${requested.length} agent(s) to ${target}\u2026
85400
85540
  `);
85401
- const result = executeRollout(steps, target, deps, { hostdContext: hostdCtx });
85541
+ const result = executeRollout(steps, target, deps, {
85542
+ hostdContext: hostdCtx,
85543
+ allowDowngrade: opts.allowDowngrade
85544
+ });
85402
85545
  if (hostdCtx) {
85403
- result.warnings.push(`still on the PRIOR version after this roll: switchroom-web ` + `(host-side: \`switchroom webd install --tag ${target}\`) and the ` + `host operator CLI (\`sudo npm i -g switchroom@${normalizeVersion(target)}\`). ` + `hostd's compose was tag-bumped by the self-bump when one ran; a ` + `full hostd template regen (only needed when a release changes ` + `hostd's mounts/env) is \`switchroom hostd install --tag ${target}\` ` + `host-side. An agent-invoked rollout cannot recreate its own hostd ` + `container mid-roll without killing itself.`);
85546
+ result.warnings.push(`still on the PRIOR version after this roll: the host operator ` + `CLI (\`sudo npm i -g switchroom@${normalizeVersion(target)}\`). ` + `hostd's compose was tag-bumped by the self-bump when one ran; a ` + `full hostd template regen (only needed when a release changes ` + `hostd's mounts/env) is \`switchroom hostd install --tag ${target}\` ` + `host-side. An agent-invoked rollout cannot recreate its own hostd ` + `container mid-roll without killing itself.` + (opts.skipWeb ? ` --skip-web was set, so switchroom-web is ALSO still on the ` + `prior version (host-side: \`switchroom webd install --tag ${target}\`).` : ""));
85404
85547
  process.stdout.write(encodeRolloutPhaseLine({ phase: "hostd-web-deferred", target }) + `
85405
85548
  `);
85406
85549
  }
@@ -85426,7 +85569,7 @@ function registerRolloutCommand(program3) {
85426
85569
  });
85427
85570
  }
85428
85571
  function registerRollbackCommand(program3) {
85429
- program3.command("rollback").description("Roll the fleet back to the previous version (operator-approved downgrade). " + "Defaults to the version captured in the last completed rollout's prior_pin. " + "Equivalent to `rollout --allow-downgrade [--pin <version>]`.").option("--to <version>", "Explicit version to roll back to (e.g. v0.15.17). " + "Omit to use the last completed rollout's prior_pin.").option("--agents <list>", "Comma-separated subset of agents to roll back (default: all configured).").option("--skip-web", "Skip the web + hostd + hindsight refresh step.").option("--dry-run", "Print the plan and exit without changing anything.").action(async (opts) => {
85572
+ program3.command("rollback").description("Roll the fleet back to the previous version (operator-approved downgrade). " + "Defaults to the version captured in the last completed rollout's prior_pin. " + "Equivalent to `rollout --allow-downgrade [--pin <version>]`.").option("--to <version>", "Explicit version to roll back to (e.g. v0.15.17). " + "Omit to use the last completed rollout's prior_pin.").option("--agents <list>", "Comma-separated subset of agents to roll back (default: all configured).").option("--skip-web", "Skip the web refresh (host-shell path: also the hostd + hindsight " + "refresh). On the hostd/agent path only the web refresh is skipped \u2014 " + "hindsight is still recreated and hostd stays deferred regardless.").option("--dry-run", "Print the plan and exit without changing anything.").action(async (opts) => {
85430
85573
  const rolloutArgs = ["--allow-downgrade"];
85431
85574
  if (opts.to)
85432
85575
  rolloutArgs.push("--pin", opts.to);
@@ -86989,7 +87132,7 @@ init_helpers();
86989
87132
  init_loader();
86990
87133
  import { existsSync as existsSync71 } from "node:fs";
86991
87134
  import { resolve as resolve43, sep as sep3 } from "node:path";
86992
- import { spawnSync as spawnSync14 } from "node:child_process";
87135
+ import { spawnSync as spawnSync15 } from "node:child_process";
86993
87136
 
86994
87137
  // src/agents/workspace.ts
86995
87138
  import { readFile as readFile2, stat } from "node:fs/promises";
@@ -87702,7 +87845,7 @@ function registerWorkspaceCommand(program3) {
87702
87845
  process.exit(1);
87703
87846
  }
87704
87847
  const editor = process.env["EDITOR"] ?? process.env["VISUAL"] ?? "vi";
87705
- const child = spawnSync14(editor, [target], { stdio: "inherit" });
87848
+ const child = spawnSync15(editor, [target], { stdio: "inherit" });
87706
87849
  if (child.status !== 0 && child.status !== null) {
87707
87850
  process.exit(child.status);
87708
87851
  }
@@ -87769,7 +87912,7 @@ function registerWorkspaceCommand(program3) {
87769
87912
  `);
87770
87913
  return;
87771
87914
  }
87772
- const statusResult = spawnSync14("git", ["status", "--short"], {
87915
+ const statusResult = spawnSync15("git", ["status", "--short"], {
87773
87916
  cwd: dir,
87774
87917
  encoding: "utf-8"
87775
87918
  });
@@ -87784,7 +87927,7 @@ function registerWorkspaceCommand(program3) {
87784
87927
  return;
87785
87928
  }
87786
87929
  const message = opts.message || `checkpoint: ${new Date().toISOString()}`;
87787
- const addResult = spawnSync14("git", ["add", "-A"], {
87930
+ const addResult = spawnSync15("git", ["add", "-A"], {
87788
87931
  cwd: dir,
87789
87932
  encoding: "utf-8"
87790
87933
  });
@@ -87793,7 +87936,7 @@ function registerWorkspaceCommand(program3) {
87793
87936
  `);
87794
87937
  process.exit(1);
87795
87938
  }
87796
- const commitResult = spawnSync14("git", ["commit", "-m", message], {
87939
+ const commitResult = spawnSync15("git", ["commit", "-m", message], {
87797
87940
  cwd: dir,
87798
87941
  encoding: "utf-8"
87799
87942
  });
@@ -87802,7 +87945,7 @@ function registerWorkspaceCommand(program3) {
87802
87945
  `);
87803
87946
  process.exit(1);
87804
87947
  }
87805
- const shaResult = spawnSync14("git", ["rev-parse", "--short", "HEAD"], {
87948
+ const shaResult = spawnSync15("git", ["rev-parse", "--short", "HEAD"], {
87806
87949
  cwd: dir,
87807
87950
  encoding: "utf-8"
87808
87951
  });
@@ -87823,7 +87966,7 @@ function registerWorkspaceCommand(program3) {
87823
87966
  `);
87824
87967
  return;
87825
87968
  }
87826
- const child = spawnSync14("git", ["status", "--short"], {
87969
+ const child = spawnSync15("git", ["status", "--short"], {
87827
87970
  cwd: dir,
87828
87971
  stdio: "inherit"
87829
87972
  });
@@ -92200,7 +92343,7 @@ import {
92200
92343
  } from "node:fs";
92201
92344
  import { tmpdir as tmpdir5, homedir as homedir50 } from "node:os";
92202
92345
  import { dirname as dirname30, join as join86, relative as relative2, resolve as resolve52 } from "node:path";
92203
- import { spawnSync as spawnSync15 } from "node:child_process";
92346
+ import { spawnSync as spawnSync16 } from "node:child_process";
92204
92347
 
92205
92348
  // src/cli/skill-common.ts
92206
92349
  var import_yaml24 = __toESM(require_dist(), 1);
@@ -92493,7 +92636,7 @@ function loadFromDir(dir) {
92493
92636
  function loadFromTarball(tarPath) {
92494
92637
  const isGz = tarPath.endsWith(".gz") || tarPath.endsWith(".tgz");
92495
92638
  const listFlags = isGz ? ["-tzf"] : ["-tf"];
92496
- const list2 = spawnSync15("tar", [...listFlags, tarPath], {
92639
+ const list2 = spawnSync16("tar", [...listFlags, tarPath], {
92497
92640
  encoding: "utf-8",
92498
92641
  stdio: ["ignore", "pipe", "pipe"]
92499
92642
  });
@@ -92510,7 +92653,7 @@ function loadFromTarball(tarPath) {
92510
92653
  const staging = mkdtempSync5(join86(tmpdir5(), "skill-apply-extract-"));
92511
92654
  try {
92512
92655
  const flags = isGz ? ["-xzf"] : ["-xf"];
92513
- const r = spawnSync15("tar", [
92656
+ const r = spawnSync16("tar", [
92514
92657
  ...flags,
92515
92658
  tarPath,
92516
92659
  "-C",
@@ -92585,7 +92728,7 @@ function validatePayload(name, files) {
92585
92728
  if (errors2.length === 0) {
92586
92729
  for (const [path8, content] of Object.entries(files)) {
92587
92730
  if (SH_SCRIPT_RE2.test(path8)) {
92588
- const r = spawnSync15("bash", ["-n"], {
92731
+ const r = spawnSync16("bash", ["-n"], {
92589
92732
  input: content,
92590
92733
  encoding: "utf-8"
92591
92734
  });
@@ -92597,7 +92740,7 @@ function validatePayload(name, files) {
92597
92740
  const tmpPy = join86(tmp, "check.py");
92598
92741
  try {
92599
92742
  writeFileSync41(tmpPy, content);
92600
- const r = spawnSync15("python3", ["-m", "py_compile", tmpPy], {
92743
+ const r = spawnSync16("python3", ["-m", "py_compile", tmpPy], {
92601
92744
  encoding: "utf-8"
92602
92745
  });
92603
92746
  if (r.status !== 0) {
@@ -92760,7 +92903,7 @@ function registerSkillCommand(program3) {
92760
92903
  \u2713 Wrote ${name} to ${currentDir}`));
92761
92904
  const applyBin = process.argv[1] ?? "switchroom";
92762
92905
  console.log(source_default.gray(`Running \`switchroom apply --non-interactive\`...`));
92763
- const r = spawnSync15(process.argv0, [applyBin, "apply", "--non-interactive"], { stdio: "inherit" });
92906
+ const r = spawnSync16(process.argv0, [applyBin, "apply", "--non-interactive"], { stdio: "inherit" });
92764
92907
  if (r.status !== 0) {
92765
92908
  console.error(source_default.yellow(`(warning: \`switchroom apply\` exited ${r.status} \u2014 skill is ` + `in the pool but symlinks may not be refreshed. Re-run manually.)`));
92766
92909
  }
@@ -92793,7 +92936,7 @@ import {
92793
92936
  } from "node:fs";
92794
92937
  import { dirname as dirname31, join as join87, relative as relative3, resolve as resolve53 } from "node:path";
92795
92938
  import { homedir as homedir51, tmpdir as tmpdir6 } from "node:os";
92796
- import { spawnSync as spawnSync16 } from "node:child_process";
92939
+ import { spawnSync as spawnSync17 } from "node:child_process";
92797
92940
  init_helpers();
92798
92941
  init_agent_config();
92799
92942
  init_source();
@@ -92994,7 +93137,7 @@ function behavioralValidate(files) {
92994
93137
  const errors2 = [];
92995
93138
  for (const [path8, content] of Object.entries(files)) {
92996
93139
  if (SH_SCRIPT_RE.test(path8)) {
92997
- const r = spawnSync16("bash", ["-n"], { input: content, encoding: "utf-8" });
93140
+ const r = spawnSync17("bash", ["-n"], { input: content, encoding: "utf-8" });
92998
93141
  if (r.status !== 0) {
92999
93142
  errors2.push(`${path8} fails \`bash -n\`: ${(r.stderr ?? "").trim()}`);
93000
93143
  }
@@ -93003,7 +93146,7 @@ function behavioralValidate(files) {
93003
93146
  const tmpPy = join87(tmp, "check.py");
93004
93147
  try {
93005
93148
  writeFileSync42(tmpPy, content);
93006
- const r = spawnSync16("python3", ["-m", "py_compile", tmpPy], {
93149
+ const r = spawnSync17("python3", ["-m", "py_compile", tmpPy], {
93007
93150
  encoding: "utf-8"
93008
93151
  });
93009
93152
  if (r.status !== 0) {
@@ -93799,43 +93942,6 @@ import { homedir as homedir54 } from "node:os";
93799
93942
  import { join as join90 } from "node:path";
93800
93943
  import { spawnSync as spawnSync20 } from "node:child_process";
93801
93944
 
93802
- // src/cli/deploy-version-guard.ts
93803
- import { spawnSync as spawnSync18 } from "node:child_process";
93804
- var defaultRunner3 = (args) => {
93805
- const r = spawnSync18("docker", args, { encoding: "utf8" });
93806
- return { ok: r.status === 0, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
93807
- };
93808
- function deployedImageTag(container, run = defaultRunner3) {
93809
- const r = run(["inspect", "-f", "{{.Config.Image}}", container]);
93810
- if (!r.ok)
93811
- return null;
93812
- const ref = r.stdout.trim();
93813
- if (!ref)
93814
- return null;
93815
- const noDigest = ref.split("@")[0];
93816
- const colon = noDigest.lastIndexOf(":");
93817
- if (colon < 0)
93818
- return null;
93819
- const tag = noDigest.slice(colon + 1);
93820
- return tag && !tag.includes("/") ? tag : null;
93821
- }
93822
- function checkDowngrade(opts) {
93823
- const running = deployedImageTag(opts.container, opts.run);
93824
- if (opts.allowDowngrade)
93825
- return { skip: false, running };
93826
- const cmp = compareReleaseTags(opts.targetTag, running);
93827
- if (cmp !== null && cmp < 0) {
93828
- return {
93829
- skip: true,
93830
- running,
93831
- message: `Skipping ${opts.container} deploy \u2014 it is already on ${running}, newer than the ` + `requested ${opts.targetTag}.
93832
- ` + ` Refusing to downgrade: this guards against a concurrent rollout/update ` + `reverting a newer build.
93833
- ` + ` To force the downgrade, re-run with --allow-downgrade.`
93834
- };
93835
- }
93836
- return { skip: false, running };
93837
- }
93838
-
93839
93945
  // src/cli/singleton-stale-cleanup.ts
93840
93946
  import { spawnSync as spawnSync19 } from "node:child_process";
93841
93947
  function makeDockerRunner() {
@@ -94277,7 +94383,7 @@ The log is created when hostd handles its first privileged-verb request.`));
94277
94383
  init_source();
94278
94384
  init_helpers();
94279
94385
  init_operator_uid();
94280
- import { existsSync as existsSync94, mkdirSync as mkdirSync53, writeFileSync as writeFileSync44, copyFileSync as copyFileSync14 } from "node:fs";
94386
+ import { chownSync as chownSync9, existsSync as existsSync94, mkdirSync as mkdirSync53, writeFileSync as writeFileSync44, copyFileSync as copyFileSync14 } from "node:fs";
94281
94387
  import { homedir as homedir55 } from "node:os";
94282
94388
  import { join as join91 } from "node:path";
94283
94389
  import { spawnSync as spawnSync21 } from "node:child_process";
@@ -94286,6 +94392,16 @@ function resolveWebImageTag(explicitTag, release) {
94286
94392
  return explicitTag;
94287
94393
  return resolveImageTag(resolveRelease({ root: release }));
94288
94394
  }
94395
+ function resolveWebHostHome(env2 = process.env, home2 = homedir55()) {
94396
+ const fromEnv = env2.SWITCHROOM_HOST_HOME?.trim();
94397
+ const resolved = fromEnv && fromEnv.length > 0 ? fromEnv : home2;
94398
+ if (resolved === "/host-home" || resolved.startsWith("/host-home/")) {
94399
+ throw new Error(`switchroom webd install: refusing to generate \u2014 the host home resolved to ` + `"${resolved}", the in-container mount point of the operator home (never a ` + `valid host bind source). Emitting it would make Docker create empty ` + `/host-home dirs on the host and break every webhook forward + secret read.
94400
+
94401
+ ` + `Recovery: run \`switchroom webd install\` from the HOST shell, or set ` + `SWITCHROOM_HOST_HOME to the real host home first.`);
94402
+ }
94403
+ return resolved;
94404
+ }
94289
94405
  var WEB_COMPOSE_PROJECT = "switchroom-web";
94290
94406
  function renderWebComposeFile(opts) {
94291
94407
  const { hostHome, imageTag, operatorUid } = opts;
@@ -94408,8 +94524,15 @@ async function doInstall2(opts, program3) {
94408
94524
  console.log(source_default.yellow(` \u23ed ${opts.dryRun ? "[dry-run] " : ""}${guard.message}`));
94409
94525
  return;
94410
94526
  }
94527
+ let hostHome;
94528
+ try {
94529
+ hostHome = resolveWebHostHome();
94530
+ } catch (err2) {
94531
+ console.error(source_default.red(err2.message));
94532
+ process.exit(1);
94533
+ }
94411
94534
  const yaml = renderWebComposeFile({
94412
- hostHome: homedir55(),
94535
+ hostHome,
94413
94536
  imageTag,
94414
94537
  operatorUid
94415
94538
  });
@@ -94423,6 +94546,14 @@ async function doInstall2(opts, program3) {
94423
94546
  if (bak)
94424
94547
  console.log(source_default.dim(` Backed up existing compose to ${bak}`));
94425
94548
  writeFileSync44(composePath, yaml, "utf8");
94549
+ try {
94550
+ if (typeof process.geteuid === "function" && process.geteuid() === 0) {
94551
+ chownSync9(dir, operatorUid, operatorUid);
94552
+ chownSync9(composePath, operatorUid, operatorUid);
94553
+ if (bak)
94554
+ chownSync9(bak, operatorUid, operatorUid);
94555
+ }
94556
+ } catch {}
94426
94557
  console.log(source_default.green(` \u2713 Wrote ${composePath}`));
94427
94558
  console.log(source_default.dim(` running as uid ${operatorUid} (operator), network_mode: host`));
94428
94559
  console.log(source_default.dim(` Pulling ghcr.io/switchroom/switchroom-web:${imageTag}\u2026`));