@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.
- package/dist/agents/conversation-agent.d.ts +7 -3
- package/dist/agents/conversation-agent.js +20 -2
- package/dist/agents/sandbox/shared.d.ts +4 -9
- package/dist/agents/sandbox/shared.js +9 -12
- package/dist/config/environment-stores.d.ts +9 -6
- package/dist/execution/artifact-record.d.ts +9 -5
- package/dist/execution/artifact-record.js +3 -3
- package/dist/execution/reconcile-manifest.js +25 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +6 -0
- package/dist/prompts/sandbox-standards.d.ts +7 -7
- package/dist/prompts/sandbox-standards.js +6 -6
- package/dist/provenance/collector.js +1 -1
- package/dist/provenance/seam.d.ts +23 -2
- package/dist/provenance/types.d.ts +9 -4
- package/dist/provenance/types.js +7 -2
- package/dist/sandbox/client.d.ts +10 -1
- package/dist/sandbox/create-sandbox.d.ts +12 -0
- package/dist/sandbox/create-sandbox.js +45 -8
- package/dist/sandbox/docker-client.d.ts +11 -0
- package/dist/sandbox/docker-client.js +19 -5
- package/dist/sandbox/farm.d.ts +3 -1
- package/dist/sandbox/farm.js +3 -1
- package/dist/sandbox/image-packages.d.ts +107 -0
- package/dist/sandbox/image-packages.js +107 -0
- package/dist/sandbox/k8s-client.d.ts +11 -0
- package/dist/sandbox/k8s-client.js +20 -4
- package/dist/sandbox/sandbox-error.d.ts +40 -5
- package/dist/sandbox/sandbox-error.js +79 -5
- package/dist/tools/sandbox/list-available-packages.d.ts +18 -16
- package/dist/tools/sandbox/list-available-packages.js +90 -52
- package/dist/tools/workspace/edit-file.d.ts +32 -6
- package/dist/tools/workspace/edit-file.js +101 -15
- package/dist/tools/workspace/mutator.d.ts +68 -32
- package/dist/tools/workspace/mutator.js +203 -55
- package/dist/tools/workspace/run-exec.d.ts +5 -5
- package/dist/tools/workspace/run-exec.js +5 -5
- package/dist/tools/workspace/write-file.d.ts +4 -4
- package/dist/tools/workspace/write-file.js +14 -6
- package/dist/workspace/paths.d.ts +1 -1
- package/dist/workspace/paths.js +1 -1
- 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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* host `fs`
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
16
|
-
import type
|
|
17
|
-
|
|
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: "
|
|
43
|
+
} | {
|
|
44
|
+
readonly status: "symlink_denied";
|
|
32
45
|
readonly path: string;
|
|
33
|
-
}
|
|
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
|
|
74
|
-
* invoking tool so a successful write is attributed to it in
|
|
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
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* host `fs`
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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,
|
|
27
|
-
const
|
|
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 (
|
|
34
|
-
return { status:
|
|
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
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
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
|
-
// `
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
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(
|
|
73
|
-
size:
|
|
169
|
+
path: landed.relative,
|
|
170
|
+
hash: computeSha256(landed.bytes),
|
|
171
|
+
size: landed.bytes.length,
|
|
74
172
|
toolName,
|
|
75
|
-
|
|
173
|
+
invocationId,
|
|
76
174
|
});
|
|
77
175
|
}
|
|
78
|
-
return { status: "ok", path:
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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` —
|
|
3
|
-
*
|
|
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 +
|
|
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` —
|
|
3
|
-
*
|
|
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 +
|
|
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
|
-
//
|
|
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({
|
|
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`
|
|
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;
|
package/dist/workspace/paths.js
CHANGED
|
@@ -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`
|
|
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