@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/registry.js
CHANGED
|
@@ -277,7 +277,7 @@ export class Registry {
|
|
|
277
277
|
throw new Error(`Connector "${c.id}" declares both staticTools and a classification; ` + "annotate static tools directly.");
|
|
278
278
|
}
|
|
279
279
|
this.connectors.set(c.id, c);
|
|
280
|
-
if (c.callAdmission) {
|
|
280
|
+
if (c.callAdmission !== undefined) {
|
|
281
281
|
this.callAdmission.set(c.id, new ConnectorCallAdmissionController(c.id, c.callAdmission));
|
|
282
282
|
}
|
|
283
283
|
}
|
|
@@ -343,7 +343,7 @@ export class Registry {
|
|
|
343
343
|
const requested = scope.connectorIds === "all" ? new Set(this.connectors.keys()) : new Set(scope.connectorIds);
|
|
344
344
|
for (const id of requested) {
|
|
345
345
|
if (!this.connectors.has(id)) {
|
|
346
|
-
throw new Error(`
|
|
346
|
+
throw new Error(`Registry scope names unknown connector "${id}"`);
|
|
347
347
|
}
|
|
348
348
|
}
|
|
349
349
|
return new ScopedRegistryView(this, requested, scope);
|
|
@@ -368,13 +368,13 @@ export class Registry {
|
|
|
368
368
|
this.warnedAbsentGrants.delete(oldest);
|
|
369
369
|
}
|
|
370
370
|
if (hasControlCharacters(toolName)) {
|
|
371
|
-
logFailure(this.opts.logger, "
|
|
371
|
+
logFailure(this.opts.logger, "pool tool grant is unreachable", failureRecord({ connector: connectorId }));
|
|
372
372
|
return;
|
|
373
373
|
}
|
|
374
374
|
// Grant names are operator data but may carry any non-control character;
|
|
375
375
|
// quote them so a line terminator a log reader honours cannot forge a line.
|
|
376
376
|
const quoted = JSON.stringify(key).replace(/[\u2028\u2029]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16)}`);
|
|
377
|
-
this.opts.logger.warn(`
|
|
377
|
+
this.opts.logger.warn(`a pool grants ${quoted} but connector "${connectorId}" lists no such tool; the grant is unreachable`);
|
|
378
378
|
}
|
|
379
379
|
async storeOAuthHandoff(connectorId, state, principalKey) {
|
|
380
380
|
const key = oauthHandoffKeys.handoff(connectorId, await sha256Hex(state));
|
|
@@ -1005,13 +1005,13 @@ class ScopedRegistryView {
|
|
|
1005
1005
|
return this.root.listConnectors().filter((connector) => this.registryFor(connector.id) !== undefined);
|
|
1006
1006
|
}
|
|
1007
1007
|
canReadConnectorSkills(id) {
|
|
1008
|
-
return
|
|
1008
|
+
return this.registryFor(id) !== undefined && !this.scope.toolAccess?.has(id);
|
|
1009
1009
|
}
|
|
1010
1010
|
getConnector(id) {
|
|
1011
1011
|
return this.registryFor(id)?.getConnector(id);
|
|
1012
1012
|
}
|
|
1013
1013
|
getResourceConnector(id) {
|
|
1014
|
-
if (this.scope.toolAccess?.has(id)
|
|
1014
|
+
if (this.scope.toolAccess?.has(id))
|
|
1015
1015
|
return undefined;
|
|
1016
1016
|
return this.getConnector(id);
|
|
1017
1017
|
}
|
|
@@ -1028,14 +1028,13 @@ class ScopedRegistryView {
|
|
|
1028
1028
|
throw new Error(`Unknown connector "${args[0]}"`);
|
|
1029
1029
|
}
|
|
1030
1030
|
const tools = await registry.getTools(args[0], args[1], args[2], args[3], this.scope);
|
|
1031
|
-
const
|
|
1032
|
-
const granted = this.scope.toolAccess?.get(args[0]) ?? guarded;
|
|
1031
|
+
const granted = this.scope.toolAccess?.get(args[0]);
|
|
1033
1032
|
if (!granted)
|
|
1034
1033
|
return tools;
|
|
1035
1034
|
// Every consumer — search, describe, call_tool, and a program's
|
|
1036
1035
|
// connecta.call — resolves through this list, so an ungranted tool is
|
|
1037
1036
|
// indistinguishable from one the connector never had.
|
|
1038
|
-
const visible = tools.filter((tool) => granted.has(tool.name)
|
|
1037
|
+
const visible = tools.filter((tool) => granted.has(tool.name));
|
|
1039
1038
|
if (visible.length < granted.size) {
|
|
1040
1039
|
const present = new Set(tools.map((tool) => tool.name));
|
|
1041
1040
|
for (const name of granted) {
|
|
@@ -1,62 +1,59 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
import { AccessTokenError, isSlotRevision } from "../access-tokens.js";
|
|
2
|
+
import { authorizeUiIdentity, boundedText, isSameOrigin, managesAgentTokens, privateJson, } from "./shared.js";
|
|
3
|
+
/** The longest body, in characters, a token mutation accepts: `{ "expect": "<revision>" }` with room to spare. */
|
|
4
|
+
const MAX_BODY_CHARS = 1_024;
|
|
5
|
+
/**
|
|
6
|
+
* The slot revision a mutation names. POST takes it only as a JSON body,
|
|
7
|
+
* `{ "expect": "<revision>" }`; DELETE takes that body or, for clients that
|
|
8
|
+
* cannot send a body with DELETE, `?expect=<revision>`, but not both. Read
|
|
9
|
+
* only after the caller is authorized.
|
|
10
|
+
*/
|
|
11
|
+
async function expectedRevision(request) {
|
|
12
|
+
const query = new URL(request.url).searchParams.getAll("expect");
|
|
13
|
+
if (query.length > 0 && request.method !== "DELETE") {
|
|
14
|
+
return { status: 400, error: "expect belongs in the JSON body" };
|
|
8
15
|
}
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
16
|
+
const raw = await boundedText(request, MAX_BODY_CHARS);
|
|
17
|
+
if (raw === undefined)
|
|
18
|
+
return { status: 413, error: "request body is too large" };
|
|
19
|
+
let candidate;
|
|
20
|
+
if (raw !== "") {
|
|
21
|
+
if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) {
|
|
22
|
+
return { status: 415, error: "Content-Type must be application/json" };
|
|
23
|
+
}
|
|
24
|
+
let body;
|
|
13
25
|
try {
|
|
14
|
-
|
|
15
|
-
const { done, value } = await reader.read();
|
|
16
|
-
if (done)
|
|
17
|
-
break;
|
|
18
|
-
size += value.byteLength;
|
|
19
|
-
if (size > 1_000) {
|
|
20
|
-
reader.cancel().catch(() => { });
|
|
21
|
-
return { ok: false, response: privateJson({ error: "request body is too large" }, { status: 413 }) };
|
|
22
|
-
}
|
|
23
|
-
chunks.push(value);
|
|
24
|
-
}
|
|
26
|
+
body = JSON.parse(raw);
|
|
25
27
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
catch {
|
|
29
|
+
return { status: 400, error: "invalid JSON body" };
|
|
28
30
|
}
|
|
31
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
32
|
+
return { status: 400, error: "invalid JSON body" };
|
|
33
|
+
}
|
|
34
|
+
if (query.length > 0)
|
|
35
|
+
return { status: 400, error: "give expect once, in the body or the query" };
|
|
36
|
+
candidate = body.expect;
|
|
29
37
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
for (const chunk of chunks) {
|
|
33
|
-
bytes.set(chunk, offset);
|
|
34
|
-
offset += chunk.byteLength;
|
|
35
|
-
}
|
|
36
|
-
const raw = new TextDecoder().decode(bytes);
|
|
37
|
-
if (raw.length > 1_000) {
|
|
38
|
-
return {
|
|
39
|
-
ok: false,
|
|
40
|
-
response: privateJson({ error: "request body is too large" }, { status: 413 }),
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
try {
|
|
44
|
-
const body = JSON.parse(raw);
|
|
45
|
-
return { ok: true, name: body.name };
|
|
38
|
+
else if (query.length === 1) {
|
|
39
|
+
candidate = query[0];
|
|
46
40
|
}
|
|
47
|
-
|
|
48
|
-
return {
|
|
49
|
-
ok: false,
|
|
50
|
-
response: privateJson({ error: "invalid JSON body" }, { status: 400 }),
|
|
51
|
-
};
|
|
41
|
+
if (candidate === undefined) {
|
|
42
|
+
return { status: 400, error: "expect is required: the slot revision from GET /ui/access-tokens" };
|
|
52
43
|
}
|
|
44
|
+
return isSlotRevision(candidate) ? { expect: candidate } : { status: 400, error: "expect is not a slot revision" };
|
|
53
45
|
}
|
|
54
46
|
/**
|
|
55
|
-
* Interactive-operator lifecycle for
|
|
47
|
+
* Interactive-operator lifecycle for agent tokens: list one slot per declared
|
|
48
|
+
* agent, issue or rotate its token, and revoke it. Each mutation names the
|
|
49
|
+
* slot revision it decided from, so a stale view refuses with 409
|
|
50
|
+
* `slot_changed` instead of issuing over a token or rotating over someone
|
|
51
|
+
* else's fresh rotation. Agents themselves come only
|
|
52
|
+
* from code, so nothing here creates, renames, or rebinds one. The token itself
|
|
56
53
|
* is deliberately never an administrator credential and cannot reach here.
|
|
57
54
|
*/
|
|
58
55
|
export async function routeAccessTokens(context, manager) {
|
|
59
|
-
const match = /^\/ui\/access-tokens(?:\/([
|
|
56
|
+
const match = /^\/ui\/access-tokens(?:\/([a-z0-9_-]{1,64}))?$/.exec(context.path);
|
|
60
57
|
if (!match)
|
|
61
58
|
return null;
|
|
62
59
|
const { request, baseUrl, opts } = context;
|
|
@@ -67,41 +64,51 @@ export async function routeAccessTokens(context, manager) {
|
|
|
67
64
|
if (mutating && !isSameOrigin(request, baseUrl)) {
|
|
68
65
|
return privateJson({ error: "same-origin request required" }, { status: 403 });
|
|
69
66
|
}
|
|
70
|
-
const admin = await authorizeUiIdentity(request, baseUrl, opts.config.auth, "access token management", context.runtimeContext
|
|
67
|
+
const admin = await authorizeUiIdentity(request, baseUrl, opts.config.auth, "access token management", context.runtimeContext);
|
|
71
68
|
if (!admin.ok)
|
|
72
69
|
return admin.response;
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
// Interactive maintainers alone manage agent tokens (#837); anyone else
|
|
71
|
+
// gets the same private 404 every operator route gives them.
|
|
72
|
+
if (!managesAgentTokens(opts, admin) || !admin.identity.principal) {
|
|
73
|
+
return privateJson({ error: "not found" }, { status: 404 });
|
|
75
74
|
}
|
|
76
|
-
const
|
|
75
|
+
const operator = `${admin.identity.principal.namespace}:${admin.identity.principal.id}`;
|
|
76
|
+
const name = match[1];
|
|
77
77
|
try {
|
|
78
|
-
if (!
|
|
79
|
-
return privateJson({
|
|
78
|
+
if (!name && request.method === "GET") {
|
|
79
|
+
return privateJson({ agents: await manager.list() });
|
|
80
80
|
}
|
|
81
|
-
if (!
|
|
82
|
-
|
|
83
|
-
if (!input.ok)
|
|
84
|
-
return input.response;
|
|
85
|
-
return privateJson(await manager.create(input.name, admin.identity.principal), { status: 201 });
|
|
81
|
+
if (!name || (request.method !== "POST" && request.method !== "DELETE")) {
|
|
82
|
+
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
86
83
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
84
|
+
const expected = await expectedRevision(request);
|
|
85
|
+
if (!("expect" in expected))
|
|
86
|
+
return privateJson({ error: expected.error }, { status: expected.status });
|
|
87
|
+
// The request's signal bounds each mutation: a caller that leaves before
|
|
88
|
+
// the slot commit publishes nothing, and one that leaves after a rotation
|
|
89
|
+
// commits has the slot revoked, since its secret cannot be delivered.
|
|
90
|
+
const options = { signal: request.signal, expect: expected.expect };
|
|
91
|
+
if (request.method === "POST")
|
|
92
|
+
return privateJson(await manager.rotate(name, operator, options));
|
|
93
|
+
return privateJson({ agent: await manager.revoke(name, options) });
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (error instanceof AccessTokenError && error.code === "unknown_agent") {
|
|
97
|
+
return privateJson({ error: "unknown agent" }, { status: 404 });
|
|
95
98
|
}
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
return accessToken
|
|
99
|
-
? privateJson({ accessToken })
|
|
100
|
-
: privateJson({ error: "unknown access token" }, { status: 404 });
|
|
99
|
+
if (error instanceof AccessTokenError && error.code === "aborted") {
|
|
100
|
+
return privateJson({ error: "request ended" }, { status: 499 });
|
|
101
101
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
102
|
+
if (error instanceof AccessTokenError && error.code === "outcome_unknown") {
|
|
103
|
+
// A fixed, operation-specific sentence; it never carries storage text.
|
|
104
|
+
return privateJson({ error: error.message }, { status: 503 });
|
|
105
|
+
}
|
|
106
|
+
if (error instanceof AccessTokenError && error.code === "slot_changed") {
|
|
107
|
+
return privateJson({ error: "The agent's token slot changed; reload and retry", code: "slot_changed" }, { status: 409 });
|
|
108
|
+
}
|
|
109
|
+
if (error instanceof AccessTokenError && error.code === "conflict") {
|
|
110
|
+
return privateJson({ error: "The agent's token changed meanwhile; reload and retry" }, { status: 409 });
|
|
111
|
+
}
|
|
112
|
+
return privateJson({ error: "Access token operation failed; check storage" }, { status: 400 });
|
|
106
113
|
}
|
|
107
114
|
}
|
|
@@ -1,23 +1,22 @@
|
|
|
1
|
-
import { intersectAccess } from "../connector-access.js";
|
|
2
1
|
import { recordedToolName } from "../operator-record.js";
|
|
3
|
-
/**
|
|
4
|
-
function activityToolVisible(
|
|
5
|
-
const allowed =
|
|
6
|
-
return
|
|
7
|
-
(!allowed || allowed.has(event.toolName)) &&
|
|
8
|
-
(!authz.guardedToolAccess?.get(event.connectorId)?.has(event.toolName) || event.classification === "read"));
|
|
2
|
+
/** A recorded tool is shown only when its name passes the record grammar and its endpoint granted it. */
|
|
3
|
+
function activityToolVisible(access, event) {
|
|
4
|
+
const allowed = access.toolAccess?.get(event.connectorId);
|
|
5
|
+
return recordedToolName({ name: event.toolName }) === event.toolName && (!allowed || allowed.has(event.toolName));
|
|
9
6
|
}
|
|
10
7
|
/**
|
|
11
|
-
* One disclosure rule for the timeline and the UI's last-call overlay.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* and
|
|
16
|
-
*
|
|
8
|
+
* One disclosure rule for the timeline and the UI's last-call overlay. Only
|
|
9
|
+
* maintainers read activity (#837), and only after the optional
|
|
10
|
+
* read gate. They may see other principals' shared-connector history,
|
|
11
|
+
* including request IDs, client name/version, and actor kind/id/namespace, on
|
|
12
|
+
* plain /mcp and in every declared pool, within the tools that pool declares.
|
|
13
|
+
* Interactive reads may add directory actor labels; machine reads do not.
|
|
14
|
+
* Personal history requires a matching principal owner. A row recorded in a
|
|
15
|
+
* pool that is no longer declared is withheld.
|
|
17
16
|
*/
|
|
18
|
-
export function activityEventVisible(context, authz, registry,
|
|
17
|
+
export function activityEventVisible(context, authz, registry, event) {
|
|
19
18
|
const connector = registry.getConnector(event.connectorId);
|
|
20
|
-
if (!connector
|
|
19
|
+
if (!connector)
|
|
21
20
|
return false;
|
|
22
21
|
if (connector.authScope === "personal" || (event.kind === "catalog_drift" && event.actorBasis === "principal")) {
|
|
23
22
|
const owner = authz.identity.principal;
|
|
@@ -27,10 +26,10 @@ export function activityEventVisible(context, authz, registry, admittedPools, ev
|
|
|
27
26
|
event.actor.namespace !== owner.namespace)
|
|
28
27
|
return false;
|
|
29
28
|
}
|
|
30
|
-
const pool = event.pool ? context.opts.pools.get(event.pool) : undefined;
|
|
31
|
-
if (event.pool && !pool)
|
|
29
|
+
const pool = event.pool !== undefined ? context.opts.pools.get(event.pool) : undefined;
|
|
30
|
+
if (event.pool !== undefined && !pool)
|
|
32
31
|
return false;
|
|
33
|
-
const access = pool ?
|
|
32
|
+
const access = pool ? pool.access : { connectorIds: "all" };
|
|
34
33
|
if (access.connectorIds !== "all" && !access.connectorIds.includes(event.connectorId))
|
|
35
34
|
return false;
|
|
36
35
|
// Connector-wide counts reveal tools outside an address-only grant.
|
package/dist/routes/activity.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { activityEventVisible } from "./activity-disclosure.js";
|
|
2
|
-
import {
|
|
2
|
+
import { Effect } from "effect";
|
|
3
3
|
import { activityBehaviorFacts, activityPackageVersion, activityClientFact, InvalidActivityCursorError, } from "../activity.js";
|
|
4
4
|
import { failureRecord, logFailure } from "../operator-record.js";
|
|
5
|
-
import {
|
|
5
|
+
import { actorKey, resolveActorLabels } from "./actor-labels.js";
|
|
6
6
|
import { authorized, refuse, serveOperator, visibleRegistry } from "./operator.js";
|
|
7
|
-
import { privateJson } from "./shared.js";
|
|
7
|
+
import { privateJson, readsActivity } from "./shared.js";
|
|
8
8
|
/**
|
|
9
9
|
* Add display-only actor labels to one authorized activity page. Resolution is
|
|
10
10
|
* best-effort, bounded, and read-time only: stored events retain stable ids and
|
|
@@ -15,50 +15,42 @@ import { privateJson } from "./shared.js";
|
|
|
15
15
|
* abandoned, as they are when the reader leaves.
|
|
16
16
|
*/
|
|
17
17
|
function enrichActivityActorLabels(page, auth) {
|
|
18
|
-
return Effect.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
...record,
|
|
45
|
-
...activityBehaviorFacts(event),
|
|
46
|
-
...(packageVersion !== undefined ? { packageVersion } : {}),
|
|
47
|
-
...(clientName !== undefined ? { clientName } : {}),
|
|
48
|
-
...(clientVersion !== undefined ? { clientVersion } : {}),
|
|
49
|
-
actor: resolved ? { ...actor, label: resolved } : actor,
|
|
50
|
-
};
|
|
51
|
-
}),
|
|
52
|
-
})));
|
|
53
|
-
});
|
|
18
|
+
return resolveActorLabels(page.events.map((event) => event.actor), auth).pipe(Effect.map((labels) => ({
|
|
19
|
+
...page,
|
|
20
|
+
events: page.events.map((event) => {
|
|
21
|
+
const resolved = event.actor.id ? labels.get(actorKey(event.actor)) : undefined;
|
|
22
|
+
// Never trust or echo a `label` supplied by storage. The persisted event
|
|
23
|
+
// schema has no label; only this authenticated read path may add one.
|
|
24
|
+
const actor = {
|
|
25
|
+
kind: event.actor.kind,
|
|
26
|
+
...(event.actor.id ? { id: event.actor.id } : {}),
|
|
27
|
+
...(event.actor.namespace ? { namespace: event.actor.namespace } : {}),
|
|
28
|
+
};
|
|
29
|
+
// Re-check stored client facts, including custom readers and old rows.
|
|
30
|
+
const { packageVersion: suppliedPackageVersion, clientName: suppliedName, clientVersion: suppliedVersion, classification: _classification, resultBytes: _resultBytes, kind: _kind, drift: _drift, pool: _pool, actorBasis: _actorBasis, ...record } = event;
|
|
31
|
+
const packageVersion = activityPackageVersion(suppliedPackageVersion);
|
|
32
|
+
const clientName = activityClientFact(suppliedName, "name");
|
|
33
|
+
const clientVersion = activityClientFact(suppliedVersion, "version");
|
|
34
|
+
return {
|
|
35
|
+
...record,
|
|
36
|
+
...activityBehaviorFacts(event),
|
|
37
|
+
...(packageVersion !== undefined ? { packageVersion } : {}),
|
|
38
|
+
...(clientName !== undefined ? { clientName } : {}),
|
|
39
|
+
...(clientVersion !== undefined ? { clientVersion } : {}),
|
|
40
|
+
actor: resolved ? { ...actor, label: resolved } : actor,
|
|
41
|
+
};
|
|
42
|
+
}),
|
|
43
|
+
})));
|
|
54
44
|
}
|
|
55
45
|
function activityRead(context) {
|
|
56
46
|
const { url, opts } = context;
|
|
57
47
|
return Effect.gen(function* () {
|
|
58
48
|
const authz = yield* authorized(context);
|
|
49
|
+
// A maintainer principal, interactive or not (#837); members and agents
|
|
50
|
+
// get the private 404 every operator route gives them.
|
|
51
|
+
if (!readsActivity(context.opts, authz))
|
|
52
|
+
return yield* refuse("not found", 404);
|
|
59
53
|
const registry = yield* visibleRegistry(context, authz);
|
|
60
|
-
if (!authz.operator)
|
|
61
|
-
return yield* refuse("activity access required", 403);
|
|
62
54
|
if (opts.config.activity?.readGate &&
|
|
63
55
|
!(yield* Effect.promise(async () => opts.config.activity?.readGate(authz.actor)))) {
|
|
64
56
|
return yield* refuse("forbidden", 403);
|
|
@@ -66,19 +58,7 @@ function activityRead(context) {
|
|
|
66
58
|
const list = opts.config.activity?.store.list?.bind(opts.config.activity?.store);
|
|
67
59
|
if (!list)
|
|
68
60
|
return yield* refuse("activity history is not configured", 404);
|
|
69
|
-
const
|
|
70
|
-
for (const [name, pool] of opts.pools) {
|
|
71
|
-
if (yield* Effect.promise(async () => {
|
|
72
|
-
try {
|
|
73
|
-
return (await pool.grant(authz.identity)) === true;
|
|
74
|
-
}
|
|
75
|
-
catch {
|
|
76
|
-
return false;
|
|
77
|
-
}
|
|
78
|
-
}))
|
|
79
|
-
admittedPools.add(name);
|
|
80
|
-
}
|
|
81
|
-
const visible = (event) => activityEventVisible(context, authz, registry, admittedPools, event);
|
|
61
|
+
const visible = (event) => activityEventVisible(context, authz, registry, event);
|
|
82
62
|
const cursor = url.searchParams.get("cursor") ?? undefined;
|
|
83
63
|
if (cursor && cursor.length > 500)
|
|
84
64
|
return yield* refuse("invalid cursor", 400);
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
// read time from the one auth provider that owns the actor's directory, and
|
|
3
3
|
// only on an authorized read path. Nothing here is persisted, and a label a
|
|
4
4
|
// store happens to carry is never trusted or echoed.
|
|
5
|
+
import { Duration, Effect } from "effect";
|
|
5
6
|
import { activityActorNamespace } from "./shared.js";
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
const ACTOR_LABEL_CONCURRENCY = 8;
|
|
8
|
+
const ACTOR_LABEL_BUDGET_MS = 1_500;
|
|
8
9
|
const ACTOR_LABEL_MAX_LENGTH = 160;
|
|
9
|
-
|
|
10
|
+
function cleanActorLabel(value) {
|
|
10
11
|
if (typeof value !== "string")
|
|
11
12
|
return undefined;
|
|
12
13
|
const compact = value.replace(/\s+/gu, " ").trim();
|
|
@@ -22,7 +23,7 @@ export function actorKey(actor) {
|
|
|
22
23
|
* the actor's directory. An actor no provider can claim unambiguously gets no
|
|
23
24
|
* lookup at all.
|
|
24
25
|
*/
|
|
25
|
-
|
|
26
|
+
function labelLookups(actors, auth) {
|
|
26
27
|
const identities = new Map();
|
|
27
28
|
for (const actor of actors) {
|
|
28
29
|
if (!actor.id)
|
|
@@ -65,3 +66,31 @@ export function labelLookups(actors, auth) {
|
|
|
65
66
|
}
|
|
66
67
|
return lookups;
|
|
67
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Resolve labels for these actors, keyed by `actorKey`. Best-effort and
|
|
71
|
+
* bounded: at most eight lookups run at once, and whatever has resolved
|
|
72
|
+
* within 1.5 seconds in all is used; the rest are abandoned, as they are when
|
|
73
|
+
* the reader leaves. A lookup that fails or answers nothing usable is absent.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveActorLabels(actors, auth) {
|
|
76
|
+
return Effect.suspend(() => {
|
|
77
|
+
const labels = new Map();
|
|
78
|
+
const resolve = ({ key, id, provider }) => Effect.tryPromise(() => Promise.resolve(provider.activityActorLabel(id))).pipe(Effect.map(cleanActorLabel), Effect.orElseSucceed(() => undefined), Effect.map((label) => {
|
|
79
|
+
if (label)
|
|
80
|
+
labels.set(key, label);
|
|
81
|
+
}));
|
|
82
|
+
return Effect.forEach(labelLookups(actors, auth), resolve, {
|
|
83
|
+
concurrency: ACTOR_LABEL_CONCURRENCY,
|
|
84
|
+
discard: true,
|
|
85
|
+
}).pipe(Effect.timeoutOption(Duration.millis(ACTOR_LABEL_BUDGET_MS)), Effect.map(() => labels));
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The activity actor a listed person would be recorded as: the principal in
|
|
90
|
+
* the namespace of the interactive provider that owns that directory. People
|
|
91
|
+
* no interactive provider owns have no actor, and so no label.
|
|
92
|
+
*/
|
|
93
|
+
export function principalActor(principal, auth) {
|
|
94
|
+
const provider = auth.find((candidate) => candidate.interactiveOperator && activityActorNamespace(candidate) === principal.namespace);
|
|
95
|
+
return provider ? { kind: provider.kind, id: principal.id, namespace: principal.namespace } : undefined;
|
|
96
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Agent endpoint paths (#837, decisions/0006). Cloudflare Access bypasses
|
|
2
|
+
// `/agent/*` at the edge after resolving dot segments, folding case, decoding
|
|
3
|
+
// unreserved escapes, and collapsing a leading `//`, while the Worker sees
|
|
4
|
+
// the raw pathname. So connecta claims every path Access might treat as
|
|
5
|
+
// `/agent/*`, and admits only the one exact, raw spelling of a pool.
|
|
6
|
+
/** Pool names, as `createConnecta` validates them. */
|
|
7
|
+
const POOL_NAME_RE = /^[a-z0-9_-]+$/;
|
|
8
|
+
/** Escapes decoded before giving up and treating the path as an agent path. */
|
|
9
|
+
const MAX_DECODE_ROUNDS = 8;
|
|
10
|
+
/**
|
|
11
|
+
* The pool an agent endpoint names, or undefined. The raw pathname must be
|
|
12
|
+
* byte-equal to `/agent/` plus a well-formed pool name: no decoding, case
|
|
13
|
+
* folding, slash collapsing, trailing slash, extra segment, or `%`.
|
|
14
|
+
*/
|
|
15
|
+
export function agentEndpointPool(path) {
|
|
16
|
+
if (!path.startsWith("/agent/"))
|
|
17
|
+
return undefined;
|
|
18
|
+
const pool = path.slice("/agent/".length);
|
|
19
|
+
return POOL_NAME_RE.test(pool) ? pool : undefined;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Whether a pathname belongs to the agent namespace: anything that starts
|
|
23
|
+
* with `/agent` once every escape is decoded, case is folded, and slashes are
|
|
24
|
+
* collapsed. This is deliberately wider than Access's own matching, so no
|
|
25
|
+
* path the edge bypasses can fall through to a human route.
|
|
26
|
+
*/
|
|
27
|
+
export function isAgentPath(path) {
|
|
28
|
+
let value = path;
|
|
29
|
+
for (let round = 0; value.includes("%"); round++) {
|
|
30
|
+
// Still encoded after the bound: refuse it with the agent 404.
|
|
31
|
+
if (round === MAX_DECODE_ROUNDS)
|
|
32
|
+
return true;
|
|
33
|
+
const decoded = value.replace(/%([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
34
|
+
if (decoded === value)
|
|
35
|
+
break;
|
|
36
|
+
value = decoded;
|
|
37
|
+
}
|
|
38
|
+
return value
|
|
39
|
+
.replace(/\\/g, "/")
|
|
40
|
+
.replace(/\/{2,}/g, "/")
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.startsWith("/agent");
|
|
43
|
+
}
|
package/dist/routes/connect.js
CHANGED
|
@@ -8,7 +8,7 @@ import { drainOAuthStartResets } from "../auth/oauth-start-reset.js";
|
|
|
8
8
|
import { finishOAuthConnectStart, consumeOAuthConnectLink, oauthConnectUnavailable, verifyOAuthHandoff, } from "../oauth-handoff.js";
|
|
9
9
|
import { failureRecord, logFailure } from "../operator-record.js";
|
|
10
10
|
import { runEdge, withDeadlineEffect } from "../runtime/run.js";
|
|
11
|
-
import { authorizeUiIdentity, mayManageConnector, privateJson,
|
|
11
|
+
import { admitsMaintainer, authorizeUiIdentity, mayManageConnector, privateJson, withSessionCookies, } from "./shared.js";
|
|
12
12
|
const START_TIMEOUT_MS = 30_000;
|
|
13
13
|
/** Clerk establishes a browser session on this origin before retrying the link. */
|
|
14
14
|
function clerkSignIn(context) {
|
|
@@ -84,33 +84,37 @@ async function connect(context) {
|
|
|
84
84
|
headers: { Location: target.href, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" },
|
|
85
85
|
});
|
|
86
86
|
};
|
|
87
|
-
const authz = await authorizeUiIdentity(request, baseUrl, opts.config.auth, "OAuth connection", runtimeContext
|
|
87
|
+
const authz = await authorizeUiIdentity(request, baseUrl, opts.config.auth, "OAuth connection", runtimeContext);
|
|
88
88
|
if (!authz.ok) {
|
|
89
89
|
// Access sign-in is enforced at the edge. Clerk needs its own sign-in page.
|
|
90
|
+
// The sign-in page returns to this link, which then sends a maintainer on
|
|
91
|
+
// to the operator UI. Pool members cannot use that UI yet (#837), so
|
|
92
|
+
// signing in there would strand them.
|
|
90
93
|
if (authz.response.status === 401 &&
|
|
91
94
|
authorizationCredential(request).kind === "absent" &&
|
|
92
95
|
!runtimeContext?.access) {
|
|
93
|
-
if (opts.config.ui)
|
|
94
|
-
return authPage();
|
|
95
96
|
const signIn = clerkSignIn(context);
|
|
96
97
|
if (signIn)
|
|
97
98
|
return signIn;
|
|
99
|
+
if (opts.config.ui)
|
|
100
|
+
return authPage();
|
|
98
101
|
}
|
|
99
102
|
return authz.response;
|
|
100
103
|
}
|
|
101
|
-
try {
|
|
102
|
-
validateAuthPermissions(authz, opts.registry);
|
|
103
|
-
}
|
|
104
|
-
catch {
|
|
105
|
-
return refuse("forbidden");
|
|
106
|
-
}
|
|
107
104
|
const connector = opts.registry.getConnector(id);
|
|
108
|
-
|
|
105
|
+
// Visibility is the caller's pools, or everything for a maintainer, so a
|
|
106
|
+
// member may connect a personal connector in a pool they belong to.
|
|
107
|
+
if (authz.principalKey !== handoff.principal || !mayManageConnector(opts, authz, connector)) {
|
|
109
108
|
return refuse("Sign in as the user who requested this connection and has permission to manage it.");
|
|
110
109
|
}
|
|
110
|
+
// The operator UI serves maintainers only until member views ship (#837).
|
|
111
|
+
// A member's OAuth link starts here directly, as it does without a UI.
|
|
112
|
+
const viaUi = Boolean(opts.config.ui) && admitsMaintainer(opts, authz);
|
|
111
113
|
if (!connector.startAuth) {
|
|
112
114
|
if (!connector.credential || !opts.config.ui || !opts.config.vault)
|
|
113
115
|
return refuse("unknown OAuth connector", 404);
|
|
116
|
+
if (!viaUi)
|
|
117
|
+
return refuse("Credential entry is in the operator UI, which only maintainers can use for now.");
|
|
114
118
|
let target;
|
|
115
119
|
try {
|
|
116
120
|
target = new URL(opts.config.ui.credentialHandoffUrl(baseUrl), baseUrl);
|
|
@@ -133,7 +137,7 @@ async function connect(context) {
|
|
|
133
137
|
},
|
|
134
138
|
}), authz.sessionCookies);
|
|
135
139
|
}
|
|
136
|
-
if (
|
|
140
|
+
if (viaUi && context.url.searchParams.get("start") !== "1")
|
|
137
141
|
return withSessionCookies(authPage(), authz.sessionCookies);
|
|
138
142
|
const registry = opts.registry.scoped({
|
|
139
143
|
connectorIds: [id],
|