@gotcos/glasses-server 6.3.1 → 6.6.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.
- package/.env.example +23 -7
- package/CHANGELOG.md +108 -0
- package/README.md +28 -8
- package/bin/cli.cjs +22 -10
- package/package.json +18 -6
- package/server/bin/cos-output-image-publisher.mjs +324 -0
- package/server/bootstrap.ts +16 -0
- package/server/index.ts +61 -21
- package/server/lib/activity-preview.ts +168 -0
- package/server/lib/archive.ts +20 -6
- package/server/lib/claude-bridge.ts +215 -60
- package/server/lib/claude-run-ledger.ts +7 -2
- package/server/lib/codex-bridge.ts +186 -71
- package/server/lib/codex-engine-sessions.ts +24 -2
- package/server/lib/codex-model-catalog.ts +450 -0
- package/server/lib/codex-run-ledger.ts +20 -4
- package/server/lib/conversation.ts +64 -2
- package/server/lib/display-bus.ts +61 -3
- package/server/lib/image-safety.ts +458 -0
- package/server/lib/listener-startup.ts +29 -0
- package/server/lib/media-store.ts +833 -0
- package/server/lib/model-image-input.ts +27 -0
- package/server/lib/model-router.ts +67 -8
- package/server/lib/query-attachments.ts +132 -0
- package/server/lib/run-output-images.ts +442 -0
- package/server/lib/server-instance-id.ts +55 -0
- package/server/lib/server-instance-lock.ts +122 -0
- package/server/lib/server-metrics.ts +7 -0
- package/server/routes/display.ts +43 -22
- package/server/routes/health.ts +19 -2
- package/server/routes/media.ts +285 -0
- package/server/routes/message-ref.ts +18 -6
- package/server/routes/openai-compat.ts +44 -11
- package/server/routes/query.ts +51 -16
- package/server/routes/sessions.ts +33 -4
- package/shared/media-attachment.ts +126 -0
- package/shared/model-preference.ts +140 -17
|
@@ -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
|
|
2
|
-
|
|
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'
|
|
@@ -23,13 +23,23 @@ import { openaiKeyRouter } from './routes/openai-key.js'
|
|
|
23
23
|
import { messageRefRouter } from './routes/message-ref.js'
|
|
24
24
|
import { archiveRouter } from './routes/archive.js'
|
|
25
25
|
import { sessionsRouter } from './routes/sessions.js'
|
|
26
|
+
import { mediaRouter, mediaBodyParser } from './routes/media.js'
|
|
26
27
|
import { prewarmContext } from './lib/context-builder.js'
|
|
27
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'
|
|
28
34
|
import { startWhisperServer, stopWhisperServer } from './lib/whisper-local.js'
|
|
29
35
|
import { initSileroVAD } from './lib/vad-silero.js'
|
|
30
36
|
import { initSessionCache } from './lib/session-cache-writer.js'
|
|
31
37
|
import { initSpeakerEmbeddings } from './lib/speaker-embeddings.js'
|
|
32
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'
|
|
41
|
+
import { serverMetrics } from './lib/server-metrics.js'
|
|
42
|
+
import { initializeServerInstanceId } from './lib/server-instance-id.js'
|
|
33
43
|
|
|
34
44
|
const app = express()
|
|
35
45
|
const PORT = parseInt(process.env.PORT ?? '3141', 10)
|
|
@@ -61,12 +71,6 @@ if (API_TOKEN_AUTO) {
|
|
|
61
71
|
} catch { /* read-only home — token stays per-session */ }
|
|
62
72
|
}
|
|
63
73
|
|
|
64
|
-
// Server metrics — shared with /api/health for monitoring
|
|
65
|
-
export const serverMetrics = {
|
|
66
|
-
startedAt: Date.now(),
|
|
67
|
-
requestCount: 0,
|
|
68
|
-
}
|
|
69
|
-
|
|
70
74
|
// IP allowlist — only accept connections from localhost, meshnet, and private networks.
|
|
71
75
|
// Blocks untrusted public access (coffee-shop WiFi, the open internet) while keeping all
|
|
72
76
|
// local + meshnet (Tailscale/CGNAT) + LAN consumers working.
|
|
@@ -99,8 +103,6 @@ app.use(cors({
|
|
|
99
103
|
cb(new Error('CORS blocked'))
|
|
100
104
|
},
|
|
101
105
|
}))
|
|
102
|
-
app.use(express.json({ limit: '10mb' }))
|
|
103
|
-
|
|
104
106
|
// Auth middleware — always active (token is auto-generated if not set)
|
|
105
107
|
app.use('/api', (req, res, next) => {
|
|
106
108
|
// Allow health checks, display stream, and client diagnostics without auth.
|
|
@@ -120,6 +122,11 @@ app.use('/api', (req, res, next) => {
|
|
|
120
122
|
next()
|
|
121
123
|
})
|
|
122
124
|
|
|
125
|
+
// Authenticate before parsing large upload bodies. The 16 MB allowance stays
|
|
126
|
+
// scoped to /api/media; every other route retains the 10 MB ceiling.
|
|
127
|
+
app.use('/api/media', mediaBodyParser)
|
|
128
|
+
app.use(express.json({ limit: '10mb' }))
|
|
129
|
+
|
|
123
130
|
// Request counter — must be before route registrations
|
|
124
131
|
app.use((_req, _res, next) => {
|
|
125
132
|
serverMetrics.requestCount++
|
|
@@ -139,6 +146,7 @@ app.use('/api', openaiKeyRouter)
|
|
|
139
146
|
app.use('/api', messageRefRouter)
|
|
140
147
|
app.use('/api', archiveRouter)
|
|
141
148
|
app.use('/api', sessionsRouter)
|
|
149
|
+
app.use('/api', mediaRouter)
|
|
142
150
|
|
|
143
151
|
// OpenAI-compatible endpoint for the G2 Agent (ER "Add Agent")
|
|
144
152
|
// Mounted at root — routes are /v1/chat/completions and /v1/models
|
|
@@ -164,12 +172,19 @@ process.on('SIGTERM', () => {
|
|
|
164
172
|
// Production stops (kill, service managers) send SIGTERM — flush session logs
|
|
165
173
|
// exactly like SIGINT so active conversations aren't lost on shutdown.
|
|
166
174
|
try { logActiveSessionsOnShutdown() } catch { /* best-effort flush */ }
|
|
175
|
+
stopCodexModelCatalogRefresh()
|
|
176
|
+
stopWhisperServer()
|
|
177
|
+
process.exit(0)
|
|
178
|
+
})
|
|
179
|
+
process.on('SIGINT', () => {
|
|
180
|
+
logActiveSessionsOnShutdown()
|
|
181
|
+
stopCodexModelCatalogRefresh()
|
|
167
182
|
stopWhisperServer()
|
|
168
183
|
process.exit(0)
|
|
169
184
|
})
|
|
170
|
-
process.on('SIGINT', () => { logActiveSessionsOnShutdown(); stopWhisperServer(); process.exit(0) })
|
|
171
185
|
|
|
172
|
-
// Crash protection
|
|
186
|
+
// Crash protection for runtime work. Listener failures are handled separately
|
|
187
|
+
// and exit immediately so a supervisor can restart a clean, unified process.
|
|
173
188
|
process.on('uncaughtException', (err) => {
|
|
174
189
|
console.error('[CRASH GUARD] Uncaught exception (server stays alive):', err.message)
|
|
175
190
|
console.error(err.stack)
|
|
@@ -184,20 +199,27 @@ process.on('unhandledRejection', (reason: any) => {
|
|
|
184
199
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
185
200
|
const HTTPS_PORT = parseInt(process.env.HTTPS_PORT ?? '3143', 10)
|
|
186
201
|
const certDir = path.join(__dirname, 'certs')
|
|
202
|
+
const listeners: RequiredListener[] = []
|
|
187
203
|
if (existsSync(path.join(certDir, 'cert.pem'))) {
|
|
188
204
|
const httpsServer = createHttpsServer({
|
|
189
205
|
cert: readFileSync(path.join(certDir, 'cert.pem')),
|
|
190
206
|
key: readFileSync(path.join(certDir, 'key.pem')),
|
|
191
207
|
}, app)
|
|
192
|
-
|
|
193
|
-
console.log(`[COS API] HTTPS server running on https://${BIND_HOST}:${HTTPS_PORT}`)
|
|
194
|
-
})
|
|
208
|
+
listeners.push({ server: httpsServer, port: HTTPS_PORT, host: BIND_HOST, label: 'HTTPS' })
|
|
195
209
|
} else {
|
|
196
210
|
console.log('[COS API] No certs found — HTTPS disabled (drop cert.pem/key.pem in server/certs to enable)')
|
|
197
211
|
}
|
|
198
212
|
|
|
199
|
-
|
|
213
|
+
const httpServer = createHttpServer(app)
|
|
214
|
+
listeners.push({ server: httpServer, port: PORT, host: BIND_HOST, label: 'HTTP' })
|
|
215
|
+
|
|
216
|
+
listenRequiredServers(listeners).then(() => {
|
|
217
|
+
const serverInstanceId = initializeServerInstanceId()
|
|
218
|
+
if (listeners.some(listener => listener.label === 'HTTPS')) {
|
|
219
|
+
console.log(`[COS API] HTTPS server running on https://${BIND_HOST}:${HTTPS_PORT}`)
|
|
220
|
+
}
|
|
200
221
|
console.log(`[COS API] HTTP server running on http://${BIND_HOST}:${PORT}`)
|
|
222
|
+
console.log(`[COS API] Server instance: ${serverInstanceId}`)
|
|
201
223
|
console.log(`[COS API] Mode: ${COS_MODE ? 'COS pipeline' : 'standalone'}`)
|
|
202
224
|
|
|
203
225
|
// Print ADDRESSES THE PHONE CAN ACTUALLY REACH. The bind address (0.0.0.0) is
|
|
@@ -228,15 +250,25 @@ app.listen(PORT, BIND_HOST, () => {
|
|
|
228
250
|
console.log('')
|
|
229
251
|
}
|
|
230
252
|
|
|
231
|
-
// Check Claude CLI availability
|
|
253
|
+
// Check Claude CLI availability. Codex-only installs remain fully valid.
|
|
254
|
+
let claudeAvailable = false
|
|
232
255
|
try {
|
|
233
256
|
execSync('claude --version', { timeout: 5000, stdio: 'pipe' })
|
|
257
|
+
claudeAvailable = true
|
|
234
258
|
console.log('[COS API] Claude Code CLI detected')
|
|
235
259
|
} catch {
|
|
236
260
|
console.warn('[COS API] Claude Code CLI not found — install from https://claude.ai/download')
|
|
237
|
-
console.warn('[COS API]
|
|
261
|
+
console.warn('[COS API] Claude models unavailable; Codex models still work when Codex CLI is installed')
|
|
238
262
|
}
|
|
239
263
|
|
|
264
|
+
const codexConfig = getCodexRunConfig()
|
|
265
|
+
console.log(`[COS API] Codex mode: ${codexConfig.persistenceEnabled ? 'persistent' : 'ephemeral'} · ${codexConfig.reasoningEffort} · ${codexConfig.trustMode}`)
|
|
266
|
+
console.log(`[COS API] Codex models (${codexConfig.catalogSource}): ${codexConfig.availableModels.map(item => `${item.displayName}=${item.model}`).join(' · ')}`)
|
|
267
|
+
console.log(`[COS API] Codex workdir: ${codexConfig.cwd}`)
|
|
268
|
+
// Refresh immediately and then periodically. The catalog module retains the
|
|
269
|
+
// last-known-good snapshot if Codex is temporarily unavailable.
|
|
270
|
+
startCodexModelCatalogRefresh()
|
|
271
|
+
|
|
240
272
|
if (COS_MODE) {
|
|
241
273
|
initSessionCache()
|
|
242
274
|
// Pre-warm context cache so first query doesn't wait for the pipeline
|
|
@@ -251,9 +283,17 @@ app.listen(PORT, BIND_HOST, () => {
|
|
|
251
283
|
const vadOk = initSileroVAD()
|
|
252
284
|
console.log(`[startup] Silero VAD: ${vadOk ? 'active' : 'disabled (model not found)'}`)
|
|
253
285
|
|
|
254
|
-
// Pre-warm
|
|
255
|
-
|
|
286
|
+
// Pre-warm Claude only when installed so Codex-only startup stays quiet.
|
|
287
|
+
if (claudeAvailable) {
|
|
288
|
+
preWarmCLI().catch(err => console.error('[startup] CLI pre-warm error:', err))
|
|
289
|
+
}
|
|
256
290
|
|
|
257
291
|
// Auto-snapshot active sessions every 5 min (survives restarts)
|
|
258
292
|
startAutoSnapshot(5 * 60_000)
|
|
293
|
+
|
|
294
|
+
// Durable media GC (staged/reserved expiry + generated-image content TTL).
|
|
295
|
+
getMediaStore().startGC()
|
|
296
|
+
}).catch((error: NodeJS.ErrnoException) => {
|
|
297
|
+
console.error(`[COS API] Fatal listener startup: ${error.message}`)
|
|
298
|
+
process.exit(error.code === 'EADDRINUSE' ? 75 : 74)
|
|
259
299
|
})
|