Compare commits

..

10 Commits

Author SHA1 Message Date
Bentlybro
75a92fc3f3 Always yield files in ClaudeCodeBlock output
Updated the ClaudeCodeBlock to always yield the 'files' key with an empty list if no files are present, ensuring consistent output that matches the Output schema.
2026-01-13 21:55:53 +00:00
Bentlybro
2c6353a6a2 Clarify timeout behavior and fix path slicing
Updated the timeout field description to specify that the timeout only applies when creating a new sandbox, not when reconnecting. Also fixed whitespace in relative path slicing for improved code clarity.
2026-01-13 21:49:28 +00:00
Bentlybro
21b70ae9ae Improve shell command safety and error handling
Use shlex.quote to safely escape shell arguments such as working_directory and session IDs, preventing shell injection vulnerabilities. Add explicit error handling for failed Claude command executions. Refine file listing to allow hidden files except for .git, and update comments for clarity.
2026-01-13 21:40:56 +00:00
Bentlybro
b007e02364 Fix credential title assignment and improve session validation
Corrects the assignment of the 'title' field in test credential inputs to use the actual title instead of type. Adds validation to require 'sandbox_id' when resuming a session with 'session_id', and improves error handling for setup command failures in ClaudeCodeBlock.
2026-01-13 21:21:54 +00:00
Bentlybro
bbc289894a Move ClaudeCodeBlock to separate module
Extracted the ClaudeCodeBlock implementation from code_executor.py into a new claude_code.py module for better separation of concerns and maintainability. Removed Anthropic test credentials from code_executor.py as they are now defined in claude_code.py.
2026-01-13 21:06:11 +00:00
Bentlybro
639ee5f073 Add conversation history support to ClaudeCodeBlock
Introduces a conversation_history input and output to enable restoring context when continuing a session in a new sandbox. Updates the code execution flow to maintain and return the full conversation history, and marks several schema fields as advanced for improved UI clarity.
2026-01-13 16:40:17 +00:00
Bentlybro
898781134d Add session and sandbox continuation to ClaudeCodeBlock
Introduces session_id and sandbox_id fields to support resuming previous conversations and reconnecting to existing sandboxes. Updates input/output schemas, command execution logic, and documentation to enable session continuation and sandbox reuse for Claude code execution.
2026-01-13 15:10:46 +00:00
Bentlybro
7a28db1649 Refactor ClaudeCodeBlock to return output files
Updated ClaudeCodeBlock to extract and return a list of files created or modified during code execution, replacing stdout and stderr logs in the output schema. Added FileOutput model, file extraction logic, and updated test mocks and method signatures accordingly.
2026-01-13 14:41:10 +00:00
Bentlybro
a916ea0f8f Add Claude Code block for Anthropic code execution
Introduces a new ClaudeCodeBlock that enables execution of coding tasks using Anthropic's Claude Code in an E2B sandbox. The block supports configuration of credentials, prompt, timeout, setup commands, and working directory, and handles sandbox lifecycle and output collection.
2026-01-13 14:28:13 +00:00
Bently
e539280e98 fix(blocks): set User-Agent header and URL-encode topic in GetWikipediaSummaryBlock (#11754)
The GetWikipediaSummaryBlock was returning HTTP 403 errors from
Wikipedia's API because it wasn't explicitly setting a User-Agent header
that complies with https://wikitech.wikimedia.org/wiki/Robot_policy.
Additionally, topics with spaces or special characters would cause
malformed URLs.

Fixes: OPEN-2889

Changes 🏗️

- URL-encode the topic parameter using urllib.parse.quote() to handle
spaces and special characters
- Explicitly set required headers per Wikimedia robot policy:
- User-Agent: Platform default user agent (includes app name, URL, and
contact email)
- Accept-Encoding: gzip, deflate: Recommended by Wikimedia to reduce
bandwidth
- Updated test mock to match the new function signature

Checklist 📋

For code changes:

- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Verify code passes syntax check
  - [x] Verify code passes ruff linting
- [x] Create an agent using GetWikipediaSummaryBlock with a topic
containing spaces (e.g., "Artificial Intelligence")
  - [x] Verify the block returns a Wikipedia summary without 403 errors

For configuration changes:

- .env.default is updated or already compatible with my changes
- docker-compose.yml is updated or already compatible with my changes
- I have included a list of my configuration changes in the PR
description (under Changes)
.
N/A - No configuration changes required.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved Wikipedia API requests by adding compatible request headers
(including a proper user agent and encoding acceptance) for more
reliable responses.
* Enhanced handling of search topics by URL-encoding terms so queries
with spaces or special characters return correct results.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-13 12:24:51 +00:00
42 changed files with 938 additions and 1777 deletions

View File

@@ -108,9 +108,6 @@ class CredentialsMetaResponse(BaseModel):
host: str | None = Field(
default=None, description="Host pattern for host-scoped credentials"
)
is_system: bool = Field(
default=False, description="Whether this is a system-managed credential"
)
@router.post("/{provider}/callback", summary="Exchange OAuth code for tokens")
@@ -178,8 +175,6 @@ async def callback(
f"Successfully processed OAuth callback for user {user_id} "
f"and provider {provider.value}"
)
from backend.integrations.credentials_store import is_system_credential
return CredentialsMetaResponse(
id=credentials.id,
provider=credentials.provider,
@@ -190,7 +185,6 @@ async def callback(
host=(
credentials.host if isinstance(credentials, HostScopedCredentials) else None
),
is_system=is_system_credential(credentials.id),
)
@@ -198,8 +192,6 @@ async def callback(
async def list_credentials(
user_id: Annotated[str, Security(get_user_id)],
) -> list[CredentialsMetaResponse]:
from backend.integrations.credentials_store import is_system_credential
credentials = await creds_manager.store.get_all_creds(user_id)
return [
CredentialsMetaResponse(
@@ -210,7 +202,6 @@ async def list_credentials(
scopes=cred.scopes if isinstance(cred, OAuth2Credentials) else None,
username=cred.username if isinstance(cred, OAuth2Credentials) else None,
host=cred.host if isinstance(cred, HostScopedCredentials) else None,
is_system=is_system_credential(cred.id),
)
for cred in credentials
]
@@ -223,8 +214,6 @@ async def list_credentials_by_provider(
],
user_id: Annotated[str, Security(get_user_id)],
) -> list[CredentialsMetaResponse]:
from backend.integrations.credentials_store import is_system_credential
credentials = await creds_manager.store.get_creds_by_provider(user_id, provider)
return [
CredentialsMetaResponse(
@@ -235,7 +224,6 @@ async def list_credentials_by_provider(
scopes=cred.scopes if isinstance(cred, OAuth2Credentials) else None,
username=cred.username if isinstance(cred, OAuth2Credentials) else None,
host=cred.host if isinstance(cred, HostScopedCredentials) else None,
is_system=is_system_credential(cred.id),
)
for cred in credentials
]

View File

@@ -0,0 +1,602 @@
import shlex
from typing import Literal
from e2b import AsyncSandbox as BaseAsyncSandbox
from pydantic import BaseModel, SecretStr
from backend.data.block import (
Block,
BlockCategory,
BlockOutput,
BlockSchemaInput,
BlockSchemaOutput,
)
from backend.data.model import (
APIKeyCredentials,
CredentialsField,
CredentialsMetaInput,
SchemaField,
)
from backend.integrations.providers import ProviderName
# Test credentials for E2B
TEST_E2B_CREDENTIALS = APIKeyCredentials(
id="01234567-89ab-cdef-0123-456789abcdef",
provider="e2b",
api_key=SecretStr("mock-e2b-api-key"),
title="Mock E2B API key",
expires_at=None,
)
TEST_E2B_CREDENTIALS_INPUT = {
"provider": TEST_E2B_CREDENTIALS.provider,
"id": TEST_E2B_CREDENTIALS.id,
"type": TEST_E2B_CREDENTIALS.type,
"title": TEST_E2B_CREDENTIALS.title,
}
# Test credentials for Anthropic
TEST_ANTHROPIC_CREDENTIALS = APIKeyCredentials(
id="2e568a2b-b2ea-475a-8564-9a676bf31c56",
provider="anthropic",
api_key=SecretStr("mock-anthropic-api-key"),
title="Mock Anthropic API key",
expires_at=None,
)
TEST_ANTHROPIC_CREDENTIALS_INPUT = {
"provider": TEST_ANTHROPIC_CREDENTIALS.provider,
"id": TEST_ANTHROPIC_CREDENTIALS.id,
"type": TEST_ANTHROPIC_CREDENTIALS.type,
"title": TEST_ANTHROPIC_CREDENTIALS.title,
}
class ClaudeCodeBlock(Block):
"""
Execute tasks using Claude Code (Anthropic's AI coding assistant) in an E2B sandbox.
Claude Code can create files, install tools, run commands, and perform complex
coding tasks autonomously within a secure sandbox environment.
"""
# Use base template - we'll install Claude Code ourselves for latest version
DEFAULT_TEMPLATE = "base"
class Input(BlockSchemaInput):
e2b_credentials: CredentialsMetaInput[
Literal[ProviderName.E2B], Literal["api_key"]
] = CredentialsField(
description=(
"API key for the E2B platform to create the sandbox. "
"Get one at https://e2b.dev/docs"
),
)
anthropic_credentials: CredentialsMetaInput[
Literal[ProviderName.ANTHROPIC], Literal["api_key"]
] = CredentialsField(
description=(
"API key for Anthropic to power Claude Code. "
"Get one at https://console.anthropic.com/"
),
)
prompt: str = SchemaField(
description=(
"The task or instruction for Claude Code to execute. "
"Claude Code can create files, install packages, run commands, "
"and perform complex coding tasks."
),
placeholder="Create a hello world index.html file",
default="",
advanced=False,
)
timeout: int = SchemaField(
description=(
"Sandbox timeout in seconds. Claude Code tasks can take "
"a while, so set this appropriately for your task complexity. "
"Note: This only applies when creating a new sandbox. "
"When reconnecting to an existing sandbox via sandbox_id, "
"the original timeout is retained."
),
default=300, # 5 minutes default
advanced=True,
)
setup_commands: list[str] = SchemaField(
description=(
"Optional shell commands to run before executing Claude Code. "
"Useful for installing dependencies or setting up the environment."
),
default_factory=list,
advanced=True,
)
working_directory: str = SchemaField(
description="Working directory for Claude Code to operate in.",
default="/home/user",
advanced=True,
)
# Session/continuation support
session_id: str = SchemaField(
description=(
"Session ID to resume a previous conversation. "
"Leave empty for a new conversation. "
"Use the session_id from a previous run to continue that conversation."
),
default="",
advanced=True,
)
sandbox_id: str = SchemaField(
description=(
"Sandbox ID to reconnect to an existing sandbox. "
"Required when resuming a session (along with session_id). "
"Use the sandbox_id from a previous run where dispose_sandbox was False."
),
default="",
advanced=True,
)
conversation_history: str = SchemaField(
description=(
"Previous conversation history to continue from. "
"Use this to restore context on a fresh sandbox if the previous one timed out. "
"Pass the conversation_history output from a previous run."
),
default="",
advanced=True,
)
dispose_sandbox: bool = SchemaField(
description=(
"Whether to dispose of the sandbox immediately after execution. "
"Set to False if you want to continue the conversation later "
"(you'll need both sandbox_id and session_id from the output)."
),
default=True,
advanced=True,
)
class FileOutput(BaseModel):
"""A file extracted from the sandbox."""
path: str
relative_path: str # Path relative to working directory (for GitHub, etc.)
name: str
content: str
class Output(BlockSchemaOutput):
response: str = SchemaField(
description="The output/response from Claude Code execution"
)
files: list["ClaudeCodeBlock.FileOutput"] = SchemaField(
description=(
"List of files created/modified by Claude Code. "
"Each file has 'path', 'name', and 'content' fields."
)
)
conversation_history: str = SchemaField(
description=(
"Full conversation history including this turn. "
"Pass this to conversation_history input to continue on a fresh sandbox "
"if the previous sandbox timed out."
)
)
session_id: str = SchemaField(
description=(
"Session ID for this conversation. "
"Pass this back along with sandbox_id to continue the conversation."
)
)
sandbox_id: str = SchemaField(
description=(
"ID of the sandbox instance. "
"Pass this back along with session_id to continue the conversation "
"(only available if dispose_sandbox was False)."
)
)
def __init__(self):
super().__init__(
id="4e34f4a5-9b89-4326-ba77-2dd6750b7194",
description=(
"Execute tasks using Claude Code in an E2B sandbox. "
"Claude Code can create files, install tools, run commands, "
"and perform complex coding tasks autonomously."
),
categories={BlockCategory.DEVELOPER_TOOLS, BlockCategory.AI},
input_schema=ClaudeCodeBlock.Input,
output_schema=ClaudeCodeBlock.Output,
test_credentials={
"e2b_credentials": TEST_E2B_CREDENTIALS,
"anthropic_credentials": TEST_ANTHROPIC_CREDENTIALS,
},
test_input={
"e2b_credentials": TEST_E2B_CREDENTIALS_INPUT,
"anthropic_credentials": TEST_ANTHROPIC_CREDENTIALS_INPUT,
"prompt": "Create a hello world HTML file",
"timeout": 300,
"setup_commands": [],
"working_directory": "/home/user",
"session_id": "",
"sandbox_id": "",
"conversation_history": "",
"dispose_sandbox": True,
},
test_output=[
("response", "Created index.html with hello world content"),
(
"files",
[
{
"path": "/home/user/index.html",
"relative_path": "index.html",
"name": "index.html",
"content": "<html>Hello World</html>",
}
],
),
(
"conversation_history",
"User: Create a hello world HTML file\n"
"Claude: Created index.html with hello world content",
),
("session_id", str),
],
test_mock={
"execute_claude_code": lambda *args, **kwargs: (
"Created index.html with hello world content", # response
[
ClaudeCodeBlock.FileOutput(
path="/home/user/index.html",
relative_path="index.html",
name="index.html",
content="<html>Hello World</html>",
)
], # files
"User: Create a hello world HTML file\n"
"Claude: Created index.html with hello world content", # conversation_history
"test-session-id", # session_id
"sandbox_id", # sandbox_id
),
},
)
async def execute_claude_code(
self,
e2b_api_key: str,
anthropic_api_key: str,
prompt: str,
timeout: int,
setup_commands: list[str],
working_directory: str,
session_id: str,
existing_sandbox_id: str,
conversation_history: str,
dispose_sandbox: bool,
) -> tuple[str, list["ClaudeCodeBlock.FileOutput"], str, str, str]:
"""
Execute Claude Code in an E2B sandbox.
Returns:
Tuple of (response, files, conversation_history, session_id, sandbox_id)
"""
import json
import uuid
# Validate that sandbox_id is provided when resuming a session
if session_id and not existing_sandbox_id:
raise ValueError(
"sandbox_id is required when resuming a session with session_id. "
"The session state is stored in the original sandbox. "
"If the sandbox has timed out, use conversation_history instead "
"to restore context on a fresh sandbox."
)
sandbox = None
try:
# Either reconnect to existing sandbox or create a new one
if existing_sandbox_id:
# Reconnect to existing sandbox for conversation continuation
sandbox = await BaseAsyncSandbox.connect(
sandbox_id=existing_sandbox_id,
api_key=e2b_api_key,
)
else:
# Create new sandbox
sandbox = await BaseAsyncSandbox.create(
template=self.DEFAULT_TEMPLATE,
api_key=e2b_api_key,
timeout=timeout,
envs={"ANTHROPIC_API_KEY": anthropic_api_key},
)
# Install Claude Code from npm (ensures we get the latest version)
install_result = await sandbox.commands.run(
"npm install -g @anthropic-ai/claude-code@latest",
timeout=120, # 2 min timeout for install
)
if install_result.exit_code != 0:
raise Exception(
f"Failed to install Claude Code: {install_result.stderr}"
)
# Run any user-provided setup commands
for cmd in setup_commands:
setup_result = await sandbox.commands.run(cmd)
if setup_result.exit_code != 0:
raise Exception(
f"Setup command failed: {cmd}\n"
f"Exit code: {setup_result.exit_code}\n"
f"Stdout: {setup_result.stdout}\n"
f"Stderr: {setup_result.stderr}"
)
# Generate or use provided session ID
current_session_id = session_id if session_id else str(uuid.uuid4())
# Build base Claude flags
base_flags = "-p --dangerously-skip-permissions --output-format json"
# Add conversation history context if provided (for fresh sandbox continuation)
history_flag = ""
if conversation_history and not session_id:
# Inject previous conversation as context via system prompt
# Use consistent escaping via _escape_prompt helper
escaped_history = self._escape_prompt(
f"Previous conversation context: {conversation_history}"
)
history_flag = f" --append-system-prompt {escaped_history}"
# Build Claude command based on whether we're resuming or starting new
# Use shlex.quote for working_directory and session IDs to prevent injection
safe_working_dir = shlex.quote(working_directory)
if session_id:
# Resuming existing session (sandbox still alive)
safe_session_id = shlex.quote(session_id)
claude_command = (
f"cd {safe_working_dir} && "
f"echo {self._escape_prompt(prompt)} | "
f"claude --resume {safe_session_id} {base_flags}"
)
else:
# New session with specific ID
safe_current_session_id = shlex.quote(current_session_id)
claude_command = (
f"cd {safe_working_dir} && "
f"echo {self._escape_prompt(prompt)} | "
f"claude --session-id {safe_current_session_id} {base_flags}{history_flag}"
)
result = await sandbox.commands.run(
claude_command,
timeout=0, # No command timeout - let sandbox timeout handle it
)
# Check for command failure
if result.exit_code != 0:
error_msg = result.stderr or result.stdout or "Unknown error"
raise Exception(
f"Claude Code command failed with exit code {result.exit_code}:\n"
f"{error_msg}"
)
raw_output = result.stdout or ""
sandbox_id = sandbox.sandbox_id
# Parse JSON output to extract response and build conversation history
response = ""
new_conversation_history = conversation_history or ""
try:
# The JSON output contains the result
output_data = json.loads(raw_output)
response = output_data.get("result", raw_output)
# Build conversation history entry
turn_entry = f"User: {prompt}\nClaude: {response}"
if new_conversation_history:
new_conversation_history = (
f"{new_conversation_history}\n\n{turn_entry}"
)
else:
new_conversation_history = turn_entry
except json.JSONDecodeError:
# If not valid JSON, use raw output
response = raw_output
turn_entry = f"User: {prompt}\nClaude: {response}"
if new_conversation_history:
new_conversation_history = (
f"{new_conversation_history}\n\n{turn_entry}"
)
else:
new_conversation_history = turn_entry
# Extract files from the working directory
files = await self._extract_files(sandbox, working_directory)
return (
response,
files,
new_conversation_history,
current_session_id,
sandbox_id,
)
finally:
if dispose_sandbox and sandbox:
await sandbox.kill()
async def _extract_files(
self,
sandbox: BaseAsyncSandbox,
working_directory: str,
) -> list["ClaudeCodeBlock.FileOutput"]:
"""
Extract files from the sandbox working directory.
Returns:
List of FileOutput objects with path, name, and content
"""
files: list[ClaudeCodeBlock.FileOutput] = []
# Text file extensions we can safely read
text_extensions = {
".txt",
".md",
".html",
".htm",
".css",
".js",
".ts",
".jsx",
".tsx",
".json",
".xml",
".yaml",
".yml",
".toml",
".ini",
".cfg",
".conf",
".py",
".rb",
".php",
".java",
".c",
".cpp",
".h",
".hpp",
".cs",
".go",
".rs",
".swift",
".kt",
".scala",
".sh",
".bash",
".zsh",
".sql",
".graphql",
".env",
".gitignore",
".dockerfile",
"Dockerfile",
".vue",
".svelte",
".astro",
".mdx",
".rst",
".tex",
".csv",
".log",
}
try:
# List files recursively using find command
# Exclude node_modules and .git directories, but allow hidden files
# like .env and .gitignore (they're filtered by text_extensions later)
safe_working_dir = shlex.quote(working_directory)
find_result = await sandbox.commands.run(
f"find {safe_working_dir} -type f "
f"-not -path '*/node_modules/*' "
f"-not -path '*/.git/*' "
f"2>/dev/null | head -100"
)
if find_result.stdout:
for file_path in find_result.stdout.strip().split("\n"):
if not file_path:
continue
# Check if it's a text file we can read
is_text = any(
file_path.endswith(ext) for ext in text_extensions
) or file_path.endswith("Dockerfile")
if is_text:
try:
content = await sandbox.files.read(file_path)
# Handle bytes or string
if isinstance(content, bytes):
content = content.decode("utf-8", errors="replace")
# Extract filename from path
file_name = file_path.split("/")[-1]
# Calculate relative path by stripping working directory
relative_path = file_path
if file_path.startswith(working_directory):
relative_path = file_path[len(working_directory) :]
# Remove leading slash if present
if relative_path.startswith("/"):
relative_path = relative_path[1:]
files.append(
ClaudeCodeBlock.FileOutput(
path=file_path,
relative_path=relative_path,
name=file_name,
content=content,
)
)
except Exception:
# Skip files that can't be read
pass
except Exception:
# If file extraction fails, return empty results
pass
return files
def _escape_prompt(self, prompt: str) -> str:
"""Escape the prompt for safe shell execution."""
# Use single quotes and escape any single quotes in the prompt
escaped = prompt.replace("'", "'\"'\"'")
return f"'{escaped}'"
async def run(
self,
input_data: Input,
*,
e2b_credentials: APIKeyCredentials,
anthropic_credentials: APIKeyCredentials,
**kwargs,
) -> BlockOutput:
try:
(
response,
files,
conversation_history,
session_id,
sandbox_id,
) = await self.execute_claude_code(
e2b_api_key=e2b_credentials.api_key.get_secret_value(),
anthropic_api_key=anthropic_credentials.api_key.get_secret_value(),
prompt=input_data.prompt,
timeout=input_data.timeout,
setup_commands=input_data.setup_commands,
working_directory=input_data.working_directory,
session_id=input_data.session_id,
existing_sandbox_id=input_data.sandbox_id,
conversation_history=input_data.conversation_history,
dispose_sandbox=input_data.dispose_sandbox,
)
yield "response", response
# Always yield files (empty list if none) to match Output schema
yield "files", [f.model_dump() for f in files]
# Always yield conversation_history so user can restore context on fresh sandbox
yield "conversation_history", conversation_history
# Always yield session_id so user can continue conversation
yield "session_id", session_id
if not input_data.dispose_sandbox and sandbox_id:
yield "sandbox_id", sandbox_id
except Exception as e:
yield "error", str(e)

View File

@@ -18,6 +18,7 @@ from backend.data.model import (
SchemaField,
)
from backend.integrations.providers import ProviderName
from backend.util.request import DEFAULT_USER_AGENT
class GetWikipediaSummaryBlock(Block, GetRequest):
@@ -39,17 +40,27 @@ class GetWikipediaSummaryBlock(Block, GetRequest):
output_schema=GetWikipediaSummaryBlock.Output,
test_input={"topic": "Artificial Intelligence"},
test_output=("summary", "summary content"),
test_mock={"get_request": lambda url, json: {"extract": "summary content"}},
test_mock={
"get_request": lambda url, headers, json: {"extract": "summary content"}
},
)
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
topic = input_data.topic
url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{topic}"
# URL-encode the topic to handle spaces and special characters
encoded_topic = quote(topic, safe="")
url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{encoded_topic}"
# Set headers per Wikimedia robot policy (https://w.wiki/4wJS)
# - User-Agent: Required, must identify the bot
# - Accept-Encoding: gzip recommended to reduce bandwidth
headers = {
"User-Agent": DEFAULT_USER_AGENT,
"Accept-Encoding": "gzip, deflate",
}
# Note: User-Agent is now automatically set by the request library
# to comply with Wikimedia's robot policy (https://w.wiki/4wJS)
try:
response = await self.get_request(url, json=True)
response = await self.get_request(url, headers=headers, json=True)
if "extract" not in response:
raise ValueError(f"Unable to parse Wikipedia response: {response}")
yield "summary", response["extract"]

View File

@@ -245,13 +245,6 @@ DEFAULT_CREDENTIALS = [
webshare_proxy_credentials,
]
SYSTEM_CREDENTIAL_IDS = {cred.id for cred in DEFAULT_CREDENTIALS}
def is_system_credential(credential_id: str) -> bool:
"""Check if a credential ID belongs to a system-managed credential."""
return credential_id in SYSTEM_CREDENTIAL_IDS
class IntegrationCredentialsStore:
def __init__(self):

View File

@@ -3,13 +3,6 @@ import { withSentryConfig } from "@sentry/nextjs";
/** @type {import('next').NextConfig} */
const nextConfig = {
productionBrowserSourceMaps: true,
// Externalize OpenTelemetry packages to fix Turbopack HMR issues
serverExternalPackages: [
"@opentelemetry/instrumentation",
"@opentelemetry/sdk-node",
"import-in-the-middle",
"require-in-the-middle",
],
experimental: {
serverActions: {
bodySizeLimit: "256mb",

View File

@@ -117,7 +117,6 @@
},
"devDependencies": {
"@chromatic-com/storybook": "4.1.2",
"@opentelemetry/instrumentation": "0.209.0",
"@playwright/test": "1.56.1",
"@storybook/addon-a11y": "9.1.5",
"@storybook/addon-docs": "9.1.5",
@@ -141,7 +140,6 @@
"eslint": "8.57.1",
"eslint-config-next": "15.5.7",
"eslint-plugin-storybook": "9.1.5",
"import-in-the-middle": "2.0.2",
"msw": "2.11.6",
"msw-storybook-addon": "2.0.6",
"orval": "7.13.0",
@@ -149,7 +147,7 @@
"postcss": "8.5.6",
"prettier": "3.6.2",
"prettier-plugin-tailwindcss": "0.7.1",
"require-in-the-middle": "8.0.1",
"require-in-the-middle": "7.5.2",
"storybook": "9.1.5",
"tailwindcss": "3.4.17",
"typescript": "5.9.3"

View File

@@ -270,9 +270,6 @@ importers:
'@chromatic-com/storybook':
specifier: 4.1.2
version: 4.1.2(storybook@9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2))
'@opentelemetry/instrumentation':
specifier: 0.209.0
version: 0.209.0(@opentelemetry/api@1.9.0)
'@playwright/test':
specifier: 1.56.1
version: 1.56.1
@@ -342,9 +339,6 @@ importers:
eslint-plugin-storybook:
specifier: 9.1.5
version: 9.1.5(eslint@8.57.1)(storybook@9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2))(typescript@5.9.3)
import-in-the-middle:
specifier: 2.0.2
version: 2.0.2
msw:
specifier: 2.11.6
version: 2.11.6(@types/node@24.10.0)(typescript@5.9.3)
@@ -367,8 +361,8 @@ importers:
specifier: 0.7.1
version: 0.7.1(prettier@3.6.2)
require-in-the-middle:
specifier: 8.0.1
version: 8.0.1
specifier: 7.5.2
version: 7.5.2
storybook:
specifier: 9.1.5
version: 9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2)
@@ -1553,10 +1547,6 @@ packages:
resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==}
engines: {node: '>=8.0.0'}
'@opentelemetry/api-logs@0.209.0':
resolution: {integrity: sha512-xomnUNi7TiAGtOgs0tb54LyrjRZLu9shJGGwkcN7NgtiPYOpNnKLkRJtzZvTjD/w6knSZH9sFZcUSUovYOPg6A==}
engines: {node: '>=8.0.0'}
'@opentelemetry/api@1.9.0':
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
engines: {node: '>=8.0.0'}
@@ -1711,12 +1701,6 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/instrumentation@0.209.0':
resolution: {integrity: sha512-Cwe863ojTCnFlxVuuhG7s6ODkAOzKsAEthKAcI4MDRYz1OmGWYnmSl4X2pbyS+hBxVTdvfZePfoEA01IjqcEyw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/redis-common@0.38.2':
resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==}
engines: {node: ^18.19.0 || >=20.6.0}
@@ -4973,8 +4957,8 @@ packages:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
import-in-the-middle@2.0.2:
resolution: {integrity: sha512-qet/hkGt3EbNGVtbDfPu0BM+tCqBS8wT1SYrstPaDKoWtshsC6licOemz7DVtpBEyvDNzo8UTBf9/GwWuSDZ9w==}
import-in-the-middle@2.0.1:
resolution: {integrity: sha512-bruMpJ7xz+9jwGzrwEhWgvRrlKRYCRDBrfU+ur3FcasYXLJDxTruJ//8g2Noj+QFyRBeqbpj8Bhn4Fbw6HjvhA==}
imurmurhash@0.1.4:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
@@ -6518,6 +6502,10 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
require-in-the-middle@7.5.2:
resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==}
engines: {node: '>=8.6.0'}
require-in-the-middle@8.0.1:
resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==}
engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'}
@@ -8732,10 +8720,6 @@ snapshots:
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs@0.209.0':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api@1.9.0': {}
'@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0)':
@@ -8936,16 +8920,7 @@ snapshots:
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.208.0
import-in-the-middle: 2.0.2
require-in-the-middle: 8.0.1
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation@0.209.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.209.0
import-in-the-middle: 2.0.2
import-in-the-middle: 2.0.1
require-in-the-middle: 8.0.1
transitivePeerDependencies:
- supports-color
@@ -9125,7 +9100,7 @@ snapshots:
'@prisma/instrumentation@6.19.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
transitivePeerDependencies:
- supports-color
@@ -9969,7 +9944,7 @@ snapshots:
'@opentelemetry/semantic-conventions': 1.38.0
'@sentry/core': 10.27.0
'@sentry/opentelemetry': 10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)
import-in-the-middle: 2.0.2
import-in-the-middle: 2.0.1
transitivePeerDependencies:
- supports-color
@@ -10008,7 +9983,7 @@ snapshots:
'@sentry/core': 10.27.0
'@sentry/node-core': 10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)
'@sentry/opentelemetry': 10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)
import-in-the-middle: 2.0.2
import-in-the-middle: 2.0.1
minimatch: 9.0.5
transitivePeerDependencies:
- supports-color
@@ -12817,7 +12792,7 @@ snapshots:
parent-module: 1.0.1
resolve-from: 4.0.0
import-in-the-middle@2.0.2:
import-in-the-middle@2.0.1:
dependencies:
acorn: 8.15.0
acorn-import-attributes: 1.9.5(acorn@8.15.0)
@@ -14656,6 +14631,14 @@ snapshots:
require-from-string@2.0.2: {}
require-in-the-middle@7.5.2:
dependencies:
debug: 4.4.3
module-details-from-path: 1.0.4
resolve: 1.22.11
transitivePeerDependencies:
- supports-color
require-in-the-middle@8.0.1:
dependencies:
debug: 4.4.3

