@zackbart/connecta 0.9.1 → 0.10.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 (111) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/README.md +49 -92
  3. package/dist/access-tokens.d.ts +32 -0
  4. package/dist/access-tokens.d.ts.map +1 -0
  5. package/dist/access-tokens.js +225 -0
  6. package/dist/access-tokens.js.map +1 -0
  7. package/dist/catalog-service.d.ts.map +1 -1
  8. package/dist/catalog-service.js +1 -4
  9. package/dist/catalog-service.js.map +1 -1
  10. package/dist/errors.d.ts +5 -0
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/errors.js +26 -0
  13. package/dist/errors.js.map +1 -1
  14. package/dist/execute.d.ts +3 -1
  15. package/dist/execute.d.ts.map +1 -1
  16. package/dist/execute.js +79 -18
  17. package/dist/execute.js.map +1 -1
  18. package/dist/executor-result.d.ts.map +1 -1
  19. package/dist/executor-result.js +37 -6
  20. package/dist/executor-result.js.map +1 -1
  21. package/dist/executors/quickjs-protocol.d.ts +12 -0
  22. package/dist/executors/quickjs-protocol.d.ts.map +1 -1
  23. package/dist/executors/quickjs-protocol.js +14 -0
  24. package/dist/executors/quickjs-protocol.js.map +1 -1
  25. package/dist/executors/quickjs-runtime.d.ts.map +1 -1
  26. package/dist/executors/quickjs-runtime.js +6 -3
  27. package/dist/executors/quickjs-runtime.js.map +1 -1
  28. package/dist/executors/quickjs.d.ts.map +1 -1
  29. package/dist/executors/quickjs.js +10 -4
  30. package/dist/executors/quickjs.js.map +1 -1
  31. package/dist/index.d.ts +27 -6
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +43 -1
  34. package/dist/index.js.map +1 -1
  35. package/dist/invocation.d.ts.map +1 -1
  36. package/dist/invocation.js +1 -4
  37. package/dist/invocation.js.map +1 -1
  38. package/dist/meta-tools.d.ts +26 -5
  39. package/dist/meta-tools.d.ts.map +1 -1
  40. package/dist/meta-tools.js +84 -40
  41. package/dist/meta-tools.js.map +1 -1
  42. package/dist/operator-ui/generated.d.ts +2 -2
  43. package/dist/operator-ui/generated.d.ts.map +1 -1
  44. package/dist/operator-ui/generated.js +2 -2
  45. package/dist/operator-ui/generated.js.map +1 -1
  46. package/dist/operator-ui/model.d.ts +2 -0
  47. package/dist/operator-ui/model.d.ts.map +1 -1
  48. package/dist/operator-ui/model.js.map +1 -1
  49. package/dist/routes/access-tokens.d.ts +7 -0
  50. package/dist/routes/access-tokens.d.ts.map +1 -0
  51. package/dist/routes/access-tokens.js +84 -0
  52. package/dist/routes/access-tokens.js.map +1 -0
  53. package/dist/routes/mcp.d.ts.map +1 -1
  54. package/dist/routes/mcp.js +8 -2
  55. package/dist/routes/mcp.js.map +1 -1
  56. package/dist/routes/shared.d.ts +11 -2
  57. package/dist/routes/shared.d.ts.map +1 -1
  58. package/dist/routes/shared.js.map +1 -1
  59. package/dist/routes/ui.d.ts.map +1 -1
  60. package/dist/routes/ui.js +9 -1
  61. package/dist/routes/ui.js.map +1 -1
  62. package/dist/server.d.ts.map +1 -1
  63. package/dist/server.js +5 -0
  64. package/dist/server.js.map +1 -1
  65. package/dist/skills.d.ts +15 -3
  66. package/dist/skills.d.ts.map +1 -1
  67. package/dist/skills.js +63 -10
  68. package/dist/skills.js.map +1 -1
  69. package/dist/storage/file.d.ts.map +1 -1
  70. package/dist/storage/file.js +5 -0
  71. package/dist/storage/file.js.map +1 -1
  72. package/dist/storage/memory.d.ts.map +1 -1
  73. package/dist/storage/memory.js +8 -0
  74. package/dist/storage/memory.js.map +1 -1
  75. package/dist/types.d.ts +19 -0
  76. package/dist/types.d.ts.map +1 -1
  77. package/dist/ui.d.ts +4 -4
  78. package/dist/ui.d.ts.map +1 -1
  79. package/dist/ui.js +44 -1
  80. package/dist/ui.js.map +1 -1
  81. package/dist/version.d.ts +1 -1
  82. package/dist/version.d.ts.map +1 -1
  83. package/dist/version.js +1 -1
  84. package/dist/version.js.map +1 -1
  85. package/package.json +2 -2
  86. package/src/access-tokens.ts +289 -0
  87. package/src/catalog-service.ts +1 -5
  88. package/src/errors.ts +28 -0
  89. package/src/execute.ts +123 -48
  90. package/src/executor-result.ts +50 -6
  91. package/src/executors/quickjs-protocol.ts +19 -0
  92. package/src/executors/quickjs-runtime.ts +6 -2
  93. package/src/executors/quickjs.ts +10 -3
  94. package/src/index.ts +85 -5
  95. package/src/invocation.ts +1 -5
  96. package/src/meta-tools.ts +116 -53
  97. package/src/operator-ui/browser.css +63 -0
  98. package/src/operator-ui/browser.ts +288 -2
  99. package/src/operator-ui/generated.ts +2 -2
  100. package/src/operator-ui/model.ts +6 -0
  101. package/src/routes/access-tokens.ts +115 -0
  102. package/src/routes/mcp.ts +8 -2
  103. package/src/routes/shared.ts +11 -1
  104. package/src/routes/ui.ts +9 -0
  105. package/src/server.ts +5 -0
  106. package/src/skills.ts +79 -9
  107. package/src/storage/file.ts +5 -0
  108. package/src/storage/memory.ts +8 -0
  109. package/src/types.ts +20 -0
  110. package/src/ui.ts +50 -1
  111. package/src/version.ts +1 -1
