@agent-native/core 0.145.0 → 0.145.2

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 (72) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/brain/server/lib/capture-sanitization.ts +6 -2
  3. package/corpus/templates/clips/changelog/2026-08-07-full-screen-recordings-on-a-multi-monitor-mac-now-let-you-pi.md +6 -0
  4. package/corpus/templates/clips/chrome-extension/src/styles.css +13 -4
  5. package/corpus/templates/clips/desktop/src/app.tsx +38 -1
  6. package/corpus/templates/clips/desktop/src/components/MediaDeviceRow.tsx +123 -104
  7. package/corpus/templates/clips/desktop/src/lib/recorder.ts +165 -2
  8. package/corpus/templates/clips/desktop/src/main.tsx +3 -0
  9. package/corpus/templates/clips/desktop/src/overlays/monitor-picker.tsx +70 -0
  10. package/corpus/templates/clips/desktop/src/styles.css +70 -11
  11. package/corpus/templates/clips/desktop/src-tauri/capabilities/default.json +2 -1
  12. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +163 -1
  13. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +6 -1
  14. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +141 -22
  15. package/corpus/templates/clips/desktop/src-tauri/src/state.rs +27 -1
  16. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +12 -1
  17. package/dist/agent/engine/index.d.ts +1 -1
  18. package/dist/agent/engine/index.js +1 -1
  19. package/dist/agent/engine/registry.d.ts +15 -0
  20. package/dist/agent/engine/registry.js +39 -10
  21. package/dist/agent/production-agent.d.ts +40 -1
  22. package/dist/agent/production-agent.js +81 -40
  23. package/dist/agent/run-manager.d.ts +44 -1
  24. package/dist/agent/run-manager.js +70 -8
  25. package/dist/chat-threads/store.js +6 -4
  26. package/dist/client/use-db-sync.js +9 -1
  27. package/dist/db/ddl-guard.d.ts +8 -0
  28. package/dist/db/ddl-guard.js +148 -0
  29. package/dist/deploy/build.d.ts +36 -0
  30. package/dist/deploy/build.js +78 -5
  31. package/dist/integrations/webhook-handler.d.ts +3 -2
  32. package/dist/integrations/webhook-handler.js +8 -38
  33. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  34. package/dist/notifications/routes.d.ts +3 -3
  35. package/dist/observability/routes.d.ts +5 -5
  36. package/dist/org/accept-pending.js +2 -2
  37. package/dist/org/auth-policy.js +4 -0
  38. package/dist/org/auto-join-domain.d.ts +9 -0
  39. package/dist/org/auto-join-domain.js +53 -4
  40. package/dist/org/context.js +12 -3
  41. package/dist/org/handlers.js +17 -5
  42. package/dist/org/request-org-cache.d.ts +24 -2
  43. package/dist/org/request-org-cache.js +57 -2
  44. package/dist/provider-api/actions/custom-provider-registration.d.ts +4 -4
  45. package/dist/provider-api/actions/provider-api.d.ts +6 -6
  46. package/dist/resources/store.d.ts +1 -1
  47. package/dist/resources/store.js +96 -7
  48. package/dist/secrets/routes.d.ts +6 -6
  49. package/dist/server/agent-chat-plugin.js +83 -37
  50. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  51. package/dist/server/auth.d.ts +3 -0
  52. package/dist/server/auth.js +22 -1
  53. package/dist/server/collab-plugin.js +10 -4
  54. package/dist/server/complete-text.js +14 -7
  55. package/dist/server/core-routes-plugin.js +3 -0
  56. package/dist/server/index.d.ts +1 -1
  57. package/dist/server/index.js +1 -1
  58. package/dist/server/request-context.d.ts +7 -1
  59. package/dist/server/session-email-cache.d.ts +14 -0
  60. package/dist/server/session-email-cache.js +48 -0
  61. package/dist/server/sweep-runtime.d.ts +22 -0
  62. package/dist/server/sweep-runtime.js +28 -0
  63. package/dist/server/transcribe-voice.d.ts +1 -1
  64. package/dist/settings/store.js +63 -7
  65. package/dist/shared/ttl-cache.d.ts +59 -0
  66. package/dist/shared/ttl-cache.js +43 -0
  67. package/dist/sharing/actions/list-resource-shares.d.ts +3 -3
  68. package/dist/sharing/actions/list-resource-shares.js +4 -1
  69. package/dist/triggers/condition-evaluator.js +11 -27
  70. package/docs/content/deployment.mdx +48 -0
  71. package/docs/content/environment-variables.mdx +11 -0
  72. package/package.json +1 -1
@@ -4,6 +4,7 @@ import { ensureColumnExists, ensureIndexExists, ensureTableExists, } from "../db
4
4
  import { widenIntColumnsToBigInt } from "../db/widen-columns.js";
5
5
  import { canUseLocalWorkspaceResourcePath, deleteLocalWorkspaceResource, isLocalWorkspaceResourceId, isLocalWorkspaceResourcesEnabled, listLocalWorkspaceResources, localWorkspaceResourcePathFromId, readLocalWorkspaceResource, writeLocalWorkspaceResource, } from "../local-artifacts/index.js";
6
6
  import { getRequestOrgId, getRequestUserEmail, } from "../server/request-context.js";
