@zackbart/connecta 0.25.0 → 0.26.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/AGENTS.md +6 -5
- package/CHANGELOG.md +271 -0
- package/README.md +32 -15
- package/SECURITY.md +6 -5
- package/bin/connecta.mjs +5 -1
- package/dist/activity.d.ts +10 -3
- package/dist/activity.js +10 -1
- package/dist/artifacts/connector.d.ts +55 -0
- package/dist/artifacts/connector.js +724 -0
- package/dist/artifacts/document.js +76 -0
- package/dist/artifacts/guide.js +140 -0
- package/dist/artifacts/html-scan.js +329 -0
- package/dist/artifacts/json.js +68 -0
- package/dist/artifacts/kv-store.d.ts +20 -0
- package/dist/artifacts/kv-store.js +140 -0
- package/dist/artifacts/markdown.js +449 -0
- package/dist/artifacts/module.d.ts +37 -0
- package/dist/artifacts/module.js +60 -0
- package/dist/artifacts/operations.d.ts +195 -0
- package/dist/artifacts/operations.js +944 -0
- package/dist/artifacts/refresh.d.ts +53 -0
- package/dist/artifacts/refresh.js +235 -0
- package/dist/artifacts/routes.js +194 -0
- package/dist/artifacts/types.d.ts +214 -0
- package/dist/artifacts/types.js +22 -0
- package/dist/artifacts/validate.d.ts +62 -0
- package/dist/artifacts/validate.js +652 -0
- package/dist/artifacts.d.ts +6 -0
- package/dist/artifacts.js +9 -0
- package/dist/catalog-service.js +39 -29
- package/dist/catalog.js +42 -8
- package/dist/connector-access.d.ts +18 -4
- package/dist/connector-access.js +65 -20
- package/dist/connector-caller.d.ts +8 -0
- package/dist/connector-caller.js +19 -0
- package/dist/connectors/remote-mcp.js +16 -2
- package/dist/errors.d.ts +16 -1
- package/dist/errors.js +28 -0
- package/dist/execute.js +427 -70
- package/dist/executor-admission.d.ts +11 -1
- package/dist/executor-admission.js +79 -14
- package/dist/executors/quickjs.js +4 -2
- package/dist/index.d.ts +62 -6
- package/dist/index.js +230 -6
- package/dist/invocation.js +56 -10
- package/dist/meta-tools.js +7 -2
- package/dist/module-contracts.d.ts +21 -1
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +62 -5
- package/dist/operator-ui/view.js +94 -5
- package/dist/program-source.js +116 -0
- package/dist/registry.d.ts +42 -6
- package/dist/registry.js +148 -47
- package/dist/resumable.js +1291 -0
- package/dist/routes/activity.js +6 -69
- package/dist/routes/actor-labels.js +114 -0
- package/dist/routes/credentials.js +27 -5
- package/dist/routes/mcp.js +116 -59
- package/dist/routes/oauth-management.js +22 -9
- package/dist/routes/operator.js +1 -0
- package/dist/routes/shared.d.ts +23 -1
- package/dist/routes/shared.js +20 -2
- package/dist/routes/ui-data.js +2 -1
- package/dist/routes/ui.js +13 -4
- package/dist/run-journal.js +347 -0
- package/dist/runtime/shared-read.js +106 -0
- package/dist/server.js +26 -0
- package/dist/skills.js +27 -8
- package/dist/timeout.js +26 -0
- package/dist/tool-safety.d.ts +35 -0
- package/dist/tool-safety.js +31 -0
- package/dist/types.d.ts +18 -0
- package/dist/ui.d.ts +12 -5
- package/dist/ui.js +32 -6
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +196 -19
- package/documentation/auth.md +83 -2
- package/documentation/code-mode.md +221 -48
- package/documentation/meta-tools.md +44 -25
- package/ethos.md +12 -10
- package/examples/worker/AGENTS.md +6 -0
- package/examples/worker/README.md +174 -10
- package/examples/worker/src/d1-activity-row.ts +14 -2
- package/examples/worker/src/d1-activity.ts +4 -2
- package/examples/worker/src/index.ts +36 -1
- package/examples/worker/src/r2-artifact-blobs.ts +29 -0
- package/examples/worker/wrangler.jsonc +5 -1
- package/package.json +6 -2
- package/templates/node/AGENTS.md +9 -2
- package/templates/node/README.md +31 -3
- package/templates/node/package.json +1 -1
- package/templates/node/src/index.ts +27 -0
|
@@ -23,6 +23,8 @@ export declare class ExecutorExecutionError extends ExecutorAdmissionError {
|
|
|
23
23
|
export interface AdmissionLease {
|
|
24
24
|
/** Time spent waiting behind active work. Zero for immediate admission. */
|
|
25
25
|
readonly waitMs: number;
|
|
26
|
+
/** Time left in the optional total admitted-request lifetime. */
|
|
27
|
+
remainingMs(): number | undefined;
|
|
26
28
|
release(): void;
|
|
27
29
|
}
|
|
28
30
|
export interface AdmissionControllerOptions {
|
|
@@ -31,6 +33,8 @@ export interface AdmissionControllerOptions {
|
|
|
31
33
|
queueTimeoutMs: number;
|
|
32
34
|
/** Suggested delay exposed with retryable overload failures. */
|
|
33
35
|
retryAfterMs?: number;
|
|
36
|
+
/** Hard admitted-request lifetime; omitted for executor admission. */
|
|
37
|
+
maxDurationMs?: number;
|
|
34
38
|
}
|
|
35
39
|
/**
|
|
36
40
|
* Runtime-portable bounded admission. It deliberately owns no executor or
|
|
@@ -42,7 +46,9 @@ export declare class AdmissionController {
|
|
|
42
46
|
readonly maxQueueSize: number;
|
|
43
47
|
readonly queueTimeoutMs: number;
|
|
44
48
|
readonly retryAfterMs: number;
|
|
49
|
+
readonly maxDurationMs: number | undefined;
|
|
45
50
|
private active;
|
|
51
|
+
private readonly leases;
|
|
46
52
|
private closed;
|
|
47
53
|
private readonly waiters;
|
|
48
54
|
private admittedTotal;
|
|
@@ -63,7 +69,11 @@ export declare class AdmissionController {
|
|
|
63
69
|
close(): void;
|
|
64
70
|
private overloaded;
|
|
65
71
|
private makeLease;
|
|
66
|
-
private
|
|
72
|
+
private releaseSlot;
|
|
73
|
+
private fillSlots;
|
|
74
|
+
private pruneExpiredWaiters;
|
|
75
|
+
private reapExpired;
|
|
76
|
+
private nextReapDelay;
|
|
67
77
|
private remove;
|
|
68
78
|
private cleanup;
|
|
69
79
|
}
|
|
@@ -54,7 +54,9 @@ export class AdmissionController {
|
|
|
54
54
|
maxQueueSize;
|
|
55
55
|
queueTimeoutMs;
|
|
56
56
|
retryAfterMs;
|
|
57
|
+
maxDurationMs;
|
|
57
58
|
active = 0;
|
|
59
|
+
leases = new Map();
|
|
58
60
|
closed = false;
|
|
59
61
|
waiters = [];
|
|
60
62
|
admittedTotal = 0;
|
|
@@ -70,6 +72,12 @@ export class AdmissionController {
|
|
|
70
72
|
this.maxQueueSize = nonNegativeWhole(options.maxQueueSize, "maxQueueSize");
|
|
71
73
|
this.queueTimeoutMs = positiveWhole(options.queueTimeoutMs, "queueTimeoutMs");
|
|
72
74
|
this.retryAfterMs = nonNegativeWhole(options.retryAfterMs ?? this.queueTimeoutMs, "retryAfterMs");
|
|
75
|
+
this.maxDurationMs = options.maxDurationMs === undefined
|
|
76
|
+
? undefined
|
|
77
|
+
: positiveWhole(options.maxDurationMs, "maxDurationMs");
|
|
78
|
+
if (this.maxDurationMs !== undefined && this.maxDurationMs > 2_147_483_647) {
|
|
79
|
+
throw new TypeError("maxDurationMs must be at most 2,147,483,647 milliseconds.");
|
|
80
|
+
}
|
|
73
81
|
}
|
|
74
82
|
get activeCount() {
|
|
75
83
|
return this.active;
|
|
@@ -78,11 +86,13 @@ export class AdmissionController {
|
|
|
78
86
|
return this.waiters.length;
|
|
79
87
|
}
|
|
80
88
|
snapshot() {
|
|
89
|
+
this.reapExpired();
|
|
81
90
|
return {
|
|
82
91
|
concurrency: this.concurrency,
|
|
83
92
|
maxQueueSize: this.maxQueueSize,
|
|
84
93
|
queueTimeoutMs: this.queueTimeoutMs,
|
|
85
94
|
retryAfterMs: this.retryAfterMs,
|
|
95
|
+
...(this.maxDurationMs !== undefined ? { maxDurationMs: this.maxDurationMs } : {}),
|
|
86
96
|
active: this.active,
|
|
87
97
|
queued: this.waiters.length,
|
|
88
98
|
closed: this.closed,
|
|
@@ -118,32 +128,83 @@ export class AdmissionController {
|
|
|
118
128
|
});
|
|
119
129
|
}
|
|
120
130
|
makeLease(waitMs) {
|
|
121
|
-
|
|
131
|
+
const id = Symbol("admission lease");
|
|
132
|
+
const record = this.maxDurationMs !== undefined
|
|
133
|
+
? { expiresAt: Date.now() + this.maxDurationMs }
|
|
134
|
+
: {};
|
|
135
|
+
this.leases.set(id, record);
|
|
122
136
|
return {
|
|
123
137
|
waitMs,
|
|
138
|
+
remainingMs: () => !this.leases.has(id)
|
|
139
|
+
? 0
|
|
140
|
+
: record.expiresAt === undefined
|
|
141
|
+
? undefined
|
|
142
|
+
: Math.max(0, record.expiresAt - Date.now()),
|
|
124
143
|
release: () => {
|
|
125
|
-
if (
|
|
144
|
+
if (!this.leases.delete(id))
|
|
126
145
|
return;
|
|
127
|
-
|
|
128
|
-
this.release();
|
|
146
|
+
this.releaseSlot();
|
|
129
147
|
},
|
|
130
148
|
};
|
|
131
149
|
}
|
|
132
|
-
|
|
150
|
+
releaseSlot() {
|
|
133
151
|
if (this.active > 0)
|
|
134
152
|
this.active--;
|
|
135
153
|
if (this.closed)
|
|
136
154
|
return;
|
|
137
|
-
|
|
138
|
-
|
|
155
|
+
this.fillSlots();
|
|
156
|
+
}
|
|
157
|
+
fillSlots() {
|
|
158
|
+
this.pruneExpiredWaiters();
|
|
159
|
+
while (this.active < this.concurrency) {
|
|
160
|
+
const waiter = this.waiters.shift();
|
|
161
|
+
if (!waiter)
|
|
162
|
+
return;
|
|
163
|
+
const waitMs = Math.max(0, Date.now() - waiter.queuedAt);
|
|
164
|
+
this.admittedTotal++;
|
|
165
|
+
this.queueWaitCount++;
|
|
166
|
+
this.queueWaitTotalMs += waitMs;
|
|
167
|
+
this.queueWaitMaxMs = Math.max(this.queueWaitMaxMs, waitMs);
|
|
168
|
+
this.active++;
|
|
169
|
+
Deferred.doneUnsafe(waiter.outcome, Effect.succeed(this.makeLease(waitMs)));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
pruneExpiredWaiters() {
|
|
173
|
+
const now = Date.now();
|
|
174
|
+
for (let index = this.waiters.length - 1; index >= 0; index--) {
|
|
175
|
+
const waiter = this.waiters[index];
|
|
176
|
+
if (now - waiter.queuedAt < this.queueTimeoutMs)
|
|
177
|
+
continue;
|
|
178
|
+
this.waiters.splice(index, 1);
|
|
179
|
+
this.rejectedTotal++;
|
|
180
|
+
Deferred.doneUnsafe(waiter.outcome, Effect.fail(this.overloaded(`Executor admission timed out after ${this.queueTimeoutMs}ms.`)));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
reapExpired() {
|
|
184
|
+
this.pruneExpiredWaiters();
|
|
185
|
+
if (this.maxDurationMs === undefined)
|
|
139
186
|
return;
|
|
140
|
-
const
|
|
141
|
-
this.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
187
|
+
const now = Date.now();
|
|
188
|
+
for (const [id, lease] of this.leases) {
|
|
189
|
+
if (lease.expiresAt !== undefined && lease.expiresAt <= now) {
|
|
190
|
+
this.leases.delete(id);
|
|
191
|
+
this.active--;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!this.closed)
|
|
195
|
+
this.fillSlots();
|
|
196
|
+
}
|
|
197
|
+
nextReapDelay() {
|
|
198
|
+
let next = Number.POSITIVE_INFINITY;
|
|
199
|
+
for (const lease of this.leases.values()) {
|
|
200
|
+
if (lease.expiresAt !== undefined)
|
|
201
|
+
next = Math.min(next, lease.expiresAt);
|
|
202
|
+
}
|
|
203
|
+
// A queued request's own timer reaps the earliest admitted lease. A
|
|
204
|
+
// fallback check covers a briefly empty lease map after a handoff.
|
|
205
|
+
return Number.isFinite(next)
|
|
206
|
+
? Math.max(1, next - Date.now())
|
|
207
|
+
: 250;
|
|
147
208
|
}
|
|
148
209
|
remove(waiter) {
|
|
149
210
|
const index = this.waiters.indexOf(waiter);
|
|
@@ -173,6 +234,7 @@ export class AdmissionController {
|
|
|
173
234
|
// this bookkeeping while emitting nothing there.
|
|
174
235
|
static {
|
|
175
236
|
provideAdmissionProgram((controller, signal) => Effect.suspend(() => {
|
|
237
|
+
controller.reapExpired();
|
|
176
238
|
if (controller.closed) {
|
|
177
239
|
controller.closedTotal++;
|
|
178
240
|
return Effect.fail(new ExecutorAdmissionError("executor_closed", "Executor is shutting down."));
|
|
@@ -210,6 +272,9 @@ export class AdmissionController {
|
|
|
210
272
|
Deferred.await(waiter.outcome),
|
|
211
273
|
Effect.sleep(Duration.millis(controller.queueTimeoutMs)).pipe(Effect.andThen(giveUp(() => controller.rejectedTotal++, () => controller.overloaded(`Executor admission timed out after ${controller.queueTimeoutMs}ms.`)))),
|
|
212
274
|
];
|
|
275
|
+
if (controller.maxDurationMs !== undefined) {
|
|
276
|
+
contenders.push(Effect.forever(Effect.suspend(() => Effect.sleep(Duration.millis(controller.nextReapDelay()))).pipe(Effect.andThen(Effect.sync(() => controller.reapExpired())))));
|
|
277
|
+
}
|
|
213
278
|
if (signal) {
|
|
214
279
|
contenders.push(fromSignal(signal).pipe(Effect.catch(() => giveUp(() => controller.cancelledTotal++, () => new ExecutorAdmissionError("executor_cancelled", "Execution was cancelled while queued.")))));
|
|
215
280
|
}
|
|
@@ -355,8 +355,10 @@ class QuickJsChildPool {
|
|
|
355
355
|
const subprocess = fork(childPath, [], {
|
|
356
356
|
// The child needs only its entry path, exec arguments, and IPC channel.
|
|
357
357
|
// Do not copy deployment credentials or Node startup configuration into
|
|
358
|
-
// the process that contains the guest runtime.
|
|
359
|
-
|
|
358
|
+
// the process that contains the guest runtime. `TZ` alone is set: the
|
|
359
|
+
// guest's local-time methods read the child's zone, and pinning it to
|
|
360
|
+
// UTC makes them the same on every host, like a Dynamic Worker's (P6).
|
|
361
|
+
env: { TZ: "UTC" },
|
|
360
362
|
execArgv: sourceMode ? ["--import", "tsx"] : [],
|
|
361
363
|
stdio: ["ignore", "ignore", "pipe", "ipc"],
|
|
362
364
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { CredentialVault } from "./credential-contract.js";
|
|
2
2
|
import { Registry } from "./registry.js";
|
|
3
|
-
import type {
|
|
4
|
-
|
|
3
|
+
import type { ConnectorGrant } from "./connector-access.js";
|
|
4
|
+
import type { ActivityModule, ArtifactsModule, OperatorSurface } from "./module-contracts.js";
|
|
5
|
+
export type { ActivityModule, ArtifactsModule, OperatorSurface, } from "./module-contracts.js";
|
|
5
6
|
export type { CredentialVault, CredentialMetadata } from "./credential-contract.js";
|
|
6
7
|
import type { AuthenticatedIdentity, Connector, Executor, IdentityReference, InboundAuth, KVStorage, Logger } from "./types.js";
|
|
7
8
|
/** Tool-catalog caching, persistence, stale fallback, and probe deadlines. */
|
|
@@ -90,6 +91,46 @@ export interface ConnectaExecuteConfig {
|
|
|
90
91
|
* fall back to the default.
|
|
91
92
|
*/
|
|
92
93
|
watchdogMs?: number;
|
|
94
|
+
/**
|
|
95
|
+
* Let programs write. A call to a tool that is not explicitly read-only
|
|
96
|
+
* pauses the run before anything is sent and returns the exact write with
|
|
97
|
+
* a token; `resume_execution` repeating it is the approval, and the
|
|
98
|
+
* program replays from a journal to send it and continue. Needs `storage`
|
|
99
|
+
* with `compareAndSet`, because two resumes of one pause must send its
|
|
100
|
+
* writes at most once. Omitted, it is on exactly when the storage has one
|
|
101
|
+
* (a single startup warning says when it is not); `true` over storage
|
|
102
|
+
* without one refuses to construct; `false` keeps E4's refusal.
|
|
103
|
+
* `resume_execution` is listed either way.
|
|
104
|
+
*/
|
|
105
|
+
resumableWrites?: boolean;
|
|
106
|
+
/**
|
|
107
|
+
* Consequential calls one run may send, on top of the host-call budget
|
|
108
|
+
* every call already spends. Default 10. A replayed write spends it again,
|
|
109
|
+
* so it bounds the run, not each play. Invalid values fall back to the
|
|
110
|
+
* default.
|
|
111
|
+
*/
|
|
112
|
+
maxWrites?: number;
|
|
113
|
+
/**
|
|
114
|
+
* How long a paused run can be resumed, in seconds from its first pause.
|
|
115
|
+
* Default 1_800. A later pause in the same run keeps the first deadline, so
|
|
116
|
+
* a run never replays reads older than this. Invalid values fall back to
|
|
117
|
+
* the default.
|
|
118
|
+
*/
|
|
119
|
+
pausedRunTtlSeconds?: number;
|
|
120
|
+
/**
|
|
121
|
+
* Which writes a program may make without pausing for approval. Keys are
|
|
122
|
+
* connector ids (all of that connector's tools) or `connector.tool`
|
|
123
|
+
* addresses (that tool); values are `"never"` (never ask) or `"ask"`. The
|
|
124
|
+
* most specific key wins, and `"ask"` switches off a connector's own
|
|
125
|
+
* default. An exempt write still spends the write budget, is recorded in
|
|
126
|
+
* activity, and is journaled like any other; it is never read-only
|
|
127
|
+
* anywhere else — discovery still lists it as approval-required and
|
|
128
|
+
* `call_tool` still refuses it. Works with resumable writes off too. An
|
|
129
|
+
* unknown connector id, or an `api()` address its tools do not include,
|
|
130
|
+
* refuses to construct; a remote connector's tool names load later, so an
|
|
131
|
+
* address naming one it never serves simply never matches.
|
|
132
|
+
*/
|
|
133
|
+
approval?: Readonly<Record<string, "never" | "ask">>;
|
|
93
134
|
}
|
|
94
135
|
export interface AdmissionPoolConfig {
|
|
95
136
|
/** Simultaneous work admitted to this pool. */
|
|
@@ -101,6 +142,10 @@ export interface AdmissionPoolConfig {
|
|
|
101
142
|
/** Retry hint returned with overload failures, in milliseconds. */
|
|
102
143
|
retryAfterMs?: number;
|
|
103
144
|
}
|
|
145
|
+
export interface RequestAdmissionConfig extends AdmissionPoolConfig {
|
|
146
|
+
/** Hard lifetime of an admitted /mcp request. Default 300,000 ms. */
|
|
147
|
+
maxDurationMs?: number;
|
|
148
|
+
}
|
|
104
149
|
/**
|
|
105
150
|
* Runtime-portable server-memory boundaries. `/health` and operator routes do
|
|
106
151
|
* not consume these permits, so they remain responsive during MCP saturation.
|
|
@@ -110,7 +155,7 @@ export interface ConnectaAdmissionConfig {
|
|
|
110
155
|
* The `/mcp` request boundary. Defaults to 16 active, 32 queued, and a
|
|
111
156
|
* 5-second maximum wait.
|
|
112
157
|
*/
|
|
113
|
-
requests?:
|
|
158
|
+
requests?: RequestAdmissionConfig;
|
|
114
159
|
/**
|
|
115
160
|
* Fallback pool for an `executor` that does not implement its own `acquire`.
|
|
116
161
|
* Defaults to 2 active, 8 queued, and a 5-second maximum wait. Bounded
|
|
@@ -124,10 +169,13 @@ export interface ConnectaIdentityConfig {
|
|
|
124
169
|
/**
|
|
125
170
|
* What this admitted identity may discover and call: `"all"`, or a list
|
|
126
171
|
* whose entries are connector ids (the whole connector) and `connector.tool`
|
|
127
|
-
* addresses (that tool only)
|
|
128
|
-
*
|
|
172
|
+
* addresses (that tool only), or `{ tool: "connector.tool",
|
|
173
|
+
* requireReadOnly: true }` for an exact tool only while its loaded catalog
|
|
174
|
+
* explicitly classifies it read-only. Grants are additive: an unrestricted
|
|
175
|
+
* connector or address grant wins over the guarded form. An address naming
|
|
176
|
+
* a tool the catalog lacks is unreachable and warned once, never widened.
|
|
129
177
|
*/
|
|
130
|
-
connectorAccess?(identity: Readonly<AuthenticatedIdentity>): "all" | readonly
|
|
178
|
+
connectorAccess?(identity: Readonly<AuthenticatedIdentity>): "all" | readonly ConnectorGrant[] | Promise<"all" | readonly ConnectorGrant[]>;
|
|
131
179
|
/** Global payload-free activity reads. Defaults to interactive humans. */
|
|
132
180
|
activityAccess?(principal: Readonly<IdentityReference>): boolean | Promise<boolean>;
|
|
133
181
|
/** Shared credential and OAuth administration. Defaults to none. */
|
|
@@ -176,6 +224,14 @@ export interface ConnectaConfig {
|
|
|
176
224
|
vault?: CredentialVault;
|
|
177
225
|
/** Optional connection UI, created by operatorUi() from /ui. */
|
|
178
226
|
ui?: OperatorSurface;
|
|
227
|
+
/**
|
|
228
|
+
* Optional team pages over stored data, created by artifacts() from
|
|
229
|
+
* /artifacts. Adds the built-in `artifacts` connector; needs `publicUrl`,
|
|
230
|
+
* because the links it hands out are shared.
|
|
231
|
+
*/
|
|
232
|
+
artifacts?: ArtifactsModule;
|
|
233
|
+
/** Optional dedicated HTTPS origin that serves only artifact pages and their library. */
|
|
234
|
+
artifactOrigin?: string;
|
|
179
235
|
/** Tool-catalog caching, persistence, stale fallback, and probe deadlines. */
|
|
180
236
|
discovery?: ConnectaDiscoveryConfig;
|
|
181
237
|
/** Deployment-wide call deadlines and result paging threshold. */
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { credentialTestRule, describeCredentialTestMismatch, } from "./credential-rules.js";
|
|
2
2
|
import { Registry } from "./registry.js";
|
|
3
|
-
import { parseConnectorAccess, POOL_NAME_RE } from "./connector-access.js";
|
|
3
|
+
import { intersectAccess, parseConnectorAccess, POOL_NAME_RE } from "./connector-access.js";
|
|
4
4
|
import { createFetchHandler } from "./server.js";
|
|
5
|
+
import { createProgramRunner } from "./execute.js";
|
|
5
6
|
import { droppedBrandingUrls, droppedThemeTokens, droppedUiAuthUrls, } from "./branding.js";
|
|
6
7
|
import { memoryStorage } from "./storage/memory.js";
|
|
7
8
|
import { CONNECTA_VERSION } from "./version.js";
|
|
8
9
|
import { AdmissionController, executorName, isAdmittingExecutor, withExecutorAdmission, } from "./executor-admission.js";
|
|
10
|
+
import { DEFAULT_MAX_WRITES, DEFAULT_PAUSED_RUN_TTL_SECONDS, } from "./resumable.js";
|
|
9
11
|
import { disposeEdgeRuntime } from "./runtime/run.js";
|
|
12
|
+
import { NO_EXEMPTIONS } from "./tool-safety.js";
|
|
10
13
|
import { createCoreRuntime, resolveLogger } from "./runtime/services.js";
|
|
11
14
|
const REQUEST_ADMISSION_DEFAULTS = {
|
|
12
15
|
concurrency: 16,
|
|
@@ -14,18 +17,20 @@ const REQUEST_ADMISSION_DEFAULTS = {
|
|
|
14
17
|
queueTimeoutMs: 5_000,
|
|
15
18
|
retryAfterMs: 1_000,
|
|
16
19
|
};
|
|
20
|
+
const DEFAULT_REQUEST_MAX_DURATION_MS = 300_000;
|
|
17
21
|
const CODE_ADMISSION_DEFAULTS = {
|
|
18
22
|
concurrency: 2,
|
|
19
23
|
maxQueueSize: 8,
|
|
20
24
|
queueTimeoutMs: 5_000,
|
|
21
25
|
retryAfterMs: 1_000,
|
|
22
26
|
};
|
|
23
|
-
function admissionController(options, defaults) {
|
|
27
|
+
function admissionController(options, defaults, maxDurationMs) {
|
|
24
28
|
return new AdmissionController({
|
|
25
29
|
concurrency: options?.concurrency ?? defaults.concurrency,
|
|
26
30
|
maxQueueSize: options?.maxQueueSize ?? defaults.maxQueueSize,
|
|
27
31
|
queueTimeoutMs: options?.queueTimeoutMs ?? defaults.queueTimeoutMs,
|
|
28
32
|
retryAfterMs: options?.retryAfterMs ?? defaults.retryAfterMs,
|
|
33
|
+
...(maxDurationMs !== undefined ? { maxDurationMs } : {}),
|
|
29
34
|
});
|
|
30
35
|
}
|
|
31
36
|
/** Bearer providers are checked before Clerk (per spec). */
|
|
@@ -42,6 +47,10 @@ const admissionPoolSchema = {
|
|
|
42
47
|
queueTimeoutMs: null,
|
|
43
48
|
retryAfterMs: null,
|
|
44
49
|
};
|
|
50
|
+
const requestAdmissionSchema = {
|
|
51
|
+
...admissionPoolSchema,
|
|
52
|
+
maxDurationMs: null,
|
|
53
|
+
};
|
|
45
54
|
const CONFIG_SCHEMA = {
|
|
46
55
|
connectors: null,
|
|
47
56
|
auth: null,
|
|
@@ -58,6 +67,8 @@ const CONFIG_SCHEMA = {
|
|
|
58
67
|
activity: null,
|
|
59
68
|
vault: null,
|
|
60
69
|
ui: null,
|
|
70
|
+
artifacts: null,
|
|
71
|
+
artifactOrigin: null,
|
|
61
72
|
discovery: {
|
|
62
73
|
concurrency: null,
|
|
63
74
|
catalogTtlSeconds: null,
|
|
@@ -79,9 +90,13 @@ const CONFIG_SCHEMA = {
|
|
|
79
90
|
maxHostCalls: null,
|
|
80
91
|
hostCallTimeoutMs: null,
|
|
81
92
|
watchdogMs: null,
|
|
93
|
+
resumableWrites: null,
|
|
94
|
+
maxWrites: null,
|
|
95
|
+
pausedRunTtlSeconds: null,
|
|
96
|
+
approval: null,
|
|
82
97
|
},
|
|
83
98
|
admission: {
|
|
84
|
-
requests:
|
|
99
|
+
requests: requestAdmissionSchema,
|
|
85
100
|
code: admissionPoolSchema,
|
|
86
101
|
},
|
|
87
102
|
logger: null,
|
|
@@ -157,6 +172,47 @@ function assertKnownConfig(config) {
|
|
|
157
172
|
typeof activity.recordTool !== "function")) {
|
|
158
173
|
throw new Error("ConnectaConfig.activity must be created with activityHistory(...)");
|
|
159
174
|
}
|
|
175
|
+
const artifacts = config.artifacts;
|
|
176
|
+
if (artifacts !== undefined) {
|
|
177
|
+
const connector = artifacts?.connector;
|
|
178
|
+
if (typeof artifacts !== "object" ||
|
|
179
|
+
artifacts === null ||
|
|
180
|
+
!connector ||
|
|
181
|
+
connector.id !== "artifacts" ||
|
|
182
|
+
typeof connector.callTool !== "function" ||
|
|
183
|
+
typeof artifacts.handle !== "function") {
|
|
184
|
+
throw new Error("ConnectaConfig.artifacts must be created with artifacts(...) from @zackbart/connecta/artifacts");
|
|
185
|
+
}
|
|
186
|
+
if (config.connectors?.some((candidate) => candidate?.id === "artifacts")) {
|
|
187
|
+
throw new Error('Connector id "artifacts" is reserved by the artifacts module; rename ' +
|
|
188
|
+
"your connector or drop the artifacts option");
|
|
189
|
+
}
|
|
190
|
+
if (!config.publicUrl) {
|
|
191
|
+
throw new Error("ConnectaConfig.artifacts needs publicUrl: artifact links are shared " +
|
|
192
|
+
"with teammates, so they must name the deployment's own origin, never " +
|
|
193
|
+
"one taken from a request's Host header");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (config.artifactOrigin !== undefined) {
|
|
197
|
+
if (!config.artifacts || !config.ui || !config.publicUrl) {
|
|
198
|
+
throw new Error("ConnectaConfig.artifactOrigin needs artifacts, ui, and publicUrl");
|
|
199
|
+
}
|
|
200
|
+
if (typeof config.artifactOrigin !== "string") {
|
|
201
|
+
throw new Error("ConnectaConfig.artifactOrigin must be an HTTPS origin");
|
|
202
|
+
}
|
|
203
|
+
let origin;
|
|
204
|
+
try {
|
|
205
|
+
origin = new URL(config.artifactOrigin);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
throw new Error("ConnectaConfig.artifactOrigin must be an HTTPS origin");
|
|
209
|
+
}
|
|
210
|
+
if (origin.protocol !== "https:" ||
|
|
211
|
+
origin.toString() !== `${origin.origin}/` ||
|
|
212
|
+
origin.origin === new URL(config.publicUrl).origin) {
|
|
213
|
+
throw new Error("ConnectaConfig.artifactOrigin must be a distinct HTTPS origin");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
160
216
|
}
|
|
161
217
|
/**
|
|
162
218
|
* Validate declared pools against the connector set. Everything checkable at
|
|
@@ -219,6 +275,92 @@ function resolvePools(pools, registry) {
|
|
|
219
275
|
}
|
|
220
276
|
return resolved;
|
|
221
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* Validate `execute.approval` against the connector set, like the pools: a
|
|
280
|
+
* malformed key or value, an unknown connector, and an `api()` address its
|
|
281
|
+
* tools do not include all throw. Remote catalogs load lazily, so an address
|
|
282
|
+
* on one is kept and simply never matches a tool it does not serve.
|
|
283
|
+
*/
|
|
284
|
+
function resolveApprovalPolicy(approval, registry) {
|
|
285
|
+
for (const connector of registry.listConnectors()) {
|
|
286
|
+
if (connector.approval !== undefined && connector.approval !== "never") {
|
|
287
|
+
throw new Error(`Connector "${connector.id}": approval must be "never" or omitted`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (approval === undefined)
|
|
291
|
+
return NO_EXEMPTIONS;
|
|
292
|
+
if (!approval || typeof approval !== "object" || Array.isArray(approval)) {
|
|
293
|
+
throw new Error("ConnectaConfig.execute.approval must be an object keyed by connector ids and connector.tool addresses");
|
|
294
|
+
}
|
|
295
|
+
const connectors = new Map();
|
|
296
|
+
const tools = new Map();
|
|
297
|
+
for (const [key, value] of Object.entries(approval)) {
|
|
298
|
+
const where = `ConnectaConfig.execute.approval[${JSON.stringify(key)}]`;
|
|
299
|
+
if (value !== "never" && value !== "ask") {
|
|
300
|
+
throw new Error(`${where} must be "never" or "ask"`);
|
|
301
|
+
}
|
|
302
|
+
const dot = key.indexOf(".");
|
|
303
|
+
const id = dot === -1 ? key : key.slice(0, dot);
|
|
304
|
+
const tool = dot === -1 ? undefined : key.slice(dot + 1);
|
|
305
|
+
if (!id || tool === "") {
|
|
306
|
+
throw new Error(`${where}: keys are connector ids or connector.tool addresses`);
|
|
307
|
+
}
|
|
308
|
+
const connector = registry.getConnector(id);
|
|
309
|
+
if (!connector)
|
|
310
|
+
throw new Error(`${where}: unknown connector "${id}"`);
|
|
311
|
+
if (tool === undefined) {
|
|
312
|
+
connectors.set(id, value);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (connector.staticTools &&
|
|
316
|
+
!connector.staticTools.some((candidate) => candidate.name === tool)) {
|
|
317
|
+
throw new Error(`${where}: connector "${id}" has no tool "${tool}"`);
|
|
318
|
+
}
|
|
319
|
+
tools.set(key, value);
|
|
320
|
+
}
|
|
321
|
+
return { connectors, tools };
|
|
322
|
+
}
|
|
323
|
+
/** A positive whole number, or the default when the value is unusable. */
|
|
324
|
+
function positiveWhole(value, fallback) {
|
|
325
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 1
|
|
326
|
+
? Math.trunc(value)
|
|
327
|
+
: fallback;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Resumable writes' deployment settings, or undefined when they are off.
|
|
331
|
+
* Omitted, they are on exactly when the storage can claim atomically, and a
|
|
332
|
+
* deployment left without them hears so once. Asked for over storage that
|
|
333
|
+
* cannot claim, they throw: at-most-once delivery of an approved write rests
|
|
334
|
+
* on exactly one resume winning the claim, and a read-then-write stand-in
|
|
335
|
+
* would let two win.
|
|
336
|
+
*/
|
|
337
|
+
function resolveResumableWrites(execute, storage, logger) {
|
|
338
|
+
const enabled = execute?.resumableWrites;
|
|
339
|
+
if (enabled !== undefined && typeof enabled !== "boolean") {
|
|
340
|
+
throw new Error("ConnectaConfig.execute.resumableWrites must be a boolean");
|
|
341
|
+
}
|
|
342
|
+
if (enabled === false)
|
|
343
|
+
return undefined;
|
|
344
|
+
if (enabled === undefined && typeof storage.compareAndSet !== "function") {
|
|
345
|
+
logger.warn("[connecta] resumable writes are off: the configured storage has no " +
|
|
346
|
+
"compareAndSet, so programs refuse writes (E4) and resume_execution " +
|
|
347
|
+
"answers resumable_writes_unavailable. Use storage with an atomic " +
|
|
348
|
+
"compareAndSet (the Worker example's D1 store, fileStorage, " +
|
|
349
|
+
"memoryStorage) to let programs pause for approval, or set " +
|
|
350
|
+
"execute.resumableWrites: false to keep this and silence the warning.");
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
if (typeof storage.compareAndSet !== "function") {
|
|
354
|
+
throw new Error("ConnectaConfig.execute.resumableWrites needs storage with compareAndSet: " +
|
|
355
|
+
"two resumes of one paused run must send its writes at most once, which " +
|
|
356
|
+
"takes an atomic claim. memoryStorage() and fileStorage() provide it, as " +
|
|
357
|
+
"does the Worker example's D1 store; Cloudflare KV cannot.");
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
maxWrites: positiveWhole(execute?.maxWrites, DEFAULT_MAX_WRITES),
|
|
361
|
+
ttlSeconds: positiveWhole(execute?.pausedRunTtlSeconds, DEFAULT_PAUSED_RUN_TTL_SECONDS),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
222
364
|
/**
|
|
223
365
|
* One-time construction warnings for deployment shapes that run fine but are
|
|
224
366
|
* usually unintended. Warning-only — never throws and never changes behavior;
|
|
@@ -231,7 +373,7 @@ function warnInsecureConfig(config, inboundAuth, logger) {
|
|
|
231
373
|
// Static API headers can carry secrets without declaring credential hooks.
|
|
232
374
|
// Any configured connector warrants the open-deployment warning.
|
|
233
375
|
if (inboundAuth.length === 0 &&
|
|
234
|
-
config.connectors.length > 0) {
|
|
376
|
+
(config.connectors.length > 0 || config.artifacts)) {
|
|
235
377
|
logger.warn("[connecta] running with no inbound authentication: any caller can " +
|
|
236
378
|
"invoke every shared connector. " +
|
|
237
379
|
(hasCredentialConnector || oauthConnectors.length > 0
|
|
@@ -239,6 +381,18 @@ function warnInsecureConfig(config, inboundAuth, logger) {
|
|
|
239
381
|
: "") +
|
|
240
382
|
"Configure `auth` (for example bearerToken(...) or Clerk) to gate access.");
|
|
241
383
|
}
|
|
384
|
+
// Artifact pages mount inside the operator UI and open only for an
|
|
385
|
+
// authenticated viewer; either missing leaves the connector with no page.
|
|
386
|
+
if (config.artifacts && !config.ui) {
|
|
387
|
+
logger.warn("[connecta] artifacts is configured without ui: agents can publish and " +
|
|
388
|
+
"read artifacts, but there is no viewer, so their links answer 404. " +
|
|
389
|
+
"Add ui: operatorUi() to serve artifact pages.");
|
|
390
|
+
}
|
|
391
|
+
else if (config.artifacts && inboundAuth.length === 0) {
|
|
392
|
+
logger.warn("[connecta] artifacts is configured with no inbound authentication: " +
|
|
393
|
+
"artifact pages refuse every request, because there is no team to " +
|
|
394
|
+
"show them to. Configure `auth` to open them.");
|
|
395
|
+
}
|
|
242
396
|
// Unset publicUrl with OAuth connectors: the downstream redirect_uri is
|
|
243
397
|
// derived per-request from the attacker-influenced inbound Host header.
|
|
244
398
|
if (oauthConnectors.length > 0 && !config.publicUrl) {
|
|
@@ -347,7 +501,12 @@ export function createConnecta(config) {
|
|
|
347
501
|
name: config.serverInfo?.name ?? "connecta",
|
|
348
502
|
version: config.serverInfo?.version ?? CONNECTA_VERSION,
|
|
349
503
|
};
|
|
350
|
-
|
|
504
|
+
// The artifacts module contributes one prebuilt connector, appended like any
|
|
505
|
+
// configured one: same catalog, invocation, admission, and activity.
|
|
506
|
+
const connectors = config.artifacts
|
|
507
|
+
? [...config.connectors, config.artifacts.connector]
|
|
508
|
+
: config.connectors;
|
|
509
|
+
const registry = new Registry(connectors, {
|
|
351
510
|
storage,
|
|
352
511
|
logger,
|
|
353
512
|
credentialVault,
|
|
@@ -370,8 +529,12 @@ export function createConnecta(config) {
|
|
|
370
529
|
});
|
|
371
530
|
const inboundAuth = configuredAuth;
|
|
372
531
|
const pools = resolvePools(config.pools, registry);
|
|
532
|
+
const resumable = resolveResumableWrites(config.execute, storage, logger);
|
|
533
|
+
const approval = resolveApprovalPolicy(config.execute?.approval, registry);
|
|
534
|
+
// Exempt writes need a budget whether or not programs can pause.
|
|
535
|
+
const maxWrites = positiveWhole(config.execute?.maxWrites, DEFAULT_MAX_WRITES);
|
|
373
536
|
warnInsecureConfig(config, inboundAuth, logger);
|
|
374
|
-
const requestAdmission = admissionController(config.admission?.requests, REQUEST_ADMISSION_DEFAULTS);
|
|
537
|
+
const requestAdmission = admissionController(config.admission?.requests, REQUEST_ADMISSION_DEFAULTS, config.admission?.requests?.maxDurationMs ?? DEFAULT_REQUEST_MAX_DURATION_MS);
|
|
375
538
|
const configuredCodeAdmission = admissionController(config.admission?.code, CODE_ADMISSION_DEFAULTS);
|
|
376
539
|
let codeAdmission;
|
|
377
540
|
let executor = config.executor;
|
|
@@ -387,6 +550,62 @@ export function createConnecta(config) {
|
|
|
387
550
|
"implements acquire() and owns its admission pool; configure that " +
|
|
388
551
|
"executor's concurrency and queue options instead.");
|
|
389
552
|
}
|
|
553
|
+
if (config.artifacts?.bindRefresh) {
|
|
554
|
+
const sharedIds = registry.listConnectors()
|
|
555
|
+
.filter((connector) => connector.id !== "artifacts" && connector.authScope !== "personal")
|
|
556
|
+
.map((connector) => connector.id);
|
|
557
|
+
const refreshConfig = {
|
|
558
|
+
discoveryConcurrency: config.discovery?.concurrency,
|
|
559
|
+
probeTimeoutMs: config.discovery?.probeTimeoutMs,
|
|
560
|
+
maxEmittedBytes: config.execute?.maxEmittedBytes,
|
|
561
|
+
maxEmittedBlocks: config.execute?.maxEmittedBlocks,
|
|
562
|
+
maxHostCalls: config.execute?.maxHostCalls,
|
|
563
|
+
hostCallTimeoutMs: config.execute?.hostCallTimeoutMs,
|
|
564
|
+
watchdogMs: config.execute?.watchdogMs,
|
|
565
|
+
failOnInvocationFailure: true,
|
|
566
|
+
// A connector's own `approval: "never"` still applies when policy maps
|
|
567
|
+
// are empty. Override every shared connector explicitly for refresh.
|
|
568
|
+
approval: {
|
|
569
|
+
connectors: new Map(sharedIds.map((id) => [id, "ask"])),
|
|
570
|
+
tools: new Map(),
|
|
571
|
+
},
|
|
572
|
+
};
|
|
573
|
+
const refreshRunner = createProgramRunner(registry.scoped({ connectorIds: [] }), config.publicUrl, executor, logger, undefined, refreshConfig);
|
|
574
|
+
config.artifacts.bindRefresh({
|
|
575
|
+
claimMs: refreshRunner.claimMs,
|
|
576
|
+
execute: async (program, owner, signal) => {
|
|
577
|
+
if (!owner)
|
|
578
|
+
throw new Error("Refresh owner is missing; reconfigure this program.");
|
|
579
|
+
let access = parseConnectorAccess(config.identity?.connectorAccess
|
|
580
|
+
? await config.identity.connectorAccess(owner.identity) : "all", { allowReadOnly: true });
|
|
581
|
+
if (owner.pool) {
|
|
582
|
+
const pool = pools.get(owner.pool);
|
|
583
|
+
if (!pool || await pool.grant(owner.identity) !== true) {
|
|
584
|
+
throw new Error("Refresh owner's pool grant is no longer available.");
|
|
585
|
+
}
|
|
586
|
+
access = intersectAccess(access, pool.access);
|
|
587
|
+
}
|
|
588
|
+
if (access.connectorIds !== "all" && !access.connectorIds.includes("artifacts")) {
|
|
589
|
+
throw new Error("Refresh owner no longer has artifact publishing access.");
|
|
590
|
+
}
|
|
591
|
+
if (access.toolAccess?.get("artifacts") &&
|
|
592
|
+
!access.toolAccess.get("artifacts").has("set_refresh")) {
|
|
593
|
+
throw new Error("Refresh owner no longer has refresh configuration access.");
|
|
594
|
+
}
|
|
595
|
+
if (access.guardedToolAccess?.get("artifacts")?.has("set_refresh")) {
|
|
596
|
+
throw new Error("A read-only guarded grant cannot configure refresh.");
|
|
597
|
+
}
|
|
598
|
+
access = intersectAccess(access, { connectorIds: sharedIds });
|
|
599
|
+
if (signal.aborted)
|
|
600
|
+
throw new Error("Refresh deadline expired before execution.");
|
|
601
|
+
const view = registry.scoped({ connectorIds: access.connectorIds,
|
|
602
|
+
...(access.toolAccess ? { toolAccess: access.toolAccess } : {}),
|
|
603
|
+
...(access.guardedToolAccess ? { guardedToolAccess: access.guardedToolAccess } : {}) });
|
|
604
|
+
const runner = createProgramRunner(view, config.publicUrl, executor, logger, undefined, refreshConfig);
|
|
605
|
+
return runner.execute({ code: program }, { signal });
|
|
606
|
+
},
|
|
607
|
+
});
|
|
608
|
+
}
|
|
390
609
|
// Every structural check has passed, so this is the configuration the
|
|
391
610
|
// deployment runs with. Creating the runtime builds nothing — a Worker may
|
|
392
611
|
// construct its Connecta at global scope, where no fiber may start.
|
|
@@ -409,11 +628,13 @@ export function createConnecta(config) {
|
|
|
409
628
|
identity: config.identity,
|
|
410
629
|
pools,
|
|
411
630
|
publicUrl: config.publicUrl,
|
|
631
|
+
artifactOrigin: config.artifactOrigin,
|
|
412
632
|
allowedOrigins: config.allowedOrigins,
|
|
413
633
|
serverInfo,
|
|
414
634
|
logger,
|
|
415
635
|
activity: config.activity?.store,
|
|
416
636
|
activityModule: config.activity,
|
|
637
|
+
artifactsModule: config.artifacts,
|
|
417
638
|
activityReadGate: config.activity?.readGate,
|
|
418
639
|
activityDeploymentId: config.activity?.deploymentId,
|
|
419
640
|
executor,
|
|
@@ -427,6 +648,9 @@ export function createConnecta(config) {
|
|
|
427
648
|
maxHostCalls: config.execute?.maxHostCalls,
|
|
428
649
|
hostCallTimeoutMs: config.execute?.hostCallTimeoutMs,
|
|
429
650
|
watchdogMs: config.execute?.watchdogMs,
|
|
651
|
+
resumable,
|
|
652
|
+
approval,
|
|
653
|
+
maxWrites,
|
|
430
654
|
credentialVault,
|
|
431
655
|
ui: config.ui,
|
|
432
656
|
deploymentInfo: config.deploymentInfo,
|