@gotcos/glasses-server 6.27.13 → 6.28.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.
@@ -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(