@zackbart/connecta 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/bin/connecta.mjs +26 -17
  3. package/bin/meta-tool-names.d.mts +3 -1
  4. package/bin/meta-tool-names.mjs +4 -1
  5. package/bin/version.mjs +1 -1
  6. package/dist/activity.d.ts +2 -1
  7. package/dist/activity.js +5 -5
  8. package/dist/advertised-schema.js +15 -0
  9. package/dist/agent-routes.js +31 -5
  10. package/dist/auth/downstream-oauth/refresh-coordinator.js +4 -1
  11. package/dist/auth/downstream-oauth.js +3 -0
  12. package/dist/auth/static-oauth.js +26 -18
  13. package/dist/auth-elicitation.js +118 -61
  14. package/dist/authorization-handoff.js +36 -3
  15. package/dist/catalog-cache.js +292 -24
  16. package/dist/catalog-freshness.js +12 -0
  17. package/dist/catalog-service.js +373 -70
  18. package/dist/catalog.js +147 -58
  19. package/dist/config-defaults.d.ts +2 -1
  20. package/dist/config-defaults.js +4 -1
  21. package/dist/config.d.ts +43 -17
  22. package/dist/config.js +26 -10
  23. package/dist/connectors/remote-mcp/catalog.js +52 -28
  24. package/dist/connectors/remote-mcp/connection.js +62 -5
  25. package/dist/connectors/remote-mcp.js +10 -1
  26. package/dist/describe-config.d.ts +3 -2
  27. package/dist/discovery-health.js +26 -11
  28. package/dist/errors.d.ts +52 -3
  29. package/dist/errors.js +86 -3
  30. package/dist/execute.js +476 -200
  31. package/dist/executor-admission.js +2 -0
  32. package/dist/executor-contract.js +6 -0
  33. package/dist/executor-result.js +2 -2
  34. package/dist/executors/quickjs-child.js +13 -4
  35. package/dist/executors/quickjs-runtime.d.ts +2 -0
  36. package/dist/executors/quickjs-runtime.js +36 -19
  37. package/dist/executors/quickjs.js +23 -6
  38. package/dist/guest-functions.js +5 -4
  39. package/dist/guest-runtime.js +37 -1
  40. package/dist/guest-types.d.ts +18 -0
  41. package/dist/http/client.js +7 -2
  42. package/dist/http/errors.js +9 -4
  43. package/dist/http/fields.js +17 -5
  44. package/dist/http/hatch.js +9 -3
  45. package/dist/http/paging.js +1 -1
  46. package/dist/http/provider.js +2 -6
  47. package/dist/http/vendor.js +6 -1
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.js +5 -0
  50. package/dist/invocation.js +98 -34
  51. package/dist/meta-output.js +24 -18
  52. package/dist/meta-tool-names.d.ts +1 -1
  53. package/dist/meta-tool-names.js +1 -1
  54. package/dist/meta-tools.js +259 -60
  55. package/dist/native-recovery.js +4 -1
  56. package/dist/native-tools.js +166 -51
  57. package/dist/node.js +3 -1
  58. package/dist/oauth-handoff.js +53 -43
  59. package/dist/operation-activity.js +2 -1
  60. package/dist/operation-vocabulary.js +6 -0
  61. package/dist/operator-record.d.ts +1 -1
  62. package/dist/operator-record.js +1 -0
  63. package/dist/operator-ui/generated.js +2 -2
  64. package/dist/program-result.js +2 -0
  65. package/dist/program-source.js +1 -1
  66. package/dist/program-writes.js +112 -19
  67. package/dist/provider.d.ts +0 -13
  68. package/dist/provider.js +0 -3
  69. package/dist/providers/_shared/google/kit.js +2 -2
  70. package/dist/providers/breeze/api.js +2 -2
  71. package/dist/providers/breeze/skill.generated.js +1 -1
  72. package/dist/providers/ccb/api.js +2 -2
  73. package/dist/providers/ccb/skill.generated.js +1 -1
  74. package/dist/providers/cloudflare/api.js +2 -2
  75. package/dist/providers/cloudflare/index.js +2 -0
  76. package/dist/providers/cloudflare/skill.generated.js +1 -1
  77. package/dist/providers/docs/skill.generated.js +1 -1
  78. package/dist/providers/drive/skill.generated.js +1 -1
  79. package/dist/providers/forms/index.js +1 -0
  80. package/dist/providers/forms/skill.generated.js +1 -1
  81. package/dist/providers/github/api.js +98 -6
  82. package/dist/providers/github/index.js +7 -2
  83. package/dist/providers/github/lib/auth.d.ts +3 -1
  84. package/dist/providers/github/lib/auth.js +43 -12
  85. package/dist/providers/github/lib/discovery.js +11 -1
  86. package/dist/providers/github/lib/operations.js +8 -0
  87. package/dist/providers/github/safety.js +39 -0
  88. package/dist/providers/github/skill.generated.js +1 -1
  89. package/dist/providers/github/tools.js +12 -5
  90. package/dist/providers/gmail/index.js +1 -0
  91. package/dist/providers/gmail/skill.generated.js +1 -1
  92. package/dist/providers/granola/skill.generated.js +1 -1
  93. package/dist/providers/overflow/index.js +2 -0
  94. package/dist/providers/overflow/skill.generated.js +1 -1
  95. package/dist/providers/planning-center/index.js +11 -5
  96. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  97. package/dist/providers/planning-center/skill.generated.js +1 -1
  98. package/dist/providers/quickbooks/index.js +2 -5
  99. package/dist/providers/quickbooks/skill.generated.js +1 -1
  100. package/dist/providers/sheets/index.js +1 -0
  101. package/dist/providers/sheets/skill.generated.js +1 -1
  102. package/dist/providers/slides/index.js +2 -2
  103. package/dist/providers/slides/skill.generated.js +1 -1
  104. package/dist/providers/stripe/skill.generated.js +1 -1
  105. package/dist/providers/tithely/index.js +1 -0
  106. package/dist/providers/tithely/skill.generated.js +1 -1
  107. package/dist/providers/vercel/api.js +2 -2
  108. package/dist/providers/vercel/skill.generated.js +1 -1
  109. package/dist/registry.d.ts +36 -4
  110. package/dist/registry.js +127 -27
  111. package/dist/result-preview.js +210 -0
  112. package/dist/result-seal.js +168 -0
  113. package/dist/route-names.js +13 -4
  114. package/dist/routes/connect.js +21 -13
  115. package/dist/routes/mcp.js +55 -26
  116. package/dist/routes/shared.d.ts +6 -0
  117. package/dist/runtime/call.js +15 -10
  118. package/dist/runtime/request-scope.js +36 -1
  119. package/dist/runtime/run.js +4 -1
  120. package/dist/sent-secrets.d.ts +20 -0
  121. package/dist/sent-secrets.js +49 -1
  122. package/dist/skills.js +100 -33
  123. package/dist/storage/keys.js +9 -6
  124. package/dist/storage/sql-native.js +1 -1
  125. package/dist/tool-safety.d.ts +33 -0
  126. package/dist/tool-safety.js +51 -15
  127. package/dist/types.d.ts +31 -0
  128. package/dist/usage-guide.js +14 -13
  129. package/dist/validate.js +29 -10
  130. package/dist/version.d.ts +1 -1
  131. package/dist/version.js +1 -1
  132. package/dist/worker.js +28 -8
  133. package/documentation/architecture.md +104 -42
  134. package/documentation/auth.md +46 -19
  135. package/documentation/code-mode.md +378 -116
  136. package/documentation/deploying.md +10 -9
  137. package/documentation/hosted-mcp-migration.md +9 -3
  138. package/documentation/integrating.md +27 -5
  139. package/documentation/meta-tools.md +655 -153
  140. package/documentation/operating.md +5 -3
  141. package/documentation/providers.md +18 -6
  142. package/examples/worker/README.md +3 -3
  143. package/guides/how-it-works.md +2 -1
  144. package/guides/safety-and-trust.md +1 -3
  145. package/package.json +1 -1
  146. package/templates/node/AGENTS.md +3 -3
  147. package/templates/node/README.md +1 -1
  148. package/templates/node/package.json +1 -1
