@gotcos/glasses-server 6.38.1 → 6.39.0
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 +21 -1
- package/CHANGELOG.md +35 -0
- package/README.md +14 -1
- package/package.json +2 -2
- package/server/lib/attached-provider-adapter.ts +2 -48
- package/server/lib/banned-permission-args.ts +45 -0
- package/server/lib/claude-bridge.ts +2 -1
- package/server/lib/codex-bridge.ts +49 -4
- package/server/lib/codex-engine-sessions.ts +8 -2
- package/server/lib/codex-extra-args.ts +222 -0
- package/server/lib/even-hub-speaker-role.ts +116 -0
- package/server/lib/fork-thread.ts +2 -2
- package/server/lib/health-static-probes.ts +23 -1
- package/server/lib/model-router.ts +14 -0
- package/server/lib/ollama-bridge.ts +274 -0
- package/server/lib/ollama-catalog.ts +161 -0
- package/server/lib/ollama-run-ledger.ts +178 -0
- package/server/lib/query-job-runtime.ts +8 -2
- package/server/lib/query-job-types.ts +2 -1
- package/server/routes/health.ts +25 -0
- package/server/routes/openai-compat.ts +2 -0
- package/server/routes/transcribe-stream.ts +25 -0
- package/shared/model-preference.ts +25 -1
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,38 @@
|
|
|
1
|
+
## 6.39.0
|
|
2
|
+
|
|
3
|
+
Ollama is a fourth G2 picker, hidden unless a local daemon answers.
|
|
4
|
+
|
|
5
|
+
When `GET http://127.0.0.1:11434/api/tags` succeeds with at least one pulled
|
|
6
|
+
model, glasses and companion Settings show **Ollama**. Queries go to
|
|
7
|
+
`POST /api/chat` on loopback — chat/summarize only, no tools, no vision, no
|
|
8
|
+
attached Continue / Fork, no Live Cues. If the daemon drops, the slot hides and
|
|
9
|
+
a saved `ollama` preference resets to Opus.
|
|
10
|
+
|
|
11
|
+
Optional pin: `COS_OLLAMA_MODEL`. Optional loopback override: `COS_OLLAMA_HOST`
|
|
12
|
+
(LAN / `0.0.0.0` refused). `COS_G2_DEFAULT_MODEL=ollama` is valid when the
|
|
13
|
+
probe is ready.
|
|
14
|
+
|
|
15
|
+
GPT Frontier / Balanced stay Codex CLI → ChatGPT. `COS_CODEX_EXTRA_ARGS` remains
|
|
16
|
+
a Codex operator hatch, not the Ollama product.
|
|
17
|
+
|
|
18
|
+
## 6.38.1
|
|
19
|
+
|
|
20
|
+
GPT Frontier can point at a local Ollama (or LM Studio) engine without a fourth
|
|
21
|
+
G2 provider. Set `COS_CODEX_EXTRA_ARGS` in `~/.cos-glasses/.env`:
|
|
22
|
+
|
|
23
|
+
`COS_CODEX_EXTRA_ARGS=--oss --local-provider ollama --model qwen2.5-coder`
|
|
24
|
+
|
|
25
|
+
Put `--model` inside that string so a Control-keep-alived plist `COS_CODEX_MODEL`
|
|
26
|
+
cannot retarget off the local id. Sessions Continue / Fork / Settings proof
|
|
27
|
+
ignore extra args. A plist-only EXTRA_ARGS dies on the next Update Server.
|
|
28
|
+
|
|
29
|
+
The hatch is allowlisted (`--oss`, `--local-provider`, `--model`, `-c` keys
|
|
30
|
+
`model` / `model_provider` / `oss_provider` / `model_reasoning_effort` /
|
|
31
|
+
`service_tier`). It cannot pass `--sandbox`, `--permission-profile`, or a `-c`
|
|
32
|
+
sandbox key. Codex still runs `--sandbox read-only`; that sandbox does not block
|
|
33
|
+
the CLI's own HTTP to `localhost:11434`. `ollama serve` must be running or Codex
|
|
34
|
+
exits `OSS setup failed`. This is not a COS Ollama product.
|
|
35
|
+
|
|
1
36
|
## 6.38.1
|
|
2
37
|
|
|
3
38
|
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.
|
|
4
|
-
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex,
|
|
3
|
+
"version": "6.39.0",
|
|
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
|
-
|
|
190
|
-
|
|
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({
|
|
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(
|
|
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 !==
|
|
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
|
+
}
|