@zackbart/connecta 0.33.0 → 0.34.1

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.
Files changed (131) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +100 -128
  3. package/SECURITY.md +1 -1
  4. package/bin/version.mjs +1 -1
  5. package/dist/activity.d.ts +3 -0
  6. package/dist/branding.d.ts +7 -0
  7. package/dist/branding.js +76 -9
  8. package/dist/catalog-drift.d.ts +30 -26
  9. package/dist/catalog-drift.js +169 -84
  10. package/dist/catalog-service.js +16 -2
  11. package/dist/config-value-sources.js +6 -1
  12. package/dist/config.d.ts +13 -2
  13. package/dist/config.js +36 -2
  14. package/dist/connector-caller.js +2 -1
  15. package/dist/connectors/api-connector.d.ts +3 -0
  16. package/dist/connectors/api-connector.js +5 -1
  17. package/dist/connectors/option-shapes.js +1 -1
  18. package/dist/connectors/remote-mcp.d.ts +2 -0
  19. package/dist/connectors/remote-mcp.js +5 -1
  20. package/dist/describe-config.d.ts +5 -1
  21. package/dist/describe-config.js +2 -0
  22. package/dist/execute.js +1 -11
  23. package/dist/fix-prompt.js +2 -2
  24. package/dist/index.js +1 -0
  25. package/dist/invocation.js +3 -2
  26. package/dist/membership.d.ts +2 -0
  27. package/dist/meta-tools.js +3 -2
  28. package/dist/oauth-callback-outcome.js +2 -2
  29. package/dist/operator-ui/agents.d.ts +1 -1
  30. package/dist/operator-ui/agents.js +16 -16
  31. package/dist/operator-ui/config-summary.js +565 -0
  32. package/dist/operator-ui/connection-map.js +376 -0
  33. package/dist/operator-ui/endpoints.js +45 -16
  34. package/dist/operator-ui/fix-prompts.js +1 -1
  35. package/dist/operator-ui/generated.js +4 -4
  36. package/dist/operator-ui/health.js +52 -0
  37. package/dist/operator-ui/member-page.js +13 -28
  38. package/dist/operator-ui/overview.js +123 -0
  39. package/dist/operator-ui/view.d.ts +26 -22
  40. package/dist/operator-ui/view.js +130 -96
  41. package/dist/page-styles.js +5 -3
  42. package/dist/provider.d.ts +6 -1
  43. package/dist/provider.js +18 -1
  44. package/dist/providers/_shared/google/workspace.d.ts +9 -4
  45. package/dist/providers/_shared/google/workspace.js +7 -3
  46. package/dist/providers/basecamp/index.d.ts +2 -0
  47. package/dist/providers/breeze/index.d.ts +2 -0
  48. package/dist/providers/ccb/index.d.ts +2 -0
  49. package/dist/providers/ccb/index.js +2 -2
  50. package/dist/providers/cloudflare/index.d.ts +2 -0
  51. package/dist/providers/cloudflare/index.js +5 -1
  52. package/dist/providers/cloudflare/openapi.generated.js +3296 -3206
  53. package/dist/providers/cloudflare/rest.js +19 -0
  54. package/dist/providers/cloudflare/skill.generated.js +2 -2
  55. package/dist/providers/cloudflare/value-safety.js +51 -18
  56. package/dist/providers/docs/index.js +1 -1
  57. package/dist/providers/docs/skill.generated.js +2 -2
  58. package/dist/providers/drive/index.js +1 -1
  59. package/dist/providers/drive/skill.generated.js +2 -2
  60. package/dist/providers/forms/index.js +1 -1
  61. package/dist/providers/forms/skill.generated.js +2 -2
  62. package/dist/providers/gmail/index.js +1 -1
  63. package/dist/providers/gmail/skill.generated.js +2 -2
  64. package/dist/providers/intercom/index.d.ts +24 -0
  65. package/dist/providers/intercom/index.js +121 -0
  66. package/dist/providers/intercom/skill.generated.js +8 -0
  67. package/dist/providers/linear/index.js +204 -19
  68. package/dist/providers/linear/skill.generated.js +2 -2
  69. package/dist/providers/mixpanel/index.d.ts +2 -0
  70. package/dist/providers/mixpanel/index.js +21 -17
  71. package/dist/providers/mixpanel/skill.generated.js +3 -3
  72. package/dist/providers/notion/index.d.ts +2 -0
  73. package/dist/providers/notion/index.js +1 -1
  74. package/dist/providers/notion/skill.generated.js +1 -1
  75. package/dist/providers/overflow/index.d.ts +2 -0
  76. package/dist/providers/overflow/index.js +1 -1
  77. package/dist/providers/overflow/skill.generated.js +2 -2
  78. package/dist/providers/planning-center/index.d.ts +2 -0
  79. package/dist/providers/planning-center/index.js +1 -1
  80. package/dist/providers/planning-center/skill.generated.js +4 -4
  81. package/dist/providers/revenuecat/index.d.ts +5 -3
  82. package/dist/providers/revenuecat/index.js +147 -7
  83. package/dist/providers/revenuecat/skill.generated.js +3 -3
  84. package/dist/providers/sheets/index.js +1 -1
  85. package/dist/providers/sheets/skill.generated.js +2 -2
  86. package/dist/providers/slides/index.js +1 -1
  87. package/dist/providers/slides/skill.generated.js +2 -2
  88. package/dist/providers/stripe/index.d.ts +2 -0
  89. package/dist/providers/stripe/index.js +50 -30
  90. package/dist/providers/stripe/skill.generated.js +2 -1
  91. package/dist/providers/tithely/index.d.ts +2 -0
  92. package/dist/providers/tithely/index.js +1 -1
  93. package/dist/providers/vercel/index.d.ts +2 -0
  94. package/dist/providers/vercel/index.js +2 -2
  95. package/dist/providers/vercel/openapi.generated.js +391 -374
  96. package/dist/providers/vercel/skill.generated.js +2 -2
  97. package/dist/providers/vercel/value-safety.js +14 -4
  98. package/dist/registry.d.ts +10 -14
  99. package/dist/registry.js +77 -62
  100. package/dist/routes/mcp.js +20 -13
  101. package/dist/routes/oauth.js +2 -2
  102. package/dist/routes/ui-data.js +8 -2
  103. package/dist/skills.js +18 -2
  104. package/dist/storage/sql.js +5 -1
  105. package/dist/types.d.ts +36 -11
  106. package/dist/ui.js +9 -4
  107. package/dist/usage-guide.js +1 -1
  108. package/dist/version.d.ts +1 -1
  109. package/dist/version.js +1 -1
  110. package/documentation/README.md +2 -2
  111. package/documentation/architecture.md +66 -18
  112. package/documentation/auth.md +183 -67
  113. package/documentation/code-mode.md +30 -10
  114. package/documentation/deploying.md +5 -1
  115. package/documentation/integrating.md +64 -7
  116. package/documentation/meta-tools.md +38 -24
  117. package/documentation/migration-0.33.md +21 -13
  118. package/documentation/migration-0.34.md +67 -0
  119. package/documentation/operating.md +1 -1
  120. package/documentation/operator-tests.md +52 -20
  121. package/documentation/operator-ui.md +341 -104
  122. package/documentation/provider-auth-migration.md +22 -0
  123. package/examples/worker/AGENTS.md +15 -9
  124. package/examples/worker/README.md +41 -14
  125. package/examples/worker/src/connecta.config.ts +5 -4
  126. package/examples/worker/wrangler.jsonc +1 -1
  127. package/package.json +23 -19
  128. package/templates/node/AGENTS.md +13 -7
  129. package/templates/node/README.md +14 -11
  130. package/templates/node/package.json +1 -1
  131. package/templates/node/src/connecta.config.ts +2 -2
