@agent-native/core 0.227.0 → 0.228.0-nightly-20261111004142

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 (190) hide show
  1. package/dist/action.d.ts +6 -2
  2. package/dist/agent/engine/builder-engine.js +6 -1
  3. package/dist/agent/engine/tool-call-journal-seed.d.ts +2 -0
  4. package/dist/agent/engine/tool-call-journal-seed.js +14 -0
  5. package/dist/agent/harness/store.js +14 -0
  6. package/dist/agent/production-agent.js +257 -17
  7. package/dist/agent/reaped-run-thread.js +6 -0
  8. package/dist/agent/run-manager.js +6 -0
  9. package/dist/agent/tool-call-journal.d.ts +4 -0
  10. package/dist/agent/tool-call-journal.js +12 -0
  11. package/dist/agent/types.d.ts +7 -1
  12. package/dist/app-config/agent.d.ts +3 -0
  13. package/dist/app-config/agent.js +7 -0
  14. package/dist/app-config/schema.d.ts +3 -0
  15. package/dist/application-state/handlers.d.ts +3 -3
  16. package/dist/application-state/handlers.js +6 -1
  17. package/dist/application-state/index.d.ts +3 -2
  18. package/dist/application-state/index.js +3 -2
  19. package/dist/application-state/last-used-tab.d.ts +11 -0
  20. package/dist/application-state/last-used-tab.js +43 -0
  21. package/dist/application-state/script-helpers.d.ts +26 -0
  22. package/dist/application-state/script-helpers.js +83 -15
  23. package/dist/application-state/store.d.ts +8 -0
  24. package/dist/application-state/store.js +16 -0
  25. package/dist/cli/agent.js +7 -3
  26. package/dist/cli/code-agent-executor.d.ts +1 -0
  27. package/dist/cli/code-agent-executor.js +37 -2
  28. package/dist/cli/code.js +3 -1
  29. package/dist/client/agent-sidebar-state.d.ts +2 -0
  30. package/dist/client/browser-tab-use.d.ts +9 -0
  31. package/dist/client/browser-tab-use.js +112 -0
  32. package/dist/client/chat/runtime.js +3 -1
  33. package/dist/client/error-capture.js +33 -1
  34. package/dist/client/navigation/index.d.ts +1 -0
  35. package/dist/client/navigation/index.js +1 -0
  36. package/dist/client/resources/mcp-integration-logos.js +16 -0
  37. package/dist/client/review/use-review.d.ts +28 -0
  38. package/dist/client/review/use-review.js +41 -12
  39. package/dist/client/route-state.js +2 -0
  40. package/dist/client/sse-event-processor.d.ts +1 -0
  41. package/dist/client/sse-event-processor.js +7 -1
  42. package/dist/client/use-db-sync.d.ts +8 -1
  43. package/dist/client/use-db-sync.js +6 -5
  44. package/dist/coding-tools/run-code.js +12 -2
  45. package/dist/coding-tools/sandbox/executions-store.d.ts +1 -1
  46. package/dist/coding-tools/sandbox/executions-store.js +9 -3
  47. package/dist/collab/routes.d.ts +1 -1
  48. package/dist/data-programs/execute.js +1 -1
  49. package/dist/db/schema-snapshot.generated.js +17 -3
  50. package/dist/extensions/url-safety.d.ts +1 -3
  51. package/dist/extensions/url-safety.js +23 -23
  52. package/dist/file-upload/actions/upload-image.d.ts +5 -5
  53. package/dist/file-upload/index.d.ts +1 -1
  54. package/dist/file-upload/index.js +1 -1
  55. package/dist/file-upload/s3-client.d.ts +1 -0
  56. package/dist/file-upload/s3-client.js +70 -13
  57. package/dist/file-upload/s3.d.ts +9 -0
  58. package/dist/file-upload/s3.js +14 -1
  59. package/dist/framework-package-versions.json +8 -8
  60. package/dist/jobs/run-history.js +10 -3
  61. package/dist/jobs/scheduler.js +5 -36
  62. package/dist/localization/core-messages/supplemental/ar-SA.js +35 -0
  63. package/dist/localization/core-messages/supplemental/de-DE.js +35 -0
  64. package/dist/localization/core-messages/supplemental/en-US.js +35 -0
  65. package/dist/localization/core-messages/supplemental/es-ES.js +35 -0
  66. package/dist/localization/core-messages/supplemental/fr-FR.js +35 -0
  67. package/dist/localization/core-messages/supplemental/hi-IN.js +35 -0
  68. package/dist/localization/core-messages/supplemental/ja-JP.js +35 -0
  69. package/dist/localization/core-messages/supplemental/ko-KR.js +35 -0
  70. package/dist/localization/core-messages/supplemental/pt-BR.js +35 -0
  71. package/dist/localization/core-messages/supplemental/zh-CN.js +35 -0
  72. package/dist/localization/core-messages/supplemental/zh-TW.js +35 -0
  73. package/dist/localization/default-messages.d.ts +35 -0
  74. package/dist/localization/default-messages.js +35 -0
  75. package/dist/localization/mcp-settings-messages.d.ts +35 -0
  76. package/dist/mcp/actions/list-mcp-client-connections.d.ts +8 -0
  77. package/dist/mcp/actions/list-mcp-client-connections.js +39 -0
  78. package/dist/mcp/browser-tab-target.d.ts +16 -0
  79. package/dist/mcp/browser-tab-target.js +58 -0
  80. package/dist/mcp/build-server.d.ts +1 -0
  81. package/dist/mcp/build-server.js +38 -7
  82. package/dist/mcp/call-attribution.d.ts +4 -1
  83. package/dist/mcp/call-attribution.js +3 -1
  84. package/dist/mcp/client-connections.d.ts +6 -0
  85. package/dist/mcp/client-connections.js +91 -0
  86. package/dist/mcp/connect-route.js +12 -2
  87. package/dist/mcp/connect-store.d.ts +3 -0
  88. package/dist/mcp/connect-store.js +10 -1
  89. package/dist/mcp/direct-action-call.js +1 -0
  90. package/dist/mcp/oauth-store.d.ts +6 -0
  91. package/dist/mcp/oauth-store.js +23 -0
  92. package/dist/mcp/oauth-token.d.ts +1 -0
  93. package/dist/mcp/oauth-token.js +1 -0
  94. package/dist/mcp/server.js +9 -20
  95. package/dist/mcp-client/config.d.ts +2 -0
  96. package/dist/mcp-client/manager.js +23 -0
  97. package/dist/mcp-client/workspace-servers.js +42 -10
  98. package/dist/notifications/routes.d.ts +3 -3
  99. package/dist/org/handlers.d.ts +1 -1
  100. package/dist/org/handlers.js +21 -17
  101. package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
  102. package/dist/provider-api/actions/provider-api.d.ts +8 -8
  103. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  104. package/dist/provider-api/index.js +17 -36
  105. package/dist/review/actions/create-review-comment.d.ts +7 -0
  106. package/dist/review/actions/create-review-comment.js +29 -3
  107. package/dist/review/actions/delete-review-comment.js +2 -2
  108. package/dist/review/actions/list-review-comments.d.ts +3 -0
  109. package/dist/review/actions/list-review-comments.js +41 -17
  110. package/dist/review/actions/react-to-review-comment.js +2 -2
  111. package/dist/review/actions/reply-review-comment.d.ts +4 -0
  112. package/dist/review/actions/reply-review-comment.js +15 -6
  113. package/dist/review/actions/update-review-comment-anchor.d.ts +6 -0
  114. package/dist/review/actions/update-review-comment-anchor.js +39 -10
  115. package/dist/review/actions/update-review-comment.d.ts +3 -0
  116. package/dist/review/actions/update-review-comment.js +28 -3
  117. package/dist/review/errors.d.ts +33 -0
  118. package/dist/review/errors.js +51 -0
  119. package/dist/review/index.d.ts +5 -4
  120. package/dist/review/index.js +3 -2
  121. package/dist/review/registry.d.ts +51 -1
  122. package/dist/review/registry.js +117 -0
  123. package/dist/review/store.d.ts +59 -14
  124. package/dist/review/store.js +622 -101
  125. package/dist/review/suggestions/actions.js +65 -10
  126. package/dist/review/suggestions/actor-kind.d.ts +5 -0
  127. package/dist/review/suggestions/actor-kind.js +20 -0
  128. package/dist/review/suggestions/store.js +16 -2
  129. package/dist/review/suggestions/types.d.ts +13 -0
  130. package/dist/review/types.d.ts +89 -0
  131. package/dist/scripts/db/dev-query-proxy.d.ts +2 -0
  132. package/dist/scripts/db/dev-query-proxy.js +4 -1
  133. package/dist/scripts/db/query.js +3 -0
  134. package/dist/scripts/dev/shell.js +4 -0
  135. package/dist/scripts/dev-session.d.ts +31 -0
  136. package/dist/scripts/dev-session.js +58 -0
  137. package/dist/scripts/resources/list.js +53 -4
  138. package/dist/scripts/runner.js +22 -4
  139. package/dist/search/query.d.ts +11 -5
  140. package/dist/search/query.js +52 -15
  141. package/dist/search/tokenize.d.ts +1 -0
  142. package/dist/search/tokenize.js +3 -0
  143. package/dist/secrets/api-keys.d.ts +4 -0
  144. package/dist/secrets/api-keys.js +5 -0
  145. package/dist/secrets/register.d.ts +2 -0
  146. package/dist/secrets/routes.d.ts +2 -2
  147. package/dist/secrets/routes.js +85 -6
  148. package/dist/secrets/storage.d.ts +2 -0
  149. package/dist/secrets/storage.js +34 -9
  150. package/dist/secrets/substitution.d.ts +3 -2
  151. package/dist/secrets/substitution.js +56 -7
  152. package/dist/server/action-discovery.js +5 -0
  153. package/dist/server/action-routes.js +8 -6
  154. package/dist/server/agent-chat/prompt-resources.js +117 -79
  155. package/dist/server/agent-chat/script-entries.js +15 -8
  156. package/dist/server/agent-chat-plugin.js +9 -4
  157. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  158. package/dist/server/agent-engine-ollama-models-route.d.ts +1 -1
  159. package/dist/server/agents-bundle.d.ts +3 -1
  160. package/dist/server/agents-bundle.js +4 -4
  161. package/dist/server/csrf.js +1 -0
  162. package/dist/server/dev-action-bridge.d.ts +2 -0
  163. package/dist/server/dev-action-bridge.js +34 -5
  164. package/dist/server/first-touch-route.d.ts +1 -1
  165. package/dist/server/http-response-telemetry.js +4 -0
  166. package/dist/server/loopback.js +3 -1
  167. package/dist/server/request-context.d.ts +26 -0
  168. package/dist/shared/app-state-routes.d.ts +7 -0
  169. package/dist/shared/app-state-routes.js +7 -0
  170. package/dist/shared/mcp-client-connections.d.ts +41 -0
  171. package/dist/shared/mcp-client-connections.js +18 -0
  172. package/dist/shared/mcp-connect-content.d.ts +18 -0
  173. package/dist/shared/mcp-connect-content.js +200 -172
  174. package/dist/shared/runtime-config.js +5 -1
  175. package/dist/templates/chat/.agents/skills/context-awareness/SKILL.md +13 -0
  176. package/dist/templates/default/.agents/skills/context-awareness/SKILL.md +13 -0
  177. package/dist/templates/workspace-core/.agents/skills/context-awareness/SKILL.md +13 -0
  178. package/dist/tracking/providers.d.ts +7 -0
  179. package/dist/tracking/providers.js +39 -17
  180. package/dist/triggers/condition-evaluator.d.ts +2 -0
  181. package/dist/triggers/condition-evaluator.js +93 -3
  182. package/dist/triggers/dispatcher.js +24 -6
  183. package/docs/AGENTS.md +16 -16
  184. package/docs/content/create-with-an-agent.mdx +10 -4
  185. package/docs/content/durable-resume.mdx +11 -2
  186. package/docs/content/toolkit-comments-review.mdx +54 -1
  187. package/package.json +5 -4
  188. package/src/templates/chat/.agents/skills/context-awareness/SKILL.md +13 -0
  189. package/src/templates/default/.agents/skills/context-awareness/SKILL.md +13 -0
  190. package/src/templates/workspace-core/.agents/skills/context-awareness/SKILL.md +13 -0
