From c3fc491e55c17d8832dcb10a10fbb9cea8ac5472 Mon Sep 17 00:00:00 2001 From: CraftBot Date: Sat, 17 Oct 2026 17:03:26 +0900 Subject: [PATCH 01/12] major UI update to incoporate session based browser and generative UI --- .gitignore | 9 +- .../core/impl/event_stream/event_stream.py | 17 +- agent_core/core/impl/session/manager.py | 25 +- agent_core/core/session/session.py | 9 +- agent_file_system/AGENT.md | 41 +- app/agent_base.py | 71 +- app/config.py | 5 +- app/data/action/mini_browser_actions.py | 14 +- app/data/action/render_ui.py | 8 +- app/data/agent_file_system_template/AGENT.md | 41 +- app/generative_ui.py | 11 + app/mini_browser/__init__.py | 33 +- app/mini_browser/actions_api.py | 12 +- app/mini_browser/bridge.py | 47 +- app/mini_browser/config.py | 136 ++- app/mini_browser/core.py | 693 ++++++++++++++-- app/mini_browser/errors.py | 37 +- app/mini_browser/favicons.py | 168 ++++ app/mini_browser/fleet.py | 294 +++++++ app/mini_browser/host.py | 109 ++- app/mini_browser/lifecycle.py | 134 ++- app/mini_browser/login.py | 6 +- app/mini_browser/ops.py | 2 +- app/mini_browser/resume.py | 197 +++++ app/mini_browser/stream.py | 20 +- app/mini_browser/types.py | 10 +- app/mini_browser/vault.py | 5 +- app/mini_browser/ws.py | 771 +++++++++++------- app/subagent/definitions/browser_agent.py | 19 +- app/ui_layer/adapters/browser_adapter.py | 76 +- app/ui_layer/browser/frontend/src/App.tsx | 4 - .../frontend/src/components/Chat/Chat.tsx | 20 +- .../GenerativeUI/GenerativeUI.module.css | 45 +- .../components/GenerativeUI/GenerativeUI.tsx | 54 +- .../GenerativeUI/OutputWorkspace.module.css | 81 +- .../GenerativeUI/OutputWorkspace.tsx | 492 ++++++++--- .../MiniBrowser/PasswordsModal.module.css | 0 .../MiniBrowser/PasswordsModal.tsx | 0 .../MiniBrowser/ResumeView.module.css | 99 +++ .../src/components/MiniBrowser/ResumeView.tsx | 77 ++ .../SessionBrowserPanel.module.css} | 34 +- .../MiniBrowser/SessionBrowserPanel.tsx} | 306 +++---- .../MiniBrowser/StatusPanel.module.css | 0 .../MiniBrowser/StatusPanel.tsx | 128 +-- .../MiniBrowser/TabStrip.module.css | 47 ++ .../src/components/MiniBrowser/TabStrip.tsx | 109 +++ .../MiniBrowser/Toolbar.module.css | 0 .../MiniBrowser/Toolbar.tsx | 0 .../MiniBrowser/clipboard.ts | 0 .../MiniBrowser/downloads.ts | 0 .../MiniBrowser/live/LiveView.module.css | 0 .../MiniBrowser/live/LiveView.tsx | 7 +- .../MiniBrowser/live/geometry.ts | 0 .../MiniBrowser/live/keyboard.ts | 0 .../MiniBrowser/live/useAgentCursor.ts | 11 +- .../MiniBrowser/live/useFramePainter.ts | 5 +- .../MiniBrowser/live/useKeyboardSink.ts | 7 +- .../MiniBrowser/live/useLiveInput.ts | 20 +- .../MiniBrowser/live/usePointerInput.ts | 0 .../components/MiniBrowser/useChatBrowser.tsx | 48 ++ .../MiniBrowser/useErrorText.ts | 2 + .../MiniBrowser/useLatest.ts | 0 .../MiniBrowser/useMiniBrowserSocket.ts | 54 +- .../MiniBrowser/useTabLabels.ts | 45 +- .../MiniBrowser/vaultForm.ts | 0 .../frontend/src/components/layout/NavBar.tsx | 23 +- .../src/components/ui/ChromeTabs.module.css | 216 +++++ .../frontend/src/components/ui/ChromeTabs.tsx | 155 ++++ .../src/contexts/WebSocketContext.tsx | 9 +- .../browser/frontend/src/locales/en/chat.json | 23 +- .../frontend/src/locales/en/minibrowser.json | 28 +- .../browser/frontend/src/locales/en/nav.json | 1 - .../browser/frontend/src/locales/es/chat.json | 9 + .../frontend/src/locales/es/minibrowser.json | 28 +- .../browser/frontend/src/locales/es/nav.json | 1 - .../browser/frontend/src/locales/id/chat.json | 9 + .../frontend/src/locales/id/minibrowser.json | 28 +- .../browser/frontend/src/locales/id/nav.json | 1 - .../browser/frontend/src/locales/ja/chat.json | 9 + .../frontend/src/locales/ja/minibrowser.json | 28 +- .../browser/frontend/src/locales/ja/nav.json | 1 - .../browser/frontend/src/locales/ko/chat.json | 9 + .../frontend/src/locales/ko/minibrowser.json | 28 +- .../browser/frontend/src/locales/ko/nav.json | 1 - .../frontend/src/locales/zh-CN/chat.json | 9 + .../src/locales/zh-CN/minibrowser.json | 28 +- .../frontend/src/locales/zh-CN/nav.json | 1 - .../frontend/src/locales/zh-TW/chat.json | 9 + .../src/locales/zh-TW/minibrowser.json | 28 +- .../frontend/src/locales/zh-TW/nav.json | 1 - .../src/pages/AgentApp/AgentAppPage.tsx | 24 +- .../pages/MiniBrowser/ChatPanel.module.css | 218 ----- .../src/pages/MiniBrowser/ChatPanel.tsx | 288 ------- .../src/pages/MiniBrowser/TabStrip.module.css | 177 ---- .../src/pages/MiniBrowser/TabStrip.tsx | 161 ---- .../frontend/src/pages/MiniBrowser/index.ts | 1 - .../browser/frontend/src/pages/index.ts | 1 - .../frontend/src/store/resources/catalog.ts | 8 + .../src/store/selectors/miniBrowser.ts | 80 +- .../frontend/src/store/selectors/sessions.ts | 6 - .../src/store/slices/messagesSlice.ts | 25 + .../src/store/slices/miniBrowserSlice.ts | 225 +++-- .../frontend/src/store/slices/uiSlice.ts | 5 + .../frontend/src/store/uiState/catalog.ts | 27 +- .../browser/frontend/src/tour/anchors.ts | 1 - .../browser/frontend/src/types/index.ts | 68 +- .../frontend/tests/generative-ui.test.ts | 325 ++++++-- .../browser/frontend/tests/harness.tsx | 36 +- app/usage/chat_storage.py | 21 + skills/mini-browser/SKILL.md | 35 +- tests/test_generative_ui.py | 123 ++- tests/test_mini_browser_actions_api.py | 49 +- tests/test_mini_browser_agent_integration.py | 67 +- tests/test_mini_browser_engine.py | 298 +++++-- tests/test_mini_browser_engine_fixes.py | 125 +-- tests/test_mini_browser_favicons.py | 166 ++++ tests/test_mini_browser_fleet.py | 674 +++++++++++++++ tests/test_mini_browser_resume.py | 102 +++ tests/test_mini_browser_session_type.py | 181 ---- tests/test_mini_browser_ws.py | 559 +++++++++---- 120 files changed, 6714 insertions(+), 2760 deletions(-) create mode 100644 app/mini_browser/favicons.py create mode 100644 app/mini_browser/fleet.py create mode 100644 app/mini_browser/resume.py rename app/ui_layer/browser/frontend/src/{pages => components}/MiniBrowser/PasswordsModal.module.css (100%) rename app/ui_layer/browser/frontend/src/{pages => components}/MiniBrowser/PasswordsModal.tsx (100%) create mode 100644 app/ui_layer/browser/frontend/src/components/MiniBrowser/ResumeView.module.css create mode 100644 app/ui_layer/browser/frontend/src/components/MiniBrowser/ResumeView.tsx rename app/ui_layer/browser/frontend/src/{pages/MiniBrowser/MiniBrowserPage.module.css => components/MiniBrowser/SessionBrowserPanel.module.css} (70%) rename app/ui_layer/browser/frontend/src/{pages/MiniBrowser/MiniBrowserPage.tsx => components/MiniBrowser/SessionBrowserPanel.tsx} (62%) rename app/ui_layer/browser/frontend/src/{pages => components}/MiniBrowser/StatusPanel.module.css (100%) rename app/ui_layer/browser/frontend/src/{pages => components}/MiniBrowser/StatusPanel.tsx (64%) create mode 100644 app/ui_layer/browser/frontend/src/components/MiniBrowser/TabStrip.module.css create mode 100644 app/ui_layer/browser/frontend/src/components/MiniBrowser/TabStrip.tsx rename app/ui_layer/browser/frontend/src/{pages => components}/MiniBrowser/Toolbar.module.css (100%) rename app/ui_layer/browser/frontend/src/{pages => components}/MiniBrowser/Toolbar.tsx (100%) rename app/ui_layer/browser/frontend/src/{pages => components}/MiniBrowser/clipboard.ts (100%) rename app/ui_layer/browser/frontend/src/{pages => components}/MiniBrowser/downloads.ts (100%) rename app/ui_layer/browser/frontend/src/{pages => components}/MiniBrowser/live/LiveView.module.css (100%) rename app/ui_layer/browser/frontend/src/{pages => components}/MiniBrowser/live/LiveView.tsx (97%) rename app/ui_layer/browser/frontend/src/{pages => components}/MiniBrowser/live/geometry.ts (100%) rename app/ui_layer/browser/frontend/src/{pages => components}/MiniBrowser/live/keyboard.ts (100%) rename app/ui_layer/browser/frontend/src/{pages => components}/MiniBrowser/live/useAgentCursor.ts (91%) rename app/ui_layer/browser/frontend/src/{pages => components}/MiniBrowser/live/useFramePainter.ts (96%) rename app/ui_layer/browser/frontend/src/{pages => components}/MiniBrowser/live/useKeyboardSink.ts (98%) rename app/ui_layer/browser/frontend/src/{pages => components}/MiniBrowser/live/useLiveInput.ts (95%) rename app/ui_layer/browser/frontend/src/{pages => components}/MiniBrowser/live/usePointerInput.ts (100%) create mode 100644 app/ui_layer/browser/frontend/src/components/MiniBrowser/useChatBrowser.tsx rename app/ui_layer/browser/frontend/src/{pages => components}/MiniBrowser/useErrorText.ts (97%) rename app/ui_layer/browser/frontend/src/{pages => components}/MiniBrowser/useLatest.ts (100%) rename app/ui_layer/browser/frontend/src/{pages => components}/MiniBrowser/useMiniBrowserSocket.ts (66%) rename app/ui_layer/browser/frontend/src/{pages => components}/MiniBrowser/useTabLabels.ts (56%) rename app/ui_layer/browser/frontend/src/{pages => components}/MiniBrowser/vaultForm.ts (100%) create mode 100644 app/ui_layer/browser/frontend/src/components/ui/ChromeTabs.module.css create mode 100644 app/ui_layer/browser/frontend/src/components/ui/ChromeTabs.tsx delete mode 100644 app/ui_layer/browser/frontend/src/pages/MiniBrowser/ChatPanel.module.css delete mode 100644 app/ui_layer/browser/frontend/src/pages/MiniBrowser/ChatPanel.tsx delete mode 100644 app/ui_layer/browser/frontend/src/pages/MiniBrowser/TabStrip.module.css delete mode 100644 app/ui_layer/browser/frontend/src/pages/MiniBrowser/TabStrip.tsx delete mode 100644 app/ui_layer/browser/frontend/src/pages/MiniBrowser/index.ts create mode 100644 tests/test_mini_browser_favicons.py create mode 100644 tests/test_mini_browser_fleet.py create mode 100644 tests/test_mini_browser_resume.py delete mode 100644 tests/test_mini_browser_session_type.py diff --git a/.gitignore b/.gitignore index 2400b7b59..3dd826b32 100644 --- a/.gitignore +++ b/.gitignore @@ -88,8 +88,7 @@ playwright-browsers/ hf-cache/ -# Mini Browser's persistent Chromium profile (cookies, signed-in sessions) -# and its cross-process lock file. Its password vault lives in .credentials/ -# (ignored above). Never commit. -**/mini_browser_profile/ -**/mini_browser_profile.lock +# Mini Browser's persistent Chromium profiles, one per chat (cookies, +# signed-in sessions), and their cross-process lock files. Its password +# vault lives in .credentials/ (ignored above). Never commit. +**/mini_browser_profile* diff --git a/agent_core/core/impl/event_stream/event_stream.py b/agent_core/core/impl/event_stream/event_stream.py index b8942efd5..6cc5685ab 100644 --- a/agent_core/core/impl/event_stream/event_stream.py +++ b/agent_core/core/impl/event_stream/event_stream.py @@ -18,7 +18,7 @@ import re import time from pathlib import Path -from typing import List, Optional, Set, Tuple +from typing import Any, Callable, Dict, List, Optional, Set, Tuple from agent_core.core.event_stream.event import Event, EventRecord, EventType from agent_core.core.protocols.llm import LLMInterfaceProtocol from agent_core.core.prompts import EVENT_STREAM_SUMMARIZATION_PROMPT @@ -826,6 +826,21 @@ def mark_delivered(self) -> None: for rec in self.tail_events: rec.delivered = True + def replace_ui_artifact( + self, artifact_id: str, replace: Callable[[Dict[str, Any]], Dict[str, Any]] + ) -> int: + """Swap the UI artifact payload of every event that carries artifact + ``artifact_id`` for ``replace(payload)`` (the user closed it). Returns + how many events carried it.""" + count = 0 + with self._lock: + for rec in self.tail_events: + artifact = rec.event.ui_artifact + if artifact and artifact.get("id") == artifact_id: + rec.event.ui_artifact = replace(artifact) + count += 1 + return count + def mark_session_synced(self, call_type: str) -> None: """ Mark that all current events have been synced to the session cache. diff --git a/agent_core/core/impl/session/manager.py b/agent_core/core/impl/session/manager.py index ac54ce36f..428abb665 100644 --- a/agent_core/core/impl/session/manager.py +++ b/agent_core/core/impl/session/manager.py @@ -2,10 +2,10 @@ """ Shared SessionManager for agent_core. -Owns the registry of persistent sessions (main / chat / agent_app / -mini_browser), their loaded capabilities (action sets + skills), todos, run -budgets, workspace directories, and their LLM session caches. -Runtime-specific behavior is injected via hooks: +Owns the registry of persistent sessions (main / chat / agent_app), their +loaded capabilities (action sets + skills), todos, run budgets, workspace +directories, and their LLM session caches. Runtime-specific behavior is +injected via hooks: State hooks: - get_agent_property / set_agent_property: session-scoped state access @@ -104,22 +104,16 @@ def main(self) -> Optional[Session]: return self.sessions.get(MAIN_SESSION_ID) def list_sessions(self, include_archived: bool = False) -> List[Session]: - """All sessions: main first, then the Mini Browser session, then - agent_app, then chats — newest-first within each type.""" + """All sessions: main first, then agent_app, then chats newest-first.""" sessions = [ s for s in self.sessions.values() if include_archived or not s.archived ] - type_rank = { - SessionType.MAIN: 0, - SessionType.MINI_BROWSER: 1, - SessionType.AGENT_APP: 2, - SessionType.CHAT: 3, - } + type_rank = {SessionType.MAIN: 0, SessionType.AGENT_APP: 1, SessionType.CHAT: 2} # Newest-first within each type bucket (two-pass stable sort) sessions.sort(key=lambda s: s.last_active_at, reverse=True) - sessions.sort(key=lambda s: type_rank.get(s.type, len(type_rank))) + sessions.sort(key=lambda s: type_rank.get(s.type, 3)) return sessions # ─────────────────────── Creation ───────────────────────────────────────── @@ -149,10 +143,9 @@ def create_session( Create a new persistent session. Args: - session_type: main | chat | agent_app | mini_browser. + session_type: main | chat | agent_app. title: Sidebar title ("New chat" placeholder until auto-titled). - session_id: Explicit id (main / agent-app / mini-browser); random - hex otherwise. + session_id: Explicit id (main / agent-app); random hex otherwise. selected_skills: Skills to preload (slash-command entry, Agent App). agent_app_project_id: Backing project for agent_app sessions. gui_mode: Whether the session starts in GUI mode. diff --git a/agent_core/core/session/session.py b/agent_core/core/session/session.py index 43dc11d67..a942e51fa 100644 --- a/agent_core/core/session/session.py +++ b/agent_core/core/session/session.py @@ -27,12 +27,8 @@ class SessionType: MAIN = "main" CHAT = "chat" AGENT_APP = "agent_app" - # The dedicated chat behind the Mini Browser page (app/mini_browser). It - # behaves like a chat, but the backend creates it (browser actions - # preloaded) and it is not listed with the sidebar's chats. - MINI_BROWSER = "mini_browser" - ALL = (MAIN, CHAT, AGENT_APP, MINI_BROWSER) + ALL = (MAIN, CHAT, AGENT_APP) # Action sets whose schemas every session of a type prints in , # on top of the always-present "core". This is the whole list: nothing a @@ -43,7 +39,6 @@ class SessionType: MAIN: (), CHAT: (), AGENT_APP: ("file_operations", "code_execution", "agent_app"), - MINI_BROWSER: ("mini_browser",), } @@ -66,7 +61,7 @@ class Session: Attributes: id: Unique identifier (``main`` for the main session). - type: One of SessionType.ALL — main | chat | agent_app | mini_browser. + type: One of SessionType.ALL — main | chat | agent_app. title: Human-readable title shown in the sidebar (auto-generated for chat sessions after the first exchange, renamable). created_at: ISO timestamp when the session was created. diff --git a/agent_file_system/AGENT.md b/agent_file_system/AGENT.md index 625cbd57e..c5a04597d 100644 --- a/agent_file_system/AGENT.md +++ b/agent_file_system/AGENT.md @@ -1,5 +1,5 @@ --- -version: 10 +version: 11 purpose: agent operations manual --- @@ -321,10 +321,11 @@ does agent_app_usage + the agent-app work by hand. needs a website USED, not just read: sign in, Mini Browser. add_action_sets(["mini_browser"]) (or -forms, shopping/booking, an account page on a use_skill("mini-browser")): a real browser with the -site with no connected integration user's saved logins (mini_browser_login fills them - from the password vault). Plain reading of public - pages stays web_search / web_fetch. +forms, shopping/booking, an account page on a use_skill("mini-browser")): this chat's own real +site with no connected integration browser, with the user's saved logins + (mini_browser_login fills them from the password + vault). Plain reading of public pages stays + web_search / web_fetch. ``` Hard rule: "I don't have that context" / "I can't access that" may only be sent AFTER the matching check came back empty or disconnected. @@ -1462,8 +1463,9 @@ To list every set currently visible to the runtime, call the `list_action_sets` core Essential actions, always available file_operations File and folder manipulation web_research Internet search and browsing -mini_browser Mini Browser: a live Chromium with the user's saved logins; each - agent drives its own tab, the user can watch and take control +mini_browser Mini Browser: this chat's own live Chromium with the user's saved + logins; each agent drives its own tab, the user can watch and take + control in the chat's browser panel mcp_playwright-mcp Headless, invisible Playwright over MCP for automated app verification (present while that MCP server is connected) document_processing PDF and document handling @@ -1508,7 +1510,7 @@ You cannot opt out of `core`, and `core` now carries the everyday surface: messa ### How sets reach you -1. **By session type** — `` holds `core` plus the base sets of the session type, and nothing else, ever: main and chat sessions get `core` only; Agent App sessions also get `file_operations`, `code_execution`, `agent_app`; the Mini Browser session also gets `mini_browser`. Source: `SessionType.BASE_ACTION_SETS` in [agent_core/core/session/session.py](agent_core/core/session/session.py). +1. **By session type** — `` holds `core` plus the base sets of the session type, and nothing else, ever: main and chat sessions get `core` only; Agent App sessions also get `file_operations`, `code_execution`, `agent_app`. Source: `SessionType.BASE_ACTION_SETS` in [agent_core/core/session/session.py](agent_core/core/session/session.py). 2. **Mid-run** — call `add_action_sets(action_sets=[...])`. It is a read: one `action_schemas` event lands in your event stream with the schemas of every action in those sets that you cannot already see, plus the guidance of each integration among them (see `## Integrations`). Call those actions by name exactly like the ones in ``. Loading a set again is a no-op while its schemas are still in the stream. Names are matched leniently: case, and `-` / `_` / spaces in either direction (`mcp_playwright_mcp` → `mcp_playwright-mcp`, `Mini-Browser` → `mini_browser`), plus the old name `web_agent` → `mini_browser`. A name that matches no set (or two sets equally) is NOT loaded and comes back in `unknown_sets` with a `hint`: a misspelled connected MCP server gets its real name; "its MCP server is not connected" means a server configured in `mcp_config.json` whose connection is down (see `## MCP`). 3. **Per run** — workflow runs (memory, proactive, skill slash commands) and `schedule_task(action_sets=[...])` deliver the run's sets the same way at run start. A user skill picked for the run (`/` or `schedule_task(skills=[...])`) also brings the sets its SKILL.md declares. 4. **Via skill selection** — if a skill's `SKILL.md` frontmatter has `action-sets: [...]`, `use_skill` delivers those sets the same way. See `## Skills`. @@ -1534,20 +1536,24 @@ Loading every set bloats the prompt and slows action selection — add only what ### The Mini Browser (`mini_browser`) -Load the set (or `use_skill("mini-browser")`, which also explains the workflow) for websites that must be USED. The always-on prompt rule and the browser-mode note that point to it appear only while the set is registered and Chromium can launch on this machine. A Docker image (or a bare Linux) has no Chromium unless it was built with `python -m playwright install --with-deps chromium`; `MINI_BROWSER_CHROMIUM_MISSING` / `MINI_BROWSER_LAUNCH_FAILED` mean exactly that — tell the user, do not retry. +Load the set (or `use_skill("mini-browser")`, which also explains the workflow) for websites that must be USED. Every session (main, each chat, each Agent App session) has a browser of its own: its own cookies and signed-in sites (the user's saved passwords are shared), shown in that chat's browser panel, which opens when you start browsing. Scheduled runs use the main session's browser; sub-agents use their parent session's. A browser starts with your first action and closes a few minutes after its panel is closed and your run has ended; when it starts again it reopens the pages it had open, as the user's tabs (switch to one with mini_browser_tabs to continue there); at most a few run at once (starting another closes the one idle the longest). The always-on prompt rule and the browser-mode note that point to it appear only while the set is registered and Chromium can launch on this machine. A Docker image (or a bare Linux) has no Chromium unless it was built with `python -m playwright install --with-deps chromium`; `MINI_BROWSER_CHROMIUM_MISSING` / `MINI_BROWSER_LAUNCH_FAILED` mean exactly that — tell the user, do not retry. ``` -tabs each session works in its own tab. mini_browser_tabs list shows yours (url, title), - other agents' (label only, off-limits) and the user's (host only; viewed=true = on - their screen). To work on the page the user is looking at, switch to that tab. +tabs each agent works in its own tab of the chat's browser. mini_browser_tabs list shows + yours (url, title), the chat's other agents' (label only, off-limits) and the user's + (host only; viewed=true = on their screen). With no tab yet, your first action uses + the page the user has open in the panel; later, switch to that tab to work on it. new tabs a click that opens a tab moves you to it; the result shows the new tab dialogs confirm() / "leave this page?" are accepted automatically, prompt() is dismissed: you cannot decline them, so ask the user BEFORE clicking anything destructive the user CAPTCHA / a sign-in with no saved login: tell the user (continue_work=true), then mini_browser_wait for_user=true: returns once they took control of your tab and handed it back (default 300 s, timeout_ms up to 900000); on timeout ask in chat -closed MINI_BROWSER_CLOSED: the user, an idle shutdown or a crash closed the browser and - your tabs are gone; navigate again only if the task still needs it +closed MINI_BROWSER_CLOSED: the user, an idle shutdown, another chat's browser needing + room, or a crash closed this chat's browser; it reopens its pages as the user's + tabs when it starts again; navigate again only if the task still needs it +busy MINI_BROWSER_TOO_MANY_BROWSERS: other chats' browsers are running and in use; + wait a little and retry once, then tell the user ``` ### Tracking what you can see @@ -1868,14 +1874,17 @@ file_index: prewarm_all_drives: bool (build the find_files index for all drives at boot) mini_browser: (hot-reloaded: applies at once, except headless / channel / - locale / sound, which apply at the browser's next start) + locale / sound, which apply at a browser's next start; + every session has a browser of its own) headless: bool (default true; false opens a visible window) adblock / humanlike / show_cursor / allow_file_urls: bool (defaults true / true / true / false) sound: bool (default true; false starts the browser muted) max_fps: int (default 12, 1-30) jpeg_quality: int (default 70, 30-95) search_url: string (default "https://duckduckgo.com/?q={query}") max_agent_tabs: int (default 6 per agent, 1-20) - idle_shutdown_minutes: int (default 30; 0 = never) + idle_shutdown_minutes: int (default 3: a session's browser closes this long after its + panel closed and its run ended; 0 = never) + max_running_browsers: int (default 4, 1-12: session browsers running at once) locale: string ("" = the OS locale) channel: string (default "chromium"; "" = Playwright's headless shell) diff --git a/app/agent_base.py b/app/agent_base.py index 81d7362a5..f732f2923 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -440,9 +440,9 @@ def __init__( event_stream_manager=self.event_stream_manager, llm_interface=self.llm, ) - # A stream is removed when a sub-agent finishes or a chat is deleted: - # either way its Mini Browser tabs are no longer anyone's. - self.event_stream_manager.add_removal_listener(self._release_mini_browser_owner) + # A stream is removed when a sub-agent finishes (its Mini Browser tabs + # close) or a session is deleted (its browser and profile go). + self.event_stream_manager.add_removal_listener(self._on_stream_removed) InternalActionInterface.initialize( self.llm, @@ -540,15 +540,12 @@ def create_chat_session( async def delete_session(self, session_id: str) -> bool: """Delete a session: triggers, runtime lane, streams, persistence.""" session = self.session_manager.get(session_id) - # The main session and the Mini Browser page's dedicated chat are - # fixtures of the UI; they can be cleared but never deleted. - if not session or session.type in (SessionType.MAIN, SessionType.MINI_BROWSER): + if not session or session.type == SessionType.MAIN: return False await self.trigger_service.cancel_sessions([session_id]) - deleted = self.session_manager.delete_session(session_id) - if deleted: - self._notify_mini_browser("release_owner", session_id) - return deleted + # Removing its event stream also deletes its Mini Browser and profile + # (_on_stream_removed). + return self.session_manager.delete_session(session_id) async def clear_session(self, session_id: str) -> bool: """Clear a session's conversation (event stream, todos, budgets). @@ -558,6 +555,21 @@ async def clear_session(self, session_id: str) -> bool: """ return self.session_manager.clear_session(session_id) + def close_ui_artifact(self, session_id: str, artifact_id: str) -> int: + """The user closed a Generative UI output: the session's events keep + only a closed marker for it (``closed_artifact``), and the stream is + persisted so the stored copy loses the code too. Returns how many + events carried it.""" + if not self.event_stream_manager.has_stream(session_id): + return 0 + from app.generative_ui import closed_artifact + + stream = self.event_stream_manager.get_stream_by_id(session_id) + count = stream.replace_ui_artifact(artifact_id, closed_artifact) + if count: + self._persist_session_stream(session_id) + return count + def rename_session(self, session_id: str, title: str) -> bool: """Rename a session's sidebar title.""" return self.session_manager.rename_session(session_id, title) @@ -1134,15 +1146,17 @@ def _reload_mini_browser_settings( if old != new: self._notify_mini_browser("reload_settings") - def _release_mini_browser_owner(self, session_id: str, _stream: object) -> None: - """Event-stream removal listener: a sub-agent finished or a chat was - deleted, so its Mini Browser tabs are released. Runs on the remover's - thread (a sub-agent's pool thread), so it only hands work off.""" - self._notify_mini_browser("release_owner", session_id) + def _on_stream_removed(self, session_id: str, _stream: object) -> None: + """Event-stream removal listener: a sub-agent finished (its Mini + Browser tabs close) or a session was deleted (every path that deletes + a session removes its stream: its browser closes for good and its + profile is deleted). Runs on the remover's thread (a sub-agent's pool + thread), so it only hands work off.""" + self._notify_mini_browser("stream_removed", session_id) async def _shutdown_mini_browser(self) -> None: - """Close the Mini Browser at exit so Chromium flushes the persistent - profile (cookies, logins). A no-op when it was never used.""" + """Close every session's Mini Browser at exit so Chromium flushes the + persistent profiles (cookies, logins). A no-op when none was used.""" import sys if "app.mini_browser.host" not in sys.modules: @@ -3005,9 +3019,11 @@ def _get_interface_capabilities_prompt(self) -> str: if self._mini_browser_usable(): prompt += ( "\n\n## Mini Browser\n" - "The user can watch your Mini Browser tabs live in the Mini Browser page and " - "take control of them. When a site needs the user (a CAPTCHA, a sign-in with no " - "saved login), tell them (send_message with continue_work=true) to do it there, " + "This chat has a browser of its own (its own cookies and signed-in sites; " + "saved passwords are shared). It opens in the chat's browser panel when you " + "start browsing, so the user can watch your tabs live and take control of them. " + "When a site needs the user (a CAPTCHA, a sign-in with no saved login), tell " + "them (send_message with continue_work=true) to do it in the browser panel, " "then call `mini_browser_wait` with for_user=true: it returns once they have " "taken control of your tab and handed it back. If it times out, ask them in " "chat as your final message. Ask for a 2FA code in chat; never guess one." @@ -3111,14 +3127,14 @@ async def reset_agent_state( self.state_manager.reset() self.event_stream_manager.clear_all() # clear_all() drops every stream but main's, while the sessions that - # outlive a reset (the Mini Browser chat, Agent App sessions) stay - # registered. Give them their own stream back FIRST: an id without - # one falls back to MAIN's stream, so their runs would read and write - # main's history (and clearing them would empty main's stream). + # outlive a reset (Agent App sessions) stay registered. Give them + # their own stream back FIRST: an id without one falls back to MAIN's + # stream, so their runs would read and write main's history (and + # clearing them would empty main's stream). self._recreate_session_streams() self.session_manager.clear_session(MAIN_SESSION_ID) for session in list(self.session_manager.sessions.values()): - if session.type in (SessionType.AGENT_APP, SessionType.MINI_BROWSER): + if session.type == SessionType.AGENT_APP: self._clear_surviving_session(session) # 2. Stop file watcher to prevent interference during reset @@ -3238,10 +3254,7 @@ async def _reset_selected_components(self, components: "Iterable[str]") -> str: get_usage_storage().clear_events() self.session_manager.clear_session(MAIN_SESSION_ID) for session in list(self.session_manager.sessions.values()): - if session.type in ( - SessionType.AGENT_APP, - SessionType.MINI_BROWSER, - ): + if session.type == SessionType.AGENT_APP: self.session_manager.clear_session(session.id) done.append(f"sessions ({count} chats deleted)") except Exception as e: diff --git a/app/config.py b/app/config.py index 0acb9e62e..1aecf9341 100644 --- a/app/config.py +++ b/app/config.py @@ -148,7 +148,10 @@ def _get_default_settings() -> Dict[str, Any]: "search_url": "https://duckduckgo.com/?q={query}", "allow_file_urls": False, "max_agent_tabs": 6, - "idle_shutdown_minutes": 30, # 0 = never + # A session's browser closes this long after its panel was closed + # and its agent's run ended (0 = never). + "idle_shutdown_minutes": 3, + "max_running_browsers": 4, # session browsers running at once "locale": "", # "" = the OS locale "channel": "chromium", # "" = Playwright's bundled headless shell }, diff --git a/app/data/action/mini_browser_actions.py b/app/data/action/mini_browser_actions.py index 8f5d1a754..6dc61fa56 100644 --- a/app/data/action/mini_browser_actions.py +++ b/app/data/action/mini_browser_actions.py @@ -1,9 +1,11 @@ """ Mini Browser actions: agents drive a real Chromium tab the user can watch. -Each agent (main session, chat sessions, scheduled runs, Agent App runs, -sub-agents) gets its own tab in one shared, persistent browser profile, so -the user's cookies and saved logins carry over. Every result carries a fresh +Every session (the main session, each chat, each Agent App session) has a +browser of its own, with its own persistent cookies and signed-in sites, +shown in that chat's browser panel; the user's saved passwords are shared. +Scheduled runs use the main session's browser and sub-agents their parent +session's; each agent gets its own tab. Every result carries a fresh ``page`` observation with numbered elements, so the agent rarely needs a separate read between steps. @@ -529,7 +531,7 @@ async def mini_browser_scroll(input_data: dict) -> dict: "(if they already control it, until they hand it back). Use for_user " "after an action is refused because the user took control, or after " "telling the user (send_message, continue_work=true) to do a step " - "only they can do in the Mini Browser page (a CAPTCHA, a sign-in). " + "only they can do in this chat's browser panel (a CAPTCHA, a sign-in). " "It returns early if the tab or browser is closed. With no argument " "it waits for the page to settle." ), @@ -643,8 +645,8 @@ async def mini_browser_upload_file(input_data: dict) -> dict: "Two-step sign-ins (email first, then password) are handled. The " "result's outcome (signed_in, needs_verification, captcha or unknown) " "and message say what to do next; never assume success on unknown. " - "If no saved login matches the site, ask the user to add one in the " - "Mini Browser's Passwords panel or to sign in themselves in the live " + "If no saved login matches the site, ask the user to add one under " + "Passwords in the browser panel or to sign in themselves in the live " "view; never ask for a password in chat." ), mode="ALL", diff --git a/app/data/action/render_ui.py b/app/data/action/render_ui.py index e7ebe18cd..1129540a6 100644 --- a/app/data/action/render_ui.py +++ b/app/data/action/render_ui.py @@ -10,7 +10,8 @@ "Prefer text when reading suffices; never render solely for decoration or topic. " "Respect requested formats. " "Use semantic, keyboard-usable HTML and addEventListener; every control must work. " - "No backend, npm/imports, external scripts, inline event handlers or secrets. " + "No backend, npm/imports, external scripts, external stylesheets or web fonts (no CSS " + "@import; use --cb-font or system fonts), inline event handlers or secrets. " "Never navigate or request permissions; preventDefault on form submits. Other resources " "are blocked by CSP. For API fetches declare exact public HTTPS origins in connect_origins " "(omit offline); require credential-free browser CORS, loading/recoverable errors, actual " @@ -30,8 +31,9 @@ "--cb-accent and --cb-font; bespoke palettes must consistently style page/text/controls " "without host-token fallbacks overriding them. " "Fit a full-height, resizable 440–900px canvas and usable 360px layout using document-width " - "responsive CSS. The host supplies title, phone/fullscreen/revision controls: don't duplicate " - "them, add faux browser chrome or repeat the title with an intro. " + "responsive CSS (narrower than 440px, the host shows it at phone width). The host supplies " + "title and revision controls: don't duplicate them, add faux browser chrome or repeat the " + "title with an intro. " "Use purposeful 150–250ms opacity/transform transitions; honor prefers-reduced-motion. " "No perpetual decoration, ticking-number animation or delayed controls. " "Keep code concise with shared styles/functions so the complete document fits one response. " diff --git a/app/data/agent_file_system_template/AGENT.md b/app/data/agent_file_system_template/AGENT.md index 625cbd57e..c5a04597d 100644 --- a/app/data/agent_file_system_template/AGENT.md +++ b/app/data/agent_file_system_template/AGENT.md @@ -1,5 +1,5 @@ --- -version: 10 +version: 11 purpose: agent operations manual --- @@ -321,10 +321,11 @@ does agent_app_usage + the agent-app work by hand. needs a website USED, not just read: sign in, Mini Browser. add_action_sets(["mini_browser"]) (or -forms, shopping/booking, an account page on a use_skill("mini-browser")): a real browser with the -site with no connected integration user's saved logins (mini_browser_login fills them - from the password vault). Plain reading of public - pages stays web_search / web_fetch. +forms, shopping/booking, an account page on a use_skill("mini-browser")): this chat's own real +site with no connected integration browser, with the user's saved logins + (mini_browser_login fills them from the password + vault). Plain reading of public pages stays + web_search / web_fetch. ``` Hard rule: "I don't have that context" / "I can't access that" may only be sent AFTER the matching check came back empty or disconnected. @@ -1462,8 +1463,9 @@ To list every set currently visible to the runtime, call the `list_action_sets` core Essential actions, always available file_operations File and folder manipulation web_research Internet search and browsing -mini_browser Mini Browser: a live Chromium with the user's saved logins; each - agent drives its own tab, the user can watch and take control +mini_browser Mini Browser: this chat's own live Chromium with the user's saved + logins; each agent drives its own tab, the user can watch and take + control in the chat's browser panel mcp_playwright-mcp Headless, invisible Playwright over MCP for automated app verification (present while that MCP server is connected) document_processing PDF and document handling @@ -1508,7 +1510,7 @@ You cannot opt out of `core`, and `core` now carries the everyday surface: messa ### How sets reach you -1. **By session type** — `` holds `core` plus the base sets of the session type, and nothing else, ever: main and chat sessions get `core` only; Agent App sessions also get `file_operations`, `code_execution`, `agent_app`; the Mini Browser session also gets `mini_browser`. Source: `SessionType.BASE_ACTION_SETS` in [agent_core/core/session/session.py](agent_core/core/session/session.py). +1. **By session type** — `` holds `core` plus the base sets of the session type, and nothing else, ever: main and chat sessions get `core` only; Agent App sessions also get `file_operations`, `code_execution`, `agent_app`. Source: `SessionType.BASE_ACTION_SETS` in [agent_core/core/session/session.py](agent_core/core/session/session.py). 2. **Mid-run** — call `add_action_sets(action_sets=[...])`. It is a read: one `action_schemas` event lands in your event stream with the schemas of every action in those sets that you cannot already see, plus the guidance of each integration among them (see `## Integrations`). Call those actions by name exactly like the ones in ``. Loading a set again is a no-op while its schemas are still in the stream. Names are matched leniently: case, and `-` / `_` / spaces in either direction (`mcp_playwright_mcp` → `mcp_playwright-mcp`, `Mini-Browser` → `mini_browser`), plus the old name `web_agent` → `mini_browser`. A name that matches no set (or two sets equally) is NOT loaded and comes back in `unknown_sets` with a `hint`: a misspelled connected MCP server gets its real name; "its MCP server is not connected" means a server configured in `mcp_config.json` whose connection is down (see `## MCP`). 3. **Per run** — workflow runs (memory, proactive, skill slash commands) and `schedule_task(action_sets=[...])` deliver the run's sets the same way at run start. A user skill picked for the run (`/` or `schedule_task(skills=[...])`) also brings the sets its SKILL.md declares. 4. **Via skill selection** — if a skill's `SKILL.md` frontmatter has `action-sets: [...]`, `use_skill` delivers those sets the same way. See `## Skills`. @@ -1534,20 +1536,24 @@ Loading every set bloats the prompt and slows action selection — add only what ### The Mini Browser (`mini_browser`) -Load the set (or `use_skill("mini-browser")`, which also explains the workflow) for websites that must be USED. The always-on prompt rule and the browser-mode note that point to it appear only while the set is registered and Chromium can launch on this machine. A Docker image (or a bare Linux) has no Chromium unless it was built with `python -m playwright install --with-deps chromium`; `MINI_BROWSER_CHROMIUM_MISSING` / `MINI_BROWSER_LAUNCH_FAILED` mean exactly that — tell the user, do not retry. +Load the set (or `use_skill("mini-browser")`, which also explains the workflow) for websites that must be USED. Every session (main, each chat, each Agent App session) has a browser of its own: its own cookies and signed-in sites (the user's saved passwords are shared), shown in that chat's browser panel, which opens when you start browsing. Scheduled runs use the main session's browser; sub-agents use their parent session's. A browser starts with your first action and closes a few minutes after its panel is closed and your run has ended; when it starts again it reopens the pages it had open, as the user's tabs (switch to one with mini_browser_tabs to continue there); at most a few run at once (starting another closes the one idle the longest). The always-on prompt rule and the browser-mode note that point to it appear only while the set is registered and Chromium can launch on this machine. A Docker image (or a bare Linux) has no Chromium unless it was built with `python -m playwright install --with-deps chromium`; `MINI_BROWSER_CHROMIUM_MISSING` / `MINI_BROWSER_LAUNCH_FAILED` mean exactly that — tell the user, do not retry. ``` -tabs each session works in its own tab. mini_browser_tabs list shows yours (url, title), - other agents' (label only, off-limits) and the user's (host only; viewed=true = on - their screen). To work on the page the user is looking at, switch to that tab. +tabs each agent works in its own tab of the chat's browser. mini_browser_tabs list shows + yours (url, title), the chat's other agents' (label only, off-limits) and the user's + (host only; viewed=true = on their screen). With no tab yet, your first action uses + the page the user has open in the panel; later, switch to that tab to work on it. new tabs a click that opens a tab moves you to it; the result shows the new tab dialogs confirm() / "leave this page?" are accepted automatically, prompt() is dismissed: you cannot decline them, so ask the user BEFORE clicking anything destructive the user CAPTCHA / a sign-in with no saved login: tell the user (continue_work=true), then mini_browser_wait for_user=true: returns once they took control of your tab and handed it back (default 300 s, timeout_ms up to 900000); on timeout ask in chat -closed MINI_BROWSER_CLOSED: the user, an idle shutdown or a crash closed the browser and - your tabs are gone; navigate again only if the task still needs it +closed MINI_BROWSER_CLOSED: the user, an idle shutdown, another chat's browser needing + room, or a crash closed this chat's browser; it reopens its pages as the user's + tabs when it starts again; navigate again only if the task still needs it +busy MINI_BROWSER_TOO_MANY_BROWSERS: other chats' browsers are running and in use; + wait a little and retry once, then tell the user ``` ### Tracking what you can see @@ -1868,14 +1874,17 @@ file_index: prewarm_all_drives: bool (build the find_files index for all drives at boot) mini_browser: (hot-reloaded: applies at once, except headless / channel / - locale / sound, which apply at the browser's next start) + locale / sound, which apply at a browser's next start; + every session has a browser of its own) headless: bool (default true; false opens a visible window) adblock / humanlike / show_cursor / allow_file_urls: bool (defaults true / true / true / false) sound: bool (default true; false starts the browser muted) max_fps: int (default 12, 1-30) jpeg_quality: int (default 70, 30-95) search_url: string (default "https://duckduckgo.com/?q={query}") max_agent_tabs: int (default 6 per agent, 1-20) - idle_shutdown_minutes: int (default 30; 0 = never) + idle_shutdown_minutes: int (default 3: a session's browser closes this long after its + panel closed and its run ended; 0 = never) + max_running_browsers: int (default 4, 1-12: session browsers running at once) locale: string ("" = the OS locale) channel: string (default "chromium"; "" = Playwright's headless shell) diff --git a/app/generative_ui.py b/app/generative_ui.py index e6ea32f5e..f63a37e08 100644 --- a/app/generative_ui.py +++ b/app/generative_ui.py @@ -49,6 +49,17 @@ def normalize_connect_origins(value: object) -> list[str]: return sorted(origins) +def closed_artifact(artifact: dict) -> dict: + """What remains of an artifact the user closed: who it was (so a later + revision still numbers on), never its code or network permissions.""" + return { + "id": artifact.get("id"), + "title": artifact.get("title"), + "revision": artifact.get("revision"), + "closed": True, + } + + def make_artifact(data: dict, revision: int = 1) -> dict: title = data.get("title") html = data.get("html") diff --git a/app/mini_browser/__init__.py b/app/mini_browser/__init__.py index 14853b8dc..700d16f8f 100644 --- a/app/mini_browser/__init__.py +++ b/app/mini_browser/__init__.py @@ -1,27 +1,35 @@ """Mini Browser — a real Chromium that agents drive and the user watches live. -Every kind of agent (main chat, chat sessions, the dedicated Mini Browser -session, scheduled runs, Agent App trigger runs and sub-agents) can use it. -Each agent session gets its own tab, so concurrent agents never act on each -other's page, and the user can watch any tab and take control of it. +Every agent session (the main session, each chat, each Agent App session) +has a browser of its own, built into its chat: the user opens it in the +chat's browser panel, and it starts when they do or when the session's agent +first needs it. A session's browser keeps its own cookies and signed-in sites +(a persistent Chromium profile per session); only the password vault is +shared. Sub-agents work in their parent session's browser, scheduled runs in +the main session's. Each agent gets its own tab, so concurrent agents never +act on each other's page, and the user can take control of any tab. Layout ------ - ``host.py`` dedicated "mini-browser" thread + event loop that owns Playwright; callers on any loop/thread marshal into it. -- ``core.py`` ``BrowserCore``: lifecycle, tabs, per-owner claims, - viewport, dialogs, downloads, crash recovery, UI ops. +- ``fleet.py`` ``BrowserFleet``: one browser per session, the limit on + browsers running at once, deleted sessions' profiles. +- ``core.py`` ``BrowserCore``: one session's browser: lifecycle, tabs, + per-owner claims, viewport, dialogs, downloads, crash + recovery, UI ops. - ``stream.py`` CDP screencast (fallback: screenshot polling) of the - viewed tab, only while a UI subscriber exists. + viewed tab, only while the session's panel is watched. - ``ops.py`` agent primitives (navigate, read, click, type, ...). - ``observe.py`` page snapshot JS + compact observations for the LLM. - ``human.py`` human-like mouse paths, typing rhythm and scrolling. - ``login.py`` password-vault autofill (origin-checked, never leaks). -- ``vault.py`` encrypted credential vault. +- ``vault.py`` encrypted credential vault (shared by every session). - ``actions_api.py`` validation + entry point used by the action stubs. - ``ws.py`` WebSocket protocol handler living in the UI adapter. - ``bridge.py`` thread-safe channel from the browser thread to the UI. -- ``lifecycle.py`` hooks called by AgentBase (run state, stop, shutdown). +- ``lifecycle.py`` hooks called by AgentBase (run state, stop, deleted + sessions, shutdown). - ``urls.py`` / ``adblock.py`` / ``config.py`` / ``errors.py`` / ``types.py``. This package is import-light on purpose: nothing here imports Playwright at @@ -29,13 +37,10 @@ every mode (browser UI, CLI, tests). """ -# The dedicated chat session behind the Mini Browser page. -SESSION_ID = "mini_browser" -SESSION_TITLE = "Mini Browser" - # Action set holding every mini_browser_* action, and the companion skill. ACTION_SET = "mini_browser" SKILL_NAME = "mini-browser" -# Owner used when an action arrives without a session id (should be rare). +# The main session's id: its browser serves actions that arrive without a +# session id (should be rare) and scheduled runs (they run in the main session). DEFAULT_OWNER = "main" diff --git a/app/mini_browser/actions_api.py b/app/mini_browser/actions_api.py index 4be185965..f35629d0c 100644 --- a/app/mini_browser/actions_api.py +++ b/app/mini_browser/actions_api.py @@ -23,7 +23,7 @@ from typing import Any, Awaitable, Callable, Dict, FrozenSet, List, Optional, Tuple from app.logger import logger -from app.mini_browser import DEFAULT_OWNER +from app.mini_browser import DEFAULT_OWNER, config from app.mini_browser.errors import ( MiniBrowserError, action_error, @@ -576,6 +576,12 @@ async def run_action(op: str, input_data: dict) -> dict: return simulated_result(op) try: owner = resolve_owner(data) + # The browser of the caller's session (a sub-agent's parent session). + key = config.browser_key(owner) + if key is None: + # A sub-agent whose session is unknown never gets a guessed + # browser (it would see another session's signed-in sites). + return action_error("MINI_BROWSER_SESSION_GONE") if op in URL_OPS: # The address is resolved on the host loop, with the core's own # settings (see core_url_context). @@ -589,8 +595,8 @@ async def run_action(op: str, input_data: dict) -> dict: from app.mini_browser.host import get_host host = get_host() - logger.debug(f"[MiniBrowser] {op} for {owner}") - result = await host.call(runner) + logger.debug(f"[MiniBrowser] {op} for {owner} in the browser of {key}") + result = await host.call(key, runner) except MiniBrowserError as exc: return from_exception(exc) except Exception as exc: diff --git a/app/mini_browser/bridge.py b/app/mini_browser/bridge.py index 671479437..e70effa13 100644 --- a/app/mini_browser/bridge.py +++ b/app/mini_browser/bridge.py @@ -1,12 +1,19 @@ """Thread-safe channel from the Mini Browser thread to the UI. -The browser runs on its own thread (see ``host.py``); the UI adapter runs on -the app's main loop. The adapter registers a :class:`UISink` (``ws.py``) that -hops messages onto its own loop; the browser publishes through +The browsers run on their own thread (see ``host.py``); the UI adapter runs +on the app's main loop. The adapter registers a :class:`UISink` (``ws.py``) +that hops messages onto its own loop; the browsers publish through :func:`publish`, which is a no-op when no UI is attached (CLI mode, tests). -The bridge also carries CraftBot's own UI origins (``host:port``), which the -Mini Browser must never open. +Every session has a browser of its own, and a UI client watches one +session's browser at a time (that chat's browser panel). So per-session +messages (state, frames, the agent's pointer, events) carry the session id +(``sessionId``) and go only to that session's viewers, while ``activity`` +(which session browsers run, and whether an agent is browsing in them) and +``install`` progress go to every client. + +The bridge also carries CraftBot's own UI origins (``host:port``), which no +browser may ever open. """ from __future__ import annotations @@ -23,13 +30,17 @@ "frame": "mini_browser_frame", "pointer": "mini_browser_pointer", "event": "mini_browser_event", + "activity": "mini_browser_activity", "install": "mini_browser_install_progress", + "last_frame": "mini_browser_last_frame", } +# Kinds that concern one session's browser: their payload names it (sessionId). +SESSION_KINDS = frozenset({"state", "frame", "pointer", "event", "last_frame"}) class UISink(Protocol): - def has_viewers(self) -> bool: - """Whether a UI client is watching the Mini Browser. Thread-safe.""" + def has_viewers(self, key: str) -> bool: + """Whether a UI client is watching session ``key``'s browser. Thread-safe.""" def post(self, kind: str, payload: Dict[str, Any]) -> None: """Deliver one message (kind in MESSAGE_TYPES). Thread-safe, non-blocking.""" @@ -41,7 +52,7 @@ def post(self, kind: str, payload: Dict[str, Any]) -> None: def register_ui_sink(sink: UISink) -> None: - """Make ``sink`` the UI the browser publishes to (replaces any previous).""" + """Make ``sink`` the UI the browsers publish to (replaces any previous).""" global _sink with _lock: _sink = sink @@ -60,9 +71,9 @@ def current_sink() -> Optional[UISink]: def set_ui_origins(origins: Iterable[str]) -> None: - """Record CraftBot's UI origins (``host:port``); the browser blocks them. + """Record CraftBot's UI origins (``host:port``); every browser blocks them. - Applied to a running browser right away (its network rules refresh). + Applied to the running browsers right away (their network rules refresh). """ global _origins normalized = frozenset( @@ -74,7 +85,7 @@ def set_ui_origins(origins: Iterable[str]) -> None: changed = normalized != _origins _origins = normalized if changed: - _refresh_running_browser() + _refresh_running_browsers() def ui_origins() -> FrozenSet[str]: @@ -93,18 +104,18 @@ def publish(kind: str, payload: Dict[str, Any]) -> None: logger.debug(f"[MiniBrowser] UI publish ({kind}) failed: {type(exc).__name__}") -def has_viewers() -> bool: - """Whether any UI client is watching the Mini Browser. Never raises.""" +def has_viewers(key: Optional[str]) -> bool: + """Whether any UI client is watching session ``key``'s browser. Never raises.""" sink = _sink - if sink is None: + if sink is None or not key: return False try: - return bool(sink.has_viewers()) + return bool(sink.has_viewers(key)) except Exception: return False -def _refresh_running_browser() -> None: +def _refresh_running_browsers() -> None: try: from app.mini_browser.host import get_host_if_started @@ -115,5 +126,5 @@ def _refresh_running_browser() -> None: logger.debug(f"[MiniBrowser] UI origin refresh failed: {type(exc).__name__}") -async def _refresh_rules(core: Any) -> None: - await core.refresh_network_rules() +async def _refresh_rules(fleet: Any) -> None: + await fleet.refresh_network_rules() diff --git a/app/mini_browser/config.py b/app/mini_browser/config.py index d2212ef49..622e2d97e 100644 --- a/app/mini_browser/config.py +++ b/app/mini_browser/config.py @@ -13,6 +13,7 @@ from __future__ import annotations +import hashlib import locale import math import os @@ -22,9 +23,10 @@ from dataclasses import asdict, dataclass from datetime import datetime from pathlib import Path -from typing import Any, Dict, Mapping, Optional, Tuple +from typing import Any, Dict, FrozenSet, Mapping, Optional, Tuple from app.logger import logger +from app.mini_browser import DEFAULT_OWNER DEFAULT_SEARCH_URL = "https://duckduckgo.com/?q={query}" @@ -50,6 +52,7 @@ "jpeg_quality": (30, 95), "max_agent_tabs": (1, 20), "idle_shutdown_minutes": (0, 24 * 60), + "max_running_browsers": (1, 12), } _LOCALE_RE = re.compile(r"[A-Za-z]{2,3}(?:-[A-Za-z0-9]{1,8})*") _MAX_SEARCH_URL_CHARS = 2048 @@ -71,7 +74,12 @@ class MiniBrowserSettings: search_url: str = DEFAULT_SEARCH_URL allow_file_urls: bool = False max_agent_tabs: int = 6 - idle_shutdown_minutes: int = 30 # 0 = never + # A session's browser closes this long after its panel was closed and its + # agent's run ended (0 = never). + idle_shutdown_minutes: int = 3 + # Session browsers running at once; opening one more closes the one idle + # the longest (each is a Chromium of its own). + max_running_browsers: int = 4 locale: str = "" # "" = the OS locale channel: str = "chromium" # "" = Playwright's default headless shell @@ -241,11 +249,45 @@ def save_setting(key: str, value: Any) -> None: # ───────────────────────────────────────────────────────────────────────────── -def profile_dir() -> Path: - """The persistent Chromium profile (cookies, logins, local storage).""" +# A session id used as it is for its profile folder name; anything else gets +# a stable hashed name (no path separators, no reserved names, bounded length). +_PLAIN_KEY_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}") +_RESERVED_DIR_NAMES = frozenset( + {"con", "prn", "aux", "nul"} + | {f"com{i}" for i in range(1, 10)} + | {f"lpt{i}" for i in range(1, 10)} +) +PROFILE_LOCK_SUFFIX = ".lock" + + +def profiles_root() -> Path: + """The folder holding every session's Chromium profile. + + Kept out of the agent workspace: a profile holds the session's cookies + and signed-in sites, which the agent's file tools must never read. + """ from app.config import APP_DATA_PATH - return Path(APP_DATA_PATH) / "mini_browser_profile" + return Path(APP_DATA_PATH) / "mini_browser_profiles" + + +def profile_dir_name(key: str) -> str: + """The profile folder name of browser ``key`` (a session id). Pure.""" + text = str(key or "") + if _PLAIN_KEY_RE.fullmatch(text) and text.lower() not in _RESERVED_DIR_NAMES: + return text + digest = hashlib.sha256(text.encode("utf-8", "surrogatepass")).hexdigest() + return f"s-{digest[:24]}" + + +def profile_dir(key: str) -> Path: + """Session ``key``'s persistent Chromium profile (cookies, logins, storage).""" + return profiles_root() / profile_dir_name(key) + + +def profile_lock_path(profile: Path) -> Path: + """The cross-process lock file kept next to ``profile``.""" + return profile.parent / f"{profile.name}{PROFILE_LOCK_SUFFIX}" def workspace_dir(owner: Optional[str]) -> Path: @@ -311,6 +353,88 @@ def session_record(session_id: Optional[str]) -> Any: return None +def session_exists(session_id: Optional[str]) -> Optional[bool]: + """Whether the agent session ``session_id`` exists; None when unknown + (no agent runtime here: CLI tools, tests).""" + try: + manager = getattr(internal_action_interface(), "session_manager", None) + except Exception: + return None + if manager is None: + return None + if not session_id: + return False + try: + return manager.get(session_id) is not None + except Exception: + return None + + +def live_session_ids() -> Optional[FrozenSet[str]]: + """Ids of the sessions the agent runtime has loaded; None when unknown.""" + try: + manager = getattr(internal_action_interface(), "session_manager", None) + sessions = getattr(manager, "sessions", None) if manager is not None else None + if not isinstance(sessions, Mapping): + return None + return frozenset(key for key in list(sessions) if isinstance(key, str)) + except Exception: + return None + + +def persisted_session_ids() -> Optional[FrozenSet[str]]: + """Ids of every session row in session storage; None when it cannot be + read. Blocking I/O (SQLite): call it from a worker thread.""" + try: + from app.usage.session_storage import get_session_storage + + rows = get_session_storage().get_all_sessions() + except Exception as exc: + logger.debug(f"[MiniBrowser] Session storage unreadable: {type(exc).__name__}") + return None + return frozenset( + row["session_id"] + for row in rows + if isinstance(row, Mapping) and isinstance(row.get("session_id"), str) + ) + + +# Sub-agent ids (app/subagent/manager.py) start with this; a sub-agent drives +# its parent session's browser. +SUBAGENT_ID_PREFIX = "sub_" +MAIN_SESSION_KEY = DEFAULT_OWNER # the main session: scheduled runs browse there +_MAX_SUBAGENT_DEPTH = 3 + + +def is_subagent_id(owner: Optional[str]) -> bool: + return isinstance(owner, str) and owner.startswith(SUBAGENT_ID_PREFIX) + + +def browser_key(owner: Optional[str]) -> Optional[str]: + """The session whose browser ``owner`` drives. + + A session drives its own browser; a sub-agent drives its parent + session's (a sub-agent spawned outside any session, the main one's). + None for an unknown sub-agent, or one nested deeper than sub-agents can + be: its browser is never guessed, since a wrong guess would hand it + another session's cookies and signed-in sites. + """ + current = owner + for _depth in range(_MAX_SUBAGENT_DEPTH + 1): + if not isinstance(current, str) or not current: + return None + if not is_subagent_id(current): + return current + record = subagent_record(current) + if record is None: + return None + parent = record[0] + if not parent: + return MAIN_SESSION_KEY + current = parent + return None + + def subagent_record( owner: Optional[str], ) -> Optional[Tuple[Optional[str], str, Optional[datetime]]]: @@ -319,7 +443,7 @@ def subagent_record( None when ``owner`` is not a known sub-agent. Sub-agent ids start with ``sub_``; ``created_at`` is naive UTC (as SubAgent stores it). """ - if not owner or not owner.startswith("sub_"): + if not is_subagent_id(owner): return None try: manager = getattr(internal_action_interface(), "subagent_manager", None) diff --git a/app/mini_browser/core.py b/app/mini_browser/core.py index 4ecdcce44..0b263a942 100644 --- a/app/mini_browser/core.py +++ b/app/mini_browser/core.py @@ -1,17 +1,23 @@ -"""BrowserCore: the Mini Browser engine. - -One persistent Chromium profile, shared by every agent and the user, each -agent owner in its own tab(s). Lives entirely on the Mini Browser host loop -(see ``host.py``): never call into it from another loop or thread except -through ``MiniBrowserHost.call`` / ``submit``. +"""BrowserCore: one session's browser. + +Every agent session (main, each chat, each Agent App session) has a browser +of its own: a Chromium with a persistent profile of its own (cookies, +signed-in sites and storage never shared with another session), driven by +the session's agent and its sub-agents, each owner in its own tab(s), and +watched by the user in that chat's browser panel. ``BrowserFleet`` +(``fleet.py``) keeps one core per session and limits how many run at once. +Lives entirely on the Mini Browser host loop (see ``host.py``): never call +into it from another loop or thread except through ``MiniBrowserHost.call`` +/ ``submit``. What the core takes care of: - lifecycle: single-flight launch with clear error codes, a cross-process profile lock (never two browsers on one profile, never released while Chromium may still run on it), clean relaunch after the browser or the - Playwright driver dies, idle shutdown, a close that never cuts a launch - in half; + Playwright driver dies, idle shutdown (nobody watching the panel and the + session's run over), a close that never cuts a launch in half, and the + removal of the profile when the session is deleted; - tabs: per-owner tabs and claims, owner labels, popups inheriting their opener's owner, crash recovery in the same tab slot, at least one tab open; every tab it opens gets a browser window of its own, so no agent's tab is @@ -21,8 +27,9 @@ saved into the owner's workspace (marked as coming from the Internet), network blocking (ads + CraftBot's own UI) without request interception, a main-frame guard; -- UI: state publishing (debounced), the live-view stream, raw user input and - "take control"; +- UI: state publishing (debounced, to the session's viewers only), the + session's browsing activity (to every client: it opens the chat's browser + panel), the live-view stream, raw user input and "take control"; - agents: :meth:`agent_op` runs one operation from ``ops.OPS`` under the tab lock and turns every outcome into a clean action result. An operation whose tab (or the whole browser) goes away ends at once with @@ -36,6 +43,8 @@ from __future__ import annotations import asyncio +import base64 +import collections import contextvars import functools import itertools @@ -43,23 +52,36 @@ import math import os import re +import shutil +import stat import sys import time import traceback from dataclasses import replace from pathlib import Path -from typing import Any, Awaitable, Dict, List, Mapping, Optional, Set, Tuple +from typing import ( + TYPE_CHECKING, + Any, + Awaitable, + Dict, + Iterator, + List, + Mapping, + Optional, + Set, + Tuple, +) from urllib.parse import urlsplit from app.logger import logger from app.mini_browser import ( DEFAULT_OWNER, - SESSION_ID, - SESSION_TITLE, adblock, bridge, config, + favicons, lifecycle, + resume, urls, ) from app.mini_browser.errors import ( @@ -83,13 +105,15 @@ EVENT_NOTICE, EVENT_POPUP, OWNER_KIND_MAIN, - OWNER_KIND_MINI_BROWSER, OWNER_KIND_SESSION, OWNER_KIND_SUBAGENT, OWNER_KIND_USER, Tab, ) +if TYPE_CHECKING: + from app.mini_browser.fleet import BrowserFleet + DEFAULT_VIEWPORT: Tuple[int, int] = (1280, 800) VIEWPORT_WIDTH = (320, 3840) VIEWPORT_HEIGHT = (240, 2160) @@ -156,14 +180,27 @@ # Told once to every owner that had tabs when the browser closed (C2). RELAUNCH_NOTE = ( - "Note: the Mini Browser was closed (by the user, an idle shutdown or a crash) " - "after your last action and has started again; your earlier tabs and pages " - "are gone." + "Note: this chat's browser was closed after your last action (by the user, " + "an idle shutdown, to make room for another chat's browser, or a crash) and " + "has started again with the pages it had open, now as the user's tabs; " + "mini_browser_tabs lists them (switch to one to continue there)." ) CLOSED_NOTE = ( - "Note: the Mini Browser was closed (by the user, an idle shutdown or a crash) " - "after your last action; your earlier tabs and pages are gone." + "Note: this chat's browser was closed after your last action (by the user, " + "an idle shutdown, to make room for another chat's browser, or a crash); " + "when it starts again it reopens the pages it had open, as the user's tabs." ) +# Where the browser left off (resume.py): the open pages are saved this long +# after they last changed, the picture of the page on screen at most this +# often while someone watches; the picture taken on close may take this long. +PAGES_SAVE_DELAY_S = 1.0 +LAST_FRAME_SAVE_DELAY_S = 10.0 +LAST_FRAME_TIMEOUT_MS = 2000 +# Site icons remembered per browser run (icon address -> data: URL or none). +FAVICON_CACHE_SIZE = 128 +# Deleting a session's profile: how often a file still held open is retried. +PROFILE_DELETE_ATTEMPTS = 8 +PROFILE_DELETE_RETRY_S = 0.5 _EVENT_LEVELS = { EVENT_DIALOG: "info", @@ -267,23 +304,36 @@ class BrowserCore: - """The Mini Browser engine (host loop only).""" + """One session's browser (host loop only).""" def __init__( self, settings: Optional[config.MiniBrowserSettings] = None, ops_registry: Optional[Mapping[str, Any]] = None, *, + key: str = config.MAIN_SESSION_KEY, + fleet: Optional["BrowserFleet"] = None, + tab_ids: Optional[Iterator[int]] = None, launch_args: Tuple[str, ...] = (), ) -> None: - """``ops_registry`` defaults to ``app.mini_browser.ops.OPS`` (imported on - first use); ``launch_args`` are extra Chromium switches (diagnostics, - tests). Without explicit ``settings``, settings.json is read now and - again before every launch.""" + """``key`` is the session id this browser belongs to (its profile, its + viewers). ``fleet`` makes room before every launch (at most + ``settings.max_running_browsers`` run at once); without one the + browser launches on its own. ``tab_ids`` numbers the tabs (the fleet + shares one counter between every browser, so a tab id names one tab + in the whole UI). ``ops_registry`` defaults to + ``app.mini_browser.ops.OPS`` (imported on first use); ``launch_args`` + are extra Chromium switches (diagnostics, tests). Without explicit + ``settings``, settings.json is read now and again before every + launch.""" + self.key = key + self._fleet = fleet self._settings_from_config = settings is None self.settings: config.MiniBrowserSettings = ( settings if settings is not None else config.load_settings() ) + # Set when the session is deleted: this browser may never start again. + self.retired = False self.status = "stopped" self.last_error: Optional[Dict[str, str]] = None self.tabs: Dict[str, Tab] = {} @@ -329,7 +379,9 @@ def __init__( self._cuts: Dict[str, Dict[str, Tuple[str, float]]] = {} self._adoptions: Set[asyncio.Task] = set() self._front_tab: Dict[int, str] = {} # window id -> its active tab id - self._tab_ids = itertools.count(1) + self._tab_ids: Iterator[int] = ( + tab_ids if tab_ids is not None else itertools.count(1) + ) self._owner_current: Dict[str, str] = {} self._owner_ops: Dict[str, Set[asyncio.Task]] = {} self._tab_switches: Dict[str, str] = {} # owner -> popup now its tab @@ -355,8 +407,89 @@ def __init__( self._last_activity = time.monotonic() self._last_title_refresh = 0.0 self._downloads_active = 0 + # An agent operation ran during the session's current run: the run is + # "browsing" until it ends (see browsing()). + self._browsing_run = False + # The activity last published (published again only when it changes). + self._activity_sent: Optional[Dict[str, Any]] = None + # Where this browser left off (resume.py): its open pages are saved + # soon after they change, the picture of the page on screen every + # LAST_FRAME_SAVE_DELAY_S while someone watches, both on close. While + # the saved pages reopen at a launch, nothing is saved. + self._restoring = False + self._reopening: Set[str] = set() # tabs whose reopened page has not loaded + self._pages_saved: Optional[resume.Pages] = None + self._pages_handle: Optional[asyncio.TimerHandle] = None + self._frame_unsaved: Optional[resume.LastFrame] = None + self._frame_handle: Optional[asyncio.TimerHandle] = None + self._save_lock = asyncio.Lock() + # Site icons (favicons.py): the lookup running per tab, and the icons + # already loaded this run, by icon address (None: there is none). + self._favicon_tasks: Dict[str, asyncio.Task] = {} + self._favicons: "collections.OrderedDict[str, Optional[str]]" = ( + collections.OrderedDict() + ) self._stream = FrameStreamer(self) + # ═════════════════════════════════════════════════════════════════════════ + # Session + # ═════════════════════════════════════════════════════════════════════════ + + def has_viewers(self) -> bool: + """Whether someone watches this session's browser panel.""" + return bridge.has_viewers(self.key) + + def is_running(self) -> bool: + """Chromium is up or starting (it counts against the running limit).""" + if self._context is not None or self.status in ("starting", "ready"): + return True + task = self._start_task + return task is not None and not task.done() + + def mark_starting(self) -> None: + """The fleet found room for this browser (``BrowserFleet.make_room``).""" + self._set_status("starting") + + def clear_launch_error(self) -> None: + """Chromium was just installed: an earlier "browser missing" failure + no longer applies (the panel offers Start again).""" + if self.status == "error" and self._context is None: + self._set_status("stopped") + + def is_idle(self) -> bool: + """Running, but nobody needs it now: no viewer, no agent operation, no + download and no run of its session in progress. The fleet may close it + to make room for another session's browser.""" + return ( + self.status == "ready" + and not self.has_viewers() + and not self._agent_busy() + and self._downloads_active == 0 + and not lifecycle.is_run_busy(self.key) + ) + + @property + def last_activity(self) -> float: + """When this browser was last used (monotonic clock).""" + return self._last_activity + + def browsing(self) -> bool: + """An agent is working in this browser: an operation is in flight, or + one ran during the session's run that is still going on. True once per + run, so the chat's browser panel opens when an agent starts browsing, + not again on every click.""" + if self._agent_busy(): + return True + return self._browsing_run and lifecycle.is_run_busy(self.key) + + def activity(self) -> Dict[str, Any]: + """The ``mini_browser_activity`` entry of this session.""" + return { + "sessionId": self.key, + "status": self.status, + "browsing": self.status in ("starting", "ready") and self.browsing(), + } + # ═════════════════════════════════════════════════════════════════════════ # Lifecycle # ═════════════════════════════════════════════════════════════════════════ @@ -370,10 +503,13 @@ async def ensure_started(self) -> None: Waits for a close in progress, then (re)launches. Raises the launch's MiniBrowserError, or MINI_BROWSER_CLOSED when a close aborted the - launch this call waited for. Only the caller's own cancellation ever - raises a CancelledError here. + launch this call waited for, or MINI_BROWSER_SESSION_GONE once the + session was deleted. Only the caller's own cancellation ever raises a + CancelledError here. """ for _attempt in range(4): + if self.retired: + raise MiniBrowserError("MINI_BROWSER_SESSION_GONE") close_task = self._close_task if close_task is not None and not close_task.done(): await asyncio.wait({close_task}) @@ -409,6 +545,22 @@ def _request_close(self) -> asyncio.Task: task = self._close_task = self.spawn(self._close()) return task + async def remove_profile(self) -> bool: + """The session was deleted: close for good and delete its profile. + + The browser never starts again (``retired``). The profile folder + (cookies, signed-in sites, storage) and its lock file are removed + once no Chromium runs on it any more. Returns whether it is gone. + """ + self.retired = True + await self.close() + task = self._lock_release + if task is not None and not task.done(): + await asyncio.wait( + {task}, timeout=PROFILE_FREE_WAIT_S + PROFILE_KILL_WAIT_S + 5.0 + ) + return await asyncio.to_thread(delete_profile, config.profile_dir(self.key)) + def reload_settings(self) -> None: """Re-read settings.json (in the background) and apply it. @@ -439,8 +591,14 @@ async def _launch(self) -> None: if self._context is not None: self._set_status("ready") return - self._set_status("starting") try: + if self._fleet is not None: + # Sets "starting" the moment there is room (an idle browser of + # another session may be closed for it first). + await self._fleet.make_room(self) + else: + self._set_status("starting") + self._check_abort() if self._settings_from_config: # Close + Start applies what changed in settings.json. self._apply_settings(await asyncio.to_thread(config.load_settings)) @@ -486,11 +644,11 @@ async def _launch_browser(self) -> None: except ImportError: raise MiniBrowserError("MINI_BROWSER_PLAYWRIGHT_MISSING") from None - profile = config.profile_dir() + profile = config.profile_dir(self.key) self._profile_path = profile await asyncio.to_thread(config.ensure_dir, profile) await self._await_lock_release() - lock = _ProfileLock(profile.parent / f"{profile.name}.lock") + lock = _ProfileLock(config.profile_lock_path(profile)) if not await _acquire_profile_lock(lock): raise MiniBrowserError("MINI_BROWSER_PROFILE_IN_USE") self._profile_lock = lock @@ -561,6 +719,7 @@ async def _launch_browser(self) -> None: for page in list(context.pages): if page not in self._page_tabs and not page.is_closed(): await self._setup_page(self._register_tab(page, None)) + await self._reopen_pages(profile) if not self.tabs: await self._open_tab(None) self._check_abort() @@ -569,7 +728,8 @@ async def _launch_browser(self) -> None: self._watchdog_task = self.spawn(self._watchdog()) await self._stream.sync() logger.info( - f"[MiniBrowser] Chromium started (headless={self.settings.headless}, " + f"[MiniBrowser] Chromium started for {self.key} " + f"(headless={self.settings.headless}, " f"channel={self.settings.channel or 'default'})" ) @@ -664,11 +824,19 @@ async def _close(self) -> None: # the profile): it stops at its next step and cleans up. self._abort_launch = True await asyncio.wait({start}) + frame = await self._save_where_left_off() if self._saving() else None had_browser = self._context is not None await self._teardown() + if self.has_viewers() and not self.retired: + # The panel stays open: it shows where the browser left off. + if frame is None: + frame = await asyncio.to_thread( + resume.resume_frame, config.profile_dir(self.key) + ) + bridge.publish("last_frame", resume.ui_payload(self.key, frame)) self._set_status("stopped") if had_browser: - logger.info("[MiniBrowser] Chromium closed") + logger.info(f"[MiniBrowser] Chromium closed for {self.key}") finally: self._closing = False self._abort_launch = False @@ -682,15 +850,21 @@ async def _teardown(self) -> None: *self._nav_refresh.values(), *self._recoveries.values(), *self._probes.values(), + *self._favicon_tasks.values(), ] for task in tasks: if task is not None and task is not current and not task.done(): task.cancel() self._watchdog_task = None + self._cancel_saves() + self._reopening.clear() + self._pages_saved = None self._nav_refresh.clear() self._nav_dirty.clear() self._recoveries.clear() self._probes.clear() + self._favicon_tasks.clear() + self._favicons.clear() gone = list(self.tabs.values()) for tab in gone: self._retire_secrets(tab) @@ -713,6 +887,7 @@ async def _teardown(self) -> None: self._own_targets.clear() self._abandoned_targets.clear() self.viewed_tab_id = None + self._browsing_run = False context, self._context = self._context, None pw, self._pw = self._pw, None session, self._browser_cdp = self._browser_cdp, None @@ -892,12 +1067,13 @@ def state(self) -> Dict[str, Any]: "canGoBack": tab.can_go_back, "canGoForward": tab.can_go_forward, "crashed": tab.crashed, + "favicon": tab.favicon, } ) return { + "sessionId": self.key, "status": self.status, "error": self.last_error, - "sessionId": SESSION_ID, "adblock": bool(self.settings.adblock), "viewedTabId": self.viewed_tab_id, "follow": self.follow, @@ -916,6 +1092,7 @@ def _set_status(self, status: str, error: Optional[Dict[str, str]] = None) -> No def _schedule_state(self) -> None: """Publish the state soon (changes within ~50 ms coalesce).""" + self._schedule_pages_save() if self._state_handle is not None or bridge.current_sink() is None: return try: @@ -927,10 +1104,247 @@ def _schedule_state(self) -> None: def _flush_state(self) -> None: self._state_handle = None try: - bridge.publish("state", self.state()) + if self.has_viewers(): + bridge.publish("state", self.state()) + self.publish_activity() except Exception as exc: logger.debug(f"[MiniBrowser] State publish failed: {type(exc).__name__}") + def publish_activity(self, *, force: bool = False) -> None: + """Tell every UI client whether this session's browser runs and an agent + browses in it (only when that changed, unless ``force``).""" + activity = self.activity() + if not force and activity == self._activity_sent: + return + self._activity_sent = activity + bridge.publish("activity", dict(activity)) + + # ═════════════════════════════════════════════════════════════════════════ + # Where it left off (resume.py) + # ═════════════════════════════════════════════════════════════════════════ + + def _saving(self) -> bool: + """What is open may be saved: the browser runs (not dying, not + reopening the saved pages) and its session still exists.""" + return ( + self.status == "ready" + and self._context is not None + and self._context is not self._dead_context + and not self._restoring + and not self.retired + ) + + def _restorable(self, url: str) -> bool: + """A page worth reopening: an address the browser may open (not a + blank tab), holding no secret the vault typed.""" + if not url or url == "about:blank" or len(url) > resume.MAX_URL_CHARS: + return False + try: + self._resolve(url, allow_search=False) + except MiniBrowserError: + return False + return not any(secret and secret in url for secret in self._secrets()) + + def _open_pages(self) -> resume.Pages: + """The pages open now, in tab order: at most ``resume.MAX_PAGES``, the + one on screen always among them.""" + tabs = [tab for tab in self.tabs.values() if self._restorable(tab.url)] + viewed = self.tabs.get(self.viewed_tab_id or "") + if len(tabs) > resume.MAX_PAGES: + kept = tabs[: resume.MAX_PAGES] + if viewed in tabs and viewed not in kept: + kept[-1] = viewed + tabs = kept + return resume.Pages( + pages=tuple( + resume.Page(url=tab.url, title=_clip(tab.title, resume.MAX_TITLE_CHARS)) + for tab in tabs + ), + viewed=tabs.index(viewed) if viewed in tabs else 0, + ) + + def _schedule_pages_save(self) -> None: + """Save the open pages soon (changes within PAGES_SAVE_DELAY_S coalesce).""" + if self._pages_handle is not None or not self._saving(): + return + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + self._pages_handle = loop.call_later(PAGES_SAVE_DELAY_S, self._save_pages_soon) + + def _save_pages_soon(self) -> None: + self._pages_handle = None + if self._saving(): + self.spawn(self._save_pages()) + + async def _save_pages(self) -> None: + async with self._save_lock: + if not self._saving(): + return + pages = self._open_pages() + if pages != self._pages_saved and await self._write_resume( + resume.save_pages, pages + ): + self._pages_saved = pages + + def note_frame(self, tab_id: str, image: str, width: int, height: int) -> None: + """The live view sent a frame (``stream.py``): keep the viewed tab's as + the picture of where this browser left off, saved every + LAST_FRAME_SAVE_DELAY_S at most.""" + tab = self.tabs.get(tab_id) + if tab is None or tab_id != self.viewed_tab_id or not self._saving(): + return + data = image.split(",", 1)[1] if image.startswith("data:") else image + self._frame_unsaved = resume.LastFrame( + url=tab.url, + title=_clip(tab.title, resume.MAX_TITLE_CHARS), + width=int(width), + height=int(height), + image=data, + ) + if self._frame_handle is None: + loop = asyncio.get_running_loop() + self._frame_handle = loop.call_later( + LAST_FRAME_SAVE_DELAY_S, self._save_frame_soon + ) + + def _save_frame_soon(self) -> None: + self._frame_handle = None + self.spawn(self._save_frame()) + + async def _save_frame(self) -> None: + async with self._save_lock: + frame, self._frame_unsaved = self._frame_unsaved, None + if frame is not None and self._saving() and self._keepable(frame): + await self._write_resume(resume.save_last_frame, frame) + + def _keepable(self, frame: resume.LastFrame) -> bool: + return ( + len(frame.image) <= resume.MAX_IMAGE_CHARS + and frame.width > 0 + and frame.height > 0 + and self._restorable(frame.url) + ) + + async def _save_where_left_off(self) -> Optional[resume.LastFrame]: + """The browser is closing: save its open pages and a fresh picture of + the page on screen. Returns that picture when it shows the page the + browser will reopen on screen (else None).""" + self._cancel_saves() + captured = await self._capture_viewed() + frame = captured or self._frame_unsaved + self._frame_unsaved = None + async with self._save_lock: + if not self._saving(): + return None + pages = self._open_pages() + if pages != self._pages_saved and await self._write_resume( + resume.save_pages, pages + ): + self._pages_saved = pages + viewed = pages.viewed_page + if ( + frame is None + or viewed is None + or frame.url != viewed.url + or not self._keepable(frame) + or not await self._write_resume(resume.save_last_frame, frame) + ): + return None + return frame + + async def _capture_viewed(self) -> Optional[resume.LastFrame]: + """A picture of the viewed tab now, or None (a blank or crashed tab, or + the page did not answer in time).""" + tab = self.tabs.get(self.viewed_tab_id or "") + if tab is None or tab.crashed or not self._restorable(tab.url): + return None + try: + if tab.page.is_closed(): + return None + jpeg = await asyncio.wait_for( + tab.page.screenshot( + type="jpeg", + quality=int(self.settings.jpeg_quality), + timeout=LAST_FRAME_TIMEOUT_MS, + ), + LAST_FRAME_TIMEOUT_MS / 1000 + 1, + ) + except Exception as exc: + logger.debug(f"[MiniBrowser] Last picture not taken: {type(exc).__name__}") + return None + width, height = self._page_size(tab) + return resume.LastFrame( + url=tab.url, + title=_clip(tab.title, resume.MAX_TITLE_CHARS), + width=width, + height=height, + image=base64.b64encode(jpeg).decode("ascii"), + ) + + async def _write_resume(self, save: Any, value: Any) -> bool: + """Write one resume file in a worker thread. Never raises.""" + try: + await asyncio.to_thread(save, config.profile_dir(self.key), value) + except OSError as exc: + logger.debug(f"[MiniBrowser] Resume file not saved: {type(exc).__name__}") + return False + return True + + def _cancel_saves(self) -> None: + for handle in (self._pages_handle, self._frame_handle): + if handle is not None: + handle.cancel() + self._pages_handle = self._frame_handle = None + + async def _reopen_pages(self, profile: Path) -> None: + """Reopen the pages this browser had open when it last closed, as the + user's tabs, and show the one that was on screen. Their pages load in + the background; a page no longer allowed is skipped.""" + saved = await asyncio.to_thread(resume.load_pages, profile) + self._pages_saved = saved + if saved is None or not saved.pages: + return + self._restoring = True + try: + blank = [ + tab + for tab in self.tabs.values() + if tab.owner is None and _is_blank(tab) + ] + reopened: Dict[int, Tab] = {} + for index, page in enumerate(saved.pages): + self._check_abort() + try: + url = self._resolve(page.url, allow_search=False).url + except MiniBrowserError: + continue + try: + tab = blank.pop(0) if blank else await self._open_tab(None) + except MiniBrowserError as exc: + if exc.code == "MINI_BROWSER_CLOSED": + raise + logger.debug(f"[MiniBrowser] Pages not all reopened: {exc.code}") + break + tab.url, tab.title, tab.loading = url, page.title, True + self._reopening.add(tab.id) + reopened[index] = tab + self.spawn(self._reopen(tab, url)) + viewed = reopened.get(saved.viewed) or next(iter(reopened.values()), None) + if viewed is not None: + self.viewed_tab_id = viewed.id + finally: + self._restoring = False + self._schedule_state() + + async def _reopen(self, tab: Tab, url: str) -> None: + try: + await self._open_in_background(tab, url) + finally: + self._reopening.discard(tab.id) + self._schedule_nav_refresh(tab) + # ═════════════════════════════════════════════════════════════════════════ # Streaming # ═════════════════════════════════════════════════════════════════════════ @@ -1283,13 +1697,18 @@ async def set_viewport(self, width: int, height: int) -> None: async def set_adblock(self, enabled: bool) -> None: """Turn ad blocking on/off for every tab (no reload) and remember it.""" enabled = bool(enabled) - self.settings = replace(self.settings, adblock=enabled) try: await asyncio.to_thread(config.save_setting, "adblock", enabled) except Exception as exc: logger.warning( f"[MiniBrowser] Could not save the ad-block setting: {type(exc).__name__}" ) + await self.apply_adblock(enabled) + + async def apply_adblock(self, enabled: bool) -> None: + """Turn ad blocking on/off for every tab (no reload), not saved: the + fleet saves the setting once for every session's browser.""" + self.settings = replace(self.settings, adblock=bool(enabled)) await self.refresh_network_rules() self._schedule_state() @@ -1355,13 +1774,21 @@ async def agent_op(self, owner: str, op: str, params: dict) -> dict: abandoned at once and the result is MINI_BROWSER_TAB_CLOSED / MINI_BROWSER_CLOSED, whatever the operation would have reported. """ - owner = owner if isinstance(owner, str) and owner else DEFAULT_OWNER + owner = owner if isinstance(owner, str) and owner else self.key params = dict(params or {}) task = asyncio.current_task() owner_tasks = self._owner_ops.setdefault(owner, set()) if task is not None: owner_tasks.add(task) self._touch() + if ( + not self._browsing_run + and lifecycle.is_run_busy(self.key) + and not self._is_revoked(owner) + ): + # The session's run is browsing from now on (its panel opens). + self._browsing_run = True + self._schedule_state() # browsing() is true while this operation runs tab: Optional[Tab] = None # A popup an earlier action opened that became this owner's tab after # that action's result had gone out: this result says so first. @@ -1554,8 +1981,8 @@ async def tab_for_owner(self, owner: str) -> Tab: 1) the owner's current tab (else its most recently used one); 2) else the viewed tab if the owner may claim it: a blank user tab, - or any idle user tab for the dedicated Mini Browser chat (other - agents' tabs are never claimed); + or, for the session's own agent, the idle user tab the user is + viewing in the panel (other agents' tabs are never claimed); 3) else a new tab, in a window of its own (at most ``settings.max_agent_tabs`` per owner). """ @@ -1955,9 +2382,10 @@ def _claimable(self, tab: Tab, owner: str, *, explicit: bool = False) -> bool: Its own tab always. Another agent's tab never. A user tab only when the user is not using it (no input for USER_ACTIVE_S, no take control) and either it is blank (about:blank with no history), or it - is the tab the user is viewing and the owner is the dedicated Mini - Browser chat, or the owner asked for it (``mini_browser_tabs`` - action='switch', ``explicit``). + is the tab the user is viewing and the owner is the session's own + agent (the user opened the page in this chat's browser panel to work + on it with that agent), or the owner asked for it + (``mini_browser_tabs`` action='switch', ``explicit``). """ if tab.user_control or tab.ops: return False @@ -1969,7 +2397,7 @@ def _claimable(self, tab: Tab, owner: str, *, explicit: bool = False) -> bool: return False if _is_blank(tab): return True - return tab.id == self.viewed_tab_id and (owner == SESSION_ID or explicit) + return tab.id == self.viewed_tab_id and (owner == self.key or explicit) def _claim(self, tab: Tab, owner: str) -> None: previous = tab.owner @@ -2007,12 +2435,6 @@ def _apply_owner(self, tab: Tab, owner: Optional[str]) -> None: def _owner_info(self, owner: str, depth: int = 0) -> Tuple[str, str, Optional[str]]: """``(label, kind, parent)`` for an owner id, from the agent runtime.""" - if owner == SESSION_ID: - return ( - self._session_title(owner) or SESSION_TITLE, - OWNER_KIND_MINI_BROWSER, - None, - ) if owner == DEFAULT_OWNER: return self._session_title(owner) or "Main", OWNER_KIND_MAIN, None if owner.startswith("sub_"): @@ -2134,7 +2556,15 @@ def on_run_state(self, owner: str, state: str) -> None: self._schedule_state() def run_state_changed(self, owner: str) -> None: - """``lifecycle.on_run_state`` recorded a change: republish busy flags.""" + """``lifecycle.on_run_state`` recorded a change: republish busy flags. + + The session's run started or ended: browsing starts over, so the + first browser action of every run opens the panel (once), and a run + that never touches the browser never does. + """ + if owner == self.key: + self._browsing_run = False + self._touch() # the idle grace counts from the end of the run self._schedule_state() def release_owner(self, owner: str, close_tabs: bool) -> None: @@ -2146,7 +2576,7 @@ def release_owner(self, owner: str, close_tabs: bool) -> None: """ if not owner: return - viewing = bridge.has_viewers() + viewing = self.has_viewers() owned = [tab for tab in self.tabs.values() if tab.owner == owner] closing = [ tab @@ -2217,7 +2647,7 @@ async def publish_pointer(self, tab: Tab, x: float, y: float, kind: str) -> None if not (math.isfinite(px) and math.isfinite(py)): return tab.mouse_x, tab.mouse_y = px, py - if tab.id != self.viewed_tab_id or not bridge.has_viewers(): + if tab.id != self.viewed_tab_id or not self.has_viewers(): return kind = kind if kind in ("move", "down", "up", "click") else "move" now = time.monotonic() @@ -2231,6 +2661,7 @@ async def publish_pointer(self, tab: Tab, x: float, y: float, kind: str) -> None bridge.publish( "pointer", { + "sessionId": self.key, "tabId": tab.id, "x": _clamp_unit(px / width), "y": _clamp_unit(py / height), @@ -2242,6 +2673,7 @@ def add_event(self, tab: Tab, kind: str, message: str, **extra: Any) -> None: """A notice for the tab's agent (attached to its next result) and a UI toast.""" event = self._agent_notice(tab, kind, message, **extra) payload = { + "sessionId": self.key, "kind": event["kind"], "level": event.pop("_level"), "message": event["message"], @@ -2640,6 +3072,7 @@ def _remove_tab(self, tab: Tab) -> None: position = order.index(tab.id) del self.tabs[tab.id] tab.mark_closed(CLOSED_TAB) # an operation on it ends at once + self._reopening.discard(tab.id) self._retire_secrets(tab) self._cuts.pop(tab.id, None) self._stream.forget_tab(tab.id) @@ -2648,6 +3081,9 @@ def _remove_tab(self, tab: Tab) -> None: self._nav_seq.pop(tab.id, None) self._blocks_reported.pop(tab.id, None) self._pointer_sent.pop(tab.id, None) + favicon_task = self._favicon_tasks.pop(tab.id, None) + if favicon_task is not None and not favicon_task.done(): + favicon_task.cancel() self._nav_dirty.discard(tab.id) refresh = self._nav_refresh.pop(tab.id, None) if refresh is not None and refresh is not asyncio.current_task(): @@ -2961,7 +3397,12 @@ def _on_frame_navigated(self, page: Any, frame: Any) -> None: url = frame.url except Exception: return - if isinstance(url, str) and url and not url.startswith("chrome-error:"): + if ( + isinstance(url, str) + and url + and not url.startswith("chrome-error:") + and not (url == "about:blank" and tab.id in self._reopening) + ): tab.url = url tab.unresponsive = False # a committed document answers again self._bump_nav(tab) @@ -3058,11 +3499,14 @@ async def _refresh_nav(self, tab: Tab) -> None: # address that failed, as a browser does. error_page = url.startswith("chrome-error:") shown = entry_url if error_page and entry_url else url + reopening = tab.id in self._reopening + if shown == "about:blank" and reopening: + shown = "" # the reopened page has not committed yet: keep its address if shown: tab.url = shown - if title is not None: + if title is not None and not (reopening and not shown): tab.title = _clip(title, 300) - pending = tab.id in self._pending_docs + pending = tab.id in self._pending_docs or reopening tab.loading = pending or (tab.loading if ready is None else ready != "complete") reason = urls.blocked_reason( tab.url, @@ -3075,8 +3519,69 @@ async def _refresh_nav(self, tab: Tab) -> None: # page that really loaded is sent away, never during a newer navigation. if not error_page and not pending: await self._leave_page(tab) + if ( + tab.favicon_page != tab.url + and not reopening + and ready in ("interactive", "complete") + ): + self._look_up_favicon(tab, error_page) self._schedule_state() + def _look_up_favicon(self, tab: Tab, error_page: bool) -> None: + """The tab shows a new page (its DOM is there): find its site icon. A + page that is no web page (blank, the browser's own, an error page) + has none.""" + tab.favicon_page = page_url = tab.url + running = self._favicon_tasks.pop(tab.id, None) + if running is not None and not running.done(): + running.cancel() + if error_page or not favicons.is_web(page_url): + tab.favicon = None + return + self._favicon_tasks[tab.id] = self.spawn(self._load_favicon(tab, page_url)) + + async def _load_favicon(self, tab: Tab, page_url: str) -> None: + try: + try: + links = await asyncio.wait_for( + tab.page.evaluate(favicons.ICON_LINKS_JS), TITLE_TIMEOUT_S + ) + except Exception: + links = None # the page did not answer: try /favicon.ico + icon: Optional[str] = None + for address in favicons.candidates(page_url, links): + if address in self._favicons: + self._favicons.move_to_end(address) + icon = self._favicons[address] + else: + context = self._context + if context is None: + return + icon = await favicons.fetch_icon( + context.request, address, self._icon_allowed + ) + self._favicons[address] = icon + while len(self._favicons) > FAVICON_CACHE_SIZE: + self._favicons.popitem(last=False) + if icon is not None: + break + if self.tabs.get(tab.id) is not tab or tab.favicon_page != page_url: + return # closed, or on another page by now + if tab.favicon != icon: + tab.favicon = icon + self._schedule_state() + finally: + if self._favicon_tasks.get(tab.id) is asyncio.current_task(): + del self._favicon_tasks[tab.id] + + def _icon_allowed(self, url: str) -> bool: + """A site icon may load from an address the browser may open: never + CraftBot's own UI.""" + return ( + urls.blocked_reason(url, allow_file=False, ui_origins=self.ui_origins()) + is None + ) + def _report_block(self, tab: Tab, url: str, reason: str) -> None: """One notice per blocked navigation (refreshes repeat).""" key = (self._nav_seq.get(tab.id, 0), url) @@ -3372,8 +3877,12 @@ async def _watchdog(self) -> None: return # the browser is gone: a close is under way await self._stream.sync() now = time.monotonic() - if bridge.has_viewers(): + watched = self.has_viewers() + if watched or lifecycle.is_run_busy(self.key): + # In use: the panel is open, or the session's run (which + # may come back to the browser) is still going on. self._last_activity = now + if watched: if now - self._last_title_refresh >= TITLE_REFRESH_S: self._last_title_refresh = now for tab in list(self.tabs.values()): @@ -3381,7 +3890,8 @@ async def _watchdog(self) -> None: elif self._idle_expired(now): minutes = self.settings.idle_shutdown_minutes logger.info( - f"[MiniBrowser] Closing Chromium after {minutes} idle minute(s)" + f"[MiniBrowser] Closing the browser of {self.key} after " + f"{minutes} idle minute(s)" ) self._request_close() return @@ -3391,6 +3901,9 @@ async def _watchdog(self) -> None: logger.debug(f"[MiniBrowser] Watchdog: {type(exc).__name__}") def _idle_expired(self, now: float) -> bool: + """Nobody has needed the browser for ``idle_shutdown_minutes``: its + panel closed, no agent operation and no download (the watchdog keeps + ``_last_activity`` fresh while the panel is open or a run goes on).""" minutes = self.settings.idle_shutdown_minutes return ( minutes > 0 @@ -3874,6 +4387,74 @@ def _kill_processes(procs: List[Any], wait: float) -> None: pass +def delete_profile(profile: Path) -> bool: + """Delete a session's profile folder and its lock file. Blocking I/O. + + Takes the profile's lock first, so a profile a browser still holds (in + this process or another CraftBot) is left alone; Chromium processes + still running on it after an unclean close are ended first. Files held + open for a moment longer (Windows) are retried. Returns whether the + profile is gone. + """ + profile = Path(profile) + lock_path = config.profile_lock_path(profile) + if not profile.exists() and not lock_path.exists(): + return True + lock = _ProfileLock(lock_path) + if not lock.acquire(): + logger.info( + f"[MiniBrowser] Profile {profile.name} is in use; it is not deleted" + ) + return False + try: + leftover = _profile_processes(profile) + if leftover: + _kill_processes(leftover, PROFILE_KILL_WAIT_S) + removed = _remove_tree(profile) + finally: + lock.release() + if removed: + try: + lock_path.unlink() + except FileNotFoundError: + pass + except OSError as exc: + logger.debug( + f"[MiniBrowser] Profile lock not removed: {type(exc).__name__}" + ) + logger.info(f"[MiniBrowser] Deleted the browser profile {profile.name}") + return removed + + +def _remove_tree(path: Path) -> bool: + """``shutil.rmtree`` that clears read-only flags and retries files still + held open for a moment (Windows). Blocking I/O. True once it is gone.""" + + def make_writable(func: Any, target: str, _error: Any) -> None: + os.chmod(target, stat.S_IWRITE) + func(target) + + for attempt in range(PROFILE_DELETE_ATTEMPTS): + if not path.exists(): + return True + try: + if sys.version_info >= (3, 12): + shutil.rmtree(path, onexc=make_writable) + else: + shutil.rmtree(path, onerror=make_writable) + except FileNotFoundError: + return True + except OSError as exc: + if attempt == PROFILE_DELETE_ATTEMPTS - 1: + logger.warning( + f"[MiniBrowser] Could not delete the browser profile " + f"{path.name}: {type(exc).__name__}" + ) + return False + time.sleep(PROFILE_DELETE_RETRY_S) + return not path.exists() + + def _reserve_download_path(directory: Path, name: str) -> Path: """Create an empty, not-yet-used file for a download. Blocking I/O.""" directory = Path(directory) diff --git a/app/mini_browser/errors.py b/app/mini_browser/errors.py index 076ab2373..94e7db661 100644 --- a/app/mini_browser/errors.py +++ b/app/mini_browser/errors.py @@ -40,15 +40,29 @@ "config", "error", "Browser not installed", - "The Mini Browser's Chromium is not installed yet. Install it from the " - "Mini Browser page, or run `python -m playwright install chromium`.", + "The Mini Browser's Chromium is not installed yet. Install it from a " + "chat's browser panel, or run `python -m playwright install chromium`.", ), "MINI_BROWSER_PROFILE_IN_USE": ( "config", "error", "Browser profile in use", - "The Mini Browser profile is already in use by another CraftBot window. " - "Close the other window and try again.", + "This chat's browser profile is already in use by another CraftBot " + "instance. Close the other instance and try again.", + ), + "MINI_BROWSER_TOO_MANY_BROWSERS": ( + "validation", + "warning", + "Too many browsers open", + "{count} chats already have their browser running and in use (at most " + "{limit} run at once). Wait for one of them to finish, or close a " + "browser from its chat's browser panel.", + ), + "MINI_BROWSER_SESSION_GONE": ( + "not_found", + "warning", + "Chat deleted", + "The chat this browser belonged to has been deleted, so its browser is gone.", ), "MINI_BROWSER_LAUNCH_FAILED": ( "internal", @@ -67,8 +81,9 @@ "warning", "Browser closed", "The Mini Browser was closed (by the user, an idle shutdown or a crash), " - "so this action did not finish and your previous tabs are gone. Navigate " - "again if the task still needs the browser.", + "so this action did not finish. When it starts again it reopens the pages " + "it had open, as the user's tabs. Navigate again if the task still needs " + "the browser.", ), "MINI_BROWSER_TAB_CLOSED": ( "not_found", @@ -134,9 +149,9 @@ "warning", "The user's tab", "Tab {tab} is the user's own tab. You can switch to it only while the " - "user is viewing it in the Mini Browser and not using it (or when it is " - "blank). Open your own tab with mini_browser_tabs action='new', or ask " - "the user to show you the page.", + "user is viewing it in this chat's browser panel and not using it (or " + "when it is blank). Open your own tab with mini_browser_tabs " + "action='new', or ask the user to show you the page.", ), "MINI_BROWSER_TOO_MANY_TABS": ( "validation", @@ -168,8 +183,8 @@ "not_found", "warning", "No saved login", - "No saved login matches {site}. Ask the user to add one in the Mini " - "Browser's Passwords panel, or to sign in themselves in the live view.", + "No saved login matches {site}. Ask the user to add one under Passwords " + "in the browser panel, or to sign in themselves in the live view.", ), "MINI_BROWSER_LOGIN_FAILED": ( "validation", diff --git a/app/mini_browser/favicons.py b/app/mini_browser/favicons.py new file mode 100644 index 000000000..f8283f990 --- /dev/null +++ b/app/mini_browser/favicons.py @@ -0,0 +1,168 @@ +"""Site icons (favicons) for the tab strip. + +After a page loads, :data:`ICON_LINKS_JS` reads the icons it declares +(```` and friends); :func:`candidates` orders them (the one +nearest 32 px first, ``/favicon.ico`` of the page's origin last) and +:func:`fetch_icon` reads one (an icon inline in the page as a ``data:`` +address needs no request) or loads it through the browser context's own +request client, so the site sees its own cookies, as with a real browser's icon +request. Every hop, redirects included, must be an http(s) address the +browser may open (never CraftBot's own UI). Only real image bytes are kept +(checked by their content, not the server's content type), at most +:data:`MAX_ICON_BYTES`, as a ``data:`` URL the UI shows as is: the UI never +fetches a remote address itself. +""" + +from __future__ import annotations + +import base64 +import binascii +import re +from typing import Any, Callable, List, Optional +from urllib.parse import unquote_to_bytes, urljoin, urlsplit + +from app.logger import logger + +MAX_ICON_BYTES = 16 * 1024 +FETCH_TIMEOUT_MS = 4000 +MAX_REDIRECTS = 3 +# Declared icons tried before /favicon.ico. +MAX_DECLARED = 2 + +# The icons a page declares: [href, sizes, type] in document order (an icon +# for another color scheme or media, per its media attribute, left out). +ICON_LINKS_JS = """() => Array.from(document.querySelectorAll('link[rel~="icon" i]')) + .filter(link => link.href && (!link.media || matchMedia(link.media).matches)) + .slice(0, 12) + .map(link => [link.href, link.getAttribute('sizes') || '', link.type || ''])""" + +_SIZE_RE = re.compile(r"(\d+)x(\d+)", re.IGNORECASE) +_SVG_RE = re.compile(rb"]", re.IGNORECASE) + + +def candidates(page_url: str, links: Any) -> List[str]: + """Icon addresses to try for a page at ``page_url``, best first: the + declared icons nearest 32 px (an SVG counts as any size), then the + origin's ``/favicon.ico``. http(s) addresses, and icons inline in the + page (``data:``).""" + declared = [] + if isinstance(links, list): + for index, item in enumerate(links): + if not isinstance(item, list) or len(item) != 3: + continue + href, sizes, kind = (str(value) for value in item) + if not (is_web(href) or _is_inline(href)): + continue + declared.append((_size_penalty(sizes, kind), index, href)) + ordered = [href for _penalty, _index, href in sorted(declared)][:MAX_DECLARED] + if is_web(page_url): + parts = urlsplit(page_url) + fallback = f"{parts.scheme}://{parts.netloc}/favicon.ico" + if fallback not in ordered: + ordered.append(fallback) + return ordered + + +async def fetch_icon( + request: Any, url: str, allowed: Callable[[str], bool] +) -> Optional[str]: + """The icon at ``url`` as a ``data:`` URL, or None (not allowed, not an + image, too large, or it failed). ``request`` is the browser context's + APIRequestContext; ``allowed`` vets every address, redirects included. + Never raises.""" + if _is_inline(url): + body = _inline_bytes(url) + return data_url(body) if body is not None else None + current = url + try: + for _hop in range(MAX_REDIRECTS + 1): + if not is_web(current) or not allowed(current): + return None + response = await request.get( + current, + timeout=FETCH_TIMEOUT_MS, + max_redirects=0, + fail_on_status_code=False, + ) + try: + location = response.headers.get("location") + if 300 <= response.status < 400 and location: + current = urljoin(current, location) + continue + if response.status != 200: + return None + length = response.headers.get("content-length", "") + if length.isdigit() and int(length) > MAX_ICON_BYTES: + return None + body = await response.body() + finally: + await response.dispose() + return data_url(body) + except Exception as exc: + logger.debug(f"[MiniBrowser] Site icon not loaded: {type(exc).__name__}") + return None + + +def data_url(body: bytes) -> Optional[str]: + """``body`` as an image ``data:`` URL, or None when it is no image (an + error page, say) or larger than MAX_ICON_BYTES.""" + if not body or len(body) > MAX_ICON_BYTES: + return None + mime = sniff(body) + if mime is None: + return None + return f"data:{mime};base64,{base64.b64encode(body).decode('ascii')}" + + +def sniff(body: bytes) -> Optional[str]: + """The image type of ``body`` by its content, or None.""" + if body.startswith(b"\x89PNG\r\n\x1a\n"): + return "image/png" + if body.startswith(b"\x00\x00\x01\x00"): + return "image/x-icon" + if body.startswith((b"GIF87a", b"GIF89a")): + return "image/gif" + if body.startswith(b"\xff\xd8\xff"): + return "image/jpeg" + if body.startswith(b"RIFF") and body[8:12] == b"WEBP": + return "image/webp" + head = body[:1024].lstrip() + if head.startswith((b" bool: + return url[:11].lower() == "data:image/" + + +def _inline_bytes(url: str) -> Optional[bytes]: + """The bytes of a ``data:`` address, or None when malformed or too large.""" + header, separator, payload = url.partition(",") + if not separator or len(payload) > MAX_ICON_BYTES * 3: + return None + try: + if header.lower().endswith(";base64"): + return base64.b64decode(payload, validate=True) + return unquote_to_bytes(payload) + except (binascii.Error, ValueError): + return None + + +def is_web(url: str) -> bool: + try: + parts = urlsplit(url) + except ValueError: + return False + return parts.scheme in ("http", "https") and bool(parts.netloc) + + +def _size_penalty(sizes: str, kind: str) -> int: + """How far an icon is from 32 px (smaller is better).""" + if "svg" in kind.lower() or sizes.strip().lower() == "any": + return 0 + best = None + for match in _SIZE_RE.finditer(sizes): + distance = abs(int(match.group(1)) - 32) + best = distance if best is None else min(best, distance) + return 16 if best is None else best diff --git a/app/mini_browser/fleet.py b/app/mini_browser/fleet.py new file mode 100644 index 000000000..311747f5a --- /dev/null +++ b/app/mini_browser/fleet.py @@ -0,0 +1,294 @@ +"""BrowserFleet: one browser per agent session. + +Every session (the main session, each chat, each Agent App session) has a +:class:`~app.mini_browser.core.BrowserCore` of its own, with a persistent +Chromium profile of its own (cookies, signed-in sites and storage are never +shared between sessions). The fleet lives on the Mini Browser host loop (see +``host.py``) and: + +- creates a session's browser on first use, without starting it: Chromium + starts when the user opens the chat's browser panel or the session's agent + first needs it; +- refuses browsers for sessions that no longer exist (a sub-agent still + running after its chat was deleted never recreates its browser); +- keeps at most ``settings.max_running_browsers`` running at once: starting + one more closes the browser idle the longest (nobody watching it, no agent + operation, no download, its session's run over), and is refused with + MINI_BROWSER_TOO_MANY_BROWSERS when every running browser is in use; +- deletes a session's profile when the session is deleted, and, once per + process, the profiles of sessions that no longer exist (left behind by a + crash between a deletion and its cleanup); +- applies settings, network rules and the ad-block switch to every browser; +- shares one tab-id counter between every browser, so a tab id names one tab + in the whole UI. +""" + +from __future__ import annotations + +import asyncio +import itertools +from pathlib import Path +from typing import Any, Callable, Dict, FrozenSet, List, Mapping, Optional, Set, Tuple + +from app.logger import logger +from app.mini_browser import config +from app.mini_browser.core import BrowserCore, delete_profile +from app.mini_browser.errors import MiniBrowserError + +# How long after the fleet starts the orphaned-profile sweep runs: the agent +# runtime has restored its sessions by then. +SWEEP_DELAY_S = 30.0 +# Room-making rounds before a launch gives up (each closes one idle browser). +_MAX_EVICTIONS = 12 + +CoreFactory = Callable[[str, "BrowserFleet"], BrowserCore] + + +class BrowserFleet: + """Every session's browser (host loop only).""" + + def __init__( + self, + settings: Optional[config.MiniBrowserSettings] = None, + ops_registry: Optional[Mapping[str, Any]] = None, + *, + launch_args: Tuple[str, ...] = (), + core_factory: Optional[CoreFactory] = None, + ) -> None: + """``settings`` / ``ops_registry`` / ``launch_args`` are handed to every + browser (see :class:`BrowserCore`); ``core_factory(key, fleet)`` builds + a session's browser instead (tests).""" + self._settings = settings + self._ops_registry = ops_registry + self._launch_args = tuple(launch_args) + self._core_factory = core_factory + self.cores: Dict[str, BrowserCore] = {} + self.tab_ids = itertools.count(1) + # Sessions whose browser is being removed (deleted sessions). + self._removals: Dict[str, asyncio.Task] = {} + self._tasks: Set[asyncio.Task] = set() + + # ── browsers ───────────────────────────────────────────────────────────── + + def core(self, key: Any) -> BrowserCore: + """Session ``key``'s browser, created (not started) on first use. + + Raises MINI_BROWSER_SESSION_GONE for a session that no longer exists + or is being removed, MINI_BROWSER_INVALID_INPUT without a session. + """ + if not isinstance(key, str) or not key: + raise MiniBrowserError("MINI_BROWSER_INVALID_INPUT", detail="No session.") + core = self.cores.get(key) + if core is not None and not core.retired: + return core + if key in self._removals or config.session_exists(key) is False: + raise MiniBrowserError("MINI_BROWSER_SESSION_GONE") + core = self._new_core(key) + self.cores[key] = core + return core + + def existing(self, key: Any) -> Optional[BrowserCore]: + """Session ``key``'s browser if it was ever used, else None (never creates).""" + core = self.cores.get(key) if isinstance(key, str) else None + return core if core is not None and not core.retired else None + + def _new_core(self, key: str) -> BrowserCore: + if self._core_factory is not None: + return self._core_factory(key, self) + return BrowserCore( + self._settings, + self._ops_registry, + key=key, + fleet=self, + tab_ids=self.tab_ids, + launch_args=self._launch_args, + ) + + def activity(self) -> List[Dict[str, Any]]: + """Every session browser's ``mini_browser_activity`` entry.""" + return [ + core.activity() for core in list(self.cores.values()) if not core.retired + ] + + # ── the running limit ──────────────────────────────────────────────────── + + async def make_room(self, core: BrowserCore) -> None: + """Called by ``core`` right before it launches Chromium. + + At most ``max_running_browsers`` run at once: while there is no room, + the browser idle the longest is closed (its agents are told on their + next action that it was closed); when every running browser is + in use, MINI_BROWSER_TOO_MANY_BROWSERS. ``core`` is marked starting + the moment there is room, with no await between the count and the + mark, so concurrent launches can never overshoot the limit. + """ + for _round in range(_MAX_EVICTIONS): + limit = max(1, int(core.settings.max_running_browsers)) + running = [ + other + for other in list(self.cores.values()) + if other is not core and other.is_running() + ] + if len(running) < limit: + core.mark_starting() + return + idle = [other for other in running if other.is_idle()] + if not idle: + raise MiniBrowserError( + "MINI_BROWSER_TOO_MANY_BROWSERS", count=len(running), limit=limit + ) + victim = min(idle, key=lambda other: other.last_activity) + logger.info( + f"[MiniBrowser] Closing the idle browser of {victim.key} to start " + f"the browser of {core.key} ({limit} run at once)" + ) + await victim.close() + raise MiniBrowserError( + "MINI_BROWSER_TOO_MANY_BROWSERS", + count=sum(1 for other in self.cores.values() if other.is_running()), + limit=max(1, int(core.settings.max_running_browsers)), + ) + + # ── deleted sessions ───────────────────────────────────────────────────── + + async def remove(self, key: str) -> bool: + """Session ``key`` was deleted: close its browser for good and delete + its profile. Idempotent (concurrent calls share one removal); returns + whether the profile is gone.""" + if not isinstance(key, str) or not key: + return False + task = self._removals.get(key) + if task is None or task.done(): + task = self._removals[key] = self._spawn(self._remove(key)) + await asyncio.wait({task}) + return bool(not task.cancelled() and task.exception() is None and task.result()) + + async def _remove(self, key: str) -> bool: + core = self.cores.get(key) + try: + if core is not None: + removed = await core.remove_profile() + core.publish_activity(force=True) # stopped, for good + else: + removed = await asyncio.to_thread( + delete_profile, config.profile_dir(key) + ) + return removed + finally: + if self.cores.get(key) is core: + self.cores.pop(key, None) + self._removals.pop(key, None) + + def start_background(self) -> None: + """Start the fleet's housekeeping (the orphaned-profile sweep).""" + self._spawn(self._sweep_later()) + + async def _sweep_later(self) -> None: + await asyncio.sleep(SWEEP_DELAY_S) + await self.sweep_orphaned_profiles() + + async def sweep_orphaned_profiles(self) -> List[str]: + """Delete the profiles of sessions that no longer exist; their names. + + Only when the agent runtime and its session storage both answer: a + profile is kept whenever its session might still exist (live, + stored, or with a browser in this fleet), and a profile in use is + never touched. + """ + live = config.live_session_ids() + if live is None: + return [] + stored = await asyncio.to_thread(config.persisted_session_ids) + if stored is None: + return [] + keep: Set[str] = set(live) | set(stored) | set(self.cores) | set(self._removals) + keep.add(config.MAIN_SESSION_KEY) + names = frozenset(config.profile_dir_name(key) for key in keep) + return await asyncio.to_thread(_sweep_profiles, config.profiles_root(), names) + + # ── every browser ──────────────────────────────────────────────────────── + + async def close_all(self) -> None: + """Close every session's browser (app exit). Never raises.""" + cores = list(self.cores.values()) + if not cores: + return + results = await asyncio.gather( + *(core.close() for core in cores), return_exceptions=True + ) + for core, result in zip(cores, results): + if isinstance(result, BaseException) and not isinstance( + result, asyncio.CancelledError + ): + logger.debug( + f"[MiniBrowser] Close of {core.key} failed: {type(result).__name__}" + ) + + async def reload_settings_now(self) -> None: + """settings.json changed: every browser re-reads the ``mini_browser`` + section (live settings apply now, launch settings at the next start).""" + await asyncio.gather( + *(core.reload_settings_now() for core in list(self.cores.values())) + ) + + async def refresh_network_rules(self) -> None: + """The UI origins changed: every running browser re-applies its blocking.""" + await asyncio.gather( + *(core.refresh_network_rules() for core in list(self.cores.values())) + ) + + async def set_adblock(self, enabled: bool) -> None: + """Turn ad blocking on/off in every browser and remember it (saved once).""" + enabled = bool(enabled) + try: + await asyncio.to_thread(config.save_setting, "adblock", enabled) + except Exception as exc: + logger.warning( + f"[MiniBrowser] Could not save the ad-block setting: {type(exc).__name__}" + ) + await asyncio.gather( + *(core.apply_adblock(enabled) for core in list(self.cores.values())) + ) + + # ── helpers ────────────────────────────────────────────────────────────── + + def _spawn(self, coro: Any) -> asyncio.Task: + task = asyncio.get_running_loop().create_task(coro) + self._tasks.add(task) + task.add_done_callback(self._task_done) + return task + + def _task_done(self, task: asyncio.Task) -> None: + self._tasks.discard(task) + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + logger.warning( + f"[MiniBrowser] Browser housekeeping failed: {type(exc).__name__}" + ) + + +def _sweep_profiles(root: Path, keep: FrozenSet[str]) -> List[str]: + """Delete every profile folder in ``root`` not named in ``keep``. Blocking I/O.""" + removed: List[str] = [] + try: + entries = list(Path(root).iterdir()) + except FileNotFoundError: + return removed + except OSError as exc: + logger.debug(f"[MiniBrowser] Profiles not listed: {type(exc).__name__}") + return removed + for entry in entries: + try: + if not entry.is_dir() or entry.name in keep: + continue + except OSError: + continue + if delete_profile(entry): + removed.append(entry.name) + if removed: + logger.info( + f"[MiniBrowser] Deleted {len(removed)} browser profile(s) of deleted sessions" + ) + return removed diff --git a/app/mini_browser/host.py b/app/mini_browser/host.py index e1217d98e..4be0b142e 100644 --- a/app/mini_browser/host.py +++ b/app/mini_browser/host.py @@ -3,15 +3,16 @@ Playwright objects belong to the event loop that created them, but Mini Browser callers live on several loops: the main agent/UI loop, and each sub-agent's private loop (``spawn_subagent`` runs ``asyncio.run`` in a worker -thread). So the browser gets one home: a daemon thread named ``mini-browser`` +thread). So the browsers get one home: a daemon thread named ``mini-browser`` running its own loop. Every Playwright object, lock and background task of -the browser lives there, and callers marshal into it with :meth:`call` -(request/response, caller cancellation propagates) or :meth:`submit` -(fire-and-forget). +every session's browser lives there, and callers marshal into it with +:meth:`call` (one session's browser) or :meth:`call_fleet` (all of them), +both request/response with caller cancellation propagating, or +:meth:`submit` (fire-and-forget). Starting the host only starts the thread and builds the -:class:`~app.mini_browser.core.BrowserCore`; Chromium itself starts on first -real use. +:class:`~app.mini_browser.fleet.BrowserFleet`; a session's Chromium starts +on first real use. :meth:`MiniBrowserHost.shutdown` is terminal: from the moment it begins, every call fails fast with MINI_BROWSER_NOT_RUNNING (never hangs on a @@ -24,6 +25,7 @@ import asyncio import concurrent.futures import contextvars +import functools import sys import threading import time @@ -33,6 +35,7 @@ if TYPE_CHECKING: from app.mini_browser.core import BrowserCore + from app.mini_browser.fleet import BrowserFleet T = TypeVar("T") @@ -42,10 +45,10 @@ _CANCEL_ROUNDS = 5 -def _default_core_factory() -> "BrowserCore": - from app.mini_browser.core import BrowserCore +def _default_fleet_factory() -> "BrowserFleet": + from app.mini_browser.fleet import BrowserFleet - return BrowserCore() + return BrowserFleet() def _not_running() -> Exception: @@ -55,16 +58,16 @@ def _not_running() -> Exception: class MiniBrowserHost: - """A daemon thread + event loop that owns one :class:`BrowserCore`.""" + """A daemon thread + event loop that owns the :class:`BrowserFleet`.""" def __init__( - self, core_factory: Optional[Callable[[], "BrowserCore"]] = None + self, fleet_factory: Optional[Callable[[], "BrowserFleet"]] = None ) -> None: - self._factory = core_factory or _default_core_factory + self._factory = fleet_factory or _default_fleet_factory self._lock = threading.Lock() self._loop: Optional[asyncio.AbstractEventLoop] = None self._thread: Optional[threading.Thread] = None - self._core: Optional["BrowserCore"] = None + self._fleet: Optional["BrowserFleet"] = None # Set when shutdown() begins; cleared only by reopen(). self._closed = False # Strong references to fire-and-forget tasks (host loop only). @@ -76,11 +79,11 @@ def __init__( # ── state ──────────────────────────────────────────────────────────────── @property - def core(self) -> "BrowserCore": - core = self._core - if core is None: + def fleet(self) -> "BrowserFleet": + fleet = self._fleet + if fleet is None: raise RuntimeError("The Mini Browser host is not running") - return core + return fleet @property def loop(self) -> Optional[asyncio.AbstractEventLoop]: @@ -104,7 +107,7 @@ def is_running(self) -> bool: # ── lifecycle ──────────────────────────────────────────────────────────── def start(self) -> None: - """Start the thread, loop and core (not Chromium). Idempotent, thread-safe. + """Start the thread, loop and fleet (not Chromium). Idempotent, thread-safe. Raises MINI_BROWSER_NOT_RUNNING once the host was shut down. """ @@ -129,7 +132,7 @@ def start(self) -> None: raise RuntimeError( f"The Mini Browser host failed to start: {type(error).__name__}" ) from error - self._loop, self._thread, self._core = loop, thread, box["core"] + self._loop, self._thread, self._fleet = loop, thread, box["fleet"] self._submitted = set() logger.debug("[MiniBrowser] Host thread started") @@ -145,7 +148,7 @@ def reopen(self, timeout: float = 10.0) -> None: with self._lock: if self._thread is not None and self._thread.is_alive(): raise RuntimeError("The Mini Browser host is still stopping") - self._loop = self._thread = self._core = None + self._loop = self._thread = self._fleet = None self._closed = False def _run( @@ -153,13 +156,16 @@ def _run( ) -> None: asyncio.set_event_loop(loop) try: - box["core"] = self._factory() + fleet = box["fleet"] = self._factory() except BaseException as exc: # reported to start() box["error"] = exc ready.set() asyncio.set_event_loop(None) loop.close() return + start_background = getattr(fleet, "start_background", None) + if callable(start_background): + loop.call_soon(start_background) ready.set() try: loop.run_forever() @@ -203,8 +209,17 @@ def _fail_pending(self) -> None: except Exception: pass # completed meanwhile - async def call(self, fn: Callable[["BrowserCore"], Awaitable[T]]) -> T: - """Run ``fn(core)`` on the host loop and return its result. + async def call(self, key: str, fn: Callable[["BrowserCore"], Awaitable[T]]) -> T: + """Run ``fn(core)`` on session ``key``'s browser and return its result. + + The session's browser is created (not started) on first use; a + session that no longer exists raises MINI_BROWSER_SESSION_GONE. Works + from any loop or thread, like :meth:`call_fleet`. + """ + return await self.call_fleet(functools.partial(_on_core, key, fn)) + + async def call_fleet(self, fn: Callable[["BrowserFleet"], Awaitable[T]]) -> T: + """Run ``fn(fleet)`` on the host loop and return its result. Works from any loop or thread. Cancelling the caller cancels the work on the host loop. Once the host is shutting down (or shut down) it @@ -213,17 +228,17 @@ async def call(self, fn: Callable[["BrowserCore"], Awaitable[T]]) -> T: the caller did not ask for. """ self.start() - loop, core = self._loop, self._core - if loop is None or core is None or self._closed: + loop, fleet = self._loop, self._fleet + if loop is None or fleet is None or self._closed: raise _not_running() try: running = asyncio.get_running_loop() except RuntimeError: running = None if running is loop: - return await fn(core) + return await fn(fleet) try: - future = asyncio.run_coroutine_threadsafe(_invoke(fn, core), loop) + future = asyncio.run_coroutine_threadsafe(_invoke(fn, fleet), loop) except RuntimeError: raise _not_running() from None self._track(future) @@ -245,9 +260,9 @@ async def call(self, fn: Callable[["BrowserCore"], Awaitable[T]]) -> T: return wrapped.result() def submit( - self, fn: Callable[["BrowserCore"], Awaitable[Any]], *, start: bool = True + self, fn: Callable[["BrowserFleet"], Awaitable[Any]], *, start: bool = True ) -> concurrent.futures.Future: - """Fire-and-forget ``fn(core)`` on the host loop. Thread-safe. + """Fire-and-forget ``fn(fleet)`` on the host loop. Thread-safe. ``fn`` must return an awaitable. Runs in a clean context (no caller context variables). Failures are logged and set on the returned @@ -262,15 +277,15 @@ def submit( except Exception as exc: result.set_exception(exc) return result - loop, core = self._loop, self._core - if loop is None or core is None or not self.is_running(): + loop, fleet = self._loop, self._fleet + if loop is None or fleet is None or not self.is_running(): result.set_exception(_not_running()) return result def schedule() -> None: if not result.set_running_or_notify_cancel(): return - task = contextvars.Context().run(loop.create_task, _logged(fn, core)) + task = contextvars.Context().run(loop.create_task, _logged(fn, fleet)) self._submitted.add(task) task.add_done_callback(lambda t: self._finish_submitted(t, result)) @@ -299,22 +314,22 @@ def _finish_submitted( result.set_result(task.result()) async def shutdown(self, timeout: float = 10.0) -> None: - """Close Chromium, stop the loop and join the thread. Never raises. + """Close every browser, stop the loop and join the thread. Never raises. Terminal: calls made from now on fail with MINI_BROWSER_NOT_RUNNING until :meth:`reopen`. Calling it again is a no-op. """ with self._lock: self._closed = True - loop, thread, core = self._loop, self._thread, self._core + loop, thread, fleet = self._loop, self._thread, self._fleet if loop is None or thread is None: return deadline = time.monotonic() + max(1.0, float(timeout)) if thread is threading.current_thread(): # Called from the host loop itself: it cannot join its own thread. try: - if core is not None: - await core.close() + if fleet is not None: + await fleet.close_all() except Exception as exc: logger.warning( f"[MiniBrowser] Browser close failed: {type(exc).__name__}" @@ -322,9 +337,9 @@ async def shutdown(self, timeout: float = 10.0) -> None: finally: loop.stop() return - if thread.is_alive() and not loop.is_closed() and core is not None: + if thread.is_alive() and not loop.is_closed() and fleet is not None: try: - future = asyncio.run_coroutine_threadsafe(core.close(), loop) + future = asyncio.run_coroutine_threadsafe(fleet.close_all(), loop) await asyncio.wait_for( asyncio.wrap_future(future), max(0.5, deadline - time.monotonic() - 1.0), @@ -346,7 +361,7 @@ async def shutdown(self, timeout: float = 10.0) -> None: self._fail_pending() with self._lock: if self._thread is thread and not thread.is_alive(): - self._loop = self._thread = self._core = None + self._loop = self._thread = self._fleet = None logger.debug("[MiniBrowser] Host stopped") @@ -362,16 +377,22 @@ def _new_loop() -> asyncio.AbstractEventLoop: async def _invoke( - fn: Callable[["BrowserCore"], Awaitable[T]], core: "BrowserCore" + fn: Callable[["BrowserFleet"], Awaitable[T]], fleet: "BrowserFleet" +) -> T: + return await fn(fleet) + + +async def _on_core( + key: str, fn: Callable[["BrowserCore"], Awaitable[T]], fleet: "BrowserFleet" ) -> T: - return await fn(core) + return await fn(fleet.core(key)) async def _logged( - fn: Callable[["BrowserCore"], Awaitable[Any]], core: "BrowserCore" + fn: Callable[["BrowserFleet"], Awaitable[Any]], fleet: "BrowserFleet" ) -> Any: try: - return await fn(core) + return await fn(fleet) except asyncio.CancelledError: raise except Exception as exc: @@ -407,7 +428,7 @@ def _cancel_pending(loop: asyncio.AbstractEventLoop) -> bool: def get_host() -> MiniBrowserHost: - """The process-wide host, started (thread + core; Chromium starts lazily). + """The process-wide host, started (thread + fleet; Chromium starts lazily). Raises MINI_BROWSER_NOT_RUNNING after :func:`shutdown_process_host` (app exit) until :func:`restart_process_host`. diff --git a/app/mini_browser/lifecycle.py b/app/mini_browser/lifecycle.py index 15735bb17..fb496362a 100644 --- a/app/mini_browser/lifecycle.py +++ b/app/mini_browser/lifecycle.py @@ -1,10 +1,12 @@ """Hooks the agent runtime calls into the Mini Browser. -Called by ``AgentBase`` (run state changes, Stop, deleted chats, finished +Called by ``AgentBase`` (run state changes, Stop, deleted sessions, finished sub-agents, settings changes, shutdown), possibly from other threads (a sub-agent finishing on its worker thread). Every hook is thread-safe, returns -immediately, never starts the browser and never raises: when the Mini -Browser was never used there is nothing to do. +immediately, never starts a browser and never raises: when a session's +browser was never used there is nothing to do. Every session has a browser +of its own (``fleet.py``); a hook about a sub-agent goes to its parent +session's browser. What the hooks report is recorded HERE, process-wide, before it is handed to a running browser: the run state of every session and the moment each Stop @@ -21,6 +23,7 @@ from typing import Any, Awaitable, Callable, Dict, Optional from app.logger import logger +from app.mini_browser import config RUN_STATES = frozenset({"running", "stopping", "idle"}) BUSY_STATES = frozenset({"running", "stopping"}) @@ -40,6 +43,9 @@ _run_states: Dict[str, str] = {} _parent_stops: Dict[str, datetime] = {} _unavailable: Optional[str] = None +# A browser started in this process: a later launch failure is that +# session's own trouble (a damaged profile), not "no browser here". +_ever_launched = False # ───────────────────────────────────────────────────────────────────────────── @@ -52,22 +58,56 @@ def on_run_state(session_id: str, state: str) -> None: if not record_run_state(session_id, state): return - async def apply(core: Any) -> None: - core.run_state_changed(session_id) + async def apply(fleet: Any) -> None: + core = fleet.existing(config.browser_key(session_id)) + if core is not None: + core.run_state_changed(session_id) _submit(session_id, apply, "run state") -def release_owner(session_id: str) -> None: - """A chat was deleted or a sub-agent finished: close its tabs.""" - if not _valid(session_id): +def stream_removed(session_id: str) -> None: + """A session's event stream was removed: either a sub-agent finished + (its tabs close) or a session was deleted (its browser and profile go).""" + if config.is_subagent_id(session_id): + release_owner(session_id) + else: + session_deleted(session_id) + + +def release_owner(owner: str) -> None: + """A sub-agent finished: close its tabs in its parent session's browser.""" + if not _valid(owner): + return + forget_owner(owner) + + async def apply(fleet: Any) -> None: + core = fleet.existing(config.browser_key(owner)) + if core is not None: + core.release_owner(owner, close_tabs=True) + + _submit(owner, apply, "release") + + +def session_deleted(session_id: str) -> None: + """A session was deleted: close its browser for good and delete its + profile (its cookies and signed-in sites). When no browser ran in this + process, the profile a previous run left is deleted right away.""" + if not _valid(session_id) or session_id == config.MAIN_SESSION_KEY: return forget_owner(session_id) - async def apply(core: Any) -> None: - core.release_owner(session_id, close_tabs=True) + async def apply(fleet: Any) -> None: + await fleet.remove(session_id) - _submit(session_id, apply, "release") + if _submit(session_id, apply, "session deletion"): + return + threading.Thread( + target=_delete_profile_now, + args=(session_id,), + name="mini-browser-profile-delete", + daemon=True, + ).start() def cancel_owner(session_id: str, include_children: bool = True) -> None: @@ -82,8 +122,10 @@ def cancel_owner(session_id: str, include_children: bool = True) -> None: if include_children: record_parent_stop(session_id) - async def apply(core: Any) -> None: - await core.cancel_owner_ops(session_id, include_children=include_children) + async def apply(fleet: Any) -> None: + core = fleet.existing(config.browser_key(session_id)) + if core is not None: + await core.cancel_owner_ops(session_id, include_children=include_children) _submit(session_id, apply, "cancel") @@ -94,30 +136,31 @@ def reload_settings() -> None: Live settings (ad blocking, frame rate and quality, human-like input, per-agent tab limit, idle shutdown, search page, file URLs) apply at once; launch settings (headless, channel, locale, sound) at the next start. - A no-op when the browser thread never started: the browser reads the - settings when it starts. + A no-op when the browser thread never started: the browsers read the + settings when they start. """ - async def apply(core: Any) -> None: - await core.reload_settings_now() + async def apply(fleet: Any) -> None: + await fleet.reload_settings_now() _submit("settings", apply, "settings reload") def unavailable_reason() -> Optional[str]: - """Why the Mini Browser cannot run here, or None. + """Why the Mini Browser cannot run on this machine, or None. - ``MINI_BROWSER_CHROMIUM_MISSING`` / ``MINI_BROWSER_PLAYWRIGHT_MISSING`` / - ``MINI_BROWSER_LAUNCH_FAILED`` after the latest launch attempt failed - that way; None after a successful launch (or before any launch). Never - starts anything. + ``MINI_BROWSER_CHROMIUM_MISSING`` / ``MINI_BROWSER_PLAYWRIGHT_MISSING`` + after the latest launch attempt failed that way, and + ``MINI_BROWSER_LAUNCH_FAILED`` when no browser has started in this + process yet; None after a successful launch (or before any launch). + Never starts anything. """ with _lock: return _unavailable async def shutdown(timeout: float = 15.0) -> None: - """App exit: close Chromium (flushing cookies and logins) and stop the thread. + """App exit: close every browser (flushing cookies and logins) and stop the thread. The process-wide browser host stays closed afterwards: later calls get MINI_BROWSER_NOT_RUNNING instead of relaunching Chromium during exit @@ -192,27 +235,35 @@ def forget_owner(session_id: Any) -> None: def note_launch_result(code: Optional[str]) -> None: - """The browser engine reports each launch: None = it started. - - Only the failures in ``UNAVAILABLE_CODES`` mark the browser unavailable; - other failures (a profile in use, a close during the launch) leave the - previous verdict alone. + """A session's browser reports each launch: None = it started. + + Only the failures in ``UNAVAILABLE_CODES`` mark the browser unavailable: + a missing Chromium or Playwright always, a failed launch only while no + browser has started in this process (once one has, the machine can run + them and a failure is that session's own, e.g. a damaged profile). Other + failures (a profile in use, a close during the launch, too many + browsers) leave the previous verdict alone. """ - global _unavailable + global _unavailable, _ever_launched with _lock: if code is None: _unavailable = None + _ever_launched = True + elif code == "MINI_BROWSER_LAUNCH_FAILED": + if not _ever_launched: + _unavailable = code elif code in UNAVAILABLE_CODES: _unavailable = code def _reset_for_tests() -> None: """Forget every record (tests only).""" - global _unavailable + global _unavailable, _ever_launched with _lock: _run_states.clear() _parent_stops.clear() _unavailable = None + _ever_launched = False # ───────────────────────────────────────────────────────────────────────────── @@ -224,17 +275,34 @@ def _valid(session_id: Any) -> bool: return isinstance(session_id, str) and bool(session_id) -def _submit(what_for: Any, fn: Callable[[Any], Awaitable[None]], what: str) -> None: - """Hand ``fn`` to a running browser thread; nothing happens otherwise.""" +def _submit(what_for: Any, fn: Callable[[Any], Awaitable[None]], what: str) -> bool: + """Hand ``fn(fleet)`` to a running browser thread; nothing happens + otherwise. Returns whether it was handed over.""" if not _valid(what_for): - return + return False try: from app.mini_browser.host import get_host_if_started host = get_host_if_started() if host is not None: host.submit(fn, start=False) + return True except Exception as exc: logger.debug( f"[MiniBrowser] Lifecycle {what} hook failed: {type(exc).__name__}" ) + return False + + +def _delete_profile_now(session_id: str) -> None: + """Delete a deleted session's profile while no browser thread runs + (blocking: runs on a daemon thread). Never raises.""" + try: + from app.mini_browser.core import delete_profile + + delete_profile(config.profile_dir(session_id)) + except Exception as exc: + logger.warning( + f"[MiniBrowser] Could not delete the browser profile of a deleted " + f"session: {type(exc).__name__}" + ) diff --git a/app/mini_browser/login.py b/app/mini_browser/login.py index a74c0221c..9004f0fe1 100644 --- a/app/mini_browser/login.py +++ b/app/mini_browser/login.py @@ -941,14 +941,14 @@ def _outcome_message(outcome: str, kind: str, who: str, title: str) -> str: return ( f"Submitted the saved login for {who}, but the site shows a CAPTCHA. " "Never try to solve it yourself: tell the user (send_message) to solve " - "it in the Mini Browser page and press Hand back, " + "it in this chat's browser panel and press Hand back, " f"{_WAIT_FOR_USER}." ) if outcome == "needs_verification" and kind == "code": return ( f"Submitted the saved login for {who}; the site now asks for a " "verification code. Ask the user for it (never guess it) and type it " - "in, or tell them to enter it in the Mini Browser page and press Hand " + "in, or tell them to enter it in this chat's browser panel and press Hand " f"back, {_WAIT_FOR_USER}." ) if outcome == "needs_verification": @@ -956,7 +956,7 @@ def _outcome_message(outcome: str, kind: str, who: str, title: str) -> str: f"Submitted the saved login for {who}; the site now wants the user to " f"confirm the sign-in (the page shows{shows}; for example approving a " "prompt on their phone). Ask the user to do that. If they have to act " - "in the Mini Browser page, tell them to press Hand back when done, " + "in this chat's browser panel, tell them to press Hand back when done, " f"{_WAIT_FOR_USER}; otherwise check again with mini_browser_wait " "(text=...) or mini_browser_read." ) diff --git a/app/mini_browser/ops.py b/app/mini_browser/ops.py index 3c476660e..dd4fb3035 100644 --- a/app/mini_browser/ops.py +++ b/app/mini_browser/ops.py @@ -2474,7 +2474,7 @@ async def _wait_for_user(core: Any, tab: Any, timeout_s: float) -> Dict[str, Any result["message"] = ( f"The user did not take control of this tab within {round(seconds)} s. " "Ask them in chat what to do, as your final message (for example to " - "solve the check in the Mini Browser page and press Hand back), and " + "solve the check in this chat's browser panel and press Hand back), and " "continue from a fresh mini_browser_read when they reply." ) result["page"] = await page_observation(core, tab) diff --git a/app/mini_browser/resume.py b/app/mini_browser/resume.py new file mode 100644 index 000000000..e10fa15b5 --- /dev/null +++ b/app/mini_browser/resume.py @@ -0,0 +1,197 @@ +"""Where a chat's browser left off: the pages it had open and the last picture +of the page on screen, kept in the session's profile folder. + +The browser reopens those pages when it starts again (``BrowserCore``), and +the chat's browser panel shows the picture, dimmed, while the browser is +starting or off (``ws.py``), so the panel is never blank. Both files are +replaced atomically: a reader sees the old file or the new one, never half +of one. Anything missing, unreadable or malformed counts as nothing saved. +The files go with the profile when the session is deleted. + +Blocking file I/O: call these from a worker thread (``asyncio.to_thread``). +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Optional, Tuple + +from app.logger import logger + +PAGES_FILE = "craftbot_pages.json" +LAST_FRAME_FILE = "craftbot_last_frame.json" + +# Pages reopened at most (the one on screen is always among them). +MAX_PAGES = 10 +MAX_URL_CHARS = 2048 +MAX_TITLE_CHARS = 300 +# A last frame larger than this (base64 JPEG) is not kept. +MAX_IMAGE_CHARS = 4_000_000 +_MAX_FILE_BYTES = MAX_IMAGE_CHARS + 64_000 + + +@dataclass(frozen=True) +class Page: + url: str + title: str + + +@dataclass(frozen=True) +class Pages: + """The pages a browser had open, in tab order, and the one on screen.""" + + pages: Tuple[Page, ...] + viewed: int = 0 # index into ``pages`` (0 when there are none) + + @property + def viewed_page(self) -> Optional[Page]: + return self.pages[self.viewed] if self.pages else None + + +@dataclass(frozen=True) +class LastFrame: + """The last picture of the page on screen (a base64 JPEG) and its page.""" + + url: str + title: str + width: int + height: int + image: str + + +def save_pages(profile: Path, pages: Pages) -> None: + """Remember ``pages`` for ``profile``'s browser. Raises OSError.""" + _write_json( + Path(profile) / PAGES_FILE, + { + "pages": [{"url": page.url, "title": page.title} for page in pages.pages], + "viewed": pages.viewed, + }, + ) + + +def load_pages(profile: Path) -> Optional[Pages]: + """The pages ``profile``'s browser had open, or None when none are saved.""" + data = _read_json(Path(profile) / PAGES_FILE) + if data is None: + return None + raw_pages = data.get("pages") + viewed = data.get("viewed") + if not isinstance(raw_pages, list) or not isinstance(viewed, int): + return None + pages = [] + for item in raw_pages[:MAX_PAGES]: + if not isinstance(item, dict): + return None + url, title = item.get("url"), item.get("title") + if not _text(url, MAX_URL_CHARS) or not isinstance(title, str): + return None + pages.append(Page(url=url, title=title[:MAX_TITLE_CHARS])) + if pages and not 0 <= viewed < len(pages): + viewed = 0 + return Pages(pages=tuple(pages), viewed=viewed if pages else 0) + + +def save_last_frame(profile: Path, frame: LastFrame) -> None: + """Remember ``frame`` for ``profile``'s browser. Raises OSError.""" + _write_json( + Path(profile) / LAST_FRAME_FILE, + { + "url": frame.url, + "title": frame.title, + "width": frame.width, + "height": frame.height, + "image": frame.image, + }, + ) + + +def load_last_frame(profile: Path) -> Optional[LastFrame]: + """The last picture saved for ``profile``'s browser, or None.""" + data = _read_json(Path(profile) / LAST_FRAME_FILE) + if data is None: + return None + url, title, image = data.get("url"), data.get("title"), data.get("image") + width, height = data.get("width"), data.get("height") + if ( + not _text(url, MAX_URL_CHARS) + or not isinstance(title, str) + or not _text(image, MAX_IMAGE_CHARS) + or not isinstance(width, int) + or not isinstance(height, int) + or width <= 0 + or height <= 0 + ): + return None + return LastFrame( + url=url, + title=title[:MAX_TITLE_CHARS], + width=width, + height=height, + image=image, + ) + + +def resume_frame(profile: Path) -> Optional[LastFrame]: + """The picture to show while ``profile``'s browser starts or is off: the + last frame, only while it shows the page the browser reopens on screen + (an agent may have browsed on since, with nobody watching).""" + pages = load_pages(profile) + viewed = pages.viewed_page if pages is not None else None + if viewed is None: + return None + frame = load_last_frame(profile) + if frame is None or frame.url != viewed.url: + return None + return frame + + +def ui_payload(key: str, frame: Optional[LastFrame]) -> Dict[str, Any]: + """The ``mini_browser_last_frame`` message of session ``key``'s panel + (``image`` None: there is no picture to show).""" + if frame is None: + return {"sessionId": key, "image": None} + return { + "sessionId": key, + "image": "data:image/jpeg;base64," + frame.image, + "width": frame.width, + "height": frame.height, + "url": frame.url, + "title": frame.title, + } + + +def _text(value: Any, limit: int) -> bool: + return isinstance(value, str) and 0 < len(value) <= limit + + +def _read_json(path: Path) -> Optional[Dict[str, Any]]: + try: + if path.stat().st_size > _MAX_FILE_BYTES: + return None + with open(path, "r", encoding="utf-8") as handle: + data = json.load(handle) + except FileNotFoundError: + return None + except (OSError, ValueError) as exc: + logger.debug(f"[MiniBrowser] {path.name} unreadable: {type(exc).__name__}") + return None + return data if isinstance(data, dict) else None + + +def _write_json(path: Path, data: Dict[str, Any]) -> None: + """Replace ``path`` atomically (a temporary file next to it, then a rename).""" + temporary = path.with_name(f"{path.name}.tmp") + try: + with open(temporary, "w", encoding="utf-8") as handle: + json.dump(data, handle, ensure_ascii=False, separators=(",", ":")) + os.replace(temporary, path) + except BaseException: + try: + temporary.unlink() + except OSError: + pass + raise diff --git a/app/mini_browser/stream.py b/app/mini_browser/stream.py index 96e0a6734..a302f1c89 100644 --- a/app/mini_browser/stream.py +++ b/app/mini_browser/stream.py @@ -1,4 +1,5 @@ -"""Live view: frames of the viewed tab, only while someone is watching. +"""Live view: frames of a session browser's viewed tab, only while someone +watches that session's browser panel. Uses CDP ``Page.startScreencast`` on the viewed tab's CDP session: Chromium pushes a JPEG whenever the page visibly changes (none while it is idle) and @@ -9,7 +10,8 @@ skipped). The last frame of each tab is kept so a new viewer gets a picture right away. -Everything here runs on the Mini Browser host loop. +Everything here runs on the Mini Browser host loop. Frames name their +session (``sessionId``) and go only to that session's viewers. """ from __future__ import annotations @@ -37,7 +39,7 @@ class FrameStreamer: - """Streams the viewed tab of one :class:`BrowserCore` to the UI.""" + """Streams the viewed tab of one session's :class:`BrowserCore` to the UI.""" def __init__(self, core: "BrowserCore") -> None: self._core = core @@ -104,7 +106,7 @@ async def push_now(self) -> None: """Send the viewed tab's latest frame right away (a viewer just arrived).""" core = self._core tab_id = core.viewed_tab_id - if not tab_id or not bridge.has_viewers(): + if not tab_id or not core.has_viewers(): return frame = self._last_frames.get(tab_id) if frame is not None: @@ -129,7 +131,7 @@ async def push_now(self) -> None: def _wanted_tab(self) -> Optional["Tab"]: core = self._core - if core.status != "ready" or not bridge.has_viewers(): + if core.status != "ready" or not core.has_viewers(): return None tab = core.tabs.get(core.viewed_tab_id) if core.viewed_tab_id else None if tab is None or tab.crashed: @@ -238,7 +240,7 @@ async def _poll(self, tab_id: str, page: Any) -> None: last_digest: Optional[bytes] = None changed_at = time.monotonic() while self._mode == "poll" and self._tab_id == tab_id: - if not bridge.has_viewers(): + if not self._core.has_viewers(): self._core.spawn(self.sync()) return try: @@ -283,7 +285,7 @@ def _flush(self) -> None: pending, self._pending = self._pending, None if pending is None: return - if not bridge.has_viewers(): + if not self._core.has_viewers(): self._core.spawn(self.sync()) # nobody watches: stop capturing return tab_id, data, width, height = pending @@ -291,6 +293,7 @@ def _flush(self) -> None: def _frame(self, tab_id: str, data: str, width: int, height: int) -> Dict[str, Any]: return { + "sessionId": self._core.key, "tabId": tab_id, "image": "data:image/jpeg;base64," + data, "width": width, @@ -303,6 +306,9 @@ def _publish(self, frame: Dict[str, Any]) -> None: self._last_frames[frame["tabId"]] = frame self._last_publish = time.monotonic() bridge.publish("frame", dict(frame)) + self._core.note_frame( + frame["tabId"], frame["image"], frame["width"], frame["height"] + ) def _cancel_flush(self) -> None: if self._flush_handle is not None: diff --git a/app/mini_browser/types.py b/app/mini_browser/types.py index b1c75e3f1..febccc353 100644 --- a/app/mini_browser/types.py +++ b/app/mini_browser/types.py @@ -11,11 +11,12 @@ from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Set, Tuple -# Who owns a tab. ``None`` owner == a tab the user opened themselves. +# Who owns a tab. ``None`` owner == a tab the user opened themselves. Every +# session has a browser of its own, so a tab's owner is that session's agent +# or one of its sub-agents. OWNER_KIND_USER = "user" OWNER_KIND_MAIN = "main" # the main session (also scheduled/proactive runs) OWNER_KIND_SESSION = "session" # a chat / Agent App session -OWNER_KIND_MINI_BROWSER = "mini_browser" # the dedicated Mini Browser chat OWNER_KIND_SUBAGENT = "subagent" # Agent-facing notice kinds collected on a tab and attached to the owner's @@ -78,6 +79,11 @@ class Tab: # Per-page CDP session (network blocking, screencast, stop-loading). cdp: Any = None + # The site icon as a data: URL (None: none known), and the page address + # it was looked up for. + favicon: Optional[str] = None + favicon_page: str = "" + # In-flight agent operation tasks (so Stop can cancel them). ops: Set[asyncio.Task] = field(default_factory=set) diff --git a/app/mini_browser/vault.py b/app/mini_browser/vault.py index 62c42b61f..d5983d51f 100644 --- a/app/mini_browser/vault.py +++ b/app/mini_browser/vault.py @@ -1,7 +1,8 @@ """Encrypted password vault for the Mini Browser. -The user saves website logins in the Mini Browser's Passwords panel and agents -sign in with them through ``login.autofill``. Passwords never travel back to +The user saves website logins under Passwords in a chat's browser panel (one +vault, shared by every session's browser) and agents sign in with them +through ``login.autofill``. Passwords never travel back to the UI or to the model: :meth:`CredentialVault.list_entries` and :meth:`CredentialVault.status` carry no passwords. The only way out is :meth:`CredentialVault.candidates_for_url`, which the autofill code uses to diff --git a/app/mini_browser/ws.py b/app/mini_browser/ws.py index 0e94bef6f..9d0767732 100644 --- a/app/mini_browser/ws.py +++ b/app/mini_browser/ws.py @@ -1,43 +1,49 @@ # -*- coding: utf-8 -*- -"""WebSocket side of the Mini Browser page, and the browser's UI sink. +"""WebSocket side of the chat browser panels, and the browsers' UI sink. -``MiniBrowserWS`` lives in the browser UI adapter, on the UI event loop, and -has three jobs. +Every agent session has a browser of its own (``fleet.py``), shown in that +chat's browser panel. ``MiniBrowserWS`` lives in the browser UI adapter, on +the UI event loop, and has three jobs. **Requests.** ``handle`` takes every ``mini_browser_*`` message from a -browser tab, validates and clamps it, and turns it into a call on the browser -host (``app.mini_browser.host``; the browser itself runs on its own thread) -or on the password vault. A handler never raises: an unhandled error would -be broadcast into every tab's main chat, so each failure is answered to the -requesting socket only, as ``{code, title, message}``. Page loads, history -moves, new tabs and explicit starts run as background tasks, so a slow site -never blocks the socket's message lane. Live-view input is queued per socket -and applied in order by one drain task; while the page lags, mouse moves -coalesce (latest wins) and wheel steps add up, so a hung page never builds a -backlog in front of the user's keystrokes, and closing the browser or taking -and handing back control (their own lanes) never wait behind it. Replies -(``{type, data}``): - -- ``mini_browser_navigate`` -> ``mini_browser_nav_result {ok, error?}`` -- ``mini_browser_copy`` -> ``mini_browser_clipboard {text}`` +browser tab, validates and clamps it, and turns it into a call on the +browser host (``app.mini_browser.host``; the browsers run on their own +thread) or on the password vault. Requests about a session's browser name +the session (``sessionId``); tab ids are unique across every session's +browser. A handler never raises: an unhandled error would be broadcast into +every tab's main chat, so each failure is answered to the requesting socket +only, as ``{code, title, message}``. Page loads, history moves, new tabs and +starts run as background tasks, so a slow site never blocks the socket's +message lane. Live-view input is queued per socket and applied in order by +one drain task; while the page lags, mouse moves coalesce (latest wins) and +wheel steps add up, so a hung page never builds a backlog in front of the +user's keystrokes, and closing a browser or taking and handing back control +(their own lanes) never wait behind it. Replies (``{type, data}``): + +- ``mini_browser_navigate`` -> ``mini_browser_nav_result {sessionId, ok, error?}`` +- ``mini_browser_copy`` -> ``mini_browser_clipboard {text, requestId?}`` +- ``mini_browser_activity_query`` -> ``mini_browser_activity {sessions, snapshot}`` - ``mini_browser_vault_list`` -> ``mini_browser_vault_list {entries, status, error?}`` - other vault requests -> ``mini_browser_vault_result {op, ok, error?}`` -- subscribe, the ad-block query -> ``mini_browser_state`` +- subscribe, start, shutdown, the ad-block switch -> ``mini_browser_state`` - any other failure -> ``mini_browser_event {kind: "error", level, code, title, - message}`` + message, sessionId?}`` -**Viewers.** Only sockets that subscribed (the Mini Browser page is open) -receive browser state, frames, agent cursor moves, events and install -progress, and the browser streams its screen only while one exists. Passive -messages (subscribe, unsubscribe, the ad-block query, the vault) never start -Chromium; while the browser host has never started, its state is synthesized -here. +**Viewers.** A socket subscribes to the session whose browser panel it +shows; only that session's viewers receive its browser's state, frames, +agent cursor moves and events, and a browser streams its screen only while +it has a viewer. Opening the panel (subscribe) starts that session's +browser. Every client receives ``mini_browser_activity`` (which session +browsers run, and whether an agent is browsing in them, which opens that +chat's panel); viewers receive install progress. While the browser host has +never started, a session's state is synthesized here. **UI sink.** The browser thread publishes through ``bridge.publish``, which calls ``post`` here; ``post`` hops onto the UI loop in a clean context (no request id of whatever request happens to be running). Frames are coalesced -into one latest-frame slot, and a socket that is still behind gets no new -frame, so a slow tab never queues a backlog of images in front of its chat. +into one latest-frame slot per session, and a socket that is still behind +gets no new frame, so a slow tab never queues a backlog of images in front +of its chat. Passwords never travel to a browser tab: vault replies carry no password, and any error text built from a request that carried one (a vault password, @@ -59,14 +65,16 @@ Callable, Deque, Dict, + FrozenSet, List, Optional, + Protocol, Sequence, + Set, Tuple, ) from app.logger import logger -from app.mini_browser import SESSION_ID, SESSION_TITLE, SKILL_NAME from app.mini_browser.errors import ( ERROR_SPECS, MiniBrowserError, @@ -75,7 +83,7 @@ ui_error, ) -__all__ = ["MiniBrowserWS"] +__all__ = ["MiniBrowserAdapter", "MiniBrowserWS"] # Bridge message kind -> WebSocket message type. _MESSAGE_TYPES: Dict[str, str] = { @@ -83,8 +91,12 @@ "frame": "mini_browser_frame", "pointer": "mini_browser_pointer", "event": "mini_browser_event", + "activity": "mini_browser_activity", "install": "mini_browser_install_progress", + "last_frame": "mini_browser_last_frame", } +# Kinds about one session's browser (their payload names it: sessionId). +_SESSION_KINDS = frozenset({"state", "frame", "pointer", "event", "last_frame"}) # Queued-message backlog above which a socket is skipped for this kind: a # frame is only worth sending to a socket that has caught up (the next frame # replaces it anyway), a cursor move to one that is not badly behind. @@ -154,6 +166,7 @@ _HANDLERS: Dict[str, str] = { "mini_browser_subscribe": "_handle_subscribe", "mini_browser_unsubscribe": "_handle_unsubscribe", + "mini_browser_activity_query": "_handle_activity_query", "mini_browser_start": "_handle_start", "mini_browser_shutdown": "_handle_shutdown", "mini_browser_install": "_handle_install", @@ -174,6 +187,25 @@ } +class MiniBrowserAdapter(Protocol): + """What ``MiniBrowserWS`` needs from the browser UI adapter.""" + + @property + def ui_hosts(self) -> FrozenSet[str]: + """CraftBot's own UI origins (``host:port``): no browser opens them.""" + + def client_channel(self, ws: Any) -> Any: + """The open outbound channel of socket ``ws`` (``closed``, ``pending``, + ``send_text``), or None when that socket is gone.""" + + def client_channels(self) -> List[Any]: + """The open outbound channel of every connected socket.""" + + def send_only_to(self, ws: Any, message: Dict[str, Any]) -> bool: + """Send ``message`` to socket ``ws`` only (never broadcast); whether + it was queued.""" + + # ───────────────────────────────────────────────────────────────────────────── # Collaborators, resolved lazily: importing this module never starts or even # imports the browser engine. Module-level so tests can substitute them. @@ -208,6 +240,14 @@ def _bridge() -> Any: return bridge +def _last_frame(key: str) -> Dict[str, Any]: + """Session ``key``'s ``mini_browser_last_frame``: where its browser left + off (``resume.py``). Blocking file I/O: run it in a worker thread.""" + from app.mini_browser import config, resume + + return resume.ui_payload(key, resume.resume_frame(config.profile_dir(key))) + + def _lifecycle() -> Any: from app.mini_browser import lifecycle @@ -226,19 +266,6 @@ def _save_setting(key: str, value: Any) -> None: set_mini_browser_setting(key, value) -def _preloaded_skills() -> List[str]: - """[SKILL_NAME] when the Mini Browser skill is installed and enabled.""" - try: - from app.skill import skill_manager - - skill = skill_manager.get_skill(SKILL_NAME) - except Exception: - return [] - if skill is not None and (skill.enabled or getattr(skill, "is_system", False)): - return [SKILL_NAME] - return [] - - def _notify_vault_changed() -> None: """Every tab showing saved logins refetches them (the data never broadcasts).""" try: @@ -252,18 +279,6 @@ def _notify_vault_changed() -> None: logger.debug(f"[MINI_BROWSER] Vault change notice failed: {e}") -def _notify_sessions_changed(session_id: str) -> None: - try: - from app.ui_layer.events.resource_changes import ( - Resource, - notify_resource_changed, - ) - - notify_resource_changed(Resource.SESSIONS, [session_id]) - except Exception as e: - logger.debug(f"[MINI_BROWSER] Session change notice failed: {e}") - - def _run_install(log: Callable[[str], None]) -> Tuple[bool, str]: """Download Playwright's Chromium (blocking: runs on a worker thread).""" from app.provision import default_context @@ -274,12 +289,32 @@ def _run_install(log: Callable[[str], None]) -> Tuple[bool, str]: # ───────────────────────────────────────────────────────────────────────────── -# Host-loop helpers (run inside host.call on the browser thread) +# Host-loop helpers (run inside host.call / call_fleet on the browser thread) # ───────────────────────────────────────────────────────────────────────────── -async def _core_state(core: Any) -> Dict[str, Any]: - return core.state() +async def _existing_state(key: str, fleet: Any) -> Optional[Dict[str, Any]]: + """Session ``key``'s browser state, or None when it was never used.""" + core = fleet.existing(key) + return core.state() if core is not None else None + + +async def _on_existing( + key: str, fn: Callable[[Any], Awaitable[Any]], fleet: Any +) -> Any: + """``fn(core)`` on session ``key``'s browser if it was ever used (else None).""" + core = fleet.existing(key) + return await fn(core) if core is not None else None + + +async def _activity(fleet: Any) -> List[Dict[str, Any]]: + return fleet.activity() + + +async def _clear_launch_errors(fleet: Any) -> None: + """After an install, an earlier "browser missing" failure no longer applies.""" + for core in list(fleet.cores.values()): + core.clear_launch_error() async def _copy_selection( @@ -310,13 +345,6 @@ def _cut_key(ws: Any, request_id: str) -> str: return f"{id(ws):x}:{request_id}" -async def _clear_launch_error(core: Any) -> None: - """After an install, an earlier "browser missing" failure no longer applies.""" - if getattr(core, "status", None) == "error": - core.status = "stopped" - core.last_error = None - - # ───────────────────────────────────────────────────────────────────────────── # Validation # ───────────────────────────────────────────────────────────────────────────── @@ -373,6 +401,14 @@ def _short_id(value: Any, name: str, *, required: bool) -> Optional[str]: return value +def _required_id(value: Any, name: str) -> str: + """A required tab / session id (see :func:`_short_id`).""" + checked = _short_id(value, name, required=True) + if checked is None: # unreachable: _short_id raises for a missing id + raise _invalid(f"{name} is required.") + return checked + + def _flag(value: Any, name: str) -> bool: if not isinstance(value, bool): raise _invalid(f"{name} must be true or false.") @@ -653,29 +689,41 @@ def worker() -> None: class _InputQueue: - """One socket's live input that has not reached the browser yet. + """One socket's live input that has not reached its browser yet. Events are applied one at a time, in arrival order, by a single drain task. While the page lags behind, continuous input is coalesced at the tail (see :func:`_coalesce`), so a slow or hung page costs a few queued events instead of a backlog of hover moves that would hold up keystrokes. + Each event names its session's browser and its tab. """ __slots__ = ("items", "task", "current", "overflowed") def __init__(self) -> None: - self.items: Deque[Tuple[str, Dict[str, Any]]] = deque() + self.items: Deque[Tuple[str, str, Dict[str, Any]]] = deque() self.task: Optional[asyncio.Task] = None - self.current: Optional[str] = None # tab of the event being applied + # (session, tab) of the event being applied. + self.current: Optional[Tuple[str, str]] = None self.overflowed = False - def pending_for(self, tab_id: str) -> bool: - """Whether input for ``tab_id`` is queued or being applied.""" - return self.current == tab_id or any(tab == tab_id for tab, _ in self.items) + def pending_for(self, key: str, tab_id: str) -> bool: + """Whether input for ``tab_id`` of session ``key`` is queued or being applied.""" + target = (key, tab_id) + return self.current == target or any( + (session, tab) == target for session, tab, _ in self.items + ) + + def drop_tab(self, key: str, tab_id: str) -> int: + """Forget the queued (not yet applied) input of one tab.""" + return self._drop(lambda session, tab: session == key and tab == tab_id) + + def drop_session(self, key: str) -> int: + """Forget the queued (not yet applied) input of a session's browser.""" + return self._drop(lambda session, _tab: session == key) - def drop_tab(self, tab_id: str) -> int: - """Forget the queued (not yet applied) input of ``tab_id``.""" - kept = [(tab, event) for tab, event in self.items if tab != tab_id] + def _drop(self, matches: Callable[[str, str], bool]) -> int: + kept = [item for item in self.items if not matches(item[0], item[1])] dropped = len(self.items) - len(kept) if dropped: self.items = deque(kept) @@ -683,7 +731,10 @@ def drop_tab(self, tab_id: str) -> int: def _coalesce( - items: Deque[Tuple[str, Dict[str, Any]]], tab_id: str, event: Dict[str, Any] + items: Deque[Tuple[str, str, Dict[str, Any]]], + key: str, + tab_id: str, + event: Dict[str, Any], ) -> bool: """Merge ``event`` into the newest queued event if both are continuous input of the same kind on the same tab: a move replaces the queued move @@ -694,46 +745,84 @@ def _coalesce( """ if not items: return False - last_tab, last = items[-1] - if last_tab != tab_id or last["kind"] != event["kind"]: + last_key, last_tab, last = items[-1] + if last_key != key or last_tab != tab_id or last["kind"] != event["kind"]: return False if event["kind"] == "mouse": if event["action"] != "move" or last["action"] != "move": return False - items[-1] = (tab_id, event) + items[-1] = (key, tab_id, event) return True if event["kind"] == "wheel": dx = last["dx"] + event["dx"] dy = last["dy"] + event["dy"] if abs(dx) > _MAX_WHEEL_DELTA or abs(dy) > _MAX_WHEEL_DELTA: return False - items[-1] = (tab_id, {**event, "dx": dx, "dy": dy}) + items[-1] = (key, tab_id, {**event, "dx": dx, "dy": dy}) return True return False +def _session_id(data: Dict[str, Any]) -> str: + """The session whose browser a request is about.""" + return _required_id(data.get("sessionId"), "sessionId") + + +def _optional_session_id(data: Dict[str, Any]) -> Optional[str]: + """The request's session id when it carries a valid one, else None.""" + try: + return _short_id(data.get("sessionId"), "sessionId", required=False) + except MiniBrowserError: + return None + + +def _dump(msg_type: str, data: Dict[str, Any]) -> Optional[str]: + try: + return json.dumps({"type": msg_type, "data": data}, allow_nan=False) + except (TypeError, ValueError) as e: + logger.warning(f"[MINI_BROWSER] Dropped an unserializable {msg_type}: {e}") + return None + + +async def _sync_core_stream(core: Any) -> None: + await core.set_streaming(core.has_viewers()) + + +async def _push_frame(core: Any) -> None: + await core.push_frame_now() + + +async def _close_core(core: Any) -> None: + await core.close() + + # ───────────────────────────────────────────────────────────────────────────── # The handler # ───────────────────────────────────────────────────────────────────────────── class MiniBrowserWS: - """Mini Browser requests, viewers and UI sink for one browser adapter. + """Browser-panel requests, viewers and UI sink for one browser adapter. Everything except ``has_viewers`` and ``post`` runs on the UI loop; those two are called from the browser thread. """ - def __init__(self, adapter: Any) -> None: + def __init__(self, adapter: MiniBrowserAdapter) -> None: self._adapter = adapter self._loop: Optional[asyncio.AbstractEventLoop] = None - # Subscribed sockets (UI loop only) and a count other threads may read. - self._viewers: set = set() - self._viewer_count = 0 - # Latest-frame slot, filled by the browser thread, drained on the UI loop. + # Sockets watching each session's browser, and the sessions each + # socket watches (UI loop only). + self._viewers: Dict[str, Set[Any]] = {} + self._watching: Dict[Any, Set[str]] = {} + # Sessions with a viewer, read by the browser thread (replaced, never + # mutated, so a read never needs a lock). + self._watched: FrozenSet[str] = frozenset() + # The latest frame of each session, filled by the browser thread and + # drained on the UI loop. self._frame_lock = threading.Lock() - self._frame: Optional[Dict[str, Any]] = None - self._frame_scheduled = False + self._frames: Dict[str, Dict[str, Any]] = {} + self._frames_scheduled = False self._tasks: set = set() self._installing = False # Live input per socket, waiting for the browser (UI loop only). @@ -753,17 +842,18 @@ def on_start(self) -> None: async def shutdown(self) -> None: """Adapter stopping: detach from the bridge, end background work and - close Chromium (it holds a lock on its profile). Never raises.""" + close every browser (each holds a lock on its profile). Never raises.""" try: _bridge().unregister_ui_sink(self) except Exception as e: logger.debug(f"[MINI_BROWSER] Sink unregister failed: {first_line(e)}") self._loop = None with self._frame_lock: - self._frame = None - self._frame_scheduled = False + self._frames.clear() + self._frames_scheduled = False self._viewers.clear() - self._viewer_count = 0 + self._watching.clear() + self._watched = frozenset() self._input_queues.clear() tasks = [task for task in self._tasks if not task.done()] for task in tasks: @@ -783,115 +873,163 @@ async def shutdown(self) -> None: logger.warning(f"[MINI_BROWSER] Browser shutdown failed: {first_line(e)}") def forget(self, ws: Any) -> None: - """A socket closed: it is no longer a viewer, and its input that has - not reached the browser yet is dropped. Never raises.""" + """A socket closed: it watches no browser any more, and its input that + has not reached a browser yet is dropped. Never raises.""" try: queue = self._input_queues.pop(ws, None) if queue is not None: queue.items.clear() - self._drop_viewer(ws) + self._forget_viewer(ws) except Exception as e: logger.debug(f"[MINI_BROWSER] forget failed: {first_line(e)}") # ── UI sink (thread-safe) ──────────────────────────────────────────────── - def has_viewers(self) -> bool: - """Whether any socket is watching the Mini Browser. Thread-safe.""" - return self._viewer_count > 0 + def has_viewers(self, key: str) -> bool: + """Whether any socket watches session ``key``'s browser. Thread-safe.""" + return key in self._watched def post(self, kind: str, payload: Dict[str, Any]) -> None: - """Deliver a bridge message to the viewers. Thread-safe, never blocks. + """Deliver a bridge message. Thread-safe, never blocks. - ``kind`` is one of state / frame / pointer / event / install. Frames - are coalesced: only the newest one waiting for the UI loop is sent. + ``kind`` is one of state / frame / pointer / event (to the viewers of + the session the payload names), activity (to every client) or + install (to every viewer). Frames are coalesced: only the newest one + of each session waiting for the UI loop is sent. """ loop = self._loop - if ( - loop is None - or self._viewer_count == 0 - or kind not in _MESSAGE_TYPES - or not isinstance(payload, dict) - ): + if loop is None or kind not in _MESSAGE_TYPES or not isinstance(payload, dict): return data = dict(payload) # the producer may reuse its dict + key = data.get("sessionId") + if kind in _SESSION_KINDS: + if not isinstance(key, str) or key not in self._watched: + return + elif kind == "install" and not self._watched: + return context = contextvars.Context() try: if kind == "frame": with self._frame_lock: - self._frame = data - if self._frame_scheduled: + self._frames[key] = data + if self._frames_scheduled: return - self._frame_scheduled = True + self._frames_scheduled = True try: - loop.call_soon_threadsafe(self._flush_frame, context=context) + loop.call_soon_threadsafe(self._flush_frames, context=context) except RuntimeError: with self._frame_lock: - self._frame_scheduled = False - self._frame = None + self._frames_scheduled = False + self._frames.clear() raise else: loop.call_soon_threadsafe(self._deliver, kind, data, context=context) except RuntimeError: pass # the UI loop is closed (shutting down): nobody to deliver to - def _flush_frame(self) -> None: + def _flush_frames(self) -> None: with self._frame_lock: - data, self._frame = self._frame, None - self._frame_scheduled = False - if data is not None: + frames, self._frames = self._frames, {} + self._frames_scheduled = False + for data in frames.values(): self._deliver("frame", data) def _deliver(self, kind: str, data: Dict[str, Any]) -> None: - """UI loop: send one bridge message to every viewer.""" + """UI loop: send one bridge message to whoever it is for.""" try: - if kind == "state": - data = self._overlay_state(data) - self._send_to_viewers(_MESSAGE_TYPES[kind], data, _MAX_PENDING.get(kind)) + msg_type = _MESSAGE_TYPES[kind] + if kind == "activity": + self._send_to_all(msg_type, {"sessions": [data], "snapshot": False}) + elif kind == "install": + self._send_to_viewers(None, msg_type, data) + else: + if kind == "state": + data = self._overlay_state(data) + self._send_to_viewers( + data["sessionId"], msg_type, data, _MAX_PENDING.get(kind) + ) except Exception as e: logger.warning(f"[MINI_BROWSER] {kind} delivery failed: {first_line(e)}") def _send_to_viewers( - self, msg_type: str, data: Dict[str, Any], max_pending: Optional[int] = None + self, + key: Optional[str], + msg_type: str, + data: Dict[str, Any], + max_pending: Optional[int] = None, ) -> None: - """Queue one message for each subscribed socket (never via _broadcast).""" - if not self._viewers: + """Queue one message for each socket watching session ``key``'s browser + (``key`` None: watching any browser). Never via a broadcast.""" + sockets = ( + list(self._viewers.get(key, ())) + if key is not None + else list(self._watching) + ) + if not sockets: return - try: - text = json.dumps({"type": msg_type, "data": data}, allow_nan=False) - except (TypeError, ValueError) as e: - logger.warning(f"[MINI_BROWSER] Dropped an unserializable {msg_type}: {e}") + text = _dump(msg_type, data) + if text is None: return - channels = getattr(self._adapter, "_channels", {}) - for ws in list(self._viewers): - channel = channels.get(ws) + for ws in sockets: + channel = self._adapter.client_channel(ws) if channel is None or channel.closed: - self._drop_viewer(ws) + self._forget_viewer(ws) elif max_pending is None or channel.pending <= max_pending: channel.send_text(text) - # ── viewers and streaming ──────────────────────────────────────────────── + def _send_to_all(self, msg_type: str, data: Dict[str, Any]) -> None: + """Queue one message for every connected socket.""" + channels = self._adapter.client_channels() + if not channels: + return + text = _dump(msg_type, data) + if text is None: + return + for channel in channels: + if not channel.closed: + channel.send_text(text) - def _channel(self, ws: Any) -> Any: - channel = ( - getattr(self._adapter, "_channels", {}).get(ws) if ws is not None else None - ) - return None if channel is None or channel.closed else channel + # ── viewers and streaming ──────────────────────────────────────────────── - def _drop_viewer(self, ws: Any) -> None: - if ws not in self._viewers: + def _add_viewer(self, ws: Any, key: str) -> bool: + """``ws`` watches session ``key``'s browser; True for its first viewer.""" + viewers = self._viewers.setdefault(key, set()) + first = not viewers + viewers.add(ws) + self._watching.setdefault(ws, set()).add(key) + if first: + self._watched = frozenset(self._viewers) + return first + + def _drop_viewer(self, ws: Any, key: str) -> None: + viewers = self._viewers.get(key) + if not viewers or ws not in viewers: return - self._viewers.discard(ws) - self._viewer_count = len(self._viewers) - if not self._viewers: - self._sync_streaming() - - def _sync_streaming(self) -> None: - """Tell the browser to stream iff someone is watching (read when it runs).""" + viewers.discard(ws) + keys = self._watching.get(ws) + if keys is not None: + keys.discard(key) + if not keys: + del self._watching[ws] + if not viewers: + del self._viewers[key] + self._watched = frozenset(self._viewers) + self._sync_streaming(key) + + def _forget_viewer(self, ws: Any) -> None: + for key in list(self._watching.get(ws, ())): + self._drop_viewer(ws, key) + + def _watches(self, ws: Any, key: str) -> bool: + return ws in self._viewers.get(key, ()) + + def _sync_streaming(self, key: str) -> None: + """Tell session ``key``'s browser to stream iff someone watches it.""" host = _host_if_started() if host is not None: self._submit( host, - lambda core: core.set_streaming(self.has_viewers()), + functools.partial(_on_existing, key, _sync_core_stream), "set_streaming", ) @@ -904,66 +1042,6 @@ def _submit(host: Any, fn: Callable[[Any], Awaitable[Any]], what: str) -> None: except Exception as e: logger.debug(f"[MINI_BROWSER] {what} not sent: {first_line(e)}") - # ── the dedicated chat session ─────────────────────────────────────────── - - def _session_manager(self) -> Any: - controller = getattr(self._adapter, "_controller", None) - agent = getattr(controller, "agent", None) - return getattr(agent, "session_manager", None) - - def _session_exists(self) -> bool: - manager = self._session_manager() - try: - return manager is not None and manager.get(SESSION_ID) is not None - except Exception: - return False - - def ensure_session(self) -> Any: - """The dedicated Mini Browser chat session, created if it is missing. - - Mirrors ``AgentAppManager.ensure_project_session``: a fixed id, the - browser's action set in (the mini_browser session type's - base set) and its skill, when installed and enabled, preloaded, - persisted by the SessionManager — so after a restart it is - restored, not recreated. Clients learn about a new session from a - ``session_created`` broadcast (the sessions store upserts it) and a - ``sessions`` resource change. Idempotent; None without a session - manager. - """ - manager = self._session_manager() - if manager is None: - return None - session = manager.get(SESSION_ID) - if session is not None: - return session - - from agent_core.core.session import SessionType - - session = manager.create_session( - session_type=SessionType.MINI_BROWSER, - title=SESSION_TITLE, - session_id=SESSION_ID, - selected_skills=_preloaded_skills(), - ) - logger.info(f"[MINI_BROWSER] Created the dedicated session '{session.id}'") - self._announce_session(session) - return session - - def _announce_session(self, session: Any) -> None: - try: - info = self._adapter._session_info(session) - message = { - "type": "session_created", - "data": {"session": info, "clientId": None}, - } - # Clean context: the broadcast must not carry this request's id. - self._spawn(lambda: self._adapter._broadcast(message), clean=True) - except Exception as e: - logger.warning( - f"[MINI_BROWSER] Could not announce the session: {first_line(e)}" - ) - _notify_sessions_changed(session.id) - # ── state ──────────────────────────────────────────────────────────────── def _ui_hosts(self) -> frozenset: @@ -973,24 +1051,20 @@ def _ui_hosts(self) -> frozenset: return frozenset() def _overlay_state(self, state: Dict[str, Any]) -> Dict[str, Any]: - """What only this side knows: the session id (once the session exists) - and an install in progress.""" - state = dict(state) - state["sessionId"] = SESSION_ID if self._session_exists() else None + """What only this side knows: a Chromium install in progress.""" if self._installing and state.get("status") in ("stopped", "error"): - state["status"] = "installing" - state["error"] = None + state = {**state, "status": "installing", "error": None} return state async def _synthesized_state( - self, error: Optional[Dict[str, str]] = None + self, key: str, error: Optional[Dict[str, str]] = None ) -> Dict[str, Any]: - """The state of a browser host that is not running (never starts it).""" + """The state of a session browser that was never used (never starts it).""" settings = await asyncio.to_thread(_load_settings) return { + "sessionId": key, "status": "error" if error else "stopped", "error": error, - "sessionId": None, "adblock": bool(settings.get("adblock", True)), "viewedTabId": None, "follow": True, @@ -1002,35 +1076,52 @@ async def _synthesized_state( }, } - async def _state_snapshot(self) -> Dict[str, Any]: - """Current ``mini_browser_state`` payload. Never starts the browser.""" + async def _state_snapshot( + self, key: str, error: Optional[Dict[str, str]] = None + ) -> Dict[str, Any]: + """Session ``key``'s current ``mini_browser_state``. Never starts a + browser. ``error`` is shown when the browser has no error of its own + (a request about it failed before it existed).""" state: Any = None - error: Optional[Dict[str, str]] = None host = _host_if_started() if host is not None: try: - state = await asyncio.wait_for(host.call(_core_state), _STATE_TIMEOUT_S) + state = await asyncio.wait_for( + host.call_fleet(functools.partial(_existing_state, key)), + _STATE_TIMEOUT_S, + ) except asyncio.TimeoutError: - error = _error_reply( + error = error or _error_reply( "MINI_BROWSER_TIMEOUT", - {"what": "The Mini Browser", "seconds": int(_STATE_TIMEOUT_S)}, + {"what": "The browser", "seconds": int(_STATE_TIMEOUT_S)}, ) except Exception as e: - error = _error_reply("MINI_BROWSER_INTERNAL", {"detail": first_line(e)}) + error = error or _error_reply( + "MINI_BROWSER_INTERNAL", {"detail": first_line(e)} + ) if not isinstance(state, dict): - state = await self._synthesized_state(error) + state = await self._synthesized_state(key, error) + elif error is not None and not state.get("error"): + state = {**state, "status": "error", "error": error} return self._overlay_state(state) - async def _publish_state(self, ws: Any = None) -> None: - """Send the current state to every viewer (and to ``ws`` if it is not one).""" + async def _publish_state( + self, key: str, ws: Any = None, error: Optional[Dict[str, str]] = None + ) -> None: + """Send session ``key``'s state to its viewers (and to ``ws`` if it + does not watch it).""" try: - state = await self._state_snapshot() - self._send_to_viewers("mini_browser_state", state) - if ws is not None and ws not in self._viewers: + state = await self._state_snapshot(key, error) + self._send_to_viewers(key, "mini_browser_state", state) + if ws is not None and not self._watches(ws, key): self._send(ws, "mini_browser_state", state) except Exception as e: logger.debug(f"[MINI_BROWSER] State refresh failed: {first_line(e)}") + async def _publish_watched_states(self) -> None: + for key in list(self._viewers): + await self._publish_state(key) + # ── request plumbing ───────────────────────────────────────────────────── async def handle(self, ws: Any, msg_type: str, data: Dict[str, Any]) -> None: @@ -1061,7 +1152,9 @@ async def _guarded( raise except MiniBrowserError as e: logger.debug(f"[MINI_BROWSER] {msg_type} refused: {e.code}") - self._reply_error(ws, msg_type, _error_reply(e.code, e.fields, secrets)) + self._reply_error( + ws, msg_type, data, _error_reply(e.code, e.fields, secrets) + ) except Exception as e: # Type and first line only: a traceback's locals could hold a # password typed into a page or sent to the vault. @@ -1072,6 +1165,7 @@ async def _guarded( self._reply_error( ws, msg_type, + data, _error_reply("MINI_BROWSER_INTERNAL", {"detail": detail}, secrets), ) @@ -1111,13 +1205,24 @@ def _task_done(self, task: asyncio.Task) -> None: def _send(self, ws: Any, msg_type: str, data: Dict[str, Any]) -> None: """Reply to ``ws`` only; dropped if it is gone (never broadcast).""" try: - self._adapter._send_only_to(ws, {"type": msg_type, "data": data}) + self._adapter.send_only_to(ws, {"type": msg_type, "data": data}) except Exception as e: logger.warning(f"[MINI_BROWSER] Could not send {msg_type}: {first_line(e)}") - def _reply_error(self, ws: Any, msg_type: str, error: Dict[str, str]) -> None: + def _reply_error( + self, + ws: Any, + msg_type: str, + data: Dict[str, Any], + error: Dict[str, str], + ) -> None: + session = _optional_session_id(data) if msg_type == "mini_browser_navigate": - self._send(ws, "mini_browser_nav_result", {"ok": False, "error": error}) + self._send( + ws, + "mini_browser_nav_result", + {"sessionId": session, "ok": False, "error": error}, + ) elif msg_type == "mini_browser_vault_list": self._send( ws, @@ -1140,6 +1245,7 @@ def _reply_error(self, ws: Any, msg_type: str, error: Dict[str, str]) -> None: "mini_browser_event", { "kind": "error", + "sessionId": session, "level": _level(error.get("code", "")), "code": error.get("code"), "title": error.get("title"), @@ -1157,62 +1263,106 @@ async def _start_host(self) -> Any: """The browser host, started if needed (its thread, not Chromium). Starting waits for the host thread to come up, so it happens on a - worker thread, never on the UI loop. A host started here after the - page subscribed learns right away that someone is watching. + worker thread, never on the UI loop. A host started here after + panels subscribed learns right away which browsers are watched. """ host = _host_if_started() if host is not None: return host host = await asyncio.to_thread(_get_host) - if self._viewers: - self._sync_streaming() + for key in list(self._viewers): + self._sync_streaming(key) return host # ── handlers: viewers and lifecycle ────────────────────────────────────── async def _handle_subscribe(self, ws: Any, data: Dict[str, Any]) -> None: - if self._channel(ws) is None: + """The chat's browser panel opened: watch that session's browser, and + turn the browser on.""" + key = _session_id(data) + if self._adapter.client_channel(ws) is None: return # the socket is already gone - first_viewer = not self._viewers - self._viewers.add(ws) - self._viewer_count = len(self._viewers) + first_viewer = self._add_viewer(ws, key) + state = await self._state_snapshot(key) + if state.get("status") != "ready": + # Until the live page is back, the panel shows where the browser + # left off (or nothing: an earlier picture may be out of date). + last_frame = await asyncio.to_thread(_last_frame, key) + self._send(ws, "mini_browser_last_frame", last_frame) + if state.get("status") == "stopped": + # Opening the panel turns the browser on (below): say so at once. + state = {**state, "status": "starting", "error": None} + self._send(ws, "mini_browser_state", state) + host = await self._start_host() + if not self._watches(ws, key): + return # the panel closed meanwhile + if first_viewer: + self._sync_streaming(key) + self._submit( + host, functools.partial(_on_existing, key, _push_frame), "push_frame_now" + ) + self._spawn(lambda: self._open_browser(ws, host, key)) + + async def _open_browser(self, ws: Any, host: Any, key: str) -> None: + """Start session ``key``'s browser for its panel. A failure is shown in + the panel (the state names it), not as a separate error.""" + error: Optional[Dict[str, str]] = None try: - self.ensure_session() + await host.call(key, lambda core: core.ensure_started()) + except asyncio.CancelledError: + raise + except MiniBrowserError as e: + logger.debug(f"[MINI_BROWSER] Browser of {key} not started: {e.code}") + error = _error_reply(e.code, e.fields) except Exception as e: - logger.warning(f"[MINI_BROWSER] Session not created: {first_line(e)}") - self._send(ws, "mini_browser_state", await self._state_snapshot()) - host = _host_if_started() - if host is not None and ws in self._viewers: - if first_viewer: - self._sync_streaming() - self._submit(host, lambda core: core.push_frame_now(), "push_frame_now") + logger.warning( + f"[MINI_BROWSER] Browser of {key} not started: {type(e).__name__}" + ) + error = _error_reply("MINI_BROWSER_INTERNAL", {"detail": first_line(e)}) + await self._publish_state(key, ws, error) async def _handle_unsubscribe(self, ws: Any, data: Dict[str, Any]) -> None: - self._drop_viewer(ws) + self._drop_viewer(ws, _session_id(data)) + + async def _handle_activity_query(self, ws: Any, data: Dict[str, Any]) -> None: + """Which session browsers run and which have an agent browsing (a + client that just connected; updates follow as they happen).""" + host = _host_if_started() + sessions: List[Dict[str, Any]] = [] + if host is not None: + sessions = await asyncio.wait_for( + host.call_fleet(_activity), _STATE_TIMEOUT_S + ) + self._send( + ws, "mini_browser_activity", {"sessions": sessions, "snapshot": True} + ) async def _handle_start(self, ws: Any, data: Dict[str, Any]) -> None: + key = _session_id(data) host = await self._start_host() async def start() -> None: try: - await host.call(lambda core: core.start()) + await host.call(key, lambda core: core.start()) except Exception: - await self._publish_state(ws) # e.g. status "error" + the reason + await self._publish_state(key, ws) # e.g. status "error" + the reason raise - await self._publish_state(ws) + await self._publish_state(key, ws) self._background(ws, "mini_browser_start", data, start) async def _handle_shutdown(self, ws: Any, data: Dict[str, Any]) -> None: + """Close one session's browser (its panel's Close button).""" + key = _session_id(data) host = _host_if_started() # Input still on its way to the closing browser has nowhere to go. for queue in self._input_queues.values(): - queue.items.clear() + queue.drop_session(key) try: if host is not None: - await host.call(lambda core: core.close()) + await host.call_fleet(functools.partial(_on_existing, key, _close_core)) finally: - await self._publish_state(ws) + await self._publish_state(key, ws) async def _handle_install(self, ws: Any, data: Dict[str, Any]) -> None: if self._installing: @@ -1220,7 +1370,7 @@ async def _handle_install(self, ws: Any, data: Dict[str, Any]) -> None: return self._installing = True try: - await self._publish_state(ws) # status "installing" + await self._publish_watched_states() # status "installing" logger.info("[MINI_BROWSER] Installing Chromium") ok, detail = await _in_daemon_thread(_run_install, self._install_log) except Exception as e: @@ -1232,7 +1382,7 @@ async def _handle_install(self, ws: Any, data: Dict[str, Any]) -> None: host = _host_if_started() if host is not None: try: - await host.call(_clear_launch_error) + await host.call_fleet(_clear_launch_errors) except Exception as e: logger.debug(f"[MINI_BROWSER] Status reset failed: {first_line(e)}") done: Dict[str, Any] = {"done": True, "ok": True} @@ -1245,10 +1395,10 @@ async def _handle_install(self, ws: Any, data: Dict[str, Any]) -> None: "MINI_BROWSER_INSTALL_FAILED", {"detail": detail or "unknown error"} ), } - self._send_to_viewers("mini_browser_install_progress", done) - if ws not in self._viewers: + self._send_to_viewers(None, "mini_browser_install_progress", done) + if ws not in self._watching: self._send(ws, "mini_browser_install_progress", done) - await self._publish_state(ws) + await self._publish_watched_states() def _install_log(self, line: str) -> None: """Install output (worker thread) -> viewers' progress panel.""" @@ -1259,17 +1409,19 @@ def _install_log(self, line: str) -> None: # ── handlers: browsing ─────────────────────────────────────────────────── async def _handle_navigate(self, ws: Any, data: Dict[str, Any]) -> None: + key = _session_id(data) tab_id = _short_id(data.get("tabId"), "tabId", required=False) text = _url_text(data.get("url"), required=True) host = await self._start_host() async def navigate() -> None: - await host.call(lambda core: core.ui_navigate(tab_id, text)) - self._send(ws, "mini_browser_nav_result", {"ok": True}) + await host.call(key, lambda core: core.ui_navigate(tab_id, text)) + self._send(ws, "mini_browser_nav_result", {"sessionId": key, "ok": True}) self._background(ws, "mini_browser_navigate", data, navigate) async def _handle_history(self, ws: Any, data: Dict[str, Any]) -> None: + key = _session_id(data) tab_id = _short_id(data.get("tabId"), "tabId", required=False) action = data.get("action") if action not in _HISTORY_ACTIONS: @@ -1279,10 +1431,11 @@ async def _handle_history(self, ws: Any, data: Dict[str, Any]) -> None: ws, "mini_browser_history", data, - lambda: host.call(lambda core: core.ui_history(tab_id, action)), + lambda: host.call(key, lambda core: core.ui_history(tab_id, action)), ) async def _handle_tab(self, ws: Any, data: Dict[str, Any]) -> None: + key = _session_id(data) action = data.get("action") if action == "new": url = _url_text(data.get("url"), required=False) @@ -1291,23 +1444,25 @@ async def _handle_tab(self, ws: Any, data: Dict[str, Any]) -> None: ws, "mini_browser_tab", data, - lambda: host.call(lambda core: core.ui_new_tab(url)), + lambda: host.call(key, lambda core: core.ui_new_tab(url)), ) elif action == "switch": - tab_id = _short_id(data.get("tabId"), "tabId", required=True) - await self._running_host().call(lambda core: core.ui_switch_tab(tab_id)) + tab_id = _required_id(data.get("tabId"), "tabId") + await self._running_host().call( + key, lambda core: core.ui_switch_tab(tab_id) + ) elif action == "close": - tab_id = _short_id(data.get("tabId"), "tabId", required=True) + tab_id = _required_id(data.get("tabId"), "tabId") host = self._running_host() # Input queued for a tab that is closing would only fail. for queue in self._input_queues.values(): - queue.drop_tab(tab_id) - await host.call(lambda core: core.ui_close_tab(tab_id)) + queue.drop_tab(key, tab_id) + await host.call(key, lambda core: core.ui_close_tab(tab_id)) else: raise _invalid("action must be new, switch or close.") async def _handle_input(self, ws: Any, data: Dict[str, Any]) -> None: - """Validate one live-view event and queue it for the browser. + """Validate one live-view event and queue it for its browser. Returns as soon as it is queued: the socket's drain task applies the events in order (see _InputQueue), so a lagging page never holds up @@ -1316,18 +1471,21 @@ async def _handle_input(self, ws: Any, data: Dict[str, Any]) -> None: event = _input_event(data.get("event")) if event is None: return # an unknown kind of input is ignored - tab_id = _short_id(data.get("tabId"), "tabId", required=True) + key = _session_id(data) + tab_id = _required_id(data.get("tabId"), "tabId") if _host_if_started() is None: return # no browser to type into if event["kind"] == "cut": event = {**event, "requestId": _cut_key(ws, event["requestId"])} - self._queue_input(ws, tab_id, event) + self._queue_input(ws, key, tab_id, event) - def _queue_input(self, ws: Any, tab_id: str, event: Dict[str, Any]) -> None: + def _queue_input( + self, ws: Any, key: str, tab_id: str, event: Dict[str, Any] + ) -> None: queue = self._input_queues.get(ws) if queue is None: queue = self._input_queues[ws] = _InputQueue() - if not _coalesce(queue.items, tab_id, event): + if not _coalesce(queue.items, key, tab_id, event): if len(queue.items) >= _MAX_QUEUED_INPUT: # The page stopped taking input; tell the user once per backlog. if not queue.overflowed: @@ -1339,28 +1497,30 @@ def _queue_input(self, ws: Any, tab_id: str, event: Dict[str, Any]) -> None: self._reply_error( ws, "mini_browser_input", + {"sessionId": key}, _error_reply("MINI_BROWSER_PAGE_UNRESPONSIVE"), ) return - queue.items.append((tab_id, event)) + queue.items.append((key, tab_id, event)) if queue.task is None or queue.task.done(): queue.task = self._spawn(lambda: self._drain_input(ws, queue), clean=True) async def _drain_input(self, ws: Any, queue: _InputQueue) -> None: """Apply a socket's queued input one event at a time, in order.""" while queue.items and self._input_queues.get(ws) is queue: - tab_id, event = queue.items.popleft() + key, tab_id, event = queue.items.popleft() host = _host_if_started() if host is None: queue.items.clear() # the browser host is gone return - queue.current = tab_id + queue.current = (key, tab_id) try: await self._guarded( ws, "mini_browser_input", - {"event": event}, # its text is scrubbed from any error - lambda: self._apply_input(host, tab_id, event), + # Its text is scrubbed from any error. + {"event": event, "sessionId": key}, + lambda: self._apply_input(host, key, tab_id, event), ) finally: queue.current = None @@ -1368,15 +1528,19 @@ async def _drain_input(self, ws: Any, queue: _InputQueue) -> None: queue.overflowed = False @staticmethod - async def _apply_input(host: Any, tab_id: str, event: Dict[str, Any]) -> None: + async def _apply_input( + host: Any, key: str, tab_id: str, event: Dict[str, Any] + ) -> None: try: - await host.call(lambda core: core.ui_input(tab_id, event)) + await host.call(key, lambda core: core.ui_input(tab_id, event)) except MiniBrowserError as e: if (event["kind"], event.get("action")) not in _CONTINUOUS_INPUT: raise logger.debug(f"[MINI_BROWSER] {event['kind']} input dropped: {e.code}") - async def _settle_input(self, ws: Any, tab_id: str, *, drop_late: bool) -> None: + async def _settle_input( + self, ws: Any, key: str, tab_id: str, *, drop_late: bool + ) -> None: """Wait (at most _INPUT_SETTLE_S) until the input ``ws`` queued for ``tab_id`` has been applied, so a request that must follow it (copy the selection it made, hand the tab back) does. ``drop_late``: input @@ -1386,10 +1550,10 @@ async def _settle_input(self, ws: Any, tab_id: str, *, drop_late: bool) -> None: return loop = asyncio.get_running_loop() deadline = loop.time() + _INPUT_SETTLE_S - while queue.pending_for(tab_id) and self._input_queues.get(ws) is queue: + while queue.pending_for(key, tab_id) and self._input_queues.get(ws) is queue: if loop.time() >= deadline: if drop_late: - dropped = queue.drop_tab(tab_id) + dropped = queue.drop_tab(key, tab_id) if dropped: logger.info( f"[MINI_BROWSER] Dropped {dropped} input event(s) the " @@ -1399,47 +1563,52 @@ async def _settle_input(self, ws: Any, tab_id: str, *, drop_late: bool) -> None: await asyncio.sleep(_INPUT_SETTLE_POLL_S) async def _handle_resize(self, ws: Any, data: Dict[str, Any]) -> None: + key = _session_id(data) width = _int_in(data.get("width"), "width", _VIEWPORT_WIDTH) height = _int_in(data.get("height"), "height", _VIEWPORT_HEIGHT) # Starts the host (not Chromium) so the first launch already has the size. host = await self._start_host() - await host.call(lambda core: core.set_viewport(width, height)) + await host.call(key, lambda core: core.set_viewport(width, height)) async def _handle_view(self, ws: Any, data: Dict[str, Any]) -> None: + key = _session_id(data) tab_id = _short_id(data.get("tabId"), "tabId", required=False) follow = data.get("follow") if follow is not None: follow = _flag(follow, "follow") host = _host_if_started() if host is not None: # nothing to view before the browser exists - await host.call(lambda core: core.ui_view(tab_id, follow)) + await host.call_fleet( + functools.partial( + _on_existing, key, lambda core: core.ui_view(tab_id, follow) + ) + ) async def _handle_control(self, ws: Any, data: Dict[str, Any]) -> None: - tab_id = _short_id(data.get("tabId"), "tabId", required=True) + key = _session_id(data) + tab_id = _required_id(data.get("tabId"), "tabId") take = _flag(data.get("take"), "take") host = self._running_host() if not take: # Control runs in its own lane: let the user's last keystrokes and # clicks land before the agent gets the tab back (otherwise they # would arrive afterwards and take control again). - await self._settle_input(ws, tab_id, drop_late=True) - await host.call(lambda core: core.ui_control(tab_id, take)) + await self._settle_input(ws, key, tab_id, drop_late=True) + await host.call(key, lambda core: core.ui_control(tab_id, take)) async def _handle_adblock(self, ws: Any, data: Dict[str, Any]) -> None: - enabled = data.get("enabled") - if enabled is None: # a query: answer without starting anything - self._send(ws, "mini_browser_state", await self._state_snapshot()) - return - enabled = _flag(enabled, "enabled") + """Ad blocking on/off: one setting for every session's browser.""" + enabled = _flag(data.get("enabled"), "enabled") host = _host_if_started() if host is not None: - await host.call(lambda core: core.set_adblock(enabled)) + await host.call_fleet(lambda fleet: fleet.set_adblock(enabled)) else: await asyncio.to_thread(_save_setting, "adblock", enabled) - await self._publish_state(ws) + await self._publish_watched_states() async def _handle_copy(self, ws: Any, data: Dict[str, Any]) -> None: - tab_id = _short_id(data.get("tabId"), "tabId", required=True) + key = _session_id(data) + tab_id = _required_id(data.get("tabId"), "tabId") # Echoed back, so the page matches the reply to its request (a cut # deletes the selection only once ITS copy has arrived). request_id = _short_id(data.get("requestId"), "requestId", required=False) @@ -1452,8 +1621,10 @@ async def _handle_copy(self, ws: Any, data: Dict[str, Any]) -> None: ) host = self._running_host() # The selection may come from input still on its way (a drag, Ctrl+A). - await self._settle_input(ws, tab_id, drop_late=False) - text = await host.call(lambda core: _copy_selection(core, tab_id, cut_request)) + await self._settle_input(ws, key, tab_id, drop_late=False) + text = await host.call( + key, lambda core: _copy_selection(core, tab_id, cut_request) + ) reply: Dict[str, Any] = {"text": text} if request_id is not None: reply["requestId"] = request_id diff --git a/app/subagent/definitions/browser_agent.py b/app/subagent/definitions/browser_agent.py index d98509145..fb8ce98fb 100644 --- a/app/subagent/definitions/browser_agent.py +++ b/app/subagent/definitions/browser_agent.py @@ -2,12 +2,12 @@ """ Browser sub-agent. -Drives its OWN tab in the user's Mini Browser (a real Chromium with the -user's cookies and logins, shared by every agent) to complete one -interactive web task: searching inside a site, clicking through results, -filling a form, comparing offers. The Mini Browser gives every caller -(here: the sub-agent's own id) a separate tab, so several browser agents -can run in parallel without touching each other's pages. +Drives its OWN tab in its parent session's Mini Browser (a real Chromium +with that chat's cookies and signed-in sites, shown in the chat's browser +panel) to complete one interactive web task: searching inside a site, +clicking through results, filling a form, comparing offers. The browser +gives every caller (here: the sub-agent's own id) a separate tab, so several +browser agents can run in parallel without touching each other's pages. Deliberately NOT allowed: ``mini_browser_login``. A sub-agent browses pages the spawning agent has not vetted, so it never gets to type the user's saved @@ -29,9 +29,10 @@ SYSTEM_PROMPT = """\ You are a web-browsing sub-agent. You complete ONE web task by driving your -OWN tab in the user's Mini Browser: a real Chromium that keeps the user's -cookies and logins, and that the user may be watching live. Other agents -work in other tabs at the same time; never use theirs. +OWN tab in the Mini Browser of the chat that spawned you: a real Chromium +that keeps that chat's cookies and signed-in sites, and that the user may be +watching live in the chat's browser panel. Other agents work in other tabs +at the same time; never use theirs. You have no memory of past conversations and see nothing of the spawning agent's context beyond the query. diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index 86e5fd333..8538bfd91 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -422,6 +422,26 @@ async def clear(self, session_id: Optional[str] = None) -> None: } ) + async def close_ui_artifact(self, session_id: str, artifact_id: str) -> int: + """The user closed a Generative UI output: loaded and stored messages + keep only a closed marker for it. Returns how many stored messages + carried it.""" + from app.generative_ui import closed_artifact + + for message in self._messages: + artifact = message.ui_artifact + if ( + message.session_id == session_id + and artifact + and artifact.get("id") == artifact_id + ): + message.ui_artifact = closed_artifact(artifact) + if not self._storage: + return 0 + return await self.run_storage( + self._storage.close_ui_artifact, session_id, artifact_id + ) + def drop_session_messages(self, session_id: str) -> None: """Drop a session's messages from memory only (storage already cleared by the caller — e.g. the /clear command or session_clear handler).""" @@ -895,8 +915,9 @@ def __init__( # Latest build todo list per Agent App, replayed to reconnecting tabs. self._agent_app_todos: Dict[str, list] = {} self._metrics_subscribers: Set = set() - # Mini Browser page: its viewers, live view and requests. Constructing - # it starts nothing (no browser thread, no Chromium). + # Chat browser panels (every session's Mini Browser): their viewers, + # live views and requests. Constructing it starts nothing (no browser + # thread, no Chromium). try: from app.mini_browser.ws import MiniBrowserWS @@ -1590,6 +1611,9 @@ async def _handle_ws_message(self, data: Dict[str, Any], ws=None) -> None: elif msg_type == "session_clear": await self._handle_session_clear(data) + elif msg_type == "generative_ui_close": + await self._handle_generative_ui_close(data) + elif msg_type == "session_list": await self._handle_session_list(ws) @@ -1663,7 +1687,7 @@ async def _handle_ws_message(self, data: Dict[str, Any], ws=None) -> None: file_path = data.get("path", "") await self._handle_open_folder(file_path) - # Mini Browser page: live view, browser controls and the password + # Chat browser panels: live view, browser controls and the password # vault (app/mini_browser/ws.py answers every failure itself). elif isinstance(msg_type, str) and msg_type.startswith("mini_browser_"): mini_browser = getattr(self, "_mini_browser_ws", None) @@ -4450,6 +4474,34 @@ async def _handle_session_clear(self, data: Dict[str, Any]) -> None: except Exception as e: logger.error(f"[SESSION] Clear failed for {session_id}: {e}") + async def _handle_generative_ui_close(self, data: Dict[str, Any]) -> None: + """The user closed a Generative UI output: its code goes from the + chat storage and the session's events (a closed marker stays, so a + later revision still numbers on), and every client drops it.""" + session_id = data.get("sessionId") + artifact_id = data.get("artifactId") + if not ( + isinstance(session_id, str) + and session_id + and isinstance(artifact_id, str) + and artifact_id + ): + return + try: + await self._chat.close_ui_artifact(session_id, artifact_id) + await asyncio.to_thread( + self._controller.agent.close_ui_artifact, session_id, artifact_id + ) + except Exception as e: + logger.error(f"[GENERATIVE_UI] Close of {artifact_id} failed: {e}") + return + await self._broadcast( + { + "type": "generative_ui_closed", + "data": {"sessionId": session_id, "artifactId": artifact_id}, + } + ) + async def _handle_session_list(self, ws=None) -> None: """Send the current session list.""" message = { @@ -8584,17 +8636,29 @@ async def _send_to(self, ws, message: Dict[str, Any]) -> None: return await self._broadcast(message) - def _send_only_to(self, ws, message: Dict[str, Any]) -> bool: + # ── Outbound channels for the chat browser panels (app/mini_browser/ws.py, + # MiniBrowserAdapter): direct per-socket delivery, never a broadcast. ── + + def send_only_to(self, ws, message: Dict[str, Any]) -> bool: """Send to one connection only. Unlike ``_send_to`` there is no broadcast fallback: when that connection is gone the message is dropped, so replies meant for one tab (saved logins) never reach another. Returns whether it was queued.""" - channel = self._channels.get(ws) if ws is not None else None - if channel is None or channel.closed: + channel = self.client_channel(ws) + if channel is None: return False channel.send_json(_with_request_id(message)) return True + def client_channel(self, ws) -> Optional[ClientChannel]: + """The open outbound channel of ``ws``, or None when it is gone.""" + channel = self._channels.get(ws) if ws is not None else None + return None if channel is None or channel.closed else channel + + def client_channels(self) -> List[ClientChannel]: + """The open outbound channel of every connected socket.""" + return [channel for channel in self._channels.values() if not channel.closed] + async def _handle_whatsapp_start_qr(self, ws=None, force: bool = False) -> None: """Start a WhatsApp link flow and return the QR to the requesting connection only. ``force`` (explicit user click) bypasses the diff --git a/app/ui_layer/browser/frontend/src/App.tsx b/app/ui_layer/browser/frontend/src/App.tsx index d1e2aa578..b1a1c90f6 100644 --- a/app/ui_layer/browser/frontend/src/App.tsx +++ b/app/ui_layer/browser/frontend/src/App.tsx @@ -6,7 +6,6 @@ import { ChatPage } from './pages/Chat' import { DashboardPage } from './pages/Dashboard' import { MemoryPage } from './pages/Memory' import { ScreenPage } from './pages/Screen' -import { MiniBrowserPage } from './pages/MiniBrowser' import { WorkspacePage } from './pages/Workspace' import { SettingsPage } from './pages/Settings' import { OnboardingPage } from './pages/Onboarding' @@ -155,9 +154,6 @@ function App() { } /> } /> } /> - } /> - {/* The page's prototype name; keeps old links and bookmarks working. */} - } /> } /> } /> } /> diff --git a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx index 3ba10714a..2158eea7e 100644 --- a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx +++ b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx @@ -11,7 +11,8 @@ import { useToast } from '../../contexts/ToastContext' import { SlashCommandAutocomplete, AttachmentPreviewModal, PlaybookModal } from '../ui' import type { SlashCommandAutocompleteHandle } from '../ui' import { ChatMessageItem } from '../../pages/Chat/ChatMessage' -import { OutputWorkspace } from '../GenerativeUI/OutputWorkspace' +import { OutputWorkspace, type PanelLayout } from '../GenerativeUI/OutputWorkspace' +import { useChatBrowser } from '../MiniBrowser/useChatBrowser' import { TypingIndicatorRow } from '../../pages/Chat/TypingIndicator' import { ReasoningBlock, ActionBlock, ChunkHeaderRow } from '../activity/ActivityBlocks' import { normalizeActionName } from '../activity/actionNames' @@ -19,6 +20,7 @@ import { useAppDispatch, useAppSelector } from '../../store/hooks' import type { RootState } from '../../store' import { selectUiState } from '../../store/selectors/ui' import { setUiState } from '../../store/slices/uiSlice' +import { uiArtifactClosed } from '../../store/slices/messagesSlice' import { UI_STATE } from '../../store/uiState' import { usePersistedSet, usePersistedState } from '../../hooks' import { selectPendingPrefill, selectDraftText } from '../../store/selectors/chatInput' @@ -70,6 +72,9 @@ interface ChatProps { sessionId: string /** Optional placeholder text for the input */ placeholder?: string + /** Where the chat's panel (outputs, browser) opens: on its left, or above + * it ('stacked', for a narrow host such as an Agent App's chat panel). */ + panelLayout?: PanelLayout } // One RENDERED row. Activity entries are grouped into chunks (consecutive @@ -158,7 +163,7 @@ const formatDateDivider = (tsMs: number): string => { return formatDate(date, { year: 'numeric', month: 'long', day: 'numeric' }) } -export function Chat({ sessionId, placeholder }: ChatProps) { +export function Chat({ sessionId, placeholder, panelLayout = 'side' }: ChatProps) { const { t } = useTranslation(['chat', 'common']) const navigate = useNavigate() const { @@ -174,6 +179,7 @@ export function Chat({ sessionId, placeholder }: ChatProps) { enhancedPrompt, enhancePrompt, clearEnhancedPrompt, + closeGenerativeUI, } = useWebSocket() const connected = useAppSelector(selectConnected) const [lastSeenBySession] = usePersistedState(UI_STATE.chat.lastSeenMessageIds) @@ -183,6 +189,8 @@ export function Chat({ sessionId, placeholder }: ChatProps) { // session only exists after the backend answers the first send with // session_created (the context then navigates to /session/{id}). const isDraft = sessionId === 'new' + // The chat's own browser, in its panel (none for a draft: no session yet). + const browser = useChatBrowser(sessionId, !isDraft) const messages = useAppSelector(state => selectSessionMessages(state, sessionId)) const activity = useAppSelector(state => selectSessionActivity(state, sessionId)) @@ -330,6 +338,12 @@ export function Chat({ sessionId, placeholder }: ChatProps) { }, [displayRows]) const dispatch = useAppDispatch() + // Closing an output's tab disposes of it: gone from this chat at once, and + // from storage, the agent's events and every other window by the backend. + const closeOutput = useCallback((artifactId: string) => { + dispatch(uiArtifactClosed({ sessionId, artifactId })) + closeGenerativeUI(sessionId, artifactId) + }, [dispatch, sessionId, closeGenerativeUI]) // Composer draft: persisted in Redux keyed by sessionId (not local // component state), so it survives both switching sessions and // navigating away to another app tab and back — Chat.tsx unmounts in @@ -1256,7 +1270,7 @@ export function Chat({ sessionId, placeholder }: ChatProps) { } return ( - +
diff --git a/app/ui_layer/browser/frontend/src/components/GenerativeUI/GenerativeUI.module.css b/app/ui_layer/browser/frontend/src/components/GenerativeUI/GenerativeUI.module.css index ba9011d66..261b0e647 100644 --- a/app/ui_layer/browser/frontend/src/components/GenerativeUI/GenerativeUI.module.css +++ b/app/ui_layer/browser/frontend/src/components/GenerativeUI/GenerativeUI.module.css @@ -1,24 +1,25 @@ .artifact { display: flex; flex: 1; flex-direction: column; min-height: 0; min-width: 0; } -.toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: 30px 36px; align-items: center; gap: 8px; padding: 10px 16px; border-bottom: 1px solid var(--border-primary); background: var(--bg-primary); flex-shrink: 0; } -.toolbarTitle { min-width: 0; grid-column: 1; grid-row: 1; } -.previewControls, .devices, .actions { display: flex; align-items: center; gap: 4px; } -.previewControls { gap: 10px; grid-column: 1 / -1; grid-row: 2; justify-content: space-between; } -.devices { padding: 3px; border: 1px solid var(--border-primary); border-radius: 8px; background: var(--bg-secondary); gap: 2px; } -.toolbar button, .menu summary { display: flex; align-items: center; justify-content: center; gap: 6px; min-height: 28px; padding: 4px 8px; border: 0; border-radius: 5px; background: transparent; color: var(--text-secondary); font-size: 12px; cursor: pointer; white-space: nowrap; } -.devices button[aria-pressed=true] { background: var(--bg-primary); box-shadow: 0 1px 3px rgba(0,0,0,.12); color: var(--text-primary); } -.toolbar button:hover, .menu summary:hover { background: var(--bg-hover); color: var(--text-primary); } -.toolbar button:focus-visible, .menu summary:focus-visible, .resume:focus-visible, .error button:focus-visible { outline: 2px solid var(--text-secondary); outline-offset: 2px; } -.toolbarActions { display: flex; justify-content: flex-end; grid-column: 2; grid-row: 1; } -.canvas { flex: 1; display: flex; justify-content: center; min-height: 0; min-width: 0; padding: 16px; background: var(--bg-secondary); } -.preview { position: relative; display: flex; flex-direction: column; width: 100%; max-width: 100%; min-height: 0; overflow: hidden; border: 1px solid var(--border-primary); border-radius: 12px; background: var(--bg-primary); box-shadow: 0 4px 20px rgba(0,0,0,.035); transition: max-width 280ms cubic-bezier(.22, 1, .36, 1), border-radius 280ms ease, box-shadow 280ms ease; } -.phone { padding: 20px; } -.phone .preview { max-width: 390px; border-radius: 24px; box-shadow: 0 12px 36px rgba(0,0,0,.1); } +/* Reload and options: round icon buttons at the output tab strip's right end. */ +.controls { display: flex; align-items: center; gap: 2px; } +.controls > button, .menu summary { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: var(--radius-full); background: transparent; color: var(--text-secondary); cursor: pointer; } +.controls > button:hover, .menu summary:hover { background: var(--bg-selected-hover); color: var(--text-primary); } +.controls > button:focus-visible, .menu summary:focus-visible, .menuItems button:focus-visible, .resume:focus-visible, .error button:focus-visible { outline: 2px solid var(--text-secondary); outline-offset: 2px; } +/* The output is the pane: no frame or padding around it. On desktop it + fills the pane; a pane narrower than the 440px desktop canvas outputs are + built for shows the phone width (see the container query below). */ +.canvas { flex: 1; display: flex; justify-content: center; min-height: 0; min-width: 0; background: var(--bg-secondary); } +.preview { position: relative; display: flex; flex-direction: column; width: 100%; max-width: 100%; min-height: 0; overflow: hidden; background: var(--bg-primary); } .frame { flex: 1; display: block; border: 0; width: 100%; min-height: 0; height: 100%; } .menu { position: relative; } .menu summary { list-style: none; } .menu summary::-webkit-details-marker { display: none; } .menuItems { position: absolute; top: calc(100% + 9px); right: 0; z-index: 5; display: grid; gap: 3px; width: 240px; padding: 6px; border: 1px solid var(--border-primary); border-radius: 10px; background: var(--bg-elevated); box-shadow: var(--shadow-md); } -.menuItems button { justify-content: flex-start; padding: 9px 10px; } +.menuItems button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 9px 10px; border: 0; border-radius: 5px; background: transparent; color: var(--text-secondary); font-size: 12px; text-align: left; white-space: nowrap; cursor: pointer; } +.menuItems button:hover { background: var(--bg-hover); color: var(--text-primary); } +.versions { display: grid; gap: 1px; margin-top: 3px; padding-top: 6px; border-top: 1px solid var(--border-primary); } +.versionsLabel { padding: 2px 10px 4px; color: var(--text-tertiary); font-size: 11px; } +.versionCheck { display: grid; place-items: center; width: 15px; flex-shrink: 0; } +.menuItems button[aria-pressed=true] { color: var(--text-primary); } .resetConfirmation { padding: 8px; font-size: 13px; color: var(--text-primary); } .resetConfirmation p { margin: 6px 0 14px; line-height: 1.5; font-size: 12px; color: var(--text-secondary); } .resetConfirmation > div { display: flex; justify-content: flex-end; gap: 6px; } @@ -38,21 +39,15 @@ .error p { margin: 8px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; } .error button { flex-shrink: 0; padding: 5px 8px; font-size: 12px; } @keyframes spin { to { transform: rotate(360deg); } } -.canvas { transition: padding 280ms cubic-bezier(.22, 1, .36, 1); } -.toolbar button, .menu summary, .resume, .error button { transition: background-color 140ms ease, color 140ms ease, box-shadow 140ms ease; } +.controls > button, .menu summary, .menuItems button, .resume, .error button { transition: background-color 140ms ease, color 140ms ease, box-shadow 140ms ease; } .menu[open] .menuItems { animation: menuEnter 160ms cubic-bezier(.22, 1, .36, 1); transform-origin: top right; } .frameReady, .paused, .error, .resetConfirmation { animation: previewReveal 200ms ease-out; } @keyframes menuEnter { from { opacity: 0; transform: translateY(-5px) scale(.97); } to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes previewReveal { from { opacity: 0; } to { opacity: 1; } } @media (prefers-reduced-motion: reduce) { .loading svg, .menu[open] .menuItems, .frameReady, .paused, .error, .resetConfirmation { animation: none; } - .preview, .canvas, .toolbar button, .menu summary, .resume, .error button { transition: none; } + .controls > button, .menu summary, .menuItems button, .resume, .error button { transition: none; } } -@container output-pane (max-width: 640px) { - .toolbar { padding: 10px 16px; } - .canvas { padding: 10px; } - .phone { padding: 12px; } -} -@media (max-width: 768px) { - .toolbarTitle { padding-left: 36px; } +@container output-pane (max-width: 439.98px) { + .preview { max-width: 390px; } } diff --git a/app/ui_layer/browser/frontend/src/components/GenerativeUI/GenerativeUI.tsx b/app/ui_layer/browser/frontend/src/components/GenerativeUI/GenerativeUI.tsx index 1644c3396..6227953f0 100644 --- a/app/ui_layer/browser/frontend/src/components/GenerativeUI/GenerativeUI.tsx +++ b/app/ui_layer/browser/frontend/src/components/GenerativeUI/GenerativeUI.tsx @@ -1,15 +1,30 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' -import { Monitor, Smartphone, MoreHorizontal, RotateCcw, RefreshCw, Pause, Play, Loader2, AlertCircle } from 'lucide-react' +import { Check, MoreHorizontal, RotateCcw, RefreshCw, Pause, Play, Loader2, AlertCircle } from 'lucide-react' import type { GenerativeUIArtifact } from '../../types' import { usePersistedState } from '../../hooks' import { UI_STATE } from '../../store/uiState' import { BRIDGE_CHANNEL, buildDocument, readHostTheme, validState } from './runtime' import styles from './GenerativeUI.module.css' -interface Props { artifact: GenerativeUIArtifact; sessionId: string; toolbarTitle?: ReactNode; toolbarActions?: ReactNode } +/** One revision of an output, in the options menu. */ +export interface OutputVersion { key: string; label: string; current: boolean } -export function GenerativeUI({ artifact, sessionId, toolbarTitle, toolbarActions }: Props) { +interface Props { + artifact: GenerativeUIArtifact + sessionId: string + /** Where the preview's controls (reload, options) go: the output tab + * strip's right end. Nothing shows until it exists. */ + controlsTarget: HTMLElement | null + /** The output's revisions (the options menu lists them from two on). */ + versions: OutputVersion[] + onSelectVersion(key: string): void +} + +/** One output running in its sandbox, with its reload and options controls + * in the tab strip above it. */ +export function GenerativeUI({ artifact, sessionId, controlsTarget, versions, onSelectVersion }: Props) { const { t } = useTranslation('chat') const timeoutText = useRef('') timeoutText.current = t('generativeUI.timeout') @@ -19,7 +34,6 @@ export function GenerativeUI({ artifact, sessionId, toolbarTitle, toolbarActions const frame = useRef(null) const options = useRef(null) const [attempt, setAttempt] = useState(0) - const [viewport, setViewport] = useState<'desktop' | 'phone'>('desktop') const [paused, setPaused] = useState(false) const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading') const [error, setError] = useState('') @@ -88,7 +102,7 @@ export function GenerativeUI({ artifact, sessionId, toolbarTitle, toolbarActions setAttempt(value => value + 1) } const preview = ( -
+
{paused ?

{t('generativeUI.pausedTitle')}

{t('generativeUI.paused')}

@@ -106,14 +120,10 @@ export function GenerativeUI({ artifact, sessionId, toolbarTitle, toolbarActions }
) - const toolbar =
-
{toolbarTitle}
-
-
- - -
-
+ const closeMenu = () => { + if (options.current) options.current.open = false + } + const controls =
{ if (!options.current?.open) setConfirmReset(false) }} onKeyDown={event => { if (event.key === 'Escape' && options.current) { event.preventDefault(); event.stopPropagation(); options.current.open = false; options.current.querySelector('summary')?.focus() } }}> @@ -122,14 +132,20 @@ export function GenerativeUI({ artifact, sessionId, toolbarTitle, toolbarActions {t('generativeUI.resetTitle')}

{t('generativeUI.resetDescription')}

: <> - {!paused && } + {!paused && } + {versions.length > 1 &&
+ {t('generativeUI.versions')} + {versions.map(version => )} +
} }
-
-
{toolbarActions}
- - return
{toolbar}{preview}
+ return
+ {controlsTarget && createPortal(controls, controlsTarget)} + {preview} +
} diff --git a/app/ui_layer/browser/frontend/src/components/GenerativeUI/OutputWorkspace.module.css b/app/ui_layer/browser/frontend/src/components/GenerativeUI/OutputWorkspace.module.css index 9eb3e7769..01bcbb5db 100644 --- a/app/ui_layer/browser/frontend/src/components/GenerativeUI/OutputWorkspace.module.css +++ b/app/ui_layer/browser/frontend/src/components/GenerativeUI/OutputWorkspace.module.css @@ -1,52 +1,57 @@ -.workspace { container: output-workspace / inline-size; display: grid; grid-template-columns: minmax(0, 1fr) 0px 0px; height: 100%; width: 100%; min-width: 0; min-height: 0; overflow: hidden; } -.conversation { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; transition: opacity 200ms ease, transform 300ms cubic-bezier(.22, 1, .36, 1); } -.withOutput { grid-template-columns: minmax(0, var(--conversation-width)) 9px minmax(0, 1fr); } -.withOutput .conversation > * { min-width: var(--conversation-width); } +/* The panel sits on the chat's left (stacked: above it), the seam between + them. Closed, the panel and seam tracks are zero and the chat fills the + workspace; opening and closing animate these tracks. */ +.workspace { display: grid; grid-template-areas: "pane seam chat"; grid-template-columns: 0px 0px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); height: 100%; width: 100%; min-width: 0; min-height: 0; overflow: hidden; } +.open { grid-template-columns: minmax(0, 1fr) 1px minmax(0, var(--conversation-width)); } +.stacked { grid-template-areas: "pane" "seam" "chat"; grid-template-columns: minmax(0, 1fr); grid-template-rows: 0px 0px minmax(0, 1fr); } +.stacked.open { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, var(--panel-share)) 1px minmax(0, var(--chat-share)); } +.conversation { grid-area: chat; position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; } .chat { flex: 1; min-height: 0; } -.conversationBar { display: flex; align-items: center; justify-content: space-between; gap: 12px; height: 56px; flex-shrink: 0; padding: 0 20px; border-bottom: 1px solid var(--border-primary); color: var(--text-secondary); font-size: 13px; } -.conversationBar button { display: flex; align-items: center; gap: 7px; border: 0; border-radius: 7px; background: transparent; color: var(--text-primary); padding: 6px 9px; font: inherit; cursor: pointer; } -.outputCount { display: grid; place-items: center; min-width: 19px; height: 19px; padding: 0 5px; border-radius: 5px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 11px; } -.output { container: output-pane / inline-size; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; background: var(--bg-primary); } -.title { display: flex; align-items: center; gap: 9px; min-width: 0; } -.title > svg { flex-shrink: 0; color: var(--text-tertiary); } -.title h2 { margin: 0; color: var(--text-primary); font-size: 13px; line-height: 1.4; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.headerActions { display: flex; align-items: center; gap: 3px; flex-shrink: 0; } -.headerActions select { max-width: 140px; padding: 5px 7px; border: 1px solid var(--border-primary); border-radius: 7px; color: var(--text-secondary); background: var(--bg-primary); font: inherit; font-size: 12px; } -.headerActions button { display: grid; place-items: center; width: 30px; height: 30px; border: 0; border-radius: 7px; color: var(--text-secondary); background: transparent; cursor: pointer; } -.headerActions button:hover, .conversationBar button:hover { color: var(--text-primary); background: var(--bg-hover); } -.resizeHandle { position: relative; display: grid; place-items: center; flex: 0 0 9px; color: transparent; cursor: col-resize; touch-action: none; outline-offset: -2px; } -.resizeHandle::before { content: ''; position: absolute; inset: 0; width: 1px; margin: auto; background: var(--border-primary); } -.resizeHandle svg { position: relative; padding: 8px 0; height: 40px; background: var(--bg-primary); } -.resizeHandle:hover, .resizeHandle:focus-visible, .resizing .resizeHandle { color: var(--text-secondary); background: var(--bg-hover); } -.resizing { user-select: none; cursor: col-resize; } +.pane { grid-area: pane; container: output-pane / inline-size; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; background: var(--bg-primary); } + +/* The panel's buttons: round icons floating at the chat's top left. */ +.switcher { position: absolute; top: 10px; left: 12px; z-index: 3; display: flex; gap: 6px; } +/* Same look as the chat's scroll-to-latest button (Chat.module.css), without its shadow. */ +.switcher button { position: relative; display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 1px solid var(--border-primary); border-radius: 50%; background: var(--bg-secondary); color: var(--text-secondary); cursor: pointer; transition: background-color 150ms ease, border-color 150ms ease, color 150ms ease; } +.switcher button:hover { background: var(--bg-tertiary); color: var(--text-primary); } +.switcher button[aria-expanded=true] { border-color: var(--text-primary); background: var(--text-primary); color: var(--bg-primary); } +/* The chat's browser runs; it pulses while an agent is browsing in it. */ +.browserDot { position: absolute; top: 1px; right: 1px; width: 8px; height: 8px; border-radius: 50%; background: var(--text-tertiary); box-shadow: 0 0 0 2px var(--bg-primary); } +.browserDotBusy { background: var(--color-success); animation: browserDotPulse 1.6s ease-in-out infinite; } +@keyframes browserDotPulse { 50% { opacity: .35; } } + +/* The seam, as on an Agent App: a hairline divider with a pill grip on it. + Drag to resize, click to close. A transparent ::after widens the grab + zone; the pill is purely visual. */ +.seam { grid-area: seam; position: relative; z-index: 2; background: var(--border-primary); cursor: col-resize; touch-action: none; outline: none; } +.seam::after { content: ''; position: absolute; top: 0; bottom: 0; left: -4px; width: 9px; } +.grip { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 5px; height: 58px; border-radius: var(--radius-full); background: var(--text-muted); pointer-events: none; transition: background var(--transition-fast), height var(--transition-fast), width var(--transition-fast); } +.seam:hover .grip, .seam:focus-visible .grip, .resizing .grip { background: var(--text-secondary); width: 7px; } +.seam:focus-visible .grip { outline: 2px solid var(--text-secondary); outline-offset: 2px; } +.stacked .seam { cursor: row-resize; } +.stacked .seam::after { top: -4px; bottom: auto; left: 0; right: 0; width: auto; height: 9px; } +.stacked .grip { width: 42px; height: 4px; } +.stacked .seam:hover .grip, .stacked .seam:focus-visible .grip, .stacked.resizing .grip { width: 60px; height: 5px; } +.resizing { cursor: col-resize; user-select: none; } +.stacked.resizing { cursor: row-resize; } .resizing iframe { pointer-events: none; } -.fullscreen { grid-template-columns: 0px 0px minmax(0, 1fr); } -.fullscreen .conversation { opacity: 0; transform: translateX(-24px); pointer-events: none; } -.fullscreen .resizeHandle, .closing .resizeHandle { visibility: hidden; } -.closing { grid-template-columns: minmax(0, 1fr) 0px 0px; } +.closing .seam { visibility: hidden; } + .reference { display: flex; align-items: center; gap: 12px; width: 100%; min-width: 0; padding: 14px; border: 1px solid var(--border-primary); border-radius: 12px; color: var(--text-primary); background: var(--bg-secondary); text-align: left; cursor: pointer; transition: border-color .15s, background .15s; } .reference:hover { border-color: var(--border-hover); background: var(--bg-hover); } .reference > svg { flex-shrink: 0; color: var(--text-tertiary); } +/* A closed output: listed, nothing to open. */ +.referenceClosed { cursor: default; opacity: .7; } +.referenceClosed:hover { border-color: var(--border-primary); background: var(--bg-secondary); } .referenceIcon { display: grid; place-items: center; flex-shrink: 0; width: 38px; height: 42px; border: 1px solid var(--border-primary); border-radius: 8px; background: var(--bg-primary); color: var(--text-secondary); } .referenceText { display: flex; flex: 1; flex-direction: column; gap: 5px; min-width: 0; } .referenceText strong { font-size: 13px; font-weight: 500; line-height: 1.4; overflow-wrap: anywhere; } .referenceText > span { color: var(--text-secondary); font-size: 11px; } -.workspace button:focus-visible, .workspace select:focus-visible, .resizeHandle:focus-visible { outline: 2px solid var(--text-secondary); outline-offset: 2px; } -.headerActions button, .conversationBar button, .resizeHandle { transition: background-color 150ms ease, color 150ms ease; } +.workspace button:focus-visible, .workspace select:focus-visible { outline: 2px solid var(--text-secondary); outline-offset: 2px; } .reference > svg { transition: transform 180ms cubic-bezier(.22, 1, .36, 1); } .reference:hover > svg { transform: translate(2px, -2px); } @media (prefers-reduced-motion: reduce) { - .conversation, .workspace button, .workspace select, .resizeHandle, .reference > svg { transition: none; } + .workspace button, .workspace select, .grip, .reference > svg { transition: none; } + .browserDotBusy { animation: none; } .reference:hover > svg { transform: none; } } -@container output-workspace (max-width: 859px) { - .withOutput:not(.closing) { grid-template-columns: 0px 0px minmax(0, 1fr); } - .withOutput:not(.closing) .conversation { opacity: 0; transform: translateX(-24px); pointer-events: none; } - .resizeHandle { visibility: hidden; } -} -@container output-pane (max-width: 520px) { - .headerActions select { max-width: 105px; } -} -@media (max-width: 768px) { - .conversationBar { padding-left: 52px; } -} diff --git a/app/ui_layer/browser/frontend/src/components/GenerativeUI/OutputWorkspace.tsx b/app/ui_layer/browser/frontend/src/components/GenerativeUI/OutputWorkspace.tsx index 7c662f492..1014c861f 100644 --- a/app/ui_layer/browser/frontend/src/components/GenerativeUI/OutputWorkspace.tsx +++ b/app/ui_layer/browser/frontend/src/components/GenerativeUI/OutputWorkspace.tsx @@ -1,18 +1,29 @@ -import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react' +import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react' +import { flushSync } from 'react-dom' import { useTranslation } from 'react-i18next' -import { ArrowUpRight, GripVertical, Layers3, Maximize2, Minimize2, PanelRightClose, PanelRightOpen } from 'lucide-react' -import type { ChatMessage, GenerativeUIArtifact } from '../../types' +import { ArrowUpRight, Globe, Layers3 } from 'lucide-react' +import type { ChatMessage, ClosedGenerativeUIArtifact, GenerativeUIArtifact } from '../../types' import { usePersistedState } from '../../hooks' +import { useAppDispatch } from '../../store/hooks' +import { resetUiState } from '../../store/slices/uiSlice' import { UI_STATE } from '../../store/uiState' -import { GenerativeUI } from './GenerativeUI' +import { ChromeTabs } from '../ui/ChromeTabs' +import { GenerativeUI, type OutputVersion } from './GenerativeUI' import styles from './OutputWorkspace.module.css' const OutputContext = createContext<((artifact: GenerativeUIArtifact) => void) | null>(null) -/** A small reference in the conversation; generated code only runs in the output pane. */ -export function OutputReference({ artifact }: { artifact: GenerativeUIArtifact }) { +/** A small reference in the conversation; generated code only runs in the + * output pane. A closed output stays listed, without anything to open. */ +export function OutputReference({ artifact }: { artifact: GenerativeUIArtifact | ClosedGenerativeUIArtifact }) { const open = useContext(OutputContext) const { t } = useTranslation('chat') + if (artifact.closed) { + return
+ + {artifact.title}{t('generativeUI.closed')} +
+ } return } -export function OutputWorkspace({ sessionId, messages, children }: { sessionId: string; messages: ChatMessage[]; children: ReactNode }) { +/** The chat's own browser as the panel's second mode. */ +export interface BrowserPanelMode { + /** The chat's browser runs. */ + running: boolean + /** An agent is browsing in it right now. */ + browsing: boolean + /** The browser pane. */ + render(): ReactNode +} + +/** Where the panel sits: on the chat's left, or above it (an Agent App's + * narrow chat panel). A workspace too narrow for both side by side stacks + * either way. */ +export type PanelLayout = 'side' | 'stacked' + +type Exiting = + | { sessionId: string; kind: 'output'; artifact: GenerativeUIArtifact } + | { sessionId: string; kind: 'browser' } + +interface Tracks { columns: string; rows: string } + +// Side by side, the chat keeps its width (like an Agent App's chat panel) +// and the panel takes the rest. Dragging the seam to within +// PANEL_COLLAPSE_WIDTH of the panel's far edge snaps the panel shut. +const CHAT_MIN_WIDTH = 320 +const PANEL_MIN_WIDTH = 440 +const PANEL_COLLAPSE_WIDTH = 400 +const SEAM_WIDTH = 1 +// Narrower than this, the panel stacks above the chat. +const SIDE_BY_SIDE_MIN_WIDTH = 860 +// Stacked, the panel's share of the height; dragging it below +// STACKED_COLLAPSE_SHARE snaps it shut. +const STACKED_MIN_SHARE = 0.2 +const STACKED_MAX_SHARE = 0.75 +const STACKED_COLLAPSE_SHARE = 0.12 +// Movement that turns a press on the seam into a drag (else it is a click). +const DRAG_THRESHOLD = 4 +const KEY_STEP_WIDTH = 32 +const KEY_STEP_SHARE = 0.04 + +const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)) +const readTracks = (element: HTMLElement): Tracks => { + const style = getComputedStyle(element) + return { columns: style.gridTemplateColumns, rows: style.gridTemplateRows } +} + +/** + * The chat with its panel. The panel shows one thing at a time: the chat's + * Generative UI outputs, or (with `browser`) the chat's own browser. The + * round buttons at the chat's top left switch between them; pressing the + * one on show closes the panel. A new output opens the panel on it; the + * chat's browser takes it over when the agent starts browsing (set by the + * browser's owner through the same per-session panel mode). Opening and + * closing animate the shared grid tracks. The seam between panel and chat + * works like an Agent App's: drag it to resize, drag it to the panel's edge + * or click it to close the panel. + */ +export function OutputWorkspace({ sessionId, messages, browser, layout = 'side', onCloseOutput, children }: { + sessionId: string + messages: ChatMessage[] + /** The chat's browser; omitted where there is none (a draft chat). */ + browser?: BrowserPanelMode + layout?: PanelLayout + /** The user closed an output (its tab's ×): it goes for good (its code + * and saved progress). */ + onCloseOutput(artifactId: string): void + children: ReactNode +}) { const { t } = useTranslation('chat') + const dispatch = useAppDispatch() + const [mode, setMode] = usePersistedState(UI_STATE.chat.panelMode(sessionId)) const [selection, setSelection] = usePersistedState(UI_STATE.chat.outputSelection(sessionId)) - const [splitRatio, setSplitRatio] = usePersistedState(UI_STATE.chat.outputSplitRatio) - const [dragRatio, setDragRatio] = useState(null) + const [chatWidth, setChatWidth] = usePersistedState(UI_STATE.chat.conversationWidth) + const [stackedShare, setStackedShare] = usePersistedState(UI_STATE.chat.stackedPanelShare) + // While the seam is held: the size it shows, uncommitted (the chat's width + // side by side, the panel's share stacked). + const [resizing, setResizing] = useState(false) + const [dragSize, setDragSize] = useState(null) const [workspaceWidth, setWorkspaceWidth] = useState(0) - const [fullscreen, setFullscreen] = useState(false) - const [closingOutput, setClosingOutput] = useState<{ sessionId: string; artifact: GenerativeUIArtifact } | null>(null) + const [closing, setClosing] = useState(null) const workspace = useRef(null) + const pane = useRef(null) + const seam = useRef(null) + const outputsButton = useRef(null) + const browserButton = useRef(null) const layoutAnimation = useRef(null) - const previousColumns = useRef(null) - const dragging = useRef(false) - const closeButton = useRef(null) - const showButton = useRef(null) - const previousNewest = useRef<{ sessionId: string; key?: string }>({ sessionId }) + const previousTracks = useRef(null) + // The grid as the last commit left it: the starting point when the panel + // opens without a user action here (the chat's browser taking the panel + // over when its agent starts browsing). + const committed = useRef<{ tracks: Tracks | null; isOpen: boolean; stacked: boolean }>({ tracks: null, isOpen: false, stacked: false }) + const press = useRef<{ x: number; y: number; moved: boolean } | null>(null) + // Focus was in the panel or on the seam when it closed: give it to the + // panel's button once the panel is gone. + const restoreFocus = useRef(false) + // The output revisions already known: an incoming one is new to this set + // (closing the newest output must not count as a new one arriving). + const knownOutputs = useRef<{ sessionId: string; keys: Set }>({ sessionId, keys: new Set() }) + const [controlsTarget, setControlsTarget] = useState(null) + // Every revision still open (a closed output keeps only a marker). const outputs = useMemo(() => { const revisions = new Map() for (const message of messages) { - if (message.style === 'agent' && message.uiArtifact) { - const artifact = message.uiArtifact + const artifact = message.uiArtifact + if (message.style === 'agent' && artifact && !artifact.closed) { revisions.set(`${artifact.id}:${artifact.revision}`, artifact) } } return Array.from(revisions.values()) }, [messages]) + // One tab per output, in order of appearance, on its newest revision. + const tabs = useMemo(() => { + const latest = new Map() + for (const artifact of outputs) { + const current = latest.get(artifact.id) + if (!current || artifact.revision > current.revision) latest.set(artifact.id, artifact) + } + return Array.from(latest.values()) + }, [outputs]) const newest = outputs[outputs.length - 1] const newestKey = newest ? `${newest.id}:${newest.revision}` : undefined - const exiting = closingOutput?.sessionId === sessionId ? closingOutput.artifact : null - const selected = exiting ?? outputs.find(artifact => `${artifact.id}:${artifact.revision}` === selection) ?? newest - const isOpen = !!selected && (selection !== 'closed' || !!exiting) - const compact = workspaceWidth > 0 && workspaceWidth < 860 + const exiting = closing?.sessionId === sessionId ? closing : null + const selected = (exiting?.kind === 'output' ? exiting.artifact : null) + ?? outputs.find(artifact => `${artifact.id}:${artifact.revision}` === selection) ?? newest + const showBrowser = !!browser && (exiting ? exiting.kind === 'browser' : mode === 'browser') + const showOutput = !showBrowser && !!selected + && (exiting ? exiting.kind === 'output' : mode === 'output' || mode === 'auto') + const isOpen = showBrowser || showOutput + const stacked = layout === 'stacked' || (workspaceWidth > 0 && workspaceWidth < SIDE_BY_SIDE_MIN_WIDTH) const captureLayout = useCallback(() => { - if (workspace.current) previousColumns.current = getComputedStyle(workspace.current).gridTemplateColumns + if (workspace.current) previousTracks.current = readTracks(workspace.current) layoutAnimation.current?.cancel() layoutAnimation.current = null }, []) - // Incoming outputs open automatically. Prepending older history or a language - // synchronization must not reopen a pane that the user deliberately closed. + // Incoming outputs open automatically (over the browser too). Prepending + // older history or a language synchronization must not reopen a pane that + // the user deliberately closed. useEffect(() => { - const previous = previousNewest.current - if (previous.sessionId === sessionId && newestKey && previous.key && previous.key !== newestKey) { + const known = knownOutputs.current + if (known.sessionId === sessionId && newestKey && known.keys.size > 0 && !known.keys.has(newestKey)) { captureLayout() - setClosingOutput(null) + setClosing(null) setSelection(newestKey) + setMode('output') } - previousNewest.current = { sessionId, key: newestKey } - }, [sessionId, newestKey, setSelection, captureLayout]) - useEffect(() => { setFullscreen(false); setClosingOutput(null) }, [sessionId]) - useLayoutEffect(() => () => { layoutAnimation.current?.cancel(); layoutAnimation.current = null; previousColumns.current = null }, [sessionId]) + const keys = known.sessionId === sessionId ? known.keys : new Set() + for (const artifact of outputs) keys.add(`${artifact.id}:${artifact.revision}`) + knownOutputs.current = { sessionId, keys } + }, [sessionId, outputs, newestKey, setSelection, setMode, captureLayout]) + useEffect(() => { setClosing(null) }, [sessionId]) + // A drag never carries over to another chat or layout. + useEffect(() => { + press.current = null + setResizing(false) + setDragSize(null) + }, [sessionId, stacked]) + // The width decides side by side or stacked before the first paint. + useLayoutEffect(() => { + const element = workspace.current + if (!element) return + setWorkspaceWidth(element.getBoundingClientRect().width) + const observer = new ResizeObserver(([entry]) => setWorkspaceWidth(entry.contentRect.width)) + observer.observe(element) + return () => observer.disconnect() + }, []) + useLayoutEffect(() => () => { + layoutAnimation.current?.cancel() + layoutAnimation.current = null + previousTracks.current = null + committed.current = { tracks: null, isOpen: false, stacked: false } + }, [sessionId]) useLayoutEffect(() => { const element = workspace.current - const previous = previousColumns.current - previousColumns.current = null - if (!element || !previous) return - const next = getComputedStyle(element).gridTemplateColumns - if (previous === next || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + const last = committed.current + const captured = previousTracks.current + previousTracks.current = null + if (!element) return + // Captured by the action that changed the layout here, else where the + // last commit left the grid (an opening from outside: the browser). + const previous = captured ?? (last.isOpen !== isOpen && last.stacked === stacked ? last.tracks : null) + if (captured === null && previous !== null) { + // Read the new layout itself, not a frame of a motion still running. + layoutAnimation.current?.cancel() + layoutAnimation.current = null + } + const next = readTracks(element) + committed.current = { tracks: next, isOpen, stacked } + if (!previous) return + const from: Keyframe = {} + const to: Keyframe = {} + if (previous.columns !== next.columns) { from.gridTemplateColumns = previous.columns; to.gridTemplateColumns = next.columns } + if (previous.rows !== next.rows) { from.gridTemplateRows = previous.rows; to.gridTemplateRows = next.rows } + if (Object.keys(to).length === 0 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { if (exiting) finishClose() return } - // Animate the shared tracks, so chat, divider, pane and toolbar travel - // together. No screenshot overlays, document recreation or text scaling. - const animation = element.animate([ - { gridTemplateColumns: previous }, { gridTemplateColumns: next }, - ], { duration: 300, easing: 'cubic-bezier(.22, 1, .36, 1)' }) + // Animate the shared tracks, so chat, seam and pane travel together. No + // screenshot overlays, document recreation or text scaling. + const animation = element.animate([from, to], { duration: 300, easing: 'cubic-bezier(.22, 1, .36, 1)' }) layoutAnimation.current = animation void animation.finished.then(() => { if (layoutAnimation.current !== animation) return layoutAnimation.current = null if (exiting) finishClose() }).catch(() => { /* Reversing direction or switching sessions cancels motion. */ }) - }, [isOpen, fullscreen, exiting]) - useLayoutEffect(() => { - const element = workspace.current - if (!element) return - const observer = new ResizeObserver(([entry]) => setWorkspaceWidth(entry.contentRect.width)) - observer.observe(element) - return () => observer.disconnect() - }, []) + }, [isOpen, exiting, stacked]) - const minWidth = 320 - const maxWidth = Math.max(minWidth, Math.min(640, workspaceWidth - 440 - 9)) - const conversationWidth = Math.max(minWidth, Math.min(maxWidth, workspaceWidth * (dragRatio ?? splitRatio))) - const resize = (clientX: number) => { + // Side by side: the chat's width, leaving the panel its minimum. + const chatMaxWidth = Math.max(CHAT_MIN_WIDTH, workspaceWidth - PANEL_MIN_WIDTH - SEAM_WIDTH) + const conversationWidth = clamp(stacked ? chatWidth : dragSize ?? chatWidth, CHAT_MIN_WIDTH, chatMaxWidth) + const panelShare = clamp(stacked ? dragSize ?? stackedShare : stackedShare, STACKED_MIN_SHARE, STACKED_MAX_SHARE) + // The seam's value is the panel's share of the workspace, in percent. + const percent = (width: number) => Math.round(width / (workspaceWidth || 1) * 100) + const seamValue = stacked + ? { now: Math.round(panelShare * 100), min: Math.round(STACKED_MIN_SHARE * 100), max: Math.round(STACKED_MAX_SHARE * 100) } + : { now: percent(workspaceWidth - conversationWidth - SEAM_WIDTH), min: percent(workspaceWidth - chatMaxWidth - SEAM_WIDTH), max: percent(workspaceWidth - CHAT_MIN_WIDTH - SEAM_WIDTH) } + // The size the pointer asks for, or 'close' within the panel's collapse zone. + const sizeAt = (clientX: number, clientY: number): number | 'close' | null => { const bounds = workspace.current?.getBoundingClientRect() - if (!bounds?.width) return splitRatio - return Math.max(minWidth, Math.min(maxWidth, clientX - bounds.left)) / bounds.width + if (!bounds?.width || !bounds.height) return null + if (stacked) { + const share = (clientY - bounds.top) / bounds.height + return share < STACKED_COLLAPSE_SHARE ? 'close' : clamp(share, STACKED_MIN_SHARE, STACKED_MAX_SHARE) + } + if (clientX - bounds.left < PANEL_COLLAPSE_WIDTH) return 'close' + return clamp(bounds.right - clientX, CHAT_MIN_WIDTH, Math.max(CHAT_MIN_WIDTH, bounds.width - PANEL_MIN_WIDTH - SEAM_WIDTH)) } + const commitSize = (size: number) => (stacked ? setStackedShare(size) : setChatWidth(size)) const open = (artifact: GenerativeUIArtifact) => { - captureLayout() - setClosingOutput(null) + if (!isOpen) captureLayout() + setClosing(null) setSelection(`${artifact.id}:${artifact.revision}`) - requestAnimationFrame(() => closeButton.current?.focus({ preventScroll: true })) + setMode('output') + } + const openBrowser = () => { + if (!isOpen) captureLayout() + setClosing(null) + setMode('browser') + } + const selectOutput = (id: string) => { + const tab = tabs.find(item => item.id === id) + if (tab) setSelection(`${tab.id}:${tab.revision}`) + } + // Closing an output disposes of it: its saved progress here, then its code + // everywhere (onCloseOutput). The tab next to it takes its place; the last + // one closes the panel. + const closeOutput = (id: string) => { + dispatch(resetUiState(UI_STATE.chat.artifactState(JSON.stringify([sessionId, id])))) + if (selected?.id === id) { + const remaining = tabs.filter(tab => tab.id !== id) + if (remaining.length === 0) { + close() + } else { + const next = remaining[Math.min(tabs.findIndex(tab => tab.id === id), remaining.length - 1)] + setSelection(`${next.id}:${next.revision}`) + } + } + onCloseOutput(id) + } + const finishClose = () => { + const opener = closing?.kind === 'browser' ? browserButton : outputsButton + setClosing(null) + if (!restoreFocus.current) return + restoreFocus.current = false + requestAnimationFrame(() => opener.current?.focus({ preventScroll: true })) } - const finishClose = () => { setClosingOutput(null); setFullscreen(false); requestAnimationFrame(() => showButton.current?.focus({ preventScroll: true })) } const close = () => { - if (exiting) return + if (exiting || !isOpen) return + const active = document.activeElement + restoreFocus.current = !!active && (active === seam.current || !!pane.current?.contains(active)) captureLayout() // Persist intent immediately; only the visible pane waits for the exit. // Switching chats during that exit must not discard the close choice. - if (selected) setClosingOutput({ sessionId, artifact: selected }) - setSelection('closed') - setFullscreen(false) + if (showBrowser) setClosing({ sessionId, kind: 'browser' }) + else if (selected) setClosing({ sessionId, kind: 'output', artifact: selected }) + setMode('closed') } - const toggleFullscreen = () => { - captureLayout() - setFullscreen(value => !value) + const endPress = () => { + press.current = null + setResizing(false) + setDragSize(null) + } + const onSeamPointerDown = (event: PointerEvent) => { + if (event.button !== 0) return + event.preventDefault() + layoutAnimation.current?.cancel() + layoutAnimation.current = null + event.currentTarget.focus({ preventScroll: true }) + event.currentTarget.setPointerCapture(event.pointerId) + press.current = { x: event.clientX, y: event.clientY, moved: false } + setResizing(true) + } + const onSeamPointerMove = (event: PointerEvent) => { + const current = press.current + if (!current) return + if (!current.moved && Math.abs(event.clientX - current.x) <= DRAG_THRESHOLD && Math.abs(event.clientY - current.y) <= DRAG_THRESHOLD) return + current.moved = true + const size = sizeAt(event.clientX, event.clientY) + if (size === 'close') { + // A pointermove is a continuous event: React would render the store's + // update (the panel mode) before this component's (the exiting pane), + // unmounting the pane before it could animate shut. One render. + flushSync(() => { + endPress() + close() + }) + } else if (size !== null) { + setDragSize(size) + } + } + const onSeamPointerUp = (event: PointerEvent) => { + const current = press.current + if (!current) return + endPress() + // A press without a drag is a click: it closes the panel. + if (!current.moved) { close(); return } + const size = sizeAt(event.clientX, event.clientY) + if (size === 'close') close() + else if (size !== null) commitSize(size) } + // Arrow keys move the seam, Home and End take the panel to its smallest + // and largest, Enter or Space closes it. + const onSeamKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + close() + return + } + if (stacked) { + let share = panelShare + if (event.key === 'ArrowUp') share -= KEY_STEP_SHARE + else if (event.key === 'ArrowDown') share += KEY_STEP_SHARE + else if (event.key === 'Home') share = STACKED_MIN_SHARE + else if (event.key === 'End') share = STACKED_MAX_SHARE + else return + event.preventDefault() + setStackedShare(clamp(share, STACKED_MIN_SHARE, STACKED_MAX_SHARE)) + return + } + let width = conversationWidth + if (event.key === 'ArrowLeft') width += KEY_STEP_WIDTH + else if (event.key === 'ArrowRight') width -= KEY_STEP_WIDTH + else if (event.key === 'Home') width = chatMaxWidth + else if (event.key === 'End') width = CHAT_MIN_WIDTH + else return + event.preventDefault() + setChatWidth(clamp(width, CHAT_MIN_WIDTH, chatMaxWidth)) + } + + const sizes = { + '--conversation-width': `${conversationWidth}px`, + '--panel-share': `${panelShare}fr`, + '--chat-share': `${1 - panelShare}fr`, + } as CSSProperties + const exitingProps = exiting ? { 'aria-hidden': true, inert: '' } : {} + // While a closed output's pane slides away, its tab stays with it. + const shownTabs = exiting?.kind === 'output' && !tabs.some(tab => tab.id === exiting.artifact.id) + ? [...tabs, exiting.artifact] + : tabs + const versions: OutputVersion[] = selected + ? outputs + .filter(artifact => artifact.id === selected.id) + .sort((a, b) => a.revision - b.revision) + .map(artifact => ({ + key: `${artifact.id}:${artifact.revision}`, + label: t('generativeUI.version', { revision: artifact.revision }), + current: artifact.revision === selected.revision, + })) + : [] return -
-
- {outputs.length > 0 &&
- {t('generativeUI.conversation')} - +
+ {isOpen && <> + {showBrowser && browser ?
+ {browser.render()} +
: selected &&
+ ({ + id: artifact.id, + title: artifact.title, + icon: , + closeLabel: t('generativeUI.closeNamed', { title: artifact.title }), + }))} + selectedId={selected.id} + onSelect={selectOutput} + onClose={closeOutput} + closeTitle={t('generativeUI.close')} + actionsRef={setControlsTarget} + /> + +
} +
+
+ } +
+ {(outputs.length > 0 || browser) &&
+ {outputs.length > 0 && } + {browser && }
}
{children}
- {isOpen && selected && <> -
setSplitRatio(0.42)} onKeyDown={event => { - let width = conversationWidth - if (event.key === 'ArrowLeft') width -= 32 - else if (event.key === 'ArrowRight') width += 32 - else if (event.key === 'Home') width = minWidth - else if (event.key === 'End') width = maxWidth - else return - event.preventDefault() - if (workspaceWidth) setSplitRatio(Math.max(minWidth, Math.min(maxWidth, width)) / workspaceWidth) - }} onPointerDown={event => { - if (event.button !== 0) return - layoutAnimation.current?.cancel() - layoutAnimation.current = null - event.preventDefault() - event.currentTarget.focus() - event.currentTarget.setPointerCapture(event.pointerId) - dragging.current = true - setDragRatio(resize(event.clientX)) - }} onPointerMove={event => { - if (dragging.current) setDragRatio(resize(event.clientX)) - }} onPointerUp={event => { - if (!dragging.current) return - setSplitRatio(resize(event.clientX)) - dragging.current = false - setDragRatio(null) - event.currentTarget.releasePointerCapture(event.pointerId) - }} onLostPointerCapture={() => { dragging.current = false; setDragRatio(null) }}>
-
{ - if (event.key === 'Escape' && fullscreen) { event.preventDefault(); toggleFullscreen() } - }}> -

{selected.title}

- } toolbarActions={ -
- {outputs.length > 1 && } - - -
- } /> - }
} diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/PasswordsModal.module.css b/app/ui_layer/browser/frontend/src/components/MiniBrowser/PasswordsModal.module.css similarity index 100% rename from app/ui_layer/browser/frontend/src/pages/MiniBrowser/PasswordsModal.module.css rename to app/ui_layer/browser/frontend/src/components/MiniBrowser/PasswordsModal.module.css diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/PasswordsModal.tsx b/app/ui_layer/browser/frontend/src/components/MiniBrowser/PasswordsModal.tsx similarity index 100% rename from app/ui_layer/browser/frontend/src/pages/MiniBrowser/PasswordsModal.tsx rename to app/ui_layer/browser/frontend/src/components/MiniBrowser/PasswordsModal.tsx diff --git a/app/ui_layer/browser/frontend/src/components/MiniBrowser/ResumeView.module.css b/app/ui_layer/browser/frontend/src/components/MiniBrowser/ResumeView.module.css new file mode 100644 index 000000000..e88dfcba9 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/components/MiniBrowser/ResumeView.module.css @@ -0,0 +1,99 @@ +/* The page the browser left off on, dimmed, in place of the live page. The + picture is drawn like a live frame (object-fit: contain); the veil over it + follows the theme. */ + +.view { + position: absolute; + inset: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + background: var(--bg-primary); +} + +.view::after { + content: ''; + position: absolute; + inset: 0; + background: color-mix(in srgb, var(--bg-primary) 55%, transparent); + pointer-events: none; +} + +.paused { + cursor: pointer; +} + +.picture { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + filter: saturate(0.6) blur(1px); + pointer-events: none; + user-select: none; + -webkit-user-drag: none; +} + +.notice, +.card { + position: relative; + z-index: 1; + color: var(--text-primary); + background: var(--bg-elevated); + border: 1px solid var(--border-primary); + box-shadow: var(--shadow-md); +} + +.notice { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + border-radius: var(--radius-full); + font-size: var(--text-sm); +} + +.card { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); + max-width: min(360px, calc(100% - 2 * var(--space-4))); + padding: var(--space-5) var(--space-6); + border-radius: var(--radius-lg); + text-align: center; + cursor: default; +} + +.cardTitle { + font-size: var(--text-base); + font-weight: var(--font-semibold); +} + +.cardBody { + margin: 0 0 var(--space-2); + color: var(--text-secondary); + font-size: var(--text-sm); + line-height: var(--leading-relaxed); +} + +.spin { + flex-shrink: 0; + color: var(--text-secondary); + animation: resumeSpin 1s linear infinite; +} + +@keyframes resumeSpin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .spin { + animation-duration: 2.4s; + } +} diff --git a/app/ui_layer/browser/frontend/src/components/MiniBrowser/ResumeView.tsx b/app/ui_layer/browser/frontend/src/components/MiniBrowser/ResumeView.tsx new file mode 100644 index 000000000..7f725b5e7 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/components/MiniBrowser/ResumeView.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Loader2, Play } from 'lucide-react' +import { Button } from '../../components/ui' +import type { MiniBrowserLastFrame } from '../../types' +import styles from './ResumeView.module.css' + +interface ResumeViewProps { + /** Where the browser left off. */ + frame: MiniBrowserLastFrame + /** 'resuming': the browser is starting (its pages reopen); 'paused': it is + * off until the user resumes it. */ + mode: 'resuming' | 'paused' + onResume(): void +} + +// A Resume click shows its spinner until the backend reports a new state; +// this only bounds it if no report ever comes. +const PENDING_TIMEOUT_MS = 20_000 + +/** + * The page the browser left off on, dimmed, in place of the live page while + * the browser starts again or is off, so the panel is never blank. + */ +export function ResumeView({ frame, mode, onResume }: ResumeViewProps) { + const { t } = useTranslation('minibrowser') + const [pending, setPending] = useState(false) + + // A new state from the backend settles a pending click. + useEffect(() => { + setPending(false) + }, [mode]) + + useEffect(() => { + if (!pending) return + const timer = window.setTimeout(() => setPending(false), PENDING_TIMEOUT_MS) + return () => window.clearTimeout(timer) + }, [pending]) + + const resume = () => { + if (pending) return + setPending(true) + onResume() + } + + return ( +
+ + {mode === 'resuming' ? ( +
+ + {t('status.resuming')} +
+ ) : ( +
+ {t('status.pausedTitle')} +

{t('status.pausedBody')}

+ +
+ )} +
+ ) +} diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/MiniBrowserPage.module.css b/app/ui_layer/browser/frontend/src/components/MiniBrowser/SessionBrowserPanel.module.css similarity index 70% rename from app/ui_layer/browser/frontend/src/pages/MiniBrowser/MiniBrowserPage.module.css rename to app/ui_layer/browser/frontend/src/components/MiniBrowser/SessionBrowserPanel.module.css index b3b4966af..8c28997fb 100644 --- a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/MiniBrowserPage.module.css +++ b/app/ui_layer/browser/frontend/src/components/MiniBrowser/SessionBrowserPanel.module.css @@ -1,22 +1,7 @@ -/* Mini Browser page: the browser on the left, the Mini Browser chat on the - right (ChatPanel). Below 768px, or when the row is too narrow for both, - the two stack vertically. */ +/* A chat's browser, in the chat's panel (the Generative UI output pane's + place): the browser toolbar, the tabs, and the live page. */ -.page { - display: flex; - flex-direction: column; - height: 100%; - overflow: hidden; -} - -.content { - display: flex; - flex: 1; - min-height: 0; - overflow: hidden; -} - -.browserPanel { +.panel { display: flex; flex-direction: column; flex: 1; @@ -90,19 +75,6 @@ font-weight: var(--font-semibold); } -/* ── Stacked: the chat (see ChatPanel) under the browser ─────────────── */ -/* Below 768px, and wherever the row is too narrow for both side by side. */ - -.contentStacked { - flex-direction: column; -} - -@media (max-width: 768px) { - .content { - flex-direction: column; - } -} - @media (prefers-reduced-motion: reduce) { .loadingBar::after { animation-duration: 2.4s; diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/MiniBrowserPage.tsx b/app/ui_layer/browser/frontend/src/components/MiniBrowser/SessionBrowserPanel.tsx similarity index 62% rename from app/ui_layer/browser/frontend/src/pages/MiniBrowser/MiniBrowserPage.tsx rename to app/ui_layer/browser/frontend/src/components/MiniBrowser/SessionBrowserPanel.tsx index bd7ec229f..89b927038 100644 --- a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/MiniBrowserPage.tsx +++ b/app/ui_layer/browser/frontend/src/components/MiniBrowser/SessionBrowserPanel.tsx @@ -1,27 +1,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { WifiOff } from 'lucide-react' -import { ConfirmModal } from '../../components/ui' +import { ConfirmModal } from '../ui' import { useWebSocket } from '../../contexts/WebSocketContext' import { useToast } from '../../contexts/ToastContext' import { useAppDispatch, useAppSelector } from '../../store/hooks' import { selectSessionRunState } from '../../store/selectors/agent' import { selectConnected } from '../../store/selectors/connection' import { - selectMiniBrowserAdblock, selectMiniBrowserAgentBusy, - selectMiniBrowserError, selectMiniBrowserEvents, - selectMiniBrowserFollow, selectMiniBrowserInstall, - selectMiniBrowserKnown, - selectMiniBrowserNavResult, - selectMiniBrowserSessionId, - selectMiniBrowserSettings, - selectMiniBrowserStatus, + selectMiniBrowserLastFrame, selectMiniBrowserTabs, + selectMiniBrowserView, selectMiniBrowserViewedTab, - selectMiniBrowserViewport, } from '../../store/selectors/miniBrowser' import { setPendingPrefill } from '../../store/slices/chatInputSlice' import { @@ -29,19 +22,21 @@ import { navResultDismissed, type MiniBrowserEventEntry, } from '../../store/slices/miniBrowserSlice' -import type { MiniBrowserTab } from '../../types' -import { ChatPanel } from './ChatPanel' +import type { MiniBrowserSettings, MiniBrowserTab } from '../../types' import { canOpenDownload, downloadName, isDangerousDownload, stripFormatControls } from './downloads' import { LiveView, type LiveViewHandle } from './live/LiveView' import { PasswordsModal } from './PasswordsModal' -import { StatusPanel, type PanelState } from './StatusPanel' +import { ResumeView } from './ResumeView' +import { StartPage, StatusPanel, type PanelState } from './StatusPanel' import { TabStrip } from './TabStrip' import { Toolbar, type AddressTarget, type StopTarget, type ToolbarHandle } from './Toolbar' import { sendAction, sendLive, useMiniBrowserViewer } from './useMiniBrowserSocket' import { useErrorText } from './useErrorText' import { useLatest } from './useLatest' import { useTabLabels } from './useTabLabels' -import styles from './MiniBrowserPage.module.css' +import styles from './SessionBrowserPanel.module.css' + +const DEFAULT_SETTINGS: MiniBrowserSettings = { humanlike: true, showCursor: true } /** The session whose run drives a tab: its owner, or a sub-agent's parent. */ function driverSession(tab: MiniBrowserTab): string | null { @@ -50,36 +45,46 @@ function driverSession(tab: MiniBrowserTab): string | null { return tab.owner } +interface SessionBrowserPanelProps { + /** The chat session whose browser this is. Mount one panel per session + * (key it by the session id): its live view, input and subscription + * belong to that session for their whole lifetime. */ + sessionId: string +} + /** - * The Mini Browser: a live view of the real Chromium that the user and every - * agent share, with tabs, an address bar, take-over controls, saved logins, - * and the dedicated Mini Browser chat beside it. + * A chat's own browser in its panel: a live view of the session's + * Chromium, with tabs, an address bar, take-over controls and saved logins. + * Opening the panel turns the browser on; the session's agent drives its + * own tab(s) while the user watches and can take control at any moment. */ -export function MiniBrowserPage() { +export function SessionBrowserPanel({ sessionId }: SessionBrowserPanelProps) { const { t } = useTranslation(['minibrowser', 'common']) const dispatch = useAppDispatch() const { stopSession, openFile, openFolder } = useWebSocket() const { showToast } = useToast() - const { ownerName } = useTabLabels() + const { ownerName } = useTabLabels(sessionId) const errorText = useErrorText() const connected = useAppSelector(selectConnected) - const known = useAppSelector(selectMiniBrowserKnown) - const status = useAppSelector(selectMiniBrowserStatus) - const error = useAppSelector(selectMiniBrowserError) - const sessionId = useAppSelector(selectMiniBrowserSessionId) - const tabs = useAppSelector(selectMiniBrowserTabs) - const viewedTab = useAppSelector(selectMiniBrowserViewedTab) - const follow = useAppSelector(selectMiniBrowserFollow) - const adblock = useAppSelector(selectMiniBrowserAdblock) - const settings = useAppSelector(selectMiniBrowserSettings) - const viewport = useAppSelector(selectMiniBrowserViewport) + const view = useAppSelector(state => selectMiniBrowserView(state, sessionId)) + const tabs = useAppSelector(state => selectMiniBrowserTabs(state, sessionId)) + const viewedTab = useAppSelector(state => selectMiniBrowserViewedTab(state, sessionId)) + const agentBusy = useAppSelector(state => selectMiniBrowserAgentBusy(state, sessionId)) const install = useAppSelector(selectMiniBrowserInstall) - const navResult = useAppSelector(selectMiniBrowserNavResult) const events = useAppSelector(selectMiniBrowserEvents) - const agentBusy = useAppSelector(selectMiniBrowserAgentBusy) - - // The Stop button stops the run driving the viewed tab. + const lastFrame = useAppSelector(state => selectMiniBrowserLastFrame(state, sessionId)) + + const known = view !== undefined + const status = view?.status ?? 'stopped' + const error = view?.error ?? null + const follow = view?.follow ?? true + const adblock = view?.adblock ?? null + const settings = view?.settings ?? DEFAULT_SETTINGS + const viewport = view?.viewport ?? null + const navResult = view?.navResult ?? null + + // The Stop button stops the run driving the viewed tab (this chat's). const driver = viewedTab ? driverSession(viewedTab) : null const driverRunState = useAppSelector(state => (driver ? selectSessionRunState(state, driver) : 'idle')) const driverActive = !!viewedTab?.busy || driverRunState !== 'idle' @@ -92,8 +97,9 @@ export function MiniBrowserPage() { const liveViewRef = useRef(null) const toolbarRef = useRef(null) - // Viewer subscription + remote viewport sizing. - useMiniBrowserViewer(stageAreaRef, connected, viewport) + // Viewer subscription (opening the panel turns the browser on) and the + // browser's viewport sizing. + useMiniBrowserViewer(sessionId, stageAreaRef, connected, viewport) // ── Browser actions ─────────────────────────────────────────────────── const viewedTabId = viewedTab?.id ?? null @@ -104,43 +110,48 @@ export function MiniBrowserPage() { // The address bar opens what was typed in the tab the edit started on — // never in whichever tab came on screen meanwhile (that may be an agent's). const navigate = useCallback((text: string, target: AddressTarget) => { - dispatch(navResultDismissed()) + dispatch(navResultDismissed(sessionId)) const tab = target.tabId ? tabsRef.current.find(t => t.id === target.tabId) : undefined // An agent claimed the tab while the user was typing (handing it back to // the user is fine: that tab is theirs to navigate). const claimedMeanwhile = !!tab && tab.owner !== null && tab.owner !== target.owner if (tab && !claimedMeanwhile) { // Show the tab that is about to load. - if (tab.id !== viewedTabRef.current?.id) sendAction('mini_browser_tab', { action: 'switch', tabId: tab.id }) - sendAction('mini_browser_navigate', { tabId: tab.id, url: text }) + if (tab.id !== viewedTabRef.current?.id) { + sendAction('mini_browser_tab', { sessionId, action: 'switch', tabId: tab.id }) + } + sendAction('mini_browser_navigate', { sessionId, tabId: tab.id, url: text }) } else if (!target.tabId && tabsRef.current.length === 0) { // Nothing is open yet: the backend starts the browser and opens one. - sendAction('mini_browser_navigate', { url: text }) + sendAction('mini_browser_navigate', { sessionId, url: text }) } else { // The tab was closed, or an agent took it over meanwhile: open the // address in a new tab of the user's own instead. - sendAction('mini_browser_tab', { action: 'new', url: text }) + sendAction('mini_browser_tab', { sessionId, action: 'new', url: text }) } - }, [dispatch, tabsRef, viewedTabRef]) + }, [dispatch, sessionId, tabsRef, viewedTabRef]) const history = useCallback((action: 'back' | 'forward' | 'reload' | 'stop') => { - sendAction('mini_browser_history', viewedTabId ? { tabId: viewedTabId, action } : { action }) - }, [viewedTabId]) + sendAction( + 'mini_browser_history', + viewedTabId ? { sessionId, tabId: viewedTabId, action } : { sessionId, action }, + ) + }, [sessionId, viewedTabId]) const newTab = useCallback(() => { - sendAction('mini_browser_tab', { action: 'new' }) - }, []) + sendAction('mini_browser_tab', { sessionId, action: 'new' }) + }, [sessionId]) const switchTab = useCallback((tabId: string) => { - sendAction('mini_browser_tab', { action: 'switch', tabId }) - }, []) + sendAction('mini_browser_tab', { sessionId, action: 'switch', tabId }) + }, [sessionId]) const [confirmCloseTab, setConfirmCloseTab] = useState(null) const [confirmShutdown, setConfirmShutdown] = useState(false) const closeTabNow = useCallback((tabId: string) => { - sendAction('mini_browser_tab', { action: 'close', tabId }) - }, []) + sendAction('mini_browser_tab', { sessionId, action: 'close', tabId }) + }, [sessionId]) const requestCloseTab = useCallback((tab: MiniBrowserTab) => { // Closing a tab an agent is working in interrupts it: ask first. @@ -149,8 +160,8 @@ export function MiniBrowserPage() { }, [closeTabNow]) const setFollow = useCallback((next: boolean) => { - sendAction('mini_browser_view', { follow: next }) - }, []) + sendAction('mini_browser_view', { sessionId, follow: next }) + }, [sessionId]) // While the user works in a tab, "follow" must not pull the view away to // whichever tab an agent touches next (mid-typing, mid-IME-composition, @@ -159,8 +170,8 @@ export function MiniBrowserPage() { const stopFollowing = useCallback(() => { if (!followRef.current) return followRef.current = false - sendLive('mini_browser_view', { follow: false }) - }, [followRef]) + sendLive('mini_browser_view', { sessionId, follow: false }) + }, [followRef, sessionId]) const setAdblock = useCallback((enabled: boolean) => { sendAction('mini_browser_adblock', { enabled }) @@ -169,36 +180,30 @@ export function MiniBrowserPage() { const setControl = useCallback((take: boolean) => { if (!viewedTabId) return if (take) stopFollowing() - sendAction('mini_browser_control', { tabId: viewedTabId, take }) - }, [stopFollowing, viewedTabId]) + sendAction('mini_browser_control', { sessionId, tabId: viewedTabId, take }) + }, [sessionId, stopFollowing, viewedTabId]) - const start = useCallback(() => sendAction('mini_browser_start'), []) + const start = useCallback(() => sendAction('mini_browser_start', { sessionId }), [sessionId]) const installBrowser = useCallback(() => { dispatch(installRequested()) sendAction('mini_browser_install') }, [dispatch]) + const shutdown = useCallback(() => sendAction('mini_browser_shutdown', { sessionId }), [sessionId]) + const requestShutdown = useCallback(() => { if (agentBusy) setConfirmShutdown(true) - else sendAction('mini_browser_shutdown') - }, [agentBusy]) + else shutdown() + }, [agentBusy, shutdown]) const stopAgent = useCallback(() => { if (stopTarget && stopTarget.runState !== 'stopping') stopSession(stopTarget.sessionId) }, [stopSession, stopTarget]) - const onUserInput = stopFollowing - const focusAddress = useCallback(() => toolbarRef.current?.focusAddress(), []) const focusPage = useCallback(() => liveViewRef.current?.focus(), []) - const contentRef = useRef(null) - // The chat sits under the browser (ChatPanel decides from the row's width). - const [stacked, setStacked] = useState(false) - // Bumped to make ChatPanel show the chat. - const [chatOpenSignal, setChatOpenSignal] = useState(0) - // ── Notices from the browser (dialogs, downloads, blocks, crashes) ──── // The headline is always in the user's language; the backend's own // sentence (English, written for agents) only ever appears as the detail @@ -265,7 +270,8 @@ export function MiniBrowserPage() { } }, [announceDownload, errorText, showToast, t, viewedTabRef]) - // Only events that arrive while the page is open are announced. + // Only this session's events that arrive while the panel is open are + // announced. const announcedSeqRef = useRef(null) useEffect(() => { const newest = events.length > 0 ? events[events.length - 1].seq : 0 @@ -273,9 +279,9 @@ export function MiniBrowserPage() { announcedSeqRef.current = newest if (since === null) return for (const event of events) { - if (event.seq > since) announce(event) + if (event.seq > since && event.sessionId === sessionId) announce(event) } - }, [events, announce]) + }, [events, announce, sessionId]) // A finished install says so (failures show in the status panel). const installDoneRef = useRef(install.doneSeq) @@ -287,94 +293,104 @@ export function MiniBrowserPage() { // ── Derived view state ──────────────────────────────────────────────── const panelState: PanelState | null = !known ? 'connecting' : status === 'ready' ? null : status + // While the browser starts again or is off, the page it left off on shows + // (dimmed) instead of a status screen. + const resumeMode = !lastFrame + ? null + : panelState === 'connecting' || panelState === 'starting' + ? 'resuming' + : panelState === 'stopped' ? 'paused' : null + // A blank tab of the user's (a new chat's first tab) shows what this + // browser is and what to try, instead of an empty page. + const blankTab = panelState === null + && !!viewedTab + && viewedTab.ownerKind === 'user' + && viewedTab.url === 'about:blank' + && !viewedTab.canGoBack + && !viewedTab.loading + && !viewedTab.crashed const browserOpen = known && status !== 'stopped' && status !== 'installing' const navError = navResult && !navResult.ok ? navResult.error : null const tabIdsKey = useMemo(() => tabs.map(tab => tab.id).join('\n'), [tabs]) const [passwordsOpen, setPasswordsOpen] = useState(false) + // An example prompt goes into this chat's composer. const onExample = useCallback((prompt: string) => { dispatch(setPendingPrefill(prompt)) - setChatOpenSignal(n => n + 1) }, [dispatch]) return ( -
-
-
- setPasswordsOpen(true)} - onCloseBrowser={requestShutdown} - onStopAgent={stopAgent} - onDismissNavError={() => dispatch(navResultDismissed())} - onSubmitted={focusPage} - /> - - {/* Fixed-size area: its size is the remote viewport (see useMiniBrowserViewer). - The live view stays mounted in every state so a frame that - arrives just before "ready" is never lost; the status panel - covers it until the browser is ready. */} -
- - {panelState !== null && ( - - )} - {viewedTab?.loading && panelState === null && -
- - + {/* Tabs above the toolbar, as in Chrome: the viewed tab flows into it. */} + + setPasswordsOpen(true)} + onCloseBrowser={requestShutdown} + onStopAgent={stopAgent} + onDismissNavError={() => dispatch(navResultDismissed(sessionId))} + onSubmitted={focusPage} + /> + {/* Its size is the browser's viewport (see useMiniBrowserViewer). The + live view stays mounted in every state so a frame that arrives just + before "ready" is never lost; the status panel covers it until the + browser is ready. */} +
+ + {panelState !== null && (lastFrame && resumeMode ? ( + + ) : ( + + ))} + {blankTab && } + {viewedTab?.loading && panelState === null && {passwordsOpen && setPasswordsOpen(false)} />} @@ -401,7 +417,7 @@ export function MiniBrowserPage() { variant="danger" onConfirm={() => { setConfirmShutdown(false) - sendAction('mini_browser_shutdown') + shutdown() }} onCancel={() => setConfirmShutdown(false)} /> diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/StatusPanel.module.css b/app/ui_layer/browser/frontend/src/components/MiniBrowser/StatusPanel.module.css similarity index 100% rename from app/ui_layer/browser/frontend/src/pages/MiniBrowser/StatusPanel.module.css rename to app/ui_layer/browser/frontend/src/components/MiniBrowser/StatusPanel.module.css diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/StatusPanel.tsx b/app/ui_layer/browser/frontend/src/components/MiniBrowser/StatusPanel.tsx similarity index 64% rename from app/ui_layer/browser/frontend/src/pages/MiniBrowser/StatusPanel.tsx rename to app/ui_layer/browser/frontend/src/components/MiniBrowser/StatusPanel.tsx index 81f782030..ef617e281 100644 --- a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/StatusPanel.tsx +++ b/app/ui_layer/browser/frontend/src/components/MiniBrowser/StatusPanel.tsx @@ -23,13 +23,11 @@ interface StatusPanelProps { state: PanelState error: MiniBrowserError | null installLines: string[] - /** The chat is ready, so example prompts can be offered. */ - chatAvailable: boolean onStart(): void onInstall(): void /** Put the cursor in the address bar. */ onOpenSite(): void - /** Drop an example prompt into the chat composer. */ + /** Drop an example prompt into this chat's composer. */ onExample(prompt: string): void } @@ -44,7 +42,6 @@ export function StatusPanel({ state, error, installLines, - chatAvailable, onStart, onInstall, onOpenSite, @@ -143,15 +140,18 @@ export function StatusPanel({ {installFailed ? t('minibrowser:status.installAgain') : t('minibrowser:status.install')} )} - + {/* A deleted chat's browser never comes back: nothing to retry. */} + {code !== 'MINI_BROWSER_SESSION_GONE' && ( + + )}
@@ -159,10 +159,32 @@ export function StatusPanel({ } // Stopped: the friendly first screen. + return ( + + ) +} + +interface StartPageProps { + /** Put the cursor in the address bar. */ + onOpenSite(): void + /** Drop an example prompt into this chat's composer. */ + onExample(prompt: string): void + /** The browser is off: a Start button leads (its spinner while pending). */ + start?: { pending: boolean; disabled: boolean; onStart(): void } +} + +/** What this chat's browser is, and what to try: in place of a browser that + * is off, and over a blank tab (a new chat's first tab). */ +export function StartPage({ onOpenSite, onExample, start }: StartPageProps) { + const { t } = useTranslation('minibrowser') const examples = [ - t('minibrowser:status.examples.compare'), - t('minibrowser:status.examples.news'), - t('minibrowser:status.examples.research'), + t('status.examples.compare'), + t('status.examples.news'), + t('status.examples.research'), ] return (
@@ -170,63 +192,63 @@ export function StatusPanel({
-

{t('minibrowser:status.stoppedTitle')}

-

{t('minibrowser:status.stoppedBody')}

+

{t('status.stoppedTitle')}

+

{t('status.stoppedBody')}

- - + )} +
- {chatAvailable && ( -
- {t('minibrowser:status.tryAsking')} -
- {examples.map(example => ( - - ))} -
+
+ {t('status.tryAsking')} +
+ {examples.map(example => ( + + ))}
- )} +
  • - {t('minibrowser:status.featureWatchTitle')} - {t('minibrowser:status.featureWatchBody')} + {t('status.featureWatchTitle')} + {t('status.featureWatchBody')}
  • - {t('minibrowser:status.featureControlTitle')} - {t('minibrowser:status.featureControlBody')} + {t('status.featureControlTitle')} + {t('status.featureControlBody')}
  • - {t('minibrowser:status.featureLoginsTitle')} - {t('minibrowser:status.featureLoginsBody')} + {t('status.featureLoginsTitle')} + {t('status.featureLoginsBody')}
diff --git a/app/ui_layer/browser/frontend/src/components/MiniBrowser/TabStrip.module.css b/app/ui_layer/browser/frontend/src/components/MiniBrowser/TabStrip.module.css new file mode 100644 index 000000000..10ea1b011 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/components/MiniBrowser/TabStrip.module.css @@ -0,0 +1,47 @@ +/* The browser's own parts of its tabs; the Chrome look itself is shared + (components/ui/ChromeTabs.module.css). */ + +/* An SVG icon that adapts to the color scheme (GitHub's) follows the app's + theme, as Chrome's icons follow the browser's. */ +.favicon { + display: block; + width: 16px; + height: 16px; + object-fit: contain; + color-scheme: dark; + pointer-events: none; + user-select: none; +} + +:global([data-theme='light']) .favicon { + color-scheme: light; +} + +/* An agent uses the tab (who, in the tooltip); blue while it works. */ +.agentMark { + display: flex; + flex-shrink: 0; + color: var(--text-muted); +} + +.tabBusy .agentMark { + color: var(--color-info); +} + +.spin { + animation: spin 0.9s linear infinite; +} + +.busyIcon { + color: var(--color-info); +} + +.crashedIcon { + color: var(--color-warning); +} + +@media (prefers-reduced-motion: reduce) { + .spin { + animation-duration: 2.4s; + } +} diff --git a/app/ui_layer/browser/frontend/src/components/MiniBrowser/TabStrip.tsx b/app/ui_layer/browser/frontend/src/components/MiniBrowser/TabStrip.tsx new file mode 100644 index 000000000..c1cdd815e --- /dev/null +++ b/app/ui_layer/browser/frontend/src/components/MiniBrowser/TabStrip.tsx @@ -0,0 +1,109 @@ +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { AlertTriangle, Bot, Globe, Hand, Loader2, Plus } from 'lucide-react' +import { IconButton } from '../../components/ui' +import { ChromeTabs, type ChromeTab } from '../../components/ui/ChromeTabs' +import type { MiniBrowserTab } from '../../types' +import { useTabLabels } from './useTabLabels' +import styles from './TabStrip.module.css' + +interface TabStripProps { + /** The chat session whose browser the tabs are in. */ + sessionId: string + tabs: MiniBrowserTab[] + viewedTabId: string | null + onSwitch(tabId: string): void + onClose(tab: MiniBrowserTab): void + onNew(): void +} + +/** A site's icon; the generic globe when it has none or it fails to draw. */ +function SiteIcon({ src }: { src: string | null }) { + const [failed, setFailed] = useState(null) + if (!src || failed === src) return + return ( + setFailed(src)} + /> + ) +} + +/** + * A chat's browser tabs, drawn like Chrome's (ChromeTabs) above the toolbar, + * where the viewed tab takes the toolbar's color and flows into it. Each tab + * shows its site icon, or its state in its place (loading/agent working, + * crashed, held by you), its title, and an agent mark when an agent uses it + * (who, in its tooltip). "+" opens a new tab. + */ +export function TabStrip({ sessionId, tabs, viewedTabId, onSwitch, onClose, onNew }: TabStripProps) { + const { t } = useTranslation(['minibrowser', 'common']) + const { ownerName, tabTitle } = useTabLabels(sessionId) + + const items: ChromeTab[] = tabs.map(tab => { + const title = tabTitle(tab) + const owner = ownerName(tab) + // The user holds an agent's tab: its agent waits until handed back, so + // the tab says so (easy to find again from another tab). + const userHolds = tab.userControl && tab.ownerKind !== 'user' + const agentWorking = tab.busy && tab.ownerKind !== 'user' && !userHolds + let icon: React.ReactNode + let state = '' + if (tab.crashed) { + icon = + state = t('minibrowser:tabs.crashed') + } else if (userHolds) { + icon = + state = t('minibrowser:tabs.userControl') + } else if (tab.loading || agentWorking) { + icon = + state = agentWorking ? t('minibrowser:tabs.busy') : t('minibrowser:tabs.loading') + } else { + icon = + } + const ownerText = t('minibrowser:tabs.ownerTooltip', { owner }) + return { + id: tab.id, + title, + icon, + tooltip: [title, tab.url !== 'about:blank' ? tab.url : '', ownerText, userHolds ? state : ''] + .filter(Boolean) + .join('\n'), + description: [state, ownerText].filter(Boolean).join('. '), + marks: tab.ownerKind !== 'user' && ( + + ), + className: agentWorking ? styles.tabBusy : undefined, + closeLabel: t('minibrowser:tabs.closeNamed', { title }), + } + }) + + return ( + { + const tab = tabs.find(item => item.id === id) + if (tab) onClose(tab) + }} + closeTitle={t('minibrowser:tabs.close')} + after={ + } + onClick={onNew} + aria-label={t('minibrowser:tabs.newTab')} + tooltip={t('minibrowser:tabs.newTab')} + /> + } + /> + ) +} diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/Toolbar.module.css b/app/ui_layer/browser/frontend/src/components/MiniBrowser/Toolbar.module.css similarity index 100% rename from app/ui_layer/browser/frontend/src/pages/MiniBrowser/Toolbar.module.css rename to app/ui_layer/browser/frontend/src/components/MiniBrowser/Toolbar.module.css diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/Toolbar.tsx b/app/ui_layer/browser/frontend/src/components/MiniBrowser/Toolbar.tsx similarity index 100% rename from app/ui_layer/browser/frontend/src/pages/MiniBrowser/Toolbar.tsx rename to app/ui_layer/browser/frontend/src/components/MiniBrowser/Toolbar.tsx diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/clipboard.ts b/app/ui_layer/browser/frontend/src/components/MiniBrowser/clipboard.ts similarity index 100% rename from app/ui_layer/browser/frontend/src/pages/MiniBrowser/clipboard.ts rename to app/ui_layer/browser/frontend/src/components/MiniBrowser/clipboard.ts diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/downloads.ts b/app/ui_layer/browser/frontend/src/components/MiniBrowser/downloads.ts similarity index 100% rename from app/ui_layer/browser/frontend/src/pages/MiniBrowser/downloads.ts rename to app/ui_layer/browser/frontend/src/components/MiniBrowser/downloads.ts diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/live/LiveView.module.css b/app/ui_layer/browser/frontend/src/components/MiniBrowser/live/LiveView.module.css similarity index 100% rename from app/ui_layer/browser/frontend/src/pages/MiniBrowser/live/LiveView.module.css rename to app/ui_layer/browser/frontend/src/components/MiniBrowser/live/LiveView.module.css diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/live/LiveView.tsx b/app/ui_layer/browser/frontend/src/components/MiniBrowser/live/LiveView.tsx similarity index 97% rename from app/ui_layer/browser/frontend/src/pages/MiniBrowser/live/LiveView.tsx rename to app/ui_layer/browser/frontend/src/components/MiniBrowser/live/LiveView.tsx index a32b5365c..007350159 100644 --- a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/live/LiveView.tsx +++ b/app/ui_layer/browser/frontend/src/components/MiniBrowser/live/LiveView.tsx @@ -32,6 +32,8 @@ export interface LiveViewHandle { } interface LiveViewProps { + /** The session whose browser this is (a live view serves one session). */ + sessionId: string /** The browser is ready and the page is on screen. While false (starting, * stopped, error…) the view stays mounted, so frames that arrive early are * kept, but it takes no input. */ @@ -61,7 +63,7 @@ interface LiveViewProps { * component nor the chat beside it re-renders per frame. */ export const LiveView = forwardRef(function LiveView(props, ref) { - const { active, tab, tabIdsKey, connected, showCursor, ownerName, onControl, onHistory, onNewTab } = props + const { sessionId, active, tab, tabIdsKey, connected, showCursor, ownerName, onControl, onHistory, onNewTab } = props const { t } = useTranslation(['minibrowser', 'common']) const { showToast } = useToast() const hintId = useId() @@ -89,7 +91,7 @@ export const LiveView = forwardRef(function LiveV return !!open && !open.crashed }, [store]) - const input = useLiveInput(latest, isTabOpen) + const input = useLiveInput(sessionId, latest, isTabOpen) const { hasFrame, shownFrameRef, onFrameLoad } = useFramePainter(imgRef, tabId, tabIdsKey) useAgentCursor({ stage: stageRef, img: imgRef, cursor: cursorRef, ripple: rippleRef }, tabId, showCursor, styles.cursorVisible) @@ -140,6 +142,7 @@ export const LiveView = forwardRef(function LiveV showToast('error', t('minibrowser:live.copyFailed')) }, [showToast, t]) const keyboard = useKeyboardSink({ + sessionId, sink: sinkRef, input, target: latest, diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/live/geometry.ts b/app/ui_layer/browser/frontend/src/components/MiniBrowser/live/geometry.ts similarity index 100% rename from app/ui_layer/browser/frontend/src/pages/MiniBrowser/live/geometry.ts rename to app/ui_layer/browser/frontend/src/components/MiniBrowser/live/geometry.ts diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/live/keyboard.ts b/app/ui_layer/browser/frontend/src/components/MiniBrowser/live/keyboard.ts similarity index 100% rename from app/ui_layer/browser/frontend/src/pages/MiniBrowser/live/keyboard.ts rename to app/ui_layer/browser/frontend/src/components/MiniBrowser/live/keyboard.ts diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/live/useAgentCursor.ts b/app/ui_layer/browser/frontend/src/components/MiniBrowser/live/useAgentCursor.ts similarity index 91% rename from app/ui_layer/browser/frontend/src/pages/MiniBrowser/live/useAgentCursor.ts rename to app/ui_layer/browser/frontend/src/components/MiniBrowser/live/useAgentCursor.ts index 98c858e16..22f9ed7b8 100644 --- a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/live/useAgentCursor.ts +++ b/app/ui_layer/browser/frontend/src/components/MiniBrowser/live/useAgentCursor.ts @@ -12,10 +12,17 @@ const RIPPLE_MS = 480 export function parsePointer(data: unknown): MiniBrowserPointer | null { if (typeof data !== 'object' || data === null) return null const d = data as Record - if (typeof d.tabId !== 'string' || typeof d.x !== 'number' || typeof d.y !== 'number') return null + if (typeof d.sessionId !== 'string' || typeof d.tabId !== 'string') return null + if (typeof d.x !== 'number' || typeof d.y !== 'number') return null if (!Number.isFinite(d.x) || !Number.isFinite(d.y)) return null const kind = d.kind === 'down' || d.kind === 'up' || d.kind === 'click' ? d.kind : 'move' - return { tabId: d.tabId, x: Math.min(1, Math.max(0, d.x)), y: Math.min(1, Math.max(0, d.y)), kind } + return { + sessionId: d.sessionId, + tabId: d.tabId, + x: Math.min(1, Math.max(0, d.x)), + y: Math.min(1, Math.max(0, d.y)), + kind, + } } interface AgentCursorElements { diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/live/useFramePainter.ts b/app/ui_layer/browser/frontend/src/components/MiniBrowser/live/useFramePainter.ts similarity index 96% rename from app/ui_layer/browser/frontend/src/pages/MiniBrowser/live/useFramePainter.ts rename to app/ui_layer/browser/frontend/src/components/MiniBrowser/live/useFramePainter.ts index 8a17dad86..ef93d38f1 100644 --- a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/live/useFramePainter.ts +++ b/app/ui_layer/browser/frontend/src/components/MiniBrowser/live/useFramePainter.ts @@ -11,8 +11,11 @@ const isImageDataUrl = (value: unknown): value is string => export function parseFrame(data: unknown): MiniBrowserFrame | null { if (typeof data !== 'object' || data === null) return null const d = data as Record - if (typeof d.tabId !== 'string' || !isImageDataUrl(d.image)) return null + if (typeof d.sessionId !== 'string' || typeof d.tabId !== 'string' || !isImageDataUrl(d.image)) { + return null + } return { + sessionId: d.sessionId, tabId: d.tabId, image: d.image, width: typeof d.width === 'number' && d.width > 0 ? d.width : 0, diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/live/useKeyboardSink.ts b/app/ui_layer/browser/frontend/src/components/MiniBrowser/live/useKeyboardSink.ts similarity index 98% rename from app/ui_layer/browser/frontend/src/pages/MiniBrowser/live/useKeyboardSink.ts rename to app/ui_layer/browser/frontend/src/components/MiniBrowser/live/useKeyboardSink.ts index b0777b534..90ed29d25 100644 --- a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/live/useKeyboardSink.ts +++ b/app/ui_layer/browser/frontend/src/components/MiniBrowser/live/useKeyboardSink.ts @@ -20,6 +20,8 @@ export interface KeyboardTarget { } interface KeyboardOptions { + /** The session whose browser the keyboard types into. */ + sessionId: string sink: RefObject input: LiveInput target: MutableRefObject @@ -68,6 +70,7 @@ interface PendingCopy { * paste, copy/cut of the page's selection, and "Escape twice" to leave. */ export function useKeyboardSink({ + sessionId, sink, input, target, @@ -98,7 +101,7 @@ export function useKeyboardSink({ input.flushText() copySeqRef.current += 1 const requestId = `copy-${copyPrefixRef.current}-${copySeqRef.current}` - if (!sendLive('mini_browser_copy', { tabId, requestId, cut })) return + if (!sendLive('mini_browser_copy', { sessionId, tabId, requestId, cut })) return const previous = copyRef.current if (previous) { previous.superseded = true @@ -139,7 +142,7 @@ export function useKeyboardSink({ input.sendTo(tabId, { kind: 'cut', requestId }) } }) - }, [input, onCopyFailed, target]) + }, [input, onCopyFailed, sessionId, target]) // The backend's copy reply arrives through the store. useEffect(() => { diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/live/useLiveInput.ts b/app/ui_layer/browser/frontend/src/components/MiniBrowser/live/useLiveInput.ts similarity index 95% rename from app/ui_layer/browser/frontend/src/pages/MiniBrowser/live/useLiveInput.ts rename to app/ui_layer/browser/frontend/src/components/MiniBrowser/live/useLiveInput.ts index 4e1728b24..674e77c49 100644 --- a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/live/useLiveInput.ts +++ b/app/ui_layer/browser/frontend/src/components/MiniBrowser/live/useLiveInput.ts @@ -94,7 +94,7 @@ const clampDelta = (value: number): number => Math.max(-WHEEL_MAX_DELTA, Math.mi export function createLiveInput( getTarget: () => LiveTarget, isTabOpen: TabOpenCheck, - sender: InputSender = sendInput, + sender: InputSender, ): LiveInput { let text = '' let textTabId: string | null = null @@ -277,9 +277,21 @@ export function createLiveInput( } } -/** The live view's input sender, stable for the component's lifetime. */ -export function useLiveInput(target: MutableRefObject, isTabOpen: TabOpenCheck): LiveInput { - const input = useMemo(() => createLiveInput(() => target.current, isTabOpen), [target, isTabOpen]) +/** The live view's input sender for one session's browser, stable for the + * component's lifetime (a panel is mounted per session). */ +export function useLiveInput( + sessionId: string, + target: MutableRefObject, + isTabOpen: TabOpenCheck, +): LiveInput { + const input = useMemo( + () => createLiveInput( + () => target.current, + isTabOpen, + (tabId, event) => sendInput(sessionId, tabId, event), + ), + [sessionId, target, isTabOpen], + ) useEffect(() => () => input.dispose(), [input]) return input } diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/live/usePointerInput.ts b/app/ui_layer/browser/frontend/src/components/MiniBrowser/live/usePointerInput.ts similarity index 100% rename from app/ui_layer/browser/frontend/src/pages/MiniBrowser/live/usePointerInput.ts rename to app/ui_layer/browser/frontend/src/components/MiniBrowser/live/usePointerInput.ts diff --git a/app/ui_layer/browser/frontend/src/components/MiniBrowser/useChatBrowser.tsx b/app/ui_layer/browser/frontend/src/components/MiniBrowser/useChatBrowser.tsx new file mode 100644 index 000000000..a017b037e --- /dev/null +++ b/app/ui_layer/browser/frontend/src/components/MiniBrowser/useChatBrowser.tsx @@ -0,0 +1,48 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react' +import type { BrowserPanelMode } from '../GenerativeUI/OutputWorkspace' +import { usePersistedState } from '../../hooks' +import { useAppSelector } from '../../store/hooks' +import { RESOURCES, useResource } from '../../store/resources' +import { selectMiniBrowserActivity } from '../../store/selectors/miniBrowser' +import { UI_STATE } from '../../store/uiState' +import { SessionBrowserPanel } from './SessionBrowserPanel' + +/** + * A chat's own browser as its panel's browser mode. + * + * Every chat session has a browser of its own. The panel opens on it by + * itself when the session's agent starts browsing (once per run: the + * activity's `browsing` turns true), but only for the chat on screen; a + * chat in the background just shows the activity dot when it comes back. + * A new Generative UI output takes the panel back (OutputWorkspace). + * Returns undefined while there is no session yet (a draft chat). + */ +export function useChatBrowser(sessionId: string, enabled: boolean): BrowserPanelMode | undefined { + // Which session browsers run: fetched on connect, then pushed on change. + useResource(RESOURCES.miniBrowserActivity) + const activity = useAppSelector(state => selectMiniBrowserActivity(state, sessionId)) + const running = activity?.status === 'starting' || activity?.status === 'ready' + const browsing = running && !!activity?.browsing + const [, setPanelMode] = usePersistedState(UI_STATE.chat.panelMode(sessionId)) + + // The agent started browsing in the chat on screen: open its browser. + const previous = useRef({ sessionId, browsing }) + useEffect(() => { + const before = previous.current + previous.current = { sessionId, browsing } + if (enabled && before.sessionId === sessionId && !before.browsing && browsing) { + setPanelMode('browser') + } + }, [enabled, sessionId, browsing, setPanelMode]) + + // One panel per session: its live view, input and subscription are that + // session's for their whole lifetime. + const render = useCallback( + () => , + [sessionId], + ) + return useMemo( + () => (enabled ? { running, browsing, render } : undefined), + [enabled, running, browsing, render], + ) +} diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/useErrorText.ts b/app/ui_layer/browser/frontend/src/components/MiniBrowser/useErrorText.ts similarity index 97% rename from app/ui_layer/browser/frontend/src/pages/MiniBrowser/useErrorText.ts rename to app/ui_layer/browser/frontend/src/components/MiniBrowser/useErrorText.ts index 320162e03..1fc591fca 100644 --- a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/useErrorText.ts +++ b/app/ui_layer/browser/frontend/src/components/MiniBrowser/useErrorText.ts @@ -22,6 +22,8 @@ const TRANSLATED_CODES = [ 'MINI_BROWSER_TAB_NOT_FOUND', 'MINI_BROWSER_TAB_CLOSED', 'MINI_BROWSER_TOO_MANY_TABS', + 'MINI_BROWSER_TOO_MANY_BROWSERS', + 'MINI_BROWSER_SESSION_GONE', 'MINI_BROWSER_TIMEOUT', 'MINI_BROWSER_VAULT_UNREADABLE', 'MINI_BROWSER_VAULT_INVALID', diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/useLatest.ts b/app/ui_layer/browser/frontend/src/components/MiniBrowser/useLatest.ts similarity index 100% rename from app/ui_layer/browser/frontend/src/pages/MiniBrowser/useLatest.ts rename to app/ui_layer/browser/frontend/src/components/MiniBrowser/useLatest.ts diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/useMiniBrowserSocket.ts b/app/ui_layer/browser/frontend/src/components/MiniBrowser/useMiniBrowserSocket.ts similarity index 66% rename from app/ui_layer/browser/frontend/src/pages/MiniBrowser/useMiniBrowserSocket.ts rename to app/ui_layer/browser/frontend/src/components/MiniBrowser/useMiniBrowserSocket.ts index 58fd7c60c..14a8fb6b4 100644 --- a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/useMiniBrowserSocket.ts +++ b/app/ui_layer/browser/frontend/src/components/MiniBrowser/useMiniBrowserSocket.ts @@ -2,8 +2,9 @@ import { useCallback, useEffect, useRef, type RefObject } from 'react' import { getSocketClient } from '../../store/socket/socketInstance' import type { MiniBrowserInputEvent, MiniBrowserViewport } from '../../types' -// The Mini Browser's one doorway to the socket. Everything else in the page -// goes through these helpers, so the two sending rules live in one place: +// The browser panel's one doorway to the socket. Everything else in the +// panel goes through these helpers, so the two sending rules live in one +// place: // // - Live traffic (input, resize, subscriptions, queries) is connected-only. // The shared client queues sends while offline and replays them on @@ -13,8 +14,10 @@ import type { MiniBrowserInputEvent, MiniBrowserViewport } from '../../types' // - User actions (navigate, tabs, start, take control…) use the normal // queued send like every other user action in the app. // -// Frames and the agent's pointer are read straight off the socket (see -// LiveView): they arrive many times a second and must not re-render React. +// Every chat session has a browser of its own: requests about a session's +// browser name it (`sessionId`). Frames and the agent's pointer are read +// straight off the socket (see LiveView): they arrive many times a second and +// must not re-render React. type Payload = Record @@ -31,9 +34,10 @@ export function sendAction(type: string, data: Payload = {}): void { getSocketClient().send(type, data) } -/** Forward one input event to a tab. Dropped while disconnected. */ -export function sendInput(tabId: string, event: MiniBrowserInputEvent): boolean { - return sendLive('mini_browser_input', { tabId, event }) +/** Forward one input event to a tab of a session's browser. Dropped while + * disconnected. */ +export function sendInput(sessionId: string, tabId: string, event: MiniBrowserInputEvent): boolean { + return sendLive('mini_browser_input', { sessionId, tabId, event }) } export function isSocketConnected(): boolean { @@ -53,16 +57,19 @@ const RESIZE_DEBOUNCE_MS = 150 const MIN_STAGE_PX = 50 /** - * Keeps this browser tab registered as a Mini Browser viewer while the page is - * mounted, the socket is connected and the document is visible, and keeps the - * remote viewport matched to the stage so frames fill it 1:1. + * Keeps this window registered as a viewer of a session's browser while its + * panel is mounted, the socket is connected and the document is visible, and + * keeps that browser's viewport matched to the stage so frames fill it 1:1. * - * Subscribing never starts Chromium; the backend replies with the current - * state and the last frame. After every (re)subscribe the stage size is sent - * again, since the backend may have restarted or another window may have - * resized the shared viewport meanwhile. + * Subscribing is opening the panel: the backend replies with the current + * state and the last frame, and starts the session's browser. After every + * (re)subscribe the stage size is sent again, since the backend may have + * restarted or another window may have resized the viewport meanwhile. The + * stage size is sent once it settles (the panel's open animation and a + * drag of its divider resize the stage on every frame). */ export function useMiniBrowserViewer( + sessionId: string, stageRef: RefObject, connected: boolean, reportedViewport: MiniBrowserViewport | null, @@ -79,24 +86,25 @@ export function useMiniBrowserViewer( if (width < MIN_STAGE_PX || height < MIN_STAGE_PX) return const key = `${width}x${height}` if (!force && key === lastSentRef.current) return - if (sendLive('mini_browser_resize', { width, height })) lastSentRef.current = key - }, [stageRef]) + if (sendLive('mini_browser_resize', { sessionId, width, height })) lastSentRef.current = key + }, [sessionId, stageRef]) useEffect(() => { if (!connected) return let subscribed = false const subscribe = () => { if (subscribed || document.visibilityState === 'hidden') return - if (!sendLive('mini_browser_subscribe')) return - subscribed = true + // The size first, so the browser starts at the panel's size. pushSize(true) + if (!sendLive('mini_browser_subscribe', { sessionId })) return + subscribed = true } const unsubscribe = () => { if (!subscribed) return subscribed = false - sendLive('mini_browser_unsubscribe') + sendLive('mini_browser_unsubscribe', { sessionId }) } - // A hidden tab stops the stream (the backend screencasts only while + // A hidden window stops the stream (the backend screencasts only while // someone watches) and resumes it when shown again. const onVisibility = () => (document.visibilityState === 'hidden' ? unsubscribe() : subscribe()) subscribe() @@ -105,7 +113,7 @@ export function useMiniBrowserViewer( document.removeEventListener('visibilitychange', onVisibility) unsubscribe() } - }, [connected, pushSize]) + }, [connected, pushSize, sessionId]) useEffect(() => { const el = stageRef.current @@ -116,8 +124,8 @@ export function useMiniBrowserViewer( timer = window.setTimeout(() => pushSize(false), RESIZE_DEBOUNCE_MS) }) observer.observe(el) - // Several CraftBot windows share one viewport; the window the user is - // looking at wins it back when focused (only if it actually differs). + // Two CraftBot windows on the same chat share its viewport; the window + // the user is looking at wins it back when focused (only if it differs). const onFocus = () => { const reported = reportedRef.current const width = Math.round(el.clientWidth) diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/useTabLabels.ts b/app/ui_layer/browser/frontend/src/components/MiniBrowser/useTabLabels.ts similarity index 56% rename from app/ui_layer/browser/frontend/src/pages/MiniBrowser/useTabLabels.ts rename to app/ui_layer/browser/frontend/src/components/MiniBrowser/useTabLabels.ts index d7c53cdb6..78ac57af8 100644 --- a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/useTabLabels.ts +++ b/app/ui_layer/browser/frontend/src/components/MiniBrowser/useTabLabels.ts @@ -1,7 +1,6 @@ import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useAppSelector } from '../../store/hooks' -import { selectMiniBrowserSessionId } from '../../store/selectors/miniBrowser' import { selectSessions } from '../../store/selectors/sessions' import type { MiniBrowserTab } from '../../types' @@ -27,51 +26,37 @@ export function isWebUrl(url: string): boolean { // The backend's English stand-in for a chat without a title. Shown as the // localized "Chat" instead. const BACKEND_UNTITLED_CHAT = 'Chat' -// Session ids the backend gives the main chat and sub-agents. +// The session id the backend gives the main chat. const MAIN_SESSION_ID = 'main' -const SUBAGENT_ID_PREFIX = 'sub_' /** - * Localized labels for tabs: who owns a tab ("You", "Main chat", the chat's - * title, "Sub-agent (Main chat)"…) and what to call it in the strip. Owner - * names are built here, from the owner's kind and the app's own session - * titles; the backend's English labels are only a last resort for sessions - * this window doesn't know. + * Localized labels for the tabs of one session's browser: who uses a tab + * ("You", "Agent" for the chat's own agent, "Sub-agent") and what to call it + * in the strip. Owner names are built here, from the owner's kind and the + * app's own session titles; the backend's English labels are only a last + * resort for sessions this window doesn't know. */ -export function useTabLabels() { +export function useTabLabels(sessionId: string) { const { t } = useTranslation(['minibrowser', 'common']) const sessions = useAppSelector(selectSessions) - const miniBrowserSessionId = useAppSelector(selectMiniBrowserSessionId) /** A chat session's name, by id. */ const sessionName = useCallback((id: string | null, backendLabel = ''): string => { if (id === MAIN_SESSION_ID) return t('minibrowser:tabs.owner.main') - if (id && id === miniBrowserSessionId) return t('minibrowser:tabs.owner.miniBrowser') - if (id?.startsWith(SUBAGENT_ID_PREFIX)) return t('minibrowser:tabs.owner.subagent') const session = id ? sessions.find(s => s.id === id) : undefined if (session?.type === 'main') return t('minibrowser:tabs.owner.main') - if (session?.type === 'mini_browser') return t('minibrowser:tabs.owner.miniBrowser') const title = session?.title.trim() || (backendLabel.trim() === BACKEND_UNTITLED_CHAT ? '' : backendLabel.trim()) return title || t('minibrowser:tabs.owner.session') - }, [miniBrowserSessionId, sessions, t]) + }, [sessions, t]) const ownerName = useCallback((tab: MiniBrowserTab): string => { - switch (tab.ownerKind) { - case 'user': - return t('minibrowser:tabs.owner.you') - case 'main': - return t('minibrowser:tabs.owner.main') - case 'mini_browser': - return t('minibrowser:tabs.owner.miniBrowser') - case 'subagent': - return tab.parentOwner - ? t('minibrowser:tabs.owner.subagentOf', { parent: sessionName(tab.parentOwner) }) - : t('minibrowser:tabs.owner.subagent') - case 'session': - default: - return sessionName(tab.owner, tab.ownerLabel) - } - }, [sessionName, t]) + if (tab.ownerKind === 'user') return t('minibrowser:tabs.owner.you') + if (tab.ownerKind === 'subagent') return t('minibrowser:tabs.owner.subagent') + // This chat's own agent (the browser is the chat's). + if (tab.owner === sessionId) return t('minibrowser:tabs.owner.agent') + if (tab.ownerKind === 'main') return t('minibrowser:tabs.owner.main') + return sessionName(tab.owner, tab.ownerLabel) + }, [sessionId, sessionName, t]) const tabTitle = useCallback((tab: MiniBrowserTab): string => { const title = tab.title.trim() diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/vaultForm.ts b/app/ui_layer/browser/frontend/src/components/MiniBrowser/vaultForm.ts similarity index 100% rename from app/ui_layer/browser/frontend/src/pages/MiniBrowser/vaultForm.ts rename to app/ui_layer/browser/frontend/src/components/MiniBrowser/vaultForm.ts diff --git a/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx b/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx index 3bf9586d1..a5509492e 100644 --- a/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx +++ b/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx @@ -7,7 +7,6 @@ import { MessageCircle, LayoutDashboard, FolderOpen, - Globe, Settings, Waypoints, Box, @@ -41,7 +40,7 @@ import { useAppSelector } from '../../store/hooks' import { UI_STATE } from '../../store/uiState' import { selectAgentAppProjects } from '../../store/selectors/agentApp' import { selectSkillMeta } from '../../store/selectors/agent' -import { selectMainSession, selectChatSessions, selectMiniBrowserSession } from '../../store/selectors/sessions' +import { selectMainSession, selectChatSessions } from '../../store/selectors/sessions' import { selectLastMessageIdBySession } from '../../store/selectors/messages' import { TopBar } from './TopBar' import styles from './NavBar.module.css' @@ -52,9 +51,6 @@ interface NavItem { icon: React.ReactNode path: string tourAnchor?: TourAnchorId - /** Chat session behind the page: its busy/unread dot shows on the item - * while the page isn't open. */ - sessionId?: string } // Sidebar title with a typewriter reveal: when the auto-title replaces the @@ -170,21 +166,11 @@ export function NavBar({ collapsed = false, onToggleCollapsed }: NavBarProps) { const location = useLocation() const navigate = useNavigate() - const miniBrowserSessionId = useAppSelector(selectMiniBrowserSession)?.id - const utilityNavItems: NavItem[] = useMemo(() => [ { id: 'dashboard', label: t('nav:items.dashboard'), icon: , path: '/dashboard', tourAnchor: 'nav-dashboard' }, { id: 'memory', label: t('nav:items.memory'), icon: , path: '/memory', tourAnchor: 'nav-memory' }, { id: 'workspace', label: t('nav:items.workspace'), icon: , path: '/workspace', tourAnchor: 'nav-workspace' }, - { - id: 'mini-browser', - label: t('nav:items.miniBrowser'), - icon: , - path: '/mini-browser', - tourAnchor: 'nav-mini-browser', - sessionId: miniBrowserSessionId, - }, - ], [t, miniBrowserSessionId]) + ], [t]) const settingsItem: NavItem = useMemo( () => ({ id: 'settings', label: t('nav:items.settings'), icon: , path: '/settings' }), @@ -869,11 +855,6 @@ export function NavBar({ collapsed = false, onToggleCollapsed }: NavBarProps) { > {item.icon} {item.label} - {item.sessionId && !isActive(item.path) && ( - collapsed - ? renderCollapsedDot([item.sessionId]) - : renderSessionDot(item.sessionId) - )} ))} diff --git a/app/ui_layer/browser/frontend/src/components/ui/ChromeTabs.module.css b/app/ui_layer/browser/frontend/src/components/ui/ChromeTabs.module.css new file mode 100644 index 000000000..4ce8eb8fb --- /dev/null +++ b/app/ui_layer/browser/frontend/src/components/ui/ChromeTabs.module.css @@ -0,0 +1,216 @@ +/* Tabs drawn like Chrome's: a frame-colored strip. The selected tab has the + color of what sits below the strip and flows into it (rounded top corners, + concave feet at the bottom); the other tabs sit flat on the frame with + thin separators between them. Fixed height, so what is below never + changes size. */ +.strip { + /* The frame is a shade darker than the selected tab, in both themes. */ + --tab-frame: color-mix(in srgb, var(--bg-secondary) 72%, #000); + --tab-active: var(--bg-secondary); + --tab-hover: color-mix(in srgb, var(--tab-frame), var(--tab-active) 55%); + --tab-radius: 8px; + + display: flex; + align-items: flex-end; + gap: 2px; + flex-shrink: 0; + height: 36px; + min-width: 0; + padding: 6px var(--space-2) 0 0; + background: var(--tab-frame); +} + +:global([data-theme='light']) .strip { + --tab-frame: color-mix(in srgb, var(--bg-secondary) 91%, #000); +} + +.tabs { + display: flex; + align-items: flex-end; + min-width: 0; + height: 100%; + /* Room for the first tab's left foot. */ + padding-left: var(--tab-radius); + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; +} + +.tabs::-webkit-scrollbar { + display: none; +} + +.tab { + position: relative; + display: flex; + align-items: center; + flex: 0 1 240px; + min-width: 96px; + height: 100%; + color: var(--text-secondary); + border-radius: var(--tab-radius) var(--tab-radius) 0 0; +} + +/* Hover: a rounded patch inside the tab, as in Chrome. */ +.tab:not(.tabSelected)::after { + content: ''; + position: absolute; + inset: 0 2px 4px; + border-radius: var(--tab-radius); + background: var(--tab-hover); + opacity: 0; + pointer-events: none; + transition: opacity var(--transition-fast); +} + +.tab:not(.tabSelected):hover::after { + opacity: 1; +} + +/* Separators between tabs: none next to the selected or a hovered tab. */ +.tab:not(.tabSelected)::before { + content: ''; + position: absolute; + top: 7px; + bottom: 11px; + left: 0; + width: 1px; + background: var(--border-primary); + pointer-events: none; +} + +.tab:not(.tabSelected):first-child::before, +.tabSelected + .tab::before, +.tab:not(.tabSelected):hover::before, +.tab:hover + .tab:not(.tabSelected)::before { + opacity: 0; +} + +.tab:hover { + color: var(--text-primary); +} + +.tabSelected { + z-index: 1; + color: var(--text-primary); + background: var(--tab-active); +} + +/* The selected tab's feet: concave curves that join it to what is below. */ +.tabSelected::before, +.tabSelected::after { + content: ''; + position: absolute; + bottom: 0; + width: var(--tab-radius); + height: var(--tab-radius); + pointer-events: none; +} + +.tabSelected::before { + left: calc(-1 * var(--tab-radius)); + background: radial-gradient( + circle at 0 0, + transparent calc(var(--tab-radius) - 0.5px), + var(--tab-active) var(--tab-radius) + ); +} + +.tabSelected::after { + right: calc(-1 * var(--tab-radius)); + background: radial-gradient( + circle at 100% 0, + transparent calc(var(--tab-radius) - 0.5px), + var(--tab-active) var(--tab-radius) + ); +} + +.tabButton { + position: relative; + z-index: 1; + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + height: 100%; + padding: 0 4px 4px 12px; + color: inherit; + font-size: var(--text-xs); + text-align: left; + border-radius: var(--tab-radius) var(--tab-radius) 0 0; + cursor: pointer; +} + +.tabButton:focus-visible { + outline: 2px solid var(--border-hover); + outline-offset: -3px; +} + +.tabIcon { + display: flex; + flex-shrink: 0; + color: var(--text-muted); +} + +/* The title fades out at the end instead of an ellipsis. */ +.tabTitle { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 20px), transparent); + mask-image: linear-gradient(to right, #000 calc(100% - 20px), transparent); +} + +.close { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 20px; + height: 20px; + margin: 0 8px 4px 2px; + color: var(--text-secondary); + border-radius: var(--radius-full); + cursor: pointer; + transition: background var(--transition-fast), color var(--transition-fast); +} + +.close:hover { + color: var(--text-primary); + background: var(--bg-selected-hover); +} + +/* Right after the last tab (Chrome's "+"), and the strip's own buttons at + its right end: round, centered on the tabs' content. */ +.after, +.actions { + display: flex; + align-items: center; + flex-shrink: 0; + gap: 2px; + align-self: center; + margin-bottom: 4px; +} + +.after { + margin-left: 2px; +} + +.actions { + margin-left: auto; +} + +.after > button { + border-radius: var(--radius-full); +} + +@media (prefers-reduced-motion: reduce) { + .tab:not(.tabSelected)::after, + .close { + transition: none; + } +} diff --git a/app/ui_layer/browser/frontend/src/components/ui/ChromeTabs.tsx b/app/ui_layer/browser/frontend/src/components/ui/ChromeTabs.tsx new file mode 100644 index 000000000..e598679c1 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/components/ui/ChromeTabs.tsx @@ -0,0 +1,155 @@ +import React, { useEffect, useRef, type ReactNode, type Ref } from 'react' +import { X } from 'lucide-react' +import styles from './ChromeTabs.module.css' + +export interface ChromeTab { + id: string + title: string + /** In the icon's place: a site icon, a kind, or a state (loading, ...). */ + icon: ReactNode + tooltip?: string + /** After the title (e.g. an agent mark). */ + marks?: ReactNode + /** Read with the tab by screen readers, not shown. */ + description?: string + /** An extra class on the tab, for the consumer's own state styling. */ + className?: string + /** The close button's accessible name. */ + closeLabel: string +} + +interface ChromeTabsProps { + /** The tab list's accessible name. */ + label: string + tabs: ChromeTab[] + selectedId: string | null + onSelect(id: string): void + onClose(id: string): void + /** The close buttons' tooltip. */ + closeTitle: string + /** Right after the last tab (Chrome's "+"). */ + after?: ReactNode + /** The strip's own buttons, at its right end. */ + actions?: ReactNode + /** The actions container: other components may render buttons into it. */ + actionsRef?: Ref +} + +const MIDDLE_BUTTON = 1 + +/** + * Tabs drawn like Chrome's (the shared look of the chat's browser and output + * panels). Keyboard: ←/→/Home/End move between tabs, Enter/Space selects + * one, Delete closes it. Middle-click closes too. The selected tab is kept + * in sight when it changes. + */ +export function ChromeTabs({ + label, + tabs, + selectedId, + onSelect, + onClose, + closeTitle, + after, + actions, + actionsRef, +}: ChromeTabsProps) { + const listRef = useRef(null) + + // Only the tab list scrolls (scrollIntoView would also scroll the panels + // around it, even ones that clip their overflow). + useEffect(() => { + const list = listRef.current + if (!selectedId || !list) return + const el = list.querySelector(`[data-tab-id="${CSS.escape(selectedId)}"]`) + if (!el) return + const listBox = list.getBoundingClientRect() + const tabBox = el.getBoundingClientRect() + if (tabBox.left < listBox.left) list.scrollLeft -= listBox.left - tabBox.left + else if (tabBox.right > listBox.right) list.scrollLeft += tabBox.right - listBox.right + }, [selectedId]) + + const focusableIndex = Math.max(0, tabs.findIndex(tab => tab.id === selectedId)) + + const onListKeyDown = (e: React.KeyboardEvent) => { + const buttons = Array.from(listRef.current?.querySelectorAll('[role="tab"]') ?? []) + const index = buttons.indexOf(document.activeElement as HTMLButtonElement) + if (index === -1) return + let next = -1 + if (e.key === 'ArrowRight') next = (index + 1) % buttons.length + else if (e.key === 'ArrowLeft') next = (index - 1 + buttons.length) % buttons.length + else if (e.key === 'Home') next = 0 + else if (e.key === 'End') next = buttons.length - 1 + else if (e.key === 'Delete') { + e.preventDefault() + const tab = tabs[index] + if (tab) onClose(tab.id) + return + } + if (next === -1) return + e.preventDefault() + buttons[next]?.focus() + } + + return ( +
+
+ {tabs.map((tab, index) => { + const selected = tab.id === selectedId + return ( +
{ + // Middle-click closes (and must not start autoscroll). + if (e.button === MIDDLE_BUTTON) e.preventDefault() + }} + onAuxClick={e => { + if (e.button !== MIDDLE_BUTTON) return + e.preventDefault() + onClose(tab.id) + }} + > + + +
+ ) + })} +
+ {after &&
{after}
} + {(actions || actionsRef) && ( +
+ {actions} +
+ )} +
+ ) +} diff --git a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx index ffaba7bbf..7dbd7684b 100644 --- a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx +++ b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx @@ -116,6 +116,8 @@ interface WebSocketContextType extends WebSocketState { deleteSession: (sessionId: string) => void renameSession: (sessionId: string, title: string) => void clearSession: (sessionId: string) => void + /** Close a Generative UI output for good (its code and saved progress). */ + closeGenerativeUI: (sessionId: string, artifactId: string) => void // Reasoning picker: a session's choice (the draft view keeps it locally // until its first message creates the session) setSessionReasoning: (sessionId: string, choice: ReasoningChoice) => void @@ -398,6 +400,10 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { sendOrQueue(JSON.stringify({ type: 'session_clear', sessionId })) }, [sendOrQueue]) + const closeGenerativeUI = useCallback((sessionId: string, artifactId: string) => { + sendOrQueue(JSON.stringify({ type: 'generative_ui_close', sessionId, artifactId })) + }, [sendOrQueue]) + const setSessionReasoning = useCallback((sessionId: string, choice: ReasoningChoice) => { if (sessionId === 'new') { dispatch(setUiState(UI_STATE.chat.draftReasoningEffort, choice)) @@ -654,6 +660,7 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { deleteSession, renameSession, clearSession, + closeGenerativeUI, setSessionReasoning, requestChatHistory, markSessionSeen, @@ -686,7 +693,7 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { setActiveAgentApp, updateAgentAppTheme, }), [ - state, sendMessage, sendCommand, stopSession, deleteSession, renameSession, clearSession, + state, sendMessage, sendCommand, stopSession, deleteSession, renameSession, clearSession, closeGenerativeUI, setSessionReasoning, requestChatHistory, markSessionSeen, openFile, openFolder, requestFilteredMetrics, subscribeDashboardMetrics, unsubscribeDashboardMetrics, requestOnboardingStep, submitOnboardingStep, skipOnboardingStep, goBackOnboardingStep, checkLocalLLM, diff --git a/app/ui_layer/browser/frontend/src/locales/en/chat.json b/app/ui_layer/browser/frontend/src/locales/en/chat.json index 303cb07c9..f37ce7688 100644 --- a/app/ui_layer/browser/frontend/src/locales/en/chat.json +++ b/app/ui_layer/browser/frontend/src/locales/en/chat.json @@ -3,18 +3,14 @@ "version": "Version {{revision}}", "openOutput": "Open output", "output": "Output", - "outputs": "Outputs", - "conversation": "Conversation", "showOutputs": "Show outputs", "outputPanel": "Output panel", - "selectOutput": "Select output revision", + "tabsLabel": "Outputs", + "close": "Close output", + "closeNamed": "Close {{title}}", + "closed": "Closed", + "versions": "Versions", "resizePanel": "Resize conversation and output", - "fullscreen": "Fullscreen output", - "exitFullscreen": "Exit fullscreen", - "closeOutput": "Back to conversation", - "viewport": "Output viewport", - "desktop": "Desktop", - "phone": "Phone", "label": "Generative UI", "loading": "Preparing your preview…", "error": "This preview needs attention", @@ -136,5 +132,14 @@ "replyTooltip": "Reply to this message", "copyTooltip": "Copy message", "copiedTooltip": "Copied!" + }, + "browserPanel": { + "show": "Show this chat's browser", + "running": "This chat's browser is on", + "browsing": "The agent is browsing", + "region": "Browser panel" + }, + "panel": { + "close": "Close panel" } } diff --git a/app/ui_layer/browser/frontend/src/locales/en/minibrowser.json b/app/ui_layer/browser/frontend/src/locales/en/minibrowser.json index 36ccead81..2269e0e3b 100644 --- a/app/ui_layer/browser/frontend/src/locales/en/minibrowser.json +++ b/app/ui_layer/browser/frontend/src/locales/en/minibrowser.json @@ -2,12 +2,6 @@ "page": { "label": "Mini Browser" }, - "chat": { - "placeholder": "Ask the agent to browse, search or fill in a form…", - "show": "Show chat", - "hide": "Hide chat", - "loading": "Getting the chat ready…" - }, "toolbar": { "label": "Browser controls", "back": "Back", @@ -53,7 +47,7 @@ "owner": { "you": "You", "main": "Main chat", - "miniBrowser": "This chat", + "agent": "Agent", "session": "Chat", "subagent": "Sub-agent", "subagentOf": "Sub-agent ({{parent}})" @@ -84,13 +78,17 @@ "status": { "connecting": "Connecting to the Mini Browser…", "starting": "Starting the browser…", + "resuming": "Picking up where you left off…", + "pausedTitle": "Browser paused", + "pausedBody": "Your pages reopen where you left off.", + "resume": "Resume", "installing": "Installing the browser…", "installingHint": "Downloading Chromium. This can take a few minutes.", "installLog": "Install log", "installDone": "Browser installed", "errorTitle": "The browser ran into a problem", "stoppedTitle": "Browse the web together with your agent", - "stoppedBody": "A real browser you share with your agents. Watch them work live, or step in whenever you like.", + "stoppedBody": "This chat's own browser. Watch your agent work live, or step in whenever you like.", "start": "Start browser", "openSite": "Open a site", "tryAsking": "Try asking the agent", @@ -108,7 +106,7 @@ "install": "Install browser", "installAgain": "Try installing again", "retry": "Try again", - "profileInUseHint": "Close the other CraftBot window that is using the Mini Browser, then try again." + "profileInUseHint": "Close the other CraftBot that is using this chat's browser, then try again." }, "events": { "download": "Downloaded {{name}}", @@ -125,7 +123,7 @@ }, "confirm": { "shutdownTitle": "Close the browser?", - "shutdownMessage": "An agent is still browsing. Closing the browser closes every tab, but an agent that is still working may open it again. To stop an agent, press Stop in its chat." + "shutdownMessage": "An agent is still browsing. Closing the browser closes every tab, but an agent that is still working may open it again. To stop the agent, press Stop." }, "passwords": { "title": "Saved passwords", @@ -194,7 +192,7 @@ }, "MINI_BROWSER_PROFILE_IN_USE": { "title": "Browser profile in use", - "message": "Another CraftBot window is already using the Mini Browser's profile." + "message": "Another CraftBot is already using this chat's browser profile." }, "MINI_BROWSER_LAUNCH_FAILED": { "title": "The browser didn't start", @@ -236,6 +234,14 @@ "title": "Too many tabs", "message": "Too many tabs are open. Close a tab, then try again." }, + "MINI_BROWSER_TOO_MANY_BROWSERS": { + "title": "Too many browsers open", + "message": "Other chats' browsers are running and in use. Close one of them, or wait for its agent to finish." + }, + "MINI_BROWSER_SESSION_GONE": { + "title": "Chat deleted", + "message": "This chat was deleted, so its browser is gone." + }, "MINI_BROWSER_TIMEOUT": { "title": "Timed out", "message": "That took too long. Try again." diff --git a/app/ui_layer/browser/frontend/src/locales/en/nav.json b/app/ui_layer/browser/frontend/src/locales/en/nav.json index a30169ff2..a94abf5e3 100644 --- a/app/ui_layer/browser/frontend/src/locales/en/nav.json +++ b/app/ui_layer/browser/frontend/src/locales/en/nav.json @@ -5,7 +5,6 @@ "dashboard": "Dashboard", "memory": "Memory", "workspace": "Workspace", - "miniBrowser": "Mini Browser", "settings": "Settings" }, "groups": { diff --git a/app/ui_layer/browser/frontend/src/locales/es/chat.json b/app/ui_layer/browser/frontend/src/locales/es/chat.json index 9713e856e..d5177215d 100644 --- a/app/ui_layer/browser/frontend/src/locales/es/chat.json +++ b/app/ui_layer/browser/frontend/src/locales/es/chat.json @@ -98,5 +98,14 @@ "replyTooltip": "Responder a este mensaje", "copyTooltip": "Copiar mensaje", "copiedTooltip": "¡Copiado!" + }, + "browserPanel": { + "show": "Mostrar el navegador de este chat", + "running": "El navegador de este chat está encendido", + "browsing": "El agente está navegando", + "region": "Panel del navegador" + }, + "panel": { + "close": "Cerrar panel" } } diff --git a/app/ui_layer/browser/frontend/src/locales/es/minibrowser.json b/app/ui_layer/browser/frontend/src/locales/es/minibrowser.json index 4d537f0bb..5dc846b6a 100644 --- a/app/ui_layer/browser/frontend/src/locales/es/minibrowser.json +++ b/app/ui_layer/browser/frontend/src/locales/es/minibrowser.json @@ -2,12 +2,6 @@ "page": { "label": "Mininavegador" }, - "chat": { - "placeholder": "Pide al agente que navegue o rellene formularios…", - "show": "Mostrar chat", - "hide": "Ocultar chat", - "loading": "Preparando el chat…" - }, "toolbar": { "label": "Controles del navegador", "back": "Atrás", @@ -53,7 +47,7 @@ "owner": { "you": "Tú", "main": "Chat principal", - "miniBrowser": "Este chat", + "agent": "Agente", "session": "Chat", "subagent": "Subagente", "subagentOf": "Subagente ({{parent}})" @@ -84,13 +78,17 @@ "status": { "connecting": "Conectando con el Mininavegador…", "starting": "Iniciando el navegador…", + "resuming": "Retomando donde lo dejaste…", + "pausedTitle": "Navegador en pausa", + "pausedBody": "Tus páginas se vuelven a abrir donde las dejaste.", + "resume": "Reanudar", "installing": "Instalando el navegador…", "installingHint": "Descargando Chromium. Puede tardar unos minutos.", "installLog": "Registro de instalación", "installDone": "Navegador instalado", "errorTitle": "El navegador tuvo un problema", "stoppedTitle": "Navega por la web junto a tu agente", - "stoppedBody": "Un navegador real que compartes con tus agentes. Míralos trabajar en directo o toma el control cuando quieras.", + "stoppedBody": "El navegador propio de este chat. Mira a tu agente trabajar en directo o toma el control cuando quieras.", "start": "Iniciar navegador", "openSite": "Abrir un sitio", "tryAsking": "Prueba a pedirle al agente", @@ -108,7 +106,7 @@ "install": "Instalar navegador", "installAgain": "Volver a instalar", "retry": "Reintentar", - "profileInUseHint": "Cierra la otra ventana de CraftBot que está usando el Mininavegador y vuelve a intentarlo." + "profileInUseHint": "Cierra el otro CraftBot que está usando el navegador de este chat y vuelve a intentarlo." }, "events": { "download": "Descargado: {{name}}", @@ -125,7 +123,7 @@ }, "confirm": { "shutdownTitle": "¿Cerrar el navegador?", - "shutdownMessage": "Un agente sigue navegando. Al cerrar el navegador se cierran todas las pestañas, pero un agente que siga trabajando puede volver a abrirlo. Para detener a un agente, pulsa Detener en su chat." + "shutdownMessage": "Un agente sigue navegando. Al cerrar el navegador se cierran todas las pestañas, pero un agente que siga trabajando puede volver a abrirlo. Para detener al agente, pulsa Detener." }, "passwords": { "title": "Contraseñas guardadas", @@ -194,7 +192,7 @@ }, "MINI_BROWSER_PROFILE_IN_USE": { "title": "Perfil del navegador en uso", - "message": "Otra ventana de CraftBot ya está usando el perfil del Mininavegador." + "message": "Otro CraftBot ya está usando el perfil del navegador de este chat." }, "MINI_BROWSER_LAUNCH_FAILED": { "title": "El navegador no se inició", @@ -236,6 +234,14 @@ "title": "Demasiadas pestañas", "message": "Hay demasiadas pestañas abiertas. Cierra una e inténtalo de nuevo." }, + "MINI_BROWSER_TOO_MANY_BROWSERS": { + "title": "Demasiados navegadores abiertos", + "message": "Los navegadores de otros chats están abiertos y en uso. Cierra uno o espera a que su agente termine." + }, + "MINI_BROWSER_SESSION_GONE": { + "title": "Chat eliminado", + "message": "Este chat se eliminó, así que su navegador ya no existe." + }, "MINI_BROWSER_TIMEOUT": { "title": "Se agotó el tiempo", "message": "Tardó demasiado. Inténtalo de nuevo." diff --git a/app/ui_layer/browser/frontend/src/locales/es/nav.json b/app/ui_layer/browser/frontend/src/locales/es/nav.json index 004fb5ae4..956b7a94b 100644 --- a/app/ui_layer/browser/frontend/src/locales/es/nav.json +++ b/app/ui_layer/browser/frontend/src/locales/es/nav.json @@ -5,7 +5,6 @@ "dashboard": "Panel", "memory": "Memoria", "workspace": "Espacio de trabajo", - "miniBrowser": "Mininavegador", "settings": "Ajustes" }, "groups": { diff --git a/app/ui_layer/browser/frontend/src/locales/id/chat.json b/app/ui_layer/browser/frontend/src/locales/id/chat.json index dfe7e89ee..3eacc84ab 100644 --- a/app/ui_layer/browser/frontend/src/locales/id/chat.json +++ b/app/ui_layer/browser/frontend/src/locales/id/chat.json @@ -98,5 +98,14 @@ "replyTooltip": "Balas pesan ini", "copyTooltip": "Salin pesan", "copiedTooltip": "Tersalin!" + }, + "browserPanel": { + "show": "Tampilkan peramban obrolan ini", + "running": "Peramban obrolan ini sedang menyala", + "browsing": "Agen sedang menjelajah", + "region": "Panel peramban" + }, + "panel": { + "close": "Tutup panel" } } diff --git a/app/ui_layer/browser/frontend/src/locales/id/minibrowser.json b/app/ui_layer/browser/frontend/src/locales/id/minibrowser.json index d04e8a2dc..0914d35b7 100644 --- a/app/ui_layer/browser/frontend/src/locales/id/minibrowser.json +++ b/app/ui_layer/browser/frontend/src/locales/id/minibrowser.json @@ -2,12 +2,6 @@ "page": { "label": "Peramban Mini" }, - "chat": { - "placeholder": "Minta agen menjelajah web atau mengisi formulir…", - "show": "Tampilkan obrolan", - "hide": "Sembunyikan obrolan", - "loading": "Menyiapkan obrolan…" - }, "toolbar": { "label": "Kontrol peramban", "back": "Kembali", @@ -53,7 +47,7 @@ "owner": { "you": "Anda", "main": "Obrolan utama", - "miniBrowser": "Obrolan ini", + "agent": "Agen", "session": "Obrolan", "subagent": "Subagen", "subagentOf": "Subagen ({{parent}})" @@ -84,13 +78,17 @@ "status": { "connecting": "Menyambung ke Peramban Mini…", "starting": "Memulai peramban…", + "resuming": "Melanjutkan dari tempat terakhir…", + "pausedTitle": "Peramban dijeda", + "pausedBody": "Halaman Anda dibuka kembali di tempat terakhir.", + "resume": "Lanjutkan", "installing": "Memasang peramban…", "installingHint": "Mengunduh Chromium. Ini bisa memakan waktu beberapa menit.", "installLog": "Log pemasangan", "installDone": "Peramban terpasang", "errorTitle": "Peramban mengalami masalah", "stoppedTitle": "Jelajahi web bersama agen Anda", - "stoppedBody": "Peramban sungguhan yang Anda gunakan bersama agen. Saksikan mereka bekerja secara langsung, atau ambil alih kapan saja.", + "stoppedBody": "Peramban milik obrolan ini sendiri. Saksikan agen Anda bekerja secara langsung, atau ambil alih kapan saja.", "start": "Mulai peramban", "openSite": "Buka situs", "tryAsking": "Coba minta agen", @@ -108,7 +106,7 @@ "install": "Pasang peramban", "installAgain": "Pasang ulang", "retry": "Coba lagi", - "profileInUseHint": "Tutup jendela CraftBot lain yang sedang memakai Peramban Mini, lalu coba lagi." + "profileInUseHint": "Tutup CraftBot lain yang sedang memakai peramban obrolan ini, lalu coba lagi." }, "events": { "download": "{{name}} telah diunduh", @@ -125,7 +123,7 @@ }, "confirm": { "shutdownTitle": "Tutup peramban?", - "shutdownMessage": "Agen masih menjelajah. Menutup peramban akan menutup semua tab, tetapi agen yang masih bekerja bisa membukanya lagi. Untuk menghentikan agen, tekan Hentikan di obrolannya." + "shutdownMessage": "Agen masih menjelajah. Menutup peramban akan menutup semua tab, tetapi agen yang masih bekerja bisa membukanya lagi. Untuk menghentikan agen, tekan Hentikan." }, "passwords": { "title": "Kata sandi tersimpan", @@ -193,7 +191,7 @@ }, "MINI_BROWSER_PROFILE_IN_USE": { "title": "Profil peramban sedang dipakai", - "message": "Jendela CraftBot lain sudah memakai profil Peramban Mini." + "message": "CraftBot lain sudah memakai profil peramban obrolan ini." }, "MINI_BROWSER_LAUNCH_FAILED": { "title": "Peramban gagal dimulai", @@ -235,6 +233,14 @@ "title": "Terlalu banyak tab", "message": "Terlalu banyak tab yang terbuka. Tutup salah satu tab, lalu coba lagi." }, + "MINI_BROWSER_TOO_MANY_BROWSERS": { + "title": "Terlalu banyak peramban terbuka", + "message": "Peramban obrolan lain sedang berjalan dan dipakai. Tutup salah satunya, atau tunggu agennya selesai." + }, + "MINI_BROWSER_SESSION_GONE": { + "title": "Obrolan dihapus", + "message": "Obrolan ini sudah dihapus, jadi perambannya juga hilang." + }, "MINI_BROWSER_TIMEOUT": { "title": "Waktu habis", "message": "Prosesnya terlalu lama. Coba lagi." diff --git a/app/ui_layer/browser/frontend/src/locales/id/nav.json b/app/ui_layer/browser/frontend/src/locales/id/nav.json index 504efbbec..09919e2c7 100644 --- a/app/ui_layer/browser/frontend/src/locales/id/nav.json +++ b/app/ui_layer/browser/frontend/src/locales/id/nav.json @@ -5,7 +5,6 @@ "dashboard": "Dasbor", "memory": "Memori", "workspace": "Ruang Kerja", - "miniBrowser": "Peramban Mini", "settings": "Pengaturan" }, "groups": { diff --git a/app/ui_layer/browser/frontend/src/locales/ja/chat.json b/app/ui_layer/browser/frontend/src/locales/ja/chat.json index 743c3719a..f894ad383 100644 --- a/app/ui_layer/browser/frontend/src/locales/ja/chat.json +++ b/app/ui_layer/browser/frontend/src/locales/ja/chat.json @@ -98,5 +98,14 @@ "replyTooltip": "このメッセージに返信", "copyTooltip": "メッセージをコピー", "copiedTooltip": "コピーしました!" + }, + "browserPanel": { + "show": "このチャットのブラウザを表示", + "running": "このチャットのブラウザは起動中です", + "browsing": "エージェントが閲覧中です", + "region": "ブラウザパネル" + }, + "panel": { + "close": "パネルを閉じる" } } diff --git a/app/ui_layer/browser/frontend/src/locales/ja/minibrowser.json b/app/ui_layer/browser/frontend/src/locales/ja/minibrowser.json index d10f203f5..d827760f7 100644 --- a/app/ui_layer/browser/frontend/src/locales/ja/minibrowser.json +++ b/app/ui_layer/browser/frontend/src/locales/ja/minibrowser.json @@ -2,12 +2,6 @@ "page": { "label": "ミニブラウザ" }, - "chat": { - "placeholder": "ウェブでの作業を頼んでみましょう…", - "show": "チャットを表示", - "hide": "チャットを非表示", - "loading": "チャットを準備しています…" - }, "toolbar": { "label": "ブラウザの操作", "back": "戻る", @@ -53,7 +47,7 @@ "owner": { "you": "あなた", "main": "メインチャット", - "miniBrowser": "このチャット", + "agent": "エージェント", "session": "チャット", "subagent": "サブエージェント", "subagentOf": "サブエージェント({{parent}})" @@ -84,13 +78,17 @@ "status": { "connecting": "ミニブラウザに接続しています…", "starting": "ブラウザを起動しています…", + "resuming": "前回の続きから再開しています…", + "pausedTitle": "ブラウザは一時停止中です", + "pausedBody": "ページは前回の続きから開き直します。", + "resume": "再開", "installing": "ブラウザをインストールしています…", "installingHint": "Chromium をダウンロードしています。数分かかることがあります。", "installLog": "インストールのログ", "installDone": "ブラウザをインストールしました", "errorTitle": "ブラウザで問題が発生しました", "stoppedTitle": "エージェントと一緒にブラウジング", - "stoppedBody": "エージェントと共有する本物のブラウザです。作業の様子をライブで見守り、いつでも操作を代われます。", + "stoppedBody": "このチャット専用のブラウザです。エージェントの作業をライブで見守り、いつでも操作を代われます。", "start": "ブラウザを起動", "openSite": "サイトを開く", "tryAsking": "エージェントにこんなふうに頼めます", @@ -108,7 +106,7 @@ "install": "ブラウザをインストール", "installAgain": "もう一度インストール", "retry": "再試行", - "profileInUseHint": "ミニブラウザを使っている別の CraftBot ウィンドウを閉じてから、もう一度お試しください。" + "profileInUseHint": "このチャットのブラウザを使っている別の CraftBot を閉じてから、もう一度お試しください。" }, "events": { "download": "{{name}} をダウンロードしました", @@ -125,7 +123,7 @@ }, "confirm": { "shutdownTitle": "ブラウザを閉じますか?", - "shutdownMessage": "エージェントがまだ閲覧中です。ブラウザを閉じるとすべてのタブが閉じますが、作業中のエージェントがもう一度ブラウザを開くことがあります。エージェントを止めるには、そのチャットの「停止」を押してください。" + "shutdownMessage": "エージェントがまだ閲覧中です。ブラウザを閉じるとすべてのタブが閉じますが、作業中のエージェントがもう一度ブラウザを開くことがあります。エージェントを止めるには「停止」を押してください。" }, "passwords": { "title": "保存したパスワード", @@ -193,7 +191,7 @@ }, "MINI_BROWSER_PROFILE_IN_USE": { "title": "ブラウザのプロファイルが使用中です", - "message": "別の CraftBot ウィンドウがミニブラウザのプロファイルを使用しています。" + "message": "別の CraftBot がこのチャットのブラウザのプロファイルを使用しています。" }, "MINI_BROWSER_LAUNCH_FAILED": { "title": "ブラウザを起動できませんでした", @@ -235,6 +233,14 @@ "title": "タブが多すぎます", "message": "開いているタブが多すぎます。タブを閉じてから、もう一度お試しください。" }, + "MINI_BROWSER_TOO_MANY_BROWSERS": { + "title": "開いているブラウザが多すぎます", + "message": "ほかのチャットのブラウザが起動中で使用されています。どれかを閉じるか、そのエージェントの作業が終わるまでお待ちください。" + }, + "MINI_BROWSER_SESSION_GONE": { + "title": "チャットが削除されました", + "message": "このチャットは削除されたため、そのブラウザもなくなりました。" + }, "MINI_BROWSER_TIMEOUT": { "title": "タイムアウトしました", "message": "時間がかかりすぎました。もう一度お試しください。" diff --git a/app/ui_layer/browser/frontend/src/locales/ja/nav.json b/app/ui_layer/browser/frontend/src/locales/ja/nav.json index cbeb16e49..d44956a3a 100644 --- a/app/ui_layer/browser/frontend/src/locales/ja/nav.json +++ b/app/ui_layer/browser/frontend/src/locales/ja/nav.json @@ -5,7 +5,6 @@ "dashboard": "ダッシュボード", "memory": "メモリー", "workspace": "ワークスペース", - "miniBrowser": "ミニブラウザ", "settings": "設定" }, "groups": { diff --git a/app/ui_layer/browser/frontend/src/locales/ko/chat.json b/app/ui_layer/browser/frontend/src/locales/ko/chat.json index 7fcd2224a..6600f6766 100644 --- a/app/ui_layer/browser/frontend/src/locales/ko/chat.json +++ b/app/ui_layer/browser/frontend/src/locales/ko/chat.json @@ -98,5 +98,14 @@ "replyTooltip": "이 메시지에 답장", "copyTooltip": "메시지 복사", "copiedTooltip": "복사됨!" + }, + "browserPanel": { + "show": "이 채팅의 브라우저 보기", + "running": "이 채팅의 브라우저가 켜져 있습니다", + "browsing": "에이전트가 탐색 중입니다", + "region": "브라우저 패널" + }, + "panel": { + "close": "패널 닫기" } } diff --git a/app/ui_layer/browser/frontend/src/locales/ko/minibrowser.json b/app/ui_layer/browser/frontend/src/locales/ko/minibrowser.json index 6f9e2a92f..826884394 100644 --- a/app/ui_layer/browser/frontend/src/locales/ko/minibrowser.json +++ b/app/ui_layer/browser/frontend/src/locales/ko/minibrowser.json @@ -2,12 +2,6 @@ "page": { "label": "미니 브라우저" }, - "chat": { - "placeholder": "에이전트에게 웹 작업을 맡겨 보세요…", - "show": "채팅 표시", - "hide": "채팅 숨기기", - "loading": "채팅을 준비하는 중…" - }, "toolbar": { "label": "브라우저 컨트롤", "back": "뒤로", @@ -53,7 +47,7 @@ "owner": { "you": "나", "main": "메인 채팅", - "miniBrowser": "이 채팅", + "agent": "에이전트", "session": "채팅", "subagent": "하위 에이전트", "subagentOf": "하위 에이전트({{parent}})" @@ -84,13 +78,17 @@ "status": { "connecting": "미니 브라우저에 연결하는 중…", "starting": "브라우저를 시작하는 중…", + "resuming": "마지막으로 보던 곳에서 이어서 여는 중…", + "pausedTitle": "브라우저 일시 중지됨", + "pausedBody": "페이지가 마지막으로 보던 곳에서 다시 열립니다.", + "resume": "다시 시작", "installing": "브라우저를 설치하는 중…", "installingHint": "Chromium을 다운로드하고 있습니다. 몇 분 정도 걸릴 수 있습니다.", "installLog": "설치 로그", "installDone": "브라우저를 설치했습니다", "errorTitle": "브라우저에 문제가 발생했습니다", "stoppedTitle": "에이전트와 함께 웹 탐색하기", - "stoppedBody": "에이전트와 함께 쓰는 진짜 브라우저입니다. 에이전트가 일하는 모습을 실시간으로 지켜보고, 언제든 직접 조작할 수 있습니다.", + "stoppedBody": "이 채팅 전용 브라우저입니다. 에이전트가 일하는 모습을 실시간으로 지켜보고, 언제든 직접 조작할 수 있습니다.", "start": "브라우저 시작", "openSite": "사이트 열기", "tryAsking": "에이전트에게 이렇게 요청해 보세요", @@ -108,7 +106,7 @@ "install": "브라우저 설치", "installAgain": "다시 설치", "retry": "다시 시도", - "profileInUseHint": "미니 브라우저를 사용 중인 다른 CraftBot 창을 닫은 뒤 다시 시도해 주세요." + "profileInUseHint": "이 채팅의 브라우저를 사용 중인 다른 CraftBot을 닫은 뒤 다시 시도해 주세요." }, "events": { "download": "{{name}} 다운로드 완료", @@ -125,7 +123,7 @@ }, "confirm": { "shutdownTitle": "브라우저를 닫을까요?", - "shutdownMessage": "에이전트가 아직 탐색 중입니다. 브라우저를 닫으면 모든 탭이 닫히지만, 작업 중인 에이전트가 브라우저를 다시 열 수 있습니다. 에이전트를 멈추려면 해당 채팅에서 '중지'를 누르세요." + "shutdownMessage": "에이전트가 아직 탐색 중입니다. 브라우저를 닫으면 모든 탭이 닫히지만, 작업 중인 에이전트가 브라우저를 다시 열 수 있습니다. 에이전트를 멈추려면 '중지'를 누르세요." }, "passwords": { "title": "저장된 비밀번호", @@ -193,7 +191,7 @@ }, "MINI_BROWSER_PROFILE_IN_USE": { "title": "브라우저 프로필 사용 중", - "message": "다른 CraftBot 창이 미니 브라우저의 프로필을 사용하고 있습니다." + "message": "다른 CraftBot이 이 채팅의 브라우저 프로필을 사용하고 있습니다." }, "MINI_BROWSER_LAUNCH_FAILED": { "title": "브라우저를 시작하지 못함", @@ -235,6 +233,14 @@ "title": "탭이 너무 많음", "message": "열린 탭이 너무 많습니다. 탭을 닫은 뒤 다시 시도해 주세요." }, + "MINI_BROWSER_TOO_MANY_BROWSERS": { + "title": "열린 브라우저가 너무 많음", + "message": "다른 채팅의 브라우저가 실행 중이며 사용되고 있습니다. 그중 하나를 닫거나 해당 에이전트의 작업이 끝날 때까지 기다려 주세요." + }, + "MINI_BROWSER_SESSION_GONE": { + "title": "채팅이 삭제됨", + "message": "이 채팅이 삭제되어 브라우저도 함께 사라졌습니다." + }, "MINI_BROWSER_TIMEOUT": { "title": "시간 초과", "message": "시간이 너무 오래 걸렸습니다. 다시 시도해 주세요." diff --git a/app/ui_layer/browser/frontend/src/locales/ko/nav.json b/app/ui_layer/browser/frontend/src/locales/ko/nav.json index 2aa4e8434..73e01d55f 100644 --- a/app/ui_layer/browser/frontend/src/locales/ko/nav.json +++ b/app/ui_layer/browser/frontend/src/locales/ko/nav.json @@ -5,7 +5,6 @@ "dashboard": "대시보드", "memory": "메모리", "workspace": "작업 공간", - "miniBrowser": "미니 브라우저", "settings": "설정" }, "groups": { diff --git a/app/ui_layer/browser/frontend/src/locales/zh-CN/chat.json b/app/ui_layer/browser/frontend/src/locales/zh-CN/chat.json index 286c7ea4b..bb293ede2 100644 --- a/app/ui_layer/browser/frontend/src/locales/zh-CN/chat.json +++ b/app/ui_layer/browser/frontend/src/locales/zh-CN/chat.json @@ -98,5 +98,14 @@ "replyTooltip": "回复此消息", "copyTooltip": "复制消息", "copiedTooltip": "已复制!" + }, + "browserPanel": { + "show": "显示本对话的浏览器", + "running": "本对话的浏览器已开启", + "browsing": "智能体正在浏览", + "region": "浏览器面板" + }, + "panel": { + "close": "关闭面板" } } diff --git a/app/ui_layer/browser/frontend/src/locales/zh-CN/minibrowser.json b/app/ui_layer/browser/frontend/src/locales/zh-CN/minibrowser.json index 2af761462..a3ef6bfbd 100644 --- a/app/ui_layer/browser/frontend/src/locales/zh-CN/minibrowser.json +++ b/app/ui_layer/browser/frontend/src/locales/zh-CN/minibrowser.json @@ -2,12 +2,6 @@ "page": { "label": "迷你浏览器" }, - "chat": { - "placeholder": "让智能体帮你浏览网页、搜索或填写表单…", - "show": "显示对话", - "hide": "隐藏对话", - "loading": "正在准备对话…" - }, "toolbar": { "label": "浏览器控件", "back": "后退", @@ -53,7 +47,7 @@ "owner": { "you": "你", "main": "主对话", - "miniBrowser": "本对话", + "agent": "智能体", "session": "对话", "subagent": "子智能体", "subagentOf": "子智能体({{parent}})" @@ -84,13 +78,17 @@ "status": { "connecting": "正在连接迷你浏览器…", "starting": "正在启动浏览器…", + "resuming": "正在从上次离开的地方继续…", + "pausedTitle": "浏览器已暂停", + "pausedBody": "你的页面会从上次离开的地方重新打开。", + "resume": "继续", "installing": "正在安装浏览器…", "installingHint": "正在下载 Chromium,可能需要几分钟。", "installLog": "安装日志", "installDone": "浏览器已安装", "errorTitle": "浏览器遇到问题", "stoppedTitle": "和智能体一起浏览网页", - "stoppedBody": "一个与智能体共用的真实浏览器。你可以实时观看它们工作,也可以随时接手操作。", + "stoppedBody": "本对话专属的浏览器。你可以实时观看智能体工作,也可以随时接手操作。", "start": "启动浏览器", "openSite": "打开网站", "tryAsking": "试着这样问智能体", @@ -108,7 +106,7 @@ "install": "安装浏览器", "installAgain": "重新安装", "retry": "重试", - "profileInUseHint": "请关闭正在使用迷你浏览器的另一个 CraftBot 窗口,然后重试。" + "profileInUseHint": "请关闭正在使用本对话浏览器的另一个 CraftBot,然后重试。" }, "events": { "download": "已下载 {{name}}", @@ -125,7 +123,7 @@ }, "confirm": { "shutdownTitle": "要关闭浏览器吗?", - "shutdownMessage": "智能体仍在浏览。关闭浏览器会关闭所有标签页,但仍在工作的智能体可能会再次打开它。要让智能体停下来,请在它的对话中点击“停止”。" + "shutdownMessage": "智能体仍在浏览。关闭浏览器会关闭所有标签页,但仍在工作的智能体可能会再次打开它。要让智能体停下来,请点击“停止”。" }, "passwords": { "title": "已保存的密码", @@ -193,7 +191,7 @@ }, "MINI_BROWSER_PROFILE_IN_USE": { "title": "浏览器配置文件正在使用中", - "message": "另一个 CraftBot 窗口正在使用迷你浏览器的配置文件。" + "message": "另一个 CraftBot 正在使用本对话浏览器的配置文件。" }, "MINI_BROWSER_LAUNCH_FAILED": { "title": "浏览器未能启动", @@ -235,6 +233,14 @@ "title": "标签页过多", "message": "打开的标签页太多了。请先关闭一个标签页,然后重试。" }, + "MINI_BROWSER_TOO_MANY_BROWSERS": { + "title": "打开的浏览器过多", + "message": "其他对话的浏览器正在运行并被使用。请关闭其中一个,或等待它的智能体完成。" + }, + "MINI_BROWSER_SESSION_GONE": { + "title": "对话已删除", + "message": "此对话已被删除,因此它的浏览器也不存在了。" + }, "MINI_BROWSER_TIMEOUT": { "title": "已超时", "message": "耗时过长,请重试。" diff --git a/app/ui_layer/browser/frontend/src/locales/zh-CN/nav.json b/app/ui_layer/browser/frontend/src/locales/zh-CN/nav.json index b6414f28f..f55a44568 100644 --- a/app/ui_layer/browser/frontend/src/locales/zh-CN/nav.json +++ b/app/ui_layer/browser/frontend/src/locales/zh-CN/nav.json @@ -5,7 +5,6 @@ "dashboard": "仪表盘", "memory": "记忆", "workspace": "工作区", - "miniBrowser": "迷你浏览器", "settings": "设置" }, "groups": { diff --git a/app/ui_layer/browser/frontend/src/locales/zh-TW/chat.json b/app/ui_layer/browser/frontend/src/locales/zh-TW/chat.json index fe46e78d9..907ddb81d 100644 --- a/app/ui_layer/browser/frontend/src/locales/zh-TW/chat.json +++ b/app/ui_layer/browser/frontend/src/locales/zh-TW/chat.json @@ -98,5 +98,14 @@ "replyTooltip": "回覆此訊息", "copyTooltip": "複製訊息", "copiedTooltip": "已複製!" + }, + "browserPanel": { + "show": "顯示這個對話的瀏覽器", + "running": "這個對話的瀏覽器已開啟", + "browsing": "代理正在瀏覽", + "region": "瀏覽器面板" + }, + "panel": { + "close": "關閉面板" } } diff --git a/app/ui_layer/browser/frontend/src/locales/zh-TW/minibrowser.json b/app/ui_layer/browser/frontend/src/locales/zh-TW/minibrowser.json index e0cbd799e..89a370c78 100644 --- a/app/ui_layer/browser/frontend/src/locales/zh-TW/minibrowser.json +++ b/app/ui_layer/browser/frontend/src/locales/zh-TW/minibrowser.json @@ -2,12 +2,6 @@ "page": { "label": "迷你瀏覽器" }, - "chat": { - "placeholder": "請代理幫你瀏覽網頁、搜尋或填寫表單…", - "show": "顯示對話", - "hide": "隱藏對話", - "loading": "正在準備對話…" - }, "toolbar": { "label": "瀏覽器控制項", "back": "上一頁", @@ -53,7 +47,7 @@ "owner": { "you": "你", "main": "主要對話", - "miniBrowser": "這個對話", + "agent": "代理", "session": "對話", "subagent": "子代理", "subagentOf": "子代理({{parent}})" @@ -84,13 +78,17 @@ "status": { "connecting": "正在連線到迷你瀏覽器…", "starting": "正在啟動瀏覽器…", + "resuming": "正在從上次離開的地方繼續…", + "pausedTitle": "瀏覽器已暫停", + "pausedBody": "你的頁面會從上次離開的地方重新開啟。", + "resume": "繼續", "installing": "正在安裝瀏覽器…", "installingHint": "正在下載 Chromium,可能需要幾分鐘。", "installLog": "安裝記錄", "installDone": "瀏覽器已安裝", "errorTitle": "瀏覽器發生問題", "stoppedTitle": "和代理一起瀏覽網頁", - "stoppedBody": "一個與代理共用的真實瀏覽器。你可以即時觀看它們工作,也可以隨時接手操作。", + "stoppedBody": "這個對話專屬的瀏覽器。你可以即時觀看代理工作,也可以隨時接手操作。", "start": "啟動瀏覽器", "openSite": "開啟網站", "tryAsking": "試著這樣請代理幫忙", @@ -108,7 +106,7 @@ "install": "安裝瀏覽器", "installAgain": "重新安裝", "retry": "重試", - "profileInUseHint": "請關閉正在使用迷你瀏覽器的另一個 CraftBot 視窗,然後再試一次。" + "profileInUseHint": "請關閉正在使用這個對話瀏覽器的另一個 CraftBot,然後再試一次。" }, "events": { "download": "已下載 {{name}}", @@ -125,7 +123,7 @@ }, "confirm": { "shutdownTitle": "要關閉瀏覽器嗎?", - "shutdownMessage": "代理仍在瀏覽中。關閉瀏覽器會關閉所有分頁,但仍在工作的代理可能會再次開啟它。若要讓代理停止,請在它的對話中按「停止」。" + "shutdownMessage": "代理仍在瀏覽中。關閉瀏覽器會關閉所有分頁,但仍在工作的代理可能會再次開啟它。若要讓代理停止,請按「停止」。" }, "passwords": { "title": "已儲存的密碼", @@ -193,7 +191,7 @@ }, "MINI_BROWSER_PROFILE_IN_USE": { "title": "瀏覽器設定檔使用中", - "message": "另一個 CraftBot 視窗正在使用迷你瀏覽器的設定檔。" + "message": "另一個 CraftBot 正在使用這個對話瀏覽器的設定檔。" }, "MINI_BROWSER_LAUNCH_FAILED": { "title": "瀏覽器無法啟動", @@ -235,6 +233,14 @@ "title": "分頁太多", "message": "開啟的分頁太多了。請先關閉一個分頁,然後再試一次。" }, + "MINI_BROWSER_TOO_MANY_BROWSERS": { + "title": "開啟的瀏覽器太多", + "message": "其他對話的瀏覽器正在執行並使用中。請關閉其中一個,或等它的代理完成。" + }, + "MINI_BROWSER_SESSION_GONE": { + "title": "對話已刪除", + "message": "這個對話已被刪除,因此它的瀏覽器也不存在了。" + }, "MINI_BROWSER_TIMEOUT": { "title": "已逾時", "message": "花費的時間太長,請再試一次。" diff --git a/app/ui_layer/browser/frontend/src/locales/zh-TW/nav.json b/app/ui_layer/browser/frontend/src/locales/zh-TW/nav.json index 7e4ab30c6..6d4e85d14 100644 --- a/app/ui_layer/browser/frontend/src/locales/zh-TW/nav.json +++ b/app/ui_layer/browser/frontend/src/locales/zh-TW/nav.json @@ -5,7 +5,6 @@ "dashboard": "儀表板", "memory": "記憶", "workspace": "工作區", - "miniBrowser": "迷你瀏覽器", "settings": "設定" }, "groups": { diff --git a/app/ui_layer/browser/frontend/src/pages/AgentApp/AgentAppPage.tsx b/app/ui_layer/browser/frontend/src/pages/AgentApp/AgentAppPage.tsx index e823f5bc5..95df4a5e6 100644 --- a/app/ui_layer/browser/frontend/src/pages/AgentApp/AgentAppPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/AgentApp/AgentAppPage.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState, useRef } from 'react' +import { flushSync } from 'react-dom' import { useTranslation } from 'react-i18next' import { useParams, useNavigate } from 'react-router-dom' import { @@ -291,6 +292,18 @@ export function AgentAppPage() { useEffect(() => { if (!isResizing) return + // The drag ends and the panel collapses in one render: a pointermove is a + // continuous event, so React would render the store's update (the panel + // closing) before this component's (the drag ending), while the drag + // still turns the panel's transition off, and the panel would jump shut. + const collapseFromDrag = () => { + dragRef.current = null + flushSync(() => { + setIsResizing(false) + setShowChat(false) + }) + } + const handlePointerMove = (e: PointerEvent) => { if (!dragRef.current) return if ( @@ -305,20 +318,16 @@ export function AgentAppPage() { if (!rect) return if (isMobile) { const ratio = (rect.bottom - e.clientY) / rect.height - // Dragged too small ⇒ collapse and end the drag. + // Dragged too small ⇒ collapse (animated) and end the drag. if (ratio < MOBILE_COLLAPSE_RATIO) { - dragRef.current = null - setIsResizing(false) - setShowChat(false) + collapseFromDrag() return } setMobileChatRatio(Math.max(MOBILE_MIN_RATIO, Math.min(MOBILE_MAX_RATIO, ratio))) } else { const newWidth = rect.right - e.clientX if (newWidth < PANEL_COLLAPSE_WIDTH) { - dragRef.current = null - setIsResizing(false) - setShowChat(false) + collapseFromDrag() return } setPanelWidth(Math.max(PANEL_MIN_WIDTH, Math.min(PANEL_MAX_WIDTH, newWidth))) @@ -453,6 +462,7 @@ export function AgentAppPage() {
diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/ChatPanel.module.css b/app/ui_layer/browser/frontend/src/pages/MiniBrowser/ChatPanel.module.css deleted file mode 100644 index c70f0651b..000000000 --- a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/ChatPanel.module.css +++ /dev/null @@ -1,218 +0,0 @@ -/* The chat beside the browser and the seam between them (the Agent App - page's pattern). Stacked (below 768px, or a row too narrow for both side - by side), the chat sits under the browser and the seam runs across. */ - -/* ── Seam ──────────────────────────────────────────────────────────────── */ - -/* A hairline divider with a pill grip; a transparent ::after widens the - grab zone. */ -.seam { - position: relative; - z-index: 3; - flex-shrink: 0; - align-self: stretch; - width: 1px; - padding: 0; - border: none; - background: var(--border-primary); - cursor: col-resize; - touch-action: none; - outline: none; -} - -.seam::after { - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: -4px; - width: 9px; -} - -.grip { - position: absolute; - top: 50%; - left: 50%; - width: 5px; - height: 58px; - transform: translate(-50%, -50%); - background: var(--text-muted); - border-radius: var(--radius-full); - pointer-events: none; - transition: background var(--transition-fast), width var(--transition-fast), height var(--transition-fast); -} - -.seam:hover .grip, -.seamActive .grip, -.seam:focus-visible .grip { - width: 7px; - background: var(--text-secondary); -} - -.seam:focus-visible .grip { - box-shadow: 0 0 0 2px var(--border-hover); -} - -/* Collapsed: the panel is gone and the seam parks at the right edge as a - half-pill tab — the way to bring the chat back. */ -.seamCollapsed { - width: 0; - overflow: visible; - cursor: pointer; -} - -.seamCollapsed::after { - display: none; -} - -.seamCollapsed .grip { - left: auto; - right: 0; - width: 5px; - height: 58px; - transform: translateY(-50%); - background: color-mix(in srgb, var(--text-muted) 40%, transparent); - border-radius: var(--radius-md) 0 0 var(--radius-md); - pointer-events: auto; -} - -.seamCollapsed:hover .grip, -.seamCollapsed:focus-visible .grip { - width: 7px; - background: var(--text-secondary); -} - -/* Covers everything mid-drag, so the live view never takes the pointer and - no text gets selected. */ -.overlay { - position: fixed; - inset: 0; - z-index: var(--z-sticky); - cursor: col-resize; - background: transparent; - user-select: none; -} - -.overlayRows { - cursor: row-resize; -} - -/* ── Panel ─────────────────────────────────────────────────────────────── */ - -.panel { - display: flex; - flex-direction: column; - flex-grow: 0; - flex-shrink: 0; - min-width: 0; - overflow: hidden; - background: var(--bg-primary); - transition: width 260ms cubic-bezier(0.22, 1, 0.36, 1); -} - -/* Track the pointer 1:1 while dragging (the animation is for collapse). */ -.panelDragging { - transition: none; -} - -.panelCollapsed { - pointer-events: none; -} - -/* Keeps the open size while the outer box animates, so the chat slides. */ -.panelInner { - display: flex; - flex-direction: column; - flex-shrink: 0; - height: 100%; -} - -.chatLoading { - display: flex; - flex: 1; - align-items: center; - justify-content: center; - gap: var(--space-2); - padding: var(--space-4); - color: var(--text-secondary); - font-size: var(--text-sm); -} - -.spin { - animation: spin 0.9s linear infinite; -} - -/* ── Stacked: the chat under the browser ───────────────────────────────── */ -/* Below 768px, and wherever the row is too narrow for the browser and the - chat side by side (ChatPanel sets .stacked on the seam and the panel). */ - -.seam.stacked { - width: auto; - height: 1px; - cursor: row-resize; -} - -.seam.stacked::after { - top: -4px; - bottom: auto; - left: 0; - right: 0; - width: auto; - height: 9px; -} - -.stacked .grip { - width: 42px; - height: 4px; -} - -.seam.stacked:hover .grip, -.seamActive.stacked .grip, -.seam.stacked:focus-visible .grip { - width: 60px; - height: 5px; -} - -.seamCollapsed.stacked { - width: 100%; - height: 0; -} - -.seamCollapsed.stacked .grip { - top: auto; - bottom: 0; - right: 50%; - left: auto; - width: 58px; - height: 11px; - transform: translateX(50%); - border-radius: var(--radius-md) var(--radius-md) 0 0; -} - -.seamCollapsed.stacked:hover .grip, -.seamCollapsed.stacked:focus-visible .grip { - width: 58px; - height: 14px; -} - -.panel.stacked { - width: 100% !important; - min-height: 0; - transition: flex-basis 260ms cubic-bezier(0.22, 1, 0.36, 1); -} - -/* Must follow the rule above to win when stacked too. */ -.panelDragging.stacked { - transition: none; -} - -.panelInner.stacked { - width: 100% !important; -} - -@media (prefers-reduced-motion: reduce) { - .panel, - .panel.stacked { - transition: none; - } -} diff --git a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/ChatPanel.tsx b/app/ui_layer/browser/frontend/src/pages/MiniBrowser/ChatPanel.tsx deleted file mode 100644 index f1cf7f66b..000000000 --- a/app/ui_layer/browser/frontend/src/pages/MiniBrowser/ChatPanel.tsx +++ /dev/null @@ -1,288 +0,0 @@ -import React, { memo, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type RefObject } from 'react' -import { useTranslation } from 'react-i18next' -import { Loader2 } from 'lucide-react' -import { Chat } from '../../components/Chat' -import { usePersistedState } from '../../hooks' -import { UI_STATE } from '../../store/uiState' -import styles from './ChatPanel.module.css' - -// Panel bounds (desktop: px width; mobile: share of the height). Dragging the -// panel below the COLLAPSE size snaps it shut — "drag it away to hide it". -const PANEL_MIN_WIDTH = 280 -const PANEL_MAX_WIDTH = 640 -const PANEL_COLLAPSE_WIDTH = 200 -// The browser keeps at least this much room next to the chat. -const BROWSER_MIN_WIDTH = 360 -// Narrower than this, the two can't sit side by side at their minimums -// (e.g. a ~800 px window with the sidebar open): the chat moves under the -// browser, collapsed until the user opens it. -export const SIDE_BY_SIDE_MIN_WIDTH = PANEL_MIN_WIDTH + BROWSER_MIN_WIDTH -const MOBILE_MIN_RATIO = 0.2 -const MOBILE_MAX_RATIO = 0.8 -const MOBILE_COLLAPSE_RATIO = 0.08 -const SEAM_KEY_STEP_PX = 24 -const SEAM_KEY_STEP_RATIO = 0.05 -const DRAG_THRESHOLD_PX = 4 -// Matches the layout's mobile breakpoint, where the panels stack. -const MOBILE_QUERY = '(max-width: 768px)' - -const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value)) - -/** Widest the panel may be in a row `total` px wide. */ -const maxPanelWidth = (total: number): number => - total > 0 ? clamp(total - BROWSER_MIN_WIDTH, PANEL_MIN_WIDTH, PANEL_MAX_WIDTH) : PANEL_MAX_WIDTH - -/** The chat itself. Memoized: browser state re-renders the page often, and - * the chat is the heaviest thing on it. */ -const ChatPane = memo(function ChatPane({ sessionId, placeholder, loadingText }: { - sessionId: string | null - placeholder: string - loadingText: string -}) { - if (!sessionId) { - return ( -
- - {loadingText} -
- ) - } - return -}) - -interface ChatPanelProps { - /** The row holding the browser and this panel; sizes are measured on it. */ - containerRef: RefObject - /** The Mini Browser's chat session (null until the backend reports it). */ - sessionId: string | null - /** The chat sits under the browser (narrow or mobile layout) rather than - * beside it; the page stacks its row accordingly. */ - onStackedChange?(stacked: boolean): void - /** Bumped to open the chat (e.g. an example prompt was put in it). */ - openSignal?: number -} - -/** - * The Mini Browser chat beside the browser, behind a seam: drag to resize, - * click (or Enter) to collapse/expand; arrows resize from the keyboard. Open - * state and sizes persist. Below 768px the panels stack and the seam runs - * horizontally — the same pattern as the Agent App page. Where the row is too - * narrow for both side by side, the chat also stacks under the browser, and - * starts collapsed (without changing the saved preference) so the browser - * keeps its room. Memoized, so browser updates on the page don't re-render it. - */ -export const ChatPanel = memo(function ChatPanel({ - containerRef, - sessionId, - onStackedChange, - openSignal = 0, -}: ChatPanelProps) { - const { t } = useTranslation(['minibrowser', 'common']) - const panelId = useId() - const [savedOpen, setSavedOpen] = usePersistedState(UI_STATE.miniBrowser.chatPanelOpen) - const [width, setWidth] = usePersistedState(UI_STATE.miniBrowser.chatPanelWidth) - const [mobileRatio, setMobileRatio] = usePersistedState(UI_STATE.miniBrowser.chatPanelMobileRatio) - const [isMobile, setIsMobile] = useState( - () => typeof window !== 'undefined' && !!window.matchMedia?.(MOBILE_QUERY).matches, - ) - const [containerWidth, setContainerWidth] = useState(0) - const [isResizing, setIsResizing] = useState(false) - // Narrow layout only: whether the user opened the chat (not saved). - const [narrowOpen, setNarrowOpen] = useState(false) - // A pointer interaction on the seam: a click toggles, a drag resizes. - const dragRef = useRef<{ startX: number; startY: number; moved: boolean } | null>(null) - - useEffect(() => { - const query = window.matchMedia?.(MOBILE_QUERY) - if (!query) return - const onChange = () => setIsMobile(query.matches) - onChange() - query.addEventListener('change', onChange) - return () => query.removeEventListener('change', onChange) - }, []) - - // Measured before the first paint, so a narrow window never flashes the - // side-by-side layout. On mount the row's own ref is attached only after - // this effect runs (React attaches a parent's refs after its children's - // layout effects), so the row is found as the seam's parent: this panel - // renders straight into it. - const seamRef = useRef(null) - useLayoutEffect(() => { - const el = containerRef.current ?? seamRef.current?.parentElement ?? null - if (!el) return - setContainerWidth(el.clientWidth) - if (typeof ResizeObserver === 'undefined') return - const observer = new ResizeObserver(() => setContainerWidth(el.clientWidth)) - observer.observe(el) - return () => observer.disconnect() - }, [containerRef]) - - const narrow = !isMobile && containerWidth > 0 && containerWidth < SIDE_BY_SIDE_MIN_WIDTH - const stacked = isMobile || narrow - const open = narrow ? narrowOpen : savedOpen - const setOpen = narrow ? setNarrowOpen : setSavedOpen - - // A wide row again: the next narrow spell starts collapsed again. - useEffect(() => { - if (!narrow) setNarrowOpen(false) - }, [narrow]) - - useLayoutEffect(() => { - onStackedChange?.(stacked) - }, [onStackedChange, stacked]) - - // Asked to show the chat, in whichever layout is current. - const seenOpenSignalRef = useRef(openSignal) - useEffect(() => { - if (openSignal === seenOpenSignalRef.current) return - seenOpenSignalRef.current = openSignal - setOpen(true) - }, [openSignal, setOpen]) - - const maxWidth = maxPanelWidth(containerWidth) - // The saved width, shrunk if the window no longer has room for it. - const shownWidth = clamp(width, PANEL_MIN_WIDTH, maxWidth) - - const toggle = useCallback(() => setOpen(value => !value), [setOpen]) - - const onSeamPointerDown = (e: React.PointerEvent) => { - if (e.button !== 0) return - e.preventDefault() - dragRef.current = { startX: e.clientX, startY: e.clientY, moved: false } - setIsResizing(true) - } - - useEffect(() => { - if (!isResizing) return - const finish = () => { - dragRef.current = null - setIsResizing(false) - } - const onMove = (e: PointerEvent) => { - const drag = dragRef.current - if (!drag) return - if (Math.abs(e.clientX - drag.startX) > DRAG_THRESHOLD_PX || Math.abs(e.clientY - drag.startY) > DRAG_THRESHOLD_PX) { - drag.moved = true - } - // A collapsed panel has nothing to resize: the seam is click-only. - if (!drag.moved || !open) return - const rect = containerRef.current?.getBoundingClientRect() - if (!rect) return - if (stacked) { - const ratio = (rect.bottom - e.clientY) / rect.height - if (ratio < MOBILE_COLLAPSE_RATIO) { - finish() - setOpen(false) - return - } - setMobileRatio(clamp(ratio, MOBILE_MIN_RATIO, MOBILE_MAX_RATIO)) - } else { - const next = rect.right - e.clientX - if (next < PANEL_COLLAPSE_WIDTH) { - finish() - setOpen(false) - return - } - setWidth(clamp(next, PANEL_MIN_WIDTH, maxPanelWidth(rect.width))) - } - } - const onUp = () => { - // No real movement: a click, which toggles the panel. - const wasClick = !!dragRef.current && !dragRef.current.moved - finish() - if (wasClick) toggle() - } - document.addEventListener('pointermove', onMove) - document.addEventListener('pointerup', onUp) - document.addEventListener('pointercancel', finish) - return () => { - document.removeEventListener('pointermove', onMove) - document.removeEventListener('pointerup', onUp) - document.removeEventListener('pointercancel', finish) - } - }, [isResizing, stacked, open, containerRef, setOpen, setWidth, setMobileRatio, toggle]) - - // Window-splitter keyboard: arrows resize, Enter/Space collapses/restores. - const onSeamKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - toggle() - return - } - if (!open) return - if (stacked && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { - e.preventDefault() - const delta = e.key === 'ArrowUp' ? SEAM_KEY_STEP_RATIO : -SEAM_KEY_STEP_RATIO - setMobileRatio(clamp(mobileRatio + delta, MOBILE_MIN_RATIO, MOBILE_MAX_RATIO)) - } else if (!stacked && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) { - e.preventDefault() - const delta = e.key === 'ArrowLeft' ? SEAM_KEY_STEP_PX : -SEAM_KEY_STEP_PX - setWidth(clamp(shownWidth + delta, PANEL_MIN_WIDTH, maxWidth)) - } - } - - // The splitter's value: the chat's share of the page, in percent. - const share = !open - ? 0 - : stacked - ? Math.round(mobileRatio * 100) - : containerWidth > 0 ? Math.round((shownWidth / containerWidth) * 100) : 0 - const label = open ? t('minibrowser:chat.hide') : t('minibrowser:chat.show') - - return ( - <> -
-
- - {/* Kept mounted so collapse/expand can animate; the inner box keeps its - open size so the chat slides out instead of reflowing. */} -
-
- -
-
- - {/* Covers the page mid-drag so the live view never takes the pointer. */} - {isResizing && ( - )}
diff --git a/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx b/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx index a5509492e..14d4b66f4 100644 --- a/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx +++ b/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx @@ -789,6 +789,7 @@ export function NavBar({ collapsed = false, onToggleCollapsed }: NavBarProps) { createdAt: '', lastActiveAt: '', reasoningEffort: null, + lastRequest: null, } return ( diff --git a/app/ui_layer/browser/frontend/src/locales/en/chat.json b/app/ui_layer/browser/frontend/src/locales/en/chat.json index f37ce7688..f7fca2f1b 100644 --- a/app/ui_layer/browser/frontend/src/locales/en/chat.json +++ b/app/ui_layer/browser/frontend/src/locales/en/chat.json @@ -74,6 +74,20 @@ "defaultTag": "Default", "notConfigurable": "Reasoning isn't adjustable for {{model}}", "clamped": "{{requested}} isn't available on this model, so {{effective}} is used.", + "context": { + "label": "Context", + "summarizesAt": "Summarizes at {{max}}" + }, + "cache": { + "label": "Cache", + "hit": "{{percent}} cached", + "notReported": "Not reported", + "notYet": "Not cached yet", + "timeLeft": "Time left", + "about": "~{{time}}", + "expired": "Expired", + "noExpiry": "No fixed expiry" + }, "choice": { "provider_default": "Provider default", "off": "Off", diff --git a/app/ui_layer/browser/frontend/src/locales/es/chat.json b/app/ui_layer/browser/frontend/src/locales/es/chat.json index d5177215d..8c7ac5b31 100644 --- a/app/ui_layer/browser/frontend/src/locales/es/chat.json +++ b/app/ui_layer/browser/frontend/src/locales/es/chat.json @@ -40,6 +40,20 @@ "defaultTag": "Predeterminado", "notConfigurable": "El razonamiento no es ajustable en {{model}}", "clamped": "{{requested}} no está disponible en este modelo, así que se usa {{effective}}.", + "context": { + "label": "Contexto", + "summarizesAt": "Se resume al llegar a {{max}}" + }, + "cache": { + "label": "Caché", + "hit": "{{percent}} en caché", + "notReported": "No informado", + "notYet": "Aún sin caché", + "timeLeft": "Tiempo restante", + "about": "~{{time}}", + "expired": "Caducada", + "noExpiry": "Sin caducidad fija" + }, "choice": { "provider_default": "Predeterminado del proveedor", "off": "Desactivado", diff --git a/app/ui_layer/browser/frontend/src/locales/id/chat.json b/app/ui_layer/browser/frontend/src/locales/id/chat.json index 3eacc84ab..c1bf6195e 100644 --- a/app/ui_layer/browser/frontend/src/locales/id/chat.json +++ b/app/ui_layer/browser/frontend/src/locales/id/chat.json @@ -40,6 +40,20 @@ "defaultTag": "Bawaan", "notConfigurable": "Penalaran tidak dapat diatur untuk {{model}}", "clamped": "{{requested}} tidak tersedia pada model ini, jadi {{effective}} yang digunakan.", + "context": { + "label": "Konteks", + "summarizesAt": "Diringkas pada {{max}}" + }, + "cache": { + "label": "Cache", + "hit": "{{percent}} dari cache", + "notReported": "Tidak dilaporkan", + "notYet": "Belum ada cache", + "timeLeft": "Sisa waktu", + "about": "~{{time}}", + "expired": "Kedaluwarsa", + "noExpiry": "Tanpa batas waktu tetap" + }, "choice": { "provider_default": "Bawaan penyedia", "off": "Mati", diff --git a/app/ui_layer/browser/frontend/src/locales/ja/chat.json b/app/ui_layer/browser/frontend/src/locales/ja/chat.json index f894ad383..a8e44aada 100644 --- a/app/ui_layer/browser/frontend/src/locales/ja/chat.json +++ b/app/ui_layer/browser/frontend/src/locales/ja/chat.json @@ -40,6 +40,20 @@ "defaultTag": "既定", "notConfigurable": "{{model}} では推論を調整できません", "clamped": "{{requested}} はこのモデルでは使えないため、{{effective}} を使用します。", + "context": { + "label": "コンテキスト", + "summarizesAt": "{{max}} で要約" + }, + "cache": { + "label": "キャッシュ", + "hit": "{{percent}} キャッシュ済み", + "notReported": "報告なし", + "notYet": "まだキャッシュなし", + "timeLeft": "残り時間", + "about": "~{{time}}", + "expired": "期限切れ", + "noExpiry": "有効期限なし" + }, "choice": { "provider_default": "プロバイダーの既定", "off": "オフ", diff --git a/app/ui_layer/browser/frontend/src/locales/ko/chat.json b/app/ui_layer/browser/frontend/src/locales/ko/chat.json index 6600f6766..7e91cc04a 100644 --- a/app/ui_layer/browser/frontend/src/locales/ko/chat.json +++ b/app/ui_layer/browser/frontend/src/locales/ko/chat.json @@ -40,6 +40,20 @@ "defaultTag": "기본값", "notConfigurable": "{{model}}에서는 추론을 조정할 수 없습니다", "clamped": "이 모델에서는 {{requested}}을(를) 사용할 수 없어 {{effective}}을(를) 사용합니다.", + "context": { + "label": "컨텍스트", + "summarizesAt": "{{max}}에서 요약" + }, + "cache": { + "label": "캐시", + "hit": "{{percent}} 캐시됨", + "notReported": "보고되지 않음", + "notYet": "아직 캐시 없음", + "timeLeft": "남은 시간", + "about": "~{{time}}", + "expired": "만료됨", + "noExpiry": "고정 만료 없음" + }, "choice": { "provider_default": "제공자 기본값", "off": "끄기", diff --git a/app/ui_layer/browser/frontend/src/locales/zh-CN/chat.json b/app/ui_layer/browser/frontend/src/locales/zh-CN/chat.json index bb293ede2..6d917a50a 100644 --- a/app/ui_layer/browser/frontend/src/locales/zh-CN/chat.json +++ b/app/ui_layer/browser/frontend/src/locales/zh-CN/chat.json @@ -40,6 +40,20 @@ "defaultTag": "默认", "notConfigurable": "{{model}} 不支持调整推理", "clamped": "此模型不支持“{{requested}}”,因此使用“{{effective}}”。", + "context": { + "label": "上下文", + "summarizesAt": "达到 {{max}} 时总结" + }, + "cache": { + "label": "缓存", + "hit": "{{percent}} 已缓存", + "notReported": "未报告", + "notYet": "尚未缓存", + "timeLeft": "剩余时间", + "about": "~{{time}}", + "expired": "已过期", + "noExpiry": "无固定有效期" + }, "choice": { "provider_default": "服务商默认", "off": "关闭", diff --git a/app/ui_layer/browser/frontend/src/locales/zh-TW/chat.json b/app/ui_layer/browser/frontend/src/locales/zh-TW/chat.json index 907ddb81d..472c49512 100644 --- a/app/ui_layer/browser/frontend/src/locales/zh-TW/chat.json +++ b/app/ui_layer/browser/frontend/src/locales/zh-TW/chat.json @@ -40,6 +40,20 @@ "defaultTag": "預設", "notConfigurable": "{{model}} 不支援調整推理", "clamped": "此模型不支援「{{requested}}」,因此使用「{{effective}}」。", + "context": { + "label": "上下文", + "summarizesAt": "達到 {{max}} 時摘要" + }, + "cache": { + "label": "快取", + "hit": "{{percent}} 已快取", + "notReported": "未回報", + "notYet": "尚未快取", + "timeLeft": "剩餘時間", + "about": "~{{time}}", + "expired": "已過期", + "noExpiry": "無固定有效期" + }, "choice": { "provider_default": "供應商預設", "off": "關閉", diff --git a/app/ui_layer/browser/frontend/src/store/slices/reasoningSlice.ts b/app/ui_layer/browser/frontend/src/store/slices/reasoningSlice.ts index fe229a391..045f57a17 100644 --- a/app/ui_layer/browser/frontend/src/store/slices/reasoningSlice.ts +++ b/app/ui_layer/browser/frontend/src/store/slices/reasoningSlice.ts @@ -7,12 +7,32 @@ import { register } from '../socket/messageRegistry' // so it tracks model switches; the CHOICE is per session and lives on the // session (SessionInfo.reasoningEffort) or, for a draft chat, in UI state. +/** How long the provider keeps a request's prompt cache. 'exact': guaranteed + * at least this long; 'typical': the provider's usual figure only. */ +export interface CacheLifetime { + kind: 'exact' | 'typical' + seconds: number +} + +/** What every model reports, adjustable reasoning or not. */ +interface ModelContext { + model: string + /** Where requests are served (provider, or its subscription backend). */ + surface: string + /** Input tokens a request may carry at each choice; the event stream is + * summarized when the next request would carry more. */ + inputBudgets: Record + /** null: the provider documents no cache lifetime. */ + cacheLifetime: CacheLifetime | null + /** false: responses never say what was cached, so no hit rate exists. */ + reportsCachedTokens: boolean +} + export type ReasoningOptions = /** The model has no reasoning rule: nothing is adjustable. */ - | { configurable: false; model: string } - | { + | (ModelContext & { configurable: false }) + | (ModelContext & { configurable: true - model: string /** Selectable choices in display order. */ choices: ReasoningChoice[] /** The model's default level (marked "Default"; new chats start here). */ @@ -21,7 +41,7 @@ export type ReasoningOptions = providerDefault: string /** Effective choice for every stored choice (pi-style clamping). */ resolution: Record - } + }) interface ReasoningState { options: ReasoningOptions | null diff --git a/app/ui_layer/browser/frontend/src/types/index.ts b/app/ui_layer/browser/frontend/src/types/index.ts index ea770b8c7..4defa4ae3 100644 --- a/app/ui_layer/browser/frontend/src/types/index.ts +++ b/app/ui_layer/browser/frontend/src/types/index.ts @@ -97,6 +97,22 @@ export interface SessionInfo { /** Always set by the backend; null only on the client-side placeholder for * the main session before the backend has sent it. */ reasoningEffort: ReasoningChoice | null + /** The session's last decision request as the provider reported it; null + * until its first request (also after a restart). */ + lastRequest: LastRequest | null +} + +/** One LLM request's context size and cache use (the reasoning menu readout). */ +export interface LastRequest { + /** Input tokens the request carried, cached ones included. */ + inputTokens: number + /** Of those, how many the provider served from its prompt cache. */ + cachedTokens: number + /** Epoch ms when the request started; cache lifetimes count from here. */ + startedAt: number + /** Where it was served and by which model: its cache belongs to them. */ + surface: string + model: string } // ───────────────────────────────────────────────────────────────────── diff --git a/app/ui_layer/commands/builtin/clear.py b/app/ui_layer/commands/builtin/clear.py index 25a362ac2..684b10927 100644 --- a/app/ui_layer/commands/builtin/clear.py +++ b/app/ui_layer/commands/builtin/clear.py @@ -1,4 +1,4 @@ -"""Clear command implementation — clears the current session's conversation.""" +"""Clear command implementation: starts the current session over.""" from __future__ import annotations @@ -10,7 +10,8 @@ class ClearCommand(Command): - """Clear the current session's conversation.""" + """Start the current session over: its conversation and the agent's + context of it are cleared, and it continues as a new session.""" @property def name(self) -> str: @@ -22,7 +23,7 @@ def aliases(self) -> List[str]: @property def description(self) -> str: - return "Clear this session's conversation" + return "Clear this session's conversation and context (starts it over)" @property def requires_session(self) -> bool: @@ -40,15 +41,17 @@ async def execute( """Execute the clear command for the session it was typed in.""" target = session_id or MAIN_SESSION_ID + # Start the session over on the agent side (stops a run in flight, + # resets its context and files). First, so the stopped run's last + # rows are cleared with the rest of the conversation. + await self._controller.agent.clear_session(target) + # Clear persisted chat + activity rows for this session from app.usage import get_action_storage, get_chat_storage get_chat_storage().clear_messages(target) get_action_storage().clear_items(target) - # Clear the agent-side session state (event stream, todos, budgets) - await self._controller.agent.clear_session(target) - # Tell the UI to drop the session's rendered conversation. Always # session-scoped: a /clear must never touch other sessions. adapter = self._controller.active_adapter diff --git a/app/usage/session_storage.py b/app/usage/session_storage.py index 595091d7a..fc23ed48c 100644 --- a/app/usage/session_storage.py +++ b/app/usage/session_storage.py @@ -87,6 +87,19 @@ def _init_db(self) -> None: ON event_records(stream_id, position) """) + # A session's LLM state per call type (LLMInterface.export_session): + # restored at boot so a restart continues the session on the + # provider's cached prefix instead of starting a new one. + cursor.execute(""" + CREATE TABLE IF NOT EXISTS llm_sessions ( + session_id TEXT NOT NULL, + call_type TEXT NOT NULL, + state_json TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (session_id, call_type) + ) + """) + # Old task-system tables are gone for good. cursor.execute("DROP TABLE IF EXISTS active_tasks") cursor.execute("DROP TABLE IF EXISTS conversation_history") @@ -131,6 +144,7 @@ def remove_session(self, session_id: str) -> None: conn.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,)) conn.execute("DELETE FROM event_records WHERE stream_id = ?", (session_id,)) conn.execute("DELETE FROM event_streams WHERE stream_id = ?", (session_id,)) + conn.execute("DELETE FROM llm_sessions WHERE session_id = ?", (session_id,)) conn.commit() def get_session(self, session_id: str) -> Optional[Dict[str, Any]]: @@ -257,6 +271,56 @@ def get_event_stream( return head_summary, records + # ─────────────────────── LLM Session Persistence ──────────────────────── + + def persist_llm_session( + self, session_id: str, state: Dict[str, Dict[str, Any]] + ) -> None: + """Replace a session's saved LLM state with ``state`` (call type -> + LLMInterface.export_session entry). An empty state removes it.""" + now = datetime.now(timezone.utc).isoformat() + rows = [] + for call_type, entry in state.items(): + try: + rows.append((session_id, call_type, json.dumps(entry), now)) + except (TypeError, ValueError) as e: + # A history that is not plain JSON cannot be restored as sent; + # the session then starts a new prefix after a restart. + logger.warning( + f"[SessionStorage] LLM state for {session_id}:{call_type} " + f"is not serializable, not saved: {e}" + ) + with sqlite3.connect(self._db_path) as conn: + conn.execute("DELETE FROM llm_sessions WHERE session_id = ?", (session_id,)) + conn.executemany( + """ + INSERT INTO llm_sessions (session_id, call_type, state_json, updated_at) + VALUES (?, ?, ?, ?) + """, + rows, + ) + conn.commit() + + def get_llm_session(self, session_id: str) -> Dict[str, Dict[str, Any]]: + """A session's saved LLM state (call type -> entry).""" + with sqlite3.connect(self._db_path) as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT call_type, state_json FROM llm_sessions WHERE session_id = ?", + (session_id,), + ) + rows = cursor.fetchall() + state: Dict[str, Dict[str, Any]] = {} + for call_type, state_json in rows: + try: + state[call_type] = json.loads(state_json) + except (ValueError, TypeError): + logger.warning( + f"[SessionStorage] Skipping corrupt LLM state for " + f"{session_id}:{call_type}" + ) + return state + # ─────────────────────── Utilities ─────────────────────────────────────── def clear_all(self) -> None: @@ -265,6 +329,7 @@ def clear_all(self) -> None: conn.execute("DELETE FROM sessions") conn.execute("DELETE FROM event_records") conn.execute("DELETE FROM event_streams") + conn.execute("DELETE FROM llm_sessions") conn.commit() logger.info("[SessionStorage] Cleared all session data") diff --git a/scripts/catalog_overrides.json b/scripts/catalog_overrides.json new file mode 100644 index 000000000..01051a8ea --- /dev/null +++ b/scripts/catalog_overrides.json @@ -0,0 +1,17 @@ +{ + "about": "Rows scripts/gen_model_catalog.py adds to the model catalog because its sources lack them. OpenAI's dated snapshots are listed same_as the alias that points to them, so they share its limits.", + "models": { + "openai/gpt-5-2025-08-07": {"same_as": "openai/gpt-5"}, + "openai/gpt-5-mini-2025-08-07": {"same_as": "openai/gpt-5-mini"}, + "openai/gpt-5-nano-2025-08-07": {"same_as": "openai/gpt-5-nano"}, + "openai/gpt-5.1-2025-11-13": {"same_as": "openai/gpt-5.1"}, + "openai/gpt-5.2-2025-12-11": {"same_as": "openai/gpt-5.2"}, + "openai/gpt-5.4-2026-03-05": {"same_as": "openai/gpt-5.4"}, + "openai/gpt-5.4-mini-2026-03-17": {"same_as": "openai/gpt-5.4-mini"}, + "openai/gpt-5.4-nano-2026-03-17": {"same_as": "openai/gpt-5.4-nano"}, + "openai/gpt-5.5-2026-04-23": {"same_as": "openai/gpt-5.5"}, + "openai/o3-2025-04-16": {"same_as": "openai/o3"}, + "openai/o3-mini-2025-01-31": {"same_as": "openai/o3-mini"}, + "openai/o4-mini-2025-04-16": {"same_as": "openai/o4-mini"} + } +} diff --git a/scripts/gen_model_catalog.py b/scripts/gen_model_catalog.py new file mode 100644 index 000000000..350b25fd5 --- /dev/null +++ b/scripts/gen_model_catalog.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Generate agent_core/core/models/model_catalog.json, the model catalog. + +The catalog holds each model's context window and, where the provider caps +input below the window, its input limit. LLMInterface budgets every request +from it (agent_core/core/models/catalog.py); a model with no row uses +settings.json model.context_window. + +Sources, all structured fields (nothing is inferred from a model's name): + +* models.dev (MODELS_DEV_URL): ``limit.context`` and ``limit.input`` of every + model of each provider profile's ``catalog_provider`` + (agent_core/core/models/provider_config.py), keyed ``/``. +* the Codex model catalogue (CODEX_CATALOG_URL): ``context_window`` of each + model on the ChatGPT-subscription surface, the window Codex runs it at. +* scripts/catalog_overrides.json: rows both sources lack. A row is either + ``{"same_as": ""}``, which copies a row the sources produced, + or ``{"context_window": N}`` with an optional ``"max_input_tokens"``. + +The output is deterministic (sorted, one row per line, no timestamps), so a +regeneration diff shows exactly which models changed. Run it by hand when +providers ship models; nothing is fetched at runtime. + +Usage: + python scripts/gen_model_catalog.py +""" + +from __future__ import annotations + +import argparse +import json +import ssl +import sys +import urllib.request +from pathlib import Path +from typing import Any, Dict + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +from agent_core.core.models.catalog import CATALOG_PATH # noqa: E402 +from agent_core.core.models.provider_config import PROVIDER_CONFIG # noqa: E402 +from agent_core.core.models.reasoning import ( # noqa: E402 + SUBSCRIPTION_AUTH_MODE, + reasoning_surface, +) + +MODELS_DEV_URL = "https://models.dev/api.json" +CODEX_CATALOG_URL = ( + "https://raw.githubusercontent.com/openai/codex/main/" + "codex-rs/models-manager/models.json" +) +OVERRIDES_PATH = Path(__file__).with_name("catalog_overrides.json") + +Row = Dict[str, int] + + +def _fetch_json(url: str) -> Any: + import certifi + + # certifi's bundle, not the OS store: a broken certificate in the Windows + # store fails the default context before any request is made. + context = ssl.create_default_context(cafile=certifi.where()) + request = urllib.request.Request( + url, headers={"User-Agent": "CraftBot gen_model_catalog.py"} + ) + with urllib.request.urlopen(request, timeout=60, context=context) as response: + return json.load(response) + + +def _positive_int(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value > 0 + + +def _models_dev_rows(models_dev: Dict[str, Any]) -> Dict[str, Row]: + rows: Dict[str, Row] = {} + for profile in PROVIDER_CONFIG.values(): + if profile.catalog_provider is None: + continue + models = models_dev[profile.catalog_provider]["models"] + for model_id, model in models.items(): + limit = model.get("limit") or {} + context = limit.get("context") + if not _positive_int(context): + continue + row: Row = {"context_window": context} + if _positive_int(limit.get("input")) and limit["input"] < context: + row["max_input_tokens"] = limit["input"] + rows[f"{profile.key}/{model_id}"] = row + return rows + + +def _codex_rows(codex: Dict[str, Any]) -> Dict[str, Row]: + surface = reasoning_surface("openai", SUBSCRIPTION_AUTH_MODE) + return { + f"{surface}/{model['slug']}": {"context_window": model["context_window"]} + for model in codex["models"] + if _positive_int(model.get("context_window")) + } + + +def _override_rows(sourced: Dict[str, Row]) -> Dict[str, Row]: + overrides = json.loads(OVERRIDES_PATH.read_text(encoding="utf-8"))["models"] + rows: Dict[str, Row] = {} + for key, override in overrides.items(): + if "same_as" in override: + target = sourced.get(override["same_as"]) + if target is None: + raise SystemExit( + f"{OVERRIDES_PATH.name}: {key} is same_as " + f"{override['same_as']}, which no source lists" + ) + rows[key] = dict(target) + continue + if not _positive_int(override.get("context_window")): + raise SystemExit(f"{OVERRIDES_PATH.name}: {key} needs a context_window") + row: Row = {"context_window": override["context_window"]} + if "max_input_tokens" in override: + if not _positive_int(override["max_input_tokens"]): + raise SystemExit( + f"{OVERRIDES_PATH.name}: {key} has an invalid max_input_tokens" + ) + row["max_input_tokens"] = override["max_input_tokens"] + rows[key] = row + return rows + + +def _render(rows: Dict[str, Row]) -> str: + items = sorted(rows.items()) + lines = [ + "{", + ' "generator": "scripts/gen_model_catalog.py",', + ' "sources": ' + + json.dumps( + [MODELS_DEV_URL, CODEX_CATALOG_URL, "scripts/catalog_overrides.json"] + ) + + ",", + ' "models": {', + ] + for index, (key, row) in enumerate(items): + comma = "," if index < len(items) - 1 else "" + lines.append(f" {json.dumps(key)}: {json.dumps(row, sort_keys=True)}{comma}") + lines += [" }", "}"] + return "\n".join(lines) + "\n" + + +def main() -> int: + argparse.ArgumentParser(description=__doc__).parse_args() + sourced = _models_dev_rows(_fetch_json(MODELS_DEV_URL)) + sourced.update(_codex_rows(_fetch_json(CODEX_CATALOG_URL))) + rows = {**sourced, **_override_rows(sourced)} + + text = _render(rows) + json.loads(text) # the hand-rendered layout must stay valid JSON + CATALOG_PATH.write_text(text, encoding="utf-8", newline="\n") + print(f"wrote {len(rows)} models to {CATALOG_PATH.relative_to(REPO_ROOT)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/conftest.py b/tests/conftest.py index 7b28746c2..874a9ebdc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,31 +45,8 @@ def _with_window(reload: bool = False): model["context_window"] = 128000 settings["model"] = model context = dict(settings.get("context") or {}) - context.setdefault("reserve_tokens", 16384) context.setdefault("keep_recent_tokens", 20000) settings["context"] = context return settings monkeypatch.setattr(app_config, "get_settings", _with_window) - - -@pytest.fixture -def event_stream_limits(monkeypatch): - """Pin how much recent history an EventStream keeps after a fold. - - The stream never folds on its own; tests call summarize_by_LLM() when - they want one. This pins keep_recent_tokens so a local settings.json - edit cannot change what an event-stream test exercises. The import is - inside the fixture so collecting unrelated tests does not pull in - event_stream (and sklearn with it). - """ - from agent_core.core.impl.event_stream import event_stream as event_stream_module - - def _pin(keep_recent_tokens: int = 10000) -> None: - monkeypatch.setattr( - event_stream_module, - "_configured_keep_recent_tokens", - lambda: keep_recent_tokens, - ) - - return _pin diff --git a/tests/llm/test_context_readout.py b/tests/llm/test_context_readout.py new file mode 100644 index 000000000..b6698639b --- /dev/null +++ b/tests/llm/test_context_readout.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +"""The reasoning menu's context and cache readout, and the KV cache under it. + +The readout shows a session's last decision request (its input against the +budget, how much the provider served from cache, and how long that cache +lasts). These tests pin: + +* recording it changes nothing that is sent (payloads stay byte-identical); +* it is recorded on the session path and on the stateless path alike; +* every provider whose profile accumulates a session history takes the + session path (a hand-kept subset of them used to re-send the whole prompt + every turn); +* cache lifetimes exist only where the provider documents one; +* the wire payloads the menu reads. +""" + +from __future__ import annotations + +import asyncio +import time +from types import SimpleNamespace + +import pytest + +from agent_core.core.impl.llm import LLMCallType +from agent_core.core.models.cache_lifetime import CacheLifetimeKind, cache_lifetime +from agent_core.core.models.catalog import ModelLimits +from agent_core.core.models.reasoning import ReasoningChoice +from agent_core.core.models.registry import session_cc_providers + +from .golden.conftest import build_interface + +ACTION = LLMCallType.ACTION_SELECTION + + +# ──────────────────────── recording the last request ──────────────────────── + + +def test_the_usage_key_changes_nothing_sent(monkeypatch): + iface, recorder = build_interface(monkeypatch, "openai", "gpt-6-luna") + asyncio.run(iface.generate_response_async("SYSTEM", "USER")) + asyncio.run( + iface.generate_response_async("SYSTEM", "USER", usage_key=f"s1:{ACTION}") + ) + first, second = recorder.calls + assert first["payload"] == second["payload"] + + +def test_a_stateless_decision_records_its_request(monkeypatch): + iface, _ = build_interface(monkeypatch, "openai", "gpt-6-luna") + asyncio.run(iface.generate_response_async("SYSTEM", "USER")) + assert iface.last_request("s1", ACTION) is None # no key: not a decision + + before = time.time() + asyncio.run( + iface.generate_response_async("SYSTEM", "USER", usage_key=f"s1:{ACTION}") + ) + usage = iface.last_request("s1", ACTION) + assert usage is not None + assert (usage.input_tokens, usage.cached_tokens) == (100, 0) + assert (usage.surface, usage.model) == ("openai", "gpt-6-luna") + assert before <= usage.started_at <= time.time() + + +def test_a_session_decision_records_its_request(monkeypatch): + iface, _ = build_interface(monkeypatch, "openai", "gpt-6-luna") + iface.create_session_cache("s1", ACTION, "SYSTEM") + iface.generate_response_with_session("s1", ACTION, "USER", log_response=False) + usage = iface.last_request("s1", ACTION) + assert usage is not None and usage.input_tokens == 100 + + +def test_the_record_outlives_a_fold_but_not_the_session(monkeypatch): + iface, _ = build_interface(monkeypatch, "openai", "gpt-6-luna") + iface.create_session_cache("s1", ACTION, "SYSTEM") + iface.generate_response_with_session("s1", ACTION, "USER", log_response=False) + iface.reset_session_history("s1", ACTION) # a fold + assert iface.last_request("s1", ACTION) is not None + iface.end_all_session_caches("s1") + assert iface.last_request("s1", ACTION) is None + + +# ─────────────────────────── the session path ─────────────────────────── + + +@pytest.mark.parametrize("provider", sorted(session_cc_providers())) +def test_every_accumulating_provider_takes_the_session_path(monkeypatch, provider): + iface, _ = build_interface(monkeypatch, provider, "some-model") + iface.create_session_cache("s1", ACTION, "SYSTEM") + assert iface.has_session_cache("s1", ACTION) + + +# ─────────────────────────── cache lifetimes ─────────────────────────── + + +def test_cache_lifetimes_exist_only_where_documented(): + luna = cache_lifetime("openai", "gpt-6-luna") + assert luna.kind is CacheLifetimeKind.EXACT and luna.seconds == 30 * 60 + older = cache_lifetime("openai", "gpt-5.2-2025-12-11") + assert older.kind is CacheLifetimeKind.TYPICAL and older.seconds == 5 * 60 + claude = cache_lifetime("anthropic", "claude-sonnet-4-6") + assert claude.kind is CacheLifetimeKind.EXACT and claude.seconds == 60 * 60 + # Undocumented or best-effort: no countdown. + assert cache_lifetime("openai", "gpt-6.1-sol", "subscription") is None + assert cache_lifetime("grok", "grok-4.3") is None + assert cache_lifetime("deepseek", "deepseek-v4-pro") is None + assert cache_lifetime("openrouter", "openai/gpt-6.1-sol") is None + assert cache_lifetime("lmstudio", "openai/gpt-oss-20b") is None + + +def test_the_gpt_5_6_rows_name_real_models(): + from agent_core.core.models.cache_lifetime import _MODEL_LIFETIMES + from agent_core.core.models.catalog import model_limits + + for model in _MODEL_LIFETIMES["openai"]: + assert model_limits("openai", model) is not None, model + + +def test_bedrock_counts_down_only_for_models_that_take_cache_points(monkeypatch): + claude, _ = build_interface( + monkeypatch, "bedrock", "us.anthropic.claude-haiku-4-5-20251001-v1:0" + ) + assert claude.prompt_cache_lifetime().seconds == 5 * 60 + llama, _ = build_interface(monkeypatch, "bedrock", "meta.llama3-70b-instruct-v1:0") + assert llama.prompt_cache_lifetime() is None + + +def test_providers_that_never_report_cached_tokens_say_so(monkeypatch): + fireworks, _ = build_interface(monkeypatch, "fireworks", "some-model") + openai, _ = build_interface(monkeypatch, "openai", "gpt-6-luna") + assert fireworks.reports_cached_tokens() is False + assert openai.reports_cached_tokens() is True + + +# ─────────────────────────── budgets per choice ─────────────────────────── + + +def test_each_choice_has_its_own_budget(monkeypatch): + import agent_core.core.impl.llm.interface as interface_module + + iface, _ = build_interface(monkeypatch, "openai", "gpt-6-luna") + iface.max_tokens = 8000 + monkeypatch.setattr( + interface_module, "model_limits", lambda *a: ModelLimits(200_000) + ) + assert iface.input_budget_for(ReasoningChoice.OFF) == 192_000 + assert iface.input_budget_for(ReasoningChoice.HIGH) == 168_000 + assert iface.input_budget_for(ReasoningChoice.XHIGH) == 136_000 + + +# ─────────────────────────── wire payloads ─────────────────────────── + + +def _adapter(llm, sent): + async def send_to(_ws, message): + sent.append(message) + + return SimpleNamespace( + _controller=SimpleNamespace(agent=SimpleNamespace(llm=llm)), + _send_to=send_to, + ) + + +def test_the_reasoning_options_carry_budgets_and_cache_facts(monkeypatch): + from app.ui_layer.adapters.browser_adapter import BrowserAdapter + + iface, _ = build_interface(monkeypatch, "openai", "gpt-6-luna") + sent = [] + asyncio.run( + BrowserAdapter._handle_reasoning_options_get(_adapter(iface, sent), None) + ) + data = sent[0]["data"] + assert data["surface"] == "openai" + assert set(data["inputBudgets"]) == {choice.value for choice in ReasoningChoice} + assert data["cacheLifetime"] == {"kind": "exact", "seconds": 1800} + assert data["reportsCachedTokens"] is True + + +def test_the_session_info_carries_the_last_request(monkeypatch): + from agent_core.core.session import Session + from app.ui_layer.adapters.browser_adapter import BrowserAdapter + + iface, _ = build_interface(monkeypatch, "openai", "gpt-6-luna") + adapter = _adapter(iface, []) + session = Session.from_dict({"id": "s1"}) + assert BrowserAdapter._session_info(adapter, session)["lastRequest"] is None + + asyncio.run( + iface.generate_response_async("SYSTEM", "USER", usage_key=f"s1:{ACTION}") + ) + last = BrowserAdapter._session_info(adapter, session)["lastRequest"] + assert last["inputTokens"] == 100 and last["cachedTokens"] == 0 + assert last["surface"] == "openai" and last["model"] == "gpt-6-luna" + assert abs(last["startedAt"] - time.time() * 1000) < 60_000 diff --git a/tests/llm/test_reasoning_rules.py b/tests/llm/test_reasoning_rules.py index 07ab26c9d..30ae8fbe6 100644 --- a/tests/llm/test_reasoning_rules.py +++ b/tests/llm/test_reasoning_rules.py @@ -747,8 +747,11 @@ def test_gemini_caller_budget_wins_over_the_session(monkeypatch, bound_session): def test_context_check_reserves_the_requests_reasoning_cap(monkeypatch, bound_session): + import agent_core.core.impl.llm.interface as interface_module import app.config as app_config + # Uncatalogued, so the settings window applies. + monkeypatch.setattr(interface_module, "model_limits", lambda *a: None) monkeypatch.setattr(app_config, "get_context_window", lambda: 40_000) prompt = "word " * 12_000 # ~12k tokens: fits 40k - 8k, not 40k - 32k @@ -824,11 +827,12 @@ def test_each_model_and_choice_is_logged_once(monkeypatch, bound_session): def test_fold_check_reserves_the_same_cap_as_the_pre_send_check( monkeypatch, bound_session ): + import agent_core.core.impl.llm.interface as interface_module import app.config as app_config - # window 128,000 - reserve 16,384 - the output reservation. + # window 128,000 - the output reservation. + monkeypatch.setattr(interface_module, "model_limits", lambda *a: None) monkeypatch.setattr(app_config, "get_context_window", lambda: 128_000) - monkeypatch.setattr(app_config, "get_reserve_tokens", lambda: 16_384) def assert_folds_after(iface, last_input_tokens: int) -> None: key = "task:action_selection" @@ -839,15 +843,15 @@ def assert_folds_after(iface, last_input_tokens: int) -> None: unruled, _ = build_interface(monkeypatch, "openai", "gpt-4o") unruled.max_tokens = APP_MAX_TOKENS - assert_folds_after(unruled, 103_616) # the caller's 8,000 + assert_folds_after(unruled, 120_000) # the caller's 8,000 ruled, _ = build_interface(monkeypatch, "openai", "gpt-5.2-2025-12-11") ruled.max_tokens = APP_MAX_TOKENS - assert_folds_after(ruled, 79_616) # default high: 32,000 + assert_folds_after(ruled, 96_000) # default high: 32,000 with bound_session(C.XHIGH): - assert_folds_after(ruled, 47_616) # 64,000 + assert_folds_after(ruled, 64_000) # 64,000 with bound_session(C.OFF): - assert_folds_after(ruled, 103_616) # no reasoning: the caller's 8,000 + assert_folds_after(ruled, 120_000) # no reasoning: the caller's 8,000 def test_decision_follows_a_model_switch(monkeypatch): @@ -1022,11 +1026,16 @@ def test_reasoning_options_handler(monkeypatch): unruled, _ = build_interface(monkeypatch, "openai", "gpt-4o") adapter = _adapter_with(SimpleNamespace(llm=unruled)) asyncio.run(adapter._handle_reasoning_options_get(ws=None)) - assert adapter.sent[0]["data"] == { + data = adapter.sent[0]["data"] + assert {key: data[key] for key in ("success", "model", "configurable")} == { "success": True, "model": "gpt-4o", "configurable": False, } + # No reasoning parameter is sent, so every choice has the same budget. + assert len(set(data["inputBudgets"].values())) == 1 + assert data["cacheLifetime"] == {"kind": "typical", "seconds": 300} + assert data["reportsCachedTokens"] is True def test_session_reasoning_set_handler(tmp_path: Path): @@ -1035,6 +1044,7 @@ def test_session_reasoning_set_handler(tmp_path: Path): agent = SimpleNamespace( session_manager=manager, set_session_reasoning_effort=manager.set_reasoning_effort, + llm=SimpleNamespace(last_request=lambda task_id, call_type: None), ) adapter = _adapter_with(agent) try: diff --git a/tests/llm/test_session_resume.py b/tests/llm/test_session_resume.py new file mode 100644 index 000000000..d2d740f3d --- /dev/null +++ b/tests/llm/test_session_resume.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +"""A restart continues each session on the provider's cached prefix. + +A session's LLM history used to live only in memory, so the first request +after a restart started a new session: the whole prompt as one new message, +of which a provider that caches at message boundaries reuses only the system +prompt (observed 2026-10-19: 8,305 of 21,053 cached). The history is now +saved with the session and restored at boot when it still matches the +restored event stream. These tests pin: + +* the first request after a restart starts with exactly the messages of the + last request before it (so the provider's cache applies); +* nothing is restored that would not match: another provider's history, a + stream that was folded or cleared since, a turn not yet marked as sent; +* the saved state survives JSON and the session storage. +""" + +from __future__ import annotations + +import json + +from agent_core.core.event_stream.event import EventRecord +from agent_core.core.impl.event_stream.event_stream import EventStream +from agent_core.core.impl.llm import LLMCallType + +from .golden.conftest import build_interface + +ACTION = LLMCallType.ACTION_SELECTION + + +def _decide(iface, stream, task_id="s1"): + """One decision the way the router makes it on the session path.""" + if stream.has_session_sync(ACTION): + prompt, _ = stream.get_delta_events(ACTION) + else: + iface.reset_session_history(task_id, ACTION) + prompt = stream.to_prompt_snapshot() + iface.generate_response_with_session( + task_id, + ACTION, + prompt, + system_prompt_for_new_session="SYSTEM", + log_response=False, + ) + stream.mark_session_synced(ACTION) + iface.checkpoint_session(task_id, ACTION, stream.sync_marker(ACTION)) + + +def _session_before_restart(monkeypatch): + iface, recorder = build_interface(monkeypatch, "openai", "gpt-6-luna") + iface.create_session_cache("s1", ACTION, "SYSTEM") + stream = EventStream(llm=None, temp_dir=None) + stream.log("action_end", "event one") + _decide(iface, stream) + stream.log("action_end", "event two") + _decide(iface, stream) + return iface, recorder, stream + + +def _restart(monkeypatch, iface, stream, provider="openai", model="gpt-6-luna"): + """Save, then rebuild everything from what was saved, as a boot does.""" + saved = json.loads(json.dumps(iface.export_session("s1"))) + records = [EventRecord.from_dict(r.to_dict()) for r in stream.tail_events] + head = stream.head_summary + + new_iface, recorder = build_interface(monkeypatch, provider, model) + new_iface.create_session_cache("s1", ACTION, "SYSTEM") + new_stream = EventStream(llm=None, temp_dir=None) + new_stream.head_summary = head + new_stream.tail_events = records + synced = new_iface.import_session("s1", saved, new_stream) + return new_iface, recorder, new_stream, synced + + +def test_the_first_request_after_a_restart_continues_the_cached_prefix(monkeypatch): + iface, recorder, stream = _session_before_restart(monkeypatch) + before = recorder.calls[-1]["payload"]["messages"] + + new_iface, new_recorder, new_stream, synced = _restart(monkeypatch, iface, stream) + assert synced == len(stream.tail_events) + + new_stream.log("action_end", "event three") + _decide(new_iface, new_stream) + after = new_recorder.calls[-1]["payload"]["messages"] + + assert after[: len(before)] == before + assert "event three" in after[-1]["content"] + assert "event two" not in after[-1]["content"] # already sent before the restart + + +def test_the_readout_survives_a_restart(monkeypatch): + iface, _, stream = _session_before_restart(monkeypatch) + new_iface, _, _, _ = _restart(monkeypatch, iface, stream) + assert new_iface.last_request("s1", ACTION) == iface.last_request("s1", ACTION) + + +def test_another_providers_history_is_not_restored(monkeypatch): + iface, _, stream = _session_before_restart(monkeypatch) + new_iface, _, new_stream, synced = _restart( + monkeypatch, iface, stream, provider="anthropic", model="claude-sonnet-4-6" + ) + assert synced is None + assert not new_stream.has_session_sync(ACTION) + assert new_iface._session_histories == {} + + +def test_a_stream_folded_since_is_not_continued(monkeypatch): + iface, _, stream = _session_before_restart(monkeypatch) + saved = json.loads(json.dumps(iface.export_session("s1"))) + stream.head_summary = "a fold happened after the save" + new_iface, _ = build_interface(monkeypatch, "openai", "gpt-6-luna") + assert new_iface.import_session("s1", saved, stream) is None + + +def test_a_cleared_stream_is_not_continued(monkeypatch): + iface, _, stream = _session_before_restart(monkeypatch) + saved = json.loads(json.dumps(iface.export_session("s1"))) + stream.clear() + stream.log("action_end", "a new conversation") + new_iface, _ = build_interface(monkeypatch, "openai", "gpt-6-luna") + assert new_iface.import_session("s1", saved, stream) is None + + +def test_a_turn_not_yet_marked_sent_is_not_saved(monkeypatch): + # The history grows in the worker thread before the router marks the + # stream synced; a save in between must stop at the last checkpoint. + iface, _, stream = _session_before_restart(monkeypatch) + checkpointed = iface.export_session("s1")[ACTION]["history"] + stream.log("action_end", "event three") + prompt, _ = stream.get_delta_events(ACTION) + iface.generate_response_with_session( + "s1", ACTION, prompt, system_prompt_for_new_session="SYSTEM", log_response=False + ) + assert iface.export_session("s1")[ACTION]["history"] == checkpointed + + +def test_a_fold_drops_the_checkpoint(monkeypatch): + iface, _, _ = _session_before_restart(monkeypatch) + iface.reset_session_history("s1", ACTION) + assert "history" not in iface.export_session("s1")[ACTION] + + +def test_the_saved_state_round_trips_through_session_storage(tmp_path, monkeypatch): + from app.usage.session_storage import SessionStorage + + storage = SessionStorage(db_path=str(tmp_path / "sessions.db")) + iface, _, _ = _session_before_restart(monkeypatch) + state = iface.export_session("s1") + storage.persist_llm_session("s1", state) + assert storage.get_llm_session("s1") == json.loads(json.dumps(state)) + + storage.persist_llm_session("s1", {}) # a cleared session + assert storage.get_llm_session("s1") == {} + + storage.persist_llm_session("s1", state) + storage.remove_session("s1") + assert storage.get_llm_session("s1") == {} + + +def test_events_after_the_restored_sync_point_stay_unsent(monkeypatch): + # They reach the model with the first request after the restart; until + # then a fold must not collapse them (see test_event_stream_delivery.py). + iface, _, stream = _session_before_restart(monkeypatch) + stream.log("action_end", "logged after the last request") + _, _, new_stream, synced = _restart(monkeypatch, iface, stream) + new_stream.mark_delivered(synced) # what the boot restore does + delivered = [record.delivered for record in new_stream.tail_events] + assert delivered == [True] * synced + [False] * (len(delivered) - synced) + assert not delivered[-1] diff --git a/tests/test_action_set_loading.py b/tests/test_action_set_loading.py index e3c250428..7b66ae109 100644 --- a/tests/test_action_set_loading.py +++ b/tests/test_action_set_loading.py @@ -92,10 +92,9 @@ def generate_response(self, **kw): # pragma: no cover - never folds here @pytest.fixture -def loader(sessions, tmp_path, monkeypatch, event_stream_limits): +def loader(sessions, tmp_path, monkeypatch): import app.internal_action_interface as iai - event_stream_limits(10000) stream = EventStream(llm=_NoLLM(), temp_dir=tmp_path / "events") monkeypatch.setattr(iai.InternalActionInterface, "session_manager", sessions) monkeypatch.setattr( diff --git a/tests/test_context_budget.py b/tests/test_context_budget.py index 71606962d..1ac28d0a8 100644 --- a/tests/test_context_budget.py +++ b/tests/test_context_budget.py @@ -5,14 +5,19 @@ threshold on the event stream alone and an independent cap on the session history. There is one decision now, and it is made on the whole request the way pi and OpenClaw make it -- the provider's own input count for the previous -request plus what is new -- against the window less the headroom the summary -request needs. These tests pin: +request plus what is new -- against the serving model's input budget: its +window (from the model catalog; settings.json for a model the catalog does not +list) less the output cap the request sends, never above the provider's input +limit. These tests pin: * the history cap is gone; a fold resets the history WITHOUT ending the session, so the router keeps the session path and its cached prefix; * the cache markers the providers need are still on the wire; * the provider's input count is recorded per session and drives fits_context; -* the event stream never folds on its own -- only on request; +* the budget comes from the serving model: catalog window, input cap, and the + settings window only for an uncatalogued model; +* the event stream never folds on its own -- only on request -- and a fold + keeps at most half of the room the budget leaves; * an overflow is surfaced as one typed error, never counted as a provider failure, never retried on a fallback provider; * the settings are configuration with shipped defaults, invalid values error. @@ -23,16 +28,18 @@ import pytest +import agent_core.core.impl.llm.interface as interface_module from agent_core.core.errors import ErrorCategory from agent_core.core.impl.llm.interface import LLMContextOverflowError, LLMInterface +from agent_core.core.models.catalog import ModelLimits, model_limits from agent_core.utils.token import count_tokens from app import config as app_config from app.models.factory import ModelFactory WINDOW = 128000 -RESERVE = 16384 MAX_TOKENS = 8000 -FOLD_POINT = WINDOW - RESERVE - MAX_TOKENS # 103,616 input tokens +# grok-3 is not catalogued, so the settings window applies. +FOLD_POINT = WINDOW - MAX_TOKENS # 120,000 input tokens def _ctx(provider, model, anthropic_client=None): @@ -63,13 +70,10 @@ def _make(provider="grok", model="grok-3", anthropic_client=None): ) -def _settings(context_window=WINDOW, reserve_tokens=RESERVE, keep_recent_tokens=20000): +def _settings(context_window=WINDOW, keep_recent_tokens=20000): return { "model": {"context_window": context_window}, - "context": { - "reserve_tokens": reserve_tokens, - "keep_recent_tokens": keep_recent_tokens, - }, + "context": {"keep_recent_tokens": keep_recent_tokens}, } @@ -147,6 +151,34 @@ def test_router_decides_on_the_request_and_restarts_without_ending(): assert "except LLMContextOverflowError" in src +def test_a_restored_session_takes_the_session_path_like_a_new_one(tmp_path): + """The LLM keeps session caches in memory, so a session restored at boot + must register them again. Without them every turn took the stateless + path, where only the system prompt hit the provider's KV cache (observed + 2026-10-18 in lui_9a07529e: 9% of 1.65M input tokens cached).""" + from agent_core.core.impl.llm import LLMCallType + from agent_core.core.impl.session.manager import SessionManager + from agent_core.core.session import Session + from agent_core.core.state.session import StateSession + + iface = _make() + engine = SimpleNamespace(make_prompt=lambda **kw: ("SYSTEM", "")) + manager = SessionManager( + event_stream_manager=None, + llm_interface=iface, + context_engine=engine, + workspace_root=tmp_path, + ) + created = manager.create_session() + restored = manager.restore_session(Session.from_dict({"id": "s-restored"})) + try: + for session in (created, restored): + assert iface.has_session_cache(session.id, LLMCallType.ACTION_SELECTION) + finally: + for session_id in (created.id, restored.id): + StateSession.end(session_id) + + def test_subagent_first_turn_resets_history(): from pathlib import Path @@ -260,23 +292,142 @@ def test_fits_context_counts_the_whole_prompt_on_a_sessions_first_request(): assert iface.fits_context("task", "action_selection", system, too_big) is False -def test_the_shipped_fold_point_for_a_128k_window(): - """window 128,000 - reserve 16,384 - output 8,000: fold when input passes 103,616.""" +def _folds_after(iface, last_input_tokens): + key = "task:action_selection" + iface._last_input_tokens[key] = last_input_tokens + fits = iface.fits_context("task", "action_selection", "s", "") + iface._last_input_tokens[key] = last_input_tokens + 1 + return fits and not iface.fits_context("task", "action_selection", "s", "") + + +def test_an_uncatalogued_model_folds_at_the_settings_window(): + """window 128,000 - output 8,000: fold when input passes 120,000.""" + iface = _make() + with patch.object(app_config, "get_settings", return_value=_settings()): + assert _folds_after(iface, 120_000) + + +def test_a_catalogued_model_folds_at_its_own_window(): + """The settings window is ignored: 200,000 - output 8,000.""" iface = _make() + with ( + patch.object(app_config, "get_settings", return_value=_settings()), + patch.object(interface_module, "model_limits", lambda *a: ModelLimits(200_000)), + ): + assert _folds_after(iface, 192_000) + + +def test_the_providers_input_limit_caps_the_budget(): + """GPT-5's shape: a 400,000 window that takes at most 272,000 of input.""" + iface = _make() + limits = ModelLimits(400_000, max_input_tokens=272_000) + with patch.object(interface_module, "model_limits", lambda *a: limits): + assert _folds_after(iface, 272_000) + with pytest.raises(LLMContextOverflowError, match="input capped at 272000"): + iface._check_context_fits( + "system", "word " * 280_000, reasoning=iface.reasoning_decision() + ) + + +def test_the_budget_follows_a_model_switch(): + iface = _make() + windows = {"small-model": ModelLimits(64_000), "big-model": ModelLimits(1_000_000)} + with patch.object( + interface_module, "model_limits", lambda provider, model, auth: windows[model] + ): + iface.model = "small-model" + assert iface.input_budget() == 56_000 + iface.model = "big-model" + assert iface.input_budget() == 992_000 + + +# ------------------------------------------------------------- the catalog + + +def test_catalog_lookups_are_exact_and_per_surface(): + # The log's model: a dated snapshot shares its alias's row. + assert model_limits("openai", "gpt-5.2-2025-12-11") == model_limits( + "openai", "gpt-5.2" + ) + assert model_limits("openai", "gpt-5.2").max_input_tokens is not None + # The ChatGPT-subscription surface has its own rows (the Codex windows). + api = model_limits("openai", "gpt-6.1-sol") + subscription = model_limits("openai", "gpt-6.1-sol", "subscription") + assert api is not None and subscription is not None and api != subscription + # No prefix or substring matching. + assert model_limits("openai", "gpt-6.1") is None + assert model_limits("openai", "gpt-6.1-sol-x") is None + # Local servers run whatever window the user loaded. + assert model_limits("lmstudio", "openai/gpt-oss-20b") is None + + +def test_every_catalog_row_is_well_formed(): + import json + + from agent_core.core.models.catalog import CATALOG_PATH + from agent_core.core.models.provider_config import PROVIDER_CONFIG + + rows = json.loads(CATALOG_PATH.read_text(encoding="utf-8"))["models"] + surfaces = {key for key, p in PROVIDER_CONFIG.items() if p.catalog_provider} + surfaces.add("openai_subscription") + assert rows + for key, row in rows.items(): + surface, _, model_id = key.partition("/") + assert surface in surfaces and model_id, key + assert set(row) <= {"context_window", "max_input_tokens"}, key + assert isinstance(row["context_window"], int) and row["context_window"] > 0 + if "max_input_tokens" in row: + assert 0 < row["max_input_tokens"] < row["context_window"], key + + +# ------------------------------------------------- how much a fold keeps + + +class _KeepRecordingStream: + head_summary = None + + def __init__(self): + self.keep_tokens = [] + + def summarize_by_LLM(self, keep_tokens): + self.keep_tokens.append(keep_tokens) + + +def _fold_keep(budget, system, static, stream=None): + from agent_core.core.impl.action.router import ActionRouter + + stream = stream or _KeepRecordingStream() + llm = SimpleNamespace(input_budget=lambda: budget) with patch.object(app_config, "get_settings", return_value=_settings()): - iface._last_input_tokens["task:action_selection"] = 103_616 - assert iface.fits_context("task", "action_selection", "s", "") is True - iface._last_input_tokens["task:action_selection"] = 103_617 - assert iface.fits_context("task", "action_selection", "s", "") is False + ActionRouter(None, llm, None)._fold(stream, system, static) + return stream.keep_tokens[0] + + +def test_a_fold_keeps_the_setting_when_the_budget_has_room(): + assert _fold_keep(900_000, "system " * 1000, "prompt " * 5000) == 20000 + + +def test_a_fold_keeps_at_most_half_the_room_the_budget_leaves(): + system, static = "system " * 1000, "prompt " * 5000 + fixed = count_tokens(system) + count_tokens(static) + assert _fold_keep(fixed + 24_000, system, static) == 12_000 + # Nothing left after the fixed prompt: keep nothing beyond the pinned tail. + assert _fold_keep(fixed - 1, system, static) == 0 + + +def test_the_running_summary_is_part_of_what_a_fold_does_not_shrink(): + stream = _KeepRecordingStream() + stream.head_summary = "summary " * 4000 + budget = 10_000 + count_tokens(stream.head_summary) + assert _fold_keep(budget, "", None, stream=stream) == 5_000 # ----------------------------------------- the stream folds only on request -def test_the_event_stream_never_folds_on_its_own(event_stream_limits): +def test_the_event_stream_never_folds_on_its_own(): from agent_core.core.impl.event_stream.event_stream import EventStream - event_stream_limits(100) llm = _CountingLLM() es = EventStream(llm=llm, temp_dir=None) for i in range(400): @@ -284,7 +435,7 @@ def test_the_event_stream_never_folds_on_its_own(event_stream_limits): assert llm.calls == 0 and es.head_summary is None es.mark_delivered() - es.summarize_by_LLM() + es.summarize_by_LLM(keep_tokens=100) assert llm.calls == 1 and es.head_summary is not None @@ -349,18 +500,16 @@ def test_shipped_defaults_apply_when_keys_are_absent(): app_config, "get_settings", return_value={"model": {}, "context": {}} ): assert app_config.get_context_window() == 128000 - assert app_config.get_reserve_tokens() == 16384 assert app_config.get_keep_recent_tokens() == 20000 @pytest.mark.parametrize("value", [0, -1, True, "16384", 12.5]) -@pytest.mark.parametrize("key", ["reserve_tokens", "keep_recent_tokens"]) -def test_an_invalid_context_setting_is_a_configuration_error(key, value): +def test_an_invalid_keep_recent_tokens_is_a_configuration_error(value): settings = _settings() - settings["context"][key] = value + settings["context"]["keep_recent_tokens"] = value with patch.object(app_config, "get_settings", return_value=settings): with pytest.raises(app_config.ConfigurationError): - getattr(app_config, f"get_{key}")() + app_config.get_keep_recent_tokens() def test_no_fallback_constants_and_no_fractions_remain(): @@ -373,5 +522,6 @@ def test_no_fallback_constants_and_no_fractions_remain(): "stream_fraction_of_window", "tail_keep_fraction", "get_context_limits", + "reserve_tokens", ): assert token not in source, token diff --git a/tests/test_event_stream_action_schemas.py b/tests/test_event_stream_action_schemas.py index 7bdd9c44a..47a3f7324 100644 --- a/tests/test_event_stream_action_schemas.py +++ b/tests/test_event_stream_action_schemas.py @@ -20,6 +20,9 @@ ) +KEEP_TOKENS = 2000 + + class _RecordingLLM: consecutive_failures = 0 _max_consecutive_failures = 5 @@ -32,8 +35,7 @@ def generate_response(self, user_prompt=None, prompt_name=None, **kw): return "SUMMARY OF OLD EVENTS" -def _stream(tmp_path, event_stream_limits, keep_recent_tokens=2000): - event_stream_limits(keep_recent_tokens) +def _stream(tmp_path): llm = _RecordingLLM() return EventStream(llm=llm, temp_dir=tmp_path / "events"), llm @@ -61,8 +63,8 @@ def _schema_records(es): return [r for r in es.tail_events if r.event.event_type is EventType.ACTION_SCHEMAS] -def test_large_schemas_stay_inline(tmp_path, event_stream_limits): - es, _ = _stream(tmp_path, event_stream_limits) +def test_large_schemas_stay_inline(tmp_path): + es, _ = _stream(tmp_path) body = "s " * MAX_EVENT_INLINE_CHARS _log_schemas(es, ["send_gmail"], body=body) @@ -71,26 +73,26 @@ def test_large_schemas_stay_inline(tmp_path, event_stream_limits): assert not list((tmp_path / "events").glob("*.txt")) -def test_a_fold_never_collapses_schemas_in_place(tmp_path, event_stream_limits): - es, _ = _stream(tmp_path, event_stream_limits, keep_recent_tokens=100_000) +def test_a_fold_never_collapses_schemas_in_place(tmp_path): + es, _ = _stream(tmp_path) _fill(es, 5) _log_schemas(es, ["send_gmail"], body="s " * MAX_EVENT_INLINE_CHARS) es.mark_delivered() - es.summarize_by_LLM() + es.summarize_by_LLM(keep_tokens=100_000) (rec,) = _schema_records(es) assert len(rec.event.message) > MAX_EVENT_INLINE_CHARS -def test_schemas_in_use_survive_a_fold(tmp_path, event_stream_limits): - es, llm = _stream(tmp_path, event_stream_limits) +def test_schemas_in_use_survive_a_fold(tmp_path): + es, llm = _stream(tmp_path) _log_schemas(es, ["send_gmail", "list_gmail"]) _fill(es, 40) _log_call(es, "send_gmail") # newest: in the kept tail es.mark_delivered() - es.summarize_by_LLM() + es.summarize_by_LLM(keep_tokens=KEEP_TOKENS) assert es.head_summary == "SUMMARY OF OLD EVENTS" (rec,) = _schema_records(es) @@ -100,16 +102,14 @@ def test_schemas_in_use_survive_a_fold(tmp_path, event_stream_limits): assert all("SCHEMA BODY" not in p for p in llm.prompts) -def test_unused_schemas_are_dropped_unsummarized_with_a_note( - tmp_path, event_stream_limits -): - es, llm = _stream(tmp_path, event_stream_limits) +def test_unused_schemas_are_dropped_unsummarized_with_a_note(tmp_path): + es, llm = _stream(tmp_path) _log_schemas(es, ["post_discord"]) _log_call(es, "post_discord") # used, but long ago _fill(es, 40) es.mark_delivered() - es.summarize_by_LLM() + es.summarize_by_LLM(keep_tokens=KEEP_TOKENS) assert _schema_records(es) == [] assert all("SCHEMA BODY" not in p for p in llm.prompts) @@ -126,23 +126,21 @@ def test_unused_schemas_are_dropped_unsummarized_with_a_note( assert es._total_tokens == sum(get_cached_token_count(r) for r in es.tail_events) -def test_only_schemas_old_enough_are_dropped_without_an_llm_call( - tmp_path, event_stream_limits -): - es, llm = _stream(tmp_path, event_stream_limits, keep_recent_tokens=600) +def test_only_schemas_old_enough_are_dropped_without_an_llm_call(tmp_path): + es, llm = _stream(tmp_path) _log_schemas(es, ["post_discord"], body="s " * 3000) _fill(es, 2) es.mark_delivered() - es.summarize_by_LLM() + es.summarize_by_LLM(keep_tokens=600) assert _schema_records(es) == [] assert llm.prompts == [] assert es.head_summary is None -def test_actions_with_schemas_lists_the_live_tail(tmp_path, event_stream_limits): - es, _ = _stream(tmp_path, event_stream_limits) +def test_actions_with_schemas_lists_the_live_tail(tmp_path): + es, _ = _stream(tmp_path) assert es.actions_with_schemas() == set() _log_schemas(es, ["send_gmail", "list_gmail"]) _log_schemas(es, ["post_discord"]) diff --git a/tests/test_event_stream_datetime.py b/tests/test_event_stream_datetime.py index 6fd5799ca..d2c6310be 100644 --- a/tests/test_event_stream_datetime.py +++ b/tests/test_event_stream_datetime.py @@ -29,8 +29,7 @@ def _kinds(es): return [r.event.kind for r in es.tail_events] -def test_first_event_gets_a_datetime_marker(event_stream_limits): - event_stream_limits() +def test_first_event_gets_a_datetime_marker(): es = EventStream(llm=_FakeLLM()) es.log("action_end", "did a thing") kinds = _kinds(es) @@ -43,16 +42,14 @@ def test_first_event_gets_a_datetime_marker(event_stream_limits): assert re.match(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b", msg) -def test_no_datetime_spam_within_window(event_stream_limits): - event_stream_limits() +def test_no_datetime_spam_within_window(): es = EventStream(llm=_FakeLLM()) for i in range(20): es.log("action_end", f"event {i}") assert _kinds(es).count("datetime") == 1 # only the first -def test_datetime_refreshes_after_interval(event_stream_limits): - event_stream_limits() +def test_datetime_refreshes_after_interval(): es = EventStream(llm=_FakeLLM()) es.log("action_end", "first") # Force the last stamp into the past to simulate >30 min elapsed. @@ -63,13 +60,12 @@ def test_datetime_refreshes_after_interval(event_stream_limits): assert _kinds(es).count("datetime") == 2 -def test_datetime_restamped_after_summarization(event_stream_limits): - event_stream_limits(100) +def test_datetime_restamped_after_summarization(): es = EventStream(llm=_FakeLLM()) for i in range(400): es.log("action_end", f"action {i} produced some output text to add tokens") es.mark_delivered() - es.summarize_by_LLM() + es.summarize_by_LLM(keep_tokens=100) assert es.head_summary is not None # A current datetime marker is always present (re-stamped post-summary). assert any(r.event.kind == "datetime" for r in es.tail_events) diff --git a/tests/test_event_stream_delivery.py b/tests/test_event_stream_delivery.py index 565f08774..74ccb7de5 100644 --- a/tests/test_event_stream_delivery.py +++ b/tests/test_event_stream_delivery.py @@ -30,6 +30,9 @@ ) +KEEP_TOKENS = 2000 + + class _RecordingLLM: consecutive_failures = 0 _max_consecutive_failures = 5 @@ -42,8 +45,7 @@ def generate_response(self, user_prompt=None, prompt_name=None, **kw): return "SUMMARY OF OLD EVENTS" -def _stream(tmp_path, event_stream_limits, keep_recent_tokens=2000): - event_stream_limits(keep_recent_tokens) +def _stream(tmp_path): llm = _RecordingLLM() return EventStream(llm=llm, temp_dir=tmp_path / "events"), llm @@ -57,10 +59,8 @@ def _read_result(i, size): return f"READ RESULT {i}: " + ("line of file content " * size) -def test_new_events_start_undelivered_and_mark_delivered_covers_the_tail( - tmp_path, event_stream_limits -): - es, _ = _stream(tmp_path, event_stream_limits) +def test_new_events_start_undelivered_and_mark_delivered_covers_the_tail(tmp_path): + es, _ = _stream(tmp_path) _history(es, 3) assert not any(r.delivered for r in es.tail_events) @@ -71,27 +71,27 @@ def test_new_events_start_undelivered_and_mark_delivered_covers_the_tail( assert not es.tail_events[-1].delivered -def test_a_session_sync_marks_delivery(tmp_path, event_stream_limits): - es, _ = _stream(tmp_path, event_stream_limits) +def test_a_session_sync_marks_delivery(tmp_path): + es, _ = _stream(tmp_path) _history(es, 3) es.mark_session_synced("action_selection") assert all(r.delivered for r in es.tail_events) -def test_a_fold_leaves_unread_results_verbatim(tmp_path, event_stream_limits): +def test_a_fold_leaves_unread_results_verbatim(tmp_path): """The log's case: the agent's parallel reads return, then the fold runs.""" - es, llm = _stream(tmp_path, event_stream_limits) + es, llm = _stream(tmp_path) _history(es) es.mark_delivered() - # Larger than keep_recent_tokens together, and each one oversized: before + # Larger than the keep target together, and each one oversized: before # the fix, every one of them became a pointer or a summary line. results = [_read_result(i, 1200) for i in range(3)] assert all(len(r) > MAX_EVENT_INLINE_CHARS for r in results) for r in results: es.log("action_end", r, action_name="read_file") - es.summarize_by_LLM() + es.summarize_by_LLM(keep_tokens=KEEP_TOKENS) messages = [r.event.message for r in es.tail_events] for r in results: @@ -103,27 +103,25 @@ def test_a_fold_leaves_unread_results_verbatim(tmp_path, event_stream_limits): assert not any(m.startswith("old step") for m in messages) -def test_read_results_collapse_once_the_model_has_them(tmp_path, event_stream_limits): +def test_read_results_collapse_once_the_model_has_them(tmp_path): # A keep window the read result fits in, so it stays in the tail. - es, _ = _stream(tmp_path, event_stream_limits, keep_recent_tokens=6000) + es, _ = _stream(tmp_path) _history(es) es.mark_delivered() es.log("action_end", _read_result(0, 1200), action_name="read_file") - es.summarize_by_LLM() + es.summarize_by_LLM(keep_tokens=6000) assert not list((tmp_path / "events").glob("event_read_file_*.txt")) es.mark_session_synced("action_selection") # the agent's next turn - es.summarize_by_LLM() + es.summarize_by_LLM(keep_tokens=6000) rec = next(r for r in es.tail_events if r.event.action_name == "read_file") assert len(rec.event.message) <= MAX_EVENT_INLINE_CHARS assert list((tmp_path / "events").glob("event_read_file_*.txt")) -def test_collapse_undelivered_oversize_only_touches_new_events( - tmp_path, event_stream_limits -): - es, _ = _stream(tmp_path, event_stream_limits) +def test_collapse_undelivered_oversize_only_touches_new_events(tmp_path): + es, _ = _stream(tmp_path) read_before = _read_result(0, 1200) es.log("action_end", read_before, action_name="read_file") es.mark_delivered() @@ -172,6 +170,10 @@ def has_session_cache(self, task_id, call_type): def reset_session_history(self, task_id, call_type): pass + def input_budget(self): + # Room to spare, so each fold keeps context.keep_recent_tokens. + return 1_000_000 + async def generate_response_async(self, system_prompt, user_prompt, **kw): from agent_core.core.impl.llm.interface import LLMContextOverflowError @@ -183,10 +185,12 @@ async def generate_response_async(self, system_prompt, user_prompt, **kw): def _router(monkeypatch, stream, llm): import agent_core.core.impl.action.router as router_module + from app import config as app_config monkeypatch.setattr( router_module, "get_event_stream_manager", lambda: _Manager(stream) ) + monkeypatch.setattr(app_config, "get_keep_recent_tokens", lambda: KEEP_TOKENS) return router_module.ActionRouter(None, llm, _ContextEngine()) @@ -201,10 +205,8 @@ def _decide(router, stream): ) -def test_router_folds_then_collapses_new_events_that_cannot_fit( - tmp_path, event_stream_limits, monkeypatch -): - es, summarizer = _stream(tmp_path, event_stream_limits) +def test_router_folds_then_collapses_new_events_that_cannot_fit(tmp_path, monkeypatch): + es, summarizer = _stream(tmp_path) _history(es) es.mark_delivered() es.log("action_end", _read_result(0, 1200), action_name="read_file") @@ -220,12 +222,10 @@ def test_router_folds_then_collapses_new_events_that_cannot_fit( assert all(r.delivered for r in es.tail_events) -def test_router_gives_up_when_nothing_is_left_to_shrink( - tmp_path, event_stream_limits, monkeypatch -): +def test_router_gives_up_when_nothing_is_left_to_shrink(tmp_path, monkeypatch): from agent_core.core.impl.llm.interface import LLMContextOverflowError - es, _ = _stream(tmp_path, event_stream_limits) + es, _ = _stream(tmp_path) _history(es) es.mark_delivered() # Many new results, each just under the externalization threshold. @@ -238,10 +238,8 @@ def test_router_gives_up_when_nothing_is_left_to_shrink( assert len(llm.sent) == 2 # refused, refused after the fold, then gave up -def test_a_stateless_answer_marks_the_stream_delivered( - tmp_path, event_stream_limits, monkeypatch -): - es, _ = _stream(tmp_path, event_stream_limits) +def test_a_stateless_answer_marks_the_stream_delivered(tmp_path, monkeypatch): + es, _ = _stream(tmp_path) _history(es, 3) llm = _StatelessLLM() diff --git a/tests/test_event_stream_oversize.py b/tests/test_event_stream_oversize.py index a884c7a71..641eaabf0 100644 --- a/tests/test_event_stream_oversize.py +++ b/tests/test_event_stream_oversize.py @@ -24,6 +24,9 @@ ) +KEEP_TOKENS = 10000 + + class _CountingLLM: consecutive_failures = 0 _max_consecutive_failures = 5 @@ -36,26 +39,23 @@ def generate_response(self, user_prompt=None, prompt_name=None, **kw): return "SUMMARY OF OLD EVENTS" -def _stream(tmp_path, llm, event_stream_limits): - event_stream_limits(10000) +def _stream(tmp_path, llm): return EventStream(llm=llm, temp_dir=tmp_path / "events") -def test_appending_never_folds_on_its_own(tmp_path, event_stream_limits): +def test_appending_never_folds_on_its_own(tmp_path): """The decision lives with the router; log() only appends.""" llm = _CountingLLM() - es = _stream(tmp_path, llm, event_stream_limits) + es = _stream(tmp_path, llm) for i in range(400): es.log("action_end", f"action {i} completed " + "x " * 200) assert llm.calls == 0 assert es.head_summary is None -def test_oversized_pinned_event_is_collapsed_in_place_by_a_fold( - tmp_path, event_stream_limits -): +def test_oversized_pinned_event_is_collapsed_in_place_by_a_fold(tmp_path): llm = _CountingLLM() - es = _stream(tmp_path, llm, event_stream_limits) + es = _stream(tmp_path, llm) for i in range(60): es.log("action_end", f"action {i} completed " + "x " * 200) @@ -66,7 +66,7 @@ def test_oversized_pinned_event_is_collapsed_in_place_by_a_fold( es.mark_delivered() # the agent read it last turn before = es._total_tokens - es.summarize_by_LLM() + es.summarize_by_LLM(keep_tokens=KEEP_TOKENS) assert es._total_tokens < before # The record survives so the UI can still pair action_start <-> action_end... @@ -78,13 +78,11 @@ def test_oversized_pinned_event_is_collapsed_in_place_by_a_fold( assert written and written[0].read_text(encoding="utf-8") == giant.strip() -def test_a_fold_gets_under_budget_and_a_second_one_is_free( - tmp_path, event_stream_limits -): - """One requested fold must leave the stream near keep_recent_tokens, with +def test_a_fold_gets_under_budget_and_a_second_one_is_free(tmp_path): + """One requested fold must leave the stream near its keep target, with protected events intact; asking again with nothing foldable costs no call.""" llm = _CountingLLM() - es = _stream(tmp_path, llm, event_stream_limits) + es = _stream(tmp_path, llm) es.log("requirements", "[ ] done_when: the ledger reconciles") for i in range(300): @@ -97,20 +95,20 @@ def test_a_fold_gets_under_budget_and_a_second_one_is_free( ) es.mark_delivered() - es.summarize_by_LLM() + es.summarize_by_LLM(keep_tokens=KEEP_TOKENS) after_first = es._total_tokens assert ( - after_first < 10000 + 4000 - ) # keep_recent_tokens plus a pinned/protected margin + after_first < KEEP_TOKENS + 4000 + ) # the keep target plus a pinned/protected margin assert any(r.event.kind == "requirements" for r in es.tail_events) calls = llm.calls - es.summarize_by_LLM() + es.summarize_by_LLM(keep_tokens=KEEP_TOKENS) assert llm.calls == calls assert es._total_tokens <= after_first -def test_tiny_foldable_region_is_pruned_not_summarized(tmp_path, event_stream_limits): +def test_tiny_foldable_region_is_pruned_not_summarized(tmp_path): """The 31,907 -> 31,529 case: a 15s LLM call that reclaimed 378 tokens. When the tail is dominated by events summarization is not allowed to touch @@ -118,7 +116,7 @@ def test_tiny_foldable_region_is_pruned_not_summarized(tmp_path, event_stream_li be far too small to be worth a blocking round trip. Prune it instead. """ llm = _CountingLLM() - es = _stream(tmp_path, llm, event_stream_limits) + es = _stream(tmp_path, llm) # A small foldable prefix the model has read... for i in range(3): @@ -128,7 +126,7 @@ def test_tiny_foldable_region_is_pruned_not_summarized(tmp_path, event_stream_li for i in range(80): es.log("action_end", f"result {i}: " + "r " * 500) - es.summarize_by_LLM() + es.summarize_by_LLM(keep_tokens=KEEP_TOKENS) assert llm.calls == 0 messages = [r.event.message for r in es.tail_events] diff --git a/tests/test_event_stream_protection.py b/tests/test_event_stream_protection.py index 661c4b1e5..d8939606e 100644 --- a/tests/test_event_stream_protection.py +++ b/tests/test_event_stream_protection.py @@ -29,10 +29,8 @@ def generate_response(self, user_prompt=None, prompt_name=None, **kw): return "SUMMARY OF OLD EVENTS" -def test_requirements_survive_summarization(event_stream_limits): +def test_requirements_survive_summarization(): assert "requirements" in PROTECTED_SUMMARY_KINDS - - event_stream_limits(100) es = EventStream(llm=_FakeLLM()) # The protected contract, logged FIRST so it becomes the oldest event. @@ -48,7 +46,7 @@ def test_requirements_survive_summarization(event_stream_limits): ) es.mark_delivered() - es.summarize_by_LLM() + es.summarize_by_LLM(keep_tokens=100) kinds = [r.event.kind for r in es.tail_events] # Summarization actually happened (old filler collapsed into the summary)… @@ -63,19 +61,17 @@ def test_requirements_survive_summarization(event_stream_limits): assert any("chronological version table" in r.event.message for r in kept) -def test_protected_only_region_is_noop(event_stream_limits): +def test_protected_only_region_is_noop(): # If the only summarizable-aged content is protected, nothing is collapsed # (and it doesn't crash). - event_stream_limits(100) es = EventStream(llm=_FakeLLM()) es.log("requirements", "\n [ ] x: y\n done_when: z") es.mark_delivered() - es.summarize_by_LLM() # force; region is tiny + protected + es.summarize_by_LLM(keep_tokens=100) # force; region is tiny + protected assert any(r.event.kind == "requirements" for r in es.tail_events) -def test_only_the_newest_requirements_snapshot_stays_pinned(event_stream_limits): - event_stream_limits(100) +def test_only_the_newest_requirements_snapshot_stays_pinned(): llm = _FakeLLM() es = EventStream(llm=llm) @@ -88,7 +84,7 @@ def test_only_the_newest_requirements_snapshot_stays_pinned(event_stream_limits) es.log("action_end", f"{task}: step {i} produced some output text") es.mark_delivered() - es.summarize_by_LLM() + es.summarize_by_LLM(keep_tokens=100) kept = [r for r in es.tail_events if r.event.kind == "requirements"] assert len(kept) == 1 @@ -98,11 +94,10 @@ def test_only_the_newest_requirements_snapshot_stays_pinned(event_stream_limits) assert "scope: send emails" in llm.prompts[0] -def test_a_superseded_requirements_update_folds_too(event_stream_limits): +def test_a_superseded_requirements_update_folds_too(): """set_requirement re-issues the COMPLETE list on every update (e.g. the Verify pass marking items satisfied), so only the latest one is the contract.""" - event_stream_limits(100) es = EventStream(llm=_FakeLLM()) es.log("requirements", "\n [ ] scope: send five emails") for i in range(100): @@ -112,7 +107,7 @@ def test_a_superseded_requirements_update_folds_too(event_stream_limits): es.log("action_end", f"later step {i} produced some output text too") es.mark_delivered() - es.summarize_by_LLM() + es.summarize_by_LLM(keep_tokens=100) kept = [r.event.message for r in es.tail_events if r.event.kind == "requirements"] assert kept == ["[SAT] scope: send five emails"] diff --git a/tests/test_session_clear.py b/tests/test_session_clear.py new file mode 100644 index 000000000..2476f936b --- /dev/null +++ b/tests/test_session_clear.py @@ -0,0 +1,225 @@ +# -*- coding: utf-8 -*- +"""/clear starts a session over as a new session. + +It used to clear only the conversation on screen plus the event stream and +todos: the session kept its loaded skills, token totals, title, NOTE.md and +EVENT.md (both named in every prompt), the LLM interface kept its history +(the cleanup called a method that does not exist), and a run in flight +carried on. These tests pin what a clear resets and what it must not touch: +other sessions' streams and caches shared between sessions. +""" + +from __future__ import annotations + +import asyncio +import time +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from agent_core.core.impl.event_stream.manager import EventStreamManager +from agent_core.core.impl.llm import LLMCallType +from agent_core.core.impl.llm.interface import LLMInterface, RequestUsage +from agent_core.core.impl.session.manager import SessionManager +from agent_core.core.session import MAIN_SESSION_ID, Session, SessionType +from agent_core.core.state.session import StateSession +from app.models.factory import ModelFactory + +ACTION = LLMCallType.ACTION_SELECTION + + +def _llm(provider="grok", model="grok-4.3"): + ctx = { + "provider": provider, + "model": model, + "client": object(), + "gemini_client": None, + "remote_url": None, + "byteplus": None, + "anthropic_client": None, + "bedrock_client": None, + "initialized": True, + "auth_mode": "api_key", + } + with patch.object(ModelFactory, "create", return_value=ctx): + return LLMInterface(provider=provider, model=model, api_key="k", base_url="") + + +def _manager(tmp_path, llm=None, own_streams=True): + streams = EventStreamManager(llm=None) + manager = SessionManager( + event_stream_manager=streams, + llm_interface=llm, + context_engine=SimpleNamespace(make_prompt=lambda **kw: ("SYSTEM", "")), + workspace_root=tmp_path, + on_stream_create=( + (lambda sid, wd: streams.create_stream(sid, wd)) if own_streams else None + ), + ) + return manager, streams + + +def _talked(manager, streams, llm, session): + """A session mid-conversation: events, todos, a skill, LLM history.""" + stream = streams.get_stream_by_id(session.id) + stream.log("action_end", "old work " + "x " * 50) + stream.head_summary = "SUMMARY OF OLD WORK" + manager.update_todos(session.id, [{"content": "Collect: old", "status": "pending"}]) + session.selected_skills = ["old-skill"] + session.total_input_tokens = 5000 + session.title = "Weather in Fukuoka" + if llm is not None: + key = f"{session.id}:{ACTION}" + llm._session_histories[key] = [{"role": "user", "content": "old"}] + llm._last_input_tokens[key] = 5000 + llm._last_requests[key] = RequestUsage( + 5000, 4000, time.time(), "grok", "grok-4.3" + ) + + +def test_a_cleared_session_starts_over_as_a_new_one(tmp_path): + llm = _llm() + manager, streams = _manager(tmp_path, llm) + session = manager.create_session() + _talked(manager, streams, llm, session) + properties_before = StateSession.get(session.id).agent_properties + try: + assert manager.clear_session(session.id) + + stream = streams.get_stream_by_id(session.id) + assert stream.tail_events == [] and stream.head_summary is None + assert session.todos == [] and session.selected_skills == [] + assert session.total_input_tokens == 0 + assert session.title == "New chat" # titled again from its next request + assert StateSession.get(session.id).agent_properties is not properties_before + # The LLM forgot the conversation but still serves it on the session path. + key = f"{session.id}:{ACTION}" + assert key not in llm._session_histories + assert llm.last_input_tokens(session.id, ACTION) is None + assert llm.last_request(session.id, ACTION) is None + assert llm.has_session_cache(session.id, ACTION) + finally: + StateSession.end(session.id) + + +def test_a_clear_keeps_the_sessions_identity(tmp_path): + manager, streams = _manager(tmp_path) + session = manager.restore_session( + Session.from_dict( + { + "id": "lui_app", + "type": SessionType.AGENT_APP, + "title": "Command Center", + "agent_app_project_id": "9a07529e", + "reasoning_effort": "high", + } + ) + ) + streams.create_stream(session.id, Path(session.workspace_dir)) + try: + manager.clear_session(session.id) + assert session.title == "Command Center" # only chats are retitled + assert session.agent_app_project_id == "9a07529e" + assert session.reasoning_effort == "high" + finally: + StateSession.end(session.id) + + +def test_a_clear_never_touches_another_sessions_stream(tmp_path): + # get_stream_by_id falls back to the main stream for a session with none. + manager, streams = _manager(tmp_path, own_streams=False) + streams.get_main_stream().log("action_end", "main's own work") + session = manager.create_session() + try: + manager.clear_session(session.id) + assert streams.get_main_stream().tail_events + finally: + StateSession.end(session.id) + + +def test_a_clear_keeps_caches_other_sessions_share(tmp_path): + # Gemini's explicit cache is keyed by system prompt, shared by every + # session: deleting it would cost all of them their cached prefix. + llm = _llm() + deleted = [] + llm._gemini_cache_manager = SimpleNamespace( + invalidate_cache=lambda *a: deleted.append(a) + ) + llm.provider = "gemini" + llm.create_session_cache("s1", ACTION, "SYSTEM") + llm.restart_session("s1") + assert deleted == [] + + +# ─────────────────────────── workspace files ─────────────────────────── + + +def test_a_clear_resets_the_files_named_in_every_prompt(tmp_path): + from app.session.session_manager import _NOTE_SCRATCHPAD_HEADER + from app.session.session_manager import SessionManager as AppSessionManager + + streams = EventStreamManager(llm=None) + manager = AppSessionManager(event_stream_manager=streams) + manager.workspace_root = tmp_path # never the real agent workspace + manager._on_session_persist = None # never the real session storage + session = manager.create_session() + workspace = Path(session.workspace_dir) + try: + (workspace / "NOTE.md").write_text("old plan", encoding="utf-8") + (workspace / "EVENT.md").write_text("old events", encoding="utf-8") + (workspace / "EVENT_UNPROCESSED.md").write_text("for memory", encoding="utf-8") + (workspace / "report.pdf").write_bytes(b"user file") + + assert manager.clear_session(session.id) + + assert (workspace / "NOTE.md").read_text( + encoding="utf-8" + ) == _NOTE_SCRATCHPAD_HEADER + assert (workspace / "EVENT.md").read_text(encoding="utf-8") == "" + # Long-term memory still learns from the cleared conversation. + assert (workspace / "EVENT_UNPROCESSED.md").read_text( + encoding="utf-8" + ) == "for memory" + assert (workspace / "report.pdf").read_bytes() == b"user file" + finally: + StateSession.end(session.id) + + +# ─────────────────────────── the agent entry ─────────────────────────── + + +def test_a_run_in_flight_is_stopped_before_the_clear_and_the_reset_is_saved(): + from app.agent_base import AgentBase + + calls = [] + + async def request_run_stop(session_id): + calls.append(("stop", session_id)) + return True + + agent = SimpleNamespace( + busy_sessions={"s1"}, + request_run_stop=request_run_stop, + session_manager=SimpleNamespace( + clear_session=lambda session_id: calls.append(("clear", session_id)) or True + ), + # The saved LLM state goes too, or a restart would bring it back. + _persist_llm_session=lambda session_id: calls.append(("save", session_id)), + ) + assert asyncio.run(AgentBase.clear_session(agent, "s1")) + assert calls == [("stop", "s1"), ("clear", "s1"), ("save", "s1")] + + calls.clear() + agent.busy_sessions = set() + asyncio.run(AgentBase.clear_session(agent, "s1")) + assert calls == [("clear", "s1"), ("save", "s1")] # idle: nothing to stop + + +def test_the_main_session_keeps_its_title(tmp_path): + manager, _ = _manager(tmp_path) + main = manager.ensure_main() + try: + manager.clear_session(MAIN_SESSION_ID) + assert main.title == "Main" + finally: + StateSession.end(MAIN_SESSION_ID) From 6e216754bc9bb9c5587676caecc936a9791e9631 Mon Sep 17 00:00:00 2001 From: CraftBot Date: Mon, 19 Oct 2026 17:31:39 +0900 Subject: [PATCH 03/12] Update model catalog to also handle cache expiration --- agent_core/core/impl/llm/interface.py | 20 +- agent_core/core/models/cache_lifetime.py | 112 ------ agent_core/core/models/catalog.py | 68 +++- agent_core/core/models/model_catalog.json | 426 +++++++++++----------- agent_core/core/models/provider_config.py | 43 --- scripts/catalog_overrides.json | 17 - scripts/gen_model_catalog.py | 197 +++++----- scripts/model_catalog_sources.json | 81 ++++ tests/llm/test_context_readout.py | 82 ++--- tests/llm/test_reasoning_rules.py | 9 +- tests/test_context_budget.py | 71 ++-- tests/test_model_catalog_generator.py | 156 ++++++++ 12 files changed, 732 insertions(+), 550 deletions(-) delete mode 100644 agent_core/core/models/cache_lifetime.py delete mode 100644 scripts/catalog_overrides.json create mode 100644 scripts/model_catalog_sources.json create mode 100644 tests/test_model_catalog_generator.py diff --git a/agent_core/core/impl/llm/interface.py b/agent_core/core/impl/llm/interface.py index f9cefbb41..670fa6302 100644 --- a/agent_core/core/impl/llm/interface.py +++ b/agent_core/core/impl/llm/interface.py @@ -44,8 +44,11 @@ ) from agent_core.core.impl.llm import transports as _transports from agent_core.core.impl.llm.types import LLMCallType -from agent_core.core.models.cache_lifetime import CacheLifetime, cache_lifetime -from agent_core.core.models.catalog import model_limits +from agent_core.core.models.catalog import ( + CacheLifetime, + model_limits, + reports_cached_tokens as _reports_cached_tokens, +) from agent_core.core.models.reasoning import ( ReasoningChoice, ReasoningDecision, @@ -931,15 +934,14 @@ def input_budget_for(self, choice: ReasoningChoice) -> int: def prompt_cache_lifetime(self) -> Optional[CacheLifetime]: """How long the provider keeps the prompt cache of a request to this - model, or None when it documents no lifetime.""" - if self.provider == "bedrock" and not self._bedrock_model_supports_caching(): - return None - return cache_lifetime(self.provider, self.model, self._auth_mode) + model (model catalog); None when the catalog gives none.""" + limits = model_limits(self.provider, self.model, self._auth_mode) + return limits.cache_lifetime if limits is not None else None def reports_cached_tokens(self) -> bool: - """Whether this provider's responses say how much input was cached.""" - profile = _get_registry().get(self.provider) - return profile is not None and profile.reports_cached_tokens + """Whether this provider's responses say how much input was cached + (model catalog).""" + return _reports_cached_tokens(self.provider, self._auth_mode) def surface(self) -> str: """Where requests are served: the provider, or its subscription diff --git a/agent_core/core/models/cache_lifetime.py b/agent_core/core/models/cache_lifetime.py deleted file mode 100644 index cc1675b40..000000000 --- a/agent_core/core/models/cache_lifetime.py +++ /dev/null @@ -1,112 +0,0 @@ -# -*- coding: utf-8 -*- -"""How long a provider keeps the prompt cache of a session's last request. - -No provider reports an expiry time in its responses, so the cache of a -session's last request can only be timed from the lifetime the provider -documents, counted from the start of that request. Only providers that -document a lifetime have a row here: - -* EXACT: the provider guarantees at least this long after a request that - wrote or read the cache (OpenAI GPT-5.6+, Anthropic, Bedrock). -* TYPICAL: the provider only documents a usual figure and may drop the - cache sooner (OpenAI models before GPT-5.6). - -Every other provider caches best-effort or not at all (xAI evicts at any -time, DeepSeek within hours to days, local servers until the model unloads, -OpenRouter depends on the upstream it picks), so it has no row and no -countdown is shown for it. - -Keys follow the reasoning table: ``reasoning_surface(provider, auth_mode)`` -and the EXACT model id (no prefix or substring matching). -""" - -from __future__ import annotations - -from dataclasses import dataclass -from enum import Enum -from typing import Dict, Mapping, Optional - -from agent_core.core.models.reasoning import reasoning_surface - - -class CacheLifetimeKind(str, Enum): - EXACT = "exact" - TYPICAL = "typical" - - -@dataclass(frozen=True) -class CacheLifetime: - """How long a request's prompt cache stays usable after it starts.""" - - kind: CacheLifetimeKind - seconds: int - #: Where the figure comes from. - source: str - - -_OPENAI_DOCS = "https://developers.openai.com/api/docs/guides/prompt-caching" - -#: prompt_cache_options.ttl defaults to "30m", its only value: a prefix stays -#: usable 30 minutes after its most recent write or reuse (a minimum). -_OPENAI_30_MIN = CacheLifetime(CacheLifetimeKind.EXACT, 30 * 60, _OPENAI_DOCS) - -#: Before GPT-5.6, in_memory entries "typically remain active for around 5 to -#: 10 minutes of inactivity" and 24h entries "around 30 minutes"; which one -#: applies depends on the organization, so the shorter figure is shown. -_OPENAI_TYPICAL = CacheLifetime(CacheLifetimeKind.TYPICAL, 5 * 60, _OPENAI_DOCS) - -#: The session path asks for ttl="1h" (transports/anthropic_messages.py); -#: every hit or write refreshes it, timed from the start of the request. -_ANTHROPIC_1_HOUR = CacheLifetime( - CacheLifetimeKind.EXACT, - 60 * 60, - "https://platform.claude.com/docs/en/build-with-claude/prompt-caching", -) - -#: cachePoint is sent without a ttl: five minutes, reset by each cache hit. -_BEDROCK_5_MIN = CacheLifetime( - CacheLifetimeKind.EXACT, - 5 * 60, - "https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html", -) - -#: Lifetime that applies to every model of a surface. -_SURFACE_LIFETIMES: Mapping[str, CacheLifetime] = { - "openai": _OPENAI_TYPICAL, - "anthropic": _ANTHROPIC_1_HOUR, - "bedrock": _BEDROCK_5_MIN, -} - - -def _models(lifetime: CacheLifetime, *model_ids: str) -> Dict[str, CacheLifetime]: - return {model_id: lifetime for model_id in model_ids} - - -#: Models whose lifetime differs from their surface's. -_MODEL_LIFETIMES: Mapping[str, Mapping[str, CacheLifetime]] = { - "openai": _models( - _OPENAI_30_MIN, - "gpt-5.6", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-6-sol", - "gpt-6-luna", - "gpt-6-astra", - "gpt-6.1-sol", - ), -} - - -def cache_lifetime( - provider: Optional[str], model: Optional[str], auth_mode: str = "api_key" -) -> Optional[CacheLifetime]: - """The documented prompt-cache lifetime of ``model``, or None if the - provider documents none.""" - if not provider or not model: - return None - surface = reasoning_surface(provider, auth_mode) - by_model = _MODEL_LIFETIMES.get(surface, {}).get(model) - if by_model is not None: - return by_model - return _SURFACE_LIFETIMES.get(surface) diff --git a/agent_core/core/models/catalog.py b/agent_core/core/models/catalog.py index 276aa8e41..2223e896e 100644 --- a/agent_core/core/models/catalog.py +++ b/agent_core/core/models/catalog.py @@ -1,57 +1,97 @@ # -*- coding: utf-8 -*- -"""Context limits per model, read from the vendored model catalog. +"""What a model can take, and what its provider does, read from the vendored +model catalog. model_catalog.json (next to this module, so it ships with the code and an -upgrade replaces it) is generated by scripts/gen_model_catalog.py from -models.dev, the Codex model catalogue (ChatGPT-subscription surface) and -scripts/catalog_overrides.json. Nothing is fetched at runtime. +upgrade replaces it) is generated by scripts/gen_model_catalog.py from the +sources and facts in scripts/model_catalog_sources.json. Nothing is fetched +at runtime, and no model or provider fact lives in code: what the catalog +does not list gets the defaults below. -Rows are keyed ``/`` with the surface from +Model rows are keyed ``/`` with the surface from ``reasoning_surface``, the same keys as the reasoning table, and match the -EXACT model id (no prefix or substring matching). A model with no row is not -catalogued; its caller uses settings.json model.context_window. +EXACT model id (no prefix or substring matching). + +Defaults for what the catalog does not list: + +* a model: no row (None); callers use settings.json model.context_window + and show no cache countdown; +* a surface: its responses report cached input. """ from __future__ import annotations import json from dataclasses import dataclass +from enum import Enum from functools import lru_cache from pathlib import Path -from typing import Mapping, Optional +from typing import Any, Mapping, Optional from agent_core.core.models.reasoning import reasoning_surface CATALOG_PATH = Path(__file__).with_name("model_catalog.json") +class CacheLifetimeKind(str, Enum): + #: The provider keeps the cache at least this long after a request. + EXACT = "exact" + #: The provider's usual figure only; it may drop the cache sooner. + TYPICAL = "typical" + + +@dataclass(frozen=True) +class CacheLifetime: + """How long a request's prompt cache stays usable after it starts.""" + + kind: CacheLifetimeKind + seconds: int + + @dataclass(frozen=True) class ModelLimits: - """What a model can take in one request.""" + """What the catalog says about one model.""" #: Input plus output tokens the model accepts. context_window: int #: Input tokens the provider accepts whatever output is requested, when - #: it caps input below the window (GPT-5: 272,000 of a 400,000 window). + #: it caps input below the window. max_input_tokens: Optional[int] = None + #: How long the provider keeps the model's prompt cache; None when the + #: provider documents no lifetime. + cache_lifetime: Optional[CacheLifetime] = None @lru_cache(maxsize=1) -def _rows() -> Mapping[str, Mapping[str, int]]: +def _catalog() -> Mapping[str, Any]: with open(CATALOG_PATH, encoding="utf-8") as f: - return json.load(f)["models"] + return json.load(f) def model_limits( provider: Optional[str], model: Optional[str], auth_mode: str = "api_key" ) -> Optional[ModelLimits]: - """The catalogued limits of ``model`` on ``provider``, or None.""" + """The catalogued facts of ``model`` on ``provider``, or None.""" if not provider or not model: return None - row = _rows().get(f"{reasoning_surface(provider, auth_mode)}/{model}") + row = _catalog()["models"].get(f"{reasoning_surface(provider, auth_mode)}/{model}") if row is None: return None + lifetime = row.get("cache_lifetime") return ModelLimits( context_window=row["context_window"], max_input_tokens=row.get("max_input_tokens"), + cache_lifetime=( + CacheLifetime(CacheLifetimeKind(lifetime["kind"]), lifetime["seconds"]) + if lifetime + else None + ), ) + + +def reports_cached_tokens(provider: Optional[str], auth_mode: str = "api_key") -> bool: + """Whether ``provider``'s responses say how much input came from cache.""" + if not provider: + return True + facts = _catalog()["surfaces"].get(reasoning_surface(provider, auth_mode), {}) + return facts.get("reports_cached_tokens", True) diff --git a/agent_core/core/models/model_catalog.json b/agent_core/core/models/model_catalog.json index 13d4e2aa0..1d4cbc00e 100644 --- a/agent_core/core/models/model_catalog.json +++ b/agent_core/core/models/model_catalog.json @@ -1,128 +1,136 @@ { - "generator": "scripts/gen_model_catalog.py", - "sources": ["https://models.dev/api.json", "https://raw.githubusercontent.com/openai/codex/main/codex-rs/models-manager/models.json", "scripts/catalog_overrides.json"], + "generator": "scripts/gen_model_catalog.py from scripts/model_catalog_sources.json", + "surfaces": { + "fireworks": {"reports_cached_tokens": false}, + "huggingface": {"reports_cached_tokens": false}, + "llamacpp": {"reports_cached_tokens": false}, + "lmstudio": {"reports_cached_tokens": false}, + "nvidia": {"reports_cached_tokens": false}, + "remote": {"reports_cached_tokens": false}, + "vllm": {"reports_cached_tokens": false} + }, "models": { - "anthropic/claude-fable-5": {"context_window": 1000000}, - "anthropic/claude-fable-5-1": {"context_window": 1000000}, - "anthropic/claude-haiku-4-5": {"context_window": 200000}, - "anthropic/claude-haiku-4-5-20251001": {"context_window": 200000}, - "anthropic/claude-haiku-5-5": {"context_window": 1000000}, - "anthropic/claude-opus-4-5": {"context_window": 200000}, - "anthropic/claude-opus-4-5-20251101": {"context_window": 200000}, - "anthropic/claude-opus-4-6": {"context_window": 1000000}, - "anthropic/claude-opus-4-7": {"context_window": 1000000}, - "anthropic/claude-opus-4-8": {"context_window": 1000000}, - "anthropic/claude-opus-5": {"context_window": 1000000}, - "anthropic/claude-opus-5-5": {"context_window": 1000000}, - "anthropic/claude-sonnet-4-5": {"context_window": 200000}, - "anthropic/claude-sonnet-4-5-20250929": {"context_window": 200000}, - "anthropic/claude-sonnet-4-6": {"context_window": 1000000}, - "anthropic/claude-sonnet-5": {"context_window": 1000000}, - "anthropic/claude-sonnet-5-5": {"context_window": 1000000}, - "bedrock/amazon.nova-2-lite-v1:0": {"context_window": 1000000}, - "bedrock/amazon.nova-lite-v1:0": {"context_window": 300000}, - "bedrock/amazon.nova-micro-v1:0": {"context_window": 128000}, - "bedrock/amazon.nova-pro-v1:0": {"context_window": 300000}, - "bedrock/anthropic.claude-fable-5": {"context_window": 1000000}, - "bedrock/anthropic.claude-fable-5-1": {"context_window": 1000000}, - "bedrock/anthropic.claude-haiku-4-5-20251001-v1:0": {"context_window": 200000}, - "bedrock/anthropic.claude-haiku-5-5": {"context_window": 1000000}, - "bedrock/anthropic.claude-opus-4-1-20250805-v1:0": {"context_window": 200000}, - "bedrock/anthropic.claude-opus-4-5-20251101-v1:0": {"context_window": 200000}, - "bedrock/anthropic.claude-opus-4-6-v1": {"context_window": 1000000}, - "bedrock/anthropic.claude-opus-4-7": {"context_window": 1000000}, - "bedrock/anthropic.claude-opus-4-8": {"context_window": 1000000}, - "bedrock/anthropic.claude-opus-5": {"context_window": 1000000}, - "bedrock/anthropic.claude-opus-5-5": {"context_window": 1000000}, - "bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0": {"context_window": 200000}, - "bedrock/anthropic.claude-sonnet-4-6": {"context_window": 1000000}, - "bedrock/anthropic.claude-sonnet-5": {"context_window": 1000000}, - "bedrock/anthropic.claude-sonnet-5-5": {"context_window": 1000000}, - "bedrock/apac.amazon.nova-lite-v1:0": {"context_window": 300000}, - "bedrock/apac.amazon.nova-micro-v1:0": {"context_window": 128000}, - "bedrock/apac.amazon.nova-pro-v1:0": {"context_window": 300000}, - "bedrock/apac.anthropic.claude-sonnet-4-20250514-v1:0": {"context_window": 200000}, - "bedrock/au.anthropic.claude-haiku-4-5-20251001-v1:0": {"context_window": 200000}, - "bedrock/au.anthropic.claude-haiku-5-5": {"context_window": 1000000}, - "bedrock/au.anthropic.claude-opus-4-6-v1": {"context_window": 1000000}, - "bedrock/au.anthropic.claude-opus-4-7": {"context_window": 1000000}, - "bedrock/au.anthropic.claude-opus-4-8": {"context_window": 1000000}, - "bedrock/au.anthropic.claude-opus-5": {"context_window": 1000000}, - "bedrock/au.anthropic.claude-opus-5-5": {"context_window": 1000000}, - "bedrock/au.anthropic.claude-sonnet-4-5-20250929-v1:0": {"context_window": 200000}, - "bedrock/au.anthropic.claude-sonnet-4-6": {"context_window": 1000000}, - "bedrock/au.anthropic.claude-sonnet-5": {"context_window": 1000000}, - "bedrock/ca.amazon.nova-lite-v1:0": {"context_window": 300000}, + "anthropic/claude-fable-5": {"cache_lifetime": {"kind": "exact", "seconds": 3600}, "context_window": 1000000}, + "anthropic/claude-fable-5-1": {"cache_lifetime": {"kind": "exact", "seconds": 3600}, "context_window": 1000000}, + "anthropic/claude-haiku-4-5": {"cache_lifetime": {"kind": "exact", "seconds": 3600}, "context_window": 200000}, + "anthropic/claude-haiku-4-5-20251001": {"cache_lifetime": {"kind": "exact", "seconds": 3600}, "context_window": 200000}, + "anthropic/claude-haiku-5-5": {"cache_lifetime": {"kind": "exact", "seconds": 3600}, "context_window": 1000000}, + "anthropic/claude-opus-4-5": {"cache_lifetime": {"kind": "exact", "seconds": 3600}, "context_window": 200000}, + "anthropic/claude-opus-4-5-20251101": {"cache_lifetime": {"kind": "exact", "seconds": 3600}, "context_window": 200000}, + "anthropic/claude-opus-4-6": {"cache_lifetime": {"kind": "exact", "seconds": 3600}, "context_window": 1000000}, + "anthropic/claude-opus-4-7": {"cache_lifetime": {"kind": "exact", "seconds": 3600}, "context_window": 1000000}, + "anthropic/claude-opus-4-8": {"cache_lifetime": {"kind": "exact", "seconds": 3600}, "context_window": 1000000}, + "anthropic/claude-opus-5": {"cache_lifetime": {"kind": "exact", "seconds": 3600}, "context_window": 1000000}, + "anthropic/claude-opus-5-5": {"cache_lifetime": {"kind": "exact", "seconds": 3600}, "context_window": 1000000}, + "anthropic/claude-sonnet-4-5": {"cache_lifetime": {"kind": "exact", "seconds": 3600}, "context_window": 200000}, + "anthropic/claude-sonnet-4-5-20250929": {"cache_lifetime": {"kind": "exact", "seconds": 3600}, "context_window": 200000}, + "anthropic/claude-sonnet-4-6": {"cache_lifetime": {"kind": "exact", "seconds": 3600}, "context_window": 1000000}, + "anthropic/claude-sonnet-5": {"cache_lifetime": {"kind": "exact", "seconds": 3600}, "context_window": 1000000}, + "anthropic/claude-sonnet-5-5": {"cache_lifetime": {"kind": "exact", "seconds": 3600}, "context_window": 1000000}, + "bedrock/amazon.nova-2-lite-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/amazon.nova-lite-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 300000}, + "bedrock/amazon.nova-micro-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 128000}, + "bedrock/amazon.nova-pro-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 300000}, + "bedrock/anthropic.claude-fable-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/anthropic.claude-fable-5-1": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/anthropic.claude-haiku-4-5-20251001-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 200000}, + "bedrock/anthropic.claude-haiku-5-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/anthropic.claude-opus-4-1-20250805-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 200000}, + "bedrock/anthropic.claude-opus-4-5-20251101-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 200000}, + "bedrock/anthropic.claude-opus-4-6-v1": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/anthropic.claude-opus-4-7": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/anthropic.claude-opus-4-8": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/anthropic.claude-opus-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/anthropic.claude-opus-5-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 200000}, + "bedrock/anthropic.claude-sonnet-4-6": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/anthropic.claude-sonnet-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/anthropic.claude-sonnet-5-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/apac.amazon.nova-lite-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 300000}, + "bedrock/apac.amazon.nova-micro-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 128000}, + "bedrock/apac.amazon.nova-pro-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 300000}, + "bedrock/apac.anthropic.claude-sonnet-4-20250514-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 200000}, + "bedrock/au.anthropic.claude-haiku-4-5-20251001-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 200000}, + "bedrock/au.anthropic.claude-haiku-5-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/au.anthropic.claude-opus-4-6-v1": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/au.anthropic.claude-opus-4-7": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/au.anthropic.claude-opus-4-8": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/au.anthropic.claude-opus-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/au.anthropic.claude-opus-5-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/au.anthropic.claude-sonnet-4-5-20250929-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 200000}, + "bedrock/au.anthropic.claude-sonnet-4-6": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/au.anthropic.claude-sonnet-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/ca.amazon.nova-lite-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 300000}, "bedrock/deepseek.r1-v1:0": {"context_window": 128000}, "bedrock/deepseek.v3-v1:0": {"context_window": 163840}, "bedrock/deepseek.v3.2": {"context_window": 163840}, - "bedrock/eu.amazon.nova-2-lite-v1:0": {"context_window": 1000000}, - "bedrock/eu.amazon.nova-lite-v1:0": {"context_window": 300000}, - "bedrock/eu.amazon.nova-micro-v1:0": {"context_window": 128000}, - "bedrock/eu.amazon.nova-pro-v1:0": {"context_window": 300000}, - "bedrock/eu.anthropic.claude-fable-5": {"context_window": 1000000}, - "bedrock/eu.anthropic.claude-haiku-4-5-20251001-v1:0": {"context_window": 200000}, - "bedrock/eu.anthropic.claude-haiku-5-5": {"context_window": 1000000}, - "bedrock/eu.anthropic.claude-opus-4-5-20251101-v1:0": {"context_window": 200000}, - "bedrock/eu.anthropic.claude-opus-4-6-v1": {"context_window": 1000000}, - "bedrock/eu.anthropic.claude-opus-4-7": {"context_window": 1000000}, - "bedrock/eu.anthropic.claude-opus-4-8": {"context_window": 1000000}, - "bedrock/eu.anthropic.claude-opus-5": {"context_window": 1000000}, - "bedrock/eu.anthropic.claude-opus-5-5": {"context_window": 1000000}, - "bedrock/eu.anthropic.claude-sonnet-4-20250514-v1:0": {"context_window": 200000}, - "bedrock/eu.anthropic.claude-sonnet-4-5-20250929-v1:0": {"context_window": 200000}, - "bedrock/eu.anthropic.claude-sonnet-4-6": {"context_window": 1000000}, - "bedrock/eu.anthropic.claude-sonnet-5": {"context_window": 1000000}, - "bedrock/eu.anthropic.claude-sonnet-5-5": {"context_window": 1000000}, + "bedrock/eu.amazon.nova-2-lite-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/eu.amazon.nova-lite-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 300000}, + "bedrock/eu.amazon.nova-micro-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 128000}, + "bedrock/eu.amazon.nova-pro-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 300000}, + "bedrock/eu.anthropic.claude-fable-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/eu.anthropic.claude-haiku-4-5-20251001-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 200000}, + "bedrock/eu.anthropic.claude-haiku-5-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/eu.anthropic.claude-opus-4-5-20251101-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 200000}, + "bedrock/eu.anthropic.claude-opus-4-6-v1": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/eu.anthropic.claude-opus-4-7": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/eu.anthropic.claude-opus-4-8": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/eu.anthropic.claude-opus-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/eu.anthropic.claude-opus-5-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/eu.anthropic.claude-sonnet-4-20250514-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 200000}, + "bedrock/eu.anthropic.claude-sonnet-4-5-20250929-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 200000}, + "bedrock/eu.anthropic.claude-sonnet-4-6": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/eu.anthropic.claude-sonnet-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/eu.anthropic.claude-sonnet-5-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, "bedrock/eu.mistral.pixtral-large-2502-v1:0": {"context_window": 128000}, - "bedrock/global.amazon.nova-2-lite-v1:0": {"context_window": 1000000}, - "bedrock/global.anthropic.claude-fable-5": {"context_window": 1000000}, - "bedrock/global.anthropic.claude-fable-5-1": {"context_window": 1000000}, - "bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0": {"context_window": 200000}, - "bedrock/global.anthropic.claude-haiku-5-5": {"context_window": 1000000}, - "bedrock/global.anthropic.claude-opus-4-5-20251101-v1:0": {"context_window": 200000}, - "bedrock/global.anthropic.claude-opus-4-6-v1": {"context_window": 1000000}, - "bedrock/global.anthropic.claude-opus-4-7": {"context_window": 1000000}, - "bedrock/global.anthropic.claude-opus-4-8": {"context_window": 1000000}, - "bedrock/global.anthropic.claude-opus-5": {"context_window": 1000000}, - "bedrock/global.anthropic.claude-opus-5-5": {"context_window": 1000000}, - "bedrock/global.anthropic.claude-sonnet-4-20250514-v1:0": {"context_window": 200000}, - "bedrock/global.anthropic.claude-sonnet-4-5-20250929-v1:0": {"context_window": 200000}, - "bedrock/global.anthropic.claude-sonnet-4-6": {"context_window": 1000000}, - "bedrock/global.anthropic.claude-sonnet-5": {"context_window": 1000000}, - "bedrock/global.anthropic.claude-sonnet-5-5": {"context_window": 1000000}, - "bedrock/global.moonshotai.kimi-k3": {"context_window": 1048576}, - "bedrock/global.openai.gpt-5.6-luna": {"context_window": 1050000, "max_input_tokens": 922000}, - "bedrock/global.openai.gpt-5.6-sol": {"context_window": 1050000, "max_input_tokens": 922000}, - "bedrock/global.openai.gpt-5.6-terra": {"context_window": 1050000, "max_input_tokens": 922000}, - "bedrock/global.openai.gpt-6-astra": {"context_window": 1050000, "max_input_tokens": 922000}, - "bedrock/global.openai.gpt-6-luna": {"context_window": 1050000, "max_input_tokens": 922000}, - "bedrock/global.openai.gpt-6-sol": {"context_window": 1050000, "max_input_tokens": 922000}, - "bedrock/global.openai.gpt-6.1-sol": {"context_window": 1050000, "max_input_tokens": 922000}, - "bedrock/global.xai.grok-4.6": {"context_window": 500000}, - "bedrock/global.xai.grok-4.7": {"context_window": 500000}, - "bedrock/global.zai.glm-5.3": {"context_window": 1000000}, + "bedrock/global.amazon.nova-2-lite-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/global.anthropic.claude-fable-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/global.anthropic.claude-fable-5-1": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 200000}, + "bedrock/global.anthropic.claude-haiku-5-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/global.anthropic.claude-opus-4-5-20251101-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 200000}, + "bedrock/global.anthropic.claude-opus-4-6-v1": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/global.anthropic.claude-opus-4-7": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/global.anthropic.claude-opus-4-8": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/global.anthropic.claude-opus-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/global.anthropic.claude-opus-5-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/global.anthropic.claude-sonnet-4-20250514-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 200000}, + "bedrock/global.anthropic.claude-sonnet-4-5-20250929-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 200000}, + "bedrock/global.anthropic.claude-sonnet-4-6": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/global.anthropic.claude-sonnet-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/global.anthropic.claude-sonnet-5-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/global.moonshotai.kimi-k3": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1048576}, + "bedrock/global.openai.gpt-5.6-luna": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, + "bedrock/global.openai.gpt-5.6-sol": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, + "bedrock/global.openai.gpt-5.6-terra": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, + "bedrock/global.openai.gpt-6-astra": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, + "bedrock/global.openai.gpt-6-luna": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, + "bedrock/global.openai.gpt-6-sol": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, + "bedrock/global.openai.gpt-6.1-sol": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, + "bedrock/global.xai.grok-4.6": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 500000}, + "bedrock/global.xai.grok-4.7": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 500000}, + "bedrock/global.zai.glm-5.3": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, "bedrock/google.gemma-3-12b-it": {"context_window": 131072}, "bedrock/google.gemma-3-27b-it": {"context_window": 131072}, "bedrock/google.gemma-3-4b-it": {"context_window": 131072}, "bedrock/google.gemma-4-26b-a4b": {"context_window": 262144}, "bedrock/google.gemma-4-31b": {"context_window": 262144}, "bedrock/google.gemma-4-e2b": {"context_window": 131072}, - "bedrock/in.anthropic.claude-haiku-4-5-20251001-v1:0": {"context_window": 200000}, - "bedrock/in.anthropic.claude-opus-5": {"context_window": 1000000}, - "bedrock/in.anthropic.claude-sonnet-5": {"context_window": 1000000}, - "bedrock/in.openai.gpt-5.6-luna": {"context_window": 1050000, "max_input_tokens": 922000}, - "bedrock/in.openai.gpt-5.6-terra": {"context_window": 1050000, "max_input_tokens": 922000}, - "bedrock/jp.amazon.nova-2-lite-v1:0": {"context_window": 1000000}, - "bedrock/jp.anthropic.claude-haiku-4-5-20251001-v1:0": {"context_window": 200000}, - "bedrock/jp.anthropic.claude-haiku-5-5": {"context_window": 1000000}, - "bedrock/jp.anthropic.claude-opus-4-7": {"context_window": 1000000}, - "bedrock/jp.anthropic.claude-opus-4-8": {"context_window": 1000000}, - "bedrock/jp.anthropic.claude-opus-5": {"context_window": 1000000}, - "bedrock/jp.anthropic.claude-opus-5-5": {"context_window": 1000000}, - "bedrock/jp.anthropic.claude-sonnet-4-5-20250929-v1:0": {"context_window": 200000}, - "bedrock/jp.anthropic.claude-sonnet-4-6": {"context_window": 1000000}, - "bedrock/jp.anthropic.claude-sonnet-5": {"context_window": 1000000}, + "bedrock/in.anthropic.claude-haiku-4-5-20251001-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 200000}, + "bedrock/in.anthropic.claude-opus-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/in.anthropic.claude-sonnet-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/in.openai.gpt-5.6-luna": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, + "bedrock/in.openai.gpt-5.6-terra": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, + "bedrock/jp.amazon.nova-2-lite-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/jp.anthropic.claude-haiku-4-5-20251001-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 200000}, + "bedrock/jp.anthropic.claude-haiku-5-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/jp.anthropic.claude-opus-4-7": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/jp.anthropic.claude-opus-4-8": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/jp.anthropic.claude-opus-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/jp.anthropic.claude-opus-5-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/jp.anthropic.claude-sonnet-4-5-20250929-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 200000}, + "bedrock/jp.anthropic.claude-sonnet-4-6": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/jp.anthropic.claude-sonnet-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, "bedrock/meta.llama3-1-70b-instruct-v1:0": {"context_window": 128000}, "bedrock/meta.llama3-1-8b-instruct-v1:0": {"context_window": 128000}, "bedrock/meta.llama3-3-70b-instruct-v1:0": {"context_window": 128000}, @@ -146,15 +154,15 @@ "bedrock/nvidia.nemotron-nano-3-30b": {"context_window": 262144}, "bedrock/nvidia.nemotron-nano-9b-v2": {"context_window": 131072}, "bedrock/nvidia.nemotron-super-3-120b": {"context_window": 262144}, - "bedrock/openai.gpt-5.4": {"context_window": 1000000}, - "bedrock/openai.gpt-5.5": {"context_window": 1000000}, - "bedrock/openai.gpt-5.6-luna": {"context_window": 1050000, "max_input_tokens": 922000}, - "bedrock/openai.gpt-5.6-sol": {"context_window": 1050000, "max_input_tokens": 922000}, - "bedrock/openai.gpt-5.6-terra": {"context_window": 1050000, "max_input_tokens": 922000}, - "bedrock/openai.gpt-6-astra": {"context_window": 1050000, "max_input_tokens": 922000}, - "bedrock/openai.gpt-6-luna": {"context_window": 1050000, "max_input_tokens": 922000}, - "bedrock/openai.gpt-6-sol": {"context_window": 1050000, "max_input_tokens": 922000}, - "bedrock/openai.gpt-6.1-sol": {"context_window": 1050000, "max_input_tokens": 922000}, + "bedrock/openai.gpt-5.4": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/openai.gpt-5.5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/openai.gpt-5.6-luna": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, + "bedrock/openai.gpt-5.6-sol": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, + "bedrock/openai.gpt-5.6-terra": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, + "bedrock/openai.gpt-6-astra": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, + "bedrock/openai.gpt-6-luna": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, + "bedrock/openai.gpt-6-sol": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, + "bedrock/openai.gpt-6.1-sol": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, "bedrock/openai.gpt-oss-120b": {"context_window": 131072}, "bedrock/openai.gpt-oss-120b-1:0": {"context_window": 131072}, "bedrock/openai.gpt-oss-20b": {"context_window": 131072}, @@ -170,27 +178,27 @@ "bedrock/qwen.qwen3-vl-235b-a22b": {"context_window": 262144}, "bedrock/us-gov.openai.gpt-oss-120b-1:0": {"context_window": 128000}, "bedrock/us-gov.openai.gpt-oss-20b-1:0": {"context_window": 128000}, - "bedrock/us.amazon.nova-2-lite-v1:0": {"context_window": 1000000}, - "bedrock/us.amazon.nova-lite-v1:0": {"context_window": 300000}, - "bedrock/us.amazon.nova-micro-v1:0": {"context_window": 128000}, - "bedrock/us.amazon.nova-premier-v1:0": {"context_window": 1000000}, - "bedrock/us.amazon.nova-pro-v1:0": {"context_window": 300000}, - "bedrock/us.anthropic.claude-fable-5": {"context_window": 1000000}, - "bedrock/us.anthropic.claude-fable-5-1": {"context_window": 1000000}, - "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0": {"context_window": 200000}, - "bedrock/us.anthropic.claude-haiku-5-5": {"context_window": 1000000}, - "bedrock/us.anthropic.claude-opus-4-1-20250805-v1:0": {"context_window": 200000}, - "bedrock/us.anthropic.claude-opus-4-5-20251101-v1:0": {"context_window": 200000}, - "bedrock/us.anthropic.claude-opus-4-6-v1": {"context_window": 1000000}, - "bedrock/us.anthropic.claude-opus-4-7": {"context_window": 1000000}, - "bedrock/us.anthropic.claude-opus-4-8": {"context_window": 1000000}, - "bedrock/us.anthropic.claude-opus-5": {"context_window": 1000000}, - "bedrock/us.anthropic.claude-opus-5-5": {"context_window": 1000000}, - "bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0": {"context_window": 200000}, - "bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0": {"context_window": 200000}, - "bedrock/us.anthropic.claude-sonnet-4-6": {"context_window": 1000000}, - "bedrock/us.anthropic.claude-sonnet-5": {"context_window": 1000000}, - "bedrock/us.anthropic.claude-sonnet-5-5": {"context_window": 1000000}, + "bedrock/us.amazon.nova-2-lite-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/us.amazon.nova-lite-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 300000}, + "bedrock/us.amazon.nova-micro-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 128000}, + "bedrock/us.amazon.nova-premier-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/us.amazon.nova-pro-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 300000}, + "bedrock/us.anthropic.claude-fable-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/us.anthropic.claude-fable-5-1": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 200000}, + "bedrock/us.anthropic.claude-haiku-5-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/us.anthropic.claude-opus-4-1-20250805-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 200000}, + "bedrock/us.anthropic.claude-opus-4-5-20251101-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 200000}, + "bedrock/us.anthropic.claude-opus-4-6-v1": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/us.anthropic.claude-opus-4-7": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/us.anthropic.claude-opus-4-8": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/us.anthropic.claude-opus-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/us.anthropic.claude-opus-5-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 200000}, + "bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 200000}, + "bedrock/us.anthropic.claude-sonnet-4-6": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/us.anthropic.claude-sonnet-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/us.anthropic.claude-sonnet-5-5": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, "bedrock/us.deepseek.r1-v1:0": {"context_window": 128000}, "bedrock/us.meta.llama3-1-70b-instruct-v1:0": {"context_window": 128000}, "bedrock/us.meta.llama3-1-8b-instruct-v1:0": {"context_window": 128000}, @@ -198,23 +206,23 @@ "bedrock/us.meta.llama4-maverick-17b-instruct-v1:0": {"context_window": 1000000}, "bedrock/us.meta.llama4-scout-17b-instruct-v1:0": {"context_window": 10000000}, "bedrock/us.mistral.pixtral-large-2502-v1:0": {"context_window": 128000}, - "bedrock/us.moonshotai.kimi-k3": {"context_window": 1048576}, - "bedrock/us.openai.gpt-5.6-luna": {"context_window": 1050000, "max_input_tokens": 922000}, - "bedrock/us.openai.gpt-5.6-sol": {"context_window": 1050000, "max_input_tokens": 922000}, - "bedrock/us.openai.gpt-5.6-terra": {"context_window": 1050000, "max_input_tokens": 922000}, - "bedrock/us.openai.gpt-6-astra": {"context_window": 1050000, "max_input_tokens": 922000}, - "bedrock/us.openai.gpt-6-luna": {"context_window": 1050000, "max_input_tokens": 922000}, - "bedrock/us.openai.gpt-6-sol": {"context_window": 1050000, "max_input_tokens": 922000}, - "bedrock/us.openai.gpt-6.1-sol": {"context_window": 1050000, "max_input_tokens": 922000}, + "bedrock/us.moonshotai.kimi-k3": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1048576}, + "bedrock/us.openai.gpt-5.6-luna": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, + "bedrock/us.openai.gpt-5.6-sol": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, + "bedrock/us.openai.gpt-5.6-terra": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, + "bedrock/us.openai.gpt-6-astra": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, + "bedrock/us.openai.gpt-6-luna": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, + "bedrock/us.openai.gpt-6-sol": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, + "bedrock/us.openai.gpt-6.1-sol": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, "bedrock/us.writer.palmyra-x4-v1:0": {"context_window": 122880}, "bedrock/us.writer.palmyra-x5-v1:0": {"context_window": 1040000}, - "bedrock/us.xai.grok-4.6": {"context_window": 500000}, - "bedrock/us.xai.grok-4.7": {"context_window": 500000}, - "bedrock/us.zai.glm-5.3": {"context_window": 1000000}, + "bedrock/us.xai.grok-4.6": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 500000}, + "bedrock/us.xai.grok-4.7": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 500000}, + "bedrock/us.zai.glm-5.3": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, "bedrock/writer.palmyra-x4-v1:0": {"context_window": 122880}, "bedrock/writer.palmyra-x5-v1:0": {"context_window": 1040000}, - "bedrock/xai.grok-4.3": {"context_window": 1000000}, - "bedrock/xai.grok-4.6": {"context_window": 500000}, + "bedrock/xai.grok-4.3": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 1000000}, + "bedrock/xai.grok-4.6": {"cache_lifetime": {"kind": "exact", "seconds": 300}, "context_window": 500000}, "bedrock/zai.glm-4.7": {"context_window": 202752}, "bedrock/zai.glm-4.7-flash": {"context_window": 202752}, "bedrock/zai.glm-5": {"context_window": 202752}, @@ -225,6 +233,7 @@ "deepseek/deepseek-v4-flash-vision-exp": {"context_window": 1000000}, "deepseek/deepseek-v4-pro": {"context_window": 1000000}, "fireworks/accounts/fireworks/models/deepseek-v4p1-flash": {"context_window": 1000000}, + "fireworks/accounts/fireworks/models/deepseek-v4p1-flash-1017": {"context_window": 1000000}, "fireworks/accounts/fireworks/models/ember-1": {"context_window": 1048576}, "fireworks/accounts/fireworks/models/glm-5p3": {"context_window": 1048573}, "fireworks/accounts/fireworks/models/glm-5p3-flash": {"context_window": 1048573}, @@ -559,68 +568,69 @@ "nvidia/sarvamai/sarvam-m": {"context_window": 128000}, "nvidia/stepfun-ai/step-3.5-flash": {"context_window": 256000}, "nvidia/stepfun-ai/step-3.7-flash": {"context_window": 256000}, + "nvidia/stepfun-ai/step-5-preview": {"context_window": 1048576}, "nvidia/thinkingmachines/inkling": {"context_window": 1048576}, "nvidia/upstage/solar-10.7b-instruct": {"context_window": 128000}, "nvidia/z-ai/glm-5.2": {"context_window": 1000000}, "nvidia/z-ai/glm-5.3": {"context_window": 1000000}, "nvidia/z-ai/glm-5.3-flash": {"context_window": 1000000}, - "openai/gpt-3.5-turbo": {"context_window": 16385}, + "openai/gpt-3.5-turbo": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 16385}, "openai/gpt-4": {"context_window": 8192}, "openai/gpt-4-turbo": {"context_window": 128000}, - "openai/gpt-4.1": {"context_window": 1047576}, - "openai/gpt-4.1-mini": {"context_window": 1047576}, - "openai/gpt-4.1-nano": {"context_window": 1047576}, - "openai/gpt-4o": {"context_window": 128000}, + "openai/gpt-4.1": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 1047576}, + "openai/gpt-4.1-mini": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 1047576}, + "openai/gpt-4.1-nano": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 1047576}, + "openai/gpt-4o": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 128000}, "openai/gpt-4o-2024-05-13": {"context_window": 128000}, - "openai/gpt-4o-2024-08-06": {"context_window": 128000}, - "openai/gpt-4o-2024-11-20": {"context_window": 128000}, - "openai/gpt-4o-mini": {"context_window": 128000}, - "openai/gpt-5": {"context_window": 400000, "max_input_tokens": 272000}, - "openai/gpt-5-2025-08-07": {"context_window": 400000, "max_input_tokens": 272000}, - "openai/gpt-5-mini": {"context_window": 400000, "max_input_tokens": 272000}, - "openai/gpt-5-mini-2025-08-07": {"context_window": 400000, "max_input_tokens": 272000}, - "openai/gpt-5-nano": {"context_window": 400000, "max_input_tokens": 272000}, - "openai/gpt-5-nano-2025-08-07": {"context_window": 400000, "max_input_tokens": 272000}, + "openai/gpt-4o-2024-08-06": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 128000}, + "openai/gpt-4o-2024-11-20": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 128000}, + "openai/gpt-4o-mini": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 128000}, + "openai/gpt-5": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 400000, "max_input_tokens": 272000}, + "openai/gpt-5-2025-08-07": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 400000, "max_input_tokens": 272000}, + "openai/gpt-5-mini": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 400000, "max_input_tokens": 272000}, + "openai/gpt-5-mini-2025-08-07": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 400000, "max_input_tokens": 272000}, + "openai/gpt-5-nano": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 400000, "max_input_tokens": 272000}, + "openai/gpt-5-nano-2025-08-07": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 400000, "max_input_tokens": 272000}, "openai/gpt-5-pro": {"context_window": 400000, "max_input_tokens": 272000}, - "openai/gpt-5.1": {"context_window": 400000, "max_input_tokens": 272000}, - "openai/gpt-5.1-2025-11-13": {"context_window": 400000, "max_input_tokens": 272000}, - "openai/gpt-5.2": {"context_window": 400000, "max_input_tokens": 272000}, - "openai/gpt-5.2-2025-12-11": {"context_window": 400000, "max_input_tokens": 272000}, - "openai/gpt-5.2-chat-latest": {"context_window": 128000}, + "openai/gpt-5.1": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 400000, "max_input_tokens": 272000}, + "openai/gpt-5.1-2025-11-13": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 400000, "max_input_tokens": 272000}, + "openai/gpt-5.2": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 400000, "max_input_tokens": 272000}, + "openai/gpt-5.2-2025-12-11": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 400000, "max_input_tokens": 272000}, + "openai/gpt-5.2-chat-latest": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 128000}, "openai/gpt-5.2-pro": {"context_window": 400000, "max_input_tokens": 272000}, - "openai/gpt-5.3-chat-latest": {"context_window": 128000}, - "openai/gpt-5.3-codex": {"context_window": 400000, "max_input_tokens": 272000}, - "openai/gpt-5.3-codex-spark": {"context_window": 128000, "max_input_tokens": 100000}, - "openai/gpt-5.4": {"context_window": 1050000, "max_input_tokens": 922000}, - "openai/gpt-5.4-2026-03-05": {"context_window": 1050000, "max_input_tokens": 922000}, - "openai/gpt-5.4-mini": {"context_window": 400000, "max_input_tokens": 272000}, - "openai/gpt-5.4-mini-2026-03-17": {"context_window": 400000, "max_input_tokens": 272000}, - "openai/gpt-5.4-nano": {"context_window": 400000, "max_input_tokens": 272000}, - "openai/gpt-5.4-nano-2026-03-17": {"context_window": 400000, "max_input_tokens": 272000}, + "openai/gpt-5.3-chat-latest": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 128000}, + "openai/gpt-5.3-codex": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 400000, "max_input_tokens": 272000}, + "openai/gpt-5.3-codex-spark": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 128000, "max_input_tokens": 100000}, + "openai/gpt-5.4": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, + "openai/gpt-5.4-2026-03-05": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, + "openai/gpt-5.4-mini": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 400000, "max_input_tokens": 272000}, + "openai/gpt-5.4-mini-2026-03-17": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 400000, "max_input_tokens": 272000}, + "openai/gpt-5.4-nano": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 400000, "max_input_tokens": 272000}, + "openai/gpt-5.4-nano-2026-03-17": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 400000, "max_input_tokens": 272000}, "openai/gpt-5.4-pro": {"context_window": 1050000, "max_input_tokens": 922000}, - "openai/gpt-5.5": {"context_window": 1050000, "max_input_tokens": 922000}, - "openai/gpt-5.5-2026-04-23": {"context_window": 1050000, "max_input_tokens": 922000}, + "openai/gpt-5.5": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, + "openai/gpt-5.5-2026-04-23": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, "openai/gpt-5.5-pro": {"context_window": 1050000, "max_input_tokens": 922000}, - "openai/gpt-5.6": {"context_window": 1050000, "max_input_tokens": 922000}, - "openai/gpt-5.6-luna": {"context_window": 1050000, "max_input_tokens": 922000}, - "openai/gpt-5.6-sol": {"context_window": 1050000, "max_input_tokens": 922000}, - "openai/gpt-5.6-terra": {"context_window": 1050000, "max_input_tokens": 922000}, - "openai/gpt-6-astra": {"context_window": 1050000, "max_input_tokens": 922000}, - "openai/gpt-6-luna": {"context_window": 1050000, "max_input_tokens": 922000}, - "openai/gpt-6-sol": {"context_window": 1050000, "max_input_tokens": 922000}, - "openai/gpt-6.1-sol": {"context_window": 1050000, "max_input_tokens": 922000}, - "openai/gpt-daybreak-blue-latest": {"context_window": 1050000, "max_input_tokens": 922000}, - "openai/gpt-daybreak-red-latest": {"context_window": 400000, "max_input_tokens": 272000}, - "openai/gpt-realtime-2.1": {"context_window": 128000, "max_input_tokens": 96000}, - "openai/o1": {"context_window": 200000}, + "openai/gpt-5.6": {"cache_lifetime": {"kind": "exact", "seconds": 1800}, "context_window": 1050000, "max_input_tokens": 922000}, + "openai/gpt-5.6-luna": {"cache_lifetime": {"kind": "exact", "seconds": 1800}, "context_window": 1050000, "max_input_tokens": 922000}, + "openai/gpt-5.6-sol": {"cache_lifetime": {"kind": "exact", "seconds": 1800}, "context_window": 1050000, "max_input_tokens": 922000}, + "openai/gpt-5.6-terra": {"cache_lifetime": {"kind": "exact", "seconds": 1800}, "context_window": 1050000, "max_input_tokens": 922000}, + "openai/gpt-6-astra": {"cache_lifetime": {"kind": "exact", "seconds": 1800}, "context_window": 1050000, "max_input_tokens": 922000}, + "openai/gpt-6-luna": {"cache_lifetime": {"kind": "exact", "seconds": 1800}, "context_window": 1050000, "max_input_tokens": 922000}, + "openai/gpt-6-sol": {"cache_lifetime": {"kind": "exact", "seconds": 1800}, "context_window": 1050000, "max_input_tokens": 922000}, + "openai/gpt-6.1-sol": {"cache_lifetime": {"kind": "exact", "seconds": 1800}, "context_window": 1050000, "max_input_tokens": 922000}, + "openai/gpt-daybreak-blue-latest": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 1050000, "max_input_tokens": 922000}, + "openai/gpt-daybreak-red-latest": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 400000, "max_input_tokens": 272000}, + "openai/gpt-realtime-2.1": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 128000, "max_input_tokens": 96000}, + "openai/o1": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 200000}, "openai/o1-pro": {"context_window": 200000}, - "openai/o3": {"context_window": 200000}, - "openai/o3-2025-04-16": {"context_window": 200000}, - "openai/o3-mini": {"context_window": 200000}, - "openai/o3-mini-2025-01-31": {"context_window": 200000}, + "openai/o3": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 200000}, + "openai/o3-2025-04-16": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 200000}, + "openai/o3-mini": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 200000}, + "openai/o3-mini-2025-01-31": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 200000}, "openai/o3-pro": {"context_window": 200000}, - "openai/o4-mini": {"context_window": 200000}, - "openai/o4-mini-2025-04-16": {"context_window": 200000}, + "openai/o4-mini": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 200000}, + "openai/o4-mini-2025-04-16": {"cache_lifetime": {"kind": "typical", "seconds": 300}, "context_window": 200000}, "openai/text-embedding-3-large": {"context_window": 8191}, "openai/text-embedding-3-small": {"context_window": 8191}, "openai/text-embedding-ada-002": {"context_window": 8192}, @@ -796,7 +806,7 @@ "openrouter/moonshotai/kimi-k2.6": {"context_window": 262144}, "openrouter/moonshotai/kimi-k2.7-code": {"context_window": 262144}, "openrouter/moonshotai/kimi-k3": {"context_window": 1048576}, - "openrouter/morph/morph-v3-fast": {"context_window": 81920}, + "openrouter/morph/morph-v3-fast": {"context_window": 262144}, "openrouter/morph/morph-v3-large": {"context_window": 262144}, "openrouter/nex-agi/nex-n2.5-mini": {"context_window": 262144}, "openrouter/nex-agi/nex-n2.5-pro": {"context_window": 262144}, @@ -970,6 +980,7 @@ "openrouter/thinkingmachines/inkling-small": {"context_window": 524288}, "openrouter/thinkingmachines/inkling-small:free": {"context_window": 1048576}, "openrouter/thinkingmachines/inkling:free": {"context_window": 1048576}, + "openrouter/unbiased/pareto-26.10": {"context_window": 1048576}, "openrouter/unbiased/pareto-26.9": {"context_window": 262144}, "openrouter/undi95/remm-slerp-l2-13b": {"context_window": 6144}, "openrouter/upstage/solar-mini4": {"context_window": 524288}, @@ -1020,7 +1031,7 @@ "openrouter/~openai/gpt-mini-latest": {"context_window": 400000}, "openrouter/~openai/gpt-sol-latest": {"context_window": 1050000}, "openrouter/~openai/gpt-terra-latest": {"context_window": 1050000}, - "openrouter/~unbiased/pareto-latest": {"context_window": 262144}, + "openrouter/~unbiased/pareto-latest": {"context_window": 1048576}, "openrouter/~x-ai/grok-latest": {"context_window": 500000}, "openrouter/~z-ai/glm-flash-latest": {"context_window": 1048576}, "openrouter/~z-ai/glm-latest": {"context_window": 1048576}, @@ -1107,9 +1118,12 @@ "together/deepseek-ai/DeepSeek-V4.1-Flash": {"context_window": 1048576}, "together/essentialai/Rnj-1-Instruct": {"context_window": 32768}, "together/meta-llama/Llama-3.3-70B-Instruct-Turbo": {"context_window": 131072}, + "together/meta-models/Muse-Spark-Open-816B-A42B": {"context_window": 1048576}, "together/moonshotai/Kimi-K3": {"context_window": 1048576}, + "together/nvidia/NVIDIA-Nemotron-3.5-Super-VL": {"context_window": 262144}, "together/nvidia/nemotron-3-ultra-550b-a55b": {"context_window": 512300}, "together/openai/gpt-oss-120b": {"context_window": 131072}, + "together/reflection/Beam-1.0-501B-A23B": {"context_window": 1048576}, "together/thinkingmachines/Inkling": {"context_window": 524288}, "together/zai-org/GLM-5": {"context_window": 202752}, "together/zai-org/GLM-5.1": {"context_window": 202752}, diff --git a/agent_core/core/models/provider_config.py b/agent_core/core/models/provider_config.py index 7075e4eb3..2be66cff8 100644 --- a/agent_core/core/models/provider_config.py +++ b/agent_core/core/models/provider_config.py @@ -73,14 +73,6 @@ class ProviderProfile: # Frontend opts into the catalog-aware model picker (OpenRouter). supports_catalog_picker: bool = False - # ── model catalog (agent_core/core/models/catalog.py) ───────────── - # models.dev provider id whose rows scripts/gen_model_catalog.py copies - # into the model catalog for this provider (each model's context window - # and input limit). Must serve the same model ids at the same endpoint. - # None -> not catalogued, so settings.json model.context_window applies: - # local servers run whatever window the user loaded. - catalog_provider: Optional[str] = None - # ── models ──────────────────────────────────────────────────────── # {InterfaceType: default model id} (was MODEL_REGISTRY row). default_models: Mapping[InterfaceType, Optional[str]] = field(default_factory=dict) @@ -102,12 +94,6 @@ class ProviderProfile: # providers that already receive it today (NFR-3: golden payloads # must not change). supports_prompt_cache_key: bool = False - # Whether responses report how many input tokens came from the provider's - # cache. False -> the cached count is always 0 whatever the provider did - # (Fireworks puts it in a header only; HF-router, hosted NVIDIA NIM, - # Ollama and the local OpenAI-compatible servers by default omit it), so - # a cache hit rate cannot be shown for it. - reports_cached_tokens: bool = True # Temperature policy for the chat_completions wire (Hermes-style): # None -> send the caller's temperature (default behavior) # OMIT_TEMPERATURE -> do NOT send temperature (Kimi/Moonshot thinking @@ -186,7 +172,6 @@ def resolve_temperature(profile: Optional[ProviderProfile], caller_temperature): PROVIDER_CONFIG: Dict[str, ProviderProfile] = { "openai": ProviderProfile( key="openai", - catalog_provider="openai", session_accumulation=True, api_key_env="OPENAI_API_KEY", display_name="OpenAI", @@ -234,7 +219,6 @@ def resolve_temperature(profile: Optional[ProviderProfile], caller_temperature): ), "gemini": ProviderProfile( key="gemini", - catalog_provider="google", api_key_env="GOOGLE_API_KEY", display_name="Google Gemini", wire="gemini_native", @@ -250,7 +234,6 @@ def resolve_temperature(profile: Optional[ProviderProfile], caller_temperature): ), "anthropic": ProviderProfile( key="anthropic", - catalog_provider="anthropic", api_key_env="ANTHROPIC_API_KEY", display_name="Anthropic", wire="anthropic_messages", @@ -268,8 +251,6 @@ def resolve_temperature(profile: Optional[ProviderProfile], caller_temperature): ), "byteplus": ProviderProfile( key="byteplus", - # No catalog_provider: models.dev lists only the China Volcengine - # endpoint, whose doubao-* ids are not the international ids below. api_key_env="BYTEPLUS_API_KEY", base_url_env="BYTEPLUS_BASE_URL", default_base_url="https://ark.ap-southeast.bytepluses.com/api/v3", @@ -291,7 +272,6 @@ def resolve_temperature(profile: Optional[ProviderProfile], caller_temperature): ), "remote": ProviderProfile( key="remote", - reports_cached_tokens=False, base_url_env="REMOTE_MODEL_URL", default_base_url="http://localhost:11434", display_name="Local (Ollama)", @@ -308,7 +288,6 @@ def resolve_temperature(profile: Optional[ProviderProfile], caller_temperature): ), "minimax": ProviderProfile( key="minimax", - catalog_provider="minimax", api_key_env="MINIMAX_API_KEY", # International OpenAI-compatible endpoint (verified 2026-08-17: # platform.minimax.io). The old api.minimax.chat domain is RETIRED. @@ -349,7 +328,6 @@ def resolve_temperature(profile: Optional[ProviderProfile], caller_temperature): ), "deepseek": ProviderProfile( key="deepseek", - catalog_provider="deepseek", session_accumulation=True, api_key_env="DEEPSEEK_API_KEY", default_base_url="https://api.deepseek.com", @@ -368,7 +346,6 @@ def resolve_temperature(profile: Optional[ProviderProfile], caller_temperature): ), "moonshot": ProviderProfile( key="moonshot", - catalog_provider="moonshotai", api_key_env="MOONSHOT_API_KEY", # International endpoint (verified 2026-08-17: platform.kimi.ai). base_url_env="MOONSHOT_BASE_URL", @@ -405,7 +382,6 @@ def resolve_temperature(profile: Optional[ProviderProfile], caller_temperature): ), "grok": ProviderProfile( key="grok", - catalog_provider="xai", session_accumulation=True, api_key_env="XAI_API_KEY", default_base_url="https://api.x.ai/v1", @@ -428,7 +404,6 @@ def resolve_temperature(profile: Optional[ProviderProfile], caller_temperature): ), "glm": ProviderProfile( key="glm", - catalog_provider="zai", session_accumulation=True, # Z.ai (Zhipu AI) GLM models -- OpenAI-compatible API. api_key_env="ZAI_API_KEY", @@ -449,7 +424,6 @@ def resolve_temperature(profile: Optional[ProviderProfile], caller_temperature): ), "fugu": ProviderProfile( key="fugu", - catalog_provider="sakana", session_accumulation=True, # Sakana AI Fugu -- OpenAI-compatible API. api_key_env="SAKANA_API_KEY", @@ -470,7 +444,6 @@ def resolve_temperature(profile: Optional[ProviderProfile], caller_temperature): ), "openrouter": ProviderProfile( key="openrouter", - catalog_provider="openrouter", session_accumulation=True, api_key_env="OPENROUTER_API_KEY", base_url_env="OPENROUTER_BASE_URL", @@ -493,7 +466,6 @@ def resolve_temperature(profile: Optional[ProviderProfile], caller_temperature): ), "bedrock": ProviderProfile( key="bedrock", - catalog_provider="amazon-bedrock", # Bedrock uses the boto3 credential chain (access_key / secret_key / # session_token) read from settings.json by the factory. There is no # single API key, so api_key_env stays None. base_url_env carries the @@ -526,7 +498,6 @@ def resolve_temperature(profile: Optional[ProviderProfile], caller_temperature): # docs/PROVIDER_LAYER_CATCHUP.md section 8.3/8.4) ────────────────── "groq": ProviderProfile( key="groq", - catalog_provider="groq", session_accumulation=True, api_key_env="GROQ_API_KEY", base_url_env="GROQ_BASE_URL", @@ -555,7 +526,6 @@ def resolve_temperature(profile: Optional[ProviderProfile], caller_temperature): ), "mistral": ProviderProfile( key="mistral", - catalog_provider="mistral", session_accumulation=True, api_key_env="MISTRAL_API_KEY", base_url_env="MISTRAL_BASE_URL", @@ -583,7 +553,6 @@ def resolve_temperature(profile: Optional[ProviderProfile], caller_temperature): ), "together": ProviderProfile( key="together", - catalog_provider="togetherai", session_accumulation=True, api_key_env="TOGETHER_API_KEY", base_url_env="TOGETHER_BASE_URL", @@ -607,8 +576,6 @@ def resolve_temperature(profile: Optional[ProviderProfile], caller_temperature): ), "fireworks": ProviderProfile( key="fireworks", - catalog_provider="fireworks-ai", - reports_cached_tokens=False, session_accumulation=True, api_key_env="FIREWORKS_API_KEY", base_url_env="FIREWORKS_BASE_URL", @@ -632,7 +599,6 @@ def resolve_temperature(profile: Optional[ProviderProfile], caller_temperature): ), "cerebras": ProviderProfile( key="cerebras", - catalog_provider="cerebras", session_accumulation=True, api_key_env="CEREBRAS_API_KEY", base_url_env="CEREBRAS_BASE_URL", @@ -662,7 +628,6 @@ def resolve_temperature(profile: Optional[ProviderProfile], caller_temperature): ), "qwen": ProviderProfile( key="qwen", - catalog_provider="alibaba", session_accumulation=True, api_key_env="DASHSCOPE_API_KEY", base_url_env="DASHSCOPE_BASE_URL", @@ -690,8 +655,6 @@ def resolve_temperature(profile: Optional[ProviderProfile], caller_temperature): ), "huggingface": ProviderProfile( key="huggingface", - catalog_provider="huggingface", - reports_cached_tokens=False, session_accumulation=True, api_key_env="HF_TOKEN", base_url_env="HF_ROUTER_BASE_URL", @@ -714,8 +677,6 @@ def resolve_temperature(profile: Optional[ProviderProfile], caller_temperature): ), "nvidia": ProviderProfile( key="nvidia", - catalog_provider="nvidia", - reports_cached_tokens=False, session_accumulation=True, api_key_env="NVIDIA_API_KEY", base_url_env="NVIDIA_BASE_URL", @@ -736,7 +697,6 @@ def resolve_temperature(profile: Optional[ProviderProfile], caller_temperature): ), "perplexity": ProviderProfile( key="perplexity", - catalog_provider="perplexity", session_accumulation=True, api_key_env="PERPLEXITY_API_KEY", base_url_env="PERPLEXITY_BASE_URL", @@ -790,7 +750,6 @@ def resolve_temperature(profile: Optional[ProviderProfile], caller_temperature): # ), "lmstudio": ProviderProfile( key="lmstudio", - reports_cached_tokens=False, session_accumulation=True, base_url_env="LMSTUDIO_BASE_URL", default_base_url="http://localhost:1234/v1", @@ -819,7 +778,6 @@ def resolve_temperature(profile: Optional[ProviderProfile], caller_temperature): ), "vllm": ProviderProfile( key="vllm", - reports_cached_tokens=False, session_accumulation=True, base_url_env="VLLM_BASE_URL", default_base_url="http://localhost:8000/v1", @@ -840,7 +798,6 @@ def resolve_temperature(profile: Optional[ProviderProfile], caller_temperature): ), "llamacpp": ProviderProfile( key="llamacpp", - reports_cached_tokens=False, session_accumulation=True, base_url_env="LLAMACPP_BASE_URL", default_base_url="http://localhost:8080/v1", diff --git a/scripts/catalog_overrides.json b/scripts/catalog_overrides.json deleted file mode 100644 index 01051a8ea..000000000 --- a/scripts/catalog_overrides.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "about": "Rows scripts/gen_model_catalog.py adds to the model catalog because its sources lack them. OpenAI's dated snapshots are listed same_as the alias that points to them, so they share its limits.", - "models": { - "openai/gpt-5-2025-08-07": {"same_as": "openai/gpt-5"}, - "openai/gpt-5-mini-2025-08-07": {"same_as": "openai/gpt-5-mini"}, - "openai/gpt-5-nano-2025-08-07": {"same_as": "openai/gpt-5-nano"}, - "openai/gpt-5.1-2025-11-13": {"same_as": "openai/gpt-5.1"}, - "openai/gpt-5.2-2025-12-11": {"same_as": "openai/gpt-5.2"}, - "openai/gpt-5.4-2026-03-05": {"same_as": "openai/gpt-5.4"}, - "openai/gpt-5.4-mini-2026-03-17": {"same_as": "openai/gpt-5.4-mini"}, - "openai/gpt-5.4-nano-2026-03-17": {"same_as": "openai/gpt-5.4-nano"}, - "openai/gpt-5.5-2026-04-23": {"same_as": "openai/gpt-5.5"}, - "openai/o3-2025-04-16": {"same_as": "openai/o3"}, - "openai/o3-mini-2025-01-31": {"same_as": "openai/o3-mini"}, - "openai/o4-mini-2025-04-16": {"same_as": "openai/o4-mini"} - } -} diff --git a/scripts/gen_model_catalog.py b/scripts/gen_model_catalog.py index 350b25fd5..7564ce200 100644 --- a/scripts/gen_model_catalog.py +++ b/scripts/gen_model_catalog.py @@ -1,25 +1,17 @@ #!/usr/bin/env python3 """Generate agent_core/core/models/model_catalog.json, the model catalog. -The catalog holds each model's context window and, where the provider caps -input below the window, its input limit. LLMInterface budgets every request -from it (agent_core/core/models/catalog.py); a model with no row uses -settings.json model.context_window. - -Sources, all structured fields (nothing is inferred from a model's name): - -* models.dev (MODELS_DEV_URL): ``limit.context`` and ``limit.input`` of every - model of each provider profile's ``catalog_provider`` - (agent_core/core/models/provider_config.py), keyed ``/``. -* the Codex model catalogue (CODEX_CATALOG_URL): ``context_window`` of each - model on the ChatGPT-subscription surface, the window Codex runs it at. -* scripts/catalog_overrides.json: rows both sources lack. A row is either - ``{"same_as": ""}``, which copies a row the sources produced, - or ``{"context_window": N}`` with an optional ``"max_input_tokens"``. - +Per model: its context window, its input limit when the provider caps input +below the window, and how long the provider keeps its prompt cache. Per +surface: whether responses report cached input. agent_core/core/models/ +catalog.py reads it, with defaults for whatever it does not list. + +Every fact comes from scripts/model_catalog_sources.json (see its "about"): +the sources to read, facts per surface, facts per model. This script only +knows how to parse each source format; it names no model, provider or URL. The output is deterministic (sorted, one row per line, no timestamps), so a -regeneration diff shows exactly which models changed. Run it by hand when -providers ship models; nothing is fetched at runtime. +regeneration diff shows exactly what changed. Run it by hand when providers +change. Usage: python scripts/gen_model_catalog.py @@ -33,26 +25,26 @@ import sys import urllib.request from pathlib import Path -from typing import Any, Dict +from typing import Any, Callable, Dict REPO_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO_ROOT)) -from agent_core.core.models.catalog import CATALOG_PATH # noqa: E402 -from agent_core.core.models.provider_config import PROVIDER_CONFIG # noqa: E402 -from agent_core.core.models.reasoning import ( # noqa: E402 - SUBSCRIPTION_AUTH_MODE, - reasoning_surface, +from agent_core.core.models.catalog import ( # noqa: E402 + CATALOG_PATH, + CacheLifetimeKind, ) -MODELS_DEV_URL = "https://models.dev/api.json" -CODEX_CATALOG_URL = ( - "https://raw.githubusercontent.com/openai/codex/main/" - "codex-rs/models-manager/models.json" -) -OVERRIDES_PATH = Path(__file__).with_name("catalog_overrides.json") +SOURCES_PATH = Path(__file__).with_name("model_catalog_sources.json") + +#: Fields a model row carries; any other key in the sources file is a note. +ROW_FIELDS = ("context_window", "max_input_tokens", "cache_lifetime") + +Row = Dict[str, Any] -Row = Dict[str, int] + +class CatalogError(ValueError): + """model_catalog_sources.json or a source does not make a valid catalog.""" def _fetch_json(url: str) -> Any: @@ -72,13 +64,15 @@ def _positive_int(value: Any) -> bool: return isinstance(value, int) and not isinstance(value, bool) and value > 0 -def _models_dev_rows(models_dev: Dict[str, Any]) -> Dict[str, Row]: +def _models_dev_rows( + source: Dict[str, Any], data: Any, surfaces: Dict +) -> Dict[str, Row]: + """models.dev: limit.context / limit.input per model. A surface's cache + lifetime goes to the models models.dev prices cache reads for.""" rows: Dict[str, Row] = {} - for profile in PROVIDER_CONFIG.values(): - if profile.catalog_provider is None: - continue - models = models_dev[profile.catalog_provider]["models"] - for model_id, model in models.items(): + for surface, provider_id in source["providers"].items(): + lifetime = surfaces.get(surface, {}).get("cache_lifetime") + for model_id, model in data[provider_id]["models"].items(): limit = model.get("limit") or {} context = limit.get("context") if not _positive_int(context): @@ -86,74 +80,113 @@ def _models_dev_rows(models_dev: Dict[str, Any]) -> Dict[str, Row]: row: Row = {"context_window": context} if _positive_int(limit.get("input")) and limit["input"] < context: row["max_input_tokens"] = limit["input"] - rows[f"{profile.key}/{model_id}"] = row + if lifetime and (model.get("cost") or {}).get("cache_read") is not None: + row["cache_lifetime"] = lifetime + rows[f"{surface}/{model_id}"] = row return rows -def _codex_rows(codex: Dict[str, Any]) -> Dict[str, Row]: - surface = reasoning_surface("openai", SUBSCRIPTION_AUTH_MODE) +def _codex_rows(source: Dict[str, Any], data: Any, surfaces: Dict) -> Dict[str, Row]: + """The Codex model catalogue: context_window per model slug.""" + surface = source["surface"] return { f"{surface}/{model['slug']}": {"context_window": model["context_window"]} - for model in codex["models"] + for model in data["models"] if _positive_int(model.get("context_window")) } -def _override_rows(sourced: Dict[str, Row]) -> Dict[str, Row]: - overrides = json.loads(OVERRIDES_PATH.read_text(encoding="utf-8"))["models"] +#: Source formats this script can read, by the "format" a source names. +PARSERS: Dict[str, Callable[[Dict[str, Any], Any, Dict], Dict[str, Row]]] = { + "models.dev": _models_dev_rows, + "codex": _codex_rows, +} + + +def _check_row(key: str, row: Row) -> None: + if not _positive_int(row.get("context_window")): + raise CatalogError(f"{key}: no source lists it and it gives no context_window") + if "max_input_tokens" in row and not _positive_int(row["max_input_tokens"]): + raise CatalogError(f"{key}: invalid max_input_tokens") + lifetime = row.get("cache_lifetime") + if lifetime is not None: + if lifetime.get("kind") not in {kind.value for kind in CacheLifetimeKind}: + raise CatalogError( + f"{key}: unknown cache_lifetime kind {lifetime.get('kind')!r}" + ) + if not _positive_int(lifetime.get("seconds")): + raise CatalogError(f"{key}: invalid cache_lifetime seconds") + + +def _model_rows(sourced: Dict[str, Row], models: Dict[str, Dict]) -> Dict[str, Row]: + """The sources file's per-model facts merged over the sourced rows.""" rows: Dict[str, Row] = {} - for key, override in overrides.items(): - if "same_as" in override: - target = sourced.get(override["same_as"]) - if target is None: - raise SystemExit( - f"{OVERRIDES_PATH.name}: {key} is same_as " - f"{override['same_as']}, which no source lists" - ) - rows[key] = dict(target) - continue - if not _positive_int(override.get("context_window")): - raise SystemExit(f"{OVERRIDES_PATH.name}: {key} needs a context_window") - row: Row = {"context_window": override["context_window"]} - if "max_input_tokens" in override: - if not _positive_int(override["max_input_tokens"]): - raise SystemExit( - f"{OVERRIDES_PATH.name}: {key} has an invalid max_input_tokens" - ) - row["max_input_tokens"] = override["max_input_tokens"] - rows[key] = row + for key, facts in models.items(): + same_as = facts.get("same_as") + if same_as is not None and same_as not in sourced: + raise CatalogError(f"{key}: same_as {same_as}, which no source lists") + base = sourced[same_as] if same_as is not None else sourced.get(key, {}) + rows[key] = {**base, **{f: facts[f] for f in ROW_FIELDS if f in facts}} return rows -def _render(rows: Dict[str, Row]) -> str: - items = sorted(rows.items()) +def build_catalog( + config: Dict[str, Any], fetch: Callable[[str], Any] +) -> Dict[str, Any]: + """The catalog from the sources file's ``config``, reading each source's + URL with ``fetch``.""" + surfaces: Dict[str, Dict[str, Any]] = config.get("surfaces", {}) + sourced: Dict[str, Row] = {} + for source in config["sources"]: + parse = PARSERS.get(source["format"]) + if parse is None: + raise CatalogError(f"unknown source format {source['format']!r}") + sourced.update(parse(source, fetch(source["url"]), surfaces)) + models = {**sourced, **_model_rows(sourced, config.get("models", {}))} + for key, row in models.items(): + _check_row(key, row) + return { + "surfaces": { + surface: {"reports_cached_tokens": facts["reports_cached_tokens"]} + for surface, facts in surfaces.items() + if "reports_cached_tokens" in facts + }, + "models": models, + } + + +def _render(catalog: Dict[str, Any]) -> str: + def section(name: str, rows: Dict[str, Any], last: bool) -> list: + items = sorted(rows.items()) + lines = [f' "{name}": {{'] + for index, (key, row) in enumerate(items): + comma = "," if index < len(items) - 1 else "" + lines.append( + f" {json.dumps(key)}: {json.dumps(row, sort_keys=True)}{comma}" + ) + lines.append(" }" if last else " },") + return lines + lines = [ "{", - ' "generator": "scripts/gen_model_catalog.py",', - ' "sources": ' - + json.dumps( - [MODELS_DEV_URL, CODEX_CATALOG_URL, "scripts/catalog_overrides.json"] - ) - + ",", - ' "models": {', + ' "generator": "scripts/gen_model_catalog.py from scripts/model_catalog_sources.json",', + *section("surfaces", catalog["surfaces"], last=False), + *section("models", catalog["models"], last=True), + "}", ] - for index, (key, row) in enumerate(items): - comma = "," if index < len(items) - 1 else "" - lines.append(f" {json.dumps(key)}: {json.dumps(row, sort_keys=True)}{comma}") - lines += [" }", "}"] return "\n".join(lines) + "\n" def main() -> int: argparse.ArgumentParser(description=__doc__).parse_args() - sourced = _models_dev_rows(_fetch_json(MODELS_DEV_URL)) - sourced.update(_codex_rows(_fetch_json(CODEX_CATALOG_URL))) - rows = {**sourced, **_override_rows(sourced)} - - text = _render(rows) + config = json.loads(SOURCES_PATH.read_text(encoding="utf-8")) + catalog = build_catalog(config, _fetch_json) + text = _render(catalog) json.loads(text) # the hand-rendered layout must stay valid JSON CATALOG_PATH.write_text(text, encoding="utf-8", newline="\n") - print(f"wrote {len(rows)} models to {CATALOG_PATH.relative_to(REPO_ROOT)}") + print( + f"wrote {len(catalog['models'])} models to {CATALOG_PATH.relative_to(REPO_ROOT)}" + ) return 0 diff --git a/scripts/model_catalog_sources.json b/scripts/model_catalog_sources.json new file mode 100644 index 000000000..f14bfb08c --- /dev/null +++ b/scripts/model_catalog_sources.json @@ -0,0 +1,81 @@ +{ + "about": "Hand-maintained input of scripts/gen_model_catalog.py, which writes agent_core/core/models/model_catalog.json. Everything here changes when providers change, so it is data: edit it and rerun the generator. sources: where each model's limits come from. surfaces: facts about a surface (a CraftBot provider key, or its subscription surface). Its cache_lifetime applies to every model of it that the source prices cache reads for; reports_cached_tokens says whether its responses report cached input (default true). models: facts per model, merged over what the sources give; same_as first copies another model's row.", + "sources": [ + { + "format": "models.dev", + "url": "https://models.dev/api.json", + "note": "CraftBot provider key -> models.dev provider id; both must serve the same model ids at the same endpoint. Not mapped: byteplus (models.dev lists only the China Volcengine naming) and local servers (they run whatever window the user loaded).", + "providers": { + "anthropic": "anthropic", + "bedrock": "amazon-bedrock", + "cerebras": "cerebras", + "deepseek": "deepseek", + "fireworks": "fireworks-ai", + "fugu": "sakana", + "gemini": "google", + "glm": "zai", + "grok": "xai", + "groq": "groq", + "huggingface": "huggingface", + "minimax": "minimax", + "mistral": "mistral", + "moonshot": "moonshotai", + "nvidia": "nvidia", + "openai": "openai", + "openrouter": "openrouter", + "perplexity": "perplexity", + "qwen": "alibaba", + "together": "togetherai" + } + }, + { + "format": "codex", + "url": "https://raw.githubusercontent.com/openai/codex/main/codex-rs/models-manager/models.json", + "note": "The ChatGPT-subscription backend's model catalogue: the window Codex runs each model at.", + "surface": "openai_subscription" + } + ], + "surfaces": { + "openai": { + "cache_lifetime": {"kind": "typical", "seconds": 300}, + "note": "Before GPT-5.6 a cached prefix typically lasts 5 to 10 minutes idle (in_memory) or about 30 minutes (24h retention), depending on the organization: the shorter figure. From GPT-5.6 on it lasts at least 30 minutes after its last write or reuse; those models are listed under models." + }, + "anthropic": { + "cache_lifetime": {"kind": "exact", "seconds": 3600}, + "note": "CraftBot asks for ttl=1h on session calls (transports/anthropic_messages.py); every hit or write restarts it, counted from the request's start." + }, + "bedrock": { + "cache_lifetime": {"kind": "exact", "seconds": 300}, + "note": "cachePoint is sent without a ttl: five minutes, restarted by every cache hit." + }, + "fireworks": {"reports_cached_tokens": false, "note": "Reports cached input in a response header only."}, + "huggingface": {"reports_cached_tokens": false}, + "nvidia": {"reports_cached_tokens": false}, + "remote": {"reports_cached_tokens": false, "note": "Ollama's API reports no cached count."}, + "lmstudio": {"reports_cached_tokens": false}, + "vllm": {"reports_cached_tokens": false, "note": "Only with --enable-prompt-tokens-details, which is off by default."}, + "llamacpp": {"reports_cached_tokens": false} + }, + "models": { + "openai/gpt-5-2025-08-07": {"same_as": "openai/gpt-5"}, + "openai/gpt-5-mini-2025-08-07": {"same_as": "openai/gpt-5-mini"}, + "openai/gpt-5-nano-2025-08-07": {"same_as": "openai/gpt-5-nano"}, + "openai/gpt-5.1-2025-11-13": {"same_as": "openai/gpt-5.1"}, + "openai/gpt-5.2-2025-12-11": {"same_as": "openai/gpt-5.2"}, + "openai/gpt-5.4-2026-03-05": {"same_as": "openai/gpt-5.4"}, + "openai/gpt-5.4-mini-2026-03-17": {"same_as": "openai/gpt-5.4-mini"}, + "openai/gpt-5.4-nano-2026-03-17": {"same_as": "openai/gpt-5.4-nano"}, + "openai/gpt-5.5-2026-04-23": {"same_as": "openai/gpt-5.5"}, + "openai/o3-2025-04-16": {"same_as": "openai/o3"}, + "openai/o3-mini-2025-01-31": {"same_as": "openai/o3-mini"}, + "openai/o4-mini-2025-04-16": {"same_as": "openai/o4-mini"}, + "openai/gpt-5.6": {"cache_lifetime": {"kind": "exact", "seconds": 1800}}, + "openai/gpt-5.6-sol": {"cache_lifetime": {"kind": "exact", "seconds": 1800}}, + "openai/gpt-5.6-terra": {"cache_lifetime": {"kind": "exact", "seconds": 1800}}, + "openai/gpt-5.6-luna": {"cache_lifetime": {"kind": "exact", "seconds": 1800}}, + "openai/gpt-6-sol": {"cache_lifetime": {"kind": "exact", "seconds": 1800}}, + "openai/gpt-6-luna": {"cache_lifetime": {"kind": "exact", "seconds": 1800}}, + "openai/gpt-6-astra": {"cache_lifetime": {"kind": "exact", "seconds": 1800}}, + "openai/gpt-6.1-sol": {"cache_lifetime": {"kind": "exact", "seconds": 1800}} + } +} diff --git a/tests/llm/test_context_readout.py b/tests/llm/test_context_readout.py index b6698639b..8a14c701e 100644 --- a/tests/llm/test_context_readout.py +++ b/tests/llm/test_context_readout.py @@ -10,7 +10,8 @@ * every provider whose profile accumulates a session history takes the session path (a hand-kept subset of them used to re-send the whole prompt every turn); -* cache lifetimes exist only where the provider documents one; +* cache facts come from the model catalog, with defaults for what it does + not list; * the wire payloads the menu reads. """ @@ -23,8 +24,12 @@ import pytest from agent_core.core.impl.llm import LLMCallType -from agent_core.core.models.cache_lifetime import CacheLifetimeKind, cache_lifetime -from agent_core.core.models.catalog import ModelLimits +from agent_core.core.models.catalog import ( + CacheLifetime, + CacheLifetimeKind, + ModelLimits, + reports_cached_tokens, +) from agent_core.core.models.reasoning import ReasoningChoice from agent_core.core.models.registry import session_cc_providers @@ -32,6 +37,25 @@ ACTION = LLMCallType.ACTION_SELECTION +#: A catalog of made-up models, so these tests pin behavior, not provider data. +FAKE_CATALOG = { + "surfaces": {"quiet": {"reports_cached_tokens": False}}, + "models": { + "openai/timed-model": { + "context_window": 400_000, + "cache_lifetime": {"kind": "exact", "seconds": 1800}, + }, + "openai/untimed-model": {"context_window": 128_000}, + }, +} + + +@pytest.fixture +def fake_catalog(monkeypatch): + import agent_core.core.models.catalog as catalog + + monkeypatch.setattr(catalog, "_catalog", lambda: FAKE_CATALOG) + # ──────────────────────── recording the last request ──────────────────────── @@ -90,46 +114,22 @@ def test_every_accumulating_provider_takes_the_session_path(monkeypatch, provide assert iface.has_session_cache("s1", ACTION) -# ─────────────────────────── cache lifetimes ─────────────────────────── - +# ─────────────────────────── cache facts ─────────────────────────── -def test_cache_lifetimes_exist_only_where_documented(): - luna = cache_lifetime("openai", "gpt-6-luna") - assert luna.kind is CacheLifetimeKind.EXACT and luna.seconds == 30 * 60 - older = cache_lifetime("openai", "gpt-5.2-2025-12-11") - assert older.kind is CacheLifetimeKind.TYPICAL and older.seconds == 5 * 60 - claude = cache_lifetime("anthropic", "claude-sonnet-4-6") - assert claude.kind is CacheLifetimeKind.EXACT and claude.seconds == 60 * 60 - # Undocumented or best-effort: no countdown. - assert cache_lifetime("openai", "gpt-6.1-sol", "subscription") is None - assert cache_lifetime("grok", "grok-4.3") is None - assert cache_lifetime("deepseek", "deepseek-v4-pro") is None - assert cache_lifetime("openrouter", "openai/gpt-6.1-sol") is None - assert cache_lifetime("lmstudio", "openai/gpt-oss-20b") is None +def test_the_cache_lifetime_comes_from_the_catalog(monkeypatch, fake_catalog): + timed, _ = build_interface(monkeypatch, "openai", "timed-model") + assert timed.prompt_cache_lifetime() == CacheLifetime(CacheLifetimeKind.EXACT, 1800) + # Catalogued without one, or not catalogued at all: no countdown. + untimed, _ = build_interface(monkeypatch, "openai", "untimed-model") + assert untimed.prompt_cache_lifetime() is None + unknown, _ = build_interface(monkeypatch, "openai", "unknown-model") + assert unknown.prompt_cache_lifetime() is None -def test_the_gpt_5_6_rows_name_real_models(): - from agent_core.core.models.cache_lifetime import _MODEL_LIFETIMES - from agent_core.core.models.catalog import model_limits - for model in _MODEL_LIFETIMES["openai"]: - assert model_limits("openai", model) is not None, model - - -def test_bedrock_counts_down_only_for_models_that_take_cache_points(monkeypatch): - claude, _ = build_interface( - monkeypatch, "bedrock", "us.anthropic.claude-haiku-4-5-20251001-v1:0" - ) - assert claude.prompt_cache_lifetime().seconds == 5 * 60 - llama, _ = build_interface(monkeypatch, "bedrock", "meta.llama3-70b-instruct-v1:0") - assert llama.prompt_cache_lifetime() is None - - -def test_providers_that_never_report_cached_tokens_say_so(monkeypatch): - fireworks, _ = build_interface(monkeypatch, "fireworks", "some-model") - openai, _ = build_interface(monkeypatch, "openai", "gpt-6-luna") - assert fireworks.reports_cached_tokens() is False - assert openai.reports_cached_tokens() is True +def test_cached_input_is_reported_unless_the_catalog_says_not(fake_catalog): + assert reports_cached_tokens("quiet") is False + assert reports_cached_tokens("openai") is True # not listed: the default # ─────────────────────────── budgets per choice ─────────────────────────── @@ -161,10 +161,10 @@ async def send_to(_ws, message): ) -def test_the_reasoning_options_carry_budgets_and_cache_facts(monkeypatch): +def test_the_reasoning_options_carry_budgets_and_cache_facts(monkeypatch, fake_catalog): from app.ui_layer.adapters.browser_adapter import BrowserAdapter - iface, _ = build_interface(monkeypatch, "openai", "gpt-6-luna") + iface, _ = build_interface(monkeypatch, "openai", "timed-model") sent = [] asyncio.run( BrowserAdapter._handle_reasoning_options_get(_adapter(iface, sent), None) diff --git a/tests/llm/test_reasoning_rules.py b/tests/llm/test_reasoning_rules.py index 30ae8fbe6..b902aafe4 100644 --- a/tests/llm/test_reasoning_rules.py +++ b/tests/llm/test_reasoning_rules.py @@ -1034,8 +1034,13 @@ def test_reasoning_options_handler(monkeypatch): } # No reasoning parameter is sent, so every choice has the same budget. assert len(set(data["inputBudgets"].values())) == 1 - assert data["cacheLifetime"] == {"kind": "typical", "seconds": 300} - assert data["reportsCachedTokens"] is True + lifetime = unruled.prompt_cache_lifetime() + assert data["cacheLifetime"] == ( + None + if lifetime is None + else {"kind": lifetime.kind.value, "seconds": lifetime.seconds} + ) + assert data["reportsCachedTokens"] is unruled.reports_cached_tokens() def test_session_reasoning_set_handler(tmp_path: Path): diff --git a/tests/test_context_budget.py b/tests/test_context_budget.py index 1ac28d0a8..39c369909 100644 --- a/tests/test_context_budget.py +++ b/tests/test_context_budget.py @@ -344,40 +344,63 @@ def test_the_budget_follows_a_model_switch(): # ------------------------------------------------------------- the catalog -def test_catalog_lookups_are_exact_and_per_surface(): - # The log's model: a dated snapshot shares its alias's row. - assert model_limits("openai", "gpt-5.2-2025-12-11") == model_limits( - "openai", "gpt-5.2" +def test_catalog_lookups_are_exact_and_per_surface(monkeypatch): + import agent_core.core.models.catalog as catalog + + monkeypatch.setattr( + catalog, + "_catalog", + lambda: { + "surfaces": {}, + "models": { + "openai/some-model": { + "context_window": 400_000, + "max_input_tokens": 272_000, + }, + "openai_subscription/some-model": {"context_window": 272_000}, + }, + }, ) - assert model_limits("openai", "gpt-5.2").max_input_tokens is not None - # The ChatGPT-subscription surface has its own rows (the Codex windows). - api = model_limits("openai", "gpt-6.1-sol") - subscription = model_limits("openai", "gpt-6.1-sol", "subscription") - assert api is not None and subscription is not None and api != subscription - # No prefix or substring matching. - assert model_limits("openai", "gpt-6.1") is None - assert model_limits("openai", "gpt-6.1-sol-x") is None - # Local servers run whatever window the user loaded. - assert model_limits("lmstudio", "openai/gpt-oss-20b") is None - - -def test_every_catalog_row_is_well_formed(): + api = model_limits("openai", "some-model") + assert api == ModelLimits(400_000, max_input_tokens=272_000) + # The ChatGPT-subscription surface has rows of its own. + assert model_limits("openai", "some-model", "subscription") == ModelLimits(272_000) + # No prefix or substring matching; an unlisted model is not catalogued. + assert model_limits("openai", "some") is None + assert model_limits("openai", "some-model-x") is None + assert model_limits("lmstudio", "some-model") is None + + +def test_the_shipped_catalog_is_well_formed(): import json - from agent_core.core.models.catalog import CATALOG_PATH + from agent_core.core.models.catalog import CATALOG_PATH, CacheLifetimeKind from agent_core.core.models.provider_config import PROVIDER_CONFIG + from agent_core.core.models.reasoning import ( + SUBSCRIPTION_AUTH_MODE, + reasoning_surface, + ) - rows = json.loads(CATALOG_PATH.read_text(encoding="utf-8"))["models"] - surfaces = {key for key, p in PROVIDER_CONFIG.items() if p.catalog_provider} - surfaces.add("openai_subscription") - assert rows - for key, row in rows.items(): + catalog = json.loads(CATALOG_PATH.read_text(encoding="utf-8")) + surfaces = { + reasoning_surface(key, auth) + for key in PROVIDER_CONFIG + for auth in ("api_key", SUBSCRIPTION_AUTH_MODE) + } + assert set(catalog["surfaces"]) <= surfaces + for facts in catalog["surfaces"].values(): + assert set(facts) == {"reports_cached_tokens"} + assert catalog["models"] + for key, row in catalog["models"].items(): surface, _, model_id = key.partition("/") assert surface in surfaces and model_id, key - assert set(row) <= {"context_window", "max_input_tokens"}, key + assert set(row) <= {"context_window", "max_input_tokens", "cache_lifetime"}, key assert isinstance(row["context_window"], int) and row["context_window"] > 0 if "max_input_tokens" in row: assert 0 < row["max_input_tokens"] < row["context_window"], key + if "cache_lifetime" in row: + CacheLifetimeKind(row["cache_lifetime"]["kind"]) + assert row["cache_lifetime"]["seconds"] > 0, key # ------------------------------------------------- how much a fold keeps diff --git a/tests/test_model_catalog_generator.py b/tests/test_model_catalog_generator.py new file mode 100644 index 000000000..15ed31f92 --- /dev/null +++ b/tests/test_model_catalog_generator.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +"""scripts/gen_model_catalog.py builds the model catalog from data only. + +Every model, provider and URL lives in scripts/model_catalog_sources.json; +the generator only parses source formats. These tests feed it made-up +sources (no network) and pin how facts combine: a surface's cache lifetime +goes to the models the source prices cache reads for, per-model facts merge +over sourced rows, same_as copies a row first, and anything that cannot make +a valid row is an error rather than a guess. +""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + + +def _generator(): + spec = importlib.util.spec_from_file_location( + "gen_model_catalog", ROOT / "scripts" / "gen_model_catalog.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +gen = _generator() + +TIMED = {"kind": "typical", "seconds": 300} +SOURCES = { + "https://source.test/models.json": { + "alpha-upstream": { + "models": { + "priced": { + "limit": {"context": 400_000, "input": 272_000}, + "cost": {"cache_read": 0.1}, + }, + "unpriced": {"limit": {"context": 128_000}}, + "no-limit": {"limit": {}}, + } + } + }, + "https://source.test/codex.json": { + "models": [ + {"slug": "sub-model", "context_window": 272_000}, + {"slug": "no-window"}, + ] + }, +} + + +def _config(models=None, surfaces=None): + return { + "sources": [ + { + "format": "models.dev", + "url": "https://source.test/models.json", + "providers": {"alpha": "alpha-upstream"}, + }, + { + "format": "codex", + "url": "https://source.test/codex.json", + "surface": "alpha_subscription", + }, + ], + "surfaces": surfaces + if surfaces is not None + else { + "alpha": {"cache_lifetime": TIMED}, + "beta": {"reports_cached_tokens": False}, + }, + "models": models or {}, + } + + +def _build(config): + return gen.build_catalog(config, SOURCES.__getitem__) + + +def test_rows_come_from_the_sources_as_configured(): + models = _build(_config())["models"] + assert models["alpha/priced"] == { + "context_window": 400_000, + "max_input_tokens": 272_000, + "cache_lifetime": TIMED, # the source prices its cache reads + } + assert models["alpha/unpriced"] == {"context_window": 128_000} + assert models["alpha_subscription/sub-model"] == {"context_window": 272_000} + assert ( + "alpha/no-limit" not in models and "alpha_subscription/no-window" not in models + ) + + +def test_model_facts_merge_over_the_sourced_row(): + exact = {"kind": "exact", "seconds": 1800} + models = _build( + _config(models={"alpha/priced": {"cache_lifetime": exact, "note": "why"}}) + )["models"] + assert models["alpha/priced"] == { + "context_window": 400_000, + "max_input_tokens": 272_000, + "cache_lifetime": exact, + } + + +def test_same_as_copies_a_sourced_row(): + models = _build(_config(models={"alpha/priced-2026": {"same_as": "alpha/priced"}}))[ + "models" + ] + assert models["alpha/priced-2026"] == models["alpha/priced"] + + +def test_only_surface_facts_the_runtime_reads_are_carried(): + assert _build(_config())["surfaces"] == {"beta": {"reports_cached_tokens": False}} + + +@pytest.mark.parametrize( + "config", + [ + _config(models={"alpha/missing": {"cache_lifetime": TIMED}}), # no window + _config(models={"alpha/copy": {"same_as": "alpha/missing"}}), + _config( + surfaces={"alpha": {"cache_lifetime": {"kind": "forever", "seconds": 1}}} + ), + {**_config(), "sources": [{"format": "unknown", "url": "x"}]}, + ], +) +def test_what_cannot_make_a_valid_row_is_an_error(config): + with pytest.raises(gen.CatalogError): + _build(config) + + +def test_the_sources_file_names_only_known_formats_and_surfaces(): + from agent_core.core.models.provider_config import PROVIDER_CONFIG + from agent_core.core.models.reasoning import ( + SUBSCRIPTION_AUTH_MODE, + reasoning_surface, + ) + + config = json.loads(gen.SOURCES_PATH.read_text(encoding="utf-8")) + known = { + reasoning_surface(key, auth) + for key in PROVIDER_CONFIG + for auth in ("api_key", SUBSCRIPTION_AUTH_MODE) + } + for source in config["sources"]: + assert source["format"] in gen.PARSERS + assert set(source.get("providers", {})) <= known + assert source.get("surface") is None or source["surface"] in known + assert set(config["surfaces"]) <= known + assert {key.partition("/")[0] for key in config["models"]} <= known From 03339c13f74dce7a48854dd4cf6b53b881fb3831 Mon Sep 17 00:00:00 2001 From: ahmad-ajmal <165422770+ahmad-ajmal@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:54:42 +0100 Subject: [PATCH 04/12] feat: add protected shared credential manager Add profile-scoped vaults, protected Windows host execution and native owner consent. Route browser, model, integration and isolated agent consumers through approved credential helpers while preserving existing generated tokens and sessions. Include security review fixes, regression coverage, offline migration tooling and deployment documentation. Windows installation and interactive acceptance remain deferred. Signed-off-by: ahmad-ajmal <165422770+ahmad-ajmal@users.noreply.github.com> --- agent_core/core/embedding_interface.py | 8 +- agent_core/core/impl/action/executor.py | 42 +- agent_core/core/impl/action/manager.py | 5 +- agent_core/core/impl/llm/cache/byteplus.py | 6 +- .../impl/llm/transports/byteplus_responses.py | 6 +- agent_core/core/impl/video_gen/interface.py | 36 +- agent_core/core/impl/vlm/interface.py | 6 +- agent_core/core/llm/google_gemini_client.py | 33 + agent_core/core/models/connection_tester.py | 118 + agent_core/core/models/factory.py | 33 + app/agent_app/integration_bridge.py | 90 + app/agent_app/manager.py | 30 + app/agent_base.py | 6 +- app/config.py | 5 + app/credentials/__init__.py | 5 + app/credentials/aws_helper.py | 73 + app/credentials/broker.py | 510 + app/credentials/browser_helper.py | 294 + app/credentials/browser_migration.py | 169 + app/credentials/catalog.py | 540 + app/credentials/client.py | 320 + app/credentials/consumer_runtime.py | 65 + app/credentials/contracts.py | 85 + app/credentials/directory.py | 124 + app/credentials/errors.py | 45 + app/credentials/execution.py | 93 + app/credentials/gateway_helper.py | 136 + app/credentials/helpers.py | 900 + app/credentials/host_service.py | 1486 ++ app/credentials/identity.py | 143 + app/credentials/integrations.py | 435 + app/credentials/invocation.py | 216 + app/credentials/keys.py | 101 + app/credentials/listeners.py | 93 + app/credentials/media_clients.py | 65 + app/credentials/migration.py | 739 + app/credentials/migration_source.py | 140 + app/credentials/model_transport.py | 249 + app/credentials/mtproto_helper.py | 239 + app/credentials/native/CredentialHost.cs | 399 + app/credentials/native/bootstrap.py | 19 + app/credentials/oauth_transport.py | 46 + app/credentials/operation_session.py | 109 + app/credentials/owner_transport.py | 261 + app/credentials/provider_auth.py | 88 + app/credentials/provider_routes.json | 20072 ++++++++++++++++ app/credentials/records.py | 220 + app/credentials/references.py | 40 + app/credentials/request_review.py | 74 + app/credentials/resolver.py | 36 + app/credentials/route_policy.py | 99 + app/credentials/secrets.py | 36 + app/credentials/settings.py | 65 + app/credentials/storage.py | 135 + app/credentials/streaming.py | 127 + app/credentials/twitter_helper.py | 83 + app/credentials/use_errors.py | 64 + app/credentials/vault.py | 540 + app/credentials/windows_host.py | 475 + app/credentials/wire.py | 112 + app/data/action/credential_operations.py | 95 + app/data/action/integrations/_helpers.py | 102 +- app/data/action/understand_video.py | 38 +- app/data/action/web_search.py | 21 +- app/integrations.py | 14 +- app/mini_browser/actions_api.py | 3 + app/mini_browser/core.py | 6 +- app/mini_browser/ops.py | 19 +- app/mini_browser/vault.py | 34 +- app/mini_browser/ws.py | 12 + app/onboarding/interfaces/steps.py | 25 +- app/ui_layer/adapters/browser_adapter.py | 5 + app/ui_layer/adapters/ws_lanes.py | 1 + .../components/MiniBrowser/PasswordsModal.tsx | 622 +- .../src/pages/Onboarding/OnboardingPage.tsx | 22 +- .../Settings/CredentialsSettings.module.css | 12 + .../pages/Settings/CredentialsSettings.tsx | 162 + .../pages/Settings/IntegrationsSettings.tsx | 88 +- .../src/pages/Settings/ModelSettings.tsx | 26 +- .../src/pages/Settings/SettingsPage.tsx | 5 +- .../frontend/src/pages/Settings/types.ts | 3 + .../src/store/resources/ResourceSync.ts | 1 - .../src/store/slices/modelSettingsSlice.ts | 1 + .../browser/frontend/src/types/index.ts | 35 +- app/ui_layer/commands/builtin/cred.py | 8 + app/ui_layer/commands/builtin/integrations.py | 98 +- app/ui_layer/onboarding/controller.py | 19 +- app/ui_layer/settings/model_settings.py | 74 +- app/ui_layer/settings/openrouter_catalog.py | 11 +- app/ui_layer/settings/profile_bundle.py | 10 + app/ui_layer/settings/provider_settings.py | 13 + craftos_integrations/contracts.py | 31 + craftos_integrations/core/accounts.py | 16 +- craftos_integrations/core/listeners.py | 7 +- craftos_integrations/core/system.py | 47 +- craftos_integrations/helpers/http.py | 46 +- craftos_integrations/helpers/source_auth.py | 68 + craftos_integrations/helpers/source_oauth.py | 40 + .../helpers/source_requests.py | 15 + .../providers/_lark_common.py | 29 +- .../providers/discord/_discord_voice.py | 13 +- .../providers/github/client.py | 10 +- craftos_integrations/providers/lark/client.py | 13 +- craftos_integrations/providers/line/client.py | 6 +- .../providers/notion/client.py | 5 +- .../providers/slack/client.py | 9 +- .../providers/supabase/client.py | 256 +- .../providers/telegram_bot/client.py | 4 +- .../telegram_user/_telegram_mtproto.py | 44 + .../providers/telegram_user/client.py | 39 +- .../providers/telegram_user/provider.py | 67 +- .../providers/twitter/client.py | 18 +- docs/plans/credential-manager-cm01.md | 133 + docs/plans/credential-manager-cm02.md | 117 + docs/plans/credential-manager-cm03.md | 176 + docs/plans/credential-manager-consumers.md | 279 + docs/plans/credential-manager-windows-host.md | 202 + .../credential-host-win_amd64-py312.txt | 21 + scripts/build_credential_host.py | 245 + scripts/credential_host.py | 110 + scripts/generate_credential_routes.py | 425 + scripts/install_credential_host.ps1 | 94 + scripts/verify_credential_host.py | 187 + tests/credentials/test_auth_protocols.py | 729 + tests/credentials/test_broker.py | 1070 + tests/credentials/test_consumer_adapters.py | 315 + tests/credentials/test_export_exclusions.py | 50 + tests/credentials/test_extended_helpers.py | 358 + tests/credentials/test_gateway_helper.py | 96 + tests/credentials/test_identity.py | 201 + .../test_integration_continuity.py | 115 + tests/credentials/test_job_transport.py | 145 + tests/credentials/test_keys.py | 148 + tests/credentials/test_migration.py | 200 + tests/credentials/test_model_transport.py | 104 + tests/credentials/test_protected_host.py | 299 + .../credentials/test_security_review_fixes.py | 644 + tests/credentials/test_source_consumers.py | 317 + tests/credentials/test_source_migration.py | 276 + tests/credentials/test_vault.py | 511 + tests/test_mini_browser_ops.py | 196 +- tests/test_mini_browser_ws.py | 211 +- 142 files changed, 39733 insertions(+), 1398 deletions(-) create mode 100644 app/credentials/__init__.py create mode 100644 app/credentials/aws_helper.py create mode 100644 app/credentials/broker.py create mode 100644 app/credentials/browser_helper.py create mode 100644 app/credentials/browser_migration.py create mode 100644 app/credentials/catalog.py create mode 100644 app/credentials/client.py create mode 100644 app/credentials/consumer_runtime.py create mode 100644 app/credentials/contracts.py create mode 100644 app/credentials/directory.py create mode 100644 app/credentials/errors.py create mode 100644 app/credentials/execution.py create mode 100644 app/credentials/gateway_helper.py create mode 100644 app/credentials/helpers.py create mode 100644 app/credentials/host_service.py create mode 100644 app/credentials/identity.py create mode 100644 app/credentials/integrations.py create mode 100644 app/credentials/invocation.py create mode 100644 app/credentials/keys.py create mode 100644 app/credentials/listeners.py create mode 100644 app/credentials/media_clients.py create mode 100644 app/credentials/migration.py create mode 100644 app/credentials/migration_source.py create mode 100644 app/credentials/model_transport.py create mode 100644 app/credentials/mtproto_helper.py create mode 100644 app/credentials/native/CredentialHost.cs create mode 100644 app/credentials/native/bootstrap.py create mode 100644 app/credentials/oauth_transport.py create mode 100644 app/credentials/operation_session.py create mode 100644 app/credentials/owner_transport.py create mode 100644 app/credentials/provider_auth.py create mode 100644 app/credentials/provider_routes.json create mode 100644 app/credentials/records.py create mode 100644 app/credentials/references.py create mode 100644 app/credentials/request_review.py create mode 100644 app/credentials/resolver.py create mode 100644 app/credentials/route_policy.py create mode 100644 app/credentials/secrets.py create mode 100644 app/credentials/settings.py create mode 100644 app/credentials/storage.py create mode 100644 app/credentials/streaming.py create mode 100644 app/credentials/twitter_helper.py create mode 100644 app/credentials/use_errors.py create mode 100644 app/credentials/vault.py create mode 100644 app/credentials/windows_host.py create mode 100644 app/credentials/wire.py create mode 100644 app/data/action/credential_operations.py create mode 100644 app/ui_layer/browser/frontend/src/pages/Settings/CredentialsSettings.module.css create mode 100644 app/ui_layer/browser/frontend/src/pages/Settings/CredentialsSettings.tsx create mode 100644 craftos_integrations/helpers/source_auth.py create mode 100644 craftos_integrations/helpers/source_oauth.py create mode 100644 craftos_integrations/helpers/source_requests.py create mode 100644 docs/plans/credential-manager-cm01.md create mode 100644 docs/plans/credential-manager-cm02.md create mode 100644 docs/plans/credential-manager-cm03.md create mode 100644 docs/plans/credential-manager-consumers.md create mode 100644 docs/plans/credential-manager-windows-host.md create mode 100644 requirements/credential-host-win_amd64-py312.txt create mode 100644 scripts/build_credential_host.py create mode 100644 scripts/credential_host.py create mode 100644 scripts/generate_credential_routes.py create mode 100644 scripts/install_credential_host.ps1 create mode 100644 scripts/verify_credential_host.py create mode 100644 tests/credentials/test_auth_protocols.py create mode 100644 tests/credentials/test_broker.py create mode 100644 tests/credentials/test_consumer_adapters.py create mode 100644 tests/credentials/test_export_exclusions.py create mode 100644 tests/credentials/test_extended_helpers.py create mode 100644 tests/credentials/test_gateway_helper.py create mode 100644 tests/credentials/test_identity.py create mode 100644 tests/credentials/test_integration_continuity.py create mode 100644 tests/credentials/test_job_transport.py create mode 100644 tests/credentials/test_keys.py create mode 100644 tests/credentials/test_migration.py create mode 100644 tests/credentials/test_model_transport.py create mode 100644 tests/credentials/test_protected_host.py create mode 100644 tests/credentials/test_security_review_fixes.py create mode 100644 tests/credentials/test_source_consumers.py create mode 100644 tests/credentials/test_source_migration.py create mode 100644 tests/credentials/test_vault.py diff --git a/agent_core/core/embedding_interface.py b/agent_core/core/embedding_interface.py index 970f54322..e13757274 100644 --- a/agent_core/core/embedding_interface.py +++ b/agent_core/core/embedding_interface.py @@ -148,7 +148,13 @@ def _get_byteplus_embedding(self, text: str) -> Optional[List[float]]: "Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}", } - response = requests.post(url, json=payload, headers=headers, timeout=120) + from app.credentials.model_transport import requests_response + + response = requests_response("byteplus", self.api_key, "POST", url, payload) + if response is None: + response = requests.post( + url, json=payload, headers=headers, timeout=120 + ) response.raise_for_status() result = response.json() data = result.get("data") diff --git a/agent_core/core/impl/action/executor.py b/agent_core/core/impl/action/executor.py index 6d15b734f..498a94a3f 100644 --- a/agent_core/core/impl/action/executor.py +++ b/agent_core/core/impl/action/executor.py @@ -685,8 +685,17 @@ class ActionExecutor: - Automatic requirement installation """ - def __init__(self): + def __init__( + self, + *, + credential_profile_id: Optional[str] = None, + credential_profile_resolver=None, + ): self._inflight: Dict[str, Any] = {} + # Host-selected preference, never action metadata or agent-bundle state. + # The protected service still requires explicit native owner approval. + self._credential_profile_id = credential_profile_id + self._credential_profile_resolver = credential_profile_resolver async def execute_atomic_action( self, @@ -719,6 +728,37 @@ async def execute_atomic_action( if requirements and execution_mode == "internal": _ensure_requirements(requirements) + if execution_mode == "credential_isolated": + from app.credentials.consumer_runtime import execute_action + + profile_id = self._credential_profile_id + if self._credential_profile_resolver is not None: + try: + profile_id = await asyncio.to_thread( + self._credential_profile_resolver + ) + except Exception: + profile_id = None + if profile_id: + from uuid import UUID + + try: + profile_id = str(UUID(profile_id)) + except (ValueError, TypeError, AttributeError): + profile_id = None + if not profile_id or requirements: + return { + "status": "error", + "message": "Credential actions require an explicit profile and the protected host's standard library runtime", + } + return await execute_action( + action.code, + input_data, + profile_id=profile_id, + subject=action.name, + timeout=effective_timeout, + ) + if execution_mode == "internal": # Use async executor which handles both sync and async action functions # Async functions are awaited directly; sync functions run in thread pool diff --git a/agent_core/core/impl/action/manager.py b/agent_core/core/impl/action/manager.py index f0a16427e..dbf7fd7e7 100644 --- a/agent_core/core/impl/action/manager.py +++ b/agent_core/core/impl/action/manager.py @@ -174,6 +174,7 @@ def __init__( on_action_end: Optional[OnActionEndHook] = None, get_parent_id: Optional[GetParentIdHook] = None, idempotency_guard: Optional["IdempotencyGuard"] = None, + credential_profile_resolver=None, ): """ Build an ActionManager that can execute and track actions. @@ -199,7 +200,9 @@ def __init__( self.event_stream_manager = event_stream_manager self.context_engine = context_engine self.state_manager = state_manager - self.executor = ActionExecutor() + self.executor = ActionExecutor( + credential_profile_resolver=credential_profile_resolver + ) # Track in-flight actions self._inflight: Dict[str, Dict] = {} diff --git a/agent_core/core/impl/llm/cache/byteplus.py b/agent_core/core/impl/llm/cache/byteplus.py index 19bf17a2f..94bd10885 100644 --- a/agent_core/core/impl/llm/cache/byteplus.py +++ b/agent_core/core/impl/llm/cache/byteplus.py @@ -143,7 +143,11 @@ def _call_responses_api( f"[BYTEPLUS REQUEST] Payload: {self._sanitize_payload_for_logging(payload)}" ) - response = requests.post(url, json=payload, headers=headers, timeout=600) + from app.credentials.model_transport import requests_response + + response = requests_response("byteplus", self.api_key, "POST", url, payload) + if response is None: + response = requests.post(url, json=payload, headers=headers, timeout=600) # Log the response status logger.info(f"[BYTEPLUS RESPONSE] Status: {response.status_code}") diff --git a/agent_core/core/impl/llm/transports/byteplus_responses.py b/agent_core/core/impl/llm/transports/byteplus_responses.py index ea5db6499..066177fa6 100644 --- a/agent_core/core/impl/llm/transports/byteplus_responses.py +++ b/agent_core/core/impl/llm/transports/byteplus_responses.py @@ -236,7 +236,11 @@ def generate_standard( ) logger.info(f"[BYTEPLUS STANDARD REQUEST] Messages count: {len(messages)}") - response = requests.post(url, json=payload, headers=headers, timeout=600) + from app.credentials.model_transport import requests_response + + response = requests_response("byteplus", iface.api_key, "POST", url, payload) + if response is None: + response = requests.post(url, json=payload, headers=headers, timeout=600) # Log response status logger.info(f"[BYTEPLUS STANDARD RESPONSE] Status: {response.status_code}") diff --git a/agent_core/core/impl/video_gen/interface.py b/agent_core/core/impl/video_gen/interface.py index d621985c6..7496a5e74 100644 --- a/agent_core/core/impl/video_gen/interface.py +++ b/agent_core/core/impl/video_gen/interface.py @@ -977,15 +977,19 @@ def _byteplus_submit( """Submit a Seedance task and return its task id.""" url = f"{base_url}/contents/generations/tasks" try: - r = requests.post( - url, - headers={ - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - }, - json=body, - timeout=60, - ) + from app.credentials.model_transport import requests_response + + r = requests_response("byteplus", api_key, "POST", url, body) + if r is None: + r = requests.post( + url, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json=body, + timeout=60, + ) except Exception as exc: raise _classified_error("byteplus", exc, self.model) from exc @@ -1024,11 +1028,15 @@ def _byteplus_poll( last_status = "unknown" while True: try: - r = requests.get( - poll_url, - headers={"Authorization": f"Bearer {api_key}"}, - timeout=30, - ) + from app.credentials.model_transport import requests_response + + r = requests_response("byteplus", api_key, "GET", poll_url) + if r is None: + r = requests.get( + poll_url, + headers={"Authorization": f"Bearer {api_key}"}, + timeout=30, + ) r.raise_for_status() except Exception as exc: raise _classified_error("byteplus", exc, self.model) from exc diff --git a/agent_core/core/impl/vlm/interface.py b/agent_core/core/impl/vlm/interface.py index df9a2f6f6..2c9ef01bb 100644 --- a/agent_core/core/impl/vlm/interface.py +++ b/agent_core/core/impl/vlm/interface.py @@ -748,7 +748,11 @@ def _byteplus_describe_bytes( "Authorization": f"Bearer {self.api_key}", } - response = requests.post(url, json=payload, headers=headers, timeout=120) + from app.credentials.model_transport import requests_response + + response = requests_response("byteplus", self.api_key, "POST", url, payload) + if response is None: + response = requests.post(url, json=payload, headers=headers, timeout=120) response.raise_for_status() result = response.json() diff --git a/agent_core/core/llm/google_gemini_client.py b/agent_core/core/llm/google_gemini_client.py index 98e2593b5..28ad7e21e 100644 --- a/agent_core/core/llm/google_gemini_client.py +++ b/agent_core/core/llm/google_gemini_client.py @@ -442,6 +442,12 @@ def delete_cache(self, cache_name: str) -> None: cache_id = f"cachedContents/{cache_name}" url = self._endpoint(cache_id) + from app.credentials.model_transport import requests_response + + protected = requests_response("gemini", self._api_key, "DELETE", url) + if protected is not None: + protected.raise_for_status() + return response = requests.delete( url, headers={"x-goog-api-key": self._api_key}, @@ -795,6 +801,12 @@ def poll_video_operation(self, operation_name: str) -> Dict[str, Any]: if path.startswith(f"{self._api_version}/"): path = path[len(f"{self._api_version}/") :] url = self._endpoint(path) + from app.credentials.model_transport import requests_response + + protected = requests_response("gemini", self._api_key, "GET", url) + if protected is not None: + protected.raise_for_status() + return protected.json() response = requests.get( url, headers={"x-goog-api-key": self._api_key}, @@ -821,6 +833,12 @@ def download_video(self, video_uri: str, *, timeout: int = 120) -> bytes: ``HTTPError`` messages (which include the request URL) and any request/response logging. """ + from app.credentials.model_transport import requests_response + + protected = requests_response("gemini", self._api_key, "GET", video_uri) + if protected is not None: + protected.raise_for_status() + return protected.content response = requests.get( video_uri, headers={"x-goog-api-key": self._api_key}, @@ -839,6 +857,14 @@ def _endpoint(self, path: str) -> str: def _post_json(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]: """Send POST request and return JSON response.""" + from app.credentials.model_transport import requests_response + + protected = requests_response( + "gemini", self._api_key, "POST", self._endpoint(path), payload + ) + if protected is not None: + protected.raise_for_status() + return protected.json() response = requests.post( self._endpoint(path), headers={"x-goog-api-key": self._api_key}, @@ -862,6 +888,13 @@ def _post_json(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]: response.raise_for_status() return response.json() + def _require_legacy_transport(self): + from app.credentials.references import is_reference + from app.credentials.client import CredentialHostUnavailable + + if is_reference(self._api_key): + raise CredentialHostUnavailable("credential_model_helper_unavailable") + @staticmethod def _extract_text(response: Dict[str, Any]) -> str: """Extract text content from Gemini response.""" diff --git a/agent_core/core/models/connection_tester.py b/agent_core/core/models/connection_tester.py index 1b9114679..c2d5f9b18 100644 --- a/agent_core/core/models/connection_tester.py +++ b/agent_core/core/models/connection_tester.py @@ -41,6 +41,124 @@ def test_provider_connection( Returns: Dictionary with success/message/provider/error. """ + from app.credentials.references import is_reference + + if is_reference(api_key): + from app.credentials.model_transport import installed_model_helpers + from app.credentials.invocation import invoke + + try: + body = None + if model: + if provider == "gemini": + operation = f"generate_{model}" + body = { + "contents": [{"parts": [{"text": "Hi"}]}], + "generationConfig": {"maxOutputTokens": 4}, + } + else: + operation = ( + "messages" if provider == "anthropic" else "chat_completions" + ) + body = { + "model": model, + "max_tokens": 4, + "messages": [{"role": "user", "content": "Hi"}], + } + else: + operation = "models" + routes = [ + item + for item in installed_model_helpers() + if item["helper"] == f"model_{provider}" + and item["operation"] == operation + and ( + not base_url + or item["endpoint"].startswith(base_url.rstrip("/") + "/") + ) + ] + if not routes and model and provider == "gemini": + from app.credentials.route_policy import match_route + + base = ( + base_url or "https://generativelanguage.googleapis.com/v1beta" + ).rstrip("/") + if not base.endswith("/v1beta") and not base_url: + base += "/v1beta" + matched = match_route( + [ + r + for r in installed_model_helpers() + if r["helper"] == "model_gemini" + ], + "POST", + base + "/models/" + model + ":generateContent", + ) + if matched is not None: + route, path, query = matched + routes = [route] + operation = route["operation"] + if route.get("envelope"): + body = {"path": path, "query": query, "data": body} + if len(routes) != 1: + return { + "success": False, + "message": "This endpoint needs one installed credential helper.", + "provider": provider, + } + route = routes[0] + response = invoke( + api_key, + route["helper"], + operation, + body, + subject=f"Test {provider} connection", + ) + success = 200 <= response["status"] < 300 + return { + "success": success, + "message": "Connection verified." if success else "Connection failed.", + "provider": provider, + } + except Exception: + return { + "success": False, + "message": "A supported credential helper and owner approval are required.", + "provider": provider, + } + if provider == "bedrock": + source = aws_credentials + if not source: + try: + from app.config import get_aws_credentials + + source = get_aws_credentials() + except Exception: + source = {} + if "source_reference" in source: + try: + from app.credentials.model_transport import VaultBedrockClient + + client = VaultBedrockClient( + source["source_reference"], + source.get("region") or base_url or "us-east-1", + ) + client.converse( + modelId=model or "us.anthropic.claude-haiku-4-5-20251001-v1:0", + messages=[{"role": "user", "content": [{"text": "Hi"}]}], + inferenceConfig={"maxTokens": 1}, + ) + return { + "success": True, + "message": "Connection verified.", + "provider": provider, + } + except Exception: + return { + "success": False, + "message": "A reviewed Bedrock signing helper and owner approval are required.", + "provider": provider, + } return _test_provider_connection_inner( provider, api_key=api_key, diff --git a/agent_core/core/models/factory.py b/agent_core/core/models/factory.py index 7d8162f88..ccb359bfe 100644 --- a/agent_core/core/models/factory.py +++ b/agent_core/core/models/factory.py @@ -107,6 +107,16 @@ def _create_openai_client( if default_headers: kwargs["default_headers"] = default_headers + if oauth_provider is None: + from app.credentials.references import is_reference + + if is_reference(api_key): + from app.credentials.model_transport import http_client + + kwargs["api_key"] = "vault-managed" + kwargs["http_client"] = http_client(provider, api_key, resolve_key) + return OpenAI(**kwargs) + if oauth_provider is None and resolve_key is not None: # Credential pool: re-resolve the key per request (same SDK property # mechanism as subscription OAuth below), so a cooldown-driven @@ -172,6 +182,15 @@ def _create_anthropic_client(*, api_key: str, resolve_key=None): "Install it with the Python that launches CraftBot: " "`python -m pip install 'anthropic>=0.97.0'`." ) from exc + from app.credentials.references import is_reference + + if is_reference(api_key): + from app.credentials.model_transport import http_client + + return Anthropic( + api_key="vault-managed", + http_client=http_client("anthropic", api_key, resolve_key), + ) if resolve_key is None: return Anthropic(api_key=api_key) @@ -597,6 +616,7 @@ def create( # If unset, fall back to settings → AWS_REGION env → default. region = resolved_base_url or "us-east-1" access_key = secret_key = session_token = None + creds = {} try: from app.config import get_aws_credentials # type: ignore @@ -610,6 +630,19 @@ def create( # role, SSO profile). Useful when running on an EC2/ECS host. pass + if "source_reference" in creds: + from app.credentials.model_transport import VaultBedrockClient + + return { + **empty_context, + "provider": provider, + "model": model, + "bedrock_client": VaultBedrockClient( + creds["source_reference"], creds.get("region") or region + ), + "initialized": True, + } + if boto3 is None: if deferred: return empty_context diff --git a/app/agent_app/integration_bridge.py b/app/agent_app/integration_bridge.py index 94ea3f2e4..7e1ee4753 100644 --- a/app/agent_app/integration_bridge.py +++ b/app/agent_app/integration_bridge.py @@ -12,6 +12,8 @@ from __future__ import annotations import logging +import asyncio +import time from pathlib import Path from typing import TYPE_CHECKING, Dict, Optional @@ -84,6 +86,7 @@ def __init__(self, manager: "AgentAppManager"): # follow_redirects is OFF for the proxy: an allowed host could 302 to an # attacker-controlled host and the injected credentials would follow it. self._http_client = httpx.AsyncClient(timeout=30, follow_redirects=False) + self._credential_runs = {} def register_routes(self, app: web.Application) -> None: """Register integration bridge routes on the aiohttp app.""" @@ -93,11 +96,98 @@ def register_routes(self, app: web.Application) -> None: app.router.add_post("/api/bridge/llm", self._handle_llm) app.router.add_post("/api/bridge/vlm", self._handle_vlm) app.router.add_post("/api/bridge/agent_request", self._handle_agent_request) + app.router.add_post("/api/credentials/worker", self._handle_credential_worker) logger.info("[INTEGRATION_BRIDGE] Routes registered") async def cleanup(self) -> None: """Close the HTTP client.""" await self._http_client.aclose() + from app.credentials.client import request + + for run_id in tuple(self._credential_runs): + try: + await asyncio.to_thread(request, "stop", run_id=run_id) + except Exception: + pass + self._credential_runs.clear() + + async def _handle_credential_worker(self, request: web.Request) -> web.Response: + """Launch a separate revision-pinned worker; never relay secret-use RPC. + + The existing bearer identifies the requesting project only. Native owner + consent and the worker's real AppContainer/job establish vault grants. + Bridge launches are development workers: the shared live/shadow token + cannot prove which deployment originated a request. + """ + project = self._validate_token(request) + if not project: + return web.json_response({"error": "Unauthorized"}, status=401) + self._credential_runs = { + key: value + for key, value in self._credential_runs.items() + if value[1] > time.monotonic() + } + try: + data = await request.json() + if type(data) is not dict: + raise ValueError() + operation = data.get("operation") + if operation == "launch" and set(data) == { + "operation", + "profile_id", + "files", + "entry", + }: + if len(self._credential_runs) >= 16: + return web.json_response( + {"error": "credential_use_limit"}, status=429 + ) + result = await self._manager.launch_credential_worker( + project, + data["profile_id"], + data["files"], + data["entry"], + development=True, + ) + self._credential_runs[result["run_id"]] = ( + project, + time.monotonic() + 900, + ) + elif ( + ( + operation in ("result", "program_result", "stop") + and set(data) == {"operation", "run_id"} + ) + or ( + operation == "response" + and set(data) == {"operation", "run_id", "offset"} + ) + or ( + operation == "stage_payload" + and set(data) == {"operation", "run_id", "offset", "data", "done"} + ) + ): + run = self._credential_runs.get(data["run_id"]) + if run is None or run[0] != project: + return web.json_response( + {"error": "credential_use_denied"}, status=403 + ) + from app.credentials.client import request as host_request + + result = await asyncio.to_thread( + host_request, + operation, + **{key: value for key, value in data.items() if key != "operation"}, + ) + if operation == "stop": + del self._credential_runs[data["run_id"]] + else: + return web.json_response({"error": "credential_use_denied"}, status=403) + return web.json_response(result) + except Exception: + return web.json_response( + {"error": "credential_worker_unavailable"}, status=400 + ) # ------------------------------------------------------------------ # Route handlers diff --git a/app/agent_app/manager.py b/app/agent_app/manager.py index a69eba8fa..c54542586 100644 --- a/app/agent_app/manager.py +++ b/app/agent_app/manager.py @@ -239,6 +239,36 @@ def to_dict(self) -> Dict[str, Any]: class AgentAppManager: """Manages Agent App project lifecycle.""" + async def launch_credential_worker( + self, + project_id: str, + profile_id: str, + files: dict, + entry: str, + *, + development=False, + ) -> dict: + """Separate protected Python worker; no bridge token or host env inheritance. + + Existing web/Node processes receive no vault authority. The service + snapshots these files and pins their actual hash and job. Project name + and deployment remain explicitly unverified claims in native consent. + """ + from app.credentials.client import request + + if self.get_project(project_id) is None: + raise ValueError("Unknown Agent App project") + return await asyncio.to_thread( + request, + "launch", + profile_id=profile_id, + kind="agent_app", + subject=project_id, + deployment="development" if development else "live", + files=files, + entry=entry, + ) + def __init__(self, workspace_root: Path): """ Initialize the Agent App Manager. diff --git a/app/agent_base.py b/app/agent_base.py index 16836ed79..bdd4534b4 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -351,6 +351,8 @@ def __init__( from app.triggers.activity_log import ActivityLogGuard, get_activity_log self.activity_log = get_activity_log() + from app.credentials.client import action_profile + self.action_manager = ActionManager( self.action_library, self.llm, @@ -359,6 +361,7 @@ def __init__( self.context_engine, self.state_manager, idempotency_guard=ActivityLogGuard(self.activity_log), + credential_profile_resolver=action_profile, ) self.action_router = ActionRouter( self.action_library, @@ -4094,7 +4097,8 @@ async def _initialize_external_libraries(self) -> None: }, extras={ "agent_name": agent_name, - "openai_api_key": os.environ.get("OPENAI_API_KEY", ""), + "openai_api_key": get_api_key("openai") + or os.environ.get("OPENAI_API_KEY", ""), # The WhatsApp bridge spawns a Node subprocess and needs the # runtime this app resolved. Injected rather than imported — # the integrations package must stay host-blind. diff --git a/app/config.py b/app/config.py index 756dbd702..eca3f5f6a 100644 --- a/app/config.py +++ b/app/config.py @@ -443,6 +443,11 @@ def get_aws_credentials() -> Dict[str, str]: aws = settings.get("aws_credentials", {}) or {} endpoints = settings.get("endpoints", {}) or {} + if aws.get("auth_mode") == "vault": + return { + "source_reference": aws.get("source_reference", ""), + "region": endpoints.get("aws_region") or "us-east-1", + } return { "access_key_id": aws.get("access_key_id") or os.environ.get("AWS_ACCESS_KEY_ID", "") diff --git a/app/credentials/__init__.py b/app/credentials/__init__.py new file mode 100644 index 000000000..e3cf28b3c --- /dev/null +++ b/app/credentials/__init__.py @@ -0,0 +1,5 @@ +"""Host-owned credential contracts and storage; no consumer routes are enabled. + +These types are not an authentication or OS isolation mechanism. Only trusted +host adapters may construct caller bindings from authenticated transport state. +""" diff --git a/app/credentials/aws_helper.py b/app/credentials/aws_helper.py new file mode 100644 index 000000000..c66fced9f --- /dev/null +++ b/app/credentials/aws_helper.py @@ -0,0 +1,73 @@ +"""Private static-AWS signing for one installed Bedrock Converse endpoint.""" + +from datetime import datetime, timezone +import hashlib +import hmac +import re +from urllib.parse import quote, urlsplit + +from .secrets import Secret +from .use_errors import CredentialUseError, UseCode + + +def endpoint_region(endpoint): + parsed = urlsplit(endpoint) + match = re.fullmatch( + r"bedrock-runtime\.([a-z]{2}(?:-[a-z]+)+-\d)\.amazonaws\.com", parsed.netloc + ) + if not match or not re.fullmatch(r"/model/[A-Za-z0-9_.:-]+/converse", parsed.path): + raise ValueError("invalid_bedrock_helper") + return match[1] + + +def signing_headers(endpoint, body: bytes, fields: Secret): + """No session-token flow here: generated AWS credentials retain their owner. + + Canonical request/signing-key derivation follow AWS SigV4. The endpoint has + no query, encoded aliases, consumer path parameters or alternate AWS service. + """ + region = endpoint_region(endpoint) + selected = Secret( + {key: fields.reveal()[key] for key in ("access_key_id", "secret_access_key")} + ) + if not re.fullmatch( + r"[A-Z0-9]{16,128}", selected.reveal()["access_key_id"] + ) or not re.fullmatch( + r"[A-Za-z0-9/+=]{16,256}", selected.reveal()["secret_access_key"] + ): + raise CredentialUseError(UseCode.DENIED) + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + date = stamp[:8] + parsed = urlsplit(endpoint) + signed = "content-type;host;x-amz-content-sha256;x-amz-date" + digest = hashlib.sha256(body).hexdigest() + canonical = Secret( + "\n".join( + ( + "POST", + quote(parsed.path, safe="/~"), + "", + f"content-type:application/json\nhost:{parsed.netloc}\nx-amz-content-sha256:{digest}\nx-amz-date:{stamp}\n", + signed, + digest, + ) + ) + ) + scope = f"{date}/{region}/bedrock/aws4_request" + message = Secret( + f"AWS4-HMAC-SHA256\n{stamp}\n{scope}\n" + + hashlib.sha256(canonical.reveal().encode()).hexdigest() + ) + key = Secret(("AWS4" + selected.reveal()["secret_access_key"]).encode()) + for part in (date, region, "bedrock", "aws4_request"): + key = Secret(hmac.digest(key.reveal(), part.encode(), "sha256")) + signature = Secret( + hmac.new(key.reveal(), message.reveal().encode(), "sha256").hexdigest() + ) + return Secret( + { + "Authorization": f"AWS4-HMAC-SHA256 Credential={selected.reveal()['access_key_id']}/{scope}, SignedHeaders={signed}, Signature={signature.reveal()}", + "x-amz-date": stamp, + "x-amz-content-sha256": digest, + } + ), selected diff --git a/app/credentials/broker.py b/app/credentials/broker.py new file mode 100644 index 000000000..7501d98e5 --- /dev/null +++ b/app/credentials/broker.py @@ -0,0 +1,510 @@ +"""Host-owned authorization and trusted execution, with no secret-returning API. + +Never expose this Python object to consumer code or deserialize its context from +JSON. A future protected IPC adapter must resolve caller identity out of band. +The default isolation/owner-verification adapters deny all credential use. +""" + +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass, field +from enum import Enum +from functools import wraps +import hashlib +import json +import math +import re +import secrets +from threading import RLock +import time +from uuid import UUID, uuid4 + +from .contracts import ( + CredentialReference, + CredentialSummary, + HelperOperation, + UnavailableIsolationBoundary, +) +from .directory import IdentityDirectory +from .helpers import ( + HelperRegistry, + HelperResult, + HttpsCredentialCheck, + HttpsJsonOperation, + JsonResult, + _execute_check, + _execute_json, + require_match, +) +from .identity import CallerBinding, ExecutionContext, VaultScope +from .keys import VerificationUnavailable +from .use_errors import CredentialUseError, UseCode, use_boundary +from .vault import ProfileVault, VaultState +from .browser_helper import HttpsFormLogin, _execute_form + + +class GrantMode(str, Enum): + ONCE = "once" + BINDING = "binding" # Until expiry/lock/revocation; never crosses a restart. + + +@dataclass(frozen=True) +class Grant: + grant_id: UUID + binding: CallerBinding + reference: CredentialReference + helper: HttpsCredentialCheck + unlock_epoch: UUID + expires_at: float # Host monotonic time; not a serialized wall-clock claim. + mode: GrantMode + request_digest: bytes | None + + @property + def operation(self) -> HelperOperation: + return self.helper.operation + + +@dataclass(frozen=True) +class UseCapability: + handle: str = field(repr=False) + + def __post_init__(self): + if not isinstance(self.handle, str) or not re.fullmatch( + r"[A-Za-z0-9_\-]{43}", self.handle + ): + raise ValueError("invalid_capability") + + +@dataclass(frozen=True) +class _PendingUse: + grant_id: UUID + context: ExecutionContext + expires_at: float + + +@dataclass(frozen=True) +class AuditEvent: + action: str + outcome: str + binding_id: UUID | None + execution_id: UUID | None + scope: VaultScope | None + grant_id: UUID | None + + +@dataclass(frozen=True) +class UseOffer: + grant_id: UUID + summary: CredentialSummary + operation: HelperOperation + + +def canonical_request(payload): + """Freeze caller data before approval/dispatch; never log the representation.""" + try: + encoded = json.dumps( + payload, + ensure_ascii=True, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode("ascii") + if len(encoded) > 32_000_020: + raise ValueError() + return json.loads(encoded), hashlib.sha256(encoded).digest() + except (TypeError, ValueError, RecursionError): + raise CredentialUseError(UseCode.DENIED) from None + + +def _audited(action): + def decorate(operation): + @wraps(operation) + def audited(self, context, *args, **kwargs): + try: + result = operation(self, context, *args, **kwargs) + except Exception: + self._event(action, context, "denied") + raise + except BaseException: + self._event(action, context, "interrupted") + raise + outcome = result.value if type(result) is HelperResult else "allowed" + self._event( + action, context, outcome, result if type(result) is Grant else None + ) + return result + + return audited + + return decorate + + +class CredentialBroker: + def __init__( + self, + directory: IdentityDirectory, + vaults: tuple[ProfileVault, ...], + registry: HelperRegistry, + *, + isolation=None, + owner_verifier=None, + clock=time.monotonic, + ): + self._directory = directory + for vault in vaults: + directory.require_vault(vault.owner, vault.profile.scope) + self._vaults = {vault.profile.scope: vault for vault in vaults} + if len(self._vaults) != len(vaults): + raise ValueError("duplicate_profile_vault") + self._registry = registry + self._isolation = ( + isolation if isolation is not None else UnavailableIsolationBoundary() + ) + self._verifier = ( + owner_verifier if owner_verifier is not None else VerificationUnavailable() + ) + self._clock = clock + self._grants: dict[UUID, Grant] = {} + self._pending: dict[bytes, _PendingUse] = {} + self._audit: deque[AuditEvent] = deque(maxlen=512) + self._lock = RLock() + + def _event(self, action, context, outcome, grant=None): + # No secret values, capability handles, response text, caller input labels + # or paths. Bounded in-memory host audit; never a consumer subscription. + valid = type(context) is ExecutionContext + with self._lock: + self._audit.append( + AuditEvent( + action, + outcome, + context.binding.binding_id + if valid + else (grant.binding.binding_id if grant else None), + context.execution_id if valid else None, + context.binding.scope + if valid + else (grant.reference.scope if grant else None), + grant.grant_id if grant else None, + ) + ) + + def audit_events(self) -> tuple[AuditEvent, ...]: + """Host owner diagnostics only; persistence/export needs an owner adapter.""" + with self._lock: + return tuple(self._audit) + + def add_profile(self, vault: ProfileVault) -> None: + """Protected owner catalog operation; never a consumer route.""" + with self._lock: + self._directory.require_vault(vault.owner, vault.profile.scope) + if vault.profile.scope in self._vaults: + raise CredentialUseError(UseCode.DENIED) + self._vaults[vault.profile.scope] = vault + + def revoke_scoped(self, grant_id: UUID, scope: VaultScope) -> None: + with self._lock: + grant = self._grants.get(grant_id) + if grant is not None and grant.reference.scope != scope: + raise CredentialUseError(UseCode.DENIED) + self.revoke(grant_id) + + def _prune(self): + now = self._clock() + self._grants = { + key: grant for key, grant in self._grants.items() if grant.expires_at > now + } + self._pending = { + key: pending + for key, pending in self._pending.items() + if pending.expires_at > now and pending.grant_id in self._grants + } + + def _vault(self, context, reference): + if ( + type(context) is not ExecutionContext + or type(reference) is not CredentialReference + ): + raise CredentialUseError(UseCode.DENIED) + self._directory.require_scope(context, reference.scope) + vault = self._vaults.get(reference.scope) + if vault is None: + raise CredentialUseError(UseCode.DENIED) + return vault + + def _isolated(self, context): + # Returning a boolean/attestation is not an implementation of this check. + try: + verified = self._isolation.require_isolated(context) is None + except Exception: + verified = False + if not verified: + raise CredentialUseError(UseCode.ISOLATION) + + def _guard(self, grant, context, vault): + if grant.binding != context.binding: + raise CredentialUseError(UseCode.DENIED) + if self._grants.get(grant.grant_id) != grant: + raise CredentialUseError(UseCode.DENIED) + if self._clock() >= grant.expires_at: + raise CredentialUseError(UseCode.EXPIRED) + if self._registry.require(grant.operation) != grant.helper: + raise CredentialUseError(UseCode.DENIED) + self._directory.require_scope(context, grant.reference.scope) + vault._require_epoch(grant.unlock_epoch) + self._isolated(context) + + @use_boundary + @_audited("approve") + def approve( + self, + context: ExecutionContext, + reference: CredentialReference, + operation: HelperOperation, + *, + mode: GrantMode = GrantMode.ONCE, + lifetime: float = 900, + payload=None, + ) -> Grant: + """Owner channel only: fresh verification for this exact displayed request. + + Verification alone is not consent. The protected owner UI must display + caller/revision/deployment, profile/account, operation AND exact endpoint, + then invoke this method only following the owner's explicit approval. + Never publish it on the agent/app request transport. + """ + _, request_digest = canonical_request(payload) + with self._lock: + if ( + type(mode) is not GrantMode + or type(lifetime) not in (float, int) + or not math.isfinite(lifetime) + or not 0 < lifetime <= 3600 + ): + raise CredentialUseError(UseCode.DENIED) + vault = self._vault(context, reference) + specification = self._registry.require(operation) + self._isolated(context) + try: + verified = self._verifier.verify(vault.owner) is None + except Exception: + verified = False + if not verified: + raise CredentialUseError(UseCode.VERIFICATION) + # Do not pin a slow owner prompt to an obsolete unlock epoch. + with self._directory.hold_current(context), vault._lock: + epoch = vault.status().unlock_epoch + vault._require_epoch(epoch) + with vault._helper_record(reference, epoch) as record: + require_match(specification, record.details) + self._isolated(context) + self._prune() + if len(self._grants) >= 4096: + raise CredentialUseError(UseCode.LIMIT) + grant = Grant( + uuid4(), + context.binding, + reference, + specification, + epoch, + self._clock() + lifetime, + mode, + request_digest if mode is GrantMode.ONCE else None, + ) + self._grants[grant.grant_id] = grant + return grant + + @use_boundary + @_audited("authorize") + def authorize(self, context: ExecutionContext, grant_id: UUID) -> UseCapability: + """Consumer use interface, with context supplied by authenticated host IPC.""" + with self._lock: + grant = self._grants.get(grant_id) + if grant is None: + raise CredentialUseError(UseCode.DENIED) + vault = self._vault(context, grant.reference) + with self._directory.hold_current(context), vault._lock: + self._guard(grant, context, vault) + with vault._helper_record( + grant.reference, grant.unlock_epoch + ) as record: + require_match( + self._registry.require(grant.operation), record.details + ) + self._guard(grant, context, vault) + self._prune() + if len(self._pending) >= 8192: + raise CredentialUseError(UseCode.LIMIT) + capability = UseCapability(secrets.token_urlsafe(32)) + self._pending[self._digest(capability)] = _PendingUse( + grant_id, context, min(self._clock() + 30, grant.expires_at) + ) + return capability + + @use_boundary + def status(self, context: ExecutionContext) -> VaultState: + """Bound profile state only; no key material or owner-unlock operation.""" + if type(context) is not ExecutionContext: + raise CredentialUseError(UseCode.DENIED) + with self._directory.hold_current(context): + vault = self._vaults.get(context.binding.scope) + if vault is None: + raise CredentialUseError(UseCode.DENIED) + return vault.status().state + + @use_boundary + def find(self, context: ExecutionContext) -> tuple[UseOffer, ...]: + """Discover only current approvals for this binding, never all accounts.""" + if type(context) is not ExecutionContext: + raise CredentialUseError(UseCode.DENIED) + with self._lock, self._directory.hold_current(context): + self._prune() + offers = [] + for grant in tuple(self._grants.values()): + if grant.binding != context.binding: + continue + vault = self._vault(context, grant.reference) + with vault._lock: + self._guard(grant, context, vault) + with vault._helper_record( + grant.reference, grant.unlock_epoch + ) as record: + require_match( + self._registry.require(grant.operation), record.details + ) + offers.append( + UseOffer( + grant.grant_id, record.details.summary, grant.operation + ) + ) + return tuple(offers) + + @staticmethod + def _digest(capability): + if type(capability) is not UseCapability: + raise CredentialUseError(UseCode.CAPABILITY) + return hashlib.sha256(capability.handle.encode("ascii")).digest() + + @use_boundary + @_audited("execute") + def execute( + self, + context: ExecutionContext, + capability: UseCapability, + payload=None, + *, + execution=None, + ) -> HelperResult | JsonResult: + from contextlib import ExitStack + + grant = None + payload, request_digest = canonical_request(payload) + try: + with ExitStack() as held: + held.enter_context(self._lock) + digest = self._digest(capability) + pending = self._pending.get(digest) + if pending is None or pending.context != context: + raise CredentialUseError(UseCode.CAPABILITY) + del self._pending[digest] + if self._clock() >= pending.expires_at: + raise CredentialUseError(UseCode.CAPABILITY) + grant = self._grants.get(pending.grant_id) + if grant is None: + raise CredentialUseError(UseCode.DENIED) + if ( + grant.mode is GrantMode.ONCE + and grant.request_digest != request_digest + ): + raise CredentialUseError(UseCode.DENIED) + vault = self._vault(context, grant.reference) + held.enter_context(self._directory.hold_current(context)) + held.enter_context(vault._lock) + self._guard(grant, context, vault) + record = held.enter_context( + vault._helper_record(grant.reference, grant.unlock_epoch) + ) + specification = self._registry.require(grant.operation) + require_match(specification, record.details) + + def before_send(): + if execution is not None and execution.cancelled.is_set(): + raise CredentialUseError(UseCode.INTERRUPTED) + if self._clock() >= pending.expires_at: + raise CredentialUseError(UseCode.CAPABILITY) + self._guard(grant, context, vault) + + def dispatched(): + # Spend one-use authority before releasing the dispatch + # reservation. Other pending capabilities cannot replay it. + if grant.mode is GrantMode.ONCE: + self._retire(grant.grant_id) + held.close() + if execution is not None: + execution.dispatched.set() + + if type(specification) is HttpsFormLogin: + result = _execute_form( + specification, record.fields, before_send, payload + ) + elif type(specification) is HttpsJsonOperation: + result = _execute_json( + specification, + record.fields, + before_send, + payload, + execution=execution, + after_send=dispatched, + ) + else: + if payload not in (None, {"body": None}): + raise CredentialUseError(UseCode.DENIED) + result = _execute_check(specification, record.fields, before_send) + if type(result) not in (HelperResult, JsonResult): + raise CredentialUseError(UseCode.HELPER) + return result + finally: + if grant is not None and grant.mode is GrantMode.ONCE: + with self._lock: + self._retire(grant.grant_id) + + def _retire(self, grant_id): + self._grants.pop(grant_id, None) + self._pending = { + key: value + for key, value in self._pending.items() + if value.grant_id != grant_id + } + + def capability_grant(self, context, capability): + """Host-internal cancellation bookkeeping; possession grants no authority.""" + with self._lock: + pending = self._pending.get(self._digest(capability)) + if pending is None or pending.context != context: + raise CredentialUseError(UseCode.CAPABILITY) + return pending.grant_id + + def revoke(self, grant_id: UUID) -> None: + """Trusted host/owner revocation. Already dispatched requests finish first.""" + with self._lock: + grant = self._grants.pop(grant_id, None) + self._pending = { + key: value + for key, value in self._pending.items() + if value.grant_id != grant_id + } + if grant is not None: + self._event("revoke", None, "revoked", grant) + + @use_boundary + @_audited("cancel") + def cancel(self, context: ExecutionContext, capability: UseCapability) -> None: + """Cancel queued use. A dispatched HTTPS request cannot be retracted.""" + with self._lock: + self._directory.require_current(context) + digest = self._digest(capability) + pending = self._pending.get(digest) + if pending is None or pending.context != context: + raise CredentialUseError(UseCode.CAPABILITY) + del self._pending[digest] diff --git a/app/credentials/browser_helper.py b/app/credentials/browser_helper.py new file mode 100644 index 000000000..aefd35456 --- /dev/null +++ b/app/credentials/browser_helper.py @@ -0,0 +1,294 @@ +"""Reviewed HTTPS form sign-in without putting source values in Playwright. + +No universal form discovery, JavaScript execution, callback URLs or consumers' +browser/CDP handles. A host-installed descriptor fixes the form, destination, +success redirect and exact session-cookie names. Other flows need owner handoff. +""" + +from dataclasses import dataclass +from html.parser import HTMLParser +import http.client +from http.cookies import SimpleCookie +import json +import re +import socket +import ssl +from threading import Timer +import time +from urllib.parse import urlencode, urljoin, urlsplit + +from .contracts import CredentialKind +from .helpers import ( + HttpsCredentialCheck, + JsonResult, + _abort_socket, + _address, + _redact_response, + _remaining, +) +from .secrets import Secret +from .use_errors import CredentialUseError, UseCode, use_boundary + + +@dataclass(frozen=True) +class HttpsFormLogin(HttpsCredentialCheck): + login_page: str = "" + success_url: str = "" + username_field: str = "username" + password_field: str = "password" + csrf_field: str | None = None + cookie_names: tuple[str, ...] = () + + def __post_init__(self): + super().__post_init__() + if self.kind is not CredentialKind.LOGIN: + raise ValueError("invalid_browser_helper") + if self.csrf_field is not None and ( + not isinstance(self.csrf_field, str) or not self.csrf_field + ): + raise ValueError("invalid_browser_fields") + for endpoint in (self.login_page, self.success_url): + candidate = HttpsCredentialCheck(self.operation, self.kind, endpoint) + if candidate.origin != self.origin: + raise ValueError("invalid_browser_origin") + names = (self.username_field, self.password_field) + ( + (self.csrf_field,) if self.csrf_field else () + ) + if len(set(names)) != len(names) or any( + not isinstance(n, str) + or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.-]{0,63}", n) + for n in names + ): + raise ValueError("invalid_browser_fields") + if ( + type(self.cookie_names) is not tuple + or not 1 <= len(self.cookie_names) <= 8 + or len(set(self.cookie_names)) != len(self.cookie_names) + or any( + not re.fullmatch(r"[A-Za-z0-9_\-]{1,128}", n) for n in self.cookie_names + ) + ): + raise ValueError("invalid_browser_cookies") + + +class _Form(HTMLParser): + def __init__(self, specification): + super().__init__(convert_charrefs=True) + self.spec = specification + self.active = False + self.forms = [] + self.current = None + + def handle_starttag(self, tag, attributes): + attrs = dict(attributes) + if tag == "form": + if self.active: + raise ValueError("nested_form") + self.active = ( + attrs.get("method", "get").lower() == "post" + and urljoin(self.spec.login_page, attrs.get("action") or "") + == self.spec.endpoint + ) + self.current = {} if self.active else None + elif tag == "input" and self.active: + name = attrs.get("name") + if name in ( + self.spec.username_field, + self.spec.password_field, + self.spec.csrf_field, + ): + if name in self.current or "disabled" in attrs: + raise ValueError("ambiguous_form") + kind = (attrs.get("type") or "text").lower() + if name == self.spec.password_field and ( + kind != "password" + or "new-password" in (attrs.get("autocomplete") or "") + ): + raise ValueError("not_login_form") + if name == self.spec.username_field and kind not in ("text", "email"): + raise ValueError("not_login_form") + if name == self.spec.csrf_field and kind != "hidden": + raise ValueError("invalid_csrf") + self.current[name] = attrs.get("value") or "" + + def handle_endtag(self, tag): + if tag == "form" and self.active: + self.forms.append(self.current) + self.active = False + + +def _roundtrip(spec, url, method, body, cookies, guard, deadline): + parsed = urlsplit(url) + family, socktype, protocol, sockaddr = _address(parsed.hostname, parsed.port or 443) + guard() + tls = ssl.create_default_context() + tls.set_alpn_protocols(["http/1.1"]) + connection = http.client.HTTPSConnection( + parsed.hostname, parsed.port or 443, context=tls + ) + connection.auto_open = 0 + connection.set_debuglevel(0) + timer = None + try: + with socket.socket(family, socktype, protocol) as transport: + transport.settimeout(_remaining(deadline)) + transport.connect(sockaddr) + transport.settimeout(_remaining(deadline)) + connection.sock = tls.wrap_socket( + transport, server_hostname=parsed.hostname + ) + timer = Timer(_remaining(deadline), _abort_socket, args=(connection.sock,)) + timer.daemon = True + timer.start() + guard() + headers = { + "User-Agent": "CraftBotCredentialHost/1", + "Connection": "close", + "Accept": "text/html", + } + if cookies.reveal(): + headers["Cookie"] = "; ".join(cookies.reveal()) + if method == "POST": + headers.update( + { + "Content-Type": "application/x-www-form-urlencoded", + "Origin": spec.origin, + "Referer": spec.login_page, + } + ) + connection.request( + method, parsed.path or "/", body.reveal() if body else None, headers + ) + with connection.getresponse() as response: + raw = Secret(response.read(512001)) + if len(raw.reveal()) > 512000: + raise CredentialUseError(UseCode.LIMIT) + return response.status, Secret(response.getheaders()), raw + finally: + if timer is not None: + timer.cancel() + connection.close() + + +def _cookies(headers, host): + result = Secret({}) + for name, value in headers.reveal(): + if name.lower() != "set-cookie": + continue + parsed = SimpleCookie() + parsed.load(value) + for key, cookie in parsed.items(): + if ( + not cookie["secure"] + or cookie["path"] not in ("", "/") + or cookie["domain"] + and cookie["domain"].lstrip(".").lower() != host.lower() + ): + continue + if len(cookie.value) > 8192 or any( + ord(c) < 33 or ord(c) > 126 for c in cookie.value + ): + raise CredentialUseError(UseCode.HELPER) + result.reveal()[key] = { + "value": cookie.value, + "httpOnly": bool(cookie["httponly"]), + "sameSite": {"strict": "Strict", "lax": "Lax", "none": "None"}.get( + cookie["samesite"].lower(), "Lax" + ), + } + if cookie["max-age"] or cookie["expires"]: + from email.utils import parsedate_to_datetime + + try: + expires = ( + time.time() + int(cookie["max-age"]) + if cookie["max-age"] + else parsedate_to_datetime(cookie["expires"]).timestamp() + ) + except (ValueError, TypeError, OverflowError): + raise CredentialUseError(UseCode.HELPER) from None + if expires <= time.time(): + result.reveal().pop(key) + else: + result.reveal()[key]["expires"] = expires + return result + + +@use_boundary +def _execute_form(specification, fields: Secret, guard, payload): + if payload not in (None, {"body": None}): + raise CredentialUseError(UseCode.DENIED) + deadline = time.monotonic() + 25 + status, headers, raw = _roundtrip( + specification, + specification.login_page, + "GET", + None, + Secret([]), + guard, + deadline, + ) + if status != 200: + raise CredentialUseError(UseCode.HELPER) + form = _Form(specification) + form.feed(raw.reveal().decode("utf-8")) + required = {specification.username_field, specification.password_field} + if specification.csrf_field: + required.add(specification.csrf_field) + if len(form.forms) != 1 or set(form.forms[0]) != required: + raise CredentialUseError(UseCode.DENIED) + data = Secret( + { + specification.username_field: fields.reveal()["username"], + specification.password_field: fields.reveal()["password"], + } + ) + if specification.csrf_field: + token = form.forms[0][specification.csrf_field] + if not token or len(token) > 8192: + raise CredentialUseError(UseCode.DENIED) + data.reveal()[specification.csrf_field] = token + host = urlsplit(specification.endpoint).hostname + initial = _cookies(headers, host) + jar = SimpleCookie() + for name, item in initial.reveal().items(): + jar[name] = item["value"] + cookies = Secret([cookie.OutputString(attrs=[]) for cookie in jar.values()]) + status, headers, _ = _roundtrip( + specification, + specification.endpoint, + "POST", + Secret(urlencode(data.reveal()).encode("utf-8")), + cookies, + guard, + deadline, + ) + locations = [v for k, v in headers.reveal() if k.lower() == "location"] + if ( + status not in (302, 303) + or len(locations) != 1 + or urljoin(specification.endpoint, locations[0]) != specification.success_url + ): + return JsonResult( + 409, '{"outcome":"owner_handoff_required"}', "application/json" + ) + session = _cookies(headers, host) + if not all(name in session.reveal() for name in specification.cookie_names): + return JsonResult( + 409, '{"outcome":"owner_handoff_required"}', "application/json" + ) + values = [ + { + "name": name, + **session.reveal()[name], + "url": specification.origin + "/", + "secure": True, + } + for name in specification.cookie_names + ] + output = json.dumps({"cookies": values, "continue_url": specification.success_url}) + if _redact_response(output, fields) != output: + raise CredentialUseError( + UseCode.HELPER + ) # Never export a source echo as a cookie. + return JsonResult(200, output, "application/json") diff --git a/app/credentials/browser_migration.py b/app/credentials/browser_migration.py new file mode 100644 index 000000000..4c572a75d --- /dev/null +++ b/app/credentials/browser_migration.py @@ -0,0 +1,169 @@ +"""Owner-only offline retirement of the three current legacy browser files. + +Encrypted legacy history is archived under the SYSTEM state root. Its old key +is itself encrypted inside a non-usable vault recovery item, never a key file. +The metadata journal makes source deletion resumable after destination proof. +""" + +import base64 +import hashlib +import hmac +import json +import os +from pathlib import Path +from uuid import uuid5 + +from .contracts import CredentialKind +from .errors import VaultError, VaultErrorCode as Code +from .migration import migrate_browser_document +from .migration_source import OwnerSettingsSource +from .references import parse +from .secrets import Secret +from .windows_host import private_acl + +FILES = ( + "mini_browser_vault.enc", + "mini_browser_vault.enc.bak", + "mini_browser_vault.key", +) + + +def migrate_browser_files(vault, path, state: Path, owner_sid, session): + from cryptography.fernet import Fernet + + path = Path(path) + if path.name != FILES[0]: + raise VaultError(Code.INVALID) + source_id = hashlib.sha256(str(path).casefold().encode("utf-8")).hexdigest() + archive = state / "legacy-browser" / str(vault.profile.scope.profile_id) / source_id + archive.mkdir(parents=True, exist_ok=True) + private_acl(archive) + journal = archive / "migration.json" + sources = {} + try: + if journal.exists(): + proof = json.loads(journal.read_text(encoding="utf-8")) + else: + raw = {} + for name in FILES: + source = OwnerSettingsSource( + path.with_name(name), owner_sid, session, filename=name + ) + if name == FILES[1]: + with source._owner(): + if not source.path.exists(): + continue + sources[name] = source + raw[name] = source.read_bytes(32_000_000 if name != FILES[2] else 65536) + scheme, separator, wrapped = raw[FILES[2]].reveal().strip().partition(b":") + if not separator or not wrapped: + raise VaultError(Code.UNREADABLE) + if scheme == b"raw": + key = Secret(wrapped) + elif scheme == b"dpapi": + import win32crypt + + with sources[FILES[2]]._owner(): + key = Secret( + win32crypt.CryptUnprotectData( + base64.b64decode(wrapped, validate=True), + b"CraftBot/mini-browser/vault-key/v1", + None, + None, + 1, + )[1] + ) + else: + raise VaultError(Code.UNREADABLE) + plaintext = Secret(Fernet(key.reveal()).decrypt(raw[FILES[0]].reveal())) + mapping = migrate_browser_document( + vault, source_id, Secret(json.loads(plaintext.reveal())) + ) + recovery = vault.import_source( + uuid5(vault.profile.scope.profile_id, source_id + "/recovery-key"), + CredentialKind.SERVICE_SECRET, + "Legacy browser recovery", + source_id[:12], + Secret({"legacy_key": key.reveal().decode("ascii")}), + allowed_origins=(), + ) + # Archive authenticated ciphertext only. The old source key never + # appears in this directory or in the metadata deletion journal. + hashes = {} + for name, contents in raw.items(): + hashes[name] = hashlib.sha256(contents.reveal()).hexdigest() + if name == FILES[2]: + continue + target = archive / name + with target.open("wb") as stream: + stream.write(contents.reveal()) + stream.flush() + os.fsync(stream.fileno()) + private_acl(target) + if hashlib.sha256(target.read_bytes()).hexdigest() != hashes[name]: + raise VaultError(Code.IO) + proof = { + "version": 1, + "profile_id": str(vault.profile.scope.profile_id), + "mapping": mapping, + "files": hashes, + "recovery_id": str(recovery.summary.reference.credential_id), + } + temporary = archive / "migration.new" + with temporary.open("w", encoding="utf-8") as stream: + stream.write(json.dumps(proof)) + stream.flush() + os.fsync(stream.fileno()) + private_acl(temporary) + import win32file + + win32file.MoveFileEx(str(temporary), str(journal), 1 | 8) + if ( + proof["version"] != 1 + or proof["profile_id"] != str(vault.profile.scope.profile_id) + or set(proof["files"]) - set(FILES) + ): + raise VaultError(Code.UNREADABLE) + current = { + str(i.summary.reference.credential_id): i.summary.reference.version + for i in vault.list_items() + } + for reference in proof["mapping"].values(): + ref = parse(reference) + if ( + ref.profile_id != vault.profile.scope.profile_id + or current.get(str(ref.credential_id)) != ref.version + ): + raise VaultError(Code.CONFLICT) + if current.get(proof["recovery_id"]) != 1: + raise VaultError(Code.CONFLICT) + for name, expected in proof["files"].items(): + if ( + name != FILES[2] + and hashlib.sha256((archive / name).read_bytes()).hexdigest() + != expected + ): + raise VaultError(Code.UNREADABLE) + source = sources.get(name) + if source is None: + source = OwnerSettingsSource( + path.with_name(name), owner_sid, session, filename=name + ) + sources[name] = source + with source._owner(): + if not source.path.exists(): + continue # Earlier verified retirement completed this file. + contents = Secret(source.path.read_bytes()) + if not hmac.compare_digest( + hashlib.sha256(contents.reveal()).hexdigest(), expected + ): + raise VaultError(Code.CONFLICT) + source.path.unlink() + return { + "imported": len(proof["mapping"]), + "mapping_retained_by_host": True, + "restart_required": True, + } + finally: + for source in sources.values(): + source.close() diff --git a/app/credentials/catalog.py b/app/credentials/catalog.py new file mode 100644 index 000000000..c16e2d354 --- /dev/null +++ b/app/credentials/catalog.py @@ -0,0 +1,540 @@ +"""Immutable host helper catalog. No provider discovery from agent settings.""" + +from .contracts import CredentialKind, HelperOperation +from .helpers import HttpsCredentialCheck, HttpsJsonOperation + + +def specification(item): + if type(item) is not dict: + raise ValueError("invalid_helper") + common = {"helper", "operation", "kind", "endpoint", "secret_field"} + form_fields = { + "login_page", + "success_url", + "username_field", + "password_field", + "csrf_field", + "cookie_names", + } + extra = { + "type", + "method", + "auth_header", + "auth_prefix", + "protocol", + "query_fields", + "path_fields", + "envelope", + "request_format", + "response_format", + "max_response_bytes", + "header_fields", + } | form_fields + if set(item) - common - extra: + raise ValueError("invalid_helper") + arguments = ( + HelperOperation(item["helper"], item["operation"]), + CredentialKind(item["kind"]), + item["endpoint"], + item.get("secret_field"), + ) + if item.get("type") == "form" and not ( + set(item) & {"method", "auth_header", "auth_prefix", "protocol", "query_fields"} + ): + from .browser_helper import HttpsFormLogin + + if type(item.get("cookie_names")) is not list: + raise ValueError("invalid_browser_cookies") + + return HttpsFormLogin( + *arguments, + **{ + key: tuple(value) if key == "cookie_names" else value + for key, value in item.items() + if key in form_fields + }, + ) + if item.get("type", "check") == "check" and not (set(item) & (extra - {"type"})): + return HttpsCredentialCheck(*arguments) + if item.get("type") == "json": + if set(item) & form_fields: + raise ValueError("invalid_helper") + if "query_fields" in item and type(item["query_fields"]) is not list: + raise ValueError("invalid_helper_query") + return HttpsJsonOperation( + *arguments, + method=item.get("method", "POST"), + auth_header=item.get("auth_header", "Authorization"), + auth_prefix=item.get("auth_prefix", "Bearer "), + protocol=item.get("protocol", "json"), + query_fields=tuple(item.get("query_fields", [])), + path_fields=tuple(item.get("path_fields", [])), + envelope=item.get("envelope", False), + request_format=item.get("request_format", "json"), + response_format=item.get("response_format", "json"), + max_response_bytes=item.get("max_response_bytes", 2_000_000), + header_fields=tuple(item.get("header_fields", [])), + ) + raise ValueError("invalid_helper") + + +def model_helpers(): + # Endpoints match the provider factory. Custom hosts require an explicit + # reviewed installation descriptor; mutable settings cannot install helpers. + result = [] + for provider, base, protocol in ( + ("openai", "https://api.openai.com/v1", "openai"), + ("openrouter", "https://openrouter.ai/api/v1", "openai"), + ("anthropic", "https://api.anthropic.com/v1", "anthropic"), + ("byteplus", "https://ark.ap-southeast.bytepluses.com/api/v3", "openai"), + ("minimax", "https://api.minimax.io/v1", "openai"), + ("deepseek", "https://api.deepseek.com", "openai"), + ("moonshot", "https://api.moonshot.ai/v1", "openai"), + ("grok", "https://api.x.ai/v1", "openai"), + ("glm", "https://api.z.ai/api/paas/v4", "openai"), + ("fugu", "https://api.sakana.ai/v1", "openai"), + ("groq", "https://api.groq.com/openai/v1", "openai"), + ("mistral", "https://api.mistral.ai/v1", "openai"), + ("together", "https://api.together.ai/v1", "openai"), + ("fireworks", "https://api.fireworks.ai/inference/v1", "openai"), + ("cerebras", "https://api.cerebras.ai/v1", "openai"), + ("qwen", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "openai"), + ("huggingface", "https://router.huggingface.co/v1", "openai"), + ("nvidia", "https://integrate.api.nvidia.com/v1", "openai"), + ("perplexity", "https://api.perplexity.ai", "openai"), + ): + routes = ( + (("messages", "POST"), ("models", "GET")) + if protocol == "anthropic" + else ( + ("chat/completions", "POST"), + ("responses", "POST"), + ("models", "GET"), + ("embeddings", "POST"), + ("images/generations", "POST"), + ) + ) + for path, method in routes: + result.append( + { + "type": "json", + "helper": f"model_{provider}", + "operation": path.replace("/", "_"), + "kind": "api_key", + "endpoint": f"{base}/{path}", + "method": method, + "max_response_bytes": 64_000_000 + if path == "images/generations" + else 2_000_000, + "protocol": protocol, + "auth_header": "x-api-key" + if protocol == "anthropic" + else "Authorization", + "auth_prefix": "" if protocol == "anthropic" else "Bearer ", + } + ) + for model in ( + "gemini-2.0-flash", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-3-pro-image", + ): + result.append( + { + "type": "json", + "helper": "model_gemini", + "operation": f"generate_{model}", + "kind": "api_key", + "endpoint": f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent", + "max_response_bytes": 64_000_000, + "auth_header": "x-goog-api-key", + "auth_prefix": "", + } + ) + result.append( + { + "type": "json", + "helper": "model_gemini", + "operation": "embed_text-embedding-004", + "kind": "api_key", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent", + "auth_header": "x-goog-api-key", + "auth_prefix": "", + } + ) + result.append( + { + "type": "json", + "helper": "model_gemini", + "operation": "create_cache", + "kind": "api_key", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/cachedContents", + "auth_header": "x-goog-api-key", + "auth_prefix": "", + } + ) + result.append( + { + "type": "json", + "helper": "model_gemini", + "operation": "models", + "kind": "api_key", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models", + "method": "GET", + "auth_header": "x-goog-api-key", + "auth_prefix": "", + } + ) + for operation, path in (("credits", "credits"), ("auth_key", "auth/key")): + result.append( + { + "type": "json", + "helper": "model_openrouter", + "operation": operation, + "kind": "api_key", + "endpoint": f"https://openrouter.ai/api/v1/{path}", + "method": "GET", + } + ) + result.append( + { + "type": "json", + "helper": "model_google", + "operation": "custom_search", + "kind": "api_key", + "endpoint": "https://customsearch.googleapis.com/customsearch/v1", + "method": "GET", + "query_fields": ["q", "cx", "num"], + "auth_header": "x-goog-api-key", + "auth_prefix": "", + } + ) + result.append( + { + "type": "json", + "helper": "model_bedrock", + "operation": "converse_default", + "kind": "service_secret", + "secret_field": "secret_access_key", + "protocol": "aws_bedrock", + "endpoint": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/converse", + } + ) + return result + media_helpers() + + +def media_helpers(): + result = [] + + def route(provider, operation, endpoint, method="POST", **extra): + from .route_policy import path_names + + result.append( + { + "type": "json", + "helper": "model_" + provider, + "operation": operation, + "kind": "api_key", + "endpoint": endpoint, + "method": method, + "envelope": True, + "path_fields": list(path_names(endpoint)), + **extra, + } + ) + + for action in ("generateContent", "embedContent", "predict", "predictLongRunning"): + route( + "gemini", + "model_" + action, + "https://generativelanguage.googleapis.com/v1beta/models/:model:" + action, + auth_header="x-goog-api-key", + auth_prefix="", + max_response_bytes=64_000_000, + ) + for path in ( + "/operations/:operation_id", + "/models/:model/operations/:operation_id", + ): + route( + "gemini", + "poll_" + str(len(result)), + "https://generativelanguage.googleapis.com/v1beta" + path, + "GET", + auth_header="x-goog-api-key", + auth_prefix="", + ) + route( + "gemini", + "delete_cache", + "https://generativelanguage.googleapis.com/v1beta/cachedContents/:cache_id", + "DELETE", + auth_header="x-goog-api-key", + auth_prefix="", + ) + for path in ( + "/v1beta/files/:file_id:download", + "/download/v1beta/files/:file_id:download", + "/v1beta/files/:file_id", + ): + route( + "gemini", + "download_" + str(len(result)), + "https://generativelanguage.googleapis.com" + path, + "GET", + auth_header="x-goog-api-key", + auth_prefix="", + query_fields=["alt"], + response_format="binary", + max_response_bytes=64_000_000, + ) + route( + "openai", + "images_edits", + "https://api.openai.com/v1/images/edits", + request_format="multipart", + max_response_bytes=64_000_000, + ) + route( + "openai", + "audio_transcriptions", + "https://api.openai.com/v1/audio/transcriptions", + request_format="multipart", + response_format="text", + ) + route( + "openai", + "audio_speech", + "https://api.openai.com/v1/audio/speech", + response_format="binary", + max_response_bytes=64_000_000, + ) + route( + "byteplus", + "video_submit", + "https://ark.ap-southeast.bytepluses.com/api/v3/contents/generations/tasks", + ) + route( + "byteplus", + "video_poll", + "https://ark.ap-southeast.bytepluses.com/api/v3/contents/generations/tasks/:task_id", + "GET", + ) + return result + + +def integration_helpers(): + result = [ + { + "type": "json", + "helper": f"integration_{provider}", + "operation": "verify", + "kind": "api_key", + "endpoint": endpoint, + "method": "GET", + "auth_header": header, + "auth_prefix": prefix, + } + for provider, endpoint, header, prefix in ( + ("github", "https://api.github.com/user", "Authorization", "Bearer "), + ("figma", "https://api.figma.com/v1/me", "X-Figma-Token", ""), + ("slack", "https://slack.com/api/auth.test", "Authorization", "Bearer "), + ) + ] + result.extend(authentication_helpers()) + result.append( + { + "type": "json", + "helper": "integration_figma", + "operation": "get_figma_current_user", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/me", + "method": "GET", + "auth_header": "X-Figma-Token", + "auth_prefix": "", + } + ) + for name, path, query in ( + ( + "list_slack_channels", + "conversations.list", + ["types", "limit", "exclude_archived", "cursor"], + ), + ("get_slack_channel_info", "conversations.info", ["channel"]), + ( + "get_slack_channel_history", + "conversations.history", + ["channel", "limit", "oldest", "latest", "cursor"], + ), + ("list_slack_users", "users.list", ["limit", "cursor"]), + ("get_slack_user_info", "users.info", ["user"]), + ( + "get_slack_thread_replies", + "conversations.replies", + ["channel", "ts", "limit", "cursor"], + ), + ): + result.append( + { + "type": "json", + "helper": "integration_slack", + "operation": name, + "kind": "api_key", + "endpoint": f"https://slack.com/api/{path}", + "method": "GET", + "query_fields": query, + } + ) + import json + from pathlib import Path + + for provider, endpoint, prefix in ( + ("discord", "https://discord.com/api/v10/users/@me", "Bot "), + ("stripe", "https://api.stripe.com/v1/account", "Bearer "), + ("notion", "https://api.notion.com/v1/users/me", "Bearer "), + ("hubspot", "https://api.hubapi.com/account-info/v3/details", "Bearer "), + ("posthog", "https://us.posthog.com/api/users/@me/", "Bearer "), + ("supabase", "https://api.supabase.com/v1/profile", "Bearer "), + ("line", "https://api.line.me/v2/bot/info", "Bearer "), + ("telegram_bot", "https://api.telegram.org/bot_TOKEN_/getMe", "Bearer "), + ): + result.append( + { + "type": "json", + "helper": "integration_" + provider, + "operation": "verify", + "kind": "api_key", + "endpoint": endpoint, + "method": "GET", + "auth_prefix": prefix, + **({"protocol": "telegram"} if provider == "telegram_bot" else {}), + } + ) + result.append( + { + "type": "json", + "helper": "integration_jira", + "operation": "verify", + "kind": "service_secret", + "secret_field": "api_token", + "protocol": "basic", + "endpoint": "https://jira.example.invalid/rest/api/3/myself", + "method": "GET", + } + ) + result.append( + { + "type": "json", + "helper": "integration_whatsapp_business", + "operation": "verify", + "kind": "api_key", + "endpoint": "https://graph.facebook.com/v21.0/:phone_number_id", + "method": "GET", + "envelope": True, + "path_fields": ["phone_number_id"], + } + ) + result.append( + { + "type": "json", + "helper": "integration_discord", + "operation": "gateway_events", + "kind": "api_key", + "endpoint": "https://gateway.discord.gg/", + "method": "GET", + "protocol": "discord_gateway", + } + ) + return ( + result + + json.loads( + Path(__file__).with_name("provider_routes.json").read_text(encoding="utf-8") + ) + + oauth_helpers() + ) + + +def oauth_helpers(): + # Match confidential-client exchanges already performed by shipped clients. + # Token persistence remains exclusively with those clients. + result = [] + for provider, endpoint, basic, json_body in ( + ("google", "https://oauth2.googleapis.com/token", False, False), + ("slack", "https://slack.com/api/oauth.v2.access", False, False), + ("linkedin", "https://www.linkedin.com/oauth/v2/accessToken", False, False), + ("hubspot", "https://api.hubapi.com/oauth/v1/token", False, False), + ("figma", "https://api.figma.com/v1/oauth/token", True, False), + ("figma", "https://api.figma.com/v1/oauth/refresh", True, False), + ("notion", "https://api.notion.com/v1/oauth/token", True, True), + ("supabase", "https://api.supabase.com/v1/oauth/token", True, False), + ("line", "https://api.line.me/oauth2/v3/token", False, False), + ): + result.append( + { + "type": "json", + "helper": "oauth_" + provider, + "operation": "exchange_" + str(len(result)), + "kind": "api_key", + "endpoint": endpoint, + "method": "POST", + "envelope": True, + "request_format": "json" if json_body else "form", + "protocol": "oauth_basic" + if basic + else "oauth_json" + if json_body + else "oauth_form", + } + ) + return result + + +def authentication_helpers(): + result = [ + { + "type": "json", + "helper": "integration_twitter", + "operation": "verify", + "kind": "service_secret", + "secret_field": "api_secret", + "endpoint": "https://api.twitter.com/2/users/me", + "method": "GET", + "protocol": "twitter_oauth1", + "envelope": True, + } + ] + for operation, protocol, endpoint in ( + ( + "tenant_token", + "lark_token", + "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal", + ), + ( + "websocket_endpoint", + "lark_websocket", + "https://open.larksuite.com/callback/ws/endpoint", + ), + ): + result.append( + { + "type": "json", + "helper": "auth_lark", + "operation": operation, + "kind": "api_key", + "endpoint": endpoint, + "protocol": protocol, + } + ) + for operation in ("send_code", "complete", "qr_login", "complete_password"): + result.append( + { + "type": "json", + "helper": "auth_telegram_user", + "operation": operation, + "kind": "service_secret", + "secret_field": "api_hash", + "endpoint": "https://api.telegram.org/mtproto/" + operation, + "protocol": "telegram_mtproto", + } + ) + return result diff --git a/app/credentials/client.py b/app/credentials/client.py new file mode 100644 index 000000000..bb6eae4ee --- /dev/null +++ b/app/credentials/client.py @@ -0,0 +1,320 @@ +"""Secret-free Windows client, also copied into isolated code snapshots. + +No vault imports, key resolver, HTTP transport or configurable server address. +The pipe must be owned by SYSTEM; unprivileged squatters cannot forge that +kernel owner. No SCM privileges or session/bridge tokens are needed in a sandbox. +""" + +from __future__ import annotations + +import ctypes as C +from ctypes import wintypes as W +import json +import os +import struct +import time + +PIPE = r"\\.\pipe\LOCAL\CraftBot.Credentials.v1" +SERVICE = "CraftBotCredentialHost" + + +class CredentialHostUnavailable(RuntimeError): + pass + + +def _dlls(): + if os.name != "nt": + raise CredentialHostUnavailable("credential_host_unavailable") + kernel = C.WinDLL("kernel32", use_last_error=True) + advapi = C.WinDLL("advapi32", use_last_error=True) + kernel.CreateFileW.argtypes = [ + W.LPCWSTR, + W.DWORD, + W.DWORD, + C.c_void_p, + W.DWORD, + W.DWORD, + W.HANDLE, + ] + kernel.CreateFileW.restype = W.HANDLE + kernel.GetNamedPipeServerProcessId.argtypes = [W.HANDLE, C.POINTER(W.DWORD)] + kernel.ReadFile.argtypes = [ + W.HANDLE, + C.c_void_p, + W.DWORD, + C.POINTER(W.DWORD), + C.c_void_p, + ] + kernel.WriteFile.argtypes = kernel.ReadFile.argtypes + kernel.CloseHandle.argtypes = [W.HANDLE] + advapi.GetSecurityInfo.argtypes = [ + W.HANDLE, + C.c_int, + W.DWORD, + C.POINTER(C.c_void_p), + C.c_void_p, + C.c_void_p, + C.c_void_p, + C.POINTER(C.c_void_p), + ] + advapi.ConvertSidToStringSidW.argtypes = [C.c_void_p, C.POINTER(W.LPWSTR)] + kernel.LocalFree.argtypes = [C.c_void_p] + return kernel, advapi + + +def _require_system_pipe(kernel, advapi, handle): + owner, descriptor = C.c_void_p(), C.c_void_p() + text = W.LPWSTR() + try: + if ( + advapi.GetSecurityInfo( + handle, 6, 1, C.byref(owner), None, None, None, C.byref(descriptor) + ) + != 0 + or not advapi.ConvertSidToStringSidW(owner, C.byref(text)) + or text.value != "S-1-5-18" + ): + raise CredentialHostUnavailable("credential_host_identity_mismatch") + finally: + if text: + kernel.LocalFree(text) + if descriptor: + kernel.LocalFree(descriptor) + + +def _request(operation: str, **parameters): + """Only metadata/references/capabilities. Context comes from the kernel peer.""" + handle = None + try: + data = json.dumps({"op": operation, **parameters}, ensure_ascii=True).encode( + "utf-8" + ) + if len(data) > 65536 or "op" in parameters: + raise CredentialHostUnavailable("credential_request_invalid") + kernel, advapi = _dlls() + # Exact data rights, not GENERIC_WRITE/FILE_CREATE_PIPE_INSTANCE. + # Identification SQOS prevents impersonation by a squatting server. + deadline = time.monotonic() + 305 + while True: + handle = kernel.CreateFileW( + PIPE, 3 | 0x120000, 0, None, 3, 0x100000 | 0x10000, None + ) + if handle != W.HANDLE(-1).value: + break + handle = None + # A missing service fails immediately. A busy protected host may be + # running an owner prompt or HTTPS operation (bounded by its 120s + # watchdog); polling controllers must not fail after five seconds. + if C.get_last_error() != 231 or time.monotonic() >= deadline: + raise CredentialHostUnavailable("credential_host_unavailable") + time.sleep(0.05) + server = W.DWORD() + if ( + not kernel.GetNamedPipeServerProcessId(handle, C.byref(server)) + or not server.value + ): + raise CredentialHostUnavailable("credential_host_identity_mismatch") + _require_system_pipe(kernel, advapi, handle) + transferred = W.DWORD() + frame = struct.pack("= deadline + ): + raise + time.sleep(0.1) + + +def use( + helper: str, + operation: str, + credential_id: str | None = None, + *, + credential_version=None, + payload=None, +): + staged = payload is None or type(payload) is dict and set(payload) == {"body"} + try: + if staged: + serialized = json.dumps( + payload["body"] if payload is not None else None, + ensure_ascii=True, + allow_nan=False, + separators=(",", ":"), + ) + if len(serialized) > 32_000_000: + raise CredentialHostUnavailable("credential_use_limit") + for offset in range(0, len(serialized), 8000): + chunk = serialized[offset : offset + 8000] + request( + "upload_payload_part", + offset=offset, + data=chunk, + done=offset + len(chunk) == len(serialized), + ) + return _use( + helper, operation, credential_id, credential_version, payload, staged + ) + except BaseException: + if staged: + try: + request("discard_payload") + except Exception: + pass + raise + + +def _use(helper, operation, credential_id, credential_version, payload, staged): + capability = request( + "request_use", + helper=helper, + operation=operation, + credential_id=credential_id, + **( + {"credential_version": credential_version} + if credential_version is not None + else {} + ), + ) + if staged: + request("start_use_staged", capability=capability["capability"]) + deadline = time.monotonic() + 900 + while time.monotonic() < deadline: + status = request("operation_status") + if status["done"]: + if status.get("error"): + raise CredentialHostUnavailable(status["error"]) + return {"outcome": status.get("outcome", "completed")} + time.sleep(0.05) + raise CredentialHostUnavailable("credential_operation_timeout") + raise CredentialHostUnavailable("credential_request_invalid") + + +def operations(): + """Page the public names without overflowing the bounded pipe frame.""" + result, offset = [], 0 + while offset <= 1024: + page = request("operations", offset=offset) + result.extend(page["operations"]) + if not page["more"]: + return result + offset += 32 + raise CredentialHostUnavailable("credential_response_invalid") + + +def find(): + result, offset = [], 0 + while offset <= 4096: + page = request("find", offset=offset) + result.extend(page["offers"]) + if not page["more"]: + return result + offset += 8 + raise CredentialHostUnavailable("credential_response_invalid") + + +def complete(result): + return request("complete", result=result) + + +def action_profile(): + """Owner-selected service preference; never read from action/bundle input.""" + from uuid import UUID + + value = request("action_profile")["profile_id"] + return str(UUID(value)) if value is not None else None + + +def request_entry(helper: str, operation: str, account: str): + """Ask the owner for protected entry; returns a reference, never values.""" + return request("request_entry", helper=helper, operation=operation, account=account) + + +def use_json(helper, operation, credential_id, *, credential_version, body=None): + """For isolated workers only. Releases bounded, host-sanitized output.""" + use( + helper, + operation, + credential_id, + credential_version=credential_version, + payload={"body": body}, + ) + result = request("read_response", offset=0) + chunks, offset = [], 0 + length = result["length"] + if type(length) is not int or not 0 <= length <= 96_000_000: + raise CredentialHostUnavailable("credential_response_invalid") + while offset < length: + chunk = result["chunk"] + if not isinstance(chunk, str) or not chunk or len(chunk) > 8000: + raise CredentialHostUnavailable("credential_response_invalid") + chunks.append(chunk) + offset += len(chunk) + if offset < length: + result = request("read_response", offset=offset) + return { + "status": result["status"], + "content_type": result["content_type"], + "body": "".join(chunks), + } diff --git a/app/credentials/consumer_runtime.py b/app/credentials/consumer_runtime.py new file mode 100644 index 000000000..81f3f9525 --- /dev/null +++ b/app/credentials/consumer_runtime.py @@ -0,0 +1,65 @@ +"""Launch credential-enabled Python code through the protected host only.""" + +import asyncio +import json +import time + +from .client import CredentialHostUnavailable, request + + +async def execute_action( + code: str, input_data: dict, *, profile_id: str, subject: str, timeout: int +): + # This wrapper is untrusted consumer code. All authority still comes from + # the native launch, kernel peer/job checks and explicit native owner consent. + data = json.dumps(input_data) + source = ( + "code = " + + repr(code) + + "\ndata = " + + repr(data) + + "\n" + + """import asyncio, inspect, json +import credential_client +namespace = {'__name__': '__credential_action__'} +try: + exec(code, namespace, namespace) + function = next(value for value in namespace.values() if inspect.isfunction(value) and value.__globals__ is namespace) + result = function(json.loads(data)) + if inspect.isawaitable(result): + result = asyncio.run(result) + credential_client.complete(result) +except BaseException: + credential_client.complete({'status': 'error', 'message': 'Isolated action failed'}) +""" + ) + launch = await asyncio.to_thread( + request, + "launch", + profile_id=profile_id, + kind="agent_session", + subject=subject, + deployment=None, + files={"action.py": source}, + entry="action.py", + ) + deadline = time.monotonic() + min(timeout, 900) + try: + while time.monotonic() < deadline: + result = await asyncio.to_thread( + request, "program_result", run_id=launch["run_id"] + ) + if "result" in result: + return result["result"] + if "exited" in result: + return { + "status": "error", + "message": "Isolated credential action exited without a result", + } + await asyncio.sleep(0.2) + return {"status": "error", "message": "Isolated credential action timed out"} + finally: + try: + await asyncio.to_thread(request, "stop", run_id=launch["run_id"]) + except CredentialHostUnavailable: + pass # Host restart/lock already killed the job; no legacy fallback. diff --git a/app/credentials/contracts.py b/app/credentials/contracts.py new file mode 100644 index 000000000..b4b17aa0e --- /dev/null +++ b/app/credentials/contracts.py @@ -0,0 +1,85 @@ +"""Secret-free consumer/helper-boundary contracts for CM-01. + +No implementation here authenticates callers, unlocks a vault, executes a +helper, or reads a credential. Those require CM-02/03 and OS-enforced isolation. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Protocol +from uuid import UUID + +from .identity import ExecutionContext, VaultScope, _label, _uuid + + +class CredentialKind(str, Enum): + LOGIN = "login" + API_KEY = "api_key" + SERVICE_SECRET = "service_secret" + + +@dataclass(frozen=True) +class CredentialReference: + scope: VaultScope + credential_id: UUID + version: int + + def __post_init__(self) -> None: + if not isinstance(self.scope, VaultScope): + raise ValueError("A vault scope is required") + _uuid(self.credential_id) + if type(self.version) is not int or self.version < 1: + raise ValueError("A positive credential version is required") + + +@dataclass(frozen=True) +class CredentialSummary: + reference: CredentialReference + kind: CredentialKind + service: str + account_label: str + + def __post_init__(self) -> None: + if not isinstance(self.reference, CredentialReference) or not isinstance( + self.kind, CredentialKind + ): + raise ValueError("A credential reference and kind are required") + _label(self.service) + _label(self.account_label) + + +@dataclass(frozen=True) +class HelperOperation: + """Registered operation identifier, never a module path or callback.""" + + helper_id: str + operation: str + + def __post_init__(self) -> None: + _label(self.helper_id) + _label(self.operation) + + +class IsolationBoundary(Protocol): + """Trusted host/platform adapter; never implemented by agent/app code. + + Must reject use unless code integrity, filesystem/process separation and + authenticated channel binding are enforced. Missing support fails closed. + A separate PID, venv, OS-user encryption or a context variable is not proof. + """ + + def require_isolated(self, context: ExecutionContext) -> None: ... + + +class IsolationUnavailable(RuntimeError): + def __init__(self) -> None: + super().__init__("credential_isolation_unavailable") + + +class UnavailableIsolationBoundary: + """Default outside the installed Windows SYSTEM host; no permissive fallback.""" + + def require_isolated(self, context: ExecutionContext) -> None: + raise IsolationUnavailable() diff --git a/app/credentials/directory.py b/app/credentials/directory.py new file mode 100644 index 000000000..32d9d9a93 --- /dev/null +++ b/app/credentials/directory.py @@ -0,0 +1,124 @@ +"""In-memory, host-owned identity directory; persistence belongs to CM-02. + +Not a consumer API. Its caller must already be a trusted host component. +This directory neither verifies OS identities nor grants credential access. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from threading import RLock +from uuid import UUID + +from .identity import ( + CallerBinding, + CallerIdentity, + ExecutionContext, + VaultOwner, + VaultProfile, + VaultScope, +) + + +class IdentityError(ValueError): + """Fixed, value-free identity errors, safe for a host adapter to map.""" + + +class IdentityDirectory: + def __init__(self) -> None: + self._owners: dict[UUID, VaultOwner] = {} + self._profiles: dict[UUID, VaultProfile] = {} + self._bindings: dict[UUID, CallerBinding] = {} + self._revoked: set[UUID] = set() + self._lock = RLock() + + def register_owner(self, owner: VaultOwner) -> None: + with self._lock: + existing = self._owners.get(owner.owner_id) + if existing is not None and existing != owner: + raise IdentityError("owner_conflict") + if any( + other.os_identity == owner.os_identity and other != owner + for other in self._owners.values() + ): + raise IdentityError("owner_conflict") + self._owners[owner.owner_id] = owner + + def register_profile(self, profile: VaultProfile) -> None: + with self._lock: + if profile.scope.owner_id not in self._owners: + raise IdentityError("owner_unknown") + existing = self._profiles.get(profile.scope.profile_id) + if existing is not None and existing != profile: + raise IdentityError("profile_conflict") + self._profiles[profile.scope.profile_id] = profile + + def bind(self, binding: CallerBinding) -> None: + """Register an explicitly authorized channel/profile association. + + Does not create a grant. A different profile/revision/runtime requires + a new binding; labels, parent sessions and app manifests confer none. + """ + with self._lock: + if binding.binding_id in self._revoked: + raise IdentityError("binding_revoked") + profile = self._profiles.get(binding.scope.profile_id) + if profile is None or profile.scope != binding.scope: + raise IdentityError("profile_unknown") + existing = self._bindings.get(binding.binding_id) + if existing is not None and existing != binding: + raise IdentityError("binding_conflict") + self._bindings[binding.binding_id] = binding + + def require_vault(self, owner: VaultOwner, scope: VaultScope) -> None: + """Reject host bootstrap wiring to a different OS owner/catalog scope.""" + with self._lock: + if self._owners.get(owner.owner_id) != owner: + raise IdentityError("owner_unknown") + profile = self._profiles.get(scope.profile_id) + if ( + owner.owner_id != scope.owner_id + or profile is None + or profile.scope != scope + ): + raise IdentityError("scope_denied") + + def resolve( + self, binding_id: UUID, authenticated_caller: CallerIdentity, execution_id: UUID + ) -> ExecutionContext: + """Resolve using transport-authenticated identity, not caller JSON. + + There is deliberately no fallback to main, a parent's binding, or a + global active profile. A new app revision/runtime cannot reuse a binding. + """ + with self._lock: + binding = self._bindings.get(binding_id) + if binding is None or binding.caller != authenticated_caller: + raise IdentityError("caller_unbound") + return ExecutionContext(binding, execution_id) + + def revoke(self, binding_id: UUID) -> None: + """Invalidate a channel without altering profiles or token/session data.""" + with self._lock: + self._bindings.pop(binding_id, None) + self._revoked.add(binding_id) + + def require_current(self, context: ExecutionContext) -> None: + """Recheck at use time: a previously resolved context may be revoked.""" + with self._lock: + if self._bindings.get(context.binding.binding_id) != context.binding: + raise IdentityError("caller_unbound") + + def require_scope(self, context: ExecutionContext, scope: VaultScope) -> None: + """Reject cross-profile/owner references even for the same human owner.""" + with self._lock: + self.require_current(context) + if context.binding.scope != scope: + raise IdentityError("scope_denied") + + @contextmanager + def hold_current(self, context: ExecutionContext): + """Serialize dispatch with revocation; already sent requests cannot be undone.""" + with self._lock: + self.require_current(context) + yield diff --git a/app/credentials/errors.py b/app/credentials/errors.py new file mode 100644 index 000000000..01e9384be --- /dev/null +++ b/app/credentials/errors.py @@ -0,0 +1,45 @@ +"""Stable errors: never include paths, provider responses or secret values.""" + +from enum import Enum +from functools import wraps + + +class VaultErrorCode(str, Enum): + LOCKED = "locked" + VERIFICATION_REQUIRED = "owner_verification_required" + BACKEND_UNAVAILABLE = "backend_unavailable" + UNREADABLE = "vault_unreadable" + IO = "vault_io_error" + INVALID = "credential_invalid" + CONFLICT = "credential_conflict" + NOT_FOUND = "credential_not_found" + ALREADY_EXISTS = "vault_already_exists" + SCOPE_DENIED = "scope_denied" + + +class VaultError(RuntimeError): + def __init__(self, code: VaultErrorCode) -> None: + self.code = code + super().__init__(code.value) + + +def sanitized_errors(operation): + """Drop inner frames as well as messages at the secret-bearing boundary. + + `raise ... from None` inside a context manager is insufficient: loguru's + diagnose mode can print the original exception from contextlib.__exit__'s + `value` local. Raise a fresh error OUTSIDE the except block, so no such + frames or exception contexts reach callers' diagnostic logs. + """ + + @wraps(operation) + def sanitized(*args, **kwargs): + try: + return operation(*args, **kwargs) + except VaultError as error: + code = error.code + except Exception: + code = VaultErrorCode.IO + raise VaultError(code) from None + + return sanitized diff --git a/app/credentials/execution.py b/app/credentials/execution.py new file mode 100644 index 000000000..51dece6f4 --- /dev/null +++ b/app/credentials/execution.py @@ -0,0 +1,93 @@ +"""Host-only cancellable execution and sanitized stream output.""" + +from threading import Event, Lock, Thread +from .helpers import JsonResult +from .use_errors import CredentialUseError, UseCode +from .wire import MAX_OUTPUT + + +class OperationJob: + def __init__(self, operation, *, budget=None, grant_id=None): + self.cancelled, self.done = Event(), Event() + self.dispatched = Event() + self.lock = Lock() + self.status, self.content_type, self.body, self.error = None, None, "", None + self.result = None + self.budget, self.grant_id, self.charged = budget, grant_id, 0 + self.thread = Thread(target=self._run, args=(operation,), daemon=True) + + def start(self, status, content_type): + with self.lock: + self.status, self.content_type = status, content_type + + def emit(self, text): + if self.cancelled.is_set(): + raise CredentialUseError(UseCode.INTERRUPTED) + with self.lock: + if len(self.body) + len(text) > MAX_OUTPUT: + raise CredentialUseError(UseCode.LIMIT) + self._charge(len(text)) + self.body += text + + def _charge(self, size): + if self.cancelled.is_set(): + raise CredentialUseError(UseCode.INTERRUPTED) + if self.budget is not None: + self.budget.change(size) + self.charged += size + + def close(self): + self.cancelled.set() + with self.lock: + if self.budget is not None: + self.budget.change(-self.charged) + self.charged, self.body, self.result = 0, "", None + + def _run(self, operation): + try: + self.result = operation(self) + if type(self.result) is JsonResult: + with self.lock: + if self.status is None: + self._charge(len(self.result.body)) + self.status, self.content_type, self.body = ( + self.result.status, + self.result.content_type, + self.result.body, + ) + except CredentialUseError as error: + self.error = ( + UseCode.INTERRUPTED if self.cancelled.is_set() else error.code + ).value + except BaseException: + self.error = ( + UseCode.INTERRUPTED if self.cancelled.is_set() else UseCode.HELPER + ).value + finally: + self.done.set() + + def snapshot(self, offset=0): + with self.lock: + if type(offset) is not int or not 0 <= offset <= len(self.body): + raise CredentialUseError(UseCode.DENIED) + return { + "status": self.status, + "content_type": self.content_type, + "length": len(self.body), + "chunk": self.body[offset : offset + 4000], + "done": self.done.is_set(), + "error": self.error, + } + + +class OutputBudget: + """Aggregate retained output across all isolated workers, in characters.""" + + def __init__(self, maximum=192_000_000): + self.maximum, self.used, self.lock = maximum, 0, Lock() + + def change(self, delta): + with self.lock: + if self.used + delta > self.maximum: + raise CredentialUseError(UseCode.LIMIT) + self.used = max(0, self.used + delta) diff --git a/app/credentials/gateway_helper.py b/app/credentials/gateway_helper.py new file mode 100644 index 000000000..454d35626 --- /dev/null +++ b/app/credentials/gateway_helper.py @@ -0,0 +1,136 @@ +"""Reviewed Discord Gateway identify + heartbeat + event receiver. + +No caller-selected WS URL, identify fields, messages, session/resume or signer. +The bot source is used only on the pinned, verified Discord TLS connection. +""" + +import json +import logging +import socket +import ssl +import time +from threading import Event, Thread, Timer + +from .helpers import JsonResult, _address, _abort_socket +from .secrets import Secret +from .streaming import StreamFilter +from .use_errors import CredentialUseError, UseCode + + +def execute(spec, fields, guard, payload, execution, after_send): + if payload != {"body": None} or execution is None: + raise CredentialUseError(UseCode.DENIED) + from websockets.sync.client import connect + + family, socktype, protocol, address = _address("gateway.discord.gg", 443) + guard() + logger = logging.Logger("credential_gateway_private") + logger.disabled, logger.propagate = True, False + deadline = time.monotonic() + 600 + finished, timer = Event(), None + try: + with socket.socket(family, socktype, protocol) as transport: + transport.settimeout(10) + transport.connect(address) + with connect( + "wss://gateway.discord.gg/?v=10&encoding=json", + sock=transport, + ssl=ssl.create_default_context(), + proxy=None, + compression=None, + open_timeout=10, + close_timeout=1, + max_size=1_000_000, + max_queue=4, + ping_interval=None, + logger=logger, + ) as websocket: + timer = Timer(600, _abort_socket, args=(websocket.socket,)) + timer.daemon = True + timer.start() + + def watch(): + while not finished.wait(0.05): + if execution.cancelled.is_set(): + _abort_socket(websocket.socket) + return + + Thread(target=watch, daemon=True).start() + hello = json.loads(websocket.recv(timeout=10)) + interval = hello.get("d", {}).get("heartbeat_interval") + if ( + hello.get("op") != 10 + or type(interval) not in (int, float) + or not 1000 <= interval <= 120000 + ): + raise CredentialUseError(UseCode.HELPER) + guard() + token = Secret(fields.reveal()["api_key"]) + if not token.reveal().isascii() or any( + ord(c) < 33 or ord(c) > 126 for c in token.reveal() + ): + raise CredentialUseError(UseCode.DENIED) + identify = Secret( + { + "op": 2, + "d": { + "token": token.reveal(), + "intents": 37376, + "properties": { + "os": "windows", + "browser": "craftosbot", + "device": "craftosbot", + }, + }, + } + ) + websocket.send(json.dumps(identify.reveal())) + if after_send is not None: + after_send() + execution.start(200, "text/event-stream") + filtering = StreamFilter( + Secret({"token": token.reveal()}), execution.emit + ) + heartbeat = time.monotonic() + last_ack, sequence = heartbeat, None + while time.monotonic() < deadline: + if execution.cancelled.is_set(): + raise CredentialUseError(UseCode.INTERRUPTED) + now = time.monotonic() + if now >= heartbeat: + if now - last_ack > 2 * interval / 1000: + raise CredentialUseError(UseCode.HELPER) + websocket.send(json.dumps({"op": 1, "d": sequence})) + heartbeat = now + interval / 1000 + try: + message = websocket.recv( + timeout=min(1, max(0.01, heartbeat - now)) + ) + except TimeoutError: + continue + if not isinstance(message, str): + raise CredentialUseError(UseCode.HELPER) + data = json.loads(message) + if type(data) is not dict or type(data.get("op")) is not int: + raise CredentialUseError(UseCode.HELPER) + if data.get("s") is not None: + if type(data["s"]) is not int: + raise CredentialUseError(UseCode.HELPER) + sequence = data["s"] + if data["op"] == 11: + last_ack = time.monotonic() + elif data["op"] == 1: + heartbeat = 0 + elif data["op"] in (7, 9): + break # New bounded worker/consent; never auto-resume. + elif data["op"] == 0 and data.get("t") in ( + "READY", + "MESSAGE_CREATE", + ): + filtering.feed("data: " + json.dumps(data)) + filtering.flush() + return JsonResult(200, "", "text/event-stream") + finally: + finished.set() + if timer is not None: + timer.cancel() diff --git a/app/credentials/helpers.py b/app/credentials/helpers.py new file mode 100644 index 000000000..b7b62e13a --- /dev/null +++ b/app/credentials/helpers.py @@ -0,0 +1,900 @@ +"""Fixed host helper implementation; no consumer URLs, code, headers or callbacks. + +The host installs immutable check specifications, never from manifests/actions. +Browser filling, provider requests and token-producing login flows need separate +reviewed helpers. This check discards all response data except a coarse outcome. +""" + +from __future__ import annotations + +import base64 +from dataclasses import dataclass +from enum import Enum +import http.client +import ipaddress +import json +import re +import socket +import ssl +from threading import Timer +import time +from types import MappingProxyType +from urllib.parse import urlsplit + +from .contracts import CredentialKind, HelperOperation +from .records import CredentialDetails, origins +from .secrets import Secret +from .use_errors import CredentialUseError, UseCode, use_boundary + + +class HelperResult(str, Enum): + ACCEPTED = "accepted" # HTTP 2xx, not proof of a provider's account identity. + REJECTED = "rejected" # HTTP 401/403. + UNAVAILABLE = "unavailable" # Includes redirects; never followed. + + +@dataclass(frozen=True) +class HttpsCredentialCheck: + """Host-reviewed, read-only GET with Basic or Bearer authorization. + + A service-secret field is explicitly selected by trusted configuration. + No field-name inference, arbitrary requests, or generated token storage. + """ + + operation: HelperOperation + kind: CredentialKind + endpoint: str + secret_field: str | None = None + + def __post_init__(self): + if not isinstance(self.operation, HelperOperation) or not isinstance( + self.kind, CredentialKind + ): + raise ValueError("invalid_helper") + if ( + not isinstance(self.endpoint, str) + or not self.endpoint.isascii() + or len(self.endpoint) > 2048 + or any(ord(char) <= 32 or ord(char) == 127 for char in self.endpoint) + or "\\" in self.endpoint + ): + raise ValueError("invalid_helper_endpoint") + parsed = urlsplit(self.endpoint) + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or "?" in self.endpoint + or "#" in self.endpoint + or parsed.netloc.endswith(":") + or not re.fullmatch(r"[A-Za-z0-9._~!$&'()*+,;=:@%/\-]*", parsed.path) + ): + raise ValueError("invalid_helper_endpoint") + # Reuse the strict origin parser; no www folding or wildcard policy. + origins((f"https://{parsed.netloc}",)) + if self.kind is CredentialKind.SERVICE_SECRET: + if not isinstance(self.secret_field, str) or not re.fullmatch( + r"[a-z][a-z0-9_]{0,63}", self.secret_field + ): + raise ValueError("invalid_helper_field") + elif self.secret_field is not None: + raise ValueError("invalid_helper_field") + + @property + def origin(self) -> str: + return origins((f"https://{urlsplit(self.endpoint).netloc}",))[0] + + +class HelperRegistry: + """Sealed at host bootstrap. No runtime register/import/execute-code API.""" + + def __init__(self, specifications: tuple[HttpsCredentialCheck, ...] = ()): + from .browser_helper import HttpsFormLogin + + if type(specifications) is not tuple or len(specifications) > 1024: + raise ValueError("invalid_helper_registry") + entries = {} + for specification in specifications: + if ( + type(specification) + not in (HttpsCredentialCheck, HttpsJsonOperation, HttpsFormLogin) + or specification.operation in entries + ): + raise ValueError("invalid_helper_registry") + entries[specification.operation] = specification + self._entries = MappingProxyType(entries) + + def require(self, operation: HelperOperation) -> HttpsCredentialCheck: + specification = self._entries.get(operation) + if specification is None: + raise CredentialUseError(UseCode.DENIED) + return specification + + +@dataclass(frozen=True) +class HttpsJsonOperation(HttpsCredentialCheck): + """One installed JSON API operation at one immutable HTTPS endpoint. + + Requesters supply JSON data, never a URL, path, method, headers or transport. + Resource parameters are constrained by sealed templates. Streaming is + filtered in the protected host before release. No response + headers, cookies, redirects or provider error bodies leave this boundary. + """ + + method: str = "POST" + auth_header: str = "Authorization" + auth_prefix: str = "Bearer " + protocol: str = "json" + query_fields: tuple[str, ...] = () + path_fields: tuple[str, ...] = () + envelope: bool = False + request_format: str = "json" + response_format: str = "json" + max_response_bytes: int = 2_000_000 + header_fields: tuple[str, ...] = () + + def __post_init__(self): + super().__post_init__() + if self.kind is CredentialKind.LOGIN or self.method not in ( + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + ): + raise ValueError("invalid_json_helper") + if ( + type(self.query_fields) is not tuple + or len(self.query_fields) > 128 + or len(json.dumps(self.query_fields)) > 4096 + or any( + not isinstance(k, str) + or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_.]{0,63}", k) + for k in self.query_fields + ) + ): + raise ValueError("invalid_helper_query") + from .route_policy import path_names + + if ( + type(self.path_fields) is not tuple + or len(set(self.path_fields)) != len(self.path_fields) + or path_names(self.endpoint) != self.path_fields + or type(self.envelope) is not bool + or self.request_format not in ("json", "form", "multipart", "image") + or self.response_format not in ("json", "binary", "text") + ): + raise ValueError("invalid_helper_route") + if ( + self.path_fields + or self.request_format != "json" + or self.response_format in ("binary", "text") + ) and not self.envelope: + raise ValueError("invalid_helper_route") + if ( + type(self.max_response_bytes) is not int + or not 1 <= self.max_response_bytes <= 64_000_000 + ): + raise ValueError("invalid_helper_limit") + if type(self.header_fields) is not tuple or set(self.header_fields) - { + "Idempotency-Key", + "Stripe-Account", + }: + raise ValueError("invalid_helper_headers") + if (self.auth_header, self.auth_prefix) not in { + ("Authorization", "Bearer "), + ("Authorization", "Bot "), + ("x-api-key", ""), + ("x-goog-api-key", ""), + ("X-Figma-Token", ""), + } or self.protocol not in ( + "json", + "openai", + "anthropic", + "aws_bedrock", + "telegram", + "basic", + "oauth_form", + "oauth_json", + "oauth_basic", + "discord_gateway", + "twitter_oauth1", + "lark_token", + "lark_websocket", + "telegram_mtproto", + ): + raise ValueError("invalid_json_helper") + if self.protocol == "twitter_oauth1" and ( + not ( + self.origin == "https://api.twitter.com" + and self.request_format == "json" + or self.endpoint == "https://upload.twitter.com/1.1/media/upload.json" + and self.method == "POST" + and self.request_format == "multipart" + ) + or self.kind is not CredentialKind.SERVICE_SECRET + or self.secret_field != "api_secret" + or not self.envelope + ): + raise ValueError("invalid_twitter_helper") + if self.protocol in ("lark_token", "lark_websocket") and ( + self.endpoint + != ( + "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal" + if self.protocol == "lark_token" + else "https://open.larksuite.com/callback/ws/endpoint" + ) + or self.kind is not CredentialKind.API_KEY + or self.method != "POST" + or self.envelope + or self.query_fields + or self.path_fields + or self.request_format != "json" + ): + raise ValueError("invalid_lark_helper") + if self.protocol == "telegram_mtproto" and ( + self.endpoint + != "https://api.telegram.org/mtproto/" + self.operation.operation + or self.operation.operation + not in ("send_code", "complete", "qr_login", "complete_password") + or self.kind is not CredentialKind.SERVICE_SECRET + or self.secret_field != "api_hash" + or self.method != "POST" + or self.envelope + or self.query_fields + or self.path_fields + ): + raise ValueError("invalid_mtproto_helper") + if self.protocol == "discord_gateway" and ( + self.endpoint != "https://gateway.discord.gg/" + or self.kind is not CredentialKind.API_KEY + or self.method != "GET" + or self.query_fields + or self.path_fields + or self.envelope + ): + raise ValueError("invalid_gateway_helper") + if self.protocol.startswith("oauth_") and ( + self.kind is not CredentialKind.API_KEY + or self.method != "POST" + or self.path_fields + or self.query_fields + or not self.envelope + or self.request_format not in ("json", "form") + ): + raise ValueError("invalid_oauth_helper") + if self.protocol == "basic" and ( + self.kind is not CredentialKind.SERVICE_SECRET + or self.secret_field != "api_token" + ): + raise ValueError("invalid_basic_helper") + if self.protocol == "telegram" and ( + self.origin != "https://api.telegram.org" + or not self.endpoint.startswith( + ( + "https://api.telegram.org/bot_TOKEN_/", + "https://api.telegram.org/file/bot_TOKEN_/", + ) + ) + or "_TOKEN_" not in self.endpoint + ): + raise ValueError("invalid_telegram_helper") + if self.protocol == "aws_bedrock": + from .aws_helper import endpoint_region + + endpoint_region(self.endpoint) + if ( + self.kind is not CredentialKind.SERVICE_SECRET + or self.secret_field != "secret_access_key" + or self.method != "POST" + or self.query_fields + ): + raise ValueError("invalid_bedrock_helper") + + +@dataclass(frozen=True) +class JsonResult: + status: int + body: str + content_type: str + + +def _response_variants(fields: Secret): + """Scrub literal and common encoded echoes before any output is released.""" + import html + from urllib.parse import quote + + variants = set() + for raw in fields.reveal().values(): + if not raw: + continue + encoded = raw.encode("utf-8") + unicode_units = raw.encode("utf-16-be") + escaped = "".join( + f"\\u{int.from_bytes(unicode_units[i : i + 2], 'big'):04x}" + for i in range(0, len(unicode_units), 2) + ) + variants.update( + ( + raw, + json.dumps(raw)[1:-1], + html.escape(raw), + quote(raw, safe=""), + encoded.hex(), + base64.b64encode(encoded).decode("ascii"), + base64.urlsafe_b64encode(encoded).decode("ascii"), + escaped, + json.dumps(escaped)[1:-1], + "".join(f"%{byte:02x}" for byte in encoded), + "".join(f"%{byte:02X}" for byte in encoded), + ) + ) + return Secret(tuple(sorted(variants, key=len, reverse=True))) + + +def _redact_response(value: str, fields: Secret) -> str: + for variant in _response_variants(fields).reveal(): + value = value.replace(variant, "[redacted]") + return value + + +def _sanitize_json_payloads(payloads, fields): + """Validate redacted JSON and reject echoes split across semantic deltas. + + Network buffering alone misses a key split between SSE data records. Keep + strings at the same JSON path together (including tool-call arguments), and + also check concatenated leaves for array/object fragments. This is defence + against accidental provider echoes, not permission to trust arbitrary URLs. + Only fields actually sent to the reviewed service participate in filtering. + """ + sanitized = [] + paths = {} + leaves = [] + + def collect(value, path=()): + if type(value) is dict: + for key, child in value.items(): + collect(child, (*path, key)) + elif type(value) is list: + for index, child in enumerate(value): + collect(child, (*path, index)) + elif isinstance(value, str): + paths.setdefault(path, []).append(value) + leaves.append(value) + + for payload in payloads: + canonical = json.dumps(payload, ensure_ascii=False, allow_nan=False) + cleaned = _redact_response(canonical, fields) + # Substitution must never return invalid JSON (e.g. a numeric key echo). + collect(json.loads(cleaned)) + sanitized.append(cleaned) + for fragments in (*paths.values(), leaves): + joined = "".join(fragments) + if _redact_response(joined, fields) != joined: + raise CredentialUseError(UseCode.HELPER) + return sanitized + + +@use_boundary +def _execute_json( + specification: HttpsJsonOperation, + fields: Secret, + guard, + payload, + *, + execution=None, + after_send=None, +): + if type(payload) is not dict or set(payload) != {"body"}: + raise CredentialUseError(UseCode.DENIED) + if specification.protocol == "discord_gateway": + from .gateway_helper import execute + + return execute(specification, fields, guard, payload, execution, after_send) + if specification.protocol == "telegram_mtproto": + from .mtproto_helper import execute + + return execute(specification, fields, guard, payload, execution, after_send) + body = payload["body"] + session = None + path = urlsplit(specification.endpoint).path or "/" + query_data = body if specification.method == "GET" else None + business_headers = {} + if specification.envelope: + from .route_policy import target + + if ( + type(body) is not dict + or not {"path", "query", "data"} <= set(body) + or set(body) + - ( + {"path", "query", "data", "headers", "session"} + if specification.protocol == "twitter_oauth1" + else {"path", "query", "data", "headers"} + ) + ): + raise CredentialUseError(UseCode.DENIED) + session = body.get("session") + business_headers = body.get("headers", {}) + if ( + type(business_headers) is not dict + or set(business_headers) - set(specification.header_fields) + or any( + not isinstance(v, str) + or not 1 <= len(v) <= 256 + or any(ord(c) < 33 or ord(c) > 126 for c in v) + for v in business_headers.values() + ) + ): + raise CredentialUseError(UseCode.DENIED) + path = target(specification.endpoint, specification.path_fields, body["path"]) + query_data, body = body["query"], body["data"] + query = "" + if query_data is not None: + from urllib.parse import urlencode + + if ( + type(query_data) is not dict + or any( + k.split("[", 1)[0] not in specification.query_fields + or not re.fullmatch( + r"[A-Za-z][A-Za-z0-9_.]*(?:\[[A-Za-z0-9_.-]*\])*", k + ) + for k in query_data + ) + or any( + type(v) not in (str, int, bool, type(None)) for v in query_data.values() + ) + ): + raise CredentialUseError(UseCode.DENIED) + query = urlencode( + { + k: str(v).lower() if type(v) is bool else v + for k, v in query_data.items() + if v is not None + } + ) + if len(query) > 8192: + raise CredentialUseError(UseCode.LIMIT) + if specification.method == "GET": + body = None + if ( + body is not None + and type(body) is not dict + and not ( + specification.envelope + and specification.request_format == "json" + and type(body) is list + ) + ): + raise CredentialUseError(UseCode.DENIED) + from .wire import encode, MAX_OUTPUT + + encoded, request_type = encode(body, specification.request_format) + # Host-controlled timeout, immutable destination, no proxy or second DNS. + deadline = time.monotonic() + (600 if execution is not None else 60) + handshake_deadline = time.monotonic() + 20 + parsed = urlsplit(specification.endpoint) + port = parsed.port or 443 + family, socktype, protocol, sockaddr = _address(parsed.hostname, port) + guard() + context = ssl.create_default_context() + context.set_alpn_protocols(["http/1.1"]) + connection = http.client.HTTPSConnection(parsed.hostname, port, context=context) + connection.auto_open = 0 + connection.set_debuglevel(0) + timer = None + finished = None + try: + with socket.socket(family, socktype, protocol) as transport: + transport.settimeout(_remaining(handshake_deadline)) + transport.connect(sockaddr) + transport.settimeout(_remaining(handshake_deadline)) + connection.sock = context.wrap_socket( + transport, server_hostname=parsed.hostname + ) + connection.sock.settimeout(_remaining(deadline)) + timer = Timer(_remaining(deadline), _abort_socket, args=(connection.sock,)) + timer.daemon = True + timer.start() + if execution is not None: + from threading import Event, Thread + + finished = Event() + + def watch(): + while not finished.wait(0.05): + if execution.cancelled.is_set(): + _abort_socket(connection.sock) + return + + Thread(target=watch, daemon=True).start() + guard() + secret = Secret( + fields.reveal()[ + "api_key" + if specification.kind is CredentialKind.API_KEY + else specification.secret_field + ] + ) + if any(ord(c) < 33 or ord(c) > 126 for c in secret.reveal()): + raise CredentialUseError(UseCode.DENIED) + headers = { + "Content-Type": request_type, + "Accept": "application/json", + "Connection": "close", + "User-Agent": "CraftBotCredentialHost/1", + specification.auth_header: specification.auth_prefix + secret.reveal(), + } + if specification.protocol == "anthropic": + headers["anthropic-version"] = "2023-06-01" + if specification.operation.helper_id == "integration_notion": + headers["Notion-Version"] = "2022-06-28" + if specification.operation.helper_id == "integration_github": + headers["Accept"] = "application/vnd.github+json" + headers["X-GitHub-Api-Version"] = "2022-11-28" + headers.update(business_headers) + used_fields = Secret({"value": secret.reveal()}) + if specification.protocol == "twitter_oauth1": + from .twitter_helper import authorization + + signed, used_fields = authorization( + specification.origin + path, + specification.method, + query, + fields, + session, + ) + headers["Authorization"] = signed.reveal() + if specification.protocol in ("lark_token", "lark_websocket"): + if ( + type(body) is not dict + or set(body) != {"app_id"} + or not isinstance(body["app_id"], str) + or not re.fullmatch(r"cli_[A-Za-z0-9_]{1,128}", body["app_id"]) + ): + raise CredentialUseError(UseCode.DENIED) + private_body = Secret( + {"app_id": body["app_id"], "app_secret": secret.reveal()} + if specification.protocol == "lark_token" + else {"AppID": body["app_id"], "AppSecret": secret.reveal()} + ) + headers.pop(specification.auth_header, None) + encoded, headers["Content-Type"] = encode(private_body.reveal(), "json") + if specification.protocol == "basic": + username = Secret(fields.reveal()["username"]) + if ":" in username.reveal() or any( + ord(c) < 32 or ord(c) == 127 for c in username.reveal() + ): + raise CredentialUseError(UseCode.DENIED) + pair = Secret(username.reveal() + ":" + secret.reveal()) + headers["Authorization"] = ( + "Basic " + base64.b64encode(pair.reveal().encode()).decode() + ) + used_fields = Secret( + { + "username": username.reveal(), + "api_token": secret.reveal(), + "pair": pair.reveal(), + } + ) + if specification.protocol.startswith("oauth_"): + if type(body) is not dict or any( + type(v) not in (str, int, bool, type(None)) for v in body.values() + ): + raise CredentialUseError(UseCode.DENIED) + private_body = Secret(dict(body)) + private_body.reveal().pop("client_secret", None) + headers.pop(specification.auth_header, None) + if specification.protocol == "oauth_basic": + client_id = private_body.reveal().pop("client_id", None) + if ( + not isinstance(client_id, str) + or not client_id + or ":" in client_id + or not client_id.isascii() + or any(ord(c) < 33 or ord(c) == 127 for c in client_id) + ): + raise CredentialUseError(UseCode.DENIED) + pair = Secret(client_id + ":" + secret.reveal()) + headers["Authorization"] = ( + "Basic " + base64.b64encode(pair.reveal().encode()).decode() + ) + used_fields = Secret( + {"value": secret.reveal(), "pair": pair.reveal()} + ) + else: + private_body.reveal()["client_secret"] = secret.reveal() + encoded, headers["Content-Type"] = encode( + private_body.reveal(), specification.request_format + ) + if specification.protocol == "aws_bedrock": + from .aws_helper import signing_headers + + signed, used_fields = signing_headers( + specification.endpoint, encoded, fields + ) + headers.update(signed.reveal()) + if specification.protocol == "telegram": + if not re.fullmatch( + r"[0-9]{5,20}:[A-Za-z0-9_-]{20,128}", secret.reveal() + ): + raise CredentialUseError(UseCode.DENIED) + path = path.replace("_TOKEN_", secret.reveal()) + headers.pop(specification.auth_header, None) + if "Authorization" in headers: + used_fields = Secret( + {**used_fields.reveal(), "authorization": headers["Authorization"]} + ) + connection.request( + specification.method, + path + ("?" + query if query else ""), + encoded, + headers, + ) + if after_send is not None: + after_send() + with connection.getresponse() as response: + if not 200 <= response.status < 300: + status = ( + response.status + if response.status + in (400, 401, 403, 404, 408, 409, 422, 429, 500, 502, 503, 504) + else 502 + ) + return JsonResult( + status, + '{"error":{"message":"Credential operation failed"}}', + "application/json", + ) + # SSE filtering retains potential source fragments across events. + if response.status == 204: + return JsonResult(204, "{}", "application/json") + response_type = ( + response.getheader("Content-Type", "").split(";")[0].strip().lower() + ) + if ( + execution is not None + and response_type == "text/event-stream" + and specification.protocol in ("openai", "anthropic") + ): + from .streaming import relay + + execution.start(response.status, response_type) + relay( + response, + used_fields, + execution, + specification.max_response_bytes, + ) + return JsonResult(response.status, "", response_type) + raw = Secret(response.read(specification.max_response_bytes + 1)) + if len(raw.reveal()) > specification.max_response_bytes: + raise CredentialUseError(UseCode.LIMIT) + content_type = ( + response.getheader("Content-Type", "").split(";")[0].strip().lower() + ) + if specification.response_format == "binary": + if content_type not in ( + "video/mp4", + "image/png", + "image/jpeg", + "image/webp", + "application/octet-stream", + "application/pdf", + "application/zip", + "text/plain", + "audio/mpeg", + "audio/ogg", + "audio/wav", + "video/webm", + "image/gif", + ): + raise CredentialUseError(UseCode.HELPER) + inspection = Secret(raw.reveal().decode("latin-1")) + binary = Secret(base64.b64encode(raw.reveal()).decode("ascii")) + if ( + _redact_response(inspection.reveal(), used_fields) + != inspection.reveal() + or _redact_response(binary.reveal(), used_fields) + != binary.reveal() + or any( + variant.encode("utf-8") in raw.reveal() + for variant in _response_variants(used_fields).reveal() + ) + ): + raise CredentialUseError(UseCode.HELPER) + return JsonResult( + response.status, + json.dumps( + {"data": binary.reveal(), "media_type": content_type} + ), + "application/json", + ) + text = Secret(raw.reveal().decode("utf-8")) + if specification.response_format == "text": + return JsonResult( + response.status, + _redact_response(text.reveal(), used_fields), + "text/plain", + ) + if content_type == "text/event-stream" and specification.protocol in ( + "openai", + "anthropic", + ): + # Only valid JSON data records and the standard terminator. + canonical = [] + payloads = [] + for line in text.reveal().splitlines(): + if line.startswith("data:"): + if line[5:].strip() != "[DONE]": + payloads.append(json.loads(line[5:])) + line = len(payloads) - 1 + elif line.startswith(":"): + continue # Provider comments are not output data. + elif line.startswith("event:"): + if not re.fullmatch(r"event: ?[A-Za-z0-9_.-]{1,64}", line): + raise CredentialUseError(UseCode.HELPER) + elif line: + raise CredentialUseError(UseCode.HELPER) + canonical.append(line) + cleaned = _sanitize_json_payloads(payloads, used_fields) + text = Secret( + "\n".join( + "data: " + cleaned[line] if type(line) is int else line + for line in canonical + ) + + "\n" + ) + elif content_type == "application/json": + text = Secret( + _sanitize_json_payloads( + [json.loads(text.reveal())], used_fields + )[0] + ) + else: + raise CredentialUseError(UseCode.HELPER) + sanitized = _redact_response(text.reveal(), used_fields) + if len(sanitized.encode("utf-8")) > MAX_OUTPUT: + raise CredentialUseError(UseCode.LIMIT) + return JsonResult(response.status, sanitized, content_type) + finally: + if finished is not None: + finished.set() + if timer is not None: + timer.cancel() + connection.close() + + +def require_match(specification: HttpsCredentialCheck, details: CredentialDetails): + if ( + specification.kind is not details.summary.kind + or specification.origin not in details.allowed_origins + ): + raise CredentialUseError(UseCode.DENIED) + + +def _address(host: str, port: int): + """Resolve once and connect to exactly a checked numeric address. + + Reject the entire answer on any private/special address, including mixed + public/private answers. No proxy environment or second DNS lookup is used. + """ + from .resolver import resolve + + addresses = resolve(host, port) + if not addresses: + raise CredentialUseError(UseCode.DENIED) + for family, socktype, protocol, _, sockaddr in addresses: + address = ipaddress.ip_address(sockaddr[0]) + if ( + family not in (socket.AF_INET, socket.AF_INET6) + or socktype != socket.SOCK_STREAM + or not address.is_global + or address.is_multicast + or address.is_reserved + or getattr(address, "is_site_local", False) + or getattr(address, "ipv4_mapped", None) is not None + or getattr(address, "sixtofour", None) is not None + or getattr(address, "teredo", None) is not None + or "%" in sockaddr[0] + ): + raise CredentialUseError(UseCode.DENIED) + family, socktype, protocol, _, sockaddr = addresses[0] + return family, socktype, protocol, sockaddr + + +def _authorization(specification: HttpsCredentialCheck, fields: Secret) -> Secret: + # All intermediates remain redacted in trusted frames; the public broker + # boundary also discards inner frames on any exception. + if specification.kind is CredentialKind.LOGIN: + pair = Secret((fields.reveal()["username"], fields.reveal()["password"])) + if ":" in pair.reveal()[0] or any( + ord(char) < 32 or ord(char) == 127 + for value in pair.reveal() + for char in value + ): + raise CredentialUseError(UseCode.DENIED) + encoded = Secret(base64.b64encode(":".join(pair.reveal()).encode("utf-8"))) + return Secret("Basic " + encoded.reveal().decode("ascii")) + value = Secret( + fields.reveal()[ + "api_key" + if specification.kind is CredentialKind.API_KEY + else specification.secret_field + ] + ) + if not re.fullmatch(r"[A-Za-z0-9._~+/\-]+=*", value.reveal()): + raise CredentialUseError(UseCode.DENIED) + return Secret("Bearer " + value.reveal()) + + +def _remaining(deadline): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError() + return remaining + + +def _abort_socket(transport): + # A total request deadline must also stop trickled status/response headers; + # a per-recv socket timeout alone does not bound that case. + try: + transport.shutdown(socket.SHUT_RDWR) + except (OSError, AttributeError): + pass + + +@use_boundary +def _execute_check(specification: HttpsCredentialCheck, fields: Secret, guard): + """Private broker entry point. `guard` is host code, never a request value.""" + deadline = time.monotonic() + 10 + parsed = urlsplit(specification.endpoint) + port = parsed.port or 443 + family, socktype, protocol, sockaddr = _address(parsed.hostname, port) + guard() # Slow DNS must not extend capability/owner-unlock lifetimes. + _remaining(deadline) + context = ssl.create_default_context() + context.set_alpn_protocols(["http/1.1"]) + connection = http.client.HTTPSConnection( + parsed.hostname, port, timeout=10, context=context + ) + connection.auto_open = 0 # Never reconnect to an unchecked DNS address. + connection.set_debuglevel(0) + timer = None + try: + with socket.socket(family, socktype, protocol) as transport: + transport.settimeout(_remaining(deadline)) + transport.connect(sockaddr) + transport.settimeout(_remaining(deadline)) + connection.sock = context.wrap_socket( + transport, server_hostname=parsed.hostname + ) + connection.sock.settimeout(_remaining(deadline)) + timer = Timer(_remaining(deadline), _abort_socket, args=(connection.sock,)) + timer.daemon = True + timer.start() + guard() # Recheck immediately before secrets enter the request. + authorization = _authorization(specification, fields) + connection.request( + "GET", + parsed.path or "/", + headers={ + "Authorization": authorization.reveal(), + "Connection": "close", + "Accept": "application/json", + }, + ) + # Do not return/cache/log headers, URL, body, cookies or client objects. + with connection.getresponse() as response: + _remaining(deadline) + if 200 <= response.status < 300: + return HelperResult.ACCEPTED + if response.status in (401, 403): + return HelperResult.REJECTED + return HelperResult.UNAVAILABLE + finally: + if timer is not None: + timer.cancel() + connection.close() diff --git a/app/credentials/host_service.py b/app/credentials/host_service.py new file mode 100644 index 000000000..4bceaabf4 --- /dev/null +++ b/app/credentials/host_service.py @@ -0,0 +1,1486 @@ +"""Installed worker. Only the SCM host's private stdin carries kernel envelopes. + +The ordinary browser/bridge is not an owner channel. Public consumer messages +cannot provide a caller context, isolated flag, PID, grant, or secret fields. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from contextlib import closing +import hashlib +import json +from pathlib import Path, PurePosixPath +import sys +import time +from threading import Timer, Thread, Event, RLock +from .execution import OperationJob, OutputBudget +from uuid import UUID, uuid4 + +from .broker import CredentialBroker, GrantMode, UseCapability, canonical_request +from .contracts import ( + CredentialKind, + CredentialReference, + HelperOperation, + IsolationUnavailable, +) +from .directory import IdentityDirectory +from .helpers import HelperRegistry, HttpsJsonOperation, JsonResult, require_match +from .catalog import specification +from .browser_helper import HttpsFormLogin +from .identity import ( + AppDeployment, + CallerBinding, + CallerIdentity, + CallerKind, + ExecutionContext, + VaultOwner, + VaultProfile, + VaultScope, +) +from .owner_transport import WindowsOwnerConsent +from .use_errors import CredentialUseError, UseCode, use_boundary +from .vault import ProfileVault, VaultState +from .windows_host import IsolatedProcess, Peer, api, check_install, private_acl + + +@dataclass +class Launch: + process: IsolatedProcess + context: ExecutionContext + controller: Peer + deadline: float + result: object = None + timer: object = None + credential_output: JsonResult | None = None + payload_text: str = "" + payload_ready: bool = False + job: OperationJob | None = None + snapshot_revision: str | None = None + closed: Event = field(default_factory=Event) + guard: object = field(default_factory=RLock) + + def close(self): + with self.guard: + if self.closed.is_set(): + return False + self.closed.set() + if self.job is not None: + self.job.close() + if self.timer is not None: + self.timer.cancel() + try: + self.process.close() + finally: + self.controller.close() + return True + + +@dataclass(frozen=True) +class OwnerView: + pid: int + created: int + scope: VaultScope + epoch: UUID + expires: float + items: tuple + + +def metadata_page(items, offset): + if type(offset) is not int or not 0 <= offset <= len(items): + raise CredentialUseError(UseCode.DENIED) + page, size = [], 0 + for item in items[offset : offset + 50]: + length = len(json.dumps(item, ensure_ascii=True).encode("utf-8")) + if size + length > 24000: + break + page.append(item) + size += length + if offset < len(items) and not page: + raise CredentialUseError(UseCode.LIMIT) + next_offset = offset + len(page) + return { + "items": page, + "next_offset": next_offset if next_offset < len(items) else None, + "total": len(items), + } + + +def source_files(value, entry): + """A bounded immutable code snapshot, not host-supplied filesystem paths.""" + if ( + type(value) is not dict + or not 1 <= len(value) <= 32 + or not isinstance(entry, str) + ): + raise CredentialUseError(UseCode.DENIED) + total = 0 + names = set() + digest = hashlib.sha256() + digest.update(b"CraftBot snapshot v1\0" + entry.encode("utf-8") + b"\0") + for name, content in sorted(value.items()): + if not isinstance(name, str) or not isinstance(content, str): + raise CredentialUseError(UseCode.DENIED) + path = PurePosixPath(name) + if ( + not name.isascii() + or len(name) > 160 + or path.is_absolute() + or not path.parts + or path.as_posix() != name + or any(part in (".", "..") for part in path.parts) + or any( + char + not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_./-" + for char in name + ) + or any( + part.split(".")[0].upper() + in { + "CON", + "PRN", + "AUX", + "NUL", + *[f"COM{i}" for i in range(10)], + *[f"LPT{i}" for i in range(10)], + } + or part.endswith((".", " ")) + for part in path.parts + ) + or name.casefold() in names + or path.parts[0].casefold() in ("credential_client.py", "_host_entry.py") + ): + raise CredentialUseError(UseCode.DENIED) + names.add(name.casefold()) + encoded = content.encode("utf-8") + total += len(encoded) + if total > 48000: + raise CredentialUseError(UseCode.LIMIT) + name_bytes = name.encode("ascii") + digest.update( + len(name_bytes).to_bytes(4, "little") + + name_bytes + + len(encoded).to_bytes(8, "little") + + encoded + ) + if entry not in value or not entry.endswith(".py"): + raise CredentialUseError(UseCode.DENIED) + # Prefix collisions (a.py plus a.py/b.py) are rejected before writing. + for name in names: + if any( + parent.as_posix().casefold() in names + for parent in PurePosixPath(name).parents + if parent.as_posix() != "." + ): + raise CredentialUseError(UseCode.DENIED) + return "sha256:" + digest.hexdigest() + + +class ProtectedHost: + def __init__(self, root: Path, state: Path, configuration: dict): + self.root, self.state = root, state + self.owner = VaultOwner( + UUID(configuration["owner_id"]), configuration["owner_sid"] + ) + self.directory = IdentityDirectory() + self.directory.register_owner(self.owner) + self.consent = WindowsOwnerConsent( + root / "CredentialHost.exe", self.owner.os_identity + ) + self.vaults = {} + self.views = {} + catalog = state / "profiles.json" + profiles = ( + json.loads(catalog.read_text(encoding="utf-8")) + if catalog.exists() + else configuration["profiles"] + ) + if type(profiles) is not list or not 1 <= len(profiles) <= 64: + raise IsolationUnavailable() + for item in profiles: + if len(item["label"]) > 64: + raise IsolationUnavailable() + profile = VaultProfile( + VaultScope(self.owner.owner_id, UUID(item["id"])), item["label"] + ) + self.directory.register_profile(profile) + if profile.scope.profile_id in self.vaults: + raise IsolationUnavailable() + self.vaults[profile.scope.profile_id] = ProfileVault( + state / "vaults", self.owner, profile, verifier=self.consent + ) + specifications = tuple( + specification(item) for item in configuration.get("helpers", []) + ) + self.registry = HelperRegistry(specifications) + self.leases: dict[UUID, Launch] = {} + self.output_budget = OutputBudget() + for vault in self.vaults.values(): + vault._on_lock = lambda scope=vault.profile.scope: self._cancel_profile( + scope + ) + self.broker = CredentialBroker( + self.directory, + tuple(self.vaults.values()), + self.registry, + isolation=self, + owner_verifier=self.consent, + ) + + def require_isolated(self, context): + launch = next( + (item for item in self.leases.values() if item.context == context), None + ) + if launch is None or time.monotonic() >= launch.deadline: + raise IsolationUnavailable() + peer = Peer(launch.process.pid) + try: + if peer.sid != self.owner.os_identity or not launch.process.matches(peer): + raise IsolationUnavailable() + finally: + peer.close() + + def _cancel_profile(self, scope): + # Called under a vault lock, so this must not acquire the broker or + # directory lock. The HTTPS/Gateway watcher aborts active sockets. + for launch in tuple(self.leases.values()): + if launch.context.binding.scope == scope and launch.job is not None: + launch.job.cancelled.set() + + def _owner(self, peer): + if ( + peer.sid != self.owner.os_identity + or peer.container + or peer.elevated + or not peer.alive() + ): + raise CredentialUseError(UseCode.DENIED) + self.consent.session = peer.session + + def _consumer(self, peer): + if peer.sid != self.owner.os_identity: + raise CredentialUseError(UseCode.DENIED) + for launch in self.leases.values(): + if launch.process.matches(peer): + self.require_isolated(launch.context) + self.consent.session = peer.session + return launch + raise IsolationUnavailable() + + @staticmethod + def _shape(payload, fields): + if type(payload) is not dict or set(payload) != {"op", *fields}: + raise CredentialUseError(UseCode.DENIED) + + def _profile(self, value): + vault = self.vaults.get(UUID(value)) + if vault is None: + raise CredentialUseError(UseCode.DENIED) + return vault + + def _prune(self): + self.views = { + key: value + for key, value in self.views.items() + if time.monotonic() < value.expires + } + for key, launch in tuple(self.leases.items()): + if time.monotonic() >= launch.deadline or not launch.controller.alive(): + self._expire(key, launch) + + def _expire(self, key, launch): + # Independent of control-pipe traffic and idempotent against stop/lock. + first = launch.close() + if self.leases.get(key) is launch: + self.leases.pop(key, None) + if not first: + return + Thread( + target=self.directory.revoke, + args=(launch.context.binding.binding_id,), + daemon=True, + ).start() + + def _watch(self, key, launch): + while not launch.closed.wait(1): + try: + expired = time.monotonic() >= launch.deadline + disconnected = not launch.controller.alive() + abandoned = ( + launch.job is not None + and not launch.job.done.is_set() + and api()[6].WaitForSingleObject(launch.process.handle, 0) == 0 + ) + if expired or disconnected or abandoned: + self._expire(key, launch) + return + except Exception: + self._expire(key, launch) + return + + @use_boundary + def dispatch(self, peer, payload): + self._prune() + if type(payload) is not dict or not isinstance(payload.get("op"), str): + raise CredentialUseError(UseCode.DENIED) + operation = payload["op"] + if operation in { + "profiles", + "action_profile", + "action_profile_set", + "initialize", + "entry", + "launch", + "result", + "program_result", + "response", + "stage_payload", + "reset_operation", + "stop", + "lock", + "unlock", + "items", + "items_page", + "edit", + "delete", + "activity", + "revoke", + "profile_add", + "migrate_settings", + "migrate_browser", + "migrate_integration", + "migrate_environment", + "catalog", + "view", + }: + self._owner(peer) + if operation == "lock": + self._shape(payload, ("profile_id",)) + self._profile(payload["profile_id"]) + # The native supervisor clears all host keys/jobs immediately, + # including a resolver blocked before it can observe cancellation. + return {"reset_host": True} + if operation not in ( + "profiles", + "action_profile", + "result", + "program_result", + "response", + "stop", + "stage_payload", + "reset_operation", + "catalog", + ) and any( + item.job is not None + and not item.job.done.is_set() + and not item.job.dispatched.is_set() + for item in self.leases.values() + ): + raise CredentialUseError(UseCode.BUSY) + return self._manage(peer, operation, payload) + launch = self._consumer(peer) + context = launch.context + vault = self.vaults[context.binding.scope.profile_id] + if operation in ( + "request_use", + "authorize_grant", + "request_entry", + "status", + "find", + ) and any( + item.job is not None + and not item.job.done.is_set() + and not item.job.dispatched.is_set() + for item in self.leases.values() + ): + raise CredentialUseError(UseCode.BUSY) + if operation == "operation_status": + self._shape(payload, ()) + if launch.job is None: + return {"done": True, "active": False, "error": None} + return { + "done": launch.job.done.is_set(), + "active": True, + "error": launch.job.error, + "outcome": launch.job.result.value + if launch.job.done.is_set() + and launch.job.result is not None + and type(launch.job.result) is not JsonResult + else "completed", + } + if operation == "authorize_grant": + self._shape(payload, ("grant_id",)) + return { + "capability": self.broker.authorize( + context, UUID(payload["grant_id"]) + ).handle + } + if operation == "start_use_staged": + self._shape(payload, ("capability",)) + if any( + item.job is not None + and not item.job.done.is_set() + and not item.job.dispatched.is_set() + for item in self.leases.values() + ): + raise CredentialUseError(UseCode.BUSY) + if ( + sum( + len(item.job.body) if item.job is not None else 0 + for item in self.leases.values() + ) + > 96_000_000 + ): + raise CredentialUseError(UseCode.LIMIT) + if ( + not launch.payload_ready + or launch.job is not None + and not launch.job.done.is_set() + ): + raise CredentialUseError(UseCode.DENIED) + body = {"body": json.loads(launch.payload_text)} + launch.payload_text, launch.payload_ready = "", False + capability = UseCapability(payload["capability"]) + with launch.guard: + if launch.closed.is_set(): + raise CredentialUseError(UseCode.DENIED) + if launch.job is not None: + launch.job.close() + launch.job = OperationJob( + lambda job: self.broker.execute( + context, capability, body, execution=job + ), + budget=self.output_budget, + grant_id=self.broker.capability_grant(context, capability), + ) + launch.job.thread.start() + return {"started": True} + if operation == "status": + self._shape(payload, ()) + return {"state": self.broker.status(context).value} + if operation == "upload_payload_part": + self._shape(payload, ("offset", "data", "done")) + return self._stage(launch, payload) + if operation == "payload_info": + self._shape(payload, ()) + return { + "ready": launch.payload_ready, + "sha256": hashlib.sha256( + launch.payload_text.encode("ascii") + ).hexdigest() + if launch.payload_ready + else None, + } + if operation == "discard_payload": + self._shape(payload, ()) + launch.payload_text, launch.payload_ready = "", False + return {"discarded": True} + if operation == "operations": + self._shape(payload, ("offset",) if "offset" in payload else ()) + offset = payload.get("offset", 0) + entries = tuple(self.registry._entries.values()) + if type(offset) is not int or not 0 <= offset <= len(entries): + raise CredentialUseError(UseCode.DENIED) + return { + "operations": [ + { + "helper": spec.operation.helper_id, + "operation": spec.operation.operation, + } + for spec in entries[offset : offset + 32] + ], + "more": offset + 32 < len(entries), + } + if operation == "find": + self._shape(payload, ("offset",) if "offset" in payload else ()) + offers = self.broker.find(context) + offset = payload.get("offset", 0) + if type(offset) is not int or not 0 <= offset <= len(offers): + raise CredentialUseError(UseCode.DENIED) + return { + "offers": [ + { + "grant_id": str(offer.grant_id), + "credential_id": str(offer.summary.reference.credential_id), + "version": offer.summary.reference.version, + "service": offer.summary.service, + "account": offer.summary.account_label, + "helper": offer.operation.helper_id, + "operation": offer.operation.operation, + } + for offer in offers[offset : offset + 8] + ], + "more": offset + 8 < len(offers), + } + if operation == "request_entry": + self._shape(payload, ("helper", "operation", "account")) + from .identity import _label + from .references import SourceReference + + _label(payload["account"]) + spec = self.registry.require( + HelperOperation(payload["helper"], payload["operation"]) + ) + self.consent.message = f"Unlock {vault.profile.label} for a secure-entry request from {context.binding.caller.subject_id}?\nHelper: {spec.operation.helper_id}/{spec.operation.operation}\nDestination: {spec.endpoint}" + if vault.status().state is not VaultState.READY: + vault.unlock() + names = { + CredentialKind.LOGIN: ("username", "password"), + CredentialKind.API_KEY: ("api_key",), + }.get(spec.kind, (spec.secret_field,)) + if type(spec) is HttpsJsonOperation and spec.protocol == "aws_bedrock": + names = ("access_key_id", "secret_access_key") + if type(spec) is HttpsJsonOperation and spec.protocol == "basic": + names = ("username", "api_token") + if type(spec) is HttpsJsonOperation and spec.protocol == "twitter_oauth1": + names = ("api_key", "api_secret") + if ( + type(spec) is HttpsJsonOperation + and spec.protocol == "telegram_mtproto" + and spec.operation.operation == "complete_password" + ): + names = ("api_hash", "password") + self.consent.message = ( + f"Save a new {spec.kind.value} in {vault.profile.label}?\nCaller: {context.binding.caller.subject_id}\n" + f"Account label: {payload['account']}\nHelper: {spec.operation.helper_id}/{spec.operation.operation}\n" + f"Permitted origin: {spec.origin}\nSaving does not approve credential use. No values return to the requesting app." + ) + fields = self.consent.entry(names) + item = vault.create( + spec.kind, + spec.operation.helper_id, + payload["account"], + fields, + allowed_origins=(spec.origin,), + ) + ref = item.summary.reference + return { + "credential_id": str(ref.credential_id), + "version": ref.version, + "reference": SourceReference( + ref.scope.profile_id, ref.credential_id, ref.version + ).encode(), + } + if operation == "request_use": + if not launch.payload_ready: + raise CredentialUseError(UseCode.DENIED) + approved_body, _ = canonical_request( + {"body": json.loads(launch.payload_text)} + ) + repeat_fields = ("repeat",) if "repeat" in payload else () + self._shape( + payload, + ("helper", "operation", "credential_id", "credential_version") + + repeat_fields + if "credential_version" in payload + else ("helper", "operation", "credential_id") + repeat_fields, + ) + specification = self.registry.require( + HelperOperation(payload["helper"], payload["operation"]) + ) + repeat = payload.get("repeat", False) + if ( + type(repeat) is not bool + or repeat + and ( + type(specification) is not HttpsJsonOperation + or specification.method != "GET" + ) + ): + raise CredentialUseError(UseCode.DENIED) + self.consent.message = f"Unlock {vault.profile.label} for a credential request?\nCaller: {context.binding.caller.subject_id}\nDestination: {specification.endpoint}" + if vault.status().state is not VaultState.READY: + vault.unlock() + candidates = [] + for item in vault.list_items(): + try: + require_match(specification, item) + except CredentialUseError: + continue + if payload[ + "credential_id" + ] is None or item.summary.reference.credential_id == UUID( + payload["credential_id"] + ): + candidates.append(item) + if len(candidates) != 1: + raise CredentialUseError( + UseCode.DENIED + ) # Explicit account selection needed. + item = candidates[0] + if "credential_version" in payload and ( + type(payload["credential_version"]) is not int + or payload["credential_version"] != item.summary.reference.version + ): + raise CredentialUseError(UseCode.CHANGED) + caller = context.binding.caller + self.consent.message = ( + "Authenticate to review a credential request on the private desktop.\n" + "Review caller claims, profile, account, resolved destination and parameters before approving.\n" + + ( + "Allow repeated GET requests for this exact operation and isolated worker until its 15-minute lease expires, lock or revocation. Resource/query parameters may change within the displayed installed route." + if repeat + else "Expires: 30 seconds; one attempt." + ) + ) + # Full request review travels only on the SYSTEM-only UI pipe, not + # a command line, browser channel, log, or caller-supplied summary. + from .request_review import review_request + + self.consent.review_request = { + "Caller label (unverified)": json.dumps(caller.subject_id), + "Caller kind (unverified)": caller.kind.value, + "Claimed deployment (unverified)": caller.deployment.value + if caller.deployment + else "agent", + "Code SHA256": getattr(launch, "snapshot_revision", None) + or caller.app_revision + or "isolated agent snapshot", + "Profile": vault.profile.label, + "Account": item.summary.account_label, + "Credential": f"{item.summary.reference.credential_id} v{item.summary.reference.version}", + "Operation": f"{specification.operation.helper_id}/{specification.operation.operation}", + **review_request(specification, approved_body, repeat=repeat), + } + try: + grant = self.broker.approve( + context, + item.summary.reference, + specification.operation, + mode=GrantMode.BINDING if repeat else GrantMode.ONCE, + lifetime=max(0.01, min(900, launch.deadline - time.monotonic())) + if repeat + else 30, + payload=approved_body, + ) + finally: + self.consent.review_request = None + return { + "capability": self.broker.authorize(context, grant.grant_id).handle, + "grant_id": str(grant.grant_id), + } + if operation == "cancel": + self._shape(payload, ("capability",)) + self.broker.cancel(context, UseCapability(payload["capability"])) + return {"cancelled": True} + if operation in ("use", "use_staged"): + # All network work runs in a cancellable job. A consumer cannot + # select a synchronous route that monopolizes the control pipe. + raise CredentialUseError(UseCode.DENIED) + if operation == "complete": + self._shape(payload, ("result",)) + if len(json.dumps(payload["result"])) > 16000: + raise CredentialUseError(UseCode.LIMIT) + launch.result = {"result": payload["result"]} + return {"completed": True} + if operation == "read_response": + self._shape(payload, ("offset",)) + if launch.job is not None: + return launch.job.snapshot(payload["offset"]) + output, offset = launch.credential_output, payload["offset"] + if ( + output is None + or type(offset) is not int + or not 0 <= offset <= len(output.body) + ): + raise CredentialUseError(UseCode.DENIED) + return { + "status": output.status, + "content_type": output.content_type, + "length": len(output.body), + "chunk": output.body[offset : offset + 4000], + } + raise CredentialUseError(UseCode.DENIED) + + def _stage(self, launch, payload): + if launch.closed.is_set(): + raise CredentialUseError(UseCode.DENIED) + data, offset, done = payload["data"], payload["offset"], payload["done"] + if ( + launch.payload_ready + or type(offset) is not int + or offset != len(launch.payload_text) + or not isinstance(data, str) + or not data.isascii() + or len(data) > 8000 + or type(done) is not bool + or len(launch.payload_text) + len(data) > 32_000_000 + or sum(len(item.payload_text) for item in self.leases.values()) + len(data) + > 64_000_000 + ): + raise CredentialUseError(UseCode.DENIED) + launch.payload_text += data + if done: + value = json.loads(launch.payload_text) + if value is not None and type(value) is not dict: + raise CredentialUseError(UseCode.DENIED) + launch.payload_ready = True + return {"uploaded": len(launch.payload_text), "ready": launch.payload_ready} + + def _manage(self, peer, operation, payload): + if operation in ("action_profile", "action_profile_set"): + self._shape( + payload, ("profile_id",) if operation == "action_profile_set" else () + ) + path = self.state / "action-profile.json" + if operation == "action_profile_set": + profile_id = payload["profile_id"] + vault = self._profile(profile_id) if profile_id is not None else None + self.consent.message = ( + f"Use {vault.profile.label} for credential-isolated agent actions?\nEach launch and credential operation still needs owner approval." + if vault + else "Disable credential-isolated agent actions?" + ) + self.consent.verify(self.owner) + temporary = path.with_suffix(".tmp") + temporary.write_text( + json.dumps( + { + "profile_id": str(vault.profile.scope.profile_id) + if vault + else None + } + ), + encoding="utf-8", + ) + private_acl(temporary) + temporary.replace(path) + value = ( + json.loads(path.read_text(encoding="utf-8"))["profile_id"] + if path.exists() + else None + ) + if value is not None: + self._profile(value) + return {"profile_id": value} + if operation in ("migrate_integration", "migrate_environment"): + self._shape( + payload, + ("profile_id", "path", "provider", "source_accounts") + if operation == "migrate_integration" + else ("profile_id", "path"), + ) + from .migration import ( + migrate_integration_document, + migrate_environment_document, + SOURCE_FIELDS, + ) + from .migration_source import OwnerSettingsSource + from .secrets import Secret + + path = payload["path"] + if ( + not isinstance(path, str) + or len(path) > 400 + or any(ord(c) < 32 for c in path) + ): + raise CredentialUseError(UseCode.DENIED) + if operation == "migrate_integration": + provider, accounts = payload["provider"], payload["source_accounts"] + if ( + provider not in SOURCE_FIELDS + or type(accounts) is not list + or len(accounts) > 32 + or any(not isinstance(a, str) or len(a) > 256 for a in accounts) + ): + raise CredentialUseError(UseCode.DENIED) + filename = provider + ".accounts.json" + else: + filename = Path(path).name + if filename not in (".env", ".env.local"): + raise CredentialUseError(UseCode.DENIED) + vault = self._profile(payload["profile_id"]) + self.consent.message = f"Offline source migration into {vault.profile.label}?\nFile: {path}\nClose all CraftBot instances and writers first. Saved manual sources become verified vault references; generated tokens/sessions stay with their current owners. Restart CraftBot afterwards." + if operation == "migrate_integration": + self.consent.message += ( + "\nExplicit manual-source accounts: " + ", ".join(accounts) + ) + self.consent.verify(self.owner) + if vault.status().state is not VaultState.READY: + vault.unlock() + source = OwnerSettingsSource( + path, self.owner.os_identity, peer.session, filename=filename + ) + try: + source_id = hashlib.sha256( + str(source.path).casefold().encode() + ).hexdigest() + catalog = [ + {"helper": spec.operation.helper_id, "endpoint": spec.endpoint} + for spec in self.registry._entries.values() + ] + if operation == "migrate_integration": + result = migrate_integration_document( + vault, + source_id, + provider, + source.read(), + source.commit, + source_accounts=accounts, + helper_catalog=catalog, + ) + else: + document = Secret(source.read_bytes().reveal().decode("utf-8")) + result = migrate_environment_document( + vault, + source_id, + document, + source.commit_text, + helper_catalog=catalog, + ) + return { + "imported": result.imported, + "retained_references": result.retained_references, + "review_required": result.review_required, + "retained_derived": result.retained_derived, + "restart_required": True, + } + finally: + source.close() + if operation == "items_page": + self._shape(payload, ("profile_id", "view_id", "offset")) + vault = self._profile(payload["profile_id"]) + view = self.views.get(UUID(payload["view_id"])) + if ( + view is None + or view.pid != peer.pid + or view.created != peer.created + or view.scope != vault.profile.scope + or view.epoch != vault.status().unlock_epoch + or time.monotonic() >= view.expires + ): + raise CredentialUseError(UseCode.DENIED) + return { + **metadata_page(view.items, payload["offset"]), + "view_id": payload["view_id"], + } + if operation == "migrate_browser": + self._shape(payload, ("profile_id", "path")) + vault = self._profile(payload["profile_id"]) + if ( + not isinstance(payload["path"], str) + or len(payload["path"]) > 400 + or any(ord(c) < 32 for c in payload["path"]) + ): + raise CredentialUseError(UseCode.DENIED) + self.consent.message = ( + f"Offline saved-login migration into {vault.profile.label}?\nSource: {payload['path']}\n" + "Close all CraftBot instances first. Current logins will enter the protected vault. Encrypted legacy history and its separately encrypted recovery key will be retained by the host; verified legacy source files will be removed. Browser profiles, cookies and sessions stay in place. Restart CraftBot afterwards." + ) + self.consent.verify(self.owner) + if vault.status().state is not VaultState.READY: + vault.unlock() + from .browser_migration import migrate_browser_files + + return migrate_browser_files( + vault, payload["path"], self.state, self.owner.os_identity, peer.session + ) + if operation == "catalog": + self._shape(payload, ("offset",)) + offset = payload["offset"] + entries = tuple(self.registry._entries.values()) + if type(offset) is not int or not 0 <= offset <= len(entries): + raise CredentialUseError(UseCode.DENIED) + rows = [ + { + "helper": spec.operation.helper_id, + "operation": spec.operation.operation, + "endpoint": spec.endpoint, + **( + { + "path_fields": list(spec.path_fields), + "query_fields": list(spec.query_fields), + "envelope": spec.envelope, + "request_format": spec.request_format, + "response_format": spec.response_format, + "header_fields": list(spec.header_fields), + "protocol": spec.protocol, + } + if type(spec) is HttpsJsonOperation + else {} + ), + "method": "POST" + if type(spec) is HttpsFormLogin + else spec.method + if type(spec) is HttpsJsonOperation + else "GET", + "type": "form" + if type(spec) is HttpsFormLogin + else "json" + if type(spec) is HttpsJsonOperation + else "check", + } + for spec in entries[offset : offset + 8] + ] + page = metadata_page(rows, 0)["items"] + next_offset = offset + len(page) + return { + "helpers": page, + "more": next_offset < len(entries), + "next_offset": next_offset, + } + if operation == "migrate_settings": + self._shape(payload, ("profile_id", "path")) + vault = self._profile(payload["profile_id"]) + if not isinstance(payload["path"], str) or len(payload["path"]) > 400: + raise CredentialUseError(UseCode.DENIED) + self.consent.message = f"Offline model-key migration into {vault.profile.label}?\nSource: {payload['path']}\nClose all CraftBot instances and other Settings writers first. Source API keys become references after encrypted destination verification. Tokens and sessions retain their owners. Restart CraftBot afterwards." + self.consent.verify(self.owner) + if vault.status().state is not VaultState.READY: + vault.unlock() + from .migration import migrate_model_document + from .migration_source import OwnerSettingsSource + + source = OwnerSettingsSource( + payload["path"], self.owner.os_identity, peer.session + ) + try: + document = source.read() + source_id = hashlib.sha256( + str(source.path).casefold().encode("utf-8") + ).hexdigest() + result = migrate_model_document( + vault, + source_id, + document, + source.commit, + helper_catalog=[ + { + "helper": s.operation.helper_id, + "endpoint": s.endpoint, + "protocol": s.protocol, + } + for s in self.registry._entries.values() + if type(s) is HttpsJsonOperation + and ( + s.kind is CredentialKind.API_KEY + or s.protocol in ("aws_bedrock", "telegram_mtproto") + ) + ], + ) + return { + "imported": result.imported, + "retained_references": result.retained_references, + "restart_required": True, + } + finally: + source.close() + if operation == "profiles": + self._shape(payload, ()) + return [ + { + "id": str(key), + "label": vault.profile.label, + "state": "busy" + if any( + item.context.binding.scope == vault.profile.scope + and item.job is not None + and not item.job.done.is_set() + for item in self.leases.values() + ) + else vault.status().state.value, + } + for key, vault in self.vaults.items() + ] + if operation == "profile_add": + self._shape(payload, ("label",)) + from .identity import _label + + _label(payload["label"]) + label = payload["label"] + if len(self.vaults) >= 64 or len(label) > 64: + raise CredentialUseError(UseCode.LIMIT) + self.consent.message = f"Create credential profile {label}?" + self.consent.verify(self.owner) + profile = VaultProfile(VaultScope(self.owner.owner_id, uuid4()), label) + vault = ProfileVault( + self.state / "vaults", self.owner, profile, verifier=self.consent + ) + vault._on_lock = lambda: self._cancel_profile(profile.scope) + # Persist a SYSTEM-owned catalog before exposing the new profile. + catalog = self.state / "profiles.new" + catalog.write_text( + json.dumps( + [ + { + "id": str(v.profile.scope.profile_id), + "label": v.profile.label, + } + for v in (*self.vaults.values(), vault) + ] + ), + encoding="utf-8", + ) + private_acl(catalog) + catalog.replace(self.state / "profiles.json") + self.directory.register_profile(profile) + self.vaults[profile.scope.profile_id] = vault + self.broker.add_profile(vault) + return {"id": str(profile.scope.profile_id)} + if operation in ( + "items", + "unlock", + "edit", + "delete", + "view", + "activity", + "revoke", + ): + fields = { + "items": ("profile_id",), + "unlock": ("profile_id",), + "activity": ("profile_id",), + "revoke": ("profile_id", "grant_id"), + "delete": ("profile_id", "credential_id", "version"), + "view": ("profile_id", "credential_id", "version"), + "edit": ( + "profile_id", + "credential_id", + "version", + "service", + "account", + "origins", + "replace_fields", + ), + }[operation] + self._shape(payload, fields) + vault = self._profile(payload["profile_id"]) + self.consent.message = ( + f"{operation.capitalize()} credentials in {vault.profile.label}?" + ) + if operation == "unlock": + vault.unlock() + return {"state": vault.status().state.value} + if operation in ("edit", "delete", "view"): + reference = CredentialReference( + vault.profile.scope, + UUID(payload["credential_id"]), + payload["version"], + ) + current = next( + (i for i in vault.list_items() if i.summary.reference == reference), + None, + ) + if current is None: + raise CredentialUseError(UseCode.CHANGED) + self.consent.message = ( + f"{operation.capitalize()} saved credential?\nProfile: {vault.profile.label}\n" + f"Service: {current.summary.service}\nAccount: {current.summary.account_label}\n" + f"Credential: {reference.credential_id} v{reference.version}" + ) + if operation == "edit": + from .identity import _label + from .records import origins + + _label(payload["service"]) + _label(payload["account"]) + allowed = origins(tuple(payload["origins"])) + self.consent.message += ( + f"\nNew service: {payload['service']}\nNew account: {payload['account']}\n" + f"New permitted origins: {', '.join(allowed)}\nReplace secret fields: {payload['replace_fields'] is True}" + ) + if operation == "revoke": + self.consent.message += f"\nGrant: {UUID(payload['grant_id'])}" + if operation == "view": + # The native executable verifies the owner before accepting the + # private SYSTEM-only response. Public RPC returns no values. + with vault._helper_record( + reference, vault.status().unlock_epoch + ) as record: + self.consent.view(record.fields) + return {"viewed": True} + # Native verification is required even if the requesting browser was + # authenticated and the profile already happens to be unlocked. + self.consent.verify(self.owner) + if operation == "items": + if len(self.views) >= 16: + raise CredentialUseError(UseCode.LIMIT) + view_id = uuid4() + items = tuple(self._item(item) for item in vault.list_items()) + epoch = vault.status().unlock_epoch + if epoch is None: + raise CredentialUseError(UseCode.LOCKED) + self.views[view_id] = OwnerView( + peer.pid, + peer.created, + vault.profile.scope, + epoch, + time.monotonic() + 60, + items, + ) + return {**metadata_page(items, 0), "view_id": str(view_id)} + if operation == "activity": + return [ + { + "action": e.action, + "outcome": e.outcome, + "grant_id": str(e.grant_id) if e.grant_id else None, + } + for e in self.broker.audit_events()[-128:] + if e.scope == vault.profile.scope + ] + if operation == "revoke": + grant_id = UUID(payload["grant_id"]) + for launch in self.leases.values(): + if ( + launch.context.binding.scope == vault.profile.scope + and launch.job is not None + and launch.job.grant_id == grant_id + ): + launch.job.cancelled.set() + self.broker.revoke_scoped(grant_id, vault.profile.scope) + return {"revoked": True} + reference = CredentialReference( + vault.profile.scope, UUID(payload["credential_id"]), payload["version"] + ) + if operation == "delete": + vault.delete(reference) + return {"deleted": True} + if type(payload["replace_fields"]) is not bool: + raise CredentialUseError(UseCode.DENIED) + item = next( + (i for i in vault.list_items() if i.summary.reference == reference), + None, + ) + if item is None: + raise CredentialUseError(UseCode.CHANGED) + secret = None + if payload["replace_fields"]: + names = { + CredentialKind.LOGIN: ("username", "password"), + CredentialKind.API_KEY: ("api_key",), + }.get(item.summary.kind) + if names is None: + with vault._helper_record( + reference, vault.status().unlock_epoch + ) as record: + names = tuple(record.fields.reveal()) + secret = self.consent.entry(names) + return self._item( + vault.update( + reference, + service=payload["service"], + account_label=payload["account"], + fields=secret, + allowed_origins=tuple(payload["origins"]), + ) + ) + if operation in ("initialize", "lock", "entry"): + fields = ( + ("profile_id",) + if operation != "entry" + else ("profile_id", "kind", "service", "account", "origins", "fields") + ) + self._shape(payload, fields) + vault = self._profile(payload["profile_id"]) + self.consent.message = f"{operation.capitalize()} credentials in profile {vault.profile.label}?" + if operation == "lock": + vault.lock() + return {"state": "locked"} + if operation == "initialize": + vault.initialize() + return {"state": "ready"} + kind = CredentialKind(payload["kind"]) + from .identity import _label + from .records import origins + + _label(payload["service"]) + _label(payload["account"]) + origins(tuple(payload["origins"])) + names = { + CredentialKind.LOGIN: ("username", "password"), + CredentialKind.API_KEY: ("api_key",), + }.get(kind) + if names is None: + names = tuple(payload["fields"]) + import re + + if ( + not 1 <= len(names) <= 8 + or len(set(names)) != len(names) + or any( + not isinstance(name, str) + or not re.fullmatch(r"[a-z][a-z0-9_]{0,63}", name) + for name in names + ) + ): + raise CredentialUseError(UseCode.DENIED) + elif payload["fields"] != []: + raise CredentialUseError(UseCode.DENIED) + if vault.status().state is not VaultState.READY: + vault.unlock() + self.consent.message = f"Save {kind.value} for {payload['service']} / {payload['account']} in {vault.profile.label}?\nPermitted origins: {', '.join(payload['origins'])}" + secret = self.consent.entry(names) + item = vault.create( + kind, + payload["service"], + payload["account"], + secret, + allowed_origins=tuple(payload["origins"]), + ) + return {"credential_id": str(item.summary.reference.credential_id)} + if operation == "launch": + self._shape( + payload, + ("profile_id", "kind", "subject", "deployment", "files", "entry"), + ) + if len(self.leases) >= 16: + raise CredentialUseError(UseCode.LIMIT) + vault = self._profile(payload["profile_id"]) + revision = source_files(payload["files"], payload["entry"]) + kind = CallerKind(payload["kind"]) + instance = uuid4() + caller = CallerIdentity( + kind, + payload["subject"], + instance, + revision if kind is CallerKind.AGENT_APP else None, + AppDeployment(payload["deployment"]) + if kind is CallerKind.AGENT_APP + else None, + ) + if kind is not CallerKind.AGENT_APP and payload["deployment"] is not None: + raise CredentialUseError(UseCode.DENIED) + self.consent.message = f"Launch isolated {kind.value}?\nRequested label (unverified): {json.dumps(caller.subject_id)}\nProfile: {vault.profile.label}\nCode SHA256: {revision}\nClaimed deployment (unverified): {payload['deployment'] or 'agent'}\nApp name and deployment are caller claims, not trusted enrollment.\nCredential use requires a separate approval." + self.consent.verify(self.owner) + if not peer.alive(): + raise CredentialUseError(UseCode.DENIED) + process = IsolatedProcess( + self.state / "runs" / instance.hex, + self.root / "python" / "python.exe", + self.owner.os_identity, + peer.session, + ) + binding = controller = None + try: + for name, content in payload["files"].items(): + path = process.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + private_acl(path, process.app_sid) + client = self.root / "app" / "credentials" / "client.py" + (process.root / "credential_client.py").write_bytes(client.read_bytes()) + private_acl(process.root / "credential_client.py", process.app_sid) + # -I removes script dir. The protected generated entry adds only + # its immutable snapshot; it never imports the CraftBot checkout. + runner = process.root / "_host_entry.py" + runner.write_text( + "import sys, runpy\nfrom pathlib import Path\nsys.path.insert(0, str(Path(__file__).parent))\nrunpy.run_path(" + + repr(str(process.root / payload["entry"])) + + ", run_name='__main__')\n", + encoding="utf-8", + ) + private_acl(runner, process.app_sid) + binding = CallerBinding(uuid4(), caller, vault.profile.scope) + self.directory.bind(binding) + context = self.directory.resolve(binding.binding_id, caller, uuid4()) + process.start("_host_entry.py") + controller = Peer(peer.pid) + if controller.created != peer.created or not controller.alive(): + raise CredentialUseError(UseCode.DENIED) + self.leases[instance] = Launch( + process, + context, + controller, + time.monotonic() + 900, + snapshot_revision=revision, + ) + launch = self.leases[instance] + timer = Timer(900, self._expire, args=(instance, launch)) + timer.daemon = True + self.leases[instance].timer = timer + timer.start() + Thread(target=self._watch, args=(instance, launch), daemon=True).start() + except BaseException: + self.leases.pop(instance, None) + if binding is not None: + self.directory.revoke(binding.binding_id) + if controller is not None: + controller.close() + process.close() + raise + return { + "run_id": str(instance), + "revision": revision, + "identity_verified": False, + } + if operation in ( + "result", + "program_result", + "stop", + "response", + "stage_payload", + "reset_operation", + ): + self._shape( + payload, + ("run_id", "offset", "data", "done") + if operation == "stage_payload" + else ("run_id", "offset") + if operation == "response" + else ("run_id",), + ) + key = UUID(payload["run_id"]) + launch = self.leases.get(key) + if ( + launch is None + or launch.controller.pid != peer.pid + or launch.controller.created != peer.created + ): + raise CredentialUseError(UseCode.DENIED) + if operation == "stage_payload": + return self._stage(launch, payload) + if operation == "program_result": + if launch.result is not None: + return launch.result + if api()[6].WaitForSingleObject(launch.process.handle, 0) == 0: + return { + "exited": api()[3].GetExitCodeProcess(launch.process.handle) + } + return {"pending": True} + if operation == "reset_operation": + if launch.job is None or not launch.job.done.is_set(): + raise CredentialUseError(UseCode.DENIED) + launch.job.close() + launch.job, launch.credential_output, launch.result = None, None, None + return {"ready": True} + if operation == "response": + if launch.job is not None: + return launch.job.snapshot(payload["offset"]) + output = launch.credential_output + offset = payload["offset"] + if ( + output is None + or type(offset) is not int + or not 0 <= offset <= len(output.body) + ): + raise CredentialUseError(UseCode.DENIED) + return {"chunk": output.body[offset : offset + 4000]} + if operation == "result": + if launch.job is not None: + snapshot = launch.job.snapshot() + if snapshot["error"]: + raise CredentialUseError(UseCode(snapshot["error"])) + if snapshot["status"] is not None: + return { + "response": { + key: snapshot[key] + for key in ("status", "content_type", "length", "done") + } + } + if snapshot["done"]: + result = launch.job.result + return { + "result": { + "outcome": result.value + if result is not None + else "unavailable" + } + } + if launch.credential_output is not None: + output = launch.credential_output + return { + "response": { + "status": output.status, + "content_type": output.content_type, + "length": len(output.body), + } + } + if launch.result is not None: + return launch.result + if api()[6].WaitForSingleObject(launch.process.handle, 0) == 0: + return { + "exited": api()[3].GetExitCodeProcess(launch.process.handle) + } + return {"pending": True} + self._expire(key, launch) + return {"stopped": True} + raise CredentialUseError(UseCode.DENIED) + + @staticmethod + def _item(item): + summary = item.summary + return { + "credential_id": str(summary.reference.credential_id), + "version": summary.reference.version, + "kind": summary.kind.value, + "service": summary.service, + "account": summary.account_label, + "origins": list(item.allowed_origins), + "updated_at": item.updated_at, + } + + +def resolve_envelope(envelope): + if ( + type(envelope) is not dict + or set(envelope) + != {"peer_pid", "peer_created", "peer_sid", "peer_app_sid", "payload"} + or type(envelope["peer_pid"]) is not int + ): + raise CredentialUseError(UseCode.DENIED) + peer = Peer(envelope["peer_pid"]) + try: + if ( + str(peer.created) != envelope["peer_created"] + or not peer.alive() + or peer.sid != envelope["peer_sid"] + or peer.app_sid != envelope["peer_app_sid"] + ): + raise CredentialUseError(UseCode.DENIED) + except BaseException: + peer.close() + raise + return peer + + +def main(root: Path): + # No environment opt-out or developer bypass. SCM/SYSTEM is mandatory. + import win32con + import win32security + import ctypes + + buffer = ctypes.create_unicode_buffer(32768) + if ctypes.windll.shell32.SHGetFolderPathW(0, 0x23, None, 0, buffer) != 0: + raise IsolationUnavailable() + state = Path(buffer.value) / "CraftBotCredentialHost" + check_install(root, state) + with closing( + win32security.OpenProcessToken(api()[0].GetCurrentProcess(), 0x28) + ) as token: + win32security.AdjustTokenPrivileges( + token, + False, + [ + ( + win32security.LookupPrivilegeValue(None, name), + win32con.SE_PRIVILEGE_ENABLED, + ) + for name in ( + "SeTcbPrivilege", + "SeAssignPrimaryTokenPrivilege", + "SeIncreaseQuotaPrivilege", + "SeRestorePrivilege", + ) + ], + ) + host = ProtectedHost( + root, state, json.loads((root / "host.json").read_text(encoding="utf-8-sig")) + ) + for line in sys.stdin: + peer = None + try: + if len(line) > 400001: # Native envelope limit plus trailing newline. + raise CredentialUseError(UseCode.DENIED) + envelope = json.loads(line) + peer = resolve_envelope(envelope) + value = host.dispatch(peer, envelope["payload"]) + response = {"ok": True, "result": value} + except CredentialUseError as error: + response = {"ok": False, "error": error.code.value} + except BaseException: + response = {"ok": False, "error": UseCode.UNAVAILABLE.value} + finally: + if peer is not None: + peer.close() + print(json.dumps(response, ensure_ascii=True), flush=True) diff --git a/app/credentials/identity.py b/app/credentials/identity.py new file mode 100644 index 000000000..8b6e4ed13 --- /dev/null +++ b/app/credentials/identity.py @@ -0,0 +1,143 @@ +"""CM-01 identity model, independent of browser owners and agent bundles. + +All records are host-owned metadata. Never deserialize a CallerBinding from +agent/tool input: UUIDs and immutable records are identifiers, not credentials. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +import unicodedata +from uuid import UUID + + +def _uuid(value: UUID) -> None: + if not isinstance(value, UUID) or value.int == 0: + raise ValueError("A nonzero UUID is required") + + +def _label(value: str) -> None: + if ( + not isinstance(value, str) + or not value.strip() + or len(value) > 256 + or any( + unicodedata.category(char) in ("Cc", "Cf", "Cs", "Zl", "Zp") + for char in value + ) + ): + raise ValueError("A nonempty label of at most 256 characters is required") + + +@dataclass(frozen=True) +class VaultOwner: + """Human owner provisioned against a verified local OS account. + + os_identity is a platform-qualified stable account identifier, not a display + name, email, Telegram owner flag, session id, or environment variable. + CM-01 does not implement OS verification or owner provisioning. + """ + + owner_id: UUID + os_identity: str = field(repr=False) + + def __post_init__(self) -> None: + _uuid(self.owner_id) + _label(self.os_identity) + + +@dataclass(frozen=True) +class VaultScope: + owner_id: UUID + profile_id: UUID + + def __post_init__(self) -> None: + _uuid(self.owner_id) + _uuid(self.profile_id) + + +@dataclass(frozen=True) +class VaultProfile: + """Credential namespace; labels are not unique or security identifiers.""" + + scope: VaultScope + label: str + + def __post_init__(self) -> None: + if not isinstance(self.scope, VaultScope): + raise ValueError("A vault scope is required") + _label(self.label) + + +class CallerKind(str, Enum): + AGENT_SESSION = "agent_session" + SUBAGENT = "subagent" + AGENT_APP = "agent_app" + + +class AppDeployment(str, Enum): + LIVE = "live" + DEVELOPMENT = "development" + + +@dataclass(frozen=True) +class CallerIdentity: + """Host binding identity, pinned to an authenticated isolated process. + + instance_id rotates when the authenticated execution channel is replaced. + Agent Apps additionally bind the actual immutable snapshot hash. Subject, + kind and deployment are unverified caller labels, never trusted enrollment. + Neither a manifest capability nor knowing these values is authentication. + """ + + kind: CallerKind + subject_id: str + instance_id: UUID + app_revision: str | None = None + deployment: AppDeployment | None = None + + def __post_init__(self) -> None: + if not isinstance(self.kind, CallerKind): + raise ValueError("A caller kind is required") + _label(self.subject_id) + _uuid(self.instance_id) + if self.kind is CallerKind.AGENT_APP: + _label(self.app_revision) + if not isinstance(self.deployment, AppDeployment): + raise ValueError("An app deployment is required") + elif self.app_revision is not None or self.deployment is not None: + raise ValueError("App identity fields require an Agent App caller") + + +@dataclass(frozen=True) +class CallerBinding: + """Host-selected profile for an authenticated consumer channel. + + Multiple channels may bind the same app/session to different profiles, but + a channel's binding never changes underneath an in-flight request. + """ + + binding_id: UUID + caller: CallerIdentity + scope: VaultScope + + def __post_init__(self) -> None: + _uuid(self.binding_id) + if not isinstance(self.caller, CallerIdentity) or not isinstance( + self.scope, VaultScope + ): + raise ValueError("A caller identity and vault scope are required") + + +@dataclass(frozen=True) +class ExecutionContext: + """Out-of-band host context, never a tool's session/profile claims.""" + + binding: CallerBinding + execution_id: UUID + + def __post_init__(self) -> None: + if not isinstance(self.binding, CallerBinding): + raise ValueError("A host caller binding is required") + _uuid(self.execution_id) diff --git a/app/credentials/integrations.py b/app/credentials/integrations.py new file mode 100644 index 000000000..5841e7d46 --- /dev/null +++ b/app/credentials/integrations.py @@ -0,0 +1,435 @@ +"""Host adapter for source accounts. No raw source credential enters a provider.""" + +import asyncio +import json + +from .client import CredentialHostUnavailable +from .invocation import invoke +from .references import parse +from types import SimpleNamespace + + +METHODS = { + "figma": {"get_current_user": ("get_figma_current_user", (), {})}, + "slack": { + "list_channels": ( + "list_slack_channels", + ("types", "limit", "exclude_archived"), + { + "types": "public_channel,private_channel", + "limit": 100, + "exclude_archived": True, + }, + ), + "get_channel_info": ("get_slack_channel_info", ("channel",), {}), + "get_channel_history": ( + "get_slack_channel_history", + ("channel", "limit", "oldest", "latest"), + {"limit": 100}, + ), + "list_users": ("list_slack_users", ("limit",), {"limit": 100}), + "get_user_info": ("get_slack_user_info", ("user_id",), {}), + "get_thread_replies": ( + "get_slack_thread_replies", + ("channel", "ts", "limit", "cursor"), + {"limit": 100}, + ), + }, +} + + +class SourceOperationFacade: + """A named-operation interface with references only; no SDK/auth internals.""" + + def __init__(self, provider, reference, metadata): + parse(reference) + self._provider, self._reference = provider, reference + self.cred = SimpleNamespace( + **{ + key: metadata[key] + for key in ("user_id", "username", "workspace_id") + if key in metadata + } + ) + + def __getattr__(self, method): + rule = METHODS.get(self._provider, {}).get(method) + if rule is None: + raise AttributeError("credential_operation_helper_unavailable") + + def call(*args, **kwargs): + operation, parameters, defaults = rule + if len(args) > len(parameters) or set(parameters[: len(args)]) & set( + kwargs + ): + raise TypeError("credential_operation_arguments_invalid") + data = {**defaults, **dict(zip(parameters, args)), **kwargs} + if self._provider == "slack" and method == "get_user_info": + data["user"] = data.pop("user_id") + response = invoke( + self._reference, + f"integration_{self._provider}", + operation, + data or None, + subject=f"{self._provider}: {operation}", + ) + if not 200 <= response["status"] < 300: + return {"error": "credential_operation_failed"} + result = json.loads(response["body"]) + return ( + {"ok": True, "result": result} if self._provider == "figma" else result + ) + + if self._provider == "figma": + + async def async_call(*args, **kwargs): + return await asyncio.to_thread(call, *args, **kwargs) + + return async_call + return call + + +class RoutedSourceClient: + """Run the unchanged provider's normalizers with a dummy, unauthenticated client. + + Context routing only selects a transport. Authority remains the protected + host's real process binding and native consent, never the ContextVar. + """ + + def __init__(self, provider, reference, metadata): + self.provider, self.reference = provider.id, reference + self.sessions = {} + fake = { + **metadata, + **{ + key: "vault-managed" + for key in ( + "access_token", + "token", + "bot_token", + "api_key", + "personal_api_key", + "secret_key", + "channel_access_token", + ) + }, + } + if provider.id in ("figma", "supabase"): + fake["auth_kind"] = "pat" + if provider.id == "twitter": + fake.update( + api_key="vault-managed", + api_secret="vault-managed", + access_token=metadata.get("access_token", ""), + access_token_secret=metadata.get("access_token_secret", ""), + ) + if provider.id == "jira": + fake.update( + access_token="", + cloud_id="", + email="vault-managed@example.invalid", + api_token="vault-managed", + ) + self.client = provider.build_client(fake, lambda updated: None) + + def _request( + self, + method, + url, + *, + cancelled, + headers=None, + json=None, + params=None, + data=None, + files=None, + content=None, + repeat=False, + **transport_options, + ): + import httpx + from .model_transport import installed_model_helpers, multipart_data + from .route_policy import match_route + from urllib.parse import urlencode + + routes = [ + r + for r in installed_model_helpers() + if r["helper"] == "integration_" + self.provider + ] + if params: + if self.provider == "telegram_bot" and isinstance(params, dict): + import json as json_module + + params = { + k: json_module.dumps(v) if isinstance(v, list) else v + for k, v in params.items() + } + url += ("&" if "?" in url else "?") + urlencode(params, doseq=True) + if self.provider == "telegram_bot": + url = url.replace("/botvault-managed/", "/bot_TOKEN_/") + matched = match_route(routes, method.upper(), url) + if matched is None: + raise CredentialHostUnavailable("credential_destination_denied") + route, path, query = matched + body = json if json is not None else data + if content is not None: + import base64 + + if route.get("request_format") != "image" or not isinstance(content, bytes): + raise CredentialHostUnavailable("credential_request_invalid") + body = { + "data": base64.b64encode(content).decode(), + "content_type": (headers or {}).get("Content-Type"), + } + if files: + request = httpx.Request(method, url, data=data, files=files) + body = multipart_data(request.read(), request.headers["Content-Type"]) + payload = ( + { + "path": path, + "query": query, + "data": body, + "headers": { + k: v + for k, v in (headers or {}).items() + if k in route.get("header_fields", ()) + }, + } + if route.get("envelope") + else query + if method.upper() == "GET" + else body + ) + if self.provider == "twitter": + payload["session"] = { + name: getattr(self.client._cred, name) + for name in ("access_token", "access_token_secret") + } + arguments = ( + self.reference, + route["helper"], + route["operation"], + payload, + ) + if repeat and method.upper() == "GET": + from .operation_session import OperationSession + import time + + key = route["operation"] + session = self.sessions.get(key) + if session is None or session.closed or time.monotonic() >= session.expires: + if session is not None: + session.close() + session = OperationSession( + *arguments[:3], self.provider + " listener: " + key + ) + self.sessions[key] = session + result = session.invoke(payload, cancelled=cancelled) + else: + result = invoke( + *arguments, + subject=self.provider + ": " + route["operation"], + cancelled=cancelled, + ) + if route.get("response_format") == "binary" and 200 <= result["status"] < 300: + import base64 + import json as json_module + + binary = json_module.loads(result["body"]) + result = { + **result, + "body": base64.b64decode(binary["data"], validate=True), + "content_type": binary["media_type"], + } + return httpx.Response( + result["status"], + content=result["body"].encode() + if isinstance(result["body"], str) + else result["body"], + headers={"Content-Type": result["content_type"]}, + ) + + def make_source_listener(self, provider, cursor, emit): + from .listeners import SourceListener + + return SourceListener(self, provider, cursor, emit) + + def close_sessions(self): + for session in self.sessions.values(): + session.close() + self.sessions.clear() + + def __getattr__(self, name): + if name in ("_headers", "_bot_headers", "_load", "_ensure_token"): + raise AttributeError("credential_auth_client_unavailable") + value = getattr(self.client, name) + if self.provider == "discord" and name.startswith("user_"): + return value # Existing generated user session retains its transport. + if not callable(value): + return value + import inspect + from threading import Event + from functools import partial + from craftos_integrations.helpers.source_requests import using + + if inspect.iscoroutinefunction(value): + + async def asynchronous(*args, **kwargs): + cancelled = Event() + try: + with using(partial(self._request, cancelled=cancelled)): + return await value(*args, **kwargs) + except asyncio.CancelledError: + cancelled.set() + raise + + return asynchronous + + def synchronous(*args, **kwargs): + with using(partial(self._request, cancelled=Event())): + return value(*args, **kwargs) + + return synchronous + + +class SavedSourceOperations: + def __init__(self, providers=()): + self.providers = {p.id: p for p in providers} + + def client_for(self, provider_id, reference, metadata): + if provider_id in self.providers: + return RoutedSourceClient(self.providers[provider_id], reference, metadata) + return SourceOperationFacade(provider_id, reference, metadata) + + def connect(self, provider_id, reference, *, public_metadata=None): + parse(reference) + public_metadata = public_metadata or {} + metadata_fields = { + "whatsapp_business": {"phone_number_id"}, + "twitter": {"access_token", "access_token_secret"}, + } + if public_metadata and set(public_metadata) != metadata_fields.get( + provider_id, set() + ): + raise CredentialHostUnavailable("credential_account_verification_failed") + body = None + if provider_id == "twitter": + if set(public_metadata) != metadata_fields[provider_id]: + raise CredentialHostUnavailable( + "credential_account_verification_failed" + ) + body = {"path": {}, "query": {}, "data": None, "session": public_metadata} + if provider_id == "whatsapp_business": + import re + + phone = public_metadata.get("phone_number_id") + if not isinstance(phone, str) or not re.fullmatch(r"[0-9]{5,32}", phone): + raise CredentialHostUnavailable( + "credential_account_verification_failed" + ) + body = {"path": {"phone_number_id": phone}, "query": {}, "data": None} + response = invoke( + reference, + f"integration_{provider_id}", + "verify", + body, + subject=f"Connect {provider_id} saved account", + ) + if response["status"] != 200: + raise CredentialHostUnavailable("credential_account_verification_failed") + data = json.loads(response["body"]) + if type(data) is not dict or data.get("ok") is False: + raise CredentialHostUnavailable("credential_account_verification_failed") + if provider_id == "twitter": + identity = data.get("data", {}).get("id") + metadata = { + "auth_kind": "vault", + "user_id": identity, + "username": data.get("data", {}).get("username", ""), + **public_metadata, + } + elif provider_id == "github": + identity = data.get("login") + metadata = {"auth_kind": "vault", "username": identity} + elif provider_id == "figma": + identity = str(data["id"]) if type(data.get("id")) in (str, int) else None + metadata = {"auth_kind": "vault", "user_id": identity} + elif provider_id == "slack" and data.get("ok") is True: + identity = data.get("team_id") + metadata = { + "auth_kind": "vault", + "workspace_id": identity, + "user_id": data.get("user_id"), + } + else: + if provider_id not in self.providers: + raise CredentialHostUnavailable( + "credential_account_verification_failed" + ) + if provider_id == "telegram_bot": + data = data.get("result", {}) + identity = str( + data.get("id") + or data.get("accountId") + or data.get("portalId") + or data.get("userId") + or data.get("account_id") + or data.get("workspace_id") + or "vault-" + str(parse(reference).credential_id) + ) + metadata = {"auth_kind": "vault", "account_id": identity} + if provider_id == "whatsapp_business": + if identity != public_metadata["phone_number_id"]: + raise CredentialHostUnavailable( + "credential_account_verification_failed" + ) + metadata["phone_number_id"] = identity + from .model_transport import installed_model_helpers + from urllib.parse import urlsplit + + route = next( + ( + r + for r in installed_model_helpers() + if r["helper"] == "integration_" + provider_id + and r["operation"] == "verify" + ), + None, + ) + if route: + origin = urlsplit(route["endpoint"]) + metadata.update( + host=f"https://{origin.netloc}", + domain=origin.netloc, + site_url=f"https://{origin.netloc}", + ) + for field in ("bot_id", "bot_user_id", "hub_id", "user_id"): + metadata[field] = identity + if isinstance(data.get("username"), str): + metadata["bot_username"] = data["username"] + if not isinstance(identity, str) or not identity or len(identity) > 256: + raise CredentialHostUnavailable("credential_account_verification_failed") + return identity, metadata + + async def execute(self, provider_id, operation, reference, input_data): + # UI shaping flags never become arbitrary provider query parameters. + data = dict(input_data) + data.pop("include_metadata", None) + if provider_id == "figma" and operation == "get_figma_current_user": + data = None + from .invocation import invoke_async + + response = await invoke_async( + reference, + f"integration_{provider_id}", + operation, + data, + subject=f"{provider_id}: {operation}", + ) + if not 200 <= response["status"] < 300: + return {"error": "credential_operation_failed"} + result = json.loads(response["body"]) + if provider_id == "slack" and result.get("ok") is not True: + return {"error": "credential_operation_failed"} + return {"status": "success", "result": result} diff --git a/app/credentials/invocation.py b/app/credentials/invocation.py new file mode 100644 index 000000000..619f80ab6 --- /dev/null +++ b/app/credentials/invocation.py @@ -0,0 +1,216 @@ +"""Host-blind controllers execute fixed operations through an isolated worker. + +Only source references, request data and sanitized responses cross this module. +The native host confirms launch, then independently confirms the one-use grant. +The public SDK never obtains a raw credential or an authenticated HTTP client. +""" + +import json +import hashlib +import time + +from .client import CredentialHostUnavailable, request +from .references import parse + + +def open_response( + reference: str, + helper: str, + operation: str, + body=None, + *, + subject="credential operation", + cancelled=None, +): + ref = parse(reference) + serialized = json.dumps( + body, ensure_ascii=True, allow_nan=False, separators=(",", ":") + ) + if len(serialized) > 32_000_000: + raise CredentialHostUnavailable("credential_use_limit") + data = { + "helper": helper, + "operation": operation, + "credential_id": str(ref.credential_id), + "credential_version": ref.version, + } + source = ( + "import credential_client, time\ndata = " + + repr(data) + + "\nexpected = " + + repr(hashlib.sha256(serialized.encode("ascii")).hexdigest()) + + """ +try: + deadline = time.monotonic() + 120 + while time.monotonic() < deadline: + info = credential_client.request('payload_info') + if info['ready']: + break + time.sleep(0.1) + if not info['ready'] or info['sha256'] != expected: + raise RuntimeError() + approval = credential_client.request('request_use', **data) + credential_client.request('start_use_staged', capability=approval['capability']) + while not credential_client.request('operation_status')['done']: + time.sleep(0.1) + credential_client.complete({'outcome': 'completed'}) +except Exception: + credential_client.complete({'error': 'credential_operation_failed'}) +""" + ) + if len(json.dumps({"call.py": source}).encode("utf-8")) > 47000: + raise CredentialHostUnavailable("credential_use_limit") + launch = request( + "launch", + profile_id=str(ref.profile_id), + kind="agent_session", + subject=subject, + deployment=None, + files={"call.py": source}, + entry="call.py", + ) + try: + for offset in range(0, len(serialized), 8000): + chunk = serialized[offset : offset + 8000] + request( + "stage_payload", + run_id=launch["run_id"], + offset=offset, + data=chunk, + done=offset + len(chunk) == len(serialized), + ) + deadline = time.monotonic() + 900 # Includes interactive owner prompts. + while time.monotonic() < deadline: + if cancelled is not None and cancelled.is_set(): + raise CredentialHostUnavailable("credential_use_interrupted") + result = request("result", run_id=launch["run_id"]) + if "response" in result: + response = result["response"] + length = response["length"] + if type(length) is not int or not 0 <= length <= 96_000_000: + raise CredentialHostUnavailable("credential_response_invalid") + return CredentialResponse(launch["run_id"], response, cancelled) + if ( + "result" in result + and type(result["result"]) is dict + and result["result"].get("outcome") + in ("accepted", "rejected", "unavailable") + ): + outcome = result["result"]["outcome"] + request("stop", run_id=launch["run_id"]) + return StaticResponse( + {"accepted": 200, "rejected": 401, "unavailable": 503}[outcome], + json.dumps({"outcome": outcome}), + ) + if "result" in result or "exited" in result: + raise CredentialHostUnavailable("credential_operation_failed") + time.sleep(0.15) + raise CredentialHostUnavailable("credential_operation_timeout") + except BaseException: + try: + request("stop", run_id=launch["run_id"]) + except CredentialHostUnavailable: + pass + raise + + +class CredentialResponse: + def __init__(self, run_id, metadata, cancelled=None, *, stop_on_close=True): + self.run_id, self.status, self.content_type = ( + run_id, + metadata["status"], + metadata["content_type"], + ) + self.initial, self.cancelled, self.closed = metadata, cancelled, False + self.stop_on_close = stop_on_close + + def __iter__(self): + offset, deadline = 0, time.monotonic() + 900 + try: + while time.monotonic() < deadline: + if self.cancelled is not None and self.cancelled.is_set(): + raise CredentialHostUnavailable("credential_use_interrupted") + result = request("response", run_id=self.run_id, offset=offset) + if result.get("error"): + raise CredentialHostUnavailable("credential_operation_failed") + chunk = result["chunk"] + if not isinstance(chunk, str) or len(chunk) > 8000: + raise CredentialHostUnavailable("credential_response_invalid") + if chunk: + offset += len(chunk) + if offset > 96_000_000: + raise CredentialHostUnavailable("credential_use_limit") + yield chunk + done = result.get("done", self.initial.get("done", True)) + length = result.get("length", self.initial["length"]) + if done and offset >= length: + return + if not chunk: + time.sleep(0.025) + raise CredentialHostUnavailable("credential_operation_timeout") + finally: + self.close() + + def close(self): + if not self.closed: + self.closed = True + if self.stop_on_close: + try: + request("stop", run_id=self.run_id) + except CredentialHostUnavailable: + pass + + +class StaticResponse: + content_type = "application/json" + + def __init__(self, status, body): + self.status, self.body = status, body + + def __iter__(self): + yield self.body + + def close(self): + pass + + +def invoke( + reference, + helper, + operation, + body=None, + *, + subject="credential operation", + cancelled=None, +): + response = open_response( + reference, helper, operation, body, subject=subject, cancelled=cancelled + ) + try: + return { + "status": response.status, + "content_type": response.content_type, + "body": "".join(response), + } + finally: + response.close() + + +async def invoke_async(*args, **kwargs): + import asyncio + from threading import Event + + cancelled = Event() + task = asyncio.create_task( + asyncio.to_thread(invoke, *args, **kwargs, cancelled=cancelled) + ) + try: + return await asyncio.shield(task) + except asyncio.CancelledError: + cancelled.set() + # The host closes an active socket when stop reaches the control pipe. + # Dispatched provider effects cannot be retracted. + task.add_done_callback( + lambda future: future.exception() if not future.cancelled() else None + ) + raise diff --git a/app/credentials/keys.py b/app/credentials/keys.py new file mode 100644 index 000000000..7140851cf --- /dev/null +++ b/app/credentials/keys.py @@ -0,0 +1,101 @@ +"""Host key facility and owner-verification ports; no raw-file fallback. + +Windows stores the encryption key as a generic OS credential, NOT beside the +vault. Same-user hostile code still requires the CM-03 isolation boundary. +""" + +from __future__ import annotations + +import sys +from typing import Protocol + +from .errors import VaultError, VaultErrorCode as Code +from .identity import VaultOwner, VaultScope +from .secrets import Secret + + +def key_target(scope: VaultScope) -> str: + return f"CraftBot/Vault/v1/{scope.owner_id}/{scope.profile_id}" + + +class KeyStore(Protocol): + """Internal OS adapter. create is called under the profile write lock.""" + + def load(self, scope: VaultScope) -> Secret | None: ... + + def create(self, scope: VaultScope, key: Secret) -> None: ... + + +class OwnerVerifier(Protocol): + """Must perform real owner verification; caller flags are not evidence.""" + + def verify(self, owner: VaultOwner) -> None: ... + + +class VerificationUnavailable: + def verify(self, owner: VaultOwner) -> None: + raise VaultError(Code.VERIFICATION_REQUIRED) + + +class WindowsCredentialKeyStore: + """Uses the already-declared Windows pywin32 dependency, loaded lazily. + + No credential enumeration, user login-password access or key deletion. + A missing Windows/logon/backend fails closed. Keys stay device-bound. + """ + + @staticmethod + def _api(): + if sys.platform != "win32": + raise VaultError(Code.BACKEND_UNAVAILABLE) + try: + import win32cred + + return win32cred + except ImportError: + raise VaultError(Code.BACKEND_UNAVAILABLE) from None + + def load(self, scope: VaultScope) -> Secret | None: + api = self._api() + try: + record = Secret(api.CredRead(key_target(scope), api.CRED_TYPE_GENERIC, 0)) + except Exception as exc: + if getattr(exc, "winerror", None) == 1168: # ERROR_NOT_FOUND only + return None + raise VaultError(Code.BACKEND_UNAVAILABLE) from None + key = Secret(record.reveal().get("CredentialBlob")) + # pywin32 writes generic CredentialBlob strings as UTF-16LE and reads + # bytes back. Fernet itself takes a 44-byte ASCII base64 key. + if not isinstance(key.reveal(), bytes) or len(key.reveal()) != 88: + raise VaultError(Code.UNREADABLE) + try: + key = Secret(key.reveal().decode("utf-16-le").encode("ascii")) + except UnicodeError: + raise VaultError(Code.UNREADABLE) from None + if len(key.reveal()) != 44: + raise VaultError(Code.UNREADABLE) + return key + + def create(self, scope: VaultScope, key: Secret) -> None: + if ( + not isinstance(key, Secret) + or not isinstance(key.reveal(), bytes) + or len(key.reveal()) != 44 + ): + raise VaultError(Code.INVALID) + if self.load(scope) is not None: + raise VaultError(Code.CONFLICT) + api = self._api() + try: + api.CredWrite( + { + "Type": api.CRED_TYPE_GENERIC, + "TargetName": key_target(scope), + "UserName": "CraftBot vault key", + "CredentialBlob": key.reveal().decode("ascii"), + "Persist": api.CRED_PERSIST_LOCAL_MACHINE, + }, + 0, + ) + except Exception: + raise VaultError(Code.BACKEND_UNAVAILABLE) from None diff --git a/app/credentials/listeners.py b/app/credentials/listeners.py new file mode 100644 index 000000000..8099716aa --- /dev/null +++ b/app/credentials/listeners.py @@ -0,0 +1,93 @@ +"""Source listeners retain normal event shaping; their transport has no sources.""" + +import asyncio +import json +from functools import partial +from threading import Event + +from craftos_integrations.helpers.source_requests import using +from .invocation import open_response + + +class SourceListener: + def __init__(self, source, provider, cursor, emit): + self.source, self.provider, self.emit = source, provider, emit + self.cancelled, self.task = Event(), None + self.inner = ( + None + if provider.id == "discord" + else provider.make_listener(source.client, cursor, emit) + ) + self.poll_interval = getattr(self.inner, "poll_interval", 3) + + async def start(self): + if self.provider.id == "discord": + if self.task is None: + self.task = asyncio.create_task(self._discord()) + if self.task.done(): + await self.task + return + if self.inner is not None: + with using( + partial(self.source._request, cancelled=self.cancelled, repeat=True) + ): + await self.inner.start() + + async def stop(self): + self.cancelled.set() + try: + if self.task is not None: + self.task.cancel() + try: + await self.task + except asyncio.CancelledError: + pass + if self.inner is not None: + await self.inner.stop() + finally: + await asyncio.to_thread(self.source.close_sessions) + + def cursor(self): + return self.inner.cursor() if self.inner is not None else None + + async def _discord(self): + from craftos_integrations.providers._shared import emit_callback + + client = self.source.client + client._message_callback = emit_callback(self.emit) + response = None + try: + response = await asyncio.to_thread( + open_response, + self.source.reference, + "integration_discord", + "gateway_events", + subject="Discord listener", + cancelled=self.cancelled, + ) + iterator, buffer = iter(response), "" + while not self.cancelled.is_set(): + part = await asyncio.to_thread(next, iterator, None) + if part is None: + break + buffer += part + while "\n\n" in buffer: + frame, buffer = buffer.split("\n\n", 1) + data = json.loads(frame.removeprefix("data: ")) + if data.get("t") == "READY": + client._bot_user_id = data["d"]["user"]["id"] + client._catchup_done = True + elif data.get("t") == "MESSAGE_CREATE": + with using( + partial( + self.source._request, + cancelled=self.cancelled, + repeat=True, + ) + ): + await client._handle_message_create(data["d"]) + raise RuntimeError("credential_listener_expired") + finally: + self.cancelled.set() + if response is not None: + await asyncio.to_thread(response.close) diff --git a/app/credentials/media_clients.py b/app/credentials/media_clients.py new file mode 100644 index 000000000..cf623d8b6 --- /dev/null +++ b/app/credentials/media_clients.py @@ -0,0 +1,65 @@ +"""Bounded inline video understanding; ordinary file bytes, never vault values.""" + +import base64 +import mimetypes +from pathlib import Path +from .references import parse +from .model_transport import requests_response + + +def analyse_video(reference, filename, model, prompt): + parse(reference) + path = Path(filename) + media_type = mimetypes.guess_type(path.name)[0] + if media_type not in ( + "video/mp4", + "video/quicktime", + "video/x-msvideo", + "video/webm", + "video/mpeg", + ): + raise ValueError("credential_video_format_unsupported") + with path.open("rb") as source: + data = source.read(23_000_001) + if len(data) > 23_000_000: + # The existing caller retains its keyframe fallback for larger inputs. + raise ValueError("credential_video_input_limit") + payload = { + "contents": [ + { + "role": "user", + "parts": [ + { + "inlineData": { + "mimeType": media_type, + "data": base64.b64encode(data).decode(), + } + }, + {"text": prompt}, + ], + } + ] + } + response = requests_response( + "gemini", + reference, + "POST", + "https://generativelanguage.googleapis.com/v1beta/models/" + + model + + ":generateContent", + payload, + ) + response.raise_for_status() + result = response.json() + if result.get("promptFeedback", {}).get("blockReason"): + raise ValueError("credential_video_no_content") + for candidate in result.get("candidates", []): + if candidate.get("finishReason") == "SAFETY": + continue + parts = (candidate.get("content") or {}).get("parts", []) + text = "".join( + part.get("text", "") for part in parts if isinstance(part, dict) + ).strip() + if text: + return text + raise ValueError("credential_video_no_content") diff --git a/app/credentials/migration.py b/app/credentials/migration.py new file mode 100644 index 000000000..2e3b33018 --- /dev/null +++ b/app/credentials/migration.py @@ -0,0 +1,739 @@ +"""Host-only, resumable source migration. Never import this into a consumer. + +Destination writes precede the single source commit. The host storage adapter +must compare the source revision and atomically publish the reference document; +failure leaves the old source intact and retries reuse the same destination IDs. +No plaintext rollback file or raw-secret-returning resolver is produced here. +""" + +from __future__ import annotations + +import copy +from dataclasses import dataclass +from uuid import uuid5 + +from .catalog import model_helpers +from .contracts import CredentialKind +from .errors import VaultError, VaultErrorCode as Code, sanitized_errors +from .identity import _label +from .references import SourceReference, is_reference +from .secrets import Secret +from .records import origins as validate_origins, validate_fields +from .vault import SourceImport + + +@dataclass(frozen=True) +class MigrationResult: + imported: int + retained_references: int + review_required: int = 0 + retained_derived: int = 0 + + +def classify_integration(provider: str, credential: dict) -> str: + """Provider/provenance classification; never guess from a 'token' key.""" + if "source_reference" in credential: + return "reference" + if provider == "figma": + return {"pat": "source", "oauth": "derived"}.get( + credential.get("auth_kind"), "review_required" + ) + if provider == "github": + return "source" # This repository's GitHub provider is PAT-only. + if provider in {"stripe", "posthog", "telegram_bot", "line"}: + return "source" + if provider == "discord": + return "source" if credential.get("bot_token") else "derived" + if provider in {"supabase", "hubspot"}: + return {"pat": "source", "private_app": "source", "oauth": "derived"}.get( + credential.get("auth_kind"), "review_required" + ) + if provider in { + "gmail", + "google_calendar", + "google_docs", + "google_drive", + "google_youtube", + "outlook", + "whatsapp_web", + "telegram_user", + }: + return "derived" + # OAuth-generated Slack bot credentials and manually entered Slack bot + # credentials can share the same fields. Missing provenance requires review. + return "review_required" + + +@sanitized_errors +def migrate_model_document( + vault, source_id: str, document: Secret, commit, *, helper_catalog=None +): + """Trusted compare-and-swap adapter receives the new Secret document only. + + This callable is host-internal, never a registered helper or a consumer + callback. Unknown providers, malformed pools and static AWS credentials + abort before any source retirement; token/session sections are untouched. + """ + _label(source_id) + if not isinstance(document, Secret) or type(document.reveal()) is not dict: + raise VaultError(Code.INVALID) + candidate = Secret(copy.deepcopy(document.reveal())) + aws = candidate.reveal().get("aws_credentials", {}) + if type(aws) is not dict: + raise VaultError(Code.INVALID) + origins = {} + aws_origins = set() + from urllib.parse import urlsplit + + for helper in model_helpers() if helper_catalog is None else helper_catalog: + if not helper["helper"].startswith("model_"): + continue + provider = helper["helper"][len("model_") :] + endpoint = urlsplit(helper["endpoint"]) + if helper.get("protocol") == "aws_bedrock": + aws_origins.add(f"https://{endpoint.netloc}") + origins.setdefault("google" if provider == "gemini" else provider, set()).add( + f"https://{endpoint.netloc}" + ) + jobs, retained = [], 0 + for block, pool in (("api_keys", False), ("extra_api_keys", True)): + entries = candidate.reveal().get(block, {}) + if type(entries) is not dict: + raise VaultError(Code.INVALID) + for provider, values in entries.items(): + if not isinstance(provider, str): + raise VaultError(Code.INVALID) + if pool and type(values) is not list: + raise VaultError(Code.INVALID) + for index, value in enumerate(values if pool else [values]): + if not isinstance(value, str): + raise VaultError(Code.INVALID) + if not value: + continue + if is_reference(value): + retained += 1 + continue + if provider not in origins: + raise VaultError(Code.INVALID) + locator = f"{block}/{provider}/{index}" + jobs.append( + ( + block, + provider, + index, + Secret(value), + locator, + tuple(sorted(origins[provider])), + ) + ) + imports = list( + SourceImport( + uuid5(vault.profile.scope.profile_id, source_id + "/" + locator), + CredentialKind.API_KEY, + provider, + "Primary" if block == "api_keys" else f"Pool {index + 1}", + Secret({"api_key": value.reveal()}), + allowed, + ) + for block, provider, index, value, locator, allowed in jobs + ) + aws_import = False + if aws.get("source_reference"): + if not is_reference(aws["source_reference"]): + raise VaultError(Code.INVALID) + retained += 1 + if (aws.get("access_key_id") or aws.get("secret_access_key")) and not aws.get( + "session_token" + ): + if aws.get("source_reference"): + raise VaultError(Code.CONFLICT) + region = ( + candidate.reveal().get("endpoints", {}).get("aws_region") or "us-east-1" + ) + origin = f"https://bedrock-runtime.{region}.amazonaws.com" + if origin not in aws_origins: + raise VaultError(Code.INVALID) + fields = Secret( + {key: aws.get(key) for key in ("access_key_id", "secret_access_key")} + ) + validate_fields(CredentialKind.SERVICE_SECRET, fields) + imports.append( + SourceImport( + uuid5(vault.profile.scope.profile_id, source_id + "/aws/static"), + CredentialKind.SERVICE_SECRET, + "AWS Bedrock", + "Static AWS account", + fields, + (origin,), + ) + ) + aws_import = True + imported = vault.import_sources(tuple(imports)) if imports else () + for (block, provider, index, value, locator, allowed), item in zip( + jobs, imported[: len(jobs)] + ): + ref = item.summary.reference + encoded = SourceReference( + ref.scope.profile_id, ref.credential_id, ref.version + ).encode() + if block == "api_keys": + candidate.reveal()[block][provider] = encoded + else: + candidate.reveal()[block][provider][index] = encoded + if aws_import: + ref = imported[-1].summary.reference + aws["source_reference"] = SourceReference( + ref.scope.profile_id, ref.credential_id, ref.version + ).encode() + aws["auth_mode"] = "vault" + del aws["access_key_id"], aws["secret_access_key"] + # One commit, after every destination has been read back successfully. + # Existing generated-token/session values remain exactly equal by deep copy. + commit(candidate) + return MigrationResult(len(imports), retained) + + +@sanitized_errors +def migrate_browser_document(vault, source_id: str, document: Secret): + """Strict legacy login conversion. Returns an ID map, never login fields.""" + _label(source_id) + value = document.reveal() + if ( + type(value) is not dict + or type(value.get("version")) is not int + or value["version"] != 1 + or set(value) != {"version", "entries"} + or type(value["entries"]) is not list + or len(value["entries"]) > 5000 + ): + raise VaultError(Code.INVALID) + jobs, seen = [], set() + allowed = { + "id", + "site", + "username", + "password", + "label", + "createdAt", + "updatedAt", + "lastUsedAt", + } + for entry in map(Secret, value["entries"]): + item = entry.reveal() + if ( + type(item) is not dict + or set(item) - allowed + or not {"id", "site", "username", "password"} <= set(item) + ): + raise VaultError(Code.INVALID) + old_id, site = item["id"], item["site"] + if ( + not isinstance(old_id, str) + or not 1 <= len(old_id) <= 64 + or old_id in seen + or not isinstance(site, str) + ): + raise VaultError(Code.INVALID) + seen.add(old_id) + origin = validate_origins( + (site if site.startswith("https://") else "https://" + site,) + ) + fields = Secret({"username": item["username"], "password": item["password"]}) + validate_fields(CredentialKind.LOGIN, fields) + label = item.get("label") or "Imported login " + old_id[:8] + _label(label) + jobs.append((old_id, origin, fields, label)) + mapping = {} + imported = ( + vault.import_sources( + tuple( + SourceImport( + uuid5( + vault.profile.scope.profile_id, source_id + "/browser/" + old_id + ), + CredentialKind.LOGIN, + "Browser login", + label, + fields, + origin, + ) + for old_id, origin, fields, label in jobs + ) + ) + if jobs + else () + ) + for (old_id, origin, fields, label), details in zip(jobs, imported): + ref = details.summary.reference + mapping[old_id] = SourceReference( + ref.scope.profile_id, ref.credential_id, ref.version + ).encode() + return mapping + + +SOURCE_FIELDS = { + "twitter": ("api_secret", ()), + **{p: (None, ()) for p in ("lark", "lark_drive", "lark_calendar", "telegram_user")}, + "github": ("access_token", ()), + "figma": ("access_token", ()), + "slack": ("bot_token", ()), + "discord": ("bot_token", ()), + "stripe": ("api_key", ()), + "notion": ("token", ()), + "hubspot": ("access_token", ()), + "posthog": ("api_key", ()), + "supabase": ("access_token", ()), + "line": ("channel_access_token", ("channel_secret",)), + "telegram_bot": ("bot_token", ()), + "whatsapp_business": ("access_token", ("app_secret", "verify_token")), + "jira": ("api_token", ()), + **{ + p: (None, ()) + for p in ( + "gmail", + "google_drive", + "google_calendar", + "google_docs", + "google_youtube", + "linkedin", + "outlook", + ) + }, +} + +OAUTH_PROVIDERS = { + **{ + p: "google" + for p in ( + "gmail", + "google_drive", + "google_calendar", + "google_docs", + "google_youtube", + ) + }, + **{p: p for p in ("slack", "linkedin", "hubspot", "figma", "notion", "supabase")}, +} + + +@sanitized_errors +def migrate_integration_document( + vault, + source_id, + provider, + document, + commit, + *, + source_accounts=(), + helper_catalog=None, +): + """Retire only classified manual sources; preserve generated records verbatim. + + Ambiguous legacy modes require explicit account IDs at the native owner + action. An OAuth mode or refresh token can never be overridden by that list. + Account identities, primary, aliases, timestamps and session data are copied. + """ + from .catalog import integration_helpers + from urllib.parse import urlsplit + + _label(source_id) + if provider not in SOURCE_FIELDS or not isinstance(document, Secret): + raise VaultError(Code.INVALID) + candidate = Secret(copy.deepcopy(document.reveal())) + value = candidate.reveal() + if ( + type(value) is not dict + or type(value.get("version")) is not int + or value["version"] != 2 + or type(value.get("accounts")) is not dict + or len(value["accounts"]) > 5000 + ): + raise VaultError(Code.INVALID) + selected = set(source_accounts) + if selected - set(value["accounts"]): + raise VaultError(Code.INVALID) + catalog = integration_helpers() if helper_catalog is None else helper_catalog + allowed = tuple( + sorted( + { + f"https://{urlsplit(r['endpoint']).netloc}" + for r in (catalog) + if r["helper"] == "integration_" + provider + } + ) + ) + if not allowed and SOURCE_FIELDS[provider][0] is not None: + raise VaultError(Code.INVALID) + jobs, bindings, retained = [], [], 0 + review_required, retained_derived = 0, 0 + primary, auxiliary = SOURCE_FIELDS[provider] + for identity, record in value["accounts"].items(): + _label(identity) + if type(record) is not dict or type(record.get("credential")) is not dict: + raise VaultError(Code.INVALID) + credential = Secret(record["credential"]) + data = credential.reveal() + if provider in ( + "lark", + "lark_drive", + "lark_calendar", + "telegram_user", + "twitter", + ): + source_name = ( + "api_hash" + if provider == "telegram_user" + else "api_secret" + if provider == "twitter" + else "app_secret" + ) + source = data.get(source_name) + if not source: + if provider == "twitter" and is_reference(data.get("source_reference")): + retained += 1 + continue + retained_derived += 1 + continue + if is_reference(source): + retained += 1 + continue + helper = ( + "auth_telegram_user" + if provider == "telegram_user" + else "integration_twitter" + if provider == "twitter" + else "auth_lark" + ) + special_origins = tuple( + sorted( + { + f"https://{urlsplit(r['endpoint']).netloc}" + for r in catalog + if r["helper"] == helper + } + ) + ) + if not special_origins: + raise VaultError(Code.INVALID) + kind = ( + CredentialKind.SERVICE_SECRET + if provider in ("telegram_user", "twitter") + else CredentialKind.API_KEY + ) + secret = Secret( + {"api_key": data.get("api_key"), "api_secret": source} + if provider == "twitter" + else {"api_hash": source} + if provider == "telegram_user" + else {"api_key": source} + ) + validate_fields(kind, secret) + jobs.append( + SourceImport( + uuid5( + vault.profile.scope.profile_id, + source_id + "/" + provider + "/" + identity + "/" + source_name, + ), + kind, + provider, + identity + " app", + secret, + special_origins, + ) + ) + bindings.append((data, source_name, provider == "twitter")) + continue # Generated tokens, expiry and sessions remain byte-for-byte values. + oauth_secret = data.get("client_secret") + if oauth_secret and provider in OAUTH_PROVIDERS: + if is_reference(oauth_secret): + retained += 1 + else: + oauth_allowed = tuple( + sorted( + { + f"https://{urlsplit(r['endpoint']).netloc}" + for r in catalog + if r["helper"] == "oauth_" + OAUTH_PROVIDERS[provider] + } + ) + ) + if not oauth_allowed: + raise VaultError(Code.INVALID) + jobs.append( + SourceImport( + uuid5( + vault.profile.scope.profile_id, + source_id + + "/" + + provider + + "/" + + identity + + "/client_secret", + ), + CredentialKind.API_KEY, + provider, + identity + " OAuth client", + Secret({"api_key": oauth_secret}), + oauth_allowed, + ) + ) + bindings.append((data, "client_secret", False)) + if primary is None: + retained_derived += 1 + continue + if data.get("source_reference"): + if not is_reference(data["source_reference"]): + raise VaultError(Code.INVALID) + retained += 1 + continue + mode = classify_integration(provider, data) + if data.get("auth_kind") == "oauth" or data.get("refresh_token"): + if identity in selected: + raise VaultError(Code.INVALID) + retained_derived += 1 + continue + if mode == "review_required" and identity not in selected: + # Retain unknown provenance; report it without echoing values. + review_required += 1 + continue + if mode == "derived": + retained_derived += 1 + continue + if not data.get(primary): + raise VaultError(Code.INVALID) + if provider in ("jira", "posthog"): + host = ( + data.get("domain") + if provider == "jira" + else data.get("host", "https://us.posthog.com") + ) + if not isinstance(host, str) or not host: + raise VaultError(Code.INVALID) + origin = host if host.startswith("https://") else "https://" + host + origin = origin.rstrip("/") + if ( + validate_origins((origin,)) != (origin,) + or origin not in allowed + or origin.endswith(".invalid") + ): + raise VaultError(Code.INVALID) + fields = [(primary, CredentialKind.API_KEY, Secret({"api_key": data[primary]}))] + if provider == "jira": + fields = [ + ( + primary, + CredentialKind.SERVICE_SECRET, + Secret({"api_token": data[primary], "username": data.get("email")}), + ) + ] + fields.extend( + (name, CredentialKind.API_KEY, Secret({"api_key": data[name]})) + for name in auxiliary + if data.get(name) + ) + for name, kind, secret in fields: + validate_fields(kind, secret) + jobs.append( + SourceImport( + uuid5( + vault.profile.scope.profile_id, + source_id + "/" + provider + "/" + identity + "/" + name, + ), + kind, + provider, + identity if name == primary else identity + " " + name, + secret, + allowed, + ) + ) + bindings.append((data, name, name == primary)) + imported = vault.import_sources(tuple(jobs)) if jobs else () + for (data, name, primary_field), item in zip(bindings, imported): + ref = item.summary.reference + encoded = SourceReference( + ref.scope.profile_id, ref.credential_id, ref.version + ).encode() + if name in ("client_secret", "app_secret", "api_hash"): + data[name] = encoded + continue + if primary_field: + data["source_reference"], data["auth_kind"] = encoded, "vault" + if provider == "jira": + data.pop("email", None) + if provider == "twitter": + data.pop("api_key", None) + else: + data.setdefault("source_auxiliary", {})[name] = encoded + del data[name] + commit(candidate) + return MigrationResult(len(imported), retained, review_required, retained_derived) + + +ENV_MODEL_KEYS = { + "OPENAI_API_KEY": "openai", + "ANTHROPIC_API_KEY": "anthropic", + "GOOGLE_API_KEY": "google", + "GEMINI_API_KEY": "google", + "BYTEPLUS_API_KEY": "byteplus", + "OPENROUTER_API_KEY": "openrouter", + "GROK_API_KEY": "grok", + "XAI_API_KEY": "grok", + "DEEPSEEK_API_KEY": "deepseek", + "MINIMAX_API_KEY": "minimax", + "MOONSHOT_API_KEY": "moonshot", + "GLM_API_KEY": "glm", + "ZAI_API_KEY": "glm", + "FUGU_API_KEY": "fugu", + "SAKANA_API_KEY": "fugu", + "GROQ_API_KEY": "groq", + "MISTRAL_API_KEY": "mistral", + "TOGETHER_API_KEY": "together", + "FIREWORKS_API_KEY": "fireworks", + "CEREBRAS_API_KEY": "cerebras", + "QWEN_API_KEY": "qwen", + "DASHSCOPE_API_KEY": "qwen", + "NVIDIA_API_KEY": "nvidia", + "PERPLEXITY_API_KEY": "perplexity", + "HF_TOKEN": "huggingface", +} + +ENV_OAUTH_KEYS = { + "TELEGRAM_API_HASH": "telegram_user", + "GOOGLE_CLIENT_SECRET": "google", + "SLACK_SHARED_CLIENT_SECRET": "slack", + "LINKEDIN_CLIENT_SECRET": "linkedin", + "HUBSPOT_SHARED_CLIENT_SECRET": "hubspot", + "FIGMA_CLIENT_SECRET": "figma", + "NOTION_SHARED_CLIENT_SECRET": "notion", + "SUPABASE_SHARED_CLIENT_SECRET": "supabase", +} + + +@sanitized_errors +def migrate_environment_document( + vault, source_id, document, commit, *, helper_catalog=None +): + """Explicit CraftBot .env conversion; never read/mutate process/OS environments. + + Only known model key assignments are eligible. Other settings, generated + tokens, sessions, comments and unsupported/dynamic assignments stay exact. + Duplicate eligible variables abort, avoiding hidden active source copies. + """ + import json + import re + + _label(source_id) + raw = document.reveal() + if not isinstance(raw, str): + raise VaultError(Code.INVALID) + lines, values, locations = raw.splitlines(keepends=True), {}, {} + review_required = 0 + for index, line in enumerate(lines): + match = re.fullmatch( + r"(\ufeff?[ \t]*(?:export[ \t]+)?)([A-Z][A-Z0-9_]*)([ \t]*=[ \t]*)(.*?)(\r?\n)?", + line, + ) + if not match: + continue + if match[2] not in (ENV_MODEL_KEYS.keys() | ENV_OAUTH_KEYS.keys()): + if match[4].strip() and re.search( + r"(?:_API_KEY|_CLIENT_SECRET|_APP_SECRET|_API_HASH|_BOT_TOKEN|_SECRET_ACCESS_KEY)$", + match[2], + ): + review_required += 1 + continue + key, text = match[2], match[4].strip() + if key in locations: + raise VaultError(Code.CONFLICT) + tail = "" + locations[key] = (index, match, tail) + if not text: + continue + if text.startswith('"'): + value, end = json.JSONDecoder().raw_decode(text) + tail = text[end:] + elif text.startswith("'"): + end = text.find("'", 1) + if end < 0: + raise VaultError(Code.INVALID) + value, tail = text[1:end], text[end + 1 :] + else: + comment = re.search(r"[ \t]+#", text) + value, tail = ( + (text[: comment.start()].rstrip(), text[comment.start() :]) + if comment + else (text, "") + ) + if tail.strip() and not tail.lstrip().startswith("#"): + raise VaultError(Code.INVALID) + locations[key] = (index, match, tail) + if "${" in value or "`" in value or "$(" in value: + raise VaultError(Code.INVALID) + values[key] = value + # Each variable is kept distinct even where two names map to one provider. + from .catalog import oauth_helpers, authentication_helpers + + allowed_catalog = ( + model_helpers() + oauth_helpers() + authentication_helpers() + if helper_catalog is None + else helper_catalog + ) + imports = [] + retained = 0 + from urllib.parse import urlsplit + + for key, value in values.items(): + if not value: + continue + if is_reference(value): + retained += 1 + continue + provider = ENV_OAUTH_KEYS[key] if key in ENV_OAUTH_KEYS else ENV_MODEL_KEYS[key] + allowed = tuple( + sorted( + { + f"https://{urlsplit(r['endpoint']).netloc}" + for r in allowed_catalog + if r["helper"] + == ("oauth_" if key in ENV_OAUTH_KEYS else "model_") + provider + or key == "TELEGRAM_API_HASH" + and r["helper"] == "auth_telegram_user" + or key not in ENV_OAUTH_KEYS + and provider == "google" + and r["helper"] == "model_gemini" + } + ) + ) + if not allowed: + raise VaultError(Code.INVALID) + imports.append( + ( + key, + SourceImport( + uuid5(vault.profile.scope.profile_id, source_id + "/env/" + key), + CredentialKind.SERVICE_SECRET + if key == "TELEGRAM_API_HASH" + else CredentialKind.API_KEY, + provider, + "Environment " + key, + Secret( + {"api_hash" if key == "TELEGRAM_API_HASH" else "api_key": value} + ), + allowed, + ), + ) + ) + imported = ( + vault.import_sources(tuple(job for key, job in imports)) if imports else () + ) + for (key, job), item in zip(imports, imported): + ref = item.summary.reference + encoded = SourceReference( + ref.scope.profile_id, ref.credential_id, ref.version + ).encode() + index, match, tail = locations[key] + lines[index] = ( + match[1] + key + match[3] + json.dumps(encoded) + tail + (match[5] or "") + ) + commit(Secret("".join(lines))) + return MigrationResult(len(imported), retained, review_required=review_required) diff --git a/app/credentials/migration_source.py b/app/credentials/migration_source.py new file mode 100644 index 000000000..bfa9e7c9a --- /dev/null +++ b/app/credentials/migration_source.py @@ -0,0 +1,140 @@ +"""Offline owner-file adapter for model Settings migration on Windows. + +Reads/writes impersonate the unelevated enrolled owner. Vault/key operations run +as SYSTEM after reverting. Never use a SYSTEM file read on a caller-selected +path. Normal application writers must be stopped before this owner action. +""" + +from contextlib import contextmanager, closing +import hashlib +import json +import os +from pathlib import Path +import stat +import re +from uuid import uuid4 + +from .errors import VaultError, VaultErrorCode as Code +from .secrets import Secret +from .windows_host import api + + +class OwnerSettingsSource: + def __init__(self, path, owner_sid, session, *, filename="settings.json"): + self.path = Path(path) + if ( + not self.path.is_absolute() + or not ( + filename + in ( + "settings.json", + "mini_browser_vault.enc", + "mini_browser_vault.enc.bak", + "mini_browser_vault.key", + ".env", + ".env.local", + ) + or re.fullmatch( + r"(?:github|figma|slack|discord|stripe|notion|hubspot|posthog|supabase|line|telegram_bot|telegram_user|twitter|lark|lark_drive|lark_calendar|whatsapp_business|jira|gmail|google_drive|google_calendar|google_docs|google_youtube|linkedin|outlook)\.accounts\.json", + filename, + ) + ) + or self.path.name != filename + or len(self.path.drive) != 2 + or self.path.drive[1] != ":" + or ".." in self.path.parts + or any(ord(c) < 32 for c in str(path)) + ): + raise VaultError(Code.INVALID) + self.owner_sid, self.session = owner_sid, session + self.handle = None + self.digest = None + + @contextmanager + def _owner(self): + _, _, _, _, security, ts, _ = api() + with closing(ts.WTSQueryUserToken(self.session)) as token: + from .windows_host import token_value + + if security.ConvertSidToStringSid( + security.GetTokenInformation(token, 1)[0] + ) != self.owner_sid or token_value(token, 20): + raise VaultError(Code.VERIFICATION_REQUIRED) + security.ImpersonateLoggedOnUser(token) + try: + yield + finally: + try: + if security.RevertToSelf() is False: + os._exit(1) + except BaseException: + os._exit(1) # Never continue in an unknown security context. + + def read(self): + return Secret(json.loads(self.read_bytes().reveal())) + + def read_bytes(self, limit=4_000_000): + import win32file + + with self._owner(): + for path in (self.path, *self.path.parents): + attributes = path.stat(follow_symlinks=False) + if ( + stat.S_ISLNK(attributes.st_mode) + or attributes.st_file_attributes & 0x400 + ): + raise VaultError(Code.INVALID) + self.handle = win32file.CreateFile( + str(self.path), 0x80000000, 1 | 4, None, 3, 0x00200000, None + ) + info = win32file.GetFileInformationByHandle(self.handle) + if info[0] & 0x400: + raise VaultError(Code.INVALID) + raw = Secret(win32file.ReadFile(self.handle, limit + 1)[1]) + if len(raw.reveal()) > limit: + raise VaultError(Code.INVALID) + self.digest = Secret(hashlib.sha256(raw.reveal()).digest()) + return raw + + def commit(self, document: Secret): + encoded = Secret( + json.dumps( + document.reveal(), indent=2, ensure_ascii=False, allow_nan=False + ).encode("utf-8") + ) + self.commit_bytes(encoded) + + def commit_text(self, document: Secret): + self.commit_bytes(Secret(document.reveal().encode("utf-8"))) + + def commit_bytes(self, encoded: Secret): + if self.handle is None or self.digest is None: + raise VaultError(Code.INVALID) + import hmac + import win32file + + temporary = self.path.with_name(".credential-migration-" + uuid4().hex + ".tmp") + with self._owner(): + # The retained source handle denies in-place writers. Check a second + # pathname read too, so an atomic replacement by another owner writer + # is detected. This is an offline migration, not a live refresh hook. + current = Secret(self.path.read_bytes()) + if not hmac.compare_digest( + self.digest.reveal(), hashlib.sha256(current.reveal()).digest() + ): + raise VaultError(Code.CONFLICT) + try: + with temporary.open("xb") as stream: + stream.write(encoded.reveal()) + stream.flush() + os.fsync(stream.fileno()) + win32file.MoveFileEx(str(temporary), str(self.path), 1 | 8) + finally: + temporary.unlink(missing_ok=True) + self.handle.Close() + self.handle = None + + def close(self): + if self.handle is not None: + self.handle.Close() + self.handle = None diff --git a/app/credentials/model_transport.py b/app/credentials/model_transport.py new file mode 100644 index 000000000..e5a8af351 --- /dev/null +++ b/app/credentials/model_transport.py @@ -0,0 +1,249 @@ +"""SDK HTTP adapter containing only source references and safe provider data.""" + +import json + +import httpx + +from .client import CredentialHostUnavailable, request as host_request +from .invocation import invoke, open_response +from .references import parse + + +def installed_model_helpers(): + """Read sealed metadata; custom settings never install a destination policy.""" + helpers, offset = [], 0 + while offset <= 1024: + result = host_request("catalog", offset=offset) + helpers.extend(item for item in result["helpers"] if item["type"] == "json") + if not result["more"]: + return helpers + offset = result.get("next_offset", offset + 8) + raise CredentialHostUnavailable("credential_response_invalid") + + +class VaultModelTransport(httpx.BaseTransport): + def __init__(self, provider, reference, resolve_reference=None): + parse(reference) + self.provider, self.reference = provider, reference + self.resolve_reference = resolve_reference + self.routes = [ + item + for item in installed_model_helpers() + if item["helper"] == f"model_{provider}" + ] + if not self.routes: + raise CredentialHostUnavailable("credential_model_helper_unavailable") + + def handle_request(self, request): + # Exact full URL: query changes, custom endpoints and redirects require + # their own reviewed descriptor, never a client-selected destination. + from .route_policy import match_route + + matched = match_route(self.routes, request.method, str(request.url)) + if matched is None: + raise CredentialHostUnavailable("credential_destination_denied") + route, path, query = matched + reference = ( + self.resolve_reference() if self.resolve_reference else self.reference + ) + parse(reference) + try: + raw = request.read() + body = ( + multipart_data(raw, request.headers.get("Content-Type", "")) + if route.get("request_format") == "multipart" + else json.loads(raw) + if raw + else None + ) + except Exception: + raise CredentialHostUnavailable("credential_request_invalid") from None + if route.get("envelope"): + body = {"path": path, "query": query, "data": body} + response = open_response( + reference, + route["helper"], + route["operation"], + body, + subject=f"{self.provider} model: {route['operation']}", + ) + if route.get("response_format") == "binary" and 200 <= response.status < 300: + import base64 + + try: + media = json.loads("".join(response)) + return httpx.Response( + response.status, + content=base64.b64decode(media["data"], validate=True), + headers={"Content-Type": media["media_type"]}, + request=request, + ) + finally: + response.close() + return httpx.Response( + response.status, + headers={"Content-Type": response.content_type}, + stream=CredentialByteStream(response), + request=request, + ) + + +class CredentialByteStream(httpx.SyncByteStream): + def __init__(self, response): + self.response = response + + def __iter__(self): + for part in self.response: + yield part.encode("utf-8") + + def close(self): + self.response.close() + + +def multipart_data(raw, content_type): + from email.parser import BytesParser + from email.policy import default + import base64 + + if len(raw) > 32_000_000: + raise ValueError("credential_use_limit") + message = BytesParser(policy=default).parsebytes( + ("Content-Type: " + content_type + "\r\nMIME-Version: 1.0\r\n\r\n").encode() + + raw + ) + if not message.is_multipart(): + raise ValueError("credential_request_invalid") + fields, files = {}, [] + for part in message.iter_parts(): + field = part.get_param("name", header="content-disposition") + data = part.get_payload(decode=True) + if part.get_filename() is not None: + files.append( + { + "field": field, + "content_type": part.get_content_type(), + "data": base64.b64encode(data).decode("ascii"), + "filename": part.get_filename(), + } + ) + else: + if field in fields: + raise ValueError("credential_request_invalid") + fields[field] = data.decode("utf-8") + return {"fields": fields, "files": files} + + +def http_client(provider, reference, resolve_reference=None): + return httpx.Client( + transport=VaultModelTransport(provider, reference, resolve_reference), + follow_redirects=False, + trust_env=False, + timeout=240, + ) + + +def openai_client(reference): + """Host injection for an SDK client containing only a saved reference.""" + parse(reference) + from openai import OpenAI + + return OpenAI(api_key="vault-managed", http_client=http_client("openai", reference)) + + +def requests_response(provider, reference, method, url, body=None): + """Adapt the existing REST consumers without handing them a source key. + + None means an unchanged legacy/generated-token path. Host errors propagate; + a reference must never fall back to the ordinary network transport. + """ + from .references import is_reference + + if not is_reference(reference): + return None + from .route_policy import match_route + + matched = match_route( + [r for r in installed_model_helpers() if r["helper"] == f"model_{provider}"], + method, + url, + ) + if matched is None: + raise CredentialHostUnavailable("credential_destination_denied") + route, path, query = matched + if route.get("envelope"): + body = {"path": path, "query": query, "data": body} + result = invoke( + reference, + route["helper"], + route["operation"], + body, + subject=f"{provider}: {route['operation']}", + ) + import requests + + response = requests.Response() + response.status_code, response.url = result["status"], url + response.headers["Content-Type"] = result["content_type"] + if route.get("response_format") == "binary" and 200 <= result["status"] < 300: + import base64 + + media = json.loads(result["body"]) + response._content = base64.b64decode(media["data"], validate=True) + response.headers["Content-Type"] = media["media_type"] + else: + response._content = result["body"].encode("utf-8") + response._content_consumed = True + response.encoding = "utf-8" + return response + + +class VaultBedrockClient: + """Converse facade: only a reference and model/region data, no boto3 keys.""" + + def __init__(self, reference, region): + parse(reference) + self.reference, self.region = reference, region + + def converse(self, **parameters): + import base64 + import re + + model = parameters.get("modelId") + if not isinstance(model, str) or not re.fullmatch(r"[A-Za-z0-9_.:-]+", model): + raise CredentialHostUnavailable("credential_destination_denied") + endpoint = f"https://bedrock-runtime.{self.region}.amazonaws.com/model/{model}/converse" + route = next( + ( + r + for r in installed_model_helpers() + if r["helper"] == "model_bedrock" + and r["method"] == "POST" + and r["endpoint"] == endpoint + ), + None, + ) + if route is None: + raise CredentialHostUnavailable("credential_destination_denied") + + def wire(value): + if isinstance(value, bytes): + return base64.b64encode(value).decode("ascii") + if type(value) is dict: + return {key: wire(child) for key, child in value.items()} + if type(value) is list: + return [wire(child) for child in value] + return value + + response = invoke( + self.reference, + route["helper"], + route["operation"], + wire({key: value for key, value in parameters.items() if key != "modelId"}), + subject="Bedrock Converse", + ) + if not 200 <= response["status"] < 300: + raise CredentialHostUnavailable("credential_operation_failed") + result = json.loads(response["body"]) + if type(result) is not dict: + raise CredentialHostUnavailable("credential_response_invalid") + return result diff --git a/app/credentials/mtproto_helper.py b/app/credentials/mtproto_helper.py new file mode 100644 index 000000000..1f0ebdfb8 --- /dev/null +++ b/app/credentials/mtproto_helper.py @@ -0,0 +1,239 @@ +"""Fixed Telegram login operations inside the protected host. + +No TL method, proxy, destination, file, code or SDK object is caller-selected. +Generated StringSessions return to their original owner; app hashes and saved +2FA passwords never leave this module. Only in-memory Telethon sessions exist. +""" + +import asyncio +import logging +import re + +from .helpers import JsonResult, _sanitize_json_payloads +from .use_errors import CredentialUseError, UseCode + +# Production DCs only. New DC addresses require a reviewed package update. +DC_ADDRESSES = { + 1: "149.154.175.50", + 2: "149.154.167.51", + 3: "149.154.175.100", + 4: "149.154.167.91", + 5: "149.154.171.5", +} + + +def validate(operation, body): + required = { + "send_code": {"api_id", "phone_number"}, + "complete": { + "api_id", + "phone_number", + "code", + "phone_code_hash", + "session_string", + }, + "complete_password": {"api_id", "session_string"}, + "qr_login": {"api_id"}, + }[operation] + if ( + type(body) is not dict + or set(body) != required + or any(not isinstance(v, str) or len(v) > 4096 for v in body.values()) + ): + raise CredentialUseError(UseCode.DENIED) + if ( + not re.fullmatch(r"[1-9][0-9]{0,9}", body["api_id"]) + or int(body["api_id"]) > 2147483647 + ): + raise CredentialUseError(UseCode.DENIED) + if "phone_number" in body and not re.fullmatch( + r"\+?[0-9]{5,20}", body["phone_number"] + ): + raise CredentialUseError(UseCode.DENIED) + if "code" in body and not re.fullmatch(r"[0-9]{3,10}", body["code"]): + raise CredentialUseError(UseCode.DENIED) + if "phone_code_hash" in body and not re.fullmatch( + r"[A-Za-z0-9_-]{1,256}", body["phone_code_hash"] + ): + raise CredentialUseError(UseCode.DENIED) + + +def execute(specification, fields, guard, payload, execution, after_send): + body, operation = payload["body"], specification.operation.operation + validate(operation, body) + if set(fields.reveal()) - {"api_hash", "password"} or not re.fullmatch( + r"[a-fA-F0-9]{32}", fields.reveal().get("api_hash", "") + ): + raise CredentialUseError(UseCode.DENIED) + if operation == "complete_password" and not fields.reveal().get("password"): + raise CredentialUseError(UseCode.DENIED) + return asyncio.run(_perform(operation, body, fields, guard, execution, after_send)) + + +async def _perform(operation, body, fields, guard, execution, after_send): + from telethon import TelegramClient, errors + from telethon.sessions import StringSession + from telethon.network.connection import ConnectionTcpFull + + class FixedConnection(ConnectionTcpFull): + async def _connect(self, timeout=None, ssl=None): + if ( + self._dc_id not in DC_ADDRESSES + or self._proxy + or self._local_addr + or ssl is not None + ): + raise CredentialUseError(UseCode.DENIED) + # Telethon also reads addresses from GetConfig during DC migration. + # Select the reviewed DC address again at every socket creation. + self._ip, self._port = DC_ADDRESSES[self._dc_id], 443 + return await super()._connect(timeout=min(timeout or 10, 10), ssl=None) + + class PrivateLogger(logging.Logger): + def getChild(self, suffix): + return self # Never create a child attached to the process root. + + logger = PrivateLogger("credential_mtproto_private") + logger.disabled = True + logger.addHandler(logging.NullHandler()) + logger.propagate = False + session = StringSession(body.get("session_string") or "") + dc = session.dc_id or 2 + if dc not in DC_ADDRESSES: + raise CredentialUseError(UseCode.DENIED) + # A staged StringSession can contain a caller-chosen address/port. Ignore + # both and retain only its DC and generated auth key. + session.set_dc(dc, DC_ADDRESSES[dc], 443) + client = TelegramClient( + session, + int(body["api_id"]), + fields.reveal()["api_hash"], + connection=FixedConnection, + proxy=None, + use_ipv6=False, + timeout=10, + request_retries=1, + connection_retries=1, + auto_reconnect=False, + flood_sleep_threshold=0, + receive_updates=operation == "qr_login", + base_logger=logger, + ) + + def checked(value): + return _sanitize_json_payloads([value], fields)[0] + + def emit(value): + text = checked(value) + if execution is not None: + execution.emit("data: " + text + "\n\n") + + async def authenticate(): + guard() + await client.connect() + guard() + # Commit this fixed dispatch; no further caller input is accepted. + if after_send is not None: + after_send() + try: + if operation == "send_code": + code = await client.send_code_request(body["phone_number"]) + return { + "ok": True, + "result": { + "phone_code_hash": code.phone_code_hash, + "phone_number": body["phone_number"], + "session_string": client.session.save(), + "status": "code_sent", + }, + } + if operation == "qr_login": + if execution is None: + raise CredentialUseError(UseCode.DENIED) + execution.start(200, "text/event-stream") + qr = await client.qr_login() + # The QR token is generated login state, not an app hash. + emit({"qr_url": qr.url}) + await qr.wait(timeout=120) + elif operation == "complete_password": + await client.sign_in(password=fields.reveal()["password"]) + else: + try: + await client.sign_in( + phone=body["phone_number"], + code=body["code"], + phone_code_hash=body["phone_code_hash"], + ) + except errors.SessionPasswordNeededError: + if not fields.reveal().get("password"): + raise + await client.sign_in(password=fields.reveal()["password"]) + me = await client.get_me() + return { + "ok": True, + "result": { + "session_string": client.session.save(), + "user_id": me.id, + "first_name": me.first_name or "", + "last_name": me.last_name or "", + "username": me.username or "", + "phone": me.phone or body.get("phone_number", ""), + "status": "authenticated", + }, + } + except errors.SessionPasswordNeededError: + return { + "error": "Two-factor authentication required", + "details": { + "status": "2fa_required", + "session_string": client.session.save(), + }, + } + except errors.PhoneCodeInvalidError: + return { + "error": "Invalid verification code", + "details": {"status": "invalid_code"}, + } + except errors.PhoneCodeExpiredError: + return { + "error": "Verification code expired", + "details": {"status": "code_expired"}, + } + except errors.PasswordHashInvalidError: + return { + "error": "Invalid 2FA password", + "details": {"status": "invalid_password"}, + } + except errors.FloodWaitError as error: + return { + "error": "Telegram rate limit", + "details": {"flood_wait_seconds": max(0, min(error.seconds, 86400))}, + } + + async def cancel_when_locked(task): + while not task.done(): + await asyncio.sleep(0.05) + if execution is not None and execution.cancelled.is_set(): + task.cancel() + return + + task = asyncio.create_task(authenticate()) + watcher = asyncio.create_task(cancel_when_locked(task)) + try: + result = await asyncio.wait_for(task, timeout=180) + if ( + operation == "qr_login" + and execution is not None + and execution.status is not None + ): + emit(result) + return JsonResult(200, "", "text/event-stream") + return JsonResult(200, checked(result), "application/json") + except asyncio.CancelledError: + raise CredentialUseError(UseCode.INTERRUPTED) from None + finally: + watcher.cancel() + try: + await asyncio.wait_for(client.disconnect(), 5) + except (Exception, asyncio.CancelledError): + pass diff --git a/app/credentials/native/CredentialHost.cs b/app/credentials/native/CredentialHost.cs new file mode 100644 index 000000000..89dcfa915 --- /dev/null +++ b/app/credentials/native/CredentialHost.cs @@ -0,0 +1,399 @@ +// Built with the Windows .NET Framework compiler. No third-party assemblies. +// The service never imports Python from a checkout or inherits its environment. +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Runtime.InteropServices; +using System.Security.Principal; +using System.ServiceProcess; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Web.Script.Serialization; +using System.Windows.Forms; +using Microsoft.Win32.SafeHandles; + +class CredentialHost : ServiceBase { + const string Name = "CraftBotCredentialHost"; + readonly object gate = new object(); + Process worker; + SafeFileHandle workerJob; + SafeFileHandle listener; + volatile bool stopping; + static readonly JavaScriptSerializer Json = new JavaScriptSerializer { MaxJsonLength = 65536, RecursionLimit = 16 }; + // Trusted identity metadata and escaping expand an otherwise valid frame. + const int EnvelopeLimit = 400000; + static string Envelope(uint pid,long created,string sid,string app,object payload) { + var serializer=new JavaScriptSerializer { MaxJsonLength=EnvelopeLimit, RecursionLimit=20 }; + string encoded=serializer.Serialize(new { peer_pid=pid,peer_created=created.ToString(System.Globalization.CultureInfo.InvariantCulture),peer_sid=sid,peer_app_sid=app,payload=payload }); + var ascii=new StringBuilder(); + foreach(char ch in encoded) { if(ch>127) ascii.Append("\\u"+((int)ch).ToString("x4")); else ascii.Append(ch); } + if(ascii.Length>EnvelopeLimit) throw new IOException(); + return ascii.ToString(); + } + static string Root { get { return AppDomain.CurrentDomain.BaseDirectory; } } + + [DllImport("advapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + static extern bool ConvertStringSecurityDescriptorToSecurityDescriptor(string text, uint revision, out IntPtr descriptor, IntPtr size); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + static extern SafeFileHandle CreateNamedPipe(string name, uint access, uint mode, uint instances, uint output, uint input, uint timeout, ref SECURITY_ATTRIBUTES attributes); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool ConnectNamedPipe(SafeFileHandle pipe, IntPtr overlapped); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool DisconnectNamedPipe(SafeFileHandle pipe); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool CancelIoEx(SafeFileHandle pipe, IntPtr overlapped); + [DllImport("kernel32.dll")] static extern IntPtr CreateEvent(IntPtr attributes,bool manual,bool initial,string name); + [DllImport("kernel32.dll")] static extern uint WaitForSingleObject(IntPtr handle,uint milliseconds); + [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle); + [DllImport("kernel32.dll",SetLastError=true)] static extern IntPtr OpenProcess(uint access,bool inherit,uint pid); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool GetProcessTimes(IntPtr process,out long created,out long exited,out long system,out long user); + [DllImport("kernel32.dll",CharSet=CharSet.Unicode)] static extern SafeFileHandle CreateJobObject(IntPtr attributes,string name); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool SetInformationJobObject(SafeFileHandle job,int kind,ref JOB_LIMITS limits,int size); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool AssignProcessToJobObject(SafeFileHandle job,IntPtr process); + [StructLayout(LayoutKind.Sequential)] struct BASIC_LIMITS { public long processTime,jobTime; public uint flags; public UIntPtr minimum,maximum; public uint active; public UIntPtr affinity; public uint priority,scheduling; } + [StructLayout(LayoutKind.Sequential)] struct IO_COUNTERS { public ulong a,b,c,d,e,f; } + [StructLayout(LayoutKind.Sequential)] struct JOB_LIMITS { public BASIC_LIMITS basic; public IO_COUNTERS io; public UIntPtr processMemory,jobMemory,peakProcess,peakJob; } + [DllImport("advapi32.dll",SetLastError=true)] static extern bool ImpersonateNamedPipeClient(SafeFileHandle pipe); + [DllImport("advapi32.dll")] static extern bool RevertToSelf(); + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentThread(); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenThreadToken(IntPtr thread,uint rights,bool asSelf,out IntPtr token); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool GetTokenInformation(IntPtr token,int kind,IntPtr data,uint size,out uint needed); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetNamedPipeClientProcessId(SafeFileHandle pipe, out uint pid); + [DllImport("kernel32.dll")] static extern IntPtr LocalFree(IntPtr value); + [StructLayout(LayoutKind.Sequential)] struct SECURITY_ATTRIBUTES { public int size; public IntPtr descriptor; public int inherit; } + [StructLayout(LayoutKind.Sequential)] struct OVERLAPPED { public IntPtr a,b; public uint c,d; public IntPtr signal; } + + public CredentialHost() { ServiceName=Name; CanHandleSessionChangeEvent=true; CanHandlePowerEvent=true; } + protected override void OnStart(string[] args) { + if (WindowsIdentity.GetCurrent().User.Value != "S-1-5-18") throw new InvalidOperationException("protected_host_required"); + Task.Factory.StartNew(Serve, TaskCreationOptions.LongRunning); + } + protected override void OnStop() { stopping=true; try { if(listener!=null) { CancelIoEx(listener,IntPtr.Zero); listener.Dispose(); } } catch {} Reset(); } + protected override void OnSessionChange(SessionChangeDescription change) { + if(change.Reason==SessionChangeReason.SessionLock || change.Reason==SessionChangeReason.SessionLogoff) Reset(); + } + protected override bool OnPowerEvent(PowerBroadcastStatus power) { if(power==PowerBroadcastStatus.Suspend) Reset(); return true; } + void Reset() { + // Do not wait behind a hung resolver or owner prompt on lock/suspend. + Process current=Interlocked.Exchange(ref worker, null); + SafeFileHandle job=Interlocked.Exchange(ref workerJob,null); + if(job!=null) job.Dispose(); + if(current!=null) { try { current.Kill(); current.WaitForExit(5000); } catch {} current.Dispose(); } + } + Process Worker() { + if(worker!=null && !worker.HasExited) return worker; + Reset(); + var start=new ProcessStartInfo(Path.Combine(Root,"python","python.exe"), "-I -S \""+Path.Combine(Root,"bootstrap.py")+"\""); + start.UseShellExecute=false; start.CreateNoWindow=true; start.WorkingDirectory=Root; + start.RedirectStandardInput=true; start.RedirectStandardOutput=true; start.RedirectStandardError=true; + start.EnvironmentVariables.Clear(); + start.EnvironmentVariables["SYSTEMROOT"]=Environment.GetFolderPath(Environment.SpecialFolder.Windows); + start.EnvironmentVariables["PATH"]=Path.Combine(Root,"python"); + worker=Process.Start(start); + workerJob=CreateJobObject(IntPtr.Zero,null); + var limits=new JOB_LIMITS(); limits.basic.flags=0x2000|0x1000; + // The service owns this kill-on-close supervisor. Cross-session native + // dialogs/consumers break away and enter their own worker-owned jobs. + if(workerJob.IsInvalid || !SetInformationJobObject(workerJob,9,ref limits,Marshal.SizeOf(typeof(JOB_LIMITS))) || !AssignProcessToJobObject(workerJob,worker.Handle)) { Reset(); throw new IOException(); } + // Never log stderr, traceback locals, protocol data or child output. + worker.ErrorDataReceived += delegate {}; worker.BeginErrorReadLine(); + return worker; + } + static string[] PipeIdentity(SafeFileHandle pipe) { + if(!ImpersonateNamedPipeClient(pipe)) throw new IOException(); + IntPtr token=IntPtr.Zero,buffer=IntPtr.Zero; + try { + if(!OpenThreadToken(GetCurrentThread(),8,true,out token)) throw new IOException(); + string sid; using(var identity=new WindowsIdentity(token)) sid=identity.User.Value; + uint needed; + buffer=Marshal.AllocHGlobal(4); + if(!GetTokenInformation(token,29,buffer,4,out needed)) throw new IOException(); + bool container=Marshal.ReadInt32(buffer)!=0; + Marshal.FreeHGlobal(buffer); buffer=IntPtr.Zero; + string app=null; + if(container) { + GetTokenInformation(token,31,IntPtr.Zero,0,out needed); + if(needed<8||needed>4096) throw new IOException(); + buffer=Marshal.AllocHGlobal((int)needed); + if(!GetTokenInformation(token,31,buffer,needed,out needed)) throw new IOException(); + app=new SecurityIdentifier(Marshal.ReadIntPtr(buffer)).Value; + } + return new string[] {sid,app}; + } finally { + if(buffer!=IntPtr.Zero) Marshal.FreeHGlobal(buffer); + if(token!=IntPtr.Zero) CloseHandle(token); + if(!RevertToSelf()) Environment.Exit(1); + } + } + void Serve() { + while(!stopping) { + IntPtr sd=IntPtr.Zero; + IntPtr connected=IntPtr.Zero; + IntPtr pendingMemory=IntPtr.Zero; + bool connectionPending=false; + IntPtr peerProcess=IntPtr.Zero; + try { + // Exact data rights exclude FILE_CREATE_PIPE_INSTANCE. Low label + // allows AppContainer writes; kernel PID/job checks authorize use. + if(!ConvertStringSecurityDescriptorToSecurityDescriptor("O:SYD:P(A;;GA;;;SY)(A;;0x120003;;;AU)(A;;0x120003;;;AC)S:(ML;;NW;;;LW)",1,out sd,IntPtr.Zero)) throw new IOException(); + var sa=new SECURITY_ATTRIBUTES { size=Marshal.SizeOf(typeof(SECURITY_ATTRIBUTES)), descriptor=sd }; + if(listener==null || listener.IsClosed || listener.IsInvalid) listener=CreateNamedPipe(@"\\.\pipe\LOCAL\CraftBot.Credentials.v1", 3|0x80000|0x40000000, 8, 1,65536,65536,0,ref sa); + if(listener.IsInvalid) throw new IOException(); // no squatting fallback + connected=CreateEvent(IntPtr.Zero,true,false,null); + var pending=new OVERLAPPED { signal=connected }; + pendingMemory=Marshal.AllocHGlobal(Marshal.SizeOf(typeof(OVERLAPPED))); + Marshal.StructureToPtr(pending,pendingMemory,false); + if(!ConnectNamedPipe(listener,pendingMemory)) { + int error=Marshal.GetLastWin32Error(); + if(error!=535) { + if(error!=997) throw new IOException(); + connectionPending=true; + while(WaitForSingleObject(connected,1000)==258) { if(stopping) throw new IOException(); } + } + } + uint pid; + if(!GetNamedPipeClientProcessId(listener,out pid) || pid==0) throw new IOException(); + peerProcess=OpenProcess(0x101000,false,pid); + long created,exited,system,user; + if(peerProcess==IntPtr.Zero || !GetProcessTimes(peerProcess,out created,out exited,out system,out user) || WaitForSingleObject(peerProcess,0)!=258) throw new IOException(); + using(var stream=new FileStream(new SafeFileHandle(listener.DangerousGetHandle(),false),FileAccess.ReadWrite,4096,true)) { + var incoming=Task.Factory.StartNew(()=>ReadFrame(stream)); + if(!incoming.Wait(10000)) { CancelIoEx(listener,IntPtr.Zero); stream.Dispose(); continue; } + object payload=Json.DeserializeObject(incoming.Result); + if(WaitForSingleObject(peerProcess,0)!=258) throw new IOException(); + string[] identity=PipeIdentity(listener); + // Client-dependent validation must never reset an existing + // worker or start one. Only worker transport failures do so. + string envelope=Envelope(pid,created,identity[0],identity[1],payload); + string response; + lock(gate) { + try { + var current=Worker(); + current.StandardInput.WriteLine(envelope); current.StandardInput.Flush(); + var answer=Task.Factory.StartNew(()=>ReadLine(current.StandardOutput)); + // Unlock, owner authentication and private entry each + // have their own deadlines; permit their bounded sum. + if(!answer.Wait(300000)) { Reset(); throw new IOException(); } + response=answer.Result; + // Only a kernel-authenticated owner lock can request + // this supervisor action. Consumer results are nested + // and cannot forge the worker's reserved response. + var answered=Json.DeserializeObject(response) as Dictionary; + var requested=payload as Dictionary; + object requestedOp,ok,returned; + if(requested!=null && requested.TryGetValue("op",out requestedOp) && (requestedOp as string)=="lock" && answered!=null && answered.TryGetValue("ok",out ok) && ok is bool && (bool)ok && answered.TryGetValue("result",out returned)) { + var result=returned as Dictionary; object reset; + if(result!=null && result.Count==1 && result.TryGetValue("reset_host",out reset) && reset is bool && (bool)reset) { + Reset(); response="{\"ok\":true,\"result\":{\"state\":\"locked\",\"all_profiles\":true}}"; + } + } + } catch { Reset(); throw; } + } + WriteFrame(stream,response); + } + } catch {} // A disconnected/malformed client cannot reset other grants. + finally { + try { if(listener!=null && !listener.IsClosed && !listener.IsInvalid) { CancelIoEx(listener,IntPtr.Zero); DisconnectNamedPipe(listener); } } catch {} + // Keep overlapped storage alive until the kernel completes it. + if(!connectionPending || WaitForSingleObject(connected,5000)==0) { + if(pendingMemory!=IntPtr.Zero) Marshal.FreeHGlobal(pendingMemory); + if(connected!=IntPtr.Zero) CloseHandle(connected); + } + if(sd!=IntPtr.Zero) LocalFree(sd); + if(peerProcess!=IntPtr.Zero) CloseHandle(peerProcess); + } + } + } + static string ReadLine(StreamReader reader) { + var value=new StringBuilder(); int c; + while((c=reader.Read())!=-1 && c!='\n') { if(value.Length>=65536) throw new IOException(); value.Append((char)c); } + if(c==-1) throw new IOException(); return value.ToString(); + } + static byte[] Exact(Stream stream,int size) { var data=new byte[size]; for(int n=0;nmaximum) throw new IOException(); return Encoding.UTF8.GetString(Exact(stream,size)); } + static void WriteFrame(Stream stream,string message,int maximum=65536) { byte[] data=Encoding.UTF8.GetBytes(message); if(data.Length>maximum) throw new IOException(); byte[] size=BitConverter.GetBytes(data.Length); stream.Write(size,0,4); stream.Write(data,0,data.Length); stream.Flush(); } + + [StructLayout(LayoutKind.Sequential,CharSet=CharSet.Unicode)] struct CREDUI { public int size; public IntPtr parent; public string message; public string caption; public IntPtr banner; } + [DllImport("credui.dll",CharSet=CharSet.Unicode)] static extern uint CredUIPromptForWindowsCredentials(ref CREDUI info,uint error,ref uint package,IntPtr input,uint inputSize,out IntPtr output,out uint size,IntPtr save,uint flags); + [DllImport("credui.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool CredUnPackAuthenticationBuffer(uint flags,IntPtr auth,uint authSize,IntPtr user,ref uint userSize,IntPtr domain,ref uint domainSize,IntPtr password,ref uint passwordSize); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool LogonUser(IntPtr user,IntPtr domain,IntPtr password,uint logon,uint provider,out SafeAccessTokenHandle token); + [DllImport("kernel32.dll",EntryPoint="RtlZeroMemory")] static extern void Zero(IntPtr buffer,UIntPtr size); + static bool Consent(string sid,string message) { + if(message.Length>1000 || WindowsIdentity.GetCurrent().User.Value!="S-1-5-18") return false; + IntPtr packed=IntPtr.Zero; uint length=0,package=0; + IntPtr user=Marshal.AllocHGlobal(2048),domain=Marshal.AllocHGlobal(2048),password=Marshal.AllocHGlobal(65536); + try { + var info=new CREDUI { size=Marshal.SizeOf(typeof(CREDUI)),message=message,caption="CraftBot credential authorization" }; + // Real secure desktop; no ordinary-web approval or generic prompt fallback. + if(CredUIPromptForWindowsCredentials(ref info,0,ref package,IntPtr.Zero,0,out packed,out length,IntPtr.Zero,0x1000)!=0) return false; + uint u=1024,d=1024,p=32768; + if(!CredUnPackAuthenticationBuffer(0,packed,length,user,ref u,domain,ref d,password,ref p)) return false; + SafeAccessTokenHandle token; + if(!LogonUser(user,domain,password,2,0,out token)) return false; + using(token) using(var identity=new WindowsIdentity(token.DangerousGetHandle())) return identity.User.Value==sid; + } finally { + Zero(user,(UIntPtr)2048); Zero(domain,(UIntPtr)2048); Zero(password,(UIntPtr)65536); + Marshal.FreeHGlobal(user); Marshal.FreeHGlobal(domain); Marshal.FreeHGlobal(password); + if(packed!=IntPtr.Zero) { Zero(packed,(UIntPtr)length); Marshal.FreeCoTaskMem(packed); } + } + } + static int OwnerDialog(string[] args) { + if(args.Length<3 || WindowsIdentity.GetCurrent().User.Value!="S-1-5-18") return 1; + string message=Encoding.UTF8.GetString(Convert.FromBase64String(args[2])); + if(!Consent(args[1],message)) return 1; + if(args[0]=="--consent") return 0; + if(args.Length==5 && (args[0]=="--view" || args[0]=="--review")) { + using(var pipe=new NamedPipeClientStream(".",args[3],PipeDirection.InOut)) { + pipe.Connect(5000); + var privateJson=new JavaScriptSerializer { MaxJsonLength=2100000,RecursionLimit=4 }; + var shown=privateJson.Deserialize>(ReadFrame(pipe,2100000)); + if(shown==null || shown.Count==0 || shown.Count>32) return 1; + try { return ProtectedEntry(args[4],shown,args[0]=="--review")==null?1:0; } + finally { shown.Clear(); } + } + } + if(args.Length!=5 || args[0]!="--entry") return 1; + Dictionary result=ProtectedEntry(args[4]); + if(result==null) return 1; + // This private SYSTEM-only pipe is created by the worker. The public + // agent transport never sees credential form values. + using(var pipe=new NamedPipeClientStream(".",args[3],PipeDirection.InOut)) { + var privateJson=new JavaScriptSerializer { MaxJsonLength=2100000,RecursionLimit=4 }; + pipe.Connect(5000); WriteFrame(pipe,privateJson.Serialize(result),2100000); + pipe.ReadByte(); + } + return 0; + } + [StructLayout(LayoutKind.Sequential)] struct SA { public int size; public IntPtr descriptor; public int inherit; } + [StructLayout(LayoutKind.Sequential,CharSet=CharSet.Unicode)] struct STARTUP { + public int size; public string reserved,desktop,title; public uint x,y,width,height,xchars,ychars,fill,flags; + public short show,reservedSize; public IntPtr reserved2,input,output,error; + } + [StructLayout(LayoutKind.Sequential)] struct PROCESS { public IntPtr process,thread; public uint pid,tid; } + [DllImport("user32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern IntPtr CreateDesktop(string name,IntPtr device,IntPtr mode,uint flags,uint access,ref SA attributes); + [DllImport("user32.dll",SetLastError=true)] static extern IntPtr OpenInputDesktop(uint flags,bool inherit,uint access); + [DllImport("user32.dll")] static extern bool SwitchDesktop(IntPtr desktop); + [DllImport("user32.dll")] static extern bool SetThreadDesktop(IntPtr desktop); + [DllImport("user32.dll")] static extern bool CloseDesktop(IntPtr desktop); + [DllImport("user32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool GetUserObjectInformation(IntPtr handle,int index,StringBuilder value,uint length,out uint needed); + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool CreateProcess(string app,StringBuilder command,IntPtr processAttributes,IntPtr threadAttributes,bool inherit,uint flags,IntPtr environment,string directory,ref STARTUP startup,out PROCESS result); + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,EntryPoint="CreateEventW",SetLastError=true)] static extern IntPtr PrivateEvent(ref SA attributes,bool manual,bool initial,string name); + [DllImport("kernel32.dll",CharSet=CharSet.Unicode)] static extern IntPtr OpenEvent(uint rights,bool inherit,string name); + [DllImport("kernel32.dll")] static extern bool SetEvent(IntPtr handle); + [DllImport("kernel32.dll")] static extern bool TerminateProcess(IntPtr handle,uint code); + + static int EntryGuardian(string[] args) { + if(args.Length!=5 || WindowsIdentity.GetCurrent().User.Value!="S-1-5-18") return 1; + uint pid; long expected; + if(!uint.TryParse(args[1],out pid)||!long.TryParse(args[2],out expected)||!args[4].StartsWith("CraftBot.Entry.")||args[3]!="Local\\"+args[4]) return 1; + IntPtr parent=OpenProcess(0x100001|0x1000,false,pid), desktop=IntPtr.Zero,ready=IntPtr.Zero; + try { + long created,exited,kernel,user; + if(parent==IntPtr.Zero || !GetProcessTimes(parent,out created,out exited,out kernel,out user)||created!=expected) return 1; + desktop=OpenInputDesktop(0,false,0x100); + ready=OpenEvent(2,false,args[3]); + if(desktop==IntPtr.Zero||ready==IntPtr.Zero||!SetEvent(ready)) return 1; + uint outcome=WaitForSingleObject(parent,90000); + // Runs outside the UI job, with no source values or public RPC. + // The bounded guardian survives a killed UI/worker/service. + RestoreDesktop(desktop,args[4]); + if(outcome!=0) TerminateProcess(parent,1); + return 0; + } finally { + if(ready!=IntPtr.Zero) CloseHandle(ready); + if(desktop!=IntPtr.Zero) CloseDesktop(desktop); + if(parent!=IntPtr.Zero) CloseHandle(parent); + } + } + static void RestoreDesktop(IntPtr original,string privateName) { + IntPtr active=OpenInputDesktop(0,false,0x101); + try { + uint needed; var name=new StringBuilder(512); + if(active!=IntPtr.Zero && GetUserObjectInformation(active,2,name,1024,out needed) && name.ToString()==privateName) SwitchDesktop(original); + } finally { if(active!=IntPtr.Zero) CloseDesktop(active); } + } + static Dictionary ProtectedEntry(string fields,Dictionary shown=null,bool review=false) { + IntPtr descriptor=IntPtr.Zero,desktop=IntPtr.Zero,ready=IntPtr.Zero,original=IntPtr.Zero; + PROCESS guardian=new PROCESS(); + try { + if(!ConvertStringSecurityDescriptorToSecurityDescriptor("O:SYD:P(A;;GA;;;SY)",1,out descriptor,IntPtr.Zero)) throw new IOException(); + var attributes=new SA { size=Marshal.SizeOf(typeof(SA)),descriptor=descriptor }; + string name="CraftBot.Entry."+Guid.NewGuid().ToString("N"),eventName="Local\\"+name; + original=OpenInputDesktop(0,false,0x100); + desktop=CreateDesktop(name,IntPtr.Zero,IntPtr.Zero,0,0x1ff,ref attributes); + ready=PrivateEvent(ref attributes,true,false,eventName); + if(ready!=IntPtr.Zero && Marshal.GetLastWin32Error()==183) throw new IOException(); + if(original==IntPtr.Zero||desktop==IntPtr.Zero||ready==IntPtr.Zero) throw new IOException(); + using(var current=Process.GetCurrentProcess()) { + long created,exited,kernel,user; + if(!GetProcessTimes(current.Handle,out created,out exited,out kernel,out user)) throw new IOException(); + var startup=new STARTUP { size=Marshal.SizeOf(typeof(STARTUP)),desktop="winsta0\\default" }; + string exe=Path.Combine(Root,"CredentialHost.exe"); + if(!CreateProcess(exe,new StringBuilder("\""+exe+"\" --entry-guardian "+current.Id+" "+created+" "+eventName+" "+name),IntPtr.Zero,IntPtr.Zero,false,0x01000000|0x08000000,IntPtr.Zero,Root,ref startup,out guardian)) throw new IOException(); + } + if(WaitForSingleObject(ready,5000)!=0) throw new IOException(); + Dictionary result=null; + // New STA thread has no windows or hooks on the ordinary desktop. + var thread=new Thread(delegate() { + try { + if(!SetThreadDesktop(desktop)||!SwitchDesktop(desktop)) return; + result=EntryForm(fields,shown,review); + } catch { result=null; } + finally { RestoreDesktop(original,name); } + }); + thread.SetApartmentState(ApartmentState.STA); thread.Start(); + if(!thread.Join(85000)) { RestoreDesktop(original,name); Environment.Exit(1); } + return result; + } finally { + // The UI thread and bounded guardian restore only their own input + // desktop, never a later prompt or the OS lock screen. + if(original!=IntPtr.Zero) CloseDesktop(original); + if(desktop!=IntPtr.Zero) CloseDesktop(desktop); + if(ready!=IntPtr.Zero) CloseHandle(ready); + if(descriptor!=IntPtr.Zero) LocalFree(descriptor); + if(guardian.thread!=IntPtr.Zero) CloseHandle(guardian.thread); + if(guardian.process!=IntPtr.Zero) CloseHandle(guardian.process); + } + } + sealed class SourceTextBox : TextBox { + protected override void WndProc(ref Message message) { + if(message.Msg==0x300 || message.Msg==0x301) return; // No global clipboard copy/cut. + base.WndProc(ref message); + } + } + static Dictionary EntryForm(string fields,Dictionary shown=null,bool review=false) { + var result=new Dictionary(); + using(var form=new Form()) { + form.Text=review?"CraftBot — approve exact credential request":shown==null?"CraftBot — secure credential entry":"CraftBot — private credential view"; form.Width=review?760:480; form.Height=review?680:400; + var panel=new FlowLayoutPanel { Dock=DockStyle.Fill,FlowDirection=FlowDirection.TopDown,WrapContents=false,AutoScroll=true }; + panel.Controls.Add(new Label { Text=shown==null?"Values go only to the protected vault.":"Private owner view. Clipboard copy is disabled.",AutoSize=true }); + var inputs=new Dictionary(); + foreach(string field in fields.Split(',')) { + var box=new SourceTextBox { Width=review?690:420,UseSystemPasswordChar=!review,MaxLength=review?2100000:32768,ReadOnly=shown!=null }; + if(review) { box.Multiline=true; box.ScrollBars=ScrollBars.Both; box.WordWrap=false; box.Height=field=="Parameters"?240:60; } + if(shown!=null) { if(!shown.ContainsKey(field)) throw new IOException(); box.Text=shown[field]; } + inputs.Add(field,box); panel.Controls.Add(new Label { Text=field,AutoSize=true }); panel.Controls.Add(box); + var multiline=new CheckBox { Text=shown==null?"Multiple lines (visible on this protected desktop)":"Reveal on this protected desktop",AutoSize=true }; + multiline.CheckedChanged+=delegate { box.UseSystemPasswordChar=!multiline.Checked; box.Multiline=multiline.Checked; box.AcceptsReturn=multiline.Checked; box.Height=multiline.Checked?90:24; }; + if(shown==null) box.TextChanged+=delegate { multiline.Enabled=box.TextLength==0; }; + if(!review) panel.Controls.Add(multiline); + } + var save=new Button { Text=review?"Approve exact request":shown==null?"Save to vault":"Close private view",AutoSize=true,DialogResult=DialogResult.OK }; + if(review) { + save.Enabled=false; + var confirm=new CheckBox { Text="I reviewed the destination, parameters and content notice.",AutoSize=true }; + confirm.CheckedChanged+=delegate { save.Enabled=confirm.Checked; }; + panel.Controls.Add(confirm); + var cancel=new Button { Text="Deny",DialogResult=DialogResult.Cancel }; panel.Controls.Add(cancel); form.CancelButton=cancel; + } else form.AcceptButton=save; + panel.Controls.Add(save); form.Controls.Add(panel); + if(form.ShowDialog()!=DialogResult.OK) return null; + foreach(var input in inputs) { if(shown==null) result.Add(input.Key,input.Value.Text); input.Value.Clear(); } + } + return result; + } + [STAThread] static int Main(string[] args) { + try { if(args.Length>0 && args[0]=="--entry-guardian") return EntryGuardian(args); if(args.Length>0) return OwnerDialog(args); ServiceBase.Run(new CredentialHost()); return 0; } + catch { return 1; } + } +} diff --git a/app/credentials/native/bootstrap.py b/app/credentials/native/bootstrap.py new file mode 100644 index 000000000..7fceef223 --- /dev/null +++ b/app/credentials/native/bootstrap.py @@ -0,0 +1,19 @@ +"""Entrypoint copied into the administrator-protected install, never a checkout.""" + +import os +from pathlib import Path +import sys + +root = Path(__file__).resolve().parent +# -I -S already excluded environment, cwd, user site and .pth execution. +sys.path[:0] = [ + str(root), + str(root / "deps"), + str(root / "deps" / "win32"), + str(root / "deps" / "win32" / "lib"), + str(root / "deps" / "pywin32_system32"), +] +_dlls = [os.add_dll_directory(str(root / "deps" / "pywin32_system32"))] +from app.credentials.host_service import main # noqa: E402 -- protected import roots first + +main(root) diff --git a/app/credentials/oauth_transport.py b/app/credentials/oauth_transport.py new file mode 100644 index 000000000..6c3e50f74 --- /dev/null +++ b/app/credentials/oauth_transport.py @@ -0,0 +1,46 @@ +"""Execute confidential-client exchanges without releasing client secrets. + +Generated access/refresh tokens return to the existing provider and persistence +callback. The vault holds only the manually configured client secret. +""" + +import json +import httpx + +from .client import CredentialHostUnavailable +from .invocation import invoke +from .model_transport import installed_model_helpers +from .references import parse + + +def request(method, url, *, reference, body, basic=False, json_body=False): + parse(reference) + protocol = "oauth_basic" if basic else "oauth_json" if json_body else "oauth_form" + route = next( + ( + r + for r in installed_model_helpers() + if r["helper"].startswith("oauth_") + and r["endpoint"] == url + and r["method"] == method.upper() + and r.get("protocol") == protocol + ), + None, + ) + if route is None: + raise CredentialHostUnavailable("credential_destination_denied") + public_body = {k: v for k, v in body.items() if k != "client_secret"} + response = invoke( + reference, + route["helper"], + route["operation"], + {"path": {}, "query": {}, "data": public_body}, + subject="OAuth exchange: " + route["helper"], + ) + # Only sanitized JSON; no cookie, redirect or provider headers. + json.loads(response["body"]) + return httpx.Response( + response["status"], + content=response["body"].encode(), + headers={"Content-Type": "application/json"}, + ) diff --git a/app/credentials/operation_session.py b/app/credentials/operation_session.py new file mode 100644 index 000000000..cf20abf6e --- /dev/null +++ b/app/credentials/operation_session.py @@ -0,0 +1,109 @@ +"""Short-lived, exact-operation polling sessions; every grant needs native consent.""" + +import json +import time +from threading import Lock +from .client import CredentialHostUnavailable, request +from .invocation import CredentialResponse +from .references import parse + + +class OperationSession: + def __init__(self, reference, helper, operation, subject): + ref = parse(reference) + self.lock, self.closed = Lock(), False + arguments = dict( + helper=helper, + operation=operation, + credential_id=str(ref.credential_id), + credential_version=ref.version, + repeat=True, + ) + source = ( + "import credential_client, time\ndata = " + + repr(arguments) + + "\n" + + """ +grant = None +try: + while True: + while not credential_client.request('payload_info')['ready']: + time.sleep(0.05) + if grant is None: + approval = credential_client.request('request_use', **data) + grant = approval['grant_id'] + else: + approval = credential_client.request('authorize_grant', grant_id=grant) + credential_client.request('start_use_staged', capability=approval['capability']) + while not credential_client.request('operation_status')['done']: + time.sleep(0.05) + while credential_client.request('operation_status')['active']: + time.sleep(0.05) +except Exception: + credential_client.complete({'error': 'credential_operation_failed'}) +""" + ) + self.run_id = request( + "launch", + profile_id=str(ref.profile_id), + kind="agent_session", + subject=subject, + deployment=None, + files={"poll.py": source}, + entry="poll.py", + )["run_id"] + self.expires = time.monotonic() + 850 + + def invoke(self, body, *, cancelled): + with self.lock: + if self.closed or time.monotonic() >= self.expires: + raise CredentialHostUnavailable("credential_grant_expired") + serialized = json.dumps( + body, ensure_ascii=True, allow_nan=False, separators=(",", ":") + ) + if len(serialized) > 32_000_000: + raise CredentialHostUnavailable("credential_use_limit") + try: + for offset in range(0, len(serialized), 8000): + chunk = serialized[offset : offset + 8000] + request( + "stage_payload", + run_id=self.run_id, + offset=offset, + data=chunk, + done=offset + len(chunk) == len(serialized), + ) + deadline = time.monotonic() + 900 + while time.monotonic() < deadline: + if cancelled.is_set(): + raise CredentialHostUnavailable("credential_use_interrupted") + result = request("result", run_id=self.run_id) + if "response" in result: + response = CredentialResponse( + self.run_id, + result["response"], + cancelled, + stop_on_close=False, + ) + text = "".join(response) + request("reset_operation", run_id=self.run_id) + return dict( + status=response.status, + content_type=response.content_type, + body=text, + ) + if "exited" in result or "result" in result: + raise CredentialHostUnavailable("credential_operation_failed") + time.sleep(0.05) + raise CredentialHostUnavailable("credential_operation_timeout") + except BaseException: + self.close() + raise + + def close(self): + if not self.closed: + self.closed = True + try: + request("stop", run_id=self.run_id) + except CredentialHostUnavailable: + pass diff --git a/app/credentials/owner_transport.py b/app/credentials/owner_transport.py new file mode 100644 index 000000000..72621af2e --- /dev/null +++ b/app/credentials/owner_transport.py @@ -0,0 +1,261 @@ +"""Protected native consent/entry. Never use the browser bridge as owner proof.""" + +from __future__ import annotations + +import base64 +import json +from pathlib import Path +import struct +import subprocess +from uuid import uuid4 + +from .errors import VaultError, VaultErrorCode +from .secrets import Secret +from .windows_host import Peer, SYSTEM, api + + +class WindowsOwnerConsent: + def __init__(self, executable: Path, owner_sid: str): + self.executable = executable + self.owner_sid = owner_sid + self.session = None + self.message = None + self.review_request = None + + def verify(self, owner): + if ( + owner.os_identity != self.owner_sid + or self.session is None + or not self.message + ): + raise VaultError(VaultErrorCode.VERIFICATION_REQUIRED) + review, self.review_request = self.review_request, None + if review is None: + self.prompt() + else: + self._exchange(tuple(review), display=Secret(review), review=True) + + def _launch(self, arguments): + winapi, _, job, process, security, _, event = api() + if not isinstance(self.message, str) or len(self.message) > 1000: + raise VaultError(VaultErrorCode.VERIFICATION_REQUIRED) + text = base64.b64encode(self.message.encode("utf-8")).decode("ascii") + current = security.OpenProcessToken(winapi.GetCurrentProcess(), 0xF01FF) + token = security.DuplicateTokenEx(current, 2, 0xF01FF, 1) + current.Close() + group = job.CreateJobObject(None, "") + info = job.QueryInformationJobObject( + group, job.JobObjectExtendedLimitInformation + ) + # Only the fixed entry guardian may break away. It restores the prior + # desktop if this job/worker/service is killed while source entry runs. + info["BasicLimitInformation"]["LimitFlags"] = 0x2000 | ( + 0x800 if arguments[0] in ("--entry", "--view", "--review") else 0 + ) + job.SetInformationJobObject(group, job.JobObjectExtendedLimitInformation, info) + try: + security.SetTokenInformation(token, 12, self.session) + startup = process.STARTUPINFO() + startup.lpDesktop = "winsta0\\default" + handle, thread, pid, _ = process.CreateProcessAsUser( + token, + str(self.executable), + subprocess.list2cmdline( + [ + str(self.executable), + arguments[0], + self.owner_sid, + text, + *arguments[1:], + ] + ), + None, + None, + False, + 4 | 0x08000000, + {"SYSTEMROOT": winapi.GetWindowsDirectory()}, + str(self.executable.parent), + startup, + ) + try: + job.AssignProcessToJobObject(group, handle) + process.ResumeThread(thread) + finally: + thread.Close() + return handle, group, pid + except BaseException: + group.Close() + raise + finally: + token.Close() + + def prompt(self): + handle, group, _ = self._launch(["--consent"]) + try: + if ( + api()[6].WaitForSingleObject(handle, 90000) != 0 + or api()[3].GetExitCodeProcess(handle) != 0 + ): + raise VaultError(VaultErrorCode.VERIFICATION_REQUIRED) + finally: + group.Close() + handle.Close() + + def entry(self, fields: tuple[str, ...]) -> Secret: + """Native SYSTEM form -> private SYSTEM pipe -> vault; never caller JSON.""" + return self._exchange(fields) + + def view(self, fields: Secret) -> None: + """Owner-only display on the private desktop, with no global clipboard.""" + self._exchange(tuple(fields.reveal()), display=fields) + + def _exchange(self, fields, display=None, *, review=False): + import pywintypes + import win32file + import win32pipe + import win32security + + name = "CraftBot.CredentialEntry." + uuid4().hex + attributes = pywintypes.SECURITY_ATTRIBUTES() + attributes.SECURITY_DESCRIPTOR = ( + win32security.ConvertStringSecurityDescriptorToSecurityDescriptor( + "O:SYD:P(A;;GA;;;SY)", 1 + ) + ) + pipe = win32pipe.CreateNamedPipe( + "\\\\.\\pipe\\" + name, + 3 | 0x80000 | 0x40000000, + 8, + 1, + 65536, + 65536, + 0, + attributes, + ) + handle = group = None + pending = pywintypes.OVERLAPPED() + pending.hEvent = api()[6].CreateEvent(None, True, False, None) + try: + try: + win32pipe.ConnectNamedPipe(pipe, pending) + except pywintypes.error as error: + if error.winerror not in (997, 535): + raise + handle, group, pid = self._launch( + [ + "--review" + if review + else "--entry" + if display is None + else "--view", + name, + ",".join(fields), + ] + ) + if ( + api()[6].WaitForMultipleObjects([pending.hEvent, handle], False, 180000) + != 0 + ): + raise VaultError(VaultErrorCode.VERIFICATION_REQUIRED) + # Kernel process ID, not the JSON body or a claimed SYSTEM flag. + import ctypes + + connected = ctypes.c_ulong() + dll = ctypes.WinDLL("kernel32", use_last_error=True) + dll.GetNamedPipeClientProcessId.argtypes = [ + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_ulong), + ] + if ( + not dll.GetNamedPipeClientProcessId(int(pipe), ctypes.byref(connected)) + or connected.value != pid + ): + raise VaultError(VaultErrorCode.VERIFICATION_REQUIRED) + peer = Peer(pid) + try: + if peer.sid != SYSTEM: + raise VaultError(VaultErrorCode.VERIFICATION_REQUIRED) + finally: + peer.close() + + if display is not None: + data = Secret( + json.dumps(display.reveal(), ensure_ascii=False).encode("utf-8") + ) + if len(data.reveal()) > 2_100_000: + raise VaultError(VaultErrorCode.INVALID) + framed = Secret(struct.pack(" 32000: + raise CredentialHostUnavailable("credential_use_limit") + while "\n\n" in pending: + record, pending = pending.split("\n\n", 1) + if not record.startswith("data: "): + raise CredentialHostUnavailable("credential_operation_failed") + value = json.loads(record[6:]) + if "qr_url" in value: + if callback: + callback(value["qr_url"]) + else: + result = value + if result is None: + raise CredentialHostUnavailable("credential_operation_failed") + return result + finally: + response.close() + + task = asyncio.create_task(asyncio.to_thread(qr)) + try: + return await asyncio.shield(task) + except asyncio.CancelledError: + cancelled.set() + task.add_done_callback( + lambda future: future.exception() if not future.cancelled() else None + ) + raise diff --git a/app/credentials/provider_routes.json b/app/credentials/provider_routes.json new file mode 100644 index 000000000..ccedda100 --- /dev/null +++ b/app/credentials/provider_routes.json @@ -0,0 +1,20072 @@ +[ + { + "type": "json", + "helper": "integration_twitter", + "operation": "get_me_ea712eb9", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/users/me", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "post_tweet_c71310a0", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/tweets", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "id", + "in_reply_to_tweet_id", + "limit", + "list.fields", + "media.fields", + "offset", + "page", + "per_page", + "reply", + "text", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "delete_tweet_3041bed1", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/tweets/:tweet_id", + "method": "DELETE", + "path_fields": [ + "tweet_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "list.fields", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "get_user_timeline_da3ecda5", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/users/:uid/tweets", + "method": "GET", + "path_fields": [ + "uid" + ], + "query_fields": [ + "cursor", + "error", + "expand", + "limit", + "list.fields", + "max_results", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "search_tweets_9b800415", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/tweets/search/recent", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "expansions", + "limit", + "list.fields", + "max_results", + "media.fields", + "offset", + "page", + "per_page", + "query", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "get_user_by_username_f4ac2f39", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/users/by/username/:username", + "method": "GET", + "path_fields": [ + "username" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "get_tweet_a804f822", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/tweets/:tweet_id", + "method": "GET", + "path_fields": [ + "tweet_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "lookup_tweets_bf43de6a", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/tweets", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "ids", + "limit", + "list.fields", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "get_user_mentions_ea2ed2f7", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/users/:uid/mentions", + "method": "GET", + "path_fields": [ + "uid" + ], + "query_fields": [ + "cursor", + "error", + "expand", + "expansions", + "limit", + "list.fields", + "max_results", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "hide_reply_f8408814", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/tweets/:reply_tweet_id/hidden", + "method": "PUT", + "path_fields": [ + "reply_tweet_id" + ], + "query_fields": [ + "cursor", + "expand", + "hidden", + "limit", + "list.fields", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "list_liking_users_217e11a1", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/tweets/:tweet_id/liking_users", + "method": "GET", + "path_fields": [ + "tweet_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "max_results", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "list_retweeted_by_0fb83c4c", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/tweets/:tweet_id/retweeted_by", + "method": "GET", + "path_fields": [ + "tweet_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "max_results", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "list_following_94726f9b", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/users/:uid/following", + "method": "GET", + "path_fields": [ + "uid" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "max_results", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "list_followers_a0a017c6", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/users/:uid/followers", + "method": "GET", + "path_fields": [ + "uid" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "max_results", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "create_list_9abf7742", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/lists", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "description", + "expand", + "limit", + "list.fields", + "media.fields", + "name", + "offset", + "page", + "per_page", + "private", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "get_list_ceb214e1", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/lists/:list_id", + "method": "GET", + "path_fields": [ + "list_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "update_list_9bd0ac37", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/lists/:list_id", + "method": "PUT", + "path_fields": [ + "list_id" + ], + "query_fields": [ + "cursor", + "description", + "error", + "expand", + "limit", + "list.fields", + "media.fields", + "name", + "offset", + "page", + "per_page", + "private", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "delete_list_f7191322", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/lists/:list_id", + "method": "DELETE", + "path_fields": [ + "list_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "list.fields", + "list_id", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "list_owned_lists_b8e64be0", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/users/:uid/owned_lists", + "method": "GET", + "path_fields": [ + "uid" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "max_results", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "add_list_member_7ff33de0", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/lists/:list_id/members", + "method": "POST", + "path_fields": [ + "list_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "remove_list_member_435923eb", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/lists/:list_id/members/:user_id", + "method": "DELETE", + "path_fields": [ + "list_id", + "user_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "list_list_members_8d93efa1", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/lists/:list_id/members", + "method": "GET", + "path_fields": [ + "list_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "max_results", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "list_list_tweets_8e7a4b5c", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/lists/:list_id/tweets", + "method": "GET", + "path_fields": [ + "list_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "max_results", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "send_dm_to_user_884e8cd6", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/dm_conversations/with/:participant_id/messages", + "method": "POST", + "path_fields": [ + "participant_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "media.fields", + "offset", + "page", + "per_page", + "text", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "send_dm_to_conversation_73b20561", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/dm_conversations/:dm_conversation_id/messages", + "method": "POST", + "path_fields": [ + "dm_conversation_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "media.fields", + "offset", + "page", + "per_page", + "text", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "create_group_dm_397a5045", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/dm_conversations", + "method": "POST", + "path_fields": [], + "query_fields": [ + "conversation_type", + "cursor", + "expand", + "limit", + "list.fields", + "media.fields", + "message", + "offset", + "page", + "participant_ids", + "per_page", + "text", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "list_dm_events_11e3db85", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/dm_events", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "max_results", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "list_dm_events_with_user_5bbb3a82", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/dm_conversations/with/:participant_id/dm_events", + "method": "GET", + "path_fields": [ + "participant_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "max_results", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "check_updates_60bf0c44", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/search/jql", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "error", + "expand", + "fields", + "jql", + "limit", + "maxResults", + "offset", + "page", + "per_page", + "result" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_myself_5446621a", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/myself", + "method": "GET", + "path_fields": [], + "query_fields": [ + "accountId", + "active", + "cursor", + "displayName", + "emailAddress", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_issue_2589c2f0", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key", + "method": "GET", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "cursor", + "expand", + "fields", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "create_issue_76346148", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue", + "method": "POST", + "path_fields": [], + "query_fields": [ + "accountId", + "assignee", + "cursor", + "description", + "expand", + "fields", + "id", + "issuetype", + "key", + "labels", + "limit", + "name", + "offset", + "page", + "per_page", + "priority", + "project", + "self", + "summary" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "update_issue_c678a8ae", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key", + "method": "PUT", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "cursor", + "expand", + "fields", + "key", + "limit", + "offset", + "page", + "per_page", + "updated" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "add_comment_9a6035ce", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/comment", + "method": "POST", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "author", + "body", + "created", + "cursor", + "expand", + "id", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_transitions_43f1a667", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/transitions", + "method": "GET", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "cursor", + "expand", + "id", + "limit", + "name", + "offset", + "page", + "per_page", + "to", + "transitions" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "transition_issue_7d484204", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/transitions", + "method": "POST", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "add", + "body", + "comment", + "cursor", + "expand", + "id", + "key", + "limit", + "offset", + "page", + "per_page", + "transition", + "transitioned", + "update" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "assign_issue_aff52ffa", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/assignee", + "method": "PUT", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "accountId", + "assigned", + "cursor", + "expand", + "key", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_projects_afcae16f", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/project/search", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "id", + "key", + "limit", + "maxResults", + "name", + "offset", + "page", + "per_page", + "projects", + "style" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "search_users_f33eb3d6", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/user/search", + "method": "GET", + "path_fields": [], + "query_fields": [ + "accountId", + "active", + "cursor", + "displayName", + "emailAddress", + "expand", + "limit", + "maxResults", + "offset", + "page", + "per_page", + "query", + "users" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_issue_comments_8fa11594", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/comment", + "method": "GET", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "author", + "body", + "comments", + "created", + "cursor", + "expand", + "id", + "limit", + "maxResults", + "offset", + "orderBy", + "page", + "per_page", + "total", + "updated" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_statuses_5fe069ff", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/project/:project_key/statuses", + "method": "GET", + "path_fields": [ + "project_key" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "delete_issue_1c9714a3", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key", + "method": "DELETE", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "cursor", + "deleteSubtasks", + "deleted", + "expand", + "key", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "update_comment_1e13f35a", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/comment/:comment_id", + "method": "PUT", + "path_fields": [ + "issue_key", + "comment_id" + ], + "query_fields": [ + "author", + "body", + "cursor", + "expand", + "id", + "limit", + "offset", + "page", + "per_page", + "updated" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "delete_comment_e8f664d5", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/comment/:comment_id", + "method": "DELETE", + "path_fields": [ + "issue_key", + "comment_id" + ], + "query_fields": [ + "comment_id", + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_watchers_74395187", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/watchers", + "method": "GET", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "accountId", + "active", + "cursor", + "displayName", + "expand", + "is_watching", + "limit", + "offset", + "page", + "per_page", + "watch_count", + "watchers" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "add_watcher_9e5a97b9", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/watchers", + "method": "POST", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "account_id", + "added", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "remove_watcher_21449e0e", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/watchers", + "method": "DELETE", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "accountId", + "account_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "removed" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "add_attachment_ef6a982c", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/attachments", + "method": "POST", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "Accept", + "Authorization", + "attachments", + "content", + "cursor", + "details", + "error", + "expand", + "file", + "filename", + "id", + "limit", + "offset", + "page", + "per_page", + "size" + ], + "envelope": true, + "request_format": "multipart", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_attachment_130fb858", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/attachment/:attachment_id", + "method": "GET", + "path_fields": [ + "attachment_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "delete_attachment_ba8f1bc2", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/attachment/:attachment_id", + "method": "DELETE", + "path_fields": [ + "attachment_id" + ], + "query_fields": [ + "attachment_id", + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "add_worklog_0a1443fb", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/worklog", + "method": "POST", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "author", + "comment", + "cursor", + "expand", + "id", + "limit", + "offset", + "page", + "per_page", + "started", + "timeSpent", + "timeSpentSeconds" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_worklogs_39be1730", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/worklog", + "method": "GET", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "author", + "comment", + "cursor", + "expand", + "id", + "limit", + "offset", + "page", + "per_page", + "started", + "timeSpent", + "timeSpentSeconds", + "total", + "worklogs" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "update_worklog_b921cea1", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/worklog/:worklog_id", + "method": "PUT", + "path_fields": [ + "issue_key", + "worklog_id" + ], + "query_fields": [ + "comment", + "cursor", + "expand", + "id", + "limit", + "offset", + "page", + "per_page", + "started", + "timeSpent", + "timeSpentSeconds", + "updated" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "delete_worklog_3ef35115", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/worklog/:worklog_id", + "method": "DELETE", + "path_fields": [ + "issue_key", + "worklog_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page", + "worklog_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "create_issue_link_96063bbc", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issueLink", + "method": "POST", + "path_fields": [], + "query_fields": [ + "body", + "comment", + "created", + "cursor", + "expand", + "inward", + "inwardIssue", + "key", + "limit", + "name", + "offset", + "outward", + "outwardIssue", + "page", + "per_page", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_issue_link_5f3dbb8b", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issueLink/:link_id", + "method": "GET", + "path_fields": [ + "link_id" + ], + "query_fields": [ + "cursor", + "expand", + "id", + "inwardIssue", + "key", + "limit", + "offset", + "outwardIssue", + "page", + "per_page", + "status", + "summary", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "delete_issue_link_14588bd7", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issueLink/:link_id", + "method": "DELETE", + "path_fields": [ + "link_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "link_id", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "list_issue_link_types_ea14a613", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issueLinkType", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "id", + "inward", + "limit", + "name", + "offset", + "outward", + "page", + "per_page", + "types" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "list_versions_bb00758c", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/project/:project_key/versions", + "method": "GET", + "path_fields": [ + "project_key" + ], + "query_fields": [ + "archived", + "cursor", + "expand", + "id", + "limit", + "name", + "offset", + "page", + "per_page", + "releaseDate", + "released", + "versions" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "create_version_40409a09", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/version", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "description", + "error", + "expand", + "id", + "limit", + "name", + "offset", + "page", + "per_page", + "projectId", + "releaseDate", + "released", + "startDate" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "update_version_486ba69a", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/version/:version_id", + "method": "PUT", + "path_fields": [ + "version_id" + ], + "query_fields": [ + "archived", + "cursor", + "description", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "releaseDate", + "released" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "delete_version_0dfecaea", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/version/:version_id", + "method": "DELETE", + "path_fields": [ + "version_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page", + "version_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "list_components_11e00965", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/project/:project_key/components", + "method": "GET", + "path_fields": [ + "project_key" + ], + "query_fields": [ + "components", + "cursor", + "description", + "expand", + "id", + "lead", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "create_component_f7095b9a", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/component", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "description", + "expand", + "id", + "leadAccountId", + "limit", + "name", + "offset", + "page", + "per_page", + "project" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "delete_component_270aec2d", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/component/:component_id", + "method": "DELETE", + "path_fields": [ + "component_id" + ], + "query_fields": [ + "component_id", + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_project_c7bc4fb8", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/project/:project_key", + "method": "GET", + "path_fields": [ + "project_key" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "list_priorities_f89e0fee", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/priority", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "id", + "limit", + "name", + "offset", + "page", + "per_page", + "priorities" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "list_issue_types_df73970f", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issuetype", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "description", + "expand", + "id", + "issue_types", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "list_boards_0eef9824", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/board", + "method": "GET", + "path_fields": [], + "query_fields": [ + "boards", + "cursor", + "expand", + "id", + "limit", + "location", + "maxResults", + "name", + "offset", + "page", + "per_page", + "projectKeyOrId", + "total", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_board_815c130e", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/board/:board_id", + "method": "GET", + "path_fields": [ + "board_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_board_issues_08ff37b5", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/board/:board_id/issue", + "method": "GET", + "path_fields": [ + "board_id" + ], + "query_fields": [ + "cursor", + "expand", + "fields", + "issues", + "jql", + "limit", + "maxResults", + "offset", + "page", + "per_page", + "total" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_board_sprints_93030111", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/board/:board_id/sprint", + "method": "GET", + "path_fields": [ + "board_id" + ], + "query_fields": [ + "cursor", + "endDate", + "expand", + "goal", + "id", + "limit", + "maxResults", + "name", + "offset", + "page", + "per_page", + "sprints", + "startDate", + "state", + "total" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_board_backlog_bea79f3f", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/board/:board_id/backlog", + "method": "GET", + "path_fields": [ + "board_id" + ], + "query_fields": [ + "cursor", + "expand", + "fields", + "issues", + "limit", + "maxResults", + "offset", + "page", + "per_page", + "total" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_sprint_b09028f3", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/sprint/:sprint_id", + "method": "GET", + "path_fields": [ + "sprint_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_sprint_issues_98b66050", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/sprint/:sprint_id/issue", + "method": "GET", + "path_fields": [ + "sprint_id" + ], + "query_fields": [ + "cursor", + "expand", + "fields", + "issues", + "jql", + "limit", + "maxResults", + "offset", + "page", + "per_page", + "total" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "create_sprint_8f0125b1", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/sprint", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "endDate", + "expand", + "goal", + "id", + "limit", + "name", + "offset", + "originBoardId", + "page", + "per_page", + "startDate", + "state" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "update_sprint_ee0d7ead", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/sprint/:sprint_id", + "method": "POST", + "path_fields": [ + "sprint_id" + ], + "query_fields": [ + "cursor", + "endDate", + "expand", + "goal", + "limit", + "name", + "offset", + "page", + "per_page", + "startDate", + "state" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "delete_sprint_46ac798a", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/sprint/:sprint_id", + "method": "DELETE", + "path_fields": [ + "sprint_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page", + "sprint_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "move_issues_to_sprint_fc5e2a2f", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/sprint/:sprint_id/issue", + "method": "POST", + "path_fields": [ + "sprint_id" + ], + "query_fields": [ + "cursor", + "expand", + "issues", + "limit", + "moved", + "offset", + "page", + "per_page", + "sprint_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "move_issues_to_backlog_a39da8c5", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/backlog/issue", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "issues", + "limit", + "moved", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_epic_4aaed319", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/epic/:epic_id_or_key", + "method": "GET", + "path_fields": [ + "epic_id_or_key" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_epic_issues_c9bf6f5b", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/epic/:epic_id_or_key/issue", + "method": "GET", + "path_fields": [ + "epic_id_or_key" + ], + "query_fields": [ + "cursor", + "expand", + "fields", + "issues", + "limit", + "maxResults", + "offset", + "page", + "per_page", + "total" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "move_issues_to_epic_16c1a13a", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/epic/:epic_id_or_key/issue", + "method": "POST", + "path_fields": [ + "epic_id_or_key" + ], + "query_fields": [ + "cursor", + "epic", + "expand", + "issues", + "limit", + "moved", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_github", + "operation": "check_notifications_sync_10bac9bf", + "kind": "api_key", + "endpoint": "https://api.github.com/notifications", + "method": "GET", + "path_fields": [], + "query_fields": [ + "all", + "cursor", + "expand", + "last_modified", + "limit", + "notifications", + "offset", + "page", + "participating", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_authenticated_user_a78c5cdf", + "kind": "api_key", + "endpoint": "https://api.github.com/user", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "id", + "limit", + "login", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_repos_84cda9eb", + "kind": "api_key", + "endpoint": "https://api.github.com/user/repos", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "description", + "expand", + "full_name", + "limit", + "name", + "offset", + "page", + "per_page", + "private", + "repos", + "sort" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_repo_397d5063", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "archived", + "cursor", + "default_branch", + "description", + "expand", + "fork", + "forks_count", + "full_name", + "html_url", + "language", + "limit", + "name", + "offset", + "open_issues_count", + "owner", + "page", + "per_page", + "private", + "pushed_at", + "stargazers_count", + "topics" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_issues_eefb6999", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "assignees", + "created_at", + "cursor", + "expand", + "is_pr", + "issues", + "labels", + "limit", + "number", + "offset", + "page", + "per_page", + "state", + "title", + "updated_at", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_issue_7c1d3957", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number", + "method": "GET", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "assignees", + "body", + "closed_at", + "comments", + "created_at", + "cursor", + "expand", + "html_url", + "is_pr", + "labels", + "limit", + "milestone", + "number", + "offset", + "page", + "per_page", + "state", + "title", + "updated_at", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "create_issue_e019cfa0", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues", + "method": "POST", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "assignees", + "body", + "cursor", + "expand", + "html_url", + "labels", + "limit", + "number", + "offset", + "page", + "per_page", + "title" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "create_comment_a293e0a9", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number/comments", + "method": "POST", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "body", + "cursor", + "expand", + "html_url", + "id", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_pull_requests_b9e6fef4", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "base", + "created_at", + "cursor", + "draft", + "expand", + "head", + "limit", + "number", + "offset", + "page", + "per_page", + "pull_requests", + "state", + "title", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "search_issues_a3309c38", + "kind": "api_key", + "endpoint": "https://api.github.com/search/issues", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "html_url", + "items", + "limit", + "number", + "offset", + "page", + "per_page", + "q", + "repo", + "state", + "title", + "total_count", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "add_labels_83f2f3bf", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number/labels", + "method": "POST", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "cursor", + "expand", + "labels", + "labels_added", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "close_issue_10f7d874", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number", + "method": "PATCH", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "closed", + "cursor", + "expand", + "limit", + "number", + "offset", + "page", + "per_page", + "state" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "create_repo_6caaab32", + "kind": "api_key", + "endpoint": "https://api.github.com/user/repos", + "method": "POST", + "path_fields": [], + "query_fields": [ + "auto_init", + "cursor", + "description", + "expand", + "full_name", + "html_url", + "limit", + "name", + "offset", + "page", + "per_page", + "private" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "update_repo_b11bc9de", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo", + "method": "PATCH", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "archived", + "cursor", + "default_branch", + "description", + "expand", + "full_name", + "html_url", + "limit", + "name", + "offset", + "page", + "per_page", + "private" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "delete_repo_b3a8302b", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo", + "method": "DELETE", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page", + "repo" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "fork_repo_570f436b", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/forks", + "method": "POST", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "default_branch", + "default_branch_only", + "expand", + "full_name", + "html_url", + "limit", + "name", + "offset", + "organization", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_forks_80ce3d89", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/forks", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "expand", + "forks", + "full_name", + "html_url", + "limit", + "offset", + "owner", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_collaborators_6bb42ef0", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/collaborators", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "collaborators", + "cursor", + "expand", + "limit", + "login", + "offset", + "page", + "per_page", + "permissions" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "add_collaborator_43bc7c83", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/collaborators/:username", + "method": "PUT", + "path_fields": [ + "owner_repo", + "username" + ], + "query_fields": [ + "added", + "cursor", + "expand", + "invitation_id", + "limit", + "offset", + "page", + "per_page", + "permission", + "username" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "remove_collaborator_3e8c06d1", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/collaborators/:username", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "username" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "removed", + "username" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_readme_d7fedf37", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/readme", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "content_b64", + "cursor", + "download_url", + "encoding", + "expand", + "limit", + "name", + "offset", + "page", + "path", + "per_page", + "ref" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_topics_779c4c31", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/topics", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "topics" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "set_topics_a5e90531", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/topics", + "method": "PUT", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "names", + "offset", + "page", + "per_page", + "topics" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_file_6c381eae", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/contents/:path", + "method": "GET", + "path_fields": [ + "owner_repo", + "path" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "ref" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "create_or_update_file_6816c240", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/contents/:path", + "method": "PUT", + "path_fields": [ + "owner_repo", + "path" + ], + "query_fields": [ + "branch", + "commit_html_url", + "commit_sha", + "content", + "content_html_url", + "content_sha", + "cursor", + "expand", + "limit", + "message", + "offset", + "page", + "path", + "per_page", + "sha" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "delete_file_065da4e6", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/contents/:path", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "path" + ], + "query_fields": [ + "branch", + "commit_sha", + "cursor", + "deleted", + "expand", + "limit", + "message", + "offset", + "page", + "per_page", + "sha" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_branches_31deaff4", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/branches", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "branches", + "cursor", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "protected", + "sha" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_branch_3802f403", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/branches/:branch", + "method": "GET", + "path_fields": [ + "owner_repo", + "branch" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "protected", + "sha" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "create_branch_0b193e87", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/git/refs", + "method": "POST", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "ref", + "sha" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "delete_branch_2679891b", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/git/refs/heads/:branch", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "branch" + ], + "query_fields": [ + "branch", + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_commits_330d6995", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/commits", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "author", + "commits", + "cursor", + "date", + "expand", + "html_url", + "limit", + "message", + "offset", + "page", + "path", + "per_page", + "sha" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_commit_6569438f", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/commits/:sha", + "method": "GET", + "path_fields": [ + "owner_repo", + "sha" + ], + "query_fields": [ + "additions", + "author", + "committer", + "cursor", + "date", + "deletions", + "email", + "expand", + "filename", + "files", + "html_url", + "limit", + "login", + "message", + "name", + "offset", + "page", + "parents", + "patch", + "per_page", + "sha", + "stats", + "status" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_pull_request_3ca661c7", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/:number", + "method": "GET", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "additions", + "assignees", + "base", + "body", + "changed_files", + "closed_at", + "comments", + "commits", + "created_at", + "cursor", + "deletions", + "draft", + "expand", + "head", + "html_url", + "labels", + "limit", + "mergeable", + "merged", + "merged_at", + "merged_by", + "milestone", + "number", + "offset", + "page", + "per_page", + "ref", + "repo", + "requested_reviewers", + "sha", + "state", + "title", + "updated_at", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "create_pull_request_c6865d07", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls", + "method": "POST", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "base", + "body", + "cursor", + "draft", + "expand", + "head", + "html_url", + "limit", + "maintainer_can_modify", + "number", + "offset", + "page", + "per_page", + "state", + "title" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "update_pull_request_40e48b66", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/:number", + "method": "PATCH", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "base", + "body", + "cursor", + "expand", + "html_url", + "limit", + "number", + "offset", + "page", + "per_page", + "state", + "title" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "merge_pull_request_36fcf780", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/:number/merge", + "method": "PUT", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "commit_message", + "commit_title", + "cursor", + "expand", + "limit", + "merge_method", + "merged", + "message", + "offset", + "page", + "per_page", + "sha" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_pr_files_be3d68e2", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/:number/files", + "method": "GET", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "additions", + "cursor", + "deletions", + "expand", + "filename", + "files", + "limit", + "offset", + "page", + "patch", + "per_page", + "status" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_pr_commits_ac738547", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/:number/commits", + "method": "GET", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "author", + "commits", + "cursor", + "expand", + "limit", + "message", + "offset", + "page", + "per_page", + "sha" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "request_pr_reviewers_3a2ce7d1", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/:number/requested_reviewers", + "method": "POST", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "requested", + "reviewers", + "team_reviewers" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "remove_pr_reviewers_63b7927f", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/:number/requested_reviewers", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "removed", + "reviewers", + "team_reviewers" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "create_pr_review_b941ad52", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/:number/reviews", + "method": "POST", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "body", + "comments", + "cursor", + "event", + "expand", + "html_url", + "id", + "limit", + "offset", + "page", + "per_page", + "state" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_pr_reviews_e89f45c1", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/:number/reviews", + "method": "GET", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "body", + "cursor", + "expand", + "id", + "limit", + "offset", + "page", + "per_page", + "reviews", + "state", + "submitted_at", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "submit_pr_review_f9f1c88e", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/:number/reviews/:review_id/events", + "method": "POST", + "path_fields": [ + "owner_repo", + "number", + "review_id" + ], + "query_fields": [ + "body", + "cursor", + "event", + "expand", + "id", + "limit", + "offset", + "page", + "per_page", + "state" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_pr_review_comments_93d682d6", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/:number/comments", + "method": "GET", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "body", + "comments", + "cursor", + "expand", + "id", + "limit", + "line", + "offset", + "page", + "path", + "per_page", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "create_pr_review_comment_61a8cf38", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/:number/comments", + "method": "POST", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "body", + "commit_id", + "cursor", + "expand", + "html_url", + "id", + "limit", + "line", + "offset", + "page", + "path", + "per_page", + "side" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "lock_issue_52338b9d", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number/lock", + "method": "PUT", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "lock_reason", + "locked", + "number", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "unlock_issue_466c7d2d", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number/lock", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "number", + "offset", + "page", + "per_page", + "unlocked" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_issue_comments_6730fbf8", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number/comments", + "method": "GET", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "body", + "comments", + "created_at", + "cursor", + "expand", + "id", + "limit", + "offset", + "page", + "per_page", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "update_issue_comment_b3934ec1", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/comments/:comment_id", + "method": "PATCH", + "path_fields": [ + "owner_repo", + "comment_id" + ], + "query_fields": [ + "body", + "cursor", + "expand", + "html_url", + "id", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "delete_issue_comment_287bd680", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/comments/:comment_id", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "comment_id" + ], + "query_fields": [ + "comment_id", + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_issue_events_f7f72dd8", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number/events", + "method": "GET", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "actor", + "created_at", + "cursor", + "event", + "events", + "expand", + "id", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "remove_issue_label_498532b9", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number/labels/:name", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "number", + "name" + ], + "query_fields": [ + "cursor", + "expand", + "labels", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "set_issue_labels_f2fcd85f", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number/labels", + "method": "PUT", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "cursor", + "expand", + "labels", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "add_assignees_47fa7b48", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number/assignees", + "method": "POST", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "assignees", + "cursor", + "expand", + "limit", + "number", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "remove_assignees_fa8a863b", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number/assignees", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "assignees", + "cursor", + "expand", + "limit", + "number", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_repo_labels_17c3bca8", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/labels", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "color", + "cursor", + "description", + "expand", + "labels", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "create_label_2fada9bc", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/labels", + "method": "POST", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "color", + "cursor", + "description", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "url" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "update_label_95da2099", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/labels/:name", + "method": "PATCH", + "path_fields": [ + "owner_repo", + "name" + ], + "query_fields": [ + "color", + "cursor", + "description", + "expand", + "limit", + "name", + "new_name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "delete_label_2ea78b86", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/labels/:name", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "name" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_milestones_c1a60f94", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/milestones", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "closed_issues", + "cursor", + "due_on", + "expand", + "limit", + "milestones", + "number", + "offset", + "open_issues", + "page", + "per_page", + "state", + "title" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "create_milestone_4366636a", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/milestones", + "method": "POST", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "description", + "due_on", + "expand", + "html_url", + "limit", + "number", + "offset", + "page", + "per_page", + "state", + "title" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "update_milestone_f1132ea8", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/milestones/:number", + "method": "PATCH", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "cursor", + "description", + "due_on", + "expand", + "limit", + "number", + "offset", + "page", + "per_page", + "state", + "title" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "delete_milestone_092b879e", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/milestones/:number", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "number", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_releases_0ce2db6e", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/releases", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "draft", + "expand", + "html_url", + "id", + "limit", + "name", + "offset", + "page", + "per_page", + "prerelease", + "published_at", + "releases", + "tag_name" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_release_7cfad5df", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/releases/:release_id", + "method": "GET", + "path_fields": [ + "owner_repo", + "release_id" + ], + "query_fields": [ + "assets", + "author", + "body", + "browser_download_url", + "created_at", + "cursor", + "download_count", + "draft", + "error", + "expand", + "html_url", + "id", + "limit", + "name", + "offset", + "page", + "per_page", + "prerelease", + "published_at", + "size", + "tag_name" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "create_release_2800e591", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/releases", + "method": "POST", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "body", + "cursor", + "draft", + "expand", + "html_url", + "id", + "limit", + "name", + "offset", + "page", + "per_page", + "prerelease", + "tag_name", + "target_commitish" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "update_release_cc9b361c", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/releases/:release_id", + "method": "PATCH", + "path_fields": [ + "owner_repo", + "release_id" + ], + "query_fields": [ + "body", + "cursor", + "draft", + "expand", + "html_url", + "id", + "limit", + "name", + "offset", + "page", + "per_page", + "prerelease", + "tag_name" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "delete_release_728ed7c7", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/releases/:release_id", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "release_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page", + "release_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_tags_ef00b15e", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/tags", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "sha", + "tags" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "add_issue_reaction_b94b540d", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number/reactions", + "method": "POST", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "content", + "cursor", + "expand", + "id", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "add_issue_comment_reaction_b395a890", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/comments/:comment_id/reactions", + "method": "POST", + "path_fields": [ + "owner_repo", + "comment_id" + ], + "query_fields": [ + "content", + "cursor", + "expand", + "id", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "add_pr_review_comment_reaction_e271355e", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/comments/:comment_id/reactions", + "method": "POST", + "path_fields": [ + "owner_repo", + "comment_id" + ], + "query_fields": [ + "content", + "cursor", + "expand", + "id", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "delete_issue_reaction_4705eb3d", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number/reactions/:reaction_id", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "number", + "reaction_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page", + "reaction_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "delete_issue_comment_reaction_1f80dcf1", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/comments/:comment_id/reactions/:reaction_id", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "comment_id", + "reaction_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page", + "reaction_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "delete_pr_review_comment_reaction_f8b831f4", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/comments/:comment_id/reactions/:reaction_id", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "comment_id", + "reaction_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page", + "reaction_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "search_repos_730bece5", + "kind": "api_key", + "endpoint": "https://api.github.com/search/repositories", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "description", + "expand", + "full_name", + "html_url", + "items", + "language", + "limit", + "offset", + "page", + "per_page", + "q", + "stars", + "total_count" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "search_code_7ea83252", + "kind": "api_key", + "endpoint": "https://api.github.com/search/code", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "html_url", + "items", + "limit", + "name", + "offset", + "page", + "path", + "per_page", + "q", + "repo", + "total_count" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "search_users_5e5b0933", + "kind": "api_key", + "endpoint": "https://api.github.com/search/users", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "html_url", + "items", + "limit", + "login", + "offset", + "page", + "per_page", + "q", + "total_count", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "search_commits_0f994a59", + "kind": "api_key", + "endpoint": "https://api.github.com/search/commits", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "html_url", + "items", + "limit", + "message", + "offset", + "page", + "per_page", + "q", + "repo", + "sha", + "total_count" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_user_c90af863", + "kind": "api_key", + "endpoint": "https://api.github.com/users/:username", + "method": "GET", + "path_fields": [ + "username" + ], + "query_fields": [ + "bio", + "cursor", + "expand", + "followers", + "following", + "html_url", + "limit", + "login", + "name", + "offset", + "page", + "per_page", + "public_repos" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_user_repos_3d0a9c64", + "kind": "api_key", + "endpoint": "https://api.github.com/users/:username/repos", + "method": "GET", + "path_fields": [ + "username" + ], + "query_fields": [ + "cursor", + "description", + "expand", + "full_name", + "html_url", + "language", + "limit", + "offset", + "page", + "per_page", + "repos", + "sort", + "stars" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "follow_user_5fb0bc2d", + "kind": "api_key", + "endpoint": "https://api.github.com/user/following/:username", + "method": "PUT", + "path_fields": [ + "username" + ], + "query_fields": [ + "cursor", + "expand", + "followed", + "limit", + "offset", + "page", + "per_page", + "username" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "unfollow_user_716e8e46", + "kind": "api_key", + "endpoint": "https://api.github.com/user/following/:username", + "method": "DELETE", + "path_fields": [ + "username" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "unfollowed", + "username" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_followers_e2e9d536", + "kind": "api_key", + "endpoint": "https://api.github.com/user/followers", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "followers", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_following_7a09905d", + "kind": "api_key", + "endpoint": "https://api.github.com/user/following", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "following", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "star_repo_21725e7d", + "kind": "api_key", + "endpoint": "https://api.github.com/user/starred/:owner_repo", + "method": "PUT", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "repo", + "starred" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "unstar_repo_64e091ea", + "kind": "api_key", + "endpoint": "https://api.github.com/user/starred/:owner_repo", + "method": "DELETE", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "repo", + "unstarred" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_starred_111412c8", + "kind": "api_key", + "endpoint": "https://api.github.com/user/starred", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "full_name", + "html_url", + "limit", + "offset", + "page", + "per_page", + "starred" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_stargazers_8be04fd5", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/stargazers", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "stargazers" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_gists_0b14a723", + "kind": "api_key", + "endpoint": "https://api.github.com/gists", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "description", + "expand", + "files", + "gists", + "html_url", + "id", + "limit", + "offset", + "page", + "per_page", + "public" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_gist_83568830", + "kind": "api_key", + "endpoint": "https://api.github.com/gists/:gist_id", + "method": "GET", + "path_fields": [ + "gist_id" + ], + "query_fields": [ + "content", + "created_at", + "cursor", + "description", + "expand", + "filename", + "files", + "html_url", + "id", + "language", + "limit", + "offset", + "owner", + "page", + "per_page", + "public", + "size", + "truncated", + "updated_at" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "create_gist_e042caa9", + "kind": "api_key", + "endpoint": "https://api.github.com/gists", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "description", + "expand", + "files", + "html_url", + "id", + "limit", + "offset", + "page", + "per_page", + "public" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "update_gist_d0bf3519", + "kind": "api_key", + "endpoint": "https://api.github.com/gists/:gist_id", + "method": "PATCH", + "path_fields": [ + "gist_id" + ], + "query_fields": [ + "cursor", + "description", + "expand", + "files", + "html_url", + "id", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "delete_gist_acda7360", + "kind": "api_key", + "endpoint": "https://api.github.com/gists/:gist_id", + "method": "DELETE", + "path_fields": [ + "gist_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "gist_id", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "mark_all_notifications_read_12bde736", + "kind": "api_key", + "endpoint": "https://api.github.com/notifications", + "method": "PUT", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "last_read_at", + "limit", + "marked_read", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "mark_notification_read_b5966744", + "kind": "api_key", + "endpoint": "https://api.github.com/notifications/threads/:thread_id", + "method": "PATCH", + "path_fields": [ + "thread_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "marked_read", + "offset", + "page", + "per_page", + "thread_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_workflows_1bc0ba27", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/actions/workflows", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "expand", + "id", + "limit", + "name", + "offset", + "page", + "path", + "per_page", + "state", + "workflows" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_workflow_run_16007a32", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/actions/runs/:run_id", + "method": "GET", + "path_fields": [ + "owner_repo", + "run_id" + ], + "query_fields": [ + "branch", + "conclusion", + "cursor", + "expand", + "html_url", + "id", + "limit", + "name", + "offset", + "page", + "per_page", + "status" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "trigger_workflow_4ae973c7", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/actions/workflows/:workflow_id/dispatches", + "method": "POST", + "path_fields": [ + "owner_repo", + "workflow_id" + ], + "query_fields": [ + "cursor", + "expand", + "inputs", + "limit", + "offset", + "page", + "per_page", + "ref", + "triggered", + "workflow_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "cancel_workflow_run_e3a4683e", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/actions/runs/:run_id/cancel", + "method": "POST", + "path_fields": [ + "owner_repo", + "run_id" + ], + "query_fields": [ + "cancelled", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "run_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "rerun_workflow_run_a988216f", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/actions/runs/:run_id/rerun", + "method": "POST", + "path_fields": [ + "owner_repo", + "run_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "rerun", + "run_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_workflow_run_logs_url_8b7206ca", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/actions/runs/:run_id/logs", + "method": "GET", + "path_fields": [ + "owner_repo", + "run_id" + ], + "query_fields": [ + "cursor", + "details", + "error", + "expand", + "limit", + "logs_url", + "offset", + "ok", + "page", + "per_page", + "result" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "refresh_access_token_70ce783c", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/oauth/refresh", + "method": "POST", + "path_fields": [], + "query_fields": [ + "Authorization", + "access_token", + "cursor", + "error", + "expand", + "limit", + "offset", + "ok", + "page", + "per_page", + "refresh_token", + "result", + "token_expiry" + ], + "envelope": true, + "request_format": "form", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "get_current_user_3e81c497", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/me", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "get_file_d35bb4df", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key", + "method": "GET", + "path_fields": [ + "file_key" + ], + "query_fields": [ + "branch_data", + "cursor", + "depth", + "expand", + "limit", + "offset", + "page", + "per_page", + "version" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "get_file_metadata_c4837f9c", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/meta", + "method": "GET", + "path_fields": [ + "file_key" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "get_nodes_c7c52c73", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/nodes", + "method": "GET", + "path_fields": [ + "file_key" + ], + "query_fields": [ + "cursor", + "depth", + "expand", + "ids", + "limit", + "offset", + "page", + "per_page", + "version" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "list_versions_051c83b6", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/versions", + "method": "GET", + "path_fields": [ + "file_key" + ], + "query_fields": [ + "after", + "before", + "cursor", + "expand", + "limit", + "offset", + "page", + "page_size", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "export_images_d3cb7888", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/images/:file_key", + "method": "GET", + "path_fields": [ + "file_key" + ], + "query_fields": [ + "cursor", + "err", + "error", + "expand", + "failed_node_ids", + "file_key", + "format", + "ids", + "limit", + "offset", + "page", + "per_page", + "result", + "scale", + "temporary_urls", + "version" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "get_image_fills_32f1934d", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/images", + "method": "GET", + "path_fields": [ + "file_key" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "list_comments_42e1c4ae", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/comments", + "method": "GET", + "path_fields": [ + "file_key" + ], + "query_fields": [ + "as_md", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "post_comment_dcfcd610", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/comments", + "method": "POST", + "path_fields": [ + "file_key" + ], + "query_fields": [ + "client_meta", + "comment_id", + "cursor", + "expand", + "limit", + "message", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "delete_comment_3f76bcbf", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/comments/:comment_id", + "method": "DELETE", + "path_fields": [ + "file_key", + "comment_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "list_comment_reactions_280e6c1d", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/comments/:comment_id/reactions", + "method": "GET", + "path_fields": [ + "file_key", + "comment_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "add_comment_reaction_93a31a97", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/comments/:comment_id/reactions", + "method": "POST", + "path_fields": [ + "file_key", + "comment_id" + ], + "query_fields": [ + "cursor", + "emoji", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "delete_comment_reaction_9ef06212", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/comments/:comment_id/reactions", + "method": "DELETE", + "path_fields": [ + "file_key", + "comment_id" + ], + "query_fields": [ + "cursor", + "emoji", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "list_team_folders_fc7a78a8", + "kind": "api_key", + "endpoint": "https://api.figma.com/v2/teams/:team_id/folders", + "method": "GET", + "path_fields": [ + "team_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "list_subfolders_036da9be", + "kind": "api_key", + "endpoint": "https://api.figma.com/v2/folders/:folder_id/folders", + "method": "GET", + "path_fields": [ + "folder_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "list_folder_files_15a54bcb", + "kind": "api_key", + "endpoint": "https://api.figma.com/v2/folders/:folder_id/files", + "method": "GET", + "path_fields": [ + "folder_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "get_folder_metadata_6b8ab416", + "kind": "api_key", + "endpoint": "https://api.figma.com/v2/folders/:folder_id/meta", + "method": "GET", + "path_fields": [ + "folder_id" + ], + "query_fields": [ + "cursor", + "expand", + "id", + "limit", + "name", + "offset", + "ok", + "page", + "partial_metadata", + "per_page", + "result", + "source", + "unavailable_fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "get_component_a0c6d9e9", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/components/:component_key", + "method": "GET", + "path_fields": [ + "component_key" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "get_component_set_8821a5c9", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/component_sets/:component_set_key", + "method": "GET", + "path_fields": [ + "component_set_key" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "get_style_921d62c0", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/styles/:style_key", + "method": "GET", + "path_fields": [ + "style_key" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "start_listening_42a58242", + "kind": "api_key", + "endpoint": "https://slack.com/api/auth.test", + "method": "POST", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "error", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "get_joined_channels_a4e146c1", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.list", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "exclude_archived", + "expand", + "limit", + "offset", + "page", + "per_page", + "types" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "poll_channels_07d959df", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.history", + "method": "GET", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "oldest", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "send_message_35d1a464", + "kind": "api_key", + "endpoint": "https://slack.com/api/chat.postMessage", + "method": "POST", + "path_fields": [], + "query_fields": [ + "blocks", + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "text", + "thread_ts" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "get_channel_info_b4ec37c2", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.info", + "method": "GET", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "create_channel_84493189", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.create", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "is_private", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "invite_to_channel_4c2a3e21", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.invite", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "users" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "list_users_98956b9e", + "kind": "api_key", + "endpoint": "https://slack.com/api/users.list", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "get_user_info_a2501367", + "kind": "api_key", + "endpoint": "https://slack.com/api/users.info", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "open_dm_3c1b987f", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.open", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "users" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "search_messages_0d9b0142", + "kind": "api_key", + "endpoint": "https://slack.com/api/search.messages", + "method": "GET", + "path_fields": [], + "query_fields": [ + "count", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "query", + "sort", + "sort_dir" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "upload_file_c3cdfcfd", + "kind": "api_key", + "endpoint": "https://slack.com/api/files.upload", + "method": "POST", + "path_fields": [], + "query_fields": [ + "Authorization", + "channels", + "content", + "cursor", + "expand", + "file", + "filename", + "initial_comment", + "limit", + "offset", + "page", + "per_page", + "title" + ], + "envelope": true, + "request_format": "multipart", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "update_message_da5a5422", + "kind": "api_key", + "endpoint": "https://slack.com/api/chat.update", + "method": "POST", + "path_fields": [], + "query_fields": [ + "attachments", + "blocks", + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "text", + "ts" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "delete_message_b3f25ac4", + "kind": "api_key", + "endpoint": "https://slack.com/api/chat.delete", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "ts" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "post_ephemeral_5bfc0c5d", + "kind": "api_key", + "endpoint": "https://slack.com/api/chat.postEphemeral", + "method": "POST", + "path_fields": [], + "query_fields": [ + "blocks", + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "text", + "thread_ts", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "schedule_message_e6cbd87b", + "kind": "api_key", + "endpoint": "https://slack.com/api/chat.scheduleMessage", + "method": "POST", + "path_fields": [], + "query_fields": [ + "blocks", + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "post_at", + "text", + "thread_ts" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "delete_scheduled_message_8755f676", + "kind": "api_key", + "endpoint": "https://slack.com/api/chat.deleteScheduledMessage", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "scheduled_message_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "list_scheduled_messages_449b847a", + "kind": "api_key", + "endpoint": "https://slack.com/api/chat.scheduledMessages.list", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "get_permalink_7e48efee", + "kind": "api_key", + "endpoint": "https://slack.com/api/chat.getPermalink", + "method": "GET", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "message_ts", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "get_thread_replies_857349ea", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.replies", + "method": "GET", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "ts" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "add_reaction_e8d53091", + "kind": "api_key", + "endpoint": "https://slack.com/api/reactions.add", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "timestamp" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "remove_reaction_994973cb", + "kind": "api_key", + "endpoint": "https://slack.com/api/reactions.remove", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "timestamp" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "get_reactions_48f1ab67", + "kind": "api_key", + "endpoint": "https://slack.com/api/reactions.get", + "method": "GET", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "full", + "limit", + "offset", + "page", + "per_page", + "timestamp" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "list_user_reactions_f8f0b196", + "kind": "api_key", + "endpoint": "https://slack.com/api/reactions.list", + "method": "GET", + "path_fields": [], + "query_fields": [ + "count", + "cursor", + "expand", + "full", + "limit", + "offset", + "page", + "per_page", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "pin_message_f99b1439", + "kind": "api_key", + "endpoint": "https://slack.com/api/pins.add", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "timestamp" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "unpin_message_fafebb32", + "kind": "api_key", + "endpoint": "https://slack.com/api/pins.remove", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "timestamp" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "list_pins_c406c33c", + "kind": "api_key", + "endpoint": "https://slack.com/api/pins.list", + "method": "GET", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "archive_channel_b836ed56", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.archive", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "unarchive_channel_809e8a6d", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.unarchive", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "rename_channel_df0c59b0", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.rename", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "set_channel_topic_38bd8cbe", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.setTopic", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "topic" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "set_channel_purpose_c408041d", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.setPurpose", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "purpose" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "join_channel_ef2aae67", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.join", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "leave_channel_94701cbd", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.leave", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "kick_user_eafcfebf", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.kick", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "close_conversation_77193316", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.close", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "list_channel_members_e05dbd74", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.members", + "method": "GET", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "list_files_4d6d538d", + "kind": "api_key", + "endpoint": "https://slack.com/api/files.list", + "method": "GET", + "path_fields": [], + "query_fields": [ + "channel", + "count", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "types", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "get_file_info_2bc0453a", + "kind": "api_key", + "endpoint": "https://slack.com/api/files.info", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "file", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "delete_file_189297ba", + "kind": "api_key", + "endpoint": "https://slack.com/api/files.delete", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "file", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "get_upload_url_external_9d79a0c3", + "kind": "api_key", + "endpoint": "https://slack.com/api/files.getUploadURLExternal", + "method": "GET", + "path_fields": [], + "query_fields": [ + "alt_txt", + "cursor", + "expand", + "filename", + "length", + "limit", + "offset", + "page", + "per_page", + "snippet_type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "complete_upload_external_23626e03", + "kind": "api_key", + "endpoint": "https://slack.com/api/files.completeUploadExternal", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel_id", + "cursor", + "expand", + "files", + "initial_comment", + "limit", + "offset", + "page", + "per_page", + "thread_ts" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "upload_file_v2_0452d01e", + "kind": "api_key", + "endpoint": "https://slack.com/api/:upload_url", + "method": "POST", + "path_fields": [ + "upload_url" + ], + "query_fields": [ + "cursor", + "details", + "error", + "expand", + "id", + "limit", + "offset", + "page", + "per_page", + "title" + ], + "envelope": true, + "request_format": "image", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "get_user_presence_ba0553a3", + "kind": "api_key", + "endpoint": "https://slack.com/api/users.getPresence", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "set_user_presence_1f9f8c79", + "kind": "api_key", + "endpoint": "https://slack.com/api/users.setPresence", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "presence" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "lookup_user_by_email_782979ce", + "kind": "api_key", + "endpoint": "https://slack.com/api/users.lookupByEmail", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "email", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "list_usergroups_1ea23eba", + "kind": "api_key", + "endpoint": "https://slack.com/api/usergroups.list", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "include_count", + "include_disabled", + "include_users", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "create_usergroup_cae0644f", + "kind": "api_key", + "endpoint": "https://slack.com/api/usergroups.create", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channels", + "cursor", + "description", + "expand", + "handle", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "update_usergroup_45263337", + "kind": "api_key", + "endpoint": "https://slack.com/api/usergroups.update", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channels", + "cursor", + "description", + "expand", + "handle", + "limit", + "name", + "offset", + "page", + "per_page", + "usergroup" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "enable_usergroup_0a57e39f", + "kind": "api_key", + "endpoint": "https://slack.com/api/usergroups.enable", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "usergroup" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "disable_usergroup_4b4e5ee6", + "kind": "api_key", + "endpoint": "https://slack.com/api/usergroups.disable", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "usergroup" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "list_usergroup_users_f971f7d9", + "kind": "api_key", + "endpoint": "https://slack.com/api/usergroups.users.list", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "include_disabled", + "limit", + "offset", + "page", + "per_page", + "usergroup" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "update_usergroup_users_3eb1b1c3", + "kind": "api_key", + "endpoint": "https://slack.com/api/usergroups.users.update", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "usergroup", + "users" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "get_team_info_3559c3b0", + "kind": "api_key", + "endpoint": "https://slack.com/api/team.info", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "team" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "list_bookmarks_944b81e8", + "kind": "api_key", + "endpoint": "https://slack.com/api/bookmarks.list", + "method": "GET", + "path_fields": [], + "query_fields": [ + "channel_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "add_bookmark_245fb15b", + "kind": "api_key", + "endpoint": "https://slack.com/api/bookmarks.add", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel_id", + "cursor", + "emoji", + "entity_id", + "expand", + "limit", + "link", + "offset", + "page", + "per_page", + "title", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "edit_bookmark_b37b5ba8", + "kind": "api_key", + "endpoint": "https://slack.com/api/bookmarks.edit", + "method": "POST", + "path_fields": [], + "query_fields": [ + "bookmark_id", + "channel_id", + "cursor", + "emoji", + "expand", + "limit", + "link", + "offset", + "page", + "per_page", + "title" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "remove_bookmark_5b83b5e8", + "kind": "api_key", + "endpoint": "https://slack.com/api/bookmarks.remove", + "method": "POST", + "path_fields": [], + "query_fields": [ + "bookmark_id", + "channel_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "add_reminder_3028d6ef", + "kind": "api_key", + "endpoint": "https://slack.com/api/reminders.add", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "text", + "time", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "list_reminders_452bd27c", + "kind": "api_key", + "endpoint": "https://slack.com/api/reminders.list", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "complete_reminder_29492cb2", + "kind": "api_key", + "endpoint": "https://slack.com/api/reminders.complete", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "reminder" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "delete_reminder_bf9572f8", + "kind": "api_key", + "endpoint": "https://slack.com/api/reminders.delete", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "reminder" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "get_reminder_info_defa4d7e", + "kind": "api_key", + "endpoint": "https://slack.com/api/reminders.info", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "reminder" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "get_bot_user_31bc71a9", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/users/@me", + "method": "GET", + "path_fields": [], + "query_fields": [ + "avatar", + "bot", + "cursor", + "discriminator", + "expand", + "id", + "limit", + "offset", + "page", + "per_page", + "username" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "get_bot_guilds_641b7f8d", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/users/@me/guilds", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "guilds", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "get_guild_channels_1fdf6551", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/channels", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "all_channels", + "categories", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "text_channels", + "voice_channels" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "get_guild_roles_b949bb7a", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/roles", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "roles" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "get_channel_a1e4326b", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id", + "method": "GET", + "path_fields": [ + "channel_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "bot_send_message_61c1a386", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/messages", + "method": "POST", + "path_fields": [ + "channel_id" + ], + "query_fields": [ + "channel_id", + "content", + "cursor", + "embeds", + "expand", + "limit", + "message_id", + "message_reference", + "offset", + "page", + "per_page", + "timestamp" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "get_messages_c5127832", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/messages", + "method": "GET", + "path_fields": [ + "channel_id" + ], + "query_fields": [ + "after", + "attachments", + "author", + "before", + "bot", + "content", + "count", + "cursor", + "embeds", + "expand", + "id", + "limit", + "messages", + "offset", + "page", + "per_page", + "timestamp", + "username" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "edit_message_768204b5", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/messages/:message_id", + "method": "PATCH", + "path_fields": [ + "channel_id", + "message_id" + ], + "query_fields": [ + "content", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "delete_message_4c62e2ee", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/messages/:message_id", + "method": "DELETE", + "path_fields": [ + "channel_id", + "message_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "create_dm_channel_9809f752", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/users/@me/channels", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "recipient_id", + "recipients", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "get_user_0f789588", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/users/:user_id", + "method": "GET", + "path_fields": [ + "user_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "get_guild_member_059b655b", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/members/:user_id", + "method": "GET", + "path_fields": [ + "guild_id", + "user_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "list_guild_members_61ec5dd5", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/members", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "members", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "add_reaction_7a3bfaf9", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/messages/:message_id/reactions/:encoded_emoji/@me", + "method": "PUT", + "path_fields": [ + "channel_id", + "message_id", + "encoded_emoji" + ], + "query_fields": [ + "added", + "cursor", + "emoji", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "user_get_dm_channels_f8314c3a", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/users/@me/channels", + "method": "GET", + "path_fields": [], + "query_fields": [ + "count", + "cursor", + "dm_channels", + "expand", + "id", + "last_message_id", + "limit", + "offset", + "page", + "per_page", + "recipients", + "type", + "username" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "user_get_relationships_464c5c1f", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/users/@me/relationships", + "method": "GET", + "path_fields": [], + "query_fields": [ + "blocked", + "cursor", + "expand", + "friends", + "id", + "incoming_requests", + "limit", + "offset", + "outgoing_requests", + "page", + "per_page", + "total_friends", + "username" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "user_search_guild_messages_dee61cc7", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/messages/search", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "content", + "cursor", + "expand", + "limit", + "messages", + "offset", + "page", + "per_page", + "total_results" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "bulk_delete_messages_dea032c7", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/messages/bulk-delete", + "method": "POST", + "path_fields": [ + "channel_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "messages", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "crosspost_message_9cde24a7", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/messages/:message_id/crosspost", + "method": "POST", + "path_fields": [ + "channel_id", + "message_id" + ], + "query_fields": [ + "cursor", + "expand", + "flags", + "id", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "pin_message_c8ea9c46", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/pins/:message_id", + "method": "PUT", + "path_fields": [ + "channel_id", + "message_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "message_id", + "offset", + "page", + "per_page", + "pinned" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "unpin_message_93ec17d0", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/pins/:message_id", + "method": "DELETE", + "path_fields": [ + "channel_id", + "message_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "message_id", + "offset", + "page", + "per_page", + "unpinned" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "list_pinned_messages_d4a76a2b", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/pins", + "method": "GET", + "path_fields": [ + "channel_id" + ], + "query_fields": [ + "count", + "cursor", + "expand", + "limit", + "messages", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "remove_user_reaction_cb00ae6b", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/messages/:message_id/reactions/:encoded/:user_id", + "method": "DELETE", + "path_fields": [ + "channel_id", + "message_id", + "encoded", + "user_id" + ], + "query_fields": [ + "cursor", + "emoji", + "expand", + "limit", + "offset", + "page", + "per_page", + "removed", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "remove_own_reaction_9e003d0f", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/messages/:message_id/reactions/:encoded/@me", + "method": "DELETE", + "path_fields": [ + "channel_id", + "message_id", + "encoded" + ], + "query_fields": [ + "cursor", + "emoji", + "expand", + "limit", + "offset", + "page", + "per_page", + "removed" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "list_reaction_users_3a5dd06c", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/messages/:message_id/reactions/:encoded", + "method": "GET", + "path_fields": [ + "channel_id", + "message_id", + "encoded" + ], + "query_fields": [ + "count", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "users" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "clear_reactions_499bbe1c", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/messages/:message_id/reactions", + "method": "DELETE", + "path_fields": [ + "channel_id", + "message_id" + ], + "query_fields": [ + "cleared", + "cursor", + "emoji", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "create_thread_from_message_dca4dc3a", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/messages/:message_id/threads", + "method": "POST", + "path_fields": [ + "channel_id", + "message_id" + ], + "query_fields": [ + "auto_archive_duration", + "cursor", + "expand", + "limit", + "name", + "offset", + "page", + "parent_id", + "per_page", + "thread_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "create_thread_172cde7a", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/threads", + "method": "POST", + "path_fields": [ + "channel_id" + ], + "query_fields": [ + "auto_archive_duration", + "cursor", + "expand", + "invitable", + "limit", + "name", + "offset", + "page", + "parent_id", + "per_page", + "rate_limit_per_user", + "thread_id", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "join_thread_87c4b3dc", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:thread_id/thread-members/@me", + "method": "PUT", + "path_fields": [ + "thread_id" + ], + "query_fields": [ + "cursor", + "expand", + "joined", + "limit", + "offset", + "page", + "per_page", + "thread_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "leave_thread_ad2c07b6", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:thread_id/thread-members/@me", + "method": "DELETE", + "path_fields": [ + "thread_id" + ], + "query_fields": [ + "cursor", + "expand", + "left", + "limit", + "offset", + "page", + "per_page", + "thread_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "add_thread_member_ebe119b1", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:thread_id/thread-members/:user_id", + "method": "PUT", + "path_fields": [ + "thread_id", + "user_id" + ], + "query_fields": [ + "added", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "remove_thread_member_14b44b7f", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:thread_id/thread-members/:user_id", + "method": "DELETE", + "path_fields": [ + "thread_id", + "user_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "removed", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "list_thread_members_f206de15", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:thread_id/thread-members", + "method": "GET", + "path_fields": [ + "thread_id" + ], + "query_fields": [ + "count", + "cursor", + "expand", + "limit", + "members", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "list_active_threads_49d9b1c0", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/threads/active", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "members", + "offset", + "page", + "per_page", + "threads" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "archive_thread_34068e86", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:thread_id", + "method": "PATCH", + "path_fields": [ + "thread_id" + ], + "query_fields": [ + "archived", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "thread_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "create_guild_channel_ccc9e5d9", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/channels", + "method": "POST", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "bitrate", + "channel_id", + "cursor", + "expand", + "limit", + "name", + "nsfw", + "offset", + "page", + "parent_id", + "per_page", + "permission_overwrites", + "position", + "rate_limit_per_user", + "topic", + "type", + "user_limit" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "modify_channel_c768423f", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id", + "method": "PATCH", + "path_fields": [ + "channel_id" + ], + "query_fields": [ + "archived", + "bitrate", + "channel_id", + "cursor", + "expand", + "limit", + "locked", + "name", + "nsfw", + "offset", + "page", + "parent_id", + "per_page", + "position", + "rate_limit_per_user", + "topic", + "user_limit" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "delete_channel_9112e39c", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id", + "method": "DELETE", + "path_fields": [ + "channel_id" + ], + "query_fields": [ + "channel_id", + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "edit_channel_permissions_801e731d", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/permissions/:overwrite_id", + "method": "PUT", + "path_fields": [ + "channel_id", + "overwrite_id" + ], + "query_fields": [ + "allow", + "cursor", + "deny", + "expand", + "limit", + "offset", + "overwrite_id", + "page", + "per_page", + "type", + "updated" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "delete_channel_permission_34b76548", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/permissions/:overwrite_id", + "method": "DELETE", + "path_fields": [ + "channel_id", + "overwrite_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "offset", + "overwrite_id", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "list_channel_invites_8c2b8fd9", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/invites", + "method": "GET", + "path_fields": [ + "channel_id" + ], + "query_fields": [ + "count", + "cursor", + "expand", + "invites", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "create_channel_invite_d40ecda2", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/invites", + "method": "POST", + "path_fields": [ + "channel_id" + ], + "query_fields": [ + "code", + "cursor", + "expand", + "limit", + "max_age", + "max_uses", + "offset", + "page", + "per_page", + "temporary", + "unique", + "url" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "delete_invite_b3cee9bf", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/invites/:invite_code", + "method": "DELETE", + "path_fields": [ + "invite_code" + ], + "query_fields": [ + "code", + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "list_channel_webhooks_499cc0cf", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/webhooks", + "method": "GET", + "path_fields": [ + "channel_id" + ], + "query_fields": [ + "count", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "webhooks" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "create_webhook_af7b2c28", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/webhooks", + "method": "POST", + "path_fields": [ + "channel_id" + ], + "query_fields": [ + "avatar", + "cursor", + "expand", + "id", + "limit", + "name", + "offset", + "page", + "per_page", + "token", + "url" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "get_webhook_b4cf1d2f", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/webhooks/:webhook_id", + "method": "GET", + "path_fields": [ + "webhook_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "modify_webhook_6fc3b226", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/webhooks/:webhook_id", + "method": "PATCH", + "path_fields": [ + "webhook_id" + ], + "query_fields": [ + "avatar", + "channel_id", + "cursor", + "expand", + "id", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "delete_webhook_f6384c78", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/webhooks/:webhook_id", + "method": "DELETE", + "path_fields": [ + "webhook_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page", + "webhook_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "execute_webhook_35fe80b9", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/webhooks/:webhook_id/:webhook_token", + "method": "POST", + "path_fields": [ + "webhook_id", + "webhook_token" + ], + "query_fields": [ + "avatar_url", + "content", + "cursor", + "embeds", + "expand", + "limit", + "message_id", + "offset", + "page", + "per_page", + "sent", + "username", + "wait" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "modify_guild_member_3774841d", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/members/:user_id", + "method": "PATCH", + "path_fields": [ + "guild_id", + "user_id" + ], + "query_fields": [ + "channel_id", + "communication_disabled_until", + "cursor", + "deaf", + "expand", + "limit", + "mute", + "nick", + "offset", + "page", + "per_page", + "roles" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "modify_current_member_nick_191003a3", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/members/@me", + "method": "PATCH", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "nick", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "add_guild_member_role_88ce086a", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/members/:user_id/roles/:role_id", + "method": "PUT", + "path_fields": [ + "guild_id", + "user_id", + "role_id" + ], + "query_fields": [ + "added", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "role_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "remove_guild_member_role_74c20f75", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/members/:user_id/roles/:role_id", + "method": "DELETE", + "path_fields": [ + "guild_id", + "user_id", + "role_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "removed", + "role_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "kick_guild_member_81a617f7", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/members/:user_id", + "method": "DELETE", + "path_fields": [ + "guild_id", + "user_id" + ], + "query_fields": [ + "cursor", + "expand", + "kicked", + "limit", + "offset", + "page", + "per_page", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "ban_guild_member_5b93bf3c", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/bans/:user_id", + "method": "PUT", + "path_fields": [ + "guild_id", + "user_id" + ], + "query_fields": [ + "banned", + "cursor", + "delete_message_seconds", + "expand", + "limit", + "offset", + "page", + "per_page", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "unban_guild_member_755deade", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/bans/:user_id", + "method": "DELETE", + "path_fields": [ + "guild_id", + "user_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "unbanned", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "list_guild_bans_4c44c708", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/bans", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "bans", + "count", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "search_guild_members_f0d6a357", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/members/search", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "count", + "cursor", + "expand", + "limit", + "members", + "offset", + "page", + "per_page", + "query" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "get_guild_0e83f2d8", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "create_guild_role_3933ca1a", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/roles", + "method": "POST", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "color", + "cursor", + "expand", + "hoist", + "limit", + "mentionable", + "name", + "offset", + "page", + "per_page", + "permissions", + "role_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "modify_guild_role_8b2aa477", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/roles/:role_id", + "method": "PATCH", + "path_fields": [ + "guild_id", + "role_id" + ], + "query_fields": [ + "color", + "cursor", + "expand", + "hoist", + "limit", + "mentionable", + "name", + "offset", + "page", + "per_page", + "permissions" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "delete_guild_role_2c0df0e8", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/roles/:role_id", + "method": "DELETE", + "path_fields": [ + "guild_id", + "role_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page", + "role_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "list_guild_emojis_8a66da4c", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/emojis", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "count", + "cursor", + "emojis", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "create_guild_emoji_621fdbf4", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/emojis", + "method": "POST", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "cursor", + "expand", + "id", + "image", + "limit", + "name", + "offset", + "page", + "per_page", + "roles" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "delete_guild_emoji_755e7f8e", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/emojis/:emoji_id", + "method": "DELETE", + "path_fields": [ + "guild_id", + "emoji_id" + ], + "query_fields": [ + "cursor", + "deleted", + "emoji_id", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "list_guild_stickers_2c2f7105", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/stickers", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "count", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "stickers" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "list_scheduled_events_76852609", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/scheduled-events", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "count", + "cursor", + "events", + "expand", + "limit", + "offset", + "page", + "per_page", + "with_user_count" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "create_scheduled_event_c9d6d7c7", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/scheduled-events", + "method": "POST", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "channel_id", + "cursor", + "description", + "entity_metadata", + "entity_type", + "event_id", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "privacy_level", + "scheduled_end_time", + "scheduled_start_time" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "modify_scheduled_event_1b19f27b", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/scheduled-events/:event_id", + "method": "PATCH", + "path_fields": [ + "guild_id", + "event_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "delete_scheduled_event_3566bca1", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/scheduled-events/:event_id", + "method": "DELETE", + "path_fields": [ + "guild_id", + "event_id" + ], + "query_fields": [ + "cursor", + "deleted", + "event_id", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "get_audit_log_d9c3a170", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/audit-logs", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "action_type", + "audit_log_entries", + "before", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "user_id", + "users", + "webhooks" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "list_guild_invites_4b742ce1", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/invites", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "count", + "cursor", + "expand", + "invites", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_account_84e8fd55", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/account", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_customers_10f3961b", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/customers", + "method": "GET", + "path_fields": [], + "query_fields": [ + "created", + "cursor", + "email", + "expand", + "gte", + "limit", + "lte", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_customer_10eab417", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/customers/:customer_id", + "method": "GET", + "path_fields": [ + "customer_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_customer_dc46cdaa", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/customers", + "method": "POST", + "path_fields": [], + "query_fields": [ + "address", + "cursor", + "description", + "email", + "expand", + "invoice_settings", + "limit", + "metadata", + "name", + "offset", + "page", + "payment_method", + "per_page", + "phone", + "shipping", + "tax_exempt" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_customer_d64b2ab3", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/customers/:customer_id", + "method": "POST", + "path_fields": [ + "customer_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "delete_customer_90055825", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/customers/:customer_id", + "method": "DELETE", + "path_fields": [ + "customer_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "search_customers_b46dd236", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/customers/search", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "query" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_payment_intents_ebf4f40a", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_intents", + "method": "GET", + "path_fields": [], + "query_fields": [ + "created", + "cursor", + "customer", + "expand", + "gte", + "limit", + "lte", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_payment_intent_3685e177", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_intents/:payment_intent_id", + "method": "GET", + "path_fields": [ + "payment_intent_id" + ], + "query_fields": [ + "client_secret", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_payment_intent_8db23b74", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_intents", + "method": "POST", + "path_fields": [], + "query_fields": [ + "amount", + "application_fee_amount", + "automatic_payment_methods", + "capture_method", + "confirm", + "confirmation_method", + "currency", + "cursor", + "customer", + "description", + "enabled", + "expand", + "limit", + "metadata", + "offset", + "on_behalf_of", + "page", + "payment_method", + "payment_method_types", + "per_page", + "receipt_email", + "setup_future_usage", + "statement_descriptor", + "statement_descriptor_suffix", + "transfer_data" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_payment_intent_1a0f10ab", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_intents/:payment_intent_id", + "method": "POST", + "path_fields": [ + "payment_intent_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "confirm_payment_intent_2b09624c", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_intents/:payment_intent_id/confirm", + "method": "POST", + "path_fields": [ + "payment_intent_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "off_session", + "offset", + "page", + "payment_method", + "per_page", + "receipt_email", + "return_url", + "setup_future_usage" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "capture_payment_intent_4b51563b", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_intents/:payment_intent_id/capture", + "method": "POST", + "path_fields": [ + "payment_intent_id" + ], + "query_fields": [ + "amount_to_capture", + "application_fee_amount", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "statement_descriptor" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "cancel_payment_intent_70afe17b", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_intents/:payment_intent_id/cancel", + "method": "POST", + "path_fields": [ + "payment_intent_id" + ], + "query_fields": [ + "cancellation_reason", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "search_payment_intents_135fd964", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_intents/search", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "query" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_charges_3167acbb", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/charges", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "customer", + "expand", + "limit", + "offset", + "page", + "payment_intent", + "per_page", + "transfer_group" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_charge_d1bff1b2", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/charges/:charge_id", + "method": "GET", + "path_fields": [ + "charge_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_refund_4fdff6eb", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/refunds", + "method": "POST", + "path_fields": [], + "query_fields": [ + "amount", + "charge", + "cursor", + "error", + "expand", + "limit", + "metadata", + "offset", + "page", + "payment_intent", + "per_page", + "reason", + "refund_application_fee", + "reverse_transfer" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_refund_c7458149", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/refunds/:refund_id", + "method": "GET", + "path_fields": [ + "refund_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_refunds_40e42e38", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/refunds", + "method": "GET", + "path_fields": [], + "query_fields": [ + "charge", + "cursor", + "expand", + "limit", + "offset", + "page", + "payment_intent", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_payment_methods_64b0e69a", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_methods", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "customer", + "expand", + "limit", + "offset", + "page", + "per_page", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_payment_method_86a271ed", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_methods/:payment_method_id", + "method": "GET", + "path_fields": [ + "payment_method_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "attach_payment_method_bcc9e4d5", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_methods/:payment_method_id/attach", + "method": "POST", + "path_fields": [ + "payment_method_id" + ], + "query_fields": [ + "cursor", + "customer", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "detach_payment_method_98944a3c", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_methods/:payment_method_id/detach", + "method": "POST", + "path_fields": [ + "payment_method_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_payment_method_d35338c5", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_methods/:payment_method_id", + "method": "POST", + "path_fields": [ + "payment_method_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_products_8a6aa928", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/products", + "method": "GET", + "path_fields": [], + "query_fields": [ + "active", + "cursor", + "expand", + "ids", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_product_5b5bba6e", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/products/:product_id", + "method": "GET", + "path_fields": [ + "product_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_product_0979c970", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/products", + "method": "POST", + "path_fields": [], + "query_fields": [ + "active", + "cursor", + "default_price_data", + "description", + "expand", + "images", + "limit", + "metadata", + "name", + "offset", + "page", + "per_page", + "shippable", + "statement_descriptor", + "tax_code", + "unit_label", + "url" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_product_22bf552a", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/products/:product_id", + "method": "POST", + "path_fields": [ + "product_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "delete_product_4b0b124d", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/products/:product_id", + "method": "DELETE", + "path_fields": [ + "product_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_prices_f2c96d3e", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/prices", + "method": "GET", + "path_fields": [], + "query_fields": [ + "active", + "currency", + "cursor", + "expand", + "interval", + "limit", + "offset", + "page", + "per_page", + "product", + "recurring", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_price_0175f6e1", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/prices/:price_id", + "method": "GET", + "path_fields": [ + "price_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_price_44656a9d", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/prices", + "method": "POST", + "path_fields": [], + "query_fields": [ + "active", + "billing_scheme", + "currency", + "cursor", + "expand", + "limit", + "metadata", + "nickname", + "offset", + "page", + "per_page", + "product", + "product_data", + "recurring", + "tax_behavior", + "tiers", + "tiers_mode", + "transform_quantity", + "unit_amount", + "unit_amount_decimal" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_price_3ffb372b", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/prices/:price_id", + "method": "POST", + "path_fields": [ + "price_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_invoices_e976f883", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoices", + "method": "GET", + "path_fields": [], + "query_fields": [ + "collection_method", + "created", + "cursor", + "customer", + "expand", + "gte", + "limit", + "lte", + "offset", + "page", + "per_page", + "status", + "subscription" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_invoice_5cc8b180", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoices/:invoice_id", + "method": "GET", + "path_fields": [ + "invoice_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_invoice_ee87e2e8", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoices", + "method": "POST", + "path_fields": [], + "query_fields": [ + "auto_advance", + "collection_method", + "cursor", + "customer", + "days_until_due", + "default_payment_method", + "default_source", + "description", + "due_date", + "expand", + "footer", + "from_invoice", + "limit", + "metadata", + "offset", + "page", + "pending_invoice_items_behavior", + "per_page", + "statement_descriptor", + "subscription" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_invoice_7c4ea08c", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoices/:invoice_id", + "method": "POST", + "path_fields": [ + "invoice_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "delete_invoice_10f17e17", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoices/:invoice_id", + "method": "DELETE", + "path_fields": [ + "invoice_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "finalize_invoice_54197d52", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoices/:invoice_id/finalize", + "method": "POST", + "path_fields": [ + "invoice_id" + ], + "query_fields": [ + "auto_advance", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "send_invoice_c8d043d9", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoices/:invoice_id/send", + "method": "POST", + "path_fields": [ + "invoice_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "pay_invoice_7c12be29", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoices/:invoice_id/pay", + "method": "POST", + "path_fields": [ + "invoice_id" + ], + "query_fields": [ + "cursor", + "expand", + "forgive", + "limit", + "off_session", + "offset", + "page", + "paid_out_of_band", + "payment_method", + "per_page", + "source" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "void_invoice_4159348f", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoices/:invoice_id/void", + "method": "POST", + "path_fields": [ + "invoice_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "mark_invoice_uncollectible_ee3bef38", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoices/:invoice_id/mark_uncollectible", + "method": "POST", + "path_fields": [ + "invoice_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_upcoming_invoice_f47b2a54", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoices/upcoming", + "method": "GET", + "path_fields": [], + "query_fields": [ + "coupon", + "cursor", + "customer", + "expand", + "limit", + "offset", + "page", + "per_page", + "subscription", + "subscription_items" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_invoice_items_110c1445", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoiceitems", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "customer", + "expand", + "invoice", + "limit", + "offset", + "page", + "pending", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_invoice_item_06840806", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoiceitems", + "method": "POST", + "path_fields": [], + "query_fields": [ + "amount", + "currency", + "cursor", + "customer", + "description", + "discountable", + "discounts", + "expand", + "invoice", + "limit", + "metadata", + "offset", + "page", + "per_page", + "period", + "price", + "price_data", + "quantity", + "subscription", + "tax_rates" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "delete_invoice_item_b471f6d9", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoiceitems/:invoice_item_id", + "method": "DELETE", + "path_fields": [ + "invoice_item_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_subscriptions_9c3a1df7", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/subscriptions", + "method": "GET", + "path_fields": [], + "query_fields": [ + "collection_method", + "cursor", + "customer", + "expand", + "limit", + "offset", + "page", + "per_page", + "price", + "status" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_subscription_d2e8bfce", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/subscriptions/:subscription_id", + "method": "GET", + "path_fields": [ + "subscription_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_subscription_e883070c", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/subscriptions", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cancel_at_period_end", + "collection_method", + "coupon", + "cursor", + "customer", + "days_until_due", + "default_payment_method", + "default_tax_rates", + "expand", + "items", + "limit", + "metadata", + "offset", + "page", + "payment_behavior", + "payment_settings", + "per_page", + "promotion_code", + "proration_behavior", + "trial_end", + "trial_period_days" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_subscription_5624c045", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/subscriptions/:subscription_id", + "method": "POST", + "path_fields": [ + "subscription_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "cancel_subscription_7dabfa5f", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/subscriptions/:subscription_id", + "method": "DELETE", + "path_fields": [ + "subscription_id" + ], + "query_fields": [ + "cancellation_details", + "cursor", + "expand", + "invoice_now", + "limit", + "offset", + "page", + "per_page", + "prorate" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "resume_subscription_64eee2ee", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/subscriptions/:subscription_id/resume", + "method": "POST", + "path_fields": [ + "subscription_id" + ], + "query_fields": [ + "billing_cycle_anchor", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "proration_behavior" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_coupons_36b02205", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/coupons", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_coupon_d8655874", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/coupons/:coupon_id", + "method": "GET", + "path_fields": [ + "coupon_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_coupon_67f98517", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/coupons", + "method": "POST", + "path_fields": [], + "query_fields": [ + "amount_off", + "applies_to", + "currency", + "cursor", + "duration", + "duration_in_months", + "expand", + "id", + "limit", + "max_redemptions", + "metadata", + "name", + "offset", + "page", + "per_page", + "percent_off", + "redeem_by" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_coupon_859b54af", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/coupons/:coupon_id", + "method": "POST", + "path_fields": [ + "coupon_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "delete_coupon_4742d866", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/coupons/:coupon_id", + "method": "DELETE", + "path_fields": [ + "coupon_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_promotion_codes_1a622590", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/promotion_codes", + "method": "GET", + "path_fields": [], + "query_fields": [ + "active", + "code", + "coupon", + "cursor", + "customer", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_promotion_code_bb668d39", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/promotion_codes", + "method": "POST", + "path_fields": [], + "query_fields": [ + "active", + "code", + "coupon", + "cursor", + "customer", + "expand", + "expires_at", + "limit", + "max_redemptions", + "metadata", + "offset", + "page", + "per_page", + "restrictions" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_promotion_code_c1714886", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/promotion_codes/:promotion_code_id", + "method": "POST", + "path_fields": [ + "promotion_code_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_checkout_sessions_3c1dd4ec", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/checkout/sessions", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "customer", + "expand", + "limit", + "offset", + "page", + "payment_intent", + "per_page", + "status", + "subscription" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_checkout_session_d12b2969", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/checkout/sessions/:session_id", + "method": "GET", + "path_fields": [ + "session_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_checkout_session_a7f31bd6", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/checkout/sessions", + "method": "POST", + "path_fields": [], + "query_fields": [ + "allow_promotion_codes", + "automatic_tax", + "billing_address_collection", + "cancel_url", + "client_reference_id", + "cursor", + "customer", + "customer_email", + "discounts", + "expand", + "expires_at", + "limit", + "line_items", + "locale", + "metadata", + "mode", + "offset", + "page", + "payment_intent_data", + "payment_method_types", + "per_page", + "return_url", + "shipping_address_collection", + "subscription_data", + "success_url", + "ui_mode" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "expire_checkout_session_cdf1b06d", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/checkout/sessions/:session_id/expire", + "method": "POST", + "path_fields": [ + "session_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_checkout_line_items_0dde5c28", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/checkout/sessions/:session_id/line_items", + "method": "GET", + "path_fields": [ + "session_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_payment_links_6f2ea21b", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_links", + "method": "GET", + "path_fields": [], + "query_fields": [ + "active", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_payment_link_62908382", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_links/:payment_link_id", + "method": "GET", + "path_fields": [ + "payment_link_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_payment_link_ef44e015", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_links", + "method": "POST", + "path_fields": [], + "query_fields": [ + "after_completion", + "allow_promotion_codes", + "automatic_tax", + "billing_address_collection", + "currency", + "cursor", + "customer_creation", + "expand", + "limit", + "line_items", + "metadata", + "offset", + "page", + "payment_intent_data", + "payment_method_types", + "per_page", + "shipping_address_collection", + "shipping_options", + "subscription_data", + "tax_id_collection" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_payment_link_82d61402", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_links/:payment_link_id", + "method": "POST", + "path_fields": [ + "payment_link_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_payment_link_line_items_6d2e6f10", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_links/:payment_link_id/line_items", + "method": "GET", + "path_fields": [ + "payment_link_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_billing_portal_session_e83ef2db", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/billing_portal/sessions", + "method": "POST", + "path_fields": [], + "query_fields": [ + "configuration", + "cursor", + "customer", + "expand", + "flow_data", + "limit", + "locale", + "offset", + "page", + "per_page", + "return_url" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_disputes_9daa2dfc", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/disputes", + "method": "GET", + "path_fields": [], + "query_fields": [ + "charge", + "cursor", + "expand", + "limit", + "offset", + "page", + "payment_intent", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_dispute_7c8346b3", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/disputes/:dispute_id", + "method": "GET", + "path_fields": [ + "dispute_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_dispute_78c2e44a", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/disputes/:dispute_id", + "method": "POST", + "path_fields": [ + "dispute_id" + ], + "query_fields": [ + "cursor", + "evidence", + "expand", + "limit", + "metadata", + "offset", + "page", + "per_page", + "submit" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "close_dispute_c26783e0", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/disputes/:dispute_id/close", + "method": "POST", + "path_fields": [ + "dispute_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_payouts_9b7c5faa", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payouts", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "destination", + "expand", + "limit", + "offset", + "page", + "per_page", + "status" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_payout_c1d16888", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payouts/:payout_id", + "method": "GET", + "path_fields": [ + "payout_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_payout_03ffd1e8", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payouts", + "method": "POST", + "path_fields": [], + "query_fields": [ + "amount", + "currency", + "cursor", + "description", + "destination", + "expand", + "limit", + "metadata", + "method", + "offset", + "page", + "per_page", + "source_type", + "statement_descriptor" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_payout_cf8fb0bf", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payouts/:payout_id", + "method": "POST", + "path_fields": [ + "payout_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "cancel_payout_dff30da5", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payouts/:payout_id/cancel", + "method": "POST", + "path_fields": [ + "payout_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_balance_72fe9252", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/balance", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_balance_transactions_369e8db7", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/balance_transactions", + "method": "GET", + "path_fields": [], + "query_fields": [ + "currency", + "cursor", + "expand", + "limit", + "offset", + "page", + "payout", + "per_page", + "source", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_balance_transaction_fda7a346", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/balance_transactions/:balance_transaction_id", + "method": "GET", + "path_fields": [ + "balance_transaction_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_quotes_3805740b", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/quotes", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "customer", + "expand", + "limit", + "offset", + "page", + "per_page", + "status" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_quote_2066e71c", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/quotes/:quote_id", + "method": "GET", + "path_fields": [ + "quote_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_quote_1124143f", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/quotes", + "method": "POST", + "path_fields": [], + "query_fields": [ + "application_fee_amount", + "application_fee_percent", + "automatic_tax", + "collection_method", + "cursor", + "customer", + "default_tax_rates", + "description", + "discounts", + "expand", + "expires_at", + "footer", + "from_quote", + "header", + "invoice_settings", + "limit", + "line_items", + "metadata", + "offset", + "on_behalf_of", + "page", + "per_page", + "subscription_data", + "test_clock", + "transfer_data" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_quote_1d584703", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/quotes/:quote_id", + "method": "POST", + "path_fields": [ + "quote_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "finalize_quote_f913b13a", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/quotes/:quote_id/finalize", + "method": "POST", + "path_fields": [ + "quote_id" + ], + "query_fields": [ + "cursor", + "expand", + "expires_at", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "accept_quote_0b23f904", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/quotes/:quote_id/accept", + "method": "POST", + "path_fields": [ + "quote_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "cancel_quote_eb7e8b42", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/quotes/:quote_id/cancel", + "method": "POST", + "path_fields": [ + "quote_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_events_0ca422b7", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/events", + "method": "GET", + "path_fields": [], + "query_fields": [ + "created", + "cursor", + "delivery_success", + "expand", + "gte", + "limit", + "lte", + "offset", + "page", + "per_page", + "type", + "types" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_event_b308b646", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/events/:event_id", + "method": "GET", + "path_fields": [ + "event_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_webhook_endpoints_d95be9fc", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/webhook_endpoints", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_webhook_endpoint_f37d55c9", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/webhook_endpoints/:endpoint_id", + "method": "GET", + "path_fields": [ + "endpoint_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_webhook_endpoint_eb6ed43b", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/webhook_endpoints", + "method": "POST", + "path_fields": [], + "query_fields": [ + "api_version", + "connect", + "cursor", + "description", + "enabled_events", + "expand", + "limit", + "metadata", + "offset", + "page", + "per_page", + "url" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_webhook_endpoint_d41e5629", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/webhook_endpoints/:endpoint_id", + "method": "POST", + "path_fields": [ + "endpoint_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "delete_webhook_endpoint_ecccb301", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/webhook_endpoints/:endpoint_id", + "method": "DELETE", + "path_fields": [ + "endpoint_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "upload_file_ef821083", + "kind": "api_key", + "endpoint": "https://files.stripe.com/v1/files", + "method": "POST", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "error", + "expand", + "file", + "limit", + "offset", + "page", + "per_page", + "purpose" + ], + "envelope": true, + "request_format": "multipart", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_file_206e2ac7", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/files/:file_id", + "method": "GET", + "path_fields": [ + "file_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_files_ff99af2d", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/files", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "purpose" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "search_9ad105e4", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/search", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "error", + "expand", + "filter", + "limit", + "offset", + "page", + "page_size", + "per_page", + "property", + "query", + "value" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "get_page_1ab98180", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/pages/:page_id", + "method": "GET", + "path_fields": [ + "page_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "get_database_e811bbac", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/databases/:database_id", + "method": "GET", + "path_fields": [ + "database_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "query_database_b9b85056", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/databases/:database_id/query", + "method": "POST", + "path_fields": [ + "database_id" + ], + "query_fields": [ + "cursor", + "expand", + "filter", + "limit", + "offset", + "page", + "page_size", + "per_page", + "sorts" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "create_page_83759b4c", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/pages", + "method": "POST", + "path_fields": [], + "query_fields": [ + "children", + "cursor", + "expand", + "limit", + "offset", + "page", + "parent", + "per_page", + "properties" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "update_page_602bbf24", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/pages/:page_id", + "method": "PATCH", + "path_fields": [ + "page_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "properties" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "get_block_children_536e1dfd", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/blocks/:block_id/children", + "method": "GET", + "path_fields": [ + "block_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "page_size", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "append_block_children_46a0c4a4", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/blocks/:block_id/children", + "method": "PATCH", + "path_fields": [ + "block_id" + ], + "query_fields": [ + "children", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "delete_block_610ea9a0", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/blocks/:block_id", + "method": "DELETE", + "path_fields": [ + "block_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "get_user_dc1ded24", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/users/:user_id", + "method": "GET", + "path_fields": [ + "user_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "get_page_property_e5ef0a35", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/pages/:page_id/properties/:property_id", + "method": "GET", + "path_fields": [ + "page_id", + "property_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "page_size", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "create_database_4534a410", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/databases", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cover", + "cursor", + "description", + "expand", + "icon", + "initial_data_source", + "is_inline", + "limit", + "offset", + "page", + "page_id", + "parent", + "per_page", + "properties", + "title" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "update_database_60e1816a", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/databases/:database_id", + "method": "PATCH", + "path_fields": [ + "database_id" + ], + "query_fields": [ + "archived", + "cursor", + "description", + "expand", + "is_inline", + "limit", + "offset", + "page", + "per_page", + "properties", + "title" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "get_block_74cd076c", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/blocks/:block_id", + "method": "GET", + "path_fields": [ + "block_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "update_block_d89c7b03", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/blocks/:block_id", + "method": "PATCH", + "path_fields": [ + "block_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "list_comments_c6bc282e", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/comments", + "method": "GET", + "path_fields": [], + "query_fields": [ + "block_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "page_size", + "per_page", + "start_cursor" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "create_comment_9693d6bb", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/comments", + "method": "POST", + "path_fields": [], + "query_fields": [ + "block_id", + "cursor", + "discussion_id", + "display_name", + "error", + "expand", + "limit", + "offset", + "page", + "page_id", + "parent", + "per_page", + "rich_text", + "validation" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "list_users_24643397", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/users", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "page_size", + "per_page", + "start_cursor" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "get_bot_info_9dca28b1", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/users/me", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "create_file_upload_1dfb7fd4", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/file_uploads", + "method": "POST", + "path_fields": [], + "query_fields": [ + "content_type", + "cursor", + "expand", + "external_url", + "filename", + "limit", + "mode", + "number_of_parts", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "send_file_upload_dd34e532", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/file_uploads/:file_upload_id/send", + "method": "POST", + "path_fields": [ + "file_upload_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "details", + "error", + "exception", + "expand", + "file", + "http", + "limit", + "offset", + "page", + "part_number", + "per_page", + "validation" + ], + "envelope": true, + "request_format": "multipart", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "complete_file_upload_9743196b", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/file_uploads/:file_upload_id/complete", + "method": "POST", + "path_fields": [ + "file_upload_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "get_file_upload_183495d9", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/file_uploads/:file_upload_id", + "method": "GET", + "path_fields": [ + "file_upload_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "list_file_uploads_2c4b79d3", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/file_uploads", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "page_size", + "per_page", + "start_cursor", + "status" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "refresh_access_token_001b80db", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/oauth/v1/token", + "method": "POST", + "path_fields": [], + "query_fields": [ + "client_id", + "client_secret", + "cursor", + "expand", + "grant_type", + "limit", + "offset", + "page", + "per_page", + "refresh_token" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_objects_13f3bd10", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/objects/:object_type", + "method": "GET", + "path_fields": [ + "object_type" + ], + "query_fields": [ + "after", + "archived", + "associations", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "properties" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "get_object_42661705", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/objects/:object_type/:object_id", + "method": "GET", + "path_fields": [ + "object_type", + "object_id" + ], + "query_fields": [ + "associations", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "properties" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "create_object_b56730bb", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/objects/:object_type", + "method": "POST", + "path_fields": [ + "object_type" + ], + "query_fields": [ + "associations", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "properties" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "update_object_5e67819a", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/objects/:object_type/:object_id", + "method": "PATCH", + "path_fields": [ + "object_type", + "object_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "properties" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "delete_object_31b2c5de", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/objects/:object_type/:object_id", + "method": "DELETE", + "path_fields": [ + "object_type", + "object_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "search_objects_c0ed612e", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/objects/:object_type/search", + "method": "POST", + "path_fields": [ + "object_type" + ], + "query_fields": [ + "after", + "cursor", + "expand", + "filterGroups", + "limit", + "offset", + "page", + "per_page", + "properties", + "query", + "sorts" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "batch_read_1d4466ea", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/objects/:object_type/batch/read", + "method": "POST", + "path_fields": [ + "object_type" + ], + "query_fields": [ + "cursor", + "expand", + "id", + "idProperty", + "inputs", + "limit", + "offset", + "page", + "per_page", + "properties" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "batch_create_65ad0526", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/objects/:object_type/batch/create", + "method": "POST", + "path_fields": [ + "object_type" + ], + "query_fields": [ + "cursor", + "expand", + "inputs", + "limit", + "offset", + "page", + "per_page", + "properties" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "batch_update_c70f9fb4", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/objects/:object_type/batch/update", + "method": "POST", + "path_fields": [ + "object_type" + ], + "query_fields": [ + "cursor", + "expand", + "inputs", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "merge_contacts_91a82dda", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/objects/contacts/merge", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "objectIdToMerge", + "offset", + "page", + "per_page", + "primaryObjectId" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_lists_439888af", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/lists/search", + "method": "POST", + "path_fields": [], + "query_fields": [ + "count", + "cursor", + "expand", + "limit", + "listIds", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "get_list_3c8ec4d1", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/lists/:list_id", + "method": "GET", + "path_fields": [ + "list_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "create_list_2b04e411", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/lists", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "filterBranch", + "limit", + "name", + "objectTypeId", + "offset", + "page", + "per_page", + "processingType" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "delete_list_9c09a903", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/lists/:list_id", + "method": "DELETE", + "path_fields": [ + "list_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "add_contacts_to_list_af684a84", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/lists/:list_id/memberships/add", + "method": "PUT", + "path_fields": [ + "list_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "remove_contacts_from_list_6dbed206", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/lists/:list_id/memberships/remove", + "method": "PUT", + "path_fields": [ + "list_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_pipelines_dd79480e", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/pipelines/:object_type", + "method": "GET", + "path_fields": [ + "object_type" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "get_pipeline_d44754db", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/pipelines/:object_type/:pipeline_id", + "method": "GET", + "path_fields": [ + "object_type", + "pipeline_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "create_pipeline_a87aff93", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/pipelines/:object_type", + "method": "POST", + "path_fields": [ + "object_type" + ], + "query_fields": [ + "cursor", + "displayOrder", + "expand", + "label", + "limit", + "offset", + "page", + "per_page", + "stages" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_pipeline_stages_dd3aac08", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/pipelines/:object_type/:pipeline_id/stages", + "method": "GET", + "path_fields": [ + "object_type", + "pipeline_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "update_pipeline_stage_7cfc365c", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/pipelines/:object_type/:pipeline_id/stages/:stage_id", + "method": "PATCH", + "path_fields": [ + "object_type", + "pipeline_id", + "stage_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_owners_b44c0835", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/owners", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "email", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "get_owner_b0195c99", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/owners/:owner_id", + "method": "GET", + "path_fields": [ + "owner_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_properties_58fd3300", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/properties/:object_type", + "method": "GET", + "path_fields": [ + "object_type" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "get_property_6df45602", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/properties/:object_type/:property_name", + "method": "GET", + "path_fields": [ + "object_type", + "property_name" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "create_property_0060cced", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/properties/:object_type", + "method": "POST", + "path_fields": [ + "object_type" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "update_property_bd218e77", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/properties/:object_type/:property_name", + "method": "PATCH", + "path_fields": [ + "object_type", + "property_name" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "delete_property_58bcb5a0", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/properties/:object_type/:property_name", + "method": "DELETE", + "path_fields": [ + "object_type", + "property_name" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_property_groups_eb7446dc", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/properties/:object_type/groups", + "method": "GET", + "path_fields": [ + "object_type" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "create_association_e1b4f2aa", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v4/objects/:from_object_type/:from_object_id/associations/:to_object_type/:to_object_id", + "method": "PUT", + "path_fields": [ + "from_object_type", + "from_object_id", + "to_object_type", + "to_object_id" + ], + "query_fields": [ + "associationCategory", + "associationTypeId", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "create_association_86b55d57", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v4/objects/:from_object_type/:from_object_id/associations/default/:to_object_type/:to_object_id", + "method": "PUT", + "path_fields": [ + "from_object_type", + "from_object_id", + "to_object_type", + "to_object_id" + ], + "query_fields": [ + "associationCategory", + "associationTypeId", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_associations_46e47496", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v4/objects/:from_object_type/:from_object_id/associations/:to_object_type", + "method": "GET", + "path_fields": [ + "from_object_type", + "from_object_id", + "to_object_type" + ], + "query_fields": [ + "after", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "delete_association_eff08c9f", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v4/objects/:from_object_type/:from_object_id/associations/:to_object_type/:to_object_id", + "method": "DELETE", + "path_fields": [ + "from_object_type", + "from_object_id", + "to_object_type", + "to_object_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_association_types_8002b044", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v4/associations/:from_object_type/:to_object_type/labels", + "method": "GET", + "path_fields": [ + "from_object_type", + "to_object_type" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_forms_dac337c4", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/marketing/v3/forms", + "method": "GET", + "path_fields": [], + "query_fields": [ + "after", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "get_form_18a4d5b4", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/marketing/v3/forms/:form_id", + "method": "GET", + "path_fields": [ + "form_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "submit_form_ddf46c9b", + "kind": "api_key", + "endpoint": "https://api.hsforms.com/submissions/v3/integration/submit/:portal_id/:form_guid", + "method": "POST", + "path_fields": [ + "portal_id", + "form_guid" + ], + "query_fields": [ + "context", + "cursor", + "expand", + "fields", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_form_submissions_ee3f8444", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/form-integrations/v1/submissions/forms/:form_guid", + "method": "GET", + "path_fields": [ + "form_guid" + ], + "query_fields": [ + "after", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_marketing_emails_8b409a34", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/marketing/v3/emails", + "method": "GET", + "path_fields": [], + "query_fields": [ + "after", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "get_marketing_email_0bb5949a", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/marketing/v3/emails/:email_id", + "method": "GET", + "path_fields": [ + "email_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "send_single_email_e127523e", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/marketing/v3/transactional/single-email/send", + "method": "POST", + "path_fields": [], + "query_fields": [ + "contactProperties", + "cursor", + "customProperties", + "emailId", + "expand", + "limit", + "message", + "name", + "offset", + "page", + "per_page", + "to", + "value" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "get_marketing_email_statistics_5a71cf2f", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/marketing/v3/emails/:email_id/statistics", + "method": "GET", + "path_fields": [ + "email_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "upload_file_8103e05b", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/files/v3/files", + "method": "POST", + "path_fields": [], + "query_fields": [ + "Authorization", + "access", + "cursor", + "error", + "expand", + "file", + "folderPath", + "limit", + "offset", + "options", + "overwrite", + "page", + "per_page" + ], + "envelope": true, + "request_format": "multipart", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "get_file_e28be584", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/files/v3/files/:file_id", + "method": "GET", + "path_fields": [ + "file_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "delete_file_c5d973c4", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/files/v3/files/:file_id", + "method": "DELETE", + "path_fields": [ + "file_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_folders_d3876b03", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/files/v3/folders", + "method": "GET", + "path_fields": [], + "query_fields": [ + "after", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_conversations_42bcbb1b", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/conversations/v3/conversations/threads", + "method": "GET", + "path_fields": [], + "query_fields": [ + "after", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "get_conversation_d28d0593", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/conversations/v3/conversations/threads/:thread_id", + "method": "GET", + "path_fields": [ + "thread_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_conversation_messages_f218eda1", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/conversations/v3/conversations/threads/:thread_id/messages", + "method": "GET", + "path_fields": [ + "thread_id" + ], + "query_fields": [ + "after", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "send_conversation_message_97e09080", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/conversations/v3/conversations/threads/:thread_id/messages", + "method": "POST", + "path_fields": [ + "thread_id" + ], + "query_fields": [ + "channelAccountId", + "channelId", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "recipients", + "senderActorId", + "text", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_webhook_subscriptions_b9d23898", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/webhooks/v3/:app_id/subscriptions", + "method": "GET", + "path_fields": [ + "app_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "create_webhook_subscription_b9a98c0f", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/webhooks/v3/:app_id/subscriptions", + "method": "POST", + "path_fields": [ + "app_id" + ], + "query_fields": [ + "active", + "cursor", + "eventType", + "expand", + "limit", + "offset", + "page", + "per_page", + "propertyName" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "delete_webhook_subscription_fdd523ab", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/webhooks/v3/:app_id/subscriptions/:subscription_id", + "method": "DELETE", + "path_fields": [ + "app_id", + "subscription_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "run_query_64634b12", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/query/", + "method": "POST", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "kind", + "limit", + "offset", + "page", + "per_page", + "query", + "refresh", + "variables" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_query_status_faec5843", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/query/:query_id/", + "method": "GET", + "path_fields": [ + "project_id", + "query_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "cancel_query_c24cdac4", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/query/:query_id/", + "method": "DELETE", + "path_fields": [ + "project_id", + "query_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "draft_sql_911f8a09", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/query/draft_sql/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "prompt" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_insights_537811a5", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/insights/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "favorited", + "limit", + "offset", + "page", + "per_page", + "search" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_insight_932ce289", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/insights/:insight_id/", + "method": "GET", + "path_fields": [ + "project_id", + "insight_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "create_insight_586fd07c", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/insights/", + "method": "POST", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "dashboards", + "description", + "expand", + "favorited", + "limit", + "name", + "offset", + "page", + "per_page", + "query", + "tags" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "update_insight_24f70f93", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/insights/:insight_id/", + "method": "PATCH", + "path_fields": [ + "project_id", + "insight_id" + ], + "query_fields": [ + "cursor", + "dashboards", + "description", + "expand", + "favorited", + "limit", + "name", + "offset", + "page", + "per_page", + "query", + "tags" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_insight_sharing_bc2eb154", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/insights/:insight_id/sharing/", + "method": "GET", + "path_fields": [ + "project_id", + "insight_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_trending_insights_86483772", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/insights/trending/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_insight_activity_9a58d8ad", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/insights/:insight_id/activity/", + "method": "GET", + "path_fields": [ + "project_id", + "insight_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_dashboards_7529ca34", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/dashboards/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "search" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_dashboard_029a1f6f", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/dashboards/:dashboard_id/", + "method": "GET", + "path_fields": [ + "project_id", + "dashboard_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "create_dashboard_9596951e", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/dashboards/", + "method": "POST", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "description", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "pinned", + "tags" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "update_dashboard_4451470d", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/dashboards/:dashboard_id/", + "method": "PATCH", + "path_fields": [ + "project_id", + "dashboard_id" + ], + "query_fields": [ + "cursor", + "description", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "pinned", + "tags" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "create_dashboard_text_tile_85538379", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/dashboards/:dashboard_id/create_text_tile/", + "method": "POST", + "path_fields": [ + "project_id", + "dashboard_id" + ], + "query_fields": [ + "body", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "delete_dashboard_tile_53226874", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/dashboards/:dashboard_id/delete_tile/", + "method": "POST", + "path_fields": [ + "project_id", + "dashboard_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "tile_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_dashboard_templates_fa870a06", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/dashboard_templates/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_feature_flags_93dffe3f", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/feature_flags/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "active", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "search" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_feature_flag_675f991c", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/feature_flags/:flag_id/", + "method": "GET", + "path_fields": [ + "project_id", + "flag_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "create_feature_flag_68b0f78f", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/feature_flags/", + "method": "POST", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "active", + "cursor", + "ensure_experience_continuity", + "expand", + "filters", + "groups", + "key", + "limit", + "name", + "offset", + "page", + "per_page", + "properties", + "rollout_percentage" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "update_feature_flag_c16d48ab", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/feature_flags/:flag_id/", + "method": "PATCH", + "path_fields": [ + "project_id", + "flag_id" + ], + "query_fields": [ + "active", + "cursor", + "expand", + "filters", + "key", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "enable_feature_flag_16501e72", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/feature_flags/:flag_id/enable/", + "method": "POST", + "path_fields": [ + "project_id", + "flag_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "disable_feature_flag_ec3b76e2", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/feature_flags/:flag_id/disable/", + "method": "POST", + "path_fields": [ + "project_id", + "flag_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "archive_feature_flag_ca71d73c", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/feature_flags/:flag_id/archive/", + "method": "POST", + "path_fields": [ + "project_id", + "flag_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "unarchive_feature_flag_4d2084ff", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/feature_flags/:flag_id/unarchive/", + "method": "POST", + "path_fields": [ + "project_id", + "flag_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_feature_flag_status_b3ca0f48", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/feature_flags/:flag_id/status/", + "method": "GET", + "path_fields": [ + "project_id", + "flag_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_feature_flag_blast_radius_c997d185", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/feature_flags/user_blast_radius/", + "method": "POST", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "condition", + "cursor", + "expand", + "group_type_index", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_my_feature_flags_606d65ea", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/feature_flags/my_flags/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_early_access_features_4d5c942d", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/early_access_feature/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "create_early_access_feature_ae590da3", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/early_access_feature/", + "method": "POST", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "description", + "expand", + "feature_flag_id", + "limit", + "name", + "offset", + "page", + "per_page", + "stage" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_persons_9b11a13e", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/persons/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "distinct_id", + "expand", + "limit", + "offset", + "page", + "per_page", + "properties", + "search" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_person_3a3082ec", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/persons/:person_id/", + "method": "GET", + "path_fields": [ + "project_id", + "person_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "update_person_20938204", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/persons/:person_id/", + "method": "PATCH", + "path_fields": [ + "project_id", + "person_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "properties" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "update_person_property_1544ffaf", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/persons/:person_id/update_property/", + "method": "POST", + "path_fields": [ + "project_id", + "person_id" + ], + "query_fields": [ + "cursor", + "expand", + "key", + "limit", + "offset", + "page", + "per_page", + "value" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "delete_person_property_71ead581", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/persons/:person_id/delete_property/", + "method": "POST", + "path_fields": [ + "project_id", + "person_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "delete_persons_3a0cb393", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/persons/bulk_delete/", + "method": "POST", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "delete_events", + "distinct_ids", + "expand", + "ids", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_person_cohorts_8f5f8f80", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/persons/cohorts/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "person_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_person_properties_timeline_4e27e3e4", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/persons/:person_id/properties_timeline/", + "method": "GET", + "path_fields": [ + "project_id", + "person_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_person_property_values_fcc866d0", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/persons/values/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "key", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_cohorts_98fef557", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/cohorts/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_cohort_8a7d1c42", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/cohorts/:cohort_id/", + "method": "GET", + "path_fields": [ + "project_id", + "cohort_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "create_cohort_23984762", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/cohorts/", + "method": "POST", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "description", + "expand", + "groups", + "is_static", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "update_cohort_b14a9044", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/cohorts/:cohort_id/", + "method": "PATCH", + "path_fields": [ + "project_id", + "cohort_id" + ], + "query_fields": [ + "cursor", + "description", + "expand", + "groups", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_cohort_persons_3d2386d4", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/cohorts/:cohort_id/persons/", + "method": "GET", + "path_fields": [ + "project_id", + "cohort_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "add_persons_to_cohort_868fc741", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/cohorts/:cohort_id/add_persons_to_static_cohort/", + "method": "PATCH", + "path_fields": [ + "project_id", + "cohort_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "person_ids" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_annotations_d2afca92", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/annotations/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_annotation_6baca3e2", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/annotations/:annotation_id/", + "method": "GET", + "path_fields": [ + "project_id", + "annotation_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "create_annotation_ee918c79", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/annotations/", + "method": "POST", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "content", + "cursor", + "dashboard_item", + "date_marker", + "expand", + "limit", + "offset", + "page", + "per_page", + "scope" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "update_annotation_830c5943", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/annotations/:annotation_id/", + "method": "PATCH", + "path_fields": [ + "project_id", + "annotation_id" + ], + "query_fields": [ + "content", + "cursor", + "date_marker", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_event_definitions_99339dcd", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/event_definitions/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "search" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_event_definition_3eda9406", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/event_definitions/:definition_id/", + "method": "GET", + "path_fields": [ + "project_id", + "definition_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "update_event_definition_21ad3a7d", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/event_definitions/:definition_id/", + "method": "PATCH", + "path_fields": [ + "project_id", + "definition_id" + ], + "query_fields": [ + "cursor", + "description", + "expand", + "limit", + "offset", + "page", + "per_page", + "tags", + "verified" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_property_definitions_0717a4f3", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/property_definitions/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "event_names", + "expand", + "limit", + "offset", + "page", + "per_page", + "search" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "update_property_definition_06f9ea77", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/property_definitions/:definition_id/", + "method": "PATCH", + "path_fields": [ + "project_id", + "definition_id" + ], + "query_fields": [ + "cursor", + "description", + "expand", + "limit", + "offset", + "page", + "per_page", + "tags" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_actions_2c9c10d7", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/actions/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_action_40c99eaa", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/actions/:action_id/", + "method": "GET", + "path_fields": [ + "project_id", + "action_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "create_action_42f44389", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/actions/", + "method": "POST", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "description", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "steps" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_current_user_f157f167", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/users/@me/", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_organization_0805ffee", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/organizations/:organization_id/", + "method": "GET", + "path_fields": [ + "organization_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_organization_members_7419c05a", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/organizations/:organization_id/members/", + "method": "GET", + "path_fields": [ + "organization_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_projects_89642ede", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/organizations/:organization_id/projects/", + "method": "GET", + "path_fields": [ + "organization_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_project_30d738c0", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/organizations/:organization_id/projects/:project_id/", + "method": "GET", + "path_fields": [ + "organization_id", + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "refresh_sync_1af9fb37", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/oauth/token", + "method": "POST", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "grant_type", + "limit", + "offset", + "page", + "per_page", + "refresh_token" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "keys_for_49567d26", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:ref/api-keys", + "method": "GET", + "path_fields": [ + "ref" + ], + "query_fields": [ + "cursor", + "error", + "expand", + "jwt", + "limit", + "offset", + "page", + "per_page", + "publishable", + "reveal", + "secret" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "rest_f40da060", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:ref/database/query", + "method": "POST", + "path_fields": [ + "ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "query" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "get_profile_d233b848", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/profile", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "list_organizations_edecc64d", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/organizations", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "name", + "offset", + "ok", + "page", + "per_page", + "projects", + "result", + "slug", + "source" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "list_organizations_7af4633a", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "name", + "offset", + "ok", + "page", + "per_page", + "projects", + "result", + "slug", + "source" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "list_organization_members_cdf0e139", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/organizations/:organization_slug/members", + "method": "GET", + "path_fields": [ + "organization_slug" + ], + "query_fields": [ + "cursor", + "details", + "error", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "get_project_81a973e8", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "list_regions_74a9d475", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/available-regions", + "method": "GET", + "path_fields": [], + "query_fields": [ + "continent", + "cursor", + "expand", + "limit", + "offset", + "organization_slug", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "create_project_f28c2e8c", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects", + "method": "POST", + "path_fields": [], + "query_fields": [ + "code", + "cursor", + "db_pass", + "desired_instance_size", + "expand", + "generated_db_password", + "limit", + "name", + "offset", + "organization_slug", + "page", + "per_page", + "region_selection", + "result", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "update_project_c7f9e43e", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref", + "method": "PATCH", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "delete_project_4507706e", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref", + "method": "DELETE", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "error", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "pause_project_c3296495", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/pause", + "method": "POST", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "restore_project_4a290ec7", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/restore", + "method": "POST", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "restart_project_b9076fe5", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/restart", + "method": "POST", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "get_project_health_4f44d2f5", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/health", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "services" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "get_service_config_752db9a7", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/:path", + "method": "GET", + "path_fields": [ + "project_ref", + "path" + ], + "query_fields": [ + "cursor", + "error", + "expand", + "limit", + "offset", + "page", + "per_page", + "result" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "run_sql_readonly_05b81c04", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/database/query/read-only", + "method": "POST", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "parameters", + "per_page", + "query" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "run_sql_75d05ab4", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/database/query", + "method": "POST", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "applied_as_migration", + "cursor", + "expand", + "limit", + "note", + "offset", + "ok", + "page", + "parameters", + "per_page", + "query", + "result" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "generate_typescript_types_285baf00", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/types/typescript", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "included_schemas", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "list_backups_c6bfb781", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/database/backups", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "list_migrations_57262f31", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/database/migrations", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "get_migration_0dc2d142", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/database/migrations/:version", + "method": "GET", + "path_fields": [ + "project_ref", + "version" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "apply_migration_61fe6b3a", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/database/migrations", + "method": "POST", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "query", + "rollback" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "rollback_migrations_586e4851", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/database/migrations", + "method": "DELETE", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "gte", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "list_functions_fcc76d2b", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/functions", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "get_function_71e9d354", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/functions/:slug", + "method": "GET", + "path_fields": [ + "project_ref", + "slug" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "deploy_function_7d543b0b", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/functions/deploy", + "method": "POST", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "entrypoint_path", + "error", + "expand", + "import_map_path", + "limit", + "metadata", + "name", + "offset", + "page", + "per_page", + "slug", + "verify_jwt" + ], + "envelope": true, + "request_format": "multipart", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "update_function_67d24262", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/functions/:slug", + "method": "PATCH", + "path_fields": [ + "project_ref", + "slug" + ], + "query_fields": [ + "cursor", + "error", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "verify_jwt" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "delete_function_abcf4462", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/functions/:slug", + "method": "DELETE", + "path_fields": [ + "project_ref", + "slug" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "list_secrets_9f420f50", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/secrets", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "name", + "offset", + "ok", + "page", + "per_page", + "result", + "updated_at" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "set_secrets_5037ad73", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/secrets", + "method": "POST", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "error", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "result", + "set", + "value" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "delete_secrets_390061c0", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/secrets", + "method": "DELETE", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "deleted", + "error", + "expand", + "limit", + "offset", + "page", + "per_page", + "result" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "list_branches_82f4f266", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/branches", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "get_branch_b4c0d8f2", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/branches/:branch_id", + "method": "GET", + "path_fields": [ + "branch_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "create_branch_60b03364", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/branches", + "method": "POST", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "branch_name", + "cursor", + "expand", + "git_branch", + "limit", + "offset", + "page", + "per_page", + "persistent", + "region", + "with_data" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "update_branch_84bbfde6", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/branches/:branch_id", + "method": "PATCH", + "path_fields": [ + "branch_id" + ], + "query_fields": [ + "branch_name", + "cursor", + "error", + "expand", + "git_branch", + "limit", + "offset", + "page", + "per_page", + "persistent" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "delete_branch_d6ff7f11", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/branches/:branch_id", + "method": "DELETE", + "path_fields": [ + "branch_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "merge_branch_434a6b8a", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/branches/:branch_id/merge", + "method": "POST", + "path_fields": [ + "branch_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "migration_version", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "reset_branch_6d8bab6a", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/branches/:branch_id/reset", + "method": "POST", + "path_fields": [ + "branch_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "migration_version", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "get_logs_9fae9246", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/analytics/endpoints/logs", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "details", + "error", + "expand", + "iso_timestamp_end", + "iso_timestamp_start", + "limit", + "offset", + "page", + "per_page", + "sql" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "get_security_advisors_3433bbfe", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/advisors/security", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "get_performance_advisors_f91ee12e", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/advisors/performance", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "get_api_usage_4457291e", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/analytics/endpoints/usage.api-counts", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "interval", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "push_text_a94c93b4", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/message/push", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "messages", + "notificationDisabled", + "offset", + "page", + "per_page", + "text", + "to", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "reply_text_20582f8b", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/message/reply", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "messages", + "notificationDisabled", + "offset", + "page", + "per_page", + "replyToken", + "text", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "multicast_text_5f8005ae", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/message/multicast", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "messages", + "notificationDisabled", + "offset", + "page", + "per_page", + "text", + "to", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "broadcast_text_8511ec8f", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/message/broadcast", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "messages", + "notificationDisabled", + "offset", + "page", + "per_page", + "text", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_profile_2d8cba85", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/profile/:user_id", + "method": "GET", + "path_fields": [ + "user_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_bot_info_d0d4ab95", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/info", + "method": "GET", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_quota_ef5c424c", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/message/quota", + "method": "GET", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_message_content_90f73398", + "kind": "api_key", + "endpoint": "https://api-data.line.me/v2/bot/message/:message_id/content", + "method": "GET", + "path_fields": [ + "message_id" + ], + "query_fields": [ + "Authorization", + "bytes_written", + "cursor", + "details", + "error", + "expand", + "limit", + "mimetype", + "offset", + "ok", + "page", + "path", + "per_page", + "result" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "binary", + "max_response_bytes": 64000000 + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_group_summary_e94faa0f", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/group/:group_id/summary", + "method": "GET", + "path_fields": [ + "group_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_group_member_count_66cc2109", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/group/:group_id/members/count", + "method": "GET", + "path_fields": [ + "group_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "list_group_member_user_ids_f4002f5b", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/group/:group_id/members/ids", + "method": "GET", + "path_fields": [ + "group_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "start" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_group_member_profile_516d8147", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/group/:group_id/member/:user_id", + "method": "GET", + "path_fields": [ + "group_id", + "user_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "leave_group_bb9f8e1c", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/group/:group_id/leave", + "method": "POST", + "path_fields": [ + "group_id" + ], + "query_fields": [ + "cursor", + "expand", + "group_id", + "left", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_room_member_count_f65de4bb", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/room/:room_id/members/count", + "method": "GET", + "path_fields": [ + "room_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "list_room_member_user_ids_2bb8c5a3", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/room/:room_id/members/ids", + "method": "GET", + "path_fields": [ + "room_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "start" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_room_member_profile_21bfdfed", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/room/:room_id/member/:user_id", + "method": "GET", + "path_fields": [ + "room_id", + "user_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "leave_room_4fb22467", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/room/:room_id/leave", + "method": "POST", + "path_fields": [ + "room_id" + ], + "query_fields": [ + "cursor", + "expand", + "left", + "limit", + "offset", + "page", + "per_page", + "room_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "create_rich_menu_a7eba8d7", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/richmenu", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_rich_menu_f95d2a05", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/richmenu/:rich_menu_id", + "method": "GET", + "path_fields": [ + "rich_menu_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "list_rich_menus_747e9188", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/richmenu/list", + "method": "GET", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "delete_rich_menu_f16ecb97", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/richmenu/:rich_menu_id", + "method": "DELETE", + "path_fields": [ + "rich_menu_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page", + "rich_menu_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "upload_rich_menu_image_eb74d853", + "kind": "api_key", + "endpoint": "https://api-data.line.me/v2/bot/richmenu/:rich_menu_id/content", + "method": "POST", + "path_fields": [ + "rich_menu_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "details", + "error", + "expand", + "limit", + "offset", + "ok", + "page", + "per_page", + "result", + "rich_menu_id", + "size" + ], + "envelope": true, + "request_format": "image", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "set_default_rich_menu_61619a1b", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/user/all/richmenu/:rich_menu_id", + "method": "POST", + "path_fields": [ + "rich_menu_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "rich_menu_id", + "set_default" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_default_rich_menu_edd4b93b", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/user/all/richmenu", + "method": "GET", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "cancel_default_rich_menu_851ec6c6", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/user/all/richmenu", + "method": "DELETE", + "path_fields": [], + "query_fields": [ + "Authorization", + "cancelled", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "link_rich_menu_to_user_c9ebec0e", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/user/:user_id/richmenu/:rich_menu_id", + "method": "POST", + "path_fields": [ + "user_id", + "rich_menu_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "linked", + "offset", + "page", + "per_page", + "rich_menu_id", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "unlink_rich_menu_from_user_92f39c16", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/user/:user_id/richmenu", + "method": "DELETE", + "path_fields": [ + "user_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "unlinked", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_user_rich_menu_59ca4d78", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/user/:user_id/richmenu", + "method": "GET", + "path_fields": [ + "user_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "bulk_link_rich_menu_49876325", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/richmenu/bulk/link", + "method": "POST", + "path_fields": [], + "query_fields": [ + "count", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "queued", + "richMenuId", + "userIds" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "bulk_unlink_rich_menu_64a9f5db", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/richmenu/bulk/unlink", + "method": "POST", + "path_fields": [], + "query_fields": [ + "count", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "queued", + "userIds" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "send_narrowcast_8f25279b", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/message/narrowcast", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "demographic", + "expand", + "filter", + "limit", + "messages", + "notificationDisabled", + "offset", + "page", + "per_page", + "queued", + "recipient", + "request_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_narrowcast_progress_5e131ad8", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/message/progress/narrowcast", + "method": "GET", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "requestId" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "create_user_id_audience_2b0c611b", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/audienceGroup/upload", + "method": "POST", + "path_fields": [], + "query_fields": [ + "audiences", + "cursor", + "description", + "expand", + "isIfaAudience", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "update_audience_description_3792c3bc", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/audienceGroup/:audience_group_id/updateDescription", + "method": "PUT", + "path_fields": [ + "audience_group_id" + ], + "query_fields": [ + "audience_group_id", + "cursor", + "description", + "expand", + "limit", + "offset", + "page", + "per_page", + "updated" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "delete_audience_6de5539f", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/audienceGroup/:audience_group_id", + "method": "DELETE", + "path_fields": [ + "audience_group_id" + ], + "query_fields": [ + "Authorization", + "audience_group_id", + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_audience_958fe8de", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/audienceGroup/:audience_group_id", + "method": "GET", + "path_fields": [ + "audience_group_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "list_audiences_2f3e0caa", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/audienceGroup/list", + "method": "GET", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "description", + "expand", + "limit", + "offset", + "page", + "per_page", + "size", + "status" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_number_of_followers_4434d7cd", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/insight/followers", + "method": "GET", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "date", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_friend_demographics_e26c35ca", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/insight/demographic", + "method": "GET", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_message_delivery_stats_4d009862", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/insight/message/delivery", + "method": "GET", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "date", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_message_event_stats_186a6e1d", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/insight/message/event", + "method": "GET", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "requestId" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_user_interaction_stats_5d476e9b", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/insight/message/event/aggregation", + "method": "GET", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "customAggregationUnit", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "set_webhook_endpoint_8253183c", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/channel/webhook/endpoint", + "method": "PUT", + "path_fields": [], + "query_fields": [ + "cursor", + "endpoint", + "expand", + "limit", + "offset", + "page", + "per_page", + "updated" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_webhook_endpoint_0ec5b36c", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/channel/webhook/endpoint", + "method": "GET", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "test_webhook_endpoint_0a36d69c", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/channel/webhook/test", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "endpoint", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "issue_channel_access_token_663a5d0b", + "kind": "api_key", + "endpoint": "https://api.line.me/oauth2/v3/token", + "method": "POST", + "path_fields": [], + "query_fields": [ + "client_id", + "client_secret", + "cursor", + "expand", + "grant_type", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "revoke_channel_access_token_3e237727", + "kind": "api_key", + "endpoint": "https://api.line.me/oauth2/v3/revoke", + "method": "POST", + "path_fields": [], + "query_fields": [ + "access_token", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "revoked" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "verify_access_token_a58cb859", + "kind": "api_key", + "endpoint": "https://api.line.me/oauth2/v3/verify", + "method": "GET", + "path_fields": [], + "query_fields": [ + "access_token", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_message_09dc036d", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendMessage", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "disable_notification", + "expand", + "limit", + "offset", + "page", + "parse_mode", + "per_page", + "reply_to_message_id", + "text" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "poll_updates_sync_c3500467", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getUpdates", + "method": "GET", + "path_fields": [], + "query_fields": [ + "allowed_updates", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "result", + "timeout" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "get_me_2a0b547f", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getMe", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_photo_7ef4cd34", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendPhoto", + "method": "POST", + "path_fields": [], + "query_fields": [ + "caption", + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "parse_mode", + "per_page", + "photo" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_document_a04ca360", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendDocument", + "method": "POST", + "path_fields": [], + "query_fields": [ + "caption", + "chat_id", + "cursor", + "document", + "expand", + "limit", + "offset", + "page", + "parse_mode", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "get_updates_5b46ae22", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getUpdates", + "method": "POST", + "path_fields": [], + "query_fields": [ + "allowed_updates", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "timeout" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "get_chat_2ba529b4", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getChat", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "get_chat_member_c8d59110", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getChatMember", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "get_chat_members_count_924e301b", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getChatMembersCount", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "forward_message_0513c22a", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/forwardMessage", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "disable_notification", + "expand", + "from_chat_id", + "limit", + "message_id", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "edit_message_text_d0e18189", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/editMessageText", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "disable_web_page_preview", + "entities", + "expand", + "limit", + "message_id", + "offset", + "page", + "parse_mode", + "per_page", + "reply_markup", + "text" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "edit_message_caption_9b6ae108", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/editMessageCaption", + "method": "POST", + "path_fields": [], + "query_fields": [ + "caption", + "chat_id", + "cursor", + "expand", + "limit", + "message_id", + "offset", + "page", + "parse_mode", + "per_page", + "reply_markup" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "edit_message_reply_markup_5739bea2", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/editMessageReplyMarkup", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "message_id", + "offset", + "page", + "per_page", + "reply_markup" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "delete_message_15e1237f", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/deleteMessage", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "message_id", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "delete_messages_531340ae", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/deleteMessages", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "message_ids", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "copy_message_02186968", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/copyMessage", + "method": "POST", + "path_fields": [], + "query_fields": [ + "caption", + "chat_id", + "cursor", + "disable_notification", + "expand", + "from_chat_id", + "limit", + "message_id", + "offset", + "page", + "parse_mode", + "per_page", + "reply_markup", + "reply_to_message_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "forward_messages_6cd7db36", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/forwardMessages", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "disable_notification", + "expand", + "from_chat_id", + "limit", + "message_ids", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "pin_message_75002f6e", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/pinChatMessage", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "disable_notification", + "expand", + "limit", + "message_id", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "unpin_message_28563267", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/unpinChatMessage", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "message_id", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "unpin_all_messages_4e1fb0d0", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/unpinAllChatMessages", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "set_message_reaction_7ba79326", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/setMessageReaction", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "is_big", + "limit", + "message_id", + "offset", + "page", + "per_page", + "reaction" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_chat_action_ba762119", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendChatAction", + "method": "POST", + "path_fields": [], + "query_fields": [ + "action", + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_video_96ffc55c", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendVideo", + "method": "POST", + "path_fields": [], + "query_fields": [ + "caption", + "chat_id", + "cursor", + "duration", + "expand", + "height", + "limit", + "offset", + "page", + "parse_mode", + "per_page", + "reply_markup", + "supports_streaming", + "video", + "width" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_audio_fc4c82a4", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendAudio", + "method": "POST", + "path_fields": [], + "query_fields": [ + "audio", + "caption", + "chat_id", + "cursor", + "duration", + "expand", + "limit", + "offset", + "page", + "parse_mode", + "per_page", + "performer", + "reply_markup", + "title" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_voice_0a0163e4", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendVoice", + "method": "POST", + "path_fields": [], + "query_fields": [ + "caption", + "chat_id", + "cursor", + "duration", + "expand", + "limit", + "offset", + "page", + "per_page", + "voice" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_video_note_e9d6dfc9", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendVideoNote", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "duration", + "expand", + "length", + "limit", + "offset", + "page", + "per_page", + "video_note" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_animation_5b70e092", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendAnimation", + "method": "POST", + "path_fields": [], + "query_fields": [ + "animation", + "caption", + "chat_id", + "cursor", + "duration", + "expand", + "height", + "limit", + "offset", + "page", + "parse_mode", + "per_page", + "width" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_sticker_15e0eb81", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendSticker", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "emoji", + "expand", + "limit", + "offset", + "page", + "per_page", + "sticker" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_location_b53223c6", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendLocation", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "heading", + "latitude", + "limit", + "live_period", + "longitude", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_venue_99c51ea2", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendVenue", + "method": "POST", + "path_fields": [], + "query_fields": [ + "address", + "chat_id", + "cursor", + "expand", + "foursquare_id", + "latitude", + "limit", + "longitude", + "offset", + "page", + "per_page", + "title" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_contact_aba136b4", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendContact", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "first_name", + "last_name", + "limit", + "offset", + "page", + "per_page", + "phone_number", + "vcard" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_dice_c322489a", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendDice", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "emoji", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_poll_98af5bf4", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendPoll", + "method": "POST", + "path_fields": [], + "query_fields": [ + "allows_multiple_answers", + "chat_id", + "correct_option_id", + "cursor", + "expand", + "explanation", + "is_anonymous", + "is_closed", + "limit", + "offset", + "open_period", + "options", + "page", + "per_page", + "question", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "stop_poll_31f88a7e", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/stopPoll", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "message_id", + "offset", + "page", + "per_page", + "reply_markup" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_media_group_f1b13c6f", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendMediaGroup", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "media", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "get_file_c6bec521", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getFile", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "file_id", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "ban_chat_member_e4f8e672", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/banChatMember", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "revoke_messages", + "until_date", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "unban_chat_member_6a33cc80", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/unbanChatMember", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "only_if_banned", + "page", + "per_page", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "restrict_chat_member_d014b2fd", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/restrictChatMember", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "permissions", + "until_date", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "promote_chat_member_b11efc48", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/promoteChatMember", + "method": "POST", + "path_fields": [], + "query_fields": [ + "can_change_info", + "can_delete_messages", + "can_edit_messages", + "can_invite_users", + "can_manage_chat", + "can_manage_topics", + "can_manage_video_chats", + "can_pin_messages", + "can_post_messages", + "can_promote_members", + "can_restrict_members", + "chat_id", + "cursor", + "expand", + "is_anonymous", + "limit", + "offset", + "page", + "per_page", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "set_chat_administrator_custom_title_4a911d15", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/setChatAdministratorCustomTitle", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "custom_title", + "expand", + "limit", + "offset", + "page", + "per_page", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "set_chat_permissions_5efee794", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/setChatPermissions", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "permissions" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "set_chat_title_cf149fa5", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/setChatTitle", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "title" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "set_chat_description_b4344cc9", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/setChatDescription", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "description", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "delete_chat_photo_d92940c1", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/deleteChatPhoto", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "leave_chat_e9d4cc89", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/leaveChat", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "export_chat_invite_link_81b81947", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/exportChatInviteLink", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "create_chat_invite_link_3b27924b", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/createChatInviteLink", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "creates_join_request", + "cursor", + "expand", + "expire_date", + "limit", + "member_limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "edit_chat_invite_link_6c4bb3fb", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/editChatInviteLink", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "creates_join_request", + "cursor", + "expand", + "expire_date", + "invite_link", + "limit", + "member_limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "revoke_chat_invite_link_da91bbb1", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/revokeChatInviteLink", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "invite_link", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "approve_chat_join_request_7d04da33", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/approveChatJoinRequest", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "decline_chat_join_request_6dbf07cd", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/declineChatJoinRequest", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "get_chat_administrators_b5c4aaf0", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getChatAdministrators", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "set_my_commands_3e8674e7", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/setMyCommands", + "method": "POST", + "path_fields": [], + "query_fields": [ + "commands", + "cursor", + "expand", + "language_code", + "limit", + "offset", + "page", + "per_page", + "scope" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "get_my_commands_63f69db9", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getMyCommands", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "language_code", + "limit", + "offset", + "page", + "per_page", + "scope" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "delete_my_commands_469652e5", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/deleteMyCommands", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "language_code", + "limit", + "offset", + "page", + "per_page", + "scope" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "set_my_description_df4dbe21", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/setMyDescription", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "description", + "expand", + "language_code", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "get_my_description_42d318ef", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getMyDescription", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "language_code", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "set_my_short_description_9d3304ac", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/setMyShortDescription", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "language_code", + "limit", + "offset", + "page", + "per_page", + "short_description" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "set_my_name_09964d13", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/setMyName", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "language_code", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "set_chat_menu_button_48f5c164", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/setChatMenuButton", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "menu_button", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "get_chat_menu_button_fc776459", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getChatMenuButton", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "set_my_default_administrator_rights_5ef1298e", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/setMyDefaultAdministratorRights", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "for_channels", + "limit", + "offset", + "page", + "per_page", + "rights" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "get_my_default_administrator_rights_e3cf13de", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getMyDefaultAdministratorRights", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "for_channels", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "answer_callback_query_5a095f8f", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/answerCallbackQuery", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cache_time", + "callback_query_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "show_alert", + "text", + "url" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "set_webhook_823e4df5", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/setWebhook", + "method": "POST", + "path_fields": [], + "query_fields": [ + "allowed_updates", + "cursor", + "drop_pending_updates", + "expand", + "ip_address", + "limit", + "max_connections", + "offset", + "page", + "per_page", + "secret_token", + "url" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "delete_webhook_c318758d", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/deleteWebhook", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "drop_pending_updates", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "get_webhook_info_b26b0b07", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getWebhookInfo", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_whatsapp_business", + "operation": "send_text_fac0338a", + "kind": "api_key", + "endpoint": "https://graph.facebook.com/v21.0/:phone_number_id/messages", + "method": "POST", + "path_fields": [ + "phone_number_id" + ], + "query_fields": [ + "body", + "cursor", + "expand", + "limit", + "messaging_product", + "offset", + "page", + "per_page", + "text", + "to", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_whatsapp_business", + "operation": "get_media_url_0f8570a8", + "kind": "api_key", + "endpoint": "https://graph.facebook.com/v21.0/:media_id", + "method": "GET", + "path_fields": [ + "media_id" + ], + "query_fields": [ + "cursor", + "expand", + "file_size", + "limit", + "mime_type", + "offset", + "page", + "per_page", + "url" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_whatsapp_business", + "operation": "get_business_profile_08cda6b1", + "kind": "api_key", + "endpoint": "https://graph.facebook.com/v21.0/:phone_number_id/whatsapp_business_profile", + "method": "GET", + "path_fields": [ + "phone_number_id" + ], + "query_fields": [ + "cursor", + "expand", + "fields", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "reviewed_dee99786f1ad", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/teams/:team_id/components", + "method": "GET", + "path_fields": [ + "team_id" + ], + "query_fields": [ + "page_size", + "after", + "before" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "reviewed_bff00ba21d61", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/components", + "method": "GET", + "path_fields": [ + "file_key" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "reviewed_d3a76683809a", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/teams/:team_id/component_sets", + "method": "GET", + "path_fields": [ + "team_id" + ], + "query_fields": [ + "page_size", + "after", + "before" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "reviewed_addcfaadc2e7", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/component_sets", + "method": "GET", + "path_fields": [ + "file_key" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "reviewed_7016b7bef252", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/teams/:team_id/styles", + "method": "GET", + "path_fields": [ + "team_id" + ], + "query_fields": [ + "page_size", + "after", + "before" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "reviewed_e6577de14def", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/styles", + "method": "GET", + "path_fields": [ + "file_key" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "reviewed_e7741efb4da1", + "kind": "service_secret", + "endpoint": "https://upload.twitter.com/1.1/media/upload.json", + "method": "POST", + "path_fields": [], + "query_fields": [], + "envelope": true, + "request_format": "multipart", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "reviewed_45020708e2eb", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/config/auth", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "reviewed_7215572b5ac3", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/config/auth", + "method": "PATCH", + "path_fields": [ + "project_ref" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "reviewed_d329a7769579", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/postgrest", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "reviewed_dba3e6872d6a", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/postgrest", + "method": "PATCH", + "path_fields": [ + "project_ref" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "reviewed_62a6a35482cb", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/config/realtime", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "reviewed_388e70c8e974", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/config/realtime", + "method": "PATCH", + "path_fields": [ + "project_ref" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "reviewed_101bf90f8611", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/config/storage", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "reviewed_d5d84927cc6d", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/config/storage", + "method": "PATCH", + "path_fields": [ + "project_ref" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "reviewed_5eaf9781ab69", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/config/database/postgres", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "reviewed_b9d52af89a1d", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/config/database/postgres", + "method": "PUT", + "path_fields": [ + "project_ref" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_whatsapp_business", + "operation": "reviewed_3412812d2d34", + "kind": "api_key", + "endpoint": "https://graph.facebook.com/v21.0/:phone_number_id", + "method": "GET", + "path_fields": [ + "phone_number_id" + ], + "query_fields": [ + "fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "reviewed_cdc656be20ca", + "kind": "api_key", + "endpoint": "https://api.telegram.org/file/bot_TOKEN_/:file_path", + "method": "GET", + "path_fields": [ + "file_path" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram", + "response_format": "binary", + "max_response_bytes": 64000000 + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "reviewed_5222b27d9c30", + "kind": "api_key", + "endpoint": "https://files.slack.com/files-pri/:team_file_id/download/:file_name", + "method": "GET", + "path_fields": [ + "team_file_id", + "file_name" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "binary", + "max_response_bytes": 64000000 + } +] diff --git a/app/credentials/records.py b/app/credentials/records.py new file mode 100644 index 000000000..2a060315f --- /dev/null +++ b/app/credentials/records.py @@ -0,0 +1,220 @@ +"""Versioned encrypted document format and metadata-only owner results.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import ipaddress +import re +from urllib.parse import urlsplit +from uuid import UUID + +from .contracts import CredentialKind, CredentialReference, CredentialSummary +from .errors import VaultError, VaultErrorCode as Code +from .identity import VaultOwner, VaultProfile, VaultScope +from .secrets import Secret + +MAX_ITEMS = 5000 + + +@dataclass(frozen=True) +class CredentialDetails: + summary: CredentialSummary + allowed_origins: tuple[str, ...] + created_at: str + updated_at: str + + +@dataclass(repr=False) +class _Record: + details: CredentialDetails + fields: Secret + + def __repr__(self) -> str: + return "" + + +def validate_fields(kind: CredentialKind, fields: Secret) -> None: + if not isinstance(kind, CredentialKind): + raise VaultError(Code.INVALID) + if not isinstance(fields, Secret) or not isinstance(fields.reveal(), dict): + raise VaultError(Code.INVALID) + if not 1 <= len(fields.reveal()) <= 32: + raise VaultError(Code.INVALID) + for entry in map(Secret, fields.reveal().items()): + if ( + not isinstance(entry.reveal()[0], str) + or not re.fullmatch(r"[a-z][a-z0-9_]{0,63}", entry.reveal()[0]) + or not isinstance(entry.reveal()[1], str) + or not 0 < len(entry.reveal()[1]) <= 32768 + ): + raise VaultError(Code.INVALID) + expected = { + CredentialKind.LOGIN: {"username", "password"}, + CredentialKind.API_KEY: {"api_key"}, + }.get(kind) + if expected is not None and set(fields.reveal()) != expected: + raise VaultError(Code.INVALID) + + +def origins(values: tuple[str, ...]) -> tuple[str, ...]: + """Exact ASCII HTTPS origins; no www folding or inferred login domains. + + These are stored policy metadata, not authorization to issue a request. + Destination/network validation and browser canonicalization belong to CM-03/05. + """ + if not isinstance(values, tuple) or len(values) > 32: + raise VaultError(Code.INVALID) + result = set() + try: + for value in values: + if ( + not isinstance(value, str) + or not value.isascii() + or len(value) > 2048 + or any(char.isspace() or ord(char) < 32 for char in value) + or "\\" in value + ): + raise ValueError() + url = urlsplit(value) + if ( + url.scheme != "https" + or not url.hostname + or url.username is not None + or url.password is not None + or url.path not in ("", "/") + or url.query + or url.fragment + or not re.fullmatch(r"[a-zA-Z0-9.:-]+", url.hostname) + or url.port == 0 + ): + raise ValueError() + host = url.hostname.lower() + if ":" in host: + ipaddress.IPv6Address(host) + host = f"[{host}]" + else: + dns = host[:-1] if host.endswith(".") else host + if len(dns) > 253 or not all( + re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label) + for label in dns.split(".") + ): + raise ValueError() + port = f":{url.port}" if url.port not in (None, 443) else "" + result.add(f"https://{host}{port}") + except ValueError: + raise VaultError(Code.INVALID) from None + return tuple(sorted(result)) + + +def encode(owner: VaultOwner, profile: VaultProfile, records: list[_Record]) -> Secret: + document = Secret( + { + "format": 1, + "owner_id": str(owner.owner_id), + "os_identity": owner.os_identity, + "profile_id": str(profile.scope.profile_id), + "profile_label": profile.label, + "items": [ + { + "id": str(record.details.summary.reference.credential_id), + "version": record.details.summary.reference.version, + "kind": record.details.summary.kind.value, + "service": record.details.summary.service, + "account_label": record.details.summary.account_label, + "origins": record.details.allowed_origins, + "created_at": record.details.created_at, + "updated_at": record.details.updated_at, + "fields": record.fields.reveal(), + } + for record in records + ], + } + ) + return Secret(json.dumps(document.reveal(), ensure_ascii=True).encode("ascii")) + + +def decode(plaintext: Secret, owner: VaultOwner, scope: VaultScope): + """Reject corrupt/unknown documents, never repair by silently dropping data.""" + try: + document = Secret(json.loads(plaintext.reveal())) + if ( + not isinstance(document.reveal(), dict) + or set(document.reveal()) + != { + "format", + "owner_id", + "os_identity", + "profile_id", + "profile_label", + "items", + } + or type(document.reveal()["format"]) is not int + or document.reveal()["format"] != 1 + or document.reveal()["owner_id"] != str(scope.owner_id) + or document.reveal()["profile_id"] != str(scope.profile_id) + or document.reveal()["os_identity"] != owner.os_identity + ): + raise ValueError() + profile = VaultProfile(scope, document.reveal()["profile_label"]) + items = Secret(document.reveal()["items"]) + if not isinstance(items.reveal(), list) or len(items.reveal()) > MAX_ITEMS: + raise ValueError() + seen = set() + records = [] + for raw in map(Secret, items.reveal()): + if not isinstance(raw.reveal(), dict) or set(raw.reveal()) != { + "id", + "version", + "kind", + "service", + "account_label", + "origins", + "created_at", + "updated_at", + "fields", + }: + raise ValueError() + reference = CredentialReference( + scope, UUID(raw.reveal()["id"]), raw.reveal()["version"] + ) + if reference.credential_id in seen: + raise ValueError() + seen.add(reference.credential_id) + summary = CredentialSummary( + reference, + CredentialKind(raw.reveal()["kind"]), + raw.reveal()["service"], + raw.reveal()["account_label"], + ) + fields = Secret(raw.reveal()["fields"]) + validate_fields(summary.kind, fields) + if not isinstance(raw.reveal()["origins"], list): + raise ValueError() + allowed = origins(tuple(raw.reveal()["origins"])) + if summary.kind is CredentialKind.LOGIN and not allowed: + raise ValueError() + for name in ("created_at", "updated_at"): + if not isinstance(raw.reveal()[name], str) or not raw.reveal()[name]: + raise ValueError() + records.append( + _Record( + CredentialDetails( + summary, + allowed, + raw.reveal()["created_at"], + raw.reveal()["updated_at"], + ), + fields, + ) + ) + return profile, records + except ( + ValueError, + TypeError, + KeyError, + AttributeError, + RecursionError, + VaultError, + ): + raise VaultError(Code.UNREADABLE) from None diff --git a/app/credentials/references.py b/app/credentials/references.py new file mode 100644 index 000000000..2abadc424 --- /dev/null +++ b/app/credentials/references.py @@ -0,0 +1,40 @@ +"""Serializable source references. A reference is an identifier, never authority.""" + +from dataclasses import dataclass +import re +from uuid import UUID + +PREFIX = "craftbot-vault://" + + +@dataclass(frozen=True) +class SourceReference: + profile_id: UUID + credential_id: UUID + version: int + + def encode(self): + return f"{PREFIX}{self.profile_id}/{self.credential_id}/{self.version}" + + +def parse(value: str) -> SourceReference: + if not isinstance(value, str) or not value.startswith(PREFIX): + raise ValueError("credential_reference_required") + parts = value[len(PREFIX) :].split("/") + if len(parts) != 3 or not re.fullmatch(r"[1-9][0-9]{0,8}", parts[2]): + raise ValueError("credential_reference_invalid") + result = SourceReference(UUID(parts[0]), UUID(parts[1]), int(parts[2])) + if ( + not result.profile_id.int + or not result.credential_id.int + or result.encode() != value + ): + raise ValueError("credential_reference_invalid") + return result + + +def is_reference(value): + if not isinstance(value, str) or not value.startswith(PREFIX): + return False + parse(value) # Malformed references must never fall through to raw-key use. + return True diff --git a/app/credentials/request_review.py b/app/credentials/request_review.py new file mode 100644 index 000000000..7e5b663bb --- /dev/null +++ b/app/credentials/request_review.py @@ -0,0 +1,74 @@ +"""Host-derived request review. No credential or generated session disclosure.""" + +import hashlib +import json +from urllib.parse import urlsplit + +from .broker import canonical_request +from .helpers import HttpsJsonOperation +from .route_policy import target +from .use_errors import CredentialUseError, UseCode + + +def review_request(specification, payload, *, repeat=False): + _, digest = canonical_request(payload) + body = payload["body"] + destination = specification.endpoint + if type(specification) is HttpsJsonOperation and specification.envelope: + if type(body) is not dict: + raise CredentialUseError(UseCode.DENIED) + parsed = urlsplit(destination) + destination = f"{parsed.scheme}://{parsed.netloc}" + target( + destination, specification.path_fields, body.get("path") + ) + + def visible(value, depth=0): + if depth > 24: + raise CredentialUseError(UseCode.LIMIT) + if type(value) is dict: + # Authentication/session material stays at its existing owner. It + # is bound by the request hash, but never displayed or persisted. + return { + k: "[authentication value hidden]" + if k.lower() + in { + "session", + "authorization", + "access_token", + "access_token_secret", + "refresh_token", + "session_string", + "password", + "api_hash", + "app_secret", + } + else visible(v, depth + 1) + for k, v in value.items() + } + if type(value) is list: + return [visible(v, depth + 1) for v in value] + if isinstance(value, str) and len(value) > 8192: + return { + "content_omitted": True, + "characters": len(value), + "sha256": hashlib.sha256(value.encode("utf-8")).hexdigest(), + } + return value + + details = { + "Destination": destination, + "Method": getattr(specification, "method", "POST"), + "Request SHA256": digest.hex(), + "Scope": "Repeated GET; resource/query parameters may change within this installed route until lease expiry, lock or revocation." + if repeat + else "ONE attempt of this exact request. Changing any parameter requires new approval.", + "Parameters": json.dumps( + visible(body), ensure_ascii=True, sort_keys=True, indent=2 + ), + "Content notice": "Large values over 8192 characters are represented by length and SHA256; approving includes that exact content. Authentication values are hidden.", + } + # Do not truncate destinations, recipients or parameter lists. A request too + # large for complete meaningful review fails closed before owner approval. + if len(json.dumps(details, ensure_ascii=True).encode("utf-8")) > 1_900_000: + raise CredentialUseError(UseCode.LIMIT) + return details diff --git a/app/credentials/resolver.py b/app/credentials/resolver.py new file mode 100644 index 000000000..b5aa49323 --- /dev/null +++ b/app/credentials/resolver.py @@ -0,0 +1,36 @@ +"""Bound DNS waits without passing secret-bearing contexts to resolver threads.""" + +import queue +import socket +from threading import BoundedSemaphore, Thread + +_slots = BoundedSemaphore(4) + + +def _lookup(host, port, output): + try: + try: + result = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) + except Exception: + result = None + output.put_nowait(result) + finally: + _slots.release() + + +def resolve(host, port): + if not _slots.acquire(blocking=False): + raise TimeoutError("credential_dns_busy") + output = queue.Queue(maxsize=1) + try: + Thread(target=_lookup, args=(host, port, output), daemon=True).start() + except BaseException: + _slots.release() + raise + try: + result = output.get(timeout=10) + except queue.Empty: + raise TimeoutError("credential_dns_timeout") from None + if result is None: + raise OSError("credential_dns_unavailable") + return result diff --git a/app/credentials/route_policy.py b/app/credentials/route_policy.py new file mode 100644 index 000000000..e4b05275c --- /dev/null +++ b/app/credentials/route_policy.py @@ -0,0 +1,99 @@ +"""Public, secret-free matching of sealed resource routes.""" + +import re +from urllib.parse import quote, unquote, urlsplit, parse_qsl + + +def path_names(endpoint): + return tuple(re.findall(r"(?<=/):([a-z][a-z0-9_]*)", urlsplit(endpoint).path)) + + +def parameter(value, name): + if type(value) is int: + value = str(value) + if not isinstance(value, str) or not 1 <= len(value) <= 1024: + raise ValueError("credential_path_invalid") + # Repository references and explicitly declared tail fields can contain + # bounded relative segments. There is never a host, URL, dot traversal, + # encoded slash, query or fragment in a resource parameter. + parts = value.split("/") + maximum = ( + 32 + if name in ("path", "file_path", "operation_name") + else 2 + if name == "owner_repo" + else 1 + ) + if len(parts) > maximum or any( + not re.fullmatch(r"[A-Za-z0-9_@.-]+(?::[A-Za-z0-9_.-]+)*", p) + or p in (".", "..") + for p in parts + ): + raise ValueError("credential_path_invalid") + return "/".join(quote(p, safe="@.:-_") for p in parts) + + +def target(endpoint, names, values): + if type(values) is not dict or set(values) != set(names): + raise ValueError("credential_path_invalid") + path = urlsplit(endpoint).path + for name in names: + path = re.sub( + r"/:(" + re.escape(name) + r")(?=/|$|:)", + lambda _: "/" + parameter(values[name], name), + path, + ) + return path or "/" + + +def match_route(routes, method, url): + parsed = urlsplit(url) + if ( + parsed.username is not None + or parsed.password is not None + or parsed.fragment + or parsed.scheme != "https" + ): + return None + for route in routes: + base = urlsplit(route["endpoint"]) + if ( + route["method"] != method + or base.scheme != parsed.scheme + or base.netloc != parsed.netloc + ): + continue + names = tuple(route.get("path_fields", ())) + pattern = re.escape(base.path) + for name in names: + pattern = pattern.replace( + re.escape(":" + name), + "(?P<" + + name + + ">" + + ( + ".+" + if name in ("owner_repo", "path", "file_path", "operation_name") + else "[^/]+" + ) + + ")", + 1, + ) + found = re.fullmatch(pattern, parsed.path) + if not found: + continue + try: + values = {k: unquote(v) for k, v in found.groupdict().items()} + # Round-trip canonicalization closes encoded separators and aliases. + if target(route["endpoint"], names, values) != parsed.path: + continue + pairs = parse_qsl(parsed.query, keep_blank_values=True, strict_parsing=True) + if len(dict(pairs)) != len(pairs) or any( + k.split("[", 1)[0] not in route.get("query_fields", ()) + for k, v in pairs + ): + continue + except ValueError: + continue + return route, values, dict(pairs) + return None diff --git a/app/credentials/secrets.py b/app/credentials/secrets.py new file mode 100644 index 000000000..b932358bf --- /dev/null +++ b/app/credentials/secrets.py @@ -0,0 +1,36 @@ +"""Redacting holders shared with the legacy browser vault. + +Internal host code only: this is not an agent API or an isolation mechanism. +Managed Python memory cannot provide guaranteed zeroization. +""" + +from typing import Any + + +class Secret: + """Wrap immediately at secure entry; never print or pickle the value.""" + + __slots__ = ("_value",) + + def __init__(self, value: Any) -> None: + self._value = value + + @classmethod + def wrap(cls, value: Any) -> "Secret": + return value if isinstance(value, Secret) else cls(value) + + def reveal(self) -> Any: + """Private-boundary plumbing; never expose through a consumer route.""" + return self._value + + def __bool__(self) -> bool: + return bool(self._value) + + def __repr__(self) -> str: + return "Secret('[redacted]')" + + def __str__(self) -> str: + return "[redacted]" + + def __reduce__(self): + raise TypeError("Secret values cannot be pickled") diff --git a/app/credentials/settings.py b/app/credentials/settings.py new file mode 100644 index 000000000..9d768f6c4 --- /dev/null +++ b/app/credentials/settings.py @@ -0,0 +1,65 @@ +"""Metadata-only UI adapter. The native service verifies every sensitive act. + +Never forward an arbitrary operation, user-provided context, source value or +capability through WebSocket. Replies belong only to the requesting connection. +""" + +from __future__ import annotations + +import asyncio + +from .client import CredentialHostUnavailable, request + +FIELDS = { + "profiles": (), + "action_profile": (), + "action_profile_set": ("profile_id",), + "profile_add": ("label",), + "initialize": ("profile_id",), + "unlock": ("profile_id",), + "lock": ("profile_id",), + "items": ("profile_id",), + "items_page": ("profile_id", "view_id", "offset"), + "activity": ("profile_id",), + "revoke": ("profile_id", "grant_id"), + "entry": ("profile_id", "kind", "service", "account", "origins", "fields"), + "edit": ( + "profile_id", + "credential_id", + "version", + "service", + "account", + "origins", + "replace_fields", + ), + "delete": ("profile_id", "credential_id", "version"), + "view": ("profile_id", "credential_id", "version"), +} + + +async def handle(adapter, ws, data): + operation = data.get("operation") if type(data) is dict else None + parameters = data.get("parameters", {}) if type(data) is dict else {} + request_id = data.get("request_id") if type(data) is dict else None + result = { + "operation": operation if isinstance(operation, str) else None, + "ok": False, + "error": "credential_use_denied", + } + if ( + isinstance(operation, str) + and operation in FIELDS + and type(parameters) is dict + and set(parameters) == set(FIELDS[operation]) + ): + try: + value = await asyncio.to_thread(request, operation, **parameters) + result = {"operation": operation, "ok": True, "value": value} + except CredentialHostUnavailable as error: + result["error"] = str(error) # Client already enforces fixed codes. + except Exception: + result["error"] = "credential_host_unavailable" + result["request_id"] = ( + request_id if isinstance(request_id, str) and len(request_id) <= 64 else None + ) + adapter._send_only_to(ws, {"type": "credentials_result", "data": result}) diff --git a/app/credentials/storage.py b/app/credentials/storage.py new file mode 100644 index 000000000..d49d699aa --- /dev/null +++ b/app/credentials/storage.py @@ -0,0 +1,135 @@ +"""Atomic profile snapshots. SQLite journals contain encrypted blobs only. + +One connection per operation, with BEGIN IMMEDIATE for cross-process writers. +No database, temporary file or backup ever holds plaintext vault data or keys. +""" + +from __future__ import annotations + +from contextlib import contextmanager +import os +from pathlib import Path +import sqlite3 +import stat + +from .errors import VaultError, VaultErrorCode as Code +from .identity import VaultScope + +MAX_SNAPSHOT_BYTES = 16 * 1024 * 1024 + + +def _private(path: Path, *, directory: bool) -> None: + info = path.lstat() + if stat.S_ISLNK(info.st_mode) or getattr(info, "st_file_attributes", 0) & 0x400: + raise VaultError(Code.IO) # Includes Windows junctions/reparse points. + if not (stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode)): + raise VaultError(Code.IO) + if os.name == "nt": + try: + import win32security + + # Grant the object's owner and SYSTEM full access; inherit to children. + sd = win32security.ConvertStringSecurityDescriptorToSecurityDescriptor( + "D:P(A;OICI;FA;;;OW)(A;OICI;FA;;;SY)", 1 + ) + win32security.SetFileSecurity( + str(path), + win32security.DACL_SECURITY_INFORMATION + | win32security.PROTECTED_DACL_SECURITY_INFORMATION, + sd, + ) + except Exception: + raise VaultError(Code.BACKEND_UNAVAILABLE) from None + else: + if info.st_uid != os.geteuid(): + raise VaultError(Code.IO) + os.chmod(path, 0o700 if directory else 0o600) + + +class SnapshotStore: + def __init__(self, root: Path, scope: VaultScope) -> None: + # Explicit host-owned root; never default to legacy .credentials or scan it. + self.root = Path(root).absolute() + self.directory = self.root / str(scope.owner_id) / str(scope.profile_id) + self.path = self.directory / "vault.sqlite3" + + @contextmanager + def transaction(self, *, write: bool = False, initialize: bool = False): + connection = None + try: + if initialize: + for directory in (self.root, self.directory.parent, self.directory): + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + _private(directory, directory=True) + else: + for directory in (self.root, self.directory.parent, self.directory): + _private(directory, directory=True) + if self.path.exists(): + _private(self.path, directory=False) + elif not initialize: + raise VaultError(Code.UNREADABLE) + connection = sqlite3.connect( + str(self.path) if initialize else self.path.as_uri() + "?mode=rw", + timeout=5, + uri=not initialize, + ) + _private(self.path, directory=False) + connection.execute("PRAGMA synchronous=FULL") + connection.execute("BEGIN IMMEDIATE" if write or initialize else "BEGIN") + tables = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ) + } + version = connection.execute("PRAGMA user_version").fetchone()[0] + if initialize and not tables and version == 0: + connection.execute( + "CREATE TABLE vault (id INTEGER PRIMARY KEY CHECK(id=1), " + "current BLOB NOT NULL, previous BLOB)" + ) + connection.execute("PRAGMA user_version=1") + elif tables != {"vault"} or version != 1: + raise VaultError(Code.UNREADABLE) + elif initialize: + raise VaultError( + Code.ALREADY_EXISTS + if connection.execute("SELECT 1 FROM vault WHERE id=1").fetchone() + else Code.UNREADABLE + ) + yield connection + connection.commit() + except sqlite3.Error as exc: + code = getattr(exc, "sqlite_errorcode", 0) & 0xFF + raise VaultError( + Code.UNREADABLE + if code in (sqlite3.SQLITE_CORRUPT, sqlite3.SQLITE_NOTADB) + else Code.IO + ) from None + except OSError: + raise VaultError(Code.IO) from None + finally: + if connection is not None: + connection.close() # Roll back any failed transaction. + + @staticmethod + def read(connection) -> bytes: + row = connection.execute( + "SELECT length(current) FROM vault WHERE id=1" + ).fetchone() + if row is None or not 0 < row[0] <= MAX_SNAPSHOT_BYTES: + raise VaultError(Code.UNREADABLE) + row = connection.execute("SELECT current FROM vault WHERE id=1").fetchone() + if not isinstance(row[0], bytes): + raise VaultError(Code.UNREADABLE) + return row[0] + + @staticmethod + def write(connection, encrypted: bytes) -> None: + if not 0 < len(encrypted) <= MAX_SNAPSHOT_BYTES: + raise VaultError(Code.INVALID) + connection.execute( + "INSERT INTO vault(id,current) VALUES(1,?) " + "ON CONFLICT(id) DO UPDATE SET previous=current, current=excluded.current", + (encrypted,), + ) diff --git a/app/credentials/streaming.py b/app/credentials/streaming.py new file mode 100644 index 000000000..b24488050 --- /dev/null +++ b/app/credentials/streaming.py @@ -0,0 +1,127 @@ +"""Validated SSE relay with bounded semantic source-echo holdback.""" + +import codecs +import json +import re +from .helpers import _response_variants, _sanitize_json_payloads +from .use_errors import CredentialUseError, UseCode + + +class StreamFilter: + def __init__(self, fields, emit): + self.fields, self.emit = fields, emit + self.patterns = _response_variants(fields) + self.tables = [] + for pattern in self.patterns.reveal(): + table, matched = [0] * len(pattern), 0 + for i in range(1, len(pattern)): + while matched and pattern[i] != pattern[matched]: + matched = table[matched - 1] + if pattern[i] == pattern[matched]: + matched += 1 + table[i] = matched + self.tables.append(table) + self.states, self.pending = {}, [] + self.pending_size = 0 + + def _strings(self, value, path=()): + if type(value) is dict: + for key, child in value.items(): + yield from self._strings(child, (*path, key)) + elif type(value) is list: + for index, child in enumerate(value): + yield from self._strings(child, (*path, index)) + elif ( + isinstance(value, str) + and path + and path[-1] + in ( + "content", + "text", + "arguments", + "partial_json", + "thinking", + "refusal", + "delta", + "signature", + ) + ): + yield path, value + + def feed(self, record): + event, data = "", [] + for line in record.splitlines(): + if line.startswith("data:"): + data.append(line[5:].lstrip(" ")) + elif line.startswith("event:") and re.fullmatch( + r"event: ?[A-Za-z0-9_.-]{1,64}", line + ): + event = line + "\n" + elif line and not line.startswith(":"): + raise CredentialUseError(UseCode.HELPER) + if not data: + return + raw = "\n".join(data) + if raw == "[DONE]": + self.flush() + self.emit("data: [DONE]\n\n") + return + cleaned = _sanitize_json_payloads([json.loads(raw)], self.fields)[0] + for path, value in self._strings(json.loads(cleaned)): + if len(self.states) > 2048: + raise CredentialUseError(UseCode.LIMIT) + states = self.states.setdefault(path, [0] * len(self.tables)) + for index, (pattern, table) in enumerate( + zip(self.patterns.reveal(), self.tables) + ): + state = states[index] + for char in value: + while state and char != pattern[state]: + state = table[state - 1] + if char == pattern[state]: + state += 1 + if state == len(pattern): + # The entire echo is still in pending frames. Never + # release even the first source fragment to the SDK. + raise CredentialUseError(UseCode.HELPER) + states[index] = state + frame = event + "data: " + cleaned + "\n\n" + self.pending.append(frame) + self.pending_size += len(frame) + if self.pending_size > 1_000_000: + raise CredentialUseError(UseCode.LIMIT) + if not any(any(states) for states in self.states.values()): + self.flush() + + def flush(self): + for frame in self.pending: + self.emit(frame) + self.pending, self.pending_size = [], 0 + + +def relay(response, fields, execution, maximum): + decoder = codecs.getincrementaldecoder("utf-8")("strict") + buffer, total = "", 0 + filtering = StreamFilter(fields, execution.emit) + while True: + if execution.cancelled.is_set(): + raise CredentialUseError(UseCode.INTERRUPTED) + chunk = response.read1(8192) + if not chunk: + buffer += decoder.decode(b"", final=True) + break + total += len(chunk) + if total > maximum: + raise CredentialUseError(UseCode.LIMIT) + buffer += decoder.decode(chunk) + # SSE line endings are normalized after decoding; a CRLF split across + # TLS reads is retained until the following chunk arrives. + buffer = buffer.replace("\r\n", "\n") + while "\n\n" in buffer: + record, buffer = buffer.split("\n\n", 1) + filtering.feed(record) + if len(buffer) > 1_000_000: + raise CredentialUseError(UseCode.LIMIT) + if buffer.strip(): + filtering.feed(buffer) + filtering.flush() diff --git a/app/credentials/twitter_helper.py b/app/credentials/twitter_helper.py new file mode 100644 index 000000000..376aad657 --- /dev/null +++ b/app/credentials/twitter_helper.py @@ -0,0 +1,83 @@ +"""Host-only OAuth 1.0a signing for sealed Twitter REST operations. + +App key/secret remain in the vault. The generated user token pair is supplied +per operation by its existing account owner; no signer or header is exposed. +""" + +import base64 +import hashlib +import hmac +import secrets +import time +from urllib.parse import parse_qsl, quote + +from .secrets import Secret +from .use_errors import CredentialUseError, UseCode + + +def authorization(url, method, query, fields, session): + if type(session) is not dict or set(session) != { + "access_token", + "access_token_secret", + }: + raise CredentialUseError(UseCode.DENIED) + private = Secret({**fields.reveal(), **session}) + if set(fields.reveal()) != {"api_key", "api_secret"} or any( + not isinstance(v, str) + or not 1 <= len(v) <= 4096 + or any(ord(c) < 33 or ord(c) > 126 for c in v) + for v in private.reveal().values() + ): + raise CredentialUseError(UseCode.DENIED) + oauth = Secret( + { + "oauth_consumer_key": private.reveal()["api_key"], + "oauth_token": private.reveal()["access_token"], + "oauth_nonce": secrets.token_hex(16), + "oauth_timestamp": str(int(time.time())), + "oauth_signature_method": "HMAC-SHA1", + "oauth_version": "1.0", + } + ) + pairs = parse_qsl(query, keep_blank_values=True) + if any(k.startswith("oauth_") for k, _ in pairs): + raise CredentialUseError(UseCode.DENIED) + normalized = Secret( + "&".join( + k + "=" + v + for k, v in sorted( + (quote(k, safe=""), quote(v, safe="")) + for k, v in [*pairs, *oauth.reveal().items()] + ) + ) + ) + base = Secret( + "&".join(quote(v, safe="") for v in (method, url, normalized.reveal())) + ) + key = Secret( + quote(private.reveal()["api_secret"], safe="") + + "&" + + quote(private.reveal()["access_token_secret"], safe="") + ) + signature = Secret( + base64.b64encode( + hmac.new( + key.reveal().encode(), base.reveal().encode(), hashlib.sha1 + ).digest() + ).decode() + ) + oauth.reveal()["oauth_signature"] = signature.reveal() + header = Secret( + "OAuth " + + ", ".join( + quote(k, safe="") + '="' + quote(v, safe="") + '"' + for k, v in sorted(oauth.reveal().items()) + ) + ) + return header, Secret( + { + **private.reveal(), + "signature": signature.reveal(), + "signing_key": key.reveal(), + } + ) diff --git a/app/credentials/use_errors.py b/app/credentials/use_errors.py new file mode 100644 index 000000000..02c9dc6e6 --- /dev/null +++ b/app/credentials/use_errors.py @@ -0,0 +1,64 @@ +"""Public helper errors discard secret-bearing traceback frames and contexts.""" + +from enum import Enum +from functools import wraps + +from .contracts import IsolationUnavailable +from .directory import IdentityError +from .errors import VaultError, VaultErrorCode + + +class UseCode(str, Enum): + DENIED = "credential_use_denied" + EXPIRED = "credential_grant_expired" + CAPABILITY = "credential_capability_invalid" + ISOLATION = "credential_isolation_unavailable" + VERIFICATION = "owner_verification_required" + LOCKED = "vault_locked" + CHANGED = "credential_changed" + UNAVAILABLE = "credential_unavailable" + HELPER = "credential_helper_failed" + LIMIT = "credential_use_limit" + INTERRUPTED = "credential_use_interrupted" + BUSY = "credential_busy" + + +class CredentialUseError(RuntimeError): + def __init__(self, code: UseCode): + self.code = code + super().__init__(code.value) + + +def use_boundary(operation): + @wraps(operation) + def sanitized(*args, **kwargs): + interrupted = None + try: + return operation(*args, **kwargs) + except CredentialUseError as error: + code = error.code + except IsolationUnavailable: + code = UseCode.ISOLATION + except IdentityError: + code = UseCode.DENIED + except VaultError as error: + code = { + VaultErrorCode.LOCKED: UseCode.LOCKED, + VaultErrorCode.CONFLICT: UseCode.CHANGED, + VaultErrorCode.VERIFICATION_REQUIRED: UseCode.VERIFICATION, + VaultErrorCode.SCOPE_DENIED: UseCode.DENIED, + }.get(error.code, UseCode.UNAVAILABLE) + except Exception: + code = UseCode.HELPER + except (KeyboardInterrupt, SystemExit, GeneratorExit) as error: + interrupted = type(error) + except BaseException: + code = UseCode.INTERRUPTED + # Outside the except block: diagnostic loggers must not see inner frames. + if interrupted is SystemExit: + raise SystemExit(1) from None + if interrupted is not None: + raise interrupted() from None + raise CredentialUseError(code) from None + + return sanitized diff --git a/app/credentials/vault.py b/app/credentials/vault.py new file mode 100644 index 000000000..01b61fbfa --- /dev/null +++ b/app/credentials/vault.py @@ -0,0 +1,540 @@ +"""Shared, host-only source-credential vault. No consumer routes. + +Keys live in an injected OS facility; snapshots contain authenticated ciphertext +only. Owner verification is mandatory, with no working default verifier. The +service must own one instance per profile; SQLite serializes other-process writes. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import dataclass, replace +from datetime import datetime, timezone +from enum import Enum +import hashlib +import hmac +import math +from pathlib import Path +from threading import RLock, Timer +import time +from uuid import UUID, uuid4 + +from .contracts import CredentialKind, CredentialReference, CredentialSummary +from .errors import VaultError, VaultErrorCode as Code, sanitized_errors +from .identity import VaultOwner, VaultProfile +from .keys import ( + KeyStore, + OwnerVerifier, + VerificationUnavailable, + WindowsCredentialKeyStore, +) +from .records import ( + CredentialDetails, + MAX_ITEMS, + _Record, + decode, + encode, + origins, + validate_fields, +) +from .secrets import Secret +from .storage import SnapshotStore +from .use_errors import CredentialUseError + + +class VaultState(str, Enum): + LOCKED = "locked" + READY = "ready" + UNREADABLE = "unreadable" + UNAVAILABLE = "unavailable" + + +@dataclass(frozen=True) +class VaultStatus: + state: VaultState + unlock_epoch: UUID | None + + +@dataclass(frozen=True) +class SourceImport: + credential_id: UUID + kind: CredentialKind + service: str + account_label: str + fields: Secret + allowed_origins: tuple[str, ...] + + +def _cipher(key: Secret): + try: + from cryptography.fernet import Fernet + except ImportError: + raise VaultError(Code.BACKEND_UNAVAILABLE) from None + try: + return Secret(Fernet(key.reveal())) + except (TypeError, ValueError): + raise VaultError(Code.UNREADABLE) from None + + +def _new_key() -> Secret: + try: + from cryptography.fernet import Fernet + except ImportError: + raise VaultError(Code.BACKEND_UNAVAILABLE) from None + return Secret(Fernet.generate_key()) + + +class ProfileVault: + def __init__( + self, + root: Path, + owner: VaultOwner, + profile: VaultProfile, + *, + key_store: KeyStore | None = None, + verifier: OwnerVerifier | None = None, + idle_seconds: float = 900, + clock=time.monotonic, + ) -> None: + if owner.owner_id != profile.scope.owner_id: + raise VaultError(Code.SCOPE_DENIED) + if not math.isfinite(idle_seconds) or idle_seconds <= 0: + raise VaultError(Code.INVALID) + self.owner = owner + self.profile = profile + self.store = SnapshotStore(root, profile.scope) + self._keys = key_store if key_store is not None else WindowsCredentialKeyStore() + self._verifier = verifier if verifier is not None else VerificationUnavailable() + self._idle = idle_seconds + self._clock = clock + self._lock = RLock() + self._crypto: Secret | None = None + self._key_tag: Secret | None = None + self._state = VaultState.LOCKED + self._epoch: UUID | None = None + self._deadline = 0.0 + self._timer: Timer | None = None + self._on_lock = ( + None # Protected host cancellation hook; never a consumer callback. + ) + + def _clear(self, state: VaultState = VaultState.LOCKED) -> None: + was_ready = self._state is VaultState.READY + if self._timer is not None: + self._timer.cancel() + self._timer = None + self._crypto = self._key_tag = None + self._epoch = None + self._deadline = 0.0 + self._state = state + if was_ready and self._on_lock is not None: + self._on_lock() + + def lock(self) -> None: + """Also used by host OS-lock/sleep/app-exit hooks; no session sign-out.""" + with self._lock: + self._clear() + + close = lock + + def _expire(self) -> None: + with self._lock: + if self._state is VaultState.READY: + if self._clock() >= self._deadline: + self._clear() + else: + self._schedule_lock() + + def _schedule_lock(self) -> None: + if self._timer is not None: + self._timer.cancel() + self._timer = Timer(max(0.001, self._deadline - self._clock()), self._expire) + self._timer.daemon = True + self._timer.start() + + def _activate(self, key: Secret) -> None: + self._crypto = _cipher(key) + self._key_tag = Secret(hashlib.sha256(key.reveal()).digest()) + self._epoch = uuid4() + self._state = VaultState.READY + self._deadline = self._clock() + self._idle + self._schedule_lock() + + def status(self) -> VaultStatus: + with self._lock: + if self._state is VaultState.READY and self._clock() >= self._deadline: + self._clear() + return VaultStatus(self._state, self._epoch) + + def owner_activity(self) -> None: + """Trusted owner UI events only; secret use never refreshes this timer.""" + with self._lock: + self._require_ready() + self._deadline = self._clock() + self._idle + self._schedule_lock() + + def _require_ready(self) -> None: + if self._state is VaultState.READY and self._clock() >= self._deadline: + self._clear() + if self._state is not VaultState.READY: + raise VaultError(Code.LOCKED) + + def _verify_owner(self) -> None: + try: + if self._verifier.verify(self.owner) is not None: + raise VaultError(Code.VERIFICATION_REQUIRED) + except VaultError: + raise + except Exception: + raise VaultError(Code.VERIFICATION_REQUIRED) from None + + def _load_key(self) -> Secret | None: + try: + return self._keys.load(self.profile.scope) + except VaultError: + raise + except Exception: + raise VaultError(Code.BACKEND_UNAVAILABLE) from None + + def _create_key(self, key: Secret) -> None: + try: + self._keys.create(self.profile.scope, key) + except VaultError: + raise + except Exception: + raise VaultError(Code.BACKEND_UNAVAILABLE) from None + + @contextmanager + def _opening(self): + with self._lock: + self._clear() + try: + self._verify_owner() + yield + except VaultError as exc: + if exc.code is Code.UNREADABLE: + self._clear(VaultState.UNREADABLE) + elif exc.code in (Code.IO, Code.BACKEND_UNAVAILABLE): + self._clear(VaultState.UNAVAILABLE) + else: + self._clear() + raise + except Exception: + self._clear(VaultState.UNAVAILABLE) + raise VaultError(Code.IO) from None + except BaseException: + self._clear() + raise + + @sanitized_errors + def initialize(self) -> VaultStatus: + """Explicit owner action; never called implicitly by a read/unlock.""" + with self._opening(): + # An existing OS key without a DB may be lost data: never reset it. + if not self.store.path.exists() and self._load_key() is not None: + raise VaultError(Code.UNREADABLE) + with self.store.transaction(initialize=True, write=True) as connection: + if ( + connection.execute("SELECT 1 FROM vault LIMIT 1").fetchone() + is not None + ): + raise VaultError(Code.ALREADY_EXISTS) + key = self._load_key() + if key is None: + key = _new_key() + self._create_key(key) + crypto = _cipher(key) + SnapshotStore.write( + connection, + crypto.reveal().encrypt( + encode(self.owner, self.profile, []).reveal() + ), + ) + self._activate(key) + return self.status() + + @sanitized_errors + def unlock(self) -> VaultStatus: + with self._opening(): + if not self.store.path.is_file(): + raise VaultError(Code.UNREADABLE) + key = self._load_key() + if key is None: + raise VaultError(Code.UNREADABLE) + self._crypto = _cipher(key) + with self.store.transaction() as connection: + self._read(connection) # Verify decrypt/schema/scope before READY. + self._activate(key) + return self.status() + + def _read(self, connection): + try: + plaintext = Secret( + self._crypto.reveal().decrypt(SnapshotStore.read(connection)) + ) + except VaultError: + raise + except Exception: + raise VaultError(Code.UNREADABLE) from None + profile, records = decode(plaintext, self.owner, self.profile.scope) + self.profile = profile + return records + + @contextmanager + def _operation(self, *, write=False, helper=False): + with self._lock: + self._require_ready() + try: + key = self._load_key() + if key is None or not hmac.compare_digest( + self._key_tag.reveal(), hashlib.sha256(key.reveal()).digest() + ): + raise VaultError(Code.UNREADABLE) + # Helpers retain the writer reservation to prevent another + # process changing/deleting the version before dispatch. + with self.store.transaction(write=write or helper) as connection: + records = self._read(connection) + yield connection, records + if write: + SnapshotStore.write( + connection, + self._crypto.reveal().encrypt( + encode(self.owner, self.profile, records).reveal() + ), + ) + except CredentialUseError: + # Policy/network failures do not indicate storage corruption. + raise + except VaultError as exc: + if exc.code is Code.UNREADABLE: + self._clear(VaultState.UNREADABLE) + elif exc.code in (Code.IO, Code.BACKEND_UNAVAILABLE): + self._clear(VaultState.UNAVAILABLE) + raise + except Exception: + self._clear(VaultState.UNAVAILABLE) + raise VaultError(Code.IO) from None + except BaseException: + self._clear() + raise + + @sanitized_errors + def list_items(self) -> tuple[CredentialDetails, ...]: + with self._operation() as (_, records): + return tuple( + sorted( + (record.details for record in records), + key=lambda item: ( + item.summary.service, + item.summary.account_label, + str(item.summary.reference.credential_id), + ), + ) + ) + + @sanitized_errors + def create( + self, + kind: CredentialKind, + service: str, + account_label: str, + fields: Secret, + *, + allowed_origins: tuple[str, ...] = (), + ) -> CredentialDetails: + """Secure owner-entry adapter must wrap fields before calling this API.""" + if not isinstance(fields, Secret) or not isinstance(fields.reveal(), dict): + raise VaultError(Code.INVALID) + fields = Secret(dict(fields.reveal())) + validate_fields(kind, fields) + allowed = origins(allowed_origins) + if kind is CredentialKind.LOGIN and not allowed: + raise VaultError(Code.INVALID) + try: + summary = CredentialSummary( + CredentialReference(self.profile.scope, uuid4(), 1), + kind, + service, + account_label, + ) + except ValueError: + raise VaultError(Code.INVALID) from None + now = datetime.now(timezone.utc).isoformat() + details = CredentialDetails(summary, allowed, now, now) + with self._operation(write=True) as (_, records): + if len(records) >= MAX_ITEMS: + raise VaultError(Code.INVALID) + records.append(_Record(details, fields)) + return details + + def _find(self, reference: CredentialReference, records: list[_Record]) -> _Record: + if reference.scope != self.profile.scope: + raise VaultError(Code.SCOPE_DENIED) + for record in records: + existing = record.details.summary.reference + if existing.credential_id == reference.credential_id: + if existing.version != reference.version: + raise VaultError(Code.CONFLICT) + return record + raise VaultError(Code.NOT_FOUND) + + @sanitized_errors + def import_source( + self, + credential_id: UUID, + kind: CredentialKind, + service: str, + account_label: str, + fields: Secret, + *, + allowed_origins: tuple[str, ...], + ): + """Host migration only. Stable IDs make interrupted imports retryable. + + An existing ID is accepted only for exactly the same source and policy; + migration never overwrites an edited item or silently creates duplicates. + """ + return self.import_sources( + ( + SourceImport( + credential_id, kind, service, account_label, fields, allowed_origins + ), + ) + )[0] + + @sanitized_errors + def import_sources(self, values: tuple[SourceImport, ...]): + """One encrypted transaction and read-back for a bounded migration batch.""" + if type(values) is not tuple or len(values) > MAX_ITEMS: + raise VaultError(Code.INVALID) + now = datetime.now(timezone.utc).isoformat() + incoming, results = {}, [] + for value in values: + if ( + type(value) is not SourceImport + or value.credential_id in incoming + or not isinstance(value.fields, Secret) + or type(value.fields.reveal()) is not dict + ): + raise VaultError(Code.INVALID) + fields = Secret(dict(value.fields.reveal())) + validate_fields(value.kind, fields) + allowed = origins(value.allowed_origins) + if value.kind is CredentialKind.LOGIN and not allowed: + raise VaultError(Code.INVALID) + summary = CredentialSummary( + CredentialReference(self.profile.scope, value.credential_id, 1), + value.kind, + value.service, + value.account_label, + ) + incoming[value.credential_id] = _Record( + CredentialDetails(summary, allowed, now, now), fields + ) + with self._operation(write=True) as (_, records): + existing = {r.details.summary.reference.credential_id: r for r in records} + if len(set(existing) | set(incoming)) > MAX_ITEMS: + raise VaultError(Code.INVALID) + for key, record in incoming.items(): + old = existing.get(key) + if old is not None: + if ( + old.details.summary != record.details.summary + or old.details.allowed_origins != record.details.allowed_origins + or old.fields.reveal() != record.fields.reveal() + ): + raise VaultError(Code.CONFLICT) + results.append(old.details) + else: + records.append(record) + results.append(record.details) + # Verify every imported source from the committed encrypted document. + with self._operation() as (_, records): + verified = {r.details.summary.reference.credential_id: r for r in records} + for key, record in incoming.items(): + if ( + key not in verified + or verified[key].details.summary != record.details.summary + or verified[key].fields.reveal() != record.fields.reveal() + ): + raise VaultError(Code.UNREADABLE) + return tuple(results) + + @contextmanager + def _helper_record(self, reference: CredentialReference, epoch: UUID): + """Broker internals only, under authorization and isolation checks. + + Keep same-instance updates/lock serialized until dispatch completes. + There is deliberately no public read/reveal API or caller callback. + """ + with self._operation(helper=True) as (_, records): + self._require_epoch(epoch) + yield self._find(reference, records) + + def _require_epoch(self, epoch: UUID) -> None: + self._require_ready() + if self._epoch != epoch: + raise VaultError(Code.LOCKED) + + @sanitized_errors + def update( + self, + reference: CredentialReference, + *, + service: str | None = None, + account_label: str | None = None, + fields: Secret | None = None, + allowed_origins: tuple[str, ...] | None = None, + ) -> CredentialDetails: + with self._operation(write=True) as (_, records): + record = self._find(reference, records) + merged = Secret(dict(record.fields.reveal())) + if fields is not None: + if not isinstance(fields, Secret) or not isinstance( + fields.reveal(), dict + ): + raise VaultError(Code.INVALID) + # Empty fields preserve existing values; no implicit secret removal. + merged.reveal().update( + { + key: value + for key, value in fields.reveal().items() + if value != "" + } + ) + validate_fields(record.details.summary.kind, merged) + allowed = ( + record.details.allowed_origins + if allowed_origins is None + else origins(allowed_origins) + ) + if record.details.summary.kind is CredentialKind.LOGIN and not allowed: + raise VaultError(Code.INVALID) + try: + summary = replace( + record.details.summary, + reference=replace(reference, version=reference.version + 1), + service=record.details.summary.service + if service is None + else service, + account_label=( + record.details.summary.account_label + if account_label is None + else account_label + ), + ) + except ValueError: + raise VaultError(Code.INVALID) from None + record.details = replace( + record.details, + summary=summary, + allowed_origins=allowed, + updated_at=datetime.now(timezone.utc).isoformat(), + ) + record.fields = merged + return record.details + + @sanitized_errors + def delete(self, reference: CredentialReference) -> None: + with self._operation(write=True) as (_, records): + records.remove(self._find(reference, records)) diff --git a/app/credentials/windows_host.py b/app/credentials/windows_host.py new file mode 100644 index 000000000..85fd60463 --- /dev/null +++ b/app/credentials/windows_host.py @@ -0,0 +1,475 @@ +"""Windows kernel boundary. Imported only by the installed SYSTEM worker. + +No attestation in a client message is used here. Handles, tokens and job +membership are queried by the host and retained for the lifetime of a launch. +""" + +from __future__ import annotations + +import ctypes as C +from contextlib import closing +from ctypes import wintypes as W +import os +from pathlib import Path +import stat +import subprocess +from threading import RLock +from uuid import uuid4 + +from .contracts import IsolationUnavailable + +SYSTEM = "S-1-5-18" +ADMIN = "S-1-5-32-544" +WRITE = 0x500D0116 # generic write/all, delete, write DACL/owner, file mutations + + +def api(): + if os.name != "nt": + raise IsolationUnavailable() + import win32api + import win32con + import win32job + import win32process + import win32security + import win32ts + import win32event + + return ( + win32api, + win32con, + win32job, + win32process, + win32security, + win32ts, + win32event, + ) + + +def native_sid(pointer): + dll = C.WinDLL("advapi32", use_last_error=True) + dll.GetLengthSid.argtypes = [C.c_void_p] + dll.GetLengthSid.restype = W.DWORD + security = api()[4] + return security.ConvertSidToStringSid( + security.SID(C.string_at(pointer, dll.GetLengthSid(pointer))) + ) + + +def token_value(token, information, *, pointer=False): + dll = C.WinDLL("advapi32", use_last_error=True) + dll.GetTokenInformation.argtypes = [ + W.HANDLE, + C.c_int, + C.c_void_p, + W.DWORD, + C.POINTER(W.DWORD), + ] + size = W.DWORD() + dll.GetTokenInformation(int(token), information, None, 0, C.byref(size)) + if not 4 <= size.value <= 4096: + raise IsolationUnavailable() + buffer = C.create_string_buffer(size.value) + if not dll.GetTokenInformation( + int(token), information, buffer, len(buffer), C.byref(size) + ): + raise IsolationUnavailable() + value = (C.c_void_p if pointer else W.DWORD).from_buffer(buffer) + return native_sid(value) if pointer and value.value else value.value + + +def private_acl(path: Path, sid: str | None = None, *, writable=False): + security = api()[4] + extra = f"(A;OICI;{'0x1301bf' if writable else 'GRGX'};;;{sid})" if sid else "" + descriptor = security.ConvertStringSecurityDescriptorToSecurityDescriptor( + "O:SYG:SYD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)" + + extra + + ("S:(ML;OICI;NW;;;LW)" if writable else ""), + 1, + ) + security.SetFileSecurity( + str(path), + security.DACL_SECURITY_INFORMATION + | security.PROTECTED_DACL_SECURITY_INFORMATION + | security.OWNER_SECURITY_INFORMATION + | (0x10 if writable else 0), # LABEL_SECURITY_INFORMATION, scratch only. + descriptor, + ) + + +def check_install(root: Path, state: Path): + """SYSTEM only; reject writable code/dependencies, links and foreign owners.""" + security = api()[4] + with closing(security.OpenProcessToken(api()[0].GetCurrentProcess(), 8)) as token: + if ( + security.ConvertSidToStringSid(security.GetTokenInformation(token, 1)[0]) + != SYSTEM + ): + raise IsolationUnavailable() + for base in (root, state): + for path in (base, *base.parents): + info = path.lstat() + if ( + stat.S_ISLNK(info.st_mode) + or getattr(info, "st_file_attributes", 0) & 0x400 + ): + raise IsolationUnavailable() + # All files loaded by the worker/runtime must be protected, not only + # the executable. The installer sets these ACLs before registering SCM. + paths = [base, *base.rglob("*")] if base == root else [base] + for path in paths: + info = path.lstat() + if getattr(info, "st_file_attributes", 0) & 0x400: + raise IsolationUnavailable() + sd = security.GetFileSecurity(str(path), 1 | 4) + if security.ConvertSidToStringSid(sd.GetSecurityDescriptorOwner()) not in ( + SYSTEM, + ADMIN, + ): + raise IsolationUnavailable() + dacl = sd.GetSecurityDescriptorDacl() + if dacl is None: + raise IsolationUnavailable() + for index in range(dacl.GetAceCount()): + ace = dacl.GetAce(index) + if ace[0][0] != 0: # no ambiguous object/callback/deny ACLs + raise IsolationUnavailable() + if ( + security.ConvertSidToStringSid(ace[-1]) not in (SYSTEM, ADMIN) + and ace[1] & WRITE + ): + raise IsolationUnavailable() + + +class Peer: + def __init__(self, pid: int): + winapi, _, _, _, security, _, _ = api() + self.pid = pid + self.handle = winapi.OpenProcess(0x1000 | 0x100000, False, pid) + dll = C.WinDLL("kernel32", use_last_error=True) + dll.GetProcessTimes.argtypes = [W.HANDLE, *([C.POINTER(C.c_ulonglong)] * 4)] + created, exited, system, user = (C.c_ulonglong() for _ in range(4)) + if not dll.GetProcessTimes( + int(self.handle), + C.byref(created), + C.byref(exited), + C.byref(system), + C.byref(user), + ): + self.handle.Close() + raise IsolationUnavailable() + self.created = created.value + with closing(security.OpenProcessToken(self.handle, 8)) as token: + self.sid = security.ConvertSidToStringSid( + security.GetTokenInformation(token, 1)[0] + ) + self.session = security.GetTokenInformation(token, 12) + self.container = bool(token_value(token, 29)) + self.elevated = bool(token_value(token, 20)) + self.app_sid = ( + token_value(token, 31, pointer=True) if self.container else None + ) + + def alive(self): + return api()[6].WaitForSingleObject(self.handle, 0) == 258 + + def close(self): + self.handle.Close() + + +class STARTUPINFO(C.Structure): + _fields_ = [ + ("cb", W.DWORD), + ("reserved", W.LPWSTR), + ("desktop", W.LPWSTR), + ("title", W.LPWSTR), + ("x", W.DWORD), + ("y", W.DWORD), + ("xs", W.DWORD), + ("ys", W.DWORD), + ("xc", W.DWORD), + ("yc", W.DWORD), + ("fill", W.DWORD), + ("flags", W.DWORD), + ("show", W.WORD), + ("reserved2size", W.WORD), + ("reserved2", C.c_void_p), + ("stdin", W.HANDLE), + ("stdout", W.HANDLE), + ("stderr", W.HANDLE), + ] + + +class STARTUPINFOEX(C.Structure): + _fields_ = [("startup", STARTUPINFO), ("attributes", C.c_void_p)] + + +class PROCESSINFO(C.Structure): + _fields_ = [ + ("process", W.HANDLE), + ("thread", W.HANDLE), + ("pid", W.DWORD), + ("tid", W.DWORD), + ] + + +class CAPS(C.Structure): + _fields_ = [ + ("sid", C.c_void_p), + ("caps", C.c_void_p), + ("count", W.DWORD), + ("reserved", W.DWORD), + ] + + +class SA(C.Structure): + _fields_ = [("length", W.DWORD), ("descriptor", C.c_void_p), ("inherit", W.BOOL)] + + +class IsolatedProcess: + """Single-process Python consumer, no descendants, handles or inherited env.""" + + def __init__(self, root: Path, runtime: Path, owner_sid: str, session: int): + winapi, _, job, _, security, ts, _ = api() + self.root = root + self._closing = RLock() + self.profile_name = "CraftBot.Credential." + uuid4().hex + self.job = job.CreateJobObject(None, "") + limits = job.QueryInformationJobObject( + self.job, job.JobObjectExtendedLimitInformation + ) + limits["BasicLimitInformation"]["LimitFlags"] = 0x2000 | 0x8 | 0x100 + limits["BasicLimitInformation"]["ActiveProcessLimit"] = 1 + limits["ProcessMemoryLimit"] = 512 * 1024 * 1024 + job.SetInformationJobObject( + self.job, job.JobObjectExtendedLimitInformation, limits + ) + job.SetInformationJobObject( + self.job, job.JobObjectBasicUIRestrictions, {"UIRestrictionsClass": 0xFF} + ) + self.handle = None + self.pid = None + self.runtime = runtime + self._sid = C.c_void_p() + userenv = C.WinDLL("userenv", use_last_error=True) + userenv.CreateAppContainerProfile.argtypes = [ + W.LPCWSTR, + W.LPCWSTR, + W.LPCWSTR, + C.c_void_p, + W.DWORD, + C.POINTER(C.c_void_p), + ] + userenv.CreateAppContainerProfile.restype = C.c_long + with closing(ts.WTSQueryUserToken(session)) as token: + if ( + security.ConvertSidToStringSid( + security.GetTokenInformation(token, 1)[0] + ) + != owner_sid + ): + raise IsolationUnavailable() + if token_value(token, 20): + dll = C.WinDLL("advapi32", use_last_error=True) + dll.GetTokenInformation.argtypes = [ + W.HANDLE, + C.c_int, + C.c_void_p, + W.DWORD, + C.POINTER(W.DWORD), + ] + linked, size = W.HANDLE(), W.DWORD() + if not dll.GetTokenInformation( + int(token), 19, C.byref(linked), C.sizeof(linked), C.byref(size) + ): + raise IsolationUnavailable() + self.token = linked.value + else: + self.token = winapi.DuplicateHandle( + winapi.GetCurrentProcess(), + token, + winapi.GetCurrentProcess(), + 0, + False, + 2, + ).Detach() + if token_value(self.token, 20): + raise IsolationUnavailable() + security.ImpersonateLoggedOnUser(self.token) + try: + code = userenv.CreateAppContainerProfile( + self.profile_name, + "CraftBot isolated credential consumer", + "", + None, + 0, + C.byref(self._sid), + ) + finally: + security.RevertToSelf() + if code != 0: + raise IsolationUnavailable() + self.app_sid = native_sid(self._sid) + root.mkdir() + private_acl(root, self.app_sid) + (root / "scratch").mkdir() + private_acl(root / "scratch", self.app_sid, writable=True) + + def start(self, entry: str): + kernel = C.WinDLL("kernel32", use_last_error=True) + advapi = C.WinDLL("advapi32", use_last_error=True) + initialize = kernel.InitializeProcThreadAttributeList + initialize.argtypes = [C.c_void_p, W.DWORD, W.DWORD, C.POINTER(C.c_size_t)] + update = kernel.UpdateProcThreadAttribute + update.argtypes = [ + C.c_void_p, + W.DWORD, + C.c_size_t, + C.c_void_p, + C.c_size_t, + C.c_void_p, + C.c_void_p, + ] + kernel.DeleteProcThreadAttributeList.argtypes = [C.c_void_p] + size = C.c_size_t() + initialize(None, 2, 0, C.byref(size)) + storage = C.create_string_buffer(size.value) + if not initialize(storage, 2, 0, C.byref(size)): + raise IsolationUnavailable() + caps = CAPS(self._sid, None, 0, 0) # No network or broad file capabilities. + no_children = W.DWORD(1) + si = STARTUPINFOEX() + si.startup.cb = C.sizeof(si) + si.attributes = C.cast(storage, C.c_void_p) + pi = PROCESSINFO() + # Process/thread objects cannot be opened/injected by the desktop owner. + security = api()[4] + descriptor = security.ConvertStringSecurityDescriptorToSecurityDescriptor( + "O:SYD:P(A;;GA;;;SY)", 1 + ) + descriptor_bytes = bytes(descriptor) + descriptor_buffer = C.create_string_buffer(descriptor_bytes) + attributes = SA(C.sizeof(SA), C.cast(descriptor_buffer, C.c_void_p), False) + command = C.create_unicode_buffer( + subprocess.list2cmdline( + [str(self.runtime), "-I", "-S", str(self.root / entry)] + ) + ) + windows = api()[0].GetWindowsDirectory() + environment = C.create_unicode_buffer( + "\0".join( + [ + "PATH=" + str(self.runtime.parent), + "SYSTEMROOT=" + windows, + "TEMP=" + str(self.root / "scratch"), + "TMP=" + str(self.root / "scratch"), + "USERPROFILE=" + str(self.root / "scratch"), + ] + ) + + "\0\0" + ) + create = advapi.CreateProcessAsUserW + create.argtypes = [ + W.HANDLE, + W.LPCWSTR, + W.LPWSTR, + C.POINTER(SA), + C.POINTER(SA), + W.BOOL, + W.DWORD, + C.c_void_p, + W.LPCWSTR, + C.POINTER(STARTUPINFOEX), + C.POINTER(PROCESSINFO), + ] + try: + if not update( + storage, 0, 0x20009, C.byref(caps), C.sizeof(caps), None, None + ) or not update( + storage, + 0, + 0x2000E, + C.byref(no_children), + C.sizeof(no_children), + None, + None, + ): + raise IsolationUnavailable() + if not create( + self.token, + str(self.runtime), + command, + C.byref(attributes), + C.byref(attributes), + False, + 0x80000 | 0x400 | 4 | 0x08000000, + environment, + str(self.root / "scratch"), + C.byref(si), + C.byref(pi), + ): + raise IsolationUnavailable() + self.handle = pi.process + self.pid = pi.pid + try: + api()[2].AssignProcessToJobObject(self.job, self.handle) + api()[3].ResumeThread(pi.thread) + except BaseException: + api()[0].TerminateProcess(self.handle, 1) + raise + finally: + api()[0].CloseHandle(pi.thread) + finally: + kernel.DeleteProcThreadAttributeList(storage) + + def matches(self, peer: Peer): + return ( + self.handle is not None + and self.pid == peer.pid + and peer.alive() + and peer.container + and peer.app_sid == self.app_sid + and not peer.elevated + and api()[2].IsProcessInJob(peer.handle, self.job) + ) + + def close(self): + with self._closing: + self._close() + + def __del__(self): + try: + self.close() + except BaseException: + pass + + def _close(self): + if self.handle is not None: + try: + api()[0].TerminateProcess(self.handle, 1) + except Exception: + pass + api()[6].WaitForSingleObject(self.handle, 5000) + api()[0].CloseHandle(self.handle) + self.handle = None + if self.job is not None: + self.job.Close() # Kill-on-close, including on worker termination. + self.job = None + if getattr(self, "token", None): + try: + security = api()[4] + security.ImpersonateLoggedOnUser(self.token) + try: + delete = C.WinDLL("userenv").DeleteAppContainerProfile + delete.argtypes = [W.LPCWSTR] + delete(self.profile_name) + finally: + security.RevertToSelf() + except Exception: + pass + api()[0].CloseHandle(self.token) + self.token = None + if self._sid: + free = C.WinDLL("advapi32").FreeSid + free.argtypes = [C.c_void_p] + free(self._sid) + self._sid = None diff --git a/app/credentials/wire.py b/app/credentials/wire.py new file mode 100644 index 000000000..c5dc9a152 --- /dev/null +++ b/app/credentials/wire.py @@ -0,0 +1,112 @@ +"""Bounded data encoders, never caller-provided HTTP transport or headers.""" + +import base64 +import json +import re +import secrets +from urllib.parse import urlencode + +MAX_INPUT = 32_000_000 +MAX_OUTPUT = 96_000_000 + + +def encode(body, format): + if body is None: + return None, "application/json" + if type(body) is not dict and not (format == "json" and type(body) is list): + raise ValueError("credential_request_invalid") + if format == "json": + result = json.dumps(body, allow_nan=False).encode("utf-8") + mime = "application/json" + elif format == "form": + if any( + type(v) not in (str, int, float, bool, type(None)) for v in body.values() + ): + raise ValueError("credential_request_invalid") + result = urlencode({k: v for k, v in body.items() if v is not None}).encode() + mime = "application/x-www-form-urlencoded" + elif format == "image": + if set(body) != {"data", "content_type"} or body["content_type"] not in ( + "image/png", + "image/jpeg", + ): + raise ValueError("credential_request_invalid") + result = base64.b64decode(body["data"], validate=True) + mime = body["content_type"] + elif format == "multipart": + if ( + set(body) != {"fields", "files"} + or type(body["fields"]) is not dict + or type(body["files"]) is not list + or not 1 <= len(body["files"]) <= 8 + ): + raise ValueError("credential_request_invalid") + boundary = "CraftBot" + secrets.token_hex(24) + parts = [] + + def name(value): + if not isinstance(value, str) or not re.fullmatch( + r"[A-Za-z][A-Za-z0-9_\[\]-]{0,63}", value + ): + raise ValueError("credential_request_invalid") + return value + + for field, value in body["fields"].items(): + if type(value) not in (str, int, float, bool): + raise ValueError("credential_request_invalid") + parts.append( + ( + f'--{boundary}\r\nContent-Disposition: form-data; name="{name(field)}"\r\n\r\n' + + str(value) + + "\r\n" + ).encode() + ) + for item in body["files"]: + if ( + type(item) is not dict + or not {"field", "data", "content_type"} <= set(item) + or set(item) - {"field", "data", "content_type", "filename"} + or item["content_type"] + not in ( + "image/png", + "image/jpeg", + "image/webp", + "video/mp4", + "application/octet-stream", + "audio/wav", + "audio/x-wav", + "audio/mpeg", + "audio/mp4", + "audio/flac", + "audio/ogg", + "application/zip", + "text/plain", + "application/javascript", + "application/typescript", + ) + ): + raise ValueError("credential_request_invalid") + filename = item.get("filename", "upload.bin") + if not isinstance(filename, str) or not re.fullmatch( + r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", filename + ): + raise ValueError("credential_request_invalid") + content = base64.b64decode(item["data"], validate=True) + if len(content) > 24_000_000: + raise ValueError("credential_use_limit") + parts.extend( + [ + ( + f'--{boundary}\r\nContent-Disposition: form-data; name="{name(item["field"])}"; filename="{filename}"\r\nContent-Type: {item["content_type"]}\r\n\r\n' + ).encode(), + content, + b"\r\n", + ] + ) + parts.append(f"--{boundary}--\r\n".encode()) + result, mime = b"".join(parts), f"multipart/form-data; boundary={boundary}" + else: + raise ValueError("credential_request_invalid") + if len(result) > MAX_INPUT: + raise ValueError("credential_use_limit") + return result, mime diff --git a/app/data/action/credential_operations.py b/app/data/action/credential_operations.py new file mode 100644 index 000000000..026fcbbc7 --- /dev/null +++ b/app/data/action/credential_operations.py @@ -0,0 +1,95 @@ +"""Agent surfaces accept identifiers and operation data, never source values.""" + +from agent_core import action + + +@action( + name="use_saved_credential", + description="Request one installed credential operation using an owner-selected source reference. The protected host asks the owner to approve the caller, account, operation and destination. Never request or provide a password/API-key value. Use Settings > Credentials for secure entry. Unknown helpers fail closed.", + mode="ALL", + execution_mode="internal", + parallelizable=False, + irreversible=True, + input_schema={ + "reference": { + "type": "string", + "description": "Owner-provided craftbot-vault:// source reference.", + }, + "helper": {"type": "string", "description": "Installed helper ID."}, + "operation": {"type": "string", "description": "Installed operation name."}, + "body": { + "type": "object", + "description": "Operation JSON data; never a URL, headers or source credentials.", + }, + }, + output_schema={"status": {"type": "string"}, "result": {"type": "object"}}, +) +async def use_saved_credential(input_data: dict) -> dict: + from app.credentials.invocation import invoke_async + + try: + response = await invoke_async( + input_data["reference"], + input_data["helper"], + input_data["operation"], + input_data.get("body"), + subject="Agent credential operation", + ) + body = response["body"] + return { + "status": "success" if 200 <= response["status"] < 300 else "error", + "result": { + "http_status": response["status"], + "body": body[:12000], + "truncated": len(body) > 12000, + }, + } + except Exception: + return { + "status": "error", + "message": "Credential use needs a supported helper, current reference and owner approval. Open Settings > Credentials to add or unlock an item.", + } + + +@action( + name="request_credential_entry", + description="Ask the owner to save a credential in the protected native dialog for an installed helper. Requires an owner-selected initialized profile ID. Returns only a reference. Saving grants no permission to use the credential. Never ask for secret values in chat or action input.", + mode="ALL", + execution_mode="internal", + parallelizable=False, + irreversible=True, + input_schema={ + "profile_id": { + "type": "string", + "description": "Owner-selected vault profile UUID.", + }, + "helper": {"type": "string", "description": "Installed helper ID."}, + "operation": {"type": "string", "description": "Installed operation name."}, + "account": { + "type": "string", + "description": "Visible account label; no username, key or password value.", + }, + }, + output_schema={"status": {"type": "string"}, "reference": {"type": "string"}}, +) +async def request_credential_entry(input_data: dict) -> dict: + from app.credentials.consumer_runtime import execute_action + + code = """def secure_entry(data): + import credential_client + result = credential_client.request_entry(data['helper'], data['operation'], data['account']) + return {'status': 'success', 'reference': result['reference']} +""" + try: + return await execute_action( + code, + {key: input_data[key] for key in ("helper", "operation", "account")}, + profile_id=input_data["profile_id"], + subject="Agent secure credential entry", + timeout=600, + ) + except Exception: + return { + "status": "error", + "message": "Secure entry requires the installed service, an initialized owner-selected profile, supported helper and native owner approval.", + } diff --git a/app/data/action/integrations/_helpers.py b/app/data/action/integrations/_helpers.py index 7856e009e..b4c26d4cb 100644 --- a/app/data/action/integrations/_helpers.py +++ b/app/data/action/integrations/_helpers.py @@ -676,46 +676,70 @@ def system_connect_token(system, integration_id: str, credentials: Dict[str, str way the connect flow's ``login()`` does, then store the credential through the integration system (``store_credential``) — never through a single-account save. Returns (success, message). """ - # Providers may carry their own verifier (the bridge-provider pattern — - # keeps each platform's connect logic in its provider package); the - # central table covers the three providers that predate it. - provider_obj = system.registry.get(integration_id) - verifier = getattr(provider_obj, "verify_token", None) or _TOKEN_VERIFIERS.get( - integration_id - ) - if verifier is None: - # Mirrors the token-connect contract for field-less - # (OAuth-only) integrations. - return ( - False, - f"Token-based login not supported for " - f"{display_name_for(system, integration_id)}", - ) - try: - ok, message, credential = verifier(credentials) - except Exception as e: - return False, f"{integration_id} token verification failed: {e}" - if not ok or not credential: - return False, message - - provider = system.registry.get(integration_id) - identity = provider.identity_of(credential) - if not identity: - # Refuse rather than store under the UNIDENTIFIED sentinel: a second - # identity-less connect would land on the same sentinel key and - # silently REPLACE the first account's credential. The sentinel - # exists only for single-account files migrating in. - return False, ( - f"Could not determine which account this " - f"{display_name_for(system, integration_id)} token belongs to — " - f"connect was aborted so an existing account can't be " - f"overwritten. Re-check the token and try again." + if "source_reference" in credentials: + if integration_id in ("lark", "lark_calendar", "lark_drive", "telegram_user"): + allowed_fields = ( + {"source_reference", "app_id"} + if integration_id.startswith("lark") + else {"source_reference", "api_id", "phone_number", "code"} + ) + if set(credentials) - allowed_fields: + return ( + False, + "Only a saved reference and login identifiers/code are accepted. Save 2FA passwords in the vault.", + ) + try: + from app.credentials.references import parse + + parse(credentials["source_reference"]) + provider = system._require_provider(integration_id) + data = {k: v for k, v in credentials.items() if k != "source_reference"} + data[ + "app_secret" + if integration_id.startswith("lark") + else "api_hash_reference" + ] = credentials["source_reference"] + ok, message, credential = provider.verify_token(data) + if ok and credential is not None: + identity = provider.identity_of(credential) + system.store_credential(integration_id, identity, credential) + return ok, message + except Exception: + return ( + False, + "Saved login requires the protected host and owner approval.", + ) + allowed = ( + {"source_reference", "phone_number_id"} + if integration_id == "whatsapp_business" + else {"source_reference", "access_token", "access_token_secret"} + if integration_id == "twitter" + else {"source_reference"} ) - system.store_credential(integration_id, identity, credential) - # Slack has a listener; reconcile so a fresh token starts listening - # immediately (no-op when no manager is attached / no listener exists). - system.reconcile_listeners() - return True, message + if set(credentials) - allowed or not isinstance( + credentials["source_reference"], str + ): + return ( + False, + "A saved source connection accepts only a credential reference.", + ) + try: + metadata = {k: v for k, v in credentials.items() if k != "source_reference"} + system.connect_source( + integration_id, + credentials["source_reference"], + **({"public_metadata": metadata} if metadata else {}), + ) + return True, "Saved credential connected through the protected host." + except Exception: + return ( + False, + "Saved credential connection needs a supported helper and owner approval. Existing token connections remain available.", + ) + return ( + False, + "Save source credentials in Settings > Credentials and connect using their reference. Generated OAuth callbacks keep their current flow.", + ) # Strong references to scheduled teardown tasks: a bare create_task result diff --git a/app/data/action/understand_video.py b/app/data/action/understand_video.py index e9cd60c6a..14f995577 100644 --- a/app/data/action/understand_video.py +++ b/app/data/action/understand_video.py @@ -119,33 +119,37 @@ def understand_video(input_data: dict) -> dict: if api_key: try: - from google import genai - - client = genai.Client(api_key=api_key) import time from datetime import datetime from app.config import AGENT_WORKSPACE_ROOT - video_file = client.files.upload(file=video_path) - - while video_file.state.name == "PROCESSING": - time.sleep(2) - video_file = client.files.get(name=video_file.name) - vlm_model = get_vlm_model() or "gemini-1.5-pro" prompt = ( query if query else "Understand and describe the contents of this video." ) - response = client.models.generate_content( - model=vlm_model, - contents=[video_file, prompt], - ) - - client.files.delete(name=video_file.name) - - full_text = response.text + from app.credentials.references import is_reference + + if is_reference(api_key): + from app.credentials.media_clients import analyse_video + + full_text = analyse_video(api_key, video_path, vlm_model, prompt) + else: + from google import genai + + client = genai.Client(api_key=api_key) + video_file = client.files.upload(file=video_path) + try: + while video_file.state.name == "PROCESSING": + time.sleep(2) + video_file = client.files.get(name=video_file.name) + response = client.models.generate_content( + model=vlm_model, contents=[video_file, prompt] + ) + full_text = response.text + finally: + client.files.delete(name=video_file.name) ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S") out_path = os.path.join(AGENT_WORKSPACE_ROOT, f"video_summary_{ts}.txt") with open(out_path, "w", encoding="utf-8") as f: diff --git a/app/data/action/web_search.py b/app/data/action/web_search.py index 4a230212d..9f42bdc54 100644 --- a/app/data/action/web_search.py +++ b/app/data/action/web_search.py @@ -160,8 +160,25 @@ def google_cse_search(q, n=5): if not api_key or not cse_id: raise Exception("No Google API credentials") - service = build("customsearch", "v1", developerKey=api_key) - res = service.cse().list(q=q, cx=cse_id, num=min(n + 5, 10)).execute() + from app.credentials.references import is_reference + + if is_reference(api_key): + import json + from app.credentials.invocation import invoke + + response = invoke( + api_key, + "model_google", + "custom_search", + {"q": q, "cx": cse_id, "num": min(n + 5, 10)}, + subject="Google Custom Search", + ) + if response["status"] != 200: + raise RuntimeError("credential_operation_failed") + res = json.loads(response["body"]) + else: + service = build("customsearch", "v1", developerKey=api_key) + res = service.cse().list(q=q, cx=cse_id, num=min(n + 5, 10)).execute() items = res.get("items", []) return [ diff --git a/app/integrations.py b/app/integrations.py index 55d1dc062..cd9a25beb 100644 --- a/app/integrations.py +++ b/app/integrations.py @@ -166,12 +166,24 @@ def set_event_callback(on_message: "MessageCallback") -> None: def get_system() -> IntegrationSystem: global _system + from app.credentials.oauth_transport import request as source_oauth_request + from app.credentials.model_transport import openai_client + from app.credentials.provider_auth import lark_request, telegram_auth + from craftos_integrations.config import ConfigStore + + ConfigStore.extras["source_oauth_request"] = source_oauth_request + ConfigStore.extras["openai_client_factory"] = openai_client + ConfigStore.extras["source_lark_request"] = lark_request + ConfigStore.extras["source_telegram_auth"] = telegram_auth if _system is None: from craftos_integrations.providers import default_providers + from app.credentials.integrations import SavedSourceOperations + providers = default_providers() _system = IntegrationSystem( store=FileCredentialStore(), - providers=default_providers(), + providers=providers, + source_credentials=SavedSourceOperations(providers), ) return _system diff --git a/app/mini_browser/actions_api.py b/app/mini_browser/actions_api.py index f35629d0c..45fed0fa9 100644 --- a/app/mini_browser/actions_api.py +++ b/app/mini_browser/actions_api.py @@ -417,6 +417,9 @@ def _v_login(data: Dict[str, Any], _context: Optional[UrlContext]) -> Dict[str, return { "username": _text(data, "username", max_chars=MAX_USERNAME_CHARS), "submit": _flag(data, "submit", True), + "credential_reference": _text(data, "credential_reference", max_chars=256), + "helper": _text(data, "helper", max_chars=256), + "operation": _text(data, "operation", max_chars=256) or "login", } diff --git a/app/mini_browser/core.py b/app/mini_browser/core.py index 0b263a942..910321742 100644 --- a/app/mini_browser/core.py +++ b/app/mini_browser/core.py @@ -2714,10 +2714,10 @@ def _agent_notice( return event def vault(self) -> Any: - """The credential vault (``vault.get_vault()``).""" - from app.mini_browser.vault import get_vault + """The ordinary browser process has no source-vault access.""" + from app.mini_browser.errors import MiniBrowserError - return get_vault() + raise MiniBrowserError("MINI_BROWSER_VAULT_UNREADABLE") def ui_origins(self) -> frozenset: """CraftBot's own UI origins (``host:port``), never opened in the browser.""" diff --git a/app/mini_browser/ops.py b/app/mini_browser/ops.py index dd4fb3035..ceeda8bc5 100644 --- a/app/mini_browser/ops.py +++ b/app/mini_browser/ops.py @@ -2809,12 +2809,27 @@ async def screenshot(core: Any, tab: Any, *, full_page: bool = False) -> Dict[st async def login( - core: Any, tab: Any, *, username: Optional[str] = None, submit: bool = True + core: Any, + tab: Any, + *, + username: Optional[str] = None, + submit: bool = True, + credential_reference: Optional[str] = None, + helper: Optional[str] = None, + operation: str = "login", ) -> Dict[str, Any]: """Sign in with a saved login from the password vault.""" from app.mini_browser.login import autofill - return await autofill(core, tab, username=username, submit=submit) + return await autofill( + core, + tab, + username=username, + submit=submit, + credential_reference=credential_reference, + helper=helper, + operation=operation, + ) _CLOSED_CODES = frozenset( diff --git a/app/mini_browser/vault.py b/app/mini_browser/vault.py index d5983d51f..bf2264eea 100644 --- a/app/mini_browser/vault.py +++ b/app/mini_browser/vault.py @@ -85,6 +85,7 @@ from urllib.parse import urlsplit from app.logger import logger +from app.credentials.secrets import Secret from app.mini_browser.errors import MiniBrowserError __all__ = [ @@ -216,39 +217,6 @@ def detail(self) -> str: return str(self.fields.get("detail", "")) -class Secret: - """A value (a password, a key) that never shows in a repr, str() or log. - - Wrap a password the moment it is read, e.g. from a WebSocket message, and - pass the wrapper around. :meth:`reveal` returns the real value. The vault - accepts either a ``Secret`` or a plain ``str`` wherever it takes a password. - """ - - __slots__ = ("_value",) - - def __init__(self, value: Any) -> None: - self._value = value - - @classmethod - def wrap(cls, value: Any) -> "Secret": - return value if isinstance(value, Secret) else cls(value) - - def reveal(self) -> Any: - return self._value - - def __bool__(self) -> bool: - return bool(self._value) - - def __repr__(self) -> str: - return "Secret('[redacted]')" - - def __str__(self) -> str: - return "[redacted]" - - def __reduce__(self): - raise TypeError("Secret values cannot be pickled") - - class _Entry(dict): """A vault entry. Its repr never shows the password (see "Secret hygiene").""" diff --git a/app/mini_browser/ws.py b/app/mini_browser/ws.py index 9d0767732..44a85286a 100644 --- a/app/mini_browser/ws.py +++ b/app/mini_browser/ws.py @@ -1127,6 +1127,18 @@ async def _publish_watched_states(self) -> None: async def handle(self, ws: Any, msg_type: str, data: Dict[str, Any]) -> None: """Handle one ``mini_browser_*`` message. Never raises (except on cancellation); every failure is answered to ``ws`` only.""" + if msg_type.startswith("mini_browser_vault_"): + # Retired transport. Never accept raw entry over the old browser API. + self._send( + ws, + "mini_browser_vault_result", + { + "op": msg_type[len("mini_browser_vault_") :], + "ok": False, + "error": "Use Settings > Credentials and the protected entry dialog.", + }, + ) + return name = _HANDLERS.get(msg_type) if name is None: logger.debug( diff --git a/app/onboarding/interfaces/steps.py b/app/onboarding/interfaces/steps.py index 16c25388b..39aeaec5d 100644 --- a/app/onboarding/interfaces/steps.py +++ b/app/onboarding/interfaces/steps.py @@ -275,9 +275,7 @@ def validate(self, value: Any) -> tuple[bool, Optional[str]]: # Proxied providers submit {api_key, via, or_model?} dict if self.provider in self.OPENROUTER_PROXIED and isinstance(value, dict): api_key = value.get("api_key", "") - if not api_key or len(str(api_key).strip()) < 10: - return False, tui("onboarding_validate_api_key_required") - return True, None + return self._validate_reference(api_key) # A connected subscription (ChatGPT Plus/Pro, SuperGrok) authorizes the # provider via an OAuth bearer, so the API key is optional. Accept an @@ -289,10 +287,20 @@ def validate(self, value: Any) -> tuple[bool, Optional[str]]: if not value or not isinstance(value, str): return False, tui("onboarding_validate_api_key_required") - if len(value.strip()) < 10: - return False, tui("onboarding_validate_api_key_too_short") + return self._validate_reference(value) - return True, None + @staticmethod + def _validate_reference(value): + from app.credentials.references import parse + + try: + parse(value) + return True, None + except (ValueError, TypeError): + return ( + False, + "Choose a saved reference from Credentials. Enter source values only in the protected dialog.", + ) def get_default(self) -> str: if self.provider == "remote": @@ -300,7 +308,10 @@ def get_default(self) -> str: # Check settings.json for existing key from app.config import get_api_key - return get_api_key(self.provider) + from app.credentials.references import is_reference + + value = get_api_key(self.provider) + return value if is_reference(value) else "" def get_env_var_name(self) -> Optional[str]: """Get the environment variable name for the current provider.""" diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index ab357d7f1..a476c4362 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -1689,6 +1689,11 @@ async def _handle_ws_message(self, data: Dict[str, Any], ws=None) -> None: # Chat browser panels: live view, browser controls and the password # vault (app/mini_browser/ws.py answers every failure itself). + elif msg_type == "credentials_request": + from app.credentials.settings import handle as handle_credentials + + await handle_credentials(self, ws, data) + elif isinstance(msg_type, str) and msg_type.startswith("mini_browser_"): mini_browser = getattr(self, "_mini_browser_ws", None) if mini_browser is not None: diff --git a/app/ui_layer/adapters/ws_lanes.py b/app/ui_layer/adapters/ws_lanes.py index 23fb6c1e1..c88bd13bf 100644 --- a/app/ui_layer/adapters/ws_lanes.py +++ b/app/ui_layer/adapters/ws_lanes.py @@ -63,6 +63,7 @@ # Message-type prefix → lane; first match wins. _PREFIX_LANES = ( + ("credentials_", "credentials"), ("agent_app_", "agent_app"), ("file_", "workspace"), ("memory_", "memory"), diff --git a/app/ui_layer/browser/frontend/src/components/MiniBrowser/PasswordsModal.tsx b/app/ui_layer/browser/frontend/src/components/MiniBrowser/PasswordsModal.tsx index decca473b..461464f98 100644 --- a/app/ui_layer/browser/frontend/src/components/MiniBrowser/PasswordsModal.tsx +++ b/app/ui_layer/browser/frontend/src/components/MiniBrowser/PasswordsModal.tsx @@ -1,615 +1,9 @@ -import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { - AlertCircle, - AlertTriangle, - Eye, - EyeOff, - KeyRound, - Loader2, - Pencil, - Search, - ShieldCheck, - Trash2, -} from 'lucide-react' -import { Button, ConfirmModal, IconButton, Modal, ModalBody, ModalFooter } from '../../components/ui' -import { useToast } from '../../contexts/ToastContext' -import { formatDate } from '../../i18n/format' -import { useAppSelector } from '../../store/hooks' -import { RESOURCES, resourceSync, useResource } from '../../store/resources' -import { - selectMiniBrowserVaultEntries, - selectMiniBrowserVaultError, - selectMiniBrowserVaultLoaded, - selectMiniBrowserVaultResult, - selectMiniBrowserVaultStatus, -} from '../../store/selectors/miniBrowser' -import type { MiniBrowserError, MiniBrowserVaultEntry, MiniBrowserVaultOp } from '../../types' -import { useErrorText } from './useErrorText' -import { isSocketConnected, sendLive } from './useMiniBrowserSocket' -import { LABEL_MAX, PASSWORD_MAX, USERNAME_MAX, siteHost, validateSite } from './vaultForm' -import styles from './PasswordsModal.module.css' - -// A write normally answers within a second; past this the dialog stops -// waiting and says so (the request may still land — the list refreshes). -const RESULT_TIMEOUT_MS = 15_000 -// Show a filter box once the list gets long. -const FILTER_THRESHOLD = 8 - -// Keep password managers (1Password, LastPass, Bitwarden, Chrome's own) away -// from this form: these are third-party credentials, not CraftBot's. -const NO_AUTOFILL = { - autoComplete: 'off', - 'data-1p-ignore': '', - 'data-lpignore': 'true', - 'data-bwignore': '', - 'data-form-type': 'other', -} as const - -type FormMode = { kind: 'add' } | { kind: 'edit'; entry: MiniBrowserVaultEntry } - -interface Pending { - op: MiniBrowserVaultOp - /** The entry being deleted. */ - entryId?: string -} - -interface PasswordsModalProps { - isOpen: boolean - onClose(): void -} - -/** Accepts epoch seconds/ms or an ISO string; null when unusable. */ -function toDate(value: string | number | null): Date | null { - if (value === null) return null - const date = typeof value === 'number' ? new Date(value < 1e12 ? value * 1000 : value) : new Date(value) - return Number.isNaN(date.getTime()) ? null : date -} - -/** - * Saved logins the agents can use to sign in. The UI never receives a - * password: the list carries site, username and label only, and a typed - * password lives in this component just until the backend confirms the save. - * Unmounting (closing) drops every field. - */ -export function PasswordsModal({ isOpen, onClose }: PasswordsModalProps) { - const { t } = useTranslation(['minibrowser', 'common']) - const { showToast } = useToast() - const errorText = useErrorText() - const idPrefix = useId() - - useResource(isOpen ? RESOURCES.miniBrowserVault : null) - const loaded = useAppSelector(selectMiniBrowserVaultLoaded) - const entries = useAppSelector(selectMiniBrowserVaultEntries) - const status = useAppSelector(selectMiniBrowserVaultStatus) - const listError = useAppSelector(selectMiniBrowserVaultError) - const result = useAppSelector(selectMiniBrowserVaultResult) - - // Every opening shows the vault as it is now (it may have recovered, or - // an agent's sign-in changed "last used"). Coalesces with useResource's - // own first request. - useEffect(() => { - if (isOpen) resourceSync.refresh(RESOURCES.miniBrowserVault) - }, [isOpen]) - - // "Try again" on a vault that couldn't be loaded: busy until the reply. - const [retrying, setRetrying] = useState(false) - useEffect(() => { - setRetrying(false) - }, [status, listError]) - useEffect(() => { - if (!retrying) return - const timer = window.setTimeout(() => setRetrying(false), RESULT_TIMEOUT_MS) - return () => window.clearTimeout(timer) - }, [retrying]) - const retryList = () => { - setRetrying(true) - resourceSync.refresh(RESOURCES.miniBrowserVault) - } - - const [mode, setMode] = useState({ kind: 'add' }) - const [site, setSite] = useState('') - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [label, setLabel] = useState('') - const [showPassword, setShowPassword] = useState(false) - const [submitted, setSubmitted] = useState(false) - const [formError, setFormError] = useState(null) - const [pending, setPending] = useState(null) - const [confirmDelete, setConfirmDelete] = useState(null) - const [confirmReset, setConfirmReset] = useState(false) - const [filter, setFilter] = useState('') - - const siteRef = useRef(null) - // Results that predate this dialog are not ours. - const seenResultSeqRef = useRef(result?.seq ?? 0) - - const resetForm = useCallback(() => { - setMode({ kind: 'add' }) - setSite('') - setUsername('') - setPassword('') - setLabel('') - setShowPassword(false) - setSubmitted(false) - setFormError(null) - }, []) - - // Closing clears every field, the password above all. - useEffect(() => { - if (isOpen) return - resetForm() - setPending(null) - setConfirmDelete(null) - setConfirmReset(false) - setFilter('') - }, [isOpen, resetForm]) - - // Start in the first field; give focus back to whatever opened the dialog. - useEffect(() => { - if (!isOpen) return - const opener = document.activeElement as HTMLElement | null - siteRef.current?.focus() - return () => opener?.focus?.({ preventScroll: true }) - }, [isOpen]) - - // ── Results of our writes ───────────────────────────────────────────── - useEffect(() => { - if (!result || result.seq === seenResultSeqRef.current) return - seenResultSeqRef.current = result.seq - if (!pending) return - const { op, entryId } = pending - setPending(null) - if (!result.ok) { - const text = result.error ? errorText(result.error) : null - setFormError(text ? [text.message || text.title, text.detail].filter(Boolean).join(' — ') : t('minibrowser:passwords.error')) - return - } - // The list is fetched again so it reflects the change right away. - resourceSync.refresh(RESOURCES.miniBrowserVault) - if (op === 'add' || op === 'update') { - resetForm() - showToast('success', op === 'add' ? t('minibrowser:passwords.saved') : t('minibrowser:passwords.updated')) - } else if (op === 'delete') { - // Deleted the login being edited: the form has nothing to edit any more. - if (mode.kind === 'edit' && mode.entry.id === entryId) resetForm() - showToast('success', t('minibrowser:passwords.deleted')) - } else { - showToast('success', t('minibrowser:passwords.resetDone')) - } - }, [result, pending, mode, errorText, resetForm, showToast, t]) - - useEffect(() => { - if (!pending) return - const timer = window.setTimeout(() => { - setPending(null) - setFormError(t('minibrowser:passwords.timeout')) - resourceSync.refresh(RESOURCES.miniBrowserVault) - }, RESULT_TIMEOUT_MS) - return () => window.clearTimeout(timer) - }, [pending, t]) - - /** Sends only while connected: a queued write would keep the password in - * the outbox and could replay much later. */ - const sendVault = useCallback((type: string, data: Record, pendingOp: Pending): void => { - if (!isSocketConnected() || !sendLive(type, data)) { - setFormError(t('minibrowser:passwords.notConnected')) - return - } - setFormError(null) - setPending(pendingOp) - }, [t]) - - // ── Validation ──────────────────────────────────────────────────────── - const editing = mode.kind === 'edit' - const siteProblem = validateSite(site) - const usernameProblem = !username.trim() - ? t('minibrowser:passwords.required') - : username.length > USERNAME_MAX - ? t('minibrowser:passwords.tooLong', { max: USERNAME_MAX }) - : null - const passwordProblem = !editing && !password - ? t('minibrowser:passwords.required') - : password.length > PASSWORD_MAX - ? t('minibrowser:passwords.tooLong', { max: PASSWORD_MAX }) - : null - const labelProblem = label.length > LABEL_MAX ? t('minibrowser:passwords.tooLong', { max: LABEL_MAX }) : null - const siteMessage = siteProblem === 'required' - ? t('minibrowser:passwords.required') - : siteProblem === 'invalid' ? t('minibrowser:passwords.invalidSite') : null - const invalid = !!(siteMessage || usernameProblem || passwordProblem || labelProblem) - - // Saving a login for an existing site + username replaces its password. - const replaces = useMemo(() => { - if (editing || !site.trim() || !username.trim()) return null - const host = siteHost(site) - const user = username.trim().toLowerCase() - return entries.find(entry => siteHost(entry.site) === host && entry.username.toLowerCase() === user) ?? null - }, [editing, entries, site, username]) - - // Three ways the vault can be other than "readable": unreadable (needs a - // reset), unavailable for now (a busy file, a missing dependency: retry), - // or the list request itself failed. None of them is an empty vault, and - // none can take a save. - const unreadable = !!status?.unreadable - const unavailable = loaded && !unreadable && (!!listError || (status !== null && !status.ok)) - const locked = unreadable || unavailable - const busy = pending !== null - const listErrorText = (error: MiniBrowserError): string => { - const text = errorText(error) - return [text.message || text.title, text.detail].filter(Boolean).join(' — ') - } - - const submit = (e: React.FormEvent) => { - e.preventDefault() - setSubmitted(true) - if (invalid || busy || locked) return - if (mode.kind === 'edit') { - const data: Record = { - id: mode.entry.id, - site: site.trim(), - username: username.trim(), - label: label.trim(), - } - if (password) data.password = password - sendVault('mini_browser_vault_update', data, { op: 'update' }) - } else { - sendVault( - 'mini_browser_vault_add', - { site: site.trim(), username: username.trim(), password, label: label.trim() }, - { op: 'add' }, - ) - } - } - - const startEdit = (entry: MiniBrowserVaultEntry) => { - setMode({ kind: 'edit', entry }) - setSite(entry.site) - setUsername(entry.username) - setLabel(entry.label) - setPassword('') - setShowPassword(false) - setSubmitted(false) - setFormError(null) - siteRef.current?.focus() - } - - const doDelete = () => { - const entry = confirmDelete - setConfirmDelete(null) - if (!entry) return - sendVault('mini_browser_vault_delete', { id: entry.id }, { op: 'delete', entryId: entry.id }) - } - - const doReset = () => { - setConfirmReset(false) - sendVault('mini_browser_vault_reset', {}, { op: 'reset' }) - } - - const dirty = !!(site || username || password || label) - const needle = filter.trim().toLowerCase() - const visible = needle - ? entries.filter(entry => - [entry.site, entry.username, entry.label].some(value => value.toLowerCase().includes(needle))) - : entries - - const fieldId = (name: string) => `${idPrefix}-${name}` - const showErrors = submitted - const lastUsed = (entry: MiniBrowserVaultEntry): string => { - const date = toDate(entry.lastUsedAt) - return date - ? t('minibrowser:passwords.lastUsed', { date: formatDate(date, { year: 'numeric', month: 'short', day: 'numeric' }) }) - : t('minibrowser:passwords.neverUsed') - } - - return ( - <> - {t('minibrowser:passwords.title')}} - closeOnEsc={!confirmDelete && !confirmReset} - closeOnOverlayClick={!dirty && !busy} - contentClassName={styles.dialog} - > - -

{t('minibrowser:passwords.intro')}

- {/* How the vault is protected is only known for a vault that loaded. */} - {status?.ok && !locked && ( -

- - {status.protection === 'dpapi' - ? t('minibrowser:passwords.protectionDpapi') - : t('minibrowser:passwords.protectionFile')} -

- )} - - {unreadable && ( -
- -
- {t('minibrowser:passwords.unreadableTitle')} -

{t('minibrowser:passwords.unreadableBody')}

- -
-
- )} - - {unavailable && ( -
- -
- {t('minibrowser:passwords.unavailableTitle')} -

{t('minibrowser:passwords.unavailableBody')}

- {listError &&

{listErrorText(listError)}

} - -
-
- )} - -
-
-

- {t('minibrowser:passwords.listLabel')} - {loaded && entries.length > 0 && ( - {t('minibrowser:passwords.count', { count: entries.length })} - )} -

- {entries.length > FILTER_THRESHOLD && ( - - )} -
- - {!loaded ? ( -
- - {t('minibrowser:passwords.loading')} -
- ) : entries.length === 0 ? ( -

- {locked ? t('minibrowser:passwords.emptyUnreadable') : t('minibrowser:passwords.empty')} -

- ) : ( -
    - {visible.map(entry => { - const beingEdited = mode.kind === 'edit' && mode.entry.id === entry.id - return ( -
  • -
    - {entry.site} - {entry.username} - - {entry.label && {entry.label}} - {lastUsed(entry)} - -
    - -
    - } - onClick={() => startEdit(entry)} - disabled={busy || locked} - aria-label={t('minibrowser:passwords.editNamed', { site: entry.site, username: entry.username })} - tooltip={t('minibrowser:passwords.edit')} - /> - } - className={styles.deleteButton} - onClick={() => setConfirmDelete(entry)} - disabled={busy || locked} - aria-label={t('minibrowser:passwords.deleteNamed', { site: entry.site, username: entry.username })} - tooltip={t('minibrowser:passwords.delete')} - /> -
    -
  • - ) - })} - {visible.length === 0 &&
  • {t('minibrowser:passwords.noMatches')}
  • } -
- )} -
- -
-

- {editing ? t('minibrowser:passwords.editTitle') : t('minibrowser:passwords.addTitle')} -

- -
- - setSite(e.target.value)} - placeholder={t('minibrowser:passwords.sitePlaceholder')} - inputMode="url" - autoCapitalize="off" - autoCorrect="off" - spellCheck={false} - disabled={busy || locked} - aria-invalid={showErrors && !!siteMessage} - aria-describedby={fieldId(showErrors && siteMessage ? 'site-error' : 'site-hint')} - {...NO_AUTOFILL} - /> - {showErrors && siteMessage ? ( - {siteMessage} - ) : ( - {t('minibrowser:passwords.siteHint')} - )} -
- -
- - setUsername(e.target.value)} - autoCapitalize="off" - autoCorrect="off" - spellCheck={false} - maxLength={USERNAME_MAX + 1} - disabled={busy || locked} - aria-invalid={showErrors && !!usernameProblem} - aria-describedby={showErrors && usernameProblem ? fieldId('username-error') : undefined} - {...NO_AUTOFILL} - /> - {showErrors && usernameProblem && ( - {usernameProblem} - )} -
- -
- -
- setPassword(e.target.value)} - placeholder={editing ? t('minibrowser:passwords.passwordKeep') : undefined} - autoCapitalize="off" - autoCorrect="off" - spellCheck={false} - maxLength={PASSWORD_MAX + 1} - disabled={busy || locked} - aria-invalid={showErrors && !!passwordProblem} - aria-describedby={showErrors && passwordProblem ? fieldId('password-error') : undefined} - {...NO_AUTOFILL} - /> - : } - onClick={() => setShowPassword(shown => !shown)} - disabled={busy || locked} - aria-pressed={showPassword} - aria-controls={fieldId('password')} - aria-label={showPassword ? t('minibrowser:passwords.hidePassword') : t('minibrowser:passwords.showPassword')} - tooltip={showPassword ? t('minibrowser:passwords.hidePassword') : t('minibrowser:passwords.showPassword')} - /> -
- {showErrors && passwordProblem && ( - {passwordProblem} - )} -
- -
- - setLabel(e.target.value)} - placeholder={t('minibrowser:passwords.labelPlaceholder')} - maxLength={LABEL_MAX + 1} - disabled={busy || locked} - aria-invalid={showErrors && !!labelProblem} - aria-describedby={showErrors && labelProblem ? fieldId('label-error') : undefined} - {...NO_AUTOFILL} - /> - {showErrors && labelProblem && ( - {labelProblem} - )} -
- - {replaces && ( -

- {t('minibrowser:passwords.willReplace', { site: replaces.site, username: replaces.username })} -

- )} - - {formError && ( -
- - {formError} -
- )} - -
- {editing && ( - - )} - -
-
-
- - - -
- - setConfirmDelete(null)} - /> - setConfirmReset(false)} - /> - - ) +import { Modal, ModalBody } from '../../components/ui' +import { CredentialsSettings } from '../Settings/CredentialsSettings' + +/** Compatibility entry point; credential management belongs to shared Settings. */ +export function PasswordsModal({ isOpen, onClose }: { isOpen: boolean; onClose(): void }) { + return + + } diff --git a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx index 75af727a8..9fe46cb80 100644 --- a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx @@ -12,7 +12,8 @@ import { WifiOff, RefreshCw, } from 'lucide-react' -import { Button } from '../../components/ui' +import { Button, Modal, ModalBody } from '../../components/ui' +import { CredentialsSettings } from '../Settings/CredentialsSettings' import { useTranslation, Trans } from 'react-i18next' import { useWebSocket } from '../../contexts/WebSocketContext' import { useAppDispatch, useAppSelector } from '../../store/hooks' @@ -504,6 +505,7 @@ export function OnboardingPage() { // Local form state const [selectedValue, setSelectedValue] = useState('') const [textValue, setTextValue] = useState('') + const [credentialsOpen, setCredentialsOpen] = useState(false) const [orModel, setOrModel] = useState('') // For proxied providers: 'direct' tries the native API, 'openrouter' routes via OR. const [proxiedVia, setProxiedVia] = useState<'direct' | 'openrouter'>('direct') @@ -776,15 +778,16 @@ export function OnboardingPage() { return (
setTextValue(e.target.value)} - placeholder={isViaOR ? t('onboarding:proxied.orKey') : t('onboarding:proxied.directKey', { provider: providerDisplay })} + placeholder="Saved credential reference from Credentials" autoFocus onKeyDown={e => { if (e.key === 'Enter' && canSubmit) handleSubmit() }} /> -
{t('onboarding:apiKey.storedLocally')}
+
Enter source values in the protected dialog, then select their reference here.
+ {isViaOR && (
)}
+ {credentialsOpen && setCredentialsOpen(false)} title="Credentials" size="lg">}
) } diff --git a/app/ui_layer/browser/frontend/src/pages/Settings/CredentialsSettings.module.css b/app/ui_layer/browser/frontend/src/pages/Settings/CredentialsSettings.module.css new file mode 100644 index 000000000..79d680f4e --- /dev/null +++ b/app/ui_layer/browser/frontend/src/pages/Settings/CredentialsSettings.module.css @@ -0,0 +1,12 @@ +.panel { max-width: 900px; color: var(--text-primary); padding: 24px; } +.panel p { line-height: 1.5; } +.row { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin: 16px 0; } +.form { display: grid; gap: 16px; margin-top: 24px; } +.form label { display: grid; gap: 6px; } +.panel input, .panel select, .panel textarea, .panel button { font: inherit; color: inherit; background: var(--bg-secondary, transparent); border: 1px solid var(--border-color, #777); border-radius: 6px; padding: 8px 12px; } +.panel button { cursor: pointer; } +.panel button:disabled { cursor: default; opacity: .5; } +.panel textarea { min-height: 72px; } +.list { padding: 0; list-style: none; } +.list li { display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--border-color, #777); padding: 12px 0; } +.list li > span { flex: 1; overflow-wrap: anywhere; } diff --git a/app/ui_layer/browser/frontend/src/pages/Settings/CredentialsSettings.tsx b/app/ui_layer/browser/frontend/src/pages/Settings/CredentialsSettings.tsx new file mode 100644 index 000000000..7ddbbba75 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/pages/Settings/CredentialsSettings.tsx @@ -0,0 +1,162 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useSettingsWebSocket } from './useSettingsWebSocket' +import styles from './CredentialsSettings.module.css' + +type Profile = { id: string; label: string; state: string } +type Item = { credential_id: string; version: number; kind: string; service: string; account: string; origins: string[] } +type Activity = { action: string; outcome: string; grant_id: string | null } +type ItemPage = { items: Item[]; view_id: string; next_offset: number | null; total: number } +type Reply = { request_id: string | null; operation: string; ok: boolean; value?: unknown; error?: string } +const errors: Record = { + credential_host_unavailable: 'The credential service is unavailable. Finish installation or restart the host.', + vault_locked: 'Unlock this profile to continue.', + owner_verification_required: 'Owner verification was cancelled or unavailable. Use your Windows password at the owner prompt.', + credential_changed: 'This item changed. Refresh its reference.', + credential_use_denied: 'This request was not approved.', + credential_busy: 'A credential request is dispatching. Try again shortly.', +} + +// Metadata stays in this mounted view. Source fields are entered only in the +// protected native host dialog, never in React, Redux or WebSocket payloads. +export function CredentialsSettings() { + const { send, onMessage, isConnected } = useSettingsWebSocket() + const [profiles, setProfiles] = useState([]) + const [profile, setProfile] = useState('') + const [actionProfile, setActionProfile] = useState(null) + const [items, setItems] = useState([]) + const [activity, setActivity] = useState([]) + const [busy, setBusy] = useState(false) + const [notice, setNotice] = useState('') + const [search, setSearch] = useState('') + const [label, setLabel] = useState('') + const [service, setService] = useState('') + const [account, setAccount] = useState('') + const [origins, setOrigins] = useState('') + const [kind, setKind] = useState('login') + const [fields, setFields] = useState('') + const [editing, setEditing] = useState(null) + const [replace, setReplace] = useState(false) + const timer = useRef>() + const pending = useRef(null) + const selected = profiles.find(p => p.id === profile) + const sendRequest = useCallback((operation: string, parameters: Record) => { + const requestId = crypto.randomUUID() + pending.current = requestId + setBusy(true) + clearTimeout(timer.current) + send('credentials_request', { operation, parameters, request_id: requestId }) + timer.current = setTimeout(() => { + setBusy(false); setNotice('The request timed out. Refresh before retrying; it may have completed.') + setItems([]); pending.current = null + }, 330_000) + }, [send]) + + useEffect(() => onMessage('credentials_result', (raw) => { + const reply = raw as Reply + if (!reply.request_id || reply.request_id !== pending.current) return + pending.current = null + clearTimeout(timer.current) + setBusy(false) + if (!reply.ok) { setNotice(errors[reply.error || ''] || 'The credential operation could not complete. Check the selected reference and installed helper.'); return } + setNotice('') + if (reply.operation === 'profiles') { + const next = reply.value as Profile[] + setProfiles(next) + setProfile(current => next.some(p => p.id === current) ? current : next[0]?.id || '') + if (next.find(p => p.id === profile)?.state !== 'ready') { setItems([]); setActivity([]) } + sendRequest('action_profile', {}) + } else if (reply.operation === 'action_profile' || reply.operation === 'action_profile_set') { + setActionProfile((reply.value as { profile_id: string | null }).profile_id) + } else if (reply.operation === 'items' || reply.operation === 'items_page') { + const page = reply.value as ItemPage + setItems(previous => reply.operation === 'items' ? page.items : [...previous, ...page.items]) + if (page.next_offset !== null) { + setBusy(true) + sendRequest('items_page', { profile_id: profile, view_id: page.view_id, offset: page.next_offset }) + } + } + else if (reply.operation === 'activity') setActivity(reply.value as Activity[]) + else { + setItems([]); setActivity([]); setEditing(null) + setNotice(reply.operation === 'lock' ? 'All profiles are locked and credential workers have stopped.' : 'Saved. Refresh the list to see the latest state.') + sendRequest('profiles', {}) + } + }), [onMessage, sendRequest, profile]) + + useEffect(() => { + if (isConnected) sendRequest('profiles', {}) + return () => { clearTimeout(timer.current); pending.current = null } + }, [isConnected, sendRequest]) + + useEffect(() => { + const interval = setInterval(() => { + if (isConnected && pending.current === null) sendRequest('profiles', {}) + }, 30_000) + return () => clearInterval(interval) + }, [isConnected, sendRequest]) + + function request(operation: string, parameters: Record) { + if (busy || !isConnected) return + setBusy(true); setNotice('Complete the Windows owner prompt when it appears.') + sendRequest(operation, parameters) + } + + function edit(item: Item) { + setEditing(item); setKind(item.kind); setService(item.service) + setAccount(item.account); setOrigins(item.origins.join('\n')); setReplace(false) + } + + return
+

Credentials

+

Save logins, API keys and service secrets in a protected profile. Labels and origins are visible metadata. Enter secret values only in the protected dialog.

+
+ + {selected?.state || 'unavailable'} + + + + +
+
+ Profile for isolated agent actions: {profiles.find(p => p.id === actionProfile)?.label || 'disabled'} + + +
+
{ e.preventDefault(); request('profile_add', { label }) }}> + + +
+

{notice}

+
+ + + +
+
    {items.filter(i => `${i.service} ${i.account} ${i.kind}`.toLowerCase().includes(search.toLowerCase())).map(item =>
  • + {item.service} · {item.account} · {item.kind}
    {item.origins.join(', ')}
    + e.target.select()} /> +
    + + + +
  • )}
+
{ + e.preventDefault() + const metadata = { profile_id: profile, service, account, origins: origins.split(/[\n,]/).map(s => s.trim()).filter(Boolean) } + if (editing) request('edit', { ...metadata, credential_id: editing.credential_id, version: editing.version, replace_fields: replace }) + else request('entry', { ...metadata, kind, fields: kind === 'service_secret' ? fields.split(',').map(s => s.trim()).filter(Boolean) : [] }) + }}> +

{editing ? 'Edit saved item' : 'Add credential'}

+ + + +