@inflexa-ai/harness 0.33.1 → 0.35.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.
Files changed (42) hide show
  1. package/dist/agents/conversation-agent.d.ts +7 -3
  2. package/dist/agents/conversation-agent.js +20 -2
  3. package/dist/agents/sandbox/shared.d.ts +4 -9
  4. package/dist/agents/sandbox/shared.js +9 -12
  5. package/dist/config/environment-stores.d.ts +9 -6
  6. package/dist/execution/artifact-record.d.ts +9 -5
  7. package/dist/execution/artifact-record.js +3 -3
  8. package/dist/execution/reconcile-manifest.js +25 -1
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.js +6 -0
  11. package/dist/prompts/sandbox-standards.d.ts +7 -7
  12. package/dist/prompts/sandbox-standards.js +6 -6
  13. package/dist/provenance/collector.js +1 -1
  14. package/dist/provenance/seam.d.ts +23 -2
  15. package/dist/provenance/types.d.ts +9 -4
  16. package/dist/provenance/types.js +7 -2
  17. package/dist/sandbox/client.d.ts +10 -1
  18. package/dist/sandbox/create-sandbox.d.ts +12 -0
  19. package/dist/sandbox/create-sandbox.js +45 -8
  20. package/dist/sandbox/docker-client.d.ts +11 -0
  21. package/dist/sandbox/docker-client.js +19 -5
  22. package/dist/sandbox/farm.d.ts +3 -1
  23. package/dist/sandbox/farm.js +3 -1
  24. package/dist/sandbox/image-packages.d.ts +107 -0
  25. package/dist/sandbox/image-packages.js +107 -0
  26. package/dist/sandbox/k8s-client.d.ts +11 -0
  27. package/dist/sandbox/k8s-client.js +20 -4
  28. package/dist/sandbox/sandbox-error.d.ts +40 -5
  29. package/dist/sandbox/sandbox-error.js +79 -5
  30. package/dist/tools/sandbox/list-available-packages.d.ts +18 -16
  31. package/dist/tools/sandbox/list-available-packages.js +90 -52
  32. package/dist/tools/workspace/edit-file.d.ts +32 -6
  33. package/dist/tools/workspace/edit-file.js +101 -15
  34. package/dist/tools/workspace/mutator.d.ts +68 -32
  35. package/dist/tools/workspace/mutator.js +203 -55
  36. package/dist/tools/workspace/run-exec.d.ts +5 -5
  37. package/dist/tools/workspace/run-exec.js +5 -5
  38. package/dist/tools/workspace/write-file.d.ts +4 -4
  39. package/dist/tools/workspace/write-file.js +14 -6
  40. package/dist/workspace/paths.d.ts +1 -1
  41. package/dist/workspace/paths.js +1 -1
  42. package/package.json +1 -1
@@ -3,20 +3,33 @@
3
3
  * working directory. The write-side counterpart to `WorkspaceFilesystem`
4
4
  * (see the harness-workspace-tools spec).
5
5
  *
6
- * It lives here (next to `run-exec`) rather than under `workspace/` because the
7
- * write path is sandbox-coupled: a write is one `SandboxClient` exec, not a
8
- * host `fs` call. The seam owns the whole gauntlet that `write_file` and
9
- * `edit_file` previously each re-implemented inline — resolve + confine to the
10
- * working directory, derive the in-sandbox path, and write the bytes through
11
- * the sandbox — so the confinement invariant is concentrated in one place
12
- * instead of being a per-tool convention.
6
+ * The seam owns the whole gauntlet that `write_file` and `edit_file` share —
7
+ * resolve + confine to the working directory, the symlink-hardened landing,
8
+ * the host `fs` write, and the provenance record — so the confinement
9
+ * invariant is concentrated in one place instead of being a per-tool
10
+ * convention.
11
+ *
12
+ * Two realizations serve the two agent contexts. `createWorkspaceMutator`
13
+ * closes over fixed per-step coordinates and records into the step-scoped
14
+ * lineage collector (the sandbox agents). `createSessionWorkspaceMutator`
15
+ * resolves its coordinates per call from the session's analysis scope — the
16
+ * write prefix is the analysis root — and emits a `write-file` session event
17
+ * through the provenance seam (the conversation agent, which runs under no
18
+ * run and no step).
19
+ *
20
+ * The landing is hardened beyond the lexical resolver: missing parent
21
+ * directories are created only inside the write prefix, the deepest existing
22
+ * ancestor is realpath-checked against the realpath'd prefix (a symlinked
23
+ * ancestor cannot re-aim the write), and the final open uses `O_NOFOLLOW`
24
+ * (a symlinked final component is refused, never followed).
13
25
  */
