switchroom 0.19.45 → 0.19.46

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.
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.19.45", COMMIT_SHA = "2ad33d5d";
2123
+ var VERSION = "0.19.46", COMMIT_SHA = "306c3ed1";
2124
2124
 
2125
2125
  // src/cli/resolve-version.ts
2126
2126
  import { existsSync, readFileSync } from "node:fs";
@@ -37439,11 +37439,14 @@ __export(exports_drive, {
37439
37439
  selectGoogleWorkspaceScopes: () => selectGoogleWorkspaceScopes,
37440
37440
  selectDriveAccountScopes: () => selectDriveAccountScopes,
37441
37441
  runDriveOAuthFlow: () => runDriveOAuthFlow,
37442
+ resolveReconsentCapabilities: () => resolveReconsentCapabilities,
37442
37443
  registerDriveCommand: () => registerDriveCommand,
37444
+ capabilitiesFromScopeString: () => capabilitiesFromScopeString,
37443
37445
  __test: () => __test,
37444
37446
  GOOGLE_SLIDES_SCOPE: () => GOOGLE_SLIDES_SCOPE,
37445
37447
  GOOGLE_SHEETS_SCOPE: () => GOOGLE_SHEETS_SCOPE,
37446
37448
  GOOGLE_DOCS_SCOPE: () => GOOGLE_DOCS_SCOPE,
37449
+ GOOGLE_CALENDAR_READONLY_SCOPE: () => GOOGLE_CALENDAR_READONLY_SCOPE,
37447
37450
  DRIVE_WRITE_SCOPES: () => DRIVE_WRITE_SCOPES,
37448
37451
  DRIVE_READONLY_SCOPES: () => DRIVE_READONLY_SCOPES
37449
37452
  });
