@gotcos/glasses-server 6.12.0 → 6.12.2

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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.12.2
4
+
5
+ First-install hardening for public `npx` users.
6
+
7
+ - **No nested install inside npm's cache.** The launcher resolves the declared
8
+ `tsx` dependency from npm's existing `npx` dependency tree and never runs a
9
+ second `npm install` from the ephemeral package directory.
10
+ - **Permission failures do not spread.** A broken or incomplete package fails
11
+ closed with a user-owned isolated-cache recovery command. COS never suggests
12
+ `sudo npm`, broad ownership changes, or writing through a root-owned cache.
13
+ - **Claude Desktop is no longer mistaken for Claude Code.** First-run guidance
14
+ explicitly requires the terminal CLI, keeps the scoped npm command on one
15
+ copyable line, forbids `sudo`, and explains the interactive sign-in step.
16
+ - **Known signed-out agents fail before startup.** Installed Claude/Codex
17
+ binaries are checked before first-query readiness. Older CLI versions whose
18
+ authentication state cannot be proven show a warning instead of a false
19
+ signed-in claim.
20
+ - **Local credentials are private.** `~/.cos-glasses` is repaired to `0700`;
21
+ the token and profile files are repaired to `0600`; symlinked credential
22
+ paths fail closed; auto-generated tokens are persisted with an atomic write.
23
+ - **Runtime behavior is unchanged.** Query, prompt recovery, meetings, media,
24
+ diagnostics, transcription, display, and server data contracts are untouched.
25
+
26
+ ## 6.12.1
27
+
28
+ Public-safe CLI diagnostics for the COS Glasses Recovery Center.
29
+
30
+ - **Both local agents are visible.** Authenticated clients can inspect a
31
+ versioned Claude Code and Codex status summary at `/api/cli/debug`, including
32
+ provider support, persistence readiness, workspace configuration, and the
33
+ latest run's safe status metadata.
34
+ - **False success is fenced.** Claude Code or Codex output that reports a
35
+ machine-shaped authentication failure while the CLI exits `0` is finalized
36
+ as a typed `auth_error`, never projected as a completed assistant reply.
37
+ - **Build 210 stays compatible.** Sanitized `/api/cli/runs` and
38
+ `/api/codex/runs` projections preserve the fields older Recovery Centers can
39
+ render while newer clients adopt the combined contract.
40
+ - **Diagnostics do not become an exfiltration path.** Responses use explicit
41
+ allowlists and omit commands, filesystem paths, trust modes, prompts,
42
+ answers, tool payloads, content previews, raw run/session/thread IDs,
43
+ resumable handles, environment values, and tokens. Legacy display IDs are
44
+ omitted and workspace state is a fixed label only.
45
+ - **Authentication is mandatory.** All three diagnostic routes remain behind
46
+ the existing `/api` token boundary. Preview-enabled ledger fixtures are
47
+ covered by recursive forbidden-field and private-value tests.
48
+ - **Unauthenticated health is capability-only.** `/api/health` and
49
+ `/api/models` advertise `capabilities.cliDebug`; health exposes only a CLI
50
+ session-availability boolean rather than the resumable session ID.
51
+ - **Backward compatible.** Query, prompt, meeting, media, display, model, and
52
+ recovery behavior is unchanged. Older clients and servers continue to use
53
+ their existing paths; a missing CLI Debug capability remains an unsupported
54
+ feature rather than a connection failure.
55
+
3
56
  ## 6.12.0
4
57
 
5
58
  Local-first transcription policy and capability-safe recovery diagnostics for
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)
@@ -106,9 +109,20 @@ BIND_HOST=0.0.0.0 npm run start:server
106
109
 
107
110
  ## Troubleshooting
108
111
 
112
+ - *Claude Desktop is installed but COS says Claude Code is missing* — Desktop
113
+ and the terminal CLI are separate. Run
114
+ `npm install -g @anthropic-ai/claude-code` on one line without `sudo`, then
115
+ run `claude` and complete sign-in. Verify with `claude --version` before
116
+ starting COS again.
117
+ - *npm reports EACCES or a root-owned cache* — never run COS or npm with
118
+ `sudo`, and do not recursively change system ownership. Use a private COS
119
+ cache instead:
120
+ `npm_config_cache="$HOME/.cos-glasses/npm-cache" npx --yes @gotcos/glasses-server@latest`.
121
+ Version 6.12.2+ never runs a second install from inside npm's temporary cache.
109
122
  - *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`.
123
+ - *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.
124
+ - *AI queries fail* — run `claude auth status` / `codex login status`, then
125
+ `claude auth login` / `codex login` when the provider reports signed out.
112
126
  - *Voice getting billed?* — voice is local-only by default in 6.12.0+. Confirm
113
127
  `/api/health` reports `capabilities.transcription.mode: "local-only"`. Remove
114
128
  `COS_OPENAI_WHISPER_FALLBACK` (or set it to `0`) to disable an earlier opt-in.
@@ -117,7 +131,7 @@ BIND_HOST=0.0.0.0 npm run start:server
117
131
  keeps compatible prompt/meeting audio available for retry instead of silently
118
132
  sending it to OpenAI.
119
133
  - *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`.