26
+ import type { AgentSession } from "../../auth/types.js";
27
+ import type { Logger } from "../../lib/logger.js";
28
+ import type { RunStep } from "../../loop/types.js";
14
29
  import type { ProvenanceCollector } from "../../provenance/collector.js";
15
- import type { SandboxClient } from "../../sandbox/client.js";
16
- import type { SandboxRef } from "../../sandbox/types.js";
17
- import type { EmitFn } from "../define-tool.js";
18
- import { type BoundedExecResult } from "./result-bounds.js";
19
- /** Outcome of a confined write. Expected outcomes are data variants — never throws. */
30
+ import { type ProvenanceSeam } from "../../provenance/seam.js";
31
+ import { type ResolveWorkspaceRoot } from "../../workspace/paths.js";
32
+ /** Outcome of a confined write. Expected outcomes are data variants — never throws on them. */
20
33
  export type WriteFileResult = {
21
34
  readonly status: "ok";
22
35
  readonly path: string;
@@ -27,10 +40,10 @@ export type WriteFileResult = {
27
40
  } | {
28
41
  readonly status: "out_of_prefix";
29
42
  readonly path: string;
30
- } | ({
31
- readonly status: "write_failed";
43
+ } | {
44
+ readonly status: "symlink_denied";
32
45
  readonly path: string;
33
- } & BoundedExecResult);
46
+ };
34
47
  /**
35
48
  * Agent-visible name of the tool driving a confined write. Rides the write args
36
49
  * so a successful write is attributed to the invoking tool in provenance
@@ -39,25 +52,11 @@ export type WriteFileResult = {
39
52
  */
40
53
  export type MutateToolName = "write_file" | "edit_file";
41
54
  export interface WorkspaceMutatorDeps {
42
- readonly sandboxClient: SandboxClient;
43
- readonly sandbox: SandboxRef;
44
55
  /** Absolute host root of this analysis's workspace tree — used by the resolver. */
45
56
  readonly workspaceRoot: string;
46
57
  readonly analysisId: string;
47
- readonly stepId: string;
48
- readonly workflowId: string;
49
58
  /** Absolute host working directory: relative paths resolve here AND writes are confined here. */
50
59
  readonly workingDir: string;
51
- /**
52
- * In-sandbox absolute path of `workingDir` (mirrors `execute_command`'s
53
- * `defaultCwd`). Passed as the write exec's `cwd` so sandbox-server roots
54
- * its tree-differ here and reports the written file as a live delta — an
55
- * empty `cwd` disables the differ, so write-only files (e.g. scripts/)
56
- * would never reach the live file tree.
57
- */
58
- readonly sandboxWorkingDir: string;
59
- readonly nextFunctionId: () => string;
60
- readonly deadlineMs: () => number;
61
60
  /**
62
61
  * Step-scoped lineage collector. On a successful confined write the seam
63
62
  * records a file-tool provenance record here — hash and size computed
@@ -70,14 +69,51 @@ export interface WorkspaceMutator {
70
69
  /**
71
70
  * Resolve `path` against the working directory (relative) or analysis root
72
71
  * (absolute `/{analysisId}/...`), confine the result to the working
73
- * directory, and write `content` through the sandbox. `toolName` names the
74
- * invoking tool so a successful write is attributed to it in provenance.
72
+ * directory, and land `content` on the host filesystem. `toolName` names
73
+ * the invoking tool so a successful write is attributed to it in
74
+ * provenance. `invocationId` is the tool call's loop id — pass the tool
75
+ * context's `invocationId`: it rides the provenance record so the write's
76
+ * call activity gets a deterministic identity. `runStep` wraps the disk
77
+ * mutation in a replay-cached step — pass the tool context's `runStep`.
78
+ * `session` is the calling agent's session — pass the tool context's
79
+ * `session`: the step-scoped realization ignores it, the session-scoped
80
+ * one resolves its coordinates and its provenance attribution from it.
75
81
  */
76
82
  writeFile(args: {
77
83
  readonly path: string;
78
84
  readonly content: string;
79
85
  readonly toolName: MutateToolName;
80
- readonly emit: EmitFn;
86
+ readonly invocationId: string;
87
+ readonly runStep: RunStep;
88
+ readonly session: AgentSession;
81
89
  }): Promise<WriteFileResult>;
82
90
  }
