@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.
- package/dist/execution/artifact-registration.js +9 -0
- package/dist/execution/artifact-registry.d.ts +14 -0
- package/dist/execution/post-step-pipeline.d.ts +6 -4
- package/dist/execution/post-step-pipeline.js +17 -8
- 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/dist/workflows/sandbox-step.js +36 -6
- package/package.json +1 -1
|
@@ -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):
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* the
|
|
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):
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* the
|
|
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
|
-
|
|
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}
|
|
129
|
-
(
|
|
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
|
-
|
|
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
|
-
//
|
|
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 } : {}),
|
|
@@ -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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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