@zackbart/connecta 0.26.0 → 0.26.1
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 +116 -0
- package/dist/artifacts/document.js +6 -2
- package/dist/artifacts/markdown.js +43 -14
- package/dist/artifacts/routes.js +27 -4
- package/dist/artifacts/validate.js +1 -0
- package/dist/auth/downstream-oauth.js +397 -48
- package/dist/auth/oauth-start-reset.js +20 -0
- package/dist/branding.d.ts +87 -0
- package/dist/branding.js +170 -1
- package/dist/connectors/remote-mcp.js +32 -7
- package/dist/execute.js +7 -8
- package/dist/oauth-callback-outcome.js +43 -18
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/view.js +288 -33
- package/dist/page-styles.js +7 -0
- package/dist/registry.js +16 -13
- package/dist/routes/mcp.js +5 -1
- package/dist/routes/oauth-management.js +66 -12
- package/dist/routes/oauth.js +54 -87
- package/dist/routes/ui.js +7 -1
- package/dist/server.js +7 -5
- package/dist/skills.js +6 -6
- package/dist/types.d.ts +5 -0
- package/dist/ui.d.ts +5 -0
- package/dist/ui.js +20 -63
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +18 -8
- package/documentation/auth.md +103 -0
- package/documentation/code-mode.md +15 -15
- package/documentation/meta-tools.md +12 -5
- package/package.json +1 -1
- package/templates/node/package.json +1 -1
|
@@ -1,8 +1,29 @@
|
|
|
1
1
|
import { Effect, Result } from "effect";
|
|
2
|
+
import { drainOAuthStartResets } from "../auth/oauth-start-reset.js";
|
|
2
3
|
import { closeScope } from "../runtime/connector-scope.js";
|
|
4
|
+
import { withDeadlineEffect } from "../runtime/run.js";
|
|
3
5
|
import { isSafeHttpUrl } from "../ui.js";
|
|
4
6
|
import { authorizedPerson, refuse, serveOperator, visibleRegistry, } from "./operator.js";
|
|
5
7
|
import { mayManageConnector, isSameOrigin, msg, privateJson, } from "./shared.js";
|
|
8
|
+
/** Bounds the start and its handoff; invalidation and scope close finish after it. */
|
|
9
|
+
const OAUTH_START_TIMEOUT_MS = 30_000;
|
|
10
|
+
/**
|
|
11
|
+
* How a POST starts authorization, from its `mode` query parameter:
|
|
12
|
+
* `restart` (the default) resets the connector to a fresh epoch — wiping the
|
|
13
|
+
* grant, the client registration, and discovery — before starting over;
|
|
14
|
+
* `continue` hands back a recent pending authorization URL when one exists
|
|
15
|
+
* and otherwise starts a flow in the current epoch, keeping the stored
|
|
16
|
+
* registration. `null` is a malformed request.
|
|
17
|
+
*/
|
|
18
|
+
function startMode(url) {
|
|
19
|
+
const modes = url.searchParams.getAll("mode");
|
|
20
|
+
if (modes.length === 0)
|
|
21
|
+
return "restart";
|
|
22
|
+
const [mode] = modes;
|
|
23
|
+
return modes.length === 1 && (mode === "continue" || mode === "restart")
|
|
24
|
+
? mode
|
|
25
|
+
: null;
|
|
26
|
+
}
|
|
6
27
|
function oauthManagementRequest(context, connectorId) {
|
|
7
28
|
const { request, baseUrl, defer, opts } = context;
|
|
8
29
|
return Effect.gen(function* () {
|
|
@@ -27,35 +48,65 @@ function oauthManagementRequest(context, connectorId) {
|
|
|
27
48
|
if (!disconnecting && request.method !== "POST") {
|
|
28
49
|
return yield* refuse("method not allowed", 405);
|
|
29
50
|
}
|
|
30
|
-
const
|
|
51
|
+
const mode = disconnecting ? undefined : startMode(context.url);
|
|
52
|
+
if (mode === null) {
|
|
53
|
+
return yield* refuse('mode must be "continue" or "restart"', 400);
|
|
54
|
+
}
|
|
55
|
+
let ctx;
|
|
31
56
|
// The connector scope this request opened ends with it, however it ends.
|
|
32
|
-
yield* Effect.addFinalizer(() => closeScope(connector, ctx, defer));
|
|
33
|
-
const
|
|
57
|
+
yield* Effect.addFinalizer(() => ctx ? closeScope(connector, ctx, defer) : Effect.void);
|
|
58
|
+
const timeoutError = new Error("OAuth authorization start timed out");
|
|
59
|
+
const operation = yield* Effect.result(withDeadlineEffect((signal) => Effect.tryPromise({
|
|
34
60
|
try: async () => {
|
|
61
|
+
ctx = registry.contextFor(connectorId, baseUrl, {}, { signal });
|
|
35
62
|
if (disconnecting) {
|
|
36
63
|
await disconnectAuth(ctx);
|
|
37
64
|
return undefined;
|
|
38
65
|
}
|
|
39
|
-
const started = await startAuth(ctx, { force:
|
|
66
|
+
const started = await startAuth(ctx, { force: mode === "restart" });
|
|
67
|
+
if (signal.aborted)
|
|
68
|
+
throw signal.reason;
|
|
40
69
|
if (started.authorizationUrl) {
|
|
41
70
|
await registry.bindOAuthHandoff(connectorId, started.authorizationUrl);
|
|
42
71
|
}
|
|
43
72
|
return started;
|
|
44
73
|
},
|
|
45
74
|
catch: (error) => error,
|
|
75
|
+
}), {
|
|
76
|
+
// A disconnect deliberately ignores both browser cancellation and
|
|
77
|
+
// this start deadline so its reset reaches invalidation.
|
|
78
|
+
...(disconnecting ? {} : { timeoutMs: OAUTH_START_TIMEOUT_MS }),
|
|
79
|
+
...(disconnecting ? {} : { signal: request.signal }),
|
|
80
|
+
timeoutError,
|
|
46
81
|
}));
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
82
|
+
const opened = ctx;
|
|
83
|
+
if (Result.isFailure(operation) && opened) {
|
|
84
|
+
// Storage has no abort contract. If a reset's generation write is still
|
|
85
|
+
// pending, answering now would let it publish over a newer flow.
|
|
86
|
+
yield* Effect.promise(() => drainOAuthStartResets(opened.requestScope ?? opened));
|
|
87
|
+
}
|
|
88
|
+
// A disconnect or a restart invalidates the old grant and its cached
|
|
89
|
+
// catalog, even when a partially failed physical cleanup left its epoch
|
|
90
|
+
// fence standing. So does a continued start that had to begin a new flow:
|
|
91
|
+
// the grant it found was missing or refused. A continued start that
|
|
92
|
+
// reused a pending URL or found the connection healthy changed nothing,
|
|
93
|
+
// so the catalog stays. (A continue does reset a disconnected connector,
|
|
94
|
+
// but the disconnect already invalidated that catalog.)
|
|
95
|
+
const started = Result.isSuccess(operation) ? operation.success : undefined;
|
|
96
|
+
const reused = mode === "continue" && started?.authorizationReused === true;
|
|
97
|
+
const unchanged = mode === "continue" && (reused || started?.state === "ok");
|
|
98
|
+
const invalidated = unchanged
|
|
99
|
+
? Result.succeed(undefined)
|
|
100
|
+
: yield* Effect.result(Effect.tryPromise({
|
|
101
|
+
try: () => registry.invalidateStored(connectorId),
|
|
102
|
+
catch: (error) => error,
|
|
103
|
+
}));
|
|
53
104
|
// Every failure below answers in the route's own fixed words. A hook's
|
|
54
105
|
// rejection and a status message can quote a token endpoint's error body
|
|
55
106
|
// or a provider's refusal, either of which can quote the secret it was
|
|
56
107
|
// sent, so that text goes to the deployment's log and nowhere else.
|
|
57
108
|
const failed = (detail, fixed, status) => {
|
|
58
|
-
opts.logger.warn(`[connecta] connector "${connectorId}" OAuth ${disconnecting ? "disconnect" :
|
|
109
|
+
opts.logger.warn(`[connecta] connector "${connectorId}" OAuth ${disconnecting ? "disconnect" : mode} failed: ${detail}`);
|
|
59
110
|
return refuse(fixed, status);
|
|
60
111
|
};
|
|
61
112
|
const couldNot = disconnecting
|
|
@@ -64,6 +115,9 @@ function oauthManagementRequest(context, connectorId) {
|
|
|
64
115
|
// A Result, not a caught value, decides whether it failed: a hook that
|
|
65
116
|
// rejects with no reason at all still failed.
|
|
66
117
|
if (Result.isFailure(operation)) {
|
|
118
|
+
if (operation.failure === timeoutError) {
|
|
119
|
+
return yield* failed(`start exceeded ${OAUTH_START_TIMEOUT_MS} ms`, "OAuth authorization start timed out", 504);
|
|
120
|
+
}
|
|
67
121
|
return yield* failed(msg(operation.failure), couldNot, 400);
|
|
68
122
|
}
|
|
69
123
|
if (Result.isFailure(invalidated)) {
|
|
@@ -92,7 +146,7 @@ function oauthManagementRequest(context, connectorId) {
|
|
|
92
146
|
// stopped shipping, and the state and the link are the whole answer.
|
|
93
147
|
return privateJson({
|
|
94
148
|
state: result.state,
|
|
95
|
-
...(authorizationUrl ? { authorizationUrl } : {}),
|
|
149
|
+
...(authorizationUrl ? { authorizationUrl, reused } : {}),
|
|
96
150
|
});
|
|
97
151
|
}).pipe(Effect.scoped);
|
|
98
152
|
}
|
package/dist/routes/oauth.js
CHANGED
|
@@ -1,92 +1,59 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
2
|
import { oauthValueStorageKey } from "../auth/downstream-oauth.js";
|
|
3
|
-
import { resolveBranding } from "../branding.js";
|
|
3
|
+
import { escapeHtml, renderPage, resolveBranding, STATUS_ICONS } from "../branding.js";
|
|
4
4
|
import { oauthCallbackOutcome, providerErrorReason, } from "../oauth-callback-outcome.js";
|
|
5
5
|
import { authorizeUiIdentity, mayManageConnector, validateAuthPermissions, loggableValue, msg, } from "./shared.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
.replaceAll('"', """);
|
|
12
|
-
}
|
|
6
|
+
const TONE_MARKS = {
|
|
7
|
+
ok: { className: " ok", icon: STATUS_ICONS.ok, label: "Connected" },
|
|
8
|
+
declined: { className: "", icon: STATUS_ICONS.declined, label: "Not connected" },
|
|
9
|
+
problem: { className: " danger", icon: STATUS_ICONS.problem, label: "Not connected" },
|
|
10
|
+
};
|
|
13
11
|
/**
|
|
14
|
-
* The callback's one page. It renders a reason from a
|
|
15
|
-
* reason's fixed copy, so nothing the request carried —
|
|
16
|
-
* parameter, an exchange failure's message — can reach
|
|
17
|
-
*
|
|
12
|
+
* The callback's one page, in the shared layout. It renders a reason from a
|
|
13
|
+
* closed set and that reason's fixed copy, so nothing the request carried —
|
|
14
|
+
* the provider's `error` parameter, an exchange failure's message — can reach
|
|
15
|
+
* the body. The connector's title appears only when `connector` is passed,
|
|
16
|
+
* which the route does only after the state check, and `oauthCallbackOutcome`
|
|
17
|
+
* gates it by reason again; every refusal before that point is the same bytes
|
|
18
|
+
* for every connector id and path. Escaping stays anyway: branding and a
|
|
19
|
+
* configured title are still strings.
|
|
20
|
+
*
|
|
21
|
+
* The fix prompt is for whoever runs the deployment, and the person reading
|
|
22
|
+
* this page is often a teammate finishing personal auth, so it sits folded
|
|
23
|
+
* under a quiet disclosure rather than in the message.
|
|
18
24
|
*/
|
|
19
|
-
function html(reason,
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
: `<span class="product">${escapeHtml(brand.productName)}</span>`
|
|
25
|
+
function html(reason, opts, connector) {
|
|
26
|
+
const brand = resolveBranding(opts.branding);
|
|
27
|
+
const outcome = oauthCallbackOutcome(reason, connector, brand.productName);
|
|
28
|
+
const mark = TONE_MARKS[outcome.tone];
|
|
29
|
+
const uiMounted = Boolean(opts.ui);
|
|
30
|
+
const home = uiMounted
|
|
31
|
+
? `<div class="status-actions"><a class="btn${outcome.tone === "ok" ? "" : " primary"}" href="/">Return to ${escapeHtml(brand.productName)}</a></div>`
|
|
32
|
+
: "";
|
|
33
|
+
const details = outcome.fixPrompt
|
|
34
|
+
? `<details class="status-details">
|
|
35
|
+
<summary>Details for the operator</summary>
|
|
36
|
+
<p>If this keeps happening, send this to whoever runs ${escapeHtml(brand.productName)}. It is written for a coding agent working on the deployment and carries nothing from the provider's response.</p>
|
|
37
|
+
<pre>${escapeHtml(outcome.fixPrompt)}</pre>
|
|
38
|
+
</details>`
|
|
34
39
|
: "";
|
|
35
|
-
|
|
36
|
-
<
|
|
37
|
-
<head>
|
|
38
|
-
<
|
|
39
|
-
<
|
|
40
|
-
<meta name="theme-color" content="${escapeHtml(brand.themeColor)}">
|
|
41
|
-
<link rel="icon" href="${escapeHtml(brand.faviconHref)}" type="image/svg+xml">
|
|
42
|
-
<link rel="shortcut icon" href="/favicon.ico">
|
|
43
|
-
<title>${escapeHtml(title)}</title>
|
|
44
|
-
<style>
|
|
45
|
-
* { border-radius: 0; box-sizing: border-box; }
|
|
46
|
-
html { color: #000; background: #fff; font: 16px/1.5 "Helvetica Neue",
|
|
47
|
-
Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; }
|
|
48
|
-
body { margin: 0; min-height: 100vh; }
|
|
49
|
-
::selection { color: #fff; background: #000; }
|
|
50
|
-
.shell { margin: 0 auto; max-width: 70rem; padding: 1rem; }
|
|
51
|
-
.grid { display: grid; gap: 1rem 1.5rem;
|
|
52
|
-
grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
53
|
-
.brand { font-weight: 500; grid-column: 1; text-decoration: none; }
|
|
54
|
-
.product { grid-column: 2 / -1; }
|
|
55
|
-
main { margin-top: 5rem; }
|
|
56
|
-
h1, p { font: inherit; margin: 0; }
|
|
57
|
-
h1 { grid-column: 1; }
|
|
58
|
-
.copy { grid-column: 2 / -1; max-width: 34em; }
|
|
59
|
-
.copy > * + * { margin-top: 1.5rem; }
|
|
60
|
-
a { color: inherit; text-decoration: underline; text-decoration-thickness: 1.5px;
|
|
61
|
-
text-underline-offset: .22em; }
|
|
62
|
-
a:hover { text-decoration-color: transparent; }
|
|
63
|
-
a:focus-visible, summary:focus-visible { outline: 1px solid #000; outline-offset: 2px; }
|
|
64
|
-
summary { cursor: pointer; }
|
|
65
|
-
pre { font: 13px/1.5 ui-monospace, Menlo, Consolas, monospace; margin: .75rem 0 0;
|
|
66
|
-
padding: .75rem; white-space: pre-wrap; border: 1px solid #000; user-select: all; }
|
|
67
|
-
@media (max-width: 36.99rem) {
|
|
68
|
-
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
69
|
-
.product { grid-column: 2; }
|
|
70
|
-
main { margin-top: 3rem; }
|
|
71
|
-
h1, .copy { grid-column: 1 / -1; }
|
|
72
|
-
}
|
|
73
|
-
</style>
|
|
74
|
-
</head>
|
|
75
|
-
<body>
|
|
76
|
-
<header class="shell grid">
|
|
77
|
-
${owner}
|
|
78
|
-
${product}
|
|
79
|
-
</header>
|
|
80
|
-
<main class="shell grid" data-oauth-callback="${outcome.reason}">
|
|
81
|
-
<h1>Connection status</h1>
|
|
82
|
-
<div class="copy">
|
|
83
|
-
<p>${escapeHtml(outcome.message)}</p>
|
|
84
|
-
${outcome.fixPrompt ? `<details><summary>Fix prompt for a coding agent</summary><pre>${escapeHtml(outcome.fixPrompt)}</pre></details>` : ""}
|
|
85
|
-
${hasUi ? `<p><a href="/">Return to ${escapeHtml(brand.productName)}</a></p>` : ""}
|
|
40
|
+
const body = `<main class="page shell" data-oauth-callback="${outcome.reason}">
|
|
41
|
+
<section class="status-page">
|
|
42
|
+
<div class="status-head">
|
|
43
|
+
<span class="status-mark${mark.className}" aria-hidden="true">${mark.icon}</span>
|
|
44
|
+
<p class="status-label${mark.className}">${mark.label}</p>
|
|
86
45
|
</div>
|
|
87
|
-
|
|
88
|
-
</
|
|
89
|
-
|
|
46
|
+
<h1>${escapeHtml(outcome.heading)}</h1>
|
|
47
|
+
<p class="status-copy">${escapeHtml(outcome.message)}</p>
|
|
48
|
+
${home}
|
|
49
|
+
${details}
|
|
50
|
+
</section>
|
|
51
|
+
</main>`;
|
|
52
|
+
return new Response(renderPage(opts.branding, {
|
|
53
|
+
title: `${outcome.heading} — ${brand.pageTitle}`,
|
|
54
|
+
uiMounted,
|
|
55
|
+
body,
|
|
56
|
+
}), { status: outcome.status, headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
90
57
|
}
|
|
91
58
|
/**
|
|
92
59
|
* Pay the storage read a real downstream-OAuth refusal pays, on the refusal
|
|
@@ -138,10 +105,10 @@ async function finishOAuthCallback(context) {
|
|
|
138
105
|
const { path, url, baseUrl, opts } = context;
|
|
139
106
|
const error = url.searchParams.get("error");
|
|
140
107
|
if (error)
|
|
141
|
-
return html(providerErrorReason(error), opts
|
|
108
|
+
return html(providerErrorReason(error), opts);
|
|
142
109
|
const code = url.searchParams.get("code");
|
|
143
110
|
if (!code)
|
|
144
|
-
return html("invalid_callback", opts
|
|
111
|
+
return html("invalid_callback", opts);
|
|
145
112
|
const id = path.slice("/oauth/callback/".length);
|
|
146
113
|
const state = url.searchParams.get("state");
|
|
147
114
|
const callbackTarget = await opts.registry.oauthCallbackView(id, state);
|
|
@@ -155,7 +122,7 @@ async function finishOAuthCallback(context) {
|
|
|
155
122
|
const connectorContext = callbackRegistry
|
|
156
123
|
? callbackRegistry.contextFor(id, baseUrl)
|
|
157
124
|
: opts.registry.contextFor(id, baseUrl);
|
|
158
|
-
const refused = () => html("invalid_callback", opts
|
|
125
|
+
const refused = () => html("invalid_callback", opts);
|
|
159
126
|
if (!connector || !connector.finishAuth) {
|
|
160
127
|
await equalizeRefusalCost(connectorContext);
|
|
161
128
|
return refused();
|
|
@@ -220,19 +187,19 @@ async function finishOAuthCallback(context) {
|
|
|
220
187
|
opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
|
|
221
188
|
`${loggableValue(id)} with 500: its principal handoff could not be ` +
|
|
222
189
|
`consumed (${loggableValue(msg(err))}). No authorization code was exchanged.`);
|
|
223
|
-
return html("handoff_failed", opts
|
|
190
|
+
return html("handoff_failed", opts, connector);
|
|
224
191
|
}
|
|
225
192
|
}
|
|
226
193
|
try {
|
|
227
194
|
await connector.finishAuth(code, connectorContext, url.searchParams);
|
|
228
195
|
await callbackRegistry.invalidateStored(id);
|
|
229
|
-
return html("connected", opts
|
|
196
|
+
return html("connected", opts, connector);
|
|
230
197
|
}
|
|
231
198
|
catch (err) {
|
|
232
199
|
// The page names the reason and nothing else; what the exchange threw can
|
|
233
200
|
// quote a token endpoint's body, so it goes only to the operator log.
|
|
234
201
|
opts.logger.warn(`[connecta] OAuth callback for connector ${loggableValue(id)} failed ` +
|
|
235
202
|
`with 500: the authorization code exchange threw ${loggableValue(msg(err))}.`);
|
|
236
|
-
return html("exchange_failed", opts
|
|
203
|
+
return html("exchange_failed", opts, connector);
|
|
237
204
|
}
|
|
238
205
|
}
|
package/dist/routes/ui.js
CHANGED
|
@@ -95,9 +95,15 @@ export async function routeUi(context) {
|
|
|
95
95
|
// An artifact shell frames the sandboxed page from its own origin and
|
|
96
96
|
// nothing else; Connections stays on the deployment's public origin.
|
|
97
97
|
const homeUrl = opts.publicUrl ? new URL("/", opts.publicUrl).toString() : "/";
|
|
98
|
+
// The artifact host serves no favicon, so a shell there takes its icons
|
|
99
|
+
// from the public origin, as the 404 does; on the main host they stay
|
|
100
|
+
// root-relative.
|
|
101
|
+
const onArtifactHost = Boolean(opts.artifactOrigin) &&
|
|
102
|
+
url.origin === new URL(opts.artifactOrigin).origin;
|
|
103
|
+
const iconOrigin = onArtifactHost ? opts.publicUrl : undefined;
|
|
98
104
|
return new Response(request.method === "HEAD"
|
|
99
105
|
? null
|
|
100
|
-
: renderUiHtml(uiAuth, mcpUrl, opts.branding, nonce, operatorPage, { homeUrl }), {
|
|
106
|
+
: renderUiHtml(uiAuth, mcpUrl, opts.branding, nonce, operatorPage, { homeUrl, iconOrigin }), {
|
|
101
107
|
status: 200,
|
|
102
108
|
headers: {
|
|
103
109
|
"Content-Type": "text/html; charset=utf-8",
|
package/dist/server.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
|
+
import { notFoundResponse } from "./branding.js";
|
|
2
3
|
import { aggregateCallAdmissionSnapshots } from "./call-admission.js";
|
|
3
4
|
import { isAdmittingExecutor } from "./executor-admission.js";
|
|
4
5
|
import { createMcpRoute, MCP_CORS_HEADERS } from "./routes/mcp.js";
|
|
@@ -107,7 +108,7 @@ export function createFetchHandler(opts) {
|
|
|
107
108
|
const response = yield* metadata(request, baseUrl);
|
|
108
109
|
if (response)
|
|
109
110
|
return response;
|
|
110
|
-
return
|
|
111
|
+
return notFoundResponse(request, opts);
|
|
111
112
|
}
|
|
112
113
|
if (path === "/health")
|
|
113
114
|
return yield* Effect.promise(health);
|
|
@@ -117,7 +118,7 @@ export function createFetchHandler(opts) {
|
|
|
117
118
|
const mcp = yield* routeMcp.handle(context);
|
|
118
119
|
if (mcp)
|
|
119
120
|
return mcp;
|
|
120
|
-
return
|
|
121
|
+
return notFoundResponse(request, opts);
|
|
121
122
|
});
|
|
122
123
|
const serve = (request, runtimeContext) => Effect.suspend(() => {
|
|
123
124
|
const url = new URL(request.url);
|
|
@@ -127,15 +128,16 @@ export function createFetchHandler(opts) {
|
|
|
127
128
|
? runtimeContext.waitUntil.bind(runtimeContext)
|
|
128
129
|
: undefined;
|
|
129
130
|
// The artifact hostname has no MCP, health, OAuth, or operator routes.
|
|
130
|
-
// Dispatch before MCP origin checks so even a hostile /mcp
|
|
131
|
+
// Dispatch before MCP origin checks so even a hostile /mcp gets the same
|
|
132
|
+
// 404 as any other unserved path.
|
|
131
133
|
if (opts.artifactOrigin && url.origin === new URL(opts.artifactOrigin).origin) {
|
|
132
134
|
if (!isArtifactPath(path) || !opts.ui || !opts.artifactsModule) {
|
|
133
|
-
return Effect.succeed(withSecurityHeaders(
|
|
135
|
+
return Effect.succeed(withSecurityHeaders(notFoundResponse(request, opts), url, path));
|
|
134
136
|
}
|
|
135
137
|
const context = {
|
|
136
138
|
request, url, path, baseUrl: opts.artifactOrigin, opts, defer, runtimeContext,
|
|
137
139
|
};
|
|
138
|
-
return Effect.map(Effect.promise(() => opts.ui.handle(context)), response => withSecurityHeaders(response ??
|
|
140
|
+
return Effect.map(Effect.promise(() => opts.ui.handle(context)), response => withSecurityHeaders(response ?? notFoundResponse(request, opts), url, path));
|
|
139
141
|
}
|
|
140
142
|
if (opts.artifactOrigin && isArtifactPath(path) && opts.ui && opts.artifactsModule) {
|
|
141
143
|
const target = new URL(opts.artifactOrigin);
|
package/dist/skills.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { boundedEchoText } from "./errors.js";
|
|
2
|
-
const ROUTE = "Choose a route before discovery. A known-address read needs only call_tool. Unknown-address read-only work starts with execute_code to discover, call, and return the answer; use the same route for reduction, multiple or dependent calls, loops, joins, or branches. Keep discovery and calls together when schemas suffice; do not return catalog matches alone. Inspect unfamiliar
|
|
2
|
+
const ROUTE = "Choose a route before discovery. A known-address read needs only call_tool. Unknown-address read-only work starts with execute_code to discover, call, and return the answer; use the same route for reduction, multiple or dependent calls, loops, joins, or branches. Keep discovery and calls together when schemas suffice; do not return catalog matches alone. Inspect unfamiliar read results with a small sample before proceeding.";
|
|
3
3
|
const RECOVERY = 'After auth_required use authorize_connector. After a truncated direct result use get_result. Guidance is on demand: fetch skills({ name: "usage" }) only when these instructions and the tool description are insufficient or a run needs repair.';
|
|
4
4
|
/**
|
|
5
5
|
* The MCP instructions of a deployment whose programs pause at writes. The
|
|
@@ -8,7 +8,7 @@ const RECOVERY = 'After auth_required use authorize_connector. After a truncated
|
|
|
8
8
|
* only that programs *may* write reads each write after a lookup as a known
|
|
9
9
|
* one and sends them one prompt at a time.
|
|
10
10
|
*/
|
|
11
|
-
export const CONNECTA_INSTRUCTIONS = `Choose a route before discovery.
|
|
11
|
+
export const CONNECTA_INSTRUCTIONS = `Choose a route before discovery. One known read: call_tool; one known write: call_destructive_tool. Everything else starts with execute_code to discover, call, and return the answer: unknown addresses, reduction, multiple or dependent calls, and writes that follow reads. Keep authorized batches in one program. Sample unfamiliar reads. Reduce a one-time write's full result there or page a direct call with get_result; never repeat it to recover output. Non-exempt program writes pause unsent; resume_execution with the returned token, address, and args approves the call and continues the program. Approval "tool" covers later same-address calls in this run; other approval-required writes need their own approval. ${RECOVERY}`;
|
|
12
12
|
/**
|
|
13
13
|
* The instructions of a deployment without resumable writes, whose programs
|
|
14
14
|
* refuse every write: the routing it always had.
|
|
@@ -35,7 +35,7 @@ The minimum guest API is:
|
|
|
35
35
|
|
|
36
36
|
## Discover and select
|
|
37
37
|
|
|
38
|
-
Search and call in one run when schemas suffice.
|
|
38
|
+
Search and call in one run when schemas suffice. Sample unfamiliar reads. Reduce a one-time write's full result here or page a direct call with get_result; never repeat the write to recover output. Search distinct operations separately with 2–4 distinctive action/object terms.
|
|
39
39
|
|
|
40
40
|
For top-level catalog inspection or approval-required discovery, omit \`limit\` initially (the default is 8), then page with a limit up to 50 if needed. Empty or whitespace-only queries browse all tools. A non-empty query with no ASCII terms returns no matches; mixed input searches with its ASCII terms. \`includeSchemas: "compact"\` adds bounded input and available output shapes. An \`outputSchemaSource: "observed"\` shape is a hint, not a contract. Plain objects expose \`inputKeys\`, \`requiredInputKeys\`, and \`outputKeys\`; truncation flags mark incomplete shapes; matches also carry declared annotations.
|
|
41
41
|
|
|
@@ -43,12 +43,12 @@ For top-level catalog inspection or approval-required discovery, omit \`limit\`
|
|
|
43
43
|
- Use \`includeSchemas: "json"\` for programmatic schema inspection; compact schemas are text, not objects with \`.properties\`. Check connectorTitle for the account/environment, then address, purpose, annotations, inputs, and outputs. Never select only because a result ranks first or has fewer required inputs.
|
|
44
44
|
- Supply every \`requiredInputKey\` from the task or a prior result. For dependencies, match the earlier \`outputKey\` to the later required key. An empty required-key list does not permit invented arguments. Missing \`outputKeys\` means inspect \`outputSchema\`.
|
|
45
45
|
- Use \`connecta.describe({ address })\` or \`{ addresses }\` when a compact schema is truncated or insufficient. Use \`format: "json"\` only for exact constraints. Write the property names the schema displays; never guess positions or aliases.
|
|
46
|
-
- Reduce through available output keys.
|
|
46
|
+
- Reduce through available output keys. An observed key is a hint; later results may differ. Do not guess collection roots such as \`items\` or \`results\`. If a match is missing, re-search or describe.
|
|
47
47
|
- Match provider identifiers and names exactly after resolving them from source data or a connector guide. A broad regular expression that merely finds a plausible value is not identity resolution.
|
|
48
48
|
- Preserve the schema's JSON types exactly: a numeric id is a number, not a numeric-looking string.
|
|
49
49
|
- Validate tabular headers, row arrays, and row widths before mapping them. Never let a header or partial row become data.
|
|
50
50
|
|
|
51
|
-
Only
|
|
51
|
+
Only explicitly \`readOnlyHint: true\` or config-exempt calls run unasked. With resumable writes, non-exempt approval-required calls pause unsent; \`resume_execution\` repeats the pending address, args, and token. Without them, use \`call_destructive_tool\` for writes. Keep each authorized batch in one resumable program, final post included. \`approval: "call"\` covers one write; \`approval: "tool"\` covers later same-address calls in this run. Other approval-required writes need approval. Replay requires the same call order. Sandbox code cannot widen host gates.
|
|
52
52
|
|
|
53
53
|
## Errors and repair
|
|
54
54
|
|
|
@@ -59,7 +59,7 @@ Caught Connecta errors expose \`message\`, \`code\`, \`retryable\`, and \`detail
|
|
|
59
59
|
- \`write_outcome_unknown\`: sent but unanswered, never re-sent; check the target.
|
|
60
60
|
- \`execution_expired\`, \`_diverged\`, \`_interrupted\`: check \`writes\`, then rerun.
|
|
61
61
|
- \`auth_required\`: let the failure reach the model, then use top-level \`authorize_connector\`, give its handoff to the operator, and retry after recovery.
|
|
62
|
-
-
|
|
62
|
+
- Truncated direct call: page with \`get_result\`, never repeat a write. A program result has no page handle; reduce a read in a new program. After a write, check the target or report the gap.
|
|
63
63
|
- Unknown addresses and tools carry scoped search recovery. Use it inside the current run. Do not invent an address.
|
|
64
64
|
|
|
65
65
|
For a direct call, \`resultMode: "value"\` unwraps the result. \`timeoutMs\` sets its deadline. Every call makes one attempt; use the returned error classification and retry hint to decide whether to reissue. \`diagnostics: true\` adds timing.
|
package/dist/types.d.ts
CHANGED
|
@@ -217,6 +217,11 @@ export interface ConnectorStatus {
|
|
|
217
217
|
state: ConnectorStatusState;
|
|
218
218
|
/** When state === "auth_required", the URL the operator should open. */
|
|
219
219
|
authorizationUrl?: string;
|
|
220
|
+
/**
|
|
221
|
+
* Set by a non-forced `startAuth` that handed back a still-recent pending
|
|
222
|
+
* authorization URL instead of starting a new flow. Absent otherwise.
|
|
223
|
+
*/
|
|
224
|
+
authorizationReused?: boolean;
|
|
220
225
|
message?: string;
|
|
221
226
|
/**
|
|
222
227
|
* Drift observed the last time this connector served a catalog refresh *in
|
package/dist/ui.d.ts
CHANGED
|
@@ -52,6 +52,11 @@ export declare function buildUiData(registry: RegistryView, baseUrl: string, ser
|
|
|
52
52
|
export declare function renderUiHtml(uiAuth?: UiAuthConfig, mcpUrl?: string, branding?: ConnectaBranding, nonce?: string, page?: OperatorPage, options?: {
|
|
53
53
|
/** Where the Connections page lives, when this shell is on another origin. */
|
|
54
54
|
homeUrl?: string;
|
|
55
|
+
/**
|
|
56
|
+
* The origin that serves `/favicon.*`, when this shell is on another one:
|
|
57
|
+
* an artifact host answers those paths with 404. See `PageLayout.iconOrigin`.
|
|
58
|
+
*/
|
|
59
|
+
iconOrigin?: string | undefined;
|
|
55
60
|
}): string;
|
|
56
61
|
/** Mount the connection UI without enabling any storage or activity module. */
|
|
57
62
|
export declare function operatorUi(options?: {
|
package/dist/ui.js
CHANGED
|
@@ -6,7 +6,7 @@ import { runEdge } from "./runtime/run.js";
|
|
|
6
6
|
import { isExplicitlyReadOnly } from "./tool-safety.js";
|
|
7
7
|
import { OPERATOR_UI_CSS, OPERATOR_UI_SCRIPT, } from "./operator-ui/generated.js";
|
|
8
8
|
export { filterUiConnectors, } from "./operator-ui/model.js";
|
|
9
|
-
import {
|
|
9
|
+
import { escapeHtml, isSafeHttpsUrl, notFoundResponse, renderPage, resolveBranding, } from "./branding.js";
|
|
10
10
|
export { CONNECTA_FAVICON_SVG, resolveBranding, isSafeHttpUrl, isSafeHttpsUrl, isSafeIconHref } from "./branding.js";
|
|
11
11
|
/**
|
|
12
12
|
* A JS string literal safe to inline in a script element. Escaping `/` keeps
|
|
@@ -98,13 +98,6 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
|
|
|
98
98
|
...detailOptions,
|
|
99
99
|
}));
|
|
100
100
|
}
|
|
101
|
-
function escapeHtmlAttr(value) {
|
|
102
|
-
return value
|
|
103
|
-
.replaceAll("&", "&")
|
|
104
|
-
.replaceAll('"', """)
|
|
105
|
-
.replaceAll("<", "<")
|
|
106
|
-
.replaceAll(">", ">");
|
|
107
|
-
}
|
|
108
101
|
function jsonForInlineScript(value) {
|
|
109
102
|
return JSON.stringify(value)
|
|
110
103
|
.replaceAll("<", "\\u003c")
|
|
@@ -145,71 +138,36 @@ export function renderUiHtml(uiAuth, mcpUrl = "/mcp", branding, nonce, page = "c
|
|
|
145
138
|
}
|
|
146
139
|
: (uiAuth ?? { kind: "bearer" });
|
|
147
140
|
const brand = resolveBranding(branding);
|
|
148
|
-
const title = operatorPageTitle(page, brand.pageTitle);
|
|
149
141
|
// When an operator shell ships a nonce-based CSP, every script it emits must
|
|
150
142
|
// carry that nonce to run; without a nonce the markup is unchanged.
|
|
151
143
|
const nonceAttr = nonce ? ` nonce="${nonce}"` : "";
|
|
152
|
-
// Top-left corner. With an owner set it reads "<owner> <product>"; without
|
|
153
|
-
// one the product label stands alone. Either half links out when the
|
|
154
|
-
// matching URL is configured.
|
|
155
|
-
const owner = brand.ownerName
|
|
156
|
-
? brand.ownerUrl
|
|
157
|
-
? `<a class="brand navlink" href="${escapeHtmlAttr(brand.ownerUrl)}">${escapeHtmlAttr(brand.ownerName)}</a>`
|
|
158
|
-
: `<span class="brand">${escapeHtmlAttr(brand.ownerName)}</span>`
|
|
159
|
-
: brand.productUrl
|
|
160
|
-
? `<a class="brand navlink" href="${escapeHtmlAttr(brand.productUrl)}">${escapeHtmlAttr(brand.productName)}</a>`
|
|
161
|
-
: `<span class="brand">${escapeHtmlAttr(brand.productName)}</span>`;
|
|
162
|
-
const product = brand.ownerName
|
|
163
|
-
? brand.productUrl
|
|
164
|
-
? `<a class="product navlink" href="${escapeHtmlAttr(brand.productUrl)}">${escapeHtmlAttr(brand.productName)}</a>`
|
|
165
|
-
: `<span class="product">${escapeHtmlAttr(brand.productName)}</span>`
|
|
166
|
-
: "";
|
|
167
144
|
const clerkScript = clerk && clerkScriptOrigin
|
|
168
|
-
? `<script${nonceAttr} crossorigin="anonymous" data-clerk-publishable-key="${
|
|
145
|
+
? `<script${nonceAttr} crossorigin="anonymous" data-clerk-publishable-key="${escapeHtml(clerk.publishableKey)}" src="${escapeHtml(clerkScriptOrigin)}/npm/@clerk/clerk-js@6/dist/clerk.browser.js"></script>`
|
|
169
146
|
: "";
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
:
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
<
|
|
181
|
-
|
|
182
|
-
<meta name="description" content="${escapeHtmlAttr(brand.description)}">
|
|
183
|
-
<link rel="icon" href="${escapeHtmlAttr(brand.faviconHref)}" type="image/svg+xml">
|
|
184
|
-
<link rel="shortcut icon" href="/favicon.ico">
|
|
185
|
-
<title>${escapeHtmlAttr(title)}</title>
|
|
186
|
-
${clerkScript}
|
|
187
|
-
<style>${OPERATOR_UI_CSS}${themeCss(brand.theme)}</style>
|
|
188
|
-
</head>
|
|
189
|
-
<body>
|
|
190
|
-
<a class="skip-link" href="#operatorContent">Skip to operator page</a>
|
|
191
|
-
<header class="masthead shell">
|
|
192
|
-
<div class="masthead-inner">
|
|
193
|
-
<div class="mast-nav">
|
|
194
|
-
${owner}
|
|
195
|
-
${product}
|
|
196
|
-
</div>
|
|
197
|
-
<div id="operatorNav"></div>
|
|
198
|
-
</div>
|
|
199
|
-
</header>
|
|
200
|
-
|
|
147
|
+
// The shared layout owns the head, scheme, theme, and masthead; the shell
|
|
148
|
+
// adds its own bundle (which already imports the shared tokens), the nav
|
|
149
|
+
// mount, and the app.
|
|
150
|
+
return renderPage(branding, {
|
|
151
|
+
title: operatorPageTitle(page, brand.pageTitle),
|
|
152
|
+
uiMounted: true,
|
|
153
|
+
iconOrigin: options.iconOrigin,
|
|
154
|
+
styles: OPERATOR_UI_CSS,
|
|
155
|
+
head: clerkScript,
|
|
156
|
+
skipTo: { id: "operatorContent", label: "Skip to operator page" },
|
|
157
|
+
mastheadEnd: '<div id="operatorNav"></div>',
|
|
158
|
+
body: `
|
|
201
159
|
<main id="operatorContent" class="page shell" tabindex="-1">
|
|
202
160
|
<div class="lead">
|
|
203
161
|
<h1>${OPERATOR_PAGE_LABELS[page]}</h1>
|
|
204
162
|
<div class="lead-copy">
|
|
205
|
-
<p>${
|
|
163
|
+
<p>${escapeHtml(brand.description)}</p>
|
|
206
164
|
<noscript><p class="msg">The operator pages need JavaScript. Nothing else here
|
|
207
165
|
does — agents reach this deployment through <span class="mono">/mcp</span>.</p></noscript>
|
|
208
166
|
</div>
|
|
209
167
|
</div>
|
|
210
168
|
</main>
|
|
211
|
-
|
|
212
|
-
|
|
169
|
+
`,
|
|
170
|
+
tail: `<script${nonceAttr}>
|
|
213
171
|
const AUTH = ${jsonForInlineScript(auth)};
|
|
214
172
|
const MCP_URL = ${jsonForInlineScript(mcpUrl)};
|
|
215
173
|
const INITIAL_PAGE = ${jsonForInlineScript(page)};
|
|
@@ -218,9 +176,8 @@ const TITLE_SUFFIX = ${jsonForInlineScript(brand.pageTitle)};
|
|
|
218
176
|
const PRODUCT_NAME = ${stringForInlineScript(brand.productName)};
|
|
219
177
|
const PRODUCT_DESCRIPTION = ${stringForInlineScript(brand.description)};
|
|
220
178
|
const PRODUCT_OPERATOR_LABEL = ${stringForInlineScript(brand.productName + " operator")};
|
|
221
|
-
${OPERATOR_UI_SCRIPT}</script
|
|
222
|
-
|
|
223
|
-
</html>`;
|
|
179
|
+
${OPERATOR_UI_SCRIPT}</script>`,
|
|
180
|
+
});
|
|
224
181
|
}
|
|
225
182
|
function ownsOperatorPath(reserved, path) {
|
|
226
183
|
if (path === "/activity")
|
|
@@ -252,7 +209,7 @@ export function operatorUi(options = {}) {
|
|
|
252
209
|
// The pages' JSON API and the sandboxed frame; the module owns both,
|
|
253
210
|
// and this bundle imports none of it.
|
|
254
211
|
return (await artifacts?.handle(context)) ??
|
|
255
|
-
|
|
212
|
+
notFoundResponse(context.request, context.opts);
|
|
256
213
|
}
|
|
257
214
|
const routes = [
|
|
258
215
|
...(context.opts.credentialVault ? [routeCredentials] : []),
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED