switchroom 0.14.11 → 0.14.13

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.
@@ -11406,6 +11406,9 @@ var HostControlConfigSchema = exports_external.object({
11406
11406
  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)."),
11407
11407
  auto_release_check: AutoReleaseCheckSchema.default({}).describe("Pull-based release-triggered fleet restart (#1743). hostd polls " + "the remote release tag on a fixed interval and applies + " + "restarts the fleet (graceful) when a new release is detected. " + "Opt-in: default enabled=false.")
11408
11408
  });
11409
+ var WebServiceConfigSchema = exports_external.object({
11410
+ managed: exports_external.boolean().default(false).describe("Whether `switchroom update` refreshes the web-service container " + "(dashboard + GitHub-webhook receiver) via `switchroom webd " + "install`. Default: false — existing installs run the web server " + "as the legacy `switchroom-web.service` systemd unit and must not " + "be surprised by a container takeover of host loopback 127.0.0.1:" + "8080 mid-update. Set true ONLY after cutting over to the " + "container (stop+disable the systemd unit, `switchroom webd " + "install`). The container runs in its own compose project " + "(`switchroom-web`), separate from the agent fleet, with " + "network_mode: host so it keeps owning loopback:8080 for the " + "cloudflared tunnel + tailscale serve consumers.")
11411
+ });
11409
11412
  var HostdConfigSchema = exports_external.object({
11410
11413
  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."),
11411
11414
  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.")
@@ -11439,6 +11442,7 @@ var SwitchroomConfigSchema = exports_external.object({
11439
11442
  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."),
11440
11443
  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)."),
11441
11444
  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)."),
11445
+ web_service: WebServiceConfigSchema.default({}).describe("Web-service container (dashboard + GitHub-webhook receiver) config. " + "Defaults to managed=false so existing systemd-mode installs are " + "untouched. Set managed: true after cutting over to the " + "`switchroom-web` container — then `switchroom update` keeps it " + "refreshed. See `switchroom webd install`."),
11442
11446
  google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
11443
11447
  message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
11444
11448
  }).transform((v) => v.trim().toLowerCase()), exports_external.object({
@@ -11406,6 +11406,9 @@ var HostControlConfigSchema = exports_external.object({
11406
11406
  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)."),
11407
11407
  auto_release_check: AutoReleaseCheckSchema.default({}).describe("Pull-based release-triggered fleet restart (#1743). hostd polls " + "the remote release tag on a fixed interval and applies + " + "restarts the fleet (graceful) when a new release is detected. " + "Opt-in: default enabled=false.")
11408
11408
  });
11409
+ var WebServiceConfigSchema = exports_external.object({
11410
+ managed: exports_external.boolean().default(false).describe("Whether `switchroom update` refreshes the web-service container " + "(dashboard + GitHub-webhook receiver) via `switchroom webd " + "install`. Default: false — existing installs run the web server " + "as the legacy `switchroom-web.service` systemd unit and must not " + "be surprised by a container takeover of host loopback 127.0.0.1:" + "8080 mid-update. Set true ONLY after cutting over to the " + "container (stop+disable the systemd unit, `switchroom webd " + "install`). The container runs in its own compose project " + "(`switchroom-web`), separate from the agent fleet, with " + "network_mode: host so it keeps owning loopback:8080 for the " + "cloudflared tunnel + tailscale serve consumers.")
11411
+ });
11409
11412
  var HostdConfigSchema = exports_external.object({
11410
11413
  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."),
11411
11414
  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.")
@@ -11439,6 +11442,7 @@ var SwitchroomConfigSchema = exports_external.object({
11439
11442
  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."),
11440
11443
  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)."),
11441
11444
  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)."),
11445
+ web_service: WebServiceConfigSchema.default({}).describe("Web-service container (dashboard + GitHub-webhook receiver) config. " + "Defaults to managed=false so existing systemd-mode installs are " + "untouched. Set managed: true after cutting over to the " + "`switchroom-web` container — then `switchroom update` keeps it " + "refreshed. See `switchroom webd install`."),
11442
11446
  google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
11443
11447
  message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
11444
11448
  }).transform((v) => v.trim().toLowerCase()), exports_external.object({
@@ -12153,6 +12153,9 @@ var HostControlConfigSchema = exports_external.object({
12153
12153
  enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip \u2014 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` \u2014 " + "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 \u00a75.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)."),
12154
12154
  auto_release_check: AutoReleaseCheckSchema.default({}).describe("Pull-based release-triggered fleet restart (#1743). hostd polls " + "the remote release tag on a fixed interval and applies + " + "restarts the fleet (graceful) when a new release is detected. " + "Opt-in: default enabled=false.")
12155
12155
  });
12156
+ var WebServiceConfigSchema = exports_external.object({
12157
+ managed: exports_external.boolean().default(false).describe("Whether `switchroom update` refreshes the web-service container " + "(dashboard + GitHub-webhook receiver) via `switchroom webd " + "install`. Default: false \u2014 existing installs run the web server " + "as the legacy `switchroom-web.service` systemd unit and must not " + "be surprised by a container takeover of host loopback 127.0.0.1:" + "8080 mid-update. Set true ONLY after cutting over to the " + "container (stop+disable the systemd unit, `switchroom webd " + "install`). The container runs in its own compose project " + "(`switchroom-web`), separate from the agent fleet, with " + "network_mode: host so it keeps owning loopback:8080 for the " + "cloudflared tunnel + tailscale serve consumers.")
12158
+ });
12156
12159
  var HostdConfigSchema = exports_external.object({
12157
12160
  config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit \u00a73). Default false \u2014 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."),
12158
12161
  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 \u00a75). 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.")
@@ -12186,6 +12189,7 @@ var SwitchroomConfigSchema = exports_external.object({
12186
12189
  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."),
12187
12190
  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)."),
12188
12191
  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 \u2014 disabled by default)."),
12192
+ web_service: WebServiceConfigSchema.default({}).describe("Web-service container (dashboard + GitHub-webhook receiver) config. " + "Defaults to managed=false so existing systemd-mode installs are " + "untouched. Set managed: true after cutting over to the " + "`switchroom-web` container \u2014 then `switchroom update` keeps it " + "refreshed. See `switchroom webd install`."),
12189
12193
  google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
12190
12194
  message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
12191
12195
  }).transform((v) => v.trim().toLowerCase()), exports_external.object({
@@ -13520,7 +13520,7 @@ var init_zod = __esm(() => {
13520
13520
  });
13521
13521
 
13522
13522
  // src/config/schema.ts
13523
- var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, DEFAULT_PROFILE = "default", AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
13523
+ var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, DEFAULT_PROFILE = "default", AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
13524
13524
  var init_schema = __esm(() => {
13525
13525
  init_zod();
13526
13526
  CodeRepoEntrySchema = exports_external.object({
@@ -13970,6 +13970,9 @@ var init_schema = __esm(() => {
13970
13970
  enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip \u2014 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` \u2014 " + "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 \u00a75.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)."),
13971
13971
  auto_release_check: AutoReleaseCheckSchema.default({}).describe("Pull-based release-triggered fleet restart (#1743). hostd polls " + "the remote release tag on a fixed interval and applies + " + "restarts the fleet (graceful) when a new release is detected. " + "Opt-in: default enabled=false.")
13972
13972
  });
13973
+ WebServiceConfigSchema = exports_external.object({
13974
+ managed: exports_external.boolean().default(false).describe("Whether `switchroom update` refreshes the web-service container " + "(dashboard + GitHub-webhook receiver) via `switchroom webd " + "install`. Default: false \u2014 existing installs run the web server " + "as the legacy `switchroom-web.service` systemd unit and must not " + "be surprised by a container takeover of host loopback 127.0.0.1:" + "8080 mid-update. Set true ONLY after cutting over to the " + "container (stop+disable the systemd unit, `switchroom webd " + "install`). The container runs in its own compose project " + "(`switchroom-web`), separate from the agent fleet, with " + "network_mode: host so it keeps owning loopback:8080 for the " + "cloudflared tunnel + tailscale serve consumers.")
13975
+ });
13973
13976
  HostdConfigSchema = exports_external.object({
13974
13977
  config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit \u00a73). Default false \u2014 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."),
13975
13978
  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 \u00a75). 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.")
@@ -14003,6 +14006,7 @@ var init_schema = __esm(() => {
14003
14006
  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."),
14004
14007
  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)."),
14005
14008
  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 \u2014 disabled by default)."),
14009
+ web_service: WebServiceConfigSchema.default({}).describe("Web-service container (dashboard + GitHub-webhook receiver) config. " + "Defaults to managed=false so existing systemd-mode installs are " + "untouched. Set managed: true after cutting over to the " + "`switchroom-web` container \u2014 then `switchroom update` keeps it " + "refreshed. See `switchroom webd install`."),
14006
14010
  google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
14007
14011
  message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
14008
14012
  }).transform((v) => v.trim().toLowerCase()), exports_external.object({
@@ -23441,9 +23445,9 @@ function emitAgentService(lines, a, imageTag, buildMode, buildContext, homePrefi
23441
23445
  if (existsSync14(`${hostHomeForChecks}/.switchroom/host-control-audit.log`)) {
23442
23446
  lines.push(` - ${homePrefix}/.switchroom/host-control-audit.log:/state/agent/home/.switchroom/host-control-audit.log:ro`);
23443
23447
  }
23444
- if (hostControlEnabled && existsSync14(`${hostHomeForChecks}/.switchroom/hostd/${a.name}`)) {
23445
- lines.push(` - ${homePrefix}/.switchroom/hostd/${a.name}:/run/switchroom/hostd/${a.name}`);
23446
- }
23448
+ }
23449
+ if (hostControlEnabled && existsSync14(`${hostHomeForChecks}/.switchroom/hostd/${a.name}`)) {
23450
+ lines.push(` - ${homePrefix}/.switchroom/hostd/${a.name}:/run/switchroom/hostd/${a.name}`);
23447
23451
  }
23448
23452
  if (a.bindMounts.length > 0) {
23449
23453
  if (!a.admin) {
@@ -49409,8 +49413,8 @@ var {
49409
49413
  } = import__.default;
49410
49414
 
49411
49415
  // src/build-info.ts
49412
- var VERSION = "0.14.11";
49413
- var COMMIT_SHA = "89d93911";
49416
+ var VERSION = "0.14.13";
49417
+ var COMMIT_SHA = "240594e9";
49414
49418
 
49415
49419
  // src/cli/agent.ts
49416
49420
  init_source();
@@ -73207,6 +73211,26 @@ function planUpdate(opts) {
73207
73211
  throw new Error("switchroom hostd install failed");
73208
73212
  }
73209
73213
  });
73214
+ let webServiceManaged;
73215
+ if (typeof opts.webServiceManaged === "boolean") {
73216
+ webServiceManaged = opts.webServiceManaged;
73217
+ } else {
73218
+ try {
73219
+ webServiceManaged = loadConfig().web_service?.managed === true;
73220
+ } catch {
73221
+ webServiceManaged = false;
73222
+ }
73223
+ }
73224
+ steps.push({
73225
+ name: "refresh-web",
73226
+ description: "switchroom webd install \u2014 pull latest web-service image + recreate the dashboard/webhook container (separate compose project)",
73227
+ skipReason: !webServiceManaged ? "web_service.managed is not true \u2014 web container not in use (legacy systemd unit)" : opts.skipImages ? "--skip-images flag set" : undefined,
73228
+ run: () => {
73229
+ const r = runner(process.execPath, [scriptPath, "webd", "install"]);
73230
+ if (r.status !== 0)
73231
+ throw new Error("switchroom webd install failed");
73232
+ }
73233
+ });
73210
73234
  steps.push({
73211
73235
  name: "sync-bundled-skills",
73212
73236
  description: "Sync shipped skills/ to ~/.switchroom/skills/_bundled/ (host-stable pool dir).",
@@ -82042,7 +82066,7 @@ services:
82042
82066
  - no-new-privileges:true
82043
82067
  volumes:
82044
82068
  # Bind-mounts the entire ~/.switchroom dir so the daemon can:
82045
- # - create ~/.switchroom/hostd/<agent>/sock per admin agent
82069
+ # - create ~/.switchroom/hostd/<agent>/sock per agent
82046
82070
  # - append to ~/.switchroom/host-control-audit.log
82047
82071
  - ${hostHome}/.switchroom:/host-home/.switchroom:rw
82048
82072
  # ~/.switchroom/switchroom.yaml is a symlink on many operator
@@ -82119,10 +82143,10 @@ async function doInstall(opts, program3) {
82119
82143
 
82120
82144
  ` + "Continuing anyway \u2014 the install completes (image-pinned compose file written), but `docker compose up` will fail-fast."));
82121
82145
  }
82122
- const adminAgents = Object.entries(cfg.agents ?? {}).filter(([, a]) => a?.admin === true).map(([name]) => name);
82123
- if (adminAgents.length === 0) {
82124
- console.error(source_default.yellow(`No admin-flagged agents in switchroom.yaml. The daemon binds one socket per admin agent \u2014 with none, it will exit on startup.
82125
- ` + "Set `admin: true` on at least one agent (typically the test runner or a dedicated operator agent)."));
82146
+ const allAgents = Object.keys(cfg.agents ?? {});
82147
+ if (allAgents.length === 0) {
82148
+ console.error(source_default.yellow(`No agents in switchroom.yaml. The daemon binds one socket per agent \u2014 with none, it will exit on startup.
82149
+ ` + "Add at least one agent before installing hostd."));
82126
82150
  }
82127
82151
  const dir = hostdDir();
82128
82152
  const composePath = hostdComposePath();
@@ -82143,7 +82167,9 @@ async function doInstall(opts, program3) {
82143
82167
  console.log(source_default.dim(` Backed up existing compose to ${bak}`));
82144
82168
  writeFileSync39(composePath, yaml, "utf8");
82145
82169
  console.log(source_default.green(` \u2713 Wrote ${composePath}`));
82146
- console.log(source_default.dim(` admin agents: ${adminAgents.length === 0 ? "(none)" : adminAgents.join(", ")}`));
82170
+ const adminAgents = Object.entries(cfg.agents ?? {}).filter(([, a]) => a?.admin === true).map(([name]) => name);
82171
+ console.log(source_default.dim(` agents served (one socket each): ${allAgents.length === 0 ? "(none)" : allAgents.join(", ")}`));
82172
+ console.log(source_default.dim(` admin agents (full config-edit verbs): ${adminAgents.length === 0 ? "(none)" : adminAgents.join(", ")}`));
82147
82173
  console.log(source_default.dim(` Pulling ghcr.io/switchroom/switchroom-hostd:${opts.tag ?? DEFAULT_IMAGE_TAG}\u2026`));
82148
82174
  const pull = runDocker(["compose", "-p", HOSTD_COMPOSE_PROJECT, "-f", composePath, "pull"]);
82149
82175
  if (!pull.ok) {
@@ -82285,6 +82311,222 @@ The log is created when hostd handles its first privileged-verb request.`));
82285
82311
  });
82286
82312
  }
