@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
@@ -3,8 +3,8 @@
3
3
  // served without connecting (INV-10).
4
4
  import { routePhrases } from "../../agent-routes.js";
5
5
  import { authView } from "../../auth/native-call.js";
6
- import { authFlow, authCall, authInvoke, authMethod, authStorageMany } from "../../auth/native.js";
7
- import { openRequest, viewServices, viewSignal, viewReads, serviceFor } from "../../runtime/call.js";
6
+ import { authFlow, authCall, authInvoke, authMethod } from "../../auth/native.js";
7
+ import { openRequest, viewServices, viewSignal } from "../../runtime/call.js";
8
8
  import { secretDigest } from "../../storage-digest.js";
9
9
  import { downstreamClientMetadata, downstreamRedirectUri, remoteClientAuthMethod, selfHostedClientUrl, } from "../../auth/downstream-client-metadata.js";
10
10
  import { Client, SdkErrorCode, SdkHttpError, UnauthorizedError } from "@modelcontextprotocol/client";
@@ -12,7 +12,7 @@ import { Deferred, Effect, Exit, Scope } from "effect";
12
12
  import { KvOAuthProvider, refreshCoordinatorsByPartition } from "../../auth/downstream-oauth.js";
13
13
  import { trackOAuthStartReset } from "../../auth/oauth-start-reset.js";
14
14
  import { sentSecretsFor } from "../../sent-secrets.js";
15
- import { catalogClientOptions, observeCatalogChange } from "../../catalog-cache.js";
15
+ import { catalogClientOptions, prefetchCatalog, observeCatalogChange } from "../../catalog-cache.js";
16
16
  import { ConnectorCallError, resendAdvice } from "../../errors.js";
17
17
  import { carryFailureFacts, failureRecord, logFailure } from "../../operator-record.js";
18
18
  import { CONNECTA_VERSION } from "../../version.js";
@@ -23,13 +23,42 @@ import { downstreamInputCapabilities } from "../../downstream-input-context.js";
23
23
  import { detach, runEdge } from "../../runtime/run.js";
24
24
  import { requestPrincipalFor } from "../../request-principal.js";
25
25
  import { callerOf } from "../../connector-caller.js";
26
- import { CatalogPolicy } from "../../catalog-policy.js";
27
- import { negotiationKeys, responseCacheKeys } from "../../storage/keys.js";
26
+ import { negotiationKeys } from "../../storage/keys.js";
28
27
  import { forgetNegotiation, readNegotiation, storeNegotiation } from "../negotiation-cache.js";
29
28
  import { carriesIllegalHeaderChar, credentialHeaderValue } from "./config.js";
30
29
  import { AUTH_OR_THROTTLE, promised, recordWireErrors, requiresAuthorization } from "./errors.js";
31
30
  import { MAX_TOOL_PAGES } from "./catalog.js";
32
31
  import { SKILLS_EXTENSION } from "./resources.js";
32
+ /** Retain every abandoned lease release until the final scope close joins it. */
33
+ export function retainCleanup(state, release) {
34
+ const pending = detach(release);
35
+ state.cleanup.add(pending);
36
+ if (state.closing)
37
+ state.closing = Promise.all([state.closing, pending]).then(() => { });
38
+ // Register at the release itself as well: a late connect can acquire a
39
+ // session after the owner's first close has already settled.
40
+ try {
41
+ state.defer?.(pending);
42
+ }
43
+ catch {
44
+ // Runtime hooks are best-effort; the bounded cleanup is already running.
45
+ }
46
+ void pending.then(() => state.cleanup.delete(pending));
47
+ return pending;
48
+ }
49
+ /** Scope.closeUnsafe returns nothing on a second close, even during release. */
50
+ export function closeConnectionScope(state) {
51
+ if (!state.closing) {
52
+ const release = Scope.closeUnsafe(state.scope, Exit.void);
53
+ if (release)
54
+ retainCleanup(state, release);
55
+ state.closing = (async () => {
56
+ while (state.cleanup.size)
57
+ await Promise.all(state.cleanup);
58
+ })();
59
+ }
60
+ return state.closing;
61
+ }
33
62
  /** One connector's connection states, credential reads and connect attempts. */
