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
package/lib/fs.js
ADDED
|
@@ -0,0 +1,1451 @@
|
|
|
1
|
+
// src/fs.ts
|
|
2
|
+
import "@deepseek-ai/cordis";
|
|
3
|
+
|
|
4
|
+
// src/host/integration/fs/fs-provider.ts
|
|
5
|
+
import { StringDecoder } from "node:string_decoder";
|
|
6
|
+
import "@deepseek-ai/cordis";
|
|
7
|
+
import { FileSystem, FsTargetKey, FsVersion } from "@deepseek-ai/dsh-fs";
|
|
8
|
+
|
|
9
|
+
// src/host/integration/fs/platform-errors.ts
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
import { FsError } from "@deepseek-ai/dsh-fs";
|
|
14
|
+
var hostClass;
|
|
15
|
+
var detection;
|
|
16
|
+
function resolveHostFsPath(entryScript) {
|
|
17
|
+
if (entryScript === void 0 || entryScript === "") return void 0;
|
|
18
|
+
try {
|
|
19
|
+
const resolved = createRequire(entryScript).resolve("@deepseek-ai/dsh-fs");
|
|
20
|
+
return typeof resolved === "string" && resolved !== "" ? resolved : void 0;
|
|
21
|
+
} catch {
|
|
22
|
+
return void 0;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function hostFsCandidates(entryScript) {
|
|
26
|
+
if (entryScript === void 0 || entryScript === "") return [];
|
|
27
|
+
const base = dirname(entryScript);
|
|
28
|
+
const relative = join("node_modules", "@deepseek-ai", "dsh-fs", "lib", "index.js");
|
|
29
|
+
return [join(base, relative), join(base, "..", relative), join(base, "..", "..", relative)].map(
|
|
30
|
+
(path) => pathToFileURL(path).href
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
function startHostErrorDetection(entryScript = process.argv[1]) {
|
|
34
|
+
if (detection !== void 0) return;
|
|
35
|
+
const started = detectHostFsError(entryScript);
|
|
36
|
+
detection = started;
|
|
37
|
+
void started.catch(() => void 0);
|
|
38
|
+
}
|
|
39
|
+
async function detectHostFsError(entryScript) {
|
|
40
|
+
const resolved = resolveHostFsPath(entryScript);
|
|
41
|
+
const sources = resolved === void 0 ? hostFsCandidates(entryScript) : [pathToFileURL(resolved).href, ...hostFsCandidates(entryScript)];
|
|
42
|
+
for (const candidate of sources) {
|
|
43
|
+
try {
|
|
44
|
+
const module = await import(candidate);
|
|
45
|
+
const found = module.FsError;
|
|
46
|
+
if (typeof found === "function" && found !== FsError) {
|
|
47
|
+
hostClass = found;
|
|
48
|
+
return hostClass;
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return void 0;
|
|
54
|
+
}
|
|
55
|
+
function isFsError(error) {
|
|
56
|
+
if (error instanceof FsError) return true;
|
|
57
|
+
return hostClass !== void 0 && error instanceof hostClass;
|
|
58
|
+
}
|
|
59
|
+
function activeFsErrorClass() {
|
|
60
|
+
return hostClass ?? FsError;
|
|
61
|
+
}
|
|
62
|
+
function platformFsError(message, code, options) {
|
|
63
|
+
const Constructor = activeFsErrorClass();
|
|
64
|
+
return new Constructor(message, code, options);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// src/host/infra/cache/lru.ts
|
|
68
|
+
var LruCache = class {
|
|
69
|
+
#maxEntries;
|
|
70
|
+
#maxBytes;
|
|
71
|
+
#sizeOf;
|
|
72
|
+
#entries = /* @__PURE__ */ new Map();
|
|
73
|
+
#bytes = 0;
|
|
74
|
+
#hits = 0;
|
|
75
|
+
#misses = 0;
|
|
76
|
+
#evictions = 0;
|
|
77
|
+
#rejectedOversize = 0;
|
|
78
|
+
constructor(options, sizeOf) {
|
|
79
|
+
if (!Number.isInteger(options.maxEntries) || options.maxEntries < 1) throw new Error("LruCache.maxEntries must be a positive integer");
|
|
80
|
+
if (!Number.isInteger(options.maxBytes) || options.maxBytes < 1) throw new Error("LruCache.maxBytes must be a positive integer");
|
|
81
|
+
this.#maxEntries = options.maxEntries;
|
|
82
|
+
this.#maxBytes = options.maxBytes;
|
|
83
|
+
this.#sizeOf = sizeOf;
|
|
84
|
+
}
|
|
85
|
+
/** Look up a value and mark it most-recently used. */
|
|
86
|
+
get(key) {
|
|
87
|
+
const entry = this.#entries.get(key);
|
|
88
|
+
if (entry === void 0) {
|
|
89
|
+
this.#misses++;
|
|
90
|
+
return void 0;
|
|
91
|
+
}
|
|
92
|
+
this.#entries.delete(key);
|
|
93
|
+
this.#entries.set(key, entry);
|
|
94
|
+
this.#hits++;
|
|
95
|
+
return entry.value;
|
|
96
|
+
}
|
|
97
|
+
/** Look up a value without changing recency or counters. */
|
|
98
|
+
peek(key) {
|
|
99
|
+
return this.#entries.get(key)?.value;
|
|
100
|
+
}
|
|
101
|
+
/** Whether the key is retained. */
|
|
102
|
+
has(key) {
|
|
103
|
+
return this.#entries.has(key);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Insert or replace a value.
|
|
107
|
+
*
|
|
108
|
+
* @returns `false` when the value alone exceeds `maxBytes` and was therefore not stored.
|
|
109
|
+
*/
|
|
110
|
+
set(key, value) {
|
|
111
|
+
const bytes = this.#sizeOf(value);
|
|
112
|
+
if (bytes > this.#maxBytes) {
|
|
113
|
+
this.delete(key);
|
|
114
|
+
this.#rejectedOversize++;
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
this.delete(key);
|
|
118
|
+
this.#entries.set(key, { value, bytes });
|
|
119
|
+
this.#bytes += bytes;
|
|
120
|
+
this.#evictToBounds();
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
/** Remove a key, returning whether it was present. */
|
|
124
|
+
delete(key) {
|
|
125
|
+
const entry = this.#entries.get(key);
|
|
126
|
+
if (entry === void 0) return false;
|
|
127
|
+
this.#entries.delete(key);
|
|
128
|
+
this.#bytes -= entry.bytes;
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Remove every entry whose key starts with `prefix`, in one pass over the internal map.
|
|
133
|
+
*
|
|
134
|
+
* 存在的理由:调用方(`VersionedCache.invalidatePrefix`)在**每次写**之后都要按前缀失效,而在此之前
|
|
135
|
+
* 它只有 `keys()` 可用——`keys()` 会把整张表**物化**成一个数组,随后又用 `delete(key)` 逐个做哈希
|
|
136
|
+
* 查找。这里直接在 `#entries` 上过一遍:不建中间数组,字节记账就地扣减,边迭代边删除(Map 迭代器
|
|
137
|
+
* 规范保证删除当前条目后仍会访问后续条目)。
|
|
138
|
+
*
|
|
139
|
+
* **为什么是"前缀"而不是更通用的"谓词"**:谓词要为每条 key 付一次 JS 回调,而一次 `keys()` 物化只是
|
|
140
|
+
* 一次指针批量拷贝。实测(20 000 条 × 2 000 次,只命中 1 条)旧写法 1 289 ms、前缀版 962 ms、
|
|
141
|
+
* 谓词版 953 ms——两者同量级;但在**生产上限**(读缓存 256 条 / 目录缓存 512 条)下谓词版反而比旧
|
|
142
|
+
* 写法慢(20 000 次:160 ms vs 148 ms),前缀版打平(147 ms)。也就是说:谓词版只在大表上不亏,
|
|
143
|
+
* 在小表上亏;前缀版两边都不亏。热路径上的方法按真实规模选形状。
|
|
144
|
+
*
|
|
145
|
+
* @param prefix - key prefix.
|
|
146
|
+
* @returns the number of removed entries.
|
|
147
|
+
*/
|
|
148
|
+
deleteByPrefix(prefix) {
|
|
149
|
+
let removed = 0;
|
|
150
|
+
for (const [key, entry] of this.#entries) {
|
|
151
|
+
if (!key.startsWith(prefix)) continue;
|
|
152
|
+
this.#entries.delete(key);
|
|
153
|
+
this.#bytes -= entry.bytes;
|
|
154
|
+
removed++;
|
|
155
|
+
}
|
|
156
|
+
return removed;
|
|
157
|
+
}
|
|
158
|
+
/** Remove everything. */
|
|
159
|
+
clear() {
|
|
160
|
+
this.#entries.clear();
|
|
161
|
+
this.#bytes = 0;
|
|
162
|
+
}
|
|
163
|
+
/** Keys from least to most recently used. */
|
|
164
|
+
keys() {
|
|
165
|
+
return [...this.#entries.keys()];
|
|
166
|
+
}
|
|
167
|
+
/** Current counters. */
|
|
168
|
+
get stats() {
|
|
169
|
+
return {
|
|
170
|
+
entries: this.#entries.size,
|
|
171
|
+
bytes: this.#bytes,
|
|
172
|
+
hits: this.#hits,
|
|
173
|
+
misses: this.#misses,
|
|
174
|
+
evictions: this.#evictions,
|
|
175
|
+
rejectedOversize: this.#rejectedOversize
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Drop the least-recently-used entry.
|
|
180
|
+
*
|
|
181
|
+
* Exposed so a shared {@link CacheBudget} can make this cache give bytes back; `#evictToBounds` is the
|
|
182
|
+
* same operation driven by this cache's own limits.
|
|
183
|
+
*
|
|
184
|
+
* @returns `false` when the cache is empty (the caller must not retry in a loop).
|
|
185
|
+
*/
|
|
186
|
+
evictOldest() {
|
|
187
|
+
const oldest = this.#entries.keys().next();
|
|
188
|
+
if (oldest.done === true) return false;
|
|
189
|
+
const entry = this.#entries.get(oldest.value);
|
|
190
|
+
if (entry === void 0) return false;
|
|
191
|
+
this.#entries.delete(oldest.value);
|
|
192
|
+
this.#bytes -= entry.bytes;
|
|
193
|
+
this.#evictions++;
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Come down to `maxBytes` by dropping the oldest entries.
|
|
198
|
+
*
|
|
199
|
+
* @param maxBytes - the ceiling to reach.
|
|
200
|
+
* @returns how many entries were dropped.
|
|
201
|
+
*/
|
|
202
|
+
trimTo(maxBytes) {
|
|
203
|
+
let dropped = 0;
|
|
204
|
+
while (this.#bytes > maxBytes && this.evictOldest()) dropped++;
|
|
205
|
+
return dropped;
|
|
206
|
+
}
|
|
207
|
+
#evictToBounds() {
|
|
208
|
+
while (this.#entries.size > this.#maxEntries || this.#bytes > this.#maxBytes) {
|
|
209
|
+
if (!this.evictOldest()) return;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// src/host/infra/cache/versioned.ts
|
|
215
|
+
var VersionedCache = class {
|
|
216
|
+
#ttlMs;
|
|
217
|
+
#now;
|
|
218
|
+
#lru;
|
|
219
|
+
#budget;
|
|
220
|
+
#hits = 0;
|
|
221
|
+
#misses = 0;
|
|
222
|
+
#freshHits = 0;
|
|
223
|
+
#staleHits = 0;
|
|
224
|
+
#revalidations = 0;
|
|
225
|
+
constructor(options, sizeOf) {
|
|
226
|
+
if (!Number.isInteger(options.ttlMs) || options.ttlMs < 0) throw new Error("VersionedCache.ttlMs must be a non-negative integer");
|
|
227
|
+
this.#ttlMs = options.ttlMs;
|
|
228
|
+
this.#now = options.now ?? Date.now;
|
|
229
|
+
this.#lru = new LruCache({ maxEntries: options.maxEntries, maxBytes: options.maxBytes }, (entry) => sizeOf(entry.value));
|
|
230
|
+
this.#budget = options.budget;
|
|
231
|
+
this.#budget?.join(this);
|
|
232
|
+
}
|
|
233
|
+
/** Bytes this cache holds (the budget member contract). */
|
|
234
|
+
get bytes() {
|
|
235
|
+
return this.#lru.stats.bytes;
|
|
236
|
+
}
|
|
237
|
+
/** Drop oldest entries down to `maxBytes` (the budget member contract). */
|
|
238
|
+
trimTo(maxBytes) {
|
|
239
|
+
return this.#lru.trimTo(maxBytes);
|
|
240
|
+
}
|
|
241
|
+
/** Drop the oldest entry (the budget member contract). */
|
|
242
|
+
evictOldest() {
|
|
243
|
+
return this.#lru.evictOldest();
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Leave the shared budget, so a retired cache stops counting against the total.
|
|
247
|
+
*
|
|
248
|
+
* Called by whoever owns the cache when its root goes away. Without it a removed root's cache stayed in the
|
|
249
|
+
* accounting forever, which is the leak the budget exists to prevent.
|
|
250
|
+
*/
|
|
251
|
+
leaveBudget() {
|
|
252
|
+
this.#budget?.leave(this);
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Look up an entry.
|
|
256
|
+
*
|
|
257
|
+
* @param key - route key.
|
|
258
|
+
* @returns the entry with its freshness, or `undefined` when nothing is retained.
|
|
259
|
+
*/
|
|
260
|
+
get(key) {
|
|
261
|
+
const stored = this.#lru.get(key);
|
|
262
|
+
if (stored === void 0) {
|
|
263
|
+
this.#misses++;
|
|
264
|
+
return void 0;
|
|
265
|
+
}
|
|
266
|
+
const ageMs = Math.max(0, this.#now() - stored.storedAt);
|
|
267
|
+
const fresh = ageMs <= this.#ttlMs;
|
|
268
|
+
this.#hits++;
|
|
269
|
+
if (fresh) this.#freshHits++;
|
|
270
|
+
else this.#staleHits++;
|
|
271
|
+
return { value: stored.value, version: stored.version, fresh, ageMs };
|
|
272
|
+
}
|
|
273
|
+
/** Insert or replace an entry, stamping it as just-read. */
|
|
274
|
+
set(key, value, version) {
|
|
275
|
+
const stored = this.#lru.set(key, { value, version, storedAt: this.#now() });
|
|
276
|
+
if (stored) this.#budget?.enforce(this);
|
|
277
|
+
return stored;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Refresh an entry's timestamp after a cheap remote version check matched.
|
|
281
|
+
*
|
|
282
|
+
* @param key - route key.
|
|
283
|
+
* @param version - the version observed by the check; a mismatch refuses the touch.
|
|
284
|
+
* @returns whether the entry was refreshed.
|
|
285
|
+
*/
|
|
286
|
+
touch(key, version) {
|
|
287
|
+
const stored = this.#lru.peek(key);
|
|
288
|
+
if (stored === void 0 || stored.version !== version) return false;
|
|
289
|
+
const refreshed = this.#lru.set(key, { value: stored.value, version: stored.version, storedAt: this.#now() });
|
|
290
|
+
if (refreshed) this.#revalidations++;
|
|
291
|
+
return refreshed;
|
|
292
|
+
}
|
|
293
|
+
/** Drop one entry. */
|
|
294
|
+
invalidate(key) {
|
|
295
|
+
return this.#lru.delete(key);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Drop every entry whose key starts with the prefix, used when a directory epoch changes.
|
|
299
|
+
*
|
|
300
|
+
* @param prefix - key prefix.
|
|
301
|
+
* @returns the number of removed entries.
|
|
302
|
+
*/
|
|
303
|
+
invalidatePrefix(prefix) {
|
|
304
|
+
return this.#lru.deleteByPrefix(prefix);
|
|
305
|
+
}
|
|
306
|
+
/** Remove everything. */
|
|
307
|
+
clear() {
|
|
308
|
+
this.#lru.clear();
|
|
309
|
+
}
|
|
310
|
+
/** Current counters. */
|
|
311
|
+
get stats() {
|
|
312
|
+
const lru = this.#lru.stats;
|
|
313
|
+
return {
|
|
314
|
+
entries: lru.entries,
|
|
315
|
+
bytes: lru.bytes,
|
|
316
|
+
hits: this.#hits,
|
|
317
|
+
misses: this.#misses,
|
|
318
|
+
freshHits: this.#freshHits,
|
|
319
|
+
staleHits: this.#staleHits,
|
|
320
|
+
revalidations: this.#revalidations,
|
|
321
|
+
evictions: lru.evictions
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
// src/host/domain/errors.ts
|
|
327
|
+
var SshDevError = class extends Error {
|
|
328
|
+
code;
|
|
329
|
+
details;
|
|
330
|
+
constructor(code, message, details = {}) {
|
|
331
|
+
super(message);
|
|
332
|
+
this.name = "SshDevError";
|
|
333
|
+
this.code = code;
|
|
334
|
+
this.details = details;
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
function isSshDevError(value) {
|
|
338
|
+
return value instanceof SshDevError;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// src/shared/root-keys.ts
|
|
342
|
+
function normalizeLocalPath(path, options = {}) {
|
|
343
|
+
const caseInsensitive = options.caseInsensitive ?? false;
|
|
344
|
+
const unified = path.replace(/\\/g, "/");
|
|
345
|
+
const unc = unified.startsWith("//");
|
|
346
|
+
const posixAbsolute = unified.startsWith("/");
|
|
347
|
+
const driveMatch = /^([A-Za-z]):/.exec(unified);
|
|
348
|
+
const driveLetter = driveMatch?.[1];
|
|
349
|
+
const drive = driveLetter === void 0 ? "" : `${driveLetter.toUpperCase()}:`;
|
|
350
|
+
const rest = unc ? unified.slice(2) : drive === "" ? unified : unified.slice(2);
|
|
351
|
+
const segments = [];
|
|
352
|
+
for (const segment of rest.split("/")) {
|
|
353
|
+
if (segment === "" || segment === ".") continue;
|
|
354
|
+
if (segment === "..") {
|
|
355
|
+
segments.pop();
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
segments.push(segment);
|
|
359
|
+
}
|
|
360
|
+
const body = segments.join("/");
|
|
361
|
+
let normalized;
|
|
362
|
+
if (unc) normalized = `//${body}`;
|
|
363
|
+
else if (drive !== "") normalized = body === "" ? `${drive}/` : `${drive}/${body}`;
|
|
364
|
+
else if (posixAbsolute) normalized = body === "" ? "/" : `/${body}`;
|
|
365
|
+
else normalized = body === "" ? "." : body;
|
|
366
|
+
return caseInsensitive ? normalized.toLowerCase() : normalized;
|
|
367
|
+
}
|
|
368
|
+
function normalizePosixPath(path) {
|
|
369
|
+
if (!path.startsWith("/")) return void 0;
|
|
370
|
+
const segments = [];
|
|
371
|
+
for (const segment of path.split("/")) {
|
|
372
|
+
if (segment === "" || segment === ".") continue;
|
|
373
|
+
if (segment === "..") {
|
|
374
|
+
segments.pop();
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
segments.push(segment);
|
|
378
|
+
}
|
|
379
|
+
return segments.length === 0 ? "/" : `/${segments.join("/")}`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// src/host/domain/path-grammar.ts
|
|
383
|
+
function normalizeRemotePath(path) {
|
|
384
|
+
const normalized = normalizePosixPath(path);
|
|
385
|
+
if (normalized !== void 0) return normalized;
|
|
386
|
+
if (path === "") throw new SshDevError("ROOT_INVALID", "\u8FDC\u7A0B\u8DEF\u5F84\u4E0D\u80FD\u4E3A\u7A7A", {});
|
|
387
|
+
throw new SshDevError("ROOT_INVALID", `\u8FDC\u7A0B\u8DEF\u5F84\u5FC5\u987B\u662F\u7EDD\u5BF9\u8DEF\u5F84\uFF1A${path}`, { path });
|
|
388
|
+
}
|
|
389
|
+
function remotePathContains(root, candidate) {
|
|
390
|
+
const normalizedRoot = normalizeRemotePath(root);
|
|
391
|
+
const normalizedCandidate = normalizeRemotePath(candidate);
|
|
392
|
+
if (normalizedRoot === "/") return true;
|
|
393
|
+
return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`);
|
|
394
|
+
}
|
|
395
|
+
function longestContainingRoot(roots, candidate) {
|
|
396
|
+
let best;
|
|
397
|
+
for (const root of roots) {
|
|
398
|
+
if (!remotePathContains(root, candidate)) continue;
|
|
399
|
+
if (best === void 0 || normalizeRemotePath(root).length > normalizeRemotePath(best).length) best = root;
|
|
400
|
+
}
|
|
401
|
+
return best;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// src/host/infra/transport/git-status.ts
|
|
405
|
+
function isGitStatusTransport(value) {
|
|
406
|
+
return typeof value === "object" && value !== null && typeof value.gitStatus === "function";
|
|
407
|
+
}
|
|
408
|
+
var DEFAULT_GIT_STATUS_TTL_MS = 5e3;
|
|
409
|
+
var DEFAULT_GIT_STATUS_MAX_ENTRIES = 64;
|
|
410
|
+
var DEFAULT_GIT_STATUS_MAX_BYTES = 4 * 1024 * 1024;
|
|
411
|
+
var DEFAULT_GIT_STATUS_MAX_CHANGES = 2e3;
|
|
412
|
+
var DEFAULT_GIT_STATUS_MAX_PATH_BYTES = 512 * 1024;
|
|
413
|
+
function cacheKey(connectionId, root) {
|
|
414
|
+
return `${connectionId}\0${root}`;
|
|
415
|
+
}
|
|
416
|
+
function awaitCallerCancellation(shared, signal) {
|
|
417
|
+
if (signal === void 0) return shared;
|
|
418
|
+
if (signal.aborted) {
|
|
419
|
+
return Promise.reject(new SshDevError("CANCELLED", "git \u72B6\u6001\u67E5\u8BE2\u5DF2\u88AB\u8C03\u7528\u65B9\u53D6\u6D88", { reason: "aborted" }));
|
|
420
|
+
}
|
|
421
|
+
return new Promise((resolve, reject) => {
|
|
422
|
+
const onAbort = () => {
|
|
423
|
+
reject(new SshDevError("CANCELLED", "git \u72B6\u6001\u67E5\u8BE2\u5DF2\u88AB\u8C03\u7528\u65B9\u53D6\u6D88", { reason: "aborted" }));
|
|
424
|
+
};
|
|
425
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
426
|
+
shared.then(
|
|
427
|
+
(value) => {
|
|
428
|
+
signal.removeEventListener("abort", onAbort);
|
|
429
|
+
resolve(value);
|
|
430
|
+
},
|
|
431
|
+
(error) => {
|
|
432
|
+
signal.removeEventListener("abort", onAbort);
|
|
433
|
+
reject(error);
|
|
434
|
+
}
|
|
435
|
+
);
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
function createGitStatusService(deps) {
|
|
439
|
+
const maxChanges = Math.max(1, Math.floor(deps.maxChanges ?? DEFAULT_GIT_STATUS_MAX_CHANGES));
|
|
440
|
+
const maxPathBytes = Math.max(1, Math.floor(deps.maxPathBytes ?? DEFAULT_GIT_STATUS_MAX_PATH_BYTES));
|
|
441
|
+
const cache = new VersionedCache(
|
|
442
|
+
{
|
|
443
|
+
ttlMs: deps.ttlMs ?? DEFAULT_GIT_STATUS_TTL_MS,
|
|
444
|
+
maxEntries: deps.maxEntries ?? DEFAULT_GIT_STATUS_MAX_ENTRIES,
|
|
445
|
+
maxBytes: deps.maxBytes ?? DEFAULT_GIT_STATUS_MAX_BYTES,
|
|
446
|
+
...deps.now === void 0 ? {} : { now: deps.now }
|
|
447
|
+
},
|
|
448
|
+
(snapshot) => snapshot.changes.reduce((total, change) => total + change.path.length + change.status.length, snapshot.branch?.length ?? 0)
|
|
449
|
+
);
|
|
450
|
+
const bounded = (changes, alreadyTruncated) => {
|
|
451
|
+
const kept = [];
|
|
452
|
+
let bytes = 0;
|
|
453
|
+
for (const change of changes) {
|
|
454
|
+
if (kept.length >= maxChanges || bytes + change.path.length > maxPathBytes) {
|
|
455
|
+
return { changes: kept, truncated: true };
|
|
456
|
+
}
|
|
457
|
+
kept.push(change);
|
|
458
|
+
bytes += change.path.length;
|
|
459
|
+
}
|
|
460
|
+
return { changes: kept, truncated: alreadyTruncated };
|
|
461
|
+
};
|
|
462
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
463
|
+
return {
|
|
464
|
+
async status(query, signal) {
|
|
465
|
+
const root = deps.resolveRoot(query.connectionId, query.path);
|
|
466
|
+
if (root === void 0) {
|
|
467
|
+
return { available: false, reason: "no-root", changes: [], truncated: false };
|
|
468
|
+
}
|
|
469
|
+
const key = cacheKey(query.connectionId, root);
|
|
470
|
+
const directory = relativeDirectory(root, query.path);
|
|
471
|
+
const forCaller = (snapshot) => snapshot.available ? { ...snapshot, directory } : snapshot;
|
|
472
|
+
const cached = cache.get(key);
|
|
473
|
+
if (cached !== void 0 && cached.fresh) return forCaller(cached.value);
|
|
474
|
+
const running = inFlight.get(key);
|
|
475
|
+
if (running !== void 0) return forCaller(await awaitCallerCancellation(running, signal));
|
|
476
|
+
const attempt = (async () => {
|
|
477
|
+
const snapshot = await fetchSnapshot(deps, query, root, directory, void 0, bounded);
|
|
478
|
+
if (snapshot.available || snapshot.reason === "tier-1" || snapshot.reason === "agent-unsupported") {
|
|
479
|
+
cache.set(key, snapshot, "");
|
|
480
|
+
}
|
|
481
|
+
return snapshot;
|
|
482
|
+
})();
|
|
483
|
+
inFlight.set(key, attempt);
|
|
484
|
+
const settle = () => {
|
|
485
|
+
if (inFlight.get(key) === attempt) inFlight.delete(key);
|
|
486
|
+
};
|
|
487
|
+
attempt.then(settle, settle);
|
|
488
|
+
return forCaller(await awaitCallerCancellation(attempt, signal));
|
|
489
|
+
},
|
|
490
|
+
invalidate(connectionId) {
|
|
491
|
+
if (connectionId === void 0) return;
|
|
492
|
+
cache.invalidatePrefix(`${connectionId}\0`);
|
|
493
|
+
},
|
|
494
|
+
get cacheStats() {
|
|
495
|
+
return cache.stats;
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
async function fetchSnapshot(deps, query, root, directory, signal, bounded) {
|
|
500
|
+
let transport;
|
|
501
|
+
try {
|
|
502
|
+
transport = await deps.transportFor(query.connectionId);
|
|
503
|
+
} catch (error) {
|
|
504
|
+
if (signal?.aborted === true || isCancellation(error)) throw error;
|
|
505
|
+
return { available: false, reason: "transport-unavailable", changes: [], truncated: false };
|
|
506
|
+
}
|
|
507
|
+
if (!isGitStatusTransport(transport)) {
|
|
508
|
+
return { available: false, reason: "tier-1", changes: [], truncated: false };
|
|
509
|
+
}
|
|
510
|
+
let answer;
|
|
511
|
+
try {
|
|
512
|
+
answer = await transport.gitStatus(root, signal);
|
|
513
|
+
} catch (error) {
|
|
514
|
+
if (signal?.aborted === true || isCancellation(error)) throw error;
|
|
515
|
+
const code = isSshDevError(error) ? error.code : void 0;
|
|
516
|
+
if (code === "REMOTE_UNSUPPORTED") return { available: false, reason: "agent-unsupported", changes: [], truncated: false };
|
|
517
|
+
if (isSshDevError(error) && error.details["agentCode"] === "E_PARAMS") return { available: false, reason: "not-a-work-tree", changes: [], truncated: false };
|
|
518
|
+
return { available: false, reason: "git-status-failed", changes: [], truncated: false };
|
|
519
|
+
}
|
|
520
|
+
const capped = bounded(answer.changes, answer.truncated);
|
|
521
|
+
return {
|
|
522
|
+
available: true,
|
|
523
|
+
root,
|
|
524
|
+
directory,
|
|
525
|
+
...answer.branch === void 0 ? {} : { branch: answer.branch },
|
|
526
|
+
changes: capped.changes,
|
|
527
|
+
truncated: capped.truncated
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
function isCancellation(error) {
|
|
531
|
+
return isSshDevError(error) && error.code === "CANCELLED";
|
|
532
|
+
}
|
|
533
|
+
function relativeDirectory(root, path) {
|
|
534
|
+
const normalizedRoot = normalizeRemotePath(root);
|
|
535
|
+
const normalizedPath = normalizeRemotePath(path);
|
|
536
|
+
if (!remotePathContains(normalizedRoot, normalizedPath)) return "";
|
|
537
|
+
if (normalizedRoot === normalizedPath) return "";
|
|
538
|
+
return normalizedPath.slice(normalizedRoot === "/" ? 1 : normalizedRoot.length + 1);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// src/host/domain/roots/root.ts
|
|
542
|
+
function normalizeLocalPath2(path, options = {}) {
|
|
543
|
+
return normalizeLocalPath(path, { caseInsensitive: options.caseInsensitive ?? process.platform === "win32" });
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// src/shared/routes.ts
|
|
547
|
+
function asRouteHost(value) {
|
|
548
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
549
|
+
const register = value.register;
|
|
550
|
+
return typeof register === "function" ? value : void 0;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// src/host/app/roots/roots-api-contract.ts
|
|
554
|
+
var ROOTS_API_MAX_BODY_BYTES = 64 * 1024;
|
|
555
|
+
function trustVerdict(req) {
|
|
556
|
+
const host = headerOf(req, "host");
|
|
557
|
+
if (host === void 0) return "no-host";
|
|
558
|
+
const hostUrl = parseAuthority(host);
|
|
559
|
+
if (hostUrl === void 0) return "bad-host";
|
|
560
|
+
if (headerOf(req, "sec-fetch-site") === "cross-site") return "cross-site";
|
|
561
|
+
const origin = headerOf(req, "origin");
|
|
562
|
+
if (origin === void 0) return "ok";
|
|
563
|
+
const trimmed = origin.trim();
|
|
564
|
+
if (trimmed === "" || trimmed === "null") return "bad-origin";
|
|
565
|
+
let originUrl;
|
|
566
|
+
try {
|
|
567
|
+
originUrl = new URL(trimmed);
|
|
568
|
+
} catch {
|
|
569
|
+
return "bad-origin";
|
|
570
|
+
}
|
|
571
|
+
if (originUrl.protocol !== "http:" && originUrl.protocol !== "https:") return "bad-origin";
|
|
572
|
+
if (originUrl.host === hostUrl.host) return "ok";
|
|
573
|
+
if (originUrl.port === "" && originUrl.hostname === hostUrl.hostname) return "ok";
|
|
574
|
+
return "bad-origin";
|
|
575
|
+
}
|
|
576
|
+
function trustMessage(verdict) {
|
|
577
|
+
switch (verdict) {
|
|
578
|
+
case "no-host":
|
|
579
|
+
return "\u8BF7\u6C42\u7F3A\u5C11 Host \u5934\uFF0C\u62D2\u7EDD";
|
|
580
|
+
case "bad-host":
|
|
581
|
+
return "Host \u5934\u65E0\u6CD5\u89E3\u6790\uFF0C\u62D2\u7EDD";
|
|
582
|
+
case "cross-site":
|
|
583
|
+
return "\u8DE8\u7AD9\u8BF7\u6C42\uFF08Sec-Fetch-Site: cross-site\uFF09\u88AB\u62D2\u7EDD";
|
|
584
|
+
case "bad-origin":
|
|
585
|
+
return "\u8BF7\u6C42\u7684 Origin \u4E0E Host \u4E0D\u540C\u6E90\uFF0C\u62D2\u7EDD";
|
|
586
|
+
default:
|
|
587
|
+
return "ok";
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
function headerOf(req, name) {
|
|
591
|
+
const value = req.headers[name] ?? req.headers[name.toLowerCase()];
|
|
592
|
+
if (value === void 0) return void 0;
|
|
593
|
+
return Array.isArray(value) ? value[0] : value;
|
|
594
|
+
}
|
|
595
|
+
function parseAuthority(authority) {
|
|
596
|
+
try {
|
|
597
|
+
return new URL(`http://${authority}`);
|
|
598
|
+
} catch {
|
|
599
|
+
return void 0;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// src/host/app/roots/roots-api-http.ts
|
|
604
|
+
async function readBody(req, maxBytes) {
|
|
605
|
+
const chunks = [];
|
|
606
|
+
let total = 0;
|
|
607
|
+
for await (const chunk of req) {
|
|
608
|
+
const buffer = toBytes(chunk);
|
|
609
|
+
total += buffer.length;
|
|
610
|
+
if (total > maxBytes) {
|
|
611
|
+
throw new SshDevError("ROOT_INVALID", `\u8BF7\u6C42\u4F53\u8D85\u8FC7\u4E0A\u9650 ${maxBytes} \u5B57\u8282\uFF0C\u62D2\u7EDD`);
|
|
612
|
+
}
|
|
613
|
+
chunks.push(buffer);
|
|
614
|
+
}
|
|
615
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
616
|
+
}
|
|
617
|
+
function toBytes(chunk) {
|
|
618
|
+
if (typeof chunk === "string") return Buffer.from(chunk, "utf8");
|
|
619
|
+
if (chunk instanceof Uint8Array) return chunk;
|
|
620
|
+
throw new Error("\u8BF7\u6C42\u4F53\u5206\u7247\u4E0D\u662F\u5B57\u7B26\u4E32\u4E5F\u4E0D\u662F\u5B57\u8282\u5E8F\u5217");
|
|
621
|
+
}
|
|
622
|
+
function send(req, res, status, value) {
|
|
623
|
+
res.statusCode = status;
|
|
624
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
625
|
+
res.setHeader("cache-control", "no-store");
|
|
626
|
+
const body = `${JSON.stringify(value)}
|
|
627
|
+
`;
|
|
628
|
+
res.end((req.method ?? "GET").toUpperCase() === "HEAD" ? void 0 : body);
|
|
629
|
+
}
|
|
630
|
+
function asRecord(value) {
|
|
631
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
632
|
+
return value;
|
|
633
|
+
}
|
|
634
|
+
function messageOf(error) {
|
|
635
|
+
return error instanceof Error ? error.message : String(error);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// src/host/infra/transport/git-status-route.ts
|
|
639
|
+
var GIT_STATUS_API_PATH = "/api/over-ssh/git-status";
|
|
640
|
+
function toPublicAnswer(snapshot) {
|
|
641
|
+
return {
|
|
642
|
+
ok: true,
|
|
643
|
+
available: snapshot.available,
|
|
644
|
+
...snapshot.root === void 0 ? {} : { root: snapshot.root },
|
|
645
|
+
...snapshot.directory === void 0 ? {} : { directory: snapshot.directory },
|
|
646
|
+
...snapshot.branch === void 0 ? {} : { branch: snapshot.branch },
|
|
647
|
+
changes: snapshot.changes.map((change) => ({ path: change.path, status: change.status })),
|
|
648
|
+
truncated: snapshot.truncated,
|
|
649
|
+
...snapshot.reason === void 0 ? {} : { reason: snapshot.reason }
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
function createGitStatusApi(deps) {
|
|
653
|
+
const handle = async (req, res) => {
|
|
654
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
655
|
+
if (method !== "POST" && method !== "GET" && method !== "HEAD") {
|
|
656
|
+
res.setHeader("allow", "GET, HEAD, POST");
|
|
657
|
+
send(req, res, 405, { ok: false, error: `\u4E0D\u652F\u6301\u7684\u65B9\u6CD5\uFF1A${method}` });
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
const verdict = trustVerdict(req);
|
|
661
|
+
if (verdict !== "ok") {
|
|
662
|
+
send(req, res, 403, { ok: false, error: trustMessage(verdict) });
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
if (method !== "POST") {
|
|
666
|
+
res.setHeader("allow", "POST");
|
|
667
|
+
send(req, res, 405, { ok: false, error: "git-status \u53EA\u63A5\u53D7 POST" });
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
try {
|
|
671
|
+
const body = asRecord(JSON.parse(await readBody(req, ROOTS_API_MAX_BODY_BYTES)));
|
|
672
|
+
const connectionId = body?.["connectionId"];
|
|
673
|
+
const path = body?.["path"];
|
|
674
|
+
if (typeof connectionId !== "string" || connectionId === "") {
|
|
675
|
+
send(req, res, 400, { ok: false, error: "git-status \u9700\u8981 connectionId", reason: "bad-connection" });
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
if (typeof path !== "string" || path === "") {
|
|
679
|
+
send(req, res, 400, { ok: false, error: "git-status \u9700\u8981 path", reason: "bad-path" });
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
const controller = new AbortController();
|
|
683
|
+
const onClose = () => {
|
|
684
|
+
controller.abort();
|
|
685
|
+
};
|
|
686
|
+
res.on?.("close", onClose);
|
|
687
|
+
let snapshot;
|
|
688
|
+
try {
|
|
689
|
+
snapshot = await deps.service.status({ connectionId, path }, controller.signal);
|
|
690
|
+
} finally {
|
|
691
|
+
res.off?.("close", onClose);
|
|
692
|
+
}
|
|
693
|
+
send(req, res, 200, toPublicAnswer(snapshot));
|
|
694
|
+
} catch (error) {
|
|
695
|
+
const invalidBody = error instanceof SyntaxError;
|
|
696
|
+
send(req, res, invalidBody ? 400 : 500, { ok: false, error: messageOf(error) });
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
return { handle };
|
|
700
|
+
}
|
|
701
|
+
function registerGitStatusRoute(provider, deps) {
|
|
702
|
+
const api = createGitStatusApi(deps);
|
|
703
|
+
try {
|
|
704
|
+
provider.inject(["webServer"], (scoped) => {
|
|
705
|
+
const host = asRouteHost(scoped.get("webServer"));
|
|
706
|
+
if (host === void 0) return void 0;
|
|
707
|
+
try {
|
|
708
|
+
const dispose = host.register({ kind: "exact", path: GIT_STATUS_API_PATH, handler: api.handle });
|
|
709
|
+
return () => dispose();
|
|
710
|
+
} catch {
|
|
711
|
+
return void 0;
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
} catch {
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// src/host/integration/fs/git-status-wiring.ts
|
|
719
|
+
function wireGitStatus(ctx) {
|
|
720
|
+
const service = createGitStatusService({
|
|
721
|
+
transportFor: async (connectionId) => {
|
|
722
|
+
const connections = ctx.get("sshDevConnections");
|
|
723
|
+
const transports = ctx.get("sshDevTransports");
|
|
724
|
+
if (connections === void 0 || transports === void 0) return void 0;
|
|
725
|
+
const profile = connections.get(connectionId);
|
|
726
|
+
if (profile === void 0) return void 0;
|
|
727
|
+
const acquired = await transports.acquire(profile);
|
|
728
|
+
return acquired.transport;
|
|
729
|
+
},
|
|
730
|
+
resolveRoot: (connectionId, path) => {
|
|
731
|
+
const registry = ctx.get("sshDevRoots");
|
|
732
|
+
if (registry === void 0) return void 0;
|
|
733
|
+
const roots = registry.records.map((record) => record.root).filter((root) => root.location === "remote" && root.connectionId === connectionId).map((root) => root.path);
|
|
734
|
+
return longestContainingRoot(roots, normalizeRemotePath(path));
|
|
735
|
+
}
|
|
736
|
+
});
|
|
737
|
+
registerGitStatusRoute(ctx, { service });
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// src/host/domain/literal-text.ts
|
|
741
|
+
function countOccurrences(haystack, needle) {
|
|
742
|
+
if (needle === "") return 0;
|
|
743
|
+
let count = 0;
|
|
744
|
+
let index = haystack.indexOf(needle);
|
|
745
|
+
while (index !== -1) {
|
|
746
|
+
count += 1;
|
|
747
|
+
index = haystack.indexOf(needle, index + needle.length);
|
|
748
|
+
}
|
|
749
|
+
return count;
|
|
750
|
+
}
|
|
751
|
+
function replaceLiteral(source, needle, replacement, all) {
|
|
752
|
+
if (needle === "") return { text: source, replacements: 0 };
|
|
753
|
+
if (all) {
|
|
754
|
+
const parts = source.split(needle);
|
|
755
|
+
return { text: parts.join(replacement), replacements: parts.length - 1 };
|
|
756
|
+
}
|
|
757
|
+
const at = source.indexOf(needle);
|
|
758
|
+
if (at === -1) return { text: source, replacements: 0 };
|
|
759
|
+
return {
|
|
760
|
+
text: `${source.slice(0, at)}${replacement}${source.slice(at + needle.length)}`,
|
|
761
|
+
replacements: 1
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// src/host/integration/session-scope.ts
|
|
766
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
767
|
+
var STORE_KEY = /* @__PURE__ */ Symbol.for("dsh-over-ssh/session-scope");
|
|
768
|
+
function store() {
|
|
769
|
+
const holder = globalThis;
|
|
770
|
+
const existing = holder[STORE_KEY];
|
|
771
|
+
if (existing !== void 0) return existing;
|
|
772
|
+
const created = new AsyncLocalStorage();
|
|
773
|
+
holder[STORE_KEY] = created;
|
|
774
|
+
return created;
|
|
775
|
+
}
|
|
776
|
+
function currentSessionId() {
|
|
777
|
+
return store().getStore()?.sessionId;
|
|
778
|
+
}
|
|
779
|
+
var MAX_FORK_DEPTH = 32;
|
|
780
|
+
var ancestorCache = /* @__PURE__ */ new Map();
|
|
781
|
+
var ANCESTOR_CACHE_MAX = 256;
|
|
782
|
+
function readSessionAncestors(ctx, sessionId) {
|
|
783
|
+
const cached = ancestorCache.get(sessionId);
|
|
784
|
+
if (cached !== void 0) {
|
|
785
|
+
ancestorCache.delete(sessionId);
|
|
786
|
+
ancestorCache.set(sessionId, cached);
|
|
787
|
+
return cached;
|
|
788
|
+
}
|
|
789
|
+
const walked = walkAncestors(ctx, sessionId);
|
|
790
|
+
if (walked.settled) {
|
|
791
|
+
ancestorCache.set(sessionId, walked.ancestors);
|
|
792
|
+
while (ancestorCache.size > ANCESTOR_CACHE_MAX) {
|
|
793
|
+
const oldest = ancestorCache.keys().next();
|
|
794
|
+
if (oldest.done === true) break;
|
|
795
|
+
ancestorCache.delete(oldest.value);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
return walked.ancestors;
|
|
799
|
+
}
|
|
800
|
+
function walkAncestors(ctx, sessionId) {
|
|
801
|
+
let list;
|
|
802
|
+
try {
|
|
803
|
+
const sessions = ctx.get("sessions");
|
|
804
|
+
list = sessions?.list?.();
|
|
805
|
+
} catch {
|
|
806
|
+
return { ancestors: [], settled: false };
|
|
807
|
+
}
|
|
808
|
+
if (!Array.isArray(list)) return { ancestors: [], settled: false };
|
|
809
|
+
const parents = /* @__PURE__ */ new Map();
|
|
810
|
+
let settled = false;
|
|
811
|
+
for (const session of list) {
|
|
812
|
+
const id = session?.header?.id;
|
|
813
|
+
const parent = session?.header?.parentSession;
|
|
814
|
+
if (typeof id !== "string" || id === "") continue;
|
|
815
|
+
if (id === sessionId) settled = true;
|
|
816
|
+
if (typeof parent === "string" && parent !== "") parents.set(id, parent);
|
|
817
|
+
}
|
|
818
|
+
const ancestors = [];
|
|
819
|
+
let current = parents.get(sessionId);
|
|
820
|
+
while (current !== void 0 && ancestors.length < MAX_FORK_DEPTH && !ancestors.includes(current)) {
|
|
821
|
+
ancestors.push(current);
|
|
822
|
+
current = parents.get(current);
|
|
823
|
+
}
|
|
824
|
+
return { ancestors, settled };
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// src/host/integration/fs/fs-provider.ts
|
|
828
|
+
var MAX_WHOLE_READ_BYTES = 16 * 1024 * 1024;
|
|
829
|
+
var BINARY_SNIFF_BYTES = 8e3;
|
|
830
|
+
var UTF8 = new TextDecoder("utf-8", { ignoreBOM: true });
|
|
831
|
+
var MIN_RANGE_CHUNK_BYTES = 1;
|
|
832
|
+
var MAX_RANGE_WINDOW_BYTES = 8 * 1024 * 1024;
|
|
833
|
+
var MAX_RANGE_CHUNK_BYTES = MAX_RANGE_WINDOW_BYTES;
|
|
834
|
+
var RoutedFsProvider = class extends FileSystem {
|
|
835
|
+
static inject = ["sshDevRoots"];
|
|
836
|
+
// `protected`, not `#private`, on purpose: the platform reaches this service through `ctx.fs`, which
|
|
837
|
+
// Cordis wraps in a Proxy, and a `#private` read through a Proxy throws ("…whose class did not declare
|
|
838
|
+
// it"). Every method here may run with the Proxy as `this`, so the state it touches cannot be `#private`.
|
|
839
|
+
rangeReader;
|
|
840
|
+
rangeChunkBytes;
|
|
841
|
+
/** Reads the live sessions' working directories; see {@link RoutedFsProviderOptions.sessionWorkspaceCwds}. */
|
|
842
|
+
sessionWorkspaceCwds;
|
|
843
|
+
/** Reads one session's fork lineage; see {@link RoutedFsProviderOptions.sessionAncestors}. */
|
|
844
|
+
sessionAncestors;
|
|
845
|
+
/**
|
|
846
|
+
* Change detector for {@link syncSessionRoots}: the state the last sync produced.
|
|
847
|
+
*
|
|
848
|
+
* A small mutable holder rather than a `string` field: every method here may run with the Cordis service Proxy
|
|
849
|
+
* as `this`, and mutating a property through that Proxy is the one thing this class must not rely on — mutating
|
|
850
|
+
* a property of an object it *read* through the Proxy is fine.
|
|
851
|
+
*/
|
|
852
|
+
sessionSync;
|
|
853
|
+
constructor(ctx, options = {}) {
|
|
854
|
+
super(ctx);
|
|
855
|
+
this.rangeReader = options.readBytesRange;
|
|
856
|
+
this.sessionWorkspaceCwds = options.sessionWorkspaceCwds ?? (() => readSessionCwds(ctx));
|
|
857
|
+
this.sessionAncestors = options.sessionAncestors ?? ((sessionId) => readSessionAncestors(ctx, sessionId));
|
|
858
|
+
this.sessionSync = { lastSignature: "" };
|
|
859
|
+
startHostErrorDetection();
|
|
860
|
+
const requested = Math.floor(options.rangeChunkBytes ?? MAX_RANGE_CHUNK_BYTES);
|
|
861
|
+
this.rangeChunkBytes = Number.isFinite(requested) && requested > 0 ? Math.min(Math.max(requested, MIN_RANGE_CHUNK_BYTES), MAX_RANGE_CHUNK_BYTES) : MAX_RANGE_CHUNK_BYTES;
|
|
862
|
+
}
|
|
863
|
+
/** This provider confines writes to the registered roots. */
|
|
864
|
+
get sandboxMode() {
|
|
865
|
+
return "workspace-write";
|
|
866
|
+
}
|
|
867
|
+
/** Resolve a model-facing path to the router's target and wrap it for the core. */
|
|
868
|
+
async resolve(path, opts) {
|
|
869
|
+
const resolved = this.resolveWithSessionSync(path, opts?.cwd);
|
|
870
|
+
if (!resolved.ok) throw toFsError(resolved.error);
|
|
871
|
+
return { targetKey: FsTargetKey(resolved.target.targetKey), displayPath: resolved.target.displayPath };
|
|
872
|
+
}
|
|
873
|
+
/** Path a subprocess in the target's world can open (the remote absolute path for remote roots). */
|
|
874
|
+
processPath(target) {
|
|
875
|
+
const routed = this.routedTarget(target);
|
|
876
|
+
return routed.backend.processPath?.(routed.absolute) ?? routed.absolute;
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Map a host path into the process world: for a local root the two are the same string.
|
|
880
|
+
*
|
|
881
|
+
* **This is a mapping, not an access check.** It used to answer `undefined` for any path outside the
|
|
882
|
+
* registered local roots, which broke the platform's own machinery: the PTC worker maps its runtime into the
|
|
883
|
+
* subprocess world through this method, so every `run_code` call died with
|
|
884
|
+
* `PTC runtime bootstrap is unavailable in the subprocess execution world:
|
|
885
|
+
* …/@deepseek-ai/dsh-ptc-runtime-node/lib/process.js` — a file that exists and that the harness has every
|
|
886
|
+
* right to read (2026-10-31, reported by the user as "the session cannot read the workspace").
|
|
887
|
+
*
|
|
888
|
+
* The core's own `dsh-fs-local` answers `resolve(hostPath)` unconditionally for exactly this reason: the
|
|
889
|
+
* question is "what is this host path called in the process world", not "may this caller open it". Access
|
|
890
|
+
* control lives in {@link resolve}/{@link readFile}/{@link writeText}, where the roots are enforced.
|
|
891
|
+
*/
|
|
892
|
+
processPathFromHostPath(hostPath) {
|
|
893
|
+
if (!isAbsoluteHostPath(hostPath)) return void 0;
|
|
894
|
+
return normalizeLocalPath2(hostPath, { caseInsensitive: false });
|
|
895
|
+
}
|
|
896
|
+
/** Canonical `file:` URI in the target's world. */
|
|
897
|
+
fileUrl(target) {
|
|
898
|
+
const routed = this.routedTarget(target);
|
|
899
|
+
if (routed.backend.fileUrl !== void 0) return routed.backend.fileUrl(routed.absolute);
|
|
900
|
+
return `file://${encodeURI(routed.absolute)}`;
|
|
901
|
+
}
|
|
902
|
+
/** Containment; two targets in different worlds never contain one another. */
|
|
903
|
+
contains(parent, child) {
|
|
904
|
+
const left = this.routedTarget(parent);
|
|
905
|
+
const right = this.routedTarget(child);
|
|
906
|
+
if (left.backendKey !== right.backendKey) return false;
|
|
907
|
+
return this.router().contains(left, right);
|
|
908
|
+
}
|
|
909
|
+
/** Metadata, or `undefined` for an absent target. */
|
|
910
|
+
async stat(target, signal) {
|
|
911
|
+
const routed = this.routedTarget(target);
|
|
912
|
+
const info = await this.call(() => routed.backend.stat(routed.absolute, signal));
|
|
913
|
+
if (!info.exists) return void 0;
|
|
914
|
+
return toFsInfo(info);
|
|
915
|
+
}
|
|
916
|
+
/** Path metadata without following a final symlink, where the backend can tell. */
|
|
917
|
+
async lstat(path, opts, signal) {
|
|
918
|
+
const resolved = this.resolveWithSessionSync(path, opts?.cwd);
|
|
919
|
+
if (!resolved.ok) throw toFsError(resolved.error);
|
|
920
|
+
const routed = resolved.target;
|
|
921
|
+
const info = await this.call(() => this.router().statTarget(routed, { followSymlink: false, ...signal === void 0 ? {} : { signal } }));
|
|
922
|
+
if (!info.exists) return void 0;
|
|
923
|
+
const fsInfo = toFsInfo(info);
|
|
924
|
+
return info.isSymlink ? { version: fsInfo.version, type: "symlink", ...fsInfo.size === void 0 ? {} : { size: fsInfo.size } } : fsInfo;
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Whole-file UTF-8 text; binary and oversize files fail with typed codes.
|
|
928
|
+
*
|
|
929
|
+
* Reads **bytes** rather than calling `backend.readText`, so the binary sniff happens before decoding: the
|
|
930
|
+
* previous shape asked the backend for the decoded text and then re-encoded the whole string
|
|
931
|
+
* (`Buffer.from(text, 'utf8')`) only to look at its first {@link BINARY_SNIFF_BYTES} bytes. Measured on a
|
|
932
|
+
* 9 MiB file (audit 2026-11-01b follow-up): `open`+`stat`+`read` 2.65 ms, `+` decode 3.95 ms, `+` the
|
|
933
|
+
* redundant re-encode **5.66 ms** — 1.71 ms, ~75% of the plugin's overhead, spent to encode bytes the caller
|
|
934
|
+
* had just decoded. `TextDecoder` then decodes straight off the read buffer without the extra copy.
|
|
935
|
+
*/
|
|
936
|
+
async readText(target, signal) {
|
|
937
|
+
const routed = this.routedTarget(target);
|
|
938
|
+
const chunk = await this.call(() => routed.backend.readBytes(routed.absolute, MAX_WHOLE_READ_BYTES, signal));
|
|
939
|
+
if (chunk.truncated) {
|
|
940
|
+
throw platformFsError(`cannot read "${routed.displayPath}": file exceeds ${MAX_WHOLE_READ_BYTES} bytes`, "FS_TOO_LARGE");
|
|
941
|
+
}
|
|
942
|
+
assertText(routed.displayPath, chunk.bytes);
|
|
943
|
+
return UTF8.decode(chunk.bytes);
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Stream text in chunks, paging through the offset reader when the composition injected one.
|
|
947
|
+
*
|
|
948
|
+
* The platform calls this for every file at or above **its own** streaming threshold —
|
|
949
|
+
* `@deepseek-ai/dsh-tool-fs/lib/index.js:217` (`STREAM_MIN_SIZE = 10 * 1024 * 1024`) feeding `:338` — and
|
|
950
|
+
* `@deepseek-ai/dsh-fs/lib/types/index.d.ts:164-172` makes the backend own both the chunking and the
|
|
951
|
+
* cross-chunk UTF-8 decoding. Answering by reading the whole file first, which is what this did, made that
|
|
952
|
+
* contract a lie twice over: it buffered the entire file *and* it refused anything past the whole-file cap
|
|
953
|
+
* with `FS_TOO_LARGE`, so the `read` tool could not open a large remote log at all even when the caller had
|
|
954
|
+
* asked for one window of it.
|
|
955
|
+
*
|
|
956
|
+
* Without the offset seam the previous semantics are kept exactly: one bounded whole read, one chunk, and
|
|
957
|
+
* `FS_TOO_LARGE` past the cap. Nothing pretends to be chunked when it is not.
|
|
958
|
+
*/
|
|
959
|
+
async streamText(target, signal) {
|
|
960
|
+
const routed = this.routedTarget(target);
|
|
961
|
+
const reader = this.rangeReader;
|
|
962
|
+
const chunkBytes = this.rangeChunkBytes;
|
|
963
|
+
if (reader !== void 0) {
|
|
964
|
+
const probe = await this.call(
|
|
965
|
+
() => reader(routed.backend, { absolute: routed.absolute, offset: 0, length: chunkBytes }, signal)
|
|
966
|
+
);
|
|
967
|
+
if (probe !== null) {
|
|
968
|
+
assertText(routed.displayPath, Buffer.from(probe.bytes.subarray(0, BINARY_SNIFF_BYTES)));
|
|
969
|
+
return pagedText(reader, chunkBytes, routed, probe, signal);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
const text = await this.readText(target, signal);
|
|
973
|
+
return (async function* whole() {
|
|
974
|
+
yield text;
|
|
975
|
+
})();
|
|
976
|
+
}
|
|
977
|
+
/** Whole-file bytes, refusing oversized targets instead of truncating. */
|
|
978
|
+
async readBytes(target, signal, maxBytes) {
|
|
979
|
+
const routed = this.routedTarget(target);
|
|
980
|
+
const chunk = await this.call(() => routed.backend.readBytes(routed.absolute, maxBytes, signal));
|
|
981
|
+
if (chunk.truncated) {
|
|
982
|
+
throw platformFsError(`cannot read "${routed.displayPath}": file exceeds ${maxBytes} bytes`, "FS_TOO_LARGE");
|
|
983
|
+
}
|
|
984
|
+
return chunk.bytes;
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* One byte window, bounded by the caller's window rather than the file size.
|
|
988
|
+
*
|
|
989
|
+
* Two paths, and they are deliberately distinguishable:
|
|
990
|
+
*
|
|
991
|
+
* - the backend has an offset read (the injected {@link BackendRangeReader} answers with a window):
|
|
992
|
+
* the window is fetched as a sequence of requests of at most the configured chunk size, each
|
|
993
|
+
* starting at the file offset it needs. Nothing before `offset` is ever transferred or held, so
|
|
994
|
+
* the memory bound is the chunk size plus the returned window, not the file size. The window
|
|
995
|
+
* itself is capped at {@link MAX_RANGE_WINDOW_BYTES} — it is returned whole, so it is allocated
|
|
996
|
+
* whole, and an unbounded `length` would be an unbounded allocation;
|
|
997
|
+
* - the backend has none (a local backend, a transport without `readFileRange`, or no injected
|
|
998
|
+
* reader at all): the previous semantics are kept exactly — one bounded prefix read `[0, end)`
|
|
999
|
+
* capped at 8 MiB, then a slice — and a window past that cap still fails with `FS_TOO_LARGE`
|
|
1000
|
+
* instead of silently buffering the file.
|
|
1001
|
+
*/
|
|
1002
|
+
async readByteRange(target, range, signal) {
|
|
1003
|
+
const routed = this.routedTarget(target);
|
|
1004
|
+
const start = Math.max(0, Math.floor(range.offset));
|
|
1005
|
+
const wanted = Math.max(0, Math.floor(range.length));
|
|
1006
|
+
if (wanted === 0) return new Uint8Array(0);
|
|
1007
|
+
if (wanted > MAX_RANGE_WINDOW_BYTES) {
|
|
1008
|
+
throw platformFsError(
|
|
1009
|
+
`cannot read "${routed.displayPath}": the requested window (${wanted} bytes) exceeds the ${MAX_RANGE_WINDOW_BYTES}-byte limit`,
|
|
1010
|
+
"FS_TOO_LARGE"
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
assertNotAborted(signal, `${routed.displayPath} [${start}, ${start + wanted})`);
|
|
1014
|
+
const reader = this.rangeReader;
|
|
1015
|
+
const chunkBytes = this.rangeChunkBytes;
|
|
1016
|
+
const chunked = await this.call(() => readChunkedWindow(reader, chunkBytes, routed, start, wanted, signal));
|
|
1017
|
+
if (chunked !== void 0) return chunked;
|
|
1018
|
+
const end = start + wanted;
|
|
1019
|
+
if (end > MAX_RANGE_WINDOW_BYTES) {
|
|
1020
|
+
throw platformFsError(
|
|
1021
|
+
`cannot read "${routed.displayPath}": this backend cannot read the window [${start}, ${end}) without buffering it (limit ${MAX_RANGE_WINDOW_BYTES} bytes)`,
|
|
1022
|
+
"FS_TOO_LARGE"
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
const chunk = await this.call(() => routed.backend.readBytes(routed.absolute, end, signal));
|
|
1026
|
+
return chunk.bytes.subarray(start, Math.min(end, chunk.bytes.byteLength));
|
|
1027
|
+
}
|
|
1028
|
+
/** Direct children, in stable name order, with resolved child targets. */
|
|
1029
|
+
async listDir(target, signal) {
|
|
1030
|
+
const routed = this.routedTarget(target);
|
|
1031
|
+
const entries = await this.call(() => this.router().listTarget(routed, signal));
|
|
1032
|
+
return entries.map((entry) => ({
|
|
1033
|
+
name: entry.name,
|
|
1034
|
+
type: entry.isDirectory ? "directory" : entry.isFile ? "file" : "other",
|
|
1035
|
+
target: { targetKey: FsTargetKey(entry.targetKey), displayPath: entry.displayPath },
|
|
1036
|
+
// 后端给的令牌带 inode,是唯一与 mtime 精度无关的「换过文件」信号;秒级现算会把同一秒内的
|
|
1037
|
+
// 两次写入算成同一个版本(审计 0086)。只有后端没给时才退回现算,且退回时也用毫秒而不是秒。
|
|
1038
|
+
version: FsVersion(entry.version ?? `${entry.size}:${entry.mtimeMs}`),
|
|
1039
|
+
size: entry.size
|
|
1040
|
+
}));
|
|
1041
|
+
}
|
|
1042
|
+
/** Atomic write, honouring the write intent and the plugin's fence. */
|
|
1043
|
+
async writeText(target, content, expected, signal, sandboxPolicy) {
|
|
1044
|
+
const routed = this.routedTarget(target);
|
|
1045
|
+
this.assertWritable(routed, sandboxPolicy);
|
|
1046
|
+
const info = await this.call(() => routed.backend.stat(routed.absolute, signal));
|
|
1047
|
+
const existed = info.exists;
|
|
1048
|
+
const before = existed ? await this.readPriorText(routed, info, signal) : void 0;
|
|
1049
|
+
if (expected?.kind === "createIfAbsent" && existed) {
|
|
1050
|
+
throw platformFsError(`cannot create "${routed.displayPath}": target already exists`, "FS_NOT_OBSERVED");
|
|
1051
|
+
}
|
|
1052
|
+
const guard = expected?.kind === "replaceIfVersion" ? expected.version : void 0;
|
|
1053
|
+
const outcome = await this.call(
|
|
1054
|
+
() => routed.backend.writeText(routed.absolute, content, guard, signal)
|
|
1055
|
+
).catch((error) => {
|
|
1056
|
+
throw mapWriteError(error, routed.displayPath);
|
|
1057
|
+
});
|
|
1058
|
+
return {
|
|
1059
|
+
operation: existed ? "update" : "create",
|
|
1060
|
+
version: FsVersion(outcome.version),
|
|
1061
|
+
// `before` is the **diff basis**, so it is LF-normalized exactly like `after`
|
|
1062
|
+
// (`@deepseek-ai/dsh-fs/lib/types/index.d.ts:118-134`: "LF-normalized storage text (the diff basis)"),
|
|
1063
|
+
// and the core's own backend does the same (`dsh-fs-local/lib/index.js:872,879`). Reporting the raw text
|
|
1064
|
+
// made every CRLF file render as a whole-file diff: `@deepseek-ai/dsh-tool-fs/lib/index.js:596` diffs
|
|
1065
|
+
// `before` against an LF-normalized `after`, so one changed line looked like every line changed.
|
|
1066
|
+
before: before === void 0 ? null : normalizeLineEndings(before),
|
|
1067
|
+
after: normalizeLineEndings(content)
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
/** Literal edit with the core's three-state semantics and an optional version guard. */
|
|
1071
|
+
async editText(target, edit, expected, signal, sandboxPolicy) {
|
|
1072
|
+
const routed = this.routedTarget(target);
|
|
1073
|
+
this.assertWritable(routed, sandboxPolicy);
|
|
1074
|
+
const chunk = await this.call(() => routed.backend.readText(routed.absolute, MAX_WHOLE_READ_BYTES, signal)).catch((error) => {
|
|
1075
|
+
throw mapWriteError(error, routed.displayPath);
|
|
1076
|
+
});
|
|
1077
|
+
if (expected !== void 0 && expected.version !== chunk.version) {
|
|
1078
|
+
throw platformFsError(`cannot edit "${routed.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
|
|
1079
|
+
}
|
|
1080
|
+
const lineEndings = chunk.text.includes("\r\n") ? "\r\n" : "\n";
|
|
1081
|
+
const before = normalizeLineEndings(chunk.text);
|
|
1082
|
+
const occurrences = countOccurrences(before, edit.oldString);
|
|
1083
|
+
if (occurrences === 0) {
|
|
1084
|
+
throw platformFsError(`cannot edit "${routed.displayPath}": the requested text was not found`, "FS_EDIT_NOT_FOUND");
|
|
1085
|
+
}
|
|
1086
|
+
if (occurrences > 1 && !edit.replaceAll) {
|
|
1087
|
+
throw platformFsError(`cannot edit "${routed.displayPath}": the requested text appears ${occurrences} times`, "FS_AMBIGUOUS_EDIT");
|
|
1088
|
+
}
|
|
1089
|
+
const after = replaceLiteral(before, edit.oldString, edit.newString, edit.replaceAll === true).text;
|
|
1090
|
+
if (routed.backend.editText !== void 0 && lineEndings === "\n") {
|
|
1091
|
+
try {
|
|
1092
|
+
const native = await this.call(
|
|
1093
|
+
() => routed.backend.editText(
|
|
1094
|
+
routed.absolute,
|
|
1095
|
+
edit.oldString,
|
|
1096
|
+
edit.newString,
|
|
1097
|
+
edit.replaceAll === true,
|
|
1098
|
+
chunk.version,
|
|
1099
|
+
signal
|
|
1100
|
+
)
|
|
1101
|
+
);
|
|
1102
|
+
return { version: FsVersion(native.version), before, after };
|
|
1103
|
+
} catch (error) {
|
|
1104
|
+
if (!isSshDevError(error) || error.code !== "REMOTE_UNSUPPORTED") {
|
|
1105
|
+
throw mapWriteError(error, routed.displayPath);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
const outcome = await this.call(
|
|
1110
|
+
() => routed.backend.writeText(routed.absolute, restoreLineEndings(after, lineEndings), chunk.version, signal)
|
|
1111
|
+
).catch((error) => {
|
|
1112
|
+
throw mapWriteError(error, routed.displayPath);
|
|
1113
|
+
});
|
|
1114
|
+
return { version: FsVersion(outcome.version), before, after };
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Watch one target through its backend.
|
|
1118
|
+
*
|
|
1119
|
+
* The route decides the world, then the backend owns the mechanism:
|
|
1120
|
+
*
|
|
1121
|
+
* - **local roots** delegate to the platform's own `FileSystem.watch` (chokidar) via `PlatformLocalBackend`.
|
|
1122
|
+
* Before this method was wired, taking over `ctx.fs` made **local** files stop reporting changes: the
|
|
1123
|
+
* platform's `dsh-api-workspace-files` got `workspace-file/watch-unsupported` for every target, because
|
|
1124
|
+
* `RoutedFsProvider` replaced the sandboxed provider (a `LocalFileSystem` subclass) and this method only
|
|
1125
|
+
* ever threw. Delegating restores the pre-takeover behaviour without a second chokidar implementation.
|
|
1126
|
+
* - **remote roots** with a native agent watcher use `watch.start/poll/stop`; Tier 1 remains unsupported
|
|
1127
|
+
* and keeps failing honestly (the caller falls back to revalidation on read).
|
|
1128
|
+
*
|
|
1129
|
+
* A backend that has **no** `watch` method keeps the historical `FS_IO_ERROR` refusal — never a silent
|
|
1130
|
+
* no-op, which would leave a consumer believing it is subscribed.
|
|
1131
|
+
*
|
|
1132
|
+
* @param target - the target whose changes were requested.
|
|
1133
|
+
* @param changed - platform callback: re-stat on no argument, subscription-failure on an error.
|
|
1134
|
+
* @param signal - cancels watcher initialization and subscription.
|
|
1135
|
+
* @returns the disposer (`() => Promise<void>`), idempotent.
|
|
1136
|
+
* @throws FsError `FS_IO_ERROR` when the backend cannot watch, plus the mapped backend failures
|
|
1137
|
+
* (`FS_NOT_FOUND`, `AbortError` for cancellation, …).
|
|
1138
|
+
*/
|
|
1139
|
+
async watch(target, changed, signal) {
|
|
1140
|
+
const routed = this.routedTarget(target);
|
|
1141
|
+
const backendWatch = routed.backend.watch;
|
|
1142
|
+
if (backendWatch === void 0) {
|
|
1143
|
+
throw platformFsError(`filesystem watching is not supported for "${routed.displayPath}"`, "FS_IO_ERROR");
|
|
1144
|
+
}
|
|
1145
|
+
return await this.call(() => backendWatch.call(routed.backend, routed.absolute, changed, signal));
|
|
1146
|
+
}
|
|
1147
|
+
rootsService() {
|
|
1148
|
+
const registry = this.ctx.sshDevRoots;
|
|
1149
|
+
if (registry === void 0) {
|
|
1150
|
+
throw platformFsError("the over-ssh root registry is not registered", "FS_IO_ERROR");
|
|
1151
|
+
}
|
|
1152
|
+
return registry;
|
|
1153
|
+
}
|
|
1154
|
+
router() {
|
|
1155
|
+
return this.rootsService().routed;
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Resolve a path, syncing the live sessions' working directories once if it fails.
|
|
1159
|
+
*
|
|
1160
|
+
* Why on failure rather than before every call: the sync costs a walk of the live-session list plus a
|
|
1161
|
+
* containment scan over the roots, and the overwhelming majority of resolutions succeed against a registered
|
|
1162
|
+
* root. Why it must exist at all: the platform reads the workspace through `ctx.fs` by **absolute path without
|
|
1163
|
+
* a cwd** while a turn starts, so a session whose own directory no root covers lost the entire turn before the
|
|
1164
|
+
* model was called — there was no cwd to key a fallback on.
|
|
1165
|
+
*
|
|
1166
|
+
* One sync, then one retry: a session that appeared a moment ago is therefore picked up on its **first** read
|
|
1167
|
+
* rather than failing once and working on the next call, and a second failure means the sync genuinely had
|
|
1168
|
+
* nothing to add (the path is outside every root), so the caller gets the original error.
|
|
1169
|
+
*
|
|
1170
|
+
* @param path - model-facing path.
|
|
1171
|
+
* @param cwd - the caller's session cwd, when it has one.
|
|
1172
|
+
* @returns the resolution; when nothing changed the caller keeps the **original** failure.
|
|
1173
|
+
*/
|
|
1174
|
+
resolveWithSessionSync(path, cwd) {
|
|
1175
|
+
const options = this.sessionRouteOptions(cwd);
|
|
1176
|
+
const first = this.router().tryResolve(path, options);
|
|
1177
|
+
if (first.ok) return first;
|
|
1178
|
+
this.syncSessionRoots();
|
|
1179
|
+
return this.router().tryResolve(path, options);
|
|
1180
|
+
}
|
|
1181
|
+
/**
|
|
1182
|
+
* The route options for one call: the cwd the platform supplied, plus the session identity this call runs
|
|
1183
|
+
* under when the tool dispatcher carried one down (`integration/session-scope.ts`, ADR-0013).
|
|
1184
|
+
*
|
|
1185
|
+
* Both are kept because they answer different questions. The cwd is what the platform's contract provides
|
|
1186
|
+
* (relative spellings, the workspace-level rule, the anchor rule); the session is the only thing that can
|
|
1187
|
+
* separate two conversations opened on the same directory. A call with neither is an agentless one and keeps
|
|
1188
|
+
* the plugin-wide view, exactly as before.
|
|
1189
|
+
*
|
|
1190
|
+
* @param cwd - the caller's session cwd, when it has one.
|
|
1191
|
+
* @returns the options to hand the route table.
|
|
1192
|
+
*/
|
|
1193
|
+
sessionRouteOptions(cwd) {
|
|
1194
|
+
const sessionId = currentSessionId();
|
|
1195
|
+
const identity = sessionId === void 0 || sessionId === "" ? void 0 : { sessionId, ancestors: this.sessionAncestors(sessionId) };
|
|
1196
|
+
const sessionCwd = cwd !== void 0 || sessionId === void 0 || sessionId === "" ? void 0 : this.sessionWorkspaceCwdOf(sessionId);
|
|
1197
|
+
return {
|
|
1198
|
+
...cwd === void 0 ? {} : { cwd },
|
|
1199
|
+
...identity === void 0 ? {} : { session: identity },
|
|
1200
|
+
...sessionCwd === void 0 ? {} : { sessionCwd }
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* The working directory of one live session, or `undefined` when it is not listed.
|
|
1205
|
+
*
|
|
1206
|
+
* @param sessionId - the asking session.
|
|
1207
|
+
* @returns its cwd, when the platform reports one.
|
|
1208
|
+
*/
|
|
1209
|
+
sessionWorkspaceCwdOf(sessionId) {
|
|
1210
|
+
return readSessionCwd(this.ctx, sessionId);
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* Re-read the live sessions and register their working directories as session roots.
|
|
1214
|
+
*
|
|
1215
|
+
* Guarded by **change detection**, not by a time window: reading the session list and comparing it with the
|
|
1216
|
+
* state the last sync produced costs microseconds, while a time throttle would have a window in which a session
|
|
1217
|
+
* created a moment ago is not registered yet — and that session's very first read is exactly the one that used
|
|
1218
|
+
* to kill the turn. The signature includes the session roots that currently exist, so a registry reset (which
|
|
1219
|
+
* drops them) is noticed and repaired too.
|
|
1220
|
+
*/
|
|
1221
|
+
syncSessionRoots() {
|
|
1222
|
+
try {
|
|
1223
|
+
const cwds = this.sessionWorkspaceCwds();
|
|
1224
|
+
if (cwds.length === 0) return;
|
|
1225
|
+
const registry = this.rootsService();
|
|
1226
|
+
if (sessionSyncSignature(cwds, registry) === this.sessionSync.lastSignature) return;
|
|
1227
|
+
registry.syncSessionWorkspaces(cwds);
|
|
1228
|
+
this.sessionSync.lastSignature = sessionSyncSignature(cwds, registry);
|
|
1229
|
+
} catch {
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
routedTarget(target) {
|
|
1233
|
+
try {
|
|
1234
|
+
return this.router().targetFromKey(target.targetKey);
|
|
1235
|
+
} catch (error) {
|
|
1236
|
+
throw isSshDevError(error) ? toFsError(error) : error;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
/** Enforce the per-call policy and the root's own writability. */
|
|
1240
|
+
assertWritable(routed, policy) {
|
|
1241
|
+
const mode = policy?.mode ?? this.sandboxMode;
|
|
1242
|
+
if (mode === "read-only") {
|
|
1243
|
+
throw platformFsError(`sandbox denies writing "${routed.displayPath}" (read-only)`, "FS_SANDBOX_DENIED");
|
|
1244
|
+
}
|
|
1245
|
+
if (routed.root.writable === false) {
|
|
1246
|
+
throw platformFsError(`the root of "${routed.displayPath}" is registered read-only`, "FS_SANDBOX_DENIED");
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* Previous text content, when it is readable text inside the cap; `undefined` otherwise.
|
|
1251
|
+
*
|
|
1252
|
+
* `undefined` here means **"no previous text to report"**, never "the target is absent" — the caller
|
|
1253
|
+
* already decided existence from {@link BackendStat.exists}. Keeping those two apart is what stops a
|
|
1254
|
+
* directory / oversized / binary file from slipping past a `createIfAbsent` guard.
|
|
1255
|
+
*/
|
|
1256
|
+
async readPriorText(routed, info, signal) {
|
|
1257
|
+
if (info.isDirectory || info.size > MAX_WHOLE_READ_BYTES) return void 0;
|
|
1258
|
+
const chunk = await routed.backend.readText(routed.absolute, MAX_WHOLE_READ_BYTES, signal).catch((error) => {
|
|
1259
|
+
if (signal?.aborted === true) throw error;
|
|
1260
|
+
return void 0;
|
|
1261
|
+
});
|
|
1262
|
+
if (chunk === void 0 || chunk.truncated) return void 0;
|
|
1263
|
+
const bytes = Buffer.from(chunk.text, "utf8");
|
|
1264
|
+
const sniff = bytes.subarray(0, BINARY_SNIFF_BYTES);
|
|
1265
|
+
if (sniff.includes(0)) return void 0;
|
|
1266
|
+
return chunk.text;
|
|
1267
|
+
}
|
|
1268
|
+
/** Run a backend call, translating domain errors into core codes. */
|
|
1269
|
+
async call(operation) {
|
|
1270
|
+
try {
|
|
1271
|
+
return await operation();
|
|
1272
|
+
} catch (error) {
|
|
1273
|
+
if (isFsError(error)) throw error;
|
|
1274
|
+
throw isSshDevError(error) ? toFsError(error) : error;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
};
|
|
1278
|
+
function createRoutedFsProvider(options) {
|
|
1279
|
+
const plugin = ((ctx) => {
|
|
1280
|
+
new RoutedFsProvider(ctx, options);
|
|
1281
|
+
try {
|
|
1282
|
+
wireGitStatus(ctx);
|
|
1283
|
+
} catch (error) {
|
|
1284
|
+
ctx.logger?.warn?.(`git status route: ${error instanceof Error ? error.message : String(error)}`);
|
|
1285
|
+
}
|
|
1286
|
+
});
|
|
1287
|
+
plugin.inject = RoutedFsProvider.inject;
|
|
1288
|
+
return plugin;
|
|
1289
|
+
}
|
|
1290
|
+
function assertNotAborted(signal, what) {
|
|
1291
|
+
if (signal?.aborted !== true) return;
|
|
1292
|
+
const error = new Error(`the read of ${what} was cancelled`);
|
|
1293
|
+
error.name = "AbortError";
|
|
1294
|
+
throw error;
|
|
1295
|
+
}
|
|
1296
|
+
function toFsInfo(info) {
|
|
1297
|
+
return {
|
|
1298
|
+
version: FsVersion(info.version),
|
|
1299
|
+
type: info.isDirectory ? "directory" : info.isFile ? "file" : "other",
|
|
1300
|
+
...info.isDirectory ? {} : { size: info.size }
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
async function readChunkedWindow(reader, chunkBytes, routed, offset, length, signal) {
|
|
1304
|
+
if (reader === void 0) return void 0;
|
|
1305
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
1306
|
+
let filled = 0;
|
|
1307
|
+
while (filled < length) {
|
|
1308
|
+
signal?.throwIfAborted();
|
|
1309
|
+
const requestLength = Math.min(length - filled, chunkBytes);
|
|
1310
|
+
const window = await reader(
|
|
1311
|
+
routed.backend,
|
|
1312
|
+
{ absolute: routed.absolute, offset: offset + filled, length: requestLength },
|
|
1313
|
+
signal
|
|
1314
|
+
);
|
|
1315
|
+
if (window === null) return void 0;
|
|
1316
|
+
assertNotAborted(signal, `${routed.displayPath} [${offset + filled}, ${offset + filled + requestLength})`);
|
|
1317
|
+
const bytes = window.bytes.subarray(0, requestLength);
|
|
1318
|
+
Buffer.from(bytes).copy(buffer, filled);
|
|
1319
|
+
filled += bytes.byteLength;
|
|
1320
|
+
if (window.truncated === false || bytes.byteLength === 0) break;
|
|
1321
|
+
}
|
|
1322
|
+
return new Uint8Array(buffer.buffer, buffer.byteOffset, filled);
|
|
1323
|
+
}
|
|
1324
|
+
async function* pagedText(reader, chunkBytes, routed, firstWindow, signal) {
|
|
1325
|
+
const decoder = new StringDecoder("utf8");
|
|
1326
|
+
let offset = 0;
|
|
1327
|
+
let window = firstWindow;
|
|
1328
|
+
for (; ; ) {
|
|
1329
|
+
signal?.throwIfAborted();
|
|
1330
|
+
const bytes = Buffer.from(window.bytes.subarray(0, chunkBytes));
|
|
1331
|
+
offset += bytes.byteLength;
|
|
1332
|
+
if (bytes.byteLength > 0) {
|
|
1333
|
+
const text = decoder.write(bytes);
|
|
1334
|
+
if (text.length > 0) yield text;
|
|
1335
|
+
}
|
|
1336
|
+
if (window.truncated === false || bytes.byteLength === 0) break;
|
|
1337
|
+
window = await reader(routed.backend, { absolute: routed.absolute, offset, length: chunkBytes }, signal).catch((error) => {
|
|
1338
|
+
throw isSshDevError(error) ? toFsError(error) : error;
|
|
1339
|
+
});
|
|
1340
|
+
assertNotAborted(signal, `${routed.displayPath} [${offset}, ${offset + chunkBytes})`);
|
|
1341
|
+
if (window === null) {
|
|
1342
|
+
throw platformFsError(
|
|
1343
|
+
`cannot stream "${routed.displayPath}": the backend stopped offering offset reads mid-stream`,
|
|
1344
|
+
"FS_IO_ERROR"
|
|
1345
|
+
);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
const tail = decoder.end();
|
|
1349
|
+
if (tail.length > 0) yield tail;
|
|
1350
|
+
}
|
|
1351
|
+
function sessionSyncSignature(cwds, registry) {
|
|
1352
|
+
const roots = registry.sessionWorkspaceRecords.map((record) => record.key).join("\0");
|
|
1353
|
+
return `${cwds.join("\0")}${roots}`;
|
|
1354
|
+
}
|
|
1355
|
+
function readSessionCwd(ctx, sessionId) {
|
|
1356
|
+
let list;
|
|
1357
|
+
try {
|
|
1358
|
+
const sessions = ctx.get("sessions");
|
|
1359
|
+
list = sessions?.list?.();
|
|
1360
|
+
} catch {
|
|
1361
|
+
return void 0;
|
|
1362
|
+
}
|
|
1363
|
+
if (!Array.isArray(list)) return void 0;
|
|
1364
|
+
for (const session of list) {
|
|
1365
|
+
if (session?.header?.id !== sessionId) continue;
|
|
1366
|
+
const cwd = session.header.cwd;
|
|
1367
|
+
return typeof cwd === "string" && cwd !== "" ? cwd : void 0;
|
|
1368
|
+
}
|
|
1369
|
+
return void 0;
|
|
1370
|
+
}
|
|
1371
|
+
function readSessionCwds(ctx) {
|
|
1372
|
+
let list;
|
|
1373
|
+
try {
|
|
1374
|
+
const sessions = ctx.get("sessions");
|
|
1375
|
+
list = sessions?.list?.();
|
|
1376
|
+
} catch {
|
|
1377
|
+
return [];
|
|
1378
|
+
}
|
|
1379
|
+
if (!Array.isArray(list)) return [];
|
|
1380
|
+
const cwds = [];
|
|
1381
|
+
for (const session of list) {
|
|
1382
|
+
const cwd = session?.header?.cwd;
|
|
1383
|
+
if (typeof cwd === "string" && cwd !== "") cwds.push(cwd);
|
|
1384
|
+
}
|
|
1385
|
+
return cwds;
|
|
1386
|
+
}
|
|
1387
|
+
function assertText(displayPath, bytes) {
|
|
1388
|
+
if (bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) {
|
|
1389
|
+
throw platformFsError(`cannot read "${displayPath}": not a UTF-8 text file`, "FS_NOT_TEXT");
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
function normalizeLineEndings(text) {
|
|
1393
|
+
return text.replace(/\r\n/g, "\n");
|
|
1394
|
+
}
|
|
1395
|
+
function restoreLineEndings(text, lineEndings) {
|
|
1396
|
+
return lineEndings === "\n" ? text : text.replace(/\n/g, lineEndings);
|
|
1397
|
+
}
|
|
1398
|
+
function isAbsoluteHostPath(path) {
|
|
1399
|
+
return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("/") || path.startsWith("//");
|
|
1400
|
+
}
|
|
1401
|
+
function toFsError(error) {
|
|
1402
|
+
if (error.code === "CANCELLED") {
|
|
1403
|
+
const cancelled = new Error(`the filesystem operation was cancelled: ${error.message}`);
|
|
1404
|
+
cancelled.name = "AbortError";
|
|
1405
|
+
throw cancelled;
|
|
1406
|
+
}
|
|
1407
|
+
const code = error.code === "REMOTE_NOT_FOUND" || error.code === "ROOT_OUTSIDE" ? "FS_NOT_FOUND" : error.code === "SANDBOX_DENIED" ? "FS_SANDBOX_DENIED" : error.code === "TRANSFER_CONFLICT" ? "FS_STALE_VERSION" : "FS_IO_ERROR";
|
|
1408
|
+
return platformFsError(`${error.code}: ${error.message}`, code, { cause: error });
|
|
1409
|
+
}
|
|
1410
|
+
function mapWriteError(error, displayPath) {
|
|
1411
|
+
if (isFsError(error)) return error;
|
|
1412
|
+
if (isSshDevError(error)) {
|
|
1413
|
+
if (error.code === "TRANSFER_CONFLICT") {
|
|
1414
|
+
return platformFsError(`cannot write "${displayPath}": file changed since it was read`, "FS_STALE_VERSION", { cause: error });
|
|
1415
|
+
}
|
|
1416
|
+
return toFsError(error);
|
|
1417
|
+
}
|
|
1418
|
+
return error;
|
|
1419
|
+
}
|
|
1420
|
+
var MAPPED_DOMAIN_CODES = [
|
|
1421
|
+
"REMOTE_NOT_FOUND",
|
|
1422
|
+
"ROOT_OUTSIDE",
|
|
1423
|
+
"SANDBOX_DENIED",
|
|
1424
|
+
"TRANSFER_CONFLICT",
|
|
1425
|
+
"REMOTE_UNSUPPORTED",
|
|
1426
|
+
"ROOT_INVALID",
|
|
1427
|
+
"REMOTE_COMMAND_FAILED",
|
|
1428
|
+
"CANCELLED"
|
|
1429
|
+
];
|
|
1430
|
+
|
|
1431
|
+
// src/fs.ts
|
|
1432
|
+
function apply(ctx) {
|
|
1433
|
+
if (ctx.get("fs") !== void 0) {
|
|
1434
|
+
ctx.logger?.warn?.("fs: ctx.fs \u5DF2\u88AB\u5360\u7528\uFF08\u6838\u5FC3 fs-sandbox \u4ECD\u5728\uFF09\uFF0C\u672C\u6B21\u8DF3\u8FC7\u63A5\u7BA1\uFF1B\u8981\u63A5\u7BA1\u8BF7\u540C\u65F6\u7981\u7528\u6838\u5FC3\u884C");
|
|
1435
|
+
return void 0;
|
|
1436
|
+
}
|
|
1437
|
+
const range = ctx.get("sshDevRangeReader");
|
|
1438
|
+
return ctx.plugin(
|
|
1439
|
+
createRoutedFsProvider({
|
|
1440
|
+
...range === void 0 ? {} : { readBytesRange: range.readBytesRange },
|
|
1441
|
+
...range?.rangeChunkBytes === void 0 ? {} : { rangeChunkBytes: range.rangeChunkBytes }
|
|
1442
|
+
})
|
|
1443
|
+
);
|
|
1444
|
+
}
|
|
1445
|
+
export {
|
|
1446
|
+
MAPPED_DOMAIN_CODES,
|
|
1447
|
+
RoutedFsProvider,
|
|
1448
|
+
createRoutedFsProvider,
|
|
1449
|
+
apply as default,
|
|
1450
|
+
toFsError
|
|
1451
|
+
};
|