@gotcos/glasses-server 6.12.1 → 6.12.3

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
@@ -38,7 +38,7 @@ BIND_HOST=0.0.0.0
38
38
 
39
39
  # ── THE LLM (chat) ──────────────────────────────────────────────────────
40
40
  # Chat runs through your LOCAL agent CLI — NOT an API key:
41
- # Opus / Fable / Sonnet -> Claude Code CLI (https://claude.ai/download, then `claude login`)
41
+ # Opus / Fable / Sonnet -> Claude Code CLI (https://docs.anthropic.com/en/docs/claude-code/getting-started, then `claude auth login`)
42
42
  # GPT Frontier/Balanced -> Codex CLI (https://developers.openai.com/codex/, then `codex login`)
43
43
  # Install at least one. Pick the default model the glasses use:
44
44
  # COS_G2_DEFAULT_MODEL=sonnet # opus | fable | sonnet | codex-frontier | codex-balanced
@@ -55,6 +55,12 @@ BIND_HOST=0.0.0.0
55
55
  #
56
56
  # Codex remains read-only by default. This is the only broader trust opt-in:
57
57
  # COS_CODEX_SANDBOX=workspace-write
58
+ #
59
+ # Claude preserves COS's established trusted-machine behavior by default.
60
+ # Security-conscious installs can remove the permission bypass and restrict
61
+ # Claude to COS's explicit per-query tool allowlist. Undeclared tools fail
62
+ # closed without an interactive prompt:
63
+ # COS_CLAUDE_TRUST_MODE=allowlist
58
64
 
59
65
  # ── VOICE (optional) ────────────────────────────────────────────────────
60
66
  # Local transcription is FREE via whisper.cpp (brew install whisper-cpp; the
@@ -68,3 +74,7 @@ BIND_HOST=0.0.0.0
68
74
  # Power users running the COS Starter Kit can point the glasses at their
69
75
  # pipeline to inherit live tasks/calendar/people context. Omit for standalone.
70
76
  # COS_SCRIPTS_DIR=/path/to/your/cos/operations/scripts
77
+
78
+ # Telegram session/activity notifications remain OFF even if the COS scripts
79
+ # directory contains .telegram_config.json. Enable export explicitly:
80
+ # COS_TELEGRAM_NOTIFICATIONS=1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.12.3
4
+
5
+ Security hardening from the public launch review, without changing app/server
6
+ wire contracts.
7
+
8
+ - **Stored prompts never enter a shell command.** Archive title generation now
9
+ launches Claude with an argument array and sends user content over stdin. A
10
+ regression test proves command substitutions and backticks remain inert.
11
+ - **Claude can run in a real allowlist mode.** Existing installs retain trusted
12
+ mode for backward compatibility. Setting `COS_CLAUDE_TRUST_MODE=allowlist`
13
+ removes the permission bypass, restricts Claude to COS's explicit per-query
14
+ tools, and denies undeclared tools without an interactive prompt.
15
+ - **Tailscale matching is exact.** Network and CORS policy now accept only the
16
+ assigned `100.64.0.0/10` CGNAT range rather than every `100.x` address.
17
+ Localhost and RFC1918 LAN access remain unchanged.
18
+ - **Durable local state is private.** Runtime data and archive directories are
19
+ repaired to `0700`; atomic state, conversation archives, session logs, and
20
+ the saved OpenAI key are created or repaired to `0600`. Credential writes use
21
+ private, exclusive, fsync-backed atomic publication.
22
+ - **Telegram export requires consent.** Merely finding a private
23
+ `.telegram_config.json` no longer enables activity export. Operators must set
24
+ the exact `COS_TELEGRAM_NOTIFICATIONS=1` opt-in.
25
+ - **Canonical history remains exact.** Operational previews and provider
26
+ ledgers keep their existing redaction, while durable prompts/answers are not
27
+ silently mutated; recovery, retries, and `reference message N` remain intact.
28
+ - **Backward compatible.** Query, prompt recovery, meetings, media, display,
29
+ diagnostics, transcription, and protocol response shapes are unchanged.
30
+
31
+ ## 6.12.2
32
+
33
+ First-install hardening for public `npx` users.
34
+
35
+ - **No nested install inside npm's cache.** The launcher resolves the declared
36
+ `tsx` dependency from npm's existing `npx` dependency tree and never runs a
37
+ second `npm install` from the ephemeral package directory.
38
+ - **Permission failures do not spread.** A broken or incomplete package fails
39
+ closed with a user-owned isolated-cache recovery command. COS never suggests
40
+ `sudo npm`, broad ownership changes, or writing through a root-owned cache.
41
+ - **Claude Desktop is no longer mistaken for Claude Code.** First-run guidance
42
+ explicitly requires the terminal CLI, keeps the scoped npm command on one
43
+ copyable line, forbids `sudo`, and explains the interactive sign-in step.
44
+ - **Known signed-out agents fail before startup.** Installed Claude/Codex
45
+ binaries are checked before first-query readiness. Older CLI versions whose
46
+ authentication state cannot be proven show a warning instead of a false
47
+ signed-in claim.
48
+ - **Local credentials are private.** `~/.cos-glasses` is repaired to `0700`;
49
+ the token and profile files are repaired to `0600`; symlinked credential
50
+ paths fail closed; auto-generated tokens are persisted with an atomic write.
51
+ - **Runtime behavior is unchanged.** Query, prompt recovery, meetings, media,
52
+ diagnostics, transcription, display, and server data contracts are untouched.
53
+
3
54
  ## 6.12.1
4
55
 
5
56
  Public-safe CLI diagnostics for the COS Glasses Recovery Center.
package/README.md CHANGED
@@ -8,7 +8,7 @@ API key is pasted into the phone for chat.
8
8
  ## Quick start
9
9
 
10
10
  ```bash
11
- npx @gotcos/glasses-server
11
+ npx --yes @gotcos/glasses-server@latest
12
12
  ```
13
13
 
14
14
  The launcher checks Node, finds your CLI, checks voice and image processing,
@@ -24,7 +24,10 @@ without silently losing completed replies.
24
24
  ## Requirements
25
25
 
26
26
  - **Node.js 20.11+** — https://nodejs.org
27
- - **Claude Code CLI** (Opus/Fable/Sonnet) https://claude.ai/download, then `claude login`
27
+ - **Claude Code CLI** (Opus/Fable/Sonnet). Claude Desktop alone does not install
28
+ the terminal command. Install it on one line with
29
+ `npm install -g @anthropic-ai/claude-code` (**never with `sudo`**), then run
30
+ `claude` and finish the browser sign-in
28
31
  _or_ **Codex CLI** (GPT Frontier/Balanced) — https://developers.openai.com/codex/, then `codex login`
29
32
  - **Even G2 glasses** + the **COS Glasses** app from the Even Hub
30
33
  - `brew install whisper-cpp` for free local voice (the launcher can download the model)
