@gotcos/glasses-server 6.99.33 → 7.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/.env.example +18 -0
  2. package/CHANGELOG.md +417 -0
  3. package/README.md +3 -1
  4. package/managed-runtime-contract.json +3 -0
  5. package/package.json +1 -1
  6. package/server/index.ts +29 -7
  7. package/server/lib/agent-session-output-images.ts +627 -49
  8. package/server/lib/agent-session-store.ts +24 -11
  9. package/server/lib/agent-session-tool-images.ts +224 -0
  10. package/server/lib/agent-session-turns.ts +19 -2
  11. package/server/lib/api-auth.ts +6 -0
  12. package/server/lib/ask-work-plan.ts +3 -1
  13. package/server/lib/calendar-source.ts +491 -0
  14. package/server/lib/codex-bridge.ts +4 -1
  15. package/server/lib/codex-run-ledger.ts +29 -0
  16. package/server/lib/context-builder.ts +2 -1
  17. package/server/lib/drop-p2-runtime.ts +1 -1
  18. package/server/lib/drop-store.ts +10 -4
  19. package/server/lib/learning-needs-you.ts +24 -7
  20. package/server/lib/live-events.ts +533 -0
  21. package/server/lib/local-path-redact.ts +272 -0
  22. package/server/lib/meeting-ask-orientation.ts +40 -6
  23. package/server/lib/meeting-ask-runner.ts +30 -10
  24. package/server/lib/morning-brief-runtime.ts +4 -2
  25. package/server/lib/native-mac-caller.ts +29 -0
  26. package/server/lib/ping-engine.ts +1 -1
  27. package/server/lib/ping-sources.ts +11 -6
  28. package/server/lib/preview-content.ts +172 -0
  29. package/server/lib/preview-error-code.ts +10 -0
  30. package/server/lib/preview-folders-setting.ts +133 -0
  31. package/server/lib/preview-log-throttle.ts +18 -0
  32. package/server/lib/preview-thumbs.ts +139 -0
  33. package/server/lib/secret-path-guard.ts +80 -0
  34. package/server/lib/session-files-setting.ts +88 -0
  35. package/server/lib/session-links.ts +280 -0
  36. package/server/lib/thread-turn-queue.ts +10 -0
  37. package/server/lib/today-brief.ts +119 -28
  38. package/server/lib/web-memories-bridge.ts +3 -1
  39. package/server/lib/work-activity.ts +56 -6
  40. package/server/lib/work-session-activity.ts +260 -0
  41. package/server/lib/your-move-count.ts +9 -3
  42. package/server/routes/agent-session-bindings.ts +13 -0
  43. package/server/routes/agent-session-stream.ts +16 -0
  44. package/server/routes/agent-sessions.ts +146 -11
  45. package/server/routes/ask-work-plan.ts +4 -1
  46. package/server/routes/claude-sessions.ts +5 -1
  47. package/server/routes/health.ts +35 -0
  48. package/server/routes/live-events.ts +329 -0
  49. package/server/routes/permission-broker.ts +69 -5
  50. package/server/routes/session-preview.ts +200 -0
  51. package/server/routes/thread-turn-queue.ts +207 -3
  52. package/server/routes/today-brief.ts +44 -9
  53. package/server/routes/welcome-context.ts +23 -3
  54. package/server/routes/work-board.ts +9 -2
  55. package/server/web-control/app.css +40 -1
  56. package/server/web-control/app.js +1309 -56
  57. package/server/web-control/control.css +25 -0
  58. package/server/web-control/index.html +7 -2
  59. package/server/web-control/memory-review.js +100 -0
  60. package/server/web-control/panes/cos-chat.css +42 -3
  61. package/server/web-control/panes/cos-chat.js +267 -15
  62. package/server/web-control/panes/meetings.css +18 -2
  63. package/server/web-control/panes/meetings.js +24 -8
  64. package/server/web-control/panes/memories.css +17 -2
  65. package/server/web-control/panes/memories.js +127 -42
  66. package/server/web-control/panes/messages.js +21 -2
  67. package/server/web-control/panes/preview.js +318 -0
  68. package/server/web-control/panes/questions.js +28 -8
  69. package/server/web-control/panes/reader-media.js +409 -23
  70. package/server/web-control/panes/sessions.css +32 -3
  71. package/server/web-control/panes/settings.js +96 -5
  72. package/server/web-control/panes/today-actions.css +11 -0
  73. package/server/web-control/panes/today-actions.js +45 -12
  74. package/server/web-control/panes/work.css +10 -0
  75. package/server/web-control/panes/work.js +10 -2
  76. package/server/web-control/shell.js +17 -14
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The content parts of the preview contract (preview engine phase 1, PLAN 4c). Pure transforms of bytes that were
3
+ * already authorized and read by the resolver (agent-session-output-images.ts `previewFile`), plus the headers each
4
+ * part is served with. Every part has a FIXED type; nothing a file says about itself decides how it is served.
5
+ *
6
+ * raw octet-stream attachment, sandbox CSP
7
+ * inline a PDF for the browser's viewer (images go through the normalizer instead)
8
+ * text JSON: a UTF-8 window, 512 KiB per call, `more` for "load more" (HTML: up to 8 MiB, one call)
9
+ * table JSON: CSV or TSV rows, 200 a page, 50 columns, 5,000 rows in all
10
+ * html the page as text/html under `CSP: sandbox` (no scripts, opaque origin, no network), full length (32 MiB)
11
+ * html-run the page under `CSP: sandbox allow-scripts` and `connect-src 'none'`: scripts run, nothing is fetched
12
+ * Both HTML parts also carry a CSP <meta> injected at the top of the document (PLAN 6), so the policy holds
13
+ * even where a response header is lost on the way (a proxy, a saved copy, a future srcdoc frame).
14
+ * thumb PNG from Quick Look (`qlmanage -t`), see preview-thumbs.ts
15
+ */
16
+
17
+ export const TEXT_WINDOW_MAX = 512 * 1024
18
+ /** HTML is shown full length (PLAN 5: the 512 KiB cut is gone), up to the raw-file cap. */
19
+ export const HTML_PREVIEW_MAX = 32 * 1024 * 1024
20
+ export const TABLE_PAGE_ROWS = 200
21
+ export const TABLE_COLUMNS_MAX = 50
22
+ export const TABLE_ROWS_MAX = 5000
23
+ export const TABLE_CELL_CHARS = 1000
24
+ export const TABLE_BYTES_MAX = 16 * 1024 * 1024
25
+
26
+ export type PreviewPart = 'raw' | 'inline' | 'text' | 'table' | 'html' | 'html-run' | 'thumb'
27
+ export const PREVIEW_PARTS: readonly PreviewPart[] = ['raw', 'inline', 'text', 'table', 'html', 'html-run', 'thumb']
28
+
29
+ /** Scripts off: nothing runs, nothing is fetched (a remote image is a tracking pixel and leaks the phone's address). */
30
+ export const HTML_SAFE_CSP = "sandbox; default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:; form-action 'none'; base-uri 'none'; frame-ancestors 'self'"
31
+ /**
32
+ * "Run scripts" (Miles decision 3): inline script runs in a unique opaque origin (no allow-same-origin), with no
33
+ * network (`connect-src 'none'`, no remote script, image, style or font), no forms, no popups and no top navigation
34
+ * (sandbox tokens other than allow-scripts are absent). It can read only itself, never the COS cookie or API.
35
+ */
36
+ export const HTML_RUN_CSP = "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; img-src data: blob:; style-src 'unsafe-inline'; font-src data:; media-src data: blob:; connect-src 'none'; form-action 'none'; base-uri 'none'; frame-ancestors 'self'"
37
+ export const DERIVED_CSP = "sandbox; default-src 'none'"
38
+ /**
39
+ * The policies injected as a <meta> (a meta CSP cannot carry `sandbox` or `frame-ancestors`; the header and the frame's
40
+ * own sandbox attribute carry those). Two policies are both enforced, so a page's own looser meta never widens these.
41
+ */
42
+ export const HTML_SAFE_META_CSP = "default-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; font-src data:; form-action 'none'; base-uri 'none'"
43
+ export const HTML_RUN_META_CSP = "default-src 'none'; script-src 'unsafe-inline'; img-src data: blob:; style-src 'unsafe-inline'; font-src data:; media-src data: blob:; connect-src 'none'; form-action 'none'; base-uri 'none'"
44
+
45
+ /**
46
+ * 7.0.5 canary 16 (Chrome 156 and WebKit 26.6, real viewer and route): with scripts on, a page script reached the
47
+ * network through WebRTC (5 STUN UDP datagrams in each). `connect-src 'none'` does not cover WebRTC and Chrome ignores
48
+ * `webrtc 'block'`. This guard, run before any page script, makes the RTC constructors non-configurable `undefined` on
49
+ * the window: 0 datagrams in both browsers. It is not a promise of "no network" (the words never say that).
50
+ */
51
+ export const HTML_RUN_RTC_NAMES = ['RTCPeerConnection', 'webkitRTCPeerConnection', 'RTCDataChannel', 'RTCSessionDescription', 'RTCIceCandidate'] as const
52
+ export const HTML_RUN_RTC_GUARD = '<script>(function(){var n=' + JSON.stringify(HTML_RUN_RTC_NAMES) +
53
+ ';for(var i=0;i<n.length;i++){try{Object.defineProperty(window,n[i],{value:undefined,writable:false,enumerable:false,configurable:false})}catch(e){}}})();</script>'
54
+
55
+ /**
56
+ * The page with a CSP <meta> as the first thing the parser sees after any BOM, leading whitespace, comments and the
57
+ * doctype (a tag before the doctype would put the page in quirks mode). Nothing before that point can load anything.
58
+ * Bytes are spliced, never re-encoded, so a page in another charset is not changed beyond the inserted tag.
59
+ */
60
+ export function withCspMeta(bytes: Buffer, part: 'html' | 'html-run'): Buffer {
61
+ const policy = part === 'html-run' ? HTML_RUN_META_CSP : HTML_SAFE_META_CSP
62
+ // Run scripts: the WebRTC guard is the first script the page has (canary 16d), right after the policy.
63
+ const tag = Buffer.from(`<meta http-equiv="Content-Security-Policy" content="${policy}">` + (part === 'html-run' ? HTML_RUN_RTC_GUARD : ''), 'utf8')
64
+ // The prefix is ASCII in every case that matters; latin1 keeps one char per byte so offsets are byte offsets.
65
+ const head = bytes.subarray(0, 64 * 1024).toString('latin1')
66
+ let at = head.charCodeAt(0) === 0xef && head.charCodeAt(1) === 0xbb && head.charCodeAt(2) === 0xbf ? 3 : 0
67
+ for (let guard = 0; guard < 64; guard++) {
68
+ const ws = /^[\t\n\f\r ]*/.exec(head.slice(at))![0].length
69
+ at += ws
70
+ if (head.startsWith('<!--', at)) {
71
+ const end = head.indexOf('-->', at + 4)
72
+ if (end < 0) break
73
+ at = end + 3; continue
74
+ }
75
+ if (/^<!doctype[\s>]/i.test(head.slice(at, at + 10))) {
76
+ const end = head.indexOf('>', at)
77
+ if (end < 0) break
78
+ at = end + 1; continue
79
+ }
80
+ break
81
+ }
82
+ return Buffer.concat([bytes.subarray(0, at), tag, bytes.subarray(at)])
83
+ }
84
+
85
+ function asciiName(name: string): string { return name.replace(/[^A-Za-z0-9._ -]/g, '_').replace(/^[ .]+/, '') || 'file' }
86
+
87
+ /** Headers for each part. `nosniff`, `no-store` and same-origin CORP on every one. */
88
+ export function previewHeaders(part: PreviewPart, name: string): Record<string, string> {
89
+ const base: Record<string, string> = { 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'private, no-store', 'Cross-Origin-Resource-Policy': 'same-origin' }
90
+ const disposition = (kind: 'inline' | 'attachment') => `${kind}; filename="${asciiName(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`
91
+ switch (part) {
92
+ case 'raw': return { ...base, 'Content-Type': 'application/octet-stream', 'Content-Disposition': disposition('attachment'), 'Content-Security-Policy': DERIVED_CSP }
93
+ case 'inline': return { ...base, 'Content-Type': 'application/pdf', 'Content-Disposition': disposition('inline') }
94
+ case 'html': return { ...base, 'Content-Type': 'text/html; charset=utf-8', 'Content-Disposition': 'inline', 'Content-Security-Policy': HTML_SAFE_CSP, 'Referrer-Policy': 'no-referrer' }
95
+ case 'html-run': return { ...base, 'Content-Type': 'text/html; charset=utf-8', 'Content-Disposition': 'inline', 'Content-Security-Policy': HTML_RUN_CSP, 'Referrer-Policy': 'no-referrer' }
96
+ case 'thumb': return { ...base, 'Content-Type': 'image/png', 'Content-Disposition': 'inline', 'Content-Security-Policy': DERIVED_CSP }
97
+ default: return { ...base, 'Content-Type': 'application/json; charset=utf-8', 'Content-Security-Policy': DERIVED_CSP }
98
+ }
99
+ }
100
+
101
+ /** A NUL byte in the first 8 KiB: a binary file, never shown as words. */
102
+ export function looksBinary(bytes: Buffer): boolean { return bytes.subarray(0, 8192).includes(0) }
103
+
104
+ function charStart(bytes: Buffer, at: number): number {
105
+ let i = Math.max(0, Math.min(at, bytes.length))
106
+ while (i < bytes.length && i > 0 && (bytes[i] & 0xc0) === 0x80) i--
107
+ return i
108
+ }
109
+
110
+ /** A UTF-8 window that never splits a character. `end` is where the next window starts. */
111
+ export function textWindow(bytes: Buffer, offset: number, limit: number, max = TEXT_WINDOW_MAX): { text: string; offset: number; end: number; size: number; more: boolean } {
112
+ const want = Math.max(1, Math.min(max, Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : max))
113
+ let start = Number.isFinite(offset) && offset > 0 ? Math.floor(offset) : 0
114
+ if (start > bytes.length) start = bytes.length
115
+ // A start inside a character moves forward to the next one.
116
+ while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++
117
+ let end = Math.min(bytes.length, start + want)
118
+ if (end < bytes.length) { const c = charStart(bytes, end); end = c > start ? c : end }
119
+ let text = bytes.subarray(start, end).toString('utf8')
120
+ if (start === 0 && text.charCodeAt(0) === 0xfeff) text = text.slice(1)
121
+ return { text, offset: start, end, size: bytes.length, more: end < bytes.length }
122
+ }
123
+
124
+ /**
125
+ * RFC 4180 rows from CSV (or TSV): quoted fields, doubled quotes, CR LF or LF, a newline inside quotes. Bounded: at most
126
+ * TABLE_ROWS_MAX rows are ever read, TABLE_COLUMNS_MAX columns per row and TABLE_CELL_CHARS per cell are kept.
127
+ */
128
+ export function tableRows(text: string, delimiter: ',' | '\t', limitRows = TABLE_ROWS_MAX): { rows: string[][]; complete: boolean; columnsCut: boolean } {
129
+ const rows: string[][] = []
130
+ let row: string[] = [], cell = '', quoted = false, columnsCut = false, i = 0
131
+ const push = () => {
132
+ if (row.length < TABLE_COLUMNS_MAX) row.push(cell.length > TABLE_CELL_CHARS ? cell.slice(0, TABLE_CELL_CHARS - 1) + '…' : cell)
133
+ else columnsCut = true
134
+ cell = ''
135
+ }
136
+ const endRow = () => { push(); rows.push(row); row = [] }
137
+ if (text.charCodeAt(0) === 0xfeff) i = 1
138
+ for (; i < text.length; i++) {
139
+ const c = text[i]
140
+ if (quoted) {
141
+ if (c === '"') { if (text[i + 1] === '"') { if (cell.length <= TABLE_CELL_CHARS) cell += '"'; i++ } else quoted = false }
142
+ else if (cell.length <= TABLE_CELL_CHARS) cell += c
143
+ continue
144
+ }
145
+ if (c === '"' && cell === '') { quoted = true; continue }
146
+ if (c === delimiter) { push(); continue }
147
+ if (c === '\r' || c === '\n') {
148
+ if (c === '\r' && text[i + 1] === '\n') i++
149
+ endRow()
150
+ if (rows.length >= limitRows) return { rows, complete: i + 1 >= text.length, columnsCut }
151
+ continue
152
+ }
153
+ if (cell.length <= TABLE_CELL_CHARS) cell += c
154
+ }
155
+ if (cell !== '' || row.length) endRow()
156
+ return { rows, complete: true, columnsCut }
157
+ }
158
+
159
+ /** One page of a table: the header row always first, then rows [offset, offset + TABLE_PAGE_ROWS). */
160
+ export function tablePage(bytes: Buffer, name: string, offset: number): { header: string[]; rows: string[][]; offset: number; next: number; more: boolean; capped: boolean; columnsCut: boolean } {
161
+ const text = bytes.subarray(0, TABLE_BYTES_MAX).toString('utf8')
162
+ const delimiter = /\.tsv$/i.test(name) ? '\t' : ','
163
+ const parsed = tableRows(text, delimiter, TABLE_ROWS_MAX + 1)
164
+ const all = parsed.rows
165
+ const header = all[0] ?? []
166
+ const body = all.slice(1, TABLE_ROWS_MAX + 1)
167
+ const start = Math.max(0, Math.min(body.length, Number.isFinite(offset) && offset > 0 ? Math.floor(offset) : 0))
168
+ const page = body.slice(start, start + TABLE_PAGE_ROWS)
169
+ const next = start + page.length
170
+ const capped = !parsed.complete || bytes.length > TABLE_BYTES_MAX || all.length > TABLE_ROWS_MAX
171
+ return { header, rows: page, offset: start, next, more: next < body.length, capped, columnsCut: parsed.columnsCut }
172
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * 7.0.4 QA: what a failed preview read is logged as: the error's code (ENOENT, EACCES, ...) or its class, never its
3
+ * message. An fs error message carries the path, and a path never goes into a log line.
4
+ */
5
+ export function previewErrorCode(error: unknown): string {
6
+ const code = (error as { code?: unknown } | null)?.code
7
+ if (typeof code === 'string' && /^[A-Z0-9_]{1,40}$/.test(code)) return code
8
+ const name = (error as { name?: unknown } | null)?.name
9
+ return typeof name === 'string' && /^[A-Za-z]{1,40}$/.test(name) ? name : 'unknown'
10
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Extra folders COS may preview from (preview engine phase 1, Miles 2026-11-08, decision 2).
3
+ *
4
+ * A link card or a sent file is served only when its canonical path is inside the session's own workspace OR inside
5
+ * one of the folders named here. Nothing else ever opens. The list starts EMPTY.
6
+ *
7
+ * Stored the way "Show session files, screenshots and images" is (lib/session-files-setting.ts): a small private JSON
8
+ * file in the data folder, written atomically with mode 0600. Only a file that is not there (first use) gets the
9
+ * default (no folders). A saved file that is damaged, unreadable or not this schema fails CLOSED: no extra folders,
10
+ * source 'damaged'. Settings says so, and saving from it writes a good file again.
11
+ *
12
+ * Each folder must be absolute (or ~/...), exist, be a directory, and must not be or contain a broad root: /, /Users,
13
+ * home, ~/Documents, ~/Library, ~/.cos-glasses, /Volumes, the temp roots (the same exact roots a session workspace may
14
+ * never be). A folder inside ~/Library, ~/.cos-glasses, ~/.ssh, ~/.codex, ~/.claude or ~/.config is refused too. Stored canonical (realpath), and
15
+ * re-checked on every read, so a folder deleted or replaced by a link later simply stops counting.
16
+ */
17
+ import { readFileSync, realpathSync, statSync } from 'node:fs'
18
+ import { homedir } from 'node:os'
19
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
20
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
21
+ import { dataPath } from './data-dir.js'
22
+ import { securePrivateDirectory } from './secure-user-config.js'
23
+ import { broadWorkspaceRoots } from './agent-session-output-images.js'
24
+
25
+ export const PREVIEW_FOLDERS_FILE = 'web-preview-folders.json'
26
+ export const PREVIEW_FOLDERS_MAX = 20
27
+ const FOLDER_CHARS_MAX = 1024
28
+
29
+ export type PreviewFoldersSource = 'settings' | 'default' | 'damaged'
30
+ export interface PreviewFoldersSetting { folders: string[]; source: PreviewFoldersSource }
31
+ export type FolderRefusal = 'not_absolute' | 'missing' | 'not_folder' | 'broad' | 'private' | 'too_long' | 'too_many'
32
+ export interface FolderError { index: number; code: FolderRefusal; message: string }
33
+ export interface PreviewFoldersStore {
34
+ read(): PreviewFoldersSetting
35
+ set(input: unknown): { ok: true; setting: PreviewFoldersSetting } | { ok: false; errors: FolderError[] }
36
+ }
37
+
38
+ const MESSAGES: Record<FolderRefusal, string> = {
39
+ not_absolute: 'Use a full folder path, like ~/cos-worktrees.',
40
+ missing: 'That folder is not on this Mac.',
41
+ not_folder: 'That is a file, not a folder.',
42
+ broad: 'That folder is too broad. Name a project folder inside it.',
43
+ private: 'COS never previews from that folder.',
44
+ too_long: 'That path is too long.',
45
+ too_many: `At most ${PREVIEW_FOLDERS_MAX} folders.`,
46
+ }
47
+
48
+ /** Folders under home that are never a preview folder, nor anything inside them. */
49
+ export const PRIVATE_HOME_FOLDERS: readonly string[] = ['Library', '.cos-glasses', '.ssh', '.codex', '.claude', '.config']
50
+
51
+ function within(root: string, path: string): boolean {
52
+ const rel = relative(root, path)
53
+ return rel === '' || (!rel.startsWith('..' + sep) && rel !== '..' && !isAbsolute(rel))
54
+ }
55
+
56
+ /** One folder as typed, checked. Pure apart from the filesystem reads. */
57
+ export function checkFolder(input: unknown, home = homedir()): { ok: true; path: string } | { ok: false; code: FolderRefusal } {
58
+ if (typeof input !== 'string') return { ok: false, code: 'not_absolute' }
59
+ const raw = input.trim()
60
+ if (!raw) return { ok: false, code: 'not_absolute' }
61
+ if (raw.length > FOLDER_CHARS_MAX || /[\u0000-\u001f\u007f]/.test(raw)) return { ok: false, code: 'too_long' }
62
+ const expanded = raw === '~' ? home : raw.startsWith('~/') ? join(home, raw.slice(2)) : raw
63
+ if (!isAbsolute(expanded)) return { ok: false, code: 'not_absolute' }
64
+ let canonical: string
65
+ try { canonical = realpathSync(resolve(expanded)) } catch { return { ok: false, code: 'missing' } }
66
+ try { if (!statSync(canonical).isDirectory()) return { ok: false, code: 'not_folder' } } catch { return { ok: false, code: 'missing' } }
67
+ // Never a broad root, and never a folder that CONTAINS one (/ contains everything, /private contains /private/tmp).
68
+ for (const broad of broadWorkspaceRoots(home)) if (within(canonical, broad)) return { ok: false, code: 'broad' }
69
+ // QA 7.0.5: the AI apps' own folders hold their sign-in tokens and settings (~/.codex/auth.json, ~/.claude, ~/.config).
70
+ for (const p of PRIVATE_HOME_FOLDERS) {
71
+ const base = join(home, p)
72
+ let real = base
73
+ try { real = realpathSync(base) } catch { /* absent: the literal still counts */ }
74
+ if (within(base, canonical) || within(real, canonical)) return { ok: false, code: 'private' }
75
+ }
76
+ return { ok: true, path: canonical }
77
+ }
78
+
79
+ /** How Settings shows a folder: ~/x inside home, the full path elsewhere. */
80
+ export function folderDisplay(path: string, home = homedir()): string {
81
+ const rel = relative(home, path)
82
+ return rel && !rel.startsWith('..') && !isAbsolute(rel) ? '~/' + rel.split(sep).join('/') : path
83
+ }
84
+
85
+ export function createPreviewFoldersSetting(deps: { file: () => string; home?: () => string }): PreviewFoldersStore {
86
+ const home = () => deps.home?.() ?? homedir()
87
+ type Saved = { kind: 'absent' } | { kind: 'damaged' } | { kind: 'saved'; folders: string[] }
88
+ const saved = (): Saved => {
89
+ let text: string
90
+ try { text = readFileSync(deps.file(), 'utf8') } catch (e) {
91
+ return (e as NodeJS.ErrnoException | null)?.code === 'ENOENT' ? { kind: 'absent' } : { kind: 'damaged' }
92
+ }
93
+ try {
94
+ const raw = JSON.parse(text) as { schema?: unknown; folders?: unknown }
95
+ if (raw && typeof raw === 'object' && raw.schema === 1 && Array.isArray(raw.folders) && raw.folders.length <= PREVIEW_FOLDERS_MAX
96
+ && raw.folders.every(f => typeof f === 'string')) return { kind: 'saved', folders: raw.folders as string[] }
97
+ } catch { /* damaged */ }
98
+ return { kind: 'damaged' }
99
+ }
100
+ const store: PreviewFoldersStore = {
101
+ read() {
102
+ const s = saved()
103
+ if (s.kind === 'damaged') return { folders: [], source: 'damaged' }
104
+ if (s.kind === 'absent') return { folders: [], source: 'default' }
105
+ // Re-checked on every read: a folder that is gone, became a link to a broad root, or is now private never counts.
106
+ const out: string[] = []
107
+ for (const f of s.folders) {
108
+ const c = checkFolder(f, home())
109
+ if (c.ok && c.path === f && !out.includes(c.path)) out.push(c.path)
110
+ }
111
+ return { folders: out, source: 'settings' }
112
+ },
113
+ set(input) {
114
+ if (!Array.isArray(input)) return { ok: false, errors: [{ index: -1, code: 'not_absolute', message: 'Send { folders: [ ... ] }.' }] }
115
+ if (input.length > PREVIEW_FOLDERS_MAX) return { ok: false, errors: [{ index: -1, code: 'too_many', message: MESSAGES.too_many }] }
116
+ const errors: FolderError[] = [], folders: string[] = []
117
+ input.forEach((f, index) => {
118
+ const c = checkFolder(f, home())
119
+ if (!c.ok) errors.push({ index, code: c.code, message: MESSAGES[c.code] })
120
+ else if (!folders.includes(c.path)) folders.push(c.path)
121
+ })
122
+ if (errors.length) return { ok: false, errors }
123
+ const file = deps.file()
124
+ securePrivateDirectory(dirname(file))
125
+ durableAtomicWriteFileSync(file, JSON.stringify({ schema: 1, folders }) + '\n', { mode: 0o600 })
126
+ return { ok: true, setting: store.read() }
127
+ },
128
+ }
129
+ return store
130
+ }
131
+
132
+ /** The server's list, in the data folder. */
133
+ export const previewFoldersSetting = createPreviewFoldersSetting({ file: () => dataPath(PREVIEW_FOLDERS_FILE) })
@@ -0,0 +1,18 @@
1
+ /**
2
+ * 7.0.4 QA, shared in 7.0.7: the one burst limit for preview refusal and failure logs. The 7.0.4 file and image routes
3
+ * (routes/agent-sessions.ts logPreviewOutcome) and the 7.0.5 preview engine routes (routes/session-preview.ts
4
+ * logPreviewRefusal) both log through it. The same line logs once per 10 seconds, with the count it held back. A line is
5
+ * only fixed words (a route, a reason code, a provider, a part, a kind, a detail code), so the keys stay few.
6
+ */
7
+ const lastLogged = new Map<string, { at: number; quiet: number }>()
8
+ export const PREVIEW_LOG_WINDOW_MS = 10_000
9
+
10
+ export function warnPreviewThrottled(key: string, line: string, now = Date.now()): void {
11
+ const last = lastLogged.get(key)
12
+ if (last && now - last.at < PREVIEW_LOG_WINDOW_MS) { last.quiet++; return }
13
+ lastLogged.set(key, { at: now, quiet: 0 })
14
+ console.warn(`${line}${last?.quiet ? ` (+${last.quiet} like it)` : ''}`)
15
+ }
16
+
17
+ /** Tests only: forget the burst window, so each case logs. */
18
+ export function resetPreviewLogThrottle(): void { lastLogged.clear() }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Card thumbnails from Quick Look (preview engine phase 1, PLAN 6 and 7). `qlmanage -t` is built into macOS and runs
3
+ * the Mac's own sandboxed Quick Look extensions; it drew page 1 of a PDF, DOCX, PPTX, XLSX and HTML headless in 0.26 to
4
+ * 0.48 s in the plan's canary, and its HTML thumbnail runs no script and loads no remote image (canary 3).
5
+ *
6
+ * - Runs on a PRIVATE COPY of bytes the resolver already validated (no TOCTOU on the original), in a 0700 temp folder,
7
+ * with an argument array, `nice`, a 10 s timeout and SIGKILL past it. Two at once, machine-wide in this process.
8
+ * - The output must be a real PNG under 4 MiB, or there is no thumbnail.
9
+ * - Cached on disk under the data folder, keyed by the file's identity (device, inode, size, mtime, ctime), never by
10
+ * path. 128 MiB and 7 days at most; the oldest go first. A hit refreshes its age.
11
+ * - When Quick Look cannot run (no window server, a launchd context that refuses it, a timeout), the answer is
12
+ * `unavailable` and the card says there is no preview image. It never fails the card.
13
+ */
14
+ import { execFile } from 'node:child_process'
15
+ import { createHash } from 'node:crypto'
16
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs'
17
+ import { tmpdir } from 'node:os'
18
+ import { extname, join } from 'node:path'
19
+ import { dataPath } from './data-dir.js'
20
+ import { previewErrorCode } from './preview-error-code.js'
21
+
22
+ export const THUMB_SIZE = 256
23
+ export const THUMB_TIMEOUT_MS = 10_000
24
+ export const THUMB_CONCURRENCY = 2
25
+ export const THUMB_CACHE_BYTES = 128 * 1024 * 1024
26
+ export const THUMB_CACHE_AGE_MS = 7 * 24 * 60 * 60_000
27
+ export const THUMB_OUTPUT_MAX = 4 * 1024 * 1024
28
+ const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
29
+
30
+ export interface Identity { dev: number; ino: number; size: number; mtimeMs: number; ctimeMs: number }
31
+ export type RunResult = { code: number | null; signal: string | null; timedOut: boolean }
32
+ /** QA 7.0.5: why a thumbnail was not made, for the route's one log line (never a path or a file name). */
33
+ export type ThumbFailure = 'timeout' | 'exit' | 'no_png' | 'bad_png' | `error_${string}`
34
+ export interface ThumbDeps {
35
+ dir(): string
36
+ tmp(): string
37
+ now(): number
38
+ run(file: string, args: string[], timeoutMs: number): Promise<RunResult>
39
+ }
40
+
41
+ /**
42
+ * 7.0.5 canary 4: Quick Look reads an HTML page with no declared charset as Latin-1, so a UTF-8 page thumbnails as
43
+ * mojibake ("Café"). In the PRIVATE copy only, and only when the page declares no charset and its bytes are valid
44
+ * UTF-8, `<meta charset="utf-8">` goes right after `<head>` (or first, when there is no head). Proven under launchd.
45
+ */
46
+ export function withUtf8Charset(bytes: Buffer, name: string): Buffer {
47
+ if (!/\.html?$/i.test(name)) return bytes
48
+ const head = bytes.subarray(0, 4096).toString('latin1')
49
+ if (/<meta\b[^>]*\bcharset\s*=/i.test(head)) return bytes
50
+ try { new TextDecoder('utf-8', { fatal: true }).decode(bytes) } catch { return bytes }
51
+ const tag = Buffer.from('<meta charset="utf-8">')
52
+ const m = /<head\b[^>]*>/i.exec(head)
53
+ const at = m ? m.index + m[0].length : 0
54
+ return Buffer.concat([bytes.subarray(0, at), tag, bytes.subarray(at)])
55
+ }
56
+
57
+ export function thumbKey(identity: Identity): string {
58
+ // v2 (7.0.5): HTML thumbnails drawn before the charset fix were mojibake; a new key draws them again.
59
+ return createHash('sha256').update(JSON.stringify(['thumb-v2', THUMB_SIZE, identity.dev, identity.ino, identity.size, identity.mtimeMs, identity.ctimeMs])).digest('hex')
60
+ }
61
+
62
+ const realRun: ThumbDeps['run'] = (file, args, timeoutMs) => new Promise(resolve => {
63
+ const child = execFile(file, args, { timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: 256 * 1024, windowsHide: true }, err => {
64
+ const e = err as (NodeJS.ErrnoException & { killed?: boolean; signal?: string; code?: number | string }) | null
65
+ resolve({ code: e ? (typeof e.code === 'number' ? e.code : null) : 0, signal: e?.signal ?? null, timedOut: !!e?.killed })
66
+ })
67
+ child.stdin?.end()
68
+ })
69
+
70
+ export function createThumbnailer(deps: ThumbDeps) {
71
+ let active = 0
72
+ const evict = (dir: string) => {
73
+ let entries: Array<{ path: string; size: number; at: number }> = []
74
+ try {
75
+ entries = readdirSync(dir).filter(n => /^[a-f0-9]{64}\.png$/.test(n)).map(n => {
76
+ const p = join(dir, n), st = statSync(p)
77
+ return { path: p, size: st.size, at: st.mtimeMs }
78
+ })
79
+ } catch { return }
80
+ const now = deps.now()
81
+ entries.sort((a, b) => a.at - b.at)
82
+ let total = entries.reduce((n, e) => n + e.size, 0)
83
+ for (const e of entries) {
84
+ if (now - e.at <= THUMB_CACHE_AGE_MS && total <= THUMB_CACHE_BYTES) continue
85
+ try { rmSync(e.path, { force: true }); total -= e.size } catch { /* next */ }
86
+ }
87
+ }
88
+ return {
89
+ get active() { return active },
90
+ /** The cached or new PNG for these bytes, or why there is none. */
91
+ async thumbnail(bytes: Buffer, name: string, identity: Identity): Promise<{ status: 'ok'; path: string } | { status: 'busy' } | { status: 'unavailable'; reason: ThumbFailure }> {
92
+ const dir = deps.dir()
93
+ const key = thumbKey(identity)
94
+ const cached = join(dir, key + '.png')
95
+ try {
96
+ const st = statSync(cached)
97
+ if (deps.now() - st.mtimeMs <= THUMB_CACHE_AGE_MS && st.size > PNG.length) {
98
+ const t = new Date(deps.now()); try { utimesSync(cached, t, t) } catch { /* age only */ }
99
+ return { status: 'ok', path: cached }
100
+ }
101
+ } catch { /* not cached */ }
102
+ if (active >= THUMB_CONCURRENCY) return { status: 'busy' }
103
+ active++
104
+ const work = mkdtempSync(join(deps.tmp(), 'cos-preview-'))
105
+ try {
106
+ chmodSync(work, 0o700)
107
+ // A fixed base name with the original extension: Quick Look picks its generator by extension, and no
108
+ // file name a session wrote reaches the command line.
109
+ const ext = extname(name).toLowerCase().replace(/[^.a-z0-9]/g, '').slice(0, 10)
110
+ const copy = join(work, 'preview' + ext)
111
+ writeFileSync(copy, withUtf8Charset(bytes, name), { mode: 0o600 })
112
+ const out = join(work, 'out'); mkdirSync(out, { mode: 0o700 })
113
+ const result = await deps.run('/usr/bin/nice', ['-n', '10', '/usr/bin/qlmanage', '-t', '-s', String(THUMB_SIZE), '-o', out, copy], THUMB_TIMEOUT_MS)
114
+ if (result.timedOut || result.code !== 0) return { status: 'unavailable', reason: result.timedOut ? 'timeout' : 'exit' }
115
+ const produced = join(out, 'preview' + ext + '.png')
116
+ let png: Buffer
117
+ try { png = readFileSync(produced) } catch { return { status: 'unavailable', reason: 'no_png' } }
118
+ if (png.length <= PNG.length || png.length > THUMB_OUTPUT_MAX || !png.subarray(0, 8).equals(PNG)) return { status: 'unavailable', reason: 'bad_png' }
119
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
120
+ const staged = join(dir, key + '.' + process.pid + '.tmp')
121
+ writeFileSync(staged, png, { mode: 0o600 })
122
+ renameSync(staged, cached)
123
+ evict(dir)
124
+ return { status: 'ok', path: cached }
125
+ } catch (error) { return { status: 'unavailable', reason: ('error_' + previewErrorCode(error)) as ThumbFailure } }
126
+ finally {
127
+ active--
128
+ try { rmSync(work, { recursive: true, force: true }) } catch { /* temp */ }
129
+ }
130
+ },
131
+ }
132
+ }
133
+
134
+ export const previewThumbnailer = createThumbnailer({
135
+ dir: () => dataPath('preview-thumbs'),
136
+ tmp: () => tmpdir(),
137
+ now: Date.now,
138
+ run: realRun,
139
+ })
@@ -0,0 +1,80 @@
1
+ // One shared secret-name deny guard for every route that serves a local file (7.0.4 S2).
2
+ //
3
+ // WHY. The preview routes (sent output files, sent output images, Markdown output images and
4
+ // path-backed tool images) authorize by workspace containment: a file inside the session's exact
5
+ // workspace is servable. A repo holds its own secrets inside that workspace (`.env`, a service
6
+ // account `*-key.json`, a `.pem`), and a session can name one in a SendUserFile call or a Markdown
7
+ // image just as easily as a render. Containment says "this session may show files from here", not
8
+ // "every file here is safe to put in a browser". This guard is the second question.
9
+ //
10
+ // HOW. By name, case-insensitively, and on both the path as given and its canonical (realpath) form, so a symlink
11
+ // with an innocent name that points at a secret is still refused. File-name patterns (`.env*`, `*-key.json`, `id_rsa`,
12
+ // ...) are matched against the BASE NAME only. Folders are matched only against an exact list of secret folder names
13
+ // (`.ssh`, `.gnupg`, `secrets`) and a few exact folder and file pairs (`.aws/config`, `.kube/config`, ...). 7.0.4 QA:
14
+ // the first cut ran every file pattern against every folder, so a repo under `credentials-portal/`, `.envoy/` or
15
+ // `id_verification/` had every preview refused. A path that cannot be canonicalised (gone, permission) is judged by its
16
+ // lexical form only; the callers refuse unreadable files separately.
17
+ //
18
+ // LIMITS. A name-based guard cannot see a COPY of a secret under another name, and realpath does not resolve a HARD
19
+ // link. The sent-file routes, which can serve any text, refuse a file with more than one hard link for that reason
20
+ // (agent-session-output-images.ts); images go through the JPEG normaliser, so a text secret cannot leave as one.
21
+ //
22
+ // The name is fixed (`isDeniedSecretPath`): another branch codes against it.
23
+ import { realpathSync } from 'node:fs'
24
+ import { isAbsolute, resolve } from 'node:path'
25
+
26
+ /** File names refused, matched against the base name only. Lower-cased input. */
27
+ const SECRET_NAME: readonly RegExp[] = [
28
+ /^\.env/, // .env*: .env, .env.local, .envrc, .env_backup …
29
+ /-key\.json$/, // service-account keys (gcp-key.json, firebase-admin-key.json)
30
+ /^service-account.*\.json$/, // service-account.json, service-account-prod.json
31
+ /^\.cos-profile/, // the COS profile and its backups
32
+ /^id_(rsa|dsa|ecdsa|ed25519|xmss)/, /^id_[a-z0-9_-]*(\.pub)?$/, // SSH keys and look-alikes (id_rsa.png), not id_card.png
33
+ /\.pem$/, /\.p12$/, /\.pfx$/, /\.key$/, // certificates and private keys
34
+ /^authkey_.*\.p8$/, // App Store Connect API keys (AuthKey_ABC123.p8)
35
+ /^credentials/, // credentials, credentials.json, credentials.csv; also .aws/credentials
36
+ /^\.git-credentials$/, // plaintext git tokens
37
+ /^tokens?\.json$/, // OAuth token stores (token.json)
38
+ /^secrets?\.ya?ml$/, // secrets.yaml, secrets.yml
39
+ /^\.netrc$/, /^\.npmrc$/, /^\.pypirc$/, /^\.pgpass$/, /^\.htpasswd$/, // package, network and database credentials
40
+ /\.keychain/, // login.keychain-db and friends
41
+ /^hook-token$/, // 7.0.5 (preview engine, PLAN 6): the COS hook token beside the glasses data
42
+ // QA 7.0.5: a link card puts a file one tap away, so these join the list.
43
+ /^hubspot\.config\.ya?ml$/, // the HubSpot CLI's portal keys
44
+ /^\.mcp\.json$/, // MCP server definitions, which often carry tokens
45
+ /^\.credentials\.json$/, // Claude Code's sign-in (~/.claude/.credentials.json)
46
+ ]
47
+ /** Folders whose whole subtree is refused, matched exactly as a folder name. Lower-cased. */
48
+ const SECRET_DIRECTORY = new Set(['.ssh', '.gnupg', 'secrets'])
49
+ /** A file refused only inside this folder (`config` alone is an ordinary name). Lower-cased `folder/file`. */
50
+ const SECRET_PAIR = new Set(['.aws/credentials', '.aws/config', '.docker/config.json', '.kube/config', 'gh/hosts.yml', '.codex/auth.json'])
51
+
52
+ function segments(path: string): string[] {
53
+ return path.split(/[\\/]+/).filter(Boolean).map(s => s.toLowerCase())
54
+ }
55
+
56
+ function deniedLexically(path: string): boolean {
57
+ const parts = segments(path)
58
+ const name = parts[parts.length - 1]
59
+ if (!name) return false
60
+ if (SECRET_NAME.some(re => re.test(name))) return true
61
+ if (parts.length >= 2 && SECRET_PAIR.has(parts[parts.length - 2] + '/' + name)) return true
62
+ return parts.slice(0, -1).some(folder => SECRET_DIRECTORY.has(folder))
63
+ }
64
+
65
+ /**
66
+ * True when this local path, or the file it canonically resolves to, may hold secrets and must
67
+ * never be served to a browser. A relative path is resolved against the process directory first;
68
+ * callers pass absolute paths. Never throws.
69
+ */
70
+ export function isDeniedSecretPath(absPath: string): boolean {
71
+ if (typeof absPath !== 'string' || !absPath) return false
72
+ const path = isAbsolute(absPath) ? absPath : resolve(absPath)
73
+ if (deniedLexically(path)) return true
74
+ let canonical: string
75
+ try { canonical = realpathSync(path) } catch { return false }
76
+ return deniedLexically(canonical)
77
+ }
78
+
79
+ /** The card text a projection shows in place of a denied file. No name beyond the base name travels with it. */
80
+ export const SECRET_FILE_NOTICE = 'Not shown: this file may hold secrets'
@@ -0,0 +1,88 @@
1
+ /**
2
+ * "Show files and images your sessions make" (6.99.35). Miles asked where a person opts in to the images (6.99.28) and
3
+ * files (SendUserFile) Control Web shows from a session's project folder. This is that switch, stored the way the
4
+ * Today summary switch is (lib/today-brief.ts): a small private JSON file in the data folder, written atomically.
5
+ *
6
+ * - DEFAULT ON, as both features shipped.
7
+ * - The Settings toggle (Control Web, Settings, Privacy) saves { schema: 1, enabled } to web-session-files-settings.json.
8
+ * - COS_WEB_SESSION_FILES=0 (or false, off) on the Mac forces it OFF, whatever was saved. No value forces it on: an
9
+ * environment that wants previews leaves the variable unset.
10
+ * - Read on every request, so a change applies at once and a restart is never needed.
11
+ * - QA 7.0.0: only a file that is not there (first use) gets the default. A saved file that is damaged, unreadable or
12
+ * not this schema fails CLOSED (source 'damaged', off): it may have said off, and a privacy switch never turns itself
13
+ * back on. The Settings row says so, and saving from it writes a good file again.
14
+ *
15
+ * Preview engine phase 1: link cards (outputLinks) and GET /api/preview/... ride the same switch, default on.
16
+ *
17
+ * Off means: GET .../output-images/:id/content, .../output-files/:id/content and .../tool-images/:id/content answer 403 previews_off before any
18
+ * transcript or file is read, and the session detail's turns carry `previewsOff: true` instead of image and file
19
+ * descriptors (withoutPreviews), so not even a name reaches the browser.
20
+ */
21
+ import { readFileSync } from 'node:fs'
22
+ import { dirname } from 'node:path'
23
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
24
+ import { dataPath } from './data-dir.js'
25
+ import { readSwitch } from './env-switch.js'
26
+ import { securePrivateDirectory } from './secure-user-config.js'
27
+ import type { SessionTurn } from './agent-session-turns.js'
28
+ import { withoutLinkCards } from './session-links.js'
29
+
30
+ export const SESSION_FILES_SETTINGS_FILE = 'web-session-files-settings.json'
31
+ export type SessionFilesSource = 'env' | 'settings' | 'default' | 'damaged'
32
+ export interface SessionFilesSetting { enabled: boolean; source: SessionFilesSource; saved: boolean | null }
33
+ export interface SessionFilesSettingStore {
34
+ read(): SessionFilesSetting
35
+ set(enabled: boolean): SessionFilesSetting
36
+ }
37
+
38
+ export function createSessionFilesSetting(deps: { file: () => string; env?: () => Record<string, string | undefined> }): SessionFilesSettingStore {
39
+ const env = deps.env ?? (() => process.env)
40
+ // 'absent' only for ENOENT (first use). Any other read error, bad JSON or a foreign shape is 'damaged'.
41
+ type Saved = { kind: 'absent' } | { kind: 'damaged' } | { kind: 'saved'; enabled: boolean }
42
+ const saved = (): Saved => {
43
+ let text: string
44
+ try { text = readFileSync(deps.file(), 'utf8') } catch (e) {
45
+ return (e as NodeJS.ErrnoException | null)?.code === 'ENOENT' ? { kind: 'absent' } : { kind: 'damaged' }
46
+ }
47
+ try {
48
+ const raw = JSON.parse(text) as unknown
49
+ if (raw && typeof raw === 'object' && (raw as { schema?: unknown }).schema === 1 && typeof (raw as { enabled?: unknown }).enabled === 'boolean') {
50
+ return { kind: 'saved', enabled: (raw as { enabled: boolean }).enabled }
51
+ }
52
+ } catch { /* damaged */ }
53
+ return { kind: 'damaged' }
54
+ }
55
+ const store: SessionFilesSettingStore = {
56
+ read() {
57
+ const s = saved()
58
+ const value = s.kind === 'saved' ? s.enabled : null
59
+ // Only an off value forces anything; readSwitch with an ON fallback is false exactly for 0, false and off.
60
+ if (!readSwitch(env().COS_WEB_SESSION_FILES, true)) return { enabled: false, source: 'env', saved: value }
61
+ if (s.kind === 'saved') return { enabled: s.enabled, source: 'settings', saved: s.enabled }
62
+ if (s.kind === 'damaged') return { enabled: false, source: 'damaged', saved: null }
63
+ return { enabled: true, source: 'default', saved: null }
64
+ },
65
+ set(enabled) {
66
+ const file = deps.file()
67
+ securePrivateDirectory(dirname(file))
68
+ durableAtomicWriteFileSync(file, JSON.stringify({ schema: 1, enabled }) + '\n', { mode: 0o600 })
69
+ return store.read()
70
+ },
71
+ }
72
+ return store
73
+ }
74
+
75
+ /** The server's switch, in the data folder. */
76
+ export const sessionFilesSetting = createSessionFilesSetting({ file: () => dataPath(SESSION_FILES_SETTINGS_FILE) })
77
+
78
+ /** Turns with every image and file descriptor (tool-result and prompt images too) removed and `previewsOff` marked where there were any. Pure. */
79
+ export function withoutPreviews(turns: SessionTurn[]): SessionTurn[] {
80
+ return turns.map(t => {
81
+ if (!t.outputImages?.length && !t.outputFiles?.length && !t.toolImages?.length && !t.toolImagesMore && !t.outputLinks?.length) return t
82
+ const { outputImages: _i, outputFiles: _f, toolImages: _t, toolImagesMore: _m, outputLinks: _l, ...rest } = t
83
+ // Preview engine phase 1: link cards ride this switch. Off, a card marker turns back into the link's words and the
84
+ // "N more linked files" sentence goes (QA 7.0.5), so not even a count reaches the browser. The Mac path was already
85
+ // gone from the text (session-links.ts).
86
+ return { ...rest, ...(_l?.length ? { text: withoutLinkCards(rest.text) } : {}), previewsOff: true as const }
87
+ })
88
+ }