Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/capabilities/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Capability access for tools (H1 — docs/PLAN_HARNESS_ALIGNMENT_v1.0.md §2).
*
* `ToolContext.caps` is optional so that the dozen existing places that build a
* ToolContext keep compiling and keep their current behaviour. Tools therefore
* never read `ctx.caps` directly — they go through `capsOf`, which supplies the
* local world when a caller has not chosen one. One import per tool, no
* `?? LOCAL` repeated at fourteen call sites where one could be forgotten.
*
* SCOPE (H1) — this seam covers the seven primitive fs/shell tools
* (read / write / edit / apply_patch / ls / grep / bash). The tools that spawn
* in the *workspace* through `src/tools/exec-util.ts` (run_checks,
* compare_agents, redeploy, dispatch_agent, and the repo/PR/review helpers)
* still use a raw `spawn` and do NOT pass through `caps.shell` yet. A sandbox
* provider swapped in at `ctx.caps` (H2) therefore bounds the primitives but
* not those — routing the exec-util family through the seam is the follow-up
* that makes "bound the world in one place" literally true. Until then, do not
* assume `capsOf` is the *only* path to the filesystem or a subprocess.
*/

import type { ToolContext } from "../types.js";
import { LOCAL_CAPABILITIES } from "./local.js";
import type { Capabilities } from "./types.js";

export function capsOf(ctx: ToolContext): Capabilities {
return ctx.caps ?? LOCAL_CAPABILITIES;
}

export { LOCAL_CAPABILITIES, localFs, localShell } from "./local.js";
export { createMemoryCapabilities, createMemoryFs, refusingShell } from "./memory.js";
export type {
Capabilities,
ExecOptions,
ExecResult,
FsCapability,
FsDirEntry,
FsStat,
ShellCapability,
} from "./types.js";
146 changes: 146 additions & 0 deletions src/capabilities/local.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* Local provider — the host's own filesystem and shell.
*
* This is the behaviour every fs/shell tool had inline before H1, moved behind
* the seam verbatim. It intentionally imposes no boundary of its own: bounding
* the world is H2's sandboxed provider, and conflating "how do I reach the
* disk" with "what am I allowed to reach" is what made the old code impossible
* to bound in one place.
*/

import fs from "node:fs/promises";
import path from "node:path";
import { spawn } from "node:child_process";
import { atomicWrite } from "../fs-utils.js";
import { defaultSandboxSpec, wrapForSandbox } from "../sandbox/sandbox.js";
import type {
Capabilities,
ExecOptions,
ExecResult,
FsCapability,
FsDirEntry,
FsStat,
ShellCapability,
} from "./types.js";

export const localFs: FsCapability = {
resolvePath(cwd: string, p: string): string {
return path.resolve(cwd, p);
},
async stat(abs: string): Promise<FsStat> {
const s = await fs.stat(abs);
return { isFile: s.isFile(), isDirectory: s.isDirectory(), size: s.size };
},
async exists(abs: string): Promise<boolean> {
try {
await fs.access(abs);
return true;
} catch {
return false;
}
},
async readFile(abs: string): Promise<string> {
return await fs.readFile(abs, "utf8");
},
async writeFile(abs: string, content: string): Promise<void> {
await atomicWrite(abs, content);
},
async readdir(abs: string): Promise<FsDirEntry[]> {
const entries = await fs.readdir(abs, { withFileTypes: true });
return entries.map((e) => ({
name: e.name,
isFile: e.isFile(),
isDirectory: e.isDirectory(),
}));
},
async unlink(abs: string): Promise<void> {
await fs.unlink(abs);
},
};

/**
* Shared child-process driver for both shell operations. Collects bounded
* output, enforces a timeout with SIGTERM→SIGKILL escalation, and resolves
* (rather than rejects) on a non-zero exit — a failing command is a result the
* model should see, not an exception.
*/
function runChild(
file: string,
args: string[],
opts: ExecOptions,
): Promise<ExecResult> {
const maxOutput = opts.maxOutputBytes ?? 64 * 1024;
return new Promise<ExecResult>((resolve, reject) => {
const child = spawn(file, args, {
cwd: opts.cwd,
env: process.env,
signal: opts.signal,
});
let stdout = "";
let stderr = "";
let truncated = false;
const onData = (buf: Buffer, target: "stdout" | "stderr") => {
const text = buf.toString("utf8");
if (target === "stdout") {
if (stdout.length + text.length > maxOutput) {
stdout += text.slice(0, maxOutput - stdout.length);
truncated = true;
} else {
stdout += text;
}
} else {
if (stderr.length + text.length > maxOutput) {
stderr += text.slice(0, maxOutput - stderr.length);
truncated = true;
} else {
stderr += text;
}
}
};
child.stdout.on("data", (b: Buffer) => onData(b, "stdout"));
child.stderr.on("data", (b: Buffer) => onData(b, "stderr"));

const timer = opts.timeoutMs
? setTimeout(() => {
child.kill("SIGTERM");
setTimeout(() => child.kill("SIGKILL"), 2000);
}, opts.timeoutMs)
: undefined;

child.on("error", (err) => {
if (timer) clearTimeout(timer);
reject(err);
});
child.on("close", (code, signal) => {
if (timer) clearTimeout(timer);
resolve({ stdout, stderr, code, signal, truncated });
});
});
}