134
+ - *Prompt recovery unavailable?* — update with `npx --yes @gotcos/glasses-server@latest`, then confirm `/api/health` reports `features.promptRecovery: true`.
121
135
  - *Durable query recovery unavailable?* — build 204+ requires server 6.10.0+ and
122
136
  `COS_DURABLE_QUERY_JOBS=1`. Restart once, then confirm `/api/health` reports
123
137
  `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.0",
3
+ "version": "6.12.2",
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'
@@ -27,6 +27,7 @@ import { archiveRouter } from './routes/archive.js'
27
27
  import { sessionsRouter } from './routes/sessions.js'
28
28
  import { mediaRouter, mediaBodyParser } from './routes/media.js'
29
29
  import { promptDraftsRouter } from './routes/prompt-drafts.js'
30
+ import { cliDebugRouter } from './routes/cli-debug.js'
30
31
  import { prewarmContext } from './lib/context-builder.js'
31
32
  import { preWarmCLI } from './lib/claude-bridge.js'
32
33
  import { getCodexRunConfig } from './lib/codex-run-ledger.js'
@@ -43,6 +44,7 @@ import { getMediaStore } from './lib/media-store.js'
43
44
  import { listenRequiredServers, type RequiredListener } from './lib/listener-startup.js'
44
45
  import { serverMetrics } from './lib/server-metrics.js'
45
46
  import { initializeServerInstanceId } from './lib/server-instance-id.js'
47
+ import { appendPrivateEnvBlock, UnsafeUserConfigPathError } from './lib/secure-user-config.js'
46
48
  import { createQueryJobsRouter } from './routes/query-jobs.js'
47
49
  import {
48
50
  initQueryJobRuntime,
@@ -75,10 +77,16 @@ let API_TOKEN_PERSISTED = false
75
77
  if (API_TOKEN_AUTO) {
76
78
  try {
77
79
  const envDir = join(homedir(), '.cos-glasses')
78
- mkdirSync(envDir, { recursive: true })
79
- appendFileSync(join(envDir, '.env'), `\n# auto-generated by the server so the app token survives restarts\nCOS_API_TOKEN=${API_TOKEN}\n`)
80
+ appendPrivateEnvBlock(
81
+ join(envDir, '.env'),
82
+ `# auto-generated by the server so the app token survives restarts\nCOS_API_TOKEN=${API_TOKEN}\n`,
83
+ )
80
84
  API_TOKEN_PERSISTED = true
81
- } catch { /* read-only home — token stays per-session */ }
85
+ } catch (error) {
86
+ // Read-only homes may continue with a per-session token, but an unsafe
87
+ // symlinked credential path must fail closed instead of being ignored.
88
+ if (error instanceof UnsafeUserConfigPathError) throw error
89
+ }
82
90
  }
83
91
 
84
92
  // IP allowlist — only accept connections from localhost, meshnet, and private networks.
@@ -163,6 +171,7 @@ app.use('/api', archiveRouter)
163
171
  app.use('/api', sessionsRouter)
164
172
  app.use('/api', mediaRouter)
165
173
  app.use('/api', promptDraftsRouter)
174
+ app.use('/api', cliDebugRouter)
166
175
 
167
176
  // OpenAI-compatible endpoint for the G2 Agent (ER "Add Agent")
168
177
  // Mounted at root — routes are /v1/chat/completions and /v1/models
