blob: d431b7f2dda863428ba22e0d41d0e93acc8fcc08 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { promises as fs } from 'node:fs';
import { exec } from 'node:child_process';
import { globFiles } from './glob-search.js';
import { isAbsolute, resolve } from 'node:path';
import {
isPathInside,
realpathAllowMissing,
resolveCanonicalDirectoryEntryTarget,
} from './path-containment.js';
import { createPatchedFile, updatePatchedFile } from './apply-patch-file.js';
import {
compareAndDeleteEntry,
hostVisibilityAfterWrite,
openStableTarget,
writeThroughHandle,
} from './file-stable-write.js';
import { promisify } from 'node:util';
import type { ToolExecutionFacts } from '@maka/core/permission';
import {
currentRipgrepEnvironment,
RipgrepUnavailableError,
ripgrepMissingOnPathMessage,
} from './ripgrep-guidance.js';
import { runProcessWithBoundedTail, runShellWithBoundedTail } from './shell-exec.js';
import type { ChildFdInput } from './child-fd-input.js';
import type { ShellPlan } from './shell-detect.js';
import { isSupportedImagePath, readWorkspaceImage } from './image-file.js';
import type { ImageMimeType } from './image-file.js';
import { readTextLineWindow } from './text-line-window.js';
import { searchFiles, type GrepResult } from './grep-search.js';
import { defaultRipgrepCandidates, resolveRipgrepExecutable } from './ripgrep-executable.js';
const execAsync = promisify(exec);
export type WorkspaceExecutorFacts = ToolExecutionFacts;
export const LOCAL_WORKSPACE_EXECUTOR_FACTS: WorkspaceExecutorFacts = {
isolation: 'none',
writesAffectHost: true,
writeBack: 'direct',
network: 'host',
secrets: 'host_env',
};
export interface WorkspaceExecInput {
command: string;
/** Final executable argv. When provided, bypasses host-shell parsing. */
argv?: readonly string[];
env?: NodeJS.ProcessEnv;
fdInputs?: readonly ChildFdInput[];
cwd: string;
timeoutMs: number;
abortSignal?: AbortSignal;
emitOutput?: (stream: 'stdout' | 'stderr', chunk: string) => void;
/** Shell to run the command with. The local executor defaults to the process-wide detected shell. */
shell?: ShellPlan;
}
export interface WorkspaceExecResult {
stdout: string;
stderr: string;
exitCode: number;
stdoutTruncated?: boolean;
stderrTruncated?: boolean;
timedOut: boolean;
aborted: boolean;
}
export interface WorkspaceReadFileInput {
cwd: string;
path: string;
offset?: number;
limit?: number;
}
export interface WorkspaceReadTextResult {
content: string;
}
export interface WorkspaceReadImageResult {
bytes: Uint8Array;
mimeType: ImageMimeType;
}
export type WorkspaceReadFileResult = WorkspaceReadTextResult | WorkspaceReadImageResult;
export interface WorkspaceWriteFileInput {
cwd: string;
path: string;
content: string;
}
export interface WorkspaceWriteFileResult {
ok: boolean;
path: string;
bytes: number;
}
export type WorkspaceApplyPatchInput = WorkspaceResolvePathInput &
({ action: 'create' | 'update'; diff: string } | { action: 'delete' }) & {
/**
* Captured at lock acquisition; carried through to the compare-and-delete
* guard for delete (#2600). Optional so external callers are unaffected.
*/
approvedIdentity?: { dev: string; ino: string };
};
export interface WorkspaceApplyPatchResult {
ok: true;
path: string;
}
/**
* A read-modify-write pinned to one file descriptor, enforcing the
* filesystem-authority contract (#2600): the approved object is opened once,
* its identity validated on the descriptor, and the read/transform/write all
* run through that descriptor. The local executor implements this; a remote or
* isolated workspace cannot pin host descriptors and stays on the path-based
* readFile/writeFile fallback (documented as unprotected by the identity
* authority).
*/
export interface WorkspaceReadModifyWriteInput {
cwd: string;
path: string;
label: string;
scope: WorkspacePathScope;
/** Captured at lock acquisition; undefined for an approved-missing target. */
approvedIdentity?: { dev: string; ino: string };
/**
* Compute the new content from the pinned read. Return null to not write
* (e.g. invalid JSON in FormatJson) — nothing is modified.
*/
transform: (existing: { content: string | null; existed: boolean }) => string | null;
}
export interface WorkspaceReadModifyWriteResult {
path: string;
/** What the pinned read saw, for the caller's diff. */
previous: 'new' | 'unknown' | string;
/** The content that was (or would have been) written. */
finalContent: string | null;
written: boolean;
}
export interface WorkspaceReadModifyWriteExecutor {
readModifyWrite(input: WorkspaceReadModifyWriteInput): Promise<WorkspaceReadModifyWriteResult>;
}
/**
* Which path space a resolution may land in.
*
* `workspace` keeps the resolved path inside the session cwd; `host` accepts
* any canonical path on the host. This is a *mechanism* parameter: the caller
* decides it from the active ExecutionBoundary, so no executor has to carry a
* containment policy of its own. Executors whose transport cannot express host
* paths (a remote or isolated workspace) reject `host` explicitly.
*/
export type WorkspacePathScope = 'workspace' | 'host';
export interface WorkspaceResolvePathInput {
cwd: string;
path: string;
label: string;
scope: WorkspacePathScope;
}
export interface WorkspaceResolvePathResult {
path: string;
}
export interface WorkspaceWriteLockKeyInput {
cwd: string;
path: string;
semantics?: 'target' | 'entry';
}
export interface WorkspaceWriteLockKeyResult {
key: string;
}
export interface WorkspaceGlobInput {
cwd: string;
pattern: string;
limit?: number;
}
export interface WorkspaceGlobResult {
files: string[];
}
export interface WorkspaceGrepInput {
cwd: string;
pattern: string;
path: string;
glob?: string;
maxCountPerFile: number;
limit: number;
timeoutMs: number;
abortSignal?: AbortSignal;
}
export type WorkspaceGrepResult = GrepResult;
export interface WorkspaceExecutorFactsProvider {
readonly facts: WorkspaceExecutorFacts;
}
export interface WorkspaceCommandExecutor {
exec(input: WorkspaceExecInput): Promise<WorkspaceExecResult>;
}
export interface WorkspaceReadFileExecutor {
readFile(input: WorkspaceReadFileInput): Promise<WorkspaceReadFileResult>;
}
export interface WorkspaceWriteFileExecutor {
writeFile(input: WorkspaceWriteFileInput): Promise<WorkspaceWriteFileResult>;
}
export interface WorkspaceApplyPatchExecutor {
applyPatch(input: WorkspaceApplyPatchInput): Promise<WorkspaceApplyPatchResult>;
}
export interface WorkspaceExistingPathResolver {
resolveExistingPath(input: WorkspaceResolvePathInput): Promise<WorkspaceResolvePathResult>;
}
export interface WorkspaceWritablePathResolver {
resolveWritablePath(input: WorkspaceResolvePathInput): Promise<WorkspaceResolvePathResult>;
}
export interface WorkspaceWriteLockProvider {
writeLockKey(input: WorkspaceWriteLockKeyInput): Promise<WorkspaceWriteLockKeyResult>;
}
export interface WorkspaceGlobFilesExecutor {
globFiles(input: WorkspaceGlobInput): Promise<WorkspaceGlobResult>;
}
export interface WorkspaceGrepFilesExecutor {
grepFiles(input: WorkspaceGrepInput): Promise<WorkspaceGrepResult>;
}
export type WorkspaceBashExecutor = WorkspaceExecutorFactsProvider & WorkspaceCommandExecutor;
export type WorkspaceReadExecutor = WorkspaceExecutorFactsProvider &
WorkspaceExistingPathResolver &
WorkspaceReadFileExecutor;
export type WorkspaceWriteExecutor = WorkspaceExecutorFactsProvider &
WorkspaceWritablePathResolver &
WorkspaceWriteLockProvider &
WorkspaceWriteFileExecutor;
export type WorkspaceEditExecutor = WorkspaceExecutorFactsProvider &
WorkspaceExistingPathResolver &
WorkspaceWriteLockProvider &
WorkspaceReadFileExecutor &
WorkspaceWriteFileExecutor;
export type WorkspaceGlobExecutor = WorkspaceExecutorFactsProvider &
WorkspaceExistingPathResolver &
WorkspaceGlobFilesExecutor;
export type WorkspaceGrepExecutor = WorkspaceExecutorFactsProvider &
WorkspaceExistingPathResolver &
WorkspaceGrepFilesExecutor;
export type WorkspaceSearchExecutor = WorkspaceGlobExecutor & WorkspaceGrepExecutor;
export interface WorkspaceExecutor
extends WorkspaceBashExecutor,
WorkspaceReadExecutor,
WorkspaceWriteExecutor,
WorkspaceEditExecutor,
WorkspaceGlobExecutor,
WorkspaceGrepExecutor,
Partial<WorkspaceApplyPatchExecutor>,
Partial<WorkspaceReadModifyWriteExecutor> {}
/** @internal Test seams for deterministic executable-discovery coverage. */
export interface LocalWorkspaceExecutorInput {
platform?: NodeJS.Platform;
hostEnv?: NodeJS.ProcessEnv;
rgCandidates?: readonly string[];
}
export class LocalWorkspaceExecutor implements WorkspaceExecutor {
readonly facts = LOCAL_WORKSPACE_EXECUTOR_FACTS;
constructor(private readonly input: LocalWorkspaceExecutorInput = {}) {}
async exec(input: WorkspaceExecInput): Promise<WorkspaceExecResult> {
const options = {
cwd: input.cwd,
timeoutMs: input.timeoutMs,
...(input.env ? { env: input.env } : {}),
...(input.fdInputs ? { fdInputs: input.fdInputs } : {}),
...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
...(input.emitOutput ? { emitOutput: input.emitOutput } : {}),
...(input.shell ? { shell: input.shell } : {}),
};
const result = input.argv
? await runProcessWithBoundedTail(input.argv[0] ?? '', input.argv.slice(1), options)
: await runShellWithBoundedTail(input.command, options);
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.timedOut ? 124 : result.aborted ? 130 : result.exitCode,
stdoutTruncated: result.stdoutTruncated,
stderrTruncated: result.stderrTruncated,
timedOut: result.timedOut,
aborted: result.aborted,
};
}
async readFile(input: WorkspaceReadFileInput): Promise<WorkspaceReadFileResult> {
if (isSupportedImagePath(input.path)) {
return await readWorkspaceImage(input.path);
}
const content = await fs.readFile(input.path, 'utf8');
return { content: readTextLineWindow(content, input.offset, input.limit) };
}
async writeFile(input: WorkspaceWriteFileInput): Promise<WorkspaceWriteFileResult> {
await fs.writeFile(input.path, input.content, 'utf8');
return {
ok: true,
path: input.path,
bytes: Buffer.byteLength(input.content, 'utf8'),
};
}
async readModifyWrite(
input: WorkspaceReadModifyWriteInput,
): Promise<WorkspaceReadModifyWriteResult> {
// Pin the approved object (#2600): open once, validate the identity on the
// descriptor, read/transform/write through it. A path swap mid-operation
// cannot divert the bytes; a failed validation leaves the file untouched.
// Resolve into the same canonical path space as every other operation so
// the approved identity (captured on the canonical path) matches what we
// open; callers may pass either a raw or an already-resolved path.
const path = input.approvedIdentity
? await resolveExistingPathInScope(input.cwd, input.path, input.label, input.scope)
: (await canonicalPathInScope(input.cwd, input.path, input.label, input.scope)).path;
const handle = await openStableTarget({
path,
approvedIdentity: input.approvedIdentity,
});
try {
const existed = input.approvedIdentity !== undefined;
let previous: 'new' | 'unknown' | string;
let content: string | null = null;
if (!existed) {
previous = 'new'; // just created by the exclusive open
} else if (isSupportedImagePath(path)) {
// Binary/image targets are never read as text: the previous state is
// unknown and no diff may claim /dev/null.
previous = 'unknown';
} else {
try {
content = await handle.readFile('utf8');
previous = content;
} catch {
previous = 'unknown';
}
}
const replacement = input.transform({ content, existed });
if (replacement === null) {
return { path, previous, finalContent: null, written: false };
}
await writeThroughHandle(handle, replacement);
const visibility = await hostVisibilityAfterWrite(path, handle);
if (visibility) throw visibility;
return { path, previous, finalContent: replacement, written: true };
} finally {
await handle.close();
}
}
async applyPatch(input: WorkspaceApplyPatchInput): Promise<WorkspaceApplyPatchResult> {
if (input.action !== 'update') {
const path = await resolveDirectoryEntryPathInScope(
input.cwd,
input.path,
input.label,
input.scope,
);
if (input.action === 'create') await createPatchedFile(path, input.diff);
// Compare-and-delete (#2600): a replacement swapped in after the check
// is restored and reported, never silently deleted.
else
await compareAndDeleteEntry({
path,
approvedIdentity: input.approvedIdentity,
});
return { ok: true, path };
}
const path = await resolveExistingPathInScope(input.cwd, input.path, input.label, input.scope);
await updatePatchedFile(path, input.diff);
return { ok: true, path };
}
async resolveExistingPath(input: WorkspaceResolvePathInput): Promise<WorkspaceResolvePathResult> {
return {
path: await resolveExistingPathInScope(input.cwd, input.path, input.label, input.scope),
};
}
async resolveWritablePath(input: WorkspaceResolvePathInput): Promise<WorkspaceResolvePathResult> {
return {
path: (await canonicalPathInScope(input.cwd, input.path, input.label, input.scope)).path,
};
}
async writeLockKey(input: WorkspaceWriteLockKeyInput): Promise<WorkspaceWriteLockKeyResult> {
// The resolvers' canonicalisation without their containment check, so every
// spelling of one file — relative, absolute, or through a symlink — takes
// the same lock. Escapes are rejected by the resolvers inside the lock, not
// here. Sharing the canonicalisation is what keeps the lock-key space and
// the resolved-path space from drifting apart.
const path =
input.semantics === 'entry'
? (await resolveCanonicalDirectoryEntryTarget(input.cwd, input.path)).path
: (await canonicalPathUnderCwd(input.cwd, input.path)).path;
return { key: path };
}
async globFiles(input: WorkspaceGlobInput): Promise<WorkspaceGlobResult> {
return globFiles(input);
}
async grepFiles(input: WorkspaceGrepInput): Promise<WorkspaceGrepResult> {
const executable = await resolveRipgrepExecutable(
this.input.rgCandidates ??
defaultRipgrepCandidates(
this.input.hostEnv ?? process.env,
this.input.platform ?? process.platform,
),
);
try {
return await searchFiles({ ...input, executable: executable ?? 'rg' });
} catch (error: any) {
// Node reports a missing spawn cwd exactly like a missing executable
// (both `spawn rg ENOENT`), so only blame ripgrep once the cwd exists.
if (error?.code === 'ENOENT' && (await isDirectory(input.cwd)))
throw new RipgrepUnavailableError(
ripgrepMissingOnPathMessage(currentRipgrepEnvironment()),
{ cause: error },
);
throw error;
}
}
}
async function isDirectory(path: string): Promise<boolean> {
return await fs.stat(path).then(
(stat) => stat.isDirectory(),
() => false,
);
}
export function createLocalWorkspaceExecutor(): WorkspaceExecutor {
return new LocalWorkspaceExecutor();
}
/**
* Canonical session cwd and the canonical path `inputPath` names under it, with
* no containment check — the single place that decides which path space every
* caller works in. Both the root and the candidate are realpath'd, the candidate
* through its deepest existing ancestor since the target may not exist yet.
* Comparing a realpath'd root against a merely resolved candidate rejected every
* legitimate absolute path whenever the session cwd sat under a symlink — macOS
* tmpdirs (`/var` → `/private/var`) and symlinked workspace roots.
*/
async function canonicalPathUnderCwd(
cwd: string,
inputPath: string,
): Promise<{ root: string; path: string }> {
const root = await fs.realpath(cwd);
const requested = isAbsolute(inputPath) ? resolve(inputPath) : resolve(root, inputPath);
return { root, path: await realpathAllowMissing(requested) };
}
/**
* The same canonical path, rejected unless it stays inside the session cwd when
* the caller asked for `workspace` scope. Following the symlinks does not weaken
* containment: a link inside the cwd that points out of it resolves to its
* outside target and is rejected.
*
* Under `host` scope the canonicalisation is identical and only the containment
* assertion is skipped, so both scopes work in one path space and a scope change
* cannot move a path.
*/
async function canonicalPathInScope(
cwd: string,
inputPath: string,
label: string,
scope: WorkspacePathScope,
): Promise<{ root: string; path: string }> {
const { root, path } = await canonicalPathUnderCwd(cwd, inputPath);
if (scope === 'host') return { root, path };
return { root, path: assertInsideCwd(root, path, inputPath, label) };
}
async function resolveExistingPathInScope(
cwd: string,
inputPath: string,
label: string,
scope: WorkspacePathScope,
): Promise<string> {
const { root, path: candidate } = await canonicalPathInScope(cwd, inputPath, label, scope);
if (scope === 'host') return await fs.realpath(candidate);
// The read/search callers depend on the target existing; surface that here
// rather than as a downstream open/spawn failure.
//
// Do not drop the second assertion: it closes the window between the two
// awaits, where a segment that was missing during canonicalisation can become
// a symlink out of the cwd before the realpath runs. It is deliberately
// defence-in-depth and no deterministic test can drive that race, so nothing
// will fail if it is removed.
return assertInsideCwd(root, await fs.realpath(candidate), inputPath, label);
}
async function resolveDirectoryEntryPathInScope(
cwd: string,
inputPath: string,
label: string,
scope: WorkspacePathScope,
): Promise<string> {
const target = await resolveCanonicalDirectoryEntryTarget(cwd, inputPath);
return scope === 'host'
? target.path
: assertInsideCwd(target.root, target.path, inputPath, label);
}
function assertInsideCwd(
root: string,
candidate: string,
inputPath: string,
label: string,
): string {
if (!isPathInside(root, candidate)) {
throw new Error(
`${label} path must stay inside session cwd ${JSON.stringify(root)}; ` +
`received ${JSON.stringify(inputPath)}, which resolves to ${JSON.stringify(candidate)}.`,
);
}
return candidate;
}