@zackbart/connecta 0.25.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.
Files changed (100) hide show
  1. package/AGENTS.md +6 -5
  2. package/CHANGELOG.md +387 -0
  3. package/README.md +32 -15
  4. package/SECURITY.md +6 -5
  5. package/bin/connecta.mjs +5 -1
  6. package/dist/activity.d.ts +10 -3
  7. package/dist/activity.js +10 -1
  8. package/dist/artifacts/connector.d.ts +55 -0
  9. package/dist/artifacts/connector.js +724 -0
  10. package/dist/artifacts/document.js +80 -0
  11. package/dist/artifacts/guide.js +140 -0
  12. package/dist/artifacts/html-scan.js +329 -0
  13. package/dist/artifacts/json.js +68 -0
  14. package/dist/artifacts/kv-store.d.ts +20 -0
  15. package/dist/artifacts/kv-store.js +140 -0
  16. package/dist/artifacts/markdown.js +478 -0
  17. package/dist/artifacts/module.d.ts +37 -0
  18. package/dist/artifacts/module.js +60 -0
  19. package/dist/artifacts/operations.d.ts +195 -0
  20. package/dist/artifacts/operations.js +944 -0
  21. package/dist/artifacts/refresh.d.ts +53 -0
  22. package/dist/artifacts/refresh.js +235 -0
  23. package/dist/artifacts/routes.js +217 -0
  24. package/dist/artifacts/types.d.ts +214 -0
  25. package/dist/artifacts/types.js +22 -0
  26. package/dist/artifacts/validate.d.ts +62 -0
  27. package/dist/artifacts/validate.js +653 -0
  28. package/dist/artifacts.d.ts +6 -0
  29. package/dist/artifacts.js +9 -0
  30. package/dist/auth/downstream-oauth.js +397 -48
  31. package/dist/auth/oauth-start-reset.js +20 -0
  32. package/dist/branding.d.ts +87 -0
  33. package/dist/branding.js +170 -1
  34. package/dist/catalog-service.js +39 -29
  35. package/dist/catalog.js +42 -8
  36. package/dist/connector-access.d.ts +18 -4
  37. package/dist/connector-access.js +65 -20
  38. package/dist/connector-caller.d.ts +8 -0
  39. package/dist/connector-caller.js +19 -0
  40. package/dist/connectors/remote-mcp.js +48 -9
  41. package/dist/errors.d.ts +16 -1
  42. package/dist/errors.js +28 -0
  43. package/dist/execute.js +431 -75
  44. package/dist/executor-admission.d.ts +11 -1
  45. package/dist/executor-admission.js +79 -14
  46. package/dist/executors/quickjs.js +4 -2
  47. package/dist/index.d.ts +62 -6
  48. package/dist/index.js +230 -6
  49. package/dist/invocation.js +56 -10
  50. package/dist/meta-tools.js +7 -2
  51. package/dist/module-contracts.d.ts +21 -1
  52. package/dist/oauth-callback-outcome.js +43 -18
  53. package/dist/operator-ui/generated.js +2 -2
  54. package/dist/operator-ui/model.d.ts +62 -5
  55. package/dist/operator-ui/view.js +372 -28
  56. package/dist/page-styles.js +7 -0
  57. package/dist/program-source.js +116 -0
  58. package/dist/registry.d.ts +42 -6
  59. package/dist/registry.js +164 -60
  60. package/dist/resumable.js +1291 -0
  61. package/dist/routes/activity.js +6 -69
  62. package/dist/routes/actor-labels.js +114 -0
  63. package/dist/routes/credentials.js +27 -5
  64. package/dist/routes/mcp.js +120 -59
  65. package/dist/routes/oauth-management.js +87 -20
  66. package/dist/routes/oauth.js +54 -87
  67. package/dist/routes/operator.js +1 -0
  68. package/dist/routes/shared.d.ts +23 -1
  69. package/dist/routes/shared.js +20 -2
  70. package/dist/routes/ui-data.js +2 -1
  71. package/dist/routes/ui.js +19 -4
  72. package/dist/run-journal.js +347 -0
  73. package/dist/runtime/shared-read.js +106 -0
  74. package/dist/server.js +30 -2
  75. package/dist/skills.js +30 -11
  76. package/dist/timeout.js +26 -0
  77. package/dist/tool-safety.d.ts +35 -0
  78. package/dist/tool-safety.js +31 -0
  79. package/dist/types.d.ts +23 -0
  80. package/dist/ui.d.ts +17 -5
  81. package/dist/ui.js +51 -68
  82. package/dist/version.d.ts +1 -1
  83. package/dist/version.js +1 -1
  84. package/documentation/architecture.md +214 -27
  85. package/documentation/auth.md +186 -2
  86. package/documentation/code-mode.md +236 -63
  87. package/documentation/meta-tools.md +55 -29
  88. package/ethos.md +12 -10
  89. package/examples/worker/AGENTS.md +6 -0
  90. package/examples/worker/README.md +174 -10
  91. package/examples/worker/src/d1-activity-row.ts +14 -2
  92. package/examples/worker/src/d1-activity.ts +4 -2
  93. package/examples/worker/src/index.ts +36 -1
  94. package/examples/worker/src/r2-artifact-blobs.ts +29 -0
  95. package/examples/worker/wrangler.jsonc +5 -1
  96. package/package.json +6 -2
  97. package/templates/node/AGENTS.md +9 -2
  98. package/templates/node/README.md +31 -3
  99. package/templates/node/package.json +1 -1
  100. package/templates/node/src/index.ts +27 -0
