switchroom 0.8.1 → 0.10.0

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 (105) hide show
  1. package/README.md +49 -57
  2. package/bin/timezone-hook.sh +9 -7
  3. package/dist/agent-scheduler/index.js +285 -45
  4. package/dist/auth-broker/index.js +13932 -0
  5. package/dist/cli/switchroom.js +15931 -12778
  6. package/dist/host-control/main.js +582 -43
  7. package/dist/vault/approvals/kernel-server.js +276 -47
  8. package/dist/vault/broker/server.js +333 -69
  9. package/examples/minimal.yaml +63 -0
  10. package/examples/personal-google-workspace-mcp/.env.example +34 -0
  11. package/examples/personal-google-workspace-mcp/README.md +194 -0
  12. package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
  13. package/examples/switchroom.yaml +220 -0
  14. package/package.json +6 -4
  15. package/profiles/_base/start.sh.hbs +3 -3
  16. package/profiles/_shared/agent-self-service.md.hbs +126 -0
  17. package/profiles/default/CLAUDE.md +10 -0
  18. package/profiles/default/CLAUDE.md.hbs +16 -0
  19. package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
  20. package/skills/buildkite-agent-runtime/SKILL.md +44 -11
  21. package/skills/buildkite-api/SKILL.md +31 -8
  22. package/skills/buildkite-cli/SKILL.md +27 -9
  23. package/skills/buildkite-migration/SKILL.md +22 -9
  24. package/skills/buildkite-pipelines/SKILL.md +26 -9
  25. package/skills/buildkite-secure-delivery/SKILL.md +23 -9
  26. package/skills/buildkite-test-engine/SKILL.md +25 -8
  27. package/skills/docx/SKILL.md +1 -1
  28. package/skills/file-bug/SKILL.md +34 -6
  29. package/skills/humanizer/SKILL.md +15 -0
  30. package/skills/humanizer-calibrate/SKILL.md +7 -1
  31. package/skills/mcp-builder/SKILL.md +1 -1
  32. package/skills/pdf/SKILL.md +1 -1
  33. package/skills/pptx/SKILL.md +1 -1
  34. package/skills/skill-creator/SKILL.md +21 -1
  35. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  36. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  37. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  38. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  39. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  40. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  41. package/skills/switchroom-cli/SKILL.md +63 -64
  42. package/skills/switchroom-health/SKILL.md +23 -10
  43. package/skills/switchroom-install/SKILL.md +3 -3
  44. package/skills/switchroom-manage/SKILL.md +26 -19
  45. package/skills/switchroom-runtime/SKILL.md +67 -15
  46. package/skills/switchroom-status/SKILL.md +26 -1
  47. package/skills/telegram-test-harness/SKILL.md +3 -0
  48. package/skills/webapp-testing/SKILL.md +31 -1
  49. package/skills/xlsx/SKILL.md +1 -1
  50. package/telegram-plugin/admin-commands/index.ts +7 -5
  51. package/telegram-plugin/dist/gateway/gateway.js +13042 -12844
  52. package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
  53. package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
  54. package/telegram-plugin/gateway/auth-command.ts +794 -0
  55. package/telegram-plugin/gateway/auth-line.ts +123 -0
  56. package/telegram-plugin/gateway/boot-card.ts +22 -36
  57. package/telegram-plugin/gateway/boot-probes.ts +3 -3
  58. package/telegram-plugin/gateway/gateway.ts +313 -798
  59. package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
  60. package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
  61. package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
  62. package/telegram-plugin/permission-title.ts +56 -0
  63. package/telegram-plugin/quota-check.ts +19 -41
  64. package/telegram-plugin/scripts/build.mjs +0 -1
  65. package/telegram-plugin/shared/bot-runtime.ts +5 -4
  66. package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
  67. package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
  68. package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
  69. package/telegram-plugin/tests/boot-probes.test.ts +11 -4
  70. package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
  71. package/telegram-plugin/tests/permission-title.test.ts +31 -0
  72. package/telegram-plugin/tests/quota-check.test.ts +5 -35
  73. package/telegram-plugin/uat/SETUP.md +31 -1
  74. package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
  75. package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
  76. package/telegram-plugin/uat/runners/report.ts +150 -0
  77. package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
  78. package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
  79. package/telegram-plugin/uat/runners/scorer.ts +106 -0
  80. package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
  81. package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
  82. package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +7 -1
  83. package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +7 -1
  84. package/telegram-plugin/auth-dashboard.ts +0 -1104
  85. package/telegram-plugin/auth-slot-parser.ts +0 -497
  86. package/telegram-plugin/dist/foreman/foreman.js +0 -31358
  87. package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
  88. package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
  89. package/telegram-plugin/foreman/foreman.ts +0 -1165
  90. package/telegram-plugin/foreman/setup-flow.ts +0 -345
  91. package/telegram-plugin/foreman/setup-state.ts +0 -239
  92. package/telegram-plugin/foreman/state.ts +0 -203
  93. package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
  94. package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
  95. package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
  96. package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
  97. package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
  98. package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
  99. package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
  100. package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
  101. package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
  102. package/telegram-plugin/tests/foreman-state.test.ts +0 -164
  103. package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
  104. package/telegram-plugin/tests/setup-flow.test.ts +0 -510
  105. package/telegram-plugin/tests/setup-state.test.ts +0 -146
