| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/sha256" |
| 7 | "encoding/hex" |
| 8 | "encoding/json" |
| 9 | "errors" |
| 10 | "io" |
| 11 | "os" |
| 12 | "path/filepath" |
| 13 | "time" |
| 14 | |
| 15 | "reasonix/desktop/internal/browserops" |
| 16 | "reasonix/internal/config" |
| 17 | "reasonix/internal/extension/rpcwire" |
| 18 | ) |
| 19 | |
| 20 | func browserDiagnosticScope(hostID, sessionID string) string { |
| 21 | if hostID == "" || sessionID == "" { |
| 22 | return "" |
| 23 | } |
| 24 | data, _ := json.Marshal([]string{hostID, sessionID}) |
| 25 | hash := sha256.Sum256(data) |
| 26 | return hex.EncodeToString(hash[:]) |
| 27 | } |
| 28 | |
| 29 | func (a *App) browserDiagnosticExport(job *sessionExportJob) map[string]any { |
| 30 | out := map[string]any{ |
| 31 | "schemaVersion": 1, "capturedAt": time.Now().UTC(), "source": "desktop", |
| 32 | "toolEvidence": "Browser tool calls, results and errors are in the session commits; their prefix cutoff can precede this runtime capture.", |
| 33 | "coverage": "Host/page events are a bounded in-memory window, lost on shell restart. This is not a complete browsing history. Remote sessions include only the browser hosted by this desktop; remote CDP/browser logs are not collected. Legacy unattributed records are omitted.", |
| 34 | } |
| 35 | if job.browserScope == "" { |
| 36 | out["unavailable"] = "No fixed browser diagnostic identity for this session." |
| 37 | return out |
| 38 | } |
| 39 | a.browserExecMu.Lock() |
| 40 | ledger := a.browserOps |
| 41 | a.browserExecMu.Unlock() |
| 42 | var operations browserops.DiagnosticSnapshot |
| 43 | var err error |
| 44 | if ledger != nil { |
| 45 | operations = ledger.Diagnostics(job.browserScope) |
| 46 | } else { |
| 47 | operations, err = browserops.ReadDiagnostics(filepath.Join(config.MemoryUserDir(), "browser", "operations-v1.json"), job.browserScope) |
| 48 | } |
| 49 | if err != nil { |
| 50 | out["operations"] = map[string]any{"available": false, "reason": "Operation ledger is absent, unreadable, unsupported or exceeds the read budget."} |
| 51 | } else { |
| 52 | out["operations"] = operations |
| 53 | } |
| 54 | var host hostRequester |
| 55 | if a.hostShell != nil && a.hostShell.server != nil { |
| 56 | host = a.hostShell.server |
| 57 | } |
| 58 | out["host"] = captureBrowserHostDiagnostics(job.ctx, host, job.browserScope) |
| 59 | return out |
| 60 | } |
| 61 | |
| 62 | func captureBrowserHostDiagnostics(ctx context.Context, host hostRequester, scope string) any { |
| 63 | unavailable := func(kind, reason string) any { |
| 64 | return map[string]any{"available": false, "kind": kind, "reason": reason} |
| 65 | } |
| 66 | if host == nil || scope == "" { |
| 67 | return unavailable("host_unavailable", "Browser diagnostic host or fixed session identity is unavailable.") |
| 68 | } |
| 69 | ctx, cancel := context.WithTimeout(ctx, 3*time.Second) |
| 70 | defer cancel() |
| 71 | var raw json.RawMessage |
| 72 | if err := host.Request(ctx, "host/browser.exportDiagnostics", map[string]string{"scope": scope}, &raw); err != nil { |
| 73 | if ctx.Err() != nil { |
| 74 | return unavailable("capture_interrupted", "Browser diagnostic capture was cancelled or exceeded its three-second deadline.") |
| 75 | } |
| 76 | var response *rpcwire.ResponseError |
| 77 | if errors.As(err, &response) && response.Code == -32601 { |
| 78 | return unavailable("capability_unsupported", "This shell does not support browser diagnostic export.") |
| 79 | } |
| 80 | return unavailable("host_request_failed", "Browser diagnostic host failed or disconnected; no browser action was replayed.") |
| 81 | } |
| 82 | if len(raw) > 512<<10 || !json.Valid(raw) { |
| 83 | return unavailable("invalid_response", "Invalid or oversized host diagnostic response.") |
| 84 | } |
| 85 | var header struct { |
| 86 | Scope string `json:"scope"` |
| 87 | Available bool `json:"available"` |
| 88 | } |
| 89 | if json.Unmarshal(raw, &header) != nil || header.Scope != scope || !header.Available { |
| 90 | return unavailable("identity_unverified", "Host diagnostic response is unavailable or its session identity does not match.") |
| 91 | } |
| 92 | return raw |
| 93 | } |
| 94 | |
| 95 | // The remote endpoint intentionally accepts only its original observation |
| 96 | // fields. Append desktop evidence locally, preserving its streaming document |
| 97 | // and compatibility with older remote services (and their 64 KiB POST limit). |
| 98 | func appendBrowserDiagnosticSection(dst io.Writer, section []byte) error { |
| 99 | f, ok := dst.(*os.File) |
| 100 | if !ok { |
| 101 | return errors.New("diagnostic destination is not seekable") |
| 102 | } |
| 103 | end, err := f.Seek(0, io.SeekCurrent) |
| 104 | if err != nil { |
| 105 | return err |
| 106 | } |
| 107 | // Diagnostic producers emit a JSON object with bounded trailing whitespace. |
| 108 | start := max(int64(0), end-4096) |
| 109 | tail := make([]byte, end-start) |
| 110 | if _, err = f.ReadAt(tail, start); err != nil { |
| 111 | return err |
| 112 | } |
| 113 | i := len(tail) - 1 |
| 114 | for i >= 0 && (tail[i] == ' ' || tail[i] == '\n' || tail[i] == '\r' || tail[i] == '\t') { |
| 115 | i-- |
| 116 | } |
| 117 | if i < 0 || tail[i] != '}' { |
| 118 | return errors.New("remote diagnostics did not end in a JSON object") |
| 119 | } |
| 120 | first := make([]byte, min(end, 4096)) |
| 121 | if _, err = f.ReadAt(first, 0); err != nil { |
| 122 | return err |
| 123 | } |
| 124 | first = bytes.TrimSpace(first) |
| 125 | if len(first) == 0 || first[0] != '{' { |
| 126 | return errors.New("remote diagnostics is not a JSON object") |
| 127 | } |
| 128 | prefix := ",\n\"browserDiagnostics\":" |
| 129 | if start == 0 && bytes.Equal(bytes.TrimSpace(tail[:i]), []byte("{")) { |
| 130 | prefix = "\n\"browserDiagnostics\":" |
| 131 | } |
| 132 | if _, err = f.Seek(start+int64(i), io.SeekStart); err != nil { |
| 133 | return err |
| 134 | } |
| 135 | if _, err = f.Write(append(append([]byte(prefix), section...), []byte("\n}\n")...)); err != nil { |
| 136 | return err |
| 137 | } |
| 138 | position, err := f.Seek(0, io.SeekCurrent) |
| 139 | if err != nil { |
| 140 | return err |
| 141 | } |
| 142 | return f.Truncate(position) |
| 143 | } |
| 144 |