@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
|
@@ -12,7 +12,18 @@ import { realAttachedWorkspaceDeps, resolveAttachedWorkspace } from './attached-
|
|
|
12
12
|
import { readComposerFacts } from './cursor-session-model.js'
|
|
13
13
|
import { MAX_OUTPUT_ARTIFACT_BYTES } from './image-safety.js'
|
|
14
14
|
import { getMediaStore, STAGED_TTL_MS, type MediaStore } from './media-store.js'
|
|
15
|
-
import {
|
|
15
|
+
import { previewFoldersSetting } from './preview-folders-setting.js'
|
|
16
|
+
import { isDeniedSecretPath, SECRET_FILE_NOTICE } from './secret-path-guard.js'
|
|
17
|
+
import { previewErrorCode } from './preview-error-code.js'
|
|
18
|
+
import { readRecentSessionTurns, SESSION_TURNS_MAX, type SessionTurn, type SessionTurnProjection } from './agent-session-turns.js'
|
|
19
|
+
import {
|
|
20
|
+
authorizedRoot, insideRoot, isLocalTarget, LINK_CARDS_PER_TURN, linkCardWords, linkLabel, linkTokens, previewKind, resolveLinkTarget, SESSION_OUTPUT_LINK_ID,
|
|
21
|
+
linkOverflowSentence, splitLineAnchor, type LinkRoots, type PreviewKind, type SessionOutputLink,
|
|
22
|
+
} from './session-links.js'
|
|
23
|
+
import {
|
|
24
|
+
decodeToolImage, estimateBytes, MAX_TOOL_IMAGE_BYTES, promptImages, SESSION_TOOL_IMAGE_ID, TOOL_IMAGES_PER_TURN, toolResultImages, toolUsesOf,
|
|
25
|
+
type FoundToolImage, type SessionToolImage, type ToolUseMeta,
|
|
26
|
+
} from './agent-session-tool-images.js'
|
|
16
27
|
|
|
17
28
|
export interface SessionOutputImage {
|
|
18
29
|
id: string
|
|
@@ -21,19 +32,63 @@ export interface SessionOutputImage {
|
|
|
21
32
|
/**
|
|
22
33
|
* `outside_workspace`: permanent (the path leaves the workspace, or the workspace is a broad
|
|
23
34
|
* root that is never eligible). `unavailable`: the workspace lookup failed this time; the
|
|
24
|
-
* content route re-resolves it, so a retry can succeed.
|
|
35
|
+
* content route re-resolves it, so a retry can succeed. `secret_file` (7.0.4): the file's name (or
|
|
36
|
+
* the name it canonically resolves to) says it may hold secrets; never served (403 secret_file_denied).
|
|
25
37
|
*/
|
|
26
|
-
reason?: 'outside_workspace' | 'unavailable'
|
|
38
|
+
reason?: 'outside_workspace' | 'unavailable' | 'missing' | 'secret_file'
|
|
39
|
+
/** 7.0.4: the words the card shows instead, set only with `secret_file`. */
|
|
40
|
+
notice?: string
|
|
27
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* A file a Claude session sent to the person with its SendUserFile tool (6.99.35), when it is not
|
|
44
|
+
* an image the preview pipeline normalizes. Only the base name travels; never a Mac path.
|
|
45
|
+
* `missing`: the path is inside the workspace but nothing is there now.
|
|
46
|
+
*/
|
|
47
|
+
export interface SessionOutputFile {
|
|
48
|
+
id: string
|
|
49
|
+
name: string
|
|
50
|
+
/** How the web may open it: `pdf` inline, `text` as words, `html` sandboxed or downloaded, `image` (GIF) as a picture, `file` downloaded. */
|
|
51
|
+
kind: SharedFileKind
|
|
52
|
+
/** A short type for people: "PDF", "HTML page", "Markdown", "Word document". */
|
|
53
|
+
type: string
|
|
54
|
+
size?: number
|
|
55
|
+
state: 'available' | 'unavailable'
|
|
56
|
+
reason?: 'outside_workspace' | 'unavailable' | 'missing' | 'secret_file'
|
|
57
|
+
/** 7.0.4: the words the card shows instead, set only with `secret_file`. */
|
|
58
|
+
notice?: string
|
|
59
|
+
}
|
|
60
|
+
export type SharedFileKind = 'pdf' | 'text' | 'html' | 'image' | 'file'
|
|
61
|
+
export const SESSION_OUTPUT_FILE_ID = /^af_[a-f0-9]{64}$/
|
|
62
|
+
/** Files one SendUserFile row may show (images and other files together). The rest are counted in words. */
|
|
63
|
+
export const SHARED_FILES_PER_TURN = 10
|
|
64
|
+
/** The largest sent file the web may fetch. Bigger files stay listed as "Too large to open here". */
|
|
65
|
+
export const MAX_SHARED_FILE_BYTES = 32 * 1024 * 1024
|
|
28
66
|
export const SESSION_OUTPUT_IMAGE_ID = /^ai_[a-f0-9]{64}$/
|
|
29
67
|
export const OUTPUT_IMAGES_PER_TURN = 5
|
|
30
68
|
export const OUTPUT_IMAGE_SCAN_CHARS = 128 * 1024
|
|
31
69
|
export const OUTPUT_IMAGE_CACHE_MAX = 128
|
|
32
70
|
export const OUTPUT_IMAGE_CONCURRENCY = 2
|
|
33
71
|
const CACHE_TTL = Math.min(30 * 60_000, STAGED_TTL_MS)
|
|
72
|
+
/** Sources kept per read: every preview the newest turns can name, a sent-files turn being the widest. */
|
|
73
|
+
const SOURCES_MAX = (SESSION_TURNS_MAX + 1) * Math.max(OUTPUT_IMAGES_PER_TURN, SHARED_FILES_PER_TURN)
|
|
34
74
|
interface Candidate { source: string; label: string }
|
|
35
75
|
interface Source { candidate: Candidate; root: string | null; descriptor: SessionOutputImage }
|
|
36
|
-
interface
|
|
76
|
+
interface FileSource { path: string | null; root: string | null; file: SessionOutputFile }
|
|
77
|
+
/** A link card's authorized source (preview engine phase 1). Kept only for an available card. */
|
|
78
|
+
interface LinkSource { path: string; root: string; link: SessionOutputLink }
|
|
79
|
+
/** Where a tool-result image's bytes are. Embedded bytes are kept only for the one id a content request names. */
|
|
80
|
+
type ToolSource = { kind: 'embedded'; mediaType: string; data: string } | { kind: 'path'; source: string; root: string; label: string }
|
|
81
|
+
interface PreviewContext { project: SessionTurnProjection; sources: Map<string, Source>; files: Map<string, FileSource>; toolSources: Map<string, ToolSource>; links: Map<string, LinkSource>; workspace: string | null }
|
|
82
|
+
/**
|
|
83
|
+
* PLAN 7 load: a reader with 20 cards asks for 20 thumbnails. The preview routes keep one projection of a transcript for
|
|
84
|
+
* 30 s while its size and mtime stand, so that is one transcript parse, not 20. Only ids, names and canonical sources are
|
|
85
|
+
* kept, and every content call still re-checks the file (containment, links, the deny list, identity) and the root.
|
|
86
|
+
*/
|
|
87
|
+
export const PREVIEW_PROJECTION_TTL_MS = 30_000
|
|
88
|
+
const PREVIEW_PROJECTIONS_MAX = 8
|
|
89
|
+
interface PreviewProjection { at: number; size: number; mtimeMs: number; read: Promise<{ context: PreviewContext; turns: SessionTurn[] }> }
|
|
90
|
+
/** Tool calls remembered per read, so a result row can be labelled by the call that asked for it. */
|
|
91
|
+
const TOOL_USES_MAX = 4096
|
|
37
92
|
interface MediaCache { id: string; at: number }
|
|
38
93
|
type PreviewStore = Pick<MediaStore, 'ingestOutputImage' | 'getContent'>
|
|
39
94
|
export interface OutputImageDeps {
|
|
@@ -44,6 +99,8 @@ export interface OutputImageDeps {
|
|
|
44
99
|
now(): number
|
|
45
100
|
/** The home directory the broad-root denylist is built from (tests inject a scratch home). */
|
|
46
101
|
home?(): string
|
|
102
|
+
/** Settings extra folders COS may preview from (lib/preview-folders-setting.ts), canonical. Read on every context. */
|
|
103
|
+
extraRoots?(): readonly string[]
|
|
47
104
|
}
|
|
48
105
|
|
|
49
106
|
function assistantText(provider: AgentProvider, row: Record<string, unknown>): string | null {
|
|
@@ -156,15 +213,126 @@ function canonicalRoot(path: string | null, home: string): string | 'broad' | nu
|
|
|
156
213
|
} catch { return null }
|
|
157
214
|
}
|
|
158
215
|
function digest(value: string): string { return createHash('sha256').update(value).digest('hex') }
|
|
216
|
+
/**
|
|
217
|
+
* 7.0.4 S2: whether a path a session named may hold secrets. Resolved against the workspace when there is one,
|
|
218
|
+
* otherwise judged by its own segments only (anchored at '/', never the server's directory). The guard checks the
|
|
219
|
+
* path as written AND its canonical file, so an innocently named link to a secret is refused too.
|
|
220
|
+
*/
|
|
221
|
+
function secretSource(root: string | null, written: string): boolean {
|
|
222
|
+
return isDeniedSecretPath(root !== null ? resolve(root, written) : resolve('/', written))
|
|
223
|
+
}
|
|
224
|
+
const SECRET = { state: 'unavailable' as const, reason: 'secret_file' as const, notice: SECRET_FILE_NOTICE }
|
|
225
|
+
|
|
226
|
+
// ── Files a Claude session sent with SendUserFile (6.99.35) ──────────────────────────────
|
|
227
|
+
// Its input is { files: [paths], caption?, status, display? }; paths are absolute or relative to the
|
|
228
|
+
// session's working directory at that moment (the row's `cwd`). The row is evidence of what was
|
|
229
|
+
// shared, not permission to read: a file is served only when its canonical path is inside the
|
|
230
|
+
// session's exact workspace, as for images. Codex and Cursor transcripts have no such record.
|
|
231
|
+
const NORMALIZED_IMAGE = /\.(png|jpe?g|webp|hei[cf]|avif)$/i
|
|
232
|
+
const SHARED_KINDS: Array<[RegExp, SharedFileKind, string]> = [
|
|
233
|
+
[/\.pdf$/i, 'pdf', 'PDF'],
|
|
234
|
+
[/\.html?$/i, 'html', 'HTML page'],
|
|
235
|
+
[/\.(md|markdown)$/i, 'text', 'Markdown'],
|
|
236
|
+
[/\.csv$/i, 'text', 'CSV'],
|
|
237
|
+
[/\.tsv$/i, 'text', 'TSV'],
|
|
238
|
+
[/\.json$/i, 'text', 'JSON'],
|
|
239
|
+
[/\.ya?ml$/i, 'text', 'YAML'],
|
|
240
|
+
[/\.(txt|log)$/i, 'text', 'Text'],
|
|
241
|
+
// An SVG can carry script, so it is shown as its source, never as a picture from a same-origin URL.
|
|
242
|
+
[/\.svg$/i, 'text', 'SVG source'],
|
|
243
|
+
[/\.xml$/i, 'text', 'XML'],
|
|
244
|
+
[/\.gif$/i, 'image', 'GIF image'],
|
|
245
|
+
[/\.docx?$/i, 'file', 'Word document'],
|
|
246
|
+
[/\.xlsx?$/i, 'file', 'Excel workbook'],
|
|
247
|
+
[/\.pptx?$/i, 'file', 'PowerPoint deck'],
|
|
248
|
+
[/\.(wav|mp3|m4a|aac|flac)$/i, 'file', 'Audio'],
|
|
249
|
+
[/\.(mp4|mov|webm)$/i, 'file', 'Video'],
|
|
250
|
+
[/\.zip$/i, 'file', 'ZIP archive'],
|
|
251
|
+
[/\.ehpk$/i, 'file', 'EHPK package'],
|
|
252
|
+
]
|
|
253
|
+
export function sharedFileKind(name: string): { kind: SharedFileKind; type: string } {
|
|
254
|
+
for (const [re, kind, type] of SHARED_KINDS) if (re.test(name)) return { kind, type }
|
|
255
|
+
const ext = /\.([a-z0-9]{1,8})$/i.exec(name)
|
|
256
|
+
return { kind: 'file', type: ext ? ext[1].toUpperCase() + ' file' : 'File' }
|
|
257
|
+
}
|
|
258
|
+
/** The base name a person sees: no directories, no control characters, at most 120 characters. */
|
|
259
|
+
function displayName(source: string): string {
|
|
260
|
+
const base = source.split(/[\\/]/).filter(Boolean).pop() || ''
|
|
261
|
+
const clean = base.replace(/[\u0000-\u001f\u007f]/g, '').trim()
|
|
262
|
+
return (clean || 'File').slice(0, 120)
|
|
263
|
+
}
|
|
264
|
+
interface SentFiles { caption: string; files: string[] }
|
|
265
|
+
/** The SendUserFile tool uses in one Claude assistant row, or null. Pure. */
|
|
266
|
+
export function sentUserFiles(provider: AgentProvider, row: Record<string, unknown>): SentFiles | null {
|
|
267
|
+
if (provider !== 'claude' || row.type !== 'assistant') return null
|
|
268
|
+
// A sub-agent's share went to its parent, not to the person; an injected row is no one's.
|
|
269
|
+
if (row.isSidechain === true || row.isMeta === true || row.isCompactSummary === true) return null
|
|
270
|
+
const message = row.message as Record<string, unknown> | null
|
|
271
|
+
const content = message && typeof message === 'object' ? message.content : null
|
|
272
|
+
if (!Array.isArray(content)) return null
|
|
273
|
+
const files: string[] = [], captions: string[] = []
|
|
274
|
+
for (const block of content) {
|
|
275
|
+
if (!block || typeof block !== 'object') continue
|
|
276
|
+
const b = block as Record<string, unknown>
|
|
277
|
+
if (b.type !== 'tool_use' || b.name !== 'SendUserFile' || !b.input || typeof b.input !== 'object') continue
|
|
278
|
+
const input = b.input as Record<string, unknown>
|
|
279
|
+
// Preview engine phase 1: 11 of 1,112 real calls sent `files` as a JSON string ("[\"a.png\"]"). Parse it, then the same checks.
|
|
280
|
+
let list: unknown = input.files
|
|
281
|
+
if (typeof list === 'string' && list.length <= 64 * 1024) { try { list = JSON.parse(list) } catch { list = null } }
|
|
282
|
+
if (!Array.isArray(list)) continue
|
|
283
|
+
let any = false
|
|
284
|
+
for (const f of list) {
|
|
285
|
+
if (typeof f !== 'string' || !f || f.length > 4096 || /[\u0000-\u001f\u007f]/.test(f)) continue
|
|
286
|
+
files.push(f); any = true
|
|
287
|
+
}
|
|
288
|
+
if (any && typeof input.caption === 'string' && input.caption.trim()) captions.push(input.caption.trim().slice(0, 4000))
|
|
289
|
+
}
|
|
290
|
+
return files.length ? { caption: captions.join('\n\n'), files } : null
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** A Claude row's content has typed words (a text block that is not blank). */
|
|
294
|
+
function hasWords(row: Record<string, unknown>): boolean {
|
|
295
|
+
const content = (row.message as Record<string, unknown> | null | undefined)?.content
|
|
296
|
+
if (typeof content === 'string') return content.trim().length > 0
|
|
297
|
+
return Array.isArray(content) && content.some(b => !!b && typeof b === 'object' && (b as Record<string, unknown>).type === 'text' && typeof (b as Record<string, unknown>).text === 'string' && ((b as Record<string, unknown>).text as string).trim().length > 0)
|
|
298
|
+
}
|
|
159
299
|
|
|
160
300
|
export class AgentSessionOutputImages {
|
|
161
301
|
private cache = new Map<string, MediaCache>()
|
|
162
302
|
private pending = new Map<string, Promise<string>>()
|
|
163
303
|
private active = 0
|
|
164
304
|
private readers = 0
|
|
305
|
+
private projections = new Map<string, PreviewProjection>()
|
|
306
|
+
/** Transcript parses the preview routes made (the load gate counts them). */
|
|
307
|
+
previewParses = 0
|
|
165
308
|
constructor(private readonly deps: OutputImageDeps) {}
|
|
166
309
|
|
|
167
|
-
|
|
310
|
+
/** The preview routes' projection of a transcript, reused for PREVIEW_PROJECTION_TTL_MS while the file is unchanged. */
|
|
311
|
+
private previewProjection(provider: AgentProvider, sessionId: string, transcript: string) {
|
|
312
|
+
const st = statSync(transcript)
|
|
313
|
+
const key = JSON.stringify([provider, sessionId, transcript])
|
|
314
|
+
const now = this.deps.now()
|
|
315
|
+
const hit = this.projections.get(key)
|
|
316
|
+
if (hit && now - hit.at < PREVIEW_PROJECTION_TTL_MS && hit.size === st.size && hit.mtimeMs === st.mtimeMs) return hit.read
|
|
317
|
+
this.previewParses++
|
|
318
|
+
const read = (async () => {
|
|
319
|
+
const context = await this.context(provider, sessionId, transcript)
|
|
320
|
+
const recent = await readRecentSessionTurns(provider, transcript, SESSION_TURNS_MAX, undefined, context.project)
|
|
321
|
+
return { context, turns: recent.turns }
|
|
322
|
+
})()
|
|
323
|
+
this.projections.delete(key)
|
|
324
|
+
this.projections.set(key, { at: now, size: st.size, mtimeMs: st.mtimeMs, read })
|
|
325
|
+
while (this.projections.size > PREVIEW_PROJECTIONS_MAX) this.projections.delete(this.projections.keys().next().value!)
|
|
326
|
+
// A failed parse is never reused.
|
|
327
|
+
read.catch(() => { if (this.projections.get(key)?.read === read) this.projections.delete(key) })
|
|
328
|
+
return read
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* `want`: the one tool-image id a content request is for. Only its embedded bytes are kept; a detail read
|
|
333
|
+
* (no `want`) keeps none, so a long session never holds 50 base64 images past the line that parsed them.
|
|
334
|
+
*/
|
|
335
|
+
async context(provider: AgentProvider, sessionId: string, transcript: string, want?: string): Promise<PreviewContext> {
|
|
168
336
|
let workspace: string | null = null
|
|
169
337
|
try { workspace = await this.deps.workspace(provider, sessionId, transcript) } catch { /* unavailable, words still load */ }
|
|
170
338
|
const canonical = canonicalRoot(workspace, this.deps.home?.() ?? homedir())
|
|
@@ -173,7 +341,19 @@ export class AgentSessionOutputImages {
|
|
|
173
341
|
const lookupFailed = canonical === null
|
|
174
342
|
const sources = new Map<string, Source>()
|
|
175
343
|
const transcriptKey = digest(resolve(transcript))
|
|
176
|
-
const
|
|
344
|
+
const files = new Map<string, FileSource>()
|
|
345
|
+
const home = this.deps.home?.() ?? homedir()
|
|
346
|
+
let extraRoots: readonly string[] = []
|
|
347
|
+
try { extraRoots = this.deps.extraRoots?.() ?? [] } catch { extraRoots = [] }
|
|
348
|
+
const roots: LinkRoots = { workspace: root, extra: extraRoots, home }
|
|
349
|
+
const links = new Map<string, LinkSource>()
|
|
350
|
+
// A Codex rollout names its working folder in session_meta and each turn_context, not on the message rows.
|
|
351
|
+
let codexCwd: string | null = null
|
|
352
|
+
const markdown: SessionTurnProjection = (p, row, turn) => {
|
|
353
|
+
if (p === 'codex' && (row.type === 'turn_context' || row.type === 'session_meta')) {
|
|
354
|
+
const cwd = (row.payload as Record<string, unknown> | null)?.cwd
|
|
355
|
+
if (typeof cwd === 'string' && isAbsolute(cwd)) codexCwd = cwd
|
|
356
|
+
}
|
|
177
357
|
const raw = assistantText(p, row)
|
|
178
358
|
if (!raw) return turn
|
|
179
359
|
// Prose cleanup can collapse a huge whitespace/code prefix, bringing an unscanned
|
|
@@ -182,36 +362,333 @@ export class AgentSessionOutputImages {
|
|
|
182
362
|
const shortened = raw.length > OUTPUT_IMAGE_SCAN_CHARS
|
|
183
363
|
const scanned = shortened ? raw.slice(0, raw.lastIndexOf('\n', OUTPUT_IMAGE_SCAN_CHARS - 1) + 1) : raw
|
|
184
364
|
const candidates = imageTokens(scanned)
|
|
185
|
-
|
|
365
|
+
// Link cards: local links only, never one inside an image token.
|
|
366
|
+
const linked = linkTokens(scanned).filter(l => isLocalTarget(l.target) && !candidates.some(c => l.start < c.end && c.start < l.end))
|
|
367
|
+
if (!candidates.length && !linked.length && !shortened) return turn
|
|
186
368
|
const recordKey = digest(JSON.stringify(row))
|
|
187
369
|
const refs = candidates.slice(0, OUTPUT_IMAGES_PER_TURN).map((c, ordinal) => {
|
|
188
370
|
const id = 'ai_' + createHmac('sha256', this.deps.secret).update(JSON.stringify([provider, sessionId, transcriptKey, recordKey, ordinal, c.source])).digest('hex')
|
|
189
|
-
|
|
190
|
-
const descriptor: SessionOutputImage =
|
|
371
|
+
// 7.0.4 S2: a secret by name is refused before containment, and its card carries the base name only.
|
|
372
|
+
const descriptor: SessionOutputImage = secretSource(root, c.source)
|
|
373
|
+
? { id, label: displayName(c.source), ...SECRET }
|
|
374
|
+
: root !== null && inside(root, resolve(root, c.source))
|
|
375
|
+
? { id, label: c.label, state: 'available' }
|
|
376
|
+
: { id, label: c.label, state: 'unavailable', reason: lookupFailed ? 'unavailable' : 'outside_workspace' }
|
|
191
377
|
sources.delete(id); sources.set(id, { candidate: c, root, descriptor })
|
|
192
|
-
while (sources.size >
|
|
378
|
+
while (sources.size > SOURCES_MAX) sources.delete(sources.keys().next().value!)
|
|
193
379
|
return descriptor
|
|
194
380
|
})
|
|
195
381
|
// Never turn an explicit local image into a browser-visible Mac path. Existing callers
|
|
196
382
|
// without this opt-in projection retain their original text and contract.
|
|
197
383
|
// The preview cap limits DTOs, not redaction: the sixth image is still private.
|
|
384
|
+
// Link cards (preview engine phase 1): each local link becomes `[words](cos-file:N)`, N its card. The Mac path
|
|
385
|
+
// never stays in the text, with previews on or off, past the card cap or not. The same target twice is one card.
|
|
386
|
+
const rowCwd = p === 'claude' ? (typeof row.cwd === 'string' && isAbsolute(row.cwd) ? row.cwd : null) : p === 'codex' ? codexCwd : null
|
|
387
|
+
const linkRefs: SessionOutputLink[] = [], byTarget = new Map<string, number>()
|
|
388
|
+
let linkExtra = 0
|
|
389
|
+
const linkWords = (token: { label: string; target: string }): string => {
|
|
390
|
+
let n = byTarget.get(token.target)
|
|
391
|
+
if (n === undefined) {
|
|
392
|
+
const resolved = resolveLinkTarget(token.target, [rowCwd, root], roots)
|
|
393
|
+
if (linkRefs.length >= LINK_CARDS_PER_TURN) { linkExtra++; return linkCardWords(token.label, resolved.name, token.target) }
|
|
394
|
+
n = linkRefs.length; byTarget.set(token.target, n)
|
|
395
|
+
linkRefs.push(this.describeLink(provider, sessionId, transcriptKey, recordKey, n, token, resolved, lookupFailed, links))
|
|
396
|
+
}
|
|
397
|
+
// Each link keeps its own words; the same target twice shares one card.
|
|
398
|
+
return '[' + linkCardWords(token.label, linkRefs[n].name, token.target) + '](cos-file:' + n + ')'
|
|
399
|
+
}
|
|
400
|
+
const edits = [
|
|
401
|
+
...candidates.map(c => ({ start: c.start, end: c.end, words: () => '[Output image]' })),
|
|
402
|
+
...linked.map(l => ({ start: l.start, end: l.end, words: () => linkWords(l) })),
|
|
403
|
+
].sort((a, b) => a.start - b.start)
|
|
198
404
|
const parts: string[] = []; let end = 0
|
|
199
|
-
for (const
|
|
405
|
+
for (const edit of edits) { parts.push(scanned.slice(end, edit.start), edit.words()); end = edit.end }
|
|
200
406
|
parts.push(scanned.slice(end))
|
|
407
|
+
if (linkExtra > 0) parts.push('\n\n' + linkOverflowSentence(linkExtra))
|
|
201
408
|
const omission = '[Reply shortened at the image preview scan limit.]'
|
|
202
|
-
|
|
409
|
+
let text = shortened
|
|
203
410
|
? [latestAssistantReply(parts.join(''), LATEST_REPLY_MAX - omission.length - 1), omission].filter(Boolean).join('\n')
|
|
204
411
|
: latestAssistantReply(parts.join(''))
|
|
412
|
+
// The reply cap can cut a card marker in half; keep its words, never a broken marker.
|
|
413
|
+
text = text.replace(/\[([^\]\n]*)\]\(cos-[^)\s]*…/g, '$1…')
|
|
205
414
|
const at = typeof row.timestamp === 'string' && Number.isFinite(Date.parse(row.timestamp)) ? row.timestamp : undefined
|
|
206
|
-
return {
|
|
415
|
+
return {
|
|
416
|
+
...(turn ?? { role: 'assistant' as const, text: '', ...(at ? { at } : {}) }), text,
|
|
417
|
+
...(candidates.length || shortened ? { outputImages: refs } : {}),
|
|
418
|
+
...(linkRefs.length ? { outputLinks: linkRefs } : {}),
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const project: SessionTurnProjection = (p, row, turn) => {
|
|
422
|
+
const shaped = markdown(p, row, turn)
|
|
423
|
+
const sent = sentUserFiles(p, row)
|
|
424
|
+
if (!sent) return shaped
|
|
425
|
+
const recordKey = digest(JSON.stringify(row))
|
|
426
|
+
// Relative paths are relative to where the session stood when it sent them, as the tool reads them.
|
|
427
|
+
const cwd = typeof row.cwd === 'string' && isAbsolute(row.cwd) ? row.cwd : root
|
|
428
|
+
const images: SessionOutputImage[] = [], out: SessionOutputFile[] = []
|
|
429
|
+
sent.files.slice(0, SHARED_FILES_PER_TURN).forEach((written, ordinal) => {
|
|
430
|
+
const name = displayName(written)
|
|
431
|
+
// Never a URL, a network path or a home shorthand: only a local path is ever opened.
|
|
432
|
+
const remote = /^[a-z][a-z0-9+.-]*:/i.test(written) || written.startsWith('//') || written.startsWith('~')
|
|
433
|
+
const lexical = remote || !cwd ? null : resolve(cwd, written)
|
|
434
|
+
let canonical: string | null = null
|
|
435
|
+
// Preview engine phase 1: a Settings extra folder is an allowed root too (Miles decision 2). Nothing else is.
|
|
436
|
+
if (lexical && (root !== null || extraRoots.length)) { try { canonical = realpathSync(lexical) } catch { canonical = null } }
|
|
437
|
+
const allowRoot = canonical !== null ? authorizedRoot(canonical, roots) : null
|
|
438
|
+
const outside = root === null && lookupFailed ? 'unavailable' as const : 'outside_workspace' as const
|
|
439
|
+
let reason: SessionOutputFile['reason'] | undefined
|
|
440
|
+
let size: number | undefined
|
|
441
|
+
// 7.0.4 S2: refused by name (as written or as it canonically resolves) before anything else is asked.
|
|
442
|
+
if (secretSource(cwd, written)) reason = 'secret_file'
|
|
443
|
+
// Preview engine phase 1: with no workspace, a Settings extra folder can still authorize the file.
|
|
444
|
+
else if (root === null && !extraRoots.length) reason = lookupFailed ? 'unavailable' : 'outside_workspace'
|
|
445
|
+
else if (!lexical) reason = 'outside_workspace'
|
|
446
|
+
// The canonical file decides: a link that leaves the workspace is outside it.
|
|
447
|
+
else if (canonical !== null && !allowRoot) reason = outside
|
|
448
|
+
else if (canonical === null) reason = authorizedRoot(lexical, roots) ? 'missing' : outside
|
|
449
|
+
else {
|
|
450
|
+
try {
|
|
451
|
+
const st = statSync(canonical)
|
|
452
|
+
if (!st.isFile()) reason = 'missing'
|
|
453
|
+
// 7.0.4 QA: a hard link is the same file under another name, and realpath cannot see it. A sent file can be
|
|
454
|
+
// any text, so one with more than one link is refused as a secret. Images go through the JPEG normaliser.
|
|
455
|
+
else if (st.nlink > 1 && !NORMALIZED_IMAGE.test(name)) reason = 'secret_file'
|
|
456
|
+
else size = st.size
|
|
457
|
+
} catch { reason = 'missing' }
|
|
458
|
+
}
|
|
459
|
+
const allowed = reason === undefined
|
|
460
|
+
if (NORMALIZED_IMAGE.test(name)) {
|
|
461
|
+
const id = 'ai_' + createHmac('sha256', this.deps.secret).update(JSON.stringify([provider, sessionId, transcriptKey, recordKey, 'sent', ordinal, written])).digest('hex')
|
|
462
|
+
const descriptor: SessionOutputImage = reason === 'secret_file' ? { id, label: name, ...SECRET } : { id, label: name, state: allowed ? 'available' : 'unavailable', ...(reason ? { reason } : {}) }
|
|
463
|
+
// The canonical path is the source, so the content route's no-symlink check holds for it.
|
|
464
|
+
sources.delete(id); sources.set(id, { candidate: { source: allowed ? canonical! : written, label: name }, root: allowRoot ?? root, descriptor })
|
|
465
|
+
while (sources.size > SOURCES_MAX) sources.delete(sources.keys().next().value!)
|
|
466
|
+
images.push(descriptor)
|
|
467
|
+
return
|
|
468
|
+
}
|
|
469
|
+
const id = 'af_' + createHmac('sha256', this.deps.secret).update(JSON.stringify([provider, sessionId, transcriptKey, recordKey, 'file', ordinal, written])).digest('hex')
|
|
470
|
+
const { kind, type } = sharedFileKind(name)
|
|
471
|
+
const file: SessionOutputFile = reason === 'secret_file' ? { id, name, kind, type, ...SECRET } : { id, name, kind, type, ...(size !== undefined ? { size } : {}), state: allowed ? 'available' : 'unavailable', ...(reason ? { reason } : {}) }
|
|
472
|
+
files.delete(id); files.set(id, { path: allowed ? canonical : null, root: allowRoot ?? root, file })
|
|
473
|
+
while (files.size > SOURCES_MAX) files.delete(files.keys().next().value!)
|
|
474
|
+
out.push(file)
|
|
475
|
+
})
|
|
476
|
+
// The caption is the session's own words; an inline image in it is never a way to show a path.
|
|
477
|
+
const tokens = imageTokens(sent.caption)
|
|
478
|
+
let caption = '', end = 0
|
|
479
|
+
for (const token of tokens) { caption += sent.caption.slice(end, token.start) + '[Output image]'; end = token.end }
|
|
480
|
+
caption += sent.caption.slice(end)
|
|
481
|
+
const extra = sent.files.length - SHARED_FILES_PER_TURN
|
|
482
|
+
const words = [caption && latestAssistantReply(caption), extra > 0 ? extra + (extra === 1 ? ' more file was sent and is not listed here.' : ' more files were sent and are not listed here.') : ''].filter(Boolean).join('\n\n')
|
|
483
|
+
const at = typeof row.timestamp === 'string' && Number.isFinite(Date.parse(row.timestamp)) ? row.timestamp : undefined
|
|
484
|
+
const base = shaped ?? { role: 'assistant' as const, text: '', ...(at ? { at } : {}) }
|
|
485
|
+
return {
|
|
486
|
+
...base,
|
|
487
|
+
text: [base.text, words].filter(Boolean).join('\n\n'),
|
|
488
|
+
...(images.length || base.outputImages?.length ? { outputImages: [...(base.outputImages ?? []), ...images] } : {}),
|
|
489
|
+
...(out.length ? { outputFiles: out } : {}),
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
// ── Images the session looked at: tool results, and images attached to prompts ──
|
|
493
|
+
// The reader shows messages, not tool calls, so a step's images ride on the assistant turn the step followed
|
|
494
|
+
// (the words before it, as the Claude app shows the step under them). A step that came before any words in this
|
|
495
|
+
// window waits for the next assistant turn and is marked `before`. A user turn ends a step that never got words.
|
|
496
|
+
const toolSources = new Map<string, ToolSource>()
|
|
497
|
+
const uses = new Map<string, ToolUseMeta>()
|
|
498
|
+
let groups = new WeakMap<SessionTurn, { before: number; after: number }>()
|
|
499
|
+
let last: SessionTurn | null = null
|
|
500
|
+
let pending: SessionToolImage[] = [], pendingExtra = 0
|
|
501
|
+
const recordKeyOf = (row: Record<string, unknown>) => typeof row.uuid === 'string' && row.uuid ? 'u:' + row.uuid : digest(JSON.stringify(row))
|
|
502
|
+
const describe = (recordKey: string, scope: 'tool' | 'prompt', ordinal: number, found: FoundToolImage): SessionToolImage => {
|
|
503
|
+
const id = 'ti_' + createHmac('sha256', this.deps.secret).update(JSON.stringify([provider, sessionId, transcriptKey, recordKey, scope, ordinal])).digest('hex')
|
|
504
|
+
const ref = found.ref
|
|
505
|
+
let reason: SessionToolImage['reason'] | undefined
|
|
506
|
+
if (ref.kind === 'embedded') {
|
|
507
|
+
// 7.0.4 QA: a Read's embedded bytes are judged by the file it read; bytes with no path (a screenshot, a prompt
|
|
508
|
+
// attachment) have no name to judge.
|
|
509
|
+
if (ref.path && secretSource(root, ref.path)) reason = 'secret_file'
|
|
510
|
+
else if (estimateBytes(ref.data) > MAX_TOOL_IMAGE_BYTES) reason = 'too_large'
|
|
511
|
+
else if (id === want) toolSources.set(id, { kind: 'embedded', mediaType: ref.mediaType, data: ref.data })
|
|
512
|
+
} else if (ref.kind === 'path') {
|
|
513
|
+
// A Read that names a path and carries no bytes: the output-image rule, the session's exact workspace only.
|
|
514
|
+
// 7.0.4 S2: a secret by name is refused first (embedded bytes from a Read are judged by the same name above).
|
|
515
|
+
let canonical: string | null = null
|
|
516
|
+
if (secretSource(root, ref.path)) reason = 'secret_file'
|
|
517
|
+
else if (root === null) reason = lookupFailed ? 'unavailable' : 'outside_workspace'
|
|
518
|
+
else if (!inside(root, resolve(root, ref.path))) reason = 'outside_workspace'
|
|
519
|
+
else {
|
|
520
|
+
// The canonical file decides, as for sent files: a link that leaves the workspace is outside it. Resolved
|
|
521
|
+
// once: a second realpath could throw if the file went away in between (7.0.4 QA), which lost the history.
|
|
522
|
+
try { canonical = realpathSync(resolve(root, ref.path)) } catch { canonical = null }
|
|
523
|
+
if (canonical === null) reason = 'missing'
|
|
524
|
+
else if (!inside(root, canonical)) reason = 'outside_workspace'
|
|
525
|
+
}
|
|
526
|
+
if (root !== null && !reason && canonical !== null) {
|
|
527
|
+
// The canonical path is the source, as for sent images, so the content read's no-link check holds for it.
|
|
528
|
+
toolSources.delete(id); toolSources.set(id, { kind: 'path', source: canonical, root, label: found.tool })
|
|
529
|
+
while (toolSources.size > SOURCES_MAX) toolSources.delete(toolSources.keys().next().value!)
|
|
530
|
+
}
|
|
531
|
+
} else reason = 'missing'
|
|
532
|
+
return { id, tool: found.tool, index: 0, total: 0, state: reason ? 'unavailable' : 'available', ...(reason ? { reason } : {}), ...(reason === 'secret_file' ? { notice: SECRET_FILE_NOTICE } : {}) }
|
|
533
|
+
}
|
|
534
|
+
// The turn is already in the reader's list, so it is extended in place. Past the cap an image is counted, never listed.
|
|
535
|
+
const attach = (turn: SessionTurn, images: SessionToolImage[], extra: number, before: boolean) => {
|
|
536
|
+
const g = groups.get(turn) ?? { before: 0, after: 0 }; groups.set(turn, g)
|
|
537
|
+
const key = before ? 'before' : 'after'
|
|
538
|
+
const list = turn.toolImages ?? []
|
|
539
|
+
for (const d of images) {
|
|
540
|
+
g[key]++
|
|
541
|
+
if (list.length < TOOL_IMAGES_PER_TURN) list.push({ ...d, index: g[key], ...(before ? { before: true as const } : {}) })
|
|
542
|
+
else turn.toolImagesMore = (turn.toolImagesMore ?? 0) + 1
|
|
543
|
+
}
|
|
544
|
+
if (extra > 0) { g[key] += extra; turn.toolImagesMore = (turn.toolImagesMore ?? 0) + extra }
|
|
545
|
+
for (const d of list) if ((d.before === true) === before) d.total = g[key]
|
|
546
|
+
if (list.length) turn.toolImages = list
|
|
207
547
|
}
|
|
208
|
-
|
|
548
|
+
const withTools: SessionTurnProjection = (p, row, turn) => {
|
|
549
|
+
for (const [id, meta] of toolUsesOf(p, row)) {
|
|
550
|
+
uses.delete(id); uses.set(id, meta)
|
|
551
|
+
while (uses.size > TOOL_USES_MAX) uses.delete(uses.keys().next().value!)
|
|
552
|
+
}
|
|
553
|
+
let result = project(p, row, turn)
|
|
554
|
+
const found = toolResultImages(p, row, uses)
|
|
555
|
+
if (found.length) {
|
|
556
|
+
const key = recordKeyOf(row)
|
|
557
|
+
const described = found.map((f, i) => describe(key, 'tool', i, f))
|
|
558
|
+
if (last && last.role === 'assistant') attach(last, described, 0, false)
|
|
559
|
+
else for (const d of described) { if (pending.length < TOOL_IMAGES_PER_TURN) pending.push(d); else pendingExtra++ }
|
|
560
|
+
}
|
|
561
|
+
const attached = promptImages(p, row)
|
|
562
|
+
if (attached.length) {
|
|
563
|
+
// A Claude prompt that is only an image (sent through the SDK) is still something the person said.
|
|
564
|
+
if (!result && p === 'claude' && !hasWords(row)) {
|
|
565
|
+
const at = typeof row.timestamp === 'string' && Number.isFinite(Date.parse(row.timestamp)) ? row.timestamp : undefined
|
|
566
|
+
result = { role: 'user', text: attached.length === 1 ? '[Image]' : `[${attached.length} images]`, ...(at ? { at } : {}) }
|
|
567
|
+
}
|
|
568
|
+
if (result && result.role === 'user') {
|
|
569
|
+
const key = recordKeyOf(row)
|
|
570
|
+
attach(result, attached.map((ref, i) => describe(key, 'prompt', i, { tool: 'Attached', ref })), 0, false)
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
if (result) {
|
|
574
|
+
if (result.role === 'assistant' && (pending.length || pendingExtra)) attach(result, pending, pendingExtra, true)
|
|
575
|
+
pending = []; pendingExtra = 0
|
|
576
|
+
last = result
|
|
577
|
+
}
|
|
578
|
+
return result
|
|
579
|
+
}
|
|
580
|
+
withTools.reset = () => {
|
|
581
|
+
// readRecentSessionTurns may grow from 2 to 8 to 24 MiB. Each pass must project
|
|
582
|
+
// only its own records, not attach early images to a turn discarded with the smaller window.
|
|
583
|
+
sources.clear(); files.clear(); toolSources.clear(); uses.clear(); links.clear()
|
|
584
|
+
codexCwd = null
|
|
585
|
+
groups = new WeakMap()
|
|
586
|
+
last = null; pending = []; pendingExtra = 0
|
|
587
|
+
}
|
|
588
|
+
return { project: withTools, sources, files, toolSources, links, workspace: root }
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** One link card's descriptor; an available card's canonical source is remembered for the preview route. */
|
|
592
|
+
private describeLink(provider: AgentProvider, sessionId: string, transcriptKey: string, recordKey: string, ordinal: number,
|
|
593
|
+
token: { label: string; target: string }, resolved: ReturnType<typeof resolveLinkTarget>, lookupFailed: boolean, links: Map<string, LinkSource>): SessionOutputLink {
|
|
594
|
+
const id = 'al_' + createHmac('sha256', this.deps.secret).update(JSON.stringify([provider, sessionId, transcriptKey, recordKey, 'link', ordinal, token.target])).digest('hex')
|
|
595
|
+
const { line } = splitLineAnchor(token.target)
|
|
596
|
+
const name = resolved.name
|
|
597
|
+
const typed = resolved.state === 'available' && resolved.isDir ? { kind: 'folder' as PreviewKind, type: 'Folder' } : previewKind(name)
|
|
598
|
+
const link: SessionOutputLink = {
|
|
599
|
+
id, label: linkLabel(token.label, name), name, kind: typed.kind, type: typed.type,
|
|
600
|
+
...(resolved.state === 'available' && resolved.size !== undefined ? { size: resolved.size } : {}),
|
|
601
|
+
...(line ? { line } : {}),
|
|
602
|
+
state: resolved.state,
|
|
603
|
+
...(resolved.state === 'unavailable' ? { reason: resolved.reason === 'outside_workspace' && lookupFailed ? 'unavailable' as const : resolved.reason } : {}),
|
|
604
|
+
...(resolved.state === 'unavailable' && resolved.reason === 'secret_file' ? { notice: SECRET_FILE_NOTICE } : {}),
|
|
605
|
+
}
|
|
606
|
+
if (resolved.state === 'available') {
|
|
607
|
+
links.delete(id); links.set(id, { path: resolved.path, root: resolved.root, link })
|
|
608
|
+
while (links.size > SOURCES_MAX) links.delete(links.keys().next().value!)
|
|
609
|
+
}
|
|
610
|
+
return link
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* A link card (`al_`) or a sent file (`af_`), re-authorized for the preview route: the id must name a card or file in
|
|
615
|
+
* the session's newest turns, its canonical path must still be inside the root it was authorized under (the workspace
|
|
616
|
+
* or a Settings extra folder, read fresh), never a denied secret name, no component a link, and the same file before
|
|
617
|
+
* and after the read. `peek` reads no bytes (the meta part and image parts).
|
|
618
|
+
*/
|
|
619
|
+
async previewFile(provider: AgentProvider, sessionId: string, id: string, peek = false): Promise<
|
|
620
|
+
| { status: 'ok'; bytes: Buffer; name: string; kind: PreviewKind; type: string; line?: number; identity: { dev: number; ino: number; size: number; mtimeMs: number; ctimeMs: number }; path: string; root: string; descriptor: SessionOutputLink | SessionOutputFile }
|
|
621
|
+
| { status: 'not_found' | 'unavailable' | 'too_large' | 'secret' | 'folder' | 'busy'; reason?: string }> {
|
|
622
|
+
const isLink = SESSION_OUTPUT_LINK_ID.test(id)
|
|
623
|
+
if (!isLink && !SESSION_OUTPUT_FILE_ID.test(id)) return { status: 'not_found' }
|
|
624
|
+
// 7.0.4 QA: at the limit the answer is "busy" (503 previews_busy, retried by the web), never "unavailable".
|
|
625
|
+
if (this.readers >= OUTPUT_IMAGE_CONCURRENCY) return { status: 'busy' }
|
|
626
|
+
this.readers++
|
|
627
|
+
try {
|
|
628
|
+
const transcript = await this.deps.findSession(provider, sessionId)
|
|
629
|
+
if (!transcript) return { status: 'not_found' }
|
|
630
|
+
const { context, turns } = await this.previewProjection(provider, sessionId, transcript)
|
|
631
|
+
const descriptor = isLink
|
|
632
|
+
? turns.flatMap(t => t.outputLinks ?? []).find(l => l.id === id)
|
|
633
|
+
: turns.flatMap(t => t.outputFiles ?? []).find(f => f.id === id)
|
|
634
|
+
if (!descriptor) return { status: 'not_found' }
|
|
635
|
+
// 7.0.4 S2: an id that names a secret answers 403 secret_file_denied, never bytes.
|
|
636
|
+
if (descriptor.reason === 'secret_file') return { status: 'secret' }
|
|
637
|
+
let path: string | null = null, root: string | null = null
|
|
638
|
+
if (isLink) { const s = context.links.get(id); path = s?.path ?? null; root = s?.root ?? null }
|
|
639
|
+
else { const s = context.files.get(id); path = s?.path ?? null; root = s?.root ?? null }
|
|
640
|
+
if (descriptor.state !== 'available' || !path || !root) return { status: 'unavailable' }
|
|
641
|
+
// The root must still be allowed NOW: a Settings extra folder removed since the projection no longer counts.
|
|
642
|
+
if (root !== context.workspace) {
|
|
643
|
+
let extra: readonly string[] = []
|
|
644
|
+
try { extra = this.deps.extraRoots?.() ?? [] } catch { extra = [] }
|
|
645
|
+
if (!extra.includes(root)) return { status: 'unavailable' }
|
|
646
|
+
}
|
|
647
|
+
// The shared deny list, before any byte is read (and for a file sent before the list existed).
|
|
648
|
+
if (isDeniedSecretPath(path)) return { status: 'secret' }
|
|
649
|
+
if ((descriptor as SessionOutputLink).kind === 'folder') return { status: 'folder' }
|
|
650
|
+
if (!insideRoot(root, path) || realpathSync(path) !== path) return { status: 'unavailable' }
|
|
651
|
+
const { kind, type } = previewKind(descriptor.name)
|
|
652
|
+
const line = isLink ? (descriptor as SessionOutputLink).line : undefined
|
|
653
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
654
|
+
try {
|
|
655
|
+
const before = await file.stat()
|
|
656
|
+
if (!before.isFile()) return { status: 'unavailable' }
|
|
657
|
+
// 7.0.4 QA (the sent-file rule, for link cards too): a file with another hard link may be a secret under an
|
|
658
|
+
// innocent name, which realpath cannot see. Pictures go through the JPEG normaliser and are exempt.
|
|
659
|
+
if (before.nlink > 1 && !NORMALIZED_IMAGE.test(descriptor.name)) return { status: 'secret' }
|
|
660
|
+
if (before.size > MAX_SHARED_FILE_BYTES) return { status: 'too_large' }
|
|
661
|
+
const identity = { dev: before.dev, ino: before.ino, size: before.size, mtimeMs: before.mtimeMs, ctimeMs: before.ctimeMs }
|
|
662
|
+
const base = { status: 'ok' as const, name: descriptor.name, kind, type, ...(line ? { line } : {}), identity, path, root, descriptor }
|
|
663
|
+
if (peek) return { ...base, bytes: Buffer.alloc(0) }
|
|
664
|
+
const bytes = Buffer.alloc(before.size)
|
|
665
|
+
let offset = 0
|
|
666
|
+
while (offset < bytes.length) { const got = await file.read(bytes, offset, bytes.length - offset, offset); if (!got.bytesRead) break; offset += got.bytesRead }
|
|
667
|
+
const after = await file.stat(), current = statSync(path)
|
|
668
|
+
if (offset !== bytes.length || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs
|
|
669
|
+
|| current.dev !== before.dev || current.ino !== before.ino || realpathSync(path) !== path) return { status: 'unavailable' }
|
|
670
|
+
return { ...base, bytes }
|
|
671
|
+
} finally { await file.close() }
|
|
672
|
+
} catch (error) {
|
|
673
|
+
// QA 7.0.5: the route logs this once, as reason=error_<code> (never the message, which carries the path).
|
|
674
|
+
return { status: 'unavailable', reason: 'error_' + previewErrorCode(error) }
|
|
675
|
+
}
|
|
676
|
+
finally { this.readers-- }
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** A link card or sent file that is a picture, through the same normalizer as output images (a JPEG). */
|
|
680
|
+
async previewImage(provider: AgentProvider, sessionId: string, id: string, variant: 'thumb' | 'phone') {
|
|
681
|
+
const found = await this.previewFile(provider, sessionId, id, true)
|
|
682
|
+
if (found.status !== 'ok') return found
|
|
683
|
+
if (found.kind !== 'image') return { status: 'unavailable' as const }
|
|
684
|
+
try { return await this.workspaceImage(id, found.root, found.path, found.name, variant) } catch (error) { return { status: 'unavailable' as const, reason: 'error_' + previewErrorCode(error) } }
|
|
209
685
|
}
|
|
210
686
|
|
|
211
687
|
async content(provider: AgentProvider, sessionId: string, imageId: string, variant: 'thumb' | 'phone') {
|
|
212
688
|
if (!SESSION_OUTPUT_IMAGE_ID.test(imageId)) return { status: 'not_found' as const }
|
|
213
|
-
// Admission covers discovery, transcript parsing, authorization and image IO alike.
|
|
214
|
-
|
|
689
|
+
// Admission covers discovery, transcript parsing, authorization and image IO alike. Full is `busy` (7.0.4 QA): the
|
|
690
|
+
// Mac is working on other previews, which is not the same as unreachable.
|
|
691
|
+
if (this.readers >= OUTPUT_IMAGE_CONCURRENCY) return { status: 'busy' as const }
|
|
215
692
|
this.readers++
|
|
216
693
|
try {
|
|
217
694
|
const transcript = await this.deps.findSession(provider, sessionId)
|
|
@@ -220,51 +697,151 @@ export class AgentSessionOutputImages {
|
|
|
220
697
|
const recent = await readRecentSessionTurns(provider, transcript, SESSION_TURNS_MAX, undefined, context.project)
|
|
221
698
|
if (!recent.turns.some(t => t.outputImages?.some(i => i.id === imageId))) return { status: 'not_found' as const }
|
|
222
699
|
const source = context.sources.get(imageId)
|
|
700
|
+
// 7.0.4 S2: after the id is authorized from the transcript, a secret by name answers 403, never bytes.
|
|
701
|
+
if (source?.descriptor.reason === 'secret_file') return { status: 'secret' as const }
|
|
223
702
|
if (!source?.root || source.descriptor.state !== 'available') return { status: 'unavailable' as const }
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
703
|
+
return await this.workspaceImage(imageId, source.root, source.candidate.source, source.candidate.label, variant)
|
|
704
|
+
} catch (error) { logPreviewFailure('output_image', error); return { status: 'unavailable' as const } }
|
|
705
|
+
finally { this.readers-- }
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* A tool-result or prompt image (`ti_` id), re-authorized from the transcript: the id must name an image in the
|
|
710
|
+
* session's newest turns. Embedded bytes are decoded here and typed by what they are; a path-only Read goes
|
|
711
|
+
* through the same workspace read and normalizer as an output image.
|
|
712
|
+
*/
|
|
713
|
+
async toolImageContent(provider: AgentProvider, sessionId: string, imageId: string): Promise<
|
|
714
|
+
{ status: 'ok'; bytes: Buffer; type: string } | { status: 'ok'; path: string; mime: string; bytes: number } | { status: 'not_found' | 'unavailable' | 'too_large' | 'secret' | 'busy' }> {
|
|
715
|
+
if (!SESSION_TOOL_IMAGE_ID.test(imageId)) return { status: 'not_found' }
|
|
716
|
+
if (this.readers >= OUTPUT_IMAGE_CONCURRENCY) return { status: 'busy' }
|
|
717
|
+
this.readers++
|
|
718
|
+
try {
|
|
719
|
+
const transcript = await this.deps.findSession(provider, sessionId)
|
|
720
|
+
if (!transcript) return { status: 'not_found' }
|
|
721
|
+
const context = await this.context(provider, sessionId, transcript, imageId)
|
|
722
|
+
const recent = await readRecentSessionTurns(provider, transcript, SESSION_TURNS_MAX, undefined, context.project)
|
|
723
|
+
const descriptor = recent.turns.flatMap(t => t.toolImages ?? []).find(i => i.id === imageId)
|
|
724
|
+
if (!descriptor) return { status: 'not_found' }
|
|
725
|
+
if (descriptor.reason === 'too_large') return { status: 'too_large' }
|
|
726
|
+
if (descriptor.reason === 'secret_file') return { status: 'secret' }
|
|
727
|
+
const source = context.toolSources.get(imageId)
|
|
728
|
+
if (descriptor.state !== 'available' || !source) return { status: 'unavailable' }
|
|
729
|
+
if (source.kind === 'embedded') return decodeToolImage(source)
|
|
730
|
+
const normalized = await this.workspaceImage(imageId, source.root, source.source, source.label, 'phone')
|
|
731
|
+
return normalized.status === 'ok' ? normalized : normalized.status === 'busy' ? { status: 'busy' } : { status: 'unavailable' }
|
|
732
|
+
} catch (error) { logPreviewFailure('tool_image', error); return { status: 'unavailable' } }
|
|
733
|
+
finally { this.readers-- }
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** The workspace read every path-based preview shares: exact containment, no links, the same file before and after. */
|
|
737
|
+
private async workspaceImage(imageId: string, root: string, written: string, label: string, variant: 'thumb' | 'phone') {
|
|
738
|
+
const source = { root, candidate: { source: written, label } }
|
|
739
|
+
const path = resolve(source.root, source.candidate.source)
|
|
740
|
+
const canonical = realpathSync(path)
|
|
741
|
+
if (!inside(source.root, canonical)) return { status: 'unavailable' as const }
|
|
742
|
+
// Reject symlinks in any component and recheck path/fd identity after reading.
|
|
743
|
+
// A changed path never becomes a published preview.
|
|
744
|
+
if (canonical !== path) return { status: 'unavailable' as const }
|
|
745
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
746
|
+
let bytes: Buffer, key: string
|
|
747
|
+
try {
|
|
748
|
+
const before = await file.stat()
|
|
749
|
+
if (!before.isFile() || before.size <= 0 || before.size > MAX_OUTPUT_ARTIFACT_BYTES) return { status: 'unavailable' as const }
|
|
750
|
+
key = digest(JSON.stringify([imageId, before.dev, before.ino, before.size, before.mtimeMs, before.ctimeMs]))
|
|
751
|
+
// Even a cache hit rechecks that the authorized source still exists within the root.
|
|
752
|
+
const hit = this.cache.get(key)
|
|
753
|
+
if (hit && this.deps.now() - hit.at < CACHE_TTL) {
|
|
754
|
+
const current = statSync(path)
|
|
755
|
+
if (realpathSync(path) !== canonical || current.dev !== before.dev || current.ino !== before.ino) return { status: 'unavailable' as const }
|
|
756
|
+
const content = this.deps.store().getContent(hit.id, variant)
|
|
757
|
+
if (content.status === 'ok') return content
|
|
758
|
+
}
|
|
759
|
+
// Bounded positional read, not readFile(): an append during read cannot allocate past the cap.
|
|
760
|
+
bytes = Buffer.alloc(before.size)
|
|
761
|
+
let offset = 0
|
|
762
|
+
while (offset < bytes.length) { const got = await file.read(bytes, offset, bytes.length - offset, offset); if (!got.bytesRead) break; offset += got.bytesRead }
|
|
763
|
+
const after = await file.stat(), current = statSync(path)
|
|
764
|
+
if (offset !== bytes.length || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs || current.dev !== before.dev || current.ino !== before.ino || realpathSync(path) !== canonical) return { status: 'unavailable' as const }
|
|
765
|
+
} finally { await file.close() }
|
|
766
|
+
let job = this.pending.get(key!)
|
|
767
|
+
if (!job) {
|
|
768
|
+
if (this.active >= OUTPUT_IMAGE_CONCURRENCY) return { status: 'busy' as const }
|
|
769
|
+
this.active++
|
|
770
|
+
job = this.deps.store().ingestOutputImage({ bytes: bytes!, kind: 'generated_visual', label: source.candidate.label }).then(ref => {
|
|
771
|
+
this.cache.set(key!, { id: ref.id, at: this.deps.now() })
|
|
772
|
+
while (this.cache.size > OUTPUT_IMAGE_CACHE_MAX) this.cache.delete(this.cache.keys().next().value!)
|
|
773
|
+
return ref.id
|
|
774
|
+
}).finally(() => { this.active--; this.pending.delete(key!) })
|
|
775
|
+
this.pending.set(key!, job)
|
|
776
|
+
}
|
|
777
|
+
return this.deps.store().getContent(await job, variant)
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* The bytes of a file a session sent (6.99.35), re-authorized like an image: the id must name a
|
|
782
|
+
* file in the session's newest turns, its canonical path must still be inside the exact workspace,
|
|
783
|
+
* no component may be a link, and the file must be the same one before and after the read.
|
|
784
|
+
*/
|
|
785
|
+
async fileContent(provider: AgentProvider, sessionId: string, fileId: string): Promise<
|
|
786
|
+
{ status: 'ok'; bytes: Buffer; name: string; kind: SharedFileKind } | { status: 'not_found' | 'unavailable' | 'too_large' | 'secret' | 'busy' }> {
|
|
787
|
+
if (!SESSION_OUTPUT_FILE_ID.test(fileId)) return { status: 'not_found' }
|
|
788
|
+
if (this.readers >= OUTPUT_IMAGE_CONCURRENCY) return { status: 'busy' }
|
|
789
|
+
this.readers++
|
|
790
|
+
try {
|
|
791
|
+
const transcript = await this.deps.findSession(provider, sessionId)
|
|
792
|
+
if (!transcript) return { status: 'not_found' }
|
|
793
|
+
const context = await this.context(provider, sessionId, transcript)
|
|
794
|
+
const recent = await readRecentSessionTurns(provider, transcript, SESSION_TURNS_MAX, undefined, context.project)
|
|
795
|
+
if (!recent.turns.some(t => t.outputFiles?.some(f => f.id === fileId))) return { status: 'not_found' }
|
|
796
|
+
const source = context.files.get(fileId)
|
|
797
|
+
if (source?.file.reason === 'secret_file') return { status: 'secret' }
|
|
798
|
+
if (!source?.root || !source.path || source.file.state !== 'available') return { status: 'unavailable' }
|
|
799
|
+
const path = source.path
|
|
800
|
+
if (!inside(source.root, path) || realpathSync(path) !== path) return { status: 'unavailable' }
|
|
230
801
|
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
231
|
-
let bytes: Buffer, key: string
|
|
232
802
|
try {
|
|
233
803
|
const before = await file.stat()
|
|
234
|
-
if (!before.isFile()
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const current = statSync(path)
|
|
240
|
-
if (realpathSync(path) !== canonical || current.dev !== before.dev || current.ino !== before.ino) return { status: 'unavailable' as const }
|
|
241
|
-
const content = this.deps.store().getContent(hit.id, variant)
|
|
242
|
-
if (content.status === 'ok') return content
|
|
243
|
-
}
|
|
244
|
-
// Bounded positional read, not readFile(): an append during read cannot allocate past the cap.
|
|
245
|
-
bytes = Buffer.alloc(before.size)
|
|
804
|
+
if (!before.isFile()) return { status: 'unavailable' }
|
|
805
|
+
// 7.0.4 QA: the hard-link rule again on the open file, so a link made after the listing is refused too.
|
|
806
|
+
if (before.nlink > 1) return { status: 'secret' }
|
|
807
|
+
if (before.size > MAX_SHARED_FILE_BYTES) return { status: 'too_large' }
|
|
808
|
+
const bytes = Buffer.alloc(before.size)
|
|
246
809
|
let offset = 0
|
|
247
810
|
while (offset < bytes.length) { const got = await file.read(bytes, offset, bytes.length - offset, offset); if (!got.bytesRead) break; offset += got.bytesRead }
|
|
248
811
|
const after = await file.stat(), current = statSync(path)
|
|
249
|
-
if (offset !== bytes.length || after.size !== before.size || after.mtimeMs !== before.mtimeMs ||
|
|
812
|
+
if (offset !== bytes.length || after.size !== before.size || after.mtimeMs !== before.mtimeMs || current.dev !== before.dev || current.ino !== before.ino || realpathSync(path) !== path) return { status: 'unavailable' }
|
|
813
|
+
return { status: 'ok', bytes, name: source.file.name, kind: source.file.kind }
|
|
250
814
|
} finally { await file.close() }
|
|
251
|
-
|
|
252
|
-
if (!job) {
|
|
253
|
-
if (this.active >= OUTPUT_IMAGE_CONCURRENCY) return { status: 'unavailable' as const }
|
|
254
|
-
this.active++
|
|
255
|
-
job = this.deps.store().ingestOutputImage({ bytes: bytes!, kind: 'generated_visual', label: source.candidate.label }).then(ref => {
|
|
256
|
-
this.cache.set(key!, { id: ref.id, at: this.deps.now() })
|
|
257
|
-
while (this.cache.size > OUTPUT_IMAGE_CACHE_MAX) this.cache.delete(this.cache.keys().next().value!)
|
|
258
|
-
return ref.id
|
|
259
|
-
}).finally(() => { this.active--; this.pending.delete(key!) })
|
|
260
|
-
this.pending.set(key!, job)
|
|
261
|
-
}
|
|
262
|
-
return this.deps.store().getContent(await job, variant)
|
|
263
|
-
} catch { return { status: 'unavailable' as const } }
|
|
815
|
+
} catch (error) { logPreviewFailure('sent_file', error); return { status: 'unavailable' } }
|
|
264
816
|
finally { this.readers-- }
|
|
265
817
|
}
|
|
266
818
|
}
|
|
267
819
|
|
|
820
|
+
/** 7.0.4 QA: a preview read that failed is logged with its kind and error code only (lib/preview-error-code.ts). */
|
|
821
|
+
function logPreviewFailure(kind: 'output_image' | 'tool_image' | 'sent_file', error: unknown): void {
|
|
822
|
+
console.warn(`[agent-session-previews] ${kind} read failed error=${previewErrorCode(error)}`)
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** Content-Type, Content-Disposition and CSP for a sent file. HTML is never served as HTML. */
|
|
826
|
+
export function sharedFileHeaders(name: string, kind: SharedFileKind): Record<string, string> {
|
|
827
|
+
const ascii = name.replace(/[^A-Za-z0-9._ -]/g, '_').replace(/^[ .]+/, '') || 'file'
|
|
828
|
+
const inline = kind === 'pdf'
|
|
829
|
+
const type = kind === 'pdf' ? 'application/pdf'
|
|
830
|
+
: kind === 'text' ? 'text/plain; charset=utf-8'
|
|
831
|
+
: kind === 'image' && /\.gif$/i.test(name) ? 'image/gif'
|
|
832
|
+
: 'application/octet-stream'
|
|
833
|
+
const headers: Record<string, string> = {
|
|
834
|
+
'Content-Type': type,
|
|
835
|
+
'Content-Disposition': `${inline ? 'inline' : 'attachment'}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`,
|
|
836
|
+
'X-Content-Type-Options': 'nosniff',
|
|
837
|
+
'Cache-Control': 'private, no-store',
|
|
838
|
+
'Cross-Origin-Resource-Policy': 'same-origin',
|
|
839
|
+
}
|
|
840
|
+
// Opened directly, nothing in it runs and it cannot reach this origin. A PDF keeps the browser's viewer.
|
|
841
|
+
if (!inline) headers['Content-Security-Policy'] = "sandbox; default-src 'none'"
|
|
842
|
+
return headers
|
|
843
|
+
}
|
|
844
|
+
|
|
268
845
|
export const agentSessionOutputImages = new AgentSessionOutputImages({
|
|
269
846
|
findSession: (p, id) => findAgentSessionFile(p, id, agentSessionRoots()),
|
|
270
847
|
workspace: async (provider, id, transcript) => {
|
|
@@ -277,4 +854,5 @@ export const agentSessionOutputImages = new AgentSessionOutputImages({
|
|
|
277
854
|
},
|
|
278
855
|
store: getMediaStore,
|
|
279
856
|
secret: randomBytes(32), now: Date.now,
|
|
857
|
+
extraRoots: () => previewFoldersSetting.read().folders,
|
|
280
858
|
})
|