@zackbart/connecta 0.8.0 → 0.9.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 +128 -0
- package/README.md +31 -55
- package/SECURITY.md +5 -11
- package/dist/activity.d.ts +0 -8
- package/dist/activity.d.ts.map +1 -1
- package/dist/activity.js +0 -1
- package/dist/activity.js.map +1 -1
- package/dist/auth/bearer.d.ts +1 -7
- package/dist/auth/bearer.d.ts.map +1 -1
- package/dist/auth/bearer.js +2 -23
- package/dist/auth/bearer.js.map +1 -1
- package/dist/auth/clerk.d.ts +2 -11
- package/dist/auth/clerk.d.ts.map +1 -1
- package/dist/auth/clerk.js +31 -46
- package/dist/auth/clerk.js.map +1 -1
- package/dist/auth/downstream-oauth.d.ts +15 -6
- package/dist/auth/downstream-oauth.d.ts.map +1 -1
- package/dist/auth/downstream-oauth.js +60 -11
- package/dist/auth/downstream-oauth.js.map +1 -1
- package/dist/call-admission.d.ts.map +1 -1
- package/dist/call-admission.js +0 -1
- package/dist/call-admission.js.map +1 -1
- package/dist/catalog-service.d.ts +1 -2
- package/dist/catalog-service.d.ts.map +1 -1
- package/dist/catalog-service.js +12 -18
- package/dist/catalog-service.js.map +1 -1
- package/dist/catalog.d.ts +6 -0
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +51 -0
- package/dist/catalog.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts +1 -1
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +73 -71
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/errors.d.ts +19 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +1 -0
- package/dist/errors.js.map +1 -1
- package/dist/execute.d.ts +4 -9
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +41 -20
- package/dist/execute.js.map +1 -1
- package/dist/executor-result.js +1 -1
- package/dist/executor-result.js.map +1 -1
- package/dist/index.d.ts +3 -82
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -90
- package/dist/index.js.map +1 -1
- package/dist/invocation.d.ts +5 -0
- package/dist/invocation.d.ts.map +1 -1
- package/dist/invocation.js +46 -10
- package/dist/invocation.js.map +1 -1
- package/dist/meta-tools.d.ts +4 -12
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +119 -169
- package/dist/meta-tools.js.map +1 -1
- package/dist/operator-ui/generated.d.ts +2 -2
- package/dist/operator-ui/generated.d.ts.map +1 -1
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/generated.js.map +1 -1
- package/dist/operator-ui/model.d.ts +1 -22
- package/dist/operator-ui/model.d.ts.map +1 -1
- package/dist/operator-ui/model.js.map +1 -1
- package/dist/registry.d.ts +11 -178
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +52 -330
- package/dist/registry.js.map +1 -1
- package/dist/retired-toolkits.d.ts +3 -0
- package/dist/retired-toolkits.d.ts.map +1 -0
- package/dist/retired-toolkits.js +9 -0
- package/dist/retired-toolkits.js.map +1 -0
- package/dist/routes/activity.d.ts.map +1 -1
- package/dist/routes/activity.js +2 -5
- package/dist/routes/activity.js.map +1 -1
- package/dist/routes/credentials.d.ts.map +1 -1
- package/dist/routes/credentials.js +1 -12
- package/dist/routes/credentials.js.map +1 -1
- package/dist/routes/mcp.d.ts.map +1 -1
- package/dist/routes/mcp.js +94 -188
- package/dist/routes/mcp.js.map +1 -1
- package/dist/routes/oauth.d.ts.map +1 -1
- package/dist/routes/oauth.js +3 -8
- package/dist/routes/oauth.js.map +1 -1
- package/dist/routes/shared.d.ts +9 -30
- package/dist/routes/shared.d.ts.map +1 -1
- package/dist/routes/shared.js +10 -69
- package/dist/routes/shared.js.map +1 -1
- package/dist/routes/ui.d.ts.map +1 -1
- package/dist/routes/ui.js +4 -10
- package/dist/routes/ui.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -31
- package/dist/server.js.map +1 -1
- package/dist/skills.d.ts +3 -6
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +9 -38
- package/dist/skills.js.map +1 -1
- package/dist/timeout.d.ts.map +1 -1
- package/dist/timeout.js +2 -4
- package/dist/timeout.js.map +1 -1
- package/dist/types.d.ts +8 -61
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +1 -2
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +5 -35
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -2
- package/src/activity.ts +0 -9
- package/src/auth/bearer.ts +3 -32
- package/src/auth/clerk.ts +35 -48
- package/src/auth/downstream-oauth.ts +106 -24
- package/src/call-admission.ts +0 -1
- package/src/catalog-service.ts +23 -23
- package/src/catalog.ts +55 -0
- package/src/connectors/remote-mcp.ts +106 -86
- package/src/errors.ts +22 -0
- package/src/execute.ts +43 -24
- package/src/executor-result.ts +1 -1
- package/src/index.ts +24 -208
- package/src/invocation.ts +63 -10
- package/src/meta-tools.ts +139 -187
- package/src/operator-ui/browser.css +1 -54
- package/src/operator-ui/browser.ts +16 -150
- package/src/operator-ui/generated.ts +2 -2
- package/src/operator-ui/model.ts +1 -23
- package/src/registry.ts +59 -429
- package/src/retired-toolkits.ts +14 -0
- package/src/routes/activity.ts +1 -6
- package/src/routes/credentials.ts +0 -12
- package/src/routes/mcp.ts +110 -230
- package/src/routes/oauth.ts +2 -8
- package/src/routes/shared.ts +10 -100
- package/src/routes/ui.ts +2 -12
- package/src/server.ts +1 -31
- package/src/skills.ts +9 -38
- package/src/timeout.ts +2 -4
- package/src/types.ts +11 -62
- package/src/ui.ts +9 -46
- package/src/version.ts +1 -1
- package/dist/credential-health.d.ts +0 -205
- package/dist/credential-health.d.ts.map +0 -1
- package/dist/credential-health.js +0 -610
- package/dist/credential-health.js.map +0 -1
- package/dist/toolkits.d.ts +0 -124
- package/dist/toolkits.d.ts.map +0 -1
- package/dist/toolkits.js +0 -322
- package/dist/toolkits.js.map +0 -1
- package/src/credential-health.ts +0 -835
- package/src/toolkits.ts +0 -461
package/src/registry.ts
CHANGED
|
@@ -11,13 +11,6 @@ import {
|
|
|
11
11
|
type CredentialVault,
|
|
12
12
|
} from "./credentials.js";
|
|
13
13
|
import { ConnectorCallError } from "./errors.js";
|
|
14
|
-
import {
|
|
15
|
-
CredentialHealthChecker,
|
|
16
|
-
type CredentialCheckOptions,
|
|
17
|
-
type CredentialCheckResult,
|
|
18
|
-
type CredentialHealthConfig,
|
|
19
|
-
type CredentialHealthRecord,
|
|
20
|
-
} from "./credential-health.js";
|
|
21
14
|
import {
|
|
22
15
|
ConnectorCallAdmissionController,
|
|
23
16
|
type CallAdmissionPermit,
|
|
@@ -34,17 +27,31 @@ import {
|
|
|
34
27
|
MAX_SERIALIZED_CATALOG_BYTES,
|
|
35
28
|
} from "./catalog-limits.js";
|
|
36
29
|
import { mapSettledWithConcurrency } from "./concurrency.js";
|
|
37
|
-
import type { DeferredWork } from "./connector-scope.js";
|
|
38
|
-
import { splitAddress, type Toolkit } from "./toolkits.js";
|
|
39
30
|
|
|
40
31
|
const ID_RE = /^[a-z0-9_-]+$/;
|
|
41
32
|
const DEFAULT_TTL_SECONDS = 300;
|
|
42
33
|
const DEFAULT_STALE_SECONDS = 3600;
|
|
43
34
|
const CATALOG_CHUNK_TTL_GRACE_SECONDS = 300;
|
|
44
35
|
const DEFAULT_MAX_RESULT_BYTES = 50_000;
|
|
36
|
+
const encoder = new TextEncoder();
|
|
45
37
|
/** Independent final-envelope boundary for `batch_call`. */
|
|
46
38
|
const DEFAULT_MAX_BATCH_RESULT_BYTES = 100_000;
|
|
47
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Split `"<connectorId>.<toolName>"` on the first dot. Connector ids contain
|
|
42
|
+
* no dots, so a downstream tool name may.
|
|
43
|
+
*/
|
|
44
|
+
function splitAddress(
|
|
45
|
+
address: string,
|
|
46
|
+
): { connectorId: string; toolName: string } | null {
|
|
47
|
+
const dot = address.indexOf(".");
|
|
48
|
+
if (dot <= 0 || dot === address.length - 1) return null;
|
|
49
|
+
return {
|
|
50
|
+
connectorId: address.slice(0, dot),
|
|
51
|
+
toolName: address.slice(dot + 1),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
48
55
|
/**
|
|
49
56
|
* Smallest accepted inline-result cap. One byte is pathological but harmless:
|
|
50
57
|
* `alignEndToCharBoundary` widens a window narrower than the codepoint at the
|
|
@@ -132,17 +139,9 @@ export interface HealthObservation {
|
|
|
132
139
|
}
|
|
133
140
|
|
|
134
141
|
/**
|
|
135
|
-
* Recent real-call outcomes per connector
|
|
136
|
-
*
|
|
137
|
-
* The deployment keeps one log, and each toolkit keeps its own. That split is a
|
|
138
|
-
* scope boundary, not bookkeeping: `lastError` is a downstream error string
|
|
139
|
-
* that routinely names the tool that failed, and `list_connectors` returns the
|
|
140
|
-
* observation verbatim. Sharing one log would let a toolkit read back the tools
|
|
141
|
-
* and failures of a sibling toolkit through a connector they happen to share —
|
|
142
|
-
* and let a sibling's failures flip this view's reported status. A scoped view
|
|
143
|
-
* reads back only the calls it made itself.
|
|
142
|
+
* Recent real-call outcomes per connector.
|
|
144
143
|
*/
|
|
145
|
-
|
|
144
|
+
class HealthLog {
|
|
146
145
|
private readonly observations = new Map<string, HealthObservation>();
|
|
147
146
|
|
|
148
147
|
recordSuccess(id: string, latencyMs: number): void {
|
|
@@ -192,8 +191,6 @@ export interface RegistryOptions {
|
|
|
192
191
|
* of bytes >= 1; anything else warns and falls back to 100_000.
|
|
193
192
|
*/
|
|
194
193
|
maxBatchResultBytes?: number;
|
|
195
|
-
/** Tuning for the credential liveness checks (issue #24). */
|
|
196
|
-
credentialHealth?: CredentialHealthConfig;
|
|
197
194
|
}
|
|
198
195
|
|
|
199
196
|
function namespaced(storage: KVStorage, prefix: string): KVStorage {
|
|
@@ -208,34 +205,6 @@ function msg(err: unknown): string {
|
|
|
208
205
|
return err instanceof Error ? err.message : String(err);
|
|
209
206
|
}
|
|
210
207
|
|
|
211
|
-
/** WHATWG TextEncoder byte length without allocating another full buffer. */
|
|
212
|
-
function utf8ByteLength(value: string): number {
|
|
213
|
-
let bytes = 0;
|
|
214
|
-
for (let index = 0; index < value.length; index++) {
|
|
215
|
-
const code = value.charCodeAt(index);
|
|
216
|
-
if (code <= 0x7f) {
|
|
217
|
-
bytes++;
|
|
218
|
-
} else if (code <= 0x7ff) {
|
|
219
|
-
bytes += 2;
|
|
220
|
-
} else if (
|
|
221
|
-
code >= 0xd800 &&
|
|
222
|
-
code <= 0xdbff &&
|
|
223
|
-
index + 1 < value.length
|
|
224
|
-
) {
|
|
225
|
-
const next = value.charCodeAt(index + 1);
|
|
226
|
-
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
227
|
-
bytes += 4;
|
|
228
|
-
index++;
|
|
229
|
-
} else {
|
|
230
|
-
bytes += 3;
|
|
231
|
-
}
|
|
232
|
-
} else {
|
|
233
|
-
bytes += 3;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
return bytes;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
208
|
export type ConnectorOperationOptions = Pick<
|
|
240
209
|
ConnectorContext,
|
|
241
210
|
"signal" | "timeoutMs"
|
|
@@ -246,12 +215,9 @@ export type ConnectorOperationOptions = Pick<
|
|
|
246
215
|
* (`src/meta-tools.ts`) and the `execute_code` sandbox bridge (`src/execute.ts`)
|
|
247
216
|
* is typed against THIS, never against the concrete `Registry`.
|
|
248
217
|
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* here is a compile error, and adding one here is a compile error until
|
|
253
|
-
* `ScopedRegistry` filters it — so a new meta-tool cannot quietly step around
|
|
254
|
-
* the boundary.
|
|
218
|
+
* The read-only seam remains useful even without scoped views: meta-tools can
|
|
219
|
+
* consume registry behavior without depending on the concrete implementation
|
|
220
|
+
* or its construction-only methods.
|
|
255
221
|
*/
|
|
256
222
|
export interface RegistryView {
|
|
257
223
|
/** Deployment-wide result-size cap threaded to the meta-tools. */
|
|
@@ -282,10 +248,7 @@ export interface RegistryView {
|
|
|
282
248
|
requestScope?: object,
|
|
283
249
|
callOptions?: ConnectorOperationOptions,
|
|
284
250
|
): ConnectorContext;
|
|
285
|
-
/**
|
|
286
|
-
* Acquire the connector's shared downstream-call permit. Scoped views
|
|
287
|
-
* delegate to the base registry so every toolkit contends on the same pool.
|
|
288
|
-
*/
|
|
251
|
+
/** Acquire the connector's shared downstream-call permit. */
|
|
289
252
|
admitCall(
|
|
290
253
|
id: string,
|
|
291
254
|
input: { toolName: string; args: unknown; signal?: AbortSignal },
|
|
@@ -295,15 +258,9 @@ export interface RegistryView {
|
|
|
295
258
|
recordFailure(id: string, latencyMs: number, error: unknown): void;
|
|
296
259
|
healthFor(id: string): HealthObservation | undefined;
|
|
297
260
|
hasObservedSuccess(id: string): boolean;
|
|
298
|
-
/** When ANY view last saw a successful call to `id`, deployment-wide. */
|
|
299
261
|
observedSuccessAt(id: string): string | undefined;
|
|
300
|
-
/**
|
|
301
|
-
|
|
302
|
-
/** Store a liveness verdict a live status check just produced. */
|
|
303
|
-
recordCredentialHealth(
|
|
304
|
-
id: string,
|
|
305
|
-
record: CredentialHealthRecord,
|
|
306
|
-
): Promise<void>;
|
|
262
|
+
/** Local declared-vs-stored credential mismatch, with no downstream I/O. */
|
|
263
|
+
credentialDriftFor(id: string): Promise<string | undefined>;
|
|
307
264
|
statusFor(
|
|
308
265
|
id: string,
|
|
309
266
|
baseUrl: string,
|
|
@@ -344,8 +301,6 @@ export class Registry implements RegistryView {
|
|
|
344
301
|
readonly maxResultBytes: number;
|
|
345
302
|
/** Final batch envelope cap threaded to the meta-tools. */
|
|
346
303
|
readonly maxBatchResultBytes: number;
|
|
347
|
-
/** Proactive liveness checks over stored downstream credentials (issue #24). */
|
|
348
|
-
private readonly credentialHealth: CredentialHealthChecker;
|
|
349
304
|
|
|
350
305
|
constructor(
|
|
351
306
|
connectors: Connector[],
|
|
@@ -387,20 +342,6 @@ export class Registry implements RegistryView {
|
|
|
387
342
|
opts.maxResultBytes,
|
|
388
343
|
opts.maxBatchResultBytes,
|
|
389
344
|
);
|
|
390
|
-
this.credentialHealth = new CredentialHealthChecker(
|
|
391
|
-
{
|
|
392
|
-
listConnectors: () => this.listConnectors(),
|
|
393
|
-
getConnector: (id) => this.getConnector(id),
|
|
394
|
-
contextFor: (id, baseUrl, requestScope) =>
|
|
395
|
-
this.contextFor(id, baseUrl, requestScope),
|
|
396
|
-
storage: opts.storage,
|
|
397
|
-
logger: opts.logger,
|
|
398
|
-
...(opts.credentialVault !== undefined
|
|
399
|
-
? { credentialVault: opts.credentialVault }
|
|
400
|
-
: {}),
|
|
401
|
-
},
|
|
402
|
-
opts.credentialHealth,
|
|
403
|
-
);
|
|
404
345
|
}
|
|
405
346
|
|
|
406
347
|
/**
|
|
@@ -740,7 +681,7 @@ export class Registry implements RegistryView {
|
|
|
740
681
|
);
|
|
741
682
|
return null;
|
|
742
683
|
}
|
|
743
|
-
const byteLength =
|
|
684
|
+
const byteLength = encoder.encode(chunk).byteLength;
|
|
744
685
|
chunkBytes += byteLength;
|
|
745
686
|
if (
|
|
746
687
|
byteLength > MAX_CATALOG_CHUNK_BYTES ||
|
|
@@ -1089,83 +1030,30 @@ export class Registry implements RegistryView {
|
|
|
1089
1030
|
return this.health.get(id);
|
|
1090
1031
|
}
|
|
1091
1032
|
|
|
1092
|
-
/**
|
|
1093
|
-
* Whether ANY view of this deployment has seen a successful call to `id` —
|
|
1094
|
-
* a bare boolean, never the observation. Connector liveness (reachable,
|
|
1095
|
-
* credentials still valid) is a deployment-level fact, not a per-view one, so
|
|
1096
|
-
* `list_connectors` may classify a connector as ok/unknown from it. The
|
|
1097
|
-
* observation itself — `lastError` above all, which names the tool that
|
|
1098
|
-
* failed — stays strictly per view.
|
|
1099
|
-
*/
|
|
1033
|
+
/** Whether this deployment has seen a successful call to `id`. */
|
|
1100
1034
|
hasObservedSuccess(id: string): boolean {
|
|
1101
1035
|
return this.observedSuccessAt(id) !== undefined;
|
|
1102
1036
|
}
|
|
1103
1037
|
|
|
1104
|
-
/**
|
|
1105
|
-
* The timestamp behind `hasObservedSuccess`, on the same deployment-wide
|
|
1106
|
-
* terms and for the same reason: it says only *when* the connector last
|
|
1107
|
-
* answered, never what was called or what failed. Credential health reads it
|
|
1108
|
-
* to decide whether a failed verdict has been overtaken by real traffic.
|
|
1109
|
-
*/
|
|
1038
|
+
/** The timestamp behind `hasObservedSuccess`. */
|
|
1110
1039
|
observedSuccessAt(id: string): string | undefined {
|
|
1111
1040
|
return this.health.get(id)?.lastSuccessAt;
|
|
1112
1041
|
}
|
|
1113
1042
|
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
): Promise<void> {
|
|
1129
|
-
return this.credentialHealth.record(id, record);
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
/**
|
|
1133
|
-
* Check stored downstream credentials now and return one outcome per
|
|
1134
|
-
* connector considered. THE operator-facing entry point behind
|
|
1135
|
-
* `Connecta.checkCredentials()`: wire it to a Worker cron trigger or a Node
|
|
1136
|
-
* interval. Never rejects; connectors checked recently are reported as
|
|
1137
|
-
* `fresh` unless `force` is set.
|
|
1138
|
-
*/
|
|
1139
|
-
checkCredentialHealth(
|
|
1140
|
-
baseUrl: string,
|
|
1141
|
-
opts?: CredentialCheckOptions,
|
|
1142
|
-
defer?: DeferredWork,
|
|
1143
|
-
): Promise<CredentialCheckResult[]> {
|
|
1144
|
-
return this.credentialHealth.check(baseUrl, opts, defer);
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
/**
|
|
1148
|
-
* The traffic-triggered sweep: a promise for the caller to defer (Workers:
|
|
1149
|
-
* `ctx.waitUntil`), or `undefined` when nothing is due — which is the common
|
|
1150
|
-
* case and costs no I/O. Called by the server after an authenticated request;
|
|
1151
|
-
* the checker owns the rate limiting.
|
|
1152
|
-
*/
|
|
1153
|
-
sweepCredentialHealthIfDue(
|
|
1154
|
-
baseUrl: string,
|
|
1155
|
-
defer?: DeferredWork,
|
|
1156
|
-
): Promise<CredentialCheckResult[]> | undefined {
|
|
1157
|
-
return this.credentialHealth.sweepIfDue(baseUrl, defer);
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
/**
|
|
1161
|
-
* Drop a connector's liveness verdict, because its credential just changed
|
|
1162
|
-
* under us (OAuth callback completed, credential stored or removed on
|
|
1163
|
-
* /credentials). A
|
|
1164
|
-
* stale `auth_required` must not outlive the re-authorization that fixed it —
|
|
1165
|
-
* that is the difference between recovery working and needing a restart.
|
|
1166
|
-
*/
|
|
1167
|
-
clearCredentialHealth(id: string): Promise<void> {
|
|
1168
|
-
return this.credentialHealth.clear(id);
|
|
1043
|
+
async credentialDriftFor(id: string): Promise<string | undefined> {
|
|
1044
|
+
const credential = this.connectors.get(id)?.credential;
|
|
1045
|
+
const vault = this.opts.credentialVault;
|
|
1046
|
+
if (!credential || !vault) return undefined;
|
|
1047
|
+
try {
|
|
1048
|
+
const values = await vault.getAll(id);
|
|
1049
|
+
const shape = storedCredentialShape(credential, values);
|
|
1050
|
+
return shape.state === "mismatch" ? shape.message : undefined;
|
|
1051
|
+
} catch (error) {
|
|
1052
|
+
this.opts.logger.warn(
|
|
1053
|
+
`[connecta] connector "${id}" credential shape read failed: ${msg(error)}`,
|
|
1054
|
+
);
|
|
1055
|
+
return undefined;
|
|
1056
|
+
}
|
|
1169
1057
|
}
|
|
1170
1058
|
|
|
1171
1059
|
/** Best-effort connector status for list_connectors. */
|
|
@@ -1193,291 +1081,33 @@ export class Registry implements RegistryView {
|
|
|
1193
1081
|
}
|
|
1194
1082
|
}
|
|
1195
1083
|
|
|
1196
|
-
|
|
1197
|
-
invalidate(id: string): void {
|
|
1198
|
-
this.advanceCatalogGeneration(id);
|
|
1199
|
-
this.cache.delete(id);
|
|
1200
|
-
this.invalidated.add(id);
|
|
1201
|
-
if (this.persistToolCatalog) {
|
|
1202
|
-
void this.enqueueCatalogMutation(id, async () => {
|
|
1203
|
-
try {
|
|
1204
|
-
await this.deleteCatalog(id);
|
|
1205
|
-
} catch (err) {
|
|
1206
|
-
this.opts.logger.warn(
|
|
1207
|
-
`[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`,
|
|
1208
|
-
);
|
|
1209
|
-
}
|
|
1210
|
-
});
|
|
1211
|
-
}
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
/** Drop both in-memory and persisted tool catalogs. */
|
|
1215
|
-
async invalidateStored(id: string): Promise<void> {
|
|
1084
|
+
private markCatalogInvalid(id: string): void {
|
|
1216
1085
|
this.advanceCatalogGeneration(id);
|
|
1217
1086
|
this.cache.delete(id);
|
|
1218
1087
|
this.invalidated.add(id);
|
|
1219
|
-
if (this.persistToolCatalog) {
|
|
1220
|
-
await this.enqueueCatalogMutation(id, async () => {
|
|
1221
|
-
try {
|
|
1222
|
-
await this.deleteCatalog(id);
|
|
1223
|
-
} catch (err) {
|
|
1224
|
-
this.opts.logger.warn(
|
|
1225
|
-
`[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`,
|
|
1226
|
-
);
|
|
1227
|
-
}
|
|
1228
|
-
});
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
|
-
/**
|
|
1234
|
-
* Per-toolkit HealthLogs, keyed by registry and then by toolkit name.
|
|
1235
|
-
*
|
|
1236
|
-
* Module-scoped rather than a `Registry` member on purpose: `Registry`'s type
|
|
1237
|
-
* is part of the public API surface (`Connecta.registry`), and this is internal
|
|
1238
|
-
* factoring only `ScopedRegistry` may touch. The WeakMap keeps the logs alive
|
|
1239
|
-
* exactly as long as their registry, and the inner map is bounded by the
|
|
1240
|
-
* number of configured toolkits.
|
|
1241
|
-
*/
|
|
1242
|
-
const toolkitHealthLogs = new WeakMap<Registry, Map<string, HealthLog>>();
|
|
1243
|
-
|
|
1244
|
-
/** The long-lived log one toolkit records into; created on first use. */
|
|
1245
|
-
function toolkitHealthLog(base: Registry, toolkitName: string): HealthLog {
|
|
1246
|
-
let logs = toolkitHealthLogs.get(base);
|
|
1247
|
-
if (!logs) {
|
|
1248
|
-
logs = new Map<string, HealthLog>();
|
|
1249
|
-
toolkitHealthLogs.set(base, logs);
|
|
1250
|
-
}
|
|
1251
|
-
let log = logs.get(toolkitName);
|
|
1252
|
-
if (!log) {
|
|
1253
|
-
log = new HealthLog();
|
|
1254
|
-
logs.set(toolkitName, log);
|
|
1255
|
-
}
|
|
1256
|
-
return log;
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
/**
|
|
1260
|
-
* THE toolkit enforcement point (issue #22).
|
|
1261
|
-
*
|
|
1262
|
-
* A filtered VIEW of one long-lived `Registry`: same connectors, same tool
|
|
1263
|
-
* caches, same health — narrowed to the connectors and tool addresses one
|
|
1264
|
-
* toolkit selects. `serveMcp` builds it once per scoped connection and hands it
|
|
1265
|
-
* to `registerMetaTools`/`registerExecuteTool`, so `list_connectors`,
|
|
1266
|
-
* `search_tools`, `describe_tools`, `call_tool`, `call_destructive_tool`,
|
|
1267
|
-
* `batch_call`, `authorize_connector`, `get_result`, `skills`, and the
|
|
1268
|
-
* `execute_code` host bridge all inherit the boundary from here instead of
|
|
1269
|
-
* re-implementing nine checks.
|
|
1270
|
-
*
|
|
1271
|
-
* Two invariants make it reviewable:
|
|
1272
|
-
*
|
|
1273
|
-
* 1. **Indistinguishability.** Out-of-scope input fails through the SAME code
|
|
1274
|
-
* path, with the same error class and message, as input naming something
|
|
1275
|
-
* that does not exist at all. An out-of-scope CONNECTOR disappears from
|
|
1276
|
-
* `resolveAddress`/`getConnector` (→ `Unknown address` / `Unknown
|
|
1277
|
-
* connector`); an out-of-scope TOOL disappears from the catalog this view
|
|
1278
|
-
* returns (→ `Unknown tool "<t>" on connector "<c>"`), which is exactly the
|
|
1279
|
-
* error a misspelled tool name already produced. Scoping deliberately does
|
|
1280
|
-
* NOT reject at `resolveAddress` for tools: that would answer with the
|
|
1281
|
-
* connector-level message and make "hidden here" distinguishable from
|
|
1282
|
-
* "never existed".
|
|
1283
|
-
* 2. **It filters views, never state.** Every read delegates to the shared
|
|
1284
|
-
* registry and filters the returned array, so the tool cache, the persisted
|
|
1285
|
-
* catalog, and the health map stay whole and shared across scopes.
|
|
1286
|
-
*/
|
|
1287
|
-
export class ScopedRegistry implements RegistryView {
|
|
1288
|
-
/** This toolkit's own health observations — see HealthLog. */
|
|
1289
|
-
private readonly health: HealthLog;
|
|
1290
|
-
|
|
1291
|
-
constructor(
|
|
1292
|
-
private readonly base: Registry,
|
|
1293
|
-
private readonly toolkit: Toolkit,
|
|
1294
|
-
) {
|
|
1295
|
-
this.health = toolkitHealthLog(base, toolkit.name);
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
get maxResultBytes(): number {
|
|
1299
|
-
return this.base.maxResultBytes;
|
|
1300
1088
|
}
|
|
1301
1089
|
|
|
1302
|
-
|
|
1303
|
-
return this.
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
/** Byte-identical to what the unscoped registry throws for an unknown id. */
|
|
1314
|
-
private unknownConnector(id: string): Error {
|
|
1315
|
-
return new Error(`Unknown connector "${id}"`);
|
|
1316
|
-
}
|
|
1317
|
-
|
|
1318
|
-
private inScopeTools(id: string, tools: ToolDef[]): ToolDef[] {
|
|
1319
|
-
return tools.filter((tool) => this.toolkit.hasTool(id, tool.name));
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
listConnectors(): Connector[] {
|
|
1323
|
-
return this.base
|
|
1324
|
-
.listConnectors()
|
|
1325
|
-
.filter((connector) => this.toolkit.hasConnector(connector.id));
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
getConnector(id: string): Connector | undefined {
|
|
1329
|
-
return this.visible(id) ? this.base.getConnector(id) : undefined;
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
resolveAddress(
|
|
1333
|
-
address: string,
|
|
1334
|
-
): { connector: Connector; toolName: string } | null {
|
|
1335
|
-
const resolved = this.base.resolveAddress(address);
|
|
1336
|
-
if (!resolved) return null;
|
|
1337
|
-
// Connector-level only — see invariant 1 above.
|
|
1338
|
-
return this.toolkit.hasConnector(resolved.connector.id) ? resolved : null;
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
async getTools(
|
|
1342
|
-
id: string,
|
|
1343
|
-
baseUrl: string,
|
|
1344
|
-
requestScope?: object,
|
|
1345
|
-
callOptions: ConnectorOperationOptions = {},
|
|
1346
|
-
): Promise<ToolDef[]> {
|
|
1347
|
-
if (!this.visible(id)) throw this.unknownConnector(id);
|
|
1348
|
-
return this.inScopeTools(
|
|
1349
|
-
id,
|
|
1350
|
-
await this.base.getTools(id, baseUrl, requestScope, callOptions),
|
|
1351
|
-
);
|
|
1352
|
-
}
|
|
1353
|
-
|
|
1354
|
-
async refreshTools(
|
|
1355
|
-
id: string,
|
|
1356
|
-
baseUrl: string,
|
|
1357
|
-
requestScope?: object,
|
|
1358
|
-
callOptions: ConnectorOperationOptions = {},
|
|
1359
|
-
): Promise<ToolDef[]> {
|
|
1360
|
-
if (!this.visible(id)) throw this.unknownConnector(id);
|
|
1361
|
-
return this.inScopeTools(
|
|
1362
|
-
id,
|
|
1363
|
-
await this.base.refreshTools(id, baseUrl, requestScope, callOptions),
|
|
1364
|
-
);
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
peekTools(id: string): ToolDef[] | undefined {
|
|
1368
|
-
if (!this.visible(id)) return undefined;
|
|
1369
|
-
const tools = this.base.peekTools(id);
|
|
1370
|
-
return tools ? this.inScopeTools(id, tools) : undefined;
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
contextFor(
|
|
1374
|
-
id: string,
|
|
1375
|
-
baseUrl: string,
|
|
1376
|
-
requestScope: object = {},
|
|
1377
|
-
callOptions: ConnectorOperationOptions = {},
|
|
1378
|
-
): ConnectorContext {
|
|
1379
|
-
// Unreachable through the meta-tools (they resolve first), so a throw here
|
|
1380
|
-
// is a loud backstop rather than a silent grant of connector storage and
|
|
1381
|
-
// credentials to a scope that may not see the connector.
|
|
1382
|
-
if (!this.visible(id)) throw this.unknownConnector(id);
|
|
1383
|
-
return this.base.contextFor(id, baseUrl, requestScope, callOptions);
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
|
-
admitCall(
|
|
1387
|
-
id: string,
|
|
1388
|
-
input: { toolName: string; args: unknown; signal?: AbortSignal },
|
|
1389
|
-
): Promise<CallAdmissionPermit> {
|
|
1390
|
-
if (!this.visible(id)) return Promise.reject(this.unknownConnector(id));
|
|
1391
|
-
return this.base.admitCall(id, input);
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
|
-
/**
|
|
1395
|
-
* Stashed oversized results are bound to the scope that produced them: a
|
|
1396
|
-
* scoped session cannot page a result it could not have produced, and an id
|
|
1397
|
-
* from another scope reads back as the ordinary "Unknown or expired result
|
|
1398
|
-
* id". Unscoped sessions keep the historical `results:` prefix untouched.
|
|
1399
|
-
*/
|
|
1400
|
-
resultsStorage(): KVStorage {
|
|
1401
|
-
return namespaced(
|
|
1402
|
-
this.base.resultsStorage(),
|
|
1403
|
-
`toolkit:${this.toolkit.name}:`,
|
|
1404
|
-
);
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
// Outcomes are recorded twice on purpose: the deployment-wide log keeps the
|
|
1408
|
-
// operator surfaces complete, while the toolkit's own log is the ONLY one
|
|
1409
|
-
// this view reads back — so `list_connectors` here never reports a sibling
|
|
1410
|
-
// toolkit's failures, or the tool names their error strings carry.
|
|
1411
|
-
recordSuccess(id: string, latencyMs: number): void {
|
|
1412
|
-
if (!this.visible(id)) return;
|
|
1413
|
-
this.base.recordSuccess(id, latencyMs);
|
|
1414
|
-
this.health.recordSuccess(id, latencyMs);
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
recordFailure(id: string, latencyMs: number, error: unknown): void {
|
|
1418
|
-
if (!this.visible(id)) return;
|
|
1419
|
-
this.base.recordFailure(id, latencyMs, error);
|
|
1420
|
-
this.health.recordFailure(id, latencyMs, error);
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
healthFor(id: string): HealthObservation | undefined {
|
|
1424
|
-
return this.visible(id) ? this.health.get(id) : undefined;
|
|
1425
|
-
}
|
|
1426
|
-
|
|
1427
|
-
/**
|
|
1428
|
-
* Deliberately NOT per view: whether the connector has ever answered is a
|
|
1429
|
-
* fact about the connector, not about a team's traffic, and withholding it
|
|
1430
|
-
* would report every remote connector as "unknown" to a scoped session that
|
|
1431
|
-
* has not called it yet. It carries no tool name, error text, or count — the
|
|
1432
|
-
* per-view isolation of those, above, is unchanged.
|
|
1433
|
-
*/
|
|
1434
|
-
hasObservedSuccess(id: string): boolean {
|
|
1435
|
-
return this.visible(id) ? this.base.hasObservedSuccess(id) : false;
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
|
-
/** Deployment-wide for the same reason as `hasObservedSuccess` above. */
|
|
1439
|
-
observedSuccessAt(id: string): string | undefined {
|
|
1440
|
-
return this.visible(id) ? this.base.observedSuccessAt(id) : undefined;
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
/**
|
|
1444
|
-
* Also deliberately NOT per view, for the same reason as
|
|
1445
|
-
* `hasObservedSuccess`: whether the credential connecta stores for a connector
|
|
1446
|
-
* still works is a fact about the deployment's credential, not about a team's
|
|
1447
|
-
* traffic. Withholding it would leave a scoped session unable to see that the
|
|
1448
|
-
* connector it shares needs re-authorization. The verdict carries the
|
|
1449
|
-
* connector's own connector-level reason — never a tool name — so the per-view
|
|
1450
|
-
* isolation of `lastError` above is unchanged.
|
|
1451
|
-
*/
|
|
1452
|
-
credentialHealthFor(id: string): Promise<CredentialHealthRecord | undefined> {
|
|
1453
|
-
return this.visible(id)
|
|
1454
|
-
? this.base.credentialHealthFor(id)
|
|
1455
|
-
: Promise.resolve(undefined);
|
|
1456
|
-
}
|
|
1457
|
-
|
|
1458
|
-
async recordCredentialHealth(
|
|
1459
|
-
id: string,
|
|
1460
|
-
record: CredentialHealthRecord,
|
|
1461
|
-
): Promise<void> {
|
|
1462
|
-
if (!this.visible(id)) return;
|
|
1463
|
-
await this.base.recordCredentialHealth(id, record);
|
|
1090
|
+
private deleteStoredCatalog(id: string): Promise<void> {
|
|
1091
|
+
return this.enqueueCatalogMutation(id, async () => {
|
|
1092
|
+
try {
|
|
1093
|
+
await this.deleteCatalog(id);
|
|
1094
|
+
} catch (err) {
|
|
1095
|
+
this.opts.logger.warn(
|
|
1096
|
+
`[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`,
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
});
|
|
1464
1100
|
}
|
|
1465
1101
|
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
callOptions: { signal?: AbortSignal; timeoutMs?: number } = {},
|
|
1471
|
-
): Promise<ConnectorStatus> {
|
|
1472
|
-
// Same shape the unscoped registry returns for an unregistered id.
|
|
1473
|
-
if (!this.visible(id)) {
|
|
1474
|
-
return { state: "error", message: "Unknown connector" };
|
|
1475
|
-
}
|
|
1476
|
-
return this.base.statusFor(id, baseUrl, requestScope, callOptions);
|
|
1102
|
+
/** Drop a connector's cached tool list (e.g. after auth completes). */
|
|
1103
|
+
invalidate(id: string): void {
|
|
1104
|
+
this.markCatalogInvalid(id);
|
|
1105
|
+
if (this.persistToolCatalog) void this.deleteStoredCatalog(id);
|
|
1477
1106
|
}
|
|
1478
1107
|
|
|
1108
|
+
/** Drop both in-memory and persisted tool catalogs. */
|
|
1479
1109
|
async invalidateStored(id: string): Promise<void> {
|
|
1480
|
-
|
|
1481
|
-
await this.
|
|
1110
|
+
this.markCatalogInvalid(id);
|
|
1111
|
+
if (this.persistToolCatalog) await this.deleteStoredCatalog(id);
|
|
1482
1112
|
}
|
|
1483
1113
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const hasOwn = (value: object, key: PropertyKey): boolean =>
|
|
2
|
+
Object.prototype.hasOwnProperty.call(value, key);
|
|
3
|
+
|
|
4
|
+
/** Fail loudly when JavaScript callers pass the retired audience-scoping keys. */
|
|
5
|
+
export function assertNoRetiredToolkitOptions(
|
|
6
|
+
source: string,
|
|
7
|
+
options: object,
|
|
8
|
+
): void {
|
|
9
|
+
if (!hasOwn(options, "toolkits") && !hasOwn(options, "unscoped")) return;
|
|
10
|
+
throw new Error(
|
|
11
|
+
`${source} options \`toolkits\` and \`unscoped\` were removed in issue ` +
|
|
12
|
+
"#178. Deploy one connecta instance per audience instead; see ethos.md.",
|
|
13
|
+
);
|
|
14
|
+
}
|
package/src/routes/activity.ts
CHANGED
|
@@ -8,9 +8,7 @@ import type { InboundAuth } from "../types.js";
|
|
|
8
8
|
import {
|
|
9
9
|
activityActorNamespace,
|
|
10
10
|
authorize,
|
|
11
|
-
isToolkitRestricted,
|
|
12
11
|
privateJson,
|
|
13
|
-
restrictedOperatorSurface,
|
|
14
12
|
type RouteContext,
|
|
15
13
|
} from "./shared.js";
|
|
16
14
|
|
|
@@ -176,11 +174,8 @@ export async function routeActivity(
|
|
|
176
174
|
if (request.method !== "GET") {
|
|
177
175
|
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
178
176
|
}
|
|
179
|
-
const authz = await authorize(request, baseUrl, opts.auth
|
|
177
|
+
const authz = await authorize(request, baseUrl, opts.auth);
|
|
180
178
|
if (!authz.ok) return authz.response;
|
|
181
|
-
if (isToolkitRestricted(authz.toolkitBinding)) {
|
|
182
|
-
return restrictedOperatorSurface();
|
|
183
|
-
}
|
|
184
179
|
if (
|
|
185
180
|
opts.activityReadGate &&
|
|
186
181
|
!(await opts.activityReadGate(authz.actor))
|
|
@@ -138,7 +138,6 @@ async function handleCredentialRequest(
|
|
|
138
138
|
request,
|
|
139
139
|
baseUrl,
|
|
140
140
|
opts.auth,
|
|
141
|
-
opts.logger,
|
|
142
141
|
);
|
|
143
142
|
if (!admin.ok) return admin.response;
|
|
144
143
|
|
|
@@ -193,13 +192,6 @@ async function handleCredentialRequest(
|
|
|
193
192
|
storedValues.value!,
|
|
194
193
|
ctx,
|
|
195
194
|
);
|
|
196
|
-
// The operator just ran the very check the liveness sweep runs; record it
|
|
197
|
-
// so cached status surfaces agree with what the operator page showed.
|
|
198
|
-
await opts.registry.recordCredentialHealth(connectorId, {
|
|
199
|
-
state: result.ok ? "ok" : "auth_required",
|
|
200
|
-
checkedAt: new Date().toISOString(),
|
|
201
|
-
...(result.message ? { message: result.message } : {}),
|
|
202
|
-
});
|
|
203
195
|
return privateJson(result);
|
|
204
196
|
} catch (err) {
|
|
205
197
|
return privateJson({ ok: false, message: msg(err) });
|
|
@@ -226,9 +218,6 @@ async function handleCredentialRequest(
|
|
|
226
218
|
admin.userId,
|
|
227
219
|
);
|
|
228
220
|
await opts.registry.invalidateStored(connectorId);
|
|
229
|
-
// The credential the last verdict judged is gone; judging its replacement
|
|
230
|
-
// is the next check's job, not this one's.
|
|
231
|
-
await opts.registry.clearCredentialHealth(connectorId);
|
|
232
221
|
return privateJson({ credential: metadata });
|
|
233
222
|
} catch (err) {
|
|
234
223
|
return privateJson({ error: msg(err) }, { status: 400 });
|
|
@@ -237,7 +226,6 @@ async function handleCredentialRequest(
|
|
|
237
226
|
if (request.method === "DELETE") {
|
|
238
227
|
await opts.credentialVault.delete(connectorId);
|
|
239
228
|
await opts.registry.invalidateStored(connectorId);
|
|
240
|
-
await opts.registry.clearCredentialHealth(connectorId);
|
|
241
229
|
return new Response(null, {
|
|
242
230
|
status: 204,
|
|
243
231
|
headers: {
|