@zackbart/connecta 0.31.0 → 0.33.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 +97 -0
- package/README.md +30 -20
- package/bin/connecta.mjs +25 -6
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.d.ts +167 -23
- package/dist/access-tokens.js +676 -140
- package/dist/agent-identity.js +47 -0
- package/dist/auth/cloudflare-access.js +13 -5
- package/dist/auth-elicitation.js +1 -1
- package/dist/call-admission-context.js +15 -0
- package/dist/call-admission.js +30 -43
- package/dist/call-recovery.js +6 -2
- package/dist/catalog-service.js +7 -2
- package/dist/config-value-sources.js +14 -9
- package/dist/config.d.ts +58 -78
- package/dist/config.js +143 -47
- package/dist/connector-access.d.ts +4 -33
- package/dist/connector-access.js +15 -99
- package/dist/connector-caller.d.ts +7 -1
- package/dist/connector-caller.js +3 -2
- package/dist/connectors/api-connector.d.ts +27 -4
- package/dist/connectors/api-connector.js +2 -0
- package/dist/connectors/guarded-fetch.d.ts +9 -0
- package/dist/connectors/guarded-fetch.js +7 -0
- package/dist/connectors/remote-mcp.js +2 -0
- package/dist/describe-config.d.ts +32 -7
- package/dist/describe-config.js +9 -18
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +1 -0
- package/dist/execute.js +16 -14
- package/dist/index.d.ts +4 -3
- package/dist/index.js +30 -10
- package/dist/invocation.js +12 -7
- package/dist/membership.d.ts +51 -0
- package/dist/membership.js +121 -0
- package/dist/meta-tools.js +17 -19
- package/dist/module-contracts.d.ts +23 -6
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +2 -2
- package/dist/operator-ui/agents.d.ts +74 -0
- package/dist/operator-ui/agents.js +104 -0
- package/dist/operator-ui/contract.d.ts +151 -29
- package/dist/operator-ui/endpoints.js +192 -0
- package/dist/operator-ui/fix-prompts.js +4 -4
- package/dist/operator-ui/generated.js +5 -4
- package/dist/operator-ui/member-page.js +112 -0
- package/dist/operator-ui/model.d.ts +15 -7
- package/dist/operator-ui/view.d.ts +84 -19
- package/dist/operator-ui/view.js +83 -21
- package/dist/page-styles.js +1 -1
- package/dist/program-writes.js +2 -2
- package/dist/provider.d.ts +63 -11
- package/dist/provider.js +46 -7
- package/dist/providers/_shared/google/workspace.d.ts +53 -16
- package/dist/providers/_shared/google/workspace.js +31 -9
- package/dist/providers/_shared/rest/tools.js +2 -2
- package/dist/providers/_shared/rest/value-safety.d.ts +50 -0
- package/dist/providers/_shared/rest/value-safety.js +699 -7
- package/dist/providers/breeze/skill.generated.js +2 -2
- package/dist/providers/ccb/index.js +1 -1
- package/dist/providers/cloudflare/index.js +3 -3
- package/dist/providers/cloudflare/rest.js +25 -3
- package/dist/providers/cloudflare/skill.generated.js +2 -2
- package/dist/providers/cloudflare/value-safety.js +32 -0
- package/dist/providers/docs/index.d.ts +1 -1
- package/dist/providers/docs/index.js +5 -5
- package/dist/providers/docs/skill.generated.js +2 -2
- package/dist/providers/drive/index.d.ts +1 -1
- package/dist/providers/drive/index.js +4 -4
- package/dist/providers/drive/skill.generated.js +2 -2
- package/dist/providers/forms/index.d.ts +1 -1
- package/dist/providers/forms/index.js +5 -5
- package/dist/providers/forms/skill.generated.js +2 -2
- package/dist/providers/github/skill.generated.js +2 -2
- package/dist/providers/gmail/index.d.ts +1 -1
- package/dist/providers/gmail/index.js +279 -26
- package/dist/providers/gmail/skill.generated.js +2 -2
- package/dist/providers/linear/index.js +1 -1
- package/dist/providers/mixpanel/index.d.ts +27 -12
- package/dist/providers/mixpanel/index.js +58 -6
- package/dist/providers/mixpanel/operations.js +20 -0
- package/dist/providers/mixpanel/rest.js +603 -0
- package/dist/providers/mixpanel/skill.generated.js +3 -2
- package/dist/providers/mixpanel/value-safety.js +16 -0
- package/dist/providers/notion/index.d.ts +7 -0
- package/dist/providers/notion/index.js +116 -11
- package/dist/providers/notion/rest.js +183 -24
- package/dist/providers/notion/skill.generated.js +4 -4
- package/dist/providers/notion/value-safety.js +30 -5
- package/dist/providers/overflow/index.js +1 -1
- package/dist/providers/planning-center/index.d.ts +27 -0
- package/dist/providers/planning-center/index.js +287 -25
- package/dist/providers/planning-center/skill.generated.js +24 -4
- package/dist/providers/sheets/index.d.ts +4 -1
- package/dist/providers/sheets/index.js +9 -6
- package/dist/providers/sheets/skill.generated.js +2 -2
- package/dist/providers/slides/index.d.ts +1 -1
- package/dist/providers/slides/index.js +6 -6
- package/dist/providers/slides/skill.generated.js +2 -2
- package/dist/providers/stripe/rest.js +9 -2
- package/dist/providers/stripe/skill.generated.js +2 -2
- package/dist/providers/stripe/value-safety.js +22 -0
- package/dist/providers/vercel/index.js +2 -2
- package/dist/providers/vercel/rest.js +20 -2
- package/dist/providers/vercel/skill.generated.js +2 -2
- package/dist/providers/vercel/value-safety.js +22 -0
- package/dist/providers/wispr-flow/index.d.ts +14 -0
- package/dist/providers/wispr-flow/index.js +117 -0
- package/dist/providers/wispr-flow/skill.generated.js +8 -0
- package/dist/registry.d.ts +2 -3
- package/dist/registry.js +8 -9
- package/dist/routes/access-tokens.js +82 -75
- package/dist/routes/activity-disclosure.js +17 -18
- package/dist/routes/activity.js +34 -54
- package/dist/routes/actor-labels.js +33 -4
- package/dist/routes/agent-path.js +43 -0
- package/dist/routes/connect.js +16 -12
- package/dist/routes/credentials.js +16 -40
- package/dist/routes/mcp.js +155 -85
- package/dist/routes/oauth-management.js +11 -5
- package/dist/routes/oauth.js +3 -9
- package/dist/routes/operator.js +25 -17
- package/dist/routes/shared.d.ts +60 -20
- package/dist/routes/shared.js +187 -96
- package/dist/routes/ui-config.js +437 -97
- package/dist/routes/ui-data.js +2 -2
- package/dist/routes/ui.js +9 -16
- package/dist/runtime/call-admission.js +9 -0
- package/dist/server.js +8 -0
- package/dist/skills.js +5 -4
- package/dist/storage/keys.js +9 -1
- package/dist/tool-safety.js +13 -5
- package/dist/types.d.ts +12 -1
- package/dist/ui.d.ts +2 -2
- package/dist/ui.js +17 -15
- package/dist/usage-guide.js +3 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/workspace-address.js +9 -0
- package/dist/workspace-subject.js +23 -0
- package/documentation/README.md +46 -22
- package/documentation/architecture.md +378 -58
- package/documentation/auth.md +806 -215
- package/documentation/code-mode.md +93 -81
- package/documentation/deploying.md +128 -26
- package/documentation/integrating.md +145 -22
- package/documentation/meta-tools.md +58 -46
- package/documentation/migration-0.33.md +344 -0
- package/documentation/operating.md +14 -9
- package/documentation/operator-tests.md +117 -31
- package/documentation/operator-ui.md +326 -55
- package/documentation/provider-auth-migration.md +42 -27
- package/documentation/provider-migration-0.29.md +3 -3
- package/examples/worker/AGENTS.md +35 -8
- package/examples/worker/README.md +92 -53
- package/examples/worker/src/connecta.config.ts +41 -20
- package/examples/worker/wrangler.jsonc +3 -0
- package/package.json +7 -2
- package/templates/node/.env.example +3 -0
- package/templates/node/AGENTS.md +33 -10
- package/templates/node/Dockerfile +2 -2
- package/templates/node/README.md +71 -44
- package/templates/node/docker-compose.yml +3 -2
- package/templates/node/package.json +4 -3
- package/templates/node/src/adopt-token.ts +23 -0
- package/templates/node/src/connecta.config.ts +33 -12
- package/templates/node/src/rotate-token.ts +17 -0
- package/dist/tool-safety.d.ts +0 -9
- package/templates/node/src/provision-token.ts +0 -17
package/dist/config.js
CHANGED
|
@@ -12,10 +12,12 @@
|
|
|
12
12
|
// reads, with defaults applied once, so no later layer re-derives a value or
|
|
13
13
|
// re-plumbs a field.
|
|
14
14
|
import { CONFIG_DEFAULTS as D } from "./config-defaults.js";
|
|
15
|
-
import { array, ConfigError,
|
|
15
|
+
import { array, ConfigError, object, opaque, record, required, seconds, readPlain, text, whole, } from "./config-schema.js";
|
|
16
|
+
import { assertAgentNames } from "./agent-identity.js";
|
|
16
17
|
import { assertExecutor } from "./executor-contract.js";
|
|
17
18
|
import { memoryStorage } from "./storage/memory.js";
|
|
18
19
|
import { CONNECTA_VERSION } from "./version.js";
|
|
20
|
+
import { isWorkspaceAddress } from "./workspace-address.js";
|
|
19
21
|
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
22
|
const STORAGE_METHODS = ["get", "set", "delete", "list", "compareAndSet"];
|
|
21
23
|
const hasMethods = (value, methods) => isObject(value) && methods.every((method) => typeof value[method] === "function");
|
|
@@ -134,7 +136,7 @@ const execute = {
|
|
|
134
136
|
*/
|
|
135
137
|
watchdogMs: whole({ min: 1, default: D.execute.watchdogMs }),
|
|
136
138
|
/**
|
|
137
|
-
*
|
|
139
|
+
* Writes one program may send on a writable endpoint, on top of the host-call
|
|
138
140
|
* budget every call already spends. Default 10.
|
|
139
141
|
*/
|
|
140
142
|
maxWrites: whole({ min: 1, default: D.execute.maxWrites }),
|
|
@@ -175,37 +177,27 @@ const admission = {
|
|
|
175
177
|
*/
|
|
176
178
|
code: object(codeAdmission),
|
|
177
179
|
};
|
|
178
|
-
|
|
180
|
+
/** The choice every deployment makes explicitly while `trust` migrates (#820). */
|
|
181
|
+
const READ_ONLY_REQUIRED = (path) => `${path} is required. Choose readOnly: true to refuse every write on /mcp, or readOnly: false to let ` +
|
|
182
|
+
"call_destructive_tool and programs write with MCP client approval. Pools inherit it unless they set their own.";
|
|
183
|
+
function assertFlag(value, path) {
|
|
184
|
+
if (typeof value !== "boolean")
|
|
185
|
+
throw new ConfigError(`${path} must be true or false.`);
|
|
186
|
+
}
|
|
187
|
+
/** Shape only; each reference resolves against auth and agents in createConnecta. */
|
|
188
|
+
function assertMemberList(value, path) {
|
|
189
|
+
if (!Array.isArray(value)) {
|
|
190
|
+
throw new ConfigError(`${path} must be an array of principal ids, { namespace, id }, or "agent:<name>".`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const pool = {
|
|
179
194
|
/**
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
* explicitly classifies it read-only. Grants are additive: an unrestricted
|
|
185
|
-
* connector or address grant wins over the guarded form. An address naming
|
|
186
|
-
* a tool the catalog lacks is unreachable and warned once, never widened.
|
|
195
|
+
* Whether this pool's endpoint refuses every write: write tools are absent
|
|
196
|
+
* from search_tools, tool listings, and describe, and every write call is
|
|
197
|
+
* refused. Inherits the deployment's `readOnly` unless set here, in either
|
|
198
|
+
* direction.
|
|
187
199
|
*/
|
|
188
|
-
|
|
189
|
-
/** Global payload-free activity reads. Defaults to interactive humans. */
|
|
190
|
-
activityAccess: fn(),
|
|
191
|
-
/** Shared credential and OAuth administration. Defaults to none. */
|
|
192
|
-
credentialAdministration: fn(),
|
|
193
|
-
/** Client-token lifecycle management by interactive humans. Defaults to false. */
|
|
194
|
-
accessTokenManagement: fn(),
|
|
195
|
-
/** Connecting or changing the caller's personal account. Defaults to none. */
|
|
196
|
-
personalConnection: fn(),
|
|
197
|
-
};
|
|
198
|
-
const trust = () => opaque({
|
|
199
|
-
check: (value, path) => {
|
|
200
|
-
if (value !== "trusted" && value !== "read-only") {
|
|
201
|
-
throw new ConfigError(`${path} must be "trusted" or "read-only".`);
|
|
202
|
-
}
|
|
203
|
-
},
|
|
204
|
-
resolve: (value) => value ?? "read-only",
|
|
205
|
-
});
|
|
206
|
-
const pool = {
|
|
207
|
-
/** Programs may write only in trusted pools. Default read-only. */
|
|
208
|
-
trust: trust(),
|
|
200
|
+
readOnly: opaque({ check: assertFlag }),
|
|
209
201
|
/** Connector ids and exact `connector.tool` addresses in this pool. */
|
|
210
202
|
tools: required(opaque({
|
|
211
203
|
check: (value, path) => {
|
|
@@ -215,11 +207,26 @@ const pool = {
|
|
|
215
207
|
},
|
|
216
208
|
})),
|
|
217
209
|
/**
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
210
|
+
* Who may open `/mcp/<name>`: principal ids in the namespace of the one
|
|
211
|
+
* interactive auth provider, `{ namespace, id }`, or `"agent:<name>"` for a
|
|
212
|
+
* declared agent. Required and non-empty; the pool serves exactly its
|
|
213
|
+
* `tools` to these members and the same 404 to everyone else.
|
|
214
|
+
*/
|
|
215
|
+
members: required(opaque({ check: assertMemberList }), (path) => `${path} is required: list who may open this pool, as principal ids, { namespace, id }, or "agent:<name>".`),
|
|
216
|
+
/**
|
|
217
|
+
* The Google Workspace address agents in this pool act as, on a Workspace
|
|
218
|
+
* connector whose `subject` is a mapping function. Humans always act as
|
|
219
|
+
* themselves through that mapping, and a fixed `subject` address applies to
|
|
220
|
+
* everyone. Without an owner, an agent's Workspace calls fail
|
|
221
|
+
* `auth_required`.
|
|
221
222
|
*/
|
|
222
|
-
|
|
223
|
+
owner: opaque({
|
|
224
|
+
check: (value, path) => {
|
|
225
|
+
if (typeof value !== "string" || !isWorkspaceAddress(value.trim())) {
|
|
226
|
+
throw new ConfigError(`${path} must be a Google Workspace email address.`);
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
}),
|
|
223
230
|
};
|
|
224
231
|
const icon = {
|
|
225
232
|
src: required(text()),
|
|
@@ -240,7 +247,7 @@ const connectaConfig = {
|
|
|
240
247
|
connectors: required(array(opaque({
|
|
241
248
|
check: (value, path) => {
|
|
242
249
|
if (isObject(value) && Object.hasOwn(value, "approval")) {
|
|
243
|
-
throw new ConfigError(`${path}.approval was removed;
|
|
250
|
+
throw new ConfigError(`${path}.approval was removed; the MCP client owns approval. Set readOnly: true to refuse writes.`);
|
|
244
251
|
}
|
|
245
252
|
if (!isObject(value) ||
|
|
246
253
|
typeof value.id !== "string" ||
|
|
@@ -272,12 +279,27 @@ const connectaConfig = {
|
|
|
272
279
|
},
|
|
273
280
|
resolve: (value) => normalizeAuth(value === undefined ? [] : Array.isArray(value) ? value : [value]),
|
|
274
281
|
}),
|
|
275
|
-
/**
|
|
276
|
-
|
|
277
|
-
|
|
282
|
+
/**
|
|
283
|
+
* Agents, declared by name: lowercase letters, digits, `_` and `-`, each
|
|
284
|
+
* unique. Each has one `cta_` token slot that operators issue, rotate, and
|
|
285
|
+
* revoke; nothing outside code adds, renames, or rebinds an agent. Removing
|
|
286
|
+
* a name stops its token on the next request. Requires `accessTokens`.
|
|
287
|
+
* An admitted agent carries `identity.agent` and has no principal, so
|
|
288
|
+
* personal connectors are absent for it. Defaults to none.
|
|
289
|
+
*/
|
|
290
|
+
agents: opaque({
|
|
291
|
+
check: (value, path) => assertAgentNames(value, path),
|
|
292
|
+
resolve: (value) => Object.freeze([...(value ?? [])]),
|
|
293
|
+
}),
|
|
294
|
+
/**
|
|
295
|
+
* The people who may use plain `/mcp`, which serves the whole catalog with
|
|
296
|
+
* the deployment's `readOnly`, and who administer the deployment. Same
|
|
297
|
+
* reference forms as pool `members`, people only. Required with inbound
|
|
298
|
+
* auth; a maintainer opens no pool unless listed among its members.
|
|
299
|
+
*/
|
|
300
|
+
maintainers: opaque({ check: assertMemberList }),
|
|
301
|
+
/** Named tool pools, each served at `/mcp/<name>` to its members only. */
|
|
278
302
|
pools: record(object(pool)),
|
|
279
|
-
/** Trust of the default /mcp endpoint. Default read-only. */
|
|
280
|
-
trust: trust(),
|
|
281
303
|
/** Exact connector id -> tool name -> verdict. Unknown names fail at catalog publication. */
|
|
282
304
|
classification: record(record(opaque({
|
|
283
305
|
check: (value, path) => {
|
|
@@ -356,11 +378,11 @@ const connectaConfig = {
|
|
|
356
378
|
}
|
|
357
379
|
},
|
|
358
380
|
}),
|
|
359
|
-
/** Optional
|
|
381
|
+
/** Optional agent tokens for the declared `agents`, created by accessTokens() from /auth/access-tokens. */
|
|
360
382
|
accessTokens: opaque({
|
|
361
383
|
check: (value) => {
|
|
362
384
|
const auth = isObject(value) ? value.auth : undefined;
|
|
363
|
-
if (!hasMethods(value, ["handle"]) ||
|
|
385
|
+
if (!hasMethods(value, ["handle", "slots", "forAgents"]) ||
|
|
364
386
|
!isObject(auth) ||
|
|
365
387
|
typeof auth.authorize !== "function" ||
|
|
366
388
|
auth.interactiveOperator) {
|
|
@@ -415,10 +437,59 @@ const connectaConfig = {
|
|
|
415
437
|
'"@zackbart/connecta/quickjs" on Node, or ' +
|
|
416
438
|
"workerExecutor({ loader: env.LOADER }) from " +
|
|
417
439
|
'"@zackbart/connecta/worker" around DynamicWorkerExecutor on Workers.'),
|
|
440
|
+
/**
|
|
441
|
+
* Required. Whether the default /mcp endpoint refuses every write. `true`:
|
|
442
|
+
* write tools are absent from search_tools, tool listings, and describe,
|
|
443
|
+
* and call_tool, call_destructive_tool, and program writes are refused.
|
|
444
|
+
* `false`: call_destructive_tool and programs may write, and the MCP
|
|
445
|
+
* client owns approval. Pools inherit it unless they set their own. It has
|
|
446
|
+
* no default while deployments migrate from `trust` (#820).
|
|
447
|
+
*/
|
|
448
|
+
readOnly: required(opaque({ check: assertFlag }), (path) => READ_ONLY_REQUIRED(path)),
|
|
418
449
|
};
|
|
419
450
|
const CONFIG = object(connectaConfig);
|
|
420
451
|
/** Pause-only options #672 removed with `resume_execution`. */
|
|
421
452
|
const RETIRED_PAUSE_OPTIONS = ["ConnectaConfig.execute.resumableWrites", "ConnectaConfig.execute.pausedRunTtlSeconds"];
|
|
453
|
+
/** Access options pools replaced (#837, decisions/0006). */
|
|
454
|
+
const isRetiredPoolGrant = (path) => /^ConnectaConfig\.pools\.[^.]+\.grant$/.test(path);
|
|
455
|
+
/** Management resolvers maintainers and pool membership replaced (#837, decisions/0006). */
|
|
456
|
+
const RETIRED_IDENTITY = [
|
|
457
|
+
["credentialAdministration", "maintainers administer shared credentials and OAuth grants. List them in maintainers."],
|
|
458
|
+
[
|
|
459
|
+
"personalConnection",
|
|
460
|
+
"pool members connect and disconnect their own personal connectors in their pools, and maintainers their own. Add people to a pool's members.",
|
|
461
|
+
],
|
|
462
|
+
[
|
|
463
|
+
"accessTokenManagement",
|
|
464
|
+
"maintainers issue, rotate, and revoke agent tokens from an interactive session. List them in maintainers.",
|
|
465
|
+
],
|
|
466
|
+
["activityAccess", "maintainers read activity; activity.readGate can still narrow it. List them in maintainers."],
|
|
467
|
+
];
|
|
468
|
+
/** `trust`, on the deployment or a pool, was replaced by `readOnly` (#820). */
|
|
469
|
+
const isRetiredTrust = (path) => path === "ConnectaConfig.trust" || (path.startsWith("ConnectaConfig.pools.") && path.endsWith(".trust"));
|
|
470
|
+
/**
|
|
471
|
+
* `identity` was removed whole (#837), so the walk reports only the section.
|
|
472
|
+
* Name each key it still holds, read by descriptor without running a getter
|
|
473
|
+
* or echoing a value, so a retired resolver's message names its replacement.
|
|
474
|
+
*/
|
|
475
|
+
function expandRetiredIdentity(config, paths) {
|
|
476
|
+
if (!paths.includes("ConnectaConfig.identity"))
|
|
477
|
+
return paths;
|
|
478
|
+
let keys = [];
|
|
479
|
+
try {
|
|
480
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(config, "identity");
|
|
481
|
+
const value = descriptor && "value" in descriptor ? descriptor.value : undefined;
|
|
482
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
483
|
+
keys = Reflect.ownKeys(value).filter((key) => typeof key === "string");
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
keys = [];
|
|
488
|
+
}
|
|
489
|
+
if (keys.length === 0)
|
|
490
|
+
return paths;
|
|
491
|
+
return paths.flatMap((path) => path === "ConnectaConfig.identity" ? keys.map((key) => `ConnectaConfig.identity.${key}`) : [path]);
|
|
492
|
+
}
|
|
422
493
|
function rejectUnknownOptions(paths) {
|
|
423
494
|
if (paths.length === 0)
|
|
424
495
|
return;
|
|
@@ -431,10 +502,28 @@ function rejectUnknownOptions(paths) {
|
|
|
431
502
|
? "\nMove branding into ui: operatorUi({ branding }) from @zackbart/connecta/ui."
|
|
432
503
|
: "") +
|
|
433
504
|
(paths.includes("ConnectaConfig.execute.approval")
|
|
434
|
-
?
|
|
505
|
+
? "\nexecute.approval was removed. The MCP client owns approval; set readOnly: true on the deployment or a pool to refuse writes."
|
|
435
506
|
: "") +
|
|
436
507
|
(paths.some((path) => RETIRED_PAUSE_OPTIONS.includes(path))
|
|
437
|
-
? "\nPrograms no longer pause at writes, so there is nothing to configure:
|
|
508
|
+
? "\nPrograms no longer pause at writes, so there is nothing to configure: writable endpoints let programs and call_destructive_tool write; readOnly endpoints refuse every write (issue #672). Delete the option."
|
|
509
|
+
: "") +
|
|
510
|
+
(paths.includes("ConnectaConfig.identity.connectorAccess")
|
|
511
|
+
? "\nidentity.connectorAccess was removed (#837): pools are the access model. List who may use plain /mcp in maintainers, and give each pool its tools and members. A guarded { tool, requireReadOnly: true } grant becomes a pool with readOnly: true."
|
|
512
|
+
: "") +
|
|
513
|
+
RETIRED_IDENTITY.filter(([key]) => paths.includes(`ConnectaConfig.identity.${key}`))
|
|
514
|
+
.map(([key, replacement]) => `\nidentity.${key} was removed (#837): ${replacement}`)
|
|
515
|
+
.join("") +
|
|
516
|
+
(paths.some((path) => path === "ConnectaConfig.identity" || path.startsWith("ConnectaConfig.identity."))
|
|
517
|
+
? "\nThe identity section was removed (#837): maintainers and pool members decide access and management. Delete it."
|
|
518
|
+
: "") +
|
|
519
|
+
(paths.includes("ConnectaConfig.grant")
|
|
520
|
+
? "\nThe top-level grant was removed (#837); list the people who may use plain /mcp in maintainers."
|
|
521
|
+
: "") +
|
|
522
|
+
(paths.some(isRetiredPoolGrant)
|
|
523
|
+
? "\nPool grant was removed (#837); list who may open the pool in its members."
|
|
524
|
+
: "") +
|
|
525
|
+
(paths.some(isRetiredTrust)
|
|
526
|
+
? '\ntrust was removed (#820); set readOnly, which every deployment now chooses explicitly. trust: "read-only" becomes readOnly: true, which now also refuses call_destructive_tool. trust: "trusted" becomes readOnly: false. A pool inherits the deployment readOnly unless it sets its own.'
|
|
438
527
|
: ""));
|
|
439
528
|
}
|
|
440
529
|
/** Checks that span fields; each value is already individually valid. */
|
|
@@ -443,6 +532,10 @@ function assertCoherent(config) {
|
|
|
443
532
|
config.discovery.catalogMaxTtlSeconds > 86_400) {
|
|
444
533
|
throw new ConfigError("ConnectaConfig.discovery requires catalogMinTtlSeconds <= catalogMaxTtlSeconds <= 86400.");
|
|
445
534
|
}
|
|
535
|
+
if (config.agents.length > 0 && !config.accessTokens) {
|
|
536
|
+
throw new ConfigError("ConnectaConfig.agents needs accessTokens: accessTokens(storage) from @zackbart/connecta/auth/access-tokens " +
|
|
537
|
+
"holds each agent's token slot.");
|
|
538
|
+
}
|
|
446
539
|
}
|
|
447
540
|
/**
|
|
448
541
|
* Validate a configuration and apply every default. Unknown keys are refused
|
|
@@ -461,7 +554,7 @@ export function resolveConfig(config) {
|
|
|
461
554
|
*/
|
|
462
555
|
export function readConfig(config) {
|
|
463
556
|
const read = readPlain(config, "ConnectaConfig", CONFIG);
|
|
464
|
-
rejectUnknownOptions(read.unknown);
|
|
557
|
+
rejectUnknownOptions(expandRetiredIdentity(config, read.unknown));
|
|
465
558
|
const input = read.value;
|
|
466
559
|
const parsed = CONFIG.parse(input, "ConnectaConfig");
|
|
467
560
|
assertCoherent(parsed);
|
|
@@ -470,10 +563,13 @@ export function readConfig(config) {
|
|
|
470
563
|
name: parsed.serverInfo.name ?? "connecta",
|
|
471
564
|
version: parsed.serverInfo.version ?? CONNECTA_VERSION,
|
|
472
565
|
};
|
|
566
|
+
// The token module admits exactly the agents this configuration declares.
|
|
567
|
+
const accessTokens = parsed.accessTokens?.forAgents(parsed.agents);
|
|
473
568
|
const resolved = Object.freeze({
|
|
474
569
|
...parsed,
|
|
570
|
+
accessTokens,
|
|
475
571
|
// A managed-token module contributes one more provider, ordered with the rest.
|
|
476
|
-
auth:
|
|
572
|
+
auth: accessTokens ? normalizeAuth([accessTokens.auth, ...parsed.auth]) : parsed.auth,
|
|
477
573
|
serverInfo: Object.freeze(info),
|
|
478
574
|
});
|
|
479
575
|
return { input, resolved };
|
|
@@ -1,47 +1,18 @@
|
|
|
1
1
|
import type { ToolAccess } from "./registry.js";
|
|
2
|
-
import type { AuthenticatedIdentity } from "./types.js";
|
|
3
|
-
/** A declared pool after construction-time validation. */
|
|
4
|
-
export interface ResolvedPool {
|
|
5
|
-
trust: import("./tool-safety.js").PoolTrust;
|
|
6
|
-
access: ConnectorAccess;
|
|
7
|
-
grant(identity: Readonly<AuthenticatedIdentity>): boolean | Promise<boolean>;
|
|
8
|
-
}
|
|
9
2
|
export declare const POOL_NAME_RE: RegExp;
|
|
10
3
|
/**
|
|
11
4
|
* One derived view: which connectors, and for connectors granted by address
|
|
12
5
|
* only, which tools. A connector absent from `toolAccess` is visible whole.
|
|
13
|
-
* `guardedToolAccess` is a subset of `toolAccess` checked against the loaded
|
|
14
|
-
* catalog each time tools are discovered or invoked.
|
|
15
6
|
*/
|
|
16
7
|
export interface ConnectorAccess {
|
|
17
8
|
connectorIds: "all" | readonly string[];
|
|
18
9
|
toolAccess?: ToolAccess;
|
|
19
|
-
/** Exact tool grants that also require a current explicit read-only annotation. */
|
|
20
|
-
guardedToolAccess?: ToolAccess;
|
|
21
|
-
}
|
|
22
|
-
interface ReadOnlyToolGrant {
|
|
23
|
-
tool: string;
|
|
24
|
-
requireReadOnly: true;
|
|
25
10
|
}
|
|
26
|
-
export type ConnectorGrant = string | ReadOnlyToolGrant;
|
|
27
11
|
/**
|
|
28
|
-
* Normalize a
|
|
12
|
+
* Normalize a pool's `tools`. A bare connector id grants every tool on that
|
|
29
13
|
* connector; a `connector.tool` address grants one tool. Grants are additive,
|
|
30
14
|
* so a bare id beside addresses for the same connector means the whole
|
|
31
|
-
* connector.
|
|
32
|
-
*
|
|
33
|
-
* pool declarations remain strings. Anything else throws, and the caller
|
|
34
|
-
* decides whether that is a construction failure or a 403: a grant that
|
|
35
|
-
* cannot be parsed must never fail open.
|
|
36
|
-
*/
|
|
37
|
-
export declare function parseConnectorAccess(value: unknown, options?: {
|
|
38
|
-
allowReadOnly?: boolean;
|
|
39
|
-
}): ConnectorAccess;
|
|
40
|
-
/**
|
|
41
|
-
* The view a pool endpoint serves: the pool's grants, never wider than the
|
|
42
|
-
* identity's own. A connector or tool outside either side is gone; a
|
|
43
|
-
* connector whose tool intersection is empty is gone too, so the pool can
|
|
44
|
-
* only narrow what the identity resolver already allowed.
|
|
15
|
+
* connector. Anything else throws: a grant that cannot be parsed must never
|
|
16
|
+
* fail open.
|
|
45
17
|
*/
|
|
46
|
-
export declare function
|
|
47
|
-
export {};
|
|
18
|
+
export declare function parseConnectorAccess(value: unknown): ConnectorAccess;
|
package/dist/connector-access.js
CHANGED
|
@@ -2,121 +2,37 @@ import { hasControlCharacters } from "./tool-name.js";
|
|
|
2
2
|
export const POOL_NAME_RE = /^[a-z0-9_-]+$/;
|
|
3
3
|
const CONNECTOR_ID_RE = /^[a-z0-9_-]+$/;
|
|
4
4
|
// MCP does not restrict tool names, and remote servers ship spaced and
|
|
5
|
-
// non-ASCII ones. Only control characters are refused, so a
|
|
6
|
-
// legitimately named tool
|
|
5
|
+
// non-ASCII ones. Only control characters are refused, so a pool naming a
|
|
6
|
+
// legitimately named tool still constructs.
|
|
7
7
|
const TOOL_ADDRESS_RE = /^[a-z0-9_-]+\..{1,256}$/su;
|
|
8
8
|
/**
|
|
9
|
-
* Normalize a
|
|
9
|
+
* Normalize a pool's `tools`. A bare connector id grants every tool on that
|
|
10
10
|
* connector; a `connector.tool` address grants one tool. Grants are additive,
|
|
11
11
|
* so a bare id beside addresses for the same connector means the whole
|
|
12
|
-
* connector.
|
|
13
|
-
*
|
|
14
|
-
* pool declarations remain strings. Anything else throws, and the caller
|
|
15
|
-
* decides whether that is a construction failure or a 403: a grant that
|
|
16
|
-
* cannot be parsed must never fail open.
|
|
12
|
+
* connector. Anything else throws: a grant that cannot be parsed must never
|
|
13
|
+
* fail open.
|
|
17
14
|
*/
|
|
18
|
-
export function parseConnectorAccess(value
|
|
19
|
-
if (value === "all")
|
|
20
|
-
return { connectorIds: "all" };
|
|
15
|
+
export function parseConnectorAccess(value) {
|
|
21
16
|
if (!Array.isArray(value))
|
|
22
17
|
throw new Error("invalid connector permission");
|
|
23
18
|
const whole = new Set();
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
whole.add(entry);
|
|
19
|
+
const partial = new Map();
|
|
20
|
+
for (const address of value) {
|
|
21
|
+
if (typeof address === "string" && CONNECTOR_ID_RE.test(address)) {
|
|
22
|
+
whole.add(address);
|
|
29
23
|
continue;
|
|
30
24
|
}
|
|
31
|
-
const isGuarded = typeof entry !== "string";
|
|
32
|
-
if (isGuarded &&
|
|
33
|
-
(!options.allowReadOnly ||
|
|
34
|
-
entry === null ||
|
|
35
|
-
typeof entry !== "object" ||
|
|
36
|
-
Array.isArray(entry) ||
|
|
37
|
-
!Object.hasOwn(entry, "tool") ||
|
|
38
|
-
!Object.hasOwn(entry, "requireReadOnly") ||
|
|
39
|
-
Object.keys(entry).length !== 2 ||
|
|
40
|
-
entry.requireReadOnly !== true)) {
|
|
41
|
-
throw new Error("invalid connector permission");
|
|
42
|
-
}
|
|
43
|
-
const address = isGuarded ? entry.tool : entry;
|
|
44
25
|
if (typeof address !== "string" || !TOOL_ADDRESS_RE.test(address) || hasControlCharacters(address)) {
|
|
45
26
|
throw new Error("invalid connector permission");
|
|
46
27
|
}
|
|
47
28
|
const dot = address.indexOf(".");
|
|
48
29
|
const connectorId = address.slice(0, dot);
|
|
49
|
-
const
|
|
50
|
-
const tools = target.get(connectorId) ?? new Set();
|
|
30
|
+
const tools = partial.get(connectorId) ?? new Set();
|
|
51
31
|
tools.add(address.slice(dot + 1));
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
for (const id of whole) {
|
|
55
|
-
plain.delete(id);
|
|
56
|
-
guarded.delete(id);
|
|
57
|
-
}
|
|
58
|
-
for (const [id, names] of plain) {
|
|
59
|
-
const restricted = guarded.get(id);
|
|
60
|
-
if (!restricted)
|
|
61
|
-
continue;
|
|
62
|
-
for (const name of names)
|
|
63
|
-
restricted.delete(name);
|
|
64
|
-
if (restricted.size === 0)
|
|
65
|
-
guarded.delete(id);
|
|
66
|
-
}
|
|
67
|
-
const partial = new Map(plain);
|
|
68
|
-
for (const [id, names] of guarded) {
|
|
69
|
-
const combined = partial.get(id) ?? new Set();
|
|
70
|
-
for (const name of names)
|
|
71
|
-
combined.add(name);
|
|
72
|
-
partial.set(id, combined);
|
|
32
|
+
partial.set(connectorId, tools);
|
|
73
33
|
}
|
|
34
|
+
for (const id of whole)
|
|
35
|
+
partial.delete(id);
|
|
74
36
|
const connectorIds = [...new Set([...whole, ...partial.keys()])];
|
|
75
|
-
return {
|
|
76
|
-
connectorIds,
|
|
77
|
-
...(partial.size > 0 ? { toolAccess: partial } : {}),
|
|
78
|
-
...(guarded.size > 0 ? { guardedToolAccess: guarded } : {}),
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* The view a pool endpoint serves: the pool's grants, never wider than the
|
|
83
|
-
* identity's own. A connector or tool outside either side is gone; a
|
|
84
|
-
* connector whose tool intersection is empty is gone too, so the pool can
|
|
85
|
-
* only narrow what the identity resolver already allowed.
|
|
86
|
-
*/
|
|
87
|
-
export function intersectAccess(ceiling, pool) {
|
|
88
|
-
if (pool.connectorIds === "all")
|
|
89
|
-
return ceiling;
|
|
90
|
-
const allowedIds = ceiling.connectorIds === "all" ? null : new Set(ceiling.connectorIds);
|
|
91
|
-
const connectorIds = [];
|
|
92
|
-
const toolAccess = new Map();
|
|
93
|
-
const guardedToolAccess = new Map();
|
|
94
|
-
for (const id of pool.connectorIds) {
|
|
95
|
-
if (allowedIds && !allowedIds.has(id))
|
|
96
|
-
continue;
|
|
97
|
-
const fromPool = pool.toolAccess?.get(id);
|
|
98
|
-
const fromCeiling = ceiling.toolAccess?.get(id);
|
|
99
|
-
if (fromPool && fromCeiling) {
|
|
100
|
-
const both = new Set([...fromPool].filter((name) => fromCeiling.has(name)));
|
|
101
|
-
if (both.size === 0)
|
|
102
|
-
continue;
|
|
103
|
-
toolAccess.set(id, both);
|
|
104
|
-
}
|
|
105
|
-
else if (fromPool ?? fromCeiling) {
|
|
106
|
-
toolAccess.set(id, (fromPool ?? fromCeiling));
|
|
107
|
-
}
|
|
108
|
-
const guarded = ceiling.guardedToolAccess?.get(id);
|
|
109
|
-
if (guarded) {
|
|
110
|
-
const allowed = toolAccess.get(id);
|
|
111
|
-
const retained = allowed ? new Set([...guarded].filter((name) => allowed.has(name))) : guarded;
|
|
112
|
-
if (retained.size > 0)
|
|
113
|
-
guardedToolAccess.set(id, retained);
|
|
114
|
-
}
|
|
115
|
-
connectorIds.push(id);
|
|
116
|
-
}
|
|
117
|
-
return {
|
|
118
|
-
connectorIds,
|
|
119
|
-
...(toolAccess.size > 0 ? { toolAccess } : {}),
|
|
120
|
-
...(guardedToolAccess.size > 0 ? { guardedToolAccess } : {}),
|
|
121
|
-
};
|
|
37
|
+
return { connectorIds, ...(partial.size > 0 ? { toolAccess: partial } : {}) };
|
|
122
38
|
}
|
|
@@ -9,8 +9,14 @@ export interface ConnectorCaller {
|
|
|
9
9
|
* with or without a subject, a Connecta-issued access token.
|
|
10
10
|
*/
|
|
11
11
|
authenticated: boolean;
|
|
12
|
-
/** The
|
|
12
|
+
/** The pool the call arrived on, at `/mcp/<pool>` or `/agent/<pool>`. */
|
|
13
13
|
pool?: string;
|
|
14
|
+
/**
|
|
15
|
+
* That pool's configured `owner`, the Workspace address an agent acts as
|
|
16
|
+
* (decisions/0006). Core copies it from the admitted pool's config; no
|
|
17
|
+
* request can set it, and humans never act as it.
|
|
18
|
+
*/
|
|
19
|
+
poolOwner?: string;
|
|
14
20
|
}
|
|
15
21
|
export declare function attachCaller(ctx: ConnectorContext, caller: ConnectorCaller | undefined): ConnectorContext;
|
|
16
22
|
export declare function callerOf(ctx: ConnectorContext): ConnectorCaller | undefined;
|
package/dist/connector-caller.js
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
// - maintained providers that act *as* the caller downstream — Google
|
|
9
9
|
// Workspace through domain-wide delegation (`src/providers/_shared/google/`) — which
|
|
10
10
|
// hand the identity to a deployment-config function that names the
|
|
11
|
-
// downstream subject (PRINCIPLES.md INV-3, #678)
|
|
12
|
-
//
|
|
11
|
+
// downstream subject (PRINCIPLES.md INV-3, #678), or, for an agent, use the
|
|
12
|
+
// admitted pool's configured owner (decisions/0006). The provider never
|
|
13
|
+
// decides whose account to open; config does, from this.
|
|
13
14
|
//
|
|
14
15
|
// Core sets it from the authorization that admitted the request, on the scoped
|
|
15
16
|
// registry view that request receives, and `api()` hands handlers that same
|
|
@@ -146,10 +146,33 @@ export interface ApiOptions {
|
|
|
146
146
|
/** The closed options api() accepts; see `assertKnownOptions`. */
|
|
147
147
|
export declare const API_OPTIONS: import("../config-schema.js").Field<unknown, unknown, true> & {
|
|
148
148
|
readonly shape: {
|
|
149
|
-
callAdmission: import("../config-schema.js").Field<unknown,
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
maxPartitions: import("../config-schema.js").Field<
|
|
149
|
+
callAdmission: Omit<import("../config-schema.js").Field<unknown, ConnectorCallAdmissionPolicy | undefined, true>, "parse"> & {
|
|
150
|
+
parse(value: unknown, path: string): ConnectorCallAdmissionPolicy;
|
|
151
|
+
readonly shape: import("../config-schema.js").Shape & {
|
|
152
|
+
maxPartitions: import("../config-schema.js").Field<number, number | undefined, true>;
|
|
153
|
+
rules: import("../config-schema.js").Field<{
|
|
154
|
+
maxConcurrency?: number | undefined;
|
|
155
|
+
maxQueueSize?: number | undefined;
|
|
156
|
+
queueTimeoutMs?: number | undefined;
|
|
157
|
+
retryAfterMs?: number | undefined;
|
|
158
|
+
partitionKey?: ((input: Readonly<import("../types.js").ConnectorCallAdmissionInput>) => string) | undefined;
|
|
159
|
+
budget?: {
|
|
160
|
+
kind: "rolling-window";
|
|
161
|
+
maxCalls: number;
|
|
162
|
+
windowMs: number;
|
|
163
|
+
} | undefined;
|
|
164
|
+
}[], readonly {
|
|
165
|
+
readonly maxConcurrency: number | undefined;
|
|
166
|
+
readonly maxQueueSize: number | undefined;
|
|
167
|
+
readonly queueTimeoutMs: number | undefined;
|
|
168
|
+
readonly retryAfterMs: number | undefined;
|
|
169
|
+
readonly partitionKey: ((input: Readonly<import("../types.js").ConnectorCallAdmissionInput>) => string) | undefined;
|
|
170
|
+
readonly budget: {
|
|
171
|
+
readonly kind: "rolling-window";
|
|
172
|
+
readonly maxCalls: number;
|
|
173
|
+
readonly windowMs: number;
|
|
174
|
+
} | undefined;
|
|
175
|
+
}[], false>;
|
|
153
176
|
};
|
|
154
177
|
};
|
|
155
178
|
usageGuide: import("../config-schema.js").Field<unknown, unknown, true> & {
|
|
@@ -90,6 +90,8 @@ function describedApiAuth(opts) {
|
|
|
90
90
|
*/
|
|
91
91
|
export function apiConnector(id, opts, oauth) {
|
|
92
92
|
opts = assertKnownOptions(opts, `api(${JSON.stringify(id)})`, API_OPTIONS);
|
|
93
|
+
if (opts.callAdmission !== undefined)
|
|
94
|
+
CALL_ADMISSION.parse(opts.callAdmission, `api(${JSON.stringify(id)}).callAdmission`);
|
|
93
95
|
assertStaticToolNames(opts.tools, `api(${JSON.stringify(id)}).tools`);
|
|
94
96
|
if (opts.oauth !== undefined && oauth === undefined) {
|
|
95
97
|
throw new Error(`api() connector "${id}" declares oauth but was built without its grant; construct it with api().`);
|
|
@@ -56,6 +56,8 @@ interface GuardedResponse {
|
|
|
56
56
|
readonly status: number;
|
|
57
57
|
readonly ok: boolean;
|
|
58
58
|
readonly headers: Headers;
|
|
59
|
+
/** Cancel an unread body without waiting for the vendor to emit a chunk. */
|
|
60
|
+
cancel(): Promise<void>;
|
|
59
61
|
/** The body as bytes. */
|
|
60
62
|
bytes(): Promise<Uint8Array>;
|
|
61
63
|
/** The body decoded as UTF-8. */
|
|
@@ -124,6 +126,13 @@ export interface GuardedFetchOptions {
|
|
|
124
126
|
* absurd response is a fact about the API, not about HTTP.
|
|
125
127
|
*/
|
|
126
128
|
maxResponseBytes: number;
|
|
129
|
+
/**
|
|
130
|
+
* Inspect the exact URL about to be fetched — confined, normalized, with its
|
|
131
|
+
* query encoded — and throw to refuse it. A policy that reads this URL reads
|
|
132
|
+
* what the downstream server will route, not a spelling of it. Runs before
|
|
133
|
+
* authentication, so a refused request never resolves a credential.
|
|
134
|
+
*/
|
|
135
|
+
admit?: (url: URL, request: GuardedRequest) => void;
|
|
127
136
|
}
|
|
128
137
|
/** Sends one already-guarded request on behalf of one call's context. */
|
|
129
138
|
type GuardedFetcher = (url: string, init: RequestInit, ctx: ConnectorContext) => Promise<Response>;
|
|
@@ -181,6 +181,9 @@ function boundedResponse(provider, response, limit) {
|
|
|
181
181
|
status: response.status,
|
|
182
182
|
ok: response.ok,
|
|
183
183
|
headers: response.headers,
|
|
184
|
+
cancel: async () => {
|
|
185
|
+
await response.body?.cancel();
|
|
186
|
+
},
|
|
184
187
|
bytes: async () => new Uint8Array(),
|
|
185
188
|
text: async () => "",
|
|
186
189
|
json: emptyJson,
|
|
@@ -228,6 +231,9 @@ function boundedResponse(provider, response, limit) {
|
|
|
228
231
|
status: response.status,
|
|
229
232
|
ok: response.ok,
|
|
230
233
|
headers: response.headers,
|
|
234
|
+
cancel: async () => {
|
|
235
|
+
await response.body?.cancel();
|
|
236
|
+
},
|
|
231
237
|
bytes,
|
|
232
238
|
text,
|
|
233
239
|
json,
|
|
@@ -307,6 +313,7 @@ export function guardedFetch(options) {
|
|
|
307
313
|
throw new Error(`A ${provider} request cannot carry both a JSON body and a raw body.`);
|
|
308
314
|
}
|
|
309
315
|
const url = confinedUrl(provider, base, basePath, request);
|
|
316
|
+
options.admit?.(url, request);
|
|
310
317
|
const headers = { ...constantHeaders };
|
|
311
318
|
for (const [name, value] of Object.entries(request.headers ?? {})) {
|
|
312
319
|
if (value !== undefined)
|
|
@@ -1024,6 +1024,8 @@ function registrationErrorCode(body) {
|
|
|
1024
1024
|
*/
|
|
1025
1025
|
export function remoteMcp(id, opts) {
|
|
1026
1026
|
opts = assertKnownOptions(opts, `remoteMcp(${JSON.stringify(id)})`, REMOTE_MCP_OPTIONS);
|
|
1027
|
+
if (opts.callAdmission !== undefined)
|
|
1028
|
+
CALL_ADMISSION.parse(opts.callAdmission, `remoteMcp(${JSON.stringify(id)}).callAdmission`);
|
|
1027
1029
|
const skillsEnabled = opts.skills;
|
|
1028
1030
|
if (skillsEnabled !== undefined && typeof skillsEnabled !== "boolean") {
|
|
1029
1031
|
throw new Error(`[connecta] connector "${id}" skills must be a boolean.`);
|