@@ -0,0 +1,289 @@
1
+ import type { AuthResult, InboundAuth, KVStorage } from "./types.js";
2
+
3
+ const TOKEN_PREFIX = "cta_";
4
+ const TOKEN_BYTES = 32;
5
+ const TOKEN_VALUE_RE = /^cta_[A-Za-z0-9_-]{43}$/;
6
+ const RECORD_PREFIX = "access-token:v1:record:";
7
+ const LOOKUP_PREFIX = "access-token:v1:lookup:";
8
+ const MAX_NAME_CHARACTERS = 80;
9
+ const DEFAULT_MAX_ACTIVE = 100;
10
+ const MAX_CONFIGURED_ACTIVE = 1_000;
11
+ const encoder = new TextEncoder();
12
+
13
+ interface StoredAccessToken {
14
+ version: 1;
15
+ id: string;
16
+ name: string;
17
+ tokenHash: string;
18
+ tokenPrefix: string;
19
+ createdAt: string;
20
+ createdBy: string;
21
+ revokedAt?: string;
22
+ revokedBy?: string;
23
+ }
24
+
25
+ interface TokenLookup {
26
+ version: 1;
27
+ id: string;
28
+ }
29
+
30
+ export interface AccessTokenMetadata {
31
+ id: string;
32
+ name: string;
33
+ tokenPrefix: string;
34
+ createdAt: string;
35
+ revokedAt?: string;
36
+ }
37
+
38
+ export interface CreatedAccessToken {
39
+ token: string;
40
+ accessToken: AccessTokenMetadata;
41
+ }
42
+
43
+ function recordKey(id: string): string {
44
+ return `${RECORD_PREFIX}${id}`;
45
+ }
46
+
47
+ function lookupKey(hash: string): string {
48
+ return `${LOOKUP_PREFIX}${hash}`;
49
+ }
50
+
51
+ function bytesToBase64Url(bytes: Uint8Array): string {
52
+ let binary = "";
53
+ for (const byte of bytes) binary += String.fromCharCode(byte);
54
+ return btoa(binary)
55
+ .replaceAll("+", "-")
56
+ .replaceAll("/", "_")
57
+ .replace(/=+$/u, "");
58
+ }
59
+
60
+ function bytesToHex(bytes: Uint8Array): string {
61
+ return [...bytes]
62
+ .map((byte) => byte.toString(16).padStart(2, "0"))
63
+ .join("");
64
+ }
65
+
66
+ async function hashToken(token: string): Promise<string> {
67
+ return bytesToHex(
68
+ new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(token))),
69
+ );
70
+ }
71
+
72
+ function normalizeName(value: unknown): string {
73
+ if (typeof value !== "string") {
74
+ throw new Error("Token name must be a string");
75
+ }
76
+ const compact = value.replace(/\s+/gu, " ").trim();
77
+ if (!compact) throw new Error("Token name cannot be empty");
78
+ if (Array.from(compact).length > MAX_NAME_CHARACTERS) {
79
+ throw new Error(
80
+ `Token name cannot exceed ${MAX_NAME_CHARACTERS} characters`,
81
+ );
82
+ }
83
+ return compact;
84
+ }
85
+
86
+ function parseRecord(raw: string): StoredAccessToken {
87
+ try {
88
+ const value = JSON.parse(raw) as Partial<StoredAccessToken>;
89
+ if (
90
+ value.version !== 1 ||
91
+ typeof value.id !== "string" ||
92
+ !/^[0-9a-f-]{36}$/u.test(value.id) ||
93
+ typeof value.name !== "string" ||
94
+ typeof value.tokenHash !== "string" ||
95
+ !/^[0-9a-f]{64}$/u.test(value.tokenHash) ||
96
+ typeof value.tokenPrefix !== "string" ||
97
+ typeof value.createdAt !== "string" ||
98
+ typeof value.createdBy !== "string" ||
99
+ (value.revokedAt !== undefined &&
100
+ typeof value.revokedAt !== "string") ||
101
+ (value.revokedBy !== undefined &&
102
+ typeof value.revokedBy !== "string")
103
+ ) {
104
+ throw new Error("invalid token record");
105
+ }
106
+ return value as StoredAccessToken;
107
+ } catch {
108
+ throw new Error("Stored access token metadata is invalid or corrupted");
109
+ }
110
+ }
111
+
112
+ function parseLookup(raw: string): TokenLookup | null {
113
+ try {
114
+ const value = JSON.parse(raw) as Partial<TokenLookup>;
115
+ return value.version === 1 && typeof value.id === "string"
116
+ ? { version: 1, id: value.id }
117
+ : null;
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ function metadata(record: StoredAccessToken): AccessTokenMetadata {
124
+ return {
125
+ id: record.id,
126
+ name: record.name,
127
+ tokenPrefix: record.tokenPrefix,
128
+ createdAt: record.createdAt,
129
+ ...(record.revokedAt ? { revokedAt: record.revokedAt } : {}),
130
+ };
131
+ }
132
+
133
+ function unauthorized(): AuthResult {
134
+ return {
135
+ ok: false,
136
+ response: new Response(JSON.stringify({ error: "unauthorized" }), {
137
+ status: 401,
138
+ headers: {
139
+ "Content-Type": "application/json",
140
+ "WWW-Authenticate": "Bearer",
141
+ },
142
+ }),
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Deployment-scoped personal access tokens. Secret material is never
148
+ * recoverable: authentication indexes a SHA-256 digest of a random 256-bit
149
+ * token, while separately enumerable metadata powers operator management.
150
+ */
151
+ export class AccessTokenManager {
152
+ readonly auth: InboundAuth;
153
+ private readonly maxActive: number;
154
+
155
+ constructor(
156
+ private readonly storage: KVStorage,
157
+ options: { maxActive?: number } = {},
158
+ ) {
159
+ if (!storage.list) {
160
+ throw new Error(
161
+ "accessTokens requires a storage adapter that implements list(prefix)",
162
+ );
163
+ }
164
+ const maxActive = options.maxActive ?? DEFAULT_MAX_ACTIVE;
165
+ if (
166
+ !Number.isInteger(maxActive) ||
167
+ maxActive < 1 ||
168
+ maxActive > MAX_CONFIGURED_ACTIVE
169
+ ) {
170
+ throw new Error(
171
+ `accessTokens.maxActive must be a whole number from 1 to ${MAX_CONFIGURED_ACTIVE}`,
172
+ );
173
+ }
174
+ this.maxActive = maxActive;
175
+ this.auth = {
176
+ kind: "access_token",
177
+ activityActorNamespace: "connecta:access-tokens:v1",
178
+ activityActorLabel: async (id) => {
179
+ try {
180
+ return (await this.read(id))?.name;
181
+ } catch {
182
+ return undefined;
183
+ }
184
+ },
185
+ authorize: (request) => this.authorize(request),
186
+ };
187
+ }
188
+
189
+ private async read(id: string): Promise<StoredAccessToken | null> {
190
+ const raw = await this.storage.get(recordKey(id));
191
+ return raw ? parseRecord(raw) : null;
192
+ }
193
+
194
+ async list(): Promise<AccessTokenMetadata[]> {
195
+ const keys = await this.storage.list!(RECORD_PREFIX);
196
+ const records = await Promise.all(
197
+ keys.map(async (key) => {
198
+ const raw = await this.storage.get(key);
199
+ return raw ? parseRecord(raw) : null;
200
+ }),
201
+ );
202
+ return records
203
+ .filter((record): record is StoredAccessToken => Boolean(record))
204
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
205
+ .map(metadata);
206
+ }
207
+
208
+ async create(name: unknown, createdBy: string): Promise<CreatedAccessToken> {
209
+ const normalizedName = normalizeName(name);
210
+ const active = (await this.list()).filter((token) => !token.revokedAt);
211
+ if (active.length >= this.maxActive) {
212
+ throw new Error(
213
+ `This deployment already has the maximum of ${this.maxActive} active access tokens`,
214
+ );
215
+ }
216
+ const secretBytes = crypto.getRandomValues(new Uint8Array(TOKEN_BYTES));
217
+ const token = TOKEN_PREFIX + bytesToBase64Url(secretBytes);
218
+ const hash = await hashToken(token);
219
+ if (await this.storage.get(lookupKey(hash))) {
220
+ throw new Error("Access token collision; create another token");
221
+ }
222
+ const record: StoredAccessToken = {
223
+ version: 1,
224
+ id: crypto.randomUUID(),
225
+ name: normalizedName,
226
+ tokenHash: hash,
227
+ tokenPrefix: token.slice(0, 12),
228
+ createdAt: new Date().toISOString(),
229
+ createdBy,
230
+ };
231
+ await this.storage.set(recordKey(record.id), JSON.stringify(record));
232
+ try {
233
+ await this.storage.set(
234
+ lookupKey(hash),
235
+ JSON.stringify({ version: 1, id: record.id } satisfies TokenLookup),
236
+ );
237
+ } catch (error) {
238
+ await this.storage.delete(recordKey(record.id)).catch(() => {});
239
+ throw error;
240
+ }
241
+ return { token, accessToken: metadata(record) };
242
+ }
243
+
244
+ async rename(
245
+ id: string,
246
+ name: unknown,
247
+ ): Promise<AccessTokenMetadata | null> {
248
+ const record = await this.read(id);
249
+ if (!record) return null;
250
+ record.name = normalizeName(name);
251
+ await this.storage.set(recordKey(id), JSON.stringify(record));
252
+ return metadata(record);
253
+ }
254
+
255
+ async revoke(
256
+ id: string,
257
+ revokedBy: string,
258
+ ): Promise<AccessTokenMetadata | null> {
259
+ const record = await this.read(id);
260
+ if (!record) return null;
261
+ if (!record.revokedAt) {
262
+ // Admission disappears first. A metadata-write failure may leave the UI
263
+ // calling the record active, but can never leave a token labelled
264
+ // revoked while its lookup still admits requests.
265
+ await this.storage.delete(lookupKey(record.tokenHash));
266
+ record.revokedAt = new Date().toISOString();
267
+ record.revokedBy = revokedBy;
268
+ await this.storage.set(recordKey(id), JSON.stringify(record));
269
+ }
270
+ return metadata(record);
271
+ }
272
+
273
+ private async authorize(request: Request): Promise<AuthResult> {
274
+ const header = request.headers.get("authorization") ?? "";
275
+ const match = /^Bearer\s+(.+)$/iu.exec(header);
276
+ const token = match?.[1];
277
+ if (!token || !TOKEN_VALUE_RE.test(token)) return unauthorized();
278
+ const hash = await hashToken(token);
279
+ const lookupRaw = await this.storage.get(lookupKey(hash));
280
+ if (!lookupRaw) return unauthorized();
281
+ const lookup = parseLookup(lookupRaw);
282
+ if (!lookup) return unauthorized();
283
+ const record = await this.read(lookup.id);
284
+ if (!record || record.revokedAt || record.tokenHash !== hash) {
285
+ return unauthorized();
286
+ }
287
+ return { ok: true, subjectId: record.id };
288
+ }
289
+ }
@@ -10,7 +10,7 @@ import {
10
10
  mapSettledWithConcurrency,
11
11
  resolveDiscoveryConcurrency,
12
12
  } from "./concurrency.js";
13
- import { classifyCallError, messageLooksRetryable } from "./errors.js";
13
+ import { classifyCallError, framingError } from "./errors.js";
14
14
  import type { CallErrorDetails } from "./errors.js";
15
15
  import type {
16
16
  ConnectorOperationOptions,
@@ -199,10 +199,6 @@ export type CatalogResolution =
199
199
  cause?: unknown;
200
200
  };
201
201
 
202
- function framingError(code: string, message: string): CallErrorDetails {
203
- return { code, message, retryable: messageLooksRetryable(message) };
204
- }
205
-
206
202
  function renderSchema(schema: JsonSchema, format: "compact" | "json"): unknown {
207
203
  return format === "json" ? schema : compactSchema(schema);
208
204
  }
package/src/errors.ts CHANGED
@@ -102,6 +102,34 @@ export interface CallErrorDetails {
102
102
  retry?: string;
103
103
  }
104
104
 
105
+ /**
106
+ * Codes whose retryability is a fact about connecta's own framing, never a
107
+ * guess from text. The message embeds the address the caller asked for, so a
108
+ * connector named `svc-503` or `temporary-export` would otherwise flip a policy
109
+ * refusal into `retryable: true` through the heuristic below — and a caller that
110
+ * trusts the flag would cheerfully retry a refusal forever.
111
+ */
112
+ const NEVER_RETRYABLE_FRAMING = new Set([
113
+ "unknown_address",
114
+ "unknown_tool",
115
+ "ambiguous_tool_alias",
116
+ "destructive_tool_requires_approval",
117
+ ]);
118
+
119
+ /**
120
+ * Details for a failure connecta itself framed — an address it could not
121
+ * resolve, a tool it refuses to run — rather than one a connector threw.
122
+ */
123
+ export function framingError(code: string, message: string): CallErrorDetails {
124
+ return {
125
+ code,
126
+ message,
127
+ retryable: NEVER_RETRYABLE_FRAMING.has(code)
128
+ ? false
129
+ : messageLooksRetryable(message),
130
+ };
131
+ }
132
+
105
133
  const RETRYABLE_MESSAGE_RE =
106
134
  /timeout|timed out|econnreset|econnrefused|temporar|rate.?limit|429|502|503|504|refcountedcanceler|different request/i;
107
135
  const TIMEOUT_MESSAGE_RE = /timed out|timeout/i;
package/src/execute.ts CHANGED
@@ -4,6 +4,7 @@ import type { ActivityRequestContext } from "./activity.js";
4
4
  import {
5
5
  boundedDiscoveryText,
6
6
  CatalogService,
7
+ DiscoveryPolicyError,
7
8
  flatSearchResult,
8
9
  } from "./catalog-service.js";
9
10
  import { errorResult, jsonResult, type ToolResult } from "./meta-tools.js";
@@ -16,12 +17,14 @@ import {
16
17
  ExecutorAdmissionError,
17
18
  isAdmittingExecutor,
18
19
  } from "./executor-admission.js";
20
+ import { classifyCallError } from "./errors.js";
19
21
  import {
20
22
  InvocationFailure,
21
23
  InvocationService,
22
24
  } from "./invocation.js";
23
25
  import type { RegistryView } from "./registry.js";
24
26
  import type {
27
+ ConnectaSurface,
25
28
  Executor,
26
29
  ExecutorProvider,
27
30
  Logger,
@@ -205,6 +208,29 @@ export async function buildSandboxProviders(
205
208
  }
206
209
  },
207
210
  });
211
+ /**
212
+ * A discovery bound is as typed a failure as a tool call is, and a program
213
+ * that lets one escape deserves the same envelope: register it on the same
214
+ * request-local channel so an unhandled `invalid_args`/`result_too_large`
215
+ * reaches the model with its code instead of as prose. The guest still sees
216
+ * only the message — that is the bridge's limit, not a policy.
217
+ */
218
+ const typedDiscovery = async <T>(operation: () => Promise<T>): Promise<T> => {
219
+ try {
220
+ return await operation();
221
+ } catch (err) {
222
+ if (err instanceof DiscoveryPolicyError) {
223
+ limits.onInvocationFailure?.(
224
+ new InvocationFailure({
225
+ code: err.code,
226
+ message: err.message,
227
+ retryable: false,
228
+ }),
229
+ );
230
+ }
231
+ throw err;
232
+ }
233
+ };
208
234
  const callAddress = async (address: unknown, args: unknown) => {
209
235
  const outcome = await invocation.invoke(
210
236
  String(address),
@@ -262,53 +288,65 @@ export async function buildSandboxProviders(
262
288
  data: await callAddress(item.address, item.args),
263
289
  };
264
290
  } catch (err) {
291
+ // Same failure shape batch_call reports: the message a program
292
+ // can log, plus the typed details it must classify by. A
293
+ // thrown host error crosses the sandbox bridge as a bare
294
+ // message string in every executor, so this is the one place a
295
+ // program can tell a policy refusal from a transient failure.
296
+ const details =
297
+ err instanceof InvocationFailure
298
+ ? err.details
299
+ : classifyCallError(err, "batch_call_failed");
265
300
  return {
266
301
  address: String(item.address),
267
302
  ok: false,
268
- error: msg(err),
303
+ error: details.message,
304
+ errorDetails: details,
269
305
  };
270
306
  }
271
307
  }),
272
308
  );