@@ -40,6 +43,10 @@ without silently losing completed replies.
40
43
  > Existing `COS_CODEX_MODEL` / `COS_CODEX_REASONING_EFFORT` settings remain
41
44
  > supported on the migrated Frontier slot; leave them blank for auto-latest.
42
45
  > Codex runs **sandboxed read-only** by default (`COS_CODEX_SANDBOX` to adjust).
46
+ > Claude preserves the established trusted-machine mode for compatibility.
47
+ > Set `COS_CLAUDE_TRUST_MODE=allowlist` to remove Claude's permission bypass
48
+ > and restrict it to COS's explicit per-query tool allowlist; undeclared tools
49
+ > then fail closed without prompting.
43
50
 
44
51
  ## Connect your phone (the one gotcha)
45
52
 
@@ -51,7 +58,9 @@ The glasses app runs on your iPhone and must reach this server on your Mac.
51
58
  4. Either way, paste the **API token** the server printed at boot.
52
59
 
53
60
  To restrict the server to localhost only, set `BIND_HOST=127.0.0.1` in `~/.cos-glasses/.env`.
54
- The built-in IP allowlist blocks public-internet traffic regardless.
61
+ The built-in IP allowlist blocks public-internet traffic regardless. Its mesh
62
+ range is the exact Tailscale/CGNAT allocation (`100.64.0.0/10`), not all of
63
+ `100.0.0.0/8`; RFC1918 LAN ranges remain supported.
55
64
 
56
65
  ## What it does
57
66
 
@@ -94,6 +103,9 @@ optional except an installed CLI. Highlights: `BIND_HOST`, `PORT`,
94
103
  server-owned query recovery), and `COS_MEDIA_ROOT` (optional image-store
95
104
  location; default `~/.cos-glasses/data/media`). Your name + transcription vocabulary live in
96
105
  `~/.cos-glasses/.cos-profile.json` (see `.cos-profile.example.json`).
106
+ Telegram activity export is disabled by default even when a private COS
107
+ pipeline contains `.telegram_config.json`; enable it only with the explicit
108
+ `COS_TELEGRAM_NOTIFICATIONS=1` opt-in.
97
109
 
98
110
  ## Run from source
99
111
 
@@ -106,9 +118,20 @@ BIND_HOST=0.0.0.0 npm run start:server
106
118
 
107
119
  ## Troubleshooting
108
120
 
121
+ - *Claude Desktop is installed but COS says Claude Code is missing* — Desktop
122
+ and the terminal CLI are separate. Run
123
+ `npm install -g @anthropic-ai/claude-code` on one line without `sudo`, then
124
+ run `claude` and complete sign-in. Verify with `claude --version` before
125
+ starting COS again.
126
+ - *npm reports EACCES or a root-owned cache* — never run COS or npm with
127
+ `sudo`, and do not recursively change system ownership. Use a private COS
128
+ cache instead:
129
+ `npm_config_cache="$HOME/.cos-glasses/npm-cache" npx --yes @gotcos/glasses-server@latest`.
130
+ Version 6.12.2+ never runs a second install from inside npm's temporary cache.
109
131
  - *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.
110
- - *Safari connects but the app does not* — confirm `npx @gotcos/glasses-server@latest` is 6.6.0+, then use the app's server reconnect/edit control to verify the current URL and token. Do not run a second source or `npx` server alongside it.
111
- - *AI queries fail* — run `claude --version` / `codex --version`, then `claude login` / `codex login`.
132
+ - *Safari connects but the app does not* — confirm `npx --yes @gotcos/glasses-server@latest` is 6.6.0+, then use the app's server reconnect/edit control to verify the current URL and token. Do not run a second source or `npx` server alongside it.
133
+ - *AI queries fail* — run `claude auth status` / `codex login status`, then
134
+ `claude auth login` / `codex login` when the provider reports signed out.
112
135
  - *Voice getting billed?* — voice is local-only by default in 6.12.0+. Confirm
113
136
  `/api/health` reports `capabilities.transcription.mode: "local-only"`. Remove
114
137
  `COS_OPENAI_WHISPER_FALLBACK` (or set it to `0`) to disable an earlier opt-in.
@@ -117,7 +140,7 @@ BIND_HOST=0.0.0.0 npm run start:server
117
140
  keeps compatible prompt/meeting audio available for retry instead of silently
118
141
  sending it to OpenAI.
119
142
  - *Photos unavailable?* — install `ffmpeg`, restart the server, and confirm `/api/health` reports `features.mediaProcessingReady: true`.
120
- - *Prompt recovery unavailable?* — update with `npx @gotcos/glasses-server@latest`, then confirm `/api/health` reports `features.promptRecovery: true`.
143
+ - *Prompt recovery unavailable?* — update with `npx --yes @gotcos/glasses-server@latest`, then confirm `/api/health` reports `features.promptRecovery: true`.
121
144
  - *Durable query recovery unavailable?* — build 204+ requires server 6.10.0+ and
122
145
  `COS_DURABLE_QUERY_JOBS=1`. Restart once, then confirm `/api/health` reports
123
146
  `features.durableQueryJobs: true`, protocol `1`, and state `ready`. To roll
package/bin/cli.cjs CHANGED
@@ -5,7 +5,17 @@
5
5
  // package — there is no clone. Config persists at ~/.cos-glasses/.
6
6
 
7
7
  const { execSync, spawn } = require('child_process')
8
- const { existsSync, mkdirSync, statSync, readFileSync, copyFileSync, unlinkSync, renameSync } = require('fs')
8
+ const {
9
+ existsSync,
10
+ mkdirSync,
11
+ statSync,
12
+ lstatSync,
13
+ readFileSync,
14
+ copyFileSync,
15
+ unlinkSync,
16
+ renameSync,
17
+ chmodSync,
18
+ } = require('fs')
9
19
  const { join, resolve } = require('path')
10
20
  const { homedir } = require('os')
11
21
 
@@ -30,11 +40,11 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
30
40
  console.log(bold(' COS Glasses Server'))
31
41
  console.log('')
32
42
  console.log(' Usage:')
33
- console.log(' npx @gotcos/glasses-server')
43
+ console.log(' npx --yes @gotcos/glasses-server@latest')
34
44
  console.log('')
35
45
  console.log(' Requirements:')
36
46
  console.log(' - Node.js 20.11+')
37
- console.log(' - Claude Code CLI (Opus / Fable / Sonnet) or Codex CLI (GPT Frontier / Balanced)')
47
+ console.log(' - Claude Code CLI (not Claude Desktop) or Codex CLI')
38
48
  console.log(' - Even G2 smart glasses + the COS Glasses app (Even Hub)')
39
49
  console.log('')
40
50
  console.log(' No API key is needed for chat — it runs through your installed CLI.')
@@ -72,52 +82,142 @@ function normalizeCodexVersion(raw) {
72
82
  const line = raw.split('\n').map((s) => s.trim()).find((s) => /^codex(?:-cli)?\s+/i.test(s)) || raw.split('\n')[0].trim()
73
83
  return line.replace(/^codex(?:-cli)?\s*/i, '') || line
74
84
  }
