| /* |
| * 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 { z } from 'zod'; |
| import { readContinuationSchema, readPageSchema } from '../read-page.js'; |
| import { validateSandboxBoundaryExpansion } from '@maka/core/sandbox-boundary'; |
| import { GREP_MAX_LINES, GREP_MAX_LINES_PER_FILE, GREP_MAX_MATCH_BYTES } from '../grep-search.js'; |
| |
| // v10 adds bounded Read pages to v9's exact Grep counts. Older workers cannot |
| // satisfy the combined result contract and must be rejected at the handshake. |
| export const FILESYSTEM_WORKER_PROTOCOL_VERSION = 10 as const; |
| |
| /** The single authority on which operation kinds are writes. Shared by the |
| * client (permission/identity decisions) and the worker (operation guards) so |
| * the set cannot drift. */ |
| export function operationAccess(kind: FilesystemWorkerOperation['kind']): 'read' | 'write' { |
| return kind === 'write' || kind === 'apply_patch' || kind === 'edit' || kind === 'format_json' |
| ? 'write' |
| : 'read'; |
| } |
| |
| const path = z.string().min(1).max(4096); |
| const cwd = z.string().min(1).max(4096); |
| |
| // Opaque identity strings: `String(stats.dev)` / `String(stats.ino)`. Decimal |
| // only so they survive JSON round-trips; compared for equality on the worker. |
| const decimalString = z.string().regex(/^\d+$/); |
| const FilesystemTargetIdentitySchema = z |
| .object({ dev: decimalString, ino: decimalString }) |
| .strict(); |
| |
| const OperationBoundarySchema = z |
| .object({ |
| filesystem: z |
| .object({ |
| entries: z |
| .array( |
| z |
| .object({ |
| path, |
| access: z.enum(['read', 'write']), |
| scope: z.enum(['exact', 'subtree']), |
| }) |
| .strict(), |
| ) |
| .max(32), |
| }) |
| .strict() |
| .optional(), |
| network: z |
| .object({ enabled: z.literal(true) }) |
| .strict() |
| .optional(), |
| }) |
| .strict() |
| .superRefine((profile, context) => { |
| const validation = validateSandboxBoundaryExpansion(profile); |
| if (!validation.ok) context.addIssue({ code: 'custom', message: validation.message }); |
| }); |
| |
| export const FilesystemWorkerTargetSchema = z |
| .object({ |
| enforcementPath: path, |
| access: z.enum(['read', 'write']), |
| scope: z.enum(['exact', 'subtree']), |
| targetType: z.enum(['file', 'directory', 'symlink', 'other', 'missing']), |
| // The execution-time identity contract, one required field (no separate |
| // T0 marker — a single three-state shape mirrors the client input, so an |
| // illegal combination cannot be expressed on the wire): |
| // - { dev, ino }: the T0 identity the worker must CAS against at T1. |
| // - 'missing': T0 saw no target; a target present at execution time was |
| // created while the call waited and must fail. |
| // - 'unchecked': the caller does not participate in CAS; the write |
| // proceeds without an identity comparison. |
| identity: FilesystemTargetIdentitySchema.or(z.literal('missing')).or(z.literal('unchecked')), |
| }) |
| .strict() |
| .superRefine((target, context) => { |
| if (target.targetType === 'missing' && typeof target.identity === 'object') { |
| context.addIssue({ |
| code: 'custom', |
| message: 'A missing target cannot carry an identity.', |
| }); |
| } |
| }); |
| |
| export const FilesystemWorkerOperationSchema = z.union([ |
| z |
| .object({ |
| kind: z.literal('read'), |
| cwd, |
| path, |
| offset: z.number().int().nonnegative().optional(), |
| limit: z.number().int().positive().optional(), |
| continuation: readContinuationSchema.optional(), |
| }) |
| .strict(), |
| z.object({ kind: z.literal('write'), cwd, path, content: z.string() }).strict(), |
| z |
| .object({ |
| kind: z.literal('apply_patch'), |
| cwd, |
| path, |
| action: z.enum(['create', 'update']), |
| diff: z.string(), |
| }) |
| .strict(), |
| z.object({ kind: z.literal('apply_patch'), cwd, path, action: z.literal('delete') }).strict(), |
| z |
| .object({ |
| kind: z.literal('edit'), |
| cwd, |
| path, |
| oldString: z.string(), |
| newString: z.string(), |
| }) |
| .strict(), |
| z |
| .object({ |
| kind: z.literal('format_json'), |
| cwd, |
| path, |
| sortKeys: z.boolean(), |
| }) |
| .strict(), |
| z |
| .object({ |
| kind: z.literal('glob'), |
| cwd, |
| path, |
| pattern: z.string().min(1), |
| limit: z.number().int().positive().optional(), |
| }) |
| .strict(), |
| z |
| .object({ |
| kind: z.literal('grep'), |
| cwd, |
| path, |
| pattern: z.string(), |
| glob: z.string().min(1).optional(), |
| maxCountPerFile: z.number().int().positive().max(GREP_MAX_LINES_PER_FILE), |
| limit: z.number().int().positive().max(GREP_MAX_LINES), |
| timeoutMs: z.number().int().positive(), |
| }) |
| .strict(), |
| ]); |
| |
| export const FilesystemWorkerRequestSchema = z |
| .object({ |
| version: z.literal(FILESYSTEM_WORKER_PROTOCOL_VERSION), |
| requestId: z.string().min(1).max(256), |
| operation: FilesystemWorkerOperationSchema, |
| operationBoundary: OperationBoundarySchema, |
| expectedTarget: FilesystemWorkerTargetSchema, |
| }) |
| .strict(); |
| |
| export const FilesystemWorkerResultSchema = z.discriminatedUnion('kind', [ |
| readPageSchema.extend({ kind: z.literal('read') }).strict(), |
| z |
| .object({ |
| kind: z.literal('read_image'), |
| base64: z.string(), |
| mimeType: z.enum(['image/png', 'image/jpeg', 'image/gif', 'image/webp']), |
| }) |
| .strict(), |
| z |
| .object({ |
| kind: z.literal('write'), |
| ok: z.literal(true), |
| path: z.string(), |
| bytes: z.number().int().nonnegative(), |
| diff: z.string().optional(), |
| }) |
| .strict(), |
| z.object({ kind: z.literal('apply_patch'), ok: z.literal(true), path: z.string() }).strict(), |
| z |
| .object({ |
| kind: z.literal('edit'), |
| ok: z.literal(true), |
| path: z.string(), |
| replacements: z.literal(1), |
| matchedVia: z.enum(['exact', 'line-trimmed', 'whitespace', 'escape']), |
| startLine: z.number().int().positive(), |
| endLine: z.number().int().positive(), |
| diff: z.string().optional(), |
| }) |
| .strict(), |
| z |
| .object({ |
| kind: z.literal('format_json'), |
| ok: z.boolean(), |
| valid: z.boolean(), |
| path: z.string(), |
| error: z.string().optional(), |
| bytesBefore: z.number().int().nonnegative(), |
| bytesAfter: z.number().int().nonnegative().optional(), |
| byteDelta: z.number().int(), |
| changed: z.boolean(), |
| diff: z.string().optional(), |
| }) |
| .strict(), |
| z.object({ kind: z.literal('glob'), files: z.array(z.string()) }).strict(), |
| z |
| .object({ |
| kind: z.literal('grep'), |
| matchedLines: z.number().int().nonnegative(), |
| returnedLines: z.number().int().nonnegative(), |
| omittedLines: z.number().int().nonnegative(), |
| truncated: z.boolean(), |
| matches: z |
| .array(z.string()) |
| .max(GREP_MAX_LINES) |
| .refine((matches) => Buffer.byteLength(JSON.stringify(matches)) <= GREP_MAX_MATCH_BYTES), |
| }) |
| .strict() |
| .refine( |
| (result) => |
| result.returnedLines === result.matches.length && |
| result.matchedLines === result.returnedLines + result.omittedLines && |
| result.truncated === result.omittedLines > 0, |
| ), |
| ]); |
| |
| export const FilesystemWorkerErrorCodeSchema = z.enum([ |
| 'invalid_request', |
| 'path_denied', |
| 'path_changed', |
| 'not_found', |
| 'edit_conflict', |
| 'grep_unavailable', |
| 'sandbox_denied', |
| 'filesystem_denied', |
| 'filesystem_error', |
| // The worker may have applied the mutation before it lost the ability to |
| // report back (e.g. it wrote the file then the post-write identity check |
| // found the on-path inode no longer matches the one it wrote). The host |
| // treats this as an unknown outcome on disk, not a clean failure. |
| 'outcome_unknown', |
| // The entry-delete path refuses directories outright (#2600): a directory |
| // cannot be unlinked, only recursively removed — a different operation. |
| 'is_directory', |
| ]); |
| |
| export const FilesystemWorkerResponseSchema = z.discriminatedUnion('ok', [ |
| z |
| .object({ |
| version: z.literal(FILESYSTEM_WORKER_PROTOCOL_VERSION), |
| requestId: z.string().min(1).max(256), |
| ok: z.literal(true), |
| result: FilesystemWorkerResultSchema, |
| }) |
| .strict(), |
| z |
| .object({ |
| version: z.literal(FILESYSTEM_WORKER_PROTOCOL_VERSION), |
| requestId: z.string().min(1).max(256), |
| ok: z.literal(false), |
| error: z |
| .object({ |
| code: FilesystemWorkerErrorCodeSchema, |
| message: z.string(), |
| }) |
| .strict(), |
| }) |
| .strict(), |
| ]); |
| |
| export type FilesystemWorkerOperation = z.infer<typeof FilesystemWorkerOperationSchema>; |
| |
| export function operationUsesDirectoryEntry(operation: FilesystemWorkerOperation): boolean { |
| return ( |
| operation.kind === 'apply_patch' && |
| (operation.action === 'create' || operation.action === 'delete') |
| ); |
| } |
| export type FilesystemWorkerTarget = z.infer<typeof FilesystemWorkerTargetSchema>; |
| export type FilesystemWorkerRequest = z.infer<typeof FilesystemWorkerRequestSchema>; |
| export type FilesystemWorkerResult = z.infer<typeof FilesystemWorkerResultSchema>; |
| export type FilesystemWorkerErrorCode = z.infer<typeof FilesystemWorkerErrorCodeSchema>; |
| export type FilesystemWorkerResponse = z.infer<typeof FilesystemWorkerResponseSchema>; |
| |
| export function parseFilesystemWorkerResponse(input: unknown): FilesystemWorkerResponse { |
| return FilesystemWorkerResponseSchema.parse(input); |
| } |