@zackbart/connecta 0.28.1 → 0.30.0
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.
- package/CHANGELOG.md +855 -5
- package/README.md +125 -116
- package/bin/connecta.mjs +121 -139
- package/bin/meta-tool-names.d.mts +2 -0
- package/bin/meta-tool-names.mjs +9 -0
- package/bin/version.mjs +3 -0
- package/dist/access-tokens.js +53 -63
- package/dist/activity-request.js +8 -0
- package/dist/activity.d.ts +76 -42
- package/dist/activity.js +110 -42
- package/dist/argument-redaction.js +230 -0
- package/dist/auth/clerk-sdk/client.d.ts +65 -0
- package/dist/auth/clerk-sdk/client.js +5 -0
- package/dist/auth/clerk-sdk/generated.js +1845 -0
- package/dist/auth/clerk-transport.js +19 -0
- package/dist/auth/clerk.d.ts +17 -7
- package/dist/auth/clerk.js +189 -107
- package/dist/auth/cloudflare-access.js +25 -26
- package/dist/auth/downstream-client-metadata.d.ts +29 -0
- package/dist/auth/downstream-client-metadata.js +133 -0
- package/dist/auth/downstream-oauth.js +1584 -1580
- package/dist/auth/downstream-revocation.js +106 -0
- package/dist/auth/oauth-token-response.js +231 -0
- package/dist/auth/oauth-v2-migration.js +116 -0
- package/dist/auth/static-oauth.js +93 -71
- package/dist/auth-elicitation.js +288 -0
- package/dist/authorization-handoff.js +111 -0
- package/dist/branding.d.ts +8 -16
- package/dist/branding.js +26 -59
- package/dist/byte-read-response.js +39 -0
- package/dist/call-admission.js +12 -16
- package/dist/catalog-cache.js +739 -0
- package/dist/catalog-drift.d.ts +50 -15
- package/dist/catalog-drift.js +365 -128
- package/dist/catalog-fingerprint.js +2 -4
- package/dist/catalog-freshness.js +13 -0
- package/dist/catalog-limits.js +2 -2
- package/dist/catalog-service.js +409 -225
- package/dist/catalog.js +43 -84
- package/dist/concurrency.js +3 -4
- package/dist/config-defaults.d.ts +39 -0
- package/dist/config-defaults.js +46 -0
- package/dist/config-schema.d.ts +181 -0
- package/dist/config-schema.js +435 -0
- package/dist/config-value-sources.js +102 -0
- package/dist/config.d.ts +633 -0
- package/dist/config.js +508 -0
- package/dist/connector-access.d.ts +1 -0
- package/dist/connector-access.js +13 -15
- package/dist/connector-caller.d.ts +8 -0
- package/dist/connector-caller.js +15 -6
- package/dist/connector-scope.js +1 -1
- package/dist/connectors/api-connector.d.ts +59 -7
- package/dist/connectors/api-connector.js +106 -19
- package/dist/connectors/api.js +4 -1
- package/dist/connectors/guarded-fetch.d.ts +144 -0
- package/dist/connectors/guarded-fetch.js +87 -38
- package/dist/connectors/negotiation-cache.js +46 -0
- package/dist/connectors/option-shapes.js +27 -0
- package/dist/connectors/remote-mcp.d.ts +30 -4
- package/dist/connectors/remote-mcp.js +1774 -320
- package/dist/connectors/resource-uri.js +335 -0
- package/dist/credential-contract.d.ts +10 -0
- package/dist/credential-rules.js +1 -3
- package/dist/credential-url.js +108 -0
- package/dist/credentials.d.ts +6 -1
- package/dist/credentials.js +27 -18
- package/dist/d1.d.ts +130 -0
- package/dist/d1.js +290 -0
- package/dist/describe-config.d.ts +165 -0
- package/dist/describe-config.js +315 -0
- package/dist/described.js +106 -0
- package/dist/downstream-input-context.js +128 -0
- package/dist/downstream-input.js +325 -0
- package/dist/errors.d.ts +132 -31
- package/dist/errors.js +175 -87
- package/dist/execute.js +798 -318
- package/dist/executor-admission.d.ts +1 -0
- package/dist/executor-admission.js +11 -15
- package/dist/executor-contract.d.ts +20 -0
- package/dist/executor-contract.js +48 -0
- package/dist/executor-result.js +30 -13
- package/dist/executors/quickjs-child.js +15 -9
- package/dist/executors/quickjs-protocol.js +5 -6
- package/dist/executors/quickjs-runtime.js +152 -59
- package/dist/executors/quickjs.js +49 -39
- package/dist/favicon.js +1 -2
- package/dist/fix-prompt.js +1 -3
- package/dist/guest-runtime.js +238 -0
- package/dist/guest-types.d.ts +218 -0
- package/dist/guest-types.js +1 -0
- package/dist/html-security.js +24 -0
- package/dist/identity.js +1 -3
- package/dist/inbound-credential.js +52 -0
- package/dist/index.d.ts +24 -256
- package/dist/index.js +65 -478
- package/dist/invocation-auth.js +69 -0
- package/dist/invocation.js +262 -79
- package/dist/json-schema.d.ts +1 -1
- package/dist/mcp-client-context.js +35 -0
- package/dist/mcp-result.js +11 -7
- package/dist/meta-output.js +61 -0
- package/dist/meta-tool-names.d.ts +2 -0
- package/dist/meta-tool-names.js +3 -0
- package/dist/meta-tools.js +419 -272
- package/dist/module-contracts.d.ts +8 -23
- package/dist/node.d.ts +0 -2
- package/dist/node.js +2 -2
- package/dist/oauth-callback-outcome.js +1 -3
- package/dist/oauth-handoff.js +194 -0
- package/dist/oauth-sealing.js +20 -0
- package/dist/operator-record.d.ts +137 -0
- package/dist/operator-record.js +420 -0
- package/dist/operator-ui/assets.js +31 -0
- package/dist/operator-ui/contract.d.ts +72 -0
- package/dist/operator-ui/contract.js +1 -0
- package/dist/operator-ui/fix-prompts.d.ts +18 -0
- package/dist/operator-ui/generated.js +5 -4
- package/dist/operator-ui/model.d.ts +12 -87
- package/dist/operator-ui/model.js +2 -9
- package/dist/operator-ui/setup-commands.js +1 -3
- package/dist/operator-ui/view.d.ts +323 -0
- package/dist/operator-ui/view.js +62 -189
- package/dist/page-styles.js +2 -3
- package/dist/program-result.js +5 -0
- package/dist/program-source.js +14 -2
- package/dist/{exempt-writes.js → program-writes.js} +60 -19
- package/dist/provider.d.ts +142 -0
- package/dist/provider.js +187 -0
- package/dist/providers/_shared/google/result-size.js +57 -0
- package/dist/providers/_shared/google/service-account.d.ts +58 -0
- package/dist/providers/_shared/google/service-account.js +500 -0
- package/dist/providers/_shared/google/workspace.d.ts +276 -0
- package/dist/providers/_shared/google/workspace.js +518 -0
- package/dist/providers/_shared/request-token.js +83 -0
- package/dist/providers/{basecamp.d.ts → basecamp/index.d.ts} +6 -28
- package/dist/providers/basecamp/index.js +997 -0
- package/dist/providers/basecamp/skill.generated.js +9 -0
- package/dist/providers/{breeze.d.ts → breeze/index.d.ts} +2 -2
- package/dist/providers/{breeze.js → breeze/index.js} +184 -140
- package/dist/providers/breeze/skill.generated.js +9 -0
- package/dist/providers/{ccb.d.ts → ccb/index.d.ts} +2 -2
- package/dist/providers/{ccb.js → ccb/index.js} +271 -116
- package/dist/providers/ccb/skill.generated.js +9 -0
- package/dist/providers/{cloudflare.d.ts → cloudflare/index.d.ts} +8 -7
- package/dist/providers/{cloudflare.js → cloudflare/index.js} +171 -738
- package/dist/providers/cloudflare/skill.generated.js +10 -0
- package/dist/providers/docs/index.d.ts +54 -0
- package/dist/providers/docs/index.js +1237 -0
- package/dist/providers/docs/skill.generated.js +9 -0
- package/dist/providers/drive/index.d.ts +53 -0
- package/dist/providers/drive/index.js +1523 -0
- package/dist/providers/drive/skill.generated.js +9 -0
- package/dist/providers/forms/index.d.ts +54 -0
- package/dist/providers/forms/index.js +1217 -0
- package/dist/providers/forms/skill.generated.js +11 -0
- package/dist/providers/github/auth.d.ts +32 -0
- package/dist/providers/github/auth.js +361 -0
- package/dist/providers/github/hosted.js +218 -0
- package/dist/providers/github/index.d.ts +13 -0
- package/dist/providers/github/index.js +140 -0
- package/dist/providers/github/key.js +100 -0
- package/dist/providers/github/rest.js +321 -0
- package/dist/providers/github/scope.d.ts +29 -0
- package/dist/providers/github/scope.js +124 -0
- package/dist/providers/github/skill.generated.js +8 -0
- package/dist/providers/gmail/index.d.ts +51 -0
- package/dist/providers/gmail/index.js +1436 -0
- package/dist/providers/gmail/skill.generated.js +9 -0
- package/dist/providers/infisical/index.d.ts +7 -0
- package/dist/providers/infisical/index.js +713 -0
- package/dist/providers/infisical/skill.generated.js +8 -0
- package/dist/providers/{linear.d.ts → linear/index.d.ts} +11 -14
- package/dist/providers/linear/index.js +352 -0
- package/dist/providers/linear/skill.generated.js +8 -0
- package/dist/providers/{mixpanel.d.ts → mixpanel/index.d.ts} +5 -10
- package/dist/providers/mixpanel/index.js +416 -0
- package/dist/providers/mixpanel/skill.generated.js +9 -0
- package/dist/providers/{notion.d.ts → notion/index.d.ts} +8 -8
- package/dist/providers/{notion.js → notion/index.js} +226 -239
- package/dist/providers/notion/skill.generated.js +10 -0
- package/dist/providers/{overflow.d.ts → overflow/index.d.ts} +2 -2
- package/dist/providers/{overflow.js → overflow/index.js} +74 -111
- package/dist/providers/overflow/skill.generated.js +8 -0
- package/dist/providers/{planning-center.d.ts → planning-center/index.d.ts} +2 -2
- package/dist/providers/{planning-center.js → planning-center/index.js} +264 -104
- package/dist/providers/planning-center/skill.generated.js +10 -0
- package/dist/providers/{revenuecat.d.ts → revenuecat/index.d.ts} +5 -10
- package/dist/providers/revenuecat/index.js +557 -0
- package/dist/providers/revenuecat/skill.generated.js +11 -0
- package/dist/providers/sheets/index.d.ts +54 -0
- package/dist/providers/sheets/index.js +1237 -0
- package/dist/providers/sheets/skill.generated.js +10 -0
- package/dist/providers/slides/index.d.ts +53 -0
- package/dist/providers/slides/index.js +2645 -0
- package/dist/providers/slides/skill.generated.js +9 -0
- package/dist/providers/{stripe.d.ts → stripe/index.d.ts} +5 -12
- package/dist/providers/stripe/index.js +266 -0
- package/dist/providers/stripe/skill.generated.js +11 -0
- package/dist/providers/{tithely.d.ts → tithely/index.d.ts} +2 -2
- package/dist/providers/{tithely.js → tithely/index.js} +60 -91
- package/dist/providers/tithely/skill.generated.js +8 -0
- package/dist/providers/{vercel.d.ts → vercel/index.d.ts} +8 -8
- package/dist/providers/vercel/index.js +1038 -0
- package/dist/providers/vercel/mcp-ownership.js +212 -0
- package/dist/providers/vercel/skill.generated.js +11 -0
- package/dist/registry.d.ts +110 -171
- package/dist/registry.js +588 -927
- package/dist/request-state.js +21 -0
- package/dist/resource-read.js +103 -0
- package/dist/result-shapes.js +17 -49
- package/dist/retired-toolkits.js +1 -1
- package/dist/routes/access-tokens.js +3 -6
- package/dist/routes/activity-context.js +21 -0
- package/dist/routes/activity-disclosure.js +40 -0
- package/dist/routes/activity.js +59 -21
- package/dist/routes/actor-labels.js +3 -50
- package/dist/routes/connect.js +221 -0
- package/dist/routes/credentials.js +15 -27
- package/dist/routes/mcp.js +365 -130
- package/dist/routes/oauth-client-metadata.js +15 -0
- package/dist/routes/oauth-management.js +45 -107
- package/dist/routes/oauth.js +110 -57
- package/dist/routes/operator.js +3 -3
- package/dist/routes/shared.d.ts +25 -64
- package/dist/routes/shared.js +160 -80
- package/dist/routes/skills.js +30 -0
- package/dist/routes/ui-config.js +225 -0
- package/dist/routes/ui-data.js +40 -35
- package/dist/routes/ui.js +66 -56
- package/dist/runtime/admission.js +4 -2
- package/dist/runtime/call-admission.js +3 -1
- package/dist/runtime/connector-scope.js +1 -4
- package/dist/runtime/run.js +10 -39
- package/dist/runtime/services.js +12 -117
- package/dist/runtime/shared-read.js +1 -3
- package/dist/runtime/storage.js +8 -1
- package/dist/sent-secrets.d.ts +93 -0
- package/dist/sent-secrets.js +1013 -0
- package/dist/server.js +28 -71
- package/dist/skills.js +416 -174
- package/dist/sqlite.d.ts +48 -0
- package/dist/sqlite.js +181 -0
- package/dist/storage/keys.js +443 -0
- package/dist/storage/memory.js +16 -4
- package/dist/storage/sql.d.ts +66 -0
- package/dist/storage/sql.js +543 -0
- package/dist/timeout.js +4 -2
- package/dist/tool-name.js +17 -0
- package/dist/tool-safety.d.ts +9 -35
- package/dist/tool-safety.js +20 -40
- package/dist/types.d.ts +336 -56
- package/dist/types.js +0 -1
- package/dist/ui.d.ts +7 -19
- package/dist/ui.js +76 -90
- package/dist/url-safety.js +3 -6
- package/dist/usage-guide.js +146 -0
- package/dist/validate.d.ts +7 -0
- package/dist/validate.js +309 -47
- package/dist/version.d.ts +1 -7
- package/dist/version.js +3 -7
- package/dist/worker.d.ts +11 -0
- package/dist/worker.js +435 -0
- package/documentation/README.md +63 -0
- package/documentation/architecture.md +696 -231
- package/documentation/auth.md +1069 -546
- package/documentation/code-mode.md +662 -356
- package/documentation/deploying.md +146 -0
- package/documentation/integrating.md +159 -0
- package/documentation/meta-tools.md +570 -216
- package/documentation/operating.md +135 -0
- package/documentation/operator-tests.md +97 -0
- package/documentation/operator-ui.md +132 -0
- package/documentation/provider-migration-0.29.md +65 -0
- package/examples/worker/AGENTS.md +34 -12
- package/examples/worker/README.md +293 -255
- package/examples/worker/kv-to-d1.wrangler.jsonc +26 -0
- package/examples/worker/scripts/copy-kv-to-d1.mjs +162 -0
- package/examples/worker/src/connecta.config.ts +151 -0
- package/examples/worker/src/index.ts +14 -197
- package/examples/worker/wrangler.jsonc +15 -35
- package/package.json +126 -54
- package/templates/node/.dockerignore +1 -2
- package/templates/node/.env.example +20 -21
- package/templates/node/AGENTS.md +21 -17
- package/templates/node/Dockerfile +3 -5
- package/templates/node/README.md +140 -58
- package/templates/node/docker-compose.yml +11 -12
- package/templates/node/package.json +4 -2
- package/templates/node/src/connecta.config.ts +119 -0
- package/templates/node/src/index.ts +9 -154
- package/templates/node/src/provision-token.ts +17 -0
- package/AGENTS.md +0 -170
- package/dist/artifacts/connector.d.ts +0 -58
- package/dist/artifacts/connector.js +0 -730
- package/dist/artifacts/document.js +0 -86
- package/dist/artifacts/guide.js +0 -140
- package/dist/artifacts/html-scan.js +0 -329
- package/dist/artifacts/json.js +0 -78
- package/dist/artifacts/kv-store.d.ts +0 -20
- package/dist/artifacts/kv-store.js +0 -140
- package/dist/artifacts/markdown.js +0 -478
- package/dist/artifacts/module.d.ts +0 -37
- package/dist/artifacts/module.js +0 -66
- package/dist/artifacts/operations.d.ts +0 -197
- package/dist/artifacts/operations.js +0 -1002
- package/dist/artifacts/refresh.d.ts +0 -55
- package/dist/artifacts/refresh.js +0 -236
- package/dist/artifacts/routes.js +0 -206
- package/dist/artifacts/types.d.ts +0 -214
- package/dist/artifacts/types.js +0 -22
- package/dist/artifacts/validate.d.ts +0 -62
- package/dist/artifacts/validate.js +0 -651
- package/dist/artifacts.d.ts +0 -6
- package/dist/artifacts.js +0 -9
- package/dist/auth/bearer.d.ts +0 -11
- package/dist/auth/bearer.js +0 -50
- package/dist/providers/basecamp.js +0 -445
- package/dist/providers/linear.js +0 -217
- package/dist/providers/mixpanel.js +0 -247
- package/dist/providers/revenuecat.js +0 -317
- package/dist/providers/stripe.js +0 -253
- package/dist/providers/vercel.js +0 -1253
- package/dist/storage/file.d.ts +0 -15
- package/dist/storage/file.js +0 -455
- package/ethos.md +0 -93
- package/examples/worker/src/cloudflare-kv.ts +0 -43
- package/examples/worker/src/d1-activity-row.ts +0 -155
- package/examples/worker/src/d1-activity.ts +0 -147
- package/examples/worker/src/d1-storage.ts +0 -138
- package/examples/worker/src/r2-artifact-blobs.ts +0 -29
- package/templates/node/src/file-activity.ts +0 -182
package/dist/registry.js
CHANGED
|
@@ -1,27 +1,28 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { markCatalogFreshness, carryCatalogFreshness } from "./catalog-freshness.js";
|
|
2
|
+
import { activityRequest } from "./activity-request.js";
|
|
3
|
+
import { attachCatalogCache, catalogExpiry, customCatalogFallback, storeCustomCatalogFallback, catalogFetchedAt, invalidateCatalogCache, observeUncachedCatalogRefresh, } from "./catalog-cache.js";
|
|
4
|
+
import { CONFIG_DEFAULTS } from "./config-defaults.js";
|
|
5
|
+
import { assertStaticToolNames, hasControlCharacters } from "./tool-name.js";
|
|
6
|
+
import { Clock, Duration, Effect, Random } from "effect";
|
|
7
|
+
import { storedCredentialShape } from "./credential-rules.js";
|
|
8
|
+
import { ConnectorCallError } from "./errors.js";
|
|
9
|
+
import { boundedStatus, failureRecord, failureStatus, logFailure, ownStatus } from "./operator-record.js";
|
|
4
10
|
import { ConnectorCallAdmissionController, aggregateCallAdmissionSnapshots, } from "./call-admission.js";
|
|
5
|
-
import { boundedCatalogDrift } from "./catalog-drift.js";
|
|
6
|
-
import {
|
|
7
|
-
import { MAX_CATALOG_CHUNK_BYTES, MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTES, } from "./catalog-limits.js";
|
|
11
|
+
import { boundedCatalogDrift, catalogReviewOf, classifyCatalog, observeReviewedDrift, observedCatalogDrift, } from "./catalog-drift.js";
|
|
12
|
+
import { MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTES } from "./catalog-limits.js";
|
|
8
13
|
import { ObservedOutputSchemas } from "./result-shapes.js";
|
|
9
|
-
import {
|
|
14
|
+
import { redactCatalog, sentSecretsFor, sentSecretsForRequest, trackCredentialReads } from "./sent-secrets.js";
|
|
15
|
+
import { GUIDE_SUMMARY_LENGTH, normalizeGuideSummary } from "./skills.js";
|
|
10
16
|
import { attachOAuthSealer, vaultOAuthSealer } from "./oauth-sealing.js";
|
|
11
17
|
import { attachOAuthPartition, oauthPartitionIdle } from "./oauth-partition.js";
|
|
12
18
|
import { attachCaller } from "./connector-caller.js";
|
|
13
|
-
import {
|
|
14
|
-
import { detach, runEdge, withDeadlineEffect } from "./runtime/run.js";
|
|
19
|
+
import { runEdge } from "./runtime/run.js";
|
|
15
20
|
import { SharedRead } from "./runtime/shared-read.js";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import { isExplicitlyReadOnly } from "./tool-safety.js";
|
|
21
|
+
import { runOnPartition, storageCompareAndSet, storageDelete, storageGet, storageSet } from "./runtime/storage.js";
|
|
22
|
+
import { jsonCodec, OAUTH_HANDOFF_TTL_SECONDS, oauthHandoffKeys, resultKeys, scopes, stashLedgerKeys, stashExpiryKeys, } from "./storage/keys.js";
|
|
23
|
+
import { classifyTool } from "./tool-safety.js";
|
|
20
24
|
const ID_RE = /^[a-z0-9_-]+$/;
|
|
21
|
-
const
|
|
22
|
-
const DEFAULT_STALE_SECONDS = 3600;
|
|
23
|
-
const CATALOG_CHUNK_TTL_GRACE_SECONDS = 300;
|
|
24
|
-
const DEFAULT_MAX_RESULT_BYTES = 24_000;
|
|
25
|
+
const DEFAULT_MAX_RESULT_BYTES = CONFIG_DEFAULTS.calls.maxResultBytes;
|
|
25
26
|
const encoder = new TextEncoder();
|
|
26
27
|
/**
|
|
27
28
|
* Split `"<connectorId>.<toolName>"` on the first dot. Connector ids contain
|
|
@@ -50,7 +51,7 @@ export const MIN_MAX_RESULT_BYTES = 1;
|
|
|
50
51
|
/**
|
|
51
52
|
* The one definition of a usable `maxResultBytes`: a finite whole number of at
|
|
52
53
|
* least {@link MIN_MAX_RESULT_BYTES} bytes. Shared by all three intake points
|
|
53
|
-
* — `calls.maxResultBytes`, the per-connector override, and `
|
|
54
|
+
* — `calls.maxResultBytes`, the per-connector override, and `connecta.result`'s
|
|
54
55
|
* `maxBytes` argument — so a value that is valid at one is valid at all.
|
|
55
56
|
*
|
|
56
57
|
* Everything else is rejected rather than coerced, because each rejected shape
|
|
@@ -64,105 +65,179 @@ export function isValidMaxResultBytes(value) {
|
|
|
64
65
|
}
|
|
65
66
|
/**
|
|
66
67
|
* Resolve a configured cap against the value it inherits, dropping anything
|
|
67
|
-
* `isValidMaxResultBytes` rejects.
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* a broken cap.
|
|
68
|
+
* `isValidMaxResultBytes` rejects. Construction already refuses an unusable
|
|
69
|
+
* cap (`Registry.assertResultCaps`); the resolution stays total so no call
|
|
70
|
+
* site has to cope with a broken one.
|
|
71
71
|
*/
|
|
72
72
|
export function resolveMaxResultBytes(value, inherited) {
|
|
73
|
-
return value !== undefined && isValidMaxResultBytes(value)
|
|
74
|
-
? value
|
|
75
|
-
: inherited;
|
|
73
|
+
return value !== undefined && isValidMaxResultBytes(value) ? value : inherited;
|
|
76
74
|
}
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Run `operation` over every chunk index or chunk with the fixed chunk I/O
|
|
91
|
-
* bound, settling each: one failure neither interrupts its siblings nor
|
|
92
|
-
* leaves their storage calls running behind the caller. Results keep input
|
|
93
|
-
* order, so the first failure by index is the one a caller reports.
|
|
94
|
-
*/
|
|
95
|
-
function forEachChunk(items, operation) {
|
|
96
|
-
return Effect.forEach(items, (item, index) => Effect.result(operation(item, index)), { concurrency: CATALOG_CHUNK_IO_CONCURRENCY });
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* A persisted-catalog mutation that logs its storage failure rather than
|
|
100
|
-
* failing, so its turn in the mutation queue always ends.
|
|
101
|
-
*/
|
|
102
|
-
function warnOnFailure(mutation, failed) {
|
|
103
|
-
return Effect.catch(mutation, (err) => LoggerService.use((logger) => Effect.sync(() => logger.warn(`[connecta] ${failed}: ${msg(err)}`))));
|
|
75
|
+
/** Freeze registry-owned facts so review digest memoization remains valid. */
|
|
76
|
+
function frozenFacts(tools) {
|
|
77
|
+
const pending = [tools];
|
|
78
|
+
while (pending.length > 0) {
|
|
79
|
+
const value = pending.pop();
|
|
80
|
+
if (typeof value !== "object" || value === null || Object.isFrozen(value))
|
|
81
|
+
continue;
|
|
82
|
+
Object.freeze(value);
|
|
83
|
+
for (const item of Object.values(value))
|
|
84
|
+
pending.push(item);
|
|
85
|
+
}
|
|
86
|
+
return tools;
|
|
104
87
|
}
|
|
105
88
|
function namespaced(storage, prefix) {
|
|
106
89
|
return {
|
|
90
|
+
capabilities: storage.capabilities,
|
|
107
91
|
get: (k) => storage.get(prefix + k),
|
|
108
92
|
set: (k, v, o) => storage.set(prefix + k, v, o),
|
|
109
93
|
delete: (k) => storage.delete(prefix + k),
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
list: async (keyPrefix) => (await storage.list(prefix + keyPrefix)).map((key) => key.slice(prefix.length)),
|
|
113
|
-
}
|
|
114
|
-
: {}),
|
|
115
|
-
...(storage.compareAndSet
|
|
116
|
-
? {
|
|
117
|
-
compareAndSet: (k, expected, next, o) => storage.compareAndSet(prefix + k, expected, next, o),
|
|
118
|
-
}
|
|
119
|
-
: {}),
|
|
94
|
+
list: async (keyPrefix) => (await storage.list(prefix + keyPrefix)).map((key) => key.slice(prefix.length)),
|
|
95
|
+
compareAndSet: (k, expected, next, o) => storage.compareAndSet(prefix + k, expected, next, o),
|
|
120
96
|
};
|
|
121
97
|
}
|
|
122
98
|
const MAX_PERSONAL_REGISTRIES = 1_024;
|
|
123
99
|
const MAX_ABSENT_GRANT_WARNINGS = 1_024;
|
|
124
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Compare-and-set attempts one stash makes on the ledger. A lost swap means
|
|
102
|
+
* another stash changed the ledger first, never that capacity ran out, so
|
|
103
|
+
* the loser re-reads and plans again after a jittered, growing pause: of 64
|
|
104
|
+
* simultaneous claims, each one either books a charge or meets a full ledger.
|
|
105
|
+
* Exhausting the attempts takes seconds of sustained contention on the one
|
|
106
|
+
* ledger record, and then the caller keeps its preview.
|
|
107
|
+
*/
|
|
108
|
+
const STASH_LEDGER_ATTEMPTS = 32;
|
|
109
|
+
/** First backoff window after a lost swap; it doubles up to the cap. */
|
|
110
|
+
const STASH_LEDGER_BACKOFF_MS = 4;
|
|
111
|
+
const STASH_LEDGER_BACKOFF_CAP_MS = 250;
|
|
112
|
+
/** Whole chunk-write phase, measured from booking, including the header. */
|
|
113
|
+
const STASH_WRITE_TIMEOUT_MS = 30_000;
|
|
114
|
+
/** Total cleanup/release or settlement budget, including ledger retries. */
|
|
115
|
+
const STASH_COMPLETION_TIMEOUT_MS = 15_000;
|
|
116
|
+
/** Stop waiting without starting background storage work after the deadline. */
|
|
117
|
+
function completeStash(effect) {
|
|
118
|
+
return effect.pipe(Effect.asVoid, Effect.timeoutOrElse({ duration: Duration.millis(STASH_COMPLETION_TIMEOUT_MS), orElse: () => Effect.void }),
|
|
119
|
+
// onError finalizers are otherwise uninterruptible, including their I/O.
|
|
120
|
+
Effect.interruptible);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Live charges in a stored ledger. Anything malformed reads as empty: the
|
|
124
|
+
* ledger bounds an advisory cache, and the next successful swap replaces it.
|
|
125
|
+
*/
|
|
126
|
+
function stashCharges(raw) {
|
|
127
|
+
if (raw === null)
|
|
128
|
+
return [];
|
|
129
|
+
let parsed;
|
|
130
|
+
try {
|
|
131
|
+
parsed = jsonCodec.decode(raw);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
const entries = parsed?.entries;
|
|
137
|
+
if (!Array.isArray(entries))
|
|
138
|
+
return [];
|
|
139
|
+
return entries.filter((entry) => Array.isArray(entry) &&
|
|
140
|
+
entry.length === 3 &&
|
|
141
|
+
typeof entry[0] === "string" &&
|
|
142
|
+
Number.isSafeInteger(entry[1]) &&
|
|
143
|
+
entry[1] >= 0 &&
|
|
144
|
+
Number.isSafeInteger(entry[2]));
|
|
145
|
+
}
|
|
146
|
+
/** Caller time can nominate a charge for pruning, but only storage proves expiry. */
|
|
147
|
+
function liveStashCharges(raw, now) {
|
|
148
|
+
return Effect.gen(function* () {
|
|
149
|
+
const live = [];
|
|
150
|
+
const expired = new Map();
|
|
151
|
+
for (const entry of stashCharges(raw)) {
|
|
152
|
+
const deadline = entry[2];
|
|
153
|
+
if (deadline > now) {
|
|
154
|
+
live.push(entry);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
let proven = expired.get(deadline);
|
|
158
|
+
if (proven === undefined) {
|
|
159
|
+
// A successful absolute-expiry write followed by an absent read proves
|
|
160
|
+
// storage has passed this deadline, even when the caller runs ahead.
|
|
161
|
+
// These probes expire at the deadline they check; no cleanup is needed.
|
|
162
|
+
const key = stashExpiryKeys.deadline(deadline);
|
|
163
|
+
yield* storageSet(key, "live", { expiresAtMs: deadline });
|
|
164
|
+
proven = (yield* storageGet(key)) === null;
|
|
165
|
+
expired.set(deadline, proven);
|
|
166
|
+
}
|
|
167
|
+
if (!proven)
|
|
168
|
+
live.push(entry);
|
|
169
|
+
}
|
|
170
|
+
return live;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/** Rewrite the ledger with bounded retries; every stored charge already expires. */
|
|
174
|
+
function swapStashLedger(plan) {
|
|
175
|
+
return Effect.gen(function* () {
|
|
176
|
+
const ledger = stashLedgerKeys.ledger;
|
|
177
|
+
for (let attempt = 0; attempt < STASH_LEDGER_ATTEMPTS; attempt++) {
|
|
178
|
+
if (attempt > 0) {
|
|
179
|
+
const window = Math.min(STASH_LEDGER_BACKOFF_CAP_MS, STASH_LEDGER_BACKOFF_MS * 2 ** (attempt - 1));
|
|
180
|
+
yield* Effect.sleep(Duration.millis(Math.floor((yield* Random.next) * window) + 1));
|
|
181
|
+
}
|
|
182
|
+
const outcome = yield* Effect.gen(function* () {
|
|
183
|
+
const raw = yield* storageGet(ledger);
|
|
184
|
+
const now = yield* Clock.currentTimeMillis;
|
|
185
|
+
const planned = plan(yield* liveStashCharges(raw, now), now);
|
|
186
|
+
if (planned.entries !== undefined) {
|
|
187
|
+
const entries = planned.entries;
|
|
188
|
+
// The ledger row itself expires with its last charge. A delayed CAS
|
|
189
|
+
// cannot create an immortal row or restart any reservation's lifetime.
|
|
190
|
+
const next = entries.length ? jsonCodec.encode({ v: 1, entries }) : null;
|
|
191
|
+
const expiresAtMs = entries.reduce((latest, entry) => Math.max(latest, entry[2]), entries[0]?.[2] ?? now);
|
|
192
|
+
if (!(yield* storageCompareAndSet(ledger, raw, next, { expiresAtMs })))
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
return { status: "applied", result: planned.result };
|
|
196
|
+
}).pipe(Effect.catch(() => Effect.succeed(undefined)));
|
|
197
|
+
if (outcome !== undefined)
|
|
198
|
+
return outcome;
|
|
199
|
+
}
|
|
200
|
+
// The final CAS can commit and then throw too. Confirm a read-only plan
|
|
201
|
+
// once more, especially an already-booked reservation, without another CAS.
|
|
202
|
+
return yield* Effect.gen(function* () {
|
|
203
|
+
const raw = yield* storageGet(ledger);
|
|
204
|
+
const now = yield* Clock.currentTimeMillis;
|
|
205
|
+
const planned = plan(yield* liveStashCharges(raw, now), now);
|
|
206
|
+
return planned.entries === undefined
|
|
207
|
+
? { status: "applied", result: planned.result }
|
|
208
|
+
: { status: "exhausted" };
|
|
209
|
+
}).pipe(Effect.catch(() => Effect.succeed({ status: "exhausted" })));
|
|
210
|
+
});
|
|
211
|
+
}
|
|
125
212
|
async function sha256Hex(value) {
|
|
126
213
|
const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value)));
|
|
127
|
-
return [...bytes]
|
|
128
|
-
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
129
|
-
.join("");
|
|
214
|
+
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
130
215
|
}
|
|
131
216
|
/**
|
|
132
|
-
* Holds the connector set, resolves addresses, and
|
|
133
|
-
*
|
|
217
|
+
* Holds the connector set, resolves addresses, and coalesces tool listings
|
|
218
|
+
* within one request. Remote MCP clients own persisted response caching. Connector failures are isolated: a broken
|
|
134
219
|
* connector surfaces status "error"; the rest keep working.
|
|
135
220
|
*/
|
|
136
221
|
export class Registry {
|
|
137
222
|
opts;
|
|
138
223
|
connectors = new Map();
|
|
139
224
|
callAdmission = new Map();
|
|
140
|
-
|
|
141
|
-
invalidated = new Set();
|
|
142
|
-
/** Per-connector epoch preventing a pre-invalidation refresh from publishing. */
|
|
143
|
-
catalogGenerations = new Map();
|
|
144
|
-
// The last queued mutation per connector, as the turn its own request
|
|
145
|
-
// completes once its storage work is done; see enqueueCatalogMutation.
|
|
146
|
-
/** Serialize persisted catalog set/delete operations within this isolate. */
|
|
147
|
-
catalogMutations = new Map();
|
|
148
|
-
/** Same-request cold loads share one read without retaining the request.
|
|
149
|
-
* See documentation/architecture.md#the-two-lifetimes. */
|
|
225
|
+
/** Coalesce listings only within one request and authorization partition. */
|
|
150
226
|
requestCatalogLoads = new WeakMap();
|
|
151
|
-
|
|
152
|
-
catalogRefreshes = new Map();
|
|
227
|
+
catalogObservedAt = new Map();
|
|
153
228
|
/** Last payload-free agent catalog access in this runtime. */
|
|
154
229
|
catalogAccess = new Map();
|
|
155
|
-
/**
|
|
156
|
-
|
|
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();
|
|
157
237
|
observedOutputSchemas;
|
|
158
|
-
ttlMs;
|
|
159
|
-
staleMs;
|
|
160
|
-
persistToolCatalog;
|
|
161
238
|
/** Result-size guard cap threaded to the meta-tools. */
|
|
162
239
|
maxResultBytes;
|
|
163
|
-
|
|
164
|
-
resultStash = new Map();
|
|
165
|
-
resultStashBytes = 0;
|
|
240
|
+
classification;
|
|
166
241
|
configuredConnectors;
|
|
167
242
|
personalRegistries = new Map();
|
|
168
243
|
oauthPartition = {};
|
|
@@ -173,13 +248,9 @@ export class Registry {
|
|
|
173
248
|
this.opts = opts;
|
|
174
249
|
this.configuredConnectors = [...connectors];
|
|
175
250
|
this.observedOutputSchemas = new ObservedOutputSchemas();
|
|
176
|
-
this.ttlMs =
|
|
177
|
-
(opts.toolCacheTtlSeconds ?? DEFAULT_TTL_SECONDS) * 1000;
|
|
178
|
-
this.staleMs =
|
|
179
|
-
(opts.toolCatalogStaleSeconds ?? DEFAULT_STALE_SECONDS) * 1000;
|
|
180
|
-
this.persistToolCatalog = opts.persistToolCatalog ?? true;
|
|
181
251
|
this.maxResultBytes = resolveMaxResultBytes(opts.maxResultBytes, DEFAULT_MAX_RESULT_BYTES);
|
|
182
252
|
for (const c of connectors) {
|
|
253
|
+
assertStaticToolNames(c.staticTools ?? [], `Connector(${JSON.stringify(c.id)}).staticTools`);
|
|
183
254
|
if ("handleRequest" in c) {
|
|
184
255
|
throw new Error(`Connector "${c.id}" declares removed handleRequest. ` +
|
|
185
256
|
"Move custom HTTP routes into the deployment's fetch handler.");
|
|
@@ -190,29 +261,46 @@ export class Registry {
|
|
|
190
261
|
if (this.connectors.has(c.id)) {
|
|
191
262
|
throw new Error(`Duplicate connector id "${c.id}"`);
|
|
192
263
|
}
|
|
193
|
-
if (c.authScope !== undefined &&
|
|
194
|
-
c.authScope !== "shared" &&
|
|
195
|
-
c.authScope !== "personal") {
|
|
264
|
+
if (c.authScope !== undefined && c.authScope !== "shared" && c.authScope !== "personal") {
|
|
196
265
|
throw new Error(`Invalid authScope on connector "${c.id}": expected "shared" or "personal"`);
|
|
197
266
|
}
|
|
198
|
-
const configuredGuideSummary = typeof c.usageGuide === "object"
|
|
199
|
-
|
|
200
|
-
: undefined;
|
|
201
|
-
if (configuredGuideSummary !== undefined &&
|
|
202
|
-
configuredGuideSummary.length > GUIDE_SUMMARY_LENGTH) {
|
|
267
|
+
const configuredGuideSummary = typeof c.usageGuide === "object" ? normalizeGuideSummary(c.usageGuide.summary ?? "") : undefined;
|
|
268
|
+
if (configuredGuideSummary !== undefined && configuredGuideSummary.length > GUIDE_SUMMARY_LENGTH) {
|
|
203
269
|
throw new Error(`Connector "${c.id}" usageGuide.summary is ` +
|
|
204
270
|
`${configuredGuideSummary.length} characters after whitespace ` +
|
|
205
271
|
`normalization; the discovery bound is ${GUIDE_SUMMARY_LENGTH}. ` +
|
|
206
272
|
"Shorten it or omit it to derive one.");
|
|
207
273
|
}
|
|
274
|
+
// Validated here, before any request (INV-11), and read once per
|
|
275
|
+
// connector object.
|
|
276
|
+
if (catalogReviewOf(c) && c.staticTools) {
|
|
277
|
+
throw new Error(`Connector "${c.id}" declares both staticTools and a classification; ` + "annotate static tools directly.");
|
|
278
|
+
}
|
|
208
279
|
this.connectors.set(c.id, c);
|
|
209
280
|
if (c.callAdmission) {
|
|
210
281
|
this.callAdmission.set(c.id, new ConnectorCallAdmissionController(c.id, c.callAdmission));
|
|
211
282
|
}
|
|
212
283
|
}
|
|
284
|
+
this.classification = Object.assign(Object.create(null), Object.fromEntries(Object.entries(opts.classification ?? {}).map(([id, tools]) => [
|
|
285
|
+
id,
|
|
286
|
+
Object.freeze(Object.assign(Object.create(null), tools)),
|
|
287
|
+
])));
|
|
288
|
+
Object.freeze(this.classification);
|
|
289
|
+
for (const [id, overrides] of Object.entries(this.classification)) {
|
|
290
|
+
for (const [name, verdict] of Object.entries(overrides)) {
|
|
291
|
+
if (!name || (verdict !== "read" && verdict !== "write")) {
|
|
292
|
+
throw new Error(`ConnectaConfig.classification.${id}.${name}: expected "read" or "write"`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const connector = this.connectors.get(id);
|
|
296
|
+
if (!connector)
|
|
297
|
+
throw new Error(`ConnectaConfig.classification: unknown connector "${id}"`);
|
|
298
|
+
if (connector.staticTools)
|
|
299
|
+
this.validateClassification(id, connector.staticTools, overrides);
|
|
300
|
+
}
|
|
301
|
+
this.assertResultCaps(opts.maxResultBytes);
|
|
213
302
|
if (opts.constructionChecks !== false) {
|
|
214
303
|
this.checkConventions(opts.logger);
|
|
215
|
-
this.checkResultCaps(opts.logger, opts.maxResultBytes);
|
|
216
304
|
}
|
|
217
305
|
}
|
|
218
306
|
personalRegistry(principalKey) {
|
|
@@ -224,15 +312,9 @@ export class Registry {
|
|
|
224
312
|
}
|
|
225
313
|
if (this.personalRegistries.size >= MAX_PERSONAL_REGISTRIES) {
|
|
226
314
|
// Eviction must not reset a live rolling budget or orphan queued calls.
|
|
227
|
-
// Nor may it drop catalog work in flight: a replacement for the same
|
|
228
|
-
// principal starts with no generations and no mutation queue, so a
|
|
229
|
-
// refresh the evicted registry finishes later would persist a listing
|
|
230
|
-
// that a credential change on the replacement had just deleted.
|
|
231
315
|
// OAuth status/start work bypasses both gates, and accepted rotations
|
|
232
316
|
// may still be saving after their caller leaves. Preserve its partition.
|
|
233
|
-
const idle = [...this.personalRegistries].find(([, candidate]) => [...candidate.callAdmission.values()].every(admission => admission.isIdle()) &&
|
|
234
|
-
candidate.catalogRefreshes.size === 0 &&
|
|
235
|
-
candidate.catalogMutations.size === 0 &&
|
|
317
|
+
const idle = [...this.personalRegistries].find(([, candidate]) => [...candidate.callAdmission.values()].every((admission) => admission.isIdle()) &&
|
|
236
318
|
oauthPartitionIdle(candidate.oauthPartition));
|
|
237
319
|
if (!idle) {
|
|
238
320
|
throw new Error("Personal connector capacity is exhausted; retry after calls, rolling budgets, and catalog refreshes drain.");
|
|
@@ -242,10 +324,15 @@ export class Registry {
|
|
|
242
324
|
}
|
|
243
325
|
const registry = new Registry(this.configuredConnectors.filter((connector) => connector.authScope === "personal"), {
|
|
244
326
|
...this.opts,
|
|
245
|
-
|
|
327
|
+
classification: Object.fromEntries(Object.entries(this.classification).filter(([id]) => this.connectors.get(id)?.authScope === "personal")),
|
|
328
|
+
storage: namespaced(this.opts.storage, scopes.principal(principalKey)),
|
|
246
329
|
credentialOwner: principalKey,
|
|
330
|
+
catalogStorage: this.opts.catalogStorage ?? this.opts.storage,
|
|
247
331
|
constructionChecks: false,
|
|
248
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;
|
|
249
336
|
if (this.callAdmissionClosed)
|
|
250
337
|
registry.closeCallAdmission();
|
|
251
338
|
this.personalRegistries.set(principalKey, registry);
|
|
@@ -253,9 +340,7 @@ export class Registry {
|
|
|
253
340
|
}
|
|
254
341
|
/** Build the only connector view an authenticated request receives. */
|
|
255
342
|
scoped(scope) {
|
|
256
|
-
const requested = scope.connectorIds === "all"
|
|
257
|
-
? new Set(this.connectors.keys())
|
|
258
|
-
: new Set(scope.connectorIds);
|
|
343
|
+
const requested = scope.connectorIds === "all" ? new Set(this.connectors.keys()) : new Set(scope.connectorIds);
|
|
259
344
|
for (const id of requested) {
|
|
260
345
|
if (!this.connectors.has(id)) {
|
|
261
346
|
throw new Error(`Identity access resolver returned unknown connector "${id}"`);
|
|
@@ -264,7 +349,7 @@ export class Registry {
|
|
|
264
349
|
return new ScopedRegistryView(this, requested, scope);
|
|
265
350
|
}
|
|
266
351
|
scopedStorage(subjectKey) {
|
|
267
|
-
return namespaced(this.opts.storage,
|
|
352
|
+
return namespaced(this.opts.storage, scopes.subject(subjectKey));
|
|
268
353
|
}
|
|
269
354
|
/**
|
|
270
355
|
* A granted `connector.tool` address the live catalog does not contain is
|
|
@@ -282,87 +367,70 @@ export class Registry {
|
|
|
282
367
|
if (oldest !== undefined)
|
|
283
368
|
this.warnedAbsentGrants.delete(oldest);
|
|
284
369
|
}
|
|
370
|
+
if (hasControlCharacters(toolName)) {
|
|
371
|
+
logFailure(this.opts.logger, "connectorAccess grant is unreachable", failureRecord({ connector: connectorId }));
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
285
374
|
// Grant names are operator data but may carry any non-control character;
|
|
286
375
|
// quote them so a line terminator a log reader honours cannot forge a line.
|
|
287
376
|
const quoted = JSON.stringify(key).replace(/[\u2028\u2029]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16)}`);
|
|
288
377
|
this.opts.logger.warn(`connectorAccess grants ${quoted} but connector "${connectorId}" lists no such tool; the grant is unreachable`);
|
|
289
378
|
}
|
|
290
|
-
oauthHandoffKey(connectorId, stateHash) {
|
|
291
|
-
return `oauth-handoff:v1:${connectorId}:${stateHash}`;
|
|
292
|
-
}
|
|
293
379
|
async storeOAuthHandoff(connectorId, state, principalKey) {
|
|
294
|
-
const key =
|
|
380
|
+
const key = oauthHandoffKeys.handoff(connectorId, await sha256Hex(state));
|
|
295
381
|
for (let attempt = 0; attempt < 32; attempt++) {
|
|
296
382
|
const existing = await this.opts.storage.get(key);
|
|
297
383
|
if (existing && existing !== principalKey) {
|
|
298
384
|
throw new Error(`Connector "${connectorId}" reused one OAuth state across principals`);
|
|
299
385
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
307
|
-
else {
|
|
308
|
-
await this.opts.storage.set(key, principalKey, expiry);
|
|
309
|
-
}
|
|
310
|
-
return;
|
|
386
|
+
// Bind ownership atomically, including renewal of the same owner's
|
|
387
|
+
// handoff. Two owners reading a miss must not overwrite each other.
|
|
388
|
+
if (await this.opts.storage.compareAndSet(key, existing, principalKey, {
|
|
389
|
+
ttlSeconds: OAUTH_HANDOFF_TTL_SECONDS,
|
|
390
|
+
}))
|
|
391
|
+
return;
|
|
311
392
|
}
|
|
312
393
|
throw new Error(`OAuth handoff for "${connectorId}" is busy; retry authorization`);
|
|
313
394
|
}
|
|
314
395
|
async oauthCallbackView(connectorId, state) {
|
|
315
|
-
const connector = this.connectors.get(connectorId);
|
|
316
|
-
if (!connector)
|
|
317
|
-
return null;
|
|
318
|
-
if (connector.authScope !== "personal")
|
|
319
|
-
return { registry: this };
|
|
320
396
|
if (!state)
|
|
321
397
|
return null;
|
|
322
|
-
const principalKey = await this.opts.storage.get(
|
|
323
|
-
|
|
398
|
+
const principalKey = await this.opts.storage.get(oauthHandoffKeys.handoff(connectorId, await sha256Hex(state)));
|
|
399
|
+
const connector = this.connectors.get(connectorId);
|
|
400
|
+
if (!connector || !principalKey)
|
|
324
401
|
return null;
|
|
325
402
|
return {
|
|
326
|
-
registry:
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
403
|
+
registry: connector.authScope === "personal"
|
|
404
|
+
? this.scoped({
|
|
405
|
+
connectorIds: [connectorId],
|
|
406
|
+
subjectKey: principalKey,
|
|
407
|
+
principalKey,
|
|
408
|
+
})
|
|
409
|
+
: this,
|
|
331
410
|
principalKey,
|
|
332
411
|
};
|
|
333
412
|
}
|
|
334
|
-
async
|
|
413
|
+
async consumeOAuthHandoff(connectorId, state, principalKey) {
|
|
335
414
|
if (!state)
|
|
336
|
-
return;
|
|
337
|
-
|
|
415
|
+
return false;
|
|
416
|
+
const key = oauthHandoffKeys.handoff(connectorId, await sha256Hex(state));
|
|
417
|
+
return this.opts.storage.compareAndSet(key, principalKey, null);
|
|
338
418
|
}
|
|
339
419
|
/**
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
* told which one is actually in force.
|
|
420
|
+
* Refuse an unusable result cap at construction, like every other
|
|
421
|
+
* structural mistake (INV-11). A rejected cap can't be honoured, and
|
|
422
|
+
* honouring it *approximately* is exactly the inversion issue #32 is about;
|
|
423
|
+
* falling back with a warning let a deployment boot on a value nobody
|
|
424
|
+
* chose.
|
|
346
425
|
*/
|
|
347
|
-
|
|
426
|
+
assertResultCaps(configured) {
|
|
348
427
|
if (configured !== undefined && !isValidMaxResultBytes(configured)) {
|
|
349
|
-
|
|
350
|
-
`bytes >= ${MIN_MAX_RESULT_BYTES}: it would serve an empty, ` +
|
|
351
|
-
"oversized, or unguarded result instead of truncating. Using the " +
|
|
352
|
-
`default ${DEFAULT_MAX_RESULT_BYTES} instead.`);
|
|
428
|
+
throw new Error(`ConnectaConfig.calls.maxResultBytes must be a whole number of bytes >= ${MIN_MAX_RESULT_BYTES}.`);
|
|
353
429
|
}
|
|
354
430
|
for (const c of this.connectors.values()) {
|
|
355
|
-
if (c.maxResultBytes !== undefined &&
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
// literally what a call falls back to: meta-tools reads the inherited
|
|
359
|
-
// cap off `RegistryView.maxResultBytes`, so the warned value and the
|
|
360
|
-
// runtime value are the same field rather than two copies of it.
|
|
361
|
-
logger.warn(`[connecta] connector "${c.id}" sets maxResultBytes ` +
|
|
362
|
-
`${c.maxResultBytes}, which is not a whole number of bytes >= ` +
|
|
363
|
-
`${MIN_MAX_RESULT_BYTES}. Ignoring the override — the connector ` +
|
|
364
|
-
`inherits the deployment-wide cap calls fall back to ` +
|
|
365
|
-
`(${this.maxResultBytes}).`);
|
|
431
|
+
if (c.maxResultBytes !== undefined && !isValidMaxResultBytes(c.maxResultBytes)) {
|
|
432
|
+
throw new Error(`Connector "${c.id}" maxResultBytes must be a whole number of bytes >= ` +
|
|
433
|
+
`${MIN_MAX_RESULT_BYTES}; omit it to inherit the deployment-wide cap.`);
|
|
366
434
|
}
|
|
367
435
|
}
|
|
368
436
|
}
|
|
@@ -393,7 +461,13 @@ export class Registry {
|
|
|
393
461
|
getConnector(id) {
|
|
394
462
|
return this.connectors.get(id);
|
|
395
463
|
}
|
|
396
|
-
|
|
464
|
+
getResourceConnector(id) {
|
|
465
|
+
return this.getConnector(id);
|
|
466
|
+
}
|
|
467
|
+
canReadConnectorSkills(id) {
|
|
468
|
+
return this.connectors.has(id);
|
|
469
|
+
}
|
|
470
|
+
contextFor(id, baseUrl, requestScope = {}, callOptions = {}, scope) {
|
|
397
471
|
const credentialConfig = this.connectors.get(id)?.credential;
|
|
398
472
|
let credentialAccess;
|
|
399
473
|
if (this.opts.credentialVault && credentialConfig) {
|
|
@@ -415,13 +489,42 @@ export class Registry {
|
|
|
415
489
|
};
|
|
416
490
|
}
|
|
417
491
|
const context = {
|
|
418
|
-
storage: namespaced(this.opts.storage,
|
|
492
|
+
storage: namespaced(this.opts.storage, scopes.connector(id)),
|
|
419
493
|
logger: this.opts.logger,
|
|
420
494
|
baseUrl,
|
|
495
|
+
...(this.opts.publicUrl !== undefined ? { publicUrl: this.opts.publicUrl } : {}),
|
|
496
|
+
...(this.opts.oauthClientName !== undefined ? { oauthClientName: this.opts.oauthClientName } : {}),
|
|
421
497
|
...(credentialAccess ? { credential: credentialAccess } : {}),
|
|
422
498
|
requestScope,
|
|
423
499
|
...callOptions,
|
|
424
500
|
};
|
|
501
|
+
attachCaller(context, scope?.caller);
|
|
502
|
+
attachCatalogCache(context, {
|
|
503
|
+
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
|
+
]),
|
|
511
|
+
sharedPartition: JSON.stringify([baseUrl, this.opts.publicUrl ?? null, scope?.caller?.pool ?? null]),
|
|
512
|
+
defaultTtlMs: (this.opts.toolCacheTtlSeconds ?? CONFIG_DEFAULTS.discovery.catalogTtlSeconds) * 1000,
|
|
513
|
+
minTtlMs: (this.opts.catalogMinTtlSeconds ?? CONFIG_DEFAULTS.discovery.catalogMinTtlSeconds) * 1000,
|
|
514
|
+
maxTtlMs: (this.opts.catalogMaxTtlSeconds ?? CONFIG_DEFAULTS.discovery.catalogMaxTtlSeconds) * 1000,
|
|
515
|
+
...(this.opts.catalogDriftActivity?.recordChange
|
|
516
|
+
? {
|
|
517
|
+
onCompletedCatalogRefresh: (refresh, ctx) => this.recordCatalogDrift(refresh.previous, refresh.next, {
|
|
518
|
+
id,
|
|
519
|
+
connector: this.connectors.get(id),
|
|
520
|
+
ctx,
|
|
521
|
+
privateCatalog: refresh.private,
|
|
522
|
+
}),
|
|
523
|
+
}
|
|
524
|
+
: {}),
|
|
525
|
+
});
|
|
526
|
+
sentSecretsFor(context);
|
|
527
|
+
trackCredentialReads(context);
|
|
425
528
|
attachOAuthPartition(context, this.oauthPartition);
|
|
426
529
|
// Downstream OAuth state is sealed under the vault key, bound to this
|
|
427
530
|
// connector and owner. The sealer rides beside the context, not on it.
|
|
@@ -446,71 +549,28 @@ export class Registry {
|
|
|
446
549
|
snapshots.get(id)?.push(admission.snapshot());
|
|
447
550
|
}
|
|
448
551
|
}
|
|
449
|
-
return Object.fromEntries([...snapshots].map(([id, values]) => [
|
|
450
|
-
id, aggregateCallAdmissionSnapshots(values),
|
|
451
|
-
]));
|
|
552
|
+
return Object.fromEntries([...snapshots].map(([id, values]) => [id, aggregateCallAdmissionSnapshots(values)]));
|
|
452
553
|
}
|
|
453
554
|
/**
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
* served a refresh *in this runtime* appear — a process or isolate that has
|
|
458
|
-
* answered no catalog request yet honestly reports nothing, and drift is not
|
|
459
|
-
* persisted the way the catalog itself is.
|
|
460
|
-
*
|
|
461
|
-
* Every report is rebuilt by {@link boundedCatalogDrift} on the way out:
|
|
462
|
-
* `Connector.catalogDrift()` is the open plugin seam, and this snapshot is
|
|
463
|
-
* serialized into an unauthenticated response.
|
|
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.
|
|
464
558
|
*/
|
|
465
|
-
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
* connector status. A first observation that is clean is not an event
|
|
480
|
-
* either — nothing moved — but a later return to clean is, because "the
|
|
481
|
-
* drift is gone" is exactly what an operator watching the timeline is
|
|
482
|
-
* waiting for.
|
|
483
|
-
*/
|
|
484
|
-
observeCatalogDrift(connector) {
|
|
485
|
-
const report = boundedCatalogDrift(connector.catalogDrift?.());
|
|
486
|
-
if (!report)
|
|
487
|
-
return;
|
|
488
|
-
const previous = this.reportedDrift.get(connector.id);
|
|
489
|
-
// Bounded counts, so a seam returning NaN cannot make every refresh look
|
|
490
|
-
// like a change and emit an event per refresh forever.
|
|
491
|
-
const counts = {
|
|
492
|
-
unclassifiedTools: report.unclassifiedTools,
|
|
493
|
-
unservedTools: report.unservedTools,
|
|
494
|
-
annotationConflicts: report.annotationConflicts,
|
|
495
|
-
schemaChanges: report.schemaChanges,
|
|
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 } : {}),
|
|
496
573
|
};
|
|
497
|
-
const unchanged = previous !== undefined &&
|
|
498
|
-
previous.unclassifiedTools === counts.unclassifiedTools &&
|
|
499
|
-
previous.unservedTools === counts.unservedTools &&
|
|
500
|
-
previous.annotationConflicts === counts.annotationConflicts &&
|
|
501
|
-
previous.schemaChanges === counts.schemaChanges;
|
|
502
|
-
if (unchanged)
|
|
503
|
-
return;
|
|
504
|
-
const clean = counts.unclassifiedTools === 0 &&
|
|
505
|
-
counts.unservedTools === 0 &&
|
|
506
|
-
counts.annotationConflicts === 0 &&
|
|
507
|
-
counts.schemaChanges === 0;
|
|
508
|
-
this.reportedDrift.set(connector.id, counts);
|
|
509
|
-
if (previous === undefined && clean)
|
|
510
|
-
return;
|
|
511
|
-
this.opts.catalogDriftActivity?.recordDrift?.(this.opts.catalogDriftActivity
|
|
512
|
-
? { ...this.opts.catalogDriftActivity, logger: this.opts.logger }
|
|
513
|
-
: undefined, { connectorId: connector.id, ...counts });
|
|
514
574
|
}
|
|
515
575
|
/** Reject queued/future downstream admission; active permits release safely. */
|
|
516
576
|
closeCallAdmission() {
|
|
@@ -521,75 +581,128 @@ export class Registry {
|
|
|
521
581
|
registry.closeCallAdmission();
|
|
522
582
|
}
|
|
523
583
|
/**
|
|
524
|
-
* Reserve capacity and write one ASCII paging envelope
|
|
584
|
+
* Reserve capacity and write one ASCII paging envelope.
|
|
585
|
+
*
|
|
586
|
+
* `chunks[n]` lands on `resultKeys.chunk(id, n)` inside `partition` — the
|
|
587
|
+
* layout connecta.result reads back, so a page fetches only the chunks it covers
|
|
588
|
+
* instead of the whole stored result (issue #540). Chunking is a read-cost
|
|
589
|
+
* decision, not a capacity one: however many keys an envelope occupies, it
|
|
590
|
+
* is one stash entry charged its total ASCII length.
|
|
525
591
|
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
592
|
+
* The bounds are the deployment's, not this isolate's. Every charge is a
|
|
593
|
+
* row in one ledger record in storage, booked by compare-and-set before any
|
|
594
|
+
* chunk is written, so every isolate and process sharing the store sees the
|
|
595
|
+
* same entries and bytes. A pending write keeps its finite charge reserved.
|
|
596
|
+
* Writes stop after 30 seconds; absolute chunk expiries cannot pass the
|
|
597
|
+
* booked deadline even on late commit. Rejected responses leave writes
|
|
598
|
+
* unconfirmed and retain their charge. Cleanup/release and settlement each
|
|
599
|
+
* stop waiting after 15 seconds. Storage TTL reclaims the rows.
|
|
531
600
|
*/
|
|
532
|
-
stashResult(
|
|
533
|
-
// The fiber runs synchronously up to its first storage call. With nothing
|
|
534
|
-
// expired to reclaim, that is the first chunk write, after the
|
|
535
|
-
// reservation: a concurrent stash already sees this one's charge.
|
|
601
|
+
stashResult(id, chunks, ttlSeconds, partition = scopes.results) {
|
|
536
602
|
return runOnPartition(Effect.gen({ self: this }, function* () {
|
|
537
|
-
const maxBytes = this.opts.results?.maxStashBytes ??
|
|
538
|
-
const maxEntries = this.opts.results?.maxStashEntries ??
|
|
603
|
+
const maxBytes = this.opts.results?.maxStashBytes ?? CONFIG_DEFAULTS.results.maxStashBytes;
|
|
604
|
+
const maxEntries = this.opts.results?.maxStashEntries ?? CONFIG_DEFAULTS.results.maxStashEntries;
|
|
539
605
|
// The paging envelope is ASCII, so its string length is its stored byte count.
|
|
540
606
|
const bytes = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
541
607
|
if (bytes > maxBytes || maxEntries === 0)
|
|
542
608
|
return false;
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
//
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
609
|
+
const keys = chunks.map((_, index) => partition + resultKeys.chunk(id, index));
|
|
610
|
+
// IDs belong to reservations, not reusable result IDs or subjects.
|
|
611
|
+
const charge = crypto.randomUUID();
|
|
612
|
+
const ttlMs = ttlSeconds * 1000;
|
|
613
|
+
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0)
|
|
614
|
+
return false;
|
|
615
|
+
const booking = yield* swapStashLedger((live, now) => {
|
|
616
|
+
// A committed CAS with a lost response is already our booking, even
|
|
617
|
+
// at full capacity. Reuse its deadline instead of adding a second slot.
|
|
618
|
+
const own = live.find((entry) => entry[0] === charge);
|
|
619
|
+
if (own)
|
|
620
|
+
return { result: own[2] };
|
|
621
|
+
const used = live.reduce((sum, entry) => sum + entry[1], 0);
|
|
622
|
+
if (live.length >= maxEntries || used + bytes > maxBytes)
|
|
623
|
+
return { result: undefined };
|
|
624
|
+
const deadline = now + STASH_WRITE_TIMEOUT_MS + ttlMs;
|
|
625
|
+
if (!Number.isSafeInteger(deadline))
|
|
626
|
+
return { result: undefined };
|
|
627
|
+
return { entries: [...live, [charge, bytes, deadline]], result: deadline };
|
|
628
|
+
});
|
|
629
|
+
if (booking.status === "exhausted" || booking.result === undefined)
|
|
559
630
|
return false;
|
|
560
|
-
const
|
|
561
|
-
const
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
631
|
+
const deadline = booking.result;
|
|
632
|
+
const writeDeadline = deadline - ttlMs;
|
|
633
|
+
const chunkExpiry = deadline - STASH_WRITE_TIMEOUT_MS;
|
|
634
|
+
const owns = (entry) => entry[0] === charge;
|
|
635
|
+
const settle = swapStashLedger((live) => live.some((entry) => owns(entry) && entry[2] > chunkExpiry)
|
|
636
|
+
? {
|
|
637
|
+
entries: live.map((entry) => (owns(entry) ? [charge, bytes, Math.min(entry[2], chunkExpiry)] : entry)),
|
|
638
|
+
result: undefined,
|
|
639
|
+
}
|
|
640
|
+
: { result: undefined });
|
|
641
|
+
let pendingWrite = false;
|
|
642
|
+
// A rejected or timed-out driver call may still commit after deletion.
|
|
643
|
+
// Only a confirmed write response clears pendingWrite.
|
|
644
|
+
const release = completeStash(Effect.gen(function* () {
|
|
645
|
+
const deleted = yield* Effect.gen(function* () {
|
|
646
|
+
for (const key of keys)
|
|
647
|
+
yield* storageDelete(key);
|
|
648
|
+
return true;
|
|
649
|
+
}).pipe(Effect.catch(() => Effect.succeed(false)));
|
|
650
|
+
if (pendingWrite)
|
|
651
|
+
return;
|
|
652
|
+
if (deleted) {
|
|
653
|
+
const released = yield* swapStashLedger((live) => live.some(owns)
|
|
654
|
+
? { entries: live.filter((entry) => !owns(entry)), result: undefined }
|
|
655
|
+
: { result: undefined });
|
|
656
|
+
if (released.status === "applied")
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
yield* settle;
|
|
660
|
+
}));
|
|
661
|
+
// Trailing chunks first, then the header. The caller receives no result
|
|
662
|
+
// ID after failure or timeout, including a late header commit.
|
|
663
|
+
const remaining = Math.max(0, writeDeadline - (yield* Clock.currentTimeMillis));
|
|
664
|
+
const written = yield* Effect.gen(function* () {
|
|
568
665
|
for (let index = keys.length - 1; index >= 0; index--) {
|
|
569
|
-
|
|
666
|
+
const before = yield* Clock.currentTimeMillis;
|
|
667
|
+
if (before >= writeDeadline || before >= chunkExpiry)
|
|
668
|
+
return false;
|
|
669
|
+
pendingWrite = true;
|
|
670
|
+
yield* storageSet(keys[index], chunks[index], { expiresAtMs: chunkExpiry }).pipe(Effect.tap(() => Effect.sync(() => {
|
|
671
|
+
pendingWrite = false;
|
|
672
|
+
})));
|
|
673
|
+
if ((yield* Clock.currentTimeMillis) >= writeDeadline)
|
|
674
|
+
return false;
|
|
570
675
|
}
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
entry.busy = false;
|
|
579
|
-
}))));
|
|
580
|
-
return true;
|
|
676
|
+
return true;
|
|
677
|
+
}).pipe(Effect.timeoutOrElse({ duration: Duration.millis(remaining), orElse: () => Effect.succeed(false) }), Effect.onError(() => release.pipe(Effect.ignore)));
|
|
678
|
+
if (!written)
|
|
679
|
+
yield* release;
|
|
680
|
+
else
|
|
681
|
+
yield* completeStash(settle);
|
|
682
|
+
return written;
|
|
581
683
|
}), this.opts);
|
|
582
684
|
}
|
|
583
685
|
/**
|
|
584
|
-
* Storage namespaced to the
|
|
585
|
-
*
|
|
686
|
+
* Storage namespaced to the root result partition, kept separate from any
|
|
687
|
+
* connector's namespace. Backs connecta.result.
|
|
586
688
|
*/
|
|
689
|
+
resultIdentity() {
|
|
690
|
+
return { subject: null, principal: null, endpoint: "/mcp", origin: null };
|
|
691
|
+
}
|
|
692
|
+
recheckResultAccess(_address, _classification, _signal, _access) {
|
|
693
|
+
return Promise.resolve(true);
|
|
694
|
+
}
|
|
587
695
|
resultsStorage() {
|
|
588
|
-
return namespaced(this.opts.storage,
|
|
696
|
+
return namespaced(this.opts.storage, scopes.results);
|
|
589
697
|
}
|
|
590
|
-
credentialUiAvailable() {
|
|
591
|
-
|
|
592
|
-
|
|
698
|
+
credentialUiAvailable() {
|
|
699
|
+
return Boolean(this.opts.credentialUi);
|
|
700
|
+
}
|
|
701
|
+
async bindOAuthHandoff(id, authorizationUrl, principalKey) {
|
|
702
|
+
const state = new URL(authorizationUrl).searchParams.get("state");
|
|
703
|
+
if (!state || !principalKey)
|
|
704
|
+
throw new Error("OAuth requires state and an initiating user");
|
|
705
|
+
await this.storeOAuthHandoff(id, state, principalKey);
|
|
593
706
|
}
|
|
594
707
|
observedOutputSchema(connectorId, definition) {
|
|
595
708
|
return this.observedOutputSchemas.get(connectorId, definition);
|
|
@@ -607,608 +720,143 @@ export class Registry {
|
|
|
607
720
|
return null;
|
|
608
721
|
return { connector, toolName: parts.toolName };
|
|
609
722
|
}
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
!Number.isInteger(manifest.chunkCount) ||
|
|
637
|
-
manifest.chunkCount < 1 ||
|
|
638
|
-
manifest.chunkCount > maxChunks ||
|
|
639
|
-
typeof manifest.fetchedAt !== "number" ||
|
|
640
|
-
typeof manifest.expiresAt !== "number" ||
|
|
641
|
-
typeof manifest.staleUntil !== "number") {
|
|
642
|
-
return null;
|
|
643
|
-
}
|
|
644
|
-
return manifest;
|
|
645
|
-
}
|
|
646
|
-
parseCatalogManifest(raw) {
|
|
647
|
-
if (!raw)
|
|
648
|
-
return null;
|
|
723
|
+
/** Compare only the fingerprints accepted in this exact cache partition. */
|
|
724
|
+
recordCatalogDrift(previous, next, attribution) {
|
|
725
|
+
if (!previous)
|
|
726
|
+
return;
|
|
727
|
+
const { id, connector, ctx, privateCatalog } = attribution;
|
|
728
|
+
const before = new Map(previous.map((tool) => [tool.name, tool.fact]));
|
|
729
|
+
const after = new Map(next.map((tool) => [tool.name, tool.fact]));
|
|
730
|
+
const addedTools = [...after.keys()].filter((name) => !before.has(name)).length;
|
|
731
|
+
const removedTools = [...before.keys()].filter((name) => !after.has(name)).length;
|
|
732
|
+
const changedTools = [...after].filter(([name, fact]) => before.has(name) && before.get(name) !== fact).length;
|
|
733
|
+
if (addedTools || removedTools || changedTools)
|
|
734
|
+
this.opts.catalogDriftActivity?.recordChange?.({ ...this.opts.catalogDriftActivity, logger: this.opts.logger, ...(ctx.defer ? { defer: ctx.defer } : {}) }, {
|
|
735
|
+
connectorId: id,
|
|
736
|
+
drift: { kind: "catalog_changed", addedTools, removedTools, changedTools },
|
|
737
|
+
privateCatalog,
|
|
738
|
+
...(connector.authScope === "personal" ? { personal: true } : {}),
|
|
739
|
+
}, activityRequest(ctx.requestScope));
|
|
740
|
+
}
|
|
741
|
+
async loadDownstreamTools(id, baseUrl, requestScope, callOptions = {}, scope) {
|
|
742
|
+
const connector = this.connectors.get(id);
|
|
743
|
+
if (!connector)
|
|
744
|
+
throw new Error(`Unknown connector "${id}"`);
|
|
745
|
+
if (connector.staticTools)
|
|
746
|
+
return markCatalogFreshness(connector.staticTools, Infinity);
|
|
747
|
+
const ctx = this.contextFor(id, baseUrl, requestScope, callOptions, scope);
|
|
748
|
+
let listed;
|
|
649
749
|
try {
|
|
650
|
-
|
|
750
|
+
listed = await connector.listTools(ctx);
|
|
651
751
|
}
|
|
652
|
-
catch {
|
|
653
|
-
|
|
752
|
+
catch (error) {
|
|
753
|
+
const fallback = error instanceof ConnectorCallError && error.code === "unavailable"
|
|
754
|
+
? await customCatalogFallback(ctx, id).catch(() => undefined)
|
|
755
|
+
: undefined;
|
|
756
|
+
if (!fallback)
|
|
757
|
+
throw sentSecretsForRequest(ctx.requestScope ?? ctx).redact(error);
|
|
758
|
+
listed = fallback;
|
|
654
759
|
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
fatal: true,
|
|
660
|
-
ignoreBOM: true,
|
|
661
|
-
});
|
|
662
|
-
let offset = 0;
|
|
663
|
-
while (offset < snapshot.serializedBytes.byteLength) {
|
|
664
|
-
let end = Math.min(offset + MAX_CATALOG_CHUNK_BYTES, snapshot.serializedBytes.byteLength);
|
|
665
|
-
// Move a boundary that landed inside a multibyte UTF-8 sequence back to
|
|
666
|
-
// the next character start. Every stored string then remains valid UTF-8.
|
|
667
|
-
while (end < snapshot.serializedBytes.byteLength &&
|
|
668
|
-
((snapshot.serializedBytes[end] ?? 0) & 0xc0) === 0x80) {
|
|
669
|
-
end--;
|
|
670
|
-
}
|
|
671
|
-
chunks.push(decoder.decode(snapshot.serializedBytes.subarray(offset, end)));
|
|
672
|
-
offset = end;
|
|
760
|
+
const tools = redactCatalog(ctx, listed).map(({ classification: _ignored, ...fact }) => fact);
|
|
761
|
+
if (tools.length > MAX_CATALOG_TOOLS ||
|
|
762
|
+
encoder.encode(JSON.stringify(tools)).byteLength > MAX_SERIALIZED_CATALOG_BYTES) {
|
|
763
|
+
throw new ConnectorCallError("connector_call_failed", "Downstream catalog exceeds the complete-catalog ceiling.", { retryable: false });
|
|
673
764
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
readCatalog(id, now) {
|
|
682
|
-
return Effect.gen({ self: this }, function* () {
|
|
683
|
-
const raw = yield* storageGet(this.catalogKey(id));
|
|
684
|
-
if (!raw)
|
|
685
|
-
return null;
|
|
686
|
-
let parsed;
|
|
765
|
+
const facts = frozenFacts(structuredClone(tools));
|
|
766
|
+
const accepted = this.acceptToolNames(facts);
|
|
767
|
+
this.observeDroppedToolNames(connector, facts.length - accepted.length);
|
|
768
|
+
const review = catalogReviewOf(connector);
|
|
769
|
+
if (review)
|
|
770
|
+
await observeReviewedDrift(connector, review, accepted, this.opts.logger);
|
|
771
|
+
if (catalogFetchedAt(ctx) === undefined) {
|
|
687
772
|
try {
|
|
688
|
-
|
|
689
|
-
}
|
|
690
|
-
catch {
|
|
691
|
-
return null;
|
|
773
|
+
await storeCustomCatalogFallback(ctx, id, facts);
|
|
692
774
|
}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
if (!manifest) {
|
|
696
|
-
logger.warn(`[connecta] connector "${id}" catalog manifest is invalid; ignoring persisted catalog.`);
|
|
697
|
-
return null;
|
|
698
|
-
}
|
|
699
|
-
if (manifest.staleUntil <= now)
|
|
700
|
-
return null;
|
|
701
|
-
const chunkReads = yield* forEachChunk(Array.from({ length: manifest.chunkCount }, (_, index) => index), (index) => storageGet(this.catalogChunkKey(id, manifest.revision, index)));
|
|
702
|
-
const chunks = [];
|
|
703
|
-
let chunkBytes = 0;
|
|
704
|
-
for (const [index, read] of chunkReads.entries()) {
|
|
705
|
-
if (Result.isFailure(read))
|
|
706
|
-
return yield* Effect.fail(read.failure);
|
|
707
|
-
const chunk = read.success;
|
|
708
|
-
if (chunk === null) {
|
|
709
|
-
logger.warn(`[connecta] connector "${id}" catalog chunk ${index + 1}/${manifest.chunkCount} is missing; ignoring persisted catalog.`);
|
|
710
|
-
return null;
|
|
711
|
-
}
|
|
712
|
-
const byteLength = encoder.encode(chunk).byteLength;
|
|
713
|
-
chunkBytes += byteLength;
|
|
714
|
-
if (byteLength > MAX_CATALOG_CHUNK_BYTES ||
|
|
715
|
-
chunkBytes > manifest.byteCount) {
|
|
716
|
-
logger.warn(`[connecta] connector "${id}" catalog chunk bounds do not match its manifest; ignoring persisted catalog.`);
|
|
717
|
-
return null;
|
|
718
|
-
}
|
|
719
|
-
chunks.push(chunk);
|
|
720
|
-
}
|
|
721
|
-
const serializedTools = chunks.join("");
|
|
722
|
-
const stored = yield* Effect.tryPromise({
|
|
723
|
-
try: () => fingerprintSerializedCatalog(serializedTools),
|
|
724
|
-
catch: (error) => error,
|
|
725
|
-
});
|
|
726
|
-
if (stored.byteLength !== manifest.byteCount ||
|
|
727
|
-
stored.fingerprint !== manifest.revision) {
|
|
728
|
-
logger.warn(`[connecta] connector "${id}" catalog fingerprint mismatch; ignoring persisted catalog.`);
|
|
729
|
-
return null;
|
|
775
|
+
catch (error) {
|
|
776
|
+
logFailure(this.opts.logger, "catalog refresh observation failed", failureRecord({ connector: id }, error));
|
|
730
777
|
}
|
|
731
|
-
let tools;
|
|
732
778
|
try {
|
|
733
|
-
|
|
779
|
+
await observeUncachedCatalogRefresh(ctx, id, facts);
|
|
734
780
|
}
|
|
735
|
-
catch {
|
|
736
|
-
logger
|
|
737
|
-
return null;
|
|
781
|
+
catch (error) {
|
|
782
|
+
logFailure(this.opts.logger, "catalog refresh observation failed", failureRecord({ connector: id }, error));
|
|
738
783
|
}
|
|
739
|
-
if (!Array.isArray(tools) || !this.validCatalogTools(tools)) {
|
|
740
|
-
logger.warn(`[connecta] connector "${id}" catalog chunks contain invalid tools; ignoring persisted catalog.`);
|
|
741
|
-
return null;
|
|
742
|
-
}
|
|
743
|
-
if (tools.length !== manifest.toolCount) {
|
|
744
|
-
logger.warn(`[connecta] connector "${id}" catalog tool count does not match its manifest; ignoring persisted catalog.`);
|
|
745
|
-
return null;
|
|
746
|
-
}
|
|
747
|
-
return {
|
|
748
|
-
tools,
|
|
749
|
-
fingerprint: stored.fingerprint,
|
|
750
|
-
fetchedAt: manifest.fetchedAt,
|
|
751
|
-
expiresAt: manifest.expiresAt,
|
|
752
|
-
staleUntil: manifest.staleUntil,
|
|
753
|
-
};
|
|
754
|
-
});
|
|
755
|
-
}
|
|
756
|
-
// Persist one complete catalog: every chunk, then the manifest. A snapshot
|
|
757
|
-
// only reaches here after the tool and byte ceilings accepted it whole.
|
|
758
|
-
storeCatalog(id, snapshot) {
|
|
759
|
-
return Effect.gen({ self: this }, function* () {
|
|
760
|
-
if (!this.persistToolCatalog)
|
|
761
|
-
return;
|
|
762
|
-
const fetchedAt = yield* Clock.currentTimeMillis;
|
|
763
|
-
const expiresAt = fetchedAt + this.ttlMs;
|
|
764
|
-
const staleUntil = expiresAt + this.staleMs;
|
|
765
|
-
const ttlSeconds = Math.max(60, Math.ceil((this.ttlMs + this.staleMs) / 1000));
|
|
766
|
-
const chunks = this.splitCatalogChunks(snapshot);
|
|
767
|
-
const chunkWrites = yield* forEachChunk(chunks, (chunk, index) => storageSet(this.catalogChunkKey(id, snapshot.fingerprint, index), chunk, {
|
|
768
|
-
ttlSeconds: ttlSeconds + CATALOG_CHUNK_TTL_GRACE_SECONDS,
|
|
769
|
-
}));
|
|
770
|
-
for (const write of chunkWrites) {
|
|
771
|
-
if (Result.isFailure(write))
|
|
772
|
-
return yield* Effect.fail(write.failure);
|
|
773
|
-
}
|
|
774
|
-
// The manifest is the only publication point. A failed/partial chunk write
|
|
775
|
-
// therefore leaves the previous manifest authoritative (or no catalog);
|
|
776
|
-
// unreachable chunks carry a bounded TTL and require no prefix scan.
|
|
777
|
-
const manifest = {
|
|
778
|
-
version: 2,
|
|
779
|
-
revision: snapshot.fingerprint,
|
|
780
|
-
toolCount: snapshot.tools.length,
|
|
781
|
-
byteCount: snapshot.serializedBytes.byteLength,
|
|
782
|
-
chunkCount: chunks.length,
|
|
783
|
-
fetchedAt,
|
|
784
|
-
expiresAt,
|
|
785
|
-
staleUntil,
|
|
786
|
-
};
|
|
787
|
-
yield* storageSet(this.catalogKey(id), JSON.stringify(manifest), {
|
|
788
|
-
ttlSeconds,
|
|
789
|
-
});
|
|
790
|
-
});
|
|
791
|
-
}
|
|
792
|
-
// Remove the persisted catalog: the manifest and every chunk it names, all
|
|
793
|
-
// at once under the chunk I/O bound. Every delete is attempted; the effect
|
|
794
|
-
// fails with the manifest read's error first, then the manifest delete's,
|
|
795
|
-
// then the first chunk delete's by index.
|
|
796
|
-
deleteCatalog(id) {
|
|
797
|
-
return Effect.gen({ self: this }, function* () {
|
|
798
|
-
const read = yield* Effect.result(storageGet(this.catalogKey(id)));
|
|
799
|
-
const manifest = Result.isSuccess(read)
|
|
800
|
-
? this.parseCatalogManifest(read.success)
|
|
801
|
-
: null;
|
|
802
|
-
// The root is authoritative, so attempt its deletion even when the
|
|
803
|
-
// best-effort read needed for physical chunk cleanup failed. It need
|
|
804
|
-
// not land first: a reader that finds the root but not a chunk has an
|
|
805
|
-
// incomplete catalog, which is never served, and an eventually
|
|
806
|
-
// consistent store could show it that way whatever the order.
|
|
807
|
-
const keys = [
|
|
808
|
-
this.catalogKey(id),
|
|
809
|
-
...Array.from({ length: manifest?.chunkCount ?? 0 }, (_, index) => this.catalogChunkKey(id, manifest.revision, index)),
|
|
810
|
-
];
|
|
811
|
-
const deleted = yield* forEachChunk(keys, (key) => storageDelete(key));
|
|
812
|
-
if (Result.isFailure(read))
|
|
813
|
-
return yield* Effect.fail(read.failure);
|
|
814
|
-
const failed = deleted.find(Result.isFailure);
|
|
815
|
-
if (failed)
|
|
816
|
-
return yield* Effect.fail(failed.failure);
|
|
817
|
-
});
|
|
818
|
-
}
|
|
819
|
-
catalogGeneration(id) {
|
|
820
|
-
return this.catalogGenerations.get(id) ?? 0;
|
|
821
|
-
}
|
|
822
|
-
advanceCatalogGeneration(id) {
|
|
823
|
-
this.catalogGenerations.set(id, this.catalogGeneration(id) + 1);
|
|
824
|
-
}
|
|
825
|
-
/**
|
|
826
|
-
* Keep this isolate's writes and invalidations ordered. Without the queue, an
|
|
827
|
-
* old refresh can finish its storage.set after a credential change deletes
|
|
828
|
-
* the catalog and resurrect the pre-change listing.
|
|
829
|
-
*/
|
|
830
|
-
async enqueueCatalogMutation(id, operation) {
|
|
831
|
-
// Mutations run in arrival order, each taking its turn from the one
|
|
832
|
-
// queued before it. That one may belong to another request, and
|
|
833
|
-
// completing a Deferred resumes its waiters inside the completing call,
|
|
834
|
-
// so the wait is an edge of its own and this request's storage work
|
|
835
|
-
// starts only after it. (Effect's Semaphore would do neither: it resumes
|
|
836
|
-
// a waiter from the releasing fiber, and a newcomer can take the permit
|
|
837
|
-
// before a waiter that queued earlier.) An operation logs its own storage
|
|
838
|
-
// failure, so a turn always ends.
|
|
839
|
-
const previous = this.catalogMutations.get(id);
|
|
840
|
-
const turn = Deferred.makeUnsafe();
|
|
841
|
-
this.catalogMutations.set(id, turn);
|
|
842
|
-
try {
|
|
843
|
-
if (previous) {
|
|
844
|
-
// A cross-request Deferred alone looks hung to workerd. Keep a timer
|
|
845
|
-
// owned by this request until the preceding mutation hands over its
|
|
846
|
-
// turn; the race interrupts the timer as soon as the wait settles.
|
|
847
|
-
await runEdge(Effect.raceFirst(Deferred.await(previous), Effect.forever(Effect.sleep(Duration.seconds(1)))));
|
|
848
|
-
}
|
|
849
|
-
await runOnPartition(operation, this.opts);
|
|
850
|
-
}
|
|
851
|
-
finally {
|
|
852
|
-
if (this.catalogMutations.get(id) === turn) {
|
|
853
|
-
this.catalogMutations.delete(id);
|
|
854
|
-
}
|
|
855
|
-
Deferred.doneUnsafe(turn, Exit.void);
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
async refreshToolsWithContext(id, connector, ctx, skipPublicationWhenAborted = false, flight) {
|
|
859
|
-
const generation = this.catalogGeneration(id);
|
|
860
|
-
const tools = await connector.listTools(ctx);
|
|
861
|
-
// The listing a maintained proxy just served is also the only catalog
|
|
862
|
-
// comparison connecta ever makes. It rides this refresh whether or not the
|
|
863
|
-
// result reaches a cache, because what drifted drifted.
|
|
864
|
-
this.observeCatalogDrift(connector);
|
|
865
|
-
// A deferred deadline may close the owned scope while a connector that
|
|
866
|
-
// ignores abort is still listing. The completed list remains a valid drift
|
|
867
|
-
// observation, but must not overwrite a newer same-generation refresh.
|
|
868
|
-
// Blocking callers do not set this flag and retain their prior publication
|
|
869
|
-
// behavior when an inbound abort races a completed listing.
|
|
870
|
-
// Every returned listing must pass the ceilings, even when invalidation
|
|
871
|
-
// or abandonment means it can no longer enter either shared cache layer.
|
|
872
|
-
if (tools.length > MAX_CATALOG_TOOLS) {
|
|
873
|
-
const message = `Connector "${id}" returned ${tools.length} tools, over the ` +
|
|
874
|
-
`${MAX_CATALOG_TOOLS}-tool catalog ceiling; refusing the complete catalog.`;
|
|
875
|
-
this.opts.logger.warn(`[connecta] ${message}`);
|
|
876
|
-
throw new Error(message);
|
|
877
|
-
}
|
|
878
|
-
const previous = this.cache.get(id);
|
|
879
|
-
const snapshot = await snapshotCatalog(tools);
|
|
880
|
-
if (snapshot.serializedBytes.byteLength > MAX_SERIALIZED_CATALOG_BYTES) {
|
|
881
|
-
const message = `Connector "${id}" returned a ${snapshot.serializedBytes.byteLength}-byte ` +
|
|
882
|
-
`serialized catalog, over the ${MAX_SERIALIZED_CATALOG_BYTES}-byte ceiling; ` +
|
|
883
|
-
"refusing the complete catalog.";
|
|
884
|
-
this.opts.logger.warn(`[connecta] ${message}`);
|
|
885
|
-
throw new Error(message);
|
|
886
|
-
}
|
|
887
|
-
// The caller that began this refresh may still use its bounded result,
|
|
888
|
-
// but credential changes, abandoned flights, and deferred cancellation
|
|
889
|
-
// prevent publication. Recheck after snapshotting, which is asynchronous.
|
|
890
|
-
if (!this.mayPublish(id, generation, flight) ||
|
|
891
|
-
(skipPublicationWhenAborted && ctx.signal?.aborted)) {
|
|
892
|
-
return tools;
|
|
893
784
|
}
|
|
894
|
-
|
|
895
|
-
const
|
|
896
|
-
const
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
tools,
|
|
901
|
-
fingerprint: snapshot.fingerprint,
|
|
902
|
-
exp: now + this.ttlMs,
|
|
903
|
-
staleUntil: now + this.ttlMs + this.staleMs,
|
|
785
|
+
this.catalogObservedAt.set(id, catalogFetchedAt(ctx) ?? Date.now());
|
|
786
|
+
const provenance = catalogExpiry(ctx);
|
|
787
|
+
const freshUntil = provenance && !provenance.staleFallback ? provenance.expiresAt : 0;
|
|
788
|
+
this.catalogAccess.set(id, {
|
|
789
|
+
state: freshUntil > Date.now() ? "fresh" : "stale",
|
|
790
|
+
observedAt: new Date().toISOString(),
|
|
904
791
|
});
|
|
905
|
-
|
|
906
|
-
if (shouldPersist) {
|
|
907
|
-
await this.enqueueCatalogMutation(id,
|
|
908
|
-
// The generation is read when the turn comes, not when it is queued:
|
|
909
|
-
// an invalidation queued meanwhile has already deleted the catalog.
|
|
910
|
-
warnOnFailure(Effect.suspend(() => generation === this.catalogGeneration(id)
|
|
911
|
-
? this.storeCatalog(id, snapshot)
|
|
912
|
-
: Effect.void), `connector "${id}" catalog persistence failed`));
|
|
913
|
-
}
|
|
914
|
-
return tools;
|
|
792
|
+
return markCatalogFreshness(facts, freshUntil);
|
|
915
793
|
}
|
|
916
|
-
/**
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
* snapshotting both yield, and a late result must never overwrite the one a
|
|
921
|
-
* fresh attempt published in the meantime.
|
|
922
|
-
*/
|
|
923
|
-
mayPublish(id, generation, flight) {
|
|
924
|
-
return (generation === this.catalogGeneration(id) &&
|
|
925
|
-
(!flight || (!flight.abandoned && Date.now() < flight.abandonAt)));
|
|
926
|
-
}
|
|
927
|
-
/**
|
|
928
|
-
* Publish one shared refresh before starting its connector work. The first
|
|
929
|
-
* caller owns the scope and signal, and the flight lives `boundMs` from now
|
|
930
|
-
* at most; every later caller joins the result without gaining access to
|
|
931
|
-
* that context. A flight found past its bound is abandoned here, and this
|
|
932
|
-
* caller starts the fresh attempt.
|
|
933
|
-
*/
|
|
934
|
-
startCatalogRefresh(id, generation, boundMs, work, ownerLeft = () => false) {
|
|
935
|
-
const existing = this.catalogRefreshes.get(id);
|
|
936
|
-
if (existing?.generation === generation) {
|
|
937
|
-
if (Date.now() < existing.abandonAt)
|
|
938
|
-
return { flight: existing };
|
|
939
|
-
this.abandonCatalogRefresh(id, existing);
|
|
940
|
-
}
|
|
941
|
-
const flight = {
|
|
942
|
-
generation,
|
|
943
|
-
abandonAt: Date.now() + boundMs,
|
|
944
|
-
abandoned: false,
|
|
945
|
-
outcome: Deferred.makeUnsafe(),
|
|
946
|
-
};
|
|
947
|
-
this.catalogRefreshes.set(id, flight);
|
|
948
|
-
const owner = Effect.onExit(work(flight), (exit) => Effect.sync(() => {
|
|
949
|
-
if (this.catalogRefreshes.get(id) === flight) {
|
|
950
|
-
this.catalogRefreshes.delete(id);
|
|
951
|
-
}
|
|
952
|
-
// A failure that was the owner's alone is no answer for anyone
|
|
953
|
-
// else: its own cancellation (its signal, read here in its own
|
|
954
|
-
// request), or anything that arrives once the flight is past its
|
|
955
|
-
// bound. Joiners make a fresh attempt under their own deadlines.
|
|
956
|
-
const ownersAlone = Exit.isFailure(exit) &&
|
|
957
|
-
(flight.abandoned ||
|
|
958
|
-
Date.now() >= flight.abandonAt ||
|
|
959
|
-
ownerLeft());
|
|
960
|
-
// Last, because joined fibers resume inside this call.
|
|
961
|
-
Deferred.doneUnsafe(flight.outcome, ownersAlone ? Exit.fail(new AbandonedCatalogRefresh(id)) : exit);
|
|
962
|
-
}));
|
|
963
|
-
return { flight, owner };
|
|
794
|
+
/** The served catalog excludes control-character names; raw caches stay complete. */
|
|
795
|
+
acceptToolNames(tools) {
|
|
796
|
+
const accepted = tools.filter((tool) => !hasControlCharacters(tool.name));
|
|
797
|
+
return accepted.length === tools.length ? tools : accepted;
|
|
964
798
|
}
|
|
965
|
-
/**
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
* request, which nothing here may reach into, and it can no longer publish.
|
|
970
|
-
*/
|
|
971
|
-
abandonCatalogRefresh(id, flight) {
|
|
972
|
-
if (flight.abandoned)
|
|
973
|
-
return;
|
|
974
|
-
flight.abandoned = true;
|
|
975
|
-
if (this.catalogRefreshes.get(id) === flight) {
|
|
976
|
-
this.catalogRefreshes.delete(id);
|
|
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() });
|
|
977
803
|
}
|
|
978
|
-
this.opts.logger.warn(`[connecta] connector "${id}" catalog refresh outlived its bound; abandoning it for a fresh attempt.`);
|
|
979
|
-
Deferred.doneUnsafe(flight.outcome, Exit.fail(new AbandonedCatalogRefresh(id)));
|
|
980
804
|
}
|
|
981
805
|
/**
|
|
982
|
-
*
|
|
983
|
-
*
|
|
984
|
-
*
|
|
985
|
-
*
|
|
986
|
-
*
|
|
987
|
-
*
|
|
806
|
+
* One connector's catalog as served: the cached downstream facts, classified
|
|
807
|
+
* on every read by the connector's `classification`, into fresh objects.
|
|
808
|
+
* Safety is derived here and nowhere else, so no cache layer can carry a
|
|
809
|
+
* stale verdict — from an older review, an older release, or a stale
|
|
810
|
+
* fallback — and nothing a caller or decorator does to a served tool reaches
|
|
811
|
+
* the next read.
|
|
988
812
|
*/
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
? Effect.succeed(joined.value)
|
|
992
|
-
: Effect.sync(() => {
|
|
993
|
-
this.abandonCatalogRefresh(id, flight);
|
|
994
|
-
return undefined;
|
|
995
|
-
})), Effect.catch((error) => error instanceof AbandonedCatalogRefresh
|
|
996
|
-
? Effect.succeed(undefined)
|
|
997
|
-
: Effect.fail(error))));
|
|
998
|
-
}
|
|
999
|
-
/** Force a live listTools refresh and replace both catalog cache layers. */
|
|
1000
|
-
async refreshTools(id, baseUrl, requestScope, callOptions = {}) {
|
|
813
|
+
async loadTools(id, baseUrl, requestScope, callOptions = {}, scope) {
|
|
814
|
+
const tools = await this.loadDownstreamTools(id, baseUrl, requestScope, callOptions, scope);
|
|
1001
815
|
const connector = this.connectors.get(id);
|
|
1002
816
|
if (!connector)
|
|
1003
817
|
throw new Error(`Unknown connector "${id}"`);
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
observeCatalogAccess(id, state) {
|
|
1029
|
-
this.catalogAccess.set(id, {
|
|
1030
|
-
state,
|
|
1031
|
-
observedAt: new Date().toISOString(),
|
|
1032
|
-
});
|
|
1033
|
-
}
|
|
1034
|
-
/**
|
|
1035
|
-
* Start or join one shared refresh. A newly deferred task owns its scope,
|
|
1036
|
-
* deadline, and teardown; no inbound signal or request scope crosses into it.
|
|
1037
|
-
*/
|
|
1038
|
-
deferCatalogRefresh(id, baseUrl, expectedGeneration, options) {
|
|
1039
|
-
const connector = this.connectors.get(id);
|
|
1040
|
-
const defer = options.defer;
|
|
1041
|
-
if (!connector || connector.staticTools || !defer)
|
|
1042
|
-
return;
|
|
1043
|
-
// The refresh this request starts, if none of this generation is live.
|
|
1044
|
-
// Its scope is closed however it ends, deadline included, before the
|
|
1045
|
-
// flight completes.
|
|
1046
|
-
const refresh = (flight) => withDeadlineEffect((signal) => Effect.gen({ self: this }, function* () {
|
|
1047
|
-
const current = this.cache.get(id);
|
|
1048
|
-
if (current && current.exp > Date.now())
|
|
1049
|
-
return current.tools;
|
|
1050
|
-
if (expectedGeneration !== this.catalogGeneration(id) ||
|
|
1051
|
-
this.invalidated.has(id)) {
|
|
1052
|
-
return yield* Effect.fail(new Error(`Deferred catalog refresh of "${id}" was invalidated before it started.`));
|
|
1053
|
-
}
|
|
1054
|
-
const ctx = this.contextFor(id, baseUrl, {}, {
|
|
1055
|
-
signal,
|
|
1056
|
-
timeoutMs: options.refreshTimeoutMs,
|
|
1057
|
-
});
|
|
1058
|
-
yield* closeScopeOnExit(connector, ctx, defer);
|
|
1059
|
-
return yield* Effect.tryPromise({
|
|
1060
|
-
try: () => this.refreshToolsWithContext(id, connector, ctx, true, flight),
|
|
1061
|
-
catch: (error) => error,
|
|
1062
|
-
});
|
|
1063
|
-
}), {
|
|
1064
|
-
timeoutMs: options.refreshTimeoutMs,
|
|
1065
|
-
timeoutError: new Error(`deferred catalog refresh of "${id}" timed out after ${options.refreshTimeoutMs}ms`),
|
|
1066
|
-
});
|
|
1067
|
-
const logFailure = (err) => {
|
|
1068
|
-
this.opts.logger.warn(`[connecta] connector "${id}" deferred catalog refresh failed: ${msg(err)}`);
|
|
818
|
+
const accepted = this.acceptToolNames(tools);
|
|
819
|
+
const overrides = this.classification[id];
|
|
820
|
+
this.validateClassification(id, accepted, overrides);
|
|
821
|
+
const review = catalogReviewOf(connector);
|
|
822
|
+
return carryCatalogFreshness(tools, review
|
|
823
|
+
? await classifyCatalog(review, id, accepted, this.opts.logger, this.verifiedFacts, overrides)
|
|
824
|
+
: accepted.map((tool) => this.publishUnreviewedTool(id, tool)));
|
|
825
|
+
}
|
|
826
|
+
publishUnreviewedTool(id, tool) {
|
|
827
|
+
const override = this.classification[id]?.[tool.name];
|
|
828
|
+
return {
|
|
829
|
+
...structuredClone(tool),
|
|
830
|
+
classification: classifyTool(tool, override),
|
|
831
|
+
...(override !== undefined
|
|
832
|
+
? {
|
|
833
|
+
annotations: {
|
|
834
|
+
...structuredClone(tool.annotations),
|
|
835
|
+
readOnlyHint: override === "read",
|
|
836
|
+
destructiveHint: override === "write",
|
|
837
|
+
},
|
|
838
|
+
}
|
|
839
|
+
: {}),
|
|
1069
840
|
};
|
|
1070
|
-
const { flight, owner } = this.startCatalogRefresh(id, expectedGeneration, options.refreshTimeoutMs, (flight) => Effect.scoped(refresh(flight)));
|
|
1071
|
-
if (owner) {
|
|
1072
|
-
// Runs past this response, under the runtime's waitUntil below.
|
|
1073
|
-
flight.deferredTail = detach(Effect.catchCause(owner, (cause) => Effect.sync(() => logFailure(Cause.squash(cause)))));
|
|
1074
|
-
}
|
|
1075
|
-
// A stale reader that joins a blocking refresh logs its failure once for
|
|
1076
|
-
// every reader after it, and waits through an edge of its own, no longer
|
|
1077
|
-
// than the flight's bound.
|
|
1078
|
-
flight.deferredTail ??= runEdge(this.joinCatalogRefresh(id, flight)).then(() => { }, logFailure);
|
|
1079
|
-
try {
|
|
1080
|
-
defer(flight.deferredTail);
|
|
1081
|
-
}
|
|
1082
|
-
catch (err) {
|
|
1083
|
-
this.opts.logger.warn(`[connecta] connector "${id}" deferred catalog refresh could not attach to the runtime: ${msg(err)}`);
|
|
1084
|
-
}
|
|
1085
841
|
}
|
|
1086
|
-
/**
|
|
1087
|
-
|
|
1088
|
-
const
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
const
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
if (hit && hit.exp > now) {
|
|
1097
|
-
if (readOptions?.defer)
|
|
1098
|
-
this.observeCatalogAccess(id, "fresh");
|
|
1099
|
-
return hit.tools;
|
|
1100
|
-
}
|
|
1101
|
-
let stale = hit && hit.staleUntil > now
|
|
1102
|
-
? { tools: hit.tools, staleUntil: hit.staleUntil }
|
|
1103
|
-
: undefined;
|
|
1104
|
-
if (this.persistToolCatalog && !this.invalidated.has(id)) {
|
|
1105
|
-
const generation = this.catalogGeneration(id);
|
|
1106
|
-
let persisted = null;
|
|
1107
|
-
try {
|
|
1108
|
-
persisted = await runOnPartition(this.readCatalog(id, now), this.opts);
|
|
1109
|
-
}
|
|
1110
|
-
catch (err) {
|
|
1111
|
-
this.opts.logger.warn(`[connecta] connector "${id}" catalog read failed: ${msg(err)}`);
|
|
1112
|
-
}
|
|
1113
|
-
if (generation !== this.catalogGeneration(id)) {
|
|
1114
|
-
persisted = null;
|
|
1115
|
-
stale = undefined;
|
|
1116
|
-
}
|
|
1117
|
-
// Storage can yield while another request finishes a live refresh. Read
|
|
1118
|
-
// the shared cache again before an older manifest gets any authority.
|
|
1119
|
-
// The candidate with the later fresh deadline wins; both candidates have
|
|
1120
|
-
// already passed their own fingerprint and completeness checks.
|
|
1121
|
-
const reconciledAt = Date.now();
|
|
1122
|
-
const current = this.cache.get(id);
|
|
1123
|
-
const usableCurrent = current && current.staleUntil > reconciledAt ? current : undefined;
|
|
1124
|
-
if (usableCurrent) {
|
|
1125
|
-
stale = {
|
|
1126
|
-
tools: usableCurrent.tools,
|
|
1127
|
-
staleUntil: usableCurrent.staleUntil,
|
|
1128
|
-
};
|
|
1129
|
-
if (usableCurrent.exp > reconciledAt) {
|
|
1130
|
-
if (readOptions?.defer)
|
|
1131
|
-
this.observeCatalogAccess(id, "fresh");
|
|
1132
|
-
return usableCurrent.tools;
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
if (persisted &&
|
|
1136
|
-
persisted.staleUntil > reconciledAt &&
|
|
1137
|
-
(!usableCurrent || persisted.expiresAt > usableCurrent.exp)) {
|
|
1138
|
-
this.cache.set(id, {
|
|
1139
|
-
tools: persisted.tools,
|
|
1140
|
-
fingerprint: persisted.fingerprint,
|
|
1141
|
-
exp: persisted.expiresAt,
|
|
1142
|
-
staleUntil: persisted.staleUntil,
|
|
1143
|
-
});
|
|
1144
|
-
if (persisted.expiresAt > reconciledAt) {
|
|
1145
|
-
if (readOptions?.defer)
|
|
1146
|
-
this.observeCatalogAccess(id, "fresh");
|
|
1147
|
-
return persisted.tools;
|
|
1148
|
-
}
|
|
1149
|
-
stale = {
|
|
1150
|
-
tools: persisted.tools,
|
|
1151
|
-
staleUntil: persisted.staleUntil,
|
|
1152
|
-
};
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
if (stale &&
|
|
1156
|
-
stale.staleUntil > Date.now() &&
|
|
1157
|
-
readOptions?.defer &&
|
|
1158
|
-
requestGeneration === this.catalogGeneration(id) &&
|
|
1159
|
-
!this.invalidated.has(id)) {
|
|
1160
|
-
this.deferCatalogRefresh(id, baseUrl, requestGeneration, readOptions);
|
|
1161
|
-
// Invalidation can land synchronously while the refresh is attached.
|
|
1162
|
-
// Repeat the authority check at the exact stale publication point.
|
|
1163
|
-
if (stale.staleUntil > Date.now() &&
|
|
1164
|
-
requestGeneration === this.catalogGeneration(id) &&
|
|
1165
|
-
!this.invalidated.has(id)) {
|
|
1166
|
-
this.observeCatalogAccess(id, "stale");
|
|
1167
|
-
return stale.tools;
|
|
1168
|
-
}
|
|
1169
|
-
}
|
|
1170
|
-
try {
|
|
1171
|
-
const tools = await this.refreshTools(id, baseUrl, requestScope, callOptions);
|
|
1172
|
-
if (readOptions?.defer)
|
|
1173
|
-
this.observeCatalogAccess(id, "fresh");
|
|
1174
|
-
return tools;
|
|
1175
|
-
}
|
|
1176
|
-
catch (err) {
|
|
1177
|
-
if (stale &&
|
|
1178
|
-
stale.staleUntil > Date.now() &&
|
|
1179
|
-
requestGeneration === this.catalogGeneration(id) &&
|
|
1180
|
-
!this.invalidated.has(id)) {
|
|
1181
|
-
if (readOptions?.defer)
|
|
1182
|
-
this.observeCatalogAccess(id, "stale");
|
|
1183
|
-
this.opts.logger.warn(`[connecta] connector "${id}" catalog refresh failed; serving stale catalog: ${msg(err)}`);
|
|
1184
|
-
return stale.tools;
|
|
1185
|
-
}
|
|
1186
|
-
throw err;
|
|
842
|
+
/** Construction-only view for the secret-free static config description. */
|
|
843
|
+
describeStaticTools(id) {
|
|
844
|
+
const tools = this.connectors.get(id)?.staticTools;
|
|
845
|
+
return tools?.map((tool) => this.publishUnreviewedTool(id, tool));
|
|
846
|
+
}
|
|
847
|
+
validateClassification(id, tools, overrides) {
|
|
848
|
+
const known = new Set(tools.map((tool) => tool.name));
|
|
849
|
+
for (const name of Object.keys(overrides ?? {})) {
|
|
850
|
+
if (!known.has(name))
|
|
851
|
+
throw new Error(`ConnectaConfig.classification: connector "${id}" has no tool "${name}"`);
|
|
1187
852
|
}
|
|
1188
853
|
}
|
|
1189
|
-
|
|
1190
|
-
* Coalesce one connector's catalog traversal inside one inbound request. The
|
|
1191
|
-
* WeakMap neither roots the request scope nor lets its connector context
|
|
1192
|
-
* escape into another request; settled entries are also removed eagerly.
|
|
1193
|
-
*
|
|
1194
|
-
* The read owns its signal, and each caller waits under its own: one that
|
|
1195
|
-
* is cancelled while others wait leaves with its own failure, and the read
|
|
1196
|
-
* is cancelled only when every caller has gone (src/runtime/shared-read.ts).
|
|
1197
|
-
*
|
|
1198
|
-
* The deployment-wide flight below this layer coalesces the actual live
|
|
1199
|
-
* refresh across requests. Its owner's context governs the listing, and its
|
|
1200
|
-
* owner's deadline bounds it; later callers join only its result, never its
|
|
1201
|
-
* request scope, and a failure that was the owner's alone sends them to a
|
|
1202
|
-
* fresh attempt rather than to them.
|
|
1203
|
-
*/
|
|
1204
|
-
async getTools(id, baseUrl, requestScope, callOptions = {}, readOptions) {
|
|
854
|
+
async getTools(id, baseUrl, requestScope, callOptions = {}, scope) {
|
|
1205
855
|
const connector = this.connectors.get(id);
|
|
1206
856
|
if (!connector)
|
|
1207
857
|
throw new Error(`Unknown connector "${id}"`);
|
|
1208
|
-
if (connector.staticTools)
|
|
1209
|
-
return connector.staticTools;
|
|
1210
858
|
if (!requestScope) {
|
|
1211
|
-
return this.loadTools(id, baseUrl, requestScope, callOptions,
|
|
859
|
+
return this.loadTools(id, baseUrl, requestScope, callOptions, scope);
|
|
1212
860
|
}
|
|
1213
861
|
let loads = this.requestCatalogLoads.get(requestScope);
|
|
1214
862
|
if (!loads) {
|
|
@@ -1216,19 +864,20 @@ export class Registry {
|
|
|
1216
864
|
this.requestCatalogLoads.set(requestScope, loads);
|
|
1217
865
|
}
|
|
1218
866
|
const requestLoads = loads;
|
|
1219
|
-
|
|
867
|
+
const key = JSON.stringify([id, scope?.principalKey, scope?.subjectKey, scope?.caller]);
|
|
868
|
+
let load = requestLoads.get(key);
|
|
1220
869
|
if (!load) {
|
|
1221
|
-
const started = new SharedRead((signal) => this.loadTools(id, baseUrl, requestScope, { ...callOptions, signal },
|
|
870
|
+
const started = new SharedRead((signal) => this.loadTools(id, baseUrl, requestScope, { ...callOptions, signal }, scope),
|
|
1222
871
|
// Settled or cancelled, the read leaves the map before any caller
|
|
1223
872
|
// resumes, so the next one asks the caches afresh.
|
|
1224
873
|
() => {
|
|
1225
|
-
if (requestLoads.get(
|
|
1226
|
-
requestLoads.delete(
|
|
874
|
+
if (requestLoads.get(key) === started)
|
|
875
|
+
requestLoads.delete(key);
|
|
1227
876
|
if (requestLoads.size === 0) {
|
|
1228
877
|
this.requestCatalogLoads.delete(requestScope);
|
|
1229
878
|
}
|
|
1230
879
|
});
|
|
1231
|
-
requestLoads.set(
|
|
880
|
+
requestLoads.set(key, started);
|
|
1232
881
|
load = started;
|
|
1233
882
|
}
|
|
1234
883
|
return runEdge(load.join(callOptions.signal));
|
|
@@ -1244,16 +893,21 @@ export class Registry {
|
|
|
1244
893
|
return shape.state === "mismatch" ? shape.message : undefined;
|
|
1245
894
|
}
|
|
1246
895
|
catch (error) {
|
|
1247
|
-
this.opts.logger
|
|
896
|
+
logFailure(this.opts.logger, "credential shape read failed", failureRecord({ connector: id }, error));
|
|
1248
897
|
return undefined;
|
|
1249
898
|
}
|
|
1250
899
|
}
|
|
900
|
+
/** Age of a complete cache entry; reads never refresh it. */
|
|
901
|
+
catalogAgeMs(id) {
|
|
902
|
+
const observedAt = this.catalogObservedAt.get(id);
|
|
903
|
+
return observedAt === undefined ? null : Math.max(0, Date.now() - observedAt);
|
|
904
|
+
}
|
|
1251
905
|
/** Best-effort connector status for the operator UI. */
|
|
1252
|
-
async statusFor(id, baseUrl, requestScope = {}, callOptions = {}) {
|
|
906
|
+
async statusFor(id, baseUrl, requestScope = {}, callOptions = {}, scope) {
|
|
1253
907
|
const connector = this.connectors.get(id);
|
|
1254
908
|
if (!connector)
|
|
1255
|
-
return { state: "error", message: "Unknown connector" };
|
|
1256
|
-
const ctx = this.contextFor(id, baseUrl, requestScope, callOptions);
|
|
909
|
+
return ownStatus({ state: "error", message: "Unknown connector" });
|
|
910
|
+
const ctx = this.contextFor(id, baseUrl, requestScope, callOptions, scope);
|
|
1257
911
|
// Whatever the state turns out to be, it carries the drift the last
|
|
1258
912
|
// refresh saw. Reading it is a lookup, not a probe: a connector that has
|
|
1259
913
|
// listed nothing yet reports nothing, and status never lists on its own to
|
|
@@ -1262,56 +916,60 @@ export class Registry {
|
|
|
1262
916
|
// is third-party output, and status is read by the operator UI and copied
|
|
1263
917
|
// into responses.
|
|
1264
918
|
const withObservations = (status) => {
|
|
1265
|
-
const report =
|
|
919
|
+
const report = this.catalogDriftOf(connector);
|
|
1266
920
|
const access = this.catalogAccess.get(id);
|
|
1267
921
|
// Connector.status is an open plugin seam. Rebuild its public fields so
|
|
1268
922
|
// a connector cannot smuggle payload through either registry-owned
|
|
1269
|
-
// observation when this runtime has not made one
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
...(status.message !== undefined ? { message: status.message } : {}),
|
|
1273
|
-
};
|
|
1274
|
-
return {
|
|
1275
|
-
...boundedStatus,
|
|
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), {
|
|
1276
926
|
...(report ? { catalogDrift: report } : {}),
|
|
1277
927
|
...(access ? { catalogAccess: { ...access } } : {}),
|
|
1278
|
-
};
|
|
928
|
+
});
|
|
1279
929
|
};
|
|
930
|
+
// Credential declarations own their readiness even when a static catalog
|
|
931
|
+
// or a plugin status would otherwise report success. Never probe an empty slot.
|
|
932
|
+
if (connector.credential) {
|
|
933
|
+
try {
|
|
934
|
+
const values = (await this.opts.credentialVault?.getAll(id, this.opts.credentialOwner)) ?? null;
|
|
935
|
+
const shape = storedCredentialShape(connector.credential, values);
|
|
936
|
+
if (shape.state === "missing")
|
|
937
|
+
return withObservations(ownStatus({ state: "credential_required" }));
|
|
938
|
+
if (shape.state === "mismatch")
|
|
939
|
+
return withObservations(ownStatus({ state: "auth_required" }));
|
|
940
|
+
}
|
|
941
|
+
catch (err) {
|
|
942
|
+
return withObservations(failureStatus(id, err));
|
|
943
|
+
}
|
|
944
|
+
}
|
|
1280
945
|
if (connector.status) {
|
|
1281
946
|
try {
|
|
1282
947
|
return withObservations(await connector.status(ctx));
|
|
1283
948
|
}
|
|
1284
949
|
catch (err) {
|
|
1285
|
-
return withObservations(
|
|
950
|
+
return withObservations(failureStatus(id, err));
|
|
1286
951
|
}
|
|
1287
952
|
}
|
|
1288
953
|
try {
|
|
1289
|
-
await this.getTools(id, baseUrl, requestScope, callOptions);
|
|
954
|
+
await this.getTools(id, baseUrl, requestScope, callOptions, scope);
|
|
1290
955
|
return withObservations({ state: "ok" });
|
|
1291
956
|
}
|
|
1292
957
|
catch (err) {
|
|
1293
|
-
return withObservations(
|
|
958
|
+
return withObservations(failureStatus(id, err));
|
|
1294
959
|
}
|
|
1295
960
|
}
|
|
1296
|
-
|
|
1297
|
-
this.advanceCatalogGeneration(id);
|
|
1298
|
-
this.cache.delete(id);
|
|
1299
|
-
this.invalidated.add(id);
|
|
1300
|
-
}
|
|
1301
|
-
deleteStoredCatalog(id) {
|
|
1302
|
-
return this.enqueueCatalogMutation(id, warnOnFailure(this.deleteCatalog(id), `connector "${id}" catalog invalidation failed`));
|
|
1303
|
-
}
|
|
1304
|
-
/** Drop a connector's cached tool list (e.g. after auth completes). */
|
|
961
|
+
/** Rotate the storage generation; in-flight old readers cannot republish it. */
|
|
1305
962
|
invalidate(id) {
|
|
1306
|
-
this.
|
|
1307
|
-
if (this.persistToolCatalog)
|
|
1308
|
-
void this.deleteStoredCatalog(id);
|
|
963
|
+
void this.invalidateStored(id);
|
|
1309
964
|
}
|
|
1310
|
-
/** Drop both in-memory and persisted tool catalogs. */
|
|
1311
965
|
async invalidateStored(id) {
|
|
1312
|
-
this.
|
|
1313
|
-
|
|
1314
|
-
await this.
|
|
966
|
+
this.catalogObservedAt.delete(id);
|
|
967
|
+
try {
|
|
968
|
+
await invalidateCatalogCache(this.opts.catalogStorage ?? this.opts.storage, id);
|
|
969
|
+
}
|
|
970
|
+
catch (error) {
|
|
971
|
+
logFailure(this.opts.logger, "catalog invalidation failed", failureRecord({ connector: id }, error));
|
|
972
|
+
}
|
|
1315
973
|
}
|
|
1316
974
|
}
|
|
1317
975
|
class ScopedRegistryView {
|
|
@@ -1322,9 +980,7 @@ class ScopedRegistryView {
|
|
|
1322
980
|
/** An evicted view's admission controller must remain closed. */
|
|
1323
981
|
admissionPersonal;
|
|
1324
982
|
get personal() {
|
|
1325
|
-
return this.scope.principalKey
|
|
1326
|
-
? this.root.personalRegistry(this.scope.principalKey)
|
|
1327
|
-
: undefined;
|
|
983
|
+
return this.scope.principalKey ? this.root.personalRegistry(this.scope.principalKey) : undefined;
|
|
1328
984
|
}
|
|
1329
985
|
constructor(root, allowed, scope) {
|
|
1330
986
|
this.root = root;
|
|
@@ -1333,9 +989,7 @@ class ScopedRegistryView {
|
|
|
1333
989
|
this.maxResultBytes = root.maxResultBytes;
|
|
1334
990
|
// Check capacity at construction, then resolve the current registry on
|
|
1335
991
|
// use. A retained view must not revive one evicted while it was idle.
|
|
1336
|
-
this.admissionPersonal = scope.principalKey
|
|
1337
|
-
? root.personalRegistry(scope.principalKey)
|
|
1338
|
-
: undefined;
|
|
992
|
+
this.admissionPersonal = scope.principalKey ? root.personalRegistry(scope.principalKey) : undefined;
|
|
1339
993
|
}
|
|
1340
994
|
registryFor(id, admission = false) {
|
|
1341
995
|
if (!this.allowed.has(id))
|
|
@@ -1350,9 +1004,17 @@ class ScopedRegistryView {
|
|
|
1350
1004
|
listConnectors() {
|
|
1351
1005
|
return this.root.listConnectors().filter((connector) => this.registryFor(connector.id) !== undefined);
|
|
1352
1006
|
}
|
|
1007
|
+
canReadConnectorSkills(id) {
|
|
1008
|
+
return (this.registryFor(id) !== undefined && !this.scope.toolAccess?.has(id) && !this.scope.guardedToolAccess?.has(id));
|
|
1009
|
+
}
|
|
1353
1010
|
getConnector(id) {
|
|
1354
1011
|
return this.registryFor(id)?.getConnector(id);
|
|
1355
1012
|
}
|
|
1013
|
+
getResourceConnector(id) {
|
|
1014
|
+
if (this.scope.toolAccess?.has(id) || this.scope.guardedToolAccess?.has(id))
|
|
1015
|
+
return undefined;
|
|
1016
|
+
return this.getConnector(id);
|
|
1017
|
+
}
|
|
1356
1018
|
resolveAddress(address) {
|
|
1357
1019
|
const parsed = splitAddress(address);
|
|
1358
1020
|
if (!parsed)
|
|
@@ -1365,7 +1027,7 @@ class ScopedRegistryView {
|
|
|
1365
1027
|
if (!registry) {
|
|
1366
1028
|
throw new Error(`Unknown connector "${args[0]}"`);
|
|
1367
1029
|
}
|
|
1368
|
-
const tools = await registry.getTools(
|
|
1030
|
+
const tools = await registry.getTools(args[0], args[1], args[2], args[3], this.scope);
|
|
1369
1031
|
const guarded = this.scope.guardedToolAccess?.get(args[0]);
|
|
1370
1032
|
const granted = this.scope.toolAccess?.get(args[0]) ?? guarded;
|
|
1371
1033
|
if (!granted)
|
|
@@ -1373,8 +1035,7 @@ class ScopedRegistryView {
|
|
|
1373
1035
|
// Every consumer — search, describe, call_tool, and a program's
|
|
1374
1036
|
// connecta.call — resolves through this list, so an ungranted tool is
|
|
1375
1037
|
// indistinguishable from one the connector never had.
|
|
1376
|
-
const visible = tools.filter((tool) => granted.has(tool.name) &&
|
|
1377
|
-
(!guarded?.has(tool.name) || isExplicitlyReadOnly(tool)));
|
|
1038
|
+
const visible = tools.filter((tool) => granted.has(tool.name) && (!guarded?.has(tool.name) || tool.classification === "read"));
|
|
1378
1039
|
if (visible.length < granted.size) {
|
|
1379
1040
|
const present = new Set(tools.map((tool) => tool.name));
|
|
1380
1041
|
for (const name of granted) {
|
|
@@ -1382,13 +1043,13 @@ class ScopedRegistryView {
|
|
|
1382
1043
|
this.root.noteAbsentGrant(args[0], name);
|
|
1383
1044
|
}
|
|
1384
1045
|
}
|
|
1385
|
-
return visible;
|
|
1046
|
+
return carryCatalogFreshness(tools, visible);
|
|
1386
1047
|
}
|
|
1387
1048
|
contextFor(...args) {
|
|
1388
1049
|
const registry = this.registryFor(args[0]);
|
|
1389
1050
|
if (!registry)
|
|
1390
1051
|
throw new Error(`Unknown connector "${args[0]}"`);
|
|
1391
|
-
return
|
|
1052
|
+
return registry.contextFor(args[0], args[1], args[2], args[3], this.scope);
|
|
1392
1053
|
}
|
|
1393
1054
|
admitCall(...args) {
|
|
1394
1055
|
const registry = this.registryFor(args[0], true);
|
|
@@ -1397,19 +1058,26 @@ class ScopedRegistryView {
|
|
|
1397
1058
|
}
|
|
1398
1059
|
return registry.admitCall(...args);
|
|
1399
1060
|
}
|
|
1400
|
-
stashResult(
|
|
1401
|
-
return this.root.stashResult(
|
|
1061
|
+
stashResult(id, chunks, ttlSeconds) {
|
|
1062
|
+
return this.root.stashResult(id, chunks, ttlSeconds, this.scope.subjectKey ? scopes.subject(this.scope.subjectKey) : scopes.results);
|
|
1063
|
+
}
|
|
1064
|
+
resultIdentity() {
|
|
1065
|
+
return {
|
|
1066
|
+
subject: this.scope.subjectKey ?? null,
|
|
1067
|
+
principal: this.scope.principalKey ?? null,
|
|
1068
|
+
endpoint: this.scope.endpoint ?? "/mcp",
|
|
1069
|
+
origin: this.scope.origin ?? null,
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
recheckResultAccess(address, classification, signal, access) {
|
|
1073
|
+
return this.scope.currentResultAccess?.(address, classification, signal, access) ?? Promise.resolve(true);
|
|
1402
1074
|
}
|
|
1403
1075
|
resultsStorage() {
|
|
1404
|
-
return this.scope.subjectKey
|
|
1405
|
-
? this.root.scopedStorage(this.scope.subjectKey)
|
|
1406
|
-
: this.root.resultsStorage();
|
|
1076
|
+
return this.scope.subjectKey ? this.root.scopedStorage(this.scope.subjectKey) : this.root.resultsStorage();
|
|
1407
1077
|
}
|
|
1408
1078
|
credentialDriftFor(id) {
|
|
1409
1079
|
const registry = this.registryFor(id);
|
|
1410
|
-
return registry
|
|
1411
|
-
? registry.credentialDriftFor(id)
|
|
1412
|
-
: Promise.resolve(undefined);
|
|
1080
|
+
return registry ? registry.credentialDriftFor(id) : Promise.resolve(undefined);
|
|
1413
1081
|
}
|
|
1414
1082
|
observedOutputSchema(connectorId, definition) {
|
|
1415
1083
|
return this.registryFor(connectorId)?.observedOutputSchema(connectorId, definition);
|
|
@@ -1417,30 +1085,23 @@ class ScopedRegistryView {
|
|
|
1417
1085
|
observeOutputShape(connectorId, definition, value) {
|
|
1418
1086
|
this.registryFor(connectorId)?.observeOutputShape(connectorId, definition, value);
|
|
1419
1087
|
}
|
|
1088
|
+
catalogAgeMs(id) {
|
|
1089
|
+
return this.registryFor(id)?.catalogAgeMs(id) ?? null;
|
|
1090
|
+
}
|
|
1420
1091
|
statusFor(...args) {
|
|
1421
1092
|
const registry = this.registryFor(args[0]);
|
|
1422
1093
|
return registry
|
|
1423
|
-
? registry.statusFor(
|
|
1094
|
+
? registry.statusFor(args[0], args[1], args[2], args[3], this.scope)
|
|
1424
1095
|
: Promise.resolve({ state: "error", message: "Unknown connector" });
|
|
1425
1096
|
}
|
|
1426
1097
|
invalidateStored(id) {
|
|
1427
1098
|
const registry = this.registryFor(id);
|
|
1428
1099
|
return registry ? registry.invalidateStored(id) : Promise.resolve();
|
|
1429
1100
|
}
|
|
1430
|
-
credentialUiAvailable() {
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
let state = null;
|
|
1436
|
-
try {
|
|
1437
|
-
state = new URL(authorizationUrl).searchParams.get("state");
|
|
1438
|
-
}
|
|
1439
|
-
catch {
|
|
1440
|
-
return;
|
|
1441
|
-
}
|
|
1442
|
-
if (state) {
|
|
1443
|
-
await this.root.storeOAuthHandoff(id, state, this.scope.principalKey);
|
|
1444
|
-
}
|
|
1101
|
+
credentialUiAvailable() {
|
|
1102
|
+
return this.root.credentialUiAvailable();
|
|
1103
|
+
}
|
|
1104
|
+
async bindOAuthHandoff(id, authorizationUrl, principalKey) {
|
|
1105
|
+
await this.root.bindOAuthHandoff(id, authorizationUrl, principalKey ?? this.scope.principalKey);
|
|
1445
1106
|
}
|
|
1446
1107
|
}
|