85
+ function commandResult(command) {
86
+ try {
87
+ return {
88
+ ok: true,
89
+ output: execSync(`${command} 2>&1`, { shell: '/bin/sh', stdio: 'pipe', timeout: 5000 }).toString().trim(),
90
+ }
91
+ } catch (err) {
92
+ return {
93
+ ok: false,
94
+ output: (err.stdout?.toString() || err.stderr?.toString() || '').trim(),
95
+ }
96
+ }
97
+ }
98
+ function claudeAuthState() {
99
+ const result = commandResult('claude auth status --json')
100
+ try {
101
+ const parsed = JSON.parse(result.output)
102
+ if (parsed.loggedIn === true) return 'ready'
103
+ if (parsed.loggedIn === false) return 'signed-out'
104
+ } catch { /* older CLIs may not support JSON status */ }
105
+ if (/not logged in|logged out|sign[ -]?in required/i.test(result.output)) return 'signed-out'
106
+ return 'unknown'
107
+ }
108
+ function codexAuthState() {
109
+ const result = commandResult('codex login status')
110
+ if (/logged in/i.test(result.output) && !/not logged in/i.test(result.output)) return 'ready'
111
+ if (/not logged in|logged out|sign[ -]?in required/i.test(result.output)) return 'signed-out'
112
+ return 'unknown'
113
+ }
75
114
  const claudeVersion = getCliVersion('claude')
76
115
  const codexVersion = getCliVersion('codex')
116
+ const claudeAuth = claudeVersion ? claudeAuthState() : null
117
+ const codexAuth = codexVersion ? codexAuthState() : null
77
118
  if (claudeVersion) {
78
- console.log(green('') + ` Claude Code ${claudeVersion} ` + dim('(Opus / Fable / Sonnet)'))
119
+ if (claudeAuth === 'signed-out') {
120
+ console.log(yellow(' ⚠') + ` Claude Code ${claudeVersion} installed — sign-in required`)
121
+ console.log(' Run: ' + bold('claude auth login'))
122
+ } else if (claudeAuth === 'unknown') {
123
+ console.log(yellow(' ⚠') + ` Claude Code ${claudeVersion} installed — sign-in status unavailable`)
124
+ console.log(' Verify: ' + bold('claude auth status'))
125
+ } else {
126
+ console.log(green(' ✓') + ` Claude Code ${claudeVersion} ` + dim('(Opus / Fable / Sonnet)'))
127
+ }
79
128
  } else {
80
129
  console.log(yellow(' ⚠') + ' Claude Code CLI not found ' + dim('— Opus/Fable/Sonnet unavailable'))
81
- console.log(' Install: ' + bold('https://claude.ai/download'))
130
+ console.log(' Claude Desktop does not install the terminal CLI.')
131
+ console.log(' Install (no sudo): ' + bold('npm install -g @anthropic-ai/claude-code'))
132
+ console.log(' Then run: ' + bold('claude') + ' and finish sign-in')
82
133
  }
83
134
  if (codexVersion) {
84
- console.log(green(' ✓') + ` Codex CLI ${normalizeCodexVersion(codexVersion)} ` + dim('(GPT Frontier / Balanced)'))
135
+ if (codexAuth === 'signed-out') {
136
+ console.log(yellow(' ⚠') + ` Codex CLI ${normalizeCodexVersion(codexVersion)} installed — sign-in required`)
137
+ console.log(' Run: ' + bold('codex login'))
138
+ } else if (codexAuth === 'unknown') {
139
+ console.log(yellow(' ⚠') + ` Codex CLI ${normalizeCodexVersion(codexVersion)} installed — sign-in status unavailable`)
140
+ console.log(' Verify: ' + bold('codex login status'))
141
+ } else {
142
+ console.log(green(' ✓') + ` Codex CLI ${normalizeCodexVersion(codexVersion)} ` + dim('(GPT Frontier / Balanced)'))
143
+ }
85
144
  } else {
86
145
  console.log(yellow(' ⚠') + ' Codex CLI not found ' + dim('— GPT Frontier/Balanced unavailable'))
87
146
  }
