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.
@@ -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({
@@ -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.35";
49443
- var COMMIT_SHA = "7ac06aea";
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.35",
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.35";
51785
- var COMMIT_SHA = "7ac06aea";
51786
- var COMMIT_DATE = "2026-06-01T21:48:46Z";
51787
- var LATEST_PR = 2072;
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
- if (VAULT_APPROVAL_AUTH_MODE === "telegram-id") {
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\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.
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
- if (VAULT_APPROVAL_AUTH_MODE === 'telegram-id') {
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. Fail-silent: an
355
- // unknown background flag (null) skips the nudge.
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
- parentTurnKey: event.turn_id ?? null,
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 always NULL (hook integration)', () => {
391
- it('pretool stores parent_turn_key from event.turn_id', () => {
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
- // After fix: parent_turn_key should be populated from event.turn_id
410
- expect(row!.parent_turn_key).toBe('turn-abc-001')
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
- function backfillJsonlAgentId(
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
+ })