@@ -292,7 +301,7 @@ listenRequiredServers(listeners).then(() => {
292
301
  claudeAvailable = true
293
302
  console.log('[COS API] Claude Code CLI detected')
294
303
  } catch {
295
- console.warn('[COS API] Claude Code CLI not found — install from https://claude.ai/download')
304
+ console.warn('[COS API] Claude Code CLI not found — install from https://docs.anthropic.com/en/docs/claude-code/getting-started')
296
305
  console.warn('[COS API] Claude models unavailable; Codex models still work when Codex CLI is installed')
297
306
  }
298
307
 
@@ -47,6 +47,7 @@ import {
47
47
  MAX_ATTACHMENTS_PER_PROMPT,
48
48
  type MediaAttachmentRef,
49
49
  } from '../../shared/media-attachment.js'
50
+ import { terminalProviderAuthFailure } from './provider-terminal-error.js'
50
51
 
51
52
  // Inactivity = no stdout data for this long → kill (catches stalls)
52
53
  const INACTIVITY_BY_MODEL: Record<ClaudeModelPreference, number> = {
@@ -325,12 +326,15 @@ export interface StreamCallbacks {
325
326
  /** Claude CLI can emit `subtype: success` with `is_error: true`; the boolean
326
327
  * is authoritative and must win before session ids or result text are saved. */
327
328
  export function claudeResultErrorMessage(event: any): string | null {
328
- if (event?.type !== 'result' || (event?.is_error !== true && event?.subtype !== 'error')) return null
329
+ if (event?.type !== 'result') return null
329
330
  const raw = typeof event?.result === 'string' ? event.result
330
331
  : typeof event?.error === 'string' ? event.error
331
332
  : typeof event?.error?.message === 'string' ? event.error.message
332
333
  : typeof event?.message === 'string' ? event.message
333
334
  : ''
335
+ const authFailure = terminalProviderAuthFailure('claude', raw, event?.error)
336
+ if (authFailure) return authFailure
337
+ if (event?.is_error !== true && event?.subtype !== 'error') return null
334
338
  const detail = raw.replace(/\s+/g, ' ').trim().slice(0, 240)
335
339
  return detail ? `claude-bridge: ${detail}` : 'claude-bridge: Claude CLI returned an error result.'
336
340
  }
@@ -545,6 +549,7 @@ export async function callClaudeStreaming(
545
549
  let stderr = ''
546
550
  let buffer = ''
547
551
  let finalized = false // Guard against double onDone/onError
552
+ let terminalTextError: string | null = null
548
553
  let lastActivity = Date.now() // Tracks last stdout data for inactivity timeout
549
554
  let receivedStreamEvents = false // Track if CLI emits stream_event (vs older assistant-only format)
550
555
  const toolInputs = new Map<number, { name: string; json: string }>()
@@ -570,6 +575,13 @@ export async function callClaudeStreaming(
570
575
 
571
576
  async function finalize(text: string) {
572
577
  if (finalized) return
578
+ const responseAuthFailure = terminalProviderAuthFailure('claude', text)
579
+ const authFailure = responseAuthFailure
580
+ ?? (!text.trim() ? terminalTextError ?? terminalProviderAuthFailure('claude', stderr) : null)
581
+ if (authFailure) {
582
+ await finalizeError(authFailure, 0)
583
+ return
584
+ }
573
585
  finalized = true
574
586
  cleanup()
575
587
  cleanupImages()
@@ -831,9 +843,16 @@ export async function callClaudeStreaming(
831
843
  text = event.content
832
844
  }
833
845
  if (text) {
834
- phase = 'generating'
835
- fullText += text
836
- callbacks.onChunk(text)
846
+ // Withhold only strongly machine-shaped provider auth output so
847
+ // credentials cannot flash through onChunk. Human sign-in
848
+ // instructions do not match the terminal classifier.
849
+ const authFailure = terminalProviderAuthFailure('claude', text)
850
+ if (authFailure) terminalTextError = authFailure
851
+ else {
852
+ phase = 'generating'
853
+ fullText += text
854
+ callbacks.onChunk(text)
855
+ }
837
856
  }
838
857
  }
839
858
  } else if (event.type === 'user') {
@@ -855,7 +874,9 @@ export async function callClaudeStreaming(
855
874
  }
856
875
  // tool_use/tool_result/other events still reset inactivity (we got stdout data)
857
876
  } catch {
858
- // Not valid JSON ignore partial lines
877
+ // Older CLI builds can emit a terminal auth error as plain text while
878
+ // still exiting 0. Remember only the canonical classification.
879
+ terminalTextError ??= terminalProviderAuthFailure('claude', trimmed)
859
880
  }
860
881
  }
861
882
  })
@@ -883,13 +904,19 @@ export async function callClaudeStreaming(
883
904
  } catch { /* ignore */ }
884
905
  }
885
906
 
886
- if (code !== 0 && !fullText) {
887
- finalizeError(`claude-bridge: exit ${code} ${stderr.trim().slice(0, 200)}`, code)
888
- } else if (fullText) {
889
- // If we got text but no explicit result event, still finalize
907
+ if (fullText) {
908
+ // If we got text but no explicit result event, still finalize. A sole,
909
+ // machine-shaped auth response is classified inside finalize.
890
910
  void finalize(fullText)
891
911
  } else {
892
- finalizeError('claude-bridge: Claude completed without a response.', code)
912
+ const authFailure = terminalTextError ?? terminalProviderAuthFailure('claude', stderr, buffer)
913
+ if (authFailure) {
914
+ finalizeError(authFailure, code)
915
+ } else if (code !== 0) {
916
+ finalizeError(`claude-bridge: exit ${code} — ${stderr.trim().slice(0, 200)}`, code)
917
+ } else {
918
+ finalizeError('claude-bridge: Claude completed without a response.', code)
919
+ }
893
920
  }
894
921
  })
