@inflexa-ai/harness 0.27.0 → 0.28.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/dist/sandbox/await-exec.js +9 -5
- package/dist/sandbox/create-sandbox.d.ts +7 -0
- package/dist/sandbox/create-sandbox.js +11 -2
- package/dist/sandbox/digest.d.ts +14 -0
- package/dist/sandbox/digest.js +17 -0
- package/dist/sandbox/hmac.d.ts +10 -2
- package/dist/sandbox/hmac.js +8 -3
- package/dist/sandbox/submit-exec.d.ts +5 -0
- package/dist/sandbox/submit-exec.js +10 -1
- package/dist/sandbox/types.d.ts +28 -6
- package/dist/sandbox/types.js +25 -6
- package/dist/tools/workspace/result-bounds.d.ts +20 -1
- package/dist/tools/workspace/result-bounds.js +67 -8
- package/package.json +1 -1
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
* varied between replays would desynchronise the recorded function-ID sequence.
|
|
56
56
|
*/
|
|
57
57
|
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
58
|
+
import { digestBody } from "./digest.js";
|
|
58
59
|
import { signCallback, verifyCallback } from "./hmac.js";
|
|
59
60
|
import { createEscalationPolicy, probeLiveness, syntheticFailureReason, syntheticFailureResult } from "./liveness.js";
|
|
60
61
|
import { ExecResultSchema, isDoneMarker, isSyntheticFailure, PollResponseSchema, } from "./types.js";
|
|
@@ -233,13 +234,16 @@ export async function awaitExecCallback(ref, execId, emit, deadlineMs, options =
|
|
|
233
234
|
throw new HardCancelError(execId, "synthetic-without-marker");
|
|
234
235
|
}
|
|
235
236
|
// Verify against the exact bytes sandbox-server signed. The route
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
237
|
+
// reduces them to `payloadDigest` at ingress — the signature covers that
|
|
238
|
+
// digest, so it is all verification needs and the message never carries
|
|
239
|
+
// a second copy of the body. `payloadRaw` is the superseded form, and
|
|
240
|
+
// the JSON.stringify fallback covers messages persisted before either
|
|
241
|
+
// field existed — re-serializing diverges from Go's HTML-escaping
|
|
242
|
+
// encoder, so it is a last resort, not a normal path.
|
|
243
|
+
const bodyDigest = msg.payloadDigest ?? digestBody(Buffer.from(msg.payloadRaw ?? JSON.stringify(msg.payload), "utf8"));
|
|
240
244
|
const result = verifyCallback({
|
|
241
245
|
execId,
|
|
242
|
-
|
|
246
|
+
bodyDigest,
|
|
243
247
|
signature: msg.signature,
|
|
244
248
|
timestamp: msg.timestamp,
|
|
245
249
|
secret: callbackSecret,
|
|
@@ -45,6 +45,13 @@ export interface CreateSandboxClientConfig {
|
|
|
45
45
|
transport?: SandboxTransport;
|
|
46
46
|
/** Default sandbox-base image when the workflow doesn't override per-step. */
|
|
47
47
|
image: string;
|
|
48
|
+
/**
|
|
49
|
+
* Per-stream retention budget sent with every exec, so the sandbox drops
|
|
50
|
+
* output past it instead of shipping it. Defaults to `EXEC_STREAM_BYTE_CAP`,
|
|
51
|
+
* the same value the host truncates to on receipt — sending more than the
|
|
52
|
+
* host will keep buys nothing.
|
|
53
|
+
*/
|
|
54
|
+
execStreamByteCap?: number;
|
|
48
55
|
/** Cluster resource ceilings; every sandbox request is clamped to these. */
|
|
49
56
|
resourceLimits: ResourceLimits;
|
|
50
57
|
/** K8s namespace; only used by the k8s backend. */
|
|
@@ -18,6 +18,7 @@ import { tailWritePrefix } from "../workspace/paths.js";
|
|
|
18
18
|
import { tryMutation } from "../lib/db-result.js";
|
|
19
19
|
import { unwrapOrThrow } from "../lib/result.js";
|
|
20
20
|
import { clearSandboxRef, setSandboxRef } from "../state/index.js";
|
|
21
|
+
import { capExecStreams, EXEC_STREAM_BYTE_CAP } from "../tools/workspace/result-bounds.js";
|
|
21
22
|
import { awaitExec } from "./await-exec.js";
|
|
22
23
|
import { createDockerSandboxOps } from "./docker-client.js";
|
|
23
24
|
import { createK8sSandboxOps } from "./k8s-client.js";
|
|
@@ -145,8 +146,16 @@ export function createSandboxClient(config) {
|
|
|
145
146
|
const resources = clampResources(meta.resources, config.resourceLimits);
|
|
146
147
|
return unwrapOrThrow(await ops.createSandbox({ ...meta, resources }, identity));
|
|
147
148
|
},
|
|
148
|
-
submitExec: async (ref, body) => submitExec(ref, body,
|
|
149
|
-
|
|
149
|
+
submitExec: async (ref, body) => submitExec(ref, body, {
|
|
150
|
+
...config.submitDeps,
|
|
151
|
+
execStreamByteCap: config.submitDeps?.execStreamByteCap ?? config.execStreamByteCap,
|
|
152
|
+
}),
|
|
153
|
+
// The one place an ExecResult crosses from the wire into the process, so
|
|
154
|
+
// the cap lands here rather than at each consumer. Every downstream use —
|
|
155
|
+
// tool results, workflow return values, durable step outputs — is bounded
|
|
156
|
+
// by construction, including against a sandbox image that predates the
|
|
157
|
+
// retention budget and still returns whole streams.
|
|
158
|
+
awaitExec: async (ref, execId, emit, deadline) => capExecStreams(await awaitExec(ref, execId, emit, deadline, composeAwaitOptions(config.awaitOptions, transport, isAlive)), config.execStreamByteCap ?? EXEC_STREAM_BYTE_CAP),
|
|
150
159
|
isAlive,
|
|
151
160
|
isAliveById: async (sandboxId) => unwrapOrThrow(await ops.isAliveById(sandboxId)),
|
|
152
161
|
teardown,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Body digest for sandbox callbacks.
|
|
3
|
+
*
|
|
4
|
+
* The callback signature is taken over the hex SHA-256 of the POSTed body
|
|
5
|
+
* rather than over the body itself, so the digest is a value the ingress can
|
|
6
|
+
* compute and forward while the bytes themselves are dropped — verification
|
|
7
|
+
* downstream needs nothing more.
|
|
8
|
+
*
|
|
9
|
+
* This lives apart from `hmac.ts` on purpose. Hashing carries no secret and
|
|
10
|
+
* decides nothing, so the route that only forwards can depend on it without
|
|
11
|
+
* taking on the verification machinery it is required to stay clear of.
|
|
12
|
+
*/
|
|
13
|
+
/** Hex SHA-256 of the POSTed bytes — the value the callback signature covers. */
|
|
14
|
+
export declare function digestBody(input: Buffer | string): string;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Body digest for sandbox callbacks.
|
|
3
|
+
*
|
|
4
|
+
* The callback signature is taken over the hex SHA-256 of the POSTed body
|
|
5
|
+
* rather than over the body itself, so the digest is a value the ingress can
|
|
6
|
+
* compute and forward while the bytes themselves are dropped — verification
|
|
7
|
+
* downstream needs nothing more.
|
|
8
|
+
*
|
|
9
|
+
* This lives apart from `hmac.ts` on purpose. Hashing carries no secret and
|
|
10
|
+
* decides nothing, so the route that only forwards can depend on it without
|
|
11
|
+
* taking on the verification machinery it is required to stay clear of.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
/** Hex SHA-256 of the POSTed bytes — the value the callback signature covers. */
|
|
15
|
+
export function digestBody(input) {
|
|
16
|
+
return createHash("sha256").update(input).digest("hex");
|
|
17
|
+
}
|
package/dist/sandbox/hmac.d.ts
CHANGED
|
@@ -16,8 +16,16 @@
|
|
|
16
16
|
*/
|
|
17
17
|
export interface VerifyCallbackInput {
|
|
18
18
|
execId: string;
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* The bytes the sandbox-server POSTed. Supply this or `bodyDigest`.
|
|
21
|
+
*
|
|
22
|
+
* The signature covers the hex SHA-256 of the body, never the body itself,
|
|
23
|
+
* so a caller that has already hashed the bytes can verify from the digest
|
|
24
|
+
* alone — which is what keeps a large payload from being carried twice.
|
|
25
|
+
*/
|
|
26
|
+
body?: Buffer | string;
|
|
27
|
+
/** Hex SHA-256 of the POSTed bytes. Takes precedence over `body`. */
|
|
28
|
+
bodyDigest?: string;
|
|
21
29
|
/** `X-Sandbox-Signature` value (lowercase hex). */
|
|
22
30
|
signature: string | null;
|
|
23
31
|
/** `X-Sandbox-Timestamp` value (unix seconds). */
|
package/dist/sandbox/hmac.js
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
* NetworkPolicy isolation a forged event implies a bug or a breach,
|
|
15
15
|
* neither of which should keep a 3-hour sandbox burning.
|
|
16
16
|
*/
|
|
17
|
-
import {
|
|
17
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
18
|
+
import { digestBody } from "./digest.js";
|
|
18
19
|
/**
|
|
19
20
|
* Decode the per-sandbox secret. sandbox-server accepts both raw UTF-8
|
|
20
21
|
* and `base64:` prefixed values (change 4 spec); we mirror the same
|
|
@@ -28,7 +29,7 @@ function decodeSecret(secret) {
|
|
|
28
29
|
return Buffer.from(secret, "utf8");
|
|
29
30
|
}
|
|
30
31
|
function sha256Hex(input) {
|
|
31
|
-
return
|
|
32
|
+
return digestBody(input);
|
|
32
33
|
}
|
|
33
34
|
/**
|
|
34
35
|
* Recompute the HMAC and compare in constant time, then check the
|
|
@@ -43,8 +44,12 @@ export function verifyCallback(input) {
|
|
|
43
44
|
if (input.signature === null || input.timestamp === null) {
|
|
44
45
|
return { valid: false, reason: "missing" };
|
|
45
46
|
}
|
|
47
|
+
const digest = input.bodyDigest ?? (input.body !== undefined ? sha256Hex(input.body) : undefined);
|
|
48
|
+
if (digest === undefined) {
|
|
49
|
+
return { valid: false, reason: "missing" };
|
|
50
|
+
}
|
|
46
51
|
const secretBytes = decodeSecret(input.secret);
|
|
47
|
-
const message = `${input.execId}:${input.timestamp}:${
|
|
52
|
+
const message = `${input.execId}:${input.timestamp}:${digest}`;
|
|
48
53
|
const expected = createHmac("sha256", secretBytes).update(message).digest("hex");
|
|
49
54
|
const provided = input.signature;
|
|
50
55
|
if (provided.length !== expected.length) {
|
|
@@ -24,6 +24,11 @@ export interface SubmitExecDeps {
|
|
|
24
24
|
runStep?: <T>(fn: () => Promise<T>, config: {
|
|
25
25
|
name: string;
|
|
26
26
|
}) => Promise<T>;
|
|
27
|
+
/**
|
|
28
|
+
* Per-stream retention budget to send when the body does not carry one.
|
|
29
|
+
* Defaults to `EXEC_STREAM_BYTE_CAP`.
|
|
30
|
+
*/
|
|
31
|
+
execStreamByteCap?: number;
|
|
27
32
|
}
|
|
28
33
|
/**
|
|
29
34
|
* Submit an exec as a DBOS step. The step name is unique per execId, so
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* untouched.
|
|
18
18
|
*/
|
|
19
19
|
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
20
|
+
import { EXEC_STREAM_BYTE_CAP } from "../tools/workspace/result-bounds.js";
|
|
20
21
|
import { signCallback } from "./hmac.js";
|
|
21
22
|
const SUBMIT_HTTP_TIMEOUT_MS = 30_000;
|
|
22
23
|
async function postExec(fetchImpl, ref, body) {
|
|
@@ -62,7 +63,15 @@ async function postExec(fetchImpl, ref, body) {
|
|
|
62
63
|
export async function submitExec(ref, body, deps = {}) {
|
|
63
64
|
const fetchImpl = deps.fetch ?? fetch;
|
|
64
65
|
const runStep = deps.runStep ?? ((fn, c) => DBOS.runStep(fn, c));
|
|
65
|
-
|
|
66
|
+
// Every exec in the process funnels through here, so this is where the
|
|
67
|
+
// retention budget is attached rather than at each of the callers that build
|
|
68
|
+
// a body. Applied before signing, since the signature covers these bytes.
|
|
69
|
+
const capped = {
|
|
70
|
+
...body,
|
|
71
|
+
stdoutByteCap: body.stdoutByteCap ?? deps.execStreamByteCap ?? EXEC_STREAM_BYTE_CAP,
|
|
72
|
+
stderrByteCap: body.stderrByteCap ?? deps.execStreamByteCap ?? EXEC_STREAM_BYTE_CAP,
|
|
73
|
+
};
|
|
74
|
+
await runStep(() => postExec(fetchImpl, ref, capped), {
|
|
66
75
|
name: `sandbox.submit-exec.${body.execId}`,
|
|
67
76
|
});
|
|
68
77
|
}
|
package/dist/sandbox/types.d.ts
CHANGED
|
@@ -103,6 +103,10 @@ export declare const ExecResultSchema: z.ZodObject<{
|
|
|
103
103
|
stderr: z.ZodDefault<z.ZodString>;
|
|
104
104
|
durationMs: z.ZodNullable<z.ZodNumber>;
|
|
105
105
|
timedOut: z.ZodDefault<z.ZodBoolean>;
|
|
106
|
+
stdoutTruncated: z.ZodOptional<z.ZodBoolean>;
|
|
107
|
+
stderrTruncated: z.ZodOptional<z.ZodBoolean>;
|
|
108
|
+
stdoutTotalBytes: z.ZodOptional<z.ZodNumber>;
|
|
109
|
+
stderrTotalBytes: z.ZodOptional<z.ZodNumber>;
|
|
106
110
|
syntheticFailure: z.ZodOptional<z.ZodObject<{
|
|
107
111
|
reason: z.ZodString;
|
|
108
112
|
}, z.core.$strip>>;
|
|
@@ -128,15 +132,21 @@ export type ExecResult = z.infer<typeof ExecResultSchema>;
|
|
|
128
132
|
* a non-null `signature` and `timestamp`; the in-process watchdog uses a
|
|
129
133
|
* `null` signature + a `synthetic-failure` payload (see `await-exec.ts`).
|
|
130
134
|
*
|
|
131
|
-
* `
|
|
132
|
-
* HMAC signs the
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
135
|
+
* `payloadDigest` is the hex SHA-256 of the exact bytes sandbox-server POSTed.
|
|
136
|
+
* The HMAC signs that digest rather than the body itself, so the digest is all
|
|
137
|
+
* verification needs — and carrying it instead of the bytes keeps the message
|
|
138
|
+
* from holding a second copy of a payload that can be arbitrarily large.
|
|
139
|
+
* Re-serializing the parsed payload would not do: Go's `encoding/json`
|
|
140
|
+
* HTML-escapes `<`, `>`, `&` by default, so a JS re-serialization diverges for
|
|
141
|
+
* any output containing those characters (common in bioinformatics: FASTA
|
|
142
|
+
* headers, shell stderr, command pipelines).
|
|
143
|
+
*
|
|
144
|
+
* `payloadRaw` is the superseded form, kept optional so messages already in
|
|
145
|
+
* flight or persisted still verify.
|
|
137
146
|
*/
|
|
138
147
|
export declare const ExecEventMessageSchema: z.ZodObject<{
|
|
139
148
|
payload: z.ZodUnknown;
|
|
149
|
+
payloadDigest: z.ZodOptional<z.ZodString>;
|
|
140
150
|
payloadRaw: z.ZodOptional<z.ZodString>;
|
|
141
151
|
signature: z.ZodNullable<z.ZodString>;
|
|
142
152
|
timestamp: z.ZodNullable<z.ZodNumber>;
|
|
@@ -175,6 +185,10 @@ export declare const PollResponseSchema: z.ZodObject<{
|
|
|
175
185
|
stderr: z.ZodDefault<z.ZodString>;
|
|
176
186
|
durationMs: z.ZodNullable<z.ZodNumber>;
|
|
177
187
|
timedOut: z.ZodDefault<z.ZodBoolean>;
|
|
188
|
+
stdoutTruncated: z.ZodOptional<z.ZodBoolean>;
|
|
189
|
+
stderrTruncated: z.ZodOptional<z.ZodBoolean>;
|
|
190
|
+
stdoutTotalBytes: z.ZodOptional<z.ZodNumber>;
|
|
191
|
+
stderrTotalBytes: z.ZodOptional<z.ZodNumber>;
|
|
178
192
|
syntheticFailure: z.ZodOptional<z.ZodObject<{
|
|
179
193
|
reason: z.ZodString;
|
|
180
194
|
}, z.core.$strip>>;
|
|
@@ -220,6 +234,14 @@ export interface SubmitExecBody {
|
|
|
220
234
|
cwd?: string;
|
|
221
235
|
env?: Record<string, string>;
|
|
222
236
|
timeoutSeconds?: number;
|
|
237
|
+
/**
|
|
238
|
+
* Per-stream retention budget for the sandbox. Omitting them leaves the
|
|
239
|
+
* sandbox unbounded, which is how a server that pre-dates the fields
|
|
240
|
+
* behaves; the host caps on receipt regardless, so the difference is how
|
|
241
|
+
* many bytes cross the wire, not what the caller ends up with.
|
|
242
|
+
*/
|
|
243
|
+
stdoutByteCap?: number;
|
|
244
|
+
stderrByteCap?: number;
|
|
223
245
|
}
|
|
224
246
|
/** Step-meta passed to `createSandbox` — what the registry row needs. */
|
|
225
247
|
export interface CreateSandboxMeta {
|
package/dist/sandbox/types.js
CHANGED
|
@@ -79,6 +79,19 @@ export const ExecResultSchema = z.object({
|
|
|
79
79
|
stderr: z.string().default(""),
|
|
80
80
|
durationMs: z.number().nullable(),
|
|
81
81
|
timedOut: z.boolean().default(false),
|
|
82
|
+
/**
|
|
83
|
+
* Set when sandbox-server dropped output past the per-stream cap it was
|
|
84
|
+
* given. The total is what the command actually produced, which is what
|
|
85
|
+
* separates "printed nothing" from "printed more than we kept" — without it
|
|
86
|
+
* a capped stream and an empty one are indistinguishable downstream.
|
|
87
|
+
*
|
|
88
|
+
* Optional because a sandbox image that pre-dates the cap omits both, and
|
|
89
|
+
* because the watchdog's synthetic failures carry neither.
|
|
90
|
+
*/
|
|
91
|
+
stdoutTruncated: z.boolean().optional(),
|
|
92
|
+
stderrTruncated: z.boolean().optional(),
|
|
93
|
+
stdoutTotalBytes: z.number().int().nonnegative().optional(),
|
|
94
|
+
stderrTotalBytes: z.number().int().nonnegative().optional(),
|
|
82
95
|
/** Set when the watchdog synthesises a completion for a dead sandbox. */
|
|
83
96
|
syntheticFailure: z
|
|
84
97
|
.object({
|
|
@@ -97,15 +110,21 @@ export const ExecResultSchema = z.object({
|
|
|
97
110
|
* a non-null `signature` and `timestamp`; the in-process watchdog uses a
|
|
98
111
|
* `null` signature + a `synthetic-failure` payload (see `await-exec.ts`).
|
|
99
112
|
*
|
|
100
|
-
* `
|
|
101
|
-
* HMAC signs the
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
113
|
+
* `payloadDigest` is the hex SHA-256 of the exact bytes sandbox-server POSTed.
|
|
114
|
+
* The HMAC signs that digest rather than the body itself, so the digest is all
|
|
115
|
+
* verification needs — and carrying it instead of the bytes keeps the message
|
|
116
|
+
* from holding a second copy of a payload that can be arbitrarily large.
|
|
117
|
+
* Re-serializing the parsed payload would not do: Go's `encoding/json`
|
|
118
|
+
* HTML-escapes `<`, `>`, `&` by default, so a JS re-serialization diverges for
|
|
119
|
+
* any output containing those characters (common in bioinformatics: FASTA
|
|
120
|
+
* headers, shell stderr, command pipelines).
|
|
121
|
+
*
|
|
122
|
+
* `payloadRaw` is the superseded form, kept optional so messages already in
|
|
123
|
+
* flight or persisted still verify.
|
|
106
124
|
*/
|
|
107
125
|
export const ExecEventMessageSchema = z.object({
|
|
108
126
|
payload: z.unknown(),
|
|
127
|
+
payloadDigest: z.string().optional(),
|
|
109
128
|
payloadRaw: z.string().optional(),
|
|
110
129
|
signature: z.string().nullable(),
|
|
111
130
|
timestamp: z.number().int().nullable(),
|
|
@@ -8,9 +8,14 @@
|
|
|
8
8
|
* The cap is well below the loop's overall result budget so a chatty
|
|
9
9
|
* command cannot blow the context window even when stdout AND stderr both
|
|
10
10
|
* cap out at the limit.
|
|
11
|
+
*
|
|
12
|
+
* The same value is sent to the sandbox as a retention budget, so the bytes are
|
|
13
|
+
* dropped at the producer and never cross the wire. Capping here as well is not
|
|
14
|
+
* redundant: a sandbox image that pre-dates the budget returns the whole stream,
|
|
15
|
+
* and this is the boundary that keeps it out of the process either way.
|
|
11
16
|
*/
|
|
12
17
|
import type { ExecResult } from "../../sandbox/types.js";
|
|
13
|
-
/** Per-stream cap.
|
|
18
|
+
/** Per-stream cap. 32 KiB — roomy for real output, well under the loop result budget. */
|
|
14
19
|
export declare const EXEC_STREAM_BYTE_CAP: number;
|
|
15
20
|
export interface BoundedStream {
|
|
16
21
|
readonly content: string;
|
|
@@ -33,10 +38,24 @@ export interface BoundedExecResult {
|
|
|
33
38
|
readonly reason: string;
|
|
34
39
|
};
|
|
35
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Bound an `ExecResult`'s streams while keeping its shape.
|
|
43
|
+
*
|
|
44
|
+
* `boundExecResult` produces the tool-facing form; this is for the boundaries
|
|
45
|
+
* that have to forward an `ExecResult` onward — notably a workflow return value,
|
|
46
|
+
* which DBOS serializes into its durable step output. Returns the input
|
|
47
|
+
* unchanged when nothing needed cutting, so the common path allocates nothing.
|
|
48
|
+
*/
|
|
49
|
+
export declare function capExecStreams(result: ExecResult, cap?: number): ExecResult;
|
|
36
50
|
/**
|
|
37
51
|
* Apply per-stream truncation to an `ExecResult`. `exitCode`, `durationMs`,
|
|
38
52
|
* `timedOut`, and `syntheticFailure` pass through unchanged regardless of
|
|
39
53
|
* stream truncation — the discriminants the loop and the model rely on are
|
|
40
54
|
* preserved.
|
|
55
|
+
*
|
|
56
|
+
* When the sandbox already truncated at the producer, what arrives here is the
|
|
57
|
+
* retained slice, not the stream — so its length is the wrong number to report.
|
|
58
|
+
* The sandbox's own totals win where present, and truncation is the union of the
|
|
59
|
+
* two cuts: either side having dropped bytes makes the result truncated.
|
|
41
60
|
*/
|
|
42
61
|
export declare function boundExecResult(result: ExecResult, cap?: number): BoundedExecResult;
|
|
@@ -8,16 +8,46 @@
|
|
|
8
8
|
* The cap is well below the loop's overall result budget so a chatty
|
|
9
9
|
* command cannot blow the context window even when stdout AND stderr both
|
|
10
10
|
* cap out at the limit.
|
|
11
|
+
*
|
|
12
|
+
* The same value is sent to the sandbox as a retention budget, so the bytes are
|
|
13
|
+
* dropped at the producer and never cross the wire. Capping here as well is not
|
|
14
|
+
* redundant: a sandbox image that pre-dates the budget returns the whole stream,
|
|
15
|
+
* and this is the boundary that keeps it out of the process either way.
|
|
16
|
+
*/
|
|
17
|
+
/** Per-stream cap. 32 KiB — roomy for real output, well under the loop result budget. */
|
|
18
|
+
export const EXEC_STREAM_BYTE_CAP = 32 * 1024;
|
|
19
|
+
/**
|
|
20
|
+
* Decode `buf` as UTF-8, dropping a trailing partial sequence rather than
|
|
21
|
+
* letting it decode to U+FFFD. Only the last few bytes can be partial, so the
|
|
22
|
+
* scan is bounded by the maximum UTF-8 sequence length.
|
|
11
23
|
*/
|
|
12
|
-
|
|
13
|
-
|
|
24
|
+
function trimPartialSequence(buf) {
|
|
25
|
+
const MAX_UTF8_SEQUENCE = 4;
|
|
26
|
+
for (let drop = 0; drop < MAX_UTF8_SEQUENCE && drop < buf.length; drop++) {
|
|
27
|
+
const candidate = buf.subarray(0, buf.length - drop);
|
|
28
|
+
const decoded = candidate.toString("utf8");
|
|
29
|
+
if (!decoded.endsWith("�")) {
|
|
30
|
+
return decoded;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// A stream that genuinely ends in U+FFFD is returned as-is; dropping more
|
|
34
|
+
// would start eating real characters.
|
|
35
|
+
return buf.toString("utf8");
|
|
36
|
+
}
|
|
14
37
|
function boundStream(stream, cap = EXEC_STREAM_BYTE_CAP) {
|
|
15
38
|
const totalLength = Buffer.byteLength(stream, "utf8");
|
|
16
39
|
if (totalLength <= cap) {
|
|
17
40
|
return { content: stream, truncated: false, totalLength, returnedBytes: totalLength };
|
|
18
41
|
}
|
|
19
|
-
|
|
20
|
-
|
|
42
|
+
// Slice the string before encoding. Encoding first allocates a Buffer the
|
|
43
|
+
// size of the whole stream in order to keep `cap` bytes of it, which makes
|
|
44
|
+
// the function that exists to bound a stream the largest allocation on the
|
|
45
|
+
// path. One UTF-16 code unit encodes to at most 3 UTF-8 bytes, so `cap`
|
|
46
|
+
// units always cover `cap` bytes and the encode stays bounded.
|
|
47
|
+
const buf = Buffer.from(stream.slice(0, cap), "utf8").subarray(0, cap);
|
|
48
|
+
// The byte cut can land mid-sequence, and decoding that directly ends the
|
|
49
|
+
// content in U+FFFD. Trim back to the last whole character instead.
|
|
50
|
+
const content = trimPartialSequence(buf);
|
|
21
51
|
return {
|
|
22
52
|
content,
|
|
23
53
|
truncated: true,
|
|
@@ -25,11 +55,40 @@ function boundStream(stream, cap = EXEC_STREAM_BYTE_CAP) {
|
|
|
25
55
|
returnedBytes: Buffer.byteLength(content, "utf8"),
|
|
26
56
|
};
|
|
27
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Bound an `ExecResult`'s streams while keeping its shape.
|
|
60
|
+
*
|
|
61
|
+
* `boundExecResult` produces the tool-facing form; this is for the boundaries
|
|
62
|
+
* that have to forward an `ExecResult` onward — notably a workflow return value,
|
|
63
|
+
* which DBOS serializes into its durable step output. Returns the input
|
|
64
|
+
* unchanged when nothing needed cutting, so the common path allocates nothing.
|
|
65
|
+
*/
|
|
66
|
+
export function capExecStreams(result, cap = EXEC_STREAM_BYTE_CAP) {
|
|
67
|
+
const out = boundStream(result.stdout, cap);
|
|
68
|
+
const err = boundStream(result.stderr, cap);
|
|
69
|
+
if (!out.truncated && !err.truncated) {
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
...result,
|
|
74
|
+
stdout: out.content,
|
|
75
|
+
stderr: err.content,
|
|
76
|
+
stdoutTruncated: out.truncated || (result.stdoutTruncated ?? false),
|
|
77
|
+
stderrTruncated: err.truncated || (result.stderrTruncated ?? false),
|
|
78
|
+
stdoutTotalBytes: result.stdoutTotalBytes ?? out.totalLength,
|
|
79
|
+
stderrTotalBytes: result.stderrTotalBytes ?? err.totalLength,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
28
82
|
/**
|
|
29
83
|
* Apply per-stream truncation to an `ExecResult`. `exitCode`, `durationMs`,
|
|
30
84
|
* `timedOut`, and `syntheticFailure` pass through unchanged regardless of
|
|
31
85
|
* stream truncation — the discriminants the loop and the model rely on are
|
|
32
86
|
* preserved.
|
|
87
|
+
*
|
|
88
|
+
* When the sandbox already truncated at the producer, what arrives here is the
|
|
89
|
+
* retained slice, not the stream — so its length is the wrong number to report.
|
|
90
|
+
* The sandbox's own totals win where present, and truncation is the union of the
|
|
91
|
+
* two cuts: either side having dropped bytes makes the result truncated.
|
|
33
92
|
*/
|
|
34
93
|
export function boundExecResult(result, cap = EXEC_STREAM_BYTE_CAP) {
|
|
35
94
|
const outStream = boundStream(result.stdout, cap);
|
|
@@ -39,10 +98,10 @@ export function boundExecResult(result, cap = EXEC_STREAM_BYTE_CAP) {
|
|
|
39
98
|
exitCode: result.exitCode,
|
|
40
99
|
stdout: outStream.content,
|
|
41
100
|
stderr: errStream.content,
|
|
42
|
-
stdoutTruncated: outStream.truncated,
|
|
43
|
-
stderrTruncated: errStream.truncated,
|
|
44
|
-
stdoutTotalLength: outStream.totalLength,
|
|
45
|
-
stderrTotalLength: errStream.totalLength,
|
|
101
|
+
stdoutTruncated: outStream.truncated || (result.stdoutTruncated ?? false),
|
|
102
|
+
stderrTruncated: errStream.truncated || (result.stderrTruncated ?? false),
|
|
103
|
+
stdoutTotalLength: result.stdoutTotalBytes ?? outStream.totalLength,
|
|
104
|
+
stderrTotalLength: result.stderrTotalBytes ?? errStream.totalLength,
|
|
46
105
|
durationMs: result.durationMs,
|
|
47
106
|
timedOut: result.timedOut,
|
|
48
107
|
...(result.syntheticFailure ? { syntheticFailure: result.syntheticFailure } : {}),
|
package/package.json
CHANGED