@zackbart/connecta 0.40.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 (115) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/bin/version.mjs +1 -1
  3. package/dist/activity.d.ts +30 -4
  4. package/dist/activity.js +28 -2
  5. package/dist/agent-observation.js +24 -2
  6. package/dist/agent-routes.js +4 -41
  7. package/dist/auth/clerk.d.ts +6 -1
  8. package/dist/auth/clerk.js +16 -0
  9. package/dist/auth/cloudflare-access.js +49 -7
  10. package/dist/auth/downstream-oauth/consent.js +79 -29
  11. package/dist/auth/downstream-oauth/grant-store.js +58 -7
  12. package/dist/auth/downstream-oauth/refresh-records.js +6 -5
  13. package/dist/auth/downstream-oauth.js +12 -4
  14. package/dist/auth/oauth-request-not-sent.js +4 -0
  15. package/dist/auth/static-oauth.js +51 -21
  16. package/dist/catalog-cache.js +62 -21
  17. package/dist/catalog-freshness.js +13 -0
  18. package/dist/catalog-service.js +39 -12
  19. package/dist/catalog.js +103 -38
  20. package/dist/config.d.ts +10 -4
  21. package/dist/config.js +30 -7
  22. package/dist/connectors/remote-mcp/call.js +1 -1
  23. package/dist/connectors/remote-mcp/connection.js +52 -18
  24. package/dist/connectors/remote-mcp/oauth.js +2 -2
  25. package/dist/connectors/remote-mcp/resources.js +5 -2
  26. package/dist/connectors/remote-mcp/transport.js +24 -6
  27. package/dist/connectors/remote-mcp.js +4 -4
  28. package/dist/credential-contract.d.ts +3 -1
  29. package/dist/describe-config.d.ts +1 -0
  30. package/dist/describe-config.js +1 -0
  31. package/dist/downstream-input.js +11 -2
  32. package/dist/errors.d.ts +8 -1
  33. package/dist/errors.js +2 -0
  34. package/dist/execute.js +20 -7
  35. package/dist/executor-result.js +1 -1
  36. package/dist/executors/quickjs-child.js +1 -1
  37. package/dist/executors/quickjs-runtime.js +1 -1
  38. package/dist/executors/quickjs.js +1 -1
  39. package/dist/guarded-transport.js +2 -0
  40. package/dist/http/client.js +7 -0
  41. package/dist/http/hatch.js +2 -0
  42. package/dist/http/node-transport.js +110 -4
  43. package/dist/http/oauth.js +17 -6
  44. package/dist/index.js +10 -21
  45. package/dist/invocation-failure.js +28 -0
  46. package/dist/invocation.js +72 -33
  47. package/dist/membership.d.ts +1 -1
  48. package/dist/meta-tools.js +27 -13
  49. package/dist/module-contracts.d.ts +3 -1
  50. package/dist/native-tools.js +11 -4
  51. package/dist/node.d.ts +7 -0
  52. package/dist/node.js +82 -3
  53. package/dist/operation-activity.js +103 -0
  54. package/dist/operator-record.d.ts +8 -0
  55. package/dist/operator-record.js +15 -0
  56. package/dist/operator-ui/generated.js +2 -2
  57. package/dist/provider.d.ts +1 -1
  58. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  59. package/dist/providers/breeze/index.js +1 -1
  60. package/dist/providers/ccb/index.js +1 -1
  61. package/dist/providers/cloudflare/index.js +1 -1
  62. package/dist/providers/docs/index.js +1 -1
  63. package/dist/providers/drive/index.js +1 -1
  64. package/dist/providers/forms/index.js +1 -1
  65. package/dist/providers/github/index.js +1 -1
  66. package/dist/providers/gmail/index.js +1 -1
  67. package/dist/providers/granola/index.js +1 -1
  68. package/dist/providers/overflow/index.js +1 -1
  69. package/dist/providers/planning-center/index.js +1 -1
  70. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  71. package/dist/providers/quickbooks/api.js +28 -25
  72. package/dist/providers/quickbooks/index.js +1 -1
  73. package/dist/providers/quickbooks/lib/writes.js +55 -0
  74. package/dist/providers/quickbooks/safety.js +17 -5
  75. package/dist/providers/quickbooks/skill.generated.js +1 -1
  76. package/dist/providers/sheets/index.js +1 -1
  77. package/dist/providers/slides/index.js +1 -1
  78. package/dist/providers/stripe/index.js +2 -2
  79. package/dist/providers/tithely/index.js +1 -1
  80. package/dist/providers/vercel/api.js +1 -0
  81. package/dist/registry.js +16 -4
  82. package/dist/resource-read.js +21 -2
  83. package/dist/route-names.js +44 -0
  84. package/dist/routes/activity-context.js +1 -0
  85. package/dist/routes/activity-disclosure.js +26 -4
  86. package/dist/routes/activity.js +2 -1
  87. package/dist/routes/mcp.js +32 -13
  88. package/dist/routes/ui-config.js +1 -1
  89. package/dist/routes/ui-data.js +3 -2
  90. package/dist/runtime/native-storage.js +12 -0
  91. package/dist/runtime/request-reads.js +66 -14
  92. package/dist/runtime/request-scope.js +2 -1
  93. package/dist/skill-origin.js +16 -0
  94. package/dist/skills.js +132 -43
  95. package/dist/storage/d1-driver.js +33 -5
  96. package/dist/storage/keys.js +7 -1
  97. package/dist/storage/sql-native.js +60 -3
  98. package/dist/storage/sql.js +6 -0
  99. package/dist/timing-trace.js +84 -0
  100. package/dist/types.d.ts +5 -0
  101. package/dist/url-safety.js +38 -20
  102. package/dist/utility-recovery.js +33 -0
  103. package/dist/version.d.ts +1 -1
  104. package/dist/version.js +1 -1
  105. package/dist/worker.js +33 -8
  106. package/documentation/architecture.md +48 -1
  107. package/documentation/auth.md +116 -36
  108. package/documentation/code-mode.md +61 -46
  109. package/documentation/deploying.md +25 -1
  110. package/documentation/meta-tools.md +113 -11
  111. package/documentation/operating.md +86 -1
  112. package/documentation/operator-ui.md +51 -2
  113. package/documentation/providers.md +16 -14
  114. package/package.json +10 -8
  115. package/templates/node/package.json +1 -1
