@gotcos/glasses-server 6.53.0 → 6.53.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/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## 6.53.1
2
+
3
+ ### Cancel the whole Codex process tree
4
+
5
+ The 6.53.0 live Codex canary found that `codex exec resume` can launch a tool in its own process group. Cancelling the wrapper group stopped the COS turn, but an in-flight tool could be re-parented to launchd and continue running. This hotfix snapshots verified descendant identities before signalling the wrapper, reaches separate descendant groups, retains PID/start-time identities across re-parenting, and escalates those retained processes to SIGKILL after the five-second cancel grace. A wrapper `close` no longer clears the escalation while any verified tree member remains. Probe failure fails closed, and PID reuse is never signalled.
6
+
7
+ A spawned cancellation is now reported as ordinary `turn_cancelled` only after the adapter confirms the entire tree was reaped. An unverified or surviving tree takes the existing ambiguous-delivery fence path. A pre-spawn cancellation remains provably process-free; a spawned pre-write cancellation still requires reaping proof even though no prompt byte was sent.
8
+
9
+ - Tests: a real detached Node wrapper launches a SIGTERM-resistant tool in a second process group; the wrapper dies, the retained tool survives TERM, SIGKILL reaches it after re-parenting, and the tree is confirmed gone. Fault-injected process-table and start-time probes stay uncertain instead of inventing death. Adapter coverage proves wrapper close cannot settle early; route coverage proves both post-write and pre-write unreaped cancellations fence. The mutation gate removes each of those protections independently.
10
+
1
11
  ## 6.53.0
2
12
 
3
13
  ### Cancel a session run from the glasses
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.53.0",
3
+ "version": "6.53.1",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
@@ -65,8 +65,8 @@
65
65
  // nothing may be sent again by COS on its own.
66
66
  //
67
67
  // Only the first two are provably safe to report as a clean failure. `cancelled` is not
68
- // a clean failure either, but it is not a fence: a fence exists to stop COS resending a
69
- // turn nobody knows the fate of, and a cancelled turn is one the user chose to end.
68
+ // a clean failure either. It avoids a fence only when the whole spawned process tree was
69
+ // positively reaped; doubt or a surviving member is ambiguous and fences fail-closed.
70
70
  // `attachedDeliveryAmbiguous` makes that mapping total, and treats any state it
71
71
  // does not recognise as ambiguous, so adding a state later cannot silently open
72
72
  // the replay path.
@@ -93,7 +93,7 @@
93
93
  import { accessSync, constants as fsConstants, statSync } from 'node:fs'
94
94
  import { delimiter, isAbsolute, join } from 'node:path'
95
95
  import { homedir } from 'node:os'
96
- import { spawn as nodeSpawn } from 'node:child_process'
96
+ import { execFileSync, spawn as nodeSpawn } from 'node:child_process'
97
97
 
98
98
  import { isValidNativeThreadId } from './native-thread-id.js'
99
99
  import { isBindableProvider, type BindableProvider } from './agent-session-binding-store.js'
