switchroom 0.12.0 → 0.12.1

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.
Files changed (29) hide show
  1. package/README.md +26 -11
  2. package/dist/auth-broker/index.js +1 -1
  3. package/dist/cli/skill-validate-pretool.mjs +7209 -0
  4. package/dist/cli/switchroom.js +869 -430
  5. package/dist/vault/broker/server.js +31 -22
  6. package/package.json +3 -2
  7. package/profiles/_shared/agent-self-service.md.hbs +1 -1
  8. package/skills/skill-creator/SKILL.md +52 -0
  9. package/telegram-plugin/auth-snapshot-format.ts +5 -5
  10. package/telegram-plugin/dist/gateway/gateway.js +62 -8
  11. package/telegram-plugin/gateway/access-validator.test.ts +8 -8
  12. package/telegram-plugin/gateway/access-validator.ts +1 -1
  13. package/telegram-plugin/gateway/boot-probes.ts +43 -3
  14. package/telegram-plugin/gateway/gateway.ts +72 -0
  15. package/telegram-plugin/recent-outbound-dedup.ts +1 -1
  16. package/telegram-plugin/registry/turns-schema.ts +1 -1
  17. package/telegram-plugin/tests/auth-add-flow.test.ts +1 -1
  18. package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
  19. package/telegram-plugin/tests/auth-snapshot-format.test.ts +17 -17
  20. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +10 -10
  21. package/telegram-plugin/tests/boot-probes.test.ts +37 -2
  22. package/telegram-plugin/tests/fixtures/service-log-current-claude-code.bin +1 -1
  23. package/telegram-plugin/tests/fleet-state.test.ts +3 -2
  24. package/telegram-plugin/tests/secret-detect-audit.test.ts +1 -1
  25. package/telegram-plugin/tests/secret-detect-pipeline.test.ts +7 -6
  26. package/telegram-plugin/tests/secret-detect-suppressor-no-silent-allow.test.ts +6 -5
  27. package/telegram-plugin/tests/secret-detect.test.ts +8 -8
  28. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +8 -8
  29. package/telegram-plugin/tests/vault-request-access-tool.test.ts +51 -0