@@ -1,9 +1,68 @@
1
1
  // Node-only: owned undici dispatcher and Node stream adapters, reached via /node.
2
+ import { Resolver } from "node:dns/promises";
3
+ import { learnedDestination } from "../guarded-transport.js";
4
+ import { learnedUrlRefusal } from "../url-safety.js";
5
+ import { OAuthRequestNotSentError } from "../auth/oauth-request-not-sent.js";
2
6
  import { Readable } from "node:stream";
3
7
  import { isIP } from "node:net";
4
8
  import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
5
9
  import { Client, ProxyAgent } from "undici";
6
10
  import { ConnectorCallError } from "../errors.js";
11
+ /** Each lookup owns its DNS channels; cancellation closes them before returning. */
12
+ async function resolveAddresses(hostname, signal) {
13
+ const resolver = new Resolver();
14
+ const cancel = () => resolver.cancel();
15
+ signal.throwIfAborted();
16
+ signal.addEventListener("abort", cancel, { once: true });
17
+ try {
18
+ const results = await Promise.allSettled([resolver.resolve4(hostname), resolver.resolve6(hostname)]);
19
+ signal.throwIfAborted();
20
+ for (const result of results) {
21
+ if (result.status === "rejected" && !["ENODATA", "ENOTFOUND"].includes(result.reason?.code))
22
+ throw result.reason;
23
+ }
24
+ return results.flatMap((result, index) => result.status === "fulfilled" ? result.value.map((address) => ({ address, family: index === 0 ? 4 : 6 })) : []);
25
+ }
26
+ finally {
27
+ signal.removeEventListener("abort", cancel);
28
+ resolver.cancel();
29
+ }
30
+ }
31
+ /** Observe even a misbehaving injected resolver, without holding the request open. */
32
+ async function boundedLookup(lookup, hostname, requestSignal) {
33
+ const controller = new AbortController();
34
+ const abort = () => controller.abort(requestSignal?.reason);
35
+ const timer = setTimeout(() => controller.abort(new Error("DNS resolution timed out.")), 10_000);
36
+ requestSignal?.addEventListener("abort", abort, { once: true });
37
+ let onAbort;
38
+ let resolution;
39
+ try {
40
+ if (requestSignal?.aborted)
41
+ abort();
42
+ controller.signal.throwIfAborted();
43
+ resolution = lookup(hostname, controller.signal);
44
+ return await Promise.race([
45
+ resolution,
46
+ new Promise((_, reject) => {
47
+ onAbort = () => reject(controller.signal.reason);
48
+ controller.signal.addEventListener("abort", onAbort, { once: true });
49
+ if (controller.signal.aborted)
50
+ onAbort();
51
+ }),
52
+ ]);
53
+ }
54
+ finally {
55
+ clearTimeout(timer);
56
+ requestSignal?.removeEventListener("abort", abort);
57
+ if (onAbort)
58
+ controller.signal.removeEventListener("abort", onAbort);
59
+ controller.abort();
60
+ // Drain the owned resolver after cancelling its channels (INV-7). Test
61
+ // injections may ignore cancellation, but cannot hold send() open.
62
+ if (lookup === resolveAddresses)
63
+ await resolution?.catch(() => { });
64
+ }
65
+ }
7
66
  /** Pull-based bridge: Node 22's Readable.toWeb can enqueue after Web cancellation. */
