/** * Per-request context using AsyncLocalStorage. * * Replaces the unsafe pattern of mutating `process.env.AGENT_USER_EMAIL` / * `process.env.AGENT_ORG_ID` on every request. On Node.js (Netlify, self-hosted) * concurrent requests would overwrite each other's env vars. AsyncLocalStorage * gives each async call-chain its own isolated context. * * Supported on all deployment targets: * - Node.js (native) * - Cloudflare Workers (via nodejs_compat flag) * - Deno Deploy (via node:async_hooks compat) * * For CLI scripts that run outside a request context, the getters fall back to * process.env so existing `AGENT_USER_EMAIL=x pnpm action foo` invocations * continue to work. */ type AsyncLocalStorageLike = { getStore(): T | undefined; run(store: T, callback: () => R): R; }; type AsyncLocalStorageCtor = new () => AsyncLocalStorageLike; class StackAsyncLocalStorage implements AsyncLocalStorageLike { private readonly stack: T[] = []; getStore(): T | undefined { return this.stack.at(-1); } run(store: T, callback: () => R): R { this.stack.push(store); try { const result = callback(); const maybePromise = result as unknown as | { finally?: (callback: () => void) => unknown } | undefined; if (maybePromise && typeof maybePromise.finally === "function") { return maybePromise.finally(() => { this.stack.pop(); }) as R; } this.stack.pop(); return result; } catch (error) { this.stack.pop(); throw error; } } } function getAsyncLocalStorageCtor(): AsyncLocalStorageCtor | undefined { if ( typeof window !== "undefined" || typeof process === "undefined" || !process.versions?.node || typeof process.getBuiltinModule !== "function" ) { return undefined; } return process.getBuiltinModule("node:async_hooks")?.AsyncLocalStorage as | AsyncLocalStorageCtor | undefined; } const AsyncLocalStorageCtor = getAsyncLocalStorageCtor(); function processEnv(name: string): string | undefined { if (typeof process === "undefined") return undefined; return process.env?.[name]; } /** * Per-request agent-run state. Lives on `RequestContext.run` so the * agent-chat plugin can populate fields as the run progresses (owner, * resolved API key, system prompt, engine, model, threadId) without * mutating module-scope `let` bindings — those leak across concurrent * requests on a single Node.js process. * * Mutated in-place by `prepareRun`, `onEngineResolved`, `onRunStart` so * tool factory closures (automation, fetch, team, builder-browser) read * the live per-request value via `getRequestRunContext()`. */ export interface RequestRunContext { /** Request-scoped serverless continuation hook, when the runtime provides one. */ waitUntil?: (promise: Promise) => void; /** Origin of the current request (used by the builder-browser tool). */ requestOrigin?: string; /** Stable browser tab id for tab-scoped app-state reads/writes. */ browserTabId?: string; /** Resource scope for the current chat thread, e.g. the active deck. */ chatScope?: { type: string; id: string; label?: string; } | null; /** Resolved owner email (set by prepareRun). */ owner?: string; /** Owner's active Anthropic API key (set by prepareRun). */ userApiKey?: string; /** Thread ID for the current run (set by onRunStart). */ threadId?: string; /** System prompt actually sent to the model for this run. */ systemPrompt?: string; /** Engine instance for this run (set by onEngineResolved). */ engine?: import("../agent/engine/types.js").AgentEngine; /** Model name for this run (set by onEngineResolved). */ model?: string; /** * True when this run is executing inside the durable background-function * worker (the `_process-run` self-dispatch), not the synchronous foreground * request. Template `extraContext` / system-prompt builders can read this to * skip heavy, hang-prone enrichment (large data-dictionary DB reads, etc.) * in the worker so it reliably claims its run within the setup budget. */ isBackgroundWorker?: boolean; /** Tool calls made so far in the current agent loop. */ toolCalls?: Array<{ name: string; input: unknown }>; /** Tool results returned so far in the current agent loop. */ toolResults?: Array<{ name: string; content: string; isError: boolean }>; /** Per-run fingerprints for large extension bodies already sent to the LLM. */ extensionContentReads?: Record; /** Per-run fingerprints for repeated tool-search calls already sent to the LLM. */ toolSearchReads?: Record< string, { totalTools: number; resultNames: string[] } >; } export interface RequestContext { userEmail?: string; userName?: string; orgId?: string; timezone?: string; /** * Set when code reads authenticated request context. Public SSR shell/data * should not depend on this value; user/org-specific reads belong behind * client-side actions/API after hydration. */ authContextAccessed?: boolean; /** * Origin of the inbound request (e.g. `http://127.0.0.1:8100`). Set by the * MCP mount from the request headers so actions that build externally * fetchable URLs (e.g. design `export-coding-handoff`'s signed raw-code URL) * resolve the real local-workspace origin instead of a prod/localhost * fallback. Optional — absent on paths that don't populate it. */ requestOrigin?: string; /** * True when this request is being processed by an integration-platform * webhook (Slack, Telegram, etc.) where the function timeout is the * binding constraint. Code that calls slow remote APIs can use this to apply * tighter budgets on this path while leaving normal agent-chat callers * (5+ min budget) unaffected. */ isIntegrationCaller?: boolean; /** * Metadata for the currently-processing integration task. This lets tools * that start long-running remote work persist a continuation that can update * the originating platform thread after the current function budget ends. */ integration?: { taskId: string; attempts?: number; incoming: import("../integrations/types.js").IncomingMessage; placeholderRef?: string; /** Opaque provider-native progress surface for a durable continuation. */ progressRef?: import("../integrations/types.js").PlatformRunProgressRef; installationId?: string; scopeId?: string; principalType?: "user" | "service"; lineage?: { runId?: string; parentTaskId?: string; source?: { kind: string; platform?: string; id: string; url?: string; }; network?: { protocol: "a2a" | "mcp" | "provider-api"; id: string; peer?: string; }; }; }; /** * Mutable per-request agent-run state. Populated by the agent-chat plugin * during a run; tool closures dereference it on each invocation. */ run?: RequestRunContext; } const GLOBAL_KEY = "__agentNativeRequestContextAls" as const; const OBSERVERS_KEY = "__agentNativeRequestContextObservers" as const; const BOUNDARY_KEY = "__agentNativeRequestBoundaryInstalled" as const; type RequestContextObserver = (ctx: RequestContext) => void; type GlobalWithRequestContext = typeof globalThis & { [GLOBAL_KEY]?: AsyncLocalStorageLike; [OBSERVERS_KEY]?: RequestContextObserver[]; [BOUNDARY_KEY]?: boolean; }; const globalRef = globalThis as GlobalWithRequestContext; if (!globalRef[GLOBAL_KEY]) { globalRef[GLOBAL_KEY] = AsyncLocalStorageCtor ? new AsyncLocalStorageCtor() : new StackAsyncLocalStorage(); } if (!globalRef[OBSERVERS_KEY]) { globalRef[OBSERVERS_KEY] = []; } const als = globalRef[GLOBAL_KEY]!; const observers = globalRef[OBSERVERS_KEY]!; /** * Register a callback fired every time `runWithRequestContext` enters a new * scope. The hook runs INSIDE the AsyncLocalStorage scope, so observability * helpers that read the current isolation scope (e.g. Sentry) attach to the * right per-request context. * * Returned function unregisters the observer. Observers must never throw — * any error is swallowed so a misbehaving observer can't break the request * path. */ export function addRequestContextObserver( observer: RequestContextObserver, ): () => void { observers.push(observer); return () => { const i = observers.indexOf(observer); if (i !== -1) observers.splice(i, 1); }; } /** * Run a callback within a per-request context. The context is available to all * async operations spawned from `fn` via `getRequestUserEmail()` / `getRequestOrgId()`. * * Any registered `addRequestContextObserver` callbacks fire inside the new * scope before `fn` runs, so observability code can pin user/org info onto * isolation-scoped backends (Sentry, OpenTelemetry, etc.). */ export function runWithRequestContext( ctx: RequestContext, fn: () => T | Promise, ): T | Promise { return als.run(ctx, () => { if (observers.length > 0) { for (const obs of observers) { try { obs(ctx); } catch { // Observers must never break the request path. } } } return fn(); }); } /** * Return the active request context, if this call chain is running under one. * * This is intentionally distinct from `getRequestUserEmail()`: callers that * have an active context with no authenticated user must not fall through to * process-wide CLI fallbacks such as `AGENT_USER_EMAIL` or "latest session". */ export function getRequestContext(): RequestContext | undefined { const store = als.getStore(); markAuthContextAccess(store); return store; } /** * True when AsyncLocalStorage has an active context for this call chain. * Useful for helpers that support both HTTP requests and standalone CLI runs. */ export function hasRequestContext(): boolean { return als.getStore() !== undefined; } /** * Record that the framework's request-boundary middleware is installed in this * process, so every inbound HTTP request runs inside a `RequestContext`. * * Once that is true, a request-scoped identity read that finds no store can no * longer be an HTTP caller — which is what makes the ambient-identity warning * in `getRequestUserEmail()` specific enough to be worth emitting. */ export function markRequestBoundaryInstalled(): void { globalRef[BOUNDARY_KEY] = true; } export function hasRequestBoundary(): boolean { return globalRef[BOUNDARY_KEY] === true; } /** * The ambient, process-wide identity configured for this deployment * (`AGENT_USER_EMAIL`). Legitimate callers are the ones with no request behind * them at all: CLI invocations, cron/scheduled jobs, seed and QA scripts. * * TRAP: this is not the caller's identity, and a request handler that reads it * authorizes whoever the deploy env names rather than whoever signed in — it * fails open toward more privilege. Request handlers read * `getRequestUserEmail()` and fail closed when it returns undefined. */ export function getAmbientUserEmail(): string | undefined { return processEnv("AGENT_USER_EMAIL"); } /** Ambient process-wide org (`AGENT_ORG_ID`). Same trap as `getAmbientUserEmail()`. */ export function getAmbientOrgId(): string | undefined { return processEnv("AGENT_ORG_ID"); } const warnedAmbientIdentities = new Set(); function warnAmbientIdentitySatisfiedRead(email: string): void { if (!hasRequestBoundary()) return; if (warnedAmbientIdentities.has(email)) return; warnedAmbientIdentities.add(email); console.warn( `[agent-native] getRequestUserEmail() found no request context and answered with the ambient ` + `AGENT_USER_EMAIL identity (${email}). This process serves HTTP requests, so a request-scoped ` + `read reaching the ambient identity is a bug: it authorizes the deploy env, not the signed-in ` + `user. Wrap the caller in runWithRequestContext({ userEmail }), or call getAmbientUserEmail() ` + `explicitly if the process identity really is what you mean.`, ); } /** * Get the current request's user email. * * - If a request context exists (HTTP/A2A path), returns its `userEmail` — * even when that value is `undefined`. The env fallback MUST NOT fire here: * a stale process-wide `AGENT_USER_EMAIL` from a CLI run or previous bug * would leak into an unauthenticated A2A/API call (e.g. unsigned or API-key * modes where `runWithRequestContext({ userEmail: undefined })` is used). * - Only when there is NO request context (CLI scripts) do we fall back to * `process.env.AGENT_USER_EMAIL`. In a process that serves HTTP requests the * framework installs a request boundary so that case cannot be a request; * if it happens anyway we warn loudly rather than answer silently. */ export function getRequestUserEmail(): string | undefined { const store = als.getStore(); if (store !== undefined) { if (store.userEmail) markAuthContextAccess(store); return store.userEmail; } const ambient = processEnv("AGENT_USER_EMAIL"); if (ambient) warnAmbientIdentitySatisfiedRead(ambient); return ambient; } /** * Get the current request's display name, when the auth provider supplied one. * * The same request-context fallback rules as `getRequestUserEmail()` apply: * HTTP/A2A calls only read AsyncLocalStorage, while CLI scripts may opt in via * `AGENT_USER_NAME`. */ export function getRequestUserName(): string | undefined { const store = als.getStore(); if (store !== undefined) { if (store.userName) markAuthContextAccess(store); return store.userName; } return processEnv("AGENT_USER_NAME"); } /** * Get the current request's org ID. * * Same store-aware semantics as `getRequestUserEmail()` — env fallback is * CLI-only, so a request that explicitly has no org doesn't inherit a stale * `process.env.AGENT_ORG_ID` from a prior request on the same Lambda instance. */ export function getRequestOrgId(): string | undefined { const store = als.getStore(); if (store !== undefined) { if (store.orgId) markAuthContextAccess(store); return store.orgId; } return processEnv("AGENT_ORG_ID"); } function markAuthContextAccess(ctx: RequestContext | undefined) { if (!ctx) return; if (ctx.userEmail || ctx.userName || ctx.orgId) { ctx.authContextAccessed = true; } } export function hasAuthContextAccess(ctx: RequestContext | undefined): boolean { return Boolean(ctx?.authContextAccessed); } /** * Get the current request's IANA timezone (e.g. "America/Los_Angeles"). * The UI sends this via the `x-user-timezone` header on every action call, and * the agent chat plugin propagates it into the request context so that * agent-initiated tool calls also see the user's timezone. Falls back to * `process.env.AGENT_USER_TIMEZONE` only for CLI scripts (no request context). */ export function getRequestTimezone(): string | undefined { const store = als.getStore(); if (store !== undefined) return store.timezone; return processEnv("AGENT_USER_TIMEZONE"); } /** * Returns true when this request is on an integration-platform path (Slack, * Telegram, etc.) — i.e. we're inside the integration plugin's processor * function and the platform's deliver-by deadline plus the host's function * timeout are the binding budget. Non-integration callers (CLI, normal * agent chat) should treat this as `false`. */ export function isIntegrationCallerRequest(): boolean { return als.getStore()?.isIntegrationCaller === true; } export function getIntegrationRequestContext(): | NonNullable | undefined { return als.getStore()?.integration; } /** * Convenience: returns `{ userEmail, orgId }` from the active request context, * suitable for passing to `resolveCredential(key, ctx)`. Returns `null` when * no user is associated with the call (e.g. an unauthenticated public route). * * For framework actions auto-mounted at `/_agent-native/actions/...` this is * always populated because action-routes wraps every invocation in * `runWithRequestContext`. For hand-written `/api/*` routes the calling code * is responsible for setting up the context (see `runWithRequestContext`). */ export function getCredentialContext(): { userEmail: string; orgId: string | null; } | null { const userEmail = getRequestUserEmail(); if (!userEmail) return null; return { userEmail, orgId: getRequestOrgId() ?? null }; } /** * Get the active request's mutable agent-run state. Returns `undefined` when * called outside an agent run (e.g. before `prepareRun` or in a non-agent * code path). Callers must tolerate the field absence; use the helper * `requireRequestRunContext()` if missing context is a programming error. */ export function getRequestRunContext(): RequestRunContext | undefined { const store = als.getStore(); if (!store) return undefined; return store.run; } /** * Ensure a `RequestRunContext` exists on the active request store and * return it. Used by the agent-chat handler to attach run state once it * starts processing a chat request. Returns `undefined` if there is no * active request store (caller should not be invoking this outside ALS). */ export function ensureRequestRunContext(): RequestRunContext | undefined { const store = als.getStore(); if (!store) return undefined; if (!store.run) store.run = {}; return store.run; }