@gotcos/glasses-server 6.38.1 → 6.39.1

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
@@ -48,7 +48,7 @@ BIND_HOST=0.0.0.0
48
48
  # Opus / Fable / Sonnet -> Claude Code CLI (https://docs.anthropic.com/en/docs/claude-code/getting-started, then `claude auth login`)
49
49
  # GPT Frontier/Balanced -> Codex CLI (https://developers.openai.com/codex/, then `codex login`)
50
50
  # Install at least one. Pick the default model the glasses use:
51
- # COS_G2_DEFAULT_MODEL=sonnet # opus | fable | sonnet | codex-frontier | codex-balanced
51
+ # COS_G2_DEFAULT_MODEL=sonnet # opus | fable | sonnet | codex-frontier | codex-balanced | cursor-grok | cursor-composer | ollama
52
52
  #
53
53
  # GPT slots refresh from the Codex model catalog every 15 minutes and retain
54
54
  # their last-known-good resolution if discovery is temporarily unavailable.
@@ -66,6 +66,26 @@ BIND_HOST=0.0.0.0
66
66
  # Pair with ~/.codex/config.toml [sandbox_workspace_write] network_access = true
67
67
  # for interactive Codex CLI; the managed glasses server also passes
68
68
  # -c sandbox_workspace_write.network_access=true when workspace-write is set.
69
+ #
70
+ # Local Ollama (https://ollama.com) as a fourth G2 picker. Hidden unless
71
+ # GET http://127.0.0.1:11434/api/tags succeeds with at least one pulled model.
72
+ # Direct POST /api/chat — chat/summarize only, no tools, no Codex --oss.
73
+ # brew install ollama
74
+ # ollama serve
75
+ # ollama run qwen2.5-coder
76
+ # COS_OLLAMA_MODEL=qwen2.5-coder
77
+ # COS_OLLAMA_HOST=http://127.0.0.1:11434
78
+ # COS_G2_DEFAULT_MODEL=ollama
79
+ #
80
+ # Codex CLI operator hatch (not the Ollama picker). Frontier stays ChatGPT
81
+ # unless you set this. Put it in ~/.cos-glasses/.env, not the LaunchAgent plist.
82
+ # Sessions Continue / Fork ignore these flags. No inline comments on this line.
83
+ # COS_CODEX_EXTRA_ARGS=--oss --local-provider ollama --model qwen2.5-coder
84
+ # Do not define [model_providers.ollama] — that id is reserved. LM Studio:
85
+ # COS_CODEX_EXTRA_ARGS=--oss --local-provider lmstudio --model <id>
86
+ # Other OpenAI-compatible proxies (not oss): --model inside EXTRA_ARGS so a
87
+ # plist COS_CODEX_MODEL cannot retarget it:
88
+ # COS_CODEX_EXTRA_ARGS=-c model_provider="local_vllm" --model <id>
69
89
 
70
90
  # Claude preserves COS's established trusted-machine behavior by default.
71
91
  # Security-conscious installs can remove the permission bypass and restrict