83
91
  export declare function createWorkspaceMutator(deps: WorkspaceMutatorDeps): WorkspaceMutator;
92
+ /** Construction deps of the session-scoped realization — see {@link createSessionWorkspaceMutator}. */
93
+ export interface SessionWorkspaceMutatorDeps {
94
+ /** Workspace-root resolution seam — the write coordinates resolve per call from the session's analysis scope. */
95
+ readonly resolveWorkspaceRoot: ResolveWorkspaceRoot;
96
+ /**
97
+ * The provenance seam. On a successful confined write the seam's session
98
+ * emit receives one `write-file` event — hash and size computed in-process
99
+ * from the exact bytes written, attributed to the session's analysis and
100
+ * thread. An unbound session emit records nothing; the write itself
101
+ * proceeds unchanged.
102
+ */
103
+ readonly provenance?: ProvenanceSeam;
104
+ readonly logger?: Logger;
105
+ }
106
+ /**
107
+ * The session-scoped `WorkspaceMutator` realization — the conversation agent's
108
+ * write path. Where `createWorkspaceMutator` closes over fixed per-step
109
+ * coordinates, this one serves every analysis from one construction: each call
110
+ * resolves the workspace root from the session's analysis scope, and the write
111
+ * prefix IS that root — the agent modifies its own analysis tree freely, while
112
+ * `..` traversal, a foreign analysis, and the symlink escapes stay refused by
113
+ * the same gauntlet.
114
+ *
115
+ * A write here runs under no run and no step, so its provenance is a session
116
+ * observation, not a collector record: each successful write emits one
117
+ * `write-file` session event (see `provenance/seam.ts`).
118
+ */
119
+ export declare function createSessionWorkspaceMutator(deps: SessionWorkspaceMutatorDeps): WorkspaceMutator;
@@ -3,79 +3,227 @@
3
3
  * working directory. The write-side counterpart to `WorkspaceFilesystem`
4
4
  * (see the harness-workspace-tools spec).
5
5
  *
6
- * It lives here (next to `run-exec`) rather than under `workspace/` because the
7
- * write path is sandbox-coupled: a write is one `SandboxClient` exec, not a
8
- * host `fs` call. The seam owns the whole gauntlet that `write_file` and
9
- * `edit_file` previously each re-implemented inline — resolve + confine to the
10
- * working directory, derive the in-sandbox path, and write the bytes through
11
- * the sandbox — so the confinement invariant is concentrated in one place
12
- * instead of being a per-tool convention.
6
+ * The seam owns the whole gauntlet that `write_file` and `edit_file` share —
7
+ * resolve + confine to the working directory, the symlink-hardened landing,
8
+ * the host `fs` write, and the provenance record — so the confinement
9
+ * invariant is concentrated in one place instead of being a per-tool
10
+ * convention.
11
+ *
12
+ * Two realizations serve the two agent contexts. `createWorkspaceMutator`
13
+ * closes over fixed per-step coordinates and records into the step-scoped
14
+ * lineage collector (the sandbox agents). `createSessionWorkspaceMutator`
15
+ * resolves its coordinates per call from the session's analysis scope — the
16
+ * write prefix is the analysis root — and emits a `write-file` session event
17
+ * through the provenance seam (the conversation agent, which runs under no
18
+ * run and no step).
19
+ *
20
+ * The landing is hardened beyond the lexical resolver: missing parent
21
+ * directories are created only inside the write prefix, the deepest existing
22
+ * ancestor is realpath-checked against the realpath'd prefix (a symlinked
23
+ * ancestor cannot re-aim the write), and the final open uses `O_NOFOLLOW`
24
+ * (a symlinked final component is refused, never followed).
13
25
  */