273
309
  },
274
- search: async (raw: unknown) => {
275
- const args = (raw ?? {}) as {
276
- query?: string;
277
- connector?: string;
278
- limit?: number;
279
- offset?: number;
280
- fullDescriptions?: boolean;
281
- includeSchemas?: "compact" | "json";
282
- includeSchemaKeys?: boolean;
283
- };
284
- const result = flatSearchResult(
285
- await catalog.search({
286
- ...args,
287
- // Key metadata rides along with schemas by default, since that is
288
- // the whole point of it in code mode. It stays opt-out because it
289
- // counts against the same hard discovery-byte ceiling.
290
- includeSchemaKeys: args.includeSchemaKeys !== false,
291
- }),
292
- );
293
- boundedDiscoveryText(
294
- result,
295
- "Request a smaller limit, omit fullDescriptions, use compact schemas, or pass includeSchemaKeys: false.",
296
- );
297
- return result;
298
- },
299
- describe: async (raw: unknown) => {
300
- const args = (raw ?? {}) as {
301
- addresses?: unknown;
302
- format?: "compact" | "json";
303
- fullDescriptions?: boolean;
304
- };
305
- const result = { tools: await catalog.describe(args) };
306
- boundedDiscoveryText(
307
- result,
308
- 'Split the address list or use format: "compact".',
309
- );
310
- return result;
311
- },
310
+ search: async (raw: unknown) =>
311
+ typedDiscovery(async () => {
312
+ const args = (raw ?? {}) as {
313
+ query?: string;
314
+ connector?: string;
315
+ limit?: number;
316
+ offset?: number;
317
+ fullDescriptions?: boolean;
318
+ includeSchemas?: "compact" | "json";
319
+ includeSchemaKeys?: boolean;
320
+ };
321
+ const result = flatSearchResult(
322
+ await catalog.search({
323
+ ...args,
324
+ // Key metadata rides along with schemas by default, since that
325
+ // is the whole point of it in code mode. It stays opt-out
326
+ // because it counts against the same discovery-byte ceiling.
327
+ includeSchemaKeys: args.includeSchemaKeys !== false,
328
+ }),
329
+ );
330
+ boundedDiscoveryText(
331
+ result,
332
+ "Request a smaller limit, omit fullDescriptions, use compact schemas, or pass includeSchemaKeys: false.",
333
+ );
334
+ return result;
335
+ }),
336
+ describe: async (raw: unknown) =>
337
+ typedDiscovery(async () => {
338
+ const args = (raw ?? {}) as {
339
+ addresses?: unknown;
340
+ format?: "compact" | "json";
341
+ fullDescriptions?: boolean;
342
+ };
343
+ const result = { tools: await catalog.describe(args) };
344
+ boundedDiscoveryText(
345
+ result,
346
+ 'Split the address list or use format: "compact".',
347
+ );
348
+ return result;
349
+ }),
312
350
  },
