@inflexa-ai/harness 0.26.3 → 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.
@@ -13,6 +13,13 @@
13
13
  *
14
14
  * A pure function over the record — no I/O, no LLM, no pipeline. It is the whole
15
15
  * mechanism.
16
+ *
17
+ * Every path it renders is projected into the frame-independent
18
+ * `/{analysisId}/…` form. The record stores a path relative to the analysis
19
+ * root, which is the correct form to store; but a step agent resolves a relative
20
+ * path against its own working directory, thus the stored form names a file that
21
+ * does not exist there. The absolute form resolves to the same file in every
22
+ * frame, including the conversation agent's, so the projection is unconditional.
16
23
  */
17
24
  import type { DataProfileResult } from "../state/data-profile.js";
18
25
  /** The character budget the projection guarantees it will not exceed. */
@@ -30,5 +37,9 @@ export declare const DATA_PROFILE_ORIENTATION_MAX_CHARS = 1200;
30
37
  * A snapshot written before the record was widened carries none of the structured
31
38
  * fields. Rather than emit a bare file list, that case falls back to the narrative
32
39
  * summary — the one orientation a legacy row does have.
40
+ *
41
+ * `analysisId` roots each rendered file path (see the module header). It is the
42
+ * id of the analysis the profile belongs to, and it is what the sandbox mounts
43
+ * the tree at.
33
44
  */
34
- export declare function buildDataProfileOrientation(result: DataProfileResult, maxChars?: number): string;
45
+ export declare function buildDataProfileOrientation(result: DataProfileResult, analysisId: string, maxChars?: number): string;
@@ -13,7 +13,15 @@
13
13
  *
14
14
  * A pure function over the record — no I/O, no LLM, no pipeline. It is the whole
15
15
  * mechanism.
16
+ *
17
+ * Every path it renders is projected into the frame-independent
18
+ * `/{analysisId}/…` form. The record stores a path relative to the analysis
19
+ * root, which is the correct form to store; but a step agent resolves a relative
20
+ * path against its own working directory, thus the stored form names a file that
21
+ * does not exist there. The absolute form resolves to the same file in every
22
+ * frame, including the conversation agent's, so the projection is unconditional.
16
23
  */
24
+ import { toAnalysisRootPath } from "../workspace/paths.js";
17
25
  /** The character budget the projection guarantees it will not exceed. */
18
26
  export const DATA_PROFILE_ORIENTATION_MAX_CHARS = 1200;
19
27
  /** Files listed before the tail is elided (the count still reports the total). */