8
67
  function responseBody(body, signal) {
9
68
  const chunks = body[Symbol.asyncIterator]();
@@ -43,7 +102,7 @@ function responseBody(body, signal) {
43
102
  * HTTP/2 REFUSED_STREAM and pipeline recovery paths, for every method.
44
103
  * The response/abort owns the client until the body ends or is cancelled.
45
104
  */
46
- export function createNodeGuardedTransport(proxy) {
105
+ export function createNodeGuardedTransport(proxy, lookup = resolveAddresses) {
47
106
  let proxyUri;
48
107
  let proxyToken;
49
108
  if (proxy !== undefined) {
@@ -68,7 +127,41 @@ export function createNodeGuardedTransport(proxy) {
68
127
  return {
69
128
  delaysDispatch: true,
70
129
  async send(url, init, beforeDispatch = () => { }) {
130
+ const configured = init[learnedDestination];
131
+ let pinned;
132
+ if (configured && url.origin !== configured.origin) {
133
+ const refuse = () => new OAuthRequestNotSentError("connector_call_failed", "The learned URL resolved to an unsafe destination; nothing was sent.", { retryable: false });
134
+ if (learnedUrlRefusal(configured, url))
135
+ throw refuse();
136
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
137
+ let addresses;
138
+ try {
139
+ addresses = isIP(hostname)
140
+ ? [{ address: hostname, family: isIP(hostname) }]
141
+ : await boundedLookup(lookup, hostname, init.signal);
142
+ }
143
+ catch {
144
+ init.signal?.throwIfAborted();
145
+ throw refuse();
146
+ }
147
+ init.signal?.throwIfAborted();
148
+ if (addresses.length === 0 ||
149
+ addresses.some(({ address, family }) => {
150
+ if (!isIP(address) || isIP(address) !== family)
151
+ return true;
152
+ const resolved = new URL(url);
153
+ resolved.hostname = family === 6 ? `[${address}]` : address;
154
+ return learnedUrlRefusal(configured, resolved) !== undefined;
155
+ }))
156
+ throw refuse();
157
+ pinned = addresses[0];
158
+ }
159
+ const destination = new URL(url);
160
+ if (pinned && proxyUri)
161
+ destination.hostname = pinned.family === 6 ? `[${pinned.address}]` : pinned.address;
71
162
  const framed = new Request(url, init);
163
+ if (pinned)
164
+ framed.headers.set("host", url.host);
72
165
  if (!framed.headers.has("user-agent"))
73
166
  framed.headers.set("user-agent", "connecta");
74
167
  let dispatched = false;
@@ -86,6 +179,7 @@ export function createNodeGuardedTransport(proxy) {
86
179
  uri: proxyUri,
87
180
  ...(proxyToken ? { token: proxyToken } : {}),
88
181
  proxyTunnel: true,
182
+ ...(pinned ? { requestTls: { servername: isIP(url.hostname) ? "" : url.hostname } } : {}),
89
183
  ...clientOptions,
90
184
  interceptors: { Agent: [], Client: [] },
91
185
  // Neither side of CONNECT uses a Pool, interceptor or global dispatcher.
@@ -99,7 +193,7 @@ export function createNodeGuardedTransport(proxy) {
99
193
  // ProxyAgent 6 explicitly sets SNI to the proxy hostname.
100
194
  // Node 26 rejects IP SNI. Omit it for literal IPs; tls.connect
101
195
  // still checks the certificate against its connection host.
102
- connect({ ...parameters, ...(name && isIP(name.replace(/^\[|\]$/g, "")) ? { servername: "" } : {}) }, callback);
196
+ connect({ ...parameters, ...(name ? { servername: isIP(name.replace(/^\[|\]$/g, "")) ? "" : name } : {}) }, callback);
103
197
  },
104
198
  }));
105
199
  },
@@ -135,7 +229,19 @@ export function createNodeGuardedTransport(proxy) {
135
229
  });
136
230
  },
137
231
  })
138
- : new Client(url.origin, clientOptions);
232
+ : new Client(url.origin, {
233
+ ...clientOptions,
234
+ ...(pinned
235
+ ? {
236
+ connect: {
237
+ // No second resolver can replace the checked address. The Client
238
+ // retains the URL hostname for SNI and certificate verification.
239
+ lookup: (_hostname, _options, callback) => callback(null, pinned.address, pinned.family),
240
+ autoSelectFamily: false,
241
+ },
242
+ }
243
+ : {}),
244
+ });
139
245
  const destroy = () => Promise.allSettled([tunnelClient?.destroy(), client.destroy()]);
140
246
  const abort = () => {
141
247
  void destroy();
@@ -146,7 +252,7 @@ export function createNodeGuardedTransport(proxy) {
146
252
  if (!proxyUri)
147
253
  dispatch();
148
254
  const answer = await client.request({
149
- origin: url.origin,
255
+ origin: destination.origin,
150
256
  path: `${url.pathname}${url.search}`,
151
257
  method: framed.method,
152
258
  headers: Object.fromEntries(framed.headers),
@@ -30,7 +30,7 @@ import * as HttpClientResponse from "effect/http/HttpClientResponse";
30
30
  import { readRequest, renewedAfterUnauthorized } from "../provider-call.js";
31
31
  import { renewableGrantFor } from "../auth/renewable-grant.js";
32
32
  import { ConnectorCallError } from "../errors.js";
33
- import { responseRequest } from "./client.js";
33
+ import { failedResponseRequest, responseRequest } from "./client.js";
34
34
  import { Refusal, RequestRefused } from "./errors.js";
35
35
  import { Dispatches, Signal, duringCall } from "./services.js";
36
36
  /** The calling owner's renewable token, as the operations a transport needs from it. */
@@ -53,6 +53,9 @@ export function grantFromOAuth(oauth) {
53
53
  return {
54
54
  token: use(grant ? () => grant.token() : missing),
55
55
  refresh: (stale) => use(grant ? () => grant.refresh(stale) : missing),
56
+ ...(grant?.observe
57
+ ? { observe: (token, outcome) => use(() => grant.observe(token, outcome)) }
58
+ : {}),
56
59
  rejected: use(grant ? () => grant.rejected() : missing),
57
60
  };
58
61
  }
@@ -80,12 +83,21 @@ export function refreshOnce(guarded, options) {
80
83
  return yield* Effect.fail(refusal);
81
84
  });
82
85
  const execute = (request) => Effect.gen(function* () {
83
- const response = yield* inner.execute(request).pipe(Effect.catchTag("ResponseTooLarge", (failure) => {
86
+ const grant = yield* Grant;
87
+ const observeOversized = (failure) => {
88
+ const sent = failedResponseRequest(failure);
89
+ return responseRequest(failure)?.status === 401 && sent && grant.observe
90
+ ? grant.observe(sentToken(sent), "refused")
91
+ : Effect.void;
92
+ };
93
+ const response = yield* inner.execute(request).pipe(Effect.tapErrorTag("ResponseTooLarge", observeOversized), Effect.catchTag("ResponseTooLarge", (failure) => {
84
94
  const entry = responseRequest(failure);
85
95
  return options?.challengeMaxBytes !== undefined && entry?.status === 401
86
96
  ? challengeFailure(entry)
87
97
  : Effect.fail(failure);
88
98
  }));
99
+ if (grant.observe && (response.status === 401 || (response.status >= 200 && response.status < 300)))
100
+ yield* grant.observe(sentToken(response.request), response.status === 401 ? "refused" : "accepted");
89
101
  if (response.status !== 401)
90
102
  return response;
91
103
  // Read under the ceiling now, so nothing is left open while renewing.
@@ -94,7 +106,6 @@ export function refreshOnce(guarded, options) {
94
106
  .pipe(Effect.catchTag("ResponseTooLarge", (failure) => options?.challengeMaxBytes !== undefined
95
107
  ? challengeFailure(responseRequest(response))
96
108
  : Effect.fail(failure)));
97
- const grant = yield* Grant;
98
109
  const stale = sentToken(response.request);
99
110
  const entry = responseRequest(response);
100
111
  if (!readRequest(entry ?? {}) && options?.writeRenewal === "discard") {
@@ -135,11 +146,11 @@ export function refreshOnce(guarded, options) {
135
146
  return yield* Effect.fail(new RequestRefused({ message: "This call has ended; no renewal work may continue." }));
136
147
  renewedAfterUnauthorized(entry, stale, fresh);
137
148
  // `authenticate` runs again on the replay and reads the renewed token.
138
- const replay = yield* inner
139
- .execute(request)
140
- .pipe(Effect.catchTag("ResponseTooLarge", (failure) => options?.challengeMaxBytes !== undefined && responseRequest(failure)?.status === 401
149
+ const replay = yield* inner.execute(request).pipe(Effect.tapErrorTag("ResponseTooLarge", observeOversized), Effect.catchTag("ResponseTooLarge", (failure) => options?.challengeMaxBytes !== undefined && responseRequest(failure)?.status === 401
141
150
  ? grant.rejected
142
151
  : Effect.fail(failure)));
152
+ if (grant.observe && (replay.status === 401 || (replay.status >= 200 && replay.status < 300)))
153
+ yield* grant.observe(sentToken(replay.request), replay.status === 401 ? "refused" : "accepted");
143
154
  if (replay.status !== 401)
144
155
  return replay;
145
156
  if (options?.replayRejection === "answer") {
package/dist/index.js CHANGED
@@ -92,14 +92,16 @@ function warnInsecureConfig(config) {
92
92
  : "") +
93
93
  "Configure Clerk, Cloudflare Access on Workers, or accessTokens(storage) to gate access.");
94
94
  }
95
- // Unset publicUrl with OAuth connectors: the downstream redirect_uri is
96
- // derived per-request from the attacker-influenced inbound Host header.
97
- if (oauthConnectors.length > 0 && !config.publicUrl) {
98
- logger.warn("[connecta] publicUrl is unset while OAuth connectors are configured: " +
99
- "the downstream OAuth redirect_uri is derived per-request from the " +
100
- "inbound Host header, so an attacker who controls that header can point " +
101
- "it at their own host and capture the authorization code. Set " +
102
- "`publicUrl` to a fixed https origin.");
95
+ // Unset publicUrl: everything built from the deployment's own URL follows the
96
+ // inbound request, whose Host (and X-Forwarded-Proto on Node) the caller
97
+ // chooses. Clerk without any publicUrl has already refused construction.
98
+ if (!config.publicUrl && (oauthConnectors.length > 0 || inboundAuth.length > 0)) {
99
+ logger.warn("[connecta] publicUrl is unset: the URLs this deployment hands out, including " +
100
+ "protected-resource metadata, authorization handoffs and the downstream OAuth " +
101
+ "redirect_uri, are derived per-request from the inbound Host header (and " +
102
+ "X-Forwarded-Proto on Node). A caller who controls those can point them at " +
103
+ "their own host and, with OAuth connectors, capture the authorization code. " +
104
+ "Set `publicUrl` to a fixed https origin.");
103
105
  }
104
106
  // Branding URLs that failed their scheme gate. Rendering silently falls back
105
107
  // (a bad URL must not take the page down), so this warning is the only way an
@@ -156,19 +158,6 @@ function warnInsecureConfig(config) {
156
158
  `action and POST /ui/credentials/${connector.id}/test answers 400 ` +
157
159
  "until the matching hook is implemented.");
158
160
  }
159
- // A vault written before seal/open existed encrypts credentials but cannot
160
- // seal downstream OAuth state, so tokens stay plaintext at rest beside them.
161
- const vault = config.vault;
162
- if (vault && (typeof vault.seal !== "function" || typeof vault.open !== "function")) {
163
- for (const connector of config.connectors) {
164
- if (!connector.startAuth)
165
- continue;
166
- logger.warn(`[connecta] connector "${connector.id}" stores its downstream OAuth ` +
167
- "tokens, client registration, and PKCE verifier as plaintext: the " +
168
- "configured vault implements no `seal`/`open`. Use " +
169
- "encryptedCredentialVault(...) or add both methods to seal them.");
170
- }
171
- }
172
161
  // OAuth connectors whose callback cannot perform a state/CSRF check. The
173
162
  // public route refuses every callback for these connectors rather than hand
174
163
  // an unverified code to finishAuth, so this warning explains why auth cannot
@@ -0,0 +1,28 @@
1
+ import { keepErrorName } from "./redaction-identity.js";
2
+ export class InvocationFailure extends Error {
3
+ details;
4
+ code;
5
+ retryable;
6
+ retryAfterMs;
7
+ connector;
8
+ operation;
9
+ recovery;
10
+ nextAction;
11
+ retry;
12
+ data;
13
+ constructor(details) {
14
+ super(details.message);
15
+ this.details = details;
16
+ this.name = "InvocationFailure";
17
+ this.code = details.code;
18
+ this.retryable = details.retryable;
19
+ this.retryAfterMs = details.retryAfterMs;
20
+ this.connector = details.connector;
21
+ this.operation = details.operation;
22
+ this.recovery = details.recovery;
23
+ this.nextAction = details.nextAction;
24
+ this.retry = details.retry;
25
+ this.data = details;
26
+ }
27
+ }
28
+ keepErrorName(InvocationFailure, "InvocationFailure");
@@ -1,3 +1,5 @@
1
+ import { utilityRecovery } from "./utility-recovery.js";
2
+ import { traceFor } from "./timing-trace.js";
1
3
  import { routePhrases } from "./agent-routes.js";
2
4
  import { authCall, registerAuthMethod } from "./auth/native.js";
3
5
  import { withAuthorizationHandoffEffect } from "./authorization-handoff.js";
@@ -20,7 +22,6 @@ import { forgetProviderFailure, isKnownProviderFailure, isSettledWrite, keyProte
20
22
  import { classifyWriteOutcome, settleWrite, settleWriteResult } from "./write-outcome.js";
21
23
  import { classificationDigest, recordAuthFailure, recordCallEntry, replayClassificationDigest, resolveInvocationAuthEffect, } from "./invocation-auth.js";
22
24
  import { downstreamContinuation, recordDownstreamArgumentEcho, isDownstreamInputResult, assertDownstreamOutputSafe, } from "./downstream-input-context.js";
23
- import { keepErrorName } from "./redaction-identity.js";
24
25
  function defined(values) {
25
26
  return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined));
26
27
  }
@@ -115,33 +116,7 @@ function answeredFailure(error, write, call) {
115
116
  return error instanceof DownstreamToolError || isKnownProviderFailure(error);
116
117
  return knownCallFailure(call, error);
117
118
  }
118
- export class InvocationFailure extends Error {
119
- details;
120
- code;
121
- retryable;
122
- retryAfterMs;
123
- connector;
124
- operation;
125
- recovery;
126
- nextAction;
127
- retry;
128
- data;
129
- constructor(details) {
130
- super(details.message);
131
- this.details = details;
132
- this.name = "InvocationFailure";
133
- this.code = details.code;
134
- this.retryable = details.retryable;
135
- this.retryAfterMs = details.retryAfterMs;
136
- this.connector = details.connector;
137
- this.operation = details.operation;
138
- this.recovery = details.recovery;
139
- this.nextAction = details.nextAction;
140
- this.retry = details.retry;
141
- this.data = details;
142
- }
143
- }
144
- keepErrorName(InvocationFailure, "InvocationFailure");
119
+ export { InvocationFailure } from "./invocation-failure.js";
145
120
  const withoutExample = ({ example: _example, ...stated }) => stated;
146
121
  /**
147
122
  * Shared downstream invocation engine. Address/catalog resolution is delegated
@@ -244,7 +219,9 @@ export class InvocationService {
244
219
  };
245
220
  const enrich = (error, target, settled) => {
246
221
  if (!target)
247
- return error;
222
+ return ["unavailable", "catalog_lookup_failed", "unsupported_capability"].includes(error.code)
223
+ ? { ...error, nextAction: error.nextAction ?? utilityRecovery(error.code, error.message).nextAction }
224
+ : error;
248
225
  const recovery = recoveryState.facts;
249
226
  const enteredWrite = dispatchedToConnector &&
250
227
  ((resolved !== undefined && resolved.definition.classification !== "read") || sentSourceWrite());
@@ -303,7 +280,16 @@ export class InvocationService {
303
280
  // retryable, and no advice below may tell the agent to retry it.
304
281
  const unknownWrite = mayHaveWritten && classifyWriteOutcome({ ok: false, dispatched: true, answered, error }) === "unknown";
305
282
  if (unknownWrite)
306
- error = { ...error, retryable: false, writeOutcome: "unknown" };
283
+ error = {
284
+ ...error,
285
+ retryable: false,
286
+ writeOutcome: "unknown",
287
+ ...(!recovery && error.retry === undefined
288
+ ? {
289
+ retry: "No safe replay action exists. Re-read the target to reconcile whether the write took effect.",
290
+ }
291
+ : {}),
292
+ };
307
293
  // The failure's advice to send it again was kept apart until now, and
308
294
  // joins its message only where the outcome allows it: never on a write
309
295
  // whose outcome is unknown, whatever redaction did to the message.
@@ -334,9 +320,22 @@ export class InvocationService {
334
320
  error = { ...error, code: "downstream_oauth_required" };
335
321
  }
336
322
  switch (error.code) {
323
+ case "unsupported_capability":
324
+ return unknownWrite
325
+ ? error
326
+ : {
327
+ ...error,
328
+ nextAction: utilityRecovery(error.code, "The requested capability is unsupported.").nextAction,
329
+ };
337
330
  case "input_required_unsupported":
338
331
  if (context.processInputRequired)
339
- return error;
332
+ return unknownWrite
333
+ ? error
334
+ : {
335
+ ...error,
336
+ nextAction: error.nextAction ??
337
+ utilityRecovery(error.code, "The requested input capability is unsupported.").nextAction,
338
+ };
340
339
  // Resending could apply the write twice: reconcile first.
341
340
  if (unknownWrite)
342
341
  return {
@@ -407,8 +406,13 @@ export class InvocationService {
407
406
  retry: "Ask the provider's resource owner or administrator to grant the required permission or scope, then retry. Repeating the call or reconnecting alone will not grant access.",
408
407
  };
409
408
  case "invalid_args":
410
- if (!error.validation || error.writeOutcome === "unknown")
409
+ if (error.writeOutcome === "unknown")
411
410
  return error;
411
+ if (!error.validation)
412
+ return {
413
+ ...error,
414
+ nextAction: this.catalog.searchRecovery({ query: target.toolName, connector: target.connector.id }, "Inspect the current input schema and correct the arguments before a fresh call."),
415
+ };
412
416
  return {
413
417
  ...error,
414
418
  // An example comes only from the published schema, never a connector's copy of the
@@ -427,6 +431,27 @@ export class InvocationService {
427
431
  }, "Inspect the current input shape if the validation findings are not sufficient."),
428
432
  retry: `Correct the listed arguments and retry ` + `${target.connector.id}.${target.toolName}.`,
429
433
  };
434
+ case "not_found":
435
+ if (unknownWrite)
436
+ return error;
437
+ return {
438
+ ...error,
439
+ retry: "No automatic retry action exists. Verify the resource id, account and visibility with a read before a fresh call.",
440
+ };
441
+ case "unavailable":
442
+ case "catalog_lookup_failed":
443
+ if (unknownWrite)
444
+ return error;
445
+ // The continuation nonce is spent. Let the input boundary supply
446
+ // its fresh-call action instead of generic availability advice.
447
+ if (context.source !== "execute_code" &&
448
+ downstreamContinuation(this.catalog.requestScope, target.connector.id, `${target.connector.id}.${target.toolName}`))
449
+ return error;
450
+ return {
451
+ ...error,
452
+ nextAction: error.nextAction ?? utilityRecovery(error.code, error.message).nextAction,
453
+ retry: "No immediate repair action exists on this endpoint. Ask the operator to check connector availability; a read can be tried again after recovery.",
454
+ };
430
455
  default:
431
456
  return error;
432
457
  }
@@ -446,6 +471,13 @@ export class InvocationService {
446
471
  }
447
472
  }
448
473
  const diagnostics = timing();
474
+ const trace = traceFor(this.catalog.requestScope);
475
+ if (trace) {
476
+ trace.record("catalog", diagnostics.catalogMs);
477
+ trace.record("admission", diagnostics.admissionMs);
478
+ trace.record("connector", diagnostics.connectorMs);
479
+ trace.record("result", diagnostics.resultProcessingMs, resultBytes ?? 0);
480
+ }
449
481
  const target = resolved ?? activityTarget;
450
482
  if (target && (error.code === "auth_required" || error.code === "downstream_oauth_required")) {
451
483
  recordAuthFailure(this.catalog.requestScope, target.connector.id, (resolved !== undefined && !isWriteLike(resolved.definition) && resolved.classificationFresh === true) ||
@@ -562,7 +594,7 @@ export class InvocationService {
562
594
  const connectorReadOnly = target.connector.readOnly === true;
563
595
  const refusal = writeRefusal(target.definition.classification, context.source, context.readOnly || connectorReadOnly);
564
596
  if (refusal === "read_only_endpoint") {
565
- return framingError("read_only_endpoint", `Tool "${canonicalAddress}" is a write, and ${connectorReadOnly ? `connector "${target.connector.id}"` : "this endpoint"} is read-only. Nothing here can send it.`);
597
+ return framingError("read_only_endpoint", `Tool "${canonicalAddress}" is a write, and ${connectorReadOnly ? `connector "${target.connector.id}"` : "this endpoint"} is read-only. No write route exists ${connectorReadOnly ? "for this connection" : "on this endpoint"}. Ask the operator for a writable connection if writes are required.`);
566
598
  }
567
599
  if (refusal === "destructive_tool_requires_approval") {
568
600
  return framingError("destructive_tool_requires_approval", `Tool "${canonicalAddress}" is a write. ${routePhrases(context).writeNeedsApproval}`);
@@ -807,6 +839,13 @@ export class InvocationService {
807
839
  try {
808
840
  const value = sentSecrets.redact(processed.value, "envelope");
809
841
  const diagnostics = timing();
842
+ const trace = traceFor(this.catalog.requestScope);
843
+ if (trace) {
844
+ trace.record("catalog", diagnostics.catalogMs);
845
+ trace.record("admission", diagnostics.admissionMs);
846
+ trace.record("connector", diagnostics.connectorMs);
847
+ trace.record("result", diagnostics.resultProcessingMs, resultBytes ?? 0);
848
+ }
810
849
  const friction = context.activityFriction?.(value);
811
850
  record("success", friction ? { friction } : {});
812
851
  return {
@@ -26,7 +26,7 @@ export interface ResolvedPool {
26
26
  members: readonly Member[];
27
27
  /** Trimmed `owner`: the Workspace address agents in this pool act as. */
28
28
  owner?: string;
29
- /** Trimmed `description`, appended to this pool's MCP instructions. */
29
+ /** Trimmed `description`, placed before this pool's MCP routing instructions. */
30
30
  description?: string;
31
31
  /** Present for a native pool; absent is the six meta-tools. */
32
32
  native?: {
@@ -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
@@ -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;