7
+ import { getSetting, putSetting, } from "../settings/store.js";
7
8
  import { emitResourceChange, emitResourceDelete } from "./emitter.js";
8
9
  export const SHARED_OWNER = "__shared__";
9
10
  export const WORKSPACE_OWNER = "__workspace__";
@@ -570,14 +571,34 @@ async function grantedWorkspaceResourceByPath(path, options) {
570
571
  return null;
571
572
  }
572
573
  }
573
- async function cleanupExpiredAgentScratchResources(client) {
574
+ /**
575
+ * Expire agent scratch resources, WITHOUT blocking the read that triggered it.
576
+ *
577
+ * This is garbage collection, not part of any read's answer: a caller asking for
578
+ * one resource does not need yesterday's expired scratch rows gone first. Awaiting
579
+ * it made every read path issue a `DELETE` before its own `SELECT` — double the
580
+ * round trips, unservable by a replica, taking row locks inside a request a user
581
+ * is waiting on, and (since nothing caught it) able to fail the read outright.
582
+ *
583
+ * The throttle is a module-scope timestamp, so it starts at 0 in a fresh isolate
584
+ * and the first resource read after every cold start still triggers one sweep.
585
+ * That is why the index below exists, and why this must not be awaited.
586
+ */
587
+ function scheduleExpiredAgentScratchCleanup(client) {
574
588
  const now = Date.now();
575
589
  if (now - _lastScratchCleanupAt < SCRATCH_CLEANUP_INTERVAL_MS)
576
590
  return;
577
591
  _lastScratchCleanupAt = now;
578
- await client.execute({
592
+ void client
593
+ .execute({
579
594
  sql: `DELETE FROM resources WHERE visibility = ? AND expires_at IS NOT NULL AND expires_at <= ?`,
580
595
  args: ["agent_scratch", now],
596
+ })
597
+ .catch((err) => {
598
+ // Say so rather than swallowing: scratch rows accumulating forever is a
599
+ // slow leak nobody would otherwise notice.
600
+ // coercion-ok: failed GC degrades storage over time, never read correctness
601
+ console.warn("[resources] expired agent-scratch cleanup failed; will retry on a later read:", err?.message ?? err);
581
602
  });
582
603
  }
583
604
  async function ensureTable() {
@@ -655,7 +676,7 @@ async function _doEnsureTable() {
655
676
  "updated_at",
656
677
  "expires_at",
657
678
  ]);
658
- // `cleanupExpiredAgentScratchResources` filters on exactly these two columns
679
+ // `scheduleExpiredAgentScratchCleanup` filters on exactly these two columns
659
680
  // and, without this, full-scans `resources` — a table shared by every
660
681
  // template and read from agent discovery, docs, chat scratch and remote-agent
661
682
  // manifests. Its 60s throttle is a module-scope timestamp that starts at 0 in
@@ -669,7 +690,20 @@ async function _doEnsureTable() {
669
690
  // coercion-ok: absence of an index degrades latency, never correctness
670
691
  console.warn("[resources] could not ensure resources_visibility_expires_idx; scratch cleanup will full-scan:", err?.message ?? err);
671
692
  });
672
- // Seed default shared resources if they don't exist (INSERT OR IGNORE to avoid race conditions)
693
+ // Seed default shared resources if they don't exist (INSERT OR IGNORE to avoid
694
+ // race conditions).
695
+ //
696
+ // Guarded by a durable marker: `_doEnsureTable` runs once per PROCESS, which on
697
+ // serverless is once per cold start, so this block was issuing ~10 writes and
698
+ // 2 migration scans per container to insert rows that had existed since day
699
+ // one (53,785 of them in one production sample). The marker makes it once per
700
+ // database, matching what the comments here already assumed.
701
+ //
702
+ // Consequence worth knowing: a default resource the user DELETES is no longer
703
+ // recreated on the next cold start. That silent resurrection was never
704
+ // intended — see `RESOURCE_SEED_VERSION` to force a re-seed.
705
+ if (await alreadySeeded(SHARED_SEED_KEY))
706
+ return;
673
707
  const now = Date.now();
674
708
  const seedSql = isPostgres()
675
709
  ? `INSERT INTO resources (id, path, owner, content, mime_type, size, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (path, owner) DO NOTHING`
@@ -797,8 +831,54 @@ async function _doEnsureTable() {
797
831
  catch {
798
832
  // Migration best-effort
799
833
  }
834
+ await markSeeded(SHARED_SEED_KEY);
800
835
  }
836
+ /**
837
+ * Bump when a default resource is ADDED, or when existing seed content must
838
+ * reach databases that already ran an earlier seed.
839
+ *
840
+ * The marker below records which version a database has been seeded at, so the
841
+ * seed runs once per database instead of once per process. Without a version, a
842
+ * newly added default would never reach an already-seeded deployment.
843
+ */
844
+ const RESOURCE_SEED_VERSION = 1;
845
+ /**
846
+ * Per-process memo ON TOP of the durable marker, so a warm process doesn't even
847
+ * pay the marker read. The durable marker is what makes this correct across cold
848
+ * starts; this only avoids a repeat lookup within one process.
849
+ */
801
850
  const _personalSeeded = new Set();
851
+ function personalSeedKey(owner) {
852
+ return `resources-seeded:personal:v${RESOURCE_SEED_VERSION}:${owner.toLowerCase()}`;
853
+ }
854
+ const SHARED_SEED_KEY = `resources-seeded:shared:v${RESOURCE_SEED_VERSION}`;
855
+ /**
856
+ * Has this database already been seeded at the current version?
857
+ *
858
+ * A failed marker read returns `false` — "seed again" — because the seeds are
859
+ * `ON CONFLICT DO NOTHING` and therefore idempotent. Guessing "already seeded"
860
+ * on an unreadable marker would skip seeding a fresh database and leave it
861
+ * without its default AGENTS.md / LEARNINGS.md, which is not recoverable by
862
+ * retrying a read.
863
+ */
864
+ async function alreadySeeded(key) {
865
+ try {
866
+ const row = await getSetting(key);
867
+ return row?.at !== undefined;
868
+ }
869
+ catch {
870
+ // coercion-ok: "not seeded" is the SAFE answer, and it is not indistinguishable from success — it re-runs an idempotent ON CONFLICT DO NOTHING seed, exactly the pre-marker behaviour. Guessing "already seeded" would leave a fresh database permanently without its default AGENTS.md / LEARNINGS.md, which no retry recovers.
871
+ return false;
872
+ }
873
+ }
874
+ async function markSeeded(key) {
875
+ try {
876
+ await putSetting(key, { at: Date.now() });
877
+ }
878
+ catch {
879
+ // coercion-ok: the marker is an optimization, not state anything reads for correctness. Failing to write it costs one repeat idempotent seed on the next cold start — the pre-marker behaviour — and the seed itself already succeeded, so there is no failure to report to the caller.
880
+ }
881
+ }
802
882
  /**
803
883
  * Seed personal AGENTS.md and LEARNINGS.md for a user if they don't exist.
804
884
  * Called when listing resources or from the agent chat plugin.
@@ -810,6 +890,14 @@ export async function ensurePersonalDefaults(owner) {
810
890
  return;
811
891
  }
812
892
  await ensureTable();
893
+ // The in-memory Set resets on every cold start, so on serverless this seed ran
894
+ // its 5 writes per container per owner, forever. The durable marker is what
895
+ // actually makes it once-per-owner.
896
+ const seedKey = personalSeedKey(owner);
897
+ if (await alreadySeeded(seedKey)) {
898
+ _personalSeeded.add(owner);
899
+ return;
900
+ }
813
901
  const client = getDbExec();
814
902
  const now = Date.now();
815
903
  const seedSql = isPostgres()
@@ -886,6 +974,7 @@ export async function ensurePersonalDefaults(owner) {
886
974
  // retries instead of permanently skipping seeding. Seeds use INSERT OR IGNORE
887
975
  // / ON CONFLICT DO NOTHING, so a concurrent re-run is harmless.
888
976
  _personalSeeded.add(owner);
977
+ await markSeeded(seedKey);
889
978
  }
890
979
  function rowToResource(row) {
891
980
  return {
@@ -932,7 +1021,7 @@ export async function resourceGet(id, options) {
932
1021
  return localWorkspaceResourceById(id);
933
1022
  }
934
1023
  const client = getDbExec();
935
- await cleanupExpiredAgentScratchResources(client);
1024
+ scheduleExpiredAgentScratchCleanup(client);
936
1025
  const { rows } = await client.execute({
937
1026
  sql: `SELECT * FROM resources WHERE id = ?`,
938
1027
  args: [id],
@@ -949,7 +1038,7 @@ export async function resourceGetByPath(owner, path, options) {
949
1038
  return local;
950
1039
  }
951
1040
  const client = getDbExec();
952
- await cleanupExpiredAgentScratchResources(client);
1041
+ scheduleExpiredAgentScratchCleanup(client);
953
1042
  const { rows } = await client.execute({
954
1043
  sql: `SELECT * FROM resources WHERE owner = ? AND path = ?`,
955
1044
  args: [owner, path],
@@ -1165,7 +1254,7 @@ export async function resourceDeleteByPath(owner, path) {
1165
1254
  export async function resourceList(owner, pathPrefix, options) {
1166
1255
  await ensureTable();
1167
1256
  const client = getDbExec();
1168
- await cleanupExpiredAgentScratchResources(client);
1257
+ scheduleExpiredAgentScratchCleanup(client);
1169
1258
  const visibilitySql = scratchFilterSql(options);
1170
1259
  if (pathPrefix) {
1171
1260
  const { rows } = await client.execute({
@@ -37,17 +37,17 @@ export declare function createWriteSecretHandler(): import("h3").EventHandlerWit
37
37
  ok?: undefined;
38
38
  status?: undefined;
39
39
  } | {
40
+ error?: undefined;
40
41
  ok: boolean;
41
42
  status: string;
42
- error?: undefined;
43
43
  } | {
44
44
  ok?: undefined;
45
45
  error: string;
46
46
  removed?: undefined;
47
47
  } | {
48
+ error?: undefined;
48
49
  ok: boolean;
49
50
  removed: boolean;
50
- error?: undefined;
51
51
  }>>;
52
52
  /**
53
53
  * POST /_agent-native/secrets/:key/test — validate an optional candidate value
@@ -58,13 +58,13 @@ export declare function createTestSecretHandler(): import("h3").EventHandlerWith
58
58
  error: string;
59
59
  note?: undefined;
60
60
  } | {
61
+ error?: undefined;
61
62
  ok: boolean;
62
63
  note?: undefined;
63
- error?: undefined;
64
64
  } | {
65
+ error?: undefined;
65
66
  ok: boolean;
66
67
  note: string;
67
- error?: undefined;
68
68
  } | {
69
69
  note?: undefined;
70
70
  ok: boolean;
@@ -95,11 +95,11 @@ export interface AdHocSecretPayload {
95
95
  export declare function createAdHocSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<AdHocSecretPayload[] | {
96
96
  error: string;
97
97
  } | {
98
+ error?: undefined;
98
99
  ok: boolean;
99
100
  key: string;
100
- error?: undefined;
101
101
  } | {
102
+ error?: undefined;
102
103
  ok: boolean;
103
104
  removed: boolean;
104
- error?: undefined;
105
105
  }>>;
@@ -11,6 +11,7 @@ import { DEFAULT_ANTHROPIC_MODEL } from "../agent/default-model.js";
11
11
  import { AGENT_CHAT_BACKGROUND_RUN_FIELD, AGENT_CHAT_PROCESS_RUN_PATH, backgroundRunMarkerExpectsBackgroundRuntime, isInBackgroundFunctionRuntime, prepareProcessRunRequest, } from "../agent/durable-background.js";
12
12
  import { resolveEngine, createAnthropicEngine, getStoredModelForEngine, normalizeModelForEngine, resolveDelegatedRunModel, getAgentEngineEntry, isAgentEnginePackageInstalled, isStoredEngineUsableForRequest, listAgentEngines, registerBuiltinEngines, } from "../agent/engine/index.js";
13
13
  import { SYSTEM_PROMPT_CACHE_SPLIT } from "../agent/engine/prompt-cache.js";
14
+ import { PROVIDER_TO_ENV } from "../agent/engine/provider-env-vars.js";
14
15
  import { createProductionAgentHandler, actionsToEngineTools, executeAgentToolCall, toolCallCacheKey, getActiveRunForThreadAsync, abortRunDurably, abortTurnDurably, subscribeToRun, } from "../agent/production-agent.js";
15
16
  import { callerHasRunAccess, callerHasThreadAccess, } from "../agent/run-ownership.js";
16
17
  import { markTurnAborted, readBackgroundRunClaim } from "../agent/run-store.js";
@@ -117,6 +118,7 @@ import { isLocalhost, shouldBlockInProductCodeEditingSurface, } from "./agent-ch
117
118
  import { loadRunCodeToolEntries } from "./agent-chat/run-code-tools.js";
118
119
  import { createAgentEngineScriptEntries, createAgentLoopSettingsScriptEntries, createCallAgentScriptEntry, createChatScriptEntries, createDbScriptEntries, createDocsScriptEntries, createResourceScriptEntries, } from "./agent-chat/script-entries.js";
119
120
  import { parseSkillFrontmatter } from "./agent-chat/skill-frontmatter.js";
121
+ import { shouldDisableInProcessSweeps } from "./sweep-runtime.js";
120
122
  export { loadResourcesForPrompt };
121
123
  export { _agentChatPromptSectionsForTests };
122
124
  export { buildPublicAgentA2ASkills };
@@ -1239,8 +1241,12 @@ export function createAgentChatPlugin(options) {
1239
1241
  }
1240
1242
  // Use the SAME agent setup as the interactive chat — identical tools,
1241
1243
  // prompt, and capabilities. The A2A agent IS the app's agent.
1242
- const { getOwnerActiveApiKey } = await import("../agent/production-agent.js");
1243
- const ownerApiKey = await getOwnerActiveApiKey(userEmail);
1244
+ const { resolveOwnerEngineApiKey } = await import("../agent/production-agent.js");
1245
+ const { apiKey: ownerApiKey, apiKeyEnvVar: ownerApiKeyEnvVar } = await resolveOwnerEngineApiKey({
1246
+ engineOption: options?.engine,
1247
+ ownerEmail: userEmail,
1248
+ anthropicFallback: options?.apiKey,
1249
+ });
1244
1250
  // A2A runs are reconstructed in a fresh processor request, so they
1245
1251
  // do not pass through the interactive handler's prepareRun hook.
1246
1252
  // Seed the same mutable run context before resolving the engine and
@@ -1253,6 +1259,7 @@ export function createAgentChatPlugin(options) {
1253
1259
  if (a2aRunContext) {
1254
1260
  a2aRunContext.owner = userEmail;
1255
1261
  a2aRunContext.userApiKey = ownerApiKey;
1262
+ a2aRunContext.userApiKeyEnvVar = ownerApiKeyEnvVar;
1256
1263
  // The async processor restores the original request origin from
1257
1264
  // task metadata. Only derive a fallback for synchronous A2A calls
1258
1265
  // where the inbound event is still the caller request.
@@ -1273,7 +1280,8 @@ export function createAgentChatPlugin(options) {
1273
1280
  }
1274
1281
  const a2aEngine = await resolveEngine({
1275
1282
  engineOption: options?.engine,
1276
- apiKey: ownerApiKey ?? options?.apiKey,
1283
+ apiKey: ownerApiKey,
1284
+ apiKeyEnvVar: ownerApiKeyEnvVar,
1277
1285
  appId: options?.appId,
1278
1286
  });
1279
1287
  const devActive = isDevMode();
@@ -1683,13 +1691,27 @@ export function createAgentChatPlugin(options) {
1683
1691
  askAgent: async (message) => {
1684
1692
  const ownerEmail = getRequestUserEmail();
1685
1693
  const mcpRunId = crypto.randomUUID();
1686
- const { getOwnerActiveApiKey } = await import("../agent/production-agent.js");
1687
- const ownerApiKey = ownerEmail
1688
- ? await getOwnerActiveApiKey(ownerEmail)
1689
- : undefined;
1694
+ const { resolveOwnerEngineApiKey } = await import("../agent/production-agent.js");
1695
+ const ownerApiKey = await resolveOwnerEngineApiKey({
1696
+ engineOption: options?.engine,
1697
+ ownerEmail,
1698
+ anthropicFallback: options?.apiKey,
1699
+ });
1700
+ // `ask_app` runs outside the interactive handler, so nothing seeds
1701
+ // the run context for it — and `onEngineResolved` never fires on
1702
+ // this path either. Without the resolved key and its provenance
1703
+ // here, an agent-team sub-agent spawned from an MCP run falls back
1704
+ // to the plugin host key while the parent bills the owner's BYO
1705
+ // credential. Same reason the A2A branch above seeds it.
1706
+ const mcpRunContext = ensureRequestRunContext();
1707
+ if (mcpRunContext) {
1708
+ mcpRunContext.userApiKey = ownerApiKey.apiKey;
1709
+ mcpRunContext.userApiKeyEnvVar = ownerApiKey.apiKeyEnvVar;
1710
+ }
1690
1711
  const mcpEngine = await resolveEngine({
1691
1712
  engineOption: options?.engine,
1692
- apiKey: ownerApiKey ?? options?.apiKey,
1713
+ apiKey: ownerApiKey.apiKey,
1714
+ apiKeyEnvVar: ownerApiKey.apiKeyEnvVar,
1693
1715
  appId: options?.appId,
1694
1716
  });
1695
1717
  const mcpModelCandidate = options?.model ??
@@ -2179,12 +2201,19 @@ export function createAgentChatPlugin(options) {
2179
2201
  getActions: buildSubAgentActions,
2180
2202
  getEngine: () => {
2181
2203
  const runCtx = getRequestRunContext();
2204
+ // Sub-agents must inherit the parent run's resolved key so
2205
+ // delegations spawned by agent-teams don't silently fall back to
2206
+ // the platform key while the parent uses BYO credentials. This
2207
+ // fallback engine is Anthropic, so a key the parent resolved for
2208
+ // another provider is not inheritable — passing it anyway sends a
2209
+ // live OpenAI/Gemini secret to Anthropic's endpoint.
2210
+ const inheritableKey = runCtx?.userApiKeyEnvVar === undefined ||
2211
+ runCtx.userApiKeyEnvVar === "ANTHROPIC_API_KEY"
2212
+ ? runCtx?.userApiKey
2213
+ : undefined;
2182
2214
  return (runCtx?.engine ??
2183
2215
  createAnthropicEngine({
2184
- // Sub-agents must inherit the parent run's resolved key so
2185
- // delegations spawned by agent-teams don't silently fall back
2186
- // to the platform key while the parent uses BYO credentials.
2187
- apiKey: runCtx?.userApiKey ?? options?.apiKey,
2216
+ apiKey: inheritableKey ?? options?.apiKey,
2188
2217
  }));
2189
2218
  },
2190
2219
  getModel: () => getRequestRunContext()?.model ?? resolvedModel,
@@ -2341,13 +2370,17 @@ export function createAgentChatPlugin(options) {
2341
2370
  // content is what the token-saving modes strip.
2342
2371
  const prepareRun = async (event) => {
2343
2372
  const owner = await getOwnerFromEvent(event);
2344
- const { getOwnerActiveApiKey } = await import("../agent/production-agent.js");
2345
- const userApiKey = await getOwnerActiveApiKey(owner);
2373
+ const { resolveOwnerEngineApiKey } = await import("../agent/production-agent.js");
2374
+ const userApiKey = await resolveOwnerEngineApiKey({
2375
+ engineOption: options?.engine,
2376
+ ownerEmail: owner,
2377
+ });
2346
2378
  const runCtx = ensureRequestRunContext();
2347
2379
  if (runCtx) {
2348
2380
  runCtx.requestOrigin = getOrigin(event);
2349
2381
  runCtx.owner = owner;
2350
- runCtx.userApiKey = userApiKey;
2382
+ runCtx.userApiKey = userApiKey.apiKey;
2383
+ runCtx.userApiKeyEnvVar = userApiKey.apiKeyEnvVar;
2351
2384
  }
2352
2385
  const extra = await resolveExtraContext(event, owner);
2353
2386
  return { owner, extra };
@@ -2937,21 +2970,28 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su
2937
2970
  resolveConfig: async ({ payload, ownerEmail, orgId: _orgId }) => {
2938
2971
  // Resolve the owner's API key so BYO-key sub-agents use the
2939
2972
  // same credentials as the parent chat.
2940
- let apiKey;
2973
+ let resolvedKey = {
2974
+ apiKey: undefined,
2975
+ apiKeyEnvVar: undefined,
2976
+ };
2941
2977
  try {
2942
- const { getOwnerActiveApiKey } = await import("../agent/production-agent.js");
2943
- apiKey =
2944
- (await getOwnerActiveApiKey(ownerEmail)) ?? undefined;
2978
+ const { resolveOwnerEngineApiKey } = await import("../agent/production-agent.js");
2979
+ resolvedKey = await resolveOwnerEngineApiKey({
2980
+ engineOption: options?.engine,
2981
+ ownerEmail,
2982
+ anthropicFallback: options?.apiKey,
2983
+ });
2945
2984
  }
2946
2985
  catch {
2947
- apiKey = undefined;
2986
+ resolvedKey = { apiKey: undefined, apiKeyEnvVar: undefined };
2948
2987
  }
2949
2988
  // Use the same resolveEngine path as the A2A and MCP
2950
2989
  // processors so Builder-gateway/OpenAI users get their
2951
2990
  // configured engine instead of always hitting the Anthropic SDK.
2952
2991
  const engine = await resolveEngine({
2953
2992
  engineOption: options?.engine,
2954
- apiKey: apiKey ?? options?.apiKey,
2993
+ apiKey: resolvedKey.apiKey,
2994
+ apiKeyEnvVar: resolvedKey.apiKeyEnvVar,
2955
2995
  appId: options?.appId,
2956
2996
  });
2957
2997
  const modelCandidate = payload.model ??
@@ -3147,15 +3187,7 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su
3147
3187
  setResponseStatus(event, 401);
3148
3188
  return { error: "Authentication required" };
3149
3189
  }
3150
- const providerToEnv = {
3151
- anthropic: "ANTHROPIC_API_KEY",
3152
- openai: "OPENAI_API_KEY",
3153
- google: "GOOGLE_GENERATIVE_AI_API_KEY",
3154
- groq: "GROQ_API_KEY",
3155
- mistral: "MISTRAL_API_KEY",
3156
- cohere: "COHERE_API_KEY",
3157
- };
3158
- const secretKey = providerToEnv[provider] ?? `${provider.toUpperCase()}_API_KEY`;
3190
+ const secretKey = PROVIDER_TO_ENV[provider] ?? `${provider.toUpperCase()}_API_KEY`;
3159
3191
  try {
3160
3192
  const { writeAppSecret } = await import("../secrets/storage.js");
3161
3193
  await writeAppSecret({
@@ -3685,9 +3717,12 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su
3685
3717
  .trim();
3686
3718
  // Mirror the chat-run resolution so BYO-key users have title
3687
3719
  // generation billed to their own key instead of the platform key.
3688
- const { getOwnerActiveApiKey } = await import("../agent/production-agent.js");
3689
- const userApiKey = await getOwnerActiveApiKey(ownerEmail);
3690
- const apiKey = userApiKey;
3720
+ // This request goes straight to Anthropic, so it needs the owner's
3721
+ // Anthropic key specifically — the active engine may be another
3722
+ // provider, whose key must never be sent here. Owners without one
3723
+ // get the truncated title.
3724
+ const { getOwnerApiKeyForEngine } = await import("../agent/production-agent.js");
3725
+ const { apiKey } = await getOwnerApiKeyForEngine("anthropic", ownerEmail);
3691
3726
  if (!apiKey) {
3692
3727
  // Fallback: truncate the message
3693
3728
  return { title: cleanMessage.trim().slice(0, 60) };
@@ -4735,6 +4770,17 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su
4735
4770
  }));
4736
4771
  const isBackgroundRuntime = isInBackgroundFunctionRuntime();
4737
4772
  const disableRecurringJobsRuntime = isBackgroundRuntime || shouldDisableRecurringJobsRuntime();
4773
+ // Opt-in kill switch for the backstop sweep timers below. On serverless
4774
+ // these are billed per warm container, so their query rate scales with
4775
+ // instance count rather than with anything happening. See
4776
+ // `shouldDisableInProcessSweeps`.
4777
+ const sweepsDisabled = shouldDisableInProcessSweeps();
4778
+ if (sweepsDisabled) {
4779
+ console.log("[agent-native] In-process backstop sweeps disabled " +
4780
+ "(AGENT_NATIVE_DISABLE_INPROCESS_SWEEPS). A durable scheduler must " +
4781
+ "drive automation redispatch, agent-teams reconciliation, and " +
4782
+ "sandbox-execution recovery, or queued work will not be retried.");
4783
+ }
4738
4784
  // ─── Recurring Jobs Scheduler ──────────────────────────────────────
4739
4785
  // Poll every 60 seconds for due recurring jobs and execute them.
4740
4786
  // Uses setInterval so it works in all deployment environments without
@@ -4832,7 +4878,7 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su
4832
4878
  // A non-Netlify self-dispatch only waits for the request to leave the
4833
4879
  // current invocation. Keep the durable history row as a retryable queue
4834
4880
  // so a frozen serverless handoff is recovered on the next sweep.
4835
- if (!isBackgroundRuntime) {
4881
+ if (!isBackgroundRuntime && !sweepsDisabled) {
4836
4882
  (() => {
4837
4883
  let inFlight = false;
4838
4884
  const sweep = async () => {
@@ -4864,7 +4910,7 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su
4864
4910
  // per instance; cheap (one indexed query when no active tasks are found).
4865
4911
  // Throttled by the same per-owner interval guard inside reconcileAgentTeamRunsForOwner.
4866
4912
  (() => {
4867
- if (isBackgroundRuntime)
4913
+ if (isBackgroundRuntime || sweepsDisabled)
4868
4914
  return;
4869
4915
  // Track when this instance last ran the sweep so only one sweep fires
4870
4916
  // per 2-min window even if multiple timers fire in overlapping invocations.
@@ -5013,7 +5059,7 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su
5013
5059
  // comment above for why this exists and the timing budget in
5014
5060
  // run-store.ts's `UNCLAIMED_BACKGROUND_RUN_FAST_SWEEP_MS` doc comment.
5015
5061
  (() => {
5016
- if (isBackgroundRuntime)
5062
+ if (isBackgroundRuntime || sweepsDisabled)
5017
5063
  return;
5018
5064
  setTimeout(() => {
5019
5065
  (async () => {
@@ -5056,7 +5102,7 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su
5056
5102
  // still fails loud, and it survives the fast sweep having missed a row
5057
5103
  // entirely (e.g. a restart landed between fast-sweep ticks).
5058
5104
  (() => {
5059
- if (isBackgroundRuntime)
5105
+ if (isBackgroundRuntime || sweepsDisabled)
5060
5106
  return;
5061
5107
  let lastSweep = 0;
5062
5108
  const SWEEP_INTERVAL_MS = 2 * 60 * 1000;
@@ -27,10 +27,10 @@ export declare function resolveAgentEngineApiKeyWriteTarget(event: H3Event, scop
27
27
  export declare function createAgentEngineApiKeyHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
28
28
  error: any;
29
29
  } | {
30
+ error?: undefined;
30
31
  ok: boolean;
31
32
  key: string;
32
33
  baseUrlKey?: string;
33
34
  scope: AgentEngineApiKeyScope;
34
- error?: undefined;
35
35
  }>>;
36
36
  export {};
@@ -226,6 +226,9 @@ export declare function removeSession(token: string): Promise<void>;
226
226
  /**
227
227
  * Look up the email associated with a legacy session token.
228
228
  * Returns null if the session doesn't exist, is expired, or has no email.
229
+ *
230
+ * Resolutions are cached across requests — see `session-email-cache.ts` for the
231
+ * TTL and the only-cache-successes rule.
229
232
  */
230
233
  export declare function getSessionEmail(token: string): Promise<string | null>;
231
234
  export interface DesktopExchangeErrorPayload {
@@ -71,6 +71,7 @@ import { isIdentitySsoEnabled } from "./identity-sso-store.js";
71
71
  import { safeOAuthReturnUrl } from "./oauth-return-url.js";
72
72
  import { getOnboardingHtml, getResetPasswordHtml, } from "./onboarding-html.js";
73
73
  import { captureAuthError } from "./sentry.js";
74
+ import { forgetCachedSessionEmail, getCachedSessionEmail, invalidateSessionEmailCache, setCachedSessionEmail, } from "./session-email-cache.js";
74
75
  import { isWorkspaceOAuthCallbackRelayEnabled } from "./workspace-oauth.js";
75
76
  /**
76
77
  * Get the configured session max age. Desktop SSO broker writes from
@@ -741,6 +742,9 @@ export async function addSession(token, email) {
741
742
  : `INSERT OR REPLACE INTO sessions (token, email, created_at) VALUES (?, ?, ?)`,
742
743
  args: [token, email ?? null, Date.now()],
743
744
  }));
745
+ // The upsert can REBIND an existing token to a different email, so a cached
746
+ // resolution for it is now wrong.
747
+ invalidateSessionEmailCache();
744
748
  }
745
749
  export async function hasLegacySessionForEmail(email) {
746
750
  await ensureSessionTable();
@@ -759,12 +763,20 @@ export async function removeSession(token) {
759
763
  sql: `DELETE FROM sessions WHERE token = ?`,
760
764
  args: [token],
761
765
  }));
766
+ // Sign-out must take effect immediately, not after the cache TTL.
767
+ invalidateSessionEmailCache();
762
768
  }
763
769
  /**
764
770
  * Look up the email associated with a legacy session token.
765
771
  * Returns null if the session doesn't exist, is expired, or has no email.
772
+ *
773
+ * Resolutions are cached across requests — see `session-email-cache.ts` for the
774
+ * TTL and the only-cache-successes rule.
766
775
  */
767
776
  export async function getSessionEmail(token) {
777
+ const cached = getCachedSessionEmail(token);
778
+ if (cached !== undefined)
779
+ return cached;
768
780
  await ensureSessionTable();
769
781
  const client = getDbExec();
770
782
  const { rows } = await retryIfSessionsMissing(() => client.execute({
@@ -779,9 +791,13 @@ export async function getSessionEmail(token) {
779
791
  sql: `DELETE FROM sessions WHERE token = ?`,
780
792
  args: [token],
781
793
  });
794
+ forgetCachedSessionEmail(token);
782
795
  return null;
783
796
  }
784
- return rows[0].email ?? null;
797
+ const email = rows[0].email ?? null;
798
+ if (email)
799
+ setCachedSessionEmail(token, email);
800
+ return email;
785
801
  }
786
802
  // ---------------------------------------------------------------------------
787
803
  // getSession — the auth contract
@@ -937,6 +953,7 @@ async function consumeDesktopExchangeFromDB(flowId) {
937
953
  sql: `DELETE FROM sessions WHERE token = ? AND created_at > ? RETURNING email`,
938
954
  args: [`dex:${flowId}`, Date.now() - DESKTOP_EXCHANGE_TTL_MS],
939
955
  });
956
+ forgetCachedSessionEmail(`dex:${flowId}`);
940
957
  if (rows.length === 0)
941
958
  return null;
942
959
  const packed = (rows[0].email ?? rows[0][0]);
@@ -2706,6 +2723,9 @@ async function mountBetterAuthRoutes(app, options) {
2706
2723
  args: [userEmail],
2707
2724
  });
2708
2725
  }
2726
+ // Deleted by email, so the removed tokens are unknown here — the
2727
+ // whole cache goes.
2728
+ invalidateSessionEmailCache();
2709
2729
  }