@@ -1,6 +1,8 @@
1
1
  import { resourceList, resourceListAccessible, ensurePersonalDefaults, isWorkspaceResourceOwner, SHARED_OWNER, WORKSPACE_OWNER, sharedResourceOwner, } from "../../resources/store.js";
2
2
  import { getAmbientUserEmail, getRequestOrgId, getRequestUserEmail, } from "../../server/request-context.js";
3
3
  import { parseArgs, fail } from "../utils.js";
4
+ const DEFAULT_PAGE_SIZE = 50;
5
+ const MAX_PAGE_SIZE = 100;
4
6
  export default async function resourceListScript(args) {
5
7
  const parsed = parseArgs(args);
6
8
  if (parsed.help === "true") {
@@ -12,9 +14,23 @@ Options:
12
14
  Scope to list (default: all)
13
15
  --format json|text Output format (default: text)
14
16
  --include-agent-scratch true Include hidden agent scratch files
17
+ --limit <n> Resources per page, 1-${MAX_PAGE_SIZE} (default: ${DEFAULT_PAGE_SIZE}).
18
+ Text output always pages; json pages only
19
+ when --limit or --offset is set
20
+ --offset <n> Resources to skip; pass the previous page's
21
+ next offset
15
22
  --help Show this help message`);
16
23
  return;
17
24
  }
25
+ const limitArg = parsed.limit;
26
+ const offsetArg = parsed.offset;
27
+ if (limitArg !== undefined &&
28
+ !(/^\d+$/.test(limitArg) && +limitArg >= 1 && +limitArg <= MAX_PAGE_SIZE)) {
29
+ fail(`--limit must be a whole number from 1 to ${MAX_PAGE_SIZE}`);
30
+ }
31
+ if (offsetArg !== undefined && !/^\d+$/.test(offsetArg)) {
32
+ fail("--offset must be a whole number, 0 or more");
33
+ }
18
34
  const prefix = parsed.prefix;
19
35
  const scope = parsed.scope ?? "all";
20
36
  const format = parsed.format ?? "text";
@@ -72,16 +88,44 @@ Options:
72
88
  })
73
89
  : await resourceListAccessible(owner, prefix, { orgId });
74
90
  }
91
+ // Agents read this through tool-result caps (12,000 chars when replayed), so
92
+ // text always pages and json pages when asked. Sorting the merged list keeps
93
+ // pages stable across calls.
94
+ resources.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
95
+ const paged = format !== "json" || limitArg !== undefined || offsetArg !== undefined;
96
+ const total = resources.length;
97
+ const offset = Number(offsetArg ?? 0);
98
+ const limit = Number(limitArg ?? DEFAULT_PAGE_SIZE);
99
+ const page = paged ? resources.slice(offset, offset + limit) : resources;
100
+ const nextOffset = offset + page.length < total ? offset + page.length : null;
75
101
  if (format === "json") {
76
- console.log(JSON.stringify(resources, null, 2));
102
+ console.log(JSON.stringify(paged
103
+ ? {
104
+ total,
105
+ offset,
106
+ limit,
107
+ hasMore: nextOffset !== null,
108
+ nextOffset,
109
+ resources: page,
110
+ }
111
+ : resources, null, 2));
77
112
  return;
78
113
  }
79
- if (resources.length === 0) {
114
+ if (total === 0) {
80
115
  console.log("No resources found.");
81
116
  return;
82
117
  }
83
- console.log(`Resources: ${resources.length}\n`);
84
- for (const r of resources) {
118
+ if (page.length === 0) {
119
+ console.log(`No resources at offset ${offset} (${total} total).`);
120
+ return;
121
+ }
122
+ // The next offset leads the header too: truncation cuts from the end.
123
+ const whole = page.length === total;
124
+ const range = whole
125
+ ? ""
126
+ : ` (showing ${offset + 1}-${offset + page.length}${nextOffset === null ? "" : `; next offset=${nextOffset}`})`;
127
+ console.log(`Resources: ${total}${range}\n`);
128
+ for (const r of page) {
85
129
  const ownerLabel = isWorkspaceResourceOwner(r.owner)
86
130
  ? "[workspace]"
87
131
  : r.owner === SHARED_OWNER
@@ -90,4 +134,9 @@ Options:
90
134
  const sizeLabel = r.size != null ? ` (${r.size} bytes)` : "";
91
135
  console.log(` ${r.path} ${ownerLabel}${sizeLabel} ${r.mimeType}`);
92
136
  }
137
+ if (whole)
138
+ return;
139
+ console.log(nextOffset === null
140
+ ? "\nEnd of list."
141
+ : `\n${total - nextOffset} more not shown. Next page: repeat this call with offset=${nextOffset}.`);
93
142
  }
@@ -11,11 +11,11 @@ import { requestCliApproval, runApprovedCliAction, takeCliApprovalFlag, } from "
11
11
  import { getUrlOpenerCommand } from "../cli/open-url.js";
12
12
  import { closeDbExec, getRuntimeDatabaseUrl, isProcessAlive, } from "../db/client.js";
13
13
  import { notifyActionChange } from "../server/action-change.js";
14
- import { DEV_ACTION_ORG_HEADER, DEV_ACTION_ROUTE, DEV_ACTION_TOKEN_HEADER, DEV_ACTION_USER_HEADER, devActionHandoffUrl, hashDatabaseKey, isLoopbackDevActionOrigin, isValidDevActionHandoffUrl, readDevActionDiscoveryFile, } from "../server/dev-action-bridge.js";
14
+ import { DEV_ACTION_ORG_HEADER, DEV_ACTION_ORG_SCOPE_HEADER, DEV_ACTION_ROUTE, DEV_ACTION_TOKEN_HEADER, DEV_ACTION_USER_HEADER, devActionHandoffUrl, hashDatabaseKey, isLoopbackDevActionOrigin, isValidDevActionHandoffUrl, readDevActionDiscoveryFile, } from "../server/dev-action-bridge.js";
15
15
  import { runWithRequestContext, getRequestOrgId, getRequestUserEmail, } from "../server/request-context.js";
16
16
  import { loadCliBootstrap } from "./cli-bootstrap.js";
17
17
  import { coreScripts, getCoreScriptNames } from "./core-scripts.js";
18
- import { resolveDevUserEmail } from "./dev-session.js";
18
+ import { devOrgChoiceFromEnv, resolveDevOrgScope, resolveDevUserEmail, } from "./dev-session.js";
19
19
  import { loadEnv } from "./utils.js";
20
20
  loadEnv();
21
21
  const CLI_HANDOFF_KEYS = new Set(["embedStartUrl", "startUrl"]);
@@ -192,8 +192,24 @@ export async function runScript(options = {}) {
192
192
  await tryForwardToDevServer(actionName, args);
193
193
  await loadCliBootstrap();
194
194
  const userEmail = await resolveDevUserEmail();
195
- const orgId = process.env.AGENT_ORG_ID || undefined;
196
- return runWithRequestContext({ userEmail, orgId }, () => dispatchAction(actionName, args, options));
195
+ let orgScope;
196
+ try {
197
+ orgScope = await resolveDevOrgScope(userEmail, devOrgChoiceFromEnv());
198
+ }
199
+ catch (err) {
200
+ // The lookup can open the database, so a failure closes it and reports
201
+ // like a failed action rather than leaving an unhandled rejection.
202
+ await closeDbExec().catch(() => { });
203
+ console.error(`Action "${actionName}" failed:`, err instanceof Error ? err.message : String(err));
204
+ process.exit(1);
205
+ }
206
+ return runWithRequestContext({ ...(userEmail ? { userEmail } : {}), ...orgScope }, () => dispatchAction(actionName, args, options));
207
+ }
208
+ /** Tells the dev server this call acts in no org, so it looks none up. */
209
+ function devOrgScopeHeader() {
210
+ return devOrgChoiceFromEnv().personal
211
+ ? { [DEV_ACTION_ORG_SCOPE_HEADER]: "personal" }
212
+ : {};
197
213
  }
198
214
  export async function tryForwardToDevServer(actionName, args) {
199
215
  const discovery = readDevActionDiscoveryFile(process.cwd());
@@ -233,6 +249,7 @@ export async function tryForwardToDevServer(actionName, args) {
233
249
  ...(process.env.AGENT_ORG_ID
234
250
  ? { [DEV_ACTION_ORG_HEADER]: process.env.AGENT_ORG_ID }
235
251
  : {}),
252
+ ...devOrgScopeHeader(),
236
253
  },
237
254
  body: JSON.stringify({
238
255
  name: actionName,
@@ -263,6 +280,7 @@ export async function tryForwardToDevServer(actionName, args) {
263
280
  ...(process.env.AGENT_ORG_ID
264
281
  ? { [DEV_ACTION_ORG_HEADER]: process.env.AGENT_ORG_ID }
265
282
  : {}),
283
+ ...devOrgScopeHeader(),
266
284
  },
267
285
  body: JSON.stringify({
268
286
  name: actionName,
@@ -6,11 +6,17 @@
6
6
  * Matching, for each term:
7
7
  * - titles and summaries match anywhere, including mid-word ("prio" finds
8
8
  * "Task Priorities"), because they're short;
9
- * - bodies match whole words, every word as a prefix, through the GIN index;
10
- * mid-word body matches are deliberately not supported. A phrase needs
11
- * its words adjacent, except in a document too long or repetitive for
12
- * Postgres to keep every word position, where every word being in one
13
- * field is enough.
9
+ * - bodies match whole words, every word as a prefix; mid-word body matches
10
+ * are deliberately not supported. A phrase needs its words adjacent, except
11
+ * in a document too long or repetitive for Postgres to keep every word
12
+ * position, where every word being in one field is enough.
13
+ *
14
+ * The index is read one row per document the app's query allows, not through
15
+ * its GIN index across the table. The table holds every user's documents, so a
16
+ * common word would otherwise read most of them, and Postgres will: it doesn't
17
+ * count what fetching a stored vector costs, so it picks that scan however many
18
+ * words the query has. Reading only the caller's rows keeps the cost to the
19
+ * size of their library, and to that of whatever else narrows the query.
14
20
  *
15
21
  * Ranking reproduces the tiers the browser lane uses
16
22
  * (`TITLE_MATCH_TIER` in Content): exact title 5, title prefix 4, title word
@@ -6,11 +6,17 @@
6
6
  * Matching, for each term:
7
7
  * - titles and summaries match anywhere, including mid-word ("prio" finds
8
8
  * "Task Priorities"), because they're short;
9
- * - bodies match whole words, every word as a prefix, through the GIN index;
10
- * mid-word body matches are deliberately not supported. A phrase needs
11
- * its words adjacent, except in a document too long or repetitive for
12
- * Postgres to keep every word position, where every word being in one
13
- * field is enough.
9
+ * - bodies match whole words, every word as a prefix; mid-word body matches
10
+ * are deliberately not supported. A phrase needs its words adjacent, except
11
+ * in a document too long or repetitive for Postgres to keep every word
12
+ * position, where every word being in one field is enough.
13
+ *
14
+ * The index is read one row per document the app's query allows, not through
15
+ * its GIN index across the table. The table holds every user's documents, so a
16
+ * common word would otherwise read most of them, and Postgres will: it doesn't
17
+ * count what fetching a stored vector costs, so it picks that scan however many
18
+ * words the query has. Reading only the caller's rows keeps the cost to the
19
+ * size of their library, and to that of whatever else narrows the query.
14
20
  *
15
21
  * Ranking reproduces the tiers the browser lane uses
16
22
  * (`TITLE_MATCH_TIER` in Content): exact title 5, title prefix 4, title word
@@ -23,7 +29,7 @@
23
29
  */
24
30
  import { and, not, or, sql } from "drizzle-orm";
25
31
  import { SEARCH_RESOURCES_TABLE } from "./index-store.js";
26
- import { anyOfTsquery, isPhraseTerm, normalizeSearchText, termTsquery, } from "./tokenize.js";
32
+ import { allOfTsquery, anyOfTsquery, isPhraseTerm, normalizeSearchText, termTsquery, } from "./tokenize.js";
27
33
  const ALIAS = "search_index";
28
34
  /** The indexer's weights for title, summary, and body. */
29
35
  const FIELD_WEIGHTS = ["A", "B", "C"];
@@ -61,8 +67,11 @@ export function indexedSearchSql(options) {
61
67
  const titleOnlyField = options.fields === "title";
62
68
  const needle = (term) => normalizeSearchText(term.text);
63
69
  const bodyTerms = (terms) => titleOnlyField ? [] : terms.filter((term) => !term.titleOnly);
70
+ const docVector = sql.raw(`${ALIAS}.doc_vector`);
71
+ const positionsComplete = sql.raw(`${ALIAS}.positions_complete`);
72
+ const termsTsquery = (terms) => anyOfTsquery(terms.map((term) => termTsquery(term.text, { prefix: true })));
64
73
  const bodyMatch = (terms) => {
65
- const tsquery = anyOfTsquery(terms.map((term) => termTsquery(term.text, { prefix: true })));
74
+ const tsquery = termsTsquery(terms);
66
75
  if (!tsquery)
67
76
  return undefined;
68
77
  // A document whose vector lost positions can't be phrase-matched
@@ -70,10 +79,9 @@ export function indexedSearchSql(options) {
70
79
  const anyOrder = anyOfTsquery(terms
71
80
  .filter((term) => isPhraseTerm(term.text))
72
81
  .flatMap((term) => FIELD_WEIGHTS.map((weights) => termTsquery(term.text, { prefix: true, anyOrder: true, weights }))));
73
- const vectorMatch = anyOrder
74
- ? sql `(doc_vector @@ ${tsquery}::tsquery OR (NOT positions_complete AND doc_vector @@ ${anyOrder}::tsquery))`
75
- : sql `doc_vector @@ ${tsquery}::tsquery`;
76
- return sql `${sql.raw(`${ALIAS}.resource_id`)} IN (SELECT resource_id FROM ${sql.raw(SEARCH_RESOURCES_TABLE)} WHERE app = ${registration.app} AND resource_type = ${registration.type} AND ${vectorMatch})`;
82
+ return anyOrder
83
+ ? sql `(${docVector} @@ ${tsquery}::tsquery OR (NOT ${positionsComplete} AND ${docVector} @@ ${anyOrder}::tsquery))`
84
+ : sql `${docVector} @@ ${tsquery}::tsquery`;
77
85
  };
78
86
  const titleAny = (terms) => or(...terms.map((term) => contains(titleNorm, needle(term))));
79
87
  const summaryAny = (terms) => {
@@ -84,8 +92,35 @@ export function indexedSearchSql(options) {
84
92
  };
85
93
  const anyField = (terms) => or(titleAny(terms), summaryAny(terms), bodyMatch(bodyTerms(terms)));
86
94
  const groups = query.groups.filter((group) => group.terms.length > 0);
95
+ /**
96
+ * Every group matching in any field, for groups of plain words. Each test of
97
+ * a stored vector reads it again, so asking for all the groups in one test
98
+ * reads each row's vector once. What that misses is a document where some
99
+ * group matches only in its title or summary, and only those few are tested
100
+ * group by group.
101
+ */
102
+ const everyPlainWordMatch = () => {
103
+ if (titleOnlyField || groups.length < 2)
104
+ return undefined;
105
+ const tested = [];
106
+ for (const group of groups) {
107
+ const terms = bodyTerms(group.terms);
108
+ const tsquery = termsTsquery(terms);
109
+ if (!tsquery || terms.some((term) => isPhraseTerm(term.text))) {
110
+ return undefined;
111
+ }
112
+ tested.push({ terms: group.terms, tsquery });
113
+ }
114
+ return or(sql `${docVector} @@ ${allOfTsquery(tested.map((group) => group.tsquery))}::tsquery`, and(or(...tested.flatMap(({ terms }) => [
115
+ titleAny(terms),
116
+ summaryAny(terms),
117
+ ])), ...tested.map(({ terms, tsquery }) => or(titleAny(terms), summaryAny(terms), sql `${docVector} @@ ${tsquery}::tsquery`))));
118
+ };
119
+ const plainWordMatch = everyPlainWordMatch();
87
120
  const match = groups.length || query.negatives.length
88
- ? and(...groups.map((group) => anyField(group.terms)), ...query.negatives.map((negative) => not(anyField([negative]))))
121
+ ? and(...(plainWordMatch
122
+ ? [plainWordMatch]
123
+ : groups.map((group) => anyField(group.terms))), ...query.negatives.map((negative) => not(anyField([negative]))))
89
124
  : sql `false`;
90
125
  const simpleQueries = groups.length > 0 && groups.every((group) => group.terms.length === 1)
91
126
  ? [groups.map((group) => group.terms[0].text.trim()).join(" ")]
@@ -132,11 +167,13 @@ export function indexedSearchSql(options) {
132
167
  ? termTsquery(phraseTerms, { prefix: true, weights: "C" })
133
168
  : null;
134
169
  const bodyPhrase = phraseQuery
135
- ? sql `case when ${sql.raw(`${ALIAS}.doc_vector`)} @@ ${phraseQuery}::tsquery then 1 else 0 end`
170
+ ? sql `case when ${docVector} @@ ${phraseQuery}::tsquery then 1 else 0 end`
136
171
  : null;
137
172
  return {
138
- join: sql `${sql.raw(SEARCH_RESOURCES_TABLE)} AS ${sql.raw(ALIAS)}`,
139
- on: sql `${sql.raw(`${ALIAS}.app`)} = ${registration.app} AND ${sql.raw(`${ALIAS}.resource_type`)} = ${registration.type} AND ${sql.raw(`${ALIAS}.resource_id`)} = ${registration.idColumn}::text`,
173
+ // LATERAL looks up each row the app's query keeps, and OFFSET keeps Postgres
174
+ // from folding the lookup back into a join it would start from this table.
175
+ join: sql `LATERAL (SELECT * FROM ${sql.raw(SEARCH_RESOURCES_TABLE)} WHERE app = ${registration.app} AND resource_type = ${registration.type} AND resource_id = ${registration.idColumn}::text OFFSET 0) AS ${sql.raw(ALIAS)}`,
176
+ on: sql `true`,
140
177
  match,
141
178
  matchTier,
142
179
  titleCoverage: titleCoverage ?? zero,
@@ -100,4 +100,5 @@ export interface QueryPhraseOptions {
100
100
  export declare function termTsquery(text: string, options?: QueryPhraseOptions): string | null;
101
101
  /** Whether a term is more than one lexeme, so it matches as a phrase. */
102
102
  export declare function isPhraseTerm(text: string): boolean;
103
+ export declare function allOfTsquery(parts: readonly string[]): string;
103
104
  export declare function anyOfTsquery(parts: readonly (string | null)[]): string | null;
@@ -357,6 +357,9 @@ export function termTsquery(text, options = {}) {
357
357
  export function isPhraseTerm(text) {
358
358
  return queryLexemes(text).length > 1;
359
359
  }
360
+ export function allOfTsquery(parts) {
361
+ return parts.map((part) => `(${part})`).join(" & ");
362
+ }
360
363
  export function anyOfTsquery(parts) {
361
364
  const present = parts.filter((part) => !!part);
362
365
  if (!present.length)
@@ -19,6 +19,8 @@ export interface ApiKeyEntry {
19
19
  * shared row (owner `org`) or, for owner `user`, the caller's solo row.
20
20
  */
21
21
  storedScope: SecretScope;
22
+ /** Exact outbound origins approved for this saved row. Empty or absent denies forwarding. */
23
+ urlAllowlist?: string[] | null;
22
24
  /** "••••1234". Absent on managed keys, which only say they exist. */
23
25
  masked?: string;
24
26
  updatedAt: number | null;
@@ -34,6 +36,8 @@ export interface ApiKeyEntry {
34
36
  managedBy?: SecretManagedBy;
35
37
  /** Saving a new value writes this same row. */
36
38
  canReplace: boolean;
39
+ /** The caller may edit this custom row's outbound origin policy. */
40
+ canEditOrigins?: boolean;
37
41
  canDelete: boolean;
38
42
  /** A registered validator can check the stored value. */
39
43
  canTest: boolean;
@@ -73,11 +73,15 @@ function toEntry(caller, meta, owner, manages) {
73
73
  target.scopeId === meta.scopeId &&
74
74
  registered?.kind !== "oauth";
75
75
  const canReplace = mayChange && !provider && writesThisRow;
76
+ const canEditOrigins = mayChange && !registered && !provider;
76
77
  return {
77
78
  name: meta.key,
78
79
  ...(registered ? { label: registered.label } : {}),
79
80
  scope: owner,
80
81
  storedScope: meta.scope,
82
+ ...(canReplace || canEditOrigins
83
+ ? { urlAllowlist: meta.urlAllowlist }
84
+ : {}),
81
85
  ...(managedBy || !meta.last4 ? {} : { masked: meta.last4 }),
82
86
  updatedAt: meta.updatedAt || null,
83
87
  ...(vault ? { vault: true } : {}),
@@ -87,6 +91,7 @@ function toEntry(caller, meta, owner, manages) {
87
91
  usedFor: describeSecretUsage(meta.key),
88
92
  ...(managedBy ? { managedBy } : {}),
89
93
  canReplace,
94
+ canEditOrigins,
90
95
  canDelete: mayChange,
91
96
  canTest: canReplace && !!registered?.validator,
92
97
  };
@@ -46,6 +46,8 @@ export interface RegisteredSecret {
46
46
  kind: SecretKind;
47
47
  required?: boolean;
48
48
  validator?: SecretValidator;
49
+ /** Approved origins for outbound `${keys.NAME}` requests. */
50
+ urlAllowlist?: readonly string[];
49
51
  oauthProvider?: string;
50
52
  oauthConnectUrl?: string;
51
53
  /** What this app uses the key for. Framework uses merge in at read time. */
@@ -55,14 +55,14 @@ export declare function createWriteSecretHandler(): import("h3").EventHandlerWit
55
55
  errorCode: "secret_managed_elsewhere";
56
56
  managedBy: SecretManagedBy;
57
57
  } | {
58
- ok?: undefined;
59
58
  error: string;
60
59
  errorCode?: undefined;
60
+ ok?: undefined;
61
61
  status?: undefined;
62
62
  } | {
63
- ok?: undefined;
64
63
  error: string;
65
64
  errorCode: string;
65
+ ok?: undefined;
66
66
  status?: undefined;
67
67
  } | {
68
68
  error?: undefined;
@@ -7,14 +7,15 @@
7
7
  */
8
8
  import { defineEventHandler, getMethod, getQuery, setResponseStatus, } from "h3";
9
9
  import { readBody } from "../server/h3-helpers.js";
10
+ import { isLoopbackHost } from "../server/origin-allowlist.js";
10
11
  import { runWithRequestContext } from "../server/request-context.js";
11
12
  async function canMutateWorkspaceScope(event, scopeId) {
12
13
  if (scopeId.startsWith("solo:"))
13
14
  return true;
14
15
  const { getOrgContext } = await import("../org/context.js");
15
16
  const ctx = await getOrgContext(event).catch(() => null);
16
- if (!ctx?.orgId)
17
- return true;
17
+ if (!ctx?.orgId || ctx.orgId !== scopeId)
18
+ return false;
18
19
  return ctx.role === "owner" || ctx.role === "admin";
19
20
  }
20
21
  /**
@@ -35,7 +36,8 @@ import { PERSONAL_PROVIDER_KEYS_RESTRICTED_ERROR_CODE, resolvePersonalProviderKe
35
36
  import { canonicalSecretKey, secretKeyNames } from "./key-aliases.js";
36
37
  import { isManagedDeleteAllowed, managedDeleteRefusal, resolveSecretManagedBy, } from "./managed-keys.js";
37
38
  import { listRequiredSecrets, getRequiredSecret, } from "./register.js";
38
- import { writeAppSecret, deleteAppSecret, last4, listAppSecretsForScope, readAppSecretMeta, VAULT_SYNC_DESCRIPTION_PREFIX, } from "./storage.js";
39
+ import { writeAppSecret, updateAppSecretUrlAllowlist, deleteAppSecret, last4, listAppSecretsForScope, readAppSecretMeta, VAULT_SYNC_DESCRIPTION_PREFIX, } from "./storage.js";
40
+ import { registeredUrlAllowlist, validateUrlAllowlist, } from "./substitution.js";
39
41
  import { describeSecretUsage } from "./usage.js";
40
42
  export const SECRET_REJECTED_ERROR_CODE = "secret_rejected";
41
43
  export const SECRET_VALIDATION_UNAVAILABLE_ERROR_CODE = "secret_validation_unavailable";
@@ -267,6 +269,15 @@ async function handleWrite(event, secret) {
267
269
  setResponseStatus(event, 400);
268
270
  return { error: "value is required" };
269
271
  }
272
+ let urlAllowlist;
273
+ if (body.urlAllowlist !== undefined) {
274
+ const normalized = normalizeUrlAllowlist(body.urlAllowlist, registeredUrlAllowlist(secret.key));
275
+ if (normalized.ok === false) {
276
+ setResponseStatus(event, 400);
277
+ return { error: normalized.error };
278
+ }
279
+ urlAllowlist = JSON.stringify(normalized.origins);
280
+ }
270
281
  const { scopeId, reason } = await resolveScopeId(event, secret.scope);
271
282
  if (!scopeId) {
272
283
  setResponseStatus(event, 401);
@@ -339,6 +350,7 @@ async function handleWrite(event, secret) {
339
350
  value,
340
351
  scope: secret.scope,
341
352
  scopeId,
353
+ ...(urlAllowlist === undefined ? {} : { urlAllowlist }),
342
354
  });
343
355
  }
344
356
  catch (err) {
@@ -510,6 +522,9 @@ export function createAdHocSecretHandler() {
510
522
  if (method === "POST" && !name) {
511
523
  return handleAdHocWrite(event);
512
524
  }
525
+ if (method === "PATCH" && name) {
526
+ return handleAdHocPolicyUpdate(event, name);
527
+ }
513
528
  if (method === "DELETE" && name) {
514
529
  return handleAdHocDelete(event, name);
515
530
  }
@@ -615,6 +630,56 @@ async function handleAdHocWrite(event) {
615
630
  }
616
631
  return { ok: true, key: name };
617
632
  }
633
+ async function handleAdHocPolicyUpdate(event, name) {
634
+ if (getRequiredSecret(name) || resolveSecretManagedBy(name)) {
635
+ setResponseStatus(event, 403);
636
+ return { error: "This key is managed by another settings surface" };
637
+ }
638
+ const rawBody = await readBody(event);
639
+ if (!rawBody || typeof rawBody !== "object" || Array.isArray(rawBody)) {
640
+ setResponseStatus(event, 400);
641
+ return { error: "A policy update body is required" };
642
+ }
643
+ const body = rawBody;
644
+ if (body.scope !== "user" &&
645
+ body.scope !== "org" &&
646
+ body.scope !== "workspace") {
647
+ setResponseStatus(event, 400);
648
+ return { error: "A saved key scope is required" };
649
+ }
650
+ if ((body.owner !== "user" && body.owner !== "org") ||
651
+ (body.owner === "user" && body.scope === "org") ||
652
+ (body.owner === "org" && body.scope === "user")) {
653
+ setResponseStatus(event, 400);
654
+ return { error: "A saved key owner is required" };
655
+ }
656
+ const normalized = normalizeUrlAllowlist(body.urlAllowlist);
657
+ if (normalized.ok === false) {
658
+ setResponseStatus(event, 400);
659
+ return { error: normalized.error };
660
+ }
661
+ const scope = body.scope;
662
+ const resolved = await resolveScopeId(event, scope === "workspace" && body.owner === "user" ? "user" : scope);
663
+ const scopeId = resolved.scopeId && scope === "workspace" && body.owner === "user"
664
+ ? `solo:${resolved.scopeId}`
665
+ : resolved.scopeId;
666
+ if (!scopeId) {
667
+ setResponseStatus(event, 401);
668
+ return { error: resolved.reason ?? "Unable to resolve scope" };
669
+ }
670
+ if (body.owner === "org" && !(await canMutateOrgScope(event, scopeId))) {
671
+ setResponseStatus(event, 403);
672
+ return {
673
+ error: "Only organization owners and admins can edit shared keys",
674
+ };
675
+ }
676
+ const updated = await updateAppSecretUrlAllowlist({ key: name, scope, scopeId }, normalized.origins);
677
+ if (!updated) {
678
+ setResponseStatus(event, 404);
679
+ return { error: "Saved key not found" };
680
+ }
681
+ return { ok: true, key: name, urlAllowlist: normalized.origins };
682
+ }
618
683
  async function handleAdHocDelete(event, name) {
619
684
  if (getRequiredSecret(name)) {
620
685
  setResponseStatus(event, 400);
@@ -693,7 +758,7 @@ function extractAdHocName(event) {
693
758
  return null;
694
759
  return AD_HOC_NAME_REGEX.test(candidate) ? candidate : null;
695
760
  }
696
- function normalizeUrlAllowlist(input) {
761
+ function normalizeUrlAllowlist(input, maximum = null) {
697
762
  if (!Array.isArray(input) || !input.every((v) => typeof v === "string")) {
698
763
  return { ok: false, error: "urlAllowlist must be an array of strings" };
699
764
  }
@@ -712,15 +777,29 @@ function normalizeUrlAllowlist(input) {
712
777
  error: `urlAllowlist entry "${value}" is not a valid URL`,
713
778
  };
714
779
  }
715
- if (url.protocol !== "https:" && url.protocol !== "http:") {
780
+ if (url.protocol !== "https:" &&
781
+ !(url.protocol === "http:" && isLoopbackHost(url.host))) {
782
+ return {
783
+ ok: false,
784
+ error: `urlAllowlist entry "${value}" must use https (or local loopback http)`,
785
+ };
786
+ }
787
+ if (url.username || url.password) {
716
788
  return {
717
789
  ok: false,
718
- error: `urlAllowlist entry "${value}" must use http or https`,
790
+ error: "urlAllowlist entries cannot contain credentials",
719
791
  };
720
792
  }
721
793
  if (!origins.includes(url.origin))
722
794
  origins.push(url.origin);
723
795
  }
796
+ if (maximum &&
797
+ origins.some((origin) => !validateUrlAllowlist(origin, maximum))) {
798
+ return {
799
+ ok: false,
800
+ error: "urlAllowlist exceeds the registered provider policy",
801
+ };
802
+ }
724
803
  return { ok: true, origins };
725
804
  }
726
805
  function extractKeyFromEvent(event, opts = {}) {
@@ -41,6 +41,8 @@ export declare class AppSecretKeyMismatchError extends Error {
41
41
  * record's id.
42
42
  */
43
43
  export declare function writeAppSecret(args: WriteSecretArgs): Promise<string>;
44
+ /** Change an existing key's outbound policy without reading or replacing its value. */
45
+ export declare function updateAppSecretUrlAllowlist(ref: SecretRef, origins: readonly string[]): Promise<boolean>;
44
46
  export interface ReadSecretResult {
45
47
  value: string;
46
48
  last4: string;
@@ -144,7 +144,7 @@ export async function writeAppSecret(args) {
144
144
  encrypted_value = excluded.encrypted_value,
145
145
  shared_encrypted_value = excluded.shared_encrypted_value,
146
146
  description = excluded.description,
147
- url_allowlist = excluded.url_allowlist,
147
+ url_allowlist = COALESCE(excluded.url_allowlist, app_secrets.url_allowlist),
148
148
  updated_at = excluded.updated_at`;
149
149
  const upsertArgs = [
150
150
  id,
@@ -167,6 +167,29 @@ export async function writeAppSecret(args) {
167
167
  invalidateAgentEngineStatusCache();
168
168
  return String(rows[0]?.id ?? id);
169
169
  }
170
+ /** Change an existing key's outbound policy without reading or replacing its value. */
171
+ export async function updateAppSecretUrlAllowlist(ref, origins) {
172
+ await ensureTable();
173
+ const { rows } = await getDbExec().execute({
174
+ sql: `UPDATE app_secrets SET url_allowlist = ?, updated_at = ?
175
+ WHERE scope = ? AND scope_id = ? AND key = ?
176
+ AND (description IS NULL OR description NOT LIKE ?)
177
+ RETURNING id`,
178
+ args: [
179
+ JSON.stringify(origins),
180
+ Date.now(),
181
+ ref.scope,
182
+ ref.scopeId,
183
+ ref.key,
184
+ `${VAULT_SYNC_DESCRIPTION_PREFIX}%`,
185
+ ],
186
+ });
187
+ if (rows.length === 0)
188
+ return false;
189
+ invalidateRequestSecret(ref);
190
+ invalidateOptionalKeyCache();
191
+ return true;
192
+ }
170
193
  function decryptAppSecretValue(encrypted, sharedEncrypted) {
171
194
  if (sharedEncrypted) {
172
195
  try {
@@ -540,18 +563,20 @@ export async function listAppSecretsForScope(scope, scopeId) {
540
563
  return results;
541
564
  }
542
565
  function parseAllowlist(raw) {
543
- if (!raw)
566
+ if (raw === null)
544
567
  return null;
568
+ let parsed;
545
569
  try {
546
- const parsed = JSON.parse(raw);
547
- if (Array.isArray(parsed) && parsed.every((v) => typeof v === "string")) {
548
- return parsed;
549
- }
550
- return null;
570
+ parsed = JSON.parse(raw);
551
571
  }
552
- catch {
553
- return null;
572
+ catch (error) {
573
+ throw new Error("Stored secret URL policy is unreadable", { cause: error });
574
+ }
575
+ if (!Array.isArray(parsed) ||
576
+ !parsed.every((value) => typeof value === "string")) {
577
+ throw new Error("Stored secret URL policy is invalid");
554
578
  }
579
+ return parsed;
555
580
  }
556
581
  export async function deleteAppSecret(ref) {
557
582
  await ensureTable();
@@ -24,7 +24,7 @@
24
24
  * installs benefit from the stricter default — opt in only after the
25
25
  * org-admin write-gate is verified to be active.
26
26
  */
27
- import type { SecretScope } from "./register.js";
27
+ import { type SecretScope } from "./register.js";
28
28
  export interface ResolveKeyReferencesResult {
29
29
  resolved: string;
30
30
  usedKeys: string[];
@@ -38,7 +38,8 @@ export interface ResolvedKeyReference {
38
38
  }
39
39
  export declare function resolveKeyReferences(text: string, scope: SecretScope, scopeId: string): Promise<ResolveKeyReferencesResult>;
40
40
  export declare function resolveKeyReferencesWithRequestScopes(text: string, userScopeId: string): Promise<ResolveKeyReferencesResult>;
41
- export declare function validateUrlAllowlist(url: string, allowlist: string[] | null): boolean;
41
+ export declare function registeredUrlAllowlist(name: string): string[] | null;
42
+ export declare function validateUrlAllowlist(url: string, allowlist: readonly string[] | null): boolean;
42
43
  /**
43
44
  * Convenience helper: look up a key's allowlist by name+scope. Returns null
44
45
  * when the key doesn't exist or has no allowlist configured.