switchroom 0.13.5 → 0.13.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14040,6 +14040,10 @@ var QuotaConfigSchema = exports_external.object({
14040
14040
  var HostControlConfigSchema = exports_external.object({
14041
14041
  enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip — the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` — " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C §5.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3).")
14042
14042
  });
14043
+ var HostdConfigSchema = exports_external.object({
14044
+ config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
14045
+ config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit §5). Default 3 cards/hour; min 1, " + "max 20. Implemented as a sqlite token bucket in PR 1c; the " + "field is wired here in PR 1a so operators can pin it before the " + "limiter is live. Above the cap, the verb returns " + "`E_RATE_LIMITED` without raising a card.")
14046
+ });
14043
14047
  var SwitchroomConfigSchema = exports_external.object({
14044
14048
  switchroom: exports_external.object({
14045
14049
  version: exports_external.literal(1).describe("Config schema version"),
@@ -14066,6 +14070,7 @@ var SwitchroomConfigSchema = exports_external.object({
14066
14070
  google_workspace: GoogleWorkspaceConfigSchema.describe("RFC G canonical key. Top-level Google Workspace configuration — " + "OAuth client credentials, approver allowlist, and tier knob (`core` " + "| `extended` | `complete`, default `core`). Mutually exclusive with " + "`drive:` at the top level (loader fails fast if both are set)."),
14067
14071
  quota: QuotaConfigSchema.optional().describe("Optional weekly/monthly USD spend budgets rendered in the session " + "greeting. Usage is read from ccusage at runtime; no network calls."),
14068
14072
  host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (docs/rfcs/host-control-daemon.md). Omit the block " + "to accept defaults; set `enabled: false` only on legacy systemd-" + "mode installs (removal tracked as RFC C Phase 3)."),
14073
+ hostd: HostdConfigSchema.default({}).describe("hostd verb-level knobs (RFC admin-agent-config-edit). Distinct " + "from `host_control:` which governs whether the daemon runs at " + "all. Currently scopes the opt-in flag and rate cap for the new " + "`config_propose_edit` verb (PR 1a — disabled by default)."),
14069
14074
  google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
14070
14075
  message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
14071
14076
  }).transform((v) => v.trim().toLowerCase()), exports_external.object({
@@ -14689,17 +14694,17 @@ var BIND_MOUNT_EXACT_SOURCE_DENY = new Set(["/var/run/docker.sock"]);
14689
14694
 
14690
14695
  // src/host-control/server.ts
14691
14696
  import { createServer } from "node:net";
14692
- import { spawn, spawnSync } from "node:child_process";
14697
+ import { spawn, spawnSync as spawnSync2 } from "node:child_process";
14693
14698
  import { mkdir, chmod, chown, unlink, appendFile } from "node:fs/promises";
14694
14699
  import {
14695
14700
  readdirSync as readdirSync2,
14696
- existsSync as existsSync5,
14697
- readFileSync as readFileSync3,
14698
- writeFileSync,
14701
+ existsSync as existsSync6,
14702
+ readFileSync as readFileSync4,
14703
+ writeFileSync as writeFileSync2,
14699
14704
  renameSync,
14700
14705
  mkdirSync
14701
14706
  } from "node:fs";
14702
- import { join as join2, dirname as dirname2, resolve as resolve5 } from "node:path";
14707
+ import { join as join3, dirname as dirname2, resolve as resolve5 } from "node:path";
14703
14708
 
14704
14709
  // src/host-control/protocol.ts
14705
14710
  var MAX_FRAME_BYTES = 64 * 1024;
@@ -14794,6 +14799,15 @@ var AgentSmokeRequestSchema = exports_external.object({
14794
14799
  deep: exports_external.boolean().optional()
14795
14800
  })
14796
14801
  });
14802
+ var ConfigProposeEditRequestSchema = exports_external.object({
14803
+ ...RequestEnvelope,
14804
+ op: exports_external.literal("config_propose_edit"),
14805
+ args: exports_external.object({
14806
+ unified_diff: exports_external.string().min(1).max(MAX_FRAME_BYTES - 1024),
14807
+ reason: exports_external.string().min(1).max(500),
14808
+ target_path: exports_external.literal("/state/config/switchroom.yaml")
14809
+ })
14810
+ });
14797
14811
  var RequestSchema = exports_external.discriminatedUnion("op", [
14798
14812
  AgentRestartRequestSchema,
14799
14813
  UpgradeStatusRequestSchema,
@@ -14806,7 +14820,8 @@ var RequestSchema = exports_external.discriminatedUnion("op", [
14806
14820
  AgentLogsRequestSchema,
14807
14821
  AgentExecRequestSchema,
14808
14822
  DoctorRequestSchema,
14809
- AgentSmokeRequestSchema
14823
+ AgentSmokeRequestSchema,
14824
+ ConfigProposeEditRequestSchema
14810
14825
  ]);
14811
14826
  var ResultSchema = exports_external.enum(["started", "completed", "denied", "error"]);
14812
14827
  var ResponseEnvelope = {
@@ -15283,12 +15298,351 @@ function detectInstallType() {
15283
15298
  }
15284
15299
  }
15285
15300
 
15301
+ // src/host-control/config-edit-validator.ts
15302
+ import { mkdtempSync, writeFileSync, rmSync, existsSync as existsSync5, readFileSync as readFileSync3 } from "node:fs";
15303
+ import { tmpdir } from "node:os";
15304
+ import { join as join2, isAbsolute as isAbsolute2, normalize } from "node:path";
15305
+ import { spawnSync } from "node:child_process";
15306
+ var MAX_PATCH_BYTES = 1024 * 1024;
15307
+ function isTargetPathHeader(headerPath, targetBasename) {
15308
+ if (headerPath === "/dev/null")
15309
+ return false;
15310
+ let p = headerPath;
15311
+ if (p.startsWith("a/") || p.startsWith("b/"))
15312
+ p = p.slice(2);
15313
+ if (isAbsolute2(p))
15314
+ return false;
15315
+ if (p.includes(".."))
15316
+ return false;
15317
+ const norm = normalize(p);
15318
+ if (norm.includes("..") || isAbsolute2(norm))
15319
+ return false;
15320
+ return norm === targetBasename;
15321
+ }
15322
+ function validateShape(unifiedDiff, targetPath) {
15323
+ const byteLen = Buffer.byteLength(unifiedDiff, "utf8");
15324
+ if (byteLen > MAX_PATCH_BYTES) {
15325
+ return {
15326
+ ok: false,
15327
+ code: "E_PATCH_INVALID_SHAPE",
15328
+ detail: `patch exceeds ${MAX_PATCH_BYTES}-byte cap (${byteLen} bytes)`
15329
+ };
15330
+ }
15331
+ if (unifiedDiff.includes("\r")) {
15332
+ return {
15333
+ ok: false,
15334
+ code: "E_PATCH_INVALID_SHAPE",
15335
+ detail: "patch must be LF-only (no CRLF / CR)"
15336
+ };
15337
+ }
15338
+ const lines = unifiedDiff.split(`
15339
+ `);
15340
+ const minusHeaders = [];
15341
+ const plusHeaders = [];
15342
+ let hunkCount = 0;
15343
+ for (const ln of lines) {
15344
+ if (ln.startsWith("--- "))
15345
+ minusHeaders.push(ln.slice(4).trim());
15346
+ else if (ln.startsWith("+++ "))
15347
+ plusHeaders.push(ln.slice(4).trim());
15348
+ else if (ln.startsWith("@@"))
15349
+ hunkCount++;
15350
+ }
15351
+ if (minusHeaders.length === 0 || plusHeaders.length === 0) {
15352
+ return {
15353
+ ok: false,
15354
+ code: "E_PATCH_INVALID_SHAPE",
15355
+ detail: "patch missing `---`/`+++` headers"
15356
+ };
15357
+ }
15358
+ if (hunkCount === 0) {
15359
+ return {
15360
+ ok: false,
15361
+ code: "E_PATCH_INVALID_SHAPE",
15362
+ detail: "patch contains no hunks"
15363
+ };
15364
+ }
15365
+ if (minusHeaders.length > 1 || plusHeaders.length > 1) {
15366
+ return {
15367
+ ok: false,
15368
+ code: "E_PATCH_INVALID_SHAPE",
15369
+ detail: "multi-file diff not allowed; single-file only"
15370
+ };
15371
+ }
15372
+ const targetBasename = targetPath.split("/").pop() ?? targetPath;
15373
+ for (const h of [...minusHeaders, ...plusHeaders]) {
15374
+ const path2 = h.split("\t")[0].trim();
15375
+ if (!isTargetPathHeader(path2, targetBasename)) {
15376
+ return {
15377
+ ok: false,
15378
+ code: "E_PATCH_INVALID_SHAPE",
15379
+ detail: `header path "${path2}" does not match target "${targetBasename}"`
15380
+ };
15381
+ }
15382
+ }
15383
+ return null;
15384
+ }
15385
+ function applyPatch(unifiedDiff, configPath, gitBin) {
15386
+ if (!existsSync5(configPath)) {
15387
+ return {
15388
+ ok: false,
15389
+ code: "E_PATCH_APPLY_FAILED",
15390
+ detail: `target config not found at ${configPath}`
15391
+ };
15392
+ }
15393
+ const liveContent = readFileSync3(configPath, "utf8");
15394
+ const scratchDir = mkdtempSync(join2(tmpdir(), "config-propose-edit-"));
15395
+ try {
15396
+ const basename = configPath.split("/").pop() ?? "switchroom.yaml";
15397
+ const scratchFile = join2(scratchDir, basename);
15398
+ writeFileSync(scratchFile, liveContent);
15399
+ const patchFile = join2(scratchDir, "proposal.patch");
15400
+ writeFileSync(patchFile, unifiedDiff);
15401
+ const bin = gitBin ?? "git";
15402
+ const baseArgs = [
15403
+ "apply",
15404
+ "--whitespace=nowarn",
15405
+ "--recount",
15406
+ "--unidiff-zero"
15407
+ ];
15408
+ const checkP1 = spawnSync(bin, [...baseArgs.slice(0, 1), "--check", ...baseArgs.slice(1), "-p1", patchFile], { cwd: scratchDir, encoding: "utf8", timeout: 1e4 });
15409
+ let pStrip = "-p1";
15410
+ if (checkP1.status !== 0) {
15411
+ const checkP0 = spawnSync(bin, [...baseArgs.slice(0, 1), "--check", ...baseArgs.slice(1), "-p0", patchFile], { cwd: scratchDir, encoding: "utf8", timeout: 1e4 });
15412
+ if (checkP0.status !== 0) {
15413
+ const stderr = (checkP1.stderr || "") + (checkP0.stderr || "");
15414
+ return {
15415
+ ok: false,
15416
+ code: "E_PATCH_APPLY_FAILED",
15417
+ detail: `git apply --check failed: ${stderr.trim().slice(0, 500)}`
15418
+ };
15419
+ }
15420
+ pStrip = "-p0";
15421
+ }
15422
+ const real = spawnSync(bin, [...baseArgs, pStrip, patchFile], { cwd: scratchDir, encoding: "utf8", timeout: 1e4 });
15423
+ if (real.status !== 0) {
15424
+ return {
15425
+ ok: false,
15426
+ code: "E_PATCH_APPLY_FAILED",
15427
+ detail: `git apply failed: ${(real.stderr || "").trim().slice(0, 500)}`
15428
+ };
15429
+ }
15430
+ const after = readFileSync3(scratchFile, "utf8");
15431
+ return { ok: true, after };
15432
+ } finally {
15433
+ rmSync(scratchDir, { recursive: true, force: true });
15434
+ }
15435
+ }
15436
+ function failsafeParse(source) {
15437
+ let doc;
15438
+ try {
15439
+ doc = $parseDocument(source, { merge: false, strict: true });
15440
+ } catch (err) {
15441
+ return {
15442
+ ok: false,
15443
+ code: "E_YAML_UNSAFE_CONSTRUCT",
15444
+ detail: `YAML parse error: ${err.message}`
15445
+ };
15446
+ }
15447
+ if (doc.errors && doc.errors.length > 0) {
15448
+ return {
15449
+ ok: false,
15450
+ code: "E_YAML_UNSAFE_CONSTRUCT",
15451
+ detail: `YAML parse error: ${doc.errors[0].message}`
15452
+ };
15453
+ }
15454
+ let rejection = null;
15455
+ $visit(doc, (_key, node, _path) => {
15456
+ if (rejection)
15457
+ return $visit.BREAK;
15458
+ if ($isAlias(node)) {
15459
+ rejection = {
15460
+ ok: false,
15461
+ code: "E_YAML_UNSAFE_CONSTRUCT",
15462
+ detail: "YAML aliases (`*name`) are not allowed"
15463
+ };
15464
+ return $visit.BREAK;
15465
+ }
15466
+ if ($isNode(node)) {
15467
+ if (node.anchor) {
15468
+ rejection = {
15469
+ ok: false,
15470
+ code: "E_YAML_UNSAFE_CONSTRUCT",
15471
+ detail: `YAML anchors (\`&${node.anchor}\`) are not allowed`
15472
+ };
15473
+ return $visit.BREAK;
15474
+ }
15475
+ const tag = node.tag;
15476
+ if (typeof tag === "string" && tag.startsWith("!")) {
15477
+ rejection = {
15478
+ ok: false,
15479
+ code: "E_YAML_UNSAFE_CONSTRUCT",
15480
+ detail: `YAML explicit tags (\`${tag}\`) are not allowed`
15481
+ };
15482
+ return $visit.BREAK;
15483
+ }
15484
+ }
15485
+ if ($isPair(node)) {
15486
+ const k = node.key;
15487
+ if ($isScalar(k) && k.value === "<<") {
15488
+ rejection = {
15489
+ ok: false,
15490
+ code: "E_YAML_UNSAFE_CONSTRUCT",
15491
+ detail: "YAML merge keys (`<<:`) are not allowed"
15492
+ };
15493
+ return $visit.BREAK;
15494
+ }
15495
+ }
15496
+ return;
15497
+ });
15498
+ if (rejection)
15499
+ return rejection;
15500
+ if (/(^|\n)\s*<<\s*:/.test(source)) {
15501
+ return {
15502
+ ok: false,
15503
+ code: "E_YAML_UNSAFE_CONSTRUCT",
15504
+ detail: "YAML merge keys (`<<:`) are not allowed"
15505
+ };
15506
+ }
15507
+ const tagMatch = source.match(/(^|\s)!![A-Za-z_][\w/.\-:]*/);
15508
+ if (tagMatch) {
15509
+ return {
15510
+ ok: false,
15511
+ code: "E_YAML_UNSAFE_CONSTRUCT",
15512
+ detail: `YAML explicit tags (\`${tagMatch[0].trim()}\`) are not allowed`
15513
+ };
15514
+ }
15515
+ return { ok: true, doc, data: doc.toJS() };
15516
+ }
15517
+ function schemaValidate(data) {
15518
+ const result = SwitchroomConfigSchema.safeParse(data);
15519
+ if (result.success)
15520
+ return null;
15521
+ const issue = result.error.issues[0];
15522
+ const where = issue?.path.join(".") || "(root)";
15523
+ return {
15524
+ ok: false,
15525
+ code: "E_SCHEMA_INVALID",
15526
+ detail: `schema validation failed at ${where}: ${issue?.message ?? "unknown"}`
15527
+ };
15528
+ }
15529
+ function collectStrings(root, out, prefix = "") {
15530
+ if (root === null || root === undefined)
15531
+ return;
15532
+ if (typeof root === "string") {
15533
+ out.set(prefix || "(root)", root);
15534
+ return;
15535
+ }
15536
+ if (Array.isArray(root)) {
15537
+ for (let i = 0;i < root.length; i++) {
15538
+ collectStrings(root[i], out, `${prefix}[${i}]`);
15539
+ }
15540
+ return;
15541
+ }
15542
+ if (typeof root === "object") {
15543
+ for (const [k, v] of Object.entries(root)) {
15544
+ const p = prefix ? `${prefix}.${k}` : k;
15545
+ collectStrings(v, out, p);
15546
+ }
15547
+ }
15548
+ }
15549
+ var SECRET_PREFIX_PATTERNS = [
15550
+ { name: "openai-key", re: /^sk-[A-Za-z0-9_\-]{20,}$/ },
15551
+ { name: "anthropic-key", re: /^sk-ant-[A-Za-z0-9_\-]{20,}$/ },
15552
+ { name: "github-pat", re: /^ghp_[A-Za-z0-9]{20,}$/ },
15553
+ { name: "github-fine-grained", re: /^github_pat_[A-Za-z0-9_]{20,}$/ },
15554
+ { name: "slack-bot", re: /^xoxb-[A-Za-z0-9\-]{10,}$/ },
15555
+ { name: "slack-user", re: /^xoxp-[A-Za-z0-9\-]{10,}$/ },
15556
+ { name: "google-api", re: /^AIza[A-Za-z0-9_\-]{30,}$/ },
15557
+ { name: "aws-access-key", re: /^AKIA[A-Z0-9]{16,}$/ }
15558
+ ];
15559
+ var CREDENTIAL_FIELD_RE = /(^|[._\-])(secret|token|password|api[_\-]?key|bot[_\-]?token|client[_\-]?secret|credential)s?($|[._\-])/i;
15560
+ var VAULT_REF_RE = /^vault:[A-Za-z0-9_\-]+(\/[A-Za-z0-9_\-]+)*$/;
15561
+ function masked(s) {
15562
+ if (s.length <= 8)
15563
+ return "***";
15564
+ return `${s.slice(0, 3)}...${s.slice(-3)}`;
15565
+ }
15566
+ function secretLeakGuard(before, after) {
15567
+ const beforeStrings = new Map;
15568
+ const afterStrings = new Map;
15569
+ collectStrings(before, beforeStrings);
15570
+ collectStrings(after, afterStrings);
15571
+ for (const [path2, val] of beforeStrings) {
15572
+ if (!VAULT_REF_RE.test(val))
15573
+ continue;
15574
+ if (!afterStrings.has(path2))
15575
+ continue;
15576
+ const newVal = afterStrings.get(path2);
15577
+ if (!VAULT_REF_RE.test(newVal)) {
15578
+ return {
15579
+ ok: false,
15580
+ code: "E_SECRET_LEAK_DETECTED",
15581
+ detail: `field "${path2}" was a vault reference (${val}) but the ` + `proposed change replaces it with a literal value ` + `(${masked(newVal)}); inlining a secret is not allowed`
15582
+ };
15583
+ }
15584
+ }
15585
+ for (const [path2, val] of afterStrings) {
15586
+ if (VAULT_REF_RE.test(val))
15587
+ continue;
15588
+ if (beforeStrings.get(path2) === val)
15589
+ continue;
15590
+ for (const { name, re } of SECRET_PREFIX_PATTERNS) {
15591
+ if (re.test(val)) {
15592
+ return {
15593
+ ok: false,
15594
+ code: "E_SECRET_LEAK_DETECTED",
15595
+ detail: `field "${path2}" contains a literal ${name}-shaped value ` + `(${masked(val)}); use a vault:<key> reference instead`
15596
+ };
15597
+ }
15598
+ }
15599
+ if (CREDENTIAL_FIELD_RE.test(path2)) {
15600
+ if (val.length >= 40 && /^[A-Za-z0-9+/=_\-]+$/.test(val)) {
15601
+ return {
15602
+ ok: false,
15603
+ code: "E_SECRET_LEAK_DETECTED",
15604
+ detail: `field "${path2}" is credential-named and the proposed ` + `value (${masked(val)}) looks like a literal secret; ` + `use a vault:<key> reference instead`
15605
+ };
15606
+ }
15607
+ }
15608
+ }
15609
+ return null;
15610
+ }
15611
+ function validateConfigEdit(opts) {
15612
+ const shapeErr = validateShape(opts.unifiedDiff, opts.targetPath);
15613
+ if (shapeErr)
15614
+ return shapeErr;
15615
+ const applied = applyPatch(opts.unifiedDiff, opts.configPath, opts.gitBin);
15616
+ if (!("ok" in applied) || applied.ok !== true) {
15617
+ return applied;
15618
+ }
15619
+ const parsedAfter = failsafeParse(applied.after);
15620
+ if (!("ok" in parsedAfter) || parsedAfter.ok !== true) {
15621
+ return parsedAfter;
15622
+ }
15623
+ const schemaErr = schemaValidate(parsedAfter.data);
15624
+ if (schemaErr)
15625
+ return schemaErr;
15626
+ let beforeData = {};
15627
+ try {
15628
+ const beforeRaw = existsSync5(opts.configPath) ? readFileSync3(opts.configPath, "utf8") : "";
15629
+ const beforeDoc = $parseDocument(beforeRaw, { merge: false, strict: false });
15630
+ beforeData = beforeDoc.toJS();
15631
+ } catch {
15632
+ beforeData = {};
15633
+ }
15634
+ const leakErr = secretLeakGuard(beforeData, parsedAfter.data);
15635
+ if (leakErr)
15636
+ return leakErr;
15637
+ return { ok: true };
15638
+ }
15639
+
15286
15640
  // src/host-control/server.ts
