switchroom 0.15.21 → 0.15.23
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.
- package/dist/agent-scheduler/index.js +5 -5
- package/dist/auth-broker/index.js +5 -5
- package/dist/cli/notion-write-pretool.mjs +5 -5
- package/dist/cli/switchroom.js +20 -15
- package/dist/host-control/main.js +93 -7
- package/dist/vault/approvals/kernel-server.js +5 -5
- package/dist/vault/broker/server.js +5 -5
- package/package.json +2 -2
- package/profiles/default/CLAUDE.md.hbs +6 -2
- package/telegram-plugin/dist/gateway/gateway.js +39 -11
- package/telegram-plugin/gateway/config-approval-handler.test.ts +24 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +28 -1
- package/telegram-plugin/gateway/ipc-protocol.ts +8 -0
- package/telegram-plugin/gateway/ipc-server.ts +10 -0
- package/telegram-plugin/tests/ipc-validator.test.ts +28 -0
- package/telegram-plugin/uat/scenarios/jtbd-effort-command-dm.test.ts +90 -0
- package/telegram-plugin/uat/scenarios/jtbd-grant-resume-telegram-id-dm.test.ts +97 -0
- package/telegram-plugin/uat/scenarios/jtbd-model-command-dm.test.ts +7 -4
- package/telegram-plugin/uat/scenarios/jtbd-model-tap-dm.test.ts +71 -0
- package/telegram-plugin/uat/scenarios/jtbd-whoami-dm.test.ts +40 -0
|
@@ -11001,11 +11001,11 @@ var ActionSpecSchema = exports_external.discriminatedUnion("type", [
|
|
|
11001
11001
|
var ScheduleEntrySchema = exports_external.object({
|
|
11002
11002
|
cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
|
|
11003
11003
|
prompt: exports_external.string().optional().describe("Prompt to send at the scheduled time (the escalation prompt when " + "kind=poll; templated with {{diff}}). Required for kind prompt/poll; " + "absent for kind=action (an action has no model fire, so no prompt)."),
|
|
11004
|
-
kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates — zero " + "tokens, no session. poll/prompt
|
|
11004
|
+
kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates — zero " + "tokens, no session. poll/prompt tiering is on by default " + "(SWITCHROOM_CHEAP_CRON=0 is the kill-switch); an action is model-free " + "regardless (the kill-switch governs model tiering, not deterministic " + "actions)."),
|
|
11005
11005
|
poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
|
|
11006
11006
|
action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
|
|
11007
11007
|
model: exports_external.string().optional().describe("Cron model hint. Reactivated by SWITCHROOM_CHEAP_CRON (was DEPRECATED/" + "IGNORED in v0.8). A known-cheap id (sonnet/haiku family) routes the " + "fire to a fresh cheap cron session (Tier 1, `context: fresh`); 'opus', " + "a custom id, or unset routes to the agent's live session (Tier 2, " + "`context: agent`) — the conservative default that preserves pre-v0.8 " + "behaviour. Note: a live session's model is fixed at launch, so on Tier " + "2 this is informational. See docs/scheduling.md."),
|
|
11008
|
-
context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' → a minimal-" + "context cheap cron session (Tier 1). 'agent' → the agent's live " + "session with full persona/memory (Tier 2). Unset → inferred from " + "`model` (cheap→fresh, else agent).
|
|
11008
|
+
context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' → a minimal-" + "context cheap cron session (Tier 1). 'agent' → the agent's live " + "session with full persona/memory (Tier 2). Unset → inferred from " + "`model` (cheap→fresh, else agent). On by default; " + "SWITCHROOM_CHEAP_CRON=0 is the kill-switch."),
|
|
11009
11009
|
secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default — broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary — " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing."),
|
|
11010
11010
|
topic: exports_external.union([
|
|
11011
11011
|
exports_external.string().min(1, "topic alias must be non-empty"),
|
|
@@ -11517,8 +11517,8 @@ var WebServiceConfigSchema = exports_external.object({
|
|
|
11517
11517
|
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.")
|
|
11518
11518
|
});
|
|
11519
11519
|
var HostdConfigSchema = exports_external.object({
|
|
11520
|
-
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
|
|
11521
|
-
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.
|
|
11520
|
+
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, 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."),
|
|
11521
|
+
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. Configurable now, but the rate limiter is not yet enforced " + "(no `E_RATE_LIMITED` is currently raised); the field is reserved so " + "operators can pin the cap ahead of the limiter going live.")
|
|
11522
11522
|
});
|
|
11523
11523
|
var CronEgressSchema = exports_external.object({
|
|
11524
11524
|
allowed_hosts: exports_external.array(exports_external.string().min(1)).default([]).describe("Hosts a poll may reach (exact, https-only). loopback/private/IP-literal are always rejected."),
|
|
@@ -11555,7 +11555,7 @@ var SwitchroomConfigSchema = exports_external.object({
|
|
|
11555
11555
|
notion_workspace: NotionWorkspaceConfigSchema.describe("RFC docs/rfcs/notion-integration.md. Top-level Notion integration " + "config — vault key for the integration token, friendly-name → " + "database UUID map, optional MCP-package version pin, and optional " + "global rate-limit override (default 3 rps, Notion's documented " + "public-API limit). Block is optional; when omitted no agent gets a " + "Notion MCP entry regardless of per-agent config."),
|
|
11556
11556
|
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."),
|
|
11557
11557
|
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)."),
|
|
11558
|
-
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.
|
|
11558
|
+
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. Scopes the opt-in flag and rate cap for the " + "`config_propose_edit` verb (disabled by default)."),
|
|
11559
11559
|
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`."),
|
|
11560
11560
|
google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
|
|
11561
11561
|
message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
|
|
@@ -11001,11 +11001,11 @@ var ActionSpecSchema = exports_external.discriminatedUnion("type", [
|
|
|
11001
11001
|
var ScheduleEntrySchema = exports_external.object({
|
|
11002
11002
|
cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
|
|
11003
11003
|
prompt: exports_external.string().optional().describe("Prompt to send at the scheduled time (the escalation prompt when " + "kind=poll; templated with {{diff}}). Required for kind prompt/poll; " + "absent for kind=action (an action has no model fire, so no prompt)."),
|
|
11004
|
-
kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates — zero " + "tokens, no session. poll/prompt
|
|
11004
|
+
kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates — zero " + "tokens, no session. poll/prompt tiering is on by default " + "(SWITCHROOM_CHEAP_CRON=0 is the kill-switch); an action is model-free " + "regardless (the kill-switch governs model tiering, not deterministic " + "actions)."),
|
|
11005
11005
|
poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
|
|
11006
11006
|
action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
|
|
11007
11007
|
model: exports_external.string().optional().describe("Cron model hint. Reactivated by SWITCHROOM_CHEAP_CRON (was DEPRECATED/" + "IGNORED in v0.8). A known-cheap id (sonnet/haiku family) routes the " + "fire to a fresh cheap cron session (Tier 1, `context: fresh`); 'opus', " + "a custom id, or unset routes to the agent's live session (Tier 2, " + "`context: agent`) — the conservative default that preserves pre-v0.8 " + "behaviour. Note: a live session's model is fixed at launch, so on Tier " + "2 this is informational. See docs/scheduling.md."),
|
|
11008
|
-
context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' → a minimal-" + "context cheap cron session (Tier 1). 'agent' → the agent's live " + "session with full persona/memory (Tier 2). Unset → inferred from " + "`model` (cheap→fresh, else agent).
|
|
11008
|
+
context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' → a minimal-" + "context cheap cron session (Tier 1). 'agent' → the agent's live " + "session with full persona/memory (Tier 2). Unset → inferred from " + "`model` (cheap→fresh, else agent). On by default; " + "SWITCHROOM_CHEAP_CRON=0 is the kill-switch."),
|
|
11009
11009
|
secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default — broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary — " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing."),
|
|
11010
11010
|
topic: exports_external.union([
|
|
11011
11011
|
exports_external.string().min(1, "topic alias must be non-empty"),
|
|
@@ -11517,8 +11517,8 @@ var WebServiceConfigSchema = exports_external.object({
|
|
|
11517
11517
|
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.")
|
|
11518
11518
|
});
|
|
11519
11519
|
var HostdConfigSchema = exports_external.object({
|
|
11520
|
-
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
|
|
11521
|
-
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.
|
|
11520
|
+
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, 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."),
|
|
11521
|
+
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. Configurable now, but the rate limiter is not yet enforced " + "(no `E_RATE_LIMITED` is currently raised); the field is reserved so " + "operators can pin the cap ahead of the limiter going live.")
|
|
11522
11522
|
});
|
|
11523
11523
|
var CronEgressSchema = exports_external.object({
|
|
11524
11524
|
allowed_hosts: exports_external.array(exports_external.string().min(1)).default([]).describe("Hosts a poll may reach (exact, https-only). loopback/private/IP-literal are always rejected."),
|
|
@@ -11555,7 +11555,7 @@ var SwitchroomConfigSchema = exports_external.object({
|
|
|
11555
11555
|
notion_workspace: NotionWorkspaceConfigSchema.describe("RFC docs/rfcs/notion-integration.md. Top-level Notion integration " + "config — vault key for the integration token, friendly-name → " + "database UUID map, optional MCP-package version pin, and optional " + "global rate-limit override (default 3 rps, Notion's documented " + "public-API limit). Block is optional; when omitted no agent gets a " + "Notion MCP entry regardless of per-agent config."),
|
|
11556
11556
|
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."),
|
|
11557
11557
|
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)."),
|
|
11558
|
-
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.
|
|
11558
|
+
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. Scopes the opt-in flag and rate cap for the " + "`config_propose_edit` verb (disabled by default)."),
|
|
11559
11559
|
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`."),
|
|
11560
11560
|
google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
|
|
11561
11561
|
message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
|
|
@@ -11749,11 +11749,11 @@ var ActionSpecSchema = exports_external.discriminatedUnion("type", [
|
|
|
11749
11749
|
var ScheduleEntrySchema = exports_external.object({
|
|
11750
11750
|
cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
|
|
11751
11751
|
prompt: exports_external.string().optional().describe("Prompt to send at the scheduled time (the escalation prompt when " + "kind=poll; templated with {{diff}}). Required for kind prompt/poll; " + "absent for kind=action (an action has no model fire, so no prompt)."),
|
|
11752
|
-
kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates \u2014 zero " + "tokens, no session. poll/prompt
|
|
11752
|
+
kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates \u2014 zero " + "tokens, no session. poll/prompt tiering is on by default " + "(SWITCHROOM_CHEAP_CRON=0 is the kill-switch); an action is model-free " + "regardless (the kill-switch governs model tiering, not deterministic " + "actions)."),
|
|
11753
11753
|
poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
|
|
11754
11754
|
action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
|
|
11755
11755
|
model: exports_external.string().optional().describe("Cron model hint. Reactivated by SWITCHROOM_CHEAP_CRON (was DEPRECATED/" + "IGNORED in v0.8). A known-cheap id (sonnet/haiku family) routes the " + "fire to a fresh cheap cron session (Tier 1, `context: fresh`); 'opus', " + "a custom id, or unset routes to the agent's live session (Tier 2, " + "`context: agent`) \u2014 the conservative default that preserves pre-v0.8 " + "behaviour. Note: a live session's model is fixed at launch, so on Tier " + "2 this is informational. See docs/scheduling.md."),
|
|
11756
|
-
context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' \u2192 a minimal-" + "context cheap cron session (Tier 1). 'agent' \u2192 the agent's live " + "session with full persona/memory (Tier 2). Unset \u2192 inferred from " + "`model` (cheap\u2192fresh, else agent).
|
|
11756
|
+
context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' \u2192 a minimal-" + "context cheap cron session (Tier 1). 'agent' \u2192 the agent's live " + "session with full persona/memory (Tier 2). Unset \u2192 inferred from " + "`model` (cheap\u2192fresh, else agent). On by default; " + "SWITCHROOM_CHEAP_CRON=0 is the kill-switch."),
|
|
11757
11757
|
secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default \u2014 broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary \u2014 " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing."),
|
|
11758
11758
|
topic: exports_external.union([
|
|
11759
11759
|
exports_external.string().min(1, "topic alias must be non-empty"),
|
|
@@ -12265,8 +12265,8 @@ var WebServiceConfigSchema = exports_external.object({
|
|
|
12265
12265
|
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.")
|
|
12266
12266
|
});
|
|
12267
12267
|
var HostdConfigSchema = exports_external.object({
|
|
12268
|
-
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
|
|
12269
|
-
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.
|
|
12268
|
+
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, 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."),
|
|
12269
|
+
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. Configurable now, but the rate limiter is not yet enforced " + "(no `E_RATE_LIMITED` is currently raised); the field is reserved so " + "operators can pin the cap ahead of the limiter going live.")
|
|
12270
12270
|
});
|
|
12271
12271
|
var CronEgressSchema = exports_external.object({
|
|
12272
12272
|
allowed_hosts: exports_external.array(exports_external.string().min(1)).default([]).describe("Hosts a poll may reach (exact, https-only). loopback/private/IP-literal are always rejected."),
|
|
@@ -12303,7 +12303,7 @@ var SwitchroomConfigSchema = exports_external.object({
|
|
|
12303
12303
|
notion_workspace: NotionWorkspaceConfigSchema.describe("RFC docs/rfcs/notion-integration.md. Top-level Notion integration " + "config \u2014 vault key for the integration token, friendly-name \u2192 " + "database UUID map, optional MCP-package version pin, and optional " + "global rate-limit override (default 3 rps, Notion's documented " + "public-API limit). Block is optional; when omitted no agent gets a " + "Notion MCP entry regardless of per-agent config."),
|
|
12304
12304
|
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."),
|
|
12305
12305
|
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)."),
|
|
12306
|
-
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.
|
|
12306
|
+
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. Scopes the opt-in flag and rate cap for the " + "`config_propose_edit` verb (disabled by default)."),
|
|
12307
12307
|
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`."),
|
|
12308
12308
|
google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
|
|
12309
12309
|
message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
|
package/dist/cli/switchroom.js
CHANGED
|
@@ -13565,11 +13565,11 @@ var init_schema = __esm(() => {
|
|
|
13565
13565
|
ScheduleEntrySchema = exports_external.object({
|
|
13566
13566
|
cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
|
|
13567
13567
|
prompt: exports_external.string().optional().describe("Prompt to send at the scheduled time (the escalation prompt when " + "kind=poll; templated with {{diff}}). Required for kind prompt/poll; " + "absent for kind=action (an action has no model fire, so no prompt)."),
|
|
13568
|
-
kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates \u2014 zero " + "tokens, no session. poll/prompt
|
|
13568
|
+
kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates \u2014 zero " + "tokens, no session. poll/prompt tiering is on by default " + "(SWITCHROOM_CHEAP_CRON=0 is the kill-switch); an action is model-free " + "regardless (the kill-switch governs model tiering, not deterministic " + "actions)."),
|
|
13569
13569
|
poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
|
|
13570
13570
|
action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
|
|
13571
13571
|
model: exports_external.string().optional().describe("Cron model hint. Reactivated by SWITCHROOM_CHEAP_CRON (was DEPRECATED/" + "IGNORED in v0.8). A known-cheap id (sonnet/haiku family) routes the " + "fire to a fresh cheap cron session (Tier 1, `context: fresh`); 'opus', " + "a custom id, or unset routes to the agent's live session (Tier 2, " + "`context: agent`) \u2014 the conservative default that preserves pre-v0.8 " + "behaviour. Note: a live session's model is fixed at launch, so on Tier " + "2 this is informational. See docs/scheduling.md."),
|
|
13572
|
-
context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' \u2192 a minimal-" + "context cheap cron session (Tier 1). 'agent' \u2192 the agent's live " + "session with full persona/memory (Tier 2). Unset \u2192 inferred from " + "`model` (cheap\u2192fresh, else agent).
|
|
13572
|
+
context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' \u2192 a minimal-" + "context cheap cron session (Tier 1). 'agent' \u2192 the agent's live " + "session with full persona/memory (Tier 2). Unset \u2192 inferred from " + "`model` (cheap\u2192fresh, else agent). On by default; " + "SWITCHROOM_CHEAP_CRON=0 is the kill-switch."),
|
|
13573
13573
|
secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default \u2014 broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary \u2014 " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing."),
|
|
13574
13574
|
topic: exports_external.union([
|
|
13575
13575
|
exports_external.string().min(1, "topic alias must be non-empty"),
|
|
@@ -14081,8 +14081,8 @@ var init_schema = __esm(() => {
|
|
|
14081
14081
|
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.")
|
|
14082
14082
|
});
|
|
14083
14083
|
HostdConfigSchema = exports_external.object({
|
|
14084
|
-
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
|
|
14085
|
-
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.
|
|
14084
|
+
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, 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."),
|
|
14085
|
+
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. Configurable now, but the rate limiter is not yet enforced " + "(no `E_RATE_LIMITED` is currently raised); the field is reserved so " + "operators can pin the cap ahead of the limiter going live.")
|
|
14086
14086
|
});
|
|
14087
14087
|
CronEgressSchema = exports_external.object({
|
|
14088
14088
|
allowed_hosts: exports_external.array(exports_external.string().min(1)).default([]).describe("Hosts a poll may reach (exact, https-only). loopback/private/IP-literal are always rejected."),
|
|
@@ -14119,7 +14119,7 @@ var init_schema = __esm(() => {
|
|
|
14119
14119
|
notion_workspace: NotionWorkspaceConfigSchema.describe("RFC docs/rfcs/notion-integration.md. Top-level Notion integration " + "config \u2014 vault key for the integration token, friendly-name \u2192 " + "database UUID map, optional MCP-package version pin, and optional " + "global rate-limit override (default 3 rps, Notion's documented " + "public-API limit). Block is optional; when omitted no agent gets a " + "Notion MCP entry regardless of per-agent config."),
|
|
14120
14120
|
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."),
|
|
14121
14121
|
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)."),
|
|
14122
|
-
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.
|
|
14122
|
+
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. Scopes the opt-in flag and rate cap for the " + "`config_propose_edit` verb (disabled by default)."),
|
|
14123
14123
|
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`."),
|
|
14124
14124
|
google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
|
|
14125
14125
|
message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
|
|
@@ -50424,7 +50424,7 @@ var init_server4 = __esm(() => {
|
|
|
50424
50424
|
},
|
|
50425
50425
|
{
|
|
50426
50426
|
name: "config_propose_edit",
|
|
50427
|
-
description: "Propose a unified-diff patch against /state/config/switchroom.yaml " + "
|
|
50427
|
+
description: "Propose a unified-diff patch against /state/config/switchroom.yaml. " + "The host validates the patch (applies cleanly + post-patch yaml parses " + "against the config schema + no secret leak), raises a Telegram approval " + "card in the OPERATOR's primary chat (NOT yours \u2014 the requesting agent's " + "chat is not the approval surface), and on Allow applies it in place and " + "reconciles (rolling back if reconcile fails); returns " + `result:"completed" on success. Use this \u2014 behind the operator's tap \u2014 ` + "to amend config instead of asking the operator to hand-edit the yaml. " + "Admin agents may propose ANY field; non-admin agents are confined to " + "their own agents.<self>.tools.allow. Requires " + "hostd.config_edit_enabled=true (operator opt-in; default off) \u2014 returns " + "E_CONFIG_EDIT_DISABLED otherwise. An applied edit is not live in the " + "running agent until it restarts (the approval card names which agents " + "to bounce).",
|
|
50428
50428
|
inputSchema: {
|
|
50429
50429
|
type: "object",
|
|
50430
50430
|
required: ["unified_diff", "reason", "target_path"],
|
|
@@ -50432,7 +50432,7 @@ var init_server4 = __esm(() => {
|
|
|
50432
50432
|
unified_diff: {
|
|
50433
50433
|
type: "string",
|
|
50434
50434
|
minLength: 1,
|
|
50435
|
-
description: "Unified diff against switchroom.yaml.
|
|
50435
|
+
description: "Unified diff against switchroom.yaml. Any context level (a " + "zero-context diff is fine); single-file, no path-traversal. " + "LF-only, \u22641 MB."
|
|
50436
50436
|
},
|
|
50437
50437
|
reason: {
|
|
50438
50438
|
type: "string",
|
|
@@ -50477,8 +50477,8 @@ var {
|
|
|
50477
50477
|
} = import__.default;
|
|
50478
50478
|
|
|
50479
50479
|
// src/build-info.ts
|
|
50480
|
-
var VERSION = "0.15.
|
|
50481
|
-
var COMMIT_SHA = "
|
|
50480
|
+
var VERSION = "0.15.23";
|
|
50481
|
+
var COMMIT_SHA = "4c70a87a";
|
|
50482
50482
|
|
|
50483
50483
|
// src/cli/agent.ts
|
|
50484
50484
|
init_source();
|
|
@@ -76387,9 +76387,8 @@ async function stepGoogleWorkspace(config, nonInteractive) {
|
|
|
76387
76387
|
console.log(source_default.gray(` Step 2 \u2014 enable the account on ${source_default.bold(firstName)}:`));
|
|
76388
76388
|
console.log(source_default.cyan(` ${enableCmd}`));
|
|
76389
76389
|
console.log();
|
|
76390
|
-
console.log(source_default.gray(` (\`account add\`
|
|
76391
|
-
console.log(source_default.gray(`
|
|
76392
|
-
console.log(source_default.gray(` ${fallbackCmd})`));
|
|
76390
|
+
console.log(source_default.gray(` (\`account add\` runs the OAuth flow in your browser. Older`));
|
|
76391
|
+
console.log(source_default.gray(` single-agent alternative: ${fallbackCmd})`));
|
|
76393
76392
|
} else {
|
|
76394
76393
|
console.log(source_default.gray(` ${STEP_DONE} Skipped \u2014 connect later with:`));
|
|
76395
76394
|
console.log(source_default.cyan(` ${accountAddCmd}`));
|
|
@@ -84003,7 +84002,7 @@ function scheduleRemove(opts) {
|
|
|
84003
84002
|
}
|
|
84004
84003
|
function registerAgentConfigWriteCommands(program3) {
|
|
84005
84004
|
const schedule = program3.command("schedule").description("Add / remove an agent's scheduled cron entries (overlay-backed)");
|
|
84006
|
-
schedule.command("add").description("Append a schedule entry to the agent's overlay dir").requiredOption("--cron <expr>", "Cron expression").requiredOption("--prompt <text>", "Prompt to fire at the scheduled time").option("--agent <name>", "Target agent (defaults to $SWITCHROOM_AGENT_NAME)").option("--secrets <list>", "Comma-separated vault keys (REJECTED for agent-authored overlays)").option("--name <slug>", "Optional human-readable name (a-z 0-9 -)").option("--model <id>", "Cheap-cron tier hint: a known-cheap model (sonnet/haiku) routes this fire to a fresh, minimal-context cron session (Tier 1) instead of the agent's full live session (Tier 2), cutting token cost.
|
|
84005
|
+
schedule.command("add").description("Append a schedule entry to the agent's overlay dir").requiredOption("--cron <expr>", "Cron expression").requiredOption("--prompt <text>", "Prompt to fire at the scheduled time").option("--agent <name>", "Target agent (defaults to $SWITCHROOM_AGENT_NAME)").option("--secrets <list>", "Comma-separated vault keys (REJECTED for agent-authored overlays)").option("--name <slug>", "Optional human-readable name (a-z 0-9 -)").option("--model <id>", "Cheap-cron tier hint: a known-cheap model (sonnet/haiku) routes this fire to a fresh, minimal-context cron session (Tier 1) instead of the agent's full live session (Tier 2), cutting token cost. Active by default; SWITCHROOM_CHEAP_CRON=0 is the kill-switch.").option("--context <mode>", "Tier hint: 'fresh' (minimal-context cheap session) or 'agent' (full live session). Unset \u2192 inferred from --model.").option("--stage-on-reject", "When a security gate trips (secrets/quota/min-interval), stage the entry under .pending/ for operator approval instead of rejecting with exit 9. Used by the MCP path; operator CLI defaults to off.").action(async (opts) => {
|
|
84007
84006
|
if (opts.context && opts.context !== "fresh" && opts.context !== "agent") {
|
|
84008
84007
|
emitError("E_INVALID_PROMPT", "--context must be 'fresh' or 'agent'");
|
|
84009
84008
|
process.exit(1);
|
|
@@ -85969,8 +85968,14 @@ services:
|
|
|
85969
85968
|
# resolve there. Mounting the file directly forces docker to
|
|
85970
85969
|
# follow the symlink at mount time and bind the underlying file
|
|
85971
85970
|
# to the container path. Mirrors how the agent containers expose
|
|
85972
|
-
# the config (also at /state/config/switchroom.yaml)
|
|
85973
|
-
|
|
85971
|
+
# the config (also at /state/config/switchroom.yaml) \u2014 but RW here,
|
|
85972
|
+
# not ro: hostd is the SANCTIONED config writer (config_propose_edit
|
|
85973
|
+
# applies an operator-approved diff in place via O_RDWR). With :ro the
|
|
85974
|
+
# write fails EROFS and every config_propose_edit rolls back
|
|
85975
|
+
# (E_RECONCILE_FAILED_ROLLED_BACK) \u2014 agents could never amend the yaml.
|
|
85976
|
+
# Agents themselves still mount it ro; only hostd, the tap-gated writer,
|
|
85977
|
+
# gets rw. The in-place writer preserves the file's owner/mode.
|
|
85978
|
+
- ${hostHome}/.switchroom/switchroom.yaml:/state/config/switchroom.yaml:rw
|
|
85974
85979
|
# docker.sock is the whole reason hostd exists \u2014 agents shouldn't
|
|
85975
85980
|
# have it, but hostd (auditing every shell-out via run-hook.sh's
|
|
85976
85981
|
# pattern) is the controlled chokepoint.
|
|
@@ -13736,11 +13736,11 @@ var ActionSpecSchema = exports_external.discriminatedUnion("type", [
|
|
|
13736
13736
|
var ScheduleEntrySchema = exports_external.object({
|
|
13737
13737
|
cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
|
|
13738
13738
|
prompt: exports_external.string().optional().describe("Prompt to send at the scheduled time (the escalation prompt when " + "kind=poll; templated with {{diff}}). Required for kind prompt/poll; " + "absent for kind=action (an action has no model fire, so no prompt)."),
|
|
13739
|
-
kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates — zero " + "tokens, no session. poll/prompt
|
|
13739
|
+
kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates — zero " + "tokens, no session. poll/prompt tiering is on by default " + "(SWITCHROOM_CHEAP_CRON=0 is the kill-switch); an action is model-free " + "regardless (the kill-switch governs model tiering, not deterministic " + "actions)."),
|
|
13740
13740
|
poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
|
|
13741
13741
|
action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
|
|
13742
13742
|
model: exports_external.string().optional().describe("Cron model hint. Reactivated by SWITCHROOM_CHEAP_CRON (was DEPRECATED/" + "IGNORED in v0.8). A known-cheap id (sonnet/haiku family) routes the " + "fire to a fresh cheap cron session (Tier 1, `context: fresh`); 'opus', " + "a custom id, or unset routes to the agent's live session (Tier 2, " + "`context: agent`) — the conservative default that preserves pre-v0.8 " + "behaviour. Note: a live session's model is fixed at launch, so on Tier " + "2 this is informational. See docs/scheduling.md."),
|
|
13743
|
-
context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' → a minimal-" + "context cheap cron session (Tier 1). 'agent' → the agent's live " + "session with full persona/memory (Tier 2). Unset → inferred from " + "`model` (cheap→fresh, else agent).
|
|
13743
|
+
context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' → a minimal-" + "context cheap cron session (Tier 1). 'agent' → the agent's live " + "session with full persona/memory (Tier 2). Unset → inferred from " + "`model` (cheap→fresh, else agent). On by default; " + "SWITCHROOM_CHEAP_CRON=0 is the kill-switch."),
|
|
13744
13744
|
secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default — broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary — " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing."),
|
|
13745
13745
|
topic: exports_external.union([
|
|
13746
13746
|
exports_external.string().min(1, "topic alias must be non-empty"),
|
|
@@ -14252,8 +14252,8 @@ var WebServiceConfigSchema = exports_external.object({
|
|
|
14252
14252
|
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.")
|
|
14253
14253
|
});
|
|
14254
14254
|
var HostdConfigSchema = exports_external.object({
|
|
14255
|
-
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
|
|
14256
|
-
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.
|
|
14255
|
+
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, 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."),
|
|
14256
|
+
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. Configurable now, but the rate limiter is not yet enforced " + "(no `E_RATE_LIMITED` is currently raised); the field is reserved so " + "operators can pin the cap ahead of the limiter going live.")
|
|
14257
14257
|
});
|
|
14258
14258
|
var CronEgressSchema = exports_external.object({
|
|
14259
14259
|
allowed_hosts: exports_external.array(exports_external.string().min(1)).default([]).describe("Hosts a poll may reach (exact, https-only). loopback/private/IP-literal are always rejected."),
|
|
@@ -14290,7 +14290,7 @@ var SwitchroomConfigSchema = exports_external.object({
|
|
|
14290
14290
|
notion_workspace: NotionWorkspaceConfigSchema.describe("RFC docs/rfcs/notion-integration.md. Top-level Notion integration " + "config — vault key for the integration token, friendly-name → " + "database UUID map, optional MCP-package version pin, and optional " + "global rate-limit override (default 3 rps, Notion's documented " + "public-API limit). Block is optional; when omitted no agent gets a " + "Notion MCP entry regardless of per-agent config."),
|
|
14291
14291
|
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."),
|
|
14292
14292
|
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)."),
|
|
14293
|
-
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.
|
|
14293
|
+
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. Scopes the opt-in flag and rate cap for the " + "`config_propose_edit` verb (disabled by default)."),
|
|
14294
14294
|
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`."),
|
|
14295
14295
|
google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
|
|
14296
14296
|
message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
|
|
@@ -20817,6 +20817,85 @@ function stripCallerAllow(cfg, caller) {
|
|
|
20817
20817
|
return clone;
|
|
20818
20818
|
}
|
|
20819
20819
|
|
|
20820
|
+
// src/host-control/config-blast-radius.ts
|
|
20821
|
+
function toObject2(v) {
|
|
20822
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
20823
|
+
}
|
|
20824
|
+
function changedConfigPaths(before, after, prefix = "") {
|
|
20825
|
+
if (deepEqual(before, after))
|
|
20826
|
+
return [];
|
|
20827
|
+
const bObj = before && typeof before === "object" && !Array.isArray(before);
|
|
20828
|
+
const aObj = after && typeof after === "object" && !Array.isArray(after);
|
|
20829
|
+
if (bObj && aObj) {
|
|
20830
|
+
const keys = new Set([
|
|
20831
|
+
...Object.keys(before),
|
|
20832
|
+
...Object.keys(after)
|
|
20833
|
+
]);
|
|
20834
|
+
const out = [];
|
|
20835
|
+
for (const k of keys) {
|
|
20836
|
+
out.push(...changedConfigPaths(before[k], after[k], prefix ? `${prefix}.${k}` : k));
|
|
20837
|
+
}
|
|
20838
|
+
return out;
|
|
20839
|
+
}
|
|
20840
|
+
return [prefix || "<root>"];
|
|
20841
|
+
}
|
|
20842
|
+
function deepEqual(a, b) {
|
|
20843
|
+
if (a === b)
|
|
20844
|
+
return true;
|
|
20845
|
+
if (typeof a !== typeof b)
|
|
20846
|
+
return false;
|
|
20847
|
+
if (a && b && typeof a === "object") {
|
|
20848
|
+
if (Array.isArray(a) !== Array.isArray(b))
|
|
20849
|
+
return false;
|
|
20850
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
20851
|
+
if (a.length !== b.length)
|
|
20852
|
+
return false;
|
|
20853
|
+
return a.every((x, i) => deepEqual(x, b[i]));
|
|
20854
|
+
}
|
|
20855
|
+
const ao = a;
|
|
20856
|
+
const bo = b;
|
|
20857
|
+
const keys = new Set([...Object.keys(ao), ...Object.keys(bo)]);
|
|
20858
|
+
for (const k of keys)
|
|
20859
|
+
if (!deepEqual(ao[k], bo[k]))
|
|
20860
|
+
return false;
|
|
20861
|
+
return true;
|
|
20862
|
+
}
|
|
20863
|
+
return false;
|
|
20864
|
+
}
|
|
20865
|
+
function classifyBlastRadius(beforeYaml, afterYaml) {
|
|
20866
|
+
let before;
|
|
20867
|
+
let after;
|
|
20868
|
+
try {
|
|
20869
|
+
before = toObject2($parse(beforeYaml));
|
|
20870
|
+
after = toObject2($parse(afterYaml));
|
|
20871
|
+
} catch {
|
|
20872
|
+
return { agents: [], fleetWide: true, changedPaths: ["<unparseable>"] };
|
|
20873
|
+
}
|
|
20874
|
+
const changedPaths = changedConfigPaths(before, after).sort();
|
|
20875
|
+
if (changedPaths.length === 0) {
|
|
20876
|
+
return { agents: [], fleetWide: false, changedPaths: [] };
|
|
20877
|
+
}
|
|
20878
|
+
const agents = new Set;
|
|
20879
|
+
let fleetWide = false;
|
|
20880
|
+
for (const path2 of changedPaths) {
|
|
20881
|
+
if (path2 === "<root>") {
|
|
20882
|
+
fleetWide = true;
|
|
20883
|
+
continue;
|
|
20884
|
+
}
|
|
20885
|
+
const segs = path2.split(".");
|
|
20886
|
+
if (segs[0] === "agents" && segs.length >= 2) {
|
|
20887
|
+
agents.add(segs[1]);
|
|
20888
|
+
} else {
|
|
20889
|
+
fleetWide = true;
|
|
20890
|
+
}
|
|
20891
|
+
}
|
|
20892
|
+
return {
|
|
20893
|
+
agents: fleetWide ? [] : [...agents].sort(),
|
|
20894
|
+
fleetWide,
|
|
20895
|
+
changedPaths
|
|
20896
|
+
};
|
|
20897
|
+
}
|
|
20898
|
+
|
|
20820
20899
|
// src/host-control/server.ts
|
|
20821
20900
|
function resolveDigests(imageRefs) {
|
|
20822
20901
|
const out = new Map;
|
|
@@ -21509,7 +21588,12 @@ class HostdServer {
|
|
|
21509
21588
|
const runner = this.opts.runReconcile ?? (async () => this.runSwitchroom(["apply"]));
|
|
21510
21589
|
const recRes = await runner({ requestId: approvalId });
|
|
21511
21590
|
if (recRes.exit_code === 0) {
|
|
21512
|
-
|
|
21591
|
+
const blast = classifyBlastRadius(snapshot, postApply);
|
|
21592
|
+
await approval.finalize({
|
|
21593
|
+
outcome: "applied",
|
|
21594
|
+
affectedAgents: blast.agents,
|
|
21595
|
+
fleetWide: blast.fleetWide
|
|
21596
|
+
});
|
|
21513
21597
|
return {
|
|
21514
21598
|
v: 1,
|
|
21515
21599
|
request_id: req.request_id,
|
|
@@ -21893,7 +21977,9 @@ class SocketApprovalGateway {
|
|
|
21893
21977
|
type: "request_config_finalize",
|
|
21894
21978
|
requestId: req.requestId,
|
|
21895
21979
|
outcome: outcome.outcome,
|
|
21896
|
-
...outcome.detail ? { detail: outcome.detail } : {}
|
|
21980
|
+
...outcome.detail ? { detail: outcome.detail } : {},
|
|
21981
|
+
...outcome.affectedAgents ? { affectedAgents: outcome.affectedAgents } : {},
|
|
21982
|
+
...outcome.fleetWide !== undefined ? { fleetWide: outcome.fleetWide } : {}
|
|
21897
21983
|
}) + `
|
|
21898
21984
|
`);
|
|
21899
21985
|
client2.end();
|
|
@@ -11344,11 +11344,11 @@ var init_schema = __esm(() => {
|
|
|
11344
11344
|
ScheduleEntrySchema = exports_external.object({
|
|
11345
11345
|
cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
|
|
11346
11346
|
prompt: exports_external.string().optional().describe("Prompt to send at the scheduled time (the escalation prompt when " + "kind=poll; templated with {{diff}}). Required for kind prompt/poll; " + "absent for kind=action (an action has no model fire, so no prompt)."),
|
|
11347
|
-
kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates — zero " + "tokens, no session. poll/prompt
|
|
11347
|
+
kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates — zero " + "tokens, no session. poll/prompt tiering is on by default " + "(SWITCHROOM_CHEAP_CRON=0 is the kill-switch); an action is model-free " + "regardless (the kill-switch governs model tiering, not deterministic " + "actions)."),
|
|
11348
11348
|
poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
|
|
11349
11349
|
action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
|
|
11350
11350
|
model: exports_external.string().optional().describe("Cron model hint. Reactivated by SWITCHROOM_CHEAP_CRON (was DEPRECATED/" + "IGNORED in v0.8). A known-cheap id (sonnet/haiku family) routes the " + "fire to a fresh cheap cron session (Tier 1, `context: fresh`); 'opus', " + "a custom id, or unset routes to the agent's live session (Tier 2, " + "`context: agent`) — the conservative default that preserves pre-v0.8 " + "behaviour. Note: a live session's model is fixed at launch, so on Tier " + "2 this is informational. See docs/scheduling.md."),
|
|
11351
|
-
context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' → a minimal-" + "context cheap cron session (Tier 1). 'agent' → the agent's live " + "session with full persona/memory (Tier 2). Unset → inferred from " + "`model` (cheap→fresh, else agent).
|
|
11351
|
+
context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' → a minimal-" + "context cheap cron session (Tier 1). 'agent' → the agent's live " + "session with full persona/memory (Tier 2). Unset → inferred from " + "`model` (cheap→fresh, else agent). On by default; " + "SWITCHROOM_CHEAP_CRON=0 is the kill-switch."),
|
|
11352
11352
|
secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default — broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary — " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing."),
|
|
11353
11353
|
topic: exports_external.union([
|
|
11354
11354
|
exports_external.string().min(1, "topic alias must be non-empty"),
|
|
@@ -11860,8 +11860,8 @@ var init_schema = __esm(() => {
|
|
|
11860
11860
|
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.")
|
|
11861
11861
|
});
|
|
11862
11862
|
HostdConfigSchema = exports_external.object({
|
|
11863
|
-
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
|
|
11864
|
-
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.
|
|
11863
|
+
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, 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."),
|
|
11864
|
+
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. Configurable now, but the rate limiter is not yet enforced " + "(no `E_RATE_LIMITED` is currently raised); the field is reserved so " + "operators can pin the cap ahead of the limiter going live.")
|
|
11865
11865
|
});
|
|
11866
11866
|
CronEgressSchema = exports_external.object({
|
|
11867
11867
|
allowed_hosts: exports_external.array(exports_external.string().min(1)).default([]).describe("Hosts a poll may reach (exact, https-only). loopback/private/IP-literal are always rejected."),
|
|
@@ -11898,7 +11898,7 @@ var init_schema = __esm(() => {
|
|
|
11898
11898
|
notion_workspace: NotionWorkspaceConfigSchema.describe("RFC docs/rfcs/notion-integration.md. Top-level Notion integration " + "config — vault key for the integration token, friendly-name → " + "database UUID map, optional MCP-package version pin, and optional " + "global rate-limit override (default 3 rps, Notion's documented " + "public-API limit). Block is optional; when omitted no agent gets a " + "Notion MCP entry regardless of per-agent config."),
|
|
11899
11899
|
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."),
|
|
11900
11900
|
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)."),
|
|
11901
|
-
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.
|
|
11901
|
+
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. Scopes the opt-in flag and rate cap for the " + "`config_propose_edit` verb (disabled by default)."),
|
|
11902
11902
|
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`."),
|
|
11903
11903
|
google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
|
|
11904
11904
|
message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
|
|
@@ -11344,11 +11344,11 @@ var init_schema = __esm(() => {
|
|
|
11344
11344
|
ScheduleEntrySchema = exports_external.object({
|
|
11345
11345
|
cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
|
|
11346
11346
|
prompt: exports_external.string().optional().describe("Prompt to send at the scheduled time (the escalation prompt when " + "kind=poll; templated with {{diff}}). Required for kind prompt/poll; " + "absent for kind=action (an action has no model fire, so no prompt)."),
|
|
11347
|
-
kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates — zero " + "tokens, no session. poll/prompt
|
|
11347
|
+
kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates — zero " + "tokens, no session. poll/prompt tiering is on by default " + "(SWITCHROOM_CHEAP_CRON=0 is the kill-switch); an action is model-free " + "regardless (the kill-switch governs model tiering, not deterministic " + "actions)."),
|
|
11348
11348
|
poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
|
|
11349
11349
|
action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
|
|
11350
11350
|
model: exports_external.string().optional().describe("Cron model hint. Reactivated by SWITCHROOM_CHEAP_CRON (was DEPRECATED/" + "IGNORED in v0.8). A known-cheap id (sonnet/haiku family) routes the " + "fire to a fresh cheap cron session (Tier 1, `context: fresh`); 'opus', " + "a custom id, or unset routes to the agent's live session (Tier 2, " + "`context: agent`) — the conservative default that preserves pre-v0.8 " + "behaviour. Note: a live session's model is fixed at launch, so on Tier " + "2 this is informational. See docs/scheduling.md."),
|
|
11351
|
-
context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' → a minimal-" + "context cheap cron session (Tier 1). 'agent' → the agent's live " + "session with full persona/memory (Tier 2). Unset → inferred from " + "`model` (cheap→fresh, else agent).
|
|
11351
|
+
context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' → a minimal-" + "context cheap cron session (Tier 1). 'agent' → the agent's live " + "session with full persona/memory (Tier 2). Unset → inferred from " + "`model` (cheap→fresh, else agent). On by default; " + "SWITCHROOM_CHEAP_CRON=0 is the kill-switch."),
|
|
11352
11352
|
secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default — broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary — " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing."),
|
|
11353
11353
|
topic: exports_external.union([
|
|
11354
11354
|
exports_external.string().min(1, "topic alias must be non-empty"),
|
|
@@ -11860,8 +11860,8 @@ var init_schema = __esm(() => {
|
|
|
11860
11860
|
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.")
|
|
11861
11861
|
});
|
|
11862
11862
|
HostdConfigSchema = exports_external.object({
|
|
11863
|
-
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
|
|
11864
|
-
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.
|
|
11863
|
+
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, 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."),
|
|
11864
|
+
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. Configurable now, but the rate limiter is not yet enforced " + "(no `E_RATE_LIMITED` is currently raised); the field is reserved so " + "operators can pin the cap ahead of the limiter going live.")
|
|
11865
11865
|
});
|
|
11866
11866
|
CronEgressSchema = exports_external.object({
|
|
11867
11867
|
allowed_hosts: exports_external.array(exports_external.string().min(1)).default([]).describe("Hosts a poll may reach (exact, https-only). loopback/private/IP-literal are always rejected."),
|
|
@@ -11898,7 +11898,7 @@ var init_schema = __esm(() => {
|
|
|
11898
11898
|
notion_workspace: NotionWorkspaceConfigSchema.describe("RFC docs/rfcs/notion-integration.md. Top-level Notion integration " + "config — vault key for the integration token, friendly-name → " + "database UUID map, optional MCP-package version pin, and optional " + "global rate-limit override (default 3 rps, Notion's documented " + "public-API limit). Block is optional; when omitted no agent gets a " + "Notion MCP entry regardless of per-agent config."),
|
|
11899
11899
|
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."),
|
|
11900
11900
|
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)."),
|
|
11901
|
-
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.
|
|
11901
|
+
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. Scopes the opt-in flag and rate cap for the " + "`config_propose_edit` verb (disabled by default)."),
|
|
11902
11902
|
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`."),
|
|
11903
11903
|
google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
|
|
11904
11904
|
message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchroom",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.23",
|
|
4
4
|
"description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"test:vitest": "vitest run",
|
|
28
28
|
"test:bun": "bun test src/watchdog/state.test.ts src/watchdog/policy.test.ts src/vault/grants.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/server-admin-only-keys.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/approval-origin.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
|
|
29
29
|
"test:watch": "vitest",
|
|
30
|
-
"lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs",
|
|
30
|
+
"lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs && node scripts/check-stale-tool-descriptions.mjs",
|
|
31
31
|
"lint:tsc": "tsc --noEmit",
|
|
32
32
|
"lint:plugin-references": "node scripts/check-plugin-references.mjs",
|
|
33
33
|
"lint:bot-api-wrapping": "bash scripts/check-bot-api-wrapping.sh",
|
|
@@ -131,9 +131,13 @@ A config-summary greeting card is sent automatically by the SessionStart hook
|
|
|
131
131
|
{{#if admin}}
|
|
132
132
|
## Admin surface
|
|
133
133
|
|
|
134
|
-
You're `admin: true`. Fleet operations live on the `hostd` MCP server: `agent_restart` / `agent_start` / `agent_stop` (lifecycle of any peer), `agent_logs` (peer container logs), `agent_exec` (read-only inspection inside any peer — argv[0] must be on the safe-command allowlist), `update_check` / `update_apply`. Treat these like a root shell on the host: confirm intent before destructive actions, refuse if unsure who's asking.
|
|
134
|
+
You're `admin: true`. Fleet operations live on the `hostd` MCP server: `agent_restart` / `agent_start` / `agent_stop` (lifecycle of any peer), `agent_logs` (peer container logs), `agent_exec` (read-only inspection inside any peer — argv[0] must be on the safe-command allowlist), `update_check` / `update_apply`, `get_status` (look up a prior mutation's outcome by request_id), and `config_propose_edit`. Treat these like a root shell on the host: confirm intent before destructive actions, refuse if unsure who's asking.
|
|
135
135
|
|
|
136
|
-
|
|
136
|
+
**Editing `switchroom.yaml` — use `config_propose_edit`, don't hand the operator a snippet.** When `hostd.config_edit_enabled` is on (it returns `E_CONFIG_EDIT_DISABLED` if not), this is the sanctioned way to amend config: you propose a unified diff, the operator gets a Telegram approval card, and on Allow the host applies + reconciles it. As admin you may propose any field. An applied edit isn't live in the affected agent until it restarts — the result card names which agents to bounce (use `agent_restart`, or tell the operator). The vault is different: `vault remove` / secret deletes stay operator-only host-CLI ops — there's no agent tool for those, so for those you DO hand the operator the command.
|
|
137
|
+
|
|
138
|
+
After a tapped `update_apply`, call `get_status` with its request_id to confirm what actually rolled out before reporting success — don't assume.
|
|
139
|
+
|
|
140
|
+
Only `update_check` (a read-only dry-run) and `get_status` run immediately. Every mutating / host verb — `update_apply`, `agent_exec`, `agent_restart` / `agent_start` / `agent_stop`, `agent_logs`, `config_propose_edit` — pauses for an **operator approval card in Telegram before it executes**: a human must tap approve. This is deliberate (you are a prompt-injectable process; the human in the loop is the safety boundary, not your own judgement). Expect the call to block until approved or denied; if denied, don't retry — relay the denial and stop.
|
|
137
141
|
{{else}}
|
|
138
142
|
## Admin operations
|
|
139
143
|
|
|
@@ -23847,11 +23847,11 @@ var init_schema = __esm(() => {
|
|
|
23847
23847
|
ScheduleEntrySchema = exports_external.object({
|
|
23848
23848
|
cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
|
|
23849
23849
|
prompt: exports_external.string().optional().describe("Prompt to send at the scheduled time (the escalation prompt when " + "kind=poll; templated with {{diff}}). Required for kind prompt/poll; " + "absent for kind=action (an action has no model fire, so no prompt)."),
|
|
23850
|
-
kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates \u2014 zero " + "tokens, no session. poll/prompt
|
|
23850
|
+
kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates \u2014 zero " + "tokens, no session. poll/prompt tiering is on by default " + "(SWITCHROOM_CHEAP_CRON=0 is the kill-switch); an action is model-free " + "regardless (the kill-switch governs model tiering, not deterministic " + "actions)."),
|
|
23851
23851
|
poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
|
|
23852
23852
|
action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
|
|
23853
23853
|
model: exports_external.string().optional().describe("Cron model hint. Reactivated by SWITCHROOM_CHEAP_CRON (was DEPRECATED/" + "IGNORED in v0.8). A known-cheap id (sonnet/haiku family) routes the " + "fire to a fresh cheap cron session (Tier 1, `context: fresh`); 'opus', " + "a custom id, or unset routes to the agent's live session (Tier 2, " + "`context: agent`) \u2014 the conservative default that preserves pre-v0.8 " + "behaviour. Note: a live session's model is fixed at launch, so on Tier " + "2 this is informational. See docs/scheduling.md."),
|
|
23854
|
-
context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' \u2192 a minimal-" + "context cheap cron session (Tier 1). 'agent' \u2192 the agent's live " + "session with full persona/memory (Tier 2). Unset \u2192 inferred from " + "`model` (cheap\u2192fresh, else agent).
|
|
23854
|
+
context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' \u2192 a minimal-" + "context cheap cron session (Tier 1). 'agent' \u2192 the agent's live " + "session with full persona/memory (Tier 2). Unset \u2192 inferred from " + "`model` (cheap\u2192fresh, else agent). On by default; " + "SWITCHROOM_CHEAP_CRON=0 is the kill-switch."),
|
|
23855
23855
|
secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default \u2014 broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary \u2014 " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing."),
|
|
23856
23856
|
topic: exports_external.union([
|
|
23857
23857
|
exports_external.string().min(1, "topic alias must be non-empty"),
|
|
@@ -24363,8 +24363,8 @@ var init_schema = __esm(() => {
|
|
|
24363
24363
|
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.")
|
|
24364
24364
|
});
|
|
24365
24365
|
HostdConfigSchema = exports_external.object({
|
|
24366
|
-
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
|
|
24367
|
-
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.
|
|
24366
|
+
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, 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."),
|
|
24367
|
+
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. Configurable now, but the rate limiter is not yet enforced " + "(no `E_RATE_LIMITED` is currently raised); the field is reserved so " + "operators can pin the cap ahead of the limiter going live.")
|
|
24368
24368
|
});
|
|
24369
24369
|
CronEgressSchema = exports_external.object({
|
|
24370
24370
|
allowed_hosts: exports_external.array(exports_external.string().min(1)).default([]).describe("Hosts a poll may reach (exact, https-only). loopback/private/IP-literal are always rejected."),
|
|
@@ -24401,7 +24401,7 @@ var init_schema = __esm(() => {
|
|
|
24401
24401
|
notion_workspace: NotionWorkspaceConfigSchema.describe("RFC docs/rfcs/notion-integration.md. Top-level Notion integration " + "config \u2014 vault key for the integration token, friendly-name \u2192 " + "database UUID map, optional MCP-package version pin, and optional " + "global rate-limit override (default 3 rps, Notion's documented " + "public-API limit). Block is optional; when omitted no agent gets a " + "Notion MCP entry regardless of per-agent config."),
|
|
24402
24402
|
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."),
|
|
24403
24403
|
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)."),
|
|
24404
|
-
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.
|
|
24404
|
+
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. Scopes the opt-in flag and rate cap for the " + "`config_propose_edit` verb (disabled by default)."),
|
|
24405
24405
|
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`."),
|
|
24406
24406
|
google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
|
|
24407
24407
|
message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
|
|
@@ -31013,6 +31013,7 @@ __export(exports_config_approval_handler, {
|
|
|
31013
31013
|
parseConfigApprovalCallback: () => parseConfigApprovalCallback,
|
|
31014
31014
|
handleRequestConfigFinalize: () => handleRequestConfigFinalize,
|
|
31015
31015
|
handleRequestConfigApproval: () => handleRequestConfigApproval,
|
|
31016
|
+
buildLiveNote: () => buildLiveNote,
|
|
31016
31017
|
buildConfigApprovalCardBody: () => buildConfigApprovalCardBody,
|
|
31017
31018
|
_resetPendingConfigApprovalsForTest: () => _resetPendingConfigApprovalsForTest,
|
|
31018
31019
|
_peekPendingConfigApprovalForTest: () => _peekPendingConfigApprovalForTest,
|
|
@@ -31156,6 +31157,22 @@ async function resolvePendingConfigApproval(requestId, verdict, deps) {
|
|
|
31156
31157
|
}
|
|
31157
31158
|
return true;
|
|
31158
31159
|
}
|
|
31160
|
+
function buildLiveNote(affectedAgents, fleetWide) {
|
|
31161
|
+
if (fleetWide) {
|
|
31162
|
+
return `
|
|
31163
|
+
|
|
31164
|
+
\u26a0\ufe0f Shared config changed \u2014 affects all agents. Not live until they ` + `restart: run <code>switchroom rollout</code> (or <code>/update apply</code>).`;
|
|
31165
|
+
}
|
|
31166
|
+
const agents = (affectedAgents ?? []).filter((a) => typeof a === "string" && a.length > 0);
|
|
31167
|
+
if (agents.length === 0)
|
|
31168
|
+
return "";
|
|
31169
|
+
const list2 = agents.map(escapeHtml12).join(", ");
|
|
31170
|
+
const cmds = agents.map((a) => `/restart ${escapeHtml12(a)}`).join(" \u00b7 ");
|
|
31171
|
+
return `
|
|
31172
|
+
|
|
31173
|
+
\uD83D\uDD04 Not live until restart \u2014 affects: <b>${list2}</b>
|
|
31174
|
+
${cmds}`;
|
|
31175
|
+
}
|
|
31159
31176
|
async function handleRequestConfigFinalize(_client, msg, deps) {
|
|
31160
31177
|
const entry = pending.get(msg.requestId);
|
|
31161
31178
|
if (!entry) {
|
|
@@ -31163,8 +31180,9 @@ async function handleRequestConfigFinalize(_client, msg, deps) {
|
|
|
31163
31180
|
return;
|
|
31164
31181
|
}
|
|
31165
31182
|
pending.delete(msg.requestId);
|
|
31183
|
+
const liveNote = msg.outcome === "applied" ? buildLiveNote(msg.affectedAgents, msg.fleetWide) : "";
|
|
31166
31184
|
const body = msg.outcome === "applied" ? `\u2705 <b>Applied</b>${msg.detail ? `
|
|
31167
|
-
${escapeHtml12(msg.detail)}` : ""}` : `\u26a0\ufe0f <b>Reconcile failed; rolled back</b>${msg.detail ? `
|
|
31185
|
+
${escapeHtml12(msg.detail)}` : ""}${liveNote}` : `\u26a0\ufe0f <b>Reconcile failed; rolled back</b>${msg.detail ? `
|
|
31168
31186
|
${escapeHtml12(msg.detail)}` : ""}`;
|
|
31169
31187
|
try {
|
|
31170
31188
|
await deps.editCard({
|
|
@@ -47641,6 +47659,16 @@ function validateClientMessage(msg) {
|
|
|
47641
47659
|
return false;
|
|
47642
47660
|
if (m.detail !== undefined && (typeof m.detail !== "string" || m.detail.length > 500))
|
|
47643
47661
|
return false;
|
|
47662
|
+
if (m.affectedAgents !== undefined) {
|
|
47663
|
+
if (!Array.isArray(m.affectedAgents) || m.affectedAgents.length > 64)
|
|
47664
|
+
return false;
|
|
47665
|
+
for (const a of m.affectedAgents) {
|
|
47666
|
+
if (typeof a !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(a))
|
|
47667
|
+
return false;
|
|
47668
|
+
}
|
|
47669
|
+
}
|
|
47670
|
+
if (m.fleetWide !== undefined && typeof m.fleetWide !== "boolean")
|
|
47671
|
+
return false;
|
|
47644
47672
|
return true;
|
|
47645
47673
|
}
|
|
47646
47674
|
case "request_drive_approval": {
|
|
@@ -54392,11 +54420,11 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
|
|
|
54392
54420
|
}
|
|
54393
54421
|
|
|
54394
54422
|
// ../src/build-info.ts
|
|
54395
|
-
var VERSION = "0.15.
|
|
54396
|
-
var COMMIT_SHA = "
|
|
54397
|
-
var COMMIT_DATE = "2026-06-
|
|
54398
|
-
var LATEST_PR =
|
|
54399
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
54423
|
+
var VERSION = "0.15.23";
|
|
54424
|
+
var COMMIT_SHA = "4c70a87a";
|
|
54425
|
+
var COMMIT_DATE = "2026-06-14T15:15:27+10:00";
|
|
54426
|
+
var LATEST_PR = null;
|
|
54427
|
+
var COMMITS_AHEAD_OF_TAG = 2;
|
|
54400
54428
|
|
|
54401
54429
|
// gateway/boot-version.ts
|
|
54402
54430
|
function formatRelativeAgo(iso) {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
14
14
|
import {
|
|
15
15
|
buildConfigApprovalCardBody,
|
|
16
|
+
buildLiveNote,
|
|
16
17
|
handleRequestConfigApproval,
|
|
17
18
|
handleRequestConfigFinalize,
|
|
18
19
|
parseConfigApprovalCallback,
|
|
@@ -266,6 +267,29 @@ describe("timeout path", () => {
|
|
|
266
267
|
});
|
|
267
268
|
});
|
|
268
269
|
|
|
270
|
+
describe("buildLiveNote", () => {
|
|
271
|
+
it("names specific affected agents + the per-agent restart command", () => {
|
|
272
|
+
const note = buildLiveNote(["clerk", "gymbro"], false);
|
|
273
|
+
expect(note).toContain("clerk, gymbro");
|
|
274
|
+
expect(note).toContain("/restart clerk");
|
|
275
|
+
expect(note).toContain("/restart gymbro");
|
|
276
|
+
expect(note).toContain("Not live until restart");
|
|
277
|
+
});
|
|
278
|
+
it("guides to a full rollout when fleet-wide (no per-agent list)", () => {
|
|
279
|
+
const note = buildLiveNote([], true);
|
|
280
|
+
expect(note).toContain("all agents");
|
|
281
|
+
expect(note).toContain("switchroom rollout");
|
|
282
|
+
expect(note).not.toContain("/restart");
|
|
283
|
+
});
|
|
284
|
+
it("is empty when nothing is runtime-affected", () => {
|
|
285
|
+
expect(buildLiveNote([], false)).toBe("");
|
|
286
|
+
expect(buildLiveNote(undefined, undefined)).toBe("");
|
|
287
|
+
});
|
|
288
|
+
it("HTML-escapes agent names", () => {
|
|
289
|
+
expect(buildLiveNote(["a<b>"], false)).toContain("a<b>");
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
269
293
|
describe("handleRequestConfigFinalize", () => {
|
|
270
294
|
it("edits the card to '✅ Applied' on success", async () => {
|
|
271
295
|
const { client, deps, editCalls } = fakeDeps();
|
|
@@ -377,6 +377,27 @@ export async function resolvePendingConfigApproval(
|
|
|
377
377
|
return true;
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
+
/**
|
|
381
|
+
* The "make it live" note appended to an Applied card. claude loads config at
|
|
382
|
+
* boot, so an applied edit is inert in the running agents until they restart —
|
|
383
|
+
* this names exactly what must bounce (and the command) instead of letting the
|
|
384
|
+
* change silently not take effect. Fleet-wide (shared config) → guide to a full
|
|
385
|
+
* rollout, never a per-agent list. Empty when nothing runtime-affected.
|
|
386
|
+
*/
|
|
387
|
+
export function buildLiveNote(affectedAgents?: string[], fleetWide?: boolean): string {
|
|
388
|
+
if (fleetWide) {
|
|
389
|
+
return (
|
|
390
|
+
`\n\n⚠️ Shared config changed — affects all agents. Not live until they ` +
|
|
391
|
+
`restart: run <code>switchroom rollout</code> (or <code>/update apply</code>).`
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
const agents = (affectedAgents ?? []).filter((a) => typeof a === "string" && a.length > 0);
|
|
395
|
+
if (agents.length === 0) return "";
|
|
396
|
+
const list = agents.map(escapeHtml).join(", ");
|
|
397
|
+
const cmds = agents.map((a) => `/restart ${escapeHtml(a)}`).join(" · ");
|
|
398
|
+
return `\n\n🔄 Not live until restart — affects: <b>${list}</b>\n${cmds}`;
|
|
399
|
+
}
|
|
400
|
+
|
|
380
401
|
/** IPC `request_config_finalize` handler — edits the card to the terminal outcome. */
|
|
381
402
|
export async function handleRequestConfigFinalize(
|
|
382
403
|
_client: Pick<IpcClient, "send">,
|
|
@@ -393,9 +414,15 @@ export async function handleRequestConfigFinalize(
|
|
|
393
414
|
// Clean up the pending entry — finalize is the terminal transition.
|
|
394
415
|
pending.delete(msg.requestId);
|
|
395
416
|
|
|
417
|
+
// On apply, tell the operator what must restart for the edit to go LIVE —
|
|
418
|
+
// claude loads config at boot, so an applied edit is inert until restart.
|
|
419
|
+
// Specific agents → name them + the one-liner to bounce them; shared config
|
|
420
|
+
// → guide to a full rollout (never silently leave the change un-live).
|
|
421
|
+
const liveNote =
|
|
422
|
+
msg.outcome === "applied" ? buildLiveNote(msg.affectedAgents, msg.fleetWide) : "";
|
|
396
423
|
const body =
|
|
397
424
|
msg.outcome === "applied"
|
|
398
|
-
? `✅ <b>Applied</b>${msg.detail ? `\n${escapeHtml(msg.detail)}` : ""}`
|
|
425
|
+
? `✅ <b>Applied</b>${msg.detail ? `\n${escapeHtml(msg.detail)}` : ""}${liveNote}`
|
|
399
426
|
: `⚠️ <b>Reconcile failed; rolled back</b>${msg.detail ? `\n${escapeHtml(msg.detail)}` : ""}`;
|
|
400
427
|
try {
|
|
401
428
|
await deps.editCard({
|
|
@@ -391,6 +391,14 @@ export interface RequestConfigFinalizeMessage {
|
|
|
391
391
|
outcome: "applied" | "reconcile_failed_rolled_back";
|
|
392
392
|
/** Optional short diagnostic appended to the card body. */
|
|
393
393
|
detail?: string;
|
|
394
|
+
/**
|
|
395
|
+
* On `applied`: agents that must restart for the edit to go live (claude
|
|
396
|
+
* loads config at boot). Empty when `fleetWide`. The finalize card offers a
|
|
397
|
+
* one-tap restart of these. Computed host-side by classifyBlastRadius.
|
|
398
|
+
*/
|
|
399
|
+
affectedAgents?: string[];
|
|
400
|
+
/** On `applied`: a shared/inherited key changed → all agents affected. */
|
|
401
|
+
fleetWide?: boolean;
|
|
394
402
|
}
|
|
395
403
|
|
|
396
404
|
/**
|
|
@@ -307,6 +307,16 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
|
|
|
307
307
|
if (m.detail !== undefined
|
|
308
308
|
&& (typeof m.detail !== "string"
|
|
309
309
|
|| (m.detail as string).length > 500)) return false;
|
|
310
|
+
// affectedAgents (optional): a bounded list of kebab-case agent names —
|
|
311
|
+
// they drive a restart button, so validate shape + charclass even though
|
|
312
|
+
// the sender (hostd) is trusted (defense in depth).
|
|
313
|
+
if (m.affectedAgents !== undefined) {
|
|
314
|
+
if (!Array.isArray(m.affectedAgents) || (m.affectedAgents as unknown[]).length > 64) return false;
|
|
315
|
+
for (const a of m.affectedAgents as unknown[]) {
|
|
316
|
+
if (typeof a !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(a)) return false;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (m.fleetWide !== undefined && typeof m.fleetWide !== "boolean") return false;
|
|
310
320
|
return true;
|
|
311
321
|
}
|
|
312
322
|
case "request_drive_approval": {
|
|
@@ -332,4 +332,32 @@ describe('validateClientMessage', () => {
|
|
|
332
332
|
expect(validateClientMessage({ ...valid, ttlMs: '300000' })).toBe(false)
|
|
333
333
|
})
|
|
334
334
|
})
|
|
335
|
+
|
|
336
|
+
describe('request_config_finalize — affectedAgents / fleetWide (#2346)', () => {
|
|
337
|
+
const valid = { type: 'request_config_finalize', requestId: 'r1', outcome: 'applied' }
|
|
338
|
+
|
|
339
|
+
it('accepts the bare message and the new optional fields', () => {
|
|
340
|
+
expect(validateClientMessage(valid)).toBe(true)
|
|
341
|
+
expect(validateClientMessage({ ...valid, affectedAgents: ['clerk', 'gymbro'], fleetWide: false })).toBe(true)
|
|
342
|
+
expect(validateClientMessage({ ...valid, affectedAgents: [], fleetWide: true })).toBe(true)
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('rejects a non-array affectedAgents', () => {
|
|
346
|
+
expect(validateClientMessage({ ...valid, affectedAgents: 'clerk' })).toBe(false)
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('rejects affectedAgents with an unsafe / non-kebab name (defense in depth)', () => {
|
|
350
|
+
expect(validateClientMessage({ ...valid, affectedAgents: ['../etc'] })).toBe(false)
|
|
351
|
+
expect(validateClientMessage({ ...valid, affectedAgents: ['has space'] })).toBe(false)
|
|
352
|
+
expect(validateClientMessage({ ...valid, affectedAgents: [42] })).toBe(false)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('rejects an over-long affectedAgents list', () => {
|
|
356
|
+
expect(validateClientMessage({ ...valid, affectedAgents: Array.from({ length: 65 }, (_, i) => `a${i}`) })).toBe(false)
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('rejects a non-boolean fleetWide', () => {
|
|
360
|
+
expect(validateClientMessage({ ...valid, fleetWide: 'yes' })).toBe(false)
|
|
361
|
+
})
|
|
362
|
+
})
|
|
335
363
|
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UAT — `/effort` command (#2336, #2342): show + tap-to-switch the
|
|
3
|
+
* Claude reasoning effort for the live session. The effort sibling of
|
|
4
|
+
* `/model`; the picker-driven menu is the same shape.
|
|
5
|
+
*
|
|
6
|
+
* Verified live on test-harness v0.15.21. Switches are session-only
|
|
7
|
+
* (revert on restart), so the tap test restores the original level.
|
|
8
|
+
*
|
|
9
|
+
* Self-skips green on an unwired host (spinUp can't resolve the chat).
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
import { spinUp } from "../harness.js";
|
|
13
|
+
|
|
14
|
+
const AGENT = "test-harness";
|
|
15
|
+
const T = 30_000;
|
|
16
|
+
|
|
17
|
+
describe("uat: /effort — show, tap-switch, bad-arg", () => {
|
|
18
|
+
it(
|
|
19
|
+
"bare /effort shows the effort menu with a tap keyboard",
|
|
20
|
+
async () => {
|
|
21
|
+
const sc = await spinUp({ agent: AGENT });
|
|
22
|
+
try {
|
|
23
|
+
await sc.sendDM("/effort");
|
|
24
|
+
const menu = await sc.expectMessage(/Effort —/, { from: "bot", timeout: T });
|
|
25
|
+
expect(menu.text).toMatch(/faster → smarter|low · medium · high/i);
|
|
26
|
+
expect(menu.text).toMatch(/switchroom\.yaml/i);
|
|
27
|
+
const kb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
|
|
28
|
+
const labels = (kb ?? []).flat().map((b) => b.text);
|
|
29
|
+
expect(labels.some((t) => /low/i.test(t)), "low button present").toBe(true);
|
|
30
|
+
expect(labels.some((t) => /max/i.test(t)), "max button present").toBe(true);
|
|
31
|
+
} finally {
|
|
32
|
+
await sc.tearDown();
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
60_000,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
it(
|
|
39
|
+
"tapping a level switches the live session, then restores",
|
|
40
|
+
async () => {
|
|
41
|
+
const sc = await spinUp({ agent: AGENT });
|
|
42
|
+
try {
|
|
43
|
+
await sc.sendDM("/effort");
|
|
44
|
+
const menu = await sc.expectMessage(/Effort —/, { from: "bot", timeout: T });
|
|
45
|
+
const kb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
|
|
46
|
+
const flat = (kb ?? []).flat();
|
|
47
|
+
// The current level is prefixed with ✅; pick a DIFFERENT one.
|
|
48
|
+
const current = flat.find((b) => /✅/.test(b.text));
|
|
49
|
+
const target = flat.find(
|
|
50
|
+
(b) => b.callbackData && !/✅/.test(b.text) && /medium|high/i.test(b.text),
|
|
51
|
+
);
|
|
52
|
+
expect(target, "a non-current effort button to tap").toBeDefined();
|
|
53
|
+
await sc.driver.pressButton(sc.botUserId, menu.messageId, target!.callbackData!);
|
|
54
|
+
// The card edits in place to prepend a confirmation line.
|
|
55
|
+
await new Promise((r) => setTimeout(r, 4000));
|
|
56
|
+
const after = await sc.driver.getMessage(sc.botUserId, menu.messageId);
|
|
57
|
+
expect(after?.text ?? "").toMatch(/Effort →|Switched|effort/i);
|
|
58
|
+
|
|
59
|
+
// Restore the original level so test-harness isn't left changed.
|
|
60
|
+
if (current?.callbackData) {
|
|
61
|
+
const kb2 = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
|
|
62
|
+
const restore = (kb2 ?? [])
|
|
63
|
+
.flat()
|
|
64
|
+
.find((b) => b.callbackData === current.callbackData);
|
|
65
|
+
if (restore?.callbackData) {
|
|
66
|
+
await sc.driver.pressButton(sc.botUserId, menu.messageId, restore.callbackData);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} finally {
|
|
70
|
+
await sc.tearDown();
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
90_000,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
it(
|
|
77
|
+
"/effort bogus → reply (error/help), never silence",
|
|
78
|
+
async () => {
|
|
79
|
+
const sc = await spinUp({ agent: AGENT });
|
|
80
|
+
try {
|
|
81
|
+
await sc.sendDM("/effort definitely-not-a-level");
|
|
82
|
+
const reply = await sc.expectMessage(/\S/, { from: "bot", timeout: T });
|
|
83
|
+
expect(reply.text.length).toBeGreaterThan(0);
|
|
84
|
+
} finally {
|
|
85
|
+
await sc.tearDown();
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
60_000,
|
|
89
|
+
);
|
|
90
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end UAT — agent AUTO-RESUMES after a vault grant approval,
|
|
3
|
+
* under the live `telegram-id` (single-factor) posture. Regression gate
|
|
4
|
+
* for the mid-turn-strand fix (#2340).
|
|
5
|
+
*
|
|
6
|
+
* THE BUG (#2340, clerk 2026-06-13): the gateway injects a synthetic
|
|
7
|
+
* "✅ approved — resume your task" inbound after the operator taps
|
|
8
|
+
* Approve. That inject used a raw `sendToAgent` and only buffered on a
|
|
9
|
+
* disconnected bridge. When the approval landed WHILE the agent's
|
|
10
|
+
* grant-requesting turn was still finishing, the socket write succeeded
|
|
11
|
+
* (`delivered=true`) but claude was mid-turn, so the channel
|
|
12
|
+
* notification stranded in its TUI composer (the #1556 race) and the
|
|
13
|
+
* agent sat idle until manually poked. Fix: route the resume through
|
|
14
|
+
* the same turn-gate as normal inbounds (buffer mid-turn, flush at
|
|
15
|
+
* turn-end). This scenario proves the agent resumes on its own.
|
|
16
|
+
*
|
|
17
|
+
* Posture: the live fleet runs `vault.broker.approvalAuth: telegram-id`
|
|
18
|
+
* (broker auto-unlocked), so tapping Approve mints silently — NO
|
|
19
|
+
* passphrase prompt. The sibling `vault-grant-auto-resume-dm.test.ts`
|
|
20
|
+
* covers the (now-legacy) passphrase posture and stays skipped.
|
|
21
|
+
*
|
|
22
|
+
* No sacrificial key needed: `vault_request_access` mints an ACL grant
|
|
23
|
+
* for the key *pattern*; the card → approve → resume cycle fires
|
|
24
|
+
* whether or not the key holds a value. We assert the RESUME (a fresh
|
|
25
|
+
* bot turn after the tap, with no driver nudge), not a secret value —
|
|
26
|
+
* so nothing sensitive is read or leaked.
|
|
27
|
+
*
|
|
28
|
+
* Self-skips green when the driver can't resolve the chat (unwired
|
|
29
|
+
* host), matching the other opt-in scenarios — uat/** is excluded from
|
|
30
|
+
* gating CI anyway. Mutates host vault state (mints a short grant on
|
|
31
|
+
* test-harness); harmless + TTL-expiring.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { describe, expect, it } from "vitest";
|
|
35
|
+
import { spinUp } from "../harness.js";
|
|
36
|
+
|
|
37
|
+
const AGENT = "test-harness";
|
|
38
|
+
const KEY = "uat/resume-probe";
|
|
39
|
+
|
|
40
|
+
describe("uat: agent auto-resumes after vault grant approval — telegram-id (#2340)", () => {
|
|
41
|
+
it(
|
|
42
|
+
"fires card → operator taps Approve → agent emits a NEW turn with no nudge",
|
|
43
|
+
async () => {
|
|
44
|
+
const sc = await spinUp({ agent: AGENT });
|
|
45
|
+
try {
|
|
46
|
+
// 1. Ask the agent to request access then resume. Steer it to
|
|
47
|
+
// end its turn after the tool call so the approval lands at
|
|
48
|
+
// a turn boundary — the exact window #2340 fixes.
|
|
49
|
+
await sc.sendDM(
|
|
50
|
+
`Call your vault_request_access MCP tool with key="${KEY}", ` +
|
|
51
|
+
`scope="read", reason="UAT #2340 resume gate". After the tool ` +
|
|
52
|
+
`returns "waiting for operator", END YOUR TURN. When the ` +
|
|
53
|
+
`operator approves, you should AUTOMATICALLY resume: confirm ` +
|
|
54
|
+
`the grant landed and that you saw the approval for ${KEY}.`,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// 2. Wait for the approval card.
|
|
58
|
+
const card = await sc.expectMessage(/wants vault access/i, {
|
|
59
|
+
from: "bot",
|
|
60
|
+
timeout: 120_000,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// 3. Find + tap the Approve button.
|
|
64
|
+
const kb = await sc.driver.getKeyboard(sc.botUserId, card.messageId);
|
|
65
|
+
const approve = kb!
|
|
66
|
+
.flat()
|
|
67
|
+
.find((b) => b.callbackData !== undefined && /approve/i.test(b.text));
|
|
68
|
+
expect(approve, "Approve button present on the card").toBeDefined();
|
|
69
|
+
const tapAtMsgId = card.messageId;
|
|
70
|
+
await sc.driver.pressButton(sc.botUserId, card.messageId, approve!.callbackData!);
|
|
71
|
+
|
|
72
|
+
// 4. Single-factor: card edits to "Granted" with no passphrase
|
|
73
|
+
// prompt. Anchor on the grant confirmation.
|
|
74
|
+
await sc.expectMessage(/Granted|already has|access to/i, {
|
|
75
|
+
from: "bot",
|
|
76
|
+
timeout: 30_000,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// 5. THE #2340 ASSERTION: the agent auto-resumes — a NEW bot
|
|
80
|
+
// turn referencing the approval/grant/key, WITHOUT the driver
|
|
81
|
+
// sending anything else. Pre-fix this stranded mid-turn and
|
|
82
|
+
// timed out. The resume reply must be a message newer than
|
|
83
|
+
// the card we tapped (not the card edit).
|
|
84
|
+
const resume = await sc.expectMessage(
|
|
85
|
+
(m) =>
|
|
86
|
+
m.messageId > tapAtMsgId &&
|
|
87
|
+
/(approv|grant|access|resume|landed|✅)/i.test(m.text),
|
|
88
|
+
{ from: "bot", timeout: 150_000 },
|
|
89
|
+
);
|
|
90
|
+
expect(resume.text.length).toBeGreaterThan(0);
|
|
91
|
+
} finally {
|
|
92
|
+
await sc.tearDown();
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
360_000,
|
|
96
|
+
);
|
|
97
|
+
});
|
|
@@ -34,10 +34,13 @@ describe("uat: /model command — show, switch, bad-name", () => {
|
|
|
34
34
|
const sc = await spinUp({ agent: AGENT });
|
|
35
35
|
try {
|
|
36
36
|
await sc.sendDM("/model");
|
|
37
|
-
// v2 (picker-driven menu)
|
|
38
|
-
// "
|
|
39
|
-
//
|
|
40
|
-
|
|
37
|
+
// v2 (picker-driven menu) renders the live model as
|
|
38
|
+
// "Default (new sessions): <model>" (shipped wording, verified
|
|
39
|
+
// live on test-harness v0.15.21); "Now: <model>" was the
|
|
40
|
+
// pre-ship wording; v1 / fallback path renders "Configured:
|
|
41
|
+
// <model>". Any of these proves the gateway handled the command
|
|
42
|
+
// rather than forwarding it to claude as plain text.
|
|
43
|
+
const shape = /Default \(new sessions\):|Now:|Configured:/i;
|
|
41
44
|
const reply = await sc.expectMessage(shape, {
|
|
42
45
|
from: "bot",
|
|
43
46
|
timeout: REPLY_TIMEOUT_MS,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UAT — `/model` v2 dashboard BUTTON TAP (#2263, #2270, #2271). The
|
|
3
|
+
* existing jtbd-model-command scenario covers the bare dashboard +
|
|
4
|
+
* typed-arg forms; this one exercises the genuinely new path the
|
|
5
|
+
* mtcute driver can now drive: tapping a model button to switch the
|
|
6
|
+
* live session via the picker, and the menu never leaving a dead card
|
|
7
|
+
* (#2270 — keeps buttons + clears the toast).
|
|
8
|
+
*
|
|
9
|
+
* Switches are session-only (revert on restart); the test taps a
|
|
10
|
+
* different model then restores the original so test-harness isn't
|
|
11
|
+
* left changed.
|
|
12
|
+
*
|
|
13
|
+
* Self-skips green on an unwired host.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, it } from "vitest";
|
|
16
|
+
import { spinUp } from "../harness.js";
|
|
17
|
+
|
|
18
|
+
const AGENT = "test-harness";
|
|
19
|
+
|
|
20
|
+
describe("uat: /model dashboard button tap switches the live session", () => {
|
|
21
|
+
it(
|
|
22
|
+
"tap a non-current model → switch confirmation; then restore",
|
|
23
|
+
async () => {
|
|
24
|
+
const sc = await spinUp({ agent: AGENT });
|
|
25
|
+
try {
|
|
26
|
+
await sc.sendDM("/model");
|
|
27
|
+
const menu = await sc.expectMessage(/Default \(new sessions\):|Now:/i, {
|
|
28
|
+
from: "bot",
|
|
29
|
+
timeout: 30_000,
|
|
30
|
+
});
|
|
31
|
+
const kb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
|
|
32
|
+
const flat = (kb ?? []).flat().filter((b) => b.callbackData);
|
|
33
|
+
// Model buttons carry mdl:s:<tag>; the current one is prefixed
|
|
34
|
+
// ✅. Refresh (mdl:r) is excluded — pick a non-current model.
|
|
35
|
+
const originalLabel = flat
|
|
36
|
+
.find((b) => /✅/.test(b.text))
|
|
37
|
+
?.text.replace(/^✅\s*/, "");
|
|
38
|
+
const target = flat.find(
|
|
39
|
+
(b) => /mdl:s:/.test(b.callbackData!) && !/✅/.test(b.text),
|
|
40
|
+
);
|
|
41
|
+
expect(target, "a non-current model button to tap").toBeDefined();
|
|
42
|
+
|
|
43
|
+
await sc.driver.pressButton(sc.botUserId, menu.messageId, target!.callbackData!);
|
|
44
|
+
await new Promise((r) => setTimeout(r, 6000));
|
|
45
|
+
// #2270: the card never goes dead — it edits in place to a
|
|
46
|
+
// confirmation and KEEPS a keyboard.
|
|
47
|
+
const after = await sc.driver.getMessage(sc.botUserId, menu.messageId);
|
|
48
|
+
expect(after?.text ?? "").toMatch(/Set model to|Switched|model/i);
|
|
49
|
+
const kbAfter = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
|
|
50
|
+
expect(
|
|
51
|
+
(kbAfter ?? []).flat().length,
|
|
52
|
+
"menu keeps its buttons after a tap (no dead card, #2270)",
|
|
53
|
+
).toBeGreaterThan(0);
|
|
54
|
+
|
|
55
|
+
// Restore the original model: tap the button whose label now
|
|
56
|
+
// matches the original (it's no longer the ✅ row).
|
|
57
|
+
if (originalLabel) {
|
|
58
|
+
const restore = (kbAfter ?? [])
|
|
59
|
+
.flat()
|
|
60
|
+
.find((b) => b.callbackData?.startsWith("mdl:s:") && b.text.replace(/^✅\s*/, "") === originalLabel);
|
|
61
|
+
if (restore?.callbackData) {
|
|
62
|
+
await sc.driver.pressButton(sc.botUserId, menu.messageId, restore.callbackData);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} finally {
|
|
66
|
+
await sc.tearDown();
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
120_000,
|
|
70
|
+
);
|
|
71
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UAT — `/whoami` (#2341): the operator's read-only view of THIS
|
|
3
|
+
* agent's sandbox (same data the agent's `config whoami` MCP tool and
|
|
4
|
+
* the `switchroom config whoami` host CLI report). Read-only, like
|
|
5
|
+
* `/version`; mutates nothing.
|
|
6
|
+
*
|
|
7
|
+
* Verified live on test-harness v0.15.21. Self-skips green on an
|
|
8
|
+
* unwired host.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
import { spinUp } from "../harness.js";
|
|
12
|
+
|
|
13
|
+
const AGENT = "test-harness";
|
|
14
|
+
|
|
15
|
+
describe("uat: /whoami shows the agent sandbox card", () => {
|
|
16
|
+
it(
|
|
17
|
+
"renders tier, model, tools, MCP, and powers",
|
|
18
|
+
async () => {
|
|
19
|
+
const sc = await spinUp({ agent: AGENT });
|
|
20
|
+
try {
|
|
21
|
+
await sc.sendDM("/whoami");
|
|
22
|
+
// Header: "👤 <agent> · <tier>"
|
|
23
|
+
const reply = await sc.expectMessage(/👤\s*test-harness/i, {
|
|
24
|
+
from: "bot",
|
|
25
|
+
timeout: 30_000,
|
|
26
|
+
});
|
|
27
|
+
// The card's load-bearing fields — proves whoami resolved the
|
|
28
|
+
// sandbox (tier/model/tools/mcp/powers), not just echoed a stub.
|
|
29
|
+
expect(reply.text).toMatch(/Model:/i);
|
|
30
|
+
expect(reply.text).toMatch(/Tools:/i);
|
|
31
|
+
expect(reply.text).toMatch(/Powers:/i);
|
|
32
|
+
// Tier marker present in the header (standard / admin / root).
|
|
33
|
+
expect(reply.text).toMatch(/·\s*(standard|admin|root)/i);
|
|
34
|
+
} finally {
|
|
35
|
+
await sc.tearDown();
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
60_000,
|
|
39
|
+
);
|
|
40
|
+
});
|