@@ -37461,7 +37464,29 @@ function selectDriveAccountScopes(write) {
37461
37464
  function selectGoogleWorkspaceScopes(opts) {
37462
37465
  const base = selectDriveAccountScopes(opts.write);
37463
37466
  const workspace = workspaceScopesForTier(opts.tier ?? "core");
37464
- return [...new Set([...base, ...workspace])];
37467
+ const calendar = opts.calendar ? [GOOGLE_CALENDAR_READONLY_SCOPE] : [];
37468
+ return [...new Set([...base, ...workspace, ...calendar])];
37469
+ }
37470
+ function capabilitiesFromScopeString(scope) {
37471
+ const have = new Set(scope.split(/\s+/).map((s) => s.trim()).filter((s) => s.length > 0));
37472
+ return {
37473
+ write: have.has("https://www.googleapis.com/auth/drive.file"),
37474
+ calendar: have.has(GOOGLE_CALENDAR_READONLY_SCOPE)
37475
+ };
37476
+ }
37477
+ function resolveReconsentCapabilities(requested, existing) {
37478
+ const keys = ["write", "calendar"];
37479
+ if (!existing) {
37480
+ return { effective: { ...requested }, carried: [], added: [] };
37481
+ }
37482
+ return {
37483
+ effective: {
37484
+ write: requested.write || existing.write,
37485
+ calendar: requested.calendar || existing.calendar
37486
+ },
37487
+ carried: keys.filter((k) => existing[k] && !requested[k]),
37488
+ added: keys.filter((k) => requested[k] && !existing[k])
37489
+ };
37465
37490
  }
37466
37491
  function getVaultPath(configPath) {
37467
37492
  try {
@@ -37875,7 +37900,7 @@ function registerDriveCommand(program3, deps = {}) {
37875
37900
  await runDisconnect({ agentName: agent }, deps);
37876
37901
  });
37877
37902
  }
37878
- var EXIT_OK = 0, EXIT_DENIED = 1, EXIT_TIMEOUT = 2, EXIT_RATE_LIMITED = 3, EXIT_ERROR = 4, EXIT_ABORTED = 130, DRIVE_READONLY_SCOPES, DRIVE_WRITE_SCOPES, DEFAULT_SCOPES, GOOGLE_DOCS_SCOPE = "https://www.googleapis.com/auth/documents", GOOGLE_SHEETS_SCOPE = "https://www.googleapis.com/auth/spreadsheets", GOOGLE_SLIDES_SCOPE = "https://www.googleapis.com/auth/presentations", __test;
37903
+ var EXIT_OK = 0, EXIT_DENIED = 1, EXIT_TIMEOUT = 2, EXIT_RATE_LIMITED = 3, EXIT_ERROR = 4, EXIT_ABORTED = 130, DRIVE_READONLY_SCOPES, DRIVE_WRITE_SCOPES, DEFAULT_SCOPES, GOOGLE_DOCS_SCOPE = "https://www.googleapis.com/auth/documents", GOOGLE_SHEETS_SCOPE = "https://www.googleapis.com/auth/spreadsheets", GOOGLE_SLIDES_SCOPE = "https://www.googleapis.com/auth/presentations", GOOGLE_CALENDAR_READONLY_SCOPE = "https://www.googleapis.com/auth/calendar.readonly", __test;
37879
37904
  var init_drive = __esm(() => {
37880
37905
  init_source();
37881
37906
  init_loader();
@@ -77304,10 +77329,15 @@ function pad(s, width) {
77304
77329
  return s.length >= width ? s : s + " ".repeat(width - s.length);
77305
77330
  }
77306
77331
  function registerAccountAdd(accountParent) {
77307
- accountParent.command("add <account>").description("Mint a Google OAuth refresh token for <account> and register it with the auth-broker. For Drive scopes the effective flow is desktop-loopback (device-code returns invalid_scope for Drive; OOB is retired) \u2014 use a Desktop OAuth client; on a headless host complete the browser step over an SSH port-forward. Add --write for create/edit (drive.file); default is read-only.").option("--replace", "Overwrite existing credentials for <account> (default refuses if account already registered)", false).option("--write", "Request Drive WRITE scope (drive.file: create + edit app-created files) in addition to read. Default is read-only \u2014 a read grant never silently becomes a write grant. Re-consent an existing account with `--replace --write`.", false).action(withConfigError(async (account, opts) => {
77332
+ accountParent.command("add <account>").description("Mint a Google OAuth refresh token for <account> and register it with the auth-broker. For Drive scopes the effective flow is desktop-loopback (device-code returns invalid_scope for Drive; OOB is retired) \u2014 use a Desktop OAuth client; on a headless host complete the browser step over an SSH port-forward. Add --write for create/edit (drive.file) and --calendar for read-only Calendar; default is Drive read-only.").option("--replace", "Overwrite existing credentials for <account> (default refuses if account already registered)", false).option("--write", "Request Drive WRITE scope (drive.file: create + edit app-created files) in addition to read. Default is read-only \u2014 a read grant never silently becomes a write grant. Re-consent an existing account with `--replace --write`.", false).option("--calendar", "Request READ-ONLY Calendar scope (calendar.readonly) so the gdrive MCP's list_calendars / get_events tools authenticate. Opt-in \u2014 no tier grants it by default, and switchroom never requests a Calendar WRITE scope. Re-consent an existing account with `--replace --calendar`.", false).action(withConfigError(async (account, opts) => {
77308
77333
  const normalizedAccount = validateAndNormalizeAccountEmail(account);
77309
77334
  const [
77310
- { runDriveOAuthFlow: runDriveOAuthFlow2, selectGoogleWorkspaceScopes: selectGoogleWorkspaceScopes2 },
77335
+ {
77336
+ runDriveOAuthFlow: runDriveOAuthFlow2,
77337
+ selectGoogleWorkspaceScopes: selectGoogleWorkspaceScopes2,
77338
+ capabilitiesFromScopeString: capabilitiesFromScopeString2,
77339
+ resolveReconsentCapabilities: resolveReconsentCapabilities2
77340
+ },
77311
77341
  { selectInitialTier: selectInitialTier2 },
77312
77342
  { brokerCall: brokerCall2 },
77313
77343
  { loadConfig: loadConfig2, resolvePath: resolvePath2 },
@@ -77377,8 +77407,14 @@ function registerAccountAdd(accountParent) {
77377
77407
  clientSecretRaw = await resolveRef(clientSecretRaw, "google_client_secret");
77378
77408
  }
77379
77409
  const tier = gw.tier ?? "core";
77410
+ const existingEntry = (await brokerCall2(async (client) => client.listGoogleAccounts())).accounts.find((a) => a.account === normalizedAccount);
77411
+ const plan = resolveReconsentCapabilities2({ write: opts.write ?? false, calendar: opts.calendar ?? false }, existingEntry ? capabilitiesFromScopeString2(existingEntry.scope) : undefined);
77412
+ if (existingEntry && !opts.replace && plan.added.length > 0) {
77413
+ throw new Error(buildScopeReconsentRequiredError(normalizedAccount, plan.added));
77414
+ }
77380
77415
  const accountScopes = selectGoogleWorkspaceScopes2({
77381
- write: opts.write ?? false,
77416
+ write: plan.effective.write,
77417
+ calendar: plan.effective.calendar,
77382
77418
  tier
77383
77419
  });
77384
77420
  const oauthCfg = {
@@ -77386,9 +77422,15 @@ function registerAccountAdd(accountParent) {
77386
77422
  client_secret: clientSecretRaw,
77387
77423
  scopes: accountScopes
77388
77424
  };
77389
- if (opts.write) {
77425
+ if (plan.effective.write) {
77390
77426
  console.log(source_default.yellow(" Requesting Drive WRITE scope (drive.file \u2014 create/edit app-created files)."));
77391
77427
  }
77428
+ if (plan.effective.calendar) {
77429
+ console.log(source_default.yellow(" Requesting Calendar READ-ONLY scope (calendar.readonly \u2014 list calendars, read events)."));
77430
+ }
77431
+ for (const line of buildCarryForwardNotices(plan.carried)) {
77432
+ console.log(source_default.gray(line));
77433
+ }
77392
77434
  console.log(source_default.gray(` Workspace tier: ${tier} \u2014 requesting Docs + Sheets` + (tier === "extended" || tier === "complete" ? " + Slides" : "") + " API scopes so the tier's tools can authenticate."));
77393
77435
  console.log(source_default.gray(` Changing the tier later requires re-running this command
77394
77436
  ` + " (`--replace`) \u2014 OAuth scopes are fixed at consent time."));
@@ -77425,6 +77467,40 @@ function registerAccountAdd(accountParent) {
77425
77467
  console.log();
77426
77468
  }));
77427
77469
  }
77470
+ var CAPABILITY_LABELS = {
77471
+ write: {
77472
+ flag: "--write",
77473
+ scope: "drive.file",
77474
+ summary: "Drive write (create + edit app-created files)"
77475
+ },
77476
+ calendar: {
77477
+ flag: "--calendar",
77478
+ scope: "calendar.readonly",
77479
+ summary: "Calendar read-only (list calendars, read events)"
77480
+ }
77481
+ };
77482
+ function buildScopeReconsentRequiredError(account, added) {
77483
+ const flags = added.map((k) => CAPABILITY_LABELS[k].flag);
77484
+ const scopes = added.map((k) => CAPABILITY_LABELS[k].scope).join(", ");
77485
+ return [
77486
+ `'${account}' is already registered with the auth-broker, and its stored ` + `token does not carry ${scopes}.`,
77487
+ "",
77488
+ "OAuth scopes are fixed at consent time \u2014 a stored token cannot be",
77489
+ "widened in place. Re-consent to mint a new token that includes it:",
77490
+ "",
77491
+ ` switchroom auth google account add ${account} --replace ${flags.join(" ")}`,
77492
+ "",
77493
+ "Scopes the account already holds are carried forward automatically,",
77494
+ "so re-consenting will not drop existing capabilities."
77495
+ ].join(`
77496
+ `);
77497
+ }
77498
+ function buildCarryForwardNotices(carried) {
77499
+ return carried.map((k) => {
77500
+ const { flag, scope, summary } = CAPABILITY_LABELS[k];
77501
+ return ` Carrying forward ${summary} \u2014 the existing token holds ${scope}, ` + `so it is re-requested even though ${flag} was not passed ` + `(re-consent would otherwise silently revoke it).`;
77502
+ });
77503
+ }
77428
77504
  function oauthClientSetupGuidance(reason) {
77429
77505
  return [
77430
77506
  reason,
@@ -100174,12 +100250,25 @@ function findMissingWorkspaceScopes(seedScope, tier) {
100174
100250
  return requiredWorkspaceScopesForTier(tier).filter((s) => !have.has(s));
100175
100251
  }
100176
100252
  var DRIVE_FILE_SCOPE = "https://www.googleapis.com/auth/drive.file";
100177
- function buildMissingScopeWarning(missing, tier, accountEmail, hasWriteScope) {
100253
+ var CALENDAR_READONLY_SCOPE = "https://www.googleapis.com/auth/calendar.readonly";
100254
+ function seedOptInCapabilities(seedScope) {
100255
+ const have = new Set(seedScope.split(/\s+/).map((s) => s.trim()).filter((s) => s.length > 0));
100256
+ return {
100257
+ write: have.has(DRIVE_FILE_SCOPE),
100258
+ calendar: have.has(CALENDAR_READONLY_SCOPE)
100259
+ };
100260
+ }
100261
+ function buildMissingScopeWarning(missing, tier, accountEmail, granted) {
100178
100262
  const short = missing.map((s) => s.replace(/^https:\/\/www\.googleapis\.com\/auth\//, "")).join(", ");
100263
+ const suffix = (granted.write ? " --write" : "") + (granted.calendar ? " --calendar" : "");
100264
+ const preserved = [
100265
+ granted.write ? "--write preserves the existing Drive write capability" : "",
100266
+ granted.calendar ? "--calendar preserves the existing Calendar read capability" : ""
100267
+ ].filter((s) => s.length > 0);
100179
100268
  return `drive-mcp-launcher: WARNING \u2014 the Google account '${accountEmail}' was ` + `consented WITHOUT the scope(s) needed for tier '${tier ?? "core"}': ` + `${short}.
100180
100269
  ` + ` The matching MCP tools (Docs / Sheets / Slides create+edit) will FAIL ` + `to authenticate. OAuth scopes are fixed at consent time \u2014 re-run on the ` + `host to re-mint the token with the correct scopes:
100181
- ` + ` switchroom auth google account add ${accountEmail} --replace` + `${hasWriteScope ? " --write" : ""}
100182
- ` + ` (scopes are derived from \`google_workspace.tier\` \u2014 set the tier ` + `before re-running${hasWriteScope ? "; --write preserves the existing " + "Drive write capability" : ""}). Drive read/file tools are unaffected.
100270
+ ` + ` switchroom auth google account add ${accountEmail} --replace` + `${suffix}
100271
+ ` + ` (scopes are derived from \`google_workspace.tier\` \u2014 set the tier ` + `before re-running${preserved.length > 0 ? "; " + preserved.join("; ") : ""}` + `). Drive read/file tools are unaffected.
100183
100272
  `;
100184
100273
  }
100185
100274
  function buildUvxArgs(tier) {
@@ -100427,8 +100516,7 @@ async function runDriveMcpLauncher(opts) {
100427
100516
  const tier = opts.tier ?? configSecrets.tier;
100428
100517
  const missingScopes = findMissingWorkspaceScopes(brokerCreds.scope, tier);
100429
100518
  if (missingScopes.length > 0) {
100430
- const hasWriteScope = brokerCreds.scope.split(/\s+/).map((s) => s.trim()).includes(DRIVE_FILE_SCOPE);
100431
- process.stderr.write(buildMissingScopeWarning(missingScopes, tier, brokerCreds.accountEmail, hasWriteScope));
100519
+ process.stderr.write(buildMissingScopeWarning(missingScopes, tier, brokerCreds.accountEmail, seedOptInCapabilities(brokerCreds.scope)));
100432
100520
  }
100433
100521
  const args = buildUvxArgs(tier);
100434
100522
  const env2 = buildChildEnv(process.env, credentialsDir, brokerCreds.accountEmail);
@@ -21402,7 +21402,7 @@ function allocateAgentUid(name) {
21402
21402
  }
21403
21403
 
21404
21404
  // src/build-info.ts
21405
- var VERSION = "0.19.45";
21405
+ var VERSION = "0.19.46";
21406
21406
 
21407
21407
  // src/setup/hindsight-recall-tunables.ts
21408
21408
  var RECALL_DEADLINE_HEADROOM_SECONDS = 2;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.19.45",
4
+ "version": "0.19.46",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -805,7 +805,26 @@ export SWITCHROOM_AGENT_START_CWD="{{agentDir}}"
805
805
  # already gets this via .mcp.json env, but the Claude Code process
806
806
  # doesn't inherit that — so without this export, `switchroom auth status`
807
807
  # from Bash inside the agent fails with "No switchroom.yaml found".
808
- export SWITCHROOM_CONFIG={{{switchroomConfigPathQ}}}
808
+ #
809
+ # DEFER to a pre-set value: in Docker, compose.ts already sets
810
+ # SWITCHROOM_CONFIG to the in-container mount (/state/config/
811
+ # switchroom.yaml). The value baked here is the HOST path — resolve()d on
812
+ # the machine that ran the scaffold — which does not exist inside the
813
+ # container. Unconditionally exporting it clobbered the correct compose
814
+ # value and silently killed the live model/effort resolvers below (their
815
+ # [ -f "$SWITCHROOM_CONFIG" ] guard failed and fell into the quiet
816
+ # no-config-mount branch), so `model:` edits + bare restart did nothing.
817
+ # The bake stays as the fallback for host-run (non-Docker) agents, where
818
+ # nothing else sets the var. ${VAR:-} also treats a set-but-EMPTY var as
819
+ # unset, so an empty compose env can never blank the fallback.
820
+ #
821
+ # NB the baked path is pre-single-quoted by the scaffold, so it is
822
+ # assigned BARE to a temp var first — embedding it directly inside
823
+ # "${SWITCHROOM_CONFIG:-...}" would keep the literal quote characters in
824
+ # the value.
825
+ _SR_CONFIG_BAKED={{{switchroomConfigPathQ}}}
826
+ export SWITCHROOM_CONFIG="${SWITCHROOM_CONFIG:-$_SR_CONFIG_BAKED}"
827
+ unset _SR_CONFIG_BAKED
809
828
  {{/if}}
810
829
  {{#if hindsightEnabled}}
811
830
  # Hindsight memory plugin (vendored at .claude/plugins/hindsight-memory/).
@@ -1722,9 +1741,11 @@ fi
1722
1741
  # launcher recorded on every prior boot (read BEFORE it is overwritten);
1723
1742
  # 3. the apply-time bake.
1724
1743
  # Steps 2/3 append to `.session-model-alert` so the gateway tells the
1725
- # operator once at boot. No SWITCHROOM_CONFIG mount at all means there is
1726
- # nothing to read live — the bake IS the truth there (stderr note only; an
1727
- # operator alert every boot forever would be noise with no action).
1744
+ # operator once at boot. No SWITCHROOM_CONFIG at all means there is
1745
+ # nothing to read live — the bake IS the truth there and the fallback is
1746
+ # silent. But SWITCHROOM_CONFIG set with the FILE MISSING is a
1747
+ # misconfiguration that kills live resolution outright, so that case is
1748
+ # loud (stderr + operator alert) — see the else branch below.
1728
1749
  #
1729
1750
  # The yaml file bind mount re-resolves by PATH at container start, so a
1730
1751
  # host-side atomic-rename write is always picked up by the NEXT boot — the
@@ -1785,6 +1806,23 @@ if [ -n "$SWITCHROOM_CONFIG" ] && [ -f "$SWITCHROOM_CONFIG" ] && command -v swit
1785
1806
  unset _lm_live _lm_try _lm_baked_class _lm_live_class
1786
1807
  else
1787
1808
  _EFFECTIVE_MODEL="$_BAKED_MODEL"
1809
+ # No live read possible. Distinguish the legitimately quiet case — no
1810
+ # SWITCHROOM_CONFIG at all (host-run agent scaffolded without a config
1811
+ # path; the bake IS the truth) — from a broken resolver hiding behind
1812
+ # it. SWITCHROOM_CONFIG set but pointing at a file that does not exist
1813
+ # is a misconfiguration (e.g. a host path leaking into a container env)
1814
+ # that silently disables live `model:`/`thinking_effort:` resolution:
1815
+ # exactly the defect that let the start.sh host-path clobber of the
1816
+ # compose-set env go unnoticed. That case is LOUD — stderr + a one-boot
1817
+ # operator alert. A set config whose file exists but with no
1818
+ # `switchroom` CLI on PATH is equally dead, stderr-only (nothing the
1819
+ # operator can act on from chat).
1820
+ if [ -n "$SWITCHROOM_CONFIG" ] && [ ! -f "$SWITCHROOM_CONFIG" ]; then
1821
+ echo "session-model: SWITCHROOM_CONFIG='$SWITCHROOM_CONFIG' is set but the file does not exist — live config resolution is DISABLED; using apply-time baked model '$_BAKED_MODEL'" >&2
1822
+ printf '`SWITCHROOM_CONFIG` points at `%s`, which does not exist in this environment, so live `model:`/`thinking_effort:` resolution is disabled and the agent booted on the apply-time default `%s`. Edits to switchroom.yaml will NOT take effect on a bare restart until this is fixed.\n' "$SWITCHROOM_CONFIG" "$_BAKED_MODEL" >> "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1823
+ elif [ -n "$SWITCHROOM_CONFIG" ]; then
1824
+ echo "session-model: SWITCHROOM_CONFIG is set but no 'switchroom' CLI is on PATH — live config resolution is DISABLED; using apply-time baked model '$_BAKED_MODEL'" >&2
1825
+ fi
1788
1826
  fi
1789
1827
  unset _BAKED_MODEL _LKG_MODEL
1790
1828
  # Record the RESOLVED configured default (raw, unquoted) every boot, before
@@ -2078,6 +2116,12 @@ if [ -n "$SWITCHROOM_CONFIG" ] && [ -f "$SWITCHROOM_CONFIG" ] && command -v swit
2078
2116
  unset _le_live _le_try
2079
2117
  else
2080
2118
  _EFFECTIVE_EFFORT="$_BAKED_EFFORT"
2119
+ # Misconfig visibility, parity with the model sibling above — which
2120
+ # owns the operator alert for this shared root cause; a second alert
2121
+ # here would double-post for the same broken env. stderr only.
2122
+ if [ -n "$SWITCHROOM_CONFIG" ] && [ ! -f "$SWITCHROOM_CONFIG" ]; then
2123
+ echo "session-effort: SWITCHROOM_CONFIG='$SWITCHROOM_CONFIG' is set but the file does not exist — live config resolution is DISABLED; using apply-time baked thinking_effort '${_BAKED_EFFORT:-<unset>}'" >&2
2124
+ fi
2081
2125
  fi
2082
2126
  unset _BAKED_EFFORT _LKG_EFFORT
2083
2127
  # Record the LIVE-RESOLVED configured default, BEFORE the carrier below can
@@ -72781,7 +72781,7 @@ function parseProviderVerb(provider, rest) {
72781
72781
  return { kind: "provider-cancel", provider };
72782
72782
  }
72783
72783
  if (sub !== "add") {
72784
- const usage = provider === "google" ? "Usage: /auth google add <email> [--replace] [--write]" : "Usage: /auth microsoft add <email> [--replace] [--org-mode]";
72784
+ const usage = provider === "google" ? "Usage: /auth google add <email> [--replace] [--write] [--calendar]" : "Usage: /auth microsoft add <email> [--replace] [--org-mode]";
72785
72785
  return {
72786
72786
  kind: "help",
72787
72787
  reason: `Unknown \`${provider}\` subcommand: \`${codeSpanSafe(sub || "(none)")}\`. ${usage}`
@@ -72792,13 +72792,13 @@ function parseProviderVerb(provider, rest) {
72792
72792
  const positional = args.filter((a) => !a.startsWith("--"));
72793
72793
  const email = positional[0];
72794
72794
  if (!email) {
72795
- const usage = provider === "google" ? "Usage: /auth google add <email> [--replace] [--write]" : "Usage: /auth microsoft add <email> [--replace] [--org-mode]";
72795
+ const usage = provider === "google" ? "Usage: /auth google add <email> [--replace] [--write] [--calendar]" : "Usage: /auth microsoft add <email> [--replace] [--org-mode]";
72796
72796
  return { kind: "help", reason: usage };
72797
72797
  }
72798
72798
  const emailErr = validateAuthAddLabel(email);
72799
72799
  if (emailErr)
72800
72800
  return { kind: "help", reason: emailErr };
72801
- const allowed = provider === "google" ? new Set(["--replace", "--write"]) : new Set(["--replace", "--org-mode"]);
72801
+ const allowed = provider === "google" ? new Set(["--replace", "--write", "--calendar"]) : new Set(["--replace", "--org-mode"]);
72802
72802
  for (const f of flags) {
72803
72803
  if (!allowed.has(f)) {
72804
72804
  return {
@@ -72813,6 +72813,7 @@ function parseProviderVerb(provider, rest) {
72813
72813
  email,
72814
72814
  replace: flags.has("--replace"),
72815
72815
  write: provider === "google" && flags.has("--write"),
72816
+ calendar: provider === "google" && flags.has("--calendar"),
72816
72817
  orgMode: provider === "microsoft" && flags.has("--org-mode")
72817
72818
  };
72818
72819
  }
@@ -74520,16 +74521,16 @@ ${loginUrl}
74520
74521
 
74521
74522
  // gateway/auth-loopback-relay.ts
74522
74523
  import { spawn } from "node:child_process";
74523
- function defaultSpawnRelay(provider, email, opts) {
74524
- const binary = opts.binary ?? "switchroom";
74525
- const args = provider === "google" ? [
74524
+ function buildRelayArgs(provider, email, opts) {
74525
+ return provider === "google" ? [
74526
74526
  "auth",
74527
74527
  "google",
74528
74528
  "account",
74529
74529
  "add",
74530
74530
  email,
74531
74531
  ...opts.replace ? ["--replace"] : [],
74532
- ...opts.write ? ["--write"] : []
74532
+ ...opts.write ? ["--write"] : [],
74533
+ ...opts.calendar ? ["--calendar"] : []
74533
74534
  ] : [
74534
74535
  "auth",
74535
74536
  "microsoft",
@@ -74539,6 +74540,10 @@ function defaultSpawnRelay(provider, email, opts) {
74539
74540
  ...opts.replace ? ["--replace"] : [],
74540
74541
  ...opts.orgMode ? ["--org-mode"] : []
74541
74542
  ];
74543
+ }
74544
+ function defaultSpawnRelay(provider, email, opts) {
74545
+ const binary = opts.binary ?? "switchroom";
74546
+ const args = buildRelayArgs(provider, email, opts);
74542
74547
  const child = spawn(binary, args, {
74543
74548
  stdio: ["ignore", "pipe", "pipe"],
74544
74549
  env: {
@@ -101041,10 +101046,10 @@ function startOutboxSweep(deps) {
101041
101046
  }
101042
101047
 
101043
101048
  // ../src/build-info.ts
101044
- var VERSION2 = "0.19.45";
101045
- var COMMIT_SHA = "2ad33d5d";
101046
- var COMMIT_DATE = "2026-08-01T23:47:32Z";
101047
- var LATEST_PR = 4192;
101049
+ var VERSION2 = "0.19.46";
101050
+ var COMMIT_SHA = "306c3ed1";
101051
+ var COMMIT_DATE = "2026-08-02T00:29:39Z";
101052
+ var LATEST_PR = 4194;
101048
101053
  var COMMITS_AHEAD_OF_TAG = 0;
101049
101054
 
101050
101055
  // gateway/boot-version.ts
@@ -112527,7 +112532,7 @@ Set \`admin: true\` on this agent in switchroom.yaml to unlock.`, { html: true }
112527
112532
  return;
112528
112533
  }
112529
112534
  try {
112530
- const { consentUrl, state: state7, port, child } = await startLoopbackFlow(parsed.provider, parsed.email, { replace: parsed.replace, write: parsed.write, orgMode: parsed.orgMode });
112535
+ const { consentUrl, state: state7, port, child } = await startLoopbackFlow(parsed.provider, parsed.email, { replace: parsed.replace, write: parsed.write, calendar: parsed.calendar, orgMode: parsed.orgMode });
112531
112536
  const newFlow = {
112532
112537
  provider: parsed.provider,
112533
112538
  email: parsed.email,
@@ -65,6 +65,8 @@ export type ParsedAuthCommand =
65
65
  replace: boolean
66
66
  /** Google `--write` (Drive write scope). */
67
67
  write: boolean
68
+ /** Google `--calendar` (read-only Calendar scope). */
69
+ calendar: boolean
68
70
  /** Microsoft `--org-mode`. */
69
71
  orgMode: boolean
70
72
  }
@@ -251,7 +253,7 @@ export function parseAuthCommand(text: string): ParsedAuthCommand | null {
251
253
  * wired; anything else is a help-with-reason so the operator sees the shape.
252
254
  *
253
255
  * Flags mirror the CLI:
254
- * google: `add <email> [--replace] [--write]`
256
+ * google: `add <email> [--replace] [--write] [--calendar]`
255
257
  * microsoft: `add <email> [--replace] [--org-mode]`
256
258
  */
257
259
  export function parseProviderVerb(
@@ -265,7 +267,7 @@ export function parseProviderVerb(
265
267
  if (sub !== 'add') {
266
268
  const usage =
267
269
  provider === 'google'
268
- ? 'Usage: /auth google add <email> [--replace] [--write]'
270
+ ? 'Usage: /auth google add <email> [--replace] [--write] [--calendar]'
269
271
  : 'Usage: /auth microsoft add <email> [--replace] [--org-mode]'
270
272
  return {
271
273
  kind: 'help',
@@ -280,7 +282,7 @@ export function parseProviderVerb(
280
282
  if (!email) {
281
283
  const usage =
282
284
  provider === 'google'
283
- ? 'Usage: /auth google add <email> [--replace] [--write]'
285
+ ? 'Usage: /auth google add <email> [--replace] [--write] [--calendar]'
284
286
  : 'Usage: /auth microsoft add <email> [--replace] [--org-mode]'
285
287
  return { kind: 'help', reason: usage }
286
288
  }
@@ -288,7 +290,7 @@ export function parseProviderVerb(
288
290
  if (emailErr) return { kind: 'help', reason: emailErr }
289
291
  // Reject unknown flags so a typo (`--replaced`) isn't silently dropped.
290
292
  const allowed = provider === 'google'
291
- ? new Set(['--replace', '--write'])
293
+ ? new Set(['--replace', '--write', '--calendar'])
292
294
  : new Set(['--replace', '--org-mode'])
293
295
  for (const f of flags) {
294
296
  if (!allowed.has(f)) {
@@ -304,6 +306,7 @@ export function parseProviderVerb(
304
306
  email,
305
307
  replace: flags.has('--replace'),
306
308
  write: provider === 'google' && flags.has('--write'),
309
+ calendar: provider === 'google' && flags.has('--calendar'),
307
310
  orgMode: provider === 'microsoft' && flags.has('--org-mode'),
308
311
  }
309
312
  }
@@ -84,6 +84,8 @@ export interface SpawnRelayOpts {
84
84
  replace?: boolean
85
85
  /** Google `--write` (Drive write scope). */
86
86
  write?: boolean
87
+ /** Google `--calendar` (read-only Calendar scope). */
88
+ calendar?: boolean
87
89
  /** Microsoft `--org-mode`. */
88
90
  orgMode?: boolean
89
91
  /** Override the CLI binary (tests / non-default install). */
@@ -96,6 +98,42 @@ export type SpawnRelay = (
96
98
  opts: SpawnRelayOpts,
97
99
  ) => RelayChild
98
100
 
101
+ /**
102
+ * Build the `switchroom auth <provider> account add` argv the relay spawns.
103
+ *
104
+ * Pure + exported so the flag plumbing is unit-pinned: every opt-in flag
105
+ * the chat command accepts has to actually reach the CLI, and a flag that
106
+ * was NOT requested must never appear (the relay is the only path most
107
+ * operators use, so a dropped `--calendar` here is indistinguishable from
108
+ * the feature not existing, and a spurious one silently widens a grant).
109
+ */
110
+ export function buildRelayArgs(
111
+ provider: LoopbackProvider,
112
+ email: string,
113
+ opts: SpawnRelayOpts,
114
+ ): string[] {
115
+ return provider === 'google'
116
+ ? [
117
+ 'auth',
118
+ 'google',
119
+ 'account',
120
+ 'add',
121
+ email,
122
+ ...(opts.replace ? ['--replace'] : []),
123
+ ...(opts.write ? ['--write'] : []),
124
+ ...(opts.calendar ? ['--calendar'] : []),
125
+ ]
126
+ : [
127
+ 'auth',
128
+ 'microsoft',
129
+ 'account',
130
+ 'add',
131
+ email,
132
+ ...(opts.replace ? ['--replace'] : []),
133
+ ...(opts.orgMode ? ['--org-mode'] : []),
134
+ ]
135
+ }
136
+
99
137
  /**
100
138
  * Default spawn: the real `switchroom` CLI, stdout+stderr piped, stdin
101
139
  * ignored. `BROWSER=/bin/true` suppresses any browser auto-open; the Drive
@@ -109,26 +147,7 @@ export function defaultSpawnRelay(
109
147
  opts: SpawnRelayOpts,
110
148
  ): RelayChild {
111
149
  const binary = opts.binary ?? 'switchroom'
112
- const args =
113
- provider === 'google'
114
- ? [
115
- 'auth',
116
- 'google',
117
- 'account',
118
- 'add',
119
- email,
120
- ...(opts.replace ? ['--replace'] : []),
121
- ...(opts.write ? ['--write'] : []),
122
- ]
123
- : [
124
- 'auth',
125
- 'microsoft',
126
- 'account',
127
- 'add',
128
- email,
129
- ...(opts.replace ? ['--replace'] : []),
130
- ...(opts.orgMode ? ['--org-mode'] : []),
131
- ]
150
+ const args = buildRelayArgs(provider, email, opts)
132
151
  const child: ChildProcess = spawn(binary, args, {
133
152
  stdio: ['ignore', 'pipe', 'pipe'],
134
153
  env: {
@@ -20738,7 +20738,7 @@ bot.command("auth", async ctx => {
20738
20738
  const { consentUrl, state, port, child } = await startLoopbackFlow(
20739
20739
  parsed.provider,
20740
20740
  parsed.email,
20741
- { replace: parsed.replace, write: parsed.write, orgMode: parsed.orgMode },
20741
+ { replace: parsed.replace, write: parsed.write, calendar: parsed.calendar, orgMode: parsed.orgMode },
20742
20742
  )
20743
20743
  const newFlow = {
20744
20744
  provider: parsed.provider,
@@ -22,6 +22,7 @@ import { describe, it, expect } from 'vitest'
22
22
  import { EventEmitter } from 'node:events'
23
23
 
24
24
  import {
25
+ buildRelayArgs,
25
26
  parseLoopbackRedirect,
26
27
  extractConsentUrl,
27
28
  parseConsentUrl,
@@ -492,6 +493,7 @@ describe('parseAuthCommand — provider verbs (#2582)', () => {
492
493
  email: 'user@example.com',
493
494
  replace: false,
494
495
  write: false,
496
+ calendar: false,
495
497
  orgMode: false,
496
498
  })
497
499
  })
@@ -501,6 +503,26 @@ describe('parseAuthCommand — provider verbs (#2582)', () => {
501
503
  expect(p).toMatchObject({ kind: 'provider-add', replace: true, write: true })
502
504
  })
503
505
 
506
+ it('parses google --calendar, and it does not imply --write', () => {
507
+ const p = parseAuthCommand('/auth google add user@example.com --replace --calendar')
508
+ expect(p).toMatchObject({
509
+ kind: 'provider-add',
510
+ replace: true,
511
+ calendar: true,
512
+ write: false,
513
+ })
514
+ })
515
+
516
+ it('parses --write and --calendar together', () => {
517
+ const p = parseAuthCommand('/auth google add user@example.com --write --calendar')
518
+ expect(p).toMatchObject({ kind: 'provider-add', write: true, calendar: true })
519
+ })
520
+
521
+ it('--calendar is google-only — microsoft rejects it as an unknown flag', () => {
522
+ const p = parseAuthCommand('/auth microsoft add user@corp.com --calendar')
523
+ expect(p).toMatchObject({ kind: 'help' })
524
+ })
525
+
504
526
  it('parses /auth microsoft add with --org-mode', () => {
505
527
  const p = parseAuthCommand('/auth microsoft add user@corp.com --org-mode')
506
528
  expect(p).toMatchObject({
@@ -531,3 +553,39 @@ describe('parseAuthCommand — provider verbs (#2582)', () => {
531
553
  expect(p).toMatchObject({ kind: 'help' })
532
554
  })
533
555
  })
556
+
557
+
558
+ describe('buildRelayArgs — chat flags actually reach the CLI', () => {
559
+ it('bare google add: no opt-in flags', () => {
560
+ expect(buildRelayArgs('google', 'a@b.com', {})).toEqual([
561
+ 'auth', 'google', 'account', 'add', 'a@b.com',
562
+ ])
563
+ })
564
+
565
+ it('forwards --calendar', () => {
566
+ expect(buildRelayArgs('google', 'a@b.com', { calendar: true })).toEqual([
567
+ 'auth', 'google', 'account', 'add', 'a@b.com', '--calendar',
568
+ ])
569
+ })
570
+
571
+ it('forwards --replace --write --calendar together, in order', () => {
572
+ expect(
573
+ buildRelayArgs('google', 'a@b.com', {
574
+ replace: true,
575
+ write: true,
576
+ calendar: true,
577
+ }),
578
+ ).toEqual([
579
+ 'auth', 'google', 'account', 'add', 'a@b.com',
580
+ '--replace', '--write', '--calendar',
581
+ ])
582
+ })
583
+
584
+ it('--calendar never leaks into the microsoft argv', () => {
585
+ expect(
586
+ buildRelayArgs('microsoft', 'a@corp.com', { calendar: true, orgMode: true }),
587
+ ).toEqual([
588
+ 'auth', 'microsoft', 'account', 'add', 'a@corp.com', '--org-mode',
589
+ ])
590
+ })
591
+ })