@agent-native/core 0.101.2 → 0.101.3

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 (55) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +18 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/credentials/index.ts +41 -8
  5. package/corpus/core/src/extensions/fetch-tool.ts +33 -3
  6. package/corpus/core/src/notifications/channels.ts +20 -6
  7. package/corpus/core/src/secrets/crypto.ts +26 -10
  8. package/corpus/core/src/secrets/storage.ts +16 -3
  9. package/corpus/core/src/server/agent-chat-plugin.ts +60 -7
  10. package/corpus/core/src/server/derived-secret.ts +5 -1
  11. package/corpus/templates/analytics/changelog/2026-07-14-long-running-cross-app-analyses-now-continue-reliably-in-the.md +6 -0
  12. package/corpus/templates/analytics/server/plugins/agent-chat.ts +8 -4
  13. package/corpus/templates/clips/app/components/player/share-dialog.tsx +44 -81
  14. package/corpus/templates/clips/app/i18n/ar-SA.ts +0 -8
  15. package/corpus/templates/clips/app/i18n/de-DE.ts +0 -9
  16. package/corpus/templates/clips/app/i18n/en-US.ts +0 -8
  17. package/corpus/templates/clips/app/i18n/es-ES.ts +0 -9
  18. package/corpus/templates/clips/app/i18n/fr-FR.ts +0 -9
  19. package/corpus/templates/clips/app/i18n/hi-IN.ts +0 -8
  20. package/corpus/templates/clips/app/i18n/ja-JP.ts +0 -9
  21. package/corpus/templates/clips/app/i18n/ko-KR.ts +0 -9
  22. package/corpus/templates/clips/app/i18n/pt-BR.ts +0 -9
  23. package/corpus/templates/clips/app/i18n/zh-CN.ts +0 -7
  24. package/corpus/templates/clips/app/i18n/zh-TW.ts +0 -7
  25. package/corpus/templates/clips/changelog/2026-07-14-share-dialogs-show-separate-links-for-people-and-agents.md +6 -0
  26. package/corpus/templates/dispatch/changelog/2026-07-14-dispatch-now-distinguishes-connected-agents-from-locally-mou.md +6 -0
  27. package/dist/credentials/index.d.ts +12 -7
  28. package/dist/credentials/index.d.ts.map +1 -1
  29. package/dist/credentials/index.js +33 -8
  30. package/dist/credentials/index.js.map +1 -1
  31. package/dist/extensions/fetch-tool.d.ts +13 -1
  32. package/dist/extensions/fetch-tool.d.ts.map +1 -1
  33. package/dist/extensions/fetch-tool.js +9 -2
  34. package/dist/extensions/fetch-tool.js.map +1 -1
  35. package/dist/notifications/channels.js +20 -4
  36. package/dist/notifications/channels.js.map +1 -1
  37. package/dist/notifications/routes.d.ts +3 -3
  38. package/dist/observability/routes.d.ts +1 -1
  39. package/dist/resources/handlers.d.ts +1 -1
  40. package/dist/secrets/crypto.d.ts +15 -7
  41. package/dist/secrets/crypto.d.ts.map +1 -1
  42. package/dist/secrets/crypto.js +23 -10
  43. package/dist/secrets/crypto.js.map +1 -1
  44. package/dist/secrets/storage.d.ts.map +1 -1
  45. package/dist/secrets/storage.js +16 -3
  46. package/dist/secrets/storage.js.map +1 -1
  47. package/dist/server/agent-chat-plugin.d.ts +20 -0
  48. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  49. package/dist/server/agent-chat-plugin.js +38 -4
  50. package/dist/server/agent-chat-plugin.js.map +1 -1
  51. package/dist/server/derived-secret.d.ts +1 -1
  52. package/dist/server/derived-secret.d.ts.map +1 -1
  53. package/dist/server/derived-secret.js.map +1 -1
  54. package/package.json +2 -2
  55. package/corpus/templates/clips/app/components/sharing/slack-share-hint.tsx +0 -68
package/corpus/README.md CHANGED
@@ -28,4 +28,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
28
28
  ## Generated Counts
29
29
 
30
30
  - core files: 2258
