@opengeni/runtime 0.11.0 → 0.13.3
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/dist/{chunk-YUAFQEFY.js → chunk-JGUQSAG5.js} +2605 -279
- package/dist/chunk-JGUQSAG5.js.map +1 -0
- package/dist/chunk-SSB7VOLE.js +301 -0
- package/dist/chunk-SSB7VOLE.js.map +1 -0
- package/dist/{index-D6yi-JcU.d.ts → index-DU-KXVlx.d.ts} +364 -31
- package/dist/index.d.ts +103 -10
- package/dist/index.js +756 -761
- package/dist/index.js.map +1 -1
- package/dist/mcp-network.d.ts +80 -0
- package/dist/mcp-network.js +66 -0
- package/dist/mcp-network.js.map +1 -0
- package/dist/sandbox/index.d.ts +1 -1
- package/dist/sandbox/index.js +39 -1
- package/package.json +9 -4
- package/src/codex-tool-search.ts +20 -1
- package/src/index.ts +502 -85
- package/src/mcp-network.ts +343 -0
- package/src/sandbox/channel-a.ts +92 -71
- package/src/sandbox/display-stack.ts +860 -23
- package/src/sandbox/exec-banner.ts +82 -0
- package/src/sandbox/index.ts +367 -145
- package/src/sandbox/provider-errors.ts +175 -0
- package/src/sandbox/providers/modal.ts +29 -2
- package/src/sandbox/routing/backend-resolver.ts +15 -0
- package/src/sandbox/routing/routing-session.ts +730 -36
- package/src/sandbox/select.ts +8 -0
- package/src/sandbox/turn-tool-cancellation.ts +366 -92
- package/src/sandbox/workspace-archive.ts +289 -0
- package/src/sandbox-computer.ts +3 -1
- package/src/usage-telemetry.ts +495 -51
- package/dist/chunk-YUAFQEFY.js.map +0 -1
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import type { Settings } from "@opengeni/config";
|
|
2
|
+
import {
|
|
3
|
+
isNonPublicAddress,
|
|
4
|
+
pinnedFetch,
|
|
5
|
+
resolvePinnedDestination,
|
|
6
|
+
type DnsLookup,
|
|
7
|
+
type FetchLike,
|
|
8
|
+
} from "@opengeni/network";
|
|
9
|
+
|
|
10
|
+
export { undiciFetch } from "@opengeni/network";
|
|
11
|
+
|
|
12
|
+
export const MCP_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
13
|
+
export const MCP_MAX_INBOUND_REQUEST_BYTES = 1024 * 1024;
|
|
14
|
+
export const MCP_MAX_TOOL_DEFINITION_BYTES = 128 * 1024;
|
|
15
|
+
export const MCP_MAX_TOOL_LIST_BYTES = 4 * 1024 * 1024;
|
|
16
|
+
export const MCP_MAX_TOOL_LIST_ENTRIES = 1_000;
|
|
17
|
+
export const MCP_MAX_TOOL_RESULT_BYTES = 1024 * 1024;
|
|
18
|
+
export const MCP_MAX_TOOL_SEARCH_DISCLOSURE_BYTES = 256 * 1024;
|
|
19
|
+
export const MCP_MAX_SELECTED_SERVERS = 64;
|
|
20
|
+
export const MCP_MAX_CONCURRENT_SERVER_OPERATIONS = 8;
|
|
21
|
+
// @openai/agents has a separate lifecycle fence around MCPServer.connect().
|
|
22
|
+
// It defaults to 10 seconds and can otherwise preempt a larger per-server
|
|
23
|
+
// transport timeout before that server finishes its own handshake.
|
|
24
|
+
export const MCP_DEFAULT_OUTER_CONNECT_TIMEOUT_MS = 10_000;
|
|
25
|
+
export const MCP_MAX_AGGREGATE_TOOL_LIST_ENTRIES = 4_096;
|
|
26
|
+
export const MCP_MAX_AGGREGATE_TOOL_LIST_BYTES = 16 * 1024 * 1024;
|
|
27
|
+
export const MCP_MAX_TOOL_SEARCH_SOURCES = 64;
|
|
28
|
+
export const MCP_MAX_TOOL_SEARCH_SOURCE_LABEL_LENGTH = 120;
|
|
29
|
+
export const MCP_MAX_TOOL_SEARCH_DESCRIPTION_BYTES = 16 * 1024;
|
|
30
|
+
|
|
31
|
+
export class McpPayloadTooLargeError extends Error {
|
|
32
|
+
constructor(
|
|
33
|
+
readonly label: string,
|
|
34
|
+
readonly actualBytes: number,
|
|
35
|
+
readonly maxBytes: number,
|
|
36
|
+
) {
|
|
37
|
+
super(`${label} exceeds the ${maxBytes}-byte safety limit`);
|
|
38
|
+
this.name = "McpPayloadTooLargeError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function mcpSerializedSizeBytes(value: unknown): number {
|
|
43
|
+
let serialized: string;
|
|
44
|
+
try {
|
|
45
|
+
serialized = JSON.stringify(value) ?? "null";
|
|
46
|
+
} catch {
|
|
47
|
+
throw new Error("MCP payload is not JSON serializable");
|
|
48
|
+
}
|
|
49
|
+
return Buffer.byteLength(serialized);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function assertMcpPayloadWithinBytes(value: unknown, maxBytes: number, label: string): void {
|
|
53
|
+
const actualBytes = mcpSerializedSizeBytes(value);
|
|
54
|
+
if (actualBytes > maxBytes) {
|
|
55
|
+
throw new McpPayloadTooLargeError(label, actualBytes, maxBytes);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function assertMcpToolListWithinBounds<T>(tools: readonly T[]): readonly T[] {
|
|
60
|
+
if (tools.length > MCP_MAX_TOOL_LIST_ENTRIES) {
|
|
61
|
+
throw new McpPayloadTooLargeError("MCP tool list", tools.length, MCP_MAX_TOOL_LIST_ENTRIES);
|
|
62
|
+
}
|
|
63
|
+
for (const tool of tools) {
|
|
64
|
+
const toolBytes = mcpSerializedSizeBytes(tool);
|
|
65
|
+
if (toolBytes > MCP_MAX_TOOL_DEFINITION_BYTES) {
|
|
66
|
+
throw new McpPayloadTooLargeError(
|
|
67
|
+
"MCP tool definition",
|
|
68
|
+
toolBytes,
|
|
69
|
+
MCP_MAX_TOOL_DEFINITION_BYTES,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Measure the complete JSON array, including brackets and separators. Summing
|
|
74
|
+
// definitions alone under-counts by one byte per separator plus the array
|
|
75
|
+
// delimiters and makes the advertised serialized-byte ceiling porous.
|
|
76
|
+
const totalBytes = mcpSerializedSizeBytes(tools);
|
|
77
|
+
if (totalBytes > MCP_MAX_TOOL_LIST_BYTES) {
|
|
78
|
+
throw new McpPayloadTooLargeError("MCP tool list", totalBytes, MCP_MAX_TOOL_LIST_BYTES);
|
|
79
|
+
}
|
|
80
|
+
return tools;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function assertMcpServerSelectionWithinBounds<T>(servers: readonly T[]): readonly T[] {
|
|
84
|
+
if (servers.length > MCP_MAX_SELECTED_SERVERS) {
|
|
85
|
+
throw new McpPayloadTooLargeError(
|
|
86
|
+
"selected MCP server count",
|
|
87
|
+
servers.length,
|
|
88
|
+
MCP_MAX_SELECTED_SERVERS,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
return servers;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function mcpOuterConnectTimeoutMs(
|
|
95
|
+
configuredTimeouts: readonly (number | undefined)[],
|
|
96
|
+
): number {
|
|
97
|
+
return Math.max(
|
|
98
|
+
MCP_DEFAULT_OUTER_CONNECT_TIMEOUT_MS,
|
|
99
|
+
...configuredTimeouts.flatMap((timeoutMs) => (timeoutMs === undefined ? [] : [timeoutMs])),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type McpToolListContribution = {
|
|
104
|
+
entries: number;
|
|
105
|
+
bytes: number;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Atomic aggregate accounting shared by every exposed tools/list in one run.
|
|
110
|
+
* A repeated list for the same source REPLACES its prior contribution, so
|
|
111
|
+
* cache invalidation/relisting cannot ratchet the budget upward. Failed
|
|
112
|
+
* replacements leave the previous accounting intact.
|
|
113
|
+
*/
|
|
114
|
+
export class McpAggregateToolListBudget {
|
|
115
|
+
private readonly contributions = new Map<string, McpToolListContribution>();
|
|
116
|
+
private totalEntries = 0;
|
|
117
|
+
private totalBytes = 0;
|
|
118
|
+
|
|
119
|
+
constructor(
|
|
120
|
+
private readonly label = "aggregate MCP tool list",
|
|
121
|
+
private readonly maxEntries = MCP_MAX_AGGREGATE_TOOL_LIST_ENTRIES,
|
|
122
|
+
private readonly maxBytes = MCP_MAX_AGGREGATE_TOOL_LIST_BYTES,
|
|
123
|
+
) {}
|
|
124
|
+
|
|
125
|
+
replace<T>(sourceId: string, tools: readonly T[]): readonly T[] {
|
|
126
|
+
assertMcpToolListWithinBounds(tools);
|
|
127
|
+
const contribution = {
|
|
128
|
+
entries: tools.length,
|
|
129
|
+
// Conservatively account each server as its complete serialized array.
|
|
130
|
+
// Summing these arrays slightly over-counts the brackets relative to one
|
|
131
|
+
// flattened array, which is intentional for a fail-closed hard ceiling.
|
|
132
|
+
bytes: mcpSerializedSizeBytes(tools),
|
|
133
|
+
};
|
|
134
|
+
const previous = this.contributions.get(sourceId) ?? { entries: 0, bytes: 0 };
|
|
135
|
+
const nextEntries = this.totalEntries - previous.entries + contribution.entries;
|
|
136
|
+
if (nextEntries > this.maxEntries) {
|
|
137
|
+
throw new McpPayloadTooLargeError(this.label, nextEntries, this.maxEntries);
|
|
138
|
+
}
|
|
139
|
+
const nextBytes = this.totalBytes - previous.bytes + contribution.bytes;
|
|
140
|
+
if (nextBytes > this.maxBytes) {
|
|
141
|
+
throw new McpPayloadTooLargeError(this.label, nextBytes, this.maxBytes);
|
|
142
|
+
}
|
|
143
|
+
this.contributions.set(sourceId, contribution);
|
|
144
|
+
this.totalEntries = nextEntries;
|
|
145
|
+
this.totalBytes = nextBytes;
|
|
146
|
+
return tools;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
remove(sourceId: string): void {
|
|
150
|
+
const previous = this.contributions.get(sourceId);
|
|
151
|
+
if (!previous) return;
|
|
152
|
+
this.contributions.delete(sourceId);
|
|
153
|
+
this.totalEntries -= previous.entries;
|
|
154
|
+
this.totalBytes -= previous.bytes;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
snapshot(): Readonly<McpToolListContribution> {
|
|
158
|
+
return { entries: this.totalEntries, bytes: this.totalBytes };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Stable-order parallel map with a hard in-flight ceiling and fail-stop drain. */
|
|
163
|
+
export async function boundedParallelMap<T, R>(
|
|
164
|
+
values: readonly T[],
|
|
165
|
+
concurrency: number,
|
|
166
|
+
mapper: (value: T, index: number) => Promise<R>,
|
|
167
|
+
): Promise<R[]> {
|
|
168
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
|
169
|
+
throw new Error("bounded parallel map concurrency must be a positive integer");
|
|
170
|
+
}
|
|
171
|
+
if (values.length === 0) return [];
|
|
172
|
+
|
|
173
|
+
const results = new Array<R>(values.length);
|
|
174
|
+
const errors: Array<{ index: number; error: unknown }> = [];
|
|
175
|
+
let nextIndex = 0;
|
|
176
|
+
let stopped = false;
|
|
177
|
+
const worker = async () => {
|
|
178
|
+
while (!stopped) {
|
|
179
|
+
const index = nextIndex;
|
|
180
|
+
if (index >= values.length) return;
|
|
181
|
+
nextIndex += 1;
|
|
182
|
+
try {
|
|
183
|
+
results[index] = await mapper(values[index]!, index);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
errors.push({ index, error });
|
|
186
|
+
stopped = true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, () => worker()));
|
|
191
|
+
if (errors.length > 0) {
|
|
192
|
+
errors.sort((left, right) => left.index - right.index);
|
|
193
|
+
throw errors[0]!.error;
|
|
194
|
+
}
|
|
195
|
+
return results;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Guard normal MCP traffic with the same deployment policy as OAuth traffic.
|
|
200
|
+
* The pinned transport resolves and policy-checks the destination after the
|
|
201
|
+
* caller has composed any credential headers, then performs the final network
|
|
202
|
+
* call through the vetted Agent. It forces manual redirects, so an
|
|
203
|
+
* Authorization-bearing request never follows a provider-controlled Location
|
|
204
|
+
* to an unvalidated host.
|
|
205
|
+
*
|
|
206
|
+
* This module is deliberately agent-loop-free. API code imports the explicit
|
|
207
|
+
* `@opengeni/runtime/mcp-network` leaf rather than the runtime root barrel.
|
|
208
|
+
*/
|
|
209
|
+
export function guardedMcpFetch<TInput extends string | URL | Request>(
|
|
210
|
+
settings: Pick<Settings, "environment" | "integrationsAllowPrivateNetworkTargets">,
|
|
211
|
+
fetchImpl: (input: TInput, init?: RequestInit) => Promise<Response>,
|
|
212
|
+
options: {
|
|
213
|
+
maxResponseBytes?: number;
|
|
214
|
+
dnsLookup?: DnsLookup;
|
|
215
|
+
requireHttpsOutsideLocalTest?: boolean;
|
|
216
|
+
} = {},
|
|
217
|
+
): (input: TInput, init?: RequestInit) => Promise<Response> {
|
|
218
|
+
return async (input: TInput, init?: RequestInit) => {
|
|
219
|
+
const response = await pinnedFetch(input, init, settings, {
|
|
220
|
+
fetchImpl: fetchImpl as FetchLike,
|
|
221
|
+
...(options.dnsLookup ? { dnsLookup: options.dnsLookup } : {}),
|
|
222
|
+
label: "MCP endpoint",
|
|
223
|
+
requireHttpsOutsideLocalTest: options.requireHttpsOutsideLocalTest ?? true,
|
|
224
|
+
});
|
|
225
|
+
return boundMcpResponseBody(response, options.maxResponseBytes ?? MCP_MAX_RESPONSE_BYTES);
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function boundMcpResponseBody(response: Response, maxBytes: number): Response {
|
|
230
|
+
const declaredLength = Number(response.headers.get("content-length"));
|
|
231
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
232
|
+
void response.body?.cancel().catch(() => undefined);
|
|
233
|
+
throw new McpPayloadTooLargeError("MCP response", declaredLength, maxBytes);
|
|
234
|
+
}
|
|
235
|
+
if (!response.body) {
|
|
236
|
+
return response;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const reader = response.body.getReader();
|
|
240
|
+
let receivedBytes = 0;
|
|
241
|
+
const body = new ReadableStream<Uint8Array>({
|
|
242
|
+
async pull(controller) {
|
|
243
|
+
try {
|
|
244
|
+
const chunk = await reader.read();
|
|
245
|
+
if (chunk.done) {
|
|
246
|
+
controller.close();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
receivedBytes += chunk.value.byteLength;
|
|
250
|
+
if (receivedBytes > maxBytes) {
|
|
251
|
+
await reader.cancel().catch(() => undefined);
|
|
252
|
+
controller.error(new McpPayloadTooLargeError("MCP response", receivedBytes, maxBytes));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
controller.enqueue(chunk.value);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
controller.error(error);
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
async cancel(reason) {
|
|
261
|
+
await reader.cancel(reason).catch(() => undefined);
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
const bounded = new Response(body, {
|
|
265
|
+
status: response.status,
|
|
266
|
+
statusText: response.statusText,
|
|
267
|
+
headers: response.headers,
|
|
268
|
+
});
|
|
269
|
+
Object.defineProperties(bounded, {
|
|
270
|
+
redirected: { value: response.redirected },
|
|
271
|
+
type: { value: response.type },
|
|
272
|
+
url: { value: response.url },
|
|
273
|
+
});
|
|
274
|
+
return bounded;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export async function cancelMcpResponseBody(response: Response): Promise<void> {
|
|
278
|
+
await response.body?.cancel().catch(() => undefined);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Read and rebuild an inbound MCP request before the SDK parses it. */
|
|
282
|
+
export async function boundedMcpRequest(
|
|
283
|
+
request: Request,
|
|
284
|
+
maxBytes = MCP_MAX_INBOUND_REQUEST_BYTES,
|
|
285
|
+
): Promise<Request> {
|
|
286
|
+
const declared = request.headers.get("content-length");
|
|
287
|
+
if (declared !== null) {
|
|
288
|
+
const normalized = declared.trim();
|
|
289
|
+
if (!/^\d+$/.test(normalized)) {
|
|
290
|
+
await request.body?.cancel().catch(() => undefined);
|
|
291
|
+
throw new McpPayloadTooLargeError("MCP request", 0, maxBytes);
|
|
292
|
+
}
|
|
293
|
+
const declaredBytes = Number(normalized);
|
|
294
|
+
if (!Number.isSafeInteger(declaredBytes) || declaredBytes > maxBytes) {
|
|
295
|
+
await request.body?.cancel().catch(() => undefined);
|
|
296
|
+
throw new McpPayloadTooLargeError("MCP request", declaredBytes, maxBytes);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (!request.body) {
|
|
300
|
+
return request;
|
|
301
|
+
}
|
|
302
|
+
const reader = request.body.getReader();
|
|
303
|
+
const chunks: Uint8Array[] = [];
|
|
304
|
+
let receivedBytes = 0;
|
|
305
|
+
try {
|
|
306
|
+
for (;;) {
|
|
307
|
+
const result = await reader.read();
|
|
308
|
+
if (result.done) break;
|
|
309
|
+
receivedBytes += result.value.byteLength;
|
|
310
|
+
if (receivedBytes > maxBytes) {
|
|
311
|
+
await reader.cancel().catch(() => undefined);
|
|
312
|
+
throw new McpPayloadTooLargeError("MCP request", receivedBytes, maxBytes);
|
|
313
|
+
}
|
|
314
|
+
chunks.push(result.value);
|
|
315
|
+
}
|
|
316
|
+
} catch (error) {
|
|
317
|
+
await reader.cancel().catch(() => undefined);
|
|
318
|
+
throw error;
|
|
319
|
+
} finally {
|
|
320
|
+
reader.releaseLock();
|
|
321
|
+
}
|
|
322
|
+
const body = new Uint8Array(receivedBytes);
|
|
323
|
+
let offset = 0;
|
|
324
|
+
for (const chunk of chunks) {
|
|
325
|
+
body.set(chunk, offset);
|
|
326
|
+
offset += chunk.byteLength;
|
|
327
|
+
}
|
|
328
|
+
return new Request(request, { body, headers: request.headers });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export async function assertMcpDestinationAllowed(
|
|
332
|
+
rawUrl: string,
|
|
333
|
+
settings: Pick<Settings, "environment" | "integrationsAllowPrivateNetworkTargets">,
|
|
334
|
+
options: { dnsLookup?: DnsLookup } = {},
|
|
335
|
+
): Promise<void> {
|
|
336
|
+
await resolvePinnedDestination(rawUrl, settings, {
|
|
337
|
+
...(options.dnsLookup ? { dnsLookup: options.dnsLookup } : {}),
|
|
338
|
+
label: "MCP endpoint",
|
|
339
|
+
requireHttpsOutsideLocalTest: true,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export const isNonPublicMcpAddress = isNonPublicAddress;
|
package/src/sandbox/channel-a.ts
CHANGED
|
@@ -58,6 +58,21 @@ import type {
|
|
|
58
58
|
TerminalExecRequest,
|
|
59
59
|
TerminalExecResponse,
|
|
60
60
|
} from "@opengeni/contracts";
|
|
61
|
+
import {
|
|
62
|
+
isExecSessionLostBanner,
|
|
63
|
+
parseExecBannerExitCode,
|
|
64
|
+
parseExecBannerSessionId,
|
|
65
|
+
} from "./exec-banner";
|
|
66
|
+
import {
|
|
67
|
+
createTurnToolCancellationController,
|
|
68
|
+
TurnSandboxCommandCancelledError,
|
|
69
|
+
} from "./turn-tool-cancellation";
|
|
70
|
+
|
|
71
|
+
export {
|
|
72
|
+
isExecSessionLostBanner,
|
|
73
|
+
parseExecBannerExitCode,
|
|
74
|
+
parseExecBannerSessionId,
|
|
75
|
+
} from "./exec-banner";
|
|
61
76
|
|
|
62
77
|
// ── The minimal session surface Channel A consumes (a structural subset of the
|
|
63
78
|
// SDK's SandboxSession, all optional — capability-probed before use). ─────────
|
|
@@ -98,6 +113,21 @@ export type ChannelASession = {
|
|
|
98
113
|
yieldTimeMs?: number;
|
|
99
114
|
maxOutputTokens?: number;
|
|
100
115
|
}): Promise<string>;
|
|
116
|
+
writeStdinForProcessMutation?(args: {
|
|
117
|
+
sessionId: number;
|
|
118
|
+
chars?: string;
|
|
119
|
+
yieldTimeMs?: number;
|
|
120
|
+
maxOutputTokens?: number;
|
|
121
|
+
}): Promise<string>;
|
|
122
|
+
writeStdinForProcessControl?(args: {
|
|
123
|
+
sessionId: number;
|
|
124
|
+
chars?: string;
|
|
125
|
+
yieldTimeMs?: number;
|
|
126
|
+
maxOutputTokens?: number;
|
|
127
|
+
}): Promise<string>;
|
|
128
|
+
cancelExecCommand?(opId: string): Promise<boolean>;
|
|
129
|
+
hasRetainedProcess?(providerSessionId: number): boolean;
|
|
130
|
+
execCommandForProcessControl?(providerSessionId: number, args: ChannelAExecArgs): Promise<string>;
|
|
101
131
|
createEditor?(runAs?: string): ChannelAEditor;
|
|
102
132
|
supportsPty?(): boolean;
|
|
103
133
|
};
|
|
@@ -1130,17 +1160,49 @@ export class SandboxChannelAService {
|
|
|
1130
1160
|
|
|
1131
1161
|
// ════════════════════════ Terminal exec + PTY (A2) ════════════════════════
|
|
1132
1162
|
|
|
1133
|
-
/** Run a bounded command
|
|
1134
|
-
*
|
|
1135
|
-
*
|
|
1136
|
-
* the agent firehose so other viewers see it. */
|
|
1163
|
+
/** Run a bounded command to physical completion and return buffered output.
|
|
1164
|
+
* A deadline cancels the exact provider operation, proves its process group
|
|
1165
|
+
* absent, and settles any retained-process admission before returning 124. */
|
|
1137
1166
|
async terminalExec(req: TerminalExecRequest): Promise<TerminalExecResponse> {
|
|
1138
|
-
const
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1167
|
+
const abort = new AbortController();
|
|
1168
|
+
const controller = createTurnToolCancellationController(abort.signal);
|
|
1169
|
+
const timeoutReason = new Error(`Terminal command exceeded ${req.timeoutMs}ms`);
|
|
1170
|
+
let timedOut = false;
|
|
1171
|
+
const timer = setTimeout(() => {
|
|
1172
|
+
timedOut = true;
|
|
1173
|
+
abort.abort(timeoutReason);
|
|
1174
|
+
}, req.timeoutMs);
|
|
1175
|
+
const terminalWorkdir = this.terminalWorkdir(req.cwd);
|
|
1176
|
+
let r: Awaited<ReturnType<typeof controller.runSandboxCommandStructured>>;
|
|
1177
|
+
try {
|
|
1178
|
+
r = await controller.runSandboxCommandStructured(this.session, {
|
|
1179
|
+
cmd: req.command,
|
|
1180
|
+
...(terminalWorkdir ? { workdir: terminalWorkdir } : {}),
|
|
1181
|
+
...(this.runAs ? { runAs: this.runAs } : {}),
|
|
1182
|
+
yieldTimeMs: req.timeoutMs,
|
|
1183
|
+
});
|
|
1184
|
+
if (timedOut) {
|
|
1185
|
+
await controller.waitForQuiescence();
|
|
1186
|
+
r = {
|
|
1187
|
+
stdout: "",
|
|
1188
|
+
stderr: timeoutReason.message,
|
|
1189
|
+
exitCode: 124,
|
|
1190
|
+
wallTimeSeconds: req.timeoutMs / 1_000,
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
} catch (error) {
|
|
1194
|
+
controller.cancel(error);
|
|
1195
|
+
await controller.waitForQuiescence();
|
|
1196
|
+
if (!timedOut && !(error instanceof TurnSandboxCommandCancelledError)) throw error;
|
|
1197
|
+
r = {
|
|
1198
|
+
stdout: "",
|
|
1199
|
+
stderr: timeoutReason.message,
|
|
1200
|
+
exitCode: 124,
|
|
1201
|
+
wallTimeSeconds: req.timeoutMs / 1_000,
|
|
1202
|
+
};
|
|
1203
|
+
} finally {
|
|
1204
|
+
clearTimeout(timer);
|
|
1205
|
+
}
|
|
1144
1206
|
if (req.emitStream && (r.stdout || r.stderr)) {
|
|
1145
1207
|
const events: { type: SessionEventType; payload: unknown }[] = [];
|
|
1146
1208
|
const commandId = crypto.randomUUID();
|
|
@@ -1160,7 +1222,7 @@ export class SandboxChannelAService {
|
|
|
1160
1222
|
stdout: r.stdout,
|
|
1161
1223
|
stderr: r.stderr,
|
|
1162
1224
|
exitCode: r.exitCode,
|
|
1163
|
-
running,
|
|
1225
|
+
running: false,
|
|
1164
1226
|
wallTimeSeconds: r.wallTimeSeconds,
|
|
1165
1227
|
};
|
|
1166
1228
|
}
|
|
@@ -1200,10 +1262,13 @@ export class SandboxChannelAService {
|
|
|
1200
1262
|
* it as terminal.pty.output.delta). Throws ChannelAUnsupportedError when the
|
|
1201
1263
|
* backend has no writeStdin. */
|
|
1202
1264
|
async ptyWrite(_req: PtyWriteRequest, execSessionId: number, data: string): Promise<string> {
|
|
1203
|
-
|
|
1265
|
+
const write =
|
|
1266
|
+
this.session.writeStdinForProcessMutation?.bind(this.session) ??
|
|
1267
|
+
this.session.writeStdin?.bind(this.session);
|
|
1268
|
+
if (!write) {
|
|
1204
1269
|
throw new ChannelAUnsupportedError("interactive terminal unsupported on this backend");
|
|
1205
1270
|
}
|
|
1206
|
-
const out = await
|
|
1271
|
+
const out = await write({
|
|
1207
1272
|
sessionId: execSessionId,
|
|
1208
1273
|
chars: data,
|
|
1209
1274
|
yieldTimeMs: 250,
|
|
@@ -1225,25 +1290,27 @@ export class SandboxChannelAService {
|
|
|
1225
1290
|
/** Resize an open PTY (SIGWINCH via stty against the exec-session). The SDK has
|
|
1226
1291
|
* no resize method; stty in the same tty session updates the geometry. */
|
|
1227
1292
|
async ptyResize(req: PtyResizeRequest, execSessionId: number): Promise<void> {
|
|
1228
|
-
|
|
1293
|
+
const write =
|
|
1294
|
+
this.session.writeStdinForProcessControl?.bind(this.session) ??
|
|
1295
|
+
this.session.writeStdin?.bind(this.session);
|
|
1296
|
+
if (!write) return;
|
|
1229
1297
|
// Send a stty in-band on the same pty session.
|
|
1230
|
-
await
|
|
1298
|
+
await write({
|
|
1231
1299
|
sessionId: execSessionId,
|
|
1232
1300
|
chars: `stty cols ${req.cols} rows ${req.rows}\n`,
|
|
1233
1301
|
yieldTimeMs: 50,
|
|
1234
1302
|
});
|
|
1235
1303
|
}
|
|
1236
1304
|
|
|
1237
|
-
/**
|
|
1238
|
-
*
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
}
|
|
1305
|
+
/** Ask an open PTY to exit and return the exact provider banner. The routing
|
|
1306
|
+
* session settles durable process authority only when that banner proves this
|
|
1307
|
+
* locator exited or was lost; callers must keep metadata open otherwise. */
|
|
1308
|
+
async ptyClose(_req: PtyCloseRequest, execSessionId: number | null): Promise<string> {
|
|
1309
|
+
const write =
|
|
1310
|
+
this.session.writeStdinForProcessControl?.bind(this.session) ??
|
|
1311
|
+
this.session.writeStdin?.bind(this.session);
|
|
1312
|
+
if (execSessionId === null || !write) return "";
|
|
1313
|
+
return await write({ sessionId: execSessionId, chars: "", yieldTimeMs: 250 }); // EOF
|
|
1247
1314
|
}
|
|
1248
1315
|
|
|
1249
1316
|
// ──────────────────────────── helpers ──────────────────────────────────────
|
|
@@ -1432,52 +1499,6 @@ function isDefinitePathNotFoundError(error: unknown): boolean {
|
|
|
1432
1499
|
);
|
|
1433
1500
|
}
|
|
1434
1501
|
|
|
1435
|
-
// Detect the Modal "the exec-session you're writing to no longer exists" banner.
|
|
1436
|
-
// writeStdin reports a vanished session as a non-throwing string of the shape
|
|
1437
|
-
// `write_stdin failed: session not found: <N>` (it does NOT raise). This is a
|
|
1438
|
-
// hard cancellation-fence fact, so classify only the complete known banner (or
|
|
1439
|
-
// its bare provider fact) carrying the exact tracked numeric id. ID-less,
|
|
1440
|
-
// malformed, mismatched, or embellished/ambiguous text must remain fail-closed.
|
|
1441
|
-
export function isExecSessionLostBanner(out: string, execSessionId: number): boolean {
|
|
1442
|
-
if (!out || !Number.isSafeInteger(execSessionId) || execSessionId < 0) return false;
|
|
1443
|
-
const match = out.match(/^(?:write_stdin failed: )?session not found: (\d+)$/);
|
|
1444
|
-
// JavaScript's `$` also matches immediately before one final line terminator.
|
|
1445
|
-
// Requiring the regex match to consume the entire string keeps even that
|
|
1446
|
-
// otherwise-special case fail-closed.
|
|
1447
|
-
if (!match || match[0] !== out) return false;
|
|
1448
|
-
// String equality is intentional: parseInt would normalize malformed facts
|
|
1449
|
-
// such as `01` and can round an out-of-range integer into another identity.
|
|
1450
|
-
return match[1] === String(execSessionId);
|
|
1451
|
-
}
|
|
1452
|
-
|
|
1453
|
-
// Recover the numeric exec-session id the SDK embeds in a formatExecResponse
|
|
1454
|
-
// banner for a STILL-RUNNING process (`Process running with session ID <N>`).
|
|
1455
|
-
// A finished command emits `Process exited with code <N>` instead (no session
|
|
1456
|
-
// id) — that yields null. Only the banner region (before the `Output:` marker)
|
|
1457
|
-
// is scanned so a session-id-looking line in the command's own output can't
|
|
1458
|
-
// spoof it. This is what makes the interactive PTY work on backends whose only
|
|
1459
|
-
// exec surface is execCommand (Modal): without it ptyOpen reports execSessionId
|
|
1460
|
-
// = null and every pty/write 409s ("interactive terminal unsupported").
|
|
1461
|
-
export function parseExecBannerSessionId(raw: string): number | null {
|
|
1462
|
-
const outputIdx = raw.indexOf("\nOutput:\n");
|
|
1463
|
-
const banner = outputIdx >= 0 ? raw.slice(0, outputIdx) : raw.startsWith("Output:\n") ? "" : raw;
|
|
1464
|
-
const match = banner.match(/Process running with session ID (\d+)/);
|
|
1465
|
-
if (!match) return null;
|
|
1466
|
-
const n = Number.parseInt(match[1]!, 10);
|
|
1467
|
-
return Number.isFinite(n) ? n : null;
|
|
1468
|
-
}
|
|
1469
|
-
|
|
1470
|
-
/** Recover a completed execCommand fallback's exit status from its banner.
|
|
1471
|
-
* Only the banner region is inspected, so command output cannot spoof success. */
|
|
1472
|
-
export function parseExecBannerExitCode(raw: string): number | null {
|
|
1473
|
-
const outputIdx = raw.indexOf("\nOutput:\n");
|
|
1474
|
-
const banner = outputIdx >= 0 ? raw.slice(0, outputIdx) : raw.startsWith("Output:\n") ? "" : raw;
|
|
1475
|
-
const match = banner.match(/Process exited with code (-?\d+)/);
|
|
1476
|
-
if (!match) return null;
|
|
1477
|
-
const exitCode = Number.parseInt(match[1]!, 10);
|
|
1478
|
-
return Number.isSafeInteger(exitCode) ? exitCode : null;
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
1502
|
function sniffBinary(bytes: Buffer): boolean {
|
|
1482
1503
|
const n = Math.min(bytes.byteLength, 8192);
|
|
1483
1504
|
for (let i = 0; i < n; i++) if (bytes[i] === 0) return true;
|