mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-05 11:06:07 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31c2fd9cc8 | |||
| 3b4698463c | |||
| a70d77ab57 | |||
| edd6cad449 | |||
| 519b7ed39f | |||
| d78546967c | |||
| 002317d00a | |||
| c08bfc8ced | |||
| 5671de49e1 | |||
| e82dce7e42 | |||
| 792fbfbe10 | |||
| 3df36dd63f | |||
| f48dc847ff | |||
| 9b57183a99 | |||
| 2cc6d10f33 |
@@ -15,7 +15,7 @@ concurrency:
|
|||||||
env:
|
env:
|
||||||
CORE_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot
|
CORE_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot
|
||||||
CLOUD_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot-cloud-core
|
CLOUD_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot-cloud-core
|
||||||
SPACE_REF: 178b1634c104e635c706e1fb4ca37cb3de073cf9
|
SPACE_REF: 58253c53933f95d81b035fbe2efedb55b6c1a82b
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ services:
|
|||||||
restart: on-failure
|
restart: on-failure
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
# Shared with the langbot service and sent only as a WebSocket handshake
|
# Optional. Leave unset on both OSS services, or set the same value on
|
||||||
# header. Generate with: openssl rand -hex 32
|
# both to protect the control WebSocket. Generate with: openssl rand -hex 32
|
||||||
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
|
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
|
||||||
# Process-wide admission for every asyncio.to_thread() call.
|
# Process-wide admission for every asyncio.to_thread() call.
|
||||||
- LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8}
|
- LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8}
|
||||||
@@ -77,8 +77,7 @@ services:
|
|||||||
restart: on-failure
|
restart: on-failure
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
# Must match langbot_plugin_runtime. Empty/missing values make the
|
# Optional. Leave unset on both OSS services, or match plugin Runtime.
|
||||||
# external control channel fail closed.
|
|
||||||
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
|
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
|
||||||
# Must match the value supplied to langbot_box. The token is sent only
|
# Must match the value supplied to langbot_box. The token is sent only
|
||||||
# in WebSocket handshake headers, never in URLs or action payloads.
|
# in WebSocket handshake headers, never in URLs or action payloads.
|
||||||
|
|||||||
+1
-1
@@ -71,7 +71,7 @@ dependencies = [
|
|||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.1.0.post3",
|
||||||
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@101e453e916b39465a6294d6471c9eaae8725d5c",
|
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@077a4185f26d19a505bacafd8fb86d6734b6344b",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"matrix-nio>=0.25.2",
|
"matrix-nio>=0.25.2",
|
||||||
|
|||||||
@@ -32,12 +32,13 @@ The `all` / `box` profile starts three services:
|
|||||||
the LangBot and Box containers. Generate it once with `openssl rand -hex 32`;
|
the LangBot and Box containers. Generate it once with `openssl rand -hex 32`;
|
||||||
never put it in `box.runtime.endpoint` or commit it to config.
|
never put it in `box.runtime.endpoint` or commit it to config.
|
||||||
|
|
||||||
Every Compose deployment also needs one
|
A Compose deployment may optionally set
|
||||||
`LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` shared by `langbot` and
|
`LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` on both `langbot` and
|
||||||
`langbot_plugin_runtime`. Generate it with `openssl rand -hex 32` and export it
|
`langbot_plugin_runtime` when port 5400 needs shared-secret protection. OSS
|
||||||
before `docker compose up`; the external Plugin Runtime fails closed when the
|
defaults to leaving it unset on both sides. If enabled, generate one value with
|
||||||
token is empty or weak. Kubernetes uses the `langbot-plugin-runtime-control`
|
`openssl rand -hex 32`; configuring only one side causes the control connection
|
||||||
Secret shown in `docker/kubernetes.yaml`.
|
to fail. Kubernetes may use the `langbot-plugin-runtime-control` Secret shown in
|
||||||
|
`docker/kubernetes.yaml`.
|
||||||
|
|
||||||
With Box off, the dashboard/skills list stays visible (read-only) but sandbox
|
With Box off, the dashboard/skills list stays visible (read-only) but sandbox
|
||||||
tools, skill add/edit, and stdio MCP are disabled. Set `box.enabled: false`
|
tools, skill add/edit, and stdio MCP are disabled. Set `box.enabled: false`
|
||||||
|
|||||||
@@ -392,8 +392,8 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
async def _(request_context: RequestContext) -> str:
|
async def _(request_context: RequestContext) -> str:
|
||||||
"""Get plugin debug information including debug URL and key"""
|
"""Get plugin debug information including debug URL and key"""
|
||||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
execution_context = await self._require_authenticated_plugin_runtime_context(request_context)
|
||||||
debug_info = await self.ap.plugin_connector.get_debug_info()
|
debug_info = await self.ap.plugin_connector.get_debug_info(execution_context)
|
||||||
|
|
||||||
# Get debug URL from config
|
# Get debug URL from config
|
||||||
plugin_config = self.ap.instance_config.data.get('plugin', {})
|
plugin_config = self.ap.instance_config.data.get('plugin', {})
|
||||||
@@ -403,6 +403,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
data={
|
data={
|
||||||
'debug_url': debug_url,
|
'debug_url': debug_url,
|
||||||
'plugin_debug_key': debug_info.get('plugin_debug_key', ''),
|
'plugin_debug_key': debug_info.get('plugin_debug_key', ''),
|
||||||
|
'expires_at': debug_info.get('expires_at', ''),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import quart
|
import quart
|
||||||
import argon2
|
import argon2
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import datetime
|
||||||
import uuid
|
import uuid
|
||||||
from urllib.parse import parse_qs, urlsplit
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
@@ -218,7 +219,22 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
try:
|
try:
|
||||||
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
|
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
|
||||||
# Exchange code for tokens
|
# Exchange code for tokens
|
||||||
token_data = await self.ap.space_service.exchange_oauth_code(code)
|
launch_workspace_uuid = consumed_state.launch_workspace_uuid
|
||||||
|
workspace_uuids = [launch_workspace_uuid] if launch_workspace_uuid else []
|
||||||
|
workspace_created_ats: dict[str, int] = {}
|
||||||
|
if not workspace_uuids and getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') != 'cloud':
|
||||||
|
binding = await self.ap.workspace_service.get_execution_binding()
|
||||||
|
workspace_uuids = [binding.workspace_uuid]
|
||||||
|
workspace_created_at = binding.workspace_created_at
|
||||||
|
if workspace_created_at is not None:
|
||||||
|
if workspace_created_at.tzinfo is None:
|
||||||
|
workspace_created_at = workspace_created_at.replace(tzinfo=datetime.UTC)
|
||||||
|
workspace_created_ats[binding.workspace_uuid] = int(workspace_created_at.timestamp())
|
||||||
|
token_data = await self.ap.space_service.exchange_oauth_code(
|
||||||
|
code,
|
||||||
|
workspace_uuids,
|
||||||
|
workspace_created_ats,
|
||||||
|
)
|
||||||
access_token = token_data.get('access_token')
|
access_token = token_data.get('access_token')
|
||||||
refresh_token = token_data.get('refresh_token')
|
refresh_token = token_data.get('refresh_token')
|
||||||
expires_in = token_data.get('expires_in', 0)
|
expires_in = token_data.get('expires_in', 0)
|
||||||
@@ -231,7 +247,6 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
access_token, refresh_token, expires_in
|
access_token, refresh_token, expires_in
|
||||||
)
|
)
|
||||||
|
|
||||||
launch_workspace_uuid = consumed_state.launch_workspace_uuid
|
|
||||||
if launch_workspace_uuid:
|
if launch_workspace_uuid:
|
||||||
try:
|
try:
|
||||||
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ class SpaceService:
|
|||||||
result_list = result.all()
|
result_list = result.all()
|
||||||
return result_list[0] if result_list else None
|
return result_list[0] if result_list else None
|
||||||
|
|
||||||
|
async def get_valid_access_token(self, user_email: str) -> str | None:
|
||||||
|
"""Return a current Space bearer, refreshing and persisting it when needed."""
|
||||||
|
return await self._ensure_valid_token(user_email)
|
||||||
|
|
||||||
async def _ensure_valid_token(self, user_email: str) -> str | None:
|
async def _ensure_valid_token(self, user_email: str) -> str | None:
|
||||||
"""Ensure access token is valid, refresh if expired. Returns valid access_token or None."""
|
"""Ensure access token is valid, refresh if expired. Returns valid access_token or None."""
|
||||||
user_obj = await self._get_user_by_email(user_email)
|
user_obj = await self._get_user_by_email(user_email)
|
||||||
@@ -117,7 +121,12 @@ class SpaceService:
|
|||||||
params['state'] = state
|
params['state'] = state
|
||||||
return f'{authorize_url}?{urlencode(params)}'
|
return f'{authorize_url}?{urlencode(params)}'
|
||||||
|
|
||||||
async def exchange_oauth_code(self, code: str) -> typing.Dict:
|
async def exchange_oauth_code(
|
||||||
|
self,
|
||||||
|
code: str,
|
||||||
|
workspace_uuids: list[str] | None = None,
|
||||||
|
workspace_created_ats: dict[str, int] | None = None,
|
||||||
|
) -> typing.Dict:
|
||||||
"""Exchange OAuth authorization code for tokens"""
|
"""Exchange OAuth authorization code for tokens"""
|
||||||
from langbot.pkg.utils import constants
|
from langbot.pkg.utils import constants
|
||||||
|
|
||||||
@@ -127,7 +136,14 @@ class SpaceService:
|
|||||||
session = httpclient.get_session()
|
session = httpclient.get_session()
|
||||||
async with session.post(
|
async with session.post(
|
||||||
f'{space_url}/api/v1/accounts/oauth/token',
|
f'{space_url}/api/v1/accounts/oauth/token',
|
||||||
json={'code': code, 'instance_id': constants.instance_id},
|
json={
|
||||||
|
'code': code,
|
||||||
|
'instance_id': constants.instance_id,
|
||||||
|
# Sending an explicit empty list tells new Space servers not to
|
||||||
|
# synthesize a legacy instance-derived Workspace binding.
|
||||||
|
'workspace_uuids': workspace_uuids if workspace_uuids is not None else [],
|
||||||
|
'workspace_created_ats': workspace_created_ats or {},
|
||||||
|
},
|
||||||
) as response:
|
) as response:
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
error = await httpclient.read_text_limited(response)
|
error = await httpclient.read_text_limited(response)
|
||||||
|
|||||||
@@ -779,8 +779,27 @@ class UserService:
|
|||||||
local_account = await self.get_user_by_email(user_email)
|
local_account = await self.get_user_by_email(user_email)
|
||||||
if local_account is None:
|
if local_account is None:
|
||||||
raise ValueError('User not found')
|
raise ValueError('User not found')
|
||||||
# Exchange code for tokens
|
# Exchange code for tokens and bind both installation and the active
|
||||||
token_data = await self.ap.space_service.exchange_oauth_code(code)
|
# OSS Workspace as independent identities.
|
||||||
|
workspace_service = getattr(self.ap, 'workspace_service', None)
|
||||||
|
if workspace_service is not None:
|
||||||
|
binding = await workspace_service.get_execution_binding()
|
||||||
|
created_at = binding.workspace_created_at
|
||||||
|
created_ts = (
|
||||||
|
int(created_at.replace(tzinfo=datetime.timezone.utc).timestamp())
|
||||||
|
if created_at.tzinfo is None
|
||||||
|
else int(created_at.timestamp())
|
||||||
|
)
|
||||||
|
token_data = await self.ap.space_service.exchange_oauth_code(
|
||||||
|
code,
|
||||||
|
[binding.workspace_uuid],
|
||||||
|
{binding.workspace_uuid: created_ts},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Compatibility for early/bootstrap call sites that have not wired
|
||||||
|
# WorkspaceService yet; old Space servers still derive the legacy
|
||||||
|
# Workspace identity from instance_id when the field is omitted.
|
||||||
|
token_data = await self.ap.space_service.exchange_oauth_code(code)
|
||||||
access_token = token_data.get('access_token')
|
access_token = token_data.get('access_token')
|
||||||
refresh_token = token_data.get('refresh_token')
|
refresh_token = token_data.get('refresh_token')
|
||||||
expires_in = token_data.get('expires_in', 0)
|
expires_in = token_data.get('expires_in', 0)
|
||||||
|
|||||||
@@ -251,6 +251,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
def _control_headers(self, *, allow_generate: bool) -> dict[str, str]:
|
def _control_headers(self, *, allow_generate: bool) -> dict[str, str]:
|
||||||
if not self._control_token and allow_generate:
|
if not self._control_token and allow_generate:
|
||||||
self._control_token = secrets.token_urlsafe(48)
|
self._control_token = secrets.token_urlsafe(48)
|
||||||
|
if not self._control_token:
|
||||||
|
return {}
|
||||||
try:
|
try:
|
||||||
self._control_token = validate_runtime_secret(
|
self._control_token = validate_runtime_secret(
|
||||||
self._control_token,
|
self._control_token,
|
||||||
@@ -1968,11 +1970,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
with runtime_handler.installation_scope(binding):
|
with runtime_handler.installation_scope(binding):
|
||||||
return await runtime_handler.handle_page_api(plugin_author, plugin_name, page_id, endpoint, method, body)
|
return await runtime_handler.handle_page_api(plugin_author, plugin_name, page_id, endpoint, method, body)
|
||||||
|
|
||||||
async def get_debug_info(self) -> dict[str, Any]:
|
async def get_debug_info(self, execution_context: ExecutionContext) -> dict[str, Any]:
|
||||||
"""Get debug information including debug key and WS URL"""
|
"""Get debug information including debug key and WS URL"""
|
||||||
if not self.is_enable_plugin or not self._runtime_available():
|
if not self.is_enable_plugin or not self._runtime_available():
|
||||||
return {}
|
return {}
|
||||||
return await self._runtime_handler().get_debug_info()
|
return await self._runtime_handler().get_debug_info(execution_context)
|
||||||
|
|
||||||
async def emit_event(
|
async def emit_event(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -1960,14 +1960,14 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def get_debug_info(self) -> dict[str, Any]:
|
async def get_debug_info(self, execution_context: ExecutionContext) -> dict[str, Any]:
|
||||||
"""Get debug information including debug key and WS URL"""
|
"""Get debug information including debug key and WS URL"""
|
||||||
with self.installation_scope(None):
|
result = await self.call_action(
|
||||||
result = await self.call_action(
|
LangBotToRuntimeAction.GET_DEBUG_INFO,
|
||||||
LangBotToRuntimeAction.GET_DEBUG_INFO,
|
{},
|
||||||
{},
|
timeout=10,
|
||||||
timeout=10,
|
action_context=execution_context,
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ================= RAG Capability Callers (LangBot -> Runtime) =================
|
# ================= RAG Capability Callers (LangBot -> Runtime) =================
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ async def build_heartbeat_payload(
|
|||||||
ap: core_app.Application,
|
ap: core_app.Application,
|
||||||
*,
|
*,
|
||||||
workspace_uuid: str,
|
workspace_uuid: str,
|
||||||
|
workspace_create_ts: int = 0,
|
||||||
workspace_resource: WorkspaceResourceSnapshot | None = None,
|
workspace_resource: WorkspaceResourceSnapshot | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Collect one anonymous Workspace profile snapshot."""
|
"""Collect one anonymous Workspace profile snapshot."""
|
||||||
@@ -212,7 +213,9 @@ async def build_heartbeat_payload(
|
|||||||
'event_type': 'instance_heartbeat',
|
'event_type': 'instance_heartbeat',
|
||||||
'query_id': '',
|
'query_id': '',
|
||||||
'version': constants.semantic_version,
|
'version': constants.semantic_version,
|
||||||
|
'instance_id': constants.instance_id,
|
||||||
'workspace_uuid': workspace_uuid,
|
'workspace_uuid': workspace_uuid,
|
||||||
|
'workspace_create_ts': workspace_create_ts,
|
||||||
'instance_create_ts': constants.instance_create_ts,
|
'instance_create_ts': constants.instance_create_ts,
|
||||||
'edition': constants.edition,
|
'edition': constants.edition,
|
||||||
'features': features,
|
'features': features,
|
||||||
@@ -220,10 +223,24 @@ async def build_heartbeat_payload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _workspace_created_timestamp(created_at: datetime | None) -> int:
|
||||||
|
if created_at is None:
|
||||||
|
return 0
|
||||||
|
if created_at.tzinfo is None:
|
||||||
|
# SQLAlchemy may return persisted UTC values without tzinfo. Never
|
||||||
|
# reinterpret them in the host's local timezone.
|
||||||
|
created_at = created_at.replace(tzinfo=timezone.utc)
|
||||||
|
return int(created_at.timestamp())
|
||||||
|
|
||||||
|
|
||||||
async def build_heartbeat_payloads(ap: core_app.Application) -> list[dict]:
|
async def build_heartbeat_payloads(ap: core_app.Application) -> list[dict]:
|
||||||
"""Build one heartbeat per active Workspace."""
|
"""Build one heartbeat per active Workspace."""
|
||||||
bindings = await ap.workspace_service.list_active_execution_bindings()
|
bindings = await ap.workspace_service.list_active_execution_bindings()
|
||||||
workspace_uuids = sorted({binding.workspace_uuid for binding in bindings})
|
workspace_uuids = sorted({binding.workspace_uuid for binding in bindings})
|
||||||
|
workspace_create_ts = {
|
||||||
|
binding.workspace_uuid: _workspace_created_timestamp(getattr(binding, 'workspace_created_at', None))
|
||||||
|
for binding in bindings
|
||||||
|
}
|
||||||
resources = {
|
resources = {
|
||||||
resource['workspace_uuid']: resource for resource in await _cloud_workspace_resource_counts(ap, bindings)
|
resource['workspace_uuid']: resource for resource in await _cloud_workspace_resource_counts(ap, bindings)
|
||||||
}
|
}
|
||||||
@@ -231,6 +248,7 @@ async def build_heartbeat_payloads(ap: core_app.Application) -> list[dict]:
|
|||||||
await build_heartbeat_payload(
|
await build_heartbeat_payload(
|
||||||
ap,
|
ap,
|
||||||
workspace_uuid=workspace_uuid,
|
workspace_uuid=workspace_uuid,
|
||||||
|
workspace_create_ts=workspace_create_ts.get(workspace_uuid, 0),
|
||||||
workspace_resource=resources.get(workspace_uuid),
|
workspace_resource=resources.get(workspace_uuid),
|
||||||
)
|
)
|
||||||
for workspace_uuid in workspace_uuids
|
for workspace_uuid in workspace_uuids
|
||||||
|
|||||||
@@ -4,13 +4,19 @@ import typing
|
|||||||
|
|
||||||
|
|
||||||
class WorkspaceExecutionContext(typing.Protocol):
|
class WorkspaceExecutionContext(typing.Protocol):
|
||||||
|
@property
|
||||||
|
def instance_uuid(self) -> str: ...
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def workspace_uuid(self) -> str: ...
|
def workspace_uuid(self) -> str: ...
|
||||||
|
|
||||||
|
|
||||||
def workspace_identity(execution_context: WorkspaceExecutionContext) -> dict[str, str]:
|
def workspace_identity(execution_context: WorkspaceExecutionContext) -> dict[str, str]:
|
||||||
"""Build the canonical telemetry identity for one Workspace execution."""
|
"""Build both first-class telemetry identities for one execution."""
|
||||||
|
instance_id = execution_context.instance_uuid.strip()
|
||||||
workspace_uuid = execution_context.workspace_uuid.strip()
|
workspace_uuid = execution_context.workspace_uuid.strip()
|
||||||
|
if not instance_id:
|
||||||
|
raise ValueError('Telemetry execution instance ID is empty')
|
||||||
if not workspace_uuid:
|
if not workspace_uuid:
|
||||||
raise ValueError('Telemetry execution Workspace UUID is empty')
|
raise ValueError('Telemetry execution Workspace UUID is empty')
|
||||||
return {'workspace_uuid': workspace_uuid}
|
return {'instance_id': instance_id, 'workspace_uuid': workspace_uuid}
|
||||||
|
|||||||
@@ -136,12 +136,31 @@ class TelemetryManager:
|
|||||||
try:
|
try:
|
||||||
# Use asyncio.wait_for to ensure we always bound the total time
|
# Use asyncio.wait_for to ensure we always bound the total time
|
||||||
telemetry_token = os.getenv('LANGBOT_TELEMETRY_INGEST_TOKEN', '').strip()
|
telemetry_token = os.getenv('LANGBOT_TELEMETRY_INGEST_TOKEN', '').strip()
|
||||||
|
headers: dict[str, str] = {}
|
||||||
if telemetry_token:
|
if telemetry_token:
|
||||||
request = client.post(
|
headers['X-LangBot-Telemetry-Token'] = telemetry_token
|
||||||
url,
|
else:
|
||||||
json=sanitized,
|
workspace_uuid = str(sanitized.get('workspace_uuid', '')).strip()
|
||||||
headers={'X-LangBot-Telemetry-Token': telemetry_token},
|
user_service = getattr(self.ap, 'user_service', None)
|
||||||
)
|
if workspace_uuid and user_service is not None:
|
||||||
|
try:
|
||||||
|
owner = await user_service.get_workspace_owner(workspace_uuid)
|
||||||
|
owner_email = str(getattr(owner, 'user', '') or '').strip()
|
||||||
|
space_service = getattr(self.ap, 'space_service', None)
|
||||||
|
access_token = (
|
||||||
|
await space_service.get_valid_access_token(owner_email)
|
||||||
|
if owner_email and space_service is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
access_token = str(access_token or '').strip()
|
||||||
|
if access_token:
|
||||||
|
headers['Authorization'] = f'Bearer {access_token}'
|
||||||
|
except Exception:
|
||||||
|
self.ap.logger.debug(
|
||||||
|
'Could not resolve authenticated telemetry reporter', exc_info=True
|
||||||
|
)
|
||||||
|
if headers:
|
||||||
|
request = client.post(url, json=sanitized, headers=headers)
|
||||||
else:
|
else:
|
||||||
request = client.post(url, json=sanitized)
|
request = client.post(url, json=sanitized)
|
||||||
resp = await asyncio.wait_for(request, timeout=10 + 1)
|
resp = await asyncio.wait_for(request, timeout=10 + 1)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
@@ -12,3 +13,4 @@ class WorkspaceExecutionBinding:
|
|||||||
placement_generation: int
|
placement_generation: int
|
||||||
write_fenced: bool
|
write_fenced: bool
|
||||||
state: str
|
state: str
|
||||||
|
workspace_created_at: datetime.datetime | None = None
|
||||||
|
|||||||
@@ -283,6 +283,7 @@ class WorkspaceService:
|
|||||||
placement_generation=execution_state.active_generation,
|
placement_generation=execution_state.active_generation,
|
||||||
write_fenced=execution_state.write_fenced,
|
write_fenced=execution_state.write_fenced,
|
||||||
state=execution_state.state,
|
state=execution_state.state,
|
||||||
|
workspace_created_at=workspace.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
binding = await self._run(operation, session=session)
|
binding = await self._run(operation, session=session)
|
||||||
|
|||||||
@@ -106,7 +106,12 @@ async def plugin_security_api(plugin_module):
|
|||||||
application.plugin_connector.require_workspace_context = AsyncMock()
|
application.plugin_connector.require_workspace_context = AsyncMock()
|
||||||
application.plugin_connector.list_plugins = AsyncMock(return_value=[raw_plugin])
|
application.plugin_connector.list_plugins = AsyncMock(return_value=[raw_plugin])
|
||||||
application.plugin_connector.get_plugin_info = AsyncMock(return_value=raw_plugin)
|
application.plugin_connector.get_plugin_info = AsyncMock(return_value=raw_plugin)
|
||||||
application.plugin_connector.get_debug_info = AsyncMock(return_value={'plugin_debug_key': 'runtime-debug-secret'})
|
application.plugin_connector.get_debug_info = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'plugin_debug_key': 'runtime-debug-secret',
|
||||||
|
'expires_at': '2026-08-04T12:00:00Z',
|
||||||
|
}
|
||||||
|
)
|
||||||
application.plugin_connector.get_plugin_logs = AsyncMock(return_value=['private runtime line'])
|
application.plugin_connector.get_plugin_logs = AsyncMock(return_value=['private runtime line'])
|
||||||
application.plugin_connector.set_plugin_config = AsyncMock()
|
application.plugin_connector.set_plugin_config = AsyncMock()
|
||||||
|
|
||||||
@@ -232,8 +237,9 @@ async def test_debug_key_requires_resource_manage_permission(plugin_security_api
|
|||||||
assert (await allowed.get_json())['data'] == {
|
assert (await allowed.get_json())['data'] == {
|
||||||
'debug_url': 'http://localhost:5401',
|
'debug_url': 'http://localhost:5401',
|
||||||
'plugin_debug_key': 'runtime-debug-secret',
|
'plugin_debug_key': 'runtime-debug-secret',
|
||||||
|
'expires_at': '2026-08-04T12:00:00Z',
|
||||||
}
|
}
|
||||||
application.plugin_connector.get_debug_info.assert_awaited_once_with()
|
application.plugin_connector.get_debug_info.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
from urllib.parse import parse_qs, urlsplit
|
from urllib.parse import parse_qs, urlsplit
|
||||||
@@ -14,6 +15,7 @@ from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
|
|||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
|
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
|
||||||
|
WORKSPACE_CREATED_AT = datetime.datetime(2026, 1, 2, 3, 4, 5, tzinfo=datetime.UTC)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -58,6 +60,12 @@ async def space_oauth_api():
|
|||||||
return_value={'account_uuid': 'account-a', 'workspace_uuid': WORKSPACE_UUID}
|
return_value={'account_uuid': 'account-a', 'workspace_uuid': WORKSPACE_UUID}
|
||||||
)
|
)
|
||||||
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access)
|
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access)
|
||||||
|
application.workspace_service.get_execution_binding = AsyncMock(
|
||||||
|
return_value=SimpleNamespace(
|
||||||
|
workspace_uuid=WORKSPACE_UUID,
|
||||||
|
workspace_created_at=WORKSPACE_CREATED_AT,
|
||||||
|
)
|
||||||
|
)
|
||||||
application.space_service.get_oauth_authorize_url = Mock(
|
application.space_service.get_oauth_authorize_url = Mock(
|
||||||
side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}'
|
side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}'
|
||||||
)
|
)
|
||||||
@@ -234,7 +242,11 @@ async def test_login_callback_requires_and_consumes_server_state(space_oauth_api
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert (await response.get_json())['data']['token'] == 'space-login-token'
|
assert (await response.get_json())['data']['token'] == 'space-login-token'
|
||||||
application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login')
|
application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login')
|
||||||
application.space_service.exchange_oauth_code.assert_awaited_once_with('oauth-code')
|
application.space_service.exchange_oauth_code.assert_awaited_once_with(
|
||||||
|
'oauth-code',
|
||||||
|
[WORKSPACE_UUID],
|
||||||
|
{WORKSPACE_UUID: int(WORKSPACE_CREATED_AT.timestamp())},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import time
|
|||||||
|
|
||||||
from langbot.pkg.api.http.service.space import SpaceService
|
from langbot.pkg.api.http.service.space import SpaceService
|
||||||
from langbot.pkg.entity.persistence.user import User
|
from langbot.pkg.entity.persistence.user import User
|
||||||
|
from langbot.pkg.utils import constants
|
||||||
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.asyncio
|
pytestmark = pytest.mark.asyncio
|
||||||
@@ -573,10 +574,20 @@ class TestSpaceServiceExchangeOAuthCode:
|
|||||||
mock_session_obj.post.return_value.__aexit__ = AsyncMock(return_value=None)
|
mock_session_obj.post.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
# Execute
|
# Execute
|
||||||
result = await service.exchange_oauth_code('auth_code')
|
result = await service.exchange_oauth_code(
|
||||||
|
'auth_code',
|
||||||
|
['workspace-1'],
|
||||||
|
{'workspace-1': 1_700_000_000},
|
||||||
|
)
|
||||||
|
|
||||||
# Verify
|
# Verify
|
||||||
assert result['access_token'] == 'new_access_token'
|
assert result['access_token'] == 'new_access_token'
|
||||||
|
assert mock_session_obj.post.call_args.kwargs['json'] == {
|
||||||
|
'code': 'auth_code',
|
||||||
|
'instance_id': constants.instance_id,
|
||||||
|
'workspace_uuids': ['workspace-1'],
|
||||||
|
'workspace_created_ats': {'workspace-1': 1_700_000_000},
|
||||||
|
}
|
||||||
|
|
||||||
async def test_exchange_oauth_code_api_error(self):
|
async def test_exchange_oauth_code_api_error(self):
|
||||||
"""Raises ValueError on API error."""
|
"""Raises ValueError on API error."""
|
||||||
|
|||||||
@@ -612,8 +612,13 @@ class TestDisabledPluginEarlyReturns:
|
|||||||
mock_app.instance_config.data = {'plugin': {'enable': False}}
|
mock_app.instance_config.data = {'plugin': {'enable': False}}
|
||||||
|
|
||||||
connector = connector_module.PluginRuntimeConnector(mock_app, mock_disconnect)
|
connector = connector_module.PluginRuntimeConnector(mock_app, mock_disconnect)
|
||||||
|
execution_context = connector_module.ExecutionContext(
|
||||||
|
instance_uuid='instance-a',
|
||||||
|
workspace_uuid='workspace-a',
|
||||||
|
placement_generation=1,
|
||||||
|
)
|
||||||
|
|
||||||
result = await connector.get_debug_info()
|
result = await connector.get_debug_info(execution_context)
|
||||||
|
|
||||||
assert result == {}
|
assert result == {}
|
||||||
|
|
||||||
|
|||||||
@@ -282,12 +282,11 @@ def test_closed_deployment_selects_instance_scoped_shared_profile():
|
|||||||
assert connector.runtime_profile == 'shared'
|
assert connector.runtime_profile == 'shared'
|
||||||
|
|
||||||
|
|
||||||
def test_external_runtime_control_headers_require_strong_secret(monkeypatch):
|
def test_external_runtime_control_headers_are_empty_when_secret_is_unset(monkeypatch):
|
||||||
monkeypatch.delenv(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV, raising=False)
|
monkeypatch.delenv(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV, raising=False)
|
||||||
connector = make_connector()
|
connector = make_connector()
|
||||||
|
|
||||||
with pytest.raises(PluginRuntimeNotConnectedError, match=PLUGIN_RUNTIME_CONTROL_TOKEN_ENV):
|
assert connector._control_headers(allow_generate=False) == {}
|
||||||
connector._control_headers(allow_generate=False)
|
|
||||||
|
|
||||||
|
|
||||||
def test_local_runtime_control_headers_generate_ephemeral_secret(monkeypatch):
|
def test_local_runtime_control_headers_generate_ephemeral_secret(monkeypatch):
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -14,6 +15,12 @@ def get_heartbeat_module():
|
|||||||
return import_module('langbot.pkg.telemetry.heartbeat')
|
return import_module('langbot.pkg.telemetry.heartbeat')
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_created_timestamp_treats_naive_database_values_as_utc():
|
||||||
|
heartbeat = get_heartbeat_module()
|
||||||
|
created_at = datetime(2026, 8, 4, 0, 0, 0)
|
||||||
|
assert heartbeat._workspace_created_timestamp(created_at) == 1785801600
|
||||||
|
|
||||||
|
|
||||||
def make_app():
|
def make_app():
|
||||||
ap = Mock()
|
ap = Mock()
|
||||||
ap.instance_config = Mock()
|
ap.instance_config = Mock()
|
||||||
@@ -57,15 +64,17 @@ def make_app():
|
|||||||
|
|
||||||
class TestBuildHeartbeatPayload:
|
class TestBuildHeartbeatPayload:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_payload_shape(self):
|
async def test_payload_shape(self, monkeypatch):
|
||||||
heartbeat = get_heartbeat_module()
|
heartbeat = get_heartbeat_module()
|
||||||
|
monkeypatch.setattr(heartbeat.constants, 'instance_id', 'instance-test')
|
||||||
ap = make_app()
|
ap = make_app()
|
||||||
payload = await heartbeat.build_heartbeat_payload(ap, workspace_uuid='workspace-a')
|
payload = await heartbeat.build_heartbeat_payload(ap, workspace_uuid='workspace-a')
|
||||||
|
|
||||||
assert payload['event_type'] == 'instance_heartbeat'
|
assert payload['event_type'] == 'instance_heartbeat'
|
||||||
assert payload['query_id'] == ''
|
assert payload['query_id'] == ''
|
||||||
assert payload['workspace_uuid'] == 'workspace-a'
|
assert payload['workspace_uuid'] == 'workspace-a'
|
||||||
assert 'instance_id' not in payload
|
assert payload['instance_id']
|
||||||
|
assert payload['workspace_create_ts'] == 0
|
||||||
assert 'instance_create_ts' in payload
|
assert 'instance_create_ts' in payload
|
||||||
assert 'timestamp' in payload
|
assert 'timestamp' in payload
|
||||||
f = payload['features']
|
f = payload['features']
|
||||||
@@ -100,8 +109,9 @@ class TestBuildHeartbeatPayload:
|
|||||||
assert payload['features']['pipeline_count'] == -1
|
assert payload['features']['pipeline_count'] == -1
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cloud_counts_loaded_registries_without_tenant_sql(self):
|
async def test_cloud_counts_loaded_registries_without_tenant_sql(self, monkeypatch):
|
||||||
heartbeat = get_heartbeat_module()
|
heartbeat = get_heartbeat_module()
|
||||||
|
monkeypatch.setattr(heartbeat.constants, 'instance_id', 'instance-test')
|
||||||
ap = make_app()
|
ap = make_app()
|
||||||
ap.persistence_mgr.mode = SimpleNamespace(value='cloud_runtime')
|
ap.persistence_mgr.mode = SimpleNamespace(value='cloud_runtime')
|
||||||
ap.persistence_mgr.execute_async = AsyncMock(
|
ap.persistence_mgr.execute_async = AsyncMock(
|
||||||
@@ -140,7 +150,11 @@ class TestBuildHeartbeatPayload:
|
|||||||
ap.workspace_service.list_active_execution_bindings = AsyncMock(
|
ap.workspace_service.list_active_execution_bindings = AsyncMock(
|
||||||
return_value=[
|
return_value=[
|
||||||
SimpleNamespace(workspace_uuid='workspace-a', placement_generation=7),
|
SimpleNamespace(workspace_uuid='workspace-a', placement_generation=7),
|
||||||
SimpleNamespace(workspace_uuid='workspace-b', placement_generation=9),
|
SimpleNamespace(
|
||||||
|
workspace_uuid='workspace-b',
|
||||||
|
placement_generation=9,
|
||||||
|
workspace_created_at=datetime(2026, 8, 4, tzinfo=timezone.utc),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
ap.platform_mgr._bots_by_key[('instance-a', 'workspace-b', 'bot-b')] = SimpleNamespace(
|
ap.platform_mgr._bots_by_key[('instance-a', 'workspace-b', 'bot-b')] = SimpleNamespace(
|
||||||
@@ -150,7 +164,9 @@ class TestBuildHeartbeatPayload:
|
|||||||
payloads = await heartbeat.build_heartbeat_payloads(ap)
|
payloads = await heartbeat.build_heartbeat_payloads(ap)
|
||||||
|
|
||||||
assert [payload['workspace_uuid'] for payload in payloads] == ['workspace-a', 'workspace-b']
|
assert [payload['workspace_uuid'] for payload in payloads] == ['workspace-a', 'workspace-b']
|
||||||
assert all('instance_id' not in payload for payload in payloads)
|
assert all(payload['instance_id'] for payload in payloads)
|
||||||
|
assert payloads[0]['workspace_create_ts'] == 0
|
||||||
|
assert payloads[1]['workspace_create_ts'] == 1785801600
|
||||||
by_workspace = {payload['workspace_uuid']: payload['features'] for payload in payloads}
|
by_workspace = {payload['workspace_uuid']: payload['features'] for payload in payloads}
|
||||||
assert by_workspace['workspace-a']['pipeline_count'] == 2
|
assert by_workspace['workspace-a']['pipeline_count'] == 2
|
||||||
assert by_workspace['workspace-a']['mcp_server_count'] == 3
|
assert by_workspace['workspace-a']['mcp_server_count'] == 3
|
||||||
|
|||||||
@@ -596,6 +596,36 @@ class TestTelemetryManagedRuntimeAuthentication:
|
|||||||
assert captured['headers'] == {'X-LangBot-Telemetry-Token': 'managed-runtime-secret'}
|
assert captured['headers'] == {'X-LangBot-Telemetry-Token': 'managed-runtime-secret'}
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthenticatedWorkspaceReporter:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_workspace_owner_access_token_is_sent_as_bearer(self):
|
||||||
|
telemetry = get_telemetry_module()
|
||||||
|
mock_app = Mock()
|
||||||
|
mock_app.logger = Mock()
|
||||||
|
mock_app.user_service = Mock()
|
||||||
|
mock_app.user_service.get_workspace_owner = AsyncMock(
|
||||||
|
return_value=Mock(user='owner@example.com', space_access_token='expired-token')
|
||||||
|
)
|
||||||
|
mock_app.space_service = Mock()
|
||||||
|
mock_app.space_service.get_valid_access_token = AsyncMock(return_value='refreshed-workspace-owner-token')
|
||||||
|
manager = telemetry.TelemetryManager(mock_app)
|
||||||
|
manager.telemetry_config = {'url': 'https://example.com'}
|
||||||
|
|
||||||
|
response = Mock(status_code=200, text='')
|
||||||
|
response.json = Mock(return_value={'code': 0})
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.post = Mock(return_value=response)
|
||||||
|
|
||||||
|
with patch.object(httpx, 'AsyncClient', return_value=mock_client):
|
||||||
|
await manager.send({'query_id': 'q-1', 'workspace_uuid': 'workspace-1'})
|
||||||
|
|
||||||
|
mock_app.user_service.get_workspace_owner.assert_awaited_once_with('workspace-1')
|
||||||
|
mock_app.space_service.get_valid_access_token.assert_awaited_once_with('owner@example.com')
|
||||||
|
assert mock_client.post.call_args.kwargs['headers'] == {
|
||||||
|
'Authorization': 'Bearer refreshed-workspace-owner-token'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class TestStartSendTask:
|
class TestStartSendTask:
|
||||||
"""Tests for start_send_task() method."""
|
"""Tests for start_send_task() method."""
|
||||||
|
|
||||||
|
|||||||
@@ -7,25 +7,28 @@ from types import SimpleNamespace
|
|||||||
def test_standard_oss_instance_id_aligns_to_embedded_uuid():
|
def test_standard_oss_instance_id_aligns_to_embedded_uuid():
|
||||||
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
|
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
|
||||||
|
|
||||||
instance_uuid = "a711d9e4-0953-443f-a0e9-7dd50193a79f"
|
instance_uuid = 'a711d9e4-0953-443f-a0e9-7dd50193a79f'
|
||||||
|
|
||||||
assert workspace_uuid_from_instance_id(instance_uuid) == instance_uuid
|
assert workspace_uuid_from_instance_id(instance_uuid) == instance_uuid
|
||||||
assert workspace_uuid_from_instance_id(f"instance_{instance_uuid}") == instance_uuid
|
assert workspace_uuid_from_instance_id(f'instance_{instance_uuid}') == instance_uuid
|
||||||
|
|
||||||
|
|
||||||
def test_custom_legacy_instance_id_maps_to_stable_valid_uuid():
|
def test_custom_legacy_instance_id_maps_to_stable_valid_uuid():
|
||||||
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
|
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
|
||||||
|
|
||||||
first = workspace_uuid_from_instance_id("instance_migration_test")
|
first = workspace_uuid_from_instance_id('instance_migration_test')
|
||||||
second = workspace_uuid_from_instance_id("instance_migration_test")
|
second = workspace_uuid_from_instance_id('instance_migration_test')
|
||||||
|
|
||||||
assert first == second
|
assert first == second
|
||||||
assert str(uuid.UUID(first)) == first
|
assert str(uuid.UUID(first)) == first
|
||||||
|
|
||||||
|
|
||||||
def test_query_telemetry_identity_uses_execution_workspace_only():
|
def test_query_telemetry_identity_reports_instance_and_workspace():
|
||||||
from langbot.pkg.telemetry.identity import workspace_identity
|
from langbot.pkg.telemetry.identity import workspace_identity
|
||||||
|
|
||||||
identity = workspace_identity(SimpleNamespace(workspace_uuid="workspace-a", instance_uuid="instance-a"))
|
identity = workspace_identity(SimpleNamespace(workspace_uuid='workspace-a', instance_uuid='instance-a'))
|
||||||
|
|
||||||
assert identity == {"workspace_uuid": "workspace-a"}
|
assert identity == {
|
||||||
|
'instance_id': 'instance-a',
|
||||||
|
'workspace_uuid': 'workspace-a',
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ function PluginListView() {
|
|||||||
const [debugInfo, setDebugInfo] = useState<{
|
const [debugInfo, setDebugInfo] = useState<{
|
||||||
debug_url: string;
|
debug_url: string;
|
||||||
plugin_debug_key: string;
|
plugin_debug_key: string;
|
||||||
|
expires_at: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [debugPopoverOpen, setDebugPopoverOpen] = useState(false);
|
const [debugPopoverOpen, setDebugPopoverOpen] = useState(false);
|
||||||
const [copiedDebugUrl, setCopiedDebugUrl] = useState(false);
|
const [copiedDebugUrl, setCopiedDebugUrl] = useState(false);
|
||||||
@@ -275,6 +276,13 @@ function PluginListView() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{debugInfo?.expires_at && (
|
||||||
|
<p className="text-xs text-muted-foreground pl-[58px]">
|
||||||
|
{t('plugins.debugKeyExpires', {
|
||||||
|
time: new Date(debugInfo.expires_at).toLocaleString(),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{!debugInfo?.plugin_debug_key && (
|
{!debugInfo?.plugin_debug_key && (
|
||||||
<p className="text-xs text-muted-foreground ml-[58px]">
|
<p className="text-xs text-muted-foreground ml-[58px]">
|
||||||
{t('plugins.debugKeyDisabled')}
|
{t('plugins.debugKeyDisabled')}
|
||||||
|
|||||||
@@ -1091,6 +1091,7 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
public getPluginDebugInfo(): Promise<{
|
public getPluginDebugInfo(): Promise<{
|
||||||
debug_url: string;
|
debug_url: string;
|
||||||
plugin_debug_key: string;
|
plugin_debug_key: string;
|
||||||
|
expires_at: string;
|
||||||
}> {
|
}> {
|
||||||
return this.get('/api/v1/plugins/debug-info');
|
return this.get('/api/v1/plugins/debug-info');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -521,9 +521,9 @@ const enUS = {
|
|||||||
debugInfoTitle: 'Plugin Debug Information',
|
debugInfoTitle: 'Plugin Debug Information',
|
||||||
debugUrl: 'Debug URL',
|
debugUrl: 'Debug URL',
|
||||||
debugKey: 'Debug Key',
|
debugKey: 'Debug Key',
|
||||||
|
debugKeyExpires: 'Rotates at {{time}}; each Workspace has a different key',
|
||||||
noDebugKey: '(Not Set)',
|
noDebugKey: '(Not Set)',
|
||||||
debugKeyDisabled:
|
debugKeyDisabled: 'Debug credential is temporarily unavailable',
|
||||||
'Debug key is not set, plugin debugging does not require authentication',
|
|
||||||
boxStatusTitle: 'Box Runtime',
|
boxStatusTitle: 'Box Runtime',
|
||||||
boxStatus: 'Status',
|
boxStatus: 'Status',
|
||||||
boxConnected: 'Connected',
|
boxConnected: 'Connected',
|
||||||
|
|||||||
@@ -535,9 +535,11 @@ const esES = {
|
|||||||
debugInfoTitle: 'Información de depuración del plugin',
|
debugInfoTitle: 'Información de depuración del plugin',
|
||||||
debugUrl: 'URL de depuración',
|
debugUrl: 'URL de depuración',
|
||||||
debugKey: 'Clave de depuración',
|
debugKey: 'Clave de depuración',
|
||||||
|
debugKeyExpires:
|
||||||
|
'Rota a las {{time}}; cada Workspace tiene una clave distinta',
|
||||||
noDebugKey: '(No establecida)',
|
noDebugKey: '(No establecida)',
|
||||||
debugKeyDisabled:
|
debugKeyDisabled:
|
||||||
'La clave de depuración no está configurada, la depuración del plugin no requiere autenticación',
|
'La credencial de depuración no está disponible temporalmente',
|
||||||
boxStatusTitle: 'Box Runtime',
|
boxStatusTitle: 'Box Runtime',
|
||||||
boxStatus: 'Estado',
|
boxStatus: 'Estado',
|
||||||
boxConnected: 'Conectado',
|
boxConnected: 'Conectado',
|
||||||
|
|||||||
@@ -527,9 +527,10 @@ const jaJP = {
|
|||||||
debugInfoTitle: 'プラグインデバッグ情報',
|
debugInfoTitle: 'プラグインデバッグ情報',
|
||||||
debugUrl: 'デバッグURL',
|
debugUrl: 'デバッグURL',
|
||||||
debugKey: 'デバッグキー',
|
debugKey: 'デバッグキー',
|
||||||
|
debugKeyExpires:
|
||||||
|
'{{time}} にローテーションします。Workspace ごとにキーが異なります',
|
||||||
noDebugKey: '(未設定)',
|
noDebugKey: '(未設定)',
|
||||||
debugKeyDisabled:
|
debugKeyDisabled: 'デバッグ認証情報を一時的に利用できません',
|
||||||
'デバッグキーが設定されていません。プラグインデバッグには認証が不要です',
|
|
||||||
boxStatusTitle: 'Box ランタイム',
|
boxStatusTitle: 'Box ランタイム',
|
||||||
boxStatus: 'ステータス',
|
boxStatus: 'ステータス',
|
||||||
boxConnected: '接続済み',
|
boxConnected: '接続済み',
|
||||||
|
|||||||
@@ -534,9 +534,9 @@ const ruRU = {
|
|||||||
debugInfoTitle: 'Отладочная информация плагина',
|
debugInfoTitle: 'Отладочная информация плагина',
|
||||||
debugUrl: 'URL для отладки',
|
debugUrl: 'URL для отладки',
|
||||||
debugKey: 'Ключ отладки',
|
debugKey: 'Ключ отладки',
|
||||||
|
debugKeyExpires: 'Смена в {{time}}; у каждого Workspace свой ключ',
|
||||||
noDebugKey: '(Не задан)',
|
noDebugKey: '(Не задан)',
|
||||||
debugKeyDisabled:
|
debugKeyDisabled: 'Учетные данные отладки временно недоступны',
|
||||||
'Ключ отладки не задан, аутентификация при отладке плагина не требуется',
|
|
||||||
boxStatusTitle: 'Box Runtime',
|
boxStatusTitle: 'Box Runtime',
|
||||||
boxStatus: 'Статус',
|
boxStatus: 'Статус',
|
||||||
boxConnected: 'Подключено',
|
boxConnected: 'Подключено',
|
||||||
|
|||||||
@@ -518,9 +518,9 @@ const thTH = {
|
|||||||
debugInfoTitle: 'ข้อมูลดีบักปลั๊กอิน',
|
debugInfoTitle: 'ข้อมูลดีบักปลั๊กอิน',
|
||||||
debugUrl: 'URL ดีบัก',
|
debugUrl: 'URL ดีบัก',
|
||||||
debugKey: 'คีย์ดีบัก',
|
debugKey: 'คีย์ดีบัก',
|
||||||
|
debugKeyExpires: 'หมุนเวียนเวลา {{time}}; แต่ละ Workspace ใช้คีย์ต่างกัน',
|
||||||
noDebugKey: '(ไม่ได้ตั้งค่า)',
|
noDebugKey: '(ไม่ได้ตั้งค่า)',
|
||||||
debugKeyDisabled:
|
debugKeyDisabled: 'ข้อมูลรับรองการดีบักไม่พร้อมใช้งานชั่วคราว',
|
||||||
'ไม่ได้ตั้งค่าคีย์ดีบัก การดีบักปลั๊กอินไม่ต้องยืนยันตัวตน',
|
|
||||||
boxStatusTitle: 'Box Runtime',
|
boxStatusTitle: 'Box Runtime',
|
||||||
boxStatus: 'สถานะ',
|
boxStatus: 'สถานะ',
|
||||||
boxConnected: 'เชื่อมต่อแล้ว',
|
boxConnected: 'เชื่อมต่อแล้ว',
|
||||||
|
|||||||
@@ -529,9 +529,9 @@ const viVN = {
|
|||||||
debugInfoTitle: 'Thông tin gỡ lỗi Plugin',
|
debugInfoTitle: 'Thông tin gỡ lỗi Plugin',
|
||||||
debugUrl: 'URL gỡ lỗi',
|
debugUrl: 'URL gỡ lỗi',
|
||||||
debugKey: 'Khóa gỡ lỗi',
|
debugKey: 'Khóa gỡ lỗi',
|
||||||
|
debugKeyExpires: 'Xoay vòng lúc {{time}}; mỗi Workspace có khóa riêng',
|
||||||
noDebugKey: '(Chưa đặt)',
|
noDebugKey: '(Chưa đặt)',
|
||||||
debugKeyDisabled:
|
debugKeyDisabled: 'Thông tin xác thực gỡ lỗi tạm thời không khả dụng',
|
||||||
'Khóa gỡ lỗi chưa được đặt, gỡ lỗi plugin không yêu cầu xác thực',
|
|
||||||
boxStatusTitle: 'Box Runtime',
|
boxStatusTitle: 'Box Runtime',
|
||||||
boxStatus: 'Trạng thái',
|
boxStatus: 'Trạng thái',
|
||||||
boxConnected: 'Đã kết nối',
|
boxConnected: 'Đã kết nối',
|
||||||
|
|||||||
@@ -496,8 +496,9 @@ const zhHans = {
|
|||||||
debugInfoTitle: '插件调试信息',
|
debugInfoTitle: '插件调试信息',
|
||||||
debugUrl: '调试地址',
|
debugUrl: '调试地址',
|
||||||
debugKey: '调试密钥',
|
debugKey: '调试密钥',
|
||||||
|
debugKeyExpires: '将于 {{time}} 轮换;每个工作区的密钥不同',
|
||||||
noDebugKey: '(未设置)',
|
noDebugKey: '(未设置)',
|
||||||
debugKeyDisabled: '未设置调试密钥,插件调试无需认证',
|
debugKeyDisabled: '调试凭据暂不可用',
|
||||||
boxStatusTitle: 'Box 运行时',
|
boxStatusTitle: 'Box 运行时',
|
||||||
boxStatus: '状态',
|
boxStatus: '状态',
|
||||||
boxConnected: '已连接',
|
boxConnected: '已连接',
|
||||||
|
|||||||
@@ -501,8 +501,9 @@ const zhHant = {
|
|||||||
debugInfoTitle: '外掛偵錯資訊',
|
debugInfoTitle: '外掛偵錯資訊',
|
||||||
debugUrl: '偵錯位址',
|
debugUrl: '偵錯位址',
|
||||||
debugKey: '偵錯金鑰',
|
debugKey: '偵錯金鑰',
|
||||||
|
debugKeyExpires: '將於 {{time}} 輪換;每個工作區的密鑰不同',
|
||||||
noDebugKey: '(未設定)',
|
noDebugKey: '(未設定)',
|
||||||
debugKeyDisabled: '未設定偵錯金鑰,外掛偵錯無需認證',
|
debugKeyDisabled: '偵錯憑據暫時無法使用',
|
||||||
boxStatusTitle: 'Box 執行時',
|
boxStatusTitle: 'Box 執行時',
|
||||||
boxStatus: '狀態',
|
boxStatus: '狀態',
|
||||||
boxConnected: '已連線',
|
boxConnected: '已連線',
|
||||||
|
|||||||
Reference in New Issue
Block a user