@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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +18 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/credentials/index.ts +41 -8
- package/corpus/core/src/extensions/fetch-tool.ts +33 -3
- package/corpus/core/src/notifications/channels.ts +20 -6
- package/corpus/core/src/secrets/crypto.ts +26 -10
- package/corpus/core/src/secrets/storage.ts +16 -3
- package/corpus/core/src/server/agent-chat-plugin.ts +60 -7
- package/corpus/core/src/server/derived-secret.ts +5 -1
- package/corpus/templates/analytics/changelog/2026-07-14-long-running-cross-app-analyses-now-continue-reliably-in-the.md +6 -0
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +8 -4
- package/corpus/templates/clips/app/components/player/share-dialog.tsx +44 -81
- package/corpus/templates/clips/app/i18n/ar-SA.ts +0 -8
- package/corpus/templates/clips/app/i18n/de-DE.ts +0 -9
- package/corpus/templates/clips/app/i18n/en-US.ts +0 -8
- package/corpus/templates/clips/app/i18n/es-ES.ts +0 -9
- package/corpus/templates/clips/app/i18n/fr-FR.ts +0 -9
- package/corpus/templates/clips/app/i18n/hi-IN.ts +0 -8
- package/corpus/templates/clips/app/i18n/ja-JP.ts +0 -9
- package/corpus/templates/clips/app/i18n/ko-KR.ts +0 -9
- package/corpus/templates/clips/app/i18n/pt-BR.ts +0 -9
- package/corpus/templates/clips/app/i18n/zh-CN.ts +0 -7
- package/corpus/templates/clips/app/i18n/zh-TW.ts +0 -7
- package/corpus/templates/clips/changelog/2026-07-14-share-dialogs-show-separate-links-for-people-and-agents.md +6 -0
- package/corpus/templates/dispatch/changelog/2026-07-14-dispatch-now-distinguishes-connected-agents-from-locally-mou.md +6 -0
- package/dist/credentials/index.d.ts +12 -7
- package/dist/credentials/index.d.ts.map +1 -1
- package/dist/credentials/index.js +33 -8
- package/dist/credentials/index.js.map +1 -1
- package/dist/extensions/fetch-tool.d.ts +13 -1
- package/dist/extensions/fetch-tool.d.ts.map +1 -1
- package/dist/extensions/fetch-tool.js +9 -2
- package/dist/extensions/fetch-tool.js.map +1 -1
- package/dist/notifications/channels.js +20 -4
- package/dist/notifications/channels.js.map +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/crypto.d.ts +15 -7
- package/dist/secrets/crypto.d.ts.map +1 -1
- package/dist/secrets/crypto.js +23 -10
- package/dist/secrets/crypto.js.map +1 -1
- package/dist/secrets/storage.d.ts.map +1 -1
- package/dist/secrets/storage.js +16 -3
- package/dist/secrets/storage.js.map +1 -1
- package/dist/server/agent-chat-plugin.d.ts +20 -0
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +38 -4
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/dist/server/derived-secret.d.ts +1 -1
- package/dist/server/derived-secret.d.ts.map +1 -1
- package/dist/server/derived-secret.js.map +1 -1
- package/package.json +2 -2
- package/corpus/templates/clips/app/components/sharing/slack-share-hint.tsx +0 -68
package/corpus/README.md
CHANGED
package/corpus/core/CHANGELOG.md
CHANGED
|
@@ -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
|
package/corpus/core/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.101.
|
|
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
|
|
65
|
-
*
|
|
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.
|
|
69
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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?: (
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
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.
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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,
|
|
167
|
-
// shared
|
|
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 =
|
|
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
|
-
|
|
797
|
-
|
|
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
|
-
|
|
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
|
|
859
|
+
const allowlist = await resolveFetchToolKeyAllowlist(
|
|
808
860
|
keyName,
|
|
809
|
-
|
|
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:
|
|
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();
|
|
@@ -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
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
durableBackgroundRuns:
|
|
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
|