@@ -81,8 +89,12 @@ function formatFileFacts(file) {
81
89
  * A snapshot written before the record was widened carries none of the structured
82
90
  * fields. Rather than emit a bare file list, that case falls back to the narrative
83
91
  * summary — the one orientation a legacy row does have.
92
+ *
93
+ * `analysisId` roots each rendered file path (see the module header). It is the
94
+ * id of the analysis the profile belongs to, and it is what the sandbox mounts
95
+ * the tree at.
84
96
  */
85
- export function buildDataProfileOrientation(result, maxChars = DATA_PROFILE_ORIENTATION_MAX_CHARS) {
97
+ export function buildDataProfileOrientation(result, analysisId, maxChars = DATA_PROFILE_ORIENTATION_MAX_CHARS) {
86
98
  const lines = [];
87
99
  const identity = [
88
100
  [present(result.domain), present(result.subtype)].filter((f) => f !== undefined).join(" / ") || undefined,
@@ -139,7 +151,7 @@ export function buildDataProfileOrientation(result, maxChars = DATA_PROFILE_ORIE
139
151
  const header = shown.length < result.files.length ? `${label} (${shown.length} of ${result.files.length}):` : `${label} (${result.files.length}):`;
140
152
  lines.push(header);
141
153
  for (const file of shown) {
142
- lines.push(`- ${file.path} — ${clip(file.description, MAX_FILE_DESCRIPTION_CHARS)}${formatFileFacts(file)}`);
154
+ lines.push(`- ${toAnalysisRootPath(analysisId, file.path)} — ${clip(file.description, MAX_FILE_DESCRIPTION_CHARS)}${formatFileFacts(file)}`);
143
155
  }
144
156
  }
145
157
  return clamp(lines.join("\n"), maxChars);
@@ -48,20 +48,30 @@ export declare const STEP_NON_TASK_FIELDS: readonly ["id", "track", "step_type",
48
48
  * improvises the rest.
49
49
  */
50
50
  export declare function renderTask(step: AnalysisStep): string;
51
- /** The two in-sandbox paths that anchor every path the agent writes or reads. */
51
+ /**
52
+ * What anchors every path the agent writes or reads: the id the tree is mounted
53
+ * at, and the step's writable directory.
54
+ *
55
+ * The frame carries the id rather than the analysis root, because the root is
56
+ * `/{analysisId}` and two fields for one value can disagree. The id is also what
57
+ * every other path in the briefing is rooted with, thus one field serves the
58
+ * workspace section and the orientation alike.
59
+ */
52
60
  export interface WorkspaceFrame {
53
- /** In-sandbox analysis root — the read-only mount of the whole analysis tree. */
54
- readonly analysisRoot: string;
61
+ /** The analysis id. The sandbox mounts the read-only analysis tree at `/{analysisId}`. */
62
+ readonly analysisId: string;
55
63
  /** In-sandbox working directory — this step's writable artifact directory, and its cwd. */
56
64
  readonly workingDir: string;
57
65
  }
66
+ /** The in-sandbox analysis root of a frame — the read-only mount of the whole tree. */
67
+ export declare function analysisRootOf(frame: WorkspaceFrame): string;
58
68
  export declare function renderWorkspace(frame: WorkspaceFrame): string;
59
69
  /**
60
70
  * What dataset the analysis is holding, projected from the persisted profile.
61
71
  * Absent or not-yet-profiled → `""`, and the agent falls back to its always-on
62
72
  * `inspect_data_profile` tool.
63
73
  */
64
- export declare function renderOrientation(profile: DataProfileResult | null | undefined): string;
74
+ export declare function renderOrientation(profile: DataProfileResult | null | undefined, analysisId: string): string;
65
75
  /**
66
76
  * One completed dependency's handoff. The seed carries the gist plus the PATHS
67
77
  * to the rest — an agent that needs more than the excerpt reads the summary at
@@ -104,12 +104,17 @@ export function renderTask(step) {
104
104
  }
105
105
  return parts.join("\n\n");
106
106
  }
107
+ /** The in-sandbox analysis root of a frame — the read-only mount of the whole tree. */
108
+ export function analysisRootOf(frame) {
109
+ return `/${frame.analysisId}`;
110
+ }
107
111
  export function renderWorkspace(frame) {
108
- if (!frame.analysisRoot.trim() || !frame.workingDir.trim())
112
+ if (!frame.analysisId.trim() || !frame.workingDir.trim())
109
113
  return "";
114
+ const analysisRoot = analysisRootOf(frame);
110
115
  return section("Workspace", bullets([
111
116
  `Working directory (writable, your cwd): \`${frame.workingDir}\` — write outputs to \`output/\`, \`figures/\`, \`scripts/\`, \`logs/\` under it.`,
112
- `Analysis root (read-only): \`${frame.analysisRoot}\` — inputs at \`${frame.analysisRoot}/data/inputs/\`, prior runs at \`${frame.analysisRoot}/runs/\`.`,
117
+ `Analysis root (read-only): \`${analysisRoot}\` — inputs at \`${analysisRoot}/data/inputs/\`, prior runs at \`${analysisRoot}/runs/\`.`,
113
118
  ]));
114
119
  }
115
120
  // ── (3) Data orientation ──────────────────────────────────────────────
@@ -118,10 +123,10 @@ export function renderWorkspace(frame) {
118
123
  * Absent or not-yet-profiled → `""`, and the agent falls back to its always-on
119
124
  * `inspect_data_profile` tool.
120
125
  */
121
- export function renderOrientation(profile) {
126
+ export function renderOrientation(profile, analysisId) {
122
127
  if (!profile)
123
128
  return "";
124
- const orientation = buildDataProfileOrientation(profile, DATA_PROFILE_ORIENTATION_MAX_CHARS);
129
+ const orientation = buildDataProfileOrientation(profile, analysisId, DATA_PROFILE_ORIENTATION_MAX_CHARS);
125
130
  if (orientation.trim().length === 0)
126
131
  return "";
127
132
  return section("Data orientation", `${orientation}\n\nThis is a bounded projection — call \`inspect_data_profile\` for the full profile.`);
@@ -160,7 +165,12 @@ function renderHandoff(handoff) {
160
165
  * caller's durable step replay-stable.
161
166
  */
162
167
  export function composeStepBriefing(briefing) {
163
- return [renderTask(briefing.step), renderWorkspace(briefing.workspace), renderOrientation(briefing.profile), renderUpstream(briefing.upstream)]
168
+ return [
169
+ renderTask(briefing.step),
170
+ renderWorkspace(briefing.workspace),
171
+ renderOrientation(briefing.profile, briefing.workspace.analysisId),
172
+ renderUpstream(briefing.upstream),
173
+ ]
164
174
  .filter(Boolean)
165
175
  .join("\n\n");
166
176
  }
@@ -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(),
@@ -428,6 +428,7 @@ CREATE TABLE IF NOT EXISTS cortex_working_memory (
428
428
  CREATE TABLE IF NOT EXISTS cortex_asks (
429
429
  id TEXT PRIMARY KEY,
430
430
  analysis_id TEXT NOT NULL,
431
+ user_id TEXT,
431
432
  thread_id TEXT,
432
433
  title TEXT NOT NULL,
433
434
  command TEXT NOT NULL,
@@ -451,16 +452,17 @@ CREATE INDEX IF NOT EXISTS idx_cortex_asks_analysis
451
452
  CREATE INDEX IF NOT EXISTS idx_cortex_asks_pending
452
453
  ON cortex_asks(status) WHERE status = 'pending';
453
454
 
454
- -- Standing approval grants behind an 'always' reply — keyed by the analysis and
455
- -- the ask's grant key (the displayed command when the tool supplied no broader
456
- -- key), so a later matching ask auto-approves without pausing. Lives for the
457
- -- analysis lifecycle, surviving process restarts, and never applies to another
458
- -- analysis.
455
+ -- Standing approval grants behind an 'always' reply — keyed by the analysis, the
456
+ -- user, and the ask's grant key (the displayed command when the tool supplied no
457
+ -- broader key), so a later matching ask auto-approves without pausing. Lives for
458
+ -- the analysis lifecycle, surviving process restarts, and never applies to
459
+ -- another analysis or another user.
459
460
  CREATE TABLE IF NOT EXISTS cortex_ask_grants (
460
461
  analysis_id TEXT NOT NULL,
462
+ user_id TEXT NOT NULL,
461
463
  grant_key TEXT NOT NULL,
462
464
  created_at TEXT NOT NULL,
463
- PRIMARY KEY (analysis_id, grant_key)
465
+ PRIMARY KEY (analysis_id, user_id, grant_key)
464
466
  );
465
467
  `;
466
468
  /**
@@ -640,6 +642,21 @@ export async function initCortexState(pool, injected) {
640
642
  ALTER TABLE cortex_ask_grants RENAME COLUMN command TO grant_key;
641
643
  END IF;
642
644
  END $$`,
645
+ "ALTER TABLE cortex_asks ADD COLUMN IF NOT EXISTS user_id TEXT",
646
+ // The user joins the grant key. Guarded and scoped like the rename
647
+ // above. An existing row keeps the empty default, which matches no real
648
+ // identity, so it is inert rather than deleted.
649
+ `DO $$ BEGIN
650
+ IF NOT EXISTS (
651
+ SELECT 1 FROM information_schema.columns
652
+ WHERE table_schema = current_schema() AND table_name = 'cortex_ask_grants' AND column_name = 'user_id'
653
+ ) THEN
654
+ ALTER TABLE cortex_ask_grants ADD COLUMN user_id TEXT NOT NULL DEFAULT '';
655
+ ALTER TABLE cortex_ask_grants ALTER COLUMN user_id DROP DEFAULT;
656
+ ALTER TABLE cortex_ask_grants DROP CONSTRAINT IF EXISTS cortex_ask_grants_pkey;
657
+ ALTER TABLE cortex_ask_grants ADD PRIMARY KEY (analysis_id, user_id, grant_key);
658
+ END IF;
659
+ END $$`,
643
660
  ];
644
661
  for (const sql of addMigrations) {
645
662
  await client.query(sql);
@@ -290,7 +290,7 @@ export async function runDataProfileBody(input, deps) {
290
290
  `Profile the input data for this analysis: say what the dataset IS, so planning can proceed.`,
291
291
  ``,
292
292
  renderWorkspace({
293
- analysisRoot: `/${analysisId}`,
293
+ analysisId,
294
294
  workingDir: toSandboxPath(workspaceRoot, analysisId, profileWritePrefix),
295
295
  }),
296
296
  ``,
@@ -53,7 +53,7 @@ async function profileOrientation(pool, analysisId, logger) {
53
53
  const status = unwrapOrThrow(await loadDataProfileStatus(pool, analysisId));
54
54
  if (!status?.result)
55
55
  return "No persisted data-profile facts are available.";
56
- return buildDataProfileOrientation(status.result, DATA_PROFILE_ORIENTATION_MAX_CHARS);
56
+ return buildDataProfileOrientation(status.result, analysisId, DATA_PROFILE_ORIENTATION_MAX_CHARS);
57
57
  }
58
58
  catch (error) {
59
59
  logger.warn("could not load data profile for ad hoc routing", { error: error instanceof Error ? error.message : String(error) });
@@ -20,11 +20,13 @@ export interface AskGatewayDeps {
20
20
  }
21
21
  /**
22
22
  * Per-turn context an embedder binds into the `Ask` seam. It scopes the ask to an
23
- * analysis/thread, carries the turn's abort `signal`, and supplies the `emit` sink
24
- * the `data-ask` part flows through.
23
+ * analysis/thread and to one user, carries the turn's abort `signal`, and supplies
24
+ * the `emit` sink the `data-ask` part flows through.
25
25
  */
26
26
  export interface AskContext {
27
27
  readonly analysisId: string;
28
+ /** The opaque identity of the person the ask goes to. The harness never reads it. */
29
+ readonly userId: string;
28
30
  readonly threadId?: string;
29
31
  readonly signal: AbortSignal;
30
32
  readonly emit: EmitFn;
@@ -29,6 +29,7 @@ export function createAskGateway(deps) {
29
29
  const row = {
30
30
  id,
31
31
  analysisId: ctx.analysisId,
32
+ userId: ctx.userId,
32
33
  threadId: ctx.threadId ?? null,
33
34
  title: request.title,
34
35
  command: request.command,
@@ -36,10 +37,10 @@ export function createAskGateway(deps) {
36
37
  grantKey: request.grantKey ?? null,
37
38
  createdAt: now,
38
39
  };
39
- // A standing grant for this ask's grant key — the command unless the tool
40
- // supplied a broader key — short-circuits the prompt: record the invocation as
41
- // `resolved` for the audit trail and return without pausing.
42
- if (unwrapOrThrow(await selectGrant(pool, ctx.analysisId, request.grantKey ?? request.command))) {
40
+ // A standing grant this user holds for this ask's grant key — the command
41
+ // unless the tool supplied a broader key — short-circuits the prompt: record
42
+ // the invocation as `resolved` for the audit trail and return without pausing.
43
+ if (unwrapOrThrow(await selectGrant(pool, ctx.analysisId, ctx.userId, request.grantKey ?? request.command))) {
43
44
  unwrapOrThrow(await insertGrantedAsk(pool, row, now));
44
45
  return { kind: "always" };
45
46
  }
@@ -21,6 +21,7 @@ export type AnswerOutcome = "applied" | "not_found" | "already_terminal";
21
21
  export interface PendingAsk {
22
22
  readonly id: string;
23
23
  readonly analysisId: string;
24
+ readonly userId: string;
24
25
  readonly threadId: string | null;
25
26
  readonly title: string;
26
27
  readonly command: string;
@@ -36,6 +37,7 @@ export interface AskResolution {
36
37
  export interface AskRow {
37
38
  readonly id: string;
38
39
  readonly analysisId: string;
40
+ readonly userId: string;
39
41
  readonly threadId: string | null;
40
42
  readonly title: string;
41
43
  readonly command: string;
@@ -52,8 +54,8 @@ export declare function insertPendingAsk(querier: Querier, row: AskRow): ResultA
52
54
  * every approval-gated action. The recorded reply is `{ kind: "always" }`.
53
55
  */
54
56
  export declare function insertGrantedAsk(querier: Querier, row: AskRow, resolvedAt: string): ResultAsync<void, DbError>;
55
- /** Does a standing grant cover `(analysisId, grantKey)` in this analysis? */
56
- export declare function selectGrant(querier: Querier, analysisId: string, grantKey: string): ResultAsync<boolean, DbError>;
57
+ /** Does a standing grant cover `(analysisId, userId, grantKey)` for this person? */
58
+ export declare function selectGrant(querier: Querier, analysisId: string, userId: string, grantKey: string): ResultAsync<boolean, DbError>;
57
59
  /** Read a row's current status + recorded reply. `null` when no such row exists. */
58
60
  export declare function selectAskResolution(querier: Querier, id: string): ResultAsync<AskResolution | null, DbError>;
59
61
  /**
@@ -15,9 +15,9 @@ export function insertPendingAsk(querier, row) {
15
15
  return tryMutation("asks.insertPending", async () => {
16
16
  await querier.query({
17
17
  text: `INSERT INTO cortex_asks
18
- (id, analysis_id, thread_id, title, command, detail, grant_key, status, created_at)
19
- VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', $8)`,
20
- values: [row.id, row.analysisId, row.threadId, row.title, row.command, row.detail, row.grantKey ?? row.command, row.createdAt],
18
+ (id, analysis_id, user_id, thread_id, title, command, detail, grant_key, status, created_at)
19
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending', $9)`,
20
+ values: [row.id, row.analysisId, row.userId, row.threadId, row.title, row.command, row.detail, row.grantKey ?? row.command, row.createdAt],
21
21
  });
22
22
  });
23
23
  }
@@ -30,11 +30,12 @@ export function insertGrantedAsk(querier, row, resolvedAt) {
30
30
  return tryMutation("asks.insertGranted", async () => {
31
31
  await querier.query({
32
32
  text: `INSERT INTO cortex_asks
33
- (id, analysis_id, thread_id, title, command, detail, grant_key, status, reply, created_at, resolved_at)
34
- VALUES ($1, $2, $3, $4, $5, $6, $7, 'resolved', $8::jsonb, $9, $10)`,
33
+ (id, analysis_id, user_id, thread_id, title, command, detail, grant_key, status, reply, created_at, resolved_at)
34
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'resolved', $9::jsonb, $10, $11)`,
35
35
  values: [
36
36
  row.id,
37
37
  row.analysisId,
38
+ row.userId,
38
39
  row.threadId,
39
40
  row.title,
40
41
  row.command,
@@ -47,12 +48,12 @@ export function insertGrantedAsk(querier, row, resolvedAt) {
47
48
  });
48
49
  });
49
50
  }
50
- /** Does a standing grant cover `(analysisId, grantKey)` in this analysis? */
51
- export function selectGrant(querier, analysisId, grantKey) {
51
+ /** Does a standing grant cover `(analysisId, userId, grantKey)` for this person? */
52
+ export function selectGrant(querier, analysisId, userId, grantKey) {
52
53
  return tryQuery("asks.selectGrant", async () => {
53
54
  const result = await querier.query({
54
- text: `SELECT 1 FROM cortex_ask_grants WHERE analysis_id = $1 AND grant_key = $2`,
55
- values: [analysisId, grantKey],
55
+ text: `SELECT 1 FROM cortex_ask_grants WHERE analysis_id = $1 AND user_id = $2 AND grant_key = $3`,
56
+ values: [analysisId, userId, grantKey],
56
57
  });
57
58
  return (result.rowCount ?? 0) > 0;
58
59
  });
@@ -86,12 +87,13 @@ export function markAborted(querier, id, resolvedAt) {
86
87
  export function selectPending(querier) {
87
88
  return tryQuery("asks.selectPending", async () => {
88
89
  const result = await querier.query({
89
- text: `SELECT id, analysis_id, thread_id, title, command, detail, created_at
90
+ text: `SELECT id, analysis_id, COALESCE(user_id, '') AS user_id, thread_id, title, command, detail, created_at
90
91
  FROM cortex_asks WHERE status = 'pending' ORDER BY created_at ASC`,
91
92
  });
92
93
  return result.rows.map((row) => ({
93
94
  id: row.id,
94
95
  analysisId: row.analysis_id,
96
+ userId: row.user_id,
95
97
  threadId: row.thread_id ?? null,
96
98
  title: row.title,
97
99
  command: row.command,
@@ -133,7 +135,7 @@ export function answerAsk(pool, id, reply, now) {
133
135
  return withTransaction(pool, "asks.answer", (client) => tryMutation("asks.answer:update", () => client.query({
134
136
  text: `UPDATE cortex_asks SET status = $1, reply = $2::jsonb, resolved_at = $3
135
137
  WHERE id = $4 AND status = 'pending'
136
- RETURNING analysis_id, COALESCE(grant_key, command) AS grant_key`,
138
+ RETURNING analysis_id, COALESCE(user_id, '') AS user_id, COALESCE(grant_key, command) AS grant_key`,
137
139
  values: [status, JSON.stringify(reply), now, id],
138
140
  })).andThen((updated) => {
139
141
  if ((updated.rowCount ?? 0) === 0)
@@ -144,12 +146,14 @@ export function answerAsk(pool, id, reply, now) {
144
146
  // caveat: every insert writes grant_key (command when the tool supplied
145
147
  // none), and the COALESCE above covers any pending row that predates
146
148
  // the column — `command` is NOT NULL, so the key can never be null.
149
+ // `user_id` reads the same way: its COALESCE gives the empty string for a
150
+ // row that predates the column, and that identity matches no real user.
147
151
  const granted = updated.rows[0];
148
152
  return tryMutation("asks.answer:grant", async () => {
149
153
  await client.query({
150
- text: `INSERT INTO cortex_ask_grants (analysis_id, grant_key, created_at)
151
- VALUES ($1, $2, $3) ON CONFLICT (analysis_id, grant_key) DO NOTHING`,
152
- values: [granted.analysis_id, granted.grant_key, now],
154
+ text: `INSERT INTO cortex_ask_grants (analysis_id, user_id, grant_key, created_at)
155
+ VALUES ($1, $2, $3, $4) ON CONFLICT (analysis_id, user_id, grant_key) DO NOTHING`,
156
+ values: [granted.analysis_id, granted.user_id, granted.grant_key, now],
153
157
  });
154
158
  }).map(() => "applied");
155
159
  }));
@@ -209,7 +209,7 @@ const NO_FACTS_GUIDANCE = "Plan from the research question alone, do not invent
209
209
  *
210
210
  * Pure — the same grounding renders the same string.
211
211
  */
212
- function renderDataContext(grounding) {
212
+ function renderDataContext(grounding, analysisId) {
213
213
  switch (grounding.kind) {
214
214
  case "absent":
215
215
  return "";
@@ -230,7 +230,7 @@ function renderDataContext(grounding) {
230
230
  "From this analysis's persisted data profile — the authoritative record of what the input data " +
231
231
  "is, derived by profiling the files themselves. Nobody typed it; ground your plan in it.",
232
232
  "",
233
- buildDataProfileOrientation(grounding.result, DATA_PROFILE_ORIENTATION_MAX_CHARS),
233
+ buildDataProfileOrientation(grounding.result, analysisId, DATA_PROFILE_ORIENTATION_MAX_CHARS),
234
234
  ];
235
235
  if (grounding.kind === "provisional") {
236
236
  lines.push("", `PROVISIONAL — this profile may not describe the analysis's current inputs: ${grounding.reasons.join("; ")}. ` +
@@ -748,7 +748,7 @@ export function createGeneratePlanTool(deps) {
748
748
  return null;
749
749
  });
750
750
  const grounding = classifyGrounding(profileStatus);
751
- const dataContextBlock = renderDataContext(grounding);
751
+ const dataContextBlock = renderDataContext(grounding, analysisId);
752
752
  // Reference data and package availability are mandatory grounding,
753
753
  // not optional planner lookups. Read both inventories host-side before
754
754
  // the loop and put their rendered content in the planner's seed.
@@ -19,6 +19,12 @@
19
19
  * Lifecycle states are data variants, not errors (the `defineTool` contract): a missing,
20
20
  * in-flight, failed, or stale profile is an ordinary, expected outcome the model must be
21
21
  * able to reason about, so each returns in the ok channel.
22
+ *
23
+ * Every path it serves is projected into the frame-independent `/{analysisId}/…`
24
+ * form. The record stores a path relative to the analysis root, and a step agent
25
+ * resolves a relative path against its own working directory — thus the stored
26
+ * form names a file that does not exist in the frame of the caller. The same
27
+ * projection guards the step briefing (`app/data-profile-orientation.ts`).
22
28
  */
23
29
  import type { Pool } from "pg";
24
30
  import { type DataProfileFile, type DataProfileResult } from "../../state/index.js";
@@ -19,12 +19,19 @@
19
19
  * Lifecycle states are data variants, not errors (the `defineTool` contract): a missing,
20
20
  * in-flight, failed, or stale profile is an ordinary, expected outcome the model must be
21
21
  * able to reason about, so each returns in the ok channel.
22
+ *
23
+ * Every path it serves is projected into the frame-independent `/{analysisId}/…`
24
+ * form. The record stores a path relative to the analysis root, and a step agent
25
+ * resolves a relative path against its own working directory — thus the stored
26
+ * form names a file that does not exist in the frame of the caller. The same
27
+ * projection guards the step briefing (`app/data-profile-orientation.ts`).
22
28
  */
23
29
  import { ok } from "neverthrow";
24
30
  import { z } from "zod";
25
31
  import { scopeResource } from "../../auth/types.js";
26
32
  import { unwrapOrThrow } from "../../lib/result.js";
27
33
  import { loadDataProfileStatus } from "../../state/index.js";
34
+ import { toAnalysisRootPath } from "../../workspace/paths.js";
28
35
  import { defineTool } from "../define-tool.js";
29
36
  /** Per-file records returned per page when the caller names none. */
30
37
  const DEFAULT_PAGE_SIZE = 20;
@@ -44,6 +51,20 @@ function datasetFileCount(result) {
44
51
  return result.kinds.reduce((sum, kind) => sum + kind.count, 0);
45
52
  return null;
46
53
  }
54
+ /** Serve each per-file record with a path that resolves in the frame of the caller. */
55
+ function rootFilePaths(files, analysisId) {
56
+ return files.map((file) => ({ ...file, path: toAnalysisRootPath(analysisId, file.path) }));
57
+ }
58
+ /**
59
+ * Serve each kind with a glob that resolves in the frame of the caller.
60
+ *
61
+ * A `pathPattern` is a path with a wildcard segment, thus it takes the same
62
+ * projection as a file path. The wildcard characters survive it: the projection
63
+ * only re-roots the pattern.
64
+ */
65
+ function rootKindPatterns(kinds, analysisId) {
66
+ return kinds.map((kind) => ({ ...kind, pathPattern: toAnalysisRootPath(analysisId, kind.pathPattern) }));
67
+ }
47
68
  /**
48
69
  * Name every reason the served profile may not describe the current inputs.
49
70
  *
@@ -212,7 +233,7 @@ export function createInspectDataProfileTool(pool) {
212
233
  ...envelope,
213
234
  scope: "kinds",
214
235
  available: true,
215
- kinds: result.kinds,
236
+ kinds: rootKindPatterns(result.kinds, resourceId),
216
237
  ...(result.axes ? { axes: result.axes } : {}),
217
238
  ...(result.coverage ? { coverage: result.coverage } : {}),
218
239
  });
@@ -229,7 +250,7 @@ export function createInspectDataProfileTool(pool) {
229
250
  pageSize,
230
251
  total,
231
252
  hasMore: start + files.length < total,
232
- files,
253
+ files: rootFilePaths(files, resourceId),
233
254
  });
234
255
  },
235
256
  });
@@ -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 } : {}),
@@ -214,7 +214,7 @@ export async function composeStepSeed(args) {
214
214
  // through the same helpers the sandbox mount and the mutate tools use, so
215
215
  // the seed cannot disagree with the container about where anything is.
216
216
  const workspace = {
217
- analysisRoot: `/${analysisId}`,
217
+ analysisId,
218
218
  workingDir: toSandboxPath(workspaceRoot, analysisId, stepWritePrefix({ workspaceRoot, runId, stepId })),
219
219
  };
220
220
  // A DB failure here throws (house rules): a swallowed error would be frozen
@@ -159,6 +159,25 @@ export declare function tailWritePrefix(args: {
159
159
  * a step briefing names (see the harness-workspace-tools spec).
160
160
  */
161
161
  export declare function toSandboxPath(workspaceRoot: string, resourceId: string, hostAbsPath: string): string;
162
+ /**
163
+ * Map an analysis-root-relative path to the frame-independent `/{resourceId}/…`
164
+ * form this module defines at the top of the file.
165
+ *
166
+ * A record that outlives one agent stores the root-relative form, because that
167
+ * form does not depend on where a host puts the root. But a reader always reads
168
+ * inside a frame, and an agent whose working directory is a step directory
169
+ * resolves `data/inputs/x.csv` against that step directory. Thus one stored path
170
+ * is correct in the store and wrong in the message. This is the projection that a
171
+ * renderer applies before a stored path reaches an agent.
172
+ *
173
+ * The input comes from a record and it is a POSIX path, thus it normalizes under
174
+ * POSIX rules. The read is tolerant of the near-misses that a model writes: a
175
+ * leading slash, a `./` prefix, and a doubled separator all name the same file,
176
+ * and a path that already carries the correct root passes through. A tail that
177
+ * climbs above the root is returned as it is, because such a path must fail at
178
+ * the resolver as `out_of_scope`. A prefix would only make it look canonical.
179
+ */
180
+ export declare function toAnalysisRootPath(resourceId: string, path: string): string;
162
181
  /**
163
182
  * Parent of every report session, workspace-root-relative. Callers that
164
183
  * enumerate the sessions join this. Callers that address one join
@@ -25,7 +25,7 @@
25
25
  * comes back `out_of_prefix` (the read surface skips that — it roams the tree
26
26
  * read-only).
27
27
  */
28
- import { relative as relativePath, resolve as resolvePath, sep } from "node:path";
28
+ import { posix as posixPath, relative as relativePath, resolve as resolvePath, sep } from "node:path";
29
29
  /**
30
30
  * Resolve an agent-supplied path against the analysis tree under the
31
31
  * frame-aware model.
@@ -149,8 +149,54 @@ export function toSandboxPath(workspaceRoot, resourceId, hostAbsPath) {
149
149
  if (tail === ".." || tail.startsWith("../")) {
150
150
  throw new Error(`toSandboxPath: host path escapes the workspace root: ${hostAbsPath}`);
151
151
  }
152
+ return joinAnalysisRoot(resourceId, tail);
153
+ }
154
+ /**
155
+ * Map an analysis-root-relative path to the frame-independent `/{resourceId}/…`
156
+ * form this module defines at the top of the file.
157
+ *
158
+ * A record that outlives one agent stores the root-relative form, because that
159
+ * form does not depend on where a host puts the root. But a reader always reads
160
+ * inside a frame, and an agent whose working directory is a step directory
161
+ * resolves `data/inputs/x.csv` against that step directory. Thus one stored path
162
+ * is correct in the store and wrong in the message. This is the projection that a
163
+ * renderer applies before a stored path reaches an agent.
164
+ *
165
+ * The input comes from a record and it is a POSIX path, thus it normalizes under
166
+ * POSIX rules. The read is tolerant of the near-misses that a model writes: a
167
+ * leading slash, a `./` prefix, and a doubled separator all name the same file,
168
+ * and a path that already carries the correct root passes through. A tail that
169
+ * climbs above the root is returned as it is, because such a path must fail at
170
+ * the resolver as `out_of_scope`. A prefix would only make it look canonical.
171
+ */
172
+ export function toAnalysisRootPath(resourceId, path) {
173
+ const trimmed = path.trim();
174
+ if (trimmed === "")
175
+ return `/${resourceId}`;
176
+ // Already rooted at this analysis: normalize the tail, then attach the root again.
177
+ const rooted = trimmed.startsWith("/") ? stripAnalysisRoot(trimmed, resourceId) : null;
178
+ if (rooted !== null)
179
+ return joinAnalysisRoot(resourceId, normalizeTail(rooted));
180
+ // A record holds no host path. Thus a leading slash is how a model writes the
181
+ // same root-relative path, and the remainder is the tail.
182
+ const tail = normalizeTail(trimmed.replace(/^\/+/, ""));
183
+ if (tail === ".." || tail.startsWith("../"))
184
+ return trimmed;
185
+ return joinAnalysisRoot(resourceId, tail);
186
+ }
187
+ /** `/{resourceId}` plus a tail. An empty tail gives the bare root. */
188
+ function joinAnalysisRoot(resourceId, tail) {
152
189
  return tail === "" ? `/${resourceId}` : `/${resourceId}/${tail}`;
153
190
  }
191
+ /** Collapse `.`, `..`, and a doubled separator in a POSIX tail. A bare `.` becomes empty. */
192
+ function normalizeTail(tail) {
193
+ if (tail === "")
194
+ return "";
195
+ const normalized = posixPath.normalize(tail);
196
+ if (normalized === "." || normalized === "./")
197
+ return "";
198
+ return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
199
+ }
154
200
  /**
155
201
  * Parent of every report session, workspace-root-relative. Callers that
156
202
  * enumerate the sessions join this. Callers that address one join
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.26.3",
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",
@@ -69,8 +69,8 @@
69
69
  "cheerio": "^1.2.0",
70
70
  "dockerode": "^4.0.12",
71
71
  "echarts": "5.5.1",
72
- "fast-xml-parser": "^5.10.0",
73
- "hono": "^4.12.30",
72
+ "fast-xml-parser": "^5.10.1",
73
+ "hono": "^4.12.34",
74
74
  "hyparquet": "^1.28.1",
75
75
  "js-tiktoken": "^1.0.21",
76
76
  "mime": "^4.1.0",
@@ -83,6 +83,13 @@
83
83
  "puppeteer-core": "^23.11.1",
84
84
  "zod": "^4.4.3"
85
85
  },
86
+ "overrides": {
87
+ "ip-address": "^10.3.1",
88
+ "js-yaml": "^4.3.1",
89
+ "protobufjs": "^7.6.5",
90
+ "undici": "^7.29.0",
91
+ "uuid": "^11.1.1"
92
+ },
86
93
  "devDependencies": {
87
94
  "@eslint/js": "^10.0.1",
88
95
  "@types/bun": "latest",