@gotcos/glasses-server 6.15.2 → 6.15.5

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/CHANGELOG.md CHANGED
@@ -1,3 +1,49 @@
1
+ ## 6.15.5
2
+
3
+ - **Speculative HQ warm (no EHPK).** While a prompt-draft chunk is acknowledged,
4
+ Fast warm still paints the HUD and, when Settings HQ is active (default), a
5
+ background large-v3 warm overwrites `warmTranscripts` with `actualQuality=hq`
6
+ under `local-only` (never OpenAI mid-speak). Finalize reuses that cache so
7
+ Render is dominated by the last unfinished chunk, not a cold full re-decode.
8
+ Killswitch: `COS_HQ_SPECULATIVE_WARM=0`.
9
+ - **Finalize dedupe.** In-flight HQ warm and finalize share one decode via a
10
+ purpose-agnostic job key so Render cannot start a second large-v3 while warm
11
+ is still running.
12
+ - **Interactive HQ latency knobs.** Interactive beam defaults to 2 (meetings
13
+ keep beam 5); short clips (<15s) use light ffmpeg enhance (highpass only).
14
+ Env: `COS_HQ_BEAM_INTERACTIVE`, `COS_HQ_ENHANCE_LIGHT_MAX_SEC`.
15
+
16
+ ## 6.15.4
17
+
18
+ - Start Whisper, Kokoro, model discovery, and local audio prerequisites while
19
+ a managed successor remains behind the authenticated maintenance gate. Start
20
+ durable recovery, session warming, snapshots, and media GC exactly once after
21
+ the controller releases admissions, so routine restarts cannot strand local
22
+ services or prematurely mutate durable state.
23
+ - Bound Whisper process and port inspection to two seconds per probe, move it
24
+ off the Node event loop, expose startup phase/error diagnostics, and only reap
25
+ processes whose executable is actually `whisper-server` with the COS model
26
+ and port signature.
27
+ - Migrate legacy bare MCP server selectors to `mcp__server__*` and warn once for
28
+ rejected local/invalid selectors without relaxing the safe tool boundary.
29
+ - Terminate abandoned, timed-out, start-failed, and ownership-lost provider
30
+ runs without orphaning tool subprocesses. Termination targets the detached
31
+ process group, escalates from SIGTERM to SIGKILL, and releases lifecycle
32
+ ownership only after Node observes process close. Control's provider proof
33
+ uses the same process-owned cancellation boundary.
34
+ - Expose persistent Whisper prerequisite state separately from batch-only CLI
35
+ availability, plus an additive readiness summary so HTTP-200 liveness cannot
36
+ hide a configured local subsystem failure.
37
+ - Keep the stable `unauthorized` error code while adding non-secret guidance to
38
+ copy and paste the complete pairing token from COS Control.
39
+
40
+ ## 6.15.3
41
+
42
+ - Make `COS_WORKDIR` the authoritative Claude, Codex, and Cursor workspace.
43
+ Legacy provider-specific and `COS_SCRIPTS_DIR` paths remain compatibility
44
+ fallbacks, so pipeline scripts can stay separate from the agent workspace.
45
+ - Add workspace-precedence coverage for migrated LaunchAgent environments.
46
+
1
47
  ## 6.15.2
2
48
 
3
49
  - Validate the inherited Kokoro Python runtime before skipping bootstrap, so a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.15.2",
3
+ "version": "6.15.5",
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
@@ -69,12 +69,14 @@ import {
69
69
  } from './lib/network-policy.js'
70
70
  import { requireApiToken } from './lib/api-auth.js'
71
71
  import { isManagedRuntime } from './lib/managed-runtime.js'
72
+ import { reportClaudeExtraToolConfiguration } from './lib/claude-tool-access.js'
72
73
  import {
73
74
  acquireMaintenanceWork,
74
75
  maintenanceOperationCredentialsValid,
75
76
  MaintenanceLifecycleError,
76
77
  maintenanceAdmissionsOpen,
77
78
  maintenanceErrorPayload,
79
+ onMaintenanceAdmissionsOpen,
78
80
  } from './lib/maintenance-lifecycle.js'
