Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/add-workspace-last-write-wins.md
Original file line number Diff line number Diff line change
@@ -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.
52 changes: 51 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ const workspace = mountStorageWorkspace(agentFiles, {
'list',
'read',
'search',
'write',
'create',
'replace',
'copy',
Expand Down Expand Up @@ -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
Expand All @@ -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:

Expand Down Expand Up @@ -275,6 +310,7 @@ import type { ToolSet } from 'ai';
'list',
'read',
'search',
'write',
'create',
'replace',
'copy',
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
125 changes: 124 additions & 1 deletion src/ai-sdk/ai-sdk-workspace-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { StorageErrorCode } from '../storage.error.js';
import {
StorageWorkspaceError,
type StorageWorkspace,
type StorageWorkspaceByteFile,
type StorageWorkspaceEntry,
type StorageWorkspaceFile,
type StorageWorkspacePermission,
Expand All @@ -16,6 +17,7 @@ import {
} from './ai-sdk-workspace-tools.js';

interface ToolView {
description?: string;
execute?: (
input: unknown,
options: {
Expand All @@ -35,6 +37,7 @@ interface WorkspaceDouble {
list: ReturnType<typeof vi.fn>;
stat: ReturnType<typeof vi.fn>;
readText: ReturnType<typeof vi.fn>;
readBytes: ReturnType<typeof vi.fn>;
search: ReturnType<typeof vi.fn>;
writeFile: ReturnType<typeof vi.fn>;
copyFile: ReturnType<typeof vi.fn>;
Expand All @@ -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"',
Expand All @@ -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[],
}));
Expand All @@ -108,6 +117,7 @@ function createWorkspaceDouble(
list,
stat,
readText,
readBytes,
search,
writeFile,
copyFile,
Expand All @@ -120,6 +130,7 @@ function createWorkspaceDouble(
list,
stat,
readText,
readBytes,
search,
writeFile,
copyFile,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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.',
});
});

Expand Down
Loading