switchroom 0.19.46 → 0.19.47

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.46", COMMIT_SHA = "306c3ed1";
2123
+ var VERSION = "0.19.47", COMMIT_SHA = "9693493e";
2124
2124
 
2125
2125
  // src/cli/resolve-version.ts
2126
2126
  import { existsSync, readFileSync } from "node:fs";
@@ -13595,7 +13595,7 @@ function isValidTimezone(value) {
13595
13595
  function resolveMemoryProfile(agentConfig) {
13596
13596
  return agentConfig?.memory?.profile ?? agentConfig?.extends ?? DEFAULT_PROFILE;
13597
13597
  }
13598
- var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, ObservationScopesSchema, ObservationScopeStrategySchema, AntiConfabulationDirectiveSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, releaseBlockFields, ReleaseBlock, RootReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, DEFAULT_PROFILE = "default", AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, SwitchroomConfigSchema;
13598
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, ObservationScopesSchema, ObservationScopeStrategySchema, AntiConfabulationDirectiveSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleServiceTokenSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, releaseBlockFields, ReleaseBlock, RootReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, DEFAULT_PROFILE = "default", AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, SwitchroomConfigSchema;
13599
13599
  var init_schema = __esm(() => {
13600
13600
  init_zod();
13601
13601
  init_observation_scopes();
@@ -13982,11 +13982,18 @@ var init_schema = __esm(() => {
13982
13982
  "extended",
13983
13983
  "complete"
13984
13984
  ]);
13985
+ GoogleServiceTokenSchema = exports_external.enum([
13986
+ "cal",
13987
+ "drive",
13988
+ "docs",
13989
+ "sheets",
13990
+ "slides"
13991
+ ]);
13985
13992
  GoogleWorkspaceConfigSchema = exports_external.object({
13986
13993
  google_client_id: exports_external.string().min(1).describe("Google OAuth client ID (literal string or vault reference e.g. 'vault:google-oauth-client-id')"),
13987
13994
  google_client_secret: exports_external.string().min(1).describe("Google OAuth client secret (literal string or vault reference e.g. 'vault:google-oauth-client-secret')"),
13988
13995
  approvers: exports_external.array(ApproverIdSchema).min(1).describe("Array of numeric Telegram user IDs authorized to approve drive onboarding. " + "At least one must be specified."),
13989
- tier: GoogleWorkspaceTierSchema.optional().describe("RFC G Phase 1: which upstream MCP tier to expose. " + "core (default) = ~16 tools (Drive+Docs+Sheets+Calendar). " + "extended = ~40 tools (+Slides, Forms, Tasks, Chat). " + "complete = ~60+ tools (+Gmail; not recommended yet \u2014 see RFC G \u00a75).")
13996
+ tier: GoogleWorkspaceTierSchema.optional().describe("RFC G Phase 1: which upstream MCP tier to expose. " + "core (default) = ~16 tools (Drive+Docs+Sheets+Calendar tools; the " + "Calendar tools only authenticate when the account holds the opt-in " + "calendar scope \u2014 no tier mints it). " + "extended = ~40 tools (+Slides, Forms, Tasks, Chat). " + "complete = ~60+ tools (+Gmail; not recommended yet \u2014 see RFC G \u00a75).")
13990
13997
  }).optional();
13991
13998
  LiteLLMConfigSchema = exports_external.object({
13992
13999
  enabled: exports_external.boolean().optional().describe("Opt-in toggle. When true, `switchroom apply` provisions a per-agent " + "LiteLLM virtual key and injects routing env into the container. " + "Default OFF."),
@@ -14019,7 +14026,7 @@ var init_schema = __esm(() => {
14019
14026
  reflect: HindsightPerOpLlmSchema.optional().describe("Per-op override for the `reflect` LLM op (synthesis / mental-model " + "refresh). Emits `HINDSIGHT_API_REFLECT_LLM_*`. Absent \u2192 uses global."),
14020
14027
  consolidation: HindsightPerOpLlmSchema.optional().describe("Per-op override for the `consolidation` LLM op (background memory " + "merge). Emits `HINDSIGHT_API_CONSOLIDATION_LLM_*`. Absent \u2192 global.")
14021
14028
  }).optional().describe("LLM knob for the hindsight container. The flat `provider`/`model` set " + "the global default (backward-compatible); optional `retain`/`reflect`/" + "`consolidation` blocks override individual ops. All fields optional; " + "unset fields fall back to the hard-coded defaults."),
14022
- env: exports_external.record(exports_external.union([exports_external.string(), exports_external.number(), exports_external.boolean()])).optional().describe("Operator overrides for switchroom's capability-gated Hindsight " + "performance defaults. Only the keys switchroom actually manages are " + "honoured (`HINDSIGHT_PERF_ENV_KEYS` in " + "src/setup/hindsight-perf-defaults.ts: RERANKER_LOCAL_FP16, " + "RERANKER_LOCAL_BATCH_SIZE, LLM_MAX_CONCURRENT, " + "RETAIN/CONSOLIDATION_LLM_MAX_CONCURRENT, LLM_STRICT_SCHEMA, " + "LLM_MAX_RETRIES, CONSOLIDATION_LLM_PARALLELISM, " + "MAX_OBSERVATIONS_PER_SCOPE, " + "RECALL_MAX_CANDIDATES_PER_SOURCE, LINK_EXPANSION_PER_ENTITY_LIMIT, " + "LINK_EXPANSION_TIMEOUT, LLM_REASONING_EFFORT, " + "RERANKER_LOCAL_BUCKET_BATCHING, RERANKER_MAX_CANDIDATES, " + "RERANKER_LOCAL_MAX_CONCURRENT, RECALL_MAX_CONCURRENT, " + "REFLECT_WALL_TIMEOUT, WORKER_CONSOLIDATION_RESERVED_SLOTS, " + "WORKER_CONSOLIDATION_SLOT_LIMIT, " + "CONSOLIDATION_MAX_MEMORIES_PER_ROUND, GRAPH_SEED_MIN_SIMILARITY, " + "LLM_SUPPORTS_MAX_ITEMS, RECENCY_DECAY_FUNCTION, " + "RECENCY_DECAY_HALFLIFE_DAYS \u2014 switchroom defaults recall's recency " + "curve to `exponential` with a 30-day half-life so a fact retained " + "today outranks a stale one, instead of upstream's near-flat " + "linear/365-day window), the override-only keys " + "switchroom manages but ships NO default for " + "(`HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS`: " + "HINDSIGHT_API_WORKER_CONSOLIDATION_BANK_PRIORITY \u2014 a per-deployment " + "`bank-pattern:priority,...` map; unset means upstream's flat " + "created_at FIFO across banks; and " + "HINDSIGHT_CE_DECISIVE_RELATIVE_GAP \u2014 the rollback knob for " + "switchroom's CE-saturation damping patch, a float; >= ~0.65 backs the " + "damping out entirely, unset means the patch's own derived gap; and " + "HINDSIGHT_API_RECENCY_DECAY_LINEAR_WINDOW_DAYS \u2014 only read when the " + "decay function is `linear`, so switchroom ships no default for it but " + "still honours an operator who flips the function back; and " + "HINDSIGHT_API_WORKER_MAX_SLOTS \u2014 the worker poller's TOTAL in-flight " + "task budget, the pool WORKER_CONSOLIDATION_RESERVED_SLOTS reserves out of; " + "unset means upstream's own default; and " + "HINDSIGHT_API_WORKER_RETAIN_RESERVED_SLOTS \u2014 the reserved slot FLOOR for the " + "retain (memory write) lane, carved from that same total; unset means " + "upstream's own 0, i.e. no floor and retain competes for the shared " + "pool; and " + "the pre-0.8.6 name HINDSIGHT_API_WORKER_<TYPE>_MAX_SLOTS is still " + "accepted for every operation type and normalised to " + "..._RESERVED_SLOTS on the way in, because setting both names for " + "one type is a hard boot failure in the engine and switchroom " + "therefore emits only the canonical name; and " + "HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY \u2014 the cosine-similarity floor a " + "candidate must reach to be returned by the semantic retrieval arm at " + "all (0..1); unset means upstream's own 0.3, and where it should sit " + "depends on the bank's embedding model and phrasing diversity, so " + "switchroom ships no opinion; and " + "HINDSIGHT_MCP_RECALL_BUDGET_MODE \u2014 the rollback knob for switchroom's " + "mcp-recall-token-budget image patch; `legacy` restores upstream's " + "exact pre-patch recall returns, anything else or unset is the " + "honest-envelope mode; and " + "HINDSIGHT_API_LLM_TEMPERATURE_REFLECT \u2014 upstream's own per-op reflect " + "temperature knob, made live by switchroom's reflect-temperature image " + "patch; a float, or `none` to omit the kwarg (provider default, " + "upstream's accidental pre-patch behaviour); unset means the image's " + "baked default 0.1; and " + "HINDSIGHT_API_CONSOLIDATION_RECALL_MAX_CONCURRENT \u2014 the background " + "half of switchroom's recall-admission split (#3660): of the " + "RECALL_MAX_CONCURRENT admission slots, at most this many may be held " + "by background consolidation recalls at once, so foreground per-turn " + "recall always keeps the remainder; must be >= 1 and strictly less " + "than RECALL_MAX_CONCURRENT or the engine refuses to boot; unset " + "means the image's derived default min(2, RECALL_MAX_CONCURRENT - 1); " + "1 biases hard toward the interactive lane while a consolidation " + "backlog drains), plus the " + "embedded-PostgreSQL (pg0) sizing keys switchroom manages in " + "src/setup/hindsight-pg-defaults.ts (`HINDSIGHT_PG_ENV_KEYS`: " + "SWITCHROOM_HINDSIGHT_PG_EFFECTIVE_CACHE_SIZE, " + "SWITCHROOM_HINDSIGHT_PG_SHARED_BUFFERS \u2014 a postgres size string such " + "as `4GB`, or the sentinel `off` to leave pg0's own default for that " + "one knob). A value set here " + "REPLACES switchroom's default and is emitted even when the gating " + "capability is absent, so an operator can always force a knob. Other " + "`HINDSIGHT_API_*` keys are deliberately IGNORED \u2014 a blanket " + "passthrough would collide with the vars startHindsight() derives " + "itself (HINDSIGHT_API_PORT, the retain token/deadline budget).")
14029
+ env: exports_external.record(exports_external.union([exports_external.string(), exports_external.number(), exports_external.boolean()])).optional().describe("Operator overrides for switchroom's capability-gated Hindsight " + "performance defaults. Only the keys switchroom actually manages are " + "honoured (`HINDSIGHT_PERF_ENV_KEYS` in " + "src/setup/hindsight-perf-defaults.ts: RERANKER_LOCAL_FP16, " + "RERANKER_LOCAL_BATCH_SIZE, LLM_MAX_CONCURRENT, " + "RETAIN/CONSOLIDATION_LLM_MAX_CONCURRENT, LLM_STRICT_SCHEMA, " + "LLM_MAX_RETRIES, CONSOLIDATION_LLM_PARALLELISM, " + "MAX_OBSERVATIONS_PER_SCOPE, " + "RECALL_MAX_CANDIDATES_PER_SOURCE, LINK_EXPANSION_PER_ENTITY_LIMIT, " + "LINK_EXPANSION_TIMEOUT, LLM_REASONING_EFFORT, " + "RERANKER_LOCAL_BUCKET_BATCHING, RERANKER_MAX_CANDIDATES, " + "RERANKER_LOCAL_MAX_CONCURRENT, RECALL_MAX_CONCURRENT, " + "REFLECT_WALL_TIMEOUT, WORKER_CONSOLIDATION_RESERVED_SLOTS, " + "WORKER_CONSOLIDATION_SLOT_LIMIT, " + "CONSOLIDATION_MAX_MEMORIES_PER_ROUND, GRAPH_SEED_MIN_SIMILARITY, " + "LLM_SUPPORTS_MAX_ITEMS, RECENCY_DECAY_FUNCTION, " + "RECENCY_DECAY_HALFLIFE_DAYS \u2014 switchroom defaults recall's recency " + "curve to `exponential` with a 30-day half-life so a fact retained " + "today outranks a stale one, instead of upstream's near-flat " + "linear/365-day window), the override-only keys " + "switchroom manages but ships NO default for " + "(`HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS`: " + "HINDSIGHT_API_WORKER_CONSOLIDATION_BANK_PRIORITY \u2014 a per-deployment " + "`bank-pattern:priority,...` map; unset means upstream's flat " + "created_at FIFO across banks; and " + "HINDSIGHT_CE_DECISIVE_RELATIVE_GAP \u2014 the rollback knob for " + "switchroom's CE-saturation damping patch, a float; >= ~0.65 backs the " + "damping out entirely, unset means the patch's own derived gap; and " + "HINDSIGHT_API_RECENCY_DECAY_LINEAR_WINDOW_DAYS \u2014 only read when the " + "decay function is `linear`, so switchroom ships no default for it but " + "still honours an operator who flips the function back; and " + "HINDSIGHT_API_WORKER_MAX_SLOTS \u2014 the worker poller's TOTAL in-flight " + "task budget, the pool WORKER_CONSOLIDATION_RESERVED_SLOTS reserves out of; " + "unset means upstream's own default; and " + "HINDSIGHT_API_WORKER_RETAIN_RESERVED_SLOTS \u2014 the reserved slot FLOOR for the " + "retain (memory write) lane, carved from that same total; unset means " + "upstream's own 0, i.e. no floor and retain competes for the shared " + "pool; and " + "the pre-0.8.6 name HINDSIGHT_API_WORKER_<TYPE>_MAX_SLOTS is still " + "accepted for every operation type and normalised to " + "..._RESERVED_SLOTS on the way in, because setting both names for " + "one type is a hard boot failure in the engine and switchroom " + "therefore emits only the canonical name; and " + "HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY \u2014 the cosine-similarity floor a " + "candidate must reach to be returned by the semantic retrieval arm at " + "all (0..1); unset means upstream's own 0.3, and where it should sit " + "depends on the bank's embedding model and phrasing diversity, so " + "switchroom ships no opinion; and " + "HINDSIGHT_MCP_RECALL_BUDGET_MODE \u2014 the rollback knob for switchroom's " + "mcp-recall-token-budget image patch; `legacy` restores upstream's " + "exact pre-patch recall returns, anything else or unset is the " + "honest-envelope mode; and " + "HINDSIGHT_API_LLM_TEMPERATURE_REFLECT \u2014 upstream's own per-op reflect " + "temperature knob, made live by switchroom's reflect-temperature image " + "patch; a float, or `none` to omit the kwarg (provider default, " + "upstream's accidental pre-patch behaviour); unset means the image's " + "baked default 0.1; and " + "HINDSIGHT_API_CONSOLIDATION_RECALL_MAX_CONCURRENT \u2014 the background " + "half of switchroom's recall-admission split (#3660): of the " + "RECALL_MAX_CONCURRENT admission slots, at most this many may be held " + "by background consolidation recalls at once, so foreground per-turn " + "recall always keeps the remainder; must be >= 1 and strictly less " + "than RECALL_MAX_CONCURRENT or the engine refuses to boot; unset " + "means the image's derived default min(2, RECALL_MAX_CONCURRENT - 1); " + "1 biases hard toward the interactive lane while a consolidation " + "backlog drains; and " + "HINDSIGHT_MM_REFRESH_MIN_INTERVAL_S \u2014 the rollback/tuning knob for " + "switchroom's MM-refresh-debounce image patch: the minimum seconds " + "between consolidation-triggered refreshes of one mental model; unset " + "means the image's baked 3600, 0 restores upstream's " + "refresh-every-round behaviour; explicit and cron-scheduled refreshes " + "are never debounced), plus the " + "embedded-PostgreSQL (pg0) sizing keys switchroom manages in " + "src/setup/hindsight-pg-defaults.ts (`HINDSIGHT_PG_ENV_KEYS`: " + "SWITCHROOM_HINDSIGHT_PG_EFFECTIVE_CACHE_SIZE, " + "SWITCHROOM_HINDSIGHT_PG_SHARED_BUFFERS \u2014 a postgres size string such " + "as `4GB`, or the sentinel `off` to leave pg0's own default for that " + "one knob). A value set here " + "REPLACES switchroom's default and is emitted even when the gating " + "capability is absent, so an operator can always force a knob. Other " + "`HINDSIGHT_API_*` keys are deliberately IGNORED \u2014 a blanket " + "passthrough would collide with the vars startHindsight() derives " + "itself (HINDSIGHT_API_PORT, the retain token/deadline budget).")
14023
14030
  });
14024
14031
  MicrosoftWorkspaceConfigSchema = exports_external.object({
14025
14032
  microsoft_client_id: exports_external.string().min(1).optional().describe("Microsoft OAuth application (client) ID from Entra portal " + "(literal string or vault reference e.g. " + "'vault:microsoft-oauth-client-id'). OPTIONAL \u2014 omit it to use " + "switchroom's shipped default Microsoft app (zero-config). " + "Set it only to bring your own Entra app (BYO)."),
@@ -14493,7 +14500,9 @@ var init_schema = __esm(() => {
14493
14500
  }).transform((v) => v.trim().toLowerCase()), exports_external.object({
14494
14501
  enabled_for: exports_external.array(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,50}$/, {
14495
14502
  message: "Agent name must match the standard agent-name pattern"
14496
- })).describe("Agent slugs that may read this account's vault slots " + "(`google:<account>:refresh_token` etc). Per-agent ACL is " + "enforced at the broker, not at the agent identity layer \u2014 " + "the agent still authenticates via socket-path-as-identity " + "per RFC D \u00a74.1, broker just gates the cross-agent token share.")
14503
+ })).describe("Agent slugs that may read this account's vault slots " + "(`google:<account>:refresh_token` etc). Per-agent ACL is " + "enforced at the broker, not at the agent identity layer \u2014 " + "the agent still authenticates via socket-path-as-identity " + "per RFC D \u00a74.1, broker just gates the cross-agent token share."),
14504
+ readonly: exports_external.boolean().optional().describe("Per-account read-only selection (v1 scope model). true \u2192 the " + "token minted by `auth google account add` carries ONLY " + ".readonly scope variants (zero write scopes), and the gdrive " + "MCP launcher passes upstream `--read-only` so write tools are " + "not exposed. Written by `account add --readonly`; re-read on " + "`--replace` so a re-consent cannot silently re-widen. Omitted " + "= legacy behaviour (tier-tied read-write document scopes)."),
14505
+ services: exports_external.array(GoogleServiceTokenSchema).min(1).optional().describe("Per-account service selection (v1 scope model). Which Google " + "services the minted token covers AND the gdrive MCP exposes " + "(upstream `--tools`): cal, drive, docs, sheets, slides. " + "Written by `account add --services`; re-read on `--replace`. " + "Omitted = the tier's default services (drive,docs,sheets; " + "+slides at extended/complete).")
14497
14506
  })).optional().describe("RFC G Phase 2: per-Google-account ACL for vault slots holding " + "OAuth refresh tokens. Maps account email \u2192 list of agents " + "permitted to read that account's slots. Written by `switchroom " + "auth google enable|disable` (Phase 3); read by the broker on " + "every Google slot access. Replaces RFC D's per-agent vault slot " + "scope (which can't express 'two agents share one Google account')."),
