@gotcos/glasses-server 6.66.0 → 6.70.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
+ // Glasses pairing (6.67.0). Contract: docs/pairing-contract.md.
2
+ //
3
+ // COS Control mints a short-lived, single-use code over loopback and shows it as a QR.
4
+ // The phone claims it over Tailscale (or the home Wi-Fi while that is armed), the Mac
5
+ // user presses Allow, and only then does the phone's poll receive the pairing token,
6
+ // exactly once. Everything here is in memory: a restart loses every code and claim,
7
+ // and `bootId` changes so Control knows to show a new code.
8
+ //
9
+ // This module is pure. The clock, the random source, the token, the host list and
10
+ // the server name are injected so every rule in the contract is testable.
11
+
12
+ import { createHash, randomInt as cryptoRandomInt, randomUUID, timingSafeEqual } from 'node:crypto'
13
+ import { isRfc1918Ipv4, isTailscaleIpv4, normalizeRemoteIp } from './network-policy.js'
14
+
15
+ export const PAIRING_PROTOCOL_VERSION = 1
16
+ export const CROCKFORD_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
17
+ export const PAIRING_CODE_LENGTH = 8
18
+ export const PAIRING_CODE_TTL_MS = 5 * 60_000
19
+ export const PAIRING_PENDING_TTL_MS = 60_000
20
+ export const PAIRING_LAN_ARM_MS = 10 * 60_000
21
+ export const PAIRING_IP_LIMIT_PER_MINUTE = 5
22
+ export const PAIRING_IP_WINDOW_MS = 60_000
23
+ export const PAIRING_CODE_ATTEMPT_LIMIT = 20
24
+ /** Decided claims stay pollable this long, so a slow phone still sees `denied`. */
25
+ export const PAIRING_CLAIM_RETENTION_MS = 10 * 60_000
26
+ /** At most this many hosts in a QR, Tailscale first, so the QR stays small. */
27
+ export const PAIRING_MAX_QR_HOSTS = 3
28
+ /** The nonce body, unanchored. The single source for the route check and the public door in api-auth.ts. */
29
+ export const PAIRING_NONCE_BODY = '[A-Za-z0-9_-]{16,64}'
30
+ export const PAIRING_NONCE_PATTERN = new RegExp(`^${PAIRING_NONCE_BODY}$`)
31
+ /**
32
+ * Between hosts in the QR. `+` is in the QR alphanumeric set (`0-9 A-Z space $ % * + - . / :`);
33
+ * a comma is not, and would push a two-host code into byte mode. Clients accept `+` and `,`.
34
+ */
35
+ export const PAIRING_QR_HOST_SEPARATOR = '+'
36
+ /** The QR grammar. Clients parse it; the server is the only writer. */
37
+ export const PAIRING_QR_PATTERN = /^COS1\/MAC\/[0-9A-HJKMNP-TV-Z]{8}\/(?:\d{1,3}(?:\.\d{1,3}){3}:\d{1,5})(?:\+\d{1,3}(?:\.\d{1,3}){3}:\d{1,5})*\/[0-9A-Z]+$/
38
+ /** Every character the server ever writes into a QR: QR alphanumeric mode, so the code stays small. */
39
+ export const PAIRING_QR_CHARSET = /^[0-9A-Z/:+.]+$/
40
+
41
+ export type PairingReason =
42
+ | 'expired'
43
+ | 'used'
44
+ | 'locked'
45
+ | 'rate_limited'
46
+ | 'not_loopback'
47
+ | 'not_allowed_network'
48
+ | 'unknown_code'
49
+ | 'denied'
50
+ | 'draining'
51
+ | 'bad_request'
52
+
53
+ export const PAIRING_REASON_STATUS: Record<PairingReason, number> = {
54
+ bad_request: 400,
55
+ not_loopback: 403,
56
+ not_allowed_network: 403,
57
+ denied: 403,
58
+ unknown_code: 404,
59
+ used: 409,
60
+ locked: 409,
61
+ expired: 410,
62
+ rate_limited: 429,
63
+ draining: 503,
64
+ }
65
+
66
+ export const PAIRING_REASON_MESSAGE: Record<PairingReason, string> = {
67
+ bad_request: 'That pairing request was not understood. Scan the code again.',
68
+ not_loopback: 'Pairing codes are made only by COS Control on this Mac.',
69
+ not_allowed_network: 'Connect over Tailscale, or turn on "Allow pairing on this Wi-Fi" in COS Control.',
70
+ denied: 'Pairing was denied on the Mac.',
71
+ unknown_code: 'This code is not current. Show a new one in COS Control.',
72
+ used: 'This code was already used. Show a new one in COS Control.',
73
+ locked: 'Too many tries, or another device is waiting. Show a new code in COS Control.',
74
+ expired: 'Code expired: show a new one in COS Control.',
75
+ rate_limited: 'Too many tries. Wait a minute and try again.',
76
+ draining: 'Your Mac is updating. Try again in a minute.',
77
+ }
78
+
79
+ export type PairingHostKind = 'tailscale' | 'lan'
80
+ export interface PairingHost {
81
+ host: string
82
+ port: number
83
+ kind: PairingHostKind
84
+ }
85
+
86
+ export type ClaimState = 'pending' | 'allowed' | 'denied' | 'expired' | 'delivered'
87
+
88
+ export class PairingError extends Error {
89
+ readonly reason: PairingReason
90
+ readonly status: number
91
+ readonly retryAfterSeconds?: number
92
+ constructor(reason: PairingReason, retryAfterSeconds?: number) {
93
+ super(reason)
94
+ this.reason = reason
95
+ this.status = PAIRING_REASON_STATUS[reason]
96
+ this.retryAfterSeconds = retryAfterSeconds
97
+ }
98
+ }
99
+
100
+ // ── Code and QR grammar ─────────────────────────────────────────────────────
101
+
102
+ export function generatePairingCode(randomInt: (max: number) => number = max => cryptoRandomInt(max)): string {
103
+ let code = ''
104
+ for (let i = 0; i < PAIRING_CODE_LENGTH; i++) code += CROCKFORD_ALPHABET[randomInt(CROCKFORD_ALPHABET.length)]
105
+ return code
106
+ }
107
+
108
+ /** Uppercase, O→0, I/L→1, strip spaces and dashes. Null unless 8 Crockford characters remain. */
109
+ export function normalizePairingCode(input: unknown): string | null {
110
+ if (typeof input !== 'string' || input.length > 32) return null
111
+ const code = input.toUpperCase().replace(/[\s-]/g, '').replace(/O/g, '0').replace(/[IL]/g, '1')
112
+ if (code.length !== PAIRING_CODE_LENGTH) return null
113
+ for (const ch of code) if (!CROCKFORD_ALPHABET.includes(ch)) return null
114
+ return code
115
+ }
116
+
117
+ export function displayPairingCode(code: string): string {
118
+ return `${code.slice(0, 4)}-${code.slice(4)}`
119
+ }
120
+
121
+ /** `COS1/MAC/<CODE>/<host>:<port>[+<host>:<port>]/<EXP>`, EXP = unix seconds in base36 uppercase. */
122
+ export function buildPairingQr(code: string, hosts: readonly PairingHost[], expiresAtMs: number): string {
123
+ const hostList = hosts.map(h => `${h.host}:${h.port}`).join(PAIRING_QR_HOST_SEPARATOR)
124
+ const exp = Math.floor(expiresAtMs / 1000).toString(36).toUpperCase()
125
+ return `COS1/MAC/${code}/${hostList}/${exp}`
126
+ }
127
+
128
+ /** Constant-time compare of two normalized codes. */
129
+ export function pairingCodesEqual(a: string, b: string): boolean {
130
+ const left = Buffer.from(a, 'utf8')
131
+ const right = Buffer.from(b, 'utf8')
132
+ return left.length === right.length && timingSafeEqual(left, right)
133
+ }
134
+
135
+ /** First 2 hex characters of sha256(code): the only form of a code that is ever logged. */
136
+ export function pairingCodeTag(code: string): string {
137
+ return createHash('sha256').update(code).digest('hex').slice(0, 2)
138
+ }
139
+
140
+ /**
141
+ * Pairing hosts from the reachable-address list: Tailscale IPv4 first, then RFC1918
142
+ * only while LAN is armed. Never IPv6 or MagicDNS. Deduplicated, capped for QR size.
143
+ */
144
+ export function pairingHostsFrom(
145
+ addresses: ReadonlyArray<{ address: string; kind: 'tailscale' | 'lan' | 'other' }>,
146
+ port: number,
147
+ lanArmed: boolean,
148
+ ): PairingHost[] {
149
+ const seen = new Set<string>()
150
+ const tailscale: PairingHost[] = []
151
+ const lan: PairingHost[] = []
152
+ for (const entry of addresses) {
153
+ if (seen.has(entry.address)) continue
154
+ if (isTailscaleIpv4(entry.address)) {
155
+ seen.add(entry.address)
156
+ tailscale.push({ host: entry.address, port, kind: 'tailscale' })
157
+ } else if (lanArmed && entry.kind === 'lan' && isRfc1918Ipv4(entry.address)) {
158
+ seen.add(entry.address)
159
+ lan.push({ host: entry.address, port, kind: 'lan' })
160
+ }
161
+ }
162
+ return [...tailscale, ...lan].slice(0, PAIRING_MAX_QR_HOSTS)
163
+ }
164
+
165
+ // ── State machine ───────────────────────────────────────────────────────────
166
+
167
+ interface CodeRecord {
168
+ code: string
169
+ expiresAt: number
170
+ attempts: number
171
+ status: 'active' | 'used' | 'locked'
172
+ }
173
+
174
+ interface ClaimRecord {
175
+ nonce: string
176
+ code: string
177
+ ip: string
178
+ at: number
179
+ state: ClaimState
180
+ decidedAt: number | null
181
+ deliveredAt: number | null
182
+ }
183
+
184
+ export interface PairingStateOptions {
185
+ now?: () => number
186
+ randomInt?: (max: number) => number
187
+ bootId?: string
188
+ /** The standing pairing token, read at delivery time so a rotation is honoured. */
189
+ token: () => string
190
+ serverName: () => string
191
+ /** Hosts for the QR, given whether LAN is armed right now. */
192
+ hosts: (lanArmed: boolean) => PairingHost[]
193
+ log?: (line: string) => void
194
+ }
195
+
196
+ export interface MintResult {
197
+ code: string
198
+ display: string
199
+ qr: string | null
200
+ expiresAt: string
201
+ bootId: string
202
+ hosts: PairingHost[]
203
+ lanArmedUntil: string | null
204
+ }
205
+
206
+ export interface PollResult {
207
+ state: ClaimState
208
+ token?: string
209
+ serverName?: string
210
+ }
211
+
212
+ export interface PairingStatus {
213
+ code: { display: string; expiresAt: string; state: CodeRecord['status'] } | null
214
+ pending: { nonce: string; ip: string; at: string } | null
215
+ lastClaim: { ip: string; at: string; allowed: boolean } | null
216
+ firstAuthAfterClaimAt: string | null
217
+ lanArmedUntil: string | null
218
+ bootId: string
219
+ }
220
+
221
+ const iso = (ms: number) => new Date(ms).toISOString()
222
+
223
+ export class PairingState {
224
+ readonly bootId: string
225
+ private readonly now: () => number
226
+ private readonly randomInt?: (max: number) => number
227
+ private readonly opts: PairingStateOptions
228
+ private readonly log: (line: string) => void
229
+ private current: CodeRecord | null = null
230
+ private pendingNonce: string | null = null
231
+ private readonly claims = new Map<string, ClaimRecord>()
232
+ private lastClaimNonce: string | null = null
233
+ private firstAuthAfterClaimAt: number | null = null
234
+ private lanArmedUntil: number | null = null
235
+ private readonly ipAttempts = new Map<string, number[]>()
236
+
237
+ constructor(opts: PairingStateOptions) {
238
+ this.opts = opts
239
+ this.now = opts.now ?? (() => Date.now())
240
+ this.randomInt = opts.randomInt
241
+ this.bootId = opts.bootId ?? randomUUID()
242
+ this.log = opts.log ?? (line => console.log(line))
243
+ }
244
+
245
+ private lanArmed(at: number): boolean {
246
+ return this.lanArmedUntil != null && at < this.lanArmedUntil
247
+ }
248
+
249
+ /** Lazy timers: a pending claim older than 60 s expires; old decided claims are dropped. */
250
+ private sweep(at: number): void {
251
+ if (this.pendingNonce) {
252
+ const pending = this.claims.get(this.pendingNonce)
253
+ if (!pending || pending.state !== 'pending') {
254
+ this.pendingNonce = null
255
+ } else if (at - pending.at >= PAIRING_PENDING_TTL_MS) {
256
+ pending.state = 'expired'
257
+ pending.decidedAt = at
258
+ this.pendingNonce = null
259
+ this.log(`[pairing] claim expired ip=${pending.ip} code#=${pairingCodeTag(pending.code)}`)
260
+ }
261
+ }
262
+ for (const [nonce, claim] of this.claims) {
263
+ if (claim.state !== 'pending' && at - claim.at > PAIRING_CLAIM_RETENTION_MS && nonce !== this.lastClaimNonce) {
264
+ this.claims.delete(nonce)
265
+ }
266
+ }
267
+ if (this.lanArmedUntil != null && at >= this.lanArmedUntil) this.lanArmedUntil = null
268
+ }
269
+
270
+ /** POST /pairing/code. A new code cancels the previous code and any pending claim. */
271
+ mint(input: { allowLan?: boolean } = {}): MintResult {
272
+ const at = this.now()
273
+ this.sweep(at)
274
+ if (this.pendingNonce) {
275
+ const pending = this.claims.get(this.pendingNonce)
276
+ if (pending && pending.state === 'pending') {
277
+ pending.state = 'expired'
278
+ pending.decidedAt = at
279
+ }
280
+ this.pendingNonce = null
281
+ }
282
+ this.lanArmedUntil = input.allowLan === true ? at + PAIRING_LAN_ARM_MS : null
283
+ const code = generatePairingCode(this.randomInt)
284
+ const expiresAt = at + PAIRING_CODE_TTL_MS
285
+ this.current = { code, expiresAt, attempts: 0, status: 'active' }
286
+ const hosts = this.opts.hosts(this.lanArmed(at))
287
+ const qr = hosts.length > 0 ? buildPairingQr(code, hosts, expiresAt) : null
288
+ this.log(`[pairing] code minted hosts=${hosts.map(h => h.host).join(',') || 'none'} lan=${this.lanArmedUntil ? 'armed' : 'off'} code#=${pairingCodeTag(code)}`)
289
+ return {
290
+ code,
291
+ display: displayPairingCode(code),
292
+ qr,
293
+ expiresAt: iso(expiresAt),
294
+ bootId: this.bootId,
295
+ hosts,
296
+ lanArmedUntil: this.lanArmedUntil != null ? iso(this.lanArmedUntil) : null,
297
+ }
298
+ }
299
+
300
+ /** Throws PairingError unless `ip` may claim at all (network, then per-IP limit). */
301
+ private admitClaimer(ip: string, at: number): void {
302
+ if (!isTailscaleIpv4(ip) && !(isRfc1918Ipv4(ip) && this.lanArmed(at))) {
303
+ throw new PairingError('not_allowed_network')
304
+ }
305
+ const recent = (this.ipAttempts.get(ip) ?? []).filter(t => at - t < PAIRING_IP_WINDOW_MS)
306
+ if (recent.length >= PAIRING_IP_LIMIT_PER_MINUTE) {
307
+ this.ipAttempts.set(ip, recent)
308
+ const retry = Math.max(1, Math.ceil((PAIRING_IP_WINDOW_MS - (at - recent[0])) / 1000))
309
+ throw new PairingError('rate_limited', retry)
310
+ }
311
+ recent.push(at)
312
+ this.ipAttempts.set(ip, recent)
313
+ if (this.ipAttempts.size > 1000) {
314
+ for (const [key, times] of this.ipAttempts) {
315
+ if (times.every(t => at - t >= PAIRING_IP_WINDOW_MS)) this.ipAttempts.delete(key)
316
+ }
317
+ }
318
+ }
319
+
320
+ /** POST /pairing/claim. Returns normally for 202 `{ pending: true }`; throws PairingError otherwise. */
321
+ claim(input: { code: unknown; nonce: unknown }, socketIp: string): void {
322
+ const at = this.now()
323
+ this.sweep(at)
324
+ const ip = normalizeRemoteIp(socketIp)
325
+ this.admitClaimer(ip, at)
326
+ const nonce = typeof input.nonce === 'string' && PAIRING_NONCE_PATTERN.test(input.nonce) ? input.nonce : null
327
+ const code = normalizePairingCode(input.code)
328
+ if (!nonce || !code) {
329
+ this.log(`[pairing] claim ip=${ip} result=bad_request`)
330
+ throw new PairingError('bad_request')
331
+ }
332
+ const tag = pairingCodeTag(code)
333
+ const refuse = (reason: PairingReason): never => {
334
+ this.log(`[pairing] claim ip=${ip} result=${reason} code#=${tag}`)
335
+ throw new PairingError(reason)
336
+ }
337
+
338
+ // Idempotent for the same nonce and code from the same socket IP.
339
+ const existing = this.claims.get(nonce)
340
+ if (existing) {
341
+ if (!pairingCodesEqual(existing.code, code) || existing.ip !== ip) refuse('locked')
342
+ if (existing.state === 'pending') return
343
+ if (existing.state === 'denied') refuse('denied')
344
+ if (existing.state === 'expired') refuse('expired')
345
+ // allowed or delivered: the claim already succeeded; the poll carries the token.
346
+ return
347
+ }
348
+
349
+ const current = this.current
350
+ if (!current) refuse('unknown_code')
351
+ const record = current as CodeRecord
352
+ const matches = pairingCodesEqual(record.code, code)
353
+ if (at >= record.expiresAt) refuse(matches ? 'expired' : 'unknown_code')
354
+ if (record.status === 'locked') refuse('locked')
355
+ // A used code has nothing left to guess: further attempts neither count nor lock it,
356
+ // so a tailnet peer cannot turn "used" into "locked" under the claim that won.
357
+ if (record.status === 'used') {
358
+ if (!matches) refuse('unknown_code')
359
+ refuse(this.pendingNonce ? 'locked' : 'used')
360
+ }
361
+ // Every attempt while a code is live counts against it, right or wrong.
362
+ record.attempts++
363
+ if (record.attempts > PAIRING_CODE_ATTEMPT_LIMIT) {
364
+ record.status = 'locked'
365
+ refuse('locked')
366
+ }
367
+ if (!matches) refuse('unknown_code')
368
+
369
+ record.status = 'used'
370
+ this.claims.set(nonce, { nonce, code, ip, at, state: 'pending', decidedAt: null, deliveredAt: null })
371
+ this.pendingNonce = nonce
372
+ this.lastClaimNonce = nonce
373
+ this.log(`[pairing] claim ip=${ip} result=pending code#=${tag}`)
374
+ }
375
+
376
+ /** GET /pairing/claim/:nonce. The token is returned once; `allowed` then reads `delivered`. */
377
+ poll(nonce: string, socketIp: string): PollResult {
378
+ const at = this.now()
379
+ this.sweep(at)
380
+ const ip = normalizeRemoteIp(socketIp)
381
+ const claim = this.claims.get(nonce)
382
+ if (!claim) throw new PairingError('unknown_code')
383
+ if (claim.ip !== ip) {
384
+ this.log(`[pairing] poll ip=${ip} result=not_allowed_network code#=${pairingCodeTag(claim.code)}`)
385
+ throw new PairingError('not_allowed_network')
386
+ }
387
+ if (claim.state === 'allowed') {
388
+ claim.state = 'delivered'
389
+ claim.deliveredAt = at
390
+ this.log(`[pairing] token delivered ip=${ip} code#=${pairingCodeTag(claim.code)}`)
391
+ return { state: 'allowed', token: this.opts.token(), serverName: this.opts.serverName() }
392
+ }
393
+ if (claim.state === 'delivered') return { state: 'delivered', serverName: this.opts.serverName() }
394
+ return { state: claim.state }
395
+ }
396
+
397
+ /** POST /pairing/decision. */
398
+ decide(nonce: unknown, allow: unknown): void {
399
+ const at = this.now()
400
+ this.sweep(at)
401
+ if (typeof nonce !== 'string' || typeof allow !== 'boolean') throw new PairingError('bad_request')
402
+ const claim = this.claims.get(nonce)
403
+ if (!claim) throw new PairingError('unknown_code')
404
+ if (claim.state === 'expired') throw new PairingError('expired')
405
+ if (claim.state !== 'pending') throw new PairingError('used')
406
+ claim.state = allow ? 'allowed' : 'denied'
407
+ claim.decidedAt = at
408
+ this.pendingNonce = null
409
+ this.lastClaimNonce = nonce
410
+ if (allow) this.firstAuthAfterClaimAt = null
411
+ this.log(`[pairing] decision ip=${claim.ip} result=${allow ? 'allowed' : 'denied'} code#=${pairingCodeTag(claim.code)}`)
412
+ }
413
+
414
+ /**
415
+ * Called after a request passes requireApiToken. Stamps `firstAuthAfterClaimAt` once,
416
+ * for the first authenticated request from the allowed claim's IP after its token
417
+ * was delivered, so another phone's traffic can never turn Control green.
418
+ */
419
+ noteAuthenticated(socketIp: string | undefined): void {
420
+ if (this.firstAuthAfterClaimAt != null || !this.lastClaimNonce) return
421
+ const claim = this.claims.get(this.lastClaimNonce)
422
+ if (!claim || claim.state !== 'delivered') return
423
+ if (normalizeRemoteIp(socketIp ?? '') !== claim.ip) return
424
+ this.firstAuthAfterClaimAt = this.now()
425
+ this.log(`[pairing] first authenticated request ip=${claim.ip}`)
426
+ }
427
+
428
+ /** GET /pairing/status. */
429
+ status(): PairingStatus {
430
+ const at = this.now()
431
+ this.sweep(at)
432
+ const current = this.current && at < this.current.expiresAt ? this.current : null
433
+ const pending = this.pendingNonce ? this.claims.get(this.pendingNonce) ?? null : null
434
+ const last = this.lastClaimNonce ? this.claims.get(this.lastClaimNonce) ?? null : null
435
+ return {
436
+ code: current ? { display: displayPairingCode(current.code), expiresAt: iso(current.expiresAt), state: current.status } : null,
437
+ pending: pending ? { nonce: pending.nonce, ip: pending.ip, at: iso(pending.at) } : null,
438
+ lastClaim: last ? { ip: last.ip, at: iso(last.at), allowed: last.state === 'allowed' || last.state === 'delivered' } : null,
439
+ firstAuthAfterClaimAt: this.firstAuthAfterClaimAt != null ? iso(this.firstAuthAfterClaimAt) : null,
440
+ lanArmedUntil: this.lanArmedUntil != null ? iso(this.lanArmedUntil) : null,
441
+ bootId: this.bootId,
442
+ }
443
+ }
444
+ }
@@ -62,6 +62,10 @@ export interface LensGistEngineRun {
62
62
  export interface LensGistRunOptions {
63
63
  timeoutMs: number
64
64
  system: string
65
+ /** Bounded on-demand consumers can share this isolated runner. */
66
+ source?: string
67
+ maxOutputTokens?: number
68
+ signal?: AbortSignal
65
69
  }
66
70
 
67
71
  /** Replaces Claude Code's own system prompt, so the call carries none of it. */
@@ -120,9 +124,10 @@ interface ProcessResult {
120
124
  export function runProcess(
121
125
  binary: string,
122
126
  args: string[],
123
- opts: { stdin: string; cwd: string; timeoutMs: number; label: string; cursorIsolation?: boolean },
127
+ opts: { stdin: string; cwd: string; timeoutMs: number; label: string; cursorIsolation?: boolean; maxOutputTokens?: number; signal?: AbortSignal },
124
128
  ): Promise<ProcessResult> {
125
129
  return new Promise((resolve, reject) => {
130
+ if (opts.signal?.aborted) { reject(new Error('cancelled')); return }
126
131
  let stdout = ''
127
132
  let stderr = ''
128
133
  let settled = false
@@ -132,7 +137,7 @@ export function runProcess(
132
137
  const isolation = opts.cursorIsolation === true ? cursorSpawnEnv({ baseEnv: lensGistChildEnv(binary) }) : null
133
138
  let proc: ChildProcessWithoutNullStreams
134
139
  try {
135
- proc = spawn(binary, args, { stdio: ['pipe', 'pipe', 'pipe'], env: isolation ? isolation.env : lensGistChildEnv(binary), cwd: opts.cwd })
140
+ proc = spawn(binary, args, { stdio: ['pipe', 'pipe', 'pipe'], env: { ...(isolation ? isolation.env : lensGistChildEnv(binary)), ...(opts.maxOutputTokens ? { CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(opts.maxOutputTokens), MAX_THINKING_TOKENS: '0' } : {}) }, cwd: opts.cwd })
136
141
  } catch (error) {
137
142
  isolation?.release()
138
143
  throw error
@@ -142,6 +147,7 @@ export function runProcess(
142
147
  if (settled) return
143
148
  settled = true
144
149
  clearTimeout(timer)
150
+ opts.signal?.removeEventListener('abort', abort)
145
151
  fn()
146
152
  }
147
153
  const terminate = () => {
@@ -153,8 +159,11 @@ export function runProcess(
153
159
  terminate()
154
160
  reject(new Error(`${opts.label} timed out after ${opts.timeoutMs} ms${stderr ? `: ${redactSecrets(stderr).slice(-200)}` : ''}`))
155
161
  }), opts.timeoutMs)
156
- proc.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
157
- proc.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
162
+ const abort = () => finish(() => { terminate(); reject(new Error('cancelled')) })
163
+ opts.signal?.addEventListener('abort', abort, { once: true })
164
+ if (opts.signal?.aborted) abort()
165
+ proc.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString(); if (stdout.length > 1_000_000) finish(() => { terminate(); reject(new Error('output_limit')) }) })
166
+ proc.stderr.on('data', (chunk: Buffer) => { stderr = (stderr + chunk.toString()).slice(-8_000) })
158
167
  proc.on('error', err => finish(() => reject(err)))
159
168
  proc.on('close', code => {
160
169
  if (killTimer) clearTimeout(killTimer)
@@ -330,7 +339,7 @@ async function runCli(
330
339
  ): Promise<LensGistEngineRun> {
331
340
  const binary = binaryFor(provider)
332
341
  const result = await withScratchWorkspace(workspace =>
333
- runProcess(binary, args(workspace), { stdin: prompt, cwd: workspace, timeoutMs: opts.timeoutMs, label: provider, cursorIsolation: provider === 'cursor' }))
342
+ runProcess(binary, args(workspace), { stdin: prompt, cwd: workspace, timeoutMs: opts.timeoutMs, label: provider, cursorIsolation: provider === 'cursor', maxOutputTokens: opts.maxOutputTokens, signal: opts.signal }))
334
343
  const providerText = `${result.stdout}\n${result.stderr}`
335
344
  if (result.code !== 0) {
336
345
  // Codex reports its failure as a JSON event AND a non-zero exit; the event says why.
@@ -416,8 +425,8 @@ export async function runLensGistEngine(
416
425
  return run
417
426
  } finally {
418
427
  logTokenAudit({
419
- source: 'g2-lens-gist',
420
- caller: 'lens_gist',
428
+ source: opts.source ?? 'g2-lens-gist',
429
+ caller: opts.source ?? 'lens_gist',
421
430
  model: `${engine}:${resolvedModel || 'default'}`,
422
431
  inputChars: prompt.length + opts.system.length,
423
432
  outputChars,