@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
package/.env.example
CHANGED
|
@@ -19,18 +19,29 @@ BIND_HOST=0.0.0.0
|
|
|
19
19
|
# here for a stable token across restarts.
|
|
20
20
|
# COS_API_TOKEN=pick-any-long-random-string
|
|
21
21
|
|
|
22
|
+
# Optional durable image-store location. Defaults to
|
|
23
|
+
# ~/.cos-glasses/data/media alongside the standalone conversation/archive data.
|
|
24
|
+
# COS_MEDIA_ROOT=/path/on-a-local-volume/media
|
|
25
|
+
|
|
22
26
|
# ── THE LLM (chat) ──────────────────────────────────────────────────────
|
|
23
27
|
# Chat runs through your LOCAL agent CLI — NOT an API key:
|
|
24
|
-
# Opus /
|
|
25
|
-
#
|
|
28
|
+
# Opus / Fable / Sonnet -> Claude Code CLI (https://claude.ai/download, then `claude login`)
|
|
29
|
+
# GPT Frontier/Balanced -> Codex CLI (https://developers.openai.com/codex/, then `codex login`)
|
|
26
30
|
# Install at least one. Pick the default model the glasses use:
|
|
27
|
-
# COS_G2_DEFAULT_MODEL=sonnet # opus | sonnet |
|
|
31
|
+
# COS_G2_DEFAULT_MODEL=sonnet # opus | fable | sonnet | codex-frontier | codex-balanced
|
|
32
|
+
#
|
|
33
|
+
# GPT slots refresh from the Codex model catalog every 15 minutes and retain
|
|
34
|
+
# their last-known-good resolution if discovery is temporarily unavailable.
|
|
35
|
+
# COS_CODEX_MODEL_REFRESH_TTL_MS=900000
|
|
36
|
+
# COS_CODEX_MODEL_REFRESH_TIMEOUT_MS=7000
|
|
28
37
|
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
# COS_CODEX_REASONING_EFFORT is low | medium | high (default high).
|
|
38
|
+
# Legacy explicit overrides remain supported for migrated codex-high/frontier
|
|
39
|
+
# installs. Leave blank for auto-latest; Balanced always stays auto-catalog.
|
|
32
40
|
# COS_CODEX_MODEL=
|
|
33
|
-
# COS_CODEX_REASONING_EFFORT=high
|
|
41
|
+
# COS_CODEX_REASONING_EFFORT=high # low | medium | high | xhigh | max | ultra
|
|
42
|
+
#
|
|
43
|
+
# Codex remains read-only by default. This is the only broader trust opt-in:
|
|
44
|
+
# COS_CODEX_SANDBOX=workspace-write
|
|
34
45
|
|
|
35
46
|
# ── VOICE (optional) ────────────────────────────────────────────────────
|
|
36
47
|
# Local transcription is FREE via whisper.cpp (brew install whisper-cpp; the
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,88 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 6.5.0
|
|
4
|
+
|
|
5
|
+
Durable phone photos and assistant-selected output images for COS Glasses
|
|
6
|
+
build 179, while preserving the public server's sandbox and privacy boundary.
|
|
7
|
+
|
|
8
|
+
- **One media contract.** Authenticated phone uploads become opaque attachment
|
|
9
|
+
refs, survive queues/restarts/archives/numbered-message recall, and resolve to
|
|
10
|
+
normalized server-owned files for Claude/Codex. Bytes and storage paths never
|
|
11
|
+
enter SSE, run ledgers, or archives.
|
|
12
|
+
- **Answer images.** Claude or Codex can publish an already-local generated,
|
|
13
|
+
researched, or explicitly used email image through a private run-scoped
|
|
14
|
+
capability. The server accepts JPEG/PNG/WebP/HEIC/HEIF/AVIF up to 16 MiB and
|
|
15
|
+
16 megapixels, strips metadata, re-encodes through the existing media store,
|
|
16
|
+
and appends refs to the completed answer.
|
|
17
|
+
- **No mailbox or URL crawler.** The publisher rejects URLs, data URIs, base64,
|
|
18
|
+
unrelated discovery, symlinks, directory replacement, content-id tampering,
|
|
19
|
+
over-capacity output, and manifest fields that could carry private paths.
|
|
20
|
+
- **Codex remains read-only.** Output publishing adds only the random private
|
|
21
|
+
run directory via `codex exec --add-dir`; global sandbox flags precede
|
|
22
|
+
`resume`. Older CLIs without `--add-dir` keep chat working and simply disable
|
|
23
|
+
Codex output-image publishing. There is no full-access fallback.
|
|
24
|
+
- **Durable finalization.** Assistant text is persisted before image
|
|
25
|
+
normalization, request media associates even if SSE disconnects, partial
|
|
26
|
+
image failures do not discard successful refs, and completion emits one
|
|
27
|
+
canonical `attachments` list with safe aggregate stats.
|
|
28
|
+
- **Lens contract.** `/api/health` advertises `mediaProcessingReady` and
|
|
29
|
+
`g2LensVariant=png-288x144-v1`; the media endpoint serves the validated phone,
|
|
30
|
+
thumbnail, and exact 288×144 G2 variants expected by build 179.
|
|
31
|
+
- **Fresh-install diagnostics.** The npm launcher now reports whether ffmpeg is
|
|
32
|
+
ready for phone/output/lens images, gives a non-blocking install command when
|
|
33
|
+
absent, and sends setup questions directly to `gotcos.com/wizard/`.
|
|
34
|
+
- **One server owner.** The public runner now claims the same atomic
|
|
35
|
+
machine-wide lock as the installed LaunchAgent before mutable modules load.
|
|
36
|
+
A duplicate exits with code 75, and HTTP/HTTPS listeners bind as one required
|
|
37
|
+
set: if either port is occupied, any earlier listener closes and the process
|
|
38
|
+
exits instead of surviving half-bound with separate SSE and media state.
|
|
39
|
+
|
|
40
|
+
Release evidence: TypeScript, 130/130 tests across 23 files, package dry-run
|
|
41
|
+
including the executable publisher and startup hardening, and a live duplicate
|
|
42
|
+
start against the installed LaunchAgent rejected before server initialization.
|
|
43
|
+
|
|
44
|
+
## 6.4.0
|
|
45
|
+
|
|
46
|
+
Fresh-install parity for COS Glasses builds 170–173, without weakening the
|
|
47
|
+
public server's sandbox defaults.
|
|
48
|
+
|
|
49
|
+
- **Auto-updating GPT Frontier + Balanced.** Stable client slots resolve to the
|
|
50
|
+
top two capable models in the newest visible GPT generation through Codex's
|
|
51
|
+
official `model/list` catalog. The server refreshes at boot and every 15
|
|
52
|
+
minutes, and each Codex run awaits the same TTL-cached/coalesced refresh
|
|
53
|
+
before resolving its slot. It preserves the last-known-good catalog on
|
|
54
|
+
failures and falls back to the CLI default only before any discovery succeeds.
|
|
55
|
+
- **Fable + effort controls.** Fable joins Opus and Sonnet as a first-class
|
|
56
|
+
Claude tier alias, and High / Extra High / Max / Ultracode now propagate from
|
|
57
|
+
`/api/query` to both Claude and Codex. Claude aliases remain versionless and
|
|
58
|
+
1M-context capable; Codex effort is clamped to each live model's advertised
|
|
59
|
+
support. Per-run ledgers record the concrete resolved model and effort.
|
|
60
|
+
- **Safe live job activity.** `activityToolMode` supports off, status-only, or
|
|
61
|
+
bounded observable tool input/output previews. ANSI/control data, credential
|
|
62
|
+
assignments, auth headers, provider tokens, JWTs, URL credentials, and opaque
|
|
63
|
+
blobs are redacted, including 40–72-character PEM/private-key body chunks.
|
|
64
|
+
Hidden reasoning is never surfaced.
|
|
65
|
+
- **Same-session run safety.** Turns for one conversation now serialize until
|
|
66
|
+
the active bridge sends a terminal callback, while different sessions remain
|
|
67
|
+
concurrent. Failed or cancelled Claude/Codex runs remove the exact pending
|
|
68
|
+
user exchange by object identity, preventing phantom prompts, duplicate-text
|
|
69
|
+
deletion, and resume-history contamination.
|
|
70
|
+
- **Authenticated transport boundary.** Activity lines are returned only on the
|
|
71
|
+
authenticated `/api/query` SSE stream. They are deliberately excluded from
|
|
72
|
+
the unauthenticated global display bus and its replay buffer.
|
|
73
|
+
- **Public trust model retained.** Codex remains read-only by default with only
|
|
74
|
+
the existing `workspace-write` opt-in. Existing archive traversal, local-day,
|
|
75
|
+
malformed-file, starter-kit launch-directory, and conversation behavior are
|
|
76
|
+
unchanged. Legacy `codex-high` state migrates to the frontier slot without
|
|
77
|
+
changing saved thread trust mode.
|
|
78
|
+
- **Diagnostics and compatibility.** `/api/models`, health data, and `/v1/models`
|
|
79
|
+
expose stable slots plus concrete live models. `cos-codex-high` remains an
|
|
80
|
+
accepted alias for older clients. Existing `COS_CODEX_MODEL` and
|
|
81
|
+
`COS_CODEX_REASONING_EFFORT` overrides continue to apply to the migrated
|
|
82
|
+
legacy/frontier slot; leave them unset for auto-latest. A new regression suite covers catalog
|
|
83
|
+
selection/fallback/refresh, sandbox arguments, migrations, effort mappings,
|
|
84
|
+
activity redaction, and the display-bus security boundary.
|
|
85
|
+
|
|
3
86
|
## 6.3.1
|
|
4
87
|
|
|
5
88
|
Security + robustness hardening on the 6.3.0 archive routes, from a 3-agent QA pass. (6.3.0 was never published; 6.3.1 is the first release of the expanded route set.)
|
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# COS Glasses Server
|
|
2
2
|
|
|
3
3
|
Self-hosted AI heads-up display for **Even G2 smart glasses**. Runs on your Mac,
|
|
4
|
-
talks to your local **Claude Code** CLI, and pushes answers, voice
|
|
4
|
+
talks to your local **Claude Code or Codex** CLI, and pushes answers, voice
|
|
5
5
|
transcription, and notes to the lens. Your data never leaves your machine, and no
|
|
6
6
|
API key is pasted into the phone for chat.
|
|
7
7
|
|
|
@@ -11,22 +11,31 @@ API key is pasted into the phone for chat.
|
|
|
11
11
|
npx @gotcos/glasses-server
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
The launcher checks Node, finds your CLI,
|
|
15
|
-
`~/.cos-glasses/.env`, and
|
|
16
|
-
|
|
14
|
+
The launcher checks Node, finds your CLI, checks voice and image processing,
|
|
15
|
+
downloads the local voice model when needed, writes `~/.cos-glasses/.env`, and
|
|
16
|
+
starts the server on `0.0.0.0:3141`. On boot it prints
|
|
17
|
+
an **API token** — paste that into the COS Glasses app. Only one COS Glasses
|
|
18
|
+
server may run on a Mac at a time; a second `npx` or source runner exits before
|
|
19
|
+
opening ports or touching shared conversation/media state.
|
|
17
20
|
|
|
18
21
|
## Requirements
|
|
19
22
|
|
|
20
23
|
- **Node.js 20.11+** — https://nodejs.org
|
|
21
|
-
- **Claude Code CLI** (Opus/Sonnet
|
|
22
|
-
_or_ **Codex CLI** (
|
|
24
|
+
- **Claude Code CLI** (Opus/Fable/Sonnet) — https://claude.ai/download, then `claude login`
|
|
25
|
+
_or_ **Codex CLI** (GPT Frontier/Balanced) — https://developers.openai.com/codex/, then `codex login`
|
|
23
26
|
- **Even G2 glasses** + the **COS Glasses** app from the Even Hub
|
|
24
27
|
- _Optional:_ `brew install whisper-cpp` for free local voice (otherwise OpenAI API)
|
|
28
|
+
- _Optional:_ `brew install ffmpeg` for phone/output image attachments (text chat remains available without it)
|
|
25
29
|
- _Optional:_ **Tailscale** so your phone reaches your Mac from anywhere
|
|
26
30
|
|
|
27
31
|
> No `ANTHROPIC_API_KEY` is needed — chat runs through your installed CLI, billed
|
|
28
32
|
> to your existing Claude or Codex subscription. Pick either per query, or set a
|
|
29
|
-
> default with `COS_G2_DEFAULT_MODEL` (`opus`|`sonnet`|`
|
|
33
|
+
> default with `COS_G2_DEFAULT_MODEL` (`opus`|`fable`|`sonnet`|`codex-frontier`|`codex-balanced`).
|
|
34
|
+
> Claude tier aliases and the two GPT slots resolve dynamically, so new model
|
|
35
|
+
> releases do not require a new glasses package. GPT discovery refreshes every
|
|
36
|
+
> 15 minutes and retains its last-known-good catalog through transient failures.
|
|
37
|
+
> Existing `COS_CODEX_MODEL` / `COS_CODEX_REASONING_EFFORT` settings remain
|
|
38
|
+
> supported on the migrated Frontier slot; leave them blank for auto-latest.
|
|
30
39
|
> Codex runs **sandboxed read-only** by default (`COS_CODEX_SANDBOX` to adjust).
|
|
31
40
|
|
|
32
41
|
## Connect your phone (the one gotcha)
|
|
@@ -44,8 +53,13 @@ The built-in IP allowlist blocks public-internet traffic regardless.
|
|
|
44
53
|
## What it does
|
|
45
54
|
|
|
46
55
|
- Ask anything, get a streamed answer on the lens (`/api/query`, `/v1/chat/completions`)
|
|
56
|
+
- Choose Opus, Fable, Sonnet, GPT Frontier, or GPT Balanced plus High, Extra
|
|
57
|
+
High, Max, or Ultracode effort; optional redacted tool activity streams only
|
|
58
|
+
to the authenticated query that requested it
|
|
47
59
|
- Message History + cross-day "reference message N" — your chats are archived by day
|
|
48
60
|
and every message keeps a permanent number you can recall (`/api/archive`, `/api/message/:num`)
|
|
61
|
+
- Send phone photos with queued prompts, and review assistant-selected generated,
|
|
62
|
+
research, or explicitly used email images in Messages and on the G2 lens
|
|
49
63
|
- Live voice capture + transcription during meetings
|
|
50
64
|
- Local whisper.cpp transcription (free) with OpenAI fallback (optional)
|
|
51
65
|
- Tasks / calendar / people context **if** you run the
|
|
@@ -57,7 +71,8 @@ The built-in IP allowlist blocks public-internet traffic regardless.
|
|
|
57
71
|
Config lives at `~/.cos-glasses/.env` (created on first run). Every key is
|
|
58
72
|
optional except an installed CLI. Highlights: `BIND_HOST`, `PORT`,
|
|
59
73
|
`COS_API_TOKEN` (auto if unset), `OPENAI_API_KEY` (cloud voice fallback),
|
|
60
|
-
`COS_SCRIPTS_DIR` (full pipeline)
|
|
74
|
+
`COS_SCRIPTS_DIR` (full pipeline), and `COS_MEDIA_ROOT` (optional image-store
|
|
75
|
+
location; default `~/.cos-glasses/data/media`). Your name + transcription vocabulary live in
|
|
61
76
|
`~/.cos-glasses/.cos-profile.json` (see `.cos-profile.example.json`).
|
|
62
77
|
|
|
63
78
|
## Run from source
|
|
@@ -74,6 +89,7 @@ BIND_HOST=0.0.0.0 npm run start:server
|
|
|
74
89
|
- *Phone can't connect* — check `BIND_HOST=0.0.0.0`, the same Tailscale account on both devices, and the correct `100.x` IP + token.
|
|
75
90
|
- *AI queries fail* — run `claude --version` / `codex --version`, then `claude login` / `codex login`.
|
|
76
91
|
- *Voice getting billed?* — install `whisper-cpp` for free local transcription.
|
|
92
|
+
- *Photos unavailable?* — install `ffmpeg`, restart the server, and confirm `/api/health` reports `features.mediaProcessingReady: true`.
|
|
77
93
|
|
|
78
94
|
## License
|
|
79
95
|
|
package/bin/cli.cjs
CHANGED
|
@@ -34,13 +34,13 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
|
34
34
|
console.log('')
|
|
35
35
|
console.log(' Requirements:')
|
|
36
36
|
console.log(' - Node.js 20.11+')
|
|
37
|
-
console.log(' - Claude Code CLI (Opus /
|
|
37
|
+
console.log(' - Claude Code CLI (Opus / Fable / Sonnet) or Codex CLI (GPT Frontier / Balanced)')
|
|
38
38
|
console.log(' - Even G2 smart glasses + the COS Glasses app (Even Hub)')
|
|
39
39
|
console.log('')
|
|
40
40
|
console.log(' No API key is needed for chat — it runs through your installed CLI.')
|
|
41
41
|
console.log(' Config persists at ~/.cos-glasses/.env')
|
|
42
42
|
console.log('')
|
|
43
|
-
console.log(' Setup guide: https://www.gotcos.com')
|
|
43
|
+
console.log(' Setup guide: https://www.gotcos.com/wizard/')
|
|
44
44
|
console.log('')
|
|
45
45
|
process.exit(0)
|
|
46
46
|
}
|
|
@@ -60,9 +60,9 @@ if (nodeMajor < 20 || (nodeMajor === 20 && nodeMinor < 11)) {
|
|
|
60
60
|
console.log(green(' ✓') + ` Node.js ${process.versions.node}`)
|
|
61
61
|
|
|
62
62
|
// Step 2: agent CLI detection — at least one of Claude Code / Codex is required
|
|
63
|
-
function getCliVersion(command) {
|
|
63
|
+
function getCliVersion(command, versionArg = '--version') {
|
|
64
64
|
try {
|
|
65
|
-
return execSync(`${command}
|
|
65
|
+
return execSync(`${command} ${versionArg} 2>&1`, { shell: '/bin/sh', stdio: 'pipe', timeout: 5000 }).toString().trim()
|
|
66
66
|
} catch {
|
|
67
67
|
return null
|
|
68
68
|
}
|
|
@@ -75,15 +75,15 @@ function normalizeCodexVersion(raw) {
|
|
|
75
75
|
const claudeVersion = getCliVersion('claude')
|
|
76
76
|
const codexVersion = getCliVersion('codex')
|
|
77
77
|
if (claudeVersion) {
|
|
78
|
-
console.log(green(' ✓') + ` Claude Code ${claudeVersion} ` + dim('(Opus /
|
|
78
|
+
console.log(green(' ✓') + ` Claude Code ${claudeVersion} ` + dim('(Opus / Fable / Sonnet)'))
|
|
79
79
|
} else {
|
|
80
|
-
console.log(yellow(' ⚠') + ' Claude Code CLI not found ' + dim('— Opus/Sonnet
|
|
80
|
+
console.log(yellow(' ⚠') + ' Claude Code CLI not found ' + dim('— Opus/Fable/Sonnet unavailable'))
|
|
81
81
|
console.log(' Install: ' + bold('https://claude.ai/download'))
|
|
82
82
|
}
|
|
83
83
|
if (codexVersion) {
|
|
84
|
-
console.log(green(' ✓') + ` Codex CLI ${normalizeCodexVersion(codexVersion)} ` + dim('(
|
|
84
|
+
console.log(green(' ✓') + ` Codex CLI ${normalizeCodexVersion(codexVersion)} ` + dim('(GPT Frontier / Balanced)'))
|
|
85
85
|
} else {
|
|
86
|
-
console.log(yellow(' ⚠') + ' Codex CLI not found ' + dim('—
|
|
86
|
+
console.log(yellow(' ⚠') + ' Codex CLI not found ' + dim('— GPT Frontier/Balanced unavailable'))
|
|
87
87
|
}
|
|
88
88
|
if (!claudeVersion && !codexVersion) {
|
|
89
89
|
console.log('')
|
|
@@ -184,13 +184,25 @@ if (whisperCliPath && hasValidModel) {
|
|
|
184
184
|
console.log(' Free local voice: ' + bold('brew install whisper-cpp') + dim(' (no Homebrew? https://brew.sh)'))
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
// Step 6:
|
|
187
|
+
// Step 6: image capability — ffmpeg validates, strips metadata, normalizes,
|
|
188
|
+
// and builds the exact 288x144 G2 variant. It is optional so text/voice remain
|
|
189
|
+
// useful on a minimal install, but the launcher should make the gap visible.
|
|
190
|
+
const ffmpegVersion = getCliVersion('ffmpeg', '-version')
|
|
191
|
+
if (ffmpegVersion) {
|
|
192
|
+
const firstLine = ffmpegVersion.split('\n')[0].trim()
|
|
193
|
+
console.log(green(' ✓') + ` ${firstLine} ` + dim('— phone + lens images ready'))
|
|
194
|
+
} else {
|
|
195
|
+
console.log(yellow(' ⚠') + ' ffmpeg not installed ' + dim('— phone and answer images disabled'))
|
|
196
|
+
console.log(' Enable photos: ' + bold('brew install ffmpeg') + dim(' (text + voice still work)'))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Step 7: phone reachability — the glasses' phone app must reach this server.
|
|
188
200
|
if (!process.env.BIND_HOST) {
|
|
189
201
|
process.env.BIND_HOST = '0.0.0.0'
|
|
190
202
|
console.log(yellow(' ⚠') + ' BIND_HOST not set — defaulting to 0.0.0.0 so your phone can reach the server')
|
|
191
203
|
}
|
|
192
204
|
|
|
193
|
-
// Step
|
|
205
|
+
// Step 8: start the bundled server
|
|
194
206
|
try {
|
|
195
207
|
const ld = process.env.COS_LAUNCH_DIR
|
|
196
208
|
if (ld && (existsSync(join(ld, '.cos', 'manifest.json')) || existsSync(join(ld, 'AGENTS.md')) || existsSync(join(ld, 'CLAUDE.md')))) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.5.0",
|
|
4
4
|
"description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by your local Claude Code or Codex CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,14 +9,25 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node bin/cli.cjs",
|
|
11
11
|
"start:server": "node --import tsx/esm server/index.ts",
|
|
12
|
+
"test": "vitest run",
|
|
12
13
|
"typecheck": "tsc --noEmit"
|
|
13
14
|
},
|
|
14
|
-
"keywords": [
|
|
15
|
+
"keywords": [
|
|
16
|
+
"even-g2",
|
|
17
|
+
"smart-glasses",
|
|
18
|
+
"claude-code",
|
|
19
|
+
"codex",
|
|
20
|
+
"ai",
|
|
21
|
+
"cos",
|
|
22
|
+
"heads-up-display",
|
|
23
|
+
"whisper"
|
|
24
|
+
],
|
|
15
25
|
"files": [
|
|
16
26
|
"bin/cli.cjs",
|
|
17
|
-
"server
|
|
18
|
-
"
|
|
19
|
-
"
|
|
27
|
+
"server",
|
|
28
|
+
"shared",
|
|
29
|
+
"!server/**/*.test.ts",
|
|
30
|
+
"!shared/**/*.test.ts",
|
|
20
31
|
".env.example",
|
|
21
32
|
".cos-profile.example.json",
|
|
22
33
|
"README.md",
|
|
@@ -48,6 +59,7 @@
|
|
|
48
59
|
"@types/cors": "^2.8.19",
|
|
49
60
|
"@types/express": "^5.0.6",
|
|
50
61
|
"@types/node": "^22.10.0",
|
|
51
|
-
"typescript": "^5.9.3"
|
|
62
|
+
"typescript": "^5.9.3",
|
|
63
|
+
"vitest": "^3.2.7"
|
|
52
64
|
}
|
|
53
65
|
}
|
|
@@ -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
|
+
}
|