switchroom 0.13.53 → 0.13.55

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.
@@ -10997,7 +10997,8 @@ var AgentMemorySchema = exports_external.object({
10997
10997
  recall: exports_external.object({
10998
10998
  max_memories: exports_external.number().int().min(0).optional().describe("Cap on the number of memories injected into the prompt by " + "auto-recall, regardless of token budget. Plugin default is 12. " + "0 disables the cap (all memories Hindsight returns are injected)."),
10999
10999
  cache_ttl_secs: exports_external.number().int().min(0).optional().describe("Per-session recall cache TTL in seconds. When > 0, identical " + "(prompt, bank) within the same session reuse the cached recall " + "result instead of round-tripping to Hindsight. 0 disables. " + "Default is 600 (10 min) for switchroom-managed agents."),
11000
- min_overlap: exports_external.number().min(0).max(1).optional().describe("Minimum Jaccard token overlap [0.0–1.0] between the user " + "prompt and a memory's text for the memory to be injected. " + "Drops low-relevance matches before the count cap so weak hits " + "don't fill the slot on real queries. 0.0 disables (default — " + "current behaviour). Try 0.10–0.20 to start; observe the " + "`overlap_dropped` field via `switchroom memory recall-log`.")
11000
+ min_overlap: exports_external.number().min(0).max(1).optional().describe("Minimum Jaccard token overlap [0.0–1.0] between the user " + "prompt and a memory's text for the memory to be injected. " + "Drops low-relevance matches before the count cap so weak hits " + "don't fill the slot on real queries. 0.0 disables (default — " + "current behaviour). Try 0.10–0.20 to start; observe the " + "`overlap_dropped` field via `switchroom memory recall-log`."),
11001
+ topic_filter_mode: exports_external.enum(["soft-preamble", "hard-filter"]).optional().describe("Supergroup-mode cross-topic memory behaviour. Default " + "(unset) → soft-preamble: recall returns memories from all " + "topics, and a 'Current topic: …' preamble tells the model " + "to self-scope. hard-filter: drop any recalled memory whose " + "metadata.thread_id differs from the active inbound's topic. " + "Flip to hard-filter when the recall_log shows binding " + "failures (model surfacing the right memory but applying " + "it to the wrong topic).")
11001
11002
  }).optional().describe("Auto-recall tuning knobs")
11002
11003
  }).optional();
11003
11004
  var HookEntrySchema = exports_external.object({
@@ -11140,6 +11141,16 @@ var MicrosoftWorkspaceConfigSchema = exports_external.object({
11140
11141
  authority: exports_external.string().url().optional().describe("Microsoft authority endpoint. Defaults to " + "'https://login.microsoftonline.com/common' which accepts both " + "personal MSA and work/school tenants. Override only for " + "single-tenant deployments."),
11141
11142
  org_mode: exports_external.boolean().optional().describe("Opt-in to Teams + SharePoint surfaces (RFC §6.4). When true, " + "the v1 scope set adds Sites.ReadWrite.All AND the launcher " + "spawns softeria with --org-mode. Defaults to false — personal " + "MSA + standard work surfaces only. Flipping for an existing " + "consented account requires re-running 'auth microsoft account " + "add --replace' to consent the additional scope.")
11142
11143
  }).optional();
11144
+ var NotionWorkspaceConfigSchema = exports_external.object({
11145
+ vault_key: exports_external.string().min(1).default("notion/integration-token").describe("Vault key holding the Notion internal-integration token. Default " + "`notion/integration-token`. Override only for non-standard vault " + "layouts. The broker's --allow ACL on this key is the authoritative " + "list of which agents may receive the token."),
11146
+ databases: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,62}$/, {
11147
+ message: "notion_workspace.databases friendly names must match " + "/^[a-z0-9][a-z0-9_-]{0,62}$/ — lowercase letters, digits, " + "hyphens, underscores. Got: '%s'."
11148
+ }), exports_external.string().regex(/^[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{12}$/, {
11149
+ message: "notion_workspace.databases values must be Notion database " + "UUIDs (32 hex characters, optional dashes)."
11150
+ })).default({}).describe("Friendly-name → Notion database UUID map. Operator-managed; agents " + "reference databases by friendly name only — they never see or type " + "UUIDs. Populate via `switchroom notion list-dbs` (PR 4) after " + "vault-putting the integration token and sharing DBs with the " + "integration in Notion's UI."),
11151
+ mcp_version: exports_external.string().min(1).optional().describe("Optional pin for the upstream `@notionhq/notion-mcp-server` npm " + "package version. Default is the build-time `NOTION_MCP_PINNED_VERSION` " + "constant. Override only when reproducing operator-specific bugs."),
11152
+ rate_limit_rps: exports_external.number().int().positive().max(10).optional().describe("Optional global rate-limit budget in requests per second across all " + "switchroom agents sharing this integration token. Defaults to 3 " + "(Notion's documented public-API limit). Lower it if you also use " + "the integration token from outside switchroom and need to share " + "budget. Higher than 10 is rejected — if you think you need it, " + "your usage probably needs a workspace-tier upgrade with Notion.")
11153
+ }).optional();
11143
11154
  var AgentGoogleWorkspaceConfigSchema = exports_external.object({
11144
11155
  account: exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
11145
11156
  message: "google_workspace.account must be a Google account email like " + "'alice@example.com' (colons not allowed)"
@@ -11153,6 +11164,13 @@ var AgentMicrosoftWorkspaceConfigSchema = exports_external.object({
11153
11164
  }).transform((v) => v.trim().toLowerCase()).optional().describe("RFC #1873: the Microsoft account this agent uses for the M365 MCP. " + "Must be a key in top-level `microsoft_accounts:` with this agent " + "listed in its `enabled_for[]`. Read by the auth-broker " + "(get-credentials, provider=microsoft) and by the scaffold to " + "decide whether to emit the `ms-365` MCP entry. Normalized to " + "lowercase so it matches the microsoft_accounts key (which is " + "also normalized)."),
11154
11165
  org_mode: exports_external.boolean().optional().describe("Per-agent org_mode override (RFC #1873 §6.4). When set, replaces " + "the top-level microsoft_workspace.org_mode for this agent. " + "Defaults to top-level value (which defaults to false).")
11155
11166
  }).optional();