package/CHANGELOG.md CHANGED
@@ -1,3 +1,51 @@
1
+ ## 6.39.1
2
+
3
+ Durable jobs now remember they ran on Ollama.
4
+
5
+ FIXED: 6.39.0 allowed `provider: 'ollama'` in the durable-job types and the
6
+ runtime stamped it, but the journal sanitizer (`safeLinkage`) and the replay
7
+ reader (`applyLinkage`) both stripped it -- so a live or replayed Ollama job
8
+ came back providerless and the phone could never acknowledge which engine
9
+ answered. Both now persist `ollama` and `ollamaRunId`; unknown providers still
10
+ strip. Pinned by journal round-trip tests, mutation-verified.
11
+
12
+ No other changes; pairs with COS Glasses 6.8.436 and COS Control 0.5.74.
13
+
14
+ ## 6.39.0
15
+
16
+ Ollama is a fourth G2 picker, hidden unless a local daemon answers.
17
+
18
+ When `GET http://127.0.0.1:11434/api/tags` succeeds with at least one pulled
19
+ model, glasses and companion Settings show **Ollama**. Queries go to
20
+ `POST /api/chat` on loopback — chat/summarize only, no tools, no vision, no
21
+ attached Continue / Fork, no Live Cues. If the daemon drops, the slot hides and
22
+ a saved `ollama` preference resets to Opus.
23
+
24
+ Optional pin: `COS_OLLAMA_MODEL`. Optional loopback override: `COS_OLLAMA_HOST`
25
+ (LAN / `0.0.0.0` refused). `COS_G2_DEFAULT_MODEL=ollama` is valid when the
26
+ probe is ready.
27
+
28
+ GPT Frontier / Balanced stay Codex CLI → ChatGPT. `COS_CODEX_EXTRA_ARGS` remains
29
+ a Codex operator hatch, not the Ollama product.
30
+
31
+ ## 6.38.1
32
+
33
+ GPT Frontier can point at a local Ollama (or LM Studio) engine without a fourth
34
+ G2 provider. Set `COS_CODEX_EXTRA_ARGS` in `~/.cos-glasses/.env`:
35
+
36
+ `COS_CODEX_EXTRA_ARGS=--oss --local-provider ollama --model qwen2.5-coder`
37
+
38
+ Put `--model` inside that string so a Control-keep-alived plist `COS_CODEX_MODEL`
39
+ cannot retarget off the local id. Sessions Continue / Fork / Settings proof
40
+ ignore extra args. A plist-only EXTRA_ARGS dies on the next Update Server.
41
+
42
+ The hatch is allowlisted (`--oss`, `--local-provider`, `--model`, `-c` keys
43
+ `model` / `model_provider` / `oss_provider` / `model_reasoning_effort` /
44
+ `service_tier`). It cannot pass `--sandbox`, `--permission-profile`, or a `-c`
45
+ sandbox key. Codex still runs `--sandbox read-only`; that sandbox does not block
46
+ the CLI's own HTTP to `localhost:11434`. `ollama serve` must be running or Codex
47
+ exits `OSS setup failed`. This is not a COS Ollama product.
48
+
1
49
  ## 6.38.1
2
50
 
3
51
  The conversation archive was quietly storing the same conversations over and over.
package/README.md CHANGED
@@ -60,7 +60,7 @@ without silently losing completed replies.
60
60
  > No provider API key is needed for chat when using signed-in CLIs. Usage is
61
61
  > billed to the corresponding Claude, Codex, or Cursor subscription. Pick a
62
62
  > provider per query, or set a default with `COS_G2_DEFAULT_MODEL`
63
- > (`opus`|`fable`|`sonnet`|`codex-frontier`|`codex-balanced`|`cursor-grok`|`cursor-composer`).
63
+ > (`opus`|`fable`|`sonnet`|`codex-frontier`|`codex-balanced`|`cursor-grok`|`cursor-composer`|`ollama`).
64
64
  > Claude tier aliases and the two GPT slots resolve dynamically, so new model
65
65
  > releases do not require a new glasses package. GPT discovery refreshes every
66
66
  > 15 minutes and retains its last-known-good catalog through transient failures.
@@ -75,6 +75,10 @@ without silently losing completed replies.
75
75
  > `COS_CODEX_SANDBOX=workspace-write` for workdir writes + outbound network
76
76
  > (`sandbox_workspace_write.network_access=true` is passed by the managed server
77
77
  > and should also be set in `~/.codex/config.toml` for interactive Codex).
78
+ > Local Ollama is a **fourth picker**, shown only when `ollama serve` answers
79
+ > `GET http://127.0.0.1:11434/api/tags` with a pulled model. Direct
80
+ > `POST /api/chat` — not Codex `--oss`. Optional pin: `COS_OLLAMA_MODEL`.
81
+ > `COS_CODEX_EXTRA_ARGS` is a Codex CLI hatch, not the Ollama UX. See Configuration.
78
82
  > **Claude is the most permissive provider by default.** It runs with
79
83
  > `--dangerously-skip-permissions`, so a glasses query on the Claude/Opus path can
80
84
  > run shell commands and read, edit, and write files on this Mac without prompting
@@ -173,6 +177,15 @@ optional except an installed CLI. Highlights: `BIND_HOST`, `PORT`,
173
177
  in the managed CLI working directory),
174
178
  `COS_CURSOR_AGENT_BIN` (optional absolute Cursor `agent` binary),
175
179
  `COS_CURSOR_PERSIST_SESSIONS=0` (disable Cursor session resume),
