| /* |
| * 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 { searchFiles, GrepSearchError, type GrepRunner } from '../grep-search.js'; |
| import { promises as fs } from 'node:fs'; |
| import { globFiles } from '../glob-search.js'; |
| import { dirname, isAbsolute, parse, resolve } from 'node:path'; |
| import { isPathInside } from '../path-containment.js'; |
| import { |
| ripgrepMissingMessage, |
| ripgrepVanishedMessage, |
| type RipgrepEnvironment, |
| } from '../ripgrep-guidance.js'; |
| import { sandboxPathApi } from './sandbox-paths.js'; |
| import { sandboxBoundaryExpansionAllowsPath } from '@maka/core/sandbox-boundary'; |
| import { |
| ApplyPatchRejectedError, |
| applyUpdateToContent, |
| createPatchedFile, |
| } from '../apply-patch-file.js'; |
| |
| import { computeEditedSource } from '../edit-replace.js'; |
| import { readPage } from '../read-page.js'; |
| import { createEditUnifiedDiff, createUnifiedDiff } from '../unified-diff.js'; |
| import { |
| compareAndDeleteEntry, |
| hostVisibilityAfterWrite, |
| openStableTarget, |
| readModifyWriteThroughHandle, |
| StableWriteFailure, |
| writeThroughHandle, |
| } from '../file-stable-write.js'; |
| import { isSupportedImagePath, readWorkspaceImage } from '../image-file.js'; |
| import { |
| FILESYSTEM_WORKER_PROTOCOL_VERSION, |
| operationAccess, |
| operationUsesDirectoryEntry, |
| type FilesystemWorkerErrorCode, |
| type FilesystemWorkerOperation, |
| type FilesystemWorkerRequest, |
| type FilesystemWorkerResponse, |
| type FilesystemWorkerResult, |
| type FilesystemWorkerTarget, |
| } from './protocol.js'; |
| import { isLikelySandboxDenial } from '../sandbox/detect.js'; |
| |
| // Canonicalisation must match the sandbox the worker runs in: realpath-based |
| // on POSIX, lexical + reparse-rejecting inside the Windows AppContainer where |
| // realpath is denied. See sandbox-paths.ts. |
| const { realpath, realpathAllowMissing, resolveCanonicalDirectoryEntryTarget } = sandboxPathApi(); |
| |
| export interface FilesystemWorkerOperationDependencies { |
| grepExecutable?: string; |
| /** Where this worker runs, as the Host observed it; names the install location in Grep's guidance. */ |
| ripgrepEnvironment?: RipgrepEnvironment; |
| runGrep?: GrepRunner; |
| /** Set when the worker runs inside the Windows AppContainer sandbox. */ |
| windowsSandboxed?: boolean; |
| } |
| |
| export async function executeFilesystemWorkerRequest( |
| request: FilesystemWorkerRequest, |
| dependencies: FilesystemWorkerOperationDependencies = {}, |
| ): Promise<FilesystemWorkerResponse> { |
| try { |
| await assertTargetUnchanged( |
| request.operation.cwd, |
| request.operation.path, |
| request.expectedTarget, |
| operationUsesDirectoryEntry(request.operation), |
| operationAccess(request.operation.kind), |
| ); |
| return { |
| version: FILESYSTEM_WORKER_PROTOCOL_VERSION, |
| requestId: request.requestId, |
| ok: true, |
| result: await executeFilesystemOperation( |
| request.operation, |
| request.operationBoundary, |
| dependencies, |
| request.expectedTarget, |
| ), |
| }; |
| } catch (error) { |
| const normalized = normalizeOperationError(error); |
| return { |
| version: FILESYSTEM_WORKER_PROTOCOL_VERSION, |
| requestId: request.requestId, |
| ok: false, |
| error: { code: normalized.code, message: normalized.message }, |
| }; |
| } |
| } |
| |
| export async function executeFilesystemOperation( |
| operation: FilesystemWorkerOperation, |
| operationBoundary: FilesystemWorkerRequest['operationBoundary'], |
| dependencies: FilesystemWorkerOperationDependencies = {}, |
| expectedTarget?: FilesystemWorkerTarget, |
| ): Promise<FilesystemWorkerResult> { |
| switch (operation.kind) { |
| case 'read': { |
| const path = await resolveExistingAllowed( |
| operation.cwd, |
| operation.path, |
| 'Read', |
| 'read', |
| operationBoundary, |
| ); |
| if (isSupportedImagePath(path)) { |
| try { |
| const image = await readWorkspaceImage(path); |
| return { |
| kind: 'read_image', |
| base64: Buffer.from(image.bytes).toString('base64'), |
| mimeType: image.mimeType, |
| }; |
| } catch (error) { |
| throw operationError( |
| 'filesystem_error', |
| error instanceof Error ? error.message : 'Image could not be read.', |
| ); |
| } |
| } |
| const content = await fs.readFile(path, 'utf8'); |
| try { |
| return { kind: 'read', ...readPage(content, operation, undefined, operation.continuation) }; |
| } catch (error) { |
| throw operationError( |
| 'invalid_request', |
| error instanceof Error |
| ? error.message |
| : 'Read pagination failed. Use the original path again.', |
| ); |
| } |
| } |
| case 'write': { |
| const path = await resolveWritableAllowed( |
| operation.cwd, |
| operation.path, |
| 'Write', |
| operationBoundary, |
| ); |
| // Pin the approved object (#2600): open once, validate the identity on |
| // the descriptor, and write through that descriptor — a path swap between |
| // validation and the write cannot divert the bytes onto the replacement. |
| // An approved-missing target is created exclusively; anything that |
| // appeared in the gap is `path_changed`, never truncated. |
| const handle = await openStableTarget({ |
| path, |
| approvedIdentity: |
| typeof expectedTarget?.identity === 'object' ? expectedTarget.identity : undefined, |
| targetType: expectedTarget?.targetType, |
| }); |
| try { |
| // Read-before-write (for the diff): only through the pinned descriptor. |
| // An approved-missing target was just created by 'wx', so it is new. |
| // The wire identity is three-state (#3484): 'missing' is a truthy |
| // string, so a truthiness test can no longer stand in for "the target |
| // was approved as missing" — targetType is the authority here. |
| let previous: 'new' | 'unknown' | string; |
| if (expectedTarget?.targetType === 'missing') { |
| previous = 'new'; |
| } else { |
| try { |
| previous = await handle.readFile('utf8'); |
| } catch { |
| previous = 'unknown'; |
| } |
| } |
| await writeThroughHandle(handle, operation.content); |
| // Host visibility: if the path no longer resolves to the pinned inode, |
| // the bytes went to an orphan and the visible file is the replacement. |
| const visibility = await hostVisibilityAfterWrite(path, handle); |
| if (visibility) throw visibility; |
| const diff = |
| previous === 'unknown' |
| ? undefined |
| : createUnifiedDiff(path, previous === 'new' ? undefined : previous, operation.content); |
| return { |
| kind: 'write', |
| ok: true, |
| path, |
| bytes: Buffer.byteLength(operation.content, 'utf8'), |
| ...(diff !== undefined ? { diff } : {}), |
| }; |
| } finally { |
| await handle.close(); |
| } |
| } |
| case 'apply_patch': { |
| if (operation.action !== 'update') { |
| const path = await resolveDirectoryEntryAllowed( |
| operation.cwd, |
| operation.path, |
| operation.action === 'create' ? 'ApplyPatch create' : 'ApplyPatch delete', |
| operationBoundary, |
| ); |
| if (operation.action === 'create') await createPatchedFile(path, operation.diff); |
| // Compare-and-delete (#2600): rename the entry to a tombstone, |
| // verify the approved identity, then unlink — a replacement swapped |
| // in after the check is restored and reported, never silently |
| // deleted. |
| else |
| await compareAndDeleteEntry({ |
| path, |
| approvedIdentity: |
| typeof expectedTarget?.identity === 'object' ? expectedTarget.identity : undefined, |
| }); |
| return { kind: 'apply_patch', ok: true, path }; |
| } |
| const path = await resolveExistingAllowed( |
| operation.cwd, |
| operation.path, |
| 'ApplyPatch update', |
| 'write', |
| operationBoundary, |
| ); |
| // Pin the target and apply the diff through one descriptor (#2600); a |
| // rejected patch propagates before any truncation, so the file is intact. |
| const handle = await openStableTarget({ |
| path, |
| approvedIdentity: |
| typeof expectedTarget?.identity === 'object' ? expectedTarget.identity : undefined, |
| targetType: expectedTarget?.targetType, |
| }); |
| try { |
| await readModifyWriteThroughHandle(handle, (existing) => |
| applyUpdateToContent(existing, operation.diff), |
| ); |
| const visibility = await hostVisibilityAfterWrite(path, handle); |
| if (visibility) throw visibility; |
| } finally { |
| await handle.close(); |
| } |
| return { kind: 'apply_patch', ok: true, path }; |
| } |
| case 'edit': { |
| const path = await resolveExistingAllowed( |
| operation.cwd, |
| operation.path, |
| 'Edit', |
| 'write', |
| operationBoundary, |
| ); |
| const handle = await openStableTarget({ |
| path, |
| approvedIdentity: |
| typeof expectedTarget?.identity === 'object' ? expectedTarget.identity : undefined, |
| targetType: expectedTarget?.targetType, |
| }); |
| try { |
| const content = await handle.readFile('utf8'); |
| let source: ReturnType<typeof computeEditedSource>; |
| try { |
| source = computeEditedSource( |
| content, |
| operation.oldString, |
| operation.newString, |
| operation.path, |
| ); |
| } catch (error) { |
| throw operationError( |
| 'edit_conflict', |
| error instanceof Error ? error.message : 'Edit could not be applied.', |
| ); |
| } |
| await writeThroughHandle(handle, source.content); |
| const visibility = await hostVisibilityAfterWrite(path, handle); |
| if (visibility) throw visibility; |
| const diff = createEditUnifiedDiff(path, content, source.content, source); |
| return { |
| kind: 'edit', |
| ok: true, |
| path, |
| replacements: 1, |
| matchedVia: source.matchedVia, |
| startLine: source.startLine, |
| endLine: source.endLine, |
| ...(diff !== undefined ? { diff } : {}), |
| }; |
| } finally { |
| await handle.close(); |
| } |
| } |
| case 'format_json': { |
| const path = await resolveExistingAllowed( |
| operation.cwd, |
| operation.path, |
| 'FormatJson', |
| 'write', |
| operationBoundary, |
| ); |
| const handle = await openStableTarget({ |
| path, |
| approvedIdentity: |
| typeof expectedTarget?.identity === 'object' ? expectedTarget.identity : undefined, |
| targetType: expectedTarget?.targetType, |
| }); |
| try { |
| const original = await handle.readFile('utf8'); |
| const bytesBefore = Buffer.byteLength(original, 'utf8'); |
| let parsed: unknown; |
| try { |
| parsed = JSON.parse(original); |
| } catch (error) { |
| // Invalid JSON: return the structured failure without writing. |
| return { |
| kind: 'format_json', |
| ok: false, |
| valid: false, |
| path, |
| error: `FormatJson: invalid JSON: ${error instanceof Error ? error.message : 'parse failed'}`, |
| bytesBefore, |
| byteDelta: 0, |
| changed: false, |
| }; |
| } |
| const formatted = JSON.stringify( |
| operation.sortKeys ? sortKeysDeep(parsed) : parsed, |
| null, |
| 2, |
| ); |
| if (formatted !== original) { |
| await writeThroughHandle(handle, formatted); |
| } |
| const visibility = await hostVisibilityAfterWrite(path, handle); |
| if (visibility) throw visibility; |
| const bytesAfter = Buffer.byteLength(formatted, 'utf8'); |
| const diff = |
| formatted === original ? undefined : createUnifiedDiff(path, original, formatted); |
| return { |
| kind: 'format_json', |
| ok: true, |
| valid: true, |
| path, |
| bytesBefore, |
| bytesAfter, |
| byteDelta: bytesAfter - bytesBefore, |
| changed: formatted !== original, |
| ...(diff !== undefined ? { diff } : {}), |
| }; |
| } finally { |
| await handle.close(); |
| } |
| } |
| case 'glob': { |
| assertContainedGlobPattern(operation.pattern); |
| const path = await resolveExistingAllowed( |
| operation.cwd, |
| operation.path, |
| 'Glob cwd', |
| 'read', |
| operationBoundary, |
| ); |
| return { kind: 'glob', ...(await globFiles({ ...operation, cwd: path })) }; |
| } |
| case 'grep': { |
| const path = await resolveExistingAllowed( |
| operation.cwd, |
| operation.path, |
| 'Grep', |
| 'read', |
| operationBoundary, |
| ); |
| // The Windows AppContainer cannot create grandchild processes (the |
| // desktop object is not granted to the container SID), so ripgrep |
| // cannot run there — and no in-process substitute preserves Grep's |
| // advertised regex/ripgrep contract (pattern dialect, gitignore |
| // filtering, glob and truncation behavior). The Windows sandbox |
| // preview therefore does not expose Grep: failing closed keeps the |
| // public contract honest until a contract-preserving search engine |
| // exists. Glob and Read remain available; an unsandboxed Windows |
| // worker never carries this marker and keeps full ripgrep behavior. |
| if (dependencies.windowsSandboxed) { |
| throw operationError( |
| 'grep_unavailable', |
| 'Grep is not available inside the Windows sandbox preview; use Glob and Read instead.', |
| ); |
| } |
| const grepExecutable = dependencies.grepExecutable; |
| if (!grepExecutable) |
| throw operationError( |
| 'grep_unavailable', |
| ripgrepMissingMessage(dependencies.ripgrepEnvironment), |
| ); |
| const result = await searchFiles( |
| { |
| ...operation, |
| path, |
| executable: grepExecutable, |
| // The target is canonical and absolute. Running from its filesystem root avoids |
| // requiring operation-scoped workers to read the broader session workspace. |
| cwd: parse(path).root, |
| timeoutMs: operation.timeoutMs, |
| }, |
| dependencies.runGrep, |
| ).catch((error: unknown) => { |
| // The cwd is a filesystem root, which always exists, so a spawn ENOENT |
| // means the executable this worker was launched with is gone — removed |
| // after the launch configuration checked it. Left alone it would be |
| // normalized to `not_found` and read as a missing search path. |
| if (nodeErrorCode(error) === 'ENOENT') |
| throw operationError( |
| 'grep_unavailable', |
| ripgrepVanishedMessage(grepExecutable, dependencies.ripgrepEnvironment), |
| ); |
| if (error instanceof GrepSearchError) { |
| throw operationError( |
| isLikelySandboxDenial({ stdout: '', stderr: error.message, sandboxed: true }) |
| ? 'sandbox_denied' |
| : 'filesystem_error', |
| error.message, |
| ); |
| } |
| throw error; |
| }); |
| return { kind: 'grep', ...result }; |
| } |
| } |
| } |
| |
| class FilesystemOperationError extends Error { |
| constructor( |
| readonly code: FilesystemWorkerErrorCode, |
| message: string, |
| ) { |
| super(message); |
| this.name = 'FilesystemOperationError'; |
| } |
| } |
| |
| function operationError( |
| code: FilesystemWorkerErrorCode, |
| message: string, |
| ): FilesystemOperationError { |
| return new FilesystemOperationError(code, message); |
| } |
| |
| function sortKeysDeep(value: unknown): unknown { |
| if (Array.isArray(value)) return value.map(sortKeysDeep); |
| if (value !== null && typeof value === 'object' && !(value instanceof Date)) { |
| return Object.fromEntries( |
| Object.keys(value) |
| .sort() |
| .map((key) => [key, sortKeysDeep((value as Record<string, unknown>)[key])]), |
| ); |
| } |
| return value; |
| } |
| |
| function normalizeOperationError(error: unknown): FilesystemOperationError { |
| if (error instanceof FilesystemOperationError) return error; |
| if (error instanceof StableWriteFailure) { |
| return operationError(error.code, error.message); |
| } |
| if (error instanceof ApplyPatchRejectedError) { |
| return operationError('edit_conflict', error.message); |
| } |
| const code = nodeErrorCode(error); |
| if (code === 'ENOENT' || code === 'ENOTDIR') |
| return operationError('not_found', 'The requested path was not found.'); |
| if (code === 'EACCES' || code === 'EPERM') |
| return operationError('filesystem_denied', 'Filesystem access was denied.'); |
| return operationError('filesystem_error', 'Filesystem operation failed.'); |
| } |
| |
| async function assertTargetUnchanged( |
| cwd: string, |
| path: string, |
| expected: FilesystemWorkerTarget, |
| noFollowFinalSymlink = false, |
| access: 'read' | 'write' = 'read', |
| ): Promise<void> { |
| const enforcementPath = noFollowFinalSymlink |
| ? (await resolveCanonicalDirectoryEntryTarget(cwd, path)).path |
| : await realpathAllowMissing(path); |
| const targetType = noFollowFinalSymlink |
| ? await lstatTargetTypeOf(enforcementPath) |
| : await targetTypeOf(enforcementPath); |
| if (enforcementPath !== expected.enforcementPath || targetType !== expected.targetType) { |
| throw operationError( |
| 'path_changed', |
| 'The approved filesystem target changed before execution.', |
| ); |
| } |
| // Compare the on-disk identity against the one captured at authorisation |
| // time. This is the load-bearing check for the queue window: a path swapped |
| // while the call waited for the lock has a different inode even when its |
| // canonical path and type still match. |
| // |
| // The wire carries one required three-state identity contract (#3484): |
| // - { dev, ino }: CAS against the on-disk inode. |
| // - 'missing': T0 saw no target but T1 does — something created it while |
| // this call waited. Writing would clobber content the caller never saw. |
| // - 'unchecked': the caller deliberately does not participate in CAS. |
| // Reads never mutate and are exempt either way. |
| if (access === 'write' && expected.targetType !== 'missing') { |
| if (expected.identity === 'missing') { |
| throw operationError( |
| 'path_changed', |
| 'The target was created while this call waited for the lock; re-read before writing.', |
| ); |
| } |
| if (typeof expected.identity === 'object') { |
| const metadata = noFollowFinalSymlink |
| ? await fs.lstat(enforcementPath, { bigint: true }) |
| : await fs.stat(enforcementPath, { bigint: true }); |
| if ( |
| String(metadata.dev) !== expected.identity.dev || |
| String(metadata.ino) !== expected.identity.ino |
| ) { |
| throw operationError( |
| 'path_changed', |
| 'The approved filesystem target changed before execution.', |
| ); |
| } |
| } |
| // identity === 'unchecked': nothing to compare, nothing to fail. |
| } |
| } |
| |
| async function resolveWritableAllowed( |
| cwd: string, |
| inputPath: string, |
| label: string, |
| permission: FilesystemWorkerRequest['operationBoundary'], |
| ): Promise<string> { |
| const { root, candidate } = await resolveCandidate(cwd, inputPath, label, 'write', permission); |
| try { |
| const target = await realpath(candidate); |
| assertAllowed(root, target, label, 'write', permission); |
| return target; |
| } catch (error) { |
| if (nodeErrorCode(error) !== 'ENOENT') throw error; |
| } |
| // The target does not exist, but it can still be a dangling symlink, and a |
| // write lands on what the link names rather than on the link. Authorise the |
| // followed path — the same one `assertTargetUnchanged` pins the request to — |
| // so the worker enforces its own boundary instead of trusting the caller to |
| // have canonicalised the path for it. |
| const followed = await realpathAllowMissing(candidate); |
| const parent = await realpath(dirname(followed)); |
| assertAllowed(root, followed, label, 'write', permission); |
| if (!isPathInside(root, parent) && !exactWriteCoversParent(permission, followed, parent)) { |
| throw operationError( |
| 'path_denied', |
| `${label} parent was not covered by the operation boundary.`, |
| ); |
| } |
| return followed; |
| } |
| |
| async function resolveDirectoryEntryAllowed( |
| cwd: string, |
| inputPath: string, |
| label: string, |
| permission: FilesystemWorkerRequest['operationBoundary'], |
| ): Promise<string> { |
| const target = await resolveCanonicalDirectoryEntryTarget(cwd, inputPath); |
| assertAllowed(target.root, target.path, label, 'write', permission); |
| return target.path; |
| } |
| |
| async function resolveExistingAllowed( |
| cwd: string, |
| inputPath: string, |
| label: string, |
| access: 'read' | 'write', |
| permission: FilesystemWorkerRequest['operationBoundary'], |
| ): Promise<string> { |
| const { root, candidate } = await resolveCandidate(cwd, inputPath, label, access, permission); |
| const target = await realpath(candidate); |
| assertAllowed(root, target, label, access, permission); |
| return target; |
| } |
| |
| async function resolveCandidate( |
| cwd: string, |
| inputPath: string, |
| label: string, |
| access: 'read' | 'write', |
| permission: FilesystemWorkerRequest['operationBoundary'], |
| ): Promise<{ root: string; candidate: string }> { |
| const root = await realpath(cwd); |
| const candidate = resolve(root, inputPath); |
| if ( |
| !isPathInside(root, candidate) && |
| !sandboxBoundaryExpansionAllowsPath(permission, candidate, access) |
| ) { |
| throw operationError('path_denied', `${label} path was not covered by the operation boundary.`); |
| } |
| return { root, candidate }; |
| } |
| |
| function assertAllowed( |
| root: string, |
| target: string, |
| label: string, |
| access: 'read' | 'write', |
| permission: FilesystemWorkerRequest['operationBoundary'], |
| ): void { |
| if (isPathInside(root, target) || sandboxBoundaryExpansionAllowsPath(permission, target, access)) |
| return; |
| throw operationError('path_denied', `${label} path escaped its approved target.`); |
| } |
| |
| function exactWriteCoversParent( |
| permission: FilesystemWorkerRequest['operationBoundary'], |
| target: string, |
| parent: string, |
| ): boolean { |
| return ( |
| permission.filesystem?.entries.some( |
| (entry) => |
| entry.access === 'write' && |
| entry.scope === 'exact' && |
| entry.path === target && |
| dirname(entry.path) === parent, |
| ) ?? false |
| ); |
| } |
| |
| function assertContainedGlobPattern(pattern: string): void { |
| if (isAbsolute(pattern) || pattern.split(/[\\/]+/).includes('..')) { |
| throw operationError('path_denied', 'Glob pattern must stay inside its search root.'); |
| } |
| } |
| |
| async function targetTypeOf(path: string): Promise<FilesystemWorkerTarget['targetType']> { |
| try { |
| const metadata = await fs.stat(path); |
| if (metadata.isFile()) return 'file'; |
| if (metadata.isDirectory()) return 'directory'; |
| return 'other'; |
| } catch (error) { |
| if (nodeErrorCode(error) === 'ENOENT') return 'missing'; |
| throw error; |
| } |
| } |
| |
| async function lstatTargetTypeOf(path: string): Promise<FilesystemWorkerTarget['targetType']> { |
| try { |
| const metadata = await fs.lstat(path); |
| if (metadata.isSymbolicLink()) return 'symlink'; |
| if (metadata.isFile()) return 'file'; |
| if (metadata.isDirectory()) return 'directory'; |
| return 'other'; |
| } catch (error) { |
| if (nodeErrorCode(error) === 'ENOENT') return 'missing'; |
| throw error; |
| } |
| } |
| |
| function nodeErrorCode(error: unknown): string | undefined { |
| if (!error || typeof error !== 'object' || !('code' in error)) return undefined; |
| return typeof error.code === 'string' ? error.code : undefined; |
| } |