@@ -18,6 +18,11 @@ interface ResolvedBranding {
18
18
  pageTitle: string;
19
19
  /** href for the page's icon link. */
20
20
  faviconHref: string;
21
+ /**
22
+ * The configured `theme-color`, or the documented `#ffffff` default.
23
+ * Informational: pages emit a configured value as-is, and without one they
24
+ * follow the scheme (`themeColorMeta`) rather than painting this default.
25
+ */
21
26
  themeColor: string;
22
27
  /** Only the tokens that survived their gate; the stylesheet owns the rest. */
23
28
  theme: ResolvedTheme;
@@ -43,6 +48,88 @@ export declare function droppedThemeTokens(theme?: ConnectaTheme): string[];
43
48
  * anything that would need escaping is dropped rather than rewritten.
44
49
  */
45
50
  export declare function themeCss(theme: ResolvedTheme): string;
51
+ /**
52
+ * The resolved scheme as the root element's attribute. The dark palette keys
53
+ * off `html[data-scheme]`, so "system" leaves it off and the media query
54
+ * decides.
55
+ */
56
+ export declare function schemeAttribute(theme: ResolvedTheme): string;
57
+ /** Text or attribute value, escaped for any position in an HTML document. */
58
+ export declare function escapeHtml(value: string): string;
59
+ export interface PageLayout {
60
+ /** The complete `<title>`. */
61
+ title: string;
62
+ /** Whether the operator UI is mounted, and so serves `/favicon.*` and `/`. */
63
+ uiMounted: boolean;
64
+ /**
65
+ * The origin that serves `/favicon.*`, for a page that may be rendered on
66
+ * another host: root-relative icon hrefs resolve against it. A browser 404
67
+ * on the artifact host passes `publicUrl`, since that host answers every
68
+ * non-artifact path, the favicons included, with 404. Omitted, they stay
69
+ * root-relative.
70
+ */
71
+ iconOrigin?: string | undefined;
72
+ /** Markup after the masthead: the page's `<main>`. */
73
+ body: string;
74
+ /**
75
+ * The page's stylesheet before the theme block. Defaults to the shared
76
+ * tokens and primitives; the operator shell passes its own bundle, which
77
+ * already imports both.
78
+ */
79
+ styles?: string;
80
+ /** Page-specific rules appended to `styles`, still before the theme. */
81
+ extraStyles?: string;
82
+ /** Additional head markup, emitted verbatim (the Clerk loader). */
83
+ head?: string;
84
+ /** Emitted verbatim at the masthead's end (the operator nav mount). */
85
+ mastheadEnd?: string;
86
+ /** A skip link to this element id, for pages with navigation to skip. */
87
+ skipTo?: {
88
+ id: string;
89
+ label: string;
90
+ };
91
+ /** Markup after the body content, emitted verbatim (the operator script). */
92
+ tail?: string;
93
+ }
94
+ /**
95
+ * The one page layout every server-rendered page shares: the operator shell,
96
+ * the OAuth callback, and browser-facing 404s. Doctype and meta, the favicon a
97
+ * deployment actually serves, the resolved scheme, the token stylesheet with
98
+ * the theme block after it, and the masthead.
99
+ *
100
+ * `/favicon.svg` and `/favicon.ico` are operator UI routes. Without the UI they
101
+ * are 404s, so the default links are left out rather than pointing at nothing
102
+ * (a browser then asks for `/favicon.ico` on its own and gets the same 404). A
103
+ * configured `favicon.href` is the operator's own icon and is linked either way.
104
+ *
105
+ * Every interpolated branding value is escaped here; `body`, `head`,
106
+ * `mastheadEnd`, and `tail` are the caller's markup and are not.
107
+ */
108
+ export declare function renderPage(branding: ConnectaBranding | undefined, layout: PageLayout): string;
109
+ /**
110
+ * Status marks for one-message pages. Decorative: the label and heading beside
111
+ * each carry the meaning, so the shape repeats it and color is never alone.
112
+ */
113
+ export declare const STATUS_ICONS: {
114
+ readonly ok: "<svg viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 10.5l3.2 3.2L15 6.8\"/></svg>";
115
+ readonly problem: "<svg viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\"><path d=\"M10 5.5v5.5\"/><path d=\"M10 14.6v.1\"/></svg>";
116
+ readonly declined: "<svg viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\"><path d=\"M6 10h8\"/></svg>";
117
+ readonly missing: "<svg viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"><circle cx=\"9\" cy=\"9\" r=\"4.5\"/><path d=\"M12.4 12.4L16 16\"/></svg>";
118
+ };
119
+ /**
120
+ * The 404 a browser gets for a path nothing serves, and everyone else's plain
121
+ * `Not Found`. The body is built from deployment configuration alone and names
122
+ * no path, so every unserved path — on the main host or an artifact host —
123
+ * answers with the same bytes for the same `Accept`: a 404 must not tell one
124
+ * route from another. The home link appears only when the operator UI is
125
+ * mounted to land on, and points at the public origin so an artifact host's
126
+ * 404 does not link to another 404.
127
+ */
128
+ export declare function notFoundResponse(request: Request, opts: {
129
+ branding?: ConnectaBranding | undefined;
130
+ ui?: object | undefined;
131
+ publicUrl?: string | undefined;
132
+ }): Response;
46
133
  /**
47
134
  * Names of the branding URLs the operator set that failed their gate and were
48
135
  * replaced by a default. Lives beside the gates so the startup warning cannot
package/dist/branding.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { PAGE_CSS, TOKENS_CSS } from "./page-styles.js";
1
2
  /** Connecta's default monochrome "C" mark. */
2
3
  export const CONNECTA_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
3
4
  <style>
@@ -37,7 +38,7 @@ export function resolveBranding(branding) {
37
38
  faviconHref: faviconHref && isSafeIconHref(faviconHref)
38
39
  ? faviconHref
39
40
  : DEFAULT_FAVICON_HREF,
40
- themeColor: trimmedString(branding?.themeColor) ?? "#ffffff",
41
+ themeColor: configuredThemeColor(branding) ?? "#ffffff",
41
42
  theme: resolveTheme(branding?.theme),
42
43
  };
43
44
  }