82287
82313
 
82314
+ // src/cli/webd.ts
82315
+ init_source();
82316
+ init_helpers();
82317
+ import { existsSync as existsSync84, mkdirSync as mkdirSync47, writeFileSync as writeFileSync40, copyFileSync as copyFileSync13 } from "node:fs";
82318
+ import { homedir as homedir47 } from "node:os";
82319
+ import { join as join80 } from "node:path";
82320
+ import { spawnSync as spawnSync14 } from "node:child_process";
82321
+ var DEFAULT_IMAGE_TAG2 = "latest";
82322
+ var WEB_COMPOSE_PROJECT = "switchroom-web";
82323
+ function renderWebComposeFile(opts) {
82324
+ const { hostHome, imageTag, operatorUid } = opts;
82325
+ return `# AUTO-GENERATED by \`switchroom webd install\` \u2014 do not hand-edit.
82326
+ # Edits land at \`switchroom webd install\` time; backed up to
82327
+ # docker-compose.yml.bak-<ts> on overwrite.
82328
+ #
82329
+ # Separate compose project (\`switchroom-web\`) from the agent fleet
82330
+ # (\`switchroom\`) so \`compose up -d --remove-orphans\` against the fleet
82331
+ # cannot recreate this container mid-request. Same isolation contract
82332
+ # as switchroom-hostd.
82333
+
82334
+ services:
82335
+ web:
82336
+ image: ghcr.io/switchroom/switchroom-web:${imageTag}
82337
+ container_name: switchroom-web
82338
+ restart: unless-stopped
82339
+ # The server MUST own host loopback 127.0.0.1:8080 \u2014 the cloudflared
82340
+ # tunnel (GitHub webhooks) and \`tailscale serve\` (dashboard) both
82341
+ # reach it there, and the dashboard CSRF gate trusts the
82342
+ # Tailscale-User-Login header only when the request arrives on
82343
+ # loopback (PR #1380). Host networking makes the container's
82344
+ # 127.0.0.1 the host loopback, so both keep working unchanged.
82345
+ network_mode: host
82346
+ # The webhook.sock forward is peercred-gated by each agent's gateway
82347
+ # to {agent uid, SWITCHROOM_WEBHOOK_RECEIVER_UID = operator uid}.
82348
+ # Run as any other uid \u2192 every forward is 503'd. Operator uid also
82349
+ # owns ~/.switchroom so config/secret reads work.
82350
+ user: "${operatorUid}:${operatorUid}"
82351
+ # tini (image ENTRYPOINT) forwards SIGTERM to the bun process on
82352
+ # \`docker stop\`; Bun.serve shuts its listener within the grace
82353
+ # window. 15s mirrors hostd.
82354
+ stop_grace_period: 15s
82355
+ cap_drop:
82356
+ - ALL
82357
+ # Deliberately NO cap_add \u2014 the web server never chowns sockets or
82358
+ # shells out to docker. Minimal surface for the one internet-facing
82359
+ # component.
82360
+ security_opt:
82361
+ - no-new-privileges:true
82362
+ volumes:
82363
+ # The receiver reads operator-owned files under ~/.switchroom:
82364
+ # - webhook-secrets.json (per-source HMAC secrets)
82365
+ # - webhook-edge-secret (Cloudflare edge lock, Phase 2)
82366
+ # - web-token (dashboard auth)
82367
+ # and forwards verified events to per-agent webhook.sock at
82368
+ # ~/.switchroom/agents/<agent>/telegram/webhook.sock
82369
+ # so it needs the whole tree read-write (the sock connect is a
82370
+ # write-side operation on the agent dir).
82371
+ - ${hostHome}/.switchroom:/host-home/.switchroom:rw
82372
+ # ~/.switchroom/switchroom.yaml is a symlink on many operator
82373
+ # setups (into a separate config repo). Mounting the file directly
82374
+ # forces docker to follow the symlink at mount time and bind the
82375
+ # underlying file. The dashboard only READS the config, so :ro.
82376
+ # Mirrors how the agent + hostd containers expose the config at
82377
+ # /state/config/switchroom.yaml.
82378
+ - ${hostHome}/.switchroom/switchroom.yaml:/state/config/switchroom.yaml:ro
82379
+ # No docker socket is mounted \u2014 the web server never touches docker.
82380
+ environment:
82381
+ # The CLI resolves homedir() for ~/.switchroom paths; pin it to
82382
+ # /host-home (bind-mounted to the operator's home) so the paths
82383
+ # the server reads/writes match the host paths the agent fleet
82384
+ # binds (~/.switchroom/agents/<agent>/telegram/webhook.sock, \u2026).
82385
+ HOME: /host-home
82386
+ # Read the same config the agent fleet's compose generator did.
82387
+ SWITCHROOM_CONFIG: /state/config/switchroom.yaml
82388
+ # PATH must include /usr/local/bin for the switchroom shim.
82389
+ PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
82390
+ # network_mode: host means no custom network is attached; the
82391
+ # container shares the host network namespace directly.
82392
+
82393
+ # Healthcheck deferred \u2014 the server has no /healthz endpoint yet and a
82394
+ # probe that hits the dashboard route would need the web-token. For now,
82395
+ # \`switchroom webd status\` is the operator surface and the server's
82396
+ # stderr lands in \`docker logs switchroom-web\`.
82397
+ `;
82398
+ }
82399
+ function webdDir() {
82400
+ return join80(homedir47(), ".switchroom", "web");
82401
+ }
82402
+ function webdComposePath() {
82403
+ return join80(webdDir(), "docker-compose.yml");
82404
+ }
82405
+ function backupExistingCompose2() {
82406
+ const p = webdComposePath();
82407
+ if (!existsSync84(p))
82408
+ return null;
82409
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
82410
+ const bak = `${p}.bak-${ts}`;
82411
+ copyFileSync13(p, bak);
82412
+ return bak;
82413
+ }
82414
+ function runDocker2(args) {
82415
+ const r = spawnSync14("docker", args, { encoding: "utf8" });
82416
+ return {
82417
+ ok: r.status === 0,
82418
+ stdout: r.stdout ?? "",
82419
+ stderr: r.stderr ?? ""
82420
+ };
82421
+ }
82422
+ async function doInstall2(opts) {
82423
+ const operatorUid = resolveOperatorUid();
82424
+ if (operatorUid === undefined) {
82425
+ console.error(source_default.red(`Could not resolve the operator uid (no SUDO_UID and getuid() is 0 or unavailable).
82426
+ ` + `The web container must run as the operator uid so its webhook forwards pass
82427
+ ` + "each agent gateway's peercred ACL. Run `switchroom webd install` as the\n" + "operator user (not root), or under `sudo` from the operator's shell."));
82428
+ process.exit(1);
82429
+ }
82430
+ const dir = webdDir();
82431
+ const composePath = webdComposePath();
82432
+ mkdirSync47(dir, { recursive: true });
82433
+ const yaml = renderWebComposeFile({
82434
+ hostHome: homedir47(),
82435
+ imageTag: opts.tag ?? DEFAULT_IMAGE_TAG2,
82436
+ operatorUid
82437
+ });
82438
+ if (opts.dryRun) {
82439
+ console.log(source_default.dim(`# Would write: ${composePath}`));
82440
+ console.log(yaml);
82441
+ console.log(source_default.dim(`# Would run: docker compose -p ${WEB_COMPOSE_PROJECT} -f ${composePath} up -d`));
82442
+ return;
82443
+ }
82444
+ const bak = backupExistingCompose2();
82445
+ if (bak)
82446
+ console.log(source_default.dim(` Backed up existing compose to ${bak}`));
82447
+ writeFileSync40(composePath, yaml, "utf8");
82448
+ console.log(source_default.green(` \u2713 Wrote ${composePath}`));
82449
+ console.log(source_default.dim(` running as uid ${operatorUid} (operator), network_mode: host`));
82450
+ console.log(source_default.dim(` Pulling ghcr.io/switchroom/switchroom-web:${opts.tag ?? DEFAULT_IMAGE_TAG2}\u2026`));
82451
+ const pull = runDocker2(["compose", "-p", WEB_COMPOSE_PROJECT, "-f", composePath, "pull"]);
82452
+ if (!pull.ok) {
82453
+ console.error(source_default.red(` pull failed:
82454
+ ${pull.stderr}`));
82455
+ console.error(source_default.yellow(` Hint: \`ghcr.io/switchroom/switchroom-web:${opts.tag ?? DEFAULT_IMAGE_TAG2}\` may not be published yet.
82456
+ ` + ` Check the docker-images workflow run and verify the tag at:
82457
+ ` + ` https://github.com/switchroom/switchroom/pkgs/container/switchroom-web`));
82458
+ process.exit(1);
82459
+ }
82460
+ console.log(source_default.dim(` Bringing up web service\u2026`));
82461
+ const up = runDocker2(["compose", "-p", WEB_COMPOSE_PROJECT, "-f", composePath, "up", "-d"]);
82462
+ if (!up.ok) {
82463
+ console.error(source_default.red(` up failed:
82464
+ ${up.stderr}`));
82465
+ process.exit(1);
82466
+ }
82467
+ console.log(source_default.green(` \u2713 Web service running (project: ${WEB_COMPOSE_PROJECT})`));
82468
+ console.log(source_default.dim(` Logs: docker logs switchroom-web --tail 50`));
82469
+ console.log(source_default.dim(` Verify: switchroom webd status`));
82470
+ console.log(source_default.yellow(` NOTE: the switchroom-web.service systemd unit (if still installed) also
82471
+ ` + ` binds 127.0.0.1:8080. Stop it before this container can claim the port:
82472
+ ` + ` systemctl --user stop switchroom-web.service
82473
+ ` + ` systemctl --user disable switchroom-web.service`));
82474
+ }
82475
+ function doStatus2() {
82476
+ const composeYml = webdComposePath();
82477
+ console.log(source_default.bold("switchroom-web"));
82478
+ console.log("");
82479
+ if (!existsSync84(composeYml)) {
82480
+ console.log(source_default.yellow(" compose: not installed"));
82481
+ console.log(source_default.dim(" run `switchroom webd install` to set up."));
82482
+ return;
82483
+ }
82484
+ console.log(source_default.green(` compose: ${composeYml}`));
82485
+ const ps = runDocker2([
82486
+ "compose",
82487
+ "-p",
82488
+ WEB_COMPOSE_PROJECT,
82489
+ "-f",
82490
+ composeYml,
82491
+ "ps",
82492
+ "--format",
82493
+ "{{.Name}} {{.Status}} {{.Image}}"
82494
+ ]);
82495
+ if (!ps.ok || !ps.stdout.trim()) {
82496
+ console.log(source_default.yellow(" container: not running"));
82497
+ } else {
82498
+ console.log(source_default.green(` container: ${ps.stdout.trim()}`));
82499
+ }
82500
+ }
82501
+ function doUninstall2() {
82502
+ const composeYml = webdComposePath();
82503
+ if (!existsSync84(composeYml)) {
82504
+ console.log(source_default.yellow(" No web-service install detected (no compose file at this path)."));
82505
+ return;
82506
+ }
82507
+ console.log(source_default.dim(` Stopping ${WEB_COMPOSE_PROJECT}\u2026`));
82508
+ const down = runDocker2(["compose", "-p", WEB_COMPOSE_PROJECT, "-f", composeYml, "down"]);
82509
+ if (!down.ok) {
82510
+ console.error(source_default.red(` down failed:
82511
+ ${down.stderr}`));
82512
+ process.exit(1);
82513
+ }
82514
+ console.log(source_default.green(" \u2713 Web service stopped"));
82515
+ console.log(source_default.dim(` Compose file left in place at ${composeYml}`));
82516
+ console.log(source_default.dim(` To re-enable: switchroom webd install`));
82517
+ console.log(source_default.yellow(` If you cut over from the systemd unit, re-enable it to restore the
82518
+ ` + ` dashboard + webhook receiver:
82519
+ ` + ` systemctl --user enable --now switchroom-web.service`));
82520
+ }
82521
+ function registerWebdCommand(program3) {
82522
+ const webd = program3.command("webd").description("Manage switchroom-web, the dashboard + GitHub-webhook receiver container");
82523
+ webd.command("install").description("Install or refresh the web container (writes ~/.switchroom/web/docker-compose.yml + docker compose up -d)").option("--tag <tag>", "Image tag (default: latest)", DEFAULT_IMAGE_TAG2).option("--dry-run", "Print the compose file and the docker commands without writing or running anything").action(withConfigError(async (opts) => {
82524
+ await doInstall2(opts);
82525
+ }));
82526
+ webd.command("status").description("Show web-service container state").action(() => doStatus2());
82527
+ webd.command("uninstall").description("Stop the web container. Leaves the compose file in place for re-install.").action(() => doUninstall2());
82528
+ }
82529
+
82288
82530
  // src/cli/index.ts
