switchroom 0.15.23 → 0.15.25

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.
@@ -50477,8 +50477,8 @@ var {
50477
50477
  } = import__.default;
50478
50478
 
50479
50479
  // src/build-info.ts
50480
- var VERSION = "0.15.23";
50481
- var COMMIT_SHA = "4c70a87a";
50480
+ var VERSION = "0.15.25";
50481
+ var COMMIT_SHA = "0d066743";
50482
50482
 
50483
50483
  // src/cli/agent.ts
50484
50484
  init_source();
@@ -52078,6 +52078,21 @@ function dedupe2(items) {
52078
52078
  function shellSingleQuote(s) {
52079
52079
  return "'" + s.replace(/'/g, `'"'"'`) + "'";
52080
52080
  }
52081
+ function hostHomeForBake() {
52082
+ return process.env.SWITCHROOM_HOST_HOME || process.env.HOME || undefined;
52083
+ }
52084
+ function hostHomeQForBake() {
52085
+ const h = hostHomeForBake();
52086
+ return h ? shellSingleQuote(h) : undefined;
52087
+ }
52088
+ function toHostHomePath(p) {
52089
+ const hostHome = process.env.SWITCHROOM_HOST_HOME;
52090
+ const containerHome = process.env.HOME;
52091
+ if (hostHome && containerHome && hostHome !== containerHome && (p === containerHome || p.startsWith(containerHome + "/"))) {
52092
+ return hostHome + p.slice(containerHome.length);
52093
+ }
52094
+ return p;
52095
+ }
52081
52096
  function composeWithSidecar(renderedBase, sidecarPath) {
52082
52097
  if (!existsSync14(sidecarPath))
52083
52098
  return renderedBase;
@@ -52648,7 +52663,7 @@ function buildWorkspaceContext(args) {
52648
52663
  } = args;
52649
52664
  return {
52650
52665
  name,
52651
- agentDir,
52666
+ agentDir: toHostHomePath(agentDir),
52652
52667
  repoRoot: REPO_ROOT,
52653
52668
  topicId,
52654
52669
  topicName: agentConfig.topic_name,
@@ -52687,7 +52702,7 @@ function buildWorkspaceContext(args) {
52687
52702
  hindsightTopicAliasesJsonQ: hindsightTopicAliasesJson ? shellSingleQuote(hindsightTopicAliasesJson) : undefined,
52688
52703
  hindsightTopicFilterMode,
52689
52704
  switchroomConfigPathQ: switchroomConfigPath ? shellSingleQuote(resolve11(switchroomConfigPath)) : undefined,
52690
- hostHomeQ: process.env.HOME ? shellSingleQuote(process.env.HOME) : undefined,
52705
+ hostHomeQ: hostHomeQForBake(),
52691
52706
  modelQ: shellSingleQuote(resolveMainModel(agentConfig.model)),
52692
52707
  ...buildCronSessionContext(agentConfig),
52693
52708
  thinkingEffort: agentConfig.thinking_effort ?? SWITCHROOM_DEFAULT_THINKING_EFFORT,
@@ -52870,7 +52885,7 @@ function scaffoldAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchro
52870
52885
  const tools = agentConfig.tools ?? { allow: [], deny: [] };
52871
52886
  const rawAllow = tools.allow ?? [];
52872
52887
  const hasAllWildcard = rawAllow.includes("all");
52873
- const baseAllow = hasAllWildcard ? ALL_BUILTIN_TOOLS : rawAllow.filter((t) => t !== "all");
52888
+ const baseAllow = hasAllWildcard ? [...ALL_BUILTIN_TOOLS, ...rawAllow.filter((t) => t !== "all")] : rawAllow.filter((t) => t !== "all");
52874
52889
  const dangerousMode = agentConfig.dangerous_mode === true;
52875
52890
  const hadExplicitAllow = rawAllow.length > 0;
52876
52891
  const readOnlyDefaults = !dangerousMode && !hadExplicitAllow ? DEFAULT_READ_ONLY_PREAPPROVED_TOOLS : [];
@@ -53704,7 +53719,7 @@ function reconcileAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchr
53704
53719
  const tools = agentConfig.tools ?? { allow: [], deny: [] };
53705
53720
  const rawAllow = tools.allow ?? [];
53706
53721
  const hasAllWildcard = rawAllow.includes("all");
53707
- const baseAllow = hasAllWildcard ? ALL_BUILTIN_TOOLS : rawAllow.filter((t) => t !== "all");
53722
+ const baseAllow = hasAllWildcard ? [...ALL_BUILTIN_TOOLS, ...rawAllow.filter((t) => t !== "all")] : rawAllow.filter((t) => t !== "all");
53708
53723
  const reconcileDangerousMode = agentConfig.dangerous_mode === true;
53709
53724
  const reconcileHadExplicitAllow = rawAllow.length > 0;
53710
53725
  const reconcileReadOnlyDefaults = !reconcileDangerousMode && !reconcileHadExplicitAllow ? DEFAULT_READ_ONLY_PREAPPROVED_TOOLS : [];
@@ -53761,7 +53776,7 @@ function reconcileAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchr
53761
53776
  const basePath = getBaseProfilePath();
53762
53777
  const startShContext = {
53763
53778
  name,
53764
- agentDir,
53779
+ agentDir: toHostHomePath(agentDir),
53765
53780
  repoRoot: REPO_ROOT,
53766
53781
  botToken: resolvedBotToken ?? rawBotToken,
53767
53782
  forumChatId: telegramConfig.forum_chat_id,
@@ -53778,7 +53793,7 @@ function reconcileAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchr
53778
53793
  hindsightRecallMinOverlap,
53779
53794
  hindsightTopicAliasesJsonQ: hindsightTopicAliasesJson ? shellSingleQuote(hindsightTopicAliasesJson) : undefined,
53780
53795
  hindsightTopicFilterMode,
53781
- hostHomeQ: process.env.HOME ? shellSingleQuote(process.env.HOME) : undefined,
53796
+ hostHomeQ: hostHomeQForBake(),
53782
53797
  modelQ: shellSingleQuote(resolveMainModel(agentConfig.model)),
53783
53798
  ...buildCronSessionContext(agentConfig),
53784
53799
  thinkingEffort: agentConfig.thinking_effort ?? SWITCHROOM_DEFAULT_THINKING_EFFORT,
@@ -82550,6 +82565,7 @@ Applying switchroom config...
82550
82565
  process.exit(6);
82551
82566
  }
82552
82567
  const composePath = options.outPath ?? DEFAULT_COMPOSE_PATH2;
82568
+ const displayComposePath = toHostHomePath(composePath);
82553
82569
  const operatorUid = resolveOperatorUid();
82554
82570
  const { bytes: composeBytes } = await writeComposeFile({
82555
82571
  config,
@@ -82560,11 +82576,11 @@ Applying switchroom config...
82560
82576
  buildContext: options.buildContext
82561
82577
  });
82562
82578
  writeOut(source_default.bold(`
82563
- Wrote `) + composePath + source_default.gray(` (${composeBytes} bytes)
82579
+ Wrote `) + displayComposePath + source_default.gray(` (${composeBytes} bytes)
82564
82580
  `));
82565
82581
  writeOut(`Bring the fleet up with:
82566
- ` + ` docker compose -p ${COMPOSE_PROJECT2} -f ${composePath} pull && \\
82567
- ` + ` docker compose -p ${COMPOSE_PROJECT2} -f ${composePath} up -d --remove-orphans
82582
+ ` + ` docker compose -p ${COMPOSE_PROJECT2} -f ${displayComposePath} pull && \\
82583
+ ` + ` docker compose -p ${COMPOSE_PROJECT2} -f ${displayComposePath} up -d --remove-orphans
82568
82584
  `);
82569
82585
  writeOut(source_default.gray(` (If pull returns 401, login to ghcr.io first: see docs/operators/install.md#ghcr-auth)
82570
82586
  `));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.15.23",
3
+ "version": "0.15.25",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -53639,7 +53639,11 @@ function scopedApprovalTtlMs(env = process.env) {
53639
53639
  }
53640
53640
  var FILE_RULE = /^(Edit|Write|MultiEdit|NotebookEdit|Read)\((.+)\)$/;
53641
53641
  var BASH_FAMILY_RULE = /^Bash\(([^:]+):\*\)$/;
53642
+ var READ_ONLY_WHOLE_TOOLS = new Set(["Grep", "Glob"]);
53642
53643
  function resolveTimeBox(toolName, inputPreview, choices) {
53644
+ if (READ_ONLY_WHOLE_TOOLS.has(toolName) && choices?.broad) {
53645
+ return { rule: choices.broad.rule, breadth: `any ${toolName}` };
53646
+ }
53643
53647
  const specific = choices?.specific;
53644
53648
  if (!specific || specific.broad)
53645
53649
  return null;
@@ -54420,11 +54424,11 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
54420
54424
  }
54421
54425
 
54422
54426
  // ../src/build-info.ts
54423
- var VERSION = "0.15.23";
54424
- var COMMIT_SHA = "4c70a87a";
54425
- var COMMIT_DATE = "2026-06-14T15:15:27+10:00";
54426
- var LATEST_PR = null;
54427
- var COMMITS_AHEAD_OF_TAG = 2;
54427
+ var VERSION = "0.15.25";
54428
+ var COMMIT_SHA = "0d066743";
54429
+ var COMMIT_DATE = "2026-06-15T02:15:36Z";
54430
+ var LATEST_PR = 2359;
54431
+ var COMMITS_AHEAD_OF_TAG = 0;
54428
54432
 
54429
54433
  // gateway/boot-version.ts
54430
54434
  function formatRelativeAgo(iso) {
@@ -83,6 +83,16 @@ export interface TimeBoxDecision {
83
83
  const FILE_RULE = /^(Edit|Write|MultiEdit|NotebookEdit|Read)\((.+)\)$/;
84
84
  const BASH_FAMILY_RULE = /^Bash\(([^:]+):\*\)$/;
85
85
 
86
+ // Read-only whole-tool inspection tools. permission-rule.ts classifies these
87
+ // as BROAD_ONLY (no meaningful path sub-scope — Grep/Glob match a pattern
88
+ // across files), so they only ever offer a broad "any Grep/Glob" grant. They
89
+ // are READ-ONLY — they cannot mutate anything — so time-boxing that broad grant
90
+ // is low-risk and is exactly the dominant "stop re-asking for the same
91
+ // low-risk inspection" case (e.g. grepping a file with several regexes). This
92
+ // deliberately EXCLUDES WebFetch/WebSearch (also BROAD_ONLY): network egress is
93
+ // a different risk class, and webkite denies them anyway.
94
+ const READ_ONLY_WHOLE_TOOLS = new Set(["Grep", "Glob"]);
95
+
86
96
  /**
87
97
  * Conservative time-box eligibility. Given the already-resolved scope
88
98
  * choices for a permission request, return the NARROW rule to time-box
@@ -96,12 +106,24 @@ const BASH_FAMILY_RULE = /^Bash\(([^:]+):\*\)$/;
96
106
  * triggering command itself is non-destructive. The family grant
97
107
  * still covers the whole `<tok>` family for matching, but match-time
98
108
  * re-checks each later command (see `lookupScopedGrant`).
109
+ * - Read-only whole-tool inspection (Grep/Glob) → time-boxable on the
110
+ * broad grant: no sub-scope exists, but they cannot mutate anything, so
111
+ * "any Grep for 30 min" is the safe, dominant low-risk-repeat case.
99
112
  */
100
113
  export function resolveTimeBox(
101
114
  toolName: string,
102
115
  inputPreview: string | undefined,
103
116
  choices: ScopedAllowChoices | null,
104
117
  ): TimeBoxDecision | null {
118
+ // Read-only whole-tool inspection (Grep/Glob) has no narrow sub-scope, only
119
+ // a broad grant — but it is read-only, so the broad grant is safe to
120
+ // time-box and is the dominant low-risk-repeat case. A stored bare-tool
121
+ // rule ("Grep") matches every later Grep call (matchesAllowRule: rule ===
122
+ // toolName), so different regexes/paths all dedup for the window.
123
+ if (READ_ONLY_WHOLE_TOOLS.has(toolName) && choices?.broad) {
124
+ return { rule: choices.broad.rule, breadth: `any ${toolName}` };
125
+ }
126
+
105
127
  // Only ever time-box the narrow scope, and only when one exists.
106
128
  const specific = choices?.specific;
107
129
  if (!specific || specific.broad) return null;
@@ -100,6 +100,26 @@ describe('resolveTimeBox — conservative eligibility', () => {
100
100
  expect(timeBoxRule('Skill', JSON.stringify({ skill: 'deep-research' }))).toBeNull()
101
101
  expect(timeBoxRule('TotallyUnknown', '{}')).toBeNull()
102
102
  })
103
+
104
+ it('time-boxes read-only whole-tool inspection (Grep/Glob) on the broad grant', () => {
105
+ // No narrow sub-scope exists (BROAD_ONLY), but they are read-only — so the
106
+ // broad grant is safe to time-box. This is the dominant "stop re-asking for
107
+ // the same low-risk inspection" case the operator wants.
108
+ expect(timeBoxRule('Grep', JSON.stringify({ pattern: 'foo', path: '/state/x.ts' }))).toBe('Grep')
109
+ expect(timeBoxRule('Glob', JSON.stringify({ pattern: '**/*.ts' }))).toBe('Glob')
110
+ })
111
+
112
+ it('honest breadth for the read-only whole-tool window', () => {
113
+ const choices = resolveScopedAllowChoices('Grep', JSON.stringify({ pattern: 'foo' }))
114
+ expect(resolveTimeBox('Grep', JSON.stringify({ pattern: 'foo' }), choices)?.breadth).toBe('any Grep')
115
+ })
116
+
117
+ it('still does NOT time-box network-egress broad-only tools (WebFetch/WebSearch)', () => {
118
+ // Read-only-of-the-filesystem is the bar; network egress is a different
119
+ // risk class and is excluded on purpose (also denied via webkite).
120
+ expect(timeBoxRule('WebFetch', JSON.stringify({ url: 'https://x' }))).toBeNull()
121
+ expect(timeBoxRule('WebSearch', JSON.stringify({ query: 'x' }))).toBeNull()
122
+ })
103
123
  })
104
124
 
105
125
  describe('lookupScopedGrant — no seed, no extend, fail closed', () => {
@@ -121,6 +141,19 @@ describe('lookupScopedGrant — no seed, no extend, fail closed', () => {
121
141
  expect(lookupScopedGrant(store, 'clerk', 'Edit', editInput('/state/y.ts'), T0 + 1)).toBeNull()
122
142
  })
123
143
 
144
+ it('a Grep grant dedups later Greps with DIFFERENT regexes (the spam case)', () => {
145
+ // Operator taps ✅ Allow on the first Grep → whole-tool window. The agent
146
+ // then greps the same file with 2 more regexes — both auto-allow, no
147
+ // re-prompt, for the life of the window.
148
+ const store: ScopedGrantStore = new Map()
149
+ const t = resolveTimeBox('Grep', JSON.stringify({ pattern: 'foo' }), resolveScopedAllowChoices('Grep', JSON.stringify({ pattern: 'foo' })))
150
+ recordScopedGrant(store, 'clerk', t!.rule, T0, TTL)
151
+ expect(lookupScopedGrant(store, 'clerk', 'Grep', JSON.stringify({ pattern: 'bar' }), T0 + 1_000)).toBe('Grep')
152
+ expect(lookupScopedGrant(store, 'clerk', 'Grep', JSON.stringify({ pattern: 'baz', path: '/state/other.ts' }), T0 + 2_000)).toBe('Grep')
153
+ // …and it still fails closed after the window.
154
+ expect(lookupScopedGrant(store, 'clerk', 'Grep', JSON.stringify({ pattern: 'bar' }), T0 + TTL)).toBeNull()
155
+ })
156
+
124
157
  it('FIXED window — a matching call never extends expiresAt', () => {
125
158
  const store: ScopedGrantStore = new Map()
126
159
  recordScopedGrant(store, 'clerk', 'Edit(/state/x.ts)', T0, TTL)