/**
* The render's REAL per-node working directory. This is the Codex-style
* affordance: a render mounts against a real directory on disk, writes real
* files there with `fs_*` / `shell_exec` / `apply_patch`, and the harness
* HARVESTS that directory → (compiled, deterministic canonicalizer, at commit) →
* published truth + FingerprintMap → signed receipt. The harvest reads a
* directory instead of the store's virtual workspace map.
*
* ===========================================================================
* SANDBOX LIMITATION — READ BEFORE EXTENDING
* ===========================================================================
* This is a SCOPED working directory with PATH-ESCAPE GUARDS only. It is NOT an
* OS sandbox. The `fs_*` tools reject any path that resolves outside the root,
* and `shell_exec` runs with `cwd` set to the root — but a shell command can
* still `cd /`, read/write anywhere the process user can, and reach the network.
* The turn/cost backstop is the only other bound.
*
* - This is acceptable for TRUSTED, self-authored `.prose` projects and the
* repo's own live tests.
* - It is NOT safe for UNTRUSTED contract sets executing arbitrary shell. Do
* NOT claim isolation we do not have.
* - The OS sandbox (seatbelt on macOS / landlock on Linux, or the SDK's
* Docker/Unix-local sandbox infra, or delegating to the real Codex via
* `codexTool()`) is deferred, to be gated when an untrusted-execution use
* case is real. Until then, callers MUST treat the contract set as trusted.
*
* Determinism boundary: the directory harvest below is a PLAIN, NON-MODEL
* `node:fs` walk. No model call enters it. The canonicalizer the harness applies
* on commit stays the deterministic op it always was; this module only changes
* WHERE the pre-commit files come from (a real dir), not how they are
* fingerprinted.
*
* Offline-build guard: this module imports ONLY `node:*` + the world-model
* canonical path helpers. It does NOT import `@openai/agents` or `zod`, so it is
* safe for the adapter's keyless construction path (the working dir is prepared
* before any model call).
*/
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { dirname, join, relative, resolve, sep } from "node:path";
import {
normalizeArtifactPath,
type WorldModelFiles,
} from "../../world-model";
/**
* Encode an arbitrary node identity into a single filesystem-safe path segment —
* lowercase hex of its UTF-8 bytes. Identical convention to the FS store's
* `nodeSegment` (fs-store.ts) so a node's working dir and its store truth use
* the same collision-free, case-insensitive-FS-safe segment.
*/
export function workingDirSegment(node: string): string {
let hex = "";
const bytes = new TextEncoder().encode(node);
for (const b of bytes) {
hex += b.toString(16).padStart(2, "0");
}
return hex;
}
/**
* Resolve the per-node working ROOT under a base directory: `//`.
* Absolute, normalized.
*/
export function nodeWorkingRoot(base: string, node: string): string {
return resolve(base, workingDirSegment(node));
}
/**
* Resolve a model-supplied RELATIVE path against the working root, REJECTING any
* path that escapes the root (the path-escape guard). Returns the absolute path
* on success; throws a legible `WorkingDirEscapeError` otherwise.
*
* The guard is belt-and-suspenders: it (1) rejects absolute inputs, (2) runs the
* world-model path normalizer (which already rejects `..` and leading `/`), and
* (3) confirms the fully-resolved absolute path is still inside the root after
* symlink-agnostic resolution — so neither `..`, an absolute path, nor a
* separator trick can reach outside the per-node dir.
*/
export function resolveWithinRoot(root: string, relPath: string): string {
if (typeof relPath !== "string" || relPath.length === 0) {
throw new WorkingDirEscapeError(relPath, "path must be a non-empty string");
}
// An absolute path is an immediate escape — reject before normalization.
if (relPath.startsWith("/") || relPath.startsWith("\\")) {
throw new WorkingDirEscapeError(relPath, "absolute paths are not allowed");
}
// Reuse the world-model path normalizer: it strips `.`/dup-slash and THROWS on
// any `..` segment or empty result — the same invariant the artifact obeys.
// Wrap its TypeError as a WorkingDirEscapeError so every guard rejection is one
// legible, catchable error type.
let normalized: string;
try {
normalized = normalizeArtifactPath(relPath);
} catch (error) {
throw new WorkingDirEscapeError(
relPath,
error instanceof Error ? error.message : "invalid path",
);
}
const absolute = resolve(root, normalized);
// Final containment check: the resolved path must be the root itself or a
// descendant. `relative(root, absolute)` is empty (root), or a path that does
// NOT start with `..` and is NOT absolute, iff `absolute` is within `root`.
const rel = relative(root, absolute);
if (rel.startsWith("..") || rel.startsWith(`..${sep}`) || resolve(root, rel) !== absolute) {
throw new WorkingDirEscapeError(relPath, "path escapes the working directory");
}
return absolute;
}
/** Thrown when a model-supplied path would escape the per-node working root. */
export class WorkingDirEscapeError extends Error {
constructor(
readonly attemptedPath: string,
reason: string,
) {
super(
`working-directory path '${String(attemptedPath)}' rejected: ${reason}`,
);
this.name = "WorkingDirEscapeError";
}
}
/**
* Prepare a node's working directory for a render: ensure `//`
* exists and is EMPTY of prior render scratch, then SEED it with the node's prior
* published files (so the agent reads its prior truth via the same `fs_read`,
* folding progressive disclosure into one mechanism). Returns the absolute
* working root.
*
* Seeding the prior published truth (not the prior workspace) keeps the
* read-by-reference discipline honest: the directory IS the node's current truth
* at render start; the agent edits it in place and the harness harvests the
* result. A cold-start node gets an empty dir.
*/
export function prepareWorkingDir(
base: string,
node: string,
priorPublished: WorldModelFiles,
): string {
const root = nodeWorkingRoot(base, node);
// A fresh, deterministic starting point each render: clear any leftover scratch
// from a prior render of this node, then recreate the dir.
if (existsSync(root)) {
rmSync(root, { recursive: true, force: true });
}
mkdirSync(root, { recursive: true });
for (const rawPath of Object.keys(priorPublished)) {
const normalized = normalizeArtifactPath(rawPath);
const absolute = resolveWithinRoot(root, normalized);
mkdirSync(dirname(absolute), { recursive: true });
writeFileSync(absolute, priorPublished[rawPath]!);
}
return root;
}
/**
* HARVEST the working directory into a `WorldModelFiles` map: a plain,
* deterministic `node:fs` walk — NO model call. Every regular file under the
* root becomes one entry keyed by its POSIX-normalized relative path; directories
* are recursed; symlinks are skipped (we never follow a link out of the root).
* The returned map is exactly what the harness promotes-and-fingerprints, so this
* function IS the harvest seam (replacing `store.read(node,"workspace")`).
*/
export function harvestDirectory(root: string): WorldModelFiles {
const out: Record = {};
if (!existsSync(root)) {
return out;
}
walk(root, root, out);
return out;
}
function walk(
root: string,
dir: string,
out: Record,
): void {
for (const name of readdirSync(dir).sort()) {
const absolute = join(dir, name);
// `lstat` semantics: do NOT follow symlinks (a link could point outside the
// root; harvesting only real files keeps the directory the truth).
const stat = statSync(absolute, { throwIfNoEntry: false });
if (stat === undefined) {
continue;
}
if (stat.isDirectory()) {
walk(root, absolute, out);
continue;
}
if (!stat.isFile()) {
continue;
}
const rel = relative(root, absolute).split(sep).join("/");
// Normalize through the artifact path rules so the harvested map obeys the
// same invariants a `commitPublished` expects (rejects nothing a real file
// under the root could be — `..` is impossible by construction here).
out[normalizeArtifactPath(rel)] = toUint8(readFileSync(absolute));
}
}
export function toUint8(buf: Uint8Array): Uint8Array {
// Node's readFileSync returns a Buffer; copy into a plain Uint8Array so the
// bytes are implementation-agnostic downstream (matches fs-store.ts).
return Uint8Array.prototype.slice.call(buf);
}