mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-02-05 04:15:08 -05:00
Compare commits
5 Commits
feat/text-
...
fix/sentry
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d58df37238 | ||
|
|
4f908d5cb3 | ||
|
|
9c41512944 | ||
|
|
c1aa684743 | ||
|
|
7e5b84cc5c |
@@ -8,7 +8,12 @@ from backend.api.features.library import model as library_model
|
|||||||
from backend.api.features.store import db as store_db
|
from backend.api.features.store import db as store_db
|
||||||
from backend.data import graph as graph_db
|
from backend.data import graph as graph_db
|
||||||
from backend.data.graph import GraphModel
|
from backend.data.graph import GraphModel
|
||||||
from backend.data.model import Credentials, CredentialsFieldInfo, CredentialsMetaInput
|
from backend.data.model import (
|
||||||
|
CredentialsFieldInfo,
|
||||||
|
CredentialsMetaInput,
|
||||||
|
HostScopedCredentials,
|
||||||
|
OAuth2Credentials,
|
||||||
|
)
|
||||||
from backend.integrations.creds_manager import IntegrationCredentialsManager
|
from backend.integrations.creds_manager import IntegrationCredentialsManager
|
||||||
from backend.util.exceptions import NotFoundError
|
from backend.util.exceptions import NotFoundError
|
||||||
|
|
||||||
@@ -273,7 +278,14 @@ async def match_user_credentials_to_graph(
|
|||||||
for cred in available_creds
|
for cred in available_creds
|
||||||
if cred.provider in credential_requirements.provider
|
if cred.provider in credential_requirements.provider
|
||||||
and cred.type in credential_requirements.supported_types
|
and cred.type in credential_requirements.supported_types
|
||||||
and _credential_has_required_scopes(cred, credential_requirements)
|
and (
|
||||||
|
cred.type != "oauth2"
|
||||||
|
or _credential_has_required_scopes(cred, credential_requirements)
|
||||||
|
)
|
||||||
|
and (
|
||||||
|
cred.type != "host_scoped"
|
||||||
|
or _credential_is_for_host(cred, credential_requirements)
|
||||||
|
)
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -318,19 +330,10 @@ async def match_user_credentials_to_graph(
|
|||||||
|
|
||||||
|
|
||||||
def _credential_has_required_scopes(
|
def _credential_has_required_scopes(
|
||||||
credential: Credentials,
|
credential: OAuth2Credentials,
|
||||||
requirements: CredentialsFieldInfo,
|
requirements: CredentialsFieldInfo,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""Check if an OAuth2 credential has all the scopes required by the input."""
|
||||||
Check if a credential has all the scopes required by the block.
|
|
||||||
|
|
||||||
For OAuth2 credentials, verifies that the credential's scopes are a superset
|
|
||||||
of the required scopes. For other credential types, returns True (no scope check).
|
|
||||||
"""
|
|
||||||
# Only OAuth2 credentials have scopes to check
|
|
||||||
if credential.type != "oauth2":
|
|
||||||
return True
|
|
||||||
|
|
||||||
# If no scopes are required, any credential matches
|
# If no scopes are required, any credential matches
|
||||||
if not requirements.required_scopes:
|
if not requirements.required_scopes:
|
||||||
return True
|
return True
|
||||||
@@ -339,6 +342,22 @@ def _credential_has_required_scopes(
|
|||||||
return set(credential.scopes).issuperset(requirements.required_scopes)
|
return set(credential.scopes).issuperset(requirements.required_scopes)
|
||||||
|
|
||||||
|
|
||||||
|
def _credential_is_for_host(
|
||||||
|
credential: HostScopedCredentials,
|
||||||
|
requirements: CredentialsFieldInfo,
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a host-scoped credential matches the host required by the input."""
|
||||||
|
# We need to know the host to match host-scoped credentials to.
|
||||||
|
# Graph.aggregate_credentials_inputs() adds the node's set URL value (if any)
|
||||||
|
# to discriminator_values. No discriminator_values -> no host to match against.
|
||||||
|
if not requirements.discriminator_values:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check that credential host matches required host.
|
||||||
|
# Host-scoped credential inputs are grouped by host, so any item from the set works.
|
||||||
|
return credential.matches_url(list(requirements.discriminator_values)[0])
|
||||||
|
|
||||||
|
|
||||||
async def check_user_has_required_credentials(
|
async def check_user_has_required_credentials(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
required_credentials: list[CredentialsMetaInput],
|
required_credentials: list[CredentialsMetaInput],
|
||||||
|
|||||||
@@ -162,8 +162,16 @@ class LinearClient:
|
|||||||
"searchTerm": team_name,
|
"searchTerm": team_name,
|
||||||
}
|
}
|
||||||
|
|
||||||
team_id = await self.query(query, variables)
|
result = await self.query(query, variables)
|
||||||
return team_id["teams"]["nodes"][0]["id"]
|
nodes = result["teams"]["nodes"]
|
||||||
|
|
||||||
|
if not nodes:
|
||||||
|
raise LinearAPIException(
|
||||||
|
f"Team '{team_name}' not found. Check the team name or key and try again.",
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nodes[0]["id"]
|
||||||
except LinearAPIException as e:
|
except LinearAPIException as e:
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
@@ -240,17 +248,44 @@ class LinearClient:
|
|||||||
except LinearAPIException as e:
|
except LinearAPIException as e:
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
async def try_search_issues(self, term: str) -> list[Issue]:
|
async def try_search_issues(
|
||||||
|
self,
|
||||||
|
term: str,
|
||||||
|
max_results: int = 10,
|
||||||
|
team_id: str | None = None,
|
||||||
|
) -> list[Issue]:
|
||||||
try:
|
try:
|
||||||
query = """
|
query = """
|
||||||
query SearchIssues($term: String!, $includeComments: Boolean!) {
|
query SearchIssues(
|
||||||
searchIssues(term: $term, includeComments: $includeComments) {
|
$term: String!,
|
||||||
|
$first: Int,
|
||||||
|
$teamId: String
|
||||||
|
) {
|
||||||
|
searchIssues(
|
||||||
|
term: $term,
|
||||||
|
first: $first,
|
||||||
|
teamId: $teamId
|
||||||
|
) {
|
||||||
nodes {
|
nodes {
|
||||||
id
|
id
|
||||||
identifier
|
identifier
|
||||||
title
|
title
|
||||||
description
|
description
|
||||||
priority
|
priority
|
||||||
|
createdAt
|
||||||
|
state {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
type
|
||||||
|
}
|
||||||
|
project {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
assignee {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,7 +293,8 @@ class LinearClient:
|
|||||||
|
|
||||||
variables: dict[str, Any] = {
|
variables: dict[str, Any] = {
|
||||||
"term": term,
|
"term": term,
|
||||||
"includeComments": True,
|
"first": max_results,
|
||||||
|
"teamId": team_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
issues = await self.query(query, variables)
|
issues = await self.query(query, variables)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from ._config import (
|
|||||||
LinearScope,
|
LinearScope,
|
||||||
linear,
|
linear,
|
||||||
)
|
)
|
||||||
from .models import CreateIssueResponse, Issue
|
from .models import CreateIssueResponse, Issue, State
|
||||||
|
|
||||||
|
|
||||||
class LinearCreateIssueBlock(Block):
|
class LinearCreateIssueBlock(Block):
|
||||||
@@ -135,9 +135,20 @@ class LinearSearchIssuesBlock(Block):
|
|||||||
description="Linear credentials with read permissions",
|
description="Linear credentials with read permissions",
|
||||||
required_scopes={LinearScope.READ},
|
required_scopes={LinearScope.READ},
|
||||||
)
|
)
|
||||||
|
max_results: int = SchemaField(
|
||||||
|
description="Maximum number of results to return",
|
||||||
|
default=10,
|
||||||
|
ge=1,
|
||||||
|
le=100,
|
||||||
|
)
|
||||||
|
team_name: str | None = SchemaField(
|
||||||
|
description="Optional team name to filter results (e.g., 'Internal', 'Open Source')",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|
||||||
class Output(BlockSchemaOutput):
|
class Output(BlockSchemaOutput):
|
||||||
issues: list[Issue] = SchemaField(description="List of issues")
|
issues: list[Issue] = SchemaField(description="List of issues")
|
||||||
|
error: str = SchemaField(description="Error message if the search failed")
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -145,8 +156,11 @@ class LinearSearchIssuesBlock(Block):
|
|||||||
description="Searches for issues on Linear",
|
description="Searches for issues on Linear",
|
||||||
input_schema=self.Input,
|
input_schema=self.Input,
|
||||||
output_schema=self.Output,
|
output_schema=self.Output,
|
||||||
|
categories={BlockCategory.PRODUCTIVITY, BlockCategory.ISSUE_TRACKING},
|
||||||
test_input={
|
test_input={
|
||||||
"term": "Test issue",
|
"term": "Test issue",
|
||||||
|
"max_results": 10,
|
||||||
|
"team_name": None,
|
||||||
"credentials": TEST_CREDENTIALS_INPUT_OAUTH,
|
"credentials": TEST_CREDENTIALS_INPUT_OAUTH,
|
||||||
},
|
},
|
||||||
test_credentials=TEST_CREDENTIALS_OAUTH,
|
test_credentials=TEST_CREDENTIALS_OAUTH,
|
||||||
@@ -156,10 +170,14 @@ class LinearSearchIssuesBlock(Block):
|
|||||||
[
|
[
|
||||||
Issue(
|
Issue(
|
||||||
id="abc123",
|
id="abc123",
|
||||||
identifier="abc123",
|
identifier="TST-123",
|
||||||
title="Test issue",
|
title="Test issue",
|
||||||
description="Test description",
|
description="Test description",
|
||||||
priority=1,
|
priority=1,
|
||||||
|
state=State(
|
||||||
|
id="state1", name="In Progress", type="started"
|
||||||
|
),
|
||||||
|
createdAt="2026-01-15T10:00:00.000Z",
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -168,10 +186,12 @@ class LinearSearchIssuesBlock(Block):
|
|||||||
"search_issues": lambda *args, **kwargs: [
|
"search_issues": lambda *args, **kwargs: [
|
||||||
Issue(
|
Issue(
|
||||||
id="abc123",
|
id="abc123",
|
||||||
identifier="abc123",
|
identifier="TST-123",
|
||||||
title="Test issue",
|
title="Test issue",
|
||||||
description="Test description",
|
description="Test description",
|
||||||
priority=1,
|
priority=1,
|
||||||
|
state=State(id="state1", name="In Progress", type="started"),
|
||||||
|
createdAt="2026-01-15T10:00:00.000Z",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -181,10 +201,22 @@ class LinearSearchIssuesBlock(Block):
|
|||||||
async def search_issues(
|
async def search_issues(
|
||||||
credentials: OAuth2Credentials | APIKeyCredentials,
|
credentials: OAuth2Credentials | APIKeyCredentials,
|
||||||
term: str,
|
term: str,
|
||||||
|
max_results: int = 10,
|
||||||
|
team_name: str | None = None,
|
||||||
) -> list[Issue]:
|
) -> list[Issue]:
|
||||||
client = LinearClient(credentials=credentials)
|
client = LinearClient(credentials=credentials)
|
||||||
response: list[Issue] = await client.try_search_issues(term=term)
|
|
||||||
return response
|
# Resolve team name to ID if provided
|
||||||
|
# Raises LinearAPIException with descriptive message if team not found
|
||||||
|
team_id: str | None = None
|
||||||
|
if team_name:
|
||||||
|
team_id = await client.try_get_team_by_name(team_name=team_name)
|
||||||
|
|
||||||
|
return await client.try_search_issues(
|
||||||
|
term=term,
|
||||||
|
max_results=max_results,
|
||||||
|
team_id=team_id,
|
||||||
|
)
|
||||||
|
|
||||||
async def run(
|
async def run(
|
||||||
self,
|
self,
|
||||||
@@ -196,7 +228,10 @@ class LinearSearchIssuesBlock(Block):
|
|||||||
"""Execute the issue search"""
|
"""Execute the issue search"""
|
||||||
try:
|
try:
|
||||||
issues = await self.search_issues(
|
issues = await self.search_issues(
|
||||||
credentials=credentials, term=input_data.term
|
credentials=credentials,
|
||||||
|
term=input_data.term,
|
||||||
|
max_results=input_data.max_results,
|
||||||
|
team_name=input_data.team_name,
|
||||||
)
|
)
|
||||||
yield "issues", issues
|
yield "issues", issues
|
||||||
except LinearAPIException as e:
|
except LinearAPIException as e:
|
||||||
|
|||||||
@@ -36,12 +36,21 @@ class Project(BaseModel):
|
|||||||
content: str | None = None
|
content: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class State(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
type: str | None = (
|
||||||
|
None # Workflow state type (e.g., "triage", "backlog", "started", "completed", "canceled")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Issue(BaseModel):
|
class Issue(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
identifier: str
|
identifier: str
|
||||||
title: str
|
title: str
|
||||||
description: str | None
|
description: str | None
|
||||||
priority: int
|
priority: int
|
||||||
|
state: State | None = None
|
||||||
project: Project | None = None
|
project: Project | None = None
|
||||||
createdAt: str | None = None
|
createdAt: str | None = None
|
||||||
comments: list[Comment] | None = None
|
comments: list[Comment] | None = None
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from typing import (
|
|||||||
cast,
|
cast,
|
||||||
get_args,
|
get_args,
|
||||||
)
|
)
|
||||||
from urllib.parse import urlparse
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from prisma.enums import CreditTransactionType, OnboardingStep
|
from prisma.enums import CreditTransactionType, OnboardingStep
|
||||||
@@ -42,6 +41,7 @@ from typing_extensions import TypedDict
|
|||||||
|
|
||||||
from backend.integrations.providers import ProviderName
|
from backend.integrations.providers import ProviderName
|
||||||
from backend.util.json import loads as json_loads
|
from backend.util.json import loads as json_loads
|
||||||
|
from backend.util.request import parse_url
|
||||||
from backend.util.settings import Secrets
|
from backend.util.settings import Secrets
|
||||||
|
|
||||||
# Type alias for any provider name (including custom ones)
|
# Type alias for any provider name (including custom ones)
|
||||||
@@ -397,19 +397,25 @@ class HostScopedCredentials(_BaseCredentials):
|
|||||||
def matches_url(self, url: str) -> bool:
|
def matches_url(self, url: str) -> bool:
|
||||||
"""Check if this credential should be applied to the given URL."""
|
"""Check if this credential should be applied to the given URL."""
|
||||||
|
|
||||||
parsed_url = urlparse(url)
|
request_host, request_port = _extract_host_from_url(url)
|
||||||
# Extract hostname without port
|
cred_scope_host, cred_scope_port = _extract_host_from_url(self.host)
|
||||||
request_host = parsed_url.hostname
|
|
||||||
if not request_host:
|
if not request_host:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Simple host matching - exact match or wildcard subdomain match
|
# If a port is specified in credential host, the request host port must match
|
||||||
if self.host == request_host:
|
if cred_scope_port is not None and request_port != cred_scope_port:
|
||||||
|
return False
|
||||||
|
# Non-standard ports are only allowed if explicitly specified in credential host
|
||||||
|
elif cred_scope_port is None and request_port not in (80, 443, None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Simple host matching
|
||||||
|
if cred_scope_host == request_host:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Support wildcard matching (e.g., "*.example.com" matches "api.example.com")
|
# Support wildcard matching (e.g., "*.example.com" matches "api.example.com")
|
||||||
if self.host.startswith("*."):
|
if cred_scope_host.startswith("*."):
|
||||||
domain = self.host[2:] # Remove "*."
|
domain = cred_scope_host[2:] # Remove "*."
|
||||||
return request_host.endswith(f".{domain}") or request_host == domain
|
return request_host.endswith(f".{domain}") or request_host == domain
|
||||||
|
|
||||||
return False
|
return False
|
||||||
@@ -551,13 +557,13 @@ class CredentialsMetaInput(BaseModel, Generic[CP, CT]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _extract_host_from_url(url: str) -> str:
|
def _extract_host_from_url(url: str) -> tuple[str, int | None]:
|
||||||
"""Extract host from URL for grouping host-scoped credentials."""
|
"""Extract host and port from URL for grouping host-scoped credentials."""
|
||||||
try:
|
try:
|
||||||
parsed = urlparse(url)
|
parsed = parse_url(url)
|
||||||
return parsed.hostname or url
|
return parsed.hostname or url, parsed.port
|
||||||
except Exception:
|
except Exception:
|
||||||
return ""
|
return "", None
|
||||||
|
|
||||||
|
|
||||||
class CredentialsFieldInfo(BaseModel, Generic[CP, CT]):
|
class CredentialsFieldInfo(BaseModel, Generic[CP, CT]):
|
||||||
@@ -606,7 +612,7 @@ class CredentialsFieldInfo(BaseModel, Generic[CP, CT]):
|
|||||||
providers = frozenset(
|
providers = frozenset(
|
||||||
[cast(CP, "http")]
|
[cast(CP, "http")]
|
||||||
+ [
|
+ [
|
||||||
cast(CP, _extract_host_from_url(str(value)))
|
cast(CP, parse_url(str(value)).netloc)
|
||||||
for value in field.discriminator_values
|
for value in field.discriminator_values
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -79,10 +79,23 @@ class TestHostScopedCredentials:
|
|||||||
headers={"Authorization": SecretStr("Bearer token")},
|
headers={"Authorization": SecretStr("Bearer token")},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert creds.matches_url("http://localhost:8080/api/v1")
|
# Non-standard ports require explicit port in credential host
|
||||||
|
assert not creds.matches_url("http://localhost:8080/api/v1")
|
||||||
assert creds.matches_url("https://localhost:443/secure/endpoint")
|
assert creds.matches_url("https://localhost:443/secure/endpoint")
|
||||||
assert creds.matches_url("http://localhost/simple")
|
assert creds.matches_url("http://localhost/simple")
|
||||||
|
|
||||||
|
def test_matches_url_with_explicit_port(self):
|
||||||
|
"""Test URL matching with explicit port in credential host."""
|
||||||
|
creds = HostScopedCredentials(
|
||||||
|
provider="custom",
|
||||||
|
host="localhost:8080",
|
||||||
|
headers={"Authorization": SecretStr("Bearer token")},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert creds.matches_url("http://localhost:8080/api/v1")
|
||||||
|
assert not creds.matches_url("http://localhost:3000/api/v1")
|
||||||
|
assert not creds.matches_url("http://localhost/simple")
|
||||||
|
|
||||||
def test_empty_headers_dict(self):
|
def test_empty_headers_dict(self):
|
||||||
"""Test HostScopedCredentials with empty headers."""
|
"""Test HostScopedCredentials with empty headers."""
|
||||||
creds = HostScopedCredentials(
|
creds = HostScopedCredentials(
|
||||||
@@ -128,8 +141,20 @@ class TestHostScopedCredentials:
|
|||||||
("*.example.com", "https://sub.api.example.com/test", True),
|
("*.example.com", "https://sub.api.example.com/test", True),
|
||||||
("*.example.com", "https://example.com/test", True),
|
("*.example.com", "https://example.com/test", True),
|
||||||
("*.example.com", "https://example.org/test", False),
|
("*.example.com", "https://example.org/test", False),
|
||||||
("localhost", "http://localhost:3000/test", True),
|
# Non-standard ports require explicit port in credential host
|
||||||
|
("localhost", "http://localhost:3000/test", False),
|
||||||
|
("localhost:3000", "http://localhost:3000/test", True),
|
||||||
("localhost", "http://127.0.0.1:3000/test", False),
|
("localhost", "http://127.0.0.1:3000/test", False),
|
||||||
|
# IPv6 addresses (frontend stores with brackets via URL.hostname)
|
||||||
|
("[::1]", "http://[::1]/test", True),
|
||||||
|
("[::1]", "http://[::1]:80/test", True),
|
||||||
|
("[::1]", "https://[::1]:443/test", True),
|
||||||
|
("[::1]", "http://[::1]:8080/test", False), # Non-standard port
|
||||||
|
("[::1]:8080", "http://[::1]:8080/test", True),
|
||||||
|
("[::1]:8080", "http://[::1]:9090/test", False),
|
||||||
|
("[2001:db8::1]", "http://[2001:db8::1]/path", True),
|
||||||
|
("[2001:db8::1]", "https://[2001:db8::1]:443/path", True),
|
||||||
|
("[2001:db8::1]", "http://[2001:db8::ff]/path", False),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_url_matching_parametrized(self, host: str, test_url: str, expected: bool):
|
def test_url_matching_parametrized(self, host: str, test_url: str, expected: bool):
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ from pydantic import SecretStr
|
|||||||
from sentry_sdk.integrations import DidNotEnable
|
from sentry_sdk.integrations import DidNotEnable
|
||||||
from sentry_sdk.integrations.anthropic import AnthropicIntegration
|
from sentry_sdk.integrations.anthropic import AnthropicIntegration
|
||||||
from sentry_sdk.integrations.asyncio import AsyncioIntegration
|
from sentry_sdk.integrations.asyncio import AsyncioIntegration
|
||||||
|
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
||||||
|
from sentry_sdk.integrations.httpx import HttpxIntegration
|
||||||
from sentry_sdk.integrations.launchdarkly import LaunchDarklyIntegration
|
from sentry_sdk.integrations.launchdarkly import LaunchDarklyIntegration
|
||||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||||
|
|
||||||
@@ -37,6 +39,8 @@ def sentry_init():
|
|||||||
_experiments={"enable_logs": True},
|
_experiments={"enable_logs": True},
|
||||||
integrations=[
|
integrations=[
|
||||||
AsyncioIntegration(),
|
AsyncioIntegration(),
|
||||||
|
FastApiIntegration(), # Traces FastAPI requests with detailed spans
|
||||||
|
HttpxIntegration(), # Traces outgoing HTTP calls (OpenAI, external APIs)
|
||||||
LoggingIntegration(sentry_logs_level=logging.INFO),
|
LoggingIntegration(sentry_logs_level=logging.INFO),
|
||||||
AnthropicIntegration(
|
AnthropicIntegration(
|
||||||
include_prompts=False,
|
include_prompts=False,
|
||||||
|
|||||||
@@ -157,12 +157,7 @@ async def validate_url(
|
|||||||
is_trusted: Boolean indicating if the hostname is in trusted_origins
|
is_trusted: Boolean indicating if the hostname is in trusted_origins
|
||||||
ip_addresses: List of IP addresses for the host; empty if the host is trusted
|
ip_addresses: List of IP addresses for the host; empty if the host is trusted
|
||||||
"""
|
"""
|
||||||
# Canonicalize URL
|
parsed = parse_url(url)
|
||||||
url = url.strip("/ ").replace("\\", "/")
|
|
||||||
parsed = urlparse(url)
|
|
||||||
if not parsed.scheme:
|
|
||||||
url = f"http://{url}"
|
|
||||||
parsed = urlparse(url)
|
|
||||||
|
|
||||||
# Check scheme
|
# Check scheme
|
||||||
if parsed.scheme not in ALLOWED_SCHEMES:
|
if parsed.scheme not in ALLOWED_SCHEMES:
|
||||||
@@ -220,6 +215,17 @@ async def validate_url(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_url(url: str) -> URL:
|
||||||
|
"""Canonicalizes and parses a URL string."""
|
||||||
|
url = url.strip("/ ").replace("\\", "/")
|
||||||
|
|
||||||
|
# Ensure scheme is present for proper parsing
|
||||||
|
if not re.match(r"[a-z0-9+.\-]+://", url):
|
||||||
|
url = f"http://{url}"
|
||||||
|
|
||||||
|
return urlparse(url)
|
||||||
|
|
||||||
|
|
||||||
def pin_url(url: URL, ip_addresses: Optional[list[str]] = None) -> URL:
|
def pin_url(url: URL, ip_addresses: Optional[list[str]] = None) -> URL:
|
||||||
"""
|
"""
|
||||||
Pins a URL to a specific IP address to prevent DNS rebinding attacks.
|
Pins a URL to a specific IP address to prevent DNS rebinding attacks.
|
||||||
|
|||||||
@@ -26,8 +26,20 @@ export function buildCopilotChatUrl(prompt: string): string {
|
|||||||
|
|
||||||
export function getQuickActions(): string[] {
|
export function getQuickActions(): string[] {
|
||||||
return [
|
return [
|
||||||
"Show me what I can automate",
|
"I don't know where to start, just ask me stuff",
|
||||||
"Design a custom workflow",
|
"I do the same thing every week and it's killing me",
|
||||||
"Help me with content creation",
|
"Help me find where I'm wasting my time",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getInputPlaceholder(width?: number) {
|
||||||
|
if (!width) return "What's your role and what eats up most of your day?";
|
||||||
|
|
||||||
|
if (width < 500) {
|
||||||
|
return "I'm a chef and I hate...";
|
||||||
|
}
|
||||||
|
if (width <= 1080) {
|
||||||
|
return "What's your role and what eats up most of your day?";
|
||||||
|
}
|
||||||
|
return "What's your role and what eats up most of your day? e.g. 'I'm a recruiter and I hate...'";
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { Text } from "@/components/atoms/Text/Text";
|
|||||||
import { Chat } from "@/components/contextual/Chat/Chat";
|
import { Chat } from "@/components/contextual/Chat/Chat";
|
||||||
import { ChatInput } from "@/components/contextual/Chat/components/ChatInput/ChatInput";
|
import { ChatInput } from "@/components/contextual/Chat/components/ChatInput/ChatInput";
|
||||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useCopilotStore } from "./copilot-page-store";
|
import { useCopilotStore } from "./copilot-page-store";
|
||||||
|
import { getInputPlaceholder } from "./helpers";
|
||||||
import { useCopilotPage } from "./useCopilotPage";
|
import { useCopilotPage } from "./useCopilotPage";
|
||||||
|
|
||||||
export default function CopilotPage() {
|
export default function CopilotPage() {
|
||||||
@@ -14,8 +16,25 @@ export default function CopilotPage() {
|
|||||||
const isInterruptModalOpen = useCopilotStore((s) => s.isInterruptModalOpen);
|
const isInterruptModalOpen = useCopilotStore((s) => s.isInterruptModalOpen);
|
||||||
const confirmInterrupt = useCopilotStore((s) => s.confirmInterrupt);
|
const confirmInterrupt = useCopilotStore((s) => s.confirmInterrupt);
|
||||||
const cancelInterrupt = useCopilotStore((s) => s.cancelInterrupt);
|
const cancelInterrupt = useCopilotStore((s) => s.cancelInterrupt);
|
||||||
|
|
||||||
|
const [inputPlaceholder, setInputPlaceholder] = useState(
|
||||||
|
getInputPlaceholder(),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
setInputPlaceholder(getInputPlaceholder(window.innerWidth));
|
||||||
|
};
|
||||||
|
|
||||||
|
handleResize();
|
||||||
|
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { greetingName, quickActions, isLoading, hasSession, initialPrompt } =
|
const { greetingName, quickActions, isLoading, hasSession, initialPrompt } =
|
||||||
state;
|
state;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
handleQuickAction,
|
handleQuickAction,
|
||||||
startChatWithPrompt,
|
startChatWithPrompt,
|
||||||
@@ -73,7 +92,7 @@ export default function CopilotPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-1 items-center justify-center overflow-y-auto bg-[#f8f8f9] px-6 py-10">
|
<div className="flex h-full flex-1 items-center justify-center overflow-y-auto bg-[#f8f8f9] px-3 py-5 md:px-6 md:py-10">
|
||||||
<div className="w-full text-center">
|
<div className="w-full text-center">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="mx-auto max-w-2xl">
|
<div className="mx-auto max-w-2xl">
|
||||||
@@ -90,25 +109,25 @@ export default function CopilotPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="mx-auto max-w-2xl">
|
<div className="mx-auto max-w-3xl">
|
||||||
<Text
|
<Text
|
||||||
variant="h3"
|
variant="h3"
|
||||||
className="mb-3 !text-[1.375rem] text-zinc-700"
|
className="mb-1 !text-[1.375rem] text-zinc-700"
|
||||||
>
|
>
|
||||||
Hey, <span className="text-violet-600">{greetingName}</span>
|
Hey, <span className="text-violet-600">{greetingName}</span>
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="h3" className="mb-8 !font-normal">
|
<Text variant="h3" className="mb-8 !font-normal">
|
||||||
What do you want to automate?
|
Tell me about your work — I'll find what to automate.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<ChatInput
|
<ChatInput
|
||||||
onSend={startChatWithPrompt}
|
onSend={startChatWithPrompt}
|
||||||
placeholder='You can search or just ask - e.g. "create a blog post outline"'
|
placeholder={inputPlaceholder}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-nowrap items-center justify-center gap-3 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
<div className="flex flex-wrap items-center justify-center gap-3 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
{quickActions.map((action) => (
|
{quickActions.map((action) => (
|
||||||
<Button
|
<Button
|
||||||
key={action}
|
key={action}
|
||||||
@@ -116,7 +135,7 @@ export default function CopilotPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => handleQuickAction(action)}
|
onClick={() => handleQuickAction(action)}
|
||||||
className="h-auto shrink-0 border-zinc-600 !px-4 !py-2 text-[1rem] text-zinc-600"
|
className="h-auto shrink-0 border-zinc-300 px-3 py-2 text-[.9rem] text-zinc-600"
|
||||||
>
|
>
|
||||||
{action}
|
{action}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessi
|
|||||||
import { Button } from "@/components/atoms/Button/Button";
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
import { Text } from "@/components/atoms/Text/Text";
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||||
import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { GlobeHemisphereEastIcon } from "@phosphor-icons/react";
|
import { GlobeHemisphereEastIcon } from "@phosphor-icons/react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -56,10 +55,6 @@ export function ChatContainer({
|
|||||||
onStreamingChange?.(isStreaming);
|
onStreamingChange?.(isStreaming);
|
||||||
}, [isStreaming, onStreamingChange]);
|
}, [isStreaming, onStreamingChange]);
|
||||||
|
|
||||||
const breakpoint = useBreakpoint();
|
|
||||||
const isMobile =
|
|
||||||
breakpoint === "base" || breakpoint === "sm" || breakpoint === "md";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -127,11 +122,7 @@ export function ChatContainer({
|
|||||||
disabled={isStreaming || !sessionId}
|
disabled={isStreaming || !sessionId}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
onStop={stopStreaming}
|
onStop={stopStreaming}
|
||||||
placeholder={
|
placeholder="What else can I help with?"
|
||||||
isMobile
|
|
||||||
? "You can search or just ask"
|
|
||||||
: 'You can search or just ask — e.g. "create a blog post outline"'
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -74,19 +74,20 @@ export function ChatInput({
|
|||||||
hasMultipleLines ? "rounded-xlarge" : "rounded-full",
|
hasMultipleLines ? "rounded-xlarge" : "rounded-full",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{!value && !isRecording && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 top-0.5 flex items-center justify-start pl-14 text-[1rem] text-zinc-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{isTranscribing ? "Transcribing..." : placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<textarea
|
<textarea
|
||||||
id={inputId}
|
id={inputId}
|
||||||
aria-label="Chat message input"
|
aria-label="Chat message input"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={
|
|
||||||
isTranscribing
|
|
||||||
? "Transcribing..."
|
|
||||||
: isRecording
|
|
||||||
? ""
|
|
||||||
: placeholder
|
|
||||||
}
|
|
||||||
disabled={isInputDisabled}
|
disabled={isInputDisabled}
|
||||||
rows={1}
|
rows={1}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -122,13 +123,14 @@ export function ChatInput({
|
|||||||
size="icon"
|
size="icon"
|
||||||
aria-label={isRecording ? "Stop recording" : "Start recording"}
|
aria-label={isRecording ? "Stop recording" : "Start recording"}
|
||||||
onClick={toggleRecording}
|
onClick={toggleRecording}
|
||||||
disabled={disabled || isTranscribing}
|
disabled={disabled || isTranscribing || isStreaming}
|
||||||
className={cn(
|
className={cn(
|
||||||
isRecording
|
isRecording
|
||||||
? "animate-pulse border-red-500 bg-red-500 text-white hover:border-red-600 hover:bg-red-600"
|
? "animate-pulse border-red-500 bg-red-500 text-white hover:border-red-600 hover:bg-red-600"
|
||||||
: isTranscribing
|
: isTranscribing
|
||||||
? "border-zinc-300 bg-zinc-100 text-zinc-400"
|
? "border-zinc-300 bg-zinc-100 text-zinc-400"
|
||||||
: "border-zinc-300 bg-white text-zinc-500 hover:border-zinc-400 hover:bg-zinc-50 hover:text-zinc-700",
|
: "border-zinc-300 bg-white text-zinc-500 hover:border-zinc-400 hover:bg-zinc-50 hover:text-zinc-700",
|
||||||
|
isStreaming && "opacity-40",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isTranscribing ? (
|
{isTranscribing ? (
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ export function AudioWaveform({
|
|||||||
// Create audio context and analyser
|
// Create audio context and analyser
|
||||||
const audioContext = new AudioContext();
|
const audioContext = new AudioContext();
|
||||||
const analyser = audioContext.createAnalyser();
|
const analyser = audioContext.createAnalyser();
|
||||||
analyser.fftSize = 512;
|
analyser.fftSize = 256;
|
||||||
analyser.smoothingTimeConstant = 0.8;
|
analyser.smoothingTimeConstant = 0.3;
|
||||||
|
|
||||||
// Connect the stream to the analyser
|
// Connect the stream to the analyser
|
||||||
const source = audioContext.createMediaStreamSource(stream);
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
@@ -73,10 +73,11 @@ export function AudioWaveform({
|
|||||||
maxAmplitude = Math.max(maxAmplitude, amplitude);
|
maxAmplitude = Math.max(maxAmplitude, amplitude);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map amplitude (0-128) to bar height
|
// Normalize amplitude (0-128 range) to 0-1
|
||||||
const normalized = (maxAmplitude / 128) * 255;
|
const normalized = maxAmplitude / 128;
|
||||||
const height =
|
// Apply sensitivity boost (multiply by 4) and use sqrt curve to amplify quiet sounds
|
||||||
minBarHeight + (normalized / 255) * (maxBarHeight - minBarHeight);
|
const boosted = Math.min(1, Math.sqrt(normalized) * 4);
|
||||||
|
const height = minBarHeight + boosted * (maxBarHeight - minBarHeight);
|
||||||
newBars.push(height);
|
newBars.push(height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export function useVoiceRecording({
|
|||||||
[value, isTranscribing, toggleRecording, baseHandleKeyDown],
|
[value, isTranscribing, toggleRecording, baseHandleKeyDown],
|
||||||
);
|
);
|
||||||
|
|
||||||
const showMicButton = isSupported && !isStreaming;
|
const showMicButton = isSupported;
|
||||||
const isInputDisabled = disabled || isStreaming || isTranscribing;
|
const isInputDisabled = disabled || isStreaming || isTranscribing;
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
|
|||||||
@@ -41,7 +41,17 @@ export function HostScopedCredentialsModal({
|
|||||||
const currentHost = currentUrl ? getHostFromUrl(currentUrl) : "";
|
const currentHost = currentUrl ? getHostFromUrl(currentUrl) : "";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
host: z.string().min(1, "Host is required"),
|
host: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Host is required")
|
||||||
|
.refine((val) => !/^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(val), {
|
||||||
|
message: "Enter only the host (e.g. api.example.com), not a full URL",
|
||||||
|
})
|
||||||
|
.refine((val) => !val.includes("/"), {
|
||||||
|
message:
|
||||||
|
"Enter only the host (e.g. api.example.com), without a trailing path. " +
|
||||||
|
"You may specify a port (e.g. api.example.com:8080) if needed.",
|
||||||
|
}),
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
headers: z.record(z.string()).optional(),
|
headers: z.record(z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ Below is a comprehensive list of all available blocks, categorized by their prim
|
|||||||
| [Get Store Agent Details](block-integrations/system/store_operations.md#get-store-agent-details) | Get detailed information about an agent from the store |
|
| [Get Store Agent Details](block-integrations/system/store_operations.md#get-store-agent-details) | Get detailed information about an agent from the store |
|
||||||
| [Get Weather Information](block-integrations/basic.md#get-weather-information) | Retrieves weather information for a specified location using OpenWeatherMap API |
|
| [Get Weather Information](block-integrations/basic.md#get-weather-information) | Retrieves weather information for a specified location using OpenWeatherMap API |
|
||||||
| [Human In The Loop](block-integrations/basic.md#human-in-the-loop) | Pause execution and wait for human approval or modification of data |
|
| [Human In The Loop](block-integrations/basic.md#human-in-the-loop) | Pause execution and wait for human approval or modification of data |
|
||||||
| [Linear Search Issues](block-integrations/linear/issues.md#linear-search-issues) | Searches for issues on Linear |
|
|
||||||
| [List Is Empty](block-integrations/basic.md#list-is-empty) | Checks if a list is empty |
|
| [List Is Empty](block-integrations/basic.md#list-is-empty) | Checks if a list is empty |
|
||||||
| [List Library Agents](block-integrations/system/library_operations.md#list-library-agents) | List all agents in your personal library |
|
| [List Library Agents](block-integrations/system/library_operations.md#list-library-agents) | List all agents in your personal library |
|
||||||
| [Note](block-integrations/basic.md#note) | A visual annotation block that displays a sticky note in the workflow editor for documentation and organization purposes |
|
| [Note](block-integrations/basic.md#note) | A visual annotation block that displays a sticky note in the workflow editor for documentation and organization purposes |
|
||||||
@@ -571,6 +570,7 @@ Below is a comprehensive list of all available blocks, categorized by their prim
|
|||||||
| [Linear Create Comment](block-integrations/linear/comment.md#linear-create-comment) | Creates a new comment on a Linear issue |
|
| [Linear Create Comment](block-integrations/linear/comment.md#linear-create-comment) | Creates a new comment on a Linear issue |
|
||||||
| [Linear Create Issue](block-integrations/linear/issues.md#linear-create-issue) | Creates a new issue on Linear |
|
| [Linear Create Issue](block-integrations/linear/issues.md#linear-create-issue) | Creates a new issue on Linear |
|
||||||
| [Linear Get Project Issues](block-integrations/linear/issues.md#linear-get-project-issues) | Gets issues from a Linear project filtered by status and assignee |
|
| [Linear Get Project Issues](block-integrations/linear/issues.md#linear-get-project-issues) | Gets issues from a Linear project filtered by status and assignee |
|
||||||
|
| [Linear Search Issues](block-integrations/linear/issues.md#linear-search-issues) | Searches for issues on Linear |
|
||||||
| [Linear Search Projects](block-integrations/linear/projects.md#linear-search-projects) | Searches for projects on Linear |
|
| [Linear Search Projects](block-integrations/linear/projects.md#linear-search-projects) | Searches for projects on Linear |
|
||||||
|
|
||||||
## Hardware
|
## Hardware
|
||||||
|
|||||||
@@ -90,9 +90,9 @@ Searches for issues on Linear
|
|||||||
|
|
||||||
### How it works
|
### How it works
|
||||||
<!-- MANUAL: how_it_works -->
|
<!-- MANUAL: how_it_works -->
|
||||||
This block searches for issues in Linear using a text query. It searches across issue titles, descriptions, and other fields to find matching issues.
|
This block searches for issues in Linear using a text query. It searches across issue titles, descriptions, and other fields to find matching issues. You can limit the number of results returned using the `max_results` parameter (default: 10, max: 100) to control token consumption and response size.
|
||||||
|
|
||||||
Returns a list of issues matching the search term.
|
Optionally filter results by team name to narrow searches to specific workspaces. If a team name is provided, the block resolves it to a team ID before searching. Returns matching issues with their state, creation date, project, and assignee information. If the search or team resolution fails, an error message is returned.
|
||||||
<!-- END MANUAL -->
|
<!-- END MANUAL -->
|
||||||
|
|
||||||
### Inputs
|
### Inputs
|
||||||
@@ -100,12 +100,14 @@ Returns a list of issues matching the search term.
|
|||||||
| Input | Description | Type | Required |
|
| Input | Description | Type | Required |
|
||||||
|-------|-------------|------|----------|
|
|-------|-------------|------|----------|
|
||||||
| term | Term to search for issues | str | Yes |
|
| term | Term to search for issues | str | Yes |
|
||||||
|
| max_results | Maximum number of results to return | int | No |
|
||||||
|
| team_name | Optional team name to filter results (e.g., 'Internal', 'Open Source') | str | No |
|
||||||
|
|
||||||
### Outputs
|
### Outputs
|
||||||
|
|
||||||
| Output | Description | Type |
|
| Output | Description | Type |
|
||||||
|--------|-------------|------|
|
|--------|-------------|------|
|
||||||
| error | Error message if the operation failed | str |
|
| error | Error message if the search failed | str |
|
||||||
| issues | List of issues | List[Issue] |
|
| issues | List of issues | List[Issue] |
|
||||||
|
|
||||||
### Possible use case
|
### Possible use case
|
||||||
|
|||||||
Reference in New Issue
Block a user