@inflexa-ai/harness 0.27.0 → 0.28.1

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.
@@ -49,6 +49,15 @@ logger = createNoopLogger()) {
49
49
  await upsertArtifacts(db, localEntries);
50
50
  const localPaths = new Set(localEntries.map((e) => e.path));
51
51
  const result = await registry.register(input, session);
52
+ // The registry excluded these from `failed` by its own severity judgement, so
53
+ // this is their only record. Logged here rather than left to the registry:
54
+ // the seam is implementable by an embedder, and a rejection does not get to
55
+ // go unrecorded because one implementation chose not to log it.
56
+ if (result.notCounted && result.notCounted.length > 0) {
57
+ log.warn("registry rejections excluded from the failure count", {
58
+ rejected: result.notCounted.map((f) => ({ path: f.path, error: f.error })),
59
+ });
60
+ }
52
61
  let externalRegistered = 0;
53
62
  for (const reg of result.registered) {
54
63
  // Skip files the registry also returned that we didn't upsert locally
@@ -64,6 +64,20 @@ export interface ExternalRegistrationResult {
64
64
  * back a whole batch (`failed` then carries one summary entry).
65
65
  */
66
66
  failedCount: number;
67
+ /**
68
+ * Rejections the registry judged non-terminal and deliberately kept out of
69
+ * `failed`/`failedCount`: rows it never attempted, so nothing registered and
70
+ * no bytes are at risk. Reported rather than dropped — excluding a rejection
71
+ * decides only that it is not worth failing a step over, never that it goes
72
+ * unrecorded. Keeping them out of `failed` is what lets the fail-fast message
73
+ * list only the paths that actually cost the step something; surfacing them
74
+ * here is what keeps the registry's verdict checkable against what the
75
+ * external system actually said.
76
+ */
77
+ notCounted?: ReadonlyArray<{
78
+ path: string;
79
+ error: string;
80
+ }>;
67
81
  }
68
82
  export interface ArtifactRegistry {
69
83
  /**
@@ -56,10 +56,12 @@ export declare function generateStepSummaryAndWrite(deps: PostStepPipelineDeps,
56
56
  /**
57
57
  * Reconcile the manifest against disk (drop phantoms, rehash) and register the
58
58
  * survivors with the local ledger + the injected `ArtifactRegistry`. Fail-fast
59
- * (see the artifact-manifest spec): a non-zero `externalFailed` is a persistent rejection that
60
- * orphans real outputs, so it throws with the per-file detail (the OSS
61
- * filesystem registry returns `externalFailed: 0`, so it never trips). Returns
62
- * the reconciled manifest.
59
+ * (see the artifact-manifest spec): `externalFailed` counts only the terminal
60
+ * rejections — a rejected output whose bytes exist nowhere but the step tree,
61
+ * plus anything rejected as a consequence of one — so a non-zero count means
62
+ * real outputs went unregistered, and it throws with the per-file detail (the
63
+ * OSS filesystem registry returns `externalFailed: 0`, so it never trips).
64
+ * Returns the reconciled manifest.
63
65
  */
64
66
  export declare function reconcileAndRegisterStepArtifacts(deps: PostStepPipelineDeps, postCtx: PostStepContext, manifest: readonly ArtifactManifestEntry[]): Promise<readonly ArtifactManifestEntry[]>;
65
67
  /**
@@ -95,10 +95,12 @@ export async function generateStepSummaryAndWrite(deps, postCtx, manifest) {
95
95
  /**
96
96
  * Reconcile the manifest against disk (drop phantoms, rehash) and register the
97
97
  * survivors with the local ledger + the injected `ArtifactRegistry`. Fail-fast
98
- * (see the artifact-manifest spec): a non-zero `externalFailed` is a persistent rejection that
99
- * orphans real outputs, so it throws with the per-file detail (the OSS
100
- * filesystem registry returns `externalFailed: 0`, so it never trips). Returns
101
- * the reconciled manifest.
98
+ * (see the artifact-manifest spec): `externalFailed` counts only the terminal
99
+ * rejections — a rejected output whose bytes exist nowhere but the step tree,
100
+ * plus anything rejected as a consequence of one — so a non-zero count means
101
+ * real outputs went unregistered, and it throws with the per-file detail (the
102
+ * OSS filesystem registry returns `externalFailed: 0`, so it never trips).
103
+ * Returns the reconciled manifest.
102
104
  */
103
105
  export async function reconcileAndRegisterStepArtifacts(deps, postCtx, manifest) {
104
106
  const { input, session, lineageCollector } = postCtx;
@@ -122,11 +124,18 @@ export async function reconcileAndRegisterStepArtifacts(deps, postCtx, manifest)
122
124
  collector: lineageCollector,
123
125
  }, session, deps.logger);
124
126
  if (reg.externalFailed > 0) {
125
- const wholeActivityFailed = reg.externalRegistered === 0;
127
+ // The registry commits per leaf and per activity, so accepted and rejected
128
+ // rows arriving together is ordinary and `externalRegistered` is what
129
+ // genuinely landed. Zero of it means this batch registered nothing at all:
130
+ // no row carries an `artifact_id`, so the byte-sync that follows selects
131
+ // none of them and every output stays local-only. That reads as a payload-
132
+ // or credential-shaped rejection rather than a per-file one, so it rides on
133
+ // the message and as its own field.
134
+ const noneRegistered = reg.externalRegistered === 0;
126
135
  const detail = reg.failureDetails.map((f) => `${f.path}: ${f.error}`).join("\n ");
127
136
  const msg = `[post-step.reconcile] external registration failed for ${input.stepId}: ` +
128
- `${reg.externalFailed} row(s) rejected, ${reg.externalRegistered}/${reg.localCount} local artifact(s) registered` +
129
- (wholeActivityFailed ? " (WHOLE ACTIVITY ROLLED BACK — outputs orphaned)" : "") +
137
+ `${reg.externalFailed} terminal rejection(s), ${reg.externalRegistered}/${reg.localCount} local artifact(s) registered` +
138
+ (noneRegistered ? " (nothing in this batch registered)" : "") +
130
139
  (detail ? `\n ${detail}` : "");
131
140
  // Logged as fields as well as thrown: the throw reaches `failStep` as one
132
141
  // opaque string, so the per-path rejections are only queryable from here.
@@ -138,7 +147,7 @@ export async function reconcileAndRegisterStepArtifacts(deps, postCtx, manifest)
138
147
  externalFailed: reg.externalFailed,
139
148
  externalRegistered: reg.externalRegistered,
140
149
  localCount: reg.localCount,
141
- wholeActivityFailed,
150
+ noneRegistered,
142
151
  failures: reg.failureDetails,
143
152
  });
144
153
  throw new Error(msg);
@@ -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 } : {}),
@@ -500,16 +500,46 @@ export async function runSandboxStepBody(input, deps) {
500
500
  // outputs, so it fails the step loudly instead of finishing green. Transient
501
501
  // managed-root errors are already retried in the client, so anything thrown here is
502
502
  // persistent — tear down, mark failed, and re-raise so the parent's fail-fast
503
- // cascade fires (mirrors the agent-loop failure path).
503
+ // cascade fires (mirrors the agent-loop failure path). The two operations are
504
+ // sequenced independently: byte-sync is attempted on both arms, so a rejected
505
+ // registration still uploads the bytes of whatever DID register.
504
506
  await emitActivity("persisting", "Registering artifacts");
507
+ // A bare call, deliberately not `DBOS.runStep`-wrapped: it is reachable from
508
+ // two arms below, and a checkpointed step reached from a conditional arm
509
+ // would shift the body's function-ID sequence between the two paths (see the
510
+ // harness-durable-runtime spec).
511
+ const syncStepArtifacts = () => deps.artifactRegistry.sync({
512
+ resourceId: input.analysisId,
513
+ runId: input.runId,
514
+ stepId: input.stepId,
515
+ }, session);
505
516
  let reconciledManifest;
506
517
  try {
507
518
  reconciledManifest = await reconcileAndRegisterStepArtifacts(deps, postCtx, manifest);
508
- await deps.artifactRegistry.sync({
509
- resourceId: input.analysisId,
510
- runId: input.runId,
511
- stepId: input.stepId,
512
- }, session);
519
+ }
520
+ catch (registrationErr) {
521
+ // Registration and byte-sync are independent, and a registry rejection is
522
+ // partial: the rows that DID register carry `artifact_id` with `file_id`
523
+ // still NULL, which is exactly what `queryUnsyncedStepArtifacts` selects.
524
+ // Syncing anyway uploads their bytes (rows that never registered are not
525
+ // selected, so this stays idempotent under re-execution); skipping it is
526
+ // what strands them registered-but-byteless.
527
+ try {
528
+ await syncStepArtifacts();
529
+ }
530
+ catch (syncErr) {
531
+ // A cancellation is control-flow, not a sync failure — re-raise it
532
+ // verbatim, as every other stage in this body does. Any real failure
533
+ // is swallowed so it cannot displace the registration rejection as the
534
+ // step's cause; this record is the only account of it.
535
+ if (syncErr instanceof DBOSErrors.DBOSWorkflowCancelledError)
536
+ throw syncErr;
537
+ logger.named("post-step.sync").warn("failed after a registration rejection (non-fatal)", logger.errorFields(syncErr));
538
+ }
539
+ throw await failStep("lineage_attestation", registrationErr);
540
+ }
541
+ try {
542
+ await syncStepArtifacts();
513
543
  }
514
544
  catch (err) {
515
545
  throw await failStep("lineage_attestation", err);
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.1",
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",