@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 +1 -1
- package/CHANGELOG.md +53 -0
- package/README.md +19 -5
- package/bin/cli.cjs +143 -26
- package/package.json +1 -1
- package/server/index.ts +14 -5
- package/server/lib/claude-bridge.ts +37 -10
- package/server/lib/cli-debug-view.ts +213 -0
- package/server/lib/codex-bridge.ts +41 -6
- package/server/lib/provider-terminal-error.ts +55 -0
- package/server/lib/secure-user-config.ts +64 -0
- package/server/routes/cli-debug.ts +66 -0
- package/server/routes/health.ts +8 -3
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://
|
|
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)
|
|
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
|
|
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 {
|
|
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 (
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
147
|
+
const hasUsableAgent = (claudeVersion && claudeAuth !== 'signed-out') || (codexVersion && codexAuth !== 'signed-out')
|
|
148
|
+
if (!hasUsableAgent) {
|
|
89
149
|
console.log('')
|
|
90
|
-
console.log(red(' ✗ No
|
|
91
|
-
console.log('
|
|
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:
|
|
98
|
-
//
|
|
99
|
-
|
|
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(
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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).
|
|
166
|
-
console.log(' ' + dim('Skip
|
|
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',
|
|
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
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
|
|
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
|
-
|
|
79
|
-
|
|
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 {
|
|
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://
|
|
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'
|
|
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
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
-
//
|
|
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 (
|
|
887
|
-
|
|
888
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
//
|
|
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 (
|
|
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
|
+
})
|
package/server/routes/health.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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,
|