switchroom 0.13.5 → 0.13.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +5 -0
- package/dist/auth-broker/index.js +5 -0
- package/dist/cli/switchroom.js +144 -64
- package/dist/host-control/main.js +402 -27
- package/dist/vault/approvals/kernel-server.js +6 -1
- package/dist/vault/broker/server.js +6 -1
- package/package.json +1 -1
- package/profiles/_shared/telegram-style.md.hbs +12 -11
- package/profiles/default/CLAUDE.md +12 -11
- package/telegram-plugin/dist/bridge/bridge.js +24 -0
- package/telegram-plugin/dist/gateway/gateway.js +49 -7
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/gateway/gateway.ts +46 -1
- package/telegram-plugin/model-unavailable.ts +4 -0
- package/telegram-plugin/session-tail.ts +53 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +9 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +43 -0
|
@@ -14040,6 +14040,10 @@ var QuotaConfigSchema = exports_external.object({
|
|
|
14040
14040
|
var HostControlConfigSchema = exports_external.object({
|
|
14041
14041
|
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).")
|
|
14042
14042
|
});
|
|
14043
|
+
var HostdConfigSchema = exports_external.object({
|
|
14044
|
+
config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
|
|
14045
|
+
config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit §5). Default 3 cards/hour; min 1, " + "max 20. Implemented as a sqlite token bucket in PR 1c; the " + "field is wired here in PR 1a so operators can pin it before the " + "limiter is live. Above the cap, the verb returns " + "`E_RATE_LIMITED` without raising a card.")
|
|
14046
|
+
});
|
|
14043
14047
|
var SwitchroomConfigSchema = exports_external.object({
|
|
14044
14048
|
switchroom: exports_external.object({
|
|
14045
14049
|
version: exports_external.literal(1).describe("Config schema version"),
|
|
@@ -14066,6 +14070,7 @@ var SwitchroomConfigSchema = exports_external.object({
|
|
|
14066
14070
|
google_workspace: GoogleWorkspaceConfigSchema.describe("RFC G canonical key. Top-level Google Workspace configuration — " + "OAuth client credentials, approver allowlist, and tier knob (`core` " + "| `extended` | `complete`, default `core`). Mutually exclusive with " + "`drive:` at the top level (loader fails fast if both are set)."),
|
|
14067
14071
|
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."),
|
|
14068
14072
|
host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (docs/rfcs/host-control-daemon.md). Omit the block " + "to accept defaults; set `enabled: false` only on legacy systemd-" + "mode installs (removal tracked as RFC C Phase 3)."),
|
|
14073
|
+
hostd: HostdConfigSchema.default({}).describe("hostd verb-level knobs (RFC admin-agent-config-edit). Distinct " + "from `host_control:` which governs whether the daemon runs at " + "all. Currently scopes the opt-in flag and rate cap for the new " + "`config_propose_edit` verb (PR 1a — disabled by default)."),
|
|
14069
14074
|
google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
|
|
14070
14075
|
message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
|
|
14071
14076
|
}).transform((v) => v.trim().toLowerCase()), exports_external.object({
|
|
@@ -14689,17 +14694,17 @@ var BIND_MOUNT_EXACT_SOURCE_DENY = new Set(["/var/run/docker.sock"]);
|
|
|
14689
14694
|
|
|
14690
14695
|
// src/host-control/server.ts
|
|
14691
14696
|
import { createServer } from "node:net";
|
|
14692
|
-
import { spawn, spawnSync } from "node:child_process";
|
|
14697
|
+
import { spawn, spawnSync as spawnSync2 } from "node:child_process";
|
|
14693
14698
|
import { mkdir, chmod, chown, unlink, appendFile } from "node:fs/promises";
|
|
14694
14699
|
import {
|
|
14695
14700
|
readdirSync as readdirSync2,
|
|
14696
|
-
existsSync as
|
|
14697
|
-
readFileSync as
|
|
14698
|
-
writeFileSync,
|
|
14701
|
+
existsSync as existsSync6,
|
|
14702
|
+
readFileSync as readFileSync4,
|
|
14703
|
+
writeFileSync as writeFileSync2,
|
|
14699
14704
|
renameSync,
|
|
14700
14705
|
mkdirSync
|
|
14701
14706
|
} from "node:fs";
|
|
14702
|
-
import { join as
|
|
14707
|
+
import { join as join3, dirname as dirname2, resolve as resolve5 } from "node:path";
|
|
14703
14708
|
|
|
14704
14709
|
// src/host-control/protocol.ts
|
|
14705
14710
|
var MAX_FRAME_BYTES = 64 * 1024;
|
|
@@ -14794,6 +14799,15 @@ var AgentSmokeRequestSchema = exports_external.object({
|
|
|
14794
14799
|
deep: exports_external.boolean().optional()
|
|
14795
14800
|
})
|
|
14796
14801
|
});
|
|
14802
|
+
var ConfigProposeEditRequestSchema = exports_external.object({
|
|
14803
|
+
...RequestEnvelope,
|
|
14804
|
+
op: exports_external.literal("config_propose_edit"),
|
|
14805
|
+
args: exports_external.object({
|
|
14806
|
+
unified_diff: exports_external.string().min(1).max(MAX_FRAME_BYTES - 1024),
|
|
14807
|
+
reason: exports_external.string().min(1).max(500),
|
|
14808
|
+
target_path: exports_external.literal("/state/config/switchroom.yaml")
|
|
14809
|
+
})
|
|
14810
|
+
});
|
|
14797
14811
|
var RequestSchema = exports_external.discriminatedUnion("op", [
|
|
14798
14812
|
AgentRestartRequestSchema,
|
|
14799
14813
|
UpgradeStatusRequestSchema,
|
|
@@ -14806,7 +14820,8 @@ var RequestSchema = exports_external.discriminatedUnion("op", [
|
|
|
14806
14820
|
AgentLogsRequestSchema,
|
|
14807
14821
|
AgentExecRequestSchema,
|
|
14808
14822
|
DoctorRequestSchema,
|
|
14809
|
-
AgentSmokeRequestSchema
|
|
14823
|
+
AgentSmokeRequestSchema,
|
|
14824
|
+
ConfigProposeEditRequestSchema
|
|
14810
14825
|
]);
|
|
14811
14826
|
var ResultSchema = exports_external.enum(["started", "completed", "denied", "error"]);
|
|
14812
14827
|
var ResponseEnvelope = {
|
|
@@ -15283,12 +15298,351 @@ function detectInstallType() {
|
|
|
15283
15298
|
}
|
|
15284
15299
|
}
|
|
15285
15300
|
|
|
15301
|
+
// src/host-control/config-edit-validator.ts
|
|
15302
|
+
import { mkdtempSync, writeFileSync, rmSync, existsSync as existsSync5, readFileSync as readFileSync3 } from "node:fs";
|
|
15303
|
+
import { tmpdir } from "node:os";
|
|
15304
|
+
import { join as join2, isAbsolute as isAbsolute2, normalize } from "node:path";
|
|
15305
|
+
import { spawnSync } from "node:child_process";
|
|
15306
|
+
var MAX_PATCH_BYTES = 1024 * 1024;
|
|
15307
|
+
function isTargetPathHeader(headerPath, targetBasename) {
|
|
15308
|
+
if (headerPath === "/dev/null")
|
|
15309
|
+
return false;
|
|
15310
|
+
let p = headerPath;
|
|
15311
|
+
if (p.startsWith("a/") || p.startsWith("b/"))
|
|
15312
|
+
p = p.slice(2);
|
|
15313
|
+
if (isAbsolute2(p))
|
|
15314
|
+
return false;
|
|
15315
|
+
if (p.includes(".."))
|
|
15316
|
+
return false;
|
|
15317
|
+
const norm = normalize(p);
|
|
15318
|
+
if (norm.includes("..") || isAbsolute2(norm))
|
|
15319
|
+
return false;
|
|
15320
|
+
return norm === targetBasename;
|
|
15321
|
+
}
|
|
15322
|
+
function validateShape(unifiedDiff, targetPath) {
|
|
15323
|
+
const byteLen = Buffer.byteLength(unifiedDiff, "utf8");
|
|
15324
|
+
if (byteLen > MAX_PATCH_BYTES) {
|
|
15325
|
+
return {
|
|
15326
|
+
ok: false,
|
|
15327
|
+
code: "E_PATCH_INVALID_SHAPE",
|
|
15328
|
+
detail: `patch exceeds ${MAX_PATCH_BYTES}-byte cap (${byteLen} bytes)`
|
|
15329
|
+
};
|
|
15330
|
+
}
|
|
15331
|
+
if (unifiedDiff.includes("\r")) {
|
|
15332
|
+
return {
|
|
15333
|
+
ok: false,
|
|
15334
|
+
code: "E_PATCH_INVALID_SHAPE",
|
|
15335
|
+
detail: "patch must be LF-only (no CRLF / CR)"
|
|
15336
|
+
};
|
|
15337
|
+
}
|
|
15338
|
+
const lines = unifiedDiff.split(`
|
|
15339
|
+
`);
|
|
15340
|
+
const minusHeaders = [];
|
|
15341
|
+
const plusHeaders = [];
|
|
15342
|
+
let hunkCount = 0;
|
|
15343
|
+
for (const ln of lines) {
|
|
15344
|
+
if (ln.startsWith("--- "))
|
|
15345
|
+
minusHeaders.push(ln.slice(4).trim());
|
|
15346
|
+
else if (ln.startsWith("+++ "))
|
|
15347
|
+
plusHeaders.push(ln.slice(4).trim());
|
|
15348
|
+
else if (ln.startsWith("@@"))
|
|
15349
|
+
hunkCount++;
|
|
15350
|
+
}
|
|
15351
|
+
if (minusHeaders.length === 0 || plusHeaders.length === 0) {
|
|
15352
|
+
return {
|
|
15353
|
+
ok: false,
|
|
15354
|
+
code: "E_PATCH_INVALID_SHAPE",
|
|
15355
|
+
detail: "patch missing `---`/`+++` headers"
|
|
15356
|
+
};
|
|
15357
|
+
}
|
|
15358
|
+
if (hunkCount === 0) {
|
|
15359
|
+
return {
|
|
15360
|
+
ok: false,
|
|
15361
|
+
code: "E_PATCH_INVALID_SHAPE",
|
|
15362
|
+
detail: "patch contains no hunks"
|
|
15363
|
+
};
|
|
15364
|
+
}
|
|
15365
|
+
if (minusHeaders.length > 1 || plusHeaders.length > 1) {
|
|
15366
|
+
return {
|
|
15367
|
+
ok: false,
|
|
15368
|
+
code: "E_PATCH_INVALID_SHAPE",
|
|
15369
|
+
detail: "multi-file diff not allowed; single-file only"
|
|
15370
|
+
};
|
|
15371
|
+
}
|
|
15372
|
+
const targetBasename = targetPath.split("/").pop() ?? targetPath;
|
|
15373
|
+
for (const h of [...minusHeaders, ...plusHeaders]) {
|
|
15374
|
+
const path2 = h.split("\t")[0].trim();
|
|
15375
|
+
if (!isTargetPathHeader(path2, targetBasename)) {
|
|
15376
|
+
return {
|
|
15377
|
+
ok: false,
|
|
15378
|
+
code: "E_PATCH_INVALID_SHAPE",
|
|
15379
|
+
detail: `header path "${path2}" does not match target "${targetBasename}"`
|
|
15380
|
+
};
|
|
15381
|
+
}
|
|
15382
|
+
}
|
|
15383
|
+
return null;
|
|
15384
|
+
}
|
|
15385
|
+
function applyPatch(unifiedDiff, configPath, gitBin) {
|
|
15386
|
+
if (!existsSync5(configPath)) {
|
|
15387
|
+
return {
|
|
15388
|
+
ok: false,
|
|
15389
|
+
code: "E_PATCH_APPLY_FAILED",
|
|
15390
|
+
detail: `target config not found at ${configPath}`
|
|
15391
|
+
};
|
|
15392
|
+
}
|
|
15393
|
+
const liveContent = readFileSync3(configPath, "utf8");
|
|
15394
|
+
const scratchDir = mkdtempSync(join2(tmpdir(), "config-propose-edit-"));
|
|
15395
|
+
try {
|
|
15396
|
+
const basename = configPath.split("/").pop() ?? "switchroom.yaml";
|
|
15397
|
+
const scratchFile = join2(scratchDir, basename);
|
|
15398
|
+
writeFileSync(scratchFile, liveContent);
|
|
15399
|
+
const patchFile = join2(scratchDir, "proposal.patch");
|
|
15400
|
+
writeFileSync(patchFile, unifiedDiff);
|
|
15401
|
+
const bin = gitBin ?? "git";
|
|
15402
|
+
const baseArgs = [
|
|
15403
|
+
"apply",
|
|
15404
|
+
"--whitespace=nowarn",
|
|
15405
|
+
"--recount",
|
|
15406
|
+
"--unidiff-zero"
|
|
15407
|
+
];
|
|
15408
|
+
const checkP1 = spawnSync(bin, [...baseArgs.slice(0, 1), "--check", ...baseArgs.slice(1), "-p1", patchFile], { cwd: scratchDir, encoding: "utf8", timeout: 1e4 });
|
|
15409
|
+
let pStrip = "-p1";
|
|
15410
|
+
if (checkP1.status !== 0) {
|
|
15411
|
+
const checkP0 = spawnSync(bin, [...baseArgs.slice(0, 1), "--check", ...baseArgs.slice(1), "-p0", patchFile], { cwd: scratchDir, encoding: "utf8", timeout: 1e4 });
|
|
15412
|
+
if (checkP0.status !== 0) {
|
|
15413
|
+
const stderr = (checkP1.stderr || "") + (checkP0.stderr || "");
|
|
15414
|
+
return {
|
|
15415
|
+
ok: false,
|
|
15416
|
+
code: "E_PATCH_APPLY_FAILED",
|
|
15417
|
+
detail: `git apply --check failed: ${stderr.trim().slice(0, 500)}`
|
|
15418
|
+
};
|
|
15419
|
+
}
|
|
15420
|
+
pStrip = "-p0";
|
|
15421
|
+
}
|
|
15422
|
+
const real = spawnSync(bin, [...baseArgs, pStrip, patchFile], { cwd: scratchDir, encoding: "utf8", timeout: 1e4 });
|
|
15423
|
+
if (real.status !== 0) {
|
|
15424
|
+
return {
|
|
15425
|
+
ok: false,
|
|
15426
|
+
code: "E_PATCH_APPLY_FAILED",
|
|
15427
|
+
detail: `git apply failed: ${(real.stderr || "").trim().slice(0, 500)}`
|
|
15428
|
+
};
|
|
15429
|
+
}
|
|
15430
|
+
const after = readFileSync3(scratchFile, "utf8");
|
|
15431
|
+
return { ok: true, after };
|
|
15432
|
+
} finally {
|
|
15433
|
+
rmSync(scratchDir, { recursive: true, force: true });
|
|
15434
|
+
}
|
|
15435
|
+
}
|
|
15436
|
+
function failsafeParse(source) {
|
|
15437
|
+
let doc;
|
|
15438
|
+
try {
|
|
15439
|
+
doc = $parseDocument(source, { merge: false, strict: true });
|
|
15440
|
+
} catch (err) {
|
|
15441
|
+
return {
|
|
15442
|
+
ok: false,
|
|
15443
|
+
code: "E_YAML_UNSAFE_CONSTRUCT",
|
|
15444
|
+
detail: `YAML parse error: ${err.message}`
|
|
15445
|
+
};
|
|
15446
|
+
}
|
|
15447
|
+
if (doc.errors && doc.errors.length > 0) {
|
|
15448
|
+
return {
|
|
15449
|
+
ok: false,
|
|
15450
|
+
code: "E_YAML_UNSAFE_CONSTRUCT",
|
|
15451
|
+
detail: `YAML parse error: ${doc.errors[0].message}`
|
|
15452
|
+
};
|
|
15453
|
+
}
|
|
15454
|
+
let rejection = null;
|
|
15455
|
+
$visit(doc, (_key, node, _path) => {
|
|
15456
|
+
if (rejection)
|
|
15457
|
+
return $visit.BREAK;
|
|
15458
|
+
if ($isAlias(node)) {
|
|
15459
|
+
rejection = {
|
|
15460
|
+
ok: false,
|
|
15461
|
+
code: "E_YAML_UNSAFE_CONSTRUCT",
|
|
15462
|
+
detail: "YAML aliases (`*name`) are not allowed"
|
|
15463
|
+
};
|
|
15464
|
+
return $visit.BREAK;
|
|
15465
|
+
}
|
|
15466
|
+
if ($isNode(node)) {
|
|
15467
|
+
if (node.anchor) {
|
|
15468
|
+
rejection = {
|
|
15469
|
+
ok: false,
|
|
15470
|
+
code: "E_YAML_UNSAFE_CONSTRUCT",
|
|
15471
|
+
detail: `YAML anchors (\`&${node.anchor}\`) are not allowed`
|
|
15472
|
+
};
|
|
15473
|
+
return $visit.BREAK;
|
|
15474
|
+
}
|
|
15475
|
+
const tag = node.tag;
|
|
15476
|
+
if (typeof tag === "string" && tag.startsWith("!")) {
|
|
15477
|
+
rejection = {
|
|
15478
|
+
ok: false,
|
|
15479
|
+
code: "E_YAML_UNSAFE_CONSTRUCT",
|
|
15480
|
+
detail: `YAML explicit tags (\`${tag}\`) are not allowed`
|
|
15481
|
+
};
|
|
15482
|
+
return $visit.BREAK;
|
|
15483
|
+
}
|
|
15484
|
+
}
|
|
15485
|
+
if ($isPair(node)) {
|
|
15486
|
+
const k = node.key;
|
|
15487
|
+
if ($isScalar(k) && k.value === "<<") {
|
|
15488
|
+
rejection = {
|
|
15489
|
+
ok: false,
|
|
15490
|
+
code: "E_YAML_UNSAFE_CONSTRUCT",
|
|
15491
|
+
detail: "YAML merge keys (`<<:`) are not allowed"
|
|
15492
|
+
};
|
|
15493
|
+
return $visit.BREAK;
|
|
15494
|
+
}
|
|
15495
|
+
}
|
|
15496
|
+
return;
|
|
15497
|
+
});
|
|
15498
|
+
if (rejection)
|
|
15499
|
+
return rejection;
|
|
15500
|
+
if (/(^|\n)\s*<<\s*:/.test(source)) {
|
|
15501
|
+
return {
|
|
15502
|
+
ok: false,
|
|
15503
|
+
code: "E_YAML_UNSAFE_CONSTRUCT",
|
|
15504
|
+
detail: "YAML merge keys (`<<:`) are not allowed"
|
|
15505
|
+
};
|
|
15506
|
+
}
|
|
15507
|
+
const tagMatch = source.match(/(^|\s)!![A-Za-z_][\w/.\-:]*/);
|
|
15508
|
+
if (tagMatch) {
|
|
15509
|
+
return {
|
|
15510
|
+
ok: false,
|
|
15511
|
+
code: "E_YAML_UNSAFE_CONSTRUCT",
|
|
15512
|
+
detail: `YAML explicit tags (\`${tagMatch[0].trim()}\`) are not allowed`
|
|
15513
|
+
};
|
|
15514
|
+
}
|
|
15515
|
+
return { ok: true, doc, data: doc.toJS() };
|
|
15516
|
+
}
|
|
15517
|
+
function schemaValidate(data) {
|
|
15518
|
+
const result = SwitchroomConfigSchema.safeParse(data);
|
|
15519
|
+
if (result.success)
|
|
15520
|
+
return null;
|
|
15521
|
+
const issue = result.error.issues[0];
|
|
15522
|
+
const where = issue?.path.join(".") || "(root)";
|
|
15523
|
+
return {
|
|
15524
|
+
ok: false,
|
|
15525
|
+
code: "E_SCHEMA_INVALID",
|
|
15526
|
+
detail: `schema validation failed at ${where}: ${issue?.message ?? "unknown"}`
|
|
15527
|
+
};
|
|
15528
|
+
}
|
|
15529
|
+
function collectStrings(root, out, prefix = "") {
|
|
15530
|
+
if (root === null || root === undefined)
|
|
15531
|
+
return;
|
|
15532
|
+
if (typeof root === "string") {
|
|
15533
|
+
out.set(prefix || "(root)", root);
|
|
15534
|
+
return;
|
|
15535
|
+
}
|
|
15536
|
+
if (Array.isArray(root)) {
|
|
15537
|
+
for (let i = 0;i < root.length; i++) {
|
|
15538
|
+
collectStrings(root[i], out, `${prefix}[${i}]`);
|
|
15539
|
+
}
|
|
15540
|
+
return;
|
|
15541
|
+
}
|
|
15542
|
+
if (typeof root === "object") {
|
|
15543
|
+
for (const [k, v] of Object.entries(root)) {
|
|
15544
|
+
const p = prefix ? `${prefix}.${k}` : k;
|
|
15545
|
+
collectStrings(v, out, p);
|
|
15546
|
+
}
|
|
15547
|
+
}
|
|
15548
|
+
}
|
|
15549
|
+
var SECRET_PREFIX_PATTERNS = [
|
|
15550
|
+
{ name: "openai-key", re: /^sk-[A-Za-z0-9_\-]{20,}$/ },
|
|
15551
|
+
{ name: "anthropic-key", re: /^sk-ant-[A-Za-z0-9_\-]{20,}$/ },
|
|
15552
|
+
{ name: "github-pat", re: /^ghp_[A-Za-z0-9]{20,}$/ },
|
|
15553
|
+
{ name: "github-fine-grained", re: /^github_pat_[A-Za-z0-9_]{20,}$/ },
|
|
15554
|
+
{ name: "slack-bot", re: /^xoxb-[A-Za-z0-9\-]{10,}$/ },
|
|
15555
|
+
{ name: "slack-user", re: /^xoxp-[A-Za-z0-9\-]{10,}$/ },
|
|
15556
|
+
{ name: "google-api", re: /^AIza[A-Za-z0-9_\-]{30,}$/ },
|
|
15557
|
+
{ name: "aws-access-key", re: /^AKIA[A-Z0-9]{16,}$/ }
|
|
15558
|
+
];
|
|
15559
|
+
var CREDENTIAL_FIELD_RE = /(^|[._\-])(secret|token|password|api[_\-]?key|bot[_\-]?token|client[_\-]?secret|credential)s?($|[._\-])/i;
|
|
15560
|
+
var VAULT_REF_RE = /^vault:[A-Za-z0-9_\-]+(\/[A-Za-z0-9_\-]+)*$/;
|
|
15561
|
+
function masked(s) {
|
|
15562
|
+
if (s.length <= 8)
|
|
15563
|
+
return "***";
|
|
15564
|
+
return `${s.slice(0, 3)}...${s.slice(-3)}`;
|
|
15565
|
+
}
|
|
15566
|
+
function secretLeakGuard(before, after) {
|
|
15567
|
+
const beforeStrings = new Map;
|
|
15568
|
+
const afterStrings = new Map;
|
|
15569
|
+
collectStrings(before, beforeStrings);
|
|
15570
|
+
collectStrings(after, afterStrings);
|
|
15571
|
+
for (const [path2, val] of beforeStrings) {
|
|
15572
|
+
if (!VAULT_REF_RE.test(val))
|
|
15573
|
+
continue;
|
|
15574
|
+
if (!afterStrings.has(path2))
|
|
15575
|
+
continue;
|
|
15576
|
+
const newVal = afterStrings.get(path2);
|
|
15577
|
+
if (!VAULT_REF_RE.test(newVal)) {
|
|
15578
|
+
return {
|
|
15579
|
+
ok: false,
|
|
15580
|
+
code: "E_SECRET_LEAK_DETECTED",
|
|
15581
|
+
detail: `field "${path2}" was a vault reference (${val}) but the ` + `proposed change replaces it with a literal value ` + `(${masked(newVal)}); inlining a secret is not allowed`
|
|
15582
|
+
};
|
|
15583
|
+
}
|
|
15584
|
+
}
|
|
15585
|
+
for (const [path2, val] of afterStrings) {
|
|
15586
|
+
if (VAULT_REF_RE.test(val))
|
|
15587
|
+
continue;
|
|
15588
|
+
if (beforeStrings.get(path2) === val)
|
|
15589
|
+
continue;
|
|
15590
|
+
for (const { name, re } of SECRET_PREFIX_PATTERNS) {
|
|
15591
|
+
if (re.test(val)) {
|
|
15592
|
+
return {
|
|
15593
|
+
ok: false,
|
|
15594
|
+
code: "E_SECRET_LEAK_DETECTED",
|
|
15595
|
+
detail: `field "${path2}" contains a literal ${name}-shaped value ` + `(${masked(val)}); use a vault:<key> reference instead`
|
|
15596
|
+
};
|
|
15597
|
+
}
|
|
15598
|
+
}
|
|
15599
|
+
if (CREDENTIAL_FIELD_RE.test(path2)) {
|
|
15600
|
+
if (val.length >= 40 && /^[A-Za-z0-9+/=_\-]+$/.test(val)) {
|
|
15601
|
+
return {
|
|
15602
|
+
ok: false,
|
|
15603
|
+
code: "E_SECRET_LEAK_DETECTED",
|
|
15604
|
+
detail: `field "${path2}" is credential-named and the proposed ` + `value (${masked(val)}) looks like a literal secret; ` + `use a vault:<key> reference instead`
|
|
15605
|
+
};
|
|
15606
|
+
}
|
|
15607
|
+
}
|
|
15608
|
+
}
|
|
15609
|
+
return null;
|
|
15610
|
+
}
|
|
15611
|
+
function validateConfigEdit(opts) {
|
|
15612
|
+
const shapeErr = validateShape(opts.unifiedDiff, opts.targetPath);
|
|
15613
|
+
if (shapeErr)
|
|
15614
|
+
return shapeErr;
|
|
15615
|
+
const applied = applyPatch(opts.unifiedDiff, opts.configPath, opts.gitBin);
|
|
15616
|
+
if (!("ok" in applied) || applied.ok !== true) {
|
|
15617
|
+
return applied;
|
|
15618
|
+
}
|
|
15619
|
+
const parsedAfter = failsafeParse(applied.after);
|
|
15620
|
+
if (!("ok" in parsedAfter) || parsedAfter.ok !== true) {
|
|
15621
|
+
return parsedAfter;
|
|
15622
|
+
}
|
|
15623
|
+
const schemaErr = schemaValidate(parsedAfter.data);
|
|
15624
|
+
if (schemaErr)
|
|
15625
|
+
return schemaErr;
|
|
15626
|
+
let beforeData = {};
|
|
15627
|
+
try {
|
|
15628
|
+
const beforeRaw = existsSync5(opts.configPath) ? readFileSync3(opts.configPath, "utf8") : "";
|
|
15629
|
+
const beforeDoc = $parseDocument(beforeRaw, { merge: false, strict: false });
|
|
15630
|
+
beforeData = beforeDoc.toJS();
|
|
15631
|
+
} catch {
|
|
15632
|
+
beforeData = {};
|
|
15633
|
+
}
|
|
15634
|
+
const leakErr = secretLeakGuard(beforeData, parsedAfter.data);
|
|
15635
|
+
if (leakErr)
|
|
15636
|
+
return leakErr;
|
|
15637
|
+
return { ok: true };
|
|
15638
|
+
}
|
|
15639
|
+
|
|
15286
15640
|
// src/host-control/server.ts
|
|
15287
15641
|
function resolveDigests(imageRefs) {
|
|
15288
15642
|
const out = new Map;
|
|
15289
15643
|
for (const ref of imageRefs) {
|
|
15290
15644
|
try {
|
|
15291
|
-
const r =
|
|
15645
|
+
const r = spawnSync2("docker", ["inspect", "--format={{index .RepoDigests 0}}", ref], { encoding: "utf-8", timeout: 5000 });
|
|
15292
15646
|
if (r.status !== 0)
|
|
15293
15647
|
continue;
|
|
15294
15648
|
const trimmed = (r.stdout ?? "").trim();
|
|
@@ -15306,11 +15660,11 @@ function resolveDigests(imageRefs) {
|
|
|
15306
15660
|
return out;
|
|
15307
15661
|
}
|
|
15308
15662
|
function readCachedInstallType(bindRoot) {
|
|
15309
|
-
const cacheDir =
|
|
15310
|
-
const cachePath =
|
|
15311
|
-
if (
|
|
15663
|
+
const cacheDir = join3(bindRoot, ".switchroom");
|
|
15664
|
+
const cachePath = join3(cacheDir, "install-type.json");
|
|
15665
|
+
if (existsSync6(cachePath)) {
|
|
15312
15666
|
try {
|
|
15313
|
-
const raw =
|
|
15667
|
+
const raw = readFileSync4(cachePath, "utf-8");
|
|
15314
15668
|
const parsed = JSON.parse(raw);
|
|
15315
15669
|
if (parsed && typeof parsed.install_type === "string" && typeof parsed.detected_at === "string") {
|
|
15316
15670
|
return parsed;
|
|
@@ -15329,7 +15683,7 @@ function readCachedInstallType(bindRoot) {
|
|
|
15329
15683
|
try {
|
|
15330
15684
|
mkdirSync(cacheDir, { recursive: true });
|
|
15331
15685
|
const tmp = `${cachePath}.tmp`;
|
|
15332
|
-
|
|
15686
|
+
writeFileSync2(tmp, JSON.stringify(payload, null, 2), { mode: 420 });
|
|
15333
15687
|
renameSync(tmp, cachePath);
|
|
15334
15688
|
} catch {}
|
|
15335
15689
|
return payload;
|
|
@@ -15350,7 +15704,7 @@ class HostdServer {
|
|
|
15350
15704
|
this.opts = opts;
|
|
15351
15705
|
}
|
|
15352
15706
|
async start() {
|
|
15353
|
-
const hostdDir =
|
|
15707
|
+
const hostdDir = join3(this.opts.homeDir, ".switchroom", "hostd");
|
|
15354
15708
|
await mkdir(hostdDir, { recursive: true });
|
|
15355
15709
|
await chmod(hostdDir, 493).catch(() => {
|
|
15356
15710
|
return;
|
|
@@ -15361,13 +15715,13 @@ class HostdServer {
|
|
|
15361
15715
|
}
|
|
15362
15716
|
try {
|
|
15363
15717
|
for (const name of agentNames) {
|
|
15364
|
-
const dir =
|
|
15365
|
-
const sockPath =
|
|
15718
|
+
const dir = join3(hostdDir, name);
|
|
15719
|
+
const sockPath = join3(dir, "sock");
|
|
15366
15720
|
await mkdir(dir, { recursive: true });
|
|
15367
15721
|
await chmod(dir, 493).catch(() => {
|
|
15368
15722
|
return;
|
|
15369
15723
|
});
|
|
15370
|
-
if (
|
|
15724
|
+
if (existsSync6(sockPath))
|
|
15371
15725
|
await unlink(sockPath).catch(() => {
|
|
15372
15726
|
return;
|
|
15373
15727
|
});
|
|
@@ -15398,13 +15752,13 @@ class HostdServer {
|
|
|
15398
15752
|
process.stderr.write(`hostd: SWITCHROOM_HOSTD_OPERATOR_UID='${opUidStr}' is not a positive integer; skipping operator listener
|
|
15399
15753
|
`);
|
|
15400
15754
|
} else {
|
|
15401
|
-
const dir =
|
|
15402
|
-
const sockPath =
|
|
15755
|
+
const dir = join3(hostdDir, "operator");
|
|
15756
|
+
const sockPath = join3(dir, "sock");
|
|
15403
15757
|
await mkdir(dir, { recursive: true });
|
|
15404
15758
|
await chmod(dir, 493).catch(() => {
|
|
15405
15759
|
return;
|
|
15406
15760
|
});
|
|
15407
|
-
if (
|
|
15761
|
+
if (existsSync6(sockPath))
|
|
15408
15762
|
await unlink(sockPath).catch(() => {
|
|
15409
15763
|
return;
|
|
15410
15764
|
});
|
|
@@ -15561,6 +15915,9 @@ class HostdServer {
|
|
|
15561
15915
|
case "agent_smoke":
|
|
15562
15916
|
resp = await this.handleAgentSmoke(req, started);
|
|
15563
15917
|
break;
|
|
15918
|
+
case "config_propose_edit":
|
|
15919
|
+
resp = this.handleConfigProposeEdit(req, started);
|
|
15920
|
+
break;
|
|
15564
15921
|
}
|
|
15565
15922
|
} catch (err) {
|
|
15566
15923
|
resp = errorResponse(req.request_id, `hostd dispatch failed: ${err.message}`, Date.now() - started);
|
|
@@ -15608,6 +15965,8 @@ class HostdServer {
|
|
|
15608
15965
|
return callerAdmin ? null : `${req.op} cross-agent requires admin: true on caller "${caller.name}"`;
|
|
15609
15966
|
case "doctor":
|
|
15610
15967
|
return callerAdmin ? null : `doctor requires admin: true on caller "${caller.name}"`;
|
|
15968
|
+
case "config_propose_edit":
|
|
15969
|
+
return callerAdmin ? null : `config_propose_edit requires admin: true on caller "${caller.name}"`;
|
|
15611
15970
|
}
|
|
15612
15971
|
}
|
|
15613
15972
|
async handleAgentRestart(req, caller, started) {
|
|
@@ -15687,10 +16046,10 @@ class HostdServer {
|
|
|
15687
16046
|
missingApplyAssets() {
|
|
15688
16047
|
const root = this.opts.applyAssetsRoot ?? resolve5(import.meta.dirname, "../..");
|
|
15689
16048
|
return [
|
|
15690
|
-
|
|
15691
|
-
|
|
15692
|
-
|
|
15693
|
-
].filter((p) => !
|
|
16049
|
+
join3(root, "profiles"),
|
|
16050
|
+
join3(root, "profiles", "default"),
|
|
16051
|
+
join3(root, "vendor", "hindsight-memory")
|
|
16052
|
+
].filter((p) => !existsSync6(p));
|
|
15694
16053
|
}
|
|
15695
16054
|
applyAssetPreflight(request_id, started) {
|
|
15696
16055
|
const missing = this.missingApplyAssets();
|
|
@@ -15854,6 +16213,22 @@ class HostdServer {
|
|
|
15854
16213
|
stderr_tail: tail(res.stderr)
|
|
15855
16214
|
};
|
|
15856
16215
|
}
|
|
16216
|
+
handleConfigProposeEdit(req, started) {
|
|
16217
|
+
const enabled = this.opts.config.hostd?.config_edit_enabled === true;
|
|
16218
|
+
if (!enabled) {
|
|
16219
|
+
return errorResponse(req.request_id, "E_CONFIG_EDIT_DISABLED: config_propose_edit is disabled; " + "operator must set hostd.config_edit_enabled=true in " + "switchroom.yaml to opt in", Date.now() - started);
|
|
16220
|
+
}
|
|
16221
|
+
const configPath = this.opts.configPath ?? req.args.target_path;
|
|
16222
|
+
const verdict = validateConfigEdit({
|
|
16223
|
+
configPath,
|
|
16224
|
+
targetPath: req.args.target_path,
|
|
16225
|
+
unifiedDiff: req.args.unified_diff
|
|
16226
|
+
});
|
|
16227
|
+
if (!verdict.ok) {
|
|
16228
|
+
return errorResponse(req.request_id, `${verdict.code}: ${verdict.detail}`, Date.now() - started);
|
|
16229
|
+
}
|
|
16230
|
+
return errorResponse(req.request_id, "E_NOT_IMPLEMENTED_APPLY_PATH: validation passed (apply path " + "not yet implemented — pending PR 1c)", Date.now() - started);
|
|
16231
|
+
}
|
|
15857
16232
|
async handleAgentSmoke(req, started) {
|
|
15858
16233
|
const container = `switchroom-${req.args.name}`;
|
|
15859
16234
|
const respond = (containerState, probes2) => ({
|
|
@@ -15959,10 +16334,10 @@ class HostdServer {
|
|
|
15959
16334
|
if (this.opts.imageRefsForDigests)
|
|
15960
16335
|
return this.opts.imageRefsForDigests();
|
|
15961
16336
|
try {
|
|
15962
|
-
const composePath =
|
|
15963
|
-
if (!
|
|
16337
|
+
const composePath = join3(this.opts.bindRoot ?? this.opts.homeDir, ".switchroom", "compose", "docker-compose.yml");
|
|
16338
|
+
if (!existsSync6(composePath))
|
|
15964
16339
|
return [];
|
|
15965
|
-
const r =
|
|
16340
|
+
const r = spawnSync2("docker", [
|
|
15966
16341
|
"compose",
|
|
15967
16342
|
"-p",
|
|
15968
16343
|
"switchroom",
|
|
@@ -16044,7 +16419,7 @@ class HostdServer {
|
|
|
16044
16419
|
}
|
|
16045
16420
|
}
|
|
16046
16421
|
auditLogPath() {
|
|
16047
|
-
return this.opts.auditLogPath ??
|
|
16422
|
+
return this.opts.auditLogPath ?? join3(this.opts.homeDir, ".switchroom", "host-control-audit.log");
|
|
16048
16423
|
}
|
|
16049
16424
|
appendAuditRow(row) {
|
|
16050
16425
|
const path2 = this.auditLogPath();
|
|
@@ -10948,7 +10948,7 @@ var init_dist = __esm(() => {
|
|
|
10948
10948
|
});
|
|
10949
10949
|
|
|
10950
10950
|
// src/config/schema.ts
|
|
10951
|
-
var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, HostControlConfigSchema, SwitchroomConfigSchema;
|
|
10951
|
+
var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, HostControlConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
|
|
10952
10952
|
var init_schema = __esm(() => {
|
|
10953
10953
|
init_zod();
|
|
10954
10954
|
CodeRepoEntrySchema = exports_external.object({
|
|
@@ -11299,6 +11299,10 @@ var init_schema = __esm(() => {
|
|
|
11299
11299
|
HostControlConfigSchema = exports_external.object({
|
|
11300
11300
|
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).")
|
|
11301
11301
|
});
|
|
11302
|
+
HostdConfigSchema = exports_external.object({
|
|
11303
|
+
config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
|
|
11304
|
+
config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit §5). Default 3 cards/hour; min 1, " + "max 20. Implemented as a sqlite token bucket in PR 1c; the " + "field is wired here in PR 1a so operators can pin it before the " + "limiter is live. Above the cap, the verb returns " + "`E_RATE_LIMITED` without raising a card.")
|
|
11305
|
+
});
|
|
11302
11306
|
SwitchroomConfigSchema = exports_external.object({
|
|
11303
11307
|
switchroom: exports_external.object({
|
|
11304
11308
|
version: exports_external.literal(1).describe("Config schema version"),
|
|
@@ -11325,6 +11329,7 @@ var init_schema = __esm(() => {
|
|
|
11325
11329
|
google_workspace: GoogleWorkspaceConfigSchema.describe("RFC G canonical key. Top-level Google Workspace configuration — " + "OAuth client credentials, approver allowlist, and tier knob (`core` " + "| `extended` | `complete`, default `core`). Mutually exclusive with " + "`drive:` at the top level (loader fails fast if both are set)."),
|
|
11326
11330
|
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."),
|
|
11327
11331
|
host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (docs/rfcs/host-control-daemon.md). Omit the block " + "to accept defaults; set `enabled: false` only on legacy systemd-" + "mode installs (removal tracked as RFC C Phase 3)."),
|
|
11332
|
+
hostd: HostdConfigSchema.default({}).describe("hostd verb-level knobs (RFC admin-agent-config-edit). Distinct " + "from `host_control:` which governs whether the daemon runs at " + "all. Currently scopes the opt-in flag and rate cap for the new " + "`config_propose_edit` verb (PR 1a — disabled by default)."),
|
|
11328
11333
|
google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
|
|
11329
11334
|
message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
|
|
11330
11335
|
}).transform((v) => v.trim().toLowerCase()), exports_external.object({
|
|
@@ -10948,7 +10948,7 @@ var init_zod = __esm(() => {
|
|
|
10948
10948
|
});
|
|
10949
10949
|
|
|
10950
10950
|
// src/config/schema.ts
|
|
10951
|
-
var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, HostControlConfigSchema, SwitchroomConfigSchema;
|
|
10951
|
+
var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, HostControlConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
|
|
10952
10952
|
var init_schema = __esm(() => {
|
|
10953
10953
|
init_zod();
|
|
10954
10954
|
CodeRepoEntrySchema = exports_external.object({
|
|
@@ -11299,6 +11299,10 @@ var init_schema = __esm(() => {
|
|
|
11299
11299
|
HostControlConfigSchema = exports_external.object({
|
|
11300
11300
|
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).")
|
|
11301
11301
|
});
|
|
11302
|
+
HostdConfigSchema = exports_external.object({
|
|
11303
|
+
config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
|
|
11304
|
+
config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit §5). Default 3 cards/hour; min 1, " + "max 20. Implemented as a sqlite token bucket in PR 1c; the " + "field is wired here in PR 1a so operators can pin it before the " + "limiter is live. Above the cap, the verb returns " + "`E_RATE_LIMITED` without raising a card.")
|
|
11305
|
+
});
|
|
11302
11306
|
SwitchroomConfigSchema = exports_external.object({
|
|
11303
11307
|
switchroom: exports_external.object({
|
|
11304
11308
|
version: exports_external.literal(1).describe("Config schema version"),
|
|
@@ -11325,6 +11329,7 @@ var init_schema = __esm(() => {
|
|
|
11325
11329
|
google_workspace: GoogleWorkspaceConfigSchema.describe("RFC G canonical key. Top-level Google Workspace configuration — " + "OAuth client credentials, approver allowlist, and tier knob (`core` " + "| `extended` | `complete`, default `core`). Mutually exclusive with " + "`drive:` at the top level (loader fails fast if both are set)."),
|
|
11326
11330
|
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."),
|
|
11327
11331
|
host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (docs/rfcs/host-control-daemon.md). Omit the block " + "to accept defaults; set `enabled: false` only on legacy systemd-" + "mode installs (removal tracked as RFC C Phase 3)."),
|
|
11332
|
+
hostd: HostdConfigSchema.default({}).describe("hostd verb-level knobs (RFC admin-agent-config-edit). Distinct " + "from `host_control:` which governs whether the daemon runs at " + "all. Currently scopes the opt-in flag and rate cap for the new " + "`config_propose_edit` verb (PR 1a — disabled by default)."),
|
|
11328
11333
|
google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
|
|
11329
11334
|
message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
|
|
11330
11335
|
}).transform((v) => v.trim().toLowerCase()), exports_external.object({
|
package/package.json
CHANGED