@@ -12986,6 +12986,14 @@ function checkAclByAgent(config, agentName, key) {
12986
12986
  if (googleSlot !== null) {
12987
12987
  return checkGoogleAccountAcl(config, agentName, googleSlot.account, key);
12988
12988
  }
12989
+ const agentBot = agentConfig.bot_token;
12990
+ const botRef = agentBot && agentBot.length > 0 ? agentBot : config.telegram?.bot_token;
12991
+ if (typeof botRef === "string" && botRef.startsWith("vault:")) {
12992
+ const botKey = botRef.slice("vault:".length).split("#")[0];
12993
+ if (botKey.length > 0 && botKey === key) {
12994
+ return { allow: true };
12995
+ }
12996
+ }
12989
12997
  const schedule = agentConfig.schedule ?? [];
12990
12998
  if (schedule.length === 0) {
12991
12999
  return {
@@ -16042,35 +16050,37 @@ class VaultBroker {
16042
16050
  const grantId = dotIdx !== -1 ? req.token.slice(0, dotIdx) : undefined;
16043
16051
  const sentinelKey = Object.keys(this.secrets)[0] ?? "__list_check__";
16044
16052
  const tokenCheck = await validateGrant(this.grantsDb, req.token, sentinelKey);
16045
- if (!tokenCheck.ok && tokenCheck.reason !== "grant-key-not-allowed") {
16053
+ if (!tokenCheck.ok && tokenCheck.reason === "grant-revoked") {
16046
16054
  this.auditLogger.write({
16047
16055
  ts: new Date().toISOString(),
16048
16056
  op: "list",
16049
16057
  caller: auditCaller,
16050
16058
  pid: auditPid,
16051
16059
  cgroup: auditCgroup,
16052
- result: `denied:${tokenCheck.reason}`,
16060
+ result: `denied:grant-revoked`,
16053
16061
  method: "grant",
16054
16062
  grant_id: grantId
16055
16063
  });
16056
- socket.write(encodeResponse(errorResponse("DENIED", tokenCheck.reason)));
16064
+ socket.write(encodeResponse(errorResponse("DENIED", "grant-revoked")));
16065
+ return;
16066
+ }
16067
+ if (tokenCheck.ok || tokenCheck.reason === "grant-key-not-allowed") {
16068
+ const grantRow = tokenCheck.ok ? tokenCheck.grant : this.grantsDb.query("SELECT key_allow FROM vault_grants WHERE id = ?").get(grantId ?? "");
16069
+ const allowedKeys = grantRow ? typeof grantRow.key_allow === "string" ? JSON.parse(grantRow.key_allow) : grantRow.key_allow : [];
16070
+ const visibleKeys2 = allowedKeys.filter((k) => (k in this.secrets));
16071
+ this.auditLogger.write({
16072
+ ts: new Date().toISOString(),
16073
+ op: "list",
16074
+ caller: auditCaller,
16075
+ pid: auditPid,
16076
+ cgroup: auditCgroup,
16077
+ result: `allowed:${visibleKeys2.length}`,
16078
+ method: "grant",
16079
+ grant_id: grantId
16080
+ });
16081
+ socket.write(encodeResponse({ ok: true, keys: visibleKeys2 }));
16057
16082
  return;
16058
16083
  }
16059
- const grantRow = tokenCheck.ok ? tokenCheck.grant : this.grantsDb.query("SELECT key_allow FROM vault_grants WHERE id = ?").get(grantId ?? "");
16060
- const allowedKeys = grantRow ? typeof grantRow.key_allow === "string" ? JSON.parse(grantRow.key_allow) : grantRow.key_allow : [];
16061
- const visibleKeys2 = allowedKeys.filter((k) => (k in this.secrets));
16062
- this.auditLogger.write({
16063
- ts: new Date().toISOString(),
16064
- op: "list",
16065
- caller: auditCaller,
16066
- pid: auditPid,
16067
- cgroup: auditCgroup,
16068
- result: `allowed:${visibleKeys2.length}`,
16069
- method: "grant",
16070
- grant_id: grantId
16071
- });
16072
- socket.write(encodeResponse({ ok: true, keys: visibleKeys2 }));
16073
- return;
16074
16084
  }
16075
16085
  if (!isOperator && agentName === null && process.platform === "linux" && peer === null) {
16076
16086
  const reason = "Unable to identify caller (peercred unavailable); denying on Linux";
@@ -16145,10 +16155,9 @@ class VaultBroker {
16145
16155
  });
16146
16156
  socket.write(encodeResponse(entryResponse(entry2)));
16147
16157
  return;
16148
- } else {
16158
+ } else if (grantResult.reason === "grant-revoked") {
16149
16159
  const dotIdx = req.token.indexOf(".");
16150
16160
  const grantId = dotIdx !== -1 ? req.token.slice(0, dotIdx) : undefined;
16151
- const denyReason = grantResult.reason;
16152
16161
  this.auditLogger.write({
16153
16162
  ts: new Date().toISOString(),
16154
16163
  op: "get",
@@ -16156,11 +16165,11 @@ class VaultBroker {
16156
16165
  caller: auditCaller,
16157
16166
  pid: auditPid,
16158
16167
  cgroup: auditCgroup,
16159
- result: `denied:${denyReason}`,
16168
+ result: `denied:grant-revoked`,
16160
16169
  method: "grant",
16161
16170
  grant_id: grantId
16162
16171
  });
16163
- socket.write(encodeResponse(errorResponse("DENIED", denyReason)));
16172
+ socket.write(encodeResponse(errorResponse("DENIED", "grant-revoked")));
16164
16173
  return;
16165
16174
  }
16166
16175
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
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": {
@@ -26,11 +26,12 @@
26
26
  "test:vitest": "vitest run",
27
27
  "test:bun": "bun test src/watchdog/state.test.ts src/watchdog/policy.test.ts src/vault/grants.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/uat/load-env.test.ts",
28
28
  "test:watch": "vitest",
29
- "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs",
29
+ "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-no-pii-secrets.mjs",
30
30
  "lint:tsc": "tsc --noEmit",
31
31
  "lint:plugin-references": "node scripts/check-plugin-references.mjs",
32
32
  "lint:bot-api-wrapping": "bash scripts/check-bot-api-wrapping.sh",
33
33
  "lint:bun-test-imports": "node scripts/check-bun-test-imports.mjs",
34
+ "lint:no-pii": "node scripts/check-no-pii-secrets.mjs",
34
35
  "prepublishOnly": "npm run build && npm run lint && npm test"
35
36
  },
36
37
  "dependencies": {
@@ -29,7 +29,7 @@ tools is to let you do the edit yourself.
29
29
  entry. The `prompt` is what *you* (the agent) will receive when the cron
30
30
  fires; phrase it from your future-self's perspective (e.g.
31
31
  `"Time for the daily digest — pull yesterday's GitHub activity and DM the
32
- summary to chat 8248703757"`, not `"please send the digest"`). Optional
32
+ summary to chat 12345"`, not `"please send the digest"`). Optional
33
33
  `name` is a stable slug for `schedule_remove`; if omitted, a 12-hex hash
34
34
  derived from the entry content is assigned.
35
35
 
@@ -460,6 +460,12 @@ In Claude.ai, the core workflow is the same (draft → test → review → impro
460
460
  - **Copy to a writeable location before editing.** The installed skill path may be read-only. Copy to `/tmp/skill-name/`, edit there, and package from the copy.
461
461
  - **If packaging manually, stage in `/tmp/` first**, then copy to the output directory -- direct writes may fail due to permissions.
462
462
 
463
+ > The two bullets above are the *generic / packaging* case (read-only
464
+ > installed skills, producing a `.skill` file for download). If you are
465
+ > running inside a **switchroom agent**, your own skills live in a
466
+ > writable, persistent directory — do **not** stage in `/tmp/`; author
467
+ > in place. See "Switchroom agent instructions" below.
468
+
463
469
  ---
464
470
 
465
471
  ## Cowork-Specific Instructions
@@ -476,6 +482,52 @@ If you're in Cowork, the main things to know are:
476
482
 
477
483
  ---
478
484
 
485
+ ## Switchroom agent instructions
486
+
487
+ If you are running as a switchroom agent (you have a
488
+ `$CLAUDE_CONFIG_DIR` and talk to a user over Telegram), authoring a
489
+ skill for *yourself* is a plain filesystem write — no special tool, no
490
+ broker, no packaging:
491
+
492
+ - **Where skills live.** Your own (agent-scope) skills go in
493
+ `$CLAUDE_CONFIG_DIR/skills/<slug>/` — one directory per skill, with
494
+ `SKILL.md` at its root. This directory is **writable by you** and
495
+ **persistent** (it survives restarts and `switchroom agent
496
+ restart`). Just `Write`/`Edit` the files there directly. Do **not**
497
+ stage in `/tmp/` and do **not** copy anything afterwards — the
498
+ native filesystem write *is* the supported path. (There is no
499
+ skill authoring tool of any kind; authoring is plain file writes.
500
+ Sharing a skill with the rest of the fleet is **not** a runtime
501
+ action — it is a reviewed pull request; see the last bullet.)
502
+ - **It goes live on your *next* turn, not this one.** Claude discovers
503
+ skills when a session starts. After you write the files, tell the
504
+ user the skill is created and will be active on the next message —
505
+ then stop. Don't write the skill and immediately try to invoke it in
506
+ the same turn; it won't be loaded yet. (If you need it active
507
+ immediately, the user can `switchroom agent restart <you>`.)
508
+ - **A linter will nudge you, not block you.** A non-blocking
509
+ PreToolUse hook checks the skill shape (slug, path allowlist,
510
+ SKILL.md frontmatter) and returns advisory feedback if something is
511
+ off — the write still goes through. The only hard limit is the
512
+ per-skill byte cap (2 MiB); a write over that is rejected, so split
513
+ large assets out.
514
+ - **Skill shape.** `SKILL.md` must start with YAML frontmatter:
515
+ `name:` equal to the `<slug>` directory name, and a `description:`
516
+ (1–1024 chars) that says when the skill should trigger. Supporting
517
+ files follow the allowlist: `README.md`, `scripts/*.{sh,py}`,
518
+ `assets/*`, `reference/*.md` (max depth 3).
519
+ - **Fleet / global skills go through review, not a command.** Making a
520
+ skill available to *other* agents is a deliberate operator decision,
521
+ not a runtime write. The path is: open a pull request adding the
522
+ skill to switchroom's reviewed skill set (the canonical repo's
523
+ `skills/` for general skills, or the operator's private
524
+ `switchroom.skills_dir` repo for operator-specific ones); once merged
525
+ it ships as a bundled default (opt-out per agent via
526
+ `bundled_skills`) or via the `skills:` cascade. You can *propose* a
527
+ fleet skill by opening that PR; a human approves it. There is no
528
+ `skill_publish` and nothing an agent runs makes a skill fleet-wide on
529
+ its own.
530
+
479
531
  ## Reference files
480
532
 
481
533
  The agents/ directory contains instructions for specialized subagents. Read them when you need to spawn the relevant subagent.
@@ -178,7 +178,7 @@ const HEALTH_TITLE: Record<AccountHealth, string> = {
178
178
  /**
179
179
  * One-line per-account summary inside its health group.
180
180
  *
181
- * pixsoul@gmail.com ● 8% / 20%
181
+ * you@example.com ● 8% / 20%
182
182
  * 5h refills 11:00 AM (in 6m) · 7d resets Sun 11:00 AM
183
183
  *
184
184
  * Two lines actually: the label/percent line and a sub-line with the
@@ -389,13 +389,13 @@ export interface FallbackAnnouncementInput {
389
389
  /**
390
390
  * Render the causal-shape fallback announcement.
391
391
  *
392
- * ✓ Switched fleet · 5-hour limit on ken
392
+ * ✓ Switched fleet · 5-hour limit on alice
393
393
  *
394
- * ken.thompson@outlookpixsoul@gmail.com
394
+ * alice@exampleyou@example.com
395
395
  * Triggered by: agent carrie
396
396
  *
397
- * ken recovers Fri 3:50 PM (in 4h 56m)
398
- * pixsoul now: 8% of 5h · 20% of 7d (plenty of headroom)
397
+ * alice recovers Fri 3:50 PM (in 4h 56m)
398
+ * you now: 8% of 5h · 20% of 7d (plenty of headroom)
399
399
  *
400
400
  * Falls back to a different shape when no eligible target was found
401
401
  * (`newLabel === null`) — see "all-blocked" branch.
@@ -28009,8 +28009,26 @@ async function probeSkills(agentDir, opts = {}) {
28009
28009
  continue;
28010
28010
  }
28011
28011
  }
28012
+ const overlayDir = opts.overlaySkillsDir ?? join21(agentDir, "skills.d");
28013
+ const overlaySlugs = new Set;
28014
+ if (fs2.exists(overlayDir)) {
28015
+ let overlayEntries = [];
28016
+ try {
28017
+ overlayEntries = fs2.readdir(overlayDir);
28018
+ } catch {}
28019
+ for (const name of overlayEntries) {
28020
+ const m = name.match(/^(.+)\.ya?ml$/i);
28021
+ if (!m)
28022
+ continue;
28023
+ overlaySlugs.add(m[1]);
28024
+ }
28025
+ }
28026
+ const liveEntries = entries.filter((n) => !dangling.includes(n)).sort();
28027
+ const switchroomSkills = liveEntries.filter((n) => !overlaySlugs.has(n));
28028
+ const agentSkills = liveEntries.filter((n) => overlaySlugs.has(n));
28029
+ const bucketed = renderBucketedSkills(switchroomSkills, agentSkills);
28012
28030
  if (dangling.length === 0) {
28013
- return { status: "ok", label: "Skills", detail: `${entries.length} resolved` };
28031
+ return { status: "ok", label: "Skills", detail: bucketed };
28014
28032
  }
28015
28033
  const named = dangling.slice(0, max).join(", ");
28016
28034
  const more = dangling.length > max ? ` +${dangling.length - max} more` : "";
@@ -28018,11 +28036,19 @@ async function probeSkills(agentDir, opts = {}) {
28018
28036
  return {
28019
28037
  status: "degraded",
28020
28038
  label: "Skills",
28021
- detail: `${dangling.length}/${entries.length} dangling: ${named}${more}`,
28039
+ detail: `${dangling.length}/${entries.length} dangling: ${named}${more} \u00b7 ${bucketed}`,
28022
28040
  nextStep: `Run \`switchroom agent reconcile${reconcileTarget}\` to rebuild symlinks, or remove unused entries from switchroom.yaml`
28023
28041
  };
28024
28042
  })());
28025
28043
  }
28044
+ function renderBucketedSkills(switchroom, agent) {
28045
+ const parts = [];
28046
+ if (switchroom.length > 0)
28047
+ parts.push(`Switchroom: ${switchroom.join(", ")}`);
28048
+ if (agent.length > 0)
28049
+ parts.push(`Agent: ${agent.join(", ")}`);
28050
+ return parts.length === 0 ? "none resolved" : parts.join(" \u00b7 ");
28051
+ }
28026
28052
  var execFile3, PROBE_TIMEOUT_MS = 2000, QUOTA_BROKER_TIMEOUT_MS = 7000, QUOTA_DIRECT_FALLBACK_TIMEOUT_MS = 5000, QUOTA_PROBE_OUTER_TIMEOUT_MS = 9000, TOKEN_EXPIRING_SOON_DAYS = 7, AGENT_RETRY_INTERVAL_MS = 1500, AGENT_RETRY_MAX_MS = 12000, AGENT_LIVE_WINDOW_MS = 45000, AGENT_LIVE_POLL_INTERVAL_MS = 2000, AGENT_LIVE_FOLLOWUP_REPOLL_MS = 30000, realProcFs, SCHEDULER_LOCK_PATH_DEFAULT = "/state/agent/scheduler.lock", SCHEDULER_JSONL_PATH_DEFAULT = "/state/agent/scheduler.jsonl", SCHEDULER_FRESH_BOOT_MS = 30000, realSchedulerFs, realSkillsFs;
28027
28053
  var init_boot_probes = __esm(() => {
28028
28054
  init_quota_cache();
@@ -46533,11 +46559,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
46533
46559
  }
46534
46560
 
46535
46561
  // ../src/build-info.ts
46536
- var VERSION = "0.12.0";
46537
- var COMMIT_SHA = "2870fb03";
46538
- var COMMIT_DATE = "2026-05-17T08:03:22Z";
46539
- var LATEST_PR = 1462;
46540
- var COMMITS_AHEAD_OF_TAG = 0;
46562
+ var VERSION = "0.12.1";
46563
+ var COMMIT_SHA = "0de1edd1";
46564
+ var COMMIT_DATE = "2026-05-18T08:08:32Z";
46565
+ var LATEST_PR = 1503;
46566
+ var COMMITS_AHEAD_OF_TAG = null;
46541
46567
 
46542
46568
  // gateway/boot-version.ts
46543
46569
  function formatRelativeAgo(iso) {
@@ -46604,6 +46630,7 @@ import * as fs2 from "node:fs";
46604
46630
  import { homedir as homedir11 } from "node:os";
46605
46631
  import { join as join29 } from "node:path";
46606
46632
  var DEFAULT_TIMEOUT_MS4 = 2000;
46633
+ var UNLOCK_TIMEOUT_MS = 30000;
46607
46634
  var LEGACY_SOCKET_PATH2 = join29(homedir11(), ".switchroom", "vault-broker.sock");
46608
46635
  var OPERATOR_SOCKET_PATH2 = join29(homedir11(), ".switchroom", "broker-operator", "sock");
46609
46636
  function defaultBrokerSocketPath2() {
@@ -46715,7 +46742,7 @@ async function lockViaBroker(opts) {
46715
46742
  async function unlockViaBroker(passphrase, opts) {
46716
46743
  const dataSocketPath = resolveBrokerSocketPath2(opts);
46717
46744
  const unlockSocketPath = unlockSocketFor(dataSocketPath);
46718
- const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS4;
46745
+ const timeoutMs = opts?.timeoutMs ?? UNLOCK_TIMEOUT_MS;
46719
46746
  return new Promise((resolve7) => {
46720
46747
  let settled = false;
46721
46748
  const settle = (val) => {
@@ -49446,6 +49473,21 @@ async function executeVaultRequestAccess(args) {
49446
49473
  }
49447
49474
  assertAllowedChat(chat_id);
49448
49475
  const agentSlug = process.env.SWITCHROOM_AGENT_NAME || "agent";
49476
+ if (scopeRaw === "read") {
49477
+ try {
49478
+ const visible = await listViaBroker();
49479
+ if (visible !== null && visible.includes(key)) {
49480
+ return {
49481
+ content: [
49482
+ {
49483
+ type: "text",
49484
+ text: `vault_request_access: '${key}' is ALREADY covered by ${agentSlug}'s ` + `standing ACL (schedule.secrets[]). No approval card or grant is needed \u2014 ` + `read it directly: \`switchroom vault get ${key}\`. Do NOT request a grant for this key (a minted token would shadow the standing ACL). If a read still returns VAULT-BROKER-DENIED, the broker likely needs a restart to ` + `pick up a recent config change \u2014 tell the operator; don't re-request.`
49485
+ }
49486
+ ]
49487
+ };
49488
+ }
49489
+ } catch {}
49490
+ }
49449
49491
  const stageId = randomBytes6(4).toString("hex");
49450
49492
  const pending = {
49451
49493
  agent: agentSlug,
@@ -52562,6 +52604,18 @@ async function handleVaultRecentDenialCallback(ctx, data) {
52562
52604
  }
52563
52605
  async function performVaultAccessApproval(ctx, pending, stageId, senderId, attestation) {
52564
52606
  const brokerAuthOpts = attestation.kind === "passphrase" ? { passphrase: attestation.passphrase } : { attest_via_posture: true };
52607
+ if (pending.scope === "read") {
52608
+ try {
52609
+ const visible = await listViaBroker();
52610
+ if (visible !== null && visible.includes(pending.key)) {
52611
+ pendingVaultRequestAccesses.delete(stageId);
52612
+ if (pending.card_message_id != null) {
52613
+ await ctx.api.editMessageText(pending.chat_id, pending.card_message_id, `\u2139\uFE0F <b>${escapeHtmlForTg(pending.agent)}</b> already has standing-ACL access to <code>${escapeHtmlForTg(pending.key)}</code> (schedule.secrets[]). ` + `<b>No grant minted</b> \u2014 a token would shadow the standing ACL. ` + `The agent can read it directly.`, { parse_mode: "HTML", reply_markup: { inline_keyboard: [] } }).catch(() => {});
52614
+ }
52615
+ return;
52616
+ }
52617
+ } catch {}
52618
+ }
52565
52619
  let existingReadKeys = [];
52566
52620
  let existingWriteKeys = [];
52567
52621
  if (pending.scope === "read" || pending.scope === "write") {
@@ -2,8 +2,8 @@
2
2
  * Unit tests for access-validator.ts — validateStringArray.
3
3
  *
4
4
  * This function guards access.json fields at load time. The motivating bug:
5
- * a hand-edit that drops quotes around IDs (`[8248703757]` instead of
6
- * `["8248703757"]`) produces a valid JSON number array. Array.includes() uses
5
+ * a hand-edit that drops quotes around IDs (`[12345]` instead of
6
+ * `["12345"]`) produces a valid JSON number array. Array.includes() uses
7
7
  * strict equality, so number entries never match the string comparison in the
8
8
  * gate — every DM is silently dropped.
9
9
  *
@@ -27,7 +27,7 @@ describe('validateStringArray', () => {
27
27
  // ─── Happy path ────────────────────────────────────────────────────────────
28
28
 
29
29
  it('returns the array unchanged for a valid string array', () => {
30
- expect(validateStringArray('allowFrom', ['8248703757', '9999'])).toEqual(['8248703757', '9999'])
30
+ expect(validateStringArray('allowFrom', ['12345', '9999'])).toEqual(['12345', '9999'])
31
31
  expect(stderrSpy).not.toHaveBeenCalled()
32
32
  })
33
33
 
@@ -49,21 +49,21 @@ describe('validateStringArray', () => {
49
49
  // ─── Bug reproduction: number array ────────────────────────────────────────
50
50
 
51
51
  it('rejects a number array (the hand-edit bug) and returns []', () => {
52
- // This is the exact bug: [8248703757] parses as a number, not a string.
53
- // Array.includes("8248703757") === false for a number entry — silently drops DMs.
54
- const result = validateStringArray('allowFrom', [8248703757])
52
+ // This is the exact bug: [12345] parses as a number, not a string.
53
+ // Array.includes("12345") === false for a number entry — silently drops DMs.
54
+ const result = validateStringArray('allowFrom', [12345])
55
55
  expect(result).toEqual([])
56
56
  expect(stderrSpy).toHaveBeenCalled()
57
57
  const msg = String(stderrSpy.mock.calls[0][0])
58
58
  expect(msg).toContain('allowFrom')
59
59
  expect(msg).toContain('non-string entries')
60
- expect(msg).toContain('8248703757')
60
+ expect(msg).toContain('12345')
61
61
  })
62
62
 
63
63
  // ─── Mixed array ──────────────────────────────────────────────────────────
64
64
 
65
65
  it('rejects a mixed array (some strings, some numbers) and returns []', () => {
66
- const result = validateStringArray('allowFrom', ['8248703757', 9999])
66
+ const result = validateStringArray('allowFrom', ['12345', 9999])
67
67
  expect(result).toEqual([])
68
68
  expect(stderrSpy).toHaveBeenCalled()
69
69
  const msg = String(stderrSpy.mock.calls[0][0])
@@ -2,7 +2,7 @@
2
2
  * Validates fields loaded from access.json, failing loudly on type mismatches.
3
3
  *
4
4
  * JSON has no type system — a hand-edit that drops quotes around IDs
5
- * (e.g. `[8248703757]` instead of `["8248703757"]`) parses without error
5
+ * (e.g. `[12345]` instead of `["12345"]`) parses without error
6
6
  * but produces a number array. The gate compares with strict equality via
7
7
  * Array.includes(), so number entries silently drop every matching DM.
8
8
  *
@@ -1318,7 +1318,14 @@ export async function probeKernel(
1318
1318
  */
1319
1319
  export async function probeSkills(
1320
1320
  agentDir: string,
1321
- opts: { fs?: SkillsFsImpl; maxNamesShown?: number; agentName?: string } = {},
1321
+ opts: {
1322
+ fs?: SkillsFsImpl
1323
+ maxNamesShown?: number
1324
+ agentName?: string
1325
+ /** Override skills.d overlay dir. Defaults to `<agentDir>/skills.d`
1326
+ * on host installs; tests inject a path. */
1327
+ overlaySkillsDir?: string
1328
+ } = {},
1322
1329
  ): Promise<ProbeResult> {
1323
1330
  return withTimeout('Skills', (async (): Promise<ProbeResult> => {
1324
1331
  const fs = opts.fs ?? realSkillsFs
@@ -1359,8 +1366,32 @@ export async function probeSkills(
1359
1366
  continue
1360
1367
  }
1361
1368
  }
1369
+
1370
+ // Bucket entries by source: agent-overlay slugs come from filenames
1371
+ // under <agentRoot>/skills.d/<slug>.yaml (written by skill_install).
1372
+ // Everything else is operator-baseline from switchroom.yaml.
1373
+ const overlayDir = opts.overlaySkillsDir ?? join(agentDir, 'skills.d')
1374
+ const overlaySlugs = new Set<string>()
1375
+ if (fs.exists(overlayDir)) {
1376
+ let overlayEntries: string[] = []
1377
+ try {
1378
+ overlayEntries = fs.readdir(overlayDir)
1379
+ } catch {
1380
+ /* unreadable overlay — fall through with empty set */
1381
+ }
1382
+ for (const name of overlayEntries) {
1383
+ const m = name.match(/^(.+)\.ya?ml$/i)
1384
+ if (!m) continue
1385
+ overlaySlugs.add(m[1])
1386
+ }
1387
+ }
1388
+ const liveEntries = entries.filter(n => !dangling.includes(n)).sort()
1389
+ const switchroomSkills = liveEntries.filter(n => !overlaySlugs.has(n))
1390
+ const agentSkills = liveEntries.filter(n => overlaySlugs.has(n))
1391
+ const bucketed = renderBucketedSkills(switchroomSkills, agentSkills)
1392
+
1362
1393
  if (dangling.length === 0) {
1363
- return { status: 'ok', label: 'Skills', detail: `${entries.length} resolved` }
1394
+ return { status: 'ok', label: 'Skills', detail: bucketed }
1364
1395
  }
1365
1396
  const named = dangling.slice(0, max).join(', ')
1366
1397
  const more = dangling.length > max ? ` +${dangling.length - max} more` : ''
@@ -1368,12 +1399,21 @@ export async function probeSkills(
1368
1399
  return {
1369
1400
  status: 'degraded',
1370
1401
  label: 'Skills',
1371
- detail: `${dangling.length}/${entries.length} dangling: ${named}${more}`,
1402
+ detail: `${dangling.length}/${entries.length} dangling: ${named}${more} · ${bucketed}`,
1372
1403
  nextStep: `Run \`switchroom agent reconcile${reconcileTarget}\` to rebuild symlinks, or remove unused entries from switchroom.yaml`,
1373
1404
  }
1374
1405
  })())
1375
1406
  }
1376
1407
 
1408
+ /** Format the two source-buckets into a single mobile-readable line.
1409
+ * Empty buckets are omitted. `none` covers the all-dangling edge case. */
1410
+ function renderBucketedSkills(switchroom: string[], agent: string[]): string {
1411
+ const parts: string[] = []
1412
+ if (switchroom.length > 0) parts.push(`Switchroom: ${switchroom.join(', ')}`)
1413
+ if (agent.length > 0) parts.push(`Agent: ${agent.join(', ')}`)
1414
+ return parts.length === 0 ? 'none resolved' : parts.join(' · ')
1415
+ }
1416
+
1377
1417
  export interface SkillsFsImpl {
1378
1418
  readdir: (p: string) => string[]
1379
1419
  exists: (p: string) => boolean
@@ -4504,6 +4504,44 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
4504
4504
 
4505
4505
  const agentSlug = process.env.SWITCHROOM_AGENT_NAME || 'agent'
4506
4506
 
4507
+ // Fix B (#1487 follow-up): if this agent's STANDING ACL already
4508
+ // covers the key, do NOT render a card or mint a grant. Minting
4509
+ // writes a `.vault-token` that — pre-#1487 — *shadowed* the standing
4510
+ // ACL (the exact gymbro trap) and is simply redundant post-#1487.
4511
+ // Determine coverage AUTHORITATIVELY by probing the broker AS THIS
4512
+ // AGENT (no-token list over the per-agent socket — path-as-identity;
4513
+ // the gateway runs in the agent's container so the broker attributes
4514
+ // it to this agent). NOT a gateway-side config read: the gateway can
4515
+ // see newer config than the broker has loaded, so a config-derived
4516
+ // "covered" could be wrong where the broker still denies. `list`
4517
+ // returns only ACL-visible key NAMES — never secret values. Read
4518
+ // scope only: schedule.secrets[] confers read, not write.
4519
+ if (scopeRaw === 'read') {
4520
+ try {
4521
+ const visible = await listViaBroker()
4522
+ if (visible !== null && visible.includes(key)) {
4523
+ return {
4524
+ content: [
4525
+ {
4526
+ type: 'text',
4527
+ text:
4528
+ `vault_request_access: '${key}' is ALREADY covered by ${agentSlug}'s ` +
4529
+ `standing ACL (schedule.secrets[]). No approval card or grant is needed — ` +
4530
+ `read it directly: \`switchroom vault get ${key}\`. Do NOT request a grant ` +
4531
+ `for this key (a minted token would shadow the standing ACL). If a read ` +
4532
+ `still returns VAULT-BROKER-DENIED, the broker likely needs a restart to ` +
4533
+ `pick up a recent config change — tell the operator; don't re-request.`,
4534
+ },
4535
+ ],
4536
+ }
4537
+ }
4538
+ } catch {
4539
+ // Probe failed (broker unreachable / transient): fall through to
4540
+ // the normal card flow. Fail-open is correct here — a redundant
4541
+ // card is harmless; suppressing a needed card is not.
4542
+ }
4543
+ }
4544
+
4507
4545
  const stageId = randomBytes(4).toString('hex')
4508
4546
  const pending: PendingVaultRequestAccess = {
4509
4547
  agent: agentSlug,
@@ -9552,6 +9590,40 @@ async function performVaultAccessApproval(
9552
9590
  attestation.kind === 'passphrase'
9553
9591
  ? { passphrase: attestation.passphrase }
9554
9592
  : { attest_via_posture: true as const }
9593
+
9594
+ // Fix B (#1487 follow-up), operator-tap guard. Defense-in-depth for a
9595
+ // card staged before the key became standing-ACL-covered (config edit
9596
+ // / #1487 deploy / drift): if the agent's standing ACL ALREADY covers
9597
+ // this read key, do NOT mint — minting writes a `.vault-token` that
9598
+ // shadows the standing ACL and is redundant. Authoritative broker
9599
+ // probe AS THIS AGENT (no-token list over the per-agent socket — same
9600
+ // rationale as executeVaultRequestAccess; never a gateway-side config
9601
+ // read). Read scope only. Fail-open on probe error (mint as before).
9602
+ if (pending.scope === 'read') {
9603
+ try {
9604
+ const visible = await listViaBroker()
9605
+ if (visible !== null && visible.includes(pending.key)) {
9606
+ pendingVaultRequestAccesses.delete(stageId)
9607
+ if (pending.card_message_id != null) {
9608
+ await ctx.api
9609
+ .editMessageText(
9610
+ pending.chat_id,
9611
+ pending.card_message_id,
9612
+ `ℹ️ <b>${escapeHtmlForTg(pending.agent)}</b> already has standing-ACL access to ` +
9613
+ `<code>${escapeHtmlForTg(pending.key)}</code> (schedule.secrets[]). ` +
9614
+ `<b>No grant minted</b> — a token would shadow the standing ACL. ` +
9615
+ `The agent can read it directly.`,
9616
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
9617
+ )
9618
+ .catch(() => {})
9619
+ }
9620
+ return
9621
+ }
9622
+ } catch {
9623
+ // Probe failed: fall through and mint as before (fail-open).
9624
+ }
9625
+ }
9626
+
9555
9627
  // #1051: union the new key with the agent's existing active grant
9556
9628
  // before minting. Without this, each fresh Approve OVERWRITES the
9557
9629
  // agent's `.vault-token` file with a single-key grant — the
@@ -14,7 +14,7 @@
14
14
  * stream_reply lands as a SECOND message with the same content
15
15
  * (raw markdown, since reply tools don't always render HTML).
16
16
  *
17
- * Smoking-gun evidence: klanker chat 8248703757, msgs 5025 + 5027,
17
+ * Smoking-gun evidence: klanker chat 12345, msgs 5025 + 5027,
18
18
  * 11s apart. msg=5025 had `<b>...</b>` (turn-flush + markdownToHtml).
19
19
  * msg=5027 had `**...**` (the raw markdown reply tool's payload).
20
20
  * Same content, different formatting, two messages.
@@ -12,7 +12,7 @@
12
12
  * Schema (one table):
13
13
  *
14
14
  * turns
15
- * turn_key TEXT PK -- e.g. "8248703757:11"
15
+ * turn_key TEXT PK -- e.g. "12345:11"
16
16
  * chat_id TEXT NOT NULL
17
17
  * thread_id TEXT -- nullable: forum topics only
18
18
  * started_at INTEGER NOT NULL -- unix ms
@@ -127,7 +127,7 @@ function fakeClaudeBinary(opts: {
127
127
  const creds = {
128
128
  claudeAiOauth: {
129
129
  accessToken: ${JSON.stringify(token)},
130
- refreshToken: 'sk-ant-ort01-test-refresh',
130
+ refreshToken: ${JSON.stringify(['sk-ant-', 'ort01-test-refresh'].join(''))},
131
131
  expiresAt: Date.now() + 8 * 3600_000,
132
132
  scopes: ['user:inference'],
133
133
  subscriptionType: 'max',
@@ -45,14 +45,14 @@ function qOk(part: Partial<QuotaUtilization>): QuotaResult {
45
45
  const NOW_MS = new Date('2026-05-15T00:53:00Z').getTime();
46
46
 
47
47
  const FIXTURE_STATE: ListStateData = {
48
- active: 'pixsoul@x',
49
- fallback_order: ['ken@x', 'me@x', 'pixsoul@x'],
48
+ active: 'you@x',
49
+ fallback_order: ['ken@x', 'me@x', 'you@x'],
50
50
  accounts: [
51
51
  { label: 'ken@x', exhausted: false },
52
52
  { label: 'me@x', exhausted: false },
53
- { label: 'pixsoul@x', exhausted: false },
53
+ { label: 'you@x', exhausted: false },
54
54
  ],
55
- agents: [{ name: 'carrie', account: 'pixsoul@x', override: null }],
55
+ agents: [{ name: 'carrie', account: 'you@x', override: null }],
56
56
  consumers: [],
57
57
  };
58
58