14498
14507
  microsoft_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
14499
14508
  message: "Account key must be a Microsoft account email like 'alice@outlook.com' or 'alice@contoso.com' (colons not allowed)"
@@ -16075,7 +16084,8 @@ var init_hindsight_perf_defaults = __esm(() => {
16075
16084
  "HINDSIGHT_MCP_RECALL_BUDGET_MODE",
16076
16085
  "HINDSIGHT_API_LLM_TEMPERATURE_REFLECT",
16077
16086
  "HINDSIGHT_API_RETAIN_WALL_TIMEOUT",
16078
- "HINDSIGHT_API_CONSOLIDATION_RECALL_MAX_CONCURRENT"
16087
+ "HINDSIGHT_API_CONSOLIDATION_RECALL_MAX_CONCURRENT",
16088
+ "HINDSIGHT_MM_REFRESH_MIN_INTERVAL_S"
16079
16089
  ]);
16080
16090
  HINDSIGHT_WORKER_SLOT_TYPES = [
16081
16091
  "consolidation",
@@ -27363,11 +27373,18 @@ function getPlaywrightMcpSettingsEntry() {
27363
27373
  }
27364
27374
  function getGdriveMcpSettingsEntry(switchroomCliPath, options = {}) {
27365
27375
  const tierArgs = options.tier ? ["--tier", options.tier] : [];
27376
+ const servicesArgs = options.services && options.services.length > 0 ? ["--services", options.services.join(",")] : [];
27377
+ const readOnlyArgs = options.readOnly ? ["--read-only"] : [];
27366
27378
  return {
27367
27379
  key: "gdrive",
27368
27380
  value: {
27369
27381
  command: switchroomCliPath,
27370
- args: ["drive-mcp-launcher", ...tierArgs]
27382
+ args: [
27383
+ "drive-mcp-launcher",
27384
+ ...tierArgs,
27385
+ ...servicesArgs,
27386
+ ...readOnlyArgs
27387
+ ]
27371
27388
  }
27372
27389
  };
27373
27390
  }
@@ -29941,7 +29958,12 @@ function resolveGdriveMcpEntry(agentName, agentConfig, switchroomConfig) {
29941
29958
  if (!shouldEmitGdriveMcp(agentName, account, googleAccounts))
29942
29959
  return null;
29943
29960
  const tier = agentConfig.google_workspace?.tier ?? switchroomConfig?.google_workspace?.tier;
29944
- const entry = getGdriveMcpSettingsEntry(DOCKER_SWITCHROOM_CLI_PATH, tier ? { tier } : {});
29961
+ const accountRecord = account ? googleAccounts?.[account.trim().toLowerCase()] : undefined;
29962
+ const entry = getGdriveMcpSettingsEntry(DOCKER_SWITCHROOM_CLI_PATH, {
29963
+ ...tier ? { tier } : {},
29964
+ ...accountRecord?.services && accountRecord.services.length > 0 ? { services: accountRecord.services } : {},
29965
+ ...accountRecord?.readonly ? { readOnly: true } : {}
29966
+ });
29945
29967
  entry.value.env = {
29946
29968
  SWITCHROOM_CONFIG: DOCKER_CONFIG_PATH,
29947
29969
  SWITCHROOM_AGENT_NAME: agentName,
@@ -37436,36 +37458,93 @@ var init_wait = __esm(() => {
37436
37458
  var exports_drive = {};
37437
37459
  __export(exports_drive, {
37438
37460
  workspaceScopesForTier: () => workspaceScopesForTier,
37461
+ tierDefaultServices: () => tierDefaultServices,
37439
37462
  selectGoogleWorkspaceScopes: () => selectGoogleWorkspaceScopes,
37440
37463
  selectDriveAccountScopes: () => selectDriveAccountScopes,
37464
+ scopesForSelection: () => scopesForSelection,
37441
37465
  runDriveOAuthFlow: () => runDriveOAuthFlow,
37466
+ resolveScopeSelection: () => resolveScopeSelection,
37442
37467
  resolveReconsentCapabilities: () => resolveReconsentCapabilities,
37443
37468
  registerDriveCommand: () => registerDriveCommand,
37469
+ parseServicesOption: () => parseServicesOption,
37444
37470
  capabilitiesFromScopeString: () => capabilitiesFromScopeString,
37445
37471
  __test: () => __test,
37446
37472
  GOOGLE_SLIDES_SCOPE: () => GOOGLE_SLIDES_SCOPE,
37473
+ GOOGLE_SLIDES_READONLY_SCOPE: () => GOOGLE_SLIDES_READONLY_SCOPE,
37447
37474
  GOOGLE_SHEETS_SCOPE: () => GOOGLE_SHEETS_SCOPE,
37475
+ GOOGLE_SHEETS_READONLY_SCOPE: () => GOOGLE_SHEETS_READONLY_SCOPE,
37476
+ GOOGLE_SERVICES: () => GOOGLE_SERVICES,
37448
37477
  GOOGLE_DOCS_SCOPE: () => GOOGLE_DOCS_SCOPE,
37478
+ GOOGLE_DOCS_READONLY_SCOPE: () => GOOGLE_DOCS_READONLY_SCOPE,
37449
37479
  GOOGLE_CALENDAR_READONLY_SCOPE: () => GOOGLE_CALENDAR_READONLY_SCOPE,
37450
37480
  DRIVE_WRITE_SCOPES: () => DRIVE_WRITE_SCOPES,
37451
37481
  DRIVE_READONLY_SCOPES: () => DRIVE_READONLY_SCOPES
37452
37482
  });
37453
37483
  import { createInterface as createInterface2 } from "node:readline";
37454
- function workspaceScopesForTier(tier) {
37455
- const docScopes = [GOOGLE_DOCS_SCOPE, GOOGLE_SHEETS_SCOPE];
37484
+ function parseServicesOption(raw) {
37485
+ const tokens = raw.split(",").map((s) => s.trim().toLowerCase()).filter((s) => s.length > 0).map((s) => s === "calendar" ? "cal" : s);
37486
+ if (tokens.length === 0) {
37487
+ throw new Error(`--services requires at least one service (valid: ${GOOGLE_SERVICES.join(", ")})`);
37488
+ }
37489
+ for (const t of tokens) {
37490
+ if (!GOOGLE_SERVICES.includes(t)) {
37491
+ throw new Error(`--services: unknown service '${t}' (valid: ${GOOGLE_SERVICES.join(", ")}; ` + `'calendar' is accepted as an alias for 'cal')`);
37492
+ }
37493
+ }
37494
+ const set = new Set(tokens);
37495
+ return GOOGLE_SERVICES.filter((s) => set.has(s));
37496
+ }
37497
+ function tierDefaultServices(tier) {
37456
37498
  if (tier === "extended" || tier === "complete") {
37457
- return [...docScopes, GOOGLE_SLIDES_SCOPE];
37499
+ return ["drive", "docs", "sheets", "slides"];
37458
37500
  }
37459
- return docScopes;
37501
+ return ["drive", "docs", "sheets"];
37502
+ }
37503
+ function scopesForSelection(sel) {
37504
+ const selected = new Set(sel.services);
37505
+ const out = [];
37506
+ if (selected.has("drive")) {
37507
+ out.push(...DRIVE_READONLY_SCOPES);
37508
+ if (sel.driveWrite && !sel.readonly) {
37509
+ out.push("https://www.googleapis.com/auth/drive.file");
37510
+ }
37511
+ }
37512
+ if (selected.has("docs")) {
37513
+ out.push(sel.readonly ? GOOGLE_DOCS_READONLY_SCOPE : GOOGLE_DOCS_SCOPE);
37514
+ }
37515
+ if (selected.has("sheets")) {
37516
+ out.push(sel.readonly ? GOOGLE_SHEETS_READONLY_SCOPE : GOOGLE_SHEETS_SCOPE);
37517
+ }
37518
+ if (selected.has("slides")) {
37519
+ out.push(sel.readonly ? GOOGLE_SLIDES_READONLY_SCOPE : GOOGLE_SLIDES_SCOPE);
37520
+ }
37521
+ if (selected.has("cal")) {
37522
+ out.push(GOOGLE_CALENDAR_READONLY_SCOPE);
37523
+ }
37524
+ return [...new Set(out)];
37525
+ }
37526
+ function workspaceScopesForTier(tier) {
37527
+ return scopesForSelection({
37528
+ readonly: false,
37529
+ driveWrite: false,
37530
+ services: tierDefaultServices(tier).filter((s) => s !== "drive")
37531
+ });
37460
37532
  }
37461
37533
  function selectDriveAccountScopes(write) {
37462
37534
  return write ? DRIVE_WRITE_SCOPES : DRIVE_READONLY_SCOPES;
37463
37535
  }
37464
37536
  function selectGoogleWorkspaceScopes(opts) {
37465
- const base = selectDriveAccountScopes(opts.write);
37466
- const workspace = workspaceScopesForTier(opts.tier ?? "core");
37467
- const calendar = opts.calendar ? [GOOGLE_CALENDAR_READONLY_SCOPE] : [];
37468
- return [...new Set([...base, ...workspace, ...calendar])];
37537
+ if (opts.readonly && opts.write) {
37538
+ throw new Error("--readonly and --write are mutually exclusive: a read-only " + "selection mints zero write scopes by definition.");
37539
+ }
37540
+ const services = opts.services ?? tierDefaultServices(opts.tier ?? "core");
37541
+ const withCal = opts.calendar && !services.includes("cal") ? [...services, "cal"] : services;
37542
+ const set = new Set(withCal);
37543
+ return scopesForSelection({
37544
+ readonly: opts.readonly ?? false,
37545
+ driveWrite: opts.write,
37546
+ services: GOOGLE_SERVICES.filter((s) => set.has(s))
37547
+ });
37469
37548
  }
37470
37549
  function capabilitiesFromScopeString(scope) {
37471
37550
  const have = new Set(scope.split(/\s+/).map((s) => s.trim()).filter((s) => s.length > 0));
@@ -37488,6 +37567,62 @@ function resolveReconsentCapabilities(requested, existing) {
37488
37567
  added: keys.filter((k) => requested[k] && !existing[k])
37489
37568
  };
37490
37569
  }
37570
+ function resolveScopeSelection(args) {
37571
+ const { flags, persisted, existing, tier } = args;
37572
+ if (flags.readonly && flags.write) {
37573
+ throw new Error("--readonly and --write are mutually exclusive. A read-only account " + "mints zero write scopes; use --write (without --readonly) for the " + "drive.file write grant.");
37574
+ }
37575
+ const dropped = [];
37576
+ const readonly = flags.readonly ? true : flags.write ? false : persisted?.readonly ?? false;
37577
+ if (flags.write && persisted?.readonly) {
37578
+ dropped.push("read-only mode (persisted `readonly: true`) \u2014 dropped because --write was passed");
37579
+ }
37580
+ let driveWrite = existing.write;
37581
+ if (readonly && existing.write) {
37582
+ driveWrite = false;
37583
+ dropped.push("Drive write (drive.file) \u2014 the existing token carries it, but --readonly was passed");
37584
+ }
37585
+ const explicitServices = !!(flags.services && flags.services.length > 0);
37586
+ let services;
37587
+ let persistServices;
37588
+ if (explicitServices) {
37589
+ services = [...flags.services];
37590
+ persistServices = true;
37591
+ if (flags.calendar && !services.includes("cal"))
37592
+ services.push("cal");
37593
+ } else if (persisted?.services && persisted.services.length > 0) {
37594
+ services = [...persisted.services];
37595
+ persistServices = true;
37596
+ if ((flags.calendar || existing.calendar) && !services.includes("cal")) {
37597
+ services.push("cal");
37598
+ }
37599
+ } else {
37600
+ services = [...tierDefaultServices(tier)];
37601
+ persistServices = false;
37602
+ if ((flags.calendar || existing.calendar) && !services.includes("cal")) {
37603
+ services.push("cal");
37604
+ }
37605
+ }
37606
+ const set = new Set(services);
37607
+ const canonical = GOOGLE_SERVICES.filter((s) => set.has(s));
37608
+ if (explicitServices) {
37609
+ const prior = persisted?.services ?? (existing.calendar ? [...tierDefaultServices(tier), "cal"] : tierDefaultServices(tier));
37610
+ for (const s of prior) {
37611
+ if (!set.has(s)) {
37612
+ dropped.push(`service '${s}' \u2014 previously granted, not in the --services list passed`);
37613
+ }
37614
+ }
37615
+ }
37616
+ const persist = flags.readonly || persistServices || persisted !== undefined ? {
37617
+ readonly,
37618
+ services: canonical
37619
+ } : null;
37620
+ return {
37621
+ selection: { readonly, services: canonical, driveWrite },
37622
+ persist,
37623
+ dropped
37624
+ };
37625
+ }
37491
37626
  function getVaultPath(configPath) {
37492
37627
  try {
37493
37628
  const config = loadConfig(configPath);
@@ -37900,7 +38035,7 @@ function registerDriveCommand(program3, deps = {}) {
37900
38035
  await runDisconnect({ agentName: agent }, deps);
37901
38036
  });
37902
38037
  }
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;
38038
+ 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", GOOGLE_DOCS_READONLY_SCOPE = "https://www.googleapis.com/auth/documents.readonly", GOOGLE_SHEETS_READONLY_SCOPE = "https://www.googleapis.com/auth/spreadsheets.readonly", GOOGLE_SLIDES_READONLY_SCOPE = "https://www.googleapis.com/auth/presentations.readonly", GOOGLE_SERVICES, __test;
37904
38039
  var init_drive = __esm(() => {
37905
38040
  init_source();
37906
38041
  init_loader();
@@ -37920,6 +38055,13 @@ var init_drive = __esm(() => {
37920
38055
  "https://www.googleapis.com/auth/drive.file"
37921
38056
  ];
37922
38057
  DEFAULT_SCOPES = DRIVE_READONLY_SCOPES;
38058
+ GOOGLE_SERVICES = [
38059
+ "drive",
38060
+ "docs",
38061
+ "sheets",
38062
+ "slides",
38063
+ "cal"
38064
+ ];
37923
38065
  __test = {
37924
38066
  runConnect,
37925
38067
  runDisconnect,
@@ -77033,6 +77175,23 @@ function listGoogleAccounts(yamlText) {
77033
77175
  }
77034
77176
  return out;
77035
77177
  }
77178
+ function setGoogleAccountSelection(yamlText, account, selection) {
77179
+ const doc = import_yaml6.parseDocument(yamlText);
77180
+ ensureGoogleAccountEntry(doc, account);
77181
+ const currentReadonly = doc.getIn([
77182
+ "google_accounts",
77183
+ account,
77184
+ "readonly"
77185
+ ]);
77186
+ const currentServices = doc.getIn(["google_accounts", account, "services"]);
77187
+ const currentServicesArr = readEnabledFor(currentServices);
77188
+ const unchanged = currentReadonly === selection.readonly && currentServicesArr.length === selection.services.length && currentServicesArr.every((s, i) => s === selection.services[i]);
77189
+ if (unchanged)
77190
+ return yamlText;
77191
+ doc.setIn(["google_accounts", account, "readonly"], selection.readonly);
77192
+ doc.setIn(["google_accounts", account, "services"], [...selection.services]);
77193
+ return String(doc);
77194
+ }
77036
77195
  function readEnabledFor(node) {
77037
77196
  if (!import_yaml6.isSeq(node))
77038
77197
  return [];
@@ -77068,7 +77227,7 @@ function registerAuthGoogleSubcommands(program3, authParent) {
77068
77227
  registerDisable(google, program3);
77069
77228
  registerList(google, program3);
77070
77229
  const account = google.command("account").description("Manage Google account credentials in the auth-broker (add / remove / list).");
77071
- registerAccountAdd(account);
77230
+ registerAccountAdd(account, program3);
77072
77231
  registerAccountRemove(account);
77073
77232
  registerAccountList(account);
77074
77233
  }
@@ -77328,15 +77487,17 @@ function expandAllAgents(agents, config) {
77328
77487
  function pad(s, width) {
77329
77488
  return s.length >= width ? s : s + " ".repeat(width - s.length);
77330
77489
  }
77331
- function registerAccountAdd(accountParent) {
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) => {
77490
+ function registerAccountAdd(accountParent, program3) {
77491
+ 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).option("--readonly", "Mint ONLY read-only scope variants for every selected service (zero write scopes), and run the gdrive MCP in upstream --read-only mode. Mutually exclusive with --write. Persisted per account and carried forward on --replace \u2014 a read-only account cannot silently re-widen.", false).option("--services <list>", "Comma-separated per-account service selection: cal,drive,docs,sheets,slides ('calendar' accepted for 'cal'). Selects BOTH the OAuth scopes minted AND the tools the gdrive MCP exposes (upstream --tools). Omitted = the tier's default services (drive,docs,sheets; +slides at extended/complete). Persisted per account and carried forward on --replace.").action(withConfigError(async (account, opts) => {
77333
77492
  const normalizedAccount = validateAndNormalizeAccountEmail(account);
77334
77493
  const [
77335
77494
  {
77336
77495
  runDriveOAuthFlow: runDriveOAuthFlow2,
77337
77496
  selectGoogleWorkspaceScopes: selectGoogleWorkspaceScopes2,
77338
77497
  capabilitiesFromScopeString: capabilitiesFromScopeString2,
77339
- resolveReconsentCapabilities: resolveReconsentCapabilities2
77498
+ resolveReconsentCapabilities: resolveReconsentCapabilities2,
77499
+ parseServicesOption: parseServicesOption2,
77500
+ resolveScopeSelection: resolveScopeSelection2
77340
77501
  },
77341
77502
  { selectInitialTier: selectInitialTier2 },
77342
77503
  { brokerCall: brokerCall2 },
@@ -77407,14 +77568,41 @@ function registerAccountAdd(accountParent) {
77407
77568
  clientSecretRaw = await resolveRef(clientSecretRaw, "google_client_secret");
77408
77569
  }
77409
77570
  const tier = gw.tier ?? "core";
77571
+ if (opts.readonly && opts.write) {
77572
+ throw new Error("--readonly and --write are mutually exclusive. A read-only " + "account mints zero write scopes; drop one of the flags.");
77573
+ }
77574
+ const requestedServices = opts.services ? parseServicesOption2(opts.services) : undefined;
77575
+ const persistedRecord = config.google_accounts?.[normalizedAccount];
77576
+ const persistedSelection = persistedRecord && (persistedRecord.readonly !== undefined || persistedRecord.services !== undefined) ? {
77577
+ readonly: persistedRecord.readonly,
77578
+ services: persistedRecord.services
77579
+ } : undefined;
77410
77580
  const existingEntry = (await brokerCall2(async (client) => client.listGoogleAccounts())).accounts.find((a) => a.account === normalizedAccount);
77411
77581
  const plan = resolveReconsentCapabilities2({ write: opts.write ?? false, calendar: opts.calendar ?? false }, existingEntry ? capabilitiesFromScopeString2(existingEntry.scope) : undefined);
77412
77582
  if (existingEntry && !opts.replace && plan.added.length > 0) {
77413
77583
  throw new Error(buildScopeReconsentRequiredError(normalizedAccount, plan.added));
77414
77584
  }
77585
+ if (existingEntry && !opts.replace && (opts.readonly || requestedServices)) {
77586
+ throw new Error(buildSelectionReconsentRequiredError(normalizedAccount, {
77587
+ readonly: opts.readonly ?? false,
77588
+ services: opts.services
77589
+ }));
77590
+ }
77591
+ const selPlan = resolveScopeSelection2({
77592
+ flags: {
77593
+ readonly: opts.readonly ?? false,
77594
+ write: opts.write ?? false,
77595
+ calendar: opts.calendar ?? false,
77596
+ services: requestedServices
77597
+ },
77598
+ persisted: persistedSelection,
77599
+ existing: plan.effective,
77600
+ tier
77601
+ });
77415
77602
  const accountScopes = selectGoogleWorkspaceScopes2({
77416
- write: plan.effective.write,
77417
- calendar: plan.effective.calendar,
77603
+ write: selPlan.selection.driveWrite,
77604
+ readonly: selPlan.selection.readonly,
77605
+ services: selPlan.selection.services,
77418
77606
  tier
77419
77607
  });
77420
77608
  const oauthCfg = {
@@ -77422,16 +77610,27 @@ function registerAccountAdd(accountParent) {
77422
77610
  client_secret: clientSecretRaw,
77423
77611
  scopes: accountScopes
77424
77612
  };
77425
- if (plan.effective.write) {
77613
+ if (selPlan.selection.readonly) {
77614
+ console.log(source_default.yellow(" READ-ONLY selection: minting only .readonly scope variants \u2014 zero write scopes."));
77615
+ }
77616
+ if (selPlan.selection.driveWrite && !selPlan.selection.readonly) {
77426
77617
  console.log(source_default.yellow(" Requesting Drive WRITE scope (drive.file \u2014 create/edit app-created files)."));
77427
77618
  }
77428
- if (plan.effective.calendar) {
77619
+ if (selPlan.selection.services.includes("cal")) {
77429
77620
  console.log(source_default.yellow(" Requesting Calendar READ-ONLY scope (calendar.readonly \u2014 list calendars, read events)."));
77430
77621
  }
77431
- for (const line of buildCarryForwardNotices(plan.carried)) {
77622
+ for (const d of selPlan.dropped) {
77623
+ console.log(source_default.yellow(` NOT carried forward: ${d}.`));
77624
+ }
77625
+ const carried = plan.carried.filter((k) => !(k === "write" && selPlan.selection.readonly) && !(k === "calendar" && !selPlan.selection.services.includes("cal")));
77626
+ for (const line of buildCarryForwardNotices(carried)) {
77432
77627
  console.log(source_default.gray(line));
77433
77628
  }
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."));
77629
+ if (selPlan.persist) {
77630
+ console.log(source_default.gray(` Service selection: ${selPlan.selection.services.join(", ")}` + (selPlan.selection.readonly ? " (read-only)" : "") + " \u2014 persisted per account and carried forward on --replace."));
77631
+ } else {
77632
+ 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."));
77633
+ }
77435
77634
  console.log(source_default.gray(` Changing the tier later requires re-running this command
77436
77635
  ` + " (`--replace`) \u2014 OAuth scopes are fixed at consent time."));
77437
77636
  const oauthEnv = {
@@ -77461,6 +77660,18 @@ function registerAccountAdd(accountParent) {
77461
77660
  });
77462
77661
  console.log();
77463
77662
  console.log(source_default.green(` \u2713 Registered Google account ${source_default.bold(normalizedAccount)} with auth-broker.`));
77663
+ if (selPlan.persist) {
77664
+ const configPath = getConfigPath(program3);
77665
+ const raw = readFileSync29(configPath, "utf-8");
77666
+ const patched = setGoogleAccountSelection(raw, normalizedAccount, {
77667
+ readonly: selPlan.persist.readonly ?? false,
77668
+ services: selPlan.persist.services ?? []
77669
+ });
77670
+ if (patched !== raw) {
77671
+ writeGoogleYaml(configPath, patched);
77672
+ console.log(source_default.gray(` Persisted selection to google_accounts.${normalizedAccount} ` + `(readonly: ${selPlan.persist.readonly ?? false}, services: ` + `${(selPlan.persist.services ?? []).join(",")}). Agents pick ` + `it up on their next restart.`));
77673
+ }
77674
+ }
77464
77675
  console.log();
77465
77676
  console.log(` Next: enable on one or more agents:`);
77466
77677
  console.log(source_default.cyan(` switchroom auth google enable ${normalizedAccount} <agent> [...]`));
@@ -77495,6 +77706,24 @@ function buildScopeReconsentRequiredError(account, added) {
77495
77706
  ].join(`
77496
77707
  `);
77497
77708
  }
77709
+ function buildSelectionReconsentRequiredError(account, requested) {
77710
+ const flags = [
77711
+ requested.readonly ? "--readonly" : "",
77712
+ requested.services ? `--services ${requested.services}` : ""
77713
+ ].filter((s) => s.length > 0).join(" ");
77714
+ return [
77715
+ `'${account}' is already registered with the auth-broker, and changing ` + `its readonly/services selection changes the OAuth scope set.`,
77716
+ "",
77717
+ "OAuth scopes are fixed at consent time \u2014 a stored token cannot be",
77718
+ "reshaped in place. Re-consent to mint a token with the new selection:",
77719
+ "",
77720
+ ` switchroom auth google account add ${account} --replace ${flags}`,
77721
+ "",
77722
+ "The persisted selection (and any capabilities the flags don't",
77723
+ "explicitly change) is carried forward, never silently widened."
77724
+ ].join(`
77725
+ `);
77726
+ }
77498
77727
  function buildCarryForwardNotices(carried) {
77499
77728
  return carried.map((k) => {
77500
77729
  const { flag, scope, summary } = CAPABILITY_LABELS[k];
@@ -96069,6 +96298,8 @@ init_agent_owned_tree();
96069
96298
  init_component_versions();
96070
96299
  var VERIFY_COMPONENTS_STEP = "verify-components";
96071
96300
  var ENSURE_BANKS_STEP = "ensure-banks";
96301
+ var ENSURE_BANKS_ATTEMPTS = 3;
96302
+ var ENSURE_BANKS_BACKOFF_MS = [2000, 5000];
96072
96303
  function normalizeVersion(v) {
96073
96304
  return v.trim().replace(/^v/, "");
96074
96305
  }
@@ -96390,25 +96621,47 @@ async function executeRollout(steps, target, deps, execOpts = {}) {
96390
96621
  break;
96391
96622
  }
96392
96623
  deps.log(`ROLL_STEP ensure-banks \u2014 creating any missing agent bank (idempotent) for ${rolled.length} agent(s)`);
96624
+ const sleep4 = deps.sleepMs ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
96625
+ const bankUnverified = [];
96626
+ let lastUnreachableReason = "";
96627
+ let concludedUnreachable = false;
96393
96628
  for (const a of rolled) {
96394
- let res;
96395
- try {
96396
- res = await deps.ensureBank(a);
96397
- } catch (e) {
96398
- res = { ok: false, reason: e.message };
96629
+ let res = {
96630
+ ok: false,
96631
+ reason: "not probed"
96632
+ };
96633
+ const maxAttempts = concludedUnreachable ? 1 : ENSURE_BANKS_ATTEMPTS;
96634
+ for (let attempt = 1;attempt <= maxAttempts; attempt++) {
96635
+ try {
96636
+ res = await deps.ensureBank(a);
96637
+ } catch (e) {
96638
+ res = { ok: false, reason: e.message };
96639
+ }
96640
+ if (res.ok || !/unreachable|timeout/i.test(res.reason))
96641
+ break;
96642
+ if (attempt < maxAttempts) {
96643
+ const backoff = ENSURE_BANKS_BACKOFF_MS[attempt - 1] ?? ENSURE_BANKS_BACKOFF_MS[ENSURE_BANKS_BACKOFF_MS.length - 1] ?? 0;
96644
+ deps.log(` ${a}: bank check ${res.reason.toLowerCase()} \u2014 retrying in ${backoff}ms ` + `(attempt ${attempt}/${maxAttempts})`);
96645
+ await sleep4(backoff);
96646
+ }
96399
96647
  }
96400
96648
  if (res.ok) {
96401
96649
  deps.log(` ${a}: bank present`);
96402
96650
  continue;
96403
96651
  }
96404
96652
  if (/unreachable|timeout/i.test(res.reason)) {
96405
- deps.log(` ${a}: bank check ${res.reason.toLowerCase()} (degraded to warning)`);
96406
- warnings.push(`ensure-banks could not reach Hindsight to verify ${a}'s bank ` + `(${res.reason}). If ${a} is brand-new and its first \`retain\` ` + `fails with a foreign-key error, restart it to recreate the bank.`);
96653
+ deps.log(` ${a}: bank check ${res.reason.toLowerCase()} after ` + `${maxAttempts} attempt(s) (transient \u2014 not treated as a missing bank)`);
96654
+ bankUnverified.push(a);
96655
+ lastUnreachableReason = res.reason;
96656
+ concludedUnreachable = true;
96407
96657
  continue;
96408
96658
  }
96409
96659
  deps.log(` \u2717 ${a}: bank could NOT be created \u2014 ${res.reason}`);
96410
96660
  bankEnsureFailed.push(a);
96411
96661
  }
96662
+ if (bankUnverified.length > 0) {
96663
+ deps.log(`ensure-banks: Hindsight was busy/unreachable (${lastUnreachableReason}) while ` + `verifying ${bankUnverified.length} bank(s) (${bankUnverified.join(", ")}) \u2014 ` + `existing banks are unaffected and nothing needs doing; a brand-new agent's ` + `bank is created automatically on its next restart-reconcile.`);
96664
+ }
96412
96665
  break;
96413
96666
  }
96414
96667
  case "verify-components": {
@@ -100251,6 +100504,59 @@ function findMissingWorkspaceScopes(seedScope, tier) {
100251
100504
  }
100252
100505
  var DRIVE_FILE_SCOPE = "https://www.googleapis.com/auth/drive.file";
100253
100506
  var CALENDAR_READONLY_SCOPE = "https://www.googleapis.com/auth/calendar.readonly";
100507
+ var LAUNCHER_GOOGLE_SERVICES = [
100508
+ "drive",
100509
+ "docs",
100510
+ "sheets",
100511
+ "slides",
100512
+ "cal"
100513
+ ];
100514
+ var UPSTREAM_SERVICE_NAMES = {
100515
+ drive: "drive",
100516
+ docs: "docs",
100517
+ sheets: "sheets",
100518
+ slides: "slides",
100519
+ cal: "calendar"
100520
+ };
100521
+ function parseLauncherServices(raw) {
100522
+ const tokens = raw.split(",").map((s) => s.trim().toLowerCase()).filter((s) => s.length > 0).map((s) => s === "calendar" ? "cal" : s);
100523
+ for (const t of tokens) {
100524
+ if (!LAUNCHER_GOOGLE_SERVICES.includes(t)) {
100525
+ throw new Error(`drive-mcp-launcher: unknown service '${t}' in --services ` + `(valid: ${LAUNCHER_GOOGLE_SERVICES.join(", ")})`);
100526
+ }
100527
+ }
100528
+ const set = new Set(tokens);
100529
+ return LAUNCHER_GOOGLE_SERVICES.filter((s) => set.has(s));
100530
+ }
100531
+ function requiredScopesForSelection(selection) {
100532
+ const ro = selection.readonly;
100533
+ const out = [];
100534
+ for (const s of selection.services) {
100535
+ switch (s) {
100536
+ case "docs":
100537
+ out.push(ro ? "https://www.googleapis.com/auth/documents.readonly" : "https://www.googleapis.com/auth/documents");
100538
+ break;
100539
+ case "sheets":
100540
+ out.push(ro ? "https://www.googleapis.com/auth/spreadsheets.readonly" : "https://www.googleapis.com/auth/spreadsheets");
100541
+ break;
100542
+ case "slides":
100543
+ out.push(ro ? "https://www.googleapis.com/auth/presentations.readonly" : "https://www.googleapis.com/auth/presentations");
100544
+ break;
100545
+ case "cal":
100546
+ out.push(CALENDAR_READONLY_SCOPE);
100547
+ break;
100548
+ case "drive":
100549
+ break;
100550
+ }
100551
+ }
100552
+ return out;
100553
+ }
100554
+ function findMissingScopesForSelection(seedScope, tier, selection) {
100555
+ if (!selection)
100556
+ return findMissingWorkspaceScopes(seedScope, tier);
100557
+ const have = new Set(seedScope.split(/\s+/).map((s) => s.trim()).filter((s) => s.length > 0));
100558
+ return requiredScopesForSelection(selection).filter((s) => !have.has(s));
100559
+ }
100254
100560
  function seedOptInCapabilities(seedScope) {
100255
100561
  const have = new Set(seedScope.split(/\s+/).map((s) => s.trim()).filter((s) => s.length > 0));
100256
100562
  return {
@@ -100258,20 +100564,22 @@ function seedOptInCapabilities(seedScope) {
100258
100564
  calendar: have.has(CALENDAR_READONLY_SCOPE)
100259
100565
  };
100260
100566
  }
100261
- function buildMissingScopeWarning(missing, tier, accountEmail, granted) {
100567
+ function buildMissingScopeWarning(missing, tier, accountEmail, granted, selection) {
100262
100568
  const short = missing.map((s) => s.replace(/^https:\/\/www\.googleapis\.com\/auth\//, "")).join(", ");
100263
- const suffix = (granted.write ? " --write" : "") + (granted.calendar ? " --calendar" : "");
100569
+ const suffix = selection ? (selection.readonly ? " --readonly" : granted.write ? " --write" : "") + ` --services ${selection.services.join(",")}` : (granted.write ? " --write" : "") + (granted.calendar ? " --calendar" : "");
100264
100570
  const preserved = [
100265
- granted.write ? "--write preserves the existing Drive write capability" : "",
100266
- granted.calendar ? "--calendar preserves the existing Calendar read capability" : ""
100571
+ selection ? `--services ${selection.services.join(",")}` + `${selection.readonly ? " --readonly" : ""} preserves the persisted per-account selection` : "",
100572
+ !selection && granted.write ? "--write preserves the existing Drive write capability" : "",
100573
+ !selection && granted.calendar ? "--calendar preserves the existing Calendar read capability" : ""
100267
100574
  ].filter((s) => s.length > 0);
100268
- return `drive-mcp-launcher: WARNING \u2014 the Google account '${accountEmail}' was ` + `consented WITHOUT the scope(s) needed for tier '${tier ?? "core"}': ` + `${short}.
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:
100575
+ const scopeSource = selection ? `for its persisted selection (services: ${selection.services.join(",")}` + `${selection.readonly ? ", read-only" : ""})` : `for tier '${tier ?? "core"}'`;
100576
+ return `drive-mcp-launcher: WARNING \u2014 the Google account '${accountEmail}' was ` + `consented WITHOUT the scope(s) needed ${scopeSource}: ` + `${short}.
100577
+ ` + ` The matching MCP tools 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:
100270
100578
  ` + ` 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.
100579
+ ` + ` (${selection ? "" : "scopes are derived from `google_workspace.tier` \u2014 set the tier before re-running"}${preserved.length > 0 ? (selection ? "" : "; ") + preserved.join("; ") : ""}). Drive read/file tools are unaffected.
100272
100580
  `;
100273
100581
  }
100274
- function buildUvxArgs(tier) {
100582
+ function buildUvxArgs(tier, selection) {
100275
100583
  const args = [
100276
100584
  "--from",
100277
100585
  `git+https://github.com/taylorwilsdon/google_workspace_mcp.git@${GOOGLE_WORKSPACE_MCP_PINNED_SHA}`,
@@ -100285,6 +100593,12 @@ function buildUvxArgs(tier) {
100285
100593
  if (tier && tier.length > 0) {
100286
100594
  args.push("--tool-tier", tier);
100287
100595
  }
100596
+ if (selection && selection.services.length > 0) {
100597
+ args.push("--tools", ...selection.services.map((s) => UPSTREAM_SERVICE_NAMES[s]));
100598
+ }
100599
+ if (selection?.readonly) {
100600
+ args.push("--read-only");
100601
+ }
100288
100602
  return args;
100289
100603
  }
100290
100604
  function buildChildEnv(baseEnv, credentialsDir, accountEmail) {
@@ -100457,6 +100771,15 @@ async function resolveConfigSecret2(raw, label) {
100457
100771
  }
100458
100772
  throw new Error(`${label} vault key '${key}' unreachable via the vault-broker (${result.msg}). Refusing to spawn the Google Workspace MCP.`);
100459
100773
  }
100774
+ function resolveLauncherSelection(record2, tier) {
100775
+ if (!record2)
100776
+ return;
100777
+ if (record2.readonly === undefined && record2.services === undefined) {
100778
+ return;
100779
+ }
100780
+ const services = record2.services && record2.services.length > 0 ? parseLauncherServices(record2.services.join(",")) : tier === "extended" || tier === "complete" ? ["drive", "docs", "sheets", "slides"] : ["drive", "docs", "sheets"];
100781
+ return { readonly: record2.readonly ?? false, services };
100782
+ }
100460
100783
  async function loadConfigSecrets() {
100461
100784
  const { loadConfig: loadConfig2 } = await Promise.resolve().then(() => (init_loader(), exports_loader));
100462
100785
  const config = loadConfig2();
@@ -100466,7 +100789,16 @@ async function loadConfigSecrets() {
100466
100789
  }
100467
100790
  const clientId = await resolveConfigSecret2(gw.google_client_id, "google_client_id");
100468
100791
  const clientSecret = await resolveConfigSecret2(gw.google_client_secret, "google_client_secret");
100469
- return { clientId, clientSecret, tier: gw.tier };
100792
+ const googleAccounts = {};
100793
+ for (const [email, record2] of Object.entries(config.google_accounts ?? {})) {
100794
+ if (!record2)
100795
+ continue;
100796
+ googleAccounts[email.toLowerCase()] = {
100797
+ readonly: record2.readonly,
100798
+ services: record2.services
100799
+ };
100800
+ }
100801
+ return { clientId, clientSecret, tier: gw.tier, googleAccounts };
100470
100802
  }
100471
100803
  async function runDriveMcpLauncher(opts) {
100472
100804
  let brokerCreds;
@@ -100514,11 +100846,27 @@ async function runDriveMcpLauncher(opts) {
100514
100846
  process.exit(1);
100515
100847
  }
100516
100848
  const tier = opts.tier ?? configSecrets.tier;
100517
- const missingScopes = findMissingWorkspaceScopes(brokerCreds.scope, tier);
100849
+ let selection;
100850
+ try {
100851
+ const configSelection = resolveLauncherSelection(configSecrets.googleAccounts[brokerCreds.accountEmail.toLowerCase()], tier);
100852
+ if (opts.services !== undefined || opts.readOnly) {
100853
+ selection = {
100854
+ readonly: opts.readOnly ?? configSelection?.readonly ?? false,
100855
+ services: opts.services !== undefined ? parseLauncherServices(opts.services) : configSelection?.services ?? resolveLauncherSelection({ readonly: false }, tier).services
100856
+ };
100857
+ } else {
100858
+ selection = configSelection;
100859
+ }
100860
+ } catch (err) {
100861
+ process.stderr.write(`drive-mcp-launcher: ${err instanceof Error ? err.message : String(err)}
100862
+ `);
100863
+ process.exit(1);
100864
+ }
100865
+ const missingScopes = findMissingScopesForSelection(brokerCreds.scope, tier, selection);
100518
100866
  if (missingScopes.length > 0) {
100519
- process.stderr.write(buildMissingScopeWarning(missingScopes, tier, brokerCreds.accountEmail, seedOptInCapabilities(brokerCreds.scope)));
100867
+ process.stderr.write(buildMissingScopeWarning(missingScopes, tier, brokerCreds.accountEmail, seedOptInCapabilities(brokerCreds.scope), selection));
100520
100868
  }
100521
- const args = buildUvxArgs(tier);
100869
+ const args = buildUvxArgs(tier, selection);
100522
100870
  const env2 = buildChildEnv(process.env, credentialsDir, brokerCreds.accountEmail);
100523
100871
  const { spawn: spawn6 } = await import("node:child_process");
100524
100872
  const os6 = await import("node:os");
@@ -100550,8 +100898,12 @@ async function runDriveMcpLauncher(opts) {
100550
100898
  return new Promise(() => {});
100551
100899
  }
100552
100900
  function registerDriveMcpLauncherCommand(program3) {
100553
- program3.command("drive-mcp-launcher", { hidden: true }).description("Internal: launch the Google Workspace MCP browserless, fed by the auth-broker. Spawned as the `gdrive` MCP command inside agents.").option("--tier <tier>", "Upstream --tool-tier (core|extended|complete). Overrides config.").action(async (opts) => {
100554
- await runDriveMcpLauncher({ tier: opts.tier });
100901
+ program3.command("drive-mcp-launcher", { hidden: true }).description("Internal: launch the Google Workspace MCP browserless, fed by the auth-broker. Spawned as the `gdrive` MCP command inside agents.").option("--tier <tier>", "Upstream --tool-tier (core|extended|complete). Overrides config.").option("--services <list>", "Comma-separated per-account service selection (cal,drive,docs,sheets,slides) \u2192 upstream --tools. Overrides the persisted google_accounts.<email>.services record.").option("--read-only", "Run upstream in --read-only mode (readonly scopes, write tools not registered). Overrides the persisted google_accounts.<email>.readonly record.").action(async (opts) => {
100902
+ await runDriveMcpLauncher({
100903
+ tier: opts.tier,
100904
+ services: opts.services,
100905
+ readOnly: opts.readOnly
100906
+ });
100555
100907
  });
100556
100908
  }
100557
100909