@zackbart/connecta 0.40.0 → 0.42.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 (195) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/bin/connecta.mjs +26 -17
  3. package/bin/meta-tool-names.d.mts +3 -1
  4. package/bin/meta-tool-names.mjs +4 -1
  5. package/bin/version.mjs +1 -1
  6. package/dist/activity.d.ts +32 -5
  7. package/dist/activity.js +32 -6
  8. package/dist/advertised-schema.js +15 -0
  9. package/dist/agent-observation.js +24 -2
  10. package/dist/agent-routes.js +33 -44
  11. package/dist/auth/clerk.d.ts +6 -1
  12. package/dist/auth/clerk.js +16 -0
  13. package/dist/auth/cloudflare-access.js +49 -7
  14. package/dist/auth/downstream-oauth/consent.js +79 -29
  15. package/dist/auth/downstream-oauth/grant-store.js +58 -7
  16. package/dist/auth/downstream-oauth/refresh-coordinator.js +4 -1
  17. package/dist/auth/downstream-oauth/refresh-records.js +6 -5
  18. package/dist/auth/downstream-oauth.js +15 -4
  19. package/dist/auth/oauth-request-not-sent.js +4 -0
  20. package/dist/auth/static-oauth.js +77 -39
  21. package/dist/auth-elicitation.js +118 -61
  22. package/dist/authorization-handoff.js +36 -3
  23. package/dist/catalog-cache.js +352 -43
  24. package/dist/catalog-freshness.js +25 -0
  25. package/dist/catalog-service.js +408 -78
  26. package/dist/catalog.js +216 -62
  27. package/dist/config-defaults.d.ts +2 -1
  28. package/dist/config-defaults.js +4 -1
  29. package/dist/config.d.ts +53 -21
  30. package/dist/config.js +56 -17
  31. package/dist/connectors/remote-mcp/call.js +1 -1
  32. package/dist/connectors/remote-mcp/catalog.js +52 -28
  33. package/dist/connectors/remote-mcp/connection.js +113 -22
  34. package/dist/connectors/remote-mcp/oauth.js +2 -2
  35. package/dist/connectors/remote-mcp/resources.js +5 -2
  36. package/dist/connectors/remote-mcp/transport.js +24 -6
  37. package/dist/connectors/remote-mcp.js +14 -5
  38. package/dist/credential-contract.d.ts +3 -1
  39. package/dist/describe-config.d.ts +4 -2
  40. package/dist/describe-config.js +1 -0
  41. package/dist/discovery-health.js +26 -11
  42. package/dist/downstream-input.js +11 -2
  43. package/dist/errors.d.ts +60 -4
  44. package/dist/errors.js +88 -3
  45. package/dist/execute.js +491 -202
  46. package/dist/executor-admission.js +2 -0
  47. package/dist/executor-contract.js +6 -0
  48. package/dist/executor-result.js +3 -3
  49. package/dist/executors/quickjs-child.js +14 -5
  50. package/dist/executors/quickjs-runtime.d.ts +2 -0
  51. package/dist/executors/quickjs-runtime.js +37 -20
  52. package/dist/executors/quickjs.js +24 -7
  53. package/dist/guarded-transport.js +2 -0
  54. package/dist/guest-functions.js +5 -4
  55. package/dist/guest-runtime.js +37 -1
  56. package/dist/guest-types.d.ts +18 -0
  57. package/dist/http/client.js +14 -2
  58. package/dist/http/errors.js +9 -4
  59. package/dist/http/fields.js +17 -5
  60. package/dist/http/hatch.js +11 -3
  61. package/dist/http/node-transport.js +110 -4
  62. package/dist/http/oauth.js +17 -6
  63. package/dist/http/paging.js +1 -1
  64. package/dist/http/provider.js +2 -6
  65. package/dist/http/vendor.js +6 -1
  66. package/dist/index.d.ts +1 -1
  67. package/dist/index.js +15 -21
  68. package/dist/invocation-failure.js +28 -0
  69. package/dist/invocation.js +167 -64
  70. package/dist/membership.d.ts +1 -1
  71. package/dist/meta-output.js +24 -18
  72. package/dist/meta-tool-names.d.ts +1 -1
  73. package/dist/meta-tool-names.js +1 -1
  74. package/dist/meta-tools.js +286 -73
  75. package/dist/module-contracts.d.ts +3 -1
  76. package/dist/native-recovery.js +4 -1
  77. package/dist/native-tools.js +177 -55
  78. package/dist/node.d.ts +7 -0
  79. package/dist/node.js +85 -4
  80. package/dist/oauth-handoff.js +53 -43
  81. package/dist/operation-activity.js +104 -0
  82. package/dist/operation-vocabulary.js +6 -0
  83. package/dist/operator-record.d.ts +9 -1
  84. package/dist/operator-record.js +16 -0
  85. package/dist/operator-ui/generated.js +2 -2
  86. package/dist/program-result.js +2 -0
  87. package/dist/program-source.js +1 -1
  88. package/dist/program-writes.js +112 -19
  89. package/dist/provider.d.ts +1 -14
  90. package/dist/provider.js +0 -3
  91. package/dist/providers/_shared/google/kit.js +2 -2
  92. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  93. package/dist/providers/breeze/api.js +2 -2
  94. package/dist/providers/breeze/index.js +1 -1
  95. package/dist/providers/breeze/skill.generated.js +1 -1
  96. package/dist/providers/ccb/api.js +2 -2
  97. package/dist/providers/ccb/index.js +1 -1
  98. package/dist/providers/ccb/skill.generated.js +1 -1
  99. package/dist/providers/cloudflare/api.js +2 -2
  100. package/dist/providers/cloudflare/index.js +3 -1
  101. package/dist/providers/cloudflare/skill.generated.js +1 -1
  102. package/dist/providers/docs/index.js +1 -1
  103. package/dist/providers/docs/skill.generated.js +1 -1
  104. package/dist/providers/drive/index.js +1 -1
  105. package/dist/providers/drive/skill.generated.js +1 -1
  106. package/dist/providers/forms/index.js +2 -1
  107. package/dist/providers/forms/skill.generated.js +1 -1
  108. package/dist/providers/github/api.js +98 -6
  109. package/dist/providers/github/index.js +8 -3
  110. package/dist/providers/github/lib/auth.d.ts +3 -1
  111. package/dist/providers/github/lib/auth.js +43 -12
  112. package/dist/providers/github/lib/discovery.js +11 -1
  113. package/dist/providers/github/lib/operations.js +8 -0
  114. package/dist/providers/github/safety.js +39 -0
  115. package/dist/providers/github/skill.generated.js +1 -1
  116. package/dist/providers/github/tools.js +12 -5
  117. package/dist/providers/gmail/index.js +2 -1
  118. package/dist/providers/gmail/skill.generated.js +1 -1
  119. package/dist/providers/granola/index.js +1 -1
  120. package/dist/providers/granola/skill.generated.js +1 -1
  121. package/dist/providers/overflow/index.js +3 -1
  122. package/dist/providers/overflow/skill.generated.js +1 -1
  123. package/dist/providers/planning-center/index.js +12 -6
  124. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  125. package/dist/providers/planning-center/skill.generated.js +1 -1
  126. package/dist/providers/quickbooks/api.js +28 -25
  127. package/dist/providers/quickbooks/index.js +3 -6
  128. package/dist/providers/quickbooks/lib/writes.js +55 -0
  129. package/dist/providers/quickbooks/safety.js +17 -5
  130. package/dist/providers/quickbooks/skill.generated.js +1 -1
  131. package/dist/providers/sheets/index.js +2 -1
  132. package/dist/providers/sheets/skill.generated.js +1 -1
  133. package/dist/providers/slides/index.js +3 -3
  134. package/dist/providers/slides/skill.generated.js +1 -1
  135. package/dist/providers/stripe/index.js +2 -2
  136. package/dist/providers/stripe/skill.generated.js +1 -1
  137. package/dist/providers/tithely/index.js +2 -1
  138. package/dist/providers/tithely/skill.generated.js +1 -1
  139. package/dist/providers/vercel/api.js +3 -2
  140. package/dist/providers/vercel/skill.generated.js +1 -1
  141. package/dist/registry.d.ts +36 -4
  142. package/dist/registry.js +142 -30
  143. package/dist/resource-read.js +21 -2
  144. package/dist/result-preview.js +210 -0
  145. package/dist/result-seal.js +168 -0
  146. package/dist/route-names.js +53 -0
  147. package/dist/routes/activity-context.js +1 -0
  148. package/dist/routes/activity-disclosure.js +26 -4
  149. package/dist/routes/activity.js +2 -1
  150. package/dist/routes/connect.js +21 -13
  151. package/dist/routes/mcp.js +83 -35
  152. package/dist/routes/shared.d.ts +6 -0
  153. package/dist/routes/ui-config.js +1 -1
  154. package/dist/routes/ui-data.js +3 -2
  155. package/dist/runtime/call.js +15 -10
  156. package/dist/runtime/native-storage.js +12 -0
  157. package/dist/runtime/request-reads.js +66 -14
  158. package/dist/runtime/request-scope.js +38 -2
  159. package/dist/runtime/run.js +4 -1
  160. package/dist/sent-secrets.d.ts +20 -0
  161. package/dist/sent-secrets.js +49 -1
  162. package/dist/skill-origin.js +16 -0
  163. package/dist/skills.js +213 -57
  164. package/dist/storage/d1-driver.js +33 -5
  165. package/dist/storage/keys.js +16 -7
  166. package/dist/storage/sql-native.js +60 -3
  167. package/dist/storage/sql.js +6 -0
  168. package/dist/timing-trace.js +84 -0
  169. package/dist/tool-safety.d.ts +33 -0
  170. package/dist/tool-safety.js +51 -15
  171. package/dist/types.d.ts +36 -0
  172. package/dist/url-safety.js +38 -20
  173. package/dist/usage-guide.js +14 -13
  174. package/dist/utility-recovery.js +33 -0
  175. package/dist/validate.js +29 -10
  176. package/dist/version.d.ts +1 -1
  177. package/dist/version.js +1 -1
  178. package/dist/worker.js +60 -15
  179. package/documentation/architecture.md +152 -43
  180. package/documentation/auth.md +160 -53
  181. package/documentation/code-mode.md +396 -119
  182. package/documentation/deploying.md +35 -10
  183. package/documentation/hosted-mcp-migration.md +9 -3
  184. package/documentation/integrating.md +27 -5
  185. package/documentation/meta-tools.md +761 -157
  186. package/documentation/operating.md +91 -4
  187. package/documentation/operator-ui.md +51 -2
  188. package/documentation/providers.md +34 -20
  189. package/examples/worker/README.md +3 -3
  190. package/guides/how-it-works.md +2 -1
  191. package/guides/safety-and-trust.md +1 -3
  192. package/package.json +10 -8
  193. package/templates/node/AGENTS.md +3 -3
  194. package/templates/node/README.md +1 -1
  195. package/templates/node/package.json +1 -1
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Schema-aware views of direct reads past their cap, as pure functions of redacted
3
+ * text. Presentation only: the stash and what a program sees stay byte-identical
4
+ * to the full result, and everything shown here also appears in it, unchanged.
5
+ * Rules: documentation/meta-tools.md#previews-of-reads-over-the-cap.
6
+ */
7
+ const enc = new TextEncoder();
8
+ const dec = new TextDecoder();
9
+ /** Bytes of whole rows a view shows. */
10
+ const ROW_BYTES = 1_500;
11
+ /** One row can pass ROW_BYTES up to this, so a wide list still shows its shape. */
12
+ const FIRST_ROW_BYTES = 3_000;
13
+ /** Everything outside the list stays whole, or the read is not a list. */
14
+ const REST_BYTES = 1_024;
15
+ const TEXT_HEAD_BYTES = 1_200;
16
+ const TEXT_TAIL_BYTES = 800;
17
+ const MIN_ROWS = 3;
18
+ const MAX_ROW_DEPTH = 3;
19
+ const MAX_KEYS = 40;
20
+ const MAX_KEY_CHARS = 64;
21
+ /** Rows sampled for key names. */
22
+ const KEY_SAMPLE_ROWS = 50;
23
+ function isRecord(value) {
24
+ return typeof value === "object" && value !== null && !Array.isArray(value);
25
+ }
26
+ const byteLength = (text) => enc.encode(text).length;
27
+ /** The largest list of records within MAX_ROW_DEPTH keys of the root. */
28
+ function dominantRows(root) {
29
+ let best;
30
+ const visit = (node, path) => {
31
+ if (Array.isArray(node)) {
32
+ if (node.length >= MIN_ROWS && node.every((row) => isRecord(row) || Array.isArray(row))) {
33
+ const bytes = byteLength(JSON.stringify(node));
34
+ if (!best || bytes > best.bytes)
35
+ best = { path, rows: node, bytes };
36
+ }
37
+ return;
38
+ }
39
+ if (!isRecord(node) || path.length >= MAX_ROW_DEPTH)
40
+ return;
41
+ for (const key of Object.keys(node))
42
+ visit(node[key], [...path, key]);
43
+ };
44
+ visit(root, []);
45
+ return best;
46
+ }
47
+ function withRows(root, path, rows) {
48
+ if (!path.length)
49
+ return rows;
50
+ const [head, ...tail] = path;
51
+ const record = root;
52
+ // Computed keys define own properties, so a key named __proto__ stays data.
53
+ return { ...record, [head]: withRows(record[head], tail, rows) };
54
+ }
55
+ function rowKeys(rows) {
56
+ const keys = new Set();
57
+ for (const row of rows.slice(0, KEY_SAMPLE_ROWS)) {
58
+ if (!isRecord(row))
59
+ continue;
60
+ for (const key of Object.keys(row))
61
+ if (key.length <= MAX_KEY_CHARS)
62
+ keys.add(key);
63
+ if (keys.size >= MAX_KEYS)
64
+ break;
65
+ }
66
+ return [...keys].slice(0, MAX_KEYS);
67
+ }
68
+ /** JavaScript that reads `path` from a parsed value; a program runs it, so it must be exact. */
69
+ function accessor(path) {
70
+ return path.map((key) => (/^[A-Za-z_$][\w$]*$/.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`)).join("");
71
+ }
72
+ function rowsPreview(parsed, total, cap) {
73
+ const found = dominantRows(parsed);
74
+ if (!found || found.bytes * 2 < total)
75
+ return undefined;
76
+ // The bytes outside the list; the ways it can be spelled do not change them.
77
+ const rest = byteLength(JSON.stringify(withRows(parsed, found.path, [])));
78
+ if (rest > REST_BYTES)
79
+ return undefined;
80
+ const firstRowBytes = Math.min(FIRST_ROW_BYTES, cap - rest);
81
+ const rowBytes = Math.min(ROW_BYTES, cap - rest);
82
+ const shown = [];
83
+ let used = 0;
84
+ for (const row of found.rows) {
85
+ const size = byteLength(JSON.stringify(row)) + 1;
86
+ if (used + size > (shown.length ? rowBytes : firstRowBytes))
87
+ break;
88
+ shown.push(row);
89
+ used += size;
90
+ }
91
+ const count = found.rows.length;
92
+ // An empty list would read as a list with no rows, and a whole one needs no sample.
93
+ if (!shown.length || shown.length === count)
94
+ return undefined;
95
+ const keys = rowKeys(found.rows);
96
+ const path = found.path.length ? accessor(found.path).replace(/^\./, "") : "(root)";
97
+ const body = JSON.stringify(withRows(parsed, found.path, shown));
98
+ if (byteLength(body) > cap)
99
+ return undefined;
100
+ return {
101
+ fields: { rows: { path, count, shown: shown.length, keys } },
102
+ body,
103
+ shown: `Sample only: ${shown.length === 1 ? "row 1" : `rows 1-${shown.length}`} of ${count} at ${path} follow, in the result's own shape, with every value outside the list; the other ${count - shown.length} are not shown.`,
104
+ noun: "rows",
105
+ reduce: `const rows = JSON.parse(text)${accessor(found.path)}; return { count: rows.length, rows: rows.slice(0, 5) };`,
106
+ };
107
+ }
108
+ function lineCount(text) {
109
+ let lines = 1;
110
+ for (let at = text.indexOf("\n"); at !== -1; at = text.indexOf("\n", at + 1))
111
+ lines++;
112
+ return lines;
113
+ }
114
+ /** Whether a byte continues a UTF-8 character rather than starting one. */
115
+ const continues = (byte) => byte !== undefined && (byte & 0xc0) === 0x80;
116
+ function textPreview(text, reading, cap) {
117
+ const bytes = enc.encode(text);
118
+ if (bytes.length <= TEXT_HEAD_BYTES + TEXT_TAIL_BYTES)
119
+ return undefined;
120
+ const marker = (omitted) => `\n…[${omitted} bytes omitted]…\n`;
121
+ let headEnd = TEXT_HEAD_BYTES;
122
+ while (continues(bytes[headEnd]))
123
+ headEnd--;
124
+ let tailStart = bytes.length - TEXT_TAIL_BYTES;
125
+ while (continues(bytes[tailStart]))
126
+ tailStart++;
127
+ const shownBytes = headEnd + bytes.length - tailStart;
128
+ const body = `${dec.decode(bytes.subarray(0, headEnd))}${marker(tailStart - headEnd)}${dec.decode(bytes.subarray(tailStart))}`;
129
+ if (byteLength(body) > cap)
130
+ return undefined;
131
+ const lines = lineCount(text);
132
+ return {
133
+ fields: { text: { lines, shownBytes } },
134
+ body,
135
+ shown: `Sample only: the first ${headEnd} and last ${bytes.length - tailStart} bytes of ${lines} lines follow; the ${tailStart - headEnd} bytes between them are not shown.`,
136
+ noun: "text",
137
+ reduce: `const lines = ${reading}.split("\\n"); return { lines: lines.length, matches: lines.filter((line) => /error|fail/i.test(line)).map((line) => line.slice(0, 500)).slice(0, 40) };`,
138
+ };
139
+ }
140
+ /** Parts of a JSON number token whose exact value is the same however it is spelled. */
141
+ function exactValue(token) {
142
+ const match = /^(-?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(token);
143
+ if (!match)
144
+ return undefined;
145
+ const fraction = match[3] ?? "";
146
+ const digits = `${match[2]}${fraction}`;
147
+ let start = 0;
148
+ let end = digits.length;
149
+ while (digits[start] === "0")
150
+ start++;
151
+ while (end > start && digits[end - 1] === "0")
152
+ end--;
153
+ if (start === end)
154
+ return `${match[1]}0`;
155
+ return `${match[1]}${digits.slice(start, end)}e${Number(match[4] ?? 0) - fraction.length + digits.length - end}`;
156
+ }
157
+ /** Whether JSON.parse then JSON.stringify keeps this token's value, including its sign and range. */
158
+ function keepsNumber(token) {
159
+ const printed = String(Number(token));
160
+ return printed === token || exactValue(printed) === exactValue(token);
161
+ }
162
+ /**
163
+ * Whether every number in text JSON.parse has already accepted survives parsing.
164
+ * Both a view and the offered program parse it, so one lossy number anywhere,
165
+ * inside or outside the rows, declines the view. Strings are skipped, not read.
166
+ */
167
+ function keepsNumbers(json) {
168
+ let at = 0;
169
+ while (at < json.length) {
170
+ const code = json.charCodeAt(at);
171
+ if (code === 34) {
172
+ at++;
173
+ while (json.charCodeAt(at) !== 34)
174
+ at += json.charCodeAt(at) === 92 ? 2 : 1;
175
+ at++;
176
+ }
177
+ else if (code === 45 || (code >= 48 && code <= 57)) {
178
+ const start = at;
179
+ while (at < json.length && "+-.0123456789Ee".includes(json[at]))
180
+ at++;
181
+ if (!keepsNumber(json.slice(start, at)))
182
+ return false;
183
+ }
184
+ else
185
+ at++;
186
+ }
187
+ return true;
188
+ }
189
+ /**
190
+ * The view of a read past its cap, or undefined when its first bytes should stand:
191
+ * documents, scalars, shallow or deep lists a view would misstate, and numbers
192
+ * parsing would change.
193
+ *
194
+ * `format` is the result's own: an API connector's text arrives here as one
195
+ * JSON string, whose decoded text is what a person would call the result.
196
+ */
197
+ export function previewResult(text, format, cap) {
198
+ let parsed;
199
+ try {
200
+ parsed = JSON.parse(text);
201
+ }
202
+ catch {
203
+ return textPreview(text, "text", cap);
204
+ }
205
+ if (typeof parsed === "string" && format === "text")
206
+ return textPreview(parsed, "JSON.parse(text)", cap);
207
+ if (typeof parsed !== "object" || parsed === null || !keepsNumbers(text))
208
+ return undefined;
209
+ return rowsPreview(parsed, byteLength(text), cap);
210
+ }
@@ -0,0 +1,168 @@
1
+ // A program's retained results are unreadable to any other request by default. Chunk 0,
2
+ // which holds the header a reader needs, stays in this memory-only ledger while the run
3
+ // lasts, so every way a run can end leaves nothing a reader can open unless sealing wrote
4
+ // that chunk. Sealing waits until the run can no longer add to its secret set, and only
5
+ // then writes the headers of entries that pass that set. Stored bytes never change, so an
6
+ // offset issued for an entry stays valid. A reclaimed transfer leaves this ledger first and
7
+ // gets no header. Sealing costs no extra writes, and reads stored bytes back only for
8
+ // entries the set grew past.
9
+ import { Duration, Effect } from "effect";
10
+ import { authStorage } from "./auth/native.js";
11
+ import { PendingWork } from "./runtime/request-scope.js";
12
+ import { withDeadlineEffect } from "./runtime/run.js";
13
+ import { resultKeys } from "./storage/keys.js";
14
+ // A BOM is payload here, as it is on every page of the same bytes.
15
+ const dec = new TextDecoder("utf-8", { ignoreBOM: true, fatal: false });
16
+ export const RESULT_ENVELOPE_V4 = "connecta-result-v4:";
17
+ /** Stored for an entry sealing did not publish, so its id can say why. Its binding and no bytes. */
18
+ const RESULT_REFUSED_V4 = "connecta-result-refused-v4:";
19
+ const SEAL_CONCURRENCY = 8;
20
+ /** Kept back from sealing's time for the markers and cleanup of unpublished entries. */
21
+ const REFUSAL_RESERVE_MS = 1000;
22
+ const envelope = (entry) => `${RESULT_ENVELOPE_V4}${entry.binding}:${entry.total}:${entry.chunkBytes}:${entry.cap}:${entry.first}`;
23
+ /** One run's unsealed results. It lives and dies with the run's request (INV-7). */
24
+ export class HeldResults {
25
+ secrets;
26
+ credentialReads;
27
+ entries = new Map();
28
+ hostCalls = new PendingWork();
29
+ closed = false;
30
+ constructor(secrets,
31
+ /** The request's credential reads, which outlast the host calls that started them. */
32
+ credentialReads) {
33
+ this.secrets = secrets;
34
+ this.credentialReads = credentialReads;
35
+ }
36
+ /** False once sealing began: a late entry would never become readable, and a late host call never starts. */
37
+ accepts() {
38
+ return !this.closed;
39
+ }
40
+ add(entry) {
41
+ if (this.closed)
42
+ return false;
43
+ this.entries.set(entry.id, entry);
44
+ return true;
45
+ }
46
+ /** Forget an entry whose stash is being given back, so sealing never writes its header.
47
+ * False once sealing began, when the entry is no longer this ledger's to give up. */
48
+ discard(id) {
49
+ return !this.closed && this.entries.delete(id);
50
+ }
51
+ /** Chunk 0 as storage will hold it, for paging inside the run that stashed it. */
52
+ stored(id) {
53
+ const entry = this.entries.get(id);
54
+ return entry && envelope(entry);
55
+ }
56
+ /** A guest host call; sealing waits for it to settle. */
57
+ track(work) {
58
+ const done = this.hostCalls.begin();
59
+ void work.then(done, done);
60
+ }
61
+ /** Stop accepting work and hand over what the run retained. */
62
+ close() {
63
+ this.closed = true;
64
+ const entries = [...this.entries.values()];
65
+ this.entries.clear();
66
+ return entries;
67
+ }
68
+ /** Succeeds once nothing the run started can add to the secret set. */
69
+ settled = Effect.suspend(() => this.hostCalls.idle).pipe(Effect.andThen(Effect.suspend(() => this.credentialReads.idle)), Effect.repeat({ until: () => !this.hostCalls.pending && !this.credentialReads.pending }), Effect.asVoid);
70
+ }
71
+ function bytesOf(encoded, total) {
72
+ const bytes = new Uint8Array(total);
73
+ let at = 0;
74
+ for (const part of encoded) {
75
+ const binary = atob(part);
76
+ for (let index = 0; index < binary.length; index++)
77
+ bytes[at++] = binary.charCodeAt(index);
78
+ }
79
+ return bytes;
80
+ }
81
+ /** The checks a result takes on its way into the stash, repeated over the whole stored value.
82
+ * Structured redaction joins text blocks, so a form split across two of them is found. */
83
+ function passesSet(secrets, text, format) {
84
+ if (format === "json") {
85
+ try {
86
+ if (secrets.changes(JSON.parse(text)))
87
+ return false;
88
+ }
89
+ catch {
90
+ // A value JSON renders as text, such as `undefined`, takes the text check alone.
91
+ }
92
+ }
93
+ return secrets.text(text) === text;
94
+ }
95
+ const SEALED = { refused: 0, refusedWrite: false };
96
+ /**
97
+ * Publish what can be published. Never fails. Nothing is published until the run's work
98
+ * has settled, and anything still pending when the budget ends leaves every entry
99
+ * unpublished, which is the refusal. Markers only improve what a later page is told, so
100
+ * a failed marker, a timeout or a cancellation changes nothing a reader can get.
101
+ */
102
+ export function sealHeldResults(held, storage,
103
+ /** The absolute time the run's response must be on its way. */
104
+ answerBy, signal) {
105
+ return Effect.suspend(() => {
106
+ const secrets = held.secrets;
107
+ const entries = held.close();
108
+ if (entries.length === 0)
109
+ return Effect.succeed(SEALED);
110
+ const published = new Set();
111
+ const key = (entry, index) => resultKeys.chunk(entry.id, index);
112
+ const passes = (entry) => Effect.gen(function* () {
113
+ const parts = [entry.first];
114
+ for (let index = 1; index < entry.chunks; index++) {
115
+ const chunk = yield* authStorage(storage, "get", [key(entry, index)]);
116
+ // Expired or evicted under a held header: nothing left to check, so nothing to offer.
117
+ if (chunk === null)
118
+ return false;
119
+ parts.push(chunk);
120
+ }
121
+ const text = dec.decode(bytesOf(parts, entry.total));
122
+ return passesSet(secrets, text, entry.format);
123
+ });
124
+ const seal = Effect.gen(function* () {
125
+ yield* held.settled;
126
+ const stable = secrets.revision;
127
+ yield* Effect.forEach(entries, (entry) => Effect.gen(function* () {
128
+ if (entry.passed !== stable && (entry.binary || !(yield* passes(entry))))
129
+ return;
130
+ // Nothing the run started is left, so this holds. If it ever fails, stop.
131
+ if (secrets.revision !== stable)
132
+ return;
133
+ yield* authStorage(storage, "set", [key(entry, 0), envelope(entry), { expiresAtMs: entry.expiresAtMs }]);
134
+ published.add(entry);
135
+ }).pipe(Effect.ignore), { concurrency: SEAL_CONCURRENCY, discard: true });
136
+ });
137
+ // A header write that commits after its timeout can replace a marker, so a marker is no
138
+ // publication fence. That write only ever carries bytes this seal already checked.
139
+ const refuse = (entry) => Effect.gen(function* () {
140
+ yield* authStorage(storage, "set", [
141
+ key(entry, 0),
142
+ `${RESULT_REFUSED_V4}${entry.binding}:`,
143
+ { expiresAtMs: entry.expiresAtMs },
144
+ ]);
145
+ for (let index = 1; index < entry.chunks; index++) {
146
+ yield* authStorage(storage, "delete", [key(entry, index)]);
147
+ }
148
+ }).pipe(Effect.ignore);
149
+ const remainingMs = Math.max(1, answerBy - Date.now());
150
+ const reserveMs = Math.min(REFUSAL_RESERVE_MS, Math.floor(remainingMs / 4));
151
+ return withDeadlineEffect(() => seal, {
152
+ timeoutMs: Math.max(1, remainingMs - reserveMs),
153
+ ...(signal ? { signal } : {}),
154
+ timeoutError: new Error("Sealing ran out of time."),
155
+ }).pipe(Effect.ignore, Effect.flatMap(() => {
156
+ const refused = entries.filter((entry) => !published.has(entry));
157
+ const outcome = refused.length
158
+ ? { refused: refused.length, refusedWrite: refused.some((entry) => entry.write) }
159
+ : SEALED;
160
+ if (refused.length === 0 || signal?.aborted)
161
+ return Effect.succeed(outcome);
162
+ return Effect.forEach(refused, refuse, { concurrency: SEAL_CONCURRENCY, discard: true }).pipe(Effect.timeoutOrElse({
163
+ duration: Duration.millis(Math.max(1, answerBy - Date.now())),
164
+ orElse: () => Effect.void,
165
+ }), Effect.as(outcome));
166
+ }));
167
+ });
168
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `meta`: the six meta-tools. `read-route`: those plus `execute_read_code`, which
3
+ * then takes read follow-ups, so they cost no write approval. `code`: in-program
4
+ * wording, selected only by the eval code arm. `native` and `native-code`: native
5
+ * pools without and with `execute_code`.
6
+ */
7
+ const NAMES = {
8
+ meta: Object.freeze({
9
+ search: "search_tools",
10
+ read: "call_tool",
11
+ write: "call_destructive_tool",
12
+ program: "execute_code",
13
+ authorize: "authorize_connector",
14
+ skills: "skills",
15
+ }),
16
+ "read-route": Object.freeze({
17
+ search: "search_tools",
18
+ read: "call_tool",
19
+ write: "call_destructive_tool",
20
+ program: "execute_read_code",
21
+ authorize: "authorize_connector",
22
+ skills: "skills",
23
+ }),
24
+ code: Object.freeze({
25
+ search: "connecta.search inside execute_code",
26
+ read: "connecta.call inside execute_code",
27
+ write: "connecta.call inside execute_code",
28
+ program: "execute_code",
29
+ authorize: "authorize_connector",
30
+ skills: "skills",
31
+ }),
32
+ native: Object.freeze({
33
+ search: "your host's tool search",
34
+ read: "the original native tool",
35
+ write: "the original native tool",
36
+ program: "your host's tool search",
37
+ authorize: "the returned authorization handoff",
38
+ skills: "skills",
39
+ }),
40
+ "native-code": Object.freeze({
41
+ search: "your host's tool search",
42
+ read: "the original native tool",
43
+ write: "the original native tool",
44
+ program: "execute_code",
45
+ authorize: "the returned authorization handoff",
46
+ skills: "skills",
47
+ }),
48
+ };
49
+ /** The meta surface's names: the default wherever no other surface applies. */
50
+ export const META_ROUTES = NAMES.meta;
51
+ export function routeNames(surface) {
52
+ return NAMES[surface];
53
+ }
@@ -8,6 +8,7 @@ export function operatorActivityContext(context, authz) {
8
8
  return {
9
9
  sink: activity.store,
10
10
  recordTool: activity.recordTool,
11
+ recordOperation: activity.recordOperation,
11
12
  actor: authz.actor,
12
13
  ...(principal
13
14
  ? { principalActor: { kind: authz.actor.kind, id: principal.id, namespace: principal.namespace } }
@@ -1,3 +1,4 @@
1
+ import { checkedActivityOperation } from "../operation-activity.js";
1
2
  import { recordedToolName } from "../operator-record.js";
2
3
  /** A recorded tool is shown only when its name passes the record grammar and its endpoint granted it. */
3
4
  function activityToolVisible(access, event) {
@@ -15,6 +16,31 @@ function activityToolVisible(access, event) {
15
16
  * pool that is no longer declared is withheld.
16
17
  */
17
18
  export function activityEventVisible(context, authz, registry, event) {
19
+ const pool = event.pool !== undefined ? context.opts.pools.get(event.pool) : undefined;
20
+ if (event.pool !== undefined && !pool)
21
+ return false;
22
+ const access = pool ? pool.access : { connectorIds: "all" };
23
+ if (event.kind === "operation") {
24
+ const operation = checkedActivityOperation(event.operation);
25
+ if (!operation ||
26
+ event.toolName !== operation.name ||
27
+ event.connectorId !== (operation.connectors.length === 1 ? operation.connectors[0] : "$operation"))
28
+ return false;
29
+ const ownerOnly = operation.personal ||
30
+ event.actorBasis === "principal" ||
31
+ operation.connectors.some((id) => registry.getConnector(id)?.authScope === "personal");
32
+ if (ownerOnly) {
33
+ const owner = authz.identity.principal;
34
+ if (!owner ||
35
+ event.actorBasis !== "principal" ||
36
+ event.actor.id !== owner.id ||
37
+ event.actor.namespace !== owner.namespace)
38
+ return false;
39
+ }
40
+ return operation.connectors.every((id) => registry.canReadConnectorSkills(id) &&
41
+ (access.connectorIds === "all" || access.connectorIds.includes(id)) &&
42
+ !access.toolAccess?.has(id));
43
+ }
18
44
  const connector = registry.getConnector(event.connectorId);
19
45
  if (!connector)
20
46
  return false;
@@ -26,10 +52,6 @@ export function activityEventVisible(context, authz, registry, event) {
26
52
  event.actor.namespace !== owner.namespace)
27
53
  return false;
28
54
  }
29
- const pool = event.pool !== undefined ? context.opts.pools.get(event.pool) : undefined;
30
- if (event.pool !== undefined && !pool)
31
- return false;
32
- const access = pool ? pool.access : { connectorIds: "all" };
33
55
  if (access.connectorIds !== "all" && !access.connectorIds.includes(event.connectorId))
34
56
  return false;
35
57
  // Connector-wide counts reveal tools outside an address-only grant.
@@ -27,12 +27,13 @@ function enrichActivityActorLabels(page, auth) {
27
27
  ...(event.actor.namespace ? { namespace: event.actor.namespace } : {}),
28
28
  };
29
29
  // Re-check stored client facts, including custom readers and old rows.
30
- const { packageVersion: suppliedPackageVersion, clientName: suppliedName, clientVersion: suppliedVersion, classification: _classification, resultBytes: _resultBytes, kind: _kind, drift: _drift, pool: _pool, actorBasis: _actorBasis, ...record } = event;
30
+ const { packageVersion: suppliedPackageVersion, clientName: suppliedName, clientVersion: suppliedVersion, classification: _classification, resultBytes: _resultBytes, kind: _kind, drift: _drift, operation: _operation, friction: suppliedFriction, pool: _pool, actorBasis: _actorBasis, ...record } = event;
31
31
  const packageVersion = activityPackageVersion(suppliedPackageVersion);
32
32
  const clientName = activityClientFact(suppliedName, "name");
33
33
  const clientVersion = activityClientFact(suppliedVersion, "version");
34
34
  return {
35
35
  ...record,
36
+ ...(event.kind !== "operation" && suppliedFriction !== undefined ? { friction: suppliedFriction } : {}),
36
37
  ...activityBehaviorFacts(event),
37
38
  ...(packageVersion !== undefined ? { packageVersion } : {}),
38
39
  ...(clientName !== undefined ? { clientName } : {}),
@@ -5,7 +5,7 @@ import { escapeHtml, renderPage } from "../branding.js";
5
5
  import { htmlSecurityHeaders } from "../html-security.js";
6
6
  import { closeConnectorScope } from "../connector-scope.js";
7
7
  import { drainOAuthStartResets } from "../auth/oauth-start-reset.js";
8
- import { finishOAuthConnectStart, consumeOAuthConnectLink, oauthConnectUnavailable, verifyOAuthHandoff, } from "../oauth-handoff.js";
8
+ import { finishOAuthConnectStart, claimOAuthConnectStart, oauthConnectUnavailable, verifyOAuthHandoff, } from "../oauth-handoff.js";
9
9
  import { failureRecord, logFailure } from "../operator-record.js";
10
10
  import { runEdge, withDeadlineEffect } from "../runtime/run.js";
11
11
  import { admitsMaintainer, authorizeUiIdentity, mayManageConnector, privateJson, withSessionCookies, } from "./shared.js";
@@ -66,6 +66,9 @@ export function routeConnect(context) {
66
66
  async function connect(context) {
67
67
  const { opts, request, baseUrl, runtimeContext } = context;
68
68
  const refuse = (error, status = 403) => privateJson({ error }, { status });
69
+ const invalidLink = () => refuse("Invalid or expired connection link. Request a new link from connecta.", 400);
70
+ // Spends nothing, so this same link works once the other visit settles.
71
+ const starting = () => refuse("This connection is starting in another visit. Reload this page in a moment. If this stays, start a new connection request.", 409);
69
72
  if (request.method !== "GET")
70
73
  return privateJson({ error: "method not allowed" }, { status: 405, headers: { Allow: "GET" } });
71
74
  const unavailable = oauthConnectUnavailable(opts);
@@ -74,7 +77,7 @@ async function connect(context) {
74
77
  const id = context.path.slice("/connect/".length);
75
78
  const handoff = await verifyOAuthHandoff(opts, baseUrl, id, context.url.searchParams.get("h"));
76
79
  if (!handoff)
77
- return refuse("Invalid or expired connection link. Request a new link from connecta.", 400);
80
+ return invalidLink();
78
81
  const authPage = () => {
79
82
  const target = new URL(`/connectors/${id}`, baseUrl);
80
83
  target.searchParams.set("h", context.url.searchParams.get("h"));
@@ -124,10 +127,11 @@ async function connect(context) {
124
127
  }
125
128
  if (target.origin !== new URL(baseUrl).origin || target.username || target.password)
126
129
  return refuse("Credential management URL is unavailable", 503);
127
- if (!(await consumeOAuthConnectLink(opts, handoff)))
128
- return refuse("Invalid or expired connection link. Request a new link from connecta.", 400);
129
- if (!(await finishOAuthConnectStart(opts, handoff, "started")))
130
- return refuse("Invalid or expired connection link. Request a new link from connecta.", 400);
130
+ const claim = await claimOAuthConnectStart(opts, handoff);
131
+ if (claim === "pending")
132
+ return starting();
133
+ if (claim === "refused" || !(await finishOAuthConnectStart(opts, handoff, "started")))
134
+ return invalidLink();
131
135
  // Credential entry stays in the authenticated operator UI on this origin.
132
136
  return withSessionCookies(new Response(null, {
133
137
  status: 302,
@@ -149,16 +153,20 @@ async function connect(context) {
149
153
  const timeoutError = new ConnectorCallError("timeout", "OAuth authorization start timed out");
150
154
  let claimed = false;
151
155
  try {
152
- if (!(await consumeOAuthConnectLink(opts, handoff)))
153
- return refuse("Invalid or expired connection link. Request a new link from connecta.", 400);
156
+ const claim = await claimOAuthConnectStart(opts, handoff);
157
+ if (claim === "pending")
158
+ return starting();
159
+ if (claim === "refused")
160
+ return invalidLink();
154
161
  claimed = true;
162
+ const force = claim === "reset";
155
163
  const status = await runEdge(withDeadlineEffect((signal) => Effect.tryPromise({
156
164
  try: async () => {
157
165
  ctx = registry.contextFor(id, baseUrl, scope, {
158
166
  signal,
159
167
  ...(context.defer ? { defer: context.defer } : {}),
160
168
  });
161
- const started = await connector.startAuth(ctx, { force: handoff.force });
169
+ const started = await connector.startAuth(ctx, { force });
162
170
  if (signal.aborted)
163
171
  throw signal.reason;
164
172
  if (started.authorizationUrl) {
@@ -181,11 +189,11 @@ async function connect(context) {
181
189
  catch: (error) => error,
182
190
  }), { timeoutMs: START_TIMEOUT_MS, signal: request.signal, timeoutError }));
183
191
  await drainOAuthStartResets(scope);
184
- if (handoff.force || (!status.authorizationReused && status.state !== "ok"))
192
+ if (force || (!status.authorizationReused && status.state !== "ok"))
185
193
  await registry.invalidateStored(id);
186
194
  if (status.state === "ok") {
187
195
  if (!(await finishOAuthConnectStart(opts, handoff, "started")))
188
- return refuse("Invalid or expired connection link. Request a new link from connecta.", 400);
196
+ return invalidLink();
189
197
  return withSessionCookies(new Response("This connector is already connected.", { headers: { "Cache-Control": "no-store" } }), authz.sessionCookies);
190
198
  }
191
199
  if (status.state !== "auth_required" || !status.authorizationUrl) {
@@ -194,7 +202,7 @@ async function connect(context) {
194
202
  // state only, since a plugin's `startAuth` returns whatever it likes.
195
203
  logFailure(opts.config.logger, "OAuth start failed", failureRecord({
196
204
  connector: id,
197
- mode: handoff.force ? "restart" : "continue",
205
+ mode: force ? "restart" : "continue",
198
206
  state: status.state,
199
207
  }));
200
208
  return refuse(status.state === "auth_required"
@@ -205,7 +213,7 @@ async function connect(context) {
205
213
  if (!["https:", "http:"].includes(target.protocol) || target.username || target.password)
206
214
  return refuse("OAuth authorization returned no safe URL", 502);
207
215
  if (!(await finishOAuthConnectStart(opts, handoff, "started")))
208
- return refuse("Invalid or expired connection link. Request a new link from connecta.", 400);
216
+ return invalidLink();
209
217
  return withSessionCookies(new Response(null, { status: 302, headers: { Location: target.href, "Cache-Control": "no-store" } }), authz.sessionCookies);
210
218
  }
211
219
  catch (error) {