88
- if (!claudeVersion && !codexVersion) {
147
+ const hasUsableAgent = (claudeVersion && claudeAuth !== 'signed-out') || (codexVersion && codexAuth !== 'signed-out')
148
+ if (!hasUsableAgent) {
89
149
  console.log('')
90
- console.log(red(' ✗ No supported agent CLI found'))
91
- console.log(' Install Claude Code: ' + bold('https://claude.ai/download'))
150
+ console.log(red(' ✗ No signed-in agent CLI is ready'))
151
+ console.log(' Claude Desktop alone is not enough; COS needs a terminal CLI.')
152
+ console.log(' Install Claude Code (no sudo): ' + bold('npm install -g @anthropic-ai/claude-code'))
153
+ console.log(' Then run: ' + bold('claude auth login'))
92
154
  console.log(' or Codex CLI: ' + bold('https://developers.openai.com/codex/') + ' then ' + bold('codex login'))
155
+ console.log(' Setup help: ' + bold('https://www.gotcos.com/wizard/'))
93
156
  console.log('')
94
157
  process.exit(1)
95
158
  }
96
159
 
97
- // Step 3: dependencies only when run from a git clone without node_modules.
98
- // `npx` already installed the package's deps alongside it.
99
- if (!existsSync(join(PKG_ROOT, 'node_modules'))) {
160
+ // Step 3: resolve the dependency npm already installed. With `npx`, dependency
161
+ // packages are siblings in npm's temporary node_modules tree rather than under
162
+ // PKG_ROOT/node_modules. Running a second `npm install` from inside that cache
163
+ // is both unnecessary and unsafe: a prior sudo-based npm install can make the
164
+ // shared cache root-owned and turn an otherwise valid first launch into EACCES.
165
+ let tsxImport
166
+ try {
167
+ tsxImport = require.resolve('tsx/esm', { paths: [PKG_ROOT] })
168
+ } catch {
100
169
  console.log('')
101
- console.log(` ${yellow('⟳')} Installing dependencies...`)
102
- try {
103
- execSync('npm install', { cwd: PKG_ROOT, stdio: 'pipe', timeout: 300000 })
104
- console.log(green('') + ' Dependencies installed')
105
- } catch (err) {
106
- console.log(red(' ✗ npm install failed'))
107
- console.log(` ${err.stderr?.toString().slice(0, 200) || err.message}`)
108
- process.exit(1)
109
- }
170
+ console.log(red(' COS package dependencies are incomplete'))
171
+ console.log(' Do not use sudo. Retry with an isolated user-owned npm cache:')
172
+ console.log(' ' + bold('npm_config_cache="$HOME/.cos-glasses/npm-cache" npx --yes @gotcos/glasses-server@latest'))
173
+ console.log(' Source installs only: run ' + bold('npm install') + ' in the cloned repository.')
174
+ process.exit(1)
110
175
  }
111
176
 
112
177
  // Step 4: persistent config at ~/.cos-glasses/ (survives npx cache churn)
113
- mkdirSync(CONFIG_DIR, { recursive: true })
178
+ function securePrivateDirectory(dir) {
179
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
180
+ const stats = lstatSync(dir)
181
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
182
+ throw new Error(`${dir} must be a private directory, not a symlink`)
183
+ }
184
+ chmodSync(dir, 0o700)
185
+ }
186
+
187
+ function securePrivateFile(file) {
188
+ if (!existsSync(file)) return
189
+ const stats = lstatSync(file)
190
+ if (stats.isSymbolicLink() || !stats.isFile()) {
191
+ throw new Error(`${file} must be a regular file, not a symlink`)
192
+ }
193
+ chmodSync(file, 0o600)
194
+ }
195
+
196
+ try {
197
+ securePrivateDirectory(CONFIG_DIR)
198
+ } catch (err) {
199
+ console.log('')
200
+ console.log(red(' ✗ COS config directory is unsafe'))
201
+ console.log(` ${err.message}`)
202
+ console.log(' Move it aside and rerun COS; do not use sudo or broad ownership changes.')
203
+ process.exit(1)
204
+ }
114
205
  const ENV_FILE = join(CONFIG_DIR, '.env')
115
206
  const ENV_EXAMPLE = join(PKG_ROOT, '.env.example')
116
207
  if (!existsSync(ENV_FILE) && existsSync(ENV_EXAMPLE)) {
117
208
  copyFileSync(ENV_EXAMPLE, ENV_FILE)
209
+ chmodSync(ENV_FILE, 0o600)
118
210
  console.log(green(' ✓') + ` Created config at ${dim(ENV_FILE)}`)
119
211
  }
120
212
  if (existsSync(ENV_FILE)) {
213
+ try {
214
+ securePrivateFile(ENV_FILE)
215
+ } catch (err) {
216
+ console.log('')
217
+ console.log(red(' ✗ COS config file is unsafe'))
218
+ console.log(` ${err.message}`)
219
+ process.exit(1)
220
+ }
121
221
  try {
122
222
  for (const line of readFileSync(ENV_FILE, 'utf-8').split('\n')) {
123
223
  const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)=(.*)$/)
@@ -130,6 +230,15 @@ const PROFILE_FILE = join(CONFIG_DIR, '.cos-profile.json')
130
230
  const PROFILE_EXAMPLE = join(PKG_ROOT, '.cos-profile.example.json')
131
231
  if (!existsSync(PROFILE_FILE) && existsSync(PROFILE_EXAMPLE)) {
132
232
  copyFileSync(PROFILE_EXAMPLE, PROFILE_FILE)
233
+ chmodSync(PROFILE_FILE, 0o600)
234
+ }
235
+ try {
236
+ securePrivateFile(PROFILE_FILE)
237
+ } catch (err) {
238
+ console.log('')
239
+ console.log(red(' ✗ COS profile is unsafe'))
240
+ console.log(` ${err.message}`)
241
+ process.exit(1)
133
242
  }
134
243
  if (!process.env.COS_PROFILE_PATH) process.env.COS_PROFILE_PATH = PROFILE_FILE
135
244
 
@@ -156,14 +265,15 @@ function isValidWhisperModel(p) {
156
265
  }
157
266
  const whisperCliPath = findWhisperCli()
158
267
  const hasValidModel = isValidWhisperModel(WHISPER_MODEL_PATH)
268
+ let localVoiceReady = Boolean(whisperCliPath && hasValidModel)
159
269
  if (whisperCliPath && hasValidModel) {
160
270
  console.log(green(' ✓') + ' whisper.cpp + model ready ' + dim('— voice = local (FREE)'))
161
271
  } else if (whisperCliPath && !hasValidModel) {
162
272
  if (existsSync(WHISPER_MODEL_PATH)) { try { unlinkSync(WHISPER_MODEL_PATH) } catch {} }
163
273
  if (existsSync(WHISPER_MODEL_PARTIAL)) { try { unlinkSync(WHISPER_MODEL_PARTIAL) } catch {} }
164
274
  console.log(yellow(' ⚠') + ' whisper.cpp installed but model missing')
165
- console.log(' ' + dim('Downloading ggml-large-v3-turbo (~1.5 GB). Ctrl-C to skip (voice remains unavailable by default).'))
166
- console.log(' ' + dim('Skip permanently: SKIP_WHISPER_DOWNLOAD=1 npx @gotcos/glasses-server'))
275
+ console.log(' ' + dim('Downloading ggml-large-v3-turbo (~1.5 GB).'))
276
+ console.log(' ' + dim('Skip: SKIP_WHISPER_DOWNLOAD=1 npx --yes @gotcos/glasses-server@latest'))
167
277
  if (process.env.SKIP_WHISPER_DOWNLOAD === '1') {
168
278
  console.log(yellow(' ⚠') + ' SKIP_WHISPER_DOWNLOAD=1 — local voice unavailable')
169
279
  } else {
@@ -173,6 +283,7 @@ if (whisperCliPath && hasValidModel) {
173
283
  const stats = statSync(WHISPER_MODEL_PARTIAL)
174
284
  if (stats.size < WHISPER_MODEL_MIN_BYTES) throw new Error(`Downloaded file too small: ${stats.size} bytes`)
175
285
  renameSync(WHISPER_MODEL_PARTIAL, WHISPER_MODEL_PATH)
286
+ localVoiceReady = true
176
287
  console.log(green(' ✓') + ' Model downloaded ' + dim('— voice = local (FREE)'))
177
288
  } catch (err) {
178
289
  try { unlinkSync(WHISPER_MODEL_PARTIAL) } catch {}
@@ -215,17 +326,23 @@ try {
215
326
  console.log(green(' ✓') + ` COS detected in ${dim(ld)} — glasses chat will load its brain`)
216
327
  }
217
328
  } catch { /* detection is best-effort */ }
329
+ if (!localVoiceReady) {
330
+ console.log('')
331
+ console.log(yellow(' ⚠ LOCAL VOICE NOT READY'))
332
+ console.log(' Text chat can start. Under the default local-only policy, voice prompts remain unavailable.')
333
+ console.log(' Install: ' + bold('brew install whisper-cpp'))
334
+ console.log(' Then stop COS with Ctrl-C and rerun: ' + bold('npx --yes @gotcos/glasses-server@latest'))
335
+ }
218
336
  console.log('')
219
337
  console.log(dim(' Starting server...'))
220
338
  console.log('')
221
339
  const serverProc = spawn(
222
340
  process.execPath,
223
- ['--import', 'tsx/esm', 'server/index.ts'],
341
+ ['--import', tsxImport, 'server/index.ts'],
224
342
  { cwd: PKG_ROOT, stdio: 'inherit', env: { ...process.env } }
225
343
  )
226
344
  serverProc.on('error', (err) => {
227
345
  console.error(red(` Server failed to start: ${err.message}`))
228
- if (err.message.includes('tsx')) console.error(' Try: cd ' + PKG_ROOT + ' && npm install')
229
346
  process.exit(1)
230
347
  })
231
348
  serverProc.on('exit', (code) => process.exit(code ?? 0))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.12.1",
3
+ "version": "6.12.3",
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": {
package/server/index.ts CHANGED
@@ -7,7 +7,7 @@ import path from 'node:path'
7
7
  import { fileURLToPath } from 'node:url'
8
8
  import { createServer as createHttpsServer } from 'node:https'
9
9
  import { createServer as createHttpServer } from 'node:http'
10
- import { readFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs'
10
+ import { readFileSync, existsSync } from 'node:fs'
11
11
  import { networkInterfaces, homedir } from 'node:os'
12
12
  import { join } from 'node:path'
13
13
  import { execSync } from 'node:child_process'
@@ -44,6 +44,7 @@ import { getMediaStore } from './lib/media-store.js'
44
44
  import { listenRequiredServers, type RequiredListener } from './lib/listener-startup.js'
45
45
  import { serverMetrics } from './lib/server-metrics.js'
46
46
  import { initializeServerInstanceId } from './lib/server-instance-id.js'
47
+ import { appendPrivateEnvBlock, UnsafeUserConfigPathError } from './lib/secure-user-config.js'
47
48
  import { createQueryJobsRouter } from './routes/query-jobs.js'
48
49
  import {
49
50
  initQueryJobRuntime,
@@ -51,6 +52,11 @@ import {
51
52
  queryJobCoordinator,
52
53
  shutdownQueryJobRuntime,
53
54
  } from './lib/query-job-runtime.js'
55
+ import {
56
+ isAllowedNetworkIp,
57
+ isAllowedNetworkOrigin,
58
+ isTailscaleIpv4,
59
+ } from './lib/network-policy.js'
54
60
 
55
61
  const app = express()
56
62
  const PORT = parseInt(process.env.PORT ?? '3141', 10)
@@ -76,10 +82,16 @@ let API_TOKEN_PERSISTED = false
76
82
  if (API_TOKEN_AUTO) {
77
83
  try {
78
84
  const envDir = join(homedir(), '.cos-glasses')
79
- mkdirSync(envDir, { recursive: true })
80
- appendFileSync(join(envDir, '.env'), `\n# auto-generated by the server so the app token survives restarts\nCOS_API_TOKEN=${API_TOKEN}\n`)
85
+ appendPrivateEnvBlock(
86
+ join(envDir, '.env'),
87
+ `# auto-generated by the server so the app token survives restarts\nCOS_API_TOKEN=${API_TOKEN}\n`,
88
+ )
81
89
  API_TOKEN_PERSISTED = true
82
- } catch { /* read-only home — token stays per-session */ }
90
+ } catch (error) {
91
+ // Read-only homes may continue with a per-session token, but an unsafe
92
+ // symlinked credential path must fail closed instead of being ignored.
93
+ if (error instanceof UnsafeUserConfigPathError) throw error
94
+ }
83
95
  }
84
96
 
85
97
  // IP allowlist — only accept connections from localhost, meshnet, and private networks.
@@ -87,15 +99,7 @@ if (API_TOKEN_AUTO) {
87
99
  // local + meshnet (Tailscale/CGNAT) + LAN consumers working.
88
100
  app.use((req, res, next) => {
89
101
  const ip = req.ip || req.socket.remoteAddress || ''
90
- // Normalize IPv6-mapped IPv4 (::ffff:127.0.0.1 → 127.0.0.1)
91
- const cleanIp = ip.replace(/^::ffff:/, '')
92
- const allowed =
93
- cleanIp === '127.0.0.1' || cleanIp === '::1' || // localhost
94
- /^100\./.test(cleanIp) || // meshnet (CGNAT)
95
- /^10\./.test(cleanIp) || // private 10.x
96
- /^172\.(1[6-9]|2\d|3[01])\./.test(cleanIp) || // private 172.16-31.x
97
- /^192\.168\./.test(cleanIp) // private 192.168.x
98
- if (!allowed) {
102
+ if (!isAllowedNetworkIp(ip)) {
99
103
  res.status(403).json({ error: 'forbidden — not on allowed network' })
100
104
  return
101
105
  }
@@ -107,10 +111,7 @@ app.use(cors({
107
111
  origin: (origin, cb) => {
108
112
  // Allow requests with no origin (same-origin, curl, SSE) or "null" origin (file:// WebViews like Even Hub)
109
113
  if (!origin || origin === 'null') return cb(null, true)
110
- // Allow localhost variants
111
- if (/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) return cb(null, true)
112
- // Allow private network IPs (10.x.x.x, 172.16-31.x.x, 192.168.x.x, 100.x.x.x for Meshnet/CGNAT)
113
- if (/^https?:\/\/(10|172\.(1[6-9]|2\d|3[01])|192\.168|100)(\.\d+){2,3}(:\d+)?$/.test(origin)) return cb(null, true)
114
+ if (isAllowedNetworkOrigin(origin)) return cb(null, true)
114
115
  cb(new Error('CORS blocked'))
115
116
  },
116
117
  }))
@@ -267,7 +268,7 @@ listenRequiredServers(listeners).then(() => {
267
268
  for (const [name, infos] of Object.entries(nets)) {
268
269
  for (const info of infos ?? []) {
269
270
  if (info.family !== 'IPv4' || info.internal) continue
270
- const isTailscale = info.address.startsWith('100.') || name.startsWith('utun') || name.startsWith('tailscale')
271
+ const isTailscale = isTailscaleIpv4(info.address) || name.startsWith('tailscale')
271
272
  addrs.push({ ip: info.address, label: isTailscale ? 'Tailscale — works from anywhere' : `${name} — same Wi-Fi only` })
272
273
  }
273
274
  }
@@ -294,7 +295,7 @@ listenRequiredServers(listeners).then(() => {
294
295
  claudeAvailable = true
295
296
  console.log('[COS API] Claude Code CLI detected')
296
297
  } catch {
297
- console.warn('[COS API] Claude Code CLI not found — install from https://claude.ai/download')
298
+ console.warn('[COS API] Claude Code CLI not found — install from https://docs.anthropic.com/en/docs/claude-code/getting-started')
298
299
  console.warn('[COS API] Claude models unavailable; Codex models still work when Codex CLI is installed')
299
300
  }
300
301
 
@@ -3,17 +3,16 @@
3
3
  // Each day's archive contains one or more "chats" (split by context breaks)
4
4
  // Summaries are generated via `claude -p --model sonnet`, budget-capped per day.
5
5
 
6
- import { mkdirSync, readdirSync } from 'node:fs'
6
+ import { chmodSync, mkdirSync, readdirSync } from 'node:fs'
7
7
  import { resolve, dirname } from 'node:path'
8
8
  import { fileURLToPath } from 'node:url'
9
- import { exec } from 'node:child_process'
10
- import { promisify } from 'node:util'
9
+ import { spawn } from 'node:child_process'
11
10
  import { logTokenAudit } from './token-audit.js'
12
11
  import { atomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
13
12
  import { consumeArchiveLLMBudget } from './archive-budget.js'
14
13
  import { mergeMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
14
+ import { secureExistingPrivateFile } from './secure-user-config.js'
15
15
 
16
- const execAsync = promisify(exec)
17
16
  import type { Exchange } from './conversation.js'
18
17
 
19
18
  import { dataPath } from './data-dir.js'
@@ -65,7 +64,8 @@ export interface SessionToArchive {
65
64
  // ── Directory management ────────────────────────────────────
66
65
 
67
66
  function ensureArchiveDir(): string {
68
- mkdirSync(ARCHIVE_DIR, { recursive: true })
67
+ mkdirSync(ARCHIVE_DIR, { recursive: true, mode: 0o700 })
68
+ chmodSync(ARCHIVE_DIR, 0o700)
69
69
  return ARCHIVE_DIR
70
70
  }
71
71
 
@@ -76,7 +76,9 @@ function archivePath(date: string): string {
76
76
  // ── Read/Write ──────────────────────────────────────────────
77
77
 
78
78
  export function loadArchive(date: string): DailyArchive | null {
79
- const result = loadJsonOrQuarantine<DailyArchive>(archivePath(date))
79
+ const path = archivePath(date)
80
+ secureExistingPrivateFile(path)
81
+ const result = loadJsonOrQuarantine<DailyArchive>(path)
80
82
  if (result.status === 'corrupt') {
81
83
  // Loud — a silent return masked archive corruption as "day unavailable"
82
84
  console.error(
@@ -178,6 +180,54 @@ function fallbackDaySummary(chats: ArchivedChat[]): string {
178
180
  return summaries.slice(0, 2).join(', ').slice(0, 60) || 'Day activity'
179
181
  }
180
182
 
183
+ /**
184
+ * Run the archive summarizer without invoking a shell.
185
+ *
186
+ * The archived query text is user-controlled. It must travel over stdin, never
187
+ * through a command string, so shell metacharacters remain inert data.
188
+ */
189
+ function runClaudeArchiveSummary(input: string, instruction: string): Promise<string> {
190
+ return new Promise((resolve, reject) => {
191
+ const proc = spawn('claude', ['-p', '--model', 'sonnet', instruction], {
192
+ stdio: ['pipe', 'pipe', 'pipe'],
193
+ })
194
+
195
+ let stdout = ''
196
+ let stderr = ''
197
+ let settled = false
198
+
199
+ const finish = (error?: Error) => {
200
+ if (settled) return
201
+ settled = true
202
+ clearTimeout(timer)
203
+ if (error) reject(error)
204
+ else resolve(stdout)
205
+ }
206
+
207
+ const timer = setTimeout(() => {
208
+ proc.kill('SIGTERM')
209
+ finish(new Error('Archive summary timed out'))
210
+ }, 15_000)
211
+ timer.unref?.()
212
+
213
+ proc.stdout.on('data', (chunk: Buffer) => {
214
+ if (stdout.length < 16_384) stdout += chunk.toString().slice(0, 16_384 - stdout.length)
215
+ })
216
+ proc.stderr.on('data', (chunk: Buffer) => {
217
+ if (stderr.length < 4_096) stderr += chunk.toString().slice(0, 4_096 - stderr.length)
218
+ })
219
+ proc.on('error', error => finish(error))
220
+ proc.on('close', code => {
221
+ if (code === 0) finish()
222
+ else finish(new Error(`Archive summary exited ${code}: ${stderr.trim()}`))
223
+ })
224
+
225
+ // Ignore EPIPE here; the close/error handlers above own the final outcome.
226
+ proc.stdin.on('error', () => {})
227
+ proc.stdin.end(input)
228
+ })
229
+ }
230
+
181
231
  /** Generate a <60 char summary for a single chat via claude -p.
182
232
  * Budget-capped: if MAX_DAILY_ARCHIVE_LLM_CALLS is exhausted or `skipLLM` is
183
233
  * passed, returns a deterministic string fallback. */
@@ -196,9 +246,9 @@ export async function generateChatSummary(exchanges: Exchange[], skipLLM = false
196
246
 
197
247
  const startMs = Date.now()
198
248
  try {
199
- const { stdout } = await execAsync(
200
- `echo ${JSON.stringify(userQueries)} | claude -p --model sonnet "Summarize these COS Glasses queries into a single title under 60 characters. Just the title, no quotes, no explanation."`,
201
- { timeout: 15_000 }
249
+ const stdout = await runClaudeArchiveSummary(
250
+ userQueries,
251
+ 'Summarize these COS Glasses queries into a single title under 60 characters. Just the title, no quotes, no explanation.',
202
252
  )
203
253
  const result = stdout.trim()
204
254
 
@@ -237,9 +287,9 @@ export async function generateDaySummary(chats: ArchivedChat[], skipLLM = false)
237
287
 
238
288
  const startMs = Date.now()
239
289
  try {
240
- const { stdout } = await execAsync(
241
- `echo ${JSON.stringify(allQueries)} | claude -p --model sonnet "Summarize these COS Glasses queries from one day into a daily title under 60 characters. Just the title, no quotes."`,
242
- { timeout: 15_000 }
290
+ const stdout = await runClaudeArchiveSummary(
291
+ allQueries,
292
+ 'Summarize these COS Glasses queries from one day into a daily title under 60 characters. Just the title, no quotes.',
243
293
  )
244
294
  const result = stdout.trim()
245
295
 
@@ -9,6 +9,7 @@
9
9
 
10
10
  import {
11
11
  closeSync,
12
+ chmodSync,
12
13
  constants,
13
14
  existsSync,
14
15
  fchmodSync,
@@ -23,9 +24,12 @@ import { randomBytes } from 'node:crypto'
23
24
  import { basename, dirname, join } from 'node:path'
24
25
 
25
26
  export function atomicWriteFileSync(path: string, data: string | Buffer, options: { mode?: number } = {}): void {
27
+ const mode = options.mode ?? 0o600
26
28
  const tmp = `${path}.tmp`
27
- writeFileSync(tmp, data, options.mode === undefined ? undefined : { mode: options.mode })
29
+ writeFileSync(tmp, data, { mode })
30
+ chmodSync(tmp, mode)
28
31
  renameSync(tmp, path)
32
+ chmodSync(path, mode)
29
33
  }
30
34
 
31
35
  /**
@@ -48,6 +48,7 @@ import {
48
48
  type MediaAttachmentRef,
49
49
  } from '../../shared/media-attachment.js'
50
50
  import { terminalProviderAuthFailure } from './provider-terminal-error.js'
51
+ import { claudePermissionArgs, getClaudeTrustMode } from './claude-permissions.js'
51
52
 
52
53
  // Inactivity = no stdout data for this long → kill (catches stalls)
53
54
  const INACTIVITY_BY_MODEL: Record<ClaudeModelPreference, number> = {
@@ -214,7 +215,7 @@ export async function preWarmCLI(): Promise<void> {
214
215
  '--effort', getClaudeEffortLevel(),
215
216
  '--output-format', 'stream-json',
216
217
  '--verbose',
217
- '--dangerously-skip-permissions',
218
+ ...claudePermissionArgs(getClaudeTrustMode(), null),
218
219
  '--system-prompt', buildPrewarmSystemPrompt(),
219
220
  ], {
220
221
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -493,20 +494,19 @@ export async function callClaudeStreaming(
493
494
  '--effort', cliEffortFlag,
494
495
  '--output-format', 'stream-json',
495
496
  '--verbose', // Required: stream-json requires --verbose
496
- '--dangerously-skip-permissions', // Required: headless CLI mode with no TTY for user prompts
497
497
  '--system-prompt', systemPrompt,
498
498
  ]
499
499
 
500
500
  // Full COS path gets tools + partial messages; lightweight gets web search only
501
501
  if (options?.lightweight) {
502
502
  if (imagePaths.length > 0) {
503
- args.push('--allowedTools', tools)
503
+ args.push(...claudePermissionArgs(getClaudeTrustMode(), tools))
504
504
  } else {
505
505
  // Lightweight: web search for general questions, no Bash/Read/Write (saves 5-10s)
506
- args.push('--allowedTools', 'WebSearch,WebFetch')
506
+ args.push(...claudePermissionArgs(getClaudeTrustMode(), 'WebSearch,WebFetch'))
507
507
  }
508
508
  } else {
509
- args.push('--allowedTools', tools, '--include-partial-messages')
509
+ args.push(...claudePermissionArgs(getClaudeTrustMode(), tools), '--include-partial-messages')
510
510
  }
511
511
 
512
512
  if (existingCliSession) {
@@ -0,0 +1,40 @@
1
+ export type ClaudeTrustMode = 'trusted' | 'allowlist'
2
+
3
+ /**
4
+ * Claude Code historically ran COS in trusted mode so headless sessions could
5
+ * use the operator's existing tools without stopping for an interactive
6
+ * permission prompt. Keep that behavior for compatibility, while allowing
7
+ * security-conscious installs to opt into a strict, non-interactive allowlist.
8
+ */
9
+ export function getClaudeTrustMode(
10
+ env: NodeJS.ProcessEnv = process.env,
11
+ ): ClaudeTrustMode {
12
+ return env.COS_CLAUDE_TRUST_MODE?.trim().toLowerCase() === 'allowlist'
13
+ ? 'allowlist'
14
+ : 'trusted'
15
+ }
16
+
17
+ /**
18
+ * Build only Claude's permission-related CLI arguments.
19
+ *
20
+ * - trusted: preserves the established COS behavior.
21
+ * - allowlist: denies undeclared tools without prompting and restricts the
22
+ * available built-ins to the explicit per-query list.
23
+ */
24
+ export function claudePermissionArgs(
25
+ mode: ClaudeTrustMode,
26
+ allowedTools: string | null,
27
+ ): string[] {
28
+ if (mode === 'trusted') {
29
+ return allowedTools === null
30
+ ? ['--dangerously-skip-permissions']
31
+ : ['--dangerously-skip-permissions', '--allowedTools', allowedTools]
32
+ }
33
+
34
+ const tools = allowedTools ?? ''
35
+ return [
36
+ '--permission-mode', 'dontAsk',
37
+ '--tools', tools,
38
+ '--allowedTools', tools,
39
+ ]
40
+ }
@@ -14,6 +14,7 @@ import { atomicWriteFileSync, durableAtomicWriteFileSync, loadJsonOrQuarantine }
14
14
  import { localDay } from './local-day.js'
15
15
  import { normalizeModelPreference, type ModelPreference } from '../../shared/model-preference.js'
16
16
  import { parseMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
17
+ import { secureExistingPrivateFile } from './secure-user-config.js'
17
18
 
18
19
  export type { ModelPreference }
19
20
 
@@ -119,6 +120,7 @@ function matchesJobIdentity(exchange: Exchange, identity: ExchangeJobIdentity):
119
120
  }
120
121
 
121
122
  function loadFromDisk(): void {
123
+ secureExistingPrivateFile(SESSION_FILE)
122
124
  const result = loadJsonOrQuarantine<SessionsFile>(SESSION_FILE)
123
125
  if (result.status === 'missing') return // fresh start
124
126
 
@@ -1,6 +1,6 @@
1
1
  import { homedir } from 'node:os'
2
2
  import { join, resolve } from 'node:path'
3
- import { mkdirSync } from 'node:fs'
3
+ import { chmodSync, mkdirSync } from 'node:fs'
4
4
 
5
5
  // Runtime state directory. Defaults to ~/.cos-glasses/data — a writable location
6
6
  // that survives `npx` cache churn and works on global/Docker installs. (Writing
@@ -11,7 +11,8 @@ export const DATA_DIR = process.env.COS_DATA_DIR
11
11
  : join(homedir(), '.cos-glasses', 'data')
12
12
 
13
13
  try {
14
- mkdirSync(DATA_DIR, { recursive: true })
14
+ mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 })
15
+ chmodSync(DATA_DIR, 0o700)
15
16
  } catch { /* best effort — individual writers also tolerate a missing dir */ }
16
17
 
17
18
  /** Build a path under the runtime data directory. */
@@ -0,0 +1,42 @@
1
+ /** Network ranges accepted by the public server before API-token auth runs. */
2
+
3
+ function parseIpv4(value: string): number[] | null {
4
+ const parts = value.split('.')
5
+ if (parts.length !== 4) return null
6
+ const octets = parts.map(part => Number(part))
7
+ if (octets.some((octet, index) => !/^\d{1,3}$/.test(parts[index]) || octet < 0 || octet > 255)) {
8
+ return null
9
+ }
10
+ return octets
11
+ }
12
+
13
+ export function normalizeRemoteIp(value: string): string {
14
+ return value.replace(/^::ffff:/, '')
15
+ }
16
+
17
+ export function isTailscaleIpv4(value: string): boolean {
18
+ const octets = parseIpv4(normalizeRemoteIp(value))
19
+ return !!octets && octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127
20
+ }
21
+
22
+ export function isAllowedNetworkIp(value: string): boolean {
23
+ const clean = normalizeRemoteIp(value)
24
+ if (clean === '127.0.0.1' || clean === '::1') return true
25
+ if (isTailscaleIpv4(clean)) return true
26
+
27
+ const octets = parseIpv4(clean)
28
+ if (!octets) return false
29
+ return octets[0] === 10 ||
30
+ (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) ||
31
+ (octets[0] === 192 && octets[1] === 168)
32
+ }
33
+
34
+ export function isAllowedNetworkOrigin(origin: string): boolean {
35
+ try {
36
+ const url = new URL(origin)
37
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return false
38
+ return url.hostname === 'localhost' || isAllowedNetworkIp(url.hostname)
39
+ } catch {
40
+ return false
41
+ }
42
+ }
@@ -20,6 +20,7 @@ import { existsSync, readFileSync } from 'node:fs'
20
20
  import { dirname, resolve } from 'node:path'
21
21
  import { fileURLToPath } from 'node:url'
22
22
  import { COS_SCRIPTS_DIR } from './python-bridge.js'
23
+ import { secureExistingPrivateFile } from './secure-user-config.js'
23
24
 
24
25
  const __dirname = dirname(fileURLToPath(import.meta.url))
25
26
 
@@ -61,6 +62,7 @@ export function clearCachedKey(): void {
61
62
  function readConfigFile(): KeyConfigFile | null {
62
63
  if (!existsSync(KEY_FILE_PATH)) return null
63
64
  try {
65
+ secureExistingPrivateFile(KEY_FILE_PATH)
64
66
  const raw = readFileSync(KEY_FILE_PATH, 'utf-8')
65
67
  const parsed = JSON.parse(raw) as Partial<KeyConfigFile>
66
68
  if (!parsed || typeof parsed.key !== 'string' || !parsed.key.trim()) return null
@@ -0,0 +1,64 @@
1
+ import {
2
+ chmodSync,
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ renameSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from 'node:fs'
11
+ import { basename, dirname, join } from 'node:path'
12
+
13
+ export class UnsafeUserConfigPathError extends Error {
14
+ constructor(message: string) {
15
+ super(message)
16
+ this.name = 'UnsafeUserConfigPathError'
17
+ }
18
+ }
19
+
20
+ export function securePrivateDirectory(dir: string): void {
21
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
22
+ const stats = lstatSync(dir)
23
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
24
+ throw new UnsafeUserConfigPathError(`${dir} must be a private directory, not a symlink`)
25
+ }
26
+ chmodSync(dir, 0o700)
27
+ }
28
+
29
+ export function secureExistingPrivateFile(file: string): void {
30
+ if (!existsSync(file)) return
31
+ const stats = lstatSync(file)
32
+ if (stats.isSymbolicLink() || !stats.isFile()) {
33
+ throw new UnsafeUserConfigPathError(`${file} must be a regular file, not a symlink`)
34
+ }
35
+ chmodSync(file, 0o600)
36
+ }
37
+
38
+ /**
39
+ * Append an env block without ever following a symlink or exposing a partially
40
+ * written credential file. The replacement is created beside the destination
41
+ * at 0600 and atomically renamed into place.
42
+ */
43
+ export function appendPrivateEnvBlock(file: string, block: string): void {
44
+ const dir = dirname(file)
45
+ securePrivateDirectory(dir)
46
+ secureExistingPrivateFile(file)
47
+
48
+ const current = existsSync(file) ? readFileSync(file, 'utf8') : ''
49
+ const separator = current.length > 0 && !current.endsWith('\n') ? '\n' : ''
50
+ const temp = join(dir, `.${basename(file)}.${process.pid}.${Date.now()}.tmp`)
51
+ try {
52
+ writeFileSync(temp, `${current}${separator}${block}`, {
53
+ encoding: 'utf8',
54
+ flag: 'wx',
55
+ mode: 0o600,
56
+ })
57
+ chmodSync(temp, 0o600)
58
+ renameSync(temp, file)
59
+ chmodSync(file, 0o600)
60
+ } catch (error) {
61
+ try { unlinkSync(temp) } catch { /* best-effort cleanup */ }
62
+ throw error
63
+ }
64
+ }
@@ -2,7 +2,7 @@
2
2
  // so COS can query Glasses sessions by original UUID, date, domain, or content.
3
3
  // Fires on: TTL expiry, explicit /api/sessions/:id/end, server shutdown.
4
4
 
5
- import { appendFileSync, mkdirSync } from 'node:fs'
5
+ import { appendFileSync, chmodSync, mkdirSync } from 'node:fs'
6
6
  import { resolve, dirname } from 'node:path'
7
7
  import type { Exchange } from './conversation.js'
8
8
 
@@ -143,7 +143,8 @@ export function writeSessionLog(entry: SessionLogEntry): boolean {
143
143
 
144
144
  try {
145
145
  mkdirSync(dirname(logPath), { recursive: true })
146
- appendFileSync(logPath, JSON.stringify(entry) + '\n')
146
+ appendFileSync(logPath, JSON.stringify(entry) + '\n', { encoding: 'utf8', mode: 0o600 })
147
+ chmodSync(logPath, 0o600)
147
148
  console.log(`[session-log] Logged session ${entry.session_id} (${entry.end_reason}, ${entry.duration_minutes}m, ${entry.total_message_count} msgs)`)
148
149
  return true
149
150
  } catch (err) {
@@ -13,7 +13,14 @@ interface TelegramConfig {
13
13
 
14
14
  let config: TelegramConfig | null = null
15
15
 
16
+ export function telegramNotificationsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
17
+ return env.COS_TELEGRAM_NOTIFICATIONS === '1'
18
+ }
19
+
16
20
  function loadConfig(): TelegramConfig | null {
21
+ // A credential file is not consent to export conversation activity. Require
22
+ // the same kind of explicit opt-in used by cloud transcription fallback.
23
+ if (!telegramNotificationsEnabled()) return null
17
24
  if (config) return config
18
25
  if (!COS_SCRIPTS_DIR) return null
19
26
 
@@ -15,11 +15,12 @@
15
15
  // of pretending the saved value is in use.
16
16
 
17
17
  import { Router } from 'express'
18
- import { existsSync, mkdirSync, unlinkSync } from 'node:fs'
18
+ import { existsSync, unlinkSync } from 'node:fs'
19
19
  import { dirname } from 'node:path'
20
20
  import { errMsg } from '../lib/utils.js'
21
- import { atomicWriteFileSync } from '../lib/atomic-fs.js'
21
+ import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
22
22
  import { KEY_FILE_PATH, clearCachedKey, getKeyStatus } from '../lib/openai-key.js'
23
+ import { securePrivateDirectory } from '../lib/secure-user-config.js'
23
24
 
24
25
  export const openaiKeyRouter = Router()
25
26
 
@@ -79,9 +80,9 @@ openaiKeyRouter.post('/openai-key/set', async (req, res) => {
79
80
  // Ensure parent dir exists (server/data/ is gitignored but may not exist
80
81
  // on a fresh checkout that's never run a budget write).
81
82
  const parent = dirname(KEY_FILE_PATH)
82
- if (!existsSync(parent)) mkdirSync(parent, { recursive: true })
83
+ securePrivateDirectory(parent)
83
84
 
84
- atomicWriteFileSync(KEY_FILE_PATH, payload)
85
+ durableAtomicWriteFileSync(KEY_FILE_PATH, payload, { mode: 0o600 })
85
86
  clearCachedKey()
86
87
 
87
88
  const status = getKeyStatus()