@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.
@@ -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 ctx = registry.contextFor(connectorId, baseUrl, {});
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 operation = yield* Effect.result(Effect.tryPromise({
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: true });
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
- // The old grant and its cached catalog are invalid after either operation,
48
- // including a partially failed physical cleanup whose epoch fence succeeded.
49
- const invalidated = yield* Effect.result(Effect.tryPromise({
50
- try: () => registry.invalidateStored(connectorId),
51
- catch: (error) => error,
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" : "restart"} failed: ${detail}`);
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
  }
@@ -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
- function escapeHtml(value) {
7
- return value
8
- .replaceAll("&", "&")
9
- .replaceAll("<", "&lt;")
10
- .replaceAll(">", "&gt;")
11
- .replaceAll('"', "&quot;");
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 closed set and that
15
- * reason's fixed copy, so nothing the request carried — the provider's `error`
16
- * parameter, an exchange failure's message — can reach the body. Escaping
17
- * stays anyway: branding and the configured connector id are still strings.
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, branding, hasUi = false, connectorId) {
20
- const outcome = oauthCallbackOutcome(reason, connectorId);
21
- const brand = resolveBranding(branding);
22
- const title = brand.pageTitle;
23
- const owner = brand.ownerName
24
- ? brand.ownerUrl
25
- ? `<a class="brand" href="${escapeHtml(brand.ownerUrl)}">${escapeHtml(brand.ownerName)}</a>`
26
- : `<span class="brand">${escapeHtml(brand.ownerName)}</span>`
27
- : brand.productUrl
28
- ? `<a class="brand" href="${escapeHtml(brand.productUrl)}">${escapeHtml(brand.productName)}</a>`
29
- : `<span class="brand">${escapeHtml(brand.productName)}</span>`;
30
- const product = brand.ownerName
31
- ? brand.productUrl
32
- ? `<a class="product" href="${escapeHtml(brand.productUrl)}">${escapeHtml(brand.productName)}</a>`
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
- return new Response(`<!doctype html>
36
- <html lang="en">
37
- <head>
38
- <meta charset="utf-8">
39
- <meta name="viewport" content="width=device-width,initial-scale=1">
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
- </main>
88
- </body>
89
- </html>`, { status: outcome.status, headers: { "Content-Type": "text/html; charset=utf-8" } });
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.branding, Boolean(opts.ui));
108
+ return html(providerErrorReason(error), opts);
142
109
  const code = url.searchParams.get("code");
143
110
  if (!code)
144
- return html("invalid_callback", opts.branding, Boolean(opts.ui));
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.branding, Boolean(opts.ui));
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.branding, Boolean(opts.ui), id);
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.branding, Boolean(opts.ui), id);
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.branding, Boolean(opts.ui), id);
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 new Response("Not Found", { status: 404 });
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 new Response("Not Found", { status: 404 });
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 is a plain 404.
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(new Response("Not Found", { status: 404 }), url, path));
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 ?? new Response("Not Found", { status: 404 }), url, path));
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 result shapes with a small sample before proceeding.";
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. A known-address read needs only 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, loops, joins, branches, and writes that follow reads. Keep discovery and calls together when schemas suffice; do not return catalog matches alone. Inspect unfamiliar result shapes with a small sample before proceeding. A program's call to a tool not annotated readOnlyHint: true pauses unsent; resume_execution with the returned token, address, and args approves it and continues the program (approval "tool" covers that tool for the run). Keep catalog inspection top level with search_tools. ${RECOVERY}`;
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. For unfamiliar result formats, return a small sample and continue; do not guess a parser. Use 2–4 distinctive action/object terms; search distinct operations separately.
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. Treat an observed key as a hint, since later results may omit it or add others. Do not guess collection roots such as \`items\` or \`results\`. If a match is missing, re-search or describe. If a result shape is unclear, return a small sample for inspection before continuing.
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 tools explicitly annotated \`readOnlyHint: true\` are reachable unasked. Where the MCP instructions say programs pause, any other call pauses unsent and \`execute_code\` returns \`paused\` with the write and a token; repeat all three unchanged in top-level \`resume_execution\` to approve (\`approval: "tool"\` covers that tool for the run). Replay reruns the program against a journal: make the same calls in the same order. The catalog, credential, admission, and approval gates run below the sandbox; code cannot widen its authority.
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
- - A truncated direct-call result: follow its \`get_result\` action. A truncated program result has no page handle; filter, map, or slice inside a new program.
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 { resolveBranding, isSafeHttpsUrl, themeCss } from "./branding.js";
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("&", "&amp;")
104
- .replaceAll('"', "&quot;")
105
- .replaceAll("<", "&lt;")
106
- .replaceAll(">", "&gt;");
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="${escapeHtmlAttr(clerk.publishableKey)}" src="${escapeHtmlAttr(clerkScriptOrigin)}/npm/@clerk/clerk-js@6/dist/clerk.browser.js"></script>`
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
- // A pinned scheme is an attribute, not a stylesheet edit: the dark palette
171
- // keys off `html[data-scheme]`, so "system" leaves the attribute off and the
172
- // media query decides.
173
- const schemeAttr = brand.theme.colorScheme === "system"
174
- ? ""
175
- : ` data-scheme="${brand.theme.colorScheme}"`;
176
- return `<!doctype html>
177
- <html lang="en"${schemeAttr}>
178
- <head>
179
- <meta charset="utf-8">
180
- <meta name="viewport" content="width=device-width, initial-scale=1">
181
- <meta name="theme-color" content="${escapeHtmlAttr(brand.themeColor)}">
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>${escapeHtmlAttr(brand.description)}</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
- <script${nonceAttr}>
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
- </body>
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
- new Response("Not Found", { status: 404 });
212
+ notFoundResponse(context.request, context.opts);
256
213
  }
257
214
  const routes = [
258
215
  ...(context.opts.credentialVault ? [routeCredentials] : []),
package/dist/version.d.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * a bump that forgets this file fails the build rather than shipping a stale
5
5
  * version to `/health` and to downstream MCP handshakes.
6
6
  */
7
- export declare const CONNECTA_VERSION = "0.26.0";
7
+ export declare const CONNECTA_VERSION = "0.26.1";
package/dist/version.js CHANGED
@@ -4,4 +4,4 @@
4
4
  * a bump that forgets this file fails the build rather than shipping a stale
5
5
  * version to `/health` and to downstream MCP handshakes.
6
6
  */
7
- export const CONNECTA_VERSION = "0.26.0";
7
+ export const CONNECTA_VERSION = "0.26.1";