mirab 0.0.0-stage → 0.2.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.
Files changed (105) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +226 -2
  3. package/package.json +58 -4
  4. package/src/agent/approved.ts +261 -0
  5. package/src/agent/define.ts +235 -0
  6. package/src/agent/guest.ts +138 -0
  7. package/src/agent/init.ts +150 -0
  8. package/src/agent/manifest.ts +141 -0
  9. package/src/agent/modules.ts +23 -0
  10. package/src/agent/runtime.ts +239 -0
  11. package/src/agent-profiles.ts +327 -0
  12. package/src/api/bootstrap-contract.ts +95 -0
  13. package/src/api/channel-contract.ts +19 -0
  14. package/src/api/client.ts +100 -0
  15. package/src/api/contract.ts +2454 -0
  16. package/src/api/events-contract.ts +103 -0
  17. package/src/api/import-contract.ts +22 -0
  18. package/src/api/orchestrator-contract.ts +73 -0
  19. package/src/api/runner-contract.ts +25 -0
  20. package/src/api/scopes.ts +41 -0
  21. package/src/api/sessions-contract.ts +71 -0
  22. package/src/api/settings-contract.ts +50 -0
  23. package/src/channel/bridge.ts +97 -0
  24. package/src/channel/local.ts +154 -0
  25. package/src/channel/state.ts +11 -0
  26. package/src/channel/wire.ts +104 -0
  27. package/src/dev-sync.ts +206 -0
  28. package/src/dev.ts +453 -0
  29. package/src/events-tail.ts +75 -0
  30. package/src/images.ts +498 -0
  31. package/src/index.ts +21 -0
  32. package/src/invocation.ts +34 -0
  33. package/src/isolation/anchored-fs.ts +284 -0
  34. package/src/isolation/colima.ts +159 -0
  35. package/src/isolation/container.ts +189 -0
  36. package/src/isolation/firecracker.ts +414 -0
  37. package/src/isolation/guest-protocol.json +1 -0
  38. package/src/isolation/host-user.ts +247 -0
  39. package/src/isolation/image.ts +336 -0
  40. package/src/isolation/index.ts +477 -0
  41. package/src/isolation/multipass.ts +541 -0
  42. package/src/isolation/network.ts +167 -0
  43. package/src/isolation/policy.ts +86 -0
  44. package/src/isolation/session.ts +385 -0
  45. package/src/isolation/sync.ts +329 -0
  46. package/src/isolation/vz-network-setup.ts +247 -0
  47. package/src/isolation/vz.ts +312 -0
  48. package/src/mirab-main.ts +536 -0
  49. package/src/mirab.ts +13 -0
  50. package/src/runner/define.ts +187 -0
  51. package/src/runner/image.ts +239 -0
  52. package/src/runner/index.ts +37 -0
  53. package/src/runner/serve.ts +491 -0
  54. package/src/runner/toolsets.ts +205 -0
  55. package/src/spawn-adapters/claude.ts +112 -0
  56. package/src/spawn-adapters/codex-logs.ts +130 -0
  57. package/src/spawn-adapters/codex-notify.ts +73 -0
  58. package/src/spawn-adapters/codex.ts +89 -0
  59. package/src/spawn-adapters/index.ts +40 -0
  60. package/src/spawn-adapters/types.ts +55 -0
  61. package/src/spawn-attach.ts +243 -0
  62. package/src/spawn-binary.ts +90 -0
  63. package/src/spawn-bridge.ts +504 -0
  64. package/src/spawn-bundle.ts +16 -0
  65. package/src/spawn-detach.ts +301 -0
  66. package/src/spawn-events.ts +35 -0
  67. package/src/spawn-hook.ts +86 -0
  68. package/src/spawn-input.ts +107 -0
  69. package/src/spawn-native.ts +49 -0
  70. package/src/spawn-nudge-worker.ts +61 -0
  71. package/src/spawn-nudge.ts +446 -0
  72. package/src/spawn-output.ts +45 -0
  73. package/src/spawn-queue.ts +135 -0
  74. package/src/spawn-quiet.ts +69 -0
  75. package/src/spawn-report.ts +114 -0
  76. package/src/spawn-sessions.ts +166 -0
  77. package/src/spawn-vm.ts +356 -0
  78. package/src/spawn-worker.ts +766 -0
  79. package/src/spawn.ts +468 -0
  80. package/src/station/config.ts +89 -0
  81. package/src/station/gateway.ts +94 -0
  82. package/src/station/vault.ts +96 -0
  83. package/src/test-cli.ts +90 -0
  84. package/src/tools.ts +3 -0
  85. package/types/agent/define.d.ts +76 -0
  86. package/types/agent/manifest.d.ts +53 -0
  87. package/types/agent/modules.d.ts +1 -0
  88. package/types/images.d.ts +124 -0
  89. package/types/index.d.ts +6 -0
  90. package/types/isolation/colima.d.ts +37 -0
  91. package/types/isolation/container.d.ts +25 -0
  92. package/types/isolation/firecracker.d.ts +55 -0
  93. package/types/isolation/host-user.d.ts +47 -0
  94. package/types/isolation/image.d.ts +158 -0
  95. package/types/isolation/index.d.ts +128 -0
  96. package/types/isolation/network.d.ts +33 -0
  97. package/types/isolation/policy.d.ts +29 -0
  98. package/types/isolation/session.d.ts +99 -0
  99. package/types/isolation/vz.d.ts +63 -0
  100. package/types/runner/define.d.ts +101 -0
  101. package/types/runner/image.d.ts +52 -0
  102. package/types/runner/index.d.ts +13 -0
  103. package/types/runner/serve.d.ts +69 -0
  104. package/types/runner/toolsets.d.ts +35 -0
  105. package/types/tools.d.ts +2 -0
