From 89b034b9258b7d10aa51ab35fc6720848892d7d0 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 3 Jun 2026 09:24:51 -0700 Subject: [PATCH] =?UTF-8?q?Release=20v0.51.232=20=E2=80=94=20Release=20GZ?= =?UTF-8?q?=20(stage-q2)=20(#3485)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Release v0.51.232 — Release GZ (stage-q2) Two low-risk fixes batched into one release. All gates green. ### Fixes | PR | Author | Fix | |----|--------|-----| | #3473 | @Mubashirrrr | Guard malformed/negative numeric query params on the cron endpoints (`/api/crons/output`, `/api/crons/recent`) — no more 500 on `?limit=abc`, no oldest-entry drop on a negative limit | | #3480 | @koshikai | Translate 80 previously-untranslated Japanese (`ja`) locale strings (MCP controls, tool summaries, toasts) — interpolation placeholders preserved, no keys added/removed | ### Gate results - **Full pytest suite**: 7446 passed, 7 skipped, 3 xpassed, **0 failed** - **ESLint runtime gate**: CLEAN - **ruff forward gate**: CLEAN - **browser-smoke gate**: CLEAN (`/`, `/#settings`, `/#sessions` — zero console errors) - **Codex (regression)**: SAFE TO SHIP (flagged a CHANGELOG endpoint-path typo `/api/cron/` → `/api/crons/`, fixed) - **Opus (correctness)**: SAFE TO SHIP (flagged an inline-comment imprecision about which entries a negative slice drops — fixed in both the comment and CHANGELOG) Co-authored-by: Mubashirrrr Co-authored-by: koshikai --- CHANGELOG.md | 8 ++ api/routes.py | 19 ++- static/i18n.js | 154 +++++++++++----------- tests/test_cron_query_param_validation.py | 152 +++++++++++++++++++++ 4 files changed, 254 insertions(+), 79 deletions(-) create mode 100644 tests/test_cron_query_param_validation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bdfe0eba..e8bf2ae90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ ## [Unreleased] +## [v0.51.232] — 2026-06-03 — Release GZ (stage-q2 — cron-endpoint query-param guards + Japanese locale translations) + +### Fixed +- The cron output (`/api/crons/output`) and cron recent (`/api/crons/recent`) endpoints no longer return a confusing HTTP 500 on a malformed numeric query param. A non-numeric `limit` (e.g. `?limit=abc`) or `since` previously let `int()`/`float()` raise `ValueError` up to the top-level handler; both are now parsed defensively (falling back to their defaults). The cron-output `limit` is also clamped to `[1, 500]` so a negative value can't reach the newest-first `files[:limit]` slice as `files[:-n]` (which would drop the oldest entries — or return an empty list when the magnitude exceeds the count — instead of the newest outputs), mirroring the guard `_handle_cron_run_detail` already uses (#3473, @Mubashirrrr). + +### Changed +- Japanese (`ja`) locale: translated 80 previously-untranslated UI strings (MCP server controls, tool summaries, and related toasts) from their English fallbacks to Japanese, with all `${…}` interpolation placeholders preserved. No locale keys added or removed (#3480, @koshikai). + ## [v0.51.231] — 2026-06-03 — Release GY (stage-q1 — /model extras-tail resolution + plugins-tab auto-hide + search-depth guard + symlink-home suggestions) ### Fixed diff --git a/api/routes.py b/api/routes.py index 29a9eaded..0f600464a 100644 --- a/api/routes.py +++ b/api/routes.py @@ -10246,9 +10246,18 @@ def _handle_cron_output(handler, parsed): qs = parse_qs(parsed.query) job_id = qs.get("job_id", [""])[0] - limit = int(qs.get("limit", ["5"])[0]) if not job_id: return j(handler, {"error": "job_id required"}, status=400) + # Reject malformed limit instead of letting int() raise ValueError and + # surface as a confusing 500. Clamp to a safe range; a negative value must + # never reach the slice below — files is sorted newest-first, so a negative + # limit on `files[:limit]` slices as `files[:-n]` and drops the n OLDEST + # entries (or all of them when |n| >= len), returning a truncated/empty list + # instead of the newest outputs. Mirrors _handle_cron_run_detail. + try: + limit = max(1, min(500, int(qs.get("limit", ["5"])[0]))) + except (ValueError, TypeError): + limit = 5 out_dir = CRON_OUT / job_id outputs = [] if out_dir.exists(): @@ -10280,7 +10289,13 @@ def _handle_cron_recent(handler, parsed): import datetime qs = parse_qs(parsed.query) - since = float(qs.get("since", ["0"])[0]) + # Reject a malformed `since` instead of letting float() raise ValueError and + # surface as a confusing 500. A bad/absent value means "from the epoch", so + # the client still gets a well-formed (if unfiltered) response. + try: + since = float(qs.get("since", ["0"])[0]) + except (ValueError, TypeError): + since = 0.0 try: from cron.jobs import list_jobs diff --git a/static/i18n.js b/static/i18n.js index 21eab3915..0f683dfbb 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -2748,11 +2748,11 @@ const LOCALES = { mcp_tool_count: '{0} 個のツール', mcp_enabled_yes: '有効', mcp_enabled_no: '無効', - mcp_enable_server: 'Enable this MCP server', - mcp_disable_server: 'Disable this MCP server', - mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, - mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, - mcp_toggle_failed: 'Failed to update MCP server.', + mcp_enable_server: 'このMCPサーバーを有効化', + mcp_disable_server: 'このMCPサーバーを無効化', + mcp_enabled_toast: (name) => `MCPサーバー「${name}」を有効化しました。`, + mcp_disabled_toast: (name) => `MCPサーバー「${name}」を無効化しました。`, + mcp_toggle_failed: 'MCPサーバーの更新に失敗しました。', mcp_tools_title: 'MCP ツール', mcp_tools_desc: 'アクティブな MCP サーバー全体から既知のツールを検索します。', mcp_tools_search_placeholder: '名前、サーバー、説明でツールを検索…', @@ -2761,20 +2761,20 @@ const LOCALES = { mcp_tools_load_failed: 'MCP ツールの読み込みに失敗しました。', mcp_tools_schema_empty: 'スキーマパラメータはありません。', mcp_tools_runtime_note: 'ツール一覧は既知の MCP ランタイム情報のみを使用します。WebUI はサーバーの起動や探索を行いません。', - mcp_tools_summary_no_matches: (query,total) => `No MCP tools matching “${query}” (${total} total MCP tools).`, - mcp_tools_summary_none: 'No MCP tools to show.', - mcp_tools_summary_matching: (query) => ` matching “${query}”`, - mcp_tools_summary_total_note: (total) => ` (${total} total MCP tools)`, - mcp_tools_summary_showing: (start,end,filtered,searchNote,totalNote,page,pages) => `Showing ${start}-${end} of ${filtered} MCP tools${searchNote}${totalNote}. Page ${page} of ${pages}.`, - mcp_tools_page_size_prefix: 'Show', - mcp_tools_page_size_suffix: 'per page', - mcp_tools_per_page_aria: 'MCP tools per page', - mcp_tools_inactive_configured_servers: (servers) => `Configured but inactive in this WebUI runtime: ${servers}.`, - mcp_tools_pagination_label: 'MCP tools pagination', - mcp_tools_previous_page: '‹ Prev', - mcp_tools_previous_page_aria: 'Previous MCP tools page', - mcp_tools_next_page: 'Next ›', - mcp_tools_next_page_aria: 'Next MCP tools page', + mcp_tools_summary_no_matches: (query,total) => `「${query}」に一致するMCPツールはありません(全${total}個)。`, + mcp_tools_summary_none: '表示するMCPツールはありません。', + mcp_tools_summary_matching: (query) => ` 「${query}」に一致`, + mcp_tools_summary_total_note: (total) => ` (全${total}個のMCPツール)`, + mcp_tools_summary_showing: (start,end,filtered,searchNote,totalNote,page,pages) => `${filtered}個のMCPツール${searchNote}${totalNote}のうち${start}〜${end}件を表示。${page}/${pages}ページ。`, + mcp_tools_page_size_prefix: '表示件数:', + mcp_tools_page_size_suffix: '件/ページ', + mcp_tools_per_page_aria: '1ページあたりのMCPツール数', + mcp_tools_inactive_configured_servers: (servers) => `このWebUIランタイムで設定済みだが非アクティブ: ${servers}。`, + mcp_tools_pagination_label: 'MCPツールのページ送り', + mcp_tools_previous_page: '‹ 前へ', + mcp_tools_previous_page_aria: '前のMCPツールページ', + mcp_tools_next_page: '次へ ›', + mcp_tools_next_page_aria: '次のMCPツールページ', // PDF preview (#480) pdf_loading: 'PDF {0} を読み込み中…', pdf_too_large: 'PDF が大きすぎてインラインプレビューできません', @@ -2847,12 +2847,12 @@ const LOCALES = { workspace_new_worktree_conversation_meta: 'このワークスペース用に隔離された git worktree を作成します。', workspace_worktree_created: 'worktree 会話を作成しました', workspace_worktree_failed: 'worktree の作成に失敗しました: ', - session_worktree_badge: 'Worktree', + session_worktree_badge: 'Worktree(作業ツリー)', model_scope_advisory: '次回のメッセージからこの会話に適用されます。', model_scope_toast: '次回のメッセージからこの会話に適用されます。', // commands.js cmd_clear: '会話メッセージをクリア', - cmd_help: 'Show available slash commands', + cmd_help: '使用可能なスラッシュコマンドを表示', cmd_compress: '会話コンテキストを手動で圧縮 (使い方: /compress [トピック])', ctx_compress_hint: 'コンテキストを圧縮して空きを確保 →', ctx_compress_action: '⚠ 今すぐ圧縮してコンテキストを確保', @@ -2899,21 +2899,21 @@ const LOCALES = { no_active_session: 'アクティブなセッションがありません', cmd_queue: '次のターン用にメッセージをキュー', cmd_goal: '永続ゴールを設定または確認', - goal_evaluating_progress: 'Evaluating goal progress…', - goal_working_toward: 'Working toward goal…', - goal_continuing_toast: 'Continuing toward goal…', - goal_status_none: 'No active goal. Set one with /goal .', - goal_status_active: (turns, max_turns, goal) => `⊙ Goal (active, ${turns}/${max_turns} turns): ${goal}`, - goal_status_paused: (turns, max_turns, reason, goal) => `⏸ Goal (paused, ${turns}/${max_turns}${reason ? `, ${reason}` : ''}): ${goal}`, - goal_status_done: (turns, max_turns, goal) => `✓ Goal done (${turns}/${max_turns}): ${goal}`, - goal_set: (turns, goal) => `⊙ Goal set (${turns}-turn budget): ${goal}`, - goal_paused: (goal) => `⏸ Goal paused: ${goal}`, - goal_resumed: (goal) => `▶ Goal resumed: ${goal}`, - goal_cleared: 'Goal cleared.', - goal_no_goal: 'No active goal.', - goal_achieved: (reason) => `✓ Goal achieved: ${reason}`, - goal_paused_budget_exhausted: (turns, max_turns) => `⏸ Goal paused — ${turns}/${max_turns} turns used. Use /goal resume to keep going, or /goal clear to stop.`, - goal_continuing: (turns, max_turns, reason) => `↻ Continuing toward goal (${turns}/${max_turns}): ${reason}`, + goal_evaluating_progress: 'ゴールの進捗を評価中…', + goal_working_toward: 'ゴールに向けて実行中…', + goal_continuing_toast: 'ゴールに向けて継続中…', + goal_status_none: 'アクティブなゴールはありません。/goal <テキスト> で設定してください。', + goal_status_active: (turns, max_turns, goal) => `⊙ ゴール(実行中、${turns}/${max_turns}ターン): ${goal}`, + goal_status_paused: (turns, max_turns, reason, goal) => `⏸ ゴール(一時停止、${turns}/${max_turns}${reason ? `、${reason}` : ''}): ${goal}`, + goal_status_done: (turns, max_turns, goal) => `✓ ゴール完了(${turns}/${max_turns}): ${goal}`, + goal_set: (turns, goal) => `⊙ ゴール設定(${turns}ターン予算): ${goal}`, + goal_paused: (goal) => `⏸ ゴール一時停止: ${goal}`, + goal_resumed: (goal) => `▶ ゴール再開: ${goal}`, + goal_cleared: 'ゴールをクリアしました。', + goal_no_goal: 'アクティブなゴールはありません。', + goal_achieved: (reason) => `✓ ゴール達成: ${reason}`, + goal_paused_budget_exhausted: (turns, max_turns) => `⏸ ゴール一時停止 — ${turns}/${max_turns}ターンを使用しました。継続するには /goal resume、停止するには /goal clear を使ってください。`, + goal_continuing: (turns, max_turns, reason) => `↻ ゴールに向けて継続中(${turns}/${max_turns}): ${reason}`, cmd_interrupt: '現在のターンをキャンセルして新規メッセージを送信', cmd_steer: 'エージェントを中断せずにターン中に修正を注入', cmd_queue_no_msg: '使い方: /queue <メッセージ>', @@ -3045,10 +3045,10 @@ const LOCALES = { workspace_empty_no_path: 'ワークスペースが選択されていません。設定 → ワークスペースで選択してください。', workspace_empty_dir: 'このワークスペースは空です。', workspace_show_hidden_files: '隠しファイルを表示', - workspace_show_hidden_files_desc: 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.', - workspace_hidden_files_visible: 'hidden visible', - workspace_hidden_files_visible_title: 'Hidden files are visible — click for options', - workspace_options: 'Workspace options', + workspace_show_hidden_files_desc: '.DS_Store、.git、node_modules などの隠しファイル・システムファイルをファイルツリーに表示します。', + workspace_hidden_files_visible: '隠しファイル表示中', + workspace_hidden_files_visible_title: '隠しファイルが表示されています — クリックでオプション', + workspace_options: 'ワークスペースオプション', dialog_confirm_title: '操作の確認', dialog_prompt_title: '値を入力', dialog_confirm_btn: '確認', @@ -3068,7 +3068,7 @@ const LOCALES = { file_open_failed: 'ファイルを開けませんでした', downloading: (name) => `${name} をダウンロード中…`, double_click_rename: 'ダブルクリックで名前変更', - session_rename_failed_no_row: 'Could not start rename — row not found.', + session_rename_failed_no_row: '名前変更を開始できませんでした — 行が見つかりません。', renamed_to: '名前を変更: ', rename_failed: '名前変更失敗: ', delete_title: '削除', @@ -3083,7 +3083,7 @@ const LOCALES = { copy_file_path: 'ファイルパスをコピー', open_in_vscode: 'VS Codeで開く', open_in_vscode_failed: 'VS Codeで開けませんでした: ', - download_folder: 'Download Folder', // TODO: translate + download_folder: 'フォルダをダウンロード', path_copied: 'ファイルパスをクリップボードにコピーしました', path_copy_failed: 'パスのコピーに失敗しました: ', session_rename: '会話の名前を変更', @@ -3200,8 +3200,8 @@ const LOCALES = { settings_label_session_endless_scroll: '上スクロールで古いメッセージを読み込む', settings_desc_session_endless_scroll: '有効にすると、上にスクロールしたとき古いメッセージを自動で読み込みます。無効の場合は古いメッセージボタンを使います。', - settings_label_activity_feed_expanded_default: 'Expand activity feed by default', - settings_desc_activity_feed_expanded_default: 'Open new Activity disclosures automatically so tool and model progress is visible without an extra click. Per-turn manual collapse/expand choices still win.', + settings_label_activity_feed_expanded_default: 'アクティビティフィードをデフォルトで展開', + settings_desc_activity_feed_expanded_default: '新しいアクティビティの詳細を自動的に展開し、ツールやモデルの進捗をクリックなしで確認できます。ターンごとの手動折りたたみ/展開は優先されます。', settings_label_tab_visibility: 'サイドバータブ', settings_desc_tab_visibility: 'サイドバーとレールに表示するタブを選択します。チャットと設定は常に表示されます。', @@ -3215,19 +3215,19 @@ const LOCALES = { settings_tab_appearance: '外観', settings_tab_preferences: '環境設定', settings_tab_plugins: 'プラグイン', - settings_plugins_title: 'Plugins', // TODO: translate + settings_plugins_title: 'プラグイン', plugins_enable_toggle: '有効化', - settings_plugins_meta: 'View installed Hermes plugins and the lifecycle hooks they register. This panel is read-only.', // TODO: translate - settings_plugins_empty: 'No Hermes plugins are currently visible. Install or enable plugins from the Hermes CLI/config to see them here.', // TODO: translate - plugins_unnamed: 'Unnamed plugin', // TODO: translate - plugins_no_description: 'No description provided.', // TODO: translate - plugins_no_hooks: 'No registered lifecycle hooks', // TODO: translate - plugins_registered_hooks: 'Registered hooks', // TODO: translate - plugins_enabled: 'Enabled', // TODO: translate - plugins_disabled: 'Disabled', // TODO: translate - plugins_active_provider: 'Active (provider)', // TODO: translate - plugins_provider_no_hooks: 'Provider plugin — no agent-visibility hooks', // TODO: translate - plugins_load_failed: 'Failed to load plugins: ', // TODO: translate + settings_plugins_meta: 'インストール済みのHermesプラグインと登録されているライフサイクルフックを確認できます。このパネルは読み取り専用です。', + settings_plugins_empty: '現在表示できるHermesプラグインがありません。Hermes CLI/configからプラグインをインストールまたは有効化すると、ここに表示されます。', + plugins_unnamed: '名前なしプラグイン', + plugins_no_description: '説明がありません。', + plugins_no_hooks: '登録済みのライフサイクルフックはありません', + plugins_registered_hooks: '登録済みフック', + plugins_enabled: '有効', + plugins_disabled: '無効', + plugins_active_provider: 'アクティブ(プロバイダー)', + plugins_provider_no_hooks: 'プロバイダープラグイン — エージェント可視フックなし', + plugins_load_failed: 'プラグインの読み込みに失敗しました: ', settings_tab_system: 'システム', settings_title: '設定', settings_save_btn: '設定を保存', @@ -3264,8 +3264,8 @@ const LOCALES = { settings_label_language: '言語', settings_label_quota_chip: 'コンポーザーにプロバイダーのクォータチップを表示', settings_desc_quota_chip: 'コンポーザーのフッターに残りクォータインジケーター(例: OpenRouter のクレジット残高)を表示します。デフォルトはオフ。有効にした場合、ラップトップや標準デスクトップの幅でコンポーザーが混雑しないよう、ワイドディスプレイ(≥1400px)でのみ表示されます。', - settings_label_hide_suggestions: 'Hide new-chat suggestions', - settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.', + settings_label_hide_suggestions: '新規チャットの候補を非表示', + settings_desc_hide_suggestions: '空の新規チャット画面に表示されるデフォルトの候補ボタン3つを非表示にして、誤タップを防ぎます。', settings_label_token_usage: 'トークン使用量を表示', settings_label_sidebar_density: 'サイドバー密度', cmd_reasoning: '思考表示の切り替え (表示/非表示)、努力レベル設定、現在状態の確認', @@ -3273,8 +3273,8 @@ const LOCALES = { settings_label_previous_messaging_sessions: '以前のメッセージングセッションを表示', settings_label_sync_insights: 'インサイトに同期', settings_label_check_updates: 'アップデートを確認', - settings_label_ignore_agent_updates: 'Ignore Agent updates', - settings_label_whats_new_summary: "Summarize What's New with AI", + settings_label_ignore_agent_updates: 'Agentのアップデートを無視', + settings_label_whats_new_summary: "What's New をAIで要約", settings_label_bot_name: 'デフォルトのアシスタント名', settings_label_password: 'アクセスパスワード', settings_saved: '設定を保存しました', @@ -3326,7 +3326,7 @@ const LOCALES = { kanban_status_running: '実行中', kanban_status_blocked: 'ブロック中', kanban_status_done: '完了', - kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.', + kanban_status_original_hint: '実際のステータス: {0}。このダイアログはトリアージ/ToDo/準備完了の編集のみ対応しています。', kanban_comments_count: 'コメント ({0})', kanban_events_count: 'イベント ({0})', kanban_links: 'リンク', @@ -3532,8 +3532,8 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'reset または compression によって置き換えられた以前の Discord、Telegram、Slack、Weixin セッションを表示します。', settings_desc_sync_insights: 'WebUI のトークン使用量を state.db にミラーし、hermes /insights にブラウザセッションのデータを含めます。デフォルトはオフ。', settings_desc_check_updates: 'WebUI または Agent の新しいバージョンが利用可能な時にバナーを表示します。バックグラウンドで定期的に git fetch を実行します。', - settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', - settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", + settings_desc_ignore_agent_updates: 'WebUIのアップデート確認は継続しますが、Agentのアップデート通知を非表示にし、Agentのアップデート取得をスキップします。', + settings_desc_whats_new_summary: "What's Newの動作を、未加工の差分を最初に開く代わりに短い人間が読みやすい要約を生成するよう変更します。通常の差分比較は要約の後も引き続き利用できます。", settings_desc_bot_name: 'デフォルトプロファイルでのみ使用されます。他のプロファイルはそれぞれのプロファイル名を使用します。', settings_desc_password: '新しいパスワードを入力すると設定または変更します。空欄なら現在の設定を維持。', password_placeholder: '新しいパスワードを入力…', @@ -3554,16 +3554,16 @@ const LOCALES = { providers_section_meta: 'AI プロバイダの APIキー を管理します。変更は即時反映されます。', providers_status_configured: 'APIキー設定済み', providers_status_not_configured: 'APIキーなし', - providers_status_oauth: 'OAuth', + providers_status_oauth: 'OAuth(認証済み)', providers_status_api_key: 'APIキー', providers_status_not_configured_label: '未設定', providers_oauth_hint: 'OAuth 認証済み。APIキーは不要です。', providers_oauth_config_yaml_hint: 'config.yaml でトークンが設定されています。更新するには config.yaml の providers セクションを編集するか hermes auth を実行してください。', providers_oauth_not_configured_hint: '未認証です。ターミナルで hermes auth を実行してこのプロバイダを設定してください。', providers_save: '保存', - providers_refresh_models: 'Refresh models', - providers_refreshing: 'Refreshing…', - providers_models_refreshed: 'Models refreshed', + providers_refresh_models: 'モデルを更新', + providers_refreshing: '更新中…', + providers_models_refreshed: 'モデルを更新しました', providers_remove: '削除', providers_saving: '保存中…', providers_removing: '削除中…', @@ -3672,7 +3672,7 @@ const LOCALES = { onboarding_notice_workspace: 'これらの値は通常アプリと同じ設定 API を使用します。', onboarding_workspace_label: 'ワークスペース', onboarding_workspace_or_path: 'またはワークスペースのパスを入力', - onboarding_workspace_placeholder: '/home/you/workspace', + onboarding_workspace_placeholder: '/home/ユーザー名/workspace', onboarding_provider_label: 'セットアップモード', onboarding_quick_setup_badge: 'クイックセットアップ', provider_category_easy_start: '簡単スタート', @@ -3689,7 +3689,7 @@ const LOCALES = { onboarding_base_url_help: 'OpenAI 互換ルーター、セルフホストサーバー、LiteLLM、Ollama、LM Studio、vLLM、または同様のエンドポイントで使用してください。', onboarding_model_label: 'デフォルトモデル', onboarding_workspace_help: 'セットアップ完了後の新規チャットで Hermes が使用するモデルを選択してください。', - onboarding_custom_model_placeholder: 'your-model-name', + onboarding_custom_model_placeholder: 'モデル名を入力', onboarding_custom_model_help: 'カスタムエンドポイントの場合、サーバーが期待する正確なモデル ID を入力してください。', onboarding_notice_password_enabled: 'パスワードは既に設定済みです。置き換える場合のみ新しいものを入力してください。', onboarding_notice_password_recommended: '任意ですが、localhost 以外で UI を公開する場合は推奨します。', @@ -3727,8 +3727,8 @@ const LOCALES = { // panel/runtime i18n error_prefix: 'エラー: ', - default: 'default', - search: 'Search', + default: 'デフォルト', + search: '検索', not_available: 'N/A', never: 'なし', add: '追加', @@ -3767,7 +3767,7 @@ const LOCALES = { cron_collapse_prompt: 'プロンプトを折りたたむ', cron_expand_output: '出力を展開', cron_collapse_output: '出力を折りたたむ', - cron_view_full_output: 'View full output', + cron_view_full_output: '全出力を表示', cron_all_runs: 'すべての実行', cron_hide_runs: '実行履歴を隠す', cron_no_runs_yet: '(まだ実行されていません)', @@ -3784,7 +3784,7 @@ const LOCALES = { cron_delete_confirm_title: 'Cronジョブを削除', cron_delete_confirm_message: 'この操作は取り消せません。', cron_job_deleted: 'ジョブを削除しました', - cron_completion_status: (name, status) => `Cron "${name}" ${status}`, + cron_completion_status: (name, status) => `Cron「${name}」${status}`, status_failed: '失敗', status_completed: '完了', todos_no_active: 'このセッションにアクティブなタスクリストがありません。', @@ -3825,12 +3825,12 @@ const LOCALES = { external_notes_tool_count: (count) => `${count} 個のノートツールが利用可能`, external_notes_configured_hint: 'この設定済みソースからツール名が想定されています。WebUI ランタイムが公開するとライブスキーマが表示されます。', external_notes_recent_ai: 'AI が最近使用', - external_notes_auto: 'auto', + external_notes_auto: '自動', external_notes_recent_ai_reason: '自動リコール', external_notes_search_placeholder: 'ノートを検索…', external_notes_search_empty: '設定済みのノートソースを検索すると、ここにプレビューが表示されます。', - source_active: 'active', - source_configured: 'configured', + source_active: 'アクティブ', + source_configured: '設定済み', no_notes_yet: 'まだノートはありません。', no_profile_yet: 'まだプロファイルはありません。', agent_soul: 'エージェントソウル', @@ -3854,7 +3854,7 @@ const LOCALES = { workspace_switch_prompt_title: 'ワークスペースを切り替え', workspace_switch_prompt_message: '追加してこの会話を切り替える絶対パスを入力してください。', workspace_switch_prompt_confirm: '切り替え', - workspace_switch_prompt_placeholder: '/Users/you/project', + workspace_switch_prompt_placeholder: '/Users/ユーザー名/project', workspace_not_added: 'ワークスペースは追加されませんでした', workspace_already_saved: 'ワークスペースは既に保存済み — 一覧から選択してください', workspace_busy_switch: 'エージェント実行中はワークスペースを切り替えできません', diff --git a/tests/test_cron_query_param_validation.py b/tests/test_cron_query_param_validation.py new file mode 100644 index 000000000..1dfa32d6e --- /dev/null +++ b/tests/test_cron_query_param_validation.py @@ -0,0 +1,152 @@ +"""Regression: malformed numeric query params on cron endpoints return a +well-formed response instead of crashing with an uncaught ValueError (500). + +``GET /api/crons/output?limit=`` and ``GET /api/crons/recent?since=`` +parsed their numeric params with a bare ``int()`` / ``float()``. A non-numeric +value raised ValueError, which propagated to the top-level request handler and +surfaced as a generic HTTP 500 — inconsistent with sibling handlers +(``_handle_cron_run_detail``, ``_handle_insights``, ``_handle_notes_search``) +that all guard the same pattern and fall back to a default. + +``_handle_cron_output`` additionally clamps ``limit`` to a positive range so a +negative value can never reach the ``files[:limit]`` slice (``files[:-1]`` would +silently drop the newest output rather than return it). +""" + +from __future__ import annotations + +import io +import json +import sys +import types +from types import SimpleNamespace + + +class _JSONHandler: + def __init__(self): + self.status = None + self.response_headers = [] + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.response_headers.append((key, value)) + + def end_headers(self): + pass + + +def _payload(handler): + return json.loads(handler.wfile.getvalue().decode("utf-8")) + + +def _stub_cron_jobs(monkeypatch, *, output_dir=None, jobs=None): + cron_pkg = types.ModuleType("cron") + cron_pkg.__path__ = [] + cron_jobs = types.ModuleType("cron.jobs") + if output_dir is not None: + cron_jobs.OUTPUT_DIR = output_dir + if jobs is not None: + cron_jobs.list_jobs = lambda include_disabled=True: jobs + monkeypatch.setitem(sys.modules, "cron", cron_pkg) + monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs) + return cron_jobs + + +def test_cron_output_non_numeric_limit_does_not_500(monkeypatch, tmp_path): + import api.routes as routes + + _stub_cron_jobs(monkeypatch, output_dir=tmp_path / "cron-out") + + handler = _JSONHandler() + # Before the fix this raised ValueError -> 500. + routes._handle_cron_output( + handler, SimpleNamespace(query="job_id=abc123&limit=notanint") + ) + + assert handler.status == 200 + body = _payload(handler) + assert body["job_id"] == "abc123" + assert body["outputs"] == [] + + +def test_cron_output_negative_limit_is_clamped(monkeypatch, tmp_path): + import api.routes as routes + + out_dir = tmp_path / "cron-out" / "job42" + out_dir.mkdir(parents=True) + # Three outputs with distinct mtimes; newest must always be returned. + for i in range(3): + f = out_dir / f"run-{i}.md" + f.write_text(f"## Response\noutput {i}\n", encoding="utf-8") + import os + os.utime(f, (1000 + i, 1000 + i)) + + _stub_cron_jobs(monkeypatch, output_dir=tmp_path / "cron-out") + + handler = _JSONHandler() + # limit=-3 with exactly 3 files: an unclamped files[:-3] slice yields [] + # (every output silently dropped). Clamped to >= 1 it returns the newest. + routes._handle_cron_output( + handler, SimpleNamespace(query="job_id=job42&limit=-3") + ) + + assert handler.status == 200 + body = _payload(handler) + assert len(body["outputs"]) >= 1 + assert body["outputs"][0]["filename"] == "run-2.md" + + +def test_cron_output_valid_limit_still_works(monkeypatch, tmp_path): + import api.routes as routes + + _stub_cron_jobs(monkeypatch, output_dir=tmp_path / "cron-out") + handler = _JSONHandler() + routes._handle_cron_output( + handler, SimpleNamespace(query="job_id=nonexistent&limit=20") + ) + assert handler.status == 200 + assert _payload(handler)["outputs"] == [] + + +def test_cron_recent_non_numeric_since_does_not_500(monkeypatch): + import api.routes as routes + + _stub_cron_jobs( + monkeypatch, + jobs=[ + {"id": "a", "name": "Job A", "last_run_at": 50, "last_status": "success"}, + ], + ) + + handler = _JSONHandler() + # Before the fix this raised ValueError -> 500. + routes._handle_cron_recent(handler, SimpleNamespace(query="since=notanum")) + + assert handler.status == 200 + body = _payload(handler) + assert body["since"] == 0.0 + # since defaults to the epoch, so the completed job is still reported. + assert {item["job_id"] for item in body["completions"]} == {"a"} + + +def test_cron_recent_valid_since_still_filters(monkeypatch): + import api.routes as routes + + _stub_cron_jobs( + monkeypatch, + jobs=[ + {"id": "old", "name": "Old", "last_run_at": 5, "last_status": "success"}, + {"id": "new", "name": "New", "last_run_at": 50, "last_status": "success"}, + ], + ) + + handler = _JSONHandler() + routes._handle_cron_recent(handler, SimpleNamespace(query="since=10")) + + assert handler.status == 200 + body = _payload(handler) + assert body["since"] == 10.0 + assert {item["job_id"] for item in body["completions"]} == {"new"}