895
922
 
@@ -0,0 +1,213 @@
1
+ import type { ClaudeRunConfig, ClaudeRunRecord } from './claude-run-ledger.js'
2
+ import type { CodexRunConfig, CodexRunRecord } from './codex-run-ledger.js'
3
+
4
+ export const CLI_DEBUG_CAPABILITY = Object.freeze({
5
+ schemaVersion: 1,
6
+ providers: Object.freeze({ claude: true, codex: true }),
7
+ metadataOnly: true,
8
+ })
9
+
10
+ export type SafeCliRunStatus =
11
+ | 'running'
12
+ | 'completed'
13
+ | 'failed'
14
+ | 'cancelled'
15
+ | 'client_disconnected'
16
+
17
+ export interface SafeCliDebugLatestRun {
18
+ status: SafeCliRunStatus
19
+ model: string
20
+ concreteModel?: string
21
+ effort?: string
22
+ resumed: boolean
23
+ durationMs?: number
24
+ updatedAt: string
25
+ errorCode?: string
26
+ }
27
+
28
+ export interface SafeCliDebugProvider {
29
+ supported: true
30
+ persistenceEnabled: boolean
31
+ workspaceConfigured: boolean
32
+ latestRun: SafeCliDebugLatestRun | null
33
+ }
34
+
35
+ export interface SafeCliDebugResponse {
36
+ schemaVersion: 1
37
+ providers: {
38
+ claude: SafeCliDebugProvider
39
+ codex: SafeCliDebugProvider
40
+ }
41
+ }
42
+
43
+ function optionalString(value: unknown): string | undefined {
44
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
45
+ }
46
+
47
+ function optionalFiniteNumber(value: unknown): number | undefined {
48
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
49
+ }
50
+
51
+ function safeStatus(value: unknown): SafeCliRunStatus {
52
+ return value === 'running' || value === 'completed' || value === 'failed'
53
+ || value === 'cancelled' || value === 'client_disconnected'
54
+ ? value
55
+ : 'failed'
56
+ }
57
+
58
+ function safeModelLabel(value: unknown, provider: 'claude' | 'codex'): string | undefined {
59
+ const model = optionalString(value)
60
+ if (!model || model.length > 96) return undefined
61
+ const allowed = provider === 'claude'
62
+ ? /^(?:claude-|opus(?:\[1m\])?$|sonnet(?:\[1m\])?$|fable(?:\[1m\])?$|haiku(?:\[1m\])?$)[a-z0-9._\[\]-]*$/i
63
+ : /^(?:gpt-|codex-)[a-z0-9._\[\]-]*$/i
64
+ return allowed.test(model) ? model : undefined
65
+ }
66
+
67
+ function safeEffort(value: unknown): string | undefined {
68
+ const effort = optionalString(value)?.toLowerCase()
69
+ return effort && ['low', 'medium', 'high', 'xhigh', 'max', 'ultra', 'ultracode'].includes(effort)
70
+ ? effort
71
+ : undefined
72
+ }
73
+
74
+ function safeTimestamp(value: unknown): string {
75
+ const timestamp = optionalString(value)
76
+ const parsed = timestamp ? Date.parse(timestamp) : Number.NaN
77
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : new Date(0).toISOString()
78
+ }
79
+
80
+ function safeErrorCode(provider: 'claude' | 'codex', value: unknown): string | undefined {
81
+ const candidate = optionalString(value)
82
+ if (!candidate) return undefined
83
+ const allowed = new Set([
84
+ `${provider}.cli_unavailable`,
85
+ `${provider}.permission_denied`,
86
+ `${provider}.auth_error`,
87
+ `${provider}.timeout`,
88
+ `${provider}.nonzero_exit`,
89
+ `${provider}.error`,
90
+ `${provider}.interrupted`,
91
+ ])
92
+ return allowed.has(candidate) ? candidate : `${provider}.error`
93
+ }
94
+
95
+ export function safeClaudeLatestRun(run?: ClaudeRunRecord): SafeCliDebugLatestRun | null {
96
+ if (!run) return null
97
+ const concreteModel = safeModelLabel(run.resolvedModelId ?? run.cliModelId, 'claude')
98
+ const effort = safeEffort(run.effortLevel)
99
+ const errorCode = safeErrorCode('claude', run.errorCode)
100
+ return {
101
+ status: safeStatus(run.status),
102
+ model: safeModelLabel(run.model, 'claude') ?? 'unknown',
103
+ ...(concreteModel ? { concreteModel } : {}),
104
+ ...(effort ? { effort } : {}),
105
+ resumed: run.resumed === true,
106
+ ...(optionalFiniteNumber(run.durationMs) !== undefined ? { durationMs: run.durationMs } : {}),
107
+ updatedAt: safeTimestamp(run.updatedAt),
108
+ ...(errorCode ? { errorCode } : {}),
109
+ }
110
+ }
111
+
112
+ export function safeCodexLatestRun(run?: CodexRunRecord): SafeCliDebugLatestRun | null {
113
+ if (!run) return null
114
+ const concreteModel = safeModelLabel(run.cliModel, 'codex')
115
+ const effort = safeEffort(run.reasoningEffort)
116
+ const errorCode = safeErrorCode('codex', run.errorCode)
117
+ return {
118
+ status: safeStatus(run.status),
119
+ model: safeModelLabel(run.model, 'codex') ?? 'unknown',
120
+ ...(concreteModel ? { concreteModel } : {}),
121
+ ...(effort ? { effort } : {}),
122
+ resumed: run.resumed === true,
123
+ ...(optionalFiniteNumber(run.durationMs) !== undefined ? { durationMs: run.durationMs } : {}),
124
+ updatedAt: safeTimestamp(run.updatedAt),
125
+ ...(errorCode ? { errorCode } : {}),
126
+ }
127
+ }
128
+
129
+ export function safeCliDebugResponse(
130
+ claudeConfig: ClaudeRunConfig,
131
+ claudeRun: ClaudeRunRecord | undefined,
132
+ codexConfig: CodexRunConfig,
133
+ codexRun: CodexRunRecord | undefined,
134
+ ): SafeCliDebugResponse {
135
+ return {
136
+ schemaVersion: 1,
137
+ providers: {
138
+ claude: {
139
+ supported: true,
140
+ persistenceEnabled: claudeConfig.persistenceEnabled === true,
141
+ workspaceConfigured: Boolean(claudeConfig.cwd),
142
+ latestRun: safeClaudeLatestRun(claudeRun),
143
+ },
144
+ codex: {
145
+ supported: true,
146
+ persistenceEnabled: codexConfig.persistenceEnabled === true,
147
+ workspaceConfigured: Boolean(codexConfig.cwd),
148
+ latestRun: safeCodexLatestRun(codexRun),
149
+ },
150
+ },
151
+ }
152
+ }
153
+
154
+ /** Compatibility projection for build 210 and earlier Settings panels.
155
+ * Every returned key is an explicit public-safe allowlist entry. */
156
+ export function safeLegacyClaudeResponse(config: ClaudeRunConfig, runs: ClaudeRunRecord[]) {
157
+ return {
158
+ schemaVersion: 1,
159
+ config: {
160
+ persistenceEnabled: config.persistenceEnabled === true,
161
+ workspaceConfigured: Boolean(config.cwd),
162
+ cwd: config.cwd ? 'Configured workspace' : 'Not configured',
163
+ defaultEffortLevel: safeEffort(config.defaultEffortLevel) ?? 'high',
164
+ contentPreviewsEnabled: false,
165
+ },
166
+ runs: runs.map(run => {
167
+ const latest = safeClaudeLatestRun(run)!
168
+ return {
169
+ status: latest.status,
170
+ model: latest.model,
171
+ ...(latest.concreteModel ? {
172
+ cliModelId: latest.concreteModel,
173
+ resolvedModelId: latest.concreteModel,
174
+ } : {}),
175
+ ...(latest.effort ? { effortLevel: latest.effort } : {}),
176
+ ...(typeof latest.resumed === 'boolean' ? { resumed: latest.resumed } : {}),
177
+ ...(latest.durationMs !== undefined ? { durationMs: latest.durationMs } : {}),
178
+ ...(latest.updatedAt ? { updatedAt: latest.updatedAt } : {}),
179
+ ...(latest.errorCode ? { errorCode: latest.errorCode } : {}),
180
+ }
181
+ }),
182
+ }
183
+ }
184
+
185
+ /** Compatibility projection for build 210 and earlier Settings panels.
186
+ * Engine sessions are deliberately absent: their thread ids are resumable
187
+ * runtime handles, not display metadata. */
188
+ export function safeLegacyCodexResponse(config: CodexRunConfig, runs: CodexRunRecord[]) {
189
+ return {
190
+ schemaVersion: 1,
191
+ config: {
192
+ persistenceEnabled: config.persistenceEnabled === true,
193
+ workspaceConfigured: Boolean(config.cwd),
194
+ cwd: config.cwd ? 'Configured workspace' : 'Not configured',
195
+ cliModel: safeModelLabel(config.cliModel, 'codex') ?? 'unknown',
196
+ reasoningEffort: safeEffort(config.reasoningEffort) ?? 'high',
197
+ contentPreviewsEnabled: false,
198
+ },
199
+ runs: runs.map(run => {
200
+ const latest = safeCodexLatestRun(run)!
201
+ return {
202
+ status: latest.status,
203
+ model: latest.model,
204
+ ...(latest.concreteModel ? { cliModel: latest.concreteModel } : {}),
205
+ ...(latest.effort ? { reasoningEffort: latest.effort } : {}),
206
+ ...(typeof latest.resumed === 'boolean' ? { resumed: latest.resumed } : {}),
207
+ ...(latest.durationMs !== undefined ? { durationMs: latest.durationMs } : {}),
208
+ ...(latest.updatedAt ? { updatedAt: latest.updatedAt } : {}),
209
+ ...(latest.errorCode ? { errorCode: latest.errorCode } : {}),
210
+ }
211
+ }),
212
+ }
213
+ }
@@ -60,6 +60,7 @@ import {
60
60
  MAX_ATTACHMENTS_PER_PROMPT,
61
61
  type MediaAttachmentRef,
62
62
  } from '../../shared/media-attachment.js'
