@gotcos/glasses-server 6.2.1 → 6.5.0

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.
@@ -0,0 +1,442 @@
1
+ // Run-scoped output image publishing foundation (Release C).
2
+ //
3
+ // A model process receives only a capability-scoped executable + private env.
4
+ // The executable copies already-local image artifacts into a unique 0700 /tmp
5
+ // inbox and appends an opaque id + generic provenance to JSONL. Once the model
6
+ // settles, the bridge calls collect(): bytes pass through MediaStore.ingestOutputImage
7
+ // (the single image-safety/normalization boundary), refs are associated with
8
+ // the run's COS session/message, and only public MediaAttachmentRef objects
9
+ // leave this module. cleanup() removes all publisher scratch data on either
10
+ // success or error.
11
+
12
+ import { createHash, randomBytes } from 'node:crypto'
13
+ import {
14
+ chmodSync,
15
+ closeSync,
16
+ constants,
17
+ existsSync,
18
+ fstatSync,
19
+ lstatSync,
20
+ mkdirSync,
21
+ mkdtempSync,
22
+ openSync,
23
+ readFileSync,
24
+ readdirSync,
25
+ realpathSync,
26
+ rmSync,
27
+ utimesSync,
28
+ writeFileSync,
29
+ } from 'node:fs'
30
+ import { join, resolve, sep } from 'node:path'
31
+ import {
32
+ MAX_ATTACHMENTS_PER_PROMPT,
33
+ type MediaAttachmentRef,
34
+ } from '../../shared/media-attachment.js'
35
+ import { MAX_OUTPUT_ARTIFACT_BYTES } from './image-safety.js'
36
+ import { getMediaStore, type MediaStore } from './media-store.js'
37
+
38
+ export type OutputImageProvenance = 'generated' | 'research' | 'email'
39
+
40
+ export interface RunOutputImageTarget {
41
+ sessionId: string
42
+ globalMsgNum?: number
43
+ runId?: string
44
+ }
45
+
46
+ export interface CreateRunOutputImagePublisherOptions extends RunOutputImageTarget {
47
+ mediaStore?: MediaStore
48
+ /** Remaining attachment capacity after request-side photos; clamped 0..5. */
49
+ maxImages?: number
50
+ /** Test-only/custom parent. It must remain beneath /tmp. */
51
+ tempRoot?: string
52
+ }
53
+
54
+ export interface RunOutputImagePublisher {
55
+ /** Append to the model prompt; contains no capability, directory, or path. */
56
+ readonly promptInstructions: string
57
+ /** Merge into the spawned CLI environment. Never serialize this object. */
58
+ readonly env: Readonly<Record<string, string>>
59
+ /** Append to Claude CLI's comma-delimited --allowedTools value. */
60
+ readonly claudeAllowedTool: string
61
+ /** Private per-run directory that Codex may receive through --add-dir.
62
+ * Server-internal only: never serialize this path to a client or ledger. */
63
+ readonly writableDirectory: string
64
+ /** Ingest + associate newly published images; safe to call/replay. */
65
+ collect(): Promise<MediaAttachmentRef[]>
66
+ /** Safe aggregate from the most recent completed collect; no paths/ids. */
67
+ readonly stats: Readonly<RunOutputImageCollectionStats>
68
+ /** Remove the private run directory. Idempotent; call on every terminal path. */
69
+ cleanup(): void
70
+ }
71
+
72
+ export interface RunOutputImageCollectionStats {
73
+ /** Valid, unique manifest publications considered by the collector. */
74
+ published: number
75
+ /** Publications durably associated with the target message/run. */
76
+ attached: number
77
+ /** Publications rejected during byte validation, ingest, or association. */
78
+ rejected: number
79
+ }
80
+
81
+ export const RUN_OUTPUT_IMAGE_DIR_PREFIX = 'cos-glasses-output-images-'
82
+ export const RUN_OUTPUT_IMAGE_STALE_MS = 2 * 60 * 60_000
83
+ const MANIFEST_MAX_BYTES = 64 * 1024
84
+ export const RUN_OUTPUT_IMAGE_COLLECTION_CONCURRENCY = 2
85
+ const ASSOCIATION_ATTEMPTS = 2
86
+ const OUTPUT_ID_RE = /^o_[a-f0-9]{32}$/
87
+ const PROVENANCE = new Set<OutputImageProvenance>(['generated', 'research', 'email'])
88
+ const LABELS: Record<OutputImageProvenance, string> = {
89
+ generated: 'Generated image',
90
+ research: 'Research image',
91
+ email: 'Email image',
92
+ }
93
+ const HELPER_PATH = resolve(import.meta.dirname, '..', 'bin', 'cos-output-image-publisher.mjs')
94
+
95
+ interface ManifestPublishEntry {
96
+ v: 1
97
+ type: 'publish'
98
+ id: string
99
+ provenance: OutputImageProvenance
100
+ }
101
+
102
+ function outputId(bytes: Buffer): string {
103
+ // Provenance is display metadata, not content identity. The first publish of
104
+ // identical bytes wins, so reusing one image for research + email consumes
105
+ // one attachment slot and creates one durable media asset.
106
+ const digest = createHash('sha256').update(bytes).digest('hex')
107
+ return `o_${digest.slice(0, 32)}`
108
+ }
109
+
110
+ interface PrivateDirectoryIdentity {
111
+ path: string
112
+ dev: number
113
+ ino: number
114
+ }
115
+
116
+ /** Validate the publisher inbox without ever chmodding an attacker-selected
117
+ * path. Opening with O_NOFOLLOW + O_DIRECTORY and comparing inode/device on
118
+ * both sides narrows the lstat/open race available in Node (which has no
119
+ * openat). Call again around each file open to detect path replacement. */
120
+ function validateItemsDirectory(runDir: string, expected?: PrivateDirectoryIdentity): PrivateDirectoryIdentity {
121
+ const path = join(runDir, 'items')
122
+ let fd = -1
123
+ try {
124
+ const before = lstatSync(path)
125
+ if (!before.isDirectory() || before.isSymbolicLink() || (before.mode & 0o777) !== 0o700) {
126
+ throw new Error('publisher items directory is invalid')
127
+ }
128
+ if (typeof process.getuid === 'function' && before.uid !== process.getuid()) {
129
+ throw new Error('publisher items directory has the wrong owner')
130
+ }
131
+ const real = realpathSync(path)
132
+ if (real !== path || !real.startsWith(`${runDir}${sep}`)) {
133
+ throw new Error('publisher items directory escapes the run directory')
134
+ }
135
+ fd = openSync(path, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) | (constants.O_NOFOLLOW ?? 0))
136
+ const opened = fstatSync(fd)
137
+ const after = lstatSync(path)
138
+ if (!opened.isDirectory() || opened.dev !== before.dev || opened.ino !== before.ino ||
139
+ after.isSymbolicLink() || after.dev !== opened.dev || after.ino !== opened.ino ||
140
+ (opened.mode & 0o777) !== 0o700 ||
141
+ (typeof process.getuid === 'function' && opened.uid !== process.getuid())) {
142
+ throw new Error('publisher items directory changed during validation')
143
+ }
144
+ if (expected && (opened.dev !== expected.dev || opened.ino !== expected.ino)) {
145
+ throw new Error('publisher items directory was replaced')
146
+ }
147
+ return { path, dev: opened.dev, ino: opened.ino }
148
+ } finally {
149
+ if (fd >= 0) {
150
+ try { closeSync(fd) } catch { /* best effort */ }
151
+ }
152
+ }
153
+ }
154
+
155
+ async function mapWithConcurrency<T, R>(
156
+ items: readonly T[],
157
+ concurrency: number,
158
+ worker: (item: T, index: number) => Promise<R>,
159
+ ): Promise<R[]> {
160
+ const results = new Array<R>(items.length)
161
+ let cursor = 0
162
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
163
+ while (true) {
164
+ const index = cursor++
165
+ if (index >= items.length) return
166
+ results[index] = await worker(items[index], index)
167
+ }
168
+ })
169
+ await Promise.all(workers)
170
+ return results
171
+ }
172
+
173
+ function safeTarget(options: CreateRunOutputImagePublisherOptions): RunOutputImageTarget {
174
+ const sessionId = typeof options.sessionId === 'string' ? options.sessionId.trim().slice(0, 64) : ''
175
+ if (!sessionId) throw new Error('run output image publisher requires a sessionId')
176
+ const globalMsgNum = typeof options.globalMsgNum === 'number' && Number.isFinite(options.globalMsgNum) && options.globalMsgNum > 0
177
+ ? Math.floor(options.globalMsgNum)
178
+ : undefined
179
+ const runId = typeof options.runId === 'string' && options.runId.trim()
180
+ ? options.runId.trim().slice(0, 120)
181
+ : undefined
182
+ return { sessionId, ...(globalMsgNum ? { globalMsgNum } : {}), ...(runId ? { runId } : {}) }
183
+ }
184
+
185
+ function assertTempRoot(rawRoot: string): string {
186
+ mkdirSync(rawRoot, { recursive: true, mode: 0o700 })
187
+ const root = realpathSync(rawRoot)
188
+ const tmp = realpathSync('/tmp')
189
+ if (root !== tmp && !root.startsWith(`${tmp}${sep}`)) {
190
+ throw new Error('run output image temp root must be beneath /tmp')
191
+ }
192
+ return root
193
+ }
194
+
195
+ function parseManifest(path: string, maxImages: number): ManifestPublishEntry[] {
196
+ if (maxImages <= 0) return []
197
+ if (!existsSync(path)) return []
198
+ let fd = -1
199
+ try {
200
+ const stat = lstatSync(path)
201
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MANIFEST_MAX_BYTES) {
202
+ console.warn('[output-images] publisher manifest rejected: invalid size/type')
203
+ return []
204
+ }
205
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
206
+ const seen = new Set<string>()
207
+ const entries: ManifestPublishEntry[] = []
208
+ const lines = readFileSync(fd, 'utf8').split('\n')
209
+ for (const line of lines) {
210
+ if (!line || line.length > 512) continue
211
+ try {
212
+ const raw = JSON.parse(line) as Record<string, unknown>
213
+ // Exact field allowlist: URLs, filesystem paths, bytes, and arbitrary
214
+ // model-controlled metadata make the whole line invalid.
215
+ if (Object.keys(raw).sort().join(',') !== 'id,provenance,type,v') continue
216
+ if (raw.v !== 1 || raw.type !== 'publish' || !OUTPUT_ID_RE.test(String(raw.id))) continue
217
+ if (typeof raw.provenance !== 'string' || !PROVENANCE.has(raw.provenance as OutputImageProvenance)) continue
218
+ const id = String(raw.id)
219
+ if (seen.has(id)) continue
220
+ seen.add(id)
221
+ entries.push({ v: 1, type: 'publish', id, provenance: raw.provenance as OutputImageProvenance })
222
+ if (entries.length >= maxImages) break
223
+ } catch {
224
+ // Append-only JSONL can end in a partial line after abrupt process
225
+ // termination. Preserve all complete earlier publications.
226
+ }
227
+ }
228
+ return entries
229
+ } catch (err) {
230
+ console.warn('[output-images] publisher manifest unavailable:', err instanceof Error ? err.message : String(err))
231
+ return []
232
+ } finally {
233
+ if (fd >= 0) {
234
+ try { closeSync(fd) } catch { /* best effort */ }
235
+ }
236
+ }
237
+ }
238
+
239
+ function readPublishedBytes(runDir: string, entry: ManifestPublishEntry): Buffer {
240
+ const items = validateItemsDirectory(runDir)
241
+ const path = join(items.path, `${entry.id}.img`)
242
+ let fd = -1
243
+ try {
244
+ const before = lstatSync(path)
245
+ if (!before.isFile() || before.isSymbolicLink() || before.size <= 0 || before.size > MAX_OUTPUT_ARTIFACT_BYTES) {
246
+ throw new Error('published image has invalid type or size')
247
+ }
248
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
249
+ const opened = fstatSync(fd)
250
+ validateItemsDirectory(runDir, items)
251
+ if (!opened.isFile() || opened.size !== before.size || opened.ino !== before.ino || opened.dev !== before.dev) {
252
+ throw new Error('published image changed while collecting')
253
+ }
254
+ const bytes = readFileSync(fd)
255
+ const after = fstatSync(fd)
256
+ if (after.size !== opened.size || after.ino !== opened.ino || after.dev !== opened.dev) {
257
+ throw new Error('published image changed while collecting')
258
+ }
259
+ validateItemsDirectory(runDir, items)
260
+ if (outputId(bytes) !== entry.id) {
261
+ throw new Error('published image content id mismatch')
262
+ }
263
+ return bytes
264
+ } finally {
265
+ if (fd >= 0) {
266
+ try { closeSync(fd) } catch { /* best effort */ }
267
+ }
268
+ }
269
+ }
270
+
271
+ export function cleanupStaleRunOutputImageDirs(options: {
272
+ tempRoot?: string
273
+ now?: number
274
+ staleAfterMs?: number
275
+ } = {}): number {
276
+ const root = assertTempRoot(options.tempRoot ?? '/tmp')
277
+ const now = options.now ?? Date.now()
278
+ const staleAfterMs = options.staleAfterMs ?? RUN_OUTPUT_IMAGE_STALE_MS
279
+ let removed = 0
280
+ let names: string[] = []
281
+ try { names = readdirSync(root) } catch { return 0 }
282
+ for (const name of names) {
283
+ if (!name.startsWith(RUN_OUTPUT_IMAGE_DIR_PREFIX)) continue
284
+ const path = join(root, name)
285
+ try {
286
+ const stat = lstatSync(path)
287
+ if (!stat.isDirectory() || stat.isSymbolicLink()) continue
288
+ if (now - stat.mtimeMs <= staleAfterMs) continue
289
+ rmSync(path, { recursive: true, force: true })
290
+ removed++
291
+ } catch { /* one inaccessible stale dir must not block startup */ }
292
+ }
293
+ return removed
294
+ }
295
+
296
+ /** Tool-activity guard for both bridges. Publisher commands contain a source
297
+ * path by necessity, so their raw command input must not be mirrored to SSE. */
298
+ export function isRunOutputImagePublisherCommand(raw: unknown): boolean {
299
+ if (typeof raw !== 'string') return false
300
+ return raw.includes('$COS_OUTPUT_IMAGE_PUBLISHER') ||
301
+ raw.includes(HELPER_PATH) ||
302
+ raw.includes('cos-output-image-publisher.mjs')
303
+ }
304
+
305
+ export function createRunOutputImagePublisher(
306
+ options: CreateRunOutputImagePublisherOptions,
307
+ ): RunOutputImagePublisher {
308
+ const target = safeTarget(options)
309
+ const maxImages = typeof options.maxImages === 'number' && Number.isFinite(options.maxImages)
310
+ ? Math.max(0, Math.min(MAX_ATTACHMENTS_PER_PROMPT, Math.floor(options.maxImages)))
311
+ : MAX_ATTACHMENTS_PER_PROMPT
312
+ const tempRoot = assertTempRoot(options.tempRoot ?? '/tmp')
313
+ cleanupStaleRunOutputImageDirs({ tempRoot })
314
+
315
+ const runDir = mkdtempSync(join(tempRoot, RUN_OUTPUT_IMAGE_DIR_PREFIX))
316
+ chmodSync(runDir, 0o700)
317
+ mkdirSync(join(runDir, 'items'), { mode: 0o700 })
318
+ validateItemsDirectory(runDir)
319
+ writeFileSync(join(runDir, 'manifest.jsonl'), '', { mode: 0o600 })
320
+ const capability = randomBytes(24).toString('hex')
321
+ writeFileSync(join(runDir, '.capability'), capability, { mode: 0o600 })
322
+
323
+ const mediaStore = options.mediaStore ?? getMediaStore()
324
+ const collected = new Map<string, MediaAttachmentRef>()
325
+ let collectChain: Promise<MediaAttachmentRef[]> = Promise.resolve([])
326
+ let lastStats: RunOutputImageCollectionStats = { published: 0, attached: 0, rejected: 0 }
327
+ let closed = false
328
+
329
+ const promptInstructions = [
330
+ 'OUTPUT IMAGES',
331
+ 'If an image you generated, selected during research, or explicitly used as an inbound/outbound email attachment for this request is materially useful in the answer, publish the already-local image artifact with:',
332
+ '$COS_OUTPUT_IMAGE_PUBLISHER <generated|research|email> "<absolute-local-image-path>"',
333
+ `Publish at most ${maxImages}. Never pass a URL, data URI, base64, or private/unrelated image.`,
334
+ 'Do not monitor or re-read Sent Mail just to find images; publish the selected local file during the original action.',
335
+ 'Do not include the local path in your response. A successful publisher call is enough; continue with the normal text answer.',
336
+ ].join('\n')
337
+
338
+ const collectOnce = async (): Promise<MediaAttachmentRef[]> => {
339
+ if (closed) return [...collected.values()]
340
+ const entries = parseManifest(join(runDir, 'manifest.jsonl'), maxImages)
341
+ const results = await mapWithConcurrency(
342
+ entries,
343
+ RUN_OUTPUT_IMAGE_COLLECTION_CONCURRENCY,
344
+ async (entry): Promise<MediaAttachmentRef | null> => {
345
+ let ref = collected.get(entry.id)
346
+ let newlyIngested = false
347
+ if (!ref) {
348
+ try {
349
+ const bytes = readPublishedBytes(runDir, entry)
350
+ // This is the only output-artifact ingestion boundary. It validates
351
+ // supported image containers, enforces dimensions/megapixels, then
352
+ // strips metadata and normalizes to the existing JPEG contract.
353
+ ref = await mediaStore.ingestOutputImage({
354
+ bytes,
355
+ kind: 'generated_visual',
356
+ label: LABELS[entry.provenance],
357
+ sessionId: target.sessionId,
358
+ })
359
+ newlyIngested = true
360
+ } catch (err) {
361
+ console.warn(
362
+ `[output-images] rejected ${entry.provenance} publication ${entry.id}:`,
363
+ err instanceof Error ? err.message : String(err),
364
+ )
365
+ return null
366
+ }
367
+ }
368
+
369
+ let associationError: unknown
370
+ for (let attempt = 0; attempt < ASSOCIATION_ATTEMPTS; attempt++) {
371
+ try {
372
+ // Associate on every replay. MediaStore.associate is idempotent; a
373
+ // second attempt repairs a transient index-write failure.
374
+ await mediaStore.associate([ref.id], target)
375
+ collected.set(entry.id, ref)
376
+ return ref
377
+ } catch (err) {
378
+ associationError = err
379
+ }
380
+ }
381
+
382
+ console.warn(
383
+ `[output-images] could not associate ${entry.provenance} publication ${entry.id} after ${ASSOCIATION_ATTEMPTS} attempts:`,
384
+ associationError instanceof Error ? associationError.message : String(associationError),
385
+ )
386
+ // An ingest that never became message-owned must not leak. This public
387
+ // API refuses to delete an asset if an ambiguous failure actually
388
+ // associated it, so cleanup is safe even after a post-commit throw.
389
+ if (newlyIngested) {
390
+ try {
391
+ await mediaStore.deleteUnassociated(ref.id)
392
+ } catch (err) {
393
+ console.warn(
394
+ `[output-images] could not release unassociated publication ${entry.id}:`,
395
+ err instanceof Error ? err.message : String(err),
396
+ )
397
+ }
398
+ }
399
+ return null
400
+ },
401
+ )
402
+ const attached = results.flatMap((ref) => ref ? [ref] : [])
403
+ lastStats = {
404
+ published: entries.length,
405
+ attached: attached.length,
406
+ rejected: entries.length - attached.length,
407
+ }
408
+ return attached
409
+ }
410
+
411
+ return {
412
+ promptInstructions,
413
+ writableDirectory: runDir,
414
+ env: Object.freeze({
415
+ COS_OUTPUT_IMAGE_PUBLISHER: HELPER_PATH,
416
+ COS_OUTPUT_IMAGE_DIR: runDir,
417
+ COS_OUTPUT_IMAGE_TOKEN: capability,
418
+ COS_OUTPUT_IMAGE_MAX: String(maxImages),
419
+ }),
420
+ claudeAllowedTool: 'Bash($COS_OUTPUT_IMAGE_PUBLISHER *)',
421
+ get stats() {
422
+ return Object.freeze({ ...lastStats })
423
+ },
424
+ collect() {
425
+ const run = collectChain.then(collectOnce, collectOnce)
426
+ collectChain = run
427
+ return run
428
+ },
429
+ cleanup() {
430
+ if (closed) return
431
+ closed = true
432
+ try { rmSync(runDir, { recursive: true, force: true }) } catch { /* best effort */ }
433
+ },
434
+ }
435
+ }
436
+
437
+ // Retained only to make stale-directory tests deterministic without exposing
438
+ // a production mutation surface.
439
+ export const _touchRunOutputImageDirForTests = (path: string, atMs: number): void => {
440
+ const at = new Date(atMs)
441
+ utimesSync(path, at, at)
442
+ }
@@ -0,0 +1,122 @@
1
+ import { lstatSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { tmpdir } from 'node:os'
4
+
5
+ interface LockOwner { pid: number; startedAt: string; entrypoint: string }
6
+
7
+ export interface ServerInstanceLockOptions {
8
+ lockDir?: string
9
+ pid?: number
10
+ now?: () => number
11
+ isPidAlive?: (pid: number) => boolean
12
+ }
13
+
14
+ export interface ServerInstanceLock { lockDir: string; pid: number; release: () => void }
15
+
16
+ export class ServerInstanceActiveError extends Error {
17
+ readonly ownerPid: number | null
18
+ constructor(lockDir: string, ownerPid: number | null) {
19
+ super(ownerPid
20
+ ? `COS Glasses server is already running as PID ${ownerPid} (${lockDir}).`
21
+ : `COS Glasses server startup is already in progress (${lockDir}).`)
22
+ this.name = 'ServerInstanceActiveError'
23
+ this.ownerPid = ownerPid
24
+ }
25
+ }
26
+
27
+ export class UnsafeServerLockError extends Error {
28
+ constructor(message: string) { super(message); this.name = 'UnsafeServerLockError' }
29
+ }
30
+
31
+ const OWNER_FILE = 'owner.json'
32
+ const RECLAIM_DIR = '.reclaim'
33
+ const INCOMPLETE_GRACE_MS = 5_000
34
+ const STALE_RECLAIM_MS = 30_000
35
+
36
+ function defaultLockDir(): string {
37
+ const uid = typeof process.getuid === 'function' ? process.getuid() : 'user'
38
+ return join(tmpdir(), `cos-glasses-server-${uid}.lock`)
39
+ }
40
+
41
+ function defaultIsPidAlive(pid: number): boolean {
42
+ try { process.kill(pid, 0); return true } catch (error: any) {
43
+ if (error?.code === 'EPERM') return true
44
+ if (error?.code === 'ESRCH') return false
45
+ throw error
46
+ }
47
+ }
48
+
49
+ function readOwner(lockDir: string): LockOwner | null {
50
+ try {
51
+ const value = JSON.parse(readFileSync(join(lockDir, OWNER_FILE), 'utf8')) as Partial<LockOwner>
52
+ if (!Number.isInteger(value.pid) || Number(value.pid) <= 0) return null
53
+ return { pid: Number(value.pid), startedAt: String(value.startedAt ?? ''), entrypoint: String(value.entrypoint ?? '') }
54
+ } catch { return null }
55
+ }
56
+
57
+ function claimStaleLock(lockDir: string, now: number): boolean {
58
+ const reclaimDir = join(lockDir, RECLAIM_DIR)
59
+ try { mkdirSync(reclaimDir, { mode: 0o700 }); return true } catch (error: any) {
60
+ if (error?.code === 'ENOENT') return false
61
+ if (error?.code !== 'EEXIST') throw error
62
+ try {
63
+ if (now - statSync(reclaimDir).mtimeMs > STALE_RECLAIM_MS) {
64
+ rmSync(reclaimDir, { recursive: true, force: true })
65
+ mkdirSync(reclaimDir, { mode: 0o700 })
66
+ return true
67
+ }
68
+ } catch (retryError: any) {
69
+ if (retryError?.code === 'ENOENT') return false
70
+ throw retryError
71
+ }
72
+ return false
73
+ }
74
+ }
75
+
76
+ export function acquireServerInstanceLock(options: ServerInstanceLockOptions = {}): ServerInstanceLock {
77
+ const lockDir = options.lockDir ?? process.env.COS_SERVER_LOCK_DIR ?? defaultLockDir()
78
+ const pid = options.pid ?? process.pid
79
+ const now = options.now ?? Date.now
80
+ const isPidAlive = options.isPidAlive ?? defaultIsPidAlive
81
+
82
+ for (let attempt = 0; attempt < 5; attempt++) {
83
+ try { mkdirSync(lockDir, { mode: 0o700 }) } catch (error: any) {
84
+ if (error?.code !== 'EEXIST') throw error
85
+ const lockStat = lstatSync(lockDir)
86
+ if (lockStat.isSymbolicLink() || !lockStat.isDirectory()) {
87
+ throw new UnsafeServerLockError(`Refusing unsafe COS server lock path: ${lockDir}`)
88
+ }
89
+ const owner = readOwner(lockDir)
90
+ if (owner && isPidAlive(owner.pid)) throw new ServerInstanceActiveError(lockDir, owner.pid)
91
+ if (!owner && now() - lockStat.mtimeMs <= INCOMPLETE_GRACE_MS) {
92
+ throw new ServerInstanceActiveError(lockDir, null)
93
+ }
94
+ if (!claimStaleLock(lockDir, now())) continue
95
+ const currentOwner = readOwner(lockDir)
96
+ if (currentOwner && isPidAlive(currentOwner.pid)) {
97
+ rmSync(join(lockDir, RECLAIM_DIR), { recursive: true, force: true })
98
+ throw new ServerInstanceActiveError(lockDir, currentOwner.pid)
99
+ }
100
+ rmSync(lockDir, { recursive: true, force: true })
101
+ continue
102
+ }
103
+
104
+ const owner: LockOwner = {
105
+ pid,
106
+ startedAt: new Date(now()).toISOString(),
107
+ entrypoint: process.env.COS_ENTRYPOINT ?? 'server/index.ts',
108
+ }
109
+ writeFileSync(join(lockDir, OWNER_FILE), `${JSON.stringify(owner)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' })
110
+ let released = false
111
+ return {
112
+ lockDir,
113
+ pid,
114
+ release: () => {
115
+ if (released) return
116
+ released = true
117
+ if (readOwner(lockDir)?.pid === pid) rmSync(lockDir, { recursive: true, force: true })
118
+ },
119
+ }
120
+ }
121
+ throw new ServerInstanceActiveError(lockDir, readOwner(lockDir)?.pid ?? null)
122
+ }
@@ -0,0 +1,79 @@
1
+ // Archive endpoints — daily conversation archive for glasses history browser
2
+ import { Router } from 'express'
3
+ import { listArchiveDates, loadArchive, getArchiveChats, getArchiveDayMessages, appendToArchive } from '../lib/archive.js'
4
+ import { getArchiveChatMessagesNumbered } from './message-ref.js'
5
+ import { getActiveSessions } from '../lib/conversation.js'
6
+
7
+ export const archiveRouter = Router()
8
+
9
+ // v5.15.6 / pkg v6.3.1 — SECURITY: :date is used to build filesystem paths
10
+ // (loadArchive/getArchiveChats/getArchiveDayMessages/getArchiveChatMessagesNumbered
11
+ // all resolve `<dir>/${date}.json`). Without validation, an encoded traversal
12
+ // (e.g. /api/archive/..%2F..%2Fetc%2Fhosts) reads/renames arbitrary *.json on
13
+ // the host. Validate the segment as a strict YYYY-MM-DD once for every :date
14
+ // route before any fs access.
15
+ archiveRouter.param('date', (req, res, next, date) => {
16
+ if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
17
+ res.status(400).json({ error: 'Invalid date' })
18
+ return
19
+ }
20
+ next()
21
+ })
22
+
23
+ // GET /api/archive — list all archive dates with summaries
24
+ archiveRouter.get('/archive', (_req, res) => {
25
+ const archives = listArchiveDates()
26
+ res.json({ archives })
27
+ })
28
+
29
+ // POST /api/archive/now — snapshot active sessions into today's archive (non-destructive)
30
+ archiveRouter.post('/archive/now', async (_req, res) => {
31
+ const activeSessions = getActiveSessions()
32
+ if (activeSessions.length === 0) {
33
+ res.json({ archived: 0, date: new Date().toISOString().slice(0, 10) })
34
+ return
35
+ }
36
+
37
+ const todayDate = new Date().toISOString().slice(0, 10)
38
+ let archived = 0
39
+ for (const session of activeSessions) {
40
+ await appendToArchive(todayDate, session, { skipLLM: true }) // public thrift: no surprise LLM spend on a manual snapshot
41
+ archived++
42
+ }
43
+
44
+ res.json({ archived, date: todayDate })
45
+ })
46
+
47
+ // GET /api/archive/:date — full daily archive
48
+ archiveRouter.get('/archive/:date', (req, res) => {
49
+ const archive = loadArchive(req.params.date)
50
+ if (!archive) {
51
+ res.status(404).json({ error: 'Archive not found for date' })
52
+ return
53
+ }
54
+ res.json(archive)
55
+ })
56
+
57
+ // GET /api/archive/:date/chats — chat summaries for a day
58
+ archiveRouter.get('/archive/:date/chats', (req, res) => {
59
+ const chats = getArchiveChats(req.params.date)
60
+ res.json({ chats })
61
+ })
62
+
63
+ // GET /api/archive/:date/chats/:index/messages — paired Q&A for a specific chat
64
+ archiveRouter.get('/archive/:date/chats/:index/messages', (req, res) => {
65
+ const index = parseInt(req.params.index, 10)
66
+ if (isNaN(index)) {
67
+ res.status(400).json({ error: 'Invalid chat index' })
68
+ return
69
+ }
70
+ // v5.15.1 — numbered form so the browser can show the durable Msg #N
71
+ const messages = getArchiveChatMessagesNumbered(req.params.date, index)
72
+ res.json({ messages })
73
+ })
74
+
75
+ // GET /api/archive/:date/messages — all messages for a day (flat)
76
+ archiveRouter.get('/archive/:date/messages', (req, res) => {
77
+ const messages = getArchiveDayMessages(req.params.date)
78
+ res.json({ messages })
79
+ })
@@ -9,6 +9,12 @@ import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
9
9
  import { isWhisperLocalAvailable, getWhisperHealth } from '../lib/whisper-local.js'
10
10
  import { getOpenAIWhisperBudgetState } from '../lib/openai-whisper-budget.js'
11
11
  import { getKeyStatus } from '../lib/openai-key.js'
12
+ import {
13
+ getCodexModelCatalog,
14
+ getCodexModelCatalogSnapshot,
15
+ } from '../lib/codex-model-catalog.js'
16
+ import { isMediaProcessingReady } from '../lib/image-safety.js'
17
+ import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
12
18
 
13
19
  export const healthRouter = Router()
14
20
 
@@ -106,6 +112,8 @@ healthRouter.get('/health', async (_req, res) => {
106
112
  cos_pipeline: COS_MODE,
107
113
  whisper: isWhisperLocalAvailable(),
108
114
  iphoneAsrCandidates: process.env.COS_IOS_ASR_CANDIDATES === '1',
115
+ mediaProcessingReady: await isMediaProcessingReady(),
116
+ g2LensVariant: G2_LENS_VARIANT_CAPABILITY,
109
117
  }
110
118
  const voice = {
111
119
  hasKey: keyStatus.hasKey,
@@ -117,7 +125,15 @@ healthRouter.get('/health', async (_req, res) => {
117
125
  const whisper_health = getWhisperHealth()
118
126
  const openai_whisper_budget = getOpenAIWhisperBudgetState()
119
127
 
120
- res.json({ ...checks, features, voice, whisper_health, openai_whisper_budget })
128
+ const codex_models = getCodexModelCatalogSnapshot()
129
+ res.json({ ...checks, features, voice, whisper_health, openai_whisper_budget, codex_models })
130
+ })
131
+
132
+ // Stable app slots backed by Codex's live model/list catalog. This route is
133
+ // authenticated by the global /api middleware; ?refresh=1 forces discovery.
134
+ healthRouter.get('/models', async (req, res) => {
135
+ const catalog = await getCodexModelCatalog(req.query.refresh === '1')
136
+ res.json(catalog)
121
137
  })
122
138
 
123
139
  // GET /api/cli-session — returns current CLI session ID for cross-device resume