180
+ `COS_CODEX_SANDBOX=workspace-write` (workdir writes + outbound network on GPT),
181
+ `COS_OLLAMA_MODEL` (optional pin; must match a name from `ollama list`),
182
+ `COS_OLLAMA_HOST` (optional loopback origin, default `http://127.0.0.1:11434`;
183
+ non-loopback is refused),
184
+ `COS_CODEX_EXTRA_ARGS` (Codex exec operator hatch — not the Ollama picker.
185
+ `--oss --local-provider ollama --model qwen2.5-coder` still retargets GPT
186
+ Frontier spawn. Put it in `~/.cos-glasses/.env`; a plist-only value is dropped
187
+ on Update Server. G2 Codex exec only — Sessions Continue / Fork ignore it.
188
+ No inline `#` on the EXTRA_ARGS line.),
176
189
  `COS_WEATHER_DEFAULT_LAT` / `COS_WEATHER_DEFAULT_LON` /
177
190
  `COS_WEATHER_DEFAULT_CITY` (optional home fallback when phone GPS is denied),
178
191
  `COS_SCRIPTS_DIR` (full pipeline), `COS_DURABLE_QUERY_JOBS=0` (optional
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.38.1",
4
- "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
3
+ "version": "6.39.1",
4
+ "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "glasses-server": "bin/cli.cjs",
@@ -110,26 +110,6 @@ export function isAttachedPermissionPolicy(value: unknown): value is AttachedPer
110
110
  return value === 'read_only'
111
111
  }
112
112
 
113
- /**
114
- * Flags that must never reach a provider that is about to write into someone's
115
- * real conversation (plan 4.7: no always-approve, bypass-permissions,
116
- * danger-full-access, force, or yolo behavior).
117
- *
118
- * Exported so the assertion can be made against the argv that is actually
119
- * spawned rather than against the source that builds it.
120
- */
121
- export const BANNED_PERMISSION_ARGS: readonly string[] = [
122
- '--dangerously-skip-permissions',
123
- '--dangerously-bypass-approvals-and-sandbox',
124
- '--dangerously-bypass-hook-trust',
125
- '--full-auto',
126
- '--yolo',
127
- '--force',
128
- 'danger-full-access',
129
- 'bypassPermissions',
130
- 'acceptEdits',
131
- ]
132
-
133
113
  /** Terminal reason on failure. Never carries prompt or transcript text. */
134
114
  export type AttachedTurnFailure =
135
115
  | 'invalid_provider'
@@ -238,6 +218,7 @@ import {
238
218
  resolveProviderBinary,
239
219
  type BinaryResolution,
240
220
  } from './provider-binary.js'
221
+ import { findBannedPermissionArg } from './banned-permission-args.js'
241
222
 
242
223
  export {
243
224
  STALE_SHIM_PREFIXES,
@@ -249,6 +230,7 @@ export {
249
230
  type BinaryResolutionFailure,
250
231
  type BinarySpec,
251
232
  } from './provider-binary.js'
233
+ export { findBannedPermissionArg, BANNED_PERMISSION_ARGS } from './banned-permission-args.js'
252
234
 
253
235
 
254
236
  // ---------------------------------------------------------------------------
@@ -506,34 +488,6 @@ export function buildCursorAttachedArgs(nativeThreadId: string, cwd: string): st
506
488
  ]
507
489
  }
508
490
 
