feat: add local debug capability with session recording, evidence bundles, and agent-friendly CLI/MCP tools - #281
Conversation
Foundation for the local debug evidence system. DebugBaseEvent / DebugIds / DebugDomain / DebugArtifact types cover every runtime domain listed in docs/debug-capability-plan.md without forcing narrow per-event shapes. redaction enforces the privacy contract: key-based redaction (case-insensitive, recursive), URL query stripping, 50KB text truncation, JS code hash+preview (no full code unless includeSensitivePayloads), circular/BigInt/symbol/function safe, async SHA-256 for artifact content with FNV fallback. RingBuffer is the bounded in-memory event store used by the recorder so a render loop or CDP firehose cannot OOM the extension. Tests cover key redaction, URL redaction, truncation, circular refs, depth limits, FIFO eviction, tail-after-wrap, single-capacity edge. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DebugStore abstracts persistence with two impls: IndexedDbDebugStore (production, shared across SW + sidepanel, survives SW restart) and InMemoryDebugStore (tests / fallback). Events are keyPath'd by eventId with by_domain / by_session indexes; artifact metadata and large content live in separate object stores so listing metadata stays cheap. session.ts provides per-context runtimeSessionId (lazy, survives until the JS context is torn down) and debug session id generation. recorder.ts is the single entry point domains will call: - recordEvent / recordError / recordArtifact / withDebugSpan - start/stop/getStatus + getEvents/getArtifacts/getArtifactContent for export - disabled state is a single boolean check, no allocation - ring buffer is synchronous, store writes are fire-and-forget with swallowed rejections so debug can never break the business path - data payloads pass through redaction; artifacts get SHA-256 + 20MB size cap with truncation marker Tests cover no-op disabled, redaction on record, circular/BigInt safety, store-failure isolation, artifact sha256 + truncation, sync/async span error propagation, monotonic time, artifactRef linking. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… index Closes stage 1 of the debug capability plan. runtimeMap builds entity relationships (sidepanels, agentRuns, lightning iterations, toolUses, tabs, nativeRequests, workflowRecordings, artifacts) from the event stream so the agent can see cross-domain correlation without re-scanning JSONL. diagnostics implements all 13 rules from the plan: native_tool_timeout, crx_tool_start_no_executor, debugger_attach_failed, sidepanel_render_spike, click_dom_identity_changed, stale_ref_after_navigation, annotated_screenshot_no_refs, js_runtime_exception, js_child_tab_adoption, workflow_event_dropped, workflow_screenshot_failed, permission_prompt_missing_handler, tool_success_but_page_unchanged. Each finding carries evidence event ids, likelyCause and nextFiles pointing at the source files to inspect. Rules are isolated — a broken rule never breaks diagnosis. exportBundle assembles eventsByDomain + artifacts + runtimeMap + diagnosis + summary.agent.md + 00-readme.md. summary.agent.md has Session, Top Findings, Runtime Map, Errors, Slow Operations, Suggested Source Files — enough for an agent to pick the next file without re-instrumenting. recorder.exportDebugBundle works on the active session or the most recent stopped session in the store. index.ts re-exports the public surface. 75 tests cover redaction, ring buffer, recorder lifecycle, all 13 diagnosis rules, bundle assembly and export from a stopped session. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
executeToolInner now records the full tool-runtime event sequence so a failed tool call can be bisected: tool.request.received → tool.tab.resolve.start/end → tool.debugger.attach.start/end → tool.executor.start → tool.execute.end → tool.response.sent, plus tool.error on navigation block. Each event carries the correlation ids (requestId, nativeRequestId, toolUseId, tabId, tabGroupId) and structural input fields (action, filter, depth, limit, diff, full, ref/coordinate presence) — never the raw args, so apiKey / token never reach the recorder. URLs are redacted to origin+path at the call site via redactUrl. debug-disabled path is untouched (recordEvent is a boolean-check no-op); error events use recordError so stack/name flow through without a manual payload. Tests mock the external deps (cdpDebugger, tabGroupManager, toolExecutor, providerClient) and assert the event sequence on success, tab-resolve failure, debugger-attach failure, executor throw, mcp-tool skip-tab-lookup, and the disabled no-op. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… events cdp/debugger.ts records cdp.attach.start/end around attachDebugger (so a failed attach is visible at the CDP layer, not just the tool-runtime wrapper), cdp.detach for both explicit detach and Chrome-initiated detach (carrying the reason — canceled_by_user maps to STOP_AGENT), cdp.command.error on sendCommand failure with method + willRetry, and cdp.tab_lock.wait/release when concurrent commands contend on the same tab. cdp/eventHandlers.ts records cdp.exception (full — exceptions are low-frequency and high-signal, with redacted sourceUrl), cdp.window_open (redacted targetUrl) and samples cdp.console.message / cdp.network.request at 1:50 so a runaway page cannot flood the bundle. CDP params are never recorded — only method, duration, and structural fields. Tests cover attach success/failure, explicit + chrome detach, command error, exception with redacted url, window_open with redacted url, and the disabled no-op. Tests use dynamic imports + resetModules so the cdp singleton re-registers listeners per test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…js-result artifact javascriptTool.execute is refactored into a wrapper that records javascript.exec.start (codeHash + 200-char preview, never full code unless includeSensitivePayloads) and javascript.exec.end (resultType + duration), delegating to executeJavascript which records the inner sequence: permission.check, security.check, runtime.evaluate.start/end (with duration + result type/subtype), runtime.exception (exceptionSummary + redacted sourceUrl + line/column), output.truncated, window_open.detected (redacted targetUrls + adoptedTabIds so diagnosis rule 9 can fire), child_tab.adopted, and search_navigation.moved. A js-result artifact is recorded on both success and exception paths, carrying outputLength, outputTruncated, openedTabIds, exceptionSummary and redacted urlOrigin — so an agent can see the exception details instead of just "Failed to execute JavaScript". Also fixes a wrong relative import path in cdp.debug.test.ts (../../debug/store → ../debug/store) that tsc flagged once the test file was re-examined. Tests cover the success event sequence + artifact, exception with redacted sourceUrl, window_open.detected with redacted url + empty adoption, and the disabled no-op. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
computerTool.execute records input.action.start (action, refSource ref/ coordinate/none, refId, hasCoordinate, redacted beforeUrl) at entry and input.action.end (success, durationMs, beforeAfterUrlSame, pageChanged, redacted before/after url) at exit — so diagnosis rule 13 (tool_success_but_page_unchanged) and rule 5 (click_dom_identity_changed) have the before/after signals they need. Errors record input.action.end with the error payload. refBridge records ref.clear, ref.prune (prunedCount), ref.register.start/end (refCount + interactiveCount) and ref.resolve_stale.start/end (refId, success, reason: no_meta / cursor_interactive) — so stale ref recovery is visible in the bundle and diagnosis rule 6 can fire. annotatedScreenshot.captureAnnotatedScreenshot records screenshot.annotate.start + screenshot.annotate.end with annotationCount, refMetaEmpty and contentQuadsAllFailed so diagnosis rule 7 (annotated_screenshot_no_refs) can distinguish "no refs registered" from "getContentQuads failed for every ref". Tests cover ref register/clear/prune/resolve, annotate refMetaEmpty / contentQuadsAllFailed / success, input screenshot success/error, refSource detection, and the disabled no-op. Full suite (582 tests) still green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
toolExecutor.handleToolCall records tool.execute.start/end around the actual tool.execute() call (single-tool granularity, distinct from the executor-level tool.executor.start in toolExecution.ts), tool.input.validation_failed when schema validation rejects input (with the error list), and tool.permission.required with handlerExists so diagnosis rule 12 (permission_prompt_missing_handler) can fire when a tool needs a prompt but no handler is registered. axSnapshot.takeSnapshot records ax.snapshot.start/end with refCount + interactiveRefCount, so read_page / find traces show how many refs were captured. Errors record ax.snapshot.end with the error payload. Tests cover tool.execute.start/end on success, validation_failed (with execute.start suppressed), permission.required with and without a handler, and the disabled no-op. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
sidepanel: useSidepanelDebug hook records sidepanel.mount/unmount with a per-mount sidepanelInstanceId, plus a 1s-sampled render counter that emits sidepanel.render.spike (warn) when render rate crosses 30/s — the signal diagnosis rule 4 consumes. wrapSetWithDebug wraps a Zustand set to emit sidepanel.store.set_state (store name + changed keys). SidepanelApp mounts the hook. agent-loop: useAgentLoop.sendPrompt records agent.run.start (agentRunId, tabId, model, messageCount, attachmentCount) and agent.run.end (durationMs) in the finally block, so a cancelled or errored run still closes the span. lightning: useLightningMode records lightning.iteration.start per iteration (lightningIterationId, iterationCount, model, tabId) so lightning traces correlate to computer/javascript domain events via the runtime map. workflow: useWorkflowRecording records workflow.start (workflowRecordingId), workflow.stop, and workflow.pause/resume so diagnosis rules 10/11 (workflow_event_dropped / workflow_screenshot_failed) have the recording lifecycle as context. Tests cover wrapSetWithDebug (changed keys, function updater, disabled no-op). Full suite (594 tests) green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CRX side: nativeHost.handleToolRequest now intercepts four debug control tools — superduck_debug_start / _stop / _status / _collect — bypassing executeTool so the debug path doesn't recursively record itself or hit the tool-runtime timeout. collect calls exportDebugBundle + serializeBundleForTransport, which JSON-stringifies the bundle and, if it exceeds the 900KB native-messaging budget, truncates events to the most recent 200 per domain with a marker in the summary. Go side: new internal/debugbundle package (types.go mirrors the CRX Bundle; collect.go writes the plan's bundle layout — 00-readme.md, summary.agent.md, diagnosis.json, runtime-map.json, events/<domain>.jsonl, artifacts/metadata.json; redact.go strips URL query as a second line of defense). cmd_debug.go wires superduck debug start|stop|status|collect|doctor, with doctor --json emitting a structured report (native_manifest, native_host_uds, debug_status checks). Go tests cover ParseBundleJSON, WriteBundle file layout + events.jsonl content, empty-events, and RedactURL. CRX nativeHost tests still green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The MCP server now exposes three debug evidence tools so an agent can self-diagnose without leaving the conversation: - superduck_debug_status: current recording state (enabled, session, counts) - superduck_debug_collect: export the full evidence bundle JSON (summary, diagnosis, runtime map, events by domain, artifact metadata) - superduck_debug_snapshot: alias for status All three route through createToolHandler → nativeHost.ExecuteTool → CRX nativeHost.handleToolRequest, which intercepts the superduck_debug_* tool names added in the previous commit. No new logic on the Go side — the CRX debug recorder + serializeBundleForTransport do the work. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
testBridge exposes the recorder to Playwright via globalThis.__superduckDebugBridge so e2e specs can drive startDebugSession / recordEvent / exportDebugBundle from the service worker without a native-host round-trip. Production code never reads it; it's a side-effect import in index.ts. Cross-context sync: startDebugSession now writes DEBUG_EVIDENCE_ENABLED + DEBUG_SESSION_META to chrome.storage.local; stopDebugSession clears the flag. The recorder module, on load, reads these and — if another context (the service worker) started a session — enables recording in this context too, reusing the shared debugSessionId but its own runtimeSessionId. This is what lets sidepanel/agent/lightning/workflow events (which run in the sidepanel context) reach the shared IndexedDB store that `superduck debug collect` reads from the service worker. chrome.storage.onChanged disables recording when stop fires. e2e (debug-collect.spec.ts): 4 tests, all green — - collect exports a structured bundle (session, eventsByDomain, diagnosis with debugger_attach_failed finding, summary.agent.md, runtime-map, readme) and the persisted cdp.attach.end event has its URL query redacted; - debug disabled is a no-op (collect returns null); - js runtime exception → js_runtime_exception finding; - native.tool_request.forwarded without CRX tool.request.received → native_tool_timeout_no_crx_start finding. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…coverage) nativeHost.handleToolRequest now records native.tool_request.received (native-bridge domain, with nativeRequestId) so a bundle can show that a tool request reached the CRX service worker from the native messaging port — the signal diagnosis rule 1 (native_tool_timeout_no_crx_start) cross-references against tool.request.received in the tool-runtime domain. tabGroups.reconcileWithChrome records tab.group.reconcile.start/end (tab-state domain), so MCP tab group reconciliation failures are visible in the bundle. With these, every domain in docs/debug-capability-plan.md has at least one recorded event: sidepanel, agent-loop, lightning, tool-runtime, permission, tab-state, cdp, input, screenshot-ref, javascript, workflow-recording, native-bridge, cli, mcp-server, diagnosis. Full CRX suite (594 tests) + Go suite green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…isable) Previously debug recording only started after an explicit `superduck debug start`, so a crash or bug that happened before the user ran the command was lost. Persistent mode fixes that: configure once, and every extension (service worker) startup auto-starts a fresh debug session. - `superduck debug enable` writes DEBUG_EVIDENCE_PERSISTENT=true to chrome.storage.local and starts a session immediately. - `superduck debug disable` clears the flag and stops the session. - recorder.tryInitFromStorage now reads DEBUG_EVIDENCE_PERSISTENT on module load; if true and no session is active, it auto-starts one. This runs in every context (SW + sidepanel), so the sidepanel also joins the auto-started session via the existing cross-context attach path. CRX handleToolRequest intercepts superduck_debug_enable / _disable alongside the existing start/stop/status/collect tools. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously the bundle only carried artifact metadata (sha256, byteLength) — the actual things the agent saw (screenshot pixels, read_page AX text, JS output) were missing. That defeated the goal of "debug must capture what the agent touched." - computerTool screenshot action records a screenshot artifact with the base64 PNG as content. - axSnapshot.takeSnapshot records an ax-summary artifact with the rendered AX text as content (+ refCount metadata). - javascriptTool js-result artifact now carries the full sanitized output (already redacted by sanitizeValue) as content, alongside the metadata. - DebugArtifact gains content? + truncated? fields; exportDebugBundle reads each artifact's content from the store and attaches it. - serializeBundleForTransport, when over the 900KB budget, drops screenshot image content first (keeps metadata + ax/js text content, which is what the agent actually consumed) and notes the drop in the summary. - Go debugbundle.WriteBundle writes each artifact's content to artifacts/<subdir>/<id>.<ext> (screenshots/, ax/, js/, tab-state/, native/, text/). JSON-string content (base64 PNG) is unquoted so the file is a real PNG. e2e: new test asserts screenshot + ax-summary content survives into the collected bundle. Go test asserts content lands on disk in the right subdir. 5 e2e + 594 CRX unit + Go unit all green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
recordArtifact now auto-emits an artifact.recorded event (diagnosis domain)
carrying artifactRefs + the caller's ids (toolUseId/tabId) + metadata
(artifactType, mimeType, byteLength, sha256, truncated). Without this, an
agent reading events/*.jsonl had no way to discover that a screenshot / AX
text / JS output artifact existed for a given toolUseId — the artifact sat in
the artifacts/ dir with no inbound reference from the event flow.
Additionally, the domain end-events now carry artifactRefs directly so the
link is co-located with the action it belongs to:
- computerTool input.action.end carries the screenshot artifact ref
- axSnapshot ax.snapshot.end carries the ax-summary artifact ref
- javascriptTool javascript.exec.end carries the js-result artifact ref
(executeJavascript now returns {result, artifactRef}; the wrapper threads
the ref into exec.end's artifactRefs)
So an agent has two paths to an artifact: the artifact.recorded event (always
emitted) and the domain end-event (co-located with the action). Both carry
the same ids, so runtime-map correlation works either way.
Tests: recorder verifies artifact.recorded is emitted with correct ids +
artifactRefs; e2e verifies screenshot + ax-summary content still reaches the
bundle. 595 CRX unit + 5 e2e green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a "Debug evidence recording" toggle in the options page Debug Mode section, next to the existing "Show context debug info" switch. The two are intentionally independent — the existing DEBUG_MODE toggle only controls the sidepanel context-window UI, while the new toggle drives the evidence bundle system. - enableDebugEverywhere / disableDebugEverywhere: UI entry points that start a session in the options context, persist DEBUG_EVIDENCE_ENABLED + DEBUG_SESSION_META + DEBUG_EVIDENCE_PERSISTENT, and rely on cross-context sync to attach the service worker + sidepanel. - getDebugStatusFromStorage: reads the shared storage flag so the options page can render the live enabled state + session id. - recorder: refactored tryInitFromStorage to share attachToSession; the chrome.storage.onChanged listener now also handles enabled→true so a running service worker attaches when the user toggles from the options page (not just on SW startup). - PermissionsTab: toggle + live session id display (truncated to 8 chars); listens to storage changes so flipping from CLI/MCP reflects in the UI. - i18n: en-US + zh-CN strings for the four new labels. 595 CRX tests green. The toggle is the fourth way to start recording (alongside `superduck debug start`, `superduck debug enable`, and the MCP superduck_debug_* tools). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds debug-real-failure.spec.ts: starts a session, calls the PRODUCTION cdpDebugger.attachDebugger(999999) through the test bridge, lets chrome.debugger.attach genuinely reject, then collects the bundle and asserts the debug system pinpoints the failure. What the bundle says for this scenario (verified green): - cdp.attach.start (ids.tabId=999999) + cdp.attach.end (level=error, success=false, error.message populated) in events/cdp.jsonl - diagnosis finding debugger_attach_failed (severity error, domain cdp) with nextFiles = [cdp/debugger.ts, toolExecution/toolExecution.ts] - summary.agent.md Top Findings line surfaces it - runtime-map.json records the 999999 tab entity testBridge gained realAttachDebugger / realTakeSnapshot. They cannot static- import cdp/axSnapshot (that creates a debug -> mcpRuntime circular dependency that breaks every unit test mocking ../cdp), and dynamic import is unreliable in the MV3 service-worker bundle. Instead cdp/index.ts and axSnapshot/index.ts register their singletons on globalThis (__superduckCdpDebugger / __superduckTakeSnapshot) and testBridge reads them at call time. No-op in production — nothing else reads those keys. 595 unit tests + 6 e2e (5 collect + 1 real failure) green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 37 real-LLM e2e tests across 8 specs (smoke/complex/search/forms/ navigation/extract/edge/interaction) driving the production agent loop against qwen3.7-plus via token.cvte.com (anthropic protocol) - runRealAgentTest helper + read-only LLM observer (request-only, no fetch wrap/tee — teeing the response stream breaks the Anthropic SDK with 'Connection error.' and fabricates a dead-loop Heisenbug) - SD_DEBUG console logs gated behind globalThis.__SD_DEBUG_MSGS in streamAndProcess / useAgentLoop / executeToolUses for stall diagnosis - REAL-LLM-BUGS.md documenting BUG-001 (read_page hang on large pages), BUG-002 (CDP debugger attach timeout in headless), BUG-003 (agent loop has no max-iteration limit) - tests skip without SUPERDUCK_REAL_LLM_API_KEY (CI-safe)
…rtion masks failure
Native host now records tool request/response events in a Go-side ring buffer (debugrec package) and injects them along with recent audit log lines into the CRX debug bundle when superduck_debug_collect is called. This closes the information gap: CLI, MCP, and CRX direct paths all return the same enriched bundle. Chrome native messaging limits are directional: CRX→host is 64 MiB, host→CRX is 1 MB. The previous 900 KB transport cap was self-imposed and unnecessary for the CRX→host direction. Raise MAX_BUNDLE_BYTES to 32 MB and MAX_EVENTS_PER_DOMAIN to 5000 (matching ring buffer capacity), verified by a new limit-probe E2E test that sends 2.8 MB through serializeBundleForTransport without truncation. New control messages get_go_debug_events and get_audit_log let the CRX pull native host data on demand for the CRX-direct access path. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…ansfer
Add internal/fileserver package: a 127.0.0.1-only HTTP server with Bearer
token auth (reusing the udsauth session token), in-memory file store with
TTL cleanup, and 64 MB max file size. Endpoints: GET/POST/DELETE /f/{id},
GET /health.
Native host integration:
- main.go starts the file server at startup and sends file_server_ready
to CRX immediately (URL + token)
- New UDS control messages: upload_file (CLI/MCP pushes a file, native
host stores it and notifies CRX via file_ready), file_server_info
- handleUploadFile stores the file and sends file_ready to CRX
CRX integration:
- handleNativeMessage handles file_server_ready (stores URL+token) and
file_ready (invokes onFileReady callback)
- fetchFileFromHost(id) fetches a file from the localhost HTTP server
with Bearer auth
- onFileReady(callback) registers a listener for uploaded files
- getFileServerInfo() returns the current URL and token
CLI:
- New `superduck push-file --path <file> [--mime <type>]` subcommand
(distinct from `superduck upload` which is browser automation)
Security:
- 127.0.0.1 binding only (not reachable from network)
- Bearer token auth with constant-time comparison
- Path traversal prevention in filename sanitization and write paths
- 64 MB file size cap matching Chrome's CRX→host limit
- TTL expiration (1h) with background cleanup
Tests: 27 fileserver unit tests, 3 native-host integration tests,
1 detectMIME unit test, 5 CRX nativeHost unit tests, 6 E2E regression
tests — all passing in Docker sandbox.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Split responsibilities to avoid single-file bloat: CRX side: - New fileServerBridge.ts (111 lines) owns all file-server state and logic (URL/token storage, file_ready callback, fetchFileFromHost). nativeHost.ts delegates file_server_ready/file_ready messages to the bridge via handleMessage, down from 928 → 881 lines. - 12 dedicated unit tests in fileServerBridge.test.ts. Go side: - debug_handlers.go (183 lines): enrichDebugBundle, injectGoEvents, injectAuditLog, handleGetGoDebugEvents, handleGetAuditLog, readAuditLines. - fileserver_handlers.go (83 lines): handleUploadFile, handleFileServerInfo. - main.go down from 1030 → 805 lines, now focused on server lifecycle and message routing only. No behavior changes — pure extraction. All 615 CRX tests and 13 Go packages pass, E2E regression verified in Docker sandbox. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
| if !report.OK { | ||
| os.Exit(1) | ||
| } | ||
| return nil | ||
| } |
There was a problem hiding this comment.
Calling os.Exit(1) directly here bypasses all deferred cleanup in main(): analytics event emission (emitAndFlush at line 295), the self-update hint check (lines 297–306), and error tracking/sentry breadcrumbs (lines 308–328). The standard pattern in this CLI is to return an error and let main() handle exit codes. Consider returning a sentinel error (e.g. errDoctorFailed) and have main() map it to exit code 1, or have cmdDebugDoctor return (bool, error) so the caller controls the exit.
Suggestion:
| if !report.OK { | |
| os.Exit(1) | |
| } | |
| return nil | |
| } | |
| if !report.OK { | |
| return errDoctorFailed | |
| } | |
| return nil | |
| } |
| // 1. native messaging manifest | ||
| var foundBrowsers []string | ||
| for _, p := range debugbundle.ManifestPaths(nativeHostName) { | ||
| if _, err := os.Stat(p); err == nil { |
There was a problem hiding this comment.
os.Stat errors are not checked with os.IsNotExist. Any I/O error (e.g., permission denied on the parent directory) will be silently treated as 'manifest not found', misleading the user. Only a genuine 'not found' should count as missing.
Suggestion:
| if _, err := os.Stat(p); err == nil { | |
| if _, err := os.Stat(p); err == nil { | |
| // manifest exists | |
| } else if !os.IsNotExist(err) { | |
| // I/O error other than not-exists; surface it | |
| manifestCheck.Status = "fail" | |
| manifestCheck.Message = fmt.Sprintf("cannot stat manifest path %s: %v", p, err) | |
| report.OK = false | |
| report.Checks = append(report.Checks, manifestCheck) | |
| continue | |
| } else { |
|
|
||
| func cmdDebug(argv []string) error { | ||
| if len(argv) == 0 { | ||
| return fmt.Errorf("usage: superduck debug <start|stop|status|collect|doctor>") |
There was a problem hiding this comment.
The usage message omits the enable and disable subcommands, which are actually implemented and routed in the switch statement below. This inconsistency can confuse users.
Suggestion:
| return fmt.Errorf("usage: superduck debug <start|stop|status|collect|doctor>") | |
| return fmt.Errorf("usage: superduck debug <enable|disable|start|stop|status|collect|doctor>") |
| func (s *Server) handleGetGoDebugEvents(raw []byte) { | ||
| var req struct { | ||
| Limit int `json:"limit"` | ||
| } | ||
| _ = json.Unmarshal(raw, &req) | ||
| events := s.recorder.Events() |
There was a problem hiding this comment.
Potential nil pointer dereference: s.recorder.Events() will panic if s.recorder is nil. While NewServer() currently always initializes recorder, the companion method enrichDebugBundle in the same file defensively checks if s.recorder == nil { return rawResponse }. For consistency and defensive safety, the same nil-guard should be applied here.
Suggestion:
| func (s *Server) handleGetGoDebugEvents(raw []byte) { | |
| var req struct { | |
| Limit int `json:"limit"` | |
| } | |
| _ = json.Unmarshal(raw, &req) | |
| events := s.recorder.Events() | |
| func (s *Server) handleGetGoDebugEvents(raw []byte) { | |
| var req struct { | |
| Limit int `json:"limit"` | |
| } | |
| _ = json.Unmarshal(raw, &req) | |
| if s.recorder == nil { | |
| s.sendToChrome(map[string]any{ | |
| "type": "go_debug_events_response", | |
| "events": []any{}, | |
| "count": 0, | |
| }) | |
| return | |
| } | |
| events := s.recorder.Events() |
| if err != nil { | ||
| s.sendToChrome(map[string]any{ | ||
| "type": "audit_log_response", | ||
| "error": err.Error(), | ||
| }) | ||
| return | ||
| } | ||
| s.sendToChrome(map[string]any{ | ||
| "type": "audit_log_response", | ||
| "lines": lines, | ||
| }) |
There was a problem hiding this comment.
Security: Raw audit log lines are sent to Chrome without redaction. The companion method injectAuditLog applies redactAuditLine to strip URL query strings (which may contain tokens or sensitive params), but handleGetAuditLog sends raw lines directly. This creates an inconsistent security posture — the same audit data is redacted when bundled but exposed raw when fetched individually. The AuditRecord struct includes url and error fields that may contain sensitive data.
Suggestion:
| if err != nil { | |
| s.sendToChrome(map[string]any{ | |
| "type": "audit_log_response", | |
| "error": err.Error(), | |
| }) | |
| return | |
| } | |
| s.sendToChrome(map[string]any{ | |
| "type": "audit_log_response", | |
| "lines": lines, | |
| }) | |
| if err != nil { | |
| s.sendToChrome(map[string]any{ | |
| "type": "audit_log_response", | |
| "error": err.Error(), | |
| }) | |
| return | |
| } | |
| redactedLines := make([]json.RawMessage, len(lines)) | |
| for i, line := range lines { | |
| redactedLines[i] = redactAuditLine(line) | |
| } | |
| s.sendToChrome(map[string]any{ | |
| "type": "audit_log_response", | |
| "lines": redactedLines, | |
| }) |
| auditEvent := map[string]any{ | ||
| "schemaVersion": 1, | ||
| "eventId": debugrec.GenID(), | ||
| "ts": time.Now().UTC().Format(time.RFC3339Nano), | ||
| "debugSessionId": bundle.Session.DebugSessionID, | ||
| "domain": "mcp-server", | ||
| "event": "cli.audit_record", | ||
| "level": "debug", | ||
| "data": redactedData, | ||
| } |
There was a problem hiding this comment.
Diagnostic accuracy: The ts field uses time.Now() (the current bundling time) instead of the actual timestamp from the audit log line. The AuditRecord struct has a ts field recording when the event occurred. Using time.Now() makes the diagnostic bundle's timeline incorrect, which undermines its primary purpose of debugging sequence-of-events issues. The original timestamp from each audit line should be extracted and used instead.
Suggestion:
| auditEvent := map[string]any{ | |
| "schemaVersion": 1, | |
| "eventId": debugrec.GenID(), | |
| "ts": time.Now().UTC().Format(time.RFC3339Nano), | |
| "debugSessionId": bundle.Session.DebugSessionID, | |
| "domain": "mcp-server", | |
| "event": "cli.audit_record", | |
| "level": "debug", | |
| "data": redactedData, | |
| } | |
| var auditRec struct { | |
| TS string `json:"ts"` | |
| } | |
| _ = json.Unmarshal([]byte(line), &auditRec) | |
| eventTS := auditRec.TS | |
| if eventTS == "" { | |
| eventTS = time.Now().UTC().Format(time.RFC3339Nano) | |
| } | |
| auditEvent := map[string]any{ | |
| "schemaVersion": 1, | |
| "eventId": debugrec.GenID(), | |
| "ts": eventTS, | |
| "debugSessionId": bundle.Session.DebugSessionID, | |
| "domain": "mcp-server", | |
| "event": "cli.audit_record", | |
| "level": "debug", | |
| "data": redactedData, | |
| } |
| info, err := os.Stat(absPath) | ||
| if err != nil { | ||
| return fmt.Errorf("stat file: %w", err) | ||
| } | ||
| if !info.Mode().IsRegular() { | ||
| return fmt.Errorf("not a regular file: %s", absPath) | ||
| } | ||
|
|
||
| data, err := os.ReadFile(absPath) |
There was a problem hiding this comment.
Missing file size limit check: The file is read entirely into memory via os.ReadFile without any size guard. The native host's FileStore.Put enforces a 64 MB limit (DefaultMaxFileSize = 64 << 20 in internal/fileserver/store.go), but the CLI side will still load arbitrarily large files into memory before sending them over UDS — only to be rejected by the server. A large file (e.g., multi-GB) would cause excessive memory consumption or OOM in the CLI process. Consider checking info.Size() against the known server limit (64 MB) right after os.Stat and returning an early error before os.ReadFile.
Suggestion:
| info, err := os.Stat(absPath) | |
| if err != nil { | |
| return fmt.Errorf("stat file: %w", err) | |
| } | |
| if !info.Mode().IsRegular() { | |
| return fmt.Errorf("not a regular file: %s", absPath) | |
| } | |
| data, err := os.ReadFile(absPath) | |
| info, err := os.Stat(absPath) | |
| if err != nil { | |
| return fmt.Errorf("stat file: %w", err) | |
| } | |
| if !info.Mode().IsRegular() { | |
| return fmt.Errorf("not a regular file: %s", absPath) | |
| } | |
| const maxFileSize = 64 << 20 // matches server DefaultMaxFileSize | |
| if info.Size() > maxFileSize { | |
| return fmt.Errorf("file too large: %d bytes (max %d)", info.Size(), maxFileSize) | |
| } | |
| data, err := os.ReadFile(absPath) |
| filename := filepath.Base(absPath) | ||
| mimeType := *mime | ||
| if mimeType == "" { | ||
| mimeType = detectMIME(filename) | ||
| } |
There was a problem hiding this comment.
MIME type detection by extension only: detectMIME relies solely on the file extension, which can be spoofed (e.g., an executable named image.png). The Go standard library provides http.DetectContentType(data) which sniffs the actual file content. Since the file data is already loaded into data at this point, consider falling back to content sniffing for unknown types or using it as a primary detection method for better accuracy and safety.
Suggestion:
| filename := filepath.Base(absPath) | |
| mimeType := *mime | |
| if mimeType == "" { | |
| mimeType = detectMIME(filename) | |
| } | |
| filename := filepath.Base(absPath) | |
| mimeType := *mime | |
| if mimeType == "" { | |
| mimeType = detectMIME(filename) | |
| // For unknown types, sniff from content for better accuracy | |
| if mimeType == "application/octet-stream" { | |
| mimeType = http.DetectContentType(data) | |
| } | |
| } |
| _ = protocol.SendMessage(writer, map[string]any{ | ||
| "type": "file_server_info_response", | ||
| "url": s.fileServer.BaseURL(), | ||
| "port": s.fileServer.Port(), | ||
| "token": s.udsAuth, | ||
| }) |
There was a problem hiding this comment.
The file_server_info response returns s.udsAuth (the UDS auth token) to the caller. While UDS connections are already authenticated (they needed this token to connect), exposing the raw auth token back over the wire is an unnecessary security risk. If any intermediate process, log, or debug bundle captures this response, the token is leaked and could be used for unauthorized access to the file server or other UDS endpoints. Consider either omitting the token entirely (the caller already has it) or returning a separate, scoped file-server-only token.
Suggestion:
| _ = protocol.SendMessage(writer, map[string]any{ | |
| "type": "file_server_info_response", | |
| "url": s.fileServer.BaseURL(), | |
| "port": s.fileServer.Port(), | |
| "token": s.udsAuth, | |
| }) | |
| // The caller already possesses the UDS auth token (required to connect). | |
| // Return only connection metadata, not the token itself. | |
| _ = protocol.SendMessage(writer, map[string]any{ | |
| "type": "file_server_info_response", | |
| "url": s.fileServer.BaseURL(), | |
| "port": s.fileServer.Port(), | |
| }) |
| func RedactURL(s string) string { | ||
| idx := strings.IndexByte(s, '?') | ||
| if idx < 0 { | ||
| return s | ||
| } | ||
| return s[:idx] + "?[redacted-query]" | ||
| } |
There was a problem hiding this comment.
The doc comment states "Non-URL strings are returned unchanged," but the implementation does not validate whether s is actually a URL. Any arbitrary string containing a ? (e.g., a log message like "Is this working?", a JSON snippet, or natural language text) will have everything after the first ? truncated and replaced with ?[redacted-query]. This can silently corrupt non-URL data in the debug bundle.
Additionally, URL fragments (e.g., https://example.com/path#access_token=xyz) are not redacted — fragments can also carry sensitive data.
Consider adding a lightweight URL sanity check (e.g., strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")) before stripping, and also handling the fragment portion.
Suggestion:
| func RedactURL(s string) string { | |
| idx := strings.IndexByte(s, '?') | |
| if idx < 0 { | |
| return s | |
| } | |
| return s[:idx] + "?[redacted-query]" | |
| } | |
| func RedactURL(s string) string { | |
| // Only redact strings that look like URLs to avoid corrupting | |
| // arbitrary log lines that happen to contain '?'. | |
| if !strings.HasPrefix(s, "http://") && !strings.HasPrefix(s, "https://") { | |
| return s | |
| } | |
| // Strip everything from the first '?' or '#'. | |
| idxQ := strings.IndexByte(s, '?') | |
| idxH := strings.IndexByte(s, '#') | |
| idx := -1 | |
| if idxQ >= 0 { | |
| idx = idxQ | |
| } | |
| if idxH >= 0 && (idx < 0 || idxH < idx) { | |
| idx = idxH | |
| } | |
| if idx < 0 { | |
| return s | |
| } | |
| return s[:idx] + "?[redacted]" | |
| } |
| if len(events) == 0 { | ||
| continue | ||
| } | ||
| fname := strings.ReplaceAll(domain, "/", "-") + ".jsonl" |
There was a problem hiding this comment.
Path Traversal Risk: strings.ReplaceAll(domain, "/", "-") only sanitizes forward slashes but does not protect against .. sequences or backslashes (\ on Windows). Since domain comes from JSON deserialization of the CRX bundle, a malicious or malformed domain key like ../../etc/cron.d could write files outside the events directory. Consider using filepath.Clean, validating against an allowlist of safe domain names, or rejecting keys containing .. or path separators.
Suggestion:
| fname := strings.ReplaceAll(domain, "/", "-") + ".jsonl" | |
| if strings.ContainsAny(domain, "\\/..") { | |
| continue // skip unsafe domain keys | |
| } | |
| fname := strings.ReplaceAll(domain, "/", "-") + ".jsonl" |
| func sanitizeFilename(s string) string { | ||
| r := strings.NewReplacer("/", "-", "\\", "-", ":", "-") | ||
| return r.Replace(s) | ||
| } |
There was a problem hiding this comment.
Path Traversal Risk: sanitizeFilename only replaces /, \, and : but does not handle .. sequences. If a.ID contains .. (e.g., ../../etc/passwd), the resulting path filepath.Join(sub, fname) could escape the intended artifacts/<subdir>/ directory, leading to arbitrary file writes. Since a.ID originates from JSON sent by the CRX, it should be treated as untrusted input. Consider using filepath.Base() or rejecting IDs containing ...
Suggestion:
| func sanitizeFilename(s string) string { | |
| r := strings.NewReplacer("/", "-", "\\", "-", ":", "-") | |
| return r.Replace(s) | |
| } | |
| func sanitizeFilename(s string) string { | |
| s = strings.NewReplacer("/", "-", "\\", "-", ":", "-").Replace(s) | |
| s = strings.ReplaceAll(s, "..", "") | |
| return filepath.Base(s) | |
| } |
| } | ||
|
|
||
| // PrintDoctor writes a human-readable doctor report. | ||
| func PrintDoctor(r *DoctorReport, w *os.File) { |
There was a problem hiding this comment.
API Design Issue: PrintDoctor takes *os.File as the writer parameter, which unnecessarily restricts it to file types. This makes the function untestable with *bytes.Buffer or other io.Writer implementations. Consider accepting io.Writer instead, which is the idiomatic Go pattern for output functions.
Suggestion:
| func PrintDoctor(r *DoctorReport, w *os.File) { | |
| func PrintDoctor(r *DoctorReport, w io.Writer) { |
| func GenID() string { | ||
| var b [12]byte | ||
| _, _ = rand.Read(b[:]) | ||
| return fmt.Sprintf("nh_%x", b[:]) | ||
| } |
There was a problem hiding this comment.
The error from crypto/rand.Read is silently ignored. In the same codebase, internal/fileserver/store.go:188 handles this error explicitly (via panic), and internal/udsauth/udsauth.go:35 returns it. If rand.Read fails under low-entropy conditions, the buffer b remains all zeros, producing an ID like nh_000000000000000000000000 — leading to duplicate IDs across events and undermining the reliability of the debug evidence bundle. Recommend handling the error consistently with the rest of the codebase, e.g. by falling back to a timestamp-based component or at least logging a warning.
Suggestion:
| func GenID() string { | |
| var b [12]byte | |
| _, _ = rand.Read(b[:]) | |
| return fmt.Sprintf("nh_%x", b[:]) | |
| } | |
| func GenID() string { | |
| var b [12]byte | |
| if _, err := rand.Read(b[:]); err != nil { | |
| // Fall back to timestamp-based uniqueness if crypto/rand fails. | |
| binary.BigEndian.PutUint64(b[:8], uint64(time.Now().UnixNano())) | |
| } | |
| return fmt.Sprintf("nh_%x", b[:]) | |
| } |
| // Reset clears the buffer. | ||
| func (r *Recorder) Reset() { | ||
| r.mu.Lock() | ||
| r.count = 0 | ||
| r.head = 0 | ||
| r.mu.Unlock() | ||
| } |
There was a problem hiding this comment.
Reset zeroes count and head but leaves old Event structs (with their Data and IDs maps) referenced in the underlying events slice. These maps won't be garbage-collected until each slot is overwritten by a new event. For a 500-slot buffer this is minor, but explicitly nil-ing the slots on reset is a good hygiene practice to release memory promptly.
Suggestion:
| // Reset clears the buffer. | |
| func (r *Recorder) Reset() { | |
| r.mu.Lock() | |
| r.count = 0 | |
| r.head = 0 | |
| r.mu.Unlock() | |
| } | |
| // Reset clears the buffer. | |
| func (r *Recorder) Reset() { | |
| r.mu.Lock() | |
| for i := range r.events { | |
| r.events[i] = Event{} | |
| } | |
| r.count = 0 | |
| r.head = 0 | |
| r.mu.Unlock() | |
| } |
| set -e | ||
| cd "$(dirname "$0")/../chrome-native-host" | ||
| golangci-lint run --timeout=5m |
There was a problem hiding this comment.
golangci-lint may not be installed on every developer's machine (it's a separate tool that requires make lint-install per AGENTS.md). With set -e, if the binary is missing the command returns 127 and the whole pre-commit hook fails, blocking the commit. The sibling script lint-staged-govet.sh doesn't have this problem because go is always available. Consider guarding the call so a missing golangci-lint is a warning rather than a hard failure (e.g. command -v golangci-lint >/dev/null 2>&1 || { echo 'golangci-lint not found, skipping'; exit 0; }), or at minimum document this requirement prominently.
Suggestion:
| set -e | |
| cd "$(dirname "$0")/../chrome-native-host" | |
| golangci-lint run --timeout=5m | |
| set -e | |
| if ! command -v golangci-lint >/dev/null 2>&1; then | |
| echo "warning: golangci-lint not found in PATH, skipping (run 'make lint-install' to install)" >&2 | |
| exit 0 | |
| fi | |
| cd "$(dirname "$0")/../chrome-native-host" | |
| golangci-lint run --timeout=5m |
| func (s *FileStore) Put(filename, mimeType string, data []byte) (string, error) { | ||
| if int64(len(data)) > s.config.MaxFileSize { | ||
| return "", ErrFileTooLarge | ||
| } | ||
| id := generateID() |
There was a problem hiding this comment.
Put() enforces a per-file size cap but there is no limit on the total number of entries or aggregate memory. A caller with the Bearer token can make many Put() calls within the 1-hour TTL window, each up to 64 MB, causing unbounded memory growth (OOM) before the cleanup loop evicts anything. Consider adding a MaxEntries (or MaxTotalSize) field to StoreConfig and rejecting Put() when the store is at capacity.
Suggestion:
| func (s *FileStore) Put(filename, mimeType string, data []byte) (string, error) { | |
| if int64(len(data)) > s.config.MaxFileSize { | |
| return "", ErrFileTooLarge | |
| } | |
| id := generateID() | |
| func (s *FileStore) Put(filename, mimeType string, data []byte) (string, error) { | |
| if int64(len(data)) > s.config.MaxFileSize { | |
| return "", ErrFileTooLarge | |
| } | |
| s.mu.Lock() | |
| if len(s.files) >= s.config.MaxEntries { | |
| s.mu.Unlock() | |
| return "", ErrStoreFull | |
| } | |
| id := generateID() | |
| // ... rest of insertion under the same lock | |
| s.files[id] = entry | |
| s.mu.Unlock() | |
| return id, nil | |
| } |
| func (s *FileStore) Get(id string) *FileEntry { | ||
| s.mu.RLock() | ||
| entry, ok := s.files[id] | ||
| s.mu.RUnlock() | ||
| if !ok { | ||
| return nil | ||
| } | ||
| if time.Now().After(entry.ExpiresAt) { |
There was a problem hiding this comment.
Get() returns a direct pointer to the internal FileEntry, whose Data slice is mutable. If any caller were to modify the returned Data, it would corrupt the store's internal state. Although current callers (handleGetFile and WriteFileToDisk) only read the data, this is a latent immutability hazard. Consider returning a copy of the entry (or at least a copy of the Data slice), or documenting that the returned *FileEntry must not be mutated.
Suggestion:
| func (s *FileStore) Get(id string) *FileEntry { | |
| s.mu.RLock() | |
| entry, ok := s.files[id] | |
| s.mu.RUnlock() | |
| if !ok { | |
| return nil | |
| } | |
| if time.Now().After(entry.ExpiresAt) { | |
| // Get returns a defensive copy of the file entry. The caller may freely | |
| // use or modify the returned Data without affecting the store. | |
| func (s *FileStore) Get(id string) *FileEntry { | |
| s.mu.RLock() | |
| entry, ok := s.files[id] | |
| if !ok { | |
| s.mu.RUnlock() | |
| return nil | |
| } | |
| // Check expiry under RLock first; only upgrade if deletion is needed. | |
| if time.Now().Before(entry.ExpiresAt) { | |
| copyEntry := *entry | |
| copyEntry.Data = append([]byte(nil), entry.Data...) | |
| s.mu.RUnlock() | |
| return ©Entry | |
| } | |
| s.mu.RUnlock() | |
| // Expired – upgrade to write lock and delete. | |
| s.mu.Lock() | |
| if e, exists := s.files[id]; exists && time.Now().After(e.ExpiresAt) { | |
| delete(s.files, id) | |
| } | |
| s.mu.Unlock() | |
| return nil | |
| } |
| func (s *FileStore) Close() { | ||
| s.mu.Lock() | ||
| defer s.mu.Unlock() | ||
| if !s.stopped { | ||
| s.stopped = true | ||
| close(s.stopCh) | ||
| } | ||
| } |
There was a problem hiding this comment.
Close() signals the cleanup goroutine to stop but does not clear the files map. On application shutdown, large file payloads (up to 64 MB each) will remain referenced and un-GC'd until the process exits. Consider clearing the map so the memory is released immediately.
Suggestion:
| func (s *FileStore) Close() { | |
| s.mu.Lock() | |
| defer s.mu.Unlock() | |
| if !s.stopped { | |
| s.stopped = true | |
| close(s.stopCh) | |
| } | |
| } | |
| func (s *FileStore) Close() { | |
| s.mu.Lock() | |
| defer s.mu.Unlock() | |
| if !s.stopped { | |
| s.stopped = true | |
| close(s.stopCh) | |
| } | |
| s.files = make(map[string]*FileEntry) | |
| } |
| w.Header().Set("Content-Type", entry.MIMEType) | ||
| if entry.Filename != "" { | ||
| safe := sanitizeHeaderValue(entry.Filename) | ||
| w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename=%q`, safe)) | ||
| } |
There was a problem hiding this comment.
Security Risk (Stored XSS via client-controlled Content-Type): The Content-Type header for GET /f/{id} is derived from the uploader-supplied Content-Type header (see handlePutFile). If an attacker (or a compromised CRX) uploads HTML or SVG content with Content-Type: text/html or image/svg+xml, the browser will render active content when navigating to the URL. Since the server runs on 127.0.0.1, this is a same-origin XSS vector against any localhost service or against the browser's privileged origins.
Suggestion: Add X-Content-Type-Options: nosniff and a restrictive Content-Security-Policy header (e.g., default-src 'none') to handleGetFile responses. Better yet, force Content-Disposition: attachment or strip/deny dangerous MIME types (text/html, image/svg+xml, etc.) unless explicitly required.
Suggestion:
| w.Header().Set("Content-Type", entry.MIMEType) | |
| if entry.Filename != "" { | |
| safe := sanitizeHeaderValue(entry.Filename) | |
| w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename=%q`, safe)) | |
| } | |
| w.Header().Set("Content-Type", entry.MIMEType) | |
| w.Header().Set("X-Content-Type-Options", "nosniff") | |
| w.Header().Set("Content-Security-Policy", "default-src 'none'") | |
| if entry.Filename != "" { | |
| safe := sanitizeHeaderValue(entry.Filename) | |
| w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename=%q`, safe)) | |
| } |
| homeClean := filepath.Clean(home) | ||
| if !strings.HasPrefix(clean, homeClean+string(filepath.Separator)) && clean != homeClean { | ||
| return "", fmt.Errorf("path must be within home directory (%s): %s", homeClean, p) | ||
| } |
There was a problem hiding this comment.
Security Risk (Unrestricted file overwrite within home directory): validateWritePath ensures the target is within $HOME, but does not prevent overwriting sensitive files like ~/.ssh/authorized_keys, ~/.bashrc, ~/.aws/credentials, etc. If the targetPath is ever influenced by external/untrusted input (directly or transitively), this could lead to permanent system compromise via arbitrary file overwrite.
Suggestion: Consider restricting writes to a designated subdirectory (e.g., ~/.<appname>/downloads/) rather than the entire home tree. At minimum, add an explicit allowlist or denylist of protected paths (e.g., ~/.ssh, ~/.config, ~/.aws).
Suggestion:
| homeClean := filepath.Clean(home) | |
| if !strings.HasPrefix(clean, homeClean+string(filepath.Separator)) && clean != homeClean { | |
| return "", fmt.Errorf("path must be within home directory (%s): %s", homeClean, p) | |
| } | |
| homeClean := filepath.Clean(home) | |
| if !strings.HasPrefix(clean, homeClean+string(filepath.Separator)) && clean != homeClean { | |
| return "", fmt.Errorf("path must be within home directory (%s): %s", homeClean, p) | |
| } | |
| // Deny well-known sensitive paths to prevent accidental or malicious overwrite. | |
| for _, denied := range []string{filepath.Join(homeClean, ".ssh"), filepath.Join(homeClean, ".aws"), filepath.Join(homeClean, ".config")} { | |
| if strings.HasPrefix(clean, denied+string(filepath.Separator)) || clean == denied { | |
| return "", fmt.Errorf("write to sensitive path denied: %s", p) | |
| } | |
| } |
| go func() { | ||
| if err := s.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { | ||
| slog.Error("fileserver serve error", "error", err) | ||
| } | ||
| }() |
There was a problem hiding this comment.
Reliability Issue (Silent serve failure): If s.server.Serve(listener) fails immediately (e.g., the listener is closed or another error occurs), the error is only logged inside the background goroutine. NewServer has already returned successfully, so the caller (and any code that subsequently sends file_server_ready to the CRX) will assume the server is alive. The CRX will then try to fetch from a dead URL and fail.
Suggestion: Use a readiness signal (e.g., a buffered channel or errgroup) so that NewServer blocks until the server is confirmed listening, or surface the serve error to the caller. For example, accept Serve's error synchronously or add a health check round-trip before returning.
Suggestion:
| go func() { | |
| if err := s.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { | |
| slog.Error("fileserver serve error", "error", err) | |
| } | |
| }() | |
| // Option A: verify readiness via a self-request before returning | |
| // Option B: propagate the error via a channel | |
| serveErr := make(chan error, 1) | |
| go func() { | |
| if err := s.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { | |
| serveErr <- err | |
| } | |
| }() | |
| // Caller can select on serveErr for early failure detection. |
| Data json.RawMessage `json:"data,omitempty"` | ||
| Content json.RawMessage `json:"content,omitempty"` |
There was a problem hiding this comment.
The Data and Content fields have identical types (json.RawMessage) and overlapping names, but their intended purposes are different and undocumented. The TypeScript counterpart (DebugArtifact in schema.ts) clarifies: data is "Small inline metadata (≤64 KB), stored directly in the artifact record" while content is "Large payload stored in a separate content store, merged at export time." Without these comments, a Go maintainer has no way to know the distinction, and collect.go only reads Content (never Data) during writeArtifactContents — inline metadata is intentionally left in metadata.json. Please add the same clarifying comments to prevent confusion about which field to use.
Suggestion:
| Data json.RawMessage `json:"data,omitempty"` | |
| Content json.RawMessage `json:"content,omitempty"` | |
| // Small inline metadata (≤64 KB), stored directly in the artifact record. | |
| Data json.RawMessage `json:"data,omitempty"` | |
| // Large payload stored in a separate content store, merged at export time. | |
| Content json.RawMessage `json:"content,omitempty"` |
| if err := protocol.SendMessage(os.Stdout, map[string]any{ | ||
| "type": "file_server_ready", | ||
| "url": fs.BaseURL(), | ||
| "token": token, | ||
| }); err != nil { |
There was a problem hiding this comment.
The file_server_ready message is sent directly via protocol.SendMessage(os.Stdout, ...), bypassing the sendToChrome() helper and its chromeOutput() indirection. While this works today because readChromeStdio hasn't started yet (it starts in server.Run()), this inconsistency could cause subtle bugs if the startup order changes. Consider using server.sendToChrome() or documenting why a direct stdout write is safe here.
Suggestion:
| if err := protocol.SendMessage(os.Stdout, map[string]any{ | |
| "type": "file_server_ready", | |
| "url": fs.BaseURL(), | |
| "token": token, | |
| }); err != nil { | |
| if err := server.sendToChrome(map[string]any{ | |
| "type": "file_server_ready", | |
| "url": fs.BaseURL(), | |
| "token": token, | |
| }); err != nil { |
There was a problem hiding this comment.
DuckPR reviewer: ocr
Model: anthropic/glm-5.2
OpenCodeReview found 68 issue(s) in this PR.
- 67 posted as inline comment(s)
- 1 shown in this summary
Warnings:
- [object Object]
- [object Object]
chrome-native-host/cmd/native-host/main.go
Shown in summary because of missing line.
All audit log events are stamped with time.Now().UTC() at enrichment time rather than parsing the original timestamp from each audit line. This means the chronological order in the debug bundle will be wrong — all audit events will appear to have occurred at the moment of debug collection, making timeline correlation with other events misleading. Consider parsing the timestamp from the audit log JSON line (e.g., a ts or time field) and using that instead.
Suggested change
Before:
"ts": time.Now().UTC().Format(time.RFC3339Nano),
After:
// Parse ts from audit line if available; fall back to now.
var ts string
if parsed, ok := parseAuditTimestamp(line); ok {
ts = parsed
} else {
ts = time.Now().UTC().Format(time.RFC3339Nano)
}
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
chrome-crx/src/options/components/PermissionsTab.tsx (2)
488-495: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winGive the evidence checkbox an accessible name.
The label wraps only the hidden input and visual switch, so assistive tech cannot associate the control with “Debug evidence recording.”
Proposed fix
- <div className="font-large text-text-100"> + <div id="debug-evidence-recording-label" className="font-large text-text-100"> <FormattedMessage defaultMessage="Debug evidence recording" id="debug_evidence_recording" /> </div> - <div className="text-text-400 font-base-sm mt-1"> + <div id="debug-evidence-recording-description" className="text-text-400 font-base-sm mt-1"> {evidenceEnabled ? ( <FormattedMessage defaultMessage="Recording all runtime events (tools, agent loop, CDP, screenshots). Export with: superduck debug collect" id="debug_evidence_enabled_description" @@ checked={evidenceEnabled} + aria-labelledby="debug-evidence-recording-label" + aria-describedby="debug-evidence-recording-description" onChange={(event) => { void toggleEvidence(event.target.checked); }}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@chrome-crx/src/options/components/PermissionsTab.tsx` around lines 488 - 495, The evidence toggle in PermissionsTab lacks an accessible name because the surrounding label only wraps the hidden checkbox and switch UI. Update the checkbox markup so the control is explicitly associated with “Debug evidence recording” using an accessible label pattern in this component, preserving the existing toggle behavior in toggleEvidence and the input/onChange wiring.
279-289: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winHandle debug-status load failures.
load()can reject throughgetDebugStatusFromStorage(), and both callers intentionally discard the promise withvoid, which can produce unhandled rejections from the options page.Proposed fix
const load = async () => { - const status = await getDebugStatusFromStorage(); - if (!mounted) return; - setEvidenceEnabled(status.enabled); - setEvidenceSessionId(status.session?.debugSessionId ?? null); + try { + const status = await getDebugStatusFromStorage(); + if (!mounted) return; + setEvidenceEnabled(status.enabled); + setEvidenceSessionId(status.session?.debugSessionId ?? null); + } catch (err) { + if (mounted) { + console.error('[options] load evidence status failed:', err); + } + } };🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@chrome-crx/src/options/components/PermissionsTab.tsx` around lines 279 - 289, The debug-status loader in PermissionsTab’s load() can reject and the current void calls discard that promise, leading to unhandled rejections. Wrap the getDebugStatusFromStorage() await in load() with error handling (or catch the returned promise) and make sure both the initial load invocation and the storage-change handler keep failures contained. Use the existing load, handler, and getDebugStatusFromStorage symbols to place the fix.
🧹 Nitpick comments (4)
docs/debug-system-design.md (4)
556-566: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAdd
textlanguage specifier to ASCII art code block.The "Go 启动" sequence diagram is a fenced code block without a language specifier, triggering markdownlint (MD040). Add
textto satisfy linting and preserve plain-text rendering.📝 Proposed fix
- ``` + ```text Go 启动 ├─ fileserver.Start() → 随机端口,生成 token ... └─ stdout: { type: "pong" } ← 时序 B</details> <details> <summary>🤖 Prompt for AI Agents</summary>Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.In
@docs/debug-system-design.mdaround lines 556 - 566, The ASCII art sequence
diagram in the markdown code fence is missing a language specifier, which
triggers markdownlint MD040. Update the fenced block around the “Go 启动” diagram
in the docs so it uses a plain-text language tag, preserving the existing
content while making the block lint-compliant.</details> <!-- cr-comment:v1:fa36d0e7443d215eaa842722 --> --- `656-664`: _📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _💤 Low value_ **Add `text` language specifier to bundle tree diagram.** The bundle directory tree lacks a language specifier, triggering markdownlint (MD040). <details> <summary>📝 Proposed fix</summary> ```diff - ``` + ```text 2026-06-30T123456Z-a1b2c3d4/ ├── 00-readme.md ← "阅读顺序:1→2→3→4→5" ... └── artifacts/ ← 截图/ax/js 的实体文件</details> <details> <summary>🤖 Prompt for AI Agents</summary>Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.In
@docs/debug-system-design.mdaround lines 656 - 664, The bundle tree diagram
in the markdown section is missing a language specifier, causing MD040. Update
the fenced code block that shows the directory tree to use the text language
identifier so the diagram in the docs renders as a properly labeled code block.
Locate the tree snippet near the bundle structure example and adjust only the
fence, not the contents.</details> <!-- cr-comment:v1:1f86df3ffe27f7872ccb8ea6 --> --- `606-611`: _📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _💤 Low value_ **Add `text` language specifier to design goals list.** The design goals block (绑定地址, 认证, 存储, 并发) lacks a language specifier, triggering markdownlint (MD040). <details> <summary>📝 Proposed fix</summary> ```diff - ``` + ```text 绑定地址: 127.0.0.1(loopback only) ... 并发: < 5 连接(只服务 CRX 一个客户端)</details> <details> <summary>🤖 Prompt for AI Agents</summary>Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.In
@docs/debug-system-design.mdaround lines 606 - 611, The design goals code
block in the documentation is missing a language specifier, which triggers
markdownlint MD040. Update the fenced block around the bindings/认证/存储/并发 list to
use a text language tag so the block is explicitly marked as plain text, and
keep the content unchanged.</details> <!-- cr-comment:v1:305eb80d0277257495f7e18c --> --- `326-330`: _📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _💤 Low value_ **Remove spurious code block around prose paragraph.** The explanatory text starting with "每个域自定义自己的 data shape..." is wrapped in triple backticks but is not code. This triggers markdownlint (MD040) and renders the paragraph as monospaced code instead of normal body text. <details> <summary>📝 Proposed fix</summary> ```diff - ``` 每个域自定义自己的 data shape,不进 schema。诊断规则通过 `e.data?.fieldName` 读取,如果字段不存在就回退到 undefined。这意味着**新增插桩点只需要在调用侧加字段,不需要改 schema.ts**。 - ```🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/debug-system-design.md` around lines 326 - 330, The prose paragraph about “每个域自定义自己的 data shape...” is incorrectly wrapped in a code fence, causing markdownlint MD040 and rendering it as monospaced text. Remove the stray triple backticks around that paragraph in the markdown section near the event naming examples, and keep the explanatory text as normal body prose while preserving the surrounding list/content structure.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@chrome-crx/src/options/components/PermissionsTab.tsx`:
- Line 305: The `disableDebugEverywhere()` flow can report success even when
clearing the persistent auto-start state fails, leaving evidence recording able
to resume after restart. Update the `PermissionsTab` caller and/or the
`disableDebugEverywhere` helper so persistence is cleared successfully before
stopping the session, and surface any failure instead of swallowing it. Make
sure the UI only marks evidence recording as disabled after the persistent flag
has been cleared, using the `disableDebugEverywhere` and related
persistence-clearing logic as the main fix points.
---
Outside diff comments:
In `@chrome-crx/src/options/components/PermissionsTab.tsx`:
- Around line 488-495: The evidence toggle in PermissionsTab lacks an accessible
name because the surrounding label only wraps the hidden checkbox and switch UI.
Update the checkbox markup so the control is explicitly associated with “Debug
evidence recording” using an accessible label pattern in this component,
preserving the existing toggle behavior in toggleEvidence and the input/onChange
wiring.
- Around line 279-289: The debug-status loader in PermissionsTab’s load() can
reject and the current void calls discard that promise, leading to unhandled
rejections. Wrap the getDebugStatusFromStorage() await in load() with error
handling (or catch the returned promise) and make sure both the initial load
invocation and the storage-change handler keep failures contained. Use the
existing load, handler, and getDebugStatusFromStorage symbols to place the fix.
---
Nitpick comments:
In `@docs/debug-system-design.md`:
- Around line 556-566: The ASCII art sequence diagram in the markdown code fence
is missing a language specifier, which triggers markdownlint MD040. Update the
fenced block around the “Go 启动” diagram in the docs so it uses a plain-text
language tag, preserving the existing content while making the block
lint-compliant.
- Around line 656-664: The bundle tree diagram in the markdown section is
missing a language specifier, causing MD040. Update the fenced code block that
shows the directory tree to use the text language identifier so the diagram in
the docs renders as a properly labeled code block. Locate the tree snippet near
the bundle structure example and adjust only the fence, not the contents.
- Around line 606-611: The design goals code block in the documentation is
missing a language specifier, which triggers markdownlint MD040. Update the
fenced block around the bindings/认证/存储/并发 list to use a text language tag so the
block is explicitly marked as plain text, and keep the content unchanged.
- Around line 326-330: The prose paragraph about “每个域自定义自己的 data shape...” is
incorrectly wrapped in a code fence, causing markdownlint MD040 and rendering it
as monospaced text. Remove the stray triple backticks around that paragraph in
the markdown section near the event naming examples, and keep the explanatory
text as normal body prose while preserving the surrounding list/content
structure.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 1da92cb9-54f2-4da3-a3de-71bf2154826d
📒 Files selected for processing (23)
.gitignorechrome-crx/e2e/helpers/realAgent.tschrome-crx/e2e/helpers/realLLM.tschrome-crx/src/background/fileServerBridge.tschrome-crx/src/debug/diagnostics.tschrome-crx/src/debug/exportBundle.tschrome-crx/src/debug/recorder.tschrome-crx/src/debug/redaction.tschrome-crx/src/debug/runtimeMap.tschrome-crx/src/debug/store.tschrome-crx/src/mcpRuntime/inputTools/computerTool.tschrome-crx/src/mcpRuntime/screenshot/annotatedScreenshot.tschrome-crx/src/mcpRuntime/toolExecution/toolExecutor.tschrome-crx/src/options/components/PermissionsTab.tsxchrome-crx/src/sidepanel/hooks/agentLoop/executeToolUses.tschrome-crx/src/sidepanel/hooks/agentLoop/streamAndProcess.tschrome-crx/src/sidepanel/hooks/useAgentLoop.tschrome-crx/src/sidepanel/hooks/useSidepanelDebug.tschrome-crx/test-results/.last-run.jsonchrome-native-host/cmd/superduck/cmd_upload_file.gochrome-native-host/internal/fileserver/server.gochrome-native-host/internal/fileserver/store.godocs/debug-system-design.md
💤 Files with no reviewable changes (1)
- chrome-crx/test-results/.last-run.json
✅ Files skipped from review due to trivial changes (3)
- chrome-crx/src/sidepanel/hooks/agentLoop/streamAndProcess.ts
- chrome-crx/src/sidepanel/hooks/agentLoop/executeToolUses.ts
- .gitignore
🚧 Files skipped from review as they are similar to previous changes (17)
- chrome-crx/src/sidepanel/hooks/useSidepanelDebug.ts
- chrome-crx/src/mcpRuntime/screenshot/annotatedScreenshot.ts
- chrome-crx/src/mcpRuntime/toolExecution/toolExecutor.ts
- chrome-crx/src/sidepanel/hooks/useAgentLoop.ts
- chrome-crx/src/mcpRuntime/inputTools/computerTool.ts
- chrome-native-host/cmd/superduck/cmd_upload_file.go
- chrome-crx/src/background/fileServerBridge.ts
- chrome-crx/src/debug/diagnostics.ts
- chrome-crx/src/debug/runtimeMap.ts
- chrome-crx/src/debug/store.ts
- chrome-crx/e2e/helpers/realAgent.ts
- chrome-crx/src/debug/exportBundle.ts
- chrome-native-host/internal/fileserver/store.go
- chrome-crx/e2e/helpers/realLLM.ts
- chrome-crx/src/debug/redaction.ts
- chrome-native-host/internal/fileserver/server.go
- chrome-crx/src/debug/recorder.ts
| const manifest = chrome.runtime.getManifest(); | ||
| await enableDebugEverywhere(manifest.version); | ||
| } else { | ||
| await disableDebugEverywhere(); |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Do not report evidence recording as disabled until persistence is cleared.
disableDebugEverywhere() currently swallows failures while clearing the persistent auto-start flag before stopping the session. That can leave evidence recording enabled again after a browser restart even though this UI appears to turn it off.
Proposed helper-side fix
export async function disableDebugEverywhere(): Promise<DebugSessionMeta | null> {
- try {
- await chrome.storage.local.set({ [DEBUG_PERSISTENT_KEY]: false });
- } catch {
- // ignore
- }
+ await chrome.storage.local.set({ [DEBUG_PERSISTENT_KEY]: false });
return stopDebugSession();
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@chrome-crx/src/options/components/PermissionsTab.tsx` at line 305, The
`disableDebugEverywhere()` flow can report success even when clearing the
persistent auto-start state fails, leaving evidence recording able to resume
after restart. Update the `PermissionsTab` caller and/or the
`disableDebugEverywhere` helper so persistence is cleared successfully before
stopping the session, and surface any failure instead of swallowing it. Make
sure the UI only marks evidence recording as disabled after the persistent flag
has been cleared, using the `disableDebugEverywhere` and related
persistence-clearing logic as the main fix points.
There was a problem hiding this comment.
DuckPR reviewer: ocr
Model: anthropic/glm-5.2
OpenCodeReview found 45 issue(s) in this PR.
- 45 posted as inline comment(s)
- 0 shown in this summary
Warnings:
- [object Object]
- [object Object]
- [object Object]
- [object Object]
- [object Object]
- [object Object]
- [object Object]
- [object Object]
- [object Object]
- [object Object]
- [object Object]
- [object Object]
- [object Object]
- [object Object]
- [object Object]
- [object Object]
- [object Object]
- [object Object]
- [object Object]
- [object Object]
- [object Object]
- [object Object]
- [object Object]
- [object Object]
- [object Object]
- [object Object]
Inline comments shown in summary
chrome-crx/src/background/fileServerBridge.ts (L54-L57)
GitHub could not post this inline: Unprocessable Entity: "Line could not be resolved"
The file_server_ready handler only checks that url and token are strings via typeof, but does not verify they are non-empty. If the native host ever sends empty strings (e.g., due to a startup race condition or bug), url and token would be silently set to '', and fetchFileFromHost would then fail with a confusing 'file server not ready' error rather than catching the malformed message early. Consider adding an emptiness check (e.g., message.url && message.token) to reject invalid messages defensively.
Suggested change
Before:
if (typeof message.url === 'string' && typeof message.token === 'string') {
url = message.url;
token = message.token;
}
After:
if (message.url && typeof message.url === 'string' && message.token && typeof message.token === 'string') {
url = message.url;
token = message.token;
}
chrome-crx/e2e/helpers/realAgent.ts (L103-L103)
GitHub could not post this inline: Unprocessable Entity: "Line could not be resolved"
This line is excessively long (~130+ characters), making it hard to read. Consider breaking it across multiple lines for better maintainability.
Suggested change
Before:
const ctx: RealAgentCtx = { result, targetPage, sidepanel, serviceWorker: opts.serviceWorker, finalUrl, sdDebug, llmCalls: result.llmCalls };
After:
const ctx: RealAgentCtx = {
result,
targetPage,
sidepanel,
serviceWorker: opts.serviceWorker,
finalUrl,
sdDebug,
llmCalls: result.llmCalls,
};
chrome-crx/src/debug/diagnostics.ts (L30-L30)
GitHub could not post this inline: Unprocessable Entity: "Line could not be resolved"
The RuleFn type declares (events, artifacts) => DiagnosisFinding[], but none of the 13 rule functions accept artifacts, and the wrapper arrow functions in RULES silently drop the artifacts argument: (events) => nativeToolTimeoutNoCrxStart(events). This means artifacts are never actually passed to any rule, making the artifacts parameter in diagnose() and the RuleFn type misleading. Either:
- If artifacts are not needed by any current rule, simplify
RuleFnto(events: DebugBaseEvent[]) => DiagnosisFinding[]to avoid confusion. - If future rules are intended to use artifacts, pass them through:
(events, artifacts) => nativeToolTimeoutNoCrxStart(events, artifacts).
Suggested change
Before:
type RuleFn = (events: DebugBaseEvent[], artifacts: DebugArtifact[]) => DiagnosisFinding[];
After:
// Option 1: Simplify if artifacts are never used
type RuleFn = (events: DebugBaseEvent[]) => DiagnosisFinding[];
// Option 2: If artifacts will be used, update rule signatures and wrappers accordingly
chrome-crx/src/debug/diagnostics.ts (L208-L208)
GitHub could not post this inline: Unprocessable Entity: "Line could not be resolved"
The ts field is typed as string (ISO timestamp per DebugBaseEvent). The comparison n.ts <= s.ts performs lexicographic string comparison, which works for ISO 8601 format but is fragile. If timestamps could ever be in a different format, this would silently produce wrong results. Consider comparing via Date.parse() or using the monotonicMs field if available for reliable numeric comparison.
Suggested change
Before:
const priorNav = navs.find((n) => n.ts <= s.ts && idsMatch(n, s, 'tabId'));
After:
const priorNav = navs.find(
(n) => new Date(n.ts).getTime() <= new Date(s.ts).getTime() && idsMatch(n, s, 'tabId')
);
chrome-crx/src/debug/recorder.ts (L509-L510)
GitHub could not post this inline: Unprocessable Entity: "Line could not be resolved"
Bug: When input.content is a TypedArray (e.g. Uint8Array) that is a view into a larger backing ArrayBuffer with byteOffset > 0, slicing .buffer from offset 0 copies the wrong region of memory. The stored artifact content will contain bytes from the beginning of the backing buffer rather than the actual view contents.
Fix: Use TypedArray.prototype.slice() which respects byteOffset and byteLength, instead of slicing the underlying .buffer. This also correctly handles the case where the view doesn't start at offset 0.
Suggested change
Before:
} else if (ArrayBuffer.isView(input.content)) {
storedContent = (input.content as ArrayBufferView).buffer.slice(0, MAX_ARTIFACT_BYTES);
After:
} else if (ArrayBuffer.isView(input.content)) {
const view = input.content as Uint8Array;
storedContent = view.slice(0, MAX_ARTIFACT_BYTES);
chrome-crx/src/debug/recorder.ts (L480-L487)
GitHub could not post this inline: Unprocessable Entity: "Line could not be resolved"
Nested ternary expressions are prohibited per the project's code quality standards — they hurt readability and are hard to debug. This is a triple-nested ternary. Refactor to use a helper function or an if/else block for clarity.
Suggested change
Before:
const shaInput =
input.content != null
? isBinary
? input.content
: typeof input.content === 'string'
? input.content
: JSON.stringify(input.content)
: JSON.stringify(redactedData);
After:
function computeShaInput(): string | ArrayBuffer | Uint8Array {
const content = input.content;
if (content == null) return JSON.stringify(redactedData);
if (content instanceof ArrayBuffer || ArrayBuffer.isView(content)) return content;
if (typeof content === 'string') return content;
return JSON.stringify(content);
}
const shaInput = computeShaInput();
chrome-crx/src/debug/ringBuffer.ts (L50-L51)
GitHub could not post this inline: Unprocessable Entity: "Line could not be resolved"
If a negative value is passed for n, Math.min(n, total) returns a negative number, and new Array(take) will throw RangeError: Invalid array length. Consider clamping take to 0 to guard against invalid input.
Suggested change
Before:
const take = Math.min(n, total);
if (take === 0) return [];
After:
const take = Math.max(0, Math.min(n, total));
if (take === 0) return [];
chrome-crx/src/debug/schema.ts (L29-L45)
GitHub could not post this inline: Unprocessable Entity: "Line could not be resolved"
The DebugDomain type union and the DEBUG_DOMAINS array are maintained independently, which means a new domain added to one could be forgotten in the other without any compile-time error. Consider deriving the type from the array to guarantee they stay in sync, e.g.:
export const DEBUG_DOMAINS = [
'sidepanel', 'agent-loop', ...
] as const;
export type DebugDomain = (typeof DEBUG_DOMAINS)[number];This eliminates the risk of drift entirely while preserving all existing usages (the array still works for iteration, and the type still works for annotations).
Suggested change
Before:
export const DEBUG_DOMAINS: readonly DebugDomain[] = [
'sidepanel',
'agent-loop',
'lightning',
'tool-runtime',
'permission',
'tab-state',
'cdp',
'input',
'screenshot-ref',
'javascript',
'workflow-recording',
'native-bridge',
'cli',
'mcp-server',
'diagnosis'
];
After:
export const DEBUG_DOMAINS = [
'sidepanel',
'agent-loop',
'lightning',
'tool-runtime',
'permission',
'tab-state',
'cdp',
'input',
'screenshot-ref',
'javascript',
'workflow-recording',
'native-bridge',
'cli',
'mcp-server',
'diagnosis',
] as const;
export type DebugDomain = (typeof DEBUG_DOMAINS)[number];
chrome-crx/src/debug/redaction.ts (L228-L234)
GitHub could not post this inline: Unprocessable Entity: "Line could not be resolved"
The _type parameter is accepted but never used in the function body. Since the caller (recorder.ts:470) passes input.type, this is either an incomplete implementation (type-specific redaction logic was planned but not yet implemented) or a dead parameter. If type-specific redaction is planned for the future, consider adding a TODO comment. If not, consider removing the parameter to avoid confusion.
Suggested change
Before:
export function redactArtifactData(
data: unknown,
_type: DebugArtifactType,
opts: RedactionOptions = {}
): ArtifactRedactionResult {
return { data: redactValue(data, opts), redacted: true };
}
After:
// TODO: implement type-specific redaction based on `_type` (e.g., screenshots vs text payloads)
export function redactArtifactData(
data: unknown,
_type: DebugArtifactType,
opts: RedactionOptions = {}
): ArtifactRedactionResult {
return { data: redactValue(data, opts), redacted: true };
}
chrome-crx/src/debug/redaction.ts (L198-L200)
GitHub could not post this inline: Unprocessable Entity: "Line could not be resolved"
The seen.delete(value) in the finally block removes the object reference after processing each branch. While this allows the same object to appear in multiple sibling positions without false-positive circular detection, it also means that a true circular reference through a shared sibling reference could slip through and cause unbounded recursion. The depth > maxDepth guard prevents an infinite loop, but for large maxDepth values with deeply aliased graphs, this could still cause a stack overflow. Consider whether removing the delete (accepting false positives for shared sibling references) or keeping it is the right trade-off for debug payload safety.
Suggested change
Before:
} finally {
seen.delete(value as object);
}
After:
// Option 1: Remove the delete to guarantee circular safety (may over-report [Circular] for shared DAG nodes):
// // (remove finally block entirely)
//
// Option 2: Keep current behavior but document the trade-off:
} finally {
seen.delete(value as object); // allows sibling reuse but relies on maxDepth for cycle safety
}
...and 35 more inline comment(s).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6255bc6b09
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if err := protocol.SendMessage(os.Stdout, map[string]any{ | ||
| "type": "file_server_ready", | ||
| "url": fs.BaseURL(), | ||
| "token": token, | ||
| }); err != nil { |
There was a problem hiding this comment.
Delay file_server_ready until after the port handshake
In the startup path this notification is sent before server.Run() starts reading stdin, so it is always delivered before the CRX can receive the pong that completes connect(). I checked chrome-crx/src/background/nativeHost.ts and the temporary handshake listener ignores every non-pong message, then the real handleNativeMessage listener is only installed after the handshake, so this one-shot file_server_ready carrying the URL/token is dropped and fetchFileFromHost() remains permanently “not ready” for that connection.
Useful? React with 👍 / 👎.
| ].join('\n'); | ||
| } | ||
|
|
||
| const MAX_BUNDLE_BYTES = 32 * 1024 * 1024; // 32MB — Chrome allows 64MiB CRX→host; leave headroom |
There was a problem hiding this comment.
Cap debug collect responses at the native protocol limit
This serializer allows a bundle up to 32 MiB, but the Go side still uses protocol.ReadMessage for Chrome stdin/UDS responses and rejects any frame over 1 MiB (chrome-native-host/internal/protocol/chrome.go:21). In a normal debug session with screenshot artifacts or enough events, superduck_debug_collect can therefore send a >1 MiB tool_response, causing the native host/CLI read to fail with “message too large” instead of writing the bundle.
Useful? React with 👍 / 👎.
| "type": "upload_file", | ||
| "filename": filename, | ||
| "mimeType": mimeType, | ||
| "data": data, |
There was a problem hiding this comment.
Avoid sending file bytes through the 1 MiB UDS frame
Putting the entire file byte slice into the control message means JSON encodes it as base64 and sends it through the same protocol.ReadMessage path that rejects payloads over 1 MiB. As a result, superduck push-file --path fails for files around 750 KiB or larger even though the new file store advertises a 64 MiB max, so the large-file transfer path is unusable for most non-trivial files.
Useful? React with 👍 / 👎.
| if (!isDebugEnabled()) { | ||
| sendToolResponse({ content: JSON.stringify({ error: 'no active debug session' }) }); | ||
| return; |
There was a problem hiding this comment.
Return an error when debug collect has no active session
When recording is disabled this sends a successful tool response whose content is just {"error":"no active debug session"}. The CLI then calls debugbundle.ParseBundleJSON, and Go's JSON decoder accepts that object as an empty Bundle, so superduck debug collect writes a misleading nosession bundle instead of failing and telling the user to start/enable debug recording.
Useful? React with 👍 / 👎.
|
Run failed. View the logs →
|
6255bc6 to
3c41ad9
Compare

Summary
为 SuperDuck 建设本地开发阶段可用的 debug 系统,解决跨域排障困难的问题。
核心变更
Debug Session 模型
debugSessionId将 CRX、native-host、MCP、CLI 的事件串联Evidence Bundle 导出
CLI + MCP 接入
superduck debug start/stop/status/collect/doctorCLI 子命令superduck_debug_status/collect/snapshotMCP tools,agent 可一键收集诊断材料File Server Bridge
E2E 测试
文件结构
Test plan
bun run test— vitest 单元测试通过(623 tests passed)bun run build:edge— 扩展构建通过🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
push-file) with supporting transfer behavior.Bug Fixes
Documentation