@zackbart/connecta 0.39.0 → 0.41.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 +115 -0
- package/bin/connecta.mjs +4 -8
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.js +6 -4
- package/dist/activity.d.ts +30 -4
- package/dist/activity.js +28 -2
- package/dist/agent-observation.js +24 -2
- package/dist/agent-routes.js +4 -41
- package/dist/auth/clerk.d.ts +6 -1
- package/dist/auth/clerk.js +16 -0
- package/dist/auth/cloudflare-access.js +49 -7
- package/dist/auth/downstream-oauth/consent.js +79 -29
- package/dist/auth/downstream-oauth/grant-store.js +58 -7
- package/dist/auth/downstream-oauth/refresh-records.js +6 -5
- package/dist/auth/downstream-oauth.js +12 -4
- package/dist/auth/native.js +10 -0
- package/dist/auth/oauth-request-not-sent.js +4 -0
- package/dist/auth/static-oauth.js +51 -21
- package/dist/catalog-cache.js +151 -37
- package/dist/catalog-freshness.js +13 -0
- package/dist/catalog-service.js +39 -12
- package/dist/catalog.js +103 -38
- package/dist/config.d.ts +10 -4
- package/dist/config.js +30 -7
- package/dist/connectors/remote-mcp/call.js +1 -1
- package/dist/connectors/remote-mcp/connection.js +52 -7
- package/dist/connectors/remote-mcp/oauth.js +6 -2
- package/dist/connectors/remote-mcp/resources.js +5 -2
- package/dist/connectors/remote-mcp/transport.js +37 -8
- package/dist/connectors/remote-mcp.js +8 -5
- package/dist/credential-contract.d.ts +3 -1
- package/dist/describe-config.d.ts +1 -0
- package/dist/describe-config.js +1 -0
- package/dist/downstream-input.js +11 -2
- package/dist/errors.d.ts +8 -1
- package/dist/errors.js +2 -0
- package/dist/execute.js +20 -7
- package/dist/executor-result.js +1 -1
- package/dist/executors/quickjs-child.js +1 -1
- package/dist/executors/quickjs-runtime.js +1 -1
- package/dist/executors/quickjs.js +1 -1
- package/dist/guarded-transport.js +2 -0
- package/dist/http/client.js +7 -0
- package/dist/http/hatch.js +2 -0
- package/dist/http/node-transport.js +110 -4
- package/dist/http/oauth.js +17 -6
- package/dist/index.js +10 -21
- package/dist/invocation-failure.js +28 -0
- package/dist/invocation.js +77 -35
- package/dist/membership.d.ts +1 -1
- package/dist/meta-tools.js +58 -36
- package/dist/module-contracts.d.ts +3 -1
- package/dist/native-tools.js +12 -5
- package/dist/node.d.ts +7 -0
- package/dist/node.js +82 -3
- package/dist/operation-activity.js +103 -0
- package/dist/operator-record.d.ts +8 -0
- package/dist/operator-record.js +15 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/provider-call-facts.js +6 -2
- package/dist/provider.d.ts +1 -1
- package/dist/providers/_shared/google/workspace.d.ts +1 -1
- package/dist/providers/breeze/index.js +1 -1
- package/dist/providers/ccb/index.js +1 -1
- package/dist/providers/cloudflare/index.js +1 -1
- package/dist/providers/docs/index.js +1 -1
- package/dist/providers/drive/index.js +1 -1
- package/dist/providers/forms/index.js +1 -1
- package/dist/providers/github/index.js +1 -1
- package/dist/providers/gmail/index.js +1 -1
- package/dist/providers/granola/index.js +1 -1
- package/dist/providers/overflow/index.js +1 -1
- package/dist/providers/planning-center/index.js +1 -1
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/providers/quickbooks/api.js +28 -25
- package/dist/providers/quickbooks/index.js +1 -1
- package/dist/providers/quickbooks/lib/writes.js +55 -0
- package/dist/providers/quickbooks/safety.js +17 -5
- package/dist/providers/quickbooks/skill.generated.js +1 -1
- package/dist/providers/sheets/index.js +1 -1
- package/dist/providers/slides/index.js +1 -1
- package/dist/providers/stripe/index.js +2 -2
- package/dist/providers/tithely/index.js +1 -1
- package/dist/providers/vercel/api.js +1 -0
- package/dist/registry.js +16 -4
- package/dist/resource-read.js +21 -2
- package/dist/route-names.js +44 -0
- package/dist/routes/activity-context.js +1 -0
- package/dist/routes/activity-disclosure.js +26 -4
- package/dist/routes/activity.js +2 -1
- package/dist/routes/mcp.js +32 -13
- package/dist/routes/ui-config.js +1 -1
- package/dist/routes/ui-data.js +3 -2
- package/dist/runtime/native-storage.js +15 -0
- package/dist/runtime/request-reads.js +87 -10
- package/dist/runtime/request-scope.js +2 -1
- package/dist/skill-origin.js +16 -0
- package/dist/skills.js +132 -43
- package/dist/storage/d1-driver.js +33 -5
- package/dist/storage/keys.js +7 -1
- package/dist/storage/sql-native.js +85 -3
- package/dist/storage/sql.js +6 -0
- package/dist/timing-trace.js +84 -0
- package/dist/types.d.ts +5 -0
- package/dist/url-safety.js +38 -20
- package/dist/utility-recovery.js +33 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.js +33 -8
- package/documentation/architecture.md +83 -2
- package/documentation/auth.md +132 -37
- package/documentation/code-mode.md +61 -46
- package/documentation/deploying.md +25 -1
- package/documentation/integrating.md +2 -2
- package/documentation/meta-tools.md +134 -15
- package/documentation/operating.md +86 -1
- package/documentation/operator-ui.md +51 -2
- package/documentation/providers.md +16 -14
- package/package.json +10 -8
- package/templates/node/package.json +1 -1
package/dist/meta-tools.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { operationActivity } from "./operation-activity.js";
|
|
2
|
+
import { SkillLookupError } from "./skills.js";
|
|
3
|
+
import { skillLookupRecovery, utilityRecovery } from "./utility-recovery.js";
|
|
1
4
|
import { META_ROUTES } from "./agent-routes.js";
|
|
2
5
|
import { toolError } from "./tool-error.js";
|
|
3
6
|
import { upwardStep } from "./upward.js";
|
|
@@ -38,6 +41,9 @@ export function jsonResult(obj, text = JSON.stringify(obj)) {
|
|
|
38
41
|
function errorResult(message) {
|
|
39
42
|
return { content: [{ type: "text", text: message }], isError: true };
|
|
40
43
|
}
|
|
44
|
+
function resultError(message) {
|
|
45
|
+
return toolError(utilityRecovery("invalid_args", message));
|
|
46
|
+
}
|
|
41
47
|
function discoveryErrorResult(error) {
|
|
42
48
|
return toolError({ code: error.code, message: error.message, retryable: false });
|
|
43
49
|
}
|
|
@@ -558,6 +564,7 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
|
|
|
558
564
|
}
|
|
559
565
|
const sentSecrets = sentSecretsForRequest(requestScope);
|
|
560
566
|
const skills = new SkillsRegistry(registry, baseUrl, {
|
|
567
|
+
readOnly: opts.readOnly,
|
|
561
568
|
requestScope,
|
|
562
569
|
requestSignal: opts.requestSignal,
|
|
563
570
|
probeTimeoutMs,
|
|
@@ -731,7 +738,7 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
|
|
|
731
738
|
};
|
|
732
739
|
});
|
|
733
740
|
});
|
|
734
|
-
|
|
741
|
+
const methods = {
|
|
735
742
|
skills: authMethod((args = {}) => authFlow(function* () {
|
|
736
743
|
try {
|
|
737
744
|
if (!args.name) {
|
|
@@ -751,7 +758,9 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
|
|
|
751
758
|
return { content: [{ type: "text", text }], structuredContent: { name: args.name, format: "text", text } };
|
|
752
759
|
}
|
|
753
760
|
catch (error) {
|
|
754
|
-
|
|
761
|
+
if (error instanceof SkillLookupError)
|
|
762
|
+
return toolError(skillLookupRecovery(error.message));
|
|
763
|
+
return toolError(utilityRecovery(error instanceof ConnectorCallError ? error.code : "unavailable", "Skills are unavailable.", error instanceof ConnectorCallError && error.retryable));
|
|
755
764
|
}
|
|
756
765
|
})),
|
|
757
766
|
searchTools: authMethod((args) => authFlow(function* () {
|
|
@@ -781,25 +790,21 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
|
|
|
781
790
|
// Defense for in-process callers too: guest paging validates these domains
|
|
782
791
|
// before calling here, while internal consumers may call readResult directly.
|
|
783
792
|
if (args.maxBytes !== undefined && !isValidMaxResultBytes(args.maxBytes)) {
|
|
784
|
-
return
|
|
793
|
+
return resultError(`Invalid maxBytes ${args.maxBytes}: must be a whole number of bytes ` +
|
|
785
794
|
`>= ${MIN_MAX_RESULT_BYTES}. Omit it to use the deployment default.`);
|
|
786
795
|
}
|
|
787
796
|
if (args.offset !== undefined && !isValidResultOffset(args.offset)) {
|
|
788
|
-
return
|
|
797
|
+
return resultError(`Invalid offset ${args.offset}: must be a whole number of bytes ` +
|
|
789
798
|
`>= ${MIN_RESULT_OFFSET}. Omit it to start at the beginning.`);
|
|
790
799
|
}
|
|
791
800
|
if (args.page !== undefined &&
|
|
792
801
|
(!Number.isSafeInteger(args.page) || args.page < 0 || args.offset !== undefined)) {
|
|
793
|
-
return
|
|
802
|
+
return resultError("Use a whole page >= 0, without offset.");
|
|
794
803
|
}
|
|
795
804
|
const results = registry.resultsStorage();
|
|
796
805
|
const unavailableResult = () => ({
|
|
797
806
|
...jsonResult({
|
|
798
|
-
error:
|
|
799
|
-
code: "unavailable",
|
|
800
|
-
message: "Result paging storage is unavailable.",
|
|
801
|
-
retryable: true,
|
|
802
|
-
},
|
|
807
|
+
error: utilityRecovery("unavailable", "Result paging storage is unavailable.", true),
|
|
803
808
|
}),
|
|
804
809
|
isError: true,
|
|
805
810
|
});
|
|
@@ -825,9 +830,9 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
|
|
|
825
830
|
if (stored === false)
|
|
826
831
|
return unavailableResult();
|
|
827
832
|
if (stored === null) {
|
|
828
|
-
return
|
|
833
|
+
return toolError(utilityRecovery("not_found", `Unknown or expired result id "${boundedEchoText(args.id, 96)}"`));
|
|
829
834
|
}
|
|
830
|
-
const denied = () =>
|
|
835
|
+
const denied = () => toolError(utilityRecovery("not_found", `Unknown or expired result id "${boundedEchoText(args.id, 96)}"`));
|
|
831
836
|
const header = RESULT_ENVELOPE_V4_HEADER.exec(stored);
|
|
832
837
|
if (!header)
|
|
833
838
|
return denied();
|
|
@@ -941,7 +946,7 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
|
|
|
941
946
|
if (encoded === null) {
|
|
942
947
|
// A chunk expired or was evicted under its own header; the id can no
|
|
943
948
|
// longer serve this range, and inventing U+0000 filler would be worse.
|
|
944
|
-
return
|
|
949
|
+
return toolError(utilityRecovery("not_found", `Unknown or expired result id "${boundedEchoText(args.id, 96)}"`));
|
|
945
950
|
}
|
|
946
951
|
const chunkStart = index * chunkBytes;
|
|
947
952
|
// Both bounds are chunk-local. `from` inherits `start`'s three-byte
|
|
@@ -999,6 +1004,15 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
|
|
|
999
1004
|
}
|
|
1000
1005
|
})),
|
|
1001
1006
|
};
|
|
1007
|
+
if (!opts.activity?.recordOperation)
|
|
1008
|
+
return methods;
|
|
1009
|
+
const track = (name, method) => authMethod((...args) => operationActivity(opts.activity, registry, name, authCall(method, args)));
|
|
1010
|
+
return {
|
|
1011
|
+
...methods,
|
|
1012
|
+
skills: track("skill", methods.skills),
|
|
1013
|
+
searchTools: track("search", methods.searchTools),
|
|
1014
|
+
readResult: track("result", methods.readResult),
|
|
1015
|
+
};
|
|
1002
1016
|
}
|
|
1003
1017
|
// Each description states only its own tool's scope; the full routing rule
|
|
1004
1018
|
// lives once in the server instructions. A client that drops instructions
|
|
@@ -1130,6 +1144,37 @@ export function registerMetaTools(server, registry, ctx) {
|
|
|
1130
1144
|
bindRequest(request);
|
|
1131
1145
|
return yield* upwardStep(authInvoke(mt, "skills", [args]));
|
|
1132
1146
|
})));
|
|
1147
|
+
server.registerTool("authorize_connector", {
|
|
1148
|
+
description: ctx.native
|
|
1149
|
+
? AUTHORIZE_DESC +
|
|
1150
|
+
" Use when a connector’s tools are missing. Granted: " +
|
|
1151
|
+
registry
|
|
1152
|
+
.listConnectors()
|
|
1153
|
+
.map((connector) => connector.id)
|
|
1154
|
+
.join(", ") +
|
|
1155
|
+
"."
|
|
1156
|
+
: AUTHORIZE_DESC,
|
|
1157
|
+
inputSchema: AUTHORIZE_INPUT,
|
|
1158
|
+
outputSchema: AUTHORIZE_OUTPUT,
|
|
1159
|
+
// Starts (or with force, resets) a downstream OAuth flow — it changes
|
|
1160
|
+
// stored connector auth state, so it is deliberately not read-only.
|
|
1161
|
+
annotations: {
|
|
1162
|
+
readOnlyHint: false,
|
|
1163
|
+
destructiveHint: false,
|
|
1164
|
+
openWorldHint: true,
|
|
1165
|
+
},
|
|
1166
|
+
}, authMethod((args, request) => authFlow(function* () {
|
|
1167
|
+
bindRequest(request);
|
|
1168
|
+
const run = authMethod(() => authInvoke(mt, "authorizeConnector", [args]));
|
|
1169
|
+
return yield* upwardStep(ctx.authElicitation
|
|
1170
|
+
? authInvoke(ctx.authElicitation, "run", [
|
|
1171
|
+
"authorize_connector",
|
|
1172
|
+
args,
|
|
1173
|
+
request,
|
|
1174
|
+
run,
|
|
1175
|
+
])
|
|
1176
|
+
: authCall(run, []));
|
|
1177
|
+
})));
|
|
1133
1178
|
if (ctx.native) {
|
|
1134
1179
|
if (!ctx.native.executeCode) {
|
|
1135
1180
|
server.registerTool("read_result", { description: READ_RESULT_DESC, inputSchema: READ_RESULT_INPUT, annotations: READ_ONLY_LOCAL }, authMethod((args, request) => authFlow(function* () {
|
|
@@ -1201,28 +1246,5 @@ export function registerMetaTools(server, registry, ctx) {
|
|
|
1201
1246
|
])
|
|
1202
1247
|
: authInvoke(mt, "callDestructiveTool", [call]));
|
|
1203
1248
|
})));
|
|
1204
|
-
server.registerTool("authorize_connector", {
|
|
1205
|
-
description: AUTHORIZE_DESC,
|
|
1206
|
-
inputSchema: AUTHORIZE_INPUT,
|
|
1207
|
-
outputSchema: AUTHORIZE_OUTPUT,
|
|
1208
|
-
// Starts (or with force, resets) a downstream OAuth flow — it changes
|
|
1209
|
-
// stored connector auth state, so it is deliberately not read-only.
|
|
1210
|
-
annotations: {
|
|
1211
|
-
readOnlyHint: false,
|
|
1212
|
-
destructiveHint: false,
|
|
1213
|
-
openWorldHint: true,
|
|
1214
|
-
},
|
|
1215
|
-
}, authMethod((args, request) => authFlow(function* () {
|
|
1216
|
-
bindRequest(request);
|
|
1217
|
-
const run = authMethod(() => authInvoke(mt, "authorizeConnector", [args]));
|
|
1218
|
-
return yield* upwardStep(ctx.authElicitation
|
|
1219
|
-
? authInvoke(ctx.authElicitation, "run", [
|
|
1220
|
-
"authorize_connector",
|
|
1221
|
-
args,
|
|
1222
|
-
request,
|
|
1223
|
-
run,
|
|
1224
|
-
])
|
|
1225
|
-
: authCall(run, []));
|
|
1226
|
-
})));
|
|
1227
1249
|
return undefined;
|
|
1228
1250
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ConnectaBranding } from "./types.js";
|
|
2
|
-
import type { ActivityStore, ActivityReadGate, recordToolActivity, recordCatalogChangeActivity } from "./activity.js";
|
|
2
|
+
import type { ActivityStore, ActivityReadGate, recordToolActivity, recordCatalogChangeActivity, recordOperationActivity } from "./activity.js";
|
|
3
3
|
import type { RouteContext } from "./routes/shared.js";
|
|
4
4
|
/** Construction-time UI contract. Core never imports the implementation. */
|
|
5
5
|
export interface OperatorSurface {
|
|
@@ -10,6 +10,8 @@ export interface OperatorSurface {
|
|
|
10
10
|
}
|
|
11
11
|
/** Optional recording and reading callbacks supplied by /activity. */
|
|
12
12
|
export interface ActivityModule {
|
|
13
|
+
readonly operations?: boolean;
|
|
14
|
+
readonly recordOperation?: typeof recordOperationActivity;
|
|
13
15
|
readonly store: ActivityStore;
|
|
14
16
|
readonly deploymentId?: string;
|
|
15
17
|
readonly readGate?: ActivityReadGate;
|
package/dist/native-tools.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolvedConnectorGuides } from "./skills.js";
|
|
1
2
|
import { bytesToHex } from "./bytes.js";
|
|
2
3
|
import { routeNames } from "./agent-routes.js";
|
|
3
4
|
import { duringViewEffect } from "./runtime/call.js";
|
|
@@ -29,7 +30,7 @@ const SEPARATOR = "__";
|
|
|
29
30
|
const PLAIN_TOOL = /^[A-Za-z0-9-](?:[A-Za-z0-9-]|_(?!_))*$/;
|
|
30
31
|
const NATIVE_NAME = new RegExp(`^[A-Za-z][A-Za-z0-9_-]{0,${NATIVE_NAME_MAX - 1}}$`);
|
|
31
32
|
/** Connecta's own tools on a native endpoint. Their names never contain `__`. */
|
|
32
|
-
const NATIVE_OWN_TOOLS = ["execute_code", "skills", "read_result"];
|
|
33
|
+
const NATIVE_OWN_TOOLS = ["execute_code", "skills", "read_result", "authorize_connector"];
|
|
33
34
|
const enc = new TextEncoder();
|
|
34
35
|
function shortHash(text) {
|
|
35
36
|
return runEdge(shortHashNative(text));
|
|
@@ -129,10 +130,12 @@ function passThroughSchema(schema) {
|
|
|
129
130
|
},
|
|
130
131
|
};
|
|
131
132
|
}
|
|
132
|
-
function describe(entry, executeCode) {
|
|
133
|
+
function describe(entry, executeCode, guideName) {
|
|
133
134
|
const lines = [entry.tool.description?.trim() || entry.tool.title || entry.tool.name];
|
|
134
135
|
if (executeCode)
|
|
135
136
|
lines.push(`Program address: ${entry.connector.id}.${entry.tool.name}`);
|
|
137
|
+
if (guideName)
|
|
138
|
+
lines.push(`Guide: skills ${guideName}`);
|
|
136
139
|
return lines.join("\n\n");
|
|
137
140
|
}
|
|
138
141
|
export class NativeSurface {
|
|
@@ -177,10 +180,10 @@ export class NativeSurface {
|
|
|
177
180
|
call(name, args, request) {
|
|
178
181
|
return runEdge(authOwnMethod(NativeSurface.prototype, this, "call", [name, args, request]));
|
|
179
182
|
}
|
|
180
|
-
registerTool(server, name, entry) {
|
|
183
|
+
registerTool(server, name, entry, guideName) {
|
|
181
184
|
server.registerTool(name, {
|
|
182
185
|
...(entry?.tool.title ? { title: entry.tool.title } : {}),
|
|
183
|
-
...(entry ? { description: describe(entry, this.options.executeCode) } : {}),
|
|
186
|
+
...(entry ? { description: describe(entry, this.options.executeCode, guideName) } : {}),
|
|
184
187
|
inputSchema: passThroughSchema(entry?.tool.inputSchema),
|
|
185
188
|
...(entry ? { annotations: annotationsFor(entry.tool) } : {}),
|
|
186
189
|
...(entry ? { _meta: { "dev.connecta/address": `${entry.connector.id}.${entry.tool.name}` } } : {}),
|
|
@@ -220,8 +223,12 @@ export class NativeSurface {
|
|
|
220
223
|
});
|
|
221
224
|
return;
|
|
222
225
|
}
|
|
226
|
+
const guides = yield* upwardStep(authCall(resolvedConnectorGuides, [
|
|
227
|
+
self.registry.listConnectors(),
|
|
228
|
+
]));
|
|
229
|
+
const guideNames = new Map(guides.map(({ connector, reference }) => [connector.id, reference]));
|
|
223
230
|
for (const entry of tools)
|
|
224
|
-
self.registerTool(server, entry.name, entry);
|
|
231
|
+
self.registerTool(server, entry.name, entry, guideNames.get(entry.connector.id));
|
|
225
232
|
})).pipe(Effect.provideService(RequestScope, requestOwner(this.options.requestScope)));
|
|
226
233
|
}, NativeSurface.prototype.register);
|
|
227
234
|
registerAuthMethod(NativeSurface.prototype, "call", function (name, args, request) {
|
package/dist/node.d.ts
CHANGED
|
@@ -8,6 +8,13 @@ export interface ListenOptions {
|
|
|
8
8
|
host?: string;
|
|
9
9
|
/** Pre-auth buffer cap, independent of the authenticated MCP cap. Default 4 MiB. */
|
|
10
10
|
maxBodyBytes?: number;
|
|
11
|
+
/**
|
|
12
|
+
* Cap on the body bytes all in-flight requests hold together, counted as
|
|
13
|
+
* each arrives and released when its response finishes or its client leaves.
|
|
14
|
+
* Past it a request is answered 503 with `Retry-After`. Default 64 MiB, or
|
|
15
|
+
* `maxBodyBytes` when larger. May not be below `maxBodyBytes`.
|
|
16
|
+
*/
|
|
17
|
+
maxInFlightBodyBytes?: number;
|
|
11
18
|
/**
|
|
12
19
|
* Stop accepting connections on SIGTERM/SIGINT, let in-flight requests
|
|
13
20
|
* finish, drain deferred work, then exit. Default true — Docker sends
|
package/dist/node.js
CHANGED
|
@@ -5,13 +5,40 @@ import { failureRecord, logFailure } from "./operator-record.js";
|
|
|
5
5
|
import { configureGuardedTransport } from "./guarded-transport.js";
|
|
6
6
|
import { createNodeGuardedTransport } from "./http/node-transport.js";
|
|
7
7
|
import { CONFIG_DEFAULTS } from "./config-defaults.js";
|
|
8
|
+
import { ConfigError } from "./config-schema.js";
|
|
8
9
|
/** The adapter buffers before auth, bounded by the same default as the core. */
|
|
9
10
|
const DEFAULT_MAX_BODY_BYTES = CONFIG_DEFAULTS.admission.requests.maxBodyBytes;
|
|
11
|
+
/** Sixteen full default bodies; well above ordinary MCP traffic, whose bodies are small. */
|
|
12
|
+
const DEFAULT_MAX_IN_FLIGHT_BODY_BYTES = 64 * 1024 * 1024;
|
|
10
13
|
const BODY_TOO_LARGE = Symbol("body-too-large");
|
|
11
|
-
|
|
14
|
+
const BODIES_AT_CAPACITY = Symbol("bodies-at-capacity");
|
|
15
|
+
/** A name, IPv4 or bracketed IPv6 host with an optional port, and nothing else. */
|
|
16
|
+
const REQUEST_HOST = /^(?:[a-z0-9._-]+|\[[0-9a-f:.]+\])(?::\d{1,5})?$/i;
|
|
17
|
+
/** `Host`, `X-Forwarded-Proto` or the request target is not a URL part; answered 400. */
|
|
18
|
+
class InvalidRequestUrl extends Error {
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The request's URL, read from values a client chooses. Each is checked on its
|
|
22
|
+
* own and the origin is parsed before the target joins it, so no value can move
|
|
23
|
+
* the boundary between authority, path and query for the routes behind it.
|
|
24
|
+
*/
|
|
25
|
+
function requestUrl(req) {
|
|
12
26
|
const host = req.headers.host ?? "localhost";
|
|
13
27
|
const proto = req.headers["x-forwarded-proto"] ?? "http";
|
|
14
|
-
|
|
28
|
+
// Origin-form only. Absolute-form and `*` have no path to route.
|
|
29
|
+
const target = req.url ?? "/";
|
|
30
|
+
if (!REQUEST_HOST.test(host) || typeof proto !== "string" || !/^https?$/i.test(proto) || !target.startsWith("/")) {
|
|
31
|
+
throw new InvalidRequestUrl();
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
return `${new URL(`${proto}://${host}`).origin}${target}`;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new InvalidRequestUrl();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function toRequest(req, maxBodyBytes, holdBodyBytes, signal) {
|
|
41
|
+
const url = requestUrl(req);
|
|
15
42
|
const method = req.method ?? "GET";
|
|
16
43
|
const headers = new Headers();
|
|
17
44
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
@@ -29,6 +56,8 @@ async function toRequest(req, maxBodyBytes, signal) {
|
|
|
29
56
|
// rest of a body we have already decided to reject.
|
|
30
57
|
if (size > maxBodyBytes)
|
|
31
58
|
return BODY_TOO_LARGE;
|
|
59
|
+
if (!holdBodyBytes(chunk.length))
|
|
60
|
+
return BODIES_AT_CAPACITY;
|
|
32
61
|
chunks.push(chunk);
|
|
33
62
|
}
|
|
34
63
|
if (chunks.length)
|
|
@@ -64,6 +93,13 @@ export function listen(connecta, portOrOptions) {
|
|
|
64
93
|
const opts = typeof portOrOptions === "number" ? { port: portOrOptions } : portOrOptions;
|
|
65
94
|
configureGuardedTransport(createNodeGuardedTransport(opts.proxy));
|
|
66
95
|
const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
96
|
+
const maxInFlightBodyBytes = opts.maxInFlightBodyBytes === undefined
|
|
97
|
+
? Math.max(DEFAULT_MAX_IN_FLIGHT_BODY_BYTES, maxBodyBytes)
|
|
98
|
+
: opts.maxInFlightBodyBytes;
|
|
99
|
+
if (!Number.isSafeInteger(maxInFlightBodyBytes) || maxInFlightBodyBytes < 1 || maxInFlightBodyBytes < maxBodyBytes) {
|
|
100
|
+
throw new ConfigError("listen: maxInFlightBodyBytes must be a whole number of bytes, at least maxBodyBytes.");
|
|
101
|
+
}
|
|
102
|
+
let inFlightBodyBytes = 0;
|
|
67
103
|
// Node has no ExecutionContext, so deferred work (activity sinks) would
|
|
68
104
|
// otherwise be an untracked floating promise. Track it here and drain it on
|
|
69
105
|
// shutdown so both runtimes behave the same.
|
|
@@ -101,7 +137,19 @@ export function listen(connecta, portOrOptions) {
|
|
|
101
137
|
res.once("close", finished);
|
|
102
138
|
void (async () => {
|
|
103
139
|
const controller = new AbortController();
|
|
140
|
+
let heldBodyBytes = 0;
|
|
141
|
+
let bodyBytesReleased = false;
|
|
142
|
+
// Pipeline teardown can wait forever on a response stream's cancel().
|
|
143
|
+
// Release on the HTTP lifecycle too, and forbid late upload reservations.
|
|
144
|
+
const releaseBodyBytes = () => {
|
|
145
|
+
if (bodyBytesReleased)
|
|
146
|
+
return;
|
|
147
|
+
bodyBytesReleased = true;
|
|
148
|
+
inFlightBodyBytes -= heldBodyBytes;
|
|
149
|
+
heldBodyBytes = 0;
|
|
150
|
+
};
|
|
104
151
|
const abortRequest = () => {
|
|
152
|
+
releaseBodyBytes();
|
|
105
153
|
if (!controller.signal.aborted) {
|
|
106
154
|
controller.abort(new Error("HTTP client disconnected."));
|
|
107
155
|
}
|
|
@@ -117,8 +165,28 @@ export function listen(connecta, portOrOptions) {
|
|
|
117
165
|
req.once("aborted", abortRequest);
|
|
118
166
|
req.once("close", abortIncompleteRequest);
|
|
119
167
|
res.once("close", abortIncompleteResponse);
|
|
168
|
+
res.once("finish", releaseBodyBytes);
|
|
169
|
+
res.once("close", releaseBodyBytes);
|
|
170
|
+
// A queued pipelined response gets neither finish nor close when the
|
|
171
|
+
// socket goes away, and its completed upload's request is already closed.
|
|
172
|
+
socket.once("close", abortRequest);
|
|
173
|
+
const holdBodyBytes = (bytes) => {
|
|
174
|
+
if (bodyBytesReleased || inFlightBodyBytes + bytes > maxInFlightBodyBytes)
|
|
175
|
+
return false;
|
|
176
|
+
inFlightBodyBytes += bytes;
|
|
177
|
+
heldBodyBytes += bytes;
|
|
178
|
+
return true;
|
|
179
|
+
};
|
|
120
180
|
try {
|
|
121
|
-
const request = await toRequest(req, maxBodyBytes, controller.signal);
|
|
181
|
+
const request = await toRequest(req, maxBodyBytes, holdBodyBytes, controller.signal);
|
|
182
|
+
if (request === BODIES_AT_CAPACITY) {
|
|
183
|
+
res.statusCode = 503;
|
|
184
|
+
res.setHeader("Retry-After", "1");
|
|
185
|
+
// Abandoned mid-body, like the 413 below.
|
|
186
|
+
res.setHeader("Connection", "close");
|
|
187
|
+
res.end("Service Unavailable");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
122
190
|
if (request === BODY_TOO_LARGE) {
|
|
123
191
|
res.statusCode = 413;
|
|
124
192
|
// The request stream was abandoned mid-body, so this connection
|
|
@@ -132,6 +200,13 @@ export function listen(connecta, portOrOptions) {
|
|
|
132
200
|
await writeResponse(res, response);
|
|
133
201
|
}
|
|
134
202
|
catch (error) {
|
|
203
|
+
if (error instanceof InvalidRequestUrl) {
|
|
204
|
+
res.statusCode = 400;
|
|
205
|
+
// Refused before reading the body, so the connection cannot be reused.
|
|
206
|
+
res.setHeader("Connection", "close");
|
|
207
|
+
res.end("Bad Request");
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
135
210
|
// A client that deliberately went away can make either the request
|
|
136
211
|
// handler or response pipeline reject. The request's abort signal is
|
|
137
212
|
// the authoritative classification: there is no caller left to answer,
|
|
@@ -152,6 +227,10 @@ export function listen(connecta, portOrOptions) {
|
|
|
152
227
|
res.end("Internal Server Error");
|
|
153
228
|
}
|
|
154
229
|
finally {
|
|
230
|
+
releaseBodyBytes();
|
|
231
|
+
res.removeListener("finish", releaseBodyBytes);
|
|
232
|
+
res.removeListener("close", releaseBodyBytes);
|
|
233
|
+
socket.removeListener("close", abortRequest);
|
|
155
234
|
req.removeListener("aborted", abortRequest);
|
|
156
235
|
req.removeListener("close", abortIncompleteRequest);
|
|
157
236
|
res.removeListener("close", abortIncompleteResponse);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// The root graph calls the optional recorder, never the /activity implementation.
|
|
2
|
+
import { Cause, Effect, Exit } from "effect";
|
|
3
|
+
import { InvocationFailure } from "./invocation-failure.js";
|
|
4
|
+
import { ExecutorAdmissionError } from "./executor-admission.js";
|
|
5
|
+
import { DiscoveryPolicyError } from "./catalog-service.js";
|
|
6
|
+
import { classificationCode, classifyCallError } from "./errors.js";
|
|
7
|
+
import { usesCallerIdentity } from "./connector-caller.js";
|
|
8
|
+
export function operationActivity(context, registry, name, work, counts) {
|
|
9
|
+
if (!context?.recordOperation)
|
|
10
|
+
return work;
|
|
11
|
+
return Effect.suspend(() => {
|
|
12
|
+
const started = performance.now();
|
|
13
|
+
const connectors = registry.listConnectors();
|
|
14
|
+
// Human operations stay owner-only, including discovery of private catalogs.
|
|
15
|
+
let personal = Boolean(context.principalActor);
|
|
16
|
+
try {
|
|
17
|
+
personal ||= connectors.some((connector) => connector.authScope === "personal" ||
|
|
18
|
+
!connector.staticTools ||
|
|
19
|
+
usesCallerIdentity(connector) ||
|
|
20
|
+
connector.describe?.().auth?.mode === "request");
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
personal = true;
|
|
24
|
+
}
|
|
25
|
+
return work.pipe(Effect.onExit((exit) => Effect.sync(() => {
|
|
26
|
+
const result = Exit.isSuccess(exit)
|
|
27
|
+
? exit.value
|
|
28
|
+
: undefined;
|
|
29
|
+
const failed = Exit.isFailure(exit) || result?.isError === true;
|
|
30
|
+
const interrupted = Exit.isFailure(exit) && exit.cause.reasons.some(Cause.isInterruptReason);
|
|
31
|
+
const failure = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined;
|
|
32
|
+
const errorCode = interrupted
|
|
33
|
+
? "cancelled"
|
|
34
|
+
: classificationCode(Exit.isFailure(exit)
|
|
35
|
+
? failure instanceof InvocationFailure ||
|
|
36
|
+
failure instanceof ExecutorAdmissionError ||
|
|
37
|
+
failure instanceof DiscoveryPolicyError
|
|
38
|
+
? failure.code
|
|
39
|
+
: classifyCallError(failure).code
|
|
40
|
+
: result?.structuredContent?.error?.code);
|
|
41
|
+
try {
|
|
42
|
+
// Result pages already measure their payload. Other operations have no
|
|
43
|
+
// reusable measurement: never serialize discovery or media for telemetry.
|
|
44
|
+
const page = name === "result" && Exit.isSuccess(exit)
|
|
45
|
+
? exit.value
|
|
46
|
+
: undefined;
|
|
47
|
+
const bytes = page?.structuredContent?.bytes ?? page?.bytes;
|
|
48
|
+
const resultBytes = typeof bytes === "number" && Number.isSafeInteger(bytes) && bytes >= 0 ? bytes : undefined;
|
|
49
|
+
context.recordOperation(context, {
|
|
50
|
+
operation: {
|
|
51
|
+
name,
|
|
52
|
+
connectors: connectors.map((connector) => connector.id),
|
|
53
|
+
personal,
|
|
54
|
+
...(counts ? { hostCalls: counts() } : {}),
|
|
55
|
+
},
|
|
56
|
+
durationMs: performance.now() - started,
|
|
57
|
+
resultBytes,
|
|
58
|
+
outcome: !failed
|
|
59
|
+
? "success"
|
|
60
|
+
: errorCode === "timeout"
|
|
61
|
+
? "timeout"
|
|
62
|
+
: errorCode === "cancelled" || errorCode === "executor_cancelled"
|
|
63
|
+
? "cancelled"
|
|
64
|
+
: "error",
|
|
65
|
+
errorCode,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
/* Optional recording cannot replace the operation's result. */
|
|
70
|
+
}
|
|
71
|
+
})));
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const OPERATION_NAMES = new Set(["search", "describe", "result", "skill", "program"]);
|
|
75
|
+
export function checkedActivityOperation(value) {
|
|
76
|
+
if (!value || typeof value !== "object")
|
|
77
|
+
return undefined;
|
|
78
|
+
const input = value;
|
|
79
|
+
if (!OPERATION_NAMES.has(input.name) ||
|
|
80
|
+
typeof input.personal !== "boolean" ||
|
|
81
|
+
!Array.isArray(input.connectors) ||
|
|
82
|
+
input.connectors.some((id) => typeof id !== "string" || !/^[a-zA-Z0-9_-]+$/.test(id)))
|
|
83
|
+
return undefined;
|
|
84
|
+
const counts = input.hostCalls;
|
|
85
|
+
if (counts &&
|
|
86
|
+
[counts.attempted, counts.admitted, counts.succeeded, counts.failed].some((n) => typeof n !== "number" || !Number.isSafeInteger(n) || n < 0))
|
|
87
|
+
return undefined;
|
|
88
|
+
return {
|
|
89
|
+
name: input.name,
|
|
90
|
+
connectors: [...new Set(input.connectors)],
|
|
91
|
+
personal: input.personal,
|
|
92
|
+
...(counts
|
|
93
|
+
? {
|
|
94
|
+
hostCalls: {
|
|
95
|
+
attempted: counts.attempted,
|
|
96
|
+
admitted: counts.admitted,
|
|
97
|
+
succeeded: counts.succeeded,
|
|
98
|
+
failed: counts.failed,
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
: {}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -23,8 +23,15 @@ export declare function labelErrorClass(ctor: {
|
|
|
23
23
|
* name is exactly one of those, else `DOMException`.
|
|
24
24
|
*/
|
|
25
25
|
export declare function errorLabel(failure: unknown): string | undefined;
|
|
26
|
+
interface IdentityLookupFact {
|
|
27
|
+
outcome: "timeout" | "rejected";
|
|
28
|
+
durationMs: number;
|
|
29
|
+
cold: boolean;
|
|
30
|
+
inFlight: number;
|
|
31
|
+
}
|
|
26
32
|
/** Facts connecta derived about a failure where it saw it. */
|
|
27
33
|
export interface FailureFacts {
|
|
34
|
+
identityLookup?: IdentityLookupFact;
|
|
28
35
|
/** Inbound refusal classification attached to its Response, never its body. */
|
|
29
36
|
reason?: AuthDenialReason;
|
|
30
37
|
step?: FailureStep;
|
|
@@ -64,6 +71,7 @@ export interface FailureSubject {
|
|
|
64
71
|
declare const recordBrand: unique symbol;
|
|
65
72
|
/** One failure as an operator may read it. Only `failureRecord` makes one. */
|
|
66
73
|
export interface FailureRecord {
|
|
74
|
+
readonly identityLookup?: IdentityLookupFact;
|
|
67
75
|
readonly [recordBrand]: true;
|
|
68
76
|
readonly reason?: AuthDenialReason;
|
|
69
77
|
readonly accessContext?: boolean;
|
package/dist/operator-record.js
CHANGED
|
@@ -165,6 +165,20 @@ export function errorLabel(failure) {
|
|
|
165
165
|
}
|
|
166
166
|
return undefined;
|
|
167
167
|
}
|
|
168
|
+
function identityLookupFact(value) {
|
|
169
|
+
if (!value ||
|
|
170
|
+
(value.outcome !== "timeout" && value.outcome !== "rejected") ||
|
|
171
|
+
count(value.durationMs, 86_400_000) === undefined ||
|
|
172
|
+
typeof value.cold !== "boolean" ||
|
|
173
|
+
count(value.inFlight, 1_000) === undefined)
|
|
174
|
+
return undefined;
|
|
175
|
+
return {
|
|
176
|
+
outcome: value.outcome,
|
|
177
|
+
durationMs: Math.round(value.durationMs),
|
|
178
|
+
cold: value.cold,
|
|
179
|
+
inFlight: Math.round(value.inFlight),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
168
182
|
function origin(value) {
|
|
169
183
|
if (typeof value !== "string")
|
|
170
184
|
return undefined;
|
|
@@ -190,6 +204,7 @@ function checkedFacts(facts) {
|
|
|
190
204
|
return {
|
|
191
205
|
...defined("reason", member(facts.reason, AUTH_DENIAL_REASONS)),
|
|
192
206
|
...defined("step", member(facts.step, STEPS)),
|
|
207
|
+
...defined("identityLookup", identityLookupFact(facts.identityLookup)),
|
|
193
208
|
...defined("origin", origin(facts.origin)),
|
|
194
209
|
...defined("httpStatus", httpStatus(facts.httpStatus)),
|
|
195
210
|
...defined("oauthError", member(facts.oauthError, OAUTH_ERROR_CODES)),
|