@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.
- package/CHANGELOG.md +128 -0
- package/README.md +49 -92
- package/dist/access-tokens.d.ts +32 -0
- package/dist/access-tokens.d.ts.map +1 -0
- package/dist/access-tokens.js +225 -0
- package/dist/access-tokens.js.map +1 -0
- package/dist/catalog-service.d.ts.map +1 -1
- package/dist/catalog-service.js +1 -4
- package/dist/catalog-service.js.map +1 -1
- package/dist/errors.d.ts +5 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +26 -0
- package/dist/errors.js.map +1 -1
- package/dist/execute.d.ts +3 -1
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +79 -18
- package/dist/execute.js.map +1 -1
- package/dist/executor-result.d.ts.map +1 -1
- package/dist/executor-result.js +37 -6
- package/dist/executor-result.js.map +1 -1
- package/dist/executors/quickjs-protocol.d.ts +12 -0
- package/dist/executors/quickjs-protocol.d.ts.map +1 -1
- package/dist/executors/quickjs-protocol.js +14 -0
- package/dist/executors/quickjs-protocol.js.map +1 -1
- package/dist/executors/quickjs-runtime.d.ts.map +1 -1
- package/dist/executors/quickjs-runtime.js +6 -3
- package/dist/executors/quickjs-runtime.js.map +1 -1
- package/dist/executors/quickjs.d.ts.map +1 -1
- package/dist/executors/quickjs.js +10 -4
- package/dist/executors/quickjs.js.map +1 -1
- package/dist/index.d.ts +27 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +43 -1
- package/dist/index.js.map +1 -1
- package/dist/invocation.d.ts.map +1 -1
- package/dist/invocation.js +1 -4
- package/dist/invocation.js.map +1 -1
- package/dist/meta-tools.d.ts +26 -5
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +84 -40
- package/dist/meta-tools.js.map +1 -1
- package/dist/operator-ui/generated.d.ts +2 -2
- package/dist/operator-ui/generated.d.ts.map +1 -1
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/generated.js.map +1 -1
- package/dist/operator-ui/model.d.ts +2 -0
- package/dist/operator-ui/model.d.ts.map +1 -1
- package/dist/operator-ui/model.js.map +1 -1
- package/dist/routes/access-tokens.d.ts +7 -0
- package/dist/routes/access-tokens.d.ts.map +1 -0
- package/dist/routes/access-tokens.js +84 -0
- package/dist/routes/access-tokens.js.map +1 -0
- package/dist/routes/mcp.d.ts.map +1 -1
- package/dist/routes/mcp.js +8 -2
- package/dist/routes/mcp.js.map +1 -1
- package/dist/routes/shared.d.ts +11 -2
- package/dist/routes/shared.d.ts.map +1 -1
- package/dist/routes/shared.js.map +1 -1
- package/dist/routes/ui.d.ts.map +1 -1
- package/dist/routes/ui.js +9 -1
- package/dist/routes/ui.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +5 -0
- package/dist/server.js.map +1 -1
- package/dist/skills.d.ts +15 -3
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +63 -10
- package/dist/skills.js.map +1 -1
- package/dist/storage/file.d.ts.map +1 -1
- package/dist/storage/file.js +5 -0
- package/dist/storage/file.js.map +1 -1
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +8 -0
- package/dist/storage/memory.js.map +1 -1
- package/dist/types.d.ts +19 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +4 -4
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +44 -1
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +2 -2
- package/src/access-tokens.ts +289 -0
- package/src/catalog-service.ts +1 -5
- package/src/errors.ts +28 -0
- package/src/execute.ts +123 -48
- package/src/executor-result.ts +50 -6
- package/src/executors/quickjs-protocol.ts +19 -0
- package/src/executors/quickjs-runtime.ts +6 -2
- package/src/executors/quickjs.ts +10 -3
- package/src/index.ts +85 -5
- package/src/invocation.ts +1 -5
- package/src/meta-tools.ts +116 -53
- package/src/operator-ui/browser.css +63 -0
- package/src/operator-ui/browser.ts +288 -2
- package/src/operator-ui/generated.ts +2 -2
- package/src/operator-ui/model.ts +6 -0
- package/src/routes/access-tokens.ts +115 -0
- package/src/routes/mcp.ts +8 -2
- package/src/routes/shared.ts +11 -1
- package/src/routes/ui.ts +9 -0
- package/src/server.ts +5 -0
- package/src/skills.ts +79 -9
- package/src/storage/file.ts +5 -0
- package/src/storage/memory.ts +8 -0
- package/src/types.ts +20 -0
- package/src/ui.ts +50 -1
- 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
|
+
}
|
package/src/catalog-service.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
mapSettledWithConcurrency,
|
|
11
11
|
resolveDiscoveryConcurrency,
|
|
12
12
|
} from "./concurrency.js";
|
|
13
|
-
import { classifyCallError,
|
|
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:
|
|
303
|
+
error: details.message,
|
|
304
|
+
errorDetails: details,
|
|
269
305
|
};
|
|
270
306
|
}
|
|
271
307
|
}),
|
|
272
308
|
);
|
|
273
309
|
},
|
|
274
|
-
search: async (raw: unknown) =>
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
result
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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 (
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
568
|
+
description: executeDescription(ctx.surface ?? "classic"),
|
|
494
569
|
inputSchema: z.object({
|
|
495
570
|
code: z
|
|
496
571
|
.string()
|
package/src/executor-result.ts
CHANGED
|
@@ -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 {
|