@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.
- package/dist/app/data-profile-orientation.d.ts +12 -1
- package/dist/app/data-profile-orientation.js +14 -2
- package/dist/prompts/briefing.d.ts +14 -4
- package/dist/prompts/briefing.js +15 -5
- 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/state/init.js +23 -6
- package/dist/tasks/data-profile.js +1 -1
- package/dist/tools/ad-hoc-router.js +1 -1
- package/dist/tools/approval/gateway.d.ts +4 -2
- package/dist/tools/approval/gateway.js +5 -4
- package/dist/tools/approval/queries.d.ts +4 -2
- package/dist/tools/approval/queries.js +18 -14
- package/dist/tools/research/generate-plan.js +3 -3
- package/dist/tools/research/inspect-data-profile.d.ts +6 -0
- package/dist/tools/research/inspect-data-profile.js +23 -2
- package/dist/tools/workspace/result-bounds.d.ts +20 -1
- package/dist/tools/workspace/result-bounds.js +67 -8
- package/dist/workflows/execute-analysis.js +1 -1
- package/dist/workspace/paths.d.ts +19 -0
- package/dist/workspace/paths.js +47 -1
- package/package.json +10 -3
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
54
|
-
readonly
|
|
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
|
package/dist/prompts/briefing.js
CHANGED
|
@@ -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.
|
|
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): \`${
|
|
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 [
|
|
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
|
-
//
|
|
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(),
|
package/dist/state/init.js
CHANGED
|
@@ -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
|
|
455
|
-
-- the ask's grant key (the displayed command when the tool supplied no
|
|
456
|
-
-- key), so a later matching ask auto-approves without pausing. Lives for
|
|
457
|
-
-- analysis lifecycle, surviving process restarts, and never applies to
|
|
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
|
-
|
|
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
|
|
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
|
|
40
|
-
// supplied a broader key — short-circuits the prompt: record
|
|
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)`
|
|
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', $
|
|
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', $
|
|
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)`
|
|
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 = $
|
|
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.
|
|
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 } : {}),
|
|
@@ -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
|
-
|
|
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
|
package/dist/workspace/paths.js
CHANGED
|
@@ -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.
|
|
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.
|
|
73
|
-
"hono": "^4.12.
|
|
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",
|