31
- - template files: 5620
31
+ - template files: 5622
@@ -1,5 +1,23 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.101.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 7cfb087: Keep centered chat composer skeletons aligned with the real composer while the chat UI loads.
8
+ - 7cfb087: Document PR Visual Recap's cost drivers and cheaper model options (`claude-haiku-4-5`, `gpt-5.6-luna`/`gpt-5.6-terra`, `openai-compatible` providers), and note that the Claude backend now defaults to `claude-sonnet-5` when `VISUAL_RECAP_MODEL` is unset. Adds a matching cost/model-choice section to the Visual Plans template doc.
9
+ - 7cfb087: Fail closed when first-party scaffolds cannot be downloaded from the CLI's immutable core version tags instead of mixing newer templates with an older runtime.
10
+ - 7cfb087: Resolve `${keys.NAME}` in the agent fetch tool and notification webhooks through the request-scope cascade so Dispatch-vault keys scoped to the workspace/org resolve like they already do for extensions and automations.
11
+ - 7cfb087: Resolve provider credentials from encrypted Dispatch vault secrets, keep hosted A2A agent discovery distinct from mounted workspace app inventory, and run long Analytics A2A tasks on the durable background worker.
12
+ - 7cfb087: Expose the embedded OG font resolver so template social images keep their text visible in serverless runtimes.
13
+ - 7cfb087: Stop agent turns from looping across alternating read-only docs and source search tools.
14
+ - 7cfb087: Publish PR Visual Recap helpers as a dependency-light CLI package so recap workflows no longer install the full Agent-Native framework dependency graph.
15
+ - 7cfb087: Derive workspace-shared secret encryption material from A2A_SECRET on hosted workspace deploys so vault keys decrypt across sibling apps, and stop serving stale shared ciphertext after a value is updated without shared key material.
16
+ - Updated dependencies [7cfb087]
17
+ - Updated dependencies [7cfb087]
18
+ - Updated dependencies [7cfb087]
19
+ - @agent-native/recap-cli@0.4.0
20
+
3
21
  ## 0.101.2
4
22
 
5
23
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.101.2",
3
+ "version": "0.101.3",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -3,6 +3,7 @@ import {
3
3
  decryptSecretValue,
4
4
  isEncryptedSecretValue,
5
5
  } from "../secrets/crypto.js";
6
+ import { readAppSecret, type SecretRef } from "../secrets/storage.js";
6
7
  import { getSetting, putSetting, deleteSetting } from "../settings/store.js";
7
8
 
8
9
  const SETTING_PREFIX = "credential:";
@@ -41,6 +42,20 @@ async function readCredentialSetting(
41
42
  }
42
43
  }
43
44
 
45
+ async function readScopedAppSecret(
46
+ key: string,
47
+ scope: SecretRef["scope"],
48
+ scopeId: string,
49
+ ): Promise<string | undefined> {
50
+ try {
51
+ return (await readAppSecret({ key, scope, scopeId }))?.value;
52
+ } catch {
53
+ // Older databases may not have app_secrets yet. Keep the legacy
54
+ // credential store available while the table bootstraps.
55
+ return undefined;
56
+ }
57
+ }
58
+
44
59
  /**
45
60
  * Resolve a credential from one explicit legacy SQL credential scope.
46
61
  *
@@ -61,16 +76,21 @@ export async function resolveCredentialForScope(
61
76
  }
62
77
 
63
78
  /**
64
- * Resolve a credential, scoped to the caller's user (and falling back to
65
- * the active org's shared credential, if any).
79
+ * Resolve a credential across the encrypted app_secrets store and the legacy
80
+ * settings-backed credential store. User overrides win, followed by the
81
+ * active org/workspace shared value.
66
82
  *
67
83
  * SECURITY: NEVER reads from process.env. Env vars are global to the
68
- * deployment and would leak across users in a multi-tenant app. The only
69
- * sources are per-user / per-org rows in the SQL `settings` table.
84
+ * deployment and would leak across users in a multi-tenant app.
85
+ *
86
+ * Read order:
87
+ * 1. user-scoped app_secrets
88
+ * 2. user-scoped legacy settings credential
89
+ * 3. org-scoped app_secrets
90
+ * 4. legacy workspace-scoped app_secrets for the org
91
+ * 5. org-scoped legacy settings credential
70
92
  *
71
- * Storage keys (priority order):
72
- * 1. u:<email>:credential:<KEY> — per-user override
73
- * 2. o:<orgId>:credential:<KEY> — per-org shared credential (if orgId given)
93
+ * Without an active org, step 3 is replaced by the solo workspace scope.
74
94
  */
