@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.
- package/CHANGELOG.md +14 -0
- package/dist/connector/web/guarded-fetch.d.ts.map +1 -1
- package/dist/connector/web/guarded-fetch.js +44 -0
- package/dist/connector/web/guarded-fetch.js.map +1 -1
- package/dist/process/kill-tree.d.ts +3 -2
- package/dist/process/kill-tree.d.ts.map +1 -1
- package/dist/process/kill-tree.js.map +1 -1
- package/dist/public-runtime.d.ts +1 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-tools.d.ts +2 -1
- package/dist/public-tools.d.ts.map +1 -1
- package/dist/public-tools.js +1 -1
- package/dist/public-tools.js.map +1 -1
- package/dist/runtime/jobs/registry.d.ts +80 -2
- package/dist/runtime/jobs/registry.d.ts.map +1 -1
- package/dist/runtime/jobs/registry.js +70 -5
- package/dist/runtime/jobs/registry.js.map +1 -1
- package/dist/runtime/jobs/websocket-source.d.ts +120 -0
- package/dist/runtime/jobs/websocket-source.d.ts.map +1 -0
- package/dist/runtime/jobs/websocket-source.js +379 -0
- package/dist/runtime/jobs/websocket-source.js.map +1 -0
- package/dist/runtime/query/steering.d.ts +2 -2
- package/dist/runtime/query/steering.d.ts.map +1 -1
- package/dist/runtime/query/steering.js +16 -3
- package/dist/runtime/query/steering.js.map +1 -1
- package/dist/tools/builtins/monitor.d.ts +38 -4
- package/dist/tools/builtins/monitor.d.ts.map +1 -1
- package/dist/tools/builtins/monitor.js +236 -97
- package/dist/tools/builtins/monitor.js.map +1 -1
- package/dist/types/sandbox/index.d.ts +10 -0
- package/dist/types/sandbox/index.d.ts.map +1 -1
- package/dist/types/sandbox/index.js.map +1 -1
- package/dist/types/tool/index.d.ts +35 -0
- package/dist/types/tool/index.d.ts.map +1 -1
- package/dist/types/tool/index.js.map +1 -1
- package/package.json +4 -2
- package/src/connector/web/guarded-fetch.ts +41 -0
- package/src/process/kill-tree.ts +4 -2
- package/src/public-runtime.ts +1 -1
- package/src/public-tools.ts +3 -0
- package/src/runtime/jobs/registry.ts +162 -13
- package/src/runtime/jobs/websocket-source.ts +438 -0
- package/src/runtime/query/steering.ts +32 -6
- package/src/tools/builtins/monitor.ts +293 -123
- package/src/types/sandbox/index.ts +10 -0
- package/src/types/tool/index.ts +27 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/types/tool/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/types/tool/index.ts"],"names":[],"mappings":"AAgyCA,cAAc,aAAa,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@namzu/sdk",
|
|
3
|
-
"version": "52.
|
|
3
|
+
"version": "52.4.0",
|
|
4
4
|
"description": "Open-source AI kernel for TypeScript: runs an agent the way an operating system runs a process, with an identity and a budget, scheduled tool calls and checkpointed turns.",
|
|
5
5
|
"license": "FSL-1.1-MIT",
|
|
6
6
|
"engines": {
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"@types/brace-expansion": "^1.1.2",
|
|
65
65
|
"@types/node": "^22.19.17",
|
|
66
66
|
"@types/turndown": "^5.0.6",
|
|
67
|
+
"@types/ws": "8.18.1",
|
|
67
68
|
"@vitest/coverage-v8": "^4.1.11",
|
|
68
69
|
"fast-check": "^4.7.0",
|
|
69
70
|
"jsonschema": "^1.5.0",
|
|
@@ -80,7 +81,8 @@
|
|
|
80
81
|
"minimatch": "^9.0.9",
|
|
81
82
|
"pica": "^10.0.3",
|
|
82
83
|
"quickjs-emscripten-core": "0.32.0",
|
|
83
|
-
"turndown": "^7.2.4"
|
|
84
|
+
"turndown": "^7.2.4",
|
|
85
|
+
"ws": "8.21.3"
|
|
84
86
|
},
|
|
85
87
|
"scripts": {
|
|
86
88
|
"build": "tsc --build",
|
|
@@ -101,6 +101,9 @@ export function isPrivateAddress(address: string): boolean {
|
|
|
101
101
|
if (a === 192 && b === 168) return true // private
|
|
102
102
|
if (a === 169 && b === 254) return true // link-local, incl. cloud metadata
|
|
103
103
|
if (a === 0) return true // "this network"
|
|
104
|
+
if (a === 100 && b >= 64 && b <= 127) return true // shared address space (CGNAT), 100.64.0.0/10
|
|
105
|
+
if (a === 198 && (b === 18 || b === 19)) return true // benchmarking, 198.18.0.0/15
|
|
106
|
+
if (a === 192 && b === 0 && parts[2] === 0) return true // IETF protocol assignments, 192.0.0.0/24
|
|
104
107
|
if (a >= 224) return true // multicast and reserved
|
|
105
108
|
return false
|
|
106
109
|
}
|
|
@@ -123,8 +126,35 @@ export function isPrivateAddress(address: string): boolean {
|
|
|
123
126
|
const low = Number.parseInt(mapped[2], 16)
|
|
124
127
|
return isPrivateAddress(`${high >>> 8}.${high & 0xff}.${low >>> 8}.${low & 0xff}`)
|
|
125
128
|
}
|
|
129
|
+
const hextets = expandHextets(canonical)
|
|
130
|
+
const embedded = (index: number): string => {
|
|
131
|
+
const high = hextets[index] ?? 0
|
|
132
|
+
const low = hextets[index + 1] ?? 0
|
|
133
|
+
return `${high >>> 8}.${high & 0xff}.${low >>> 8}.${low & 0xff}`
|
|
134
|
+
}
|
|
135
|
+
const zeros = (from: number, to: number): boolean =>
|
|
136
|
+
hextets.slice(from, to).every((hextet) => hextet === 0)
|
|
137
|
+
// IPv4-compatible, ::/96 (deprecated): `::7f00:1` is 127.0.0.1 for a
|
|
138
|
+
// stack that still honours it, and nothing public is addressed this way.
|
|
139
|
+
if (zeros(0, 6)) return true
|
|
140
|
+
// IPv4-translated, ::ffff:0:0/96 (SIIT): the IPv4 address is the last 32 bits.
|
|
141
|
+
if (zeros(0, 4) && hextets[4] === 0xffff && hextets[5] === 0)
|
|
142
|
+
return isPrivateAddress(embedded(6))
|
|
143
|
+
// NAT64 well-known prefix, 64:ff9b::/96: a translator turns it into the
|
|
144
|
+
// IPv4 address in the last 32 bits, so `64:ff9b::7f00:1` reaches
|
|
145
|
+
// 127.0.0.1 through it. Refused only when that address is private: a
|
|
146
|
+
// public one is what the prefix is for on an IPv6-only network.
|
|
147
|
+
if (hextets[0] === 0x64 && hextets[1] === 0xff9b && zeros(2, 6)) {
|
|
148
|
+
return isPrivateAddress(embedded(6))
|
|
149
|
+
}
|
|
150
|
+
// NAT64 local-use prefix, 64:ff9b:1::/48 (RFC 8215): a site's own
|
|
151
|
+
// translator, so the embedded address cannot be read from a fixed place.
|
|
152
|
+
if (hextets[0] === 0x64 && hextets[1] === 0xff9b && hextets[2] === 1) return true
|
|
153
|
+
// 6to4, 2002::/16: the IPv4 address is the next 32 bits.
|
|
154
|
+
if (hextets[0] === 0x2002) return isPrivateAddress(embedded(1))
|
|
126
155
|
const firstHextet = Number.parseInt(canonical.split(':', 1)[0] ?? '0', 16)
|
|
127
156
|
if ((firstHextet & 0xffc0) === 0xfe80) return true // link-local fe80::/10
|
|
157
|
+
if ((firstHextet & 0xffc0) === 0xfec0) return true // site-local fec0::/10 (deprecated)
|
|
128
158
|
if ((firstHextet & 0xfe00) === 0xfc00) return true // unique-local fc00::/7
|
|
129
159
|
if ((firstHextet & 0xff00) === 0xff00) return true // multicast ff00::/8
|
|
130
160
|
return false
|
|
@@ -132,6 +162,17 @@ export function isPrivateAddress(address: string): boolean {
|
|
|
132
162
|
return false
|
|
133
163
|
}
|
|
134
164
|
|
|
165
|
+
/** A canonical IPv6 address (as `new URL` writes it, no dots) as its eight 16-bit groups. */
|
|
166
|
+
function expandHextets(canonical: string): number[] {
|
|
167
|
+
const [head = '', tail] = canonical.split('::')
|
|
168
|
+
const front = head === '' ? [] : head.split(':')
|
|
169
|
+
const back = tail === undefined || tail === '' ? [] : tail.split(':')
|
|
170
|
+
const fill = tail === undefined ? 0 : 8 - front.length - back.length
|
|
171
|
+
return [...front, ...new Array<string>(Math.max(0, fill)).fill('0'), ...back].map((group) =>
|
|
172
|
+
Number.parseInt(group, 16),
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
135
176
|
export class GuardedFetchProvider implements WebFetchProvider {
|
|
136
177
|
private readonly timeoutMs: number
|
|
137
178
|
private readonly maxBytes: number
|
package/src/process/kill-tree.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process'
|
|
2
|
-
import type { spawn } from 'node:child_process'
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
4
|
* Git Bash children that the Windows process tree cannot reach.
|
|
@@ -83,7 +82,10 @@ function taskkillPath(): string {
|
|
|
83
82
|
* already-dead tree that second call is a deliberate no-op, the same as the
|
|
84
83
|
* POSIX SIGKILL that follows a SIGTERM which already worked.
|
|
85
84
|
*/
|
|
86
|
-
export function killTree(
|
|
85
|
+
export function killTree(
|
|
86
|
+
child: { readonly pid?: number | undefined },
|
|
87
|
+
signal: NodeJS.Signals,
|
|
88
|
+
): void {
|
|
87
89
|
if (!child.pid) return
|
|
88
90
|
try {
|
|
89
91
|
if (process.platform === 'win32') {
|
package/src/public-runtime.ts
CHANGED
|
@@ -1587,7 +1587,7 @@ export type {
|
|
|
1587
1587
|
LoggerOptions,
|
|
1588
1588
|
MutableLogSinkCounters,
|
|
1589
1589
|
} from './utils/log/types.js'
|
|
1590
|
-
export type { JobProcess } from './runtime/jobs/registry.js'
|
|
1590
|
+
export type { JobChild, JobHost, JobPipe, JobProcess, JobSource } from './runtime/jobs/registry.js'
|
|
1591
1591
|
export { WORKING_STATE_MIME } from './connector/mcp/adapter.js'
|
|
1592
1592
|
|
|
1593
1593
|
export {
|
package/src/public-tools.ts
CHANGED
|
@@ -92,9 +92,12 @@ export { LSP_TOOL_NAME, LspTool, getCodeNavigationTools } from './tools/builtins
|
|
|
92
92
|
export {
|
|
93
93
|
MONITOR_CANNOT_DETACH,
|
|
94
94
|
MONITOR_TOOL_NAME,
|
|
95
|
+
MONITOR_WEBSOCKET_WEB_OFF,
|
|
95
96
|
MonitorTool,
|
|
97
|
+
createMonitorTool,
|
|
96
98
|
getMonitorTools,
|
|
97
99
|
} from './tools/builtins/monitor.js'
|
|
100
|
+
export type { MonitorToolOptions } from './tools/builtins/monitor.js'
|
|
98
101
|
export { GlobTool } from './tools/builtins/glob.js'
|
|
99
102
|
export { GrepTool } from './tools/builtins/grep.js'
|
|
100
103
|
// Reads, lists and stops what `bash run_in_background` starts. Ships in
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
BackgroundJobOutputWaitOptions,
|
|
10
10
|
BackgroundJobOutputWaitResult,
|
|
11
11
|
} from '../../types/job/index.js'
|
|
12
|
+
import type { MonitorWebSocketTarget } from '../../types/tool/index.js'
|
|
12
13
|
import { subscribeToAbort } from '../../utils/abort.js'
|
|
13
14
|
import { oneMetadataLine } from '../system-events.js'
|
|
14
15
|
import { createBackgroundJobId } from './job-id.js'
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
expiryNotice,
|
|
20
21
|
tooMuchOutputNotice,
|
|
21
22
|
} from './monitor-stream.js'
|
|
23
|
+
import { connectWebSocketSource, webSocketJobCommand } from './websocket-source.js'
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* Work that outlives a tool call, owned by whoever started it.
|
|
@@ -82,6 +84,15 @@ export interface BackgroundJob {
|
|
|
82
84
|
* runs and when the command ended on its own.
|
|
83
85
|
*/
|
|
84
86
|
readonly stopReason?: 'expired' | 'too_much_output' | 'stopped'
|
|
87
|
+
/**
|
|
88
|
+
* What the monitor's source is, when it is not a command: `websocket` for a
|
|
89
|
+
* monitor of a WebSocket connection, whose `command` is then
|
|
90
|
+
* `websocket <url>` and which has no exit code of a process.
|
|
91
|
+
*/
|
|
92
|
+
readonly transport?: 'websocket'
|
|
93
|
+
/** The close code and reason the server ended a WebSocket connection with, if it did. */
|
|
94
|
+
readonly closeCode?: number
|
|
95
|
+
readonly closeReason?: string
|
|
85
96
|
}
|
|
86
97
|
|
|
87
98
|
/**
|
|
@@ -97,6 +108,8 @@ export interface MonitorEvent {
|
|
|
97
108
|
readonly kind: 'output' | 'notice'
|
|
98
109
|
readonly text: string
|
|
99
110
|
readonly at: number
|
|
111
|
+
/** Set when the source is a WebSocket connection, whose text the server wrote. */
|
|
112
|
+
readonly transport?: 'websocket'
|
|
100
113
|
}
|
|
101
114
|
|
|
102
115
|
export interface BackgroundJobOutput {
|
|
@@ -118,6 +131,60 @@ export interface BackgroundJobOutput {
|
|
|
118
131
|
readonly error?: string
|
|
119
132
|
}
|
|
120
133
|
|
|
134
|
+
/**
|
|
135
|
+
* What a `spawn` that is not a child process hands the registry: the few
|
|
136
|
+
* members of {@link JobChild} and how to stop it. A {@link JobProcess} is one
|
|
137
|
+
* too, so a `spawn` may return either.
|
|
138
|
+
*/
|
|
139
|
+
export interface JobSource {
|
|
140
|
+
readonly child: JobChild
|
|
141
|
+
/** How to stop it, when the registry's process-group kill would not reach everything. */
|
|
142
|
+
kill?(signal: NodeJS.Signals): void
|
|
143
|
+
/** Confirm the owned tree stopped; reject instead of claiming an unconfirmed exit. */
|
|
144
|
+
terminate?(signal?: NodeJS.Signals): Promise<void>
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* What the registry reads of a job's process, and nothing more.
|
|
149
|
+
*
|
|
150
|
+
* A `ChildProcess` satisfies it, and so does anything else that writes bytes
|
|
151
|
+
* on two pipes and ends: a WebSocket connection held as a monitor's source is
|
|
152
|
+
* such a thing. Named so that the registry's contract is the few members it
|
|
153
|
+
* uses and not a type from `node:child_process` it would have to be cast to.
|
|
154
|
+
*/
|
|
155
|
+
export interface JobChild {
|
|
156
|
+
readonly pid?: number | undefined
|
|
157
|
+
readonly stdout?: JobPipe | null
|
|
158
|
+
readonly stderr?: JobPipe | null
|
|
159
|
+
once(event: 'spawn', listener: () => void): unknown
|
|
160
|
+
on(event: 'error', listener: (error: Error) => void): unknown
|
|
161
|
+
once(
|
|
162
|
+
event: 'close',
|
|
163
|
+
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
|
|
164
|
+
): unknown
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A readable stream, as far as the registry reads one. */
|
|
168
|
+
export interface JobPipe {
|
|
169
|
+
on(event: 'data', listener: (data: Buffer | string) => void): unknown
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* What a process can say to the registry besides its output, handed to the
|
|
174
|
+
* `spawn` that asked for it.
|
|
175
|
+
*/
|
|
176
|
+
export interface JobHost {
|
|
177
|
+
/**
|
|
178
|
+
* Something the SOURCE has to say, delivered to a monitor's listeners as a
|
|
179
|
+
* `notice` event and never rationed as output is. `final` ends the stream:
|
|
180
|
+
* what is pending goes out first, and nothing the process writes after the
|
|
181
|
+
* notice is delivered. Ignored for a job that is not a monitor.
|
|
182
|
+
*/
|
|
183
|
+
notice(text: string, options?: { readonly final?: boolean }): void
|
|
184
|
+
/** The server closed a WebSocket connection with this code and reason. */
|
|
185
|
+
connectionClosed(code: number, reason: string): void
|
|
186
|
+
}
|
|
187
|
+
|
|
121
188
|
/** A job's process, when something other than the registry starts it. */
|
|
122
189
|
export interface JobProcess {
|
|
123
190
|
readonly child: ReturnType<typeof spawn>
|
|
@@ -138,7 +205,7 @@ export interface StartJobParams {
|
|
|
138
205
|
* host in the `bash` tool's shell (`tools/command-shell.ts`). The process must be the leader of its own group and must not
|
|
139
206
|
* expect stdin.
|
|
140
207
|
*/
|
|
141
|
-
readonly spawn?: () => JobProcess
|
|
208
|
+
readonly spawn?: (host: JobHost) => JobProcess | JobSource
|
|
142
209
|
/**
|
|
143
210
|
* Output the process already wrote before the registry took it over, in
|
|
144
211
|
* the order it was written. Used when a foreground command is moved to the
|
|
@@ -160,7 +227,12 @@ export interface StartJobParams {
|
|
|
160
227
|
* `timeoutMs`. Both streams stay in the retained buffer, so `read` still
|
|
161
228
|
* shows everything, stderr included.
|
|
162
229
|
*/
|
|
163
|
-
readonly monitor?: {
|
|
230
|
+
readonly monitor?: {
|
|
231
|
+
readonly description: string
|
|
232
|
+
readonly timeoutMs: number
|
|
233
|
+
/** The source is a WebSocket connection and not a command; see {@link BackgroundJob.transport}. */
|
|
234
|
+
readonly transport?: 'websocket'
|
|
235
|
+
}
|
|
164
236
|
}
|
|
165
237
|
|
|
166
238
|
export interface BackgroundJobRegistryConfig {
|
|
@@ -241,7 +313,7 @@ export class UnknownBackgroundJobError extends Error {
|
|
|
241
313
|
|
|
242
314
|
interface JobEntry {
|
|
243
315
|
record: BackgroundJob
|
|
244
|
-
child:
|
|
316
|
+
child: JobChild
|
|
245
317
|
/** The spawner's own kill, when it gave one. */
|
|
246
318
|
killProcess?: (signal: NodeJS.Signals) => void
|
|
247
319
|
terminateProcess?: (signal?: NodeJS.Signals) => Promise<void>
|
|
@@ -269,6 +341,8 @@ interface MonitorState {
|
|
|
269
341
|
readonly timeoutMs: number
|
|
270
342
|
seq: number
|
|
271
343
|
expiryTimer?: ReturnType<typeof setTimeout>
|
|
344
|
+
/** A terminal notice went out: nothing more is delivered, from the source or from anywhere. */
|
|
345
|
+
sealed?: boolean
|
|
272
346
|
}
|
|
273
347
|
|
|
274
348
|
/** Whether any process of the group led by `pid` is still alive. */
|
|
@@ -343,6 +417,7 @@ export class BackgroundJobRegistry {
|
|
|
343
417
|
kind,
|
|
344
418
|
text,
|
|
345
419
|
at: Date.now(),
|
|
420
|
+
...(entry.record.transport ? { transport: entry.record.transport } : {}),
|
|
346
421
|
}
|
|
347
422
|
for (const listener of this.monitorListeners) {
|
|
348
423
|
try {
|
|
@@ -432,8 +507,14 @@ export class BackgroundJobRegistry {
|
|
|
432
507
|
// The same spawn as a foreground `bash` call, so the permission
|
|
433
508
|
// rules' reading of the line holds for the job too, and so the two
|
|
434
509
|
// cannot disagree about how a shell is started on a given platform.
|
|
435
|
-
const
|
|
436
|
-
|
|
510
|
+
const host: JobHost = {
|
|
511
|
+
notice: (text, options) => this.noticeFromSource(entry, text, options?.final === true),
|
|
512
|
+
connectionClosed: (code, reason) => {
|
|
513
|
+
entry.record = { ...entry.record, closeCode: code, closeReason: reason }
|
|
514
|
+
},
|
|
515
|
+
}
|
|
516
|
+
const started: JobProcess | JobSource = params.spawn
|
|
517
|
+
? params.spawn(host)
|
|
437
518
|
: {
|
|
438
519
|
child: spawnHostShell(params.command, {
|
|
439
520
|
cwd: params.workingDirectory,
|
|
@@ -484,6 +565,7 @@ export class BackgroundJobRegistry {
|
|
|
484
565
|
// follows is described as what it was and not as a stop
|
|
485
566
|
// somebody asked for.
|
|
486
567
|
entry.record = { ...entry.record, stopReason: 'too_much_output' }
|
|
568
|
+
if (entry.monitor) entry.monitor.sealed = true
|
|
487
569
|
this.announceMonitorEvent(entry, 'notice', tooMuchOutputNotice(suppressed, overMs))
|
|
488
570
|
void this.kill(id).catch(() => {
|
|
489
571
|
// The retained record carries the failure (recoveryRequired).
|
|
@@ -507,6 +589,7 @@ export class BackgroundJobRegistry {
|
|
|
507
589
|
kind: 'monitor' as const,
|
|
508
590
|
description: monitor.description,
|
|
509
591
|
expiresAt: startedAt + monitor.timeoutMs,
|
|
592
|
+
...(params.monitor?.transport ? { transport: params.monitor.transport } : {}),
|
|
510
593
|
}
|
|
511
594
|
: {}),
|
|
512
595
|
},
|
|
@@ -673,12 +756,31 @@ export class BackgroundJobRegistry {
|
|
|
673
756
|
// Nothing follows a terminal notice. A command that ignores SIGTERM keeps
|
|
674
757
|
// printing until the SIGKILL, and those lines would reach the model after
|
|
675
758
|
// it was told the watch was over.
|
|
759
|
+
monitor.sealed = true
|
|
676
760
|
monitor.stream.close()
|
|
677
761
|
void this.kill(entry.record.id).catch(() => {
|
|
678
762
|
// The retained record carries the failure (recoveryRequired).
|
|
679
763
|
})
|
|
680
764
|
}
|
|
681
765
|
|
|
766
|
+
/**
|
|
767
|
+
* A notice from the source of a monitor (a WebSocket's upgrade was refused,
|
|
768
|
+
* its connection closed). Delivered after whatever was already complete, so
|
|
769
|
+
* the last lines are not overtaken by the news that the stream ended.
|
|
770
|
+
*/
|
|
771
|
+
private noticeFromSource(entry: JobEntry, text: string, final: boolean): void {
|
|
772
|
+
const monitor = entry.monitor
|
|
773
|
+
if (!monitor || monitor.sealed) return
|
|
774
|
+
monitor.stream.flush()
|
|
775
|
+
// The flush can itself have stopped the monitor for too much output.
|
|
776
|
+
if (monitor.sealed) return
|
|
777
|
+
this.announceMonitorEvent(entry, 'notice', text)
|
|
778
|
+
if (final) {
|
|
779
|
+
monitor.sealed = true
|
|
780
|
+
monitor.stream.close()
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
682
784
|
/**
|
|
683
785
|
* Resolves once the job's process exists, or once it could not be
|
|
684
786
|
* created (`error` then says why). Lets a caller report a start that
|
|
@@ -949,9 +1051,10 @@ export class BackgroundJobRegistry {
|
|
|
949
1051
|
// out, and nothing the dying process prints afterwards does. (The expiry
|
|
950
1052
|
// and output rules close it themselves, after their own notice.)
|
|
951
1053
|
const closeStream = (): void => {
|
|
952
|
-
if (!stoppedBy) return
|
|
953
|
-
entry.monitor
|
|
954
|
-
entry.monitor
|
|
1054
|
+
if (!stoppedBy || !entry.monitor) return
|
|
1055
|
+
entry.monitor.stream.flush()
|
|
1056
|
+
entry.monitor.sealed = true
|
|
1057
|
+
entry.monitor.stream.close()
|
|
955
1058
|
}
|
|
956
1059
|
if (entry.terminateProcess) {
|
|
957
1060
|
entry.killRequested = true
|
|
@@ -1095,6 +1198,8 @@ export function bindOwner(
|
|
|
1095
1198
|
...(monitor ? { monitor } : {}),
|
|
1096
1199
|
})
|
|
1097
1200
|
}
|
|
1201
|
+
const clampMonitorTimeout = (requested: number): number =>
|
|
1202
|
+
Math.min(requested, defaults.monitorTimeoutCapMs ?? MONITOR_TIMEOUT_CAP_MS)
|
|
1098
1203
|
return {
|
|
1099
1204
|
start: (params: { command: string; workingDirectory?: string }) => launch(params),
|
|
1100
1205
|
// The same spawn, owner and cap as `start`, plus a deadline. The timeout
|
|
@@ -1106,14 +1211,35 @@ export function bindOwner(
|
|
|
1106
1211
|
timeoutMs: number
|
|
1107
1212
|
workingDirectory?: string
|
|
1108
1213
|
}) => {
|
|
1109
|
-
const timeoutMs =
|
|
1110
|
-
params.timeoutMs,
|
|
1111
|
-
defaults.monitorTimeoutCapMs ?? MONITOR_TIMEOUT_CAP_MS,
|
|
1112
|
-
)
|
|
1214
|
+
const timeoutMs = clampMonitorTimeout(params.timeoutMs)
|
|
1113
1215
|
const job = launch(params, { description: params.description, timeoutMs })
|
|
1114
1216
|
defaults.onMonitorStarted?.(job.id)
|
|
1115
1217
|
return { job, timeoutMs }
|
|
1116
1218
|
},
|
|
1219
|
+
// A monitor whose source is a WebSocket connection the HOST process
|
|
1220
|
+
// opens, whatever the sandbox spawn would have done with a command: the
|
|
1221
|
+
// tool has already checked the address and the sandbox's network rule,
|
|
1222
|
+
// and the socket is pinned to the addresses it checked. A separate
|
|
1223
|
+
// method so a host that implements `startMonitor` for commands is not
|
|
1224
|
+
// handed an input it never wrote code for.
|
|
1225
|
+
startWebSocketMonitor: (params: {
|
|
1226
|
+
description: string
|
|
1227
|
+
timeoutMs: number
|
|
1228
|
+
workingDirectory?: string
|
|
1229
|
+
webSocket: MonitorWebSocketTarget
|
|
1230
|
+
}) => {
|
|
1231
|
+
const timeoutMs = clampMonitorTimeout(params.timeoutMs)
|
|
1232
|
+
const { webSocket } = params
|
|
1233
|
+
const job = registry.start({
|
|
1234
|
+
owner,
|
|
1235
|
+
command: webSocketJobCommand(webSocket.url),
|
|
1236
|
+
workingDirectory: params.workingDirectory ?? defaults.workingDirectory ?? process.cwd(),
|
|
1237
|
+
spawn: (host) => connectWebSocketSource({ target: webSocket, host }),
|
|
1238
|
+
monitor: { description: params.description, timeoutMs, transport: 'websocket' },
|
|
1239
|
+
})
|
|
1240
|
+
defaults.onMonitorStarted?.(job.id)
|
|
1241
|
+
return { job, timeoutMs }
|
|
1242
|
+
},
|
|
1117
1243
|
// A foreground command that ran out of time, kept instead of killed.
|
|
1118
1244
|
// The same owner and cap as `start`; the process is the one already
|
|
1119
1245
|
// running, with what it has written so far.
|
|
@@ -1176,11 +1302,34 @@ export function bindOwner(
|
|
|
1176
1302
|
* own, so the summary does not repeat it.
|
|
1177
1303
|
*/
|
|
1178
1304
|
export function describeMonitorEnd(
|
|
1179
|
-
job: Pick<
|
|
1305
|
+
job: Pick<
|
|
1306
|
+
BackgroundJob,
|
|
1307
|
+
| 'description'
|
|
1308
|
+
| 'status'
|
|
1309
|
+
| 'exitCode'
|
|
1310
|
+
| 'signal'
|
|
1311
|
+
| 'error'
|
|
1312
|
+
| 'transport'
|
|
1313
|
+
| 'closeCode'
|
|
1314
|
+
| 'closeReason'
|
|
1315
|
+
>,
|
|
1180
1316
|
stats: Pick<MonitorStreamStats, 'stdoutChars'>,
|
|
1181
1317
|
): { status: 'completed' | 'failed' | 'killed'; summary: string } {
|
|
1182
1318
|
const label = `Monitor "${oneMetadataLine(job.description ?? 'unnamed')}"`
|
|
1183
1319
|
if (job.status === 'killed') return { status: 'killed', summary: `${label} stopped` }
|
|
1320
|
+
if (job.transport === 'websocket') {
|
|
1321
|
+
// A connection has no script to fail and no exit status to quote: it was
|
|
1322
|
+
// closed, by the server and with a code, or it never opened.
|
|
1323
|
+
if (job.closeCode === undefined)
|
|
1324
|
+
return { status: 'failed', summary: `${label} connection failed` }
|
|
1325
|
+
// The code only. The server's reason is text it chose, and the summary
|
|
1326
|
+
// is a metadata line outside the untrusted frame; the reason reaches the
|
|
1327
|
+
// model inside the frame, in the `[WebSocket closed: ...]` notice.
|
|
1328
|
+
return {
|
|
1329
|
+
status: job.exitCode === 0 ? 'completed' : 'failed',
|
|
1330
|
+
summary: `${label} connection closed (${job.closeCode})`,
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1184
1333
|
if (job.exitCode === 0 && job.error === undefined) {
|
|
1185
1334
|
return stats.stdoutChars > 0
|
|
1186
1335
|
? { status: 'completed', summary: `${label} stream ended` }
|