@@ -316,6 +316,12 @@ export interface AttachedTurnDeps {
316
316
  * process group on the developer's machine.
317
317
  */
318
318
  terminate: (child: AttachedChildProcess, signal: NodeJS.Signals) => void
319
+ /**
320
+ * True while any verified member of the provider process tree is still live.
321
+ * Unknown/probe failure MUST read as true: a cancellation may only be reported
322
+ * reaped after the whole tree is positively gone.
323
+ */
324
+ processTreeAlive: (child: AttachedChildProcess) => boolean
319
325
  /**
320
326
  * Argv builder. Optional, defaults to the real one.
321
327
  *
@@ -878,6 +884,15 @@ function safeTerminate(deps: AttachedTurnDeps, child: AttachedChildProcess, sign
878
884
  }
879
885
  }
880
886
 
887
+ function safeProcessTreeAlive(deps: AttachedTurnDeps, child: AttachedChildProcess): boolean {
888
+ try {
889
+ return deps.processTreeAlive(child) === true
890
+ } catch {
891
+ // A failed ownership probe is not proof that the process tree is gone.
892
+ return true
893
+ }
894
+ }
895
+
881
896
  function safeRelease(deps: AttachedTurnDeps, pid: number): void {
882
897
  try {
883
898
  deps.releaseSpawn(pid)
@@ -919,11 +934,14 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
919
934
  let deadline: ReturnType<typeof setTimeout> | null = null
920
935
  let graceTimer: ReturnType<typeof setTimeout> | null = null
921
936
  let forceTimer: ReturnType<typeof setTimeout> | null = null
937
+ let treePollTimer: ReturnType<typeof setTimeout> | null = null
938
+ let terminalObserved = false
922
939
 
923
940
  const clearTimers = () => {
924
941
  if (deadline) { clearTimeout(deadline); deadline = null }
925
942
  if (graceTimer) { clearTimeout(graceTimer); graceTimer = null }
926
943
  if (forceTimer) { clearTimeout(forceTimer); forceTimer = null }
944
+ if (treePollTimer) { clearTimeout(treePollTimer); treePollTimer = null }
927
945
  }
928
946
 
929
947
  const duration = () => readDuration(deps, startedAt)
@@ -994,6 +1012,10 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
994
1012
  }
995
1013
 
996
1014
  const finishTerminal = () => {
1015
+ // The CLI wrapper may close before a tool in a separate process group.
1016
+ // Keep the escalation timers alive until the verified whole tree is gone.
1017
+ if ((cancelled || timedOut) && safeProcessTreeAlive(deps, child)) return
1018
+
997
1019
  // Drain whatever sat in the trailing partial line before judging.
998
1020
  if (stdoutTail.length > 0) {
999
1021
  for (const id of extractNativeIdsFromLine(stdoutTail)) {
@@ -1064,6 +1086,7 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1064
1086
  })
1065
1087
  child.on('close', (code: number | null) => {
1066
1088
  if (typeof code === 'number') exitCode = code
1089
+ terminalObserved = true
1067
1090
  finishTerminal()
1068
1091
  })
1069
1092
  } catch {
@@ -1097,14 +1120,22 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1097
1120
  safeTerminate(deps, child, 'SIGTERM')
1098
1121
  graceTimer = setTimeout(() => {
1099
1122
  safeTerminate(deps, child, 'SIGKILL')
1100
- forceTimer = setTimeout(() => {
1123
+ const forceAt = Date.now() + FORCE_SETTLE_MS
1124
+ const pollForTreeExit = () => {
1125
+ if (settled) return
1126
+ if (terminalObserved && !safeProcessTreeAlive(deps, child)) return finishTerminal()
1127
+ if (Date.now() < forceAt) {
1128
+ treePollTimer = setTimeout(pollForTreeExit, 25)
1129
+ return
1130
+ }
1101
1131
  // A child that survived SIGKILL cannot be reached from here, and
1102
1132
  // blocking forever would wedge the coordinator and any Control drain
1103
1133
  // behind it. A cancel that landed during this escalation still reads
1104
1134
  // as the user's (6.53.0).
1105
1135
  if (cancelled) settleFailure('cancelled', { delivery: 'cancelled', detail: 'unreaped', reaped: false })
1106
1136
  else settleFailure('timeout', { detail: 'unreaped', reaped: false })
1107
- }, FORCE_SETTLE_MS)
1137
+ }
1138
+ forceTimer = setTimeout(pollForTreeExit, 25)
1108
1139
  }, KILL_GRACE_MS)
1109
1140
  }, timeoutMs)
1110
1141
 
@@ -1123,9 +1154,17 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1123
1154
  safeTerminate(deps, child, 'SIGTERM')
1124
1155
  graceTimer = setTimeout(() => {
1125
1156
  safeTerminate(deps, child, 'SIGKILL')
1126
- forceTimer = setTimeout(() => {
1157
+ const forceAt = Date.now() + FORCE_SETTLE_MS
1158
+ const pollForTreeExit = () => {
1159
+ if (settled) return
1160
+ if (terminalObserved && !safeProcessTreeAlive(deps, child)) return finishTerminal()
1161
+ if (Date.now() < forceAt) {
1162
+ treePollTimer = setTimeout(pollForTreeExit, 25)
1163
+ return
1164
+ }
1127
1165
  settleFailure('cancelled', { delivery: 'cancelled', detail: 'unreaped', reaped: false })
1128
- }, FORCE_SETTLE_MS)
1166
+ }
1167
+ forceTimer = setTimeout(pollForTreeExit, 25)
1129
1168
  }, CANCEL_KILL_GRACE_MS)
1130
1169
  }
1131
1170
  try {
@@ -1181,6 +1220,207 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1181
1220
  // Production wiring
1182
1221
  // ---------------------------------------------------------------------------
1183
1222
 
1223
+ interface AttachedProcessRow {
1224
+ pid: number
1225
+ ppid: number
1226
+ pgid: number
1227
+ }
1228
+
1229
+ /**
1230
+ * Read the live parent and process-group graph for an attached provider.
1231
+ *
1232
+ * Codex may put a shell tool in its OWN process group. Signalling only the
1233
+ * detached CLI group then kills Codex while the tool is re-parented to pid 1.
1234
+ * This probe is synchronous because termination is a one-shot ownership
1235
+ * boundary: the parent graph must be captured before the CLI can exit and erase
1236
+ * it. A failed probe still signals the original root group below, but latches
1237
+ * uncertainty so the adapter cannot later report a false whole-tree reap.
1238
+ */
1239
+ function readAttachedProcessRows(): AttachedProcessRow[] | null {
1240
+ if (process.platform === 'win32') return []
1241
+ try {
1242
+ const output = execFileSync('/bin/ps', ['-axo', 'pid=,ppid=,pgid='], {
1243
+ encoding: 'utf8',
1244
+ timeout: 1_000,
1245
+ maxBuffer: 2 * 1024 * 1024,
1246
+ })
1247
+ return output.split('\n').flatMap(line => {
1248
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s*$/)
1249
+ if (!match) return []
1250
+ const pid = Number(match[1])
1251
+ const ppid = Number(match[2])
1252
+ const pgid = Number(match[3])
1253
+ if (![pid, ppid, pgid].every(Number.isSafeInteger) || pid <= 0 || ppid < 0 || pgid <= 0) return []
1254
+ return [{ pid, ppid, pgid }]
1255
+ })
1256
+ } catch {
1257
+ return null
1258
+ }
1259
+ }
1260
+
1261
+ function attachedProcessTree(rows: AttachedProcessRow[], roots: Iterable<number>): AttachedProcessRow[] {
1262
+ const owned = new Set<number>(roots)
1263
+ let changed = true
1264
+ while (changed) {
1265
+ changed = false
1266
+ for (const row of rows) {
1267
+ if (!owned.has(row.pid) && owned.has(row.ppid)) {
1268
+ owned.add(row.pid)
1269
+ changed = true
1270
+ }
1271
+ }
1272
+ }
1273
+ return rows.filter(row => owned.has(row.pid))
1274
+ }
1275
+
1276
+ function signalPid(pid: number, signal: NodeJS.Signals): void {
1277
+ try { process.kill(pid, signal) } catch { /* already terminal or unavailable */ }
1278
+ }
1279
+
1280
+ function signalGroup(pgid: number, signal: NodeJS.Signals): void {
1281
+ if (process.platform === 'win32') return
1282
+ try { process.kill(-pgid, signal) } catch { /* already terminal or unavailable */ }
1283
+ }
1284
+
1285
+ /**
1286
+ * Stop the provider plus every descendant visible before its parent link is
1287
+ * lost. Captured pid/start identities survive the SIGTERM grace, so SIGKILL can
1288
+ * still reach an uncooperative tool after the CLI has exited and the tool has
1289
+ * been re-parented to launchd. Identities are re-checked before reuse; a recycled
1290
+ * pid is never signalled from remembered state.
1291
+ */
1292
+ function terminateAttachedProcessTree(
1293
+ child: AttachedChildProcess,
1294
+ signal: NodeJS.Signals,
1295
+ rememberedByRoot: Map<number, Map<number, number>>,
1296
+ uncertainRoots: Set<number>,
1297
+ ): void {
1298
+ const rootPid = child.pid
1299
+ if (typeof rootPid !== 'number' || !Number.isSafeInteger(rootPid) || rootPid <= 0) {
1300
+ try { ;(child as any).kill?.(signal) } catch { /* nothing further is available */ }
1301
+ return
1302
+ }
1303
+
1304
+ const remembered = rememberedByRoot.get(rootPid) ?? new Map<number, number>()
1305
+ rememberedByRoot.set(rootPid, remembered)
1306
+
1307
+ // Only identities that still name the same kernel process may seed a fresh
1308
+ // descendant scan. This is what makes the five-second SIGKILL safe after the
1309
+ // provider leader has already disappeared.
1310
+ const rememberedRoots: number[] = []
1311
+ for (const [pid, startedAt] of remembered) {
1312
+ if (realProcessStartMs(pid) === startedAt) rememberedRoots.push(pid)
1313
+ }
1314
+
1315
+ const rows = readAttachedProcessRows()
1316
+ if (rows === null) uncertainRoots.add(rootPid)
1317
+ const knownRootStart = remembered.get(rootPid)
1318
+ const currentRootStart = realProcessStartMs(rootPid)
1319
+ const rootVerified = currentRootStart !== null
1320
+ && (knownRootStart === undefined || currentRootStart === knownRootStart)
1321
+ if (currentRootStart === null && rows?.some(row => row.pid === rootPid)) uncertainRoots.add(rootPid)
1322
+ if (knownRootStart === undefined && currentRootStart !== null) remembered.set(rootPid, currentRootStart)
1323
+ const roots = [...rememberedRoots]
1324
+ if (rootVerified && !roots.includes(rootPid)) roots.push(rootPid)
1325
+ const tree = rows === null ? [] : attachedProcessTree(rows, roots)
1326
+ for (const row of tree) {
1327
+ const startedAt = realProcessStartMs(row.pid)
1328
+ if (startedAt !== null) remembered.set(row.pid, startedAt)
1329
+ else uncertainRoots.add(rootPid)
1330
+ }
1331
+
1332
+ // A child in a separate group must be signalled BEFORE the CLI group. If the
1333
+ // CLI exits first, macOS reparents that child to pid 1 and the relationship is
1334
+ // no longer discoverable.
1335
+ const descendantGroups = new Set(
1336
+ tree.filter(row => row.pid !== rootPid && row.pgid !== rootPid).map(row => row.pgid),
1337
+ )
1338
+ for (const pgid of descendantGroups) signalGroup(pgid, signal)
1339
+ for (const row of tree) {
1340
+ if (row.pid !== rootPid) signalPid(row.pid, signal)
1341
+ }
1342
+
1343
+ const rootGroupStillOwned = rootVerified || tree.some(row => row.pgid === rootPid)
1344
+ if (rootGroupStillOwned) signalGroup(rootPid, signal)
1345
+
1346
+ // Direct-child fallback covers a spawn that failed before setsid established
1347
+ // the detached group, and is harmless after a successful group signal.
1348
+ try { ;(child as any).kill?.(signal) } catch { /* already terminal */ }
1349
+
1350
+ }
1351
+
1352
+ function attachedProcessTreeAlive(
1353
+ child: AttachedChildProcess,
1354
+ rememberedByRoot: Map<number, Map<number, number>>,
1355
+ uncertainRoots: Set<number>,
1356
+ ): boolean {
1357
+ const rootPid = child.pid
1358
+ if (typeof rootPid !== 'number' || !Number.isSafeInteger(rootPid) || rootPid <= 0) return false
1359
+ const remembered = rememberedByRoot.get(rootPid)
1360
+ const rows = readAttachedProcessRows()
1361
+ // A failed global process-table read cannot prove closure. Keep the record so
1362
+ // the SIGKILL escalation can still target identities captured before TERM.
1363
+ if (rows === null) return true
1364
+
1365
+ let anyLive = false
1366
+ if (remembered) {
1367
+ const verifiedRoots: number[] = []
1368
+ for (const [pid, startedAt] of remembered) {
1369
+ const rowExists = rows.some(row => row.pid === pid)
1370
+ if (!rowExists) {
1371
+ // Absence from a successful full process-table snapshot is the only
1372
+ // positive proof this identity is gone.
1373
+ remembered.delete(pid)
1374
+ continue
1375
+ }
1376
+ const currentStart = realProcessStartMs(pid)
1377
+ if (currentStart === startedAt) {
1378
+ anyLive = true
1379
+ verifiedRoots.push(pid)
1380
+ } else if (currentStart === null) {
1381
+ // The pid still exists but its start could not be read. That is doubt,
1382
+ // not death; retain the identity and permanently fail this cancellation
1383
+ // toward the route's ambiguity fence.
1384
+ anyLive = true
1385
+ uncertainRoots.add(rootPid)
1386
+ } else {
1387
+ // A different start means PID reuse. The original identity is gone and
1388
+ // the replacement may never be signalled from remembered state.
1389
+ remembered.delete(pid)
1390
+ }
1391
+ }
1392
+ const fresh = attachedProcessTree(rows, verifiedRoots)
1393
+ for (const row of fresh) {
1394
+ const startedAt = realProcessStartMs(row.pid)
1395
+ if (startedAt !== null) {
1396
+ remembered.set(row.pid, startedAt)
1397
+ anyLive = true
1398
+ } else {
1399
+ anyLive = true
1400
+ uncertainRoots.add(rootPid)
1401
+ }
1402
+ }
1403
+ }
1404
+
1405
+ // Once a snapshot/start probe missed part of the tree, later ancestry cannot
1406
+ // reconstruct a process that may already have been re-parented. Never convert
1407
+ // that uncertainty into reaped:true; the route will fence after force-settle.
1408
+ if (uncertainRoots.has(rootPid)) anyLive = true
1409
+
1410
+ try {
1411
+ process.kill(-rootPid, 0)
1412
+ anyLive = true
1413
+ } catch (error: any) {
1414
+ if (error?.code !== 'ESRCH') anyLive = true
1415
+ }
1416
+
1417
+ if (!anyLive) {
1418
+ rememberedByRoot.delete(rootPid)
1419
+ uncertainRoots.delete(rootPid)
1420
+ }
1421
+ return anyLive
1422
+ }
1423
+
1184
1424
  /**
1185
1425
  * The real dependency set.
1186
1426
  *
@@ -1190,6 +1430,8 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1190
1430
  * which is the one answer this module must never invent.
1191
1431
  */