79
81
 
80
82
  const app = express()
@@ -153,6 +155,7 @@ app.use('/api', (req, res, next) => {
153
155
  if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next()
154
156
  const lifecycleOwned = (req.path === '/query-jobs' && req.method === 'POST')
155
157
  || req.path === '/query'
158
+ || req.path === '/diagnostics/provider-proof'
156
159
  || req.path === '/transcribe'
157
160
  || req.path.startsWith('/transcribe-stream')
158
161
  || req.path === '/meeting/save'
@@ -326,20 +329,8 @@ listenRequiredServers(listeners).then(() => {
326
329
  console.log(`[COS API] Server instance: ${serverInstanceId}`)
327
330
  console.log(`[COS API] Mode: ${COS_MODE ? 'COS pipeline' : 'standalone'}`)
328
331
 
329
- if (startupAdmissionsOpen) {
330
- void initQueryJobRuntime().then(health => {
331
- if (process.env.COS_DURABLE_QUERY_JOBS === '1') {
332
- console.log(`[COS API] Durable query jobs: ${health.store.state} · ${health.store.retainedIdentities} retained`)
333
- } else {
334
- console.log('[COS API] Durable query jobs: disabled (set COS_DURABLE_QUERY_JOBS=1 to enable)')
335
- }
336
- }).catch(error => {
337
- // The store remains degraded and rejects admission. Legacy /api/query is
338
- // still mounted, so disabling the feature flag is an immediate rollback.
339
- console.error('[COS API] Durable query-job store unavailable:', error)
340
- })
341
- } else {
342
- console.log('[COS API] Startup maintenance gate is closed — durable recovery waits for controller adoption')
332
+ if (!startupAdmissionsOpen) {
333
+ console.log('[COS API] Startup maintenance gate is closed — durable recovery waits for controller release')
343
334
  }
344
335
 
345
336
  // Print ADDRESSES THE PHONE CAN ACTUALLY REACH. The bind address (0.0.0.0) is
@@ -387,16 +378,20 @@ listenRequiredServers(listeners).then(() => {
387
378
  console.log(`[COS API] Codex mode: ${codexConfig.persistenceEnabled ? 'persistent' : 'ephemeral'} · ${codexConfig.reasoningEffort} · ${codexConfig.trustMode}`)
388
379
  console.log(`[COS API] Codex models (${codexConfig.catalogSource}): ${codexConfig.availableModels.map(item => `${item.displayName}=${item.model}`).join(' · ')}`)
389
380
  console.log(`[COS API] Codex workdir: ${codexConfig.cwd}`)
390
- // Refresh immediately and then periodically. The catalog module retains the
391
- // last-known-good snapshot if Codex is temporarily unavailable.
392
- if (startupAdmissionsOpen) {
393
- startCodexModelCatalogRefresh()
381
+ reportClaudeExtraToolConfiguration()
394
382
 
395
- if (COS_MODE) {
396
- initSessionCache()
397
- // Pre-warm context cache so first query doesn't wait for the pipeline
398
- prewarmContext()
399
- }
383
+ // These services do not admit or mutate user work. They must start while a
384
+ // managed successor is still behind the cross-boot gate so COS Control can
385
+ // prove the candidate before opening admissions. Keeping this idempotent also
386
+ // prevents a release notification from creating duplicate sidecars.
387
+ let proofSafeServicesStarted = false
388
+ const startProofSafeServices = () => {
389
+ if (proofSafeServicesStarted) return
390
+ proofSafeServicesStarted = true
391
+
392
+ // Refresh immediately and then periodically. The catalog retains the last
393
+ // known-good snapshot if Codex is temporarily unavailable.
394
+ startCodexModelCatalogRefresh()
400
395
  // Start local whisper-server (model stays in RAM for ~50ms transcription)
401
396
  startWhisperServer().catch(err => console.error('[startup] Whisper server error:', err))
402
397
  startLocalTtsServer().catch(err => console.error('[startup] Local TTS server error:', err))
@@ -406,6 +401,33 @@ listenRequiredServers(listeners).then(() => {
406
401
  // Initialize Silero VAD (silence trimming before Whisper) — fails soft if model absent
407
402
  const vadOk = initSileroVAD()
408
403
  console.log(`[startup] Silero VAD: ${vadOk ? 'active' : 'disabled (model not found)'}`)
404
+ }
405
+
406
+ // Durable recovery and state-mutating background work remain closed until an
407
+ // authenticated controller release. The lifecycle callback is delivered for
408
+ // both an already-open fresh boot and a later cross-boot release.
409
+ let admittedRuntimeStarted = false
410
+ const startAdmittedRuntime = () => {
411
+ if (admittedRuntimeStarted) return
412
+ admittedRuntimeStarted = true
413
+
414
+ void initQueryJobRuntime().then(health => {
415
+ if (process.env.COS_DURABLE_QUERY_JOBS === '1') {
416
+ console.log(`[COS API] Durable query jobs: ${health.store.state} · ${health.store.retainedIdentities} retained`)
417
+ } else {
418
+ console.log('[COS API] Durable query jobs: disabled (set COS_DURABLE_QUERY_JOBS=1 to enable)')
419
+ }
420
+ }).catch(error => {
421
+ // The store remains degraded and rejects admission. Legacy /api/query is
422
+ // still mounted, so disabling the feature flag is an immediate rollback.
423
+ console.error('[COS API] Durable query-job store unavailable:', error)
424
+ })
425
+
426
+ if (COS_MODE) {
427
+ initSessionCache()
428
+ // Pre-warm context cache so first query doesn't wait for the pipeline
429
+ prewarmContext()
430
+ }
409
431
 
410
432
  // Pre-warm Claude only when installed so Codex-only startup stays quiet.
411
433
  if (claudeAvailable) {
@@ -418,6 +440,9 @@ listenRequiredServers(listeners).then(() => {
418
440
  // Durable media GC (staged/reserved expiry + generated-image content TTL).
419
441
  getMediaStore().startGC()
420
442
  }
443
+
444
+ startProofSafeServices()
445
+ onMaintenanceAdmissionsOpen(startAdmittedRuntime)
421
446
  }).catch((error: NodeJS.ErrnoException) => {
422
447
  console.error(`[COS API] Fatal listener startup: ${error.message}`)
423
448
  process.exit(error.code === 'EADDRINUSE' ? 75 : 74)
@@ -27,7 +27,11 @@ export function requireApiToken(apiToken: string): RequestHandler {
27
27
  return (req, res, next) => {
28
28
  if (isPublicApiRequest(req.method, req.path)) return next()
29
29
  if (!timingSafeTokenEqual(req.headers['x-cos-token'], apiToken)) {
30
- return res.status(401).json({ error: 'unauthorized' })
30
+ return res.status(401).json({
31
+ error: 'unauthorized',
32
+ reason: 'pairing_token_rejected',
33
+ message: 'In COS Control choose Copy Pairing Token, then paste the complete value into COS Glasses.',
34
+ })
31
35
  }
32
36
  next()
33
37
  }
@@ -2,10 +2,9 @@
2
2
  // Extracted so both batch (post-meeting) and one-shot (message query HQ) paths
3
3
  // can use the same filter chain.
4
4
  //
5
- // Filter chain:
6
- // highpass=f=80 kills low-freq rumble (HVAC, body noise, table thumps)
7
- // afftdn=nt=w FFT-based denoiser (white noise, fan hum)
8
- // loudnorm — EBU R128 loudness normalization (fixes quiet speakers)
5
+ // Filter chains:
6
+ // light — highpass=f=80 only (short interactive clips; lower latency)
7
+ // full highpass + afftdn + loudnorm (meetings / longer outdoor audio)
9
8
  //
10
9
  // Graceful: returns the original buffer if ffmpeg is missing, fails, or times out.
11
10
  // Callers should never crash a user request because enhancement couldn't run.
@@ -16,7 +15,10 @@ import { join } from 'node:path'
16
15
  import { randomUUID } from 'node:crypto'
17
16
 
18
17
  const FFMPEG_TIMEOUT_MS = 30_000
19
- const FILTER_CHAIN = 'highpass=f=80,afftdn=nt=w,loudnorm=I=-16:LRA=11:TP=-1.5'
18
+ const FILTER_FULL = 'highpass=f=80,afftdn=nt=w,loudnorm=I=-16:LRA=11:TP=-1.5'
19
+ const FILTER_LIGHT = 'highpass=f=80'
20
+
21
+ export type EnhanceProfile = 'light' | 'full'
20
22
 
21
23
  /**
22
24
  * Enhance raw audio (WAV/webm/etc) and return a 16kHz mono WAV buffer suitable
@@ -25,7 +27,12 @@ const FILTER_CHAIN = 'highpass=f=80,afftdn=nt=w,loudnorm=I=-16:LRA=11:TP=-1.5'
25
27
  *
26
28
  * Returns the ORIGINAL buffer unchanged on any failure. Logs the reason.
27
29
  */
28
- export async function enhanceAudio(audioBuffer: Buffer): Promise<Buffer> {
30
+ export async function enhanceAudio(
31
+ audioBuffer: Buffer,
32
+ opts: { profile?: EnhanceProfile } = {},
33
+ ): Promise<Buffer> {
34
+ const profile: EnhanceProfile = opts.profile === 'light' ? 'light' : 'full'
35
+ const filterChain = profile === 'light' ? FILTER_LIGHT : FILTER_FULL
29
36
  const id = randomUUID().slice(0, 8)
30
37
  const inputPath = join('/tmp', `cos-enhance-in-${id}`)
31
38
  const outputPath = join('/tmp', `cos-enhance-out-${id}.wav`)
@@ -36,7 +43,7 @@ export async function enhanceAudio(audioBuffer: Buffer): Promise<Buffer> {
36
43
  const enhanced = await new Promise<Buffer>((resolve, reject) => {
37
44
  const proc = spawn('ffmpeg', [
38
45
  '-i', inputPath,
39
- '-af', FILTER_CHAIN,
46
+ '-af', filterChain,
40
47
  '-ar', '16000',
41
48
  '-ac', '1',
42
49
  '-f', 'wav',
@@ -78,7 +85,7 @@ export async function enhanceAudio(audioBuffer: Buffer): Promise<Buffer> {
78
85
  return enhanced
79
86
  } catch (err: unknown) {
80
87
  const msg = err instanceof Error ? err.message : String(err)
81
- console.warn(`[audio-enhance] ffmpeg failed, returning original buffer: ${msg}`)
88
+ console.warn(`[audio-enhance] ffmpeg failed (${profile}), returning original buffer: ${msg}`)
82
89
  return audioBuffer
83
90
  } finally {
84
91
  try { unlinkSync(inputPath) } catch { /* ignore */ }
@@ -11,7 +11,7 @@ import { writeFileSync, readFileSync, unlinkSync, existsSync } from 'node:fs'
11
11
  import { appendFileSync } from 'node:fs'
12
12
  import { COS_SCRIPTS_DIR } from './python-bridge.js'
13
13
  import { cleanupModelImageInputs, type ModelImageInput } from './model-image-input.js'
14
- import { cosBrainDir } from './launch-dir.js'
14
+ import { resolveProviderWorkDir } from './launch-dir.js'
15
15
  import { logTokenAudit } from './token-audit.js'
16
16
  import { buildSystemPrompt, buildLightweightSystemPrompt, buildPrewarmSystemPrompt, getCachedContextInstant } from './context-builder.js'
17
17
  import { getHistory, addExchange, setExchangeAttachments, removeExchange, formatHistoryForPrompt, getOrCreateSession, isNewSession, markSessionNotified, getSessionModel, getSessionRaw, replaceLastExchangeWithSummary, type ModelPreference, type PromptReference } from './conversation.js'
@@ -54,6 +54,7 @@ import {
54
54
  } from './claude-tool-access.js'
55
55
  import { terminalProviderAuthFailure } from './provider-terminal-error.js'
56
56
  import { claudePermissionArgs, getClaudeTrustMode } from './claude-permissions.js'
57
+ import { terminateProviderProcess } from './provider-process-lifecycle.js'
57
58
 
58
59
  // Inactivity = no stdout data for this long → kill (catches stalls)
59
60
  const INACTIVITY_BY_MODEL: Record<ClaudeModelPreference, number> = {
@@ -225,7 +226,7 @@ export async function preWarmCLI(): Promise<void> {
225
226
  ], {
226
227
  stdio: ['pipe', 'pipe', 'pipe'],
227
228
  env,
228
- cwd: COS_SCRIPTS_DIR ?? cosBrainDir() ?? process.cwd(),
229
+ cwd: resolveProviderWorkDir({ scriptsDir: COS_SCRIPTS_DIR }),
229
230
  })
230
231
 
231
232
  let buffer = ''
@@ -528,7 +529,7 @@ export async function callClaudeStreaming(
528
529
  const env = { ...process.env }
529
530
  delete env.CLAUDECODE
530
531
  if (outputImagePublisher) Object.assign(env, outputImagePublisher.env)
531
- const cliCwd = COS_SCRIPTS_DIR ?? cosBrainDir() ?? process.cwd()
532
+ const cliCwd = resolveProviderWorkDir({ scriptsDir: COS_SCRIPTS_DIR })
532
533
  const inactivityMs = INACTIVITY_BY_MODEL[resolvedModel]
533
534
  const defaultWallMax = WALL_MAX_BY_MODEL[resolvedModel]
534
535
  const effortWallMax = resolvedEffort === 'max' || resolvedEffort === 'ultracode'
@@ -553,12 +554,14 @@ export async function callClaudeStreaming(
553
554
  stdio: ['pipe', 'pipe', 'pipe'],
554
555
  env,
555
556
  cwd: cliCwd,
557
+ detached: true,
556
558
  })
557
559
 
558
560
  let fullText = ''
559
561
  let stderr = ''
560
562
  let buffer = ''
561
563
  let finalized = false // Guard against double onDone/onError
564
+ let terminationRequested = false // Forced terminal callbacks wait for confirmed process close
562
565
  let terminalTextError: string | null = null
563
566
  let lastActivity = Date.now() // Tracks last stdout data for inactivity timeout
564
567
  let receivedStreamEvents = false // Track if CLI emits stream_event (vs older assistant-only format)
@@ -722,10 +725,27 @@ export async function callClaudeStreaming(
722
725
  await callbacks.onError(msg)
723
726
  }
724
727
 
728
+ async function terminateForTerminal(
729
+ reason: string,
730
+ onClosed: (result: Awaited<ReturnType<typeof terminateProviderProcess>>) => void | Promise<void>,
731
+ ) {
732
+ if (finalized || terminationRequested) return
733
+ terminationRequested = true
734
+ cleanup()
735
+ const result = await terminateProviderProcess(proc)
736
+ if (!result.closed) {
737
+ console.error(`[claude-bridge] provider did not close after SIGKILL (${reason}); retaining lifecycle ownership`)
738
+ return
739
+ }
740
+ await onClosed(result)
741
+ }
742
+
725
743
  function handleAbort() {
726
- if (finalized) return
727
- proc.kill('SIGTERM')
728
- finalizeError('claude-bridge: client disconnected before Claude completed.', null, 'client_disconnected')
744
+ void terminateForTerminal('client disconnect', result => finalizeError(
745
+ 'claude-bridge: client disconnected before Claude completed.',
746
+ result.code,
747
+ 'client_disconnected',
748
+ ))
729
749
  }
730
750
 
731
751
  // ─── Heartbeat: emit phase status during silence ───
@@ -743,31 +763,35 @@ export async function callClaudeStreaming(
743
763
  // ─── Inactivity timeout: resets on any stdout data ───
744
764
 
745
765
  let inactivityTimer = setTimeout(() => {
746
- proc.kill('SIGTERM')
747
766
  const elapsed = Math.round((Date.now() - startTime) / 1000)
748
- finalizeError(`No output for ${inactivityMs / 1000}s (${elapsed}s total). Process killed.`)
767
+ void terminateForTerminal('inactivity timeout', result => finalizeError(
768
+ `No output for ${inactivityMs / 1000}s (${elapsed}s total). Process killed.`,
769
+ result.code,
770
+ ))
749
771
  }, inactivityMs)
750
772
 
751
773
  function resetInactivity() {
752
774
  lastActivity = Date.now()
753
775
  clearTimeout(inactivityTimer)
754
776
  inactivityTimer = setTimeout(() => {
755
- proc.kill('SIGTERM')
756
777
  const elapsed = Math.round((Date.now() - startTime) / 1000)
757
- finalizeError(`No output for ${inactivityMs / 1000}s (${elapsed}s total). Process killed.`)
778
+ void terminateForTerminal('inactivity timeout', result => finalizeError(
779
+ `No output for ${inactivityMs / 1000}s (${elapsed}s total). Process killed.`,
780
+ result.code,
781
+ ))
758
782
  }, inactivityMs)
759
783
  }
760
784
 
761
785
  // ─── Wall clock max: absolute cap ───
762
786
 
763
787
  const wallTimer = setTimeout(() => {
764
- proc.kill('SIGTERM')
765
- if (fullText) {
766
- // Got partial output — deliver what we have
767
- void finalize(fullText)
768
- } else {
769
- finalizeError(`Wall clock limit reached (${wallMax / 1000}s). Process killed.`)
770
- }
788
+ void terminateForTerminal('wall timeout', result => {
789
+ if (fullText) {
790
+ // Got partial output — deliver only after the process tree is closed.
791
+ return finalize(fullText)
792
+ }
793
+ return finalizeError(`Wall clock limit reached (${wallMax / 1000}s). Process killed.`, result.code)
794
+ })
771
795
  }, wallMax)
772
796
 
773
797
  function cleanup() {
@@ -898,6 +922,7 @@ export async function callClaudeStreaming(
898
922
  })
899
923
 
900
924
  proc.on('close', (code) => {
925
+ if (terminationRequested) return
901
926
  // Process any remaining buffer
902
927
  if (buffer.trim()) {
903
928
  try {
@@ -931,9 +956,11 @@ export async function callClaudeStreaming(
931
956
  })
932
957
 
933
958
  proc.on('error', (err) => {
959
+ if (terminationRequested) return
934
960
  finalizeError(`claude-bridge: ${err.message}`, null)
935
961
  })
936
962
  proc.stdin.on('error', (err) => {
963
+ if (terminationRequested) return
937
964
  finalizeError(`claude-bridge: stdin failed — ${err.message}`, null)
938
965
  })
939
966
 
@@ -959,8 +986,9 @@ export async function callClaudeStreaming(
959
986
  generation: options?.generation,
960
987
  })
961
988
  if (providerOwned === false) {
962
- proc.kill('SIGTERM')
963
- abandonLostDurableOwnership('claude-bridge: durable provider ownership was lost.')
989
+ await terminateForTerminal('provider ownership lost', () => {
990
+ abandonLostDurableOwnership('claude-bridge: durable provider ownership was lost.')
991
+ })
964
992
  return sid
965
993
  }
966
994
  if (finalized) return sid
@@ -969,8 +997,10 @@ export async function callClaudeStreaming(
969
997
  } catch (err) {
970
998
  const message = err instanceof Error ? err.message : String(err)
971
999
  if (!finalized) {
972
- proc.kill('SIGTERM')
973
- await finalizeError(`claude-bridge: provider start failed — ${message}`, null)
1000
+ await terminateForTerminal('provider start failure', result => finalizeError(
1001
+ `claude-bridge: provider start failed — ${message}`,
1002
+ result.code,
1003
+ ))
974
1004
  }
975
1005
  }
976
1006
 
@@ -5,20 +5,86 @@ import { resolve } from 'node:path'
5
5
  // server's built-in Web/Read tools remain code-owned, so a remotely reachable
6
6
  // glasses query cannot turn a local env typo into Bash/Write access.
7
7
  const MCP_SELECTOR = /^mcp__[A-Za-z0-9][A-Za-z0-9_.:@/-]*__[A-Za-z0-9*][A-Za-z0-9_.*:@/-]*$/
8
+ const LEGACY_MCP_SERVER_SELECTOR = /^mcp__(?!.*__)[A-Za-z0-9][A-Za-z0-9_.:@/-]*$/
8
9
 
9
- export function configuredClaudeExtraTools(
10
+ export interface ClaudeExtraToolConfiguration {
11
+ accepted: string[]
12
+ rejected: string[]
13
+ migrated: Array<{ from: string; to: string }>
14
+ }
15
+
16
+ let reportedConfiguration: string | null = null
17
+
18
+ export function parseClaudeExtraToolConfiguration(
10
19
  env: NodeJS.ProcessEnv = process.env,
11
- ): string[] {
20
+ ): ClaudeExtraToolConfiguration {
12
21
  const raw = env.COS_EXTRA_TOOLS ?? ''
13
22
  const seen = new Set<string>()
14
- const tools: string[] = []
23
+ const rejectedSeen = new Set<string>()
24
+ const accepted: string[] = []
25
+ const rejected: string[] = []
26
+ const migrated: Array<{ from: string; to: string }> = []
27
+
15
28
  for (const value of raw.split(',')) {
16
- const tool = value.trim()
17
- if (!tool || !MCP_SELECTOR.test(tool) || seen.has(tool)) continue
29
+ const original = value.trim()
30
+ if (!original) continue
31
+ const tool = LEGACY_MCP_SERVER_SELECTOR.test(original)
32
+ ? `${original}__*`
33
+ : original
34
+ if (!MCP_SELECTOR.test(tool)) {
35
+ if (!rejectedSeen.has(original)) {
36
+ rejectedSeen.add(original)
37
+ rejected.push(original)
38
+ }
39
+ continue
40
+ }
41
+ if (seen.has(tool)) continue
18
42
  seen.add(tool)
19
- tools.push(tool)
43
+ accepted.push(tool)
44
+ if (tool !== original) migrated.push({ from: original, to: tool })
45
+ }
46
+
47
+ return { accepted, rejected, migrated }
48
+ }
49
+
50
+ export function configuredClaudeExtraTools(
51
+ env: NodeJS.ProcessEnv = process.env,
52
+ ): string[] {
53
+ return parseClaudeExtraToolConfiguration(env).accepted
54
+ }
55
+
56
+ function safeSelectorList(values: string[]): string {
57
+ const visible = values.slice(0, 8).map(value => {
58
+ const sanitized = value.replace(/[^A-Za-z0-9_.:@/*=>-]/g, '?')
59
+ return sanitized.length > 64 ? `${sanitized.slice(0, 61)}...` : sanitized
60
+ })
61
+ return `${visible.join(', ')}${values.length > visible.length ? ` (+${values.length - visible.length} more)` : ''}`
62
+ }
63
+
64
+ /** Log migration/rejection once per process configuration, never per query. */
65
+ export function reportClaudeExtraToolConfiguration(
66
+ env: NodeJS.ProcessEnv = process.env,
67
+ ): ClaudeExtraToolConfiguration {
68
+ const raw = env.COS_EXTRA_TOOLS ?? ''
69
+ const parsed = parseClaudeExtraToolConfiguration(env)
70
+ if (!raw.trim() || reportedConfiguration === raw) return parsed
71
+ reportedConfiguration = raw
72
+
73
+ if (parsed.migrated.length > 0) {
74
+ console.warn(
75
+ '[claude-tools] Migrated legacy COS_EXTRA_TOOLS server selector(s): ' +
76
+ safeSelectorList(parsed.migrated.map(item => `${item.from}->${item.to}`)) +
77
+ '. Persist the full mcp__server__* form.',
78
+ )
79
+ }
80
+ if (parsed.rejected.length > 0) {
81
+ console.warn(
82
+ '[claude-tools] Ignored unsafe or invalid COS_EXTRA_TOOLS selector(s): ' +
83
+ safeSelectorList(parsed.rejected) +
84
+ '. Use mcp__server__tool or mcp__server__*. Read/Glob/Grep/Bash/Write cannot be enabled through this setting.',
85
+ )
20
86
  }
21
- return tools
87
+ return parsed
22
88
  }
23
89
 
24
90
  export function buildClaudeToolList(input: {