11167
+ var AgentNotionWorkspaceConfigSchema = exports_external.object({
11168
+ databases: exports_external.array(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,62}$/, {
11169
+ message: "notion_workspace.databases entries must be friendly names " + "matching /^[a-z0-9][a-z0-9_-]{0,62}$/ — these must appear as " + "keys in top-level notion_workspace.databases."
11170
+ })).min(1, {
11171
+ message: "notion_workspace.databases must list at least one friendly " + "name. An empty list rejects every Notion tool call — if you " + "want to remove this agent's Notion access, delete the entire " + "notion_workspace block instead."
11172
+ }).optional().describe("Optional per-agent allowlist of database friendly names this " + "agent may read/write. Each name must exist as a key in top-level " + "notion_workspace.databases. Omit the field (or leave it undefined) " + "to grant access to every DB the upstream integration can see — " + "appropriate for an admin/orchestrator agent. Set the list to " + "narrow access for specialist agents.")
11173
+ }).optional();
11156
11174
  var ReactionsSchema = exports_external.object({
11157
11175
  enabled: exports_external.boolean().optional().describe("Master switch for the reaction-trigger path. When false, " + "reactions are still persisted via recordReaction but never " + "dispatched to the agent as synthetic inbound turns. Default true."),
11158
11176
  trigger_emojis: exports_external.array(exports_external.string()).optional().describe("Emoji allowlist that triggers a synthetic inbound when reacted " + "to a bot message. Default ['\uD83D\uDC4E', '❌', '\uD83D\uDC4D', '✅']. Cascade " + "mode: REPLACE (not union) — setting this at a layer replaces " + "lower layers entirely, so an operator can narrow to [] to " + "disable triggering without flipping `enabled`."),
@@ -11289,6 +11307,7 @@ var AgentSchema = exports_external.object({
11289
11307
  drive: AgentGoogleWorkspaceConfigSchema.describe("RFC D legacy key — use `google_workspace:` instead. Per-agent " + "google_workspace overrides (currently approvers + tier). When set, " + "replaces the top-level approvers list for this agent. " + "google_client_id/secret are not per-agent — they live at the top level."),
11290
11308
  google_workspace: AgentGoogleWorkspaceConfigSchema.describe("RFC G canonical key. Per-agent Google Workspace overrides — currently " + "approvers (replaces, does not extend the top-level list) and tier " + "(`core` | `extended` | `complete`, replaces top-level default). " + "google_client_id/secret are not per-agent — they live at the top level. " + "Mutually exclusive with `drive:` on the same agent (loader fails fast " + "if both are set)."),
11291
11309
  microsoft_workspace: AgentMicrosoftWorkspaceConfigSchema.describe("RFC #1873 (Microsoft 365 integration). Per-agent Microsoft Workspace " + "override — pins the Microsoft account this agent reads via the " + "auth-broker (must be a key in top-level `microsoft_accounts:` with " + "this agent in its `enabled_for[]`) and optionally overrides org_mode. " + "microsoft_client_id/secret are not per-agent."),
11310
+ notion_workspace: AgentNotionWorkspaceConfigSchema.describe("RFC docs/rfcs/notion-integration.md. Per-agent Notion access. " + "Presence opts the agent IN (launcher scaffolded, MCP entry emitted, " + "broker grants the integration token). Optional `databases:` filter " + "narrows which DBs this agent may read/write — names must resolve in " + "top-level notion_workspace.databases. Absence opts the agent OUT."),
11292
11311
  repos: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9-]*$/, "Repo slug must be kebab-case ASCII: start with a lowercase letter or digit, contain only lowercase letters, digits, and hyphens"), exports_external.object({
11293
11312
  url: exports_external.string().min(1).describe("Git remote URL for the repo (e.g. 'git@github.com:org/repo.git' or " + "'https://github.com/org/repo.git'). Used verbatim for git clone."),
11294
11313
  branch_default: exports_external.string().optional().describe("Default branch to track (defaults to the remote's HEAD, typically 'main'). " + "The per-agent branch 'agent/<agentName>/main' fast-forwards to this branch " + "when the worktree is clean on session start.")
@@ -11412,6 +11431,7 @@ var SwitchroomConfigSchema = exports_external.object({
11412
11431
  drive: GoogleWorkspaceConfigSchema.describe("RFC D legacy key — use `google_workspace:` instead. Optional Google " + "Workspace onboarding configuration. When set, supplies Google OAuth " + "client credentials, the approver allowlist for `switchroom drive " + "connect`, and the optional tier knob. Env vars " + "(SWITCHROOM_GOOGLE_CLIENT_ID, SWITCHROOM_GOOGLE_CLIENT_SECRET, " + "SWITCHROOM_APPROVER_USER_ID) take precedence over this block when " + "set, preserving back-compat with the env-only flow shipped in #766."),
11413
11432
  google_workspace: GoogleWorkspaceConfigSchema.describe("RFC G canonical key. Top-level Google Workspace configuration — " + "OAuth client credentials, approver allowlist, and tier knob (`core` " + "| `extended` | `complete`, default `core`). Mutually exclusive with " + "`drive:` at the top level (loader fails fast if both are set)."),
11414
11433
  microsoft_workspace: MicrosoftWorkspaceConfigSchema.describe("RFC #1873 (Microsoft 365 integration). Top-level Microsoft Workspace " + "configuration — OAuth client credentials (Entra app), authority " + "endpoint (defaults to /common for personal MSA + work), and the " + "org_mode opt-in for Teams/SharePoint surfaces. Block is optional; " + "when omitted the broker does not register the Microsoft provider."),
11434
+ notion_workspace: NotionWorkspaceConfigSchema.describe("RFC docs/rfcs/notion-integration.md. Top-level Notion integration " + "config — vault key for the integration token, friendly-name → " + "database UUID map, optional MCP-package version pin, and optional " + "global rate-limit override (default 3 rps, Notion's documented " + "public-API limit). Block is optional; when omitted no agent gets a " + "Notion MCP entry regardless of per-agent config."),
11415
11435
  quota: QuotaConfigSchema.optional().describe("Optional weekly/monthly USD spend budgets rendered in the session " + "greeting. Usage is read from ccusage at runtime; no network calls."),
11416
11436
  host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (docs/rfcs/host-control-daemon.md). Omit the block " + "to accept defaults; set `enabled: false` only on legacy systemd-" + "mode installs (removal tracked as RFC C Phase 3)."),
11417
11437
  hostd: HostdConfigSchema.default({}).describe("hostd verb-level knobs (RFC admin-agent-config-edit). Distinct " + "from `host_control:` which governs whether the daemon runs at " + "all. Currently scopes the opt-in flag and rate cap for the new " + "`config_propose_edit` verb (PR 1a — disabled by default)."),
@@ -11907,6 +11927,34 @@ function mergeAgentConfig(defaultsIn, agentIn) {
11907
11927
  mergeAgentConfig.notifiedWorkerIsolationMove = false;
11908
11928
  })(mergeAgentConfig ||= {});
11909
11929
 
11930
+ // src/config/notion-workspace-acl.ts
11931
+ function validateNotionWorkspaceConfig(config) {
11932
+ const issues = [];
11933
+ const dbMap = config.notion_workspace?.databases ?? {};
11934
+ const known = new Set(Object.keys(dbMap));
11935
+ for (const [agentName, agentRaw] of Object.entries(config.agents ?? {})) {
11936
+ if (!agentRaw)
11937
+ continue;
11938
+ const dbFilter = agentRaw.notion_workspace?.databases;
11939
+ if (dbFilter === undefined)
11940
+ continue;
11941
+ if (dbFilter.length === 0) {
11942
+ issues.push(` agents.${agentName}.notion_workspace.databases is an empty ` + `list. Delete the entire notion_workspace block to remove Notion ` + `access, or list at least one database friendly name.`);
11943
+ continue;
11944
+ }
11945
+ if (config.notion_workspace === undefined) {
11946
+ issues.push(` agents.${agentName}.notion_workspace is set but the top-level ` + `notion_workspace block is missing. Configure the integration ` + `globally first (vault_key + databases map), then grant per-agent ` + `access.`);
11947
+ continue;
11948
+ }
11949
+ for (const name of dbFilter) {
11950
+ if (!known.has(name)) {
11951
+ issues.push(` agents.${agentName}.notion_workspace.databases references ` + `unknown database "${name}". Add it to top-level ` + `notion_workspace.databases (run \`switchroom notion list-dbs\` ` + `to see the UUIDs the integration can read), or remove the ` + `reference.`);
11952
+ }
11953
+ }
11954
+ }
11955
+ return issues;
11956
+ }
11957
+
11910
11958
  // src/config/loader.ts
11911
11959
  class ConfigError extends Error {
11912
11960
  details;
@@ -12026,6 +12074,10 @@ function loadConfig(configPath) {
12026
12074
  }
12027
12075
  applyAgentOverlays(config);
12028
12076
  validateAllCronTopicAliases(config, filePath);
12077
+ const notionIssues = validateNotionWorkspaceConfig(config);
12078
+ if (notionIssues.length > 0) {
12079
+ throw new ConfigError(`Invalid notion_workspace configuration in ${filePath}`, notionIssues);
12080
+ }
12029
12081
  return config;
12030
12082
  }
12031
12083
  function validateAllCronTopicAliases(config, filePath) {
@@ -10997,7 +10997,8 @@ var AgentMemorySchema = exports_external.object({
10997
10997
  recall: exports_external.object({
10998
10998
  max_memories: exports_external.number().int().min(0).optional().describe("Cap on the number of memories injected into the prompt by " + "auto-recall, regardless of token budget. Plugin default is 12. " + "0 disables the cap (all memories Hindsight returns are injected)."),
10999
10999
  cache_ttl_secs: exports_external.number().int().min(0).optional().describe("Per-session recall cache TTL in seconds. When > 0, identical " + "(prompt, bank) within the same session reuse the cached recall " + "result instead of round-tripping to Hindsight. 0 disables. " + "Default is 600 (10 min) for switchroom-managed agents."),
11000
- min_overlap: exports_external.number().min(0).max(1).optional().describe("Minimum Jaccard token overlap [0.0–1.0] between the user " + "prompt and a memory's text for the memory to be injected. " + "Drops low-relevance matches before the count cap so weak hits " + "don't fill the slot on real queries. 0.0 disables (default — " + "current behaviour). Try 0.10–0.20 to start; observe the " + "`overlap_dropped` field via `switchroom memory recall-log`.")
11000
+ min_overlap: exports_external.number().min(0).max(1).optional().describe("Minimum Jaccard token overlap [0.0–1.0] between the user " + "prompt and a memory's text for the memory to be injected. " + "Drops low-relevance matches before the count cap so weak hits " + "don't fill the slot on real queries. 0.0 disables (default — " + "current behaviour). Try 0.10–0.20 to start; observe the " + "`overlap_dropped` field via `switchroom memory recall-log`."),
11001
+ topic_filter_mode: exports_external.enum(["soft-preamble", "hard-filter"]).optional().describe("Supergroup-mode cross-topic memory behaviour. Default " + "(unset) → soft-preamble: recall returns memories from all " + "topics, and a 'Current topic: …' preamble tells the model " + "to self-scope. hard-filter: drop any recalled memory whose " + "metadata.thread_id differs from the active inbound's topic. " + "Flip to hard-filter when the recall_log shows binding " + "failures (model surfacing the right memory but applying " + "it to the wrong topic).")
11001
11002
  }).optional().describe("Auto-recall tuning knobs")
11002
11003
  }).optional();
11003
11004
  var HookEntrySchema = exports_external.object({
@@ -11140,6 +11141,16 @@ var MicrosoftWorkspaceConfigSchema = exports_external.object({
11140
11141
  authority: exports_external.string().url().optional().describe("Microsoft authority endpoint. Defaults to " + "'https://login.microsoftonline.com/common' which accepts both " + "personal MSA and work/school tenants. Override only for " + "single-tenant deployments."),
11141
11142
  org_mode: exports_external.boolean().optional().describe("Opt-in to Teams + SharePoint surfaces (RFC §6.4). When true, " + "the v1 scope set adds Sites.ReadWrite.All AND the launcher " + "spawns softeria with --org-mode. Defaults to false — personal " + "MSA + standard work surfaces only. Flipping for an existing " + "consented account requires re-running 'auth microsoft account " + "add --replace' to consent the additional scope.")
11142
11143
  }).optional();
11144
+ var NotionWorkspaceConfigSchema = exports_external.object({
11145
+ vault_key: exports_external.string().min(1).default("notion/integration-token").describe("Vault key holding the Notion internal-integration token. Default " + "`notion/integration-token`. Override only for non-standard vault " + "layouts. The broker's --allow ACL on this key is the authoritative " + "list of which agents may receive the token."),
11146
+ databases: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,62}$/, {
11147
+ message: "notion_workspace.databases friendly names must match " + "/^[a-z0-9][a-z0-9_-]{0,62}$/ — lowercase letters, digits, " + "hyphens, underscores. Got: '%s'."
11148
+ }), exports_external.string().regex(/^[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{12}$/, {
11149
+ message: "notion_workspace.databases values must be Notion database " + "UUIDs (32 hex characters, optional dashes)."
11150
+ })).default({}).describe("Friendly-name → Notion database UUID map. Operator-managed; agents " + "reference databases by friendly name only — they never see or type " + "UUIDs. Populate via `switchroom notion list-dbs` (PR 4) after " + "vault-putting the integration token and sharing DBs with the " + "integration in Notion's UI."),
11151
+ mcp_version: exports_external.string().min(1).optional().describe("Optional pin for the upstream `@notionhq/notion-mcp-server` npm " + "package version. Default is the build-time `NOTION_MCP_PINNED_VERSION` " + "constant. Override only when reproducing operator-specific bugs."),
11152
+ rate_limit_rps: exports_external.number().int().positive().max(10).optional().describe("Optional global rate-limit budget in requests per second across all " + "switchroom agents sharing this integration token. Defaults to 3 " + "(Notion's documented public-API limit). Lower it if you also use " + "the integration token from outside switchroom and need to share " + "budget. Higher than 10 is rejected — if you think you need it, " + "your usage probably needs a workspace-tier upgrade with Notion.")
11153
+ }).optional();
11143
11154
  var AgentGoogleWorkspaceConfigSchema = exports_external.object({
11144
11155
  account: exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
11145
11156
  message: "google_workspace.account must be a Google account email like " + "'alice@example.com' (colons not allowed)"
@@ -11153,6 +11164,13 @@ var AgentMicrosoftWorkspaceConfigSchema = exports_external.object({
11153
11164
  }).transform((v) => v.trim().toLowerCase()).optional().describe("RFC #1873: the Microsoft account this agent uses for the M365 MCP. " + "Must be a key in top-level `microsoft_accounts:` with this agent " + "listed in its `enabled_for[]`. Read by the auth-broker " + "(get-credentials, provider=microsoft) and by the scaffold to " + "decide whether to emit the `ms-365` MCP entry. Normalized to " + "lowercase so it matches the microsoft_accounts key (which is " + "also normalized)."),
11154
11165
  org_mode: exports_external.boolean().optional().describe("Per-agent org_mode override (RFC #1873 §6.4). When set, replaces " + "the top-level microsoft_workspace.org_mode for this agent. " + "Defaults to top-level value (which defaults to false).")
11155
11166
  }).optional();
11167
+ var AgentNotionWorkspaceConfigSchema = exports_external.object({
11168
+ databases: exports_external.array(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,62}$/, {
11169
+ message: "notion_workspace.databases entries must be friendly names " + "matching /^[a-z0-9][a-z0-9_-]{0,62}$/ — these must appear as " + "keys in top-level notion_workspace.databases."
11170
+ })).min(1, {
11171
+ message: "notion_workspace.databases must list at least one friendly " + "name. An empty list rejects every Notion tool call — if you " + "want to remove this agent's Notion access, delete the entire " + "notion_workspace block instead."
11172
+ }).optional().describe("Optional per-agent allowlist of database friendly names this " + "agent may read/write. Each name must exist as a key in top-level " + "notion_workspace.databases. Omit the field (or leave it undefined) " + "to grant access to every DB the upstream integration can see — " + "appropriate for an admin/orchestrator agent. Set the list to " + "narrow access for specialist agents.")
11173
+ }).optional();
11156
11174
  var ReactionsSchema = exports_external.object({
11157
11175
  enabled: exports_external.boolean().optional().describe("Master switch for the reaction-trigger path. When false, " + "reactions are still persisted via recordReaction but never " + "dispatched to the agent as synthetic inbound turns. Default true."),
11158
11176
  trigger_emojis: exports_external.array(exports_external.string()).optional().describe("Emoji allowlist that triggers a synthetic inbound when reacted " + "to a bot message. Default ['\uD83D\uDC4E', '❌', '\uD83D\uDC4D', '✅']. Cascade " + "mode: REPLACE (not union) — setting this at a layer replaces " + "lower layers entirely, so an operator can narrow to [] to " + "disable triggering without flipping `enabled`."),
@@ -11289,6 +11307,7 @@ var AgentSchema = exports_external.object({
11289
11307
  drive: AgentGoogleWorkspaceConfigSchema.describe("RFC D legacy key — use `google_workspace:` instead. Per-agent " + "google_workspace overrides (currently approvers + tier). When set, " + "replaces the top-level approvers list for this agent. " + "google_client_id/secret are not per-agent — they live at the top level."),
11290
11308
  google_workspace: AgentGoogleWorkspaceConfigSchema.describe("RFC G canonical key. Per-agent Google Workspace overrides — currently " + "approvers (replaces, does not extend the top-level list) and tier " + "(`core` | `extended` | `complete`, replaces top-level default). " + "google_client_id/secret are not per-agent — they live at the top level. " + "Mutually exclusive with `drive:` on the same agent (loader fails fast " + "if both are set)."),
11291
11309
  microsoft_workspace: AgentMicrosoftWorkspaceConfigSchema.describe("RFC #1873 (Microsoft 365 integration). Per-agent Microsoft Workspace " + "override — pins the Microsoft account this agent reads via the " + "auth-broker (must be a key in top-level `microsoft_accounts:` with " + "this agent in its `enabled_for[]`) and optionally overrides org_mode. " + "microsoft_client_id/secret are not per-agent."),
11310
+ notion_workspace: AgentNotionWorkspaceConfigSchema.describe("RFC docs/rfcs/notion-integration.md. Per-agent Notion access. " + "Presence opts the agent IN (launcher scaffolded, MCP entry emitted, " + "broker grants the integration token). Optional `databases:` filter " + "narrows which DBs this agent may read/write — names must resolve in " + "top-level notion_workspace.databases. Absence opts the agent OUT."),
11292
11311
  repos: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9-]*$/, "Repo slug must be kebab-case ASCII: start with a lowercase letter or digit, contain only lowercase letters, digits, and hyphens"), exports_external.object({
11293
11312
  url: exports_external.string().min(1).describe("Git remote URL for the repo (e.g. 'git@github.com:org/repo.git' or " + "'https://github.com/org/repo.git'). Used verbatim for git clone."),
11294
11313
  branch_default: exports_external.string().optional().describe("Default branch to track (defaults to the remote's HEAD, typically 'main'). " + "The per-agent branch 'agent/<agentName>/main' fast-forwards to this branch " + "when the worktree is clean on session start.")
@@ -11412,6 +11431,7 @@ var SwitchroomConfigSchema = exports_external.object({
11412
11431
  drive: GoogleWorkspaceConfigSchema.describe("RFC D legacy key — use `google_workspace:` instead. Optional Google " + "Workspace onboarding configuration. When set, supplies Google OAuth " + "client credentials, the approver allowlist for `switchroom drive " + "connect`, and the optional tier knob. Env vars " + "(SWITCHROOM_GOOGLE_CLIENT_ID, SWITCHROOM_GOOGLE_CLIENT_SECRET, " + "SWITCHROOM_APPROVER_USER_ID) take precedence over this block when " + "set, preserving back-compat with the env-only flow shipped in #766."),
11413
11432
  google_workspace: GoogleWorkspaceConfigSchema.describe("RFC G canonical key. Top-level Google Workspace configuration — " + "OAuth client credentials, approver allowlist, and tier knob (`core` " + "| `extended` | `complete`, default `core`). Mutually exclusive with " + "`drive:` at the top level (loader fails fast if both are set)."),
11414
11433
  microsoft_workspace: MicrosoftWorkspaceConfigSchema.describe("RFC #1873 (Microsoft 365 integration). Top-level Microsoft Workspace " + "configuration — OAuth client credentials (Entra app), authority " + "endpoint (defaults to /common for personal MSA + work), and the " + "org_mode opt-in for Teams/SharePoint surfaces. Block is optional; " + "when omitted the broker does not register the Microsoft provider."),
11434
+ notion_workspace: NotionWorkspaceConfigSchema.describe("RFC docs/rfcs/notion-integration.md. Top-level Notion integration " + "config — vault key for the integration token, friendly-name → " + "database UUID map, optional MCP-package version pin, and optional " + "global rate-limit override (default 3 rps, Notion's documented " + "public-API limit). Block is optional; when omitted no agent gets a " + "Notion MCP entry regardless of per-agent config."),
11415
11435
  quota: QuotaConfigSchema.optional().describe("Optional weekly/monthly USD spend budgets rendered in the session " + "greeting. Usage is read from ccusage at runtime; no network calls."),
11416
11436
  host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (docs/rfcs/host-control-daemon.md). Omit the block " + "to accept defaults; set `enabled: false` only on legacy systemd-" + "mode installs (removal tracked as RFC C Phase 3)."),
11417
11437
  hostd: HostdConfigSchema.default({}).describe("hostd verb-level knobs (RFC admin-agent-config-edit). Distinct " + "from `host_control:` which governs whether the daemon runs at " + "all. Currently scopes the opt-in flag and rate cap for the new " + "`config_propose_edit` verb (PR 1a — disabled by default)."),
@@ -11907,6 +11927,34 @@ function mergeAgentConfig(defaultsIn, agentIn) {
11907
11927
  mergeAgentConfig.notifiedWorkerIsolationMove = false;
11908
11928
  })(mergeAgentConfig ||= {});
11909
11929
 
11930
+ // src/config/notion-workspace-acl.ts
11931
+ function validateNotionWorkspaceConfig(config) {
11932
+ const issues = [];
11933
+ const dbMap = config.notion_workspace?.databases ?? {};
11934
+ const known = new Set(Object.keys(dbMap));
11935
+ for (const [agentName, agentRaw] of Object.entries(config.agents ?? {})) {
11936
+ if (!agentRaw)
11937
+ continue;
11938
+ const dbFilter = agentRaw.notion_workspace?.databases;
11939
+ if (dbFilter === undefined)
11940
+ continue;
11941
+ if (dbFilter.length === 0) {
11942
+ issues.push(` agents.${agentName}.notion_workspace.databases is an empty ` + `list. Delete the entire notion_workspace block to remove Notion ` + `access, or list at least one database friendly name.`);
11943
+ continue;
11944
+ }
11945
+ if (config.notion_workspace === undefined) {
11946
+ issues.push(` agents.${agentName}.notion_workspace is set but the top-level ` + `notion_workspace block is missing. Configure the integration ` + `globally first (vault_key + databases map), then grant per-agent ` + `access.`);
11947
+ continue;
11948
+ }
11949
+ for (const name of dbFilter) {
11950
+ if (!known.has(name)) {
11951
+ issues.push(` agents.${agentName}.notion_workspace.databases references ` + `unknown database "${name}". Add it to top-level ` + `notion_workspace.databases (run \`switchroom notion list-dbs\` ` + `to see the UUIDs the integration can read), or remove the ` + `reference.`);
11952
+ }
11953
+ }
11954
+ }
11955
+ return issues;
11956
+ }
11957
+
11910
11958
  // src/config/loader.ts
11911
11959
  class ConfigError extends Error {
11912
11960
  details;
@@ -12026,6 +12074,10 @@ function loadConfig(configPath) {
12026
12074
  }
12027
12075
  applyAgentOverlays(config);
12028
12076
  validateAllCronTopicAliases(config, filePath);
12077
+ const notionIssues = validateNotionWorkspaceConfig(config);
12078
+ if (notionIssues.length > 0) {
12079
+ throw new ConfigError(`Invalid notion_workspace configuration in ${filePath}`, notionIssues);
12080
+ }
12029
12081
  return config;
12030
12082
  }
12031
12083
  function validateAllCronTopicAliases(config, filePath) {
@@ -0,0 +1,259 @@
1
+ import { createRequire } from "node:module";
2
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
3
+
4
+ // src/cli/ms-365-write-pretool.ts
5
+ import { readFileSync } from "node:fs";
6
+ import { createConnection } from "node:net";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { randomBytes } from "node:crypto";
10
+ var HOOK_TIMEOUT_MS = 5 * 60 * 1000;
11
+ var KERNEL_POLL_INTERVAL_MS = 2000;
12
+ var IPC_CONNECT_TIMEOUT_MS = 3000;
13
+ var IPC_REPLY_TIMEOUT_MS = 1e4;
14
+ var KERNEL_RPC_TIMEOUT_MS = 3000;
15
+ var GATEWAY_SOCKET = process.env.SWITCHROOM_GATEWAY_SOCKET ?? (process.env.TELEGRAM_STATE_DIR !== undefined ? join(process.env.TELEGRAM_STATE_DIR, "gateway.sock") : join(homedir(), ".claude", "channels", "telegram", "gateway.sock"));
16
+ var KERNEL_SOCKET = process.env.SWITCHROOM_KERNEL_SOCKET ?? "/run/switchroom/kernel/sock";
17
+ var TOOL_PREFIX = "mcp__ms-365__";
18
+ var GATED_MS365_WRITE_TOOLS = new Set([
19
+ "upload-file-content",
20
+ "create-upload-session",
21
+ "create-event",
22
+ "update-event",
23
+ "delete-event",
24
+ "update-message",
25
+ "delete-message"
26
+ ]);
27
+ function isGatedMs365Tool(toolName) {
28
+ if (!toolName.startsWith(TOOL_PREFIX))
29
+ return false;
30
+ return GATED_MS365_WRITE_TOOLS.has(toolName.slice(TOOL_PREFIX.length));
31
+ }
32
+ function readStdin() {
33
+ try {
34
+ return readFileSync(0, "utf8");
35
+ } catch {
36
+ return "";
37
+ }
38
+ }
39
+ function parseHookInput() {
40
+ const raw = readStdin();
41
+ if (!raw)
42
+ return null;
43
+ try {
44
+ const obj = JSON.parse(raw);
45
+ return obj && typeof obj === "object" ? obj : null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+ function extractMs365Preview(toolName, toolInput) {
51
+ const o = toolInput && typeof toolInput === "object" ? toolInput : {};
52
+ let itemId = "(new)";
53
+ for (const k of ["itemId", "item_id", "id", "driveItemId", "eventId", "messageId", "message_id"]) {
54
+ if (typeof o[k] === "string" && o[k].length > 0) {
55
+ itemId = o[k];
56
+ break;
57
+ }
58
+ }
59
+ let itemDisplayName = "(unknown)";
60
+ for (const k of ["name", "fileName", "file_name", "displayName", "subject", "title"]) {
61
+ if (typeof o[k] === "string" && o[k].length > 0) {
62
+ itemDisplayName = o[k];
63
+ break;
64
+ }
65
+ }
66
+ let deepLink;
67
+ for (const k of ["webUrl", "web_url", "url", "link"]) {
68
+ if (typeof o[k] === "string" && o[k].startsWith("http")) {
69
+ deepLink = o[k];
70
+ break;
71
+ }
72
+ }
73
+ let sizeBytesAfter;
74
+ for (const k of ["contentSize", "content_size", "fileSize", "size"]) {
75
+ if (typeof o[k] === "number" && Number.isFinite(o[k])) {
76
+ sizeBytesAfter = o[k];
77
+ break;
78
+ }
79
+ }
80
+ if (sizeBytesAfter === undefined && typeof o.content === "string") {
81
+ const len = o.content.length;
82
+ sizeBytesAfter = Math.floor(len * 3 / 4);
83
+ }
84
+ return { itemId, itemDisplayName, deepLink, sizeBytesAfter };
85
+ }
86
+ async function sendGatewayRequest(socket, payload, matchType, correlationId) {
87
+ return new Promise((resolve) => {
88
+ const conn = createConnection(socket);
89
+ let buf = "";
90
+ const cleanup = () => {
91
+ try {
92
+ conn.destroy();
93
+ } catch {}
94
+ };
95
+ const replyTimer = setTimeout(() => {
96
+ cleanup();
97
+ resolve({ ok: false, reason: "gateway reply timeout" });
98
+ }, IPC_REPLY_TIMEOUT_MS);
99
+ const connectTimer = setTimeout(() => {
100
+ cleanup();
101
+ resolve({ ok: false, reason: "gateway connect timeout" });
102
+ }, IPC_CONNECT_TIMEOUT_MS);
103
+ conn.once("error", (err) => {
104
+ clearTimeout(replyTimer);
105
+ clearTimeout(connectTimer);
106
+ resolve({ ok: false, reason: `gateway: ${err.message}` });
107
+ });
108
+ conn.once("connect", () => {
109
+ clearTimeout(connectTimer);
110
+ conn.write(JSON.stringify(payload) + `
111
+ `);
112
+ });
113
+ conn.on("data", (chunk) => {
114
+ buf += chunk.toString("utf8");
115
+ while (true) {
116
+ const lineEnd = buf.indexOf(`
117
+ `);
118
+ if (lineEnd === -1)
119
+ break;
120
+ const line = buf.slice(0, lineEnd);
121
+ buf = buf.slice(lineEnd + 1);
122
+ try {
123
+ const parsed = JSON.parse(line);
124
+ if (parsed?.type === matchType && parsed?.correlationId === correlationId) {
125
+ clearTimeout(replyTimer);
126
+ cleanup();
127
+ resolve({ ok: true, value: parsed });
128
+ return;
129
+ }
130
+ } catch {}
131
+ }
132
+ });
133
+ });
134
+ }
135
+ async function rpcKernel(payload) {
136
+ return new Promise((resolve) => {
137
+ const conn = createConnection(KERNEL_SOCKET);
138
+ let buf = "";
139
+ const cleanup = () => {
140
+ try {
141
+ conn.destroy();
142
+ } catch {}
143
+ };
144
+ const timer = setTimeout(() => {
145
+ cleanup();
146
+ resolve({ ok: false, reason: "kernel rpc timeout" });
147
+ }, KERNEL_RPC_TIMEOUT_MS);
148
+ conn.once("error", (err) => {
149
+ clearTimeout(timer);
150
+ resolve({ ok: false, reason: `kernel: ${err.message}` });
151
+ });
152
+ conn.once("connect", () => {
153
+ conn.write(JSON.stringify(payload) + `
154
+ `);
155
+ });
156
+ conn.on("data", (chunk) => {
157
+ buf += chunk.toString("utf8");
158
+ const lineEnd = buf.indexOf(`
159
+ `);
160
+ if (lineEnd === -1)
161
+ return;
162
+ const line = buf.slice(0, lineEnd);
163
+ clearTimeout(timer);
164
+ cleanup();
165
+ try {
166
+ resolve({ ok: true, value: JSON.parse(line) });
167
+ } catch (err) {
168
+ resolve({ ok: false, reason: `kernel parse: ${String(err)}` });
169
+ }
170
+ });
171
+ });
172
+ }
173
+ async function approvalLookup(agentUnit, scope, approverSet) {
174
+ const res = await rpcKernel({
175
+ v: 1,
176
+ op: "approval_lookup",
177
+ agent_unit: agentUnit,
178
+ scope,
179
+ action: "write",
180
+ current_approver_set: approverSet
181
+ });
182
+ if (!res.ok)
183
+ return null;
184
+ return res.value;
185
+ }
186
+ function fail(reason) {
187
+ process.stdout.write(JSON.stringify({
188
+ decision: "block",
189
+ reason: `ms-365 write blocked: ${reason}`
190
+ }));
191
+ process.exit(0);
192
+ }
193
+ function allow() {
194
+ process.exit(0);
195
+ }
196
+ async function main() {
197
+ const input = parseHookInput();
198
+ if (!input)
199
+ fail("malformed stdin");
200
+ const toolName = input.tool_name;
201
+ if (typeof toolName !== "string" || !isGatedMs365Tool(toolName)) {
202
+ allow();
203
+ }
204
+ const agentName = process.env.SWITCHROOM_AGENT_NAME;
205
+ if (!agentName) {
206
+ allow();
207
+ }
208
+ const accountEmail = process.env.SWITCHROOM_MICROSOFT_ACCOUNT ?? "(unknown)";
209
+ const extract = extractMs365Preview(toolName, input.tool_input);
210
+ const preview = {
211
+ agentName,
212
+ toolName,
213
+ itemId: extract.itemId,
214
+ itemDisplayName: extract.itemDisplayName,
215
+ accountEmail,
216
+ deepLink: extract.deepLink,
217
+ sizeBytesAfter: extract.sizeBytesAfter
218
+ };
219
+ const correlationId = randomBytes(16).toString("hex");
220
+ const requestResult = await sendGatewayRequest(GATEWAY_SOCKET, {
221
+ type: "request_ms365_approval",
222
+ correlationId,
223
+ agentName,
224
+ preview,
225
+ ttlMs: HOOK_TIMEOUT_MS
226
+ }, "ms365_approval_posted", correlationId);
227
+ if (!requestResult.ok)
228
+ fail(requestResult.reason);
229
+ const response = requestResult.value;
230
+ if (!response.ok || !response.requestId) {
231
+ fail(response.reason ?? "gateway returned ok=false");
232
+ }
233
+ const deadline = response.expiresAtMs ?? Date.now() + HOOK_TIMEOUT_MS;
234
+ const scope = `ms-365:write:${preview.itemId}`;
235
+ while (Date.now() < deadline) {
236
+ await new Promise((r) => setTimeout(r, KERNEL_POLL_INTERVAL_MS));
237
+ const lookup = await approvalLookup(agentName, scope, []);
238
+ if (!lookup)
239
+ continue;
240
+ const state = lookup.state;
241
+ if (state === "granted")
242
+ allow();
243
+ if (state === "denied" || state === "drift_revoked" || state === "expired") {
244
+ fail(`operator ${state}`);
245
+ }
246
+ }
247
+ fail("approval timed out");
248
+ }
249
+ if (__require.main == __require.module) {
250
+ main().catch((err) => {
251
+ const m = err instanceof Error ? err.message : String(err);
252
+ fail(`hook error: ${m}`);
253
+ });
254
+ }
255
+ export {
256
+ isGatedMs365Tool,
257
+ extractMs365Preview,
258
+ GATED_MS365_WRITE_TOOLS
259
+ };