@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.
- package/.env.example +18 -0
- package/CHANGELOG.md +417 -0
- package/README.md +3 -1
- package/managed-runtime-contract.json +3 -0
- package/package.json +1 -1
- package/server/index.ts +29 -7
- package/server/lib/agent-session-output-images.ts +627 -49
- package/server/lib/agent-session-store.ts +24 -11
- package/server/lib/agent-session-tool-images.ts +224 -0
- package/server/lib/agent-session-turns.ts +19 -2
- package/server/lib/api-auth.ts +6 -0
- package/server/lib/ask-work-plan.ts +3 -1
- package/server/lib/calendar-source.ts +491 -0
- package/server/lib/codex-bridge.ts +4 -1
- package/server/lib/codex-run-ledger.ts +29 -0
- package/server/lib/context-builder.ts +2 -1
- package/server/lib/drop-p2-runtime.ts +1 -1
- package/server/lib/drop-store.ts +10 -4
- package/server/lib/learning-needs-you.ts +24 -7
- package/server/lib/live-events.ts +533 -0
- package/server/lib/local-path-redact.ts +272 -0
- package/server/lib/meeting-ask-orientation.ts +40 -6
- package/server/lib/meeting-ask-runner.ts +30 -10
- package/server/lib/morning-brief-runtime.ts +4 -2
- package/server/lib/native-mac-caller.ts +29 -0
- package/server/lib/ping-engine.ts +1 -1
- package/server/lib/ping-sources.ts +11 -6
- package/server/lib/preview-content.ts +172 -0
- package/server/lib/preview-error-code.ts +10 -0
- package/server/lib/preview-folders-setting.ts +133 -0
- package/server/lib/preview-log-throttle.ts +18 -0
- package/server/lib/preview-thumbs.ts +139 -0
- package/server/lib/secret-path-guard.ts +80 -0
- package/server/lib/session-files-setting.ts +88 -0
- package/server/lib/session-links.ts +280 -0
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +119 -28
- package/server/lib/web-memories-bridge.ts +3 -1
- package/server/lib/work-activity.ts +56 -6
- package/server/lib/work-session-activity.ts +260 -0
- package/server/lib/your-move-count.ts +9 -3
- package/server/routes/agent-session-bindings.ts +13 -0
- package/server/routes/agent-session-stream.ts +16 -0
- package/server/routes/agent-sessions.ts +146 -11
- package/server/routes/ask-work-plan.ts +4 -1
- package/server/routes/claude-sessions.ts +5 -1
- package/server/routes/health.ts +35 -0
- package/server/routes/live-events.ts +329 -0
- package/server/routes/permission-broker.ts +69 -5
- package/server/routes/session-preview.ts +200 -0
- package/server/routes/thread-turn-queue.ts +207 -3
- package/server/routes/today-brief.ts +44 -9
- package/server/routes/welcome-context.ts +23 -3
- package/server/routes/work-board.ts +9 -2
- package/server/web-control/app.css +40 -1
- package/server/web-control/app.js +1309 -56
- package/server/web-control/control.css +25 -0
- package/server/web-control/index.html +7 -2
- package/server/web-control/memory-review.js +100 -0
- package/server/web-control/panes/cos-chat.css +42 -3
- package/server/web-control/panes/cos-chat.js +267 -15
- package/server/web-control/panes/meetings.css +18 -2
- package/server/web-control/panes/meetings.js +24 -8
- package/server/web-control/panes/memories.css +17 -2
- package/server/web-control/panes/memories.js +127 -42
- package/server/web-control/panes/messages.js +21 -2
- package/server/web-control/panes/preview.js +318 -0
- package/server/web-control/panes/questions.js +28 -8
- package/server/web-control/panes/reader-media.js +409 -23
- package/server/web-control/panes/sessions.css +32 -3
- package/server/web-control/panes/settings.js +96 -5
- package/server/web-control/panes/today-actions.css +11 -0
- package/server/web-control/panes/today-actions.js +45 -12
- package/server/web-control/panes/work.css +10 -0
- package/server/web-control/panes/work.js +10 -2
- 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
|
+
}
|