diff --git a/.changeset/request-action-surfaces.md b/.changeset/request-action-surfaces.md new file mode 100644 index 0000000000..3af4cdfd2f --- /dev/null +++ b/.changeset/request-action-surfaces.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": minor +--- + +Add request-scoped action allowlists for interactive agent chat. diff --git a/packages/core/docs/content/agent-surfaces.mdx b/packages/core/docs/content/agent-surfaces.mdx index 716f586f86..a99bc79579 100644 --- a/packages/core/docs/content/agent-surfaces.mdx +++ b/packages/core/docs/content/agent-surfaces.mdx @@ -167,6 +167,36 @@ React widget, use [Generative UI](/docs/generative-ui): it renders sandboxed Alpine/Tailwind UI inline, can read app state and slot context, and can send selected values back to chat. +### Request-scoped action surfaces + +Use `resolveActionSurface` when the selected agent or thread must expose only a +server-authorized subset of native actions. The callback runs for every +interactive chat request after `prepareRequest`. Returned names form a hard +allowlist for that request: omitted actions are absent from provider schemas, +the execution registry, plan-mode preloading, and `tool-search` discovery. + +```ts +createAgentChatPlugin({ + actions, + nativeActionsInDev: true, + resolveActionSurface: async ({ threadId, availableActionNames }) => ({ + allowedActionNames: await loadAllowedActions( + threadId, + availableActionNames, + ), + }), +}); +``` + +An empty list exposes no native actions. Unknown names fail the request instead +of widening access. Every allowed action is loaded directly on the first model +request; include `tool-search` explicitly only when discovery inside the +already-authorized catalog is wanted. The callback scopes interactive agent +chat only—it does not change HTTP, MCP, A2A, job, or trigger exposure. Default +framework guidance and spawned sub-agents inherit the same surface. Trusted +shell tools are automatically reduced to the request-filtered sandbox because +an unrestricted shell cannot enforce a hard action boundary. + ## Native inline UI {#native-inline-ui} Use this when your actions return structured data — a list of records, a chart dataset, a status summary — that should render as a real UI component inside the chat thread rather than a plain text description. You define a `chatUI` renderer on the action, and Agent-Native renders it as a first-party React component: no iframes, no separate rendering path. diff --git a/packages/core/docs/content/locales/ar-SA/agent-surfaces.mdx b/packages/core/docs/content/locales/ar-SA/agent-surfaces.mdx index 66ff9d0fe7..bcb03dde6a 100644 --- a/packages/core/docs/content/locales/ar-SA/agent-surfaces.mdx +++ b/packages/core/docs/content/locales/ar-SA/agent-surfaces.mdx @@ -166,6 +166,35 @@ React محدد مسبقًا، استخدم [واجهة المستخدم التو Alpine/Tailwind المحمية مضمّنةً، ويمكنها قراءة حالة التطبيق وسياق الفتحة، وإرسال القيم المحددة مرة أخرى إلى المحادثة. +### واجهات إجراءات على مستوى الطلب + +استخدم `resolveActionSurface` عندما يجب أن يعرض الوكيل أو سلسلة المحادثة +المحددة مجموعة فرعية فقط من الإجراءات الأصلية التي اعتمدها الخادم. يُشغّل +الاستدعاء لكل طلب محادثة تفاعلي بعد `prepareRequest`. تشكّل الأسماء المعادة +قائمة سماح صارمة: لا تظهر الإجراءات المحذوفة في مخططات المزوّد أو سجل التنفيذ +أو التحميل المسبق لوضع Plan أو نتائج بحث `tool-search`. + +```ts +createAgentChatPlugin({ + actions, + nativeActionsInDev: true, + resolveActionSurface: async ({ threadId, availableActionNames }) => ({ + allowedActionNames: await loadAllowedActions( + threadId, + availableActionNames, + ), + }), +}); +``` + +لا تعرض القائمة الفارغة أي إجراءات أصلية. تؤدي الأسماء غير المعروفة إلى فشل +الطلب بدلاً من توسيع الوصول. تُحمّل جميع الإجراءات المسموح بها مباشرة في أول +طلب للنموذج؛ أضف `tool-search` صراحةً فقط للبحث داخل الفهرس المعتمد مسبقاً. +يقيّد الاستدعاء محادثة الوكيل التفاعلية فقط ولا يغيّر HTTP أو MCP أو A2A أو +المهام أو المشغلات. ترث إرشادات الإطار الافتراضية والوكلاء الفرعيون المشغّلون +السطح نفسه. تُخفّض أدوات shell الموثوقة تلقائياً إلى sandbox المصفّى حسب الطلب، +لأن shell غير المقيّد لا يمكنه فرض حد صارم للإجراءات. + ## واجهة مستخدم مضمّنة أصلية {#native-inline-ui} استخدم هذا عندما تُرجع إجراءاتك بيانات منظمة — قائمة سجلات، أو مجموعة بيانات مخطط، أو ملخص حالة — يجب أن تُعرض كمكوّن واجهة مستخدم حقيقي داخل خيط المحادثة بدلاً من وصف نصي بسيط. تُعرّف مُصيِّر `chatUI` على الإجراء، ويُعرضه Agent-Native كمكوّن React من الدرجة الأولى: بدون iframes، بدون مسار عرض منفصل. diff --git a/packages/core/docs/content/locales/de-DE/agent-surfaces.mdx b/packages/core/docs/content/locales/de-DE/agent-surfaces.mdx index b5a21b86bc..81d3741970 100644 --- a/packages/core/docs/content/locales/de-DE/agent-surfaces.mdx +++ b/packages/core/docs/content/locales/de-DE/agent-surfaces.mdx @@ -168,6 +168,39 @@ React-Widgets benötigt, verwenden Sie [Generative UI](/docs/generative-ui): Es Alpine/Tailwind-UI inline, kann App-Zustand und Slot-Kontext lesen und ausgewählte Werte an den Chat zurückschicken. +### Request-bezogene Action-Oberflächen + +Verwende `resolveActionSurface`, wenn der ausgewählte Agent oder Thread nur +eine serverseitig autorisierte Teilmenge nativer Actions sehen darf. Der +Callback läuft für jede interaktive Chat-Anfrage nach `prepareRequest`. Die +zurückgegebenen Namen bilden eine harte Allowlist: Nicht enthaltene Actions +fehlen in Provider-Schemas, Ausführungs-Registry, Plan-Modus-Vorladung und der +Suche über `tool-search`. + +```ts +createAgentChatPlugin({ + actions, + nativeActionsInDev: true, + resolveActionSurface: async ({ threadId, availableActionNames }) => ({ + allowedActionNames: await loadAllowedActions( + threadId, + availableActionNames, + ), + }), +}); +``` + +Eine leere Liste stellt keine nativen Actions bereit. Unbekannte Namen lassen +die Anfrage fehlschlagen, statt den Zugriff zu erweitern. Alle erlaubten +Actions werden direkt mit der ersten Modellanfrage geladen; `tool-search` wird +nur ausdrücklich hinzugefügt, wenn Suche innerhalb des bereits autorisierten +Katalogs gewünscht ist. Der Callback begrenzt ausschließlich den interaktiven +Agentenchat, nicht HTTP, MCP, A2A, Jobs oder Trigger. In der lokalen Entwicklung +übernehmen Standard-Framework-Hinweise und gestartete Sub-Agenten dieselbe +Oberfläche. Vertrauenswürdige Shell-Tools werden automatisch auf die +anfragegefilterte Sandbox reduziert, da eine unbeschränkte Shell keine harte +Action-Grenze durchsetzen kann. + ## Native Inline-UI {#native-inline-ui} Verwenden Sie diese, wenn Ihre Aktionen strukturierte Daten zurückgeben — eine Liste von Einträgen, einen Diagramm-Datensatz, eine Statuszusammenfassung — die als echte UI-Komponente im Chat-Thread gerendert werden sollen, anstatt als reine Textbeschreibung. Sie definieren einen `chatUI`-Renderer auf der Aktion, und Agent-Native rendert ihn als First-Party-React-Komponente: keine iFrames, kein separater Rendering-Pfad. diff --git a/packages/core/docs/content/locales/es-ES/agent-surfaces.mdx b/packages/core/docs/content/locales/es-ES/agent-surfaces.mdx index 506b76bccc..8fb64017b0 100644 --- a/packages/core/docs/content/locales/es-ES/agent-surfaces.mdx +++ b/packages/core/docs/content/locales/es-ES/agent-surfaces.mdx @@ -170,6 +170,39 @@ widget React predefinido, usa [Generative UI](/docs/generative-ui): renderiza in aislada en línea, puede leer el estado de la aplicación y el contexto del slot, y puede enviar los valores seleccionados de vuelta al chat. +### Superficies de acciones por solicitud + +Usa `resolveActionSurface` cuando el agente o hilo seleccionado deba exponer +solo un subconjunto de acciones nativas autorizado por el servidor. El callback +se ejecuta en cada solicitud de chat interactivo después de `prepareRequest`. +Los nombres devueltos forman una lista permitida estricta: las acciones omitidas +no aparecen en los esquemas del proveedor, el registro de ejecución, la +precarga del modo Plan ni la búsqueda de `tool-search`. + +```ts +createAgentChatPlugin({ + actions, + nativeActionsInDev: true, + resolveActionSurface: async ({ threadId, availableActionNames }) => ({ + allowedActionNames: await loadAllowedActions( + threadId, + availableActionNames, + ), + }), +}); +``` + +Una lista vacía no expone acciones nativas. Los nombres desconocidos hacen +fallar la solicitud en vez de ampliar el acceso. Todas las acciones permitidas +se cargan directamente en la primera solicitud al modelo; incluye +`tool-search` de forma explícita solo si deseas descubrir herramientas dentro +del catálogo ya autorizado. El callback solo limita el chat interactivo, no +HTTP, MCP, A2A, jobs ni triggers. En desarrollo local, usa acciones nativas y +las instrucciones predeterminadas del framework y los subagentes iniciados +heredan la misma superficie. Las herramientas shell de confianza se reducen +automáticamente al sandbox filtrado por solicitud, porque una shell sin +restricciones no puede imponer una frontera estricta de acciones. + ## Interfaz de usuario nativa integrada {#native-inline-ui} Úsala cuando tus acciones devuelvan datos estructurados — una lista de registros, un conjunto de datos de gráfico, un resumen de estado — que deban renderizarse como un componente de interfaz de usuario real dentro del hilo de chat en lugar de una descripción de texto simple. Defines un renderizador `chatUI` en la acción, y Agent-Native lo renderiza como un componente React de primera clase: sin iframes, sin ruta de renderizado separada. diff --git a/packages/core/docs/content/locales/fr-FR/agent-surfaces.mdx b/packages/core/docs/content/locales/fr-FR/agent-surfaces.mdx index 0622df9a6a..1901e3219d 100644 --- a/packages/core/docs/content/locales/fr-FR/agent-surfaces.mdx +++ b/packages/core/docs/content/locales/fr-FR/agent-surfaces.mdx @@ -169,6 +169,38 @@ widget React prédéfini, utilisez [Generative UI](/docs/generative-ui) : il aff dans un bac à sable intégré, peut lire l'état de l'application et le contexte de l'emplacement, et peut renvoyer les valeurs sélectionnées vers le chat. +### Surfaces d’actions limitées à la requête + +Utilisez `resolveActionSurface` lorsque l’agent ou le fil sélectionné ne doit +exposer qu’un sous-ensemble d’actions natives autorisé par le serveur. Le +callback s’exécute pour chaque requête de chat interactif après +`prepareRequest`. Les noms retournés forment une liste d’autorisation stricte : +les actions omises sont absentes des schémas du fournisseur, du registre +d’exécution, du préchargement du mode Plan et de la recherche `tool-search`. + +```ts +createAgentChatPlugin({ + actions, + nativeActionsInDev: true, + resolveActionSurface: async ({ threadId, availableActionNames }) => ({ + allowedActionNames: await loadAllowedActions( + threadId, + availableActionNames, + ), + }), +}); +``` + +Une liste vide n’expose aucune action native. Un nom inconnu fait échouer la +requête au lieu d’élargir l’accès. Toutes les actions autorisées sont chargées +dès la première requête au modèle ; ajoutez explicitement `tool-search` +uniquement pour rechercher dans le catalogue déjà autorisé. Le callback limite +seulement le chat interactif, pas HTTP, MCP, A2A, les jobs ou les triggers. En +outre, les consignes par défaut du framework et les sous-agents lancés héritent +de la même surface. Les outils shell de confiance sont automatiquement ramenés +au bac à sable filtré par requête, car un shell sans restriction ne peut pas +appliquer une frontière d’actions stricte. + ## Interface utilisateur native intégrée {#native-inline-ui} Utilisez cette surface lorsque vos actions renvoient des données structurées — une liste d'enregistrements, un jeu de données de graphique, un résumé d'état — qui doivent s'afficher en tant que vrai composant d'interface dans le fil de chat plutôt qu'une simple description textuelle. Vous définissez un renderer `chatUI` sur l'action, et Agent-Native l'affiche en tant que composant React de premier niveau : pas d'iframe, pas de chemin de rendu séparé. diff --git a/packages/core/docs/content/locales/hi-IN/agent-surfaces.mdx b/packages/core/docs/content/locales/hi-IN/agent-surfaces.mdx index a7d1aad4e2..e2a079d7e1 100644 --- a/packages/core/docs/content/locales/hi-IN/agent-surfaces.mdx +++ b/packages/core/docs/content/locales/hi-IN/agent-surfaces.mdx @@ -156,6 +156,36 @@ export function ProjectChat({ threadId }: { threadId: string }) { Actions स्पष्ट native widget results वापस कर सकते हैं ताकि chat आउटपुट केवल टेक्स्ट न हो। Tables, charts, और typed product cards iframes के बिना chat में first-party React components के रूप में रेंडर होते हैं। [Native Chat UI](/docs/native-chat-ui) देखें। जब एजेंट को पूर्वनिर्धारित React widget के बजाय arbitrary generated controls की आवश्यकता हो, तो [Generative UI](/docs/generative-ui) का उपयोग करें: यह sandboxed Alpine/Tailwind UI इनलाइन रेंडर करता है, app state और slot context पढ़ सकता है, और चुने गए values को chat में वापस भेज सकता है। +### अनुरोध-स्कोप वाली action surfaces + +जब चुने गए Agent या thread को केवल server द्वारा अधिकृत native actions का +subset दिखाना हो, तब `resolveActionSurface` का उपयोग करें। callback हर +interactive chat request पर `prepareRequest` के बाद चलता है। लौटाए गए नाम एक +सख्त allowlist बनाते हैं: छोड़े गए actions provider schemas, execution +registry, Plan mode preload और `tool-search` खोज में उपलब्ध नहीं होते। + +```ts +createAgentChatPlugin({ + actions, + nativeActionsInDev: true, + resolveActionSurface: async ({ threadId, availableActionNames }) => ({ + allowedActionNames: await loadAllowedActions( + threadId, + availableActionNames, + ), + }), +}); +``` + +खाली सूची कोई native action उपलब्ध नहीं कराती। अज्ञात नाम access बढ़ाने के +बजाय request को fail करते हैं। सभी अनुमत actions पहली model request पर सीधे +लोड होते हैं; पहले से अधिकृत catalog में खोज चाहिए तभी `tool-search` को साफ़ +तौर पर शामिल करें। callback केवल interactive Agent chat को सीमित करता है, +HTTP, MCP, A2A, jobs या triggers को नहीं। default framework guidance और शुरू +किए गए sub-agents वही surface inherit करते हैं। trusted shell tools अपने-आप +request-filtered sandbox तक सीमित हो जाते हैं, क्योंकि unrestricted shell +सख्त action boundary लागू नहीं कर सकता। + ## Native inline UI {#native-inline-ui} इसका उपयोग तब करें जब आपके actions structured data वापस करते हैं — records की एक list, एक chart dataset, एक status summary — जो plain text विवरण के बजाय chat thread के अंदर एक वास्तविक UI component के रूप में रेंडर होनी चाहिए। आप action पर एक `chatUI` renderer परिभाषित करते हैं, और Agent-Native इसे एक first-party React component के रूप में रेंडर करता है: कोई iframes नहीं, कोई अलग rendering path नहीं। diff --git a/packages/core/docs/content/locales/ja-JP/agent-surfaces.mdx b/packages/core/docs/content/locales/ja-JP/agent-surfaces.mdx index 8dfed4339f..7216746252 100644 --- a/packages/core/docs/content/locales/ja-JP/agent-surfaces.mdx +++ b/packages/core/docs/content/locales/ja-JP/agent-surfaces.mdx @@ -158,6 +158,38 @@ export function ProjectChat({ threadId }: { threadId: string }) { アクションは明示的なネイティブウィジェット結果を返せるため、チャットの出力はテキストだけではありません。テーブル、チャート、型付きプロダクトカードは、iframeなしでチャット内のファーストパーティReactコンポーネントとしてレンダリングされます。[Native Chat UI](/docs/native-chat-ui) を参照してください。エージェントが事前定義されたReactウィジェットの代わりに任意の生成コントロールを必要とする場合は、[Generative UI](/docs/generative-ui) を使用してください。これはサンドボックス化されたAlpine/Tailwind UIをインラインでレンダリングし、アプリの状態とスロットコンテキストを読み取り、選択した値をチャットに送り返すことができます。 +### リクエスト単位のアクションサーフェス + +選択されたエージェントまたはスレッドに、サーバーで承認された +ネイティブアクションだけを公開する場合は `resolveActionSurface` を +使用します。コールバックは各インタラクティブチャットリクエストで +`prepareRequest` の後に実行されます。返された名前が厳格な許可リストに +なり、省略されたアクションはプロバイダースキーマ、実行レジストリ、 +Plan モードのプリロード、`tool-search` の検索結果に含まれません。 + +```ts +createAgentChatPlugin({ + actions, + nativeActionsInDev: true, + resolveActionSurface: async ({ threadId, availableActionNames }) => ({ + allowedActionNames: await loadAllowedActions( + threadId, + availableActionNames, + ), + }), +}); +``` + +空のリストではネイティブアクションは公開されません。不明な名前は +アクセスを広げず、リクエストを失敗させます。許可されたアクションは +最初のモデルリクエストですべて直接読み込まれます。承認済みカタログ内で +検索したい場合だけ `tool-search` を明示的に追加してください。この +コールバックが制限するのはインタラクティブチャットだけで、HTTP、MCP、 +A2A、ジョブ、トリガーには影響しません。既定のフレームワークガイダンスと +起動したサブエージェントも同じサーフェスを継承します。無制限の shell では +厳格なアクション境界を強制できないため、trusted shell ツールはリクエストで +フィルタされた sandbox に自動的に制限されます。 + ## ネイティブインラインUI {#native-inline-ui} アクションが構造化データ(レコードのリスト、チャートデータセット、ステータスサマリーなど)を返し、プレーンテキストの説明ではなく、チャットスレッド内の実際のUIコンポーネントとしてレンダリングする必要がある場合に使用してください。アクションに `chatUI` レンダラーを定義すると、Agent-Native はそれをファーストパーティのReactコンポーネントとしてレンダリングします。iframeも別のレンダリングパスも不要です。 diff --git a/packages/core/docs/content/locales/ko-KR/agent-surfaces.mdx b/packages/core/docs/content/locales/ko-KR/agent-surfaces.mdx index 2e8e31d8f2..e5630e9fe6 100644 --- a/packages/core/docs/content/locales/ko-KR/agent-surfaces.mdx +++ b/packages/core/docs/content/locales/ko-KR/agent-surfaces.mdx @@ -156,6 +156,36 @@ export function ProjectChat({ threadId }: { threadId: string }) { 액션은 채팅 출력이 단순 텍스트가 아닌 명시적 네이티브 위젯 결과를 반환할 수 있습니다. 테이블, 차트, 타입이 지정된 제품 카드가 iframes 없이 채팅에서 1st-party React 컴포넌트로 렌더링됩니다. [Native Chat UI](/docs/native-chat-ui)를 참조하세요. 에이전트가 미리 정의된 React 위젯 대신 임의로 생성된 컨트롤이 필요할 때는 [Generative UI](/docs/generative-ui)를 사용하세요: Alpine/Tailwind UI를 인라인으로 샌드박스 렌더링하고, 앱 상태와 슬롯 컨텍스트를 읽을 수 있으며, 선택된 값을 채팅으로 다시 보낼 수 있습니다. +### 요청 범위 액션 서피스 + +선택된 에이전트나 스레드가 서버에서 승인한 네이티브 액션 일부만 +노출해야 할 때 `resolveActionSurface`를 사용하세요. 콜백은 각 대화형 +채팅 요청에서 `prepareRequest` 이후 실행됩니다. 반환된 이름은 엄격한 +허용 목록이 되며, 제외된 액션은 공급자 스키마, 실행 레지스트리, Plan +모드 사전 로드 및 `tool-search` 검색 결과에 나타나지 않습니다. + +```ts +createAgentChatPlugin({ + actions, + nativeActionsInDev: true, + resolveActionSurface: async ({ threadId, availableActionNames }) => ({ + allowedActionNames: await loadAllowedActions( + threadId, + availableActionNames, + ), + }), +}); +``` + +빈 목록은 네이티브 액션을 노출하지 않습니다. 알 수 없는 이름은 접근을 +넓히지 않고 요청을 실패시킵니다. 허용된 모든 액션은 첫 모델 요청에 직접 +로드됩니다. 이미 승인된 카탈로그 안에서 검색할 때만 `tool-search`를 +명시적으로 포함하세요. 이 콜백은 대화형 에이전트 채팅만 제한하며 HTTP, +MCP, A2A, 작업 또는 트리거에는 영향을 주지 않습니다. 기본 프레임워크 +안내와 시작된 하위 에이전트도 같은 표면을 상속합니다. 제한 없는 shell은 +엄격한 액션 경계를 강제할 수 없으므로 trusted shell 도구는 요청으로 필터링된 +sandbox로 자동 제한됩니다. + ## 네이티브 인라인 UI {#native-inline-ui} 액션이 구조화된 데이터(레코드 목록, 차트 데이터셋, 상태 요약)를 반환하고, 이를 채팅 스레드에서 일반 텍스트 설명이 아닌 실제 UI 컴포넌트로 렌더링해야 할 때 사용하세요. 액션에 `chatUI` 렌더러를 정의하면 Agent-Native가 이를 1st-party React 컴포넌트로 렌더링합니다: iframes 없이, 별도의 렌더링 경로 없이. diff --git a/packages/core/docs/content/locales/pt-BR/agent-surfaces.mdx b/packages/core/docs/content/locales/pt-BR/agent-surfaces.mdx index b6d1b373cc..8e41554efe 100644 --- a/packages/core/docs/content/locales/pt-BR/agent-surfaces.mdx +++ b/packages/core/docs/content/locales/pt-BR/agent-surfaces.mdx @@ -159,6 +159,38 @@ export function ProjectChat({ threadId }: { threadId: string }) { As actions podem retornar resultados de widgets nativos explícitos para que a saída do chat não seja apenas texto. Tabelas, gráficos e cards de produto tipados são renderizados como componentes React de primeira linha no chat, sem iframes. Veja [Native Chat UI](/docs/native-chat-ui). Quando o agente precisa de controles gerados arbitrários em vez de um widget React predefinido, use [Generative UI](/docs/generative-ui): ele renderiza UI Alpine/Tailwind em sandbox inline, pode ler o estado do app e o contexto de slot, e pode enviar valores selecionados de volta ao chat. +### Superfícies de ações por solicitação + +Use `resolveActionSurface` quando o agente ou thread selecionado puder expor +somente um subconjunto de ações nativas autorizado pelo servidor. O callback é +executado em cada solicitação de chat interativo após `prepareRequest`. Os +nomes retornados formam uma allowlist rígida: ações omitidas não aparecem nos +schemas do provedor, no registro de execução, no pré-carregamento do modo Plan +nem na descoberta por `tool-search`. + +```ts +createAgentChatPlugin({ + actions, + nativeActionsInDev: true, + resolveActionSurface: async ({ threadId, availableActionNames }) => ({ + allowedActionNames: await loadAllowedActions( + threadId, + availableActionNames, + ), + }), +}); +``` + +Uma lista vazia não expõe ações nativas. Nomes desconhecidos fazem a +solicitação falhar em vez de ampliar o acesso. Todas as ações permitidas são +carregadas diretamente na primeira solicitação ao modelo; inclua +`tool-search` explicitamente apenas para pesquisar dentro do catálogo já +autorizado. O callback limita apenas o chat interativo, não HTTP, MCP, A2A, +jobs ou triggers. As orientações padrão do framework e os subagentes iniciados +herdam a mesma superfície. Ferramentas shell confiáveis são automaticamente +reduzidas ao sandbox filtrado por solicitação, pois um shell irrestrito não +consegue impor uma fronteira rígida de ações. + ## UI inline nativa {#native-inline-ui} Use isso quando suas actions retornam dados estruturados — uma lista de registros, um conjunto de dados de gráfico, um resumo de status — que devem ser renderizados como um componente de UI real dentro da thread do chat, em vez de uma descrição em texto simples. Você define um renderizador `chatUI` na action, e o Agent-Native o renderiza como um componente React de primeira linha: sem iframes, sem caminho de renderização separado. diff --git a/packages/core/docs/content/locales/zh-CN/agent-surfaces.mdx b/packages/core/docs/content/locales/zh-CN/agent-surfaces.mdx index 8835b54c52..efef5afa44 100644 --- a/packages/core/docs/content/locales/zh-CN/agent-surfaces.mdx +++ b/packages/core/docs/content/locales/zh-CN/agent-surfaces.mdx @@ -156,6 +156,34 @@ export function ProjectChat({ threadId }: { threadId: string }) { 动作可以返回明确的原生控件结果,使聊天输出不仅仅是文字。表格、图表和带类型的产品卡片以第一方 React 组件的形式在聊天中渲染,无需 iframe。详见 [Native Chat UI](/docs/native-chat-ui)。当 Agent 需要的是任意生成的控件而非预定义的 React 组件时,请使用 [Generative UI](/docs/generative-ui):它在沙箱中内联渲染 Alpine/Tailwind UI,可以读取应用状态和插槽上下文,并将选中的值发回聊天。 +### 请求级操作界面 + +当所选 Agent 或线程只能公开服务器已授权的原生操作子集时,请使用 +`resolveActionSurface`。该回调会在每个交互式聊天请求的 +`prepareRequest` 之后运行。返回的名称构成严格允许列表:未包含的操作 +不会出现在提供商 schema、执行注册表、Plan 模式预加载或 +`tool-search` 搜索结果中。 + +```ts +createAgentChatPlugin({ + actions, + nativeActionsInDev: true, + resolveActionSurface: async ({ threadId, availableActionNames }) => ({ + allowedActionNames: await loadAllowedActions( + threadId, + availableActionNames, + ), + }), +}); +``` + +空列表不会公开任何原生操作。未知名称会使请求失败,而不会扩大访问范围。 +所有允许的操作都会在第一次模型请求中直接加载;只有需要在已授权目录内 +搜索时才显式加入 `tool-search`。该回调仅限制交互式 Agent 聊天,不会 +更改 HTTP、MCP、A2A、任务或触发器的暴露范围。默认框架说明和启动的子 +Agent 会继承同一操作范围。由于不受限制的 shell 无法强制执行严格的操作 +边界,trusted shell 工具会自动降为按请求过滤的 sandbox。 + ## 原生内联 UI {#native-inline-ui} 当你的动作返回结构化数据——记录列表、图表数据集、状态摘要——且这些数据应在聊天会话中以真实 UI 组件而非纯文字描述的形式渲染时,请使用此功能。你在动作上定义一个 `chatUI` 渲染器,Agent-Native 会将其渲染为第一方 React 组件:无 iframe,无独立渲染路径。 diff --git a/packages/core/docs/content/locales/zh-TW/agent-surfaces.mdx b/packages/core/docs/content/locales/zh-TW/agent-surfaces.mdx index e89e354cde..62e46f3475 100644 --- a/packages/core/docs/content/locales/zh-TW/agent-surfaces.mdx +++ b/packages/core/docs/content/locales/zh-TW/agent-surfaces.mdx @@ -156,6 +156,34 @@ export function ProjectChat({ threadId }: { threadId: string }) { Actions 可以回傳明確的原生 widget 結果,讓對話輸出不僅限於文字。表格、圖表和類型化產品卡片會以第一方 React 元件的形式在對話中呈現,無需 iframe。詳見 [Native Chat UI](/docs/native-chat-ui)。當代理需要任意生成的控制項,而非預先定義的 React widget 時,請使用 [Generative UI](/docs/generative-ui):它在對話中內嵌呈現沙盒化的 Alpine/Tailwind UI,可讀取應用程式狀態和 slot context,並能將選取的值回傳至對話。 +### 請求範圍的操作介面 + +當所選 Agent 或執行緒只能公開伺服器已授權的原生操作子集時,請使用 +`resolveActionSurface`。此回呼會在每個互動式聊天請求的 +`prepareRequest` 之後執行。回傳的名稱形成嚴格允許清單:未包含的操作 +不會出現在供應商 schema、執行登錄、Plan 模式預先載入或 +`tool-search` 搜尋結果中。 + +```ts +createAgentChatPlugin({ + actions, + nativeActionsInDev: true, + resolveActionSurface: async ({ threadId, availableActionNames }) => ({ + allowedActionNames: await loadAllowedActions( + threadId, + availableActionNames, + ), + }), +}); +``` + +空清單不會公開任何原生操作。未知名稱會讓請求失敗,而不會擴大存取範圍。 +所有允許的操作都會在第一次模型請求中直接載入;只有需要在已授權目錄內 +搜尋時才明確加入 `tool-search`。此回呼只限制互動式 Agent 聊天,不會 +改變 HTTP、MCP、A2A、工作或觸發器的暴露範圍。預設框架指引和啟動的子 +Agent 會繼承相同操作表面。由於不受限制的 shell 無法強制執行嚴格的操作 +邊界,trusted shell 工具會自動降為依請求過濾的 sandbox。 + ## 原生內嵌 UI {#native-inline-ui} 當你的 actions 回傳結構化資料——記錄清單、圖表資料集、狀態摘要——且這些資料應在對話串中呈現為真實的 UI 元件,而非純文字描述時,請使用此功能。你在 action 上定義 `chatUI` 渲染器,Agent-Native 會將其呈現為第一方 React 元件:沒有 iframe,沒有獨立的渲染路徑。 diff --git a/packages/core/src/agent/index.ts b/packages/core/src/agent/index.ts index e1e72da77a..aae986c1b3 100644 --- a/packages/core/src/agent/index.ts +++ b/packages/core/src/agent/index.ts @@ -3,6 +3,8 @@ export { type ActionEntry, type ScriptEntry, type ProductionAgentOptions, + type AgentActionSurface, + type AgentActionSurfaceDetails, type AgentLoopFinalResponseGuard, type AgentLoopFinalResponseGuardContext, type AgentLoopFinalResponseGuardResult, diff --git a/packages/core/src/agent/production-agent.spec.ts b/packages/core/src/agent/production-agent.spec.ts index 182076045b..174d7a1f86 100644 --- a/packages/core/src/agent/production-agent.spec.ts +++ b/packages/core/src/agent/production-agent.spec.ts @@ -34,11 +34,14 @@ import { createPlanModeActionRegistry, createProductionAgentHandler, preloadPlanModeEngineTools, + readPersistedActionSurface, + readPersistedAllowedActionNames, isPlanModeToolCallAllowed, isCachedToolResultVisibleInContext, isContextTooLongError, isRetryableError, actionsToEngineTools, + filterActionsByAllowedNames, filterInitialEngineTools, findApprovedStructuredToolCall, MAX_BACKGROUND_RUN_CONTINUATIONS, @@ -1419,6 +1422,266 @@ describe("resolveAgentOwnerEmail", () => { }); describe("createProductionAgentHandler", () => { + it("limits each request to the action names returned by resolveActionSurface", async () => { + const seenTools: string[][] = []; + const lifecycle: string[] = []; + const engine: AgentEngine = { + name: "test", + label: "Test", + defaultModel: "test-model", + supportedModels: ["test-model"], + capabilities: { + thinking: false, + promptCaching: false, + vision: false, + computerUse: false, + parallelToolCalls: false, + }, + async *stream(opts): AsyncIterable { + lifecycle.push("stream"); + seenTools.push(opts.tools.map((tool) => tool.name)); + yield { + type: "assistant-content", + parts: [{ type: "text", text: "done" }], + }; + yield { type: "stop", reason: "end_turn" }; + }, + }; + const handler = createProductionAgentHandler({ + systemPrompt: "Test", + engine, + actions: { + allowed: actionEntry({}), + denied: actionEntry({}), + "tool-search": actionEntry({}), + }, + prepareRequest: async () => { + lifecycle.push("prepare"); + }, + resolveActionSurface: async ({ threadId, availableActionNames }) => { + lifecycle.push("surface"); + expect(threadId).toBe("thread-allowed"); + expect(availableActionNames).toEqual([ + "allowed", + "denied", + "tool-search", + ]); + return { allowedActionNames: ["allowed"] }; + }, + }); + const event = mockEvent( + new Request("http://app.example.com/_agent-native/agent-chat", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + message: "Use the configured agent", + threadId: "thread-allowed", + }), + }), + ); + + const response = await runWithRequestContext( + { userEmail: "owner@example.com", run: {} }, + () => handler(event), + ); + if (response instanceof ReadableStream) { + const reader = response.getReader(); + while (!(await reader.read()).done) {} + } + + await vi.waitFor(() => { + expect(seenTools).toEqual([["allowed"]]); + }); + expect(lifecycle).toEqual(["prepare", "surface", "stream"]); + expect(getRequestRunContext()).toBeUndefined(); + }); + + it("keeps concurrent request action surfaces isolated by thread", async () => { + const seenTools: string[][] = []; + const seenContinuations: Array<[string | undefined, boolean]> = []; + const engine: AgentEngine = { + name: "test", + label: "Test", + defaultModel: "test-model", + supportedModels: ["test-model"], + capabilities: { + thinking: false, + promptCaching: false, + vision: false, + computerUse: false, + parallelToolCalls: false, + }, + async *stream(opts): AsyncIterable { + seenTools.push(opts.tools.map((tool) => tool.name)); + yield { + type: "assistant-content", + parts: [{ type: "text", text: "done" }], + }; + yield { type: "stop", reason: "end_turn" }; + }, + }; + const handler = createProductionAgentHandler({ + systemPrompt: "Test", + engine, + actions: { + alpha: actionEntry({}), + beta: actionEntry({}), + "tool-search": actionEntry({}), + }, + resolveActionSurface: async ({ threadId, internalContinuation }) => { + seenContinuations.push([threadId, internalContinuation]); + if (threadId === "thread-alpha") { + await new Promise((resolve) => setTimeout(resolve, 10)); + return { allowedActionNames: ["alpha"] }; + } + return { allowedActionNames: ["beta"] }; + }, + }); + + const runThread = async (threadId: string, ownerEmail: string) => { + const event = mockEvent( + new Request("http://app.example.com/_agent-native/agent-chat", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + message: "Run", + threadId, + internalContinuation: threadId === "thread-beta", + }), + }), + ); + const response = await runWithRequestContext( + { userEmail: ownerEmail, run: {} }, + () => handler(event), + ); + if (response instanceof ReadableStream) { + const reader = response.getReader(); + while (!(await reader.read()).done) {} + } + }; + + await Promise.all([ + runThread("thread-alpha", "alpha@example.com"), + runThread("thread-beta", "beta@example.com"), + ]); + + expect(seenTools).toHaveLength(2); + expect(seenTools).toContainEqual(["alpha"]); + expect(seenTools).toContainEqual(["beta"]); + expect(seenContinuations).toContainEqual(["thread-alpha", false]); + expect(seenContinuations).toContainEqual(["thread-beta", true]); + }); + + it("fails closed when resolveActionSurface returns an unknown action", async () => { + const engineStream = vi.fn(); + const engine: AgentEngine = { + name: "test", + label: "Test", + defaultModel: "test-model", + supportedModels: ["test-model"], + capabilities: { + thinking: false, + promptCaching: false, + vision: false, + computerUse: false, + parallelToolCalls: false, + }, + stream: engineStream, + }; + const handler = createProductionAgentHandler({ + systemPrompt: "Test", + engine, + actions: { known: actionEntry({}) }, + resolveActionSurface: async () => ({ + allowedActionNames: ["missing"], + }), + }); + const event = mockEvent( + new Request("http://app.example.com/_agent-native/agent-chat", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message: "Run" }), + }), + ); + + await expect( + runWithRequestContext({ userEmail: "owner@example.com", run: {} }, () => + handler(event), + ), + ).rejects.toThrow( + "resolveActionSurface returned unknown action name(s): missing", + ); + expect(engineStream).not.toHaveBeenCalled(); + }); + + it("ignores forged durable-worker fields on ordinary chat requests", async () => { + const seenTools: string[][] = []; + const resolver = vi.fn(async () => ({ + allowedActionNames: ["allowed"], + })); + const engine: AgentEngine = { + name: "test", + label: "Test", + defaultModel: "test-model", + supportedModels: ["test-model"], + capabilities: { + thinking: false, + promptCaching: false, + vision: false, + computerUse: false, + parallelToolCalls: false, + }, + async *stream(opts): AsyncIterable { + seenTools.push(opts.tools.map((tool) => tool.name)); + yield { + type: "assistant-content", + parts: [{ type: "text", text: "done" }], + }; + yield { type: "stop", reason: "end_turn" }; + }, + }; + const handler = createProductionAgentHandler({ + systemPrompt: "Test", + engine, + actions: { + allowed: actionEntry({}), + denied: actionEntry({}), + }, + resolveActionSurface: resolver, + }); + const event = mockEvent( + new Request("http://app.example.com/_agent-native/agent-chat", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + message: "Run", + __backgroundRun: { + runId: "attacker-selected-run", + continuationCount: 1, + }, + __resolvedActionSurface: { + orgId: "attacker-selected-org", + allowedActionNames: ["denied"], + }, + }), + }), + ); + + const response = await runWithRequestContext( + { userEmail: "owner@example.com", orgId: "real-org", run: {} }, + () => handler(event), + ); + if (response instanceof ReadableStream) { + const reader = response.getReader(); + while (!(await reader.read()).done) {} + } + + await vi.waitFor(() => expect(seenTools).toEqual([["allowed"]])); + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ orgId: "real-org" }), + ); + }); + it("passes queued message identity to onRunPrepared", async () => { const onRunPrepared = vi.fn(); const engine: AgentEngine = { @@ -1471,6 +1734,113 @@ describe("createProductionAgentHandler", () => { }); }); +describe("filterActionsByAllowedNames", () => { + it("treats an explicit empty allowlist as no actions", () => { + expect( + filterActionsByAllowedNames( + { one: actionEntry({}), two: actionEntry({}) }, + [], + ), + ).toEqual({}); + }); + + it("preserves the allowlist order and removes duplicates", () => { + expect( + Object.keys( + filterActionsByAllowedNames( + { one: actionEntry({}), two: actionEntry({}) }, + ["two", "one", "two"], + ), + ), + ).toEqual(["two", "one"]); + }); + + it("rejects inherited object properties as unknown actions", () => { + expect(() => + filterActionsByAllowedNames({ allowed: actionEntry({}) }, [ + "constructor", + ]), + ).toThrow( + "resolveActionSurface returned unknown action name(s): constructor", + ); + }); + + it("distinguishes absent persisted surfaces from malformed ones", () => { + expect(readPersistedAllowedActionNames({})).toBeUndefined(); + expect( + readPersistedAllowedActionNames({ + allowedActionNames: null, + }), + ).toEqual([]); + expect( + readPersistedActionSurface({}, "__resolvedActionSurface"), + ).toBeUndefined(); + expect( + readPersistedActionSurface( + { __resolvedActionSurface: { allowedActionNames: "invalid" } }, + "__resolvedActionSurface", + ), + ).toEqual({ orgId: null, allowedActionNames: [] }); + expect( + readPersistedActionSurface( + { __resolvedActionSurface: { allowedActionNames: ["allowed"] } }, + "__resolvedActionSurface", + ), + ).toEqual({ orgId: null, allowedActionNames: [] }); + expect( + readPersistedActionSurface( + { + __resolvedActionSurface: { + orgId: 42, + allowedActionNames: ["allowed"], + }, + }, + "__resolvedActionSurface", + ), + ).toEqual({ orgId: null, allowedActionNames: [] }); + expect( + readPersistedActionSurface( + { + __resolvedActionSurface: { + orgId: "org-123", + allowedActionNames: ["allowed", "allowed"], + }, + }, + "__resolvedActionSurface", + ), + ).toEqual({ orgId: "org-123", allowedActionNames: ["allowed"] }); + expect( + readPersistedActionSurface( + { + __resolvedActionSurface: { + orgId: null, + allowedActionNames: ["allowed"], + }, + }, + "__resolvedActionSurface", + ), + ).toEqual({ orgId: null, allowedActionNames: ["allowed"] }); + }); + + it("keeps tool-search scoped to the filtered request registry", async () => { + const fullRegistry = attachToolSearch({ + allowed: actionEntry({ description: "Allowed action" }), + denied: actionEntry({ description: "Denied action" }), + }); + const filtered = filterActionsByAllowedNames(fullRegistry, [ + "allowed", + "tool-search", + ]); + + const result = await filtered["tool-search"].run({}); + + expect(result.results.map((entry: { name: string }) => entry.name)).toEqual( + ["allowed"], + ); + expect(result.totalTools).toBe(1); + }); +}); + describe("runAgentLoop", () => { it("passes trusted automation context through to the selected action", async () => { const run = vi.fn(async () => "updated"); diff --git a/packages/core/src/agent/production-agent.ts b/packages/core/src/agent/production-agent.ts index 48e781e43d..aee3ff35da 100644 --- a/packages/core/src/agent/production-agent.ts +++ b/packages/core/src/agent/production-agent.ts @@ -43,6 +43,7 @@ import { } from "../server/credential-provider.js"; import { readBody } from "../server/h3-helpers.js"; import { + assertRequestActionSurfaceIsolation, getRequestRunContext, ensureRequestRunContext, getRequestContext, @@ -762,6 +763,101 @@ export type ScriptEntry = ActionEntry; export type AgentExecutionMode = "act" | "plan"; +export interface AgentActionSurface { + allowedActionNames: readonly string[]; +} + +export interface AgentActionSurfaceDetails { + event: any; + ownerEmail: string | null; + orgId: string | null; + threadId?: string; + mode: AgentExecutionMode; + internalContinuation: boolean; + availableActionNames: readonly string[]; +} + +function hasOwn( + value: unknown, + propertyName: string, +): value is Record { + return ( + typeof value === "object" && + value !== null && + Object.prototype.hasOwnProperty.call(value, propertyName) + ); +} + +/** Read a persisted allowlist while preserving legacy absence and failing + * closed when the field is present but malformed. */ +export function readPersistedAllowedActionNames( + value: unknown, +): string[] | undefined { + if (!hasOwn(value, "allowedActionNames")) return undefined; + const names = value.allowedActionNames; + if ( + !Array.isArray(names) || + !names.every((name) => typeof name === "string") + ) { + return []; + } + return [...new Set(names)]; +} + +export interface PersistedActionSurface { + orgId: string | null; + allowedActionNames: string[]; +} + +/** Read a nested persisted action surface. An absent envelope is legacy; + * a present but invalid envelope is an explicit org-less, empty surface. */ +export function readPersistedActionSurface( + value: unknown, + propertyName: string, +): PersistedActionSurface | undefined { + if (!hasOwn(value, propertyName)) return undefined; + const surface = value[propertyName]; + const allowedActionNames = readPersistedAllowedActionNames(surface) ?? []; + if (!hasOwn(surface, "orgId")) { + return { orgId: null, allowedActionNames: [] }; + } + const orgId = surface.orgId; + if ( + orgId !== null && + (typeof orgId !== "string" || orgId.trim().length === 0) + ) { + return { orgId: null, allowedActionNames: [] }; + } + return { orgId, allowedActionNames }; +} + +export function filterActionsByAllowedNames( + actions: Record, + allowedActionNames: readonly string[], +): Record { + const allowedNames = [...new Set(allowedActionNames)]; + const unknownNames = allowedNames.filter( + (name) => !Object.prototype.hasOwnProperty.call(actions, name), + ); + if (unknownNames.length > 0) { + throw new Error( + `resolveActionSurface returned unknown action name(s): ${unknownNames.join(", ")}`, + ); + } + const filtered = Object.fromEntries( + allowedNames.map((name) => [name, actions[name]!]), + ); + + // `attachToolSearch` closes over the registry it was originally attached to. + // Rebind it to this request's filtered registry so explicitly allowing + // `tool-search` cannot disclose actions that the resolver omitted. + if (filtered[TOOL_SEARCH_ACTION_NAME]) { + filtered[TOOL_SEARCH_ACTION_NAME] = createToolSearchEntry(() => filtered); + } + + return filtered; +} + export const PLAN_MODE_SYSTEM_PROMPT = `## Plan Mode Active You are in Plan mode. This turn is for research, clarification, and a proposed approach only. @@ -1109,6 +1205,15 @@ export interface ProductionAgentOptions { displayMessage?: string; attachments?: AgentChatAttachment[]; }>; + /** + * Resolve the exact action registry exposed to one interactive agent-chat + * request. Returned names are a hard allowlist: omitted actions are absent + * from both the provider schemas and the searchable registry. When set, all + * allowed actions are loaded directly on the first model request. + */ + resolveActionSurface?: ( + details: AgentActionSurfaceDetails, + ) => AgentActionSurface | Promise; /** Optional per-app agent run chunk budget in milliseconds. Defaults to * AGENT_RUN_SOFT_TIMEOUT_MS when set, otherwise no framework-imposed * timeout. When reached, the client receives an internal auto-continuation @@ -8011,6 +8116,9 @@ export function resolveAgentRequestReasoningEffort({ export function createProductionAgentHandler( options: ProductionAgentOptions, ): H3EventHandler { + if (options.resolveActionSurface) { + assertRequestActionSurfaceIsolation(); + } // Undefined = let each engine pick its own defaultModel at request time. const configuredModel = options.model; @@ -8022,7 +8130,7 @@ export function createProductionAgentHandler( // the settings UI) show up to the LLM without a process restart. MCP tools // are also scope-filtered per request — a user-scope server added by Alice // must not appear in Bob's tool list in a shared-process deployment. - const getEngineTools = ( + const getRequestActions = ( actions: Record = resolvedActions, ) => { const filtered: Record = {}; @@ -8032,8 +8140,10 @@ export function createProductionAgentHandler( } filtered[name] = entry; } - return actionsToEngineTools(filtered); + return filtered; }; + const getEngineTools = (actions: Record) => + actionsToEngineTools(getRequestActions(actions)); return defineEventHandler(async (event) => { // Diagnostic-only setup-timing instrumentation. Captures wall-clock offsets @@ -8093,12 +8203,20 @@ export function createProductionAgentHandler( // run the loop inline with the background soft-timeout, reusing the // pre-claimed runId/turnId — we must NOT re-claim the slot or re-dispatch. const backgroundRunMarker = + preInjectedBody && body[AGENT_CHAT_BACKGROUND_RUN_FIELD] && typeof body[AGENT_CHAT_BACKGROUND_RUN_FIELD] === "object" && typeof body[AGENT_CHAT_BACKGROUND_RUN_FIELD]!.runId === "string" ? body[AGENT_CHAT_BACKGROUND_RUN_FIELD]! : null; const isBackgroundWorker = backgroundRunMarker !== null; + // Both fields are internal durable-dispatch artifacts, never client input. + // A body marker alone is not proof of worker identity: only the verified + // `_process-run` route can install `preInjectedBody` on the event context. + if (!isBackgroundWorker) { + delete body[AGENT_CHAT_BACKGROUND_RUN_FIELD]; + delete body.__resolvedActionSurface; + } // DIAGNOSTIC-ONLY: progressive per-stage hang localizer for the bg worker. // The worker's runId is available EARLY on the marker (the general `runId` // var resolves much later), so capture it now and emit the LAST setup stage @@ -8214,6 +8332,38 @@ export function createProductionAgentHandler( requestAttachments = preparedRequest.attachments; } } + const availableRequestActions = getRequestActions(); + let surfacedRequestActions = availableRequestActions; + if (options.resolveActionSurface) { + const persistedSurface = isBackgroundWorker + ? readPersistedActionSurface(body, "__resolvedActionSurface") + : undefined; + const surface = + persistedSurface !== undefined + ? persistedSurface + : await options.resolveActionSurface({ + event, + ownerEmail, + orgId: getRequestOrgId() ?? null, + threadId, + mode: requestMode, + internalContinuation: Boolean(internalContinuation), + availableActionNames: Object.keys(availableRequestActions), + }); + surfacedRequestActions = filterActionsByAllowedNames( + availableRequestActions, + surface.allowedActionNames, + ); + const allowedNames = Object.keys(surfacedRequestActions); + const runCtx = ensureRequestRunContext(); + if (runCtx) runCtx.allowedActionNames = allowedNames; + if (!isBackgroundWorker) { + body.__resolvedActionSurface = { + orgId: getRequestOrgId() ?? null, + allowedActionNames: allowedNames, + }; + } + } // DIAGNOSTIC-ONLY: owner/request context prep (resolveAgentOwnerEmail + // prepareRequest) finished. A worker stuck before this points at the // owner/request-context awaits. @@ -8498,7 +8648,7 @@ export function createProductionAgentHandler( (async (): Promise => { const screenStart = Date.now(); try { - const viewScreenAction = resolvedActions["view-screen"]; + const viewScreenAction = surfacedRequestActions["view-screen"]; if (viewScreenAction) { const result = await viewScreenAction.run( {}, @@ -8807,13 +8957,15 @@ export function createProductionAgentHandler( const screenContext = timeBlock + screenBlock + urlBlock + selectionBlock; const requestActions = requestMode === "plan" - ? createPlanModeActionRegistry(resolvedActions) - : resolvedActions; + ? createPlanModeActionRegistry(surfacedRequestActions) + : surfacedRequestActions; const availableRequestTools = getEngineTools(requestActions); - const initialRequestTools = filterInitialEngineTools( - availableRequestTools, - options.initialToolNames, - ); + const initialRequestTools = options.resolveActionSurface + ? availableRequestTools + : filterInitialEngineTools( + availableRequestTools, + options.initialToolNames, + ); const requestTools = requestMode === "plan" ? preloadPlanModeEngineTools({ diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts index 165924f744..dfb1c2b84d 100644 --- a/packages/core/src/agent/types.ts +++ b/packages/core/src/agent/types.ts @@ -208,6 +208,15 @@ export interface AgentChatRequest { */ payloadRef?: boolean; }; + /** + * Server-resolved action authorization carried across authenticated durable + * background dispatches. Normal client requests must not trust this field; + * the foreground handler deletes and replaces it before persistence. + */ + __resolvedActionSurface?: { + orgId: string | null; + allowedActionNames: string[]; + }; /** * Stable identity for the logical assistant turn this request belongs to. * The client sends the SAME turnId for the initial POST and every diff --git a/packages/core/src/coding-tools/run-code.background.spec.ts b/packages/core/src/coding-tools/run-code.background.spec.ts index 8e5fa881bf..cec5665361 100644 --- a/packages/core/src/coding-tools/run-code.background.spec.ts +++ b/packages/core/src/coding-tools/run-code.background.spec.ts @@ -50,6 +50,7 @@ const { getSandboxExecutionInternal, resetSandboxExecutionsStoreForTests } = const { resetSandboxBackgroundForTests } = await import("./sandbox/background.js"); const { resetSandboxAdapterForTests } = await import("./sandbox/index.js"); +const { runWithRequestContext } = await import("../server/request-context.js"); const OWNER = "alice@example.com"; const ctx: ActionRunContext = { @@ -158,6 +159,31 @@ describe("run-code background param", () => { expect(pollResult).toContain("bg says 42"); }, 40_000); + it("persists the request action surface when enqueueing background code", async () => { + serverless = true; + const entry = createRunCodeEntry(makeActions); + + const enqueueResult = (await runWithRequestContext( + { + userEmail: OWNER, + orgId: "org-1", + run: { allowedActionNames: ["run-code"] }, + }, + () => + entry.run( + { + code: 'console.log(await appAction("omitted-reader", {}))', + background: true as never, + }, + ctx, + ), + )) as string; + const enqueued = JSON.parse(enqueueResult); + + const updated = await getSandboxExecutionInternal(enqueued.executionId); + expect(updated!.allowedActionNames).toEqual(["run-code"]); + }); + it("dispatches to the processor route instead of running inline on serverless", async () => { serverless = true; const entry = createRunCodeEntry(makeActions); diff --git a/packages/core/src/coding-tools/run-code.ts b/packages/core/src/coding-tools/run-code.ts index 1c6c28e37a..c4ce76db4c 100644 --- a/packages/core/src/coding-tools/run-code.ts +++ b/packages/core/src/coding-tools/run-code.ts @@ -34,7 +34,10 @@ import http from "node:http"; import type { ActionRunContext } from "../action.js"; import type { ActionEntry } from "../agent/production-agent.js"; -import { getRequestUserEmail } from "../server/request-context.js"; +import { + getRequestRunContext, + getRequestUserEmail, +} from "../server/request-context.js"; import { failExpiredSandboxExecution, getSandboxExecutionForOwner, @@ -418,6 +421,7 @@ async function enqueueBackgroundRunCode(input: { owner, orgId: input.context?.orgId ?? null, threadId: input.context?.threadId ?? null, + allowedActionNames: getRequestRunContext()?.allowedActionNames, }); const hasGetTool = Boolean(input.getActions()["get-code-execution"]); diff --git a/packages/core/src/coding-tools/sandbox/background.spec.ts b/packages/core/src/coding-tools/sandbox/background.spec.ts index 1ad88fcacf..07d588234b 100644 --- a/packages/core/src/coding-tools/sandbox/background.spec.ts +++ b/packages/core/src/coding-tools/sandbox/background.spec.ts @@ -61,6 +61,8 @@ const { resolveExecutionSandboxAdapter, resetSandboxAdapterForTests, } = await import("./index.js"); +const { getRequestRunContext, runWithRequestContext } = + await import("../../server/request-context.js"); const OWNER = "alice@example.com"; @@ -193,6 +195,46 @@ describe("processQueuedSandboxExecution", () => { expect(done!.finishedAt).not.toBeNull(); }); + it("restores the queued action surface before a runner resolves bridge actions", async () => { + const omittedActionRun = vi.fn(async () => ({ secret: true })); + registerSandboxExecutionRunner( + { + execute: async () => { + const allowedNames = getRequestRunContext()?.allowedActionNames; + if (!allowedNames || allowedNames.includes("omitted-reader")) { + await omittedActionRun(); + return { + stdout: "unexpected action result", + stderr: "", + exitCode: 0, + timedOut: false, + bridgeToolsUsed: ["omitted-reader"], + }; + } + return { + stdout: "", + stderr: 'Tool "omitted-reader" is not registered.', + exitCode: 1, + timedOut: false, + bridgeToolsUsed: [], + }; + }, + }, + { replace: true }, + ); + const row = await makeExecution({ + allowedActionNames: ["run-code"], + }); + + const result = await runWithRequestContext( + { userEmail: OWNER, orgId: "org-1" }, + () => processQueuedSandboxExecution(row.id), + ); + + expect(result).toEqual({ status: "completed", finalStatus: "failed" }); + expect(omittedActionRun).not.toHaveBeenCalled(); + }); + it("maps a timed-out run to timed_out with a structured error", async () => { okRunner({ timedOut: true, exitCode: null, stderr: "killed" }); const row = await makeExecution(); diff --git a/packages/core/src/coding-tools/sandbox/background.ts b/packages/core/src/coding-tools/sandbox/background.ts index 1aaaa0274d..d9463b38e5 100644 --- a/packages/core/src/coding-tools/sandbox/background.ts +++ b/packages/core/src/coding-tools/sandbox/background.ts @@ -160,6 +160,7 @@ export interface EnqueueSandboxExecutionInput { owner: string; orgId?: string | null; threadId?: string | null; + allowedActionNames?: readonly string[]; } export interface EnqueueSandboxExecutionResult { @@ -182,6 +183,7 @@ export async function enqueueSandboxExecution( code: input.code, timeoutMs: input.timeoutMs, maxOutputChars: input.maxOutputChars, + allowedActionNames: input.allowedActionNames, }); let driveNote: string | undefined; try { @@ -322,7 +324,14 @@ export async function processQueuedSandboxExecution( actionName: "run-code", }; const output = await runWithRequestContext( - { userEmail: claimed.owner, orgId: claimed.orgId ?? undefined }, + { + userEmail: claimed.owner, + orgId: claimed.orgId ?? undefined, + run: + claimed.allowedActionNames === undefined + ? undefined + : { allowedActionNames: claimed.allowedActionNames }, + }, () => runner.execute({ code: claimed.code, diff --git a/packages/core/src/coding-tools/sandbox/executions-store.spec.ts b/packages/core/src/coding-tools/sandbox/executions-store.spec.ts index 75886ed784..f5c494a14c 100644 --- a/packages/core/src/coding-tools/sandbox/executions-store.spec.ts +++ b/packages/core/src/coding-tools/sandbox/executions-store.spec.ts @@ -77,6 +77,22 @@ describe("sandbox executions store", () => { expect(row.timeoutMs).toBe(600_000); expect(row.claimToken).toBeNull(); expect(row.leaseExpiresAt).toBeNull(); + expect(row.allowedActionNames).toBeUndefined(); + }); + + it("persists an explicit action surface and fails malformed data closed", async () => { + const row = await createSandboxExecution( + baseInput({ allowedActionNames: ["run-code", "read-things"] }), + ); + expect(row.allowedActionNames).toEqual(["run-code", "read-things"]); + + sqlite + .prepare( + "UPDATE sandbox_executions SET allowed_action_names = ? WHERE id = ?", + ) + .run("not-json", row.id); + const malformed = await getSandboxExecutionInternal(row.id); + expect(malformed!.allowedActionNames).toEqual([]); }); it("scopes owner reads: another owner cannot see the row", async () => { diff --git a/packages/core/src/coding-tools/sandbox/executions-store.ts b/packages/core/src/coding-tools/sandbox/executions-store.ts index 207b52df5a..b9c57c3907 100644 --- a/packages/core/src/coding-tools/sandbox/executions-store.ts +++ b/packages/core/src/coding-tools/sandbox/executions-store.ts @@ -72,6 +72,7 @@ export interface SandboxExecutionRow { timedOut: boolean; error: string | null; bridgeToolsUsed: string[]; + allowedActionNames: string[] | undefined; createdAt: number; startedAt: number | null; finishedAt: number | null; @@ -87,6 +88,7 @@ export interface CreateSandboxExecutionInput { maxOutputChars: number; maxAttempts?: number; runtime?: string; + allowedActionNames?: readonly string[]; } export interface FinalizeSandboxExecutionInput { @@ -152,6 +154,7 @@ async function _doEnsureTable(): Promise { timed_out ${intType()} NOT NULL DEFAULT 0, error TEXT, bridge_tools_used TEXT, + allowed_action_names TEXT, created_at ${intType()} NOT NULL, started_at ${intType()}, finished_at ${intType()}, @@ -168,7 +171,10 @@ async function _doEnsureTable(): Promise { await ensureTableExists(TABLE, createSql); // Additive-column guard: keeps older deployments (created before a column // was added) self-healing without destructive migrations. - const pgColumns: Array<[string, string]> = [["bridge_tools_used", "TEXT"]]; + const pgColumns: Array<[string, string]> = [ + ["bridge_tools_used", "TEXT"], + ["allowed_action_names", "TEXT"], + ]; for (const [col, def] of pgColumns) { await ensureColumnExists( TABLE, @@ -183,6 +189,20 @@ async function _doEnsureTable(): Promise { await ensureIndexExists("sandbox_executions_due_idx", dueIdxSql); } else { await retryOnDdlRace(() => client.execute(createSql)); + try { + await retryOnDdlRace(() => + client.execute( + `ALTER TABLE ${TABLE} ADD COLUMN allowed_action_names TEXT`, + ), + ); + } catch (error) { + const message = String( + (error as { message?: unknown } | null)?.message ?? error, + ); + if (!/duplicate column name|column .* already exists/i.test(message)) { + throw error; + } + } await retryOnDdlRace(() => client.execute(ownerIdxSql)); await retryOnDdlRace(() => client.execute(dueIdxSql)); } @@ -225,6 +245,27 @@ function rowFromDb(raw: Record): SandboxExecutionRow { bridgeToolsUsed = []; } } + let allowedActionNames: string[] | undefined; + if ( + raw.allowed_action_names !== null && + raw.allowed_action_names !== undefined + ) { + if (typeof raw.allowed_action_names !== "string") { + allowedActionNames = []; + } else { + try { + const parsed = JSON.parse(raw.allowed_action_names); + allowedActionNames = + Array.isArray(parsed) && + parsed.every((item) => typeof item === "string") + ? [...new Set(parsed)] + : []; + } catch { + // A malformed persisted surface must never restore the full registry. + allowedActionNames = []; + } + } + } return { id: String(raw.id), owner: String(raw.owner), @@ -259,6 +300,7 @@ function rowFromDb(raw: Record): SandboxExecutionRow { error: raw.error === null || raw.error === undefined ? null : String(raw.error), bridgeToolsUsed, + allowedActionNames, createdAt: toNumberOrNull(raw.created_at) ?? 0, startedAt: toNumberOrNull(raw.started_at), finishedAt: toNumberOrNull(raw.finished_at), @@ -280,8 +322,8 @@ export async function createSandboxExecution( ); await client.execute({ sql: `INSERT INTO ${TABLE} - (id, owner, org_id, thread_id, runtime, code, status, timeout_ms, max_output_chars, attempt_count, max_attempts, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, 'queued', ?, ?, 0, ?, ?, ?)`, + (id, owner, org_id, thread_id, runtime, code, status, timeout_ms, max_output_chars, attempt_count, max_attempts, allowed_action_names, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 'queued', ?, ?, 0, ?, ?, ?, ?)`, args: [ id, input.owner, @@ -292,6 +334,9 @@ export async function createSandboxExecution( input.timeoutMs, input.maxOutputChars, maxAttempts, + input.allowedActionNames === undefined + ? null + : JSON.stringify([...new Set(input.allowedActionNames)]), now, now, ], diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f4d8ef0bc3..5a73cb2021 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,6 +9,8 @@ export { type ActionEntry, type ScriptEntry, type ProductionAgentOptions, + type AgentActionSurface, + type AgentActionSurfaceDetails, type ActionTool, type ScriptTool, type AgentMessage, diff --git a/packages/core/src/server/agent-chat-plugin.surface.spec.ts b/packages/core/src/server/agent-chat-plugin.surface.spec.ts index dc72ea804f..4c33cdb752 100644 --- a/packages/core/src/server/agent-chat-plugin.surface.spec.ts +++ b/packages/core/src/server/agent-chat-plugin.surface.spec.ts @@ -2,11 +2,17 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; +import type { ActionEntry } from "../agent/production-agent.js"; +import { attachToolSearch } from "../agent/tool-search.js"; import type { FrameworkToolGroup } from "../framework-tools.js"; import { _agentChatPromptSectionsForTests, buildLeanSystemPrompt, buildLeanRunPolicyPrompt, + filterFrameworkPromptToSurface, + filterPromptActionsToSurface, + filterRuntimeActionsToSurface, + resolveProductionCodeExecutionForActionSurface, resolveHostedBuilderHandoff, resolveInteractiveAgentRunOptions, shouldBlockInProductCodeEditingSurface, @@ -20,6 +26,7 @@ import { buildFrameworkCore, buildFrameworkCoreCompact, } from "./prompts/index.js"; +import { runWithRequestContext } from "./request-context.js"; describe("shouldBlockInProductCodeEditingSurface", () => { it("blocks app-rendered chat surfaces, including legacy iframe labels", () => { @@ -115,6 +122,187 @@ describe("interactive agent run options", () => { }); }); +describe("request-scoped action surface", () => { + it("restores the durable worker org from the validated persisted surface", () => { + const source = readFileSync("src/server/agent-chat-plugin.ts", { + encoding: "utf-8", + }); + + expect(source).toMatch( + /const persistedSurface = readPersistedActionSurface\(\s*workerBody,\s*"__resolvedActionSurface",\s*\);[\s\S]*?seedBackgroundAgentRunOwnerContext\([\s\S]*?persistedSurface\?\.orgId,/, + ); + }); + + it("removes guidance for actions omitted from the request surface", () => { + const prompt = [ + "Keep this general guidance.", + "Use `tool-search` before concluding a capability is unavailable.", + "Delegate with `agent-teams` for independent work.", + "Call `allowed-action` when it matches the request.", + ].join("\n"); + const actions = { + "tool-search": {} as ActionEntry, + "agent-teams": {} as ActionEntry, + "allowed-action": {} as ActionEntry, + }; + + expect( + filterFrameworkPromptToSurface(prompt, actions, ["allowed-action"]), + ).toBe( + [ + "Keep this general guidance.", + "Call `allowed-action` when it matches the request.", + ].join("\n"), + ); + }); + + it("removes denied discovery, team, and job guidance from the default framework prompt", () => { + const { PROD_FRAMEWORK_PROMPT_COMPACT } = + _agentChatPromptSectionsForTests.buildFrameworkPrompts(); + const filtered = filterFrameworkPromptToSurface( + PROD_FRAMEWORK_PROMPT_COMPACT, + { + "tool-search": {} as ActionEntry, + "agent-teams": {} as ActionEntry, + "manage-jobs": {} as ActionEntry, + }, + [], + ); + + expect(filtered).not.toContain("tool-search"); + expect(filtered).not.toContain("agent-teams"); + expect(filtered).not.toContain("manage-jobs"); + expect(filtered).toContain("### How You Work"); + }); + + it("downgrades trusted production code execution to the sandbox for scoped surfaces", () => { + expect( + resolveProductionCodeExecutionForActionSurface("trusted", true), + ).toBe("sandboxed"); + expect( + resolveProductionCodeExecutionForActionSurface("trusted", false), + ).toBe("trusted"); + expect(resolveProductionCodeExecutionForActionSurface("off", true)).toBe( + "off", + ); + }); + + it("wires the safe code-execution mode into the interactive production registry", () => { + const source = readFileSync("src/server/agent-chat-plugin.ts", { + encoding: "utf-8", + }); + + expect(source).toMatch( + /resolveProductionCodeExecutionForActionSurface\(\s*resolvedProdCodeExec,\s*Boolean\(options\?\.resolveActionSurface\),/, + ); + expect(source).toMatch( + /!canToggle && effectiveProdCodeExec === "trusted"\s*\? prodCodingTools/, + ); + }); + + it("removes denied actions before the actions prompt is generated", () => { + const actions = { + allowed: { + tool: { + description: "Allowed action", + parameters: { type: "object", properties: {} }, + }, + run: async () => "allowed", + chatUI: { renderer: "core.allowed" }, + }, + denied: { + tool: { + description: "Denied action", + parameters: { type: "object", properties: {} }, + }, + run: async () => "denied", + chatUI: { renderer: "core.denied" }, + }, + } as never; + + const prompt = _agentChatPromptSectionsForTests.generateActionsPrompt( + filterPromptActionsToSurface(actions, ["allowed"]), + "tool", + ); + + expect(prompt).toContain("core.allowed"); + expect(prompt).not.toContain("Denied action"); + expect(prompt).not.toContain("core.denied"); + }); + + it("forwards the resolver into every interactive production handler", () => { + const source = readFileSync("src/server/agent-chat-plugin.ts", { + encoding: "utf-8", + }); + + expect( + source.match(/resolveActionSurface: options\?\.resolveActionSurface,/g), + ).toHaveLength(3); + }); + + it("filters late-bound sandbox bridge registries to the request surface", async () => { + const actions = attachToolSearch({ + allowed: { + tool: { + description: "Allowed reader", + parameters: { type: "object", properties: {} }, + }, + readOnly: true, + run: async () => "allowed", + }, + denied: { + tool: { + description: "Denied reader", + parameters: { type: "object", properties: {} }, + }, + readOnly: true, + run: async () => "denied", + }, + } satisfies Record); + + await runWithRequestContext( + { + run: { + allowedActionNames: ["allowed", "tool-search", "run-code"], + }, + }, + async () => { + const filtered = filterRuntimeActionsToSurface(actions); + expect(Object.keys(filtered)).toEqual(["allowed", "tool-search"]); + const search = await filtered["tool-search"].run({}); + expect( + search.results.map((item: { name: string }) => item.name), + ).toEqual(["allowed"]); + }, + ); + }); + + it("uses the request-filtered supplier for production, lean, and dev sandbox meta-tools", () => { + const source = readFileSync("src/server/agent-chat-plugin.ts", { + encoding: "utf-8", + }); + + expect( + source.match( + /\(\) => filterRuntimeActionsToSurface\([^)]*RunCodeToolActions\)/g, + ), + ).toHaveLength(3); + }); + + it("filters the action registry before agent-team tasks snapshot it", () => { + const source = readFileSync("src/server/agent-chat-plugin.ts", { + encoding: "utf-8", + }); + + expect(source).toMatch( + /getActions:\s*\(\) =>\s*filterRuntimeActionsToSurface\(buildSubAgentActions\(\)\),/, + ); + expect(source).toMatch( + /baseSystemPrompt: filterFrameworkPromptToSurface\(\s*basePrompt,\s*prodActions,\s*payload\.allowedActionNames,/, + ); + }); +}); + describe("hosted Builder handoff surface", () => { const connectBuilder = { tool: { description: "Render the Builder handoff.", parameters: {} }, diff --git a/packages/core/src/server/agent-chat-plugin.ts b/packages/core/src/server/agent-chat-plugin.ts index 883b5c9991..9fb43793ed 100644 --- a/packages/core/src/server/agent-chat-plugin.ts +++ b/packages/core/src/server/agent-chat-plugin.ts @@ -72,6 +72,8 @@ import { createProductionAgentHandler, actionsToEngineTools, executeAgentToolCall, + filterActionsByAllowedNames, + readPersistedActionSurface, toolCallCacheKey, getActiveRunForThreadAsync, abortRunDurably, @@ -491,6 +493,65 @@ export function buildLeanRunPolicyPrompt( return codeEditingSurfaceRestriction + prodCodeExecPromptNote; } +export function filterPromptActionsToSurface( + actions: Record, + allowedActionNames?: readonly string[], +): Record { + if (!allowedActionNames) return actions; + return filterActionsByAllowedNames( + actions, + allowedActionNames.filter((name) => actions[name]), + ); +} + +/** Keep late-bound sandbox and data-program bridges on the current request's + * authorized registry instead of the plugin's process-wide action catalog. */ +export function filterRuntimeActionsToSurface( + actions: Record, +): Record { + return filterPromptActionsToSurface( + actions, + getRequestRunContext()?.allowedActionNames, + ); +} + +/** Remove framework guidance for tools that the request surface does not + * expose. The default framework prompt is line-oriented, so dropping the + * affected instruction keeps unrelated behavioral guidance intact without + * teaching the model names it cannot call. */ +export function filterFrameworkPromptToSurface( + prompt: string, + actions: Record, + allowedActionNames?: readonly string[], +): string { + if (!allowedActionNames) return prompt; + const allowedNames = new Set(allowedActionNames); + const deniedPatterns = Object.keys(actions) + .filter((name) => !allowedNames.has(name)) + .sort((a, b) => b.length - a.length) + .map((name) => { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(^|[^a-z0-9-])${escaped}(?=$|[^a-z0-9-])`); + }); + if (deniedPatterns.length === 0) return prompt; + return prompt + .split("\n") + .filter((line) => !deniedPatterns.some((pattern) => pattern.test(line))) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trimEnd(); +} + +export function resolveProductionCodeExecutionForActionSurface( + mode: "off" | "sandboxed" | "trusted", + hasRequestScopedSurface: boolean, +): "off" | "sandboxed" | "trusted" { + // A trusted shell can reach action routes outside the native registry, so it + // cannot uphold a hard per-request allowlist. Keep sandboxed run-code, whose + // bridge is filtered against the current request, as the safe equivalent. + return hasRequestScopedSurface && mode === "trusted" ? "sandboxed" : mode; +} + /** * In-memory rate-limit tracker for `/generate-title`. Keyed by user email, * value is recent invocation timestamps within the rolling window. Stale @@ -1296,6 +1357,19 @@ export function createAgentChatPlugin( : rawEnvCodeExec === "off" ? "off" : (options?.codeExecution?.production ?? "off"); + const effectiveProdCodeExec = + resolveProductionCodeExecutionForActionSurface( + resolvedProdCodeExec, + Boolean(options?.resolveActionSurface), + ); + if ( + resolvedProdCodeExec === "trusted" && + effectiveProdCodeExec !== "trusted" + ) { + console.warn( + "[agent-native] Request-scoped action surfaces disable trusted shell tools; using sandboxed code execution instead.", + ); + } // Forward-declaration for the production code-execution bridge supplier. // Must come before the code entries are created so their closures can capture it. @@ -1309,14 +1383,14 @@ export function createAgentChatPlugin( await loadRunCodeToolEntries( // Supplier is evaluated at invocation time so runtime additions to // prodActions (e.g. MCP sync) are visible to the bridge. - () => prodRunCodeToolActions, + () => filterRuntimeActionsToSurface(prodRunCodeToolActions), { bridgeTools: options?.codeExecution?.bridgeTools }, ); const leanRunCodeTool: Record = await loadRunCodeToolEntries( // Lean prompt mode intentionally exposes a much smaller action // surface; keep sandbox appAction() calls scoped to that same surface. - () => leanRunCodeToolActions, + () => filterRuntimeActionsToSurface(leanRunCodeToolActions), { bridgeTools: options?.codeExecution?.bridgeTools }, ); @@ -1350,9 +1424,12 @@ export function createAgentChatPlugin( // use a late-binding supplier so devRunCodeTool can reference the // devActions registry once it is built below (see devHandler block). const devRunCodeTool: Record = canToggle - ? await loadRunCodeToolEntries(() => devRunCodeToolActions, { - bridgeTools: options?.codeExecution?.bridgeTools, - }) + ? await loadRunCodeToolEntries( + () => filterRuntimeActionsToSurface(devRunCodeToolActions), + { + bridgeTools: options?.codeExecution?.bridgeTools, + }, + ) : {}; // Registry `generateCorpusToolsPrompt` (below) reads from to decide what @@ -1362,7 +1439,7 @@ export function createAgentChatPlugin( ...templateScripts, ...(canToggle ? devRunCodeTool - : resolvedProdCodeExec !== "off" + : effectiveProdCodeExec !== "off" ? runCodeTool : {}), }; @@ -2213,6 +2290,52 @@ export function createAgentChatPlugin( "cli", ) + corpusToolsPrompt; + const filterPromptActionsForRequest = ( + actions: Record, + ): Record => { + return filterPromptActionsToSurface( + actions, + getRequestRunContext()?.allowedActionNames, + ); + }; + + const resolveRequestActionsPrompt = (mode: "tool" | "cli"): string => { + const allowedNames = getRequestRunContext()?.allowedActionNames; + if (!allowedNames) { + return mode === "tool" ? prodActionsPrompt : devActionsPrompt; + } + const promptActions = filterPromptActionsForRequest( + mode === "tool" + ? templateScripts + : { ...discoveredActions, ...templateScripts }, + ); + const promptCorpus = + filterPromptActionsForRequest(corpusPromptRegistry); + return ( + generateActionsPrompt(promptActions, mode) + + (loadCorpusToolsInitially + ? generateCorpusToolsPrompt(promptCorpus) + : "") + ); + }; + + const leanActionsPrompt = + prodActionsPrompt + + (a2aAgentDelegationEnabled + ? generateActionsPrompt(callAgentScript, "tool") + : ""); + const resolveRequestLeanActionsPrompt = (): string => { + const allowedNames = getRequestRunContext()?.allowedActionNames; + if (!allowedNames) return leanActionsPrompt; + return generateActionsPrompt( + filterPromptActionsForRequest({ + ...templateScripts, + ...(a2aAgentDelegationEnabled ? callAgentScript : {}), + }), + "tool", + ); + }; + // Build system prompts — dynamic functions that pre-load resources per-request. // Production gets PROD_FRAMEWORK_PROMPT, dev gets DEV_FRAMEWORK_PROMPT. // Custom systemPrompt from options overrides the framework default entirely. @@ -2225,20 +2348,43 @@ export function createAgentChatPlugin( // `nativeActionsInDev` or `leanPrompt`), the dev prompt's "invoke // template actions via bash" guidance is wrong — use the prod prompt // + tool-format action list instead, same as production. - const devNative = options?.nativeActionsInDev === true || leanPrompt; - const devPrompt = devNative - ? prodPrompt - : (options?.devSystemPrompt - ? options.devSystemPrompt + - (options?.systemPrompt ?? - (lazyContext - ? PROD_FRAMEWORK_PROMPT_COMPACT - : PROD_FRAMEWORK_PROMPT)) - : lazyContext - ? DEV_FRAMEWORK_PROMPT_COMPACT - : DEV_FRAMEWORK_PROMPT) + devActionsPrompt; + const devNative = + options?.nativeActionsInDev === true || + leanPrompt || + Boolean(options?.resolveActionSurface); // Keep legacy names for the composition below const basePrompt = prodPrompt; + const getFrameworkPromptActions = (): Record => + Object.fromEntries( + Object.entries(prodActions).filter( + ([name]) => !templateScripts[name] && !mcpActionEntries[name], + ), + ); + + const resolveRequestBasePrompt = (): string => + (options?.systemPrompt ?? + filterFrameworkPromptToSurface( + lazyContext ? PROD_FRAMEWORK_PROMPT_COMPACT : PROD_FRAMEWORK_PROMPT, + getFrameworkPromptActions(), + getRequestRunContext()?.allowedActionNames, + )) + resolveRequestActionsPrompt("tool"); + + const resolveRequestLeanPrompt = (): string => + (options?.systemPrompt ?? "") + resolveRequestLeanActionsPrompt(); + + const resolveRequestDevPrompt = (): string => { + if (devNative) return resolveRequestBasePrompt(); + const frameworkPrompt = options?.devSystemPrompt + ? options.devSystemPrompt + + (options?.systemPrompt ?? + (lazyContext + ? PROD_FRAMEWORK_PROMPT_COMPACT + : PROD_FRAMEWORK_PROMPT)) + : lazyContext + ? DEV_FRAMEWORK_PROMPT_COMPACT + : DEV_FRAMEWORK_PROMPT; + return frameworkPrompt + resolveRequestActionsPrompt("cli"); + }; if (mcpOptions.enabled) { // Mount MCP remote server — same action registry as A2A + agent chat @@ -2892,7 +3038,7 @@ export function createAgentChatPlugin( getOwner: () => requireCurrentRunOwner("spawn or manage sub-agents"), getSystemPrompt: () => getRequestRunContext()?.systemPrompt ?? basePrompt, - getActions: buildSubAgentActions, + getActions: () => filterRuntimeActionsToSurface(buildSubAgentActions()), getEngine: () => { const runCtx = getRequestRunContext(); // Sub-agents must inherit the parent run's resolved key so @@ -2999,9 +3145,11 @@ export function createAgentChatPlugin( ...mcpActionEntries, // Sandboxed run-code for hosted production when enabled, and for the // app-rendered production-style handler in local dev. - ...(canToggle || resolvedProdCodeExec !== "off" ? runCodeTool : {}), + ...(canToggle || effectiveProdCodeExec !== "off" ? runCodeTool : {}), // Full coding tools in production when mode is "trusted". - ...(!canToggle ? prodCodingTools : {}), + ...(!canToggle && effectiveProdCodeExec === "trusted" + ? prodCodingTools + : {}), }); mountRealtimeVoiceRoutes(nitroApp, prodActions, { @@ -3053,7 +3201,9 @@ export function createAgentChatPlugin( // Otherwise templates with a minimal prompt can advertise sandboxed // execution in the system prompt while the actual tool registry omits // it. - ...(canToggle || resolvedProdCodeExec !== "off" ? leanRunCodeTool : {}), + ...(canToggle || effectiveProdCodeExec !== "off" + ? leanRunCodeTool + : {}), }); leanRunCodeToolActions = leanActions; @@ -3073,12 +3223,6 @@ export function createAgentChatPlugin( // Lean mode: use only the template's systemPrompt + actions list. // Skip resource loading and schema block — those add DB round-trips // and tokens that minimal/voice apps don't need. - const leanActionsPrompt = - prodActionsPrompt + - (a2aAgentDelegationEnabled - ? generateActionsPrompt(callAgentScript, "tool") - : ""); - const leanBasePrompt = (options?.systemPrompt ?? "") + leanActionsPrompt; const anonymousReadOnlyPrompt = (options?.systemPrompt ?? PROD_FRAMEWORK_PROMPT_COMPACT) + generateActionsPrompt( @@ -3087,6 +3231,26 @@ export function createAgentChatPlugin( lazyContext ? effectiveInitialToolNames : undefined, ) + "\n\nYou are answering from a public shared page. Treat the visible resource as read-only: do not create, edit, delete, comment on, share, or otherwise mutate app data. If the user asks for a change, describe what you would change or suggest signing in to edit."; + const resolveAnonymousReadOnlyPrompt = (): string => { + if (!getRequestRunContext()?.allowedActionNames) { + return anonymousReadOnlyPrompt; + } + return ( + (options?.systemPrompt ?? + filterFrameworkPromptToSurface( + PROD_FRAMEWORK_PROMPT_COMPACT, + getFrameworkPromptActions(), + getRequestRunContext()?.allowedActionNames, + )) + + generateActionsPrompt( + filterPromptActionsForRequest( + filterReadOnlyActions(templateScripts), + ), + "tool", + ) + + "\n\nYou are answering from a public shared page. Treat the visible resource as read-only: do not create, edit, delete, comment on, share, or otherwise mutate app data. If the user asks for a change, describe what you would change or suggest signing in to edit." + ); + }; // Per-request preamble shared by both prod and dev handlers. Resolves // owner + user API key onto the AsyncLocalStorage run context so @@ -3276,8 +3440,8 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su // System-prompt note appended when production code execution is enabled. const prodCodeExecPromptNote = - !canToggle && resolvedProdCodeExec !== "off" - ? resolvedProdCodeExec === "trusted" + !canToggle && effectiveProdCodeExec !== "off" + ? effectiveProdCodeExec === "trusted" ? "\n\nFull shell access is enabled (trusted mode). You have bash, read, edit, write, and run-code tools available. Use bash for file discovery, running tests and builds, and project CLIs. Use run-code for sandboxed JavaScript data processing: provider/API pagination, joins, classification, aggregation, and large-response reduction. Use tool-orchestration for short bounded fan-out or reduction over read-only tools. Use `pnpm action ` in bash to invoke registered app actions from the shell." : "\n\nSandboxed code execution is enabled. Use tool-orchestration for short bounded fan-out, joins, and reduction over read-only tools. Use run-code when you need its broader provider/web helpers, workspace staging, or durable background execution. In either tool, authenticated calls go through the provided host globals and results should be reduced before printing." : ""; @@ -3286,6 +3450,10 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su actions: leanPrompt ? leanActions : prodActions, systemPrompt: async (event: any) => { const { owner, extra } = await prepareRun(event); + const requestActionsPrompt = resolveRequestActionsPrompt("tool"); + const requestLeanActionsPrompt = resolveRequestLeanActionsPrompt(); + const requestBasePrompt = resolveRequestBasePrompt(); + const requestLeanPrompt = resolveRequestLeanPrompt(); const runtimeContext = runtimeContextForEvent(event); const codeEditingSurfaceRestriction = shouldBlockInProductCodeEditing( event, @@ -3315,11 +3483,14 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su { disabledFrameworkGroups }, ); await emitContextXraySystemSections(event, { - frameworkPrompt: leanBasePrompt.slice( + frameworkPrompt: requestLeanPrompt.slice( 0, - Math.max(0, leanBasePrompt.length - leanActionsPrompt.length), + Math.max( + 0, + requestLeanPrompt.length - requestLeanActionsPrompt.length, + ), ), - actionsPrompt: leanActionsPrompt, + actionsPrompt: requestLeanActionsPrompt, additionalFramework: leanRunPolicyPrompt, resources, extra, @@ -3328,7 +3499,7 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su }); return setSystemPromptOnContext( buildLeanSystemPrompt({ - basePrompt: leanBasePrompt, + basePrompt: requestLeanPrompt, additionalFramework: leanRunPolicyPrompt, cacheSplit: SYSTEM_PROMPT_CACHE_SPLIT, resources, @@ -3351,11 +3522,14 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su ? "" : await buildSchemaBlock(owner, databaseToolsMode); await emitContextXraySystemSections(event, { - frameworkPrompt: basePrompt.slice( + frameworkPrompt: requestBasePrompt.slice( 0, - Math.max(0, basePrompt.length - prodActionsPrompt.length), + Math.max( + 0, + requestBasePrompt.length - requestActionsPrompt.length, + ), ), - actionsPrompt: prodActionsPrompt, + actionsPrompt: requestActionsPrompt, resources, schemaBlock, extra, @@ -3365,7 +3539,7 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su codeEditingSurfaceRestriction + prodCodeExecPromptNote, }); return setSystemPromptOnContext( - basePrompt + + requestBasePrompt + SYSTEM_PROMPT_CACHE_SPLIT + resources + schemaBlock + @@ -3437,6 +3611,7 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su message, }; }, + resolveActionSurface: options?.resolveActionSurface, skipFilesContext, initialToolNames: effectiveInitialToolNames, ...(options?.toolLimits ? { toolLimits: options.toolLimits } : {}), @@ -3475,13 +3650,14 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su actions: anonymousReadOnlyActions, systemPrompt: async (event: any) => { const { extra } = await prepareRun(event); + const requestAnonymousPrompt = resolveAnonymousReadOnlyPrompt(); await emitContextXraySystemSections(event, { - frameworkPrompt: anonymousReadOnlyPrompt, + frameworkPrompt: requestAnonymousPrompt, extra, runtimeContext: runtimeContextForEvent(event), }); return setSystemPromptOnContext( - anonymousReadOnlyPrompt + + requestAnonymousPrompt + extra + runtimeContextForEvent(event), ); @@ -3492,6 +3668,7 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su ...resolveInteractiveAgentRunOptions(options), finalResponseGuard: options?.finalResponseGuard, prepareRequest: options?.prepareRequest, + resolveActionSurface: options?.resolveActionSurface, skipFilesContext: true, initialToolNames: effectiveInitialToolNames, onEngineResolved: (engine, model) => { @@ -3593,6 +3770,12 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su actions: devActions, systemPrompt: async (event: any) => { const { owner, extra } = await prepareRun(event); + const requestActionsPrompt = resolveRequestActionsPrompt( + devNative ? "tool" : "cli", + ); + const requestLeanActionsPrompt = resolveRequestLeanActionsPrompt(); + const requestLeanPrompt = resolveRequestLeanPrompt(); + const requestDevPrompt = resolveRequestDevPrompt(); const runtimeContext = runtimeContextForEvent(event); const modelOverlay = resolveModelOverlay(); // Stable-first ordering: runtimeContext (day-granular) is @@ -3608,11 +3791,14 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su { disabledFrameworkGroups }, ); await emitContextXraySystemSections(event, { - frameworkPrompt: leanBasePrompt.slice( + frameworkPrompt: requestLeanPrompt.slice( 0, - Math.max(0, leanBasePrompt.length - leanActionsPrompt.length), + Math.max( + 0, + requestLeanPrompt.length - requestLeanActionsPrompt.length, + ), ), - actionsPrompt: leanActionsPrompt, + actionsPrompt: requestLeanActionsPrompt, resources, extra, modelOverlay, @@ -3620,7 +3806,7 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su }); return setSystemPromptOnContext( buildLeanSystemPrompt({ - basePrompt: leanBasePrompt, + basePrompt: requestLeanPrompt, resources, extra, modelOverlay, @@ -3640,16 +3826,14 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su ? "" : await buildSchemaBlock(owner, databaseToolsMode); await emitContextXraySystemSections(event, { - frameworkPrompt: devNative - ? basePrompt.slice( - 0, - Math.max(0, basePrompt.length - prodActionsPrompt.length), - ) - : devPrompt.slice( - 0, - Math.max(0, devPrompt.length - devActionsPrompt.length), - ), - actionsPrompt: devNative ? prodActionsPrompt : devActionsPrompt, + frameworkPrompt: requestDevPrompt.slice( + 0, + Math.max( + 0, + requestDevPrompt.length - requestActionsPrompt.length, + ), + ), + actionsPrompt: requestActionsPrompt, resources, schemaBlock, extra, @@ -3657,7 +3841,7 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su runtimeContext, }); return setSystemPromptOnContext( - devPrompt + + requestDevPrompt + resources + schemaBlock + extra + @@ -3690,6 +3874,7 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su } return options?.prepareRequest?.(details); }, + resolveActionSurface: options?.resolveActionSurface, skipFilesContext, initialToolNames: effectiveInitialToolNames, ...(options?.toolLimits ? { toolLimits: options.toolLimits } : {}), @@ -3890,7 +4075,11 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su // just told, and filtering to the full set would only add a // tool-search schema with nothing new for it to find. return { - baseSystemPrompt: basePrompt, + baseSystemPrompt: filterFrameworkPromptToSurface( + basePrompt, + prodActions, + payload.allowedActionNames, + ), actions: buildSubAgentActions(), engine, model, @@ -6023,7 +6212,15 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su // helper expands that owner into the same user/org AsyncLocalStorage // context the foreground request uses, so credential and data scoping // stay aligned. - await seedBackgroundAgentRunOwnerContext(event, prepared.runId); + const persistedSurface = readPersistedActionSurface( + workerBody, + "__resolvedActionSurface", + ); + await seedBackgroundAgentRunOwnerContext( + event, + prepared.runId, + persistedSurface?.orgId, + ); return await invokeAgentChatHandler(event); } catch (err: any) { console.error("[agent-chat] _process-run failed:", err); diff --git a/packages/core/src/server/agent-chat/plugin-options.ts b/packages/core/src/server/agent-chat/plugin-options.ts index 53688b804a..c17811931a 100644 --- a/packages/core/src/server/agent-chat/plugin-options.ts +++ b/packages/core/src/server/agent-chat/plugin-options.ts @@ -1,5 +1,8 @@ -import type { ActionEntry } from "../../agent/production-agent.js"; -import type { AgentLoopFinalResponseGuard } from "../../agent/production-agent.js"; +import type { + ActionEntry, + AgentLoopFinalResponseGuard, + ProductionAgentOptions, +} from "../../agent/production-agent.js"; import type { AgentChatAttachment, AgentChatReference, @@ -189,6 +192,12 @@ export interface AgentChatPluginOptions { displayMessage?: string; attachments?: AgentChatAttachment[]; }>; + /** + * Resolve the exact native action surface for each interactive chat request. + * Omitted names are not sent to the model and are not discoverable through + * tool-search. When configured, every allowed action is loaded directly. + */ + resolveActionSurface?: ProductionAgentOptions["resolveActionSurface"]; /** * Use ONLY the template's `systemPrompt` and the actions list — skip the * framework prompt wrapper, resource loading (AGENTS.md/LEARNINGS.md/ diff --git a/packages/core/src/server/agent-run-context.spec.ts b/packages/core/src/server/agent-run-context.spec.ts index f4411de0a2..e203c91e63 100644 --- a/packages/core/src/server/agent-run-context.spec.ts +++ b/packages/core/src/server/agent-run-context.spec.ts @@ -231,4 +231,38 @@ describe("server/agent-run-context", () => { await expect(resolveAgentRunOwnerContext(event)).resolves.toBe(seeded); expect(getSessionMock).not.toHaveBeenCalled(); }); + + it("preserves the foreground org binding for cookieless background workers", async () => { + const event = makeEvent(); + getRunOwnerEmailMock.mockResolvedValue("owner@example.com"); + resolveOrgIdForEmailMock.mockResolvedValue("org-from-another-membership"); + + const seeded = await seedBackgroundAgentRunOwnerContext( + event, + "run_123", + "org-from-foreground", + ); + + await expect( + resolveAgentRunOrgId({ event, ownerContext: seeded! }), + ).resolves.toBe("org-from-foreground"); + expect(resolveOrgIdForEmailMock).not.toHaveBeenCalled(); + }); + + it("preserves an explicitly org-less foreground request in the worker", async () => { + const event = makeEvent(); + getRunOwnerEmailMock.mockResolvedValue("owner@example.com"); + resolveOrgIdForEmailMock.mockResolvedValue("org-from-membership"); + + const seeded = await seedBackgroundAgentRunOwnerContext( + event, + "run_123", + null, + ); + + await expect( + resolveAgentRunOrgId({ event, ownerContext: seeded! }), + ).resolves.toBeUndefined(); + expect(resolveOrgIdForEmailMock).not.toHaveBeenCalled(); + }); }); diff --git a/packages/core/src/server/agent-run-context.ts b/packages/core/src/server/agent-run-context.ts index 30e1d4e11b..7caaee5187 100644 --- a/packages/core/src/server/agent-run-context.ts +++ b/packages/core/src/server/agent-run-context.ts @@ -11,6 +11,12 @@ export type AgentRunOwnerContext = { owner: string; anonymous: boolean; name?: string; + /** + * Trusted org binding for a cookieless durable worker. Presence matters: + * `null` means the authenticated foreground request had no org and must not + * fall back to another membership during worker re-entry. + */ + orgId?: string | null; }; export const AGENT_RUN_OWNER_CONTEXT_KEY = "__agentNativeOwnerContext"; @@ -109,12 +115,17 @@ export function seedAgentRunOwnerContext( export async function seedBackgroundAgentRunOwnerContext( event: H3Event, runId: string, + orgId?: string | null, ): Promise { try { const { getRunOwnerEmail } = await import("../agent/run-store.js"); const owner = await getRunOwnerEmail(runId); if (!owner) return null; - return seedAgentRunOwnerContext(event, { owner, anonymous: false }); + return seedAgentRunOwnerContext(event, { + owner, + anonymous: false, + ...(orgId !== undefined ? { orgId } : {}), + }); } catch { return null; } @@ -158,6 +169,10 @@ export async function resolveAgentRunOrgId(options: { ownerContext: AgentRunOwnerContext; resolveOrgId?: OrgIdResolver; }): Promise { + if (Object.prototype.hasOwnProperty.call(options.ownerContext, "orgId")) { + return normalizeId(options.ownerContext.orgId); + } + let resolvedOrgId: string | undefined; if (options.resolveOrgId) { diff --git a/packages/core/src/server/agent-teams-delegation-depth.spec.ts b/packages/core/src/server/agent-teams-delegation-depth.spec.ts index b21e3c5319..4a68ceb22b 100644 --- a/packages/core/src/server/agent-teams-delegation-depth.spec.ts +++ b/packages/core/src/server/agent-teams-delegation-depth.spec.ts @@ -2,6 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // ── app_state (task records + thread reverse-lookup) ────────────────────── const appState = vi.hoisted(() => new Map>()); +const requestContextState = vi.hoisted(() => ({ + active: false, + orgId: undefined as string | undefined, + allowedActionNames: undefined as readonly string[] | undefined, +})); vi.mock("../application-state/script-helpers.js", () => ({ readAppState: vi.fn(async (key: string) => appState.get(key) ?? null), @@ -42,8 +47,11 @@ vi.mock("./self-dispatch.js", () => ({ fireInternalDispatch: fireInternalDispatchMock, })); +const resolveOrgIdForEmailMock = vi.hoisted(() => + vi.fn(async () => "email-selected-org" as string | null), +); vi.mock("../org/context.js", () => ({ - resolveOrgIdForEmail: vi.fn(async () => null), + resolveOrgIdForEmail: resolveOrgIdForEmailMock, })); vi.mock("../progress/registry.js", () => ({ @@ -53,7 +61,12 @@ vi.mock("../progress/registry.js", () => ({ })); vi.mock("./request-context.js", () => ({ + getRequestOrgId: () => requestContextState.orgId, + getRequestRunContext: () => ({ + allowedActionNames: requestContextState.allowedActionNames, + }), getRequestUserEmail: () => "owner@example.com", + hasRequestContext: () => requestContextState.active, runWithRequestContext: (_ctx: unknown, fn: () => unknown) => fn(), })); @@ -80,6 +93,11 @@ describe("agent-teams delegation-depth guardrail", () => { enqueueAgentTeamRunMock.mockClear(); fireInternalDispatchMock.mockClear(); createThreadMock.mockClear(); + resolveOrgIdForEmailMock.mockClear(); + resolveOrgIdForEmailMock.mockResolvedValue("email-selected-org"); + requestContextState.active = false; + requestContextState.orgId = undefined; + requestContextState.allowedActionNames = undefined; delete process.env.AGENT_NATIVE_MAX_SUBAGENT_DEPTH; }); @@ -103,6 +121,77 @@ describe("agent-teams delegation-depth guardrail", () => { FIRST_AGENT_TEAMS_IMPORT_TIMEOUT_MS, ); + it("persists the exact spawned action surface for durable execution", async () => { + const { spawnTask } = await import("./agent-teams.js"); + requestContextState.allowedActionNames = ["agent-teams"]; + + await spawnTask({ + ...baseSpawnOptions(), + actions: { + allowed: { + tool: { description: "Allowed", parameters: {} }, + run: async () => "ok", + }, + }, + }); + + expect(enqueueAgentTeamRunMock).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + allowedActionNames: ["allowed"], + }), + }), + ); + }); + + it("keeps unscoped durable tasks on their legacy action behavior", async () => { + const { spawnTask } = await import("./agent-teams.js"); + + await spawnTask({ + ...baseSpawnOptions(), + actions: { + reader: { + tool: { description: "Reader", parameters: {} }, + run: async () => "ok", + }, + }, + }); + + expect(enqueueAgentTeamRunMock).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.not.objectContaining({ + allowedActionNames: expect.anything(), + }), + }), + ); + }); + + it("persists the active parent organization instead of resolving another membership by email", async () => { + const { spawnTask } = await import("./agent-teams.js"); + requestContextState.active = true; + requestContextState.orgId = "parent-org"; + + await spawnTask(baseSpawnOptions()); + + expect(enqueueAgentTeamRunMock).toHaveBeenCalledWith( + expect.objectContaining({ orgId: "parent-org" }), + ); + expect(resolveOrgIdForEmailMock).not.toHaveBeenCalled(); + }); + + it("preserves an explicitly org-less parent request", async () => { + const { spawnTask } = await import("./agent-teams.js"); + requestContextState.active = true; + requestContextState.orgId = undefined; + + await spawnTask(baseSpawnOptions()); + + expect(enqueueAgentTeamRunMock).toHaveBeenCalledWith( + expect.objectContaining({ orgId: null }), + ); + expect(resolveOrgIdForEmailMock).not.toHaveBeenCalled(); + }); + it("scopes a spawned thread to the parent app", async () => { const { spawnTask } = await import("./agent-teams.js"); diff --git a/packages/core/src/server/agent-teams-process-run.spec.ts b/packages/core/src/server/agent-teams-process-run.spec.ts index 266eccbeab..f0c9f7d4cd 100644 --- a/packages/core/src/server/agent-teams-process-run.spec.ts +++ b/packages/core/src/server/agent-teams-process-run.spec.ts @@ -127,8 +127,13 @@ vi.mock("../db/client.js", () => ({ // ── app_state (task records + thread reverse-lookup) ────────────────────── const appState = new Map(); -const requestContexts: Array<{ userEmail?: string; orgId?: string }> = []; -let activeRequestContext: { userEmail?: string; orgId?: string } | undefined; +type MockRequestContext = { + userEmail?: string; + orgId?: string; + run?: { allowedActionNames?: readonly string[] }; +}; +const requestContexts: MockRequestContext[] = []; +let activeRequestContext: MockRequestContext | undefined; function requireMockRequestContext(): void { if (!activeRequestContext?.userEmail) { @@ -268,7 +273,32 @@ function fakeFilterInitialEngineTools( vi.mock("../agent/production-agent.js", () => ({ actionsToEngineTools: (actions: any) => actionsToEngineToolsMock(actions), + filterActionsByAllowedNames: ( + actions: Record, + allowedActionNames: string[], + ) => { + const unknown = allowedActionNames.filter((name) => !actions[name]); + if (unknown.length > 0) throw new Error(`Unknown actions: ${unknown}`); + return Object.fromEntries( + allowedActionNames.map((name) => [name, actions[name]]), + ); + }, filterInitialEngineTools: fakeFilterInitialEngineTools, + readPersistedAllowedActionNames: (value: unknown) => { + if ( + typeof value !== "object" || + value === null || + !Object.prototype.hasOwnProperty.call(value, "allowedActionNames") + ) { + return undefined; + } + const names = (value as { allowedActionNames?: unknown }) + .allowedActionNames; + return Array.isArray(names) && + names.every((name) => typeof name === "string") + ? [...new Set(names)] + : []; + }, resolveAgentRequestReasoningEffort: ({ model }: { model: string }) => model === "gpt-5.6" ? "medium" : undefined, resolveMainChatMaxOutputTokens: (model: string) => @@ -311,7 +341,7 @@ vi.mock("../org/context.js", () => ({ vi.mock("./request-context.js", () => ({ getRequestUserEmail: () => activeRequestContext?.userEmail, - getRequestRunContext: () => undefined, + getRequestRunContext: () => activeRequestContext?.run, runWithRequestContext: (ctx: any, fn: () => any) => { const previous = activeRequestContext; activeRequestContext = ctx; @@ -352,7 +382,11 @@ const { runWithRequestContext } = await import("./request-context.js"); const OWNER = "owner@example.com"; -async function seedTask(taskId: string, parentRunId?: string) { +async function seedTask( + taskId: string, + parentRunId?: string, + allowedActionNames?: string[], +) { await queue.enqueueAgentTeamRun({ taskId, threadId: "thread-1", @@ -363,6 +397,7 @@ async function seedTask(taskId: string, parentRunId?: string) { description: "do the thing", turnId: `run-task-${taskId}`, ...(parentRunId ? { parentRunId } : {}), + ...(allowedActionNames ? { allowedActionNames } : {}), }, }); appState.set(`agent-task:${taskId}`, { @@ -471,6 +506,118 @@ describe("processAgentTeamRun (durable serverless execution)", () => { ); }); + it("reapplies the persisted action surface in the durable processor", async () => { + actionsToEngineToolsMock.mockImplementation((actions: any) => + Object.keys(actions).map((name) => ({ name })), + ); + runAgentLoopMock.mockImplementation(async () => {}); + await seedTask("surface", undefined, ["allowed"]); + + await processAgentTeamRun({ + taskId: "surface", + mode: "start", + resolveConfig: async () => ({ + ...resolveConfig(), + actions: { + allowed: { + tool: { description: "Allowed", parameters: {} }, + run: async () => "allowed", + }, + denied: { + tool: { description: "Denied", parameters: {} }, + run: async () => "denied", + }, + }, + }), + }); + + expect(actionsToEngineToolsMock).toHaveBeenCalledWith( + expect.objectContaining({ allowed: expect.any(Object) }), + ); + expect(actionsToEngineToolsMock).not.toHaveBeenCalledWith( + expect.objectContaining({ denied: expect.any(Object) }), + ); + }); + + it("restores the persisted surface inside the durable agent loop context", async () => { + let observedAllowedActionNames: readonly string[] | undefined; + runAgentLoopMock.mockImplementation(async () => { + observedAllowedActionNames = + activeRequestContext?.run?.allowedActionNames; + }); + await seedTask("surface-context", undefined, ["allowed"]); + + await processAgentTeamRun({ + taskId: "surface-context", + mode: "start", + resolveConfig: async () => ({ + ...resolveConfig(), + actions: { + allowed: { + tool: { description: "Allowed", parameters: {} }, + run: async () => "allowed", + }, + }, + }), + }); + + expect(runAgentLoopMock).toHaveBeenCalledTimes(1); + expect(observedAllowedActionNames).toEqual(["allowed"]); + }); + + it("treats a malformed persisted sub-agent surface as an empty allowlist", async () => { + actionsToEngineToolsMock.mockImplementation((actions: any) => + Object.keys(actions).map((name) => ({ name })), + ); + runAgentLoopMock.mockImplementation(async () => {}); + await seedTask("malformed-surface"); + const queued = queueRows.find((row) => row.task_id === "malformed-surface"); + if (!queued) throw new Error("missing malformed-surface queue row"); + queued.payload = JSON.stringify({ + description: "do the thing", + turnId: "run-task-malformed-surface", + allowedActionNames: null, + }); + let resolvedAllowedActionNames: unknown; + + await processAgentTeamRun({ + taskId: "malformed-surface", + mode: "start", + resolveConfig: async ({ payload }) => { + resolvedAllowedActionNames = payload.allowedActionNames; + return { + ...resolveConfig(), + actions: { + denied: { + tool: { description: "Denied", parameters: {} }, + run: async () => "denied", + }, + }, + }; + }, + }); + + expect(resolvedAllowedActionNames).toEqual([]); + expect(actionsToEngineToolsMock).toHaveBeenCalledWith({}); + }, 20_000); + + it("fails closed if a persisted sub-agent action no longer exists", async () => { + await seedTask("missing-surface", undefined, ["removed"]); + + const result = await processAgentTeamRun({ + taskId: "missing-surface", + mode: "start", + resolveConfig: async () => ({ + ...resolveConfig(), + actions: {}, + }), + }); + + expect(result).toEqual({ ok: false, skipped: "config-failed" }); + expect(appState.get("agent-task:missing-surface").status).toBe("errored"); + expect(runAgentLoopMock).not.toHaveBeenCalled(); + }); + it("defers framework-added tools behind tool-search on the first sub-agent request when an initial tool list is supplied", async () => { actionsToEngineToolsMock.mockImplementation( (actionsMap: Record) => diff --git a/packages/core/src/server/agent-teams-run-queue.ts b/packages/core/src/server/agent-teams-run-queue.ts index cca21b2364..4e1f88d6f7 100644 --- a/packages/core/src/server/agent-teams-run-queue.ts +++ b/packages/core/src/server/agent-teams-run-queue.ts @@ -60,6 +60,9 @@ export interface AgentTeamRunPayload { parentRunId?: string; /** Display name for the sub-agent tab. */ name?: string; + /** Exact action registry captured when the parent spawned this task. Older + * queue rows omit it and retain their original unscoped behavior. */ + allowedActionNames?: string[]; /** Logical-turn id, stable across continuation chunks so durable assistant * messages fold into one. */ turnId: string; diff --git a/packages/core/src/server/agent-teams.ts b/packages/core/src/server/agent-teams.ts index 724d498d7f..affdc9acb2 100644 --- a/packages/core/src/server/agent-teams.ts +++ b/packages/core/src/server/agent-teams.ts @@ -26,7 +26,9 @@ import type { } from "../agent/production-agent.js"; import { actionsToEngineTools, + filterActionsByAllowedNames, filterInitialEngineTools, + readPersistedAllowedActionNames, resolveAgentRequestReasoningEffort, } from "../agent/production-agent.js"; import { @@ -93,7 +95,10 @@ import { type AgentTeamRunPayload, } from "./agent-teams-run-queue.js"; import { + getRequestOrgId, + getRequestRunContext, getRequestUserEmail, + hasRequestContext, runWithRequestContext, } from "./request-context.js"; import { fireInternalDispatch } from "./self-dispatch.js"; @@ -1390,11 +1395,15 @@ export async function spawnTask(opts: SpawnTaskOptions): Promise { // (integrations/webhook-handler.ts). Execution happens in `processAgentTeamRun`, // invoked by the `/_agent-native/agent-teams/_process-run` route mounted // inside the agent-chat plugin (where the action/prompt/engine closures live). - let orgId: string | null = null; - try { - orgId = (await resolveOrgIdForEmail(opts.ownerEmail)) ?? null; - } catch { - orgId = null; + let orgId: string | null; + if (hasRequestContext()) { + orgId = getRequestOrgId() ?? null; + } else { + try { + orgId = await resolveOrgIdForEmail(opts.ownerEmail); + } catch { + orgId = null; + } } const payload: AgentTeamRunPayload = { @@ -1404,6 +1413,9 @@ export async function spawnTask(opts: SpawnTaskOptions): Promise { ...(opts.parentThreadId ? { parentThreadId: opts.parentThreadId } : {}), ...(opts.parentRunId ? { parentRunId: opts.parentRunId } : {}), ...(opts.name ? { name: opts.name } : {}), + ...(getRequestRunContext()?.allowedActionNames !== undefined + ? { allowedActionNames: Object.keys(opts.actions) } + : {}), // Stable across continuation chunks so the durable assistant message folds. turnId: runId, }; @@ -1672,11 +1684,25 @@ export async function processAgentTeamRun( ): Promise<{ ok: boolean; skipped?: string }> { const claimed = await claimAgentTeamRun(opts.taskId); if (!claimed) return { ok: true, skipped: "already-claimed-or-missing" }; + const persistedAllowedActionNames = readPersistedAllowedActionNames( + claimed.payload, + ); + const payload = + persistedAllowedActionNames === undefined + ? claimed.payload + : { + ...claimed.payload, + allowedActionNames: persistedAllowedActionNames, + }; return await runWithRequestContext( { userEmail: claimed.ownerEmail ?? undefined, orgId: claimed.orgId ?? undefined, + run: + persistedAllowedActionNames === undefined + ? undefined + : { allowedActionNames: persistedAllowedActionNames }, }, async () => { const task = await loadTask(opts.taskId); @@ -1692,7 +1718,6 @@ export async function processAgentTeamRun( return { ok: true, skipped: "task-terminal" }; } - const payload = claimed.payload; const ownerEmail = claimed.ownerEmail ?? getRequestUserEmail() ?? ""; const orgId = claimed.orgId; const turnId = payload.turnId || taskRunId(opts.taskId); @@ -1700,6 +1725,15 @@ export async function processAgentTeamRun( let config: AgentTeamRunConfig; try { config = await opts.resolveConfig({ payload, ownerEmail, orgId }); + if (persistedAllowedActionNames !== undefined) { + config = { + ...config, + actions: filterActionsByAllowedNames( + config.actions, + persistedAllowedActionNames, + ), + }; + } } catch (err) { const message = err instanceof Error @@ -1845,7 +1879,14 @@ export async function processAgentTeamRun( } }; await runWithRequestContext( - { userEmail: ownerEmail || undefined, orgId: orgId ?? undefined }, + { + userEmail: ownerEmail || undefined, + orgId: orgId ?? undefined, + run: + persistedAllowedActionNames === undefined + ? undefined + : { allowedActionNames: persistedAllowedActionNames }, + }, // Record THIS sub-agent's own delegation depth as the ambient // depth for the duration of its agent loop. If a tool call from // within the loop reaches `spawnTask` (even with the team tool not diff --git a/packages/core/src/server/index.ts b/packages/core/src/server/index.ts index 42ce47ae0c..a7b4fc7365 100644 --- a/packages/core/src/server/index.ts +++ b/packages/core/src/server/index.ts @@ -108,6 +108,8 @@ export { type ActionEntry, type ScriptEntry, type ProductionAgentOptions, + type AgentActionSurface, + type AgentActionSurfaceDetails, type ActionTool, type ScriptTool, type AgentMessage, diff --git a/packages/core/src/server/request-context-fallback.spec.ts b/packages/core/src/server/request-context-fallback.spec.ts new file mode 100644 index 0000000000..04dbd5a638 --- /dev/null +++ b/packages/core/src/server/request-context-fallback.spec.ts @@ -0,0 +1,96 @@ +import { readFileSync } from "node:fs"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const requestContextGlobalKeys = [ + "__agentNativeRequestContextAls", + "__agentNativeRequestContextObservers", + "__agentNativeRequestContextContinuationLocal", + "__agentNativeRequestBoundaryInstalled", +] as const; + +const globalState = globalThis as Record; +const savedGlobalState = new Map< + (typeof requestContextGlobalKeys)[number], + { exists: boolean; value: unknown } +>(); + +describe("server/request-context fallback", () => { + beforeEach(() => { + vi.resetModules(); + for (const key of requestContextGlobalKeys) { + savedGlobalState.set(key, { + exists: Object.prototype.hasOwnProperty.call(globalState, key), + value: globalState[key], + }); + delete globalState[key]; + } + vi.stubGlobal("window", {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + for (const key of requestContextGlobalKeys) { + const saved = savedGlobalState.get(key); + if (saved?.exists) { + globalState[key] = saved.value; + } else { + delete globalState[key]; + } + } + savedGlobalState.clear(); + vi.resetModules(); + }); + + it("fails closed for overlapping action-surface requests", async () => { + const { runWithRequestContext } = await import("./request-context.js"); + const alphaRun = vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + }); + const betaRun = vi.fn(async () => { + await Promise.resolve(); + }); + + const results = await Promise.allSettled([ + Promise.resolve().then(() => + runWithRequestContext( + { run: { allowedActionNames: ["alpha"] } }, + alphaRun, + ), + ), + Promise.resolve().then(() => + runWithRequestContext( + { run: { allowedActionNames: ["beta"] } }, + betaRun, + ), + ), + ]); + + expect(results.map((result) => result.status)).toEqual([ + "rejected", + "rejected", + ]); + expect(alphaRun).not.toHaveBeenCalled(); + expect(betaRun).not.toHaveBeenCalled(); + for (const result of results) { + if (result.status === "rejected") { + expect(result.reason).toMatchObject({ + message: expect.stringContaining("continuation-local"), + }); + } + } + }); + + it("guards configured action surfaces before handler setup", async () => { + const { assertRequestActionSurfaceIsolation } = + await import("./request-context.js"); + const source = readFileSync("src/agent/production-agent.ts", "utf8"); + + expect(() => assertRequestActionSurfaceIsolation()).toThrow( + "continuation-local", + ); + expect(source).toMatch( + /createProductionAgentHandler\([\s\S]*?if \(options\.resolveActionSurface\) \{\s*assertRequestActionSurfaceIsolation\(\);/, + ); + }); +}); diff --git a/packages/core/src/server/request-context.ts b/packages/core/src/server/request-context.ts index 210a193d95..4b53c98879 100644 --- a/packages/core/src/server/request-context.ts +++ b/packages/core/src/server/request-context.ts @@ -116,6 +116,8 @@ export interface RequestRunContext { engine?: import("../agent/engine/types.js").AgentEngine; /** Model name for this run (set by onEngineResolved). */ model?: string; + /** Request-authorized action names exposed to this agent run. */ + allowedActionNames?: readonly string[]; /** * True when this run is executing inside the durable background-function * worker (the `_process-run` self-dispatch), not the synchronous foreground @@ -229,14 +231,18 @@ export interface RequestContext { const GLOBAL_KEY = "__agentNativeRequestContextAls" as const; const OBSERVERS_KEY = "__agentNativeRequestContextObservers" as const; const BOUNDARY_KEY = "__agentNativeRequestBoundaryInstalled" as const; +const CONTINUATION_LOCAL_KEY = + "__agentNativeRequestContextContinuationLocal" as const; type RequestContextObserver = (ctx: RequestContext) => void; type GlobalWithRequestContext = typeof globalThis & { [GLOBAL_KEY]?: AsyncLocalStorageLike; [OBSERVERS_KEY]?: RequestContextObserver[]; [BOUNDARY_KEY]?: boolean; + [CONTINUATION_LOCAL_KEY]?: boolean; }; const globalRef = globalThis as GlobalWithRequestContext; if (!globalRef[GLOBAL_KEY]) { + globalRef[CONTINUATION_LOCAL_KEY] = Boolean(AsyncLocalStorageCtor); globalRef[GLOBAL_KEY] = AsyncLocalStorageCtor ? new AsyncLocalStorageCtor() : new StackAsyncLocalStorage(); @@ -247,6 +253,18 @@ if (!globalRef[OBSERVERS_KEY]) { const als = globalRef[GLOBAL_KEY]!; const observers = globalRef[OBSERVERS_KEY]!; +/** + * Authorization state must never use the shared-stack compatibility fallback: + * overlapping async requests are only isolated by native AsyncLocalStorage. + */ +export function assertRequestActionSurfaceIsolation(): void { + if (globalRef[CONTINUATION_LOCAL_KEY] === true) return; + throw new Error( + "Request-scoped action surfaces require continuation-local request context storage; " + + "this runtime only provides the non-isolated fallback.", + ); +} + /** * Register a callback fired every time `runWithRequestContext` enters a new * scope. The hook runs INSIDE the AsyncLocalStorage scope, so observability @@ -279,6 +297,9 @@ export function runWithRequestContext( ctx: RequestContext, fn: () => T | Promise, ): T | Promise { + if (ctx.run?.allowedActionNames !== undefined) { + assertRequestActionSurfaceIsolation(); + } return als.run(ctx, () => { if (observers.length > 0) { for (const obs of observers) {