@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.
- package/.env.example +18 -7
- package/CHANGELOG.md +116 -0
- package/README.md +26 -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 +65 -15
- package/server/lib/activity-preview.ts +168 -0
- package/server/lib/archive.ts +27 -9
- 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/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-lock.ts +122 -0
- package/server/routes/archive.ts +79 -0
- package/server/routes/health.ts +17 -1
- package/server/routes/media.ts +285 -0
- package/server/routes/message-ref.ts +202 -0
- package/server/routes/openai-compat.ts +44 -11
- package/server/routes/query.ts +51 -16
- package/server/routes/sessions.ts +299 -0
- 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'
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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]
|
|
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
|
|
247
|
-
|
|
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
|
+
}
|