34
63
  export function remoteMcpConnection(rt) {
35
64
  const { requestAuth, forwarding, id, staticClient, clientMetadataUrl, opts, oauthScope, authorizationParams, destination, isOauth, extraHeaders, credentialHeader, credentialScheme, skillsEnabled, principalPolicy, credentialAuth, } = rt;
@@ -105,6 +134,9 @@ export function remoteMcpConnection(rt) {
105
134
  if (!state) {
106
135
  state = {
107
136
  scope: Scope.makeUnsafe(),
137
+ closing: null,
138
+ cleanup: new Set(),
139
+ defer: ctx.defer,
108
140
  lease: null,
109
141
  client: null,
110
142
  transport: null,
@@ -120,10 +152,8 @@ export function remoteMcpConnection(rt) {
120
152
  states.set(key, state);
121
153
  const owned = state;
122
154
  owner.own(() => {
123
- const closing = Scope.closeUnsafe(owned.scope, Exit.void);
155
+ void closeConnectionScope(owned);
124
156
  reset(owned);
125
- if (closing)
126
- detach(closing);
127
157
  });
128
158
  }
129
159
  return state;
@@ -191,7 +221,7 @@ export function remoteMcpConnection(rt) {
191
221
  reset(state);
192
222
  const release = lease && Scope.closeUnsafe(lease, Exit.void);
193
223
  if (release)
194
- detach(release);
224
+ retainCleanup(state, release);
195
225
  };
196
226
  const waitForConnection = (attempt, signal) => runEdge(Deferred.await(attempt), signal ? { signal } : {});
197
227
  const oauthConfigFor = (ctx) => isOauth
@@ -274,6 +304,7 @@ export function remoteMcpConnection(rt) {
274
304
  try {
275
305
  let epoch = "";
276
306
  let identity;
307
+ let pinnedIdentity;
277
308
  if (isOauth) {
278
309
  // Read-only grant snapshots: no in-place sealing, epoch binding or
279
310
  // refresh. Every identity check during cache acceptance re-reads the
@@ -284,6 +315,7 @@ export function remoteMcpConnection(rt) {
284
315
  epoch = snapshot.epoch;
285
316
  if (provider.isOperatorDisconnectedEpoch(epoch) || !snapshot.tokens)
286
317
  return preread;
318
+ pinnedIdentity = yield authCall(secretDigest, [ctx, JSON.stringify(snapshot.tokens)]);
287
319
  const bound = epoch;
288
320
  identity = authMethod(() => authFlow(function* () {
289
321
  const current = (yield authInvoke(provider, "peekGrant", []));
@@ -297,13 +329,15 @@ export function remoteMcpConnection(rt) {
297
329
  const digest = (yield authCall(negotiationKey, [ctx, credentialDigest, epoch]));
298
330
  // Both reads only locate cached data. Acceptance and dispatch keep
299
331
  // their fresh generation and credential checks.
300
- yield viewReads(ctx).prefetch([
301
- { storage: ctx.storage, key: negotiationKeys.verdict(digest) },
302
- {
303
- storage: serviceFor(ctx, CatalogPolicy)?.storage ?? ctx.storage,
304
- key: responseCacheKeys.generation(id),
305
- },
306
- ], (storage, keys) => authStorageMany(storage, keys));
332
+ yield authCall(prefetchCatalog, [
333
+ ctx,
334
+ id,
335
+ (yield authCall(catalogConfigFor, [ctx])),
336
+ JSON.stringify([epoch, credentialDigest]),
337
+ catalogSharing,
338
+ pinnedIdentity,
339
+ negotiationKeys.verdict(digest),
340
+ ]);
307
341
  const prior = (yield authCall(readNegotiation, [ctx, digest]));
308
342
  preread.negotiation = { digest, prior };
309
343
  if (prior?.kind === "modern")
@@ -644,13 +678,13 @@ export function remoteMcpConnection(rt) {
644
678
  state.authRequired = true;
645
679
  const release = Scope.closeUnsafe(lease, Exit.void);
646
680
  if (release)
647
- detach(release);
681
+ retainCleanup(state, release);
648
682
  }
649
683
  else {
650
684
  // Whoever abandoned this attempt closed the lease already, while
651
685
  // the connect was in flight. A session it acquired after that
652
686
  // close still owes its DELETE.
653
- detach(rt.closeConnection(held.client, held.transport, ctx.logger));
687
+ retainCleanup(state, rt.closeConnection(held.client, held.transport, ctx.logger));
654
688
  }
655
689
  if (err instanceof UnauthorizedError) {
656
690
  return Effect.fail(carryFailureFacts(err, rt.authRequiredError(ctx)));
@@ -1,5 +1,6 @@
1
1
  // The downstream OAuth surface: bounded SDK flows, early renewal before a
2
2
  // call, unchallenged consent starts, and the connector's auth operations.
3
+ import { retainCleanup } from "./connection.js";
3
4
  import { authView } from "../../auth/native-call.js";
4
5
  import { authFlow, authCall, authInvoke, authMethod } from "../../auth/native.js";
5
6
  import { viewSignal } from "../../runtime/call.js";
@@ -10,7 +11,6 @@ import { byteReadResponse } from "../../byte-read-response.js";
10
11
  import { requestSentSecrets, sentSecretsFor } from "../../sent-secrets.js";
11
12
  import { ConnectorCallError, resendAdvice, WithheldTextError } from "../../errors.js";
12
13
  import { attachFailureFacts, carryFailureFacts, OAUTH_ERROR_CODES } from "../../operator-record.js";
13
- import { detach } from "../../runtime/run.js";
14
14
  import { retryAfterMs } from "../../http-policy.js";
15
15
  import { sendCredentialPost } from "../../guarded-transport.js";
16
16
  import { UnboundOAuthFlowsError, downstreamCallError, errorKind, hasSdkPayload, ownAbortReason, requiresAuthorization, startMessage, withheldAs, } from "./errors.js";
@@ -376,7 +376,7 @@ export function remoteMcpOAuth(rt) {
376
376
  ]));
377
377
  }
378
378
  finally {
379
- detach(rt.closeConnection(null, t, ctx.logger));
379
+ retainCleanup(state, rt.closeConnection(null, t, ctx.logger));
380
380
  }
381
381
  })), true));
382
382
  connector.verifyState = authMethod((oauthState, ctx) => authFlow(function* () {
@@ -345,8 +345,11 @@ export function remoteMcpResources(rt) {
345
345
  if (client.getProtocolEra() !== "modern" ||
346
346
  !skillObject(capabilities?.resources) ||
347
347
  !skillObject(extension) ||
348
+ // The released Beta specification defines no revision setting.
349
+ // Never enable a future breaking revision under the same ID.
350
+ extension.revision !== undefined ||
348
351
  (extension.directoryRead !== undefined && typeof extension.directoryRead !== "boolean")) {
349
- throw new ConnectorCallError("connector_call_failed", "The downstream did not declare a valid Skills extension and resources capability.");
352
+ throw new ConnectorCallError("unsupported_capability", "The downstream did not declare a valid Skills extension and resources capability.");
350
353
  }
351
354
  const result = (yield authCall(run, [client, state]));
352
355
  skillScopeOpen(ctx, state);
@@ -415,7 +418,7 @@ export function remoteMcpResources(rt) {
415
418
  rt.listingContexts.set(options, ctx);
416
419
  try {
417
420
  if (!client.getServerCapabilities()?.resources)
418
- throw unadvertisedResource();
421
+ throw new ConnectorCallError("unsupported_capability", "The downstream did not declare resources support.");
419
422
  const resources = (yield authInvoke(client, "listResources", [undefined, options]));
420
423
  const templates = (yield authInvoke(client, "listResourceTemplates", [
421
424
  undefined,
@@ -18,7 +18,7 @@ import { learnedUrlRefusal } from "../../url-safety.js";
18
18
  import { RequestScope } from "../../runtime/request-scope.js";
19
19
  import { Context } from "effect";
20
20
  import { downstreamWriteContinuation } from "../../downstream-input-context.js";
21
- import { guardedTransport, sendGuarded, sendCredentialPost } from "../../guarded-transport.js";
21
+ import { learnedDestination, guardedTransport, sendGuarded, sendCredentialPost, } from "../../guarded-transport.js";
22
22
  import { dispatchProviderRequest, recordProviderResponse, knownProviderFailure, providerRequest, renewedAfterUnauthorized, } from "../../provider-call.js";
23
23
  import { recordRequestField } from "../../provider-call-facts.js";
24
24
  import { AUTH_OR_THROTTLE, errorBodyText, jsonRpcErrorBody, keepsModernVerdict, promised } from "./errors.js";
@@ -128,11 +128,25 @@ labelErrorClass(RemoteMcpDestinationError, "RemoteMcpDestinationError");
128
128
  */
129
129
  export function learnedUrlSafeFetch(connectorId, configured, baseFetch) {
130
130
  return authMethod((input, init) => authFlow(function* () {
131
- const reason = learnedUrlRefusal(configured, new URL(input));
132
- if (reason !== undefined) {
133
- throw new RemoteMcpDestinationError(connectorId, reason);
131
+ let target = new URL(input);
132
+ for (let hop = 0; hop < 10; hop++) {
133
+ const reason = learnedUrlRefusal(configured, target);
134
+ if (reason !== undefined)
135
+ throw new RemoteMcpDestinationError(connectorId, reason);
136
+ const marked = { ...init, redirect: "manual", [learnedDestination]: configured };
137
+ const response = (yield authCall(baseFetch, [target, marked]));
138
+ if (!REDIRECT_STATUSES.has(response.status) ||
139
+ !response.headers.has("location") ||
140
+ (init?.method ?? "GET").toUpperCase() !== "GET")
141
+ return byteReadResponse(response);
142
+ const location = response.headers.get("location");
143
+ void response.body?.cancel().catch(() => { });
144
+ const redirected = new URL(location, target);
145
+ if (redirected.origin !== target.origin)
146
+ throw new RemoteMcpDestinationError(connectorId, "cross-origin redirect refused");
147
+ target = redirected;
134
148
  }
135
- return byteReadResponse((yield authCall(baseFetch, [input, init])));
149
+ throw new RemoteMcpDestinationError(connectorId, "redirect limit exceeded");
136
150
  }));
137
151
  }
138
152
  /**
@@ -289,7 +303,11 @@ export function remoteMcpTransport(rt) {
289
303
  if (customFetch && new URL(input instanceof Request ? input.url : String(input)).origin !== destination.origin)
290
304
  throw new ConnectorCallError("connector_call_failed", `Connector "${id}" refused a request outside its origin; nothing was sent.`, { retryable: false });
291
305
  // The deployment's fetch owns the wire: each write is handed over once.
292
- const sender = flight?.write ? (customSender ?? guardedTransport(true)) : undefined;
306
+ const sender = flight?.write
307
+ ? (customSender ?? guardedTransport(true))
308
+ : init?.[learnedDestination]
309
+ ? guardedTransport(false)
310
+ : undefined;
293
311
  if (flight?.write) {
294
312
  if (flight.sent) {
295
313
  if (flight.request && flight.authorization)
@@ -5,7 +5,7 @@ import { authFlow, authCall, authInvoke, authValue, authMethod, authCatch } from
5
5
  import { viewServices, viewSignal } from "../runtime/call.js";
6
6
  import { declareSelfHostedClient } from "../auth/downstream-client-metadata.js";
7
7
  import { isInputRequiredResult } from "@modelcontextprotocol/client";
8
- import { Duration, Effect, Exit, Scope } from "effect";
8
+ import { Duration, Effect } from "effect";
9
9
  import { byteReadResponse } from "../byte-read-response.js";
10
10
  import { redactSentSecrets, requestSentSecrets, sentSecretsFor } from "../sent-secrets.js";
11
11
  import { closeCatalogCacheScope } from "../catalog-cache.js";
@@ -28,7 +28,7 @@ import { providerBoundary } from "../provider-call.js";
28
28
  import { downstreamCallError, hasSdkPayload, payloadFree, remoteMcpErrors } from "./remote-mcp/errors.js";
29
29
  import { LOCAL_CLOSE_BUDGET_MS, REDIRECT_STATUSES, remoteMcpTransport } from "./remote-mcp/transport.js";
30
30
  import { remoteMcpConfig } from "./remote-mcp/config.js";
31
- import { remoteMcpConnection } from "./remote-mcp/connection.js";
31
+ import { closeConnectionScope, remoteMcpConnection } from "./remote-mcp/connection.js";
32
32
  import { remoteMcpOAuth } from "./remote-mcp/oauth.js";
33
33
  import { remoteMcpCatalog } from "./remote-mcp/catalog.js";
34
34
  import { remoteMcpResources } from "./remote-mcp/resources.js";
@@ -267,11 +267,11 @@ export function remoteMcp(id, opts) {
267
267
  // Revocation and all finalizers start before any caller guard. Cleanup
268
268
  // retains its own bounded settlement even after the request has closed.
269
269
  const state = entryFor(ctx);
270
- const release = Scope.closeUnsafe(state.scope, Exit.void);
270
+ const release = closeConnectionScope(state);
271
271
  const cacheClosing = closeCatalogCacheScope(ctx, id, cacheOwner);
272
272
  reset(state);
273
273
  const closing = runEdge(Effect.all([
274
- release ?? Effect.void,
274
+ Effect.promise(() => release),
275
275
  cacheClosing
276
276
  ? Effect.raceAllFirst([
277
277
  Effect.promise(() => cacheClosing),
@@ -39,7 +39,9 @@ export interface CredentialVault {
39
39
  * Encrypt downstream OAuth state (tokens, a registered client, a PKCE
40
40
  * verifier) before it enters storage. `purpose` is the storage key the value
41
41
  * is written under, so the result is bound to one connector, owner, and key.
42
- * Without both `seal` and `open`, that state is stored as plaintext.
42
+ * Construction refuses a vault without both `seal` and `open` when any
43
+ * connector has `startAuth`, so that state is never left plaintext beside
44
+ * credentials this vault encrypts.
43
45
  */
44
46
  seal?(connectorId: string, purpose: string, plaintext: string, owner?: string): Promise<string>;
45
47
  /** Sign browser OAuth handoffs with a purpose-specific key; nothing is stored. */
@@ -151,6 +151,7 @@ export interface ConnectaConfigDescription {
151
151
  };
152
152
  activity: {
153
153
  enabled: boolean;
154
+ operations?: true;
154
155
  readable?: boolean;
155
156
  deploymentId?: string;
156
157
  store?: {
@@ -269,6 +269,7 @@ export function describeConfig(input) {
269
269
  ? {
270
270
  enabled: true,
271
271
  readable: typeof config.activity.store.list === "function",
272
+ ...(config.activity.operations ? { operations: true } : {}),
272
273
  ...(str(config.activity.deploymentId) !== undefined ? { deploymentId: config.activity.deploymentId } : {}),
273
274
  store: {
274
275
  kind: storeKind(activityStore),
@@ -316,12 +316,21 @@ export class DownstreamElicitation {
316
316
  // The nonce is spent even when the downstream failure would normally
317
317
  // allow a retry. Preserve write reconciliation and auth prerequisites.
318
318
  const echoed = downstreamArgumentEcho(self.options.requestScope);
319
+ const recoveryError = { ...error };
320
+ const freshRead = state.tool === "call_tool" &&
321
+ error.code !== "provider_permission_denied" &&
322
+ error.nextAction === undefined &&
323
+ error.recovery === undefined &&
324
+ error.writeOutcome !== "unknown" &&
325
+ error.uncertainCall === undefined;
326
+ if (freshRead)
327
+ delete recoveryError.retry;
319
328
  const structuredContent = {
320
329
  ...result.structuredContent,
321
330
  error: {
322
- ...error,
331
+ ...recoveryError,
323
332
  retryable: false,
324
- ...(state.tool === "call_tool" && error.nextAction === undefined && error.retry === undefined
333
+ ...(freshRead
325
334
  ? {
326
335
  nextAction: {
327
336
  tool: state.tool,
package/dist/errors.d.ts CHANGED
@@ -24,7 +24,7 @@ export type ConnectorCallErrorCode = "timeout" | "auth_required" | "downstream_o
24
24
  * change, and retries with the base `current` reports — repeating the same
25
25
  * call can never succeed, and waiting will not help either.
26
26
  */
27
- | "conflict" | "resource_template_ambiguous" | "resource_match_budget_exceeded" | "input_required_unsupported" | "input_required_invalid" | "input_required_limit" | "connector_call_failed";
27
+ | "conflict" | "resource_template_ambiguous" | "resource_match_budget_exceeded" | "unsupported_capability" | "input_required_unsupported" | "input_required_invalid" | "input_required_limit" | "connector_call_failed";
28
28
  /**
29
29
  * Every classification code connecta assigns: a connector's typed codes, the
30
30
  * executor's admission codes, and the refusals connecta frames itself.
@@ -285,6 +285,13 @@ export interface CallErrorDetails extends Partial<AuthorizationHandoff> {
285
285
  writeOutcome?: "unknown";
286
286
  /** The single model-facing entry point for every credential class. */
287
287
  nextAction?: {
288
+ tool: "skills";
289
+ arguments: Record<string, never>;
290
+ purpose: string;
291
+ } | {
292
+ action: "reconnect_host" | "contact_operator" | "inspect_capabilities" | "recover_result";
293
+ purpose: string;
294
+ } | {
288
295
  tool: "authorize_connector";
289
296
  arguments: {
290
297
  connector: string;
package/dist/errors.js CHANGED
@@ -15,6 +15,7 @@ const CLASSIFICATION_CODE_TABLE = {
15
15
  conflict: true,
16
16
  resource_template_ambiguous: true,
17
17
  resource_match_budget_exceeded: true,
18
+ unsupported_capability: true,
18
19
  input_required_unsupported: true,
19
20
  input_required_invalid: true,
20
21
  input_required_limit: true,
@@ -337,6 +338,7 @@ const RETRYABLE_BY_CODE = {
337
338
  conflict: false,
338
339
  resource_template_ambiguous: false,
339
340
  resource_match_budget_exceeded: false,
341
+ unsupported_capability: false,
340
342
  input_required_unsupported: false,
341
343
  input_required_invalid: false,
342
344
  input_required_limit: false,
package/dist/execute.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { operationActivity } from "./operation-activity.js";
2
+ import { SkillLookupError } from "./skills.js";
3
+ import { skillLookupRecovery, utilityRecovery } from "./utility-recovery.js";
1
4
  import { META_ROUTES, routeNames } from "./agent-routes.js";
2
5
  import { authCall, authFlow, authInvoke, authMethod } from "./auth/native.js";
3
6
  import { nativeRecovery } from "./native-recovery.js";
@@ -342,7 +345,9 @@ function serializedDiagnosticBytes(value) {
342
345
  }
343
346
  }
344
347
  function guestFailure(code, message, retryable = false) {
345
- return new InvocationFailure({ code, message, retryable });
348
+ return new InvocationFailure(["unavailable", "unsupported_capability", "input_required_unsupported"].includes(code)
349
+ ? utilityRecovery(code, message, retryable)
350
+ : { code, message, retryable });
346
351
  }
347
352
  /**
348
353
  * E1/X11: bound typed host failure metadata, including JSON escapes, before transport.
@@ -724,7 +729,7 @@ function sandboxProvider(registry, baseUrl, activity, limits, requestScope = {})
724
729
  if (typeof id !== "string" ||
725
730
  (!options && options !== undefined) ||
726
731
  (options !== undefined && (typeof options !== "object" || Array.isArray(options)))) {
727
- throw guestFailure("invalid_args", "Use connecta.result(handleOrId, { page?, offset?, maxBytes? }).");
732
+ throw new InvocationFailure(utilityRecovery("invalid_args", "Use connecta.result(handleOrId, { page?, offset?, maxBytes? })."));
728
733
  }
729
734
  const args = { ...(options ?? {}) };
730
735
  if (Object.keys(args).some((key) => !["page", "offset", "maxBytes"].includes(key)) ||
@@ -737,7 +742,7 @@ function sandboxProvider(registry, baseUrl, activity, limits, requestScope = {})
737
742
  (typeof args.offset !== "number" || !Number.isSafeInteger(args.offset) || args.offset < 0)) ||
738
743
  (args.maxBytes !== undefined &&
739
744
  (typeof args.maxBytes !== "number" || !Number.isSafeInteger(args.maxBytes) || args.maxBytes < 1))) {
740
- throw guestFailure("invalid_args", "Use connecta.result(handleOrId, { page?, offset?, maxBytes? }) with whole page/offset >= 0 and maxBytes >= 1; page and offset are mutually exclusive.");
745
+ throw new InvocationFailure(utilityRecovery("invalid_args", "Use connecta.result(handleOrId, { page?, offset?, maxBytes? }) with whole page/offset >= 0 and maxBytes >= 1; page and offset are mutually exclusive."));
741
746
  }
742
747
  // A page must fit every shipped bridge even when the connector cap is raised.
743
748
  args.maxBytes = Math.min(args.maxBytes ?? PROGRAM_RESULT_PAGE_BYTES, PROGRAM_RESULT_PAGE_BYTES);
@@ -768,6 +773,7 @@ function sandboxProvider(registry, baseUrl, activity, limits, requestScope = {})
768
773
  if (typeof skillName !== "string")
769
774
  throw guestFailure("invalid_args", 'Use connecta.skill("usage") or connecta.skill("connector:<id>") with an exact skill name; the skills tool lists names.');
770
775
  const skills = new SkillsRegistry(registry, baseUrl, {
776
+ readOnly: limits.readOnly,
771
777
  requestScope,
772
778
  requestSignal: utilitySignal ?? hostAccessSignal,
773
779
  onRead: (connector, source) => limits.resultDependencies?.push({
@@ -780,7 +786,11 @@ function sandboxProvider(registry, baseUrl, activity, limits, requestScope = {})
780
786
  });
781
787
  return { name: skillName, format: "text", text: await skills.text(skillName) };
782
788
  },
783
- catch: (err) => err instanceof ConnectorCallError ? guestFailure(err.code, err.message, err.code === "unavailable") : err,
789
+ catch: (err) => err instanceof SkillLookupError
790
+ ? new InvocationFailure(skillLookupRecovery(err.message))
791
+ : err instanceof ConnectorCallError
792
+ ? guestFailure(err.code, "Skills are unavailable.", err.retryable)
793
+ : err,
784
794
  }),
785
795
  read: (uri) => Effect.suspend(() => {
786
796
  const started = Date.now();
@@ -923,7 +933,10 @@ function sandboxProvider(registry, baseUrl, activity, limits, requestScope = {})
923
933
  timeoutError: guestFailure("timeout", `connecta.${name} timed out.`),
924
934
  })
925
935
  : invoke;
926
- const settled = runEdge(guarded.pipe(Effect.catch((error) => {
936
+ const operationEffect = activity?.recordOperation && ["search", "describe", "result", "skill"].includes(name)
937
+ ? operationActivity(activity, registry, name, guarded)
938
+ : guarded;
939
+ const settled = runEdge(operationEffect.pipe(Effect.catch((error) => {
927
940
  if (deadline && hostAccessSignal?.aborted)
928
941
  return typedFailure(guestFailure("cancelled", `connecta.${name} was cancelled because the run ended.`));
929
942
  if (deadline && error instanceof InvocationFailure && error.code === "timeout") {
@@ -1230,7 +1243,7 @@ export function createExecuteTool(registry, baseUrl, executor, logger, activity,
1230
1243
  }
1231
1244
  return play(normalizeProgramSource(code), diagnostics, options.signal, requestScope, hostCalls, programWrites);
1232
1245
  });
1233
- return response.pipe(Effect.provideService(RequestScope, owner), Effect.map((unfinished) => {
1246
+ return operationActivity(activity, registry, "program", response.pipe(Effect.provideService(RequestScope, owner), Effect.map((unfinished) => {
1234
1247
  // Every response, including intake refusals, crosses accounting and
1235
1248
  // exactly one envelope check at this operation boundary.
1236
1249
  const result = redactAgentOutput(sentSecrets, programWrites.finish(unfinished));
@@ -1241,7 +1254,7 @@ export function createExecuteTool(registry, baseUrl, executor, logger, activity,
1241
1254
  if (result.content[0]?.type === "text")
1242
1255
  result.content[0].text = JSON.stringify(result.structuredContent);
1243
1256
  return result;
1244
- }), Effect.catchDefect(Effect.fail), Effect.mapError((error) => redactAgentOutput(sentSecrets, error)));
1257
+ }), Effect.catchDefect(Effect.fail), Effect.mapError((error) => redactAgentOutput(sentSecrets, error))), () => ({ ...hostCalls, failed: hostCalls.attempted - hostCalls.succeeded }));
1245
1258
  }));
1246
1259
  }
1247
1260
  /** An execution that never produced an ExecuteResult, as the model sees it. */
@@ -1,4 +1,4 @@
1
- import { META_ROUTES } from "./agent-routes.js";
1
+ import { META_ROUTES } from "./route-names.js";
2
2
  import { CONFIG_DEFAULTS } from "./config-defaults.js";
3
3
  import { msg } from "./errors.js";
4
4
  /** ~6k tokens. Sandbox code should filter data down before returning. */
@@ -1,5 +1,5 @@
1
1
  import { prepareExecuteResultForTransport } from "../executor-result.js";
2
- import { InvocationFailure } from "../invocation.js";
2
+ import { InvocationFailure } from "../invocation-failure.js";
3
3
  import { msg } from "../errors.js";
4
4
  import { MAX_QUICKJS_IPC_BYTES, MAX_QUICKJS_RESULT_BYTES, QUICKJS_RESULT_CHUNK_CHARS, MAX_QUICKJS_HOST_RPC_BYTES, serializedBytes, stringifyBounded, } from "./quickjs-protocol.js";
5
5
  import { executeQuickJs, prepareQuickJs } from "./quickjs-runtime.js";
@@ -9,7 +9,7 @@ import { utf8Length } from "../bytes.js";
9
9
  // continuations from a host-side loop. Values cross the boundary as JSON, so
10
10
  // provider args/results must be JSON-serializable.
11
11
  import { getQuickJS } from "quickjs-emscripten";
12
- import { InvocationFailure } from "../invocation.js";
12
+ import { InvocationFailure } from "../invocation-failure.js";
13
13
  import { msg } from "../errors.js";
14
14
  import { withoutProgramTerminator } from "../program-source.js";
15
15
  import { hostCallLabel, MAX_QUICKJS_LOG_TRANSPORT_BYTES, serializedBytes } from "./quickjs-protocol.js";
@@ -18,7 +18,7 @@ import { fileURLToPath } from "node:url";
18
18
  import { Deferred, Duration, Effect, Exit, Schedule, Scope } from "effect";
19
19
  import { AdmissionController, ExecutorAdmissionError, ExecutorExecutionError } from "../executor-admission.js";
20
20
  import { msg } from "../errors.js";
21
- import { InvocationFailure } from "../invocation.js";
21
+ import { InvocationFailure } from "../invocation-failure.js";
22
22
  import { brandExecutor } from "../executor-contract.js";
23
23
  import { MAX_EXECUTE_LOG_CHARS } from "../executor-result.js";
24
24
  import { admit } from "../runtime/admission.js";
@@ -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);