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