@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.
@@ -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
- // preserves them as `payloadRaw`; the JSON.stringify fallback exists
237
- // only for messages persisted before that field was added — re-
238
- // serializing diverges from Go's HTML-escaping encoder.
239
- const bodyBytes = Buffer.from(msg.payloadRaw ?? JSON.stringify(msg.payload), "utf8");
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
- body: bodyBytes,
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, config.submitDeps),
149
- awaitExec: (ref, execId, emit, deadline) => awaitExec(ref, execId, emit, deadline, composeAwaitOptions(config.awaitOptions, transport, isAlive)),
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
+ }
@@ -16,8 +16,16 @@
16
16
  */
17
17
  export interface VerifyCallbackInput {
18
18
  execId: string;
19
- /** The bytes the sandbox-server POSTed. The dumb route preserves them. */
20
- body: Buffer | string;
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). */
@@ -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 { createHash, createHmac, timingSafeEqual } from "node:crypto";
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 createHash("sha256").update(input).digest("hex");
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}:${sha256Hex(input.body)}`;
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
- await runStep(() => postExec(fetchImpl, ref, body), {
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
  }
@@ -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
- * `payloadRaw` is the exact bytes sandbox-server POSTed — sandbox-server's
132
- * HMAC signs the raw body, and Go's `encoding/json` HTML-escapes `<`, `>`,
133
- * `&` by default, so verifying against a JS re-serialization of the parsed
134
- * payload would diverge for any output containing those characters
135
- * (common in bioinformatics: FASTA headers, shell stderr, command pipelines).
136
- * Optional only for back-compat with messages that pre-date this field.
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 {
@@ -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
- * `payloadRaw` is the exact bytes sandbox-server POSTed — sandbox-server's
101
- * HMAC signs the raw body, and Go's `encoding/json` HTML-escapes `<`, `>`,
102
- * `&` by default, so verifying against a JS re-serialization of the parsed
103
- * payload would diverge for any output containing those characters
104
- * (common in bioinformatics: FASTA headers, shell stderr, command pipelines).
105
- * Optional only for back-compat with messages that pre-date this field.
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. 8 KiB — multi-KB but well under the loop result budget. */
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
- /** Per-stream cap. 8 KiB — multi-KB but well under the loop result budget. */
13
- export const EXEC_STREAM_BYTE_CAP = 8 * 1024;
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
- const buf = Buffer.from(stream, "utf8").subarray(0, cap);
20
- const content = buf.toString("utf8");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Host-agnostic bioinformatics agent harness — hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",