@namzu/sdk 52.3.0 → 52.4.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 (46) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/connector/web/guarded-fetch.d.ts.map +1 -1
  3. package/dist/connector/web/guarded-fetch.js +44 -0
  4. package/dist/connector/web/guarded-fetch.js.map +1 -1
  5. package/dist/process/kill-tree.d.ts +3 -2
  6. package/dist/process/kill-tree.d.ts.map +1 -1
  7. package/dist/process/kill-tree.js.map +1 -1
  8. package/dist/public-runtime.d.ts +1 -1
  9. package/dist/public-runtime.d.ts.map +1 -1
  10. package/dist/public-tools.d.ts +2 -1
  11. package/dist/public-tools.d.ts.map +1 -1
  12. package/dist/public-tools.js +1 -1
  13. package/dist/public-tools.js.map +1 -1
  14. package/dist/runtime/jobs/registry.d.ts +80 -2
  15. package/dist/runtime/jobs/registry.d.ts.map +1 -1
  16. package/dist/runtime/jobs/registry.js +70 -5
  17. package/dist/runtime/jobs/registry.js.map +1 -1
  18. package/dist/runtime/jobs/websocket-source.d.ts +120 -0
  19. package/dist/runtime/jobs/websocket-source.d.ts.map +1 -0
  20. package/dist/runtime/jobs/websocket-source.js +379 -0
  21. package/dist/runtime/jobs/websocket-source.js.map +1 -0
  22. package/dist/runtime/query/steering.d.ts +2 -2
  23. package/dist/runtime/query/steering.d.ts.map +1 -1
  24. package/dist/runtime/query/steering.js +16 -3
  25. package/dist/runtime/query/steering.js.map +1 -1
  26. package/dist/tools/builtins/monitor.d.ts +38 -4
  27. package/dist/tools/builtins/monitor.d.ts.map +1 -1
  28. package/dist/tools/builtins/monitor.js +236 -97
  29. package/dist/tools/builtins/monitor.js.map +1 -1
  30. package/dist/types/sandbox/index.d.ts +10 -0
  31. package/dist/types/sandbox/index.d.ts.map +1 -1
  32. package/dist/types/sandbox/index.js.map +1 -1
  33. package/dist/types/tool/index.d.ts +35 -0
  34. package/dist/types/tool/index.d.ts.map +1 -1
  35. package/dist/types/tool/index.js.map +1 -1
  36. package/package.json +4 -2
  37. package/src/connector/web/guarded-fetch.ts +41 -0
  38. package/src/process/kill-tree.ts +4 -2
  39. package/src/public-runtime.ts +1 -1
  40. package/src/public-tools.ts +3 -0
  41. package/src/runtime/jobs/registry.ts +162 -13
  42. package/src/runtime/jobs/websocket-source.ts +438 -0
  43. package/src/runtime/query/steering.ts +32 -6
  44. package/src/tools/builtins/monitor.ts +293 -123
  45. package/src/types/sandbox/index.ts +10 -0
  46. package/src/types/tool/index.ts +27 -0
