@zackbart/connecta 0.24.4 → 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.
Files changed (155) hide show
  1. package/AGENTS.md +43 -12
  2. package/CHANGELOG.md +524 -0
  3. package/README.md +32 -15
  4. package/SECURITY.md +6 -5
  5. package/bin/connecta.mjs +5 -1
  6. package/dist/activity.d.ts +10 -3
  7. package/dist/activity.js +10 -1
  8. package/dist/advertised-schema.js +53 -0
  9. package/dist/artifacts/connector.d.ts +55 -0
  10. package/dist/artifacts/connector.js +724 -0
  11. package/dist/artifacts/document.js +76 -0
  12. package/dist/artifacts/guide.js +140 -0
  13. package/dist/artifacts/html-scan.js +329 -0
  14. package/dist/artifacts/json.js +68 -0
  15. package/dist/artifacts/kv-store.d.ts +20 -0
  16. package/dist/artifacts/kv-store.js +140 -0
  17. package/dist/artifacts/markdown.js +449 -0
  18. package/dist/artifacts/module.d.ts +37 -0
  19. package/dist/artifacts/module.js +60 -0
  20. package/dist/artifacts/operations.d.ts +195 -0
  21. package/dist/artifacts/operations.js +944 -0
  22. package/dist/artifacts/refresh.d.ts +53 -0
  23. package/dist/artifacts/refresh.js +235 -0
  24. package/dist/artifacts/routes.js +194 -0
  25. package/dist/artifacts/types.d.ts +214 -0
  26. package/dist/artifacts/types.js +22 -0
  27. package/dist/artifacts/validate.d.ts +62 -0
  28. package/dist/artifacts/validate.js +652 -0
  29. package/dist/artifacts.d.ts +6 -0
  30. package/dist/artifacts.js +9 -0
  31. package/dist/auth/downstream-oauth.js +490 -106
  32. package/dist/call-admission.js +139 -113
  33. package/dist/catalog-service.js +152 -106
  34. package/dist/catalog.js +297 -38
  35. package/dist/concurrency.js +24 -23
  36. package/dist/connector-access.d.ts +18 -4
  37. package/dist/connector-access.js +65 -20
  38. package/dist/connector-caller.d.ts +8 -0
  39. package/dist/connector-caller.js +19 -0
  40. package/dist/connector-scope.js +6 -37
  41. package/dist/connectors/api.d.ts +2 -2
  42. package/dist/connectors/remote-mcp.d.ts +6 -5
  43. package/dist/connectors/remote-mcp.js +330 -240
  44. package/dist/credential-contract.d.ts +9 -0
  45. package/dist/credential-rules.js +2 -2
  46. package/dist/credentials.d.ts +6 -1
  47. package/dist/credentials.js +45 -3
  48. package/dist/errors.d.ts +16 -1
  49. package/dist/errors.js +28 -0
  50. package/dist/execute.js +745 -282
  51. package/dist/executor-admission.d.ts +11 -1
  52. package/dist/executor-admission.js +145 -63
  53. package/dist/executors/quickjs-runtime.js +4 -2
  54. package/dist/executors/quickjs.js +255 -212
  55. package/dist/fix-prompt.js +37 -0
  56. package/dist/index.d.ts +72 -7
  57. package/dist/index.js +281 -26
  58. package/dist/invocation.js +371 -286
  59. package/dist/meta-tools.js +213 -94
  60. package/dist/module-contracts.d.ts +21 -1
  61. package/dist/oauth-callback-outcome.js +94 -0
  62. package/dist/oauth-sealing.js +29 -0
  63. package/dist/operator-ui/fix-prompts.js +87 -0
  64. package/dist/operator-ui/generated.js +2 -2
  65. package/dist/operator-ui/model.d.ts +103 -1
  66. package/dist/operator-ui/setup-commands.js +53 -0
  67. package/dist/operator-ui/view.js +128 -3
  68. package/dist/program-source.js +321 -0
  69. package/dist/providers/notion.js +2 -2
  70. package/dist/providers/revenuecat.js +32 -5
  71. package/dist/providers/stripe.d.ts +3 -2
  72. package/dist/providers/vercel.js +1 -1
  73. package/dist/registry.d.ts +43 -7
  74. package/dist/registry.js +434 -288
  75. package/dist/resumable.js +1291 -0
  76. package/dist/routes/activity.js +74 -154
  77. package/dist/routes/actor-labels.js +114 -0
  78. package/dist/routes/credentials.js +196 -163
  79. package/dist/routes/mcp.js +228 -112
  80. package/dist/routes/oauth-management.js +73 -71
  81. package/dist/routes/oauth.js +44 -16
  82. package/dist/routes/operator.js +83 -0
  83. package/dist/routes/shared.d.ts +25 -1
  84. package/dist/routes/shared.js +20 -2
  85. package/dist/routes/ui-data.js +211 -0
  86. package/dist/routes/ui.js +76 -35
  87. package/dist/run-journal.js +347 -0
  88. package/dist/runtime/admission.js +43 -0
  89. package/dist/runtime/call-admission.js +67 -0
  90. package/dist/runtime/connector-scope.js +72 -0
  91. package/dist/runtime/run.js +174 -0
  92. package/dist/runtime/services.js +130 -0
  93. package/dist/runtime/shared-read.js +106 -0
  94. package/dist/runtime/storage.js +66 -0
  95. package/dist/server.js +137 -89
  96. package/dist/skills.js +28 -9
  97. package/dist/storage/file.js +39 -2
  98. package/dist/storage/memory.js +28 -16
  99. package/dist/timeout.js +39 -43
  100. package/dist/tool-safety.d.ts +27 -1
  101. package/dist/tool-safety.js +31 -0
  102. package/dist/types.d.ts +45 -7
  103. package/dist/ui.d.ts +25 -4
  104. package/dist/ui.js +77 -167
  105. package/dist/url-safety.js +157 -0
  106. package/dist/version.d.ts +1 -1
  107. package/dist/version.js +1 -1
  108. package/documentation/architecture.md +367 -16
  109. package/documentation/auth.md +163 -2
  110. package/documentation/code-mode.md +274 -56
  111. package/documentation/meta-tools.md +200 -44
  112. package/ethos.md +46 -47
  113. package/examples/worker/AGENTS.md +6 -0
  114. package/examples/worker/README.md +224 -14
  115. package/examples/worker/src/d1-activity-row.ts +14 -2
  116. package/examples/worker/src/d1-activity.ts +4 -2
  117. package/examples/worker/src/d1-storage.ts +116 -0
  118. package/examples/worker/src/index.ts +42 -7
  119. package/examples/worker/src/r2-artifact-blobs.ts +29 -0
  120. package/examples/worker/wrangler.jsonc +21 -5
  121. package/package.json +16 -4
  122. package/templates/node/AGENTS.md +9 -2
  123. package/templates/node/README.md +31 -3
  124. package/templates/node/package.json +1 -1
  125. package/templates/node/src/index.ts +27 -0
  126. package/dist/auth/downstream-oauth.d.ts +0 -200
  127. package/dist/catalog-fingerprint.d.ts +0 -19
  128. package/dist/catalog-limits.d.ts +0 -9
  129. package/dist/catalog-service.d.ts +0 -263
  130. package/dist/catalog.d.ts +0 -87
  131. package/dist/concurrency.d.ts +0 -3
  132. package/dist/connectors/guarded-fetch.d.ts +0 -96
  133. package/dist/credential-rules.d.ts +0 -85
  134. package/dist/execute.d.ts +0 -154
  135. package/dist/executor-result.d.ts +0 -10
  136. package/dist/executors/quickjs-child.d.ts +0 -1
  137. package/dist/executors/quickjs-protocol.d.ts +0 -67
  138. package/dist/favicon.d.ts +0 -1
  139. package/dist/identity.d.ts +0 -4
  140. package/dist/invocation.d.ts +0 -74
  141. package/dist/mcp-result.d.ts +0 -8
  142. package/dist/meta-tools.d.ts +0 -129
  143. package/dist/operator-ui/generated.d.ts +0 -2
  144. package/dist/operator-ui/view.d.ts +0 -188
  145. package/dist/result-shapes.d.ts +0 -13
  146. package/dist/retired-toolkits.d.ts +0 -2
  147. package/dist/routes/activity.d.ts +0 -2
  148. package/dist/routes/credentials.d.ts +0 -2
  149. package/dist/routes/mcp.d.ts +0 -10
  150. package/dist/routes/oauth-management.d.ts +0 -2
  151. package/dist/routes/oauth.d.ts +0 -2
  152. package/dist/routes/ui.d.ts +0 -2
  153. package/dist/server.d.ts +0 -9
  154. package/dist/skills.d.ts +0 -44
  155. package/dist/timeout.d.ts +0 -21
package/dist/server.js CHANGED
@@ -1,27 +1,155 @@
1
+ import { Effect } from "effect";
1
2
  import { aggregateCallAdmissionSnapshots } from "./call-admission.js";
2
3
  import { isAdmittingExecutor } from "./executor-admission.js";
3
4
  import { createMcpRoute, MCP_CORS_HEADERS } from "./routes/mcp.js";
4
5
  import { routeOAuthCallback, } from "./routes/oauth.js";
6
+ import { runEdge } from "./runtime/run.js";
5
7
  import { withSecurityHeaders, } from "./routes/shared.js";
8
+ const isArtifactPath = (path) => path === "/artifacts" || path.startsWith("/artifacts/");
6
9
  /**
7
10
  * Build the Web-standard fetch handler.
8
11
  *
9
12
  * Route ordering is the contract: private mutation routes precede wildcard
10
13
  * OPTIONS, and the security wrapper is applied to every response, including 404s.
14
+ *
15
+ * Each request is one fiber tied to `request.signal`: when its caller leaves,
16
+ * the request stops waiting on whatever it was waiting on, and what it holds
17
+ * is released (routes/mcp.ts). It runs on no Connecta runtime, because
18
+ * `/health` and a closed deployment's 503 must keep answering after
19
+ * `close()` has disposed that runtime.
11
20
  */
12
21
  export function createFetchHandler(opts) {
13
22
  const { auth, publicUrl, registry } = opts;
14
23
  const routeMcp = createMcpRoute(opts);
15
- return async function fetch(request, runtimeContext) {
24
+ // The first provider's metadata answer for this request, if any has one.
25
+ const metadata = (request, baseUrl) => Effect.gen(function* () {
26
+ for (const provider of auth) {
27
+ const handleMetadata = provider.handleMetadata?.bind(provider);
28
+ if (handleMetadata) {
29
+ const response = yield* Effect.promise(async () => handleMetadata(request, baseUrl));
30
+ if (response)
31
+ return response;
32
+ }
33
+ }
34
+ return null;
35
+ });
36
+ const health = async () => {
37
+ // The executor is required, so code admission always has a shape to
38
+ // report: either the executor's own pool or the fallback controller
39
+ // wrapped around it at construction.
40
+ const codeAdmission = isAdmittingExecutor(opts.executor)
41
+ ? opts.executor.admissionSnapshot?.()
42
+ : undefined;
43
+ return Response.json({
44
+ status: "ok",
45
+ connectors: registry.listConnectors().length,
46
+ server: opts.serverInfo,
47
+ // Which sandbox, not how it is tuned: `connecta doctor` reports the
48
+ // executor it just exercised rather than assuming one, and a
49
+ // deployment whose executor identifies as nothing omits the key
50
+ // instead of inviting a guess (#368).
51
+ ...(opts.executorName !== undefined
52
+ ? { executor: { name: opts.executorName } }
53
+ : {}),
54
+ // Whether a program's write pauses for resume_execution or fails E4.
55
+ // The tool is listed either way; this is the only place an operator
56
+ // can see which answer it gives (#565).
57
+ resumableWrites: opts.resumable !== undefined,
58
+ // Counts only, from refreshes that already happened — the endpoint
59
+ // asks no downstream anything, and `connecta doctor` reads it to
60
+ // report a stale allowlist without a probe of its own (#343).
61
+ // Stable 64-bit hashes preserve that shape without publishing ids.
62
+ catalogDrift: Object.fromEntries(await Promise.all(Object.entries(registry.catalogDriftSnapshot()).map(async ([id, report]) => {
63
+ const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(id)));
64
+ const key = Array.from(hash.subarray(0, 8), byte => byte.toString(16).padStart(2, "0")).join("");
65
+ return [key, report];
66
+ }))),
67
+ admission: {
68
+ policy: "global-fifo",
69
+ requests: opts.requestAdmission.snapshot(),
70
+ code: codeAdmission ?? { managedByExecutor: true },
71
+ downstreamCalls: {
72
+ policy: "connector-partitioned-per-runtime",
73
+ aggregate: aggregateCallAdmissionSnapshots(Object.values(registry.callAdmissionSnapshot())),
74
+ },
75
+ reservedRoutes: [
76
+ "/health",
77
+ ...(opts.ui?.reservedPaths ?? []),
78
+ ...(opts.ui && opts.activity?.list ? ["/activity"] : []),
79
+ ...(opts.ui && opts.artifactsModule ? ["/artifacts", "/artifacts/*"] : []),
80
+ ],
81
+ },
82
+ ...(opts.deploymentInfo ? { deployment: opts.deploymentInfo } : {}),
83
+ });
84
+ };
85
+ const route = (context) => Effect.gen(function* () {
86
+ const { request, path, baseUrl } = context;
87
+ // Private mutations own OPTIONS so they never inherit wildcard CORS.
88
+ const ui = opts.ui;
89
+ if (ui) {
90
+ const uiResponse = yield* Effect.promise(() => ui.handle(context));
91
+ if (uiResponse)
92
+ return uiResponse;
93
+ }
94
+ if (request.method === "OPTIONS") {
95
+ const preflight = yield* routeMcp.handle(context);
96
+ if (preflight)
97
+ return preflight;
98
+ const response = yield* metadata(request, baseUrl);
99
+ if (response)
100
+ return response;
101
+ return new Response(null, {
102
+ status: 204,
103
+ headers: MCP_CORS_HEADERS,
104
+ });
105
+ }
106
+ if (path.startsWith("/.well-known/")) {
107
+ const response = yield* metadata(request, baseUrl);
108
+ if (response)
109
+ return response;
110
+ return new Response("Not Found", { status: 404 });
111
+ }
112
+ if (path === "/health")
113
+ return yield* Effect.promise(health);
114
+ const oauthCallback = yield* routeOAuthCallback(context);
115
+ if (oauthCallback)
116
+ return oauthCallback;
117
+ const mcp = yield* routeMcp.handle(context);
118
+ if (mcp)
119
+ return mcp;
120
+ return new Response("Not Found", { status: 404 });
121
+ });
122
+ const serve = (request, runtimeContext) => Effect.suspend(() => {
16
123
  const url = new URL(request.url);
17
124
  const baseUrl = publicUrl ?? url.origin;
18
125
  const path = url.pathname;
19
126
  const defer = runtimeContext
20
127
  ? runtimeContext.waitUntil.bind(runtimeContext)
21
128
  : undefined;
129
+ // The artifact hostname has no MCP, health, OAuth, or operator routes.
130
+ // Dispatch before MCP origin checks so even a hostile /mcp is a plain 404.
131
+ if (opts.artifactOrigin && url.origin === new URL(opts.artifactOrigin).origin) {
132
+ if (!isArtifactPath(path) || !opts.ui || !opts.artifactsModule) {
133
+ return Effect.succeed(withSecurityHeaders(new Response("Not Found", { status: 404 }), url, path));
134
+ }
135
+ const context = {
136
+ request, url, path, baseUrl: opts.artifactOrigin, opts, defer, runtimeContext,
137
+ };
138
+ return Effect.map(Effect.promise(() => opts.ui.handle(context)), response => withSecurityHeaders(response ?? new Response("Not Found", { status: 404 }), url, path));
139
+ }
140
+ if (opts.artifactOrigin && isArtifactPath(path) && opts.ui && opts.artifactsModule) {
141
+ const target = new URL(opts.artifactOrigin);
142
+ target.pathname = path;
143
+ target.search = url.search;
144
+ return Effect.succeed(withSecurityHeaders(new Response(null, {
145
+ status: 308,
146
+ headers: { Location: target.toString() },
147
+ }), url, path));
148
+ }
22
149
  const originRefusal = routeMcp.rejectOrigin(request);
23
- if (originRefusal)
24
- return withSecurityHeaders(originRefusal, url, path);
150
+ if (originRefusal) {
151
+ return Effect.succeed(withSecurityHeaders(originRefusal, url, path));
152
+ }
25
153
  // Container and orchestrator probes reach /health over plain HTTP on
26
154
  // loopback, where no proxy has set X-Forwarded-Proto. Redirecting them to
27
155
  // the public origin would make an internal liveness check depend on
@@ -43,10 +171,10 @@ export function createFetchHandler(opts) {
43
171
  target.pathname = path === "/ui" ? "/" : url.pathname;
44
172
  target.search = url.search;
45
173
  target.hash = "";
46
- return withSecurityHeaders(new Response(null, {
174
+ return Effect.succeed(withSecurityHeaders(new Response(null, {
47
175
  status: 308,
48
176
  headers: { Location: target.toString() },
49
- }), url, path);
177
+ }), url, path));
50
178
  }
51
179
  const context = {
52
180
  request,
@@ -57,89 +185,9 @@ export function createFetchHandler(opts) {
57
185
  defer,
58
186
  runtimeContext,
59
187
  };
60
- const route = async () => {
61
- // Private mutations own OPTIONS so they never inherit wildcard CORS.
62
- const uiResponse = await opts.ui?.handle(context);
63
- if (uiResponse)
64
- return uiResponse;
65
- if (request.method === "OPTIONS") {
66
- const preflight = await routeMcp.handle(context);
67
- if (preflight)
68
- return preflight;
69
- for (const provider of auth) {
70
- if (provider.handleMetadata) {
71
- const response = await provider.handleMetadata(request, baseUrl);
72
- if (response)
73
- return response;
74
- }
75
- }
76
- return new Response(null, {
77
- status: 204,
78
- headers: MCP_CORS_HEADERS,
79
- });
80
- }
81
- if (path.startsWith("/.well-known/")) {
82
- for (const provider of auth) {
83
- if (provider.handleMetadata) {
84
- const response = await provider.handleMetadata(request, baseUrl);
85
- if (response)
86
- return response;
87
- }
88
- }
89
- return new Response("Not Found", { status: 404 });
90
- }
91
- if (path === "/health") {
92
- // The executor is required, so code admission always has a shape to
93
- // report: either the executor's own pool or the fallback controller
94
- // wrapped around it at construction.
95
- const codeAdmission = isAdmittingExecutor(opts.executor)
96
- ? opts.executor.admissionSnapshot?.()
97
- : undefined;
98
- return Response.json({
99
- status: "ok",
100
- connectors: registry.listConnectors().length,
101
- server: opts.serverInfo,
102
- // Which sandbox, not how it is tuned: `connecta doctor` reports the
103
- // executor it just exercised rather than assuming one, and a
104
- // deployment whose executor identifies as nothing omits the key
105
- // instead of inviting a guess (#368).
106
- ...(opts.executorName !== undefined
107
- ? { executor: { name: opts.executorName } }
108
- : {}),
109
- // Counts only, from refreshes that already happened — the endpoint
110
- // asks no downstream anything, and `connecta doctor` reads it to
111
- // report a stale allowlist without a probe of its own (#343).
112
- // Stable 64-bit hashes preserve that shape without publishing ids.
113
- catalogDrift: Object.fromEntries(await Promise.all(Object.entries(registry.catalogDriftSnapshot()).map(async ([id, report]) => {
114
- const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(id)));
115
- const key = Array.from(hash.subarray(0, 8), byte => byte.toString(16).padStart(2, "0")).join("");
116
- return [key, report];
117
- }))),
118
- admission: {
119
- policy: "global-fifo",
120
- requests: opts.requestAdmission.snapshot(),
121
- code: codeAdmission ?? { managedByExecutor: true },
122
- downstreamCalls: {
123
- policy: "connector-partitioned-per-runtime",
124
- aggregate: aggregateCallAdmissionSnapshots(Object.values(registry.callAdmissionSnapshot())),
125
- },
126
- reservedRoutes: [
127
- "/health",
128
- ...(opts.ui?.reservedPaths ?? []),
129
- ...(opts.ui && opts.activity?.list ? ["/activity"] : []),
130
- ],
131
- },
132
- ...(opts.deploymentInfo ? { deployment: opts.deploymentInfo } : {}),
133
- });
134
- }
135
- const oauthCallback = await routeOAuthCallback(context);
136
- if (oauthCallback)
137
- return oauthCallback;
138
- const mcp = await routeMcp.handle(context);
139
- if (mcp)
140
- return mcp;
141
- return new Response("Not Found", { status: 404 });
142
- };
143
- return withSecurityHeaders(await route(), url, path);
188
+ return Effect.map(route(context), (response) => withSecurityHeaders(response, url, path));
189
+ });
190
+ return function fetch(request, runtimeContext) {
191
+ return runEdge(serve(request, runtimeContext), { signal: request.signal });
144
192
  };
145
193
  }
package/dist/skills.js CHANGED
@@ -1,10 +1,24 @@
1
1
  import { boundedEchoText } from "./errors.js";
2
- export const CONNECTA_INSTRUCTIONS = 'Choose a route before discovery. A known-address read needs only call_tool. Unknown-address read-only work starts with execute_code to discover, call, and return the answer; use the same route for reduction, multiple or dependent calls, loops, joins, or branches. Keep discovery and calls together when schemas suffice; do not return catalog matches alone. Inspect unfamiliar result shapes with a small sample before proceeding. Only readOnlyHint: true tools run there. Keep catalog inspection and unannotated, write-capable, or destructive work top level: search_tools then call_destructive_tool when a call is needed. After auth_required use authorize_connector. After a truncated direct result use get_result. Guidance is on demand: fetch skills({ name: "usage" }) only when these instructions and the tool description are insufficient or a run needs repair.';
2
+ const ROUTE = "Choose a route before discovery. A known-address read needs only call_tool. Unknown-address read-only work starts with execute_code to discover, call, and return the answer; use the same route for reduction, multiple or dependent calls, loops, joins, or branches. Keep discovery and calls together when schemas suffice; do not return catalog matches alone. Inspect unfamiliar result shapes with a small sample before proceeding.";
3
+ const RECOVERY = 'After auth_required use authorize_connector. After a truncated direct result use get_result. Guidance is on demand: fetch skills({ name: "usage" }) only when these instructions and the tool description are insufficient or a run needs repair.';
4
+ /**
5
+ * The MCP instructions of a deployment whose programs pause at writes. The
6
+ * route sentence is its own, not ROUTE's: with writes in programs, "one known
7
+ * write" is the only work left for call_destructive_tool, and an agent told
8
+ * only that programs *may* write reads each write after a lookup as a known
9
+ * one and sends them one prompt at a time.
10
+ */
11
+ export const CONNECTA_INSTRUCTIONS = `Choose a route before discovery. A known-address read needs only call_tool; one known write, call_destructive_tool. Everything else starts with execute_code to discover, call, and return the answer: unknown addresses, reduction, multiple or dependent calls, loops, joins, branches, and writes that follow reads. Keep discovery and calls together when schemas suffice; do not return catalog matches alone. Inspect unfamiliar result shapes with a small sample before proceeding. A program's call to a tool not annotated readOnlyHint: true pauses unsent; resume_execution with the returned token, address, and args approves it and continues the program (approval "tool" covers that tool for the run). Keep catalog inspection top level with search_tools. ${RECOVERY}`;
12
+ /**
13
+ * The instructions of a deployment without resumable writes, whose programs
14
+ * refuse every write: the routing it always had.
15
+ */
16
+ export const READ_ONLY_PROGRAM_INSTRUCTIONS = `${ROUTE} Only readOnlyHint: true tools run there. Keep catalog inspection and unannotated, write-capable, or destructive work top level: search_tools then call_destructive_tool when a call is needed. ${RECOVERY}`;
3
17
  const USAGE_SKILL_BASE = `# Connecta usage
4
18
 
5
19
  ## The surface
6
20
 
7
- Seven tools: \`execute_code\`, \`search_tools\`, \`call_tool\`, \`call_destructive_tool\`, \`authorize_connector\`, \`get_result\`, \`skills\`. Read-only discovery and multi-call work live in a program. Top-level search remains for catalog inspection and approval-required work.
21
+ Eight tools: \`execute_code\`, \`resume_execution\`, \`search_tools\`, \`call_tool\`, \`call_destructive_tool\`, \`authorize_connector\`, \`get_result\`, \`skills\`. Discovery and multi-call work live in a program. Top-level search remains for catalog inspection and approval-required work.
8
22
 
9
23
  Follow the MCP instructions for routing. Read at most once for syntax or repair.
10
24
 
@@ -25,7 +39,7 @@ Search and call in one run when schemas suffice. For unfamiliar result formats,
25
39
 
26
40
  For top-level catalog inspection or approval-required discovery, omit \`limit\` initially (the default is 8), then page with a limit up to 50 if needed. Empty or whitespace-only queries browse all tools. A non-empty query with no ASCII terms returns no matches; mixed input searches with its ASCII terms. \`includeSchemas: "compact"\` adds bounded input and available output shapes. An \`outputSchemaSource: "observed"\` shape is a hint, not a contract. Plain objects expose \`inputKeys\`, \`requiredInputKeys\`, and \`outputKeys\`; truncation flags mark incomplete shapes; matches also carry declared annotations.
27
41
 
28
- - \`connecta.search({})\` loads all catalogs. Pass \`connector: "<id>"\` when the integration is obvious. Use \`safety: "readOnly"\` for program calls. These inputs filter discovery; they grant no authority.
42
+ - \`connecta.search({})\` loads all catalogs. Pass \`connector: "<id>"\` when the integration is obvious. Use \`safety: "readOnly"\` for reads. These inputs filter discovery; they grant no authority.
29
43
  - Use \`includeSchemas: "json"\` for programmatic schema inspection; compact schemas are text, not objects with \`.properties\`. Check connectorTitle for the account/environment, then address, purpose, annotations, inputs, and outputs. Never select only because a result ranks first or has fewer required inputs.
30
44
  - Supply every \`requiredInputKey\` from the task or a prior result. For dependencies, match the earlier \`outputKey\` to the later required key. An empty required-key list does not permit invented arguments. Missing \`outputKeys\` means inspect \`outputSchema\`.
31
45
  - Use \`connecta.describe({ address })\` or \`{ addresses }\` when a compact schema is truncated or insufficient. Use \`format: "json"\` only for exact constraints. Write the property names the schema displays; never guess positions or aliases.
@@ -34,27 +48,32 @@ For top-level catalog inspection or approval-required discovery, omit \`limit\`
34
48
  - Preserve the schema's JSON types exactly: a numeric id is a number, not a numeric-looking string.
35
49
  - Validate tabular headers, row arrays, and row widths before mapping them. Never let a header or partial row become data.
36
50
 
37
- Only tools explicitly annotated \`readOnlyHint: true\` are reachable. The catalog, credential, admission, and read-only gates run below the sandbox; code cannot widen its authority.
51
+ Only tools explicitly annotated \`readOnlyHint: true\` are reachable unasked. Where the MCP instructions say programs pause, any other call pauses unsent and \`execute_code\` returns \`paused\` with the write and a token; repeat all three unchanged in top-level \`resume_execution\` to approve (\`approval: "tool"\` covers that tool for the run). Replay reruns the program against a journal: make the same calls in the same order. The catalog, credential, admission, and approval gates run below the sandbox; code cannot widen its authority.
38
52
 
39
53
  ## Errors and repair
40
54
 
41
55
  Caught Connecta errors expose \`message\`, \`code\`, \`retryable\`, and \`details\`. Promise rejections retain these fields. Branch on fields, never prose. After a shared argument failure, repair one call before repeating it across other records. Do not retry \`retryable: false\`, and do not retry \`rate_limited\` immediately because portable code has no timer.
42
56
 
43
- - \`destructive_tool_requires_approval\`: stop the program and use the returned canonical address with top-level \`call_destructive_tool\`.
57
+ - \`destructive_tool_requires_approval\`: stop and send the returned address through top-level \`call_destructive_tool\`.
58
+ - \`execution_paused\`: the run already stopped at a write; let it end.
59
+ - \`write_outcome_unknown\`: sent but unanswered, never re-sent; check the target.
60
+ - \`execution_expired\`, \`_diverged\`, \`_interrupted\`: check \`writes\`, then rerun.
44
61
  - \`auth_required\`: let the failure reach the model, then use top-level \`authorize_connector\`, give its handoff to the operator, and retry after recovery.
45
62
  - A truncated direct-call result: follow its \`get_result\` action. A truncated program result has no page handle; filter, map, or slice inside a new program.
46
63
  - Unknown addresses and tools carry scoped search recovery. Use it inside the current run. Do not invent an address.
47
64
 
48
65
  For a direct call, \`resultMode: "value"\` unwraps the result. \`timeoutMs\` sets its deadline. Every call makes one attempt; use the returned error classification and retry hint to decide whether to reissue. \`diagnostics: true\` adds timing.
49
66
 
50
- \`get_result({ id, offset?, maxBytes? })\` returns \`{ text, offset, nextOffset?, totalBytes }\` for a direct-call result. Both sizes are byte counts: \`maxBytes\` must be a whole number at least 1 and defaults to the deployment cap; \`offset\` must be a whole number at least 0 and defaults to 0. An offset inside a multi-byte character moves back to its first byte, and the response reports the served offset. Follow \`nextOffset\` to reassemble pages. An unknown or expired id is an error.
67
+ \`get_result({ id, offset?, maxBytes? })\` returns a one-line JSON header \`{ resultId, offset, bytes, totalBytes, hasMore, nextAction? }\`, a newline, and then the page as raw text, for a direct-call result. Both sizes are byte counts: \`maxBytes\` must be a whole number at least 1, defaults to the result's cap, and is clamped to it; \`offset\` must be a whole number at least 0 and defaults to 0. An offset inside a multi-byte character moves back to its first byte, and the header reports the served offset. Follow \`nextAction\` until \`hasMore\` is false to reassemble pages. An unknown or expired id is an error.
51
68
 
52
- Limits: 20 host calls per run and a 15-second deadline per host call.
69
+ The \`execute_code\` description states this deployment's host-call and write budgets and per-call deadline.
53
70
 
54
71
  ## Runtime portability
55
72
 
56
73
  Portable code uses standard JavaScript builtins, \`connecta\`, and \`console.*\`. QuickJS blocks imports and lacks fetch, process, timers, crypto, and WebSocket. Dynamic Workers must use only \`{ loader }\`; bindings, modules, or globalOutbound grant ambient authority. With loader only, environment maps are empty; node:fs/http/https are absent; outbound fetch, WebSocket, node:net, and node:tls are denied; DNS is unresolved. Runtime builtins remain through \`import()\` and \`process.getBuiltinModule()\`, including node:path and cloudflare:workers; this set can drift. Timers, process, crypto, WebSocket, and data: fetch remain. Avoid every runtime-only capability because QuickJS fails.
57
74
 
75
+ \`Date.now()\` is the run's start and does not advance; \`Math.random()\` is seeded per run.
76
+
58
77
  ## Examples
59
78
 
60
79
  One read-only call at a known address:
@@ -95,8 +114,8 @@ Connector guides appear in \`skills({})\` and discovery with an exact \`guide\`
95
114
  /** Shared Connecta routing guidance, byte-identical across deployments. */
96
115
  export const USAGE_SKILL = USAGE_SKILL_BASE + CONNECTOR_GUIDES_SECTION;
97
116
  /** The always-loaded MCP `instructions` string. */
98
- export function instructionsFor() {
99
- return CONNECTA_INSTRUCTIONS;
117
+ export function instructionsFor(resumableWrites) {
118
+ return resumableWrites ? CONNECTA_INSTRUCTIONS : READ_ONLY_PROGRAM_INSTRUCTIONS;
100
119
  }
101
120
  /** True when at least one of `connectors` carries a usage guide. */
102
121
  export function hasConnectorGuides(connectors) {
@@ -259,8 +259,9 @@ export function fileStorage(path, opts = {}) {
259
259
  throw new Error(`[connecta] state file ${path} is closed.`);
260
260
  };
261
261
  const logger = opts.logger ?? console;
262
- // The state file holds downstream OAuth access/refresh tokens in cleartext,
263
- // so keep it owner-only. Repair is best-effort: chmod is a no-op or throws on
262
+ // With a sealing vault, downstream OAuth tokens here are ciphertext; without
263
+ // one they are cleartext, and OAuth flow state is plaintext either way. Keep
264
+ // the file owner-only regardless. Repair is best-effort: chmod is a no-op or throws on
264
265
  // non-POSIX filesystems, and a loose mode must never keep the store from
265
266
  // starting.
266
267
  const tighten = () => {
@@ -377,5 +378,41 @@ export function fileStorage(path, opts = {}) {
377
378
  .filter((key) => Boolean(fresh(key)) && key.startsWith(prefix))
378
379
  .sort();
379
380
  },
381
+ // Atomic twice over: the lock makes this process the only writer of the
382
+ // file, and nothing between the comparison and the persisted rename
383
+ // yields, so no other call on this store can interleave. A write that
384
+ // cannot be persisted is rolled back so memory never claims a value the
385
+ // file does not hold.
386
+ async compareAndSet(key, expected, next, opts) {
387
+ assertOpen();
388
+ lock.assertHeld();
389
+ const previous = fresh(key);
390
+ if ((previous?.value ?? null) !== expected)
391
+ return false;
392
+ if (next === null) {
393
+ if (!previous)
394
+ return true;
395
+ delete data[key];
396
+ }
397
+ else {
398
+ data[key] = {
399
+ value: next,
400
+ ...(opts?.ttlSeconds
401
+ ? { exp: Date.now() + opts.ttlSeconds * 1000 }
402
+ : {}),
403
+ };
404
+ }
405
+ try {
406
+ persist();
407
+ }
408
+ catch (error) {
409
+ if (previous)
410
+ data[key] = previous;
411
+ else
412
+ delete data[key];
413
+ throw error;
414
+ }
415
+ return true;
416
+ },
380
417
  };
381
418
  }
@@ -12,27 +12,28 @@ export function memoryStorage() {
12
12
  }
13
13
  return e;
14
14
  };
15
+ const write = (key, value, ttlSeconds) => {
16
+ // Rotate through at most 16 existing keys. Live entries cannot keep an
17
+ // expired tail resident forever, and no request starts a background job.
18
+ for (let i = 0; i < 16; i++) {
19
+ const next = sweep.next();
20
+ if (next.done) {
21
+ sweep = map.keys();
22
+ break;
23
+ }
24
+ fresh(next.value);
25
+ }
26
+ map.set(key, {
27
+ value,
28
+ ...(ttlSeconds ? { exp: Date.now() + ttlSeconds * 1000 } : {}),
29
+ });
30
+ };
15
31
  return {
16
32
  async get(key) {
17
33
  return fresh(key)?.value ?? null;
18
34
  },
19
35
  async set(key, value, opts) {
20
- // Rotate through at most 16 existing keys. Live entries cannot keep an
21
- // expired tail resident forever, and no request starts a background job.
22
- for (let i = 0; i < 16; i++) {
23
- const next = sweep.next();
24
- if (next.done) {
25
- sweep = map.keys();
26
- break;
27
- }
28
- fresh(next.value);
29
- }
30
- map.set(key, {
31
- value,
32
- ...(opts?.ttlSeconds
33
- ? { exp: Date.now() + opts.ttlSeconds * 1000 }
34
- : {}),
35
- });
36
+ write(key, value, opts?.ttlSeconds);
36
37
  },
37
38
  async delete(key) {
38
39
  map.delete(key);
@@ -45,5 +46,16 @@ export function memoryStorage() {
45
46
  })
46
47
  .sort();
47
48
  },
49
+ // Atomic because nothing between the read and the write yields: the body
50
+ // runs to completion before any other call on this map can start.
51
+ async compareAndSet(key, expected, next, opts) {
52
+ if ((fresh(key)?.value ?? null) !== expected)
53
+ return false;
54
+ if (next === null)
55
+ map.delete(key);
56
+ else
57
+ write(key, next, opts?.ttlSeconds);
58
+ return true;
59
+ },
48
60
  };
49
61
  }
package/dist/timeout.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // The deadline vocabulary shared by downstream discovery probes and tool calls.
2
2
  // One definition keeps those waits bounded consistently.
3
+ import { Effect } from "effect";
4
+ import { runEdge, withDeadlineEffect } from "./runtime/run.js";
3
5
  /**
4
6
  * Generous default bound for a single downstream probe/catalog call. High enough
5
7
  * to trip only on a pathological hang, not a realistically slow probe.
@@ -12,54 +14,48 @@ export function normalizeTimeoutMs(value) {
12
14
  }
13
15
  return Math.max(1, Math.trunc(value));
14
16
  }
15
- /** Resolve after `ms`, or false when the caller aborts first. */
16
- export function sleep(ms, signal) {
17
- return new Promise((resolve) => {
18
- let settled = false;
19
- const finish = (value) => {
20
- if (settled)
21
- return;
22
- settled = true;
23
- clearTimeout(timer);
24
- signal?.removeEventListener("abort", cancel);
25
- resolve(value);
26
- };
27
- const timer = setTimeout(() => finish(true), ms);
28
- const cancel = () => finish(false);
29
- signal?.addEventListener("abort", cancel, { once: true });
30
- if (signal?.aborted)
31
- cancel();
32
- });
33
- }
17
+ /**
18
+ * Run one operation under a deadline and the caller's signal, whichever ends
19
+ * it first.
20
+ *
21
+ * The operation gets a signal of its own: the caller's abort is forwarded to
22
+ * it, and the deadline aborts it with `timeoutError`. Either way the promise
23
+ * rejects with that signal's reason, without waiting for work that ignores
24
+ * the signal. A synchronous throw rejects like an asynchronous one. This is
25
+ * the Promise face of `withDeadlineEffect` (src/runtime/run.ts), which owns
26
+ * the contract; an Effect caller uses that directly.
27
+ */
34
28
  export function withDeadline(operation, options) {
29
+ return runEdge(withDeadlineEffect((signal) => Effect.tryPromise({
30
+ try: () => operation(signal),
31
+ catch: (error) => error,
32
+ }), options));
33
+ }
34
+ /**
35
+ * Run `operation` under one signal that aborts when any of `signals` does —
36
+ * the MCP call's own and the inbound request's, for the program tools — and
37
+ * detach every listener afterwards, however it ends.
38
+ */
39
+ export async function underAnySignal(signals, operation) {
35
40
  const controller = new AbortController();
36
- const forwardAbort = () => controller.abort(options.signal?.reason);
37
- options.signal?.addEventListener("abort", forwardAbort, { once: true });
38
- if (options.signal?.aborted)
39
- forwardAbort();
40
- let rejectAbort;
41
- const aborted = new Promise((_, reject) => { rejectAbort = reject; });
42
- const onAbort = () => rejectAbort(controller.signal.reason ?? options.timeoutError);
43
- controller.signal.addEventListener("abort", onAbort, { once: true });
44
- if (controller.signal.aborted)
45
- onAbort();
46
- const timer = options.timeoutMs === undefined
47
- ? undefined
48
- : setTimeout(() => controller.abort(options.timeoutError), options.timeoutMs);
49
- let work;
41
+ const forwarders = signals
42
+ .filter((signal) => signal !== undefined)
43
+ .map((signal) => {
44
+ const forward = () => controller.abort(signal.reason);
45
+ if (signal.aborted)
46
+ forward();
47
+ else
48
+ signal.addEventListener("abort", forward, { once: true });
49
+ return { signal, forward };
50
+ });
50
51
  try {
51
- work = operation(controller.signal);
52
+ return await operation(controller.signal);
52
53
  }
53
- catch (error) {
54
- work = Promise.reject(error);
54
+ finally {
55
+ for (const { signal, forward } of forwarders) {
56
+ signal.removeEventListener("abort", forward);
57
+ }
55
58
  }
56
- return Promise.race([work, aborted]).finally(() => {
57
- controller.abort();
58
- if (timer)
59
- clearTimeout(timer);
60
- controller.signal.removeEventListener("abort", onAbort);
61
- options.signal?.removeEventListener("abort", forwardAbort);
62
- });
63
59
  }
64
60
  /**
65
61
  * Give one operation a caller-facing deadline and the matching cancellation
@@ -1,4 +1,4 @@
1
- import type { ToolDef } from "./types.js";
1
+ import type { Connector, ToolDef } from "./types.js";
2
2
  /**
3
3
  * The one fail-closed classification shared by discovery and invocation.
4
4
  *
@@ -7,3 +7,29 @@ import type { ToolDef } from "./types.js";
7
7
  * approval-visible call path.
8
8
  */
9
9
  export declare function isExplicitlyReadOnly(definition: ToolDef): boolean;
10
+ /**
11
+ * A deployment's approval exemptions, resolved once at construction from
12
+ * `execute.approval` (#566). Config is the only source: nothing an agent, the
13
+ * operator UI, or a downstream catalog says can add an entry.
14
+ */
15
+ export interface ApprovalPolicy {
16
+ /** Connector id → the default for that connector's tools. */
17
+ readonly connectors: ReadonlyMap<string, "never" | "ask">;
18
+ /** Canonical `connector.tool` address → that tool's own setting. */
19
+ readonly tools: ReadonlyMap<string, "never" | "ask">;
20
+ }
21
+ export declare const NO_EXEMPTIONS: ApprovalPolicy;
22
+ /**
23
+ * Whether a program may call this tool without pausing for approval.
24
+ *
25
+ * Only a tool that is *not* explicitly read-only can be exempt — a read-only
26
+ * tool needs no exemption, and reporting one would blur the two classes an
27
+ * exemption must never merge. The most specific setting wins: the tool's
28
+ * address, then its connector's entry, then the connector's own default,
29
+ * then `"ask"`. Annotations play no part beyond that first test: a downstream
30
+ * that could annotate its way out of approval would be granting itself the
31
+ * capability this exists to keep in config. Exempt never means read-only:
32
+ * discovery keeps it in the approval-required class, and `call_tool` still
33
+ * refuses it.
34
+ */
35
+ export declare function isApprovalExempt(policy: ApprovalPolicy, connector: Pick<Connector, "id" | "approval">, toolName: string, definition: ToolDef): boolean;
@@ -9,3 +9,34 @@ export function isExplicitlyReadOnly(definition) {
9
9
  return (definition.annotations?.readOnlyHint === true &&
10
10
  definition.annotations?.destructiveHint !== true);
11
11
  }
12
+ export const NO_EXEMPTIONS = {
13
+ connectors: new Map(),
14
+ tools: new Map(),
15
+ };
16
+ /**
17
+ * Whether a program may call this tool without pausing for approval.
18
+ *
19
+ * Only a tool that is *not* explicitly read-only can be exempt — a read-only
20
+ * tool needs no exemption, and reporting one would blur the two classes an
21
+ * exemption must never merge. The most specific setting wins: the tool's
22
+ * address, then its connector's entry, then the connector's own default,
23
+ * then `"ask"`. Annotations play no part beyond that first test: a downstream
24
+ * that could annotate its way out of approval would be granting itself the
25
+ * capability this exists to keep in config. Exempt never means read-only:
26
+ * discovery keeps it in the approval-required class, and `call_tool` still
27
+ * refuses it.
28
+ */
29
+ export function isApprovalExempt(policy, connector, toolName, definition) {
30
+ if (isExplicitlyReadOnly(definition))
31
+ return false;
32
+ // This tool starts another program. Letting a program call it while holding
33
+ // the only executor permit would deadlock; manual refresh is a top-level
34
+ // action, even though other artifact writes are exempt inside programs.
35
+ if (connector.id === "artifacts" && toolName === "run_refresh")
36
+ return false;
37
+ const setting = policy.tools.get(`${connector.id}.${toolName}`) ??
38
+ policy.connectors.get(connector.id) ??
39
+ connector.approval ??
40
+ "ask";
41
+ return setting === "never";
42
+ }