diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 514445f1c..bd0a9c674 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -1,5 +1,5 @@ name: 漏洞反馈 -description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://link.langbot.app/zh/docs/network +description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://langbot.app/docs/zh/workshop/network-details title: "[Bug]: " labels: ["bug?"] body: @@ -22,7 +22,7 @@ body: - type: textarea attributes: label: 异常情况 - description: 完整描述异常情况,什么时候发生的、发生了什么。**请附带日志信息。** + description: 完整描述异常情况,什么时候发生的、发生了什么。**请附带日志信息。** validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/bug-report_en.yml b/.github/ISSUE_TEMPLATE/bug-report_en.yml index d2111e097..7edaaefa7 100644 --- a/.github/ISSUE_TEMPLATE/bug-report_en.yml +++ b/.github/ISSUE_TEMPLATE/bug-report_en.yml @@ -1,5 +1,5 @@ name: Bug report -description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://link.langbot.app/en/docs/network +description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://langbot.app/docs/en/workshop/network-details title: "[Bug]: " labels: ["bug?"] body: diff --git a/.github/discord-release/README.md b/.github/discord-release/README.md new file mode 100644 index 000000000..21deee164 --- /dev/null +++ b/.github/discord-release/README.md @@ -0,0 +1,111 @@ +# Discord release announcements + +This independent workflow announces new stable LangBot releases in the channel +selected by a dedicated Discord incoming webhook. It does not change the existing +release/build workflows, edit releases, run a persistent service, poll, or backfill. +Announcements run on publication, independently of artifact builds finishing. + +## Setup and read-only validation + +1. In the intended community **announcement channel**, create a dedicated incoming + webhook (Channel Settings → Integrations → Webhooks). Copy its URL; do not reuse + a webhook belonging to another automation. +2. In `langbot-app/LangBot` → Settings → Secrets and variables → Actions, create the + **repository secret** `DISCORD_RELEASE_WEBHOOK_URL`. Its value must be exactly + `https://discord.com/api/webhooks//` — no query, trailing slash, + API-version segment, or alternate domain. Treat the entire URL as a password. +3. Once this workflow is on `master`, open Actions → **Discord Release Announcement** + → Run workflow, choosing `master`. Alternatively: + + ```sh + gh workflow run discord-release.yml --repo langbot-app/LangBot --ref master + ``` + +4. Inspect **Validate webhook (GET only, no message)**. It checks webhook type `1` + and reports `guild_id` and `channel_id`; compare both with the intended server + and channel using Discord Developer Mode → Copy ID. The secret determines the + destination; no channel ID is guessed or overridden. The URL/token is never + logged. Dispatch cannot send a test message or announce an old release, even + when run again. Missing/invalid secrets fail validation clearly; offline tests + do not need secrets. + +GET validation confirms the webhook's identity, not delivery or notification +permissions. Verify those on the first genuine release. `mention_everyone=true` +confirms Discord parsed the mention; it cannot prove every member received a push +notification (member/server notification settings still apply). + +## Activation and message + +The workflow and `.github/discord-release/` helper **must be in the commit targeted +by each new release tag**. Merging to `master` does not enable announcements for +old tags whose commits lack these files. Manual dispatch becomes available when +the workflow is on the default branch. Only publish release tags from trusted, +reviewed commits: release workflows execute that tag's code with the secret. + +Only `release` events with action `published`, `draft=false`, and +`prerelease=false` can send. Drafts and prereleases are skipped; release edits do +not trigger announcements. The helper requires the repository to be exactly +`langbot-app/LangBot`, a stable `vX.Y.Z` tag (ASCII digits, at most 64 characters), +and its exact canonical GitHub release URL. Other naming schemes fail closed. + +Example message (the version and URL come from the validated event file): + +```text +@everyone LangBot v4.10.11 is now available! +Release notes: https://github.com/langbot-app/LangBot/releases/tag/v4.10.11 +``` + +The release title/body is never copied. There is one literal `@everyone`, explicit +`allowed_mentions.parse=["everyone"]`, empty user/role allowlists, and no reply +mention. TTS and notification-suppressing flags are disabled. Requests use HTTPS +only to `discord.com`, an explicit User-Agent, and no redirects or automatic +retries. After a webhook identity GET, one `POST ?wait=true` obtains a message ID; +an exact `/messages/` GET verifies its ID, webhook/channel, content, +`mention_everyone=true`, and empty user/role mention arrays before success. + +## Repeat guard and manual recovery + +Production sending requires **`GITHUB_RUN_ATTEMPT == "1"`**. Any Actions rerun +(including “Re-run failed jobs”) refuses to POST and requires manual reconciliation, +even if the first attempt failed before sending. Read-only dispatch may be rerun. + +This is a practical repeat guard, **not durable exactly-once delivery**. It cannot +prevent duplicates from a separate new run/event (for example deleting/recreating +a release), separate automation, or manual posting. It stores no durable dedupe +state and never modifies the release to mark delivery. + +If a POST times out, returns an error, or readback fails, the message may already +exist. The workflow fails rather than blindly sending again. A returned message ID +is included in the safe error when available. A runner termination can also leave +an ambiguous send without that log line. + +1. Inspect the announcement channel and the failed run logs. Locate the canonical + release link and, if available, the returned message ID. A failed verification + does **not** mean the message was absent. +2. If present, reconcile the existing message/mention problem manually; do not + rerun, create another release event, or send a duplicate ping. +3. If an operator has positively confirmed no message exists, fix the secret or + permission issue and use read-only dispatch to validate configuration. A + maintainer may then post the announcement manually once in Discord and record + the message link in the incident/run notes. Do not override the attempt guard + or delete/recreate a release to force recovery. +4. If absence cannot be established, pause and reconcile rather than resending. + +To stop future sends, disable **Discord Release Announcement** in Actions. Rotate +or delete the dedicated Discord webhook if the URL is exposed, and update the +secret before validation. No rollback of release artifacts is involved. + +## Local checks + +Requires Python 3.11+ and the standard library only: + +```sh +python3 -m unittest discover -s .github/discord-release -p 'test_*.py' -v +python3 -m py_compile .github/discord-release/announce.py .github/discord-release/test_announce.py +``` + +Tests exercise policy, CLI/event-file handling, mention payloads, hostile inputs, +HTTP failures, exact message readback, and refusal to retry. Only the HTTPS +transport is mocked for Discord tests; no live Discord requests or messages are +made. Changes to this directory or its workflow run the offline tests on push and +pull request; tests also gate release sending and read-only dispatch validation. diff --git a/.github/discord-release/announce.py b/.github/discord-release/announce.py new file mode 100644 index 000000000..c8548d380 --- /dev/null +++ b/.github/discord-release/announce.py @@ -0,0 +1,162 @@ +"""Announce only first-attempt stable releases; dispatch is read-only validation.""" + +import http.client +import json +import os +from pathlib import Path +import re +import sys + +REPOSITORY = 'langbot-app/LangBot' +RELEASE_PREFIX = f'https://github.com/{REPOSITORY}/releases/tag/' +RECONCILE = ( + 'Do not resend or bypass the run-attempt guard; manual reconciliation is required. ' + 'Inspect the announcement channel and workflow logs before any manual recovery ' + '(see .github/discord-release/README.md).' +) + + +class AnnouncementError(Exception): + """A safe, operator-facing error containing no webhook URL or response body.""" + + +def release_payload(event, attempt): + """Return a bounded, mention-safe payload, or None for draft/preview releases.""" + if not isinstance(event, dict) or event.get('action') != 'published': + raise AnnouncementError('Only release.published events are accepted.') + repository = event.get('repository') + if not isinstance(repository, dict) or repository.get('full_name') != REPOSITORY: + raise AnnouncementError('Unexpected release repository.') + release = event.get('release') + if not isinstance(release, dict) or any(type(release.get(key)) is not bool for key in ('draft', 'prerelease')): + raise AnnouncementError('Invalid release flags.') + if release['draft'] or release['prerelease']: + return None + if attempt != '1': + raise AnnouncementError(f'Release reruns or missing run attempts are refused. {RECONCILE}') + tag = release.get('tag_name') + if not isinstance(tag, str) or len(tag) > 64 or not re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+', tag): + raise AnnouncementError('Expected a stable release tag in vX.Y.Z format (at most 64 characters).') + url = RELEASE_PREFIX + tag + if release.get('html_url') != url: + raise AnnouncementError('Release URL must be the canonical LangBot release URL matching its tag.') + return { + 'content': f'@everyone LangBot {tag} is now available!\nRelease notes: {url}', + 'allowed_mentions': {'parse': ['everyone'], 'users': [], 'roles': [], 'replied_user': False}, + 'tts': False, + 'flags': 0, + } + + +def is_snowflake(value): + return isinstance(value, str) and re.fullmatch(r'[0-9]{1,20}', value) is not None + + +class DiscordWebhook: + def __init__(self, url): + if not url: + raise AnnouncementError('DISCORD_RELEASE_WEBHOOK_URL is missing. Set the repository Actions secret.') + match = re.fullmatch(r'https://discord\.com(/api/webhooks/([0-9]{1,20})/[A-Za-z0-9_-]+)', url) + if not match: + raise AnnouncementError('Invalid webhook URL; expected https://discord.com/api/webhooks//.') + self.path, self.id = match.groups() + + def _request(self, method, suffix='', payload=None): + # Direct HTTPS, default certificate verification, no proxies or redirect/retry machinery. + connection = http.client.HTTPSConnection('discord.com', timeout=20) + try: + body = json.dumps(payload).encode('utf-8') if payload is not None else None + connection.request( + method, + self.path + suffix, + body=body, + headers={'Content-Type': 'application/json', 'User-Agent': 'LangBot-Release-Announcements/1.0'}, + ) + response = connection.getresponse() + if response.status != 200: + raise AnnouncementError(f'Discord {method} returned HTTP {response.status}; no retry was attempted.') + raw = response.read(1_048_577) + if len(raw) > 1_048_576: + raise AnnouncementError('Discord response exceeded the size limit.') + return json.loads(raw) + except (OSError, http.client.HTTPException, ValueError, UnicodeError): + # Exceptions and bodies can contain the token; never print them or chain them. + raise AnnouncementError( + f'Discord {method} failed or returned invalid JSON; no retry was attempted.' + ) from None + finally: + connection.close() + + def validate(self): + """GET only: verify an incoming webhook and return safe identifying fields.""" + webhook = self._request('GET') + if ( + not isinstance(webhook, dict) + or type(webhook.get('type')) is not int + or webhook['type'] != 1 + or webhook.get('id') != self.id + or not is_snowflake(webhook.get('guild_id')) + or not is_snowflake(webhook.get('channel_id')) + ): + raise AnnouncementError('Expected an incoming (type 1) webhook with matching ID and guild/channel IDs.') + return {key: webhook[key] for key in ('id', 'type', 'guild_id', 'channel_id')} + + def send(self, payload): + """One POST, followed by exact message GET; never automatically retry a send.""" + webhook = self.validate() + message_id = None + try: + sent = self._request('POST', '?wait=true', payload) + if not isinstance(sent, dict) or not is_snowflake(sent.get('id')): + raise AnnouncementError('Discord did not return a valid message ID.') + message_id = sent['id'] + saved = self._request('GET', f'/messages/{message_id}') + if ( + not isinstance(saved, dict) + or saved.get('id') != message_id + or saved.get('webhook_id') != self.id + or saved.get('channel_id') != webhook['channel_id'] + or saved.get('content') != payload['content'] + or saved.get('mention_everyone') is not True + or saved.get('mentions') != [] + or saved.get('mention_roles') != [] + ): + raise AnnouncementError('Discord message readback did not match content, identity, or mentions.') + except AnnouncementError as error: + reference = f' Returned message ID: {message_id}.' if message_id else '' + raise AnnouncementError(f'Delivery not confirmed. {error}{reference} {RECONCILE}') from None + return message_id + + +def main(env=None): + env = os.environ if env is None else env + try: + if env.get('GITHUB_REPOSITORY') != REPOSITORY: + raise AnnouncementError('This workflow is restricted to langbot-app/LangBot.') + name = env.get('GITHUB_EVENT_NAME') + if name == 'workflow_dispatch': + webhook = DiscordWebhook(env.get('DISCORD_RELEASE_WEBHOOK_URL')).validate() + print( + f'Validated incoming webhook: guild_id={webhook["guild_id"]} channel_id={webhook["channel_id"]}. No message sent.' + ) + return 0 + if name != 'release': + raise AnnouncementError('Only release and workflow_dispatch events are accepted by this helper.') + try: + event = json.loads(Path(env.get('GITHUB_EVENT_PATH', '')).read_text(encoding='utf-8')) + except (OSError, ValueError, UnicodeError): + raise AnnouncementError('Cannot read a valid JSON release event from GITHUB_EVENT_PATH.') from None + payload = release_payload(event, env.get('GITHUB_RUN_ATTEMPT')) + if payload is None: + print('Skipped draft or prerelease; no message sent.') + return 0 + message_id = DiscordWebhook(env.get('DISCORD_RELEASE_WEBHOOK_URL')).send(payload) + print(f'Announcement verified by exact message readback: message_id={message_id}.') + return 0 + except AnnouncementError as error: + print(f'Error: {error}', file=sys.stderr) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.github/discord-release/test_announce.py b/.github/discord-release/test_announce.py new file mode 100644 index 000000000..f77bae8b0 --- /dev/null +++ b/.github/discord-release/test_announce.py @@ -0,0 +1,427 @@ +"""Offline contract tests; no Discord credentials or network required.""" + +import contextlib +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +try: + import announce +except ModuleNotFoundError: + announce = None + +WEBHOOK = 'https://discord.com/api/webhooks/123456789012345678/fixture_token-ONLY' +WEBHOOK_ID = '123456789012345678' +GUILD_ID = '234567890123456789' +CHANNEL_ID = '345678901234567890' +MESSAGE_ID = '456789012345678901' +REPO = 'langbot-app/LangBot' +URL = f'https://github.com/{REPO}/releases/tag/v4.10.11' +CONTENT = f'@everyone LangBot v4.10.11 is now available!\nRelease notes: {URL}' + + +def event(): + return { + 'action': 'published', + 'repository': {'full_name': REPO}, + 'release': { + 'draft': False, + 'prerelease': False, + 'tag_name': 'v4.10.11', + 'html_url': URL, + 'name': 'Hostile @everyone <@123> $(touch /tmp/unsafe)', + 'body': '@everyone @here <@123> <@&456> `hostile`', + }, + } + + +def metadata(): + return {'id': WEBHOOK_ID, 'type': 1, 'guild_id': GUILD_ID, 'channel_id': CHANNEL_ID} + + +def message(): + return { + 'id': MESSAGE_ID, + 'webhook_id': WEBHOOK_ID, + 'channel_id': CHANNEL_ID, + 'content': CONTENT, + 'mention_everyone': True, + 'mentions': [], + 'mention_roles': [], + } + + +class BaseTest(unittest.TestCase): + def setUp(self): + self.assertIsNotNone(announce, 'The release announcement helper must exist') + + +class PolicyTests(BaseTest): + def test_payload_has_one_literal_everyone_and_no_untrusted_body(self): + payload = announce.release_payload(event(), '1') + self.assertEqual(payload['content'], CONTENT) + self.assertEqual(json.dumps(payload).count('@everyone'), 1) + self.assertEqual( + payload['allowed_mentions'], + { + 'parse': ['everyone'], + 'users': [], + 'roles': [], + 'replied_user': False, + }, + ) + self.assertIs(payload['tts'], False) + self.assertEqual(payload['flags'], 0) + + def test_drafts_and_prereleases_are_skipped(self): + for flag in ('draft', 'prerelease'): + with self.subTest(flag=flag): + value = event() + value['release'][flag] = True + self.assertIsNone(announce.release_payload(value, '1')) + + def test_only_published_action_is_accepted(self): + for action in ('edited', 'created', 'released', 'deleted', '', None): + with self.subTest(action=action): + value = event() + value['action'] = action + with self.assertRaises(announce.AnnouncementError): + announce.release_payload(value, '1') + + def test_reruns_and_missing_attempt_refuse_manual_reconciliation(self): + for attempt in ('2', '3', '', None, '01', '0', '1\n'): + with self.subTest(attempt=attempt): + with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation'): + announce.release_payload(event(), attempt) + + def test_repository_must_match_exactly(self): + for repo in ('evil/LangBot', 'langbot-app/langbot', None): + value = event() + value['repository']['full_name'] = repo + with self.assertRaises(announce.AnnouncementError): + announce.release_payload(value, '1') + + def test_hostile_and_noncanonical_tags_are_rejected(self): + for tag in ( + 'v1.2.3 @everyone', + 'v1.2.3\n', + 'v1.2.3/../../x', + 'v1.2.3?x=y', + '$(id)', + 'v1.2.3-rc.1', + 'v1.2.3', + 'v1.2.3%0a', + '<@123>', + 'v1.2.' + '3' * 100, + '', + None, + 123, + ): + with self.subTest(tag=tag): + value = event() + value['release']['tag_name'] = tag + value['release']['html_url'] = f'https://github.com/{REPO}/releases/tag/{tag}' + with self.assertRaises(announce.AnnouncementError): + announce.release_payload(value, '1') + + def test_release_url_must_be_canonical_and_match_tag(self): + for url in ( + 'https://evil.example/tag/v4.10.11', + URL + '?x=y', + URL + '#anchor', + URL + '/', + URL.replace('v4.10.11', 'v4.10.12'), + URL.replace('github.com', 'github.com@evil.example'), + URL.replace('https:', 'http:'), + URL + '\n', + None, + ): + with self.subTest(url=url): + value = event() + value['release']['html_url'] = url + with self.assertRaises(announce.AnnouncementError): + announce.release_payload(value, '1') + + def test_malformed_events_fail_closed(self): + for value in (None, [], {}, {'release': []}, {'repository': None}): + with self.subTest(value=value): + with self.assertRaises(announce.AnnouncementError): + announce.release_payload(value, '1') + for flag in ('draft', 'prerelease'): + for bad in (None, 'false', 0, 1): + value = event() + value['release'][flag] = bad + with self.assertRaises(announce.AnnouncementError): + announce.release_payload(value, '1') + + +class DiscordTests(BaseTest): + def setUp(self): + super().setUp() + self.patch = patch('announce.http.client.HTTPSConnection') + self.connection_class = self.patch.start() + self.addCleanup(self.patch.stop) + self.connection = self.connection_class.return_value + + def respond(self, *values): + responses = [] + for value in values: + response = MagicMock() + response.status = 200 + response.read.return_value = json.dumps(value).encode() + responses.append(response) + self.connection.getresponse.side_effect = responses + + def methods(self): + return [call.args[0] for call in self.connection.request.call_args_list] + + def test_webhook_validation_is_get_only_and_reports_ids(self): + self.respond(metadata()) + result = announce.DiscordWebhook(WEBHOOK).validate() + self.assertEqual(result, metadata()) + self.assertEqual(self.methods(), ['GET']) + self.assertEqual( + self.connection.request.call_args.args[:2], ('GET', f'/api/webhooks/{WEBHOOK_ID}/fixture_token-ONLY') + ) + self.connection_class.assert_called_with('discord.com', timeout=20) + self.connection.close.assert_called_once() + + def test_invalid_webhook_urls_are_rejected_before_network(self): + for url in ( + '', + None, + WEBHOOK + '/', + WEBHOOK + '?wait=true', + WEBHOOK + '#x', + WEBHOOK + '\n', + ' ' + WEBHOOK, + WEBHOOK.replace('https:', 'http:'), + WEBHOOK.replace('discord.com', 'discord.com.evil.example'), + WEBHOOK.replace('discord.com', 'discord.com@evil.example'), + WEBHOOK.replace('discord.com', 'discord.com:443'), + WEBHOOK.replace('/api/', '/api/v10/'), + WEBHOOK.replace(WEBHOOK_ID, 'abc'), + WEBHOOK + '/../../x', + WEBHOOK.replace('fixture_token-ONLY', 'a%2Fb'), + ): + with self.subTest(url=url): + with self.assertRaises(announce.AnnouncementError): + announce.DiscordWebhook(url) + self.connection_class.assert_not_called() + + def test_webhook_metadata_requires_incoming_type_and_ids(self): + invalid = [ + None, + [], + {}, + dict(metadata(), type=2), + dict(metadata(), type=True), + dict(metadata(), id='999'), + dict(metadata(), channel_id=None), + dict(metadata(), guild_id='::error::hostile'), + ] + for value in invalid: + with self.subTest(value=value): + self.respond(value) + with self.assertRaises(announce.AnnouncementError): + announce.DiscordWebhook(WEBHOOK).validate() + self.assertNotIn('POST', self.methods()) + + def test_send_waits_and_reads_back_exact_returned_message(self): + self.respond(metadata(), message(), message()) + result = announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1')) + self.assertEqual(result, MESSAGE_ID) + self.assertEqual(self.methods(), ['GET', 'POST', 'GET']) + calls = self.connection.request.call_args_list + self.assertEqual(calls[1].args[:2], ('POST', f'/api/webhooks/{WEBHOOK_ID}/fixture_token-ONLY?wait=true')) + self.assertEqual(json.loads(calls[1].kwargs['body']), announce.release_payload(event(), '1')) + self.assertEqual( + calls[2].args[:2], ('GET', f'/api/webhooks/{WEBHOOK_ID}/fixture_token-ONLY/messages/{MESSAGE_ID}') + ) + + def test_readback_must_match_content_mentions_and_identity(self): + for field, bad in ( + ('content', 'wrong'), + ('mention_everyone', False), + ('mention_everyone', 1), + ('mentions', [{'id': '123'}]), + ('mention_roles', ['123']), + ('id', '999'), + ('channel_id', '999'), + ('webhook_id', '999'), + ): + with self.subTest(field=field, bad=bad): + self.connection.reset_mock() + self.respond(metadata(), message(), dict(message(), **{field: bad})) + with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation'): + announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1')) + self.assertEqual(self.methods().count('POST'), 1) + + def test_missing_readback_fields_fail_closed(self): + for field in message(): + value = message() + del value[field] + self.respond(metadata(), message(), value) + with self.assertRaises(announce.AnnouncementError): + announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1')) + + def test_unsafe_post_message_id_never_becomes_get_path(self): + for value in (None, {}, dict(message(), id='../evil'), dict(message(), id='123?x=y')): + self.connection.reset_mock() + self.respond(metadata(), value) + with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation'): + announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1')) + self.assertEqual(self.methods(), ['GET', 'POST']) + + def test_post_failure_never_retries_and_never_logs_secret(self): + for status in (301, 302, 307, 308, 400, 401, 403, 429, 500, 204): + with self.subTest(status=status): + self.connection.reset_mock() + self.respond(metadata(), message()) + responses = list(self.connection.getresponse.side_effect) + responses[1].status = status + self.connection.getresponse.side_effect = responses + with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation') as caught: + announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1')) + self.assertNotIn('fixture_token', str(caught.exception)) + self.assertEqual(self.methods(), ['GET', 'POST']) + + def test_ambiguous_timeout_never_retries_or_echoes_exception(self): + self.respond(metadata()) + first = next(self.connection.getresponse.side_effect) + self.connection.getresponse.side_effect = [first, TimeoutError(WEBHOOK)] + with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation') as caught: + announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1')) + self.assertNotIn('fixture_token', str(caught.exception)) + self.assertEqual(self.methods(), ['GET', 'POST']) + + def test_malformed_json_response_is_sanitized(self): + self.respond(metadata()) + response = next(self.connection.getresponse.side_effect) + response.read.return_value = WEBHOOK.encode() + self.connection.getresponse.side_effect = [response] + with self.assertRaises(announce.AnnouncementError) as caught: + announce.DiscordWebhook(WEBHOOK).validate() + self.assertNotIn('fixture_token', str(caught.exception)) + + def test_get_redirect_is_not_followed(self): + self.respond(metadata()) + response = next(self.connection.getresponse.side_effect) + response.status = 302 + response.getheader.return_value = 'https://evil.example/' + self.connection.getresponse.side_effect = [response] + with self.assertRaises(announce.AnnouncementError): + announce.DiscordWebhook(WEBHOOK).validate() + self.assertEqual(self.methods(), ['GET']) + + +class EntrypointTests(BaseTest): + def run_main(self, data=None, **overrides): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'event.json' + path.write_text(json.dumps(event() if data is None else data)) + env = { + 'GITHUB_EVENT_NAME': 'release', + 'GITHUB_EVENT_PATH': str(path), + 'GITHUB_REPOSITORY': REPO, + 'GITHUB_RUN_ATTEMPT': '1', + 'DISCORD_RELEASE_WEBHOOK_URL': WEBHOOK, + } + env.update(overrides) + output = io.StringIO() + with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output): + result = announce.main(env) + return result, output.getvalue() + + def test_dispatch_only_validates_even_if_event_contains_release(self): + with patch('announce.DiscordWebhook') as client: + client.return_value.validate.return_value = metadata() + result, output = self.run_main(GITHUB_EVENT_NAME='workflow_dispatch') + self.assertEqual(result, 0) + client.return_value.validate.assert_called_once() + client.return_value.send.assert_not_called() + self.assertIn(GUILD_ID, output) + self.assertIn(CHANNEL_ID, output) + self.assertNotIn('fixture_token', output) + + def test_production_release_sends_once(self): + with patch('announce.DiscordWebhook') as client: + client.return_value.send.return_value = MESSAGE_ID + result, output = self.run_main() + self.assertEqual(result, 0) + client.return_value.send.assert_called_once_with(announce.release_payload(event(), '1')) + self.assertIn(MESSAGE_ID, output) + + def test_skipped_releases_need_no_secret_or_network(self): + for flag in ('draft', 'prerelease'): + value = event() + value['release'][flag] = True + with patch('announce.DiscordWebhook') as client: + result, _ = self.run_main(value, DISCORD_RELEASE_WEBHOOK_URL='') + self.assertEqual(result, 0) + client.assert_not_called() + + def test_rerun_never_constructs_client(self): + with patch('announce.DiscordWebhook') as client: + result, output = self.run_main(GITHUB_RUN_ATTEMPT='2') + self.assertEqual(result, 1) + self.assertIn('manual reconciliation', output) + client.assert_not_called() + + def test_unexpected_event_or_repository_cannot_send(self): + for overrides in ( + {'GITHUB_EVENT_NAME': 'push'}, + {'GITHUB_EVENT_NAME': 'pull_request'}, + {'GITHUB_REPOSITORY': 'evil/LangBot'}, + ): + with patch('announce.DiscordWebhook') as client: + result, _ = self.run_main(**overrides) + self.assertEqual(result, 1) + client.assert_not_called() + + def test_missing_secret_fails_clearly_for_send_and_validation(self): + for name in ('release', 'workflow_dispatch'): + result, output = self.run_main(GITHUB_EVENT_NAME=name, DISCORD_RELEASE_WEBHOOK_URL='') + self.assertEqual(result, 1) + self.assertIn('DISCORD_RELEASE_WEBHOOK_URL is missing', output) + + def test_cli_reads_event_file_and_redacts_invalid_input(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'event.json' + value = event() + value['release']['tag_name'] = '::error::hostile @everyone' + path.write_text(json.dumps(value)) + env = dict( + os.environ, + GITHUB_EVENT_NAME='release', + GITHUB_EVENT_PATH=str(path), + GITHUB_REPOSITORY=REPO, + GITHUB_RUN_ATTEMPT='1', + DISCORD_RELEASE_WEBHOOK_URL=WEBHOOK, + ) + result = subprocess.run( + [sys.executable, str(Path(__file__).with_name('announce.py'))], + env=env, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 1) + self.assertNotIn('hostile', result.stderr) + self.assertNotIn('fixture_token', result.stderr) + self.assertNotIn('Traceback', result.stderr) + + def test_unreadable_event_fails_safely(self): + result, output = self.run_main(GITHUB_EVENT_PATH='/nonexistent/event.json') + self.assertEqual(result, 1) + self.assertNotIn('Traceback', output) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/workflows/build-fnos-fpk.yaml b/.github/workflows/build-fnos-fpk.yaml new file mode 100644 index 000000000..e24906e39 --- /dev/null +++ b/.github/workflows/build-fnos-fpk.yaml @@ -0,0 +1,78 @@ +name: Build fnOS FPK + +on: + workflow_dispatch: + ## 发布release的时候会自动构建 + release: + types: [published] + +permissions: + contents: write + +jobs: + build-fnos-fpk: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + persist-credentials: false + + - name: Check version + id: check_version + run: | + echo $GITHUB_REF + # 如果是tag,则去掉refs/tags/前缀(与其他 release workflow 一致,版本号取 tag 名) + if [[ $GITHUB_REF == refs/tags/* ]]; then + echo "It's a tag" + echo "version=$(echo $GITHUB_REF | awk -F '/' '{print $3}')" >> $GITHUB_OUTPUT + else + # 手动触发(workflow_dispatch):读不到 tag,使用 manifest 内维护的版本 + echo "It's not a tag" + echo "version=$(grep '^version=' packaging/fnos/manifest | cut -d= -f2)" >> $GITHUB_OUTPUT + fi + + - name: Setup Node + uses: actions/setup-node@v2 + with: + node-version: '22' + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install build tools + run: | + pip install pillow + # fnpack:飞牛官方打包 CLI(静态二进制) + curl -fsSL -o /usr/local/bin/fnpack \ + https://static2.fnnas.com/fnpack/fnpack-1.2.3-linux-amd64 + chmod +x /usr/local/bin/fnpack + + - name: Build FPK + env: + FPK_VERSION: ${{ steps.check_version.outputs.version }} + run: | + bash packaging/fnos/build.sh + test -f packaging/fnos/langbot.fpk + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: langbot-${{ steps.check_version.outputs.version }}-fnos + path: packaging/fnos/langbot.fpk + + - name: Upload To Release + # 仅 release 触发时执行;手动/workflow_dispatch 触发时没有 release, + # 且 github.event.release.tag_name 为空(否则 gh release upload 缺参数报错) + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # tag 可能已带 -fnos 后缀(如 v4.10.9-fnos),产物名统一为 + # langbot-<基础版本>-fnos.fpk,避免出现 -fnos-fnos + VER="${{ steps.check_version.outputs.version }}" + BASE="${VER#v}"; BASE="${BASE%-fnos}" + cp packaging/fnos/langbot.fpk "langbot-${BASE}-fnos.fpk" + gh release upload ${{ github.event.release.tag_name }} "langbot-${BASE}-fnos.fpk" diff --git a/.github/workflows/discord-release.yml b/.github/workflows/discord-release.yml new file mode 100644 index 000000000..23e5d8033 --- /dev/null +++ b/.github/workflows/discord-release.yml @@ -0,0 +1,64 @@ +name: Discord Release Announcement + +on: + release: + types: [published] + workflow_dispatch: + push: + paths: + - '.github/workflows/discord-release.yml' + - '.github/discord-release/**' + pull_request: + paths: + - '.github/workflows/discord-release.yml' + - '.github/discord-release/**' + +permissions: + contents: read + +jobs: + tests: + name: Offline announcement tests + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Test helper without secrets or network + run: python3 -m unittest discover -s .github/discord-release -p 'test_*.py' -v + + validate: + name: Validate webhook (GET only, no message) + if: github.repository == 'langbot-app/LangBot' && github.event_name == 'workflow_dispatch' + needs: tests + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Validate incoming webhook and report guild/channel IDs + env: + DISCORD_RELEASE_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} + run: python3 .github/discord-release/announce.py + + announce: + name: Announce published stable release + if: >- + github.repository == 'langbot-app/LangBot' && + github.event_name == 'release' && github.event.action == 'published' && + github.event.release.draft == false && github.event.release.prerelease == false + needs: tests + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + # The helper refuses GITHUB_RUN_ATTEMPT != 1 with recovery guidance. + # Never interpolate release data into a shell command. + - name: Send once and verify the exact Discord message + env: + DISCORD_RELEASE_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} + run: python3 .github/discord-release/announce.py diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml index 086a29915..811939454 100644 --- a/.github/workflows/test-migrations.yml +++ b/.github/workflows/test-migrations.yml @@ -10,12 +10,16 @@ on: - 'src/langbot/pkg/persistence/**' - 'src/langbot/pkg/entity/persistence/**' - 'tests/integration/persistence/**' + - 'tests/unit_tests/api/service/test_monitoring_sessions.py' + - '.github/workflows/test-migrations.yml' pull_request: types: [opened, synchronize, reopened, ready_for_review] paths: - 'src/langbot/pkg/persistence/**' - 'src/langbot/pkg/entity/persistence/**' - 'tests/integration/persistence/**' + - 'tests/unit_tests/api/service/test_monitoring_sessions.py' + - '.github/workflows/test-migrations.yml' jobs: test-migrations-sqlite: @@ -80,6 +84,8 @@ jobs: run: >- uv run pytest tests/integration/persistence/test_migrations_postgres.py + tests/integration/persistence/test_monitoring_postgres.py + tests/unit_tests/api/service/test_monitoring_sessions.py::test_postgres_upgrade_rls_and_concurrent_bot_counts tests/integration/persistence/test_pgvector_postgres.py tests/integration/persistence/test_release_migration_postgres.py tests/integration/persistence/test_plugin_identity_migration.py diff --git a/.gitignore b/.gitignore index 97a64ba81..83b4faf7f 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,17 @@ testsdk/ # Next.js build cache (legacy) web/.next/ +web/.pnpm-home +.tmp +Caddyfile + +# fnOS packaging build artifacts (packaging/fnos/build.sh) +packaging/fnos/app/langbot/ +packaging/fnos/app/bin/ +packaging/fnos/ICON.PNG +packaging/fnos/ICON_256.PNG +packaging/fnos/app/ui/images/ +packaging/fnos/app/desktop/images/ +packaging/fnos/*.fpk + +r.ps1 diff --git a/AGENTS.md b/AGENTS.md index 82739895e..760cf06b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,8 +43,8 @@ Run the narrowest useful test first, then broader checks when confidence is need ## Where to Look - Architecture map: `ARCHITECTURE.md`. -- Dev environment guide: https://docs.langbot.app/zh/develop/dev-config. -- Plugin runtime / CLI / SDK debugging: https://docs.langbot.app/zh/develop/plugin-runtime. +- Dev environment guide: https://langbot.app/docs/zh/develop/dev-config. +- Plugin runtime / CLI / SDK debugging: https://langbot.app/docs/zh/develop/plugin-runtime. - API-key auth: `docs/API_KEY_AUTH.md`. - Box deep-dive notes: `docs/review/box-architecture.md` and related files. - In-repo skills: `skills/` is the single source of truth for LangBot agent skills. diff --git a/README.md b/README.md index f6eeb0b22..5de92d3ad 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本 [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) Website | -Features | -Docs | -API | +Features | +Docs | +API | Cloud | Plugin Market | Roadmap @@ -49,7 +49,7 @@ LangBot is an **open-source, production-grade platform** for building AI-powered - **Web Management Panel** — Configure, manage, and monitor your bots through an intuitive browser interface. No YAML editing required. - **Multi-Pipeline Architecture** — Different bots for different scenarios, with comprehensive monitoring and exception handling. -[→ Learn more about all features](https://link.langbot.app/en/docs/features) +[→ Learn more about all features](https://langbot.app/docs/en/insight/features) 📍 Practical guides: [deploy a multi-platform AI bot in 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connect DeepSeek to WeChat, Discord, and Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [run a Dify Agent in Discord, Telegram, and Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), and [build an n8n-powered chatbot](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). @@ -89,18 +89,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**More options:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) - ---- - -## Live Demo - -**Try it now:** https://demo.langbot.dev/ - -- Email: `demo@langbot.app` -- Password: `langbot123456` - -_Note: Public demo environment. Do not enter sensitive information._ +**More options:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manual](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes) --- @@ -151,7 +140,7 @@ _Note: Public demo environment. Do not enter sensitive information._ | [302.AI](https://share.302ai.cn/SuTG99) | Gateway | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ | -[→ View all integrations](https://link.langbot.app/en/docs/features) +[→ View all integrations](https://langbot.app/docs/en/insight/features) --- diff --git a/README_CN.md b/README_CN.md index 77b448f87..aad5c6efa 100644 --- a/README_CN.md +++ b/README_CN.md @@ -21,9 +21,9 @@ [![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot) 官网 | -特性 | -文档 | -API | +特性 | +文档 | +API | Cloud | 扩展市场 | 路线图 @@ -49,7 +49,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时 - **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。 - **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。 -[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features) +[→ 了解更多功能特性](https://langbot.app/docs/zh/insight/features) 📍 实践指南:[5 分钟部署多平台 AI 机器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[将 DeepSeek 接入微信、企业微信与 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[让 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 构建多平台 AI 聊天机器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。 @@ -89,17 +89,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手动部署](https://link.langbot.app/zh/docs/manual-deploy) · [宝塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes) - ---- - -## 在线演示 - -**立即体验:** https://demo.langbot.dev/ -- 邮箱:`demo@langbot.app` -- 密码:`langbot123456` - -*注意:公开演示环境,请不要在其中填入任何敏感信息。* +**更多方式:** [Docker](https://langbot.app/docs/zh/deploy/langbot/docker) · [手动部署](https://langbot.app/docs/zh/deploy/langbot/manual) · [宝塔面板](https://langbot.app/docs/zh/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/zh/deploy/langbot/kubernetes) --- @@ -152,7 +142,7 @@ docker compose --profile all up -d | [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ | | [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ | -[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features) +[→ 查看完整集成列表](https://langbot.app/docs/zh/insight/features) ### TTS(语音合成) diff --git a/README_ES.md b/README_ES.md index 2f7ec0ce1..04ebc78a3 100644 --- a/README_ES.md +++ b/README_ES.md @@ -19,9 +19,9 @@ [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) Inicio | -Características | -Documentación | -API | +Características | +Documentación | +API | Mercado de Plugins | Hoja de Ruta @@ -48,7 +48,7 @@ LangBot es una **plataforma de código abierto y grado de producción** para con - **Panel de Gestión Web** — Configure, gestione y monitoree sus bots a través de una interfaz de navegador intuitiva. Sin necesidad de editar YAML. - **Arquitectura Multi-Pipeline** — Diferentes bots para diferentes escenarios, con monitoreo completo y manejo de excepciones. -[→ Conocer más sobre todas las funcionalidades](https://link.langbot.app/en/docs/features) +[→ Conocer más sobre todas las funcionalidades](https://langbot.app/docs/en/insight/features) 📍 Guías prácticas: [desplegar un bot de IA multiplataforma en 5 minutos](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [conectar DeepSeek a WeChat, Discord y Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [ejecutar un Dify Agent en Discord, Telegram y Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) y [crear un chatbot con n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). @@ -88,17 +88,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**Más opciones:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) - ---- - -## Demo en Vivo - -**Pruébelo ahora:** https://demo.langbot.dev/ -- Correo electrónico: `demo@langbot.app` -- Contraseña: `langbot123456` - -*Nota: Entorno de demostración público. No ingrese información confidencial.* +**Más opciones:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manual](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes) --- @@ -149,7 +139,7 @@ docker compose --profile all up -d | [302.AI](https://share.302ai.cn/SuTG99) | Pasarela | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ | -[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features) +[→ Ver todas las integraciones](https://langbot.app/docs/en/insight/features) --- diff --git a/README_FR.md b/README_FR.md index 66d1f9bb6..78d99c692 100644 --- a/README_FR.md +++ b/README_FR.md @@ -19,9 +19,9 @@ [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) Accueil | -Fonctionnalités | -Documentation | -API | +Fonctionnalités | +Documentation | +API | Marché des Plugins | Feuille de Route @@ -48,7 +48,7 @@ LangBot est une **plateforme open-source de niveau production** pour créer des - **Panneau de Gestion Web** — Configurez, gérez et surveillez vos bots via une interface navigateur intuitive. Aucune édition de YAML requise. - **Architecture Multi-Pipeline** — Différents bots pour différents scénarios, avec surveillance complète et gestion des exceptions. -[→ En savoir plus sur toutes les fonctionnalités](https://link.langbot.app/en/docs/features) +[→ En savoir plus sur toutes les fonctionnalités](https://langbot.app/docs/en/insight/features) 📍 Guides pratiques : [déployer un bot IA multiplateforme en 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connecter DeepSeek à WeChat, Discord et Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [exécuter un Dify Agent dans Discord, Telegram et Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) et [créer un chatbot avec n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). @@ -88,17 +88,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**Plus d'options :** [Docker](https://link.langbot.app/en/docs/docker) · [Manuel](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) - ---- - -## Démo en Ligne - -**Essayez maintenant :** https://demo.langbot.dev/ -- Email : `demo@langbot.app` -- Mot de passe : `langbot123456` - -*Note : Environnement de démonstration public. Ne saisissez pas d'informations sensibles.* +**Plus d'options :** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manuel](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes) --- @@ -149,7 +139,7 @@ docker compose --profile all up -d | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ | -[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features) +[→ Voir toutes les intégrations](https://langbot.app/docs/en/insight/features) --- diff --git a/README_JP.md b/README_JP.md index efc6e23b2..b876bd4ee 100644 --- a/README_JP.md +++ b/README_JP.md @@ -19,9 +19,9 @@ [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) ホーム | -機能 | -ドキュメント | -API | +機能 | +ドキュメント | +API | プラグインマーケット | ロードマップ @@ -48,7 +48,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構 - **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。 - **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。 -[→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/features) +[→ すべての機能について詳しく見る](https://langbot.app/docs/ja/insight/features) 📍 実践ガイド: [5分でマルチプラットフォームAIボットをデプロイ](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/)、[DeepSeekをWeChat・Discord・Telegramに接続](https://langbot.app/en/blog/connect-deepseek-to-wechat/)、[Dify AgentをDiscord・Telegram・Slackで動かす](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/)、[n8n連携チャットボットを構築](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/)。 @@ -88,17 +88,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**その他:** [Docker](https://link.langbot.app/en/docs/docker) · [手動デプロイ](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) - ---- - -## ライブデモ - -**今すぐ試す:** https://demo.langbot.dev/ -- メール: `demo@langbot.app` -- パスワード: `langbot123456` - -*注意: 公開デモ環境です。機密情報を入力しないでください。* +**その他:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [手動デプロイ](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes) --- @@ -149,7 +139,7 @@ docker compose --profile all up -d | [302.AI](https://share.302ai.cn/SuTG99) | ゲートウェイ | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ | -[→ すべての統合を表示](https://link.langbot.app/en/docs/features) +[→ すべての統合を表示](https://langbot.app/docs/en/insight/features) --- diff --git a/README_KO.md b/README_KO.md index 7e0284788..b28d3ec2c 100644 --- a/README_KO.md +++ b/README_KO.md @@ -19,9 +19,9 @@ [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) 홈 | -기능 | -문서 | -API | +기능 | +문서 | +API | 플러그인 마켓 | 로드맵 @@ -48,7 +48,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈 - **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요. - **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리. -[→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features) +[→ 모든 기능 자세히 보기](https://langbot.app/docs/en/insight/features) 📍 실전 가이드: [5분 만에 멀티 플랫폼 AI 봇 배포하기](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [DeepSeek를 WeChat, Discord, Telegram에 연결하기](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [Dify Agent를 Discord, Telegram, Slack에서 실행하기](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), [n8n 기반 챗봇 만들기](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). @@ -88,17 +88,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**더 많은 옵션:** [Docker](https://link.langbot.app/en/docs/docker) · [수동 배포](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) - ---- - -## 라이브 데모 - -**지금 체험:** https://demo.langbot.dev/ -- 이메일: `demo@langbot.app` -- 비밀번호: `langbot123456` - -*참고: 공개 데모 환경입니다. 민감한 정보를 입력하지 마세요.* +**더 많은 옵션:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [수동 배포](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes) --- @@ -149,7 +139,7 @@ docker compose --profile all up -d | [302.AI](https://share.302ai.cn/SuTG99) | 게이트웨이 | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ | -[→ 모든 통합 보기](https://link.langbot.app/en/docs/features) +[→ 모든 통합 보기](https://langbot.app/docs/en/insight/features) --- diff --git a/README_RU.md b/README_RU.md index f779c0c46..f6c1f8bce 100644 --- a/README_RU.md +++ b/README_RU.md @@ -19,9 +19,9 @@ [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) Главная | -Возможности | -Документация | -API | +Возможности | +Документация | +API | Магазин плагинов | Дорожная карта @@ -48,7 +48,7 @@ LangBot — это **платформа с открытым исходным к - **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется. - **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений. -[→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features) +[→ Подробнее обо всех возможностях](https://langbot.app/docs/en/insight/features) 📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 5 минут](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [подключить DeepSeek к WeChat, Discord и Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [запустить Dify Agent в Discord, Telegram и Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) и [создать чат-бота на n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). @@ -88,17 +88,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**Другие варианты:** [Docker](https://link.langbot.app/en/docs/docker) · [Ручная установка](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) - ---- - -## Демо - -**Попробуйте прямо сейчас:** https://demo.langbot.dev/ -- Email: `demo@langbot.app` -- Пароль: `langbot123456` - -*Примечание: Публичная демо-среда. Не вводите конфиденциальную информацию.* +**Другие варианты:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Ручная установка](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes) --- @@ -149,7 +139,7 @@ docker compose --profile all up -d | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ | -[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features) +[→ Смотреть все интеграции](https://langbot.app/docs/en/insight/features) --- diff --git a/README_TW.md b/README_TW.md index 9abb32976..140515650 100644 --- a/README_TW.md +++ b/README_TW.md @@ -21,9 +21,9 @@ [![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot) 官網 | -特性 | -文件 | -API | +特性 | +文件 | +API | 外掛市場 | 路線圖 @@ -50,7 +50,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時 - **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。 - **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。 -[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features) +[→ 了解更多功能特性](https://langbot.app/docs/zh/insight/features) 📍 實踐指南:[5 分鐘部署多平台 AI 機器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[將 DeepSeek 接入微信、企業微信與 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[讓 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 建構多平台 AI 聊天機器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。 @@ -90,17 +90,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手動部署](https://link.langbot.app/zh/docs/manual-deploy) · [寶塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes) - ---- - -## 線上演示 - -**立即體驗:** https://demo.langbot.dev/ -- 信箱:`demo@langbot.app` -- 密碼:`langbot123456` - -*注意:公開演示環境,請不要在其中填入任何敏感資訊。* +**更多方式:** [Docker](https://langbot.app/docs/zh/deploy/langbot/docker) · [手動部署](https://langbot.app/docs/zh/deploy/langbot/manual) · [寶塔面板](https://langbot.app/docs/zh/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/zh/deploy/langbot/kubernetes) --- @@ -165,7 +155,7 @@ docker compose --profile all up -d |-----------|------| | 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) | -[→ 查看完整整合列表](https://link.langbot.app/zh/docs/features) +[→ 查看完整整合列表](https://langbot.app/docs/zh/insight/features) --- diff --git a/README_VI.md b/README_VI.md index ca9ef667d..356f577b3 100644 --- a/README_VI.md +++ b/README_VI.md @@ -19,9 +19,9 @@ [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) Trang chủ | -Tính năng | -Tài liệu | -API | +Tính năng | +Tài liệu | +API | Chợ Plugin | Lộ trình @@ -48,7 +48,7 @@ LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để x - **Bảng quản lý Web** — Cấu hình, quản lý và giám sát bot thông qua giao diện trình duyệt trực quan. Không cần chỉnh sửa YAML. - **Kiến trúc đa Pipeline** — Các bot khác nhau cho các kịch bản khác nhau, với giám sát toàn diện và xử lý ngoại lệ. -[→ Tìm hiểu thêm về tất cả tính năng](https://link.langbot.app/en/docs/features) +[→ Tìm hiểu thêm về tất cả tính năng](https://langbot.app/docs/en/insight/features) 📍 Hướng dẫn thực hành: [triển khai bot AI đa nền tảng trong 5 phút](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [kết nối DeepSeek với WeChat, Discord và Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [chạy Dify Agent trên Discord, Telegram và Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) và [xây dựng chatbot với n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). @@ -88,17 +88,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**Thêm tùy chọn:** [Docker](https://link.langbot.app/en/docs/docker) · [Thủ công](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) - ---- - -## Demo trực tuyến - -**Thử ngay:** https://demo.langbot.dev/ -- Email: `demo@langbot.app` -- Mật khẩu: `langbot123456` - -*Lưu ý: Môi trường demo công khai. Không nhập thông tin nhạy cảm.* +**Thêm tùy chọn:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Thủ công](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes) --- @@ -149,7 +139,7 @@ docker compose --profile all up -d | [302.AI](https://share.302ai.cn/SuTG99) | Cổng | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ | -[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features) +[→ Xem tất cả tích hợp](https://langbot.app/docs/en/insight/features) --- diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index c2c276ac5..cb5f1c88e 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -1,5 +1,5 @@ # Docker Compose configuration for LangBot -# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://docs.langbot.app +# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://langbot.app/docs version: "3" services: diff --git a/docker/kubernetes.yaml b/docker/kubernetes.yaml index 5504e5219..7ce145610 100644 --- a/docker/kubernetes.yaml +++ b/docker/kubernetes.yaml @@ -1,7 +1,7 @@ # Kubernetes Deployment for LangBot # This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml # -# Full deployment guide (zh/en/ja): https://docs.langbot.app -> Installation -> Kubernetes +# Full deployment guide (zh/en/ja): https://langbot.app/docs -> Installation -> Kubernetes # # Usage: # kubectl -n langbot create secret generic langbot-plugin-runtime-control \ diff --git a/docs/API_KEY_AUTH.md b/docs/API_KEY_AUTH.md index 49d80b6f9..f825ea0a5 100644 --- a/docs/API_KEY_AUTH.md +++ b/docs/API_KEY_AUTH.md @@ -88,6 +88,23 @@ Each endpoint accepts **either**: 1. **User Token** (via `Authorization: Bearer `) - for web UI and authenticated users 2. **API Key** (via `X-API-Key` or `Authorization: Bearer `) - for external services +### Inspecting API Key Identity + +`GET /api/v1/system/context` validates an API key (user JWT not accepted) and returns its bound identity without requiring resource permissions: + +```json +{ + "code": 0, + "msg": "ok", + "data": { + "instance_uuid": "...", + "workspace_uuid": "...", + "api_key_id": "...", + "permissions": ["..."] + } +} +``` + ## Example: Model Management ### List All LLM Models diff --git a/docs/CODEX_SUBSCRIPTION.md b/docs/CODEX_SUBSCRIPTION.md new file mode 100644 index 000000000..b7324e404 --- /dev/null +++ b/docs/CODEX_SUBSCRIPTION.md @@ -0,0 +1,65 @@ +# ChatGPT / Codex subscription + +LangBot's **OpenAI Codex** model provider uses **Sign in with ChatGPT** and the account's Codex entitlement. It is separate from the existing OpenAI API-key provider: subscribing to ChatGPT does not supply an OpenAI Platform API key, and API-key billing is unchanged. + +## Connect an account + +1. Open **Models**, choose **Add Provider**, and select **OpenAI Codex**. +2. Enter a provider name and choose **Save and sign in**. This saves the provider before authorization, so an interrupted login can be retried from its settings. +3. Open the OpenAI authorization link and enter the one-time code displayed in LangBot. Sign in on OpenAI's site, not in LangBot. +4. If OpenAI asks you to enable device-code authorization, enable it in your ChatGPT account's security settings, or contact your workspace administrator. +5. Keep the LangBot dialog open until it confirms the connection, then finish the form. +6. Use the existing **Scan models** or **Add model** controls, test the model, and select it in a pipeline as usual. Only LLM models are supported by this provider. + +The device-code flow also works when LangBot runs remotely or in Docker: the browser does not need to reach a localhost OAuth callback on the server. Serve the LangBot management panel over HTTPS when accessing it remotely. + +The account's model catalog is authoritative. A model listed elsewhere or entered manually is not a guarantee that this account has access. Scan errors are reported rather than replaced with a fabricated available-model list. + +## Reconnect and disconnect + +Open the provider's existing settings to sign in again or disconnect. LangBot refreshes expiring access tokens automatically. A revoked or invalid refresh grant requires another sign-in; transient network failures are not proof that the grant was revoked. + +**Disconnect** removes this provider's locally stored authorization. It does not log the account out of other applications or revoke the account globally. Canceling a pending sign-in is separate from disconnecting an existing account. Removing a provider also removes its authorization; the normal rule that models must be removed first still applies. + +A saved provider can remain disconnected. Scanning or invoking it then returns a sign-in-required error; LangBot does not silently switch to paid API-key billing. + +## Usage and deployment boundary + +Calls consume the connected account's included Codex usage and remain subject to OpenAI's plan limits, model availability, workspace policies, and terms. Token counts recorded by LangBot are request usage, not a measurement of remaining subscription quota or an OpenAI invoice. + +Use this integration for your own authorized account and trusted workflows. Third-party sign-in support is not permission to pool accounts, resell subscription quota, or redistribute one subscription as a shared API service. For a public or commercial multi-user service, use the appropriate OpenAI API or separately authorized enterprise arrangement. The provider remains a Workspace resource in LangBot: consider who can invoke its models before connecting a personal account. + +## Credential handling and API surface + +- OAuth credentials are stored server-side separately from provider API keys. Provider and model reads do not supply OAuth access, refresh, or ID tokens. +- Authorization uses a fixed OpenAI origin. The Codex provider does not accept a custom base URL or manually supplied API keys. +- Authentication controls require an authenticated LangBot browser user with `provider_secret.manage` in the selected Workspace. Pending attempts are scoped to the Workspace, provider, and initiating user. +- Browser storage must not contain OAuth tokens. Treat the server database and its backups as sensitive application data. +- MCP and LangBot API keys do not expose the browser-only OAuth controls. Agents may inspect configured providers and models with the existing tools, but a human connects the subscription in the management panel. + +The provider-scoped authentication routes are under `/api/v1/provider/providers/{uuid}/codex`: + +| Method | Suffix | Purpose | +| --- | --- | --- | +| GET | `/status` | Read local connection state without returning credentials | +| POST | `/device` | Start device authorization | +| POST | `/device/poll` | Poll the initiating user's authorization attempt | +| DELETE | `/device/{authorization_id}` | Cancel only that pending attempt | +| DELETE | `/auth` | Remove local authorization | + +Use the returned polling interval and expiration time. An expired attempt must be restarted. These routes are not a general-purpose subscription-to-API gateway. + +## References + +- [OpenAI Codex authentication](https://developers.openai.com/codex/auth): ChatGPT versus API-key access and device-code login. +- [Hermes Agent providers](https://hermes-agent.nousresearch.com/docs/integrations/providers/): subscription device authentication and refresh recovery. +- [OpenClaw OpenAI provider](https://docs.openclaw.ai/providers/openai): subscription and API-key route distinctions. +- [New API](https://github.com/QuantumNous/new-api): reference for Codex protocol compatibility; its gateway/account-pooling product model is not adopted here. + +## 中文快速说明 + +在「模型」中添加提供商,选择 **OpenAI Codex**,填写名称并点击「保存并登录」。打开 OpenAI 授权页面,输入 LangBot 显示的一次性验证码,完成授权后回到原对话框。随后照常扫描或添加模型、测试模型,并在流水线中选择它。 + +无需填写 API Key,也无需为远程服务器配置 localhost 回调。登录中断后可以从该提供商的设置中重试;断开连接只删除 LangBot 中保存的授权。调用消耗所登录账号的 Codex 额度,受账号实际权限和 OpenAI 限制约束,不会自动转用按量付费的 OpenAI API。 + +此功能用于自己的授权账号及可信工作流,不应将个人订阅作为面向多个用户转售或共享的 API 服务。提供商仍是 LangBot 工作空间内的资源,连接个人账号前请确认模型的使用范围。 diff --git a/docs/HTTP_BOT_ADAPTER_DESIGN.md b/docs/HTTP_BOT_ADAPTER_DESIGN.md index 31e9a4861..d0826f2b5 100644 --- a/docs/HTTP_BOT_ADAPTER_DESIGN.md +++ b/docs/HTTP_BOT_ADAPTER_DESIGN.md @@ -218,8 +218,8 @@ metadata: spec: categories: [popular, global] help_links: - zh: https://docs.langbot.app/zh/platforms/http-bot - en: https://docs.langbot.app/en/platforms/http-bot + zh: https://langbot.app/docs/zh/platforms/http-bot + en: https://langbot.app/docs/en/platforms/http-bot config: - { name: inbound_secret, type: string, required: true, default: "" } - { name: callback_url, type: string, required: false, default: "" } diff --git a/docs/SEEKDB_INTEGRATION.md b/docs/SEEKDB_INTEGRATION.md index a38eb9f08..4a2b55181 100644 --- a/docs/SEEKDB_INTEGRATION.md +++ b/docs/SEEKDB_INTEGRATION.md @@ -243,7 +243,7 @@ For large datasets: - SeekDB GitHub: https://github.com/oceanbase/seekdb - pyseekdb SDK: https://github.com/oceanbase/pyseekdb - OceanBase Documentation: https://oceanbase.ai -- LangBot Documentation: https://docs.langbot.app +- LangBot Documentation: https://langbot.app/docs ## License diff --git a/docs/assets/pr-2363/oauth-required-state.png b/docs/assets/pr-2363/oauth-required-state.png new file mode 100644 index 000000000..a969c3b9f Binary files /dev/null and b/docs/assets/pr-2363/oauth-required-state.png differ diff --git a/examples/http-bot/README.md b/examples/http-bot/README.md index b04387d78..62a1e469d 100644 --- a/examples/http-bot/README.md +++ b/examples/http-bot/README.md @@ -6,7 +6,7 @@ Minimal, dependency-light clients for the LangBot **HTTP Bot** platform adapter. They show the whole loop: signing a request, pushing a message, and receiving multi-part replies on a callback endpoint. -Full guide: [docs.langbot.app — HTTP Bot](https://docs.langbot.app/en/usage/platforms/http-bot). +Full guide: [docs.langbot.app — HTTP Bot](https://langbot.app/docs/en/usage/platforms/http-bot). Machine-readable contract: [`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json). ## Files diff --git a/examples/http-bot/README.zh.md b/examples/http-bot/README.zh.md index 1baf81272..fbd2f4394 100644 --- a/examples/http-bot/README.zh.md +++ b/examples/http-bot/README.zh.md @@ -6,7 +6,7 @@ 它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收 1→M 的多段回复。 -完整指南:[docs.langbot.app —— HTTP Bot](https://docs.langbot.app/zh/usage/platforms/http-bot)。 +完整指南:[docs.langbot.app —— HTTP Bot](https://langbot.app/docs/zh/usage/platforms/http-bot)。 机器可读的接口契约:[`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json)。 ## 文件清单 diff --git a/examples/web-page-bot/README.md b/examples/web-page-bot/README.md index e31f41ca2..d52bd9a52 100644 --- a/examples/web-page-bot/README.md +++ b/examples/web-page-bot/README.md @@ -6,7 +6,7 @@ A single self-contained HTML page that demos the LangBot **Page Bot** (`web_page_bot`) embeddable chat widget — the one you drop onto any website with a single ``, + }), + ); + await page.addInitScript((mode) => { + const w = window as any; + w.copyEvents = []; + document.addEventListener('copy', () => { + const el = document.activeElement as HTMLTextAreaElement; + w.copyEvents.push({ + tag: el.tagName, + selected: el.value?.slice(el.selectionStart, el.selectionEnd), + }); + }); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: + mode === 'unavailable' + ? undefined + : { + writeText: (text: string) => { + if (mode === 'success') { + w.written = text; + return Promise.resolve(); + } + if (mode === 'delayed') + return new Promise((resolve) => { + w.resolveCopy = resolve; + }); + return Promise.reject(new Error('denied')); + }, + }, + }); + if (mode === 'false') document.execCommand = () => false; + if (mode === 'throw') + document.execCommand = () => { + throw new Error('denied'); + }; + }, mode); + await page.goto('/copy-harness'); + await expect( + page.getByRole('button', { name: 'models.codex.copyCode', exact: true }), + ).toBeVisible(); +} +const copy = (page: Page) => + page.getByRole('button', { name: 'models.codex.copyCode', exact: true }); +const copied = (page: Page) => + page.getByRole('button', { name: 'models.codex.copied', exact: true }); + +test('Clipboard API success shows icon, toast and transient feedback', async ({ + page, +}) => { + await mount(page, 'success'); + await expect(copy(page).locator('svg.lucide-copy')).toBeVisible(); + await copy(page).click(); + await expect(copied(page).locator('svg.lucide-check')).toBeVisible(); + await expect( + page.getByText('common.copySuccess', { exact: true }), + ).toBeVisible(); + expect(await page.evaluate(() => (window as any).written)).toBe( + 'FIXTURE-1234', + ); + await expect(copy(page)).toBeVisible({ timeout: 4000 }); +}); +for (const mode of ['unavailable', 'rejected']) + test(`${mode} API performs a real selected-text copy inside modal`, async ({ + page, + }) => { + await mount(page, mode); + await copy(page).click(); + await expect(copied(page)).toBeVisible(); + expect(await page.evaluate(() => (window as any).copyEvents)).toEqual([ + { tag: 'TEXTAREA', selected: 'FIXTURE-1234' }, + ]); + await expect(copied(page)).toBeFocused(); + await expect(page.locator('textarea')).toHaveCount(0); + }); +for (const mode of ['false', 'throw']) + test(`${mode} fallback reports failure and manual guidance`, async ({ + page, + }) => { + await mount(page, mode); + await copy(page).click(); + await expect( + page.getByText('common.copyFailed', { exact: true }), + ).toBeVisible(); + await expect( + page.getByText('models.codex.copyManually', { exact: true }), + ).toBeVisible(); + await expect(copy(page)).toBeVisible(); + await expect(page.locator('textarea')).toHaveCount(0); + await expect(copy(page)).toBeFocused(); + }); +test('new code or attempt clears copied feedback', async ({ page }) => { + await mount(page, 'success'); + await copy(page).click(); + await expect(copied(page)).toBeVisible(); + await page.evaluate(() => + (window as any).renderCode('FIXTURE-5678', 'attempt-2'), + ); + await expect(copy(page)).toBeVisible(); + await copy(page).click(); + await expect(copied(page)).toBeVisible(); + await page.evaluate(() => + (window as any).renderCode('FIXTURE-5678', 'attempt-3'), + ); + await expect(copy(page)).toBeVisible(); +}); +test('completion from an old attempt cannot mark the new code copied', async ({ + page, +}) => { + await mount(page, 'delayed'); + await copy(page).click(); + await page.evaluate(() => + (window as any).renderCode('FIXTURE-5678', 'attempt-2'), + ); + await expect(page.getByText('FIXTURE-5678')).toBeVisible(); + await page.evaluate(() => (window as any).resolveCopy()); + await expect(copy(page)).toBeVisible(); + await expect(copied(page)).toHaveCount(0); +}); diff --git a/web/tests/e2e/codex-subscription.spec.ts b/web/tests/e2e/codex-subscription.spec.ts new file mode 100644 index 000000000..5a67867f9 --- /dev/null +++ b/web/tests/e2e/codex-subscription.spec.ts @@ -0,0 +1,341 @@ +import { writeFileSync } from 'node:fs'; +import { expect, test, type Page, type Route } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +// All OAuth, provider and model responses here are explicit UI fixtures. +// These tests never authenticate with OpenAI or use a real subscription. +async function fixture(page: Page) { + await installLangBotApiMocks(page, { authenticated: true }); + const state = { + providers: [] as Record[], + creates: 0, + starts: 0, + polls: 0, + cancels: 0, + disconnects: 0, + connected: false, + failStart: false, + pollStatus: 'pending', + interval: 1, + expiresIn: 600, + }; + const ok = (route: Route, data: unknown) => + route.fulfill({ json: { code: 0, data } }); + await page.route('**/api/v1/provider/**', async (route) => { + const url = new URL(route.request().url()); + const path = url.pathname; + const method = route.request().method(); + if (path.endsWith('/icon')) + return route.fulfill({ + contentType: 'image/svg+xml', + body: '', + }); + if (path.endsWith('/requesters')) + return ok(route, { + requesters: ['openai-codex', 'openai'].map((name) => ({ + name, + label: { + en_US: name === 'openai-codex' ? 'OpenAI Codex' : 'OpenAI API', + }, + description: { en_US: '' }, + spec: { + provider_category: 'manufacturer', + support_type: ['llm'], + config: [ + { name: 'base_url', default: 'https://api.openai.com/v1' }, + ], + }, + })), + }); + if (path.endsWith('/providers')) { + if (method === 'POST') { + state.creates++; + const provider = { + ...route.request().postDataJSON(), + uuid: `provider-${state.creates}`, + }; + state.providers.push(provider); + return ok(route, { uuid: provider.uuid }); + } + return ok(route, { providers: state.providers }); + } + if (path.endsWith('/codex/status')) + return ok(route, { + status: state.connected ? 'connected' : 'disconnected', + connected: state.connected, + expires_at: null, + }); + if (path.endsWith('/codex/device') && method === 'POST') { + state.starts++; + if (state.failStart) + return route.fulfill({ + status: 400, + json: { code: 400, msg: 'Fixture start failure' }, + }); + return ok(route, { + authorization_id: `attempt-${state.starts}`, + user_code: 'TEST-1234', + verification_uri: 'https://auth.openai.com/codex/device', + interval: state.interval, + expires_at: Date.now() / 1000 + state.expiresIn, + }); + } + if (path.endsWith('/codex/device/poll')) { + state.polls++; + expect(route.request().postDataJSON()).toEqual({ + authorization_id: `attempt-${state.starts}`, + }); + if (state.pollStatus === 'connected') state.connected = true; + return ok(route, { status: state.pollStatus, interval: state.interval }); + } + if (path.includes('/codex/device/') && method === 'DELETE') { + state.cancels++; + return ok(route, {}); + } + if (path.endsWith('/codex/auth') && method === 'DELETE') { + state.disconnects++; + state.connected = false; + return ok(route, {}); + } + if (/\/providers\/provider-\d+$/.test(path)) { + const provider = state.providers.find((p) => + path.endsWith(String(p.uuid)), + ); + if (method === 'PUT') + Object.assign(provider!, route.request().postDataJSON()); + return ok(route, { provider }); + } + if (path.includes('/models/')) return ok(route, { models: [] }); + return ok(route, {}); + }); + return state; +} + +async function openModels(page: Page) { + await page.goto('/home/bots'); + await page.getByRole('button', { name: 'Models', exact: true }).click(); + await page.getByRole('button', { name: 'Add Provider', exact: true }).click(); +} +async function choose(page: Page, name: string) { + await page + .getByRole('button', { name: 'Select Provider Type', exact: true }) + .click(); + await page.getByRole('button', { name: new RegExp(name) }).click(); +} + +for (const width of [1280, 390, 320]) { + test(`subscription sign-in in the existing provider dialog (${width}px, UI fixture)`, async ({ + page, + }) => { + const state = await fixture(page); + await page.setViewportSize({ width: 1280, height: 900 }); + await openModels(page); + await page.setViewportSize({ width, height: 900 }); + await page.locator('input[name="name"]').fill('My Codex'); + await choose(page, 'OpenAI Codex'); + await expect(page.locator('input[name="api_key"]')).toHaveCount(0); + await expect(page.locator('input[name="base_url"]')).toHaveCount(0); + await page + .getByRole('button', { name: 'Save and sign in', exact: true }) + .click(); + await expect(page.getByText('TEST-1234')).toBeVisible(); + await page.getByRole('button', { name: 'Copy code', exact: true }).click(); + await expect( + page.getByRole('button', { name: 'Copied', exact: true }), + ).toBeVisible(); + await expect( + page.getByText('Copy Successfully', { exact: true }), + ).toBeInViewport({ ratio: 1 }); + expect(state.creates).toBe(1); + expect(state.providers[0]).toMatchObject({ + requester: 'openai-codex', + api_keys: [], + base_url: 'https://chatgpt.com/backend-api/codex', + }); + await expect( + page.getByRole('link', { name: 'Continue at OpenAI' }), + ).toHaveAttribute('href', 'https://auth.openai.com/codex/device'); + const geometry = await page.getByTestId('codex-account').evaluate((el) => { + const box = el.getBoundingClientRect(); + return { + left: box.left, + right: box.right, + width: innerWidth, + documentWidth: document.documentElement.scrollWidth, + }; + }); + expect(geometry.left).toBeGreaterThanOrEqual(0); + expect(geometry.right).toBeLessThanOrEqual(width); + expect(geometry.documentWidth).toBeLessThanOrEqual(width); + if (process.env.CODEX_EVIDENCE_DIR) { + await page.locator('[data-sonner-toast]').evaluate(async (el) => { + await Promise.all( + el + .getAnimations({ subtree: true }) + .map((animation) => animation.finished.catch(() => undefined)), + ); + }); + const screenshot = `${process.env.CODEX_EVIDENCE_DIR}/codex-${width}.png`; + await page.screenshot({ path: screenshot, fullPage: true }); + writeFileSync( + `${process.env.CODEX_EVIDENCE_DIR}/codex-${width}.json`, + JSON.stringify( + { + evidence: 'UI fixture only; not live OpenAI sign-in', + viewport: { width, height: 900 }, + geometry, + screenshot, + }, + null, + 2, + ), + ); + } + state.pollStatus = 'connected'; + await expect(page.getByText('Connected', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Done', exact: true }).click(); + await expect(page.getByText('My Codex', { exact: true })).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Add Model', exact: true }), + ).toBeVisible(); + expect(state.creates).toBe(1); + expect( + await page.evaluate(() => JSON.stringify({ ...localStorage })), + ).not.toContain('attempt-'); + }); +} + +test('failed start retries reuse saved provider; cancellation refreshes list', async ({ + page, +}) => { + const state = await fixture(page); + state.failStart = true; + await openModels(page); + await page.locator('input[name="name"]').fill('Retry Codex'); + await choose(page, 'OpenAI Codex'); + await page + .getByRole('button', { name: 'Save and sign in', exact: true }) + .click(); + await expect(page.getByRole('alert')).toContainText('Unable to sign in'); + state.failStart = false; + await page.getByRole('button', { name: 'Try again', exact: true }).click(); + await expect(page.getByText('TEST-1234')).toBeVisible(); + await page + .getByRole('button', { name: 'Cancel sign-in', exact: true }) + .click(); + await expect.poll(() => state.cancels).toBe(1); + await page.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(page.getByText('Retry Codex', { exact: true })).toBeVisible(); + expect(state.creates).toBe(1); +}); + +test('reconnect cancellation preserves connection and disconnect requires confirmation', async ({ + page, +}) => { + const state = await fixture(page); + state.pollStatus = 'connected'; + await openModels(page); + await page.locator('input[name="name"]').fill('Managed Codex'); + await choose(page, 'OpenAI Codex'); + await page + .getByRole('button', { name: 'Save and sign in', exact: true }) + .click(); + await expect(page.getByText('Connected', { exact: true })).toBeVisible(); + state.pollStatus = 'pending'; + await page.getByRole('button', { name: 'Reconnect', exact: true }).click(); + await expect(page.getByText('TEST-1234')).toBeVisible(); + await page + .getByRole('button', { name: 'Cancel sign-in', exact: true }) + .click(); + await expect(page.getByText('Connected', { exact: true })).toBeVisible(); + expect(state.disconnects).toBe(0); + await page.getByRole('button', { name: 'Disconnect', exact: true }).click(); + expect(state.disconnects).toBe(0); + await page + .getByRole('button', { name: 'Confirm disconnect', exact: true }) + .click(); + await expect(page.getByText('Not connected', { exact: true })).toBeVisible(); + expect(state.disconnects).toBe(1); + expect(state.creates).toBe(1); +}); + +test('expiration permits retry without duplicate provider and closing cancels pending login', async ({ + page, +}) => { + const state = await fixture(page); + state.expiresIn = 1; + await openModels(page); + await page.locator('input[name="name"]').fill('Expired Codex'); + await choose(page, 'OpenAI Codex'); + await page + .getByRole('button', { name: 'Save and sign in', exact: true }) + .click(); + await expect( + page.getByText('Sign-in expired. Start again to get a new code.'), + ).toBeVisible(); + await expect.poll(() => state.cancels).toBe(1); + state.expiresIn = 600; + await page.getByRole('button', { name: 'Try again', exact: true }).click(); + await expect(page.getByText('TEST-1234')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect.poll(() => state.cancels).toBe(2); + await expect(page.getByText('Expired Codex', { exact: true })).toBeVisible(); + expect(state.creates).toBe(1); + await page.getByRole('button', { name: 'Add Provider', exact: true }).click(); + await page.locator('input[name="name"]').fill('Second Codex'); + await choose(page, 'OpenAI Codex'); + await page + .getByRole('button', { name: 'Save and sign in', exact: true }) + .click(); + await expect(page.getByText('TEST-1234')).toBeVisible(); + expect(state.creates).toBe(2); + expect(state.providers.map((provider) => provider.name)).toEqual([ + 'Expired Codex', + 'Second Codex', + ]); + await page.keyboard.press('Escape'); + await expect.poll(() => state.cancels).toBe(3); +}); + +test('model test retains the connected provider identity', async ({ page }) => { + const state = await fixture(page); + state.connected = true; + state.providers.push({ + uuid: 'provider-1', + name: 'Connected Codex', + requester: 'openai-codex', + base_url: 'https://chatgpt.com/backend-api/codex', + api_keys: [], + }); + await page.goto('/home/bots'); + await page.getByRole('button', { name: 'Models', exact: true }).click(); + await page.getByRole('button', { name: 'Add Model', exact: true }).click(); + await page + .getByPlaceholder('Model Name', { exact: true }) + .fill('fixture-codex-model'); + const requestPromise = page.waitForRequest('**/models/llm/_/test'); + await page.getByRole('button', { name: 'Test', exact: true }).click(); + const payload = (await requestPromise).postDataJSON(); + expect(payload.provider_uuid).toBe('provider-1'); + expect(payload.provider.uuid).toBe('provider-1'); + expect(payload.provider.api_keys).toEqual([]); +}); + +test('ordinary API-key provider still saves and closes', async ({ page }) => { + const state = await fixture(page); + await openModels(page); + await page.locator('input[name="name"]').fill('My API'); + await choose(page, 'OpenAI API'); + await page.locator('input[name="api_key"]').fill('fixture-api-key-not-real'); + await page + .locator('input[name="base_url"]') + .fill('https://api.example.test/v1'); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(page.getByText('My API', { exact: true })).toBeVisible(); + expect(state.providers[0]).toMatchObject({ + requester: 'openai', + api_keys: ['fixture-api-key-not-real'], + base_url: 'https://api.example.test/v1', + }); + expect(state.starts).toBe(0); +}); diff --git a/web/tests/e2e/fixtures/dynamic-form.html b/web/tests/e2e/fixtures/dynamic-form.html new file mode 100644 index 000000000..3b36b4581 --- /dev/null +++ b/web/tests/e2e/fixtures/dynamic-form.html @@ -0,0 +1,11 @@ + + + + + Dynamic form compatibility fixture + + +
+ + + diff --git a/web/tests/e2e/fixtures/dynamic-form.tsx b/web/tests/e2e/fixtures/dynamic-form.tsx new file mode 100644 index 000000000..35a8829ae --- /dev/null +++ b/web/tests/e2e/fixtures/dynamic-form.tsx @@ -0,0 +1,89 @@ +import { useEffect, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; +import { getBoxScopeContext } from '@/app/home/pipelines/components/pipeline-form/BoxScopeContext'; +import type { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic'; +import scope from '../../fixtures/sandbox-scope-schema.json'; +import '@/app/global.css'; + +// Browser component fixture only. These legacy context keys are NOT supplied by +// PipelineForm in 4.11: actual execution scope remains owned by the Runner Host. +const params = new URLSearchParams(window.location.search); +const initialValues = { + [scope.name]: scope.default, + mode: 'live', + secret: '', + count: 3, +}; +const fields = [ + { ...scope, id: 'scope' }, + { + id: 'mode', + name: 'mode', + type: 'select', + label: { en_US: 'Mode' }, + default: 'live', + options: [ + { name: 'live', label: { en_US: 'Live' } }, + { name: 'hidden', label: { en_US: 'Hidden' } }, + ], + }, + { + id: 'secret', + name: 'secret', + type: 'secret', + label: { en_US: 'Plugin secret' }, + default: '', + show_if: { field: 'mode', operator: 'eq', value: 'live' }, + disable_if: { field: '__system.locked', operator: 'eq', value: true }, + disabled_tooltip: { en_US: 'Locked by context' }, + disabled_tooltip_overrides: [ + { + when: { field: 'mode', operator: 'in', value: ['live'] }, + tooltip: { en_US: 'Live reason wins' }, + }, + ], + }, + { + id: 'count', + name: 'count', + type: 'number', + label: { en_US: 'Plugin count' }, + default: 3, + show_if: { field: 'mode', operator: 'neq', value: 'hidden' }, + }, +] as IDynamicFormItemSchema[]; + +function Fixture() { + const [context, setContext] = useState>({ + ...getBoxScopeContext( + params.get('available') === 'true', + params.get('forced') || '', + ), + locked: false, + }); + const [saved, setSaved] = useState({}); + useEffect(() => { + const update = (event: Event) => + setContext((current) => ({ + ...current, + ...(event as CustomEvent>).detail, + })); + window.addEventListener('test-form-context', update); + return () => window.removeEventListener('test-form-context', update); + }, []); + return ( +
+ + {JSON.stringify(saved)} +
+ ); +} + +createRoot(document.getElementById('root')!).render(); diff --git a/web/tests/e2e/mcp-oauth-required.spec.ts b/web/tests/e2e/mcp-oauth-required.spec.ts new file mode 100644 index 000000000..61dc5fa5c --- /dev/null +++ b/web/tests/e2e/mcp-oauth-required.spec.ts @@ -0,0 +1,74 @@ +import { expect, test } from '@playwright/test'; + +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +function ok(data: unknown) { + return { + code: 0, + message: 'ok', + data, + timestamp: Date.now(), + }; +} + +test('shows an actionable OAuth-required state after a transient MCP test', async ({ + page, +}, testInfo) => { + await installLangBotApiMocks(page, { authenticated: true }); + + await page.route('**/api/v1/mcp/servers/_/test', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(ok({ task_id: 2363 })), + }); + }); + await page.route('**/api/v1/system/tasks/2363', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify( + ok({ + runtime: { + done: true, + exception: 'Connection failed', + state: 'error', + }, + task_context: { + current_action: 'Testing MCP server', + log: '', + metadata: { + runtime_info: { + status: 'error', + error_phase: 'oauth_required', + retry_count: 1, + tool_count: 0, + tools: [], + resource_count: 0, + resources: [], + }, + }, + }, + }), + ), + }); + }); + + await page.goto('/home/mcp?id=new'); + await page.locator('input[name="name"]').fill('oauth-protected-mcp'); + await page + .locator('input[name="url"]') + .fill('https://mcp.example.test/protected'); + await page.getByRole('button', { name: /^Test$/ }).click(); + + await expect(page.getByText('OAuth authorization required')).toBeVisible(); + await expect( + page.getByText( + 'This MCP server requires OAuth sign-in. OAuth sign-in is not available yet; add an Authorization header manually if the server supports it.', + ), + ).toBeVisible(); + await page.screenshot({ + path: testInfo.outputPath('oauth-required.png'), + fullPage: true, + }); +}); diff --git a/web/tests/e2e/monitoring-turns.spec.ts b/web/tests/e2e/monitoring-turns.spec.ts index 89c98554f..2abc76eb6 100644 --- a/web/tests/e2e/monitoring-turns.spec.ts +++ b/web/tests/e2e/monitoring-turns.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, test, Route } from '@playwright/test'; import { installLangBotApiMocks } from './fixtures/langbot-api'; import { buildConversationTurns } from '../../src/app/home/monitoring/utils/conversationTurns'; @@ -271,7 +271,198 @@ function rawMonitoringData() { }; } +async function respond(route: Route, label: string) { + const data = rawMonitoringData(); + data.messages = [rawMessage(message(label, 'user', 10, label))]; + await route.fulfill({ json: { code: 0, data } }); +} + +test.describe('monitoring request contracts', () => { + test('shows failures instead of empty success and retries with auth and Workspace headers', async ({ + page, + }) => { + await installLangBotApiMocks(page, { authenticated: true }); + let failing = true; + await page.route('**/api/v1/monitoring/data?*', async (route) => { + expect(route.request().headers().authorization).toBe( + 'Bearer playwright-token', + ); + expect(route.request().headers()['x-workspace-id']).toBe( + 'workspace-playwright', + ); + if (failing) + await route.fulfill({ + status: 500, + json: { code: 500, msg: 'fixture database unavailable' }, + }); + else await respond(route, 'Recovered monitoring'); + }); + await page.goto('/home/monitoring'); + await expect(page.getByRole('alert')).toContainText( + 'Failed to load monitoring data', + ); + await expect(page.getByText('No message records')).toHaveCount(0); + failing = false; + await page.getByRole('button', { name: 'Retry', exact: true }).click(); + await expect( + page.getByText('Recovered monitoring', { exact: true }), + ).toBeVisible(); + await expect(page.getByRole('alert')).toHaveCount(0); + }); + + test('latest filter request wins over delayed data and delayed failures', async ({ + page, + }) => { + await installLangBotApiMocks(page, { authenticated: true }); + const pending: Route[] = []; + await page.route('**/api/v1/monitoring/data?*', (route) => { + pending.push(route); + }); + await page.goto('/home/monitoring'); + await expect.poll(() => pending.length).toBe(2); + await page.getByRole('combobox').last().click(); + await page.getByRole('option', { name: /Last 7 days/i }).click(); + await expect.poll(() => pending.length).toBe(3); + await respond(pending[2], 'Latest filter data'); + await expect( + page.getByText('Latest filter data', { exact: true }), + ).toBeVisible(); + await respond(pending[0], 'Obsolete filter data'); + await respond(pending[1], 'Obsolete filter data'); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + await expect( + page.getByText('Latest filter data', { exact: true }), + ).toBeVisible(); + await page + .getByRole('button', { name: 'Refresh Data', exact: true }) + .click(); + await expect.poll(() => pending.length).toBe(4); + await expect( + page.getByText('Obsolete filter data', { exact: true }), + ).toHaveCount(0); + await page.getByRole('combobox').last().click(); + await page.getByRole('option', { name: /Last 24 hours/i }).click(); + await expect.poll(() => pending.length).toBe(5); + await respond(pending[4], 'Current result'); + await expect( + page.getByText('Current result', { exact: true }), + ).toBeVisible(); + await pending[3].fulfill({ + status: 500, + json: { code: 500, msg: 'old failure' }, + }); + await expect( + page.getByText('Current result', { exact: true }), + ).toBeVisible(); + await expect(page.getByRole('alert')).toHaveCount(0); + }); + + test('uses aggregate traffic rather than the sparse record page and discloses truncation', async ({ + page, + }) => { + const data = rawMonitoringData(); + data.totalCount.messages = 125; + await installLangBotApiMocks(page, { + authenticated: true, + monitoringData: { + ...data, + traffic: { + bucket: 'hour', + truncated: true, + points: [ + { timestamp: time(0).toISOString(), messages: 125, llm_calls: 77 }, + { timestamp: time(1).toISOString(), messages: 0, llm_calls: 0 }, + ], + }, + }, + }); + await page.goto('/home/monitoring'); + await expect( + page.getByText( + 'Showing 7 of 125 messages. Conversation traces may be incomplete.', + ), + ).toBeVisible(); + await expect( + page.getByText('Traffic range truncated. Choose a shorter time range.'), + ).toBeVisible(); + const chart = page.locator('.recharts-wrapper'); + await expect(chart).toHaveCount(1); + await chart + .locator(':scope > .recharts-surface') + .hover({ position: { x: 70, y: 100 } }); + await expect(chart.locator('.recharts-tooltip-wrapper')).toContainText( + '125', + ); + await expect(chart.locator('.recharts-tooltip-wrapper')).toContainText( + '77', + ); + }); + + test('does not invent traffic totals when aggregation is unavailable', async ({ + page, + }) => { + await installLangBotApiMocks(page, { + authenticated: true, + monitoringData: rawMonitoringData(), + }); + await page.goto('/home/monitoring'); + await expect( + page.getByText('Traffic aggregation unavailable'), + ).toBeVisible(); + await expect(page.locator('.recharts-wrapper')).toHaveCount(0); + }); +}); + test.describe('monitoring conversation turn grouping', () => { + test('does not reassign explicitly linked activity outside the visible page', () => { + const turns = buildConversationTurns( + [message('visible', 'user', 10, 'Visible turn')], + [llmCall('older-call', 11, 'off-page', 10, 5, 40)], + [errorLog('older-error', 11, 'off-page')], + [toolCall('older-tool', 11, 'off-page', 'search', 40)], + ); + expect(turns[0].llmCalls).toEqual([]); + expect(turns[0].toolCalls).toEqual([]); + expect(turns[0].errors).toEqual([]); + }); + + test('does not assign unlinked activity before the first visible turn', () => { + const turns = buildConversationTurns( + [message('visible', 'user', 10, 'Visible turn')], + [llmCall('older-call', 1, undefined, 10, 5, 40)], + [{ ...errorLog('older-error', 1, ''), messageId: undefined }], + [toolCall('older-tool', 1, undefined, 'search', 40)], + ); + expect(turns[0].llmCalls).toEqual([]); + expect(turns[0].toolCalls).toEqual([]); + expect(turns[0].errors).toEqual([]); + }); + + test('isolates same-session messages and activity by bot identity', () => { + const first = message('first', 'user', 1, 'Bot one'); + const other = { + ...message('other', 'user', 2, 'Bot two'), + botId: 'other-bot', + }; + const reply = message('reply', 'assistant', 3, 'Bot one reply'); + const turns = buildConversationTurns( + [first, other, reply], + [llmCall('call', 3, undefined, 10, 5, 40)], + [errorLog('error', 3, first.id)], + [toolCall('tool', 3, undefined, 'search', 40)], + ); + const own = turns.find((turn) => turn.id === first.id)!; + expect(own.assistantMessages.map((item) => item.id)).toEqual(['reply']); + expect(own.llmCalls.map((item) => item.id)).toEqual(['call']); + expect(own.toolCalls.map((item) => item.id)).toEqual(['tool']); + expect(turns.find((turn) => turn.id === other.id)?.totalTokens).toBe(0); + }); + test('keeps a single user message as one observable turn', () => { const userOnly = message( 'single-user-only', diff --git a/web/tests/e2e/provider-delete-footer.spec.ts b/web/tests/e2e/provider-delete-footer.spec.ts new file mode 100644 index 000000000..49d696a9e --- /dev/null +++ b/web/tests/e2e/provider-delete-footer.spec.ts @@ -0,0 +1,340 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +// UI fixtures only: no real provider/model deletion or subscription authentication. +async function fixture(page: Page, requester = 'openai', empty = false) { + await installLangBotApiMocks(page, { authenticated: true }); + const provider = { + uuid: 'provider-delete-fixture', + name: 'Delete fixture provider', + requester, + base_url: 'https://example.test/v1', + api_keys: [], + llm_count: empty ? 0 : 1, + embedding_count: empty ? 0 : 1, + rerank_count: empty ? 0 : 1, + }; + const state = { + deleted: false, + fail: false, + deletes: [] as string[], + reads: [] as string[], + release: undefined as (() => void) | undefined, + hold: false, + }; + const ok = (route: Route, data: unknown) => + route.fulfill({ json: { code: 0, data } }); + await page.route('**/api/v1/provider/**', async (route) => { + const url = new URL(route.request().url()); + const path = url.pathname; + const method = route.request().method(); + if (method === 'DELETE') { + state.deletes.push(path + url.search); + if (state.hold) + await new Promise((resolve) => { + state.release = resolve; + }); + if (state.fail) + return route.fulfill({ + status: 409, + json: { code: 409, msg: 'Fixture deletion blocked; try again.' }, + }); + state.deleted = true; + return ok(route, {}); + } + if (path.endsWith('/icon')) + return route.fulfill({ + contentType: 'image/svg+xml', + body: '', + }); + if (path.endsWith('/requesters')) + return ok(route, { + requesters: ['openai', 'openai-codex'].map((name) => ({ + name, + label: { en_US: name }, + description: { en_US: '' }, + spec: { + provider_category: 'manufacturer', + support_type: ['llm', 'embedding', 'rerank'], + config: [], + }, + })), + }); + if (method === 'GET') state.reads.push(path + url.search); + if (path.endsWith('/providers')) + return ok(route, { providers: state.deleted ? [] : [provider] }); + if (path.endsWith('/codex/status')) + return ok(route, { + status: 'connected', + connected: true, + expires_at: null, + }); + if (path.includes('/models/')) { + const type = path.split('/').pop(); + return ok(route, { + models: state.deleted + ? [] + : [ + { + uuid: `fixture-${type}`, + name: `Fixture ${type} model`, + provider_uuid: provider.uuid, + provider, + abilities: [], + extra_args: {}, + }, + ], + }); + } + if (path.endsWith(provider.uuid)) return ok(route, { provider }); + return ok(route, {}); + }); + await page.goto('/home/bots'); + await page.getByRole('button', { name: 'Models', exact: true }).click(); + return state; +} +const editDialog = (page: Page) => + page.locator('[role="dialog"]').filter({ + has: page.locator('[data-slot="dialog-title"]', { + hasText: /^Edit Provider$/, + }), + }); +async function edit(page: Page) { + const card = page + .locator('[data-slot="card"]') + .filter({ hasText: 'Delete fixture provider' }); + await card.getByRole('button', { name: 'Expand', exact: true }).click(); + await expect( + card.getByText('Fixture llm model', { exact: true }), + ).toBeVisible(); + await card + .locator('button') + .filter({ has: page.locator('svg.lucide-settings') }) + .click(); + await expect(editDialog(page).locator('input[name="name"]')).toHaveValue( + 'Delete fixture provider', + ); +} + +for (const width of [1280, 320]) { + test(`confirmation stays centered throughout entry (${width}px)`, async ({ + page, + }) => { + const state = await fixture(page); + await edit(page); + await page.setViewportSize({ width, height: 900 }); + // Trigger without Playwright's post-click wait so the browser animation is + // still live. Sample its actual keyframes, not only the final screenshot. + await editDialog(page) + .getByRole('button', { name: 'Delete', exact: true }) + .evaluate((el) => (el as HTMLButtonElement).click()); + const confirmation = page.getByRole('alertdialog'); + for (const phase of ['entry']) { + const samples = await confirmation.evaluate(async (el) => { + const animations = el.getAnimations(); + if (!animations.length) + throw new Error('Expected the real dialog animation'); + await Promise.all(animations.map((a) => a.ready)); + animations.forEach((a) => a.pause()); + const samples = [0, 0.25, 0.5, 0.75, 0.99].map((fraction) => { + animations.forEach((a) => { + a.currentTime = Number(a.effect!.getTiming().duration) * fraction; + }); + const r = el.getBoundingClientRect(); + return { + x: r.x + r.width / 2, + y: r.y + r.height / 2, + left: r.left, + right: r.right, + }; + }); + animations.forEach((a) => a.finish()); + return samples; + }); + for (const sample of samples) { + expect( + Math.abs(sample.x - width / 2), + `${phase} horizontal center`, + ).toBeLessThan(1); + expect( + Math.abs(sample.y - 450), + `${phase} vertical center`, + ).toBeLessThan(1); + expect(sample.left).toBeGreaterThanOrEqual(0); + expect(sample.right).toBeLessThanOrEqual(width); + } + } + await confirmation + .getByRole('button', { name: 'Cancel', exact: true }) + .click(); + await expect(confirmation).toHaveCount(0); + expect(state.deletes).toEqual([]); + }); +} + +for (const requester of ['openai', 'openai-codex']) { + for (const width of [1280, 320]) { + test(`footer deletion confirmation cancellation and geometry (${requester}, ${width}px)`, async ({ + page, + }) => { + const state = await fixture(page, requester); + await edit(page); + await page.setViewportSize({ width, height: 900 }); + const dialog = editDialog(page); + const footer = dialog.locator('[data-slot="dialog-footer"]'); + const remove = footer.getByRole('button', { + name: 'Delete', + exact: true, + }); + await expect(remove).toBeVisible(); + for (const button of await footer.getByRole('button').all()) { + await expect(button).toBeInViewport({ ratio: 1 }); + const box = await button.boundingBox(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(width); + } + const left = await remove.boundingBox(); + const cancel = await footer + .getByRole('button', { name: 'Cancel', exact: true }) + .boundingBox(); + expect(left!.x + left!.width).toBeLessThan(cancel!.x); + await remove.click(); + const confirmation = page.getByRole('alertdialog'); + await expect(confirmation).toContainText('this provider and ALL models'); + await expect(confirmation).toContainText('cannot be undone'); + await expect(confirmation).toBeInViewport({ ratio: 1 }); + await confirmation.evaluate(async (element) => { + await Promise.all( + element.getAnimations().map((animation) => animation.finished), + ); + }); + const box = await confirmation.boundingBox(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(width); + await confirmation + .getByRole('button', { name: 'Cancel', exact: true }) + .click(); + await expect(confirmation).toHaveCount(0); + await expect(dialog).toBeVisible(); + expect(state.deletes).toEqual([]); + }); + } + test(`one awaited cascade request refreshes providers and clears models (${requester})`, async ({ + page, + }) => { + const state = await fixture(page, requester); + await edit(page); + state.hold = true; + await editDialog(page) + .getByRole('button', { name: 'Delete', exact: true }) + .click(); + const confirmation = page.getByRole('alertdialog'); + await confirmation + .getByRole('button', { name: 'Delete', exact: true }) + .click(); + await expect.poll(() => state.deletes.length).toBe(1); + await expect( + confirmation.getByRole('button', { name: 'Delete', exact: true }), + ).toBeDisabled(); + await expect( + confirmation.getByRole('button', { name: 'Cancel', exact: true }), + ).toBeDisabled(); + await expect( + editDialog(page).getByRole('button', { + name: requester === 'openai' ? 'Save' : 'Done', + exact: true, + includeHidden: true, + }), + ).toBeDisabled(); + await page.keyboard.press('Escape'); + await expect(confirmation).toBeVisible(); + state.reads = []; + state.release!(); + await expect(editDialog(page)).toHaveCount(0); + await expect( + page.getByText('Delete fixture provider', { exact: true }), + ).toHaveCount(0); + await expect( + page.getByText('Fixture llm model', { exact: true }), + ).toHaveCount(0); + expect(state.deletes).toEqual([ + '/api/v1/provider/providers/provider-delete-fixture?cascade=true', + ]); + expect(state.reads).toContain('/api/v1/provider/providers'); + }); +} + +test('failed cascade retains readable error and can retry', async ({ + page, +}) => { + const state = await fixture(page); + await edit(page); + state.fail = true; + await editDialog(page) + .getByRole('button', { name: 'Delete', exact: true }) + .click(); + const confirmation = page.getByRole('alertdialog'); + await confirmation + .getByRole('button', { name: 'Delete', exact: true }) + .click(); + await expect(confirmation.getByRole('alert')).toContainText( + 'Fixture deletion blocked; try again.', + ); + await expect( + confirmation.getByRole('button', { name: 'Delete', exact: true }), + ).toBeEnabled(); + await expect(editDialog(page)).toBeVisible(); + state.fail = false; + await confirmation + .getByRole('button', { name: 'Delete', exact: true }) + .click(); + await expect(editDialog(page)).toHaveCount(0); + expect(state.deletes).toHaveLength(2); +}); + +test('new providers do not expose footer deletion', async ({ page }) => { + const state = await fixture(page); + await page.getByRole('button', { name: 'Add Provider', exact: true }).click(); + await expect( + page + .getByRole('dialog', { name: 'Add Provider', exact: true }) + .getByRole('button', { name: 'Delete', exact: true }), + ).toHaveCount(0); + expect(state.deletes).toEqual([]); +}); + +test('system-managed provider has no edit or delete entry', async ({ + page, +}) => { + const state = await fixture(page, 'space-chat-completions'); + const card = page + .locator('[data-slot="card"]') + .filter({ hasText: 'Delete fixture provider' }); + await expect(card).toBeVisible(); + await expect(card.locator('svg.lucide-settings')).toHaveCount(0); + await expect(card.locator('svg.lucide-trash-2')).toHaveCount(0); + expect(state.deletes).toEqual([]); +}); + +test('existing empty-provider card delete keeps its non-cascade request', async ({ + page, +}) => { + const state = await fixture(page, 'openai', true); + const card = page + .locator('[data-slot="card"]') + .filter({ hasText: 'Delete fixture provider' }); + await card + .locator('button') + .filter({ has: page.locator('svg.lucide-trash-2') }) + .click(); + await expect( + page.getByText('Are you sure you want to delete this provider?', { + exact: true, + }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Delete', exact: true }).click(); + await expect(card).toHaveCount(0); + expect(state.deletes).toEqual([ + '/api/v1/provider/providers/provider-delete-fixture', + ]); +}); diff --git a/web/tests/e2e/provider-dropdown.spec.ts b/web/tests/e2e/provider-dropdown.spec.ts new file mode 100644 index 000000000..9424cc713 --- /dev/null +++ b/web/tests/e2e/provider-dropdown.spec.ts @@ -0,0 +1,177 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { expect, test } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +// UI fixtures only: never authenticate or write a real provider. +test.use({ hasTouch: true }); +for (const width of [1280, 390, 320]) { + test(`provider dropdown bounded without dialog growth (${width}px)`, async ({ + page, + }, testInfo) => { + await installLangBotApiMocks(page, { authenticated: true }); + await page.route('**/api/v1/provider/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path.endsWith('/icon')) + return route.fulfill({ + contentType: 'image/svg+xml', + body: '', + }); + const data = path.endsWith('/requesters') + ? { + requesters: Array.from({ length: 30 }, (_, i) => ({ + name: i === 0 ? 'openai-codex' : `provider-${i}`, + label: { en_US: i === 0 ? 'OpenAI Codex' : `Provider ${i}` }, + description: { en_US: '' }, + spec: { + provider_category: 'manufacturer', + config: [], + support_type: ['llm'], + }, + })), + } + : { providers: [], models: [] }; + await route.fulfill({ json: { code: 0, data } }); + }); + await page.setViewportSize({ width: 1280, height: 720 }); + await page.goto('/home/bots'); + await page.getByRole('button', { name: 'Models', exact: true }).click(); + await page + .getByRole('button', { name: 'Add Provider', exact: true }) + .click(); + await page.setViewportSize({ width, height: 720 }); + const trigger = page.getByRole('button', { + name: 'Select Provider Type', + exact: true, + }); + const dialog = page + .locator('[role="dialog"]') + .filter({ has: page.locator('input[name="name"]') }); + await trigger.scrollIntoViewIfNeeded(); + const before = await dialog.evaluate((el) => ({ + height: el.clientHeight, + scroll: el.scrollHeight, + })); + await trigger.click(); + const search = page.getByPlaceholder('Search providers...'); + await expect(search).toBeFocused(); + const menu = search.locator('../..'); + await expect( + page.getByRole('button', { name: 'Provider 29', exact: false }), + ).toBeAttached(); + await menu.evaluate(async (el) => { + await Promise.all(el.getAnimations().map((a) => a.finished)); + }); + const options = menu.locator(':scope > div').last(); + await options.hover(); + await page.mouse.wheel(0, 1200); + await expect + .poll(() => options.evaluate((el) => el.scrollTop)) + .toBeGreaterThan(0); + if (width < 1280) { + await page.mouse.wheel(0, -1200); + await expect.poll(() => options.evaluate((el) => el.scrollTop)).toBe(0); + const box = (await options.boundingBox())!; + const session = await page.context().newCDPSession(page); + const x = box.x + box.width / 2; + const y = box.y + box.height - 30; + await session.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{ x, y }], + }); + for (let step = 1; step <= 10; step++) { + await session.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [{ x, y: y - step * 18 }], + }); + } + await session.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: [], + }); + await session.detach(); + await expect + .poll(() => options.evaluate((el) => el.scrollTop)) + .toBeGreaterThan(0); + } + const geometry = await menu.evaluate((el) => { + const rect = el.getBoundingClientRect(); + const list = el.lastElementChild as HTMLElement; + const clipped: string[] = []; + for ( + let parent = el.parentElement; + parent; + parent = parent.parentElement + ) { + const bounds = parent.getBoundingClientRect(); + if ( + /(auto|scroll|hidden|clip)/.test( + getComputedStyle(parent).overflowY, + ) && + (rect.bottom > bounds.bottom + 1 || rect.top < bounds.top - 1) + ) + clipped.push(parent.tagName); + } + return { + left: rect.left, + right: rect.right, + top: rect.top, + bottom: rect.bottom, + clipped, + listHeight: list.clientHeight, + listScroll: list.scrollHeight, + scrollTop: list.scrollTop, + documentWidth: document.documentElement.scrollWidth, + }; + }); + const after = await dialog.evaluate((el) => ({ + height: el.clientHeight, + scroll: el.scrollHeight, + })); + const dir = process.env.DROPDOWN_EVIDENCE_DIR || testInfo.outputDir; + mkdirSync(dir, { recursive: true }); + await page.screenshot({ + path: `${dir}/dropdown-${width}.png`, + fullPage: true, + }); + writeFileSync( + `${dir}/dropdown-${width}.json`, + JSON.stringify( + { evidence: 'UI fixture only', width, before, after, geometry }, + null, + 2, + ), + ); + expect.soft(after).toEqual(before); + expect.soft(geometry.clipped).toEqual([]); + expect.soft(geometry.left).toBeGreaterThanOrEqual(0); + expect.soft(geometry.right).toBeLessThanOrEqual(width); + expect.soft(geometry.top).toBeGreaterThanOrEqual(0); + expect.soft(geometry.bottom).toBeLessThanOrEqual(720); + expect.soft(geometry.documentWidth).toBeLessThanOrEqual(width); + expect(geometry.listScroll).toBeGreaterThan(geometry.listHeight); + expect(geometry.scrollTop).toBeGreaterThan(0); + await page.keyboard.press('Escape'); + await expect(search).toBeHidden(); + await expect(dialog).toBeVisible(); + await expect(trigger).toBeFocused(); + await trigger.click(); + await search.fill('Provider 29'); + await page.locator('input[name="name"]').click(); + await expect(search).toBeHidden(); + await expect(page.locator('input[name="name"]')).toBeFocused(); + await trigger.click(); + await expect(search).toHaveValue(''); + await search.fill('Codex'); + await page + .getByRole('button', { name: 'OpenAI Codex', exact: false }) + .click(); + await expect(search).toBeHidden(); + await expect(page.locator('input[name="api_key"]')).toHaveCount(0); + await expect( + page.getByRole('button', { name: 'Save and sign in', exact: true }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: 'OpenAI Codex', exact: false }), + ).toBeFocused(); + }); +} diff --git a/web/tests/e2e/provider-edit-loading.spec.ts b/web/tests/e2e/provider-edit-loading.spec.ts new file mode 100644 index 000000000..e73a59196 --- /dev/null +++ b/web/tests/e2e/provider-edit-loading.spec.ts @@ -0,0 +1,262 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +// All API traffic is intercepted; no real provider secrets or mutations. +async function fixture(page: Page, requester = 'openai') { + await installLangBotApiMocks(page, { authenticated: true }); + const providers = ['alpha', 'beta'].map((id) => ({ + uuid: `loading-${id}`, + name: `Loading fixture ${id}`, + requester, + base_url: `https://${id}.example.test/v1`, + api_keys: [`fixture-key-${id}`], + llm_count: 0, + embedding_count: 0, + rerank_count: 0, + })); + const state = { + hold: '' as '' | 'detail' | 'requesters', + fail: '' as '' | 'detail' | 'requesters', + held: [] as { release: () => void; finished: Promise }[], + reads: [] as string[], + mutations: [] as string[], + errors: [] as string[], + }; + page.on('pageerror', (error) => state.errors.push(error.message)); + const ok = (route: Route, data: unknown) => + route.fulfill({ json: { code: 0, data } }); + await page.route('**/api/v1/provider/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (route.request().method() !== 'GET') { + state.mutations.push(route.request().method() + ' ' + path); + return ok(route, {}); + } + if (path.endsWith('/icon')) + return route.fulfill({ + contentType: 'image/svg+xml', + body: '', + }); + state.reads.push(path); + const provider = providers.find((p) => path.endsWith('/' + p.uuid)); + const dependency = path.endsWith('/requesters') + ? 'requesters' + : provider + ? 'detail' + : ''; + const fail = dependency && state.fail === dependency; + let finish: (() => void) | undefined; + if (dependency && state.hold === dependency) { + const finished = new Promise((resolve) => { + finish = resolve; + }); + await new Promise((release) => + state.held.push({ release, finished }), + ); + } + try { + if (fail) + return await route.fulfill({ + status: 503, + json: { code: 503, msg: `Fixture ${dependency} unavailable` }, + }); + if (dependency === 'requesters') + return await ok(route, { + requesters: [ + { + name: requester, + label: { + en_US: + requester === 'openai' ? 'OpenAI fixture' : 'Codex fixture', + }, + description: { en_US: '' }, + spec: { + provider_category: 'manufacturer', + support_type: ['llm'], + config: [], + }, + }, + ], + }); + if (provider) return await ok(route, { provider }); + if (path.endsWith('/providers')) return await ok(route, { providers }); + if (path.endsWith('/codex/status')) + return await ok(route, { + status: 'connected', + connected: true, + expires_at: null, + }); + return await ok(route, { models: [] }); + } finally { + finish?.(); + } + }); + await page.goto('/home/bots'); + await page.getByRole('button', { name: 'Models', exact: true }).click(); + await expect( + page.getByText(providers[0].name, { exact: true }), + ).toBeVisible(); + // Let the panel's independent requester-support read finish before gating the form. + await expect + .poll(() => state.reads.filter((p) => p.endsWith('/requesters')).length) + .toBeGreaterThanOrEqual(1); + return state; +} + +const dialog = (page: Page) => + page.getByRole('dialog', { name: 'Edit Provider', exact: true }); +const editButton = (page: Page, id = 'alpha') => + page + .locator('[data-slot="card"]') + .filter({ hasText: `Loading fixture ${id}` }) + .locator('button') + .filter({ has: page.locator('svg.lucide-settings') }); + +async function expectLoading(page: Page) { + const form = dialog(page); + await expect(form.getByRole('status')).toContainText('Loading...'); + await expect( + form.getByRole('status').locator('svg.animate-spin'), + ).toBeVisible(); + await expect(form.locator('input')).toHaveCount(0); + await expect( + form.getByRole('button', { name: /^(Save|Done|Delete)$/ }), + ).toHaveCount(0); + await expect( + form.getByRole('button', { name: 'Cancel', exact: true }), + ).toBeEnabled(); +} + +async function expectReady(page: Page, id = 'alpha', requester = 'openai') { + const form = dialog(page); + await expect(form.locator('input[name="name"]')).toHaveValue( + `Loading fixture ${id}`, + ); + await expect( + form.getByRole('status', { name: 'Loading...', exact: true }), + ).toHaveCount(0); + await expect( + form.getByRole('button', { name: 'Delete', exact: true }), + ).toBeEnabled(); + await expect( + form.getByRole('button', { + name: requester === 'openai' ? 'Save' : 'Done', + exact: true, + }), + ).toBeEnabled(); + if (requester === 'openai') { + await expect(form.locator('input[name="base_url"]')).toHaveValue( + `https://${id}.example.test/v1`, + ); + await expect(form.locator('input[name="api_key"]')).toHaveValue( + `fixture-key-${id}`, + ); + await expect( + form.getByRole('button', { name: /OpenAI fixture/ }), + ).toBeVisible(); + } else { + await expect(form.locator('input[name="api_key"]')).toHaveCount(0); + await expect( + form.getByRole('button', { name: /Codex fixture/ }), + ).toBeVisible(); + } +} + +for (const requester of ['openai', 'openai-codex']) { + for (const dependency of ['detail', 'requesters'] as const) { + test(`edit waits for ${dependency} before showing populated ${requester} form`, async ({ + page, + }) => { + const state = await fixture(page, requester); + state.hold = dependency; + await editButton(page).click(); + await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1); + await expectLoading(page); + // Remain gated for the whole delay, not just the first render. + await page.waitForTimeout(250); + await expectLoading(page); + state.hold = ''; + state.held.forEach((request) => request.release()); + await expectReady(page, 'alpha', requester); + expect(state.mutations).toEqual([]); + expect(state.errors).toEqual([]); + }); + } +} + +for (const dependency of ['detail', 'requesters'] as const) { + test(`${dependency} load failure is recoverable with Retry or Cancel`, async ({ + page, + }) => { + const state = await fixture(page); + state.fail = dependency; + await editButton(page).click(); + const form = dialog(page); + await expect(form.getByRole('alert')).toContainText('Failed to load data'); + await expect(form.locator('input')).toHaveCount(0); + await expect( + form.getByRole('button', { name: /^(Save|Done|Delete)$/ }), + ).toHaveCount(0); + await expect( + form.getByRole('button', { name: 'Retry', exact: true }), + ).toBeEnabled(); + await expect( + form.getByRole('button', { name: 'Cancel', exact: true }), + ).toBeEnabled(); + state.fail = ''; + state.hold = dependency; + await form.getByRole('button', { name: 'Retry', exact: true }).click(); + await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1); + await expectLoading(page); + state.hold = ''; + state.held.forEach((request) => request.release()); + await expectReady(page); + await form.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(form).toHaveCount(0); + state.fail = dependency; + await editButton(page).click(); + await expect(form.getByRole('alert')).toBeVisible(); + await form.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(form).toHaveCount(0); + expect(state.mutations).toEqual([]); + expect(state.errors).toEqual([]); + }); +} + +for (const next of ['alpha', 'beta']) { + for (const staleFailure of [false, true]) { + test(`closed request ${staleFailure ? 'failure' : 'success'} cannot affect reopened ${next}`, async ({ + page, + }) => { + const state = await fixture(page); + state.hold = 'detail'; + state.fail = staleFailure ? 'detail' : ''; + await editButton(page).click(); + await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1); + await expectLoading(page); + const staleRequests = state.held.splice(0); + await dialog(page) + .getByRole('button', { name: 'Cancel', exact: true }) + .click(); + state.fail = ''; + // Reopen during the closing animation, before Radix's retained content unmounts. + await editButton(page, next).dispatchEvent('click'); + await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1); + await expectLoading(page); + state.hold = ''; + state.held.forEach((request) => request.release()); + await expectReady(page, next); + await dialog(page) + .locator('input[name="name"]') + .fill('Unsaved fixture edit'); + staleRequests.forEach((request) => request.release()); + await Promise.all(staleRequests.map((request) => request.finished)); + await page.waitForTimeout(250); + await expect(dialog(page).locator('input[name="name"]')).toHaveValue( + 'Unsaved fixture edit', + ); + await expect(dialog(page).getByRole('alert')).toHaveCount(0); + expect(state.mutations).toEqual([]); + expect(state.errors).toEqual([]); + }); + } +} diff --git a/web/tests/e2e/reset-password.spec.ts b/web/tests/e2e/reset-password.spec.ts new file mode 100644 index 000000000..96170f386 --- /dev/null +++ b/web/tests/e2e/reset-password.spec.ts @@ -0,0 +1,122 @@ +import { expect, test, type Page } from '@playwright/test'; + +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +const resetEndpoint = '**/api/v1/user/reset-password'; +const email = 'reset-password@example.com'; +const newPassword = 'Regression-password-2026!'; +const successMessage = 'Password reset successfully, please login'; +const failureMessage = + 'Password reset failed, please check your email and recovery key'; + +async function fillResetForm(page: Page, recoveryKey: string) { + await page.goto('/reset-password'); + await page.getByPlaceholder('Enter email address').fill(email); + const recoveryInput = page.getByPlaceholder('Enter recovery key'); + await recoveryInput.fill(recoveryKey); + await expect(recoveryInput).toHaveValue(recoveryKey); + await page.getByPlaceholder('Enter new password').fill(newPassword); +} + +test.beforeEach(async ({ page }) => { + await installLangBotApiMocks(page, { authenticated: false }); +}); + +const recoveryKeys = [ + { name: 'eight-character recovery code', value: '2A3B4C5D' }, + { name: 'six-character legacy recovery key', value: 'ABC123' }, + { + name: '43-character mixed-case base64url recovery key', + value: 'aB-_'.repeat(10) + 'xYz', + }, +]; + +for (const { name, value } of recoveryKeys) { + test(`submits the ${name} verbatim and returns to login`, async ({ + page, + }) => { + const requests: { method: string; body: unknown }[] = []; + await page.route(resetEndpoint, async (route) => { + requests.push({ + method: route.request().method(), + body: route.request().postDataJSON(), + }); + await route.fulfill({ + status: 200, + json: { code: 0, msg: 'ok', data: { user: email } }, + }); + }); + + await fillResetForm(page, value); + await page + .getByRole('button', { name: 'Reset Password', exact: true }) + .click(); + + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByText(successMessage, { exact: true })).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Login with password', exact: true }), + ).toBeVisible(); + expect(requests).toEqual([ + { + method: 'POST', + body: { user: email, recovery_key: value, new_password: newPassword }, + }, + ]); + await expect(page.getByText(failureMessage, { exact: true })).toHaveCount( + 0, + ); + }); +} + +test('HTTP 429 shows failure, stays on reset-password, and reenables submission', async ({ + page, +}) => { + const recoveryKey = '2A3B4C5D'; + const requests: { method: string; body: unknown }[] = []; + let releaseResponse!: () => void; + const responseGate = new Promise((resolve) => { + releaseResponse = resolve; + }); + await page.route(resetEndpoint, async (route) => { + requests.push({ + method: route.request().method(), + body: route.request().postDataJSON(), + }); + await responseGate; + await route.fulfill({ + status: 429, + json: { code: -1, msg: 'Too many attempts, try again later' }, + }); + }); + + await fillResetForm(page, recoveryKey); + const submit = page.locator('button[type="submit"]'); + await submit.click(); + try { + await expect.poll(() => requests.length).toBe(1); + await expect(submit).toBeDisabled(); + await expect(submit).toHaveText('Resetting...'); + } finally { + releaseResponse(); + } + + await expect(page.getByText(failureMessage, { exact: true })).toBeVisible(); + await expect(submit).toBeEnabled(); + await expect(submit).toHaveText('Reset Password'); + await expect(page).toHaveURL(/\/reset-password$/); + await expect(page.getByText(successMessage, { exact: true })).toHaveCount(0); + await expect(page.getByPlaceholder('Enter recovery key')).toHaveValue( + recoveryKey, + ); + expect(requests).toEqual([ + { + method: 'POST', + body: { + user: email, + recovery_key: recoveryKey, + new_password: newPassword, + }, + }, + ]); +}); diff --git a/web/tests/e2e/sandbox-scope-tooltip.spec.ts b/web/tests/e2e/sandbox-scope-tooltip.spec.ts new file mode 100644 index 000000000..6c7405a22 --- /dev/null +++ b/web/tests/e2e/sandbox-scope-tooltip.spec.ts @@ -0,0 +1,268 @@ +import { expect, test, type Page } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; +import { getBoxScopeContext } from '../../src/app/home/pipelines/components/pipeline-form/BoxScopeContext'; + +const unavailableHint = '沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。'; +const forcedHint = '已强制使用全局沙箱,无法修改作用域。'; +const customHint = '已强制使用固定沙箱作用域,无法修改作用域。'; + +// Exercise the real generic renderer, not a retired local-agent page. Host +// execution scope is not a plugin configuration field; see fixtures/README.md. +async function openConditionForm(page: Page, available: boolean, forced = '') { + await installLangBotApiMocks(page, { + authenticated: true, + storage: { langbot_language: 'zh-Hans' }, + }); + const params = new URLSearchParams({ available: String(available), forced }); + await page.goto(`/tests/e2e/fixtures/dynamic-form.html?${params}`); + const scope = page + .locator('[data-slot="form-item"]') + .filter({ has: page.getByText('沙箱作用域', { exact: true }) }) + .getByRole('combobox'); + await expect(scope).toBeVisible(); + return scope; +} + +async function updateContext(page: Page, context: Record) { + await page.evaluate((detail) => { + window.dispatchEvent(new CustomEvent('test-form-context', { detail })); + }, context); +} + +async function expectWarning(page: Page, hint: string) { + const warning = page.getByRole('button', { name: hint, exact: true }); + await expect(warning).toBeVisible(); + await warning.hover(); + await expect(page.getByRole('tooltip')).toHaveText(hint); +} + +async function expectNoWarning(page: Page) { + for (const hint of [unavailableHint, forcedHint, customHint]) { + await expect(page.getByRole('button', { name: hint })).toHaveCount(0); + } + await expect(page.getByRole('tooltip')).toHaveCount(0); +} + +test.describe('sandbox condition renderer compatibility (UI fixture only)', () => { + for (const scenario of [ + { name: 'Box disabled', forced: '' }, + { name: 'Box disconnected', forced: '' }, + { + name: 'unavailable Box takes precedence over forced global', + forced: '{global}', + }, + { + name: 'unavailable Box takes precedence over forced fixed', + forced: '{pipeline_id}', + }, + ]) { + test(scenario.name, async ({ page }) => { + const scope = await openConditionForm(page, false, scenario.forced); + await expect(scope).toHaveCSS('pointer-events', 'none'); + await expectWarning(page, unavailableHint); + await expect(page.getByRole('tooltip')).not.toContainText('强制'); + await expect(page.getByRole('button', { name: forcedHint })).toHaveCount( + 0, + ); + }); + } + + for (const forced of ['{global}', ' {global} ', '{pipeline_id}']) { + test(`available context explains the restriction without rewriting config (${JSON.stringify(forced)})`, async ({ + page, + }) => { + const scope = await openConditionForm(page, true, forced); + await expect(scope).toHaveCSS('pointer-events', 'none'); + // The generic renderer must not coerce values based on a Host policy. + await expect(scope).toHaveText('每个会话(推荐)'); + await expectWarning( + page, + forced.trim() === '{global}' ? forcedHint : customHint, + ); + await expect( + page.getByRole('button', { name: unavailableHint }), + ).toHaveCount(0); + await expect(page.getByTestId('saved-values')).toContainText( + '"box-session-id-template":"{launcher_type}_{launcher_id}"', + ); + }); + } + + for (const forced of ['', ' ']) { + test(`available unforced context is editable (${JSON.stringify(forced)})`, async ({ + page, + }) => { + const scope = await openConditionForm(page, true, forced); + await expect(scope).toHaveCSS('pointer-events', 'auto'); + await expect(scope).toHaveText('每个会话(推荐)'); + await expectNoWarning(page); + await scope.click(); + await page + .getByRole('option', { name: '全局(所有人共享)', exact: true }) + .click(); + await expect(scope).toHaveText('全局(所有人共享)'); + await expectNoWarning(page); + }); + } + + for (const forced of ['', '{global}']) { + test(`caller context updates the warning without remounting (${forced || 'unforced'})`, async ({ + page, + }) => { + const scope = await openConditionForm(page, false, forced); + const original = await scope.elementHandle(); + await expect(scope).toHaveCSS('pointer-events', 'none'); + await expectWarning(page, unavailableHint); + await page.mouse.move(0, 0); + await updateContext(page, getBoxScopeContext(true, forced)); + if (forced) { + await expect(scope).toHaveCSS('pointer-events', 'none'); + await expectWarning(page, forcedHint); + } else { + await expect(scope).toHaveCSS('pointer-events', 'auto'); + await expectNoWarning(page); + } + await page.mouse.move(0, 0); + await updateContext(page, getBoxScopeContext(false, forced)); + await expect(scope).toHaveCSS('pointer-events', 'none'); + await expectWarning(page, unavailableHint); + await expect(page.getByRole('tooltip')).not.toContainText('强制'); + expect(await original!.evaluate((element) => element.isConnected)).toBe( + true, + ); + }); + } + + test('manifest secret and number aliases retain live show/disable conditions', async ({ + page, + }) => { + await openConditionForm(page, true); + const secret = page + .locator('[data-slot="form-item"]') + .filter({ has: page.getByText('Plugin secret', { exact: true }) }) + .locator('input'); + await expect(secret).toHaveAttribute('type', 'password'); + await expect(secret).toHaveCSS('pointer-events', 'auto'); + await expect(page.getByRole('spinbutton')).toHaveValue('3'); + await secret.fill('fixture-only-not-a-credential'); + await updateContext(page, { locked: true }); + await expect(secret).toHaveCSS('pointer-events', 'none'); + await expectWarning(page, 'Live reason wins'); + await page.mouse.move(0, 0); + await page + .locator('[data-slot="form-item"]') + .filter({ has: page.getByText('Mode', { exact: true }) }) + .getByRole('combobox') + .click(); + await page.getByRole('option', { name: 'Hidden', exact: true }).click(); + await expect(secret).toHaveCount(0); + await expect(page.getByRole('spinbutton')).toHaveCount(0); + await expect( + page.getByRole('button', { name: 'Live reason wins' }), + ).toHaveCount(0); + }); +}); + +test('pipeline plugin schema round-trips without resurrecting Core sandbox ownership', async ({ + page, +}) => { + await installLangBotApiMocks(page, { + authenticated: true, + storage: { langbot_language: 'en-US' }, + }); + const runnerId = 'plugin:qa/conditions/default'; + await page.route('**/api/v1/pipelines/_/metadata', (route) => + route.fulfill({ + json: { + code: 0, + data: { + configs: [ + { + name: 'ai', + label: { en_US: 'AI Feature' }, + stages: [ + { + name: 'runner', + label: { en_US: 'Runner' }, + config: [ + { + name: 'id', + type: 'select', + label: { en_US: 'Runner' }, + default: runnerId, + options: [ + { name: runnerId, label: { en_US: 'Fixture runner' } }, + ], + }, + ], + }, + { + name: runnerId, + label: { en_US: 'Plugin settings' }, + config: [ + { + name: 'note', + type: 'string', + label: { en_US: 'Plugin note' }, + default: '', + show_if: { + field: '__system.pipeline_id', + operator: 'eq', + value: 'pipeline-scope-fixture', + }, + }, + ], + }, + ], + }, + ], + }, + }, + }), + ); + const pipeline = { + uuid: 'pipeline-scope-fixture', + name: 'Plugin scope fixture', + description: '', + emoji: '⚙️', + is_default: false, + config: { + ai: { + runner: { id: runnerId }, + runner_config: { [runnerId]: { note: 'original' } }, + }, + trigger: {}, + safety: {}, + output: {}, + }, + }; + let saved: typeof pipeline | undefined; + await page.route( + '**/api/v1/pipelines/pipeline-scope-fixture', + async (route) => { + if (route.request().method() === 'PUT') { + saved = route.request().postDataJSON(); + await route.fulfill({ json: { code: 0, data: {} } }); + } else { + await route.fulfill({ json: { code: 0, data: { pipeline } } }); + } + }, + ); + await page.goto('/home/pipelines?id=pipeline-scope-fixture'); + await page.getByRole('tab', { name: 'AI', exact: true }).click(); + await expect( + page.getByRole('region', { name: 'Configuration' }).getByRole('textbox'), + ).toHaveValue('original'); + await expect(page.getByText('Sandbox Scope', { exact: true })).toHaveCount(0); + await page + .getByRole('region', { name: 'Configuration' }) + .getByRole('textbox') + .fill('round-trip'); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect + .poll(() => saved?.config.ai) + .toEqual({ + runner: { id: runnerId }, + runner_config: { [runnerId]: { note: 'round-trip' } }, + }); +}); diff --git a/web/tests/fixtures/README.md b/web/tests/fixtures/README.md new file mode 100644 index 000000000..d458f14eb --- /dev/null +++ b/web/tests/fixtures/README.md @@ -0,0 +1,17 @@ +# Sandbox condition compatibility fixture + +`sandbox-scope-schema.json` preserves the sandbox field from master +`9b7ba0d64708496ace30a82866f6dbc185f089dc` (`templates/metadata/pipeline/ai.yaml`) +solely as regression input for the generic dynamic-form condition renderer. +It is **not** shipped pipeline metadata, an installed plugin manifest, or an +assertion that 4.11 plugins may select the Host's execution sandbox scope. + +The old local-agent page is retired. Its unit matrix (availability precedence, +trimmed forced scope, ordered overrides, locale parity and live/external/system +resolution) remains covered. Browser coverage now mounts the real +`DynamicFormComponent` and updates caller context without remounting, rather +than expecting a removed PipelineForm Box poller or scope-value coercion. +A separate real-pipeline browser regression verifies plugin `runner_config` +round-tripping and absence of an injected Core sandbox selector. + +The fixture uses no real Box, provider credentials, or production resources. diff --git a/web/tests/fixtures/sandbox-scope-schema.json b/web/tests/fixtures/sandbox-scope-schema.json new file mode 100644 index 000000000..e282d2a81 --- /dev/null +++ b/web/tests/fixtures/sandbox-scope-schema.json @@ -0,0 +1,149 @@ +{ + "name": "box-session-id-template", + "label": { + "en_US": "Sandbox Scope", + "zh_Hans": "沙箱作用域", + "zh_Hant": "沙箱作用域", + "ja_JP": "サンドボックススコープ", + "vi_VN": "Phạm vi Sandbox", + "th_TH": "ขอบเขต Sandbox", + "es_ES": "Alcance del Sandbox", + "ru_RU": "Область песочницы" + }, + "description": { + "en_US": "Determines how sandbox environments are shared across messages.", + "zh_Hans": "决定沙箱环境在不同消息间的共享方式。", + "zh_Hant": "決定沙箱環境在不同訊息間的共享方式。", + "ja_JP": "メッセージ間でサンドボックス環境を共有する方法を決定します。", + "vi_VN": "Xác định cách chia sẻ môi trường sandbox giữa các tin nhắn.", + "th_TH": "กำหนดวิธีแชร์สภาพแวดล้อม Sandbox ระหว่างข้อความ", + "es_ES": "Determina cómo se comparten los entornos sandbox entre mensajes.", + "ru_RU": "Определяет, как песочницы используются совместно между сообщениями." + }, + "disable_if": { + "field": "__system.box_scope_editable", + "operator": "eq", + "value": false + }, + "disabled_tooltip": { + "en_US": "Sandbox is unavailable. Enable Box and check its connection before changing the scope.", + "zh_Hans": "沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。", + "zh_Hant": "沙箱未啟用,請啟用 Box 並確認連線正常後再修改作用域。", + "ja_JP": "サンドボックスは利用できません。Box を有効にし、接続を確認してからスコープを変更してください。", + "vi_VN": "Sandbox không khả dụng. Hãy bật Box và kiểm tra kết nối trước khi thay đổi phạm vi.", + "th_TH": "Sandbox ไม่พร้อมใช้งาน โปรดเปิดใช้งาน Box และตรวจสอบการเชื่อมต่อก่อนเปลี่ยนขอบเขต", + "es_ES": "El sandbox no está disponible. Active Box y compruebe su conexión antes de cambiar el alcance.", + "ru_RU": "Песочница недоступна. Включите Box и проверьте подключение, прежде чем менять область." + }, + "disabled_tooltip_overrides": [ + { + "when": { + "field": "__system.box_scope_forced_global", + "operator": "eq", + "value": true + }, + "tooltip": { + "en_US": "A global sandbox is enforced; the scope cannot be changed.", + "zh_Hans": "已强制使用全局沙箱,无法修改作用域。", + "zh_Hant": "已強制使用全域沙箱,無法修改作用域。", + "ja_JP": "グローバルサンドボックスの使用が強制されているため、スコープを変更できません。", + "vi_VN": "Bắt buộc sử dụng sandbox toàn cục; không thể thay đổi phạm vi.", + "th_TH": "ระบบบังคับใช้ Sandbox ส่วนกลาง จึงไม่สามารถเปลี่ยนขอบเขตได้", + "es_ES": "Se impone un sandbox global; no se puede cambiar el alcance.", + "ru_RU": "Принудительно используется глобальная песочница; изменить область нельзя." + } + }, + { + "when": { + "field": "__system.box_scope_forced", + "operator": "eq", + "value": true + }, + "tooltip": { + "en_US": "A fixed sandbox scope is enforced; the scope cannot be changed.", + "zh_Hans": "已强制使用固定沙箱作用域,无法修改作用域。", + "zh_Hant": "已強制使用固定沙箱作用域,無法修改作用域。", + "ja_JP": "固定のサンドボックススコープが強制されているため、スコープを変更できません。", + "vi_VN": "Phạm vi sandbox đã được cố định bắt buộc; không thể thay đổi phạm vi.", + "th_TH": "ระบบบังคับใช้ขอบเขต Sandbox แบบตายตัว จึงไม่สามารถเปลี่ยนขอบเขตได้", + "es_ES": "Se impone un alcance fijo del sandbox; no se puede cambiar el alcance.", + "ru_RU": "Принудительно задана фиксированная область песочницы; изменить её нельзя." + } + } + ], + "type": "select", + "required": false, + "default": "{launcher_type}_{launcher_id}", + "options": [ + { + "name": "{global}", + "label": { + "en_US": "Global (shared by all)", + "zh_Hans": "全局(所有人共享)", + "zh_Hant": "全域(所有人共用)", + "ja_JP": "グローバル(全員共有)", + "vi_VN": "Toàn cục (chia sẻ cho tất cả)", + "th_TH": "ทั่วไป (แชร์ทั้งหมด)", + "es_ES": "Global (compartido por todos)", + "ru_RU": "Глобальный (общий для всех)" + } + }, + { + "name": "{launcher_type}_{launcher_id}", + "label": { + "en_US": "Per chat (Recommended)", + "zh_Hans": "每个会话(推荐)", + "zh_Hant": "每個會話(推薦)", + "ja_JP": "チャットごと(推奨)", + "vi_VN": "Mỗi cuộc trò chuyện (Khuyến nghị)", + "th_TH": "ต่อแชท (แนะนำ)", + "es_ES": "Por chat (Recomendado)", + "ru_RU": "По чату (Рекомендуется)" + } + }, + { + "name": "{launcher_type}_{launcher_id}_{sender_id}", + "label": { + "en_US": "Per user in chat", + "zh_Hans": "会话中每个用户", + "zh_Hant": "會話中每個用戶", + "ja_JP": "チャット内のユーザーごと", + "vi_VN": "Mỗi người dùng trong cuộc trò chuyện", + "th_TH": "ต่อผู้ใช้ในแชท", + "es_ES": "Por usuario en chat", + "ru_RU": "По пользователю в чате" + } + }, + { + "name": "{launcher_type}_{launcher_id}_{conversation_id}", + "label": { + "en_US": "Per conversation context", + "zh_Hans": "每个对话上下文", + "zh_Hant": "每個對話上下文", + "ja_JP": "会話コンテキストごと", + "vi_VN": "Mỗi ngữ cảnh hội thoại", + "th_TH": "ต่อบริบทการสนทนา", + "es_ES": "Por contexto de conversación", + "ru_RU": "По контексту разговора" + } + }, + { + "name": "{query_id}", + "label": { + "en_US": "Per message (isolated)", + "zh_Hans": "每条消息(完全隔离)", + "zh_Hant": "每條訊息(完全隔離)", + "ja_JP": "メッセージごと(隔離)", + "vi_VN": "Mỗi tin nhắn (cách ly)", + "th_TH": "ต่อข้อความ (แยกส่วน)", + "es_ES": "Por mensaje (aislado)", + "ru_RU": "По сообщению (изолированно)" + } + } + ], + "show_if": { + "field": "__system.is_wizard", + "operator": "neq", + "value": true + } +} diff --git a/web/tests/unit/cloud-new-account-entry.test.mjs b/web/tests/unit/cloud-new-account-entry.test.mjs new file mode 100644 index 000000000..8a096be33 --- /dev/null +++ b/web/tests/unit/cloud-new-account-entry.test.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +const source = fs.readFileSync( + new URL('../../src/app/login/page.tsx', import.meta.url), + 'utf8', +); + +test('normal Cloud login uses the standard Space OAuth callback path', () => { + assert.doesNotMatch(source, /cloudEntry/); + assert.match(source, /getSpaceAuthorizeUrl\(redirectUri\)/); +}); + +test('invitation login uses the same OAuth callback before accepting the invitation', () => { + assert.doesNotMatch(source, /cloudEntry/); + assert.match(source, /const invitationToken = getPendingInvitationToken\(\)/); + assert.match(source, /acceptWorkspaceInvitation\(invitationToken\)/); +}); diff --git a/web/tests/unit/codex-subscription.test.mjs b/web/tests/unit/codex-subscription.test.mjs new file mode 100644 index 000000000..6d8d9bbdf --- /dev/null +++ b/web/tests/unit/codex-subscription.test.mjs @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import ts from 'typescript'; + +test('all locale catalogs cover Codex states and preserve the expiry placeholder', () => { + const directory = new URL('../../src/i18n/locales/', import.meta.url); + let expected; + for (const file of fs.readdirSync(directory)) { + const compiled = ts.transpileModule( + fs.readFileSync(new URL(file, directory), 'utf8'), + { + compilerOptions: { module: ts.ModuleKind.CommonJS }, + }, + ).outputText; + const module = { exports: {} }; + new Function('module', 'exports', compiled)(module, module.exports); + const catalog = (module.exports.default || Object.values(module.exports)[0]) + .models.codex; + const keys = Object.keys(catalog).sort(); + expected ??= keys; + assert.deepEqual(keys, expected, file); + assert.equal(keys.length, 26, file); + assert.ok(catalog.expiresAt.includes('{{time}}'), file); + } +}); + +function policy() { + const source = fs.readFileSync( + new URL( + '../../src/app/home/components/models-dialog/component/provider-form/codexPolicy.ts', + import.meta.url, + ), + 'utf8', + ); + const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS }, + }).outputText; + const module = { exports: {} }; + new Function('module', 'exports', compiled)(module, module.exports); + return module.exports; +} + +test('Codex payload discards previously entered API credentials and URL', () => { + const { providerPayload } = policy(); + assert.deepEqual( + providerPayload({ + name: 'Subscription', + requester: 'openai-codex', + base_url: 'https://proxy.invalid', + api_key: 'fixture-only', + }), + { + name: 'Subscription', + requester: 'openai-codex', + base_url: 'https://chatgpt.com/backend-api/codex', + api_keys: [], + }, + ); +}); + +test('ordinary providers preserve API key and base URL behavior', () => { + assert.deepEqual( + policy().providerPayload({ + name: 'API', + requester: 'openai', + base_url: 'https://api.example.test/v1', + api_key: 'fixture-only', + }), + { + name: 'API', + requester: 'openai', + base_url: 'https://api.example.test/v1', + api_keys: ['fixture-only'], + }, + ); +}); + +test('poll delay honors upstream minimum and transient backoff', () => { + const { pollDelay } = policy(); + assert.equal(pollDelay(5, 0), 5000); + assert.equal(pollDelay(10, 2), 40000); + assert.equal(pollDelay(120, 3), 120000); + assert.equal(pollDelay(NaN, 0), 5000); + assert.equal(pollDelay(-1, 0), 5000); +}); + +test('only the contracted OpenAI device authorization URL can be opened', () => { + const { isCodexVerificationUri } = policy(); + assert.equal( + isCodexVerificationUri('https://auth.openai.com/codex/device'), + true, + ); + for (const url of [ + 'javascript:alert(1)', + 'https://auth.openai.com.evil.test/codex/device', + 'https://evil.test', + 'https://user@auth.openai.com/codex/device', + ]) { + assert.equal(isCodexVerificationUri(url), false); + } +}); diff --git a/web/tests/unit/embed-widget-assistant-dedupe.test.mjs b/web/tests/unit/embed-widget-assistant-dedupe.test.mjs new file mode 100644 index 000000000..42f172d3d --- /dev/null +++ b/web/tests/unit/embed-widget-assistant-dedupe.test.mjs @@ -0,0 +1,201 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)); +const widgetPath = path.resolve( + testDirectory, + '../../../src/langbot/templates/embed/widget.js', +); +const widgetSource = fs.readFileSync(widgetPath, 'utf8'); + +class FakeElement { + constructor(tagName) { + this.tagName = tagName; + this.children = []; + this.className = ''; + this.dataset = {}; + this.style = {}; + this.listeners = {}; + this._innerHTML = ''; + } + + appendChild(child) { + this.children.push(child); + return child; + } + + setAttribute(name, value) { + this[name] = String(value); + } + + addEventListener(event, listener) { + this.listeners[event] = listener; + } + + click() { + this.listeners.click?.({}); + } + + attachShadow() { + this.shadowRoot = new FakeElement('shadow-root'); + return this.shadowRoot; + } + + get classList() { + return { + add: (...names) => { + const classes = `${this.className} ${names.join(' ')}` + .trim() + .split(/\s+/); + this.className = [...new Set(classes)].join(' '); + }, + }; + } + + set textContent(value) { + this._innerHTML = String(value ?? ''); + } + + set innerHTML(value) { + this._innerHTML = String(value ?? ''); + } + + get innerHTML() { + return this._innerHTML; + } + + querySelectorAll(selector) { + const matches = []; + for (const child of this.children) { + if ( + selector.startsWith('.') && + child.className.split(/\s+/).includes(selector.slice(1)) + ) { + matches.push(child); + } + matches.push(...child.querySelectorAll(selector)); + } + return matches; + } + + querySelector(selector) { + return this.querySelectorAll(selector)[0] ?? null; + } +} + +class FakeDocument { + constructor() { + this.body = new FakeElement('body'); + this.head = new FakeElement('head'); + this.readyState = 'complete'; + this.currentScript = { getAttribute: () => null }; + } + + createElement(tagName) { + return new FakeElement(tagName); + } + + getElementById(id) { + const find = (element) => { + if (element.id === id) return element; + for (const child of element.children) { + const match = find(child); + if (match) return match; + } + return null; + }; + return find(this.body) ?? find(this.head); + } +} + +class FakeWebSocket { + static OPEN = 1; + static instances = []; + + constructor(url) { + this.url = url; + this.readyState = FakeWebSocket.OPEN; + FakeWebSocket.instances.push(this); + } + + send() {} +} + +function launchWidget() { + FakeWebSocket.instances = []; + const document = new FakeDocument(); + const window = { + crypto: { randomUUID: () => '00000000-0000-4000-8000-000000000000' }, + sessionStorage: { getItem: () => null, setItem: () => {} }, + }; + const context = vm.createContext({ + document, + window, + navigator: { clipboard: { writeText: () => Promise.resolve() } }, + WebSocket: FakeWebSocket, + fetch: () => new Promise(() => {}), + requestAnimationFrame: () => 0, + setTimeout: () => 0, + clearTimeout: () => {}, + setInterval: () => 0, + clearInterval: () => {}, + }); + + vm.runInContext(widgetSource, context, { filename: widgetPath }); + const root = document.getElementById('langbot-widget-root'); + assert.ok(root, 'widget should initialize'); + root.shadowRoot.querySelector('.lb-bubble').click(); + const socket = FakeWebSocket.instances.at(-1); + assert.ok(socket, 'opening widget should connect its WebSocket'); + + return { + receive(message) { + socket.onmessage({ + data: JSON.stringify({ type: 'response', data: message }), + }); + }, + assistantMessages() { + return root.shadowRoot.querySelectorAll('.lb-msg-assistant'); + }, + }; +} + +function assistant(id, content) { + return { id, role: 'assistant', content, is_final: true }; +} + +test('renders a non-empty reply after an empty assistant frame', () => { + const widget = launchWidget(); + + widget.receive(assistant('thought', '')); + widget.receive(assistant('answer', 'visible answer')); + + const messages = widget.assistantMessages(); + assert.equal(messages.length, 2); + assert.equal( + messages[1].querySelector('.lb-msg-bubble').innerHTML, + 'visible answer', + ); +}); + +test('still drops a duplicate non-empty assistant frame', () => { + const widget = launchWidget(); + + widget.receive(assistant('answer-1', 'same answer')); + widget.receive(assistant('answer-2', 'same answer')); + + assert.equal(widget.assistantMessages().length, 1); +}); + +test('still keeps two distinct non-empty assistant frames', () => { + const widget = launchWidget(); + + widget.receive(assistant('answer-1', 'first answer')); + widget.receive(assistant('answer-2', 'second answer')); + + assert.equal(widget.assistantMessages().length, 2); +}); diff --git a/web/tests/unit/n8n-auth-field-visibility.test.mjs b/web/tests/unit/n8n-auth-field-visibility.test.mjs new file mode 100644 index 000000000..7a022d774 --- /dev/null +++ b/web/tests/unit/n8n-auth-field-visibility.test.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import ts from 'typescript'; +import { fileURLToPath } from 'node:url'; + +const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); +const sourcePath = path.resolve( + currentDirectory, + '../../src/app/home/components/dynamic-form/N8nAuthFieldVisibility.ts', +); + +function loadVisibilityPolicy() { + const source = fs.readFileSync(sourcePath, 'utf8'); + const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS }, + }).outputText; + const loadedModule = { exports: {} }; + new Function('require', 'module', 'exports', compiled)( + () => { + throw new Error('N8nAuthFieldVisibility must not have runtime imports'); + }, + loadedModule, + loadedModule.exports, + ); + return loadedModule.exports; +} + +test('shows response handling with the other common n8n fields', () => { + const { shouldShowN8nConfigField } = loadVisibilityPolicy(); + + for (const field of [ + 'webhook-url', + 'auth-type', + 'timeout', + 'output-key', + 'response-handling', + ]) { + assert.equal(shouldShowN8nConfigField(field, 'none'), true, field); + } +}); + +test('shows only fields for the selected n8n authentication method', () => { + const { shouldShowN8nConfigField } = loadVisibilityPolicy(); + + assert.equal(shouldShowN8nConfigField('basic-username', 'basic'), true); + assert.equal(shouldShowN8nConfigField('basic-password', 'jwt'), false); + assert.equal(shouldShowN8nConfigField('jwt-secret', 'jwt'), true); + assert.equal(shouldShowN8nConfigField('header-name', 'header'), true); + assert.equal(shouldShowN8nConfigField('unrelated-field', 'none'), false); +}); diff --git a/web/tests/unit/runner-merge-contract.test.mjs b/web/tests/unit/runner-merge-contract.test.mjs new file mode 100644 index 000000000..6432bccb4 --- /dev/null +++ b/web/tests/unit/runner-merge-contract.test.mjs @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import ts from 'typescript'; + +const read = (path) => fs.readFileSync(new URL(path, import.meta.url), 'utf8'); + +test('dynamic form combines manifest normalization and shared condition helpers', () => { + const source = read( + '../../src/app/home/components/dynamic-form/DynamicFormComponent.tsx', + ); + const parsed = ts.createSourceFile( + 'DynamicFormComponent.tsx', + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + assert.equal( + parsed.parseDiagnostics.length, + 0, + 'Dynamic form must parse after the merge', + ); + const imports = parsed.statements.filter(ts.isImportDeclaration); + const importedNames = (module) => + imports + .filter((item) => item.moduleSpecifier.text === module) + .flatMap( + (item) => + item.importClause?.namedBindings?.elements?.map( + (entry) => entry.name.text, + ) ?? [], + ); + assert.ok( + importedNames('./DynamicFormItemConfig').includes( + 'parseDynamicFormItemType', + ), + ); + assert.deepEqual(importedNames('./DynamicFormConditions').sort(), [ + 'resolveDisabledState', + 'resolveShowIfValue', + ]); + assert.equal( + parsed.statements.filter( + (item) => + ts.isFunctionDeclaration(item) && + item.name?.text === 'resolveShowIfValue', + ).length, + 0, + ); +}); + +test('pipeline defaults retain plugin runner containers, not retired Core runners', () => { + const config = JSON.parse( + read('../../../src/langbot/templates/default-pipeline-config.json'), + ); + assert.deepEqual(config.ai, { + runner: { id: '', 'expire-time': 0 }, + runner_config: {}, + }); + const metadata = read( + '../../../src/langbot/templates/metadata/pipeline/ai.yaml', + ); + assert.doesNotMatch( + metadata, + /^\s+- name: (?:local-agent|n8n-service-api|langflow-api)$/m, + ); + assert.match(metadata, /RunnerRegistry/); +}); + +test('pipeline UI does not restore host-owned Box scope as local-agent config', () => { + const source = read( + '../../src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx', + ); + assert.doesNotMatch( + source, + /getBoxScopeContext|force_box_session_id_template|N8nAuthFormComponent/, + ); + assert.match(source, /ai\.runner_config/); + assert.match(source, /systemContext=\{dynamicFormSystemContext\}/); +}); + +test('backend client preserves complementary Codex and runner debug API imports', () => { + const source = read('../../src/app/infra/http/BackendClient.ts'); + const parsed = ts.createSourceFile( + 'BackendClient.ts', + source, + ts.ScriptTarget.Latest, + true, + ); + assert.equal( + parsed.parseDiagnostics.length, + 0, + 'API client must parse after the merge', + ); + const imports = parsed.statements + .filter(ts.isImportDeclaration) + .flatMap( + (item) => + item.importClause?.namedBindings?.elements?.map( + (entry) => entry.name.text, + ) ?? [], + ); + for (const name of [ + 'CodexAuthStatus', + 'CodexDeviceAuthorization', + 'CodexDevicePoll', + 'DebugExecutionEvent', + ]) { + assert.ok(imports.includes(name), name); + } +}); diff --git a/web/tests/unit/sandbox-scope-tooltip.test.mjs b/web/tests/unit/sandbox-scope-tooltip.test.mjs new file mode 100644 index 000000000..a5cf624aa --- /dev/null +++ b/web/tests/unit/sandbox-scope-tooltip.test.mjs @@ -0,0 +1,246 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import ts from 'typescript'; + +// Compatibility fixture, not Core metadata: 4.11 owns sandbox scope in the +// Runner Host. Keep the original condition/locale matrix without reintroducing +// a local-agent configuration stage or claiming plugins control Host scope. +const scope = JSON.parse( + fs.readFileSync( + new URL('../fixtures/sandbox-scope-schema.json', import.meta.url), + 'utf8', + ), +); +const unavailable = '沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。'; +const globalForced = '已强制使用全局沙箱,无法修改作用域。'; +const customForced = '已强制使用固定沙箱作用域,无法修改作用域。'; + +function loadSource(relativePath) { + const filename = new URL(`../../src/${relativePath}`, import.meta.url); + assert.ok(fs.existsSync(filename), `Missing policy module: ${relativePath}`); + const compiled = ts.transpileModule(fs.readFileSync(filename, 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS }, + }).outputText; + const loaded = { exports: {} }; + new Function('require', 'module', 'exports', compiled)( + (name) => { + if (name === '@/app/infra/entities/form/dynamic') + return loadSource('app/infra/entities/form/dynamic.ts'); + throw new Error(`Unexpected runtime import: ${name}`); + }, + loaded, + loaded.exports, + ); + return loaded.exports; +} + +function policies() { + return { + ...loadSource('app/home/components/dynamic-form/DynamicFormConditions.ts'), + ...loadSource( + 'app/home/pipelines/components/pipeline-form/BoxScopeContext.ts', + ), + }; +} + +function scopeState(available, forcedTemplate) { + const { getBoxScopeContext, resolveDisabledState } = policies(); + return resolveDisabledState( + scope, + {}, + undefined, + getBoxScopeContext(available, forcedTemplate), + ); +} + +test('sandbox default tooltip explains only unavailability', () => { + assert.equal(scope.disabled_tooltip.zh_Hans, unavailable); +}); + +for (const [name, available, template, expected] of [ + ['Box disabled', false, '', unavailable], + ['Box disconnected', false, undefined, unavailable], + [ + 'unavailable takes precedence over forced global', + false, + '{global}', + unavailable, + ], + [ + 'unavailable takes precedence over forced custom', + false, + '{pipeline_id}', + unavailable, + ], + ['available forced global', true, '{global}', globalForced], + ['available padded forced global', true, ' {global} ', globalForced], + ['available whitespace-only editable', true, ' ', undefined], + ['available forced custom', true, '{pipeline_id}', customForced], + ['available forced literal', true, 'tenant-sandbox', customForced], + ['available editable', true, '', undefined], + ['available without limitation', true, undefined, undefined], +]) { + test(name, () => { + const state = scopeState(available, template); + assert.equal(state.isDisabledByCondition, expected !== undefined); + assert.equal(state.disabledTooltip?.zh_Hans, expected); + }); +} + +test('reason follows availability and forced-scope transitions without mutating metadata', () => { + const snapshot = structuredClone(scope); + for (const [available, template, expected] of [ + [false, '{global}', unavailable], + [true, '{global}', globalForced], + [true, '{pipeline_id}', customForced], + [true, '', undefined], + [false, '', unavailable], + [true, '', undefined], + ]) { + assert.equal( + scopeState(available, template).disabledTooltip?.zh_Hans, + expected, + ); + } + assert.deepEqual(scope, snapshot); +}); + +test('all sandbox reason variants preserve the eight metadata locales', () => { + const locales = [ + 'en_US', + 'zh_Hans', + 'zh_Hant', + 'ja_JP', + 'vi_VN', + 'th_TH', + 'es_ES', + 'ru_RU', + ].sort(); + assert.equal(scope.disabled_tooltip_overrides?.length, 2); + const messages = [ + scope.disabled_tooltip, + ...scope.disabled_tooltip_overrides.map((entry) => entry.tooltip), + ]; + for (const message of messages) { + assert.deepEqual(Object.keys(message).sort(), locales); + for (const locale of locales) assert.ok(message[locale].trim(), locale); + } + for (const locale of locales) { + assert.equal( + new Set(messages.map((message) => message[locale])).size, + 3, + locale, + ); + assert.equal( + scopeState(false, '{global}').disabledTooltip[locale], + messages[0][locale], + ); + assert.equal( + scopeState(true, '{global}').disabledTooltip[locale], + messages[1][locale], + ); + assert.equal( + scopeState(true, '{pipeline_id}').disabledTooltip[locale], + messages[2][locale], + ); + } +}); + +test('ordinary static disabled tooltip remains compatible', () => { + const { resolveDisabledState } = policies(); + const tooltip = { en_US: 'Read only' }; + const config = { + disable_if: { field: 'locked', operator: 'eq', value: true }, + disabled_tooltip: tooltip, + }; + assert.deepEqual(resolveDisabledState(config, { locked: true }), { + isDisabledByCondition: true, + disabledTooltip: tooltip, + }); + assert.deepEqual(resolveDisabledState(config, { locked: false }), { + isDisabledByCondition: false, + disabledTooltip: undefined, + }); + assert.equal( + resolveDisabledState({ disabled_tooltip: tooltip }, {}).disabledTooltip, + undefined, + ); + assert.equal( + resolveDisabledState({ disable_if: config.disable_if }, { locked: true }) + .disabledTooltip, + undefined, + ); +}); + +test('conditional overrides reuse eq, neq, in and live/external/system resolution', () => { + const { matchesFormCondition, resolveDisabledState } = policies(); + const watched = { mode: 'live', empty: null, '__system.locked': false }; + const external = { mode: 'external', fallback: 3, empty: 'external' }; + const system = { locked: true }; + for (const [condition, expected] of [ + [{ field: 'mode', operator: 'eq', value: 'live' }, true], + [{ field: 'mode', operator: 'eq', value: 'external' }, false], + [{ field: 'fallback', operator: 'neq', value: 4 }, true], + [{ field: 'fallback', operator: 'in', value: [2, 3] }, true], + [{ field: 'fallback', operator: 'in', value: '3' }, false], + [{ field: 'fallback', operator: 'eq', value: '3' }, false], + [{ field: 'empty', operator: 'eq', value: null }, true], + [{ field: '__system.locked', operator: 'eq', value: true }, true], + [{ field: 'absent', operator: 'eq', value: true }, false], + ]) + assert.equal( + matchesFormCondition(condition, watched, external, system), + expected, + ); + const config = { + disable_if: { field: '__system.locked', operator: 'eq', value: true }, + disabled_tooltip: { en_US: 'Default' }, + disabled_tooltip_overrides: [ + { + when: { field: 'mode', operator: 'eq', value: 'external' }, + tooltip: { en_US: 'Wrong' }, + }, + { + when: { field: 'fallback', operator: 'in', value: [3] }, + tooltip: { en_US: 'First match' }, + }, + { + when: { field: 'mode', operator: 'neq', value: 'external' }, + tooltip: { en_US: 'Later match' }, + }, + ], + }; + assert.equal( + resolveDisabledState(config, watched, external, system).disabledTooltip + .en_US, + 'First match', + ); + assert.equal( + resolveDisabledState(config, {}, {}, system).disabledTooltip.en_US, + 'Later match', + ); + assert.equal( + resolveDisabledState(config, watched, external, { locked: false }) + .disabledTooltip, + undefined, + ); + assert.equal( + resolveDisabledState( + { ...config, disabled_tooltip_overrides: [] }, + watched, + external, + system, + ).disabledTooltip.en_US, + 'Default', + ); + const unmatched = { + ...config, + disabled_tooltip_overrides: [config.disabled_tooltip_overrides[0]], + }; + assert.equal( + resolveDisabledState(unmatched, watched, external, system).disabledTooltip + .en_US, + 'Default', + ); +}); diff --git a/web/tests/unit/session-monitor-pagination.test.mjs b/web/tests/unit/session-monitor-pagination.test.mjs new file mode 100644 index 000000000..38b09f306 --- /dev/null +++ b/web/tests/unit/session-monitor-pagination.test.mjs @@ -0,0 +1,155 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..', +); +const repoRoot = path.resolve(root, '..'); +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); +const readRepo = (file) => fs.readFileSync(path.join(repoRoot, file), 'utf8'); +const includes = (source, token, message) => + assert.ok(source.includes(token), message); + +test('session list request supports a server-side page and operator filters', () => { + const client = read('src/app/infra/http/BackendClient.ts'); + includes(client, 'startTime?: string', 'client accepts a start date'); + includes(client, 'endTime?: string', 'client accepts an end date'); + includes(client, 'userQuery?: string', 'client accepts a user query'); + includes( + client, + "queryParams.append('offset', options.offset.toString())", + 'client sends the requested session offset', + ); + includes( + client, + "queryParams.append('userQuery', options.userQuery)", + 'client sends the user query', + ); + + const monitor = read( + 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', + ); + for (const token of [ + 'SESSION_PAGE_SIZE', + 'sessionTotal', + 'sessionPage', + 'startDate', + 'endDate', + 'userQuery', + ]) { + includes(monitor, token, `monitor includes ${token}`); + } +}); + +test('session detail requests and renders a bounded message page', () => { + const monitor = read( + 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', + ); + for (const token of [ + 'MESSAGE_PAGE_SIZE', + 'messageTotal', + 'messagePage', + 'page * MESSAGE_PAGE_SIZE', + ]) { + includes(monitor, token, `message pagination includes ${token}`); + } +}); + +test('backend filters sessions by user id or user name in the existing endpoint', () => { + const controller = readRepo( + 'src/langbot/pkg/api/http/controller/groups/monitoring.py', + ); + const service = readRepo('src/langbot/pkg/api/http/service/monitoring.py'); + includes( + controller, + "quart.request.args.get('userQuery')", + 'route accepts userQuery', + ); + includes(controller, 'user_query=user_query', 'route forwards userQuery'); + includes( + service, + 'user_query: str | None = None', + 'service accepts userQuery', + ); + includes( + service, + 'MonitoringSession.user_id.ilike', + 'service searches user ids', + ); + includes( + service, + 'MonitoringSession.user_name.ilike', + 'service searches user names', + ); +}); + +test('stale session and message page responses cannot overwrite the latest page', () => { + const monitor = read( + 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', + ); + for (const token of [ + 'sessionRequestIdRef', + 'messageRequestIdRef', + 'requestId !== sessionRequestIdRef.current', + 'requestId !== messageRequestIdRef.current', + 'messageRequestIdRef.current += 1', + ]) { + includes(monitor, token, `stale response guard includes ${token}`); + } +}); + +test('changing the session page or filters clears the selected detail', () => { + const monitor = read( + 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', + ); + includes(monitor, 'setSelectedSessionId(null)', 'selection is cleared'); + includes( + monitor, + '[appliedUserQuery, botId, endDate, sessionPage, startDate]', + 'page and filters invalidate the selected session', + ); +}); + +test('date filters use the operator local calendar day', () => { + const monitor = read( + 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', + ); + includes( + monitor, + 'localDateBoundaryToISOString(startDate, false)', + 'local start-of-day conversion', + ); + includes( + monitor, + 'localDateBoundaryToISOString(endDate, true)', + 'local end-of-day conversion', + ); +}); + +test('session tool calls are bounded to the visible message page', () => { + const monitor = read( + 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', + ); + includes(monitor, 'startTime: sorted[0]?.timestamp', 'analysis page start'); + includes( + monitor, + 'endTime: sorted[sorted.length - 1]?.timestamp', + 'analysis page end', + ); + includes(monitor, 'sessionId, botId, {', 'bot-scoped analysis'); + const client = read('src/app/infra/http/BackendClient.ts'); + includes( + client, + "queryParams.set('startTime', options.startTime)", + 'analysis start query', + ); + includes( + client, + "queryParams.set('endTime', options.endTime)", + 'analysis end query', + ); +});