75
95
  export async function resolveCredential(
76
96
  key: string,
@@ -78,6 +98,9 @@ export async function resolveCredential(
78
98
  ): Promise<string | undefined> {
79
99
  if (!ctx?.userEmail) return undefined;
80
100
 
101
+ const userSecret = await readScopedAppSecret(key, "user", ctx.userEmail);
102
+ if (userSecret) return userSecret;
103
+
81
104
  const userSetting = await resolveCredentialForScope(key, {
82
105
  ...ctx,
83
106
  scope: "user",
@@ -85,10 +108,20 @@ export async function resolveCredential(
85
108
  if (userSetting) return userSetting;
86
109
 
87
110
  if (ctx.orgId) {
111
+ const orgSecret = await readScopedAppSecret(key, "org", ctx.orgId);
112
+ if (orgSecret) return orgSecret;
113
+
114
+ const workspaceSecret = await readScopedAppSecret(
115
+ key,
116
+ "workspace",
117
+ ctx.orgId,
118
+ );
119
+ if (workspaceSecret) return workspaceSecret;
120
+
88
121
  return resolveCredentialForScope(key, { ...ctx, scope: "org" });
89
122
  }
90
123
 
91
- return undefined;
124
+ return readScopedAppSecret(key, "workspace", `solo:${ctx.userEmail}`);
92
125
  }
93
126
 
94
127
  /**
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { ActionEntry } from "../agent/production-agent.js";
14
+ import type { ResolvedKeyReference } from "../secrets/substitution.js";
14
15
  import {
15
16
  collectSecretValues,
16
17
  MAX_EXTENSION_PROXY_RESPONSE_SIZE,
@@ -77,9 +78,23 @@ export interface FetchToolOptions {
77
78
  resolved: string;
78
79
  usedKeys: string[];
79
80
  secretValues?: string[];
81
+ /**
82
+ * Optional: which scope (user/org/workspace) each used key actually
83
+ * resolved at. Populated by resolvers like
84
+ * `resolveKeyReferencesWithRequestScopes` so `validateUrl` can look up
85
+ * the allowlist at the scope the value came from rather than assuming
86
+ * user scope. Backwards compatible — callers that omit this (or a
87
+ * resolver that doesn't report it) are unaffected.
88
+ */
89
+ resolvedKeys?: ResolvedKeyReference[];
80
90
  }>;
81
91
  /** Validate URL against per-key allowlists. */
82
- validateUrl?: (url: string, usedKeys: string[]) => Promise<boolean>;
92
+ validateUrl?: (
93
+ url: string,
94
+ usedKeys: string[],
95
+ /** Accumulated `resolvedKeys` from every `resolveKeys` call this request made, when the resolver reported them. */
96
+ resolvedKeys?: ResolvedKeyReference[],
97
+ ) => Promise<boolean>;
83
98
  }
84
99
 
85
100
  /**
@@ -207,6 +222,7 @@ export function createFetchToolEntry(
207
222
  let resolvedBody = rawBody;
208
223
  const allUsedKeys: string[] = [];
209
224
  const allSecretValues: string[] = [];
225
+ const allResolvedKeys: ResolvedKeyReference[] = [];
210
226
 
211
227
  if (opts.resolveKeys) {
212
228
  try {
@@ -214,23 +230,29 @@ export function createFetchToolEntry(
214
230
  resolvedUrl = urlResult.resolved;
215
231
  allUsedKeys.push(...urlResult.usedKeys);
216
232
  allSecretValues.push(...(urlResult.secretValues ?? []));
233
+ allResolvedKeys.push(...(urlResult.resolvedKeys ?? []));
217
234
 
218
235
  const headerResult = await opts.resolveKeys(rawHeaders);
219
236
  resolvedHeaders = headerResult.resolved;
220
237
  allUsedKeys.push(...headerResult.usedKeys);
221
238
  allSecretValues.push(...(headerResult.secretValues ?? []));
239
+ allResolvedKeys.push(...(headerResult.resolvedKeys ?? []));
222
240
 
223
241
  if (rawBody) {
224
242
  const bodyResult = await opts.resolveKeys(rawBody);
225
243
  resolvedBody = bodyResult.resolved;
226
244
  allUsedKeys.push(...bodyResult.usedKeys);
227
245
  allSecretValues.push(...(bodyResult.secretValues ?? []));
246
+ allResolvedKeys.push(...(bodyResult.resolvedKeys ?? []));
228
247
  }
229
248
  } catch (err: any) {
230
249
  return `Error resolving key references: ${err?.message ?? err}`;
231
250
  }
232
251
  }
233
252
  const secretValues = collectSecretValues(allSecretValues);
253
+ const resolvedKeys = allResolvedKeys.length
254
+ ? allResolvedKeys
255
+ : undefined;
234
256
 
235
257
  // Block SSRF targets regardless of key usage
236
258
  if (await isBlockedExtensionUrlWithDns(resolvedUrl)) {
@@ -240,7 +262,11 @@ export function createFetchToolEntry(
240
262
  // Validate URL against per-key allowlists
241
263
  if (opts.validateUrl && allUsedKeys.length > 0) {
242
264
  try {
243
- const allowed = await opts.validateUrl(resolvedUrl, allUsedKeys);
265
+ const allowed = await opts.validateUrl(
266
+ resolvedUrl,
267
+ allUsedKeys,
268
+ resolvedKeys,
269
+ );
244
270
  if (!allowed) {
245
271
  return `URL "${rawUrl}" is not in the allowlist for the referenced keys. Check your key settings.`;
246
272
  }
@@ -298,7 +324,11 @@ export function createFetchToolEntry(
298
324
  return "Redirect to private/internal address blocked.";
299
325
  }
300
326
  if (redirectUrl && opts.validateUrl && allUsedKeys.length > 0) {
301
- const allowed = await opts.validateUrl(redirectUrl, allUsedKeys);
327
+ const allowed = await opts.validateUrl(
328
+ redirectUrl,
329
+ allUsedKeys,
330
+ resolvedKeys,
331
+ );
302
332
  if (!allowed) {
303
333
  return "Redirect URL is not in the allowlist for the referenced keys.";
304
334
  }
@@ -29,7 +29,7 @@
29
29
 
30
30
  import { ssrfSafeFetch } from "../extensions/url-safety.js";
31
31
  import {
32
- resolveKeyReferences,
32
+ resolveKeyReferencesWithRequestScopes,
33
33
  validateUrlAllowlist,
34
34
  getKeyAllowlist,
35
35
  } from "../secrets/substitution.js";
@@ -215,21 +215,30 @@ async function resolveWebhookRequest(
215
215
  owner: string,
216
216
  label: string,
217
217
  ): Promise<{ url: string; headers: Record<string, string> }> {
218
- // Resolve `${keys.NAME}` references against the owner's user-scope secrets.
218
+ // Resolve `${keys.NAME}` references through the same request-scope
219
+ // cascade already used by extension fetches (extensions/routes.ts) and
220
+ // automation connector headers (automation/index.ts): user scope first
221
+ // (a personal override always wins), then the active org scope — where
222
+ // the Dispatch vault syncs workspace secrets — then workspace scope.
223
+ // Org/workspace vault rows are write-gated (org-admin + Dispatch vault
224
+ // UI), so reading them here is safe by default; this is unrelated to the
225
+ // opt-in-only user→workspace fallback in resolveKeyReferences (audit 05
226
+ // H2 in secrets/substitution.ts), which stays off. In headless contexts
227
+ // (e.g. a scheduled monitor check) getRequestOrgId() may be unset, in
228
+ // which case the cascade checks user + solo-workspace scopes only — a
229
+ // strict superset of the previous user-scope-only behavior.
219
230
  // Missing keys throw — the error surfaces in logs and the channel is marked
220
231
  // un-delivered, but other channels still run.
221
- const { resolved: url } = await resolveKeyReferences(
232
+ const { resolved: url } = await resolveKeyReferencesWithRequestScopes(
222
233
  urlTemplate,
223
- "user",
224
234
  owner,
225
235
  );
226
236
  const headers: Record<string, string> = {
227
237
  "Content-Type": "application/json",
228
238
  };
229
239
  if (authTemplate) {
230
- const { resolved: auth } = await resolveKeyReferences(
240
+ const { resolved: auth } = await resolveKeyReferencesWithRequestScopes(
231
241
  authTemplate,
232
- "user",
233
242
  owner,
234
243
  );
235
244
  headers.Authorization = auth;
@@ -237,6 +246,11 @@ async function resolveWebhookRequest(
237
246
 
238
247
  // If the user set an allowlist on a referenced key, enforce it here —
239
248
  // origin-level check, same rule the automations fetch-tool applies.
249
+ // NOTE: this still checks the allowlist at user scope only (unlike the
250
+ // fetch tool's validateUrl, which now consults getResolvedKeyAllowlist at
251
+ // the scope the key actually resolved at — see agent-chat-plugin.ts). A
252
+ // key that resolves at org/workspace scope with an allowlist configured
253
+ // on that org/workspace row will not have it enforced here.
240
254
  const keyNames = Array.from(
241
255
  new Set(
242
256
  Array.from(urlTemplate.matchAll(/\$\{keys\.([A-Za-z0-9_-]+)\}/g), (m) =>
@@ -14,15 +14,22 @@
14
14
  * `<APP_NAME>_SECRETS_ENCRYPTION_KEY` when set, then `SECRETS_ENCRYPTION_KEY`,
15
15
  * then `BETTER_AUTH_SECRET`. The app-scoped key lets a local multi-app
16
16
  * workspace read one app's encrypted production data without replacing the
17
- * shared local auth secret. In production we refuse to start without
18
- * configured key material a CWD-derived fallback would be effectively
19
- * static (e.g. `/var/task` on Lambda), so anyone with read access to the DB
20
- * could decrypt every secret.
17
+ * shared local auth secret. Hosted workspace deploys (`AGENT_NATIVE_WORKSPACE`)
18
+ * typically set none of those literal env vars per app; in that case the
19
+ * shared-key variant falls back further to material derived from the
20
+ * workspace-wide `A2A_SECRET` (see `getWorkspaceA2ADerivedSecret`), so sibling
21
+ * apps in the same workspace still land on the same shared key without any
22
+ * extra configuration. In production we refuse to start without configured
23
+ * key material — a CWD-derived fallback would be effectively static (e.g.
24
+ * `/var/task` on Lambda), so anyone with read access to the DB could decrypt
25
+ * every secret.
21
26
  *
22
27
  * Encrypted values are tagged `v1:<iv-hex>:<ct-hex>:<tag-hex>`. The `v1:` prefix
23
28
  * lets readers distinguish ciphertext from legacy plaintext during migration.
24
29
  */
25
30
 
31
+ import { getWorkspaceA2ADerivedSecret } from "../server/derived-secret.js";
32
+
26
33
  type NodeCryptoModule = typeof import("node:crypto");
27
34
 
28
35
  function getNodeCrypto(): NodeCryptoModule | undefined {
@@ -75,7 +82,11 @@ function appScopedEncryptionKey(): string | undefined {
75
82
 
76
83
  function sharedEncryptionKeyMaterial(): string | undefined {
77
84
  if (typeof process === "undefined") return undefined;
78
- return process.env.SECRETS_ENCRYPTION_KEY || process.env.BETTER_AUTH_SECRET;
85
+ return (
86
+ process.env.SECRETS_ENCRYPTION_KEY ||
87
+ process.env.BETTER_AUTH_SECRET ||
88
+ getWorkspaceA2ADerivedSecret("secrets-encryption")
89
+ );
79
90
  }
80
91
 
81
92
  function deriveSecretEncryptionKey(
@@ -129,17 +140,22 @@ export function getSecretEncryptionKey(): Buffer {
129
140
  * Derive the preferred workspace-shared key used by `app_secrets` rows.
130
141
  * Unlike generic column-level encryption, workspace vault data should decrypt
131
142
  * in sibling apps, so `SECRETS_ENCRYPTION_KEY` / `BETTER_AUTH_SECRET` take
132
- * precedence. The app-scoped key remains a compatibility fallback for
133
- * deployments that have not configured shared material yet; once the shared
134
- * key is configured, writes use it and reads still fall back to old rows.
143
+ * precedence. Hosted workspace deploys (`AGENT_NATIVE_WORKSPACE`) that set
144
+ * neither literal var still get stable shared material derived from the
145
+ * workspace-wide `A2A_SECRET` before falling further back. The app-scoped key
146
+ * remains a compatibility fallback for deployments that have not configured
147
+ * shared material yet; once the shared key is configured, writes use it and
148
+ * reads still fall back to old rows.
135
149
  */
136
150
  export function getSharedSecretEncryptionKey(): Buffer {
137
151
  return deriveSecretEncryptionKey(
138
152
  sharedEncryptionKeyMaterial() || appScopedEncryptionKey(),
139
153
  "[agent-native/secrets] Refusing to start in production without encryption key material for workspace secrets. " +
140
- "Set SECRETS_ENCRYPTION_KEY, BETTER_AUTH_SECRET, or the app-scoped *_SECRETS_ENCRYPTION_KEY in the deploy environment.",
154
+ "Set SECRETS_ENCRYPTION_KEY, BETTER_AUTH_SECRET, or the app-scoped *_SECRETS_ENCRYPTION_KEY in the deploy environment " +
155
+ "— or, on a hosted workspace deploy, ensure A2A_SECRET is set so shared material can be derived from it.",
141
156
  "[agent-native/secrets] SECRETS_ENCRYPTION_KEY not set — using app-scoped or machine-local fallback for workspace secrets. " +
142
- "Set SECRETS_ENCRYPTION_KEY or BETTER_AUTH_SECRET in every workspace app so sibling apps share vault rows.",
157
+ "Set SECRETS_ENCRYPTION_KEY or BETTER_AUTH_SECRET in every workspace app so sibling apps share vault rows, " +
158
+ "or rely on A2A_SECRET-derived material on hosted workspace deploys.",
143
159
  );
144
160
  }
145
161
 
@@ -163,8 +163,9 @@ export async function writeAppSecret(args: WriteSecretArgs): Promise<string> {
163
163
  const now = Date.now();
164
164
  // Dual-write during rollout: old readers continue using encrypted_value,
165
165
  // while new readers prefer the nullable shared ciphertext. An app-only
166
- // deployment leaves the shared column null; on an update, an existing
167
- // shared ciphertext is preserved by the upsert below.
166
+ // deployment leaves the shared column null; on an update, a writer without
167
+ // shared key material clears any existing shared ciphertext rather than
168
+ // preserving it (see the upsert SQL below for why).
168
169
  const encrypted = encryptLegacyValue(value);
169
170
  const sharedEncrypted = hasSharedSecretEncryptionKeyMaterial()
170
171
  ? encryptValue(value)
@@ -182,11 +183,23 @@ export async function writeAppSecret(args: WriteSecretArgs): Promise<string> {
182
183
  // keeps its original id (any stored references stay stable); only a
183
184
  // genuinely new row gets the freshly generated `id`. This syntax is
184
185
  // portable across SQLite (UPSERT since 3.24) and Postgres.
186
+ //
187
+ // shared_encrypted_value is overwritten with `excluded.shared_encrypted_value`
188
+ // (NULL when this writer lacks shared key material) rather than preserved
189
+ // via COALESCE. Preserving an existing shared ciphertext across a value
190
+ // update would let a sibling app silently decrypt a STALE value after the
191
+ // owner rotates it — a material-less writer has no way to produce the new
192
+ // shared ciphertext, so it must clear the old one instead of leaving it
193
+ // pointing at data that's no longer current. Siblings then get an honest
194
+ // cache miss (falling back to the legacy column or reporting missing) until
195
+ // the owning app's next read repopulates shared_encrypted_value via
196
+ // `populateSharedAppSecret`, which only ever fills a NULL column. A
197
+ // temporary miss is safer than serving rotated-away plaintext.
185
198
  const upsertSql = `INSERT INTO app_secrets (id, scope, scope_id, key, encrypted_value, shared_encrypted_value, description, url_allowlist, created_at, updated_at)
186
199
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
187
200
  ON CONFLICT (scope, scope_id, key) DO UPDATE SET
188
201
  encrypted_value = excluded.encrypted_value,
189
- shared_encrypted_value = COALESCE(excluded.shared_encrypted_value, app_secrets.shared_encrypted_value),
202
+ shared_encrypted_value = excluded.shared_encrypted_value,
190
203
  description = excluded.description,
191
204
  url_allowlist = excluded.url_allowlist,
192
205
  updated_at = excluded.updated_at`;
@@ -134,6 +134,7 @@ import {
134
134
  WORKSPACE_OWNER,
135
135
  } from "../resources/store.js";
136
136
  import { normalizeDatabaseToolsMode } from "../scripts/db/tool-mode.js";
137
+ import type { ResolvedKeyReference } from "../secrets/substitution.js";
137
138
  import { getSetting, putSetting } from "../settings/store.js";
138
139
  import {
139
140
  handleSharedThreadRequest,
@@ -323,6 +324,41 @@ const generateTitleRateLimit = new Map<string, number[]>();
323
324
  * so the common small-map case stays O(1). */
324
325
  const RATE_LIMIT_SWEEP_THRESHOLD = 1000;
325
326
 
327
+ /**
328
+ * Pick the URL allowlist to enforce for a `${keys.NAME}` reference used by
329
+ * the agent fetch tool.
330
+ *
331
+ * SECURITY (audit 05 H2 alignment): the allowlist check must stay
332
+ * consistent with the scope a key actually resolved at (see the
333
+ * `getKeyAllowlist` docstring in secrets/substitution.ts). Since the fetch
334
+ * tool now resolves keys via `resolveKeyReferencesWithRequestScopes` — which
335
+ * cascades user → org → workspace scope — a plain user-scope-only allowlist
336
+ * lookup would silently miss an allowlist configured on the org/workspace
337
+ * row that actually supplied the value. When `resolvedKeys` reports which
338
+ * scope a key resolved at, look up the allowlist there; only fall back to
339
+ * the legacy user-scope lookup for a key the resolver didn't report (should
340
+ * not normally happen — every key the cascade resolves reports a ref).
341
+ */
342
+ export async function resolveFetchToolKeyAllowlist(
343
+ keyName: string,
344
+ resolvedKeys: ResolvedKeyReference[] | undefined,
345
+ owner: string,
346
+ deps: {
347
+ getKeyAllowlist: (
348
+ name: string,
349
+ scope: "user",
350
+ scopeId: string,
351
+ ) => Promise<string[] | null>;
352
+ getResolvedKeyAllowlist: (
353
+ ref: ResolvedKeyReference,
354
+ ) => Promise<string[] | null>;
355
+ },
356
+ ): Promise<string[] | null> {
357
+ const ref = resolvedKeys?.find((candidate) => candidate.name === keyName);
358
+ if (ref) return deps.getResolvedKeyAllowlist(ref);
359
+ return deps.getKeyAllowlist(keyName, "user", owner);
360
+ }
361
+
326
362
  export function createAgentChatPlugin(
327
363
  options?: AgentChatPluginOptions,
328
364
  ): NitroPluginDef {
@@ -793,21 +829,38 @@ export function createAgentChatPlugin(
793
829
  try {
794
830
  const { createFetchToolEntry } =
795
831
  await import("../extensions/fetch-tool.js");
796
- const { resolveKeyReferences, validateUrlAllowlist, getKeyAllowlist } =
797
- await import("../secrets/substitution.js");
832
+ // Resolve `${keys.NAME}` through the same request-scope cascade
833
+ // already used by extension fetches (extensions/routes.ts) and
834
+ // automation connector headers (automation/index.ts): user scope
835
+ // first (personal overrides win), then the active org scope (the
836
+ // Dispatch vault syncs workspace secrets here), then workspace
837
+ // scope. Org/workspace vault rows are write-gated (org-admin +
838
+ // Dispatch vault UI), so this is safe to read by default — unlike
839
+ // the opt-in-only user→workspace fallback in resolveKeyReferences
840
+ // (see audit 05 H2 in secrets/substitution.ts), which stays off.
841
+ // Previously this tool only looked at scope "user", so a
842
+ // ${keys.NAME} reference to a key synced into the org/workspace
843
+ // vault could never resolve here even though the same key already
844
+ // worked for extension fetches and automations.
845
+ const {
846
+ resolveKeyReferencesWithRequestScopes,
847
+ validateUrlAllowlist,
848
+ getKeyAllowlist,
849
+ getResolvedKeyAllowlist,
850
+ } = await import("../secrets/substitution.js");
798
851
  fetchTool = createFetchToolEntry({
799
852
  resolveKeys: async (text) =>
800
- resolveKeyReferences(
853
+ resolveKeyReferencesWithRequestScopes(
801
854
  text,
802
- "user",
803
855
  requireCurrentRunOwner("resolve key references"),
804
856
  ),
805
- validateUrl: async (url, usedKeys) => {
857
+ validateUrl: async (url, usedKeys, resolvedKeys) => {
806
858
  for (const keyName of usedKeys) {
807
- const allowlist = await getKeyAllowlist(
859
+ const allowlist = await resolveFetchToolKeyAllowlist(
808
860
  keyName,
809
- "user",
861
+ resolvedKeys,
810
862
  requireCurrentRunOwner("validate URL allowlist"),
863
+ { getKeyAllowlist, getResolvedKeyAllowlist },
811
864
  );
812
865
  if (allowlist && !validateUrlAllowlist(url, allowlist)) {
813
866
  return false;
@@ -20,7 +20,11 @@ export function deriveServerSecret(
20
20
  }
21
21
 
22
22
  export function getWorkspaceA2ADerivedSecret(
23
- purpose: "better-auth" | "oauth-state" | "short-lived-token",
23
+ purpose:
24
+ | "better-auth"
25
+ | "oauth-state"
26
+ | "short-lived-token"
27
+ | "secrets-encryption",
24
28
  ): string | undefined {
25
29
  if (!isWorkspaceRuntime()) return undefined;
26
30
  const rootSecret = process.env.A2A_SECRET?.trim();
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-14
4
+ ---
5
+
6
+ Long-running cross-app analyses now continue reliably in the background and can use synced workspace provider credentials.
@@ -27,6 +27,9 @@ import {
27
27
  needsCorpusWorkflowForCoverageSensitiveRequest,
28
28
  needsSourceRecordBodyWorkflowForCoverageSensitiveRequest,
29
29
  } from "../lib/real-data-actions";
30
+
31
+ const ANALYTICS_BACKGROUND_RUN_SOFT_TIMEOUT_MS = 13 * 60_000;
32
+
30
33
  export const SIMPLE_TIME_BOUNDED_METRIC_FAST_PATH_GUIDANCE =
31
34
  "SIMPLE TIME-BOUNDED METRIC FAST PATH — When the data dictionary or a known canonical source identifies the metric, run one bounded aggregate. Once it returns a valid result, answer the explicit question immediately with the source, time window, row count, and only necessary caveats. Do not schema-discover, retry, enrich, cross-check, or add breakdowns after that successful result unless the query failed or the result conflicts with the known metric definition. This does not waive the real-data requirement: never answer from a guess, stale value, or unverified result. ";
32
35
 
@@ -271,10 +274,11 @@ export default createAgentChatPlugin({
271
274
  // Operators deploying to trusted internal environments can set
272
275
  // AGENT_PROD_CODE_EXECUTION=trusted to also enable bash/read/edit/write.
273
276
  codeExecution: { production: "sandboxed" },
274
- // Analytics uses the portable A2A processor until its dedicated background
275
- // worker is verified live. This explicit opt-out also protects production
276
- // from a stale deploy-wide AGENT_CHAT_DURABLE_BACKGROUND flag.
277
- durableBackgroundRuns: false,
277
+ // Long-running A2A analysis belongs on the durable worker so provider
278
+ // pagination, cross-source joins, and corpus reduction can outlive the
279
+ // standard serverless request budget without orphaning the task.
280
+ durableBackgroundRuns: true,
281
+ runSoftTimeoutMs: ANALYTICS_BACKGROUND_RUN_SOFT_TIMEOUT_MS,
278
282
  connectorCatalog: [...ANALYTICS_CONNECTOR_CATALOG],
279
283
  externalAgents: {
280
284
  // Keep the direct MCP surface deliberately curated. External agents