blob: 8d32e02a29720f555cf9012df30bad3afa4f7cf4 [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 { randomUUID } from 'node:crypto';
import { lstat, realpath } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { canReadPath, canWritePath, type PermissionProfile } from '@maka/core/permission-profile';
import { compilePermissionProfile } from '@maka/core/permission-profile-compiler';
import { type ExecutionBoundary, type SandboxBoundaryExpansion } from '@maka/core/sandbox-boundary';
import { type PermissionMode } from '@maka/core/permission';
import { normalizeSandboxBoundaryPath } from '../sandbox-boundary-path.js';
import { MAX_CHILD_FD } from '../child-fd-input.js';
import { resolveCanonicalDirectoryEntryTarget } from '../path-containment.js';
import { pinExistingLinuxProfilePath } from '../sandbox/linux-profile-path.js';
import { classifyWindowsBrokerFailure } from '../sandbox/windows-broker-errors.js';
import type { SandboxManager } from '../sandbox/sandbox-manager.js';
import type { SandboxPlatform } from '../sandbox/types.js';
import type { FilesystemWorkerLaunchSpecProvider } from './launch-spec.js';
import {
FILESYSTEM_WORKER_DEFAULT_TIMEOUT_MS,
runFilesystemWorkerProcess,
type FilesystemWorkerProcessRunner,
} from './process-runner.js';
import {
FILESYSTEM_WORKER_PROTOCOL_VERSION,
FilesystemWorkerOperationSchema,
operationAccess,
operationUsesDirectoryEntry,
parseFilesystemWorkerResponse,
type FilesystemWorkerErrorCode,
type FilesystemWorkerOperation,
type FilesystemWorkerResult,
type FilesystemWorkerTarget,
} from './protocol.js';
export const FILESYSTEM_WORKER_MAX_REQUEST_BYTES = 16 * 1024 * 1024;
export type FilesystemWorkerClientOperation = FilesystemWorkerOperation extends infer Operation
? Operation extends { cwd: string }
? Omit<Operation, 'cwd'>
: never
: never;
export interface FilesystemWorkerClientInput {
getLaunchSpec: FilesystemWorkerLaunchSpecProvider;
sandboxManager: SandboxManager;
runProcess?: FilesystemWorkerProcessRunner;
newId?: () => string;
timeoutMs?: number;
platform?: SandboxPlatform;
}
/**
* What the caller observed about the operation target at lock acquisition
* (T0). Required, so every caller must decide explicitly which CAS contract it
* is participating in — an absent field is no longer a silently accepted "no
* CAS" that a queue window can slip through (#3484).
*
* - `{ dev, ino }`: T0 observed an existing target; the worker compare-and-
* swaps this against the on-disk inode at T1.
* - `'missing'`: T0 observed no target (a create). If the target exists by T1,
* something created it while this call waited — writing would clobber
* content this call never saw, so the operation fails with `path_changed`.
* - `'unchecked'`: the caller does not participate in CAS (no T0 snapshot,
* e.g. a verification script or a read). Writes proceed without an identity
* check; use deliberately, never as a default for mutations.
*/
export type FilesystemWorkerExpectedIdentity =
| { readonly dev: string; readonly ino: string }
| 'missing'
| 'unchecked';
export interface FilesystemWorkerExecuteInput {
operation: FilesystemWorkerClientOperation;
cwd: string;
executionBoundary?: ExecutionBoundary;
mode?: PermissionMode;
/** Explicit embedding policy. Mode-based defaults are compiled only when omitted. */
permissionProfile?: PermissionProfile;
abortSignal?: AbortSignal;
/**
* The caller's T0 observation, see `FilesystemWorkerExpectedIdentity`.
*
* REQUIRED for write operations: the client throws at runtime when a write
* arrives without one, so a JavaScript caller (which TypeScript cannot
* guard) fails loudly instead of silently skipping the queue-window CAS.
* Reads never participate in CAS: the client sends 'unchecked' for them
* automatically, so read callers have no way to get this wrong.
*/
expectedIdentity?: FilesystemWorkerExpectedIdentity;
}
export type FilesystemWorkerClientErrorReason =
| 'invalid_operation'
| 'invalid_request'
| 'request_overflow'
| 'worker_bundle_unavailable'
| 'runtime_executable_unavailable'
| 'spawn_failed'
| 'worker_io_incomplete'
| 'timeout'
| 'aborted'
| 'response_overflow'
| 'worker_crashed'
| 'invalid_response'
| 'response_id_mismatch'
| 'response_kind_mismatch'
| FilesystemWorkerErrorCode
| 'unsupported_platform'
| 'backend_not_available'
| 'backend_not_implemented'
| 'sandbox_required'
| 'sandbox_boundary_required';
export class FilesystemWorkerClientError extends Error {
readonly code = 'SANDBOX_FILESYSTEM_OPERATION_FAILED';
readonly domain = 'filesystem' as const;
readonly reason: FilesystemWorkerClientErrorReason;
readonly stage: 'validation' | 'transform' | 'launch' | 'protocol' | 'operation';
readonly recoverable: boolean;
readonly requestId?: string;
readonly backend?: 'none' | 'macos-seatbelt' | 'linux' | 'windows';
readonly profileName?: string;
readonly requiredExpansion?: SandboxBoundaryExpansion;
/**
* Whether the request had been dispatched to the worker before this failure.
* Only meaningful for `'launch'`/`'protocol'` failures: `undefined` means
* "the question does not apply" (pre-flight validation). When `false` the
* child never ran, so nothing on disk could have changed; when `true` the
* child ran and the outcome on disk is genuinely unknown.
*/
readonly dispatched?: boolean;
constructor(input: {
reason: FilesystemWorkerClientErrorReason;
stage: FilesystemWorkerClientError['stage'];
message?: string;
recoverable?: boolean;
requestId?: string;
backend?: 'none' | 'macos-seatbelt' | 'linux' | 'windows';
profileName?: string;
requiredExpansion?: SandboxBoundaryExpansion;
dispatched?: boolean;
}) {
super(input.message ?? `Filesystem worker failed: ${input.reason}.`);
this.name = 'FilesystemWorkerClientError';
this.reason = input.reason;
this.stage = input.stage;
this.recoverable = input.recoverable ?? false;
this.requestId = input.requestId;
this.backend = input.backend;
this.profileName = input.profileName;
this.requiredExpansion = input.requiredExpansion;
this.dispatched = input.dispatched;
}
}
export class FilesystemWorkerClient {
private readonly runProcess: FilesystemWorkerProcessRunner;
private readonly newId: () => string;
private readonly timeoutMs: number;
constructor(private readonly input: FilesystemWorkerClientInput) {
this.runProcess = input.runProcess ?? runFilesystemWorkerProcess;
this.newId = input.newId ?? randomUUID;
this.timeoutMs = input.timeoutMs ?? FILESYSTEM_WORKER_DEFAULT_TIMEOUT_MS;
}
async execute(input: FilesystemWorkerExecuteInput): Promise<FilesystemWorkerResult> {
const requestId = this.newId();
if (input.abortSignal?.aborted) {
// Pre-flight cancel: nothing has been dispatched, so this is a clean
// cancellation, not an unknown outcome. Carrying dispatched:false lets
// the host tell the two apart instead of blaming every queued tool.
throw clientError('aborted', 'launch', requestId, undefined, false, {}, false);
}
if (input.executionBoundary && input.executionBoundary.kind !== 'managed') {
throw clientError(
'invalid_request',
'validation',
requestId,
'Filesystem worker execution requires a managed boundary.',
);
}
const canonicalCwd = await realpath(input.cwd).catch(() => {
throw clientError(
'invalid_operation',
'validation',
requestId,
'Session cwd is unavailable.',
);
});
const parsedOperation = FilesystemWorkerOperationSchema.safeParse({
...input.operation,
cwd: canonicalCwd,
});
if (!parsedOperation.success) throw clientError('invalid_operation', 'validation', requestId);
const access = operationAccess(parsedOperation.data.kind);
// Reads never participate in CAS: the client sends 'unchecked' for them
// automatically, so read callers (including plain-JavaScript verifiers
// that bypass TypeScript) have no way to get the identity wrong.
// Writes require an explicit T0 state, enforced at runtime: a caller that
// omits it fails loudly here instead of silently skipping the
// queue-window CAS (#3487, maintainer review).
const writeIdentity = access === 'write' ? input.expectedIdentity : 'unchecked';
if (access === 'write' && writeIdentity === undefined) {
throw clientError(
'invalid_request',
'validation',
requestId,
'A write operation requires an explicit expectedIdentity: {dev, ino}, "missing", or "unchecked".',
);
}
const entryMode = operationUsesDirectoryEntry(parsedOperation.data);
// The wire identity contract is derived below from the caller's explicit
// expectedIdentity; the normalised target itself has no identity field,
// so the declared type omits it.
const target: Omit<FilesystemWorkerTarget, 'identity'> & { writableAncestor?: string } =
await (entryMode
? normalizeDirectoryEntryTarget({
path: parsedOperation.data.path,
access,
cwd: canonicalCwd,
})
: normalizeSandboxBoundaryPath({
path: parsedOperation.data.path,
access,
scope: operationScope(parsedOperation.data.kind),
cwd: canonicalCwd,
})
).catch(() => {
throw clientError('invalid_operation', 'validation', requestId);
});
// The identity was captured by the caller at lock acquisition (T0) and
// passed in as expectedIdentity. Do NOT re-derive it here: re-deriving at
// this point (after the lock is held) would sample the post-queue inode,
// making the CAS self-fulfilling and re-opening the queue window.
//
// Missing↔existing transitions while queued are reconciled here, against
// the T1 reality the target normaliser just derived:
// - T0 existing (identity present) but T1 missing: the target was removed
// while this call waited — typically a cooperative Maka delete that ran
// first under the same write lock. Drop the stale identity and let the
// mutation proceed as a fresh exclusive create ("delete then rewrite"
// stays a clean apply; a rename-swap is NOT this case — it leaves an
// existing inode and is caught by the identity comparison instead).
// - T0 missing ('missing') but T1 existing: the target was created while
// this call waited. Writing would clobber content this call never saw,
// so fail with a meaningful path_changed (never invalid_request).
// - 'unchecked': the caller does not participate in CAS. The target may
// be present at T1 without this being a race — the caller simply has no
// T0 snapshot, so nothing can be compared (#3484).
const targetExistsAtT1 = target.targetType !== 'missing';
const identity =
targetExistsAtT1 && typeof writeIdentity === 'object' ? writeIdentity : undefined;
if (targetExistsAtT1 && access === 'write' && writeIdentity === 'missing') {
throw clientError(
'path_changed',
'validation',
requestId,
'The target was created while this call waited for the lock; re-read before writing.',
);
}
const compiled =
input.executionBoundary?.kind === 'managed'
? {
profile: input.executionBoundary.profile,
workspaceRoots: [canonicalCwd],
}
: input.permissionProfile
? {
profile: input.permissionProfile,
workspaceRoots: [canonicalCwd],
}
: compilePermissionProfile({ mode: input.mode ?? 'ask', cwd: canonicalCwd });
const effectiveProfile = compiled.profile;
const platform = this.input.platform ?? process.platform;
const runtimeWritableRoots = filesystemWorkerRuntimeWritableRoots({
platform,
access,
enforcementPath: target.enforcementPath,
targetType: target.targetType,
entryMode,
writableAncestor: target.writableAncestor,
});
const pathContext = {
workspaceRoots: compiled.workspaceRoots,
tmpdir: await canonicalPath(tmpdir()),
...(platform === 'win32' ? {} : { slashTmp: await canonicalPath('/tmp') }),
...(runtimeWritableRoots ? { runtimeWritableRoots } : {}),
};
const allowed =
access === 'write'
? canWritePath(effectiveProfile, target.enforcementPath, pathContext)
: canReadPath(effectiveProfile, target.enforcementPath, pathContext);
if (!allowed) {
throw clientError(
input.executionBoundary?.kind === 'managed' ? 'sandbox_boundary_required' : 'path_denied',
'validation',
requestId,
undefined,
true,
input.executionBoundary?.kind === 'managed'
? {
requiredExpansion: {
filesystem: {
entries: [
{
path: target.enforcementPath,
access,
scope: target.scope,
},
],
},
},
}
: {},
);
}
const boundaryTarget = target.writableAncestor
? { path: target.writableAncestor, access: 'write' as const, scope: 'subtree' as const }
: { path: target.enforcementPath, access, scope: target.scope };
const operationBoundary = {
filesystem: {
entries: [boundaryTarget],
},
} as const;
const operation = FilesystemWorkerOperationSchema.parse({
...parsedOperation.data,
path: target.enforcementPath,
});
const request = {
version: FILESYSTEM_WORKER_PROTOCOL_VERSION,
requestId,
operation,
operationBoundary,
expectedTarget: {
enforcementPath: target.enforcementPath,
access,
scope: target.scope,
targetType: target.targetType,
// The execution-time identity contract. A concrete identity is only
// carried when the target still exists at T1; a target that vanished
// while queued (or was never there) is 'missing'; reads always say
// 'unchecked' (the client generates it, callers cannot get it wrong).
identity: typeof writeIdentity === 'object' ? (identity ?? 'missing') : writeIdentity,
},
} as const;
const requestJson = JSON.stringify(request);
if (Buffer.byteLength(requestJson, 'utf8') > FILESYSTEM_WORKER_MAX_REQUEST_BYTES) {
throw clientError('request_overflow', 'validation', requestId);
}
const launch = await this.input.getLaunchSpec(operation);
if (!launch.ok) throw clientError(launch.reason, 'launch', requestId, launch.message);
const searchMetadata: {
path: string;
canonicalPath: string;
targetType: 'file' | 'directory';
}[] = [];
if (operation.kind === 'grep' && target.targetType === 'directory') {
// Narrow the data scan to its target without dropping already-authorized
// ancestor ignore rules. Configuration files never grant their parent tree.
for (let parent = dirname(target.enforcementPath); ; parent = dirname(parent)) {
for (const name of ['.gitignore', '.ignore', '.rgignore', '.git', '.git/info/exclude']) {
const path = join(parent, name);
if (!canReadPath(effectiveProfile, path, pathContext)) continue;
const canonical = await realpath(path).catch(() => undefined);
if (canonical && canReadPath(effectiveProfile, canonical, pathContext)) {
const metadata = await lstat(canonical).catch(() => undefined);
if (metadata?.isFile() || (name === '.git' && metadata?.isDirectory())) {
searchMetadata.push({
path,
canonicalPath: canonical,
targetType: metadata.isDirectory() ? 'directory' : 'file',
});
}
}
}
if (dirname(parent) === parent) break;
}
}
const firstMetadataFd = 5;
if (
platform === 'linux' &&
searchMetadata.some(
({ targetType }, index) => targetType === 'file' && firstMetadataFd + index > MAX_CHILD_FD,
)
) {
throw clientError(
'request_overflow',
'validation',
requestId,
'Too many ancestor ignore files for one Grep operation. Search from a higher-level directory.',
);
}
const workerProfile = deriveWorkerProfile(
effectiveProfile,
operationBoundary,
searchMetadata.map(({ path, canonicalPath }) =>
platform === 'linux' ? path : canonicalPath,
),
);
const pinnedTarget =
platform === 'linux' && !entryMode && target.targetType !== 'missing'
? (() => {
try {
return pinExistingLinuxProfilePath({
path: target.enforcementPath,
access,
targetType: target.targetType as 'file' | 'directory' | 'other',
childFd: 4,
});
} catch {
throw clientError(
'path_changed',
'validation',
requestId,
'The approved filesystem target changed before sandbox launch.',
);
}
})()
: undefined;
if (platform === 'linux' && !entryMode && target.targetType !== 'missing' && !pinnedTarget) {
throw clientError(
'path_changed',
'validation',
requestId,
'The approved filesystem target changed before sandbox launch.',
);
}
const pinnedRuntimeWritableRoot =
platform === 'linux' &&
runtimeWritableRoots?.[0] &&
(entryMode || target.targetType === 'missing')
? (() => {
try {
return pinExistingLinuxProfilePath({
path: runtimeWritableRoots[0],
access: 'write',
targetType: 'directory',
childFd: 4,
});
} catch {
throw clientError(
'path_changed',
'validation',
requestId,
'The approved filesystem target parent changed before sandbox launch.',
);
}
})()
: undefined;
if (
platform === 'linux' &&
runtimeWritableRoots &&
(entryMode || target.targetType === 'missing') &&
!pinnedRuntimeWritableRoot
) {
throw clientError(
'path_changed',
'validation',
requestId,
'The approved filesystem target parent changed before sandbox launch.',
);
}
let transformed: ReturnType<SandboxManager['transform']>;
const pinnedMetadata: NonNullable<ReturnType<typeof pinExistingLinuxProfilePath>>[] = [];
const releasePinnedPaths = () => {
pinnedTarget?.releaseSource();
pinnedRuntimeWritableRoot?.releaseSource();
for (const pinned of pinnedMetadata) pinned.releaseSource();
};
try {
if (platform === 'linux') {
for (const metadata of searchMetadata) {
const pinned = pinExistingLinuxProfilePath({
path: metadata.canonicalPath,
targetType: metadata.targetType,
access: 'read',
childFd: firstMetadataFd + pinnedMetadata.length,
});
if (!pinned) {
throw clientError('path_changed', 'validation', requestId);
}
// rg opens the ancestor's lexical name. Bind the verified canonical
// source there even when the original metadata entry was a symlink.
pinnedMetadata.push({ ...pinned, path: metadata.path });
}
}
transformed = this.input.sandboxManager.transform({
platform,
command: {
program: launch.spec.program,
args: launch.spec.args,
cwd: canonicalCwd,
env: launch.spec.env,
profile: workerProfile,
pathContext: {
...pathContext,
runtimeReadableRoots: launch.spec.runtimeReadableRoots,
executableRoots: launch.spec.executableRoots,
...(pinnedTarget || pinnedMetadata.length
? {
pinnedProfilePaths: [
...(pinnedTarget ? [pinnedTarget] : []),
...pinnedMetadata,
].map((pinned) => ({
path: pinned.path,
access: pinned.access,
fd: pinned.childFd,
sourceFd: pinned.sourceFd,
releaseSource: pinned.releaseSource,
})),
}
: {}),
...(pinnedRuntimeWritableRoot
? {
pinnedRuntimeWritableRoots: [
{
path: pinnedRuntimeWritableRoot.path,
fd: pinnedRuntimeWritableRoot.childFd,
sourceFd: pinnedRuntimeWritableRoot.sourceFd,
releaseSource: pinnedRuntimeWritableRoot.releaseSource,
},
],
}
: {}),
},
},
});
} catch (error) {
releasePinnedPaths();
throw error;
}
if (!transformed.ok) {
releasePinnedPaths();
throw clientError(transformed.reason, 'transform', requestId, transformed.message, false, {
backend: transformed.sandboxType,
profileName: effectiveProfile.name ?? effectiveProfile.type,
});
}
let processResult: Awaited<ReturnType<FilesystemWorkerProcessRunner>>;
try {
processResult = await this.runProcess({
argv: transformed.exec.argv,
cwd: transformed.exec.cwd,
env: transformed.exec.env ?? {},
stdin: requestJson,
...(transformed.exec.fdInputs ? { fdInputs: transformed.exec.fdInputs } : {}),
timeoutMs: this.timeoutMs,
...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
});
} catch (error) {
// The process-runner attaches a `dispatched` flag to the rejection so we
// can tell "the child never started" (spawn_failed — clean) from "the
// child ran but its result was lost" (worker_io_incomplete — the outcome
// on disk is unknown). A thrown error without the flag (e.g. spawn()
// itself raising before any 'spawn' event could fire) is treated as
// never-dispatched.
const dispatched = (error as { dispatched?: boolean } | null)?.dispatched === true;
throw clientError(
dispatched ? 'worker_io_incomplete' : 'spawn_failed',
'launch',
requestId,
undefined,
false,
{},
dispatched,
);
} finally {
releasePinnedPaths();
}
if (processResult.timedOut) {
throw clientError(
'timeout',
'launch',
requestId,
undefined,
false,
{},
processResult.dispatched,
);
}
if (processResult.aborted) {
// A post-dispatch abort means the child had the request and may have
// acted on it before being killed; carry dispatched so the host can
// classify it as an unknown outcome rather than a clean cancel.
throw clientError(
'aborted',
'launch',
requestId,
undefined,
false,
{},
processResult.dispatched,
);
}
if (processResult.responseOverflow) {
throw clientError(
'response_overflow',
'launch',
requestId,
undefined,
false,
{},
processResult.dispatched,
);
}
if (processResult.exitCode !== 0) {
const brokerFailure =
transformed.sandboxType === 'windows'
? classifyWindowsBrokerFailure(processResult.stderrTail)
: undefined;
if (brokerFailure) {
throw clientError(
brokerFailure.reason,
'launch',
requestId,
processResult.stderrTail || undefined,
brokerFailure.recoverable,
{ backend: 'windows' },
);
}
throw clientError(
'worker_crashed',
'launch',
requestId,
processResult.stderrTail || undefined,
false,
{},
processResult.dispatched,
);
}
let response: ReturnType<typeof parseFilesystemWorkerResponse>;
try {
response = parseFilesystemWorkerResponse(JSON.parse(processResult.stdout));
} catch {
// The child produced output we could not parse, but it ran and emitted
// something, so the request was dispatched (the on-disk outcome is
// unknown for a mutation).
throw clientError('invalid_response', 'protocol', requestId, undefined, false, {}, true);
}
if (response.requestId !== requestId)
throw clientError('response_id_mismatch', 'protocol', requestId, undefined, false, {}, true);
if (!response.ok) {
// A well-formed worker error means the child ran and answered: dispatch
// had happened. Carry dispatched:true so a mutating op that fails here
// (e.g. the worker reports `outcome_unknown` after a partial write) is
// classified as an unknown outcome rather than slipping through.
throw clientError(
response.error.code,
'operation',
requestId,
response.error.message,
response.error.code === 'not_found' || response.error.code === 'edit_conflict',
{
backend: transformed.exec.sandboxType,
profileName: effectiveProfile.name ?? effectiveProfile.type,
},
true,
);
}
if (
response.result.kind !== operation.kind &&
!(operation.kind === 'read' && response.result.kind === 'read_image')
) {
throw clientError(
'response_kind_mismatch',
'protocol',
requestId,
undefined,
false,
{},
true,
);
}
return response.result;
}
}
/** @internal Runtime-only widening for a trusted, single-operation worker. */
export function filesystemWorkerRuntimeWritableRoots(input: {
platform: SandboxPlatform;
access: 'read' | 'write';
enforcementPath: string;
targetType: FilesystemWorkerTarget['targetType'];
entryMode?: boolean;
writableAncestor?: string;
}): readonly string[] | undefined {
// Linux mounts and Windows ACL grants can only target existing paths, so a
// write whose target does not exist yet is enforced through its existing
// writable ancestor. macOS seatbelt policies may reference missing paths
// directly and need no ancestor root.
if ((input.platform !== 'linux' && input.platform !== 'win32') || input.access !== 'write') {
return undefined;
}
if (input.entryMode) return input.writableAncestor ? [input.writableAncestor] : undefined;
return input.targetType === 'missing' ? [dirname(input.enforcementPath)] : undefined;
}
function deriveWorkerProfile(
profile: PermissionProfile,
operationBoundary: {
readonly filesystem: {
readonly entries: readonly [
{
readonly path: string;
readonly access: 'read' | 'write';
readonly scope: 'exact' | 'subtree';
},
];
};
},
searchMetadata: readonly string[],
): PermissionProfile {
if (profile.type !== 'managed' || profile.fileSystem.kind !== 'restricted') return profile;
const target = operationBoundary.filesystem.entries[0];
return {
...profile,
fileSystem: {
...profile.fileSystem,
entries: [
...profile.fileSystem.entries.filter((entry) => entry.access === 'deny'),
{
kind: 'path',
path: target.path,
access: target.access,
match: target.scope,
},
...searchMetadata.map((path) => ({
kind: 'path' as const,
path,
access: 'read' as const,
match: 'exact' as const,
})),
],
},
network: { kind: 'restricted' },
};
}
function operationScope(kind: FilesystemWorkerOperation['kind']): 'exact' | 'subtree' | 'auto' {
if (kind === 'glob') return 'subtree';
return kind === 'grep' ? 'auto' : 'exact';
}
async function canonicalPath(path: string): Promise<string> {
return await realpath(path).catch(() => path);
}
async function normalizeDirectoryEntryTarget(input: {
path: string;
cwd: string;
access: 'read' | 'write';
}): Promise<Omit<FilesystemWorkerTarget, 'identity'> & { writableAncestor?: string }> {
const target = await resolveCanonicalDirectoryEntryTarget(input.cwd, input.path);
let targetType: FilesystemWorkerTarget['targetType'];
try {
const metadata = await lstat(target.path);
targetType = metadata.isSymbolicLink()
? 'symlink'
: metadata.isFile()
? 'file'
: metadata.isDirectory()
? 'directory'
: 'other';
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'ENOENT' && code !== 'ENOTDIR') throw error;
targetType = 'missing';
}
return {
enforcementPath: target.path,
access: input.access,
scope: 'exact',
targetType,
writableAncestor: target.existingAncestor,
};
}
function clientError(
reason: FilesystemWorkerClientErrorReason,
stage: FilesystemWorkerClientError['stage'],
requestId: string,
message?: string,
recoverable = false,
metadata: {
backend?: 'none' | 'macos-seatbelt' | 'linux' | 'windows';
profileName?: string;
requiredExpansion?: SandboxBoundaryExpansion;
} = {},
dispatched?: boolean,
): FilesystemWorkerClientError {
return new FilesystemWorkerClientError({
reason,
stage,
requestId,
message,
recoverable,
...metadata,
...(dispatched !== undefined ? { dispatched } : {}),
});
}