@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,324 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Capability-scoped image publisher used by a single COS Glasses model run.
4
+ // It copies one already-local supported image artifact into the run's private inbox and
5
+ // appends only an opaque content id + generic provenance to the manifest.
6
+ // Source paths, URLs, and bytes never enter the manifest or stdout.
7
+
8
+ import { createHash, timingSafeEqual } from 'node:crypto'
9
+ import {
10
+ chmodSync,
11
+ closeSync,
12
+ constants,
13
+ existsSync,
14
+ fstatSync,
15
+ fsyncSync,
16
+ lstatSync,
17
+ mkdirSync,
18
+ openSync,
19
+ readFileSync,
20
+ realpathSync,
21
+ renameSync,
22
+ rmSync,
23
+ statSync,
24
+ writeFileSync,
25
+ writeSync,
26
+ } from 'node:fs'
27
+ import { basename, isAbsolute, join, sep } from 'node:path'
28
+
29
+ const RUN_DIR_PREFIX = 'cos-glasses-output-images-'
30
+ const ABSOLUTE_MAX_IMAGES = 5
31
+ const MAX_IMAGE_BYTES = 16 * 1024 * 1024
32
+ const MANIFEST_MAX_BYTES = 64 * 1024
33
+ const PROVENANCE = new Set(['generated', 'research', 'email'])
34
+ const OUTPUT_ID_RE = /^o_[a-f0-9]{32}$/
35
+ const WAIT_ARRAY = new Int32Array(new SharedArrayBuffer(4))
36
+
37
+ function configuredMaxImages() {
38
+ const parsed = Number(process.env.COS_OUTPUT_IMAGE_MAX)
39
+ if (!Number.isInteger(parsed)) return ABSOLUTE_MAX_IMAGES
40
+ return Math.max(0, Math.min(ABSOLUTE_MAX_IMAGES, parsed))
41
+ }
42
+
43
+ function fail(message) {
44
+ throw new Error(message)
45
+ }
46
+
47
+ function sleep(ms) {
48
+ Atomics.wait(WAIT_ARRAY, 0, 0, ms)
49
+ }
50
+
51
+ function isSupportedMagic(bytes) {
52
+ const jpeg = bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff
53
+ const png = bytes.length >= 8 &&
54
+ bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 &&
55
+ bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a
56
+ const webp = bytes.length >= 12 &&
57
+ bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP'
58
+ let isoImage = false
59
+ if (bytes.length >= 12 && bytes.toString('ascii', 4, 8) === 'ftyp') {
60
+ const declaredBoxSize = bytes.readUInt32BE(0)
61
+ const boxEnd = Math.min(bytes.length, declaredBoxSize >= 16 ? declaredBoxSize : 16, 256)
62
+ const brands = [bytes.toString('ascii', 8, 12)]
63
+ for (let offset = 16; offset + 4 <= boxEnd; offset += 4) brands.push(bytes.toString('ascii', offset, offset + 4))
64
+ isoImage = brands.some((brand) => [
65
+ 'avif', 'avis',
66
+ 'heic', 'heix', 'hevc', 'hevx', 'heim', 'heis',
67
+ 'mif1', 'msf1',
68
+ ].includes(brand))
69
+ }
70
+ return jpeg || png || webp || isoImage
71
+ }
72
+
73
+ function outputId(bytes) {
74
+ // Provenance is presentation metadata, not identity. The first publication
75
+ // of identical bytes wins regardless of how that image was later reused.
76
+ const digest = createHash('sha256').update(bytes).digest('hex')
77
+ return `o_${digest.slice(0, 32)}`
78
+ }
79
+
80
+ function validateItemsDir(runDir, expected) {
81
+ const itemsDir = join(runDir, 'items')
82
+ let fd = -1
83
+ try {
84
+ const before = lstatSync(itemsDir)
85
+ if (!before.isDirectory() || before.isSymbolicLink() || (before.mode & 0o777) !== 0o700) {
86
+ fail('Publisher items directory is unavailable.')
87
+ }
88
+ if (typeof process.getuid === 'function' && before.uid !== process.getuid()) {
89
+ fail('Publisher items directory is unavailable.')
90
+ }
91
+ const real = realpathSync(itemsDir)
92
+ if (real !== itemsDir || !real.startsWith(`${runDir}${sep}`)) {
93
+ fail('Publisher items directory is unavailable.')
94
+ }
95
+ fd = openSync(itemsDir, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) | (constants.O_NOFOLLOW ?? 0))
96
+ const opened = fstatSync(fd)
97
+ const after = lstatSync(itemsDir)
98
+ if (!opened.isDirectory() || opened.dev !== before.dev || opened.ino !== before.ino ||
99
+ after.isSymbolicLink() || after.dev !== opened.dev || after.ino !== opened.ino ||
100
+ (opened.mode & 0o777) !== 0o700 ||
101
+ (typeof process.getuid === 'function' && opened.uid !== process.getuid())) {
102
+ fail('Publisher items directory is unavailable.')
103
+ }
104
+ if (expected && (opened.dev !== expected.dev || opened.ino !== expected.ino)) {
105
+ fail('Publisher items directory is unavailable.')
106
+ }
107
+ return { path: itemsDir, dev: opened.dev, ino: opened.ino }
108
+ } catch (err) {
109
+ if (err instanceof Error && err.message === 'Publisher items directory is unavailable.') throw err
110
+ fail('Publisher items directory is unavailable.')
111
+ } finally {
112
+ if (fd >= 0) {
113
+ try { closeSync(fd) } catch { /* best effort */ }
114
+ }
115
+ }
116
+ }
117
+
118
+ function readManifestIds(path) {
119
+ if (!existsSync(path)) return new Set()
120
+ let contents = ''
121
+ let fd = -1
122
+ try {
123
+ const stat = lstatSync(path)
124
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MANIFEST_MAX_BYTES) {
125
+ fail('Publisher manifest is unavailable.')
126
+ }
127
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
128
+ contents = readFileSync(fd, 'utf8')
129
+ } catch {
130
+ fail('Publisher manifest is unavailable.')
131
+ } finally {
132
+ if (fd >= 0) {
133
+ try { closeSync(fd) } catch { /* best effort */ }
134
+ }
135
+ }
136
+ const ids = new Set()
137
+ for (const line of contents.split('\n')) {
138
+ if (!line || line.length > 512) continue
139
+ try {
140
+ const item = JSON.parse(line)
141
+ if (item?.v === 1 && item?.type === 'publish' && OUTPUT_ID_RE.test(item.id) && PROVENANCE.has(item.provenance)) {
142
+ ids.add(item.id)
143
+ }
144
+ } catch {
145
+ // A process can die between append bytes. A malformed tail never makes
146
+ // a valid earlier publication disappear.
147
+ }
148
+ }
149
+ return ids
150
+ }
151
+
152
+ function acquireLock(runDir) {
153
+ const lockDir = join(runDir, '.publish.lock')
154
+ const deadline = Date.now() + 5_000
155
+ while (Date.now() < deadline) {
156
+ try {
157
+ mkdirSync(lockDir, { mode: 0o700 })
158
+ return lockDir
159
+ } catch (err) {
160
+ if (err?.code !== 'EEXIST') fail('Publisher lock is unavailable.')
161
+ try {
162
+ if (Date.now() - statSync(lockDir).mtimeMs > 30_000) {
163
+ rmSync(lockDir, { recursive: true, force: true })
164
+ continue
165
+ }
166
+ } catch {
167
+ // The owner may have released it between the failed mkdir and stat.
168
+ }
169
+ sleep(25)
170
+ }
171
+ }
172
+ fail('Publisher is busy; try once more.')
173
+ }
174
+
175
+ function readCapability(runDir, supplied) {
176
+ if (!supplied || Buffer.byteLength(supplied) > 256) fail('Publisher capability is unavailable.')
177
+ let expected = ''
178
+ try {
179
+ expected = readFileSync(join(runDir, '.capability'), 'utf8').trim()
180
+ } catch {
181
+ fail('Publisher capability is unavailable.')
182
+ }
183
+ const a = Buffer.from(supplied)
184
+ const b = Buffer.from(expected)
185
+ if (a.length !== b.length || !timingSafeEqual(a, b)) fail('Publisher capability is unavailable.')
186
+ }
187
+
188
+ function validateRunDir(rawDir) {
189
+ if (!rawDir || !isAbsolute(rawDir)) fail('Publisher directory is unavailable.')
190
+ let runDir
191
+ let tmpRoot
192
+ try {
193
+ runDir = realpathSync(rawDir)
194
+ tmpRoot = realpathSync('/tmp')
195
+ const stat = lstatSync(runDir)
196
+ if (!stat.isDirectory() || stat.isSymbolicLink()) fail('Publisher directory is unavailable.')
197
+ if ((stat.mode & 0o777) !== 0o700) fail('Publisher directory is not private.')
198
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) fail('Publisher directory is unavailable.')
199
+ } catch {
200
+ fail('Publisher directory is unavailable.')
201
+ }
202
+ if (!runDir.startsWith(`${tmpRoot}${sep}`) || !basename(runDir).startsWith(RUN_DIR_PREFIX)) {
203
+ fail('Publisher directory is unavailable.')
204
+ }
205
+ return runDir
206
+ }
207
+
208
+ function readLocalImage(source) {
209
+ if (!source || !isAbsolute(source) || /^[a-z][a-z0-9+.-]*:/i.test(source) || source.startsWith('//')) {
210
+ fail('Publisher accepts an absolute local file, never a URL.')
211
+ }
212
+ let fd = -1
213
+ try {
214
+ const before = lstatSync(source)
215
+ if (!before.isFile() || before.isSymbolicLink() || before.size <= 0 || before.size > MAX_IMAGE_BYTES) {
216
+ fail('Source must be a supported local image no larger than 16 MiB.')
217
+ }
218
+ fd = openSync(source, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
219
+ const opened = fstatSync(fd)
220
+ if (!opened.isFile() || opened.size !== before.size || opened.ino !== before.ino || opened.dev !== before.dev) {
221
+ fail('Source image changed while publishing.')
222
+ }
223
+ const bytes = readFileSync(fd)
224
+ const after = fstatSync(fd)
225
+ if (after.size !== opened.size || after.ino !== opened.ino || after.dev !== opened.dev) {
226
+ fail('Source image changed while publishing.')
227
+ }
228
+ if (!isSupportedMagic(bytes)) fail('Only JPEG, PNG, WebP, HEIC, HEIF, or AVIF images can be published.')
229
+ return bytes
230
+ } catch (err) {
231
+ if (typeof err?.message === 'string' && err.message.startsWith('Source ')) throw err
232
+ fail('Source image is unavailable.')
233
+ } finally {
234
+ if (fd >= 0) {
235
+ try { closeSync(fd) } catch { /* best effort */ }
236
+ }
237
+ }
238
+ }
239
+
240
+ function main() {
241
+ const [provenance, source, ...extra] = process.argv.slice(2)
242
+ if (!PROVENANCE.has(provenance) || !source || extra.length > 0) {
243
+ fail('Usage: publisher <generated|research|email> <absolute-local-image>')
244
+ }
245
+
246
+ const runDir = validateRunDir(process.env.COS_OUTPUT_IMAGE_DIR)
247
+ readCapability(runDir, process.env.COS_OUTPUT_IMAGE_TOKEN)
248
+ const bytes = readLocalImage(source)
249
+ const id = outputId(bytes)
250
+ const maxImages = configuredMaxImages()
251
+ const manifestPath = join(runDir, 'manifest.jsonl')
252
+ const items = validateItemsDir(runDir)
253
+ const itemsDir = items.path
254
+ const lockDir = acquireLock(runDir)
255
+
256
+ try {
257
+ const published = readManifestIds(manifestPath)
258
+ if (!published.has(id) && published.size >= maxImages) {
259
+ fail(maxImages === 0 ? 'This response cannot accept output images.' : `This response already has ${maxImages} published images.`)
260
+ }
261
+
262
+ const target = join(itemsDir, `${id}.img`)
263
+ if (!existsSync(target)) {
264
+ const tmp = join(itemsDir, `.${id}-${process.pid}.tmp`)
265
+ let fd = -1
266
+ try {
267
+ validateItemsDir(runDir, items)
268
+ fd = openSync(
269
+ tmp,
270
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0),
271
+ 0o600,
272
+ )
273
+ const opened = fstatSync(fd)
274
+ if (!opened.isFile() || opened.nlink !== 1 || (opened.mode & 0o777) !== 0o600) {
275
+ fail('Publisher items directory is unavailable.')
276
+ }
277
+ // If the path was swapped between validation and open, detect that
278
+ // before writing any source bytes to the selected directory.
279
+ validateItemsDir(runDir, items)
280
+ writeSync(fd, bytes)
281
+ fsyncSync(fd)
282
+ closeSync(fd)
283
+ fd = -1
284
+ validateItemsDir(runDir, items)
285
+ renameSync(tmp, target)
286
+ validateItemsDir(runDir, items)
287
+ } finally {
288
+ if (fd >= 0) {
289
+ try { closeSync(fd) } catch { /* best effort */ }
290
+ }
291
+ try { rmSync(tmp, { force: true }) } catch { /* best effort */ }
292
+ }
293
+ }
294
+
295
+ if (!published.has(id)) {
296
+ const line = `${JSON.stringify({ v: 1, type: 'publish', id, provenance })}\n`
297
+ const fd = openSync(
298
+ manifestPath,
299
+ constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | (constants.O_NOFOLLOW ?? 0),
300
+ 0o600,
301
+ )
302
+ try {
303
+ const stat = fstatSync(fd)
304
+ if (!stat.isFile()) fail('Publisher manifest is unavailable.')
305
+ writeSync(fd, line)
306
+ fsyncSync(fd)
307
+ } finally {
308
+ closeSync(fd)
309
+ }
310
+ chmodSync(manifestPath, 0o600)
311
+ }
312
+ process.stdout.write(`Published ${provenance} image.\n`)
313
+ } finally {
314
+ rmSync(lockDir, { recursive: true, force: true })
315
+ }
316
+ }
317
+
318
+ try {
319
+ main()
320
+ } catch (err) {
321
+ const message = err instanceof Error ? err.message : 'Image publication failed.'
322
+ process.stderr.write(`${message}\n`)
323
+ process.exitCode = 1
324
+ }
@@ -0,0 +1,16 @@
1
+ // Must remain the first import in server/index.ts. Claim the same machine-wide
2
+ // slot used by the installed LaunchAgent before mutable routes or stores load.
3
+ import './env.js'
4
+ import { acquireServerInstanceLock, ServerInstanceActiveError } from './lib/server-instance-lock.js'
5
+
6
+ try {
7
+ const instanceLock = acquireServerInstanceLock()
8
+ process.once('exit', instanceLock.release)
9
+ } catch (error) {
10
+ if (error instanceof ServerInstanceActiveError) {
11
+ console.error(`[COS API] Startup refused: ${error.message}`)
12
+ process.exit(75)
13
+ }
14
+ console.error('[COS API] Startup refused: single-instance lock failed.', error)
15
+ process.exit(74)
16
+ }
package/server/index.ts CHANGED
@@ -1,12 +1,12 @@
1
- // Load .env FIRST ESM evaluates this module before subsequent imports,
2
- // ensuring process.env is populated before python-bridge.ts reads COS_SCRIPTS_DIR.
3
- import './env.js'
1
+ // Load env and claim the one server slot before any mutable module initializes.
2
+ import './bootstrap.js'
4
3
 