@@ -3,8 +3,8 @@ export const skill = {
3
3
  "content": "- Resolve team, project, and deployment ids with list reads; never guess one. Team ids begin `team_`, project ids `prj_`, and deployment ids `dpl_`.\n- Diagnose a deployment by reading its state first (`readyState`, `errorCode`, `errorMessage`), then its build logs, then runtime logs for a deployment that built. Narrow time windows before raising limits.\n- Environment variable changes apply only to future deployments; a new deployment is a separate write. Promoting an existing deployment to production does not rebuild it.\n- Treat every create, update, delete, promotion, cancellation, and purchase as a write. For guest calls, classification, and routing, fetch `skills({ name: \"usage\" })`.\n",
4
4
  "instructionsHeading": "Account instructions",
5
5
  "fragments": {
6
- "oauth": "\n\nTool names, descriptions, argument schemas, and result schemas come from Vercel's live server. Connecta applies release-reviewed classification; reviewed writes stay writes even when Vercel claims they only read.\n\n- Discover the live catalog before assuming a tool exists. Vercel can change the surface independently of a Connecta release, and account features may affect what the authorization can reach.\n- Complete authorization and grant access to the owning team before acting. Listing this server does not prove team access.\n- MCP pagination and arguments follow the live MCP schemas; do not apply REST pagination or argument shapes to them.\n- Diagnose deployments with `get_deployment`, then build logs, runtime error clusters, and runtime logs.\n- `get_project_env` and `filter_project_envs` can return decrypted environment values, so Connecta reviews them as writes, and a read-only endpoint refuses them. Prefer reading variable keys and targets without values.\n- Purchase tools change billing. Read a quote first and carry its price, idempotency key, and requested term into the confirmed purchase unchanged.\n- `get_access_to_vercel_url` creates a temporary access grant. Treat the URL it returns as a credential and do not expose it outside the requested task.\n- `deploy_to_vercel` and `import-claude-design-from-url` can create or update live projects. Read the target and deployment mode before invoking them.\n- An `auth_required` failure means this connector's OAuth grant is missing or expired. Run `authorize_connector` for this connector id, then retry.\n",
7
- "key": "\n\n- This is Connecta's REST connector to `api.vercel.com`, holding one operator-managed access token. A default team routes calls; it does not narrow what the token can reach.\n- `vercel_api_search` finds API operations, not records: it searches the pinned API index and names the tool that calls each operation. `vercel_api_details` returns one operation's parameters with types, enums, and required names. Neither sends a request.\n- `vercel_api_read` calls GET operations, plus reviewed POSTs that only read: `POST /v2/observability/query`, registrar availability, price, and search, and `POST /v8/artifacts`. `vercel_api_write` calls every other POST, PUT, PATCH, and DELETE, with `method` and `path` stated; a read-only endpoint refuses it.\n- Paths are concrete and carry the version search returned: `/v13/deployments/dpl_123`, not `/deployments/dpl_123`. Pass `query` and `body` as JSON objects named as `vercel_api_details` lists them. Every call is checked against the pinned index before it is sent: an unknown path returns the nearest operations, and an unknown or missing parameter returns `validation.issues` with the accepted names. Nothing reached Vercel, so fix the call from that answer.\n- Results arrive as `{ status, data, page? }`. On a list, `page.next` is the cursor and `page.param` the query parameter to pass it as: `until` (a millisecond timestamp from `pagination.next`) on most lists, `cursor` on newer ones. Lists belong inside `execute_code`; pass `select` (dot paths such as `deployments.uid` and `deployments.state`) to keep only the fields the question needs.\n- `list_teams` returns team ids and slugs and the connection's default team. `get_deployment_build_logs` reads a deployment's build events without following live output. `get_runtime_logs` collects the runtime log stream for at most `waitMs` or `maxRows`, then stops and says why. Generic reads refuse `follow` and the runtime-log stream, which never end on their own.\n- `vercel_api_upload` sends explicit text or base64 bytes to an octet-stream operation, such as `POST /v2/files` before a deployment (Connecta computes `x-vercel-digest`). `vercel_api_write` sends JSON only.\n- Environment values never come back. `list_project_env_vars` never decrypts. `upsert_project_env_var` (`upsert: false` for create-only), `update_project_env_var`, and `delete_project_env_var` return metadata without values; values are write input only. Generic reads refuse `GET /v1/projects/{idOrName}/env/{id}`, `GET /v10/projects/{idOrName}/env`, `GET /v1/env/{id}`, and any `decrypt`, and Connecta replaces the value of every environment variable in any result with `\"[redacted]\"`, including project reads, shared variables, and audit events.\n- Other secrets read as `[redacted]` in every result: credential-named fields and their whole contents (`token`, `secret`, `password`, `jwt`, `credentials`, `Authorization`, …), protection-bypass secrets (each bypass's metadata stays under a numbered placeholder), deploy hook URLs, drain and webhook headers and signing secrets (including the one a new webhook or drain returns), drain and webhook destination URLs and external route, rewrite, and redirect destinations beyond their scheme and host, the args and values of every header, cookie, and query rule (route transforms and conditions, firewall conditions), team invite codes, synced Global Config items, and credentials inside other URLs. Failures of every reviewed operation, and of any operation whose request takes a credential-named field, say only the status and a reviewed code. Read or rotate withheld secrets in the Vercel dashboard. A `[redacted]` field is never the real value; do not send it back.\n- Refused by design: endpoints that mint credentials (access tokens, AI Gateway API keys, project OIDC and trace tokens, Connect and SSO tokens, Edge Config tokens, KMS-signed tokens, Flags SDK keys, protection-bypass secrets, installation credential rotation), Connect authorization requests, project transfer requests (the code lets another team claim the project), KMS signing, a domain move-out (its answer is a transfer token), Global Config items, backups, and tokens, and domain transfer auth codes. Each refusal names its reason.\n- The hosted OAuth connection's documentation search, runtime error clusters, toolbar threads, agent runs, web analytics summaries, purchase quotes, `deploy_to_vercel`, and access grants for protected URLs do not exist with a token. Configure an OAuth connector for those.\n- A `429` carries Vercel's reset time; Vercel meters endpoints separately, so wait before retrying the same operation. An `auth_required` failure means the token is missing, expired, or outside this team: an operator must replace it in this connection in the operator UI, and `authorize_connector` returns that handoff. A rejected argument comes back in Vercel's own words; read it rather than replacing the token.\n- Embedded URL safety uses reviewed paths plus global signing parameters. Generic credential-named query parameters (`code`, `key`, `token`, `sig`, etc.) and credential-looking fragments withhold the destination's entire query/fragment only in deployment `errorMessage`, build-event `payload.text`/`text`, and runtime-log `message` on the reviewed deployment/log operations and vendor error text. Ordinary `code`/`key` links can therefore be false positives in these diagnostics. Deployment metadata and other application content keep those ordinary URL parameters byte for byte. Literal AWS SigV4/Google V4 `X-Amz-*`/`X-Goog-*` Signature, Credential and Security-Token values are withheld in every decoded string before projection. A withheld value is not the original: do not write it back. Deployment creation also inspects decoded `files[].data` when `encoding` is `base64` (one decode bounded by the field's encoded length and the host's request limit; malformed base64 is refused). All writes, including named writes and supported raw/base64/multipart uploads, reject URL destinations whose own query/fragment contains the exact `[redacted]` placeholder, plain or encoded, with `invalid_args` before dispatch. Admission iteratively checks every JSON key and value within the host's accepted request size, without separate depth or node-count ceilings; cyclic direct arguments are refused. A marker outside the destination remains ordinary text. Split strings, encoded host/signing names, bare hosts without `//`, and opaque bearer paths remain outside this scan.\n",
6
+ "oauth": "\n\nTool names, descriptions, argument schemas, and result schemas come from Vercel's live server. Connecta applies release-reviewed classification; reviewed writes stay writes even when Vercel claims they only read.\n\n- Discover the live catalog before assuming a tool exists. Vercel can change the surface independently of a Connecta release, and account features may affect what the authorization can reach.\n- Complete authorization and grant access to the owning team before acting. Listing this server does not prove team access.\n- MCP pagination and arguments follow the live MCP schemas; do not apply REST pagination or argument shapes to them.\n- Diagnose deployments with `get_deployment`, then build logs, runtime error clusters, and runtime logs.\n- `get_project_env` and `filter_project_envs` can return decrypted environment values, so Connecta reviews them as writes, and a read-only endpoint refuses them. Prefer reading variable keys and targets without values.\n- Purchase tools change billing. Read a quote first and carry its price, idempotency key, and requested term into the confirmed purchase unchanged.\n- `get_access_to_vercel_url` creates a temporary access grant. Treat the URL it returns as a credential and do not expose it outside the requested task.\n- `web_fetch_vercel_url` runs application code and is reviewed as destructive; the toolbar reply, reaction, edit, and resolve tools are writes.\n- `deploy_to_vercel` and `import-claude-design-from-url` can create or update live projects. Read the target and deployment mode before invoking them.\n- An `auth_required` failure means this connector's OAuth grant is missing or expired. Run `authorize_connector` for this connector id, then retry.\n",
7
+ "key": "\n\n- This is Connecta's REST connector to `api.vercel.com`, holding one operator-managed access token. A default team routes calls; it does not narrow what the token can reach.\n- `vercel_api_search` finds API operations, not records: it searches the pinned API index and names the tool that calls each operation. `vercel_api_details` returns one operation's parameters with types, enums, and required names. Neither sends a request.\n- `vercel_api_read` calls GET operations, plus reviewed POSTs that only read: `POST /v2/observability/query`, registrar availability, price, and search, and `POST /v8/artifacts`. `vercel_api_write` calls every other POST, PUT, PATCH, and DELETE, with `method` and `path` stated; a read-only endpoint refuses it.\n- Paths are concrete and carry the version search returned: `/v13/deployments/dpl_123`, not `/deployments/dpl_123`. Pass `query` and `body` as JSON objects named as `vercel_api_details` lists them. Every call is checked against the pinned index before it is sent: an unknown path returns the nearest operations, and an unknown or missing parameter returns `validation.issues` with the accepted names. Nothing reached Vercel, so fix the call from that answer.\n- Results arrive as `{ status, data, page? }`. On a list, `page.next` is the cursor and `page.param` the query parameter to pass it as: `until` (a millisecond timestamp from `pagination.next`) on most lists, `cursor` on newer ones. Lists belong inside `execute_code`; pass `select` (dot paths such as `deployments.uid` and `deployments.state`) to keep only the fields the question needs.\n- `list_teams` returns team ids and slugs and the connection's default team. `get_deployment_build_logs` reads a deployment's build events without following live output. `get_runtime_logs` collects the runtime log stream for at most `waitMs` or `maxRows`, then stops and says why. Generic reads refuse `follow` and the runtime-log stream, which never end on their own.\n- `vercel_api_upload` sends explicit text or base64 bytes to an octet-stream operation, such as `POST /v2/files` before a deployment (Connecta computes `x-vercel-digest`). `vercel_api_write` sends JSON only.\n- Environment values never come back. `list_project_env_vars` never decrypts. `upsert_project_env_var` (`upsert: false` for create-only), `update_project_env_var`, and `delete_project_env_var` return metadata without values; values are write input only. Generic reads refuse `GET /v1/projects/{idOrName}/env/{id}`, `GET /v10/projects/{idOrName}/env`, `GET /v1/env/{id}`, and any `decrypt`, and Connecta replaces the value of every environment variable in any result with `\"[redacted]\"`, including project reads, shared variables, and audit events.\n- Other secrets read as `[redacted]` in every result: credential-named fields and their whole contents (`token`, `secret`, `password`, `jwt`, `credentials`, `Authorization`, …), protection-bypass secrets (each bypass's metadata stays under a numbered placeholder), deploy hook URLs, daemon liveness-probe header values and internal endpoint ciphertext, drain and webhook headers and signing secrets (including the one a new webhook or drain returns), drain and webhook destination URLs and external route, rewrite, and redirect destinations beyond their scheme and host, the args and values of every header, cookie, and query rule (route transforms and conditions, firewall conditions), conditional firewall bypass compiled `condition` expressions and their credential match values, team invite codes, synced Global Config items, and credentials inside other URLs. Failures of every reviewed operation, and of any operation whose request takes a credential-named field, say only the status and a reviewed code. Read or rotate withheld secrets in the Vercel dashboard. A `[redacted]` field is never the real value; do not send it back.\n- Refused by design: endpoints that mint credentials (access tokens, AI Gateway API keys, project OIDC and trace tokens, Connect and SSO tokens, Edge Config tokens, KMS-signed tokens, Flags SDK keys, protection-bypass secrets, installation credential rotation), Connect authorization requests, project transfer requests (the code lets another team claim the project), KMS signing, a domain move-out (its answer is a transfer token), Global Config items, backups, and tokens, and domain transfer auth codes. Each refusal names its reason.\n- The hosted OAuth connection's documentation search, runtime error clusters, toolbar threads, agent runs, web analytics summaries, purchase quotes, `deploy_to_vercel`, and access grants for protected URLs do not exist with a token. Configure an OAuth connector for those.\n- A `429` carries Vercel's reset time; Vercel meters endpoints separately, so wait before retrying the same operation. An `auth_required` failure means the token is missing, expired, or outside this team: an operator must replace it in this connection in the operator UI, and `authorize_connector` returns that handoff. A rejected argument comes back in Vercel's own words; read it rather than replacing the token.\n- Embedded URL safety uses reviewed paths plus global signing parameters. Generic credential-named query parameters (`code`, `key`, `token`, `sig`, etc.) and credential-looking fragments withhold the destination's entire query/fragment only in deployment `errorMessage`, build-event `payload.text`/`text`, and runtime-log `message` on the reviewed deployment/log operations and vendor error text. Ordinary `code`/`key` links can therefore be false positives in these diagnostics. Deployment metadata and other application content keep those ordinary URL parameters byte for byte. Literal AWS SigV4/Google V4 `X-Amz-*`/`X-Goog-*` Signature, Credential and Security-Token values are withheld in every decoded string before projection. A withheld value is not the original: do not write it back. Deployment creation also inspects decoded `files[].data` when `encoding` is `base64` (one decode bounded by the field's encoded length and the host's request limit; malformed base64 is refused). All writes, including named writes and supported raw/base64/multipart uploads, reject URL destinations whose own query/fragment contains the exact `[redacted]` placeholder, plain or encoded, with `invalid_args` before dispatch. Admission iteratively checks every JSON key and value within the host's accepted request size, without separate depth or node-count ceilings; cyclic direct arguments are refused. A marker outside the destination remains ordinary text. Split strings, encoded host/signing names, bare hosts without `//`, and opaque bearer paths remain outside this scan.\n",
8
8
  "shared": "- Resolve team, project, and deployment ids with list reads; never guess one. Team ids begin `team_`, project ids `prj_`, and deployment ids `dpl_`.\n- Diagnose a deployment by reading its state first (`readyState`, `errorCode`, `errorMessage`), then its build logs, then runtime logs for a deployment that built. Narrow time windows before raising limits.\n- Environment variable changes apply only to future deployments; a new deployment is a separate write. Promoting an existing deployment to production does not rebuild it.\n- Treat every create, update, delete, promotion, cancellation, and purchase as a write. For guest calls, classification, and routing, fetch `skills({ name: \"usage\" })`.\n"
9
9
  }
10
10
  };
