dsh-over-ssh 0.0.0-stage → 0.1.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.md +119 -2
- package/agents/agent-manifest.json +20 -0
- package/agents/linux-amd64/dsh-over-ssh-agent +0 -0
- package/agents/linux-arm64/dsh-over-ssh-agent +0 -0
- package/cordis.patch.yml +84 -0
- package/lib/client.js +6660 -0
- package/lib/file-references.js +411 -0
- package/lib/fs.js +1451 -0
- package/lib/index.js +19740 -0
- package/lib/local-bridge-refs.js +16 -0
- package/lib/local-bridge.js +16 -0
- package/lib/terminal-wrapper.js +299 -0
- package/lib/types/src/client/index.d.ts +49 -0
- package/lib/types/src/config.d.ts +383 -0
- package/lib/types/src/file-references.d.ts +24 -0
- package/lib/types/src/host/app/backend.d.ts +178 -0
- package/lib/types/src/host/app/recycle-bin/recycle-bin-contract.d.ts +246 -0
- package/lib/types/src/host/app/recycle-bin/recycle-bin-service.d.ts +44 -0
- package/lib/types/src/host/app/session-admin/session-admin-contract.d.ts +232 -0
- package/lib/types/src/host/app/session-admin/session-admin-service.d.ts +39 -0
- package/lib/types/src/host/app/transport/transport-selector.d.ts +97 -0
- package/lib/types/src/host/app/transport/transport-service.d.ts +81 -0
- package/lib/types/src/host/domain/capability.d.ts +77 -0
- package/lib/types/src/host/domain/errors.d.ts +54 -0
- package/lib/types/src/host/domain/roots/connection.d.ts +105 -0
- package/lib/types/src/host/domain/roots/root.d.ts +123 -0
- package/lib/types/src/host/domain/roots/session-roots.d.ts +150 -0
- package/lib/types/src/host/domain/roots/ssh-config.d.ts +72 -0
- package/lib/types/src/host/domain/session/session-admin.d.ts +344 -0
- package/lib/types/src/host/domain/transfer/recycle-bin.d.ts +336 -0
- package/lib/types/src/host/domain/transfer/transfer.d.ts +259 -0
- package/lib/types/src/host/infra/transport/agent/agent-installer.d.ts +120 -0
- package/lib/types/src/host/infra/transport/ssh/ssh2-transport.d.ts +327 -0
- package/lib/types/src/host/infra/transport/transport-base.d.ts +33 -0
- package/lib/types/src/host/infra/transport/transport.d.ts +541 -0
- package/lib/types/src/host/integration/agent/agent-preflight.d.ts +55 -0
- package/lib/types/src/host/integration/fs/file-reference-provider.d.ts +141 -0
- package/lib/types/src/host/integration/fs/fs-provider.d.ts +366 -0
- package/lib/types/src/host/integration/transfer/transfer-service.d.ts +211 -0
- package/lib/types/src/index.d.ts +88 -0
- package/lib/types/src/local-bridge-refs.d.ts +25 -0
- package/lib/types/src/local-bridge.d.ts +35 -0
- package/lib/types/src/shared/format.d.ts +13 -0
- package/lib/types/src/shared/root-keys.d.ts +120 -0
- package/lib/types/src/wiring/helpers.d.ts +143 -0
- package/package.json +122 -4
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-root implementation of the core `ctx.fileReferences` seam.
|
|
3
|
+
*
|
|
4
|
+
* The core ships [`@deepseek-ai/dsh-file-reference-local`] for **one** local workspace. This plugin's whole
|
|
5
|
+
* point is several roots of two kinds, so this provider answers the same contract for both:
|
|
6
|
+
*
|
|
7
|
+
* - a **remote** root is enumerated by {@link RemoteIndex} (Tier 2 `index.scan` when the connection has an
|
|
8
|
+
* agent, else one bounded `find`/`git ls-files`), cached per root;
|
|
9
|
+
* - a **local** root is enumerated here with a bounded walk, because installing this provider **replaces**
|
|
10
|
+
* the core's local one — returning nothing for local agents would be a regression, not a feature.
|
|
11
|
+
*
|
|
12
|
+
* Candidate paths use the router's display spelling (`ssh://<connection>/abs` for remote roots) so a token
|
|
13
|
+
* stays unambiguous in a multi-root workspace; the local case keeps ordinary host paths.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-over-ssh/integration/file-reference-provider
|
|
16
|
+
*/
|
|
17
|
+
import { Context } from '@deepseek-ai/cordis';
|
|
18
|
+
import FileReferenceService, { type FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference';
|
|
19
|
+
import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
20
|
+
import type { SessionIdentity, SessionRootOutcome } from '../../domain/roots/session-roots.ts';
|
|
21
|
+
/** The ADR-0013 session-root rule, published as `ctx.sshDevSessionRoots` by the main entry. */
|
|
22
|
+
export interface FileReferenceSessionRoots {
|
|
23
|
+
/** Which remote root this session added with 「添加目录」, when its cwd names none. */
|
|
24
|
+
outcomeFor(workspacePath: string | undefined, identity?: SessionIdentity): SessionRootOutcome;
|
|
25
|
+
}
|
|
26
|
+
declare module '@deepseek-ai/cordis' {
|
|
27
|
+
interface Context {
|
|
28
|
+
/** Session-added remote roots, shared with the tool surfaces (ADR-0013). */
|
|
29
|
+
sshDevSessionRoots?: FileReferenceSessionRoots;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** What the provider needs from the index layer (provided as `ctx.sshDevIndex`). */
|
|
33
|
+
export interface FileReferenceIndex {
|
|
34
|
+
/**
|
|
35
|
+
* Rank candidates for a partially typed `@` query inside one remote root.
|
|
36
|
+
*
|
|
37
|
+
* @param connectionId - connection that owns the root.
|
|
38
|
+
* @param root - backend-absolute remote root path.
|
|
39
|
+
* @param query - text after `@`.
|
|
40
|
+
* @param signal - caller cancellation.
|
|
41
|
+
* @returns ranked candidates whose `path` is a router-resolvable spelling
|
|
42
|
+
* (`ssh://<connection>/abs`), so a token stays unambiguous across roots.
|
|
43
|
+
*/
|
|
44
|
+
complete(connectionId: string, root: string, query: string, signal: AbortSignal): Promise<readonly FileReferenceCandidate[]>;
|
|
45
|
+
}
|
|
46
|
+
declare module '@deepseek-ai/cordis' {
|
|
47
|
+
interface Context {
|
|
48
|
+
/** Remote index used by the file-reference provider. */
|
|
49
|
+
sshDevIndex: FileReferenceIndex;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Directory names never traversed by the local walk (same intent as the core's exclusion list). */
|
|
53
|
+
export declare const LOCAL_EXCLUDED_DIRECTORIES: readonly string[];
|
|
54
|
+
/** Default cap for the local walk; the core uses a comparable bound for its own index. */
|
|
55
|
+
export declare const LOCAL_MAX_ENTRIES = 20000;
|
|
56
|
+
/** How deep the local walk may go before it gives up (protects against pathological trees). */
|
|
57
|
+
export declare const LOCAL_MAX_DEPTH = 12;
|
|
58
|
+
/**
|
|
59
|
+
* How long a local enumeration may be reused before the tree is walked again.
|
|
60
|
+
*
|
|
61
|
+
* **Short on purpose, and much shorter than the remote branch's 30 s** (`RemoteIndex` `ttlMs`). That cache
|
|
62
|
+
* exists to avoid a *network round trip*; this one exists only to collapse **one typing burst** into a single
|
|
63
|
+
* walk. A burst is a few keystrokes a couple of hundred milliseconds apart, so a quarter second folds the
|
|
64
|
+
* whole burst while keeping staleness below the threshold where a user could notice — creating a file and
|
|
65
|
+
* typing `@` again within 250 ms is not something a person does.
|
|
66
|
+
*
|
|
67
|
+
* The failure mode is deliberately the cheap one: a miss costs a walk, i.e. exactly today's behaviour, so
|
|
68
|
+
* this can never be *slower* than not caching, only less fresh for at most this long.
|
|
69
|
+
*
|
|
70
|
+
* Measured (`.verify/probe-local-fref-cache.ts`, 10 000-entry tree): typing `@module` walked the whole tree
|
|
71
|
+
* **6 times** and cost 413 ms; the walk is ~68 ms each, and it is paid per keystroke, on the host's event loop.
|
|
72
|
+
*/
|
|
73
|
+
export declare const LOCAL_CACHE_TTL_MS = 250;
|
|
74
|
+
/**
|
|
75
|
+
* Cap for the local enumeration cache.
|
|
76
|
+
*
|
|
77
|
+
* Keys are registry root paths, so the set is bounded by how many roots the user registered — but it is
|
|
78
|
+
* capped anyway, for the same reason `RemoteIndex` bounds its own caches: a bound that depends on "the
|
|
79
|
+
* caller will not do that" is not a bound.
|
|
80
|
+
*/
|
|
81
|
+
export declare const LOCAL_MAX_CACHED_ROOTS = 8;
|
|
82
|
+
/** How many candidates one query returns (the core's local provider uses the same order of magnitude). */
|
|
83
|
+
export declare const DEFAULT_MAX_RESULTS = 50;
|
|
84
|
+
/** One locally enumerated entry, with the kind completion needs. */
|
|
85
|
+
export interface LocalEntry {
|
|
86
|
+
readonly path: string;
|
|
87
|
+
readonly kind: 'file' | 'directory';
|
|
88
|
+
}
|
|
89
|
+
/** Construction options; `walk` is injected so tests never touch a real filesystem. */
|
|
90
|
+
export interface RemoteFileReferenceOptions {
|
|
91
|
+
/** Maximum entries collected in one local walk. Default {@link LOCAL_MAX_ENTRIES}. */
|
|
92
|
+
readonly maxEntries?: number;
|
|
93
|
+
/** Maximum depth of the local walk. Default {@link LOCAL_MAX_DEPTH}. */
|
|
94
|
+
readonly maxDepth?: number;
|
|
95
|
+
/** Maximum candidates returned per query. Default {@link DEFAULT_MAX_RESULTS}. */
|
|
96
|
+
readonly maxResults?: number;
|
|
97
|
+
/** Local enumerator, injected for tests. Defaults to a real bounded `readdir` walk. */
|
|
98
|
+
readonly walk?: (root: string, signal: AbortSignal) => Promise<readonly LocalEntry[]>;
|
|
99
|
+
/** How long a local enumeration stays reusable. Default {@link LOCAL_CACHE_TTL_MS}. */
|
|
100
|
+
readonly localTtlMs?: number;
|
|
101
|
+
/** Cap for the local enumeration cache. Default {@link LOCAL_MAX_CACHED_ROOTS}. */
|
|
102
|
+
readonly maxCachedRoots?: number;
|
|
103
|
+
/** Injectable clock, so the TTL is testable without sleeping. */
|
|
104
|
+
readonly now?: () => number;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The provider.
|
|
108
|
+
*
|
|
109
|
+
* Lifecycle note: it is registered as a **separate module** (`dsh-over-ssh/file-references`) so the
|
|
110
|
+
* composition decides whether to replace the core's local service — enabling it is an explicit choice.
|
|
111
|
+
*/
|
|
112
|
+
export declare class RemoteFileReferenceService extends FileReferenceService {
|
|
113
|
+
#private;
|
|
114
|
+
static inject: string[];
|
|
115
|
+
constructor(ctx: Context, options?: RemoteFileReferenceOptions);
|
|
116
|
+
/**
|
|
117
|
+
* List candidates for one agent's working directory.
|
|
118
|
+
*
|
|
119
|
+
* **The session cwd always answers, even when no registered root covers it** (FR-1.9 / ADR-0012).
|
|
120
|
+
* Stage 2 mounts this provider as `ctx.fs`, and the workspace a session was opened on need not be a
|
|
121
|
+
* registered root — the shape that broke it is a root registered *underneath* the cwd (session
|
|
122
|
+
* `D:\project\x`, the plugin's own root `D:\project\x\dsh-over-ssh`), where no root contains
|
|
123
|
+
* the cwd and the router answers `ROOT_OUTSIDE`. `@` completion must not go dark in that workspace,
|
|
124
|
+
* so the cwd itself is used as an **implicit local root** — the same rule `routed-fs` applies to the
|
|
125
|
+
* filesystem: local only, only the cwd subtree, remote roots unchanged.
|
|
126
|
+
*
|
|
127
|
+
* The implicit root is **this session's own directory**, so session isolation (ADR-0012) is not
|
|
128
|
+
* weakened: the walk starts at the cwd and cannot reach a sibling root, and a path outside the cwd is
|
|
129
|
+
* still not offered. Containment comes from the walk's root argument rather than a per-entry filter —
|
|
130
|
+
* a filter would re-normalize up to {@link LOCAL_MAX_ENTRIES} paths on **every keystroke**, which is
|
|
131
|
+
* exactly the cost audit 0057 removed.
|
|
132
|
+
*
|
|
133
|
+
* @param agent - target agent; its session cwd decides which root answers.
|
|
134
|
+
* @param query - text after `@` (may be empty: "show the shallowest entries").
|
|
135
|
+
* @param signal - caller cancellation.
|
|
136
|
+
* @returns ranked candidates, best first; empty when nothing can answer — no registry, a remote cwd
|
|
137
|
+
* without an index, or a cwd that is not an absolute host path.
|
|
138
|
+
*/
|
|
139
|
+
list(agent: Agent, query: string, signal: AbortSignal): Promise<FileReferenceCandidate[]>;
|
|
140
|
+
}
|
|
141
|
+
export default RemoteFileReferenceService;
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `ctx.fs` provider: the core filesystem seam served by this plugin's router.
|
|
3
|
+
*
|
|
4
|
+
* Core components (the model's `read`/`write`/`edit` tools, the workspace file panel, agent
|
|
5
|
+
* instructions) reach the filesystem only through `ctx.fs`, so registering this provider is what
|
|
6
|
+
* makes remote roots usable everywhere without touching a single consumer.
|
|
7
|
+
*
|
|
8
|
+
* Mapping rules that matter:
|
|
9
|
+
*
|
|
10
|
+
* - `targetKey` is the router's namespaced key, wrapped in the core's branded type. Consumers treat
|
|
11
|
+
* it as opaque; this provider is the owner that may parse it back.
|
|
12
|
+
* - Core error codes are produced here, not leaked from the domain layer: `FS_STALE_VERSION`,
|
|
13
|
+
* `FS_NOT_OBSERVED`, `FS_EDIT_NOT_FOUND`, `FS_AMBIGUOUS_EDIT`, `FS_NOT_TEXT`, `FS_TOO_LARGE`,
|
|
14
|
+
* `FS_SANDBOX_DENIED`, `FS_IO_ERROR`.
|
|
15
|
+
* - The fence is the plugin's own multi-root confinement (ADR-0003): `read-only` denies every
|
|
16
|
+
* mutation, a root registered `writable: false` is denied, and `workspace-write` admits every
|
|
17
|
+
* registered root — the core's single-root fence cannot express several roots.
|
|
18
|
+
* - A failed resolution first **syncs the live sessions' working directories** into session-scoped local
|
|
19
|
+
* roots and retries once. The platform reads the workspace by absolute path *without* a cwd while a turn
|
|
20
|
+
* starts (`agent-instructions`), so a session whose cwd no root covered used to lose the whole turn; the
|
|
21
|
+
* sync is what makes its own directory resolvable. It costs nothing on the happy path.
|
|
22
|
+
*
|
|
23
|
+
* @module dsh-over-ssh/integration/fs-provider
|
|
24
|
+
*/
|
|
25
|
+
import { Context } from '@deepseek-ai/cordis';
|
|
26
|
+
import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs';
|
|
27
|
+
import type { FsDirEntry, FsEditOutcome, FsEditRequest, FsInfo, FsPathInfo, FsTarget, FsWriteIntent, FsWriteOutcome } from '@deepseek-ai/dsh-fs';
|
|
28
|
+
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox';
|
|
29
|
+
import type { Backend } from '../../app/backend.ts';
|
|
30
|
+
import { SshDevError, type SshDevErrorCode } from '../../domain/errors.ts';
|
|
31
|
+
/**
|
|
32
|
+
* One true window read: the bytes at `[offset, offset + length)`, fetched by the backend that owns
|
|
33
|
+
* the path **without** buffering the prefix before `offset` (the defect recorded in audit 0016's Lead
|
|
34
|
+
* list, item 1: `readByteRange` used to read `[0, end)` and slice).
|
|
35
|
+
*/
|
|
36
|
+
export interface RangeWindow {
|
|
37
|
+
readonly bytes: Uint8Array;
|
|
38
|
+
/** Full size of the file when the backend can report it cheaply; `undefined` when it cannot. */
|
|
39
|
+
readonly size?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Whether `bytes` stops before the end of the file.
|
|
42
|
+
*
|
|
43
|
+
* `false` means the window reached EOF and is the **only** signal
|
|
44
|
+
* {@link RoutedFsProvider.readByteRange} needs to stop paging: without it a window that ends exactly
|
|
45
|
+
* at EOF would cost one extra empty round trip, and a window that ends past EOF would look identical
|
|
46
|
+
* to one that stopped short. Every reader must report it truthfully — a reader that keeps saying
|
|
47
|
+
* `true` makes the provider page on past the end.
|
|
48
|
+
*/
|
|
49
|
+
readonly truncated: boolean;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Offset-read seam.
|
|
53
|
+
*
|
|
54
|
+
* This is an **injected optional function**, not a `Backend` method: `Backend` (and
|
|
55
|
+
* `RemoteBackend`, the only backend that could implement it) is deliberately outside this change's
|
|
56
|
+
* write scope, so the capability arrives as a constructor dependency instead. It is handed the
|
|
57
|
+
* resolved backend and its root-absolute path, so one injected function can serve every root.
|
|
58
|
+
*
|
|
59
|
+
* Return `null` — never throw — to say "this backend/transport has no offset read". The provider then
|
|
60
|
+
* falls back to the bounded prefix read and does **not** pretend the read was chunked.
|
|
61
|
+
*/
|
|
62
|
+
export type BackendRangeReader = (backend: Backend, request: {
|
|
63
|
+
readonly absolute: string;
|
|
64
|
+
readonly offset: number;
|
|
65
|
+
readonly length: number;
|
|
66
|
+
}, signal?: AbortSignal) => Promise<RangeWindow | null>;
|
|
67
|
+
/** Options for {@link RoutedFsProvider}. */
|
|
68
|
+
export interface RoutedFsProviderOptions {
|
|
69
|
+
/**
|
|
70
|
+
* How to read a byte window where the backend can seek.
|
|
71
|
+
*
|
|
72
|
+
* Omitted (the default, and what `ctx.plugin(RoutedFsProvider)` gets) means every read keeps the
|
|
73
|
+
* old semantics. Wire it at the point the remote backend is built (`src/index.ts`
|
|
74
|
+
* `createRemoteBackend`, currently lines 355-365), where the transport — and therefore
|
|
75
|
+
* `Transport.readFileRange` — is reachable; see {@link createBackendRangeReader}.
|
|
76
|
+
*/
|
|
77
|
+
readonly readBytesRange?: BackendRangeReader;
|
|
78
|
+
/** Largest window one underlying request may ask for; the memory bound for a chunked read. */
|
|
79
|
+
readonly rangeChunkBytes?: number;
|
|
80
|
+
/**
|
|
81
|
+
* Reads the working directories of the live sessions, for the session-root sync.
|
|
82
|
+
*
|
|
83
|
+
* Defaults to the platform's session service (`ctx.get('sessions')`, see {@link readSessionCwds}); injected
|
|
84
|
+
* here so a test can drive the sync without a Cordis host. Returning `[]` is always safe: it disables the
|
|
85
|
+
* sync and leaves every existing behaviour untouched.
|
|
86
|
+
*/
|
|
87
|
+
readonly sessionWorkspaceCwds?: () => readonly string[];
|
|
88
|
+
/**
|
|
89
|
+
* The fork lineage of one session, nearest first (ADR-0013).
|
|
90
|
+
*
|
|
91
|
+
* Defaults to the platform's session service (`ctx.get('sessions')`, see {@link readSessionAncestors});
|
|
92
|
+
* injected here so a test can drive the rule without a Cordis host. Returning `[]` is always safe: a fork
|
|
93
|
+
* then simply does not inherit, which is the pre-ADR-0013 behaviour.
|
|
94
|
+
*/
|
|
95
|
+
readonly sessionAncestors?: (sessionId: string) => readonly string[];
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Filesystem provider over the plugin's roots.
|
|
99
|
+
*
|
|
100
|
+
* Registers as `ctx.fs`; the composition replaces the core `fs-sandbox` row with this one so the
|
|
101
|
+
* model's file tools follow the router (see `cordis.patch.yml`).
|
|
102
|
+
*/
|
|
103
|
+
export declare class RoutedFsProvider extends FileSystem {
|
|
104
|
+
static inject: string[];
|
|
105
|
+
protected readonly rangeReader: BackendRangeReader | undefined;
|
|
106
|
+
protected readonly rangeChunkBytes: number;
|
|
107
|
+
/** Reads the live sessions' working directories; see {@link RoutedFsProviderOptions.sessionWorkspaceCwds}. */
|
|
108
|
+
protected readonly sessionWorkspaceCwds: () => readonly string[];
|
|
109
|
+
/** Reads one session's fork lineage; see {@link RoutedFsProviderOptions.sessionAncestors}. */
|
|
110
|
+
protected readonly sessionAncestors: (sessionId: string) => readonly string[];
|
|
111
|
+
/**
|
|
112
|
+
* Change detector for {@link syncSessionRoots}: the state the last sync produced.
|
|
113
|
+
*
|
|
114
|
+
* A small mutable holder rather than a `string` field: every method here may run with the Cordis service Proxy
|
|
115
|
+
* as `this`, and mutating a property through that Proxy is the one thing this class must not rely on — mutating
|
|
116
|
+
* a property of an object it *read* through the Proxy is fine.
|
|
117
|
+
*/
|
|
118
|
+
protected readonly sessionSync: {
|
|
119
|
+
lastSignature: string;
|
|
120
|
+
};
|
|
121
|
+
constructor(ctx: Context, options?: RoutedFsProviderOptions);
|
|
122
|
+
/** This provider confines writes to the registered roots. */
|
|
123
|
+
get sandboxMode(): SandboxMode;
|
|
124
|
+
/** Resolve a model-facing path to the router's target and wrap it for the core. */
|
|
125
|
+
resolve(path: string, opts?: {
|
|
126
|
+
cwd?: string;
|
|
127
|
+
signal?: AbortSignal;
|
|
128
|
+
}): Promise<FsTarget>;
|
|
129
|
+
/** Path a subprocess in the target's world can open (the remote absolute path for remote roots). */
|
|
130
|
+
processPath(target: FsTarget): string;
|
|
131
|
+
/**
|
|
132
|
+
* Map a host path into the process world: for a local root the two are the same string.
|
|
133
|
+
*
|
|
134
|
+
* **This is a mapping, not an access check.** It used to answer `undefined` for any path outside the
|
|
135
|
+
* registered local roots, which broke the platform's own machinery: the PTC worker maps its runtime into the
|
|
136
|
+
* subprocess world through this method, so every `run_code` call died with
|
|
137
|
+
* `PTC runtime bootstrap is unavailable in the subprocess execution world:
|
|
138
|
+
* …/@deepseek-ai/dsh-ptc-runtime-node/lib/process.js` — a file that exists and that the harness has every
|
|
139
|
+
* right to read (2026-10-31, reported by the user as "the session cannot read the workspace").
|
|
140
|
+
*
|
|
141
|
+
* The core's own `dsh-fs-local` answers `resolve(hostPath)` unconditionally for exactly this reason: the
|
|
142
|
+
* question is "what is this host path called in the process world", not "may this caller open it". Access
|
|
143
|
+
* control lives in {@link resolve}/{@link readFile}/{@link writeText}, where the roots are enforced.
|
|
144
|
+
*/
|
|
145
|
+
processPathFromHostPath(hostPath: string): string | undefined;
|
|
146
|
+
/** Canonical `file:` URI in the target's world. */
|
|
147
|
+
fileUrl(target: FsTarget): string;
|
|
148
|
+
/** Containment; two targets in different worlds never contain one another. */
|
|
149
|
+
contains(parent: FsTarget, child: FsTarget): boolean;
|
|
150
|
+
/** Metadata, or `undefined` for an absent target. */
|
|
151
|
+
stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>;
|
|
152
|
+
/** Path metadata without following a final symlink, where the backend can tell. */
|
|
153
|
+
lstat(path: string, opts?: {
|
|
154
|
+
cwd?: string;
|
|
155
|
+
}, signal?: AbortSignal): Promise<FsPathInfo | undefined>;
|
|
156
|
+
/**
|
|
157
|
+
* Whole-file UTF-8 text; binary and oversize files fail with typed codes.
|
|
158
|
+
*
|
|
159
|
+
* Reads **bytes** rather than calling `backend.readText`, so the binary sniff happens before decoding: the
|
|
160
|
+
* previous shape asked the backend for the decoded text and then re-encoded the whole string
|
|
161
|
+
* (`Buffer.from(text, 'utf8')`) only to look at its first {@link BINARY_SNIFF_BYTES} bytes. Measured on a
|
|
162
|
+
* 9 MiB file (audit 2026-11-01b follow-up): `open`+`stat`+`read` 2.65 ms, `+` decode 3.95 ms, `+` the
|
|
163
|
+
* redundant re-encode **5.66 ms** — 1.71 ms, ~75% of the plugin's overhead, spent to encode bytes the caller
|
|
164
|
+
* had just decoded. `TextDecoder` then decodes straight off the read buffer without the extra copy.
|
|
165
|
+
*/
|
|
166
|
+
readText(target: FsTarget, signal?: AbortSignal): Promise<string>;
|
|
167
|
+
/**
|
|
168
|
+
* Stream text in chunks, paging through the offset reader when the composition injected one.
|
|
169
|
+
*
|
|
170
|
+
* The platform calls this for every file at or above **its own** streaming threshold —
|
|
171
|
+
* `@deepseek-ai/dsh-tool-fs/lib/index.js:217` (`STREAM_MIN_SIZE = 10 * 1024 * 1024`) feeding `:338` — and
|
|
172
|
+
* `@deepseek-ai/dsh-fs/lib/types/index.d.ts:164-172` makes the backend own both the chunking and the
|
|
173
|
+
* cross-chunk UTF-8 decoding. Answering by reading the whole file first, which is what this did, made that
|
|
174
|
+
* contract a lie twice over: it buffered the entire file *and* it refused anything past the whole-file cap
|
|
175
|
+
* with `FS_TOO_LARGE`, so the `read` tool could not open a large remote log at all even when the caller had
|
|
176
|
+
* asked for one window of it.
|
|
177
|
+
*
|
|
178
|
+
* Without the offset seam the previous semantics are kept exactly: one bounded whole read, one chunk, and
|
|
179
|
+
* `FS_TOO_LARGE` past the cap. Nothing pretends to be chunked when it is not.
|
|
180
|
+
*/
|
|
181
|
+
streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>;
|
|
182
|
+
/** Whole-file bytes, refusing oversized targets instead of truncating. */
|
|
183
|
+
readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array>;
|
|
184
|
+
/**
|
|
185
|
+
* One byte window, bounded by the caller's window rather than the file size.
|
|
186
|
+
*
|
|
187
|
+
* Two paths, and they are deliberately distinguishable:
|
|
188
|
+
*
|
|
189
|
+
* - the backend has an offset read (the injected {@link BackendRangeReader} answers with a window):
|
|
190
|
+
* the window is fetched as a sequence of requests of at most the configured chunk size, each
|
|
191
|
+
* starting at the file offset it needs. Nothing before `offset` is ever transferred or held, so
|
|
192
|
+
* the memory bound is the chunk size plus the returned window, not the file size. The window
|
|
193
|
+
* itself is capped at {@link MAX_RANGE_WINDOW_BYTES} — it is returned whole, so it is allocated
|
|
194
|
+
* whole, and an unbounded `length` would be an unbounded allocation;
|
|
195
|
+
* - the backend has none (a local backend, a transport without `readFileRange`, or no injected
|
|
196
|
+
* reader at all): the previous semantics are kept exactly — one bounded prefix read `[0, end)`
|
|
197
|
+
* capped at 8 MiB, then a slice — and a window past that cap still fails with `FS_TOO_LARGE`
|
|
198
|
+
* instead of silently buffering the file.
|
|
199
|
+
*/
|
|
200
|
+
readByteRange(target: FsTarget, range: {
|
|
201
|
+
offset: number;
|
|
202
|
+
length: number;
|
|
203
|
+
}, signal?: AbortSignal): Promise<Uint8Array>;
|
|
204
|
+
/** Direct children, in stable name order, with resolved child targets. */
|
|
205
|
+
listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]>;
|
|
206
|
+
/** Atomic write, honouring the write intent and the plugin's fence. */
|
|
207
|
+
writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy): Promise<FsWriteOutcome>;
|
|
208
|
+
/** Literal edit with the core's three-state semantics and an optional version guard. */
|
|
209
|
+
editText(target: FsTarget, edit: FsEditRequest, expected?: {
|
|
210
|
+
version: FsVersion;
|
|
211
|
+
}, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy): Promise<FsEditOutcome>;
|
|
212
|
+
/**
|
|
213
|
+
* Watch one target through its backend.
|
|
214
|
+
*
|
|
215
|
+
* The route decides the world, then the backend owns the mechanism:
|
|
216
|
+
*
|
|
217
|
+
* - **local roots** delegate to the platform's own `FileSystem.watch` (chokidar) via `PlatformLocalBackend`.
|
|
218
|
+
* Before this method was wired, taking over `ctx.fs` made **local** files stop reporting changes: the
|
|
219
|
+
* platform's `dsh-api-workspace-files` got `workspace-file/watch-unsupported` for every target, because
|
|
220
|
+
* `RoutedFsProvider` replaced the sandboxed provider (a `LocalFileSystem` subclass) and this method only
|
|
221
|
+
* ever threw. Delegating restores the pre-takeover behaviour without a second chokidar implementation.
|
|
222
|
+
* - **remote roots** with a native agent watcher use `watch.start/poll/stop`; Tier 1 remains unsupported
|
|
223
|
+
* and keeps failing honestly (the caller falls back to revalidation on read).
|
|
224
|
+
*
|
|
225
|
+
* A backend that has **no** `watch` method keeps the historical `FS_IO_ERROR` refusal — never a silent
|
|
226
|
+
* no-op, which would leave a consumer believing it is subscribed.
|
|
227
|
+
*
|
|
228
|
+
* @param target - the target whose changes were requested.
|
|
229
|
+
* @param changed - platform callback: re-stat on no argument, subscription-failure on an error.
|
|
230
|
+
* @param signal - cancels watcher initialization and subscription.
|
|
231
|
+
* @returns the disposer (`() => Promise<void>`), idempotent.
|
|
232
|
+
* @throws FsError `FS_IO_ERROR` when the backend cannot watch, plus the mapped backend failures
|
|
233
|
+
* (`FS_NOT_FOUND`, `AbortError` for cancellation, …).
|
|
234
|
+
*/
|
|
235
|
+
watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise<void>>;
|
|
236
|
+
private rootsService;
|
|
237
|
+
private router;
|
|
238
|
+
/**
|
|
239
|
+
* Resolve a path, syncing the live sessions' working directories once if it fails.
|
|
240
|
+
*
|
|
241
|
+
* Why on failure rather than before every call: the sync costs a walk of the live-session list plus a
|
|
242
|
+
* containment scan over the roots, and the overwhelming majority of resolutions succeed against a registered
|
|
243
|
+
* root. Why it must exist at all: the platform reads the workspace through `ctx.fs` by **absolute path without
|
|
244
|
+
* a cwd** while a turn starts, so a session whose own directory no root covers lost the entire turn before the
|
|
245
|
+
* model was called — there was no cwd to key a fallback on.
|
|
246
|
+
*
|
|
247
|
+
* One sync, then one retry: a session that appeared a moment ago is therefore picked up on its **first** read
|
|
248
|
+
* rather than failing once and working on the next call, and a second failure means the sync genuinely had
|
|
249
|
+
* nothing to add (the path is outside every root), so the caller gets the original error.
|
|
250
|
+
*
|
|
251
|
+
* @param path - model-facing path.
|
|
252
|
+
* @param cwd - the caller's session cwd, when it has one.
|
|
253
|
+
* @returns the resolution; when nothing changed the caller keeps the **original** failure.
|
|
254
|
+
*/
|
|
255
|
+
private resolveWithSessionSync;
|
|
256
|
+
/**
|
|
257
|
+
* The route options for one call: the cwd the platform supplied, plus the session identity this call runs
|
|
258
|
+
* under when the tool dispatcher carried one down (`integration/session-scope.ts`, ADR-0013).
|
|
259
|
+
*
|
|
260
|
+
* Both are kept because they answer different questions. The cwd is what the platform's contract provides
|
|
261
|
+
* (relative spellings, the workspace-level rule, the anchor rule); the session is the only thing that can
|
|
262
|
+
* separate two conversations opened on the same directory. A call with neither is an agentless one and keeps
|
|
263
|
+
* the plugin-wide view, exactly as before.
|
|
264
|
+
*
|
|
265
|
+
* @param cwd - the caller's session cwd, when it has one.
|
|
266
|
+
* @returns the options to hand the route table.
|
|
267
|
+
*/
|
|
268
|
+
private sessionRouteOptions;
|
|
269
|
+
/**
|
|
270
|
+
* The working directory of one live session, or `undefined` when it is not listed.
|
|
271
|
+
*
|
|
272
|
+
* @param sessionId - the asking session.
|
|
273
|
+
* @returns its cwd, when the platform reports one.
|
|
274
|
+
*/
|
|
275
|
+
private sessionWorkspaceCwdOf;
|
|
276
|
+
/**
|
|
277
|
+
* Re-read the live sessions and register their working directories as session roots.
|
|
278
|
+
*
|
|
279
|
+
* Guarded by **change detection**, not by a time window: reading the session list and comparing it with the
|
|
280
|
+
* state the last sync produced costs microseconds, while a time throttle would have a window in which a session
|
|
281
|
+
* created a moment ago is not registered yet — and that session's very first read is exactly the one that used
|
|
282
|
+
* to kill the turn. The signature includes the session roots that currently exist, so a registry reset (which
|
|
283
|
+
* drops them) is noticed and repaired too.
|
|
284
|
+
*/
|
|
285
|
+
private syncSessionRoots;
|
|
286
|
+
private routedTarget;
|
|
287
|
+
/** Enforce the per-call policy and the root's own writability. */
|
|
288
|
+
private assertWritable;
|
|
289
|
+
/**
|
|
290
|
+
* Previous text content, when it is readable text inside the cap; `undefined` otherwise.
|
|
291
|
+
*
|
|
292
|
+
* `undefined` here means **"no previous text to report"**, never "the target is absent" — the caller
|
|
293
|
+
* already decided existence from {@link BackendStat.exists}. Keeping those two apart is what stops a
|
|
294
|
+
* directory / oversized / binary file from slipping past a `createIfAbsent` guard.
|
|
295
|
+
*/
|
|
296
|
+
private readPriorText;
|
|
297
|
+
/** Run a backend call, translating domain errors into core codes. */
|
|
298
|
+
private call;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* The plugin callback {@link createRoutedFsProvider} answers.
|
|
302
|
+
*
|
|
303
|
+
* `inject` is part of the shape because Cordis reads it off the plugin it is handed; typing it here keeps
|
|
304
|
+
* the factory's contract honest instead of relying on a cast at the call site.
|
|
305
|
+
*/
|
|
306
|
+
export interface FsProviderPlugin {
|
|
307
|
+
(ctx: Context): void;
|
|
308
|
+
inject: readonly string[];
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Build the `ctx.fs` plugin with an injected offset-read seam.
|
|
312
|
+
*
|
|
313
|
+
* Cordis registers a plugin by calling it with the context, so a deployment that has the range reader
|
|
314
|
+
* available can register `createRoutedFsProvider({ readBytesRange })` instead of the bare class and
|
|
315
|
+
* get true chunked window reads; registering the class itself keeps the old semantics. Both are the
|
|
316
|
+
* same provider, so nothing else in the composition changes.
|
|
317
|
+
*
|
|
318
|
+
* Two details are load-bearing, and both were wrong before (found by loading the real `./fs` row in a
|
|
319
|
+
* packaged host — the provider never activated, so every `fs` consumer stayed pending):
|
|
320
|
+
*
|
|
321
|
+
* - the callback must **not return the instance**. Cordis treats a function plugin's return value as
|
|
322
|
+
* an effect to apply, and a provider instance is not one: `ctx.plugin(fn)` fails with
|
|
323
|
+
* `Invalid effect` and the service is never provided. Constructing the instance inside the call is
|
|
324
|
+
* what registers it (it extends `FileSystem`, which registers itself in the constructor).
|
|
325
|
+
* - the callback must **carry the class's `inject`**. Cordis reads `inject` off the plugin it is
|
|
326
|
+
* given, and a bare arrow function has none, so the provider would install before `sshDevRoots`
|
|
327
|
+
* exists and silently read an undefined service.
|
|
328
|
+
*
|
|
329
|
+
* @param options - the seam plus the chunk size; see {@link RoutedFsProviderOptions}.
|
|
330
|
+
* @returns a plugin callback that constructs the provider on registration.
|
|
331
|
+
*/
|
|
332
|
+
export declare function createRoutedFsProvider(options: RoutedFsProviderOptions): FsProviderPlugin;
|
|
333
|
+
/**
|
|
334
|
+
* Build the {@link BackendRangeReader} the provider injects.
|
|
335
|
+
*
|
|
336
|
+
* The Lead wires the pieces that live outside this change's write scope: a store that already holds a
|
|
337
|
+
* backend's bytes (tests), or — in production — the transport behind the backend, calling
|
|
338
|
+
* `Transport.readFileRange({ path, offset, maxBytes })` and returning `null` when that optional method
|
|
339
|
+
* is absent. The provider owns everything else: chunking, the memory bound, EOF short-circuiting.
|
|
340
|
+
*
|
|
341
|
+
* @param store - reads `[offset, offset + length)` from a backend, or `undefined` for a missing path.
|
|
342
|
+
* @param options - `chunkSizeHint` advertises the largest window worth asking for; it is **not** how the
|
|
343
|
+
* provider bounds memory (the provider chunks by its own `rangeChunkBytes`).
|
|
344
|
+
* @returns a reader that answers `null` (never throws) when the store has no offset read.
|
|
345
|
+
*/
|
|
346
|
+
export declare function createBackendRangeReader(store: (backend: Backend, offset: number, length: number) => {
|
|
347
|
+
readonly bytes: Uint8Array;
|
|
348
|
+
readonly size: number;
|
|
349
|
+
} | undefined, options?: {
|
|
350
|
+
readonly chunkSizeHint?: number;
|
|
351
|
+
}): BackendRangeReader;
|
|
352
|
+
/**
|
|
353
|
+
* Translate a domain failure into the core's filesystem vocabulary.
|
|
354
|
+
*
|
|
355
|
+
* Two mappings are deliberately *not* the tempting ones:
|
|
356
|
+
*
|
|
357
|
+
* - **`CANCELLED` becomes an `AbortError`, not `FS_IO_ERROR`.** A cancelled tool call is not an I/O failure,
|
|
358
|
+
* and the platform already recognises the abort shape (the same one {@link assertNotAborted} produces), so
|
|
359
|
+
* reporting it as `FS_IO_ERROR` told the UI "the filesystem broke" (audit 2026-10-31 P2).
|
|
360
|
+
* - **`REMOTE_UNSUPPORTED` becomes `FS_IO_ERROR`, not `FS_NOT_REGULAR_FILE`.** It means "this host/agent
|
|
361
|
+
* cannot do that", never "this path is not a regular file"; the old mapping sent the caller to inspect the
|
|
362
|
+
* path instead of the capability.
|
|
363
|
+
*/
|
|
364
|
+
export declare function toFsError(error: SshDevError): FsError;
|
|
365
|
+
/** Codes this provider can surface, exported for tests that assert the mapping table. */
|
|
366
|
+
export declare const MAPPED_DOMAIN_CODES: readonly SshDevErrorCode[];
|