5
4
  import express from 'express'
6
5
  import cors from 'cors'
7
6
  import path from 'node:path'
8
7
  import { fileURLToPath } from 'node:url'
9
8
  import { createServer as createHttpsServer } from 'node:https'
9
+ import { createServer as createHttpServer } from 'node:http'
10
10
  import { readFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs'
11
11
  import { networkInterfaces, homedir } from 'node:os'
12
12
  import { join } from 'node:path'
@@ -20,13 +20,24 @@ import { displayRouter } from './routes/display.js'
20
20
  import { transcribeStreamRouter } from './routes/transcribe-stream.js'
21
21
  import { openaiCompatRouter } from './routes/openai-compat.js'
22
22
  import { openaiKeyRouter } from './routes/openai-key.js'
23
+ import { messageRefRouter } from './routes/message-ref.js'
24
+ import { archiveRouter } from './routes/archive.js'
25
+ import { sessionsRouter } from './routes/sessions.js'
26
+ import { mediaRouter, mediaBodyParser } from './routes/media.js'
23
27
  import { prewarmContext } from './lib/context-builder.js'
24
28
  import { preWarmCLI } from './lib/claude-bridge.js'
29
+ import { getCodexRunConfig } from './lib/codex-run-ledger.js'
30
+ import {
31
+ startCodexModelCatalogRefresh,
32
+ stopCodexModelCatalogRefresh,
33
+ } from './lib/codex-model-catalog.js'
25
34
  import { startWhisperServer, stopWhisperServer } from './lib/whisper-local.js'
26
35
  import { initSileroVAD } from './lib/vad-silero.js'
27
36
  import { initSessionCache } from './lib/session-cache-writer.js'
28
37
  import { initSpeakerEmbeddings } from './lib/speaker-embeddings.js'
29
38
  import { logActiveSessionsOnShutdown, startAutoSnapshot } from './lib/conversation.js'
39
+ import { getMediaStore } from './lib/media-store.js'
40
+ import { listenRequiredServers, type RequiredListener } from './lib/listener-startup.js'
30
41
 
31
42
  const app = express()
32
43
  const PORT = parseInt(process.env.PORT ?? '3141', 10)
@@ -96,8 +107,6 @@ app.use(cors({
96
107
  cb(new Error('CORS blocked'))
97
108
  },
98
109
  }))