@@ -148,6 +149,174 @@ export function themeCss(theme) {
148
149
  ].filter(Boolean);
149
150
  return declarations.length ? `:root{${declarations.join(";")}}` : "";
150
151
  }
152
+ /**
153
+ * The resolved scheme as the root element's attribute. The dark palette keys
154
+ * off `html[data-scheme]`, so "system" leaves it off and the media query
155
+ * decides.
156
+ */
157
+ export function schemeAttribute(theme) {
158
+ return theme.colorScheme === "system"
159
+ ? ""
160
+ : ` data-scheme="${theme.colorScheme}"`;
161
+ }
162
+ /** Text or attribute value, escaped for any position in an HTML document. */
163
+ export function escapeHtml(value) {
164
+ return value
165
+ .replaceAll("&", "&amp;")
166
+ .replaceAll('"', "&quot;")
167
+ .replaceAll("<", "&lt;")
168
+ .replaceAll(">", "&gt;");
169
+ }
170
+ /**
171
+ * The masthead's `--surface` in each palette, for the browser chrome a
172
+ * `theme-color` paints. A configured `themeColor` wins; without one a dark
173
+ * page should not sit under a white status bar.
174
+ */
175
+ const SURFACE = { light: "#ffffff", dark: "#151a21" };
176
+ /** The operator's own `themeColor`, read the one way both callers read it. */
177
+ function configuredThemeColor(branding) {
178
+ return trimmedString(branding?.themeColor);
179
+ }
180
+ function themeColorMeta(branding, theme) {
181
+ const configured = configuredThemeColor(branding);
182
+ if (configured)
183
+ return `<meta name="theme-color" content="${escapeHtml(configured)}">`;
184
+ if (theme.colorScheme !== "system") {
185
+ return `<meta name="theme-color" content="${SURFACE[theme.colorScheme]}">`;
186
+ }
187
+ return `<meta name="theme-color" content="${SURFACE.light}" media="(prefers-color-scheme: light)">\n` +
188
+ `<meta name="theme-color" content="${SURFACE.dark}" media="(prefers-color-scheme: dark)">`;
189
+ }
190
+ /**
191
+ * The one page layout every server-rendered page shares: the operator shell,
192
+ * the OAuth callback, and browser-facing 404s. Doctype and meta, the favicon a
193
+ * deployment actually serves, the resolved scheme, the token stylesheet with
194
+ * the theme block after it, and the masthead.
195
+ *
196
+ * `/favicon.svg` and `/favicon.ico` are operator UI routes. Without the UI they
197
+ * are 404s, so the default links are left out rather than pointing at nothing
198
+ * (a browser then asks for `/favicon.ico` on its own and gets the same 404). A
199
+ * configured `favicon.href` is the operator's own icon and is linked either way.
200
+ *
201
+ * Every interpolated branding value is escaped here; `body`, `head`,
202
+ * `mastheadEnd`, and `tail` are the caller's markup and are not.
203
+ */
204
+ export function renderPage(branding, layout) {
205
+ const brand = resolveBranding(branding);
206
+ // Top-left corner. With an owner set it reads "<owner> <product>"; without
207
+ // one the product label stands alone. Either half links out when the
208
+ // matching URL is configured.
209
+ const label = (className, text, href) => href
210
+ ? `<a class="${className} navlink" href="${escapeHtml(href)}">${escapeHtml(text)}</a>`
211
+ : `<span class="${className}">${escapeHtml(text)}</span>`;
212
+ const owner = brand.ownerName
213
+ ? label("brand", brand.ownerName, brand.ownerUrl)
214
+ : label("brand", brand.productName, brand.productUrl);
215
+ const product = brand.ownerName
216
+ ? label("product", brand.productName, brand.productUrl)
217
+ : "";
218
+ const customIcon = brand.faviconHref !== DEFAULT_FAVICON_HREF;
219
+ // `isSafeIconHref` already limited a relative href to one leading slash, so
220
+ // resolving it can only land on `iconOrigin` itself.
221
+ const iconHref = (href) => layout.iconOrigin && href.startsWith("/")
222
+ ? new URL(href, layout.iconOrigin).toString()
223
+ : href;
224
+ const icons = [
225
+ ...(customIcon || layout.uiMounted
226
+ ? [`<link rel="icon" href="${escapeHtml(iconHref(brand.faviconHref))}" type="image/svg+xml">`]
227
+ : []),
228
+ ...(layout.uiMounted
229
+ ? [`<link rel="shortcut icon" href="${escapeHtml(iconHref("/favicon.ico"))}">`]
230
+ : []),
231
+ ];
232
+ const skip = layout.skipTo
233
+ ? `<a class="skip-link" href="#${escapeHtml(layout.skipTo.id)}">${escapeHtml(layout.skipTo.label)}</a>\n`
234
+ : "";
235
+ const styles = (layout.styles ?? TOKENS_CSS + PAGE_CSS) + (layout.extraStyles ?? "");
236
+ return `<!doctype html>
237
+ <html lang="en"${schemeAttribute(brand.theme)}>
238
+ <head>
239
+ <meta charset="utf-8">
240
+ <meta name="viewport" content="width=device-width, initial-scale=1">
241
+ ${themeColorMeta(branding, brand.theme)}
242
+ <meta name="description" content="${escapeHtml(brand.description)}">
243
+ ${icons.join("\n")}
244
+ <title>${escapeHtml(layout.title)}</title>
245
+ ${layout.head ?? ""}
246
+ <style>${styles}${themeCss(brand.theme)}</style>
247
+ </head>
248
+ <body>
249
+ ${skip}<header class="masthead shell">
250
+ <div class="masthead-inner">
251
+ <div class="mast-nav">
252
+ ${owner}${product ? `\n ${product}` : ""}
253
+ </div>${layout.mastheadEnd ? `\n ${layout.mastheadEnd}` : ""}
254
+ </div>
255
+ </header>
256
+ ${layout.body}${layout.tail ? `\n${layout.tail}` : ""}
257
+ </body>
258
+ </html>`;
259
+ }
260
+ /**
261
+ * Status marks for one-message pages. Decorative: the label and heading beside
262
+ * each carry the meaning, so the shape repeats it and color is never alone.
263
+ */
264
+ export const STATUS_ICONS = {
265
+ ok: '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 10.5l3.2 3.2L15 6.8"/></svg>',
266
+ problem: '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M10 5.5v5.5"/><path d="M10 14.6v.1"/></svg>',
267
+ declined: '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M6 10h8"/></svg>',
268
+ missing: '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="9" cy="9" r="4.5"/><path d="M12.4 12.4L16 16"/></svg>',
269
+ };
270
+ /**
271
+ * A request a person's browser made for a page, as opposed to a client asking
272
+ * for data: navigations send `text/html` in `Accept`, `fetch()` and MCP
273
+ * clients do not.
274
+ */
275
+ function wantsHtml(request) {
276
+ return /\btext\/html\b/i.test(request.headers.get("Accept") ?? "");
277
+ }
278
+ /**
279
+ * The 404 a browser gets for a path nothing serves, and everyone else's plain
280
+ * `Not Found`. The body is built from deployment configuration alone and names
281
+ * no path, so every unserved path — on the main host or an artifact host —
282
+ * answers with the same bytes for the same `Accept`: a 404 must not tell one
283
+ * route from another. The home link appears only when the operator UI is
284
+ * mounted to land on, and points at the public origin so an artifact host's
285
+ * 404 does not link to another 404.
286
+ */
287
+ export function notFoundResponse(request, opts) {
288
+ if (!wantsHtml(request)) {
289
+ return new Response("Not Found", { status: 404, headers: { Vary: "Accept" } });
290
+ }
291
+ const brand = resolveBranding(opts.branding);
292
+ const uiMounted = Boolean(opts.ui);
293
+ const homeHref = opts.publicUrl ? new URL("/", opts.publicUrl).toString() : "/";
294
+ const home = uiMounted
295
+ ? `<div class="status-actions"><a class="btn" href="${escapeHtml(homeHref)}">Go to ${escapeHtml(brand.productName)}</a></div>`
296
+ : "";
297
+ const body = `<main class="page shell">
298
+ <section class="status-page" data-not-found>
299
+ <div class="status-head">
300
+ <span class="status-mark" aria-hidden="true">${STATUS_ICONS.missing}</span>
301
+ <p class="status-label">404</p>
302
+ </div>
303
+ <h1>Page not found</h1>
304
+ <p class="status-copy">Nothing is served at this address. Check the link, or ask whoever runs ${escapeHtml(brand.productName)} for the right one.</p>
305
+ ${home}
306
+ </section>
307
+ </main>`;
308
+ return new Response(renderPage(opts.branding, {
309
+ title: `Page not found — ${brand.pageTitle}`,
310
+ uiMounted,
311
+ // Same bytes on either host: the icons, like the home link, come from
312
+ // the public origin.
313
+ iconOrigin: opts.publicUrl,
314
+ body,
315
+ }), {
316
+ status: 404,
317
+ headers: { "Content-Type": "text/html; charset=utf-8", Vary: "Accept" },
318
+ });
319
+ }
151
320
  /**
152
321
  * Whether the operator meant to supply a value here — the question every
153
322
  * dropped-value warning asks before naming a field, and one definition so the
@@ -1,11 +1,12 @@
1
- import { Deferred, Effect, Exit, Result } from "effect";
1
+ import { Effect, Result } from "effect";
2
2
  import { compactDiscoverySchema, compactDescriptionSchema, lexicalCorpusStatistics, lexicalQueryTerms, lexicalSearchQuery, matchesLexicalTerm, rankTools, schemaObjectKeys, summarizeDiscoveryDescription, summarizeDescription, typescriptSignature, } from "./catalog.js";
3
3
  import { resolveDiscoveryConcurrency } from "./concurrency.js";
4
4
  import { boundedEchoText, classifyCallError, framingError, } from "./errors.js";
5
5
  import { connectorGuide, connectorGuideRequired, connectorGuideSummary, connectorSkillName, } from "./skills.js";
6
6
  import { runEdge, withDeadlineEffect } from "./runtime/run.js";
7
+ import { SharedRead } from "./runtime/shared-read.js";
7
8
  import { DEFAULT_PROBE_TIMEOUT_MS, normalizeTimeoutMs, } from "./timeout.js";
8
- import { isExplicitlyReadOnly } from "./tool-safety.js";
9
+ import { isApprovalExempt, isExplicitlyReadOnly, NO_EXEMPTIONS, } from "./tool-safety.js";
9
10
  export const DEFAULT_SEARCH_LIMIT = 8;
10
11
  export const MAX_SEARCH_LIMIT = 100;
11
12
  export const MAX_DESCRIBE_ADDRESSES = 100;
@@ -202,12 +203,13 @@ export class CatalogService {
202
203
  searchRoute;
203
204
  readOptions;
204
205
  requestSignal;
205
- // The request-scoped catalog cache: one Deferred per connector asked about,
206
- // completed by the first asker's registry read and joined by every later
207
- // one. A success stays for the rest of the request, so a search and the call
208
- // after it see one catalog; a failure is dropped so the next ask reads
209
- // again. The service lives and dies with one request, so no Deferred here is
210
- // ever awaited from another.
206
+ approval;
207
+ // The request-scoped catalog cache: one shared read per connector asked
208
+ // about, started by the first asker and joined by every later one. A
209
+ // success stays for the rest of the request, so a search and the call after
210
+ // it see one catalog; a failure, or a read cancelled because every asker
211
+ // left it, is dropped so the next ask reads again. The service lives and
212
+ // dies with one request, so no read here is ever awaited from another.
211
213
  //
212
214
  // Registry.getTools also joins concurrent reads in one request scope, which
213
215
  // the operator UI and status reads rely on. This map overlaps it only for
@@ -218,6 +220,7 @@ export class CatalogService {
218
220
  constructor(registry, baseUrl, options = {}) {
219
221
  this.registry = registry;
220
222
  this.baseUrl = baseUrl;
223
+ this.approval = options.approval ?? NO_EXEMPTIONS;
221
224
  this.requestScope = options.requestScope ?? {};
222
225
  this.probeTimeoutMs =
223
226
  normalizeTimeoutMs(options.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
@@ -256,28 +259,27 @@ export class CatalogService {
256
259
  return runEdge(this.catalog(id, callOptions));
257
260
  }
258
261
  // One connector's catalog from the request-scoped cache, reading it through
259
- // the registry when this request has not yet. The first asker's options
260
- // govern the read and a joiner shares its outcome, as the registry's own
261
- // flight below does. The read settles the Deferred itself, not the asker's
262
- // fiber, so a probe deadline ends one asker's wait and never the read.
262
+ // the registry when this request has not yet. No asker's options govern the
263
+ // read: it carries its own signal and the catalog deadline, the probe
264
+ // timeout, whichever asker starts it. Each asker waits under its own signal
265
+ // and deadline, so a short-deadline call that starts the read and a search
266
+ // that joins it each end on their own terms, and the read is cancelled only
267
+ // once every asker has gone (#571).
263
268
  catalog(id, callOptions) {
264
269
  return Effect.suspend(() => {
265
- const cached = this.catalogs.get(id);
266
- if (cached)
267
- return Deferred.await(cached);
268
- const read = Deferred.makeUnsafe();
269
- this.catalogs.set(id, read);
270
- // The executor turns a synchronous throw from getTools into a rejection.
271
- new Promise((resolve) => resolve(this.registry.getTools(id, this.baseUrl, this.requestScope, callOptions, this.readOptions))).then((tools) => {
272
- Deferred.doneUnsafe(read, Exit.succeed(tools));
273
- }, (cause) => {
274
- // Evicted before the Deferred resumes anyone, so an asker that
275
- // retries on hearing of the failure starts a fresh read.
276
- if (this.catalogs.get(id) === read)
277
- this.catalogs.delete(id);
278
- Deferred.doneUnsafe(read, Exit.fail(cause));
279
- });
280
- return Deferred.await(read);
270
+ let read = this.catalogs.get(id);
271
+ if (!read) {
272
+ const started = new SharedRead((signal) => this.registry.getTools(id, this.baseUrl, this.requestScope, { signal, timeoutMs: this.probeTimeoutMs }, this.readOptions), (succeeded) => {
273
+ // Evicted before the read resumes anyone, so an asker that
274
+ // retries on hearing of the failure starts a fresh read.
275
+ if (!succeeded && this.catalogs.get(id) === started) {
276
+ this.catalogs.delete(id);
277
+ }
278
+ });
279
+ this.catalogs.set(id, started);
280
+ read = started;
281
+ }
282
+ return read.join(callOptions.signal);
281
283
  });
282
284
  }
283
285
  // Every named catalog under its own probe deadline, `concurrency` at a time,
@@ -286,7 +288,7 @@ export class CatalogService {
286
288
  // A probe also ends with its request, so no listing outlives the caller
287
289
  // that asked for it.
288
290
  discoveryCatalogs(ids, route) {
289
- return Effect.forEach(ids, (id) => Effect.result(withDeadlineEffect((signal) => this.catalog(id, { signal, timeoutMs: this.probeTimeoutMs }), {
291
+ return Effect.forEach(ids, (id) => Effect.result(withDeadlineEffect((signal) => this.catalog(id, { signal }), {
290
292
  ...(this.requestSignal ? { signal: this.requestSignal } : {}),
291
293
  timeoutMs: this.probeTimeoutMs,
292
294
  timeoutError: new Error(`${route} probe of "${id}" timed out after ${this.probeTimeoutMs}ms`),
@@ -536,6 +538,11 @@ export class CatalogService {
536
538
  ...(match.tool.annotations
537
539
  ? { annotations: match.tool.annotations }
538
540
  : {}),
541
+ // Config lets a program call this write without pausing (#566).
542
+ // A marker, not a class: the tool stays approval-required.
543
+ ...(isApprovalExempt(this.approval, match.connector, match.tool.name, match.tool)
544
+ ? { approval: "exempt" }
545
+ : {}),
539
546
  ...(requiredReasons
540
547
  ? {
541
548
  guideRequired: true,
@@ -847,6 +854,9 @@ export class CatalogService {
847
854
  : {}),
848
855
  ...(output.source ? { outputSchemaSource: output.source } : {}),
849
856
  ...(tool.annotations ? { annotations: tool.annotations } : {}),
857
+ ...(isApprovalExempt(this.approval, addressResolution.connector, tool.name, tool)
858
+ ? { approval: "exempt" }
859
+ : {}),
850
860
  };
851
861
  });
852
862
  }
package/dist/catalog.js CHANGED
@@ -14,6 +14,12 @@ class SchemaWork {
14
14
  remaining = MAX_SCHEMA_WORK;
15
15
  truncated = false;
16
16
  refs = new Map();
17
+ /**
18
+ * Renderings whose top level is a bare `|` or `&`, recorded as the walk
19
+ * produces them. Compact carries raw keys and `//` prose, so unlike the
20
+ * TypeScript dialect its text cannot be rescanned for a top-level operator.
21
+ */
22
+ ungrouped = new Set();
17
23
  constructor(byteLimit = MAX_COMPACT_DISCOVERY_SCHEMA_BYTES) {
18
24
  this.byteLimit = byteLimit;
19
25
  }
