switchroom 0.15.17 → 0.15.18
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
CHANGED
|
@@ -49571,9 +49571,9 @@ __export(exports_server, {
|
|
|
49571
49571
|
dispatchTool: () => dispatchTool,
|
|
49572
49572
|
TOOLS: () => TOOLS
|
|
49573
49573
|
});
|
|
49574
|
-
import { spawnSync as
|
|
49574
|
+
import { spawnSync as spawnSync14 } from "node:child_process";
|
|
49575
49575
|
function execCli(args, stdin) {
|
|
49576
|
-
const r =
|
|
49576
|
+
const r = spawnSync14(CLI_BIN, args, {
|
|
49577
49577
|
encoding: "utf-8",
|
|
49578
49578
|
env: process.env,
|
|
49579
49579
|
timeout: 15000,
|
|
@@ -50459,8 +50459,8 @@ var {
|
|
|
50459
50459
|
} = import__.default;
|
|
50460
50460
|
|
|
50461
50461
|
// src/build-info.ts
|
|
50462
|
-
var VERSION = "0.15.
|
|
50463
|
-
var COMMIT_SHA = "
|
|
50462
|
+
var VERSION = "0.15.18";
|
|
50463
|
+
var COMMIT_SHA = "d7c044b9";
|
|
50464
50464
|
|
|
50465
50465
|
// src/cli/agent.ts
|
|
50466
50466
|
init_source();
|
|
@@ -76809,6 +76809,194 @@ function registerUpdateCommand(program3) {
|
|
|
76809
76809
|
});
|
|
76810
76810
|
}
|
|
76811
76811
|
|
|
76812
|
+
// src/cli/rollout.ts
|
|
76813
|
+
init_helpers();
|
|
76814
|
+
import { spawnSync as spawnSync10 } from "node:child_process";
|
|
76815
|
+
function normalizeVersion(v) {
|
|
76816
|
+
return v.trim().replace(/^v/, "");
|
|
76817
|
+
}
|
|
76818
|
+
function isVersionAssertable(target) {
|
|
76819
|
+
return /^v?\d+\.\d+\.\d+$/.test(target.trim());
|
|
76820
|
+
}
|
|
76821
|
+
function orderAgentsCanaryFirst(agents) {
|
|
76822
|
+
const canary = agents.filter((a) => a === "test-harness");
|
|
76823
|
+
const rest = agents.filter((a) => a !== "test-harness");
|
|
76824
|
+
return [...canary, ...rest];
|
|
76825
|
+
}
|
|
76826
|
+
function planRollout(agents, opts = {}) {
|
|
76827
|
+
const steps = [{ kind: "apply" }];
|
|
76828
|
+
for (const agent of orderAgentsCanaryFirst(agents)) {
|
|
76829
|
+
steps.push({ kind: "restart-agent", agent });
|
|
76830
|
+
}
|
|
76831
|
+
if (!opts.skipWeb) {
|
|
76832
|
+
steps.push({ kind: "refresh-web" });
|
|
76833
|
+
steps.push({ kind: "refresh-hostd" });
|
|
76834
|
+
}
|
|
76835
|
+
steps.push({ kind: "sweep" });
|
|
76836
|
+
return steps;
|
|
76837
|
+
}
|
|
76838
|
+
function formatRolloutPlan(steps, target) {
|
|
76839
|
+
const lines = [`Rollout plan \u2192 ${target}:`];
|
|
76840
|
+
let n = 0;
|
|
76841
|
+
for (const s of steps) {
|
|
76842
|
+
n += 1;
|
|
76843
|
+
switch (s.kind) {
|
|
76844
|
+
case "apply":
|
|
76845
|
+
lines.push(` ${n}. apply \u2014 regenerate compose with ${target} image refs`);
|
|
76846
|
+
break;
|
|
76847
|
+
case "restart-agent":
|
|
76848
|
+
lines.push(` ${n}. restart ${s.agent} (--wait --force) + assert --version=${target}`);
|
|
76849
|
+
break;
|
|
76850
|
+
case "refresh-web":
|
|
76851
|
+
lines.push(` ${n}. webd install --tag ${target} (separate compose project)`);
|
|
76852
|
+
break;
|
|
76853
|
+
case "refresh-hostd":
|
|
76854
|
+
lines.push(` ${n}. hostd install --tag ${target} (separate compose project)`);
|
|
76855
|
+
break;
|
|
76856
|
+
case "sweep":
|
|
76857
|
+
lines.push(` ${n}. sweep \u2014 print per-agent version table`);
|
|
76858
|
+
break;
|
|
76859
|
+
}
|
|
76860
|
+
}
|
|
76861
|
+
lines.push("");
|
|
76862
|
+
lines.push("Stops on the first agent that doesn't come back on the target version.");
|
|
76863
|
+
return lines.join(`
|
|
76864
|
+
`);
|
|
76865
|
+
}
|
|
76866
|
+
function executeRollout(steps, target, deps, pinOnApply) {
|
|
76867
|
+
const targetNorm = normalizeVersion(target);
|
|
76868
|
+
const rolled = [];
|
|
76869
|
+
const warnings = [];
|
|
76870
|
+
for (const step of steps) {
|
|
76871
|
+
switch (step.kind) {
|
|
76872
|
+
case "apply": {
|
|
76873
|
+
deps.log(`\u2192 apply \u2014 regenerating compose for ${target}`);
|
|
76874
|
+
const args = pinOnApply ? ["apply", "--pin", target] : ["apply"];
|
|
76875
|
+
const r = deps.run(args);
|
|
76876
|
+
if (r.status !== 0) {
|
|
76877
|
+
return { ok: false, rolled, failedStep: "apply", warnings };
|
|
76878
|
+
}
|
|
76879
|
+
break;
|
|
76880
|
+
}
|
|
76881
|
+
case "restart-agent": {
|
|
76882
|
+
deps.log(`\u2192 restart ${step.agent} (--wait --force)`);
|
|
76883
|
+
deps.run(["agent", "restart", step.agent, "--wait", "--force"]);
|
|
76884
|
+
const got = deps.probeVersion(step.agent);
|
|
76885
|
+
if (got === null || normalizeVersion(got) !== targetNorm) {
|
|
76886
|
+
deps.log(` \u2717 ${step.agent} \u2192 ${got ?? "<unreachable>"} (expected ${target}) \u2014 STOPPING`);
|
|
76887
|
+
return {
|
|
76888
|
+
ok: false,
|
|
76889
|
+
rolled,
|
|
76890
|
+
failedStep: "restart-agent",
|
|
76891
|
+
failedAgent: step.agent,
|
|
76892
|
+
got,
|
|
76893
|
+
warnings
|
|
76894
|
+
};
|
|
76895
|
+
}
|
|
76896
|
+
rolled.push(step.agent);
|
|
76897
|
+
deps.log(` \u2713 ${step.agent} \u2192 ${got}`);
|
|
76898
|
+
break;
|
|
76899
|
+
}
|
|
76900
|
+
case "refresh-web": {
|
|
76901
|
+
deps.log(`\u2192 webd install --tag ${target}`);
|
|
76902
|
+
const r = deps.run(["webd", "install", "--tag", target]);
|
|
76903
|
+
if (r.status !== 0)
|
|
76904
|
+
warnings.push(`web refresh failed (non-fatal); agents already rolled`);
|
|
76905
|
+
break;
|
|
76906
|
+
}
|
|
76907
|
+
case "refresh-hostd": {
|
|
76908
|
+
deps.log(`\u2192 hostd install --tag ${target}`);
|
|
76909
|
+
const r = deps.run(["hostd", "install", "--tag", target]);
|
|
76910
|
+
if (r.status !== 0)
|
|
76911
|
+
warnings.push(`hostd refresh failed (non-fatal); agents already rolled`);
|
|
76912
|
+
break;
|
|
76913
|
+
}
|
|
76914
|
+
case "sweep": {
|
|
76915
|
+
deps.log(`\u2192 sweep`);
|
|
76916
|
+
for (const a of rolled) {
|
|
76917
|
+
const v = deps.probeVersion(a);
|
|
76918
|
+
deps.log(` ${a}: ${v ?? "<unreachable>"}`);
|
|
76919
|
+
}
|
|
76920
|
+
break;
|
|
76921
|
+
}
|
|
76922
|
+
}
|
|
76923
|
+
}
|
|
76924
|
+
return { ok: true, rolled, warnings };
|
|
76925
|
+
}
|
|
76926
|
+
function registerRolloutCommand(program3) {
|
|
76927
|
+
program3.command("rollout").description("Deploy a pinned version to the fleet, safely (staggered restart + " + "per-agent version assert + web/hostd refresh). Run with sudo.").option("--pin <version>", "Version to roll (e.g. v0.15.18). Defaults to release.pin from config.").option("--agents <list>", "Comma-separated subset of agents to roll (default: all configured).").option("--skip-web", "Skip the web + hostd refresh step.").option("--dry-run", "Print the plan and exit without changing anything.").action(async (opts) => {
|
|
76928
|
+
const config = getConfig(program3);
|
|
76929
|
+
const target = opts.pin ?? config.release?.pin;
|
|
76930
|
+
if (!target) {
|
|
76931
|
+
process.stderr.write("rollout needs a pinned version: pass --pin vX.Y.Z, or set " + "`release.pin` in switchroom.yaml. (A floating channel has no " + `fixed version to assert against.)
|
|
76932
|
+
`);
|
|
76933
|
+
process.exitCode = 2;
|
|
76934
|
+
return;
|
|
76935
|
+
}
|
|
76936
|
+
if (!isVersionAssertable(target)) {
|
|
76937
|
+
process.stderr.write(`rollout asserts the in-container \`switchroom --version\` (always a ` + `semver), so the target must be a tagged release like v0.15.18 \u2014 ` + `\`${target}\` isn't version-assertable. Pass --pin vX.Y.Z.
|
|
76938
|
+
`);
|
|
76939
|
+
process.exitCode = 2;
|
|
76940
|
+
return;
|
|
76941
|
+
}
|
|
76942
|
+
const allAgents = Object.keys(config.agents ?? {});
|
|
76943
|
+
const requested = opts.agents ? opts.agents.split(",").map((s) => s.trim()).filter(Boolean) : allAgents;
|
|
76944
|
+
const unknown = requested.filter((a) => !allAgents.includes(a));
|
|
76945
|
+
if (unknown.length > 0) {
|
|
76946
|
+
process.stderr.write(`unknown agent(s): ${unknown.join(", ")}
|
|
76947
|
+
`);
|
|
76948
|
+
process.exitCode = 2;
|
|
76949
|
+
return;
|
|
76950
|
+
}
|
|
76951
|
+
if (requested.length === 0) {
|
|
76952
|
+
process.stderr.write(`no agents to roll.
|
|
76953
|
+
`);
|
|
76954
|
+
process.exitCode = 2;
|
|
76955
|
+
return;
|
|
76956
|
+
}
|
|
76957
|
+
const steps = planRollout(requested, { skipWeb: opts.skipWeb });
|
|
76958
|
+
if (opts.dryRun) {
|
|
76959
|
+
process.stdout.write(formatRolloutPlan(steps, target) + `
|
|
76960
|
+
`);
|
|
76961
|
+
return;
|
|
76962
|
+
}
|
|
76963
|
+
const scriptPath = process.argv[1] ?? "switchroom";
|
|
76964
|
+
const deps = {
|
|
76965
|
+
run: (args) => {
|
|
76966
|
+
const r = spawnSync10(process.execPath, [scriptPath, ...args], { stdio: "inherit" });
|
|
76967
|
+
return { status: r.status ?? 1 };
|
|
76968
|
+
},
|
|
76969
|
+
probeVersion: (agent) => {
|
|
76970
|
+
const r = spawnSync10("docker", ["exec", `switchroom-${agent}`, "sh", "-lc", "switchroom --version"], { encoding: "utf8" });
|
|
76971
|
+
if (r.status !== 0)
|
|
76972
|
+
return null;
|
|
76973
|
+
return (r.stdout ?? "").trim().split(`
|
|
76974
|
+
`).pop()?.trim() ?? null;
|
|
76975
|
+
},
|
|
76976
|
+
log: (line) => process.stdout.write(line + `
|
|
76977
|
+
`)
|
|
76978
|
+
};
|
|
76979
|
+
process.stdout.write(`Rolling ${requested.length} agent(s) to ${target}\u2026
|
|
76980
|
+
`);
|
|
76981
|
+
const result = executeRollout(steps, target, deps, opts.pin != null);
|
|
76982
|
+
for (const w of result.warnings)
|
|
76983
|
+
process.stderr.write(`\u26a0\ufe0f ${w}
|
|
76984
|
+
`);
|
|
76985
|
+
if (!result.ok) {
|
|
76986
|
+
process.stderr.write(`
|
|
76987
|
+
\u2717 Rollout STOPPED at ${result.failedStep}` + (result.failedAgent ? ` (${result.failedAgent} \u2192 ${result.got ?? "unreachable"})` : "") + `.
|
|
76988
|
+
Rolled before stop: ${result.rolled.join(", ") || "none"}.
|
|
76989
|
+
` + ` Fix the cause, then re-run \`switchroom rollout --pin ${target}\` ` + `(idempotent \u2014 already-current agents bounce back to the same version).
|
|
76990
|
+
`);
|
|
76991
|
+
process.exitCode = 1;
|
|
76992
|
+
return;
|
|
76993
|
+
}
|
|
76994
|
+
process.stdout.write(`
|
|
76995
|
+
\u2705 Rollout complete \u2014 ${result.rolled.length} agent(s) on ${target}` + `${result.warnings.length ? ` (with ${result.warnings.length} warning(s) above)` : ""}.
|
|
76996
|
+
`);
|
|
76997
|
+
});
|
|
76998
|
+
}
|
|
76999
|
+
|
|
76812
77000
|
// src/cli/restart.ts
|
|
76813
77001
|
init_source();
|
|
76814
77002
|
init_helpers();
|
|
@@ -78264,7 +78452,7 @@ init_helpers();
|
|
|
78264
78452
|
init_loader();
|
|
78265
78453
|
import { existsSync as existsSync64 } from "node:fs";
|
|
78266
78454
|
import { resolve as resolve39, sep as sep3 } from "node:path";
|
|
78267
|
-
import { spawnSync as
|
|
78455
|
+
import { spawnSync as spawnSync11 } from "node:child_process";
|
|
78268
78456
|
|
|
78269
78457
|
// src/agents/workspace.ts
|
|
78270
78458
|
import { readFile as readFile2, stat } from "node:fs/promises";
|
|
@@ -78977,7 +79165,7 @@ function registerWorkspaceCommand(program3) {
|
|
|
78977
79165
|
process.exit(1);
|
|
78978
79166
|
}
|
|
78979
79167
|
const editor = process.env["EDITOR"] ?? process.env["VISUAL"] ?? "vi";
|
|
78980
|
-
const child =
|
|
79168
|
+
const child = spawnSync11(editor, [target], { stdio: "inherit" });
|
|
78981
79169
|
if (child.status !== 0 && child.status !== null) {
|
|
78982
79170
|
process.exit(child.status);
|
|
78983
79171
|
}
|
|
@@ -79044,7 +79232,7 @@ function registerWorkspaceCommand(program3) {
|
|
|
79044
79232
|
`);
|
|
79045
79233
|
return;
|
|
79046
79234
|
}
|
|
79047
|
-
const statusResult =
|
|
79235
|
+
const statusResult = spawnSync11("git", ["status", "--short"], {
|
|
79048
79236
|
cwd: dir,
|
|
79049
79237
|
encoding: "utf-8"
|
|
79050
79238
|
});
|
|
@@ -79059,7 +79247,7 @@ function registerWorkspaceCommand(program3) {
|
|
|
79059
79247
|
return;
|
|
79060
79248
|
}
|
|
79061
79249
|
const message = opts.message || `checkpoint: ${new Date().toISOString()}`;
|
|
79062
|
-
const addResult =
|
|
79250
|
+
const addResult = spawnSync11("git", ["add", "-A"], {
|
|
79063
79251
|
cwd: dir,
|
|
79064
79252
|
encoding: "utf-8"
|
|
79065
79253
|
});
|
|
@@ -79068,7 +79256,7 @@ function registerWorkspaceCommand(program3) {
|
|
|
79068
79256
|
`);
|
|
79069
79257
|
process.exit(1);
|
|
79070
79258
|
}
|
|
79071
|
-
const commitResult =
|
|
79259
|
+
const commitResult = spawnSync11("git", ["commit", "-m", message], {
|
|
79072
79260
|
cwd: dir,
|
|
79073
79261
|
encoding: "utf-8"
|
|
79074
79262
|
});
|
|
@@ -79077,7 +79265,7 @@ function registerWorkspaceCommand(program3) {
|
|
|
79077
79265
|
`);
|
|
79078
79266
|
process.exit(1);
|
|
79079
79267
|
}
|
|
79080
|
-
const shaResult =
|
|
79268
|
+
const shaResult = spawnSync11("git", ["rev-parse", "--short", "HEAD"], {
|
|
79081
79269
|
cwd: dir,
|
|
79082
79270
|
encoding: "utf-8"
|
|
79083
79271
|
});
|
|
@@ -79098,7 +79286,7 @@ function registerWorkspaceCommand(program3) {
|
|
|
79098
79286
|
`);
|
|
79099
79287
|
return;
|
|
79100
79288
|
}
|
|
79101
|
-
const child =
|
|
79289
|
+
const child = spawnSync11("git", ["status", "--short"], {
|
|
79102
79290
|
cwd: dir,
|
|
79103
79291
|
stdio: "inherit"
|
|
79104
79292
|
});
|
|
@@ -84093,7 +84281,7 @@ import {
|
|
|
84093
84281
|
} from "node:fs";
|
|
84094
84282
|
import { tmpdir as tmpdir5, homedir as homedir45 } from "node:os";
|
|
84095
84283
|
import { dirname as dirname23, join as join79, relative as relative2, resolve as resolve47 } from "node:path";
|
|
84096
|
-
import { spawnSync as
|
|
84284
|
+
import { spawnSync as spawnSync12 } from "node:child_process";
|
|
84097
84285
|
|
|
84098
84286
|
// src/cli/skill-common.ts
|
|
84099
84287
|
var import_yaml22 = __toESM(require_dist(), 1);
|
|
@@ -84346,7 +84534,7 @@ function loadFromDir(dir) {
|
|
|
84346
84534
|
function loadFromTarball(tarPath) {
|
|
84347
84535
|
const isGz = tarPath.endsWith(".gz") || tarPath.endsWith(".tgz");
|
|
84348
84536
|
const listFlags = isGz ? ["-tzf"] : ["-tf"];
|
|
84349
|
-
const list2 =
|
|
84537
|
+
const list2 = spawnSync12("tar", [...listFlags, tarPath], {
|
|
84350
84538
|
encoding: "utf-8",
|
|
84351
84539
|
stdio: ["ignore", "pipe", "pipe"]
|
|
84352
84540
|
});
|
|
@@ -84363,7 +84551,7 @@ function loadFromTarball(tarPath) {
|
|
|
84363
84551
|
const staging = mkdtempSync5(join79(tmpdir5(), "skill-apply-extract-"));
|
|
84364
84552
|
try {
|
|
84365
84553
|
const flags = isGz ? ["-xzf"] : ["-xf"];
|
|
84366
|
-
const r =
|
|
84554
|
+
const r = spawnSync12("tar", [
|
|
84367
84555
|
...flags,
|
|
84368
84556
|
tarPath,
|
|
84369
84557
|
"-C",
|
|
@@ -84438,7 +84626,7 @@ function validatePayload(name, files) {
|
|
|
84438
84626
|
if (errors2.length === 0) {
|
|
84439
84627
|
for (const [path8, content] of Object.entries(files)) {
|
|
84440
84628
|
if (SH_SCRIPT_RE2.test(path8)) {
|
|
84441
|
-
const r =
|
|
84629
|
+
const r = spawnSync12("bash", ["-n"], {
|
|
84442
84630
|
input: content,
|
|
84443
84631
|
encoding: "utf-8"
|
|
84444
84632
|
});
|
|
@@ -84450,7 +84638,7 @@ function validatePayload(name, files) {
|
|
|
84450
84638
|
const tmpPy = join79(tmp, "check.py");
|
|
84451
84639
|
try {
|
|
84452
84640
|
writeFileSync39(tmpPy, content);
|
|
84453
|
-
const r =
|
|
84641
|
+
const r = spawnSync12("python3", ["-m", "py_compile", tmpPy], {
|
|
84454
84642
|
encoding: "utf-8"
|
|
84455
84643
|
});
|
|
84456
84644
|
if (r.status !== 0) {
|
|
@@ -84613,7 +84801,7 @@ function registerSkillCommand(program3) {
|
|
|
84613
84801
|
\u2713 Wrote ${name} to ${currentDir}`));
|
|
84614
84802
|
const applyBin = process.argv[1] ?? "switchroom";
|
|
84615
84803
|
console.log(source_default.gray(`Running \`switchroom apply --non-interactive\`...`));
|
|
84616
|
-
const r =
|
|
84804
|
+
const r = spawnSync12(process.argv0, [applyBin, "apply", "--non-interactive"], { stdio: "inherit" });
|
|
84617
84805
|
if (r.status !== 0) {
|
|
84618
84806
|
console.error(source_default.yellow(`(warning: \`switchroom apply\` exited ${r.status} \u2014 skill is ` + `in the pool but symlinks may not be refreshed. Re-run manually.)`));
|
|
84619
84807
|
}
|
|
@@ -84645,7 +84833,7 @@ import {
|
|
|
84645
84833
|
} from "node:fs";
|
|
84646
84834
|
import { dirname as dirname24, join as join80, relative as relative3, resolve as resolve48 } from "node:path";
|
|
84647
84835
|
import { homedir as homedir46, tmpdir as tmpdir6 } from "node:os";
|
|
84648
|
-
import { spawnSync as
|
|
84836
|
+
import { spawnSync as spawnSync13 } from "node:child_process";
|
|
84649
84837
|
init_helpers();
|
|
84650
84838
|
init_source();
|
|
84651
84839
|
var PERSONAL_PREFIX = "personal-";
|
|
@@ -84834,7 +85022,7 @@ function behavioralValidate(files) {
|
|
|
84834
85022
|
const errors2 = [];
|
|
84835
85023
|
for (const [path8, content] of Object.entries(files)) {
|
|
84836
85024
|
if (SH_SCRIPT_RE.test(path8)) {
|
|
84837
|
-
const r =
|
|
85025
|
+
const r = spawnSync13("bash", ["-n"], { input: content, encoding: "utf-8" });
|
|
84838
85026
|
if (r.status !== 0) {
|
|
84839
85027
|
errors2.push(`${path8} fails \`bash -n\`: ${(r.stderr ?? "").trim()}`);
|
|
84840
85028
|
}
|
|
@@ -84843,7 +85031,7 @@ function behavioralValidate(files) {
|
|
|
84843
85031
|
const tmpPy = join80(tmp, "check.py");
|
|
84844
85032
|
try {
|
|
84845
85033
|
writeFileSync40(tmpPy, content);
|
|
84846
|
-
const r =
|
|
85034
|
+
const r = spawnSync13("python3", ["-m", "py_compile", tmpPy], {
|
|
84847
85035
|
encoding: "utf-8"
|
|
84848
85036
|
});
|
|
84849
85037
|
if (r.status !== 0) {
|
|
@@ -85536,7 +85724,7 @@ init_helpers();
|
|
|
85536
85724
|
import { existsSync as existsSync84, mkdirSync as mkdirSync47, readdirSync as readdirSync33, readFileSync as readFileSync71, writeFileSync as writeFileSync41, statSync as statSync34, copyFileSync as copyFileSync12 } from "node:fs";
|
|
85537
85725
|
import { homedir as homedir48 } from "node:os";
|
|
85538
85726
|
import { join as join82 } from "node:path";
|
|
85539
|
-
import { spawnSync as
|
|
85727
|
+
import { spawnSync as spawnSync15 } from "node:child_process";
|
|
85540
85728
|
init_audit_reader();
|
|
85541
85729
|
function resolveHostdImageTag(explicitTag, release) {
|
|
85542
85730
|
if (explicitTag)
|
|
@@ -85668,7 +85856,7 @@ function backupExistingCompose() {
|
|
|
85668
85856
|
return bak;
|
|
85669
85857
|
}
|
|
85670
85858
|
function runDocker(args) {
|
|
85671
|
-
const r =
|
|
85859
|
+
const r = spawnSync15("docker", args, { encoding: "utf8" });
|
|
85672
85860
|
return {
|
|
85673
85861
|
ok: r.status === 0,
|
|
85674
85862
|
stdout: r.stdout ?? "",
|
|
@@ -85861,7 +86049,7 @@ init_helpers();
|
|
|
85861
86049
|
import { existsSync as existsSync85, mkdirSync as mkdirSync48, writeFileSync as writeFileSync42, copyFileSync as copyFileSync13 } from "node:fs";
|
|
85862
86050
|
import { homedir as homedir49 } from "node:os";
|
|
85863
86051
|
import { join as join83 } from "node:path";
|
|
85864
|
-
import { spawnSync as
|
|
86052
|
+
import { spawnSync as spawnSync16 } from "node:child_process";
|
|
85865
86053
|
function resolveWebImageTag(explicitTag, release) {
|
|
85866
86054
|
if (explicitTag)
|
|
85867
86055
|
return explicitTag;
|
|
@@ -85960,7 +86148,7 @@ function backupExistingCompose2() {
|
|
|
85960
86148
|
return bak;
|
|
85961
86149
|
}
|
|
85962
86150
|
function runDocker2(args) {
|
|
85963
|
-
const r =
|
|
86151
|
+
const r = spawnSync16("docker", args, { encoding: "utf8" });
|
|
85964
86152
|
return {
|
|
85965
86153
|
ok: r.status === 0,
|
|
85966
86154
|
stdout: r.stdout ?? "",
|
|
@@ -86090,6 +86278,7 @@ var program3 = new Command().name("switchroom").description("Multi-agent orchest
|
|
|
86090
86278
|
registerSetupCommand(program3);
|
|
86091
86279
|
registerDoctorCommand(program3);
|
|
86092
86280
|
registerUpdateCommand(program3);
|
|
86281
|
+
registerRolloutCommand(program3);
|
|
86093
86282
|
registerRestartCommand(program3);
|
|
86094
86283
|
registerVersionCommand(program3);
|
|
86095
86284
|
registerVersionsCommand(program3);
|
package/package.json
CHANGED
|
@@ -54158,11 +54158,11 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
|
|
|
54158
54158
|
}
|
|
54159
54159
|
|
|
54160
54160
|
// ../src/build-info.ts
|
|
54161
|
-
var VERSION = "0.15.
|
|
54162
|
-
var COMMIT_SHA = "
|
|
54163
|
-
var COMMIT_DATE = "2026-06-
|
|
54164
|
-
var LATEST_PR =
|
|
54165
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
54161
|
+
var VERSION = "0.15.18";
|
|
54162
|
+
var COMMIT_SHA = "d7c044b9";
|
|
54163
|
+
var COMMIT_DATE = "2026-06-14T08:55:07+10:00";
|
|
54164
|
+
var LATEST_PR = null;
|
|
54165
|
+
var COMMITS_AHEAD_OF_TAG = 3;
|
|
54166
54166
|
|
|
54167
54167
|
// gateway/boot-version.ts
|
|
54168
54168
|
function formatRelativeAgo(iso) {
|
|
@@ -57206,10 +57206,8 @@ if (inboundSpool != null) {
|
|
|
57206
57206
|
}
|
|
57207
57207
|
}
|
|
57208
57208
|
var pendingPermissionBuffer = createPendingPermissionBuffer();
|
|
57209
|
-
function buildPermissionActionRow(requestId, showAlways
|
|
57210
|
-
const kb = new import_grammy9.InlineKeyboard().text("\u274C Deny", `perm:deny:${requestId}`).text("\u2705 Allow
|
|
57211
|
-
if (showTimeBox)
|
|
57212
|
-
kb.text("\u23F1 30 min", `perm:tmb:${requestId}`);
|
|
57209
|
+
function buildPermissionActionRow(requestId, showAlways) {
|
|
57210
|
+
const kb = new import_grammy9.InlineKeyboard().text("\u274C Deny", `perm:deny:${requestId}`).text("\u2705 Allow", `perm:allow:${requestId}`);
|
|
57213
57211
|
if (showAlways)
|
|
57214
57212
|
kb.text("\uD83D\uDD01 Always\u2026", `perm:always:${requestId}`);
|
|
57215
57213
|
return kb;
|
|
@@ -57465,10 +57463,8 @@ var ipcServer = createIpcServer({
|
|
|
57465
57463
|
description,
|
|
57466
57464
|
agentName: _client.agentName
|
|
57467
57465
|
});
|
|
57468
|
-
const
|
|
57469
|
-
const
|
|
57470
|
-
const showTimeBox = scopedApprovalTtlMs() > 0 && resolveTimeBox(toolName, inputPreview, scopeChoices) != null;
|
|
57471
|
-
const keyboard = buildPermissionActionRow(requestId, showAlways, showTimeBox);
|
|
57466
|
+
const showAlways = resolveScopedAllowChoices(toolName, inputPreview) != null;
|
|
57467
|
+
const keyboard = buildPermissionActionRow(requestId, showAlways);
|
|
57472
57468
|
const activeTurn = currentTurn;
|
|
57473
57469
|
const targets = resolvePermissionCardTargets();
|
|
57474
57470
|
for (const { chatId, threadId } of targets) {
|
|
@@ -65080,8 +65076,8 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
|
|
|
65080
65076
|
const cbMessageId = ctx.callbackQuery?.message?.message_id;
|
|
65081
65077
|
const metaForMessage = cbMessageId != null ? agentButtonMeta.get(`${cbChatId}:${cbMessageId}`) : undefined;
|
|
65082
65078
|
const tapMeta = metaForMessage?.get(agentCb.raw);
|
|
65083
|
-
const
|
|
65084
|
-
await ctx.answerCallbackQuery({ text:
|
|
65079
|
+
const ackText2 = typeof tapMeta?.ack_text === "string" && tapMeta.ack_text.length > 0 ? tapMeta.ack_text : "\u2713 received";
|
|
65080
|
+
await ctx.answerCallbackQuery({ text: ackText2 }).catch(() => {});
|
|
65085
65081
|
const buttonText = (() => {
|
|
65086
65082
|
const msg2 = ctx.callbackQuery?.message;
|
|
65087
65083
|
const kb = msg2 && "reply_markup" in msg2 ? msg2.reply_markup : undefined;
|
|
@@ -65150,7 +65146,7 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
|
|
|
65150
65146
|
}
|
|
65151
65147
|
return;
|
|
65152
65148
|
}
|
|
65153
|
-
const m = /^perm:(allow|deny|always|asn|asb|back
|
|
65149
|
+
const m = /^perm:(allow|deny|always|asn|asb|back):([a-km-z]{5})$/.exec(data);
|
|
65154
65150
|
if (!m) {
|
|
65155
65151
|
await ctx.answerCallbackQuery().catch(() => {});
|
|
65156
65152
|
return;
|
|
@@ -65170,9 +65166,7 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
|
|
|
65170
65166
|
}
|
|
65171
65167
|
let keyboard;
|
|
65172
65168
|
if (behavior === "back") {
|
|
65173
|
-
|
|
65174
|
-
const backTimeBox = scopedApprovalTtlMs() > 0 && resolveTimeBox(details.tool_name, details.input_preview, backChoices) != null;
|
|
65175
|
-
keyboard = buildPermissionActionRow(request_id, true, backTimeBox);
|
|
65169
|
+
keyboard = buildPermissionActionRow(request_id, true);
|
|
65176
65170
|
} else {
|
|
65177
65171
|
const choices = resolveScopedAllowChoices(details.tool_name, details.input_preview);
|
|
65178
65172
|
if (choices == null) {
|
|
@@ -65305,12 +65299,12 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
|
|
|
65305
65299
|
}
|
|
65306
65300
|
const ok = durable;
|
|
65307
65301
|
const legacyNote = legacy && durable;
|
|
65308
|
-
const
|
|
65302
|
+
const ackText2 = ok ? legacyNote ? `\u2705 Saved. ${agentName3} can now ${grantPhrase} without asking (legacy path).` : `\u2705 Saved. ${agentName3} can now ${grantPhrase} without asking.` : editLockHint ? `\u26A0\uFE0F Allowed for now \u2014 config edits are locked. Enable hostd.config_edit_enabled.` : `\u26A0\uFE0F Allowed for now, but "always" did NOT save \u2014 it will ask again after restart. Check gateway log.`;
|
|
65309
65303
|
const sourceMsg = ctx.callbackQuery?.message;
|
|
65310
65304
|
const baseText2 = sourceMsg && "text" in sourceMsg && sourceMsg.text ? escapeHtmlForTg(sourceMsg.text) : "";
|
|
65311
65305
|
const editLabel = ok ? legacyNote ? `\u2705 <b>${escapeHtmlForTg(agentName3)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking (legacy path); restart agent for full effect` : `\u2705 <b>${escapeHtmlForTg(agentName3)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking; restart agent for full effect` : editLockHint ? `\u26A0\uFE0F <b>Allowed for now \u2014 "always" did NOT save.</b> Config edits are locked; enable <code>hostd.config_edit_enabled</code>.` : `\u26A0\uFE0F <b>Allowed for now \u2014 "always" did NOT save.</b> It will ask again after restart. Check gateway log.`;
|
|
65312
65306
|
await finalizeCallback(ctx, {
|
|
65313
|
-
ackText:
|
|
65307
|
+
ackText: ackText2.slice(0, 200),
|
|
65314
65308
|
newText: baseText2 ? `${baseText2}
|
|
65315
65309
|
|
|
65316
65310
|
${editLabel}` : editLabel,
|
|
@@ -65318,64 +65312,28 @@ ${editLabel}` : editLabel,
|
|
|
65318
65312
|
});
|
|
65319
65313
|
return;
|
|
65320
65314
|
}
|
|
65321
|
-
|
|
65322
|
-
|
|
65323
|
-
|
|
65324
|
-
|
|
65325
|
-
|
|
65326
|
-
|
|
65327
|
-
|
|
65328
|
-
|
|
65329
|
-
|
|
65330
|
-
return;
|
|
65331
|
-
}
|
|
65332
|
-
const choices = resolveScopedAllowChoices(details.tool_name, details.input_preview);
|
|
65333
|
-
const tb = resolveTimeBox(details.tool_name, details.input_preview, choices);
|
|
65334
|
-
if (!tb) {
|
|
65335
|
-
await ctx.answerCallbackQuery({ text: "This action can't be time-boxed." }).catch(() => {});
|
|
65336
|
-
return;
|
|
65337
|
-
}
|
|
65338
|
-
const agentName3 = selfAgentName();
|
|
65339
|
-
if (!agentName3) {
|
|
65340
|
-
await ctx.answerCallbackQuery({ text: "Time-box needs SWITCHROOM_AGENT_NAME \u2014 gateway is misconfigured." }).catch(() => {});
|
|
65341
|
-
return;
|
|
65342
|
-
}
|
|
65343
|
-
pendingPermissions.delete(request_id);
|
|
65344
|
-
dispatchPermissionVerdict({ type: "permission", requestId: request_id, behavior: "allow" });
|
|
65345
|
-
recordScopedGrant(scopedGrants, agentName3, tb.rule, Date.now(), ttl);
|
|
65346
|
-
resumeReactionAfterVerdict();
|
|
65347
|
-
postPermissionResumeMessage({
|
|
65348
|
-
behavior: "allow",
|
|
65349
|
-
action: naturalAction(details.tool_name, details.input_preview)
|
|
65350
|
-
});
|
|
65351
|
-
process.stderr.write(`telegram gateway: scoped-approval granted rule="${tb.rule}" agent=${agentName3} ttl_ms=${ttl} (request_id=${request_id})
|
|
65315
|
+
const pd = pendingPermissions.get(request_id);
|
|
65316
|
+
const resumeAction = pd ? naturalAction(pd.tool_name, pd.input_preview) : "";
|
|
65317
|
+
const scopedTtl = scopedApprovalTtlMs();
|
|
65318
|
+
const timeBox = behavior === "allow" && scopedTtl > 0 && pd ? resolveTimeBox(pd.tool_name, pd.input_preview, resolveScopedAllowChoices(pd.tool_name, pd.input_preview)) : null;
|
|
65319
|
+
const grantAgent = selfAgentName();
|
|
65320
|
+
pendingPermissions.delete(request_id);
|
|
65321
|
+
if (timeBox && grantAgent) {
|
|
65322
|
+
recordScopedGrant(scopedGrants, grantAgent, timeBox.rule, Date.now(), scopedTtl);
|
|
65323
|
+
process.stderr.write(`telegram gateway: scoped-approval granted via Allow rule="${timeBox.rule}" agent=${grantAgent} ttl_ms=${scopedTtl} (request_id=${request_id})
|
|
65352
65324
|
`);
|
|
65353
|
-
const mins = Math.max(1, Math.round(ttl / 60000));
|
|
65354
|
-
const sourceMsg = ctx.callbackQuery?.message;
|
|
65355
|
-
const baseText2 = sourceMsg && "text" in sourceMsg && sourceMsg.text ? escapeHtmlForTg(sourceMsg.text) : "";
|
|
65356
|
-
const editLabel = `\u23F1 <b>Allowed for ${mins} min \u2014 ${escapeHtmlForTg(tb.breadth)}</b> \xB7 re-asks after that, and now for anything else`;
|
|
65357
|
-
await finalizeCallback(ctx, {
|
|
65358
|
-
ackText: `\u23F1 Allowed for ${mins} min`.slice(0, 200),
|
|
65359
|
-
newText: baseText2 ? `${baseText2}
|
|
65360
|
-
|
|
65361
|
-
${editLabel}` : editLabel,
|
|
65362
|
-
parseMode: "HTML"
|
|
65363
|
-
});
|
|
65364
|
-
return;
|
|
65365
65325
|
}
|
|
65366
|
-
const
|
|
65367
|
-
|
|
65368
|
-
|
|
65369
|
-
|
|
65370
|
-
pendingPermissions.delete(request_id);
|
|
65371
|
-
const label = behavior === "allow" ? "\u2705 Allowed" : "\u274C Denied";
|
|
65326
|
+
const scopedMins = Math.max(1, Math.round(scopedTtl / 60000));
|
|
65327
|
+
const windowGranted = timeBox != null && grantAgent !== "";
|
|
65328
|
+
const ackText = behavior === "deny" ? "\u274C Denied" : windowGranted ? `\u2705 Allowed (${scopedMins} min)` : "\u2705 Allowed once";
|
|
65329
|
+
const htmlLabel = behavior === "deny" ? "\u274C <b>Denied</b>" : windowGranted ? `\u2705 <b>Allowed \u2014 won't ask again about ${escapeHtmlForTg(timeBox.breadth)} for ${scopedMins} min</b>` : "\u2705 <b>Allowed once</b>";
|
|
65372
65330
|
const msg = ctx.callbackQuery?.message;
|
|
65373
65331
|
const baseText = msg && "text" in msg && msg.text ? escapeHtmlForTg(msg.text) : "";
|
|
65374
65332
|
await finalizeCallback(ctx, {
|
|
65375
|
-
ackText:
|
|
65333
|
+
ackText: ackText.slice(0, 200),
|
|
65376
65334
|
newText: baseText ? `${baseText}
|
|
65377
65335
|
|
|
65378
|
-
${
|
|
65336
|
+
${htmlLabel}` : htmlLabel,
|
|
65379
65337
|
parseMode: "HTML",
|
|
65380
65338
|
synthInbound: () => {
|
|
65381
65339
|
dispatchPermissionVerdict({
|
|
@@ -3660,8 +3660,9 @@ function sweepStaleAlwaysAllowCorrelations(now = Date.now()): void {
|
|
|
3660
3660
|
}
|
|
3661
3661
|
}
|
|
3662
3662
|
|
|
3663
|
-
//
|
|
3664
|
-
//
|
|
3663
|
+
// Scoped-approval store: the 30-min window that backs the "✅ Allow" tap for
|
|
3664
|
+
// narrow non-destructive scopes (not a separate button — it IS what Allow
|
|
3665
|
+
// means for those). Operator-tapped, gateway-side ONLY (never pushed to the
|
|
3665
3666
|
// bridge's untimed sessionAllowRules), fixed-window, fail-closed. Keyed by
|
|
3666
3667
|
// agent name for per-agent isolation. All policy lives in
|
|
3667
3668
|
// ../scoped-approval.ts (pure + unit-tested); this gateway only wires it.
|
|
@@ -5605,24 +5606,27 @@ const pendingPermissionBuffer = createPendingPermissionBuffer()
|
|
|
5605
5606
|
* are resolved at call-time, after module init.)
|
|
5606
5607
|
*/
|
|
5607
5608
|
/**
|
|
5608
|
-
* The default permission-card action row: ❌ Deny · ✅ Allow
|
|
5609
|
+
* The default permission-card action row: ❌ Deny · ✅ Allow ·
|
|
5609
5610
|
* 🔁 Always… (the last only when a meaningful always-rule exists).
|
|
5610
5611
|
* Tapping "🔁 Always…" swaps this row for the scope sub-menu; "← Back"
|
|
5611
5612
|
* rebuilds this row. callback_data stays tiny (verb + 5-char id) so we
|
|
5612
5613
|
* never approach Telegram's 64-byte ceiling.
|
|
5614
|
+
*
|
|
5615
|
+
* "✅ Allow" is no longer always literally "once": for a NARROW,
|
|
5616
|
+
* non-destructive scope it auto-grants a fixed 30-min window (so the same
|
|
5617
|
+
* action stops re-asking) — that is the default behavior of the allow tap,
|
|
5618
|
+
* not a separate button. Broad / MCP / destructive scopes stay truly once.
|
|
5619
|
+
* The post-tap card states which happened (honest-card contract). The
|
|
5620
|
+
* decision lives in the allow handler via resolveTimeBox; the label here is
|
|
5621
|
+
* deliberately neutral ("Allow", not "Allow once" or "Allow 30 min").
|
|
5613
5622
|
*/
|
|
5614
5623
|
function buildPermissionActionRow(
|
|
5615
5624
|
requestId: string,
|
|
5616
5625
|
showAlways: boolean,
|
|
5617
|
-
showTimeBox = false,
|
|
5618
5626
|
): InlineKeyboard {
|
|
5619
5627
|
const kb = new InlineKeyboard()
|
|
5620
5628
|
.text('❌ Deny', `perm:deny:${requestId}`)
|
|
5621
|
-
.text('✅ Allow
|
|
5622
|
-
// "⏱ 30 min" sits between once and always. Only shown for a narrow,
|
|
5623
|
-
// non-destructive scope (resolveTimeBox decides); broad/MCP/destructive
|
|
5624
|
-
// requests get once/always only.
|
|
5625
|
-
if (showTimeBox) kb.text('⏱ 30 min', `perm:tmb:${requestId}`)
|
|
5629
|
+
.text('✅ Allow', `perm:allow:${requestId}`)
|
|
5626
5630
|
if (showAlways) kb.text('🔁 Always…', `perm:always:${requestId}`)
|
|
5627
5631
|
return kb
|
|
5628
5632
|
}
|
|
@@ -6044,19 +6048,15 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
6044
6048
|
description,
|
|
6045
6049
|
agentName: _client.agentName,
|
|
6046
6050
|
})
|
|
6047
|
-
// Compact action row: ❌ Deny · ✅ Allow
|
|
6048
|
-
//
|
|
6049
|
-
//
|
|
6050
|
-
//
|
|
6051
|
-
//
|
|
6052
|
-
//
|
|
6053
|
-
|
|
6054
|
-
const showAlways =
|
|
6055
|
-
|
|
6056
|
-
// when the tier is enabled (TTL > 0).
|
|
6057
|
-
const showTimeBox = scopedApprovalTtlMs() > 0 &&
|
|
6058
|
-
resolveTimeBox(toolName, inputPreview, scopeChoices) != null
|
|
6059
|
-
const keyboard = buildPermissionActionRow(requestId, showAlways, showTimeBox)
|
|
6051
|
+
// Compact action row: ❌ Deny · ✅ Allow · 🔁 Always… — the scope of an
|
|
6052
|
+
// "always" grant stays hidden until the operator taps "🔁 Always…",
|
|
6053
|
+
// which swaps the row for a scope choice (this file / any file ⚠️). The
|
|
6054
|
+
// "🔁 Always…" button only appears when we can synthesize a meaningful
|
|
6055
|
+
// rule for this tool; unknown tools get the two-button row only. "Allow"
|
|
6056
|
+
// itself auto-grants a 30-min window for narrow non-destructive scopes
|
|
6057
|
+
// (decided in the allow handler), so there is no separate time-box button.
|
|
6058
|
+
const showAlways = resolveScopedAllowChoices(toolName, inputPreview) != null
|
|
6059
|
+
const keyboard = buildPermissionActionRow(requestId, showAlways)
|
|
6060
6060
|
// Route the card to the SAME place the post-verdict resume message
|
|
6061
6061
|
// lands (resolvePermissionCardTargets): the ORIGINATING chat+topic when
|
|
6062
6062
|
// there's an active turn — so a supergroup agent's card appears IN the
|
|
@@ -19073,7 +19073,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
19073
19073
|
}
|
|
19074
19074
|
|
|
19075
19075
|
// Permission request buttons.
|
|
19076
|
-
const m = /^perm:(allow|deny|always|asn|asb|back
|
|
19076
|
+
const m = /^perm:(allow|deny|always|asn|asb|back):([a-km-z]{5})$/.exec(data)
|
|
19077
19077
|
if (!m) { await ctx.answerCallbackQuery().catch(() => {}); return }
|
|
19078
19078
|
const access = loadAccess()
|
|
19079
19079
|
const senderId = String(ctx.from.id)
|
|
@@ -19088,10 +19088,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
19088
19088
|
if (!details) { await ctx.answerCallbackQuery({ text: 'Details no longer available.' }).catch(() => {}); return }
|
|
19089
19089
|
let keyboard: InlineKeyboard
|
|
19090
19090
|
if (behavior === 'back') {
|
|
19091
|
-
|
|
19092
|
-
const backTimeBox = scopedApprovalTtlMs() > 0 &&
|
|
19093
|
-
resolveTimeBox(details.tool_name, details.input_preview, backChoices) != null
|
|
19094
|
-
keyboard = buildPermissionActionRow(request_id, true, backTimeBox)
|
|
19091
|
+
keyboard = buildPermissionActionRow(request_id, true)
|
|
19095
19092
|
} else {
|
|
19096
19093
|
const choices = resolveScopedAllowChoices(details.tool_name, details.input_preview)
|
|
19097
19094
|
if (choices == null) {
|
|
@@ -19315,69 +19312,47 @@ bot.on('callback_query:data', async ctx => {
|
|
|
19315
19312
|
return
|
|
19316
19313
|
}
|
|
19317
19314
|
|
|
19318
|
-
//
|
|
19319
|
-
//
|
|
19320
|
-
//
|
|
19321
|
-
//
|
|
19322
|
-
//
|
|
19323
|
-
|
|
19324
|
-
|
|
19325
|
-
|
|
19326
|
-
|
|
19327
|
-
|
|
19328
|
-
|
|
19329
|
-
|
|
19330
|
-
|
|
19331
|
-
|
|
19332
|
-
|
|
19333
|
-
|
|
19334
|
-
|
|
19335
|
-
|
|
19336
|
-
// strictly gateway-side; the bridge must not cache it untimed).
|
|
19337
|
-
dispatchPermissionVerdict({ type: 'permission', requestId: request_id, behavior: 'allow' })
|
|
19338
|
-
// (2) Record the fixed-window grant so matching calls auto-allow.
|
|
19339
|
-
recordScopedGrant(scopedGrants, agentName, tb.rule, Date.now(), ttl)
|
|
19340
|
-
resumeReactionAfterVerdict()
|
|
19341
|
-
postPermissionResumeMessage({
|
|
19342
|
-
behavior: 'allow',
|
|
19343
|
-
action: naturalAction(details.tool_name, details.input_preview),
|
|
19344
|
-
})
|
|
19315
|
+
// Forward permission decision to connected bridges. Capture the pending
|
|
19316
|
+
// details BEFORE deleting the entry — the resume message names the resumed
|
|
19317
|
+
// work, and "Allow" on a narrow non-destructive scope auto-grants a 30-min
|
|
19318
|
+
// window (resolveTimeBox) so the same action stops re-asking. This IS the
|
|
19319
|
+
// default behavior of Allow (no separate button); broad / MCP / destructive
|
|
19320
|
+
// scopes (resolveTimeBox → null) and the disabled tier (ttl<=0) stay truly
|
|
19321
|
+
// once. The verdict is still dispatched WITHOUT a `rule` (below), so the
|
|
19322
|
+
// bridge never caches it untimed — the window lives only in scopedGrants.
|
|
19323
|
+
const pd = pendingPermissions.get(request_id)
|
|
19324
|
+
const resumeAction = pd ? naturalAction(pd.tool_name, pd.input_preview) : ''
|
|
19325
|
+
const scopedTtl = scopedApprovalTtlMs()
|
|
19326
|
+
const timeBox = (behavior === 'allow' && scopedTtl > 0 && pd)
|
|
19327
|
+
? resolveTimeBox(pd.tool_name, pd.input_preview, resolveScopedAllowChoices(pd.tool_name, pd.input_preview))
|
|
19328
|
+
: null
|
|
19329
|
+
const grantAgent = selfAgentName()
|
|
19330
|
+
pendingPermissions.delete(request_id)
|
|
19331
|
+
if (timeBox && grantAgent) {
|
|
19332
|
+
recordScopedGrant(scopedGrants, grantAgent, timeBox.rule, Date.now(), scopedTtl)
|
|
19345
19333
|
process.stderr.write(
|
|
19346
|
-
`telegram gateway: scoped-approval granted rule="${
|
|
19347
|
-
`ttl_ms=${
|
|
19334
|
+
`telegram gateway: scoped-approval granted via Allow rule="${timeBox.rule}" ` +
|
|
19335
|
+
`agent=${grantAgent} ttl_ms=${scopedTtl} (request_id=${request_id})\n`,
|
|
19348
19336
|
)
|
|
19349
|
-
|
|
19350
|
-
const mins = Math.max(1, Math.round(ttl / 60_000))
|
|
19351
|
-
// Honest card: state the real BREADTH (e.g. "any `git` command"), not
|
|
19352
|
-
// just the rule, plus the window — consent covers both (access-model
|
|
19353
|
-
// honest-card contract).
|
|
19354
|
-
const sourceMsg = ctx.callbackQuery?.message
|
|
19355
|
-
const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
|
|
19356
|
-
? escapeHtmlForTg(sourceMsg.text)
|
|
19357
|
-
: ''
|
|
19358
|
-
const editLabel = `⏱ <b>Allowed for ${mins} min — ${escapeHtmlForTg(tb.breadth)}</b> · re-asks after that, and now for anything else`
|
|
19359
|
-
await finalizeCallback(ctx, {
|
|
19360
|
-
ackText: `⏱ Allowed for ${mins} min`.slice(0, 200),
|
|
19361
|
-
newText: baseText ? `${baseText}\n\n${editLabel}` : editLabel,
|
|
19362
|
-
parseMode: 'HTML',
|
|
19363
|
-
})
|
|
19364
|
-
return
|
|
19365
19337
|
}
|
|
19366
|
-
|
|
19367
|
-
// Forward permission decision to connected bridges. Capture the work
|
|
19368
|
-
// phrase BEFORE deleting the pending entry — postPermissionResumeMessage
|
|
19369
|
-
// (fired in synthInbound below) names the resumed work.
|
|
19370
|
-
const resumeAction = (() => {
|
|
19371
|
-
const d = pendingPermissions.get(request_id)
|
|
19372
|
-
return d ? naturalAction(d.tool_name, d.input_preview) : ''
|
|
19373
|
-
})()
|
|
19374
|
-
pendingPermissions.delete(request_id)
|
|
19338
|
+
const scopedMins = Math.max(1, Math.round(scopedTtl / 60_000))
|
|
19375
19339
|
// The card collapses to a plain verdict label. The distinct agent-voiced
|
|
19376
19340
|
// "got it, continuing: …" message (posted on resume below) now carries
|
|
19377
19341
|
// the "is it working or did my tap do nothing?" signal the old
|
|
19378
19342
|
// `▶️ resuming…` card footnote used to — and names the work, which the
|
|
19379
19343
|
// footnote never did. Keeps the card terse and the resume legible.
|
|
19380
|
-
|
|
19344
|
+
// Honest-card: only claim the window when one was actually recorded
|
|
19345
|
+
// (timeBox eligible AND we had an agent name to key it under) — never
|
|
19346
|
+
// promise "won't ask again for 30 min" if nothing was stored.
|
|
19347
|
+
const windowGranted = timeBox != null && grantAgent !== ''
|
|
19348
|
+
const ackText = behavior === 'deny'
|
|
19349
|
+
? '❌ Denied'
|
|
19350
|
+
: (windowGranted ? `✅ Allowed (${scopedMins} min)` : '✅ Allowed once')
|
|
19351
|
+
const htmlLabel = behavior === 'deny'
|
|
19352
|
+
? '❌ <b>Denied</b>'
|
|
19353
|
+
: (windowGranted
|
|
19354
|
+
? `✅ <b>Allowed — won't ask again about ${escapeHtmlForTg(timeBox!.breadth)} for ${scopedMins} min</b>`
|
|
19355
|
+
: '✅ <b>Allowed once</b>')
|
|
19381
19356
|
// HTML-escape the source text — same hazard as the scope-commit and
|
|
19382
19357
|
// recent-denial paths above. The permission card body
|
|
19383
19358
|
// (formatPermissionCardBody) appends claude-supplied `description`
|
|
@@ -19396,10 +19371,12 @@ bot.on('callback_query:data', async ctx => {
|
|
|
19396
19371
|
// permission was already broadcast. Routing through finalizeCallback
|
|
19397
19372
|
// strips the keyboard atomically with the status-line edit.
|
|
19398
19373
|
await finalizeCallback(ctx, {
|
|
19399
|
-
ackText:
|
|
19400
|
-
newText: baseText ? `${baseText}\n\n${
|
|
19374
|
+
ackText: ackText.slice(0, 200),
|
|
19375
|
+
newText: baseText ? `${baseText}\n\n${htmlLabel}` : htmlLabel,
|
|
19401
19376
|
parseMode: 'HTML',
|
|
19402
19377
|
synthInbound: () => {
|
|
19378
|
+
// No `rule` → the bridge does NOT cache this (truly once on the bridge);
|
|
19379
|
+
// any 30-min stickiness lives only in scopedGrants (recorded above).
|
|
19403
19380
|
dispatchPermissionVerdict({
|
|
19404
19381
|
type: 'permission',
|
|
19405
19382
|
requestId: request_id,
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Scoped, time-boxed approval — the
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Scoped, time-boxed approval — the default behavior of the "✅ Allow" tap
|
|
3
|
+
* for a NARROW, non-destructive scope. Tapping Allow on such a request
|
|
4
|
+
* auto-grants a fixed window so byte-identical in-scope requests auto-allow
|
|
5
|
+
* without re-carding (killing tap-fatigue on re-edits / re-runs). Broad /
|
|
6
|
+
* MCP / destructive scopes get no window — Allow stays truly once for them.
|
|
7
|
+
* "🔁 Always…" remains the separate durable (`tools.allow`, forever) tier.
|
|
8
|
+
* There is deliberately no separate time-box button: the window IS what
|
|
9
|
+
* "Allow" means for a narrow safe scope, disclosed honestly on the post-tap
|
|
10
|
+
* card ("won't ask again about <breadth> for 30 min" vs "allowed once").
|
|
7
11
|
*
|
|
8
12
|
* Design contract (reference/access-model.md — "you hold the leash"):
|
|
9
13
|
*
|
|
@@ -21,14 +25,13 @@
|
|
|
21
25
|
* - **Conservative scope (this tier, v1).** Only the *narrow* scope is
|
|
22
26
|
* ever time-boxed: an exact file path (`Edit(/x.ts)`) or a Bash
|
|
23
27
|
* command-family (`Bash(git:*)`). Broad scopes ("any file", resource-
|
|
24
|
-
* blind MCP, "any command")
|
|
25
|
-
* once / always. This covers the real fatigue (re-editing the same
|
|
28
|
+
* blind MCP, "any command") get NO window — Allow stays truly once for them. This covers the real fatigue (re-editing the same
|
|
26
29
|
* file, re-running a safe command) without fanning one tap across an
|
|
27
30
|
* unbounded action set.
|
|
28
31
|
* - **Fail-closed on irreversible.** A Bash family grant (`Bash(git:*)`)
|
|
29
32
|
* must never auto-allow a destructive member of that family
|
|
30
33
|
* (`git push --force`, `git reset --hard`). `isDestructiveBashCommand`
|
|
31
|
-
* is re-checked at BOTH grant time (
|
|
34
|
+
* is re-checked at BOTH grant time (no window granted) and match time
|
|
32
35
|
* (a cached family grant fails closed → re-cards) so per-call consent
|
|
33
36
|
* for irreversible actions is preserved.
|
|
34
37
|
*
|
|
@@ -45,8 +48,8 @@ export const SCOPED_APPROVAL_DEFAULT_TTL_MS = 30 * 60 * 1000;
|
|
|
45
48
|
|
|
46
49
|
/**
|
|
47
50
|
* Resolve the configured window from the environment. `0` (or negative)
|
|
48
|
-
* disables the
|
|
49
|
-
* short-circuits. A blank/garbage value falls back to the 30-min default.
|
|
51
|
+
* disables the window — Allow becomes truly once for every scope and the
|
|
52
|
+
* gateway never short-circuits. A blank/garbage value falls back to the 30-min default.
|
|
50
53
|
* Kill-switch: `SWITCHROOM_SCOPED_APPROVAL_TTL_MS=0`.
|
|
51
54
|
*/
|
|
52
55
|
export function scopedApprovalTtlMs(
|
|
@@ -84,7 +87,7 @@ const BASH_FAMILY_RULE = /^Bash\(([^:]+):\*\)$/;
|
|
|
84
87
|
* Conservative time-box eligibility. Given the already-resolved scope
|
|
85
88
|
* choices for a permission request, return the NARROW rule to time-box
|
|
86
89
|
* plus an honest breadth phrase — or `null` when this request must not
|
|
87
|
-
* get a
|
|
90
|
+
* get a window (broad-only tools, MCP, Skill, a destructive Bash
|
|
88
91
|
* command, or any tool with no narrow sub-scope).
|
|
89
92
|
*
|
|
90
93
|
* - File tools with an exact path → time-boxable (bounded to the one
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for the
|
|
2
|
+
* Tests for scoped-approval.ts — the 30-min window backing the "Allow" tap
|
|
3
3
|
* the middle rung between "Allow once" and "🔁 Always".
|
|
4
4
|
*
|
|
5
5
|
* These pin the access-model invariants the adversarial review flagged as
|
|
@@ -233,7 +233,7 @@ describe('isDestructiveBashCommand — fail-closed denylist', () => {
|
|
|
233
233
|
recordScopedGrant(store, 'clerk', 'Bash(git:*)', T0, TTL)
|
|
234
234
|
// first token is the harmless `git`, but the backtick hides `rm -rf`
|
|
235
235
|
expect(lookupScopedGrant(store, 'clerk', 'Bash', bashInput('git status `rm -rf /important`'), T0 + 1)).toBeNull()
|
|
236
|
-
// and the request never gets
|
|
236
|
+
// and the request never gets a window at grant time either
|
|
237
237
|
expect(timeBoxRule('Bash', bashInput('git status `rm -rf x`'))).toBeNull()
|
|
238
238
|
})
|
|
239
239
|
|