switchroom 0.21.12 → 0.21.14
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 +23 -0
- package/dist/auth-broker/index.js +24 -1
- package/dist/cli/notion-write-pretool.mjs +23 -0
- package/dist/cli/switchroom.js +3763 -1897
- package/dist/host-control/main.js +40 -15
- package/dist/vault/approvals/kernel-server.js +24 -1
- package/dist/vault/broker/server.js +150 -10
- package/package.json +1 -1
- package/profiles/_shared/agent-self-service.md.hbs +32 -86
- package/profiles/_shared/vault-protocol.md.hbs +17 -62
- package/profiles/default/CLAUDE.md.hbs +76 -74
- package/skills/switchroom-architecture/SKILL.md +11 -7
- package/skills/switchroom-architecture/cascade.md +29 -10
- package/skills/switchroom-architecture/sub-agents.md +27 -26
- package/skills/switchroom-architecture/telegram.md +44 -17
- package/skills/switchroom-runtime/SKILL.md +32 -0
- package/skills/switchroom-status/SKILL.md +17 -18
- package/skills/switchroom-status/scripts/status.sh +8 -17
- package/skills/telegram-test-harness/SKILL.md +6 -5
- package/telegram-plugin/dist/gateway/gateway.js +133 -5
- package/telegram-plugin/gateway/gateway.ts +2 -0
- package/telegram-plugin/shared/utf8-sanitize.ts +269 -0
- package/telegram-plugin/tests/utf8-sanitize-wire.test.ts +588 -0
- package/vendor/hindsight-memory/scripts/lib/directives.py +18 -0
- package/vendor/hindsight-memory/scripts/recall.py +14 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +46 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +53 -0
|
@@ -10994,7 +10994,7 @@ var init_observation_scopes = __esm(() => {
|
|
|
10994
10994
|
});
|
|
10995
10995
|
|
|
10996
10996
|
// src/config/schema.ts
|
|
10997
|
-
var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, ObservationScopesSchema, ObservationScopeStrategySchema, AntiConfabulationDirectiveSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, BuzzChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleServiceTokenSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, releaseBlockFields, ReleaseBlock, RootReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, ConfigRepoConfigSchema, SwitchroomConfigSchema;
|
|
10997
|
+
var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, ObservationScopesSchema, ObservationScopeStrategySchema, AntiConfabulationDirectiveSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, BuzzChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleServiceTokenSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, releaseBlockFields, ReleaseBlock, RootReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, ScratchConfigSchema, DiskConfigSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, ConfigRepoConfigSchema, SwitchroomConfigSchema;
|
|
10998
10998
|
var init_schema = __esm(() => {
|
|
10999
10999
|
init_zod();
|
|
11000
11000
|
init_observation_scopes();
|
|
@@ -11867,6 +11867,27 @@ var init_schema = __esm(() => {
|
|
|
11867
11867
|
notify_on_detect: exports_external.boolean().default(false).describe("KEN-129 — operator-in-the-loop update prompt. Only consulted " + "when apply_on_detect is false (auto-apply supersedes notify): " + "a newly detected release posts ONE operator approval card " + "('fleet is behind — tap to apply') via an admin agent's " + "gateway; Approve runs hostd's update_apply path (fleet-" + "mutation-locked, durable status rows, get_status-pollable). " + "Dedup on release id: the last-notified id persists in " + "~/.switchroom/release-notify-state.json, so a card that " + "reached the operator is never re-posted for the same release."),
|
|
11868
11868
|
image_ref: exports_external.string().default("ghcr.io/switchroom/switchroom-agent:latest").describe("Image reference whose remote digest is compared to the local " + "image digest. Defaults to the agent image's :latest tag, which " + "is the canonical signal that a release has been promoted.")
|
|
11869
11869
|
});
|
|
11870
|
+
ScratchConfigSchema = exports_external.object({
|
|
11871
|
+
enabled: exports_external.boolean().default(true).describe("Whether agents get a scratch mount at /scratch with their package " + "caches redirected there. Default: true — but the feature only " + "engages when `volume` actually exists on the host, so a single-disk " + "machine is unaffected. Set false to opt out even where it does."),
|
|
11872
|
+
volume: exports_external.string().refine((v) => v.startsWith("/"), {
|
|
11873
|
+
message: "scratch.volume must be an absolute host path"
|
|
11874
|
+
}).default("/mnt/bulkdata").describe("Absolute host path of the bulk device's mountpoint. MUST already " + "exist — its presence is the single probe that turns the feature on, " + "so a typo degrades to the pre-existing behaviour rather than " + "quietly relocating caches somewhere that is still the root disk. " + "Default: /mnt/bulkdata."),
|
|
11875
|
+
subdir: exports_external.string().refine((v) => !v.startsWith("/") && !v.split("/").includes(".."), {
|
|
11876
|
+
message: "scratch.subdir must be a relative path without '..' segments"
|
|
11877
|
+
}).default("switchroom/scratch").describe("Relative path under `volume` holding the per-agent scratch " + "directories (`<volume>/<subdir>/<agent>`). Default: " + "switchroom/scratch.")
|
|
11878
|
+
});
|
|
11879
|
+
DiskConfigSchema = exports_external.object({
|
|
11880
|
+
warn_pct: exports_external.number().int().min(1).max(99).default(80).describe("Used-percentage at or above which `switchroom doctor` WARNs about " + "the filesystem holding the agents directory. Default 80 — the " + "reference fleet was at 85% when the condition was found by hand."),
|
|
11881
|
+
fail_pct: exports_external.number().int().min(2).max(100).default(90).describe("Used-percentage at or above which `switchroom doctor` FAILs. Must " + "be greater than `warn_pct`. Default 90."),
|
|
11882
|
+
reap_report: exports_external.object({
|
|
11883
|
+
enabled: exports_external.boolean().default(true).describe("Whether doctor checks that the report-only worktree sweep " + "(`switchroom worktree reap-report --append <file>`) is actually " + "running. Set false on a host that deliberately does not run it."),
|
|
11884
|
+
log: exports_external.string().min(1).default("/var/log/switchroom/reap-report.jsonl").describe("Path of the JSONL evidence log the scheduled `worktree " + "reap-report --append` writes. Doctor reads the newest record's " + "`generatedAt` — it detects the sweep by its OUTPUT, so it is " + "agnostic about whether an operator crontab, /etc/cron.d, or a " + "systemd timer drives it. Default " + "/var/log/switchroom/reap-report.jsonl (the path documented in " + "docs/operators/worktree-gc.md)."),
|
|
11885
|
+
max_age_hours: exports_external.number().int().min(1).max(720).default(48).describe("How old the newest record in `log` may be before doctor WARNs " + "that the sweep has stopped running. Default 48 — twice the " + "documented daily cadence, so a single missed run is not noise.")
|
|
11886
|
+
}).default({}).describe("Liveness check for the report-only worktree sweep. REPORT-ONLY by " + "construction: doctor reads an evidence log and never invokes any " + "reclaim path.")
|
|
11887
|
+
}).refine((v) => v.fail_pct > v.warn_pct, {
|
|
11888
|
+
message: "disk.fail_pct must be greater than disk.warn_pct",
|
|
11889
|
+
path: ["fail_pct"]
|
|
11890
|
+
});
|
|
11870
11891
|
HostControlConfigSchema = exports_external.object({
|
|
11871
11892
|
enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip — the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` — " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C §5.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3)."),
|
|
11872
11893
|
auto_release_check: AutoReleaseCheckSchema.default({}).describe("Pull-based release-triggered fleet restart (#1743). hostd polls " + "the remote release tag on a fixed interval and applies + " + "restarts the fleet (graceful) when a new release is detected. " + "Opt-in: default enabled=false.")
|
|
@@ -11941,6 +11962,8 @@ var init_schema = __esm(() => {
|
|
|
11941
11962
|
microsoft_workspace: MicrosoftWorkspaceConfigSchema.describe("RFC #1873 (Microsoft 365 integration). Top-level Microsoft Workspace " + "configuration — OAuth client credentials (Entra app), authority " + "endpoint (defaults to /common for personal MSA + work), and the " + "org_mode opt-in for Teams/SharePoint surfaces. Block is optional; " + "when omitted the broker does not register the Microsoft provider."),
|
|
11942
11963
|
notion_workspace: NotionWorkspaceConfigSchema.describe("RFC reference/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."),
|
|
11943
11964
|
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."),
|
|
11965
|
+
scratch: ScratchConfigSchema.optional().describe("Per-agent scratch volume. Relocates every agent's build/package " + "caches (uv, npm, bun, playwright, puppeteer, pip user-site) off the " + "root disk onto a bulk device, bind-mounted at /scratch inside each " + "container. Framework-injected for EVERY agent — not routed through " + "the admin-only `bind_mounts:` escalation, because the biggest cache " + "consumers are ordinary non-admin agents. Omit the block to accept " + "defaults; the feature is a no-op unless `scratch.volume` exists on " + "the host."),
|
|
11966
|
+
disk: DiskConfigSchema.default({}).describe("Root-disk headroom thresholds for `switchroom doctor`. Measured " + "against the filesystem holding the agents directory (and the scratch " + "volume when that feature is engaged), not against `/`. Omit the block " + "to accept the defaults (WARN at 80% used, FAIL at 90%)."),
|
|
11944
11967
|
host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (reference/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)."),
|
|
11945
11968
|
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)."),
|
|
11946
11969
|
fleet_health: FleetHealthConfigSchema.default({}).describe("Fleet Health — job-spec-anchored, operator-facing issue tracker (RFC " + "fleet-health.md, serves fleet-stays-healthy). Assigns the owner agent " + "that runs the nightly model-free sensor + weekly deep-dive. Default " + "unset owner_agent → inert; the admin page renders an empty state."),
|
|
@@ -21565,7 +21588,7 @@ function allocateAgentUid(name) {
|
|
|
21565
21588
|
}
|
|
21566
21589
|
|
|
21567
21590
|
// src/build-info.ts
|
|
21568
|
-
var VERSION = "0.21.
|
|
21591
|
+
var VERSION = "0.21.14";
|
|
21569
21592
|
|
|
21570
21593
|
// src/setup/hindsight-recall-passthrough.ts
|
|
21571
21594
|
var HINDSIGHT_RECALL_TAG_WEIGHT_SEED = Object.freeze({ sidechain: 0.8 });
|
|
@@ -29041,6 +29064,21 @@ var ROLLOUT_PROBE_TIMEOUT_MS = envMs("SWITCHROOM_ROLLOUT_PROBE_TIMEOUT_MS", 60 *
|
|
|
29041
29064
|
import { chownSync as chownSync3 } from "node:fs";
|
|
29042
29065
|
import { mkdir, readFile, writeFile, rename, copyFile } from "node:fs/promises";
|
|
29043
29066
|
init_merge();
|
|
29067
|
+
|
|
29068
|
+
// src/agents/singleton-reconcile.ts
|
|
29069
|
+
var import_yaml4 = __toESM(require_dist(), 1);
|
|
29070
|
+
var CORE_SINGLETON_SERVICES = [
|
|
29071
|
+
"vault-broker",
|
|
29072
|
+
"approval-kernel",
|
|
29073
|
+
"switchroom-auth-broker"
|
|
29074
|
+
];
|
|
29075
|
+
var VOICE_SIDECAR_SERVICE = "voice-sidecar";
|
|
29076
|
+
var SINGLETON_SERVICES = [
|
|
29077
|
+
...CORE_SINGLETON_SERVICES,
|
|
29078
|
+
VOICE_SIDECAR_SERVICE
|
|
29079
|
+
];
|
|
29080
|
+
|
|
29081
|
+
// src/cli/write-compose.ts
|
|
29044
29082
|
var AGENT_IMAGE_TAG_RE = /image:\s*\S*switchroom-agent:(\S+)/;
|
|
29045
29083
|
var BACKUP_SUFFIX = ".bak";
|
|
29046
29084
|
function composeBackupPath(composePath) {
|
|
@@ -29154,19 +29192,6 @@ var MAX_BYTES = 10 * 1024 * 1024;
|
|
|
29154
29192
|
// src/agents/docker-fleet.ts
|
|
29155
29193
|
init_loader();
|
|
29156
29194
|
|
|
29157
|
-
// src/agents/singleton-reconcile.ts
|
|
29158
|
-
var import_yaml4 = __toESM(require_dist(), 1);
|
|
29159
|
-
var CORE_SINGLETON_SERVICES = [
|
|
29160
|
-
"vault-broker",
|
|
29161
|
-
"approval-kernel",
|
|
29162
|
-
"switchroom-auth-broker"
|
|
29163
|
-
];
|
|
29164
|
-
var VOICE_SIDECAR_SERVICE = "voice-sidecar";
|
|
29165
|
-
var SINGLETON_SERVICES = [
|
|
29166
|
-
...CORE_SINGLETON_SERVICES,
|
|
29167
|
-
VOICE_SIDECAR_SERVICE
|
|
29168
|
-
];
|
|
29169
|
-
|
|
29170
29195
|
// src/agents/lifecycle.ts
|
|
29171
29196
|
function containerName(name) {
|
|
29172
29197
|
return `switchroom-${name}`;
|
|
@@ -4434,7 +4434,7 @@ var init_observation_scopes = __esm(() => {
|
|
|
4434
4434
|
});
|
|
4435
4435
|
|
|
4436
4436
|
// src/config/schema.ts
|
|
4437
|
-
var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, ObservationScopesSchema, ObservationScopeStrategySchema, AntiConfabulationDirectiveSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, BuzzChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleServiceTokenSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, releaseBlockFields, ReleaseBlock, RootReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, ConfigRepoConfigSchema, SwitchroomConfigSchema;
|
|
4437
|
+
var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, ObservationScopesSchema, ObservationScopeStrategySchema, AntiConfabulationDirectiveSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, BuzzChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleServiceTokenSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, releaseBlockFields, ReleaseBlock, RootReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, ScratchConfigSchema, DiskConfigSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, ConfigRepoConfigSchema, SwitchroomConfigSchema;
|
|
4438
4438
|
var init_schema = __esm(() => {
|
|
4439
4439
|
init_zod();
|
|
4440
4440
|
init_observation_scopes();
|
|
@@ -5307,6 +5307,27 @@ var init_schema = __esm(() => {
|
|
|
5307
5307
|
notify_on_detect: exports_external.boolean().default(false).describe("KEN-129 — operator-in-the-loop update prompt. Only consulted " + "when apply_on_detect is false (auto-apply supersedes notify): " + "a newly detected release posts ONE operator approval card " + "('fleet is behind — tap to apply') via an admin agent's " + "gateway; Approve runs hostd's update_apply path (fleet-" + "mutation-locked, durable status rows, get_status-pollable). " + "Dedup on release id: the last-notified id persists in " + "~/.switchroom/release-notify-state.json, so a card that " + "reached the operator is never re-posted for the same release."),
|
|
5308
5308
|
image_ref: exports_external.string().default("ghcr.io/switchroom/switchroom-agent:latest").describe("Image reference whose remote digest is compared to the local " + "image digest. Defaults to the agent image's :latest tag, which " + "is the canonical signal that a release has been promoted.")
|
|
5309
5309
|
});
|
|
5310
|
+
ScratchConfigSchema = exports_external.object({
|
|
5311
|
+
enabled: exports_external.boolean().default(true).describe("Whether agents get a scratch mount at /scratch with their package " + "caches redirected there. Default: true — but the feature only " + "engages when `volume` actually exists on the host, so a single-disk " + "machine is unaffected. Set false to opt out even where it does."),
|
|
5312
|
+
volume: exports_external.string().refine((v) => v.startsWith("/"), {
|
|
5313
|
+
message: "scratch.volume must be an absolute host path"
|
|
5314
|
+
}).default("/mnt/bulkdata").describe("Absolute host path of the bulk device's mountpoint. MUST already " + "exist — its presence is the single probe that turns the feature on, " + "so a typo degrades to the pre-existing behaviour rather than " + "quietly relocating caches somewhere that is still the root disk. " + "Default: /mnt/bulkdata."),
|
|
5315
|
+
subdir: exports_external.string().refine((v) => !v.startsWith("/") && !v.split("/").includes(".."), {
|
|
5316
|
+
message: "scratch.subdir must be a relative path without '..' segments"
|
|
5317
|
+
}).default("switchroom/scratch").describe("Relative path under `volume` holding the per-agent scratch " + "directories (`<volume>/<subdir>/<agent>`). Default: " + "switchroom/scratch.")
|
|
5318
|
+
});
|
|
5319
|
+
DiskConfigSchema = exports_external.object({
|
|
5320
|
+
warn_pct: exports_external.number().int().min(1).max(99).default(80).describe("Used-percentage at or above which `switchroom doctor` WARNs about " + "the filesystem holding the agents directory. Default 80 — the " + "reference fleet was at 85% when the condition was found by hand."),
|
|
5321
|
+
fail_pct: exports_external.number().int().min(2).max(100).default(90).describe("Used-percentage at or above which `switchroom doctor` FAILs. Must " + "be greater than `warn_pct`. Default 90."),
|
|
5322
|
+
reap_report: exports_external.object({
|
|
5323
|
+
enabled: exports_external.boolean().default(true).describe("Whether doctor checks that the report-only worktree sweep " + "(`switchroom worktree reap-report --append <file>`) is actually " + "running. Set false on a host that deliberately does not run it."),
|
|
5324
|
+
log: exports_external.string().min(1).default("/var/log/switchroom/reap-report.jsonl").describe("Path of the JSONL evidence log the scheduled `worktree " + "reap-report --append` writes. Doctor reads the newest record's " + "`generatedAt` — it detects the sweep by its OUTPUT, so it is " + "agnostic about whether an operator crontab, /etc/cron.d, or a " + "systemd timer drives it. Default " + "/var/log/switchroom/reap-report.jsonl (the path documented in " + "docs/operators/worktree-gc.md)."),
|
|
5325
|
+
max_age_hours: exports_external.number().int().min(1).max(720).default(48).describe("How old the newest record in `log` may be before doctor WARNs " + "that the sweep has stopped running. Default 48 — twice the " + "documented daily cadence, so a single missed run is not noise.")
|
|
5326
|
+
}).default({}).describe("Liveness check for the report-only worktree sweep. REPORT-ONLY by " + "construction: doctor reads an evidence log and never invokes any " + "reclaim path.")
|
|
5327
|
+
}).refine((v) => v.fail_pct > v.warn_pct, {
|
|
5328
|
+
message: "disk.fail_pct must be greater than disk.warn_pct",
|
|
5329
|
+
path: ["fail_pct"]
|
|
5330
|
+
});
|
|
5310
5331
|
HostControlConfigSchema = exports_external.object({
|
|
5311
5332
|
enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip — the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` — " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C §5.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3)."),
|
|
5312
5333
|
auto_release_check: AutoReleaseCheckSchema.default({}).describe("Pull-based release-triggered fleet restart (#1743). hostd polls " + "the remote release tag on a fixed interval and applies + " + "restarts the fleet (graceful) when a new release is detected. " + "Opt-in: default enabled=false.")
|
|
@@ -5381,6 +5402,8 @@ var init_schema = __esm(() => {
|
|
|
5381
5402
|
microsoft_workspace: MicrosoftWorkspaceConfigSchema.describe("RFC #1873 (Microsoft 365 integration). Top-level Microsoft Workspace " + "configuration — OAuth client credentials (Entra app), authority " + "endpoint (defaults to /common for personal MSA + work), and the " + "org_mode opt-in for Teams/SharePoint surfaces. Block is optional; " + "when omitted the broker does not register the Microsoft provider."),
|
|
5382
5403
|
notion_workspace: NotionWorkspaceConfigSchema.describe("RFC reference/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."),
|
|
5383
5404
|
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."),
|
|
5405
|
+
scratch: ScratchConfigSchema.optional().describe("Per-agent scratch volume. Relocates every agent's build/package " + "caches (uv, npm, bun, playwright, puppeteer, pip user-site) off the " + "root disk onto a bulk device, bind-mounted at /scratch inside each " + "container. Framework-injected for EVERY agent — not routed through " + "the admin-only `bind_mounts:` escalation, because the biggest cache " + "consumers are ordinary non-admin agents. Omit the block to accept " + "defaults; the feature is a no-op unless `scratch.volume` exists on " + "the host."),
|
|
5406
|
+
disk: DiskConfigSchema.default({}).describe("Root-disk headroom thresholds for `switchroom doctor`. Measured " + "against the filesystem holding the agents directory (and the scratch " + "volume when that feature is engaged), not against `/`. Omit the block " + "to accept the defaults (WARN at 80% used, FAIL at 90%)."),
|
|
5384
5407
|
host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (reference/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)."),
|
|
5385
5408
|
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)."),
|
|
5386
5409
|
fleet_health: FleetHealthConfigSchema.default({}).describe("Fleet Health — job-spec-anchored, operator-facing issue tracker (RFC " + "fleet-health.md, serves fleet-stays-healthy). Assigns the owner agent " + "that runs the nightly model-free sensor + weekly deep-dive. Default " + "unset owner_agent → inert; the admin page renders an empty state."),
|
|
@@ -4030,7 +4030,7 @@ var init_observation_scopes = __esm(() => {
|
|
|
4030
4030
|
});
|
|
4031
4031
|
|
|
4032
4032
|
// src/config/schema.ts
|
|
4033
|
-
var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, ObservationScopesSchema, ObservationScopeStrategySchema, AntiConfabulationDirectiveSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, BuzzChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleServiceTokenSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, releaseBlockFields, ReleaseBlock, RootReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, ConfigRepoConfigSchema, SwitchroomConfigSchema;
|
|
4033
|
+
var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, ObservationScopesSchema, ObservationScopeStrategySchema, AntiConfabulationDirectiveSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, BuzzChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleServiceTokenSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, releaseBlockFields, ReleaseBlock, RootReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, ScratchConfigSchema, DiskConfigSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, ConfigRepoConfigSchema, SwitchroomConfigSchema;
|
|
4034
4034
|
var init_schema = __esm(() => {
|
|
4035
4035
|
init_zod();
|
|
4036
4036
|
init_observation_scopes();
|
|
@@ -4903,6 +4903,27 @@ var init_schema = __esm(() => {
|
|
|
4903
4903
|
notify_on_detect: exports_external.boolean().default(false).describe("KEN-129 — operator-in-the-loop update prompt. Only consulted " + "when apply_on_detect is false (auto-apply supersedes notify): " + "a newly detected release posts ONE operator approval card " + "('fleet is behind — tap to apply') via an admin agent's " + "gateway; Approve runs hostd's update_apply path (fleet-" + "mutation-locked, durable status rows, get_status-pollable). " + "Dedup on release id: the last-notified id persists in " + "~/.switchroom/release-notify-state.json, so a card that " + "reached the operator is never re-posted for the same release."),
|
|
4904
4904
|
image_ref: exports_external.string().default("ghcr.io/switchroom/switchroom-agent:latest").describe("Image reference whose remote digest is compared to the local " + "image digest. Defaults to the agent image's :latest tag, which " + "is the canonical signal that a release has been promoted.")
|
|
4905
4905
|
});
|
|
4906
|
+
ScratchConfigSchema = exports_external.object({
|
|
4907
|
+
enabled: exports_external.boolean().default(true).describe("Whether agents get a scratch mount at /scratch with their package " + "caches redirected there. Default: true — but the feature only " + "engages when `volume` actually exists on the host, so a single-disk " + "machine is unaffected. Set false to opt out even where it does."),
|
|
4908
|
+
volume: exports_external.string().refine((v) => v.startsWith("/"), {
|
|
4909
|
+
message: "scratch.volume must be an absolute host path"
|
|
4910
|
+
}).default("/mnt/bulkdata").describe("Absolute host path of the bulk device's mountpoint. MUST already " + "exist — its presence is the single probe that turns the feature on, " + "so a typo degrades to the pre-existing behaviour rather than " + "quietly relocating caches somewhere that is still the root disk. " + "Default: /mnt/bulkdata."),
|
|
4911
|
+
subdir: exports_external.string().refine((v) => !v.startsWith("/") && !v.split("/").includes(".."), {
|
|
4912
|
+
message: "scratch.subdir must be a relative path without '..' segments"
|
|
4913
|
+
}).default("switchroom/scratch").describe("Relative path under `volume` holding the per-agent scratch " + "directories (`<volume>/<subdir>/<agent>`). Default: " + "switchroom/scratch.")
|
|
4914
|
+
});
|
|
4915
|
+
DiskConfigSchema = exports_external.object({
|
|
4916
|
+
warn_pct: exports_external.number().int().min(1).max(99).default(80).describe("Used-percentage at or above which `switchroom doctor` WARNs about " + "the filesystem holding the agents directory. Default 80 — the " + "reference fleet was at 85% when the condition was found by hand."),
|
|
4917
|
+
fail_pct: exports_external.number().int().min(2).max(100).default(90).describe("Used-percentage at or above which `switchroom doctor` FAILs. Must " + "be greater than `warn_pct`. Default 90."),
|
|
4918
|
+
reap_report: exports_external.object({
|
|
4919
|
+
enabled: exports_external.boolean().default(true).describe("Whether doctor checks that the report-only worktree sweep " + "(`switchroom worktree reap-report --append <file>`) is actually " + "running. Set false on a host that deliberately does not run it."),
|
|
4920
|
+
log: exports_external.string().min(1).default("/var/log/switchroom/reap-report.jsonl").describe("Path of the JSONL evidence log the scheduled `worktree " + "reap-report --append` writes. Doctor reads the newest record's " + "`generatedAt` — it detects the sweep by its OUTPUT, so it is " + "agnostic about whether an operator crontab, /etc/cron.d, or a " + "systemd timer drives it. Default " + "/var/log/switchroom/reap-report.jsonl (the path documented in " + "docs/operators/worktree-gc.md)."),
|
|
4921
|
+
max_age_hours: exports_external.number().int().min(1).max(720).default(48).describe("How old the newest record in `log` may be before doctor WARNs " + "that the sweep has stopped running. Default 48 — twice the " + "documented daily cadence, so a single missed run is not noise.")
|
|
4922
|
+
}).default({}).describe("Liveness check for the report-only worktree sweep. REPORT-ONLY by " + "construction: doctor reads an evidence log and never invokes any " + "reclaim path.")
|
|
4923
|
+
}).refine((v) => v.fail_pct > v.warn_pct, {
|
|
4924
|
+
message: "disk.fail_pct must be greater than disk.warn_pct",
|
|
4925
|
+
path: ["fail_pct"]
|
|
4926
|
+
});
|
|
4906
4927
|
HostControlConfigSchema = exports_external.object({
|
|
4907
4928
|
enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip — the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` — " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C §5.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3)."),
|
|
4908
4929
|
auto_release_check: AutoReleaseCheckSchema.default({}).describe("Pull-based release-triggered fleet restart (#1743). hostd polls " + "the remote release tag on a fixed interval and applies + " + "restarts the fleet (graceful) when a new release is detected. " + "Opt-in: default enabled=false.")
|
|
@@ -4977,6 +4998,8 @@ var init_schema = __esm(() => {
|
|
|
4977
4998
|
microsoft_workspace: MicrosoftWorkspaceConfigSchema.describe("RFC #1873 (Microsoft 365 integration). Top-level Microsoft Workspace " + "configuration — OAuth client credentials (Entra app), authority " + "endpoint (defaults to /common for personal MSA + work), and the " + "org_mode opt-in for Teams/SharePoint surfaces. Block is optional; " + "when omitted the broker does not register the Microsoft provider."),
|
|
4978
4999
|
notion_workspace: NotionWorkspaceConfigSchema.describe("RFC reference/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."),
|
|
4979
5000
|
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."),
|
|
5001
|
+
scratch: ScratchConfigSchema.optional().describe("Per-agent scratch volume. Relocates every agent's build/package " + "caches (uv, npm, bun, playwright, puppeteer, pip user-site) off the " + "root disk onto a bulk device, bind-mounted at /scratch inside each " + "container. Framework-injected for EVERY agent — not routed through " + "the admin-only `bind_mounts:` escalation, because the biggest cache " + "consumers are ordinary non-admin agents. Omit the block to accept " + "defaults; the feature is a no-op unless `scratch.volume` exists on " + "the host."),
|
|
5002
|
+
disk: DiskConfigSchema.default({}).describe("Root-disk headroom thresholds for `switchroom doctor`. Measured " + "against the filesystem holding the agents directory (and the scratch " + "volume when that feature is engaged), not against `/`. Omit the block " + "to accept the defaults (WARN at 80% used, FAIL at 90%)."),
|
|
4980
5003
|
host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (reference/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)."),
|
|
4981
5004
|
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)."),
|
|
4982
5005
|
fleet_health: FleetHealthConfigSchema.default({}).describe("Fleet Health — job-spec-anchored, operator-facing issue tracker (RFC " + "fleet-health.md, serves fleet-stays-healthy). Assigns the owner agent " + "that runs the nightly model-free sensor + weekly deep-dive. Default " + "unset owner_agent → inert; the admin page renders an empty state."),
|
|
@@ -19622,7 +19645,7 @@ var init_resolver = __esm(() => {
|
|
|
19622
19645
|
|
|
19623
19646
|
// src/vault/broker/server.ts
|
|
19624
19647
|
import * as net from "node:net";
|
|
19625
|
-
import { mkdirSync as mkdirSync8, chmodSync as chmodSync5, chownSync as chownSync2, existsSync as existsSync13, readFileSync as readFileSync13, readdirSync as readdirSync5, statSync as
|
|
19648
|
+
import { mkdirSync as mkdirSync8, chmodSync as chmodSync5, chownSync as chownSync2, existsSync as existsSync13, readFileSync as readFileSync13, readdirSync as readdirSync5, statSync as statSync8, unlinkSync as unlinkSync6, writeFileSync as writeFileSync5 } from "node:fs";
|
|
19626
19649
|
|
|
19627
19650
|
// src/agents/compose.ts
|
|
19628
19651
|
init_schema();
|
|
@@ -21400,6 +21423,66 @@ function createAuditLogger(opts = {}) {
|
|
|
21400
21423
|
};
|
|
21401
21424
|
}
|
|
21402
21425
|
|
|
21426
|
+
// src/vault/broker/vault-refresh.ts
|
|
21427
|
+
init_vault();
|
|
21428
|
+
import { statSync as statSync7 } from "node:fs";
|
|
21429
|
+
function sameVaultStamp(a, b) {
|
|
21430
|
+
return a.mtimeNs === b.mtimeNs && a.size === b.size && a.ino === b.ino;
|
|
21431
|
+
}
|
|
21432
|
+
function readVaultStamp(vaultPath) {
|
|
21433
|
+
try {
|
|
21434
|
+
const st = statSync7(vaultPath, { bigint: true });
|
|
21435
|
+
return { mtimeNs: st.mtimeNs, size: st.size, ino: st.ino };
|
|
21436
|
+
} catch {
|
|
21437
|
+
return null;
|
|
21438
|
+
}
|
|
21439
|
+
}
|
|
21440
|
+
function zeroSecrets(secrets) {
|
|
21441
|
+
for (const [, entry] of Object.entries(secrets)) {
|
|
21442
|
+
try {
|
|
21443
|
+
if (entry.kind === "string" || entry.kind === "binary") {
|
|
21444
|
+
entry.value = "";
|
|
21445
|
+
}
|
|
21446
|
+
} catch {}
|
|
21447
|
+
}
|
|
21448
|
+
}
|
|
21449
|
+
function refreshVaultIfChanged(state, opts) {
|
|
21450
|
+
const { vaultPath, force = false } = opts;
|
|
21451
|
+
const open = opts.open ?? openVault;
|
|
21452
|
+
const warn = opts.warn ?? ((m) => {
|
|
21453
|
+
process.stderr.write(m);
|
|
21454
|
+
});
|
|
21455
|
+
const secrets = state.secrets;
|
|
21456
|
+
const passphrase = state.passphrase;
|
|
21457
|
+
if (secrets === null || passphrase === null)
|
|
21458
|
+
return state;
|
|
21459
|
+
const known = state.loadedStamp;
|
|
21460
|
+
if (known === null)
|
|
21461
|
+
return state;
|
|
21462
|
+
const current = readVaultStamp(vaultPath);
|
|
21463
|
+
if (current === null)
|
|
21464
|
+
return state;
|
|
21465
|
+
if (!force && sameVaultStamp(current, known))
|
|
21466
|
+
return state;
|
|
21467
|
+
if (!force && state.failedStamp !== null && sameVaultStamp(state.failedStamp, current)) {
|
|
21468
|
+
return state;
|
|
21469
|
+
}
|
|
21470
|
+
opts.beforeOpen?.(vaultPath);
|
|
21471
|
+
let next;
|
|
21472
|
+
try {
|
|
21473
|
+
next = open(passphrase, vaultPath);
|
|
21474
|
+
} catch (err) {
|
|
21475
|
+
const alreadyWarned = state.failedStamp !== null && sameVaultStamp(state.failedStamp, current);
|
|
21476
|
+
if (!alreadyWarned) {
|
|
21477
|
+
warn(`[vault-broker] WARNING: ${vaultPath} changed on disk but ` + `could not be re-opened (${err?.message ?? "unknown error"}) — ` + `continuing to serve the previously loaded secrets; will retry when ` + `the file changes again
|
|
21478
|
+
`);
|
|
21479
|
+
}
|
|
21480
|
+
return { ...state, failedStamp: current };
|
|
21481
|
+
}
|
|
21482
|
+
zeroSecrets(secrets);
|
|
21483
|
+
return { secrets: next, passphrase, loadedStamp: current, failedStamp: null };
|
|
21484
|
+
}
|
|
21485
|
+
|
|
21403
21486
|
// src/vault/grants.ts
|
|
21404
21487
|
import { createHash as createHash5, randomBytes as randomBytes4 } from "node:crypto";
|
|
21405
21488
|
|
|
@@ -23806,6 +23889,8 @@ class VaultBroker {
|
|
|
23806
23889
|
testOpts;
|
|
23807
23890
|
secrets = null;
|
|
23808
23891
|
passphrase = null;
|
|
23892
|
+
vaultStamp = null;
|
|
23893
|
+
failedVaultStamp = null;
|
|
23809
23894
|
config = null;
|
|
23810
23895
|
startedAt = Date.now();
|
|
23811
23896
|
server = null;
|
|
@@ -23900,13 +23985,53 @@ class VaultBroker {
|
|
|
23900
23985
|
}
|
|
23901
23986
|
unlockFromPassphrase(passphrase) {
|
|
23902
23987
|
detectVaultLayoutDrift(this.vaultPath);
|
|
23988
|
+
const stamp = this._readVaultStamp();
|
|
23903
23989
|
const secrets = openVault(passphrase, this.vaultPath);
|
|
23904
23990
|
this.secrets = secrets;
|
|
23905
23991
|
this.passphrase = passphrase;
|
|
23992
|
+
this.vaultStamp = stamp;
|
|
23993
|
+
this.failedVaultStamp = null;
|
|
23906
23994
|
this._setReadinessSentinel(true);
|
|
23907
23995
|
}
|
|
23996
|
+
_readVaultStamp() {
|
|
23997
|
+
return readVaultStamp(this.vaultPath);
|
|
23998
|
+
}
|
|
23999
|
+
_reloadSecretsIfVaultChanged(force = false) {
|
|
24000
|
+
let next;
|
|
24001
|
+
try {
|
|
24002
|
+
next = refreshVaultIfChanged({
|
|
24003
|
+
secrets: this.secrets,
|
|
24004
|
+
passphrase: this.passphrase,
|
|
24005
|
+
loadedStamp: this.vaultStamp,
|
|
24006
|
+
failedStamp: this.failedVaultStamp
|
|
24007
|
+
}, { vaultPath: this.vaultPath, force, beforeOpen: detectVaultLayoutDrift });
|
|
24008
|
+
} catch (err) {
|
|
24009
|
+
this._failClosedOnVaultDrift(err);
|
|
24010
|
+
return;
|
|
24011
|
+
}
|
|
24012
|
+
this.secrets = next.secrets;
|
|
24013
|
+
this.vaultStamp = next.loadedStamp;
|
|
24014
|
+
this.failedVaultStamp = next.failedStamp;
|
|
24015
|
+
}
|
|
24016
|
+
_failClosedOnVaultDrift(err) {
|
|
24017
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
24018
|
+
process.stderr.write(`[vault-broker] FATAL: ${detail}
|
|
24019
|
+
[vault-broker] Refusing to serve secrets from a vault whose on-disk ` + `layout has diverged — locking. Fix the layout and restart the broker ` + `(\`switchroom apply\`); see docs/operators/state-e-recovery.md.
|
|
24020
|
+
`);
|
|
24021
|
+
try {
|
|
24022
|
+
this.auditLogger.write({
|
|
24023
|
+
ts: new Date().toISOString(),
|
|
24024
|
+
op: "lock",
|
|
24025
|
+
caller: `pid:${process.pid}`,
|
|
24026
|
+
pid: process.pid,
|
|
24027
|
+
result: "error:vault-layout-drift"
|
|
24028
|
+
});
|
|
24029
|
+
} catch {}
|
|
24030
|
+
this.lock();
|
|
24031
|
+
}
|
|
23908
24032
|
reload(config) {
|
|
23909
24033
|
this.config = config;
|
|
24034
|
+
this._reloadSecretsIfVaultChanged(true);
|
|
23910
24035
|
}
|
|
23911
24036
|
_setReadinessSentinel(ready) {
|
|
23912
24037
|
const p = process.env.SWITCHROOM_VAULT_BROKER_READY_PATH;
|
|
@@ -23922,15 +24047,11 @@ class VaultBroker {
|
|
|
23922
24047
|
}
|
|
23923
24048
|
lock() {
|
|
23924
24049
|
if (this.secrets !== null) {
|
|
23925
|
-
|
|
23926
|
-
try {
|
|
23927
|
-
if (entry.kind === "string" || entry.kind === "binary") {
|
|
23928
|
-
entry.value = "";
|
|
23929
|
-
}
|
|
23930
|
-
} catch {}
|
|
23931
|
-
}
|
|
24050
|
+
zeroSecrets(this.secrets);
|
|
23932
24051
|
this.secrets = null;
|
|
23933
24052
|
}
|
|
24053
|
+
this.vaultStamp = null;
|
|
24054
|
+
this.failedVaultStamp = null;
|
|
23934
24055
|
this.passphrase = null;
|
|
23935
24056
|
this._setReadinessSentinel(false);
|
|
23936
24057
|
}
|
|
@@ -24250,6 +24371,7 @@ class VaultBroker {
|
|
|
24250
24371
|
socket.write(encodeResponse(errorResponse("DENIED", "preflight_access is operator-only")));
|
|
24251
24372
|
return;
|
|
24252
24373
|
}
|
|
24374
|
+
this._reloadSecretsIfVaultChanged();
|
|
24253
24375
|
if (this.secrets === null) {
|
|
24254
24376
|
socket.write(encodeResponse(errorResponse("LOCKED", "Vault is locked")));
|
|
24255
24377
|
return;
|
|
@@ -24281,6 +24403,7 @@ class VaultBroker {
|
|
|
24281
24403
|
return;
|
|
24282
24404
|
}
|
|
24283
24405
|
if (req.op === "list") {
|
|
24406
|
+
this._reloadSecretsIfVaultChanged();
|
|
24284
24407
|
if (this.secrets === null) {
|
|
24285
24408
|
socket.write(encodeResponse(errorResponse("LOCKED", "Vault is locked")));
|
|
24286
24409
|
return;
|
|
@@ -24356,6 +24479,7 @@ class VaultBroker {
|
|
|
24356
24479
|
return;
|
|
24357
24480
|
}
|
|
24358
24481
|
if (req.op === "get") {
|
|
24482
|
+
this._reloadSecretsIfVaultChanged();
|
|
24359
24483
|
if (this.secrets === null) {
|
|
24360
24484
|
socket.write(encodeResponse(errorResponse("LOCKED", "Vault is locked")));
|
|
24361
24485
|
return;
|
|
@@ -24543,10 +24667,24 @@ class VaultBroker {
|
|
|
24543
24667
|
return;
|
|
24544
24668
|
}
|
|
24545
24669
|
if (req.op === "put") {
|
|
24670
|
+
this._reloadSecretsIfVaultChanged();
|
|
24546
24671
|
if (this.secrets === null || this.passphrase === null) {
|
|
24547
24672
|
socket.write(encodeResponse(errorResponse("LOCKED", "Vault is locked")));
|
|
24548
24673
|
return;
|
|
24549
24674
|
}
|
|
24675
|
+
if (this.failedVaultStamp !== null) {
|
|
24676
|
+
writeAudit({
|
|
24677
|
+
ts: new Date().toISOString(),
|
|
24678
|
+
op: "put",
|
|
24679
|
+
key: req.key,
|
|
24680
|
+
caller: auditCaller,
|
|
24681
|
+
pid: auditPid,
|
|
24682
|
+
cgroup: auditCgroup,
|
|
24683
|
+
result: "denied:vault-file-unreadable"
|
|
24684
|
+
});
|
|
24685
|
+
socket.write(encodeResponse(errorResponse("INTERNAL", "put refused: the vault file on disk changed and could not be decrypted with the passphrase this broker holds. Writing now would overwrite it with the broker's older in-memory state. Restore the vault file the broker was unlocked with, or restart the broker and unlock it with the current passphrase (reads keep serving the previously loaded secrets meanwhile).")));
|
|
24686
|
+
return;
|
|
24687
|
+
}
|
|
24550
24688
|
let passphraseAttested = false;
|
|
24551
24689
|
const requestedPostureAttest = req.attest_via_posture === true;
|
|
24552
24690
|
if (requestedPostureAttest && req.passphrase !== undefined && req.passphrase !== "") {
|
|
@@ -24790,6 +24928,8 @@ class VaultBroker {
|
|
|
24790
24928
|
return;
|
|
24791
24929
|
}
|
|
24792
24930
|
this.chownVaultToOperator();
|
|
24931
|
+
this.vaultStamp = this._readVaultStamp();
|
|
24932
|
+
this.failedVaultStamp = null;
|
|
24793
24933
|
this.auditLogger.write({
|
|
24794
24934
|
ts: new Date().toISOString(),
|
|
24795
24935
|
op: "put",
|
|
@@ -25365,7 +25505,7 @@ class VaultBroker {
|
|
|
25365
25505
|
if (!existsSync13(filePath))
|
|
25366
25506
|
return false;
|
|
25367
25507
|
try {
|
|
25368
|
-
if (
|
|
25508
|
+
if (statSync8(filePath).size === 0)
|
|
25369
25509
|
return false;
|
|
25370
25510
|
} catch {
|
|
25371
25511
|
return false;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchroom",
|
|
3
3
|
"//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
|
|
4
|
-
"version": "0.21.
|
|
4
|
+
"version": "0.21.14",
|
|
5
5
|
"description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -1,92 +1,38 @@
|
|
|
1
1
|
## Self-service: scheduled tasks (cron) and config introspection
|
|
2
2
|
|
|
3
|
-
You have an **`agent-config`** MCP server
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
tools
|
|
3
|
+
You have an **`agent-config`** MCP server for inspecting your own config and
|
|
4
|
+
creating/removing your own scheduled tasks — don't hand the user a yaml
|
|
5
|
+
snippet, do the edit yourself.
|
|
6
|
+
|
|
7
|
+
- Write tools: `schedule_add` (e.g. `cron_expr: "0 17 * * 0"` for "call mom
|
|
8
|
+
every Sunday at 5pm", or `"0 8 * * 1-5"` for a weekday-morning digest) /
|
|
9
|
+
`schedule_remove`; `skill_install` (`source: "bundled:<name>"`) /
|
|
10
|
+
`skill_remove`; and the personal-skill set `skill_init_personal` /
|
|
11
|
+
`skill_clone_to_personal` / `skill_edit_personal` / `skill_remove_personal`.
|
|
12
|
+
If YOU find a bug in a skill you're using, fork-and-fix is your OWN call,
|
|
13
|
+
not the operator's: `skill_clone_to_personal` then `skill_edit_personal`,
|
|
14
|
+
then use `personal-<name>`.
|
|
15
|
+
- Read tools: `cron_list`, `cron_doctor`, `skill_list`, `skill_list_personal`,
|
|
16
|
+
`skill_search` (answers "is there a skill for X?"), `config_get`, `whoami`,
|
|
17
|
+
`audit_tail`, `peers_list` (live — never cache or memorize the fleet into
|
|
18
|
+
memory).
|
|
19
|
+
- Safety rails the broker hard-rejects, so know them before promising
|
|
20
|
+
anything: **5-minute minimum interval** (offer `*/5`, `*/15`, or `*/30`
|
|
21
|
+
instead of anything faster) and **20 entries** each for schedules and
|
|
22
|
+
skills — check `cron_list` / `skill_list` first if you're near a cap.
|
|
23
|
+
These numbers live only in the broker's rejection messages, not the tool
|
|
24
|
+
schema — know them before the call, not after the error. No `secrets:` on
|
|
25
|
+
agent-authored cron entries — go through `vault_request_access` at
|
|
26
|
+
runtime instead. You manage only your OWN schedule/skills — cross-agent
|
|
27
|
+
writes are refused; for another agent, point the user to it via
|
|
28
|
+
`peers_list`.
|
|
29
|
+
- After a successful `schedule_add`, tell the user the human-readable
|
|
30
|
+
schedule, the cron expression you actually wrote, and the `name` slug (so
|
|
31
|
+
they can remove it later). After a failed write, surface the error
|
|
32
|
+
verbatim and the closest legal alternative.
|
|
33
|
+
- Cron-fired sessions render rich markdown, so scheduled/digest output should
|
|
34
|
+
use **bold** headers and `code` for identifiers, just like a live reply.
|
|
8
35
|
|
|
9
|
-
### When to reach for these tools
|
|
10
|
-
|
|
11
|
-
| User says… | You call… |
|
|
12
|
-
|---|---|
|
|
13
|
-
| "remind me to call mom every Sunday at 5pm" | `schedule_add` with `cron_expr: "0 17 * * 0"` |
|
|
14
|
-
| "run the morning digest at 8am every weekday" | `schedule_add` with `cron_expr: "0 8 * * 1-5"` |
|
|
15
|
-
| "check the build every 15 minutes" | `schedule_add` with `cron_expr: "*/15 * * * *"` (legal — 15min ≥ the 5-min floor) |
|
|
16
|
-
| "ping me every 2 minutes" | rejected by the 5-min floor — offer `*/5` or `*/10` instead |
|
|
17
|
-
| "stop the daily digest" | `cron_list` to find the entry, then `schedule_remove` by `name` |
|
|
18
|
-
| "what tasks am I running on a schedule?" | `cron_list` |
|
|
19
|
-
| "what skills do I have access to?" | `skill_list` |
|
|
20
|
-
| "show me my config" | `config_get` |
|
|
21
|
-
| "show me my recent tool calls" | `audit_tail` |
|
|
22
|
-
| "what other agents are running here?" / "is there an agent that does X?" / "who handles Y?" | `peers_list` |
|
|
23
|
-
| "install the foo skill" / "give yourself the foo skill" | `skill_install` with `source: "bundled:foo"` |
|
|
24
|
-
| "drop the foo skill" / "remove the foo skill" | `skill_remove` with `name: "foo"` |
|
|
25
|
-
| "is there a skill for X?" | `skill_search` with `query: "X"` |
|
|
26
|
-
| **YOU find a bug in a skill you're using** | `skill_clone_to_personal` then `skill_edit_personal` — fork-and-fix yourself |
|
|
27
|
-
| "write me a custom skill that does X" | `skill_init_personal` |
|
|
28
|
-
|
|
29
|
-
The tool schemas carry the parameters, enums, and error codes — read them
|
|
30
|
-
there. What follows is only what you need *before* the call, because it
|
|
31
|
-
changes the advice you give the user.
|
|
32
|
-
|
|
33
|
-
### Writing a cron prompt
|
|
34
|
-
|
|
35
|
-
`schedule_add`'s `prompt` is what *you* receive when the cron fires — phrase it
|
|
36
|
-
from your future-self's perspective (`"Time for the daily digest — pull
|
|
37
|
-
yesterday's GitHub activity and DM the summary to chat 12345"`, not `"please
|
|
38
|
-
send the digest"`). A new entry takes effect within **~30s**; the scheduler
|
|
39
|
-
hot-reloads, no restart needed.
|
|
40
|
-
|
|
41
|
-
### Mind the cost — pick the cheapest tier that does the job
|
|
42
|
-
|
|
43
|
-
- **Tier 1 — cheap fresh session.** The default for a **frequent** cron (≤60min):
|
|
44
|
-
a fresh Sonnet that keeps your memory + tools but drops your conversation
|
|
45
|
-
context. Force it for a daily/weekly self-contained job with `model:
|
|
46
|
-
"sonnet"` / `context: "fresh"`.
|
|
47
|
-
- **Tier 2 — a full turn in your live session.** The default for **daily/weekly**
|
|
48
|
-
crons. `context: "agent"` pins a fire here when it genuinely needs your
|
|
49
|
-
accumulated context (this always wins).
|
|
50
|
-
- **Model-free tiers — you must REQUEST these from the operator** (they need a
|
|
51
|
-
config commit; you can only self-author plain prompt crons plus the
|
|
52
|
-
`model`/`context` hints): **`kind: action`** posts a FIXED text at zero
|
|
53
|
-
tokens (renders full GFM — set `parse_mode: text` for verbatim);
|
|
54
|
-
**`kind: poll`** is a model-free check where only a *change* wakes you —
|
|
55
|
-
don't burn a frequent prompt cron on polling; **`reaction_dispatch`** wakes
|
|
56
|
-
you instantly on an emoji reaction, zero polling.
|
|
57
|
-
|
|
58
|
-
Cron-fired sessions render rich markdown, so scheduled/digest output should use
|
|
59
|
-
**bold** headers and `code` for identifiers, just like a live reply.
|
|
60
|
-
|
|
61
|
-
### Safety rails — know these BEFORE you promise anything
|
|
62
|
-
|
|
63
|
-
The broker hard-rejects writes that violate these, so they shape the advice
|
|
64
|
-
you give — don't surprise the user with an error after agreeing to something
|
|
65
|
-
the rails will block:
|
|
66
|
-
|
|
67
|
-
- **Minimum 5-minute interval.** Anything more frequent (`* * * * *`, `*/2`,
|
|
68
|
-
`*/3`, `*/4`) is rejected. Offer `*/5`, `*/15`, `*/30`, or hourly instead.
|
|
69
|
-
- **20 schedule entries per agent.** Near the cap, `cron_list` first; if full,
|
|
70
|
-
ask the user which one to drop before adding.
|
|
71
|
-
- **20 skills per agent**, same shape.
|
|
72
|
-
- **No `secrets:` on agent-authored entries.** Cron fires the prompt; you go
|
|
73
|
-
through `vault_request_access` at runtime instead.
|
|
74
|
-
- **You manage only your OWN schedule and skills.** The broker pins identity
|
|
75
|
-
via `$SWITCHROOM_AGENT_NAME`; for another agent, tell the user to ask that
|
|
76
|
-
agent. `skill_remove` likewise won't remove skills the operator wrote
|
|
77
|
-
directly into `switchroom.yaml` — those are operator-only.
|
|
78
|
-
- **`peers_list` is live** — never cache or memorize the fleet into memory.
|
|
79
|
-
|
|
80
|
-
Fixing a skill you're using is your call, not the operator's:
|
|
81
|
-
`skill_clone_to_personal` → `skill_edit_personal` → use `personal-<name>`. The
|
|
82
|
-
fork is durable and auto-mirrors to `~/.switchroom-config/` when present.
|
|
83
|
-
|
|
84
|
-
### Confirm honestly
|
|
85
|
-
|
|
86
|
-
After a successful `schedule_add`, tell the user the human-readable schedule,
|
|
87
|
-
the cron expression you actually wrote, and the `name` slug (so they can
|
|
88
|
-
remove it later). After a failed write, surface the error verbatim, name
|
|
89
|
-
which rail tripped, and offer the closest legal alternative.
|
|
90
36
|
{{#if linearAgentEnabled}}
|
|
91
37
|
|
|
92
38
|
## Capture to Linear (👨💻 / 📌 reaction → new issue)
|