@gotcos/glasses-server 6.61.6 → 6.62.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 +6 -0
- package/CHANGELOG.md +41 -0
- package/README.md +5 -1
- package/bin/cli.cjs +50 -3
- package/bin/hooks/cos-cursor-observer.cjs +36 -0
- package/bin/hooks/cos-session-hook +46 -11
- package/docs/provider-parity.md +43 -0
- package/managed-runtime-contract.json +1 -0
- package/package.json +5 -2
- package/server/index.ts +135 -16
- package/server/lib/agent-session-binding-store.ts +9 -3
- package/server/lib/agent-session-store.ts +7 -4
- package/server/lib/attached-provider-adapter.ts +158 -45
- package/server/lib/banned-permission-args.ts +42 -1
- package/server/lib/claude-bridge.ts +17 -8
- package/server/lib/claude-hooks-installer.ts +28 -5
- package/server/lib/codex-bridge.ts +17 -2
- package/server/lib/codex-hooks-installer.ts +538 -0
- package/server/lib/codex-live-queue.ts +1 -1
- package/server/lib/codex-model-catalog.ts +49 -2
- package/server/lib/codex-rollout-state.ts +90 -0
- package/server/lib/codex-session-posture.ts +528 -0
- package/server/lib/codex-subagent-activity.ts +119 -0
- package/server/lib/continue-plan.ts +180 -0
- package/server/lib/continue-read-health.ts +26 -0
- package/server/lib/cursor-bridge.ts +26 -6
- package/server/lib/cursor-fork-context.ts +140 -0
- package/server/lib/cursor-model-catalog.ts +62 -5
- package/server/lib/cursor-observer-installer.ts +79 -0
- package/server/lib/cursor-session-model.ts +339 -0
- package/server/lib/cursor-spawn-env.ts +210 -0
- package/server/lib/fork-thread.ts +143 -25
- package/server/lib/health-static-probes.ts +21 -11
- package/server/lib/lens-gist-engines.ts +15 -4
- package/server/lib/lens-gist.ts +1 -1
- package/server/lib/live-cues-cursor.ts +16 -6
- package/server/lib/occupancy-probes.ts +45 -1
- package/server/lib/permission-broker.ts +24 -9
- package/server/lib/provider-actions.ts +149 -0
- package/server/lib/provider-failure-text.ts +27 -0
- package/server/lib/provider-observe.ts +212 -0
- package/server/lib/provider-proof.ts +23 -14
- package/server/lib/query-job-runtime.ts +16 -1
- package/server/lib/query-job-types.ts +2 -1
- package/server/lib/session-act-fields.ts +61 -0
- package/server/lib/session-cancel.ts +95 -4
- package/server/lib/session-continue-facts-real.ts +74 -0
- package/server/lib/session-continue-facts.ts +267 -0
- package/server/lib/session-halt.ts +67 -6
- package/server/lib/session-hook-events.ts +60 -0
- package/server/lib/session-hook-ledger.ts +21 -3
- package/server/lib/session-hook-spool.ts +68 -6
- package/server/lib/session-hooks-runtime.ts +40 -3
- package/server/lib/session-peer-inbox-deps.ts +7 -6
- package/server/lib/session-recommendation.ts +3 -2
- package/server/lib/session-signal-store.ts +81 -3
- package/server/lib/session-stream-events.ts +32 -1
- package/server/lib/session-transcript-watcher.ts +25 -1
- package/server/lib/session-work-observation.ts +46 -0
- package/server/lib/thread-attach-capability.ts +15 -1
- package/server/lib/thread-drain-kick.ts +4 -2
- package/server/lib/thread-occupancy.ts +44 -0
- package/server/lib/thread-turn-queue-deliver.ts +6 -1
- package/server/lib/thread-turn-queue.ts +6 -0
- package/server/lib/turn-request.ts +274 -0
- package/server/routes/agent-session-bindings.ts +224 -22
- package/server/routes/agent-session-stream.ts +26 -7
- package/server/routes/agent-sessions.ts +163 -10
- package/server/routes/health.ts +27 -2
- package/server/routes/session-hooks.ts +60 -3
- package/server/routes/thread-turn-queue.ts +37 -9
- package/server/scripts/hooks-cli.ts +38 -9
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// What a Codex rollout's tail says about the thread RIGHT NOW (6.62.0, plan 1.3 and 1.9), for
|
|
2
|
+
// the session rows: is a turn open, and when did the newest context compaction finish.
|
|
3
|
+
//
|
|
4
|
+
// A DISPLAY HINT ONLY. The write gate keeps its own reading (`codex-turn-clock.ts`, the
|
|
5
|
+
// occupancy probe); nothing here can let a Continue through or hold one back.
|
|
6
|
+
//
|
|
7
|
+
// The turn rule is the queue's (`transcriptTurnVerdict` / `turnFromTail('codex')`): a
|
|
8
|
+
// `task_started` opens a turn, `task_complete` / `turn_complete` / `turn_aborted` close it.
|
|
9
|
+
// It reads the last 512 KiB synchronously, so it is MEMOIZED on (path, mtime, size): a steady
|
|
10
|
+
// list poll re-reads nothing, and only a rollout that changed is read again. A rollout not
|
|
11
|
+
// written inside the hook-active cap is never read at all: whatever its tail says, it is not
|
|
12
|
+
// working now (the cap decides), and a cold list stays free.
|
|
13
|
+
|
|
14
|
+
import { statSync } from 'node:fs'
|
|
15
|
+
import { turnFromTail, type TurnFromTail } from './session-stream-events.js'
|
|
16
|
+
import { TURN_END_TAIL_BYTES, readTranscriptTailLines } from './thread-turn-queue-store.js'
|
|
17
|
+
|
|
18
|
+
/** Hook or transcript evidence counts as "working" for this long after the last event (plan 1.1-1.3, W5). */
|
|
19
|
+
export const HOOK_ACTIVE_CAP_MS = 5 * 60_000
|
|
20
|
+
|
|
21
|
+
export interface CodexRolloutFacts {
|
|
22
|
+
/** The tail's verdict; null when the tail was not read (cold) or could not be. */
|
|
23
|
+
reason: TurnFromTail['reason'] | null
|
|
24
|
+
/** A turn is open in the tail AND the file was written inside the cap. */
|
|
25
|
+
open: boolean
|
|
26
|
+
mtimeMs: number
|
|
27
|
+
/** When the newest `ContextCompaction` item completed, from the tail; null when none is there. */
|
|
28
|
+
compactedAt: number | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const MEMO_MAX = 256
|
|
32
|
+
const memo = new Map<string, { mtimeMs: number; size: number; reason: TurnFromTail['reason'] | null; compactedAt: number | null }>()
|
|
33
|
+
let reads = 0
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The newest completed context compaction in a tail. Codex writes the `ContextCompaction` item
|
|
37
|
+
* (and its `compacted` replacement) only when a compaction is OVER: nothing in a rollout marks
|
|
38
|
+
* one starting, so this can close a compaction the PreCompact hook opened, never open one.
|
|
39
|
+
*/
|
|
40
|
+
export function newestCompactionAt(lines: readonly string[]): number | null {
|
|
41
|
+
let newest: number | null = null
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
if (!line.includes('ContextCompaction')) continue
|
|
44
|
+
let record: Record<string, unknown>
|
|
45
|
+
try { record = JSON.parse(line) as Record<string, unknown> } catch { continue }
|
|
46
|
+
const payload = record.payload as Record<string, unknown> | undefined
|
|
47
|
+
const item = payload?.item as Record<string, unknown> | undefined
|
|
48
|
+
if (record.type !== 'event_msg' || payload?.type !== 'item_completed' || item?.type !== 'ContextCompaction') continue
|
|
49
|
+
const at = typeof payload.completed_at_ms === 'number' ? payload.completed_at_ms : Date.parse(String(record.timestamp))
|
|
50
|
+
if (Number.isFinite(at) && (newest === null || at > newest)) newest = at
|
|
51
|
+
}
|
|
52
|
+
return newest
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function codexRolloutFacts(path: string | null | undefined, now = Date.now()): CodexRolloutFacts | null {
|
|
56
|
+
if (!path) return null
|
|
57
|
+
let mtimeMs: number
|
|
58
|
+
let size: number
|
|
59
|
+
try {
|
|
60
|
+
const st = statSync(path)
|
|
61
|
+
mtimeMs = st.mtimeMs
|
|
62
|
+
size = st.size
|
|
63
|
+
} catch {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
const fresh = now - mtimeMs <= HOOK_ACTIVE_CAP_MS
|
|
67
|
+
if (!fresh) return { reason: null, open: false, mtimeMs, compactedAt: null }
|
|
68
|
+
let hit = memo.get(path)
|
|
69
|
+
if (!hit || hit.mtimeMs !== mtimeMs || hit.size !== size) {
|
|
70
|
+
const lines = readTranscriptTailLines(path, TURN_END_TAIL_BYTES)
|
|
71
|
+
reads++
|
|
72
|
+
hit = { mtimeMs, size, reason: lines === null ? null : turnFromTail('codex', lines).reason, compactedAt: lines === null ? null : newestCompactionAt(lines) }
|
|
73
|
+
memo.delete(path)
|
|
74
|
+
if (memo.size >= MEMO_MAX) {
|
|
75
|
+
const oldest = memo.keys().next().value
|
|
76
|
+
if (oldest !== undefined) memo.delete(oldest)
|
|
77
|
+
}
|
|
78
|
+
memo.set(path, hit)
|
|
79
|
+
}
|
|
80
|
+
return { reason: hit.reason, open: hit.reason === 'prompt_open', mtimeMs, compactedAt: hit.compactedAt }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Test seams. */
|
|
84
|
+
export function __resetCodexRolloutStateForTests(): void {
|
|
85
|
+
memo.clear()
|
|
86
|
+
reads = 0
|
|
87
|
+
}
|
|
88
|
+
export function codexRolloutReads(): number {
|
|
89
|
+
return reads
|
|
90
|
+
}
|
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
// What a Codex session runs with, read from its own rollout (6.62.0, plan 3.2).
|
|
2
|
+
//
|
|
3
|
+
// WHY. `codex exec resume <id>` does NOT keep the session's settings. Canary C11
|
|
4
|
+
// (2026-10-05): a thread created `-s danger-full-access -m gpt-5.6-luna` (effort low)
|
|
5
|
+
// resumed with no flags ran turn 2 on `gpt-6.1-sol high workspace-write`, which are the
|
|
6
|
+
// CONFIG defaults. So a Continue that should carry the session's own permissions (D1)
|
|
7
|
+
// has to pass the session's model, effort and sandbox explicitly, and those live in the
|
|
8
|
+
// rollout's newest `turn_context` row.
|
|
9
|
+
//
|
|
10
|
+
// WHERE THAT ROW IS. Not in the tail a list read takes. Desktop rollout 01a10197 is 297 MB;
|
|
11
|
+
// its newest `turn_context` sat 3.23 MB from the end, with gaps up to 16 MB between them
|
|
12
|
+
// (validation W3). So the read scans BACKWARDS in 1 MB chunks up to a 64 MB cap, and the
|
|
13
|
+
// answer is cached by (path, size): rollouts only append, so the next read scans just the
|
|
14
|
+
// bytes written since (plus a small overlap for a row that was mid-write last time).
|
|
15
|
+
//
|
|
16
|
+
// WHAT IT NEVER DOES. It never broadens. No posture, or a posture this build does not
|
|
17
|
+
// recognise, plans the 6.61 sandbox (`getCodexTrustMode()`, normally read-only) with an
|
|
18
|
+
// honest note. `danger-full-access` passes through ONLY when the session ran with it AND
|
|
19
|
+
// Codex itself already trusts the folder in `~/.codex/config.toml`. Codex writes a permanent
|
|
20
|
+
// `[projects."…"] trust_level = "trusted"` entry when a NEW session starts with a writable
|
|
21
|
+
// sandbox in an untrusted folder (canaries C11/C12/K4; a resume does not write one, K4), and
|
|
22
|
+
// a trusted entry loads that repo's own hooks and config, so COS never relies on it.
|
|
23
|
+
//
|
|
24
|
+
// WHICH FOLDER (6.62.0 /qa W5, canary K7). Codex trusts the cwd itself or its GIT ROOT, not
|
|
25
|
+
// any parent: a session in a nested repo under a trusted folder wrote its own entry. So the
|
|
26
|
+
// rule is: trusted iff the cwd, or its `git rev-parse --show-toplevel`, is a trusted entry;
|
|
27
|
+
// a LINKED worktree resolves to its MAIN worktree (`git worktree list --porcelain`, first
|
|
28
|
+
// entry), never `--git-common-dir`'s parent (this repo's git dir lives outside the tree).
|
|
29
|
+
// The config is parsed read-only, with a regex, and never written.
|
|
30
|
+
|
|
31
|
+
import { open, stat } from 'node:fs/promises'
|
|
32
|
+
import { readFileSync } from 'node:fs'
|
|
33
|
+
import { homedir } from 'node:os'
|
|
34
|
+
import { join } from 'node:path'
|
|
35
|
+
|
|
36
|
+
/** One backward read. */
|
|
37
|
+
export const POSTURE_CHUNK_BYTES = 1024 * 1024
|
|
38
|
+
/** How far back a cold read looks before it gives up and plans the fallback. */
|
|
39
|
+
export const POSTURE_SCAN_CAP_BYTES = 64 * 1024 * 1024
|
|
40
|
+
/** Re-read before the cached size, so a row mid-write at the last read is seen whole. */
|
|
41
|
+
const POSTURE_REREAD_OVERLAP_BYTES = 64 * 1024
|
|
42
|
+
/** A `turn_context` row is about 4 KB; any line longer than this is something else. */
|
|
43
|
+
const MAX_TURN_CONTEXT_LINE_BYTES = 256 * 1024
|
|
44
|
+
const MAX_CACHE_ENTRIES = 256
|
|
45
|
+
const TURN_CONTEXT_MARKER = Buffer.from('"type":"turn_context"')
|
|
46
|
+
|
|
47
|
+
export type CodexSandboxType = 'read-only' | 'workspace-write' | 'danger-full-access'
|
|
48
|
+
|
|
49
|
+
export interface CodexSessionPosture {
|
|
50
|
+
model: string | null
|
|
51
|
+
effort: string | null
|
|
52
|
+
sandboxType: CodexSandboxType | null
|
|
53
|
+
/** `sandbox_policy.network_access` for workspace-write; true for danger-full-access. */
|
|
54
|
+
networkAccess: boolean | null
|
|
55
|
+
approvalPolicy: string | null
|
|
56
|
+
cwd: string | null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/
|
|
60
|
+
const EFFORT_RE = /^[a-z]{2,16}$/
|
|
61
|
+
|
|
62
|
+
/** One rollout row to a posture, or null when it is not a usable `turn_context`. */
|
|
63
|
+
export function parseTurnContextLine(line: string): CodexSessionPosture | null {
|
|
64
|
+
let row: any
|
|
65
|
+
try {
|
|
66
|
+
row = JSON.parse(line)
|
|
67
|
+
} catch {
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
if (!row || typeof row !== 'object' || row.type !== 'turn_context') return null
|
|
71
|
+
const payload = row.payload
|
|
72
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null
|
|
73
|
+
const policy = payload.sandbox_policy
|
|
74
|
+
const rawType = policy && typeof policy === 'object' ? policy.type : null
|
|
75
|
+
const sandboxType: CodexSandboxType | null = rawType === 'read-only' || rawType === 'workspace-write' || rawType === 'danger-full-access'
|
|
76
|
+
? rawType
|
|
77
|
+
: null
|
|
78
|
+
let networkAccess: boolean | null = null
|
|
79
|
+
if (sandboxType === 'danger-full-access') networkAccess = true
|
|
80
|
+
else if (sandboxType === 'workspace-write' && typeof policy.network_access === 'boolean') networkAccess = policy.network_access
|
|
81
|
+
else if (sandboxType === 'read-only') networkAccess = false
|
|
82
|
+
const model = typeof payload.model === 'string' && MODEL_RE.test(payload.model) ? payload.model : null
|
|
83
|
+
const effortRaw = typeof payload.effort === 'string'
|
|
84
|
+
? payload.effort
|
|
85
|
+
: typeof payload?.collaboration_mode?.settings?.reasoning_effort === 'string'
|
|
86
|
+
? payload.collaboration_mode.settings.reasoning_effort
|
|
87
|
+
: null
|
|
88
|
+
const effort = effortRaw && EFFORT_RE.test(effortRaw) ? effortRaw : null
|
|
89
|
+
return {
|
|
90
|
+
model,
|
|
91
|
+
effort,
|
|
92
|
+
sandboxType,
|
|
93
|
+
networkAccess,
|
|
94
|
+
approvalPolicy: typeof payload.approval_policy === 'string' ? payload.approval_policy.slice(0, 32) : null,
|
|
95
|
+
cwd: typeof payload.cwd === 'string' && payload.cwd.startsWith('/') ? payload.cwd : null,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The newest `turn_context` row in `[lower, upper)` of an open file, scanning backwards.
|
|
101
|
+
* Null when there is none. A line whose start lies before `lower` is not looked at: it
|
|
102
|
+
* cannot be judged whole.
|
|
103
|
+
*/
|
|
104
|
+
async function scanBackwards(
|
|
105
|
+
handle: { read: (buffer: Buffer, offset: number, length: number, position: number) => Promise<{ bytesRead: number }> },
|
|
106
|
+
lower: number,
|
|
107
|
+
upper: number,
|
|
108
|
+
): Promise<CodexSessionPosture | null> {
|
|
109
|
+
let end = upper
|
|
110
|
+
// Bytes after the first newline of the chunk we just read and before the newline that
|
|
111
|
+
// ended the last line we judged: the start of a line that began in an earlier chunk.
|
|
112
|
+
let carry: Buffer = Buffer.alloc(0)
|
|
113
|
+
// The carry outgrew any turn_context row; skip until that line's start is found.
|
|
114
|
+
let oversized = false
|
|
115
|
+
while (end > lower) {
|
|
116
|
+
const start = Math.max(lower, end - POSTURE_CHUNK_BYTES)
|
|
117
|
+
const chunk = Buffer.alloc(end - start)
|
|
118
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, start)
|
|
119
|
+
const data = bytesRead === chunk.length ? chunk : chunk.subarray(0, bytesRead)
|
|
120
|
+
const buffer = carry.length > 0 ? Buffer.concat([data, carry]) : data
|
|
121
|
+
// The tail of THIS buffer (after its last newline) is the line that was carried.
|
|
122
|
+
let lineEnd = buffer.length
|
|
123
|
+
let cursor = buffer.lastIndexOf(0x0a, lineEnd - 1)
|
|
124
|
+
while (cursor >= 0) {
|
|
125
|
+
const line = buffer.subarray(cursor + 1, lineEnd)
|
|
126
|
+
if (!oversized && line.length <= MAX_TURN_CONTEXT_LINE_BYTES && line.indexOf(TURN_CONTEXT_MARKER) >= 0) {
|
|
127
|
+
const posture = parseTurnContextLine(line.toString('utf8'))
|
|
128
|
+
if (posture) return posture
|
|
129
|
+
}
|
|
130
|
+
oversized = false
|
|
131
|
+
lineEnd = cursor
|
|
132
|
+
cursor = lineEnd > 0 ? buffer.lastIndexOf(0x0a, lineEnd - 1) : -1
|
|
133
|
+
}
|
|
134
|
+
// What is left (before the first newline) began earlier; carry it, within bounds.
|
|
135
|
+
const rest = buffer.subarray(0, lineEnd)
|
|
136
|
+
if (start === 0) {
|
|
137
|
+
// The file's first line: whole by definition.
|
|
138
|
+
if (!oversized && rest.length <= MAX_TURN_CONTEXT_LINE_BYTES && rest.indexOf(TURN_CONTEXT_MARKER) >= 0) {
|
|
139
|
+
return parseTurnContextLine(rest.toString('utf8'))
|
|
140
|
+
}
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
if (oversized || rest.length > MAX_TURN_CONTEXT_LINE_BYTES) {
|
|
144
|
+
carry = Buffer.alloc(0)
|
|
145
|
+
oversized = true
|
|
146
|
+
} else {
|
|
147
|
+
carry = Buffer.from(rest)
|
|
148
|
+
}
|
|
149
|
+
end = start
|
|
150
|
+
}
|
|
151
|
+
return null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface PostureCacheEntry {
|
|
155
|
+
size: number
|
|
156
|
+
posture: CodexSessionPosture | null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const postureCache = new Map<string, PostureCacheEntry>()
|
|
160
|
+
|
|
161
|
+
/** Tests only. */
|
|
162
|
+
export function __resetCodexPostureCacheForTests(): void {
|
|
163
|
+
postureCache.clear()
|
|
164
|
+
postureReadStats.reads = 0
|
|
165
|
+
postureReadStats.bytes = 0
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** How much work the reader did: lets a test prove the cache and the incremental read. */
|
|
169
|
+
export const postureReadStats = { reads: 0, bytes: 0 }
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The session's newest posture, or null when the rollout carries none within the cap or
|
|
173
|
+
* cannot be read. Never throws.
|
|
174
|
+
*/
|
|
175
|
+
export async function readCodexSessionPosture(
|
|
176
|
+
path: string | null | undefined,
|
|
177
|
+
options: { capBytes?: number } = {},
|
|
178
|
+
): Promise<CodexSessionPosture | null> {
|
|
179
|
+
// The cap is injectable so a test can prove it bounds the scan without a 64 MB fixture.
|
|
180
|
+
const cap = typeof options.capBytes === 'number' && options.capBytes > 0 ? options.capBytes : POSTURE_SCAN_CAP_BYTES
|
|
181
|
+
if (typeof path !== 'string' || !path.startsWith('/')) return null
|
|
182
|
+
let size: number
|
|
183
|
+
try {
|
|
184
|
+
const st = await stat(path)
|
|
185
|
+
if (!st.isFile()) return null
|
|
186
|
+
size = st.size
|
|
187
|
+
} catch {
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
const cached = postureCache.get(path)
|
|
191
|
+
if (cached && cached.size === size) return cached.posture
|
|
192
|
+
|
|
193
|
+
let lower = Math.max(0, size - cap)
|
|
194
|
+
// Grown since the last read: only the new bytes (plus the overlap) can hold a newer row.
|
|
195
|
+
const incremental = !!cached && cached.size < size
|
|
196
|
+
if (incremental) lower = Math.max(lower, cached!.size - POSTURE_REREAD_OVERLAP_BYTES)
|
|
197
|
+
|
|
198
|
+
let found: CodexSessionPosture | null = null
|
|
199
|
+
try {
|
|
200
|
+
const handle = await open(path, 'r')
|
|
201
|
+
try {
|
|
202
|
+
postureReadStats.reads += 1
|
|
203
|
+
postureReadStats.bytes += size - lower
|
|
204
|
+
found = await scanBackwards(handle, lower, size)
|
|
205
|
+
} finally {
|
|
206
|
+
await handle.close()
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
return cached?.posture ?? null
|
|
210
|
+
}
|
|
211
|
+
const posture = found ?? (incremental ? cached!.posture : null)
|
|
212
|
+
postureCache.delete(path)
|
|
213
|
+
postureCache.set(path, { size, posture })
|
|
214
|
+
while (postureCache.size > MAX_CACHE_ENTRIES) {
|
|
215
|
+
const oldest = postureCache.keys().next()
|
|
216
|
+
if (oldest.done) break
|
|
217
|
+
postureCache.delete(oldest.value)
|
|
218
|
+
}
|
|
219
|
+
return posture
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// Trust: which folders Codex itself already trusts
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
/** `~/.codex/config.toml`, honouring CODEX_HOME like the rest of the server. */
|
|
227
|
+
export function codexConfigPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
228
|
+
return join(env.CODEX_HOME?.trim() || join(homedir(), '.codex'), 'config.toml')
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The live config's text, or null. Read-only; never written by COS. */
|
|
232
|
+
export function readCodexConfigText(path = codexConfigPath()): string | null {
|
|
233
|
+
try {
|
|
234
|
+
return readFileSync(path, 'utf8')
|
|
235
|
+
} catch {
|
|
236
|
+
return null
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function unescapeTomlBasic(value: string): string {
|
|
241
|
+
return value.replace(/\\(["\\])/g, '$1')
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Every `[projects."<path>"]` table whose `trust_level` is `"trusted"`. A regex parse on
|
|
246
|
+
* purpose (the server has no TOML dependency): table headers in basic or literal strings,
|
|
247
|
+
* the key read only up to the next table header.
|
|
248
|
+
*/
|
|
249
|
+
export function trustedCodexProjects(configText: string | null): string[] {
|
|
250
|
+
if (typeof configText !== 'string' || configText.length === 0) return []
|
|
251
|
+
const trusted: string[] = []
|
|
252
|
+
const header = /^\s*\[\s*projects\s*\.\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)')\s*\]\s*$/gm
|
|
253
|
+
let match: RegExpExecArray | null
|
|
254
|
+
while ((match = header.exec(configText)) !== null) {
|
|
255
|
+
const projectPath = match[1] !== undefined ? unescapeTomlBasic(match[1]) : match[2]!
|
|
256
|
+
const bodyStart = header.lastIndex
|
|
257
|
+
const next = configText.slice(bodyStart).search(/^\s*\[/m)
|
|
258
|
+
const body = next < 0 ? configText.slice(bodyStart) : configText.slice(bodyStart, bodyStart + next)
|
|
259
|
+
if (/^\s*trust_level\s*=\s*["']trusted["']\s*(?:#.*)?$/m.test(body) && projectPath.startsWith('/')) {
|
|
260
|
+
trusted.push(projectPath.replace(/\/+$/, '') || '/')
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return trusted
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const normalizeFolder = (path: string): string => path.replace(/\/+$/, '') || '/'
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* K7's rule: trusted iff the cwd itself, or its git trust root (the repo's top level, or the
|
|
270
|
+
* MAIN worktree for a linked one; `codexGitTrustRoot`), is a trusted `[projects]` entry.
|
|
271
|
+
* Exact folders only: a trusted parent does not cover a repo nested under it.
|
|
272
|
+
*/
|
|
273
|
+
export function codexFolderTrusted(cwd: string, gitTrustRoot: string | null, configText: string | null): boolean {
|
|
274
|
+
if (typeof cwd !== 'string' || !cwd.startsWith('/')) return false
|
|
275
|
+
const trusted = new Set(trustedCodexProjects(configText).map(normalizeFolder))
|
|
276
|
+
if (trusted.has(normalizeFolder(cwd))) return true
|
|
277
|
+
return typeof gitTrustRoot === 'string' && gitTrustRoot.startsWith('/') && trusted.has(normalizeFolder(gitTrustRoot))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// The git trust root (K7), bounded in time and cached
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
/** One git call's budget. A folder git cannot answer for in this long is not a repo here. */
|
|
285
|
+
export const CODEX_GIT_TIMEOUT_MS = 2_000
|
|
286
|
+
const GIT_ROOT_TTL_MS = 60_000
|
|
287
|
+
export type GitRunner = (args: readonly string[], cwd: string, timeoutMs: number) => Promise<string>
|
|
288
|
+
export type GitRunnerSync = (args: readonly string[], cwd: string, timeoutMs: number) => string
|
|
289
|
+
|
|
290
|
+
const gitRootCache = new Map<string, { at: number; root: string | null }>()
|
|
291
|
+
|
|
292
|
+
/** Tests only. */
|
|
293
|
+
export function __resetCodexGitRootCacheForTests(): void {
|
|
294
|
+
gitRootCache.clear()
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** From `git rev-parse --show-toplevel` and `git worktree list --porcelain` output to the root. */
|
|
298
|
+
export function gitTrustRootFrom(toplevel: string, worktreeList: string | null): string | null {
|
|
299
|
+
const top = toplevel.trim()
|
|
300
|
+
if (!top.startsWith('/')) return null
|
|
301
|
+
// The first `worktree <path>` record is the MAIN worktree, whichever one asked.
|
|
302
|
+
const main = typeof worktreeList === 'string' ? /^worktree (.+)$/m.exec(worktreeList)?.[1]?.trim() : undefined
|
|
303
|
+
return main && main.startsWith('/') ? main : top
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function cachedRoot(cwd: string, now: number): { hit: boolean; root: string | null } {
|
|
307
|
+
const entry = gitRootCache.get(cwd)
|
|
308
|
+
if (entry && now - entry.at < GIT_ROOT_TTL_MS) return { hit: true, root: entry.root }
|
|
309
|
+
return { hit: false, root: null }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function rememberRoot(cwd: string, root: string | null, now: number): string | null {
|
|
313
|
+
gitRootCache.delete(cwd)
|
|
314
|
+
gitRootCache.set(cwd, { at: now, root })
|
|
315
|
+
while (gitRootCache.size > 256) {
|
|
316
|
+
const oldest = gitRootCache.keys().next()
|
|
317
|
+
if (oldest.done) break
|
|
318
|
+
gitRootCache.delete(oldest.value)
|
|
319
|
+
}
|
|
320
|
+
return root
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The git trust root for `cwd`, or null (not a repo, git missing, or too slow). Never throws. */
|
|
324
|
+
export async function codexGitTrustRoot(cwd: string, run: GitRunner, now = Date.now()): Promise<string | null> {
|
|
325
|
+
if (typeof cwd !== 'string' || !cwd.startsWith('/')) return null
|
|
326
|
+
const hit = cachedRoot(cwd, now)
|
|
327
|
+
if (hit.hit) return hit.root
|
|
328
|
+
let root: string | null = null
|
|
329
|
+
try {
|
|
330
|
+
const top = await run(['rev-parse', '--show-toplevel'], cwd, CODEX_GIT_TIMEOUT_MS)
|
|
331
|
+
let list: string | null = null
|
|
332
|
+
try { list = await run(['worktree', 'list', '--porcelain'], cwd, CODEX_GIT_TIMEOUT_MS) } catch { list = null }
|
|
333
|
+
root = gitTrustRootFrom(top, list)
|
|
334
|
+
} catch {
|
|
335
|
+
root = null
|
|
336
|
+
}
|
|
337
|
+
return rememberRoot(cwd, root, now)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** The same, synchronously, for the adapter's spawn-time re-check (a fresh cache entry answers). */
|
|
341
|
+
export function codexGitTrustRootSync(cwd: string, run: GitRunnerSync, now = Date.now()): string | null {
|
|
342
|
+
if (typeof cwd !== 'string' || !cwd.startsWith('/')) return null
|
|
343
|
+
const hit = cachedRoot(cwd, now)
|
|
344
|
+
if (hit.hit) return hit.root
|
|
345
|
+
let root: string | null = null
|
|
346
|
+
try {
|
|
347
|
+
const top = run(['rev-parse', '--show-toplevel'], cwd, CODEX_GIT_TIMEOUT_MS)
|
|
348
|
+
let list: string | null = null
|
|
349
|
+
try { list = run(['worktree', 'list', '--porcelain'], cwd, CODEX_GIT_TIMEOUT_MS) } catch { list = null }
|
|
350
|
+
root = gitTrustRootFrom(top, list)
|
|
351
|
+
} catch {
|
|
352
|
+
root = null
|
|
353
|
+
}
|
|
354
|
+
return rememberRoot(cwd, root, now)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
// The plan: what one Codex Continue runs with
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
export interface CodexContinuePlan {
|
|
362
|
+
provider: 'codex'
|
|
363
|
+
sandbox: CodexSandboxType
|
|
364
|
+
/** `-m`, only when the model is in the full app-server list. */
|
|
365
|
+
model: string | null
|
|
366
|
+
/** `-c model_reasoning_effort="…"`. */
|
|
367
|
+
effort: string | null
|
|
368
|
+
/** `-c sandbox_workspace_write.network_access=…`, for workspace-write only. */
|
|
369
|
+
networkAccess: boolean | null
|
|
370
|
+
/** `session`: read from the rollout. `fallback`: the 6.61 sandbox, nothing else. */
|
|
371
|
+
source: 'session' | 'fallback'
|
|
372
|
+
/** At most 60 characters (plan 3.12). */
|
|
373
|
+
note: string
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export interface CodexPlanDeps {
|
|
377
|
+
/** The 6.61 posture: `getCodexTrustMode()`. */
|
|
378
|
+
fallbackSandbox: () => 'read-only' | 'workspace-write'
|
|
379
|
+
/** The config text, read at plan time (never cached across turns). */
|
|
380
|
+
configText: () => string | null
|
|
381
|
+
/** 6.62.0 /qa (W5): the cwd's git trust root, already resolved (`codexGitTrustRoot`). */
|
|
382
|
+
gitTrustRoot?: string | null
|
|
383
|
+
/** Is this model in the full app-server list? */
|
|
384
|
+
isKnownModel: (model: string) => boolean
|
|
385
|
+
/** A display name for a known model (`gpt-6.1-sol` to `GPT-6.1 Sol`). */
|
|
386
|
+
modelLabel?: (model: string) => string | null
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export const CONTINUE_NOTE_MAX = 60
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Measured on the lens (integrator, 2026-10-05): a real note fits one line (the longest,
|
|
393
|
+
* "This Codex session's full access, GPT-6.1 Sol High.", is 440 of ~546 px), but 60
|
|
394
|
+
* characters of wide glyphs can wrap two or three. So a note that would pass this many
|
|
395
|
+
* characters with the model's DISPLAY name names the model by its short id instead.
|
|
396
|
+
*/
|
|
397
|
+
export const CONTINUE_NOTE_SOFT_MAX = 45
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* A note that names a model: the display-name form when it stays within the soft budget,
|
|
401
|
+
* else the short-id form, else the model-free fallback. Never past CONTINUE_NOTE_MAX.
|
|
402
|
+
*/
|
|
403
|
+
export function fitModelNote(
|
|
404
|
+
template: (model: string) => string,
|
|
405
|
+
display: string | null,
|
|
406
|
+
shortId: string | null,
|
|
407
|
+
fallback: string,
|
|
408
|
+
): string {
|
|
409
|
+
const pretty = display ? template(display) : null
|
|
410
|
+
if (pretty && pretty.length <= CONTINUE_NOTE_SOFT_MAX) return pretty
|
|
411
|
+
const short = shortId ? template(shortId) : null
|
|
412
|
+
if (short && short.length <= CONTINUE_NOTE_MAX) return short
|
|
413
|
+
if (!shortId && pretty && pretty.length <= CONTINUE_NOTE_MAX) return pretty
|
|
414
|
+
return fitNote(fallback)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** The first candidate that fits the lens budget. The last one must always fit. */
|
|
418
|
+
export function fitNote(...candidates: Array<string | null | undefined>): string {
|
|
419
|
+
for (const candidate of candidates) {
|
|
420
|
+
if (typeof candidate === 'string' && candidate.length > 0 && candidate.length <= CONTINUE_NOTE_MAX) return candidate
|
|
421
|
+
}
|
|
422
|
+
const last = candidates.filter((c): c is string => typeof c === 'string' && c.length > 0).pop() ?? ''
|
|
423
|
+
return last.slice(0, CONTINUE_NOTE_MAX)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const EFFORT_LABELS: Record<string, string> = {
|
|
427
|
+
minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra High', max: 'Max', ultra: 'Ultra',
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function effortLabel(effort: string | null): string | null {
|
|
431
|
+
if (!effort) return null
|
|
432
|
+
return EFFORT_LABELS[effort] ?? effort.charAt(0).toUpperCase() + effort.slice(1)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** `gpt-6.1-sol` to `GPT-6.1 Sol`, used when the catalog has no display name. */
|
|
436
|
+
export function defaultCodexModelLabel(model: string): string {
|
|
437
|
+
return model
|
|
438
|
+
.split('-')
|
|
439
|
+
.map((part, index) => (index === 0 && /^gpt$/i.test(part) ? 'GPT' : part.charAt(0).toUpperCase() + part.slice(1)))
|
|
440
|
+
.join('-')
|
|
441
|
+
.replace(/^GPT-(\d+(?:\.\d+)*)-/, 'GPT-$1 ')
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* The plan for one Codex Continue. Pure over its deps: every branch is a test.
|
|
446
|
+
*
|
|
447
|
+
* `posture` null (unreadable, or no `turn_context` within the cap) or with no sandbox this
|
|
448
|
+
* build recognises: the fallback, never broader than 6.61 and never the config defaults.
|
|
449
|
+
*/
|
|
450
|
+
export function planCodexContinue(posture: CodexSessionPosture | null, cwd: string, deps: CodexPlanDeps): CodexContinuePlan {
|
|
451
|
+
const fallback = (): CodexContinuePlan => {
|
|
452
|
+
let sandbox: 'read-only' | 'workspace-write' = 'read-only'
|
|
453
|
+
try {
|
|
454
|
+
sandbox = deps.fallbackSandbox() === 'workspace-write' ? 'workspace-write' : 'read-only'
|
|
455
|
+
} catch {
|
|
456
|
+
sandbox = 'read-only'
|
|
457
|
+
}
|
|
458
|
+
return {
|
|
459
|
+
provider: 'codex',
|
|
460
|
+
sandbox,
|
|
461
|
+
model: null,
|
|
462
|
+
effort: null,
|
|
463
|
+
networkAccess: null,
|
|
464
|
+
source: 'fallback',
|
|
465
|
+
note: sandbox === 'read-only' ? 'Read-only: session settings unreadable.' : 'Workspace write: session settings unreadable.',
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (!posture || posture.sandboxType === null) return fallback()
|
|
469
|
+
|
|
470
|
+
let known = false
|
|
471
|
+
try {
|
|
472
|
+
known = posture.model !== null && deps.isKnownModel(posture.model) === true
|
|
473
|
+
} catch {
|
|
474
|
+
known = false
|
|
475
|
+
}
|
|
476
|
+
const model = known ? posture.model : null
|
|
477
|
+
const effort = posture.effort
|
|
478
|
+
let label: string | null = null
|
|
479
|
+
if (model) {
|
|
480
|
+
try {
|
|
481
|
+
label = deps.modelLabel?.(model) ?? null
|
|
482
|
+
} catch {
|
|
483
|
+
label = null
|
|
484
|
+
}
|
|
485
|
+
label = label || defaultCodexModelLabel(model)
|
|
486
|
+
}
|
|
487
|
+
// Display form (`GPT-6.1 Sol High`) and short form (`gpt-6.1-sol high`), for fitModelNote.
|
|
488
|
+
const display = model ? [label, effortLabel(effort)].filter(Boolean).join(' ') : 'Codex default model'
|
|
489
|
+
const shortId = model ? [model, effort].filter(Boolean).join(' ') : null
|
|
490
|
+
|
|
491
|
+
if (posture.sandboxType === 'danger-full-access') {
|
|
492
|
+
let trusted = false
|
|
493
|
+
try {
|
|
494
|
+
trusted = codexFolderTrusted(cwd, deps.gitTrustRoot ?? null, deps.configText())
|
|
495
|
+
} catch {
|
|
496
|
+
trusted = false
|
|
497
|
+
}
|
|
498
|
+
if (trusted) {
|
|
499
|
+
return {
|
|
500
|
+
provider: 'codex', sandbox: 'danger-full-access', model, effort, networkAccess: true, source: 'session',
|
|
501
|
+
note: fitModelNote(m => `This Codex session's full access, ${m}.`, display, shortId, "This Codex session's full access."),
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// The session ran with full access, but Codex has not trusted this folder: passing it
|
|
505
|
+
// would make Codex trust it permanently. Workspace write, said plainly.
|
|
506
|
+
return {
|
|
507
|
+
provider: 'codex', sandbox: 'workspace-write', model, effort, networkAccess: true, source: 'session',
|
|
508
|
+
note: fitNote('Workspace write: Codex has not trusted this folder.'),
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if (posture.sandboxType === 'workspace-write') {
|
|
512
|
+
return {
|
|
513
|
+
provider: 'codex', sandbox: 'workspace-write', model, effort,
|
|
514
|
+
networkAccess: posture.networkAccess, source: 'session',
|
|
515
|
+
note: fitModelNote(m => `This Codex session's workspace access, ${m}.`, display, shortId, "This Codex session's workspace access."),
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return {
|
|
519
|
+
provider: 'codex', sandbox: 'read-only', model, effort, networkAccess: null, source: 'session',
|
|
520
|
+
note: fitModelNote(m => `Read-only, as this Codex session runs, ${m}.`, display, shortId, 'Read-only, as this Codex session runs.'),
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** `reported_model` for a Codex row (plan 1.8): `<model> <effort>`, or null. */
|
|
525
|
+
export function codexReportedModel(posture: CodexSessionPosture | null): string | null {
|
|
526
|
+
if (!posture?.model) return null
|
|
527
|
+
return posture.effort ? `${posture.model} ${posture.effort}` : posture.model
|
|
528
|
+
}
|