@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
@@ -6,6 +6,17 @@ import {
6
6
  MONITOR_TIMEOUT_MIN_MS,
7
7
  formatMonitorDuration,
8
8
  } from '../../runtime/jobs/monitor-stream.js'
9
+ import {
10
+ WEBSOCKET_PROTOCOLS_MAX,
11
+ WebSocketRefusedError,
12
+ type WebSocketVetOptions,
13
+ vetWebSocketTarget,
14
+ webSocketHostname,
15
+ webSocketProtocolsProblem,
16
+ webSocketSandboxRefusal,
17
+ webSocketUrlProblem,
18
+ } from '../../runtime/jobs/websocket-source.js'
19
+ import type { MonitorWebSocketTarget } from '../../types/tool/index.js'
9
20
  import {
10
21
  bashToolDialect,
11
22
  hostCommandShell,
@@ -38,42 +49,82 @@ import {
38
49
  * read the command the way they read `bash`'s, and it is never read-only. It
39
50
  * has no way out of the sandbox: a watch that outlives one approved call must
40
51
  * not run outside the boundary that call was approved under.
52
+ *
53
+ * A source can also be a WebSocket (`ws`), for services that push their events
54
+ * and offer no command that prints them. That one is a different thing to
55
+ * approve: it is not a shell line, it opens a connection FROM THE HOST to an
56
+ * address the model chose, and it does so outside any sandbox. So it is read
57
+ * by no command rule, it is always reviewed, a sandbox that blocks the network
58
+ * refuses it, and the address is checked and pinned (see
59
+ * `runtime/jobs/websocket-source.ts`). Untrusted frames reach the model the way
60
+ * a command's lines do: through the monitor event path, in its untrusted frame.
41
61
  */
42
62
 
43
- const inputSchema = z.object({
44
- description: z
45
- .string()
46
- .min(1)
47
- .max(200)
48
- .describe(
49
- 'What you are watching, in a few specific words. It is shown with every event ("Monitor event: <description>"), so make it identify this watch among several: "errors in deploy log", not "monitor".',
50
- ),
51
- command: z
63
+ const webSocketInput = z.object({
64
+ url: z
52
65
  .string()
53
- .min(1)
54
- // Same rewrite as `bash`, for the same reason: the rules read the line a
55
- // shell will run.
56
- .transform((command) =>
57
- isWindowsBash(hostCommandShell()) ? rewriteWindowsNullRedirect(command) : command,
58
- )
59
- .describe(
60
- 'The shell command to watch. Each line it prints to stdout becomes an event. It runs until it ends or the timeout; it must not need stdin.',
61
- ),
62
- timeout_ms: z
63
- .preprocess(
64
- (v) => (typeof v === 'string' ? Number(v) : v),
65
- z
66
- .number()
67
- .int()
68
- .min(MONITOR_TIMEOUT_MIN_MS)
69
- .max(MONITOR_TIMEOUT_MAX_MS)
70
- .default(MONITOR_TIMEOUT_DEFAULT_MS),
71
- )
66
+ .superRefine((value, ctx) => {
67
+ const problem = webSocketUrlProblem(value)
68
+ if (problem !== undefined) ctx.addIssue({ code: z.ZodIssueCode.custom, message: problem })
69
+ })
72
70
  .describe(
73
- `How long to watch, in milliseconds. Default ${MONITOR_TIMEOUT_DEFAULT_MS}. The host caps it (30 minutes by default; shorter in a single-shot run), and a larger value is lowered to that cap without an error; the result states the time actually granted.`,
71
+ 'The ws:// or wss:// URL to connect to. A query string is kept; credentials in it are refused.',
74
72
  ),
73
+ protocols: z
74
+ .array(z.string())
75
+ .max(WEBSOCKET_PROTOCOLS_MAX)
76
+ .superRefine((value, ctx) => {
77
+ const problem = webSocketProtocolsProblem(value)
78
+ if (problem !== undefined) ctx.addIssue({ code: z.ZodIssueCode.custom, message: problem })
79
+ })
80
+ .optional()
81
+ .describe('Subprotocol names to offer, when the service asks for one.'),
75
82
  })
76
83
 
84
+ const inputSchema = z
85
+ .object({
86
+ description: z
87
+ .string()
88
+ .min(1)
89
+ .max(200)
90
+ .describe(
91
+ 'What you are watching, in a few specific words. It is shown with every event ("Monitor event: <description>"), so make it identify this watch among several: "errors in deploy log", not "monitor".',
92
+ ),
93
+ command: z
94
+ .string()
95
+ .min(1)
96
+ // Same rewrite as `bash`, for the same reason: the rules read the line a
97
+ // shell will run.
98
+ .transform((command) =>
99
+ isWindowsBash(hostCommandShell()) ? rewriteWindowsNullRedirect(command) : command,
100
+ )
101
+ .optional()
102
+ .describe(
103
+ 'The shell command to watch. Each line it prints to stdout becomes an event. It runs until it ends or the timeout; it must not need stdin. Give this or ws, not both.',
104
+ ),
105
+ ws: webSocketInput
106
+ .optional()
107
+ .describe(
108
+ 'Watch a WebSocket instead of a command: each line of each text frame the server sends becomes an event. Give this or command, not both.',
109
+ ),
110
+ timeout_ms: z
111
+ .preprocess(
112
+ (v) => (typeof v === 'string' ? Number(v) : v),
113
+ z
114
+ .number()
115
+ .int()
116
+ .min(MONITOR_TIMEOUT_MIN_MS)
117
+ .max(MONITOR_TIMEOUT_MAX_MS)
118
+ .default(MONITOR_TIMEOUT_DEFAULT_MS),
119
+ )
120
+ .describe(
121
+ `How long to watch, in milliseconds. Default ${MONITOR_TIMEOUT_DEFAULT_MS}. The host caps it (30 minutes by default; shorter in a single-shot run), and a larger value is lowered to that cap without an error; the result states the time actually granted.`,
122
+ ),
123
+ })
124
+ .refine((input) => (input.command === undefined) !== (input.ws === undefined), {
125
+ message: 'Give exactly one of command and ws.',
126
+ })
127
+
77
128
  type MonitorInput = z.infer<typeof inputSchema>
78
129
 
79
130
  export const MONITOR_TOOL_NAME = 'monitor'
@@ -88,109 +139,227 @@ export const MONITOR_CANNOT_DETACH =
88
139
  'monitor is unavailable: this sandbox cannot start a detached process, and the watch will not be run on the host to get around it. Run the command in the foreground with bash, with a timeout.'
89
140
 
90
141
  const MONITOR_DESCRIPTION =
91
- 'Starts a command and tells you about each line it prints to stdout, as it prints it, while you keep working. Lines that arrive within 200 ms come together as one event. Use it to watch a log, a test or deploy run, a status endpoint — anything where you must react to something happening INSIDE a long-running command. For ONE notification when something finishes, do not use this: run bash with run_in_background and wait_for_job. Every stage of a pipe must flush per line or events arrive late or never (grep --line-buffered, awk with fflush(), sed -u; `head` cannot). Make the filter match failure as well as success (crash, Traceback, Error, FAILED, Killed…): silence from a monitor is not success. Poll a remote API no faster than every 30 seconds. stderr is not an event; read it with job. A monitor that prints too much is stopped, so filter at the source. It expires after the timeout (default 5 minutes, at most 30) and you get one notice; start it again if you still need it. Stop one early with job {"action":"kill","id":"<id>"}.'
92
-
93
- export const MonitorTool = defineTool({
94
- name: MONITOR_TOOL_NAME,
95
- description: MONITOR_DESCRIPTION,
96
- inputSchema,
97
- category: 'shell',
98
- // Bash's, because it IS a shell command: the gate reads the line, splits
99
- // chains, and applies the operator's rules to each part.
100
- commandArgument: 'command',
101
- commandDialect: bashToolDialect,
102
- permissions: ['shell_execute'],
103
- readOnly: false,
104
- destructive: (input: MonitorInput) => isDangerousCommand(input.command),
105
- // Starting a monitor registers a job against the owner's cap and its id
106
- // is then named in later calls; two at once could race the cap, as they
107
- // can for a background `bash`.
108
- concurrencySafe: false,
109
-
110
- presentCall(input) {
111
- if (typeof input.command !== 'string') return undefined
112
- return {
113
- kind: 'generic',
114
- label: `Monitor · ${typeof input.description === 'string' ? input.description : input.command}`,
115
- facts: { kind: 'command', command: input.command, background: true },
116
- }
117
- },
118
-
119
- async execute(input, context) {
120
- if (isDangerousCommand(input.command)) {
121
- return {
122
- success: false,
123
- output: '',
124
- error: `Dangerous command blocked: "${input.command}"`,
125
- facts: { kind: 'failure', reason: 'blocked', subject: MONITOR_TOOL_NAME },
142
+ 'Starts a command and tells you about each line it prints to stdout, as it prints it, while you keep working. Lines that arrive within 200 ms come together as one event. Use it to watch a log, a test or deploy run, a status endpoint — anything where you must react to something happening INSIDE a long-running command. For ONE notification when something finishes, do not use this: run bash with run_in_background and wait_for_job. Every stage of a pipe must flush per line or events arrive late or never (grep --line-buffered, awk with fflush(), sed -u; `head` cannot). Make the filter match failure as well as success (crash, Traceback, Error, FAILED, Killed…): silence from a monitor is not success. Poll a remote API no faster than every 30 seconds. stderr is not an event; read it with job. A monitor that prints too much is stopped, so filter at the source. It expires after the timeout (default 5 minutes, at most 30) and you get one notice; start it again if you still need it. Stop one early with job {"action":"kill","id":"<id>"}. ' +
143
+ 'To watch a service that PUSHES events (a CI or log stream, a change feed) give ws instead of command: { "url": "wss://…", "protocols": [...] }. Each line of each text frame becomes an event, binary frames are only counted, and the watch ends when the server closes the connection. Use it for a push stream, not for request and response. It connects from this machine, so private and local addresses are refused, and you are asked before it connects.'
144
+
145
+ /** What a host can tune about the WebSocket source; for tests only, see {@link WebSocketVetOptions}. */
146
+ export interface MonitorToolOptions {
147
+ readonly webSocket?: WebSocketVetOptions
148
+ /**
149
+ * How to turn web access on, in the host's own words, appended to the
150
+ * refusal a WebSocket call gets while it is off. The SDK cannot name a
151
+ * switch it does not own (a CLI config key, a desktop setting), and a
152
+ * refusal that only says "off" leaves the operator guessing. One short
153
+ * sentence; it is shown to the model and the operator as written.
154
+ */
155
+ readonly webAccessHint?: string
156
+ }
157
+
158
+ /** The text that says a WebSocket watch was refused because the host has web access off. */
159
+ export const MONITOR_WEBSOCKET_WEB_OFF =
160
+ "WebSocket monitors follow the host's web access setting, and web access is switched off here, so no connection was opened. Watch the service with a command (for example curl or websocat through monitor) if the operator allows it."
161
+
162
+ const MONITOR_WEBSOCKET_NOT_SUPPORTED =
163
+ 'This host cannot start a WebSocket monitor, so no connection was opened. Watch the service with a command through monitor if the operator allows it.'
164
+
165
+ /**
166
+ * The tool, built once with defaults for a host and again by a test that needs
167
+ * to reach a server on this machine. The options belong to whoever builds the
168
+ * tool; nothing a model sends can set them.
169
+ */
170
+ export function createMonitorTool(options: MonitorToolOptions = {}) {
171
+ return defineTool({
172
+ name: MONITOR_TOOL_NAME,
173
+ description: MONITOR_DESCRIPTION,
174
+ inputSchema,
175
+ category: 'shell',
176
+ // Bash's, because it IS a shell command: the gate reads the line, splits
177
+ // chains, and applies the operator's rules to each part. A WebSocket call
178
+ // has no `command`, so a rule written for commands has nothing to match
179
+ // and cannot allow it; `requiresApproval` below is what asks.
180
+ commandArgument: 'command',
181
+ commandDialect: bashToolDialect,
182
+ // `network_access` too: a WebSocket call reaches the network. The gate
183
+ // reads a tool's `category`, not this list, to route a call, so declaring
184
+ // it changes no decision for a command; it says what the tool may do.
185
+ permissions: ['shell_execute', 'network_access'],
186
+ readOnly: false,
187
+ destructive: (input: MonitorInput) =>
188
+ input.command !== undefined && isDangerousCommand(input.command),
189
+ // A WebSocket call is the one input-aware seam the gate offers that no
190
+ // allow rule or mode outranks: it connects from the host, outside any
191
+ // sandbox, to an address the model chose. `category: 'shell'` is what the
192
+ // sandboxed-shell preset auto-approves, and moving the whole tool to
193
+ // `network` would start asking about commands that were never a question;
194
+ // this asks only about the connection. It is stronger than "review where
195
+ // the mode reviews non-read-only calls": `auto` mode and an unattended
196
+ // turn are asked too (or refuse when nobody can be asked), which is the
197
+ // point for a connection that leaves the boundary those modes rely on.
198
+ requiresApproval: (input: MonitorInput) => input.ws !== undefined,
199
+ // Starting a monitor registers a job against the owner's cap and its id
200
+ // is then named in later calls; two at once could race the cap, as they
201
+ // can for a background `bash`.
202
+ concurrencySafe: false,
203
+
204
+ presentCall(input) {
205
+ const label = typeof input.description === 'string' ? input.description : undefined
206
+ if (input.ws !== undefined && typeof input.ws === 'object') {
207
+ const ws = input.ws as { url?: unknown; protocols?: unknown }
208
+ if (typeof ws.url !== 'string') return undefined
209
+ const protocols =
210
+ Array.isArray(ws.protocols) && ws.protocols.length > 0
211
+ ? ` (subprotocols: ${ws.protocols.join(', ')})`
212
+ : ''
213
+ // What will happen, said plainly, because this is what a person
214
+ // approves: no command line stands in for it.
215
+ return {
216
+ kind: 'generic',
217
+ label: `Monitor${label ? ` · ${label}` : ''} · WebSocket ${ws.url}${protocols}`,
218
+ }
126
219
  }
127
- }
128
- if (backgroundTasksDisabled()) {
220
+ if (typeof input.command !== 'string') return undefined
129
221
  return {
130
- success: false,
131
- output: '',
132
- error: `Background jobs are switched off here (${DISABLE_BACKGROUND_TASKS_ENV}), and a monitor is one. Check in the foreground with bash instead.`,
222
+ kind: 'generic',
223
+ label: `Monitor · ${label ?? input.command}`,
224
+ facts: { kind: 'command', command: input.command, background: true },
133
225
  }
134
- }
135
- // Same rule as background bash: the executor withholds the registry from
136
- // a sandbox that cannot start a detached process, and this check is for
137
- // the direct caller who hands both.
138
- if (context.sandbox && context.sandbox.spawnDetached === undefined) {
139
- return { success: false, output: '', error: MONITOR_CANNOT_DETACH }
140
- }
141
- const jobs = context.backgroundJobs
142
- if (!jobs) {
143
- return {
144
- success: false,
145
- output: '',
146
- error:
147
- 'monitor was requested, but this host provides no background job registry. Run the command in the foreground with bash, with a timeout.',
226
+ },
227
+
228
+ async execute(input, context) {
229
+ if (input.command !== undefined && isDangerousCommand(input.command)) {
230
+ return {
231
+ success: false,
232
+ output: '',
233
+ error: `Dangerous command blocked: "${input.command}"`,
234
+ facts: { kind: 'failure', reason: 'blocked', subject: MONITOR_TOOL_NAME },
235
+ }
148
236
  }
149
- }
150
- if (!jobs.startMonitor) {
151
- return {
152
- success: false,
153
- output: '',
154
- error:
155
- 'This host cannot deliver monitor events, so no watch was started. Use bash with run_in_background and wait_for_job to be told when a command finishes.',
237
+ if (backgroundTasksDisabled()) {
238
+ return {
239
+ success: false,
240
+ output: '',
241
+ error: `Background jobs are switched off here (${DISABLE_BACKGROUND_TASKS_ENV}), and a monitor is one. Check in the foreground with bash instead.`,
242
+ }
243
+ }
244
+ // What is checked about a connection is checked before anything that
245
+ // speaks of commands, so a refusal names its own cause.
246
+ let webSocket: MonitorWebSocketTarget | undefined
247
+ if (input.ws !== undefined) {
248
+ // The operator's switch for reaching the web at all. A host that has
249
+ // not given this turn a web capability has not decided to let the
250
+ // model open connections, and a WebSocket is one.
251
+ if (context.web?.fetch === undefined) {
252
+ return {
253
+ success: false,
254
+ output: '',
255
+ error: options.webAccessHint
256
+ ? `${MONITOR_WEBSOCKET_WEB_OFF} ${options.webAccessHint}`
257
+ : MONITOR_WEBSOCKET_WEB_OFF,
258
+ }
259
+ }
260
+ const hostname = webSocketHostname(input.ws.url)
261
+ const sandboxRefusal = webSocketSandboxRefusal(context.sandbox, hostname)
262
+ if (sandboxRefusal !== undefined) {
263
+ return { success: false, output: '', error: sandboxRefusal }
264
+ }
265
+ try {
266
+ const addresses = await vetWebSocketTarget(input.ws.url, options.webSocket)
267
+ webSocket = {
268
+ url: input.ws.url,
269
+ ...(input.ws.protocols ? { protocols: input.ws.protocols } : {}),
270
+ addresses,
271
+ }
272
+ } catch (err) {
273
+ if (err instanceof WebSocketRefusedError) {
274
+ return { success: false, output: '', error: err.message }
275
+ }
276
+ throw err
277
+ }
278
+ } else if (context.sandbox && context.sandbox.spawnDetached === undefined) {
279
+ // Same rule as background bash: the executor withholds the registry
280
+ // from a sandbox that cannot start a detached process, and this check
281
+ // is for the direct caller who hands both.
282
+ return { success: false, output: '', error: MONITOR_CANNOT_DETACH }
156
283
  }
157
- }
158
-
159
- try {
160
- const { job, timeoutMs } = jobs.startMonitor({
161
- command: input.command,
162
- description: input.description,
163
- timeoutMs: input.timeout_ms,
164
- workingDirectory: context.workingDirectory,
165
- })
166
- // `startMonitor` returns before the process exists, and a spawn that
167
- // fails reports a tick later; wait for that one event so "started" is
168
- // never said of a watch that could not start.
169
- const startup = await jobs.awaitStarted?.(job.id)
170
- if (startup?.error !== undefined) {
284
+ const jobs = context.backgroundJobs
285
+ if (!jobs) {
171
286
  return {
172
287
  success: false,
173
288
  output: '',
174
- error: `${startup.error} (monitor ${job.id} did not run).`,
175
- data: { jobId: job.id, monitor: true, startFailed: true },
289
+ error:
290
+ 'monitor was requested, but this host provides no background job registry. Run the command in the foreground with bash, with a timeout.',
176
291
  }
177
292
  }
178
- return {
179
- success: true,
180
- output: `Monitor started (job ${job.id}, expires in ${formatMonitorDuration(timeoutMs)} unless the command ends first; you get one notice at expiry — start it again if you still need the watch). Each line the command prints to stdout reaches you as an event while you keep working; do not poll or sleep. Stop it with job {"action":"kill","id":"${job.id}"}. An event may arrive while you are waiting for the user — it is not their reply.`,
181
- data: { jobId: job.id, monitor: true, timeoutMs },
182
- facts: { kind: 'command', command: input.command, background: true },
293
+ if (webSocket !== undefined && !jobs.startWebSocketMonitor) {
294
+ return {
295
+ success: false,
296
+ output: '',
297
+ error: MONITOR_WEBSOCKET_NOT_SUPPORTED,
298
+ }
183
299
  }
184
- } catch (err) {
185
- // The per-owner cap, most likely; naming the limit is actionable.
186
- return {
187
- success: false,
188
- output: '',
189
- error: err instanceof Error ? err.message : String(err),
300
+ if (webSocket === undefined && !jobs.startMonitor) {
301
+ return {
302
+ success: false,
303
+ output: '',
304
+ error:
305
+ 'This host cannot deliver monitor events, so no watch was started. Use bash with run_in_background and wait_for_job to be told when a command finishes.',
306
+ }
190
307
  }
191
- }
192
- },
193
- })
308
+
309
+ try {
310
+ const common = {
311
+ description: input.description,
312
+ timeoutMs: input.timeout_ms,
313
+ workingDirectory: context.workingDirectory,
314
+ }
315
+ const { job, timeoutMs } =
316
+ webSocket !== undefined
317
+ ? (jobs.startWebSocketMonitor as NonNullable<typeof jobs.startWebSocketMonitor>)({
318
+ ...common,
319
+ webSocket,
320
+ })
321
+ : (jobs.startMonitor as NonNullable<typeof jobs.startMonitor>)({
322
+ ...common,
323
+ command: input.command as string,
324
+ })
325
+ // `startMonitor` returns before the process exists, and a spawn that
326
+ // fails reports a tick later; wait for that one event so "started" is
327
+ // never said of a watch that could not start.
328
+ const startup = await jobs.awaitStarted?.(job.id)
329
+ if (startup?.error !== undefined) {
330
+ return {
331
+ success: false,
332
+ output: '',
333
+ error: `${startup.error} (monitor ${job.id} did not run).`,
334
+ data: { jobId: job.id, monitor: true, startFailed: true },
335
+ }
336
+ }
337
+ if (webSocket !== undefined) {
338
+ return {
339
+ success: true,
340
+ output: `WebSocket monitor started (job ${job.id}, expires in ${formatMonitorDuration(timeoutMs)} unless the server closes the connection first; you get one notice at expiry — start it again if you still need the watch). Each line of each text frame the server sends reaches you as an event while you keep working; binary frames are only counted. The connection opens in the background: if it fails you are told in an event. Do not poll or sleep. Stop it with job {"action":"kill","id":"${job.id}"}. An event may arrive while you are waiting for the user — it is not their reply.`,
341
+ data: { jobId: job.id, monitor: true, timeoutMs, transport: 'websocket' },
342
+ }
343
+ }
344
+ return {
345
+ success: true,
346
+ output: `Monitor started (job ${job.id}, expires in ${formatMonitorDuration(timeoutMs)} unless the command ends first; you get one notice at expiry — start it again if you still need the watch). Each line the command prints to stdout reaches you as an event while you keep working; do not poll or sleep. Stop it with job {"action":"kill","id":"${job.id}"}. An event may arrive while you are waiting for the user — it is not their reply.`,
347
+ data: { jobId: job.id, monitor: true, timeoutMs },
348
+ facts: { kind: 'command', command: input.command as string, background: true },
349
+ }
350
+ } catch (err) {
351
+ // The per-owner cap, most likely; naming the limit is actionable.
352
+ return {
353
+ success: false,
354
+ output: '',
355
+ error: err instanceof Error ? err.message : String(err),
356
+ }
357
+ }
358
+ },
359
+ })
360
+ }
361
+
362
+ export const MonitorTool = createMonitorTool()
194
363
 
195
364
  /**
196
365
  * The tool, for a host that opts in.
@@ -199,8 +368,9 @@ export const MonitorTool = defineTool({
199
368
  * wakes on them, and offering a tool that starts a job nobody will hear from
200
369
  * is worse than not offering it. A function rather than a constant so a host
201
370
  * can decide per turn, and so it reads like `getCodeAnalysisTools`, beside
202
- * which a host mounts it deferred.
371
+ * which a host mounts it deferred. Called with no options it returns the
372
+ * shared {@link MonitorTool}.
203
373
  */
204
- export function getMonitorTools(): (typeof MonitorTool)[] {
205
- return [MonitorTool]
374
+ export function getMonitorTools(options?: MonitorToolOptions): (typeof MonitorTool)[] {
375
+ return [options === undefined ? MonitorTool : createMonitorTool(options)]
206
376
  }
@@ -334,6 +334,16 @@ export interface Sandbox {
334
334
  readonly status: SandboxStatus
335
335
  readonly rootDir: string
336
336
  readonly environment: SandboxEnvironment
337
+ /**
338
+ * The network policy in force right now, when the backend can say.
339
+ *
340
+ * Optional, and absent does NOT mean open: a caller that needs to know
341
+ * whether a host is reachable (the host process opening a connection on a
342
+ * turn's behalf, outside the boundary) treats absence as "not allowed" on a
343
+ * tier that isolates the network. A backend that enforces
344
+ * {@link setNetworkPolicy} should report what it enforces here.
345
+ */
346
+ readonly networkPolicy?: SandboxNetworkPolicy
337
347
  exec(command: string, args?: string[], opts?: SandboxExecOptions): Promise<SandboxExecResult>
338
348
 
339
349
  /**
@@ -105,6 +105,20 @@ export interface SkillRegistryRef {
105
105
  names(): readonly string[]
106
106
  }
107
107
 
108
+ /**
109
+ * Where a WebSocket monitor connects, already checked.
110
+ *
111
+ * `addresses` are what the caller verified are outside the host's own
112
+ * network; the connection is pinned to them and never resolves the name
113
+ * itself. A caller that did not check must not hand it a target: the registry
114
+ * trusts it, as it trusts a spawn it is given.
115
+ */
116
+ export interface MonitorWebSocketTarget {
117
+ readonly url: string
118
+ readonly protocols?: readonly string[]
119
+ readonly addresses: readonly { readonly address: string; readonly family: 4 | 6 }[]
120
+ }
121
+
108
122
  /**
109
123
  * The slice of the background job registry a tool is given.
110
124
  *
@@ -135,6 +149,19 @@ export interface BackgroundJobRegistryRef {
135
149
  timeoutMs: number
136
150
  workingDirectory?: string
137
151
  }): { job: { id: string; status: string }; timeoutMs: number }
152
+ /**
153
+ * Start a monitor whose source is a WebSocket connection. Separate from
154
+ * {@link startMonitor} so a host that implements that one for commands is
155
+ * not handed an input it was not written for; a host without this method
156
+ * refuses a `ws` call with a plain message. The target's addresses are
157
+ * already checked; see {@link MonitorWebSocketTarget}.
158
+ */
159
+ startWebSocketMonitor?(params: {
160
+ description: string
161
+ timeoutMs: number
162
+ workingDirectory?: string
163
+ webSocket: MonitorWebSocketTarget
164
+ }): { job: { id: string; status: string }; timeoutMs: number }
138
165
  get(id: string): {
139
166
  id: string
140
167
  status: string