@zackbart/connecta 0.40.0 → 0.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +148 -0
- package/bin/connecta.mjs +26 -17
- package/bin/meta-tool-names.d.mts +3 -1
- package/bin/meta-tool-names.mjs +4 -1
- package/bin/version.mjs +1 -1
- package/dist/activity.d.ts +32 -5
- package/dist/activity.js +32 -6
- package/dist/advertised-schema.js +15 -0
- package/dist/agent-observation.js +24 -2
- package/dist/agent-routes.js +33 -44
- package/dist/auth/clerk.d.ts +6 -1
- package/dist/auth/clerk.js +16 -0
- package/dist/auth/cloudflare-access.js +49 -7
- package/dist/auth/downstream-oauth/consent.js +79 -29
- package/dist/auth/downstream-oauth/grant-store.js +58 -7
- package/dist/auth/downstream-oauth/refresh-coordinator.js +4 -1
- package/dist/auth/downstream-oauth/refresh-records.js +6 -5
- package/dist/auth/downstream-oauth.js +15 -4
- package/dist/auth/oauth-request-not-sent.js +4 -0
- package/dist/auth/static-oauth.js +77 -39
- package/dist/auth-elicitation.js +118 -61
- package/dist/authorization-handoff.js +36 -3
- package/dist/catalog-cache.js +352 -43
- package/dist/catalog-freshness.js +25 -0
- package/dist/catalog-service.js +408 -78
- package/dist/catalog.js +216 -62
- package/dist/config-defaults.d.ts +2 -1
- package/dist/config-defaults.js +4 -1
- package/dist/config.d.ts +53 -21
- package/dist/config.js +56 -17
- package/dist/connectors/remote-mcp/call.js +1 -1
- package/dist/connectors/remote-mcp/catalog.js +52 -28
- package/dist/connectors/remote-mcp/connection.js +113 -22
- package/dist/connectors/remote-mcp/oauth.js +2 -2
- package/dist/connectors/remote-mcp/resources.js +5 -2
- package/dist/connectors/remote-mcp/transport.js +24 -6
- package/dist/connectors/remote-mcp.js +14 -5
- package/dist/credential-contract.d.ts +3 -1
- package/dist/describe-config.d.ts +4 -2
- package/dist/describe-config.js +1 -0
- package/dist/discovery-health.js +26 -11
- package/dist/downstream-input.js +11 -2
- package/dist/errors.d.ts +60 -4
- package/dist/errors.js +88 -3
- package/dist/execute.js +491 -202
- package/dist/executor-admission.js +2 -0
- package/dist/executor-contract.js +6 -0
- package/dist/executor-result.js +3 -3
- package/dist/executors/quickjs-child.js +14 -5
- package/dist/executors/quickjs-runtime.d.ts +2 -0
- package/dist/executors/quickjs-runtime.js +37 -20
- package/dist/executors/quickjs.js +24 -7
- package/dist/guarded-transport.js +2 -0
- package/dist/guest-functions.js +5 -4
- package/dist/guest-runtime.js +37 -1
- package/dist/guest-types.d.ts +18 -0
- package/dist/http/client.js +14 -2
- package/dist/http/errors.js +9 -4
- package/dist/http/fields.js +17 -5
- package/dist/http/hatch.js +11 -3
- package/dist/http/node-transport.js +110 -4
- package/dist/http/oauth.js +17 -6
- package/dist/http/paging.js +1 -1
- package/dist/http/provider.js +2 -6
- package/dist/http/vendor.js +6 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +15 -21
- package/dist/invocation-failure.js +28 -0
- package/dist/invocation.js +167 -64
- package/dist/membership.d.ts +1 -1
- package/dist/meta-output.js +24 -18
- package/dist/meta-tool-names.d.ts +1 -1
- package/dist/meta-tool-names.js +1 -1
- package/dist/meta-tools.js +286 -73
- package/dist/module-contracts.d.ts +3 -1
- package/dist/native-recovery.js +4 -1
- package/dist/native-tools.js +177 -55
- package/dist/node.d.ts +7 -0
- package/dist/node.js +85 -4
- package/dist/oauth-handoff.js +53 -43
- package/dist/operation-activity.js +104 -0
- package/dist/operation-vocabulary.js +6 -0
- package/dist/operator-record.d.ts +9 -1
- package/dist/operator-record.js +16 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/program-result.js +2 -0
- package/dist/program-source.js +1 -1
- package/dist/program-writes.js +112 -19
- package/dist/provider.d.ts +1 -14
- package/dist/provider.js +0 -3
- package/dist/providers/_shared/google/kit.js +2 -2
- package/dist/providers/_shared/google/workspace.d.ts +1 -1
- package/dist/providers/breeze/api.js +2 -2
- package/dist/providers/breeze/index.js +1 -1
- package/dist/providers/breeze/skill.generated.js +1 -1
- package/dist/providers/ccb/api.js +2 -2
- package/dist/providers/ccb/index.js +1 -1
- package/dist/providers/ccb/skill.generated.js +1 -1
- package/dist/providers/cloudflare/api.js +2 -2
- package/dist/providers/cloudflare/index.js +3 -1
- package/dist/providers/cloudflare/skill.generated.js +1 -1
- package/dist/providers/docs/index.js +1 -1
- package/dist/providers/docs/skill.generated.js +1 -1
- package/dist/providers/drive/index.js +1 -1
- package/dist/providers/drive/skill.generated.js +1 -1
- package/dist/providers/forms/index.js +2 -1
- package/dist/providers/forms/skill.generated.js +1 -1
- package/dist/providers/github/api.js +98 -6
- package/dist/providers/github/index.js +8 -3
- package/dist/providers/github/lib/auth.d.ts +3 -1
- package/dist/providers/github/lib/auth.js +43 -12
- package/dist/providers/github/lib/discovery.js +11 -1
- package/dist/providers/github/lib/operations.js +8 -0
- package/dist/providers/github/safety.js +39 -0
- package/dist/providers/github/skill.generated.js +1 -1
- package/dist/providers/github/tools.js +12 -5
- package/dist/providers/gmail/index.js +2 -1
- package/dist/providers/gmail/skill.generated.js +1 -1
- package/dist/providers/granola/index.js +1 -1
- package/dist/providers/granola/skill.generated.js +1 -1
- package/dist/providers/overflow/index.js +3 -1
- package/dist/providers/overflow/skill.generated.js +1 -1
- package/dist/providers/planning-center/index.js +12 -6
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/providers/planning-center/skill.generated.js +1 -1
- package/dist/providers/quickbooks/api.js +28 -25
- package/dist/providers/quickbooks/index.js +3 -6
- package/dist/providers/quickbooks/lib/writes.js +55 -0
- package/dist/providers/quickbooks/safety.js +17 -5
- package/dist/providers/quickbooks/skill.generated.js +1 -1
- package/dist/providers/sheets/index.js +2 -1
- package/dist/providers/sheets/skill.generated.js +1 -1
- package/dist/providers/slides/index.js +3 -3
- package/dist/providers/slides/skill.generated.js +1 -1
- package/dist/providers/stripe/index.js +2 -2
- package/dist/providers/stripe/skill.generated.js +1 -1
- package/dist/providers/tithely/index.js +2 -1
- package/dist/providers/tithely/skill.generated.js +1 -1
- package/dist/providers/vercel/api.js +3 -2
- package/dist/providers/vercel/skill.generated.js +1 -1
- package/dist/registry.d.ts +36 -4
- package/dist/registry.js +142 -30
- package/dist/resource-read.js +21 -2
- package/dist/result-preview.js +210 -0
- package/dist/result-seal.js +168 -0
- package/dist/route-names.js +53 -0
- package/dist/routes/activity-context.js +1 -0
- package/dist/routes/activity-disclosure.js +26 -4
- package/dist/routes/activity.js +2 -1
- package/dist/routes/connect.js +21 -13
- package/dist/routes/mcp.js +83 -35
- package/dist/routes/shared.d.ts +6 -0
- package/dist/routes/ui-config.js +1 -1
- package/dist/routes/ui-data.js +3 -2
- package/dist/runtime/call.js +15 -10
- package/dist/runtime/native-storage.js +12 -0
- package/dist/runtime/request-reads.js +66 -14
- package/dist/runtime/request-scope.js +38 -2
- package/dist/runtime/run.js +4 -1
- package/dist/sent-secrets.d.ts +20 -0
- package/dist/sent-secrets.js +49 -1
- package/dist/skill-origin.js +16 -0
- package/dist/skills.js +213 -57
- package/dist/storage/d1-driver.js +33 -5
- package/dist/storage/keys.js +16 -7
- package/dist/storage/sql-native.js +60 -3
- package/dist/storage/sql.js +6 -0
- package/dist/timing-trace.js +84 -0
- package/dist/tool-safety.d.ts +33 -0
- package/dist/tool-safety.js +51 -15
- package/dist/types.d.ts +36 -0
- package/dist/url-safety.js +38 -20
- package/dist/usage-guide.js +14 -13
- package/dist/utility-recovery.js +33 -0
- package/dist/validate.js +29 -10
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.js +60 -15
- package/documentation/architecture.md +152 -43
- package/documentation/auth.md +160 -53
- package/documentation/code-mode.md +396 -119
- package/documentation/deploying.md +35 -10
- package/documentation/hosted-mcp-migration.md +9 -3
- package/documentation/integrating.md +27 -5
- package/documentation/meta-tools.md +761 -157
- package/documentation/operating.md +91 -4
- package/documentation/operator-ui.md +51 -2
- package/documentation/providers.md +34 -20
- package/examples/worker/README.md +3 -3
- package/guides/how-it-works.md +2 -1
- package/guides/safety-and-trust.md +1 -3
- package/package.json +10 -8
- package/templates/node/AGENTS.md +3 -3
- package/templates/node/README.md +1 -1
- package/templates/node/package.json +1 -1
|
@@ -6,7 +6,7 @@ import { SentSecrets } from "../sent-secrets.js";
|
|
|
6
6
|
import { authenticateRemoteClient, downstreamClientMetadata, remoteClientAuthMethod, } from "./downstream-client-metadata.js";
|
|
7
7
|
import { authMethods } from "./native.js";
|
|
8
8
|
import { OAUTH_REFRESH_SKEW_MS } from "./oauth-grant.js";
|
|
9
|
-
import { consentMethods } from "./downstream-oauth/consent.js";
|
|
9
|
+
import { consentMethods, withoutUrlSecrets } from "./downstream-oauth/consent.js";
|
|
10
10
|
import { ACTIVE_EPOCH_PREFIX, GRANT, grantStoreMethods, isDisconnectedEpoch, } from "./downstream-oauth/grant-store.js";
|
|
11
11
|
import { aborted, tokenResponse, } from "./downstream-oauth/refresh-coordinator.js";
|
|
12
12
|
import { refreshRecordMethods } from "./downstream-oauth/refresh-records.js";
|
|
@@ -32,8 +32,7 @@ export function authorizingContext(ctx) {
|
|
|
32
32
|
return deriveContext(ctx, { allowAuthorization: true });
|
|
33
33
|
}
|
|
34
34
|
/** A local destination guard proved that no OAuth HTTP request left. */
|
|
35
|
-
export
|
|
36
|
-
}
|
|
35
|
+
export { OAuthRequestNotSentError } from "./oauth-request-not-sent.js";
|
|
37
36
|
/**
|
|
38
37
|
* OAuthClientProvider over one owner's grant record and its consents' flow
|
|
39
38
|
* records, in one connector's namespace.
|
|
@@ -106,6 +105,8 @@ export class KvOAuthProvider {
|
|
|
106
105
|
* only: no credential beyond the access token it already handed out.
|
|
107
106
|
*/
|
|
108
107
|
observed;
|
|
108
|
+
/** The same grant's tokens, which a catalog's identity digests. Memory only, like the grant they came from. */
|
|
109
|
+
observedTokens;
|
|
109
110
|
constructor(connectorId, storage, redirectUri, refreshCoordinator, allowAuthorization = true,
|
|
110
111
|
/** Present when the deployment's vault can seal; the grant is then ciphertext. */
|
|
111
112
|
sealer,
|
|
@@ -154,9 +155,11 @@ export class KvOAuthProvider {
|
|
|
154
155
|
}
|
|
155
156
|
observe(grant) {
|
|
156
157
|
const { tokens, expiresAt } = grant.body;
|
|
158
|
+
this.observedTokens = tokens;
|
|
157
159
|
this.observed = tokens
|
|
158
160
|
? {
|
|
159
161
|
epoch: grant.epoch,
|
|
162
|
+
revision: grant.tokenRevision ?? 0,
|
|
160
163
|
accessToken: tokens.access_token,
|
|
161
164
|
refreshable: typeof tokens.refresh_token === "string" && tokens.refresh_token !== "",
|
|
162
165
|
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
@@ -195,7 +198,7 @@ export class KvOAuthProvider {
|
|
|
195
198
|
if (!this.callback ||
|
|
196
199
|
(issuer === null
|
|
197
200
|
? this.callback.flow.binding?.issRequired === true
|
|
198
|
-
: issuer !== this.callback.flow.binding?.issuer)) {
|
|
201
|
+
: this.flowValue(issuer) !== this.flowValue(this.callback.flow.binding?.issuer))) {
|
|
199
202
|
throw new ConnectorCallError("connector_call_failed", `Connector "${this.connectorId}" authorization callback issuer does not match its consent; nothing was exchanged.`);
|
|
200
203
|
}
|
|
201
204
|
}
|
|
@@ -203,6 +206,14 @@ export class KvOAuthProvider {
|
|
|
203
206
|
flowLabel(key, epoch) {
|
|
204
207
|
return `${key}:${epoch}`;
|
|
205
208
|
}
|
|
209
|
+
/** The sealed record's own label, so a verifier's ciphertext cannot stand in for it. */
|
|
210
|
+
flowRecordLabel(key, epoch) {
|
|
211
|
+
return `${this.flowLabel(key, epoch)}:record`;
|
|
212
|
+
}
|
|
213
|
+
/** A binding value as this provider's flow records hold it, for comparison. */
|
|
214
|
+
flowValue(value) {
|
|
215
|
+
return this.sealer ? value : withoutUrlSecrets(value);
|
|
216
|
+
}
|
|
206
217
|
isOperatorDisconnectedEpoch(epoch) {
|
|
207
218
|
return isDisconnectedEpoch(epoch);
|
|
208
219
|
}
|
|
@@ -397,25 +397,31 @@ export function staticOAuth(id, config) {
|
|
|
397
397
|
return new StaticOAuthProvider(id, ctx, coordinatorFor(ctx), settings, requestOwned);
|
|
398
398
|
};
|
|
399
399
|
/**
|
|
400
|
-
* The
|
|
401
|
-
*
|
|
402
|
-
* from configuration the token endpoint is
|
|
403
|
-
* the check keeps the headers on it even if
|
|
400
|
+
* The token request as sent, with the configured token-request headers laid
|
|
401
|
+
* over the SDK's own, and every credential it carries registered. With
|
|
402
|
+
* discovery and registration answered from configuration the token endpoint is
|
|
403
|
+
* the only URL the SDK fetches here; the check keeps the headers on it even if
|
|
404
|
+
* that ever changes.
|
|
404
405
|
*/
|
|
406
|
+
const registeredTokenRequest = (secrets, input, init = {}) => {
|
|
407
|
+
const url = new URL(input);
|
|
408
|
+
const request = { ...init, redirect: "manual" };
|
|
409
|
+
if (settings.tokenRequestHeaders.length && `${url.origin}${url.pathname}` === settings.identity) {
|
|
410
|
+
const headers = new Headers(init.headers);
|
|
411
|
+
for (const [name, value] of settings.tokenRequestHeaders)
|
|
412
|
+
headers.set(name, value);
|
|
413
|
+
for (const [name, value] of settings.tokenRequestHeaders)
|
|
414
|
+
if (!PUBLIC_TOKEN_REQUEST_HEADERS.get(name.toLowerCase())?.has(value))
|
|
415
|
+
secrets.header(value);
|
|
416
|
+
request.headers = headers;
|
|
417
|
+
}
|
|
418
|
+
trackRemoteClientRequest(secrets, input, request);
|
|
419
|
+
return request;
|
|
420
|
+
};
|
|
421
|
+
/** The owned credential transport. */
|
|
405
422
|
const tokenEndpointFetch = authMethod((secrets, input, init = {}) => {
|
|
406
423
|
return authFlow(function* () {
|
|
407
|
-
const
|
|
408
|
-
const request = { ...init, redirect: "manual" };
|
|
409
|
-
if (settings.tokenRequestHeaders.length && `${url.origin}${url.pathname}` === settings.identity) {
|
|
410
|
-
const headers = new Headers(init.headers);
|
|
411
|
-
for (const [name, value] of settings.tokenRequestHeaders)
|
|
412
|
-
headers.set(name, value);
|
|
413
|
-
for (const [name, value] of settings.tokenRequestHeaders)
|
|
414
|
-
if (!PUBLIC_TOKEN_REQUEST_HEADERS.get(name.toLowerCase())?.has(value))
|
|
415
|
-
secrets.header(value);
|
|
416
|
-
request.headers = headers;
|
|
417
|
-
}
|
|
418
|
-
trackRemoteClientRequest(secrets, input, request);
|
|
424
|
+
const request = registeredTokenRequest(secrets, input, init);
|
|
419
425
|
// The coordinator or code-exchange wrapper authorizes this send. Refresh
|
|
420
426
|
// claim submission may outlive the caller; this transport uses no
|
|
421
427
|
// request-owned capability and changes no closed resource-call ledger.
|
|
@@ -425,7 +431,6 @@ export function staticOAuth(id, config) {
|
|
|
425
431
|
provider: id,
|
|
426
432
|
method: request.method ?? "GET",
|
|
427
433
|
};
|
|
428
|
-
secrets.request(input, request);
|
|
429
434
|
const response = (yield authCall(sendCredentialPost, [
|
|
430
435
|
input,
|
|
431
436
|
request,
|
|
@@ -446,6 +451,7 @@ export function staticOAuth(id, config) {
|
|
|
446
451
|
early) => {
|
|
447
452
|
return authFlow(function* () {
|
|
448
453
|
const secrets = sentSecretsFor(ctx);
|
|
454
|
+
const owner = viewSignal(ctx, requestOwned || exchange !== undefined);
|
|
449
455
|
const coordinated = coordinatorFor(ctx).coordinatedFetch(provider, authMethod((input, init) => {
|
|
450
456
|
return authFlow(function* () {
|
|
451
457
|
if (exchange) {
|
|
@@ -478,8 +484,10 @@ export function staticOAuth(id, config) {
|
|
|
478
484
|
}
|
|
479
485
|
return (yield authCall(tokenEndpointFetch, [secrets, input, init]));
|
|
480
486
|
});
|
|
481
|
-
}),
|
|
487
|
+
}), owner, ctx.defer, undefined, (input, init) => {
|
|
482
488
|
guardedTransport(true);
|
|
489
|
+
if (!owner.aborted)
|
|
490
|
+
registeredTokenRequest(secrets, input, init);
|
|
483
491
|
});
|
|
484
492
|
return (yield authCall(auth, [
|
|
485
493
|
provider,
|
|
@@ -547,12 +555,14 @@ export function staticOAuth(id, config) {
|
|
|
547
555
|
// A token that lives under a minute is due again at once: the invocation's
|
|
548
556
|
// check renews it, and the handler sends it while it is unexpired.
|
|
549
557
|
const earlyRenewed = new EarlyRenewals();
|
|
550
|
-
/**
|
|
551
|
-
* One call's grant use; submitted refresh work keeps the coordinator's owner.
|
|
552
|
-
* `renewAgain` is the invocation's check, which renews any due token,
|
|
553
|
-
* even one this request's earlier renewal produced.
|
|
554
|
-
*/
|
|
555
558
|
const grantFor = (ctx, requestOwned = false, renewAgain = false) => {
|
|
559
|
+
const observations = new Map();
|
|
560
|
+
const capture = (provider, token) => {
|
|
561
|
+
const seen = provider.observed;
|
|
562
|
+
if (seen?.accessToken === token)
|
|
563
|
+
observations.set(token, { epoch: seen.epoch, revision: seen.revision });
|
|
564
|
+
return token;
|
|
565
|
+
};
|
|
556
566
|
let provider;
|
|
557
567
|
const use = authMethod((work) => {
|
|
558
568
|
return authView(ctx, authMethod((signal) => {
|
|
@@ -611,9 +621,9 @@ export function staticOAuth(id, config) {
|
|
|
611
621
|
true,
|
|
612
622
|
]));
|
|
613
623
|
earlyRenewed.record(scope, tokens.access_token, fresh, provider.observedExpiry(fresh));
|
|
614
|
-
return fresh;
|
|
624
|
+
return capture(provider, fresh);
|
|
615
625
|
}
|
|
616
|
-
return tokens.access_token;
|
|
626
|
+
return capture(provider, tokens.access_token);
|
|
617
627
|
});
|
|
618
628
|
}),
|
|
619
629
|
]);
|
|
@@ -621,10 +631,21 @@ export function staticOAuth(id, config) {
|
|
|
621
631
|
refresh: authMethod((stale) => {
|
|
622
632
|
return authCall(use, [
|
|
623
633
|
authMethod((provider, live) => {
|
|
624
|
-
return
|
|
634
|
+
return authFlow(function* () {
|
|
635
|
+
const token = (yield authCall(renewedToken, [provider, live, stale, requestOwned]));
|
|
636
|
+
return capture(provider, token);
|
|
637
|
+
});
|
|
625
638
|
}),
|
|
626
639
|
]);
|
|
627
640
|
}),
|
|
641
|
+
observe: authMethod((token, outcome) => {
|
|
642
|
+
const snapshot = observations.get(token);
|
|
643
|
+
if (!snapshot)
|
|
644
|
+
return Effect.void;
|
|
645
|
+
return authCall(use, [
|
|
646
|
+
authMethod((provider) => authInvoke(provider, "recordTokenOutcome", [snapshot, outcome])),
|
|
647
|
+
]).pipe(Effect.asVoid, Effect.catch(() => Effect.void));
|
|
648
|
+
}),
|
|
628
649
|
rejected: authMethod(() => {
|
|
629
650
|
return authView(ctx, authMethod(() => {
|
|
630
651
|
return authValue(() => {
|
|
@@ -735,6 +756,10 @@ export function staticOAuth(id, config) {
|
|
|
735
756
|
const grant = grantFor(ctx, auxiliary);
|
|
736
757
|
const stale = (yield authInvoke(grant, "token", []));
|
|
737
758
|
const { response: first, dispatch } = (yield authCall(send, [stale]));
|
|
759
|
+
if (first.status === 401 || (first.status >= 200 && first.status < 300))
|
|
760
|
+
yield authInvoke(grant, "observe", [stale, first.status === 401 ? "refused" : "accepted"]);
|
|
761
|
+
if (signal.aborted)
|
|
762
|
+
throw signal.reason;
|
|
738
763
|
if (first.status !== 401)
|
|
739
764
|
return first;
|
|
740
765
|
try {
|
|
@@ -773,6 +798,10 @@ export function staticOAuth(id, config) {
|
|
|
773
798
|
throw signal.reason;
|
|
774
799
|
renewedAfterUnauthorized(dispatch, stale, accessToken);
|
|
775
800
|
const { response: second } = (yield authCall(send, [accessToken]));
|
|
801
|
+
if (second.status === 401 || (second.status >= 200 && second.status < 300))
|
|
802
|
+
yield authInvoke(grant, "observe", [accessToken, second.status === 401 ? "refused" : "accepted"]);
|
|
803
|
+
if (signal.aborted)
|
|
804
|
+
throw signal.reason;
|
|
776
805
|
if (second.status !== 401)
|
|
777
806
|
return second;
|
|
778
807
|
void second.body?.cancel().catch(() => { });
|
|
@@ -800,7 +829,7 @@ export function staticOAuth(id, config) {
|
|
|
800
829
|
}), auxiliary));
|
|
801
830
|
});
|
|
802
831
|
});
|
|
803
|
-
const statusOf = authMethod((ctx, provider) => {
|
|
832
|
+
const statusOf = authMethod((ctx, provider, forCall = false) => {
|
|
804
833
|
return authFlow(function* () {
|
|
805
834
|
if (rejectedScopes.has(scopeOf(ctx))) {
|
|
806
835
|
return {
|
|
@@ -808,16 +837,18 @@ export function staticOAuth(id, config) {
|
|
|
808
837
|
message: "Authorization required \u2014 the downstream rejected this connector's OAuth grant.",
|
|
809
838
|
};
|
|
810
839
|
}
|
|
811
|
-
//
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
// asked nothing.
|
|
815
|
-
if ((yield authInvoke(provider, "operatorDisconnected", []))) {
|
|
840
|
+
// Peek opens the current grant without migration writes or authorization.
|
|
841
|
+
const grant = (yield authInvoke(provider, "peekGrant", []));
|
|
842
|
+
if (provider.isOperatorDisconnectedEpoch(grant.epoch))
|
|
816
843
|
return { state: "auth_required", message: disconnectedMessage };
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
844
|
+
if (!grant.tokens || grant.issuer !== settings.identity)
|
|
845
|
+
return { state: "auth_required", message: AUTH_REQUIRED_MESSAGE };
|
|
846
|
+
return !forCall && grant.observation?.outcome === "refused"
|
|
847
|
+
? {
|
|
848
|
+
state: "auth_required",
|
|
849
|
+
message: "Authorization required — the downstream rejected this connector's OAuth grant.",
|
|
850
|
+
}
|
|
851
|
+
: { state: "ok" };
|
|
821
852
|
});
|
|
822
853
|
});
|
|
823
854
|
const status = authMethod((ctx) => {
|
|
@@ -832,7 +863,9 @@ export function staticOAuth(id, config) {
|
|
|
832
863
|
const prepareCall = authMethod((ctx) => {
|
|
833
864
|
return authFlow(function* () {
|
|
834
865
|
const provider = providerFor(ctx);
|
|
835
|
-
|
|
866
|
+
// A persisted API refusal is diagnostic, not proof the next request will fail.
|
|
867
|
+
// Keep the request-local latch, but let a later invocation observe recovery.
|
|
868
|
+
const state = (yield authCall(statusOf, [ctx, provider, true]));
|
|
836
869
|
if (state.state === "ok" && provider.renewalDue() !== undefined)
|
|
837
870
|
(yield authInvoke(grantFor(ctx, false, true), "token", []));
|
|
838
871
|
return state;
|
|
@@ -849,8 +882,13 @@ export function staticOAuth(id, config) {
|
|
|
849
882
|
: (yield authInvoke(provider, "operatorDisconnected", []));
|
|
850
883
|
if (ctx.signal?.aborted)
|
|
851
884
|
throw ctx.signal.reason;
|
|
852
|
-
|
|
853
|
-
|
|
885
|
+
const observed = (yield authInvoke(provider, "peekGrant", []));
|
|
886
|
+
const refused = observed.observation?.outcome === "refused";
|
|
887
|
+
if (opts?.force || disconnected || refused || rejectedScopes.has(scopeOf(original))) {
|
|
888
|
+
const fence = !opts?.force && !disconnected && refused
|
|
889
|
+
? { epoch: observed.epoch, revision: observed.observation.revision }
|
|
890
|
+
: undefined;
|
|
891
|
+
(yield authInvoke(provider, "resetAuthorization", [false, false, fence]));
|
|
854
892
|
rejectedScopes.delete(scopeOf(original));
|
|
855
893
|
}
|
|
856
894
|
else {
|
package/dist/auth-elicitation.js
CHANGED
|
@@ -3,7 +3,7 @@ import { RequestScope, requestOwner } from "./runtime/request-scope.js";
|
|
|
3
3
|
import { upwardStep } from "./upward.js";
|
|
4
4
|
import { authAwait, authCall, authInvoke, authFlow, authMethod, registerAuthMethod, authOwnMethod, } from "./auth/native.js";
|
|
5
5
|
import { registryProgram } from "./runtime/registry.js";
|
|
6
|
-
import { enableAuthorizationHandoffs } from "./authorization-handoff.js";
|
|
6
|
+
import { authorizationHandoff, connectLinkFields, enableAuthorizationHandoffs } from "./authorization-handoff.js";
|
|
7
7
|
import { createRequestStateCodec, inputRequired, ProtocolError, INVALID_PARAMS, PROTOCOL_VERSION_META_KEY, CLIENT_CAPABILITIES_META_KEY, } from "@modelcontextprotocol/server";
|
|
8
8
|
import { Effect } from "effect";
|
|
9
9
|
import { runEdge, withDeadlineEffect } from "./runtime/run.js";
|
|
@@ -17,6 +17,23 @@ import { DownstreamElicitation, MAX_RELAY_STATE_CHARS, } from "./downstream-inpu
|
|
|
17
17
|
import { canonicalState as canonical, requestDigest as digest, stateObject as object, invalidRequestState as invalidState, REQUEST_STATE_TTL_MS as TTL_MS, MAX_INPUT_ROUNDS as MAX_ROUNDS, } from "./request-state.js";
|
|
18
18
|
const INPUT_KEY = "connecta_auth";
|
|
19
19
|
const MESSAGE = "Connect this service in your browser, then retry the request.";
|
|
20
|
+
// A client may answer without showing anything, and the response cannot prove
|
|
21
|
+
// otherwise. Neither text claims a person saw the prompt, and neither names a
|
|
22
|
+
// route: an agent told to call again would only reach the same refusal.
|
|
23
|
+
const REFUSED = {
|
|
24
|
+
decline: {
|
|
25
|
+
code: "auth_declined",
|
|
26
|
+
message: "The client declined the connection prompt, possibly automatically, so the user may not have seen it. Nothing was retried.",
|
|
27
|
+
},
|
|
28
|
+
cancel: {
|
|
29
|
+
code: "auth_cancelled",
|
|
30
|
+
message: "The client cancelled the connection prompt, possibly automatically, so the user may not have seen it. Nothing was retried.",
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
// Only a repeat can recover a refusal whose link failed, and it asks for a new
|
|
34
|
+
// prompt, so a person's refusal stays final. Nothing on the server limits it to one.
|
|
35
|
+
const REFUSED_NO_LINK = " No connection link could be issued. If the user has not refused this connection, repeat this request once for a new link, then give it to them. Stop if that repeat also fails.";
|
|
36
|
+
const refusedPurpose = (recipient) => `Give this link to the user, unchanged, ${recipient}once, then stop. Do not prompt again, retry, or reach this service another way until they confirm. If they already refused this connection in this conversation, only mention that the link is available.`;
|
|
20
37
|
function failure(code, message) {
|
|
21
38
|
return toolError({ code, message, retryable: false }, { ok: false });
|
|
22
39
|
}
|
|
@@ -45,14 +62,26 @@ export class AuthElicitation {
|
|
|
45
62
|
context,
|
|
46
63
|
]));
|
|
47
64
|
}
|
|
48
|
-
|
|
49
|
-
return runEdge(authOwnMethod(AuthElicitation.prototype, this, "
|
|
50
|
-
state,
|
|
51
|
-
]));
|
|
65
|
+
browserCompleted(state) {
|
|
66
|
+
return runEdge(authOwnMethod(AuthElicitation.prototype, this, "browserCompleted", [state]));
|
|
52
67
|
}
|
|
53
68
|
connected(id, context) {
|
|
54
69
|
return runEdge(authOwnMethod(AuthElicitation.prototype, this, "connected", [id, context]));
|
|
55
70
|
}
|
|
71
|
+
refused(action, state, force) {
|
|
72
|
+
return runEdge(authOwnMethod(AuthElicitation.prototype, this, "refused", [action, state, force]));
|
|
73
|
+
}
|
|
74
|
+
/** The one gate before any recovery link: this caller may connect this connector here. */
|
|
75
|
+
mayConnect(id) {
|
|
76
|
+
const { registry, principal, publicUrl, canManage, unavailable, credentialUi } = this.options;
|
|
77
|
+
const connector = typeof id === "string" ? registry.getConnector(id) : undefined;
|
|
78
|
+
return (typeof id === "string" &&
|
|
79
|
+
/^[a-z0-9_-]+$/.test(id) &&
|
|
80
|
+
Boolean(connector?.startAuth || (connector?.credential && credentialUi)) &&
|
|
81
|
+
Boolean(principal && publicUrl) &&
|
|
82
|
+
!unavailable &&
|
|
83
|
+
canManage(id));
|
|
84
|
+
}
|
|
56
85
|
bind(context) {
|
|
57
86
|
this.downstream.bind(context);
|
|
58
87
|
}
|
|
@@ -114,12 +143,8 @@ export class AuthElicitation {
|
|
|
114
143
|
!(yield* upwardStep(authInvoke(self.options, "claimRetry", [previous.connector, previous.browserNonces.at(-1)])))) {
|
|
115
144
|
return failure("invalid_request_state", "Invalid or expired requestState");
|
|
116
145
|
}
|
|
117
|
-
if (previous && object(response))
|
|
118
|
-
|
|
119
|
-
return failure("auth_declined", "Connection was declined. The request was not retried.");
|
|
120
|
-
if (response.action === "cancel")
|
|
121
|
-
return failure("auth_cancelled", "Connection was cancelled. The request was not retried.");
|
|
122
|
-
}
|
|
146
|
+
if (previous && object(response) && (response.action === "decline" || response.action === "cancel"))
|
|
147
|
+
return yield* upwardStep(authInvoke(self, "refused", [response.action, previous, args.force]));
|
|
123
148
|
if (previous?.reads.length) {
|
|
124
149
|
const catalog = new CatalogService(self.options.registry, self.options.publicUrl, {
|
|
125
150
|
requestScope: self.options.requestScope,
|
|
@@ -156,18 +181,12 @@ export class AuthElicitation {
|
|
|
156
181
|
}
|
|
157
182
|
bindReplayReads(self.options.requestScope, previous.reads);
|
|
158
183
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
yield* upwardStep(authInvoke(self.options, "closeLinks", [previous.connector, previous.browserNonces]));
|
|
166
|
-
if ((yield* upwardStep(authInvoke(self, "browserProgress", [previous]))).completed &&
|
|
167
|
-
(yield* upwardStep(authInvoke(self, "connected", [previous.connector, context])))) {
|
|
168
|
-
const structuredContent = { connector: previous.connector, status: "ok" };
|
|
169
|
-
return { structuredContent, content: [{ type: "text", text: JSON.stringify(structuredContent) }] };
|
|
170
|
-
}
|
|
184
|
+
if (previous &&
|
|
185
|
+
tool === "authorize_connector" &&
|
|
186
|
+
(yield* upwardStep(authInvoke(self, "browserCompleted", [previous]))) &&
|
|
187
|
+
(yield* upwardStep(authInvoke(self, "connected", [previous.connector, context])))) {
|
|
188
|
+
const structuredContent = { connector: previous.connector, status: "ok" };
|
|
189
|
+
return { structuredContent, content: [{ type: "text", text: JSON.stringify(structuredContent) }] };
|
|
171
190
|
}
|
|
172
191
|
const envelope = context.mcpReq.envelope;
|
|
173
192
|
const capabilities = envelope?.[CLIENT_CAPABILITIES_META_KEY];
|
|
@@ -216,16 +235,8 @@ export class AuthElicitation {
|
|
|
216
235
|
content: [{ type: "text", text: JSON.stringify(structuredContent) }],
|
|
217
236
|
};
|
|
218
237
|
}
|
|
219
|
-
const {
|
|
220
|
-
|
|
221
|
-
const recoverable = connector?.startAuth || (connector?.credential && self.options.credentialUi);
|
|
222
|
-
if (typeof id !== "string" ||
|
|
223
|
-
!/^[a-z0-9_-]+$/.test(id) ||
|
|
224
|
-
!recoverable ||
|
|
225
|
-
!principal ||
|
|
226
|
-
!publicUrl ||
|
|
227
|
-
unavailable ||
|
|
228
|
-
!canManage(id))
|
|
238
|
+
const { vault, principal } = self.options;
|
|
239
|
+
if (!self.mayConnect(id) || !principal)
|
|
229
240
|
return result;
|
|
230
241
|
// Incapable hosts already received the shared explicit-authorization handoff.
|
|
231
242
|
if (!capable)
|
|
@@ -237,9 +248,9 @@ export class AuthElicitation {
|
|
|
237
248
|
...result.structuredContent,
|
|
238
249
|
error: {
|
|
239
250
|
...result.structuredContent?.error,
|
|
240
|
-
|
|
251
|
+
...connectLinkFields(error, id, error.authorizationUrl ??
|
|
241
252
|
(yield* upwardStep(authInvoke(self.options, "connectLink", [id])))
|
|
242
|
-
.url,
|
|
253
|
+
.url),
|
|
243
254
|
},
|
|
244
255
|
};
|
|
245
256
|
return {
|
|
@@ -250,13 +261,13 @@ export class AuthElicitation {
|
|
|
250
261
|
}
|
|
251
262
|
if (previous && previous.round >= MAX_ROUNDS)
|
|
252
263
|
return failure("auth_round_limit", "Connection is still required after three prompts. Connect explicitly, then start a new request.");
|
|
253
|
-
//
|
|
254
|
-
//
|
|
264
|
+
// Accept is consent to open the link, not proof it opened, so every round's
|
|
265
|
+
// link stays live and they share one reset (claimOAuthConnectStart).
|
|
255
266
|
const forced = explicit && args.force === true;
|
|
256
267
|
const link = yield* upwardStep(authInvoke(self.options, "connectLink", [
|
|
257
268
|
id,
|
|
258
|
-
forced
|
|
259
|
-
forced ?
|
|
269
|
+
forced,
|
|
270
|
+
forced ? previous?.browserNonces[0] : undefined,
|
|
260
271
|
]));
|
|
261
272
|
const state = {
|
|
262
273
|
version: 1,
|
|
@@ -285,7 +296,7 @@ export class AuthElicitation {
|
|
|
285
296
|
...result.structuredContent,
|
|
286
297
|
error: {
|
|
287
298
|
...result.structuredContent?.error,
|
|
288
|
-
|
|
299
|
+
...connectLinkFields(result.structuredContent?.error, id, link.url),
|
|
289
300
|
},
|
|
290
301
|
};
|
|
291
302
|
return {
|
|
@@ -305,6 +316,59 @@ export class AuthElicitation {
|
|
|
305
316
|
});
|
|
306
317
|
}), this.options.requestSignal).pipe(Effect.provideService(RequestScope, requestOwner(this.options.requestScope)));
|
|
307
318
|
}, AuthElicitation.prototype.run);
|
|
319
|
+
registerAuthMethod(AuthElicitation.prototype, "refused", function (action, state, force) {
|
|
320
|
+
const self = this;
|
|
321
|
+
return authAwait(authFlow(function* () {
|
|
322
|
+
const { code, message } = REFUSED[action];
|
|
323
|
+
const { connector: id, tool } = state;
|
|
324
|
+
if (!self.mayConnect(id))
|
|
325
|
+
return failure(code, message);
|
|
326
|
+
const { registry, publicUrl, canManage, unavailable, credentialHandoffUrl, routes, requestScope } = self.options;
|
|
327
|
+
try {
|
|
328
|
+
// The signed state bound these arguments, so this is the restart the
|
|
329
|
+
// caller asked for, sharing the prompt's reset if that prompt was opened.
|
|
330
|
+
const forced = tool === "authorize_connector" && force === true;
|
|
331
|
+
// The ordinary handoff, except that the link is the sealed form this
|
|
332
|
+
// client was already shown: the agent sees no principal in it.
|
|
333
|
+
const handoff = yield* upwardStep(authCall(authorizationHandoff, [
|
|
334
|
+
registry,
|
|
335
|
+
publicUrl,
|
|
336
|
+
requestScope,
|
|
337
|
+
{
|
|
338
|
+
canManageAuth: canManage,
|
|
339
|
+
credentialHandoffUrl,
|
|
340
|
+
oauthConnectUnavailable: unavailable,
|
|
341
|
+
routes,
|
|
342
|
+
oauthConnectUrl: authMethod(() => authInvoke(self.options, "connectLink", [
|
|
343
|
+
id,
|
|
344
|
+
forced,
|
|
345
|
+
forced ? state.browserNonces[0] : undefined,
|
|
346
|
+
]).pipe(Effect.map((link) => link.url))),
|
|
347
|
+
},
|
|
348
|
+
{ connector: id },
|
|
349
|
+
]));
|
|
350
|
+
const url = handoff.authorizationUrl ?? handoff.operatorUrl;
|
|
351
|
+
if (!url)
|
|
352
|
+
return failure(code, message);
|
|
353
|
+
if (handoff.authorizationUrl)
|
|
354
|
+
sentSecretsForRequest(requestScope).mintedState(handoff.authorizationUrl);
|
|
355
|
+
return toolError({
|
|
356
|
+
...handoff,
|
|
357
|
+
code,
|
|
358
|
+
message,
|
|
359
|
+
retryable: false,
|
|
360
|
+
nextAction: {
|
|
361
|
+
url,
|
|
362
|
+
purpose: refusedPurpose(handoff.authorizationUrl ? "" : "for the operator to open, "),
|
|
363
|
+
},
|
|
364
|
+
}, { ok: false });
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
// A signing or storage failure cannot replace the refusal itself.
|
|
368
|
+
return failure(code, message + REFUSED_NO_LINK);
|
|
369
|
+
}
|
|
370
|
+
}), this.options.requestSignal).pipe(Effect.provideService(RequestScope, requestOwner(this.options.requestScope)));
|
|
371
|
+
}, AuthElicitation.prototype.refused);
|
|
308
372
|
registerAuthMethod(AuthElicitation.prototype, "connected", function (id, context) {
|
|
309
373
|
const self = this;
|
|
310
374
|
return authAwait(authFlow(function* () {
|
|
@@ -331,29 +395,16 @@ export class AuthElicitation {
|
|
|
331
395
|
}
|
|
332
396
|
}), this.options.requestSignal).pipe(Effect.provideService(RequestScope, requestOwner(this.options.requestScope)));
|
|
333
397
|
}, AuthElicitation.prototype.connected);
|
|
334
|
-
registerAuthMethod(AuthElicitation.prototype, "
|
|
398
|
+
registerAuthMethod(AuthElicitation.prototype, "browserCompleted", function (state) {
|
|
335
399
|
const self = this;
|
|
336
400
|
return authAwait(authFlow(function* () {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const progress = yield* upwardStep(authInvoke(self.options, "linkProgress", [
|
|
343
|
-
state.connector,
|
|
344
|
-
nonce,
|
|
345
|
-
]));
|
|
346
|
-
if (progress === "started")
|
|
347
|
-
started = nonce;
|
|
348
|
-
if (progress === "claimed")
|
|
349
|
-
claimed = nonce;
|
|
350
|
-
completed ||= progress === "started";
|
|
351
|
-
pending ||= progress === "claimed";
|
|
352
|
-
}
|
|
353
|
-
const predecessor = started ?? claimed;
|
|
354
|
-
return { ...(predecessor ? { predecessor } : {}), completed: completed && !pending };
|
|
401
|
+
// A reset never leaves "started", so no reset can be running behind this link.
|
|
402
|
+
for (const nonce of state.browserNonces)
|
|
403
|
+
if (yield* upwardStep(authInvoke(self.options, "linkStarted", [state.connector, nonce])))
|
|
404
|
+
return true;
|
|
405
|
+
return false;
|
|
355
406
|
}), this.options.requestSignal).pipe(Effect.provideService(RequestScope, requestOwner(this.options.requestScope)));
|
|
356
|
-
}, AuthElicitation.prototype.
|
|
407
|
+
}, AuthElicitation.prototype.browserCompleted);
|
|
357
408
|
registerAuthMethod(AuthElicitation.prototype, "verify", function (wire, context) {
|
|
358
409
|
const self = this;
|
|
359
410
|
return authAwait(authFlow(function* () {
|
|
@@ -380,7 +431,13 @@ export class AuthElicitation {
|
|
|
380
431
|
!Number.isInteger(state.round) ||
|
|
381
432
|
state.round < 1 ||
|
|
382
433
|
state.round > MAX_ROUNDS ||
|
|
383
|
-
![
|
|
434
|
+
![
|
|
435
|
+
"call_tool",
|
|
436
|
+
"call_destructive_tool",
|
|
437
|
+
"execute_code",
|
|
438
|
+
"execute_read_code",
|
|
439
|
+
"authorize_connector",
|
|
440
|
+
].includes(String(state.tool)) ||
|
|
384
441
|
typeof state.wireTool !== "string" ||
|
|
385
442
|
typeof state.digest !== "string" ||
|
|
386
443
|
!/^[a-f0-9]{64}$/.test(state.digest) ||
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { routePhrases } from "./agent-routes.js";
|
|
1
|
+
import { handoffInHand, routePhrases } from "./agent-routes.js";
|
|
2
2
|
import { upwardStep } from "./upward.js";
|
|
3
3
|
import { authCall, authInvoke, authFlow, registerAuth } from "./auth/native.js";
|
|
4
4
|
import { Context, Deferred, Effect, Scheduler } from "effect";
|
|
@@ -15,6 +15,38 @@ function outcomeAware(build) {
|
|
|
15
15
|
afterUnknownWrite.set(handoff, build(true));
|
|
16
16
|
return handoff;
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* What to do with a handoff the error already carries. Passing it on is the
|
|
20
|
+
* whole step: calling authorize_connector would only return the same thing.
|
|
21
|
+
* After a write of unknown outcome the follow-up reconciles, never retries.
|
|
22
|
+
*/
|
|
23
|
+
function handoffAction(handoff, unknownWrite) {
|
|
24
|
+
const then = unknownWrite
|
|
25
|
+
? "After they confirm, re-read the write's target before any fresh call."
|
|
26
|
+
: "Retry after they confirm.";
|
|
27
|
+
const url = handoff.authorizationUrl ?? handoff.operatorUrl;
|
|
28
|
+
if (!url)
|
|
29
|
+
return {
|
|
30
|
+
action: "contact_operator",
|
|
31
|
+
purpose: `Tell the user the operator must fix this connector, then stop. ${then}`,
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
url,
|
|
35
|
+
purpose: `Give this link to the user, unchanged, ${handoff.authorizationUrl ? "" : "for the operator to open, "}then stop. ${then}`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const unknownWriteError = (error) => error.writeOutcome === "unknown" || error.reconciliationRequired === true;
|
|
39
|
+
/**
|
|
40
|
+
* The fields for an auth error given only a connect link, where a capable host's
|
|
41
|
+
* prompt could not carry it: the same direct step as a full handoff.
|
|
42
|
+
*/
|
|
43
|
+
export function connectLinkFields(error, connector, authorizationUrl) {
|
|
44
|
+
return {
|
|
45
|
+
...(typeof error.message === "string" ? { message: handoffInHand(error.message, connector) } : {}),
|
|
46
|
+
authorizationUrl,
|
|
47
|
+
nextAction: handoffAction({ connector, recovery: "oauth", authorizationUrl }, unknownWriteError(error)),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
18
50
|
function oauthFollowUp(connectorId, unknownWrite, opts) {
|
|
19
51
|
const then = `Then ${unknownWrite ? RECONCILE : "retry the original call"}`;
|
|
20
52
|
// Without programs, no connecta.search can confirm the catalog.
|
|
@@ -95,7 +127,7 @@ const authorizationHandoffNative = /* @__PURE__ */ registerAuth(authorizationHan
|
|
|
95
127
|
recovery: "oauth",
|
|
96
128
|
status: "auth_required",
|
|
97
129
|
authorizationUrl,
|
|
98
|
-
instructions: "
|
|
130
|
+
instructions: "For the person opening this link: sign in as the user who requested this connection; Connecta verifies identity and permission before starting consent. For the agent: pass authorizationUrl on exactly as returned. " +
|
|
99
131
|
oauthFollowUp(connector.id, unknownWrite, opts),
|
|
100
132
|
}));
|
|
101
133
|
});
|
|
@@ -180,7 +212,8 @@ export function withAuthorizationHandoffEffect(scope, error) {
|
|
|
180
212
|
return {
|
|
181
213
|
...error,
|
|
182
214
|
...value,
|
|
183
|
-
message: error.message,
|
|
215
|
+
message: handoffInHand(error.message, connector),
|
|
216
|
+
nextAction: handoffAction(value, unknownWriteError(error)),
|
|
184
217
|
...(value.message ? { instructions: value.message } : {}),
|
|
185
218
|
};
|
|
186
219
|
}),
|