@gotcos/glasses-server 6.24.2 → 6.24.4
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/CHANGELOG.md +58 -0
- package/README.md +10 -3
- package/package.json +1 -1
- package/server/index.ts +2 -1
- package/server/lib/claude-bridge.ts +19 -5
- package/server/lib/codex-bridge.ts +14 -5
- package/server/lib/context-builder.ts +1 -0
- package/server/lib/cursor-bridge.ts +9 -4
- package/server/lib/media-store.ts +139 -4
- package/server/lib/prompt-reference-boundary.ts +24 -0
- package/server/lib/quarantine-auto-recover.ts +30 -4
- package/server/lib/query-attachments.ts +62 -4
- package/server/lib/query-job-runtime.ts +8 -3
- package/server/lib/rich-media-safety.ts +324 -0
- package/server/routes/health.ts +15 -1
- package/server/routes/media.ts +46 -1
- package/server/routes/meeting.ts +2 -1
- package/server/routes/query.ts +5 -3
- package/server/routes/transcribe-stream.ts +15 -1
- package/shared/media-attachment.ts +84 -4
|
@@ -15,20 +15,28 @@
|
|
|
15
15
|
import {
|
|
16
16
|
MAX_ATTACHMENTS_PER_PROMPT,
|
|
17
17
|
isValidMediaId,
|
|
18
|
+
mediaCategoryOf,
|
|
18
19
|
parseMediaIdList,
|
|
19
20
|
type MediaAttachmentRef,
|
|
20
21
|
} from '../../shared/media-attachment.js'
|
|
21
22
|
import { getMediaStore, MediaStoreError } from './media-store.js'
|
|
22
23
|
import { strictBase64Decode, ImageSafetyError } from './image-safety.js'
|
|
23
24
|
import type { ModelImageInput } from './model-image-input.js'
|
|
25
|
+
import { formatAttachmentSourceData, type AttachmentSourceData } from './prompt-reference-boundary.js'
|
|
24
26
|
|
|
25
27
|
export interface ResolvedQueryAttachments {
|
|
26
28
|
inputs: ModelImageInput[]
|
|
27
29
|
refs: MediaAttachmentRef[]
|
|
28
30
|
/** Ids to associate with the final message when the run completes. */
|
|
29
31
|
ids: string[]
|
|
32
|
+
/** Provider-neutral quoted document data. Rebuilt at execution time; never
|
|
33
|
+
* persisted in a durable job or public attachment ref. */
|
|
34
|
+
promptBlock?: string
|
|
30
35
|
}
|
|
31
36
|
|
|
37
|
+
const MAX_MODEL_IMAGE_INPUTS = 12
|
|
38
|
+
const MAX_ATTACHMENT_PROMPT_CHARS = 60_000
|
|
39
|
+
|
|
32
40
|
export class QueryAttachmentError extends Error {
|
|
33
41
|
readonly status: number
|
|
34
42
|
readonly code: string
|
|
@@ -89,6 +97,11 @@ export async function resolveQueryAttachments(body: QueryImageBody): Promise<Res
|
|
|
89
97
|
: undefined
|
|
90
98
|
|
|
91
99
|
const inputs: ModelImageInput[] = []
|
|
100
|
+
const refs: MediaAttachmentRef[] = []
|
|
101
|
+
const resolvedIds: string[] = []
|
|
102
|
+
const sourceData: AttachmentSourceData[] = []
|
|
103
|
+
const optionalDocumentImages: Array<{ path: string; attachment: MediaAttachmentRef }> = []
|
|
104
|
+
let promptChars = 0
|
|
92
105
|
|
|
93
106
|
// Reject over-limit requests BEFORE the shared parser caps them. Silent
|
|
94
107
|
// truncation would run a successful vision query while dropping a user's
|
|
@@ -107,8 +120,39 @@ export async function resolveQueryAttachments(body: QueryImageBody): Promise<Res
|
|
|
107
120
|
// 1. Durable attachment ids.
|
|
108
121
|
const ids = parseMediaIdList(body.attachmentIds)
|
|
109
122
|
for (const id of ids) {
|
|
110
|
-
const
|
|
111
|
-
|
|
123
|
+
const resolved = store.resolveModelDerivatives(id, clientQueueItemId)
|
|
124
|
+
const category = mediaCategoryOf(resolved.record.ref)
|
|
125
|
+
refs.push(resolved.record.ref)
|
|
126
|
+
resolvedIds.push(id)
|
|
127
|
+
if (category === 'image') {
|
|
128
|
+
inputs.push({ path: resolved.originalPath, attachment: resolved.record.ref, deleteAfterRun: false })
|
|
129
|
+
} else if (category === 'video') {
|
|
130
|
+
for (const path of resolved.imagePaths) {
|
|
131
|
+
inputs.push({ path, attachment: resolved.record.ref, deleteAfterRun: false })
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
// PDF page images are an optional layout aid. Text remains canonical;
|
|
135
|
+
// use only the remaining image budget instead of dropping user photos
|
|
136
|
+
// or making a text-readable PDF fail because it has many pages.
|
|
137
|
+
for (const path of resolved.imagePaths) optionalDocumentImages.push({ path, attachment: resolved.record.ref })
|
|
138
|
+
if (resolved.text !== undefined) {
|
|
139
|
+
const room = Math.max(0, MAX_ATTACHMENT_PROMPT_CHARS - promptChars)
|
|
140
|
+
const content = resolved.text.slice(0, room)
|
|
141
|
+
const truncated = content.length < resolved.text.length || resolved.record.ref.truncated === true
|
|
142
|
+
sourceData.push({
|
|
143
|
+
id,
|
|
144
|
+
label: resolved.record.ref.label ?? 'Attached document',
|
|
145
|
+
mime: resolved.record.ref.mime,
|
|
146
|
+
content,
|
|
147
|
+
...(truncated ? { truncated: true } : {}),
|
|
148
|
+
})
|
|
149
|
+
promptChars += content.length
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const requiredVisualCount = inputs.filter(input => mediaCategoryOf(input.attachment) !== 'document').length
|
|
154
|
+
if (requiredVisualCount > MAX_MODEL_IMAGE_INPUTS) {
|
|
155
|
+
throw new QueryAttachmentError(400, 'too_many_attachment_frames', `max ${MAX_MODEL_IMAGE_INPUTS} image/video frames per prompt`)
|
|
112
156
|
}
|
|
113
157
|
|
|
114
158
|
// 2. Legacy base64 — ingested through the SAME store (no bypass).
|
|
@@ -118,12 +162,26 @@ export async function resolveQueryAttachments(body: QueryImageBody): Promise<Res
|
|
|
118
162
|
const ref = await store.ingestImage({ bytes, kind: 'user_photo', sessionId })
|
|
119
163
|
const { path } = store.resolveUsable(ref.id)
|
|
120
164
|
inputs.push({ path, attachment: ref, deleteAfterRun: false })
|
|
165
|
+
refs.push(ref)
|
|
166
|
+
resolvedIds.push(ref.id)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// PDF page renders are optional aids. Add them only after all required
|
|
170
|
+
// photo and video inputs so a PDF can never evict a user's visual source.
|
|
171
|
+
const remaining = Math.max(0, MAX_MODEL_IMAGE_INPUTS - inputs.length)
|
|
172
|
+
for (const item of optionalDocumentImages.slice(0, remaining)) {
|
|
173
|
+
inputs.push({ path: item.path, attachment: item.attachment, deleteAfterRun: false })
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (inputs.length > MAX_MODEL_IMAGE_INPUTS) {
|
|
177
|
+
throw new QueryAttachmentError(400, 'too_many_attachment_frames', `max ${MAX_MODEL_IMAGE_INPUTS} image/video frames per prompt`)
|
|
121
178
|
}
|
|
122
179
|
|
|
123
180
|
return {
|
|
124
181
|
inputs,
|
|
125
|
-
refs
|
|
126
|
-
ids:
|
|
182
|
+
refs,
|
|
183
|
+
ids: resolvedIds,
|
|
184
|
+
...(sourceData.length > 0 ? { promptBlock: formatAttachmentSourceData(sourceData) } : {}),
|
|
127
185
|
}
|
|
128
186
|
} catch (err) {
|
|
129
187
|
if (err instanceof QueryAttachmentError) throw err
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type ModelPreference,
|
|
19
19
|
} from '../../shared/model-preference.js'
|
|
20
20
|
import { mergeMediaAttachmentRefs } from '../../shared/media-attachment.js'
|
|
21
|
+
import { attachmentHistoryPrefix, defaultAttachmentRequest } from '../../shared/media-attachment.js'
|
|
21
22
|
import {
|
|
22
23
|
findExchangesByJobIdentity,
|
|
23
24
|
flushConversationToDisk,
|
|
@@ -99,9 +100,11 @@ async function projectPublicConversationTerminal(
|
|
|
99
100
|
|
|
100
101
|
const existing = findExchangesByJobIdentity(request.sessionId, identity)
|
|
101
102
|
const existingAssistant = existing.find(exchange => exchange.role === 'assistant')
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
const userContent =
|
|
103
|
+
const attachmentPrefix = attachmentHistoryPrefix(request.attachmentRefs)
|
|
104
|
+
const defaultRequest = defaultAttachmentRequest(request.attachmentRefs)
|
|
105
|
+
const userContent = attachmentPrefix
|
|
106
|
+
? `${attachmentPrefix} ${request.query || defaultRequest}`
|
|
107
|
+
: request.query
|
|
105
108
|
const requestIds = new Set(request.attachmentRefs.map(ref => ref.id))
|
|
106
109
|
const outputAttachments = job.attachments.filter(ref => !requestIds.has(ref.id))
|
|
107
110
|
const existingOutputAttachments = existingAssistant?.attachments?.filter(ref => !requestIds.has(ref.id))
|
|
@@ -281,6 +284,8 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
281
284
|
: {}),
|
|
282
285
|
clientJobId: request.clientJobId,
|
|
283
286
|
generation: request.generation,
|
|
287
|
+
requestAttachments: resolvedAttachments.refs,
|
|
288
|
+
attachmentPromptBlock: resolvedAttachments.promptBlock,
|
|
284
289
|
sessionLockHeld: true,
|
|
285
290
|
},
|
|
286
291
|
)
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// Strict document/video validation and derivative generation for user uploads.
|
|
2
|
+
// Paths and filenames never enter the public attachment contract. Inputs are
|
|
3
|
+
// bounded bytes from the authenticated binary route; no URL/path ingestion.
|
|
4
|
+
|
|
5
|
+
import { spawn } from 'node:child_process'
|
|
6
|
+
import {
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from 'node:fs'
|
|
13
|
+
import { tmpdir } from 'node:os'
|
|
14
|
+
import { extname, join } from 'node:path'
|
|
15
|
+
import type { MediaMime } from '../../shared/media-attachment.js'
|
|
16
|
+
|
|
17
|
+
export const MAX_RICH_MEDIA_BYTES = 64 * 1024 * 1024
|
|
18
|
+
export const MAX_DOCUMENT_TEXT_CHARS = 100_000
|
|
19
|
+
export const MAX_VIDEO_DURATION_MS = 20 * 60_000
|
|
20
|
+
export const MAX_DERIVATIVE_IMAGES = 8
|
|
21
|
+
const PROCESS_STDERR_MAX = 8_192
|
|
22
|
+
const PROCESS_TIMEOUT_MS = 30_000
|
|
23
|
+
|
|
24
|
+
let richMediaCapabilityCache: { pdf: boolean; video: boolean } | null = null
|
|
25
|
+
|
|
26
|
+
export type RichMediaErrorCode =
|
|
27
|
+
| 'unsupported_attachment_format'
|
|
28
|
+
| 'attachment_too_large'
|
|
29
|
+
| 'corrupt_attachment'
|
|
30
|
+
| 'attachment_processing_unavailable'
|
|
31
|
+
| 'attachment_processing_failed'
|
|
32
|
+
| 'video_too_long'
|
|
33
|
+
|
|
34
|
+
export class RichMediaSafetyError extends Error {
|
|
35
|
+
constructor(readonly code: RichMediaErrorCode, message: string) {
|
|
36
|
+
super(message)
|
|
37
|
+
this.name = 'RichMediaSafetyError'
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PreparedDocument {
|
|
42
|
+
category: 'document'
|
|
43
|
+
mime: Extract<MediaMime, 'text/plain' | 'text/markdown' | 'text/csv' | 'application/json' | 'application/pdf'>
|
|
44
|
+
original: Buffer
|
|
45
|
+
extractedText: string
|
|
46
|
+
textTruncated: boolean
|
|
47
|
+
pageImages: Buffer[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PreparedVideo {
|
|
51
|
+
category: 'video'
|
|
52
|
+
mime: Extract<MediaMime, 'video/mp4' | 'video/quicktime'>
|
|
53
|
+
original: Buffer
|
|
54
|
+
width: number
|
|
55
|
+
height: number
|
|
56
|
+
durationMs: number
|
|
57
|
+
frames: Buffer[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type PreparedRichMedia = PreparedDocument | PreparedVideo
|
|
61
|
+
|
|
62
|
+
async function executableReady(command: string, args: string[]): Promise<boolean> {
|
|
63
|
+
return new Promise(resolve => {
|
|
64
|
+
let settled = false
|
|
65
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
66
|
+
const finish = (ready: boolean) => {
|
|
67
|
+
if (settled) return
|
|
68
|
+
settled = true
|
|
69
|
+
if (timer) clearTimeout(timer)
|
|
70
|
+
resolve(ready)
|
|
71
|
+
}
|
|
72
|
+
let proc: ReturnType<typeof spawn>
|
|
73
|
+
try {
|
|
74
|
+
proc = spawn(command, args, { stdio: 'ignore' })
|
|
75
|
+
} catch {
|
|
76
|
+
finish(false)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
timer = setTimeout(() => {
|
|
80
|
+
proc.kill('SIGKILL')
|
|
81
|
+
finish(false)
|
|
82
|
+
}, 2_000)
|
|
83
|
+
timer.unref?.()
|
|
84
|
+
proc.once('error', () => finish(false))
|
|
85
|
+
proc.once('close', code => finish(code === 0))
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Public capability only—never paths. Cached because health is polled often. */
|
|
90
|
+
export async function getRichMediaProcessingCapabilities(): Promise<{ pdf: boolean; video: boolean }> {
|
|
91
|
+
if (richMediaCapabilityCache) return { ...richMediaCapabilityCache }
|
|
92
|
+
const [pdftotext, pdftoppm, ffmpeg, ffprobe] = await Promise.all([
|
|
93
|
+
executableReady('pdftotext', ['-v']),
|
|
94
|
+
executableReady('pdftoppm', ['-v']),
|
|
95
|
+
executableReady('ffmpeg', ['-version']),
|
|
96
|
+
executableReady('ffprobe', ['-version']),
|
|
97
|
+
])
|
|
98
|
+
richMediaCapabilityCache = { pdf: pdftotext && pdftoppm, video: ffmpeg && ffprobe }
|
|
99
|
+
return { ...richMediaCapabilityCache }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function _resetRichMediaCapabilityCacheForTests(): void {
|
|
103
|
+
richMediaCapabilityCache = null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function boundedLabel(label: string | undefined): string {
|
|
107
|
+
return (label ?? '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 120)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function fileExtension(label: string | undefined): string {
|
|
111
|
+
return extname(boundedLabel(label)).toLowerCase()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isPdf(bytes: Buffer): boolean {
|
|
115
|
+
return bytes.length >= 5 && bytes.subarray(0, 5).toString('ascii') === '%PDF-'
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isIsoBmff(bytes: Buffer): boolean {
|
|
119
|
+
return bytes.length >= 12 && bytes.subarray(4, 8).toString('ascii') === 'ftyp'
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function decodeStrictUtf8(bytes: Buffer): string {
|
|
123
|
+
if (bytes.includes(0)) throw new RichMediaSafetyError('corrupt_attachment', 'text attachment contains NUL bytes')
|
|
124
|
+
let text: string
|
|
125
|
+
try {
|
|
126
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
|
127
|
+
} catch {
|
|
128
|
+
throw new RichMediaSafetyError('corrupt_attachment', 'text attachment is not valid UTF-8')
|
|
129
|
+
}
|
|
130
|
+
const sample = text.slice(0, 20_000)
|
|
131
|
+
const printable = [...sample].filter(ch => ch === '\n' || ch === '\r' || ch === '\t' || ch >= ' ').length
|
|
132
|
+
if (sample.length > 0 && printable / sample.length < 0.9) {
|
|
133
|
+
throw new RichMediaSafetyError('corrupt_attachment', 'text attachment contains too much control data')
|
|
134
|
+
}
|
|
135
|
+
return text.replace(/\r\n?/g, '\n')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function capText(text: string): { text: string; truncated: boolean } {
|
|
139
|
+
const normalized = text.replace(/\u0000/g, '').trim()
|
|
140
|
+
if (normalized.length <= MAX_DOCUMENT_TEXT_CHARS) return { text: normalized, truncated: false }
|
|
141
|
+
return {
|
|
142
|
+
text: `${normalized.slice(0, MAX_DOCUMENT_TEXT_CHARS)}\n\n[Document truncated by COS at ${MAX_DOCUMENT_TEXT_CHARS} characters.]`,
|
|
143
|
+
truncated: true,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function runProcess(command: string, args: string[], timeoutMs = PROCESS_TIMEOUT_MS): Promise<void> {
|
|
148
|
+
await new Promise<void>((resolve, reject) => {
|
|
149
|
+
let settled = false
|
|
150
|
+
let stderr = ''
|
|
151
|
+
const proc = spawn(command, args, { stdio: ['ignore', 'ignore', 'pipe'] })
|
|
152
|
+
const timer = setTimeout(() => {
|
|
153
|
+
if (settled) return
|
|
154
|
+
settled = true
|
|
155
|
+
proc.kill('SIGKILL')
|
|
156
|
+
reject(new RichMediaSafetyError('attachment_processing_failed', `${command} timed out`))
|
|
157
|
+
}, timeoutMs)
|
|
158
|
+
timer.unref?.()
|
|
159
|
+
proc.stderr.on('data', (chunk: Buffer) => {
|
|
160
|
+
if (stderr.length < PROCESS_STDERR_MAX) stderr += chunk.toString('utf8').slice(0, PROCESS_STDERR_MAX - stderr.length)
|
|
161
|
+
})
|
|
162
|
+
proc.once('error', error => {
|
|
163
|
+
if (settled) return
|
|
164
|
+
settled = true
|
|
165
|
+
clearTimeout(timer)
|
|
166
|
+
const code = (error as NodeJS.ErrnoException).code === 'ENOENT'
|
|
167
|
+
? 'attachment_processing_unavailable'
|
|
168
|
+
: 'attachment_processing_failed'
|
|
169
|
+
reject(new RichMediaSafetyError(code, `${command} unavailable: ${error.message}`))
|
|
170
|
+
})
|
|
171
|
+
proc.once('close', code => {
|
|
172
|
+
if (settled) return
|
|
173
|
+
settled = true
|
|
174
|
+
clearTimeout(timer)
|
|
175
|
+
if (code === 0) resolve()
|
|
176
|
+
else reject(new RichMediaSafetyError('corrupt_attachment', `${command} rejected attachment`))
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function processPdf(bytes: Buffer): Promise<PreparedDocument> {
|
|
182
|
+
const root = mkdtempSync(join(tmpdir(), 'cos-pdf-'))
|
|
183
|
+
const input = join(root, 'input.pdf')
|
|
184
|
+
const output = join(root, 'content.txt')
|
|
185
|
+
const pagesPrefix = join(root, 'page')
|
|
186
|
+
try {
|
|
187
|
+
writeFileSync(input, bytes, { mode: 0o600 })
|
|
188
|
+
await runProcess('pdftotext', ['-layout', '-enc', 'UTF-8', input, output])
|
|
189
|
+
const rawText = readFileSync(output, 'utf8')
|
|
190
|
+
const capped = capText(rawText)
|
|
191
|
+
const pageImages: Buffer[] = []
|
|
192
|
+
try {
|
|
193
|
+
await runProcess('pdftoppm', ['-jpeg', '-r', '120', '-f', '1', '-l', String(MAX_DERIVATIVE_IMAGES), input, pagesPrefix])
|
|
194
|
+
for (const name of readdirSync(root).filter(name => /^page-\d+\.jpg$/i.test(name)).sort().slice(0, MAX_DERIVATIVE_IMAGES)) {
|
|
195
|
+
pageImages.push(readFileSync(join(root, name)))
|
|
196
|
+
}
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (capped.text.length === 0) throw error
|
|
199
|
+
}
|
|
200
|
+
if (capped.text.length === 0 && pageImages.length === 0) {
|
|
201
|
+
throw new RichMediaSafetyError('corrupt_attachment', 'PDF contains no extractable text or pages')
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
category: 'document', mime: 'application/pdf', original: bytes,
|
|
205
|
+
extractedText: capped.text, textTruncated: capped.truncated, pageImages,
|
|
206
|
+
}
|
|
207
|
+
} finally {
|
|
208
|
+
try { rmSync(root, { recursive: true, force: true }) } catch { /* private tmp cleanup */ }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
interface ProbePayload {
|
|
213
|
+
format?: { duration?: string; format_name?: string; tags?: Record<string, string> }
|
|
214
|
+
streams?: Array<{ codec_type?: string; width?: number; height?: number }>
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function processVideo(bytes: Buffer, label: string | undefined, declaredMime: string | undefined): Promise<PreparedVideo> {
|
|
218
|
+
const root = mkdtempSync(join(tmpdir(), 'cos-video-'))
|
|
219
|
+
const ext = fileExtension(label) === '.mov' ? '.mov' : '.mp4'
|
|
220
|
+
const input = join(root, `input${ext}`)
|
|
221
|
+
const probePath = join(root, 'probe.json')
|
|
222
|
+
try {
|
|
223
|
+
writeFileSync(input, bytes, { mode: 0o600 })
|
|
224
|
+
await new Promise<void>((resolve, reject) => {
|
|
225
|
+
let settled = false
|
|
226
|
+
let stdout = ''
|
|
227
|
+
let stderr = ''
|
|
228
|
+
const proc = spawn('ffprobe', [
|
|
229
|
+
'-v', 'error', '-show_entries', 'format=duration,format_name:stream=codec_type,width,height',
|
|
230
|
+
'-of', 'json', input,
|
|
231
|
+
], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
232
|
+
const timer = setTimeout(() => {
|
|
233
|
+
if (settled) return
|
|
234
|
+
settled = true
|
|
235
|
+
proc.kill('SIGKILL')
|
|
236
|
+
reject(new RichMediaSafetyError('attachment_processing_failed', 'ffprobe timed out'))
|
|
237
|
+
}, 10_000)
|
|
238
|
+
proc.stdout.on('data', (chunk: Buffer) => { if (stdout.length < 64_000) stdout += chunk.toString('utf8') })
|
|
239
|
+
proc.stderr.on('data', (chunk: Buffer) => { if (stderr.length < PROCESS_STDERR_MAX) stderr += chunk.toString('utf8') })
|
|
240
|
+
proc.once('error', error => {
|
|
241
|
+
if (settled) return
|
|
242
|
+
settled = true
|
|
243
|
+
clearTimeout(timer)
|
|
244
|
+
reject(new RichMediaSafetyError(
|
|
245
|
+
(error as NodeJS.ErrnoException).code === 'ENOENT' ? 'attachment_processing_unavailable' : 'attachment_processing_failed',
|
|
246
|
+
`ffprobe unavailable: ${error.message}`,
|
|
247
|
+
))
|
|
248
|
+
})
|
|
249
|
+
proc.once('close', code => {
|
|
250
|
+
if (settled) return
|
|
251
|
+
settled = true
|
|
252
|
+
clearTimeout(timer)
|
|
253
|
+
if (code !== 0) {
|
|
254
|
+
reject(new RichMediaSafetyError('corrupt_attachment', 'ffprobe rejected video'))
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
writeFileSync(probePath, stdout, { mode: 0o600 })
|
|
258
|
+
resolve()
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
let probe: ProbePayload
|
|
262
|
+
try { probe = JSON.parse(readFileSync(probePath, 'utf8')) as ProbePayload } catch {
|
|
263
|
+
throw new RichMediaSafetyError('corrupt_attachment', 'ffprobe returned invalid metadata')
|
|
264
|
+
}
|
|
265
|
+
const stream = probe.streams?.find(item => item.codec_type === 'video')
|
|
266
|
+
const durationMs = Math.round(Number(probe.format?.duration) * 1000)
|
|
267
|
+
if (!stream || !Number.isFinite(stream.width) || !Number.isFinite(stream.height)
|
|
268
|
+
|| !Number.isFinite(durationMs) || durationMs <= 0) {
|
|
269
|
+
throw new RichMediaSafetyError('corrupt_attachment', 'attachment has no valid video stream')
|
|
270
|
+
}
|
|
271
|
+
if (durationMs > MAX_VIDEO_DURATION_MS) {
|
|
272
|
+
throw new RichMediaSafetyError('video_too_long', `video exceeds ${MAX_VIDEO_DURATION_MS / 60_000} minute limit`)
|
|
273
|
+
}
|
|
274
|
+
const frameCount = Math.min(MAX_DERIVATIVE_IMAGES, Math.max(1, Math.ceil(durationMs / 15_000)))
|
|
275
|
+
const fps = Math.max(0.001, frameCount / (durationMs / 1000))
|
|
276
|
+
await runProcess('ffmpeg', [
|
|
277
|
+
'-nostdin', '-v', 'error', '-i', input,
|
|
278
|
+
'-vf', `fps=${fps.toFixed(6)},scale=1280:-2:force_original_aspect_ratio=decrease`,
|
|
279
|
+
'-frames:v', String(frameCount), '-q:v', '3', join(root, 'frame-%02d.jpg'),
|
|
280
|
+
])
|
|
281
|
+
const frames = readdirSync(root)
|
|
282
|
+
.filter(name => /^frame-\d+\.jpg$/i.test(name)).sort().slice(0, MAX_DERIVATIVE_IMAGES)
|
|
283
|
+
.map(name => readFileSync(join(root, name)))
|
|
284
|
+
if (frames.length === 0) throw new RichMediaSafetyError('corrupt_attachment', 'video produced no review frames')
|
|
285
|
+
const mime: PreparedVideo['mime'] = declaredMime === 'video/quicktime' || ext === '.mov'
|
|
286
|
+
? 'video/quicktime' : 'video/mp4'
|
|
287
|
+
return {
|
|
288
|
+
category: 'video', mime, original: bytes,
|
|
289
|
+
width: Math.floor(stream.width!), height: Math.floor(stream.height!), durationMs, frames,
|
|
290
|
+
}
|
|
291
|
+
} finally {
|
|
292
|
+
try { rmSync(root, { recursive: true, force: true }) } catch { /* private tmp cleanup */ }
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export async function prepareRichMedia(
|
|
297
|
+
bytes: Buffer,
|
|
298
|
+
options: { label?: string; declaredMime?: string },
|
|
299
|
+
): Promise<PreparedRichMedia> {
|
|
300
|
+
if (bytes.length === 0) throw new RichMediaSafetyError('corrupt_attachment', 'attachment is empty')
|
|
301
|
+
if (bytes.length > MAX_RICH_MEDIA_BYTES) {
|
|
302
|
+
throw new RichMediaSafetyError('attachment_too_large', `attachment exceeds ${MAX_RICH_MEDIA_BYTES} byte limit`)
|
|
303
|
+
}
|
|
304
|
+
if (isPdf(bytes)) return processPdf(bytes)
|
|
305
|
+
if (isIsoBmff(bytes)) return processVideo(bytes, options.label, options.declaredMime)
|
|
306
|
+
|
|
307
|
+
const ext = fileExtension(options.label)
|
|
308
|
+
const declared = (options.declaredMime ?? '').toLowerCase().split(';', 1)[0]
|
|
309
|
+
const textMime: PreparedDocument['mime'] | null = ext === '.md' || ext === '.markdown' || declared === 'text/markdown'
|
|
310
|
+
? 'text/markdown'
|
|
311
|
+
: ext === '.csv' || declared === 'text/csv'
|
|
312
|
+
? 'text/csv'
|
|
313
|
+
: ext === '.json' || declared === 'application/json'
|
|
314
|
+
? 'application/json'
|
|
315
|
+
: ext === '.txt' || declared === 'text/plain'
|
|
316
|
+
? 'text/plain'
|
|
317
|
+
: null
|
|
318
|
+
if (!textMime) throw new RichMediaSafetyError('unsupported_attachment_format', 'supported files: TXT, MD, CSV, JSON, PDF, MP4, MOV')
|
|
319
|
+
const capped = capText(decodeStrictUtf8(bytes))
|
|
320
|
+
return {
|
|
321
|
+
category: 'document', mime: textMime, original: bytes,
|
|
322
|
+
extractedText: capped.text, textTruncated: capped.truncated, pageImages: [],
|
|
323
|
+
}
|
|
324
|
+
}
|
package/server/routes/health.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Router } from 'express'
|
|
2
|
+
import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
|
|
2
3
|
import { statSync } from 'node:fs'
|
|
3
4
|
import { resolve } from 'node:path'
|
|
4
5
|
import { COS_SCRIPTS_DIR, COS_MODE } from '../lib/python-bridge.js'
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
isCursorProviderReady,
|
|
30
31
|
} from '../lib/cursor-model-catalog.js'
|
|
31
32
|
import { isMediaProcessingReady } from '../lib/image-safety.js'
|
|
33
|
+
import { getRichMediaProcessingCapabilities } from '../lib/rich-media-safety.js'
|
|
32
34
|
import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
|
|
33
35
|
import { durableQueryJobsCapability } from '../lib/query-job-feature.js'
|
|
34
36
|
import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
|
|
@@ -161,6 +163,7 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
161
163
|
// Computed once per request; the same value feeds features.liveCues and
|
|
162
164
|
// capabilities.liveCues so the two surfaces can never disagree.
|
|
163
165
|
const liveCues = liveCuesCapability()
|
|
166
|
+
const richMedia = await getRichMediaProcessingCapabilities()
|
|
164
167
|
const features = {
|
|
165
168
|
claude: claudeAvailable,
|
|
166
169
|
codex: codexAvailable,
|
|
@@ -172,6 +175,8 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
172
175
|
meetingFinalization: true,
|
|
173
176
|
iphoneAsrCandidates: process.env.COS_IOS_ASR_CANDIDATES === '1',
|
|
174
177
|
mediaProcessingReady: await isMediaProcessingReady(),
|
|
178
|
+
pdfProcessingReady: richMedia.pdf,
|
|
179
|
+
videoProcessingReady: richMedia.video,
|
|
175
180
|
g2LensVariant: G2_LENS_VARIANT_CAPABILITY,
|
|
176
181
|
durableQueryJobs: durableJobs.enabled,
|
|
177
182
|
durableQueryJobsProtocol: durableJobs.protocolVersion,
|
|
@@ -227,7 +232,7 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
227
232
|
// plus the recover action live on the authenticated /api/meeting/orphans.
|
|
228
233
|
const unsavedList = listUnsavedCaptures()
|
|
229
234
|
const unsaved_captures = {
|
|
230
|
-
count: unsavedList.filter(
|
|
235
|
+
count: unsavedList.filter(isWorthRecovering).length,
|
|
231
236
|
items: unsavedList.slice(0, 10).map(item => ({
|
|
232
237
|
sessionId: item.sessionId,
|
|
233
238
|
ageHours: item.ageHours,
|
|
@@ -295,6 +300,15 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
295
300
|
},
|
|
296
301
|
cliDebug: CLI_DEBUG_CAPABILITY,
|
|
297
302
|
liveCues,
|
|
303
|
+
richMedia: {
|
|
304
|
+
text: true,
|
|
305
|
+
pdf: richMedia.pdf,
|
|
306
|
+
video: richMedia.video,
|
|
307
|
+
maxAttachments: 5,
|
|
308
|
+
maxBytesPerAttachment: 64 * 1024 * 1024,
|
|
309
|
+
maxVideoMinutes: 20,
|
|
310
|
+
maxStillFrames: 8,
|
|
311
|
+
},
|
|
298
312
|
meetingLifecycle: {
|
|
299
313
|
earlySyncClaim: getEarlyMeetingSyncSnapshot(),
|
|
300
314
|
progressiveHq,
|
package/server/routes/media.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// (8 MiB decoded ≈ 10.7 MiB base64) doesn't trip the global 10 MB limit;
|
|
16
16
|
// the allowance stays scoped to /api/media.
|
|
17
17
|
|
|
18
|
-
import { Router, json, type Request, type Response } from 'express'
|
|
18
|
+
import { Router, json, raw, type Request, type Response } from 'express'
|
|
19
19
|
import { readFileSync } from 'node:fs'
|
|
20
20
|
import {
|
|
21
21
|
MAX_ATTACHMENTS_PER_PROMPT,
|
|
@@ -35,11 +35,18 @@ import {
|
|
|
35
35
|
isMediaProcessingReady,
|
|
36
36
|
strictBase64Decode,
|
|
37
37
|
} from '../lib/image-safety.js'
|
|
38
|
+
import {
|
|
39
|
+
MAX_RICH_MEDIA_BYTES,
|
|
40
|
+
RichMediaSafetyError,
|
|
41
|
+
} from '../lib/rich-media-safety.js'
|
|
38
42
|
|
|
39
43
|
// Route-scoped parser: 16 MB covers the max valid batch after base64 + JSON
|
|
40
44
|
// overhead. Mounted only for /api/media in server/index.ts — the global
|
|
41
45
|
// server limit is unchanged.
|
|
42
46
|
export const mediaBodyParser = json({ limit: '16mb' })
|
|
47
|
+
/** Binary parser is mounted only on /api/media/file before global JSON. It
|
|
48
|
+
* avoids base64 amplification while retaining a hard route-local cap. */
|
|
49
|
+
export const mediaBinaryBodyParser = raw({ type: () => true, limit: MAX_RICH_MEDIA_BYTES })
|
|
43
50
|
|
|
44
51
|
export const mediaRouter = Router()
|
|
45
52
|
|
|
@@ -58,6 +65,12 @@ const SAFETY_ERROR_STATUS: Record<string, number> = {
|
|
|
58
65
|
corrupt_image: 400,
|
|
59
66
|
media_processing_unavailable: 503,
|
|
60
67
|
normalization_failed: 500,
|
|
68
|
+
unsupported_attachment_format: 400,
|
|
69
|
+
attachment_too_large: 413,
|
|
70
|
+
corrupt_attachment: 400,
|
|
71
|
+
attachment_processing_unavailable: 503,
|
|
72
|
+
attachment_processing_failed: 500,
|
|
73
|
+
video_too_long: 400,
|
|
61
74
|
}
|
|
62
75
|
|
|
63
76
|
function sendMediaError(res: Response, err: unknown): void {
|
|
@@ -73,6 +86,10 @@ function sendMediaError(res: Response, err: unknown): void {
|
|
|
73
86
|
})
|
|
74
87
|
return
|
|
75
88
|
}
|
|
89
|
+
if (err instanceof RichMediaSafetyError) {
|
|
90
|
+
res.status(SAFETY_ERROR_STATUS[err.code] ?? 500).json({ error: err.code, detail: err.message })
|
|
91
|
+
return
|
|
92
|
+
}
|
|
76
93
|
console.error('[media] unexpected error:', err)
|
|
77
94
|
res.status(500).json({ error: 'media_internal_error' })
|
|
78
95
|
}
|
|
@@ -143,6 +160,34 @@ mediaRouter.post('/media', async (req: Request, res: Response) => {
|
|
|
143
160
|
}
|
|
144
161
|
})
|
|
145
162
|
|
|
163
|
+
/** One document or video per binary request. The client may issue several
|
|
164
|
+
* bounded requests, but composer admission still owns the shared five-item
|
|
165
|
+
* prompt cap. Filename and MIME are hints only; prepareRichMedia sniffs and
|
|
166
|
+
* validates the actual bytes. */
|
|
167
|
+
mediaRouter.post('/media/file', async (req: Request, res: Response) => {
|
|
168
|
+
try {
|
|
169
|
+
if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
|
|
170
|
+
res.status(400).json({ error: 'attachment_bytes_required' })
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
const rawLabel = safeString(req.header('x-cos-filename'), 360)
|
|
174
|
+
let label: string | undefined
|
|
175
|
+
if (rawLabel) {
|
|
176
|
+
try { label = decodeURIComponent(rawLabel).slice(0, 120) } catch { label = rawLabel.slice(0, 120) }
|
|
177
|
+
}
|
|
178
|
+
const attachment = await getMediaStore().ingestRichMedia({
|
|
179
|
+
bytes: req.body,
|
|
180
|
+
label,
|
|
181
|
+
declaredMime: safeString(req.header('content-type'), 120),
|
|
182
|
+
capturedAt: safeString(req.header('x-cos-captured-at'), 40),
|
|
183
|
+
sessionId: safeString(req.header('x-cos-session-id'), 64),
|
|
184
|
+
})
|
|
185
|
+
res.json({ attachment })
|
|
186
|
+
} catch (err) {
|
|
187
|
+
sendMediaError(res, err)
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
|
|
146
191
|
// ── Lifecycle ────────────────────────────────────────────────────────────────
|
|
147
192
|
|
|
148
193
|
mediaRouter.post('/media/reserve', async (req: Request, res: Response) => {
|
package/server/routes/meeting.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// are durable before the session is closed; batch improvement runs afterward.
|
|
4
4
|
|
|
5
5
|
import { existsSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync } from 'node:fs'
|
|
6
|
+
import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
|
|
6
7
|
import { resolve } from 'node:path'
|
|
7
8
|
import { Router } from 'express'
|
|
8
9
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
@@ -1672,7 +1673,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1672
1673
|
// route answered count: 0.
|
|
1673
1674
|
const stranded = getStrandedCaptures()
|
|
1674
1675
|
res.json({
|
|
1675
|
-
count: items.filter(
|
|
1676
|
+
count: items.filter(isWorthRecovering).length,
|
|
1676
1677
|
strandedCount: stranded.length,
|
|
1677
1678
|
stranded,
|
|
1678
1679
|
recovering: [...recoveringOrphans],
|
package/server/routes/query.ts
CHANGED
|
@@ -83,10 +83,10 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
83
83
|
|
|
84
84
|
const resolvedQuery = typeof query === 'string' ? query : ''
|
|
85
85
|
|
|
86
|
-
//
|
|
87
|
-
if ((!resolvedQuery || typeof resolvedQuery !== 'string') &&
|
|
86
|
+
// Attachment-only queries use a category-aware provider default.
|
|
87
|
+
if ((!resolvedQuery || typeof resolvedQuery !== 'string') && attachmentRefs.length === 0) {
|
|
88
88
|
maintenanceLease.release()
|
|
89
|
-
return res.status(400).json({ error: 'query string or
|
|
89
|
+
return res.status(400).json({ error: 'query string or attachment required' })
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
// Validate model if provided
|
|
@@ -201,6 +201,8 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
201
201
|
abortSignal: abortController.signal,
|
|
202
202
|
effort: validEffort,
|
|
203
203
|
messageEra: activeMessageEra,
|
|
204
|
+
requestAttachments: attachmentRefs,
|
|
205
|
+
attachmentPromptBlock: resolvedAttachments.promptBlock,
|
|
204
206
|
...(validModel && isCursorModel(validModel) ? { cursorExecutionMode } : {}),
|
|
205
207
|
},
|
|
206
208
|
)
|
|
@@ -1015,8 +1015,22 @@ setInterval(() => {
|
|
|
1015
1015
|
}
|
|
1016
1016
|
}
|
|
1017
1017
|
purgeExpiredQuarantine()
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
// Was a bare `catch {}`. That is what made the auto-recover failure below
|
|
1020
|
+
// undiagnosable: three minutes of watching a live server produced no recovery, no
|
|
1021
|
+
// log, and nothing to reason about, because any throw in this block vanished.
|
|
1022
|
+
console.error(`[cleanup] Orphan-audio sweep failed: ${error instanceof Error ? error.message : error}`)
|
|
1023
|
+
}
|
|
1024
|
+
// DELIBERATELY ITS OWN TRY. This used to sit inside the block above, one line after
|
|
1025
|
+
// purgeExpiredQuarantine, so a throw anywhere in that sweep meant auto-recovery
|
|
1026
|
+
// silently never ran — which is exactly what happened in production on 6.23.1
|
|
1027
|
+
// through 6.24.2. Recovering quarantined audio has nothing to do with sweeping
|
|
1028
|
+
// orphaned session-audio dirs and must not depend on it succeeding.
|
|
1029
|
+
try {
|
|
1018
1030
|
autoRecoverOneQuarantinedCapture()
|
|
1019
|
-
} catch {
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
console.error(`[quarantine] Auto-recover pass failed: ${error instanceof Error ? error.message : error}`)
|
|
1033
|
+
}
|
|
1020
1034
|
// Purge stale pending-batch dirs. HQ large-v3 on long meetings + Control
|
|
1021
1035
|
// restart can exceed 2h (2026-07-27: two sessions purged before batch).
|
|
1022
1036
|
// Use 12h, and never purge while a meeting_batch_finalization lease is held.
|