@gotcos/glasses-server 6.27.13 → 6.29.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/CHANGELOG.md +59 -0
- package/package.json +1 -1
- package/server/index.ts +186 -0
- package/server/lib/agent-session-binding-registry.ts +1225 -0
- package/server/lib/agent-session-binding-store.ts +327 -0
- package/server/lib/agent-session-ownership-store.ts +395 -0
- package/server/lib/attached-provider-adapter.ts +1223 -0
- package/server/lib/attached-workspace.ts +197 -0
- package/server/lib/fork-thread.ts +957 -0
- package/server/lib/native-head.ts +649 -0
- package/server/lib/native-thread-id.ts +23 -0
- package/server/lib/occupancy-probes.ts +444 -0
- package/server/lib/query-job-runtime.ts +24 -0
- package/server/lib/thread-attach-capability.ts +200 -0
- package/server/lib/thread-occupancy.ts +367 -0
- package/server/routes/agent-session-bindings.ts +2024 -0
- package/server/routes/health.ts +24 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
// The real filesystem and process probes behind the thread-occupancy detector.
|
|
2
|
+
//
|
|
3
|
+
// `OccupancyProbes` in thread-occupancy.ts had zero implementations, so the
|
|
4
|
+
// detector could not actually run against this machine. This file is the only
|
|
5
|
+
// place that touches the disk or spawns a process on its behalf.
|
|
6
|
+
//
|
|
7
|
+
// EVERY FUNCTION HERE RESOLVES DOUBT TOWARD "OCCUPIED". The detector's contract
|
|
8
|
+
// is that a null, a false, or a throw is treated as a reason to refuse — so the
|
|
9
|
+
// mistake this file must not make is converting an error into a benign-looking
|
|
10
|
+
// value. `readDir` returning [] on EACCES would read as "the registry is empty",
|
|
11
|
+
// which is the exact fail-open that shipped once already. It throws instead.
|
|
12
|
+
//
|
|
13
|
+
// FOUR THINGS MEASURED ON THIS MACHINE 2026-08-15, not assumed:
|
|
14
|
+
//
|
|
15
|
+
// 1. `TZ=UTC LC_ALL=C ps -o lstart= -p <pid>` prints `Sun Aug 16 02:03:05 2026`
|
|
16
|
+
// — the SAME string, to the second, that Claude Code writes into
|
|
17
|
+
// `~/.claude/sessions/<pid>.json` as `procStart`. Verified against live pid
|
|
18
|
+
// 7872. That is why this file forces TZ rather than parsing localized output:
|
|
19
|
+
// `ps` without TZ printed `Sat Aug 15 21:03:05 2026` for that same process,
|
|
20
|
+
// and comparing THAT against the registry is the false PID-reuse bug the
|
|
21
|
+
// detector's own header documents.
|
|
22
|
+
// 2. `ps -o etimes=` does not exist on macOS ("keyword not found"), so the
|
|
23
|
+
// obvious non-localized alternative is unavailable here. Elapsed-seconds
|
|
24
|
+
// arithmetic would also carry up to 2000ms of truncation error against a
|
|
25
|
+
// 1500ms tolerance, i.e. it would reject valid matches.
|
|
26
|
+
// 3. `lsof -t` has THREE distinct exit-1 shapes and they mean different things:
|
|
27
|
+
// - file exists, nobody holds it -> exit 1, empty stdout, EMPTY stderr
|
|
28
|
+
// - file absent -> exit 1, empty stdout, "status error ... No such file"
|
|
29
|
+
// - parent dir unreadable -> exit 1, empty stdout, "status error ... Permission denied"
|
|
30
|
+
// The first two are "no holders". The third is a failed probe and MUST throw.
|
|
31
|
+
// They are separated by an independent `lstat`, not by matching lsof's English.
|
|
32
|
+
// 4. `statSync` on a mode-000 directory still reports isDirectory() true, so
|
|
33
|
+
// `dirExists` says the detector applies and `readDir` then throws EACCES.
|
|
34
|
+
// That is the intended chain: detector_unavailable is for "no such install",
|
|
35
|
+
// probe_failed is for "the mechanism exists and broke".
|
|
36
|
+
|
|
37
|
+
import { execFileSync } from 'node:child_process'
|
|
38
|
+
import {
|
|
39
|
+
closeSync,
|
|
40
|
+
constants as fsConstants,
|
|
41
|
+
existsSync,
|
|
42
|
+
fstatSync,
|
|
43
|
+
lstatSync,
|
|
44
|
+
openSync,
|
|
45
|
+
readFileSync,
|
|
46
|
+
readdirSync,
|
|
47
|
+
statSync,
|
|
48
|
+
} from 'node:fs'
|
|
49
|
+
import { homedir } from 'node:os'
|
|
50
|
+
import { basename, join, resolve } from 'node:path'
|
|
51
|
+
import { NATIVE_THREAD_ID_RE } from './native-thread-id.js'
|
|
52
|
+
import { parseProcStartUtcMs, type OccupancyDirs, type OccupancyProbes } from './thread-occupancy.js'
|
|
53
|
+
|
|
54
|
+
// Re-exported rather than reimplemented. `claudeSessionsDir` already encodes the
|
|
55
|
+
// COS_CLAUDE_SESSIONS_DIR -> CLAUDE_CONFIG_DIR -> ~/.claude precedence AND is the
|
|
56
|
+
// only test seam for a non-mockable `homedir()`. A second copy would drift the
|
|
57
|
+
// first time someone adds an override, and the two consumers would then disagree
|
|
58
|
+
// about which directory "empty" was observed in.
|
|
59
|
+
export { claudeSessionsDir } from '../routes/claude-sessions.js'
|
|
60
|
+
import { claudeSessionsDir } from '../routes/claude-sessions.js'
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `<CODEX_HOME|~/.codex>/thread-writer-locks`.
|
|
64
|
+
*
|
|
65
|
+
* CODEX_HOME is Codex's own variable, so it is both the real override and the
|
|
66
|
+
* test seam; no COS-specific alias is invented here. An empty string is falsy
|
|
67
|
+
* and correctly falls through to the default rather than resolving to cwd.
|
|
68
|
+
*/
|
|
69
|
+
export function codexLocksDir(): string {
|
|
70
|
+
const home = process.env.CODEX_HOME
|
|
71
|
+
return join(home ? resolve(home) : join(homedir(), '.codex'), 'thread-writer-locks')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function realOccupancyDirs(): OccupancyDirs {
|
|
75
|
+
return { claudeSessionsDir: claudeSessionsDir(), codexLocksDir: codexLocksDir() }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Absolute first, bare name as the fallback: a launchd/Finder-spawned server has no login PATH. */
|
|
79
|
+
const PS_BIN = existsSync('/bin/ps') ? '/bin/ps' : 'ps'
|
|
80
|
+
const LSOF_BIN = existsSync('/usr/sbin/lsof') ? '/usr/sbin/lsof' : 'lsof'
|
|
81
|
+
|
|
82
|
+
/** Both probes sit in the interactive attach path. lsof can block for seconds on a stale mount. */
|
|
83
|
+
const PROBE_TIMEOUT_MS = 2_000
|
|
84
|
+
const PROBE_MAX_BUFFER = 1 << 20
|
|
85
|
+
|
|
86
|
+
/** A registry record is a few hundred bytes. Anything larger is not one. */
|
|
87
|
+
const MAX_RECORD_BYTES = 256 * 1024
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A process cannot have started after now. Only slack for the sub-second
|
|
91
|
+
* truncation `ps` applies plus scheduling delay between the two readings.
|
|
92
|
+
*/
|
|
93
|
+
const FUTURE_START_SLACK_MS = 5_000
|
|
94
|
+
|
|
95
|
+
/** Sanity floor. A parse that lands before this is a misread, not a process. */
|
|
96
|
+
const EARLIEST_PLAUSIBLE_START_MS = Date.UTC(2000, 0, 1)
|
|
97
|
+
|
|
98
|
+
/** `process.kill` treats 0 as "this process group" and -1 as "broadcast". */
|
|
99
|
+
function isProbablePid(pid: unknown): pid is number {
|
|
100
|
+
return typeof pid === 'number' && Number.isInteger(pid) && pid > 0
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type Presence = 'present' | 'absent' | 'unknown'
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Does this path exist, and if not, do we actually KNOW that?
|
|
107
|
+
*
|
|
108
|
+
* `existsSync` collapses "definitely not there" and "cannot see it" into the
|
|
109
|
+
* same false, which is what makes it unsafe for interpreting an lsof failure:
|
|
110
|
+
* a lock inside a directory we cannot traverse would read as absent.
|
|
111
|
+
*/
|
|
112
|
+
function presence(path: string): Presence {
|
|
113
|
+
try {
|
|
114
|
+
lstatSync(path)
|
|
115
|
+
return 'present'
|
|
116
|
+
} catch (error: any) {
|
|
117
|
+
return error?.code === 'ENOENT' ? 'absent' : 'unknown'
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Force a stable, non-localized rendering of the child's time and messages.
|
|
123
|
+
*
|
|
124
|
+
* TZ is the load-bearing one — see note 1 in the header. LC_ALL beats LC_TIME and
|
|
125
|
+
* LANG, so the month abbreviation is always English. The rest of the environment
|
|
126
|
+
* is inherited on purpose: stripping it (`env -i`) is a documented way to break
|
|
127
|
+
* macOS subprocesses and then misread the breakage as the probe's answer.
|
|
128
|
+
*/
|
|
129
|
+
function probeEnv(): NodeJS.ProcessEnv {
|
|
130
|
+
return { ...process.env, TZ: 'UTC', LC_ALL: 'C', LANG: 'C' }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface ProbeOutcome {
|
|
134
|
+
ok: boolean
|
|
135
|
+
stdout: string
|
|
136
|
+
stderr: string
|
|
137
|
+
status: number | null
|
|
138
|
+
/** True when the hard timeout fired. Never a normal result. */
|
|
139
|
+
killed: boolean
|
|
140
|
+
spawnError: string | null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function runProbe(bin: string, args: string[]): ProbeOutcome {
|
|
144
|
+
try {
|
|
145
|
+
const stdout = execFileSync(bin, args, {
|
|
146
|
+
encoding: 'utf8',
|
|
147
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
148
|
+
killSignal: 'SIGKILL',
|
|
149
|
+
maxBuffer: PROBE_MAX_BUFFER,
|
|
150
|
+
env: probeEnv(),
|
|
151
|
+
// Explicit, so a child's stderr is captured for diagnosis instead of being
|
|
152
|
+
// inherited onto the server's console.
|
|
153
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
154
|
+
})
|
|
155
|
+
return { ok: true, stdout: String(stdout), stderr: '', status: 0, killed: false, spawnError: null }
|
|
156
|
+
} catch (error: any) {
|
|
157
|
+
// execFileSync sets `status: null` (DEFINED, not undefined) for ENOENT and
|
|
158
|
+
// EACCES, so an `!== undefined` test is true for every error shape and
|
|
159
|
+
// `spawnError` could never be non-null. Measured: missing binary -> code
|
|
160
|
+
// ENOENT, status null, signal null; timeout -> signal 'SIGKILL'; exit 1 ->
|
|
161
|
+
// status 1. Fail-closed was intact either way (both paths throw), but a
|
|
162
|
+
// missing `lsof` reported as "lsof failed (status=null)" instead of naming
|
|
163
|
+
// ENOENT, which is the difference between a five-minute and an hour-long
|
|
164
|
+
// diagnosis.
|
|
165
|
+
const spawned = typeof error?.status === 'number' || typeof error?.signal === 'string'
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
stdout: String(error?.stdout ?? ''),
|
|
169
|
+
stderr: String(error?.stderr ?? ''),
|
|
170
|
+
status: typeof error?.status === 'number' ? error.status : null,
|
|
171
|
+
killed: Boolean(error?.killed) || Boolean(error?.signal),
|
|
172
|
+
// ENOENT/EACCES on the binary itself: the mechanism is missing, which is a
|
|
173
|
+
// different failure from the mechanism running and reporting nothing.
|
|
174
|
+
spawnError: spawned ? null : String(error?.code ?? error?.message ?? 'spawn failed'),
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Actual process start, epoch ms, or null when it cannot be established.
|
|
181
|
+
*
|
|
182
|
+
* Null is doubt, never "recently". The two callers both take the safe branch on
|
|
183
|
+
* null: the Claude scan records `unverifiable_process_start`, and the self-owned
|
|
184
|
+
* check refuses to credit the spawn ledger.
|
|
185
|
+
*/
|
|
186
|
+
export function processStartMs(pid: number): number | null {
|
|
187
|
+
// Redundant with `ps`'s own argument checking on macOS ("Invalid process id:
|
|
188
|
+
// -1", "process id too large"), kept so a nonsense pid never reaches a
|
|
189
|
+
// subprocess argument at all. Named in knownGaps: no test distinguishes it,
|
|
190
|
+
// because `ps` refuses the same inputs one layer down.
|
|
191
|
+
if (!isProbablePid(pid)) return null
|
|
192
|
+
const out = runProbe(PS_BIN, ['-o', 'lstart=', '-p', String(pid)])
|
|
193
|
+
// A dead pid exits 1 with no output; a timeout or a missing `ps` also lands
|
|
194
|
+
// here. All of them are "cannot determine", which is exactly what null means.
|
|
195
|
+
if (!out.ok) return null
|
|
196
|
+
return interpretPsLstart(out.stdout, Date.now())
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Turn `ps -o lstart=` output into epoch ms, or null.
|
|
201
|
+
*
|
|
202
|
+
* Separated from the spawn for the same reason as `interpretLockHolders`: the
|
|
203
|
+
* readings that must be REFUSED — two lines, a start in the future, a pre-2000
|
|
204
|
+
* epoch — cannot be produced by asking the real `ps` about a real process, so
|
|
205
|
+
* inline they would be guards no test could ever reach.
|
|
206
|
+
*/
|
|
207
|
+
export function interpretPsLstart(stdout: string, now: number): number | null {
|
|
208
|
+
const lines = stdout.split('\n').map(l => l.trim()).filter(l => l.length > 0)
|
|
209
|
+
// Exactly one process was requested. Two lines means the output is not what
|
|
210
|
+
// this parser thinks it is, and guessing which line is right is how a wrong
|
|
211
|
+
// start time becomes a forged identity.
|
|
212
|
+
if (lines.length !== 1) return null
|
|
213
|
+
|
|
214
|
+
const ms = parseProcStartUtcMs(lines[0])
|
|
215
|
+
if (ms === null) return null
|
|
216
|
+
// Catches a `ps` that ignored TZ in a positive-offset zone: the reading would
|
|
217
|
+
// land hours in the future, which no real process start can be.
|
|
218
|
+
if (ms > now + FUTURE_START_SLACK_MS) return null
|
|
219
|
+
if (ms < EARLIEST_PLAUSIBLE_START_MS) return null
|
|
220
|
+
return ms
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* signal-0 liveness, matching `realProbes.isAlive` in routes/claude-sessions.ts.
|
|
225
|
+
*
|
|
226
|
+
* DELIBERATE, FLAGGED DIVERGENCE IN MEANING, NOT IN CODE. EPERM from `kill(pid,0)`
|
|
227
|
+
* means the process EXISTS and belongs to another user. The presence list treats
|
|
228
|
+
* that as "not ours, do not show it", which is right for a presence list. For a
|
|
229
|
+
* safety gate the honest reading is the opposite: something is alive there.
|
|
230
|
+
*
|
|
231
|
+
* This matches the route anyway, because for THIS registry the two readings
|
|
232
|
+
* coincide. `~/.claude/sessions` is mode 0700 in our own home, so every record in
|
|
233
|
+
* it was written by a process running as us; a pid in it that we cannot signal is
|
|
234
|
+
* a pid that has been RECYCLED by a foreign process, meaning the recorded Claude
|
|
235
|
+
* process is dead and the record is stale. Returning false is then correct.
|
|
236
|
+
*
|
|
237
|
+
* The gap is real when that premise breaks: if COS_CLAUDE_SESSIONS_DIR is pointed
|
|
238
|
+
* at another user's registry that we can somehow read, a live foreign owner
|
|
239
|
+
* returns EPERM, is scored dead, contributes no doubt, and the thread can come
|
|
240
|
+
* back attachable. Distinguishing it needs a third state the boolean cannot
|
|
241
|
+
* carry, so it is named in knownGaps rather than papered over here.
|
|
242
|
+
*/
|
|
243
|
+
export function isAlive(pid: number): boolean {
|
|
244
|
+
if (!isProbablePid(pid)) return false
|
|
245
|
+
try {
|
|
246
|
+
process.kill(pid, 0)
|
|
247
|
+
return true
|
|
248
|
+
} catch {
|
|
249
|
+
return false
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Existence only. Follows symlinks on purpose: a dangling link is not evidence of a live socket. */
|
|
254
|
+
export function fileExists(path: string): boolean {
|
|
255
|
+
try {
|
|
256
|
+
return existsSync(path)
|
|
257
|
+
} catch {
|
|
258
|
+
return false
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** True only for a real directory. Unreadable, missing, or a plain file are all false. */
|
|
263
|
+
export function dirExists(path: string): boolean {
|
|
264
|
+
try {
|
|
265
|
+
return statSync(path).isDirectory()
|
|
266
|
+
} catch {
|
|
267
|
+
return false
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Entry names. THROWS on an unreadable directory — never [].
|
|
273
|
+
*
|
|
274
|
+
* An empty array here is indistinguishable from an empty registry, and the
|
|
275
|
+
* detector's one attachable verdict requires exactly that observation. EACCES
|
|
276
|
+
* must not be able to manufacture it.
|
|
277
|
+
*/
|
|
278
|
+
export function readDir(path: string): string[] {
|
|
279
|
+
return readdirSync(path)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* File contents, or null when genuinely unreadable. The caller treats null as doubt.
|
|
284
|
+
*
|
|
285
|
+
* O_NOFOLLOW rather than lstat-then-read: routes/claude-sessions.ts uses lstat +
|
|
286
|
+
* isFile, which is correct but leaves a window where the entry is swapped for a
|
|
287
|
+
* symlink between the check and the read. Refusing to follow at open() time
|
|
288
|
+
* closes it, and a symlinked `<pid>.json` — which could otherwise point at any
|
|
289
|
+
* file on disk and be parsed as a session record — surfaces as ELOOP -> null ->
|
|
290
|
+
* registry_unreadable.
|
|
291
|
+
*/
|
|
292
|
+
export function readFile(path: string): string | null {
|
|
293
|
+
let fd: number | null = null
|
|
294
|
+
try {
|
|
295
|
+
// Belt: the lstat check works everywhere. Braces: O_NOFOLLOW closes the race
|
|
296
|
+
// between the two, and is skipped rather than silently zeroed on a platform
|
|
297
|
+
// that does not define it (`x | undefined` is `x`, which would drop the guard
|
|
298
|
+
// without any error).
|
|
299
|
+
if (lstatSync(path).isSymbolicLink()) return null
|
|
300
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0
|
|
301
|
+
// O_NONBLOCK is load-bearing, not defensive tidiness. `openSync` on a FIFO
|
|
302
|
+
// with no writer BLOCKS FOREVER, and it is a synchronous syscall on Node's
|
|
303
|
+
// single thread — so one `mkfifo sessions/4242.json` wedges the ENTIRE
|
|
304
|
+
// glasses server (health, meeting save, transcribe-stream, all of it), not
|
|
305
|
+
// just this request. Express `requestTimeout` cannot interrupt a blocked
|
|
306
|
+
// syscall, and the `isFile()` guard below runs AFTER the open, so it cannot
|
|
307
|
+
// prevent this by itself. Verified 2026-08-15: without the flag the open
|
|
308
|
+
// never returns (killed at 6s); with it the open completes in 0ms and
|
|
309
|
+
// `isFile()` is false, so the FIFO is rejected by the guard that was always
|
|
310
|
+
// meant to reject it. Reachable via COS_CLAUDE_SESSIONS_DIR / CLAUDE_CONFIG_DIR
|
|
311
|
+
// pointing at any writable directory.
|
|
312
|
+
const nonBlock = typeof fsConstants.O_NONBLOCK === 'number' ? fsConstants.O_NONBLOCK : 0
|
|
313
|
+
fd = openSync(path, fsConstants.O_RDONLY | noFollow | nonBlock)
|
|
314
|
+
const stat = fstatSync(fd)
|
|
315
|
+
if (!stat.isFile()) return null
|
|
316
|
+
if (stat.size > MAX_RECORD_BYTES) return null
|
|
317
|
+
return readFileSync(fd, 'utf-8')
|
|
318
|
+
} catch {
|
|
319
|
+
return null
|
|
320
|
+
} finally {
|
|
321
|
+
if (fd !== null) {
|
|
322
|
+
try { closeSync(fd) } catch { /* already gone */ }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* PIDs holding an open descriptor on a Codex writer lock.
|
|
329
|
+
*
|
|
330
|
+
* `[]` means the probe RAN and found nobody. Anything it could not establish
|
|
331
|
+
* throws, so `threadOccupancy` reports probe_failed instead of attachable.
|
|
332
|
+
*
|
|
333
|
+
* The basename guard is defense in depth. `threadOccupancy` validates the thread
|
|
334
|
+
* id before `codexLockPath` builds this path, but this function is the last stop
|
|
335
|
+
* before a caller-influenced string reaches a process argument, and the repo's
|
|
336
|
+
* other id validator (`SAFE_ID_RE`) permits `/` and `.`. If that validation is
|
|
337
|
+
* ever dropped upstream, this refuses rather than probing an arbitrary path.
|
|
338
|
+
*/
|
|
339
|
+
export function lockHolders(path: string): number[] {
|
|
340
|
+
if (typeof path !== 'string' || path.length === 0 || path.includes('\0')) {
|
|
341
|
+
throw new Error('lockHolders: refusing an unusable path')
|
|
342
|
+
}
|
|
343
|
+
const name = basename(path)
|
|
344
|
+
if (!name.endsWith('.lock') || !NATIVE_THREAD_ID_RE.test(name.slice(0, -'.lock'.length))) {
|
|
345
|
+
throw new Error('lockHolders: refusing a path that is not a <native-thread-id>.lock')
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// `--` terminates option parsing so a path can never be read as a flag; `-w`
|
|
349
|
+
// suppresses mount-point warnings that would otherwise make a successful probe
|
|
350
|
+
// look like the failure case below.
|
|
351
|
+
return interpretLockHolders(runProbe(LSOF_BIN, ['-w', '-t', '--', path]), path)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Turn one lsof run into holders, or throw.
|
|
356
|
+
*
|
|
357
|
+
* Split out because the branches that matter most are the ones a real filesystem
|
|
358
|
+
* will not produce on demand — a timeout, a missing `lsof`, garbage on stdout.
|
|
359
|
+
* Leaving them untested is how "any failure means nobody is holding it" survives
|
|
360
|
+
* a green suite, so they are exercised directly with synthesized outcomes while
|
|
361
|
+
* the happy and permission-denied paths are still covered end to end.
|
|
362
|
+
*/
|
|
363
|
+
export function interpretLockHolders(out: ProbeOutcome, path: string): number[] {
|
|
364
|
+
if (!out.ok) {
|
|
365
|
+
if (out.spawnError !== null) {
|
|
366
|
+
throw new Error(`lockHolders: lsof unavailable (${out.spawnError})`)
|
|
367
|
+
}
|
|
368
|
+
if (out.killed) {
|
|
369
|
+
throw new Error('lockHolders: lsof timed out')
|
|
370
|
+
}
|
|
371
|
+
// Measured: exit 1 + empty stdout is lsof's "no matches". With EMPTY stderr
|
|
372
|
+
// that is a clean no-holders result on a file that exists.
|
|
373
|
+
if (out.status === 1 && out.stdout.trim() === '') {
|
|
374
|
+
if (out.stderr.trim() === '') return []
|
|
375
|
+
// Non-empty stderr is a "status error". Decide what it meant from our own
|
|
376
|
+
// lstat, not from lsof's wording: absent means nobody can be holding it,
|
|
377
|
+
// and anything else (Permission denied, unreadable mount) is a failed probe.
|
|
378
|
+
if (presence(path) === 'absent') return []
|
|
379
|
+
throw new Error(`lockHolders: lsof could not inspect the lock (${out.stderr.trim().slice(0, 160)})`)
|
|
380
|
+
}
|
|
381
|
+
throw new Error(`lockHolders: lsof failed (status=${out.status}) ${out.stderr.trim().slice(0, 160)}`)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const pids = new Set<number>()
|
|
385
|
+
for (const token of out.stdout.split(/\s+/)) {
|
|
386
|
+
if (token.length === 0) continue
|
|
387
|
+
// Unrecognised output is not "no holders". Refuse the whole reading.
|
|
388
|
+
if (!/^\d+$/.test(token)) {
|
|
389
|
+
throw new Error('lockHolders: unrecognised lsof output')
|
|
390
|
+
}
|
|
391
|
+
const pid = Number(token)
|
|
392
|
+
if (!isProbablePid(pid)) {
|
|
393
|
+
throw new Error('lockHolders: lsof reported an implausible pid')
|
|
394
|
+
}
|
|
395
|
+
pids.add(pid)
|
|
396
|
+
}
|
|
397
|
+
return [...pids]
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Supplies the pid -> process-start map that is the only route to a self-owned verdict. */
|
|
401
|
+
export type SpawnLedgerAccessor = () => ReadonlyMap<number, number>
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Copy the ledger into a Map we know the shape of.
|
|
405
|
+
*
|
|
406
|
+
* Self-ownership is the ONLY input that can turn a live owner into attachable, so
|
|
407
|
+
* the accessor is treated as untrusted. A Map-LIKE object whose `get` returns a
|
|
408
|
+
* matching start for every pid would grant self-ownership over an arbitrary
|
|
409
|
+
* desktop process; `instanceof Map` is what makes that unrepresentable. Throwing
|
|
410
|
+
* (rather than substituting an empty map) keeps "the ledger is broken" distinct
|
|
411
|
+
* from "the ledger says none of these are ours" — the detector reports the first
|
|
412
|
+
* as probe_failed.
|
|
413
|
+
*/
|
|
414
|
+
function sanitizeLedger(raw: unknown): ReadonlyMap<number, number> {
|
|
415
|
+
if (!(raw instanceof Map)) {
|
|
416
|
+
throw new Error('cosSpawnedPids: spawn ledger did not return a Map')
|
|
417
|
+
}
|
|
418
|
+
const clean = new Map<number, number>()
|
|
419
|
+
for (const [pid, startedAt] of raw) {
|
|
420
|
+
if (!isProbablePid(pid)) continue
|
|
421
|
+
if (typeof startedAt !== 'number' || !Number.isFinite(startedAt)) continue
|
|
422
|
+
clean.set(pid, startedAt)
|
|
423
|
+
}
|
|
424
|
+
return clean
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* The production probe set.
|
|
429
|
+
*
|
|
430
|
+
* The spawn ledger is injected rather than imported so this stays testable and so
|
|
431
|
+
* the detector cannot accidentally be wired to a ledger that does not exist yet.
|
|
432
|
+
*/
|
|
433
|
+
export function realOccupancyProbes(ledger: SpawnLedgerAccessor): OccupancyProbes {
|
|
434
|
+
return {
|
|
435
|
+
isAlive,
|
|
436
|
+
processStartMs,
|
|
437
|
+
fileExists,
|
|
438
|
+
dirExists,
|
|
439
|
+
readDir,
|
|
440
|
+
readFile,
|
|
441
|
+
lockHolders,
|
|
442
|
+
cosSpawnedPids: () => sanitizeLedger(ledger()),
|
|
443
|
+
}
|
|
444
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { carriesBoundTo } from './agent-session-binding-store.js'
|
|
1
2
|
import { randomUUID } from 'node:crypto'
|
|
2
3
|
import { resolve } from 'node:path'
|
|
3
4
|
import { acquireModelSessionRunLock, callModelStreaming } from './model-router.js'
|
|
@@ -53,6 +54,29 @@ export class QueryJobAdmissionPreparationError extends Error {
|
|
|
53
54
|
export async function preparePublicDurableQueryAdmission(raw: unknown): Promise<unknown> {
|
|
54
55
|
if (!raw || typeof raw !== 'object') return raw
|
|
55
56
|
const input = raw as Record<string, unknown>
|
|
57
|
+
|
|
58
|
+
// Plan 4.2: an attached turn must NEVER silently degrade into an ordinary COS
|
|
59
|
+
// turn. The binding lives in the route path rather than the body, so a
|
|
60
|
+
// re-admission through this generic route would otherwise carry no attachment
|
|
61
|
+
// fields at all and there would be nothing here to reject — the turn would just
|
|
62
|
+
// quietly run against COS's own conversation instead of the user's desktop
|
|
63
|
+
// thread, and look like it worked.
|
|
64
|
+
//
|
|
65
|
+
// `carriesBoundTo` is the marker that makes such a request recognisable. It has
|
|
66
|
+
// existed, tested, with no caller since it was written.
|
|
67
|
+
//
|
|
68
|
+
// Defensive today: attached turns are not journal-backed until Phase 2, so
|
|
69
|
+
// nothing currently produces a request carrying this marker. It is wired now
|
|
70
|
+
// because the moment Phase 2 does, the absence of this check becomes a silent
|
|
71
|
+
// downgrade rather than a loud refusal.
|
|
72
|
+
if (carriesBoundTo(input)) {
|
|
73
|
+
throw new QueryJobAdmissionPreparationError(
|
|
74
|
+
409,
|
|
75
|
+
'attached_turn_on_generic_route',
|
|
76
|
+
'That turn belongs to a thread on your Mac and cannot run as an ordinary COS turn.',
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
56
80
|
const activeEra = currentMessageEra()
|
|
57
81
|
if (activeEra !== LEGACY_MESSAGE_ERA && input.messageEra !== activeEra) {
|
|
58
82
|
throw new QueryJobAdmissionPreparationError(
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Does this server offer Continue at all, and for what?
|
|
2
|
+
//
|
|
3
|
+
// The Continue/Fork affordance is a UI decision that has to be made BEFORE any
|
|
4
|
+
// thread is named, so COS Control and the phone cannot discover it the way they
|
|
5
|
+
// discover everything else about a thread — by asking
|
|
6
|
+
// /api/agent-sessions/:provider/:threadId/attachability. When the feature is off
|
|
7
|
+
// the two write routes are not registered at all, so a client that reaches for
|
|
8
|
+
// them gets a bare 404 with no reason and no copy, which is indistinguishable
|
|
9
|
+
// from a typo, a proxy, or a dead server. This module is what a client reads
|
|
10
|
+
// instead, once, up front.
|
|
11
|
+
//
|
|
12
|
+
// ---------------------------------------------------------------- the rule
|
|
13
|
+
//
|
|
14
|
+
// ABSENT MEANS DISABLED. NEVER ENABLED.
|
|
15
|
+
//
|
|
16
|
+
// These fields did not exist before this build. Every client older than it will
|
|
17
|
+
// read `undefined` from all three, and `undefined` MUST resolve to off — the same
|
|
18
|
+
// fail-closed posture as the rest of Continue Original Agent Thread, for the same
|
|
19
|
+
// reason: a client that guesses "the field is missing, so probably fine" offers a
|
|
20
|
+
// Continue button that writes into a real human's conversation, and the cost of
|
|
21
|
+
// guessing the other way is one Fork.
|
|
22
|
+
//
|
|
23
|
+
// That rule cannot be enforced by the wire format, because absence is exactly what
|
|
24
|
+
// an old client sees and an old client cannot be changed. So it is enforced by
|
|
25
|
+
// `readThreadAttachCapability`, which is the ONLY sanctioned way to interpret this
|
|
26
|
+
// surface, and by the test that feeds it a genuine older-shaped /api/health body
|
|
27
|
+
// and requires all three answers to come back off.
|
|
28
|
+
//
|
|
29
|
+
// `supported` exists solely to make the two OFFs distinguishable. Without it,
|
|
30
|
+
// "this server has never heard of Continue" and "this server can Continue but the
|
|
31
|
+
// user has not switched it on" are the same empty answer, and a client cannot tell
|
|
32
|
+
// a user to flip a setting that may not exist. It is a compile-time constant: this
|
|
33
|
+
// build has the code. Whether the routes are reachable is `enabled`.
|
|
34
|
+
//
|
|
35
|
+
// ------------------------------------------------------------ anti-drift
|
|
36
|
+
//
|
|
37
|
+
// `enabled` reads `threadAttachEnabled()` — the SAME function
|
|
38
|
+
// routes/agent-session-bindings.ts calls to decide whether to register the two
|
|
39
|
+
// POST routes. Not a second copy of `process.env.COS_THREAD_ATTACH_ENABLED === '1'`.
|
|
40
|
+
// A copy is how this surface comes to advertise a write path that 404s, and the
|
|
41
|
+
// repo already carries that lesson one import above the call site in
|
|
42
|
+
// routes/health.ts: MEDIA_CHUNKED_UPLOAD_ENABLED is imported from the route that
|
|
43
|
+
// owns the endpoints "so it cannot drift from whether they are actually
|
|
44
|
+
// registered."
|
|
45
|
+
//
|
|
46
|
+
// `providers` is sourced from BINDABLE_PROVIDERS for the same reason. Cursor must
|
|
47
|
+
// not appear (plan 2.5 makes it Fork-only), and the way to guarantee that is to
|
|
48
|
+
// read the list that `isBindableProvider` — the check the attach route itself
|
|
49
|
+
// applies — is built from, rather than restating two names here and hoping.
|
|
50
|
+
//
|
|
51
|
+
// ------------------------------------------------- why providers empties out
|
|
52
|
+
//
|
|
53
|
+
// When the gate is off, `providers` is `[]` rather than the list this build could
|
|
54
|
+
// drive if it were on. A client that reads only `threadAttachProviders` and never
|
|
55
|
+
// looks at `threadAttachEnabled` is then still correct. Every field independently
|
|
56
|
+
// resolves to "no" when the answer is no; there is no combination of fields a
|
|
57
|
+
// careless reader can pick that yields a Continue button pointing at an
|
|
58
|
+
// unregistered route.
|
|
59
|
+
//
|
|
60
|
+
// The cost is that a disabled server cannot tell an operator WHICH providers it
|
|
61
|
+
// would support. That is a settings-copy problem, and `supported: true` is enough
|
|
62
|
+
// to say "this build can do it, turn it on."
|
|
63
|
+
|
|
64
|
+
import {
|
|
65
|
+
BINDABLE_PROVIDERS,
|
|
66
|
+
isBindableProvider,
|
|
67
|
+
type BindableProvider,
|
|
68
|
+
} from './agent-session-binding-store.js'
|
|
69
|
+
import { threadAttachEnabled } from '../routes/agent-session-bindings.js'
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* This build has the feature compiled in.
|
|
73
|
+
*
|
|
74
|
+
* A constant, not a probe. There is no configuration under which the code is
|
|
75
|
+
* absent from a build that contains this file; the reachable/unreachable question
|
|
76
|
+
* is `enabled`. It is published so that `undefined` (an older server) and `false`
|
|
77
|
+
* (this build, switched off) are different answers on the wire.
|
|
78
|
+
*/
|
|
79
|
+
export const THREAD_ATTACH_SUPPORTED = true
|
|
80
|
+
|
|
81
|
+
export interface ThreadAttachCapability {
|
|
82
|
+
/** The build knows what Continue is. False only when read off an older payload. */
|
|
83
|
+
supported: boolean
|
|
84
|
+
/** The write routes are registered and a Continue may be attempted. */
|
|
85
|
+
enabled: boolean
|
|
86
|
+
/**
|
|
87
|
+
* Providers that can be CONTINUED, not merely browsed or forked.
|
|
88
|
+
*
|
|
89
|
+
* Empty whenever `enabled` is false. Cursor is never a member: it is Fork-only.
|
|
90
|
+
*/
|
|
91
|
+
providers: BindableProvider[]
|
|
92
|
+
/**
|
|
93
|
+
* Fork is available. NOT gated by `enabled`.
|
|
94
|
+
*
|
|
95
|
+
* Fork creates a new thread and leaves the source byte-identical, so the flag
|
|
96
|
+
* that protects an existing conversation does not apply. Gating it produced an
|
|
97
|
+
* incoherent default: every refusal recommends Fork while the route 404s.
|
|
98
|
+
*/
|
|
99
|
+
forkSupported: boolean
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The capability as this server should publish it.
|
|
104
|
+
*
|
|
105
|
+
* @param gate Tests only. Production takes the default, which is the same
|
|
106
|
+
* function that decides whether the write routes exist. Passing anything else
|
|
107
|
+
* from production code re-opens the drift this module was written to close.
|
|
108
|
+
*/
|
|
109
|
+
export function threadAttachCapability(
|
|
110
|
+
gate: () => boolean = threadAttachEnabled,
|
|
111
|
+
): ThreadAttachCapability {
|
|
112
|
+
let enabled = false
|
|
113
|
+
try {
|
|
114
|
+
// Anything other than an exact `true` is off. A gate that answers "maybe" is
|
|
115
|
+
// answering no, and a gate that throws has not answered at all.
|
|
116
|
+
enabled = gate() === true
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.error(
|
|
119
|
+
`[thread-attach-capability] gate threw: ${error instanceof Error ? error.message : error}`,
|
|
120
|
+
)
|
|
121
|
+
enabled = false
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
supported: THREAD_ATTACH_SUPPORTED,
|
|
125
|
+
enabled,
|
|
126
|
+
providers: enabled ? [...BINDABLE_PROVIDERS] : [],
|
|
127
|
+
// NOT gated by `enabled`: fork creates a new thread and leaves the source
|
|
128
|
+
// byte-identical, so the flag protecting an existing conversation does not
|
|
129
|
+
// apply. Gating it made every refusal recommend a route that 404s.
|
|
130
|
+
forkSupported: THREAD_ATTACH_SUPPORTED,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The three keys as they appear on /api/health and /api/models. */
|
|
135
|
+
export interface ThreadAttachHealthFields {
|
|
136
|
+
threadAttachSupported: boolean
|
|
137
|
+
threadAttachEnabled: boolean
|
|
138
|
+
/** Fork is available whenever the build supports it, gate or no gate. */
|
|
139
|
+
threadForkSupported: boolean
|
|
140
|
+
threadAttachProviders: BindableProvider[]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Flatten the capability onto the health payload's key names. */
|
|
144
|
+
export function threadAttachHealthFields(
|
|
145
|
+
capability: ThreadAttachCapability,
|
|
146
|
+
): ThreadAttachHealthFields {
|
|
147
|
+
return {
|
|
148
|
+
threadAttachSupported: capability.supported,
|
|
149
|
+
threadAttachEnabled: capability.enabled,
|
|
150
|
+
threadAttachProviders: capability.providers,
|
|
151
|
+
threadForkSupported: capability.forkSupported,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Interpret a health payload from ANY server, including one that predates these
|
|
157
|
+
* fields entirely.
|
|
158
|
+
*
|
|
159
|
+
* The whole point of this function is the defaults, so read them as the contract:
|
|
160
|
+
*
|
|
161
|
+
* - a payload that is not an object at all -> everything off
|
|
162
|
+
* - `threadAttachSupported` anything but exactly `true` -> not supported, and
|
|
163
|
+
* therefore not enabled, whatever the other two fields say
|
|
164
|
+
* - `threadAttachEnabled` anything but exactly `true` -> off, so `'true'`, `1`
|
|
165
|
+
* and `'yes'` from a hand-rolled or proxied payload all fail closed
|
|
166
|
+
* - `providers` dropped entirely unless enabled, and filtered to names THIS
|
|
167
|
+
* build can actually drive, so a newer server naming a fourth provider does
|
|
168
|
+
* not make an older client offer a Continue it cannot perform
|
|
169
|
+
*
|
|
170
|
+
* A payload that contradicts itself — enabled without supported, or providers
|
|
171
|
+
* while disabled — is a defect somewhere, and a defect resolves to refuse.
|
|
172
|
+
*/
|
|
173
|
+
export function readThreadAttachCapability(payload: unknown): ThreadAttachCapability {
|
|
174
|
+
const row =
|
|
175
|
+
payload && typeof payload === 'object' && !Array.isArray(payload)
|
|
176
|
+
? (payload as Record<string, unknown>)
|
|
177
|
+
: null
|
|
178
|
+
|
|
179
|
+
const supported = row?.threadAttachSupported === true
|
|
180
|
+
const enabled = supported && row?.threadAttachEnabled === true
|
|
181
|
+
|
|
182
|
+
const providers: BindableProvider[] = []
|
|
183
|
+
const raw = row?.threadAttachProviders
|
|
184
|
+
if (enabled && Array.isArray(raw)) {
|
|
185
|
+
for (const entry of raw) {
|
|
186
|
+
// `isBindableProvider` is the attach route's own check. Anything it rejects
|
|
187
|
+
// is a provider this build has no code path for, so offering it would
|
|
188
|
+
// produce a Continue that cannot be honoured.
|
|
189
|
+
if (!isBindableProvider(entry)) continue
|
|
190
|
+
if (providers.includes(entry)) continue
|
|
191
|
+
providers.push(entry)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Read off a payload from another server. An older build has no fork route, and
|
|
196
|
+
// its payload carries no such field, so absent must read as NOT supported — the
|
|
197
|
+
// same fail-closed rule the rest of this module runs on.
|
|
198
|
+
const forkSupported = row?.threadForkSupported === true
|
|
199
|
+
return { supported, enabled, providers, forkSupported }
|
|
200
|
+
}
|