63
+ import { terminalProviderAuthFailure } from './provider-terminal-error.js'
63
64
 
64
65
  const INACTIVITY_MS = 180_000
65
66
  const WALL_MAX_MS = 900_000
@@ -366,6 +367,7 @@ export async function callCodexStreaming(
366
367
  let stderr = ''
367
368
  let buffer = ''
368
369
  let finalized = false
370
+ let terminalTextError: string | null = null
369
371
  let lastActivity = Date.now()
370
372
  const emittedBlocks = new Set<string>()
371
373
 
@@ -425,6 +427,13 @@ export async function callCodexStreaming(
425
427
 
426
428
  async function finalize(text: string) {
427
429
  if (finalized) return
430
+ const responseAuthFailure = terminalProviderAuthFailure('codex', text)
431
+ const authFailure = responseAuthFailure
432
+ ?? (!text.trim() ? terminalTextError ?? terminalProviderAuthFailure('codex', stderr) : null)
433
+ if (authFailure) {
434
+ await finalizeError(authFailure, 0)
435
+ return
436
+ }
428
437
  finalized = true
429
438
  cleanup()
430
439
  cleanupImages()
@@ -614,13 +623,32 @@ export async function callCodexStreaming(
614
623
  }
615
624
 
616
625
  const text = extractCodexResponseText(event)
617
- if (text) emitText(text)
626
+ if (text) {
627
+ // Withhold only strongly machine-shaped provider auth output so
628
+ // credentials cannot flash through onChunk. Human sign-in instructions
629
+ // do not match the terminal classifier.
630
+ const authFailure = terminalProviderAuthFailure('codex', text)
631
+ if (authFailure) terminalTextError = authFailure
632
+ else emitText(text)
633
+ }
618
634
 
619
635
  const type = String(event?.type ?? '')
620
636
  if (type === 'turn.completed') {
621
637
  void finalize(fullText)
622
638
  } else if (type === 'turn.failed' || type === 'error') {
623
- finalizeError(`codex-bridge: ${event?.error ?? event?.message ?? 'unknown error'}`)
639
+ const rawProviderError = typeof event?.error === 'string' ? event.error
640
+ : typeof event?.error?.message === 'string' ? event.error.message
641
+ : typeof event?.message === 'string' ? event.message
642
+ : 'unknown error'
643
+ const structuredProviderError = event?.error && typeof event.error === 'object'
644
+ ? JSON.stringify(event.error)
645
+ : ''
646
+ const authenticationError = terminalProviderAuthFailure(
647
+ 'codex',
648
+ rawProviderError,
649
+ structuredProviderError,
650
+ )
651
+ finalizeError(authenticationError ?? `codex-bridge: ${rawProviderError}`)
624
652
  }
625
653
  }
626
654
 
@@ -636,7 +664,9 @@ export async function callCodexStreaming(
636
664
  try {
637
665
  handleEvent(JSON.parse(trimmed))
638
666
  } catch {
639
- // Ignore non-JSON status lines from older CLI builds.
667
+ // Older CLI builds can emit a terminal auth error as plain text while
668
+ // still exiting 0. Remember only the canonical classification.
669
+ terminalTextError ??= terminalProviderAuthFailure('codex', trimmed)
640
670
  }
641
671
  }
642
672
  })