@@ -435,8 +441,11 @@ function renderEnum(values, work, byteLimit, onTruncated) {
435
441
  const full = prefix.join(" | ");
436
442
  if (schemaEncoder.encode(full).length > limit)
437
443
  break;
438
- if (index === values.length - 1)
444
+ if (index === values.length - 1) {
445
+ if (index > 0)
446
+ work.ungrouped.add(full);
439
447
  return full;
448
+ }
440
449
  const omitted = values.length - prefix.length;
441
450
  const candidate = `(${full} | ${marker(omitted)})`;
442
451
  if (schemaEncoder.encode(candidate).length <= limit)
@@ -582,6 +591,12 @@ function renderSchema(schema, defs, seen, depth, options) {
582
591
  function renderSchemaNode(schema, defs, seen, depth, options) {
583
592
  const ts = options.typescript === true;
584
593
  const group = ts ? groupedType : grouped;
594
+ const ungrouped = options.work.ungrouped;
595
+ // A `[]` suffix binds tighter than `|` and `&`, so an element rendered at
596
+ // operator level needs parentheses or it reads as its last member's array.
597
+ const element = (rendered) => ts
598
+ ? groupedType(rendered)
599
+ : ungrouped.has(rendered) ? `(${rendered})` : rendered;
585
600
  if (depth > 4) {
586
601
  if (!ts)
587
602
  return "…";
@@ -607,9 +622,17 @@ function renderSchemaNode(schema, defs, seen, depth, options) {
607
622
  : "unknown";
608
623
  return rendered === "unknown" ? rendered : `${rendered} | null`;
609
624
  }
610
- const constrain = (rendered) => options.renderConstraints
611
- ? renderConstraints(rendered, s, options.constraintByteLimit, options.onConstraintTruncated)
612
- : rendered;
625
+ const constrain = (rendered) => {
626
+ if (!options.renderConstraints)
627
+ return rendered;
628
+ const result = renderConstraints(rendered, s, options.constraintByteLimit, options.onConstraintTruncated);
629
+ // Constraints parenthesize a union before their comment but leave an
630
+ // intersection bare, and a comment does not group what precedes it.
631
+ if (ungrouped.has(rendered) && !result.startsWith(`(${rendered})`)) {
632
+ ungrouped.add(result);
633
+ }
634
+ return result;
635
+ };
613
636
  // Conditions cannot be expressed by a single static shape. Preserve the
614
637
  // base and send callers to the exact schema instead of hiding the rules.
615
638
  if (s.dependentSchemas !== undefined || s.if !== undefined ||
@@ -623,7 +646,10 @@ function renderSchemaNode(schema, defs, seen, depth, options) {
623
646
  const rendered = declaresShape(base)
624
647
  ? renderSchema(base, defs, seen, depth, options)
625
648
  : "unknown";
626
- return `${rendered} /* conditional */`;
649
+ const result = `${rendered} /* conditional */`;
650
+ if (ungrouped.has(rendered))
651
+ ungrouped.add(result);
652
+ return result;
627
653
  }
628
654
  // allOf composes rather than replaces: it is checked before every other
629
655
  // shape keyword, and renders the schema's own shape alongside its members instead
@@ -653,7 +679,10 @@ function renderSchemaNode(schema, defs, seen, depth, options) {
653
679
  }
654
680
  if (parts.length === 0)
655
681
  return "unknown";
656
- return parts.finish();
682
+ const rendered = parts.finish();
683
+ if (parts.length > 1)
684
+ ungrouped.add(rendered);
685
+ return rendered;
657
686
  }
658
687
  const reference = s.$ref ?? s.$dynamicRef;
659
688
  if (typeof reference === "string") {
@@ -693,6 +722,8 @@ function renderSchemaNode(schema, defs, seen, depth, options) {
693
722
  parts.add(renderSchema(member, defs, seen, depth + 1, options));
694
723
  }
695
724
  const rendered = parts.finish() || "unknown";
725
+ if (parts.length > 1)
726
+ ungrouped.add(rendered);
696
727
  return constrain(rendered);
697
728
  }
698
729
  if (Array.isArray(s.enum)) {
@@ -743,7 +774,7 @@ function renderSchemaNode(schema, defs, seen, depth, options) {
743
774
  const rest = s.items === undefined || s.items === true
744
775
  ? "unknown"
745
776
  : renderSchema(s.items, defs, seen, depth + 1, options);
746
- parts.add(`...${group(rest)}[]`);
777
+ parts.add(`...${element(rest)}[]`);
747
778
  }
748
779
  return parts.finish();
749
780
  }
@@ -751,7 +782,7 @@ function renderSchemaNode(schema, defs, seen, depth, options) {
751
782
  const items = s.items
752
783
  ? renderSchema(s.items, defs, seen, depth + 1, options)
753
784
  : "unknown";
754
- return ts ? `${groupedType(items)}[]` : `${items}[]`;
785
+ return `${element(items)}[]`;
755
786
  }
756
787
  if (type === "object" || s.properties) {
757
788
  const props = (s.properties ?? {});
@@ -809,6 +840,8 @@ function renderSchemaNode(schema, defs, seen, depth, options) {
809
840
  parts.add(options.work.text(String(item)));
810
841
  }
811
842
  const rendered = parts.finish();
843
+ if (parts.length > 1)
844
+ ungrouped.add(rendered);
812
845
  return constrain(rendered);
813
846
  }
814
847
  if (options.renderConstraints && constraintEntries(s).length > 0) {
@@ -959,6 +992,7 @@ function boundedCompactSchema(schema, description, typescript = false) {
959
992
  // are memoized only within each pass because their text includes constraints.
960
993
  if (error === schemaSizeExceeded && !description) {
961
994
  work.refs.clear();
995
+ work.ungrouped.clear();
962
996
  try {
963
997
  return {
964
998
  text: renderSchema(schema, schema, new Set(), 0, {
@@ -9,20 +9,33 @@ export declare const POOL_NAME_RE: RegExp;
9
9
  /**
10
10
  * One derived view: which connectors, and for connectors granted by address
11
11
  * only, which tools. A connector absent from `toolAccess` is visible whole.
12
+ * `guardedToolAccess` is a subset of `toolAccess` checked against the loaded
13
+ * catalog each time tools are discovered or invoked.
12
14
  */
13
15
  export interface ConnectorAccess {
14
16
  connectorIds: "all" | readonly string[];
15
17
  toolAccess?: ToolAccess;
18
+ /** Exact tool grants that also require a current explicit read-only annotation. */
19
+ guardedToolAccess?: ToolAccess;
16
20
  }
21
+ interface ReadOnlyToolGrant {
22
+ tool: string;
23
+ requireReadOnly: true;
24
+ }
25
+ export type ConnectorGrant = string | ReadOnlyToolGrant;
17
26
  /**
18
27
  * Normalize a grant list. A bare connector id grants every tool on that
19
28
  * connector; a `connector.tool` address grants one tool. Grants are additive,
20
29
  * so a bare id beside addresses for the same connector means the whole
21
- * connector. Anything else — an unknown shape, an empty tool name, a
22
- * non-string — throws, and the caller decides whether that is a construction
23
- * failure or a 403: a grant that cannot be parsed must never fail open.
30
+ * connector. Identity resolution can opt into `{ tool, requireReadOnly: true }`
31
+ * for a fixed address that also needs a current explicit read-only annotation;
32
+ * pool declarations remain strings. Anything else throws, and the caller
33
+ * decides whether that is a construction failure or a 403: a grant that
34
+ * cannot be parsed must never fail open.
24
35
  */
25
- export declare function parseConnectorAccess(value: unknown): ConnectorAccess;
36
+ export declare function parseConnectorAccess(value: unknown, options?: {
37
+ allowReadOnly?: boolean;
38
+ }): ConnectorAccess;
26
39
  /**
27
40
  * The view a pool endpoint serves: the pool's grants, never wider than the
28
41
  * identity's own. A connector or tool outside either side is gone; a
@@ -30,3 +43,4 @@ export declare function parseConnectorAccess(value: unknown): ConnectorAccess;
30
43
  * only narrow what the identity resolver already allowed.
31
44
  */
32
45
  export declare function intersectAccess(ceiling: ConnectorAccess, pool: ConnectorAccess): ConnectorAccess;
46
+ export {};