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.
- package/CHANGELOG.md +60 -0
- package/README.md +174 -104
- package/cordis.patch.yml +11 -6
- package/docs/README.zh.md +154 -168
- package/helper/sky-service.mjs +484 -0
- package/lib/app.js +284 -0
- package/lib/app.js.map +1 -0
- package/lib/approval.js +45 -4
- package/lib/approval.js.map +1 -1
- package/lib/connection.js +82 -35
- package/lib/connection.js.map +1 -1
- package/lib/control.js +214 -0
- package/lib/control.js.map +1 -0
- package/lib/diag.js +1 -1
- package/lib/host-guard.js +2 -2
- package/lib/index.js +100 -30
- package/lib/index.js.map +1 -1
- package/lib/session.js +137 -0
- package/lib/session.js.map +1 -0
- package/lib/tool.js +7 -4
- package/lib/tool.js.map +1 -1
- package/lib/types/app.d.ts +118 -0
- package/lib/types/app.d.ts.map +1 -0
- package/lib/types/approval.d.ts +28 -3
- package/lib/types/approval.d.ts.map +1 -1
- package/lib/types/connection.d.ts +27 -13
- package/lib/types/connection.d.ts.map +1 -1
- package/lib/types/control.d.ts +54 -0
- package/lib/types/control.d.ts.map +1 -0
- package/lib/types/diag.d.ts +1 -1
- package/lib/types/host-guard.d.ts +2 -2
- package/lib/types/index.d.ts +26 -6
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/session.d.ts +89 -0
- package/lib/types/session.d.ts.map +1 -0
- package/lib/types/tool.d.ts +6 -5
- package/lib/types/tool.d.ts.map +1 -1
- package/package.json +8 -6
- package/scripts/probe-lcu.mjs +40 -18
- package/scripts/gen-presets.mjs +0 -266
|
@@ -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
|
+
}
|