@deepseek-ai/dsh-api-workspace-files 0.1.5-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +154 -0
- package/README.zh.md +154 -0
- package/lib/client.js +522 -0
- package/lib/index.js +551 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +853 -0
- package/lib/typert.remote-client.d.ts +38 -0
- package/lib/typert.remote-client.js +287 -0
- package/lib/types/changes.d.ts +26 -0
- package/lib/types/changes.js +109 -0
- package/lib/types/client/change-feed.d.ts +105 -0
- package/lib/types/client/change-feed.js +296 -0
- package/lib/types/client/index.d.ts +18 -0
- package/lib/types/client/index.js +26 -0
- package/lib/types/client/provider.d.ts +45 -0
- package/lib/types/client/provider.js +129 -0
- package/lib/types/client/remote.d.ts +44 -0
- package/lib/types/client/remote.js +2 -0
- package/lib/types/client/types.d.ts +65 -0
- package/lib/types/client/types.js +2 -0
- package/lib/types/index.d.ts +143 -0
- package/lib/types/index.js +368 -0
- package/lib/types/types.d.ts +163 -0
- package/lib/types/types.js +17 -0
- package/package.json +86 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace file service: paged text reads, byte-window reads, stats, directory
|
|
3
|
+
* listings, and the agent-write change feed inside one session's workspace
|
|
4
|
+
* root, exposed as the `workspaceFiles` Remote namespace.
|
|
5
|
+
*
|
|
6
|
+
* Reads through `ctx.fs` are deliberately unconfined — the sandboxing backend
|
|
7
|
+
* fences writes and edits only, and says so. Every constraint this service
|
|
8
|
+
* needs is therefore its own, and there are four:
|
|
9
|
+
*
|
|
10
|
+
* 1. The path is authorized by containment in the session's workspace root.
|
|
11
|
+
* 2. Containment is decided by {@link FileSystem.contains}, never by comparing
|
|
12
|
+
* path strings: `resolve` realpaths, so a prefix test cannot see a symlink
|
|
13
|
+
* that leaves the root. `lstat` rejects a link before that follow happens.
|
|
14
|
+
* 3. Every cap is validated Config, changeable per deployment. A page is cut by
|
|
15
|
+
* lines and refused, not shortened, when its bytes exceed the byte cap; a
|
|
16
|
+
* listing is cut by entries and says so.
|
|
17
|
+
* 4. Failures are one `RemoteError` per reason, declared in `./types`.
|
|
18
|
+
*
|
|
19
|
+
* A page is cut from `streamText`, which decodes and rejects non-UTF-8 as it
|
|
20
|
+
* goes, so the file is read only up to the first character past the page and
|
|
21
|
+
* never held whole in memory; the NUL scan runs on the page itself.
|
|
22
|
+
*
|
|
23
|
+
* This is NOT modelled on `session.openWorkspacePath`. That endpoint hands a
|
|
24
|
+
* path to the local opener and leaves the effect on the machine; this one sends
|
|
25
|
+
* file content across the wire, which is a different level of exposure.
|
|
26
|
+
*/
|
|
27
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
28
|
+
import z from '@deepseek-ai/schemastery';
|
|
29
|
+
import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
30
|
+
import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
31
|
+
import type { WorkspaceByteRange, WorkspaceDirectoryListing, WorkspaceFileBytes, WorkspaceFileRange, WorkspaceFileStat, WorkspaceFileText, WorkspaceFileWatchFrame } from './types.ts';
|
|
32
|
+
export type * from './types.ts';
|
|
33
|
+
declare module '@deepseek-ai/cordis' {
|
|
34
|
+
interface Context {
|
|
35
|
+
/** Host owner of the `workspaceFiles` Remote namespace. */
|
|
36
|
+
workspaceFiles: WorkspaceFiles;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Deployment caps on one page or one listing. */
|
|
40
|
+
export interface Config {
|
|
41
|
+
/**
|
|
42
|
+
* Inclusive byte cap on one page's text and on one byte window.
|
|
43
|
+
*
|
|
44
|
+
* A page above this fails; it is not shortened, because a silently cut page
|
|
45
|
+
* reads as the whole page. A byte window asking for more is refused the same
|
|
46
|
+
* way. The file itself has no size cap: a caller pages through it.
|
|
47
|
+
*/
|
|
48
|
+
readonly maxBytes: number;
|
|
49
|
+
/** Default and largest page size in lines; a request asking for more is refused. */
|
|
50
|
+
readonly maxLines: number;
|
|
51
|
+
/** Cap on returned directory entries; the rest is dropped and reported cut. */
|
|
52
|
+
readonly maxEntries: number;
|
|
53
|
+
}
|
|
54
|
+
/** Host Remote service over the composed filesystem, confined to one workspace. */
|
|
55
|
+
export declare class WorkspaceFiles extends TypertRemoteService {
|
|
56
|
+
private readonly config;
|
|
57
|
+
static inject: string[];
|
|
58
|
+
static Config: z<Config>;
|
|
59
|
+
private readonly feed;
|
|
60
|
+
/**
|
|
61
|
+
* @param ctx - Host context carrying the filesystem and the sandbox policy.
|
|
62
|
+
* @param config - deployment caps on one page or one listing.
|
|
63
|
+
*/
|
|
64
|
+
constructor(ctx: Context, config: Config);
|
|
65
|
+
/**
|
|
66
|
+
* Read one page of lines from a UTF-8 text file inside the Agent's workspace.
|
|
67
|
+
* @param agent - target Agent resolved from the Session identity on the wire.
|
|
68
|
+
* @param path - workspace path, absolute or relative to the workspace root.
|
|
69
|
+
* @param range - the line window; omitted fields take the page defaults.
|
|
70
|
+
* @param signal - caller cancellation.
|
|
71
|
+
* @returns the page, the file's version at the stat before it, and whether it reaches the last line.
|
|
72
|
+
*/
|
|
73
|
+
read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText>;
|
|
74
|
+
/**
|
|
75
|
+
* Read one byte window of a regular file inside the Agent's workspace: raw
|
|
76
|
+
* bytes, no text decoding and no binary rejection.
|
|
77
|
+
* @param agent - target Agent resolved from the Session identity on the wire.
|
|
78
|
+
* @param path - workspace path, absolute or relative to the workspace root.
|
|
79
|
+
* @param range - the byte window; omitted fields take the window defaults.
|
|
80
|
+
* @param signal - caller cancellation.
|
|
81
|
+
* @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte.
|
|
82
|
+
*/
|
|
83
|
+
readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes>;
|
|
84
|
+
/**
|
|
85
|
+
* Report one regular file's identity, version, and size without its content.
|
|
86
|
+
* @param agent - target Agent resolved from the Session identity on the wire.
|
|
87
|
+
* @param path - workspace path, absolute or relative to the workspace root.
|
|
88
|
+
* @param signal - caller cancellation.
|
|
89
|
+
* @returns the file's absolute path, current version, and byte size.
|
|
90
|
+
*/
|
|
91
|
+
stat(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>;
|
|
92
|
+
/**
|
|
93
|
+
* List the direct children of one directory inside the Agent's workspace.
|
|
94
|
+
* @param agent - target Agent resolved from the Session identity on the wire.
|
|
95
|
+
* @param path - workspace path, absolute or relative to the workspace root.
|
|
96
|
+
* @param signal - caller cancellation.
|
|
97
|
+
* @returns the directory's children in the backend's stable name order, bounded by the entry cap.
|
|
98
|
+
*/
|
|
99
|
+
list(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>;
|
|
100
|
+
/**
|
|
101
|
+
* Stream every `fs/observed` observation of a file inside the Agent's
|
|
102
|
+
* workspace. Only Agent filesystem operations report here; the OS is not
|
|
103
|
+
* watched.
|
|
104
|
+
* @param agent - target Agent resolved from the Session identity on the wire.
|
|
105
|
+
* @param signal - generation cancellation.
|
|
106
|
+
* @returns `ready` once the Host observation queue is active and the workspace
|
|
107
|
+
* root is resolved, then queued and live observations in emission order.
|
|
108
|
+
*/
|
|
109
|
+
changes(agent: Agent, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>;
|
|
110
|
+
/** Apply the page defaults and caps here, so the request never carries them implicitly. */
|
|
111
|
+
private resolvePage;
|
|
112
|
+
/** Apply the byte-window defaults and cap; a window above the cap is refused, not shortened. */
|
|
113
|
+
private resolveWindow;
|
|
114
|
+
/**
|
|
115
|
+
* The workspace root comes from the policy, not from the backend's own cwd
|
|
116
|
+
* default: the `minimal` preset shadows the host provider with a bare
|
|
117
|
+
* `fs-local` whose cwd differs, and resolving explicitly makes the answer
|
|
118
|
+
* the same whichever instance answers.
|
|
119
|
+
*/
|
|
120
|
+
private workspaceRootOf;
|
|
121
|
+
/**
|
|
122
|
+
* Gates 1 and 2 up to the point where the path's own type is known. The
|
|
123
|
+
* path is inspected before containment is decided, so a caller learns whether
|
|
124
|
+
* an outside path exists and what kind it is before `outside-workspace`
|
|
125
|
+
* refuses it; the caller is the Session's own owner, who can read the Host
|
|
126
|
+
* through the Agent anyway, and the accepted cost buys one `lstat` gate for
|
|
127
|
+
* every method instead of two resolution orders.
|
|
128
|
+
*/
|
|
129
|
+
private inspect;
|
|
130
|
+
/** Resolve an inspected path and refuse it unless the workspace contains it. */
|
|
131
|
+
private confine;
|
|
132
|
+
/**
|
|
133
|
+
* All gates for a regular file, ending in the one stat that names its version
|
|
134
|
+
* and size. The stat re-checks what `lstat` saw: the file may have gone or
|
|
135
|
+
* changed kind in between.
|
|
136
|
+
*/
|
|
137
|
+
private locateFile;
|
|
138
|
+
private statOf;
|
|
139
|
+
/** Stream the file as text and cut the page, classifying the backend's non-text refusal. */
|
|
140
|
+
private cutPage;
|
|
141
|
+
}
|
|
142
|
+
export default WorkspaceFiles;
|
|
143
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace file service: paged text reads, byte-window reads, stats, directory
|
|
3
|
+
* listings, and the agent-write change feed inside one session's workspace
|
|
4
|
+
* root, exposed as the `workspaceFiles` Remote namespace.
|
|
5
|
+
*
|
|
6
|
+
* Reads through `ctx.fs` are deliberately unconfined — the sandboxing backend
|
|
7
|
+
* fences writes and edits only, and says so. Every constraint this service
|
|
8
|
+
* needs is therefore its own, and there are four:
|
|
9
|
+
*
|
|
10
|
+
* 1. The path is authorized by containment in the session's workspace root.
|
|
11
|
+
* 2. Containment is decided by {@link FileSystem.contains}, never by comparing
|
|
12
|
+
* path strings: `resolve` realpaths, so a prefix test cannot see a symlink
|
|
13
|
+
* that leaves the root. `lstat` rejects a link before that follow happens.
|
|
14
|
+
* 3. Every cap is validated Config, changeable per deployment. A page is cut by
|
|
15
|
+
* lines and refused, not shortened, when its bytes exceed the byte cap; a
|
|
16
|
+
* listing is cut by entries and says so.
|
|
17
|
+
* 4. Failures are one `RemoteError` per reason, declared in `./types`.
|
|
18
|
+
*
|
|
19
|
+
* A page is cut from `streamText`, which decodes and rejects non-UTF-8 as it
|
|
20
|
+
* goes, so the file is read only up to the first character past the page and
|
|
21
|
+
* never held whole in memory; the NUL scan runs on the page itself.
|
|
22
|
+
*
|
|
23
|
+
* This is NOT modelled on `session.openWorkspacePath`. That endpoint hands a
|
|
24
|
+
* path to the local opener and leaves the effect on the machine; this one sends
|
|
25
|
+
* file content across the wire, which is a different level of exposure.
|
|
26
|
+
*/
|
|
27
|
+
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
|
28
|
+
var useValue = arguments.length > 2;
|
|
29
|
+
for (var i = 0; i < initializers.length; i++) {
|
|
30
|
+
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
31
|
+
}
|
|
32
|
+
return useValue ? value : void 0;
|
|
33
|
+
};
|
|
34
|
+
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
35
|
+
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
|
|
36
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
37
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
38
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
39
|
+
var _, done = false;
|
|
40
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
41
|
+
var context = {};
|
|
42
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
43
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
44
|
+
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
|
|
45
|
+
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
|
46
|
+
if (kind === "accessor") {
|
|
47
|
+
if (result === void 0) continue;
|
|
48
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
49
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
50
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
51
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
52
|
+
}
|
|
53
|
+
else if (_ = accept(result)) {
|
|
54
|
+
if (kind === "field") initializers.unshift(_);
|
|
55
|
+
else descriptor[key] = _;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
59
|
+
done = true;
|
|
60
|
+
};
|
|
61
|
+
import z from '@deepseek-ai/schemastery';
|
|
62
|
+
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
63
|
+
import { WorkspaceChangeFeed } from "./changes.js";
|
|
64
|
+
/** The byte text never carries: its presence marks a page as binary. */
|
|
65
|
+
const NUL = String.fromCharCode(0);
|
|
66
|
+
/** Refuse anything the wire schema admits as a number but a window cannot use: only safe integers index a file. */
|
|
67
|
+
function integerAtLeast(value, min, name) {
|
|
68
|
+
if (!Number.isSafeInteger(value) || value < min) {
|
|
69
|
+
throw new RemoteError('gateway/bad-request', `${name} must be a safe integer of at least ${min}`, {});
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Cut lines `offset` through `offset + limit - 1` from decoded chunks, stopping
|
|
75
|
+
* at the first character past the page so the rest of the file is never read.
|
|
76
|
+
* Lines before the page are counted, not kept, and the page is refused the
|
|
77
|
+
* moment its bytes exceed `maxBytes`, so one giant line cannot grow memory past
|
|
78
|
+
* the cap either.
|
|
79
|
+
*/
|
|
80
|
+
async function cutPage(chunks, offset, limit, maxBytes, path) {
|
|
81
|
+
const last = offset + limit - 1;
|
|
82
|
+
const lines = [];
|
|
83
|
+
let current = '';
|
|
84
|
+
let bytes = 0;
|
|
85
|
+
let lineNumber = 1;
|
|
86
|
+
const admit = (size) => {
|
|
87
|
+
bytes += size;
|
|
88
|
+
if (bytes > maxBytes) {
|
|
89
|
+
throw new RemoteError('workspace-file/too-large', `lines ${offset}-${last} of "${path}" exceed the ${maxBytes} byte cap`, { path, limit: maxBytes });
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const complete = () => {
|
|
93
|
+
if (lines.length > 0)
|
|
94
|
+
admit(1);
|
|
95
|
+
lines.push(current);
|
|
96
|
+
current = '';
|
|
97
|
+
};
|
|
98
|
+
for await (const chunk of chunks) {
|
|
99
|
+
let position = 0;
|
|
100
|
+
while (position < chunk.length) {
|
|
101
|
+
if (lineNumber > last)
|
|
102
|
+
return { text: lines.join('\n'), lines: lines.length, eof: false };
|
|
103
|
+
const newline = chunk.indexOf('\n', position);
|
|
104
|
+
const segment = newline === -1 ? chunk.slice(position) : chunk.slice(position, newline);
|
|
105
|
+
if (lineNumber >= offset) {
|
|
106
|
+
admit(Buffer.byteLength(segment, 'utf8'));
|
|
107
|
+
current += segment;
|
|
108
|
+
}
|
|
109
|
+
if (newline === -1)
|
|
110
|
+
break;
|
|
111
|
+
if (lineNumber >= offset)
|
|
112
|
+
complete();
|
|
113
|
+
lineNumber += 1;
|
|
114
|
+
position = newline + 1;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Only an in-page line can be pending here: earlier lines were never kept,
|
|
118
|
+
// and a character past the page returned above.
|
|
119
|
+
if (current.length > 0)
|
|
120
|
+
complete();
|
|
121
|
+
return { text: lines.join('\n'), lines: lines.length, eof: true };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Workspace path of `target` relative to `root`, derived from the two canonical
|
|
125
|
+
* `file:` URIs so the answer is `/`-joined on every platform. Empty for the root.
|
|
126
|
+
*/
|
|
127
|
+
function workspacePathOf(rootUrl, targetUrl) {
|
|
128
|
+
const root = new URL(rootUrl).pathname.replace(/\/+$/, '');
|
|
129
|
+
const target = new URL(targetUrl).pathname;
|
|
130
|
+
if (target === root)
|
|
131
|
+
return '';
|
|
132
|
+
return target.slice(root.length + 1).split('/').map(decodeURIComponent).join('/');
|
|
133
|
+
}
|
|
134
|
+
/** Strip the resolved child target: the wire carries names and metadata only. */
|
|
135
|
+
function directoryEntry(child) {
|
|
136
|
+
return {
|
|
137
|
+
name: child.name,
|
|
138
|
+
type: child.type,
|
|
139
|
+
...child.size === undefined ? {} : { size: child.size },
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** Host Remote service over the composed filesystem, confined to one workspace. */
|
|
143
|
+
let WorkspaceFiles = (() => {
|
|
144
|
+
let _classSuper = TypertRemoteService;
|
|
145
|
+
let _instanceExtraInitializers = [];
|
|
146
|
+
let _read_decorators;
|
|
147
|
+
let _readBytes_decorators;
|
|
148
|
+
let _stat_decorators;
|
|
149
|
+
let _list_decorators;
|
|
150
|
+
let _changes_decorators;
|
|
151
|
+
return class WorkspaceFiles extends _classSuper {
|
|
152
|
+
static {
|
|
153
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
154
|
+
_read_decorators = [Remote];
|
|
155
|
+
_readBytes_decorators = [Remote];
|
|
156
|
+
_stat_decorators = [Remote];
|
|
157
|
+
_list_decorators = [Remote];
|
|
158
|
+
_changes_decorators = [Remote({ mode: 'stream' })];
|
|
159
|
+
__esDecorate(this, null, _read_decorators, { kind: "method", name: "read", static: false, private: false, access: { has: obj => "read" in obj, get: obj => obj.read }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
160
|
+
__esDecorate(this, null, _readBytes_decorators, { kind: "method", name: "readBytes", static: false, private: false, access: { has: obj => "readBytes" in obj, get: obj => obj.readBytes }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
161
|
+
__esDecorate(this, null, _stat_decorators, { kind: "method", name: "stat", static: false, private: false, access: { has: obj => "stat" in obj, get: obj => obj.stat }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
162
|
+
__esDecorate(this, null, _list_decorators, { kind: "method", name: "list", static: false, private: false, access: { has: obj => "list" in obj, get: obj => obj.list }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
163
|
+
__esDecorate(this, null, _changes_decorators, { kind: "method", name: "changes", static: false, private: false, access: { has: obj => "changes" in obj, get: obj => obj.changes }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
164
|
+
if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
165
|
+
}
|
|
166
|
+
config = __runInitializers(this, _instanceExtraInitializers);
|
|
167
|
+
static inject = ['fs', 'sandboxPolicy', 'typert'];
|
|
168
|
+
static Config = z.object({
|
|
169
|
+
maxBytes: z.number().step(1).min(1).default(2 * 1024 * 1024),
|
|
170
|
+
maxLines: z.number().step(1).min(1).default(5000),
|
|
171
|
+
maxEntries: z.number().step(1).min(1).default(2000),
|
|
172
|
+
});
|
|
173
|
+
feed;
|
|
174
|
+
/**
|
|
175
|
+
* @param ctx - Host context carrying the filesystem and the sandbox policy.
|
|
176
|
+
* @param config - deployment caps on one page or one listing.
|
|
177
|
+
*/
|
|
178
|
+
constructor(ctx, config) {
|
|
179
|
+
super(ctx, 'workspaceFiles');
|
|
180
|
+
this.config = config;
|
|
181
|
+
this.feed = new WorkspaceChangeFeed(ctx);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Read one page of lines from a UTF-8 text file inside the Agent's workspace.
|
|
185
|
+
* @param agent - target Agent resolved from the Session identity on the wire.
|
|
186
|
+
* @param path - workspace path, absolute or relative to the workspace root.
|
|
187
|
+
* @param range - the line window; omitted fields take the page defaults.
|
|
188
|
+
* @param signal - caller cancellation.
|
|
189
|
+
* @returns the page, the file's version at the stat before it, and whether it reaches the last line.
|
|
190
|
+
*/
|
|
191
|
+
async read(agent, path, range, signal) {
|
|
192
|
+
const { offset, limit } = this.resolvePage(range);
|
|
193
|
+
const { target, info } = await this.locateFile(agent, path, signal);
|
|
194
|
+
const page = await this.cutPage(target, offset, limit, signal, path);
|
|
195
|
+
if (page.text.includes(NUL)) {
|
|
196
|
+
throw new RemoteError('workspace-file/not-text', `"${path}" contains NUL bytes`, { path });
|
|
197
|
+
}
|
|
198
|
+
return { ...this.statOf(target, info), offset, text: page.text, lines: page.lines, eof: page.eof };
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Read one byte window of a regular file inside the Agent's workspace: raw
|
|
202
|
+
* bytes, no text decoding and no binary rejection.
|
|
203
|
+
* @param agent - target Agent resolved from the Session identity on the wire.
|
|
204
|
+
* @param path - workspace path, absolute or relative to the workspace root.
|
|
205
|
+
* @param range - the byte window; omitted fields take the window defaults.
|
|
206
|
+
* @param signal - caller cancellation.
|
|
207
|
+
* @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte.
|
|
208
|
+
*/
|
|
209
|
+
async readBytes(agent, path, range, signal) {
|
|
210
|
+
const { offset, length } = this.resolveWindow(range, path);
|
|
211
|
+
const { target, info } = await this.locateFile(agent, path, signal);
|
|
212
|
+
const data = await this.ctx.fs.readByteRange(target, { offset, length }, signal);
|
|
213
|
+
const eof = info.size === undefined ? data.length < length : offset + data.length >= info.size;
|
|
214
|
+
return { ...this.statOf(target, info), offset, data: Buffer.from(data).toString('base64'), eof };
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Report one regular file's identity, version, and size without its content.
|
|
218
|
+
* @param agent - target Agent resolved from the Session identity on the wire.
|
|
219
|
+
* @param path - workspace path, absolute or relative to the workspace root.
|
|
220
|
+
* @param signal - caller cancellation.
|
|
221
|
+
* @returns the file's absolute path, current version, and byte size.
|
|
222
|
+
*/
|
|
223
|
+
async stat(agent, path, signal) {
|
|
224
|
+
const { target, info } = await this.locateFile(agent, path, signal);
|
|
225
|
+
return this.statOf(target, info);
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* List the direct children of one directory inside the Agent's workspace.
|
|
229
|
+
* @param agent - target Agent resolved from the Session identity on the wire.
|
|
230
|
+
* @param path - workspace path, absolute or relative to the workspace root.
|
|
231
|
+
* @param signal - caller cancellation.
|
|
232
|
+
* @returns the directory's children in the backend's stable name order, bounded by the entry cap.
|
|
233
|
+
*/
|
|
234
|
+
async list(agent, path, signal) {
|
|
235
|
+
const { root, workspaceRoot, entry } = await this.inspect(agent, path, signal);
|
|
236
|
+
if (entry.type !== 'directory') {
|
|
237
|
+
throw new RemoteError('workspace-file/not-directory', `"${path}" is a ${entry.type}`, { path, kind: entry.type });
|
|
238
|
+
}
|
|
239
|
+
const target = await this.confine(root, workspaceRoot, path, signal);
|
|
240
|
+
const children = await this.ctx.fs.listDir(target, signal);
|
|
241
|
+
return {
|
|
242
|
+
path: workspacePathOf(this.ctx.fs.fileUrl(root), this.ctx.fs.fileUrl(target)),
|
|
243
|
+
entries: children.slice(0, this.config.maxEntries).map(directoryEntry),
|
|
244
|
+
truncated: children.length > this.config.maxEntries,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Stream every `fs/observed` observation of a file inside the Agent's
|
|
249
|
+
* workspace. Only Agent filesystem operations report here; the OS is not
|
|
250
|
+
* watched.
|
|
251
|
+
* @param agent - target Agent resolved from the Session identity on the wire.
|
|
252
|
+
* @param signal - generation cancellation.
|
|
253
|
+
* @returns `ready` once the Host observation queue is active and the workspace
|
|
254
|
+
* root is resolved, then queued and live observations in emission order.
|
|
255
|
+
*/
|
|
256
|
+
changes(agent, signal) {
|
|
257
|
+
return this.feed.follow(this.workspaceRootOf(agent), signal);
|
|
258
|
+
}
|
|
259
|
+
/** Apply the page defaults and caps here, so the request never carries them implicitly. */
|
|
260
|
+
resolvePage(range) {
|
|
261
|
+
const offset = range.offset === undefined ? 1 : integerAtLeast(range.offset, 1, 'offset');
|
|
262
|
+
const limit = range.limit === undefined ? this.config.maxLines : integerAtLeast(range.limit, 1, 'limit');
|
|
263
|
+
if (limit > this.config.maxLines) {
|
|
264
|
+
throw new RemoteError('gateway/bad-request', `limit must be at most ${this.config.maxLines}`, {});
|
|
265
|
+
}
|
|
266
|
+
return { offset, limit };
|
|
267
|
+
}
|
|
268
|
+
/** Apply the byte-window defaults and cap; a window above the cap is refused, not shortened. */
|
|
269
|
+
resolveWindow(range, path) {
|
|
270
|
+
const offset = range.offset === undefined ? 0 : integerAtLeast(range.offset, 0, 'offset');
|
|
271
|
+
const length = range.length === undefined ? this.config.maxBytes : integerAtLeast(range.length, 1, 'length');
|
|
272
|
+
if (offset + length > Number.MAX_SAFE_INTEGER) {
|
|
273
|
+
throw new RemoteError('gateway/bad-request', 'offset plus length must stay a safe integer', {});
|
|
274
|
+
}
|
|
275
|
+
if (length > this.config.maxBytes) {
|
|
276
|
+
throw new RemoteError('workspace-file/too-large', `${length} bytes of "${path}" exceed the ${this.config.maxBytes} byte cap`, { path, limit: this.config.maxBytes });
|
|
277
|
+
}
|
|
278
|
+
return { offset, length };
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* The workspace root comes from the policy, not from the backend's own cwd
|
|
282
|
+
* default: the `minimal` preset shadows the host provider with a bare
|
|
283
|
+
* `fs-local` whose cwd differs, and resolving explicitly makes the answer
|
|
284
|
+
* the same whichever instance answers.
|
|
285
|
+
*/
|
|
286
|
+
workspaceRootOf(agent) {
|
|
287
|
+
return this.ctx.sandboxPolicy.resolve({ session: agent.session }).workspaceRoot;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Gates 1 and 2 up to the point where the path's own type is known. The
|
|
291
|
+
* path is inspected before containment is decided, so a caller learns whether
|
|
292
|
+
* an outside path exists and what kind it is before `outside-workspace`
|
|
293
|
+
* refuses it; the caller is the Session's own owner, who can read the Host
|
|
294
|
+
* through the Agent anyway, and the accepted cost buys one `lstat` gate for
|
|
295
|
+
* every method instead of two resolution orders.
|
|
296
|
+
*/
|
|
297
|
+
async inspect(agent, path, signal) {
|
|
298
|
+
if (path.length === 0)
|
|
299
|
+
throw new RemoteError('gateway/bad-request', 'path is required', {});
|
|
300
|
+
const workspaceRoot = this.workspaceRootOf(agent);
|
|
301
|
+
const root = await this.ctx.fs.resolve(workspaceRoot, { signal });
|
|
302
|
+
// Gate on the path itself before anything follows it.
|
|
303
|
+
const entry = await this.ctx.fs.lstat(path, { cwd: workspaceRoot }, signal);
|
|
304
|
+
if (entry === undefined) {
|
|
305
|
+
throw new RemoteError('workspace-file/not-found', `no entry at "${path}"`, { path });
|
|
306
|
+
}
|
|
307
|
+
return { root, workspaceRoot, entry };
|
|
308
|
+
}
|
|
309
|
+
/** Resolve an inspected path and refuse it unless the workspace contains it. */
|
|
310
|
+
async confine(root, workspaceRoot, path, signal) {
|
|
311
|
+
const target = await this.ctx.fs.resolve(path, { cwd: workspaceRoot, signal });
|
|
312
|
+
if (!this.ctx.fs.contains(root, target)) {
|
|
313
|
+
throw new RemoteError('workspace-file/outside-workspace', `"${path}" is outside the workspace`, { path });
|
|
314
|
+
}
|
|
315
|
+
return target;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* All gates for a regular file, ending in the one stat that names its version
|
|
319
|
+
* and size. The stat re-checks what `lstat` saw: the file may have gone or
|
|
320
|
+
* changed kind in between.
|
|
321
|
+
*/
|
|
322
|
+
async locateFile(agent, path, signal) {
|
|
323
|
+
const { root, workspaceRoot, entry } = await this.inspect(agent, path, signal);
|
|
324
|
+
if (entry.type !== 'file') {
|
|
325
|
+
throw new RemoteError('workspace-file/not-regular-file', `"${path}" is a ${entry.type}`, { path, kind: entry.type });
|
|
326
|
+
}
|
|
327
|
+
const target = await this.confine(root, workspaceRoot, path, signal);
|
|
328
|
+
const info = await this.ctx.fs.stat(target, signal);
|
|
329
|
+
if (info === undefined) {
|
|
330
|
+
throw new RemoteError('workspace-file/not-found', `no entry at "${path}"`, { path });
|
|
331
|
+
}
|
|
332
|
+
if (info.type !== 'file') {
|
|
333
|
+
throw new RemoteError('workspace-file/not-regular-file', `"${path}" is a ${info.type}`, { path, kind: info.type });
|
|
334
|
+
}
|
|
335
|
+
return { target, info };
|
|
336
|
+
}
|
|
337
|
+
statOf(target, info) {
|
|
338
|
+
return {
|
|
339
|
+
absolutePath: this.ctx.fs.processPath(target),
|
|
340
|
+
version: info.version,
|
|
341
|
+
...info.size === undefined ? {} : { bytes: info.size },
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
/** Stream the file as text and cut the page, classifying the backend's non-text refusal. */
|
|
345
|
+
async cutPage(target, offset, limit, signal, path) {
|
|
346
|
+
try {
|
|
347
|
+
return await cutPage(await this.ctx.fs.streamText(target, signal), offset, limit, this.config.maxBytes, path);
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
if (isNotTextRefusal(error)) {
|
|
351
|
+
throw new RemoteError('workspace-file/not-text', `"${path}" is not UTF-8 text`, { path }, { cause: error });
|
|
352
|
+
}
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
})();
|
|
358
|
+
export { WorkspaceFiles };
|
|
359
|
+
/**
|
|
360
|
+
* The backend's non-text refusal, recognized by its code alone: the error class
|
|
361
|
+
* belongs to whichever `dsh-fs` instance the provider loaded, so no class
|
|
362
|
+
* identity is shared across the package boundary.
|
|
363
|
+
*/
|
|
364
|
+
function isNotTextRefusal(error) {
|
|
365
|
+
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'FS_NOT_TEXT';
|
|
366
|
+
}
|
|
367
|
+
export default WorkspaceFiles;
|
|
368
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire types of the `workspaceFiles` Remote namespace. Types only: generated
|
|
3
|
+
* Remote clients consume this module without Host runtime code.
|
|
4
|
+
*
|
|
5
|
+
* Two path vocabularies leave here, and each method uses exactly one:
|
|
6
|
+
*
|
|
7
|
+
* - `read`, `readBytes`, `stat`, and `changes` name a file by its absolute path in the
|
|
8
|
+
* filesystem's execution world, because their consumer is the Client
|
|
9
|
+
* resource system, whose `dsh-resource://file/session/<id>/<path>` address carries that
|
|
10
|
+
* same path.
|
|
11
|
+
* - `list` speaks workspace paths — the same syntax its `path` argument accepts —
|
|
12
|
+
* because its consumer is a tree rooted at the workspace root.
|
|
13
|
+
*
|
|
14
|
+
* @module @deepseek-ai/dsh-api-workspace-files/types
|
|
15
|
+
*/
|
|
16
|
+
/** Identity and freshness of one workspace file, without its content. */
|
|
17
|
+
export interface WorkspaceFileStat {
|
|
18
|
+
/**
|
|
19
|
+
* Absolute path of the file in the filesystem's execution world, symlinks
|
|
20
|
+
* resolved: `/`-separated on POSIX, drive-rooted with the platform separator
|
|
21
|
+
* on Windows. What a `dsh-resource://file/absolute/…` address carries, and
|
|
22
|
+
* what a `dsh-resource://file/session/<sessionId>/…` address's
|
|
23
|
+
* workspace-relative path resolves to against that Session's root.
|
|
24
|
+
*/
|
|
25
|
+
readonly absolutePath: string;
|
|
26
|
+
/** Opaque freshness token at the time of the stat; never parsed. */
|
|
27
|
+
readonly version: string;
|
|
28
|
+
/** Byte size of the complete file, when the backend reports it. */
|
|
29
|
+
readonly bytes?: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The line window one `read` returns. Lines are 1-based and end at `\n`; a
|
|
33
|
+
* final `\n` terminates the last line rather than starting an empty one.
|
|
34
|
+
*/
|
|
35
|
+
export interface WorkspaceFileRange {
|
|
36
|
+
/** First line of the page. Defaults to 1. */
|
|
37
|
+
readonly offset?: number;
|
|
38
|
+
/** Largest number of lines on the page. Defaults to, and may not exceed, the configured `maxLines`. */
|
|
39
|
+
readonly limit?: number;
|
|
40
|
+
}
|
|
41
|
+
/** One page of a workspace text file as a Client reads it. */
|
|
42
|
+
export interface WorkspaceFileText extends WorkspaceFileStat {
|
|
43
|
+
/** First line of the page, as requested. */
|
|
44
|
+
readonly offset: number;
|
|
45
|
+
/**
|
|
46
|
+
* The page's lines joined by `\n`, without a terminator after the last one.
|
|
47
|
+
* Empty for a page past the file's last line and for a page holding one
|
|
48
|
+
* empty line; `lines` tells them apart.
|
|
49
|
+
*/
|
|
50
|
+
readonly text: string;
|
|
51
|
+
/** How many lines the page holds; `0` when `offset` lies past the file's last line. */
|
|
52
|
+
readonly lines: number;
|
|
53
|
+
/** Whether the page includes the file's last line. */
|
|
54
|
+
readonly eof: boolean;
|
|
55
|
+
}
|
|
56
|
+
/** The byte window one `readBytes` returns. Offsets are 0-based. */
|
|
57
|
+
export interface WorkspaceByteRange {
|
|
58
|
+
/** First byte of the window. Defaults to 0. */
|
|
59
|
+
readonly offset?: number;
|
|
60
|
+
/** Largest number of bytes in the window. Defaults to, and may not exceed, the configured `maxBytes`. */
|
|
61
|
+
readonly length?: number;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* One byte window of a workspace file as a Client reads it: raw bytes, no text
|
|
65
|
+
* decoding and no binary rejection. `bytes` is the complete file's size.
|
|
66
|
+
*/
|
|
67
|
+
export interface WorkspaceFileBytes extends WorkspaceFileStat {
|
|
68
|
+
/** First byte of the window, as requested. */
|
|
69
|
+
readonly offset: number;
|
|
70
|
+
/** The window's bytes in base64; empty when `offset` lies at or past the file's end. */
|
|
71
|
+
readonly data: string;
|
|
72
|
+
/** Whether the window includes the file's last byte. */
|
|
73
|
+
readonly eof: boolean;
|
|
74
|
+
}
|
|
75
|
+
/** One direct child of a listed workspace directory. */
|
|
76
|
+
export interface WorkspaceDirectoryEntry {
|
|
77
|
+
/** Basename inside the listed directory. */
|
|
78
|
+
readonly name: string;
|
|
79
|
+
/**
|
|
80
|
+
* What the child resolves to. A symlink reports the type of its destination,
|
|
81
|
+
* and `other` covers everything that is neither a regular file nor a
|
|
82
|
+
* directory; `read` still refuses a symlink, so `file` here is a listing fact,
|
|
83
|
+
* not a promise that the content is readable.
|
|
84
|
+
*/
|
|
85
|
+
readonly type: 'file' | 'directory' | 'other';
|
|
86
|
+
/** Byte size, present only for a regular file whose backend reports it. */
|
|
87
|
+
readonly size?: number;
|
|
88
|
+
}
|
|
89
|
+
/** Direct children of one workspace directory. */
|
|
90
|
+
export interface WorkspaceDirectoryListing {
|
|
91
|
+
/**
|
|
92
|
+
* The listed directory as a workspace path, relative to the workspace root
|
|
93
|
+
* and empty for the root itself. A child's path is this value joined with
|
|
94
|
+
* {@link WorkspaceDirectoryEntry.name} by `/`.
|
|
95
|
+
*/
|
|
96
|
+
readonly path: string;
|
|
97
|
+
/**
|
|
98
|
+
* Direct children in the backend's stable name order, cut to the configured
|
|
99
|
+
* entry cap. Presentation order is the caller's choice.
|
|
100
|
+
*/
|
|
101
|
+
readonly entries: readonly WorkspaceDirectoryEntry[];
|
|
102
|
+
/** Whether the entry cap dropped children from {@link entries}. */
|
|
103
|
+
readonly truncated: boolean;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* One observation of a workspace file made by an Agent's own filesystem
|
|
107
|
+
* operation. Frames report observations, not deltas: a consumer already holding
|
|
108
|
+
* `version` learns nothing new from the frame and can ignore it.
|
|
109
|
+
*/
|
|
110
|
+
export type WorkspaceFileChange = {
|
|
111
|
+
/** Absolute path of the observed file, in the same form as {@link WorkspaceFileStat.absolutePath}. */
|
|
112
|
+
readonly absolutePath: string;
|
|
113
|
+
/** Opaque freshness token after the observed operation; never parsed. */
|
|
114
|
+
readonly version: string;
|
|
115
|
+
} | {
|
|
116
|
+
/** Absolute path of the observed file, in the same form as {@link WorkspaceFileStat.absolutePath}. */
|
|
117
|
+
readonly absolutePath: string;
|
|
118
|
+
/** The file was observed to be gone. */
|
|
119
|
+
readonly absent: true;
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* One frame of a workspace file watch generation. `ready` confirms that the
|
|
123
|
+
* Host is observing filesystem operations and has resolved the workspace
|
|
124
|
+
* root; observations queued during that resolution follow as `change` frames.
|
|
125
|
+
*/
|
|
126
|
+
export type WorkspaceFileWatchFrame = {
|
|
127
|
+
readonly kind: 'ready';
|
|
128
|
+
} | {
|
|
129
|
+
readonly kind: 'change';
|
|
130
|
+
readonly change: WorkspaceFileChange;
|
|
131
|
+
};
|
|
132
|
+
declare module '@deepseek-ai/dsh-typert-protocol' {
|
|
133
|
+
interface RemoteErrorDetailsMap {
|
|
134
|
+
/** No entry exists at that path inside the workspace. */
|
|
135
|
+
'workspace-file/not-found': {
|
|
136
|
+
readonly path: string;
|
|
137
|
+
};
|
|
138
|
+
/** The path resolves outside the session's workspace root. */
|
|
139
|
+
'workspace-file/outside-workspace': {
|
|
140
|
+
readonly path: string;
|
|
141
|
+
};
|
|
142
|
+
/** The requested page exceeds the configured byte cap; nothing is returned. */
|
|
143
|
+
'workspace-file/too-large': {
|
|
144
|
+
readonly path: string;
|
|
145
|
+
readonly limit: number;
|
|
146
|
+
};
|
|
147
|
+
/** The content read so far is not decodable UTF-8 text, or the page carries NUL bytes. */
|
|
148
|
+
'workspace-file/not-text': {
|
|
149
|
+
readonly path: string;
|
|
150
|
+
};
|
|
151
|
+
/** The path is not a regular file, so it has no text to read. */
|
|
152
|
+
'workspace-file/not-regular-file': {
|
|
153
|
+
readonly path: string;
|
|
154
|
+
readonly kind: 'directory' | 'symlink' | 'other';
|
|
155
|
+
};
|
|
156
|
+
/** The path is not a directory, so it has no children to list. */
|
|
157
|
+
'workspace-file/not-directory': {
|
|
158
|
+
readonly path: string;
|
|
159
|
+
readonly kind: 'file' | 'symlink' | 'other';
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=types.d.ts.map
|