@@ -0,0 +1,438 @@
1
+ import { lookup as dnsLookup } from 'node:dns/promises'
2
+ import { EventEmitter } from 'node:events'
3
+ import { isIP } from 'node:net'
4
+
5
+ import type { WebSocket as WebSocketClient } from 'ws'
6
+
7
+ import { isPrivateAddress } from '../../connector/web/guarded-fetch.js'
8
+ import { isolationOf } from '../../sandbox/isolation.js'
9
+ import type { Sandbox } from '../../types/sandbox/index.js'
10
+ import type { MonitorWebSocketTarget } from '../../types/tool/index.js'
11
+ import type { JobHost, JobSource } from './registry.js'
12
+
13
+ /**
14
+ * A WebSocket as a monitor's source.
15
+ *
16
+ * A command's stdout is not the only thing worth watching: some services push
17
+ * their events (a CI run's log stream, a server's change feed) and offer no
18
+ * command that prints them. This module turns such a connection into the same
19
+ * thing a command is to the registry: a process-shaped object that writes
20
+ * lines on stdout, can be asked to stop, and ends. Everything after that (the
21
+ * 200 ms batches, the rationing, the expiry, the stop, the untrusted frame the
22
+ * model reads it in) is the registry's and is shared with commands unchanged.
23
+ *
24
+ * What is different is where it runs and what it can reach. A command runs
25
+ * where the registry's spawn puts it, inside a sandbox if there is one; this
26
+ * connection is opened by the HOST process, outside any sandbox. So the
27
+ * address is checked before anything is sent, a sandbox that isolates the
28
+ * network refuses it unless it has said the host is allowed, and the tool
29
+ * asks a person first (see `tools/builtins/monitor.ts`).
30
+ */
31
+
32
+ /** The largest frame accepted; a bigger one ends the watch. Same as the reference. */
33
+ export const WEBSOCKET_MAX_PAYLOAD_BYTES = 1_048_576
34
+ export const WEBSOCKET_HANDSHAKE_TIMEOUT_MS = 15_000
35
+ /** After a polite close, how long a peer gets to answer before the socket is destroyed. */
36
+ export const WEBSOCKET_CLOSE_GRACE_MS = 1000
37
+ export const WEBSOCKET_URL_MAX_CHARS = 2048
38
+ export const WEBSOCKET_PROTOCOLS_MAX = 16
39
+ /** Longest close reason relayed; the protocol allows 123 bytes. */
40
+ const CLOSE_REASON_MAX_CHARS = 123
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Input
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /** RFC 6455 subprotocol names are RFC 2616 tokens: no separators, no controls. */
47
+ const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
48
+
49
+ /** Why a URL cannot be a monitor's source, or undefined when it can. */
50
+ export function webSocketUrlProblem(raw: string): string | undefined {
51
+ if (raw.length > WEBSOCKET_URL_MAX_CHARS) {
52
+ return `The URL is longer than ${WEBSOCKET_URL_MAX_CHARS} characters.`
53
+ }
54
+ // ASCII without whitespace or controls: a model that wants a non-ASCII host
55
+ // writes it in punycode, and nothing here is left to the parser's leniency
56
+ // (it strips tabs and newlines silently).
57
+ if (!/^[\x21-\x7e]+$/.test(raw)) {
58
+ return 'The URL must be ASCII, with no spaces or control characters.'
59
+ }
60
+ let url: URL
61
+ try {
62
+ url = new URL(raw)
63
+ } catch {
64
+ return 'That is not a URL.'
65
+ }
66
+ if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
67
+ return 'The URL must start with ws:// or wss://.'
68
+ }
69
+ if (url.username !== '' || url.password !== '') {
70
+ return 'A URL with a user name or password is not opened.'
71
+ }
72
+ if (url.hash !== '') return 'A WebSocket URL cannot have a fragment (#...).'
73
+ if (url.hostname === '') return 'The URL has no host.'
74
+ return undefined
75
+ }
76
+
77
+ /** Why a subprotocol list is not valid, or undefined when it is. */
78
+ export function webSocketProtocolsProblem(protocols: readonly string[]): string | undefined {
79
+ if (protocols.length > WEBSOCKET_PROTOCOLS_MAX) {
80
+ return `At most ${WEBSOCKET_PROTOCOLS_MAX} subprotocols.`
81
+ }
82
+ for (const protocol of protocols) {
83
+ if (!TOKEN.test(protocol)) return `"${protocol}" is not a valid subprotocol name.`
84
+ }
85
+ if (new Set(protocols).size !== protocols.length) return 'Subprotocol names must be unique.'
86
+ return undefined
87
+ }
88
+
89
+ /** What a record and an approval call this source: the URL as given, which can carry no credentials. */
90
+ export function webSocketJobCommand(url: string): string {
91
+ return `websocket ${url}`
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Where it may connect
96
+ // ---------------------------------------------------------------------------
97
+
98
+ /** The address check refused, in words for the model. */
99
+ export class WebSocketRefusedError extends Error {
100
+ constructor(message: string) {
101
+ super(message)
102
+ this.name = 'WebSocketRefusedError'
103
+ }
104
+ }
105
+
106
+ export interface WebSocketVetOptions {
107
+ /**
108
+ * How a name becomes addresses. Defaults to `node:dns`. For tests only, and
109
+ * never reachable from a tool argument: the hook belongs to whoever builds
110
+ * the tool (`createMonitorTool`).
111
+ */
112
+ readonly resolve?: (hostname: string) => Promise<readonly string[]>
113
+ /**
114
+ * Allow addresses inside the host's own network. For tests only: a local
115
+ * server on 127.0.0.1 is the one honest reason to, and it is a decision the
116
+ * code that builds the tool makes, never the model.
117
+ */
118
+ readonly allowPrivateAddresses?: boolean
119
+ }
120
+
121
+ /** The host name of a WebSocket URL as a resolver or an address check wants it. */
122
+ export function webSocketHostname(raw: string): string {
123
+ return new URL(raw).hostname.toLowerCase().replace(/^\[|\]$/g, '')
124
+ }
125
+
126
+ /**
127
+ * Resolve the host and refuse unless EVERY address is outside the host's own
128
+ * network; return the addresses that were checked.
129
+ *
130
+ * Refusal covers a name that does not resolve, one that resolves to nothing,
131
+ * and one with any private address among its answers: a name that answers both
132
+ * a public and a private address is a name an attacker controls, and a
133
+ * connection may pick either. Failing to resolve is a refusal and never a
134
+ * pass: an empty list satisfies "no private address found" and the connection
135
+ * that followed would resolve the name again against whatever answered.
136
+ */
137
+ export async function vetWebSocketTarget(
138
+ raw: string,
139
+ options: WebSocketVetOptions = {},
140
+ ): Promise<readonly { readonly address: string; readonly family: 4 | 6 }[]> {
141
+ const hostname = webSocketHostname(raw)
142
+ let addresses: readonly string[]
143
+ const literal = isIP(hostname)
144
+ if (literal !== 0) {
145
+ addresses = [hostname]
146
+ } else {
147
+ const resolve =
148
+ options.resolve ??
149
+ (async (name: string) => (await dnsLookup(name, { all: true })).map((r) => r.address))
150
+ try {
151
+ addresses = await resolve(hostname)
152
+ } catch (err) {
153
+ throw new WebSocketRefusedError(
154
+ `Refused "${hostname}": it could not be resolved (${err instanceof Error ? err.message : String(err)}), so nothing here can say where it points.`,
155
+ )
156
+ }
157
+ if (addresses.length === 0) {
158
+ throw new WebSocketRefusedError(`Refused "${hostname}": it resolves to no addresses.`)
159
+ }
160
+ }
161
+ if (options.allowPrivateAddresses !== true) {
162
+ for (const address of addresses) {
163
+ if (isPrivateAddress(address)) {
164
+ throw new WebSocketRefusedError(
165
+ `Refused "${hostname}": it resolves to ${address}, inside this host's own network. A WebSocket monitor only connects to public addresses.`,
166
+ )
167
+ }
168
+ }
169
+ }
170
+ return addresses.map((address) => ({ address, family: isIP(address) === 6 ? 6 : 4 }))
171
+ }
172
+
173
+ /**
174
+ * A `lookup` for the socket that answers with the addresses that were checked
175
+ * and nothing else.
176
+ *
177
+ * This is what closes the gap `web_fetch` documents: there the address is
178
+ * checked, then the platform `fetch` resolves the name AGAIN, so a name that
179
+ * answers publicly the first time and privately the second (DNS rebinding)
180
+ * gets through. Here the connection never asks DNS: it is handed the checked
181
+ * addresses, so there is no second answer to differ. The URL still names the
182
+ * host, so TLS keeps its SNI and its certificate check on the hostname and not
183
+ * on an address.
184
+ */
185
+ export function pinnedLookup(
186
+ addresses: readonly { readonly address: string; readonly family: 4 | 6 }[],
187
+ ) {
188
+ return (
189
+ _hostname: string,
190
+ options: { all?: boolean; family?: number | string } | undefined,
191
+ callback: (
192
+ error: NodeJS.ErrnoException | null,
193
+ address: string | { address: string; family: number }[],
194
+ family?: number,
195
+ ) => void,
196
+ ): void => {
197
+ // A caller that wants one family gets that family's addresses; asking for
198
+ // one we hold none of is an error rather than a fall back to DNS.
199
+ const wanted = options?.family === 4 || options?.family === 6 ? options.family : undefined
200
+ const usable = wanted === undefined ? addresses : addresses.filter((a) => a.family === wanted)
201
+ const first = usable[0]
202
+ if (first === undefined) {
203
+ const error: NodeJS.ErrnoException = new Error('no vetted address of that family')
204
+ error.code = 'ENOTFOUND'
205
+ callback(error, '', 0)
206
+ return
207
+ }
208
+ if (options?.all === true) {
209
+ callback(
210
+ null,
211
+ usable.map((a) => ({ address: a.address, family: a.family })),
212
+ )
213
+ return
214
+ }
215
+ callback(null, first.address, first.family)
216
+ }
217
+ }
218
+
219
+ /** `api.example.com` is that host; `.example.com` is the domain and its subdomains. */
220
+ export function hostMatchesPolicy(hostname: string, allowedHosts: readonly string[]): boolean {
221
+ const host = hostname.toLowerCase().replace(/\.$/, '')
222
+ return allowedHosts.some((entry) => {
223
+ const allowed = entry.toLowerCase().replace(/\.$/, '')
224
+ if (allowed.startsWith('.')) return host === allowed.slice(1) || host.endsWith(allowed)
225
+ return host === allowed
226
+ })
227
+ }
228
+
229
+ /**
230
+ * Why a sandbox forbids this connection, or undefined when it does not.
231
+ *
232
+ * The connection is opened by the host process, outside the boundary, so a
233
+ * sandbox's network rules do not bind it. If a turn runs under a sandbox that
234
+ * isolates the network, letting the host connect would be a way out of that
235
+ * isolation, so it is refused unless the sandbox's live policy names the host.
236
+ * A sandbox that does not isolate the network says nothing about it, and the
237
+ * connection is allowed, subject to the address check and to review.
238
+ */
239
+ export function webSocketSandboxRefusal(
240
+ sandbox: Sandbox | undefined,
241
+ hostname: string,
242
+ ): string | undefined {
243
+ if (sandbox === undefined) return undefined
244
+ if (!isolationOf(sandbox.environment).network) return undefined
245
+ const allowed = sandbox.networkPolicy?.allowedHosts
246
+ if (allowed !== undefined && hostMatchesPolicy(hostname, allowed)) return undefined
247
+ return `A WebSocket monitor connects from the host, outside this turn's sandbox, which blocks the network. "${hostname}" is not on the sandbox's list of allowed hosts, so the connection was not opened. Allow the host in the sandbox's network policy, or watch the service from inside the sandbox with a command.`
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Notices
252
+ // ---------------------------------------------------------------------------
253
+
254
+ /** Server-written text, made one short line before it goes into a notice. */
255
+ function oneLine(text: string): string {
256
+ const line = Array.from(text, (character) => {
257
+ const code = character.charCodeAt(0)
258
+ return code < 0x20 || (code >= 0x7f && code <= 0x9f) ? ' ' : character
259
+ })
260
+ .join('')
261
+ .replace(/\s+/g, ' ')
262
+ .trim()
263
+ return line.length <= CLOSE_REASON_MAX_CHARS
264
+ ? line
265
+ : `${line.slice(0, CLOSE_REASON_MAX_CHARS)}...`
266
+ }
267
+
268
+ export const webSocketRejectedNotice = (status: number): string =>
269
+ `[WebSocket upgrade rejected: HTTP ${status}]`
270
+ export const webSocketErrorNotice = (message: string): string =>
271
+ `[WebSocket error: ${oneLine(message)}]`
272
+ export const webSocketClosedNotice = (code: number, reason: string): string => {
273
+ const why = oneLine(reason)
274
+ return why === '' ? `[WebSocket closed: ${code}]` : `[WebSocket closed: ${code} ${why}]`
275
+ }
276
+ export const webSocketDroppedNotice = (): string =>
277
+ `[Dropped a frame larger than ${WEBSOCKET_MAX_PAYLOAD_BYTES} bytes; closing]`
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // The connection, as something the registry can hold
281
+ // ---------------------------------------------------------------------------
282
+
283
+ export interface WebSocketSourceOptions {
284
+ readonly target: MonitorWebSocketTarget
285
+ readonly host: JobHost
286
+ }
287
+
288
+ function frameBytes(data: Buffer | ArrayBuffer | Buffer[]): number {
289
+ if (Array.isArray(data)) return data.reduce((sum, part) => sum + part.length, 0)
290
+ return data instanceof ArrayBuffer ? data.byteLength : data.length
291
+ }
292
+
293
+ /**
294
+ * Open the connection and return it as a job's process.
295
+ *
296
+ * The socket library is loaded when the first monitor of this kind starts, so
297
+ * a session that never watches a WebSocket never pays for it.
298
+ */
299
+ export function connectWebSocketSource(options: WebSocketSourceOptions): JobSource {
300
+ const { target, host } = options
301
+ const stand = Object.assign(new EventEmitter(), {
302
+ pid: undefined,
303
+ stdout: new EventEmitter(),
304
+ stderr: new EventEmitter(),
305
+ })
306
+ let socket: WebSocketClient | undefined
307
+ let graceTimer: ReturnType<typeof setTimeout> | undefined
308
+ // Set once somebody asked the connection to stop (a kill, the expiry, the
309
+ // output rule). From then on the connection says nothing: the registry has
310
+ // already told the model why the watch ended, and nothing follows that.
311
+ let stopping = false
312
+ let opened = false
313
+ let rejected = false
314
+ let oversize = false
315
+ // The connection failed before it opened: the error notice was the last word.
316
+ let failedToOpen = false
317
+ let ended = false
318
+
319
+ const write = (text: string): void => {
320
+ if (stopping || ended) return
321
+ stand.stdout.emit('data', text)
322
+ }
323
+ const end = (exitCode: number): void => {
324
+ if (ended) return
325
+ ended = true
326
+ clearTimeout(graceTimer)
327
+ stand.emit('close', exitCode, null)
328
+ }
329
+
330
+ void import('ws')
331
+ .then(({ WebSocket }) => {
332
+ if (stopping) {
333
+ end(0)
334
+ return
335
+ }
336
+ const client = new WebSocket(
337
+ target.url,
338
+ target.protocols && target.protocols.length > 0 ? [...target.protocols] : undefined,
339
+ {
340
+ maxPayload: WEBSOCKET_MAX_PAYLOAD_BYTES,
341
+ // A redirect could lead to an address nobody checked.
342
+ followRedirects: false,
343
+ // A compressed frame inflates past the size cap before it is
344
+ // measured; refusing the extension leaves every frame as sent.
345
+ perMessageDeflate: false,
346
+ handshakeTimeout: WEBSOCKET_HANDSHAKE_TIMEOUT_MS,
347
+ // Only the vetted addresses; see `pinnedLookup`.
348
+ lookup: pinnedLookup(target.addresses),
349
+ },
350
+ )
351
+ socket = client
352
+ client.on('open', () => {
353
+ opened = true
354
+ })
355
+ client.on('unexpected-response', (_request, response) => {
356
+ if (stopping) return
357
+ rejected = true
358
+ response.resume()
359
+ host.notice(webSocketRejectedNotice(response.statusCode ?? 0), { final: true })
360
+ // With a listener for this event the library leaves the half-open
361
+ // request to us.
362
+ client.terminate()
363
+ })
364
+ client.on('error', (error: Error & { code?: string }) => {
365
+ if (stopping || rejected || oversize) return
366
+ if (error.code === 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH') {
367
+ // The library knows a frame was too big and not how big: it stops
368
+ // reading at the length field.
369
+ oversize = true
370
+ host.notice(webSocketDroppedNotice(), { final: true })
371
+ client.terminate()
372
+ return
373
+ }
374
+ if (!opened) {
375
+ // It never opened, so there is no close to report after this:
376
+ // one notice says it failed, and it is the last.
377
+ failedToOpen = true
378
+ host.notice(webSocketErrorNotice(error.message), { final: true })
379
+ return
380
+ }
381
+ host.notice(webSocketErrorNotice(error.message))
382
+ })
383
+ client.on('message', (data, isBinary) => {
384
+ if (isBinary) {
385
+ write(`[binary frame, ${frameBytes(data)} bytes]\n`)
386
+ return
387
+ }
388
+ const text = Array.isArray(data)
389
+ ? Buffer.concat(data).toString('utf8')
390
+ : Buffer.from(data as Buffer).toString('utf8')
391
+ // Frames are not lines. A newline closes this one, so the next frame
392
+ // starts a fresh line and a frame without one is not glued to it.
393
+ write(text.endsWith('\n') ? text : `${text}\n`)
394
+ })
395
+ client.on('close', (code, reason) => {
396
+ const why = reason.toString('utf8')
397
+ if (!stopping && !rejected && !oversize && !failedToOpen) {
398
+ host.notice(webSocketClosedNotice(code, why), { final: true })
399
+ if (opened) host.connectionClosed(code, oneLine(why))
400
+ }
401
+ // A normal or going-away close is the stream ending; anything else,
402
+ // and a connection that never opened, is a failure.
403
+ end(opened && (code === 1000 || code === 1001 || code === 1005) ? 0 : 1)
404
+ })
405
+ })
406
+ .catch((error: unknown) => {
407
+ host.notice(webSocketErrorNotice(error instanceof Error ? error.message : String(error)), {
408
+ final: true,
409
+ })
410
+ end(1)
411
+ })
412
+
413
+ return {
414
+ child: stand,
415
+ // Not a process, so the registry's group kill has nothing to signal; this
416
+ // is how it is stopped. SIGTERM asks politely, SIGKILL (and the grace
417
+ // running out) destroys the socket.
418
+ kill(signal) {
419
+ if (ended) return
420
+ if (signal === 'SIGKILL') {
421
+ stopping = true
422
+ if (socket) socket.terminate()
423
+ else end(0)
424
+ return
425
+ }
426
+ if (stopping) return
427
+ stopping = true
428
+ if (!socket) return // the import resolves, sees `stopping`, and ends
429
+ if (socket.readyState === socket.OPEN) {
430
+ socket.close(1000)
431
+ graceTimer = setTimeout(() => socket?.terminate(), WEBSOCKET_CLOSE_GRACE_MS)
432
+ graceTimer.unref?.()
433
+ } else {
434
+ socket.terminate()
435
+ }
436
+ },
437
+ }
438
+ }
@@ -167,7 +167,8 @@ const SYSTEM_EVENT_FRAME = /^<system-event-([A-Za-z0-9]+) [^\n]*\n[\s\S]*?\n<\/s
167
167
  * renders them with it too, so both read the same words.
168
168
  */
169
169
  export function formatMonitorEvent(
170
- event: Pick<MonitorEvent, 'jobId' | 'description' | 'kind' | 'text'>,
170
+ event: Pick<MonitorEvent, 'jobId' | 'description' | 'kind' | 'text'> &
171
+ Partial<Pick<MonitorEvent, 'transport'>>,
171
172
  command: string,
172
173
  ): string {
173
174
  return formatSystemEvent({
@@ -181,21 +182,46 @@ export function formatMonitorEvent(
181
182
  envelope: {
182
183
  kind: event.kind === 'output' ? 'monitor-output' : 'monitor-notice',
183
184
  attributes: { job: event.jobId },
184
- provenance:
185
- event.kind === 'output'
186
- ? 'Lines the watched command printed to stdout. The command, or whatever it reads, wrote them; they are not the operator.'
187
- : 'Written by the monitor itself, not by the watched command.',
185
+ provenance: monitorProvenance(event.kind, event.transport),
188
186
  },
189
187
  content: capMonitorContent(event.text),
190
188
  },
191
189
  })
192
190
  }
193
191
 
192
+ /**
193
+ * Who wrote what is inside the frame. For a WebSocket the server wrote the
194
+ * frames and also the close reason and error text the monitor quotes in its
195
+ * notices, so neither is claimed to be the monitor's own words.
196
+ */
197
+ function monitorProvenance(
198
+ kind: MonitorEvent['kind'],
199
+ transport: MonitorEvent['transport'],
200
+ ): string {
201
+ if (transport === 'websocket') {
202
+ return kind === 'output'
203
+ ? 'Text frames the WebSocket server sent. The server, or whatever it relays, wrote them; they are not the operator.'
204
+ : 'A notice from the monitor about the WebSocket connection. It can quote text the server sent (a close reason, an error message); that text is not the operator.'
205
+ }
206
+ return kind === 'output'
207
+ ? 'Lines the watched command printed to stdout. The command, or whatever it reads, wrote them; they are not the operator.'
208
+ : 'Written by the monitor itself, not by the watched command.'
209
+ }
210
+
194
211
  /** A monitor's end as the system-event envelope, in the words {@link describeMonitorEnd} gives it. */
195
212
  export function formatMonitorEnd(
196
213
  job: Pick<
197
214
  BackgroundJob,
198
- 'id' | 'command' | 'description' | 'status' | 'exitCode' | 'signal' | 'error'
215
+ | 'id'
216
+ | 'command'
217
+ | 'description'
218
+ | 'status'
219
+ | 'exitCode'
220
+ | 'signal'
221
+ | 'error'
222
+ | 'transport'
223
+ | 'closeCode'
224
+ | 'closeReason'
199
225
  >,
200
226
  stats: { readonly stdoutChars: number },
201
227
  ): string {