blob: 1c1baa3a58f9b40e2b6a023af3ba6deaf086fa1b [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 { 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);
}