1192
1432
  export function realAttachedTurnDeps(preflight: () => AttachedPreflightVerdict): AttachedTurnDeps {
1433
+ const rememberedProcesses = new Map<number, Map<number, number>>()
1434
+ const uncertainProcessTrees = new Set<number>()
1193
1435
  return {
1194
1436
  now: () => Date.now(),
1195
1437
  preflight,
@@ -1205,23 +1447,7 @@ export function realAttachedTurnDeps(preflight: () => AttachedPreflightVerdict):
1205
1447
  processStartMs: pid => realProcessStartMs(pid),
1206
1448
  recordSpawn: (pid, startMs) => recordCosSpawn(pid, startMs),
1207
1449
  releaseSpawn: pid => releaseCosSpawn(pid),
1208
- terminate: (child, signal) => {
1209
- const pid = child.pid
1210
- if (typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0) {
1211
- try {
1212
- // Negative pid = process group, reachable because we spawned
1213
- // detached. Kills the CLI's own children too.
1214
- process.kill(-pid, signal)
1215
- return
1216
- } catch {
1217
- /* fall through to the direct signal */
1218
- }
1219
- }
1220
- try {
1221
- ;(child as any).kill?.(signal)
1222
- } catch {
1223
- /* nothing further is available */
1224
- }
1225
- },
1450
+ terminate: (child, signal) => terminateAttachedProcessTree(child, signal, rememberedProcesses, uncertainProcessTrees),
1451
+ processTreeAlive: child => attachedProcessTreeAlive(child, rememberedProcesses, uncertainProcessTrees),
1226
1452
  }
1227
1453
  }
