switchroom 0.14.35 → 0.14.37
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/agent-scheduler/index.js +2 -1
- package/dist/auth-broker/index.js +2 -1
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +53 -3
- package/dist/host-control/main.js +2 -1
- package/dist/vault/approvals/kernel-server.js +2 -1
- package/dist/vault/broker/server.js +51 -1
- package/package.json +2 -2
- package/telegram-plugin/dist/gateway/gateway.js +52 -7
- package/telegram-plugin/gateway/gateway.ts +41 -1
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +88 -3
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +11 -1
- package/telegram-plugin/registry/subagents-bugs.test.ts +12 -4
- package/telegram-plugin/subagent-watcher.ts +45 -1
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +73 -0
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +155 -0
|
@@ -11386,7 +11386,8 @@ var VaultConfigSchema = exports_external.object({
|
|
|
11386
11386
|
autoUnlock: exports_external.boolean().default(false).describe("Auto-unlock the vault at broker start using a machine-bound " + "encrypted blob. Off by default. When enabled, the broker reads " + "the configured blob path, derives the AES key from /etc/machine-id, " + "decrypts the passphrase, and unlocks the vault — no sudo, no " + "systemd-creds, no TPM. Run `switchroom vault broker " + "enable-auto-unlock` once to write the blob."),
|
|
11387
11387
|
autoUnlockCredentialPath: exports_external.string().default("~/.switchroom/vault-auto-unlock").describe("Path to the machine-bound auto-unlock blob (see " + "src/vault/auto-unlock.ts for the format). Default lives under " + "~/.switchroom so it can be bind-mounted into the vault-broker " + "container by docker compose. Tilde-expansion happens " + "at read time."),
|
|
11388
11388
|
approvalAuth: exports_external.enum(["passphrase", "telegram-id"]).default("passphrase").describe("Posture for tap-to-Approve on vault grant cards. `passphrase` " + "(default) prompts the operator to type the vault passphrase on " + "every Approve — two-factor (Telegram ID + passphrase). " + "`telegram-id` mints immediately on Approve with no passphrase " + "prompt — single-factor (Telegram ID only); REQUIRES " + "`autoUnlock: true` so the broker already holds the passphrase. " + "Trades a factor of security for smoother UX; opt-in only."),
|
|
11389
|
-
postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` — no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored — passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config.")
|
|
11389
|
+
postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` — no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored — passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config."),
|
|
11390
|
+
adminOnlyKeys: exports_external.array(exports_external.string().min(1)).default([]).describe("Vault keys held to a higher approval bar: only the admin operator " + "(`access.allowFrom[0]`) may approve a grant for them, and they can " + "NEVER be minted via posture attestation — granting one requires the " + "operator passphrase (so an agent, even one on `postureMintAgents`, " + "cannot self-grant it). Entries are exact key names or `*` globs, " + "e.g. `stripe/*`, `*/oauth-token`, `microsoft/ken-tokens` (`*` matches " + "any run of characters incl. `/`; case-sensitive). Default `[]` — no " + "key is admin-only. Posture may RETAIN an admin-only key across a " + "union re-mint but never ADD one. Takes effect on broker + gateway " + "restart (broker has no ACL hot-reload).")
|
|
11390
11391
|
}).default({}).superRefine((broker, ctx) => {
|
|
11391
11392
|
if (broker.approvalAuth === "telegram-id" && broker.autoUnlock !== true) {
|
|
11392
11393
|
ctx.addIssue({
|
|
@@ -11386,7 +11386,8 @@ var VaultConfigSchema = exports_external.object({
|
|
|
11386
11386
|
autoUnlock: exports_external.boolean().default(false).describe("Auto-unlock the vault at broker start using a machine-bound " + "encrypted blob. Off by default. When enabled, the broker reads " + "the configured blob path, derives the AES key from /etc/machine-id, " + "decrypts the passphrase, and unlocks the vault — no sudo, no " + "systemd-creds, no TPM. Run `switchroom vault broker " + "enable-auto-unlock` once to write the blob."),
|
|
11387
11387
|
autoUnlockCredentialPath: exports_external.string().default("~/.switchroom/vault-auto-unlock").describe("Path to the machine-bound auto-unlock blob (see " + "src/vault/auto-unlock.ts for the format). Default lives under " + "~/.switchroom so it can be bind-mounted into the vault-broker " + "container by docker compose. Tilde-expansion happens " + "at read time."),
|
|
11388
11388
|
approvalAuth: exports_external.enum(["passphrase", "telegram-id"]).default("passphrase").describe("Posture for tap-to-Approve on vault grant cards. `passphrase` " + "(default) prompts the operator to type the vault passphrase on " + "every Approve — two-factor (Telegram ID + passphrase). " + "`telegram-id` mints immediately on Approve with no passphrase " + "prompt — single-factor (Telegram ID only); REQUIRES " + "`autoUnlock: true` so the broker already holds the passphrase. " + "Trades a factor of security for smoother UX; opt-in only."),
|
|
11389
|
-
postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` — no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored — passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config.")
|
|
11389
|
+
postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` — no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored — passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config."),
|
|
11390
|
+
adminOnlyKeys: exports_external.array(exports_external.string().min(1)).default([]).describe("Vault keys held to a higher approval bar: only the admin operator " + "(`access.allowFrom[0]`) may approve a grant for them, and they can " + "NEVER be minted via posture attestation — granting one requires the " + "operator passphrase (so an agent, even one on `postureMintAgents`, " + "cannot self-grant it). Entries are exact key names or `*` globs, " + "e.g. `stripe/*`, `*/oauth-token`, `microsoft/ken-tokens` (`*` matches " + "any run of characters incl. `/`; case-sensitive). Default `[]` — no " + "key is admin-only. Posture may RETAIN an admin-only key across a " + "union re-mint but never ADD one. Takes effect on broker + gateway " + "restart (broker has no ACL hot-reload).")
|
|
11390
11391
|
}).default({}).superRefine((broker, ctx) => {
|
|
11391
11392
|
if (broker.approvalAuth === "telegram-id" && broker.autoUnlock !== true) {
|
|
11392
11393
|
ctx.addIssue({
|
|
@@ -12134,7 +12134,8 @@ var VaultConfigSchema = exports_external.object({
|
|
|
12134
12134
|
autoUnlock: exports_external.boolean().default(false).describe("Auto-unlock the vault at broker start using a machine-bound " + "encrypted blob. Off by default. When enabled, the broker reads " + "the configured blob path, derives the AES key from /etc/machine-id, " + "decrypts the passphrase, and unlocks the vault \u2014 no sudo, no " + "systemd-creds, no TPM. Run `switchroom vault broker " + "enable-auto-unlock` once to write the blob."),
|
|
12135
12135
|
autoUnlockCredentialPath: exports_external.string().default("~/.switchroom/vault-auto-unlock").describe("Path to the machine-bound auto-unlock blob (see " + "src/vault/auto-unlock.ts for the format). Default lives under " + "~/.switchroom so it can be bind-mounted into the vault-broker " + "container by docker compose. Tilde-expansion happens " + "at read time."),
|
|
12136
12136
|
approvalAuth: exports_external.enum(["passphrase", "telegram-id"]).default("passphrase").describe("Posture for tap-to-Approve on vault grant cards. `passphrase` " + "(default) prompts the operator to type the vault passphrase on " + "every Approve \u2014 two-factor (Telegram ID + passphrase). " + "`telegram-id` mints immediately on Approve with no passphrase " + "prompt \u2014 single-factor (Telegram ID only); REQUIRES " + "`autoUnlock: true` so the broker already holds the passphrase. " + "Trades a factor of security for smoother UX; opt-in only."),
|
|
12137
|
-
postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` \u2014 no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored \u2014 passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config.")
|
|
12137
|
+
postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` \u2014 no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored \u2014 passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config."),
|
|
12138
|
+
adminOnlyKeys: exports_external.array(exports_external.string().min(1)).default([]).describe("Vault keys held to a higher approval bar: only the admin operator " + "(`access.allowFrom[0]`) may approve a grant for them, and they can " + "NEVER be minted via posture attestation \u2014 granting one requires the " + "operator passphrase (so an agent, even one on `postureMintAgents`, " + "cannot self-grant it). Entries are exact key names or `*` globs, " + "e.g. `stripe/*`, `*/oauth-token`, `microsoft/ken-tokens` (`*` matches " + "any run of characters incl. `/`; case-sensitive). Default `[]` \u2014 no " + "key is admin-only. Posture may RETAIN an admin-only key across a " + "union re-mint but never ADD one. Takes effect on broker + gateway " + "restart (broker has no ACL hot-reload).")
|
|
12138
12139
|
}).default({}).superRefine((broker, ctx) => {
|
|
12139
12140
|
if (broker.approvalAuth === "telegram-id" && broker.autoUnlock !== true) {
|
|
12140
12141
|
ctx.addIssue({
|
package/dist/cli/switchroom.js
CHANGED
|
@@ -13950,7 +13950,8 @@ var init_schema = __esm(() => {
|
|
|
13950
13950
|
autoUnlock: exports_external.boolean().default(false).describe("Auto-unlock the vault at broker start using a machine-bound " + "encrypted blob. Off by default. When enabled, the broker reads " + "the configured blob path, derives the AES key from /etc/machine-id, " + "decrypts the passphrase, and unlocks the vault \u2014 no sudo, no " + "systemd-creds, no TPM. Run `switchroom vault broker " + "enable-auto-unlock` once to write the blob."),
|
|
13951
13951
|
autoUnlockCredentialPath: exports_external.string().default("~/.switchroom/vault-auto-unlock").describe("Path to the machine-bound auto-unlock blob (see " + "src/vault/auto-unlock.ts for the format). Default lives under " + "~/.switchroom so it can be bind-mounted into the vault-broker " + "container by docker compose. Tilde-expansion happens " + "at read time."),
|
|
13952
13952
|
approvalAuth: exports_external.enum(["passphrase", "telegram-id"]).default("passphrase").describe("Posture for tap-to-Approve on vault grant cards. `passphrase` " + "(default) prompts the operator to type the vault passphrase on " + "every Approve \u2014 two-factor (Telegram ID + passphrase). " + "`telegram-id` mints immediately on Approve with no passphrase " + "prompt \u2014 single-factor (Telegram ID only); REQUIRES " + "`autoUnlock: true` so the broker already holds the passphrase. " + "Trades a factor of security for smoother UX; opt-in only."),
|
|
13953
|
-
postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` \u2014 no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored \u2014 passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config.")
|
|
13953
|
+
postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` \u2014 no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored \u2014 passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config."),
|
|
13954
|
+
adminOnlyKeys: exports_external.array(exports_external.string().min(1)).default([]).describe("Vault keys held to a higher approval bar: only the admin operator " + "(`access.allowFrom[0]`) may approve a grant for them, and they can " + "NEVER be minted via posture attestation \u2014 granting one requires the " + "operator passphrase (so an agent, even one on `postureMintAgents`, " + "cannot self-grant it). Entries are exact key names or `*` globs, " + "e.g. `stripe/*`, `*/oauth-token`, `microsoft/ken-tokens` (`*` matches " + "any run of characters incl. `/`; case-sensitive). Default `[]` \u2014 no " + "key is admin-only. Posture may RETAIN an admin-only key across a " + "union re-mint but never ADD one. Takes effect on broker + gateway " + "restart (broker has no ACL hot-reload).")
|
|
13954
13955
|
}).default({}).superRefine((broker, ctx) => {
|
|
13955
13956
|
if (broker.approvalAuth === "telegram-id" && broker.autoUnlock !== true) {
|
|
13956
13957
|
ctx.addIssue({
|
|
@@ -49439,8 +49440,8 @@ var {
|
|
|
49439
49440
|
} = import__.default;
|
|
49440
49441
|
|
|
49441
49442
|
// src/build-info.ts
|
|
49442
|
-
var VERSION = "0.14.
|
|
49443
|
-
var COMMIT_SHA = "
|
|
49443
|
+
var VERSION = "0.14.37";
|
|
49444
|
+
var COMMIT_SHA = "90d0c420";
|
|
49444
49445
|
|
|
49445
49446
|
// src/cli/agent.ts
|
|
49446
49447
|
init_source();
|
|
@@ -61321,6 +61322,31 @@ function listGrants(db, agent_slug) {
|
|
|
61321
61322
|
return rows.map(rowToGrant);
|
|
61322
61323
|
}
|
|
61323
61324
|
|
|
61325
|
+
// src/vault/admin-only-keys.ts
|
|
61326
|
+
function globToRegExp(pattern) {
|
|
61327
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
61328
|
+
return new RegExp(`^${escaped}$`);
|
|
61329
|
+
}
|
|
61330
|
+
function matchesAdminOnlyKey(key, patterns) {
|
|
61331
|
+
for (const p of patterns) {
|
|
61332
|
+
if (p === key)
|
|
61333
|
+
return true;
|
|
61334
|
+
if (p.includes("*") && globToRegExp(p).test(key))
|
|
61335
|
+
return true;
|
|
61336
|
+
}
|
|
61337
|
+
return false;
|
|
61338
|
+
}
|
|
61339
|
+
function adminOnlyKeysBeingAdded(requestedKeys, existingKeys, patterns) {
|
|
61340
|
+
const existing = new Set(existingKeys);
|
|
61341
|
+
const blocked = [];
|
|
61342
|
+
for (const k of requestedKeys) {
|
|
61343
|
+
if (matchesAdminOnlyKey(k, patterns) && !existing.has(k) && !blocked.includes(k)) {
|
|
61344
|
+
blocked.push(k);
|
|
61345
|
+
}
|
|
61346
|
+
}
|
|
61347
|
+
return blocked;
|
|
61348
|
+
}
|
|
61349
|
+
|
|
61324
61350
|
// src/vault/grants-db.ts
|
|
61325
61351
|
import * as os3 from "node:os";
|
|
61326
61352
|
import * as path2 from "node:path";
|
|
@@ -62699,6 +62725,30 @@ class VaultBroker {
|
|
|
62699
62725
|
socket.write(encodeResponse(errorResponse("DENIED", `posture-attested list refused: request.agent=${reqAgent} but calling peer is ${agentName}`)));
|
|
62700
62726
|
return;
|
|
62701
62727
|
}
|
|
62728
|
+
if (req.op === "mint_grant") {
|
|
62729
|
+
const adminOnly = this.config?.vault?.broker?.adminOnlyKeys ?? [];
|
|
62730
|
+
if (adminOnly.length > 0) {
|
|
62731
|
+
const requested = [
|
|
62732
|
+
...req.keys ?? [],
|
|
62733
|
+
...req.write_keys ?? []
|
|
62734
|
+
];
|
|
62735
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
62736
|
+
const existingKeys = listGrants(this.grantsDb, agentName).filter((g) => g.expires_at === null || g.expires_at > nowSec).flatMap((g) => [...g.key_allow ?? [], ...g.write_allow ?? []]);
|
|
62737
|
+
const blocked = adminOnlyKeysBeingAdded(requested, existingKeys, adminOnly);
|
|
62738
|
+
if (blocked.length > 0) {
|
|
62739
|
+
writeAudit({
|
|
62740
|
+
ts: new Date().toISOString(),
|
|
62741
|
+
op: req.op,
|
|
62742
|
+
caller: auditCaller,
|
|
62743
|
+
pid: auditPid,
|
|
62744
|
+
cgroup: auditCgroup,
|
|
62745
|
+
result: "denied:posture-mint-admin-only-key"
|
|
62746
|
+
});
|
|
62747
|
+
socket.write(encodeResponse(errorResponse("DENIED", `admin-only credential(s) ${blocked.join(", ")} cannot be minted via posture ` + `attestation \u2014 they require the operator passphrase (vault.broker.adminOnlyKeys)`)));
|
|
62748
|
+
return;
|
|
62749
|
+
}
|
|
62750
|
+
}
|
|
62751
|
+
}
|
|
62702
62752
|
if (this.passphrase === null) {
|
|
62703
62753
|
writeAudit({
|
|
62704
62754
|
ts: new Date().toISOString(),
|
|
@@ -14121,7 +14121,8 @@ var VaultConfigSchema = exports_external.object({
|
|
|
14121
14121
|
autoUnlock: exports_external.boolean().default(false).describe("Auto-unlock the vault at broker start using a machine-bound " + "encrypted blob. Off by default. When enabled, the broker reads " + "the configured blob path, derives the AES key from /etc/machine-id, " + "decrypts the passphrase, and unlocks the vault — no sudo, no " + "systemd-creds, no TPM. Run `switchroom vault broker " + "enable-auto-unlock` once to write the blob."),
|
|
14122
14122
|
autoUnlockCredentialPath: exports_external.string().default("~/.switchroom/vault-auto-unlock").describe("Path to the machine-bound auto-unlock blob (see " + "src/vault/auto-unlock.ts for the format). Default lives under " + "~/.switchroom so it can be bind-mounted into the vault-broker " + "container by docker compose. Tilde-expansion happens " + "at read time."),
|
|
14123
14123
|
approvalAuth: exports_external.enum(["passphrase", "telegram-id"]).default("passphrase").describe("Posture for tap-to-Approve on vault grant cards. `passphrase` " + "(default) prompts the operator to type the vault passphrase on " + "every Approve — two-factor (Telegram ID + passphrase). " + "`telegram-id` mints immediately on Approve with no passphrase " + "prompt — single-factor (Telegram ID only); REQUIRES " + "`autoUnlock: true` so the broker already holds the passphrase. " + "Trades a factor of security for smoother UX; opt-in only."),
|
|
14124
|
-
postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` — no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored — passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config.")
|
|
14124
|
+
postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` — no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored — passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config."),
|
|
14125
|
+
adminOnlyKeys: exports_external.array(exports_external.string().min(1)).default([]).describe("Vault keys held to a higher approval bar: only the admin operator " + "(`access.allowFrom[0]`) may approve a grant for them, and they can " + "NEVER be minted via posture attestation — granting one requires the " + "operator passphrase (so an agent, even one on `postureMintAgents`, " + "cannot self-grant it). Entries are exact key names or `*` globs, " + "e.g. `stripe/*`, `*/oauth-token`, `microsoft/ken-tokens` (`*` matches " + "any run of characters incl. `/`; case-sensitive). Default `[]` — no " + "key is admin-only. Posture may RETAIN an admin-only key across a " + "union re-mint but never ADD one. Takes effect on broker + gateway " + "restart (broker has no ACL hot-reload).")
|
|
14125
14126
|
}).default({}).superRefine((broker, ctx) => {
|
|
14126
14127
|
if (broker.approvalAuth === "telegram-id" && broker.autoUnlock !== true) {
|
|
14127
14128
|
ctx.addIssue({
|
|
@@ -11707,7 +11707,8 @@ var init_schema = __esm(() => {
|
|
|
11707
11707
|
autoUnlock: exports_external.boolean().default(false).describe("Auto-unlock the vault at broker start using a machine-bound " + "encrypted blob. Off by default. When enabled, the broker reads " + "the configured blob path, derives the AES key from /etc/machine-id, " + "decrypts the passphrase, and unlocks the vault — no sudo, no " + "systemd-creds, no TPM. Run `switchroom vault broker " + "enable-auto-unlock` once to write the blob."),
|
|
11708
11708
|
autoUnlockCredentialPath: exports_external.string().default("~/.switchroom/vault-auto-unlock").describe("Path to the machine-bound auto-unlock blob (see " + "src/vault/auto-unlock.ts for the format). Default lives under " + "~/.switchroom so it can be bind-mounted into the vault-broker " + "container by docker compose. Tilde-expansion happens " + "at read time."),
|
|
11709
11709
|
approvalAuth: exports_external.enum(["passphrase", "telegram-id"]).default("passphrase").describe("Posture for tap-to-Approve on vault grant cards. `passphrase` " + "(default) prompts the operator to type the vault passphrase on " + "every Approve — two-factor (Telegram ID + passphrase). " + "`telegram-id` mints immediately on Approve with no passphrase " + "prompt — single-factor (Telegram ID only); REQUIRES " + "`autoUnlock: true` so the broker already holds the passphrase. " + "Trades a factor of security for smoother UX; opt-in only."),
|
|
11710
|
-
postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` — no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored — passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config.")
|
|
11710
|
+
postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` — no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored — passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config."),
|
|
11711
|
+
adminOnlyKeys: exports_external.array(exports_external.string().min(1)).default([]).describe("Vault keys held to a higher approval bar: only the admin operator " + "(`access.allowFrom[0]`) may approve a grant for them, and they can " + "NEVER be minted via posture attestation — granting one requires the " + "operator passphrase (so an agent, even one on `postureMintAgents`, " + "cannot self-grant it). Entries are exact key names or `*` globs, " + "e.g. `stripe/*`, `*/oauth-token`, `microsoft/ken-tokens` (`*` matches " + "any run of characters incl. `/`; case-sensitive). Default `[]` — no " + "key is admin-only. Posture may RETAIN an admin-only key across a " + "union re-mint but never ADD one. Takes effect on broker + gateway " + "restart (broker has no ACL hot-reload).")
|
|
11711
11712
|
}).default({}).superRefine((broker, ctx) => {
|
|
11712
11713
|
if (broker.approvalAuth === "telegram-id" && broker.autoUnlock !== true) {
|
|
11713
11714
|
ctx.addIssue({
|
|
@@ -11707,7 +11707,8 @@ var init_schema = __esm(() => {
|
|
|
11707
11707
|
autoUnlock: exports_external.boolean().default(false).describe("Auto-unlock the vault at broker start using a machine-bound " + "encrypted blob. Off by default. When enabled, the broker reads " + "the configured blob path, derives the AES key from /etc/machine-id, " + "decrypts the passphrase, and unlocks the vault — no sudo, no " + "systemd-creds, no TPM. Run `switchroom vault broker " + "enable-auto-unlock` once to write the blob."),
|
|
11708
11708
|
autoUnlockCredentialPath: exports_external.string().default("~/.switchroom/vault-auto-unlock").describe("Path to the machine-bound auto-unlock blob (see " + "src/vault/auto-unlock.ts for the format). Default lives under " + "~/.switchroom so it can be bind-mounted into the vault-broker " + "container by docker compose. Tilde-expansion happens " + "at read time."),
|
|
11709
11709
|
approvalAuth: exports_external.enum(["passphrase", "telegram-id"]).default("passphrase").describe("Posture for tap-to-Approve on vault grant cards. `passphrase` " + "(default) prompts the operator to type the vault passphrase on " + "every Approve — two-factor (Telegram ID + passphrase). " + "`telegram-id` mints immediately on Approve with no passphrase " + "prompt — single-factor (Telegram ID only); REQUIRES " + "`autoUnlock: true` so the broker already holds the passphrase. " + "Trades a factor of security for smoother UX; opt-in only."),
|
|
11710
|
-
postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` — no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored — passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config.")
|
|
11710
|
+
postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` — no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored — passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config."),
|
|
11711
|
+
adminOnlyKeys: exports_external.array(exports_external.string().min(1)).default([]).describe("Vault keys held to a higher approval bar: only the admin operator " + "(`access.allowFrom[0]`) may approve a grant for them, and they can " + "NEVER be minted via posture attestation — granting one requires the " + "operator passphrase (so an agent, even one on `postureMintAgents`, " + "cannot self-grant it). Entries are exact key names or `*` globs, " + "e.g. `stripe/*`, `*/oauth-token`, `microsoft/ken-tokens` (`*` matches " + "any run of characters incl. `/`; case-sensitive). Default `[]` — no " + "key is admin-only. Posture may RETAIN an admin-only key across a " + "union re-mint but never ADD one. Takes effect on broker + gateway " + "restart (broker has no ACL hot-reload).")
|
|
11711
11712
|
}).default({}).superRefine((broker, ctx) => {
|
|
11712
11713
|
if (broker.approvalAuth === "telegram-id" && broker.autoUnlock !== true) {
|
|
11713
11714
|
ctx.addIssue({
|
|
@@ -15712,6 +15713,31 @@ function listGrants(db, agent_slug) {
|
|
|
15712
15713
|
return rows.map(rowToGrant);
|
|
15713
15714
|
}
|
|
15714
15715
|
|
|
15716
|
+
// src/vault/admin-only-keys.ts
|
|
15717
|
+
function globToRegExp(pattern) {
|
|
15718
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
15719
|
+
return new RegExp(`^${escaped}$`);
|
|
15720
|
+
}
|
|
15721
|
+
function matchesAdminOnlyKey(key, patterns) {
|
|
15722
|
+
for (const p of patterns) {
|
|
15723
|
+
if (p === key)
|
|
15724
|
+
return true;
|
|
15725
|
+
if (p.includes("*") && globToRegExp(p).test(key))
|
|
15726
|
+
return true;
|
|
15727
|
+
}
|
|
15728
|
+
return false;
|
|
15729
|
+
}
|
|
15730
|
+
function adminOnlyKeysBeingAdded(requestedKeys, existingKeys, patterns) {
|
|
15731
|
+
const existing = new Set(existingKeys);
|
|
15732
|
+
const blocked = [];
|
|
15733
|
+
for (const k of requestedKeys) {
|
|
15734
|
+
if (matchesAdminOnlyKey(k, patterns) && !existing.has(k) && !blocked.includes(k)) {
|
|
15735
|
+
blocked.push(k);
|
|
15736
|
+
}
|
|
15737
|
+
}
|
|
15738
|
+
return blocked;
|
|
15739
|
+
}
|
|
15740
|
+
|
|
15715
15741
|
// src/vault/grants-db.ts
|
|
15716
15742
|
import * as os2 from "node:os";
|
|
15717
15743
|
import * as path2 from "node:path";
|
|
@@ -17090,6 +17116,30 @@ class VaultBroker {
|
|
|
17090
17116
|
socket.write(encodeResponse(errorResponse("DENIED", `posture-attested list refused: request.agent=${reqAgent} but calling peer is ${agentName}`)));
|
|
17091
17117
|
return;
|
|
17092
17118
|
}
|
|
17119
|
+
if (req.op === "mint_grant") {
|
|
17120
|
+
const adminOnly = this.config?.vault?.broker?.adminOnlyKeys ?? [];
|
|
17121
|
+
if (adminOnly.length > 0) {
|
|
17122
|
+
const requested = [
|
|
17123
|
+
...req.keys ?? [],
|
|
17124
|
+
...req.write_keys ?? []
|
|
17125
|
+
];
|
|
17126
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
17127
|
+
const existingKeys = listGrants(this.grantsDb, agentName).filter((g) => g.expires_at === null || g.expires_at > nowSec).flatMap((g) => [...g.key_allow ?? [], ...g.write_allow ?? []]);
|
|
17128
|
+
const blocked = adminOnlyKeysBeingAdded(requested, existingKeys, adminOnly);
|
|
17129
|
+
if (blocked.length > 0) {
|
|
17130
|
+
writeAudit({
|
|
17131
|
+
ts: new Date().toISOString(),
|
|
17132
|
+
op: req.op,
|
|
17133
|
+
caller: auditCaller,
|
|
17134
|
+
pid: auditPid,
|
|
17135
|
+
cgroup: auditCgroup,
|
|
17136
|
+
result: "denied:posture-mint-admin-only-key"
|
|
17137
|
+
});
|
|
17138
|
+
socket.write(encodeResponse(errorResponse("DENIED", `admin-only credential(s) ${blocked.join(", ")} cannot be minted via posture ` + `attestation — they require the operator passphrase (vault.broker.adminOnlyKeys)`)));
|
|
17139
|
+
return;
|
|
17140
|
+
}
|
|
17141
|
+
}
|
|
17142
|
+
}
|
|
17093
17143
|
if (this.passphrase === null) {
|
|
17094
17144
|
writeAudit({
|
|
17095
17145
|
ts: new Date().toISOString(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchroom",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.37",
|
|
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": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"pretest": "npm run build",
|
|
26
26
|
"test": "vitest run && bun test 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/resume-inbound-builder.test.ts telegram-plugin/registry/api-registry.test.ts telegram-plugin/registry/turns-schema.test.ts telegram-plugin/tests/idle-footer-wiring.test.ts telegram-plugin/tests/subagent-tracker-hooks.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/gateway/webhook-ingest-server.test.ts",
|
|
27
27
|
"test:vitest": "vitest run",
|
|
28
|
-
"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/approval-origin.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/resume-inbound-builder.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 telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
|
|
28
|
+
"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/server-admin-only-keys.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/approval-origin.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/resume-inbound-builder.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 telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
|
|
29
29
|
"test:watch": "vitest",
|
|
30
30
|
"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 && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs",
|
|
31
31
|
"lint:tsc": "tsc --noEmit",
|
|
@@ -24092,7 +24092,8 @@ var init_schema = __esm(() => {
|
|
|
24092
24092
|
autoUnlock: exports_external.boolean().default(false).describe("Auto-unlock the vault at broker start using a machine-bound " + "encrypted blob. Off by default. When enabled, the broker reads " + "the configured blob path, derives the AES key from /etc/machine-id, " + "decrypts the passphrase, and unlocks the vault \u2014 no sudo, no " + "systemd-creds, no TPM. Run `switchroom vault broker " + "enable-auto-unlock` once to write the blob."),
|
|
24093
24093
|
autoUnlockCredentialPath: exports_external.string().default("~/.switchroom/vault-auto-unlock").describe("Path to the machine-bound auto-unlock blob (see " + "src/vault/auto-unlock.ts for the format). Default lives under " + "~/.switchroom so it can be bind-mounted into the vault-broker " + "container by docker compose. Tilde-expansion happens " + "at read time."),
|
|
24094
24094
|
approvalAuth: exports_external.enum(["passphrase", "telegram-id"]).default("passphrase").describe("Posture for tap-to-Approve on vault grant cards. `passphrase` " + "(default) prompts the operator to type the vault passphrase on " + "every Approve \u2014 two-factor (Telegram ID + passphrase). " + "`telegram-id` mints immediately on Approve with no passphrase " + "prompt \u2014 single-factor (Telegram ID only); REQUIRES " + "`autoUnlock: true` so the broker already holds the passphrase. " + "Trades a factor of security for smoother UX; opt-in only."),
|
|
24095
|
-
postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` \u2014 no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored \u2014 passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config.")
|
|
24095
|
+
postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` \u2014 no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored \u2014 passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config."),
|
|
24096
|
+
adminOnlyKeys: exports_external.array(exports_external.string().min(1)).default([]).describe("Vault keys held to a higher approval bar: only the admin operator " + "(`access.allowFrom[0]`) may approve a grant for them, and they can " + "NEVER be minted via posture attestation \u2014 granting one requires the " + "operator passphrase (so an agent, even one on `postureMintAgents`, " + "cannot self-grant it). Entries are exact key names or `*` globs, " + "e.g. `stripe/*`, `*/oauth-token`, `microsoft/ken-tokens` (`*` matches " + "any run of characters incl. `/`; case-sensitive). Default `[]` \u2014 no " + "key is admin-only. Posture may RETAIN an admin-only key across a " + "union re-mint but never ADD one. Takes effect on broker + gateway " + "restart (broker has no ACL hot-reload).")
|
|
24096
24097
|
}).default({}).superRefine((broker, ctx) => {
|
|
24097
24098
|
if (broker.approvalAuth === "telegram-id" && broker.autoUnlock !== true) {
|
|
24098
24099
|
ctx.addIssue({
|
|
@@ -49570,6 +49571,20 @@ function backfillJsonlAgentId(db2, jsonlPath, agentId, log) {
|
|
|
49570
49571
|
}
|
|
49571
49572
|
db2.prepare("UPDATE subagents SET jsonl_agent_id = ? WHERE id = ?").run(agentId, candidate.id);
|
|
49572
49573
|
log?.(`subagent-watcher: backfill linked ${agentId} \u2192 ${candidate.id}`);
|
|
49574
|
+
try {
|
|
49575
|
+
const linkedRow = db2.prepare("SELECT started_at, parent_turn_key FROM subagents WHERE id = ?").get(candidate.id);
|
|
49576
|
+
if (linkedRow != null && linkedRow.parent_turn_key == null) {
|
|
49577
|
+
const turn = db2.prepare(`SELECT turn_key FROM turns
|
|
49578
|
+
WHERE started_at <= ? AND (ended_at IS NULL OR ended_at >= ?)
|
|
49579
|
+
ORDER BY started_at DESC LIMIT 1`).get(linkedRow.started_at, linkedRow.started_at);
|
|
49580
|
+
if (turn?.turn_key != null) {
|
|
49581
|
+
db2.prepare("UPDATE subagents SET parent_turn_key = ? WHERE id = ?").run(turn.turn_key, candidate.id);
|
|
49582
|
+
log?.(`subagent-watcher: backfill parent_turn_key ${candidate.id} \u2192 ${turn.turn_key}`);
|
|
49583
|
+
}
|
|
49584
|
+
}
|
|
49585
|
+
} catch (err) {
|
|
49586
|
+
log?.(`subagent-watcher: parent_turn_key backfill skipped for ${candidate.id} \u2014 ${err.message}`);
|
|
49587
|
+
}
|
|
49573
49588
|
}
|
|
49574
49589
|
function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, parentStateDir, onUnstall, onFileVanished, onProgress) {
|
|
49575
49590
|
try {
|
|
@@ -51781,10 +51796,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
51781
51796
|
}
|
|
51782
51797
|
|
|
51783
51798
|
// ../src/build-info.ts
|
|
51784
|
-
var VERSION = "0.14.
|
|
51785
|
-
var COMMIT_SHA = "
|
|
51786
|
-
var COMMIT_DATE = "2026-06-
|
|
51787
|
-
var LATEST_PR =
|
|
51799
|
+
var VERSION = "0.14.37";
|
|
51800
|
+
var COMMIT_SHA = "90d0c420";
|
|
51801
|
+
var COMMIT_DATE = "2026-06-02T02:10:03Z";
|
|
51802
|
+
var LATEST_PR = 2078;
|
|
51788
51803
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
51789
51804
|
|
|
51790
51805
|
// gateway/boot-version.ts
|
|
@@ -52083,6 +52098,21 @@ function resolveVaultApprovalPosture(broker) {
|
|
|
52083
52098
|
return { mode: "passphrase" };
|
|
52084
52099
|
}
|
|
52085
52100
|
|
|
52101
|
+
// ../src/vault/admin-only-keys.ts
|
|
52102
|
+
function globToRegExp(pattern) {
|
|
52103
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
52104
|
+
return new RegExp(`^${escaped}$`);
|
|
52105
|
+
}
|
|
52106
|
+
function matchesAdminOnlyKey(key, patterns) {
|
|
52107
|
+
for (const p of patterns) {
|
|
52108
|
+
if (p === key)
|
|
52109
|
+
return true;
|
|
52110
|
+
if (p.includes("*") && globToRegExp(p).test(key))
|
|
52111
|
+
return true;
|
|
52112
|
+
}
|
|
52113
|
+
return false;
|
|
52114
|
+
}
|
|
52115
|
+
|
|
52086
52116
|
// registry/turns-schema.ts
|
|
52087
52117
|
import { chmodSync as chmodSync4, mkdirSync as mkdirSync22 } from "fs";
|
|
52088
52118
|
import { join as join33 } from "path";
|
|
@@ -53495,6 +53525,7 @@ function rememberAgentButtonMeta(chatId, messageId, meta) {
|
|
|
53495
53525
|
var vaultPassphraseCache = new Map;
|
|
53496
53526
|
var VAULT_PASSPHRASE_TTL_MS = 1800000;
|
|
53497
53527
|
var VAULT_APPROVAL_AUTH_MODE = "passphrase";
|
|
53528
|
+
var ADMIN_ONLY_KEYS = [];
|
|
53498
53529
|
function initVaultApprovalPosture() {
|
|
53499
53530
|
let cfg;
|
|
53500
53531
|
try {
|
|
@@ -53508,6 +53539,11 @@ function initVaultApprovalPosture() {
|
|
|
53508
53539
|
VAULT_APPROVAL_AUTH_MODE = resolved.mode;
|
|
53509
53540
|
if (resolved.mode === "telegram-id") {
|
|
53510
53541
|
process.stderr.write(`telegram gateway: vault approval posture = telegram-id ` + `(single-factor \u2014 broker mediates attestation via attest_via_posture)
|
|
53542
|
+
`);
|
|
53543
|
+
}
|
|
53544
|
+
ADMIN_ONLY_KEYS = cfg.vault?.broker?.adminOnlyKeys ?? [];
|
|
53545
|
+
if (ADMIN_ONLY_KEYS.length > 0) {
|
|
53546
|
+
process.stderr.write(`telegram gateway: ${ADMIN_ONLY_KEYS.length} admin-only vault key pattern(s) ` + `\u2014 grants approved by allowFrom[0] + operator passphrase only
|
|
53511
53547
|
`);
|
|
53512
53548
|
}
|
|
53513
53549
|
}
|
|
@@ -59724,7 +59760,14 @@ async function handleVaultRequestAccessCallback(ctx, data) {
|
|
|
59724
59760
|
return;
|
|
59725
59761
|
}
|
|
59726
59762
|
if (action === "approve") {
|
|
59727
|
-
|
|
59763
|
+
const isAdminOnly = matchesAdminOnlyKey(pending2.key, ADMIN_ONLY_KEYS);
|
|
59764
|
+
if (isAdminOnly && senderId !== access.allowFrom[0]) {
|
|
59765
|
+
await ctx.answerCallbackQuery({
|
|
59766
|
+
text: "\uD83D\uDD12 Admin-only credential \u2014 only the owner can approve this."
|
|
59767
|
+
}).catch(() => {});
|
|
59768
|
+
return;
|
|
59769
|
+
}
|
|
59770
|
+
if (!isAdminOnly && VAULT_APPROVAL_AUTH_MODE === "telegram-id") {
|
|
59728
59771
|
const username = ctx.from?.username ?? ctx.from?.first_name ?? `id=${senderId}`;
|
|
59729
59772
|
if (pending2.card_message_id != null) {
|
|
59730
59773
|
await ctx.api.editMessageText(pending2.chat_id, pending2.card_message_id, `\u2705 Approved by @${escapeHtmlForTg(username)} \u2014 minting\u2026`, { parse_mode: "HTML", reply_markup: { inline_keyboard: [] } }).catch(() => {});
|
|
@@ -59754,7 +59797,9 @@ async function handleVaultRequestAccessCallback(ctx, data) {
|
|
|
59754
59797
|
});
|
|
59755
59798
|
const joiningBatch = items.length > 1;
|
|
59756
59799
|
await ctx.answerCallbackQuery({ text: joiningBatch ? `\uD83D\uDD10 Queued \u2014 one passphrase covers ${items.length} cards` : "\uD83D\uDD10 Send your passphrase\u2026" }).catch(() => {});
|
|
59757
|
-
await ctx.api.editMessageText(pending2.chat_id, pending2.card_message_id, joiningBatch ? `\uD83D\uDD10 <b>Queued behind an earlier card.</b> Type your passphrase as your next message \u2014 it covers <b>${items.length}</b> pending approvals in this chat (one entry mints all grants, no re-type per card).` : `\uD83D\
|
|
59800
|
+
await ctx.api.editMessageText(pending2.chat_id, pending2.card_message_id, joiningBatch ? `\uD83D\uDD10 <b>Queued behind an earlier card.</b> Type your passphrase as your next message \u2014 it covers <b>${items.length}</b> pending approvals in this chat (one entry mints all grants, no re-type per card).` : isAdminOnly ? `\uD83D\uDD12 <b>Admin-only credential.</b> <code>${escapeHtmlForTg(pending2.key)}</code> requires your vault passphrase to grant \u2014 reply with it as your next message and we'll mint the grant for <b>${escapeHtmlForTg(pending2.agent)}</b>, then delete the passphrase message.
|
|
59801
|
+
|
|
59802
|
+
<i>The passphrase is what proves it's you: an agent can never mint this key on its own.</i>` : `\uD83D\uDD10 <b>Vault is locked.</b> Reply with your passphrase as your next message \u2014 we'll unlock, mint the grant for <b>${escapeHtmlForTg(pending2.agent)}</b>, and delete the passphrase message in one step.
|
|
59758
59803
|
|
|
59759
59804
|
<i>Mint authority stays operator-only: the broker only accepts the grant when the passphrase matches.</i>`, { parse_mode: "HTML", reply_markup: { inline_keyboard: [] } }).catch(() => {});
|
|
59760
59805
|
return;
|
|
@@ -420,6 +420,7 @@ import {
|
|
|
420
420
|
approvalRecord,
|
|
421
421
|
} from '../../src/vault/approvals/client.js'
|
|
422
422
|
import { resolveVaultApprovalPosture } from '../vault-approval-posture.js'
|
|
423
|
+
import { matchesAdminOnlyKey } from '../../src/vault/admin-only-keys.js'
|
|
423
424
|
import {
|
|
424
425
|
openTurnsDb,
|
|
425
426
|
markOrphanedWithTimeoutClassification,
|
|
@@ -2690,6 +2691,15 @@ const VAULT_PASSPHRASE_TTL_MS = 30 * 60 * 1000
|
|
|
2690
2691
|
*/
|
|
2691
2692
|
let VAULT_APPROVAL_AUTH_MODE: 'passphrase' | 'telegram-id' = 'passphrase'
|
|
2692
2693
|
|
|
2694
|
+
/**
|
|
2695
|
+
* Admin-only vault keys (`vault.broker.adminOnlyKeys`). A grant for one
|
|
2696
|
+
* of these may be approved ONLY by the admin operator
|
|
2697
|
+
* (`access.allowFrom[0]`) and is always minted via the operator
|
|
2698
|
+
* passphrase — never posture, even under telegram-id mode (the broker
|
|
2699
|
+
* enforces the same rule). Cached at boot alongside the posture mode.
|
|
2700
|
+
*/
|
|
2701
|
+
let ADMIN_ONLY_KEYS: string[] = []
|
|
2702
|
+
|
|
2693
2703
|
export function initVaultApprovalPosture(): void {
|
|
2694
2704
|
let cfg: ReturnType<typeof loadSwitchroomConfig>
|
|
2695
2705
|
try {
|
|
@@ -2722,6 +2732,13 @@ export function initVaultApprovalPosture(): void {
|
|
|
2722
2732
|
`(single-factor — broker mediates attestation via attest_via_posture)\n`,
|
|
2723
2733
|
)
|
|
2724
2734
|
}
|
|
2735
|
+
ADMIN_ONLY_KEYS = cfg.vault?.broker?.adminOnlyKeys ?? []
|
|
2736
|
+
if (ADMIN_ONLY_KEYS.length > 0) {
|
|
2737
|
+
process.stderr.write(
|
|
2738
|
+
`telegram gateway: ${ADMIN_ONLY_KEYS.length} admin-only vault key pattern(s) ` +
|
|
2739
|
+
`— grants approved by allowFrom[0] + operator passphrase only\n`,
|
|
2740
|
+
)
|
|
2741
|
+
}
|
|
2725
2742
|
}
|
|
2726
2743
|
/**
|
|
2727
2744
|
* Gateway-side guard on vault-key shape — UX gate, not a security
|
|
@@ -13851,11 +13868,31 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
|
|
|
13851
13868
|
}
|
|
13852
13869
|
|
|
13853
13870
|
if (action === 'approve') {
|
|
13871
|
+
// Admin-only credentials (`vault.broker.adminOnlyKeys`) are held to a
|
|
13872
|
+
// higher bar: ONLY the admin operator (allowFrom[0]) may approve, and
|
|
13873
|
+
// the grant must be minted with the operator passphrase — never
|
|
13874
|
+
// posture, even under telegram-id mode (the broker enforces the same
|
|
13875
|
+
// rule, so a posture mint would just be rejected). So for an
|
|
13876
|
+
// admin-only key we (a) reject taps from any non-admin allowFrom
|
|
13877
|
+
// member, and (b) skip the telegram-id posture branch below, falling
|
|
13878
|
+
// through to the passphrase-prompt path. The card + buttons stay
|
|
13879
|
+
// intact on a non-admin tap so the admin can still approve.
|
|
13880
|
+
const isAdminOnly = matchesAdminOnlyKey(pending.key, ADMIN_ONLY_KEYS)
|
|
13881
|
+
if (isAdminOnly && senderId !== access.allowFrom[0]) {
|
|
13882
|
+
await ctx
|
|
13883
|
+
.answerCallbackQuery({
|
|
13884
|
+
text: '🔒 Admin-only credential — only the owner can approve this.',
|
|
13885
|
+
})
|
|
13886
|
+
.catch(() => {})
|
|
13887
|
+
return
|
|
13888
|
+
}
|
|
13889
|
+
|
|
13854
13890
|
// Posture: telegram-id (opt-in single-factor). The broker is
|
|
13855
13891
|
// auto-unlocked and we silently hold the passphrase in memory; skip
|
|
13856
13892
|
// the passphrase-cache lookup + prompt entirely and mint directly.
|
|
13857
13893
|
// Allowlist check above already attested the operator's Telegram ID.
|
|
13858
|
-
|
|
13894
|
+
// Admin-only keys are excluded — they take the passphrase path below.
|
|
13895
|
+
if (!isAdminOnly && VAULT_APPROVAL_AUTH_MODE === 'telegram-id') {
|
|
13859
13896
|
const username = ctx.from?.username ?? ctx.from?.first_name ?? `id=${senderId}`
|
|
13860
13897
|
if (pending.card_message_id != null) {
|
|
13861
13898
|
await ctx.api
|
|
@@ -13920,6 +13957,9 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
|
|
|
13920
13957
|
pending.card_message_id,
|
|
13921
13958
|
joiningBatch
|
|
13922
13959
|
? `🔐 <b>Queued behind an earlier card.</b> Type your passphrase as your next message — it covers <b>${items.length}</b> pending approvals in this chat (one entry mints all grants, no re-type per card).`
|
|
13960
|
+
: isAdminOnly
|
|
13961
|
+
? `🔒 <b>Admin-only credential.</b> <code>${escapeHtmlForTg(pending.key)}</code> requires your vault passphrase to grant — reply with it as your next message and we'll mint the grant for <b>${escapeHtmlForTg(pending.agent)}</b>, then delete the passphrase message.\n\n` +
|
|
13962
|
+
`<i>The passphrase is what proves it's you: an agent can never mint this key on its own.</i>`
|
|
13923
13963
|
: `🔐 <b>Vault is locked.</b> Reply with your passphrase as your next message — we'll unlock, mint the grant for <b>${escapeHtmlForTg(pending.agent)}</b>, and delete the passphrase message in one step.\n\n` +
|
|
13924
13964
|
`<i>Mint authority stays operator-only: the broker only accepts the grant when the passphrase matches.</i>`,
|
|
13925
13965
|
{ parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
|
|
@@ -156,6 +156,53 @@ function extractResultSummary(toolResponse) {
|
|
|
156
156
|
return str.slice(0, 200) || null
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Extract the full text of a PostToolUse tool_response (untruncated).
|
|
161
|
+
* Mirrors extractResultSummary's shape handling but returns the whole
|
|
162
|
+
* string so callers can pattern-match on it.
|
|
163
|
+
*/
|
|
164
|
+
function toolResponseText(toolResponse) {
|
|
165
|
+
if (!toolResponse) return ''
|
|
166
|
+
if (Array.isArray(toolResponse.content)) {
|
|
167
|
+
return toolResponse.content
|
|
168
|
+
.filter((c) => c && typeof c === 'object' && c.type === 'text' && typeof c.text === 'string')
|
|
169
|
+
.map((c) => c.text)
|
|
170
|
+
.join('\n')
|
|
171
|
+
}
|
|
172
|
+
if (typeof toolResponse.result === 'string') return toolResponse.result
|
|
173
|
+
if (typeof toolResponse.output === 'string') return toolResponse.output
|
|
174
|
+
if (typeof toolResponse === 'string') return toolResponse
|
|
175
|
+
return ''
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Detect Claude Code's async-launch ACK in a PostToolUse tool_response.
|
|
180
|
+
*
|
|
181
|
+
* A `run_in_background` Agent/Task returns IMMEDIATELY with an
|
|
182
|
+
* acknowledgement ("Async agent launched successfully … The agent is working
|
|
183
|
+
* in the background …"), NOT the sub-agent's final result. This ACK is the
|
|
184
|
+
* authoritative, uniform signal that the dispatch was a background one — it is
|
|
185
|
+
* present even when Claude Code omits `run_in_background` from the tool_input
|
|
186
|
+
* the PREtool hook sees (observed on claude-code 2.1.159: a worker whose
|
|
187
|
+
* tool_input lacked the flag still returned this ACK and ran ~3 min past the
|
|
188
|
+
* parent turn, so the pretool recorded background=0 and the worker card never
|
|
189
|
+
* fired). We therefore trust this ACK over the pretool's input-derived flag.
|
|
190
|
+
*
|
|
191
|
+
* Anchored on the specific "async agent launched" phrase (a foreground
|
|
192
|
+
* sub-agent's final report is extremely unlikely to contain it), with a
|
|
193
|
+
* structural backstop ("working in the background" + an agentId token) in case
|
|
194
|
+
* the launch-verb wording drifts. A major wording change degrades to the
|
|
195
|
+
* pretool flag — still correct whenever the model DID pass run_in_background,
|
|
196
|
+
* never worse than before.
|
|
197
|
+
*/
|
|
198
|
+
function isAsyncLaunchAck(toolResponse) {
|
|
199
|
+
const t = toolResponseText(toolResponse).toLowerCase()
|
|
200
|
+
if (!t) return false
|
|
201
|
+
if (t.includes('async agent launched')) return true
|
|
202
|
+
if (t.includes('working in the background') && t.includes('agentid')) return true
|
|
203
|
+
return false
|
|
204
|
+
}
|
|
205
|
+
|
|
159
206
|
// ---------------------------------------------------------------------------
|
|
160
207
|
// DB write
|
|
161
208
|
// ---------------------------------------------------------------------------
|
|
@@ -172,9 +219,18 @@ function extractResultSummary(toolResponse) {
|
|
|
172
219
|
* recordSubagentEnd (driven by the JSONL turn_end event) remains the
|
|
173
220
|
* authoritative end-of-life signal.
|
|
174
221
|
*
|
|
222
|
+
* Mis-recorded background (DB background = 0 but `asyncLaunch` is true):
|
|
223
|
+
* Claude Code returned the async-launch ACK even though run_in_background was
|
|
224
|
+
* absent from the tool_input the pretool saw, so the row was wrongly recorded
|
|
225
|
+
* foreground. PROMOTE it to background = 1 and take the background path — do
|
|
226
|
+
* NOT terminalize, because the worker is still running (the ACK is a launch,
|
|
227
|
+
* not a completion). This is the authoritative correction that makes the
|
|
228
|
+
* gateway's worker-feed card fire (onProgress re-reads `background` per tick)
|
|
229
|
+
* AND prevents the premature `completed` the foreground path would write.
|
|
230
|
+
*
|
|
175
231
|
* The done(err | null) callback is invoked after all DB operations complete.
|
|
176
232
|
*/
|
|
177
|
-
function updateRow(dbPath, { id, status, resultSummary, now }, done) {
|
|
233
|
+
function updateRow(dbPath, { id, status, resultSummary, now, asyncLaunch }, done) {
|
|
178
234
|
// SQL to read the background flag so we can choose the right update path.
|
|
179
235
|
const SELECT_SQL = `SELECT background FROM subagents WHERE id = ?`
|
|
180
236
|
|
|
@@ -194,12 +250,22 @@ function updateRow(dbPath, { id, status, resultSummary, now }, done) {
|
|
|
194
250
|
AND status NOT IN ('completed', 'failed')
|
|
195
251
|
`
|
|
196
252
|
|
|
253
|
+
// Promote a mis-recorded foreground row to background (sets background = 1),
|
|
254
|
+
// bumping activity but NOT terminalizing — same shape as BACKGROUND_SQL.
|
|
255
|
+
const PROMOTE_BACKGROUND_SQL = `
|
|
256
|
+
UPDATE subagents
|
|
257
|
+
SET background = 1, result_summary = COALESCE(?, result_summary), last_activity_at = ?
|
|
258
|
+
WHERE id = ?
|
|
259
|
+
AND status NOT IN ('completed', 'failed')
|
|
260
|
+
`
|
|
261
|
+
|
|
197
262
|
// Snapshot all values used inside closures before setImmediate fires.
|
|
198
263
|
const snapDbPath = dbPath
|
|
199
264
|
const snapId = id
|
|
200
265
|
const snapStatus = status
|
|
201
266
|
const snapResultSummary = resultSummary
|
|
202
267
|
const snapNow = now
|
|
268
|
+
const snapAsyncLaunch = asyncLaunch === true
|
|
203
269
|
|
|
204
270
|
// Resolve a synchronous SQLite binding (node:sqlite under Node 22+,
|
|
205
271
|
// bun:sqlite under bun, else null → CLI fallback). See helper docs.
|
|
@@ -216,6 +282,8 @@ function updateRow(dbPath, { id, status, resultSummary, now }, done) {
|
|
|
216
282
|
const isBackground = row != null && row.background === 1
|
|
217
283
|
if (isBackground) {
|
|
218
284
|
db.prepare(BACKGROUND_SQL).run(snapResultSummary, snapNow, snapId)
|
|
285
|
+
} else if (snapAsyncLaunch) {
|
|
286
|
+
db.prepare(PROMOTE_BACKGROUND_SQL).run(snapResultSummary, snapNow, snapId)
|
|
219
287
|
} else {
|
|
220
288
|
db.prepare(FOREGROUND_SQL).run(snapNow, snapStatus, snapResultSummary, snapNow, snapId)
|
|
221
289
|
}
|
|
@@ -239,6 +307,12 @@ function updateRow(dbPath, { id, status, resultSummary, now }, done) {
|
|
|
239
307
|
fillPlaceholders(BACKGROUND_SQL.trim(), [snapResultSummary, snapNow, snapId]),
|
|
240
308
|
done,
|
|
241
309
|
)
|
|
310
|
+
} else if (snapAsyncLaunch) {
|
|
311
|
+
spawnSql(
|
|
312
|
+
snapDbPath,
|
|
313
|
+
fillPlaceholders(PROMOTE_BACKGROUND_SQL.trim(), [snapResultSummary, snapNow, snapId]),
|
|
314
|
+
done,
|
|
315
|
+
)
|
|
242
316
|
} else {
|
|
243
317
|
spawnSql(
|
|
244
318
|
snapDbPath,
|
|
@@ -345,16 +419,26 @@ function main() {
|
|
|
345
419
|
|
|
346
420
|
const toolResponse = event.tool_response ?? null
|
|
347
421
|
|
|
422
|
+
// Authoritative background signal: Claude Code's async-launch ACK. Trusted
|
|
423
|
+
// over the pretool's input-derived flag (which is missing whenever the
|
|
424
|
+
// model/runtime omits run_in_background from tool_input — see
|
|
425
|
+
// isAsyncLaunchAck). Gates both the nudge below and the promote path in
|
|
426
|
+
// updateRow.
|
|
427
|
+
const asyncLaunch = isAsyncLaunchAck(toolResponse)
|
|
428
|
+
|
|
348
429
|
// conversational-pacing beat 4 (foreground half). A foreground
|
|
349
430
|
// sub-agent's PostToolUse fires at real completion, mid-parent-turn,
|
|
350
431
|
// with its result in tool_response — nudge the parent to synthesise a
|
|
351
432
|
// user-facing handback. Background sub-agents are gated OUT: their
|
|
352
433
|
// PostToolUse fires on the launch ACK (BACKGROUND_SQL leaves status
|
|
353
434
|
// untouched for that reason), and their handback is driven by the
|
|
354
|
-
// gateway's subagent-watcher onFinish path instead.
|
|
355
|
-
//
|
|
435
|
+
// gateway's subagent-watcher onFinish path instead. A launch ACK is also
|
|
436
|
+
// gated out via `!asyncLaunch` — at this point the DB flag may still read 0
|
|
437
|
+
// (updateRow promotes it on the next tick), so the ACK is the reliable
|
|
438
|
+
// tell. Fail-silent: an unknown background flag (null) skips the nudge.
|
|
356
439
|
if (
|
|
357
440
|
process.env.SWITCHROOM_SUBAGENT_HANDBACK !== '0'
|
|
441
|
+
&& !asyncLaunch
|
|
358
442
|
&& detectStatus(toolResponse) === 'completed'
|
|
359
443
|
&& readBackgroundFlagSync(dbPath, id) === 0
|
|
360
444
|
) {
|
|
@@ -368,6 +452,7 @@ function main() {
|
|
|
368
452
|
status: detectStatus(toolResponse),
|
|
369
453
|
resultSummary: extractResultSummary(toolResponse),
|
|
370
454
|
now: Date.now(),
|
|
455
|
+
asyncLaunch,
|
|
371
456
|
},
|
|
372
457
|
(err) => {
|
|
373
458
|
if (err) {
|
|
@@ -262,7 +262,17 @@ function main() {
|
|
|
262
262
|
{
|
|
263
263
|
id: event.tool_use_id ?? null,
|
|
264
264
|
parentSessionId: event.session_id ?? null,
|
|
265
|
-
|
|
265
|
+
// parent_turn_key is intentionally NULL here. Claude Code's PreToolUse
|
|
266
|
+
// payload carries its own session id, not the gateway-minted Telegram
|
|
267
|
+
// turn_key (a chat+topic+turn key) the `turns` table is keyed on —
|
|
268
|
+
// `event.turn_id` is always undefined, and even if a future CLI
|
|
269
|
+
// populated it, it would not match a `turns.turn_key`. The gateway
|
|
270
|
+
// resolves parent_turn_key from the
|
|
271
|
+
// sub-agent's started_at at jsonl-link time (subagent-watcher.ts
|
|
272
|
+
// backfillJsonlAgentId), which works even after the parent turn ends.
|
|
273
|
+
// Writing a bogus value here would defeat that backfill's
|
|
274
|
+
// `parent_turn_key IS NULL` guard.
|
|
275
|
+
parentTurnKey: null,
|
|
266
276
|
agentType: input.subagent_type ?? null,
|
|
267
277
|
description: input.description ?? null,
|
|
268
278
|
background: input.run_in_background === true ? 1 : 0,
|
|
@@ -387,8 +387,16 @@ describe('Bug 4 — result_summary always NULL (hook integration)', () => {
|
|
|
387
387
|
|
|
388
388
|
// ─── Bug 5 — parent_turn_key always NULL ─────────────────────────────────────
|
|
389
389
|
|
|
390
|
-
describe('Bug 5 — parent_turn_key
|
|
391
|
-
it('pretool
|
|
390
|
+
describe('Bug 5 — parent_turn_key backfilled by gateway, not the hook', () => {
|
|
391
|
+
it('pretool writes parent_turn_key=NULL even when event.turn_id is present', () => {
|
|
392
|
+
// Claude Code's PreToolUse payload carries its own session id, never the
|
|
393
|
+
// gateway-minted Telegram turn_key (a chat+topic+turn key) the `turns`
|
|
394
|
+
// table is keyed on. `event.turn_id` — even if a future CLI populated it —
|
|
395
|
+
// would not match a `turns.turn_key`, so the hook intentionally writes
|
|
396
|
+
// NULL and lets the gateway backfill parent_turn_key from the sub-agent's
|
|
397
|
+
// started_at at jsonl-link time (subagent-watcher.ts backfillJsonlAgentId).
|
|
398
|
+
// Writing a bogus value here would defeat that backfill's
|
|
399
|
+
// `parent_turn_key IS NULL` guard.
|
|
392
400
|
const event = {
|
|
393
401
|
session_id: 'sess-turnkey',
|
|
394
402
|
turn_id: 'turn-abc-001',
|
|
@@ -406,8 +414,8 @@ describe('Bug 5 — parent_turn_key always NULL (hook integration)', () => {
|
|
|
406
414
|
| undefined
|
|
407
415
|
|
|
408
416
|
expect(row).toBeDefined()
|
|
409
|
-
//
|
|
410
|
-
expect(row!.parent_turn_key).
|
|
417
|
+
// The hook never trusts event.turn_id — gateway backfill owns this column.
|
|
418
|
+
expect(row!.parent_turn_key).toBeNull()
|
|
411
419
|
})
|
|
412
420
|
|
|
413
421
|
it('pretool stores parent_turn_key as NULL when turn_id absent (no regression)', () => {
|
|
@@ -508,7 +508,10 @@ interface FsLike {
|
|
|
508
508
|
* - Row already linked to a different agentId: SQL `WHERE jsonl_agent_id IS
|
|
509
509
|
* NULL` skips it. Re-runs are safe.
|
|
510
510
|
*/
|
|
511
|
-
|
|
511
|
+
// Exported for unit-testing the parent_turn_key backfill (telegram-plugin/
|
|
512
|
+
// tests/subagent-watcher-parent-turn-key.test.ts). Not intended for
|
|
513
|
+
// consumption by other modules.
|
|
514
|
+
export function backfillJsonlAgentId(
|
|
512
515
|
db: SubagentLivenessDb,
|
|
513
516
|
jsonlPath: string,
|
|
514
517
|
agentId: string,
|
|
@@ -555,6 +558,47 @@ function backfillJsonlAgentId(
|
|
|
555
558
|
.prepare('UPDATE subagents SET jsonl_agent_id = ? WHERE id = ?')
|
|
556
559
|
.run(agentId, candidate.id)
|
|
557
560
|
log?.(`subagent-watcher: backfill linked ${agentId} → ${candidate.id}`)
|
|
561
|
+
|
|
562
|
+
// Backfill parent_turn_key (gateway-side). The PreToolUse hook can't know
|
|
563
|
+
// the gateway-minted Telegram turn_key (a chat+topic+turn key) — it only
|
|
564
|
+
// sees Claude Code's session id — so the row was inserted with
|
|
565
|
+
// parent_turn_key=NULL. Resolve
|
|
566
|
+
// it now from the turn whose [started_at, ended_at] window contained the
|
|
567
|
+
// sub-agent's dispatch (its started_at). Keying on the historical
|
|
568
|
+
// started_at, NOT "the turn active now", is what makes this correct for a
|
|
569
|
+
// background worker that outlives its parent turn: the turn may have already
|
|
570
|
+
// ended by link time, but the containment match still finds it. Turns are
|
|
571
|
+
// processed serially per agent, so at most one window contains a given
|
|
572
|
+
// instant; the ORDER BY ... DESC LIMIT 1 is just a defensive tie-break.
|
|
573
|
+
//
|
|
574
|
+
// Without this, resolveSubagentOriginChat() returns null and the live
|
|
575
|
+
// worker card + handback fall back to the operator DM instead of the
|
|
576
|
+
// originating group/forum-topic, and resolveCallingSubagent()'s turn-scoped
|
|
577
|
+
// heuristic (WHERE parent_turn_key = ?) can never see the row. Best-effort:
|
|
578
|
+
// any failure leaves parent_turn_key NULL (today's behaviour) and never
|
|
579
|
+
// throws out of the watcher poll loop.
|
|
580
|
+
try {
|
|
581
|
+
const linkedRow = db
|
|
582
|
+
.prepare('SELECT started_at, parent_turn_key FROM subagents WHERE id = ?')
|
|
583
|
+
.get(candidate.id) as { started_at: number; parent_turn_key: string | null } | null
|
|
584
|
+
if (linkedRow != null && linkedRow.parent_turn_key == null) {
|
|
585
|
+
const turn = db
|
|
586
|
+
.prepare(
|
|
587
|
+
`SELECT turn_key FROM turns
|
|
588
|
+
WHERE started_at <= ? AND (ended_at IS NULL OR ended_at >= ?)
|
|
589
|
+
ORDER BY started_at DESC LIMIT 1`,
|
|
590
|
+
)
|
|
591
|
+
.get(linkedRow.started_at, linkedRow.started_at) as { turn_key: string } | null
|
|
592
|
+
if (turn?.turn_key != null) {
|
|
593
|
+
db
|
|
594
|
+
.prepare('UPDATE subagents SET parent_turn_key = ? WHERE id = ?')
|
|
595
|
+
.run(turn.turn_key, candidate.id)
|
|
596
|
+
log?.(`subagent-watcher: backfill parent_turn_key ${candidate.id} → ${turn.turn_key}`)
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
} catch (err) {
|
|
600
|
+
log?.(`subagent-watcher: parent_turn_key backfill skipped for ${candidate.id} — ${(err as Error).message}`)
|
|
601
|
+
}
|
|
558
602
|
}
|
|
559
603
|
|
|
560
604
|
// Exported for unit-testing the ENOENT/EACCES deregister path
|
|
@@ -271,6 +271,79 @@ describe('subagent-tracker-posttool', () => {
|
|
|
271
271
|
expect(postResult.status).toBe(0)
|
|
272
272
|
expect(postResult.stdout).not.toContain('additionalContext')
|
|
273
273
|
})
|
|
274
|
+
|
|
275
|
+
// The async-launch ACK is Claude Code's verbatim immediate return for a
|
|
276
|
+
// run_in_background Agent/Task dispatch. The posttool trusts it over the
|
|
277
|
+
// pretool's input-derived background flag, which is missing whenever the
|
|
278
|
+
// runtime omits run_in_background from the tool_input the pretool saw
|
|
279
|
+
// (observed on claude-code 2.1.159 — the clerk worker that never surfaced).
|
|
280
|
+
const ASYNC_LAUNCH_ACK =
|
|
281
|
+
'Async agent launched successfully.\n'
|
|
282
|
+
+ 'agentId: go-live-sync-a176dc93\n'
|
|
283
|
+
+ 'The agent is working in the background. You will be notified '
|
|
284
|
+
+ 'automatically when it completes.'
|
|
285
|
+
|
|
286
|
+
it('promotes a mis-recorded foreground row to background from the launch ACK', () => {
|
|
287
|
+
// Pretool sees NO run_in_background key (the production bug) → records
|
|
288
|
+
// background=0, status=running.
|
|
289
|
+
const preResult = runHook(PRETOOL_SCRIPT, {
|
|
290
|
+
session_id: 's-promote',
|
|
291
|
+
tool_name: 'Agent',
|
|
292
|
+
tool_use_id: 'toolu_promote1',
|
|
293
|
+
tool_input: { subagent_type: 'worker', description: 'Go-live sync' },
|
|
294
|
+
})
|
|
295
|
+
expect(preResult.status).toBe(0)
|
|
296
|
+
|
|
297
|
+
const db = openDb()
|
|
298
|
+
const before = db.prepare('SELECT background, status FROM subagents WHERE id = ?').get('toolu_promote1') as
|
|
299
|
+
| { background: number; status: string }
|
|
300
|
+
| undefined
|
|
301
|
+
expect(before?.background).toBe(0)
|
|
302
|
+
expect(before?.status).toBe('running')
|
|
303
|
+
|
|
304
|
+
// Posttool receives the async-launch ACK → promote to background, do NOT
|
|
305
|
+
// terminalize, and do NOT emit a foreground handback nudge.
|
|
306
|
+
const postResult = runHook(POSTTOOL_SCRIPT, {
|
|
307
|
+
tool_name: 'Agent',
|
|
308
|
+
tool_use_id: 'toolu_promote1',
|
|
309
|
+
tool_response: { content: [{ type: 'text', text: ASYNC_LAUNCH_ACK }] },
|
|
310
|
+
})
|
|
311
|
+
expect(postResult.status).toBe(0)
|
|
312
|
+
expect(postResult.stdout).not.toContain('additionalContext')
|
|
313
|
+
|
|
314
|
+
const after = db.prepare('SELECT background, status, ended_at FROM subagents WHERE id = ?').get('toolu_promote1') as
|
|
315
|
+
| { background: number; status: string; ended_at: number | null }
|
|
316
|
+
| undefined
|
|
317
|
+
expect(after?.background).toBe(1)
|
|
318
|
+
expect(after?.status).toBe('running')
|
|
319
|
+
expect(after?.ended_at == null).toBe(true)
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('still terminalizes a genuine foreground completion (no false promote)', () => {
|
|
323
|
+
// A real foreground sub-agent whose final report happens to mention
|
|
324
|
+
// "background" must NOT be mistaken for a launch ACK — the promote path
|
|
325
|
+
// only fires on the specific async-launch phrasing.
|
|
326
|
+
runHook(PRETOOL_SCRIPT, {
|
|
327
|
+
session_id: 's-noflip',
|
|
328
|
+
tool_name: 'Agent',
|
|
329
|
+
tool_use_id: 'toolu_noflip1',
|
|
330
|
+
tool_input: { subagent_type: 'worker', description: 'Real foreground task', run_in_background: false },
|
|
331
|
+
})
|
|
332
|
+
const postResult = runHook(POSTTOOL_SCRIPT, {
|
|
333
|
+
tool_name: 'Agent',
|
|
334
|
+
tool_use_id: 'toolu_noflip1',
|
|
335
|
+
tool_response: { result: 'Done. The feature now runs as a background job.', is_error: false },
|
|
336
|
+
})
|
|
337
|
+
expect(postResult.status).toBe(0)
|
|
338
|
+
expect(postResult.stdout).toContain('additionalContext')
|
|
339
|
+
|
|
340
|
+
const db = openDb()
|
|
341
|
+
const row = db.prepare('SELECT background, status FROM subagents WHERE id = ?').get('toolu_noflip1') as
|
|
342
|
+
| { background: number; status: string }
|
|
343
|
+
| undefined
|
|
344
|
+
expect(row?.background).toBe(0)
|
|
345
|
+
expect(row?.status).toBe('completed')
|
|
346
|
+
})
|
|
274
347
|
})
|
|
275
348
|
|
|
276
349
|
describe('agent-dir resolution (RFC §Bug 2)', () => {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the parent_turn_key backfill in subagent-watcher.ts
|
|
3
|
+
* (backfillJsonlAgentId).
|
|
4
|
+
*
|
|
5
|
+
* The PreToolUse hook records a sub-agent row with parent_turn_key=NULL — it
|
|
6
|
+
* only sees Claude Code's session id, never the Telegram turn_key
|
|
7
|
+
* (chat_id:msg_id) the gateway keys turns on. The gateway backfills
|
|
8
|
+
* parent_turn_key when it links the JSONL stem to the row, resolving it from
|
|
9
|
+
* the turn whose [started_at, ended_at] window contained the sub-agent's
|
|
10
|
+
* dispatch (its started_at). These tests pin that resolution — in particular
|
|
11
|
+
* that it stays correct for a background worker that outlives its parent turn.
|
|
12
|
+
*
|
|
13
|
+
* bun:sqlite — run under Bun:
|
|
14
|
+
* bun test telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
|
18
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
19
|
+
import { tmpdir } from 'os'
|
|
20
|
+
import { join } from 'path'
|
|
21
|
+
import { openTurnsDbInMemory } from '../registry/turns-schema.js'
|
|
22
|
+
import { applySubagentsSchema } from '../registry/subagents-schema.js'
|
|
23
|
+
import { backfillJsonlAgentId } from '../subagent-watcher.js'
|
|
24
|
+
|
|
25
|
+
type Db = ReturnType<typeof openTurnsDbInMemory>
|
|
26
|
+
|
|
27
|
+
let tempDir: string
|
|
28
|
+
let db: Db
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
tempDir = mkdtempSync(join(tmpdir(), 'sub-parent-turn-'))
|
|
32
|
+
db = openTurnsDbInMemory()
|
|
33
|
+
applySubagentsSchema(db)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
try { db.close() } catch { /* ignore */ }
|
|
38
|
+
try { rmSync(tempDir, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
function insertTurn(args: {
|
|
42
|
+
turnKey: string
|
|
43
|
+
chatId: string
|
|
44
|
+
threadId?: string | null
|
|
45
|
+
startedAt: number
|
|
46
|
+
endedAt?: number | null
|
|
47
|
+
}) {
|
|
48
|
+
db.prepare(`
|
|
49
|
+
INSERT INTO turns (turn_key, chat_id, thread_id, started_at, ended_at, created_at, updated_at)
|
|
50
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
51
|
+
`).run(
|
|
52
|
+
args.turnKey,
|
|
53
|
+
args.chatId,
|
|
54
|
+
args.threadId ?? null,
|
|
55
|
+
args.startedAt,
|
|
56
|
+
args.endedAt ?? null,
|
|
57
|
+
args.startedAt,
|
|
58
|
+
args.startedAt,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function insertSub(args: {
|
|
63
|
+
id: string
|
|
64
|
+
agentType: string
|
|
65
|
+
description: string
|
|
66
|
+
startedAt: number
|
|
67
|
+
parentTurnKey?: string | null
|
|
68
|
+
}) {
|
|
69
|
+
db.prepare(`
|
|
70
|
+
INSERT INTO subagents
|
|
71
|
+
(id, parent_session_id, parent_turn_key, agent_type, description,
|
|
72
|
+
background, started_at, last_activity_at, status, jsonl_agent_id)
|
|
73
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running', NULL)
|
|
74
|
+
`).run(
|
|
75
|
+
args.id,
|
|
76
|
+
'sess-1',
|
|
77
|
+
args.parentTurnKey ?? null,
|
|
78
|
+
args.agentType,
|
|
79
|
+
args.description,
|
|
80
|
+
1,
|
|
81
|
+
args.startedAt,
|
|
82
|
+
args.startedAt,
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Write the meta.json the backfill reads to match a row by (agentType, description). */
|
|
87
|
+
function writeMeta(agentType: string, description: string): string {
|
|
88
|
+
const jsonlPath = join(tempDir, 'worker.jsonl')
|
|
89
|
+
writeFileSync(join(tempDir, 'worker.meta.json'), JSON.stringify({ agentType, description }))
|
|
90
|
+
return jsonlPath
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function readSub(id: string) {
|
|
94
|
+
return db.prepare('SELECT jsonl_agent_id, parent_turn_key FROM subagents WHERE id = ?').get(id) as
|
|
95
|
+
| { jsonl_agent_id: string | null; parent_turn_key: string | null }
|
|
96
|
+
| undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe('backfillJsonlAgentId — parent_turn_key resolution', () => {
|
|
100
|
+
it('resolves parent_turn_key from the turn whose window contains the sub-agent started_at', () => {
|
|
101
|
+
insertTurn({ turnKey: '555:10', chatId: '555', threadId: '42', startedAt: 1000, endedAt: 2000 })
|
|
102
|
+
insertSub({ id: 'toolu_a', agentType: 'worker', description: 'Go-live sync', startedAt: 1500 })
|
|
103
|
+
|
|
104
|
+
const jsonlPath = writeMeta('worker', 'Go-live sync')
|
|
105
|
+
backfillJsonlAgentId(db, jsonlPath, 'agentstem_a')
|
|
106
|
+
|
|
107
|
+
const row = readSub('toolu_a')
|
|
108
|
+
expect(row?.jsonl_agent_id).toBe('agentstem_a')
|
|
109
|
+
expect(row?.parent_turn_key).toBe('555:10')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('resolves to the parent turn even after it has ended (background worker outlives the turn)', () => {
|
|
113
|
+
// Parent turn already ended at 1600; a later turn is active "now".
|
|
114
|
+
insertTurn({ turnKey: '555:10', chatId: '555', startedAt: 1000, endedAt: 1600 })
|
|
115
|
+
insertTurn({ turnKey: '555:20', chatId: '555', startedAt: 1700, endedAt: null })
|
|
116
|
+
// The worker was dispatched at 1500 — inside the FIRST (now-ended) turn.
|
|
117
|
+
insertSub({ id: 'toolu_b', agentType: 'worker', description: 'Long task', startedAt: 1500 })
|
|
118
|
+
|
|
119
|
+
const jsonlPath = writeMeta('worker', 'Long task')
|
|
120
|
+
backfillJsonlAgentId(db, jsonlPath, 'agentstem_b')
|
|
121
|
+
|
|
122
|
+
// Must pick the containing (ended) turn, NOT the turn active at link time.
|
|
123
|
+
expect(readSub('toolu_b')?.parent_turn_key).toBe('555:10')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('does NOT overwrite an already-populated parent_turn_key', () => {
|
|
127
|
+
insertTurn({ turnKey: '555:10', chatId: '555', startedAt: 1000, endedAt: 2000 })
|
|
128
|
+
insertSub({
|
|
129
|
+
id: 'toolu_c',
|
|
130
|
+
agentType: 'worker',
|
|
131
|
+
description: 'Preset',
|
|
132
|
+
startedAt: 1500,
|
|
133
|
+
parentTurnKey: '999:9',
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const jsonlPath = writeMeta('worker', 'Preset')
|
|
137
|
+
backfillJsonlAgentId(db, jsonlPath, 'agentstem_c')
|
|
138
|
+
|
|
139
|
+
expect(readSub('toolu_c')?.parent_turn_key).toBe('999:9')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('leaves parent_turn_key NULL when no turn window contains the dispatch', () => {
|
|
143
|
+
insertTurn({ turnKey: '555:10', chatId: '555', startedAt: 1000, endedAt: 2000 })
|
|
144
|
+
// Dispatched at 50 — before any turn started.
|
|
145
|
+
insertSub({ id: 'toolu_d', agentType: 'worker', description: 'Orphan', startedAt: 50 })
|
|
146
|
+
|
|
147
|
+
const jsonlPath = writeMeta('worker', 'Orphan')
|
|
148
|
+
backfillJsonlAgentId(db, jsonlPath, 'agentstem_d')
|
|
149
|
+
|
|
150
|
+
const row = readSub('toolu_d')
|
|
151
|
+
// Still linked, but no turn to attribute it to.
|
|
152
|
+
expect(row?.jsonl_agent_id).toBe('agentstem_d')
|
|
153
|
+
expect(row?.parent_turn_key == null).toBe(true)
|
|
154
|
+
})
|
|
155
|
+
})
|