@@ -0,0 +1,86 @@
1
+ // Isolation policy: which level a run gets, and when it is refused.
2
+ //
3
+ // Levels, strongest first. `none` is never picked automatically: it needs an
4
+ // explicit request and is labelled unisolated everywhere it is shown.
5
+ export const LEVELS = ['vm', 'container', 'host', 'none'] as const
6
+ export type Level = (typeof LEVELS)[number]
7
+
8
+ export const strength = (level: Level) => LEVELS.length - LEVELS.indexOf(level)
9
+
10
+ export type RequestSource = 'flag' | 'job' | 'profile' | 'host-default' | 'auto'
11
+
12
+ export interface PolicyInput {
13
+ /** `--isolation` on this run. Most specific, wins over everything below. */
14
+ flag?: Level
15
+ /** `isolation:` on the workflow job. */
16
+ job?: Level
17
+ /** `isolation` in the agent profile. */
18
+ profile?: Level
19
+ /** The host default the owner set once in the host config. */
20
+ hostDefault?: Level
21
+ /** Owner floor: levels weaker than this are refused, never upgraded. */
22
+ floor?: Level
23
+ /** Backends usable on this machine. */
24
+ available: readonly Level[]
25
+ }
26
+
27
+ export interface Resolved {
28
+ level: Level
29
+ source: RequestSource
30
+ /** Set when the requested level could not be had and a weaker tier took the run. */
31
+ fallback?: string
32
+ }
33
+
34
+ export class IsolationRefused extends Error {}
35
+
36
+ export function parseLevel(
37
+ text: string | undefined,
38
+ where: string,
39
+ ): Level | undefined {
40
+ if (text === undefined) return undefined
41
+ if ((LEVELS as readonly string[]).includes(text)) return text as Level
42
+ throw new IsolationRefused(
43
+ `${where}: unknown isolation "${text}" (use ${LEVELS.join(', ')})`,
44
+ )
45
+ }
46
+
47
+ export function resolveIsolation(input: PolicyInput): Resolved {
48
+ const asked: [Level | undefined, RequestSource][] = [
49
+ [input.flag, 'flag'],
50
+ [input.job, 'job'],
51
+ [input.profile, 'profile'],
52
+ [input.hostDefault, 'host-default'],
53
+ ]
54
+ const hit = asked.find(([level]) => level !== undefined)
55
+ const floor = input.floor
56
+ let chosen: Resolved
57
+ if (hit) {
58
+ chosen = { level: hit[0]!, source: hit[1] }
59
+ } else {
60
+ // Nothing asked: strongest available level that satisfies the floor.
61
+ const auto = LEVELS.filter((l) => l !== 'none').find((l) =>
62
+ input.available.includes(l),
63
+ )
64
+ if (auto && (!floor || strength(auto) >= strength(floor)))
65
+ return { level: auto, source: 'auto' }
66
+ throw new IsolationRefused(
67
+ floor
68
+ ? `this host requires isolation "${floor}" or stronger and none is available (available: ${input.available.join(', ') || 'none'})`
69
+ : `no isolation backend is available on this host (tried vm, container, host)`,
70
+ )
71
+ }
72
+ if (floor && strength(chosen.level) < strength(floor))
73
+ throw new IsolationRefused(
74
+ `isolation "${chosen.level}" (from ${chosen.source}) is below this host's floor "${floor}"`,
75
+ )
76
+ if (!input.available.includes(chosen.level))
77
+ throw new IsolationRefused(
78
+ `isolation "${chosen.level}" (from ${chosen.source}) is not available on this host (available: ${input.available.join(', ') || 'none'})`,
79
+ )
80
+ return chosen
81
+ }
82
+
83
+ export const labelFor = (level: Level, backend: string) =>
84
+ level === 'none'
85
+ ? 'unisolated (runs as the runner user)'
86
+ : `${level} (${backend})`
@@ -0,0 +1,385 @@
1
+ // One exec contract for every isolation backend (see guest-agent/src/main.rs).
2
+ // A backend only has to open a byte channel to the guest agent; the runner and
3
+ // spawn code talk to `Isolation` and never branch on the backend.
4
+ import type { Level } from './policy'
5
+ import shared from './guest-protocol.json'
6
+
7
+ /** The guest-agent wire protocol this CLI speaks; guest-agent/src/main.rs reads the same file. */
8
+ export const GUEST_PROTOCOL: number = shared.protocol
9
+ /** Where the guest image is rebuilt from; printed with every mismatch. */
10
+ export const REBUILD_HINT =
11
+ process.platform === 'darwin'
12
+ ? 'Rebuild the guest image from a mirab checkout at this version: `scripts/build-vm-guest.sh <dir>` (docs/VM.md)'
13
+ : 'Rebuild the guest image from a mirab checkout at this version: `bun run vm:image` (docs/VM.md)'
14
+
15
+ export interface Channel {
16
+ write(data: Uint8Array): void
17
+ onData(cb: (chunk: Buffer) => void): void
18
+ onClose(cb: (error?: Error) => void): void
19
+ close(): void
20
+ }
21
+
22
+ export interface ExecOptions {
23
+ cwd: string
24
+ env: Record<string, string>
25
+ timeoutMs: number
26
+ signal: AbortSignal
27
+ /** One call per output line (stdout and stderr), already de-framed. */
28
+ onLine: (line: string) => Promise<void>
29
+ }
30
+
31
+ export interface Isolation {
32
+ readonly level: Level
33
+ readonly backend: string
34
+ /** What `mirab status` and job logs print. */
35
+ readonly label: string
36
+ /** Host path of the job work directory -> where the guest sees it. */
37
+ guestPath(hostPath: string): string
38
+ put(
39
+ hostPath: string,
40
+ content: string | Uint8Array,
41
+ mode?: number,
42
+ ): Promise<void>
43
+ /** Run a step; resolves with its exit code (124 timeout, 130 cancelled). */
44
+ exec(argv: string[], options: ExecOptions): Promise<number>
45
+ /** Interactive program on a guest pty (spawn --vm). */
46
+ pty?(request: PtyRequest): Promise<PtyHandle>
47
+ /** Read the guest file tree (working-directory sync); backends without a copy omit it. */
48
+ files?: GuestFiles
49
+ /** Messages the guest initiates toward the host on a vsock port (hooks). */
50
+ onGuestMessage?(port: number, handler: (message: any) => void): void
51
+ /** Serve the VM's guest-to-host `port` (e.g. the station's egress): `serve`
52
+ * listens on the socket path given, which belongs to this VM alone. */
53
+ onGuestConnect?(
54
+ port: number,
55
+ serve: (socketPath: string) => Promise<import('node:net').Server>,
56
+ ): Promise<void>
57
+ /** Stop the backend; preserve its workspace for recovery after incomplete sync. */
58
+ close(options?: {
59
+ preserveWorkspace?: boolean
60
+ /** Keep this VM's root disk (its own copy) at this path once it has
61
+ * stopped: how an agent's image is built and updated. */
62
+ keepRoot?: string
63
+ }): Promise<void>
64
+ }
65
+
66
+ /** One entry under a sync root, the same shape the guest agent's `list` op returns. */
67
+ export interface Entry {
68
+ /** Path relative to the root, `/`-separated. */
69
+ p: string
70
+ /** `f` regular file, `d` directory, `l` symlink. */
71
+ k: 'f' | 'd' | 'l'
72
+ /** Permission bits (0 for symlinks). */
73
+ m: number
74
+ s?: number
75
+ /** sha256 hex of the content (files). */
76
+ h?: string
77
+ /** Link target (symlinks). */
78
+ t?: string
79
+ }
80
+
81
+ export interface GuestFiles {
82
+ list(root: string): Promise<Entry[]>
83
+ read(path: string): Promise<Buffer>
84
+ }
85
+
86
+ export interface PtyRequest {
87
+ argv: string[]
88
+ env: Record<string, string>
89
+ cwd: string
90
+ cols: number
91
+ rows: number
92
+ }
93
+
94
+ export interface PtyHandle {
95
+ write(data: Uint8Array | string): void
96
+ resize(cols: number, rows: number): void
97
+ onData(cb: (data: Buffer) => void): void
98
+ onExit(cb: (code: number) => void): void
99
+ /** Drop the connection: the guest hangs up the session. */
100
+ close(): void
101
+ }
102
+
103
+ export type Opener = () => Promise<Channel>
104
+
105
+ const MAX_LINE = 32768
106
+
107
+ function frame(value: unknown): Buffer {
108
+ const body = Buffer.from(JSON.stringify(value))
109
+ const head = Buffer.alloc(4)
110
+ head.writeUInt32BE(body.length)
111
+ return Buffer.concat([head, body])
112
+ }
113
+
114
+ type Reply =
115
+ | { t: 'out' | 'err'; d: string }
116
+ | { t: 'entries'; e: Entry[] }
117
+ | { t: 'exit'; code: number; timed_out: boolean }
118
+ | { t: 'ok'; protocol?: number; agent?: string }
119
+ | { t: 'error'; message: string }
120
+
121
+ /** Send one request and stream replies until `done` returns a value. */
122
+ function converse<T>(
123
+ open: Opener,
124
+ request: unknown,
125
+ signal: AbortSignal | undefined,
126
+ onReply: (r: Reply) => T | undefined | Promise<T | undefined>,
127
+ ): Promise<T> {
128
+ return new Promise<T>(async (resolve, reject) => {
129
+ let ch: Channel
130
+ try {
131
+ ch = await open()
132
+ } catch (e) {
133
+ return reject(e)
134
+ }
135
+ let buf = Buffer.alloc(0),
136
+ finished = false,
137
+ chain = Promise.resolve()
138
+ const end = (fn: () => void) => {
139
+ if (finished) return
140
+ finished = true
141
+ signal?.removeEventListener('abort', abort)
142
+ fn()
143
+ ch.close()
144
+ }
145
+ const abort = () => ch.close() // guest kills the step when the connection drops
146
+ signal?.addEventListener('abort', abort, { once: true })
147
+ ch.onData((chunk) => {
148
+ buf = Buffer.concat([buf, chunk])
149
+ while (buf.length >= 4 && buf.length >= 4 + buf.readUInt32BE(0)) {
150
+ const n = buf.readUInt32BE(0)
151
+ const reply = JSON.parse(buf.subarray(4, 4 + n).toString()) as Reply
152
+ buf = buf.subarray(4 + n)
153
+ chain = chain.then(async () => {
154
+ if (finished) return
155
+ try {
156
+ const out = await onReply(reply)
157
+ if (out !== undefined) end(() => resolve(out))
158
+ } catch (e) {
159
+ end(() => reject(e))
160
+ }
161
+ })
162
+ }
163
+ })
164
+ ch.onClose((error) => {
165
+ chain = chain.then(() =>
166
+ end(() => reject(error ?? new Error('guest connection closed early'))),
167
+ )
168
+ })
169
+ ch.write(frame(request))
170
+ })
171
+ }
172
+
173
+ export interface GuestHello {
174
+ /** 1 for agents that predate the handshake (their ping reply has no number). */
175
+ protocol: number
176
+ agent?: string
177
+ }
178
+
179
+ export async function guestPing(open: Opener): Promise<GuestHello> {
180
+ return converse<GuestHello>(open, { op: 'ping' }, undefined, (r) =>
181
+ r.t === 'ok'
182
+ ? {
183
+ protocol: typeof r.protocol === 'number' ? r.protocol : 1,
184
+ agent: r.agent,
185
+ }
186
+ : undefined,
187
+ )
188
+ }
189
+
190
+ export class GuestProtocolError extends Error {}
191
+
192
+ /** Refuse a guest agent that speaks another protocol, at connect time and
193
+ * with the fix, instead of failing mid-session on an op it lacks. */
194
+ export function checkGuestProtocol(hello: GuestHello, image: string) {
195
+ if (hello.protocol === GUEST_PROTOCOL) return
196
+ const agent = `mirab-guest-agent ${hello.agent ?? '(no version, before 0.2.0)'}`
197
+ throw new GuestProtocolError(
198
+ hello.protocol < GUEST_PROTOCOL
199
+ ? `The guest image ${image} is outdated: its ${agent} speaks protocol ${hello.protocol}, this mirab needs ${GUEST_PROTOCOL}. ${REBUILD_HINT}.`
200
+ : `The guest image ${image} is newer than this mirab: its ${agent} speaks protocol ${hello.protocol}, this mirab speaks ${GUEST_PROTOCOL}. Update mirab, or ${REBUILD_HINT.charAt(0).toLowerCase() + REBUILD_HINT.slice(1)}.`,
201
+ )
202
+ }
203
+
204
+ export async function guestPut(
205
+ open: Opener,
206
+ path: string,
207
+ content: string | Uint8Array,
208
+ mode = 0o600,
209
+ ) {
210
+ await converse<true>(
211
+ open,
212
+ { op: 'put', path, mode, content: Buffer.from(content).toString('base64') },
213
+ undefined,
214
+ (r) => {
215
+ if (r.t === 'error') throw new Error(r.message)
216
+ return r.t === 'ok' ? true : undefined
217
+ },
218
+ )
219
+ }
220
+
221
+ export function guestFiles(open: Opener): GuestFiles {
222
+ return {
223
+ async list(root) {
224
+ const all: Entry[] = []
225
+ await converse<true>(open, { op: 'list', root }, undefined, (r) => {
226
+ if (r.t === 'error') throw new Error(r.message)
227
+ if (r.t === 'entries') all.push(...r.e)
228
+ return r.t === 'ok' ? true : undefined
229
+ })
230
+ return all
231
+ },
232
+ async read(path) {
233
+ const parts: Buffer[] = []
234
+ await converse<true>(open, { op: 'get', path }, undefined, (r) => {
235
+ if (r.t === 'error') throw new Error(r.message)
236
+ if (r.t === 'out') parts.push(Buffer.from(r.d, 'base64'))
237
+ return r.t === 'ok' ? true : undefined
238
+ })
239
+ return Buffer.concat(parts)
240
+ },
241
+ }
242
+ }
243
+
244
+ export async function guestExec(
245
+ open: Opener,
246
+ argv: string[],
247
+ o: ExecOptions,
248
+ ): Promise<number> {
249
+ if (o.signal.aborted) return 130
250
+ const pending = { out: '', err: '' },
251
+ decoders = { out: new TextDecoder(), err: new TextDecoder() }
252
+ let failure: Error | undefined
253
+ const feed = async (which: 'out' | 'err', data: Buffer) => {
254
+ pending[which] += decoders[which].decode(data, { stream: true })
255
+ let at: number
256
+ while ((at = pending[which].indexOf('\n')) >= 0) {
257
+ if (at > MAX_LINE) return tooLong(which)
258
+ await o.onLine(pending[which].slice(0, at).replace(/\r$/, ''))
259
+ pending[which] = pending[which].slice(at + 1)
260
+ }
261
+ if (pending[which].length > MAX_LINE) tooLong(which)
262
+ }
263
+ // Same rule as host steps: never emit the tail of an oversized line, it could
264
+ // be half of a secret split across chunks.
265
+ const tooLong = (which: 'out' | 'err') => {
266
+ failure = new Error('output line exceeds 32 KiB')
267
+ pending[which] = ''
268
+ ctl.abort()
269
+ }
270
+ const ctl = new AbortController(),
271
+ relay = () => ctl.abort()
272
+ o.signal.addEventListener('abort', relay, { once: true })
273
+ try {
274
+ const code = await converse<number>(
275
+ open,
276
+ { op: 'exec', argv, env: o.env, cwd: o.cwd, timeout_ms: o.timeoutMs },
277
+ ctl.signal,
278
+ async (r) => {
279
+ if (failure) return undefined
280
+ if (r.t === 'out' || r.t === 'err') {
281
+ await feed(r.t, Buffer.from(r.d, 'base64'))
282
+ return undefined
283
+ }
284
+ if (r.t === 'error') throw new Error(r.message)
285
+ if (r.t === 'exit') {
286
+ for (const w of ['out', 'err'] as const) {
287
+ pending[w] += decoders[w].decode()
288
+ if (pending[w]) await o.onLine(pending[w])
289
+ }
290
+ if (r.timed_out)
291
+ await o.onLine(
292
+ `[mirab: step timeout after ${o.timeoutMs / 60000} minutes]`,
293
+ )
294
+ return r.code
295
+ }
296
+ return undefined
297
+ },
298
+ ).catch((e) => {
299
+ if (failure) throw failure
300
+ if (o.signal.aborted) return 130
301
+ throw e
302
+ })
303
+ if (failure) throw failure
304
+ return code
305
+ } finally {
306
+ o.signal.removeEventListener('abort', relay)
307
+ }
308
+ }
309
+
310
+ export async function guestPty(
311
+ open: Opener,
312
+ request: PtyRequest,
313
+ ): Promise<PtyHandle> {
314
+ const ch = await open()
315
+ // Every onData listener sees every byte (a detached session has two: the
316
+ // output log and the quiet detector). Output that arrives before the first
317
+ // listener is held for it, and an exit that comes before onExit is replayed.
318
+ let buf = Buffer.alloc(0),
319
+ exitCode: number | undefined,
320
+ pending: Buffer[] | undefined = []
321
+ const dataCbs: ((d: Buffer) => void)[] = [],
322
+ exitCbs: ((code: number) => void)[] = []
323
+ const emit = (d: Buffer) => {
324
+ if (pending) pending.push(d)
325
+ else for (const cb of dataCbs) cb(d)
326
+ }
327
+ const done = (code: number) => {
328
+ if (exitCode !== undefined) return
329
+ exitCode = code
330
+ for (const cb of exitCbs) cb(code)
331
+ }
332
+ ch.onData((chunk) => {
333
+ buf = Buffer.concat([buf, chunk])
334
+ while (buf.length >= 4 && buf.length >= 4 + buf.readUInt32BE(0)) {
335
+ const n = buf.readUInt32BE(0)
336
+ const r = JSON.parse(buf.subarray(4, 4 + n).toString()) as Reply
337
+ buf = buf.subarray(4 + n)
338
+ if (r.t === 'out') emit(Buffer.from(r.d, 'base64'))
339
+ else if (r.t === 'exit') done(r.code)
340
+ else if (r.t === 'error') {
341
+ emit(Buffer.from(`mirab: ${r.message}\r\n`))
342
+ done(127)
343
+ }
344
+ }
345
+ })
346
+ ch.onClose(() => done(129))
347
+ ch.write(frame({ op: 'pty', ...request }))
348
+ return {
349
+ write: (d) =>
350
+ ch.write(frame({ t: 'in', d: Buffer.from(d).toString('base64') })),
351
+ resize: (cols, rows) => ch.write(frame({ t: 'resize', cols, rows })),
352
+ onData: (cb) => {
353
+ dataCbs.push(cb)
354
+ if (!pending) return
355
+ const held = pending
356
+ pending = undefined
357
+ for (const d of held) cb(d)
358
+ },
359
+ onExit: (cb) => {
360
+ exitCbs.push(cb)
361
+ if (exitCode !== undefined) cb(exitCode)
362
+ },
363
+ close: () => ch.close(),
364
+ }
365
+ }
366
+
367
+ /** Read one length-prefixed JSON message from a connection the guest opened. */
368
+ export function readOneMessage(stream: {
369
+ on(event: 'data', cb: (c: Buffer) => void): unknown
370
+ }): Promise<any> {
371
+ return new Promise((resolve, reject) => {
372
+ let buf = Buffer.alloc(0)
373
+ stream.on('data', (chunk) => {
374
+ buf = Buffer.concat([buf, chunk])
375
+ if (buf.length >= 4 && buf.length >= 4 + buf.readUInt32BE(0))
376
+ try {
377
+ resolve(
378
+ JSON.parse(buf.subarray(4, 4 + buf.readUInt32BE(0)).toString()),
379
+ )
380
+ } catch (e) {
381
+ reject(e)
382
+ }
383
+ })
384
+ })
385
+ }