15287
15641
  function resolveDigests(imageRefs) {
15288
15642
  const out = new Map;
15289
15643
  for (const ref of imageRefs) {
15290
15644
  try {
15291
- const r = spawnSync("docker", ["inspect", "--format={{index .RepoDigests 0}}", ref], { encoding: "utf-8", timeout: 5000 });
15645
+ const r = spawnSync2("docker", ["inspect", "--format={{index .RepoDigests 0}}", ref], { encoding: "utf-8", timeout: 5000 });
15292
15646
  if (r.status !== 0)
15293
15647
  continue;
15294
15648
  const trimmed = (r.stdout ?? "").trim();
@@ -15306,11 +15660,11 @@ function resolveDigests(imageRefs) {
15306
15660
  return out;
15307
15661
  }
15308
15662
  function readCachedInstallType(bindRoot) {
15309
- const cacheDir = join2(bindRoot, ".switchroom");
15310
- const cachePath = join2(cacheDir, "install-type.json");
15311
- if (existsSync5(cachePath)) {
15663
+ const cacheDir = join3(bindRoot, ".switchroom");
15664
+ const cachePath = join3(cacheDir, "install-type.json");
15665
+ if (existsSync6(cachePath)) {
15312
15666
  try {
15313
- const raw = readFileSync3(cachePath, "utf-8");
15667
+ const raw = readFileSync4(cachePath, "utf-8");
15314
15668
  const parsed = JSON.parse(raw);
15315
15669
  if (parsed && typeof parsed.install_type === "string" && typeof parsed.detected_at === "string") {
15316
15670
  return parsed;
@@ -15329,7 +15683,7 @@ function readCachedInstallType(bindRoot) {
15329
15683
  try {
15330
15684
  mkdirSync(cacheDir, { recursive: true });
15331
15685
  const tmp = `${cachePath}.tmp`;
15332
- writeFileSync(tmp, JSON.stringify(payload, null, 2), { mode: 420 });
15686
+ writeFileSync2(tmp, JSON.stringify(payload, null, 2), { mode: 420 });
15333
15687
  renameSync(tmp, cachePath);
15334
15688
  } catch {}
15335
15689
  return payload;
@@ -15350,7 +15704,7 @@ class HostdServer {
15350
15704
  this.opts = opts;
15351
15705
  }
15352
15706
  async start() {
15353
- const hostdDir = join2(this.opts.homeDir, ".switchroom", "hostd");
15707
+ const hostdDir = join3(this.opts.homeDir, ".switchroom", "hostd");
15354
15708
  await mkdir(hostdDir, { recursive: true });
15355
15709
  await chmod(hostdDir, 493).catch(() => {
15356
15710
  return;
@@ -15361,13 +15715,13 @@ class HostdServer {
15361
15715
  }
15362
15716
  try {
15363
15717
  for (const name of agentNames) {
15364
- const dir = join2(hostdDir, name);
15365
- const sockPath = join2(dir, "sock");
15718
+ const dir = join3(hostdDir, name);
15719
+ const sockPath = join3(dir, "sock");
15366
15720
  await mkdir(dir, { recursive: true });
15367
15721
  await chmod(dir, 493).catch(() => {
15368
15722
  return;
15369
15723
  });
15370
- if (existsSync5(sockPath))
15724
+ if (existsSync6(sockPath))
15371
15725
  await unlink(sockPath).catch(() => {
15372
15726
  return;
15373
15727
  });
@@ -15398,13 +15752,13 @@ class HostdServer {
15398
15752
  process.stderr.write(`hostd: SWITCHROOM_HOSTD_OPERATOR_UID='${opUidStr}' is not a positive integer; skipping operator listener
15399
15753
  `);
15400
15754
  } else {
15401
- const dir = join2(hostdDir, "operator");
15402
- const sockPath = join2(dir, "sock");
15755
+ const dir = join3(hostdDir, "operator");
15756
+ const sockPath = join3(dir, "sock");
15403
15757
  await mkdir(dir, { recursive: true });
15404
15758
  await chmod(dir, 493).catch(() => {
15405
15759
  return;
15406
15760
  });
15407
- if (existsSync5(sockPath))
15761
+ if (existsSync6(sockPath))
15408
15762
  await unlink(sockPath).catch(() => {
15409
15763
  return;
15410
15764
  });
@@ -15561,6 +15915,9 @@ class HostdServer {
15561
15915
  case "agent_smoke":
15562
15916
  resp = await this.handleAgentSmoke(req, started);
15563
15917
  break;
15918
+ case "config_propose_edit":
15919
+ resp = this.handleConfigProposeEdit(req, started);
15920
+ break;
15564
15921
  }
15565
15922
  } catch (err) {
15566
15923
  resp = errorResponse(req.request_id, `hostd dispatch failed: ${err.message}`, Date.now() - started);
@@ -15608,6 +15965,8 @@ class HostdServer {
15608
15965
  return callerAdmin ? null : `${req.op} cross-agent requires admin: true on caller "${caller.name}"`;
15609
15966
  case "doctor":
15610
15967
  return callerAdmin ? null : `doctor requires admin: true on caller "${caller.name}"`;
15968
+ case "config_propose_edit":
15969
+ return callerAdmin ? null : `config_propose_edit requires admin: true on caller "${caller.name}"`;
15611
15970
  }
15612
15971
  }
15613
15972
  async handleAgentRestart(req, caller, started) {
@@ -15687,10 +16046,10 @@ class HostdServer {
15687
16046
  missingApplyAssets() {
15688
16047
  const root = this.opts.applyAssetsRoot ?? resolve5(import.meta.dirname, "../..");
15689
16048
  return [
15690
- join2(root, "profiles"),
15691
- join2(root, "profiles", "default"),
15692
- join2(root, "vendor", "hindsight-memory")
15693
- ].filter((p) => !existsSync5(p));
16049
+ join3(root, "profiles"),
16050
+ join3(root, "profiles", "default"),
16051
+ join3(root, "vendor", "hindsight-memory")
16052
+ ].filter((p) => !existsSync6(p));
15694
16053
  }
15695
16054
  applyAssetPreflight(request_id, started) {
15696
16055
  const missing = this.missingApplyAssets();
@@ -15854,6 +16213,22 @@ class HostdServer {
15854
16213
  stderr_tail: tail(res.stderr)
15855
16214
  };
15856
16215
  }
16216
+ handleConfigProposeEdit(req, started) {
16217
+ const enabled = this.opts.config.hostd?.config_edit_enabled === true;
16218
+ if (!enabled) {
16219
+ return errorResponse(req.request_id, "E_CONFIG_EDIT_DISABLED: config_propose_edit is disabled; " + "operator must set hostd.config_edit_enabled=true in " + "switchroom.yaml to opt in", Date.now() - started);
16220
+ }
16221
+ const configPath = this.opts.configPath ?? req.args.target_path;
16222
+ const verdict = validateConfigEdit({
16223
+ configPath,
16224
+ targetPath: req.args.target_path,
16225
+ unifiedDiff: req.args.unified_diff
16226
+ });
16227
+ if (!verdict.ok) {
16228
+ return errorResponse(req.request_id, `${verdict.code}: ${verdict.detail}`, Date.now() - started);
16229
+ }
16230
+ return errorResponse(req.request_id, "E_NOT_IMPLEMENTED_APPLY_PATH: validation passed (apply path " + "not yet implemented — pending PR 1c)", Date.now() - started);
16231
+ }
15857
16232
  async handleAgentSmoke(req, started) {
15858
16233
  const container = `switchroom-${req.args.name}`;
15859
16234
  const respond = (containerState, probes2) => ({
@@ -15959,10 +16334,10 @@ class HostdServer {
15959
16334
  if (this.opts.imageRefsForDigests)
15960
16335
  return this.opts.imageRefsForDigests();
15961
16336
  try {
15962
- const composePath = join2(this.opts.bindRoot ?? this.opts.homeDir, ".switchroom", "compose", "docker-compose.yml");
15963
- if (!existsSync5(composePath))
16337
+ const composePath = join3(this.opts.bindRoot ?? this.opts.homeDir, ".switchroom", "compose", "docker-compose.yml");
16338
+ if (!existsSync6(composePath))
15964
16339
  return [];
15965
- const r = spawnSync("docker", [
16340
+ const r = spawnSync2("docker", [
15966
16341
  "compose",
15967
16342
  "-p",
15968
16343
  "switchroom",
@@ -16044,7 +16419,7 @@ class HostdServer {
16044
16419
  }
16045
16420
  }
16046
16421
  auditLogPath() {
16047
- return this.opts.auditLogPath ?? join2(this.opts.homeDir, ".switchroom", "host-control-audit.log");
16422
+ return this.opts.auditLogPath ?? join3(this.opts.homeDir, ".switchroom", "host-control-audit.log");
16048
16423
  }
16049
16424
  appendAuditRow(row) {
16050
16425
  const path2 = this.auditLogPath();
@@ -10948,7 +10948,7 @@ var init_dist = __esm(() => {
10948
10948
  });
10949
10949
 
10950
10950
  // src/config/schema.ts
10951
- var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, HostControlConfigSchema, SwitchroomConfigSchema;
10951
+ var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, HostControlConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
10952
10952
  var init_schema = __esm(() => {
10953
10953
  init_zod();
10954
10954
  CodeRepoEntrySchema = exports_external.object({
@@ -11299,6 +11299,10 @@ var init_schema = __esm(() => {
11299
11299
  HostControlConfigSchema = exports_external.object({
11300
11300
  enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip — the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` — " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C §5.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3).")
11301
11301
  });
11302
+ HostdConfigSchema = exports_external.object({
11303
+ config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
11304
+ config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit §5). Default 3 cards/hour; min 1, " + "max 20. Implemented as a sqlite token bucket in PR 1c; the " + "field is wired here in PR 1a so operators can pin it before the " + "limiter is live. Above the cap, the verb returns " + "`E_RATE_LIMITED` without raising a card.")
11305
+ });
11302
11306
  SwitchroomConfigSchema = exports_external.object({
11303
11307
  switchroom: exports_external.object({
11304
11308
  version: exports_external.literal(1).describe("Config schema version"),
@@ -11325,6 +11329,7 @@ var init_schema = __esm(() => {
11325
11329
  google_workspace: GoogleWorkspaceConfigSchema.describe("RFC G canonical key. Top-level Google Workspace configuration — " + "OAuth client credentials, approver allowlist, and tier knob (`core` " + "| `extended` | `complete`, default `core`). Mutually exclusive with " + "`drive:` at the top level (loader fails fast if both are set)."),
11326
11330
  quota: QuotaConfigSchema.optional().describe("Optional weekly/monthly USD spend budgets rendered in the session " + "greeting. Usage is read from ccusage at runtime; no network calls."),
11327
11331
  host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (docs/rfcs/host-control-daemon.md). Omit the block " + "to accept defaults; set `enabled: false` only on legacy systemd-" + "mode installs (removal tracked as RFC C Phase 3)."),
11332
+ hostd: HostdConfigSchema.default({}).describe("hostd verb-level knobs (RFC admin-agent-config-edit). Distinct " + "from `host_control:` which governs whether the daemon runs at " + "all. Currently scopes the opt-in flag and rate cap for the new " + "`config_propose_edit` verb (PR 1a — disabled by default)."),
11328
11333
  google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
11329
11334
  message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
11330
11335
  }).transform((v) => v.trim().toLowerCase()), exports_external.object({
@@ -10948,7 +10948,7 @@ var init_zod = __esm(() => {
10948
10948
  });
10949
10949
 
10950
10950
  // src/config/schema.ts
10951
- var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, HostControlConfigSchema, SwitchroomConfigSchema;
10951
+ var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, HostControlConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
10952
10952
  var init_schema = __esm(() => {
10953
10953
  init_zod();
10954
10954
  CodeRepoEntrySchema = exports_external.object({
@@ -11299,6 +11299,10 @@ var init_schema = __esm(() => {
11299
11299
  HostControlConfigSchema = exports_external.object({
11300
11300
  enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip — the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` — " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C §5.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3).")
11301
11301
  });
11302
+ HostdConfigSchema = exports_external.object({
11303
+ config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
11304
+ config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit §5). Default 3 cards/hour; min 1, " + "max 20. Implemented as a sqlite token bucket in PR 1c; the " + "field is wired here in PR 1a so operators can pin it before the " + "limiter is live. Above the cap, the verb returns " + "`E_RATE_LIMITED` without raising a card.")
11305
+ });
11302
11306
  SwitchroomConfigSchema = exports_external.object({
11303
11307
  switchroom: exports_external.object({
11304
11308
  version: exports_external.literal(1).describe("Config schema version"),
@@ -11325,6 +11329,7 @@ var init_schema = __esm(() => {
11325
11329
  google_workspace: GoogleWorkspaceConfigSchema.describe("RFC G canonical key. Top-level Google Workspace configuration — " + "OAuth client credentials, approver allowlist, and tier knob (`core` " + "| `extended` | `complete`, default `core`). Mutually exclusive with " + "`drive:` at the top level (loader fails fast if both are set)."),
11326
11330
  quota: QuotaConfigSchema.optional().describe("Optional weekly/monthly USD spend budgets rendered in the session " + "greeting. Usage is read from ccusage at runtime; no network calls."),
11327
11331
  host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (docs/rfcs/host-control-daemon.md). Omit the block " + "to accept defaults; set `enabled: false` only on legacy systemd-" + "mode installs (removal tracked as RFC C Phase 3)."),
11332
+ hostd: HostdConfigSchema.default({}).describe("hostd verb-level knobs (RFC admin-agent-config-edit). Distinct " + "from `host_control:` which governs whether the daemon runs at " + "all. Currently scopes the opt-in flag and rate cap for the new " + "`config_propose_edit` verb (PR 1a — disabled by default)."),
11328
11333
  google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
11329
11334
  message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
11330
11335
  }).transform((v) => v.trim().toLowerCase()), exports_external.object({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.13.5",
3
+ "version": "0.13.8",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {