@zackbart/connecta 0.25.0 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/AGENTS.md +6 -5
  2. package/CHANGELOG.md +387 -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/artifacts/connector.d.ts +55 -0
  9. package/dist/artifacts/connector.js +724 -0
  10. package/dist/artifacts/document.js +80 -0
  11. package/dist/artifacts/guide.js +140 -0
  12. package/dist/artifacts/html-scan.js +329 -0
  13. package/dist/artifacts/json.js +68 -0
  14. package/dist/artifacts/kv-store.d.ts +20 -0
  15. package/dist/artifacts/kv-store.js +140 -0
  16. package/dist/artifacts/markdown.js +478 -0
  17. package/dist/artifacts/module.d.ts +37 -0
  18. package/dist/artifacts/module.js +60 -0
  19. package/dist/artifacts/operations.d.ts +195 -0
  20. package/dist/artifacts/operations.js +944 -0
  21. package/dist/artifacts/refresh.d.ts +53 -0
  22. package/dist/artifacts/refresh.js +235 -0
  23. package/dist/artifacts/routes.js +217 -0
  24. package/dist/artifacts/types.d.ts +214 -0
  25. package/dist/artifacts/types.js +22 -0
  26. package/dist/artifacts/validate.d.ts +62 -0
  27. package/dist/artifacts/validate.js +653 -0
  28. package/dist/artifacts.d.ts +6 -0
  29. package/dist/artifacts.js +9 -0
  30. package/dist/auth/downstream-oauth.js +397 -48
  31. package/dist/auth/oauth-start-reset.js +20 -0
  32. package/dist/branding.d.ts +87 -0
  33. package/dist/branding.js +170 -1
  34. package/dist/catalog-service.js +39 -29
  35. package/dist/catalog.js +42 -8
  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/connectors/remote-mcp.js +48 -9
  41. package/dist/errors.d.ts +16 -1
  42. package/dist/errors.js +28 -0
  43. package/dist/execute.js +431 -75
  44. package/dist/executor-admission.d.ts +11 -1
  45. package/dist/executor-admission.js +79 -14
  46. package/dist/executors/quickjs.js +4 -2
  47. package/dist/index.d.ts +62 -6
  48. package/dist/index.js +230 -6
  49. package/dist/invocation.js +56 -10
  50. package/dist/meta-tools.js +7 -2
  51. package/dist/module-contracts.d.ts +21 -1
  52. package/dist/oauth-callback-outcome.js +43 -18
  53. package/dist/operator-ui/generated.js +2 -2
  54. package/dist/operator-ui/model.d.ts +62 -5
  55. package/dist/operator-ui/view.js +372 -28
  56. package/dist/page-styles.js +7 -0
  57. package/dist/program-source.js +116 -0
  58. package/dist/registry.d.ts +42 -6
  59. package/dist/registry.js +164 -60
  60. package/dist/resumable.js +1291 -0
  61. package/dist/routes/activity.js +6 -69
  62. package/dist/routes/actor-labels.js +114 -0
  63. package/dist/routes/credentials.js +27 -5
  64. package/dist/routes/mcp.js +120 -59
  65. package/dist/routes/oauth-management.js +87 -20
  66. package/dist/routes/oauth.js +54 -87
  67. package/dist/routes/operator.js +1 -0
  68. package/dist/routes/shared.d.ts +23 -1
  69. package/dist/routes/shared.js +20 -2
  70. package/dist/routes/ui-data.js +2 -1
  71. package/dist/routes/ui.js +19 -4
  72. package/dist/run-journal.js +347 -0
  73. package/dist/runtime/shared-read.js +106 -0
  74. package/dist/server.js +30 -2
  75. package/dist/skills.js +30 -11
  76. package/dist/timeout.js +26 -0
  77. package/dist/tool-safety.d.ts +35 -0
  78. package/dist/tool-safety.js +31 -0
  79. package/dist/types.d.ts +23 -0
  80. package/dist/ui.d.ts +17 -5
  81. package/dist/ui.js +51 -68
  82. package/dist/version.d.ts +1 -1
  83. package/dist/version.js +1 -1
  84. package/documentation/architecture.md +214 -27
  85. package/documentation/auth.md +186 -2
  86. package/documentation/code-mode.md +236 -63
  87. package/documentation/meta-tools.md +55 -29
  88. package/ethos.md +12 -10
  89. package/examples/worker/AGENTS.md +6 -0
  90. package/examples/worker/README.md +174 -10
  91. package/examples/worker/src/d1-activity-row.ts +14 -2
  92. package/examples/worker/src/d1-activity.ts +4 -2
  93. package/examples/worker/src/index.ts +36 -1
  94. package/examples/worker/src/r2-artifact-blobs.ts +29 -0
  95. package/examples/worker/wrangler.jsonc +5 -1
  96. package/package.json +6 -2
  97. package/templates/node/AGENTS.md +9 -2
  98. package/templates/node/README.md +31 -3
  99. package/templates/node/package.json +1 -1
  100. package/templates/node/src/index.ts +27 -0
