switchroom 0.18.11 → 0.18.12
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 +29 -5
- package/dist/auth-broker/index.js +53 -13
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/notion-write-pretool.mjs +29 -5
- package/dist/cli/switchroom.js +2453 -1293
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +504 -100
- package/dist/vault/approvals/kernel-server.js +53 -13
- package/dist/vault/broker/server.js +162 -114
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +73 -1
- package/telegram-plugin/dist/gateway/gateway.js +3602 -1007
- package/telegram-plugin/dist/server.js +74 -2
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/gateway/approval-hold.ts +583 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +76 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1482 -165
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +209 -2
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/send-gate-degraded.test.ts +443 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +698 -0
- package/telegram-plugin/send-gate.ts +982 -0
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +102 -2
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +296 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +284 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
|
@@ -15989,7 +15989,7 @@ var require_command = __commonJS((exports2) => {
|
|
|
15989
15989
|
var EventEmitter2 = __require("node:events").EventEmitter;
|
|
15990
15990
|
var childProcess = __require("node:child_process");
|
|
15991
15991
|
var path2 = __require("node:path");
|
|
15992
|
-
var
|
|
15992
|
+
var fs3 = __require("node:fs");
|
|
15993
15993
|
var process2 = __require("node:process");
|
|
15994
15994
|
var { Argument, humanReadableArgName } = require_argument();
|
|
15995
15995
|
var { CommanderError } = require_error();
|
|
@@ -16510,7 +16510,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
|
|
|
16510
16510
|
this.processedArgs = [];
|
|
16511
16511
|
}
|
|
16512
16512
|
_checkForMissingExecutable(executableFile, executableDir, subcommandName) {
|
|
16513
|
-
if (
|
|
16513
|
+
if (fs3.existsSync(executableFile))
|
|
16514
16514
|
return;
|
|
16515
16515
|
const executableDirMessage = executableDir ? `searched for local subcommand relative to directory '${executableDir}'` : "no directory for search for local subcommand, use .executableDir() to supply a custom directory";
|
|
16516
16516
|
const executableMissing = `'${executableFile}' does not exist
|
|
@@ -16525,11 +16525,11 @@ Expecting one of '${allowedValues.join("', '")}'`);
|
|
|
16525
16525
|
const sourceExt = [".js", ".ts", ".tsx", ".mjs", ".cjs"];
|
|
16526
16526
|
function findFile(baseDir, baseName) {
|
|
16527
16527
|
const localBin = path2.resolve(baseDir, baseName);
|
|
16528
|
-
if (
|
|
16528
|
+
if (fs3.existsSync(localBin))
|
|
16529
16529
|
return localBin;
|
|
16530
16530
|
if (sourceExt.includes(path2.extname(baseName)))
|
|
16531
16531
|
return;
|
|
16532
|
-
const foundExt = sourceExt.find((ext) =>
|
|
16532
|
+
const foundExt = sourceExt.find((ext) => fs3.existsSync(`${localBin}${ext}`));
|
|
16533
16533
|
if (foundExt)
|
|
16534
16534
|
return `${localBin}${foundExt}`;
|
|
16535
16535
|
return;
|
|
@@ -16541,7 +16541,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
|
|
|
16541
16541
|
if (this._scriptPath) {
|
|
16542
16542
|
let resolvedScriptPath;
|
|
16543
16543
|
try {
|
|
16544
|
-
resolvedScriptPath =
|
|
16544
|
+
resolvedScriptPath = fs3.realpathSync(this._scriptPath);
|
|
16545
16545
|
} catch {
|
|
16546
16546
|
resolvedScriptPath = this._scriptPath;
|
|
16547
16547
|
}
|
|
@@ -17316,7 +17316,7 @@ var require_commander = __commonJS((exports2) => {
|
|
|
17316
17316
|
|
|
17317
17317
|
// src/host-control/main.ts
|
|
17318
17318
|
import { homedir as homedir6 } from "node:os";
|
|
17319
|
-
import { existsSync as
|
|
17319
|
+
import { existsSync as existsSync12, readdirSync as readdirSync4 } from "node:fs";
|
|
17320
17320
|
import { join as join11, resolve as resolve9 } from "node:path";
|
|
17321
17321
|
|
|
17322
17322
|
// src/config/loader.ts
|
|
@@ -21399,7 +21399,12 @@ var ScheduleEntrySchema = exports_external.object({
|
|
|
21399
21399
|
var AgentSoulSchema = exports_external.object({
|
|
21400
21400
|
name: exports_external.string().describe("Agent persona name (e.g., 'Coach', 'Sage')"),
|
|
21401
21401
|
style: exports_external.string().describe("Communication style description"),
|
|
21402
|
-
|
|
21402
|
+
creature: exports_external.string().optional().describe("Persona creature/form (e.g., 'owl', 'octopus')"),
|
|
21403
|
+
vibe: exports_external.string().optional().describe("One-line personality vibe (e.g., 'calm, precise')"),
|
|
21404
|
+
expertise: exports_external.string().optional().describe("Domain expertise summary rendered into the persona"),
|
|
21405
|
+
emoji: exports_external.string().optional().describe("Signature emoji for the persona (e.g., '\uD83E\uDD89')"),
|
|
21406
|
+
boundaries: exports_external.string().optional().describe("Behavioral boundaries and disclaimers"),
|
|
21407
|
+
shape: exports_external.enum(["executive-assistant", "developer", "coach", "generalist"]).default("generalist").describe("Persona-shape discriminator the per-agent CLAUDE.md template can " + "branch on (e.g., 'you are an executive assistant' vs 'a senior " + "engineer'). No runtime behavior yet — template work lands in a " + "later issue. Cascade: override (per-agent wins over default).")
|
|
21403
21408
|
}).optional();
|
|
21404
21409
|
var AgentToolsSchema = exports_external.object({
|
|
21405
21410
|
allow: exports_external.array(exports_external.string()).default([]).describe("Allowed tools (use ['all'] for unrestricted)"),
|
|
@@ -21564,6 +21569,11 @@ var TelegramChannelSchema = exports_external.object({
|
|
|
21564
21569
|
safe_boundary: exports_external.boolean().optional().describe("When true (the default), a `!`-prefix interrupt that arrives while " + "the agent is mid-tool-call is DEFERRED: the SIGINT and the " + "replacement turn wait until the in-flight tool call finishes (a " + "clean boundary) instead of C-c'ing the agent mid-write/mid-bash. If " + "no tool is in flight the interrupt still fires immediately. Bounded " + "by max_wait_ms so a long tool never strands the user. Set false to " + "fire synchronously the moment `!` is received (historical " + "behaviour). Rapid repeated `!` while one is pending coalesce into a " + "single deferred interrupt carrying the latest body."),
|
|
21565
21570
|
max_wait_ms: exports_external.number().int().positive().optional().describe("Upper bound (ms) the gateway waits for a safe boundary before firing " + "a deferred `!` interrupt anyway. Only consulted when safe_boundary is " + "true. Default 8000. Keep it short — the user explicitly asked to " + "interrupt, so a long in-flight tool shouldn't ghost them; the cap " + "trades a tiny risk of a mid-tool C-c for a guaranteed response.")
|
|
21566
21571
|
}).optional().describe("Interrupt timing — how a `!`-prefix interrupt behaves when it lands " + "mid-tool-call. Off by default (fire immediately). Cascades from " + "defaults.channels.telegram.interrupt."),
|
|
21572
|
+
button_choice_confirmation: exports_external.object({
|
|
21573
|
+
enabled: exports_external.boolean().default(false).describe("When true, tapping an agent-emitted inline_keyboard button annotates " + "the source message body with a '✅ You chose: <label> · HH:MM' line so " + "the chat surface is self-documenting. Only applies to single-use " + "keyboards (re-tappable keyboards are never annotated). Requires the " + "agent's parseMode to be the default 'html'. Opt-in (default false). " + "Limitation: the annotated body is rebuilt from the message's plain " + "text, so any formatting entities (bold, links, code, ...) on the " + "original message are lost when the annotation is applied."),
|
|
21574
|
+
format: exports_external.string().default("✅ You chose: {label} · {time}").describe("Template for the annotation line. `{label}` is replaced with the " + "tapped button's text (newlines stripped, HTML-escaped, trimmed to 60 " + "chars) and `{time}` with HH:MM in the chosen timezone. Note: retap " + "dedup (replacing a prior annotation instead of appending another " + "line) only recognizes the DEFAULT format shape — a custom format " + "accumulates one line per retap."),
|
|
21575
|
+
timezone: exports_external.enum(["gateway", "utc"]).default("gateway").describe("Timezone for the {time} placeholder. 'gateway' uses the gateway " + "process's local time; 'utc' uses UTC. Per-user timezones are out of " + "scope for v1.")
|
|
21576
|
+
}).optional().describe("Auto-confirm 'You chose: X' annotation on inline_keyboard taps (#789). " + "Cascades from defaults.channels.telegram.button_choice_confirmation."),
|
|
21567
21577
|
webhook_sources: exports_external.array(exports_external.enum(["github", "generic", "linear"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token, " + "'linear' = Linear-Signature bare-hex HMAC-SHA256 of the raw body). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default — webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 — see #577.)"),
|
|
21568
21578
|
webhook_dispatch: exports_external.object({
|
|
21569
21579
|
github: exports_external.array(webhookDispatchRule).optional(),
|
|
@@ -21712,7 +21722,12 @@ var profileFields = {
|
|
|
21712
21722
|
soul: exports_external.object({
|
|
21713
21723
|
name: exports_external.string().optional(),
|
|
21714
21724
|
style: exports_external.string().optional(),
|
|
21715
|
-
|
|
21725
|
+
creature: exports_external.string().optional(),
|
|
21726
|
+
vibe: exports_external.string().optional(),
|
|
21727
|
+
expertise: exports_external.string().optional(),
|
|
21728
|
+
emoji: exports_external.string().optional(),
|
|
21729
|
+
boundaries: exports_external.string().optional(),
|
|
21730
|
+
shape: exports_external.enum(["executive-assistant", "developer", "coach", "generalist"]).optional()
|
|
21716
21731
|
}).optional(),
|
|
21717
21732
|
tools: exports_external.object({
|
|
21718
21733
|
allow: exports_external.array(exports_external.string()).optional(),
|
|
@@ -21909,7 +21924,8 @@ var VaultConfigSchema = exports_external.object({
|
|
|
21909
21924
|
autoUnlockCredentialPath: exports_external.string().default("~/.switchroom/vault-auto-unlock").describe("Path to the machine-bound auto-unlock blob (see " + "src/vault/auto-unlock.ts for the format). Default lives under " + "~/.switchroom so it can be bind-mounted into the vault-broker " + "container by docker compose. Tilde-expansion happens " + "at read time."),
|
|
21910
21925
|
approvalAuth: exports_external.enum(["passphrase", "telegram-id"]).default("passphrase").describe("Posture for tap-to-Approve on vault grant cards. `passphrase` " + "(default) prompts the operator to type the vault passphrase on " + "every Approve — two-factor (Telegram ID + passphrase). " + "`telegram-id` mints immediately on Approve with no passphrase " + "prompt — single-factor (Telegram ID only); REQUIRES " + "`autoUnlock: true` so the broker already holds the passphrase. " + "Trades a factor of security for smoother UX; opt-in only."),
|
|
21911
21926
|
postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` — no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored — passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config."),
|
|
21912
|
-
adminOnlyKeys: exports_external.array(exports_external.string().min(1)).default([]).describe("Vault keys held to a higher approval bar: only the admin operator " + "(`access.allowFrom[0]`) may approve a grant for them, and they can " + "NEVER be minted via posture attestation — granting one requires the " + "operator passphrase (so an agent, even one on `postureMintAgents`, " + "cannot self-grant it). Entries are exact key names or `*` globs, " + "e.g. `stripe/*`, `*/oauth-token`, `microsoft/ken-tokens` (`*` matches " + "any run of characters incl. `/`; case-sensitive). Default `[]` — no " + "key is admin-only. Posture may RETAIN an admin-only key across a " + "union re-mint but never ADD one. Takes effect on broker + gateway " + "restart (broker has no ACL hot-reload).")
|
|
21927
|
+
adminOnlyKeys: exports_external.array(exports_external.string().min(1)).default([]).describe("Vault keys held to a higher approval bar: only the admin operator " + "(`access.allowFrom[0]`) may approve a grant for them, and they can " + "NEVER be minted via posture attestation — granting one requires the " + "operator passphrase (so an agent, even one on `postureMintAgents`, " + "cannot self-grant it). Entries are exact key names or `*` globs, " + "e.g. `stripe/*`, `*/oauth-token`, `microsoft/ken-tokens` (`*` matches " + "any run of characters incl. `/`; case-sensitive). Default `[]` — no " + "key is admin-only. Posture may RETAIN an admin-only key across a " + "union re-mint but never ADD one. Takes effect on broker + gateway " + "restart (broker has no ACL hot-reload)."),
|
|
21928
|
+
auditFailClosed: exports_external.boolean().default(false).describe("sec WS10-F3 (#1420) — fail-CLOSED on an audit-append failure. When " + "`false` (default, backward-compatible) the broker fails OPEN: if a " + "vault-audit.log append fails it logs to stderr, bumps the durable " + "fail-open counter, and STILL releases the secret. When `true`, a " + "failed audit append DENIES the secret release with " + "`AUDIT_UNAVAILABLE` — no secret leaves the broker without a durable " + "audit row. The env var `SWITCHROOM_VAULT_AUDIT_FAIL_CLOSED` " + "(`1`/`true` or `0`/`false`) overrides this at runtime. Either way " + "the fail-open counter is surfaced by `switchroom doctor`. Takes " + "effect on broker restart.")
|
|
21913
21929
|
}).default({}).superRefine((broker, ctx) => {
|
|
21914
21930
|
if (broker.approvalAuth === "telegram-id" && broker.autoUnlock !== true) {
|
|
21915
21931
|
ctx.addIssue({
|
|
@@ -21948,7 +21964,9 @@ var FleetHealthConfigSchema = exports_external.object({
|
|
|
21948
21964
|
});
|
|
21949
21965
|
var HostdConfigSchema = exports_external.object({
|
|
21950
21966
|
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."),
|
|
21951
|
-
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. ENFORCED server-side: a caller exceeding this in a sliding " + "1-hour window is rejected with `E_RATE_LIMITED` (carrying a " + "`retry_after` fix) instead of posting another operator approval " + "card — so a looping agent is throttled rather than spamming the chat.")
|
|
21967
|
+
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. ENFORCED server-side: a caller exceeding this in a sliding " + "1-hour window is rejected with `E_RATE_LIMITED` (carrying a " + "`retry_after` fix) instead of posting another operator approval " + "card — so a looping agent is throttled rather than spamming the chat."),
|
|
21968
|
+
operator_attest_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the operator-passphrase 2nd factor on hostd's " + "mutating verbs (#1841, RFC host-control-daemon.md §5.4). Default " + "false — attestation is ACCEPTED-and-audited whenever present, but " + "no verb REQUIRES it (behaviour is byte-identical to today, so " + "existing fleets and the Telegram approval-card flow are unchanged). " + "When true, the verbs in `operator_attest_required_verbs` demand a " + "valid operator-passphrase attestation IN ADDITION to admin: hostd " + "forwards the passphrase to the vault broker over its own admin-client " + "connection (`/run/switchroom/broker/hostd/sock`) and treats a broker " + "DENIED as a gate failure. hostd never holds the passphrase. Leave " + "this off until BOTH the gateway passphrase-forward and the " + "cross-compose broker socket are deployed, or gated verbs will fail."),
|
|
21969
|
+
operator_attest_required_verbs: exports_external.array(exports_external.string().min(1)).default(["update_apply", "apply", "rollout"]).describe("Which mutating hostd verbs REQUIRE a valid operator-passphrase " + "attestation when `operator_attest_enabled` is true (#1841). Default " + "is the RFC §5.4 fleet-mutation set (`update_apply`, `apply`, " + "`rollout`). Verbs NOT in this list still accept-and-audit an " + "attestation when one is supplied, but never require it. Ignored " + "entirely when `operator_attest_enabled` is false.")
|
|
21952
21970
|
});
|
|
21953
21971
|
var CronEgressSchema = exports_external.object({
|
|
21954
21972
|
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."),
|
|
@@ -22371,6 +22389,11 @@ function mergeAgentConfig(defaultsIn, agentIn) {
|
|
|
22371
22389
|
if (v !== undefined)
|
|
22372
22390
|
combined[k] = v;
|
|
22373
22391
|
}
|
|
22392
|
+
const baseSoul = base;
|
|
22393
|
+
const overrideSoul = override;
|
|
22394
|
+
if (baseSoul.shape !== undefined && (overrideSoul.shape === undefined || overrideSoul.shape === "generalist")) {
|
|
22395
|
+
combined.shape = baseSoul.shape;
|
|
22396
|
+
}
|
|
22374
22397
|
merged.soul = combined;
|
|
22375
22398
|
}
|
|
22376
22399
|
if (defaults.memory || merged.memory) {
|
|
@@ -23230,18 +23253,22 @@ var tails = new Map;
|
|
|
23230
23253
|
|
|
23231
23254
|
// src/agents/inject.ts
|
|
23232
23255
|
var INJECT_COMMANDS = new Map([
|
|
23233
|
-
["/cost", { description: "Show session cost", expectsOutput: true, dialog: true }],
|
|
23234
|
-
["/status", { description: "Show session status", expectsOutput: true, dialog: true }],
|
|
23235
|
-
["/usage", { description: "Show plan quota", expectsOutput: true, dialog: true }],
|
|
23236
|
-
["/hooks", { description: "List configured hooks", expectsOutput: true, dialog: true }],
|
|
23237
|
-
["/memory", { description: "Open memory picker", expectsOutput: true, dialog: true }],
|
|
23238
|
-
["/
|
|
23256
|
+
["/cost", { description: "Show session cost", expectsOutput: true, dialog: true, argsAllowed: false }],
|
|
23257
|
+
["/status", { description: "Show session status", expectsOutput: true, dialog: true, argsAllowed: false }],
|
|
23258
|
+
["/usage", { description: "Show plan quota", expectsOutput: true, dialog: true, argsAllowed: false }],
|
|
23259
|
+
["/hooks", { description: "List configured hooks", expectsOutput: true, dialog: true, argsAllowed: false }],
|
|
23260
|
+
["/memory", { description: "Open memory picker", expectsOutput: true, dialog: true, argsAllowed: false }],
|
|
23261
|
+
["/help", { description: "Show help / command discovery", expectsOutput: true, dialog: true, argsAllowed: false }],
|
|
23262
|
+
["/context", { description: "Show context window usage", expectsOutput: true, argsAllowed: false }],
|
|
23263
|
+
["/release-notes", { description: "Show release-notes version list", expectsOutput: true, dialog: true, argsAllowed: false }],
|
|
23264
|
+
["/model", { description: "Open model picker", expectsOutput: true, argsAllowed: true }],
|
|
23239
23265
|
[
|
|
23240
23266
|
"/clear",
|
|
23241
23267
|
{
|
|
23242
23268
|
description: "Clear session screen",
|
|
23243
23269
|
expectsOutput: false,
|
|
23244
|
-
silentNote: "context cleared — fresh slate"
|
|
23270
|
+
silentNote: "context cleared — fresh slate",
|
|
23271
|
+
argsAllowed: false
|
|
23245
23272
|
}
|
|
23246
23273
|
],
|
|
23247
23274
|
[
|
|
@@ -23249,7 +23276,8 @@ var INJECT_COMMANDS = new Map([
|
|
|
23249
23276
|
{
|
|
23250
23277
|
description: "Compact conversation history",
|
|
23251
23278
|
expectsOutput: false,
|
|
23252
|
-
silentNote: "compaction runs silently"
|
|
23279
|
+
silentNote: "compaction runs silently",
|
|
23280
|
+
argsAllowed: false
|
|
23253
23281
|
}
|
|
23254
23282
|
]
|
|
23255
23283
|
]);
|
|
@@ -23259,6 +23287,15 @@ var INJECT_BLOCKED = new Map([
|
|
|
23259
23287
|
["/logout", { reason: "would terminate the agent's auth session" }],
|
|
23260
23288
|
["/exit", { reason: "would kill the agent process" }],
|
|
23261
23289
|
["/quit", { reason: "would kill the agent process" }],
|
|
23290
|
+
["/upgrade", { reason: "mutates the Claude Code installation" }],
|
|
23291
|
+
["/init", { reason: "generates/overwrites CLAUDE.md and runs a model turn" }],
|
|
23292
|
+
["/mcp", { reason: "opens an interactive MCP server management dialog" }],
|
|
23293
|
+
["/permissions", { reason: "opens an interactive permissions editor that mutates tool policy" }],
|
|
23294
|
+
["/install-github-app", { reason: "runs a network/OAuth install flow" }],
|
|
23295
|
+
["/add-dir", { reason: "mutates the session's working-directory set" }],
|
|
23296
|
+
["/terminal-setup", { reason: "mutates terminal keybinding configuration" }],
|
|
23297
|
+
["/privacy-settings", { reason: "opens an interactive privacy-settings dialog" }],
|
|
23298
|
+
["/bug", { reason: "submits a bug report over the network" }],
|
|
23262
23299
|
[
|
|
23263
23300
|
"/effort",
|
|
23264
23301
|
{
|
|
@@ -23276,6 +23313,7 @@ var HINDSIGHT_CONSUMER_NAME = "hindsight";
|
|
|
23276
23313
|
var HINDSIGHT_IMAGE_REPO = "ghcr.io/switchroom/switchroom-hindsight";
|
|
23277
23314
|
var HINDSIGHT_IMAGE = `${HINDSIGHT_IMAGE_REPO}:latest`;
|
|
23278
23315
|
var HINDSIGHT_BROKER_SOCK_VOLUME = `auth-broker-${HINDSIGHT_CONSUMER_NAME}-sock`;
|
|
23316
|
+
var HINDSIGHT_CREDS_MIRROR_VOLUME = `consumer-creds-${HINDSIGHT_CONSUMER_NAME}`;
|
|
23279
23317
|
var HINDSIGHT_HEALTHCHECK_PY = 'import urllib.request,sys; sys.exit(0 if urllib.request.urlopen("http://localhost:8888/health",timeout=4).getcode()==200 else 1)';
|
|
23280
23318
|
var HINDSIGHT_HEALTHCHECK_CMD = `python3 -c '${HINDSIGHT_HEALTHCHECK_PY}'`;
|
|
23281
23319
|
|
|
@@ -23308,6 +23346,8 @@ var KNOWN_VAULT_ARTIFACT_NAMES = new Set([
|
|
|
23308
23346
|
var SCRYPT_MAXMEM = 128 * 1024 * 1024;
|
|
23309
23347
|
|
|
23310
23348
|
// src/vault/broker/client.ts
|
|
23349
|
+
import * as net from "node:net";
|
|
23350
|
+
import * as fs from "node:fs";
|
|
23311
23351
|
import { homedir as homedir3 } from "node:os";
|
|
23312
23352
|
import { join as join4 } from "node:path";
|
|
23313
23353
|
|
|
@@ -23520,7 +23560,8 @@ var ErrorCode = exports_external.enum([
|
|
|
23520
23560
|
"DENIED",
|
|
23521
23561
|
"UNKNOWN_KEY",
|
|
23522
23562
|
"BAD_REQUEST",
|
|
23523
|
-
"INTERNAL"
|
|
23563
|
+
"INTERNAL",
|
|
23564
|
+
"AUDIT_UNAVAILABLE"
|
|
23524
23565
|
]);
|
|
23525
23566
|
var OkEntryResponseSchema = exports_external.object({
|
|
23526
23567
|
ok: exports_external.literal(true),
|
|
@@ -23657,16 +23698,123 @@ var ResponseSchema = exports_external.union([
|
|
|
23657
23698
|
OkApprovalConsumeRecordResponseSchema,
|
|
23658
23699
|
ErrorResponseSchema
|
|
23659
23700
|
]);
|
|
23701
|
+
function encodeRequest(req) {
|
|
23702
|
+
const json = JSON.stringify(req);
|
|
23703
|
+
if (Buffer.byteLength(json, "utf8") > MAX_FRAME_BYTES) {
|
|
23704
|
+
throw new Error(`Request frame too large (${Buffer.byteLength(json, "utf8")} bytes; max ${MAX_FRAME_BYTES})`);
|
|
23705
|
+
}
|
|
23706
|
+
return json + `
|
|
23707
|
+
`;
|
|
23708
|
+
}
|
|
23709
|
+
function decodeResponse(line) {
|
|
23710
|
+
if (Buffer.byteLength(line, "utf8") > MAX_FRAME_BYTES) {
|
|
23711
|
+
throw new RangeError(`Response frame too large (${Buffer.byteLength(line, "utf8")} bytes; max ${MAX_FRAME_BYTES})`);
|
|
23712
|
+
}
|
|
23713
|
+
const obj = JSON.parse(line);
|
|
23714
|
+
return ResponseSchema.parse(obj);
|
|
23715
|
+
}
|
|
23716
|
+
|
|
23717
|
+
// src/runtime-mode.ts
|
|
23718
|
+
function isDockerRuntime() {
|
|
23719
|
+
return process.env.SWITCHROOM_RUNTIME === "docker";
|
|
23720
|
+
}
|
|
23660
23721
|
|
|
23661
23722
|
// src/vault/broker/client.ts
|
|
23723
|
+
var DEFAULT_TIMEOUT_MS = 2000;
|
|
23662
23724
|
var LEGACY_SOCKET_PATH = join4(homedir3(), ".switchroom", "vault-broker.sock");
|
|
23663
23725
|
var OPERATOR_SOCKET_PATH = join4(homedir3(), ".switchroom", "broker-operator", "sock");
|
|
23726
|
+
function defaultBrokerSocketPath() {
|
|
23727
|
+
if (fs.existsSync(OPERATOR_SOCKET_PATH))
|
|
23728
|
+
return OPERATOR_SOCKET_PATH;
|
|
23729
|
+
if (isDockerRuntime())
|
|
23730
|
+
return OPERATOR_SOCKET_PATH;
|
|
23731
|
+
return LEGACY_SOCKET_PATH;
|
|
23732
|
+
}
|
|
23733
|
+
function resolveBrokerSocketPath(opts) {
|
|
23734
|
+
if (opts?.socket)
|
|
23735
|
+
return opts.socket;
|
|
23736
|
+
const env = process.env.SWITCHROOM_VAULT_BROKER_SOCK;
|
|
23737
|
+
if (env)
|
|
23738
|
+
return env;
|
|
23739
|
+
if (opts?.vaultBrokerSocket)
|
|
23740
|
+
return opts.vaultBrokerSocket;
|
|
23741
|
+
return defaultBrokerSocketPath();
|
|
23742
|
+
}
|
|
23743
|
+
async function rpcRaw(req, opts) {
|
|
23744
|
+
return rpc(req, opts);
|
|
23745
|
+
}
|
|
23746
|
+
async function rpc(req, opts) {
|
|
23747
|
+
const socketPath = resolveBrokerSocketPath(opts);
|
|
23748
|
+
const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
23749
|
+
return new Promise((resolve5) => {
|
|
23750
|
+
let settled = false;
|
|
23751
|
+
const settle = (val) => {
|
|
23752
|
+
if (settled)
|
|
23753
|
+
return;
|
|
23754
|
+
settled = true;
|
|
23755
|
+
resolve5(val);
|
|
23756
|
+
};
|
|
23757
|
+
const client = new net.Socket;
|
|
23758
|
+
const timer = setTimeout(() => {
|
|
23759
|
+
client.destroy();
|
|
23760
|
+
settle({ kind: "unreachable", msg: `broker did not respond within ${timeoutMs}ms` });
|
|
23761
|
+
}, timeoutMs);
|
|
23762
|
+
client.on("error", (err) => {
|
|
23763
|
+
clearTimeout(timer);
|
|
23764
|
+
const code = err.code ?? "ERR";
|
|
23765
|
+
let msg;
|
|
23766
|
+
if (code === "ENOENT")
|
|
23767
|
+
msg = "broker socket not found (is the daemon running?)";
|
|
23768
|
+
else if (code === "ECONNREFUSED")
|
|
23769
|
+
msg = "broker socket exists but refused connection";
|
|
23770
|
+
else if (code === "EACCES")
|
|
23771
|
+
msg = "broker socket access denied (wrong UID?)";
|
|
23772
|
+
else
|
|
23773
|
+
msg = `broker connection failed: ${err.message}`;
|
|
23774
|
+
settle({ kind: "unreachable", msg });
|
|
23775
|
+
});
|
|
23776
|
+
let buffer = "";
|
|
23777
|
+
client.on("data", (chunk) => {
|
|
23778
|
+
buffer += chunk.toString("utf8");
|
|
23779
|
+
const newlineIdx = buffer.indexOf(`
|
|
23780
|
+
`);
|
|
23781
|
+
if (newlineIdx !== -1) {
|
|
23782
|
+
const line = buffer.slice(0, newlineIdx).trimEnd();
|
|
23783
|
+
clearTimeout(timer);
|
|
23784
|
+
client.destroy();
|
|
23785
|
+
try {
|
|
23786
|
+
const resp = decodeResponse(line);
|
|
23787
|
+
settle({ kind: "response", resp });
|
|
23788
|
+
} catch (err) {
|
|
23789
|
+
settle({
|
|
23790
|
+
kind: "unreachable",
|
|
23791
|
+
msg: `unparseable broker response: ${err instanceof Error ? err.message : String(err)}`
|
|
23792
|
+
});
|
|
23793
|
+
}
|
|
23794
|
+
}
|
|
23795
|
+
});
|
|
23796
|
+
client.on("connect", () => {
|
|
23797
|
+
try {
|
|
23798
|
+
client.write(encodeRequest(req));
|
|
23799
|
+
} catch (err) {
|
|
23800
|
+
clearTimeout(timer);
|
|
23801
|
+
client.destroy();
|
|
23802
|
+
settle({
|
|
23803
|
+
kind: "unreachable",
|
|
23804
|
+
msg: `failed to send request: ${err instanceof Error ? err.message : String(err)}`
|
|
23805
|
+
});
|
|
23806
|
+
}
|
|
23807
|
+
});
|
|
23808
|
+
client.connect({ path: socketPath });
|
|
23809
|
+
});
|
|
23810
|
+
}
|
|
23664
23811
|
|
|
23665
23812
|
// src/vault/resolver.ts
|
|
23666
23813
|
var materializedDirs = new Set;
|
|
23667
23814
|
|
|
23668
23815
|
// src/agents/scaffold.ts
|
|
23669
23816
|
var REPO_ROOT = resolve5(import.meta.dirname, "../..");
|
|
23817
|
+
var CLAUDE_MD_YOURS_PLACEHOLDER = "This space is yours. Add per-agent rules, exceptions, or context the " + "Switchroom template doesn't capture. Everything above the marker line is " + "regenerated on every apply; this section is preserved.";
|
|
23670
23818
|
var SWITCHROOM_OWNED_SETTINGS_KEYS = new Set([
|
|
23671
23819
|
"permissions",
|
|
23672
23820
|
"mcpServers",
|
|
@@ -23697,8 +23845,8 @@ import { spawn as spawn2, spawnSync as spawnSync3 } from "node:child_process";
|
|
|
23697
23845
|
import { mkdir, chmod, chown, unlink, appendFile } from "node:fs/promises";
|
|
23698
23846
|
import {
|
|
23699
23847
|
readdirSync as readdirSync3,
|
|
23700
|
-
existsSync as
|
|
23701
|
-
readFileSync as
|
|
23848
|
+
existsSync as existsSync11,
|
|
23849
|
+
readFileSync as readFileSync9,
|
|
23702
23850
|
writeFileSync as writeFileSync5,
|
|
23703
23851
|
renameSync as renameSync3,
|
|
23704
23852
|
mkdirSync as mkdirSync4,
|
|
@@ -23719,7 +23867,8 @@ var IDEMPOTENCY_WINDOW_MS = 15000;
|
|
|
23719
23867
|
var RequestEnvelope = {
|
|
23720
23868
|
v: exports_external.literal(1),
|
|
23721
23869
|
request_id: exports_external.string().min(1).max(128),
|
|
23722
|
-
idempotency_key: exports_external.string().min(1).max(128).optional()
|
|
23870
|
+
idempotency_key: exports_external.string().min(1).max(128).optional(),
|
|
23871
|
+
operator_passphrase: exports_external.string().min(1).max(1024).optional()
|
|
23723
23872
|
};
|
|
23724
23873
|
var AgentRestartRequestSchema = exports_external.object({
|
|
23725
23874
|
...RequestEnvelope,
|
|
@@ -28399,9 +28548,9 @@ class PostHog extends PostHogBackendClient {
|
|
|
28399
28548
|
|
|
28400
28549
|
// src/analytics/posthog.ts
|
|
28401
28550
|
import {
|
|
28402
|
-
existsSync as
|
|
28551
|
+
existsSync as existsSync7,
|
|
28403
28552
|
mkdirSync as mkdirSync3,
|
|
28404
|
-
readFileSync as
|
|
28553
|
+
readFileSync as readFileSync6,
|
|
28405
28554
|
writeFileSync as writeFileSync3
|
|
28406
28555
|
} from "node:fs";
|
|
28407
28556
|
import { dirname as dirname2 } from "node:path";
|
|
@@ -28420,8 +28569,8 @@ function getDistinctId() {
|
|
|
28420
28569
|
return cachedDistinctId;
|
|
28421
28570
|
const path = resolveStatePath("analytics-id");
|
|
28422
28571
|
try {
|
|
28423
|
-
if (
|
|
28424
|
-
const existing =
|
|
28572
|
+
if (existsSync7(path)) {
|
|
28573
|
+
const existing = readFileSync6(path, "utf-8").trim();
|
|
28425
28574
|
if (existing) {
|
|
28426
28575
|
cachedDistinctId = existing;
|
|
28427
28576
|
return existing;
|
|
@@ -29040,7 +29189,7 @@ function redactedMarker(ruleId) {
|
|
|
29040
29189
|
return `[REDACTED:${trimmed}]`;
|
|
29041
29190
|
}
|
|
29042
29191
|
// src/cli/install-detect.ts
|
|
29043
|
-
import * as
|
|
29192
|
+
import * as fs2 from "node:fs";
|
|
29044
29193
|
import * as path from "node:path";
|
|
29045
29194
|
import * as os from "node:os";
|
|
29046
29195
|
var BIN_PATH = "/usr/local/bin/switchroom";
|
|
@@ -29054,12 +29203,12 @@ function detectInstallType() {
|
|
|
29054
29203
|
try {
|
|
29055
29204
|
const repoArtifact = sourceArtifactPath();
|
|
29056
29205
|
const distPrefix = sourceDistPrefix();
|
|
29057
|
-
const binExists =
|
|
29058
|
-
const repoExists =
|
|
29206
|
+
const binExists = fs2.existsSync(BIN_PATH);
|
|
29207
|
+
const repoExists = fs2.existsSync(repoArtifact);
|
|
29059
29208
|
if (binExists) {
|
|
29060
|
-
const lst =
|
|
29209
|
+
const lst = fs2.lstatSync(BIN_PATH);
|
|
29061
29210
|
if (lst.isSymbolicLink()) {
|
|
29062
|
-
const target =
|
|
29211
|
+
const target = fs2.readlinkSync(BIN_PATH);
|
|
29063
29212
|
const resolved = path.isAbsolute(target) ? target : path.resolve(path.dirname(BIN_PATH), target);
|
|
29064
29213
|
if (resolved.startsWith(distPrefix)) {
|
|
29065
29214
|
return {
|
|
@@ -29096,20 +29245,20 @@ var import_yaml3 = __toESM(require_dist(), 1);
|
|
|
29096
29245
|
init_atomic();
|
|
29097
29246
|
|
|
29098
29247
|
// src/cli/resolve-version.ts
|
|
29099
|
-
import { existsSync as
|
|
29248
|
+
import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:fs";
|
|
29100
29249
|
import { dirname as dirname4, join as join7 } from "node:path";
|
|
29101
29250
|
|
|
29102
29251
|
// src/build-info.ts
|
|
29103
|
-
var VERSION = "0.18.
|
|
29252
|
+
var VERSION = "0.18.12";
|
|
29104
29253
|
|
|
29105
29254
|
// src/cli/resolve-version.ts
|
|
29106
29255
|
function readPackageVersion() {
|
|
29107
29256
|
let dir = import.meta.dirname;
|
|
29108
29257
|
for (let i = 0;i < 12 && dir && dir !== "/"; i++) {
|
|
29109
29258
|
const p = join7(dir, "package.json");
|
|
29110
|
-
if (
|
|
29259
|
+
if (existsSync9(p)) {
|
|
29111
29260
|
try {
|
|
29112
|
-
const pkg = JSON.parse(
|
|
29261
|
+
const pkg = JSON.parse(readFileSync7(p, "utf-8"));
|
|
29113
29262
|
if (pkg?.name === "switchroom" && typeof pkg.version === "string") {
|
|
29114
29263
|
return pkg.version;
|
|
29115
29264
|
}
|
|
@@ -29170,6 +29319,8 @@ function parseAuditLine(line) {
|
|
|
29170
29319
|
entry.error = o.error;
|
|
29171
29320
|
if (typeof o.phase === "string")
|
|
29172
29321
|
entry.phase = o.phase;
|
|
29322
|
+
if (typeof o.method === "string")
|
|
29323
|
+
entry.method = o.method;
|
|
29173
29324
|
if (typeof o.stdout_tail === "string")
|
|
29174
29325
|
entry.stdout_tail = o.stdout_tail;
|
|
29175
29326
|
if (typeof o.stderr_tail === "string")
|
|
@@ -29418,7 +29569,7 @@ function parseUpdateResultLine(stdout) {
|
|
|
29418
29569
|
|
|
29419
29570
|
// src/host-control/config-edit-validator.ts
|
|
29420
29571
|
var import_yaml5 = __toESM(require_dist(), 1);
|
|
29421
|
-
import { mkdtempSync, writeFileSync as writeFileSync4, rmSync as rmSync2, existsSync as
|
|
29572
|
+
import { mkdtempSync, writeFileSync as writeFileSync4, rmSync as rmSync2, existsSync as existsSync10, readFileSync as readFileSync8 } from "node:fs";
|
|
29422
29573
|
import { tmpdir } from "node:os";
|
|
29423
29574
|
import { join as join9, isAbsolute as isAbsolute2, normalize, basename as basename2 } from "node:path";
|
|
29424
29575
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
@@ -29530,17 +29681,44 @@ function validateShape(unifiedDiff, targetPath) {
|
|
|
29530
29681
|
};
|
|
29531
29682
|
}
|
|
29532
29683
|
}
|
|
29684
|
+
{
|
|
29685
|
+
let inHunk = false;
|
|
29686
|
+
let hunkHasContext = false;
|
|
29687
|
+
const failZeroContext = {
|
|
29688
|
+
ok: false,
|
|
29689
|
+
code: "E_PATCH_INVALID_SHAPE",
|
|
29690
|
+
detail: "zero-context hunk not allowed; regenerate the diff with context " + "lines (git diff default of 3)"
|
|
29691
|
+
};
|
|
29692
|
+
const scanLines = lines.length > 0 && lines[lines.length - 1] === "" ? lines.slice(0, -1) : lines;
|
|
29693
|
+
for (const ln of scanLines) {
|
|
29694
|
+
if (ln.startsWith("@@")) {
|
|
29695
|
+
if (inHunk && !hunkHasContext)
|
|
29696
|
+
return failZeroContext;
|
|
29697
|
+
inHunk = true;
|
|
29698
|
+
hunkHasContext = false;
|
|
29699
|
+
} else if (ln.startsWith("--- ") || ln.startsWith("+++ ")) {
|
|
29700
|
+
if (inHunk && !hunkHasContext)
|
|
29701
|
+
return failZeroContext;
|
|
29702
|
+
inHunk = false;
|
|
29703
|
+
} else if (inHunk) {
|
|
29704
|
+
if (ln.startsWith(" ") || ln === "")
|
|
29705
|
+
hunkHasContext = true;
|
|
29706
|
+
}
|
|
29707
|
+
}
|
|
29708
|
+
if (inHunk && !hunkHasContext)
|
|
29709
|
+
return failZeroContext;
|
|
29710
|
+
}
|
|
29533
29711
|
return null;
|
|
29534
29712
|
}
|
|
29535
29713
|
function applyPatch(unifiedDiff, configPath, gitBin) {
|
|
29536
|
-
if (!
|
|
29714
|
+
if (!existsSync10(configPath)) {
|
|
29537
29715
|
return {
|
|
29538
29716
|
ok: false,
|
|
29539
29717
|
code: "E_PATCH_APPLY_FAILED",
|
|
29540
29718
|
detail: `target config not found at ${configPath}`
|
|
29541
29719
|
};
|
|
29542
29720
|
}
|
|
29543
|
-
const liveContent =
|
|
29721
|
+
const liveContent = readFileSync8(configPath, "utf8");
|
|
29544
29722
|
const scratchDir = mkdtempSync(join9(tmpdir(), "config-propose-edit-"));
|
|
29545
29723
|
try {
|
|
29546
29724
|
const targetBasename = configPath.split("/").pop() ?? "switchroom.yaml";
|
|
@@ -29553,8 +29731,7 @@ function applyPatch(unifiedDiff, configPath, gitBin) {
|
|
|
29553
29731
|
const baseArgs = [
|
|
29554
29732
|
"apply",
|
|
29555
29733
|
"--whitespace=nowarn",
|
|
29556
|
-
"--recount"
|
|
29557
|
-
"--unidiff-zero"
|
|
29734
|
+
"--recount"
|
|
29558
29735
|
];
|
|
29559
29736
|
const checkP1 = spawnSync2(bin, [...baseArgs.slice(0, 1), "--check", ...baseArgs.slice(1), "-p1", patchFile], { cwd: scratchDir, encoding: "utf8", timeout: 1e4 });
|
|
29560
29737
|
let pStrip = "-p1";
|
|
@@ -29578,7 +29755,7 @@ function applyPatch(unifiedDiff, configPath, gitBin) {
|
|
|
29578
29755
|
detail: `git apply failed: ${(real.stderr || "").trim().slice(0, 500)}`
|
|
29579
29756
|
};
|
|
29580
29757
|
}
|
|
29581
|
-
const after =
|
|
29758
|
+
const after = readFileSync8(scratchFile, "utf8");
|
|
29582
29759
|
return { ok: true, after };
|
|
29583
29760
|
} finally {
|
|
29584
29761
|
rmSync2(scratchDir, { recursive: true, force: true });
|
|
@@ -29776,7 +29953,7 @@ function validateConfigEdit(opts) {
|
|
|
29776
29953
|
return schemaErr;
|
|
29777
29954
|
let beforeData = {};
|
|
29778
29955
|
try {
|
|
29779
|
-
const beforeRaw =
|
|
29956
|
+
const beforeRaw = existsSync10(opts.configPath) ? readFileSync8(opts.configPath, "utf8") : "";
|
|
29780
29957
|
const beforeDoc = import_yaml5.parseDocument(beforeRaw, { merge: false, strict: false });
|
|
29781
29958
|
beforeData = beforeDoc.toJS();
|
|
29782
29959
|
} catch {
|
|
@@ -30225,12 +30402,12 @@ function collectScheduleEntries(config) {
|
|
|
30225
30402
|
// src/agent-scheduler/replay.ts
|
|
30226
30403
|
var STALE_LOOKBACK_MAX_MIN = 14 * 24 * 60;
|
|
30227
30404
|
function readRecentFires(jsonlPath) {
|
|
30228
|
-
const
|
|
30229
|
-
if (!
|
|
30405
|
+
const fs3 = __require("node:fs");
|
|
30406
|
+
if (!fs3.existsSync(jsonlPath))
|
|
30230
30407
|
return [];
|
|
30231
30408
|
let raw;
|
|
30232
30409
|
try {
|
|
30233
|
-
raw =
|
|
30410
|
+
raw = fs3.readFileSync(jsonlPath, "utf8");
|
|
30234
30411
|
} catch {
|
|
30235
30412
|
return [];
|
|
30236
30413
|
}
|
|
@@ -30248,6 +30425,13 @@ function readRecentFires(jsonlPath) {
|
|
|
30248
30425
|
}
|
|
30249
30426
|
|
|
30250
30427
|
// src/host-control/server.ts
|
|
30428
|
+
var HOSTD_BROKER_SOCKET_PATH = "/run/switchroom/broker/hostd/sock";
|
|
30429
|
+
var DEFAULT_OPERATOR_ATTEST_VERBS = [
|
|
30430
|
+
"update_apply",
|
|
30431
|
+
"apply",
|
|
30432
|
+
"rollout"
|
|
30433
|
+
];
|
|
30434
|
+
var ATTEST_AUDIT_METHOD = "passphrase-attest";
|
|
30251
30435
|
function resolveDigests(imageRefs) {
|
|
30252
30436
|
const out = new Map;
|
|
30253
30437
|
for (const ref of imageRefs) {
|
|
@@ -30272,9 +30456,9 @@ function resolveDigests(imageRefs) {
|
|
|
30272
30456
|
function readCachedInstallType(bindRoot) {
|
|
30273
30457
|
const cacheDir = join10(bindRoot, ".switchroom");
|
|
30274
30458
|
const cachePath = join10(cacheDir, "install-type.json");
|
|
30275
|
-
if (
|
|
30459
|
+
if (existsSync11(cachePath)) {
|
|
30276
30460
|
try {
|
|
30277
|
-
const raw =
|
|
30461
|
+
const raw = readFileSync9(cachePath, "utf-8");
|
|
30278
30462
|
const parsed = JSON.parse(raw);
|
|
30279
30463
|
if (parsed && typeof parsed.install_type === "string" && typeof parsed.detected_at === "string") {
|
|
30280
30464
|
return parsed;
|
|
@@ -30323,7 +30507,7 @@ function writeFileInPlacePreservingInode(targetPath, content) {
|
|
|
30323
30507
|
} finally {
|
|
30324
30508
|
closeSync3(fd);
|
|
30325
30509
|
}
|
|
30326
|
-
const readBack =
|
|
30510
|
+
const readBack = readFileSync9(targetPath);
|
|
30327
30511
|
if (readBack.length !== buf.length) {
|
|
30328
30512
|
throw new Error(`in-place write short: wrote ${buf.length} bytes but read back ${readBack.length}`);
|
|
30329
30513
|
}
|
|
@@ -30369,7 +30553,7 @@ class HostdServer {
|
|
|
30369
30553
|
await chmod(dir, 493).catch(() => {
|
|
30370
30554
|
return;
|
|
30371
30555
|
});
|
|
30372
|
-
if (
|
|
30556
|
+
if (existsSync11(sockPath))
|
|
30373
30557
|
await unlink(sockPath).catch(() => {
|
|
30374
30558
|
return;
|
|
30375
30559
|
});
|
|
@@ -30406,7 +30590,7 @@ class HostdServer {
|
|
|
30406
30590
|
await chmod(dir, 493).catch(() => {
|
|
30407
30591
|
return;
|
|
30408
30592
|
});
|
|
30409
|
-
if (
|
|
30593
|
+
if (existsSync11(sockPath))
|
|
30410
30594
|
await unlink(sockPath).catch(() => {
|
|
30411
30595
|
return;
|
|
30412
30596
|
});
|
|
@@ -30442,11 +30626,11 @@ class HostdServer {
|
|
|
30442
30626
|
}
|
|
30443
30627
|
async reconcileOrphanedFleetMutations() {
|
|
30444
30628
|
const path2 = this.auditLogPath();
|
|
30445
|
-
if (!
|
|
30629
|
+
if (!existsSync11(path2))
|
|
30446
30630
|
return;
|
|
30447
30631
|
let raw;
|
|
30448
30632
|
try {
|
|
30449
|
-
raw =
|
|
30633
|
+
raw = readFileSync9(path2, "utf-8");
|
|
30450
30634
|
} catch {
|
|
30451
30635
|
return;
|
|
30452
30636
|
}
|
|
@@ -30584,6 +30768,15 @@ class HostdServer {
|
|
|
30584
30768
|
socket.end();
|
|
30585
30769
|
return;
|
|
30586
30770
|
}
|
|
30771
|
+
const attest = await this.checkOperatorAttest(req, caller);
|
|
30772
|
+
if (attest.denied !== null) {
|
|
30773
|
+
const resp2 = deniedResponse(req.request_id, attest.denied);
|
|
30774
|
+
await this.writeAudit({ caller, req, resp: resp2, method: attest.method });
|
|
30775
|
+
socket.write(encodeResponse(resp2));
|
|
30776
|
+
socket.end();
|
|
30777
|
+
return;
|
|
30778
|
+
}
|
|
30779
|
+
const attestMethod = attest.method;
|
|
30587
30780
|
const started = Date.now();
|
|
30588
30781
|
let resp;
|
|
30589
30782
|
try {
|
|
@@ -30642,10 +30835,70 @@ class HostdServer {
|
|
|
30642
30835
|
resp = err("E_DISPATCH_FAILED", "hostd dispatch failed").why(msg).op(req.op).caller(caller.kind === "agent" ? "agent" : "operator").agentName(caller.kind === "agent" ? caller.name : undefined).build(req.request_id, Date.now() - started);
|
|
30643
30836
|
resp = { ...resp, error: `hostd dispatch failed: ${msg}` };
|
|
30644
30837
|
}
|
|
30645
|
-
await this.writeAudit({ caller, req, resp });
|
|
30838
|
+
await this.writeAudit({ caller, req, resp, method: attestMethod });
|
|
30646
30839
|
socket.write(encodeResponse(resp));
|
|
30647
30840
|
socket.end();
|
|
30648
30841
|
}
|
|
30842
|
+
async checkOperatorAttest(req, caller) {
|
|
30843
|
+
const hostdCfg = this.opts.config.hostd;
|
|
30844
|
+
if (hostdCfg?.operator_attest_enabled !== true) {
|
|
30845
|
+
return { denied: null };
|
|
30846
|
+
}
|
|
30847
|
+
if (caller.kind === "operator")
|
|
30848
|
+
return { denied: null };
|
|
30849
|
+
const requiredVerbs = hostdCfg.operator_attest_required_verbs ?? DEFAULT_OPERATOR_ATTEST_VERBS;
|
|
30850
|
+
const required = requiredVerbs.includes(req.op);
|
|
30851
|
+
const passphrase = req.operator_passphrase;
|
|
30852
|
+
if (!required) {
|
|
30853
|
+
if (passphrase === undefined)
|
|
30854
|
+
return { denied: null };
|
|
30855
|
+
const v2 = await this.attestVerifier().verify(passphrase);
|
|
30856
|
+
return v2.ok ? { denied: null, method: ATTEST_AUDIT_METHOD } : {
|
|
30857
|
+
denied: `${req.op} operator-attest failed: ${v2.reason}`,
|
|
30858
|
+
method: ATTEST_AUDIT_METHOD
|
|
30859
|
+
};
|
|
30860
|
+
}
|
|
30861
|
+
if (passphrase === undefined) {
|
|
30862
|
+
return {
|
|
30863
|
+
denied: `${req.op} requires operator-passphrase attestation ` + `(hostd.operator_attest_enabled is on and ${req.op} is in ` + `operator_attest_required_verbs)`,
|
|
30864
|
+
method: ATTEST_AUDIT_METHOD
|
|
30865
|
+
};
|
|
30866
|
+
}
|
|
30867
|
+
const v = await this.attestVerifier().verify(passphrase);
|
|
30868
|
+
return v.ok ? { denied: null, method: ATTEST_AUDIT_METHOD } : {
|
|
30869
|
+
denied: `${req.op} operator-attest failed: ${v.reason}`,
|
|
30870
|
+
method: ATTEST_AUDIT_METHOD
|
|
30871
|
+
};
|
|
30872
|
+
}
|
|
30873
|
+
attestVerifier() {
|
|
30874
|
+
if (this.opts.attestVerifier)
|
|
30875
|
+
return this.opts.attestVerifier;
|
|
30876
|
+
if (!this._defaultAttestVerifier) {
|
|
30877
|
+
this._defaultAttestVerifier = {
|
|
30878
|
+
verify: (passphrase) => this.brokerAttestVerify(passphrase)
|
|
30879
|
+
};
|
|
30880
|
+
}
|
|
30881
|
+
return this._defaultAttestVerifier;
|
|
30882
|
+
}
|
|
30883
|
+
_defaultAttestVerifier;
|
|
30884
|
+
async brokerAttestVerify(passphrase) {
|
|
30885
|
+
const res = await rpcRaw({
|
|
30886
|
+
v: 1,
|
|
30887
|
+
op: "list_grants",
|
|
30888
|
+
agent: "hostd",
|
|
30889
|
+
passphrase
|
|
30890
|
+
}, { vaultBrokerSocket: HOSTD_BROKER_SOCKET_PATH, timeoutMs: 5000 });
|
|
30891
|
+
if (res.kind === "unreachable") {
|
|
30892
|
+
return { ok: false, reason: `broker unreachable: ${res.msg}` };
|
|
30893
|
+
}
|
|
30894
|
+
const resp = res.resp;
|
|
30895
|
+
if (resp.ok === true)
|
|
30896
|
+
return { ok: true };
|
|
30897
|
+
return {
|
|
30898
|
+
ok: false,
|
|
30899
|
+
reason: `broker denied attestation (${resp.code ?? "DENIED"})`
|
|
30900
|
+
};
|
|
30901
|
+
}
|
|
30649
30902
|
checkGate(req, caller) {
|
|
30650
30903
|
if (caller.kind === "operator")
|
|
30651
30904
|
return null;
|
|
@@ -30779,7 +31032,7 @@ class HostdServer {
|
|
|
30779
31032
|
join10(root, "profiles"),
|
|
30780
31033
|
join10(root, "profiles", "default"),
|
|
30781
31034
|
join10(root, "vendor", "hindsight-memory")
|
|
30782
|
-
].filter((p) => !
|
|
31035
|
+
].filter((p) => !existsSync11(p));
|
|
30783
31036
|
}
|
|
30784
31037
|
applyAssetPreflight(request_id, started) {
|
|
30785
31038
|
const missing = this.missingApplyAssets();
|
|
@@ -30889,6 +31142,26 @@ class HostdServer {
|
|
|
30889
31142
|
const assetDenied = this.applyAssetPreflight(req.request_id, started);
|
|
30890
31143
|
if (assetDenied)
|
|
30891
31144
|
return assetDenied;
|
|
31145
|
+
if (req.args.agents !== undefined) {
|
|
31146
|
+
let validAgents = null;
|
|
31147
|
+
try {
|
|
31148
|
+
const cfg = loadConfig(this.opts.configPath);
|
|
31149
|
+
validAgents = Object.keys(cfg.agents ?? {});
|
|
31150
|
+
} catch {
|
|
31151
|
+
validAgents = null;
|
|
31152
|
+
}
|
|
31153
|
+
if (validAgents !== null) {
|
|
31154
|
+
const buildDenied = (human) => err("E_UNKNOWN_AGENT", human).fixBadInput("agents").op("rollout").caller(caller.kind).agentName(caller.kind === "agent" ? caller.name : undefined).asDenied().build(req.request_id, Date.now() - started);
|
|
31155
|
+
const requested = req.args.agents;
|
|
31156
|
+
if (requested.length === 0) {
|
|
31157
|
+
return buildDenied(`empty agents list — pass at least one agent, ` + `or omit "agents" to roll all. Valid agents: ${validAgents.join(", ")}`);
|
|
31158
|
+
}
|
|
31159
|
+
const unknown = requested.filter((a) => !validAgents.includes(a));
|
|
31160
|
+
if (unknown.length > 0) {
|
|
31161
|
+
return buildDenied(`unknown agent(s): ${unknown.join(", ")}. ` + `Valid agents: ${validAgents.join(", ")}`);
|
|
31162
|
+
}
|
|
31163
|
+
}
|
|
31164
|
+
}
|
|
30892
31165
|
const entry = this.launchRollout(req.args, req.request_id, caller, started);
|
|
30893
31166
|
return {
|
|
30894
31167
|
v: 1,
|
|
@@ -30952,10 +31225,10 @@ class HostdServer {
|
|
|
30952
31225
|
async beginSelfBump(req, caller, started) {
|
|
30953
31226
|
const ownVersion = this.opts.selfVersion ?? SWITCHROOM_VERSION;
|
|
30954
31227
|
const composePath = join10(this.hostdDirPath(), "docker-compose.yml");
|
|
30955
|
-
if (!
|
|
31228
|
+
if (!existsSync11(composePath)) {
|
|
30956
31229
|
return deniedResponse(req.request_id, `rollout: hostd's CLI (v${ownVersion}) is older than the target ` + `${req.args.pin} and needs a self-bump first, but its compose file ` + `is missing at ${composePath}. Run \`switchroom hostd install ` + `--tag ${req.args.pin}\` on the host, then re-run the roll. ` + `Nothing was changed.`, Date.now() - started);
|
|
30957
31230
|
}
|
|
30958
|
-
const before =
|
|
31231
|
+
const before = readFileSync9(composePath, "utf8");
|
|
30959
31232
|
const bumped = bumpHostdComposeImageTag(before, req.args.pin);
|
|
30960
31233
|
if (!bumped) {
|
|
30961
31234
|
return deniedResponse(req.request_id, `rollout: hostd needs a self-bump to ${req.args.pin} but no ` + `switchroom-hostd image line was found in ${composePath} ` + `(hand-edited compose?). Run \`switchroom hostd install --tag ` + `${req.args.pin}\` on the host, then re-run the roll. Nothing ` + `was changed.`, Date.now() - started);
|
|
@@ -31086,11 +31359,11 @@ class HostdServer {
|
|
|
31086
31359
|
}
|
|
31087
31360
|
async resumePendingSelfBumpRollout() {
|
|
31088
31361
|
const markerPath = join10(this.hostdDirPath(), SELF_BUMP_MARKER_FILENAME);
|
|
31089
|
-
if (!
|
|
31362
|
+
if (!existsSync11(markerPath))
|
|
31090
31363
|
return;
|
|
31091
31364
|
let raw;
|
|
31092
31365
|
try {
|
|
31093
|
-
raw =
|
|
31366
|
+
raw = readFileSync9(markerPath, "utf8");
|
|
31094
31367
|
} finally {
|
|
31095
31368
|
try {
|
|
31096
31369
|
unlinkSync2(markerPath);
|
|
@@ -31221,18 +31494,19 @@ class HostdServer {
|
|
|
31221
31494
|
if (!verdict.ok) {
|
|
31222
31495
|
return err(verdict.code, verdict.detail).fixBadInput("unified_diff").op("config_propose_edit").caller(caller.kind === "agent" ? "agent" : "operator").agentName(caller.kind === "agent" ? caller.name : undefined).build(req.request_id, Date.now() - started);
|
|
31223
31496
|
}
|
|
31497
|
+
let beforeContent;
|
|
31498
|
+
try {
|
|
31499
|
+
beforeContent = readFileSync9(configPath, "utf-8");
|
|
31500
|
+
} catch {
|
|
31501
|
+
beforeContent = "";
|
|
31502
|
+
}
|
|
31224
31503
|
if (caller.kind === "agent" && this.opts.config.agents[caller.name]?.admin !== true) {
|
|
31225
|
-
let beforeContent;
|
|
31226
|
-
try {
|
|
31227
|
-
beforeContent = readFileSync8(configPath, "utf-8");
|
|
31228
|
-
} catch {
|
|
31229
|
-
beforeContent = "";
|
|
31230
|
-
}
|
|
31231
31504
|
const admission = admitSelfScopedNonAdminEdit(beforeContent, verdict.postApplyContent, caller.name);
|
|
31232
31505
|
if (!admission.ok) {
|
|
31233
31506
|
return err("E_NOT_SELF_SCOPED", admission.detail).why("non-admin agents may only add rules to their own " + "agents.<self>.tools.allow OR append their own " + "agents.<self>.memory.mental_models via config_propose_edit " + `(mental-model check: ${admission.mentalModelDetail})`).fixBadInput("unified_diff").op("config_propose_edit").caller("agent").agentName(caller.name).asDenied().build(req.request_id, Date.now() - started);
|
|
31234
31507
|
}
|
|
31235
31508
|
}
|
|
31509
|
+
const proposedChangedPaths = classifyBlastRadius(beforeContent, verdict.postApplyContent).changedPaths;
|
|
31236
31510
|
if (!this.opts.approvalGateway) {
|
|
31237
31511
|
return err("E_NO_APPROVAL_GATEWAY", "validation passed but hostd was started without an approval-gateway wiring; the operator build is missing the telegram-plugin link").fixOperatorAction("infra", [
|
|
31238
31512
|
"ensure hostd was launched with --approval-gateway / telegram-plugin link"
|
|
@@ -31246,23 +31520,42 @@ class HostdServer {
|
|
|
31246
31520
|
`);
|
|
31247
31521
|
return await pending;
|
|
31248
31522
|
}
|
|
31249
|
-
const
|
|
31250
|
-
|
|
31251
|
-
|
|
31252
|
-
|
|
31523
|
+
const run = this.runConfigProposeRateThenApply(req, caller, callerName, configPath, started, proposedChangedPaths);
|
|
31524
|
+
this.inflightConfigProposals.set(dedupeKey, run);
|
|
31525
|
+
try {
|
|
31526
|
+
return await run;
|
|
31527
|
+
} finally {
|
|
31528
|
+
this.inflightConfigProposals.delete(dedupeKey);
|
|
31529
|
+
}
|
|
31530
|
+
}
|
|
31531
|
+
async runConfigProposeRateThenApply(req, caller, callerName, configPath, started, proposedChangedPaths) {
|
|
31532
|
+
let preApproved = false;
|
|
31533
|
+
try {
|
|
31534
|
+
if (this.opts.approvalGateway.checkPreApproved) {
|
|
31535
|
+
preApproved = await this.opts.approvalGateway.checkPreApproved(callerName, req.args.unified_diff);
|
|
31536
|
+
}
|
|
31537
|
+
} catch {
|
|
31538
|
+
preApproved = false;
|
|
31539
|
+
}
|
|
31540
|
+
if (!preApproved) {
|
|
31541
|
+
const rate = this.checkConfigEditRate(callerName, Date.now());
|
|
31542
|
+
if (!rate.ok) {
|
|
31543
|
+
const retryAtIso = new Date(rate.retryAtMs).toISOString();
|
|
31544
|
+
process.stderr.write(`hostd: config_propose_edit — RATE-LIMITED ${callerName} ` + `(>${rate.limit}/hour); next slot ${retryAtIso}
|
|
31253
31545
|
`);
|
|
31254
|
-
|
|
31255
|
-
|
|
31256
|
-
|
|
31257
|
-
|
|
31258
|
-
|
|
31259
|
-
|
|
31260
|
-
|
|
31261
|
-
|
|
31262
|
-
|
|
31263
|
-
|
|
31264
|
-
|
|
31265
|
-
|
|
31546
|
+
this.appendAuditRow({
|
|
31547
|
+
ts: new Date().toISOString(),
|
|
31548
|
+
op: "config_propose_edit",
|
|
31549
|
+
phase: "rate_limited",
|
|
31550
|
+
request_id: req.request_id,
|
|
31551
|
+
caller: caller.kind === "agent" ? { kind: "agent", name: caller.name } : { kind: "operator" },
|
|
31552
|
+
result: "denied",
|
|
31553
|
+
exit_code: null,
|
|
31554
|
+
duration_ms: Date.now() - started,
|
|
31555
|
+
error: `E_RATE_LIMITED: >${rate.limit} config_propose_edit cards/hour`
|
|
31556
|
+
});
|
|
31557
|
+
return err("E_RATE_LIMITED", `config_propose_edit rate limit exceeded (max ${rate.limit}/hour for this agent)`).why(`next slot opens at ${retryAtIso}`).fixRetryAfter(retryAtIso).op("config_propose_edit").caller(caller.kind === "agent" ? "agent" : "operator").agentName(caller.kind === "agent" ? caller.name : undefined).asDenied().build(req.request_id, Date.now() - started);
|
|
31558
|
+
}
|
|
31266
31559
|
}
|
|
31267
31560
|
this.appendAuditRow({
|
|
31268
31561
|
ts: new Date().toISOString(),
|
|
@@ -31272,15 +31565,10 @@ class HostdServer {
|
|
|
31272
31565
|
caller: caller.kind === "agent" ? { kind: "agent", name: caller.name } : { kind: "operator" },
|
|
31273
31566
|
result: "started",
|
|
31274
31567
|
exit_code: null,
|
|
31275
|
-
duration_ms: Date.now() - started
|
|
31568
|
+
duration_ms: Date.now() - started,
|
|
31569
|
+
...preApproved ? { pre_approved: true } : {}
|
|
31276
31570
|
});
|
|
31277
|
-
|
|
31278
|
-
this.inflightConfigProposals.set(dedupeKey, run);
|
|
31279
|
-
try {
|
|
31280
|
-
return await run;
|
|
31281
|
-
} finally {
|
|
31282
|
-
this.inflightConfigProposals.delete(dedupeKey);
|
|
31283
|
-
}
|
|
31571
|
+
return await this.runConfigProposeApprovalAndApply(req, caller, callerName, configPath, started, proposedChangedPaths);
|
|
31284
31572
|
}
|
|
31285
31573
|
inflightConfigProposals = new Map;
|
|
31286
31574
|
configEditPostTimes = new Map;
|
|
@@ -31299,7 +31587,7 @@ class HostdServer {
|
|
|
31299
31587
|
this.configEditPostTimes.set(callerName, prior);
|
|
31300
31588
|
return { ok: true };
|
|
31301
31589
|
}
|
|
31302
|
-
async runConfigProposeApprovalAndApply(req, caller, callerName, configPath,
|
|
31590
|
+
async runConfigProposeApprovalAndApply(req, caller, callerName, configPath, started, proposedChangedPaths) {
|
|
31303
31591
|
const approvalId = (this.opts.generateApprovalId ?? defaultApprovalId)();
|
|
31304
31592
|
const approval = await this.opts.approvalGateway.requestApproval({
|
|
31305
31593
|
requestId: approvalId,
|
|
@@ -31328,7 +31616,7 @@ class HostdServer {
|
|
|
31328
31616
|
try {
|
|
31329
31617
|
let snapshot;
|
|
31330
31618
|
try {
|
|
31331
|
-
snapshot =
|
|
31619
|
+
snapshot = readFileSync9(configPath, "utf-8");
|
|
31332
31620
|
} catch (e) {
|
|
31333
31621
|
await approval.finalize({
|
|
31334
31622
|
outcome: "reconcile_failed_rolled_back",
|
|
@@ -31336,8 +31624,43 @@ class HostdServer {
|
|
|
31336
31624
|
});
|
|
31337
31625
|
return this.reconcileFailedRolledBack(`snapshot read failed: ${e.message}`, req, caller, started);
|
|
31338
31626
|
}
|
|
31627
|
+
const reverdict = validateConfigEdit({
|
|
31628
|
+
configPath,
|
|
31629
|
+
targetPath: req.args.target_path,
|
|
31630
|
+
unifiedDiff: req.args.unified_diff
|
|
31631
|
+
});
|
|
31632
|
+
if (!reverdict.ok) {
|
|
31633
|
+
const why = `stored diff no longer applies against the current config` + `: ${reverdict.detail}`;
|
|
31634
|
+
await approval.finalize({
|
|
31635
|
+
outcome: "aborted_config_changed",
|
|
31636
|
+
detail: `config changed since proposal — re-propose (${why})`
|
|
31637
|
+
});
|
|
31638
|
+
return this.configChangedSinceProposal(why, req, caller, started);
|
|
31639
|
+
}
|
|
31640
|
+
const postApplyFresh = reverdict.postApplyContent;
|
|
31641
|
+
const applyChangedPaths = classifyBlastRadius(snapshot, postApplyFresh).changedPaths;
|
|
31642
|
+
const sameChangeSet = applyChangedPaths.length === proposedChangedPaths.length && applyChangedPaths.every((p, i) => p === proposedChangedPaths[i]);
|
|
31643
|
+
if (!sameChangeSet) {
|
|
31644
|
+
const why = `re-applied diff changes different config paths than approved ` + `(approved: [${proposedChangedPaths.join(", ")}]; ` + `would change: [${applyChangedPaths.join(", ")}])`;
|
|
31645
|
+
await approval.finalize({
|
|
31646
|
+
outcome: "aborted_config_changed",
|
|
31647
|
+
detail: `config changed since proposal — re-propose (${why})`
|
|
31648
|
+
});
|
|
31649
|
+
return this.configChangedSinceProposal(why, req, caller, started);
|
|
31650
|
+
}
|
|
31651
|
+
if (caller.kind === "agent" && this.opts.config.agents[caller.name]?.admin !== true) {
|
|
31652
|
+
const reAdmission = admitSelfScopedNonAdminEdit(snapshot, postApplyFresh, caller.name);
|
|
31653
|
+
if (!reAdmission.ok) {
|
|
31654
|
+
const why = `re-applied diff is no longer self-scoped for non-admin caller ` + `"${caller.name}": ${reAdmission.detail}`;
|
|
31655
|
+
await approval.finalize({
|
|
31656
|
+
outcome: "aborted_config_changed",
|
|
31657
|
+
detail: `config changed since proposal — re-propose (${why})`
|
|
31658
|
+
});
|
|
31659
|
+
return this.configChangedSinceProposal(why, req, caller, started);
|
|
31660
|
+
}
|
|
31661
|
+
}
|
|
31339
31662
|
try {
|
|
31340
|
-
writeFileInPlacePreservingInode(configPath,
|
|
31663
|
+
writeFileInPlacePreservingInode(configPath, postApplyFresh);
|
|
31341
31664
|
} catch (e) {
|
|
31342
31665
|
try {
|
|
31343
31666
|
writeFileInPlacePreservingInode(configPath, snapshot);
|
|
@@ -31351,7 +31674,7 @@ class HostdServer {
|
|
|
31351
31674
|
const runner = this.opts.runReconcile ?? (async () => this.runSwitchroom(caller.kind === "agent" ? ["apply", "--only", callerName, "--non-interactive"] : ["apply", "--non-interactive"]));
|
|
31352
31675
|
const recRes = await runner({ requestId: approvalId });
|
|
31353
31676
|
if (recRes.exit_code === 0) {
|
|
31354
|
-
const blast = classifyBlastRadius(snapshot,
|
|
31677
|
+
const blast = classifyBlastRadius(snapshot, postApplyFresh);
|
|
31355
31678
|
await approval.finalize({
|
|
31356
31679
|
outcome: "applied",
|
|
31357
31680
|
affectedAgents: blast.agents,
|
|
@@ -31389,6 +31712,11 @@ class HostdServer {
|
|
|
31389
31712
|
release();
|
|
31390
31713
|
}
|
|
31391
31714
|
}
|
|
31715
|
+
configChangedSinceProposal(why, req, caller, started) {
|
|
31716
|
+
const legacy = `E_CONFIG_CHANGED: config changed since proposal — re-propose (${why})`;
|
|
31717
|
+
const built = err("E_CONFIG_CHANGED", "config changed since the proposal was validated; re-propose against the current config").why(why).fixBadInput("unified_diff").op("config_propose_edit").caller(caller.kind === "agent" ? "agent" : "operator").agentName(caller.kind === "agent" ? caller.name : undefined).asDenied().build(req.request_id, Date.now() - started);
|
|
31718
|
+
return { ...built, error: legacy };
|
|
31719
|
+
}
|
|
31392
31720
|
reconcileFailedRolledBack(detail, req, caller, started, output) {
|
|
31393
31721
|
const legacy = `E_RECONCILE_FAILED_ROLLED_BACK: ${detail}`;
|
|
31394
31722
|
const built = err("E_RECONCILE_FAILED_ROLLED_BACK", "config write or reconcile failed; live file rolled back to snapshot").why(detail).fixOperatorAction("infra", [
|
|
@@ -31581,7 +31909,7 @@ ${output.recovery.stderr}` : "";
|
|
|
31581
31909
|
return this.opts.imageRefsForDigests();
|
|
31582
31910
|
try {
|
|
31583
31911
|
const composePath = join10(this.opts.bindRoot ?? this.opts.homeDir, ".switchroom", "compose", "docker-compose.yml");
|
|
31584
|
-
if (!
|
|
31912
|
+
if (!existsSync11(composePath))
|
|
31585
31913
|
return [];
|
|
31586
31914
|
const r = spawnSync3("docker", [
|
|
31587
31915
|
"compose",
|
|
@@ -31720,10 +32048,10 @@ ${output.recovery.stderr}` : "";
|
|
|
31720
32048
|
}
|
|
31721
32049
|
rolloutRowExistsInLog(targetRequestId) {
|
|
31722
32050
|
const path2 = this.auditLogPath();
|
|
31723
|
-
if (!
|
|
32051
|
+
if (!existsSync11(path2))
|
|
31724
32052
|
return false;
|
|
31725
32053
|
try {
|
|
31726
|
-
const raw =
|
|
32054
|
+
const raw = readFileSync9(path2, "utf-8");
|
|
31727
32055
|
return latestRolloutRowForRequest(raw, targetRequestId) !== null;
|
|
31728
32056
|
} catch {
|
|
31729
32057
|
return false;
|
|
@@ -31731,11 +32059,11 @@ ${output.recovery.stderr}` : "";
|
|
|
31731
32059
|
}
|
|
31732
32060
|
rolloutStatusFromLog(responseRequestId, targetRequestId, started) {
|
|
31733
32061
|
const path2 = this.auditLogPath();
|
|
31734
|
-
if (!
|
|
32062
|
+
if (!existsSync11(path2))
|
|
31735
32063
|
return null;
|
|
31736
32064
|
let raw;
|
|
31737
32065
|
try {
|
|
31738
|
-
raw =
|
|
32066
|
+
raw = readFileSync9(path2, "utf-8");
|
|
31739
32067
|
} catch {
|
|
31740
32068
|
return null;
|
|
31741
32069
|
}
|
|
@@ -31840,6 +32168,7 @@ ${output.recovery.stderr}` : "";
|
|
|
31840
32168
|
await this.appendAuditRow({
|
|
31841
32169
|
ts: new Date().toISOString(),
|
|
31842
32170
|
op: args.req.op,
|
|
32171
|
+
...args.method ? { method: args.method } : {},
|
|
31843
32172
|
caller: args.caller.kind === "agent" ? { kind: "agent", name: args.caller.name } : { kind: "operator" },
|
|
31844
32173
|
request_id: args.req.request_id,
|
|
31845
32174
|
result: args.resp.result,
|
|
@@ -32068,12 +32397,83 @@ function isSafeExecArgvElement(s) {
|
|
|
32068
32397
|
|
|
32069
32398
|
// src/host-control/approval-gateway.ts
|
|
32070
32399
|
import { connect as connect2 } from "node:net";
|
|
32400
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
32401
|
+
var PRE_APPROVED_QUERY_TIMEOUT_MS = 2000;
|
|
32071
32402
|
|
|
32072
32403
|
class SocketApprovalGateway {
|
|
32073
32404
|
opts;
|
|
32074
32405
|
constructor(opts) {
|
|
32075
32406
|
this.opts = opts;
|
|
32076
32407
|
}
|
|
32408
|
+
async checkPreApproved(agentName, unifiedDiff) {
|
|
32409
|
+
const sockPath = this.opts.resolveGatewaySocket(agentName);
|
|
32410
|
+
if (sockPath === null)
|
|
32411
|
+
return false;
|
|
32412
|
+
const log = this.opts.log ?? (() => {});
|
|
32413
|
+
const correlationId = `preapp-${randomBytes2(8).toString("hex")}`;
|
|
32414
|
+
return await new Promise((resolve9) => {
|
|
32415
|
+
let settled = false;
|
|
32416
|
+
const client2 = connect2({ path: sockPath });
|
|
32417
|
+
let buffer = "";
|
|
32418
|
+
const done = (result) => {
|
|
32419
|
+
if (settled)
|
|
32420
|
+
return;
|
|
32421
|
+
settled = true;
|
|
32422
|
+
clearTimeout(timer);
|
|
32423
|
+
try {
|
|
32424
|
+
client2.destroy();
|
|
32425
|
+
} catch {}
|
|
32426
|
+
resolve9(result);
|
|
32427
|
+
};
|
|
32428
|
+
const timer = setTimeout(() => {
|
|
32429
|
+
log(`checkPreApproved timed out after ${PRE_APPROVED_QUERY_TIMEOUT_MS}ms (agent=${agentName}) — failing closed`);
|
|
32430
|
+
done(false);
|
|
32431
|
+
}, PRE_APPROVED_QUERY_TIMEOUT_MS);
|
|
32432
|
+
client2.on("connect", () => {
|
|
32433
|
+
try {
|
|
32434
|
+
client2.write(JSON.stringify({
|
|
32435
|
+
type: "check_pre_approved",
|
|
32436
|
+
agentName,
|
|
32437
|
+
correlationId,
|
|
32438
|
+
unifiedDiff
|
|
32439
|
+
}) + `
|
|
32440
|
+
`);
|
|
32441
|
+
} catch (err2) {
|
|
32442
|
+
log(`check_pre_approved write failed (agent=${agentName}): ${err2.message} — failing closed`);
|
|
32443
|
+
done(false);
|
|
32444
|
+
}
|
|
32445
|
+
});
|
|
32446
|
+
client2.on("data", (chunk2) => {
|
|
32447
|
+
buffer += chunk2.toString("utf8");
|
|
32448
|
+
const lines = buffer.split(`
|
|
32449
|
+
`);
|
|
32450
|
+
buffer = lines.pop() ?? "";
|
|
32451
|
+
for (const line of lines) {
|
|
32452
|
+
if (!line.trim())
|
|
32453
|
+
continue;
|
|
32454
|
+
let parsed;
|
|
32455
|
+
try {
|
|
32456
|
+
parsed = JSON.parse(line);
|
|
32457
|
+
} catch {
|
|
32458
|
+
done(false);
|
|
32459
|
+
return;
|
|
32460
|
+
}
|
|
32461
|
+
const obj = parsed;
|
|
32462
|
+
if (obj.type === "pre_approved_result" && obj.correlationId === correlationId) {
|
|
32463
|
+
done(obj.preApproved === true);
|
|
32464
|
+
return;
|
|
32465
|
+
}
|
|
32466
|
+
done(false);
|
|
32467
|
+
return;
|
|
32468
|
+
}
|
|
32469
|
+
});
|
|
32470
|
+
client2.on("error", (err2) => {
|
|
32471
|
+
log(`checkPreApproved socket error (agent=${agentName}): ${err2.message} — failing closed`);
|
|
32472
|
+
done(false);
|
|
32473
|
+
});
|
|
32474
|
+
client2.on("close", () => done(false));
|
|
32475
|
+
});
|
|
32476
|
+
}
|
|
32077
32477
|
async requestApproval(req) {
|
|
32078
32478
|
const sockPath = this.opts.resolveGatewaySocket(req.agentName);
|
|
32079
32479
|
if (sockPath === null) {
|
|
@@ -32914,7 +33314,7 @@ async function loadConfigResilient() {
|
|
|
32914
33314
|
try {
|
|
32915
33315
|
for (const name of readdirSync4(agentsDir).sort()) {
|
|
32916
33316
|
const sock = resolve9(agentsDir, name, "telegram", "gateway.sock");
|
|
32917
|
-
if (
|
|
33317
|
+
if (existsSync12(sock))
|
|
32918
33318
|
out.push({ agent: name, sock });
|
|
32919
33319
|
}
|
|
32920
33320
|
} catch {}
|
|
@@ -32964,7 +33364,7 @@ async function main() {
|
|
|
32964
33364
|
const agentsDir = process.env.SWITCHROOM_AGENTS_DIR ?? join11(homedir6(), ".switchroom", "agents");
|
|
32965
33365
|
const resolveGatewaySocket = (agentName) => {
|
|
32966
33366
|
const sock = resolve9(agentsDir, agentName, "telegram", "gateway.sock");
|
|
32967
|
-
return
|
|
33367
|
+
return existsSync12(sock) ? sock : null;
|
|
32968
33368
|
};
|
|
32969
33369
|
const approvalGateway = new SocketApprovalGateway({
|
|
32970
33370
|
resolveGatewaySocket,
|
|
@@ -32992,7 +33392,11 @@ async function main() {
|
|
|
32992
33392
|
])),
|
|
32993
33393
|
...config.hostd ? {
|
|
32994
33394
|
hostd: {
|
|
32995
|
-
...config.hostd.config_edit_enabled !== undefined ? { config_edit_enabled: config.hostd.config_edit_enabled } : {}
|
|
33395
|
+
...config.hostd.config_edit_enabled !== undefined ? { config_edit_enabled: config.hostd.config_edit_enabled } : {},
|
|
33396
|
+
...config.hostd.operator_attest_enabled !== undefined ? { operator_attest_enabled: config.hostd.operator_attest_enabled } : {},
|
|
33397
|
+
...config.hostd.operator_attest_required_verbs !== undefined ? {
|
|
33398
|
+
operator_attest_required_verbs: config.hostd.operator_attest_required_verbs
|
|
33399
|
+
} : {}
|
|
32996
33400
|
}
|
|
32997
33401
|
} : {}
|
|
32998
33402
|
},
|