switchroom 0.12.0 → 0.12.2
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/README.md +26 -11
- package/dist/auth-broker/index.js +1 -1
- package/dist/cli/skill-validate-pretool.mjs +7209 -0
- package/dist/cli/switchroom.js +891 -434
- package/dist/vault/broker/server.js +31 -22
- package/package.json +3 -2
- package/profiles/_shared/agent-self-service.md.hbs +1 -1
- package/skills/skill-creator/SKILL.md +52 -0
- package/telegram-plugin/auth-snapshot-format.ts +5 -5
- package/telegram-plugin/dist/gateway/gateway.js +62 -8
- package/telegram-plugin/gateway/access-validator.test.ts +8 -8
- package/telegram-plugin/gateway/access-validator.ts +1 -1
- package/telegram-plugin/gateway/boot-probes.ts +43 -3
- package/telegram-plugin/gateway/gateway.ts +72 -0
- package/telegram-plugin/recent-outbound-dedup.ts +1 -1
- package/telegram-plugin/registry/turns-schema.ts +1 -1
- package/telegram-plugin/tests/auth-add-flow.test.ts +1 -1
- package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +17 -17
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +10 -10
- package/telegram-plugin/tests/boot-probes.test.ts +37 -2
- package/telegram-plugin/tests/fixtures/service-log-current-claude-code.bin +1 -1
- package/telegram-plugin/tests/fleet-state.test.ts +3 -2
- package/telegram-plugin/tests/secret-detect-audit.test.ts +1 -1
- package/telegram-plugin/tests/secret-detect-pipeline.test.ts +7 -6
- package/telegram-plugin/tests/secret-detect-suppressor-no-silent-allow.test.ts +6 -5
- package/telegram-plugin/tests/secret-detect.test.ts +8 -8
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +8 -8
- 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
|
|
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
|
|
16060
|
+
result: `denied:grant-revoked`,
|
|
16053
16061
|
method: "grant",
|
|
16054
16062
|
grant_id: grantId
|
|
16055
16063
|
});
|
|
16056
|
-
socket.write(encodeResponse(errorResponse("DENIED",
|
|
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
|
|
16168
|
+
result: `denied:grant-revoked`,
|
|
16160
16169
|
method: "grant",
|
|
16161
16170
|
grant_id: grantId
|
|
16162
16171
|
});
|
|
16163
|
-
socket.write(encodeResponse(errorResponse("DENIED",
|
|
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.
|
|
3
|
+
"version": "0.12.2",
|
|
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
|
|
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
|
-
*
|
|
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
|
|
392
|
+
* ✓ Switched fleet · 5-hour limit on alice
|
|
393
393
|
*
|
|
394
|
-
*
|
|
394
|
+
* alice@example → you@example.com
|
|
395
395
|
* Triggered by: agent carrie
|
|
396
396
|
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
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:
|
|
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.
|
|
46537
|
-
var COMMIT_SHA = "
|
|
46538
|
-
var COMMIT_DATE = "2026-05-
|
|
46539
|
-
var LATEST_PR =
|
|
46540
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
46562
|
+
var VERSION = "0.12.2";
|
|
46563
|
+
var COMMIT_SHA = "1eeb64be";
|
|
46564
|
+
var COMMIT_DATE = "2026-05-18T09:09:53Z";
|
|
46565
|
+
var LATEST_PR = 1507;
|
|
46566
|
+
var COMMITS_AHEAD_OF_TAG = 2;
|
|
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 ??
|
|
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 (`[
|
|
6
|
-
* `["
|
|
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', ['
|
|
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: [
|
|
53
|
-
// Array.includes("
|
|
54
|
-
const result = validateStringArray('allowFrom', [
|
|
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('
|
|
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', ['
|
|
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. `[
|
|
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: {
|
|
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:
|
|
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
|
|
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.
|
|
@@ -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: '
|
|
49
|
-
fallback_order: ['ken@x', 'me@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: '
|
|
53
|
+
{ label: 'you@x', exhausted: false },
|
|
54
54
|
],
|
|
55
|
-
agents: [{ name: 'carrie', account: '
|
|
55
|
+
agents: [{ name: 'carrie', account: 'you@x', override: null }],
|
|
56
56
|
consumers: [],
|
|
57
57
|
};
|
|
58
58
|
|