509
- const PATH_VALUED_FLAGS = new Set(['--workspace', '-C', '--cd', '--add-dir'])
510
- const BARE_BANNED_PERMISSION_ARGS = new Set(
511
- BANNED_PERMISSION_ARGS.filter(flag => !flag.startsWith('-')),
512
- )
513
-
514
- /**
515
- * Is this argv free of every flag plan 4.7 bans?
516
- *
517
- * Flag-position tokens (start with `-`) are substring-matched so
518
- * `--permission-mode=bypassPermissions` still hits. Bare tokens
519
- * (`danger-full-access`) match only as their own argv slot. Values of
520
- * `--workspace` / `-C` are skipped: a cwd containing `--force` is a path,
521
- * not a permission flag.
522
- */
523
- export function findBannedPermissionArg(args: readonly string[]): string | null {
524
- for (let i = 0; i < args.length; i++) {
525
- const value = String(args[i])
526
- const prev = i > 0 ? String(args[i - 1]) : ''
527
- if (PATH_VALUED_FLAGS.has(prev)) continue
528
- if (BARE_BANNED_PERMISSION_ARGS.has(value)) return value
529
- if (!value.startsWith('-')) continue
530
- for (const banned of BANNED_PERMISSION_ARGS) {
531
- if (value.includes(banned)) return banned
532
- }
533
- }
534
- return null
535
- }
536
-
537
491
  /**
538
492
  * Build the argv, and REFUSE to hand back one carrying a banned flag.
539
493
  *
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Shared permission-ban list for attached, fork, and G2 Codex extra-args.
3
+ * Keep PATH_VALUED_FLAGS and the bare-token set private; they only exist to
4
+ * make findBannedPermissionArg skip cwd-shaped values.
5
+ */
6
+
7
+ export const BANNED_PERMISSION_ARGS: readonly string[] = [
8
+ '--dangerously-skip-permissions',
9
+ '--dangerously-bypass-approvals-and-sandbox',
10
+ '--dangerously-bypass-hook-trust',
11
+ '--full-auto',
12
+ '--yolo',
13
+ '--force',
14
+ 'danger-full-access',
15
+ 'bypassPermissions',
16
+ 'acceptEdits',
17
+ ]
18
+
19
+ const PATH_VALUED_FLAGS = new Set(['--workspace', '-C', '--cd', '--add-dir'])
20
+ const BARE_BANNED_PERMISSION_ARGS = new Set(
21
+ BANNED_PERMISSION_ARGS.filter(flag => !flag.startsWith('-')),
22
+ )
23
+
24
+ /**
25
+ * Is this argv free of every flag plan 4.7 bans?
26
+ *
27
+ * Flag-position tokens (start with `-`) are substring-matched so
28
+ * `--permission-mode=bypassPermissions` still hits. Bare tokens
29
+ * (`danger-full-access`) match only as their own argv slot. Values of
30
+ * `--workspace` / `-C` are skipped: a cwd containing `--force` is a path,
31
+ * not a permission flag.
32
+ */
33
+ export function findBannedPermissionArg(args: readonly string[]): string | null {
34
+ for (let i = 0; i < args.length; i++) {
35
+ const value = String(args[i])
36
+ const prev = i > 0 ? String(args[i - 1]) : ''
37
+ if (PATH_VALUED_FLAGS.has(prev)) continue
38
+ if (BARE_BANNED_PERMISSION_ARGS.has(value)) return value
39
+ if (!value.startsWith('-')) continue
40
+ for (const banned of BANNED_PERMISSION_ARGS) {
41
+ if (value.includes(banned)) return banned
42
+ }
43
+ }
44
+ return null
45
+ }
@@ -311,6 +311,7 @@ export interface ModelRunMetadata {
311
311
  codexThreadId?: string
312
312
  cursorRunId?: string
313
313
  cursorChatId?: string
314
+ ollamaRunId?: string
314
315
  outputAttachments?: MediaAttachmentRef[]
315
316
  outputImageStats?: RunOutputImageCollectionStats
316
317
  }