@@ -651,10 +681,15 @@ export async function callCodexStreaming(
651
681
  try { handleEvent(JSON.parse(buffer.trim())) } catch { /* ignore */ }
652
682
  }
653
683
  if (finalized) return
654
- if (code !== 0) {
655
- finalizeError(`codex-bridge: exit ${code} — ${stderr.trim().slice(0, 240)}`, code)
656
- } else if (fullText) {
684
+ if (fullText) {
657
685
  void finalize(fullText)
686
+ return
687
+ }
688
+ const authFailure = terminalTextError ?? terminalProviderAuthFailure('codex', stderr, buffer)
689
+ if (authFailure) {
690
+ finalizeError(authFailure, code)
691
+ } else if (code !== 0) {
692
+ finalizeError(`codex-bridge: exit ${code} — ${stderr.trim().slice(0, 240)}`, code)
658
693
  } else {
659
694
  finalizeError('codex-bridge: Codex completed without a response.')
660
695
  }
@@ -0,0 +1,55 @@
1
+ export type CliProvider = 'claude' | 'codex'
2
+
3
+ const AUTH_CODE = /^(?:401|403|unauthori[sz]ed|forbidden|authentication_error|authorization_error|invalid_api_key|not_authenticated)$/i
4
+ // Match whole, terminal-looking provider failures only. Natural assistant
5
+ // answers such as "Please sign in to the customer portal, then..." must not
6
+ // be reinterpreted as failures merely because their first words mention auth.
7
+ const AUTH_FAILURE_PREFIX = /^(?:\s*(?:api\s+|http\s+|request\s+)?error(?:\[[^\]]+\])?\s*:\s*.{0,180}\b(?:401|403|unauthori[sz]ed|forbidden|authentication\s+(?:failed|required)|login\s+required|not\s+(?:logged|signed)\s+in)\b.*|\s*(?:http\s+)?(?:401|403)(?:\s+(?:unauthori[sz]ed|forbidden))?(?:\s*[:.\-]\s*.*)?|\s*(?:unauthori[sz]ed|forbidden)(?:\s*[:.\-]\s*.*)?|\s*(?:authentication|authorization)\s+(?:failed|required|error|missing|denied)(?:\s*[:.\-]\s*.*)?|\s*(?:login|sign[ -]?in)\s+(?:required|failed)(?:\s*[:.\-]\s*.*)?|\s*(?:you(?:'re| are)\s+)?not\s+(?:logged|signed)\s+in(?:\s*[:.\-]\s*.*)?|\s*please\s+run\s+(?:[`'"]?(?:claude|codex)[`'"]?\s+)?(?:[`'"]?\/?login[`'"]?|[`'"]?auth(?:enticate)?[`'"]?)\b.*)\s*$/i
8
+
9
+ function stripAnsi(value: string): string {
10
+ // eslint-disable-next-line no-control-regex
11
+ return value.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
12
+ }
13
+
14
+ function structuredAuthFailure(value: unknown): boolean {
15
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
16
+ const record = value as Record<string, unknown>
17
+ if (record.status === 401 || record.status === 403 || record.statusCode === 401 || record.statusCode === 403) return true
18
+ for (const key of ['code', 'type']) {
19
+ if (typeof record[key] === 'string' && AUTH_CODE.test(record[key])) return true
20
+ }
21
+ if (typeof record.error === 'string') {
22
+ return AUTH_CODE.test(record.error.trim()) || AUTH_FAILURE_PREFIX.test(record.error)
23
+ }
24
+ return structuredAuthFailure(record.error)
25
+ }
26
+
27
+ function looksLikeTerminalAuthFailure(value: unknown): boolean {
28
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
29
+ return structuredAuthFailure(value)
30
+ }
31
+ if (typeof value !== 'string') return false
32
+ const text = stripAnsi(value).trim()
33
+ if (!text || text.length > 2_000) return false
34
+ if (AUTH_FAILURE_PREFIX.test(text.replace(/\s+/g, ' '))) return true
35
+ if (!(text.startsWith('{') && text.endsWith('}'))) return false
36
+ try {
37
+ return structuredAuthFailure(JSON.parse(text))
38
+ } catch {
39
+ return false
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Some CLI versions report authentication failures as successful process
45
+ * output and exit 0. Detect only terminal, machine-shaped auth messages and
46
+ * return a canonical error that cannot echo credentials or provider output.
47
+ */
48
+ export function terminalProviderAuthFailure(
49
+ provider: CliProvider,
50
+ ...terminalValues: unknown[]
51
+ ): string | null {
52
+ return terminalValues.some(looksLikeTerminalAuthFailure)
53
+ ? `${provider}-bridge: authentication required.`
54
+ : null
55
+ }
@@ -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
+ }
@@ -0,0 +1,66 @@
1
+ import { Router } from 'express'
2
+ import { normalizeModelPreference, isClaudeModel } from '../../shared/model-preference.js'
3
+ import {
4
+ getClaudeRunConfig,
5
+ listClaudeRuns,
6
+ } from '../lib/claude-run-ledger.js'
7
+ import {
8
+ getCodexRunConfig,
9
+ listCodexRuns,
10
+ } from '../lib/codex-run-ledger.js'
11
+ import {
12
+ safeCliDebugResponse,
13
+ safeLegacyClaudeResponse,
14
+ safeLegacyCodexResponse,
15
+ } from '../lib/cli-debug-view.js'
16
+
17
+ export const cliDebugRouter = Router()
18
+
19
+ function boundedLimit(value: unknown): number {
20
+ const raw = Number(value ?? 20)
21
+ return Number.isFinite(raw) && raw > 0 ? Math.min(Math.floor(raw), 50) : 20
22
+ }
23
+
24
+ function optionalSessionId(value: unknown): string | undefined {
25
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
26
+ }
27
+
28
+ function optionalClaudeModel(value: unknown) {
29
+ const raw = typeof value === 'string' ? normalizeModelPreference(value) : undefined
30
+ return raw && isClaudeModel(raw) ? raw : undefined
31
+ }
32
+
33
+ // Versioned public-safe view consumed by Recovery Center. Global /api auth
34
+ // protects this route; it must never be added to the unauthenticated allowlist.
35
+ cliDebugRouter.get('/cli/debug', (req, res) => {
36
+ const limit = boundedLimit(req.query.limit)
37
+ const sessionId = optionalSessionId(req.query.sessionId)
38
+ const model = optionalClaudeModel(req.query.model)
39
+ const claudeConfig = getClaudeRunConfig()
40
+ const codexConfig = getCodexRunConfig()
41
+ const claudeRuns = listClaudeRuns(limit, sessionId, model)
42
+ const codexRuns = listCodexRuns(limit, sessionId)
43
+ res.json(safeCliDebugResponse(
44
+ claudeConfig,
45
+ claudeRuns[0],
46
+ codexConfig,
47
+ codexRuns[0],
48
+ ))
49
+ })
50
+
51
+ // Build-210 compatibility. These legacy shapes retain only the fields the old
52
+ // panel can render safely; they do not expose raw ledger records.
53
+ cliDebugRouter.get('/cli/runs', (req, res) => {
54
+ const limit = boundedLimit(req.query.limit)
55
+ const sessionId = optionalSessionId(req.query.sessionId)
56
+ const model = optionalClaudeModel(req.query.model)
57
+ const config = getClaudeRunConfig()
58
+ res.json(safeLegacyClaudeResponse(config, listClaudeRuns(limit, sessionId, model)))
59
+ })
60
+
61
+ cliDebugRouter.get('/codex/runs', (req, res) => {
62
+ const limit = boundedLimit(req.query.limit)
63
+ const sessionId = optionalSessionId(req.query.sessionId)
64
+ const config = getCodexRunConfig()
65
+ res.json(safeLegacyCodexResponse(config, listCodexRuns(limit, sessionId)))
66
+ })
@@ -20,6 +20,7 @@ import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
20
20
  import { durableQueryJobsCapability } from '../lib/query-job-feature.js'