313
351
  },
314
352
  ];
@@ -413,13 +451,26 @@ export function createExecuteTool(
413
451
  // an unhandled tool failure keeps the same structured contract as
414
452
  // call_tool and batch_call. Failures caught by model code never reach
415
453
  // outcome.error and therefore remain under that code's control.
454
+ //
455
+ // An error the program let through unchanged matches exactly, and an
456
+ // exact match always wins: a program that wrapped one failure's message
457
+ // around another's must not have the wrong type attached. Containment is
458
+ // the fallback, so a wrapped message still reports its underlying type
459
+ // rather than losing it to prose.
416
460
  let invocationFailure: InvocationFailure | undefined;
417
- for (let i = invocationFailures.length - 1; i >= 0; i--) {
418
- const candidate = invocationFailures[i];
419
- if (candidate && outcome.error.includes(candidate.message)) {
420
- invocationFailure = candidate;
421
- break;
461
+ for (const match of [
462
+ (candidate: InvocationFailure) => outcome.error === candidate.message,
463
+ (candidate: InvocationFailure) =>
464
+ outcome.error?.includes(candidate.message) === true,
465
+ ]) {
466
+ for (let i = invocationFailures.length - 1; i >= 0; i--) {
467
+ const candidate = invocationFailures[i];
468
+ if (candidate && match(candidate)) {
469
+ invocationFailure = candidate;
470
+ break;
471
+ }
422
472
  }
473
+ if (invocationFailure) break;
423
474
  }
424
475
  if (invocationFailure) {
425
476
  const result = jsonResult({
@@ -451,15 +502,37 @@ export function createExecuteTool(
451
502
  };
452
503
  }
453
504
 
454
- const EXECUTE_DESC = `Use for dependent multi-step calls, loops, joins, branching, or reducing large results in a sandbox. Never use execute_code for search-only discovery or one downstream call: use search_tools, then call_tool when needed. For 2–10 independent calls use batch_call. Only tools explicitly annotated readOnlyHint: true are available. Each run is limited to ${EXECUTE_MAX_HOST_CALLS} host calls; connecta.batch accepts at most ${EXECUTE_MAX_BATCH_CALLS}; each host call has a ${EXECUTE_HOST_CALL_TIMEOUT_MS / 1_000}-second deadline.
505
+ /**
506
+ * How the tool opens, and where a program's argument schemas come from. Both
507
+ * differ by surface: on the classic surface `execute_code` is the tool of last
508
+ * resort and its neighbours (`batch_call`, `describe_tools`) own the simpler
509
+ * jobs, while on the code-first surface those tools are gone and the program is
510
+ * where all of that work happens. Everything after these two phrases is
511
+ * identical, so the shared body below has one source of truth.
512
+ */
513
+ const EXECUTE_ROUTING = {
514
+ classic:
515
+ "Use for dependent multi-step calls, loops, joins, branching, or reducing large results in a sandbox. Never use execute_code for search-only discovery or one downstream call: use search_tools, then call_tool when needed. For 2–10 independent calls use batch_call.",
516
+ "code-first":
517
+ "The primary surface. Use for discovery beyond one lookup, two or more calls, dependent steps, loops, joins, branching, or reducing large results before they reach the model — connecta.search and connecta.describe browse and expand catalogs in the run, and connecta.batch replaces a separate batch tool. The exception is a single call at an address already in hand: search_tools then one call_tool is cheaper than a program.",
518
+ } as const;
519
+
520
+ const EXECUTE_SCHEMA_SOURCE = {
521
+ classic: "describe_tools",
522
+ "code-first": "connecta.describe",
523
+ } as const;
524
+
525
+ const executeDescription = (
526
+ surface: ConnectaSurface,
527
+ ) => `${EXECUTE_ROUTING[surface]} Only tools explicitly annotated readOnlyHint: true are available. Each run is limited to ${EXECUTE_MAX_HOST_CALLS} host calls; connecta.batch accepts at most ${EXECUTE_MAX_BATCH_CALLS}; each host call has a ${EXECUTE_HOST_CALL_TIMEOUT_MS / 1_000}-second deadline.
455
528
 
456
529
  Write an async arrow function. It runs with NO network, filesystem, timers, or imports — the only capabilities are:
457
- - One global per connector: every address <connectorId>.<toolName> from search_tools is callable as <connectorId>.<toolName>(args) with a single args object matching the schema from describe_tools. Names are sanitized to JS identifiers: characters outside [A-Za-z0-9_$] become "_" (e.g. my-service.get.thing → my_service.get_thing), leading digits get "_" prefixed, reserved words get "_" appended.
530
+ - One global per connector: every address <connectorId>.<toolName> from search_tools is callable as <connectorId>.<toolName>(args) with a single args object matching the schema from ${EXECUTE_SCHEMA_SOURCE[surface]}. Names are sanitized to JS identifiers: characters outside [A-Za-z0-9_$] become "_" (e.g. my-service.get.thing → my_service.get_thing), leading digits get "_" prefixed, reserved words get "_" appended.
458
531
  - connecta.call(address, args) and connecta.batch(calls) — call raw addresses.
459
532
  - connecta.search(args) and connecta.describe(args) — load and inspect request-local catalogs on demand. Matches carrying schemas also list inputKeys, requiredInputKeys, and outputKeys — the same names the schema shows, ready to check against before building args. They are absent when a schema is not a plain object shape, so read the schema itself rather than assuming a missing list means no fields.
460
533
  - console.log(...) — captured and returned alongside the result.
461
534
 
462
- Tool calls return plain values (MCP text content is JSON-parsed when possible) and throw on downstream errors — use try/catch to handle them. Return a JSON-serializable value; large results are truncated, so reduce data in code instead of returning raw payloads.
535
+ Tool calls return plain values (MCP text content is JSON-parsed when possible) and throw on downstream errors — use try/catch to handle them. A thrown error carries only a message; connecta.batch reports each call as { address, ok: true, data } or { address, ok: false, error, errorDetails: { code, retryable } }, so use it when the program must tell a policy refusal from a transient failure. Never retry a failure whose retryable is false, and never retry a rate_limited one immediately — the sandbox has no timers. Return a JSON-serializable value; large results are truncated, so reduce data in code instead of returning raw payloads.
463
536
 
464
537
  Plain JavaScript only — no TypeScript syntax. For unknown-address dependent work, use one execute_code call: search inside it, read the compact schemas, and continue to the dependent calls; do not return search results for a second execute_code call. Compact schemas are TypeScript-like strings, not JSON Schema objects: write the property names they display, never a positional guess or an invented alias.
465
538
  Dependent example (only when the second call requires a value returned by the first): async () => { const { tools } = await connecta.search({ query: "pipeline run job logs", includeSchemas: "compact" }); const pick = (suffix) => { const match = tools.find((tool) => tool.address.endsWith(suffix)); if (!match) throw new Error("no tool matching " + suffix); return match.address; }; const run = await connecta.call(pick(".get_run"), { runId: 42 }); const logs = await connecta.call(pick(".get_job_logs"), { jobId: run.failedJobId }); return [run, logs]; }`;
@@ -475,6 +548,8 @@ export function registerExecuteTool(
475
548
  activity?: ActivityRequestContext;
476
549
  requestSignal?: AbortSignal;
477
550
  discoveryConcurrency?: number;
551
+ /** The advertised surface, which decides this tool's routing copy. */
552
+ surface?: ConnectaSurface;
478
553
  },
479
554
  ): void {
480
555
  const handler = createExecuteTool(
@@ -490,7 +565,7 @@ export function registerExecuteTool(
490
565
  server.registerTool(
491
566
  "execute_code",
492
567
  {
493
- description: EXECUTE_DESC,
568
+ description: executeDescription(ctx.surface ?? "classic"),
494
569
  inputSchema: z.object({
495
570
  code: z
496
571
  .string()
@@ -13,15 +13,59 @@ function serializeExecuteValue(value: unknown): string {
13
13
  return serialized === undefined ? String(value) : serialized;
14
14
  }
15
15
 
16
+ const TRUNCATION_HINT =
17
+ "filter/map/slice data inside execute_code and return only what you need";
18
+
19
+ /**
20
+ * Shape the over-cap notice so the **serialized envelope** fits the same cap
21
+ * the raw value missed. Escaping matters: a preview sliced to the cap is JSON
22
+ * text whose quotes and newlines re-escape to well over it, so a fixed slice
23
+ * would leave the envelope over-cap and a second pass through this guard would
24
+ * truncate the truncation — reporting the envelope's length as `totalChars` and
25
+ * burying the real size. Shrinking proportionally until it fits keeps the guard
26
+ * idempotent by construction: `totalChars` is always the true serialized size
27
+ * of what the program returned, and truncation happens exactly once no matter
28
+ * how many hops the value takes.
29
+ */
30
+ function truncationEnvelope(text: string): {
31
+ truncated: true;
32
+ preview: string;
33
+ totalChars: number;
34
+ hint: string;
35
+ } {
36
+ const base = {
37
+ truncated: true as const,
38
+ preview: "",
39
+ totalChars: text.length,
40
+ hint: TRUNCATION_HINT,
41
+ };
42
+ let budget = Math.max(
43
+ 0,
44
+ MAX_EXECUTE_RESULT_CHARS - JSON.stringify(base).length,
45
+ );
46
+ for (let attempt = 0; attempt < 8 && budget > 0; attempt += 1) {
47
+ const candidate = { ...base, preview: text.slice(0, budget) };
48
+ const size = JSON.stringify(candidate).length;
49
+ if (size <= MAX_EXECUTE_RESULT_CHARS) return candidate;
50
+ // Every character costs at least one serialized character, so scaling by
51
+ // the overshoot ratio (minus a step) strictly shrinks the budget.
52
+ budget = Math.max(
53
+ 0,
54
+ Math.floor(budget * (MAX_EXECUTE_RESULT_CHARS / size)) - 8,
55
+ );
56
+ }
57
+ // The loop shrinks monotonically, so this is unreachable in practice — but an
58
+ // unchecked slice is exactly how a "bounded" envelope stops being bounded.
59
+ const clamped = { ...base, preview: text.slice(0, Math.max(0, budget)) };
60
+ return JSON.stringify(clamped).length <= MAX_EXECUTE_RESULT_CHARS
61
+ ? clamped
62
+ : { ...base, preview: "" };
63
+ }
64
+
16
65
  export function guardExecuteResultValue(value: unknown): unknown {
17
66
  const text = serializeExecuteValue(value);
18
67
  if (text.length <= MAX_EXECUTE_RESULT_CHARS) return value;
19
- return {
20
- truncated: true,
21
- preview: text.slice(0, MAX_EXECUTE_RESULT_CHARS),
22
- totalChars: text.length,
23
- hint: "filter/map/slice data inside execute_code and return only what you need",
24
- };
68
+ return truncationEnvelope(text);
25
69
  }
26
70
 
27
71
  export function truncateExecuteText(text: string, max: number): string {