@@ -318,7 +319,7 @@ export interface ModelRunMetadata {
318
319
  /** Public-safe provider launch metadata for durable job coordination. It
319
320
  * deliberately exposes no ChildProcess object, kill handle, paths, or env. */
320
321
  export interface ProviderProcessMetadata {
321
- provider: 'claude' | 'codex' | 'cursor'
322
+ provider: 'claude' | 'codex' | 'cursor' | 'ollama'
322
323
  runId: string
323
324
  pid?: number
324
325
  clientJobId?: string
@@ -7,6 +7,15 @@ import { spawn, spawnSync } from 'node:child_process'
7
7
  // because COS Control injects that directory into the managed plist PATH -- it is ENOENT
8
8
  // for every public npx user, and for anything launchd- or Finder-spawned.
9
9
  import { resolveProviderBinary } from './provider-binary.js'
10
+ import {
11
+ CodexExtraArgsError,
12
+ assertSafeCodexExtraArgs,
13
+ codexEngineFingerprint,
14
+ parseCodexExtraArgs,
15
+ skipsCosModel,
16
+ skipsCosReasoningEffort,
17
+ skipsCosServiceTier,
18
+ } from './codex-extra-args.js'
10
19
  import { logTokenAudit } from './token-audit.js'
11
20
  import { cleanupModelImageInputs, type ModelImageInput } from './model-image-input.js'
12
21
  import { buildSystemPrompt, buildLightweightSystemPrompt } from './context-builder.js'
@@ -161,6 +170,13 @@ export function codexSupportsAdditionalDir(): boolean {
161
170
  return addDirSupported
162
171
  }
163
172
 
173
+ export {
174
+ CodexExtraArgsError,
175
+ parseCodexExtraArgs,
176
+ assertSafeCodexExtraArgs,
177
+ codexEngineFingerprint,
178
+ } from './codex-extra-args.js'
179
+
164
180
  export function buildCodexExecArgs(input: {
165
181
  codexCwd: string
166
182
  imagePaths?: string[]
@@ -170,6 +186,7 @@ export function buildCodexExecArgs(input: {
170
186
  resolvedModel?: CodexModelOption
171
187
  effort?: EffortPreference
172
188
  publisherWritableDirectory?: string
189
+ extra?: string[]
173
190
  }): string[] {
174
191
  const imagePaths = input.imagePaths ?? []
175
192
  const resolvedModel = input.resolvedModel
@@ -184,10 +201,18 @@ export function buildCodexExecArgs(input: {
184
201
  args.push('--add-dir', input.publisherWritableDirectory)
185
202
  }
186
203
 
204
+ const extra = input.extra ?? parseCodexExtraArgs(process.env.COS_CODEX_EXTRA_ARGS)
205
+ assertSafeCodexExtraArgs(extra)
206
+ args.push(...extra)
207
+
187
208
  const appendModelConfig = () => {
188
- if (resolvedModel.id) args.push('--model', resolvedModel.id)
189
- args.push('-c', `model_reasoning_effort="${reasoningEffort}"`)
190
- if (serviceTier) args.push('-c', `service_tier="${serviceTier}"`)
209
+ if (resolvedModel.id && !skipsCosModel(extra)) args.push('--model', resolvedModel.id)
210
+ if (!skipsCosReasoningEffort(extra)) {
211
+ args.push('-c', `model_reasoning_effort="${reasoningEffort}"`)
212
+ }
213
+ if (serviceTier && !skipsCosServiceTier(extra)) {
214
+ args.push('-c', `service_tier="${serviceTier}"`)
215
+ }
191
216
  }
192
217
 
193
218
  if (input.codexThreadId) {
@@ -299,6 +324,18 @@ export async function callCodexStreaming(
299
324
  if (options?.abortSignal?.aborted) {
300
325
  throw new Error('codex-bridge: client disconnected before Codex started.')
301
326
  }
327
+ let extra: string[]
328
+ try {
329
+ extra = parseCodexExtraArgs(process.env.COS_CODEX_EXTRA_ARGS)
330
+ assertSafeCodexExtraArgs(extra)
331
+ } catch (error) {
332
+ if (error instanceof CodexExtraArgsError) {
333
+ await callbacks.onError(error.message)
334
+ return sid
335
+ }
336
+ throw error
337
+ }
338
+ const extraFingerprint = codexEngineFingerprint(extra)
302
339
  const history = getHistory(sid)
303
340
  const session = getSessionRaw(sid)
304
341
  const contextBreaks = session?.contextBreaks ?? []
@@ -310,7 +347,13 @@ export async function callCodexStreaming(
310
347
  const resolvedCodexModel = resolveCodexModelOption(model)
311
348
  const resolvedCodexEffort = resolveCodexEffortForModel(resolvedCodexModel, options?.effort)
312
349
  const engineSession = persistentCodexSession
313
- ? getCodexEngineSession({ cosSessionId: sid, model, cwd: codexCwd, trustMode: codexTrustMode })
350
+ ? getCodexEngineSession({
351
+ cosSessionId: sid,
352
+ model,
353
+ cwd: codexCwd,
354
+ trustMode: codexTrustMode,
355
+ engineFingerprint: extraFingerprint,
356
+ })
314
357
  : null
315
358
  const imageInputs: ModelImageInput[] = images ?? []
316
359
  const imagePaths = imageInputs.map(input => input.path)
@@ -419,6 +462,7 @@ export async function callCodexStreaming(
419
462
  resolvedModel: resolvedCodexModel,
420
463
  effort: options?.effort,
421
464
  publisherWritableDirectory: outputImagePublisher?.writableDirectory,
465
+ extra,
422
466
  })
423
467
 
424
468
  const env = { ...process.env }
@@ -481,6 +525,7 @@ export async function callCodexStreaming(
481
525
  codexThreadId,
482
526
  cwd: codexCwd,
483
527
  trustMode: codexTrustMode,
528
+ engineFingerprint: extraFingerprint,
484
529
  })
485
530
  updateCodexRun(run.runId, { codexThreadId, expiresAt: saved.expiresAt })
486
531
  } catch (error) {
@@ -20,6 +20,7 @@ export interface CodexEngineSession {
20
20
  savedAt: number
21
21
  lastUsedAt: number
22
22
  expiresAt: string
23
+ engineFingerprint: string
23
24
  }
24
25
 
25
26
  interface CodexEngineSessionFile {
@@ -93,18 +94,21 @@ export function getCodexEngineSession(input: {
93
94
  model: CodexModelPreference
94
95
  cwd: string
95
96
  trustMode: CodexTrustMode
97
+ engineFingerprint?: string
96
98
  }): CodexEngineSession | null {
99
+ const { engineFingerprint = '', ...rest } = input
97
100
  const store = readStore()
98
101
  const now = Date.now()
99
102
  const sessions = pruneExpired(store.sessions, now)
100
- const existing = sessions[sessionKey(input.cosSessionId, input.model)]
103
+ const existing = sessions[sessionKey(rest.cosSessionId, rest.model)]
101
104
  if (!existing) {
102
105
  if (Object.keys(sessions).length !== Object.keys(store.sessions).length) {
103
106
  writeStore({ sessions, savedAt: new Date().toISOString() })
104
107
  }
105
108
  return null
106
109
  }
107
- if (existing.cwd !== input.cwd || existing.trustMode !== input.trustMode) return null
110
+ if (existing.cwd !== rest.cwd || existing.trustMode !== rest.trustMode) return null
111
+ if ((existing.engineFingerprint ?? '') !== (engineFingerprint ?? '')) return null
108
112
  return existing
109
113
  }
110
114
 
@@ -115,6 +119,7 @@ export function saveCodexEngineSession(input: {
115
119
  cwd: string
116
120
  trustMode: CodexTrustMode
117
121
  now?: number
122
+ engineFingerprint?: string
118
123
  }): CodexEngineSession {
119
124
  const now = input.now ?? Date.now()
120
125
  const store = readStore()
@@ -131,6 +136,7 @@ export function saveCodexEngineSession(input: {
131
136
  savedAt: previous?.savedAt ?? now,
132
137
  lastUsedAt: now,
133
138
  expiresAt: expiresAtFrom(now),
139
+ engineFingerprint: input.engineFingerprint ?? '',
134
140
  }
135
141
  sessions[key] = session
136
142
  writeStore({ sessions, savedAt: new Date().toISOString() })
@@ -0,0 +1,222 @@
1
+ import { findBannedPermissionArg } from './banned-permission-args.js'
2
+
3
+ export const CODEX_EXTRA_ARGS_MAX_TOKENS = 32
4
+ export const CODEX_EXTRA_ARGS_MAX_CHARS = 2_000
5
+
6
+ const BOOLEAN_FLAGS = new Set(['--oss'])
7
+ const VALUE_FLAGS = new Set(['-c', '--config', '--model', '-m', '--local-provider'])
8
+ const ALLOWED_FLAGS = new Set(['--oss', '--local-provider', '--model', '-m', '-c', '--config'])
9
+ const ALLOWED_CONFIG_KEYS = new Set([
10
+ 'model',
11
+ 'model_provider',
12
+ 'oss_provider',
13
+ 'model_reasoning_effort',
14
+ 'service_tier',
15
+ ])
16
+ const LOCAL_PROVIDER_VALUES = new Set(['ollama', 'lmstudio'])
17
+
18
+ export class CodexExtraArgsError extends Error {
19
+ flag: string
20
+ constructor(flag: string) {
21
+ super(`Invalid COS_CODEX_EXTRA_ARGS (${flag}). Edit ~/.cos-glasses/.env and restart the glasses server.`)
22
+ this.name = 'CodexExtraArgsError'
23
+ this.flag = flag
24
+ }
25
+ }
26
+
27
+ function extraArgsUserMessage(flag: string): string {
28
+ return `Invalid COS_CODEX_EXTRA_ARGS (${flag}). Edit ~/.cos-glasses/.env and restart the glasses server.`
29
+ }
30
+
31
+ export function extraArgsErrorMessage(flag: string): string {
32
+ return extraArgsUserMessage(flag)
33
+ }
34
+
35
+ function unwrapWholeValue(raw: string): string {
36
+ if (raw.length >= 2) {
37
+ const first = raw[0]
38
+ const last = raw[raw.length - 1]
39
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
40
+ return raw.slice(1, -1).trim()
41
+ }
42
+ }
43
+ return raw
44
+ }
45
+
46
+ function splitUnquotedWhitespace(raw: string): string[] {
47
+ const tokens: string[] = []
48
+ let current = ''
49
+ let quote: '"' | "'" | null = null
50
+ for (const ch of raw) {
51
+ if (quote) {
52
+ current += ch
53
+ if (ch === quote) quote = null
54
+ continue
55
+ }
56
+ if (ch === '"' || ch === "'") {
57
+ quote = ch
58
+ current += ch
59
+ continue
60
+ }
61
+ if (/\s/.test(ch)) {
62
+ if (current) {
63
+ tokens.push(current)
64
+ current = ''
65
+ }
66
+ continue
67
+ }
68
+ current += ch
69
+ }
70
+ if (quote) throw new CodexExtraArgsError('quotes')
71
+ if (current) tokens.push(current)
72
+ return tokens
73
+ }
74
+
75
+ export function parseCodexExtraArgs(raw: string | undefined): string[] {
76
+ const trimmed = (raw ?? '').trim()
77
+ if (!trimmed) return []
78
+ if (trimmed.length > CODEX_EXTRA_ARGS_MAX_CHARS) throw new CodexExtraArgsError('length')
79
+ const unwrapped = unwrapWholeValue(trimmed)
80
+ if (!unwrapped) return []
81
+ const tokens = splitUnquotedWhitespace(unwrapped)
82
+ if (tokens.length > CODEX_EXTRA_ARGS_MAX_TOKENS) throw new CodexExtraArgsError('length')
83
+ return tokens
84
+ }
85
+
86
+ function stripWrappingQuotes(value: string): string {
87
+ if (value.length >= 2) {
88
+ const first = value[0]
89
+ const last = value[value.length - 1]
90
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
91
+ return value.slice(1, -1)
92
+ }
93
+ }
94
+ return value
95
+ }
96
+
97
+ function fusedValueFlag(token: string): string | null {
98
+ for (const flag of VALUE_FLAGS) {
99
+ if (token.startsWith(`${flag}=`)) return flag
100
+ }
101
+ return null
102
+ }
103
+
104
+ type Walked =
105
+ | { kind: 'boolean'; flag: string }
106
+ | { kind: 'value'; flag: string; value: string }
107
+
108
+ function walkCodexExtraArgs(args: string[]): Walked[] {
109
+ const walked: Walked[] = []
110
+ let i = 0
111
+ while (i < args.length) {
112
+ const token = args[i]
113
+ if (BOOLEAN_FLAGS.has(token)) {
114
+ walked.push({ kind: 'boolean', flag: token })
115
+ i += 1
116
+ continue
117
+ }
118
+ const fused = fusedValueFlag(token)
119
+ if (fused) {
120
+ const value = token.slice(fused.length + 1)
121
+ if (!value) throw new CodexExtraArgsError(fused)
122
+ if (value.startsWith('-')) throw new CodexExtraArgsError(fused)
123
+ walked.push({ kind: 'value', flag: fused, value })
124
+ i += 1
125
+ continue
126
+ }
127
+ if (VALUE_FLAGS.has(token)) {
128
+ const value = args[i + 1]
129
+ if (value === undefined) throw new CodexExtraArgsError(token)
130
+ if (value.startsWith('-')) throw new CodexExtraArgsError(token)
131
+ walked.push({ kind: 'value', flag: token, value })
132
+ i += 2
133
+ continue
134
+ }
135
+ throw new CodexExtraArgsError(token)
136
+ }
137
+ return walked
138
+ }
139
+
140
+ function configPayloads(walked: Walked[]): { key: string; value: string }[] {
141
+ const payloads: { key: string; value: string }[] = []
142
+ for (const step of walked) {
143
+ if (step.kind !== 'value' || (step.flag !== '-c' && step.flag !== '--config')) continue
144
+ const eq = step.value.indexOf('=')
145
+ if (eq <= 0) throw new CodexExtraArgsError(`-c ${stripWrappingQuotes(step.value)}`)
146
+ const key = stripWrappingQuotes(step.value.slice(0, eq))
147
+ const value = step.value.slice(eq + 1)
148
+ payloads.push({ key, value })
149
+ }
150
+ return payloads
151
+ }
152
+
153
+ export function extraIncludesFlag(args: string[], flags: string[]): boolean {
154
+ for (const token of args) {
155
+ for (const flag of flags) {
156
+ if (token === flag || token.startsWith(`${flag}=`)) return true
157
+ }
158
+ }
159
+ return false
160
+ }
161
+
162
+ export function extraIncludesConfigKey(args: string[], key: string): boolean {
163
+ const walked = walkCodexExtraArgs(args)
164
+ return configPayloads(walked).some(payload => payload.key === key)
165
+ }
166
+
167
+ export function assertSafeCodexExtraArgs(args: string[]): void {
168
+ if (args.length === 0) return
169
+ const walked = walkCodexExtraArgs(args)
170
+ for (const step of walked) {
171
+ if (!ALLOWED_FLAGS.has(step.flag)) throw new CodexExtraArgsError(step.flag)
172
+ }
173
+ const hasOss = walked.some(step => step.kind === 'boolean' && step.flag === '--oss')
174
+ const hasLocalProvider = extraIncludesFlag(args, ['--local-provider'])
175
+ const payloads = configPayloads(walked)
176
+ const hasOssProvider = payloads.some(payload => payload.key === 'oss_provider')
177
+ const hasModelProvider = payloads.some(payload => payload.key === 'model_provider')
178
+
179
+ if (hasOss && hasModelProvider) throw new CodexExtraArgsError('-c model_provider')
180
+ if (hasOss && !hasLocalProvider && !hasOssProvider) throw new CodexExtraArgsError('--oss')
181
+ if ((hasLocalProvider || hasOssProvider) && !hasOss) {
182
+ throw new CodexExtraArgsError(hasLocalProvider ? '--local-provider' : '-c oss_provider')
183
+ }
184
+
185
+ for (const step of walked) {
186
+ if (step.kind !== 'value') continue
187
+ if (step.flag === '--local-provider') {
188
+ if (!LOCAL_PROVIDER_VALUES.has(stripWrappingQuotes(step.value))) {
189
+ throw new CodexExtraArgsError('--local-provider')
190
+ }
191
+ }
192
+ }
193
+ for (const payload of payloads) {
194
+ if (!ALLOWED_CONFIG_KEYS.has(payload.key)) throw new CodexExtraArgsError(`-c ${payload.key}`)
195
+ if (payload.key === 'oss_provider' && !LOCAL_PROVIDER_VALUES.has(stripWrappingQuotes(payload.value))) {
196
+ throw new CodexExtraArgsError('-c oss_provider')
197
+ }
198
+ }
199
+
200
+ const banned = findBannedPermissionArg(args)
201
+ if (banned !== null) throw new CodexExtraArgsError(banned)
202
+ }
203
+
204
+ export function skipsCosModel(extra: string[]): boolean {
205
+ return extraIncludesFlag(extra, ['--model', '-m']) || extraIncludesConfigKey(extra, 'model')
206
+ }
207
+
208
+ export function skipsCosServiceTier(extra: string[]): boolean {
209
+ return extraIncludesFlag(extra, ['--oss', '--local-provider'])
210
+ || extraIncludesConfigKey(extra, 'model_provider')
211
+ || extraIncludesConfigKey(extra, 'service_tier')
212
+ }
213
+
214
+ export function skipsCosReasoningEffort(extra: string[]): boolean {
215
+ return extraIncludesConfigKey(extra, 'model_reasoning_effort')
216
+ || extraIncludesFlag(extra, ['--oss', '--local-provider'])
217
+ || extraIncludesConfigKey(extra, 'model_provider')
218
+ }
219
+
220
+ export function codexEngineFingerprint(extra: string[]): string {
221
+ return extra.join('\0')
222
+ }