switchroom 0.18.10 → 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.
@@ -17428,6 +17428,40 @@ function registerAgentConfigCommands(program2) {
17428
17428
  process.exit(1);
17429
17429
  }
17430
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
+ });
17431
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) => {
17432
17466
  let agent;
17433
17467
  try {
@@ -17588,6 +17622,7 @@ function registerAgentConfigCommands(program2) {
17588
17622
  var AUDIT_ROOT;
17589
17623
  var init_agent_config = __esm(() => {
17590
17624
  init_helpers();
17625
+ init_loader();
17591
17626
  init_merge();
17592
17627
  init_acl();
17593
17628
  init_cron_introspect();
@@ -63399,9 +63434,9 @@ __export(exports_server, {
63399
63434
  dispatchTool: () => dispatchTool,
63400
63435
  TOOLS: () => TOOLS
63401
63436
  });
63402
- import { spawnSync as spawnSync17 } from "node:child_process";
63437
+ import { spawnSync as spawnSync18 } from "node:child_process";
63403
63438
  function execCli(args, stdin) {
63404
- const r = spawnSync17(CLI_BIN, args, {
63439
+ const r = spawnSync18(CLI_BIN, args, {
63405
63440
  encoding: "utf-8",
63406
63441
  env: process.env,
63407
63442
  timeout: 15000,
@@ -63905,6 +63940,7 @@ __export(exports_server2, {
63905
63940
  resolveAuditLogPath: () => resolveAuditLogPath,
63906
63941
  getLastUpdateApplyStatus: () => getLastUpdateApplyStatus,
63907
63942
  dispatchTool: () => dispatchTool2,
63943
+ UPDATE_APPLY_SEMVER_PIN_ADVISORY: () => UPDATE_APPLY_SEMVER_PIN_ADVISORY,
63908
63944
  TOOLS: () => TOOLS2
63909
63945
  });
63910
63946
  import { randomBytes as randomBytes16 } from "node:crypto";
@@ -63919,7 +63955,7 @@ function wireTimeoutForOp(op) {
63919
63955
  return WIRE_TIMEOUT_MS_BY_OP[op] ?? DEFAULT_WIRE_TIMEOUT_MS;
63920
63956
  }
63921
63957
  async function dispatchTool2(name, args) {
63922
- if (name === "get_status") {
63958
+ if (name === "get_status" && args.request_id === undefined) {
63923
63959
  return getLastUpdateApplyStatus();
63924
63960
  }
63925
63961
  if (!SELF_AGENT) {
@@ -63930,6 +63966,7 @@ async function dispatchTool2(name, args) {
63930
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.`);
63931
63967
  }
63932
63968
  let req;
63969
+ let advisory;
63933
63970
  switch (name) {
63934
63971
  case "agent_restart": {
63935
63972
  if (!args.name)
@@ -64022,6 +64059,9 @@ async function dispatchTool2(name, args) {
64022
64059
  if (args.pin && !/^(sha-[0-9a-f]{7,40}|v\d+\.\d+\.\d+)$/.test(args.pin)) {
64023
64060
  return errorText2(`update_apply: pin "${args.pin}" is invalid. Expected sha-<7-40 hex> or v<semver>.`);
64024
64061
  }
64062
+ if (args.pin && ROLLOUT_SEMVER_PIN_RE.test(args.pin)) {
64063
+ advisory = UPDATE_APPLY_SEMVER_PIN_ADVISORY;
64064
+ }
64025
64065
  req = {
64026
64066
  v: 1,
64027
64067
  op: "update_apply",
@@ -64060,6 +64100,18 @@ async function dispatchTool2(name, args) {
64060
64100
  };
64061
64101
  break;
64062
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
+ }
64063
64115
  case "config_propose_edit": {
64064
64116
  if (!args.unified_diff || typeof args.unified_diff !== "string") {
64065
64117
  return errorText2("config_propose_edit: unified_diff is required (non-empty string).");
@@ -64095,7 +64147,11 @@ async function dispatchTool2(name, args) {
64095
64147
  return errorText2(`hostd wire error (request_id=${req.request_id}): ` + `${err2.message}`);
64096
64148
  }
64097
64149
  if (resp.result === "started" || resp.result === "completed") {
64098
- return jsonText2(resp);
64150
+ const out = jsonText2(resp);
64151
+ if (advisory) {
64152
+ out.content.push({ type: "text", text: advisory });
64153
+ }
64154
+ return out;
64099
64155
  }
64100
64156
  const content = [
64101
64157
  { type: "text", text: JSON.stringify(resp) }
@@ -64167,7 +64223,7 @@ async function runHostdMcpServer() {
64167
64223
  const transport = new StdioServerTransport;
64168
64224
  await server.connect(transport);
64169
64225
  }
64170
- 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;
64171
64227
  var init_server4 = __esm(() => {
64172
64228
  init_server2();
64173
64229
  init_stdio2();
@@ -64179,6 +64235,7 @@ var init_server4 = __esm(() => {
64179
64235
  config_propose_edit: 61 * 60 * 1000
64180
64236
  };
64181
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.";
64182
64239
  TOOLS2 = [
64183
64240
  {
64184
64241
  name: "agent_restart",
@@ -64302,7 +64359,7 @@ var init_server4 = __esm(() => {
64302
64359
  },
64303
64360
  {
64304
64361
  name: "update_apply",
64305
- 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.",
64306
64363
  inputSchema: {
64307
64364
  type: "object",
64308
64365
  properties: {
@@ -64334,7 +64391,7 @@ var init_server4 = __esm(() => {
64334
64391
  },
64335
64392
  {
64336
64393
  name: "rollout",
64337
- 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).",
64338
64395
  inputSchema: {
64339
64396
  type: "object",
64340
64397
  required: ["pin"],
@@ -64357,11 +64414,11 @@ var init_server4 = __esm(() => {
64357
64414
  },
64358
64415
  skip_web: {
64359
64416
  type: "boolean",
64360
- 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."
64361
64418
  },
64362
64419
  allow_downgrade: {
64363
64420
  type: "boolean",
64364
- 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."
64365
64422
  }
64366
64423
  }
64367
64424
  }
@@ -64394,10 +64451,17 @@ var init_server4 = __esm(() => {
64394
64451
  },
64395
64452
  {
64396
64453
  name: "get_status",
64397
- 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.",
64398
64455
  inputSchema: {
64399
64456
  type: "object",
64400
- 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
+ },
64401
64465
  additionalProperties: false
64402
64466
  }
64403
64467
  }
@@ -64913,8 +64977,8 @@ import { existsSync, readFileSync } from "node:fs";
64913
64977
  import { dirname, join } from "node:path";
64914
64978
 
64915
64979
  // src/build-info.ts
64916
- var VERSION = "0.18.10";
64917
- var COMMIT_SHA = "1f778844";
64980
+ var VERSION = "0.18.11";
64981
+ var COMMIT_SHA = "8fbd9574";
64918
64982
 
64919
64983
  // src/cli/resolve-version.ts
64920
64984
  function readPackageVersion() {
@@ -85054,13 +85118,52 @@ function registerUpdateCommand(program3) {
85054
85118
 
85055
85119
  // src/cli/rollout.ts
85056
85120
  init_helpers();
85057
- import { spawnSync as spawnSync13 } from "node:child_process";
85121
+ import { spawnSync as spawnSync14 } from "node:child_process";
85058
85122
  import { readFileSync as readFileSync58, chownSync as chownSync6, statSync as statSync30 } from "node:fs";
85059
85123
  import { homedir as homedir40 } from "node:os";
85060
85124
  init_operator_uid();
85061
85125
  init_atomic();
85062
85126
  init_audit_reader();
85063
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
85064
85167
  function normalizeVersion(v) {
85065
85168
  return v.trim().replace(/^v/, "");
85066
85169
  }
@@ -85089,6 +85192,9 @@ function planRollout(agents, opts = {}) {
85089
85192
  } else if (opts.pinToPersist) {
85090
85193
  steps.push({ kind: "persist-pin", pin: opts.pinToPersist });
85091
85194
  }
85195
+ if (!opts.skipWeb) {
85196
+ steps.push({ kind: "refresh-web" });
85197
+ }
85092
85198
  steps.push({ kind: "refresh-hindsight" });
85093
85199
  steps.push({ kind: "sweep" });
85094
85200
  return steps;
@@ -85240,15 +85346,24 @@ function executeRollout(steps, target, deps, execOpts = {}) {
85240
85346
  break;
85241
85347
  }
85242
85348
  case "refresh-web": {
85243
- deps.log(`ROLL_STEP refresh-web \u2014 webd install --tag ${target}`);
85244
- const r = deps.run(["webd", "install", "--tag", target]);
85245
- if (r.status !== 0)
85246
- 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
+ }
85247
85361
  break;
85248
85362
  }
85249
85363
  case "refresh-hostd": {
85250
- deps.log(`ROLL_STEP refresh-hostd \u2014 hostd install --tag ${target}`);
85251
- 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]);
85252
85367
  if (r.status !== 0)
85253
85368
  warnings.push(`hostd refresh failed (non-fatal); agents already rolled`);
85254
85369
  break;
@@ -85303,7 +85418,7 @@ function resolveRollbackTarget(auditLogPath) {
85303
85418
  }
85304
85419
  function registerRolloutCommand(program3) {
85305
85420
  const hostdCtx = isHostdContext2();
85306
- 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) => {
85307
85422
  const config = getConfig(program3);
85308
85423
  let resolvedPin = opts.pin;
85309
85424
  if (opts.allowDowngrade && !resolvedPin) {
@@ -85387,11 +85502,11 @@ function registerRolloutCommand(program3) {
85387
85502
  const scriptPath = process.argv[1] ?? "switchroom";
85388
85503
  const deps = {
85389
85504
  run: (args) => {
85390
- const r = spawnSync13(process.execPath, [scriptPath, ...args], { stdio: "inherit" });
85505
+ const r = spawnSync14(process.execPath, [scriptPath, ...args], { stdio: "inherit" });
85391
85506
  return { status: r.status ?? 1 };
85392
85507
  },
85393
85508
  probeVersion: (agent) => {
85394
- 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" });
85395
85510
  if (r.status !== 0)
85396
85511
  return null;
85397
85512
  return (r.stdout ?? "").trim().split(`
@@ -85401,6 +85516,7 @@ function registerRolloutCommand(program3) {
85401
85516
  `),
85402
85517
  emitPhase: (phase) => process.stdout.write(encodeRolloutPhaseLine(phase) + `
85403
85518
  `),
85519
+ webImageTag: () => deployedImageTag("switchroom-web"),
85404
85520
  persistPin: (pin) => {
85405
85521
  const before = readFileSync58(configPath, "utf8");
85406
85522
  const after = setReleasePinInConfig(before, pin);
@@ -85422,9 +85538,12 @@ function registerRolloutCommand(program3) {
85422
85538
  };
85423
85539
  process.stdout.write(`${opts.allowDowngrade ? "Rolling back" : "Rolling"} ${requested.length} agent(s) to ${target}\u2026
85424
85540
  `);
85425
- const result = executeRollout(steps, target, deps, { hostdContext: hostdCtx });
85541
+ const result = executeRollout(steps, target, deps, {
85542
+ hostdContext: hostdCtx,
85543
+ allowDowngrade: opts.allowDowngrade
85544
+ });
85426
85545
  if (hostdCtx) {
85427
- 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}\`).` : ""));
85428
85547
  process.stdout.write(encodeRolloutPhaseLine({ phase: "hostd-web-deferred", target }) + `
85429
85548
  `);
85430
85549
  }
@@ -85450,7 +85569,7 @@ function registerRolloutCommand(program3) {
85450
85569
  });
85451
85570
  }
85452
85571
  function registerRollbackCommand(program3) {
85453
- 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) => {
85454
85573
  const rolloutArgs = ["--allow-downgrade"];
85455
85574
  if (opts.to)
85456
85575
  rolloutArgs.push("--pin", opts.to);
@@ -87013,7 +87132,7 @@ init_helpers();
87013
87132
  init_loader();
87014
87133
  import { existsSync as existsSync71 } from "node:fs";
87015
87134
  import { resolve as resolve43, sep as sep3 } from "node:path";
87016
- import { spawnSync as spawnSync14 } from "node:child_process";
87135
+ import { spawnSync as spawnSync15 } from "node:child_process";
87017
87136
 
87018
87137
  // src/agents/workspace.ts
87019
87138
  import { readFile as readFile2, stat } from "node:fs/promises";
@@ -87726,7 +87845,7 @@ function registerWorkspaceCommand(program3) {
87726
87845
  process.exit(1);
87727
87846
  }
87728
87847
  const editor = process.env["EDITOR"] ?? process.env["VISUAL"] ?? "vi";
87729
- const child = spawnSync14(editor, [target], { stdio: "inherit" });
87848
+ const child = spawnSync15(editor, [target], { stdio: "inherit" });
87730
87849
  if (child.status !== 0 && child.status !== null) {
87731
87850
  process.exit(child.status);
87732
87851
  }
@@ -87793,7 +87912,7 @@ function registerWorkspaceCommand(program3) {
87793
87912
  `);
87794
87913
  return;
87795
87914
  }
87796
- const statusResult = spawnSync14("git", ["status", "--short"], {
87915
+ const statusResult = spawnSync15("git", ["status", "--short"], {
87797
87916
  cwd: dir,
87798
87917
  encoding: "utf-8"
87799
87918
  });
@@ -87808,7 +87927,7 @@ function registerWorkspaceCommand(program3) {
87808
87927
  return;
87809
87928
  }
87810
87929
  const message = opts.message || `checkpoint: ${new Date().toISOString()}`;
87811
- const addResult = spawnSync14("git", ["add", "-A"], {
87930
+ const addResult = spawnSync15("git", ["add", "-A"], {
87812
87931
  cwd: dir,
87813
87932
  encoding: "utf-8"
87814
87933
  });
@@ -87817,7 +87936,7 @@ function registerWorkspaceCommand(program3) {
87817
87936
  `);
87818
87937
  process.exit(1);
87819
87938
  }
87820
- const commitResult = spawnSync14("git", ["commit", "-m", message], {
87939
+ const commitResult = spawnSync15("git", ["commit", "-m", message], {
87821
87940
  cwd: dir,
87822
87941
  encoding: "utf-8"
87823
87942
  });
@@ -87826,7 +87945,7 @@ function registerWorkspaceCommand(program3) {
87826
87945
  `);
87827
87946
  process.exit(1);
87828
87947
  }
87829
- const shaResult = spawnSync14("git", ["rev-parse", "--short", "HEAD"], {
87948
+ const shaResult = spawnSync15("git", ["rev-parse", "--short", "HEAD"], {
87830
87949
  cwd: dir,
87831
87950
  encoding: "utf-8"
87832
87951
  });
@@ -87847,7 +87966,7 @@ function registerWorkspaceCommand(program3) {
87847
87966
  `);
87848
87967
  return;
87849
87968
  }
87850
- const child = spawnSync14("git", ["status", "--short"], {
87969
+ const child = spawnSync15("git", ["status", "--short"], {
87851
87970
  cwd: dir,
87852
87971
  stdio: "inherit"
87853
87972
  });
@@ -92224,7 +92343,7 @@ import {
92224
92343
  } from "node:fs";
92225
92344
  import { tmpdir as tmpdir5, homedir as homedir50 } from "node:os";
92226
92345
  import { dirname as dirname30, join as join86, relative as relative2, resolve as resolve52 } from "node:path";
92227
- import { spawnSync as spawnSync15 } from "node:child_process";
92346
+ import { spawnSync as spawnSync16 } from "node:child_process";
92228
92347
 
92229
92348
  // src/cli/skill-common.ts
92230
92349
  var import_yaml24 = __toESM(require_dist(), 1);
@@ -92517,7 +92636,7 @@ function loadFromDir(dir) {
92517
92636
  function loadFromTarball(tarPath) {
92518
92637
  const isGz = tarPath.endsWith(".gz") || tarPath.endsWith(".tgz");
92519
92638
  const listFlags = isGz ? ["-tzf"] : ["-tf"];
92520
- const list2 = spawnSync15("tar", [...listFlags, tarPath], {
92639
+ const list2 = spawnSync16("tar", [...listFlags, tarPath], {
92521
92640
  encoding: "utf-8",
92522
92641
  stdio: ["ignore", "pipe", "pipe"]
92523
92642
  });
@@ -92534,7 +92653,7 @@ function loadFromTarball(tarPath) {
92534
92653
  const staging = mkdtempSync5(join86(tmpdir5(), "skill-apply-extract-"));
92535
92654
  try {
92536
92655
  const flags = isGz ? ["-xzf"] : ["-xf"];
92537
- const r = spawnSync15("tar", [
92656
+ const r = spawnSync16("tar", [
92538
92657
  ...flags,
92539
92658
  tarPath,
92540
92659
  "-C",
@@ -92609,7 +92728,7 @@ function validatePayload(name, files) {
92609
92728
  if (errors2.length === 0) {
92610
92729
  for (const [path8, content] of Object.entries(files)) {
92611
92730
  if (SH_SCRIPT_RE2.test(path8)) {
92612
- const r = spawnSync15("bash", ["-n"], {
92731
+ const r = spawnSync16("bash", ["-n"], {
92613
92732
  input: content,
92614
92733
  encoding: "utf-8"
92615
92734
  });
@@ -92621,7 +92740,7 @@ function validatePayload(name, files) {
92621
92740
  const tmpPy = join86(tmp, "check.py");
92622
92741
  try {
92623
92742
  writeFileSync41(tmpPy, content);
92624
- const r = spawnSync15("python3", ["-m", "py_compile", tmpPy], {
92743
+ const r = spawnSync16("python3", ["-m", "py_compile", tmpPy], {
92625
92744
  encoding: "utf-8"
92626
92745
  });
92627
92746
  if (r.status !== 0) {
@@ -92784,7 +92903,7 @@ function registerSkillCommand(program3) {
92784
92903
  \u2713 Wrote ${name} to ${currentDir}`));
92785
92904
  const applyBin = process.argv[1] ?? "switchroom";
92786
92905
  console.log(source_default.gray(`Running \`switchroom apply --non-interactive\`...`));
92787
- const r = spawnSync15(process.argv0, [applyBin, "apply", "--non-interactive"], { stdio: "inherit" });
92906
+ const r = spawnSync16(process.argv0, [applyBin, "apply", "--non-interactive"], { stdio: "inherit" });
92788
92907
  if (r.status !== 0) {
92789
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.)`));
92790
92909
  }
@@ -92817,7 +92936,7 @@ import {
92817
92936
  } from "node:fs";
92818
92937
  import { dirname as dirname31, join as join87, relative as relative3, resolve as resolve53 } from "node:path";
92819
92938
  import { homedir as homedir51, tmpdir as tmpdir6 } from "node:os";
92820
- import { spawnSync as spawnSync16 } from "node:child_process";
92939
+ import { spawnSync as spawnSync17 } from "node:child_process";
92821
92940
  init_helpers();
92822
92941
  init_agent_config();
92823
92942
  init_source();
@@ -93018,7 +93137,7 @@ function behavioralValidate(files) {
93018
93137
  const errors2 = [];
93019
93138
  for (const [path8, content] of Object.entries(files)) {
93020
93139
  if (SH_SCRIPT_RE.test(path8)) {
93021
- const r = spawnSync16("bash", ["-n"], { input: content, encoding: "utf-8" });
93140
+ const r = spawnSync17("bash", ["-n"], { input: content, encoding: "utf-8" });
93022
93141
  if (r.status !== 0) {
93023
93142
  errors2.push(`${path8} fails \`bash -n\`: ${(r.stderr ?? "").trim()}`);
93024
93143
  }
@@ -93027,7 +93146,7 @@ function behavioralValidate(files) {
93027
93146
  const tmpPy = join87(tmp, "check.py");
93028
93147
  try {
93029
93148
  writeFileSync42(tmpPy, content);
93030
- const r = spawnSync16("python3", ["-m", "py_compile", tmpPy], {
93149
+ const r = spawnSync17("python3", ["-m", "py_compile", tmpPy], {
93031
93150
  encoding: "utf-8"
93032
93151
  });
93033
93152
  if (r.status !== 0) {
@@ -93823,43 +93942,6 @@ import { homedir as homedir54 } from "node:os";
93823
93942
  import { join as join90 } from "node:path";
93824
93943
  import { spawnSync as spawnSync20 } from "node:child_process";
93825
93944
 
93826
- // src/cli/deploy-version-guard.ts
93827
- import { spawnSync as spawnSync18 } from "node:child_process";
93828
- var defaultRunner3 = (args) => {
93829
- const r = spawnSync18("docker", args, { encoding: "utf8" });
93830
- return { ok: r.status === 0, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
93831
- };
93832
- function deployedImageTag(container, run = defaultRunner3) {
93833
- const r = run(["inspect", "-f", "{{.Config.Image}}", container]);
93834
- if (!r.ok)
93835
- return null;
93836
- const ref = r.stdout.trim();
93837
- if (!ref)
93838
- return null;
93839
- const noDigest = ref.split("@")[0];
93840
- const colon = noDigest.lastIndexOf(":");
93841
- if (colon < 0)
93842
- return null;
93843
- const tag = noDigest.slice(colon + 1);
93844
- return tag && !tag.includes("/") ? tag : null;
93845
- }
93846
- function checkDowngrade(opts) {
93847
- const running = deployedImageTag(opts.container, opts.run);
93848
- if (opts.allowDowngrade)
93849
- return { skip: false, running };
93850
- const cmp = compareReleaseTags(opts.targetTag, running);
93851
- if (cmp !== null && cmp < 0) {
93852
- return {
93853
- skip: true,
93854
- running,
93855
- message: `Skipping ${opts.container} deploy \u2014 it is already on ${running}, newer than the ` + `requested ${opts.targetTag}.
93856
- ` + ` Refusing to downgrade: this guards against a concurrent rollout/update ` + `reverting a newer build.
93857
- ` + ` To force the downgrade, re-run with --allow-downgrade.`
93858
- };
93859
- }
93860
- return { skip: false, running };
93861
- }
93862
-
93863
93945
  // src/cli/singleton-stale-cleanup.ts
93864
93946
  import { spawnSync as spawnSync19 } from "node:child_process";
93865
93947
  function makeDockerRunner() {
@@ -94301,7 +94383,7 @@ The log is created when hostd handles its first privileged-verb request.`));
94301
94383
  init_source();
94302
94384
  init_helpers();
94303
94385
  init_operator_uid();
94304
- 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";
94305
94387
  import { homedir as homedir55 } from "node:os";
94306
94388
  import { join as join91 } from "node:path";
94307
94389
  import { spawnSync as spawnSync21 } from "node:child_process";
@@ -94310,6 +94392,16 @@ function resolveWebImageTag(explicitTag, release) {
94310
94392
  return explicitTag;
94311
94393
  return resolveImageTag(resolveRelease({ root: release }));
94312
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
+ }
94313
94405
  var WEB_COMPOSE_PROJECT = "switchroom-web";
94314
94406
  function renderWebComposeFile(opts) {
94315
94407
  const { hostHome, imageTag, operatorUid } = opts;
@@ -94432,8 +94524,15 @@ async function doInstall2(opts, program3) {
94432
94524
  console.log(source_default.yellow(` \u23ed ${opts.dryRun ? "[dry-run] " : ""}${guard.message}`));
94433
94525
  return;
94434
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
+ }
94435
94534
  const yaml = renderWebComposeFile({
94436
- hostHome: homedir55(),
94535
+ hostHome,
94437
94536
  imageTag,
94438
94537
  operatorUid
94439
94538
  });
@@ -94447,6 +94546,14 @@ async function doInstall2(opts, program3) {
94447
94546
  if (bak)
94448
94547
  console.log(source_default.dim(` Backed up existing compose to ${bak}`));
94449
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 {}
94450
94557
  console.log(source_default.green(` \u2713 Wrote ${composePath}`));
94451
94558
  console.log(source_default.dim(` running as uid ${operatorUid} (operator), network_mode: host`));
94452
94559
  console.log(source_default.dim(` Pulling ghcr.io/switchroom/switchroom-web:${imageTag}\u2026`));
Binary file
Binary file
Binary file