82289
82531
  installGlobalErrorHandlers();
82290
82532
  var program3 = new Command().name("switchroom").description("Multi-agent orchestrator for Claude Code. One Telegram group, many specialized agents.").version(VERSION).option("-c, --config <path>", "Path to switchroom.yaml config file").hook("preAction", async (_thisCommand, actionCommand) => {
@@ -82333,6 +82575,7 @@ registerSkillSearchCommand(program3);
82333
82575
  registerAgentConfigMcpCommand(program3);
82334
82576
  registerHostdMcpCommand(program3);
82335
82577
  registerHostdCommand(program3);
82578
+ registerWebdCommand(program3);
82336
82579
 
82337
82580
  // bin/switchroom.ts
82338
82581
  program3.parse();
@@ -14141,6 +14141,9 @@ var HostControlConfigSchema = exports_external.object({
14141
14141
  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)."),
14142
14142
  auto_release_check: AutoReleaseCheckSchema.default({}).describe("Pull-based release-triggered fleet restart (#1743). hostd polls " + "the remote release tag on a fixed interval and applies + " + "restarts the fleet (graceful) when a new release is detected. " + "Opt-in: default enabled=false.")
14143
14143
  });
14144
+ var WebServiceConfigSchema = exports_external.object({
14145
+ managed: exports_external.boolean().default(false).describe("Whether `switchroom update` refreshes the web-service container " + "(dashboard + GitHub-webhook receiver) via `switchroom webd " + "install`. Default: false — existing installs run the web server " + "as the legacy `switchroom-web.service` systemd unit and must not " + "be surprised by a container takeover of host loopback 127.0.0.1:" + "8080 mid-update. Set true ONLY after cutting over to the " + "container (stop+disable the systemd unit, `switchroom webd " + "install`). The container runs in its own compose project " + "(`switchroom-web`), separate from the agent fleet, with " + "network_mode: host so it keeps owning loopback:8080 for the " + "cloudflared tunnel + tailscale serve consumers.")
14146
+ });
14144
14147
  var HostdConfigSchema = exports_external.object({
14145
14148
  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."),
14146
14149
  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.")
@@ -14174,6 +14177,7 @@ var SwitchroomConfigSchema = exports_external.object({
14174
14177
  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."),
14175
14178
  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)."),
14176
14179
  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)."),
14180
+ web_service: WebServiceConfigSchema.default({}).describe("Web-service container (dashboard + GitHub-webhook receiver) config. " + "Defaults to managed=false so existing systemd-mode installs are " + "untouched. Set managed: true after cutting over to the " + "`switchroom-web` container — then `switchroom update` keeps it " + "refreshed. See `switchroom webd install`."),
14177
14181
  google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
14178
14182
  message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
14179
14183
  }).transform((v) => v.trim().toLowerCase()), exports_external.object({
@@ -20188,6 +20192,7 @@ import { mkdtempSync, writeFileSync as writeFileSync2, rmSync, existsSync as exi
20188
20192
  import { tmpdir } from "node:os";
20189
20193
  import { join as join2, isAbsolute as isAbsolute2, normalize } from "node:path";
20190
20194
  import { spawnSync } from "node:child_process";
20195
+ import { isDeepStrictEqual } from "node:util";
20191
20196
  var MAX_PATCH_BYTES = 1024 * 1024;
20192
20197
  var UNLOCK_CARD_YAML_ALLOWLIST = new Set([
20193
20198
  "hostd.config_edit_enabled"
@@ -20524,6 +20529,68 @@ function validateConfigEdit(opts) {
20524
20529
  return leakErr;
20525
20530
  return { ok: true, postApplyContent: applied.after };
20526
20531
  }
20532
+ function assertSelfScopedAllowEdit(beforeContent, afterContent, caller) {
20533
+ let before;
20534
+ let after;
20535
+ try {
20536
+ before = toObject($parseDocument(beforeContent, { merge: false, strict: false }).toJS());
20537
+ after = toObject($parseDocument(afterContent, { merge: false, strict: false }).toJS());
20538
+ } catch (e) {
20539
+ return { ok: false, detail: `self-scope: config did not parse (${e.message})` };
20540
+ }
20541
+ const beforeAllow = readCallerAllow(before, caller);
20542
+ const afterAllow = readCallerAllow(after, caller);
20543
+ for (const rule of beforeAllow) {
20544
+ if (!afterAllow.includes(rule)) {
20545
+ return {
20546
+ ok: false,
20547
+ detail: `self-scope: edit removes existing allow rule "${rule}" from agents.${caller}.tools.allow`
20548
+ };
20549
+ }
20550
+ }
20551
+ const beforeStripped = stripCallerAllow(before, caller);
20552
+ const afterStripped = stripCallerAllow(after, caller);
20553
+ if (!isDeepStrictEqual(beforeStripped, afterStripped)) {
20554
+ return {
20555
+ ok: false,
20556
+ detail: `self-scope: edit changes config outside agents.${caller}.tools.allow ` + `(a non-admin agent may only widen its own allow-list)`
20557
+ };
20558
+ }
20559
+ return { ok: true };
20560
+ }
20561
+ function toObject(v) {
20562
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
20563
+ }
20564
+ function readCallerAllow(cfg, caller) {
20565
+ const agents = cfg.agents;
20566
+ if (!agents || typeof agents !== "object")
20567
+ return [];
20568
+ const agent = agents[caller];
20569
+ if (!agent || typeof agent !== "object")
20570
+ return [];
20571
+ const tools = agent.tools;
20572
+ if (!tools || typeof tools !== "object")
20573
+ return [];
20574
+ const allow = tools.allow;
20575
+ return Array.isArray(allow) ? allow.filter((x) => typeof x === "string") : [];
20576
+ }
20577
+ function stripCallerAllow(cfg, caller) {
20578
+ const clone = structuredClone(cfg);
20579
+ const agents = clone.agents;
20580
+ if (!agents || typeof agents !== "object")
20581
+ return clone;
20582
+ const agent = agents[caller];
20583
+ if (!agent || typeof agent !== "object")
20584
+ return clone;
20585
+ const tools = agent.tools;
20586
+ if (!tools || typeof tools !== "object")
20587
+ return clone;
20588
+ delete tools.allow;
20589
+ if (Object.keys(tools).length === 0) {
20590
+ delete agent.tools;
20591
+ }
20592
+ return clone;
20593
+ }
20527
20594
 
20528
20595
  // src/host-control/server.ts
20529
20596
  function resolveDigests(imageRefs) {
@@ -20873,7 +20940,7 @@ class HostdServer {
20873
20940
  case "doctor":
20874
20941
  return callerAdmin ? null : `doctor requires admin: true on caller "${caller.name}"`;
20875
20942
  case "config_propose_edit":
20876
- return callerAdmin ? null : `config_propose_edit requires admin: true on caller "${caller.name}"`;
20943
+ return null;
20877
20944
  }
20878
20945
  }
20879
20946
  async handleAgentRestart(req, caller, started) {
@@ -21134,6 +21201,18 @@ class HostdServer {
21134
21201
  if (!verdict.ok) {
21135
21202
  return err(verdict.code, verdict.detail).fixBadInput("unified_diff").op("config_propose_edit").caller(caller.kind === "agent" ? "agent" : "operator").agentName(caller.kind === "agent" ? caller.name : undefined).build(req.request_id, Date.now() - started);
21136
21203
  }
21204
+ if (caller.kind === "agent" && this.opts.config.agents[caller.name]?.admin !== true) {
21205
+ let beforeContent;
21206
+ try {
21207
+ beforeContent = readFileSync5(configPath, "utf-8");
21208
+ } catch {
21209
+ beforeContent = "";
21210
+ }
21211
+ const scope = assertSelfScopedAllowEdit(beforeContent, verdict.postApplyContent, caller.name);
21212
+ if (!scope.ok) {
21213
+ return err("E_NOT_SELF_SCOPED", scope.detail).why("non-admin agents may only add rules to their own " + "agents.<self>.tools.allow via config_propose_edit").fixBadInput("unified_diff").op("config_propose_edit").caller("agent").agentName(caller.name).asDenied().build(req.request_id, Date.now() - started);
21214
+ }
21215
+ }
21137
21216
  if (!this.opts.approvalGateway) {
21138
21217
  return err("E_NO_APPROVAL_GATEWAY", "validation passed but hostd was started without an approval-gateway wiring; the operator build is missing the telegram-plugin link").fixOperatorAction("infra", [
21139
21218
  "ensure hostd was launched with --approval-gateway / telegram-plugin link"
@@ -21922,13 +22001,12 @@ async function main() {
21922
22001
  process.exit(2);
21923
22002
  }
21924
22003
  const agentUids = {};
21925
- for (const [name, agent] of Object.entries(config.agents)) {
21926
- if (agent.admin === true) {
21927
- agentUids[name] = allocateAgentUid(name);
21928
- }
22004
+ for (const name of Object.keys(config.agents)) {
22005
+ agentUids[name] = allocateAgentUid(name);
21929
22006
  }
21930
22007
  if (Object.keys(agentUids).length === 0) {
21931
- process.stderr.write("hostd: no admin-flagged agents — nothing to serve. Set `admin: true` on at least one agent.\n");
22008
+ process.stderr.write(`hostd: no agents configured — nothing to serve.
22009
+ `);
21932
22010
  process.exit(2);
21933
22011
  }
21934
22012
  const agentsDir = process.env.SWITCHROOM_AGENTS_DIR ?? join4(homedir3(), ".switchroom", "agents");
@@ -11271,7 +11271,7 @@ var init_dist = __esm(() => {
11271
11271
  });
11272
11272
 
11273
11273
  // src/config/schema.ts
11274
- var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
11274
+ var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
11275
11275
  var init_schema = __esm(() => {
11276
11276
  init_zod();
11277
11277
  CodeRepoEntrySchema = exports_external.object({
@@ -11721,6 +11721,9 @@ var init_schema = __esm(() => {
11721
11721
  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)."),
11722
11722
  auto_release_check: AutoReleaseCheckSchema.default({}).describe("Pull-based release-triggered fleet restart (#1743). hostd polls " + "the remote release tag on a fixed interval and applies + " + "restarts the fleet (graceful) when a new release is detected. " + "Opt-in: default enabled=false.")
11723
11723
  });
11724
+ WebServiceConfigSchema = exports_external.object({
11725
+ managed: exports_external.boolean().default(false).describe("Whether `switchroom update` refreshes the web-service container " + "(dashboard + GitHub-webhook receiver) via `switchroom webd " + "install`. Default: false — existing installs run the web server " + "as the legacy `switchroom-web.service` systemd unit and must not " + "be surprised by a container takeover of host loopback 127.0.0.1:" + "8080 mid-update. Set true ONLY after cutting over to the " + "container (stop+disable the systemd unit, `switchroom webd " + "install`). The container runs in its own compose project " + "(`switchroom-web`), separate from the agent fleet, with " + "network_mode: host so it keeps owning loopback:8080 for the " + "cloudflared tunnel + tailscale serve consumers.")
11726
+ });
11724
11727
  HostdConfigSchema = exports_external.object({
11725
11728
  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."),
11726
11729
  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.")
@@ -11754,6 +11757,7 @@ var init_schema = __esm(() => {
11754
11757
  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."),
11755
11758
  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)."),
11756
11759
  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)."),
11760
+ web_service: WebServiceConfigSchema.default({}).describe("Web-service container (dashboard + GitHub-webhook receiver) config. " + "Defaults to managed=false so existing systemd-mode installs are " + "untouched. Set managed: true after cutting over to the " + "`switchroom-web` container — then `switchroom update` keeps it " + "refreshed. See `switchroom webd install`."),
11757
11761
  google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
11758
11762
  message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
11759
11763
  }).transform((v) => v.trim().toLowerCase()), exports_external.object({