@@ -64,17 +64,33 @@ function isCallerCancellation(error, signal) {
64
64
  return (signal?.aborted === true ||
65
65
  (isCallAdmissionError(error) && error.admissionKind === "cancelled"));
66
66
  }
67
+ /**
68
+ * A downstream MCP tool answered with `isError`. Classified exactly as the
69
+ * plain `Error` it used to be — same message, same heuristics — and marked so
70
+ * a write's outcome can tell "the service said no" from "nobody answered".
71
+ */
72
+ class DownstreamToolError extends Error {
73
+ }
67
74
  function assertRawMcpSuccess(kind, result) {
68
75
  if (kind !== "mcp" || result == null || typeof result !== "object")
69
76
  return;
70
77
  const mcpResult = result;
71
78
  if (!mcpResult.isError)
72
79
  return;
73
- throw new Error(boundedEchoText(mcpResult.content
80
+ throw new DownstreamToolError(boundedEchoText(mcpResult.content
74
81
  ?.filter((block) => block.type === "text")
75
82
  .map((block) => block.text ?? "")
76
83
  .join("") || "Downstream tool call failed"));
77
84
  }
85
+ /**
86
+ * Whether a failed attempt is the downstream tool's own answer: an MCP
87
+ * `isError`. A connector's typed error is not, by itself, one — a response
88
+ * too large to read or a refused redirect comes after the request was acted
89
+ * on — so its code decides instead (`classifyWriteOutcome`).
90
+ */
91
+ function answeredFailure(error) {
92
+ return error instanceof DownstreamToolError;
93
+ }
78
94
  export class InvocationFailure extends Error {
79
95
  details;
80
96
  code;
@@ -134,6 +150,10 @@ export class InvocationService {
134
150
  let connectorMs = 0;
135
151
  let resultProcessingMs = 0;
136
152
  let attempts = 0;
153
+ let dispatchedToConnector = false;
154
+ let answered = false;
155
+ // How a write gate's refusal is recorded; see WriteGateDecision.
156
+ let gateActivity;
137
157
  let resolved;
138
158
  let activityTarget;
139
159
  // The address as written, used for activity when resolution never reached
@@ -227,6 +247,25 @@ export class InvocationService {
227
247
  const diagnostics = timing();
228
248
  const target = resolved ?? activityTarget;
229
249
  const details = enrich(error, target);
250
+ const outcome = () => ({
251
+ ok: false,
252
+ durationMs: Date.now() - started,
253
+ attempts,
254
+ timing: diagnostics,
255
+ ...defined({ resolved }),
256
+ dispatched: dispatchedToConnector,
257
+ ...(dispatchedToConnector ? { answered } : {}),
258
+ error: details,
259
+ });
260
+ // A pause is not a failure: it leaves one payload-free `paused` event
261
+ // naming the call that waits, and nothing in the operator's log. A call
262
+ // refused only because its run had already stopped leaves neither.
263
+ if (gateActivity === "none")
264
+ return outcome();
265
+ if (gateActivity === "paused") {
266
+ record("paused");
267
+ return outcome();
268
+ }
230
269
  // Activity rows stay payload-free by construction; the operator's log is
231
270
  // where the downstream reason goes, bounded and without arguments.
232
271
  if (target && details.code !== "destructive_tool_requires_approval") {
@@ -249,14 +288,7 @@ export class InvocationService {
249
288
  : details.code === "cancelled"
250
289
  ? "cancelled"
251
290
  : "error", { errorCode: details.code });
252
- return {
253
- ok: false,
254
- durationMs: Date.now() - started,
255
- attempts,
256
- timing: diagnostics,
257
- ...defined({ resolved }),
258
- error: details,
259
- };
291
+ return outcome();
260
292
  };
261
293
  if (context.requestSignal?.aborted) {
262
294
  // Preserve the cancelled admission-attempt count without starting discovery.
@@ -290,7 +322,11 @@ export class InvocationService {
290
322
  const target = resolution.resolved;
291
323
  resolved = target;
292
324
  activityTarget = target;
293
- if (!isExplicitlyReadOnly(target.definition) && !context.allowDestructive) {
325
+ const consequential = !isExplicitlyReadOnly(target.definition) && !context.allowDestructive;
326
+ const gate = consequential && (context.gates?.(target) ?? true)
327
+ ? context.writeGate
328
+ : undefined;
329
+ if (consequential && !gate) {
294
330
  const canonicalAddress = `${target.connector.id}.${target.toolName}`;
295
331
  return framingError("destructive_tool_requires_approval", `Tool "${canonicalAddress}" is not explicitly read-only. Invoke it through call_destructive_tool so the MCP host can request explicit approval.`);
296
332
  }
@@ -308,6 +344,13 @@ export class InvocationService {
308
344
  if (invalid)
309
345
  return classifyCallError(invalid);
310
346
  }
347
+ if (gate) {
348
+ const decision = yield* gate(target, args ?? {});
349
+ if (decision.kind === "refuse") {
350
+ gateActivity = decision.activity;
351
+ return decision.error;
352
+ }
353
+ }
311
354
  try {
312
355
  context.beforeDispatch?.();
313
356
  }
@@ -329,6 +372,7 @@ export class InvocationService {
329
372
  // Cancellation can arrive during admission or context construction.
330
373
  if (callSignal?.aborted)
331
374
  throw callSignal.reason;
375
+ dispatchedToConnector = true;
332
376
  return target.connector.callTool(target.toolName, args ?? {}, connectorContext);
333
377
  };
334
378
  // The permit belongs to this scope, so success, failure, and the
@@ -351,6 +395,7 @@ export class InvocationService {
351
395
  if (callSignal?.aborted)
352
396
  return yield* Effect.fail(callSignal.reason);
353
397
  const attemptError = Cause.squash(attempt.cause);
398
+ answered = answeredFailure(attemptError);
354
399
  return isCallerCancellation(attemptError, context.requestSignal)
355
400
  ? callerCancelledDetails()
356
401
  : classifyCallError(attemptError);
@@ -415,6 +460,7 @@ export class InvocationService {
415
460
  durationMs: Date.now() - started,
416
461
  attempts,
417
462
  timing: diagnostics,
463
+ dispatched: dispatchedToConnector,
418
464
  };
419
465
  }
420
466
  catch {
@@ -400,6 +400,7 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
400
400
  concurrency: discoveryConcurrency,
401
401
  defer: opts.defer,
402
402
  requestSignal: opts.requestSignal,
403
+ approval: opts.approval,
403
404
  // searchRoute keeps its top-level default. In-program callers use a
404
405
  // separate CatalogService configured for connecta.search.
405
406
  });
@@ -470,6 +471,8 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
470
471
  // Sanitized `unavailable` diagnostics ride the structured shape too;
471
472
  // the plain-text path would drop them (#539).
472
473
  outcome.error.details !== undefined ||
474
+ // So does a conflict's `current`: where things stand is the retry.
475
+ outcome.error.current !== undefined ||
473
476
  [
474
477
  "auth_required",
475
478
  "invalid_args",
@@ -780,7 +783,7 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
780
783
  },
781
784
  };
782
785
  }
783
- const SEARCH_DESC = `Use top-level search for catalog inspection or approval-required work before call_destructive_tool. Unknown-address read-only work belongs in one execute_code program that searches, calls, and returns the answer. Use 2–4 action/object terms and includeSchemas="compact"; the default limit is ${DEFAULT_SEARCH_LIMIT}. Set connector when known. safety="readOnly" finds direct or program calls; "approvalRequired" finds the fail-closed complement. These filters grant no authority. Empty query browses.`;
786
+ const SEARCH_DESC = `Use top-level search for catalog inspection or approval-required work before call_destructive_tool. Unknown-address read-only work belongs in one execute_code program that searches, calls, and returns the answer. Use 2–4 action/object terms and includeSchemas="compact"; the default limit is ${DEFAULT_SEARCH_LIMIT}. Set connector when known. safety="readOnly" finds tools that run unasked; "approvalRequired" finds the fail-closed complement. These filters grant no authority. Empty query browses.`;
784
787
  const CALL_DESC = 'Call one known-address tool explicitly annotated readOnlyHint: true. Use execute_code for unknown-address, multiple, dependent, or reduced read-only work. Unannotated or write-capable tools fail closed to call_destructive_tool. A truncated result carries a get_result action.';
785
788
  const CALL_DESTRUCTIVE_DESC = "Call any tool not explicitly annotated readOnlyHint: true. Include a short reason for the human reviewer after checking the schema and consequences. The reason grants no authority and is not sent downstream.";
786
789
  const GET_RESULT_DESC = "Page a truncated direct-call result by id and byte offset. A program result is never paged; reduce it inside execute_code. Returns a one-line JSON header (offset, bytes, totalBytes, hasMore, nextAction) and then the page as raw text. maxBytes is an upper bound, clamped to the result's inline cap.";
@@ -868,7 +871,8 @@ const GET_RESULT_INPUT = advertisedSchema(z.object({
868
871
  }));
869
872
  /**
870
873
  * Register the six explicit meta-tools onto an McpServer instance.
871
- * `registerExecuteTool` adds the seventh, `execute_code`. Broad discovery and
874
+ * `registerExecuteTool` adds the seventh, `execute_code`, and
875
+ * `registerResumeTool` the eighth, `resume_execution`. Broad discovery and
872
876
  * multi-call work uses discovery and ordinary JavaScript promises inside a
873
877
  * program, which `execute_code` builds over the same
874
878
  * `CatalogService` and `InvocationService` these handlers use — one shared
@@ -884,6 +888,7 @@ export function registerMetaTools(server, registry, ctx) {
884
888
  credentialHandoffUrl: ctx.credentialHandoffUrl,
885
889
  requestSignal: ctx.requestSignal,
886
890
  defer: ctx.defer,
891
+ approval: ctx.approval,
887
892
  });
888
893
  server.registerTool("skills", {
889
894
  description: describedFor(registry, SKILLS_DESC, "skills"),
@@ -1,6 +1,7 @@
1
- import type { ConnectaBranding } from "./types.js";
1
+ import type { ConnectaBranding, Connector } from "./types.js";
2
2
  import type { ActivityStore, ActivityReadGate, recordToolActivity, recordCatalogDriftActivity } from "./activity.js";
3
3
  import type { RouteContext } from "./routes/shared.js";
4
+ import type { ArtifactRefreshRuntime } from "./artifacts/refresh.js";
4
5
  /** Construction-time UI contract. Core never imports the implementation. */
5
6
  export interface OperatorSurface {
6
7
  readonly branding?: ConnectaBranding;
@@ -17,3 +18,22 @@ export interface ActivityModule {
17
18
  readonly recordTool: typeof recordToolActivity;
18
19
  readonly recordDrift: typeof recordCatalogDriftActivity;
19
20
  }
21
+ /**
22
+ * The artifacts module, created by `artifacts()` from /artifacts. Core appends
23
+ * its one prebuilt connector to the configured set — the same catalog,
24
+ * invocation, admission, and activity paths as any other — and imports none
25
+ * of its implementation.
26
+ */
27
+ export interface ArtifactsModule {
28
+ /** The built-in `artifacts` connector. */
29
+ readonly connector: Connector;
30
+ /**
31
+ * Serve the pages' JSON API (`/artifacts/_api/*`) and the sandboxed frame
32
+ * (`/artifacts/_frame`); null for any other path. The operator UI delegates
33
+ * here and serves the page shells itself, so without `ui` there are no
34
+ * artifact routes at all.
35
+ */
36
+ handle(context: RouteContext): Promise<Response | null>;
37
+ /** Bind the optional refresh runner after core has built its registry and executor. */
38
+ bindRefresh?(runtime: ArtifactRefreshRuntime): void;
39
+ }
@@ -1,9 +1,17 @@
1
1
  import { renderFixPrompt } from "./fix-prompt.js";
2
2
  const OUTCOMES = {
3
- connected: { status: 200, message: "Connected. You can close this window." },
3
+ connected: {
4
+ status: 200,
5
+ tone: "ok",
6
+ heading: "{name} is connected",
7
+ message: "You can close this window.",
8
+ namesConnector: true,
9
+ },
4
10
  denied: {
5
11
  status: 400,
6
- message: "Authorization was declined at the provider. Nothing was stored; start authorization again when you are ready.",
12
+ tone: "declined",
13
+ heading: "Authorization was declined",
14
+ message: "Consent was declined at the provider, so nothing was stored. Start authorization again whenever you are ready.",
7
15
  fix: {
8
16
  problem: "Consent for a downstream OAuth connector was declined at the provider, so no grant was stored.",
9
17
  steps: [
@@ -15,7 +23,9 @@ const OUTCOMES = {
15
23
  },
16
24
  provider_error: {
17
25
  status: 400,
18
- message: "The provider returned an error instead of an authorization code. Nothing was stored.",
26
+ tone: "problem",
27
+ heading: "The provider returned an error",
28
+ message: "It sent back an error instead of an authorization code, so nothing was stored.",
19
29
  fix: {
20
30
  problem: "A provider redirected back to the OAuth callback with an OAuth error instead of an authorization code.",
21
31
  steps: [
@@ -27,7 +37,9 @@ const OUTCOMES = {
27
37
  },
28
38
  invalid_callback: {
29
39
  status: 400,
30
- message: "Authorization could not be completed. The link may be expired, already used, or replaced by a newer attempt. Re-run authorization from connecta and try again.",
40
+ tone: "problem",
41
+ heading: "Authorization could not be completed",
42
+ message: "The link may be expired, already used, or replaced by a newer attempt. Start authorization again from {product}.",
31
43
  fix: {
32
44
  problem: "An OAuth callback arrived that the deployment could not match to an authorization flow it started: stale, already used, replaced by a newer attempt, or finished in a different session.",
33
45
  steps: [
@@ -39,7 +51,9 @@ const OUTCOMES = {
39
51
  },
40
52
  handoff_failed: {
41
53
  status: 500,
42
- message: "Authorization could not be completed. Nothing was exchanged; try again shortly.",
54
+ tone: "problem",
55
+ heading: "{name} could not be connected",
56
+ message: "Authorization could not be completed, and nothing was exchanged. Try again shortly.",
43
57
  namesConnector: true,
44
58
  fix: {
45
59
  problem: "The deployment could not consume the stored principal handoff for a personal-auth OAuth flow, so no authorization code was exchanged.",
@@ -51,7 +65,9 @@ const OUTCOMES = {
51
65
  },
52
66
  exchange_failed: {
53
67
  status: 500,
54
- message: "Authorization failed: the provider did not accept the authorization code exchange. Nothing was stored.",
68
+ tone: "problem",
69
+ heading: "{name} could not be connected",
70
+ message: "The provider did not accept the authorization code exchange. Nothing was stored.",
55
71
  namesConnector: true,
56
72
  fix: {
57
73
  problem: "A provider returned an authorization code, but exchanging it for tokens failed.",
@@ -63,7 +79,6 @@ const OUTCOMES = {
63
79
  },
64
80
  },
65
81
  };
66
- const CONNECTOR_ID_RE = /^[a-z0-9_-]+$/;
67
82
  /**
68
83
  * RFC 6749 names `access_denied` for a declined consent (section 4.1.2.1). Every other
69
84
  * value, standard or invented, is the provider refusing the request itself —
@@ -73,22 +88,32 @@ const CONNECTOR_ID_RE = /^[a-z0-9_-]+$/;
73
88
  export function providerErrorReason(error) {
74
89
  return error === "access_denied" ? "denied" : "provider_error";
75
90
  }
76
- /** Page copy and fix prompt for one reason. Nothing here reads the request. */
77
- export function oauthCallbackOutcome(reason, connectorId) {
91
+ /**
92
+ * Page copy and fix prompt for one reason. Nothing here reads the request.
93
+ *
94
+ * `connector` is read only for a reason that sets `namesConnector`. The route
95
+ * passes one only once the state check has proved the flow, and this gate
96
+ * keeps a caller that passed one earlier from naming it anyway. `productName`
97
+ * is deployment configuration, the same for every connector, so it cannot
98
+ * tell one refusal from another.
99
+ */
100
+ export function oauthCallbackOutcome(reason, connector, productName = "Connecta") {
78
101
  const outcome = OUTCOMES[reason];
79
- // Success is reached only after the state check proved the id configured,
80
- // so it is the one page that may name the connector in its copy.
81
- const message = reason === "connected" && connectorId && CONNECTOR_ID_RE.test(connectorId)
82
- ? `Connected "${connectorId}". You can close this window.`
83
- : outcome.message;
102
+ const named = outcome.namesConnector ? connector : undefined;
103
+ const name = named ? displayName(named) : "The connection";
84
104
  return {
85
105
  reason,
86
106
  status: outcome.status,
87
- message,
107
+ tone: outcome.tone,
108
+ heading: outcome.heading.replace("{name}", () => name),
109
+ message: outcome.message.replace("{product}", () => productName),
88
110
  ...(outcome.fix
89
- ? {
90
- fixPrompt: renderFixPrompt(outcome.fix, outcome.namesConnector ? connectorId : undefined),
91
- }
111
+ ? { fixPrompt: renderFixPrompt(outcome.fix, named?.id) }
92
112
  : {}),
93
113
  };
94
114
  }
115
+ /** A connector's title when it has a usable one, otherwise its id. */
116
+ function displayName(connector) {
117
+ const title = typeof connector.title === "string" ? connector.title.trim() : "";
118
+ return title || connector.id;
119
+ }