diff --git a/.changeset/add-workspace-last-write-wins.md b/.changeset/add-workspace-last-write-wins.md new file mode 100644 index 0000000..dae46c5 --- /dev/null +++ b/.changeset/add-workspace-last-write-wins.md @@ -0,0 +1,17 @@ +--- +'@nestm/storage': minor +--- + +Add explicit last-write-wins workspace write, copy, and unconditional-delete +operations that traverse the ordinary Files SDK plugin, hook, and receipt +pipeline while retaining the existing native conditional create, replace, +copy, move, and delete variants. Unconditional delete requires both `write` and +`delete`; move remains conditional-only because a non-atomic +download/upload/delete sequence could delete a newer source generation. Add a +separate `write` permission and an AI tool factory mutation-mode switch whose +default remains conditional. + +Add bounded binary workspace reads through `readBytes`, alongside the existing +UTF-8 `readText` API. `readBytes` is a required `StorageWorkspace` member, so +custom interface implementations and typed test doubles must add it when +upgrading; workspaces returned by `mountStorageWorkspace` need no changes. diff --git a/README.md b/README.md index a5b855d..53bddba 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,7 @@ const workspace = mountStorageWorkspace(agentFiles, { 'list', 'read', 'search', + 'write', 'create', 'replace', 'copy', @@ -199,8 +200,16 @@ await workspace.writeFile('src/main.ts', 'export const ready = false;\n', { etag: created.etag, contentType: 'text/typescript', }); + +const image = await workspace.readBytes('assets/logo.png'); +console.log(image.bytes.byteLength); ``` +`readBytes` is now a required member of the exported `StorageWorkspace` +interface. Workspaces returned by `mountStorageWorkspace` provide it +automatically; custom implementations and typed test doubles must add the +method when adopting this alpha minor. + Create, replace, and delete are conditional operations. A driver that cannot enforce the requested not-exists or ETag precondition fails with `NOT_SUPPORTED`; the workspace never substitutes an `exists()`/`head()` check @@ -215,6 +224,32 @@ the call returns `CONFLICT`; inspect both logical paths before retrying. This preserves at least one copy across provider timeouts and post-operation hook failures, but does not pretend a multi-object move is transactionally atomic. +Callers that prefer the ordinary Files pipeline can opt into explicit +last-write-wins variants. The `write` permission is separate from conditional +`create` and `replace` authority: + +```ts +await workspace.writeFile('notes.txt', 'latest contents', { + mode: 'overwrite', +}); +await workspace.copyFile('notes.txt', 'backup.txt', { mode: 'overwrite' }); +await workspace.deleteFile('backup.txt', { mode: 'unconditional' }); +``` + +Overwrite copy reads the latest source through the ordinary download pipeline, +enforces `maxWriteBytes` while collecting it, and uploads it through the +ordinary upload pipeline. It never substitutes the provider's server-side +copy. These paths compose with Files SDK plugins, hooks, and receipts, including +the built-in `encryption()` plugin. That plugin is useful compatibility +evidence, not an Artifact-specific security policy: strict encrypted-only +reads, tenant/path-bound AAD, key custody and rotation, and copy/move rules +remain application-owned. + +Move remains conditional-only. A last-write-wins download/upload/delete +sequence could copy one source generation and then delete a newer generation +written during the transfer. Use the ETag-conditional `moveFile` variant when a +move is required. + A child mount may further restrict a directory, permissions, or limits, but it cannot widen any of them: @@ -275,6 +310,7 @@ import type { ToolSet } from 'ai'; 'list', 'read', 'search', + 'write', 'create', 'replace', 'copy', @@ -312,6 +348,13 @@ workspace capability remains the authorization boundary even when approval is disabled. The module's `AiSdkService.files()` API is the model provider's file upload facility and is unrelated to storage workspaces. +`mutationMode` defaults to `'conditional'`. A trusted composition can instead +select `{ mutationMode: 'last-write-wins' }`; generated mutation schemas then +omit ETags and modes, hardcode the explicit overwrite/unconditional workspace +variants, and require `write` permission for destination mutations. +Unconditional delete requires both `write` and `delete`. The move tool is +omitted in last-write-wins mode because Workspace move remains conditional-only. + Atomic create collisions remain sanitized tool errors by default. Applications that model an existing destination as a normal tool result can map that one case while preserving replace/ETag conflicts as failures: @@ -328,7 +371,8 @@ const tools = createAiSdkWorkspaceTools({ ``` The mapper receives only the logical workspace path; provider errors, object -keys, and mount coordinates are never exposed. +keys, and mount coordinates are never exposed. `mapCreateConflict` is valid +only in conditional mode and is rejected with last-write-wins mode. This logical confinement is sufficient for a `ToolLoopAgent` whose only file capabilities are these tools. It cannot constrain a coding harness that already @@ -590,6 +634,12 @@ result transforms. This compatibility gate is intended to be removed once native CAS can traverse the upstream operation and plugin pipeline rather than becoming a second generic CRUD facade here. +`StorageWorkspace` therefore exposes both contracts without weakening either: +its existing create/replace, exact-read copy/move, and conditional-delete paths +retain native CAS and this fail-closed gate, while explicit overwrite and +unconditional-delete variants use the ordinary Files pipeline. Lower-level +conditional client and driver APIs remain available to callers that need them. + ## Storage API `StorageClient` exposes: diff --git a/src/ai-sdk/ai-sdk-workspace-tools.spec.ts b/src/ai-sdk/ai-sdk-workspace-tools.spec.ts index ecb6a0d..93e56b8 100644 --- a/src/ai-sdk/ai-sdk-workspace-tools.spec.ts +++ b/src/ai-sdk/ai-sdk-workspace-tools.spec.ts @@ -5,6 +5,7 @@ import { StorageErrorCode } from '../storage.error.js'; import { StorageWorkspaceError, type StorageWorkspace, + type StorageWorkspaceByteFile, type StorageWorkspaceEntry, type StorageWorkspaceFile, type StorageWorkspacePermission, @@ -16,6 +17,7 @@ import { } from './ai-sdk-workspace-tools.js'; interface ToolView { + description?: string; execute?: ( input: unknown, options: { @@ -35,6 +37,7 @@ interface WorkspaceDouble { list: ReturnType; stat: ReturnType; readText: ReturnType; + readBytes: ReturnType; search: ReturnType; writeFile: ReturnType; copyFile: ReturnType; @@ -57,6 +60,11 @@ const TEXT_FILE: StorageWorkspaceTextFile = { text: 'hello', }; +const BYTE_FILE: StorageWorkspaceByteFile = { + ...FILE, + bytes: new TextEncoder().encode('hello'), +}; + const MALICIOUS_ETAGS = [ '', '"etag-a", "etag-b"', @@ -83,6 +91,7 @@ function createWorkspaceDouble( const list = vi.fn(async () => ({ entries: [] as StorageWorkspaceEntry[] })); const stat = vi.fn(async () => FILE); const readText = vi.fn(async () => TEXT_FILE); + const readBytes = vi.fn(async () => BYTE_FILE); const search = vi.fn(async () => ({ entries: [] as StorageWorkspaceEntry[], })); @@ -108,6 +117,7 @@ function createWorkspaceDouble( list, stat, readText, + readBytes, search, writeFile, copyFile, @@ -120,6 +130,7 @@ function createWorkspaceDouble( list, stat, readText, + readBytes, search, writeFile, copyFile, @@ -207,6 +218,118 @@ describe('createAiSdkWorkspaceTools', () => { expect('workspace_move_file' in tools).toBe(false); }); + it('uses write-authorized last-write-wins schemas and omits unsafe move', async () => { + const fixture = createWorkspaceDouble([ + 'read', + 'write', + 'create', + 'copy', + 'move', + 'delete', + ]); + const tools = createAiSdkWorkspaceTools({ + mutationMode: 'last-write-wins', + workspace: fixture.workspace, + }); + + const writeSchema = viewTool(tools, 'workspace_write_file').inputSchema; + const copySchema = viewTool(tools, 'workspace_copy_file').inputSchema; + const deleteSchema = viewTool(tools, 'workspace_delete_file').inputSchema; + expect('workspace_move_file' in tools).toBe(false); + expect(viewTool(tools, 'workspace_stat').description).toContain( + 'informational', + ); + expect( + writeSchema.safeParse({ path: 'written.txt', content: 'body' }).success, + ).toBe(true); + expect( + writeSchema.safeParse({ + path: 'written.txt', + content: 'body', + mode: 'overwrite', + }).success, + ).toBe(false); + expect( + copySchema.safeParse({ + source: 'written.txt', + destination: 'copied.txt', + }).success, + ).toBe(true); + expect( + copySchema.safeParse({ + source: 'written.txt', + destination: 'copied.txt', + etag: 'source-etag', + }).success, + ).toBe(false); + expect( + deleteSchema.safeParse({ path: 'copied.txt', etag: 'old-etag' }).success, + ).toBe(false); + + await executeTool(tools, 'workspace_write_file', { + path: 'written.txt', + content: 'body', + }); + expect(fixture.writeFile).toHaveBeenCalledWith('written.txt', 'body', { + mode: 'overwrite', + }); + await executeTool(tools, 'workspace_copy_file', { + source: 'written.txt', + destination: 'copied.txt', + }); + expect(fixture.copyFile).toHaveBeenCalledWith('written.txt', 'copied.txt', { + mode: 'overwrite', + }); + await expect( + executeTool(tools, 'workspace_delete_file', { path: 'copied.txt' }), + ).resolves.toEqual({ deleted: true, path: 'copied.txt' }); + expect(fixture.deleteFile).toHaveBeenCalledWith('copied.txt', { + mode: 'unconditional', + }); + + const withoutWrite = createWorkspaceDouble([ + 'read', + 'copy', + 'move', + 'delete', + ]); + const narrowed = createAiSdkWorkspaceTools({ + mutationMode: 'last-write-wins', + workspace: withoutWrite.workspace, + }); + expect('workspace_write_file' in narrowed).toBe(false); + expect('workspace_copy_file' in narrowed).toBe(false); + expect('workspace_move_file' in narrowed).toBe(false); + expect('workspace_delete_file' in narrowed).toBe(false); + + const withoutDelete = createWorkspaceDouble(['write']); + const writeOnly = createAiSdkWorkspaceTools({ + mutationMode: 'last-write-wins', + workspace: withoutDelete.workspace, + }); + expect('workspace_delete_file' in writeOnly).toBe(false); + }); + + it('rejects invalid mutation policy combinations before inspecting permissions', () => { + const fixture = createWorkspaceDouble(['write']); + const allows = vi.spyOn(fixture.workspace, 'allows'); + + expect(() => + createAiSdkWorkspaceTools({ + mutationMode: 'unknown' as never, + workspace: fixture.workspace, + }), + ).toThrow(/mutationMode/u); + expect(() => + createAiSdkWorkspaceTools({ + mapCreateConflict: ({ path }) => ({ path }), + mutationMode: 'last-write-wins', + workspace: fixture.workspace, + }), + ).toThrow(/mapCreateConflict/u); + expect(allows).not.toHaveBeenCalled(); + }); + it('requires approval for mutations by default and supports granular overrides', () => { const fixture = createWorkspaceDouble([ 'read', @@ -765,7 +888,7 @@ describe('createAiSdkWorkspaceTools', () => { ).rejects.toMatchObject({ code: StorageErrorCode.CONFLICT, message: - 'The operation conflicts with current workspace state. Refresh metadata and retry with the current ETag or a new destination.', + 'The operation conflicts with current workspace state. Inspect the affected paths before retrying.', }); }); diff --git a/src/ai-sdk/ai-sdk-workspace-tools.ts b/src/ai-sdk/ai-sdk-workspace-tools.ts index 99bf20d..7a766b6 100644 --- a/src/ai-sdk/ai-sdk-workspace-tools.ts +++ b/src/ai-sdk/ai-sdk-workspace-tools.ts @@ -45,6 +45,8 @@ export type AiSdkWorkspaceMutationToolName = Extract< export type AiSdkWorkspaceApprovalConfig = boolean | Partial>; +export type AiSdkWorkspaceMutationMode = 'conditional' | 'last-write-wins'; + export interface AiSdkWorkspaceCreateConflict { /** The logical destination inside the mounted workspace. */ readonly path: string; @@ -66,10 +68,16 @@ export interface CreateAiSdkWorkspaceToolsOptions< maxReadBytes?: number; /** Mutation tools require approval by default. */ requireApproval?: AiSdkWorkspaceApprovalConfig; + /** + * Selects the mutation contract exposed to the model. Conditional mode is + * the default; last-write-wins uses the workspace's ordinary Files path. + */ + mutationMode?: AiSdkWorkspaceMutationMode; /** * Maps an atomic create collision to an application result. When omitted, * the collision remains an AiSdkWorkspaceToolError like every other storage - * failure. Replace conflicts are never mapped by this hook. + * failure. Replace conflicts are never mapped by this hook. This option is + * valid only when mutationMode is conditional. */ mapCreateConflict?: AiSdkWorkspaceCreateConflictMapper; } @@ -83,7 +91,7 @@ const SAFE_ERROR_MESSAGES: Readonly< [StorageErrorCode.UNAUTHORIZED]: 'This operation is not permitted in the workspace.', [StorageErrorCode.CONFLICT]: - 'The operation conflicts with current workspace state. Refresh metadata and retry with the current ETag or a new destination.', + 'The operation conflicts with current workspace state. Inspect the affected paths before retrying.', [StorageErrorCode.READ_ONLY]: 'This operation is not permitted in the workspace.', [StorageErrorCode.INVALID_ARGUMENT]: 'The workspace tool input was rejected.', @@ -383,9 +391,20 @@ export function createAiSdkWorkspaceTools< >({ workspace, maxReadBytes: requestedMaxReadBytes, + mutationMode = 'conditional', requireApproval = true, mapCreateConflict, }: CreateAiSdkWorkspaceToolsOptions): ToolSet { + if (mutationMode !== 'conditional' && mutationMode !== 'last-write-wins') { + throw new RangeError( + 'mutationMode must be "conditional" or "last-write-wins".', + ); + } + if (mutationMode === 'last-write-wins' && mapCreateConflict !== undefined) { + throw new RangeError( + 'mapCreateConflict is available only in conditional mutation mode.', + ); + } const maxReadBytes = resolveReadLimit(workspace, requestedMaxReadBytes); const tools: ToolSet = {}; const pathSchema = logicalPath('File path', workspace.limits.maxPathBytes); @@ -441,7 +460,9 @@ export function createAiSdkWorkspaceTools< if (workspace.allows('read')) { tools.workspace_stat = tool({ description: - 'Inspect a file inside the mounted workspace without reading its contents. Returns the ETag required for safe replace, move, and delete operations.', + mutationMode === 'conditional' + ? 'Inspect a file inside the mounted workspace without reading its contents. Returns the ETag required for safe replace, move, and delete operations.' + : 'Inspect a file inside the mounted workspace without reading its contents. Any returned ETag is informational in last-write-wins mode.', strict: true, inputSchema: z.object({ path: pathSchema }).strict(), execute: ({ path }, { abortSignal }) => @@ -512,86 +533,139 @@ export function createAiSdkWorkspaceTools< }); } - const canCreate = workspace.allows('create'); - const canReplace = workspace.allows('replace'); - if (canCreate || canReplace) { - const commonWriteShape = { - path: pathSchema, - content: z - .string() - .refine( - (value) => - utf8Encoder.encode(value).byteLength <= - workspace.limits.maxWriteBytes, - { - message: `Content exceeds the ${workspace.limits.maxWriteBytes}-byte workspace write limit.`, - }, - ) - .describe( - `UTF-8 text to write. The workspace enforces its ${workspace.limits.maxWriteBytes}-byte write limit.`, - ), - }; - const createSchema = z - .object({ ...commonWriteShape, mode: z.literal('create') }) - .strict(); - const replaceSchema = z - .object({ - ...commonWriteShape, - mode: z.literal('replace'), - etag: etagSchema, - }) - .strict(); - const inputSchema = - canCreate && canReplace - ? z.discriminatedUnion('mode', [createSchema, replaceSchema]) - : canCreate - ? createSchema - : replaceSchema; - - tools.workspace_write_file = tool< - z.infer, - unknown, - Record - >({ + const commonWriteShape = { + path: pathSchema, + content: z + .string() + .refine( + (value) => + utf8Encoder.encode(value).byteLength <= + workspace.limits.maxWriteBytes, + { + message: `Content exceeds the ${workspace.limits.maxWriteBytes}-byte workspace write limit.`, + }, + ) + .describe( + `UTF-8 text to write. The workspace enforces its ${workspace.limits.maxWriteBytes}-byte write limit.`, + ), + }; + if (mutationMode === 'last-write-wins' && workspace.allows('write')) { + const inputSchema = z.object(commonWriteShape).strict(); + tools.workspace_write_file = tool({ description: - canCreate && canReplace - ? 'Create a new UTF-8 text file or replace an existing file inside the mounted workspace. Create fails if the destination exists; replace requires its current ETag.' - : canCreate - ? 'Create a new UTF-8 text file inside the mounted workspace. The operation fails if the destination already exists.' - : 'Replace an existing UTF-8 text file inside the mounted workspace using its current ETag.', - // The combined create/replace schema is a discriminated union. OpenAI - // strict function tools reject its root-level oneOf, while the runtime - // Zod schema continues to validate every tool call in non-strict mode. - strict: !(canCreate && canReplace), + 'Write a UTF-8 text file inside the mounted workspace. An existing destination is overwritten; the last successful writer wins.', + strict: true, inputSchema, needsApproval: resolveApproval('workspace_write_file', requireApproval), - execute: (input, { abortSignal }) => - executeCreateAware( - abortSignal, - input, - async () => - serializeFile( - input.mode === 'create' - ? await workspace.writeFile(input.path, input.content, { - mode: 'create', - ...operationOptions(abortSignal), - }) - : await workspace.writeFile(input.path, input.content, { - mode: 'replace', - etag: input.etag, - ...operationOptions(abortSignal), - }), - ), - mapCreateConflict, + execute: ({ path, content }, { abortSignal }) => + executeSafely(abortSignal, async () => + serializeFile( + await workspace.writeFile(path, content, { + mode: 'overwrite', + ...operationOptions(abortSignal), + }), + ), ), }); + } else if (mutationMode === 'conditional') { + const canCreate = workspace.allows('create'); + const canReplace = workspace.allows('replace'); + if (canCreate || canReplace) { + const createSchema = z + .object({ ...commonWriteShape, mode: z.literal('create') }) + .strict(); + const replaceSchema = z + .object({ + ...commonWriteShape, + mode: z.literal('replace'), + etag: etagSchema, + }) + .strict(); + const inputSchema = + canCreate && canReplace + ? z.discriminatedUnion('mode', [createSchema, replaceSchema]) + : canCreate + ? createSchema + : replaceSchema; + + tools.workspace_write_file = tool< + z.infer, + unknown, + Record + >({ + description: + canCreate && canReplace + ? 'Create a new UTF-8 text file or replace an existing file inside the mounted workspace. Create fails if the destination exists; replace requires its current ETag.' + : canCreate + ? 'Create a new UTF-8 text file inside the mounted workspace. The operation fails if the destination already exists.' + : 'Replace an existing UTF-8 text file inside the mounted workspace using its current ETag.', + // The combined create/replace schema is a discriminated union. OpenAI + // strict function tools reject its root-level oneOf, while the runtime + // Zod schema continues to validate every tool call in non-strict mode. + strict: !(canCreate && canReplace), + inputSchema, + needsApproval: resolveApproval('workspace_write_file', requireApproval), + execute: (input, { abortSignal }) => + executeCreateAware( + abortSignal, + input, + async () => + serializeFile( + input.mode === 'create' + ? await workspace.writeFile(input.path, input.content, { + mode: 'create', + ...operationOptions(abortSignal), + }) + : await workspace.writeFile(input.path, input.content, { + mode: 'replace', + etag: input.etag, + ...operationOptions(abortSignal), + }), + ), + mapCreateConflict, + ), + }); + } } - const canCopy = - workspace.allows('copy') && - workspace.allows('read') && - workspace.allows('create'); - if (canCopy) { + const canCopy = workspace.allows('copy') && workspace.allows('read'); + if ( + mutationMode === 'last-write-wins' && + canCopy && + workspace.allows('write') + ) { + tools.workspace_copy_file = tool({ + description: + 'Copy the latest readable contents of a file inside the mounted workspace. The source remains intact, and an existing destination is overwritten.', + strict: true, + inputSchema: z + .object({ + source: logicalPath( + 'Source file path', + workspace.limits.maxPathBytes, + ), + destination: logicalPath( + 'Destination file path', + workspace.limits.maxPathBytes, + ), + }) + .strict(), + needsApproval: resolveApproval('workspace_copy_file', requireApproval), + execute: ({ source, destination }, { abortSignal }) => + executeSafely(abortSignal, async () => + serializeFile( + await workspace.copyFile(source, destination, { + mode: 'overwrite', + ...operationOptions(abortSignal), + }), + ), + ), + }); + } else if ( + mutationMode === 'conditional' && + canCopy && + workspace.allows('create') + ) { tools.workspace_copy_file = tool({ description: 'Copy an exact observed version of a file inside the mounted workspace. The source remains intact, and the operation fails if the source changed or the destination already exists.', @@ -624,12 +698,13 @@ export function createAiSdkWorkspaceTools< }); } - const canMove = + if ( + mutationMode === 'conditional' && workspace.allows('move') && workspace.allows('read') && - workspace.allows('create') && - workspace.allows('delete'); - if (canMove) { + workspace.allows('delete') && + workspace.allows('create') + ) { tools.workspace_move_file = tool({ description: "Move a file inside the mounted workspace using the source's current ETag. The operation fails if the destination already exists. If source deletion cannot be confirmed, the destination is retained and the tool reports a conflict; inspect both paths before retrying.", @@ -662,7 +737,27 @@ export function createAiSdkWorkspaceTools< }); } - if (workspace.allows('delete')) { + if ( + mutationMode === 'last-write-wins' && + workspace.allows('delete') && + workspace.allows('write') + ) { + tools.workspace_delete_file = tool({ + description: + 'Unconditionally delete the current file at a path inside the mounted workspace.', + strict: true, + inputSchema: z.object({ path: pathSchema }).strict(), + needsApproval: resolveApproval('workspace_delete_file', requireApproval), + execute: ({ path }, { abortSignal }) => + executeSafely(abortSignal, async () => { + await workspace.deleteFile(path, { + mode: 'unconditional', + ...operationOptions(abortSignal), + }); + return { deleted: true as const, path }; + }), + }); + } else if (mutationMode === 'conditional' && workspace.allows('delete')) { tools.workspace_delete_file = tool({ description: 'Delete a file inside the mounted workspace using its current ETag.', diff --git a/src/ai-sdk/index.ts b/src/ai-sdk/index.ts index 2ac17d0..b8400fc 100644 --- a/src/ai-sdk/index.ts +++ b/src/ai-sdk/index.ts @@ -9,6 +9,7 @@ export { type AiSdkWorkspaceDirectoryResult, type AiSdkWorkspaceEntryResult, type AiSdkWorkspaceFileResult, + type AiSdkWorkspaceMutationMode, type AiSdkWorkspaceMutationToolName, type AiSdkWorkspacePageResult, type AiSdkWorkspaceTextFileResult, diff --git a/src/workspace/index.ts b/src/workspace/index.ts index 7a1a770..5b39707 100644 --- a/src/workspace/index.ts +++ b/src/workspace/index.ts @@ -20,6 +20,9 @@ export { STORAGE_WORKSPACE_PERMISSIONS, type MountStorageWorkspaceOptions, type StorageWorkspaceBody, + type StorageWorkspaceByteFile, + type StorageWorkspaceCopyOptions, + type StorageWorkspaceDeleteOptions, type StorageWorkspace, type StorageWorkspaceDirectory, type StorageWorkspaceEntry, @@ -28,11 +31,13 @@ export { type StorageWorkspaceListOptions, type StorageWorkspaceMountOptions, type StorageWorkspaceMutationOptions, + type StorageWorkspaceOverwriteOptions, type StorageWorkspacePage, type StorageWorkspacePermission, type StorageWorkspaceReadOptions, type StorageWorkspaceSearchMatch, type StorageWorkspaceSearchOptions, type StorageWorkspaceTextFile, + type StorageWorkspaceUnconditionalDeleteOptions, type StorageWorkspaceWriteOptions, } from './storage-workspace.types.js'; diff --git a/src/workspace/storage-workspace.spec.ts b/src/workspace/storage-workspace.spec.ts index 94a7a00..c7f3da7 100644 --- a/src/workspace/storage-workspace.spec.ts +++ b/src/workspace/storage-workspace.spec.ts @@ -1,6 +1,7 @@ import { mkdirSync, mkdtempSync, + readFileSync, rmSync, symlinkSync, writeFileSync, @@ -9,6 +10,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { handlers } from 'files-sdk'; +import { encryption } from 'files-sdk/encryption'; import { createFsStorageDriver } from '../files-sdk/fs/index.js'; import { StorageClient } from '../storage.client.js'; @@ -29,6 +31,7 @@ const ALL_PERMISSIONS: readonly StorageWorkspacePermission[] = [ 'list', 'read', 'search', + 'write', 'create', 'replace', 'copy', @@ -250,6 +253,240 @@ describe('StorageWorkspace', () => { ); }); + it('routes overwrite reads and writes through the Files encryption plugin', async () => { + const driver = createFsStorageDriver({ + adapter: { root }, + plugins: [encryption(new Uint8Array(32).fill(0x5a))], + }); + const workspace = mountStorageWorkspace( + new StorageClient('encrypted-overwrite', driver), + { + permissions: ['read', 'write', 'copy', 'delete'], + prefix: 'runs/run-1', + }, + ); + + await workspace.writeFile('protected.txt', 'first plaintext', { + mode: 'overwrite', + }); + await workspace.writeFile('protected.txt', 'second plaintext', { + mode: 'overwrite', + }); + + await expect(workspace.readText('protected.txt')).resolves.toMatchObject({ + text: 'second plaintext', + }); + const raw = readFileSync(join(root, 'runs/run-1/protected.txt')); + expect(raw.includes(Buffer.from('first plaintext'))).toBe(false); + expect(raw.includes(Buffer.from('second plaintext'))).toBe(false); + + await workspace.copyFile('protected.txt', 'copied.txt', { + mode: 'overwrite', + }); + await expect(workspace.readText('copied.txt')).resolves.toMatchObject({ + text: 'second plaintext', + }); + const copiedRaw = readFileSync(join(root, 'runs/run-1/copied.txt')); + expect(copiedRaw.includes(Buffer.from('second plaintext'))).toBe(false); + expect(copiedRaw.equals(raw)).toBe(false); + + await workspace.deleteFile('copied.txt', { mode: 'unconditional' }); + await expect(workspace.stat('copied.txt')).rejects.toMatchObject({ + code: StorageErrorCode.NOT_FOUND, + }); + }); + + it('supports explicit last-write-wins copy and delete variants', async () => { + const { workspace } = mountedFs(root); + await workspace.writeFile('source.txt', 'latest', { + metadata: { owner: 'workspace' }, + mode: 'overwrite', + }); + await workspace.writeFile('copy.txt', 'stale-copy', { mode: 'overwrite' }); + + await expect( + workspace.copyFile('source.txt', 'copy.txt', { mode: 'overwrite' }), + ).resolves.toMatchObject({ path: 'copy.txt' }); + await expect(workspace.readText('copy.txt')).resolves.toMatchObject({ + text: 'latest', + }); + + await workspace.deleteFile('copy.txt', { mode: 'unconditional' }); + await expect(workspace.stat('copy.txt')).rejects.toMatchObject({ + code: StorageErrorCode.NOT_FOUND, + }); + }); + + it('bounds last-write-wins copy by streamed bytes before upload', async () => { + const driver = createMemoryStorageDriver(); + driver.download = vi.fn(async (key): Promise => ({ + body: new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + controller.enqueue(new Uint8Array([4, 5, 6])); + controller.close(); + }, + }), + contentType: 'application/octet-stream', + key, + name: key, + size: 1, + })); + const upload = vi.spyOn(driver, 'upload'); + const workspace = mountStorageWorkspace( + new StorageClient('bounded-overwrite-copy', driver), + { + limits: { maxWriteBytes: 5 }, + permissions: ['copy', 'read', 'write'], + prefix: 'scope', + }, + ); + + await expect( + workspace.copyFile('source.bin', 'destination.bin', { + mode: 'overwrite', + }), + ).rejects.toMatchObject({ code: StorageErrorCode.LIMIT_EXCEEDED }); + expect(upload).not.toHaveBeenCalled(); + }); + + it('keeps write authority separate from conditional create and replace', async () => { + const driver = createMemoryStorageDriver(); + const upload = vi.spyOn(driver, 'upload'); + const client = new StorageClient('write-permission', driver); + const conditionalOnly = mountStorageWorkspace(client, { + permissions: ['create', 'replace'], + prefix: 'conditional', + }); + const overwriteOnly = mountStorageWorkspace(client, { + permissions: ['write'], + prefix: 'overwrite', + }); + + await expect( + conditionalOnly.writeFile('file.txt', 'body', { mode: 'overwrite' }), + ).rejects.toMatchObject({ code: StorageErrorCode.UNAUTHORIZED }); + expect(upload).not.toHaveBeenCalled(); + + await expect( + overwriteOnly.writeFile('file.txt', 'body', { mode: 'overwrite' }), + ).resolves.toMatchObject({ path: 'file.txt' }); + await expect( + overwriteOnly.writeFile('conditional.txt', 'body', { mode: 'create' }), + ).rejects.toMatchObject({ code: StorageErrorCode.UNAUTHORIZED }); + }); + + it('requires both delete and write before an unconditional delete', async () => { + const driver = createMemoryStorageDriver({ + adapter: { initial: { 'scope/target.txt': 'body' } }, + }); + const deleteObject = vi.spyOn(driver, 'delete'); + const deleteConditional = vi.spyOn(driver, 'deleteConditional'); + const workspace = mountStorageWorkspace( + new StorageClient('unconditional-delete-permissions', driver), + { permissions: ['delete'], prefix: 'scope' }, + ); + + await expect( + workspace.deleteFile('target.txt', { mode: 'unconditional' }), + ).rejects.toMatchObject({ code: StorageErrorCode.UNAUTHORIZED }); + expect(deleteObject).not.toHaveBeenCalled(); + expect(deleteConditional).not.toHaveBeenCalled(); + }); + + it('rejects unknown explicit mutation modes before provider I/O', async () => { + const driver = createMemoryStorageDriver({ + adapter: { initial: { 'scope/source.txt': 'body' } }, + }); + const upload = vi.spyOn(driver, 'upload'); + const uploadConditional = vi.spyOn(driver, 'uploadConditional'); + const download = vi.spyOn(driver, 'download'); + const deleteObject = vi.spyOn(driver, 'delete'); + const deleteConditional = vi.spyOn(driver, 'deleteConditional'); + const workspace = mountStorageWorkspace( + new StorageClient('invalid-modes', driver), + { permissions: ALL_PERMISSIONS, prefix: 'scope' }, + ); + + await expect( + workspace.writeFile('target.txt', 'body', { + mode: 'unknown', + } as never), + ).rejects.toMatchObject({ code: StorageErrorCode.INVALID_ARGUMENT }); + await expect( + workspace.copyFile('source.txt', 'copy.txt', { + mode: 'unknown', + } as never), + ).rejects.toMatchObject({ code: StorageErrorCode.INVALID_ARGUMENT }); + await expect( + workspace.moveFile('source.txt', 'move.txt', { + mode: 'unknown', + } as never), + ).rejects.toMatchObject({ code: StorageErrorCode.INVALID_ARGUMENT }); + await expect( + workspace.deleteFile('source.txt', { mode: 'unknown' } as never), + ).rejects.toMatchObject({ code: StorageErrorCode.INVALID_ARGUMENT }); + await expect( + workspace.writeFile('target.txt', 'body', { + etag: 'ignored-etag', + mode: 'create', + } as never), + ).rejects.toMatchObject({ code: StorageErrorCode.INVALID_ARGUMENT }); + await expect( + workspace.writeFile('target.txt', 'body', { + etag: 'ignored-etag', + mode: 'overwrite', + } as never), + ).rejects.toMatchObject({ code: StorageErrorCode.INVALID_ARGUMENT }); + await expect( + workspace.copyFile('source.txt', 'copy.txt', { + etag: 'ignored-etag', + mode: 'overwrite', + } as never), + ).rejects.toMatchObject({ code: StorageErrorCode.INVALID_ARGUMENT }); + await expect( + workspace.moveFile('source.txt', 'move.txt', { + mode: 'overwrite', + } as never), + ).rejects.toMatchObject({ code: StorageErrorCode.INVALID_ARGUMENT }); + await expect( + workspace.moveFile('source.txt', 'move.txt', { + etag: 'ignored-etag', + mode: 'overwrite', + } as never), + ).rejects.toMatchObject({ code: StorageErrorCode.INVALID_ARGUMENT }); + await expect( + workspace.deleteFile('source.txt', { + etag: 'ignored-etag', + mode: 'unconditional', + } as never), + ).rejects.toMatchObject({ code: StorageErrorCode.INVALID_ARGUMENT }); + + expect(upload).not.toHaveBeenCalled(); + expect(uploadConditional).not.toHaveBeenCalled(); + expect(download).not.toHaveBeenCalled(); + expect(deleteObject).not.toHaveBeenCalled(); + expect(deleteConditional).not.toHaveBeenCalled(); + }); + + it('reads bounded binary files without requiring UTF-8', async () => { + const { workspace } = mountedFs(root); + const bytes = new Uint8Array([0, 0xff, 1, 0x80]); + await workspace.writeFile('binary.dat', bytes, { + contentType: 'application/octet-stream', + mode: 'overwrite', + }); + + await expect(workspace.readBytes('binary.dat')).resolves.toMatchObject({ + bytes, + contentType: 'application/octet-stream', + path: 'binary.dat', + }); + await expect(workspace.readText('binary.dat')).rejects.toMatchObject({ + code: StorageErrorCode.INVALID_ARGUMENT, + }); + }); + it('rejects non-canonical ETags for every conditional mutation', async () => { const driver = createFsStorageDriver({ adapter: { root } }); const workspace = mountStorageWorkspace( @@ -344,6 +581,10 @@ describe('StorageWorkspace', () => { code: StorageErrorCode.LIMIT_EXCEEDED, key: 'large.txt', }); + await expect(workspace.readBytes('large.txt')).rejects.toMatchObject({ + code: StorageErrorCode.LIMIT_EXCEEDED, + key: 'large.txt', + }); }); it('lists and searches relative to a selected directory with opaque cursors', async () => { diff --git a/src/workspace/storage-workspace.ts b/src/workspace/storage-workspace.ts index 2d25e36..61d8365 100644 --- a/src/workspace/storage-workspace.ts +++ b/src/workspace/storage-workspace.ts @@ -33,6 +33,9 @@ import type { MountStorageWorkspaceOptions, StorageWorkspace as StorageWorkspaceContract, StorageWorkspaceBody, + StorageWorkspaceByteFile, + StorageWorkspaceCopyOptions, + StorageWorkspaceDeleteOptions, StorageWorkspaceDirectory, StorageWorkspaceEntry, StorageWorkspaceFile, @@ -40,6 +43,7 @@ import type { StorageWorkspaceListOptions, StorageWorkspaceMountOptions, StorageWorkspaceMutationOptions, + StorageWorkspaceOverwriteOptions, StorageWorkspacePage, StorageWorkspacePermission, StorageWorkspaceReadOptions, @@ -106,6 +110,60 @@ function assertEtag(etag: string, operation: string): void { } } +function writeModeOf( + options: StorageWorkspaceWriteOptions, +): StorageWorkspaceWriteOptions['mode'] { + const candidate = options as { mode?: unknown; etag?: unknown } | undefined; + const mode = candidate?.mode; + if (mode === 'create' || mode === 'overwrite' || mode === 'replace') { + if (mode !== 'replace' && candidate !== undefined && 'etag' in candidate) { + throw workspaceError( + StorageErrorCode.INVALID_ARGUMENT, + 'Workspace write options may include an etag only in replace mode.', + { permanent: true }, + ); + } + return mode; + } + throw workspaceError( + StorageErrorCode.INVALID_ARGUMENT, + 'Workspace write mode must be create, overwrite, or replace.', + { permanent: true }, + ); +} + +function hasExplicitMode( + options: unknown, + expected: 'overwrite' | 'unconditional', + operation: string, +): boolean { + if (typeof options !== 'object' || options === null) { + throw workspaceError( + StorageErrorCode.INVALID_ARGUMENT, + `${operation} options must be an object.`, + { permanent: true }, + ); + } + if (!('mode' in options)) { + return false; + } + if (options.mode !== expected) { + throw workspaceError( + StorageErrorCode.INVALID_ARGUMENT, + `${operation} mode must be ${expected}.`, + { permanent: true }, + ); + } + if ('etag' in options) { + throw workspaceError( + StorageErrorCode.INVALID_ARGUMENT, + `${operation} ${expected} options must not include an etag.`, + { permanent: true }, + ); + } + return true; +} + function resolveLimits( requested: Partial | undefined, parent?: Readonly, @@ -364,7 +422,7 @@ async function collectBoundedBytes( await stream.cancel().catch(() => undefined); throw workspaceError( StorageErrorCode.LIMIT_EXCEEDED, - `Workspace file "${path}" exceeds the ${maxBytes}-byte copy limit.`, + `Workspace file "${path}" exceeds the ${maxBytes}-byte limit.`, { path, permanent: true }, ); } @@ -389,7 +447,7 @@ async function collectBoundedBytes( await reader.cancel().catch(() => undefined); throw workspaceError( StorageErrorCode.LIMIT_EXCEEDED, - `Workspace file "${path}" exceeded the ${maxBytes}-byte copy limit.`, + `Workspace file "${path}" exceeded the ${maxBytes}-byte limit.`, { path, permanent: true }, ); } @@ -500,6 +558,42 @@ class StorageWorkspaceImplementation implements StorageWorkspaceContract { } } + async readBytes( + path: string, + options?: StorageWorkspaceReadOptions, + ): Promise { + this.#require('read'); + const logicalPath = this.#filePath(path); + const maxBytes = options?.maxBytes ?? this.#limits.maxReadBytes; + positiveSafeInteger(maxBytes, 'maxBytes'); + if (maxBytes > this.#limits.maxReadBytes) { + throw workspaceError( + StorageErrorCode.LIMIT_EXCEEDED, + `maxBytes cannot exceed the ${this.#limits.maxReadBytes}-byte workspace read limit.`, + { path: logicalPath, permanent: true }, + ); + } + try { + const object = await this.#state.client.downloadStream( + this.#scope(logicalPath), + operationOptions(options), + ); + this.#assertResultPath(object.key, logicalPath); + const bytes = await collectBoundedBytes( + object.body, + object.size, + maxBytes, + logicalPath, + ); + return { ...logicalFile(object, logicalPath), bytes }; + } catch (error) { + throw sanitizeWorkspaceError(error, { + operation: 'read', + path: logicalPath, + }); + } + } + async list( options: StorageWorkspaceListOptions = {}, ): Promise { @@ -702,7 +796,8 @@ class StorageWorkspaceImplementation implements StorageWorkspaceContract { body: StorageWorkspaceBody, options: StorageWorkspaceWriteOptions, ): Promise { - this.#require(options.mode); + const mode = writeModeOf(options); + this.#require(mode === 'overwrite' ? 'write' : mode); const logicalPath = this.#filePath(path); if (typeof body !== 'string' && !(body instanceof Uint8Array)) { throw workspaceError( @@ -722,9 +817,6 @@ class StorageWorkspaceImplementation implements StorageWorkspaceContract { { path: logicalPath, permanent: true }, ); } - if (options.mode === 'replace') { - assertEtag(options.etag, 'Replace'); - } try { const common = { ...(options.contentType !== undefined && { @@ -733,26 +825,36 @@ class StorageWorkspaceImplementation implements StorageWorkspaceContract { ...(options.metadata !== undefined && { metadata: options.metadata }), ...operationOptions(options), }; - const result = - options.mode === 'create' - ? await this.#state.client.uploadConditional( - this.#scope(logicalPath), - body, - { ...common, condition: { type: 'create' } }, - ) - : await this.#state.client.uploadConditional( - this.#scope(logicalPath), - body, - { - ...common, - condition: { etag: options.etag, type: 'replace' }, - }, - ); + let result: StorageUploadResult; + if (mode === 'overwrite') { + result = await this.#state.client.upload( + this.#scope(logicalPath), + body, + common, + ); + } else if (mode === 'create') { + result = await this.#state.client.uploadConditional( + this.#scope(logicalPath), + body, + { ...common, condition: { type: 'create' } }, + ); + } else { + const etag = (options as { etag: string }).etag; + assertEtag(etag, 'Replace'); + result = await this.#state.client.uploadConditional( + this.#scope(logicalPath), + body, + { + ...common, + condition: { etag, type: 'replace' }, + }, + ); + } this.#assertResultPath(result.key, logicalPath); return logicalFile(result, logicalPath); } catch (error) { throw sanitizeWorkspaceError(error, { - operation: options.mode, + operation: mode, path: logicalPath, }); } @@ -761,11 +863,12 @@ class StorageWorkspaceImplementation implements StorageWorkspaceContract { async copyFile( source: string, destination: string, - options: StorageWorkspaceMutationOptions, + options: StorageWorkspaceCopyOptions, ): Promise { this.#require('copy'); this.#require('read'); - this.#require('create'); + const overwrite = hasExplicitMode(options, 'overwrite', 'Copy'); + this.#require(overwrite ? 'write' : 'create'); const sourcePath = this.#filePath(source, 'source path'); const destinationPath = this.#filePath(destination, 'destination path'); if (sourcePath === destinationPath) { @@ -775,7 +878,15 @@ class StorageWorkspaceImplementation implements StorageWorkspaceContract { { path: destinationPath, permanent: true }, ); } - assertEtag(options.etag, 'Copy'); + if (overwrite) { + return this.#copyOverwrite( + sourcePath, + destinationPath, + options as StorageWorkspaceOverwriteOptions, + ); + } + const conditional = options as StorageWorkspaceMutationOptions; + assertEtag(conditional.etag, 'Copy'); const capabilities = this.#state.client.capabilities; if ( capabilities.conditionalCreate?.resultEtag !== true || @@ -787,7 +898,7 @@ class StorageWorkspaceImplementation implements StorageWorkspaceContract { { operation: 'copy', path: destinationPath, permanent: true }, ); } - return this.#copyCreate(sourcePath, destinationPath, options); + return this.#copyCreate(sourcePath, destinationPath, conditional); } async moveFile( @@ -799,16 +910,11 @@ class StorageWorkspaceImplementation implements StorageWorkspaceContract { this.#require('read'); this.#require('create'); this.#require('delete'); - const capabilities = this.#state.client.capabilities; - if ( - capabilities.conditionalCreate?.resultEtag !== true || - capabilities.conditionalDelete?.etag !== true || - capabilities.conditionalRead?.etag !== true - ) { + if (typeof options !== 'object' || options === null || 'mode' in options) { throw workspaceError( - StorageErrorCode.NOT_SUPPORTED, - 'Safe move requires exact-ETag reads, create-only uploads that return an ETag, and conditional delete.', - { operation: 'move', permanent: true }, + StorageErrorCode.INVALID_ARGUMENT, + 'Workspace move supports only an ETag-conditional mutation.', + { permanent: true }, ); } const sourcePath = this.#filePath(source, 'source path'); @@ -820,6 +926,18 @@ class StorageWorkspaceImplementation implements StorageWorkspaceContract { { path: destinationPath, permanent: true }, ); } + const capabilities = this.#state.client.capabilities; + if ( + capabilities.conditionalCreate?.resultEtag !== true || + capabilities.conditionalDelete?.etag !== true || + capabilities.conditionalRead?.etag !== true + ) { + throw workspaceError( + StorageErrorCode.NOT_SUPPORTED, + 'Safe move requires exact-ETag reads, create-only uploads that return an ETag, and conditional delete.', + { operation: 'move', permanent: true }, + ); + } assertEtag(options.etag, 'Move'); const copied = await this.#copyCreate(sourcePath, destinationPath, options); if (copied.etag === undefined) { @@ -850,16 +968,28 @@ class StorageWorkspaceImplementation implements StorageWorkspaceContract { async deleteFile( path: string, - options: StorageWorkspaceMutationOptions, + options: StorageWorkspaceDeleteOptions, ): Promise { this.#require('delete'); const logicalPath = this.#filePath(path); - assertEtag(options.etag, 'Delete'); + const unconditional = hasExplicitMode(options, 'unconditional', 'Delete'); + if (unconditional) { + this.#require('write'); + } try { - await this.#state.client.deleteConditional(this.#scope(logicalPath), { - condition: { etag: options.etag }, - ...operationOptions(options), - }); + if (unconditional) { + await this.#state.client.delete( + this.#scope(logicalPath), + operationOptions(options), + ); + } else { + const conditional = options as StorageWorkspaceMutationOptions; + assertEtag(conditional.etag, 'Delete'); + await this.#state.client.deleteConditional(this.#scope(logicalPath), { + condition: { etag: conditional.etag }, + ...operationOptions(conditional), + }); + } } catch (error) { throw sanitizeWorkspaceError(error, { operation: 'delete', @@ -939,6 +1069,42 @@ class StorageWorkspaceImplementation implements StorageWorkspaceContract { } } + async #copyOverwrite( + sourcePath: string, + destinationPath: string, + options: StorageWorkspaceOverwriteOptions, + ): Promise { + try { + const source = await this.#state.client.downloadStream( + this.#scope(sourcePath), + operationOptions(options), + ); + this.#assertResultPath(source.key, sourcePath); + const bytes = await collectBoundedBytes( + source.body, + source.size, + this.#limits.maxWriteBytes, + sourcePath, + ); + const result = await this.#state.client.upload( + this.#scope(destinationPath), + bytes, + { + contentType: source.contentType, + ...(source.metadata !== undefined && { metadata: source.metadata }), + ...operationOptions(options), + }, + ); + this.#assertResultPath(result.key, destinationPath); + return logicalFile(result, destinationPath); + } catch (error) { + throw sanitizeWorkspaceError(error, { + operation: 'copy', + path: destinationPath, + }); + } + } + async #searchPage( query: string, context: { diff --git a/src/workspace/storage-workspace.types.ts b/src/workspace/storage-workspace.types.ts index a1bbf68..673cc20 100644 --- a/src/workspace/storage-workspace.types.ts +++ b/src/workspace/storage-workspace.types.ts @@ -6,6 +6,7 @@ export const STORAGE_WORKSPACE_PERMISSIONS = [ 'list', 'read', 'search', + 'write', 'create', 'replace', 'copy', @@ -21,7 +22,7 @@ export interface StorageWorkspaceLimits { maxCursorBytes: number; /** Maximum UTF-8 byte length of a workspace-relative path. */ maxPathBytes: number; - /** Maximum bytes returned by a buffered text read. */ + /** Maximum bytes returned by one buffered text or binary read. */ maxReadBytes: number; /** Maximum bytes accepted by one write. */ maxWriteBytes: number; @@ -73,6 +74,10 @@ export interface StorageWorkspaceTextFile extends StorageWorkspaceFile { text: string; } +export interface StorageWorkspaceByteFile extends StorageWorkspaceFile { + bytes: Uint8Array; +} + export interface StorageWorkspacePage { entries: StorageWorkspaceEntry[]; cursor?: string; @@ -119,6 +124,8 @@ interface StorageWorkspaceWriteCommon extends StorageOperationOptions { export type StorageWorkspaceWriteOptions = StorageWorkspaceWriteCommon & ( | { mode: 'create' } + /** Unconditionally writes the destination through the ordinary Files path. */ + | { mode: 'overwrite' } | { mode: 'replace'; etag: string; @@ -129,6 +136,22 @@ export interface StorageWorkspaceMutationOptions extends StorageOperationOptions etag: string; } +export interface StorageWorkspaceOverwriteOptions extends StorageOperationOptions { + /** Unconditionally overwrites the destination through the ordinary Files path. */ + mode: 'overwrite'; +} + +export interface StorageWorkspaceUnconditionalDeleteOptions extends StorageOperationOptions { + /** Deletes the current destination without an ETag precondition. */ + mode: 'unconditional'; +} + +export type StorageWorkspaceCopyOptions = + StorageWorkspaceMutationOptions | StorageWorkspaceOverwriteOptions; + +export type StorageWorkspaceDeleteOptions = + StorageWorkspaceMutationOptions | StorageWorkspaceUnconditionalDeleteOptions; + export type StorageWorkspaceBody = Extract; export interface StorageWorkspace { @@ -143,6 +166,10 @@ export interface StorageWorkspace { path: string, options?: StorageWorkspaceReadOptions, ): Promise; + readBytes( + path: string, + options?: StorageWorkspaceReadOptions, + ): Promise; list(options?: StorageWorkspaceListOptions): Promise; search( query: string, @@ -156,7 +183,7 @@ export interface StorageWorkspace { copyFile( source: string, destination: string, - options: StorageWorkspaceMutationOptions, + options: StorageWorkspaceCopyOptions, ): Promise; moveFile( source: string, @@ -165,7 +192,7 @@ export interface StorageWorkspace { ): Promise; deleteFile( path: string, - options: StorageWorkspaceMutationOptions, + options: StorageWorkspaceDeleteOptions, ): Promise; mount( directory: string,