View File

@@ -1,38 +1,32 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/molecules/Alert/Alert";
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { cn } from "@/lib/utils";
import { PlusIcon } from "@phosphor-icons/react";
import { useEffect, useState } from "react";
import { AgentVersionChangelog } from "./components/AgentVersionChangelog";
import { AgentSettingsModal } from "./components/modals/AgentSettingsModal/AgentSettingsModal";
import { RunAgentModal } from "./components/modals/RunAgentModal/RunAgentModal";
import { useMarketplaceUpdate } from "./hooks/useMarketplaceUpdate";
import { AgentVersionChangelog } from "./components/AgentVersionChangelog";
import { MarketplaceBanners } from "@/components/contextual/MarketplaceBanners/MarketplaceBanners";
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
import { AgentSettingsButton } from "./components/other/AgentSettingsButton";
import { AgentRunsLoading } from "./components/other/AgentRunsLoading";
import { EmptySchedules } from "./components/other/EmptySchedules";
import { EmptyTasks } from "./components/other/EmptyTasks";
import { EmptyTemplates } from "./components/other/EmptyTemplates";
import { EmptyTriggers } from "./components/other/EmptyTriggers";
import { MarketplaceBanners } from "./components/other/MarketplaceBanners";
import { SectionWrap } from "./components/other/SectionWrap";
import { LoadingSelectedContent } from "./components/selected-views/LoadingSelectedContent";
import { SelectedRunView } from "./components/selected-views/SelectedRunView/SelectedRunView";
import { SelectedScheduleView } from "./components/selected-views/SelectedScheduleView/SelectedScheduleView";
import { SelectedSettingsView } from "./components/selected-views/SelectedSettingsView/SelectedSettingsView";
import { SelectedTemplateView } from "./components/selected-views/SelectedTemplateView/SelectedTemplateView";
import { SelectedTriggerView } from "./components/selected-views/SelectedTriggerView/SelectedTriggerView";
import { SelectedViewLayout } from "./components/selected-views/SelectedViewLayout";
import { SidebarRunsList } from "./components/sidebar/SidebarRunsList/SidebarRunsList";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "./helpers";
import { useAgentMissingCredentials } from "./hooks/useAgentMissingCredentials";
import { useMarketplaceUpdate } from "./hooks/useMarketplaceUpdate";
import { useNewAgentLibraryView } from "./useNewAgentLibraryView";
export function NewAgentLibraryView() {
@@ -51,6 +45,7 @@ export function NewAgentLibraryView() {
handleSelectRun,
handleCountsChange,
handleClearSelectedRun,
handleSelectSettings,
onRunInitiated,
onTriggerSetup,
onScheduleCreated,
@@ -68,10 +63,6 @@ export function NewAgentLibraryView() {
} = useMarketplaceUpdate({ agent });
const [changelogOpen, setChangelogOpen] = useState(false);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const { hasMissingCredentials, isLoading: isLoadingCredentials } =
useAgentMissingCredentials(agent);
useEffect(() => {
if (agent) {
@@ -146,33 +137,13 @@ export function NewAgentLibraryView() {
return (
<>
<div className="flex h-full flex-col">
<div className="mx-6 flex flex-col gap-4 pt-4">
<div className="flex items-center justify-between">
<Breadcrumbs
items={[
{ name: "My Library", link: "/library" },
{ name: agent.name, link: `/library/agents/${agentId}` },
]}
/>
<AgentSettingsModal agent={agent} />
</div>
{hasMissingCredentials && !isLoadingCredentials && (
<Alert variant="warning">
<AlertTitle>Missing credentials</AlertTitle>
<AlertDescription>
<Text variant="small" className="text-zinc-800">
This agent requires credentials that are not configured.{" "}
<button
onClick={() => setSettingsModalOpen(true)}
className="font-medium underline hover:no-underline"
>
Configure credentials
</button>{" "}
to run tasks.
</Text>
</AlertDescription>
</Alert>
)}
<div className="mx-6 pt-4">
<Breadcrumbs
items={[
{ name: "My Library", link: "/library" },
{ name: agent.name, link: `/library/agents/${agentId}` },
]}
/>
</div>
<div className="flex min-h-0 flex-1">
<EmptyTasks
@@ -183,13 +154,6 @@ export function NewAgentLibraryView() {
/>
</div>
</div>
{agent && (
<AgentSettingsModal
agent={agent}
controlledOpen={settingsModalOpen}
onOpenChange={setSettingsModalOpen}
/>
)}
{renderPublishAgentModal()}
{renderVersionChangelog()}
</>
@@ -200,49 +164,37 @@ export function NewAgentLibraryView() {
<>
<div className="mx-4 grid h-full grid-cols-1 gap-0 pt-3 md:ml-4 md:mr-0 md:gap-4 lg:grid-cols-[25%_70%]">
<SectionWrap className="mb-3 block">
{hasMissingCredentials && !isLoadingCredentials && (
<div className={cn("mb-4", AGENT_LIBRARY_SECTION_PADDING_X)}>
<Alert variant="warning">
<AlertTitle>Missing credentials</AlertTitle>
<AlertDescription>
<Text variant="small" className="text-zinc-800">
This agent requires credentials that are not configured.{" "}
<button
onClick={() => setSettingsModalOpen(true)}
className="font-medium underline hover:no-underline"
>
Configure credentials
</button>{" "}
to run tasks.
</Text>
</AlertDescription>
</Alert>
</div>
)}
<div
className={cn(
"border-b border-zinc-100 pb-5",
AGENT_LIBRARY_SECTION_PADDING_X,
)}
>
<RunAgentModal
triggerSlot={
<Button
variant="primary"
size="large"
className="w-full"
disabled={isTemplateLoading && activeTab === "templates"}
>
<PlusIcon size={20} /> New task
</Button>
}
agent={agent}
onRunCreated={onRunInitiated}
onScheduleCreated={onScheduleCreated}
onTriggerSetup={onTriggerSetup}
initialInputValues={activeTemplate?.inputs}
initialInputCredentials={activeTemplate?.credentials}
/>
<div className="flex items-center gap-2">
<RunAgentModal
triggerSlot={
<Button
variant="primary"
size="large"
className="flex-1"
disabled={isTemplateLoading && activeTab === "templates"}
>
<PlusIcon size={20} /> New task
</Button>
}
agent={agent}
onRunCreated={onRunInitiated}
onScheduleCreated={onScheduleCreated}
onTriggerSetup={onTriggerSetup}
initialInputValues={activeTemplate?.inputs}
initialInputCredentials={activeTemplate?.credentials}
/>
<AgentSettingsButton
agent={agent}
onSelectSettings={handleSelectSettings}
selected={activeItem === "settings"}
/>
</div>
</div>
<SidebarRunsList
@@ -256,7 +208,12 @@ export function NewAgentLibraryView() {
</SectionWrap>
{activeItem ? (
activeTab === "scheduled" ? (
activeItem === "settings" ? (
<SelectedSettingsView
agent={agent}
onClearSelectedRun={handleClearSelectedRun}
/>
) : activeTab === "scheduled" ? (
<SelectedScheduleView
agent={agent}
scheduleId={activeItem}
@@ -289,6 +246,8 @@ export function NewAgentLibraryView() {
onSelectRun={handleSelectRun}
onClearSelectedRun={handleClearSelectedRun}
banner={renderMarketplaceUpdateBanner()}
onSelectSettings={handleSelectSettings}
selectedSettings={activeItem === "settings"}
/>
)
) : sidebarLoading ? (
@@ -328,13 +287,6 @@ export function NewAgentLibraryView() {
</SelectedViewLayout>
)}
</div>
{agent && (
<AgentSettingsModal
agent={agent}
controlledOpen={settingsModalOpen}
onOpenChange={setSettingsModalOpen}
/>
)}
{renderPublishAgentModal()}
{renderVersionChangelog()}
</>

View File

@@ -4,7 +4,6 @@ import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Text } from "@/components/atoms/Text/Text";
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { CredentialsInput } from "../CredentialsInputs/CredentialsInputs";
import { isSystemCredential } from "../CredentialsInputs/helpers";
import { RunAgentInputs } from "../RunAgentInputs/RunAgentInputs";
import { getAgentCredentialsFields, getAgentInputFields } from "./helpers";
@@ -72,7 +71,6 @@ export function AgentInputsReadOnly({
{credentialFieldEntries.map(([key, inputSubSchema]) => {
const credential = credentialInputs![key];
if (!credential) return null;
if (isSystemCredential(credential)) return null;
return (
<CredentialsInput

View File

@@ -1,131 +0,0 @@
"use client";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Button } from "@/components/atoms/Button/Button";
import { Switch } from "@/components/atoms/Switch/Switch";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
import { GearIcon } from "@phosphor-icons/react";
import { useMemo, useState } from "react";
import { useAgentSystemCredentials } from "../../../hooks/useAgentSystemCredentials";
import { SystemCredentialRow } from "../../selected-views/SelectedSettingsView/components/SystemCredentialRow";
interface Props {
agent: LibraryAgent;
controlledOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function AgentSettingsModal({
agent,
controlledOpen,
onOpenChange,
}: Props) {
const [internalIsOpen, setInternalIsOpen] = useState(false);
const isOpen = controlledOpen !== undefined ? controlledOpen : internalIsOpen;
function setIsOpen(open: boolean) {
if (onOpenChange) {
onOpenChange(open);
} else {
setInternalIsOpen(open);
}
}
const { currentSafeMode, isPending, hasHITLBlocks, handleToggle } =
useAgentSafeMode(agent);
const { hasSystemCredentials, systemCredentials } =
useAgentSystemCredentials(agent);
// Only show credential fields that have system credentials
const credentialFieldsWithSystemCreds = useMemo(() => {
return systemCredentials.map((item) => ({
fieldKey: item.key,
schema: item.schema,
systemCredential: item.credential,
}));
}, [systemCredentials]);
const hasAnySettings = hasHITLBlocks || hasSystemCredentials;
return (
<Dialog
controlled={{ isOpen, set: setIsOpen }}
styling={{ maxWidth: "600px", maxHeight: "90vh" }}
title="Agent Settings"
>
{controlledOpen === undefined && (
<Dialog.Trigger>
<Button
variant="ghost"
size="small"
className="m-0 min-w-0 rounded-full p-0 px-1"
aria-label="Agent Settings"
>
<GearIcon size={18} className="text-zinc-600" />
<Text variant="small">Agent Settings</Text>
</Button>
</Dialog.Trigger>
)}
<Dialog.Content>
<div className="space-y-6">
{hasHITLBlocks && (
<div className="flex w-full flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<div className="flex w-full items-start justify-between gap-4">
<div className="flex-1">
<Text variant="large-semibold">Require human approval</Text>
<Text variant="large" className="mt-1 text-zinc-900">
The agent will pause and wait for your review before
continuing
</Text>
</div>
<Switch
checked={currentSafeMode || false}
onCheckedChange={handleToggle}
disabled={isPending}
className="mt-1"
/>
</div>
</div>
)}
{hasSystemCredentials && (
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<div>
<Text variant="large-semibold">System Credentials</Text>
<Text variant="body" className="mt-1 text-muted-foreground">
These credentials are managed by AutoGPT and used by the agent
to access various services. You can switch to your own
credentials if preferred.
</Text>
</div>
<div className="w-full space-y-4">
{credentialFieldsWithSystemCreds.map(
({ fieldKey, schema, systemCredential }) => (
<SystemCredentialRow
key={fieldKey}
credentialKey={fieldKey}
agentId={agent.id.toString()}
schema={schema}
systemCredential={systemCredential}
/>
),
)}
</div>
</div>
)}
{!hasAnySettings && (
<div className="py-6">
<Text variant="body" className="text-muted-foreground">
This agent doesn&apos;t have any configurable settings.
</Text>
</div>
)}
</div>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -10,10 +10,11 @@ import { toDisplayName } from "@/providers/agent-credentials/helper";
import { APIKeyCredentialsModal } from "./components/APIKeyCredentialsModal/APIKeyCredentialsModal";
import { CredentialRow } from "./components/CredentialRow/CredentialRow";
import { CredentialsSelect } from "./components/CredentialsSelect/CredentialsSelect";
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal/DeleteConfirmationModal";
import { HostScopedCredentialsModal } from "./components/HotScopedCredentialsModal/HotScopedCredentialsModal";
import { OAuthFlowWaitingModal } from "./components/OAuthWaitingModal/OAuthWaitingModal";
import { PasswordCredentialsModal } from "./components/PasswordCredentialsModal/PasswordCredentialsModal";
import { isSystemCredential } from "./helpers";
import { getCredentialDisplayName } from "./helpers";
import {
CredentialsInputState,
useCredentialsInput,
@@ -36,7 +37,6 @@ type Props = {
isOptional?: boolean;
showTitle?: boolean;
variant?: "default" | "node";
allowSystemCredentials?: boolean; // Allow system credentials (for settings only)
};
export function CredentialsInput({
@@ -50,7 +50,6 @@ export function CredentialsInput({
isOptional = false,
showTitle = true,
variant = "default",
allowSystemCredentials = false,
}: Props) {
const hookData = useCredentialsInput({
schema,
@@ -60,7 +59,6 @@ export function CredentialsInput({
onLoaded,
readOnly,
isOptional,
allowSystemCredentials,
});
if (!isLoaded(hookData)) {
@@ -81,22 +79,21 @@ export function CredentialsInput({
isHostScopedCredentialsModalOpen,
isOAuth2FlowInProgress,
oAuthPopupController,
credentialToDelete,
deleteCredentialsMutation,
actionButtonText,
setAPICredentialsModalOpen,
setUserPasswordCredentialsModalOpen,
setHostScopedCredentialsModalOpen,
setCredentialToDelete,
handleActionButtonClick,
handleCredentialSelect,
handleDeleteCredential,
handleDeleteConfirm,
} = hookData;
const displayName = toDisplayName(provider);
const hasCredentialsToShow = credentialsToShow.length > 0;
const selectedCredentialIsSystem =
selectedCredential && isSystemCredential(selectedCredential);
if (readOnly && selectedCredentialIsSystem) {
return null;
}
return (
<div className={cn("mb-6", className)}>
@@ -140,6 +137,15 @@ export function CredentialsInput({
provider={provider}
displayName={displayName}
onSelect={() => handleCredentialSelect(credential.id)}
onDelete={() =>
handleDeleteCredential({
id: credential.id,
title: getCredentialDisplayName(
credential,
displayName,
),
})
}
readOnly={readOnly}
/>
);
@@ -223,6 +229,13 @@ export function CredentialsInput({
Error: {oAuthError}
</Text>
) : null}
<DeleteConfirmationModal
credentialToDelete={credentialToDelete}
isDeleting={deleteCredentialsMutation.isPending}
onClose={() => setCredentialToDelete(null)}
onConfirm={handleDeleteConfirm}
/>
</>
)}
</div>

View File

@@ -1,11 +1,11 @@
import { Input } from "@/components/atoms/Input/Input";
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
Form,
FormDescription,
FormField,
} from "@/components/__legacy__/ui/form";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
@@ -60,10 +60,7 @@ export function APIKeyCredentialsModal({
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-2 px-2"
>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
<FormField
control={form.control}
name="apiKey"
@@ -73,7 +70,8 @@ export function APIKeyCredentialsModal({
id="apiKey"
label="API Key"
type="password"
placeholder="Enter API Key..."
placeholder="Enter API key..."
size="small"
hint={
schema.credentials_scopes ? (
<FormDescription>
@@ -100,7 +98,8 @@ export function APIKeyCredentialsModal({
id="title"
label="Name"
type="text"
placeholder="Enter a name for this API Key..."
placeholder="Enter a name for this API key..."
size="small"
{...field}
/>
)}
@@ -114,12 +113,13 @@ export function APIKeyCredentialsModal({
label="Expiration Date"
type="datetime-local"
placeholder="Select expiration date..."
size="small"
{...field}
/>
)}
/>
<Button type="submit" className="min-w-68">
Add API Key
<Button type="submit" size="small" className="min-w-68">
Save & use this API key
</Button>
</form>
</Form>

View File

@@ -26,7 +26,7 @@ type CredentialRowProps = {
provider: string;
displayName: string;
onSelect: () => void;
onDelete?: () => void;
onDelete: () => void;
readOnly?: boolean;
showCaret?: boolean;
asSelectTrigger?: boolean;
@@ -100,7 +100,7 @@ export function CredentialRow({
{showCaret && !asSelectTrigger && (
<CaretDown className="h-4 w-4 shrink-0 text-gray-400" />
)}
{!readOnly && !showCaret && !asSelectTrigger && onDelete && (
{!readOnly && !showCaret && !asSelectTrigger && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button

View File

@@ -65,7 +65,7 @@ export function CredentialsSelect({
>
<SelectTrigger
className={cn(
"h-auto min-h-12 w-full rounded-medium p-0 pr-4 shadow-none",
"h-auto min-h-12 w-full rounded-medium border-zinc-200 p-0 pr-4 shadow-none",
variant === "node" && "overflow-hidden",
)}
>
@@ -87,39 +87,6 @@ export function CredentialsSelect({
variant={variant}
/>
</SelectValue>
) : allowNone ? (
<SelectValue key="__none__" asChild>
<div
className={cn(
"flex items-center gap-3 rounded-medium border border-zinc-200 bg-white p-3 transition-colors",
variant === "node"
? "min-w-0 flex-1 overflow-hidden border-0 bg-transparent"
: "border-0 bg-transparent",
)}
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-zinc-200">
<Text
variant="body"
className="text-xs font-medium text-zinc-500"
>
</Text>
</div>
<div
className={cn(
"flex min-w-0 flex-1 flex-nowrap items-center gap-4",
variant === "node" && "overflow-hidden",
)}
>
<Text
variant="body"
className={cn("tracking-tight text-zinc-500")}
>
None (skip this credential)
</Text>
</div>
</div>
</SelectValue>
) : (
<SelectValue key="placeholder" placeholder="Select credential" />
)}

View File

@@ -100,29 +100,3 @@ export function getCredentialDisplayName(
export const OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
export const MASKED_KEY_LENGTH = 30;
export function isSystemCredential(credential: {
title?: string | null;
is_system?: boolean;
}): boolean {
if (credential.is_system === true) return true;
if (!credential.title) return false;
const titleLower = credential.title.toLowerCase();
return (
titleLower.includes("system") ||
titleLower.startsWith("use credits for") ||
titleLower.includes("use credits")
);
}
export function filterSystemCredentials<
T extends { title?: string; is_system?: boolean },
>(credentials: T[]): T[] {
return credentials.filter((cred) => !isSystemCredential(cred));
}
export function getSystemCredentials<
T extends { title?: string; is_system?: boolean },
>(credentials: T[]): T[] {
return credentials.filter((cred) => isSystemCredential(cred));
}

View File

@@ -6,11 +6,9 @@ import {
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
filterSystemCredentials,
getActionButtonText,
getSystemCredentials,
OAUTH_TIMEOUT_MS,
OAuthPopupResultMessage,
} from "./helpers";
@@ -25,7 +23,6 @@ type Params = {
onLoaded?: (loaded: boolean) => void;
readOnly?: boolean;
isOptional?: boolean;
allowSystemCredentials?: boolean; // Allow system credentials (for settings only)
};
export function useCredentialsInput({
@@ -36,7 +33,6 @@ export function useCredentialsInput({
onLoaded,
readOnly = false,
isOptional = false,
allowSystemCredentials = false,
}: Params) {
const [isAPICredentialsModalOpen, setAPICredentialsModalOpen] =
useState(false);
@@ -58,7 +54,6 @@ export function useCredentialsInput({
const api = useBackendAPI();
const queryClient = useQueryClient();
const credentials = useCredentials(schema, siblingInputs);
const hasAttemptedAutoSelect = useRef(false);
const deleteCredentialsMutation = useDeleteV1DeleteCredentials({
mutation: {
@@ -87,22 +82,13 @@ export function useCredentialsInput({
useEffect(() => {
if (readOnly) return;
if (!credentials || !("savedCredentials" in credentials)) return;
const availableCreds = allowSystemCredentials
? credentials.savedCredentials
: filterSystemCredentials(credentials.savedCredentials);
if (
selectedCredential &&
!availableCreds.some((c) => c.id === selectedCredential.id)
!credentials.savedCredentials.some((c) => c.id === selectedCredential.id)
) {
onSelectCredential(undefined);
}
}, [
credentials,
selectedCredential,
onSelectCredential,
readOnly,
allowSystemCredentials,
]);
}, [credentials, selectedCredential, onSelectCredential, readOnly]);
// The available credential, if there is only one
const singleCredential = useMemo(() => {
@@ -110,111 +96,24 @@ export function useCredentialsInput({
return null;
}
const credsToUse = allowSystemCredentials
? credentials.savedCredentials
: filterSystemCredentials(credentials.savedCredentials);
return credsToUse.length === 1 ? credsToUse[0] : null;
}, [credentials, allowSystemCredentials]);
return credentials.savedCredentials.length === 1
? credentials.savedCredentials[0]
: null;
}, [credentials]);
// Auto-select the one available credential
// Prioritize system credentials if available
// For system credentials, always auto-select even if optional (they should be used by default)
// Auto-select the one available credential (only if not optional)
useEffect(() => {
if (readOnly) return;
if (!credentials || !("savedCredentials" in credentials)) return;
// Early return if already selected to prevent infinite loops
const currentSelectedId = selectedCredential?.id;
if (currentSelectedId) {
hasAttemptedAutoSelect.current = true;
return;
}
// If selectedCredential is explicitly undefined and isOptional is true,
// don't auto-select - this could mean "None" was explicitly selected
// The parent component should handle setting the initial value
if (selectedCredential === undefined && isOptional) {
// Mark as attempted to prevent auto-selection when "None" is a valid choice
hasAttemptedAutoSelect.current = true;
return;
}
// Only attempt auto-selection once per credential load
if (hasAttemptedAutoSelect.current) return;
const supportedTypes = schema.credentials_types || [];
const requiredScopes = schema.credentials_scopes;
const savedCreds = credentials.savedCredentials;
const systemCreds = getSystemCredentials(savedCreds);
// Filter system credentials by type and scopes (same logic as useCredentials)
const matchingSystemCreds = systemCreds.filter((cred) => {
// Check type match
if (!supportedTypes.includes(cred.type)) {
return false;
}
// For OAuth2 credentials, check scopes
if (
cred.type === "oauth2" &&
requiredScopes &&
requiredScopes.length > 0
) {
const grantedScopes = new Set(cred.scopes || []);
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
grantedScopes,
);
if (!hasAllRequiredScopes) {
return false;
}
}
return true;
});
// First, try to auto-select system credential if available
if (matchingSystemCreds.length === 1) {
const systemCred = matchingSystemCreds[0];
const credProvider = credentials.provider;
hasAttemptedAutoSelect.current = true;
onSelectCredential({
id: systemCred.id,
type: systemCred.type,
provider: credProvider,
title: (systemCred as any).title,
});
return;
}
// Otherwise, auto-select single credential if there's only one (and not optional)
if (!isOptional && singleCredential) {
hasAttemptedAutoSelect.current = true;
if (isOptional) return; // Don't auto-select when credential is optional
if (singleCredential && !selectedCredential) {
onSelectCredential(singleCredential);
}
}, [
singleCredential?.id, // Only depend on the ID, not the whole object
selectedCredential?.id, // Only depend on the ID, not the whole object
singleCredential,
selectedCredential,
onSelectCredential,
readOnly,
isOptional,
credentials,
schema.credentials_types,
schema.credentials_scopes,
// Note: onSelectCredential removed from deps to prevent infinite loops
// It should be stable, but if it's not, the ref will prevent multiple calls
]);
// Reset the ref when credentials change significantly
useEffect(() => {
if (credentials && "savedCredentials" in credentials) {
hasAttemptedAutoSelect.current = false;
}
}, [
credentials && "savedCredentials" in credentials
? credentials.savedCredentials.length
: 0,
credentials && "savedCredentials" in credentials
? credentials.provider
: null,
]);
if (
@@ -238,11 +137,6 @@ export function useCredentialsInput({
oAuthCallback,
} = credentials;
// Filter system credentials unless explicitly allowed (for settings)
const filteredCredentials = allowSystemCredentials
? savedCredentials
: filterSystemCredentials(savedCredentials);
async function handleOAuthLogin() {
setOAuthError(null);
const { login_url, state_token } = await api.oAuthLogin(
@@ -397,7 +291,7 @@ export function useCredentialsInput({
supportsOAuth2,
supportsUserPassword,
supportsHostScoped,
credentialsToShow: filteredCredentials,
credentialsToShow: savedCredentials,
selectedCredential,
oAuthError,
isAPICredentialsModalOpen,
@@ -412,7 +306,7 @@ export function useCredentialsInput({
supportsApiKey,
supportsUserPassword,
supportsHostScoped,
filteredCredentials.length > 0,
savedCredentials.length > 0,
),
setAPICredentialsModalOpen,
setUserPasswordCredentialsModalOpen,

View File

@@ -12,7 +12,7 @@ import {
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { ScheduleAgentModal } from "../ScheduleAgentModal/ScheduleAgentModal";
import { ModalHeader } from "./components/ModalHeader/ModalHeader";
import { ModalRunSection } from "./components/ModalRunSection/ModalRunSection";
@@ -82,8 +82,6 @@ export function RunAgentModal({
});
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
const [hasOverflow, setHasOverflow] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const hasAnySetupFields =
Object.keys(agentInputFields || {}).length > 0 ||
@@ -91,43 +89,6 @@ export function RunAgentModal({
const isTriggerRunType = defaultRunType.includes("trigger");
useEffect(() => {
if (!isOpen) return;
function checkOverflow() {
if (!contentRef.current) return;
const scrollableParent = contentRef.current
.closest("[data-dialog-content]")
?.querySelector('[class*="overflow-y-auto"]');
if (scrollableParent) {
setHasOverflow(
scrollableParent.scrollHeight > scrollableParent.clientHeight,
);
}
}
const timeoutId = setTimeout(checkOverflow, 100);
const resizeObserver = new ResizeObserver(checkOverflow);
if (contentRef.current) {
const scrollableParent = contentRef.current
.closest("[data-dialog-content]")
?.querySelector('[class*="overflow-y-auto"]');
if (scrollableParent) {
resizeObserver.observe(scrollableParent);
}
}
return () => {
clearTimeout(timeoutId);
resizeObserver.disconnect();
};
}, [
isOpen,
hasAnySetupFields,
agentInputFields,
agentCredentialsInputFields,
]);
function handleInputChange(key: string, value: string) {
setInputValues((prev) => ({
...prev,
@@ -173,97 +134,91 @@ export function RunAgentModal({
>
<Dialog.Trigger>{triggerSlot}</Dialog.Trigger>
<Dialog.Content>
<div ref={contentRef} className="flex min-h-full flex-col">
<div className="flex-1">
{/* Header */}
<ModalHeader agent={agent} />
{/* Header */}
<ModalHeader agent={agent} />
{/* Content */}
{hasAnySetupFields ? (
<div className="mt-10 pb-32">
<RunAgentModalContextProvider
value={{
agent,
defaultRunType,
presetName,
setPresetName,
presetDescription,
setPresetDescription,
inputValues,
setInputValue: handleInputChange,
agentInputFields,
inputCredentials,
setInputCredentialsValue: handleCredentialsChange,
agentCredentialsInputFields,
}}
>
<ModalRunSection />
</RunAgentModalContextProvider>
</div>
) : null}
{/* Content */}
{hasAnySetupFields ? (
<div className="mt-10">
<RunAgentModalContextProvider
value={{
agent,
defaultRunType,
presetName,
setPresetName,
presetDescription,
setPresetDescription,
inputValues,
setInputValue: handleInputChange,
agentInputFields,
inputCredentials,
setInputCredentialsValue: handleCredentialsChange,
agentCredentialsInputFields,
}}
>
<ModalRunSection />
</RunAgentModalContextProvider>
</div>
) : null}
<Dialog.Footer
className={`sticky bottom-0 z-10 bg-white pt-4 ${
hasOverflow
? "border-t border-neutral-100 shadow-[0_-2px_8px_rgba(0,0,0,0.04)]"
: ""
}`}
>
<div className="flex items-center justify-end gap-3">
{isTriggerRunType ? null : !allRequiredInputsAreSet ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="secondary"
onClick={handleOpenScheduleModal}
disabled={
isExecuting ||
isSettingUpTrigger ||
!allRequiredInputsAreSet
}
>
Schedule Task
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>
Please set up all required inputs and credentials
before scheduling
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Button
variant="secondary"
onClick={handleOpenScheduleModal}
disabled={isExecuting || isSettingUpTrigger}
>
Schedule Task
</Button>
)}
<RunActions
defaultRunType={defaultRunType}
onRun={handleRun}
isExecuting={isExecuting}
isSettingUpTrigger={isSettingUpTrigger}
isRunReady={allRequiredInputsAreSet}
/>
</div>
<ScheduleAgentModal
isOpen={isScheduleModalOpen}
onClose={handleCloseScheduleModal}
agent={agent}
inputValues={inputValues}
inputCredentials={inputCredentials}
onScheduleCreated={handleScheduleCreated}
<Dialog.Footer className="mt-6 bg-white pt-4">
<div className="flex items-center justify-end gap-3">
{isTriggerRunType ? null : !allRequiredInputsAreSet ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="secondary"
onClick={handleOpenScheduleModal}
disabled={
isExecuting ||
isSettingUpTrigger ||
!allRequiredInputsAreSet
}
>
Schedule Task
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>
Please set up all required inputs and credentials before
scheduling
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Button
variant="secondary"
onClick={handleOpenScheduleModal}
disabled={
isExecuting ||
isSettingUpTrigger ||
!allRequiredInputsAreSet
}
>
Schedule Task
</Button>
)}
<RunActions
defaultRunType={defaultRunType}
onRun={handleRun}
isExecuting={isExecuting}
isSettingUpTrigger={isSettingUpTrigger}
isRunReady={allRequiredInputsAreSet}
/>
</Dialog.Footer>
</div>
</div>
<ScheduleAgentModal
isOpen={isScheduleModalOpen}
onClose={handleCloseScheduleModal}
agent={agent}
inputValues={inputValues}
inputCredentials={inputCredentials}
onScheduleCreated={handleScheduleCreated}
/>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
</>

View File

@@ -1,16 +1,6 @@
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import { Input } from "@/components/atoms/Input/Input";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
import { useContext, useMemo } from "react";
import {
NONE_CREDENTIAL_MARKER,
useAgentCredentialPreferencesStore,
} from "../../../../../stores/agentCredentialPreferencesStore";
import {
filterSystemCredentials,
isSystemCredential,
} from "../../../CredentialsInputs/helpers";
import { RunAgentInputs } from "../../../RunAgentInputs/RunAgentInputs";
import { useRunAgentModalContext } from "../../context";
import { ModalSection } from "../ModalSection/ModalSection";
@@ -32,44 +22,8 @@ export function ModalRunSection() {
agentCredentialsInputFields,
} = useRunAgentModalContext();
const allProviders = useContext(CredentialsProvidersContext);
const store = useAgentCredentialPreferencesStore();
const inputFields = Object.entries(agentInputFields || {});
// Only show credential fields that have user credentials (NOT system credentials)
// System credentials should only be shown in settings, not in run modal
const credentialFields = useMemo(() => {
if (!allProviders || !agentCredentialsInputFields) return [];
return Object.entries(agentCredentialsInputFields).filter(
([_key, schema]) => {
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
// Check if any provider has user credentials (NOT system credentials)
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const userCreds = filterSystemCredentials(
providerData.savedCredentials,
);
const matchingUserCreds = userCreds.filter((cred: { type: string }) =>
supportedTypes.includes(cred.type),
);
// If there are user credentials available, show this field
if (matchingUserCreds.length > 0) {
return true;
}
}
// Hide the field if only system credentials exist (or no credentials at all)
return false;
},
);
}, [agentCredentialsInputFields, allProviders]);
const credentialFields = Object.entries(agentCredentialsInputFields || {});
// Get the list of required credentials from the schema
const requiredCredentials = new Set(
@@ -144,113 +98,22 @@ export function ModalRunSection() {
subtitle="These are the credentials the agent will use to perform this task"
>
<div className="space-y-6">
{credentialFields
.map(([key, inputSubSchema]) => {
const selectedCred = inputCredentials?.[key];
// Check if the selected credential is a system credential
// First check the credential object itself, then look it up in providers
let isSystemCredSelected = false;
if (selectedCred) {
// Check if credential object has is_system or title indicates system credential
isSystemCredSelected = isSystemCredential(
selectedCred as { title?: string; is_system?: boolean },
);
// If not detected by title/is_system, check by looking up in providers
if (
!isSystemCredSelected &&
selectedCred.id &&
allProviders
) {
const providerNames =
inputSubSchema.credentials_provider || [];
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const systemCreds = providerData.savedCredentials.filter(
(cred: any) => cred.is_system === true,
);
if (
systemCreds.some(
(cred: any) => cred.id === selectedCred.id,
)
) {
isSystemCredSelected = true;
break;
}
}
{Object.entries(agentCredentialsInputFields || {}).map(
([key, inputSubSchema]) => (
<CredentialsInput
key={key}
schema={
{ ...inputSubSchema, discriminator: undefined } as any
}
}
// If a system credential is selected, check if there are user credentials available
// If not, hide this field entirely (it will still be used for execution)
if (isSystemCredSelected) {
const providerNames =
inputSubSchema.credentials_provider || [];
const supportedTypes = inputSubSchema.credentials_types || [];
const hasUserCreds = providerNames.some(
(providerName: string) => {
const providerData = allProviders?.[providerName];
if (!providerData) return false;
const userCreds = filterSystemCredentials(
providerData.savedCredentials,
);
return userCreds.some((cred: { type: string }) =>
supportedTypes.includes(cred.type),
);
},
);
// If no user credentials available, hide the field completely
if (!hasUserCreds) {
return null;
selectedCredentials={inputCredentials?.[key]}
onSelectCredentials={(value) =>
setInputCredentialsValue(key, value)
}
}
// If a system credential is selected but user creds exist, don't show it in the UI
// (it will still be used for execution, but user can select a user credential instead)
const credToDisplay = isSystemCredSelected
? undefined
: selectedCred;
return (
<CredentialsInput
key={key}
schema={
{ ...inputSubSchema, discriminator: undefined } as any
}
selectedCredentials={credToDisplay}
onSelectCredentials={(value) => {
// When user selects a credential, update the state and save to preferences
setInputCredentialsValue(key, value);
// Save to preferences store
if (value === undefined) {
store.setCredentialPreference(
agent.id.toString(),
key,
NONE_CREDENTIAL_MARKER,
);
} else if (value === null) {
store.setCredentialPreference(
agent.id.toString(),
key,
null,
);
} else {
store.setCredentialPreference(
agent.id.toString(),
key,
value,
);
}
}}
siblingInputs={inputValues}
isOptional={!requiredCredentials.has(key)}
/>
);
})
.filter(Boolean)}
siblingInputs={inputValues}
isOptional={!requiredCredentials.has(key)}
/>
),
)}
</div>
</ModalSection>
) : null}

View File

@@ -11,25 +11,9 @@ import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { isEmpty } from "@/lib/utils";
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
import { analytics } from "@/services/analytics";
import { useQueryClient } from "@tanstack/react-query";
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
NONE_CREDENTIAL_MARKER,
useAgentCredentialPreferencesStore,
} from "../../../stores/agentCredentialPreferencesStore";
import {
filterSystemCredentials,
getSystemCredentials,
} from "../CredentialsInputs/helpers";
import { useCallback, useEffect, useMemo, useState } from "react";
import { showExecutionErrorToast } from "./errorHelpers";
export type RunVariant =
@@ -58,10 +42,8 @@ export function useAgentRunModal(
const [inputCredentials, setInputCredentials] = useState<Record<string, any>>(
callbacks?.initialInputCredentials || {},
);
const [presetName, setPresetName] = useState<string>("");
const [presetDescription, setPresetDescription] = useState<string>("");
const hasInitializedSystemCreds = useRef(false);
// Determine the default run type based on agent capabilities
const defaultRunType: RunVariant = agent.trigger_setup_info
@@ -76,198 +58,6 @@ export function useAgentRunModal(
setInputCredentials(callbacks?.initialInputCredentials || {});
}, [callbacks?.initialInputValues, callbacks?.initialInputCredentials]);
const allProviders = useContext(CredentialsProvidersContext);
const store = useAgentCredentialPreferencesStore();
// Initialize credentials from saved preferences or default system credentials
// This ensures credentials are used even when the field is not displayed
useEffect(() => {
if (!allProviders || !agent.credentials_input_schema?.properties) return;
if (callbacks?.initialInputCredentials) {
hasInitializedSystemCreds.current = true;
return; // Don't override if initial credentials provided
}
if (hasInitializedSystemCreds.current) return; // Already initialized
const properties = agent.credentials_input_schema.properties as Record<
string,
any
>;
// Use functional update to get current state and avoid stale closures
setInputCredentials((currentCreds) => {
const credsToAdd: Record<string, any> = {};
for (const [key, schema] of Object.entries(properties)) {
// Skip if already set
if (currentCreds[key]) continue;
// First, check if user has a saved preference
const savedPreference = store.getCredentialPreference(
agent.id.toString(),
key,
);
// Check if "None" was explicitly selected (special marker)
if (savedPreference === NONE_CREDENTIAL_MARKER) {
// User explicitly selected "None" - don't add any credential
continue;
}
if (savedPreference) {
credsToAdd[key] = savedPreference;
continue;
}
// Otherwise, find default system credentials for this field
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
const requiredScopes = schema.credentials_scopes;
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const systemCreds = getSystemCredentials(
providerData.savedCredentials,
);
const matchingSystemCreds = systemCreds.filter((cred) => {
if (!supportedTypes.includes(cred.type)) return false;
// For OAuth2 credentials, check scopes
if (
cred.type === "oauth2" &&
requiredScopes &&
requiredScopes.length > 0
) {
const grantedScopes = new Set(cred.scopes || []);
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
grantedScopes,
);
if (!hasAllRequiredScopes) return false;
}
return true;
});
// If there's exactly one system credential, use it as default
if (matchingSystemCreds.length === 1) {
const systemCred = matchingSystemCreds[0];
credsToAdd[key] = {
id: systemCred.id,
type: systemCred.type,
provider: providerName,
title: systemCred.title,
};
break; // Use first matching provider
}
}
}
// Only update if we found credentials to add
if (Object.keys(credsToAdd).length > 0) {
hasInitializedSystemCreds.current = true;
return {
...currentCreds,
...credsToAdd,
};
}
return currentCreds; // No changes
});
}, [
allProviders,
agent.credentials_input_schema,
agent.id,
store,
callbacks?.initialInputCredentials,
]);
// Sync credentials with preferences store when modal opens
useEffect(() => {
if (!isOpen || !allProviders || !agent.credentials_input_schema?.properties)
return;
if (callbacks?.initialInputCredentials) return; // Don't override if initial credentials provided
const properties = agent.credentials_input_schema.properties as Record<
string,
any
>;
setInputCredentials((currentCreds) => {
const updatedCreds: Record<string, any> = { ...currentCreds };
for (const [key, schema] of Object.entries(properties)) {
const savedPreference = store.getCredentialPreference(
agent.id.toString(),
key,
);
if (savedPreference === NONE_CREDENTIAL_MARKER) {
// User explicitly selected "None" - remove from credentials
delete updatedCreds[key];
} else if (savedPreference) {
// User has a saved preference - use it
updatedCreds[key] = savedPreference;
} else if (!updatedCreds[key]) {
// No preference and no current credential - try to find default system credential
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
const requiredScopes = schema.credentials_scopes;
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const systemCreds = getSystemCredentials(
providerData.savedCredentials,
);
const matchingSystemCreds = systemCreds.filter((cred) => {
if (!supportedTypes.includes(cred.type)) return false;
if (
cred.type === "oauth2" &&
requiredScopes &&
requiredScopes.length > 0
) {
const grantedScopes = new Set(cred.scopes || []);
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
grantedScopes,
);
if (!hasAllRequiredScopes) return false;
}
return true;
});
if (matchingSystemCreds.length === 1) {
const systemCred = matchingSystemCreds[0];
updatedCreds[key] = {
id: systemCred.id,
type: systemCred.type,
provider: providerName,
title: systemCred.title,
};
break;
}
}
}
}
return updatedCreds;
});
}, [
isOpen,
agent.id,
agent.credentials_input_schema,
allProviders,
store,
callbacks?.initialInputCredentials,
]);
// Reset initialization flag when modal closes/opens or agent changes
useEffect(() => {
hasInitializedSystemCreds.current = false;
}, [isOpen, agent.graph_id]);
// API mutations
const executeGraphMutation = usePostV1ExecuteGraphAgent({
mutation: {
@@ -379,70 +169,15 @@ export function useAgentRunModal(
(agent.credentials_input_schema?.required as string[]) || [],
);
// Filter out credential fields that only have system credentials available
// System credentials should not be required in the run modal
// Also check if user has a saved preference (including NONE_MARKER)
const requiredCredentialsToCheck = [...requiredCredentials].filter(
(key) => {
// Check if user has a saved preference first
const savedPreference = store.getCredentialPreference(
agent.id.toString(),
key,
);
// If "None" was explicitly selected, don't require it
if (savedPreference === NONE_CREDENTIAL_MARKER) {
return false;
}
// If user has a saved preference, it should be checked
if (savedPreference) {
return true;
}
const schema = agentCredentialsInputFields[key];
if (!schema || !allProviders) return true; // If we can't check, include it
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
// Check if any provider has non-system credentials available
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const userCreds = filterSystemCredentials(
providerData.savedCredentials,
);
const matchingUserCreds = userCreds.filter((cred) =>
supportedTypes.includes(cred.type),
);
// If there are user credentials available, this field should be checked
if (matchingUserCreds.length > 0) {
return true;
}
}
// If only system credentials are available, exclude from required check
return false;
},
);
// Check if required credentials have valid id (not just key existence)
// A credential is valid only if it has an id field set
const missing = requiredCredentialsToCheck.filter((key) => {
const missing = [...requiredCredentials].filter((key) => {
const cred = inputCredentials[key];
return !cred || !cred.id;
});
return [missing.length === 0, missing];
}, [
agent.credentials_input_schema,
agentCredentialsInputFields,
inputCredentials,
allProviders,
agent.id,
store,
]);
}, [agent.credentials_input_schema, inputCredentials]);
const credentialsRequired = useMemo(
() => Object.keys(agentCredentialsInputFields || {}).length > 0,

View File

@@ -1,17 +1,37 @@
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { GearIcon } from "@phosphor-icons/react";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
interface Props {
agent: LibraryAgent;
onSelectSettings: () => void;
selected?: boolean;
}
export function AgentSettingsButton({
agent,
onSelectSettings,
selected,
}: Props) {
const { hasHITLBlocks } = useAgentSafeMode(agent);
if (!hasHITLBlocks) {
return null;
}
export function AgentSettingsButton() {
return (
<Button
variant="ghost"
variant={selected ? "secondary" : "ghost"}
size="small"
className="m-0 min-w-0 rounded-full p-0 px-1"
onClick={onSelectSettings}
aria-label="Agent Settings"
>
<GearIcon size={18} className="text-zinc-600" />
<Text variant="small">Agent Settings</Text>
<GearIcon
size={18}
className={selected ? "text-zinc-900" : "text-zinc-600"}
/>
</Button>
);
}

View File

@@ -1,5 +1,3 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
export function EmptySchedules() {

View File

@@ -20,7 +20,6 @@ import { useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useAgentMissingCredentials } from "../../hooks/useAgentMissingCredentials";
import { RunAgentModal } from "../modals/RunAgentModal/RunAgentModal";
import { RunDetailCard } from "../selected-views/RunDetailCard/RunDetailCard";
import { EmptyTasksIllustration } from "./EmptyTasksIllustration";
@@ -45,7 +44,6 @@ export function EmptyTasks({
const [isDeletingAgent, setIsDeletingAgent] = useState(false);
const { mutateAsync: deleteAgent } = useDeleteV2DeleteLibraryAgent();
const { hasMissingCredentials } = useAgentMissingCredentials(agent);
async function handleDeleteAgent() {
if (!agent.id) return;
@@ -126,7 +124,6 @@ export function EmptyTasks({
variant="primary"
size="large"
className="inline-flex w-[19.75rem]"
disabled={hasMissingCredentials}
>
Setup your task
</Button>

View File

@@ -1,5 +1,3 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
export function EmptyTemplates() {

View File

@@ -1,5 +1,3 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
export function EmptyTriggers() {

View File

@@ -1,5 +1,3 @@
"use client";
import { cn } from "@/lib/utils";
type Props = {

View File

@@ -1,16 +1,22 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
import { cn } from "@/lib/utils";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../helpers";
import { SelectedViewLayout } from "./SelectedViewLayout";
interface Props {
agent: LibraryAgent;
onSelectSettings?: () => void;
selectedSettings?: boolean;
}
export function LoadingSelectedContent(props: Props) {
return (
<SelectedViewLayout agent={props.agent}>
<SelectedViewLayout
agent={props.agent}
onSelectSettings={props.onSelectSettings}
selectedSettings={props.selectedSettings}
>
<div
className={cn("flex flex-col gap-4", AGENT_LIBRARY_SECTION_PADDING_X)}
>

View File

@@ -33,6 +33,8 @@ interface Props {
onSelectRun?: (id: string) => void;
onClearSelectedRun?: () => void;
banner?: React.ReactNode;
onSelectSettings?: () => void;
selectedSettings?: boolean;
}
export function SelectedRunView({
@@ -41,6 +43,8 @@ export function SelectedRunView({
onSelectRun,
onClearSelectedRun,
banner,
onSelectSettings,
selectedSettings,
}: Props) {
const { run, preset, isLoading, responseError, httpError } =
useSelectedRunView(agent.graph_id, runId);
@@ -80,7 +84,12 @@ export function SelectedRunView({
return (
<div className="flex h-full w-full gap-4">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SelectedViewLayout agent={agent} banner={banner}>
<SelectedViewLayout
agent={agent}
banner={banner}
onSelectSettings={onSelectSettings}
selectedSettings={selectedSettings}
>
<div className="flex flex-col gap-4">
<RunDetailHeader agent={agent} run={run} />

View File

@@ -21,6 +21,8 @@ interface Props {
scheduleId: string;
onClearSelectedRun?: () => void;
banner?: React.ReactNode;
onSelectSettings?: () => void;
selectedSettings?: boolean;
}
export function SelectedScheduleView({
@@ -28,6 +30,8 @@ export function SelectedScheduleView({
scheduleId,
onClearSelectedRun,
banner,
onSelectSettings,
selectedSettings,
}: Props) {
const { schedule, isLoading, error } = useSelectedScheduleView(
agent.graph_id,
@@ -72,7 +76,12 @@ export function SelectedScheduleView({
return (
<div className="flex h-full w-full gap-4">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SelectedViewLayout agent={agent} banner={banner}>
<SelectedViewLayout
agent={agent}
banner={banner}
onSelectSettings={onSelectSettings}
selectedSettings={selectedSettings}
>
<div className="flex flex-col gap-4">
<div className="flex w-full flex-col gap-0">
<RunDetailHeader

View File

@@ -1,12 +1,11 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Button } from "@/components/atoms/Button/Button";
import { Switch } from "@/components/atoms/Switch/Switch";
import { Text } from "@/components/atoms/Text/Text";
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
import { Switch } from "@/components/atoms/Switch/Switch";
import { Button } from "@/components/atoms/Button/Button";
import { ArrowLeftIcon } from "@phosphor-icons/react";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../../helpers";
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
import { SelectedViewLayout } from "../SelectedViewLayout";
import { SystemCredentialsSection } from "./components/SystemCredentialsSection";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../../helpers";
interface Props {
agent: LibraryAgent;
@@ -17,12 +16,8 @@ export function SelectedSettingsView({ agent, onClearSelectedRun }: Props) {
const { currentSafeMode, isPending, hasHITLBlocks, handleToggle } =
useAgentSafeMode(agent);
const hasCredentialsSchema =
agent.credentials_input_schema &&
Object.keys(agent.credentials_input_schema.properties || {}).length > 0;
return (
<SelectedViewLayout agent={agent}>
<SelectedViewLayout agent={agent} onSelectSettings={() => {}}>
<div className="flex flex-col gap-4">
<div
className={`${AGENT_LIBRARY_SECTION_PADDING_X} mb-8 flex items-center gap-3`}
@@ -38,8 +33,15 @@ export function SelectedSettingsView({ agent, onClearSelectedRun }: Props) {
<Text variant="h2">Agent Settings</Text>
</div>
<div className={`${AGENT_LIBRARY_SECTION_PADDING_X} space-y-6`}>
{hasHITLBlocks && (
<div className={AGENT_LIBRARY_SECTION_PADDING_X}>
{!hasHITLBlocks ? (
<div className="rounded-xl border border-zinc-100 bg-white p-6">
<Text variant="body" className="text-muted-foreground">
This agent doesn&apos;t have any human-in-the-loop blocks, so
there are no settings to configure.
</Text>
</div>
) : (
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<div className="flex w-full items-start justify-between gap-4">
<div className="flex-1">
@@ -58,16 +60,6 @@ export function SelectedSettingsView({ agent, onClearSelectedRun }: Props) {
</div>
</div>
)}
{hasCredentialsSchema && <SystemCredentialsSection agent={agent} />}
{!hasHITLBlocks && !hasCredentialsSchema && (
<div className="rounded-xl border border-zinc-100 bg-white p-6">
<Text variant="body" className="text-muted-foreground">
This agent doesn&apos;t have any configurable settings.
</Text>
</div>
)}
</div>
</div>
</SelectedViewLayout>

View File

@@ -1,99 +0,0 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
import { CredentialsMetaResponse } from "@/lib/autogpt-server-api/types";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { useEffect, useState } from "react";
import { CredentialsInput } from "../../../../components/modals/CredentialsInputs/CredentialsInputs";
import {
NONE_CREDENTIAL_MARKER,
useAgentCredentialPreferencesStore,
} from "../../../../stores/agentCredentialPreferencesStore";
interface Props {
credentialKey: string;
agentId: string;
schema: any;
systemCredential: CredentialsMetaResponse;
}
export function SystemCredentialRow({
credentialKey,
agentId,
schema,
systemCredential,
}: Props) {
const store = useAgentCredentialPreferencesStore();
// Initialize with saved preference or default to system credential
const savedPreference = store.getCredentialPreference(agentId, credentialKey);
const defaultCredential = {
id: systemCredential.id,
type: systemCredential.type,
provider: systemCredential.provider,
title: systemCredential.title,
};
// If saved preference is the NONE marker, use undefined (which CredentialsInput interprets as "None")
// Otherwise use saved preference or default
const [selectedCredential, setSelectedCredential] = useState<any>(
savedPreference === NONE_CREDENTIAL_MARKER
? undefined
: savedPreference || defaultCredential,
);
// Update when preference changes externally
useEffect(() => {
const preference = store.getCredentialPreference(agentId, credentialKey);
if (preference === NONE_CREDENTIAL_MARKER) {
setSelectedCredential(undefined);
} else if (preference) {
setSelectedCredential(preference);
} else {
setSelectedCredential(defaultCredential);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [credentialKey, agentId]);
const providerName = schema.credentials_provider?.[0] || "";
const displayName = toDisplayName(providerName);
function handleSelectCredentials(value: any) {
setSelectedCredential(value);
// Save preference:
// - undefined = explicitly selected "None" (save NONE_CREDENTIAL_MARKER)
// - null = use default system credential (fallback behavior, save null)
// - credential object = use this specific credential
if (value === undefined) {
// User explicitly selected "None" - save special marker
store.setCredentialPreference(
agentId,
credentialKey,
NONE_CREDENTIAL_MARKER,
);
} else if (value === null) {
// User cleared selection - use default system credential
store.setCredentialPreference(agentId, credentialKey, null);
} else {
// User selected a credential
store.setCredentialPreference(agentId, credentialKey, value);
}
}
return (
<div className="rounded-lg border border-zinc-100 bg-zinc-50/50 px-4 pb-2 pt-4">
<Text variant="body-medium" className="mb-2 ml-2">
{displayName}
</Text>
<CredentialsInput
schema={{ ...schema, discriminator: undefined }}
selectedCredentials={selectedCredential}
onSelectCredentials={handleSelectCredentials}
showTitle={false}
isOptional
allowSystemCredentials={true}
/>
</div>
);
}

View File

@@ -1,63 +0,0 @@
"use client";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Text } from "@/components/atoms/Text/Text";
import { useAgentSystemCredentials } from "../../../../hooks/useAgentSystemCredentials";
import { SystemCredentialRow } from "./SystemCredentialRow";
interface Props {
agent: LibraryAgent;
}
export function SystemCredentialsSection({ agent }: Props) {
const { hasSystemCredentials, systemCredentials, isLoading } =
useAgentSystemCredentials(agent);
if (isLoading) {
return (
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<Text variant="large-semibold">System Credentials</Text>
<Text variant="body" className="text-muted-foreground">
Loading credentials...
</Text>
</div>
);
}
if (!hasSystemCredentials) return null;
// Group by credential field key (from schema) to show one row per field
const credentialsByField = systemCredentials.reduce(
(acc, item) => {
if (!acc[item.key]) {
acc[item.key] = item;
}
return acc;
},
{} as Record<string, (typeof systemCredentials)[0]>,
);
return (
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<div>
<Text variant="large-semibold">System Credentials</Text>
<Text variant="body" className="mt-1 text-muted-foreground">
These credentials are managed by AutoGPT and used by the agent to
access various services. You can switch to your own credentials if
preferred.
</Text>
</div>
<div className="w-full space-y-4">
{Object.entries(credentialsByField).map(([fieldKey, item]) => (
<SystemCredentialRow
key={fieldKey}
credentialKey={fieldKey}
agentId={agent.id.toString()}
schema={item.schema}
systemCredential={item.credential}
/>
))}
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
import { AgentSettingsButton } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/AgentSettingsButton";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../helpers";
import { AgentSettingsModal } from "../modals/AgentSettingsModal/AgentSettingsModal";
import { SectionWrap } from "../other/SectionWrap";
interface Props {
@@ -9,6 +9,8 @@ interface Props {
children: React.ReactNode;
banner?: React.ReactNode;
additionalBreadcrumb?: { name: string; link?: string };
onSelectSettings?: () => void;
selectedSettings?: boolean;
}
export function SelectedViewLayout(props: Props) {
@@ -17,8 +19,8 @@ export function SelectedViewLayout(props: Props) {
<div
className={`${AGENT_LIBRARY_SECTION_PADDING_X} flex-shrink-0 border-b border-zinc-100 pb-0 lg:pb-4`}
>
{props.banner}
<div className="relative flex w-full items-center justify-between">
{props.banner && <div className="mb-4">{props.banner}</div>}
<div className="relative flex w-fit items-center gap-2">
<Breadcrumbs
items={[
{ name: "My Library", link: "/library" },
@@ -31,9 +33,15 @@ export function SelectedViewLayout(props: Props) {
: []),
]}
/>
<div className="absolute right-0">
<AgentSettingsModal agent={props.agent} />
</div>
{props.agent && props.onSelectSettings && (
<div className="absolute -right-8">
<AgentSettingsButton
agent={props.agent}
onSelectSettings={props.onSelectSettings}
selected={props.selectedSettings}
/>
</div>
)}
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-visible">

View File

@@ -1,109 +0,0 @@
"use client";
import { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { storage } from "@/services/storage/local-storage";
import { useCallback, useEffect, useState } from "react";
// Special marker to indicate "None" was explicitly selected
export const NONE_CREDENTIAL_MARKER = { __none__: true } as const;
type AgentCredentialPreferences = Record<
string,
CredentialsMetaInput | null | typeof NONE_CREDENTIAL_MARKER
>;
const STORAGE_KEY_PREFIX = "agent_credential_prefs_";
function getStorageKey(agentId: string): string {
return `${STORAGE_KEY_PREFIX}${agentId}`;
}
function loadPreferences(agentId: string): AgentCredentialPreferences {
const key = getStorageKey(agentId);
const stored = storage.get(key as any);
if (!stored) return {};
try {
const parsed = JSON.parse(stored);
// Convert serialized NONE markers back to the constant
const result: AgentCredentialPreferences = {};
for (const [key, value] of Object.entries(parsed)) {
if (
value &&
typeof value === "object" &&
"__none__" in value &&
(value as any).__none__ === true
) {
result[key] = NONE_CREDENTIAL_MARKER;
} else {
result[key] = value as CredentialsMetaInput | null;
}
}
return result;
} catch {
return {};
}
}
function savePreferences(
agentId: string,
preferences: AgentCredentialPreferences,
): void {
const key = getStorageKey(agentId);
storage.set(key as any, JSON.stringify(preferences));
}
export function useAgentCredentialPreferences(agentId: string) {
const [preferences, setPreferences] = useState<AgentCredentialPreferences>(
() => loadPreferences(agentId),
);
useEffect(() => {
const loaded = loadPreferences(agentId);
setPreferences(loaded);
}, [agentId]);
const setCredentialPreference = useCallback(
(
credentialKey: string,
credential: CredentialsMetaInput | null | typeof NONE_CREDENTIAL_MARKER,
) => {
setPreferences((prev) => {
const updated = {
...prev,
[credentialKey]: credential,
};
savePreferences(agentId, updated);
return updated;
});
},
[agentId],
);
const getCredentialPreference = useCallback(
(
credentialKey: string,
): CredentialsMetaInput | null | typeof NONE_CREDENTIAL_MARKER => {
return preferences[credentialKey] ?? null;
},
[preferences],
);
const clearPreference = useCallback(
(credentialKey: string) => {
setPreferences((prev) => {
const updated = { ...prev };
delete updated[credentialKey];
savePreferences(agentId, updated);
return updated;
});
},
[agentId],
);
return {
preferences,
setCredentialPreference,
getCredentialPreference,
clearPreference,
};
}

View File

@@ -1,105 +0,0 @@
"use client";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { useContext, useMemo } from "react";
import { getSystemCredentials } from "../components/modals/CredentialsInputs/helpers";
/**
* Hook to check if an agent is missing required SYSTEM credentials.
* This is only used to block "New Task" buttons.
* User credential validation is handled separately in RunAgentModal.
*/
export function useAgentMissingCredentials(
agent: LibraryAgent | null | undefined,
) {
const allProviders = useContext(CredentialsProvidersContext);
const result = useMemo(() => {
if (
!agent ||
!agent.id ||
!allProviders ||
!agent.credentials_input_schema?.properties
) {
return {
hasMissingCredentials: false,
missingCredentials: [],
isLoading: !allProviders || !agent,
};
}
const properties = agent.credentials_input_schema.properties as Record<
string,
any
>;
const requiredCredentials = new Set(
(agent.credentials_input_schema.required as string[]) || [],
);
const missingCredentials: Array<{
key: string;
providerDisplayName: string;
}> = [];
for (const [key, schema] of Object.entries(properties)) {
const isRequired = requiredCredentials.has(key);
if (!isRequired) continue; // Only check required credentials
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
const requiredScopes = schema.credentials_scopes;
let hasSystemCredential = false;
// Check if any provider has a system credential available
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const systemCreds = getSystemCredentials(providerData.savedCredentials);
const matchingSystemCreds = systemCreds.filter((cred) => {
if (!supportedTypes.includes(cred.type)) return false;
if (
cred.type === "oauth2" &&
requiredScopes &&
requiredScopes.length > 0
) {
const grantedScopes = new Set(cred.scopes || []);
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
grantedScopes,
);
if (!hasAllRequiredScopes) return false;
}
return true;
});
// If there's a system credential available, it's not missing
if (matchingSystemCreds.length > 0) {
hasSystemCredential = true;
break;
}
}
// If no system credential available, mark as missing
if (!hasSystemCredential) {
const providerName = providerNames[0] || "";
missingCredentials.push({
key,
providerDisplayName: toDisplayName(providerName),
});
}
}
return {
hasMissingCredentials: missingCredentials.length > 0,
missingCredentials,
isLoading: false,
};
}, [allProviders, agent?.credentials_input_schema, agent?.id]);
return result;
}

View File

@@ -1,130 +0,0 @@
"use client";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { CredentialsMetaResponse } from "@/lib/autogpt-server-api/types";
import {
CredentialsProviderData,
CredentialsProvidersContext,
} from "@/providers/agent-credentials/credentials-provider";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { useContext, useMemo } from "react";
import {
filterSystemCredentials,
getSystemCredentials,
} from "../components/modals/CredentialsInputs/helpers";
interface SystemCredentialInfo {
key: string;
provider: string;
schema: any;
credential: CredentialsMetaResponse;
}
interface MissingCredentialInfo {
key: string;
provider: string;
providerDisplayName: string;
}
interface UseAgentSystemCredentialsResult {
hasSystemCredentials: boolean;
systemCredentials: SystemCredentialInfo[];
hasMissingSystemCredentials: boolean;
missingSystemCredentials: MissingCredentialInfo[];
isLoading: boolean;
}
export function useAgentSystemCredentials(
agent: LibraryAgent,
): UseAgentSystemCredentialsResult {
const allProviders = useContext(CredentialsProvidersContext);
const result = useMemo(() => {
const empty = {
hasSystemCredentials: false,
systemCredentials: [],
hasMissingSystemCredentials: false,
missingSystemCredentials: [],
isLoading: false,
};
if (!agent.credentials_input_schema?.properties) return empty;
if (!allProviders) return { ...empty, isLoading: true };
const properties = agent.credentials_input_schema.properties as Record<
string,
any
>;
const requiredCredentials = new Set(
(agent.credentials_input_schema.required as string[]) || [],
);
const systemCredentials: SystemCredentialInfo[] = [];
const missingSystemCredentials: MissingCredentialInfo[] = [];
for (const [key, schema] of Object.entries(properties)) {
const providerNames = schema.credentials_provider || [];
const isRequired = requiredCredentials.has(key);
const supportedTypes = schema.credentials_types || [];
for (const providerName of providerNames) {
const providerData: CredentialsProviderData | undefined =
allProviders[providerName];
if (!providerData) {
// Provider not loaded yet - don't mark as missing, wait for load
continue;
}
// Check for system credentials - now backend always returns them with is_system: true
const systemCreds = getSystemCredentials(providerData.savedCredentials);
const userCreds = filterSystemCredentials(
providerData.savedCredentials,
);
const matchingSystemCreds = systemCreds.filter((cred) =>
supportedTypes.includes(cred.type),
);
const matchingUserCreds = userCreds.filter((cred) =>
supportedTypes.includes(cred.type),
);
// Add system credentials if they exist (even if not configured, backend returns them)
for (const cred of matchingSystemCreds) {
systemCredentials.push({
key,
provider: providerName,
schema,
credential: cred,
});
}
// Only mark as missing if it's required AND there are NO credentials available
// (neither system nor user). This is a true "missing" state.
// Note: We don't block based on this anymore since the run modal
// has its own validation (allRequiredInputsAreSet)
if (
isRequired &&
matchingSystemCreds.length === 0 &&
matchingUserCreds.length === 0
) {
missingSystemCredentials.push({
key,
provider: providerName,
providerDisplayName: toDisplayName(providerName),
});
}
}
}
return {
hasSystemCredentials: systemCredentials.length > 0,
systemCredentials,
hasMissingSystemCredentials: missingSystemCredentials.length > 0,
missingSystemCredentials,
isLoading: false,
};
}, [agent.credentials_input_schema, allProviders]);
return result;
}

View File

@@ -1,135 +0,0 @@
import { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { storage } from "@/services/storage/local-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
// Special marker to indicate "None" was explicitly selected
export const NONE_CREDENTIAL_MARKER = { __none__: true } as const;
type CredentialPreference =
| CredentialsMetaInput
| null
| typeof NONE_CREDENTIAL_MARKER;
type AgentCredentialPreferences = Record<string, CredentialPreference>;
interface AgentCredentialPreferencesStore {
preferences: Record<string, AgentCredentialPreferences>; // agentId -> preferences
setCredentialPreference: (
agentId: string,
credentialKey: string,
credential: CredentialPreference,
) => void;
getCredentialPreference: (
agentId: string,
credentialKey: string,
) => CredentialPreference;
clearPreference: (agentId: string, credentialKey: string) => void;
}
const STORAGE_KEY = "agent_credential_preferences";
// Custom storage adapter for localStorage
const customStorage = {
getItem: (name: string): string | null => {
return storage.get(name as any) || null;
},
setItem: (name: string, value: string): void => {
storage.set(name as any, value);
},
removeItem: (name: string): void => {
storage.clean(name as any);
},
};
export const useAgentCredentialPreferencesStore =
create<AgentCredentialPreferencesStore>()(
persist(
(set, get) => ({
preferences: {},
setCredentialPreference: (agentId, credentialKey, credential) => {
set((state) => {
const agentPrefs = state.preferences[agentId] || {};
const updated = {
...state.preferences,
[agentId]: {
...agentPrefs,
[credentialKey]: credential,
},
};
return { preferences: updated };
});
},
getCredentialPreference: (agentId, credentialKey) => {
const state = get();
const pref = state.preferences[agentId]?.[credentialKey];
// Convert serialized NONE marker back to constant
if (
pref &&
typeof pref === "object" &&
"__none__" in pref &&
(pref as any).__none__ === true &&
pref !== NONE_CREDENTIAL_MARKER
) {
return NONE_CREDENTIAL_MARKER;
}
return pref ?? null;
},
clearPreference: (agentId, credentialKey) => {
set((state) => {
const agentPrefs = state.preferences[agentId] || {};
const updated = { ...agentPrefs };
delete updated[credentialKey];
return {
preferences: {
...state.preferences,
[agentId]: updated,
},
};
});
},
}),
{
name: STORAGE_KEY,
storage: createJSONStorage(() => customStorage),
// Transform on rehydrate to convert NONE markers
onRehydrateStorage: () => (state, error) => {
if (error || !state) {
console.error("Failed to rehydrate credential preferences:", error);
return;
}
// Convert serialized NONE markers back to constant
const converted: Record<string, AgentCredentialPreferences> = {};
for (const [agentId, prefs] of Object.entries(
state.preferences || {},
)) {
const convertedPrefs: AgentCredentialPreferences = {};
for (const [key, value] of Object.entries(prefs)) {
if (
value &&
typeof value === "object" &&
"__none__" in value &&
(value as any).__none__ === true &&
value !== NONE_CREDENTIAL_MARKER
) {
convertedPrefs[key] = NONE_CREDENTIAL_MARKER;
} else {
convertedPrefs[key] = value as CredentialPreference;
}
}
converted[agentId] = convertedPrefs;
}
// Update state with converted preferences
if (
Object.keys(converted).length > 0 ||
Object.keys(state.preferences || {}).length > 0
) {
state.preferences = converted;
}
},
},
),
);

View File

@@ -6792,12 +6792,6 @@
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Host",
"description": "Host pattern for host-scoped credentials"
},
"is_system": {
"type": "boolean",
"title": "Is System",
"description": "Whether this is a system-managed credential",
"default": false
}
},
"type": "object",

View File

@@ -20,7 +20,6 @@ export function Button(props: ButtonProps) {
rightIcon,
children,
as = "button",
asChild: _asChild, // Destructure to prevent passing to DOM
...restProps
} = props;

View File

@@ -3,7 +3,7 @@
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
interface Props {
interface MarketplaceBannersProps {
hasUpdate?: boolean;
latestVersion?: number;
hasUnpublishedChanges?: boolean;
@@ -21,7 +21,7 @@ export function MarketplaceBanners({
isUpdating,
onUpdate,
onPublish,
}: Props) {
}: MarketplaceBannersProps) {
const renderUpdateBanner = () => {
if (hasUpdate && latestVersion) {
return (

View File

@@ -49,12 +49,7 @@ export function DrawerWrap({
>
{title ? (
<Drawer.Title className={drawerStyles.title}>{title}</Drawer.Title>
) : (
<span className="sr-only">
{/* Title is required for a11y compliance even if not displayed so screen readers can announce it */}
<Drawer.Title>{title}</Drawer.Title>
</span>
)}
) : null}
{!isForceOpen ? (
title ? (

View File

@@ -593,7 +593,6 @@ export type CredentialsMetaResponse = {
scopes?: Array<string>;
username?: string;
host?: string;
is_system?: boolean;
};
/* Mirror of backend/server/integrations/router.py:CredentialsDeletionResponse */

View File

@@ -1,4 +1,5 @@
import { useToastOnFail } from "@/components/molecules/Toast/use-toast";
import { createContext, useCallback, useEffect, useState } from "react";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import {
APIKeyCredentials,
CredentialsDeleteNeedConfirmationResponse,
@@ -9,9 +10,8 @@ import {
UserPasswordCredentials,
} from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { useToastOnFail } from "@/components/molecules/Toast/use-toast";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { createContext, useCallback, useEffect, useState } from "react";
type APIKeyCredentialsCreatable = Omit<
APIKeyCredentials,
@@ -72,8 +72,6 @@ export default function CredentialsProvider({
const api = useBackendAPI();
const onFailToast = useToastOnFail();
console.log("providers", providers);
const addCredentials = useCallback(
(
provider: CredentialsProviderName,
@@ -220,7 +218,17 @@ export default function CredentialsProvider({
[api, onFailToast],
);
const loadCredentials = useCallback(() => {
// Fetch provider names on mount
useEffect(() => {
api
.listProviders()
.then((names) => {
setProviderNames(names);
})
.catch(onFailToast("load provider names"));
}, [api, onFailToast]);
useEffect(() => {
if (!isLoggedIn || providerNames.length === 0) {
if (isLoggedIn == false) setProviders({});
return;
@@ -280,20 +288,6 @@ export default function CredentialsProvider({
onFailToast,
]);
// Fetch provider names on mount
useEffect(() => {
api
.listProviders()
.then((names) => {
setProviderNames(names);
})
.catch(onFailToast("Load provider names"));
}, [api, onFailToast]);
useEffect(() => {
loadCredentials();
}, [loadCredentials]);
return (
<CredentialsProvidersContext.Provider value={providers}>
{children}