export const localShell: ShellCapability = {
async run(command: string, opts: ExecOptions): Promise<ExecResult> {
// The opt-in LISA_SANDBOX wrapper moved here from the bash tool: which
// confinement a command runs under is a property of the execution world,
// not of the tool that asked. H2 replaces this with a real sandboxed
// provider (and closes the hole that fs writes never went through it);
// for now it is the previous behaviour, relocated unchanged.
const wrapped = await wrapForSandbox(
defaultSandboxSpec({ cwd: opts.cwd }),
command,
);
try {
return await runChild(wrapped.command, wrapped.args, opts);
} finally {
await wrapped.cleanup?.();
}
},
async exec(file: string, args: string[], opts: ExecOptions): Promise<ExecResult> {
return await runChild(file, args, opts);
},
};

export const LOCAL_CAPABILITIES: Capabilities = {
fs: localFs,
shell: localShell,
};
142 changes: 142 additions & 0 deletions src/capabilities/memory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* In-memory provider — proof that the seam is real, and a test fixture.
*
* Its value is not that tests get faster; it is that a second provider exists
* at all. If the fs/shell tools can run unmodified against a filesystem that is
* a `Map`, they can run against a container or a remote host, which is what
* Dispatch and Cloud need from H1. Every tool test that uses this instead of a
* tmpdir is also a test that the tool did not smuggle in a direct `node:fs`
* call behind the seam's back.
*
* The shell here refuses to run anything: an in-memory world has no processes.
* Refusing is deliberate — a stub that silently returned success would let a
* test pass while the real path was broken.
*/

import path from "node:path";
import type {
Capabilities,
ExecOptions,
ExecResult,
FsCapability,
FsDirEntry,
FsStat,
ShellCapability,
} from "./types.js";

export interface MemoryFsOptions {
/** Seed files, keyed by absolute path. */
files?: Record<string, string>;
/**
* Bound the world to this prefix. Paths resolving outside it throw — the
* same contract H2's sandboxed provider will implement against the OS.
*/
root?: string;
}

export interface MemoryFs extends FsCapability {
/** Current contents, keyed by absolute path — for assertions. */
snapshot(): Record<string, string>;
}

export function createMemoryFs(opts: MemoryFsOptions = {}): MemoryFs {
const files = new Map<string, string>(Object.entries(opts.files ?? {}));
const root = opts.root;

const dirsOf = (): Set<string> => {
const dirs = new Set<string>();
for (const file of files.keys()) {
let dir = path.dirname(file);
while (dir && dir !== path.dirname(dir)) {
dirs.add(dir);
dir = path.dirname(dir);
}
}
return dirs;
};

const mustExist = (abs: string): string => {
const content = files.get(abs);
if (content === undefined) {
const err = new Error(`ENOENT: no such file or directory, open '${abs}'`);
(err as NodeJS.ErrnoException).code = "ENOENT";
throw err;
}
return content;
};

return {
resolvePath(cwd: string, p: string): string {
const abs = path.resolve(cwd, p);
if (root && abs !== root && !abs.startsWith(root + path.sep)) {
throw new Error(`path escapes the workspace root: ${abs} (root ${root})`);
}
return abs;
},
async stat(abs: string): Promise<FsStat> {
const content = files.get(abs);
if (content !== undefined) {
return {
isFile: true,
isDirectory: false,
size: Buffer.byteLength(content, "utf8"),
};
}
if (dirsOf().has(abs)) {
return { isFile: false, isDirectory: true, size: 0 };
}
const err = new Error(`ENOENT: no such file or directory, stat '${abs}'`);
(err as NodeJS.ErrnoException).code = "ENOENT";
throw err;
},
async exists(abs: string): Promise<boolean> {
return files.has(abs) || dirsOf().has(abs);
},
async readFile(abs: string): Promise<string> {
return mustExist(abs);
},
async writeFile(abs: string, content: string): Promise<void> {
files.set(abs, content);
},
async readdir(abs: string): Promise<FsDirEntry[]> {
const prefix = abs.endsWith(path.sep) ? abs : abs + path.sep;
const seen = new Map<string, FsDirEntry>();
for (const file of files.keys()) {
if (!file.startsWith(prefix)) continue;
const rest = file.slice(prefix.length);
const head = rest.split(path.sep)[0]!;
const isFile = !rest.includes(path.sep);
seen.set(head, { name: head, isFile, isDirectory: !isFile });
}
if (seen.size === 0 && !dirsOf().has(abs)) {
const err = new Error(`ENOENT: no such file or directory, scandir '${abs}'`);
(err as NodeJS.ErrnoException).code = "ENOENT";
throw err;
}
return [...seen.values()];
},
async unlink(abs: string): Promise<void> {
mustExist(abs);
files.delete(abs);
},
snapshot(): Record<string, string> {
return Object.fromEntries(files);
},
};
}

/** A shell that has no processes to run, and says so instead of pretending. */
export const refusingShell: ShellCapability = {
async run(_command: string, _opts: ExecOptions): Promise<ExecResult> {
throw new Error("shell is unavailable in this execution world");
},
async exec(_file: string, _args: string[], _opts: ExecOptions): Promise<ExecResult> {
throw new Error("shell is unavailable in this execution world");
},
};

export function createMemoryCapabilities(
opts: MemoryFsOptions = {},
): Capabilities & { fs: MemoryFs } {
return { fs: createMemoryFs(opts), shell: refusingShell };
}
Loading