99
- app.use(express.json({ limit: '10mb' }))
100
-
101
110
  // Auth middleware — always active (token is auto-generated if not set)
102
111
  app.use('/api', (req, res, next) => {
103
112
  // Allow health checks, display stream, and client diagnostics without auth.
@@ -117,6 +126,11 @@ app.use('/api', (req, res, next) => {
117
126
  next()
118
127
  })
119
128
 
129
+ // Authenticate before parsing large upload bodies. The 16 MB allowance stays
130
+ // scoped to /api/media; every other route retains the 10 MB ceiling.
131
+ app.use('/api/media', mediaBodyParser)
132
+ app.use(express.json({ limit: '10mb' }))
133
+
120
134
  // Request counter — must be before route registrations
121
135
  app.use((_req, _res, next) => {
122
136
  serverMetrics.requestCount++
@@ -131,6 +145,12 @@ app.use('/api', transcribeRouter)
131
145
  app.use('/api', displayRouter)
132
146
  app.use('/api', transcribeStreamRouter)
133
147
  app.use('/api', openaiKeyRouter)
148
+ // v6.3.0 — Message History, cross-day 'reference message N', and history
149
+ // recovery for public npx users (previously full-COS-server only).
150
+ app.use('/api', messageRefRouter)
151
+ app.use('/api', archiveRouter)
152
+ app.use('/api', sessionsRouter)
153
+ app.use('/api', mediaRouter)
134
154
 
135
155
  // OpenAI-compatible endpoint for the G2 Agent (ER "Add Agent")
136
156
  // Mounted at root — routes are /v1/chat/completions and /v1/models
@@ -156,12 +176,19 @@ process.on('SIGTERM', () => {
156
176
  // Production stops (kill, service managers) send SIGTERM — flush session logs
157
177
  // exactly like SIGINT so active conversations aren't lost on shutdown.
158
178
  try { logActiveSessionsOnShutdown() } catch { /* best-effort flush */ }
179
+ stopCodexModelCatalogRefresh()
180
+ stopWhisperServer()
181
+ process.exit(0)
182
+ })
183
+ process.on('SIGINT', () => {
184
+ logActiveSessionsOnShutdown()
185
+ stopCodexModelCatalogRefresh()
159
186
  stopWhisperServer()
160
187
  process.exit(0)
161
188
  })
162
- process.on('SIGINT', () => { logActiveSessionsOnShutdown(); stopWhisperServer(); process.exit(0) })
163
189
 
164
- // Crash protection log and survive instead of dying mid-meeting
190
+ // Crash protection for runtime work. Listener failures are handled separately
191
+ // and exit immediately so a supervisor can restart a clean, unified process.
165
192
  process.on('uncaughtException', (err) => {
166
193
  console.error('[CRASH GUARD] Uncaught exception (server stays alive):', err.message)
167
194
  console.error(err.stack)
@@ -176,19 +203,24 @@ process.on('unhandledRejection', (reason: any) => {
176
203
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
177
204
  const HTTPS_PORT = parseInt(process.env.HTTPS_PORT ?? '3143', 10)
178
205
  const certDir = path.join(__dirname, 'certs')
206
+ const listeners: RequiredListener[] = []
179
207
  if (existsSync(path.join(certDir, 'cert.pem'))) {
180
208
  const httpsServer = createHttpsServer({
181
209
  cert: readFileSync(path.join(certDir, 'cert.pem')),
182
210
  key: readFileSync(path.join(certDir, 'key.pem')),
183
211
  }, app)
184
- httpsServer.listen(HTTPS_PORT, BIND_HOST, () => {
185
- console.log(`[COS API] HTTPS server running on https://${BIND_HOST}:${HTTPS_PORT}`)
186
- })
212
+ listeners.push({ server: httpsServer, port: HTTPS_PORT, host: BIND_HOST, label: 'HTTPS' })
187
213
  } else {
188
214
  console.log('[COS API] No certs found — HTTPS disabled (drop cert.pem/key.pem in server/certs to enable)')
189
215
  }
190
216
 
191
- app.listen(PORT, BIND_HOST, () => {
217
+ const httpServer = createHttpServer(app)
218
+ listeners.push({ server: httpServer, port: PORT, host: BIND_HOST, label: 'HTTP' })
219
+
220
+ listenRequiredServers(listeners).then(() => {
221
+ if (listeners.some(listener => listener.label === 'HTTPS')) {
222
+ console.log(`[COS API] HTTPS server running on https://${BIND_HOST}:${HTTPS_PORT}`)
223
+ }
192
224
  console.log(`[COS API] HTTP server running on http://${BIND_HOST}:${PORT}`)
193
225
  console.log(`[COS API] Mode: ${COS_MODE ? 'COS pipeline' : 'standalone'}`)
194
226
 
@@ -220,15 +252,25 @@ app.listen(PORT, BIND_HOST, () => {
220
252
  console.log('')
221
253
  }
222
254
 
223
- // Check Claude CLI availability the chat backend
255
+ // Check Claude CLI availability. Codex-only installs remain fully valid.
256
+ let claudeAvailable = false
224
257
  try {
225
258
  execSync('claude --version', { timeout: 5000, stdio: 'pipe' })
259
+ claudeAvailable = true
226
260
  console.log('[COS API] Claude Code CLI detected')
227
261
  } catch {
228
262
  console.warn('[COS API] Claude Code CLI not found — install from https://claude.ai/download')
229
- console.warn('[COS API] AI queries will not work without the Claude Code CLI')
263
+ console.warn('[COS API] Claude models unavailable; Codex models still work when Codex CLI is installed')
230
264
  }
231
265
 
266
+ const codexConfig = getCodexRunConfig()
267
+ console.log(`[COS API] Codex mode: ${codexConfig.persistenceEnabled ? 'persistent' : 'ephemeral'} · ${codexConfig.reasoningEffort} · ${codexConfig.trustMode}`)
268
+ console.log(`[COS API] Codex models (${codexConfig.catalogSource}): ${codexConfig.availableModels.map(item => `${item.displayName}=${item.model}`).join(' · ')}`)
269
+ console.log(`[COS API] Codex workdir: ${codexConfig.cwd}`)
270
+ // Refresh immediately and then periodically. The catalog module retains the
271
+ // last-known-good snapshot if Codex is temporarily unavailable.
272
+ startCodexModelCatalogRefresh()
273
+
232
274
  if (COS_MODE) {
233
275
  initSessionCache()
234
276
  // Pre-warm context cache so first query doesn't wait for the pipeline
@@ -243,9 +285,17 @@ app.listen(PORT, BIND_HOST, () => {
243
285
  const vadOk = initSileroVAD()
244
286
  console.log(`[startup] Silero VAD: ${vadOk ? 'active' : 'disabled (model not found)'}`)
245
287
 
246
- // Pre-warm the Claude CLI so the first query doesn't eat a 2-15s cold start
247
- preWarmCLI().catch(err => console.error('[startup] CLI pre-warm error:', err))
288
+ // Pre-warm Claude only when installed so Codex-only startup stays quiet.
289
+ if (claudeAvailable) {
290
+ preWarmCLI().catch(err => console.error('[startup] CLI pre-warm error:', err))
291
+ }
248
292
 
249
293
  // Auto-snapshot active sessions every 5 min (survives restarts)
250
294
  startAutoSnapshot(5 * 60_000)
295
+
296
+ // Durable media GC (staged/reserved expiry + generated-image content TTL).
297
+ getMediaStore().startGC()
298
+ }).catch((error: NodeJS.ErrnoException) => {
299
+ console.error(`[COS API] Fatal listener startup: ${error.message}`)
300
+ process.exit(error.code === 'EADDRINUSE' ? 75 : 74)
251
301
  })
@@ -0,0 +1,168 @@
1
+ // Safe, bounded activity previews for the live job monitor. This module only
2
+ // surfaces observable tool actions/results; it never exposes model reasoning.
3
+
4
+ export interface ActivityPreviewLine {
5
+ kind: 'input' | 'output'
6
+ text: string
7
+ }
8
+
9
+ const ANSI_RE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g
10
+ const PRIVATE_MATERIAL_BLOCK_RE = /-----BEGIN [A-Z0-9 ]*(?:PRIVATE KEY|CREDENTIALS?)-----[\s\S]*?(?:-----END [A-Z0-9 ]*(?:PRIVATE KEY|CREDENTIALS?)-----|$)/gi
11
+
12
+ const SECRET_PATTERNS: Array<[RegExp, string]> = [
13
+ // Cookie headers are credential containers; partial parsing is unsafe.
14
+ [/\b((?:set-)?cookie\s*:\s*)[^\r\n]+/gi, '$1[redacted]'],
15
+ // Authorization and proxy-authorization headers, including Basic credentials.
16
+ [/\b((?:proxy-)?authorization\s*[:=]\s*)(?:bearer|basic|digest)\s+[^\s,;"']+/gi, '$1[redacted]'],
17
+ [/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, 'Bearer [redacted]'],
18
+ // Structured assignments and JSON/env values. Key names are intentionally
19
+ // broad; a false-positive here is safer than putting a credential on a lens.
20
+ [/\b((?:[a-z0-9]+[_-])*(?:api[_-]?key|access[_-]?key(?:[_-]?id)?|access[_-]?token|refresh[_-]?token|session[_-]?token|token|credential|auth(?:orization)?|password|passwd|pwd|secret|client[_-]?secret|private[_-]?key|database[_-]?url|connection[_-]?string|dsn|session(?:[_-]?id)?|cookie|phpsessid|jsessionid|sid)(?:[_-][a-z0-9]+)*["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;}&]+)/gi, '$1[redacted]'],
21
+ // Shell/env whitespace assignments such as `export TOKEN value`.
22
+ [/\b((?:export|set|env)\s+(?:[a-z0-9]+[_-])*(?:api[_-]?key|access[_-]?token|refresh[_-]?token|session[_-]?token|token|credential|auth|password|passwd|pwd|secret|client[_-]?secret|private[_-]?key|database[_-]?url|connection[_-]?string|dsn|session(?:[_-]?id)?|cookie)\s+)(?:"[^"]*"|'[^']*'|\S+)/gi, '$1[redacted]'],
23
+ // Command-line flags commonly used for credentials.
24
+ [/(\s--?(?:api-key|token|access-token|refresh-token|password|passwd|secret|client-secret)(?:=|\s+))(?:"[^"]*"|'[^']*'|\S+)/gi, '$1[redacted]'],
25
+ [/(\s(?:-u|--user)(?:=|\s+))(?:"[^"]*"|'[^']*'|\S+)/gi, '$1[redacted]'],
26
+ // URL userinfo and secret-bearing query parameters.
27
+ [/([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]*:[^\s/@]+@/gi, '$1[redacted]@'],
28
+ [/([?&](?:api[_-]?key|access[_-]?token|token|auth|password|secret)=)[^&#\s]+/gi, '$1[redacted]'],
29
+ // Common provider token formats.
30
+ [/\b(?:sk-(?:proj-|live-|test-)?|sk_(?:live|test)_|sess-|pat-|gh[pousr]_|github_pat_|glpat-|npm_|pypi-|shpat_|xox[baprs]-)[A-Za-z0-9._-]{8,}\b/gi, '[redacted-token]'],
31
+ [/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, '[redacted-aws-key]'],
32
+ [/\bAIza[A-Za-z0-9_-]{30,}\b/g, '[redacted-google-key]'],
33
+ // JWTs and PEM material.
34
+ [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, '[redacted-jwt]'],
35
+ [/-----BEGIN [A-Z0-9 ]*(?:PRIVATE KEY|CREDENTIALS?)-----/gi, '[redacted-private-material]'],
36
+ [/-----END [A-Z0-9 ]*(?:PRIVATE KEY|CREDENTIALS?)-----/gi, '[redacted-private-material]'],
37
+ ]
38
+
39
+ function looksOpaqueSecret(text: string): boolean {
40
+ const compact = text.replace(/\s/g, '')
41
+ if (compact.length < 40) return false
42
+ if (!/^[A-Za-z0-9+/=_:.-]+$/.test(compact)) return false
43
+
44
+ // PEM bodies are conventionally wrapped into 64-character base64 lines,
45
+ // but exporters commonly use widths from 40–72. Suppress every standalone
46
+ // base64/hex chunk in that range, including low-variety padding lines.
47
+ if (!/\s/.test(text) && /^(?:[A-Za-z0-9+/]{40,}={0,2}|[A-Fa-f0-9]{40,})$/.test(compact)) return true
48
+
49
+ // Ordinary prose rarely has this mix without spaces. Requiring several
50
+ // character classes avoids hiding long paths or repeated divider lines.
51
+ let classes = 0
52
+ if (/[a-z]/.test(compact)) classes++
53
+ if (/[A-Z]/.test(compact)) classes++
54
+ if (/\d/.test(compact)) classes++
55
+ if (/[+/=_:.-]/.test(compact)) classes++
56
+ return classes >= 3
57
+ }
58
+
59
+ export function sanitizeActivityPreview(raw: unknown, max = 180): string | null {
60
+ if (typeof raw !== 'string') return null
61
+ let text = raw.replace(ANSI_RE, '').replace(PRIVATE_MATERIAL_BLOCK_RE, '[private material hidden]')
62
+ for (const [pattern, replacement] of SECRET_PATTERNS) text = text.replace(pattern, replacement)
63
+ text = text.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim()
64
+ if (!text) return null
65
+ if (looksOpaqueSecret(text)) return '[opaque output hidden]'
66
+ const safeMax = Number.isFinite(max) && max >= 8 ? Math.floor(max) : 180
67
+ return text.length > safeMax ? `${text.slice(0, safeMax - 1)}…` : text
68
+ }
69
+
70
+ export function textPreviewLines(raw: unknown, maxLines = 3): string[] {
71
+ if (typeof raw !== 'string') return []
72
+ const safeMaxLines = Number.isFinite(maxLines) && maxLines > 0 ? Math.min(Math.floor(maxLines), 5) : 3
73
+ const lines = raw.replace(PRIVATE_MATERIAL_BLOCK_RE, '[private material hidden]').replace(/\r\n?/g, '\n').split('\n')
74
+ .map(line => sanitizeActivityPreview(line))
75
+ .filter((line): line is string => !!line)
76
+ return lines.slice(-safeMaxLines)
77
+ }
78
+
79
+ function resultText(value: unknown): string | null {
80
+ if (typeof value === 'string') return value
81
+ if (Array.isArray(value)) {
82
+ const parts = value.flatMap((part) => {
83
+ if (typeof part === 'string') return [part]
84
+ if (typeof part?.text === 'string') return [part.text]
85
+ if (typeof part?.content === 'string') return [part.content]
86
+ return []
87
+ })
88
+ return parts.length ? parts.join('\n') : null
89
+ }
90
+ if (value && typeof value === 'object') {
91
+ const record = value as Record<string, unknown>
92
+ return resultText(record.content) ?? resultText(record.text) ?? resultText(record.output)
93
+ }
94
+ return null
95
+ }
96
+
97
+ /** Extract observable Codex tool activity from `codex exec --json` events. */
98
+ export function codexActivityPreviewLines(event: any): ActivityPreviewLine[] {
99
+ const eventType = String(event?.type ?? '')
100
+ const item = event?.item ?? event?.payload ?? {}
101
+ const itemType = String(item?.type ?? '')
102
+ const lines: ActivityPreviewLine[] = []
103
+
104
+ if (/command_execution|command|shell|exec/i.test(itemType)) {
105
+ if (/started/i.test(eventType)) {
106
+ const command = sanitizeActivityPreview(item.command ?? item.input)
107
+ if (command) lines.push({ kind: 'input', text: `$ ${command}` })
108
+ }
109
+ const output = item.aggregated_output ?? item.output ?? item.stdout ?? item.stderr
110
+ for (const text of textPreviewLines(output)) lines.push({ kind: 'output', text })
111
+ if (/completed/i.test(eventType) && Number.isInteger(item.exit_code)) {
112
+ lines.push({ kind: 'output', text: `exit ${item.exit_code}` })
113
+ }
114
+ return lines
115
+ }
116
+
117
+ if (/file_change|patch/i.test(itemType)) {
118
+ const changes = Array.isArray(item.changes) ? item.changes : []
119
+ for (const change of changes.slice(-3)) {
120
+ const path = sanitizeActivityPreview(change?.path ?? change?.file)
121
+ if (path) lines.push({ kind: 'output', text: `${change?.kind ?? 'updated'} ${path}` })
122
+ }
123
+ return lines
124
+ }
125
+
126
+ if (/web_search/i.test(itemType)) {
127
+ const query = sanitizeActivityPreview(item.query)
128
+ if (query) lines.push({ kind: 'input', text: `Search: ${query}` })
129
+ return lines
130
+ }
131
+
132
+ if (/mcp_tool_call|tool_call|tool/i.test(itemType)) {
133
+ const name = sanitizeActivityPreview([item.server, item.tool ?? item.name].filter(Boolean).join('.'))
134
+ if (name && /started/i.test(eventType)) lines.push({ kind: 'input', text: name })
135
+ const text = resultText(item.result ?? item.output)
136
+ for (const preview of textPreviewLines(text)) lines.push({ kind: 'output', text: preview })
137
+ }
138
+
139
+ return lines
140
+ }
141
+
142
+ /** Extract text from Claude tool_result events, never assistant reasoning. */
143
+ export function claudeToolResultPreviewLines(event: any): ActivityPreviewLine[] {
144
+ if (event?.type !== 'user') return []
145
+ const content = event?.message?.content ?? event?.content
146
+ if (!Array.isArray(content)) return []
147
+ const lines: ActivityPreviewLine[] = []
148
+ for (const block of content) {
149
+ if (block?.type !== 'tool_result') continue
150
+ const text = resultText(block.content)
151
+ for (const preview of textPreviewLines(text)) lines.push({ kind: 'output', text: preview })
152
+ }
153
+ return lines.slice(-3)
154
+ }
155
+
156
+ /** Turn completed Claude tool input JSON into one allowlisted useful line. */
157
+ export function claudeToolInputPreview(name: string, rawJson: string): ActivityPreviewLine | null {
158
+ let input: Record<string, unknown> = {}
159
+ try { input = JSON.parse(rawJson || '{}') } catch { return null }
160
+ const candidate = input.query ?? input.url ?? input.file_path ?? input.path ?? input.command
161
+ const preview = sanitizeActivityPreview(candidate)
162
+ if (!preview) return null
163
+ const label = name === 'WebSearch' ? 'Search'
164
+ : name === 'WebFetch' ? 'Read'
165
+ : name === 'Read' ? 'File'
166
+ : name
167
+ return { kind: 'input', text: `${label}: ${preview}` }
168
+ }