@@ -1196,7 +1196,7 @@ type Delivery =
1196
1196
  | { kind: 'completed'; after: string | null }
1197
1197
  | { kind: 'aborted' }
1198
1198
  | { kind: 'ambiguous' }
1199
- /** 6.53.0: stopped by a cancel this route requested. Never fenced. */
1199
+ /** Stopped by a cancel this route requested and positively reaped. */
1200
1200
  | { kind: 'cancelled' }
1201
1201
 
1202
1202
  /**
@@ -1214,11 +1214,17 @@ type Delivery =
1214
1214
  export function classifyDelivery(result: unknown, cancelRequested = false): Delivery {
1215
1215
  if (!result || typeof result !== 'object' || Array.isArray(result)) return { kind: 'ambiguous' }
1216
1216
  if (cancelRequested === true) {
1217
- const { ok, reason, delivery } = result as { ok?: unknown; reason?: unknown; delivery?: unknown }
1217
+ const { ok, reason, delivery, reaped } = result as { ok?: unknown; reason?: unknown; delivery?: unknown; reaped?: unknown }
1218
1218
  if (ok === false && reason === 'cancelled'
1219
- && (delivery === 'cancelled' || delivery === 'not_attempted' || delivery === 'aborted')) {
1219
+ && ((delivery === 'cancelled' && reaped === true)
1220
+ || delivery === 'not_attempted'
1221
+ || (delivery === 'aborted' && reaped === true))) {
1220
1222
  return { kind: 'cancelled' }
1221
1223
  }
1224
+ // A cancel result that names a spawned-but-unreaped child must not fall
1225
+ // through to the generic `aborted` classifier below. The route requested
1226
+ // the stop, but it cannot claim the process tree is gone, so fence it.
1227
+ if (ok === false && reason === 'cancelled') return { kind: 'ambiguous' }
1222
1228
  }
1223
1229
  const status = (result as { status?: unknown }).status
1224
1230
  if (status === 'completed') {