@@ -6935,9 +6935,9 @@ var require_public_api = __commonJS((exports) => {
6935
6935
  import { homedir as homedir2 } from "node:os";
6936
6936
 
6937
6937
  // src/config/loader.ts
6938
- import { readFileSync, existsSync } from "node:fs";
6938
+ import { readFileSync as readFileSync2, existsSync as existsSync3 } from "node:fs";
6939
6939
  import { homedir } from "node:os";
6940
- import { resolve } from "node:path";
6940
+ import { resolve as resolve3 } from "node:path";
6941
6941
 
6942
6942
  // node_modules/.bun/yaml@2.8.3/node_modules/yaml/dist/index.js
6943
6943
  var composer = require_composer();
@@ -11012,7 +11012,7 @@ var AgentHooksSchema = exports_external.object({
11012
11012
  SessionEnd: exports_external.array(HookEntrySchema).optional()
11013
11013
  }).catchall(exports_external.array(HookEntrySchema)).optional();
11014
11014
  var SubagentSchema = exports_external.object({
11015
- description: exports_external.string().describe("When the main agent should delegate to this sub-agent"),
11015
+ description: exports_external.string().optional().describe("When the main agent should delegate to this sub-agent"),
11016
11016
  model: exports_external.string().optional().describe("Model: 'sonnet', 'opus', 'haiku', full ID, or 'inherit' (default)"),
11017
11017
  background: exports_external.boolean().optional().describe("Run in background by default (non-blocking). Default false"),
11018
11018
  isolation: exports_external.enum(["worktree"]).optional().describe("'worktree' gives the sub-agent its own git branch"),
@@ -11091,13 +11091,20 @@ var ChannelsSchema = exports_external.object({
11091
11091
  }).optional();
11092
11092
  var TIMEZONE_REGEX = /^UTC$|^[A-Z][A-Za-z0-9_+-]+(\/[A-Z][A-Za-z0-9_+-]+){1,2}$/;
11093
11093
  var ApproverIdSchema = exports_external.union([exports_external.number(), exports_external.string().regex(/^\d+$/)]);
11094
- var DriveConfigSchema = exports_external.object({
11094
+ var GoogleWorkspaceTierSchema = exports_external.enum([
11095
+ "core",
11096
+ "extended",
11097
+ "complete"
11098
+ ]);
11099
+ var GoogleWorkspaceConfigSchema = exports_external.object({
11095
11100
  google_client_id: exports_external.string().min(1).describe("Google OAuth client ID (literal string or vault reference e.g. 'vault:google-oauth-client-id')"),
11096
11101
  google_client_secret: exports_external.string().min(1).describe("Google OAuth client secret (literal string or vault reference e.g. 'vault:google-oauth-client-secret')"),
11097
- approvers: exports_external.array(ApproverIdSchema).min(1).describe("Array of numeric Telegram user IDs authorized to approve drive onboarding. " + "At least one must be specified.")
11102
+ approvers: exports_external.array(ApproverIdSchema).min(1).describe("Array of numeric Telegram user IDs authorized to approve drive onboarding. " + "At least one must be specified."),
11103
+ tier: GoogleWorkspaceTierSchema.optional().describe("RFC G Phase 1: which upstream MCP tier to expose. " + "core (default) = ~16 tools (Drive+Docs+Sheets+Calendar). " + "extended = ~40 tools (+Slides, Forms, Tasks, Chat). " + "complete = ~60+ tools (+Gmail; not recommended yet — see RFC G §5).")
11098
11104
  }).optional();
11099
- var AgentDriveConfigSchema = exports_external.object({
11100
- approvers: exports_external.array(ApproverIdSchema).min(1).optional().describe("Per-agent approver override. When set, replaces (does not extend) " + "the top-level drive.approvers list for this agent's onboarding card.")
11105
+ var AgentGoogleWorkspaceConfigSchema = exports_external.object({
11106
+ approvers: exports_external.array(ApproverIdSchema).min(1).optional().describe("Per-agent approver override. When set, replaces (does not extend) " + "the top-level drive.approvers list for this agent's onboarding card."),
11107
+ tier: GoogleWorkspaceTierSchema.optional().describe("Per-agent tier override (RFC G Phase 1). When set, replaces the " + "top-level google_workspace.tier for this agent. Common case: most " + "agents on `core`, one specialist on `extended` for Slides access.")
11101
11108
  }).optional();
11102
11109
  var ReactionsSchema = exports_external.object({
11103
11110
  enabled: exports_external.boolean().optional().describe("Master switch for the reaction-trigger path. When false, " + "reactions are still persisted via recordReaction but never " + "dispatched to the agent as synthetic inbound turns. Default true."),
@@ -11151,6 +11158,12 @@ var profileFields = {
11151
11158
  claude_md_raw: exports_external.string().optional(),
11152
11159
  cli_args: exports_external.array(exports_external.string()).optional(),
11153
11160
  extra_stable_files: exports_external.array(exports_external.string()).optional().describe("Extra filenames (relative to the agent's workspace directory) to append " + "to the stable bootstrap render. Loaded once at session start via " + "`--append-system-prompt`. Missing files are silently skipped. " + "Example: ['BRIEF.md', 'CONTEXT.md']."),
11161
+ resources: exports_external.object({
11162
+ memory: exports_external.string().regex(/^\d+(\.\d+)?[kmgKMG]?$/, "memory must be a Docker size string like '6g', '512m', '1.5g'").optional().describe("Hard memory cap (Docker `mem_limit` → cgroup memory.max). When the " + "container exceeds this, the kernel OOM-kills processes in the cgroup. " + "Format: '6g', '1.5g', '512m'. When unset at every cascade layer the " + "compose generator falls back to the hard-coded per-profile defaults " + "in src/agents/compose.ts (klanker 6g, coding 2g, conversational 1.5g, " + "lightweight 1g, default 1.5g)."),
11163
+ memory_reservation: exports_external.string().regex(/^\d+(\.\d+)?[kmgKMG]?$/, "memory_reservation must be a Docker size string like '4g', '256m'").optional().describe("Soft memory floor (Docker `mem_reservation` → cgroup memory.low). " + "Under host-wide memory pressure, the kernel protects at least this " + "much from being reclaimed from the cgroup. Must be ≤ memory. Use to " + "keep an agent RAM-resident when the host has other tenants that " + "might push the box (Coolify apps, build jobs). Default: unset."),
11164
+ pids_limit: exports_external.number().int().positive().optional().describe("Max processes the cgroup can spawn (cgroup pids.max). Prevents " + "fork bombs and runaway test runners. Counts every process in the " + "cgroup including bash subprocesses, claude itself, sidecars, and " + "any test/build worker. A typical agent at idle uses ~30 PIDs; " + "`npm test`-style workloads can spike to 200+. Set generously " + "(2000 is a comfortable cap for test-running agents). Default: " + "unset (no cgroup pid cap)."),
11165
+ cpus: exports_external.number().positive().optional().describe("CPU quota (Docker `cpus`). Fractional values OK (e.g. 0.5, 2.0). " + "When unset at every cascade layer the compose generator falls " + "back to the per-profile default (klanker/coding 2.0, default 1.0, " + "lightweight 0.5).")
11166
+ }).optional().describe("Per-agent resource limits. Cascades through defaults → profile → " + "per-agent with per-field merge (agent wins on each field independently). " + "Any field left unset at every layer falls back to the hard-coded " + "per-profile defaults in src/agents/compose.ts."),
11154
11167
  experimental: exports_external.object({
11155
11168
  legacy_pty: exports_external.boolean().optional().describe("Opt out of the default tmux supervisor (#725) and run the agent under " + "the legacy PTY supervisor instead. Default: false (tmux is the default)."),
11156
11169
  legacy_autoaccept_expect: exports_external.boolean().optional().describe("Opt the autoaccept gateway back into the legacy expect-script behaviour " + "instead of the tmux send-keys path. Default: false.")
@@ -11164,13 +11177,13 @@ var AgentSchema = exports_external.object({
11164
11177
  bot_token: exports_external.string().optional().describe("Per-agent Telegram bot token or vault reference (overrides global telegram.bot_token)"),
11165
11178
  bot_username: exports_external.string().optional().describe("Per-agent Telegram bot username (without leading @) when it doesn't " + "contain the agent slug. Replaces the default 'username includes slug' " + "preflight check with an exact (case-insensitive) match. Use when an " + "agent and its bot have intentionally divergent names (e.g. agent " + "'lawgpt' paired with bot '@meken_law_bot')."),
11166
11179
  timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("Per-agent IANA timezone override. Wins over any profile/defaults " + "value and over the top-level switchroom.timezone global. Controls " + "the UserPromptSubmit timezone hook's emitted local time and the " + "systemd unit's TZ= env."),
11167
- auth_label: exports_external.string().optional().describe("Human-readable identity for the session-start greeting (e.g. 'user@example.com'). " + "Anthropic does not expose a public user-profile endpoint for OAuth tokens, so the " + "email/account cannot be read locally; the user declares it here. Appears in the Auth " + "row as '✓ max · <label> · expires ...'."),
11168
11180
  auth: exports_external.object({
11169
- accounts: exports_external.array(exports_external.string()).optional().describe("Ordered list of Anthropic account labels (from `~/.switchroom/accounts/`) " + "this agent can use. The first non-quota-exhausted account is the active one; " + "subsequent entries are auto-fallback targets. switchroom-auth-broker keeps " + "`<agentDir>/.claude/credentials.json` in sync with the active account on " + "every refresh and on every quota event. When unset, the agent falls back to " + "a single 'default' account; if no `default` account exists, the boot self-test " + "surfaces a one-line nudge to run `switchroom auth account add`.")
11170
- }).optional().describe("Account routing for switchroom-auth-broker. See " + "reference/share-auth-across-the-fleet.md for the unit-of-authentication model."),
11181
+ override: exports_external.string().min(1).optional().describe("Per-agent override of the fleet-wide `auth.active`. Edge-case use only " + "this agent talks to the named account regardless of fleet active. See RFC H §4.5.")
11182
+ }).optional().describe("Account routing for switchroom-auth-broker. RFC H schema uses " + "fleet-wide `auth.active` plus per-agent `override:` for edge cases. " + "Pre-RFC-H `auth.accounts: [..]` and `auth_label:` are migrated in-place " + "on first apply (see src/auth/migrate-schema.ts)."),
11171
11183
  dm_only: exports_external.boolean().optional().describe("Mark this agent as a DM-only bot — has its own bot_token and lives " + "exclusively in a private chat with the operator. Suppresses " + "scaffolding's default behavior of inheriting the global " + "telegram.forum_chat_id into the agent's access.json `groups` entry " + "(the forum chat the bot isn't a member of, which would otherwise " + "trigger a 'boot-probe-failed: 400 chat not found' warning every " + "restart). topic_name is still schema-required but unused — set it " + "to a display label like 'DM' for /switchroom status output."),
11172
11184
  topic_name: exports_external.string().describe("Telegram forum topic display name"),
11173
11185
  topic_emoji: exports_external.string().optional().describe("Emoji for the topic (e.g., '\uD83C\uDFCB️')"),
11186
+ purpose: exports_external.string().max(140).optional().describe("One-line description of what this agent does (≤140 chars). Shown to " + "peer agents when they call the agent-config MCP `peers_list` tool, so " + "every agent on the instance can answer 'is there an agent that does X' " + "without baking the fleet into prompts. Sourced live from " + "switchroom.yaml — never memorized into Hindsight. Falls back to " + "`topic_name` when absent."),
11174
11187
  role: exports_external.enum(["assistant", "foreman"]).optional().describe("Agent role. Default (omitted) is `assistant` — a fleet agent doing " + "user-facing tasks. `foreman` opts the agent in to switchroom's bundled " + "operator skills (switchroom-architecture / cli / health / install / manage " + "/ status), auto-symlinked into the agent's .claude/skills/ on scaffold and " + "reconcile. Fleet agents (assistant role) get no operator skills; reconcile " + "actively retracts them if the role flips back. See docs/skills.md for the model."),
11175
11188
  topic_id: exports_external.number().optional().describe("Telegram topic thread ID (auto-populated by switchroom topics sync)"),
11176
11189
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic"])).optional().describe("[DEPRECATED — moved to channels.telegram.webhook_sources in #596] " + "Old per-agent location. Still read but logs a deprecation warning. " + "See channels.telegram.webhook_sources for the canonical spot."),
@@ -11217,7 +11230,8 @@ var AgentSchema = exports_external.object({
11217
11230
  disallowed_tools: exports_external.array(exports_external.string()).optional().describe("Granular tool denylist passed verbatim to Claude Code's --disallowedTools " + "flag. Same pattern syntax as allowed_tools (e.g. 'Bash(rm *)'). See #199."),
11218
11231
  extra_stable_files: exports_external.array(exports_external.string()).optional().describe("Extra filenames (relative to the agent's workspace directory) to append " + "to the stable bootstrap render. Loaded once at session start via " + "`--append-system-prompt`. Missing files are silently skipped. " + "Example: ['BRIEF.md', 'CONTEXT.md']."),
11219
11232
  code_repos: exports_external.array(CodeRepoEntrySchema).optional().describe("Git repositories this agent is allowed to claim worktrees from. " + "Each entry provides a short name alias, a source path, and an " + "optional concurrency cap (default 5). When code_repos is set, " + "claim_worktree accepts the alias as the repo argument. " + "Absolute paths may always be passed regardless of this list."),
11220
- drive: AgentDriveConfigSchema.describe("Per-agent drive onboarding overrides (currently just approvers). " + "When set, replaces the top-level drive.approvers list for this agent. " + "google_client_id/secret are not per-agent — they live at the top level."),
11233
+ drive: AgentGoogleWorkspaceConfigSchema.describe("RFC D legacy key — use `google_workspace:` instead. Per-agent " + "google_workspace overrides (currently approvers + tier). When set, " + "replaces the top-level approvers list for this agent. " + "google_client_id/secret are not per-agent — they live at the top level."),
11234
+ google_workspace: AgentGoogleWorkspaceConfigSchema.describe("RFC G canonical key. Per-agent Google Workspace overrides — currently " + "approvers (replaces, does not extend the top-level list) and tier " + "(`core` | `extended` | `complete`, replaces top-level default). " + "google_client_id/secret are not per-agent — they live at the top level. " + "Mutually exclusive with `drive:` on the same agent (loader fails fast " + "if both are set)."),
11221
11235
  repos: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9-]*$/, "Repo slug must be kebab-case ASCII: start with a lowercase letter or digit, contain only lowercase letters, digits, and hyphens"), exports_external.object({
11222
11236
  url: exports_external.string().min(1).describe("Git remote URL for the repo (e.g. 'git@github.com:org/repo.git' or " + "'https://github.com/org/repo.git'). Used verbatim for git clone."),
11223
11237
  branch_default: exports_external.string().optional().describe("Default branch to track (defaults to the remote's HEAD, typically 'main'). " + "The per-agent branch 'agent/<agentName>/main' fast-forwards to this branch " + "when the worktree is clean on session start.")
@@ -11225,7 +11239,13 @@ var AgentSchema = exports_external.object({
11225
11239
  experimental: exports_external.object({
11226
11240
  legacy_pty: exports_external.boolean().optional().describe("Opt out of the default tmux supervisor (#725) and run the agent " + "under the legacy PTY supervisor instead. Default: false."),
11227
11241
  legacy_autoaccept_expect: exports_external.boolean().optional().describe("Opt the autoaccept gateway back into the legacy expect-script " + "behaviour instead of the tmux send-keys path. Default: false.")
11228
- }).optional().describe("Opt-in flags for experimental / legacy behaviours. Cascades through " + "defaults → profile → per-agent.")
11242
+ }).optional().describe("Opt-in flags for experimental / legacy behaviours. Cascades through " + "defaults → profile → per-agent."),
11243
+ resources: exports_external.object({
11244
+ memory: exports_external.string().regex(/^\d+(\.\d+)?[kmgKMG]?$/).optional(),
11245
+ memory_reservation: exports_external.string().regex(/^\d+(\.\d+)?[kmgKMG]?$/).optional(),
11246
+ pids_limit: exports_external.number().int().positive().optional(),
11247
+ cpus: exports_external.number().positive().optional()
11248
+ }).optional()
11229
11249
  });
11230
11250
  var TelegramConfigSchema = exports_external.object({
11231
11251
  bot_token: exports_external.string().describe("Telegram bot token or vault reference (e.g., 'vault:telegram-bot-token')"),
@@ -11266,7 +11286,7 @@ var QuotaConfigSchema = exports_external.object({
11266
11286
  monthly_budget_usd: exports_external.number().positive().optional().describe("Monthly USD spend budget. If unset, the greeting shows raw usage only.")
11267
11287
  });
11268
11288
  var HostControlConfigSchema = exports_external.object({
11269
- enabled: exports_external.boolean().optional().describe("Opt-in to the host-control daemon. Default: false (Phase 1). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. The daemon itself is installed by `switchroom setup` " + "as a systemd user unit. systemd hosts only compose-mode " + "support is deferred to a v2 RFC (see RFC C §5.1 for why). " + "Gateway integration (swap of spawnSwitchroomDetached callsites) " + "lands in a Phase 2 follow-up PR; setting enabled: true in " + "Phase 1 ships the daemon but does not change gateway behavior.")
11289
+ enabled: exports_external.boolean().optional().describe("Opt-in to the host-control daemon. Default: false. " + "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. " + "Since Phase 2 (#1175 PR γ) the gateway's /restart, /new, /reset, " + "and /update apply slash-commands automatically dispatch through " + "hostd when enabled replacing the in-container " + "`spawnSwitchroomDetached` shellout that requires docker access. " + "Set enabled: true on docker-mode installs to make those verbs work " + "(they otherwise fail because the agent container has no docker " + "binary/socket).")
11270
11290
  });
11271
11291
  var SwitchroomConfigSchema = exports_external.object({
11272
11292
  switchroom: exports_external.object({
@@ -11278,9 +11298,28 @@ var SwitchroomConfigSchema = exports_external.object({
11278
11298
  telegram: TelegramConfigSchema,
11279
11299
  memory: MemoryBackendConfigSchema.optional(),
11280
11300
  vault: VaultConfigSchema.optional(),
11281
- drive: DriveConfigSchema.describe("Optional drive onboarding configuration. When set, supplies Google " + "OAuth client credentials and the approver allowlist for `switchroom " + "drive connect`. 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."),
11301
+ auth: exports_external.object({
11302
+ active: exports_external.string().min(1).optional().describe("Fleet-wide active Anthropic account label. Every agent without " + "an explicit `agent.auth.override` uses this account. See " + "docs/auth.md for the full model. Set by `switchroom auth use <label>`."),
11303
+ fallback_order: exports_external.array(exports_external.string().min(1)).optional().describe("Ordered list of account labels for `switchroom auth rotate` to cycle " + "through when the active account hits a quota event. First entry is " + "normally the same as `auth.active`. When unset, `rotate` is a no-op."),
11304
+ consumers: exports_external.array(exports_external.object({
11305
+ name: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
11306
+ message: "Consumer name must be a path-safe slug (letters, digits, underscore, hyphen)"
11307
+ }).describe("Socket-path identity; binds at /run/switchroom/auth-broker/<name>/sock"),
11308
+ account: exports_external.string().min(1).describe("Pinned account label for this consumer. `get-credentials` returns " + "this account's credentials; `mark-exhausted` from this consumer " + "only affects this account."),
11309
+ 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).")
11310
+ })).optional().describe("Non-agent peers that hold a broker socket (RFC H §4.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.")
11311
+ }).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)."),
11312
+ drive: GoogleWorkspaceConfigSchema.describe("RFC D legacy key — 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."),
11313
+ 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)."),
11282
11314
  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."),
11283
11315
  host_control: HostControlConfigSchema.optional().describe("Optional host-control daemon configuration. See RFC C " + "(docs/rfcs/host-control-daemon.md) and the field-level help on " + "`enabled` for the Phase 1 scope."),
11316
+ google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
11317
+ message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
11318
+ }).transform((v) => v.trim().toLowerCase()), exports_external.object({
11319
+ enabled_for: exports_external.array(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,50}$/, {
11320
+ message: "Agent name must match the standard agent-name pattern"
11321
+ })).describe("Agent slugs that may read this account's vault slots " + "(`google:<account>:refresh_token` etc). Per-agent ACL is " + "enforced at the broker, not at the agent identity layer — " + "the agent still authenticates via socket-path-as-identity " + "per RFC D §4.1, broker just gates the cross-agent token share.")
11322
+ })).optional().describe("RFC G Phase 2: per-Google-account ACL for vault slots holding " + "OAuth refresh tokens. Maps account email → list of agents " + "permitted to read that account's slots. Written by `switchroom " + "auth google enable|disable` (Phase 3); read by the broker on " + "every Google slot access. Replaces RFC D's per-agent vault slot " + "scope (which can't express 'two agents share one Google account')."),
11284
11323
  defaults: AgentDefaultsSchema.describe("Implicit bottom-of-cascade profile applied to every agent before " + "per-agent config and `extends:` resolution. Tools, mcp_servers, and " + "schedule are unioned/concatenated; scalars and nested objects are " + "shallow-merged with per-agent values winning."),
11285
11324
  profiles: exports_external.record(exports_external.string(), ProfileSchema).optional().describe("Named profile definitions. Agents reference via `extends: <name>`. " + "Inline profiles declared here take priority over filesystem " + "profiles/<name>/ directories when both exist."),
11286
11325
  agents: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,50}$/, {
@@ -11288,6 +11327,158 @@ var SwitchroomConfigSchema = exports_external.object({
11288
11327
  }), AgentSchema).describe("Map of agent name to agent configuration")
11289
11328
  });
11290
11329
 
11330
+ // src/config/paths.ts
11331
+ import { existsSync } from "node:fs";
11332
+ import { resolve } from "node:path";
11333
+ var DEFAULT_STATE_DIR = ".switchroom";
11334
+ var LEGACY_STATE_DIR = ".clerk";
11335
+ function home() {
11336
+ return process.env.HOME ?? "/root";
11337
+ }
11338
+ function resolveDualPath(pathStr) {
11339
+ const h = home();
11340
+ if (pathStr.startsWith("~/")) {
11341
+ const rest = pathStr.slice(2);
11342
+ const absolute = resolve(h, rest);
11343
+ if (rest.startsWith(`${DEFAULT_STATE_DIR}/`)) {
11344
+ const frag = rest.slice(DEFAULT_STATE_DIR.length + 1);
11345
+ if (!existsSync(absolute)) {
11346
+ const legacy = resolve(h, LEGACY_STATE_DIR, frag);
11347
+ if (existsSync(legacy))
11348
+ return legacy;
11349
+ }
11350
+ }
11351
+ return absolute;
11352
+ }
11353
+ return resolve(pathStr);
11354
+ }
11355
+
11356
+ // src/config/overlay-loader.ts
11357
+ import { existsSync as existsSync2, readFileSync, readdirSync, statSync } from "node:fs";
11358
+ import { resolve as resolve2 } from "node:path";
11359
+
11360
+ // src/config/overlay-schema.ts
11361
+ var OverlayDocSchema = exports_external.object({
11362
+ schedule: exports_external.array(ScheduleEntrySchema).optional(),
11363
+ skills: exports_external.array(exports_external.string()).optional()
11364
+ }).strict();
11365
+
11366
+ // src/config/overlay-loader.ts
11367
+ var OVERLAY_SOURCE = Symbol.for("switchroom.config.overlay-source");
11368
+ function overlayDirFor(agentName, subdir) {
11369
+ const base = resolveDualPath(`~/.switchroom/agents/${agentName}/${subdir}`);
11370
+ return resolve2(base);
11371
+ }
11372
+ function listYamlFiles(dir) {
11373
+ if (!existsSync2(dir))
11374
+ return [];
11375
+ let entries;
11376
+ try {
11377
+ entries = readdirSync(dir);
11378
+ } catch {
11379
+ return [];
11380
+ }
11381
+ const out = [];
11382
+ for (const name of entries) {
11383
+ if (!/\.ya?ml$/i.test(name))
11384
+ continue;
11385
+ const full = resolve2(dir, name);
11386
+ try {
11387
+ if (statSync(full).isFile())
11388
+ out.push(full);
11389
+ } catch {}
11390
+ }
11391
+ return out.sort();
11392
+ }
11393
+ function stampOverlay(entry) {
11394
+ Object.defineProperty(entry, OVERLAY_SOURCE, {
11395
+ value: true,
11396
+ enumerable: false,
11397
+ configurable: false,
11398
+ writable: false
11399
+ });
11400
+ return entry;
11401
+ }
11402
+ function applyAgentOverlays(config) {
11403
+ const warnings = [];
11404
+ const agents = config.agents ?? {};
11405
+ for (const [agentName, agentCfg] of Object.entries(agents)) {
11406
+ try {
11407
+ const scheduleDir = overlayDirFor(agentName, "schedule.d");
11408
+ const files = listYamlFiles(scheduleDir);
11409
+ if (files.length > 0) {
11410
+ const merged = [...agentCfg.schedule ?? []];
11411
+ for (const file of files) {
11412
+ try {
11413
+ const raw = readFileSync(file, "utf-8");
11414
+ const parsed = $parse(raw);
11415
+ const doc = OverlayDocSchema.parse(parsed);
11416
+ for (const entry of doc.schedule ?? []) {
11417
+ if (entry.secrets && entry.secrets.length > 0) {
11418
+ const w = {
11419
+ agent: agentName,
11420
+ file,
11421
+ reason: "Overlay schedule entry declares secrets — dropped pending Phase E operator approval"
11422
+ };
11423
+ warnings.push(w);
11424
+ console.warn(`[switchroom] overlay-loader: agent='${agentName}' file='${file}': ${w.reason}`);
11425
+ continue;
11426
+ }
11427
+ merged.push(stampOverlay(entry));
11428
+ }
11429
+ } catch (err) {
11430
+ const reason = err instanceof ZodError ? `schema rejection: ${err.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join("; ")}` : `parse error: ${err.message}`;
11431
+ warnings.push({ agent: agentName, file, reason });
11432
+ console.warn(`[switchroom] overlay-loader: agent='${agentName}' file='${file}': ${reason}`);
11433
+ }
11434
+ }
11435
+ agentCfg.schedule = merged;
11436
+ }
11437
+ } catch (err) {
11438
+ warnings.push({
11439
+ agent: agentName,
11440
+ file: "(agent schedule overlay scan)",
11441
+ reason: `unexpected error: ${err.message}`
11442
+ });
11443
+ console.warn(`[switchroom] overlay-loader: agent='${agentName}' schedule.d: unexpected error: ${err.message}`);
11444
+ }
11445
+ try {
11446
+ const skillsDir = overlayDirFor(agentName, "skills.d");
11447
+ const skillFiles = listYamlFiles(skillsDir);
11448
+ if (skillFiles.length === 0) {} else {
11449
+ const merged = [...agentCfg.skills ?? []];
11450
+ const seen = new Set(merged);
11451
+ for (const file of skillFiles) {
11452
+ try {
11453
+ const raw = readFileSync(file, "utf-8");
11454
+ const parsed = $parse(raw);
11455
+ const doc = OverlayDocSchema.parse(parsed);
11456
+ for (const skillName of doc.skills ?? []) {
11457
+ if (seen.has(skillName))
11458
+ continue;
11459
+ seen.add(skillName);
11460
+ merged.push(skillName);
11461
+ }
11462
+ } catch (err) {
11463
+ const reason = err instanceof ZodError ? `schema rejection: ${err.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join("; ")}` : `parse error: ${err.message}`;
11464
+ warnings.push({ agent: agentName, file, reason });
11465
+ console.warn(`[switchroom] overlay-loader: agent='${agentName}' file='${file}': ${reason}`);
11466
+ }
11467
+ }
11468
+ agentCfg.skills = merged;
11469
+ }
11470
+ } catch (err) {
11471
+ warnings.push({
11472
+ agent: agentName,
11473
+ file: "(agent skills overlay scan)",
11474
+ reason: `unexpected error: ${err.message}`
11475
+ });
11476
+ console.warn(`[switchroom] overlay-loader: agent='${agentName}' skills.d: unexpected error: ${err.message}`);
11477
+ }
11478
+ }
11479
+ return { config, warnings };
11480
+ }
11481
+
11291
11482
  // src/config/loader.ts
11292
11483
  class ConfigError extends Error {
11293
11484
  details;
@@ -11303,27 +11494,65 @@ function formatZodErrors(error) {
11303
11494
  return ` ${path}: ${e.message}`;
11304
11495
  });
11305
11496
  }
11497
+ function coerceLegacyGoogleWorkspaceKeys(parsed, filePath) {
11498
+ const stableStringify = (v) => {
11499
+ if (v === null || typeof v !== "object")
11500
+ return JSON.stringify(v);
11501
+ if (Array.isArray(v))
11502
+ return `[${v.map(stableStringify).join(",")}]`;
11503
+ const obj = v;
11504
+ const keys = Object.keys(obj).sort();
11505
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
11506
+ };
11507
+ const aliasInPlace = (obj, where) => {
11508
+ const a = obj.drive;
11509
+ const b = obj.google_workspace;
11510
+ if (a !== undefined && b !== undefined) {
11511
+ if (stableStringify(a) !== stableStringify(b)) {
11512
+ throw new ConfigError(`Both \`drive:\` and \`google_workspace:\` are set on ${where} in ${filePath} with different values.`, [
11513
+ " These are aliases — pick one and remove the other.",
11514
+ " `google_workspace:` is the RFC G canonical key; `drive:` is the legacy alias.",
11515
+ " Allowed during transition: setting both with identical values."
11516
+ ]);
11517
+ }
11518
+ return;
11519
+ }
11520
+ if (a !== undefined && b === undefined)
11521
+ obj.google_workspace = a;
11522
+ if (b !== undefined && a === undefined)
11523
+ obj.drive = b;
11524
+ };
11525
+ aliasInPlace(parsed, "the top level");
11526
+ const agents = parsed.agents;
11527
+ if (agents && typeof agents === "object" && !Array.isArray(agents)) {
11528
+ for (const [name, agent] of Object.entries(agents)) {
11529
+ if (agent && typeof agent === "object" && !Array.isArray(agent)) {
11530
+ aliasInPlace(agent, `agent \`${name}\``);
11531
+ }
11532
+ }
11533
+ }
11534
+ }
11306
11535
  function findConfigFile(startDir) {
11307
11536
  const envPath = process.env.SWITCHROOM_CONFIG;
11308
- const home = homedir();
11309
- const userDir = resolve(home, ".switchroom");
11537
+ const home2 = homedir();
11538
+ const userDir = resolve3(home2, ".switchroom");
11310
11539
  const searchPaths = [
11311
- envPath ? resolve(envPath) : null,
11312
- startDir ? resolve(startDir, "switchroom.yaml") : null,
11313
- startDir ? resolve(startDir, "switchroom.yml") : null,
11314
- startDir ? resolve(startDir, "clerk.yaml") : null,
11315
- startDir ? resolve(startDir, "clerk.yml") : null,
11316
- resolve(process.cwd(), "switchroom.yaml"),
11317
- resolve(process.cwd(), "switchroom.yml"),
11318
- resolve(process.cwd(), "clerk.yaml"),
11319
- resolve(process.cwd(), "clerk.yml"),
11320
- resolve(userDir, "switchroom.yaml"),
11321
- resolve(userDir, "switchroom.yml"),
11322
- resolve(userDir, "clerk.yaml"),
11323
- resolve(userDir, "clerk.yml")
11540
+ envPath ? resolve3(envPath) : null,
11541
+ startDir ? resolve3(startDir, "switchroom.yaml") : null,
11542
+ startDir ? resolve3(startDir, "switchroom.yml") : null,
11543
+ startDir ? resolve3(startDir, "clerk.yaml") : null,
11544
+ startDir ? resolve3(startDir, "clerk.yml") : null,
11545
+ resolve3(process.cwd(), "switchroom.yaml"),
11546
+ resolve3(process.cwd(), "switchroom.yml"),
11547
+ resolve3(process.cwd(), "clerk.yaml"),
11548
+ resolve3(process.cwd(), "clerk.yml"),
11549
+ resolve3(userDir, "switchroom.yaml"),
11550
+ resolve3(userDir, "switchroom.yml"),
11551
+ resolve3(userDir, "clerk.yaml"),
11552
+ resolve3(userDir, "clerk.yml")
11324
11553
  ].filter(Boolean);
11325
11554
  for (const path of searchPaths) {
11326
- if (existsSync(path)) {
11555
+ if (existsSync3(path)) {
11327
11556
  return path;
11328
11557
  }
11329
11558
  }
@@ -11331,12 +11560,12 @@ function findConfigFile(startDir) {
11331
11560
  }
11332
11561
  function loadConfig(configPath) {
11333
11562
  const filePath = configPath ?? findConfigFile();
11334
- if (!existsSync(filePath)) {
11563
+ if (!existsSync3(filePath)) {
11335
11564
  throw new ConfigError(`Config file not found: ${filePath}`);
11336
11565
  }
11337
11566
  let raw;
11338
11567
  try {
11339
- raw = readFileSync(filePath, "utf-8");
11568
+ raw = readFileSync2(filePath, "utf-8");
11340
11569
  } catch (err) {
11341
11570
  throw new ConfigError(`Failed to read config file: ${filePath}`, [
11342
11571
  ` ${err.message}`
@@ -11355,14 +11584,20 @@ function loadConfig(configPath) {
11355
11584
  obj.switchroom = obj.clerk;
11356
11585
  delete obj.clerk;
11357
11586
  }
11587
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
11588
+ coerceLegacyGoogleWorkspaceKeys(parsed, filePath);
11589
+ }
11590
+ let config;
11358
11591
  try {
11359
- return SwitchroomConfigSchema.parse(parsed);
11592
+ config = SwitchroomConfigSchema.parse(parsed);
11360
11593
  } catch (err) {
11361
11594
  if (err instanceof ZodError) {
11362
11595
  throw new ConfigError("Invalid switchroom.yaml configuration", formatZodErrors(err));
11363
11596
  }
11364
11597
  throw err;
11365
11598
  }
11599
+ applyAgentOverlays(config);
11600
+ return config;
11366
11601
  }
11367
11602
 
11368
11603
  // src/agents/compose.ts
@@ -11643,6 +11878,11 @@ function mergeAgentConfig(defaultsIn, agentIn) {
11643
11878
  }
11644
11879
  merged.reactions = combined;
11645
11880
  }
11881
+ if (defaults.resources || merged.resources) {
11882
+ const d = defaults.resources ?? {};
11883
+ const a = merged.resources ?? {};
11884
+ merged.resources = { ...d, ...a };
11885
+ }
11646
11886
  if (defaults.experimental || merged.experimental) {
11647
11887
  const d = defaults.experimental ?? {};
11648
11888
  const a = merged.experimental ?? {};
@@ -11685,7 +11925,7 @@ var BIND_MOUNT_EXACT_SOURCE_DENY = new Set(["/var/run/docker.sock"]);
11685
11925
  import { createServer } from "node:net";
11686
11926
  import { spawn } from "node:child_process";
11687
11927
  import { mkdir, chmod, chown, unlink, appendFile } from "node:fs/promises";
11688
- import { readdirSync, existsSync as existsSync2, statSync } from "node:fs";
11928
+ import { readdirSync as readdirSync2, existsSync as existsSync4, statSync as statSync2 } from "node:fs";
11689
11929
  import { join, dirname } from "node:path";
11690
11930
 
11691
11931
  // src/host-control/protocol.ts
@@ -11717,10 +11957,66 @@ var GetStatusRequestSchema = exports_external.object({
11717
11957
  target_request_id: exports_external.string().min(1).max(128)
11718
11958
  })
11719
11959
  });
11960
+ var AgentNameSchema = exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, "agent name must be kebab-case ASCII");
11961
+ var UpdateCheckRequestSchema = exports_external.object({
11962
+ ...RequestEnvelope,
11963
+ op: exports_external.literal("update_check"),
11964
+ args: exports_external.object({}).optional()
11965
+ });
11966
+ var UpdateApplyRequestSchema = exports_external.object({
11967
+ ...RequestEnvelope,
11968
+ op: exports_external.literal("update_apply"),
11969
+ args: exports_external.object({
11970
+ skip_images: exports_external.boolean().optional(),
11971
+ rebuild: exports_external.boolean().optional()
11972
+ }).optional()
11973
+ });
11974
+ var ApplyRequestSchema = exports_external.object({
11975
+ ...RequestEnvelope,
11976
+ op: exports_external.literal("apply"),
11977
+ args: exports_external.object({}).optional()
11978
+ });
11979
+ var AgentStartRequestSchema = exports_external.object({
11980
+ ...RequestEnvelope,
11981
+ op: exports_external.literal("agent_start"),
11982
+ args: exports_external.object({
11983
+ name: AgentNameSchema
11984
+ })
11985
+ });
11986
+ var AgentStopRequestSchema = exports_external.object({
11987
+ ...RequestEnvelope,
11988
+ op: exports_external.literal("agent_stop"),
11989
+ args: exports_external.object({
11990
+ name: AgentNameSchema
11991
+ })
11992
+ });
11993
+ var AgentLogsRequestSchema = exports_external.object({
11994
+ ...RequestEnvelope,
11995
+ op: exports_external.literal("agent_logs"),
11996
+ args: exports_external.object({
11997
+ name: AgentNameSchema,
11998
+ tail: exports_external.number().int().positive().max(2000).optional()
11999
+ })
12000
+ });
12001
+ var AgentExecRequestSchema = exports_external.object({
12002
+ ...RequestEnvelope,
12003
+ op: exports_external.literal("agent_exec"),
12004
+ args: exports_external.object({
12005
+ name: AgentNameSchema,
12006
+ argv: exports_external.array(exports_external.string().min(1)).min(1).max(32)
12007
+ })
12008
+ });
11720
12009
  var RequestSchema = exports_external.discriminatedUnion("op", [
11721
12010
  AgentRestartRequestSchema,
11722
12011
  UpgradeStatusRequestSchema,
11723
- GetStatusRequestSchema
12012
+ GetStatusRequestSchema,
12013
+ UpdateCheckRequestSchema,
12014
+ UpdateApplyRequestSchema,
12015
+ ApplyRequestSchema,
12016
+ AgentStartRequestSchema,
12017
+ AgentStopRequestSchema,
12018
+ AgentLogsRequestSchema,
12019
+ AgentExecRequestSchema
11724
12020
  ]);
11725
12021
  var ResultSchema = exports_external.enum(["started", "completed", "denied", "error"]);
11726
12022
  var ResponseEnvelope = {
@@ -11799,13 +12095,14 @@ class HostdServer {
11799
12095
  servers = new Map;
11800
12096
  statusByRequestId = new Map;
11801
12097
  idempotencyKeys = new Map;
12098
+ fleetMutationInFlight = null;
11802
12099
  constructor(opts) {
11803
12100
  this.opts = opts;
11804
12101
  }
11805
12102
  async start() {
11806
12103
  const hostdDir = join(this.opts.homeDir, ".switchroom", "hostd");
11807
12104
  await mkdir(hostdDir, { recursive: true });
11808
- await chmod(hostdDir, 448).catch(() => {
12105
+ await chmod(hostdDir, 493).catch(() => {
11809
12106
  return;
11810
12107
  });
11811
12108
  const agentNames = Object.keys(this.opts.agentUids).sort();
@@ -11817,10 +12114,10 @@ class HostdServer {
11817
12114
  const dir = join(hostdDir, name);
11818
12115
  const sockPath = join(dir, "sock");
11819
12116
  await mkdir(dir, { recursive: true });
11820
- await chmod(dir, 448).catch(() => {
12117
+ await chmod(dir, 493).catch(() => {
11821
12118
  return;
11822
12119
  });
11823
- if (existsSync2(sockPath))
12120
+ if (existsSync4(sockPath))
11824
12121
  await unlink(sockPath).catch(() => {
11825
12122
  return;
11826
12123
  });
@@ -11829,8 +12126,8 @@ class HostdServer {
11829
12126
  process.stderr.write(`hostd: server error on ${sockPath}: ${err.message}
11830
12127
  `);
11831
12128
  });
11832
- await new Promise((resolve2, reject) => {
11833
- server.listen(sockPath, () => resolve2());
12129
+ await new Promise((resolve4, reject) => {
12130
+ server.listen(sockPath, () => resolve4());
11834
12131
  server.once("error", reject);
11835
12132
  });
11836
12133
  await chmod(sockPath, 432).catch(() => {
@@ -11852,7 +12149,7 @@ class HostdServer {
11852
12149
  async stop() {
11853
12150
  const paths = [...this.servers.keys()];
11854
12151
  for (const [, server] of this.servers) {
11855
- await new Promise((resolve2) => server.close(() => resolve2()));
12152
+ await new Promise((resolve4) => server.close(() => resolve4()));
11856
12153
  }
11857
12154
  this.servers.clear();
11858
12155
  for (const s of paths) {
@@ -11949,6 +12246,27 @@ class HostdServer {
11949
12246
  case "get_status":
11950
12247
  resp = this.handleGetStatus(req, caller, started);
11951
12248
  break;
12249
+ case "update_check":
12250
+ resp = await this.handleUpdateCheck(req, started);
12251
+ break;
12252
+ case "update_apply":
12253
+ resp = this.handleUpdateApply(req, caller, started);
12254
+ break;
12255
+ case "apply":
12256
+ resp = this.handleApply(req, caller, started);
12257
+ break;
12258
+ case "agent_start":
12259
+ resp = await this.handleAgentStart(req, started);
12260
+ break;
12261
+ case "agent_stop":
12262
+ resp = await this.handleAgentStop(req, started);
12263
+ break;
12264
+ case "agent_logs":
12265
+ resp = await this.handleAgentLogs(req, started);
12266
+ break;
12267
+ case "agent_exec":
12268
+ resp = await this.handleAgentExec(req, started);
12269
+ break;
11952
12270
  }
11953
12271
  } catch (err) {
11954
12272
  resp = errorResponse(req.request_id, `hostd dispatch failed: ${err.message}`, Date.now() - started);
@@ -11978,6 +12296,21 @@ class HostdServer {
11978
12296
  return null;
11979
12297
  return `get_status: request_id not found or not visible to caller "${caller.name}"`;
11980
12298
  }
12299
+ case "update_check":
12300
+ return null;
12301
+ case "update_apply":
12302
+ case "apply":
12303
+ return callerAdmin ? null : `${req.op} requires admin: true on caller "${caller.name}"`;
12304
+ case "agent_start":
12305
+ case "agent_stop":
12306
+ if (req.args.name === ("name" in caller ? caller.name : null))
12307
+ return null;
12308
+ return callerAdmin ? null : `${req.op} cross-agent requires admin: true on caller "${caller.name}"`;
12309
+ case "agent_logs":
12310
+ case "agent_exec":
12311
+ if (req.args.name === caller.name)
12312
+ return null;
12313
+ return callerAdmin ? null : `${req.op} cross-agent requires admin: true on caller "${caller.name}"`;
11981
12314
  }
11982
12315
  }
11983
12316
  async handleAgentRestart(req, caller, started) {
@@ -12029,6 +12362,190 @@ class HostdServer {
12029
12362
  stderr_tail: tail(res.stderr)
12030
12363
  };
12031
12364
  }
12365
+ async handleUpdateCheck(req, started) {
12366
+ const res = await this.runSwitchroom(["update", "--check"]);
12367
+ const result = res.exit_code === 0 ? "completed" : "error";
12368
+ return {
12369
+ v: 1,
12370
+ request_id: req.request_id,
12371
+ result,
12372
+ exit_code: res.exit_code,
12373
+ duration_ms: Date.now() - started,
12374
+ stdout_tail: tail(res.stdout),
12375
+ stderr_tail: tail(res.stderr)
12376
+ };
12377
+ }
12378
+ handleUpdateApply(req, caller, started) {
12379
+ const denied = this.checkFleetMutationLock(req.op, req.request_id, started);
12380
+ if (denied)
12381
+ return denied;
12382
+ const args = ["update"];
12383
+ if (req.args?.skip_images)
12384
+ args.push("--skip-images");
12385
+ if (req.args?.rebuild)
12386
+ args.push("--rebuild");
12387
+ const entry = {
12388
+ request_id: req.request_id,
12389
+ caller,
12390
+ op: req.op,
12391
+ result: "started",
12392
+ exit_code: null,
12393
+ started_at: started,
12394
+ finished_at: null,
12395
+ stdout_tail: "",
12396
+ stderr_tail: ""
12397
+ };
12398
+ this.recordStatus(entry);
12399
+ this.fleetMutationInFlight = {
12400
+ op: "update_apply",
12401
+ request_id: req.request_id,
12402
+ started_at: started
12403
+ };
12404
+ this.spawnFleetMutation(req.op, args, entry);
12405
+ return {
12406
+ v: 1,
12407
+ request_id: req.request_id,
12408
+ result: "started",
12409
+ exit_code: null,
12410
+ duration_ms: Date.now() - started
12411
+ };
12412
+ }
12413
+ handleApply(req, caller, started) {
12414
+ const denied = this.checkFleetMutationLock(req.op, req.request_id, started);
12415
+ if (denied)
12416
+ return denied;
12417
+ const args = ["apply", "--non-interactive"];
12418
+ const entry = {
12419
+ request_id: req.request_id,
12420
+ caller,
12421
+ op: req.op,
12422
+ result: "started",
12423
+ exit_code: null,
12424
+ started_at: started,
12425
+ finished_at: null,
12426
+ stdout_tail: "",
12427
+ stderr_tail: ""
12428
+ };
12429
+ this.recordStatus(entry);
12430
+ this.fleetMutationInFlight = {
12431
+ op: "apply",
12432
+ request_id: req.request_id,
12433
+ started_at: started
12434
+ };
12435
+ this.spawnFleetMutation(req.op, args, entry);
12436
+ return {
12437
+ v: 1,
12438
+ request_id: req.request_id,
12439
+ result: "started",
12440
+ exit_code: null,
12441
+ duration_ms: Date.now() - started
12442
+ };
12443
+ }
12444
+ async handleAgentStart(req, started) {
12445
+ const res = await this.runSwitchroom(["agent", "start", req.args.name]);
12446
+ return {
12447
+ v: 1,
12448
+ request_id: req.request_id,
12449
+ result: res.exit_code === 0 ? "completed" : "error",
12450
+ exit_code: res.exit_code,
12451
+ duration_ms: Date.now() - started,
12452
+ stdout_tail: tail(res.stdout),
12453
+ stderr_tail: tail(res.stderr)
12454
+ };
12455
+ }
12456
+ async handleAgentStop(req, started) {
12457
+ const args = ["agent", "stop", req.args.name];
12458
+ const res = await this.runSwitchroom(args);
12459
+ return {
12460
+ v: 1,
12461
+ request_id: req.request_id,
12462
+ result: res.exit_code === 0 ? "completed" : "error",
12463
+ exit_code: res.exit_code,
12464
+ duration_ms: Date.now() - started,
12465
+ stdout_tail: tail(res.stdout),
12466
+ stderr_tail: tail(res.stderr)
12467
+ };
12468
+ }
12469
+ async handleAgentLogs(req, started) {
12470
+ const tailLines = req.args.tail ?? 100;
12471
+ const container = `switchroom-${req.args.name}`;
12472
+ const res = await this.runDocker([
12473
+ "logs",
12474
+ "--tail",
12475
+ String(tailLines),
12476
+ container
12477
+ ]);
12478
+ return {
12479
+ v: 1,
12480
+ request_id: req.request_id,
12481
+ result: res.exit_code === 0 ? "completed" : "error",
12482
+ exit_code: res.exit_code,
12483
+ duration_ms: Date.now() - started,
12484
+ stdout_tail: tail(res.stdout),
12485
+ stderr_tail: tail(res.stderr)
12486
+ };
12487
+ }
12488
+ async handleAgentExec(req, started) {
12489
+ const argv0 = req.args.argv[0];
12490
+ if (!isAllowlistedReadOnlyArgv(argv0)) {
12491
+ return deniedResponse(req.request_id, `agent_exec: "${argv0}" is not on the read-only allowlist. ` + `Allowed: ${READONLY_EXEC_ALLOWLIST.join(", ")}. ` + `Writes inside peer containers require the host_os.exec ` + `approval-kernel scope, which is not yet wired — see ` + `docs/rfcs/approval-kernel.md §6 (deferred follow-up).`, Date.now() - started);
12492
+ }
12493
+ const container = `switchroom-${req.args.name}`;
12494
+ const res = await this.runDocker(["exec", container, ...req.args.argv]);
12495
+ return {
12496
+ v: 1,
12497
+ request_id: req.request_id,
12498
+ result: res.exit_code === 0 ? "completed" : "error",
12499
+ exit_code: res.exit_code,
12500
+ duration_ms: Date.now() - started,
12501
+ stdout_tail: tail(res.stdout),
12502
+ stderr_tail: tail(res.stderr)
12503
+ };
12504
+ }
12505
+ runDocker(args) {
12506
+ return new Promise((resolve4, reject) => {
12507
+ const bin = this.opts.dockerBin ?? "docker";
12508
+ const child = spawn(bin, args, {
12509
+ stdio: ["ignore", "pipe", "pipe"],
12510
+ env: { ...process.env }
12511
+ });
12512
+ let stdout = "";
12513
+ let stderr = "";
12514
+ child.stdout.on("data", (d) => {
12515
+ stdout += d.toString("utf8");
12516
+ });
12517
+ child.stderr.on("data", (d) => {
12518
+ stderr += d.toString("utf8");
12519
+ });
12520
+ child.on("error", (err) => reject(err));
12521
+ child.on("close", (code) => resolve4({ exit_code: code ?? -1, stdout, stderr }));
12522
+ });
12523
+ }
12524
+ checkFleetMutationLock(op, request_id, started) {
12525
+ const inFlight = this.fleetMutationInFlight;
12526
+ if (!inFlight)
12527
+ return null;
12528
+ const ageMs = Date.now() - inFlight.started_at;
12529
+ return deniedResponse(request_id, `${op}: fleet-mutation lock held by ${inFlight.op} ` + `(request_id "${inFlight.request_id}", running ${Math.floor(ageMs / 1000)}s). ` + `Wait for it to complete (poll get_status with target_request_id="${inFlight.request_id}") ` + `before issuing another fleet mutation.`, Date.now() - started);
12530
+ }
12531
+ spawnFleetMutation(op, args, entry) {
12532
+ this.runSwitchroom(args).then((res) => {
12533
+ entry.result = res.exit_code === 0 ? "completed" : "error";
12534
+ entry.exit_code = res.exit_code;
12535
+ entry.finished_at = Date.now();
12536
+ entry.stdout_tail = tail(res.stdout);
12537
+ entry.stderr_tail = tail(res.stderr);
12538
+ }).catch((err) => {
12539
+ entry.result = "error";
12540
+ entry.exit_code = null;
12541
+ entry.finished_at = Date.now();
12542
+ entry.error = err.message;
12543
+ }).finally(() => {
12544
+ if (this.fleetMutationInFlight && this.fleetMutationInFlight.request_id === entry.request_id) {
12545
+ this.fleetMutationInFlight = null;
12546
+ }
12547
+ });
12548
+ }
12032
12549
  handleGetStatus(req, _caller, started) {
12033
12550
  const entry = this.statusByRequestId.get(req.args.target_request_id);
12034
12551
  if (!entry) {
@@ -12089,7 +12606,7 @@ class HostdServer {
12089
12606
  });
12090
12607
  }
12091
12608
  runSwitchroom(args) {
12092
- return new Promise((resolve2, reject) => {
12609
+ return new Promise((resolve4, reject) => {
12093
12610
  const bin = this.opts.switchroomBin ?? "switchroom";
12094
12611
  const child = spawn(bin, args, {
12095
12612
  stdio: ["ignore", "pipe", "pipe"],
@@ -12104,7 +12621,7 @@ class HostdServer {
12104
12621
  stderr += d.toString("utf8");
12105
12622
  });
12106
12623
  child.on("error", (err) => reject(err));
12107
- child.on("close", (code) => resolve2({ exit_code: code ?? -1, stdout, stderr }));
12624
+ child.on("close", (code) => resolve4({ exit_code: code ?? -1, stdout, stderr }));
12108
12625
  });
12109
12626
  }
12110
12627
  }
@@ -12113,6 +12630,28 @@ function tail(s, bytes = TAIL_BYTES) {
12113
12630
  return s;
12114
12631
  return s.slice(s.length - bytes);
12115
12632
  }
12633
+ var READONLY_EXEC_ALLOWLIST = [
12634
+ "cat",
12635
+ "df",
12636
+ "du",
12637
+ "free",
12638
+ "grep",
12639
+ "head",
12640
+ "hostname",
12641
+ "id",
12642
+ "ls",
12643
+ "ps",
12644
+ "pwd",
12645
+ "stat",
12646
+ "tail",
12647
+ "uname",
12648
+ "uptime",
12649
+ "wc",
12650
+ "whoami"
12651
+ ];
12652
+ function isAllowlistedReadOnlyArgv(argv0) {
12653
+ return READONLY_EXEC_ALLOWLIST.includes(argv0);
12654
+ }
12116
12655
 
12117
12656
  // src/host-control/main.ts
12118
12657
  async function main() {