26
+ import { constants } from "node:fs";
27
+ import { lstat, mkdir, open, realpath } from "node:fs/promises";
28
+ import { basename, dirname, join as joinPath, relative as relativePath, resolve as resolvePath, sep } from "node:path";
29
+ import { createNoopLogger } from "../../lib/console-logger.js";
14
30
  import { computeSha256 } from "../../lib/fs-helpers.js";
31
+ import { tryFs, tryFsWrite } from "../../lib/fs-result.js";
32
+ import { unwrapOrThrow } from "../../lib/result.js";
33
+ import { bindSessionEmit } from "../../provenance/seam.js";
15
34
  import { resolveForWrite } from "../../workspace/paths.js";
16
- import { boundExecResult } from "./result-bounds.js";
17
- import { runSandboxExec } from "./run-exec.js";
18
- const WRITE_BYTES_PROGRAM = [
19
- "import sys,base64,os",
20
- "p=sys.argv[1]",
21
- "os.makedirs(os.path.dirname(p), exist_ok=True)",
22
- "open(p,'wb').write(base64.b64decode(sys.argv[2]))",
23
- ].join("\n");
35
+ /**
36
+ * Land `bytes` at `absolute`, hardened against a symlink re-aiming the write.
37
+ * `absolute` is already lexically confined under `prefix` by the resolver;
38
+ * this function guards the physical path:
39
+ *
40
+ * 1. Realpath the prefix (created if absent — it is workflow-owned
41
+ * infrastructure, not agent input).
42
+ * 2. Realpath the deepest existing ancestor of the target and refuse when it
43
+ * lands outside the realpath'd prefix — a symlinked ancestor inside the
44
+ * prefix cannot point the write elsewhere. A dangling-symlink ancestor
45
+ * has no landing at all and is refused too.
46
+ * 3. Create the missing parents under that canonical ancestor, then realpath
47
+ * the created directory and refuse when it lands outside the prefix — a
48
+ * symlink raced into a missing segment between the ancestor check and the
49
+ * recursive mkdir would re-aim the tail of the path.
50
+ * 4. Refuse a symlinked final component (lstat), then open with
51
+ * `O_NOFOLLOW` so a symlink raced in after the check still cannot be
52
+ * followed.
53
+ *
54
+ * Refusals are data; a genuine I/O failure throws (the loop's dispatch catch
55
+ * maps it to an error tool result).
56
+ */
57
+ async function landBytes(prefix, absolute, bytes) {
58
+ unwrapOrThrow(await tryFsWrite("mutator.mkdirPrefix", () => mkdir(prefix, { recursive: true }), { path: prefix }));
59
+ const realPrefix = unwrapOrThrow(await tryFs("mutator.realpathPrefix", () => realpath(prefix), { path: prefix }));
60
+ // Deepest existing ancestor of the target. The walk stops at the prefix,
61
+ // which exists — the resolver already confined the lexical path under it.
62
+ let deepest = dirname(absolute);
63
+ while (deepest !== prefix) {
64
+ const stat = unwrapOrThrow(await tryFs("mutator.lstatAncestor", () => lstat(deepest), { path: deepest, onAbsent: () => null }));
65
+ if (stat !== null)
66
+ break;
67
+ deepest = dirname(deepest);
68
+ }
69
+ const realDeepest = unwrapOrThrow(await tryFs("mutator.realpathAncestor", () => realpath(deepest), { path: deepest, onAbsent: () => null }));
70
+ if (realDeepest === null)
71
+ return "symlink_denied";
72
+ if (realDeepest !== realPrefix && !realDeepest.startsWith(realPrefix + sep))
73
+ return "out_of_prefix";
74
+ const targetDir = joinPath(realDeepest, relativePath(deepest, dirname(absolute)));
75
+ let landingDir = realDeepest;
76
+ if (targetDir !== realDeepest) {
77
+ unwrapOrThrow(await tryFsWrite("mutator.mkdirParents", () => mkdir(targetDir, { recursive: true }), { path: targetDir }));
78
+ // Recursive mkdir follows a symlink that already occupies a segment, so
79
+ // a symlink raced into a missing segment after the ancestor check would
80
+ // re-aim the tail. The canonical path of the created directory exposes
81
+ // it; a directory that vanished before the check is hostile too. The
82
+ // open below uses the canonical path, so the write never traverses the
83
+ // raced segment again.
84
+ const realTarget = unwrapOrThrow(await tryFs("mutator.realpathTarget", () => realpath(targetDir), { path: targetDir, onAbsent: () => null }));
85
+ if (realTarget === null)
86
+ return "symlink_denied";
87
+ if (realTarget !== realPrefix && !realTarget.startsWith(realPrefix + sep))
88
+ return "out_of_prefix";
89
+ landingDir = realTarget;
90
+ }
91
+ const finalPath = joinPath(landingDir, basename(absolute));
92
+ const finalStat = unwrapOrThrow(await tryFs("mutator.lstatFinal", () => lstat(finalPath), { path: finalPath, onAbsent: () => null }));
93
+ if (finalStat !== null && finalStat.isSymbolicLink())
94
+ return "symlink_denied";
95
+ // `O_NOFOLLOW` backstops the lstat refusal: a symlink raced in between the
96
+ // two calls fails the open (`ELOOP`) instead of being followed.
97
+ const handle = unwrapOrThrow(await tryFsWrite("mutator.open", () => open(finalPath, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW), {
98
+ path: finalPath,
99
+ }));
100
+ try {
101
+ unwrapOrThrow(await tryFsWrite("mutator.writeBytes", () => handle.writeFile(bytes), { path: finalPath }));
102
+ }
103
+ finally {
104
+ unwrapOrThrow(await tryFsWrite("mutator.close", () => handle.close(), { path: finalPath }));
105
+ }
106
+ return "ok";
107
+ }
108
+ /**
109
+ * The shared write gauntlet both realizations run: resolve + confine
110
+ * (`resolveForWrite`), then the hardened landing wrapped in `runStep`. A
111
+ * refusal comes back as data; an ok carries what the caller's provenance
112
+ * record needs — the analysis-relative path and the exact bytes that landed.
113
+ */
114
+ async function confinedWrite(args) {
115
+ const scoped = resolveForWrite({
116
+ workspaceRoot: args.workspaceRoot,
117
+ analysisId: args.analysisId,
118
+ workingDir: args.workingDir,
119
+ path: args.path,
120
+ });
121
+ if (scoped.kind === "out_of_scope")
122
+ return { status: "out_of_scope" };
123
+ if (scoped.kind === "out_of_prefix")
124
+ return { status: "out_of_prefix" };
125
+ // Analysis-root-relative, forward-slashed: the agent-facing result
126
+ // path prepends the `/{analysisId}` frame; a provenance record uses
127
+ // the bare tail (the collector normalizes it step-relative itself).
128
+ const relative = scoped.relative.split("\\").join("/");
129
+ const agentPath = `/${args.analysisId}/${relative}`;
130
+ const bytes = Buffer.from(args.content, "utf8");
131
+ const prefix = resolvePath(args.workingDir);
132
+ // The loop dispatches a workflow-mode tool body unwrapped (see
133
+ // `dispatchTools` in loop/run-agent.ts), so a DBOS replay re-runs
134
+ // this body. The step wrapper caches the landing: a replay returns
135
+ // the recorded outcome instead of touching the disk again. On the
136
+ // chat route the injected `runStep` is the passthrough — the same
137
+ // call, no durability, no fork of this path.
138
+ const landed = await args.runStep("write", () => landBytes(prefix, scoped.absolute, bytes));
139
+ if (landed !== "ok")
140
+ return { status: landed };
141
+ return { status: "ok", relative, agentPath, bytes };
142
+ }
24
143
  export function createWorkspaceMutator(deps) {
25
144
  return {
26
- async writeFile({ path, content, toolName, emit }) {
27
- const scoped = resolveForWrite({
145
+ async writeFile({ path, content, toolName, invocationId, runStep }) {
146
+ const landed = await confinedWrite({
28
147
  workspaceRoot: deps.workspaceRoot,
29
148
  analysisId: deps.analysisId,
30
149
  workingDir: deps.workingDir,
31
150
  path,
151
+ content,
152
+ runStep,
32
153
  });
33
- if (scoped.kind === "out_of_scope")
34
- return { status: "out_of_scope", path };
35
- if (scoped.kind === "out_of_prefix")
36
- return { status: "out_of_prefix", path };
37
- // Analysis-root-relative, forward-slashed: the sandbox path prepends
38
- // the `/{analysisId}` mount; the provenance record uses the bare tail
39
- // (the collector normalizes it step-relative itself).
40
- const relative = scoped.relative.split("\\").join("/");
41
- const sandboxPath = `/${deps.analysisId}/${relative}`;
42
- const contentBytes = Buffer.from(content, "utf8");
43
- const execId = `${deps.workflowId}:${deps.stepId}:${deps.nextFunctionId()}`;
44
- const result = await runSandboxExec({
45
- sandboxClient: deps.sandboxClient,
46
- sandbox: deps.sandbox,
47
- execId,
48
- command: ["python3", "-c", WRITE_BYTES_PROGRAM, sandboxPath, contentBytes.toString("base64")],
49
- cwd: deps.sandboxWorkingDir,
50
- deadlineMs: deps.deadlineMs(),
51
- emit,
52
- });
53
- if (result.exitCode !== 0) {
54
- return { status: "write_failed", path: sandboxPath, ...boundExecResult(result) };
55
- }
154
+ if (landed.status !== "ok")
155
+ return { status: landed.status, path };
56
156
  // Attest the write in-process from the exact bytes just written — the
57
157
  // seam owns write provenance the same way it owns confinement. The
58
- // sandbox exec frame this write produces is deliberately not fed to
59
- // `feedExecFrame`: doing so would mint a `python3`+base64 command
60
- // record, the very misattribution this file-tool record supersedes.
158
+ // record lands outside the write step on purpose: the collector is
159
+ // process-local state, so a recovered replay must re-record into the
160
+ // fresh collector even while the cached step skips the disk write.
61
161
  //
62
- // `timestamp` is a write-time wall clock — normally a replay hazard in
63
- // provenance, but safe here because the record contributes only producer
64
- // IDENTITY downstream: the bridge drops it (the cli's file-tool ref has no
65
- // timestamp field) and the signed `inflexa:FileToolWrite` activity carries
66
- // just the tool name, so a re-execution's fresh stamp changes nothing in
67
- // the attested graph. Do NOT start forwarding it into an identifier or a
68
- // formal PROV position without making it replay-stable first.
162
+ // `invocationId` is replay-stable: the model turn that minted the tool
163
+ // call is a durably cached step, so a re-execution replays the same id.
164
+ // That is what lets the bridge key a DETERMINISTIC call-activity
165
+ // identifier on it — a wall-clock stamp here would be re-minted per
166
+ // replay and must never reach an identifier or a formal PROV position.
69
167
  if (deps.lineageCollector) {
70
168
  deps.lineageCollector.recordFileToolWrite({
71
- path: relative,
72
- hash: computeSha256(contentBytes),
73
- size: contentBytes.length,
169
+ path: landed.relative,
170
+ hash: computeSha256(landed.bytes),
171
+ size: landed.bytes.length,
74
172
  toolName,
75
- timestamp: new Date().toISOString(),
173
+ invocationId,
76
174
  });
77
175
  }
78
- return { status: "ok", path: sandboxPath, bytesWritten: contentBytes.length };
176
+ return { status: "ok", path: landed.agentPath, bytesWritten: landed.bytes.length };
177
+ },
178
+ };
179
+ }
180
+ /**
181
+ * The session-scoped `WorkspaceMutator` realization — the conversation agent's
182
+ * write path. Where `createWorkspaceMutator` closes over fixed per-step
183
+ * coordinates, this one serves every analysis from one construction: each call
184
+ * resolves the workspace root from the session's analysis scope, and the write
185
+ * prefix IS that root — the agent modifies its own analysis tree freely, while
186
+ * `..` traversal, a foreign analysis, and the symlink escapes stay refused by
187
+ * the same gauntlet.
188
+ *
189
+ * A write here runs under no run and no step, so its provenance is a session
190
+ * observation, not a collector record: each successful write emits one
191
+ * `write-file` session event (see `provenance/seam.ts`).
192
+ */
193
+ export function createSessionWorkspaceMutator(deps) {
194
+ const logger = (deps.logger ?? createNoopLogger()).named("workspace-mutator");
195
+ const observe = bindSessionEmit(deps.provenance, logger);
196
+ return {
197
+ async writeFile({ path, content, toolName, invocationId, runStep, session }) {
198
+ // Only an analysis scope has a workspace tree; under any other
199
+ // scope there is nothing in scope to write.
200
+ const scope = session.scope;
201
+ if (scope.kind !== "analysis")
202
+ return { status: "out_of_scope", path };
203
+ // Throws on an unknown analysis — the loop's dispatch catch maps
204
+ // it to an error tool result, like any unexpected host failure.
205
+ const workspaceRoot = deps.resolveWorkspaceRoot(scope.analysisId);
206
+ const landed = await confinedWrite({
207
+ workspaceRoot,
208
+ analysisId: scope.analysisId,
209
+ workingDir: workspaceRoot,
210
+ path,
211
+ content,
212
+ runStep,
213
+ });
214
+ if (landed.status !== "ok")
215
+ return { status: landed.status, path };
216
+ observe({
217
+ type: "write-file",
218
+ analysisId: scope.analysisId,
219
+ ...(scope.threadId !== undefined ? { threadId: scope.threadId } : {}),
220
+ path: landed.relative,
221
+ hash: computeSha256(landed.bytes),
222
+ size: landed.bytes.length,
223
+ tool: toolName,
224
+ invocationId,
225
+ });
226
+ return { status: "ok", path: landed.agentPath, bytesWritten: landed.bytes.length };
79
227
  },
80
228
  };
81
229
  }
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Internal helper used by dependency-bearing tools to drive one sandbox exec
3
3
  * through the `SandboxClient`. Centralises the submit/await pair, exec-id
4
- * derivation, and intermediate-event forwarding so all three tools share
5
- * one chokepoint. `write_file` / `edit_file` are layered on the workspace
6
- * mutator and environment introspection tools such as `list_available_refs`
7
- * use this same replay-safe submit/await path rather than a parallel protocol.
4
+ * derivation, and intermediate-event forwarding in one chokepoint.
5
+ * `execute_command` and environment introspection tools such as
6
+ * `list_available_refs` use this same replay-safe submit/await path rather
7
+ * than a parallel protocol.
8
8
  *
9
9
  * Not a registered tool — `defineTool` is called by the user-facing tools
10
- * (`execute-command.ts`, `write-file.ts`, `edit-file.ts`) which wrap this.
10
+ * (`execute-command.ts`) which wrap this.
11
11
  */
12
12
  import type { SandboxClient } from "../../sandbox/client.js";
13
13
  import type { ExecResult, SandboxRef } from "../../sandbox/types.js";
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Internal helper used by dependency-bearing tools to drive one sandbox exec
3
3
  * through the `SandboxClient`. Centralises the submit/await pair, exec-id
4
- * derivation, and intermediate-event forwarding so all three tools share
5
- * one chokepoint. `write_file` / `edit_file` are layered on the workspace
6
- * mutator and environment introspection tools such as `list_available_refs`
7
- * use this same replay-safe submit/await path rather than a parallel protocol.
4
+ * derivation, and intermediate-event forwarding in one chokepoint.
5
+ * `execute_command` and environment introspection tools such as
6
+ * `list_available_refs` use this same replay-safe submit/await path rather
7
+ * than a parallel protocol.
8
8
  *
9
9
  * Not a registered tool — `defineTool` is called by the user-facing tools
10
- * (`execute-command.ts`, `write-file.ts`, `edit-file.ts`) which wrap this.
10
+ * (`execute-command.ts`) which wrap this.
11
11
  */
12
12
  export async function runSandboxExec(args) {
13
13
  const body = {
@@ -1,10 +1,10 @@
1
1
  /**
2
- * `write_file` — sandbox-gated workspace write, confined to the agent's
3
- * writable working directory.
2
+ * `write_file` — workspace write, confined to the agent's writable working
3
+ * directory.
4
4
  *
5
5
  * A thin adapter over the `WorkspaceMutator` seam (see the harness-durable-runtime / harness-workspace-tools specs): the
6
- * mutator owns resolve + confine + sandbox write + provenance. This tool only
7
- * declares the input schema and forwards. `edit_file` rides the same seam.
6
+ * mutator owns resolve + confine + hardened host write + provenance. This tool
7
+ * only declares the input schema and forwards. `edit_file` rides the same seam.
8
8
  */
9
9
  import type { WorkspaceMutator } from "./mutator.js";
10
10
  export interface WriteFileDeps {
@@ -1,10 +1,10 @@
1
1
  /**
2
- * `write_file` — sandbox-gated workspace write, confined to the agent's
3
- * writable working directory.
2
+ * `write_file` — workspace write, confined to the agent's writable working
3
+ * directory.
4
4
  *
5
5
  * A thin adapter over the `WorkspaceMutator` seam (see the harness-durable-runtime / harness-workspace-tools specs): the
6
- * mutator owns resolve + confine + sandbox write + provenance. This tool only
7
- * declares the input schema and forwards. `edit_file` rides the same seam.
6
+ * mutator owns resolve + confine + hardened host write + provenance. This tool
7
+ * only declares the input schema and forwards. `edit_file` rides the same seam.
8
8
  */
9
9
  import { ok } from "neverthrow";
10
10
  import { z } from "zod";
@@ -21,7 +21,8 @@ const WriteFileInputSchema = z.object({
21
21
  export function createWriteFileTool(deps) {
22
22
  return defineTool({
23
23
  id: "write_file",
24
- // Body-only `awaitExec`-recv → runs unwrapped in the workflow body (see the harness-tools spec).
24
+ // The mutator wraps the disk mutation in `ctx.runStep` itself, so the
25
+ // body runs unwrapped in the workflow body (see the harness-tools spec).
25
26
  executionMode: "workflow",
26
27
  description: "Write a UTF-8 text file in your working directory. Relative paths " +
27
28
  "resolve against it; a path outside it returns an `out_of_prefix` data " +
@@ -31,6 +32,13 @@ export function createWriteFileTool(deps) {
31
32
  // The path only. `content` is a whole file and must never ride a display
32
33
  // channel, which the emit-site length cap enforces regardless.
33
34
  describeCall: ({ path }) => path,
34
- execute: async ({ path, content }, ctx) => ok(await deps.mutator.writeFile({ path, content, toolName: "write_file", emit: ctx.emit })),
35
+ execute: async ({ path, content }, ctx) => ok(await deps.mutator.writeFile({
36
+ path,
37
+ content,
38
+ toolName: "write_file",
39
+ invocationId: ctx.invocationId,
40
+ runStep: ctx.runStep,
41
+ session: ctx.session,
42
+ })),
35
43
  });
36
44
  }
@@ -155,7 +155,7 @@ export declare function tailWritePrefix(args: {
155
155
  * in-sandbox absolute path. The tree is bind-mounted at `/{resourceId}`, so
156
156
  * the sandbox path is the resource id plus the root-relative tail — the host
157
157
  * location of the root never leaks into the container. Single source of truth
158
- * for the `execute_command` / `write_file` cwd and for the working directory
158
+ * for the `execute_command` cwd and for the working directory
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;
@@ -137,7 +137,7 @@ export function tailWritePrefix(args) {
137
137
  * in-sandbox absolute path. The tree is bind-mounted at `/{resourceId}`, so
138
138
  * the sandbox path is the resource id plus the root-relative tail — the host
139
139
  * location of the root never leaks into the container. Single source of truth
140
- * for the `execute_command` / `write_file` cwd and for the working directory
140
+ * for the `execute_command` cwd and for the working directory
141
141
  * a step briefing names (see the harness-workspace-tools spec).
142
142
  */
143
143
  export function toSandboxPath(workspaceRoot, resourceId, hostAbsPath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.33.1",
3
+ "version": "0.35.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",