|
|
@@ -1,12 +1,14 @@
|
|
|
/**
|
|
|
* Workspace file service: read-only file previews, workspace directory
|
|
|
- * listings, and the agent-write change feed, exposed as `workspaceFiles`.
|
|
|
+ * listings, and the filesystem-observation change feed, exposed as
|
|
|
+ * `workspaceFiles`.
|
|
|
*
|
|
|
* File reads follow the composed filesystem's read access, including paths
|
|
|
- * outside the workspace. The Session's policy supplies the base for relative
|
|
|
- * paths, not a read-containment restriction. Directory listings and change
|
|
|
- * observations remain workspace-scoped. File-kind checks and configured read
|
|
|
- * caps apply to every preview; this service exposes no mutations.
|
|
|
+ * outside the workspace. The selected Session header supplies the base for
|
|
|
+ * relative paths, with the sandbox policy root as its no-cwd fallback, not a
|
|
|
+ * read-containment restriction. Directory listings and change observations
|
|
|
+ * remain workspace-scoped. File-kind checks and configured read caps apply to
|
|
|
+ * every preview; this service exposes no mutations.
|
|
|
*
|
|
|
* A page is cut from `streamText`, which decodes and rejects non-UTF-8 as it
|
|
|
* goes, so the file is read only up to the first character past the page and
|
|
|
@@ -20,11 +22,13 @@
|
|
|
import { posix, win32 } from 'node:path'
|
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
|
import z from '@deepseek-ai/schemastery'
|
|
|
-import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
|
import type {} from '@deepseek-ai/dsh-fs'
|
|
|
import type { FsDirEntry, FsInfo, FsPathInfo, FsTarget } from '@deepseek-ai/dsh-fs'
|
|
|
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
|
|
-import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
|
|
+import type {} from '@deepseek-ai/dsh-session'
|
|
|
+import type {} from '@deepseek-ai/dsh-session-persistence'
|
|
|
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
|
|
+import { Remote, RemoteError, TypertRemoteService, type TypertLookup } from '@deepseek-ai/dsh-typert-protocol'
|
|
|
import { WorkspaceChangeFeed } from './changes.ts'
|
|
|
import type {
|
|
|
WorkspaceByteRange,
|
|
|
@@ -46,6 +50,21 @@ declare module '@deepseek-ai/cordis' {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+/** Header-derived file resolution context for one Session identity. */
|
|
|
+export interface WorkspaceFileScope {
|
|
|
+ /** Session identity received on the wire. */
|
|
|
+ readonly sessionId: SessionId
|
|
|
+ /** Session workspace root, or the deployment fallback when its header has no cwd. */
|
|
|
+ readonly workspaceRoot: string
|
|
|
+}
|
|
|
+
|
|
|
+declare module '@deepseek-ai/dsh-typert-protocol' {
|
|
|
+ interface TypertLookupMap {
|
|
|
+ /** Resolve a Session id to its workspace root without loading its event body or activating an Agent. */
|
|
|
+ workspaceFileScope: TypertLookup<WorkspaceFileScope, SessionId>
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
/** Deployment caps on one page or one listing. */
|
|
|
export interface Config {
|
|
|
/**
|
|
|
@@ -161,7 +180,7 @@ function directoryEntry(child: FsDirEntry): WorkspaceDirectoryEntry {
|
|
|
|
|
|
/** Host Remote file reads and workspace directory observations over the composed filesystem. */
|
|
|
export class WorkspaceFiles extends TypertRemoteService {
|
|
|
- static inject = ['fs', 'sandboxPolicy', 'typert']
|
|
|
+ static inject = ['fs', 'sandboxPolicy', 'sessions', 'typert']
|
|
|
|
|
|
static Config: z<Config> = z.object({
|
|
|
maxBytes: z.number().step(1).min(1).default(2 * 1024 * 1024),
|
|
|
@@ -179,20 +198,45 @@ export class WorkspaceFiles extends TypertRemoteService {
|
|
|
constructor(ctx: Context, private readonly config: Config) {
|
|
|
super(ctx, 'workspaceFiles')
|
|
|
this.feed = new WorkspaceChangeFeed(ctx)
|
|
|
+ ctx.inject(['sessions', 'typert'], (scope) => {
|
|
|
+ scope.typert.lookups.register('workspaceFileScope', {
|
|
|
+ parameter: 'workspaceFileScope',
|
|
|
+ wire: 'workspaceFileScopeId',
|
|
|
+ hostTypeSymbol: '@deepseek-ai/dsh-api-workspace-files#WorkspaceFileScope',
|
|
|
+ wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
|
|
|
+ resolve: async (sessionId) => {
|
|
|
+ const live = scope.sessions.get(sessionId)?.header
|
|
|
+ const stored = live === undefined
|
|
|
+ ? await scope.get('sessionPersistence')?.stat(sessionId)
|
|
|
+ : undefined
|
|
|
+ const header = live ?? stored?.header
|
|
|
+ if (header === undefined) return undefined
|
|
|
+ return {
|
|
|
+ sessionId,
|
|
|
+ workspaceRoot: header.cwd ?? scope.sandboxPolicy.workspaceRoot,
|
|
|
+ }
|
|
|
+ },
|
|
|
+ })
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Read one page of lines from a UTF-8 file readable by the filesystem backend.
|
|
|
- * @param agent - target Agent resolved from the Session identity on the wire.
|
|
|
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
|
|
|
* @param path - absolute path or path relative to the workspace root; files outside it are allowed.
|
|
|
* @param range - the line window; omitted fields take the page defaults.
|
|
|
* @param signal - caller cancellation.
|
|
|
* @returns the page, the file's version at the stat before it, and whether it reaches the last line.
|
|
|
*/
|
|
|
@Remote
|
|
|
- async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText> {
|
|
|
+ async read(
|
|
|
+ workspaceFileScope: WorkspaceFileScope,
|
|
|
+ path: string,
|
|
|
+ range: WorkspaceFileRange,
|
|
|
+ signal: AbortSignal,
|
|
|
+ ): Promise<WorkspaceFileText> {
|
|
|
const { offset, limit } = this.resolvePage(range)
|
|
|
- const { target, info } = await this.locateFile(agent, path, signal)
|
|
|
+ const { target, info } = await this.locateFile(workspaceFileScope, path, signal)
|
|
|
const page = await this.cutPage(target, offset, limit, signal, path)
|
|
|
if (page.text.includes(NUL)) {
|
|
|
throw new RemoteError('workspace-file/not-text', `"${path}" contains NUL bytes`, { path })
|
|
|
@@ -203,16 +247,21 @@ export class WorkspaceFiles extends TypertRemoteService {
|
|
|
/**
|
|
|
* Read one byte window of a regular file readable by the filesystem backend: raw
|
|
|
* bytes, no text decoding and no binary rejection.
|
|
|
- * @param agent - target Agent resolved from the Session identity on the wire.
|
|
|
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
|
|
|
* @param path - absolute path or path relative to the workspace root; files outside it are allowed.
|
|
|
* @param range - the byte window; omitted fields take the window defaults.
|
|
|
* @param signal - caller cancellation.
|
|
|
* @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte.
|
|
|
*/
|
|
|
@Remote
|
|
|
- async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes> {
|
|
|
+ async readBytes(
|
|
|
+ workspaceFileScope: WorkspaceFileScope,
|
|
|
+ path: string,
|
|
|
+ range: WorkspaceByteRange,
|
|
|
+ signal: AbortSignal,
|
|
|
+ ): Promise<WorkspaceFileBytes> {
|
|
|
const { offset, length } = this.resolveWindow(range, path)
|
|
|
- const { target, info } = await this.locateFile(agent, path, signal)
|
|
|
+ const { target, info } = await this.locateFile(workspaceFileScope, path, signal)
|
|
|
const data = await this.ctx.fs.readByteRange(target, { offset, length }, signal)
|
|
|
const eof = info.size === undefined ? data.length < length : offset + data.length >= info.size
|
|
|
return { ...this.statOf(target, info), offset, data: Buffer.from(data).toString('base64'), eof }
|
|
|
@@ -220,14 +269,14 @@ export class WorkspaceFiles extends TypertRemoteService {
|
|
|
|
|
|
/**
|
|
|
* Read a complete regular file as bytes, subject to the configured full-file cap.
|
|
|
- * @param agent - target Agent whose workspace resolves relative paths.
|
|
|
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
|
|
|
* @param path - absolute or workspace-relative file path.
|
|
|
* @param signal - caller cancellation.
|
|
|
* @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large.
|
|
|
*/
|
|
|
@Remote
|
|
|
- async readAll(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes> {
|
|
|
- const { target, info } = await this.locateFile(agent, path, signal)
|
|
|
+ async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes> {
|
|
|
+ const { target, info } = await this.locateFile(workspaceFileScope, path, signal)
|
|
|
const limit = this.config.maxFileBytes
|
|
|
if (info.size !== undefined && info.size > limit) {
|
|
|
throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit })
|
|
|
@@ -241,47 +290,52 @@ export class WorkspaceFiles extends TypertRemoteService {
|
|
|
|
|
|
/**
|
|
|
* Read a complete file relative to another file's directory, including outside the workspace.
|
|
|
- * @param agent - Agent whose workspace resolves the base file's relative path.
|
|
|
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
|
|
|
* @param path - base file, absolute or workspace-relative.
|
|
|
* @param relativePath - relative filesystem path, not a URL or absolute path.
|
|
|
* @param signal - caller cancellation.
|
|
|
* @returns the complete related file using the ordinary file-size and access checks.
|
|
|
*/
|
|
|
@Remote
|
|
|
- async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes> {
|
|
|
+ async readRelated(
|
|
|
+ workspaceFileScope: WorkspaceFileScope,
|
|
|
+ path: string,
|
|
|
+ relativePath: string,
|
|
|
+ signal: AbortSignal,
|
|
|
+ ): Promise<WorkspaceFileBytes> {
|
|
|
const relative = relativePath.replace(/\\/g, '/')
|
|
|
if (relative.length === 0 || relative.startsWith('/') || /^[a-z][a-z\d+.-]*:/iu.test(relative) || relative.includes(NUL)) {
|
|
|
throw new RemoteError('gateway/bad-request', 'relativePath must be a relative filesystem path', {})
|
|
|
}
|
|
|
- const { target } = await this.locateFile(agent, path, signal)
|
|
|
+ const { target } = await this.locateFile(workspaceFileScope, path, signal)
|
|
|
const absolute = this.ctx.fs.processPath(target)
|
|
|
const paths = absolute.startsWith('/') ? posix : win32
|
|
|
- return this.readAll(agent, paths.resolve(paths.dirname(absolute), relative), signal)
|
|
|
+ return this.readAll(workspaceFileScope, paths.resolve(paths.dirname(absolute), relative), signal)
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Report one regular file's identity, version, and size without its content.
|
|
|
- * @param agent - target Agent resolved from the Session identity on the wire.
|
|
|
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
|
|
|
* @param path - absolute path or path relative to the workspace root; files outside it are allowed.
|
|
|
* @param signal - caller cancellation.
|
|
|
* @returns the file's absolute path, current version, and byte size.
|
|
|
*/
|
|
|
@Remote
|
|
|
- async stat(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileStat> {
|
|
|
- const { target, info } = await this.locateFile(agent, path, signal)
|
|
|
+ async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileStat> {
|
|
|
+ const { target, info } = await this.locateFile(workspaceFileScope, path, signal)
|
|
|
return this.statOf(target, info)
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * List the direct children of one directory inside the Agent's workspace.
|
|
|
- * @param agent - target Agent resolved from the Session identity on the wire.
|
|
|
+ * List the direct children of one directory inside the Session's workspace.
|
|
|
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
|
|
|
* @param path - workspace path, absolute or relative to the workspace root.
|
|
|
* @param signal - caller cancellation.
|
|
|
* @returns the directory's children in the backend's stable name order, bounded by the entry cap.
|
|
|
*/
|
|
|
@Remote
|
|
|
- async list(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing> {
|
|
|
- const { root, workspaceRoot, entry } = await this.inspect(agent, path, signal)
|
|
|
+ async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing> {
|
|
|
+ const { root, workspaceRoot, entry } = await this.inspect(workspaceFileScope, path, signal)
|
|
|
if (entry.type !== 'directory') {
|
|
|
throw new RemoteError(
|
|
|
'workspace-file/not-directory',
|
|
|
@@ -299,17 +353,17 @@ export class WorkspaceFiles extends TypertRemoteService {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Stream every `fs/observed` observation of a file inside the Agent's
|
|
|
- * workspace. Only Agent filesystem operations report here; the OS is not
|
|
|
- * watched.
|
|
|
- * @param agent - target Agent resolved from the Session identity on the wire.
|
|
|
+ * Stream every `fs/observed` observation of a file inside the Session's
|
|
|
+ * workspace. Only instrumented filesystem operations report here; the OS is
|
|
|
+ * not watched.
|
|
|
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
|
|
|
* @param signal - generation cancellation.
|
|
|
* @returns `ready` once the Host observation queue is active and the workspace
|
|
|
* root is resolved, then queued and live observations in emission order.
|
|
|
*/
|
|
|
@Remote({ mode: 'stream' })
|
|
|
- changes(agent: Agent, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame> {
|
|
|
- return this.feed.follow(this.workspaceRootOf(agent), signal)
|
|
|
+ changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame> {
|
|
|
+ return this.feed.follow(workspaceFileScope.workspaceRoot, signal)
|
|
|
}
|
|
|
|
|
|
/** Apply the page defaults and caps here, so the request never carries them implicitly. */
|
|
|
@@ -338,29 +392,17 @@ export class WorkspaceFiles extends TypertRemoteService {
|
|
|
}
|
|
|
return { offset, length }
|
|
|
}
|
|
|
-
|
|
|
-
|
|
|
- /**
|
|
|
- * The workspace root comes from the policy, not from the backend's own cwd
|
|
|
- * default: the `minimal` preset shadows the host provider with a bare
|
|
|
- * `fs-local` whose cwd differs, and resolving explicitly makes the answer
|
|
|
- * the same whichever instance answers.
|
|
|
- */
|
|
|
- private workspaceRootOf(agent: Agent): string {
|
|
|
- return this.ctx.sandboxPolicy.resolve({ session: agent.session }).workspaceRoot
|
|
|
- }
|
|
|
-
|
|
|
/**
|
|
|
* Inspect the requested path itself before resolution follows its final
|
|
|
* component. Directory containment is checked separately by `list`.
|
|
|
*/
|
|
|
private async inspect(
|
|
|
- agent: Agent,
|
|
|
+ workspaceFileScope: WorkspaceFileScope,
|
|
|
path: string,
|
|
|
signal: AbortSignal,
|
|
|
): Promise<{ root: FsTarget; workspaceRoot: string; entry: FsPathInfo }> {
|
|
|
if (path.length === 0) throw new RemoteError('gateway/bad-request', 'path is required', {})
|
|
|
- const workspaceRoot = this.workspaceRootOf(agent)
|
|
|
+ const { workspaceRoot } = workspaceFileScope
|
|
|
const root = await this.ctx.fs.resolve(workspaceRoot, { signal })
|
|
|
// Gate on the path itself before anything follows it.
|
|
|
const entry = await this.ctx.fs.lstat(path, { cwd: workspaceRoot }, signal)
|
|
|
@@ -384,8 +426,12 @@ export class WorkspaceFiles extends TypertRemoteService {
|
|
|
* and size. The stat re-checks what `lstat` saw: the file may have gone or
|
|
|
* changed kind in between.
|
|
|
*/
|
|
|
- private async locateFile(agent: Agent, path: string, signal: AbortSignal): Promise<{ target: FsTarget; info: FsInfo }> {
|
|
|
- const { workspaceRoot, entry } = await this.inspect(agent, path, signal)
|
|
|
+ private async locateFile(
|
|
|
+ workspaceFileScope: WorkspaceFileScope,
|
|
|
+ path: string,
|
|
|
+ signal: AbortSignal,
|
|
|
+ ): Promise<{ target: FsTarget; info: FsInfo }> {
|
|
|
+ const { workspaceRoot, entry } = await this.inspect(workspaceFileScope, path, signal)
|
|
|
if (entry.type !== 'file') {
|
|
|
throw new RemoteError('workspace-file/not-regular-file', `"${path}" is a ${entry.type}`, { path, kind: entry.type })
|
|
|
}
|