@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.
Files changed (120) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/bin/connecta.mjs +4 -8
  3. package/bin/version.mjs +1 -1
  4. package/dist/access-tokens.js +6 -4
  5. package/dist/activity.d.ts +30 -4
  6. package/dist/activity.js +28 -2
  7. package/dist/agent-observation.js +24 -2
  8. package/dist/agent-routes.js +4 -41
  9. package/dist/auth/clerk.d.ts +6 -1
  10. package/dist/auth/clerk.js +16 -0
  11. package/dist/auth/cloudflare-access.js +49 -7
  12. package/dist/auth/downstream-oauth/consent.js +79 -29
  13. package/dist/auth/downstream-oauth/grant-store.js +58 -7
  14. package/dist/auth/downstream-oauth/refresh-records.js +6 -5
  15. package/dist/auth/downstream-oauth.js +12 -4
  16. package/dist/auth/native.js +10 -0
  17. package/dist/auth/oauth-request-not-sent.js +4 -0
  18. package/dist/auth/static-oauth.js +51 -21
  19. package/dist/catalog-cache.js +151 -37
  20. package/dist/catalog-freshness.js +13 -0
  21. package/dist/catalog-service.js +39 -12
  22. package/dist/catalog.js +103 -38
  23. package/dist/config.d.ts +10 -4
  24. package/dist/config.js +30 -7
  25. package/dist/connectors/remote-mcp/call.js +1 -1
  26. package/dist/connectors/remote-mcp/connection.js +52 -7
  27. package/dist/connectors/remote-mcp/oauth.js +6 -2
  28. package/dist/connectors/remote-mcp/resources.js +5 -2
  29. package/dist/connectors/remote-mcp/transport.js +37 -8
  30. package/dist/connectors/remote-mcp.js +8 -5
  31. package/dist/credential-contract.d.ts +3 -1
  32. package/dist/describe-config.d.ts +1 -0
  33. package/dist/describe-config.js +1 -0
  34. package/dist/downstream-input.js +11 -2
  35. package/dist/errors.d.ts +8 -1
  36. package/dist/errors.js +2 -0
  37. package/dist/execute.js +20 -7
  38. package/dist/executor-result.js +1 -1
  39. package/dist/executors/quickjs-child.js +1 -1
  40. package/dist/executors/quickjs-runtime.js +1 -1
  41. package/dist/executors/quickjs.js +1 -1
  42. package/dist/guarded-transport.js +2 -0
  43. package/dist/http/client.js +7 -0
  44. package/dist/http/hatch.js +2 -0
  45. package/dist/http/node-transport.js +110 -4
  46. package/dist/http/oauth.js +17 -6
  47. package/dist/index.js +10 -21
  48. package/dist/invocation-failure.js +28 -0
  49. package/dist/invocation.js +77 -35
  50. package/dist/membership.d.ts +1 -1
  51. package/dist/meta-tools.js +58 -36
  52. package/dist/module-contracts.d.ts +3 -1
  53. package/dist/native-tools.js +12 -5
  54. package/dist/node.d.ts +7 -0
  55. package/dist/node.js +82 -3
  56. package/dist/operation-activity.js +103 -0
  57. package/dist/operator-record.d.ts +8 -0
  58. package/dist/operator-record.js +15 -0
  59. package/dist/operator-ui/generated.js +2 -2
  60. package/dist/provider-call-facts.js +6 -2
  61. package/dist/provider.d.ts +1 -1
  62. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  63. package/dist/providers/breeze/index.js +1 -1
  64. package/dist/providers/ccb/index.js +1 -1
  65. package/dist/providers/cloudflare/index.js +1 -1
  66. package/dist/providers/docs/index.js +1 -1
  67. package/dist/providers/drive/index.js +1 -1
  68. package/dist/providers/forms/index.js +1 -1
  69. package/dist/providers/github/index.js +1 -1
  70. package/dist/providers/gmail/index.js +1 -1
  71. package/dist/providers/granola/index.js +1 -1
  72. package/dist/providers/overflow/index.js +1 -1
  73. package/dist/providers/planning-center/index.js +1 -1
  74. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  75. package/dist/providers/quickbooks/api.js +28 -25
  76. package/dist/providers/quickbooks/index.js +1 -1
  77. package/dist/providers/quickbooks/lib/writes.js +55 -0
  78. package/dist/providers/quickbooks/safety.js +17 -5
  79. package/dist/providers/quickbooks/skill.generated.js +1 -1
  80. package/dist/providers/sheets/index.js +1 -1
  81. package/dist/providers/slides/index.js +1 -1
  82. package/dist/providers/stripe/index.js +2 -2
  83. package/dist/providers/tithely/index.js +1 -1
  84. package/dist/providers/vercel/api.js +1 -0
  85. package/dist/registry.js +16 -4
  86. package/dist/resource-read.js +21 -2
  87. package/dist/route-names.js +44 -0
  88. package/dist/routes/activity-context.js +1 -0
  89. package/dist/routes/activity-disclosure.js +26 -4
  90. package/dist/routes/activity.js +2 -1
  91. package/dist/routes/mcp.js +32 -13
  92. package/dist/routes/ui-config.js +1 -1
  93. package/dist/routes/ui-data.js +3 -2
  94. package/dist/runtime/native-storage.js +15 -0
  95. package/dist/runtime/request-reads.js +87 -10
  96. package/dist/runtime/request-scope.js +2 -1
  97. package/dist/skill-origin.js +16 -0
  98. package/dist/skills.js +132 -43
  99. package/dist/storage/d1-driver.js +33 -5
  100. package/dist/storage/keys.js +7 -1
  101. package/dist/storage/sql-native.js +85 -3
  102. package/dist/storage/sql.js +6 -0
  103. package/dist/timing-trace.js +84 -0
  104. package/dist/types.d.ts +5 -0
  105. package/dist/url-safety.js +38 -20
  106. package/dist/utility-recovery.js +33 -0
  107. package/dist/version.d.ts +1 -1
  108. package/dist/version.js +1 -1
  109. package/dist/worker.js +33 -8
  110. package/documentation/architecture.md +83 -2
  111. package/documentation/auth.md +132 -37
  112. package/documentation/code-mode.md +61 -46
  113. package/documentation/deploying.md +25 -1
  114. package/documentation/integrating.md +2 -2
  115. package/documentation/meta-tools.md +134 -15
  116. package/documentation/operating.md +86 -1
  117. package/documentation/operator-ui.md +51 -2
  118. package/documentation/providers.md +16 -14
  119. package/package.json +10 -8
  120. package/templates/node/package.json +1 -1
@@ -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
- return {
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
- return errorResult(error instanceof ConnectorCallError ? error.message : "Skills are unavailable.");
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 errorResult(`Invalid maxBytes ${args.maxBytes}: must be a whole number of bytes ` +
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 errorResult(`Invalid offset ${args.offset}: must be a whole number of bytes ` +
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 errorResult("Use a whole page >= 0, without offset.");
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 errorResult(`Unknown or expired result id "${boundedEchoText(args.id)}"`);
833
+ return toolError(utilityRecovery("not_found", `Unknown or expired result id "${boundedEchoText(args.id, 96)}"`));
829
834
  }
830
- const denied = () => errorResult(`Unknown or expired result id "${boundedEchoText(args.id)}"`);
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 errorResult(`Unknown or expired result id "${boundedEchoText(args.id)}"`);
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;
@@ -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
- async function toRequest(req, maxBodyBytes, signal) {
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
- const url = `${proto}://${host}${req.url ?? "/"}`;
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;
@@ -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)),