@@ -24,7 +24,7 @@
24
24
  // Residual risk, as with Infisical: a secret a person typed into a free-text
25
25
  // name, description, log line, or identifier is out of scope.
26
26
  // Embedded URL verdict (#812): reviewed paths + global signing parameters.
27
- // Evidence: pinned index/candidates sha256:32399b3ed273 identify deployment
27
+ // Evidence: pinned index/candidates sha256:5a702fd2dc76 identify deployment
28
28
  // reads/creation/listing, build events and runtime logs; the operation/path
29
29
  // list below scans errorMessage, deployments.errorMessage, payload.text/text
30
30
  // and message respectively, both lists and individual named-stream rows.
@@ -33,9 +33,9 @@
33
33
  // other application content retain generic query parameters byte for byte.
34
34
  // Literal X-Amz-/X-Goog- Signature/Credential/Security-Token values are
35
35
  // withheld in every string. Existing structured-capability rules still apply.
36
- // The original full response document is unavailable at its unversioned URL:
37
- // current upstream differs and is supplementary evidence only. This is not a
38
- // claim of complete pinned-response review or of a Vercel-wide host policy.
36
+ // The full pinned response document was reviewed on 2026-10-19, including
37
+ // CI failureReason and task recommendations: no vendor-minted bearer links
38
+ // are documented there. This is not a Vercel-wide host policy.
39
39
  import { redact, refuse as refusal, safe, under, } from "../_shared/rest/value-safety.js";
