@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 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 / Sonnet / Haiku -> Claude Code CLI (https://claude.ai/download, then `claude login`)
25
- # Codex High -> Codex CLI (https://developers.openai.com/codex/, then `codex login`)
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 | haiku | codex-high
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
- # Codex options (only used for the codex-high model):
30
- # COS_CODEX_MODEL pins a specific codex model id; blank = use your codex CLI default.
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, downloads the local voice model, writes
15
- `~/.cos-glasses/.env`, and starts the server on `0.0.0.0:3141`. On boot it prints
16
- an **API token** paste that into the COS Glasses app.
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/Haiku) — https://claude.ai/download, then `claude login`
22
- _or_ **Codex CLI** (Codex High) — https://developers.openai.com/codex/, then `codex login`
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`|`haiku`|`codex-high`).
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). Your name + transcription vocabulary live in
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 / Sonnet / Haiku) or Codex CLI (Codex High)')
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} --version 2>&1`, { shell: '/bin/sh', stdio: 'pipe', timeout: 5000 }).toString().trim()
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 / Sonnet / Haiku)'))
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/Haiku unavailable'))
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('(Codex High)'))
84
+ console.log(green(' ✓') + ` Codex CLI ${normalizeCodexVersion(codexVersion)} ` + dim('(GPT Frontier / Balanced)'))
85
85
  } else {
86
- console.log(yellow(' ⚠') + ' Codex CLI not found ' + dim('— Codex High unavailable'))
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: phone reachabilitythe glasses' phone app must reach this server.
187
+ // Step 6: image capabilityffmpeg 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 7: start the bundled server
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.1",
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": ["even-g2", "smart-glasses", "claude-code", "codex", "ai", "cos", "heads-up-display", "whisper"],
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/**/*.ts",
18
- "server/**/*.js",
19
- "shared/**/*.ts",
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
+ }