@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,6 +1,8 @@
1
1
  // The plain runtime slot keeps the HTTP kit and Node sender off the root
2
2
  // and Worker graphs. /node installs its sender when listen() starts serving.
3
3
  import { ConnectorCallError } from "./errors.js";
4
+ /** Internal request marker: only downstream-learned URL fetches set this. */
5
+ export const learnedDestination = Symbol("connecta.learnedDestination");
4
6
  let owned;
5
7
  /** Runtime configuration, never request data. Returns the previous slot for isolated fixtures. */
6
8
  export function configureGuardedTransport(transport) {
@@ -30,6 +30,12 @@ import { RedirectRefused, Refusal, RequestRefused, ResendRefused, ResponseTooLar
30
30
  import { Classification, Dispatches, IdempotencyKey, Ledger, RequestFacts, Secrets, Signal, WriteCapacity, reserveWrite, writeRequestKey, } from "./services.js";
31
31
  const decoder = new TextDecoder();
32
32
  const responseEntries = new WeakMap();
33
+ // Private request identity for middleware observing a rejected response. Never
34
+ // attach headers or body to the failure or its payload-free ledger entry.
35
+ const failedRequests = new WeakMap();
36
+ export function failedResponseRequest(failure) {
37
+ return failedRequests.get(failure);
38
+ }
33
39
  /** The core's physical request, also available to direct HttpClient middleware. */
34
40
  export function responseRequest(response) {
35
41
  return responseEntries.get(response);
@@ -216,6 +222,7 @@ export function guardedClient(options) {
216
222
  const refused = (failure) => {
217
223
  dispatch.failure = failure;
218
224
  responseEntries.set(failure, entry);
225
+ failedRequests.set(failure, request);
219
226
  return Effect.fail(failure);
220
227
  };
221
228
  const response = yield* Effect.tryPromise({
@@ -1171,6 +1171,8 @@ export function hatch(options) {
1171
1171
  const send = options.binding
1172
1172
  ? options.binding.send(call(), sendOptions)
1173
1173
  : connection.sendPath(request, sendOptions);
1174
+ // Both send paths share the binding result contract. Keep its optional
1175
+ // metadata typed even when the plain HTTP branch omits those properties.
1174
1176
  const answer = yield* send.pipe(Effect.catch((failure) => Effect.gen(function* () {
1175
1177
  if (!key || args.idempotencyKey)
1176
1178
  return yield* Effect.fail(failure);
@@ -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";
@@ -16,11 +18,10 @@ import { recoveryRecord, recordRecoveryFacts } from "./call-recovery.js";
16
18
  import { callFromContext, contextView, viewServices, Credentials } from "./runtime/call.js";
17
19
  import { chargeCallBudget } from "./runtime/call-admission.js";
18
20
  import { callEntry } from "./runtime/call-entry.js";
19
- import { forgetProviderFailure, isKnownProviderFailure, isSettledWrite, keyProtectsCall, knownCallFailure, onlyAuxiliaryRequests, knownProviderFailure, openLogicalCall, } from "./provider-call.js";
21
+ import { forgetProviderFailure, isKnownProviderFailure, isSettledWrite, keyProtectsCall, knownCallFailure, refusalRequest, onlyAuxiliaryRequests, knownProviderFailure, openLogicalCall, } from "./provider-call.js";
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());
@@ -288,10 +265,13 @@ export class InvocationService {
288
265
  }
289
266
  if (mayHaveWritten &&
290
267
  (error.code === "auth_required" || error.code === "downstream_oauth_required") &&
291
- !logicalCall?.sent.some((request) => request.dispatched)) {
268
+ !logicalCall?.sent.some((request) => request.dispatched) &&
269
+ !(answered && refusalRequest(error)?.dispatched === false)) {
292
270
  // An empty resource ledger cannot settle this auth failure. Keep
293
271
  // actual resource-refusal provenance, but withdraw an empty ledger's
294
272
  // known mark before composing advice and counting program writes.
273
+ // A boundary-owned physical pre-dispatch refusal is positive proof;
274
+ // its private provenance cannot be supplied by error fields.
295
275
  answered = false;
296
276
  forgetProviderFailure(error);
297
277
  }
@@ -300,7 +280,16 @@ export class InvocationService {
300
280
  // retryable, and no advice below may tell the agent to retry it.
301
281
  const unknownWrite = mayHaveWritten && classifyWriteOutcome({ ok: false, dispatched: true, answered, error }) === "unknown";
302
282
  if (unknownWrite)
303
- 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
+ };
304
293
  // The failure's advice to send it again was kept apart until now, and
305
294
  // joins its message only where the outcome allows it: never on a write
306
295
  // whose outcome is unknown, whatever redaction did to the message.
@@ -331,9 +320,22 @@ export class InvocationService {
331
320
  error = { ...error, code: "downstream_oauth_required" };
332
321
  }
333
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
+ };
334
330
  case "input_required_unsupported":
335
331
  if (context.processInputRequired)
336
- 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
+ };
337
339
  // Resending could apply the write twice: reconcile first.
338
340
  if (unknownWrite)
339
341
  return {
@@ -404,8 +406,13 @@ export class InvocationService {
404
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.",
405
407
  };
406
408
  case "invalid_args":
407
- if (!error.validation || error.writeOutcome === "unknown")
409
+ if (error.writeOutcome === "unknown")
408
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
+ };
409
416
  return {
410
417
  ...error,
411
418
  // An example comes only from the published schema, never a connector's copy of the
@@ -424,6 +431,27 @@ export class InvocationService {
424
431
  }, "Inspect the current input shape if the validation findings are not sufficient."),
425
432
  retry: `Correct the listed arguments and retry ` + `${target.connector.id}.${target.toolName}.`,
426
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
+ };
427
455
  default:
428
456
  return error;
429
457
  }
@@ -443,6 +471,13 @@ export class InvocationService {
443
471
  }
444
472
  }
445
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
+ }
446
481
  const target = resolved ?? activityTarget;
447
482
  if (target && (error.code === "auth_required" || error.code === "downstream_oauth_required")) {
448
483
  recordAuthFailure(this.catalog.requestScope, target.connector.id, (resolved !== undefined && !isWriteLike(resolved.definition) && resolved.classificationFresh === true) ||
@@ -559,7 +594,7 @@ export class InvocationService {
559
594
  const connectorReadOnly = target.connector.readOnly === true;
560
595
  const refusal = writeRefusal(target.definition.classification, context.source, context.readOnly || connectorReadOnly);
561
596
  if (refusal === "read_only_endpoint") {
562
- 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.`);
563
598
  }
564
599
  if (refusal === "destructive_tool_requires_approval") {
565
600
  return framingError("destructive_tool_requires_approval", `Tool "${canonicalAddress}" is a write. ${routePhrases(context).writeNeedsApproval}`);
@@ -804,6 +839,13 @@ export class InvocationService {
804
839
  try {
805
840
  const value = sentSecrets.redact(processed.value, "envelope");
806
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
+ }
807
849
  const friction = context.activityFriction?.(value);
808
850
  record("success", friction ? { friction } : {});
809
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?: {