switchroom 0.14.12 → 0.14.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +13 -11
- package/dist/host-control/main.js +80 -6
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +17 -3
- package/telegram-plugin/dist/bridge/bridge.js +61 -8
- package/telegram-plugin/dist/gateway/gateway.js +283 -161
- package/telegram-plugin/dist/server.js +64 -9
- package/telegram-plugin/gateway/gateway.ts +78 -66
- package/telegram-plugin/gateway/ipc-protocol.ts +4 -2
- package/telegram-plugin/permission-rule.ts +200 -122
- package/telegram-plugin/permission-title.ts +209 -197
- package/telegram-plugin/tests/always-allow-grant.test.ts +86 -54
- package/telegram-plugin/tests/always-allow-persist.test.ts +35 -34
- package/telegram-plugin/tests/permission-rule.test.ts +185 -127
- package/telegram-plugin/tests/permission-title.test.ts +109 -195
package/dist/cli/switchroom.js
CHANGED
|
@@ -23445,9 +23445,9 @@ function emitAgentService(lines, a, imageTag, buildMode, buildContext, homePrefi
|
|
|
23445
23445
|
if (existsSync14(`${hostHomeForChecks}/.switchroom/host-control-audit.log`)) {
|
|
23446
23446
|
lines.push(` - ${homePrefix}/.switchroom/host-control-audit.log:/state/agent/home/.switchroom/host-control-audit.log:ro`);
|
|
23447
23447
|
}
|
|
23448
|
-
|
|
23449
|
-
|
|
23450
|
-
}
|
|
23448
|
+
}
|
|
23449
|
+
if (hostControlEnabled && existsSync14(`${hostHomeForChecks}/.switchroom/hostd/${a.name}`)) {
|
|
23450
|
+
lines.push(` - ${homePrefix}/.switchroom/hostd/${a.name}:/run/switchroom/hostd/${a.name}`);
|
|
23451
23451
|
}
|
|
23452
23452
|
if (a.bindMounts.length > 0) {
|
|
23453
23453
|
if (!a.admin) {
|
|
@@ -49413,8 +49413,8 @@ var {
|
|
|
49413
49413
|
} = import__.default;
|
|
49414
49414
|
|
|
49415
49415
|
// src/build-info.ts
|
|
49416
|
-
var VERSION = "0.14.
|
|
49417
|
-
var COMMIT_SHA = "
|
|
49416
|
+
var VERSION = "0.14.14";
|
|
49417
|
+
var COMMIT_SHA = "502cc8ee";
|
|
49418
49418
|
|
|
49419
49419
|
// src/cli/agent.ts
|
|
49420
49420
|
init_source();
|
|
@@ -82066,7 +82066,7 @@ services:
|
|
|
82066
82066
|
- no-new-privileges:true
|
|
82067
82067
|
volumes:
|
|
82068
82068
|
# Bind-mounts the entire ~/.switchroom dir so the daemon can:
|
|
82069
|
-
# - create ~/.switchroom/hostd/<agent>/sock per
|
|
82069
|
+
# - create ~/.switchroom/hostd/<agent>/sock per agent
|
|
82070
82070
|
# - append to ~/.switchroom/host-control-audit.log
|
|
82071
82071
|
- ${hostHome}/.switchroom:/host-home/.switchroom:rw
|
|
82072
82072
|
# ~/.switchroom/switchroom.yaml is a symlink on many operator
|
|
@@ -82143,10 +82143,10 @@ async function doInstall(opts, program3) {
|
|
|
82143
82143
|
|
|
82144
82144
|
` + "Continuing anyway \u2014 the install completes (image-pinned compose file written), but `docker compose up` will fail-fast."));
|
|
82145
82145
|
}
|
|
82146
|
-
const
|
|
82147
|
-
if (
|
|
82148
|
-
console.error(source_default.yellow(`No
|
|
82149
|
-
` + "
|
|
82146
|
+
const allAgents = Object.keys(cfg.agents ?? {});
|
|
82147
|
+
if (allAgents.length === 0) {
|
|
82148
|
+
console.error(source_default.yellow(`No agents in switchroom.yaml. The daemon binds one socket per agent \u2014 with none, it will exit on startup.
|
|
82149
|
+
` + "Add at least one agent before installing hostd."));
|
|
82150
82150
|
}
|
|
82151
82151
|
const dir = hostdDir();
|
|
82152
82152
|
const composePath = hostdComposePath();
|
|
@@ -82167,7 +82167,9 @@ async function doInstall(opts, program3) {
|
|
|
82167
82167
|
console.log(source_default.dim(` Backed up existing compose to ${bak}`));
|
|
82168
82168
|
writeFileSync39(composePath, yaml, "utf8");
|
|
82169
82169
|
console.log(source_default.green(` \u2713 Wrote ${composePath}`));
|
|
82170
|
-
|
|
82170
|
+
const adminAgents = Object.entries(cfg.agents ?? {}).filter(([, a]) => a?.admin === true).map(([name]) => name);
|
|
82171
|
+
console.log(source_default.dim(` agents served (one socket each): ${allAgents.length === 0 ? "(none)" : allAgents.join(", ")}`));
|
|
82172
|
+
console.log(source_default.dim(` admin agents (full config-edit verbs): ${adminAgents.length === 0 ? "(none)" : adminAgents.join(", ")}`));
|
|
82171
82173
|
console.log(source_default.dim(` Pulling ghcr.io/switchroom/switchroom-hostd:${opts.tag ?? DEFAULT_IMAGE_TAG}\u2026`));
|
|
82172
82174
|
const pull = runDocker(["compose", "-p", HOSTD_COMPOSE_PROJECT, "-f", composePath, "pull"]);
|
|
82173
82175
|
if (!pull.ok) {
|
|
@@ -20192,6 +20192,7 @@ import { mkdtempSync, writeFileSync as writeFileSync2, rmSync, existsSync as exi
|
|
|
20192
20192
|
import { tmpdir } from "node:os";
|
|
20193
20193
|
import { join as join2, isAbsolute as isAbsolute2, normalize } from "node:path";
|
|
20194
20194
|
import { spawnSync } from "node:child_process";
|
|
20195
|
+
import { isDeepStrictEqual } from "node:util";
|
|
20195
20196
|
var MAX_PATCH_BYTES = 1024 * 1024;
|
|
20196
20197
|
var UNLOCK_CARD_YAML_ALLOWLIST = new Set([
|
|
20197
20198
|
"hostd.config_edit_enabled"
|
|
@@ -20528,6 +20529,68 @@ function validateConfigEdit(opts) {
|
|
|
20528
20529
|
return leakErr;
|
|
20529
20530
|
return { ok: true, postApplyContent: applied.after };
|
|
20530
20531
|
}
|
|
20532
|
+
function assertSelfScopedAllowEdit(beforeContent, afterContent, caller) {
|
|
20533
|
+
let before;
|
|
20534
|
+
let after;
|
|
20535
|
+
try {
|
|
20536
|
+
before = toObject($parseDocument(beforeContent, { merge: false, strict: false }).toJS());
|
|
20537
|
+
after = toObject($parseDocument(afterContent, { merge: false, strict: false }).toJS());
|
|
20538
|
+
} catch (e) {
|
|
20539
|
+
return { ok: false, detail: `self-scope: config did not parse (${e.message})` };
|
|
20540
|
+
}
|
|
20541
|
+
const beforeAllow = readCallerAllow(before, caller);
|
|
20542
|
+
const afterAllow = readCallerAllow(after, caller);
|
|
20543
|
+
for (const rule of beforeAllow) {
|
|
20544
|
+
if (!afterAllow.includes(rule)) {
|
|
20545
|
+
return {
|
|
20546
|
+
ok: false,
|
|
20547
|
+
detail: `self-scope: edit removes existing allow rule "${rule}" from agents.${caller}.tools.allow`
|
|
20548
|
+
};
|
|
20549
|
+
}
|
|
20550
|
+
}
|
|
20551
|
+
const beforeStripped = stripCallerAllow(before, caller);
|
|
20552
|
+
const afterStripped = stripCallerAllow(after, caller);
|
|
20553
|
+
if (!isDeepStrictEqual(beforeStripped, afterStripped)) {
|
|
20554
|
+
return {
|
|
20555
|
+
ok: false,
|
|
20556
|
+
detail: `self-scope: edit changes config outside agents.${caller}.tools.allow ` + `(a non-admin agent may only widen its own allow-list)`
|
|
20557
|
+
};
|
|
20558
|
+
}
|
|
20559
|
+
return { ok: true };
|
|
20560
|
+
}
|
|
20561
|
+
function toObject(v) {
|
|
20562
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
20563
|
+
}
|
|
20564
|
+
function readCallerAllow(cfg, caller) {
|
|
20565
|
+
const agents = cfg.agents;
|
|
20566
|
+
if (!agents || typeof agents !== "object")
|
|
20567
|
+
return [];
|
|
20568
|
+
const agent = agents[caller];
|
|
20569
|
+
if (!agent || typeof agent !== "object")
|
|
20570
|
+
return [];
|
|
20571
|
+
const tools = agent.tools;
|
|
20572
|
+
if (!tools || typeof tools !== "object")
|
|
20573
|
+
return [];
|
|
20574
|
+
const allow = tools.allow;
|
|
20575
|
+
return Array.isArray(allow) ? allow.filter((x) => typeof x === "string") : [];
|
|
20576
|
+
}
|
|
20577
|
+
function stripCallerAllow(cfg, caller) {
|
|
20578
|
+
const clone = structuredClone(cfg);
|
|
20579
|
+
const agents = clone.agents;
|
|
20580
|
+
if (!agents || typeof agents !== "object")
|
|
20581
|
+
return clone;
|
|
20582
|
+
const agent = agents[caller];
|
|
20583
|
+
if (!agent || typeof agent !== "object")
|
|
20584
|
+
return clone;
|
|
20585
|
+
const tools = agent.tools;
|
|
20586
|
+
if (!tools || typeof tools !== "object")
|
|
20587
|
+
return clone;
|
|
20588
|
+
delete tools.allow;
|
|
20589
|
+
if (Object.keys(tools).length === 0) {
|
|
20590
|
+
delete agent.tools;
|
|
20591
|
+
}
|
|
20592
|
+
return clone;
|
|
20593
|
+
}
|
|
20531
20594
|
|
|
20532
20595
|
// src/host-control/server.ts
|
|
20533
20596
|
function resolveDigests(imageRefs) {
|
|
@@ -20877,7 +20940,7 @@ class HostdServer {
|
|
|
20877
20940
|
case "doctor":
|
|
20878
20941
|
return callerAdmin ? null : `doctor requires admin: true on caller "${caller.name}"`;
|
|
20879
20942
|
case "config_propose_edit":
|
|
20880
|
-
return
|
|
20943
|
+
return null;
|
|
20881
20944
|
}
|
|
20882
20945
|
}
|
|
20883
20946
|
async handleAgentRestart(req, caller, started) {
|
|
@@ -21138,6 +21201,18 @@ class HostdServer {
|
|
|
21138
21201
|
if (!verdict.ok) {
|
|
21139
21202
|
return err(verdict.code, verdict.detail).fixBadInput("unified_diff").op("config_propose_edit").caller(caller.kind === "agent" ? "agent" : "operator").agentName(caller.kind === "agent" ? caller.name : undefined).build(req.request_id, Date.now() - started);
|
|
21140
21203
|
}
|
|
21204
|
+
if (caller.kind === "agent" && this.opts.config.agents[caller.name]?.admin !== true) {
|
|
21205
|
+
let beforeContent;
|
|
21206
|
+
try {
|
|
21207
|
+
beforeContent = readFileSync5(configPath, "utf-8");
|
|
21208
|
+
} catch {
|
|
21209
|
+
beforeContent = "";
|
|
21210
|
+
}
|
|
21211
|
+
const scope = assertSelfScopedAllowEdit(beforeContent, verdict.postApplyContent, caller.name);
|
|
21212
|
+
if (!scope.ok) {
|
|
21213
|
+
return err("E_NOT_SELF_SCOPED", scope.detail).why("non-admin agents may only add rules to their own " + "agents.<self>.tools.allow via config_propose_edit").fixBadInput("unified_diff").op("config_propose_edit").caller("agent").agentName(caller.name).asDenied().build(req.request_id, Date.now() - started);
|
|
21214
|
+
}
|
|
21215
|
+
}
|
|
21141
21216
|
if (!this.opts.approvalGateway) {
|
|
21142
21217
|
return err("E_NO_APPROVAL_GATEWAY", "validation passed but hostd was started without an approval-gateway wiring; the operator build is missing the telegram-plugin link").fixOperatorAction("infra", [
|
|
21143
21218
|
"ensure hostd was launched with --approval-gateway / telegram-plugin link"
|
|
@@ -21926,13 +22001,12 @@ async function main() {
|
|
|
21926
22001
|
process.exit(2);
|
|
21927
22002
|
}
|
|
21928
22003
|
const agentUids = {};
|
|
21929
|
-
for (const
|
|
21930
|
-
|
|
21931
|
-
agentUids[name] = allocateAgentUid(name);
|
|
21932
|
-
}
|
|
22004
|
+
for (const name of Object.keys(config.agents)) {
|
|
22005
|
+
agentUids[name] = allocateAgentUid(name);
|
|
21933
22006
|
}
|
|
21934
22007
|
if (Object.keys(agentUids).length === 0) {
|
|
21935
|
-
process.stderr.write(
|
|
22008
|
+
process.stderr.write(`hostd: no agents configured — nothing to serve.
|
|
22009
|
+
`);
|
|
21936
22010
|
process.exit(2);
|
|
21937
22011
|
}
|
|
21938
22012
|
const agentsDir = process.env.SWITCHROOM_AGENTS_DIR ?? join4(homedir3(), ".switchroom", "agents");
|
package/package.json
CHANGED
|
@@ -26,6 +26,21 @@
|
|
|
26
26
|
# `src/agents/lifecycle.ts:attachAgent` expect — the contract is the
|
|
27
27
|
# same one v0.6 systemd has always honored, just enforced inside the
|
|
28
28
|
# container instead of by the host's user systemd manager.
|
|
29
|
+
|
|
30
|
+
{{#if handoffEnabled}}
|
|
31
|
+
# The telegram-plugin gateway reads SWITCHROOM_HANDOFF_SHOW_LINE to
|
|
32
|
+
# decide whether to prepend the visible "↩️ Picked up where we left
|
|
33
|
+
# off …" line on the first reply after a restart. It MUST be exported
|
|
34
|
+
# *before* the gateway is forked in the docker preamble below (and
|
|
35
|
+
# before the tmux re-exec), otherwise the gateway — the sole consumer —
|
|
36
|
+
# never inherits it and session_continuity.show_handoff_line:false
|
|
37
|
+
# silently no-ops on every docker agent. Living here, ahead of the
|
|
38
|
+
# runtime branch, covers docker (both the outer fork pass and the inner
|
|
39
|
+
# tmux pass) and the v0.6 non-docker path in one place. Gated on
|
|
40
|
+
# handoffEnabled so a handoff-disabled agent emits no handoff env at all.
|
|
41
|
+
export SWITCHROOM_HANDOFF_SHOW_LINE={{#if handoffShowLine}}true{{else}}false{{/if}}
|
|
42
|
+
{{/if}}
|
|
43
|
+
|
|
29
44
|
if [ "$SWITCHROOM_RUNTIME" = "docker" ] && [ -z "$SWITCHROOM_DOCKER_TMUX_INNER" ]; then
|
|
30
45
|
# Hoist TELEGRAM_STATE_DIR up here so the gateway daemon (forked
|
|
31
46
|
# below) finds gateway.sock / gateway.pid.json / history.db at the
|
|
@@ -510,9 +525,8 @@ if [ ! -s "$HANDOFF_FILE" ]; then
|
|
|
510
525
|
timeout 5 handoff-briefing.sh 2>/dev/null || true
|
|
511
526
|
fi
|
|
512
527
|
fi
|
|
513
|
-
#
|
|
514
|
-
#
|
|
515
|
-
export SWITCHROOM_HANDOFF_SHOW_LINE={{#if handoffShowLine}}true{{else}}false{{/if}}
|
|
528
|
+
# SWITCHROOM_HANDOFF_SHOW_LINE is exported near the top of this script
|
|
529
|
+
# (ahead of the docker preamble) so the gateway sidecar inherits it.
|
|
516
530
|
APPEND_PROMPT={{#if systemPromptAppendShellQuoted}}{{{systemPromptAppendShellQuoted}}}{{else}}""{{/if}}
|
|
517
531
|
# Inject .handoff-briefing.md first (assembled from live sources), then
|
|
518
532
|
# .handoff.md (raw transcript tail from the Stop hook). If both
|
|
@@ -24446,6 +24446,40 @@ function createIpcClient(options) {
|
|
|
24446
24446
|
|
|
24447
24447
|
// permission-rule.ts
|
|
24448
24448
|
import { basename as basename2 } from "node:path";
|
|
24449
|
+
var FILE_TOOLS = new Set([
|
|
24450
|
+
"Edit",
|
|
24451
|
+
"Write",
|
|
24452
|
+
"MultiEdit",
|
|
24453
|
+
"NotebookEdit",
|
|
24454
|
+
"Read"
|
|
24455
|
+
]);
|
|
24456
|
+
var BROAD_ONLY_TOOLS = new Set([
|
|
24457
|
+
"Glob",
|
|
24458
|
+
"Grep",
|
|
24459
|
+
"WebFetch",
|
|
24460
|
+
"WebSearch",
|
|
24461
|
+
"Task",
|
|
24462
|
+
"Agent",
|
|
24463
|
+
"TodoWrite",
|
|
24464
|
+
"ExitPlanMode"
|
|
24465
|
+
]);
|
|
24466
|
+
function resolveSkillName(input) {
|
|
24467
|
+
return readString(input, "skill") ?? readString(input, "skill_name") ?? readString(input, "skillName") ?? readString(input, "name") ?? skillBasenameFromPath(input);
|
|
24468
|
+
}
|
|
24469
|
+
function filePathFrom(input) {
|
|
24470
|
+
if (!input)
|
|
24471
|
+
return null;
|
|
24472
|
+
return readString(input, "file_path") ?? readString(input, "notebook_path");
|
|
24473
|
+
}
|
|
24474
|
+
function bashFirstToken(command) {
|
|
24475
|
+
const m = /^\s*([^\s|&;<>()`$]+)/.exec(command);
|
|
24476
|
+
if (!m)
|
|
24477
|
+
return null;
|
|
24478
|
+
const tok = m[1];
|
|
24479
|
+
if (tok.includes(".."))
|
|
24480
|
+
return null;
|
|
24481
|
+
return /^[A-Za-z0-9._\-\/]+$/.test(tok) ? tok : null;
|
|
24482
|
+
}
|
|
24449
24483
|
function parseInput(raw) {
|
|
24450
24484
|
if (!raw || typeof raw !== "string")
|
|
24451
24485
|
return null;
|
|
@@ -24474,16 +24508,35 @@ function skillBasenameFromPath(input) {
|
|
|
24474
24508
|
function matchesAllowRule(rule, toolName, inputPreview) {
|
|
24475
24509
|
if (!rule || !toolName)
|
|
24476
24510
|
return false;
|
|
24477
|
-
|
|
24478
|
-
|
|
24479
|
-
|
|
24511
|
+
if (rule.endsWith("__*") && rule.startsWith("mcp__")) {
|
|
24512
|
+
const prefix = rule.slice(0, -1);
|
|
24513
|
+
return toolName.startsWith(prefix);
|
|
24514
|
+
}
|
|
24515
|
+
const scoped = /^([A-Za-z]+)\((.+)\)$/.exec(rule);
|
|
24516
|
+
if (scoped) {
|
|
24517
|
+
const ruleTool = scoped[1];
|
|
24518
|
+
const arg = scoped[2];
|
|
24519
|
+
if (ruleTool !== toolName)
|
|
24480
24520
|
return false;
|
|
24481
|
-
const ruleSkill = skillMatch[1];
|
|
24482
24521
|
const input = parseInput(inputPreview);
|
|
24483
|
-
if (
|
|
24484
|
-
|
|
24485
|
-
|
|
24486
|
-
|
|
24522
|
+
if (ruleTool === "Skill") {
|
|
24523
|
+
if (!input)
|
|
24524
|
+
return false;
|
|
24525
|
+
return resolveSkillName(input) === arg;
|
|
24526
|
+
}
|
|
24527
|
+
if (ruleTool === "Bash") {
|
|
24528
|
+
const cmd = input ? readString(input, "command") : null;
|
|
24529
|
+
if (!cmd)
|
|
24530
|
+
return false;
|
|
24531
|
+
const m = /^([^:]+):\*$/.exec(arg);
|
|
24532
|
+
if (!m)
|
|
24533
|
+
return false;
|
|
24534
|
+
return bashFirstToken(cmd) === m[1];
|
|
24535
|
+
}
|
|
24536
|
+
if (FILE_TOOLS.has(ruleTool)) {
|
|
24537
|
+
return filePathFrom(input) === arg;
|
|
24538
|
+
}
|
|
24539
|
+
return false;
|
|
24487
24540
|
}
|
|
24488
24541
|
return rule === toolName;
|
|
24489
24542
|
}
|