21
21
  import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
22
22
  import { getTranscriptionPolicySnapshot } from '../lib/transcription-policy.js'
23
+ import { CLI_DEBUG_CAPABILITY } from '../lib/cli-debug-view.js'
23
24
 
24
25
  export const healthRouter = Router()
25
26
 
@@ -53,7 +54,7 @@ function durableQueryJobStatus() {
53
54
  }
54
55
 
55
56
  healthRouter.get('/health', async (_req, res) => {
56
- const checks: Record<string, string | number> = {
57
+ const checks: Record<string, string | number | boolean> = {
57
58
  status: 'ok',
58
59
  mode: COS_MODE ? 'cos' : 'standalone',
59
60
  server: 'ok',
@@ -128,9 +129,11 @@ healthRouter.get('/health', async (_req, res) => {
128
129
 
129
130
  checks.silero_vad = isSileroAvailable() ? 'active' : 'disabled'
130
131
 
131
- // Include CLI session ID if available (pre-warmed or active)
132
+ // Health is unauthenticated. Publish only availability; the actual CLI
133
+ // session id is a resumable runtime handle and belongs on authenticated
134
+ // query/debug surfaces.
132
135
  const cliSid = getAvailableCliSessionId()
133
- if (cliSid) checks.cli_session_id = cliSid
136
+ checks.cli_session_available = Boolean(cliSid)
134
137
 
135
138
  // Feature summary for client capability detection.
136
139
  // v5.9.5 — voice.hasKey reflects the centralized resolver (env > saved file >
@@ -185,6 +188,7 @@ healthRouter.get('/health', async (_req, res) => {
185
188
  capabilities: {
186
189
  transcription,
187
190
  recovery,
191
+ cliDebug: CLI_DEBUG_CAPABILITY,
188
192
  ...(localFirstMeetings ? { localFirstMeetings } : {}),
189
193
  },
190
194
  // /api/health is intentionally unauthenticated for setup diagnostics.
@@ -215,6 +219,7 @@ healthRouter.get('/models', async (req, res) => {
215
219
  protocolVersion: durableJobs.protocolVersion,
216
220
  },
217
221
  transcription,
222
+ cliDebug: CLI_DEBUG_CAPABILITY,
218
223
  recovery: {
219
224
  status: false,
220
225
  restartWhisper: false,