@@ -2,6 +2,9 @@ import type { ConnectorContext, JsonSchema, Logger } from "./types.js";
2
2
  /** One payload-free warning per configured connector, regardless of requests. */
3
3
  export declare function shortSecretWarning(): (value: string | undefined, logger: Logger) => void;
4
4
  export declare class SentSecrets {
5
+ /** Rises whenever this set could match more. Text checked at one revision has not been
6
+ * checked against a later one. */
7
+ revision: number;
5
8
  /** Normalized form to its strongest kind. */
6
9
  private readonly forms;
7
10
  /** A form's leading units that encode only public auth framing; absent is none. */
@@ -44,6 +47,10 @@ export declare class SentSecrets {
44
47
  contains(value: string, ignoreCase?: boolean): boolean;
45
48
  containsUrl(value: string): boolean;
46
49
  header(value: string): void;
50
+ /** A header maintained provider code declares a public identifier: matched whole only. A name
51
+ * alone never establishes this. Whitespace means an auth scheme or other framing, so that value
52
+ * is a credential whatever its header, and the answer is false. */
53
+ publicIdentifierHeader(value: string): boolean;
47
54
  request(input: RequestInfo | URL, init?: RequestInit): void;
48
55
  /** Serialized wire text about to be returned, stored, or paged, under payload rules. */
49
56
  text(value: string): string;
@@ -95,6 +102,13 @@ export declare class SentSecrets {
95
102
  /** Credentials alone: later calls keep the private arguments they submit, and cached
96
103
  * catalogs never depend on one request's arguments. */
97
104
  redactCredentials<T>(value: T): T;
105
+ /** A program's outbound arguments, with what the {@link outbound} check matched replaced,
106
+ * and whether it matched anything: a write that did is refused rather than sent altered.
107
+ * Output keeps the stricter matching; a program never holds what output withheld. */
108
+ filterCredentials<T>(value: T): {
109
+ value: T;
110
+ changed: boolean;
111
+ };
98
112
  private idle;
99
113
  private scan;
100
114
  private matcher;
@@ -104,6 +118,12 @@ export declare class SentSecrets {
104
118
  private wholeMatcher;
105
119
  private fragments;
106
120
  private credentials;
121
+ /** Credentials as an outbound argument can hold them: a whole form, or eight units of what
122
+ * follows its public framing and label. Identifiers and submitted private values are not
123
+ * here. Telling a secret from a label by shape has a limit: fewer than eight units past a
124
+ * label pass, and a public value sharing eight units of the rest, such as a publishable key
125
+ * beside its secret key, still matches. */
126
+ private outbound;
107
127
  /** Every form lowercased, for fields such as hosts that reach output lowercased. */
108
128
  private folded;
109
129
  /** The shortest text that might hold something this set cannot check, which is withheld: a
@@ -24,6 +24,11 @@ const MIN_SECRET_LENGTH = 8;
24
24
  // units is fragment-indexed. Eight units keeps ordinary words, dates, counts and auth framing out
25
25
  // of the index: a public run must repeat eight consecutive units of a secret's form to match.
26
26
  const FRAGMENT_LENGTH = MIN_SECRET_LENGTH;
27
+ // A credential's public label, for the outbound argument check alone: a vendor prefix of up to
28
+ // three short segments (`sk_test_`, `github_pat_`, `xoxb-`) or a JWT's header segment. Documents
29
+ // name these and every credential of a kind shares them. Reading a secret's opening as a label
30
+ // only stops runs starting inside it from matching; the rest of the value still matches.
31
+ const PUBLIC_LABEL = /^(?:[Ee]yJ[\w-]*\.|(?:[A-Za-z][A-Za-z0-9]{1,6}[-_]){1,3})/;
27
32
  // A private value shorter than this is matched only as a whole field value: a leaf, key, number,
28
33
  // boolean or decoded view equal to one of its forms. One to three units recur inside ordinary
29
34
  // words, counts and ids, so a substring match would rewrite them everywhere.
@@ -115,7 +120,7 @@ const TAGS = new Set(("type:text type:image type:audio type:resource type:resour
115
120
  "resultType:input_required mode:form mode:url method:elicitation/create jsonrpc:2.0 writeOutcome:unknown").split(" "));
116
121
  // Outcome flags: a boolean under these names is kept, since a boolean carries one bit whatever
117
122
  // produced it. A number or string under them is matched like any other.
118
- const FACTS = new Set(["isError", "ok", "retryable", "truncated", "hasMore"]);
123
+ const FACTS = new Set(["isError", "ok", "retryable", "truncated", "hasMore", "toolAnswered"]);
119
124
  // An error's diagnostics. Its own accessors under these names, such as V8's stack, are read once
120
125
  // into its copy, as is whatever its chain answers for them; any other own accessor is never
121
126
  // invoked. Accessors its class chain defines are read once too.
@@ -1096,6 +1101,9 @@ function windows(layer, reach) {
1096
1101
  return ranges;
1097
1102
  }
1098
1103
  export class SentSecrets {
1104
+ /** Rises whenever this set could match more. Text checked at one revision has not been
1105
+ * checked against a later one. */
1106
+ revision = 0;
1099
1107
  /** Normalized form to its strongest kind. */
1100
1108
  forms = new Map();
1101
1109
  /** A form's leading units that encode only public auth framing; absent is none. */
@@ -1132,6 +1140,7 @@ export class SentSecrets {
1132
1140
  this.widestForm = Math.max(this.widestForm, form.length);
1133
1141
  this.smallForms ||= kind < PRIVATE;
1134
1142
  this.unicodeForms ||= /[\u0080-\uffff]/.test(form);
1143
+ this.revision++;
1135
1144
  this.views = {};
1136
1145
  this.memo = new Map();
1137
1146
  for (const recipient of this.recipients)
@@ -1141,6 +1150,7 @@ export class SentSecrets {
1141
1150
  if (length >= this.unmatched)
1142
1151
  return;
1143
1152
  this.unmatched = length;
1153
+ this.revision++;
1144
1154
  this.views = {};
1145
1155
  this.memo = new Map();
1146
1156
  for (const recipient of this.recipients)
@@ -1278,6 +1288,17 @@ export class SentSecrets {
1278
1288
  else
1279
1289
  this.secret(framed[1]);
1280
1290
  }
1291
+ /** A header maintained provider code declares a public identifier: matched whole only. A name
1292
+ * alone never establishes this. Whitespace means an auth scheme or other framing, so that value
1293
+ * is a credential whatever its header, and the answer is false. */
1294
+ publicIdentifierHeader(value) {
1295
+ if (/\s/.test(value)) {
1296
+ this.header(value);
1297
+ return false;
1298
+ }
1299
+ this.identifier(value);
1300
+ return true;
1301
+ }
1281
1302
  request(input, init) {
1282
1303
  const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
1283
1304
  for (const [name, value] of headers) {
@@ -1412,6 +1433,16 @@ export class SentSecrets {
1412
1433
  redactCredentials(value) {
1413
1434
  return this.credentials().redact(value, "envelope");
1414
1435
  }
1436
+ /** A program's outbound arguments, with what the {@link outbound} check matched replaced,
1437
+ * and whether it matched anything: a write that did is refused rather than sent altered.
1438
+ * Output keeps the stricter matching; a program never holds what output withheld. */
1439
+ filterCredentials(value) {
1440
+ const outbound = this.outbound();
1441
+ if (outbound.idle())
1442
+ return { value, changed: false };
1443
+ const filtered = outbound.project(value, outbound.scan(false));
1444
+ return { value: filtered.value, changed: filtered.changed };
1445
+ }
1415
1446
  idle() {
1416
1447
  return !this.forms.size && this.unmatched === Infinity;
1417
1448
  }
@@ -1453,6 +1484,23 @@ export class SentSecrets {
1453
1484
  }
1454
1485
  return this.views.credentials;
1455
1486
  }
1487
+ /** Credentials as an outbound argument can hold them: a whole form, or eight units of what
1488
+ * follows its public framing and label. Identifiers and submitted private values are not
1489
+ * here. Telling a secret from a label by shape has a limit: fewer than eight units past a
1490
+ * label pass, and a public value sharing eight units of the rest, such as a publishable key
1491
+ * beside its secret key, still matches. */
1492
+ outbound() {
1493
+ if (!this.views.outbound) {
1494
+ const view = new SentSecrets();
1495
+ for (const [form, kind] of this.forms) {
1496
+ const frame = this.frames.get(form) ?? 0;
1497
+ if (kind === CREDENTIAL && frame !== Infinity)
1498
+ view.store(form, kind, frame + (PUBLIC_LABEL.exec(form.slice(frame))?.[0].length ?? 0));
1499
+ }
1500
+ this.views.outbound = view;
1501
+ }
1502
+ return this.views.outbound;
1503
+ }
1456
1504
  /** Every form lowercased, for fields such as hosts that reach output lowercased. */
1457
1505
  folded() {
1458
1506
  if (!this.views.folded) {
package/dist/skills.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { META_ROUTES, routeNames } from "./agent-routes.js";
2
+ import { READ_PROGRAM_SOURCE } from "./tool-safety.js";
2
3
  import { bytesToHex, utf8Length } from "./bytes.js";
3
4
  import { duringViewEffect } from "./runtime/call.js";
4
5
  import { RequestScope, requestOwner } from "./runtime/request-scope.js";
@@ -16,44 +17,92 @@ export class SkillLookupError extends ConnectorCallError {
16
17
  }
17
18
  // The meta instructions and built-in guides use the meta names: built-in guides
18
19
  // are byte-identical across deployments, and the eval code arm replaces usage.
19
- const ROUTE = `Choose a route before discovery. One known-address read uses ${META_ROUTES.read}; one known-address write uses ${META_ROUTES.write}. Unknown-address read-only work starts with ${META_ROUTES.program} 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. Sample unfamiliar reads.`;
20
- const RECOVERY = `When an auth error includes a handoff, give it to the user or operator. Use ${META_ROUTES.authorize} for a fresh link. Page resultId notices and format:"paged" handles with connecta.result(handleOrId, { page? }). Missing guidance/repair only: skills tool "usage" or connecta.skill("usage").`;
21
- /**
22
- * The always-loaded MCP `instructions` string. An endpoint is writable or
23
- * read-only: a writable one lets programs and call_destructive_tool write,
24
- * and the client owns approval; a read-only one omits write tools from search
25
- * and describe and refuses every write call. Guides may still name them.
26
- */
27
- function writePolicy(readOnly, routes) {
28
- return readOnly
29
- ? "This endpoint can't perform writes; guide steps that change data will be refused. No write route exists on this endpoint."
30
- : `This endpoint permits writes through ${routes.write}${routes.program === META_ROUTES.program ? ` and ${routes.program}` : ""}, subject to connector restrictions; the host owns approval.`;
20
+ const WRITE_POLICY = "This endpoint permits writes, subject to connector restrictions; the host owns approval. Never resend a write to see its output.";
21
+ const READ_ONLY_POLICY = "This endpoint can't perform writes; no write route exists here.";
22
+ const RECOVERY = "On errors and truncated results, follow nextAction; give auth links to the user or operator.";
23
+ /** A guide can name a write step, so its notice says what happens to one. */
24
+ const READ_ONLY_GUIDE_NOTICE = "This endpoint can't perform writes; guide steps that change data will be refused. No write route exists on this endpoint.";
25
+ const policy = (readOnly) => (readOnly ? READ_ONLY_POLICY : WRITE_POLICY);
26
+ /** Connector ids in instructions; execute_code's inventory carries the rest. */
27
+ const INSTRUCTION_CONNECTORS_MAX_BYTES = 120;
28
+ /** Ids in roster order, then an exact count once they pass the byte cap. Ids are ASCII. */
29
+ function connectorList(ids) {
30
+ if (ids.length === 0)
31
+ return "none";
32
+ const all = ids.join(", ");
33
+ if (all.length <= INSTRUCTION_CONNECTORS_MAX_BYTES)
34
+ return all;
35
+ for (let shown = ids.length - 1; shown > 0; shown--) {
36
+ const line = [...ids.slice(0, shown), `+${ids.length - shown} more`].join(", ");
37
+ if (line.length <= INSTRUCTION_CONNECTORS_MAX_BYTES)
38
+ return line;
39
+ }
40
+ return `+${ids.length} more`;
31
41
  }
32
- export const CONNECTA_INSTRUCTIONS = `${ROUTE} ${writePolicy(false, META_ROUTES)} Never repeat a write to recover its output. ${RECOVERY}`;
33
42
  /**
34
- * The instructions one endpoint serves: pool scope and guide pointer first,
35
- * then stable routing rules. Plain `/mcp` and a
36
- * pool without a description serve the constant unchanged. The description
37
- * comes from deployment config for the pool the request was admitted through;
38
- * nothing a request sends reaches it.
43
+ * The instructions one endpoint serves: pool scope, what the endpoint reaches,
44
+ * routing, write policy, recovery. The description comes from deployment config
45
+ * for the pool the request was admitted through. `connectorIds` is that
46
+ * request's admitted roster, the same one execute_code's inventory discloses:
47
+ * configured ids only, in registry order, with no catalog, call or auth state.
48
+ * A native pool omits them because its tool names already show them.
39
49
  */
40
- export function endpointInstructions(description, native, readOnly = false) {
50
+ export function endpointInstructions(description, native, readOnly, connectorIds, readRoute = false) {
51
+ const gateway = `Gateway to connected services: ${connectorList(connectorIds)}. One known read: ${META_ROUTES.read}. ${readOnly ? "" : `One known write: ${META_ROUTES.write}. `}`;
41
52
  const instructions = native
42
53
  ? nativeInstructions(native.executeCode, readOnly)
43
- : readOnly
44
- ? `Choose a route before discovery. One known read uses ${META_ROUTES.read}; other read work uses ${META_ROUTES.program} to discover, call and return the answer. ${writePolicy(true, META_ROUTES)} Never repeat a write for output. ${RECOVERY}`
45
- : CONNECTA_INSTRUCTIONS;
46
- if (description === undefined)
47
- return instructions;
48
- const routing = native
49
- ? instructions
50
- : `Known read: ${META_ROUTES.read}${readOnly ? "" : `; known write: ${META_ROUTES.write}`}. Other work: ${META_ROUTES.program}; discover and call together, return answers, sample unfamiliar reads. ${writePolicy(readOnly, META_ROUTES)} Never repeat writes for output. Give auth handoffs to user/operator; ${META_ROUTES.authorize} refreshes links. Page resultId/format:"paged" with connecta.result(handleOrId,{page?}).`;
51
- return `Use skills. ${description} ${routing}`;
54
+ : readRoute
55
+ ? `${gateway}Other read work: one ${READ_PROGRAM_SOURCE} program that searches, calls and returns the answer, with no write approval. ${readOnly ? "" : `Programs that write: ${META_ROUTES.program}. ${META_ROUTES.search} finds a write's address. `}${policy(readOnly)} ${RECOVERY}`
56
+ : `${gateway}Everything else: one ${META_ROUTES.program} program that searches, calls and returns the answer. ${policy(readOnly)} ${RECOVERY}`;
57
+ return description === undefined ? instructions : `${description} ${instructions}`;
52
58
  }
53
59
  /** Instructions for pools whose host searches the listed native tools. */
54
60
  export function nativeInstructions(executeCode, readOnly = false) {
55
61
  const routes = routeNames(executeCode ? "native-code" : "native");
56
- return `Annotations classify reads/writes. Call known tools directly; ${executeCode ? `combine work in ${routes.program} using tool descriptions’ program addresses.` : "find tools with your host's search."} ${writePolicy(readOnly, routes)} Never repeat a write for output. Give auth handoffs to the user/operator. Tools missing? Ask the operator to repair, then reconnect. Follow nextAction to page with ${executeCode ? `connecta.result in ${routes.program}` : "read_result"}. ${routes.skills} lists guidance.`;
62
+ return `Call known tools directly; ${executeCode ? `combine several in ${routes.program} using each tool's Program address.` : "find others with your host's search."} ${policy(readOnly)} ${RECOVERY} Tools missing: ask the operator to repair, then reconnect.`;
63
+ }
64
+ /** The `capabilities.extensions` key carrying {@link endpointExtension}. */
65
+ export const ENDPOINT_EXTENSION = "dev.connecta/endpoint";
66
+ const inventoryWords = (text) => text
67
+ .toLowerCase()
68
+ .split(/[^\p{L}\p{N}]+/u)
69
+ .filter(Boolean);
70
+ /** Text that only respells what its entry already shows ("Google Calendar" for google_calendar) adds nothing. */
71
+ export function addsInformation(shown, text) {
72
+ const words = inventoryWords(text);
73
+ const shownWords = inventoryWords(shown);
74
+ return words.join("") !== shownWords.join("") && words.some((word) => !shownWords.includes(word));
75
+ }
76
+ /** Clips on a whole token, so text with no token that fits leaves nothing to show. */
77
+ export const inventoryNote = (text, maxBytes) => {
78
+ const clipped = boundedEchoText(text, maxBytes);
79
+ return clipped === "…" ? undefined : clipped;
80
+ };
81
+ /** A connector's bounded title for inventories, or undefined when it adds nothing to the id or cannot fit. */
82
+ export function connectorInventoryTitle(connector) {
83
+ const title = connector.title?.replace(/\s+/g, " ").trim();
84
+ return title && addsInformation(connector.id, title) ? inventoryNote(title, 45) : undefined;
85
+ }
86
+ /**
87
+ * Facts about the endpoint this request reached, for a client composing its own
88
+ * host-specific guidance. Built only from the admitted scope and configuration:
89
+ * `connectors` is the caller's registry view, the roster `execute_code` and
90
+ * `authorize_connector` already describe, and nothing here loads a catalog.
91
+ */
92
+ export function endpointExtension(connectors, native, executeCode, readOnly) {
93
+ return {
94
+ surface: native ? "native" : "meta",
95
+ executeCode,
96
+ readOnly,
97
+ connectors: connectors.map((connector) => {
98
+ const title = connectorInventoryTitle(connector);
99
+ return {
100
+ id: connector.id,
101
+ ...(title ? { title } : {}),
102
+ ...(connectorGuideRequired(connector) ? { readFirst: connectorSkillName(connector.id) } : {}),
103
+ };
104
+ }),
105
+ };
57
106
  }
58
107
  /** True when at least one of `connectors` carries a usage guide. */
59
108
  export function hasConnectorGuides(connectors) {
@@ -115,11 +164,16 @@ const CONNECTOR_SKILL_PREFIX = "connector:";
115
164
  export function connectorSkillName(connectorId) {
116
165
  return `${CONNECTOR_SKILL_PREFIX}${connectorId}`;
117
166
  }
118
- /** The connector's guide, or undefined when it declares none (or a blank one). */
119
- export function connectorGuide(connector) {
167
+ /** The guide as authored, or undefined when the connector declares none (or a blank one). */
168
+ function authoredGuide(connector) {
120
169
  const guide = connector.usageGuide;
121
170
  const content = typeof guide === "string" ? guide : guide?.content;
122
- if (!content || content.trim() === "")
171
+ return !content || content.trim() === "" ? undefined : content;
172
+ }
173
+ /** The connector's guide with its connection's read-only notice, or undefined when it has none. */
174
+ export function connectorGuide(connector) {
175
+ const content = authoredGuide(connector);
176
+ if (content === undefined)
123
177
  return undefined;
124
178
  return connector.readOnly === true
125
179
  ? `This connection can't perform writes; guide steps that change data will be refused. No write route exists for this connection.\n\n${content}`
@@ -356,6 +410,11 @@ const PRIVATE = { resultType: "complete", ttlMs: 0, cacheScope: "private" };
356
410
  function localRecord(name, uri, description, content, aliases) {
357
411
  return { entry: { uri, frontmatter: { name, description }, resources: "dynamic" }, content, aliases };
358
412
  }
413
+ /** A local record's text after the frontmatter block Connecta wrote ahead of it. */
414
+ function localSkillBody(text) {
415
+ const close = text.indexOf("\n---\n", 3);
416
+ return close === -1 ? text : text.slice(close + "\n---\n".length).replace(/^\n/, "");
417
+ }
359
418
  /** JSON frontmatter is YAML too; no runtime provider imports or filesystem reads. */
360
419
  function validSkillName(name) {
361
420
  return name.length <= 64 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name);
@@ -416,7 +475,11 @@ const localRecordsNative = /* @__PURE__ */ registerAuth(localRecords, function (
416
475
  const guides = identities.map(({ connector, name, legacy }) => {
417
476
  const description = connectorGuideSummary(connector);
418
477
  const root = `skill://connecta/connectors/${encodeURIComponent(name)}`;
419
- const content = `---\n${JSON.stringify({ name, description }, null, 2)}\n---\n\n${readOnly ? `${writePolicy(true, META_ROUTES)}\n\n` : ""}${connectorGuide(connector)}`;
478
+ // One read-only notice: the endpoint's already covers its connections.
479
+ const guide = readOnly
480
+ ? `${READ_ONLY_GUIDE_NOTICE}\n\n${authoredGuide(connector)}`
481
+ : connectorGuide(connector);
482
+ const content = `---\n${JSON.stringify({ name, description }, null, 2)}\n---\n\n${guide}`;
420
483
  return {
421
484
  ...localRecord(name, `${root}/SKILL.md`, description, content, [
422
485
  connectorSkillName(connector.id),
@@ -666,6 +729,10 @@ export class SkillsRegistry {
666
729
  return duringViewEffect({ requestScope: this.scope, signal: this.options.requestSignal }, () => authFlow(function* () {
667
730
  const record = yield* upwardStep(authInvoke(self, "lookup", [name]));
668
731
  const content = (yield* upwardStep(authInvoke(self, "read", [record.entry.uri]))).contents[0];
732
+ // Connecta prepends frontmatter to local guides for native SKILL.md; a
733
+ // tool reader wants only the body. Downstream files keep their bytes.
734
+ if (!record.files && typeof content.text === "string")
735
+ return localSkillBody(content.text);
669
736
  const origin = record.files ? `${downstreamOrigin(record.connector.id)}\n\n` : "";
670
737
  if (typeof content.text === "string")
671
738
  return origin + content.text;
@@ -58,7 +58,8 @@ export const scopes = {
58
58
  export const RESULT_TTL_SECONDS = 900;
59
59
  /**
60
60
  * A stashed result: chunk 0 at `result:<id>`, chunk n at `result:<id>#<n>`.
61
- * The value is the paging envelope `src/meta-tools.ts` writes and reads.
61
+ * The value is the paging envelope `src/meta-tools.ts` writes and reads, or at
62
+ * chunk 0 the refusal `src/result-seal.ts` writes for a result it did not publish.
62
63
  */
63
64
  export const resultKeys = {
64
65
  family: {
@@ -191,7 +192,7 @@ export const responseCacheKeys = {
191
192
  codec: textCodec,
192
193
  ttl: {
193
194
  kind: "configured",
194
- by: "bounded downstream ttlMs; generations and hash refresh baselines expire after 48 hours",
195
+ by: "bounded downstream ttlMs; generations, refusal fences and hash refresh baselines expire after 48 hours",
195
196
  },
196
197
  durable: false,
197
198
  },
@@ -356,19 +357,21 @@ export const oauthRefreshActiveKeys = {
356
357
  prefix: "oauth:refresh-active:",
357
358
  holder: (epoch, holder) => validateStorageKey(`oauth:refresh-active:${epoch}:${holder}`),
358
359
  };
359
- /** Single-use browser links (`used`/`started`/`failed`/`closed`) and MRTR rounds. */
360
+ /** Single-use browser links and forced resets (`used`/`started`/`failed`), and MRTR rounds. */
360
361
  export const oauthConnectKeys = {
361
362
  family: {
362
363
  name: "oauth-connect",
363
364
  scope: "connector",
364
- prefixes: ["oauth:connect-used:", "oauth:request-used:"],
365
+ prefixes: ["oauth:connect-used:", "oauth:connect-reset:", "oauth:request-used:"],
365
366
  version: { number: 1, in: "untagged" },
366
367
  codec: textCodec,
367
- // The link's remaining lifetime, or fifteen minutes when retiring it.
368
- ttl: { kind: "fixed", seconds: 15 * 60 },
368
+ // A link's remaining lifetime, fifteen minutes for a round, or thirty for a
369
+ // reset, which must outlive every link its flow can issue.
370
+ ttl: { kind: "fixed", seconds: 30 * 60 },
369
371
  durable: false,
370
372
  },
371
373
  used: (nonce) => validateStorageKey(`oauth:connect-used:${nonce}`),
374
+ reset: (flow) => validateStorageKey(`oauth:connect-reset:${flow}`),
372
375
  retry: (nonce) => validateStorageKey(`oauth:request-used:${nonce}`),
373
376
  };
374
377
  /**
@@ -134,7 +134,7 @@ export function nativeSqlStorage(driver) {
134
134
  },
135
135
  catalogSnapshot(keys, generationKey, prefix, suffixes) {
136
136
  return Effect.gen(function* () {
137
- if (keys.length < 1 || keys.length > 4 || suffixes.length > 2)
137
+ if (keys.length < 1 || keys.length > 4 || suffixes.length > 3)
138
138
  throw new RangeError("Catalog locator exceeds bounds");
139
139
  for (const key of [...keys, generationKey, prefix, ...suffixes])
140
140
  validateStorageKey(key);
@@ -0,0 +1,33 @@
1
+ import type { ToolDef } from "./types.js";
2
+ /** What deployment code may say about one tool. */
3
+ export type ClassificationOverride = "read" | "read-repeatable" | "write" | "hidden";
4
+ export declare function sourceClassification(tool: ToolDef): "read" | "write";
5
+ /** The source said this tool writes, as opposed to saying nothing. */
6
+ export declare function sourceDeclaresWrite(tool: ToolDef): boolean;
7
+ /**
8
+ * Deployment override, then fail-closed annotations. `"read"` speaks only where
9
+ * the source is silent: a declared write stays a write, because a read annotation
10
+ * lets a host resend the call. `"read-repeatable"` is the deployment accepting
11
+ * that resend for a tool it has checked, so it serves as a read regardless. Either
12
+ * way a source write stays write-like, so Connecta itself never replays it.
13
+ */
14
+ export declare function classifyTool(definition: ToolDef, override?: Exclude<ClassificationOverride, "hidden">): "read" | "write";
15
+ export declare function carryToolClassification(source: ToolDef, served: ToolDef, acceptRepeat?: boolean): ToolDef;
16
+ /** Replay and outcome policy only; admission and presentation use the served class. */
17
+ export declare function isWriteLike(tool: ToolDef): boolean;
18
+ /** The surface that runs a read-only program: it never dispatches a write-like call. */
19
+ export declare const READ_PROGRAM_SOURCE: "execute_read_code";
20
+ /**
21
+ * What a read program refuses: anything write-like, unless the deployment accepted
22
+ * repeats. A plain `"read"` over a source write stays out, because only
23
+ * `"read-repeatable"` says a host's resend of the program is acceptable.
24
+ */
25
+ export declare function readProgramRefuses(tool: ToolDef): boolean;
26
+ export declare function cloneTool(tool: ToolDef): ToolDef;
27
+ /**
28
+ * The only permission decision for a connector call. Approval is client-owned.
29
+ * A read-only endpoint refuses every write on every surface; a writable one
30
+ * sends writes through call_destructive_tool and writing programs, never call_tool
31
+ * or a read program.
32
+ */
33
+ export declare function writeRefusal(classification: ToolDef["classification"], surface: string, readOnly?: boolean): "read_only_endpoint" | "destructive_tool_requires_approval" | undefined;
@@ -1,33 +1,69 @@
1
- /** Deployment override, then fail-closed annotations. */
1
+ function sourceFactsOf(annotations) {
2
+ const readOnly = annotations?.readOnlyHint;
3
+ const destructive = annotations?.destructiveHint;
4
+ if (readOnly === true && destructive !== true)
5
+ return "read";
6
+ // Anything but a clean read or silence counts as declared, including contradictory
7
+ // or malformed hints: an override must not turn an unclear statement into a read.
8
+ return readOnly === undefined && destructive !== true ? "silent" : "declared_write";
9
+ }
10
+ // Source policy is fail-closed annotations, with silence kept apart from a
11
+ // declared write. Deployment overrides never enter it. It is private metadata,
12
+ // not catalog data. Keep it through host-owned clones without exposing it to
13
+ // tools or storage.
14
+ const sourceFacts = new WeakMap();
15
+ const factsFor = (tool) => sourceFacts.get(tool) ?? sourceFactsOf(tool.annotations);
16
+ export function sourceClassification(tool) {
17
+ return factsFor(tool) === "read" ? "read" : "write";
18
+ }
19
+ /** The source said this tool writes, as opposed to saying nothing. */
20
+ export function sourceDeclaresWrite(tool) {
21
+ return factsFor(tool) === "declared_write";
22
+ }
23
+ /**
24
+ * Deployment override, then fail-closed annotations. `"read"` speaks only where
25
+ * the source is silent: a declared write stays a write, because a read annotation
26
+ * lets a host resend the call. `"read-repeatable"` is the deployment accepting
27
+ * that resend for a tool it has checked, so it serves as a read regardless. Either
28
+ * way a source write stays write-like, so Connecta itself never replays it.
29
+ */
2
30
  export function classifyTool(definition, override) {
3
- if (override !== undefined)
31
+ if (override === "read-repeatable")
32
+ return "read";
33
+ if (override === "write" || (override === "read" && !sourceDeclaresWrite(definition)))
4
34
  return override;
5
- return definition.annotations?.readOnlyHint === true && definition.annotations?.destructiveHint !== true
6
- ? "read"
7
- : "write";
8
- }
9
- // Source policy is fail-closed annotations.
10
- // Deployment overrides never enter it. It is private metadata, not catalog data.
11
- // Keep it through host-owned clones without exposing it to tools or storage.
12
- const sourceClassifications = new WeakMap();
13
- export function sourceClassification(tool) {
14
- return sourceClassifications.get(tool) ?? classifyTool(tool);
35
+ return sourceClassification(definition);
15
36
  }
16
- export function carryToolClassification(source, served) {
17
- sourceClassifications.set(served, sourceClassification(source));
37
+ // Tools a deployment set to `"read-repeatable"`, private like the source facts.
38
+ const repeatAccepted = new WeakSet();
39
+ export function carryToolClassification(source, served, acceptRepeat = repeatAccepted.has(source)) {
40
+ sourceFacts.set(served, factsFor(source));
41
+ if (acceptRepeat)
42
+ repeatAccepted.add(served);
18
43
  return served;
19
44
  }
20
45
  /** Replay and outcome policy only; admission and presentation use the served class. */
21
46
  export function isWriteLike(tool) {
22
47
  return tool.classification !== "read" || sourceClassification(tool) === "write";
23
48
  }
49
+ /** The surface that runs a read-only program: it never dispatches a write-like call. */
50
+ export const READ_PROGRAM_SOURCE = "execute_read_code";
51
+ /**
52
+ * What a read program refuses: anything write-like, unless the deployment accepted
53
+ * repeats. A plain `"read"` over a source write stays out, because only
54
+ * `"read-repeatable"` says a host's resend of the program is acceptable.
55
+ */
56
+ export function readProgramRefuses(tool) {
57
+ return isWriteLike(tool) && !repeatAccepted.has(tool);
58
+ }
24
59
  export function cloneTool(tool) {
25
60
  return carryToolClassification(tool, structuredClone(tool));
26
61
  }
27
62
  /**
28
63
  * The only permission decision for a connector call. Approval is client-owned.
29
64
  * A read-only endpoint refuses every write on every surface; a writable one
30
- * sends writes through call_destructive_tool and programs, never call_tool.
65
+ * sends writes through call_destructive_tool and writing programs, never call_tool
66
+ * or a read program.
31
67
  */
32
68
  export function writeRefusal(classification, surface, readOnly = false) {
33
69
  if (classification === "read")
package/dist/types.d.ts CHANGED
@@ -335,6 +335,12 @@ export type ConnectorSkillResourceContents = {
335
335
  text?: never;
336
336
  });
337
337
  /** The whole plugin contract — the one open seam. */
338
+ /** A complete cached catalog and the times it was fetched and expires, in epoch milliseconds. */
339
+ export interface ConnectorCatalogPeek {
340
+ tools: ToolDef[];
341
+ fetchedAt: number;
342
+ expiresAt: number;
343
+ }
338
344
  export interface Connector {
339
345
  /** Serve only read-classified tools on every endpoint. Defaults to false. */
340
346
  readOnly?: boolean;
@@ -399,6 +405,16 @@ export interface Connector {
399
405
  * fetched lazily over the network and are not known at construction time.
400
406
  */
401
407
  staticTools?: ToolDef[];
408
+ /**
409
+ * Optional: the newest complete catalog this context could be served, fresh
410
+ * or stale, read from storage alone. No transport, no authorization and no
411
+ * writes. Discovery lists its result and marks it; call resolution never
412
+ * reads it. `remoteMcp()` implements it. A custom connector omits it and
413
+ * discovery falls back to the catalog core retains for an authenticated owner.
414
+ * A wrapper that replaces `listTools` must replace or remove this as well, or
415
+ * discovery lists what the wrapper would have changed.
416
+ */
417
+ peekCatalog?(ctx: ConnectorContext): Promise<ConnectorCatalogPeek | undefined>;
402
418
  /**
403
419
  * Optional: what this connector is, for `Connecta.describeConfig()` and the
404
420
  * operator surfaces built on it — where it points, how it authenticates,
@@ -616,6 +632,21 @@ export interface Executor {
616
632
  * rewrite; set this to say the same thing minification-proof.
617
633
  */
618
634
  readonly name?: string;
635
+ /**
636
+ * The run's wall-clock deadline, in whole milliseconds, when this sandbox has
637
+ * one: time from `execute()` to its own timeout result. Connecta ends a
638
+ * program's reads just before it, so a slow read becomes a catchable
639
+ * `timeout` rather than the whole run's failure. Omit it if the sandbox
640
+ * has no fixed deadline. See documentation/code-mode.md#cancellation-and-limits.
641
+ */
642
+ readonly deadlineMs?: number;
643
+ /**
644
+ * The largest stored result, in UTF-8 bytes, that one program may read whole
645
+ * with `connecta.result(id, { all: true })` and still have budget left to use
646
+ * it. Default 1.5 MiB. Set it lower when a tighter CPU or memory limit would
647
+ * end the run first.
648
+ */
649
+ readonly maxReadBytes?: number;
619
650
  execute(code: string, providers: ExecutorProvider[]): Promise<ExecuteResult>;
620
651
  /** Release runtime resources. Node's built-in pool implements this. */
621
652
  close?(): void | Promise<void>;
@@ -1,4 +1,5 @@
1
1
  import { META_ROUTES } from "./agent-routes.js";
2
+ import { READ_PROGRAM_SOURCE } from "./tool-safety.js";
2
3
  import { UNCOUNTED_GUEST_FUNCTIONS } from "./guest-functions.js";
3
4
  /** Self-contained content for the usage skill; independent of skill registration. */
4
5
  export const USAGE_SKILL = `---
@@ -14,7 +15,7 @@ Route as the server instructions and ${META_ROUTES.program}'s first sentence say
14
15
  Read one account with ${META_ROUTES.read}: \`{ "address": "crm.get_account", "args": { "id": "acct_42" } }\`.
15
16
  On a read-only endpoint search omits writes (guides may name them) and each
16
17
  fails read_only_endpoint; tell the user. Unannotated tools fail closed to writes.
17
- Connecta classifies and routes a requested write; the host owns approval.
18
+ Where listed, ${READ_PROGRAM_SOURCE} refuses writes, marked \`callable: false\`.
18
19
 
19
20
  ## The global and the envelopes
20
21
 
@@ -28,7 +29,7 @@ Parse \`format: "text"\` only when documented as JSON. Verify collection roots.
28
29
  \`connecta.read("resource://id/"+encodeURIComponent(uri))\` returns \`{ contents }\` for advertised URIs.
29
30
 
30
31
  Search/describe return \`{ tools }\`, never an array. Check search's
31
- \`catalogErrors\` and \`absence\` before selecting a match. Search defaults to a
32
+ \`catalogErrors\` and \`absence\` before selecting a match; a \`stale: true\` error's tools are listed from an older catalog, and a call still checks the live one. Search defaults to a
32
33
  TypeScript \`signature\`; text schemas have \`schemaFormat: "text"\` and no .properties.
33
34
  A \`readyCalls\` entry is a complete read: \`connecta.call(entry.address, entry.args)\`. Select the exact tool and
34
35
  account/environment, inspect its schema, and supply its required keys and types.
@@ -85,14 +86,14 @@ Otherwise fetch for units, pagination, aliases, or sequences.
85
86
 
86
87
  ## Large results and recovery
87
88
 
88
- Large direct results and program returns carry resultId when paging is available. Follow
89
- nextOffset in UTF-8 bytes, reassemble and reduce inside one program. Use the
90
- returned id in place of result-id below; this example assumes documented JSON.
89
+ Large results carry resultId. Follow nextOffset in UTF-8 bytes, reassemble and reduce
90
+ inside one program. Use that id for result-id below; this example assumes documented JSON.
91
91
 
92
- Calls reconstruct up to 1 MiB through stash pages. Larger values return
93
- \`{ format: "paged", valueFormat, resultId?, totalBytes, hint }\`. Pass the handle
94
- to \`connecta.result\`. No resultId means no paging. IDs expire after
95
- 15 minutes; only explicit paging spends extra host calls.
92
+ Calls inline up to 1 MiB; larger return
93
+ \`{ format: "paged", valueFormat, resultId?, totalBytes, hint }\`. Follow its hint:
94
+ \`connecta.result(handle, { all: true })\` gives \`{ data, format }\` for one host call,
95
+ or throws result_too_large; then page. IDs last 15 minutes; only explicit
96
+ paging spends extra host calls.
96
97
  Use zero-based \`{ page: 0 }\` or UTF-8 \`{ offset }\`, never both. Keep maxBytes
97
98
  fixed across pages; connector caps and the 32 KiB bridge ceiling apply.
98
99
 
@@ -100,7 +101,7 @@ fixed across pages; connector caps and the 32 KiB bridge ceiling apply.
100
101
  async () => {
101
102
  let text = "", offset = 0, page;
102
103
  do {
103
- page = await connecta.result("result-id", { offset, maxBytes: 8000 });
104
+ page = await connecta.result("result-id", { offset });
104
105
  text += page.text;
105
106
  offset = page.nextOffset;
106
107
  } while (page.hasMore);
@@ -108,13 +109,13 @@ async () => {
108
109
  }
109
110
  \`\`\`
110
111
 
111
- Oversized returns lead with nextAction. Never rerun writes for output. write_outcome_unknown or writeOutcome forbids retry, even if caught.
112
+ Oversized returns lead with nextAction. Never rerun writes for output. write_outcome_unknown or writeOutcome forbids retry, even if caught. Give the user any approval link in the tool's words; a call after approval is new.
112
113
  Recovery args may be partial (argsRedacted) or absent (argsOmitted). Reconcile
113
114
  before retrying with the original args or repeating a program that sent writes.
114
115
  Non-retryable errors need repair.
115
116
  host_auth_required needs host connection repair; provider_permission_denied needs
116
- resource-owner access. Auth failures carry the handoff in error.data. When an auth
117
- error includes a handoff, give it to the user or operator.
117
+ resource-owner access. Auth failures carry the handoff in error.data; give it to the
118
+ user or operator.
118
119
 
119
120
  ## Output
120
121