From 1238524dc2805e372cb79217da4f931623653268 Mon Sep 17 00:00:00 2001 From: xuhaihui Date: Fri, 20 Mar 2026 15:31:03 +0800 Subject: [PATCH 1/2] feat: add feedback bundle export for diagnostics --- src/cccc/daemon/ops/diagnostics_ops.py | 331 ++++++++++++++---- src/cccc/ports/web/routes/base.py | 14 + src/cccc/ports/web/schemas.py | 4 + tests/test_diagnostics_ops.py | 69 ++++ web/src/components/SettingsModal.tsx | 39 +++ .../modals/settings/DeveloperTab.tsx | 39 +++ web/src/i18n/locales/en/settings.json | 5 + web/src/i18n/locales/ja/settings.json | 5 + web/src/i18n/locales/zh/settings.json | 5 + web/src/services/api.ts | 10 + 10 files changed, 462 insertions(+), 59 deletions(-) diff --git a/src/cccc/daemon/ops/diagnostics_ops.py b/src/cccc/daemon/ops/diagnostics_ops.py index d2d8ba10..4799d834 100644 --- a/src/cccc/daemon/ops/diagnostics_ops.py +++ b/src/cccc/daemon/ops/diagnostics_ops.py @@ -2,13 +2,19 @@ from __future__ import annotations +import io +import json import os +import re +import zipfile from pathlib import Path from typing import Any, Callable, Dict, Optional from ...contracts.v1 import DaemonError, DaemonResponse from ...kernel.actors import find_actor, get_effective_role, list_actors +from ...kernel.blobs import sanitize_filename, store_blob_bytes from ...kernel.group import load_group +from ...kernel.ledger import read_last_lines from ...kernel.settings import get_remote_access_settings, resolve_remote_access_web_binding from ...kernel.terminal_transcript import get_terminal_transcript_settings from ...paths import ensure_home @@ -29,6 +35,18 @@ from ... import __version__ +_ASSIGNMENT_RE = re.compile( + r"(?im)(\b[A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|ACCESS_KEY|PRIVATE_KEY|SESSION|COOKIE|AUTH)[A-Z0-9_]*\b\s*[:=]\s*)([^\s,;]+)" +) +_JSON_SECRET_RE = re.compile( + r'(?im)("?[A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|ACCESS_KEY|PRIVATE_KEY|SESSION|COOKIE|AUTH)[A-Z0-9_]*"?\s*:\s*)(".*?"|\'.*?\'|[^,\s}]+)' +) +_AUTH_BEARER_RE = re.compile(r"(?im)(authorization\s*:\s*bearer\s+)([^\s]+)") +_COOKIE_RE = re.compile(r"(?im)(cookie\s*:\s*)(.+)") +_OPENAI_KEY_RE = re.compile(r"\bsk-[A-Za-z0-9_-]{8,}\b") +_ANTHROPIC_KEY_RE = re.compile(r"\bsk-ant-[A-Za-z0-9_-]{8,}\b") + + def _error(code: str, message: str, *, details: Optional[Dict[str, Any]] = None) -> DaemonResponse: return DaemonResponse(ok=False, error=DaemonError(code=code, message=message, details=(details or {}))) @@ -58,6 +76,17 @@ def _web_exposure_class(host: str, public_url: str) -> str: return "local" if is_loopback_host(host) else "private" +def _authorize_debug_group_access(*, group_id: str, by: str) -> tuple[Any | None, DaemonResponse | None]: + group = load_group(group_id) if group_id else None + if group_id and group is None: + return None, _error("group_not_found", f"group not found: {group_id}") + if group is not None and by and by != "user": + role = get_effective_role(group, by) + if role != "foreman": + return None, _error("permission_denied", "debug tools are restricted to user + foreman") + return group, None + + def _build_web_debug_snapshot(*, home: Path) -> Dict[str, Any]: binding = resolve_remote_access_web_binding() remote_cfg = get_remote_access_settings() @@ -132,6 +161,110 @@ def _build_web_debug_snapshot(*, home: Path) -> Dict[str, Any]: } +def _build_debug_snapshot_result( + *, + home: Path, + group: Any | None, + get_observability: Callable[[], Dict[str, Any]], + effective_runner_kind: Callable[[str], str], + throttle_debug_summary: Callable[[str], Dict[str, Any]], +) -> Dict[str, Any]: + out: Dict[str, Any] = { + "developer_mode": True, + "observability": get_observability(), + "daemon": { + "pid": os.getpid(), + "version": __version__, + "ts": utc_now_iso(), + "log_path": str(home / "daemon" / "ccccd.log"), + }, + "web": _build_web_debug_snapshot(home=home), + } + if group is None: + return out + out["group"] = { + "group_id": group.group_id, + "state": str(group.doc.get("state") or "active"), + "active_scope_key": str(group.doc.get("active_scope_key") or ""), + "title": str(group.doc.get("title") or ""), + } + actors = [] + for actor in list_actors(group): + if not isinstance(actor, dict): + continue + aid = str(actor.get("id") or "").strip() + if not aid: + continue + runner_kind = str(actor.get("runner") or "pty") + runner_effective = effective_runner_kind(runner_kind) + running = False + try: + if runner_effective == "pty": + running = pty_runner.SUPERVISOR.actor_running(group.group_id, aid) + elif runner_effective == "headless": + running = headless_runner.SUPERVISOR.actor_running(group.group_id, aid) + except Exception: + running = False + actors.append( + { + "id": aid, + "role": get_effective_role(group, aid), + "runtime": str(actor.get("runtime") or ""), + "runner": runner_kind, + "runner_effective": (runner_effective if runner_effective != runner_kind else runner_kind), + "enabled": coerce_bool(actor.get("enabled"), default=True), + "running": bool(running), + "unread_count": int(actor.get("unread_count") or 0), + } + ) + out["actors"] = actors + try: + out["delivery"] = throttle_debug_summary(group.group_id) + except Exception: + out["delivery"] = {} + return out + + +def _redact_sensitive_text(text: str) -> str: + if not text: + return "" + out = str(text) + out = _AUTH_BEARER_RE.sub(r"\1[REDACTED]", out) + out = _COOKIE_RE.sub(r"\1[REDACTED]", out) + out = _ASSIGNMENT_RE.sub(r"\1[REDACTED]", out) + out = _JSON_SECRET_RE.sub(r'\1"[REDACTED]"', out) + out = _OPENAI_KEY_RE.sub("[REDACTED]", out) + out = _ANTHROPIC_KEY_RE.sub("[REDACTED]", out) + return out + + +def _collect_terminal_snapshot(*, group_id: str, actor_id: str, max_chars: int, compact: bool = True) -> str: + try: + raw = pty_runner.SUPERVISOR.tail_output(group_id=group_id, actor_id=actor_id, max_bytes=max_chars * 4) + except Exception: + return "" + raw_text = raw.decode("utf-8", errors="replace") + text = raw_text + try: + from ...util.terminal_render import render_transcript + + text = render_transcript(raw_text, compact=compact) + except Exception: + text = raw_text + if len(text) > max_chars: + text = text[-max_chars:] + return _redact_sensitive_text(text) + + +def _collect_log_tail(path: Path | None, *, lines: int) -> str: + if path is None or not path.exists() or not path.is_file(): + return "" + try: + return _redact_sensitive_text("\n".join(read_last_lines(path, int(lines)))) + except Exception: + return "" + + def handle_debug_snapshot( args: Dict[str, Any], *, @@ -144,68 +277,19 @@ def handle_debug_snapshot( return _error("developer_mode_required", "developer mode is disabled") group_id = str(args.get("group_id") or "").strip() by = str(args.get("by") or "user").strip() - group = load_group(group_id) if group_id else None - if group_id and group is None: - return _error("group_not_found", f"group not found: {group_id}") - if group is not None and by and by != "user": - role = get_effective_role(group, by) - if role != "foreman": - return _error("permission_denied", "debug tools are restricted to user + foreman") + group, auth_err = _authorize_debug_group_access(group_id=group_id, by=by) + if auth_err is not None: + return auth_err try: home = ensure_home() - out: Dict[str, Any] = { - "developer_mode": True, - "observability": get_observability(), - "daemon": { - "pid": os.getpid(), - "version": __version__, - "ts": utc_now_iso(), - "log_path": str(home / "daemon" / "ccccd.log"), - }, - "web": _build_web_debug_snapshot(home=home), - } - if group is not None: - out["group"] = { - "group_id": group.group_id, - "state": str(group.doc.get("state") or "active"), - "active_scope_key": str(group.doc.get("active_scope_key") or ""), - "title": str(group.doc.get("title") or ""), - } - actors = [] - for actor in list_actors(group): - if not isinstance(actor, dict): - continue - aid = str(actor.get("id") or "").strip() - if not aid: - continue - runner_kind = str(actor.get("runner") or "pty") - runner_effective = effective_runner_kind(runner_kind) - running = False - try: - if runner_effective == "pty": - running = pty_runner.SUPERVISOR.actor_running(group.group_id, aid) - elif runner_effective == "headless": - running = headless_runner.SUPERVISOR.actor_running(group.group_id, aid) - except Exception: - running = False - actors.append( - { - "id": aid, - "role": get_effective_role(group, aid), - "runtime": str(actor.get("runtime") or ""), - "runner": runner_kind, - "runner_effective": (runner_effective if runner_effective != runner_kind else runner_kind), - "enabled": coerce_bool(actor.get("enabled"), default=True), - "running": bool(running), - "unread_count": int(actor.get("unread_count") or 0), - } - ) - out["actors"] = actors - try: - out["delivery"] = throttle_debug_summary(group.group_id) - except Exception: - out["delivery"] = {} + out = _build_debug_snapshot_result( + home=home, + group=group, + get_observability=get_observability, + effective_runner_kind=effective_runner_kind, + throttle_debug_summary=throttle_debug_summary, + ) return DaemonResponse(ok=True, result=out) except Exception as e: return _error("debug_snapshot_failed", str(e)) @@ -408,6 +492,126 @@ def handle_debug_clear_logs(args: Dict[str, Any], *, developer_mode_enabled: Cal return _error("debug_clear_logs_failed", str(e)) +def handle_feedback_bundle_export( + args: Dict[str, Any], + *, + developer_mode_enabled: Callable[[], bool], + get_observability: Callable[[], Dict[str, Any]], + effective_runner_kind: Callable[[str], str], + throttle_debug_summary: Callable[[str], Dict[str, Any]], + pty_backlog_bytes: Callable[[], int], +) -> DaemonResponse: + if not developer_mode_enabled(): + return _error("developer_mode_required", "developer mode is disabled") + group_id = str(args.get("group_id") or "").strip() + by = str(args.get("by") or "user").strip() + if not group_id: + return _error("missing_group_id", "missing group_id") + group, auth_err = _authorize_debug_group_access(group_id=group_id, by=by) + if auth_err is not None: + return auth_err + if group is None: + return _error("group_not_found", f"group not found: {group_id}") + + log_lines = int(args.get("log_lines") or 400) + if log_lines <= 0: + log_lines = 400 + if log_lines > 4000: + log_lines = 4000 + terminal_max_chars = int(args.get("terminal_max_chars") or 12000) + if terminal_max_chars <= 0: + terminal_max_chars = 12000 + if terminal_max_chars > 200_000: + terminal_max_chars = 200_000 + + try: + home = ensure_home() + snapshot = _build_debug_snapshot_result( + home=home, + group=group, + get_observability=get_observability, + effective_runner_kind=effective_runner_kind, + throttle_debug_summary=throttle_debug_summary, + ) + + daemon_log_path, _ = _debug_component_path(component="daemon", group_id=group_id) + web_log_path, _ = _debug_component_path(component="web", group_id=group_id) + im_log_path, _ = _debug_component_path(component="im", group_id=group_id) + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w", compression=zipfile.ZIP_DEFLATED) as zf: + manifest = { + "kind": "feedback_bundle", + "generated_at": utc_now_iso(), + "group_id": group_id, + "group_title": str(group.doc.get("title") or ""), + "by": by, + "log_lines": log_lines, + "terminal_max_chars": terminal_max_chars, + "redaction": "best_effort", + } + zf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2)) + zf.writestr("snapshot/debug_snapshot.json", json.dumps(snapshot, ensure_ascii=False, indent=2)) + + daemon_log = _collect_log_tail(daemon_log_path, lines=log_lines) + if daemon_log: + zf.writestr("logs/daemon.log", daemon_log) + web_log = _collect_log_tail(web_log_path, lines=log_lines) + if web_log: + zf.writestr("logs/web.log", web_log) + im_log = _collect_log_tail(im_log_path, lines=log_lines) + if im_log: + zf.writestr("logs/im.log", im_log) + + terminal_capture_bytes = max(terminal_max_chars * 4, int(pty_backlog_bytes())) + for actor in list_actors(group): + if not isinstance(actor, dict): + continue + actor_id = str(actor.get("id") or "").strip() + if not actor_id or str(actor.get("runner") or "pty").strip() != "pty": + continue + try: + running = bool(pty_runner.SUPERVISOR.actor_running(group_id, actor_id)) + except Exception: + running = False + if not running: + continue + transcript = _collect_terminal_snapshot( + group_id=group_id, + actor_id=actor_id, + max_chars=min(terminal_max_chars, terminal_capture_bytes), + ) + if not transcript: + continue + safe_actor_id = sanitize_filename(actor_id, fallback="actor") + zf.writestr(f"terminals/{safe_actor_id}.txt", transcript) + + filename = sanitize_filename( + f"cccc-feedback-bundle-{group_id}-{utc_now_iso().replace(':', '').replace('-', '')}.zip", + fallback=f"cccc-feedback-bundle-{group_id}.zip", + ) + attachment = store_blob_bytes( + group, + data=zip_buffer.getvalue(), + filename=filename, + mime_type="application/zip", + kind="file", + ) + return DaemonResponse( + ok=True, + result={ + "group_id": group_id, + "attachment": attachment, + "bundle": { + "filename": filename, + "redaction": "best_effort", + }, + }, + ) + except Exception as e: + return _error("feedback_bundle_export_failed", str(e)) + + def try_handle_diagnostics_op( op: str, args: Dict[str, Any], @@ -438,6 +642,15 @@ def try_handle_diagnostics_op( args, can_read_terminal_transcript=can_read_terminal_transcript, ) + if op == "feedback_bundle_export": + return handle_feedback_bundle_export( + args, + developer_mode_enabled=developer_mode_enabled, + get_observability=get_observability, + effective_runner_kind=effective_runner_kind, + throttle_debug_summary=throttle_debug_summary, + pty_backlog_bytes=pty_backlog_bytes, + ) if op == "debug_tail_logs": return handle_debug_tail_logs(args, developer_mode_enabled=developer_mode_enabled) if op == "debug_clear_logs": diff --git a/src/cccc/ports/web/routes/base.py b/src/cccc/ports/web/routes/base.py index a8eb796a..dfc6d741 100644 --- a/src/cccc/ports/web/routes/base.py +++ b/src/cccc/ports/web/routes/base.py @@ -10,6 +10,7 @@ from ....kernel.scope import detect_scope from ..schemas import ( DebugClearLogsRequest, + FeedbackBundleExportRequest, ObservabilityUpdateRequest, RegistryReconcileRequest, RemoteAccessConfigureRequest, @@ -579,6 +580,19 @@ async def terminal_clear(group_id: str, actor_id: str) -> Dict[str, Any]: } ) + @group_router.post("/feedback_bundle/export") + async def feedback_bundle_export(group_id: str, req: FeedbackBundleExportRequest) -> Dict[str, Any]: + """Generate a redacted feedback bundle zip for this group and store it under blobs.""" + return await ctx.daemon( + { + "op": "feedback_bundle_export", + "args": { + "group_id": group_id, + "by": str(req.by or "user"), + }, + } + ) + @group_router.get("/capabilities/state") async def capability_state(group_id: str, actor_id: str = "user") -> Dict[str, Any]: """Get caller-effective capability state and visible/dynamic tools for a group.""" diff --git a/src/cccc/ports/web/schemas.py b/src/cccc/ports/web/schemas.py index c930b23c..00cba9c9 100644 --- a/src/cccc/ports/web/schemas.py +++ b/src/cccc/ports/web/schemas.py @@ -67,6 +67,10 @@ class DebugClearLogsRequest(BaseModel): by: str = Field(default="user") +class FeedbackBundleExportRequest(BaseModel): + by: str = Field(default="user") + + class GroupTemplatePreviewRequest(BaseModel): template: str = Field(default="") by: str = Field(default="user") diff --git a/tests/test_diagnostics_ops.py b/tests/test_diagnostics_ops.py index 40dbce2b..34c5f337 100644 --- a/tests/test_diagnostics_ops.py +++ b/tests/test_diagnostics_ops.py @@ -1,7 +1,9 @@ import os import tempfile import unittest +import zipfile from pathlib import Path +from unittest.mock import patch class TestDiagnosticsOps(unittest.TestCase): @@ -26,6 +28,13 @@ def _call(self, op: str, args: dict): return handle_request(DaemonRequest.model_validate({"op": op, "args": args})) + def _create_group(self, title: str = "diagnostics-test") -> str: + resp, _ = self._call("group_create", {"title": title, "topic": "", "by": "user"}) + self.assertTrue(resp.ok, getattr(resp, "error", None)) + gid = str((resp.result or {}).get("group_id") or "") + self.assertTrue(gid) + return gid + def test_debug_ops_require_developer_mode(self) -> None: _, cleanup = self._with_home() try: @@ -155,6 +164,66 @@ def test_debug_snapshot_marks_stale_web_runtime_pid(self) -> None: finally: cleanup() + def test_feedback_bundle_export_redacts_logs_and_transcripts(self) -> None: + td, cleanup = self._with_home() + try: + update, _ = self._call("observability_update", {"by": "user", "patch": {"developer_mode": True}}) + self.assertTrue(update.ok, getattr(update, "error", None)) + + gid = self._create_group("feedback-bundle") + add_actor, _ = self._call( + "actor_add", + { + "group_id": gid, + "actor_id": "peer1", + "runtime": "codex", + "runner": "pty", + "by": "user", + }, + ) + self.assertTrue(add_actor.ok, getattr(add_actor, "error", None)) + + daemon_log = Path(td) / "daemon" / "ccccd.log" + daemon_log.parent.mkdir(parents=True, exist_ok=True) + daemon_log.write_text( + "Authorization: Bearer sk-live-secret-token\nOPENAI_API_KEY=sk-abcdefghi123456\n", + encoding="utf-8", + ) + + with patch("cccc.runners.pty.SUPERVISOR.actor_running", return_value=True), patch( + "cccc.runners.pty.SUPERVISOR.tail_output", + return_value=b'export OPENAI_API_KEY="sk-terminal-secret"\nCookie: sessionid=abc123\n', + ): + resp, _ = self._call("feedback_bundle_export", {"group_id": gid, "by": "user"}) + + self.assertTrue(resp.ok, getattr(resp, "error", None)) + result = resp.result if isinstance(resp.result, dict) else {} + attachment = result.get("attachment") if isinstance(result.get("attachment"), dict) else {} + rel_path = str(attachment.get("path") or "") + self.assertTrue(rel_path.startswith("state/blobs/")) + + from cccc.kernel.group import load_group + + group = load_group(gid) + self.assertIsNotNone(group) + bundle_path = Path(str(group.path)) / rel_path + self.assertTrue(bundle_path.exists()) + + with zipfile.ZipFile(bundle_path) as zf: + daemon_text = zf.read("logs/daemon.log").decode("utf-8") + terminal_text = zf.read("terminals/peer1.txt").decode("utf-8") + manifest = zf.read("manifest.json").decode("utf-8") + + self.assertIn("[REDACTED]", daemon_text) + self.assertNotIn("sk-live-secret-token", daemon_text) + self.assertNotIn("sk-abcdefghi123456", daemon_text) + self.assertIn("[REDACTED]", terminal_text) + self.assertNotIn("sk-terminal-secret", terminal_text) + self.assertNotIn("sessionid=abc123", terminal_text) + self.assertIn('"kind": "feedback_bundle"', manifest) + finally: + cleanup() + if __name__ == "__main__": unittest.main() diff --git a/web/src/components/SettingsModal.tsx b/web/src/components/SettingsModal.tsx index feaa1d71..1506862a 100644 --- a/web/src/components/SettingsModal.tsx +++ b/web/src/components/SettingsModal.tsx @@ -140,6 +140,9 @@ export function SettingsModal({ const [registryBusy, setRegistryBusy] = useState(false); const [registryErr, setRegistryErr] = useState(""); const [registryResult, setRegistryResult] = useState(null); + const [feedbackBundleBusy, setFeedbackBundleBusy] = useState(false); + const [feedbackBundleErr, setFeedbackBundleErr] = useState(""); + const [feedbackBundleInfo, setFeedbackBundleInfo] = useState(""); // ============ Effects ============ @@ -749,6 +752,38 @@ export function SettingsModal({ } }; + const handleExportFeedbackBundle = async () => { + if (!groupId) return; + setFeedbackBundleBusy(true); + setFeedbackBundleErr(""); + setFeedbackBundleInfo(""); + try { + const resp = await api.exportFeedbackBundle(groupId); + if (!resp.ok) { + setFeedbackBundleErr(resp.error?.message || t("developer.feedbackBundleFailed")); + return; + } + const path = String(resp.result?.attachment?.path || "").trim(); + const blobName = path.split("/").pop() || ""; + if (!path.startsWith("state/blobs/") || !blobName) { + setFeedbackBundleErr(t("developer.feedbackBundleFailed")); + return; + } + const a = document.createElement("a"); + a.href = `/api/v1/groups/${encodeURIComponent(groupId)}/blobs/${encodeURIComponent(blobName)}`; + a.download = String(resp.result?.attachment?.title || blobName); + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setFeedbackBundleInfo(t("developer.feedbackBundleDownloaded")); + window.setTimeout(() => setFeedbackBundleInfo(""), 1500); + } catch { + setFeedbackBundleErr(t("developer.feedbackBundleFailed")); + } finally { + setFeedbackBundleBusy(false); + } + }; + // ============ Derived state (must be before early return to keep hooks stable) ============ const globalSettingsEnabled = canAccessGlobalSettings === true; @@ -1054,6 +1089,10 @@ export function SettingsModal({ registryResult={registryResult} onPreviewRegistry={loadRegistryPreview} onReconcileRegistry={handleReconcileRegistry} + feedbackBundleBusy={feedbackBundleBusy} + feedbackBundleErr={feedbackBundleErr} + feedbackBundleInfo={feedbackBundleInfo} + onExportFeedbackBundle={handleExportFeedbackBundle} /> )} diff --git a/web/src/components/modals/settings/DeveloperTab.tsx b/web/src/components/modals/settings/DeveloperTab.tsx index 78d85d31..ea81e52d 100644 --- a/web/src/components/modals/settings/DeveloperTab.tsx +++ b/web/src/components/modals/settings/DeveloperTab.tsx @@ -47,6 +47,10 @@ interface DeveloperTabProps { } | null; onPreviewRegistry: () => void; onReconcileRegistry: () => void; + feedbackBundleBusy: boolean; + feedbackBundleErr: string; + feedbackBundleInfo: string; + onExportFeedbackBundle: () => void; } export function DeveloperTab({ @@ -84,6 +88,10 @@ export function DeveloperTab({ registryResult, onPreviewRegistry, onReconcileRegistry, + feedbackBundleBusy, + feedbackBundleErr, + feedbackBundleInfo, + onExportFeedbackBundle, }: DeveloperTabProps) { const { t } = useTranslation("settings"); const missing = Array.isArray(registryResult?.missing_group_ids) ? registryResult!.missing_group_ids : []; @@ -242,6 +250,37 @@ export function DeveloperTab({ +
+
+
+
{t("developer.feedbackBundleTitle")}
+
+ {t("developer.feedbackBundleHint")} +
+
+ +
+ + {!groupId && ( +
+ {t("developer.openFromGroup")} +
+ )} + + {feedbackBundleErr ? ( +
{feedbackBundleErr}
+ ) : null} + {feedbackBundleInfo ? ( +
{feedbackBundleInfo}
+ ) : null} +
+ {/* Registry maintenance */}
diff --git a/web/src/i18n/locales/en/settings.json b/web/src/i18n/locales/en/settings.json index 301e12f6..3b5e95a9 100644 --- a/web/src/i18n/locales/en/settings.json +++ b/web/src/i18n/locales/en/settings.json @@ -665,6 +665,11 @@ "webScrollback": "Web scrollback (lines)", "webScrollbackHint": "Controls how far you can scroll up in the Web terminal.", "saveDeveloperSettings": "Save Developer Settings", + "feedbackBundleTitle": "Feedback bundle", + "feedbackBundleHint": "Packages a redacted debug snapshot, recent logs, and live PTY transcripts for this group into a zip download.", + "feedbackBundleAction": "Generate & Download", + "feedbackBundleDownloaded": "Feedback bundle downloaded.", + "feedbackBundleFailed": "Failed to export feedback bundle", "registryTitle": "Registry maintenance", "registryDescription": "Scan missing/corrupt groups in registry. Cleanup only removes missing entries.", "scan": "Scan", diff --git a/web/src/i18n/locales/ja/settings.json b/web/src/i18n/locales/ja/settings.json index 5039f16d..6fd8d187 100644 --- a/web/src/i18n/locales/ja/settings.json +++ b/web/src/i18n/locales/ja/settings.json @@ -665,6 +665,11 @@ "webScrollback": "Web スクロールバック (行)", "webScrollbackHint": "Web ターミナルでどこまで上にスクロールできるかを制御します。", "saveDeveloperSettings": "開発者設定の保存", + "feedbackBundleTitle": "フィードバックバンドル", + "feedbackBundleHint": "このグループの秘匿情報をマスクしたデバッグスナップショット、最新ログ、PTY 端末抜粋を zip としてまとめてダウンロードします。", + "feedbackBundleAction": "生成してダウンロード", + "feedbackBundleDownloaded": "フィードバックバンドルをダウンロードしました。", + "feedbackBundleFailed": "フィードバックバンドルのエクスポートに失敗しました", "registryTitle": "レジストリのメンテナンス", "registryDescription": "レジストリ内の欠落または破損したグループをスキャンします。クリーンアップでは、欠落しているエントリのみが削除されます。", "scan": "スキャン", diff --git a/web/src/i18n/locales/zh/settings.json b/web/src/i18n/locales/zh/settings.json index 7edcac99..c3f2f94e 100644 --- a/web/src/i18n/locales/zh/settings.json +++ b/web/src/i18n/locales/zh/settings.json @@ -665,6 +665,11 @@ "webScrollback": "Web 回滚(行数)", "webScrollbackHint": "控制在 Web 终端中可以向上滚动多远。", "saveDeveloperSettings": "保存开发者设置", + "feedbackBundleTitle": "反馈包", + "feedbackBundleHint": "将当前工作组的脱敏调试快照、最近日志和 PTY 终端摘录打包成 zip 下载。", + "feedbackBundleAction": "生成并下载", + "feedbackBundleDownloaded": "反馈包已下载。", + "feedbackBundleFailed": "导出反馈包失败", "registryTitle": "注册表维护", "registryDescription": "扫描注册表中缺失/损坏的工作组。清理仅移除缺失条目。", "scan": "扫描", diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 949dfaec..b834ece2 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -2371,3 +2371,13 @@ export async function clearLogs(component: "daemon" | "web" | "im", groupId: str body: JSON.stringify({ component, group_id: groupId, by: "user" }), }); } + +export async function exportFeedbackBundle(groupId: string) { + return apiJson<{ attachment?: { path?: string; title?: string; mime_type?: string; bytes?: number } }>( + `/api/v1/groups/${encodeURIComponent(groupId)}/feedback_bundle/export`, + { + method: "POST", + body: JSON.stringify({ by: "user" }), + } + ); +} From 844680cf3e4f2c11003191b7c6285f4dad6d6164 Mon Sep 17 00:00:00 2001 From: xuhaihui Date: Fri, 20 Mar 2026 15:38:14 +0800 Subject: [PATCH 2/2] docs: document feedback bundle export IPC op --- docs/standards/CCCC_DAEMON_IPC_V1.md | 32 ++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/standards/CCCC_DAEMON_IPC_V1.md b/docs/standards/CCCC_DAEMON_IPC_V1.md index 54819508..74044781 100644 --- a/docs/standards/CCCC_DAEMON_IPC_V1.md +++ b/docs/standards/CCCC_DAEMON_IPC_V1.md @@ -327,6 +327,38 @@ Notes: - Requires developer mode. - Permission is `user`, or `foreman` when `group_id` is provided. +#### `feedback_bundle_export` + +Developer-mode export of a redacted diagnostics bundle for a group. + +Args: +```ts +{ group_id: string; by?: string } +``` + +Result: +```ts +{ + group_id: string + attachment: { + path: string + title?: string + mime_type?: string + bytes?: number + } + bundle: { + filename: string + redaction: "best_effort" + } +} +``` + +Notes: +- Requires developer mode. +- Requires `group_id`. +- Permission is `user`, or `foreman` for group-scoped access. +- The exported zip may include `manifest.json`, `snapshot/debug_snapshot.json`, recent logs, and PTY terminal captures with best-effort redaction applied. + ### 8.3 Groups and Scopes #### `attach`