@zackbart/connecta 0.29.0 → 0.30.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.
- package/CHANGELOG.md +67 -0
- package/README.md +123 -134
- package/bin/connecta.mjs +13 -2
- package/bin/version.mjs +1 -1
- package/dist/argument-redaction.js +230 -0
- package/dist/auth-elicitation.js +14 -9
- package/dist/authorization-handoff.js +111 -0
- package/dist/catalog-cache.js +2 -2
- package/dist/catalog-service.js +103 -63
- package/dist/config.d.ts +2 -0
- package/dist/config.js +12 -7
- package/dist/connectors/guarded-fetch.js +3 -3
- package/dist/connectors/negotiation-cache.js +1 -1
- package/dist/downstream-input-context.js +9 -0
- package/dist/downstream-input.js +13 -11
- package/dist/errors.d.ts +36 -12
- package/dist/errors.js +19 -9
- package/dist/execute.js +375 -43
- package/dist/executor-result.js +11 -9
- package/dist/executors/quickjs-child.js +12 -5
- package/dist/executors/quickjs-protocol.js +5 -6
- package/dist/executors/quickjs-runtime.js +6 -1
- package/dist/executors/quickjs.js +20 -4
- package/dist/guest-runtime.js +52 -2
- package/dist/guest-types.d.ts +47 -5
- package/dist/index.d.ts +1 -1
- package/dist/invocation.js +40 -14
- package/dist/meta-output.js +0 -15
- package/dist/meta-tools.js +166 -95
- package/dist/oauth-handoff.js +88 -11
- package/dist/program-result.js +5 -0
- package/dist/program-writes.js +5 -1
- package/dist/provider.d.ts +1 -1
- package/dist/providers/_shared/google/workspace.d.ts +1 -1
- package/dist/providers/_shared/request-token.js +83 -0
- package/dist/providers/infisical/index.d.ts +7 -0
- package/dist/providers/infisical/index.js +713 -0
- package/dist/providers/infisical/skill.generated.js +8 -0
- package/dist/registry.d.ts +8 -6
- package/dist/registry.js +134 -61
- package/dist/resource-read.js +4 -1
- package/dist/routes/mcp.js +11 -2
- package/dist/sent-secrets.d.ts +54 -13
- package/dist/sent-secrets.js +770 -231
- package/dist/skills.js +26 -16
- package/dist/sqlite.js +21 -6
- package/dist/storage/keys.js +20 -5
- package/dist/storage/memory.js +9 -4
- package/dist/storage/sql.js +18 -9
- package/dist/types.d.ts +17 -3
- package/dist/usage-guide.js +49 -40
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.js +2 -1
- package/documentation/README.md +63 -0
- package/documentation/architecture.md +121 -89
- package/documentation/auth.md +138 -5
- package/documentation/code-mode.md +224 -107
- package/documentation/deploying.md +146 -0
- package/documentation/integrating.md +159 -0
- package/documentation/meta-tools.md +135 -30
- package/documentation/operating.md +135 -0
- package/documentation/operator-tests.md +4 -0
- package/documentation/operator-ui.md +132 -0
- package/documentation/provider-migration-0.29.md +5 -0
- package/examples/worker/README.md +7 -0
- package/package.json +7 -1
- package/templates/node/README.md +7 -0
- package/templates/node/package.json +1 -1
package/dist/meta-tools.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { authorizationHandoff, bindAuthorizationHandoffs } from "./authorization-handoff.js";
|
|
1
2
|
import { bindMcpClient } from "./mcp-client-context.js";
|
|
2
3
|
import { CONFIG_DEFAULTS } from "./config-defaults.js";
|
|
3
4
|
import { z } from "zod";
|
|
@@ -6,18 +7,20 @@ import { boundedDiscoveryText, CatalogService, DEFAULT_SEARCH_LIMIT, DiscoveryPo
|
|
|
6
7
|
import { resolveDiscoveryConcurrency } from "./concurrency.js";
|
|
7
8
|
import { boundedEchoText, ConnectorCallError, msg } from "./errors.js";
|
|
8
9
|
import { failureRecord, logFailure } from "./operator-record.js";
|
|
9
|
-
import { MAX_EXECUTE_RESULT_CHARS, serializeResultText } from "./executor-result.js";
|
|
10
|
+
import { guardExecuteResultValue, MAX_EXECUTE_RESULT_CHARS, serializeResultText } from "./executor-result.js";
|
|
10
11
|
import { InvocationService } from "./invocation.js";
|
|
11
12
|
import { isValidMaxResultBytes, MIN_MAX_RESULT_BYTES, resolveMaxResultBytes, } from "./registry.js";
|
|
12
13
|
import { hasConnectorGuides, SkillsRegistry } from "./skills.js";
|
|
13
14
|
import { DEFAULT_PROBE_TIMEOUT_MS, normalizeTimeoutMs } from "./timeout.js";
|
|
14
|
-
import { AUTHORIZE_OUTPUT,
|
|
15
|
+
import { AUTHORIZE_OUTPUT, SEARCH_OUTPUT, SKILLS_OUTPUT } from "./meta-output.js";
|
|
15
16
|
import { RESULT_TTL_SECONDS, resultKeys } from "./storage/keys.js";
|
|
16
17
|
import { agentOutputOperations, sentSecretsForRequest } from "./sent-secrets.js";
|
|
17
18
|
import { captureDownstreamInput } from "./downstream-input.js";
|
|
19
|
+
import { PROGRAM_RESULT_WIRE_BYTES } from "./program-result.js";
|
|
18
20
|
export { MAX_DESCRIBE_ADDRESSES, MAX_DISCOVERY_RESULT_BYTES, MAX_SEARCH_LIMIT };
|
|
19
21
|
const enc = new TextEncoder();
|
|
20
|
-
|
|
22
|
+
// Each page is a slice of payload bytes, so a leading U+FEFF is data.
|
|
23
|
+
const dec = new TextDecoder("utf-8", { ignoreBOM: true, fatal: false });
|
|
21
24
|
export function jsonResult(obj, text = JSON.stringify(obj)) {
|
|
22
25
|
return {
|
|
23
26
|
content: [{ type: "text", text }],
|
|
@@ -142,6 +145,23 @@ function base64Of(bytes) {
|
|
|
142
145
|
}
|
|
143
146
|
/** Opens a write's notice, ahead of any paging instruction. */
|
|
144
147
|
const WRITE_ALREADY_RAN = "This write already ran: do not call it again to see its result.";
|
|
148
|
+
/** UTF-8 cannot represent lone UTF-16 surrogates without changing them. */
|
|
149
|
+
function isWellFormedText(text) {
|
|
150
|
+
const native = String.prototype.isWellFormed;
|
|
151
|
+
if (native)
|
|
152
|
+
return native.call(text);
|
|
153
|
+
for (let at = 0; at < text.length; at++) {
|
|
154
|
+
const unit = text.charCodeAt(at);
|
|
155
|
+
if (unit >= 0xd800 && unit <= 0xdbff) {
|
|
156
|
+
const next = text.charCodeAt(++at);
|
|
157
|
+
if (!(next >= 0xdc00 && next <= 0xdfff))
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
else if (unit >= 0xdc00 && unit <= 0xdfff)
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
145
165
|
/**
|
|
146
166
|
* Offers a read the route meta-tools.md prefers for finding something in it:
|
|
147
167
|
* repeat the call inside a program and reduce it there. Paging comes second,
|
|
@@ -153,6 +173,8 @@ const WRITE_ALREADY_RAN = "This write already ran: do not call it again to see i
|
|
|
153
173
|
const READ_REDUCE_FIRST = "To find something specific, repeat this read inside execute_code with connecta.call and filter or search the result there, returning only what matters";
|
|
154
174
|
function pagingHint(results, totalBytes, preview) {
|
|
155
175
|
if (!results.write) {
|
|
176
|
+
if (results.binding.program)
|
|
177
|
+
return "filter/map/slice data inside execute_code and return only what you need; page this completed return with connecta.result using nextAction.";
|
|
156
178
|
const [shown, full] = preview.kind === "prefix"
|
|
157
179
|
? [`Bytes 0-${preview.bytes} of ${totalBytes} follow.`, "to read it in full, page the rest"]
|
|
158
180
|
: preview.kind === "text-of-envelope"
|
|
@@ -177,8 +199,17 @@ function pagingHint(results, totalBytes, preview) {
|
|
|
177
199
|
* documentation/meta-tools.md#result-representation). It is one compact JSON
|
|
178
200
|
* line with no raw newline, so the preview starts after the first `\n`.
|
|
179
201
|
*/
|
|
180
|
-
async function stashResult(bytes, results, preview) {
|
|
202
|
+
async function stashResult(bytes, results, preview, rawText) {
|
|
181
203
|
const totalBytes = bytes.length;
|
|
204
|
+
// Direct pages promise offsets into the original UTF-8 text. JSON-escaping
|
|
205
|
+
// raw text would change that contract; never stash the encoder's replacement
|
|
206
|
+
// characters as if they were the completed call's output.
|
|
207
|
+
if (rawText !== undefined && !isWellFormedText(rawText))
|
|
208
|
+
return {
|
|
209
|
+
truncated: true,
|
|
210
|
+
totalBytes,
|
|
211
|
+
hint: `${results.write ? WRITE_ALREADY_RAN + " " : ""}Paging is unavailable: text contains unpaired surrogates and can't be paged as text; request the value as JSON (value mode or a JSON-returning call) to recover it.`,
|
|
212
|
+
};
|
|
182
213
|
if (!results.pageable)
|
|
183
214
|
return {
|
|
184
215
|
truncated: true,
|
|
@@ -238,6 +269,74 @@ async function stashResult(bytes, results, preview) {
|
|
|
238
269
|
},
|
|
239
270
|
};
|
|
240
271
|
}
|
|
272
|
+
/** Program calls use the direct-call envelope, ledger, expiry and authority. */
|
|
273
|
+
export async function pageProgramResult(registry, baseUrl, resolved, value, format, secrets, trust, requestScope, wireBytes = PROGRAM_RESULT_WIRE_BYTES) {
|
|
274
|
+
const data = secrets.redact(value);
|
|
275
|
+
const wire = JSON.stringify({ data, format });
|
|
276
|
+
if (enc.encode(wire).length <= wireBytes)
|
|
277
|
+
return { data, format };
|
|
278
|
+
const text = secrets.text(format === "text" ? String(data) : serializeResultText(data));
|
|
279
|
+
const bytes = enc.encode(text);
|
|
280
|
+
const write = resolved.definition.classification !== "read";
|
|
281
|
+
const notice = await stashResult(bytes, {
|
|
282
|
+
pageable: !write || trust === "trusted",
|
|
283
|
+
binding: {
|
|
284
|
+
identity: registry.resultIdentity(),
|
|
285
|
+
baseUrl,
|
|
286
|
+
connector: resolved.connector.id,
|
|
287
|
+
tool: resolved.definition.name,
|
|
288
|
+
classification: write ? "write" : "read",
|
|
289
|
+
},
|
|
290
|
+
write,
|
|
291
|
+
cap: resolveMaxResultBytes(resolved.connector.maxResultBytes, registry.maxResultBytes),
|
|
292
|
+
set: (id, chunks, ttl) => registry.stashResult(id, chunks, ttl),
|
|
293
|
+
warn: () => logFailure(registry.contextFor(resolved.connector.id, baseUrl, requestScope).logger, "result paging unavailable", failureRecord({ connector: resolved.connector.id, tool: resolved.definition })),
|
|
294
|
+
}, { kind: "none" }, format === "text" ? text : undefined);
|
|
295
|
+
return {
|
|
296
|
+
format: "paged",
|
|
297
|
+
valueFormat: format,
|
|
298
|
+
...(notice.resultId ? { resultId: notice.resultId } : {}),
|
|
299
|
+
totalBytes: bytes.length,
|
|
300
|
+
truncated: true,
|
|
301
|
+
hint: notice.resultId
|
|
302
|
+
? `${write ? WRITE_ALREADY_RAN + " " : ""}Page this result with connecta.result(handle); do not repeat the call for output.`
|
|
303
|
+
: notice.hint,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
/** Program returns share the direct stash, with all host-admitted call dependencies. */
|
|
307
|
+
export async function guardProgramReturn(value, registry, baseUrl, dependencies, secrets, trust, logger, maxChars, completedWrite) {
|
|
308
|
+
const serialized = serializeResultText(value);
|
|
309
|
+
if (serialized.length <= maxChars)
|
|
310
|
+
return value;
|
|
311
|
+
const text = secrets.text(serialized);
|
|
312
|
+
const bytes = enc.encode(text);
|
|
313
|
+
const write = dependencies.some((dependency) => dependency.classification === "write");
|
|
314
|
+
const cap = Math.min(registry.maxResultBytes, ...dependencies.map((dependency) => resolveMaxResultBytes(registry.getConnector(dependency.connector)?.maxResultBytes, registry.maxResultBytes)));
|
|
315
|
+
const results = {
|
|
316
|
+
secrets,
|
|
317
|
+
pageable: !write || trust === "trusted",
|
|
318
|
+
binding: {
|
|
319
|
+
identity: registry.resultIdentity(),
|
|
320
|
+
baseUrl,
|
|
321
|
+
classification: write ? "write" : "read",
|
|
322
|
+
program: dependencies,
|
|
323
|
+
},
|
|
324
|
+
write: completedWrite,
|
|
325
|
+
cap,
|
|
326
|
+
set: (id, chunks, ttl) => registry.stashResult(id, chunks, ttl),
|
|
327
|
+
warn: () => logFailure(logger, "result paging unavailable", failureRecord({})),
|
|
328
|
+
};
|
|
329
|
+
const notice = await stashResult(bytes, results, { kind: "none" }, text);
|
|
330
|
+
// Put the entire recovery route ahead of the preview in both response forms.
|
|
331
|
+
// A client retaining only the first 2,000 characters still sees the action.
|
|
332
|
+
if (notice.resultId) {
|
|
333
|
+
const base = { ...notice, totalChars: text.length, preview: "" };
|
|
334
|
+
const budget = Math.max(0, maxChars - JSON.stringify(base).length);
|
|
335
|
+
return { ...base, preview: escapedHeadOf(bytes, Math.min(512, budget)) };
|
|
336
|
+
}
|
|
337
|
+
const fallback = guardExecuteResultValue(secrets.redact(value), maxChars, notice.hint);
|
|
338
|
+
return { truncated: true, totalChars: fallback.totalChars, hint: fallback.hint, preview: fallback.preview };
|
|
339
|
+
}
|
|
241
340
|
/** The first `cap` bytes of `bytes`, ending on a character boundary. */
|
|
242
341
|
function headOf(bytes, cap) {
|
|
243
342
|
return bytes.subarray(0, alignEndToCharBoundary(bytes, 0, cap, bytes.length));
|
|
@@ -246,7 +345,6 @@ function headOf(bytes, cap) {
|
|
|
246
345
|
function noticeFirst(notice, preview) {
|
|
247
346
|
return {
|
|
248
347
|
content: [{ type: "text", text: `${JSON.stringify(notice)}\n${preview}` }],
|
|
249
|
-
structuredContent: { ...notice, format: "text" },
|
|
250
348
|
};
|
|
251
349
|
}
|
|
252
350
|
/**
|
|
@@ -264,6 +362,10 @@ async function guardEncoded(text, bytes, results, cap) {
|
|
|
264
362
|
truncated: false,
|
|
265
363
|
};
|
|
266
364
|
}
|
|
365
|
+
if (!isWellFormedText(text)) {
|
|
366
|
+
const notice = await stashResult(bytes, results, { kind: "none" }, text);
|
|
367
|
+
return { result: noticeFirst(notice, ""), truncated: true };
|
|
368
|
+
}
|
|
267
369
|
const head = headOf(bytes, cap);
|
|
268
370
|
const notice = await stashResult(bytes, results, {
|
|
269
371
|
kind: "prefix",
|
|
@@ -380,13 +482,6 @@ async function guardContent(content, results, cap) {
|
|
|
380
482
|
truncated: true,
|
|
381
483
|
};
|
|
382
484
|
}
|
|
383
|
-
/**
|
|
384
|
-
* The sentence that closes the OAuth handoff, telling the operator's agent how
|
|
385
|
-
* to confirm the flow landed through the one surface it can call.
|
|
386
|
-
*/
|
|
387
|
-
function oauthFollowUp(connectorId) {
|
|
388
|
-
return `Then retry the original call; connecta.search({ connector: ${JSON.stringify(connectorId)} }) inside execute_code confirms the catalog now loads.`;
|
|
389
|
-
}
|
|
390
485
|
/**
|
|
391
486
|
* Every meta-tool handler over a registry, one per registered tool. Exported for
|
|
392
487
|
* direct testing; registerMetaTools() wires the six explicit tools onto an
|
|
@@ -415,6 +510,9 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
|
|
|
415
510
|
// identity lets remote connectors reuse one downstream client inside that
|
|
416
511
|
// request without leaking request-bound I/O into the next one.
|
|
417
512
|
const requestScope = opts.requestScope ?? {};
|
|
513
|
+
if (opts.canManageAuth || opts.oauthConnectUrl || opts.credentialHandoffUrl || opts.oauthConnectUnavailable) {
|
|
514
|
+
bindAuthorizationHandoffs(requestScope, (connector) => authorizationHandoff(registry, baseUrl, requestScope, opts, { connector }));
|
|
515
|
+
}
|
|
418
516
|
const sentSecrets = sentSecretsForRequest(requestScope);
|
|
419
517
|
const skills = new SkillsRegistry(registry, baseUrl, {
|
|
420
518
|
requestScope,
|
|
@@ -498,12 +596,14 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
|
|
|
498
596
|
];
|
|
499
597
|
}
|
|
500
598
|
const guarded = await guardContent(content, results, cap);
|
|
501
|
-
|
|
599
|
+
// Native blocks and bounded previews are the result. A format-only
|
|
600
|
+
// structuredContent hides them in clients that prefer that field.
|
|
601
|
+
guarded.result._meta = { "dev.connecta/format": format };
|
|
502
602
|
return processed(guarded.result, guarded.truncated);
|
|
503
603
|
}
|
|
504
604
|
const value = result;
|
|
505
605
|
const guarded = await guardText(serializeResultText(value), results, cap);
|
|
506
|
-
guarded.result.
|
|
606
|
+
guarded.result._meta = { "dev.connecta/format": format };
|
|
507
607
|
return processed(guarded.result, guarded.truncated, { value });
|
|
508
608
|
},
|
|
509
609
|
activityFriction: (processed) => processed.friction,
|
|
@@ -615,6 +715,9 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
|
|
|
615
715
|
return errorResult(`Invalid offset ${args.offset}: must be a whole number of bytes ` +
|
|
616
716
|
`>= ${MIN_RESULT_OFFSET}. Omit it to start at the beginning.`);
|
|
617
717
|
}
|
|
718
|
+
if (args.page !== undefined && (!Number.isSafeInteger(args.page) || args.page < 0 || args.offset !== undefined)) {
|
|
719
|
+
return errorResult("Use a whole page >= 0, without offset.");
|
|
720
|
+
}
|
|
618
721
|
const results = registry.resultsStorage();
|
|
619
722
|
const unavailableResult = () => ({
|
|
620
723
|
...jsonResult({
|
|
@@ -668,21 +771,48 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
|
|
|
668
771
|
binding.identity.principal !== identity.principal ||
|
|
669
772
|
binding.identity.endpoint !== identity.endpoint ||
|
|
670
773
|
binding.identity.origin !== identity.origin ||
|
|
671
|
-
|
|
672
|
-
|
|
774
|
+
(binding.program === undefined
|
|
775
|
+
? typeof binding.connector !== "string" || typeof binding.tool !== "string"
|
|
776
|
+
: !Array.isArray(binding.program) ||
|
|
777
|
+
binding.program.some((dependency) => !dependency ||
|
|
778
|
+
typeof dependency.connector !== "string" ||
|
|
779
|
+
(dependency.tool !== undefined && typeof dependency.tool !== "string") ||
|
|
780
|
+
(dependency.access !== undefined &&
|
|
781
|
+
(dependency.access !== "catalog" || dependency.classification !== "read")) ||
|
|
782
|
+
!["read", "write"].includes(dependency.classification))) ||
|
|
673
783
|
!["read", "write"].includes(binding.classification))
|
|
674
784
|
return denied();
|
|
675
785
|
const allowed = async () => {
|
|
676
786
|
options.signal?.throwIfAborted();
|
|
677
787
|
try {
|
|
678
|
-
|
|
679
|
-
|
|
788
|
+
if (binding.program &&
|
|
789
|
+
!(await registry.recheckResultAccess(undefined, binding.classification, options.signal)))
|
|
680
790
|
return false;
|
|
681
|
-
if (
|
|
791
|
+
if (binding.classification === "write" && opts.trust !== "trusted")
|
|
682
792
|
return false;
|
|
683
|
-
const
|
|
684
|
-
const
|
|
685
|
-
|
|
793
|
+
const dependencies = binding.program ?? [binding];
|
|
794
|
+
for (const dependency of dependencies) {
|
|
795
|
+
const address = dependency.tool === undefined ? dependency.connector : `${dependency.connector}.${dependency.tool}`;
|
|
796
|
+
if (!(await registry.recheckResultAccess(address, dependency.classification, options.signal, dependency.access)))
|
|
797
|
+
return false;
|
|
798
|
+
if (dependency.tool === undefined) {
|
|
799
|
+
if (dependency.access === "catalog"
|
|
800
|
+
? !registry.getConnector(dependency.connector)
|
|
801
|
+
: !registry.getResourceConnector(dependency.connector))
|
|
802
|
+
return false;
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
if (!registry.getConnector(dependency.connector))
|
|
806
|
+
return false;
|
|
807
|
+
const tools = await registry.getTools(dependency.connector, baseUrl, requestScope, options.signal ? { signal: options.signal } : {});
|
|
808
|
+
const tool = tools.find((tool) => tool.name === dependency.tool);
|
|
809
|
+
if (!tool ||
|
|
810
|
+
!(dependency.access === "catalog" ||
|
|
811
|
+
(dependency.classification === "read" && tool.classification === "read") ||
|
|
812
|
+
opts.trust === "trusted"))
|
|
813
|
+
return false;
|
|
814
|
+
}
|
|
815
|
+
return true;
|
|
686
816
|
}
|
|
687
817
|
catch {
|
|
688
818
|
options.signal?.throwIfAborted();
|
|
@@ -691,7 +821,8 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
|
|
|
691
821
|
};
|
|
692
822
|
if (!(await allowed()))
|
|
693
823
|
return denied();
|
|
694
|
-
|
|
824
|
+
opts.onResultRead?.(binding.program ?? [binding]);
|
|
825
|
+
let requestedOffset = args.offset ?? 0;
|
|
695
826
|
const cap = Number(header[4]);
|
|
696
827
|
if (!isValidMaxResultBytes(cap))
|
|
697
828
|
return denied();
|
|
@@ -708,6 +839,12 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
|
|
|
708
839
|
chunkBytes < 3 ||
|
|
709
840
|
chunkBytes % 3 !== 0)
|
|
710
841
|
return denied();
|
|
842
|
+
if (args.page !== undefined) {
|
|
843
|
+
// At least one complete UTF-8 codepoint per nominal window. Adjacent
|
|
844
|
+
// pages align both edges backwards, so no character repeats or vanishes.
|
|
845
|
+
const width = Math.max(4, maxBytes);
|
|
846
|
+
requestedOffset = Math.min(total, Math.min(args.page, Math.ceil(total / width)) * width);
|
|
847
|
+
}
|
|
711
848
|
start = Math.floor(Math.max(0, Math.min(requestedOffset, total) - 3) / 3) * 3;
|
|
712
849
|
const end = Math.min(total, requestedOffset + maxBytes + 4);
|
|
713
850
|
bytes = new Uint8Array(Math.max(0, end - start));
|
|
@@ -744,7 +881,10 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
|
|
|
744
881
|
// never split across pages (which would emit U+FFFD on both sides).
|
|
745
882
|
// `nextOffset` is this aligned end, so it is a valid boundary for the
|
|
746
883
|
// next call and paging reassembles the original byte-for-byte.
|
|
747
|
-
const end =
|
|
884
|
+
const end = args.page === undefined
|
|
885
|
+
? start + alignEndToCharBoundary(bytes, offset - start, offset - start + maxBytes, total - start)
|
|
886
|
+
: start +
|
|
887
|
+
alignStartToCharBoundary(bytes, Math.min(total - start, requestedOffset - start + Math.max(4, maxBytes)));
|
|
748
888
|
const slice = dec.decode(bytes.subarray(offset - start, end - start));
|
|
749
889
|
const hasMore = end < total;
|
|
750
890
|
// The same notice-first shape as a truncated call: one line of header,
|
|
@@ -766,76 +906,8 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
|
|
|
766
906
|
return page;
|
|
767
907
|
},
|
|
768
908
|
async authorizeConnector(args) {
|
|
769
|
-
const connector = registry.getConnector(args.connector);
|
|
770
|
-
if (!connector) {
|
|
771
|
-
return errorResult(`Unknown connector "${boundedEchoText(args.connector)}"`);
|
|
772
|
-
}
|
|
773
|
-
if (!connector.startAuth) {
|
|
774
|
-
if (!connector.credential) {
|
|
775
|
-
return jsonResult({
|
|
776
|
-
connector: connector.id,
|
|
777
|
-
recovery: "unavailable",
|
|
778
|
-
message: `Connector "${connector.id}" declares neither downstream OAuth ` +
|
|
779
|
-
"nor an operator-managed credential slot. Update the connector " +
|
|
780
|
-
"or deployment configuration before retrying.",
|
|
781
|
-
});
|
|
782
|
-
}
|
|
783
|
-
const ctx = registry.contextFor(connector.id, baseUrl, requestScope);
|
|
784
|
-
if (!ctx.credential || !opts.credentialHandoffUrl) {
|
|
785
|
-
return jsonResult({
|
|
786
|
-
connector: connector.id,
|
|
787
|
-
recovery: "unavailable",
|
|
788
|
-
message: "Credential recovery needs both a vault and the optional UI. Configure " +
|
|
789
|
-
"vault and ui in deployment code, then call " +
|
|
790
|
-
"authorize_connector again.",
|
|
791
|
-
});
|
|
792
|
-
}
|
|
793
|
-
const fields = connector.credential.fields?.map((field) => ({
|
|
794
|
-
name: field.name,
|
|
795
|
-
guidance: field.description ?? field.label,
|
|
796
|
-
})) ?? [
|
|
797
|
-
{
|
|
798
|
-
name: "value",
|
|
799
|
-
guidance: connector.credential.description ?? connector.credential.label,
|
|
800
|
-
},
|
|
801
|
-
];
|
|
802
|
-
return jsonResult({
|
|
803
|
-
connector: connector.id,
|
|
804
|
-
recovery: "operator_config",
|
|
805
|
-
credential: {
|
|
806
|
-
label: connector.credential.label,
|
|
807
|
-
fields,
|
|
808
|
-
},
|
|
809
|
-
operatorUrl: opts.credentialHandoffUrl,
|
|
810
|
-
instructions: "Have the operator open operatorUrl, set and test the credential, " +
|
|
811
|
-
"then retry the original call. No redeploy is needed. " +
|
|
812
|
-
(connector.authScope === "personal"
|
|
813
|
-
? "Credential mutation requires the signed-in principal who owns this connection."
|
|
814
|
-
: "Shared credential mutation requires a signed-in human with access to this connector."),
|
|
815
|
-
});
|
|
816
|
-
}
|
|
817
|
-
if (opts.oauthConnectUnavailable || !opts.oauthConnectUrl)
|
|
818
|
-
return jsonResult({
|
|
819
|
-
connector: connector.id,
|
|
820
|
-
recovery: "unavailable",
|
|
821
|
-
message: opts.oauthConnectUnavailable ??
|
|
822
|
-
"OAuth connection requires an interactive provider and a credential vault with a handoff signing key.",
|
|
823
|
-
});
|
|
824
|
-
if (!opts.canManageAuth?.(connector.id))
|
|
825
|
-
return jsonResult({
|
|
826
|
-
connector: connector.id,
|
|
827
|
-
recovery: "unavailable",
|
|
828
|
-
message: "Your identity is not permitted to manage authentication for this connection.",
|
|
829
|
-
});
|
|
830
909
|
try {
|
|
831
|
-
return jsonResult(
|
|
832
|
-
connector: connector.id,
|
|
833
|
-
recovery: "oauth",
|
|
834
|
-
status: "auth_required",
|
|
835
|
-
authorizationUrl: await opts.oauthConnectUrl(connector.id, args.force),
|
|
836
|
-
instructions: "Open authorizationUrl in a browser and sign in as the user who requested this connection. Connecta verifies your identity and permission before starting consent. " +
|
|
837
|
-
oauthFollowUp(connector.id),
|
|
838
|
-
});
|
|
910
|
+
return jsonResult(await authorizationHandoff(registry, baseUrl, requestScope, opts, args));
|
|
839
911
|
}
|
|
840
912
|
catch (err) {
|
|
841
913
|
return errorResult(msg(err));
|
|
@@ -967,7 +1039,7 @@ export function registerMetaTools(server, registry, ctx) {
|
|
|
967
1039
|
server.registerTool("call_tool", {
|
|
968
1040
|
description: CALL_DESC,
|
|
969
1041
|
inputSchema: CALL_INPUT,
|
|
970
|
-
|
|
1042
|
+
// Default mode returns native content, so no structured result is promised.
|
|
971
1043
|
// call_tool admits only tools that are themselves explicitly read-only;
|
|
972
1044
|
// anything else is refused and routed to call_destructive_tool.
|
|
973
1045
|
annotations: READ_ONLY_REMOTE,
|
|
@@ -980,7 +1052,6 @@ export function registerMetaTools(server, registry, ctx) {
|
|
|
980
1052
|
server.registerTool("call_destructive_tool", {
|
|
981
1053
|
description: describedFor(registry, CALL_DESTRUCTIVE_DESC, "destructive"),
|
|
982
1054
|
inputSchema: CALL_DESTRUCTIVE_INPUT,
|
|
983
|
-
outputSchema: CALL_OUTPUT,
|
|
984
1055
|
annotations: {
|
|
985
1056
|
destructiveHint: true,
|
|
986
1057
|
readOnlyHint: false,
|
package/dist/oauth-handoff.js
CHANGED
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
import { oauthConnectKeys } from "./storage/keys.js";
|
|
2
2
|
const HANDOFF_TTL_MS = 15 * 60_000;
|
|
3
3
|
const INTERACTIVE_OAUTH_REQUIRED = "An interactive provider (Clerk or Cloudflare Access) is required to connect OAuth connectors.";
|
|
4
|
+
// Length-prefixed fields avoid JSON keys and escaping without reserving any
|
|
5
|
+
// character in an identity. This is an opaque wire encoding, not encryption.
|
|
6
|
+
function encodeHandoff(h) {
|
|
7
|
+
return [
|
|
8
|
+
h.connector,
|
|
9
|
+
h.owner,
|
|
10
|
+
h.principal,
|
|
11
|
+
h.origin,
|
|
12
|
+
h.expiresAt.toString(36),
|
|
13
|
+
h.nonce,
|
|
14
|
+
h.force ? "1" : "0",
|
|
15
|
+
h.after ?? "",
|
|
16
|
+
]
|
|
17
|
+
.map((field) => `${field.length}:${field}`)
|
|
18
|
+
.join("");
|
|
19
|
+
}
|
|
20
|
+
function decodeHandoff(value) {
|
|
21
|
+
const fields = [];
|
|
22
|
+
let offset = 0;
|
|
23
|
+
while (offset < value.length && fields.length < 8) {
|
|
24
|
+
const colon = value.indexOf(":", offset);
|
|
25
|
+
const lengthText = value.slice(offset, colon);
|
|
26
|
+
if (colon < offset || !/^(0|[1-9][0-9]*)$/.test(lengthText))
|
|
27
|
+
throw new Error("Invalid handoff field");
|
|
28
|
+
const length = Number(lengthText);
|
|
29
|
+
offset = colon + 1;
|
|
30
|
+
if (!Number.isSafeInteger(length) || length > value.length - offset)
|
|
31
|
+
throw new Error("Invalid handoff length");
|
|
32
|
+
fields.push(value.slice(offset, offset + length));
|
|
33
|
+
offset += length;
|
|
34
|
+
}
|
|
35
|
+
const [connector, owner, principal, origin, expiry, nonce, force, after] = fields;
|
|
36
|
+
if (fields.length !== 8 || offset !== value.length || !/^[0-9a-z]+$/.test(expiry) || !/^[01]$/.test(force))
|
|
37
|
+
throw new Error("Invalid handoff fields");
|
|
38
|
+
return {
|
|
39
|
+
connector: connector,
|
|
40
|
+
owner: owner,
|
|
41
|
+
principal: principal,
|
|
42
|
+
origin: origin,
|
|
43
|
+
expiresAt: parseInt(expiry, 36),
|
|
44
|
+
nonce: nonce,
|
|
45
|
+
force: force === "1",
|
|
46
|
+
...(after ? { after } : {}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function encodeSegment(value) {
|
|
50
|
+
let binary = "";
|
|
51
|
+
for (const byte of new TextEncoder().encode(value))
|
|
52
|
+
binary += String.fromCharCode(byte);
|
|
53
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
54
|
+
}
|
|
55
|
+
function decodeSegment(value) {
|
|
56
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value))
|
|
57
|
+
throw new Error("Invalid handoff encoding");
|
|
58
|
+
const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/"));
|
|
59
|
+
const decoded = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(Uint8Array.from(binary, (char) => char.charCodeAt(0)));
|
|
60
|
+
if (encodeSegment(decoded) !== value)
|
|
61
|
+
throw new Error("Noncanonical handoff encoding");
|
|
62
|
+
return decoded;
|
|
63
|
+
}
|
|
4
64
|
export function oauthConnectUnavailable(opts) {
|
|
5
65
|
if (!opts.config.auth.some((provider) => provider.interactiveOperator &&
|
|
6
66
|
(provider.uiAuth?.kind === "clerk" || provider.uiAuth?.kind === "cloudflare-access"))) {
|
|
@@ -34,10 +94,13 @@ export async function oauthConnectLink(opts, baseUrl, connectorId, principal, fo
|
|
|
34
94
|
const vault = opts.config.vault;
|
|
35
95
|
if (opaque && (!vault.seal || !vault.open))
|
|
36
96
|
throw new Error("URL elicitation requires a sealing credential vault.");
|
|
97
|
+
const compact = encodeHandoff(handoff);
|
|
37
98
|
const payload = opaque
|
|
38
|
-
? `
|
|
39
|
-
:
|
|
40
|
-
|
|
99
|
+
? `v4.${encodeSegment(await vault.seal(connectorId, "connecta:connect-link:v4", compact))}`
|
|
100
|
+
: `v3.${encodeSegment(compact)}`;
|
|
101
|
+
// Preserve the vault's entire signature and its representation, including
|
|
102
|
+
// custom vaults, while keeping every URL segment unpadded base64url.
|
|
103
|
+
const signature = encodeSegment(await vault.signOAuthHandoff(payload));
|
|
41
104
|
const url = new URL(`/connect/${connectorId}`, baseUrl);
|
|
42
105
|
url.searchParams.set("h", `${payload}.${signature}`);
|
|
43
106
|
return { url: url.toString(), nonce: handoff.nonce };
|
|
@@ -63,15 +126,29 @@ export async function claimAuthRetry(opts, baseUrl, id, nonce) {
|
|
|
63
126
|
export async function verifyOAuthHandoff(opts, baseUrl, connectorId, token) {
|
|
64
127
|
if (!token || token.length > 4096)
|
|
65
128
|
return null;
|
|
66
|
-
const [payload, signature, ...rest] = token.split(".");
|
|
67
|
-
if (!payload || !signature || rest.length)
|
|
68
|
-
return null;
|
|
69
129
|
try {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
130
|
+
const parts = token.split(".");
|
|
131
|
+
let h;
|
|
132
|
+
if (parts.length === 3 && (parts[0] === "v3" || parts[0] === "v4")) {
|
|
133
|
+
const [version, body, signature] = parts;
|
|
134
|
+
const payload = `${version}.${body}`;
|
|
135
|
+
if (!(await opts.config.vault?.verifyOAuthHandoff?.(payload, decodeSegment(signature))))
|
|
136
|
+
return null;
|
|
137
|
+
const decoded = decodeSegment(body);
|
|
138
|
+
h = decodeHandoff(version === "v4" ? await opts.config.vault.open(connectorId, "connecta:connect-link:v4", decoded) : decoded);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
// Keep pre-upgrade JSON and sealed v2 links until their original expiry.
|
|
142
|
+
// Authenticate their exact original text; never normalize before verifying.
|
|
143
|
+
const [payload, signature, ...rest] = parts;
|
|
144
|
+
if (!payload || !signature || rest.length)
|
|
145
|
+
return null;
|
|
146
|
+
if (!(await opts.config.vault?.verifyOAuthHandoff?.(payload, signature)))
|
|
147
|
+
return null;
|
|
148
|
+
h = JSON.parse(payload.startsWith("v2:")
|
|
149
|
+
? await opts.config.vault.open(connectorId, "connecta:connect-link:v2", atob(payload.slice(3)))
|
|
150
|
+
: atob(payload));
|
|
151
|
+
}
|
|
75
152
|
const connector = opts.registry.getConnector(connectorId);
|
|
76
153
|
if (!connector ||
|
|
77
154
|
h.connector !== connectorId ||
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Shared transport policy, independent of any optional executor implementation. */
|
|
2
|
+
export const PROGRAM_RESULT_INLINE_BYTES = 1024 * 1024;
|
|
3
|
+
/** Leave room for the bridge envelope and six-character JSON escapes per byte. */
|
|
4
|
+
export const PROGRAM_RESULT_PAGE_BYTES = 32 * 1024;
|
|
5
|
+
export const PROGRAM_RESULT_WIRE_BYTES = 240 * 1024;
|
package/dist/program-writes.js
CHANGED
|
@@ -104,6 +104,10 @@ export class ProgramWrites {
|
|
|
104
104
|
get isClosed() {
|
|
105
105
|
return this.closed;
|
|
106
106
|
}
|
|
107
|
+
/** Whether a dispatched write completed successfully in this run. */
|
|
108
|
+
get hasSucceeded() {
|
|
109
|
+
return this.states.includes("ok");
|
|
110
|
+
}
|
|
107
111
|
close() {
|
|
108
112
|
this.closed = true;
|
|
109
113
|
}
|
|
@@ -151,7 +155,7 @@ export class ProgramWrites {
|
|
|
151
155
|
if (writes.unknown > 0) {
|
|
152
156
|
return errorEnvelope({
|
|
153
157
|
code: "write_outcome_unknown",
|
|
154
|
-
message: "A write this program sent has no known outcome, so that is the result rather than what the program returned. It will not be sent again. Check its target before doing anything that depends on it.",
|
|
158
|
+
message: "A write this program sent has no known outcome, so that is the result rather than what the program returned. It will not be sent again. Check its target before doing anything that depends on it. Argument echoes may be partial or absent; use the original arguments if reconciliation requires another call.",
|
|
155
159
|
retryable: false,
|
|
156
160
|
...(this.deadlines.length > 0 ? { details: this.deadlines[0] } : {}),
|
|
157
161
|
...(this.deadlines.length > 1 ? { timeouts: this.deadlines } : {}),
|
package/dist/provider.d.ts
CHANGED
|
@@ -35,11 +35,11 @@ export declare const PROVIDER_COMMON: {
|
|
|
35
35
|
maxPartitions: Field<unknown, unknown, true>;
|
|
36
36
|
};
|
|
37
37
|
};
|
|
38
|
+
instructions: Field<unknown, unknown, true>;
|
|
38
39
|
title: Field<unknown, unknown, true>;
|
|
39
40
|
authScope: Field<unknown, unknown, true>;
|
|
40
41
|
maxResultBytes: Field<unknown, unknown, true>;
|
|
41
42
|
purpose: Field<unknown, unknown, true>;
|
|
42
|
-
instructions: Field<unknown, unknown, true>;
|
|
43
43
|
};
|
|
44
44
|
/** The provider's maintained, connection-independent usage guide. */
|
|
45
45
|
export interface ProviderSkill {
|
|
@@ -109,12 +109,12 @@ export declare const GOOGLE_WORKSPACE_OPTIONS: import("../../../config-schema.js
|
|
|
109
109
|
maxPartitions: import("../../../config-schema.js").Field<unknown, unknown, true>;
|
|
110
110
|
};
|
|
111
111
|
};
|
|
112
|
+
instructions: import("../../../config-schema.js").Field<unknown, unknown, true>;
|
|
112
113
|
title: import("../../../config-schema.js").Field<unknown, unknown, true>;
|
|
113
114
|
maxResultBytes: import("../../../config-schema.js").Field<unknown, unknown, true>;
|
|
114
115
|
baseUrl: import("../../../config-schema.js").Field<unknown, unknown, true>;
|
|
115
116
|
subject: import("../../../config-schema.js").Field<unknown, unknown, true>;
|
|
116
117
|
purpose: import("../../../config-schema.js").Field<unknown, unknown, true>;
|
|
117
|
-
instructions: import("../../../config-schema.js").Field<unknown, unknown, true>;
|
|
118
118
|
};
|
|
119
119
|
};
|
|
120
120
|
/** The checked, construction-time half of {@link GoogleWorkspaceOptions}. */
|