dsh-plugin-lcu 0.2.9 → 0.3.4

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,484 @@
1
+ /**
2
+ * The runtime's Sky service, with a turn-ended hook.
3
+ *
4
+ * The runtime loads this module as its `sky` trusted service and calls
5
+ * `handleRpc` for every request. Everything is forwarded to the application's
6
+ * own service unchanged; the only addition is the per-turn cleanup that tells the
7
+ * host application a turn is over.
8
+ *
9
+ * That hook is not optional bookkeeping. Without it the application keeps
10
+ * believing the turn is open, and a per-application Stop it recorded inside that
11
+ * turn is never released: every later turn then fails with "explicitly stopped by
12
+ * the user until the host application is relaunched.
13
+ *
14
+ * The related wrapper this replaces also signalled a separate supervisor process
15
+ * over a lifetime socket, which spawned the signed client binary with a
16
+ * `turn-ended` argument. That step needs Apple Events, which macOS refuses to
17
+ * grant a hardened-runtime harness — and refuses to even prompt for — so it
18
+ * always timed out. It is deliberately absent here: the application's own IPC is
19
+ * the step that performs the cleanup, and it needs no Apple Events at all.
20
+ *
21
+ * Two environment variables come from the launcher:
22
+ * `DSH_SKY_SERVICE_PATH` is the application's real service module, and
23
+ * `DSH_SKY_CLIENT_PATH` is the signed client the cleanup call goes through.
24
+ *
25
+ * @module dsh-plugin-lcu/sky-service
26
+ */
27
+
28
+ import { pathToFileURL } from 'node:url'
29
+
30
+ // Neither a file write nor console output can prove this module loaded: the
31
+ // runtime's JavaScript sandbox denies file writes, and it captures console
32
+ // output. `DSH_SKY_FORCE_ERROR=1` is the observable probe instead — it makes the
33
+ // next call fail with a message only this module can produce.
34
+
35
+ /** How long the runtime waits for a turn-ended handler before failing the turn. */
36
+ const HOOK_TIMEOUT_MS = 4_000
37
+ /** The application's own budget for one cleanup call. */
38
+ const CALL_TIMEOUT_SECONDS = 15
39
+ /** Bound the remembered turn identities, so a long session cannot grow without limit. */
40
+ const METADATA_LIMIT = 128
41
+ /** Give up on a cleanup that will not settle, rather than wedging every later call. */
42
+ const CLEANUP_ATTEMPTS = 3
43
+
44
+ let original
45
+ let client
46
+ let registered = false
47
+ let cleanupInFlight
48
+
49
+ /** Turn identities seen on requests, keyed by `[session, turn]`. */
50
+ const metadata = new Map()
51
+ /** Turns whose cleanup has not settled yet. A Map keeps insertion order for retries. */
52
+ const pending = new Map()
53
+ /**
54
+ * The last thing worth reporting, surfaced by `DSH_SKY_REPORT=1`.
55
+ *
56
+ * This module runs where the obvious channels do not work: the sandbox denies
57
+ * file writes and the runtime captures console output. Failing a later call is
58
+ * the only way to get a fact out of it.
59
+ */
60
+ let lastReport
61
+
62
+ function runtime() {
63
+ return globalThis.nodeRepl
64
+ }
65
+
66
+ /**
67
+ * Whether to trace the hook. `DSH_SKY_DEBUG=1` turns it on.
68
+ *
69
+ * A handler that never fires is indistinguishable from one that fired and found
70
+ * nothing to do, and the difference decides where the bug is.
71
+ */
72
+ function trace(message) {
73
+ if (runtime()?.env?.DSH_SKY_DEBUG === '1') console.error(`dsh-plugin-lcu[sky]: ${message}`)
74
+ }
75
+
76
+ function fail(message) {
77
+ return new Error(`dsh-plugin-lcu Sky service: ${message}`)
78
+ }
79
+
80
+ /**
81
+ * The identity a request carries, when it carries a usable one.
82
+ *
83
+ * A malformed identity is not an error: the runtime falls back to whatever its
84
+ * own metadata says, and a call without one simply gets no cleanup hook.
85
+ */
86
+ function readMetadata() {
87
+ try {
88
+ const raw = runtime()?.requestMeta?.['x-codex-turn-metadata']
89
+ let value = raw
90
+ if (raw instanceof Uint8Array) value = JSON.parse(Buffer.from(raw).toString('utf8'))
91
+ else if (typeof raw === 'string') value = JSON.parse(raw)
92
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
93
+ const session = value.session_id
94
+ const turn = value.turn_id
95
+ if (typeof session !== 'string' || session.trim() === '') return undefined
96
+ if (typeof turn !== 'string' || turn.trim() === '') return undefined
97
+ // Structured clone, so a later mutation of the request cannot alter a record
98
+ // that is about to be sent to the application.
99
+ return JSON.parse(JSON.stringify(value))
100
+ } catch {
101
+ return undefined
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Ask the host application to clean up one finished turn.
107
+ *
108
+ * This is the application's own IPC through its own signed client, which is why
109
+ * it works where a shelled-out client does not.
110
+ */
111
+ async function cleanUp(item) {
112
+ const clientPath = runtime()?.env?.DSH_SKY_CLIENT_PATH
113
+ if (typeof clientPath !== 'string' || clientPath === '') {
114
+ throw fail('DSH_SKY_CLIENT_PATH is not set')
115
+ }
116
+ const { MacComputerUseClient } = await import(pathToFileURL(clientPath).href)
117
+ client ??= new MacComputerUseClient()
118
+ return await client.request('ComputerUseIPCCodexTurnEndedRequest', {
119
+ threadID: item.session_id,
120
+ turnID: item.turn_id,
121
+ }, { codexMetadata: item.metadata, timeoutSeconds: CALL_TIMEOUT_SECONDS })
122
+ }
123
+
124
+ /** Settle every pending cleanup, in the order the turns ended. */
125
+ async function settlePending() {
126
+ if (cleanupInFlight !== undefined) return await cleanupInFlight
127
+ cleanupInFlight = (async () => {
128
+ for (const [key, item] of pending) {
129
+ if (item.attempts >= CLEANUP_ATTEMPTS) {
130
+ console.error(`dsh-plugin-lcu: giving up on turn cleanup for ${item.turn_id} after ${item.attempts} attempts`)
131
+ pending.delete(key)
132
+ continue
133
+ }
134
+ item.attempts += 1
135
+ const started = Date.now()
136
+ try {
137
+ await cleanUp(item)
138
+ pending.delete(key)
139
+ // Reported because the alternative is silence: a cleanup that never runs
140
+ // looks exactly like one that ran, until an application refuses a later
141
+ // turn for reasons the user cannot see.
142
+ lastReport = `acknowledged turn=${item.turn_id} in ${Date.now() - started}ms`
143
+ console.error(`dsh-plugin-lcu: turn cleanup ${lastReport}`)
144
+ } catch (error) {
145
+ // Leave it pending: the next request retries, which is what keeps a slow
146
+ // application from losing the cleanup entirely. The attempt count above
147
+ // is what stops a permanent failure from wedging the session.
148
+ lastReport = `failed turn=${item.turn_id} attempt=${item.attempts}/${CLEANUP_ATTEMPTS}: ${String(error)}`
149
+ console.error(`dsh-plugin-lcu: turn cleanup ${lastReport}`)
150
+ }
151
+ }
152
+ })().finally(() => { cleanupInFlight = undefined })
153
+ return await cleanupInFlight
154
+ }
155
+
156
+ /** Install the turn-ended hook once. */
157
+ function register() {
158
+ if (registered) return
159
+ const rt = runtime()
160
+ if (typeof rt?.addTurnEndedHandler !== 'function') {
161
+ throw fail('the runtime has no turn-ended hook to install on')
162
+ }
163
+ rt.addTurnEndedHandler({
164
+ timeoutMs: HOOK_TIMEOUT_MS,
165
+ run: async ({ session_id: session, turn_id: turn }) => {
166
+ trace(`turn-ended hook fired session=${String(session)} turn=${String(turn)} known=${String(metadata.size)}`)
167
+ if (typeof session !== 'string' || session.trim() === '') throw fail('a turn-ended hook arrived without a session')
168
+ if (typeof turn !== 'string' || turn.trim() === '') throw fail('a turn-ended hook arrived without a turn')
169
+ const key = JSON.stringify([session, turn])
170
+ const carried = metadata.get(key)
171
+ metadata.delete(key)
172
+ trace(`hook lookup ${key} -> ${carried === undefined ? 'no metadata' : 'found'}`)
173
+ // A turn that carried no identity has nothing to send the application, so
174
+ // there is no cleanup to attempt and no reason to fail the turn. Recorded
175
+ // because "the hook fired and had nothing to send" is a different bug from
176
+ // "the hook never fired", and the two look identical from outside.
177
+ if (carried === undefined) {
178
+ lastReport = `no metadata for turn=${turn} (hook fired, known=${metadata.size})`
179
+ return
180
+ }
181
+ pending.set(key, { session_id: session, turn_id: turn, metadata: carried, attempts: 0 })
182
+ endControlTurn(session, turn)
183
+ await settlePending()
184
+ },
185
+ })
186
+ registered = true
187
+ }
188
+
189
+
190
+ // --- the control channel ----------------------------------------------------
191
+ //
192
+ // The runtime can hold an application open for a turn, which is what lets a later
193
+ // call act on the same window. Releasing one early needs the runtime's control
194
+ // API, and that API lives in *this* process — so the request has to be answered
195
+ // here rather than in the plugin. The plugin serves a socket, this connects to it
196
+ // as the service, and the plugin relays requests across.
197
+
198
+ /** Active turn contexts, keyed by [session, turn, app]. */
199
+ const activeContexts = new Map()
200
+ const CONTEXT_LIMIT = 128
201
+ const STATUS_TIMEOUT_SECONDS = 15
202
+ const STOP_TIMEOUT_SECONDS = 15
203
+ /** Bound the per-context policy lookups so a Stop still has time to be sent. */
204
+ const POLICY_BUDGET_MS = 5_000
205
+
206
+ let controlSocket
207
+ let controlConnecting
208
+ let controlBuffer = Buffer.alloc(0)
209
+
210
+ /** The application's signed client, imported once. */
211
+ async function appClient() {
212
+ const clientPath = runtime()?.env?.DSH_SKY_CLIENT_PATH
213
+ if (typeof clientPath !== 'string' || clientPath === '') throw fail('DSH_SKY_CLIENT_PATH is not set')
214
+ const { MacComputerUseClient } = await import(pathToFileURL(clientPath).href)
215
+ client ??= new MacComputerUseClient()
216
+ return client
217
+ }
218
+
219
+ /** The application a request is about, when it names one. */
220
+ function appFromRequest(request) {
221
+ try {
222
+ if (request?.type === 'execute' && Array.isArray(request.args)) {
223
+ const argument = request.args[0]
224
+ if (typeof argument === 'string' && request.method === 'get_app_state') return argument
225
+ if (argument !== null && typeof argument === 'object' && typeof argument.app === 'string') return argument.app
226
+ }
227
+ } catch {
228
+ // An unreadable request names no application, which only means no context.
229
+ }
230
+ return undefined
231
+ }
232
+
233
+ function sendControl(message) {
234
+ if (controlSocket === undefined || controlSocket.destroyed) return
235
+ try {
236
+ // A Buffer, not a string. The runtime's native pipe does not accept a string
237
+ // write: it is silently dropped, which is indistinguishable from a peer that
238
+ // never answered. The wrapper this replaces writes Buffers for the same reason.
239
+ controlSocket.write(Buffer.from(`${JSON.stringify(message)}\n`))
240
+ } catch {
241
+ // The channel is gone; the next request reconnects.
242
+ controlSocket = undefined
243
+ }
244
+ }
245
+
246
+ /** Connect to the plugin's socket once, and answer whatever it asks. */
247
+ function controlChannel() {
248
+ if (controlSocket !== undefined && !controlSocket.destroyed) return Promise.resolve(controlSocket)
249
+ if (controlConnecting !== undefined) return controlConnecting
250
+ const path = runtime()?.env?.LCU_MAC_CONTROL_SOCKET
251
+ if (typeof path !== 'string' || path === '') {
252
+ lastReport = `control: no socket in env (keys with LCU/DSH: ${Object.keys(runtime()?.env ?? {}).filter((k) => k.startsWith('LCU') || k.startsWith('DSH')).join(',') || 'none'})`
253
+ return Promise.resolve(undefined)
254
+ }
255
+ lastReport = `control: connecting to ${path}`
256
+
257
+ controlConnecting = (async () => {
258
+ // The runtime's own native-pipe API, not `node:net`. This module runs inside
259
+ // the runtime's JavaScript sandbox, and that sandbox refuses an ordinary
260
+ // socket connection with EPERM — the same boundary that denies its file
261
+ // writes. `nativePipe` is the sanctioned way out, and it is what the wrapper
262
+ // this replaces uses. `node:net` stays as the fallback so the module can be
263
+ // exercised outside a runtime.
264
+ let socket
265
+ try {
266
+ const opening = Promise.resolve().then(() =>
267
+ typeof runtime()?.nativePipe?.createConnection === 'function'
268
+ ? runtime().nativePipe.createConnection(path)
269
+ : import('node:net').then(({ createConnection }) => createConnection(path)))
270
+ // A deadline, because a native-pipe connection can hang rather than fail.
271
+ socket = await Promise.race([
272
+ opening,
273
+ new Promise((_, reject) => { setTimeout(() => { reject(new Error('control connection timed out')) }, 2_000) }),
274
+ ])
275
+ if (socket === undefined || socket === null) throw new Error('the runtime returned no control connection')
276
+ } catch (error) {
277
+ lastReport = `control: connect failed ${String(error?.message ?? error).slice(0, 90)}`
278
+ return undefined
279
+ }
280
+ // Deliberately no `connect` event: the runtime's native pipe does not emit one,
281
+ // and waiting for it meant the channel never came up. The first write is
282
+ // buffered by the fallback and sent immediately by the native pipe.
283
+ socket.setNoDelay?.(true)
284
+ socket.on('data', (chunk) => {
285
+ controlBuffer = Buffer.concat([controlBuffer, Buffer.from(chunk)])
286
+ if (controlBuffer.length > 256 * 1024) {
287
+ controlBuffer = Buffer.alloc(0)
288
+ return
289
+ }
290
+ for (;;) {
291
+ const newline = controlBuffer.indexOf(10)
292
+ if (newline < 0) break
293
+ const line = controlBuffer.subarray(0, newline).toString('utf8')
294
+ controlBuffer = controlBuffer.subarray(newline + 1)
295
+ if (line.trim() !== '') onControlLine(line)
296
+ }
297
+ })
298
+ socket.on('error', (error) => {
299
+ lastReport = `control: channel error ${String(error?.message ?? error).slice(0, 90)}`
300
+ controlSocket = undefined
301
+ })
302
+ socket.on('close', () => { controlSocket = undefined })
303
+ controlSocket = socket
304
+ sendControl({ type: 'service' })
305
+ trace('control: connected to the plugin')
306
+ return socket
307
+ })().finally(() => { controlConnecting = undefined })
308
+ return controlConnecting
309
+ }
310
+
311
+ /**
312
+ * Answer one control request from the plugin.
313
+ *
314
+ * Every question here is one only this process can answer: it holds the turn
315
+ * metadata the runtime keys the held applications by.
316
+ */
317
+ async function handleControl(request) {
318
+ const matches = [...activeContexts.values()].filter(
319
+ (item) => item.session_id === request.session_id && item.turn_id === request.turn_id,
320
+ )
321
+ if (matches.length === 0) {
322
+ // The phrase the plugin's client reads as "nothing is held": a turn this
323
+ // runtime never saw has nothing to release, which is not a failure.
324
+ throw fail('The requested session and turn are not active in the trusted runtime')
325
+ }
326
+
327
+ const client = await appClient()
328
+ const status = await client.request('ComputerUseIPCCodexStatusItemMenuStateRequest', {}, {
329
+ codexMetadata: matches[0].metadata,
330
+ timeoutSeconds: STATUS_TIMEOUT_SECONDS,
331
+ })
332
+ const active = status?.computerUse?.activeApplications
333
+ if (!Array.isArray(active) || active.some((app) => app === null || typeof app?.bundleIdentifier !== 'string')) {
334
+ throw fail('the runtime returned an invalid active application list')
335
+ }
336
+
337
+ // Which of those this session's turns may see: the ones they named, plus the
338
+ // ones the runtime says the turn is allowed to use.
339
+ const targeted = new Map()
340
+ const deadline = Date.now() + POLICY_BUDGET_MS
341
+ for (const item of matches) {
342
+ if (typeof item.app !== 'string' || item.app === '') continue
343
+ if (active.some((app) => app.bundleIdentifier === item.app)) {
344
+ targeted.set(item, item.app)
345
+ continue
346
+ }
347
+ if (Date.now() >= deadline) continue
348
+ try {
349
+ const policy = await client.getAppPolicy(item.app, {
350
+ codexMetadata: item.metadata,
351
+ timeoutSeconds: STATUS_TIMEOUT_SECONDS,
352
+ })
353
+ const bundle = policy?.target?.bundleIdentifier
354
+ if (policy?.decision === 'allowed' && typeof bundle === 'string' && bundle.trim() !== '') {
355
+ targeted.set(item, bundle)
356
+ }
357
+ } catch {
358
+ // An application this turn cannot use is simply not visible to it.
359
+ }
360
+ }
361
+ const visible = active.filter((app) => new Set(targeted.values()).has(app.bundleIdentifier))
362
+
363
+ if (request.type === 'status') {
364
+ return { computerUse: { ...status.computerUse, activeApplications: visible }, computerHistory: status.computerHistory }
365
+ }
366
+
367
+ if (typeof request.app !== 'string' || !visible.some((app) => app.bundleIdentifier === request.app)) {
368
+ throw fail('the selected application is not targeted by an active computer-use call')
369
+ }
370
+ const selected = visible.find((app) => app.bundleIdentifier === request.app)
371
+ if (typeof selected?.id !== 'string' || selected.id.trim() === '') {
372
+ throw fail('the runtime did not provide the selected application id')
373
+ }
374
+ const context = matches.find((item) => targeted.get(item) === request.app)
375
+ if (context === undefined) throw fail('the turn ended before Stop could be sent')
376
+
377
+ await client.request('ComputerUseIPCAppStopRequest', { app: selected.id }, {
378
+ codexMetadata: context.metadata,
379
+ timeoutSeconds: STOP_TIMEOUT_SECONDS,
380
+ })
381
+ // The shape the plugin's client confirms against: a Stop is only done when the
382
+ // runtime accepted it *and* the application it acted on is the one asked for.
383
+ return { accepted: true, applicationId: request.app }
384
+ }
385
+
386
+ function onControlLine(line) {
387
+ let message
388
+ try {
389
+ message = JSON.parse(line)
390
+ } catch {
391
+ return
392
+ }
393
+ if (message === null || typeof message !== 'object' || typeof message.id !== 'number') return
394
+ const id = message.id
395
+ void (async () => {
396
+ try {
397
+ sendControl({ id, ok: true, result: await handleControl(message) })
398
+ } catch (error) {
399
+ sendControl({ id, ok: false, error: String(error?.message ?? error).slice(0, 400) })
400
+ }
401
+ })()
402
+ }
403
+
404
+ /** Report one turn's context, so a later request can be attributed to it. */
405
+ async function registerContext(carried, request) {
406
+ if (carried === undefined) return
407
+ const app = appFromRequest(request)
408
+ if (typeof app !== 'string' || app === '') return
409
+ const token = JSON.stringify([carried.session_id, carried.turn_id, app])
410
+ if (activeContexts.has(token) || activeContexts.size >= CONTEXT_LIMIT) return
411
+ const socket = await controlChannel()
412
+ if (socket === undefined) return
413
+ activeContexts.set(token, { session_id: carried.session_id, turn_id: carried.turn_id, app, metadata: carried })
414
+ sendControl({ type: 'context', token, session_id: carried.session_id, turn_id: carried.turn_id, app })
415
+ }
416
+
417
+ /** Forget a finished turn's contexts. */
418
+ function endControlTurn(session, turn) {
419
+ for (const [token, item] of activeContexts) {
420
+ if (item.session_id === session && item.turn_id === turn) {
421
+ activeContexts.delete(token)
422
+ sendControl({ type: 'context-ended', token })
423
+ }
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Handle one Sky request.
429
+ *
430
+ * Exported because this module *is* the trusted service: the runtime calls this
431
+ * for every request, and the application's own service does the work.
432
+ *
433
+ * @param request - the runtime's Sky request.
434
+ * @returns whatever the application's own service returns.
435
+ */
436
+ export async function handleRpc(request) {
437
+ if (runtime()?.env?.DSH_SKY_FORCE_ERROR === '1') {
438
+ throw fail('the wrapper is active (DSH_SKY_FORCE_ERROR=1)')
439
+ }
440
+ // Console output and file writes are both unavailable inside this sandbox, so
441
+ // the only trustworthy way to report a cleanup is to fail a later call with it.
442
+ if (runtime()?.env?.DSH_SKY_REPORT === '1' && lastReport !== undefined) {
443
+ const report = lastReport
444
+ lastReport = undefined
445
+ throw fail(`last cleanup ${report}`)
446
+ }
447
+ register()
448
+ // A previous turn's cleanup is retried before the next request, because the
449
+ // application refuses some actions while it still believes that turn is open.
450
+ await settlePending()
451
+
452
+ const rt = runtime()
453
+ const servicePath = rt?.env?.DSH_SKY_SERVICE_PATH
454
+ if (typeof servicePath !== 'string' || servicePath === '') {
455
+ throw fail('DSH_SKY_SERVICE_PATH is not set')
456
+ }
457
+ original ??= import(pathToFileURL(servicePath).href)
458
+
459
+ if (runtime()?.env?.DSH_SKY_DEBUG === '1' && lastReport === undefined) {
460
+ const first = Array.isArray(request?.args) ? request.args[0] : undefined
461
+ const shape = typeof first === 'string'
462
+ ? first
463
+ : first === null || first === undefined
464
+ ? String(first)
465
+ : `keys=${Object.keys(first).join('+')} app=${typeof first.app}`
466
+ lastReport = `request type=${String(request?.type)} method=${String(request?.method)} arg0=${shape}`
467
+ }
468
+ const carried = readMetadata()
469
+ trace(`handleRpc requestMeta=${String(runtime()?.requestMeta === undefined ? 'absent' : JSON.stringify(Object.keys(runtime()?.requestMeta ?? {})))} identity=${carried === undefined ? 'none' : String(carried.turn_id)}`)
470
+ if (carried !== undefined) {
471
+ const key = JSON.stringify([carried.session_id, carried.turn_id])
472
+ if (!metadata.has(key) && metadata.size >= METADATA_LIMIT) {
473
+ throw fail('too many turn identities are open; refusing a new request until cleanup settles')
474
+ }
475
+ metadata.set(key, carried)
476
+ await registerContext(carried, request)
477
+ }
478
+
479
+ const service = await original
480
+ if (typeof service?.handleRpc !== 'function') {
481
+ throw fail(`the application service at ${servicePath} does not export handleRpc`)
482
+ }
483
+ return await service.handleRpc(request)
484
+ }