@zackbart/connecta 0.36.1 → 0.37.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 +36 -0
- package/bin/version.mjs +1 -1
- package/dist/auth/downstream-oauth.js +34 -12
- package/dist/auth/oauth-authorization-params.js +52 -0
- package/dist/auth/static-oauth.js +4 -6
- package/dist/auth/stored-observation.js +8 -0
- package/dist/call-admission.js +27 -14
- package/dist/connectors/api-connector.js +4 -0
- package/dist/connectors/option-shapes.js +6 -1
- package/dist/connectors/remote-mcp.d.ts +32 -3
- package/dist/connectors/remote-mcp.js +152 -21
- package/dist/execute.js +5 -5
- package/dist/executor-admission.js +27 -13
- package/dist/executors/quickjs.js +4 -1
- package/dist/google.d.ts +13 -0
- package/dist/google.js +42 -0
- package/dist/index.d.ts +2 -1
- package/dist/invocation.js +6 -7
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +2 -0
- package/dist/providers/_shared/google/service-account.d.ts +1 -1
- package/dist/providers/_shared/google/service-account.js +20 -8
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/request-principal.js +34 -0
- package/dist/request-token.d.ts +53 -0
- package/dist/request-token.js +1 -0
- package/dist/routes/mcp.js +6 -6
- package/dist/routes/ui-config.js +38 -9
- package/dist/routes/ui-data.js +30 -5
- package/dist/routes/ui.js +10 -2
- package/dist/runtime/settlement.js +59 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.js +4 -1
- package/documentation/README.md +13 -12
- package/documentation/architecture.md +91 -26
- package/documentation/auth.md +317 -12
- package/documentation/code-mode.md +25 -5
- package/documentation/integrating.md +18 -4
- package/documentation/operator-tests.md +19 -0
- package/documentation/operator-ui.md +57 -46
- package/examples/worker/README.md +5 -1
- package/guides/connecting-agents.md +6 -0
- package/guides/safety-and-trust.md +1 -0
- package/package.json +5 -1
- package/templates/node/package.json +1 -1
|
@@ -57,6 +57,8 @@ interface UiCredential {
|
|
|
57
57
|
notice?: string;
|
|
58
58
|
}
|
|
59
59
|
export interface UiConnector {
|
|
60
|
+
/** Complete catalog and status from this detail request, when supported. */
|
|
61
|
+
overlay?: OperatorConnectorOverlay;
|
|
60
62
|
id: string;
|
|
61
63
|
/** Downstream authentication owner. Older payload fixtures omit it as shared. */
|
|
62
64
|
authScope?: "shared" | "personal";
|
|
@@ -35,7 +35,7 @@ export declare function parseServiceAccount(owner: string, input: unknown): Serv
|
|
|
35
35
|
/** One delegated token request: whose account, acting as whom, for what. */
|
|
36
36
|
export interface DelegatedTokenRequest {
|
|
37
37
|
account: ServiceAccountKey;
|
|
38
|
-
subject
|
|
38
|
+
subject?: string | undefined;
|
|
39
39
|
scopes: readonly string[];
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A Google service account acting for Workspace users through
|
|
3
|
-
* delegation: the key,
|
|
4
|
-
* access tokens
|
|
5
|
-
* own export.
|
|
2
|
+
* A Google service account acting as itself or for Workspace users through
|
|
3
|
+
* domain-wide delegation: the key, signed JWT-bearer assertions and short-lived
|
|
4
|
+
* access tokens. Shared by Workspace providers and the optional /google helper.
|
|
6
5
|
*
|
|
7
6
|
* Why not the vault. One key serves every Workspace connector a deployment
|
|
8
7
|
* declares — Gmail, Drive, Docs — and it is a deployment secret in the same
|
|
@@ -300,9 +299,17 @@ function tokenAnswer(status, code) {
|
|
|
300
299
|
* Map a refused token request to what fixes it. The codes are RFC 6749's and
|
|
301
300
|
* Google documents what each means for a delegated assertion.
|
|
302
301
|
*/
|
|
303
|
-
function tokenFailure(account, scopes, status, headers, payload) {
|
|
302
|
+
function tokenFailure(account, scopes, status, headers, payload, subject) {
|
|
304
303
|
const code = asRecord(payload)["error"];
|
|
305
304
|
const said = tokenAnswer(status, code);
|
|
305
|
+
if (subject === undefined) {
|
|
306
|
+
if (code === "unauthorized_client" || code === "access_denied" || code === "invalid_scope" || status === 403) {
|
|
307
|
+
return new ConnectorCallError("provider_permission_denied", `Google refused a service-account token.${said} Check the requested OAuth scopes and the service account's IAM permissions for the target API and resources.`);
|
|
308
|
+
}
|
|
309
|
+
if (code === "invalid_grant") {
|
|
310
|
+
return resendAdvice(new ConnectorCallError("auth_required", `Google refused the service-account assertion.${said} The key may be deleted or disabled, or this host's clock may be minutes off. Check the key and clock.`), " before retrying");
|
|
311
|
+
}
|
|
312
|
+
}
|
|
306
313
|
const client = account.clientId
|
|
307
314
|
? `client ID ${account.clientId} (${account.clientEmail})`
|
|
308
315
|
: `the client ID of ${account.clientEmail}`;
|
|
@@ -325,7 +332,7 @@ function tokenFailure(account, scopes, status, headers, payload) {
|
|
|
325
332
|
if (status === 403) {
|
|
326
333
|
return new ConnectorCallError("provider_permission_denied", `Google refused domain-wide delegation.${said} Ask a Workspace super admin to review ${client} in the Admin console's Manage Domain Wide Delegation entry and grant exactly these scopes: ${scopes.join(",")}.`);
|
|
327
334
|
}
|
|
328
|
-
return new ConnectorCallError("connector_call_failed", `Google's token endpoint refused the delegated assertion with HTTP ${status}.${said}`, { retryable: false });
|
|
335
|
+
return new ConnectorCallError("connector_call_failed", `Google's token endpoint refused the ${subject === undefined ? "service-account" : "delegated"} assertion with HTTP ${status}.${said}`, { retryable: false });
|
|
329
336
|
}
|
|
330
337
|
async function mint(account, subject, scopes, ctx) {
|
|
331
338
|
const issuedAt = Date.now();
|
|
@@ -339,7 +346,7 @@ async function mint(account, subject, scopes, ctx) {
|
|
|
339
346
|
const parsed = await response.jsonResult();
|
|
340
347
|
const payload = "value" in parsed ? parsed.value : undefined;
|
|
341
348
|
if (!response.ok) {
|
|
342
|
-
throw verdict(tokenFailure(account, scopes, response.status, response.headers, payload));
|
|
349
|
+
throw verdict(tokenFailure(account, scopes, response.status, response.headers, payload, subject));
|
|
343
350
|
}
|
|
344
351
|
const body = asRecord(payload);
|
|
345
352
|
const accessToken = body["access_token"];
|
|
@@ -383,7 +390,12 @@ function keyIdentity(account) {
|
|
|
383
390
|
}
|
|
384
391
|
async function cacheKey({ account, subject, scopes }) {
|
|
385
392
|
// Workspace addresses are case-insensitive; scope order is not a new grant.
|
|
386
|
-
return
|
|
393
|
+
return JSON.stringify([
|
|
394
|
+
account.clientEmail,
|
|
395
|
+
await keyIdentity(account),
|
|
396
|
+
subject?.toLowerCase() ?? null,
|
|
397
|
+
[...new Set(scopes)].sort(),
|
|
398
|
+
]);
|
|
387
399
|
}
|
|
388
400
|
function remember(key, minted) {
|
|
389
401
|
tokens.delete(key);
|
|
@@ -55,4 +55,4 @@ export declare const PLANNING_CENTER_MAX_RESPONSE_BYTES: number;
|
|
|
55
55
|
*/
|
|
56
56
|
export declare const PLANNING_CENTER_ADMISSION: ConnectorCallAdmissionPolicy;
|
|
57
57
|
/** Planning Center refuses a request without a descriptive one (HTTP 403). */
|
|
58
|
-
export declare const DEFAULT_USER_AGENT = "connecta/0.
|
|
58
|
+
export declare const DEFAULT_USER_AGENT = "connecta/0.37.0 (+https://github.com/zackbart/connecta)";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The admitted principal a request-auth resolver may receive (#1103).
|
|
2
|
+
//
|
|
3
|
+
// Only the caller core attached from the admitting authorization counts
|
|
4
|
+
// (`callerOf`, see connector-caller.ts). No argument, program or header can
|
|
5
|
+
// name it, and a context no request admitted has none. Nothing here
|
|
6
|
+
// invents a delegated identity: a caller that is not an admitted person, or a
|
|
7
|
+
// declared agent the deployment explicitly accepts, is refused before the
|
|
8
|
+
// resolver runs.
|
|
9
|
+
import { isAgentName } from "./agent-identity.js";
|
|
10
|
+
import { callerOf } from "./connector-caller.js";
|
|
11
|
+
import { validIdentityReference } from "./identity.js";
|
|
12
|
+
/**
|
|
13
|
+
* The principal this operation's admitted caller is. Anything else is refused
|
|
14
|
+
* through `refuse`, the connector's own `auth_required` error.
|
|
15
|
+
*/
|
|
16
|
+
export function requestPrincipalFor(policy, ctx, refuse) {
|
|
17
|
+
const caller = callerOf(ctx);
|
|
18
|
+
// An operator check, a context no request admitted, or an open deployment's anonymous caller.
|
|
19
|
+
if (!caller?.authenticated)
|
|
20
|
+
throw refuse("acts as the admitted caller, and this operation has none");
|
|
21
|
+
const { identity } = caller;
|
|
22
|
+
if (identity.agent !== undefined) {
|
|
23
|
+
if (policy !== "human-or-agent")
|
|
24
|
+
throw refuse("refuses agents unless deployment code accepts them");
|
|
25
|
+
if (!isAgentName(identity.agent) || identity.principal !== undefined || !caller.pool)
|
|
26
|
+
throw refuse("acts as the admitted caller, and refuses this one");
|
|
27
|
+
return Object.freeze({ kind: "agent", name: identity.agent, pool: caller.pool });
|
|
28
|
+
}
|
|
29
|
+
const principal = identity.principal;
|
|
30
|
+
if (!validIdentityReference(principal))
|
|
31
|
+
throw refuse("acts as the admitted caller, and refuses this one");
|
|
32
|
+
// Fixed key order: `JSON.stringify` of the frozen result is its partition key.
|
|
33
|
+
return Object.freeze({ kind: "human", namespace: principal.namespace, id: principal.id });
|
|
34
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ConnectorContext } from "./types.js";
|
|
2
|
+
/** A request-local Bearer resolver with optional cache invalidation on HTTP 401. */
|
|
3
|
+
export interface RequestToken {
|
|
4
|
+
(ctx: ConnectorContext): Promise<string>;
|
|
5
|
+
/** Drop only the rejected token. This hook never authorizes replay. */
|
|
6
|
+
invalidate?: (rejected: string, ctx: ConnectorContext) => Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* A person inbound auth admitted, as the `{ namespace, id }` a member
|
|
10
|
+
* reference resolves to. Never taken from arguments, programs or headers.
|
|
11
|
+
*/
|
|
12
|
+
export interface HumanRequestPrincipal {
|
|
13
|
+
readonly kind: "human";
|
|
14
|
+
readonly namespace: string;
|
|
15
|
+
readonly id: string;
|
|
16
|
+
}
|
|
17
|
+
/** A declared agent and the pool it was admitted through. */
|
|
18
|
+
export interface AgentRequestPrincipal {
|
|
19
|
+
readonly kind: "agent";
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly pool: string;
|
|
22
|
+
}
|
|
23
|
+
/** The admitted caller a principal-forwarding request resolver receives. */
|
|
24
|
+
export type RequestPrincipal = HumanRequestPrincipal | AgentRequestPrincipal;
|
|
25
|
+
/**
|
|
26
|
+
* Which admitted callers a principal-forwarding request resolver accepts.
|
|
27
|
+
* `"human"` refuses agents; `"human-or-agent"` also accepts declared agents.
|
|
28
|
+
*/
|
|
29
|
+
export type RequestPrincipalPolicy = "human" | "human-or-agent";
|
|
30
|
+
/**
|
|
31
|
+
* Opt-in principal forwarding for request auth: the resolver receives the
|
|
32
|
+
* admitted caller, and every other caller is refused before it runs.
|
|
33
|
+
*/
|
|
34
|
+
export type PrincipalForwarding = {
|
|
35
|
+
accept: "human";
|
|
36
|
+
token: PrincipalRequestToken<HumanRequestPrincipal>;
|
|
37
|
+
} | {
|
|
38
|
+
accept: "human-or-agent";
|
|
39
|
+
token: PrincipalRequestToken;
|
|
40
|
+
};
|
|
41
|
+
/** Per-resolution input for a principal-forwarding resolver. */
|
|
42
|
+
export interface RequestTokenInput<P extends RequestPrincipal = RequestPrincipal> {
|
|
43
|
+
readonly principal: P;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A request-local Bearer resolver that receives the admitted principal.
|
|
47
|
+
* Return the same token for the same principal while it is valid.
|
|
48
|
+
*/
|
|
49
|
+
export interface PrincipalRequestToken<P extends RequestPrincipal = RequestPrincipal> {
|
|
50
|
+
(ctx: ConnectorContext, input: RequestTokenInput<P>): Promise<string>;
|
|
51
|
+
/** Drop only the rejected token for this principal. This hook never authorizes replay. */
|
|
52
|
+
invalidate?: (rejected: string, ctx: ConnectorContext, input: RequestTokenInput<P>) => Promise<void>;
|
|
53
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/routes/mcp.js
CHANGED
|
@@ -267,12 +267,12 @@ function scopedToBody(handler, signal) {
|
|
|
267
267
|
/**
|
|
268
268
|
* The request's admission permit, owned by its Scope.
|
|
269
269
|
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
270
|
+
* The pool is shared by every request in the isolate, and a queued request is
|
|
271
|
+
* handed its permit from inside the request whose body just ended. The queue
|
|
272
|
+
* wakes it through a Promise its own request created
|
|
273
|
+
* (src/runtime/settlement.ts), and this fiber awaits `acquire()`'s Promise, so
|
|
274
|
+
* it authorizes and serves this request in its own I/O context, never that
|
|
275
|
+
* one's, which workerd refuses.
|
|
276
276
|
*
|
|
277
277
|
* The wait stays interruptible, and a permit granted just as it was
|
|
278
278
|
* interrupted still arrives, later, to be released on arrival.
|
package/dist/routes/ui-config.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { storedAuthObservation } from "../auth/stored-observation.js";
|
|
1
2
|
import { agentDescriptions } from "../agent-description.js";
|
|
2
3
|
import { readAgentAdmission } from "../agent-observation.js";
|
|
3
4
|
// `GET /ui/api/config`: operator contract v2 (#837, decisions/0006), the
|
|
@@ -25,6 +26,8 @@ import { workspaceSubjectOf } from "../workspace-subject.js";
|
|
|
25
26
|
import { authorized, refuse, scopeFor, serveOperator } from "./operator.js";
|
|
26
27
|
import { isMaintainer, isMember } from "../membership.js";
|
|
27
28
|
import { admitsMaintainer, mayManageConnector, privateJson, readsActivity } from "./shared.js";
|
|
29
|
+
const cachedMaintainers = new WeakSet();
|
|
30
|
+
const cachedMode = (context) => context.url.searchParams.get("live") === "cached";
|
|
28
31
|
const attempt = (operation) => Effect.tryPromise({ try: operation, catch: (error) => error });
|
|
29
32
|
const validName = (name) => recordedToolName({ name }) === name;
|
|
30
33
|
const classified = (tool) => tool.classification === "read" ? "read" : "write";
|
|
@@ -65,7 +68,7 @@ function probe(context, registry, connector, scope, work, fallback) {
|
|
|
65
68
|
*/
|
|
66
69
|
function bounded(context, read, fallback) {
|
|
67
70
|
return withDeadlineEffect(() => read(), {
|
|
68
|
-
timeoutMs: context.opts.config.discovery.probeTimeoutMs,
|
|
71
|
+
timeoutMs: cachedMaintainers.has(context) ? 500 : context.opts.config.discovery.probeTimeoutMs,
|
|
69
72
|
signal: context.request.signal,
|
|
70
73
|
timeoutError: new Error("Operator config read timed out."),
|
|
71
74
|
}).pipe(Effect.catchDefect((defect) => Effect.fail(defect)), Effect.catch(fallback));
|
|
@@ -141,7 +144,7 @@ const isTimestamp = (value) => /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(v
|
|
|
141
144
|
* declared agent's `lastSeenAt` from the rows this maintainer may see. Agent
|
|
142
145
|
* rows are recorded under the access-token directory with the agent's name.
|
|
143
146
|
*/
|
|
144
|
-
function
|
|
147
|
+
export function scanOperatorActivity(context, authz, registry, rows, agents) {
|
|
145
148
|
const activity = context.opts.config.activity;
|
|
146
149
|
if (!activity?.store.list)
|
|
147
150
|
return Effect.succeed("unconfigured");
|
|
@@ -232,6 +235,12 @@ function sharedSource(context, connector, row) {
|
|
|
232
235
|
// Only a connector asking to be authorized has no grant; any other
|
|
233
236
|
// state, a credential prompt or an error included, has one. A grant
|
|
234
237
|
// carries no saved-at time of its own.
|
|
238
|
+
if (cachedMode(context)) {
|
|
239
|
+
const observe = storedAuthObservation(connector);
|
|
240
|
+
return observe
|
|
241
|
+
? bounded(context, () => attempt(() => observe(opts.registry.contextFor(connector.id, context.baseUrl, {}))).pipe(Effect.map((ok) => (ok ? { kind: "shared", source: "oauth", savedAt: null } : MISSING))), () => Effect.succeed(MISSING))
|
|
242
|
+
: Effect.succeed(MISSING);
|
|
243
|
+
}
|
|
235
244
|
return Effect.succeed(row?.status === "auth_required" ? MISSING : { kind: "shared", source: "oauth", savedAt: null });
|
|
236
245
|
}
|
|
237
246
|
if (described?.auth?.mode === "none" && described.source.kind === "remote-mcp") {
|
|
@@ -258,6 +267,13 @@ function connectedPeople(context, connector, people) {
|
|
|
258
267
|
? yield* bounded(context, () => attempt(() => vault.metadata(connector.id, principalKey)).pipe(Effect.map((metadata) => storedCredentialShape(credential, metadata?.fields ?? null).state === "valid")), () => Effect.succeed(false))
|
|
259
268
|
: false;
|
|
260
269
|
}
|
|
270
|
+
else if (cachedMode(context)) {
|
|
271
|
+
const registry = opts.registry.scoped({ connectorIds: [connector.id], principalKey });
|
|
272
|
+
const observe = storedAuthObservation(connector);
|
|
273
|
+
ok = observe
|
|
274
|
+
? yield* bounded(context, () => attempt(() => observe(registry.contextFor(connector.id, baseUrl, {}))), () => Effect.succeed(false))
|
|
275
|
+
: false;
|
|
276
|
+
}
|
|
261
277
|
else {
|
|
262
278
|
const registry = opts.registry.scoped({ connectorIds: [connector.id], principalKey });
|
|
263
279
|
const scope = {};
|
|
@@ -354,11 +370,14 @@ function maintainerRead(context, authz) {
|
|
|
354
370
|
for (const person of [...opts.maintainers, ...[...opts.pools.values()].flatMap(humansOf)])
|
|
355
371
|
people.set(referenceKey(person), person);
|
|
356
372
|
const requestActivity = operatorActivityContext(context, authz);
|
|
373
|
+
const cached = cachedMode(context);
|
|
357
374
|
const [rows, label, slots] = yield* Effect.all([
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
375
|
+
cached
|
|
376
|
+
? Effect.succeed([])
|
|
377
|
+
: Effect.forEach(visible, (c) => observe(context, registry, c, requestActivity), {
|
|
378
|
+
concurrency: resolveDiscoveryConcurrency(opts.config.discovery.concurrency),
|
|
379
|
+
}),
|
|
380
|
+
bounded(context, () => humanLabels(context, authz, [...people.values()]), () => Effect.succeed(() => null)),
|
|
362
381
|
// Every declared agent's slot, exactly as GET /ui/access-tokens lists
|
|
363
382
|
// it. Without them the agents list cannot be whole, so a failed read
|
|
364
383
|
// fails the view rather than dropping agents from it.
|
|
@@ -399,7 +418,7 @@ function maintainerRead(context, authz) {
|
|
|
399
418
|
token: tokenSlots.get(name) ?? null,
|
|
400
419
|
lastSeenAt: null,
|
|
401
420
|
}));
|
|
402
|
-
const activity = yield*
|
|
421
|
+
const activity = yield* scanOperatorActivity(context, authz, registry, rows, agents);
|
|
403
422
|
let admissionsVisible = readsActivity(opts, authz) && activity !== "forbidden";
|
|
404
423
|
// A configured read gate still applies when the history store has no list.
|
|
405
424
|
if (admissionsVisible && activity === "unconfigured" && opts.config.activity?.readGate) {
|
|
@@ -572,8 +591,18 @@ function memberRead(context, authz, pools) {
|
|
|
572
591
|
function configRead(context) {
|
|
573
592
|
return Effect.gen(function* () {
|
|
574
593
|
const authz = yield* authorized(context);
|
|
575
|
-
if (admitsMaintainer(context.opts, authz))
|
|
576
|
-
|
|
594
|
+
if (admitsMaintainer(context.opts, authz)) {
|
|
595
|
+
if (!cachedMode(context))
|
|
596
|
+
return yield* maintainerRead(context, authz);
|
|
597
|
+
cachedMaintainers.add(context);
|
|
598
|
+
// The entire metadata build stays below the browser admission deadline,
|
|
599
|
+
// even for a store that ignores cancellation or an unusually large roster.
|
|
600
|
+
return yield* withDeadlineEffect(() => maintainerRead(context, authz), {
|
|
601
|
+
timeoutMs: 5000,
|
|
602
|
+
signal: context.request.signal,
|
|
603
|
+
timeoutError: new Error("Operator metadata timed out."),
|
|
604
|
+
}).pipe(Effect.catch(() => refuse("operator metadata could not be read", 503)));
|
|
605
|
+
}
|
|
577
606
|
const pools = memberPools(context, authz);
|
|
578
607
|
if (pools.length > 0)
|
|
579
608
|
return yield* memberRead(context, authz, pools);
|
package/dist/routes/ui-data.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { describedTools } from "../described.js";
|
|
1
2
|
// The Effect core of the operator payload: `buildUiData` in src/ui.ts is its
|
|
2
3
|
// Promise face, and the `/ui/connectors/<id>` route yields it directly.
|
|
3
4
|
//
|
|
@@ -12,7 +13,7 @@ import { credentialTestRule, describeUndeclaredCredentialFields, storedCredentia
|
|
|
12
13
|
import { failureRecord, failureStatus, logFailure, recordedToolName, statusFailure } from "../operator-record.js";
|
|
13
14
|
import { closeScope } from "../runtime/connector-scope.js";
|
|
14
15
|
import { withDeadlineEffect } from "../runtime/run.js";
|
|
15
|
-
import { uiProblemFor
|
|
16
|
+
import { uiProblemFor } from "../ui.js";
|
|
16
17
|
import { CONNECTA_VERSION } from "../version.js";
|
|
17
18
|
const DETAIL_TIMEOUT_MS = 30_000;
|
|
18
19
|
const DETAIL_TIMED_OUT = "Connection details timed out. Retry this connection.";
|
|
@@ -63,11 +64,13 @@ export function uiData(registry, baseUrl, options) {
|
|
|
63
64
|
return yield* attempt(async () => (await registry.getTools(c.id, baseUrl, requestScope, {
|
|
64
65
|
signal,
|
|
65
66
|
...(options.defer ? { defer: options.defer } : {}),
|
|
66
|
-
}))
|
|
67
|
+
}))
|
|
68
|
+
.flatMap((t) => describedTools([t]))
|
|
69
|
+
.map((t) => ({
|
|
70
|
+
...t,
|
|
67
71
|
name: recordedToolName(t),
|
|
68
72
|
address: `${c.id}.${recordedToolName(t)}`,
|
|
69
|
-
|
|
70
|
-
safety: uiToolSafety(t),
|
|
73
|
+
classification: t.classification === "read" ? "read" : "write",
|
|
71
74
|
}))).pipe(Effect.map((tools) => ({ status, tools, catalogFailed: false })),
|
|
72
75
|
// The registry owns the failed catalog observation; the page only
|
|
73
76
|
// needs to know there was one.
|
|
@@ -169,7 +172,21 @@ export function uiData(registry, baseUrl, options) {
|
|
|
169
172
|
status: status.state,
|
|
170
173
|
...(problem ? { problem } : {}),
|
|
171
174
|
toolCount: tools.length,
|
|
172
|
-
tools
|
|
175
|
+
tools: tools.map((tool) => ({
|
|
176
|
+
name: tool.name,
|
|
177
|
+
address: tool.address,
|
|
178
|
+
...(tool.description ? { description: tool.description } : {}),
|
|
179
|
+
safety: tool.classification === "read" ? "runs_in_programs" : "needs_approval",
|
|
180
|
+
})),
|
|
181
|
+
overlay: {
|
|
182
|
+
id: c.id,
|
|
183
|
+
status: status.state,
|
|
184
|
+
tools,
|
|
185
|
+
...(problem ? { problem } : {}),
|
|
186
|
+
...(status.registrationPath ? { auth: { registrationPath: status.registrationPath } } : {}),
|
|
187
|
+
catalogAgeMs: registry.catalogAgeMs(c.id),
|
|
188
|
+
lastCall: null,
|
|
189
|
+
},
|
|
173
190
|
// Counts only, and only when a refresh in this runtime produced them.
|
|
174
191
|
// `Registry.withCatalogObservations` rebuilt the report through
|
|
175
192
|
...(status.catalogAccess ? { catalogAccess: status.catalogAccess } : {}),
|
|
@@ -193,6 +210,14 @@ export function uiData(registry, baseUrl, options) {
|
|
|
193
210
|
oauth: Boolean(c.startAuth && c.disconnectAuth),
|
|
194
211
|
toolCount: 0,
|
|
195
212
|
tools: [],
|
|
213
|
+
overlay: {
|
|
214
|
+
id: c.id,
|
|
215
|
+
status: "error",
|
|
216
|
+
problem: "connector_unavailable",
|
|
217
|
+
tools: [],
|
|
218
|
+
catalogAgeMs: registry.catalogAgeMs(c.id),
|
|
219
|
+
lastCall: null,
|
|
220
|
+
},
|
|
196
221
|
};
|
|
197
222
|
});
|
|
198
223
|
const row = (c) => Effect.suspend(() => {
|
package/dist/routes/ui.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withDeadlineEffect } from "../runtime/run.js";
|
|
1
2
|
import { operatorAsset } from "../operator-ui/assets.js";
|
|
2
3
|
import { operatorActivityContext } from "./activity-context.js";
|
|
3
4
|
import { isSafeHttpsUrl } from "../branding.js";
|
|
@@ -10,7 +11,7 @@ import { CONNECTA_FAVICON_ICO } from "../favicon.js";
|
|
|
10
11
|
import { CONNECTA_FAVICON_SVG, operatorPageForPath, renderUiHtml } from "../ui.js";
|
|
11
12
|
import { authorized, refuse, scopeFor, serveOperator, visibleRegistry, } from "./operator.js";
|
|
12
13
|
import { managesAgentTokens, mayManageConnector, privateJson, readsActivity } from "./shared.js";
|
|
13
|
-
import { routeUiConfig } from "./ui-config.js";
|
|
14
|
+
import { routeUiConfig, scanOperatorActivity } from "./ui-config.js";
|
|
14
15
|
import { uiData } from "./ui-data.js";
|
|
15
16
|
/**
|
|
16
17
|
* Headers that make an operator-supplied favicon body inert on this origin.
|
|
@@ -160,7 +161,14 @@ function connectorDetail(context, id) {
|
|
|
160
161
|
timeoutMs: opts.config.discovery.probeTimeoutMs,
|
|
161
162
|
signal: request.signal,
|
|
162
163
|
});
|
|
163
|
-
|
|
164
|
+
const detail = data.connectors[0];
|
|
165
|
+
if (detail.overlay)
|
|
166
|
+
yield* withDeadlineEffect(() => scanOperatorActivity(context, authz, registry, [detail.overlay], []), {
|
|
167
|
+
timeoutMs: 500,
|
|
168
|
+
signal: request.signal,
|
|
169
|
+
timeoutError: new Error("Activity observation timed out."),
|
|
170
|
+
}).pipe(Effect.catch(() => Effect.succeed("unavailable")));
|
|
171
|
+
return privateJson({ ...detail, permissions: permissions(connector) });
|
|
164
172
|
});
|
|
165
173
|
}
|
|
166
174
|
/** The summary: every visible connector as "loading", with no probe at all. */
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// One outcome that a fiber in one request waits on while another request may
|
|
2
|
+
// deliver it: a queued admission permit, a shared token refresh.
|
|
3
|
+
//
|
|
4
|
+
// Effect resumes a waiting fiber synchronously, inside whatever completes the
|
|
5
|
+
// Deferred it awaits, and runs it in the async context it suspended in: the
|
|
6
|
+
// fiber captured a `node:async_hooks` AsyncResource when it suspended and
|
|
7
|
+
// resumes through `runInAsyncScope`. On Workers that context belongs to the
|
|
8
|
+
// waiter's request. workerd refuses to enter it from another request ("Cannot
|
|
9
|
+
// call this AsyncLocalStorage bound function outside of the request in which
|
|
10
|
+
// it was created.") when the Worker's compatibility date is 2025-06-16 or
|
|
11
|
+
// later, in releases since September 2026. The refusal throws out of the call
|
|
12
|
+
// that completed the Deferred, failing the request that released a slot, and
|
|
13
|
+
// the waiter never resumes (#1106). Awaiting a Promise around that wait is no
|
|
14
|
+
// help: the fiber waiting on the Deferred inside the Promise is resumed in the
|
|
15
|
+
// completing request just the same.
|
|
16
|
+
//
|
|
17
|
+
// So nothing here resumes a fiber. `settle` records the outcome and resolves,
|
|
18
|
+
// for each waiter, a Promise that the waiter's own request created. workerd
|
|
19
|
+
// runs a Promise's reactions in the request that created it, whichever
|
|
20
|
+
// request resolves it; Node runs them one microtask later in the waiter's
|
|
21
|
+
// async context. The waiter resumes there. The record itself is synchronous,
|
|
22
|
+
// so a queue's arbitration reads the outcome at once, and a waiter that leaves
|
|
23
|
+
// before it wakes can still give back what it was handed.
|
|
24
|
+
import { Effect } from "effect";
|
|
25
|
+
export class Settlement {
|
|
26
|
+
exit;
|
|
27
|
+
wakers = new Set();
|
|
28
|
+
/** The recorded outcome; undefined until `settle` runs. */
|
|
29
|
+
get outcome() {
|
|
30
|
+
return this.exit;
|
|
31
|
+
}
|
|
32
|
+
/** Record the first outcome and wake each waiter in its own request. */
|
|
33
|
+
settle(exit) {
|
|
34
|
+
if (this.exit)
|
|
35
|
+
return false;
|
|
36
|
+
this.exit = exit;
|
|
37
|
+
const wakers = [...this.wakers];
|
|
38
|
+
this.wakers.clear();
|
|
39
|
+
for (const wake of wakers)
|
|
40
|
+
wake();
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
/** The outcome, waited for by a fiber that resumes in its own request. */
|
|
44
|
+
wait() {
|
|
45
|
+
return Effect.suspend(() => this.exit ??
|
|
46
|
+
Effect.callback((resume) => {
|
|
47
|
+
let wake;
|
|
48
|
+
// Created here, by the waiting fiber, so it belongs to the waiting
|
|
49
|
+
// request whichever request resolves it.
|
|
50
|
+
void new Promise((resolve) => {
|
|
51
|
+
wake = resolve;
|
|
52
|
+
}).then(() => resume(this.exit));
|
|
53
|
+
this.wakers.add(wake);
|
|
54
|
+
return Effect.sync(() => {
|
|
55
|
+
this.wakers.delete(wake);
|
|
56
|
+
});
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CONNECTA_VERSION = "0.
|
|
1
|
+
export declare const CONNECTA_VERSION = "0.37.0";
|
package/dist/version.js
CHANGED
package/dist/worker.js
CHANGED
|
@@ -45,10 +45,13 @@ function routeGuestImports(source) {
|
|
|
45
45
|
}
|
|
46
46
|
/** Loader-only upstream sandbox whose lease disposes handles at run end. */
|
|
47
47
|
export function workerExecutor(options) {
|
|
48
|
+
// One pool per executor, so per isolate. A queued run may wait out one
|
|
49
|
+
// default guest deadline: programs spend it awaiting host calls, and a
|
|
50
|
+
// five-second wait refused nearly every run queued behind one (#1106).
|
|
48
51
|
const admission = new AdmissionController(options.admission ?? {
|
|
49
52
|
concurrency: 2,
|
|
50
53
|
maxQueueSize: 8,
|
|
51
|
-
queueTimeoutMs:
|
|
54
|
+
queueTimeoutMs: 60_000,
|
|
52
55
|
retryAfterMs: 1_000,
|
|
53
56
|
});
|
|
54
57
|
const active = new Set();
|
package/documentation/README.md
CHANGED
|
@@ -8,18 +8,19 @@ For a new deployment, start with [Deploying Connecta](./deploying.md).
|
|
|
8
8
|
|
|
9
9
|
## Operator tasks
|
|
10
10
|
|
|
11
|
-
| Task
|
|
12
|
-
|
|
|
13
|
-
| Add or remove a connector
|
|
14
|
-
| Set up a maintained provider
|
|
15
|
-
| Inspect maintained API sources and tools
|
|
16
|
-
| Organize an agent's pools and tiers
|
|
17
|
-
| Add a person (maintainer or pool member)
|
|
18
|
-
| Connect a
|
|
19
|
-
|
|
|
20
|
-
|
|
|
21
|
-
|
|
|
22
|
-
|
|
|
11
|
+
| Task | Self-contained procedure |
|
|
12
|
+
| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| Add or remove a connector | [Change the connector roster](./integrating.md#add-or-remove-a-connector) |
|
|
14
|
+
| Set up a maintained provider | [Maintained provider setup](./providers.md) |
|
|
15
|
+
| Inspect maintained API sources and tools | [API sources and tool inventory](./providers.md#api-sources-and-tool-inventory) |
|
|
16
|
+
| Organize an agent's pools and tiers | [Organizing an agent's pools](./auth.md#organizing-an-agents-pools) |
|
|
17
|
+
| Add a person (maintainer or pool member) | [Add a person](./auth.md#add-a-person), [member page](./operator-ui.md#use-the-member-page) |
|
|
18
|
+
| Connect Google hosted MCP with a service account | [Google tokens and shared-agent/personal-people setup](./auth.md#google-service-account-tokens-and-hosted-mcp) |
|
|
19
|
+
| Connect a shared credential | [Connect a shared credential](./auth.md#connect-a-shared-credential) |
|
|
20
|
+
| Issue, rotate, or revoke an agent token | [Manage an agent token](./auth.md#issue-rotate-or-revoke-an-agent-token) |
|
|
21
|
+
| Upgrade between versions | [Upgrade an existing deployment](./deploying.md#upgrade-an-existing-deployment), including [migration to 0.33](./migration-0.33.md) |
|
|
22
|
+
| Diagnose with `connecta doctor` | [Verify and diagnose](./deploying.md#verify-and-diagnose) |
|
|
23
|
+
| Connect a client with tool search | [Native tool surface](./meta-tools.md#native-tool-surface), [Eve agents](./meta-tools.md#eve-agents) |
|
|
23
24
|
|
|
24
25
|
For removed hosted presets and the former hosted modes of Stripe, Vercel,
|
|
25
26
|
Cloudflare, and GitHub, use [Migrate hosted MCP presets](./hosted-mcp-migration.md).
|