40
40
  /** A refusal's reason reads as a sentence about the operation: "It mints …". */
41
41
  const refuse = (reason) => refusal(`It ${reason}`);
@@ -51,6 +51,7 @@ const PROJECT = [
51
51
  "internalRoutes[].missing[?header].value",
52
52
  "security.firewallRoutes[].has[?header].value",
53
53
  "security.firewallRoutes[].missing[?header].value",
54
+ "security.firewallRoutes[].response_has[?header].value",
54
55
  ];
55
56
  /** Project metadata whose names contain a credential word. */
56
57
  const PROJECT_ALLOW = [
@@ -112,6 +113,7 @@ const FIREWALL_CONDITIONS = [
112
113
  ];
113
114
  /** Every firewall config shape: rules, rulesets, and top-level conditions, active, draft, and versions. */
114
115
  const FIREWALL = under(["rules[]", "rulesets[]", "conditions[]"], FIREWALL_CONDITIONS);
116
+ const BYPASS_CONDITIONS = ["condition", "conditionGroup[].conditions[?header].value"];
115
117
  const FIREWALL_REASON = "Firewall rules match header, cookie, and query values, which can be credentials a rule checks for (an X-Auth-Key).";
116
118
  const TEAM_INVITE = "Teams carry inviteCode, which lets its holder join the team (visible to owners).";
117
119
  /**
@@ -120,6 +122,11 @@ const TEAM_INVITE = "Teams carry inviteCode, which lets its holder join the team
120
122
  * these operations project it themselves (value-safe environment variables).
121
123
  */
122
124
  const VALUE_SAFETY = {
125
+ "GET /ai-gateway/budgets/list": safe("Coverage counts per API key; no key value.", ["defaultCoveredCounts.api-key"]),
126
+ "GET /v8/artifacts/status": safe("Remote-cache read-only status is a flag.", ["readOnly"]),
127
+ "GET /v1/security/firewall/bypass/rules": redact(FIREWALL_REASON, under(["", "rules[]"], BYPASS_CONDITIONS)),
128
+ "POST /v1/security/firewall/bypass/rules": redact("The compiled condition and header, cookie, or query matches can contain stored credentials.", BYPASS_CONDITIONS),
129
+ "DELETE /v1/security/firewall/bypass/rules": safe("Deletes a conditional bypass rule; no response value."),
123
130
  "PATCH /aliases/{id}/protection-bypass": refuse("creates a shareable link that bypasses Deployment Protection."),
124
131
  "POST /api-keys": refuse("mints an AI Gateway API key and returns it in cleartext."),
125
132
  "POST /v1/connect/authorize/{connector}": refuse("starts a Connect authorization for a caller-chosen subject and returns its verifier and device code."),
@@ -185,6 +192,9 @@ const VALUE_SAFETY = {
185
192
  "POST /v1/kms/issuers/{issuerId}/sign/token": refuse("signs a token with the issuer's private key."),
186
193
  "GET /v1/microfrontends/groups/{groupId}/projects": redact(PROJECT_REASON, under(["projects[]"], PROJECT), under(["projects[]"], PROJECT_ALLOW)),
187
194
  "POST /v1/projects/traces/session": refuse("mints a trace session token for a deployment."),
195
+ "GET /v1/projects/{idOrName}/daemons": redact("Daemon liveness probes store custom HTTP header values that may authenticate under arbitrary names; encrypted internal endpoint data is also withheld.", ["daemons.config.livenessProbe.httpGet.httpHeaders.value", "daemons.replicas.items.iroh.publicKeyCiphertext"]),
196
+ "PUT /v1/projects/{idOrName}/daemons/{daemonName}/instances": safe("Returns only an ok boolean after scaling instances."),
197
+ "POST /v1/projects/{idOrName}/daemons/{daemonName}/instances/{instanceId}/restart": safe("Returns only an ok boolean after accepting an instance restart."),
188
198
  "POST /v1/projects/{idOrName}/avatar": redact(PROJECT_REASON, PROJECT, PROJECT_ALLOW),
189
199
  "DELETE /v1/projects/{idOrName}/avatar": redact(PROJECT_REASON, PROJECT, PROJECT_ALLOW),
190
200
  "DELETE /v1/projects/{idOrName}/env": redact(ENV_REASON, PROJECT_ENV),
@@ -124,6 +124,8 @@ export interface RegistryView {
124
124
  /** Age of the complete cached catalog; null for static or unobserved catalogs. */
125
125
  catalogAgeMs(id: string): number | null;
126
126
  statusFor(id: string, baseUrl: string, requestScope?: object, callOptions?: ConnectorOperationOptions): Promise<ConnectorStatus>;
127
+ /** Rebuild a status with this partition's latest bounded observations, without probing. */
128
+ withCatalogObservations(id: string, baseUrl: string, status: ConnectorStatus): ConnectorStatus;
127
129
  invalidateStored(id: string): Promise<void>;
128
130
  /** Bind returned OAuth state to its initiating user for either owner scope. */
129
131
  bindOAuthHandoff(id: string, authorizationUrl: string, principalKey?: string): Promise<void>;
@@ -159,13 +161,8 @@ export declare class Registry implements RegistryView {
159
161
  private readonly catalogObservedAt;
160
162
  /** Last payload-free agent catalog access in this runtime. */
161
163
  private readonly catalogAccess;
162
- /** Count-only intake findings, including catalogs loaded from storage. */
163
- private droppedToolNames;
164
- /**
165
- * Schema digest verification per reviewed facts array. Only the registry's
166
- * own deep-frozen arrays are keys, so an entry stays true while they live.
167
- */
168
- private readonly verifiedFacts;
164
+ /** Bounded tokens isolating count-only observations by catalog identity. */
165
+ private readonly catalogObservationPartitions;
169
166
  private readonly observedOutputSchemas;
170
167
  /** Result-size guard cap threaded to the meta-tools. */
171
168
  readonly maxResultBytes: number;
@@ -220,11 +217,10 @@ export declare class Registry implements RegistryView {
220
217
  }): Promise<CallAdmissionPermit>;
221
218
  /** Connector totals across root and personal controllers; health removes ids. */
222
219
  callAdmissionSnapshot(): Record<string, ConnectorCallAdmissionSnapshot>;
223
- /**
224
- * The drift a connector last showed in this runtime: the registry's own
225
- * observation against a connector's `classification`, otherwise whatever
226
- * the connector's `catalogDrift()` seam reports, bounded either way.
227
- */
220
+ /** The admitted identity fields used by both catalog caching and reporting. */
221
+ private catalogPartition;
222
+ /** Bounded, in-memory tokens; no principal or catalog data is persisted. */
223
+ private catalogObservationPartition;
228
224
  private catalogDriftOf;
229
225
  /** Reject queued/future downstream admission; active permits release safely. */
230
226
  closeCallAdmission(): void;
@@ -268,8 +264,6 @@ export declare class Registry implements RegistryView {
268
264
  private loadDownstreamTools;
269
265
  /** The served catalog excludes control-character names; raw caches stay complete. */
270
266
  private acceptToolNames;
271
- /** Observe intake once, never while serving an older cached listing. */
272
- private observeDroppedToolNames;
273
267
  /**
274
268
  * One connector's catalog as served: the cached downstream facts, classified
275
269
  * on every read by the connector's `classification`, into fresh objects.
@@ -287,6 +281,8 @@ export declare class Registry implements RegistryView {
287
281
  credentialDriftFor(id: string): Promise<string | undefined>;
288
282
  /** Age of a complete cache entry; reads never refresh it. */
289
283
  catalogAgeMs(id: string): number | null;
284
+ /** Pure lookup shared by status probes and operator catalog refreshes. */
285
+ withCatalogObservations(id: string, baseUrl: string, status: ConnectorStatus, scope?: RegistryScope): ConnectorStatus;
290
286
  /** Best-effort connector status for the operator UI. */
291
287
  statusFor(id: string, baseUrl: string, requestScope?: object, callOptions?: ConnectorOperationOptions, scope?: RegistryScope): Promise<ConnectorStatus>;
292
288
  /** Rotate the storage generation; in-flight old readers cannot republish it. */
package/dist/registry.js CHANGED
@@ -16,6 +16,8 @@ import { GUIDE_SUMMARY_LENGTH, normalizeGuideSummary } from "./skills.js";
16
16
  import { attachOAuthSealer, vaultOAuthSealer } from "./oauth-sealing.js";
17
17
  import { attachOAuthPartition, oauthPartitionIdle } from "./oauth-partition.js";
18
18
  import { attachCaller } from "./connector-caller.js";
19
+ import { isWorkspaceAddress } from "./workspace-address.js";
20
+ import { workspaceSubjectOf } from "./workspace-subject.js";
19
21
  import { runEdge } from "./runtime/run.js";
20
22
  import { SharedRead } from "./runtime/shared-read.js";
21
23
  import { runOnPartition, storageCompareAndSet, storageDelete, storageGet, storageSet } from "./runtime/storage.js";
@@ -227,13 +229,8 @@ export class Registry {
227
229
  catalogObservedAt = new Map();
228
230
  /** Last payload-free agent catalog access in this runtime. */
229
231
  catalogAccess = new Map();
230
- /** Count-only intake findings, including catalogs loaded from storage. */
231
- droppedToolNames = new Map();
232
- /**
233
- * Schema digest verification per reviewed facts array. Only the registry's
234
- * own deep-frozen arrays are keys, so an entry stays true while they live.
235
- */
236
- verifiedFacts = new WeakMap();
232
+ /** Bounded tokens isolating count-only observations by catalog identity. */
233
+ catalogObservationPartitions = new Map();
237
234
  observedOutputSchemas;
238
235
  /** Result-size guard cap threaded to the meta-tools. */
239
236
  maxResultBytes;
@@ -261,6 +258,9 @@ export class Registry {
261
258
  if (this.connectors.has(c.id)) {
262
259
  throw new Error(`Duplicate connector id "${c.id}"`);
263
260
  }
261
+ if (c.readOnly !== undefined && typeof c.readOnly !== "boolean") {
262
+ throw new Error(`Connector "${c.id}" readOnly must be a boolean.`);
263
+ }
264
264
  if (c.authScope !== undefined && c.authScope !== "shared" && c.authScope !== "personal") {
265
265
  throw new Error(`Invalid authScope on connector "${c.id}": expected "shared" or "personal"`);
266
266
  }
@@ -330,9 +330,6 @@ export class Registry {
330
330
  catalogStorage: this.opts.catalogStorage ?? this.opts.storage,
331
331
  constructionChecks: false,
332
332
  });
333
- // Counts only, like the connector-wide reviewed drift observation. No
334
- // principal, credentials, or catalog payload crosses this registry boundary.
335
- registry.droppedToolNames = this.droppedToolNames;
336
333
  if (this.callAdmissionClosed)
337
334
  registry.closeCallAdmission();
338
335
  this.personalRegistries.set(principalKey, registry);
@@ -501,13 +498,7 @@ export class Registry {
501
498
  attachCaller(context, scope?.caller);
502
499
  attachCatalogCache(context, {
503
500
  storage: this.opts.catalogStorage ?? this.opts.storage,
504
- partition: JSON.stringify([
505
- scope?.principalKey ?? this.opts.credentialOwner ?? null,
506
- scope?.subjectKey ?? null,
507
- scope?.caller?.identity ?? null,
508
- scope?.caller?.authenticated ?? false,
509
- scope?.caller?.pool ?? null,
510
- ]),
501
+ partition: this.catalogPartition(scope),
511
502
  sharedPartition: JSON.stringify([baseUrl, this.opts.publicUrl ?? null, scope?.caller?.pool ?? null]),
512
503
  defaultTtlMs: (this.opts.toolCacheTtlSeconds ?? CONFIG_DEFAULTS.discovery.catalogTtlSeconds) * 1000,
513
504
  minTtlMs: (this.opts.catalogMinTtlSeconds ?? CONFIG_DEFAULTS.discovery.catalogMinTtlSeconds) * 1000,
@@ -551,26 +542,38 @@ export class Registry {
551
542
  }
552
543
  return Object.fromEntries([...snapshots].map(([id, values]) => [id, aggregateCallAdmissionSnapshots(values)]));
553
544
  }
554
- /**
555
- * The drift a connector last showed in this runtime: the registry's own
556
- * observation against a connector's `classification`, otherwise whatever
557
- * the connector's `catalogDrift()` seam reports, bounded either way.
558
- */
559
- catalogDriftOf(connector) {
560
- const report = boundedCatalogDrift(catalogReviewOf(connector) ? observedCatalogDrift(connector) : connector.catalogDrift?.());
561
- const dropped = this.droppedToolNames.get(connector.id);
562
- if (!dropped)
563
- return report;
564
- return {
565
- ...(report ?? {
566
- unclassifiedTools: 0,
567
- unservedTools: 0,
568
- annotationConflicts: 0,
569
- schemaChanges: 0,
570
- }),
571
- observedAt: dropped.observedAt,
572
- ...(dropped.count > 0 ? { droppedTools: dropped.count } : {}),
573
- };
545
+ /** The admitted identity fields used by both catalog caching and reporting. */
546
+ catalogPartition(scope) {
547
+ return JSON.stringify([
548
+ scope?.principalKey ?? this.opts.credentialOwner ?? null,
549
+ scope?.subjectKey ?? null,
550
+ scope?.caller?.identity ?? null,
551
+ scope?.caller?.authenticated ?? false,
552
+ scope?.caller?.pool ?? null,
553
+ ]);
554
+ }
555
+ /** Bounded, in-memory tokens; no principal or catalog data is persisted. */
556
+ catalogObservationPartition(id, baseUrl, scope, create = false) {
557
+ const key = JSON.stringify([id, this.catalogPartition(scope), baseUrl, this.opts.publicUrl ?? null]);
558
+ let partition = this.catalogObservationPartitions.get(key);
559
+ if (!partition && create) {
560
+ partition = {};
561
+ this.catalogObservationPartitions.set(key, partition);
562
+ if (this.catalogObservationPartitions.size > 1024)
563
+ this.catalogObservationPartitions.delete(this.catalogObservationPartitions.keys().next().value);
564
+ }
565
+ return partition;
566
+ }
567
+ catalogDriftOf(connector, baseUrl, scope) {
568
+ const partition = this.catalogObservationPartition(connector.id, baseUrl, scope);
569
+ const observed = partition ? observedCatalogDrift(connector, partition) : undefined;
570
+ // Plugin reports cannot establish a reviewed baseline or schema coverage
571
+ // that this registry has not checked. Personal plugin seams have no subject
572
+ // parameter and must never bypass the partition above.
573
+ const fallback = !observed && !catalogReviewOf(connector) && connector.authScope !== "personal"
574
+ ? connector.catalogDrift?.()
575
+ : undefined;
576
+ return boundedCatalogDrift(observed ?? (fallback ? { ...fallback, reviewedBaseline: false } : undefined));
574
577
  }
575
578
  /** Reject queued/future downstream admission; active permits release safely. */
576
579
  closeCallAdmission() {
@@ -743,7 +746,7 @@ export class Registry {
743
746
  if (!connector)
744
747
  throw new Error(`Unknown connector "${id}"`);
745
748
  if (connector.staticTools)
746
- return markCatalogFreshness(connector.staticTools, Infinity);
749
+ return { tools: markCatalogFreshness(connector.staticTools, Infinity), verification: undefined };
747
750
  const ctx = this.contextFor(id, baseUrl, requestScope, callOptions, scope);
748
751
  let listed;
749
752
  try {
@@ -764,10 +767,8 @@ export class Registry {
764
767
  }
765
768
  const facts = frozenFacts(structuredClone(tools));
766
769
  const accepted = this.acceptToolNames(facts);
767
- this.observeDroppedToolNames(connector, facts.length - accepted.length);
768
770
  const review = catalogReviewOf(connector);
769
- if (review)
770
- await observeReviewedDrift(connector, review, accepted, this.opts.logger);
771
+ const verification = await observeReviewedDrift(connector, review, accepted, this.opts.logger, this.catalogObservationPartition(id, baseUrl, scope, true), facts.length - accepted.length);
771
772
  if (catalogFetchedAt(ctx) === undefined) {
772
773
  try {
773
774
  await storeCustomCatalogFallback(ctx, id, facts);
@@ -789,19 +790,13 @@ export class Registry {
789
790
  state: freshUntil > Date.now() ? "fresh" : "stale",
790
791
  observedAt: new Date().toISOString(),
791
792
  });
792
- return markCatalogFreshness(facts, freshUntil);
793
+ return { tools: markCatalogFreshness(accepted, freshUntil), verification };
793
794
  }
794
795
  /** The served catalog excludes control-character names; raw caches stay complete. */
795
796
  acceptToolNames(tools) {
796
797
  const accepted = tools.filter((tool) => !hasControlCharacters(tool.name));
797
798
  return accepted.length === tools.length ? tools : accepted;
798
799
  }
799
- /** Observe intake once, never while serving an older cached listing. */
800
- observeDroppedToolNames(connector, count) {
801
- if (count > 0 || this.droppedToolNames.has(connector.id)) {
802
- this.droppedToolNames.set(connector.id, { count, observedAt: new Date().toISOString() });
803
- }
804
- }
805
800
  /**
806
801
  * One connector's catalog as served: the cached downstream facts, classified
807
802
  * on every read by the connector's `classification`, into fresh objects.
@@ -811,7 +806,7 @@ export class Registry {
811
806
  * the next read.
812
807
  */
813
808
  async loadTools(id, baseUrl, requestScope, callOptions = {}, scope) {
814
- const tools = await this.loadDownstreamTools(id, baseUrl, requestScope, callOptions, scope);
809
+ const { tools, verification } = await this.loadDownstreamTools(id, baseUrl, requestScope, callOptions, scope);
815
810
  const connector = this.connectors.get(id);
816
811
  if (!connector)
817
812
  throw new Error(`Unknown connector "${id}"`);
@@ -820,7 +815,7 @@ export class Registry {
820
815
  this.validateClassification(id, accepted, overrides);
821
816
  const review = catalogReviewOf(connector);
822
817
  return carryCatalogFreshness(tools, review
823
- ? await classifyCatalog(review, id, accepted, this.opts.logger, this.verifiedFacts, overrides)
818
+ ? await classifyCatalog(review, id, accepted, this.opts.logger, overrides, verification)
824
819
  : accepted.map((tool) => this.publishUnreviewedTool(id, tool)));
825
820
  }
826
821
  publishUnreviewedTool(id, tool) {
@@ -902,6 +897,20 @@ export class Registry {
902
897
  const observedAt = this.catalogObservedAt.get(id);
903
898
  return observedAt === undefined ? null : Math.max(0, Date.now() - observedAt);
904
899
  }
900
+ /** Pure lookup shared by status probes and operator catalog refreshes. */
901
+ withCatalogObservations(id, baseUrl, status, scope) {
902
+ const connector = this.connectors.get(id);
903
+ if (!connector)
904
+ return ownStatus({ state: "error", message: "Unknown connector" });
905
+ const report = this.catalogDriftOf(connector, baseUrl, scope);
906
+ const access = this.catalogAccess.get(id);
907
+ // Connector.status is an open plugin seam. Rebuild its public fields so
908
+ // it cannot smuggle payload or another partition's observations (INV-6).
909
+ return Object.assign(boundedStatus(status), {
910
+ ...(report ? { catalogDrift: report } : {}),
911
+ ...(access ? { catalogAccess: { ...access } } : {}),
912
+ });
913
+ }
905
914
  /** Best-effort connector status for the operator UI. */
906
915
  async statusFor(id, baseUrl, requestScope = {}, callOptions = {}, scope) {
907
916
  const connector = this.connectors.get(id);
@@ -915,18 +924,7 @@ export class Registry {
915
924
  // The report is rebuilt rather than spread through: what the seam returned
916
925
  // is third-party output, and status is read by the operator UI and copied
917
926
  // into responses.
918
- const withObservations = (status) => {
919
- const report = this.catalogDriftOf(connector);
920
- const access = this.catalogAccess.get(id);
921
- // Connector.status is an open plugin seam. Rebuild its public fields so
922
- // a connector cannot smuggle payload through either registry-owned
923
- // observation when this runtime has not made one, nor through its
924
- // message, which survives only when connecta wrote it (INV-6).
925
- return Object.assign(boundedStatus(status), {
926
- ...(report ? { catalogDrift: report } : {}),
927
- ...(access ? { catalogAccess: { ...access } } : {}),
928
- });
929
- };
927
+ const withObservations = (status) => this.withCatalogObservations(id, baseUrl, status, scope);
930
928
  // Credential declarations own their readiness even when a static catalog
931
929
  // or a plugin status would otherwise report success. Never probe an empty slot.
932
930
  if (connector.credential) {
@@ -997,10 +995,23 @@ class ScopedRegistryView {
997
995
  const connector = this.root.getConnector(id);
998
996
  if (!connector)
999
997
  return undefined;
998
+ // An agent has no Workspace account: on a mapping-subject Workspace
999
+ // connector it acts as the pool's owner. Without one there is no account
1000
+ // to act as, so the connector is absent, as a personal connector is.
1001
+ if (this.agentWithoutOwner && workspaceSubjectOf(connector)?.kind === "mapping")
1002
+ return undefined;
1000
1003
  if (connector.authScope !== "personal")
1001
1004
  return this.root;
1002
1005
  return admission ? this.admissionPersonal : this.personal;
1003
1006
  }
1007
+ /** An agent admitted through a pool that names no Workspace owner. */
1008
+ get agentWithoutOwner() {
1009
+ const caller = this.scope.caller;
1010
+ if (caller?.identity.agent === undefined)
1011
+ return false;
1012
+ // The same test the Workspace provider applies before acting as the owner.
1013
+ return caller.pool === undefined || !isWorkspaceAddress(caller.poolOwner);
1014
+ }
1004
1015
  listConnectors() {
1005
1016
  return this.root.listConnectors().filter((connector) => this.registryFor(connector.id) !== undefined);
1006
1017
  }
@@ -1093,6 +1104,10 @@ class ScopedRegistryView {
1093
1104
  ? registry.statusFor(args[0], args[1], args[2], args[3], this.scope)
1094
1105
  : Promise.resolve({ state: "error", message: "Unknown connector" });
1095
1106
  }
1107
+ withCatalogObservations(id, baseUrl, status) {
1108
+ return (this.registryFor(id)?.withCatalogObservations(id, baseUrl, status, this.scope) ??
1109
+ ownStatus({ state: "error", message: "Unknown connector" }));
1110
+ }
1096
1111
  invalidateStored(id) {
1097
1112
  const registry = this.registryFor(id);
1098
1113
  return registry ? registry.invalidateStored(id) : Promise.resolve();
@@ -8,7 +8,7 @@ import { Duration, Effect, Exit, Option, Result, Scope } from "effect";
8
8
  import { registerExecuteTool } from "../execute.js";
9
9
  import { ExecutorAdmissionError } from "../executor-admission.js";
10
10
  import { registerMetaTools } from "../meta-tools.js";
11
- import { CONNECTA_INSTRUCTIONS } from "../skills.js";
11
+ import { endpointInstructions } from "../skills.js";
12
12
  import { registerSkills } from "./skills.js";
13
13
  import { failureRecord, logFailure } from "../operator-record.js";
14
14
  import { redactAgentOutput, sentSecretsForRequest } from "../sent-secrets.js";
@@ -341,7 +341,10 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
341
341
  // A request-local server cannot publish catalog changes. Set this before
342
342
  // tool registration, whose SDK default otherwise advertises listChanged.
343
343
  capabilities: { tools: { listChanged: false }, extensions: { "io.modelcontextprotocol/skills": {} } },
344
- instructions: CONNECTA_INSTRUCTIONS,
344
+ // Instructions differ per endpoint only by the admitted pool's
345
+ // description. Every cache hint below is private, so a client keeps
346
+ // what it learned per endpoint URL and never shares it across them.
347
+ instructions: endpointInstructions(pool !== undefined ? opts.pools.get(pool)?.description : undefined),
345
348
  inputRequired: { legacyShim: false },
346
349
  requestState: { verify: (state, context) => authElicitation.verify(state, context) },
347
350
  cacheHints: {
@@ -738,6 +741,18 @@ export function createMcpRoute(opts) {
738
741
  return yield* refuseAdmission(late.failure);
739
742
  }
740
743
  }
744
+ // The caller rides on the view, and the paging recheck builds the
745
+ // same one: an agent's Workspace visibility depends on the owner.
746
+ const callerFor = (identity, owner) => ({
747
+ identity,
748
+ // `authorize` admits an open deployment's every request as the
749
+ // anonymous actor; only a provider's `ok` is an authentication.
750
+ authenticated: opts.config.auth.length > 0,
751
+ // Both pool endpoints build the caller here from the same
752
+ // admitted scope, so an agent at /agent/<pool> carries the owner.
753
+ ...(poolName !== undefined ? { pool: poolName } : {}),
754
+ ...(owner !== undefined ? { poolOwner: owner } : {}),
755
+ });
741
756
  let scopedRegistry;
742
757
  try {
743
758
  scopedRegistry = opts.registry.scoped({
@@ -760,11 +775,12 @@ export function createMcpRoute(opts) {
760
775
  const currentScope = scopeFor(current);
761
776
  if (!currentScope)
762
777
  return false;
763
- const { access: currentAccess, readOnly: currentReadOnly } = currentScope;
778
+ const { access: currentAccess, readOnly: currentReadOnly, poolOwner: currentOwner } = currentScope;
764
779
  const view = opts.registry.scoped({
765
780
  ...currentAccess,
766
781
  ...(current.subjectKey ? { subjectKey: current.subjectKey } : {}),
767
782
  ...(current.principalKey ? { principalKey: current.principalKey } : {}),
783
+ caller: callerFor(current.identity, currentOwner),
768
784
  });
769
785
  if (classification === "write" && currentReadOnly)
770
786
  return false;
@@ -789,16 +805,7 @@ export function createMcpRoute(opts) {
789
805
  await closeConnectorScope(resolved.connector, view.contextFor(resolved.connector.id, baseUrl, requestScope), runtimeContext?.waitUntil?.bind(runtimeContext));
790
806
  }
791
807
  },
792
- caller: {
793
- identity: authz.identity,
794
- // `authorize` admits an open deployment's every request as the
795
- // anonymous actor; only a provider's `ok` is an authentication.
796
- authenticated: opts.config.auth.length > 0,
797
- // Both pool endpoints build the caller here from the same
798
- // admitted scope, so an agent at /agent/<pool> carries the owner.
799
- ...(poolName !== undefined ? { pool: poolName } : {}),
800
- ...(poolOwner !== undefined ? { poolOwner } : {}),
801
- },
808
+ caller: callerFor(authz.identity, poolOwner),
802
809
  });
803
810
  }
804
811
  catch {
@@ -38,7 +38,7 @@ function html(reason, opts, connector) {
38
38
  const details = outcome.fixPrompt
39
39
  ? `<details class="status-details">
40
40
  <summary>Details for the operator</summary>
41
- <p>If this keeps happening, send this to whoever runs ${escapeHtml(brand.productName)}. It is written for a coding agent working on the deployment and carries nothing from the provider's response.</p>
41
+ <p>If this keeps happening, send this to whoever runs ${escapeHtml(brand.productName)}. It is for a coding agent and holds nothing from the provider.</p>
42
42
  <pre>${escapeHtml(outcome.fixPrompt)}</pre>
43
43
  </details>`
44
44
  : "";
@@ -55,7 +55,7 @@ function html(reason, opts, connector) {
55
55
  </section>
56
56
  </main>`;
57
57
  return new Response(renderPage(opts.config.ui?.branding, {
58
- title: `${outcome.heading} — ${brand.pageTitle}`,
58
+ title: `${outcome.heading} · ${brand.pageTitle}`,
59
59
  uiMounted,
60
60
  body,
61
61
  }), { status: outcome.status, headers: htmlSecurityHeaders({ "Content-Type": "text/html; charset=utf-8" }) });
@@ -74,7 +74,13 @@ export function uiData(registry, baseUrl, options) {
74
74
  }))).pipe(Effect.map((tools) => ({ status, tools, catalogFailed: false })),
75
75
  // The registry owns the failed catalog observation; the page only
76
76
  // needs to know there was one.
77
- Effect.catch(() => Effect.succeed({ status, tools: [], catalogFailed: !signal.aborted })));
77
+ Effect.catch(() => Effect.succeed({ status, tools: [], catalogFailed: !signal.aborted })),
78
+ // Loading the catalog may replace an earlier clean observation.
79
+ // Read this view's current bounded facts without another status probe.
80
+ Effect.map((observed) => ({
81
+ ...observed,
82
+ status: registry.withCatalogObservations(c.id, baseUrl, observed.status),
83
+ })));
78
84
  }).pipe(Effect.catch((error) => Effect.succeed({
79
85
  status: failureStatus(c.id, error),
80
86
  tools: [],
@@ -168,7 +174,7 @@ export function uiData(registry, baseUrl, options) {
168
174
  toolCount: tools.length,
169
175
  tools,
170
176
  // Counts only, and only when a refresh in this runtime produced them.
171
- // `Registry.statusFor` already rebuilt the report through
177
+ // `Registry.withCatalogObservations` rebuilt the report through
172
178
  // `boundedCatalogDrift`, so what lands here cannot carry a name or a
173
179
  // schema even if the plugin seam returned one.
174
180
  ...(status.catalogDrift ? { catalogDrift: status.catalogDrift } : {}),
package/dist/skills.js CHANGED
@@ -13,6 +13,16 @@ const RECOVERY = 'When an auth error includes a handoff, give it to the user or
13
13
  * and describe and refuses every write call. Guides may still name them.
14
14
  */
15
15
  export const CONNECTA_INSTRUCTIONS = `${ROUTE} On a writable endpoint, programs and call_destructive_tool may write. On a read-only endpoint, search omits write tools and every write call is refused; guides may still name them. The execute_code description says which this endpoint is. Never repeat a write to recover its output. ${RECOVERY}`;
16
+ /**
17
+ * The instructions one endpoint serves: {@link CONNECTA_INSTRUCTIONS}, then
18
+ * the admitted pool's `description` after a fixed lead-in. Plain `/mcp` and a
19
+ * pool without a description serve the constant unchanged. The description
20
+ * comes from deployment config for the pool the request was admitted through;
21
+ * nothing a request sends reaches it.
22
+ */
23
+ export function endpointInstructions(description) {
24
+ return description === undefined ? CONNECTA_INSTRUCTIONS : `${CONNECTA_INSTRUCTIONS} This endpoint: ${description}`;
25
+ }
16
26
  export { USAGE_SKILL } from "./usage-guide.js";
17
27
  /** True when at least one of `connectors` carries a usage guide. */
18
28
  export function hasConnectorGuides(connectors) {
@@ -76,7 +86,11 @@ export function connectorSkillName(connectorId) {
76
86
  export function connectorGuide(connector) {
77
87
  const guide = connector.usageGuide;
78
88
  const content = typeof guide === "string" ? guide : guide?.content;
79
- return content && content.trim() !== "" ? content : undefined;
89
+ if (!content || content.trim() === "")
90
+ return undefined;
91
+ return connector.readOnly === true
92
+ ? `Read-only connection: only read-classified tools are available. Write calls are refused, including tools named in this guide.\n\n${content}`
93
+ : content;
80
94
  }
81
95
  /** Discovery budget for one connector-guide summary, including an ellipsis. */
82
96
  export const GUIDE_SUMMARY_LENGTH = 120;
@@ -288,7 +302,9 @@ export function connectorGuideSummary(connector) {
288
302
  // normalization here so direct Connector callers see the same text.
289
303
  boundedSummary(connector.usageGuide.summary ?? "")
290
304
  : undefined;
291
- return configured ?? summarizeGuide(connector, guide);
305
+ const original = typeof connector.usageGuide === "string" ? connector.usageGuide : connector.usageGuide?.content;
306
+ const summary = configured ?? summarizeGuide(connector, original ?? guide);
307
+ return connector.readOnly === true ? boundedSummary(`Read-only. ${summary}`) : summary;
292
308
  }
293
309
  /** Whether correct use always depends on conventions outside the tool schema. */
294
310
  export function connectorGuideRequired(connector) {
@@ -492,7 +492,11 @@ export function sqlActivityStore(driver, kind, options = {}) {
492
492
  }
493
493
  });
494
494
  return {
495
- describe: () => ({ kind, retentionDays }),
495
+ describe: () => ({
496
+ kind,
497
+ retentionDays,
498
+ optionSources: { retentionDays: options.retentionDays === undefined ? "default" : "config" },
499
+ }),
496
500
  async record(event) {
497
501
  await ensure();
498
502
  const occurredAtMs = Date.parse(event.occurredAt);