2710
2730
  }
2711
2731
  catch {
@@ -2939,6 +2959,7 @@ async function mountBetterAuthRoutes(app, options) {
2939
2959
  catch {
2940
2960
  // Best-effort.
2941
2961
  }
2962
+ invalidateSessionEmailCache();
2942
2963
  // 3. Drop the current request's cookie and best-effort sign out
2943
2964
  // of Better Auth (so the response sets the proper expiry header).
2944
2965
  clearFrameworkSessionCookies(event);
@@ -211,8 +211,12 @@ export function createCollabPlugin(options = {}) {
211
211
  (action === "json" && method === "POST") ||
212
212
  (action === "patch" && method === "POST");
213
213
  if (isWrite) {
214
- // assertAccess throws ForbiddenError (→ 403) if no editor access
215
- const access = await assertAccess(resourceType, resourceId, "editor");
214
+ // assertAccess throws ForbiddenError (→ 403) if no editor access.
215
+ // Projected: only ownerEmail/orgId are read below, and a collab
216
+ // resource's body is the largest column it has — loading it here
217
+ // means every keystroke-driven update read the whole document
218
+ // twice, once for the ACL and once for the edit itself.
219
+ const access = await assertAccess(resourceType, resourceId, "editor", undefined, { skipResourceBody: true });
216
220
  const resource = access.resource;
217
221
  const awarenessScope = {
218
222
  resourceType,
@@ -229,8 +233,10 @@ export function createCollabPlugin(options = {}) {
229
233
  }
230
234
  }
231
235
  else {
232
- // resolveAccess returns null when no access; return 404 to avoid leaking existence
233
- const access = await resolveAccess(resourceType, resourceId);
236
+ // resolveAccess returns null when no access; return 404 to avoid leaking existence.
237
+ // Projected: only ownerEmail/orgId are read below, and a collab
238
+ // resource's body is the largest column it has.
239
+ const access = await resolveAccess(resourceType, resourceId, undefined, { skipResourceBody: true });
234
240
  if (!access) {
235
241
  setResponseStatus(event, 404);
236
242
  return { error: "Not found" };
@@ -1,6 +1,6 @@
1
1
  import { getStoredModelForEngine, normalizeModelForEngine, registerBuiltinEngines, resolveEngine, } from "../agent/engine/index.js";
2
2
  import { EngineError } from "../agent/engine/types.js";
3
- import { getOwnerActiveApiKey } from "../agent/production-agent.js";
3
+ import { resolveOwnerEngineApiKey, } from "../agent/production-agent.js";
4
4
  import { getRequestUserEmail } from "./request-context.js";
5
5
  function normalizeCompleteTextMessages(messages, input) {
6
6
  const normalized = [];
@@ -56,14 +56,20 @@ function createCompletionAbortSignal(signal, timeoutMs) {
56
56
  },
57
57
  };
58
58
  }
59
- async function resolveCompletionApiKey(explicitApiKey) {
60
- if (explicitApiKey)
61
- return explicitApiKey;
59
+ async function resolveCompletionApiKey(options) {
60
+ // A caller-supplied key is opaque: `CompleteTextOptions.apiKey` names no
61
+ // provider, so it keeps the caller's contract and is passed through untagged.
62
+ if (options.apiKey) {
63
+ return { apiKey: options.apiKey, apiKeyEnvVar: undefined };
64
+ }
62
65
  try {
63
- return await getOwnerActiveApiKey(getRequestUserEmail());
66
+ return await resolveOwnerEngineApiKey({
67
+ engineOption: options.engine,
68
+ ownerEmail: getRequestUserEmail(),
69
+ });
64
70
  }
65
71
  catch {
66
- return undefined;
72
+ return { apiKey: undefined, apiKeyEnvVar: undefined };
67
73
  }
68
74
  }
69
75
  /**
@@ -75,10 +81,11 @@ async function resolveCompletionApiKey(explicitApiKey) {
75
81
  */
76
82
  export async function completeText(options) {
77
83
  registerBuiltinEngines();
78
- const apiKey = await resolveCompletionApiKey(options.apiKey);
84
+ const { apiKey, apiKeyEnvVar } = await resolveCompletionApiKey(options);
79
85
  const engine = await resolveEngine({
80
86
  engineOption: options.engine,
81
87
  apiKey,
88
+ apiKeyEnvVar,
82
89
  model: options.model,
83
90
  appId: options.appId,
84
91
  });