dsh-over-ssh 0.1.1 → 0.2.0
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/README.md +10 -8
- package/agents/agent-manifest.json +6 -6
- package/agents/linux-amd64/dsh-over-ssh-agent +0 -0
- package/agents/linux-arm64/dsh-over-ssh-agent +0 -0
- package/lib/client.js +60 -24
- package/lib/file-references.js +20 -8
- package/lib/fs.js +138 -39
- package/lib/index.js +2367 -491
- package/lib/types/src/config.d.ts +76 -0
- package/lib/types/src/host/app/backend.d.ts +35 -2
- package/lib/types/src/host/domain/roots/session-roots.d.ts +23 -0
- package/lib/types/src/host/infra/transport/forward.d.ts +75 -0
- package/lib/types/src/host/infra/transport/ssh/ssh2-transport.d.ts +5 -0
- package/lib/types/src/host/infra/transport/transport.d.ts +26 -0
- package/lib/types/src/host/integration/fs/fs-provider.d.ts +18 -1
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -46,8 +46,8 @@ function sshFailureToError(connectionId, input) {
|
|
|
46
46
|
stderrTail: tail(input.stderr ?? "", 2e3)
|
|
47
47
|
});
|
|
48
48
|
}
|
|
49
|
-
function tail(
|
|
50
|
-
return
|
|
49
|
+
function tail(text8, maxChars) {
|
|
50
|
+
return text8.length <= maxChars ? text8 : text8.slice(text8.length - maxChars);
|
|
51
51
|
}
|
|
52
52
|
var SshDevError, FAILURE_PATTERNS;
|
|
53
53
|
var init_errors = __esm({
|
|
@@ -130,6 +130,9 @@ function quote(value) {
|
|
|
130
130
|
}
|
|
131
131
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
132
132
|
}
|
|
133
|
+
function quoteAll(values) {
|
|
134
|
+
return values.map((value) => quote(value)).join(" ");
|
|
135
|
+
}
|
|
133
136
|
var init_shell = __esm({
|
|
134
137
|
"src/host/domain/shell.ts"() {
|
|
135
138
|
"use strict";
|
|
@@ -201,8 +204,8 @@ var init_capability = __esm({
|
|
|
201
204
|
function encodeBase64(bytes) {
|
|
202
205
|
return Buffer.from(bytes).toString("base64");
|
|
203
206
|
}
|
|
204
|
-
function decodeBase64(
|
|
205
|
-
const decoded = Buffer.from(
|
|
207
|
+
function decodeBase64(text8) {
|
|
208
|
+
const decoded = Buffer.from(text8, "base64");
|
|
206
209
|
return decoded.byteOffset === 0 && decoded.buffer.byteLength === decoded.byteLength ? decoded : new Uint8Array(decoded);
|
|
207
210
|
}
|
|
208
211
|
var TIER1_REMOTE_MAY_STILL_RUN_HINT, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_MAX_READ_BYTES2;
|
|
@@ -266,6 +269,198 @@ var init_shell_quote = __esm({
|
|
|
266
269
|
}
|
|
267
270
|
});
|
|
268
271
|
|
|
272
|
+
// src/host/infra/transport/forward.ts
|
|
273
|
+
function isValidPort(value) {
|
|
274
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535;
|
|
275
|
+
}
|
|
276
|
+
function normalizeForwardRequest(request) {
|
|
277
|
+
const remoteHost = request.remoteHost.trim();
|
|
278
|
+
if (remoteHost === "") {
|
|
279
|
+
throw new SshDevError("CONNECTION_INVALID", "forward \u9700\u8981 remoteHost", {});
|
|
280
|
+
}
|
|
281
|
+
if (/[\s,]/.test(remoteHost)) {
|
|
282
|
+
throw new SshDevError("CONNECTION_INVALID", `remoteHost \u4E0D\u80FD\u5305\u542B\u7A7A\u767D\u6216\u9017\u53F7\uFF1A${JSON.stringify(remoteHost)}`, { remoteHost });
|
|
283
|
+
}
|
|
284
|
+
const bracketed = remoteHost.startsWith("[") && remoteHost.endsWith("]");
|
|
285
|
+
if (remoteHost.includes(":") && !bracketed) {
|
|
286
|
+
throw new SshDevError(
|
|
287
|
+
"CONNECTION_INVALID",
|
|
288
|
+
`remoteHost \u542B\u5192\u53F7\u65F6\u5FC5\u987B\u5199\u6210\u65B9\u62EC\u53F7 IPv6 \u5F62\u5F0F\uFF08\u4F8B\u5982 [::1]\uFF09\uFF1A${remoteHost}`,
|
|
289
|
+
{ remoteHost }
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
if (!isValidPort(request.remotePort)) {
|
|
293
|
+
throw new SshDevError("CONNECTION_INVALID", `remotePort \u5FC5\u987B\u662F 1..65535 \u7684\u6574\u6570\uFF1A${String(request.remotePort)}`, {
|
|
294
|
+
remotePort: request.remotePort
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
if (request.localPort !== void 0 && !isValidPort(request.localPort)) {
|
|
298
|
+
throw new SshDevError("CONNECTION_INVALID", `localPort \u5FC5\u987B\u662F 1..65535 \u7684\u6574\u6570\uFF1A${String(request.localPort)}`, {
|
|
299
|
+
localPort: request.localPort
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
remoteHost,
|
|
304
|
+
remotePort: request.remotePort,
|
|
305
|
+
...request.localPort === void 0 ? {} : { localPort: request.localPort }
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
var FORWARD_LOCAL_HOST;
|
|
309
|
+
var init_forward = __esm({
|
|
310
|
+
"src/host/infra/transport/forward.ts"() {
|
|
311
|
+
"use strict";
|
|
312
|
+
init_errors();
|
|
313
|
+
FORWARD_LOCAL_HOST = "127.0.0.1";
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// src/host/infra/transport/forward-proxy.ts
|
|
318
|
+
import { connect, createServer } from "node:net";
|
|
319
|
+
async function probe(opener, request, signal) {
|
|
320
|
+
const channel = await opener(request, signal);
|
|
321
|
+
channel.destroy();
|
|
322
|
+
}
|
|
323
|
+
function createProxyForwarder(options) {
|
|
324
|
+
const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
325
|
+
const now = options.now ?? Date.now;
|
|
326
|
+
const live = /* @__PURE__ */ new Map();
|
|
327
|
+
const closeEntry = async (entry) => {
|
|
328
|
+
if (live.get(entry.handle.localPort) === entry) live.delete(entry.handle.localPort);
|
|
329
|
+
const listeners = [...entry.closedListeners];
|
|
330
|
+
entry.closedListeners.clear();
|
|
331
|
+
for (const listener of listeners) {
|
|
332
|
+
try {
|
|
333
|
+
listener();
|
|
334
|
+
} catch {
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (entry.timer !== void 0) clearInterval(entry.timer);
|
|
338
|
+
for (const socket of entry.sockets) socket.destroy();
|
|
339
|
+
entry.sockets.clear();
|
|
340
|
+
await new Promise((resolve3) => entry.server.close(() => resolve3()));
|
|
341
|
+
};
|
|
342
|
+
const forward = async (request, signal) => {
|
|
343
|
+
const normalized = normalizeForwardRequest(request);
|
|
344
|
+
if (signal?.aborted === true) throw new SshDevError("CANCELLED", "\u7AEF\u53E3\u8F6C\u53D1\u5DF2\u53D6\u6D88", {});
|
|
345
|
+
await probe(options.openChannel, normalized, signal);
|
|
346
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
347
|
+
let boundPort = 0;
|
|
348
|
+
const server = createServer((socket) => {
|
|
349
|
+
sockets.add(socket);
|
|
350
|
+
const entry2 = live.get(boundPort);
|
|
351
|
+
if (entry2 !== void 0) entry2.lastActivityAt = now();
|
|
352
|
+
socket.once("close", () => {
|
|
353
|
+
sockets.delete(socket);
|
|
354
|
+
const current = live.get(boundPort);
|
|
355
|
+
if (current !== void 0) current.lastActivityAt = now();
|
|
356
|
+
});
|
|
357
|
+
void options.openChannel(normalized, signal).then((channel) => pipeBidirectional(socket, channel)).catch(() => {
|
|
358
|
+
socket.destroy();
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
const port = await new Promise((resolvePort, rejectPort) => {
|
|
362
|
+
const onError = (error) => {
|
|
363
|
+
server.removeListener("listening", onListening);
|
|
364
|
+
rejectPort(
|
|
365
|
+
new SshDevError("CONNECTION_UNREACHABLE", `\u672C\u5730\u7AEF\u53E3\u76D1\u542C\u5931\u8D25\uFF1A${error.message}`, {
|
|
366
|
+
...normalized.localPort === void 0 ? {} : { localPort: normalized.localPort }
|
|
367
|
+
})
|
|
368
|
+
);
|
|
369
|
+
};
|
|
370
|
+
const onListening = () => {
|
|
371
|
+
server.removeListener("error", onError);
|
|
372
|
+
const address = server.address();
|
|
373
|
+
const bound = typeof address === "object" && address !== null ? address.port : 0;
|
|
374
|
+
if (bound === 0) {
|
|
375
|
+
rejectPort(new SshDevError("CONNECTION_UNREACHABLE", "\u672C\u5730\u7AEF\u53E3\u76D1\u542C\u540E\u62FF\u4E0D\u5230\u771F\u5B9E\u7AEF\u53E3", {}));
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
boundPort = bound;
|
|
379
|
+
resolvePort(bound);
|
|
380
|
+
};
|
|
381
|
+
server.once("error", onError);
|
|
382
|
+
server.once("listening", onListening);
|
|
383
|
+
server.listen(normalized.localPort ?? 0, FORWARD_LOCAL_HOST);
|
|
384
|
+
});
|
|
385
|
+
let handle;
|
|
386
|
+
handle = {
|
|
387
|
+
localPort: port,
|
|
388
|
+
remoteHost: normalized.remoteHost,
|
|
389
|
+
remotePort: normalized.remotePort,
|
|
390
|
+
close: async () => {
|
|
391
|
+
const entry2 = live.get(port);
|
|
392
|
+
if (entry2 !== void 0) await closeEntry(entry2);
|
|
393
|
+
},
|
|
394
|
+
onClosed: (listener) => {
|
|
395
|
+
const entry2 = live.get(port);
|
|
396
|
+
if (entry2 === void 0) {
|
|
397
|
+
listener();
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
entry2.closedListeners.add(listener);
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
const entry = {
|
|
404
|
+
handle,
|
|
405
|
+
server,
|
|
406
|
+
sockets,
|
|
407
|
+
lastActivityAt: now(),
|
|
408
|
+
timer: void 0,
|
|
409
|
+
closedListeners: /* @__PURE__ */ new Set()
|
|
410
|
+
};
|
|
411
|
+
live.set(port, entry);
|
|
412
|
+
const checkMs = options.idleCheckIntervalMs ?? IDLE_TICK_MS;
|
|
413
|
+
if (idleTimeoutMs > 0 && checkMs > 0) {
|
|
414
|
+
entry.timer = setInterval(() => {
|
|
415
|
+
const current = live.get(port);
|
|
416
|
+
if (current === void 0) return;
|
|
417
|
+
if (current.sockets.size > 0) {
|
|
418
|
+
current.lastActivityAt = now();
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const idleMs = now() - current.lastActivityAt;
|
|
422
|
+
if (idleMs < idleTimeoutMs) return;
|
|
423
|
+
void closeEntry(current).then(() => options.onReclaimed?.(handle, idleMs));
|
|
424
|
+
}, checkMs);
|
|
425
|
+
entry.timer.unref?.();
|
|
426
|
+
}
|
|
427
|
+
return handle;
|
|
428
|
+
};
|
|
429
|
+
return {
|
|
430
|
+
forward,
|
|
431
|
+
get forwards() {
|
|
432
|
+
return [...live.values()].map((entry) => entry.handle);
|
|
433
|
+
},
|
|
434
|
+
closeAll: async () => {
|
|
435
|
+
const entries = [...live.values()];
|
|
436
|
+
live.clear();
|
|
437
|
+
await Promise.all(entries.map((entry) => closeEntry(entry)));
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
function pipeBidirectional(socket, channel) {
|
|
442
|
+
socket.pipe(channel.writable);
|
|
443
|
+
channel.readable.pipe(socket);
|
|
444
|
+
const teardown = () => {
|
|
445
|
+
channel.destroy();
|
|
446
|
+
socket.destroy();
|
|
447
|
+
};
|
|
448
|
+
socket.once("error", teardown);
|
|
449
|
+
socket.once("close", teardown);
|
|
450
|
+
channel.readable.once("error", teardown);
|
|
451
|
+
channel.readable.once("close", teardown);
|
|
452
|
+
}
|
|
453
|
+
var DEFAULT_IDLE_TIMEOUT_MS, IDLE_TICK_MS;
|
|
454
|
+
var init_forward_proxy = __esm({
|
|
455
|
+
"src/host/infra/transport/forward-proxy.ts"() {
|
|
456
|
+
"use strict";
|
|
457
|
+
init_errors();
|
|
458
|
+
init_forward();
|
|
459
|
+
DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
460
|
+
IDLE_TICK_MS = 3e4;
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
|
|
269
464
|
// src/host/infra/transport/transport-base.ts
|
|
270
465
|
var TransportBase;
|
|
271
466
|
var init_transport_base = __esm({
|
|
@@ -320,6 +515,55 @@ var init_temp_name = __esm({
|
|
|
320
515
|
}
|
|
321
516
|
});
|
|
322
517
|
|
|
518
|
+
// src/host/infra/transport/ssh/ssh2-forward.ts
|
|
519
|
+
function openChannel(session, request) {
|
|
520
|
+
return new Promise((resolve3, reject) => {
|
|
521
|
+
session.forwardOut(FORWARD_SRC_HOST, 0, request.remoteHost, request.remotePort, (error, stream) => {
|
|
522
|
+
if (error !== void 0) {
|
|
523
|
+
reject(
|
|
524
|
+
new SshDevError(
|
|
525
|
+
"CONNECTION_UNREACHABLE",
|
|
526
|
+
`\u8FDC\u7AEF\u62D2\u7EDD\u8F6C\u53D1\u5230 ${request.remoteHost}:${String(request.remotePort)}\uFF1A${error.message}`,
|
|
527
|
+
{ remoteHost: request.remoteHost, remotePort: request.remotePort }
|
|
528
|
+
)
|
|
529
|
+
);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (stream === void 0) {
|
|
533
|
+
reject(new SshDevError("REMOTE_COMMAND_FAILED", "forwardOut \u6CA1\u6709\u8FD4\u56DE\u901A\u9053", {}));
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const duplex = stream;
|
|
537
|
+
resolve3({ readable: duplex, writable: duplex, destroy: () => duplex.destroy() });
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
function createSsh2Forwarder(options) {
|
|
542
|
+
const sessionOf = () => typeof options.session === "function" ? options.session() : options.session;
|
|
543
|
+
return createProxyForwarder({
|
|
544
|
+
connectionId: options.connectionId,
|
|
545
|
+
...options.idleTimeoutMs === void 0 ? {} : { idleTimeoutMs: options.idleTimeoutMs },
|
|
546
|
+
...options.now === void 0 ? {} : { now: options.now },
|
|
547
|
+
...options.onReclaimed === void 0 ? {} : { onReclaimed: options.onReclaimed },
|
|
548
|
+
openChannel: async (request) => {
|
|
549
|
+
const session = sessionOf();
|
|
550
|
+
if (session === void 0) {
|
|
551
|
+
throw new SshDevError("CONNECTION_LOST", "SSH \u4F1A\u8BDD\u4E0D\u53EF\u7528\uFF0C\u4E0D\u80FD\u5EFA\u7ACB\u7AEF\u53E3\u8F6C\u53D1", {});
|
|
552
|
+
}
|
|
553
|
+
return openChannel(session, request);
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
var FORWARD_SRC_HOST;
|
|
558
|
+
var init_ssh2_forward = __esm({
|
|
559
|
+
"src/host/infra/transport/ssh/ssh2-forward.ts"() {
|
|
560
|
+
"use strict";
|
|
561
|
+
init_errors();
|
|
562
|
+
init_forward_proxy();
|
|
563
|
+
FORWARD_SRC_HOST = "127.0.0.1";
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
|
|
323
567
|
// src/host/infra/transport/ssh/ssh2-transport.ts
|
|
324
568
|
var ssh2_transport_exports = {};
|
|
325
569
|
__export(ssh2_transport_exports, {
|
|
@@ -330,7 +574,7 @@ __export(ssh2_transport_exports, {
|
|
|
330
574
|
sftpError: () => sftpError,
|
|
331
575
|
versionToken: () => versionToken
|
|
332
576
|
});
|
|
333
|
-
import { createHash } from "node:crypto";
|
|
577
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
334
578
|
function withSftpDeadline(guard, what, path, promise) {
|
|
335
579
|
if (guard.timeoutMs <= 0 && guard.signal === void 0) return promise;
|
|
336
580
|
return new Promise((resolve3, reject) => {
|
|
@@ -507,10 +751,10 @@ function isSymlinkEntry(entry) {
|
|
|
507
751
|
}
|
|
508
752
|
function removeTree(sftp, directory, guard) {
|
|
509
753
|
const readdir7 = sftp.readdir?.bind(sftp);
|
|
510
|
-
const
|
|
754
|
+
const rmdir2 = sftp.rmdir?.bind(sftp);
|
|
511
755
|
return (async () => {
|
|
512
756
|
throwIfCancelled(guard.signal, directory);
|
|
513
|
-
if (readdir7 === void 0 ||
|
|
757
|
+
if (readdir7 === void 0 || rmdir2 === void 0) {
|
|
514
758
|
throw new SshDevError("REMOTE_UNSUPPORTED", "\u8BE5 SFTP \u5B9E\u73B0\u6CA1\u6709\u63D0\u4F9B readdir/rmdir\uFF0C\u65E0\u6CD5\u9012\u5F52\u5220\u9664\u76EE\u5F55", { path: directory });
|
|
515
759
|
}
|
|
516
760
|
const entries = await withSftpDeadline(guard, "readdir", directory, new Promise((resolve3, reject) => {
|
|
@@ -534,7 +778,7 @@ function removeTree(sftp, directory, guard) {
|
|
|
534
778
|
}
|
|
535
779
|
throwIfCancelled(guard.signal, directory);
|
|
536
780
|
await withSftpDeadline(guard, "rmdir", directory, new Promise((resolve3, reject) => {
|
|
537
|
-
|
|
781
|
+
rmdir2(directory, (error) => error === void 0 ? resolve3() : reject(sftpError("rmdir", directory, error)));
|
|
538
782
|
}));
|
|
539
783
|
})();
|
|
540
784
|
}
|
|
@@ -644,6 +888,7 @@ var init_ssh2_transport = __esm({
|
|
|
644
888
|
init_shell_quote();
|
|
645
889
|
init_transport();
|
|
646
890
|
init_transport_base();
|
|
891
|
+
init_ssh2_forward();
|
|
647
892
|
SSH2_STDERR_MAX_BYTES = 256 * 1024;
|
|
648
893
|
STDERR_TRUNCATION_NOTICE = "\uFF08stderr \u8FC7\u957F\uFF0C\u4EC5\u4FDD\u7559\u672B\u5C3E\u7247\u6BB5\uFF09";
|
|
649
894
|
DEFAULT_SFTP_TIMEOUT_MS = 3e4;
|
|
@@ -653,6 +898,8 @@ var init_ssh2_transport = __esm({
|
|
|
653
898
|
#now;
|
|
654
899
|
#session;
|
|
655
900
|
#sftp;
|
|
901
|
+
/** 懒建的转发器;`dispose()` 时收掉它的全部转发。 */
|
|
902
|
+
#forwarderInstance;
|
|
656
903
|
/** The in-flight first open of the SFTP subsystem (see `#requireSftp`). */
|
|
657
904
|
#sftpPending;
|
|
658
905
|
#connecting;
|
|
@@ -725,7 +972,26 @@ var init_ssh2_transport = __esm({
|
|
|
725
972
|
this.#connectingAbort?.abort();
|
|
726
973
|
}
|
|
727
974
|
/** Close the session. Idempotent. */
|
|
975
|
+
/** {@inheritDoc Transport.forward} */
|
|
976
|
+
async forward(request, signal) {
|
|
977
|
+
return this.#forwarder().forward(request, signal);
|
|
978
|
+
}
|
|
979
|
+
/** {@inheritDoc Transport.closeForwards} */
|
|
980
|
+
async closeForwards() {
|
|
981
|
+
const forwarder = this.#forwarderInstance;
|
|
982
|
+
if (forwarder === void 0) return;
|
|
983
|
+
await forwarder.closeAll();
|
|
984
|
+
}
|
|
985
|
+
/** 懒建转发器;与 SFTP 同一条纪律:没有转发需求就不建。 */
|
|
986
|
+
#forwarder() {
|
|
987
|
+
this.#forwarderInstance ??= createSsh2Forwarder({
|
|
988
|
+
connectionId: this.id,
|
|
989
|
+
session: () => this.#session
|
|
990
|
+
});
|
|
991
|
+
return this.#forwarderInstance;
|
|
992
|
+
}
|
|
728
993
|
async dispose() {
|
|
994
|
+
await this.closeForwards().catch(() => void 0);
|
|
729
995
|
this.#sftp?.end();
|
|
730
996
|
this.#sftp = void 0;
|
|
731
997
|
const hops = this.#session?.hops ?? [];
|
|
@@ -742,6 +1008,12 @@ var init_ssh2_transport = __esm({
|
|
|
742
1008
|
}
|
|
743
1009
|
/** Run one remote command over a channel of the shared session. */
|
|
744
1010
|
async exec(request, signal) {
|
|
1011
|
+
if (request.writeRoots !== void 0 && request.writeRoots.length > 0) {
|
|
1012
|
+
throw new SshDevError("REMOTE_UNSUPPORTED", "\u8BE5\u4F20\u8F93\uFF08Tier 1\uFF0C\u65E0\u4EE3\u7406\uFF09\u65E0\u6CD5\u5EFA\u7ACB Landlock \u5199\u5165\u56F4\u680F", {
|
|
1013
|
+
connectionId: this.id,
|
|
1014
|
+
writeRoots: [...request.writeRoots]
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
745
1017
|
await this.connect(signal);
|
|
746
1018
|
const command = this.#commandFor(request);
|
|
747
1019
|
const startedAt = this.#now();
|
|
@@ -867,7 +1139,7 @@ var init_ssh2_transport = __esm({
|
|
|
867
1139
|
await closeQuietly(sftp, handle, guard);
|
|
868
1140
|
}
|
|
869
1141
|
}
|
|
870
|
-
const digest =
|
|
1142
|
+
const digest = createHash2("sha256").update(request.bytes).digest("hex");
|
|
871
1143
|
return { bytes: payload.byteLength, sha256: digest };
|
|
872
1144
|
}
|
|
873
1145
|
/**
|
|
@@ -963,7 +1235,7 @@ var init_ssh2_transport = __esm({
|
|
|
963
1235
|
await unlinkQuietly(sftp, temporary, guard);
|
|
964
1236
|
throw error;
|
|
965
1237
|
}
|
|
966
|
-
const digest =
|
|
1238
|
+
const digest = createHash2("sha256").update(request.bytes).digest("hex");
|
|
967
1239
|
const stats = await statOrThrow(sftp, request.path, guard);
|
|
968
1240
|
return { bytes: request.bytes.byteLength, sha256: digest, version: versionToken(stats.size, stats.mtime) };
|
|
969
1241
|
}
|
|
@@ -1045,14 +1317,14 @@ ${buildRemoteScript(request.script)}`;
|
|
|
1045
1317
|
this.#sftp = void 0;
|
|
1046
1318
|
this.#sftpPending = void 0;
|
|
1047
1319
|
this.markState("connecting");
|
|
1048
|
-
const
|
|
1049
|
-
if (
|
|
1320
|
+
const connect2 = this.#options.connect;
|
|
1321
|
+
if (connect2 === void 0) {
|
|
1050
1322
|
this.markState("idle");
|
|
1051
1323
|
throw new SshDevError("CONNECTION_INVALID", "Ssh2Transport \u9700\u8981\u8FDE\u63A5\u914D\u7F6E", { connectionId: this.id });
|
|
1052
1324
|
}
|
|
1053
1325
|
const connector = this.#options.connector ?? defaultConnector;
|
|
1054
1326
|
try {
|
|
1055
|
-
this.#session = await connector(signal === void 0 ?
|
|
1327
|
+
this.#session = await connector(signal === void 0 ? connect2 : { ...connect2, signal });
|
|
1056
1328
|
this.#sessionCount++;
|
|
1057
1329
|
this.markState("ready");
|
|
1058
1330
|
} catch (error) {
|
|
@@ -1190,7 +1462,7 @@ ${stderrTail.toString("utf8")}` : stderrTail.toString("utf8"),
|
|
|
1190
1462
|
keepaliveInterval: config.keepAliveMs ?? 15e3,
|
|
1191
1463
|
...config.hostKeyPolicy === "strict" ? {
|
|
1192
1464
|
hostVerifier: (key) => {
|
|
1193
|
-
const fingerprint2 = `SHA256:${
|
|
1465
|
+
const fingerprint2 = `SHA256:${createHash2("sha256").update(key).digest("base64").replace(/=+$/, "")}`;
|
|
1194
1466
|
return (config.trustedHostKeys ?? []).includes(fingerprint2);
|
|
1195
1467
|
}
|
|
1196
1468
|
} : {}
|
|
@@ -1388,6 +1660,9 @@ function store() {
|
|
|
1388
1660
|
holder[STORE_KEY] = created;
|
|
1389
1661
|
return created;
|
|
1390
1662
|
}
|
|
1663
|
+
function currentSessionId() {
|
|
1664
|
+
return store().getStore()?.sessionId;
|
|
1665
|
+
}
|
|
1391
1666
|
function runInSession(sessionId, run) {
|
|
1392
1667
|
return store().run({ sessionId }, run);
|
|
1393
1668
|
}
|
|
@@ -1438,6 +1713,42 @@ function walkAncestors(ctx, sessionId) {
|
|
|
1438
1713
|
}
|
|
1439
1714
|
return { ancestors, settled };
|
|
1440
1715
|
}
|
|
1716
|
+
function readSessionCwd(ctx, sessionId) {
|
|
1717
|
+
const cached2 = sessionCwdCache.get(sessionId);
|
|
1718
|
+
if (cached2 !== void 0) {
|
|
1719
|
+
sessionCwdCache.delete(sessionId);
|
|
1720
|
+
sessionCwdCache.set(sessionId, cached2);
|
|
1721
|
+
return cached2.value === "" ? void 0 : cached2.value;
|
|
1722
|
+
}
|
|
1723
|
+
const found = walkSessionCwd(ctx, sessionId);
|
|
1724
|
+
if (found.settled) {
|
|
1725
|
+
sessionCwdCache.set(sessionId, { value: found.cwd ?? "" });
|
|
1726
|
+
while (sessionCwdCache.size > SESSION_CWD_CACHE_MAX) {
|
|
1727
|
+
const oldest = sessionCwdCache.keys().next();
|
|
1728
|
+
if (oldest.done === true) break;
|
|
1729
|
+
sessionCwdCache.delete(oldest.value);
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
return found.cwd;
|
|
1733
|
+
}
|
|
1734
|
+
var sessionCwdCache = /* @__PURE__ */ new Map();
|
|
1735
|
+
var SESSION_CWD_CACHE_MAX = 256;
|
|
1736
|
+
function walkSessionCwd(ctx, sessionId) {
|
|
1737
|
+
let list;
|
|
1738
|
+
try {
|
|
1739
|
+
const sessions = ctx.get("sessions");
|
|
1740
|
+
list = sessions?.list?.();
|
|
1741
|
+
} catch {
|
|
1742
|
+
return { cwd: void 0, settled: false };
|
|
1743
|
+
}
|
|
1744
|
+
if (!Array.isArray(list)) return { cwd: void 0, settled: false };
|
|
1745
|
+
for (const session of list) {
|
|
1746
|
+
if (session?.header?.id !== sessionId) continue;
|
|
1747
|
+
const cwd = session.header.cwd;
|
|
1748
|
+
return { cwd: typeof cwd === "string" && cwd !== "" ? cwd : void 0, settled: true };
|
|
1749
|
+
}
|
|
1750
|
+
return { cwd: void 0, settled: false };
|
|
1751
|
+
}
|
|
1441
1752
|
function sessionIdentityOf(ctx, sessionId) {
|
|
1442
1753
|
if (sessionId === void 0 || sessionId === "") return {};
|
|
1443
1754
|
return { sessionId, ancestors: readSessionAncestors(ctx, sessionId) };
|
|
@@ -1548,7 +1859,26 @@ var Config = z.object({
|
|
|
1548
1859
|
* 立刻把自己的条目淘汰掉,行为既不是"关缓存"也不是"缓存"。
|
|
1549
1860
|
*/
|
|
1550
1861
|
readCacheTotalMaxBytes: z.natural().default(64 * 1024 * 1024),
|
|
1862
|
+
/**
|
|
1863
|
+
* 端口转发的空闲回收窗口(毫秒);`0` 关闭回收。
|
|
1864
|
+
*
|
|
1865
|
+
* 判据是"没有任何活跃连接且距最后一次活动超过这个窗口"。没有回收的话,一个被 agent 起过、
|
|
1866
|
+
* 之后没人用的转发会一直占着本地端口(用户实测关心的问题)。默认 30 分钟:比一次开发会话的
|
|
1867
|
+
* 间隔长,比"忘了关一整天"短。
|
|
1868
|
+
*/
|
|
1869
|
+
forwardIdleTimeoutMs: z.natural().default(30 * 60 * 1e3),
|
|
1551
1870
|
terminalStateDir: z.string(),
|
|
1871
|
+
/**
|
|
1872
|
+
* 是否给 `remote_exec` / `remote_exec_background` 套**远端写入围栏**(Tier 2 的 Landlock)。
|
|
1873
|
+
*
|
|
1874
|
+
* **默认关**是刻意的:围栏只放行 workspace 根与本会话添加的目录,构建工具往 `$HOME/.cache`、
|
|
1875
|
+
* `/tmp` 写缓存时会被拒——默认开启会把正常命令弄坏。关着的时候 shell 命令能碰到 SSH 用户能碰的
|
|
1876
|
+
* 一切,这与"添加目录"的文件边界是两件事,工具描述里如实写着。
|
|
1877
|
+
*
|
|
1878
|
+
* 打开之后,`remote_exec` 只能写:命令起始的 workspace 根、本会话添加的可写根。Tier 1(无代理)
|
|
1879
|
+
* 没有 Landlock 能力,此项对它们无效。
|
|
1880
|
+
*/
|
|
1881
|
+
execWriteFence: z.boolean().default(false),
|
|
1552
1882
|
transferChunkBytes: z.natural().default(4 * 1024 * 1024),
|
|
1553
1883
|
/** Chunks in flight at once during a transfer. 1 restores the sequential behaviour. */
|
|
1554
1884
|
transferConcurrency: z.natural().default(4),
|
|
@@ -1667,6 +1997,9 @@ function parseSshPath(input) {
|
|
|
1667
1997
|
return { connectionId, path: normalizeRemotePath(rawPath) };
|
|
1668
1998
|
}
|
|
1669
1999
|
function normalizeRemotePath(path) {
|
|
2000
|
+
if (/[\u0000-\u001f]/.test(path)) {
|
|
2001
|
+
throw new SshDevError("ROOT_INVALID", "\u8FDC\u7A0B\u8DEF\u5F84\u4E0D\u80FD\u5305\u542B\u63A7\u5236\u5B57\u7B26", { path: JSON.stringify(path) });
|
|
2002
|
+
}
|
|
1670
2003
|
const normalized = normalizePosixPath(path);
|
|
1671
2004
|
if (normalized !== void 0) return normalized;
|
|
1672
2005
|
if (path === "") throw new SshDevError("ROOT_INVALID", "\u8FDC\u7A0B\u8DEF\u5F84\u4E0D\u80FD\u4E3A\u7A7A", {});
|
|
@@ -1691,6 +2024,14 @@ function remoteBasename(path) {
|
|
|
1691
2024
|
const normalized = normalizeRemotePath(path);
|
|
1692
2025
|
return normalized === "/" ? "" : normalized.slice(normalized.lastIndexOf("/") + 1);
|
|
1693
2026
|
}
|
|
2027
|
+
function longestContainingRoot(roots, candidate) {
|
|
2028
|
+
let best;
|
|
2029
|
+
for (const root of roots) {
|
|
2030
|
+
if (!remotePathContains(root, candidate)) continue;
|
|
2031
|
+
if (best === void 0 || normalizeRemotePath(root).length > normalizeRemotePath(best).length) best = root;
|
|
2032
|
+
}
|
|
2033
|
+
return best;
|
|
2034
|
+
}
|
|
1694
2035
|
|
|
1695
2036
|
// src/host/domain/tool-requests.ts
|
|
1696
2037
|
init_shell();
|
|
@@ -1893,11 +2234,11 @@ function envArg(value) {
|
|
|
1893
2234
|
}
|
|
1894
2235
|
function fileModeArg(value) {
|
|
1895
2236
|
if (value === void 0 || value === null) return { mode: void 0 };
|
|
1896
|
-
const
|
|
1897
|
-
if (
|
|
2237
|
+
const text8 = typeof value === "number" && Number.isInteger(value) ? String(value) : typeof value === "string" ? value.trim() : void 0;
|
|
2238
|
+
if (text8 === void 0 || text8 === "") {
|
|
1898
2239
|
return { error: 'mode \u5FC5\u987B\u662F 3 \u6216 4 \u4F4D\u516B\u8FDB\u5236\u5B57\u7B26\u4E32\uFF08\u4F8B\u5982 "755"\uFF09\uFF0C\u6216\u7B49\u4EF7\u7684\u516B\u8FDB\u5236\u6570\u5B57\uFF08\u4F8B\u5982 755\uFF09' };
|
|
1899
2240
|
}
|
|
1900
|
-
const digits =
|
|
2241
|
+
const digits = text8.startsWith("0o") || text8.startsWith("0O") ? text8.slice(2) : text8;
|
|
1901
2242
|
if (!/^[0-7]{3,4}$/.test(digits)) {
|
|
1902
2243
|
return { error: `mode \u5FC5\u987B\u662F 3 \u6216 4 \u4F4D\u516B\u8FDB\u5236\u6570\u5B57\uFF08\u6536\u5230 ${JSON.stringify(value)}\uFF09\uFF1B\u4F8B\u5982 "755" \u6216 755` };
|
|
1903
2244
|
}
|
|
@@ -2071,18 +2412,18 @@ function parseGrepOutput(stdout, maxResults) {
|
|
|
2071
2412
|
}
|
|
2072
2413
|
return { matches, truncated: false };
|
|
2073
2414
|
}
|
|
2074
|
-
function boundOutput(
|
|
2075
|
-
const bytes = Buffer.byteLength(
|
|
2076
|
-
if (bytes <= maxBytes) return { text:
|
|
2415
|
+
function boundOutput(text8, maxBytes = MAX_OUTPUT_BYTES, keep = "head") {
|
|
2416
|
+
const bytes = Buffer.byteLength(text8, "utf8");
|
|
2417
|
+
if (bytes <= maxBytes) return { text: text8, truncated: false };
|
|
2077
2418
|
if (keep === "tail") {
|
|
2078
|
-
let kept2 =
|
|
2419
|
+
let kept2 = text8.slice(-maxBytes);
|
|
2079
2420
|
while (Buffer.byteLength(kept2, "utf8") > maxBytes) kept2 = kept2.slice(1);
|
|
2080
2421
|
const firstNewline = kept2.indexOf("\n");
|
|
2081
2422
|
if (firstNewline > 0) kept2 = kept2.slice(firstNewline + 1);
|
|
2082
2423
|
return { text: `\u2026\uFF08\u8F93\u51FA\u88AB\u622A\u65AD\uFF1A\u4EC5\u4FDD\u7559\u672B\u5C3E ${maxBytes} \u5B57\u8282\uFF09
|
|
2083
2424
|
${kept2}`, truncated: true };
|
|
2084
2425
|
}
|
|
2085
|
-
let kept =
|
|
2426
|
+
let kept = text8.slice(0, maxBytes);
|
|
2086
2427
|
while (Buffer.byteLength(kept, "utf8") > maxBytes) kept = kept.slice(0, -1);
|
|
2087
2428
|
const lastNewline = kept.lastIndexOf("\n");
|
|
2088
2429
|
if (lastNewline > 0) kept = kept.slice(0, lastNewline);
|
|
@@ -3218,8 +3559,8 @@ function serializeRecycleBinIndex(entries) {
|
|
|
3218
3559
|
return `${JSON.stringify(document, null, 2)}
|
|
3219
3560
|
`;
|
|
3220
3561
|
}
|
|
3221
|
-
function parseRecycleBinIndex(
|
|
3222
|
-
const body =
|
|
3562
|
+
function parseRecycleBinIndex(text8) {
|
|
3563
|
+
const body = text8.charCodeAt(0) === 65279 ? text8.slice(1) : text8;
|
|
3223
3564
|
if (body.trim() === "") {
|
|
3224
3565
|
throw new SshDevError("TRASH_NOT_FOUND", "\u56DE\u6536\u7AD9\u7D22\u5F15\u4E3A\u7A7A\uFF0C\u65E0\u6CD5\u786E\u5B9A\u56DE\u6536\u7AD9\u5185\u5BB9", { reason: "index-unreadable" });
|
|
3225
3566
|
}
|
|
@@ -3321,8 +3662,8 @@ function createBinWorld(deps) {
|
|
|
3321
3662
|
const path = joinUnderPath(binRootOf(root), RECYCLE_BIN_INDEX_FILE);
|
|
3322
3663
|
const info = await statFor(root, path, signal);
|
|
3323
3664
|
if (!info.exists) return { path, entries: [], skipped: 0 };
|
|
3324
|
-
const
|
|
3325
|
-
const parsed = parseRecycleBinIndex(
|
|
3665
|
+
const text8 = await ioFor(root).readText(path, signal);
|
|
3666
|
+
const parsed = parseRecycleBinIndex(text8);
|
|
3326
3667
|
return { path, entries: parsed.entries, skipped: parsed.skipped.length };
|
|
3327
3668
|
}
|
|
3328
3669
|
async function writeIndex(root, path, entries, signal) {
|
|
@@ -3920,8 +4261,8 @@ function serializeSessionTrashIndex(entries) {
|
|
|
3920
4261
|
return `${JSON.stringify(document, null, 2)}
|
|
3921
4262
|
`;
|
|
3922
4263
|
}
|
|
3923
|
-
function parseSessionTrashIndex(
|
|
3924
|
-
const body =
|
|
4264
|
+
function parseSessionTrashIndex(text8) {
|
|
4265
|
+
const body = text8.charCodeAt(0) === 65279 ? text8.slice(1) : text8;
|
|
3925
4266
|
if (body.trim() === "") {
|
|
3926
4267
|
throw new SshDevError("TRASH_NOT_FOUND", "\u4F1A\u8BDD\u56DE\u6536\u7AD9\u7D22\u5F15\u4E3A\u7A7A\uFF0C\u65E0\u6CD5\u786E\u5B9A\u5185\u5BB9", { reason: "index-unreadable" });
|
|
3927
4268
|
}
|
|
@@ -4074,22 +4415,22 @@ function createSessionAdminContext(deps) {
|
|
|
4074
4415
|
);
|
|
4075
4416
|
const entryDir = (segment) => joinPath(binRoot, "entries", segment);
|
|
4076
4417
|
const readIndex = async () => {
|
|
4077
|
-
let
|
|
4418
|
+
let text8;
|
|
4078
4419
|
try {
|
|
4079
|
-
|
|
4420
|
+
text8 = await deps.readText(indexePath());
|
|
4080
4421
|
} catch (error) {
|
|
4081
4422
|
if (isMissing(error)) return [];
|
|
4082
4423
|
throw error;
|
|
4083
4424
|
}
|
|
4084
|
-
return parseSessionTrashIndex(
|
|
4425
|
+
return parseSessionTrashIndex(text8).entries;
|
|
4085
4426
|
};
|
|
4086
4427
|
const writeIndex = async (entries) => {
|
|
4087
|
-
const
|
|
4428
|
+
const text8 = serializeSessionTrashIndex(entries);
|
|
4088
4429
|
if (deps.writeTextAtomic !== void 0) {
|
|
4089
|
-
await deps.writeTextAtomic(indexePath(),
|
|
4430
|
+
await deps.writeTextAtomic(indexePath(), text8);
|
|
4090
4431
|
return;
|
|
4091
4432
|
}
|
|
4092
|
-
await deps.writeText(indexePath(),
|
|
4433
|
+
await deps.writeText(indexePath(), text8);
|
|
4093
4434
|
};
|
|
4094
4435
|
const inventory = async (options = {}) => {
|
|
4095
4436
|
void options;
|
|
@@ -4456,8 +4797,8 @@ import { join } from "node:path";
|
|
|
4456
4797
|
import { createZstdDecompress } from "node:zlib";
|
|
4457
4798
|
var SESSION_HEADER_READ_BYTES = 64 * 1024;
|
|
4458
4799
|
var SESSION_HEADER_RETRY_BYTES = 1024 * 1024;
|
|
4459
|
-
function parseSessionHeaderLine(
|
|
4460
|
-
const firstLine =
|
|
4800
|
+
function parseSessionHeaderLine(text8) {
|
|
4801
|
+
const firstLine = text8.split("\n")[0];
|
|
4461
4802
|
if (firstLine === void 0 || firstLine === "") return void 0;
|
|
4462
4803
|
let header;
|
|
4463
4804
|
try {
|
|
@@ -4487,13 +4828,13 @@ function createSessionHeaderReader(io) {
|
|
|
4487
4828
|
if (log === void 0) return void 0;
|
|
4488
4829
|
const path = join(dir, log);
|
|
4489
4830
|
for (const [index, cap] of caps.entries()) {
|
|
4490
|
-
let
|
|
4831
|
+
let text8;
|
|
4491
4832
|
try {
|
|
4492
|
-
|
|
4833
|
+
text8 = await io.decompress(await io.readHead(path, cap));
|
|
4493
4834
|
} catch {
|
|
4494
4835
|
continue;
|
|
4495
4836
|
}
|
|
4496
|
-
if (
|
|
4837
|
+
if (text8.includes("\n") || index === caps.length - 1) return parseSessionHeaderLine(text8);
|
|
4497
4838
|
}
|
|
4498
4839
|
return void 0;
|
|
4499
4840
|
} catch {
|
|
@@ -4580,6 +4921,25 @@ function replaceLiteral(source, needle, replacement, all) {
|
|
|
4580
4921
|
init_errors();
|
|
4581
4922
|
|
|
4582
4923
|
// src/host/domain/roots/session-roots.ts
|
|
4924
|
+
function workspaceWriteArea(root, workspaceRoot, cwd, identity = {}) {
|
|
4925
|
+
const sessions = root.sessions ?? [];
|
|
4926
|
+
if (sessions.length > 0 && identity.sessionId !== void 0 && identity.sessionId !== "") {
|
|
4927
|
+
if (addedBySession(sessions, identity)) return "whole-root";
|
|
4928
|
+
} else if (sessions.length === 0 && cwd !== void 0 && cwd !== "") {
|
|
4929
|
+
if ((root.workspaces ?? []).some((candidate) => sameWorkspacePath(candidate, cwd))) return "whole-root";
|
|
4930
|
+
}
|
|
4931
|
+
if (workspaceRoot === void 0 || workspaceRoot === "") return "deny";
|
|
4932
|
+
if (root.location === "remote") {
|
|
4933
|
+
const anchor = root.anchorPath;
|
|
4934
|
+
if (anchor === void 0 || anchor === "") return "deny";
|
|
4935
|
+
if (sameWorkspacePath(anchor, workspaceRoot) || localPathContains(anchor, workspaceRoot)) return "whole-root";
|
|
4936
|
+
if (localPathContains(workspaceRoot, anchor)) return "whole-root";
|
|
4937
|
+
return "deny";
|
|
4938
|
+
}
|
|
4939
|
+
if (localPathContains(root.path, workspaceRoot)) return "workspace-only";
|
|
4940
|
+
if (cwd !== void 0 && cwd !== "" && sameWorkspacePath(root.path, cwd)) return "workspace-only";
|
|
4941
|
+
return "deny";
|
|
4942
|
+
}
|
|
4583
4943
|
function addedBySession(sessions, identity) {
|
|
4584
4944
|
const sessionId = identity.sessionId;
|
|
4585
4945
|
if (sessionId === void 0 || sessionId === "") return false;
|
|
@@ -5156,10 +5516,10 @@ var RoutedFileSystem = class _RoutedFileSystem {
|
|
|
5156
5516
|
* @param options - policy, version guard and cancellation.
|
|
5157
5517
|
* @returns the write outcome plus the resolved target.
|
|
5158
5518
|
*/
|
|
5159
|
-
async writeText(input,
|
|
5519
|
+
async writeText(input, text8, options = {}) {
|
|
5160
5520
|
const target = this.resolve(input, options.cwd === void 0 ? {} : { cwd: options.cwd });
|
|
5161
5521
|
this.#assertWritable(target, options.sandbox ?? "workspace-write");
|
|
5162
|
-
const outcome = await target.backend.writeText(target.absolute,
|
|
5522
|
+
const outcome = await target.backend.writeText(target.absolute, text8, options.expectedVersion, options.signal);
|
|
5163
5523
|
return { ...outcome, target };
|
|
5164
5524
|
}
|
|
5165
5525
|
/**
|
|
@@ -5549,6 +5909,15 @@ var RootRegistry = class {
|
|
|
5549
5909
|
if (this.#records.has(key)) {
|
|
5550
5910
|
throw new SshDevError("ROOT_INVALID", `\u8BE5\u6839\u5DF2\u6CE8\u518C\uFF1A${key}`, { root: key });
|
|
5551
5911
|
}
|
|
5912
|
+
const hidden = this.#hidden.get(key);
|
|
5913
|
+
if (hidden !== void 0) {
|
|
5914
|
+
this.#hidden.delete(key);
|
|
5915
|
+
this.#sessionWorkspaceKeys.delete(key);
|
|
5916
|
+
const promoted = { key, root, backend: hidden.backend };
|
|
5917
|
+
this.#records.set(key, promoted);
|
|
5918
|
+
if (options.silent !== true) this.#notify();
|
|
5919
|
+
return promoted;
|
|
5920
|
+
}
|
|
5552
5921
|
const backend = root.location === "remote" ? this.#options.createRemoteBackend(root) : this.#options.createLocalBackend(root);
|
|
5553
5922
|
this.#routed.addRoot(root, backend);
|
|
5554
5923
|
const record2 = { key, root, backend };
|
|
@@ -5626,14 +5995,19 @@ var LEGACY_DIR_NAMES = ["remote-workspace", "ssh-dev"];
|
|
|
5626
5995
|
var CARRIED_FILES = ["roots.json", "user-roots.json"];
|
|
5627
5996
|
var CARRIED_DIRS = ["roots", "state"];
|
|
5628
5997
|
var cached;
|
|
5998
|
+
function dshHome() {
|
|
5999
|
+
const fromEnv = process.env["DSH_HOME"];
|
|
6000
|
+
if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv.trim();
|
|
6001
|
+
return join3(homedir(), ".dsh");
|
|
6002
|
+
}
|
|
5629
6003
|
var migrationWarnings = [];
|
|
5630
6004
|
function resolveStateDir() {
|
|
5631
6005
|
if (cached !== void 0) return cached;
|
|
5632
|
-
const home =
|
|
5633
|
-
const current = join3(home,
|
|
6006
|
+
const home = dshHome();
|
|
6007
|
+
const current = join3(home, STATE_DIR_NAME);
|
|
5634
6008
|
mkdirSync2(current, { recursive: true, mode: 448 });
|
|
5635
6009
|
for (const name2 of LEGACY_DIR_NAMES) {
|
|
5636
|
-
migrationWarnings.push(...carryMissingState(join3(home,
|
|
6010
|
+
migrationWarnings.push(...carryMissingState(join3(home, name2), current, CARRIED_FILES, CARRIED_DIRS));
|
|
5637
6011
|
}
|
|
5638
6012
|
cached = current;
|
|
5639
6013
|
return current;
|
|
@@ -6180,7 +6554,11 @@ function compactParams(request) {
|
|
|
6180
6554
|
...request.timeoutMs === void 0 ? {} : { timeoutMs: request.timeoutMs },
|
|
6181
6555
|
...request.maxBytes === void 0 ? {} : { maxBytes: request.maxBytes },
|
|
6182
6556
|
...request.stdin === void 0 ? {} : { stdin: request.stdin },
|
|
6183
|
-
...request.stdinBase64 === void 0 ? {} : { stdinBase64: request.stdinBase64 }
|
|
6557
|
+
...request.stdinBase64 === void 0 ? {} : { stdinBase64: request.stdinBase64 },
|
|
6558
|
+
// **围栏必须上线。** 真机验收抓到过:`ExecRequest.writeRoots` 有了、`proc.start` 的
|
|
6559
|
+
// `compactProcParams` 也发了,唯独同步 `proc.exec` 的这个构建函数漏了它——于是
|
|
6560
|
+
// "只允许写这些根" 在同步路径上完全没生效(围栏外的写入 exit=0 成功),而且没有任何报错。
|
|
6561
|
+
...request.writeRoots === void 0 ? {} : { writeRoots: [...request.writeRoots] }
|
|
6184
6562
|
};
|
|
6185
6563
|
}
|
|
6186
6564
|
var NS_PER_MS = 1e6;
|
|
@@ -6241,9 +6619,19 @@ function readJobOutput(raw) {
|
|
|
6241
6619
|
lost: record2["lost"] === true,
|
|
6242
6620
|
exitCode: typeof record2["exitCode"] === "number" ? record2["exitCode"] : 0,
|
|
6243
6621
|
...typeof record2["signal"] === "string" ? { signal: record2["signal"] } : {},
|
|
6244
|
-
timedOut: record2["timedOut"] === true
|
|
6622
|
+
timedOut: record2["timedOut"] === true,
|
|
6623
|
+
...exactBytesOf(record2)
|
|
6245
6624
|
};
|
|
6246
6625
|
}
|
|
6626
|
+
function exactBytesOf(record2) {
|
|
6627
|
+
const encoded = record2["dataBase64"];
|
|
6628
|
+
if (typeof encoded !== "string" || encoded === "") return {};
|
|
6629
|
+
try {
|
|
6630
|
+
return { exactBytes: Uint8Array.from(Buffer.from(encoded, "base64")) };
|
|
6631
|
+
} catch {
|
|
6632
|
+
return {};
|
|
6633
|
+
}
|
|
6634
|
+
}
|
|
6247
6635
|
function readLspStatus(raw) {
|
|
6248
6636
|
const record2 = asRecord(raw) ?? {};
|
|
6249
6637
|
return {
|
|
@@ -6271,7 +6659,8 @@ function readLspRead(raw) {
|
|
|
6271
6659
|
state: typeof record2["state"] === "string" ? record2["state"] : "unknown",
|
|
6272
6660
|
droppedBytes: typeof record2["droppedBytes"] === "number" ? record2["droppedBytes"] : 0,
|
|
6273
6661
|
totalBytes: typeof record2["totalBytes"] === "number" ? record2["totalBytes"] : 0,
|
|
6274
|
-
lost: record2["lost"] === true
|
|
6662
|
+
lost: record2["lost"] === true,
|
|
6663
|
+
...exactBytesOf(record2)
|
|
6275
6664
|
};
|
|
6276
6665
|
}
|
|
6277
6666
|
function compactProcParams(request) {
|
|
@@ -7081,7 +7470,7 @@ var BLOB_DRAIN_TIMEOUT_MS = 2e3;
|
|
|
7081
7470
|
init_capability();
|
|
7082
7471
|
init_errors();
|
|
7083
7472
|
init_transport();
|
|
7084
|
-
import { spawn } from "node:child_process";
|
|
7473
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
7085
7474
|
|
|
7086
7475
|
// src/host/infra/transport/fs-commands.ts
|
|
7087
7476
|
init_shell_quote();
|
|
@@ -7183,6 +7572,170 @@ ${stderr}`;
|
|
|
7183
7572
|
if (index === -1) return void 0;
|
|
7184
7573
|
return haystack.slice(index + DSH_COMMAND_ERROR.length).split("\n")[0]?.trim();
|
|
7185
7574
|
}
|
|
7575
|
+
function symlinkCommand(target, linkPath) {
|
|
7576
|
+
const quotedTarget = shellQuote(target);
|
|
7577
|
+
const quotedLink = shellQuote(linkPath);
|
|
7578
|
+
return [
|
|
7579
|
+
// 用全仓统一的拒绝标记(`DSH_ERROR=`),而不是自己发明一套前缀——`commandRefusal` 只认这一种。
|
|
7580
|
+
`if [ -e ${quotedLink} ] || [ -L ${quotedLink} ]; then printf '${DSH_COMMAND_ERROR}exists\\n'; exit 3; fi`,
|
|
7581
|
+
`ln -s ${quotedTarget} ${quotedLink} || { printf '${DSH_COMMAND_ERROR}ln-failed\\n'; exit 4; }`
|
|
7582
|
+
].join("\n");
|
|
7583
|
+
}
|
|
7584
|
+
function readlinkCommand(absolute) {
|
|
7585
|
+
const quoted = shellQuote(absolute);
|
|
7586
|
+
return `if [ ! -L ${quoted} ]; then echo 'DSH_NOTLINK'; exit 0; fi
|
|
7587
|
+
readlink ${quoted}`;
|
|
7588
|
+
}
|
|
7589
|
+
|
|
7590
|
+
// src/host/infra/transport/ssh/alias-forward.ts
|
|
7591
|
+
init_errors();
|
|
7592
|
+
init_forward_proxy();
|
|
7593
|
+
|
|
7594
|
+
// src/host/infra/transport/ssh/ssh-argv.ts
|
|
7595
|
+
function credentialArgv(options) {
|
|
7596
|
+
const argv = [];
|
|
7597
|
+
if (options.identityFile !== void 0 && options.identityFile !== "") argv.push("-i", options.identityFile);
|
|
7598
|
+
if (options.knownHostsFile !== void 0 && options.knownHostsFile !== "") {
|
|
7599
|
+
argv.push("-o", `UserKnownHostsFile=${options.knownHostsFile}`);
|
|
7600
|
+
}
|
|
7601
|
+
if (options.user !== void 0 && options.user !== "") argv.push("-l", options.user);
|
|
7602
|
+
return argv;
|
|
7603
|
+
}
|
|
7604
|
+
function commonSshOptions(options) {
|
|
7605
|
+
const argv = [
|
|
7606
|
+
"-o",
|
|
7607
|
+
`BatchMode=${options.batchMode === false ? "no" : "yes"}`
|
|
7608
|
+
];
|
|
7609
|
+
if (options.connectTimeoutMs !== void 0) {
|
|
7610
|
+
argv.push("-o", `ConnectTimeout=${Math.max(1, Math.ceil(options.connectTimeoutMs / 1e3))}`);
|
|
7611
|
+
}
|
|
7612
|
+
if (options.hostKeyPolicy !== void 0) {
|
|
7613
|
+
argv.push("-o", `StrictHostKeyChecking=${options.hostKeyPolicy === "strict" ? "yes" : "accept-new"}`);
|
|
7614
|
+
}
|
|
7615
|
+
for (const option of options.extraOptions ?? []) argv.push("-o", option);
|
|
7616
|
+
argv.push(...credentialArgv(options));
|
|
7617
|
+
return argv;
|
|
7618
|
+
}
|
|
7619
|
+
function sshPrefix(sshBinary, options) {
|
|
7620
|
+
return [sshBinary, "-T", ...commonSshOptions(options), options.alias];
|
|
7621
|
+
}
|
|
7622
|
+
|
|
7623
|
+
// src/host/infra/transport/ssh/forward-argv.ts
|
|
7624
|
+
function buildForwardChannelArgv(options) {
|
|
7625
|
+
const sshArgv = {
|
|
7626
|
+
alias: options.alias,
|
|
7627
|
+
...options.user === void 0 ? {} : { user: options.user },
|
|
7628
|
+
...options.identityFile === void 0 ? {} : { identityFile: options.identityFile },
|
|
7629
|
+
...options.knownHostsFile === void 0 ? {} : { knownHostsFile: options.knownHostsFile },
|
|
7630
|
+
...options.hostKeyPolicy === void 0 ? {} : { hostKeyPolicy: options.hostKeyPolicy },
|
|
7631
|
+
...options.connectTimeoutMs === void 0 ? {} : { connectTimeoutMs: options.connectTimeoutMs },
|
|
7632
|
+
...options.batchMode === void 0 ? {} : { batchMode: options.batchMode },
|
|
7633
|
+
...options.extraOptions === void 0 ? {} : { extraOptions: options.extraOptions }
|
|
7634
|
+
};
|
|
7635
|
+
const argv = [options.sshBinary ?? "ssh"];
|
|
7636
|
+
argv.push(
|
|
7637
|
+
"-o",
|
|
7638
|
+
`BatchMode=${options.batchMode === false ? "no" : "yes"}`,
|
|
7639
|
+
"-o",
|
|
7640
|
+
"ServerAliveInterval=15",
|
|
7641
|
+
"-o",
|
|
7642
|
+
"ServerAliveCountMax=3",
|
|
7643
|
+
...commonSshOptions(sshArgv),
|
|
7644
|
+
"-W",
|
|
7645
|
+
`${options.remoteHost}:${String(options.remotePort)}`,
|
|
7646
|
+
options.alias
|
|
7647
|
+
);
|
|
7648
|
+
return argv;
|
|
7649
|
+
}
|
|
7650
|
+
|
|
7651
|
+
// src/host/infra/transport/ssh/alias-forward.ts
|
|
7652
|
+
function channelFromChild(child) {
|
|
7653
|
+
const readable = child.stdout;
|
|
7654
|
+
const writable = child.stdin;
|
|
7655
|
+
if (readable === null || writable === null) {
|
|
7656
|
+
child.kill();
|
|
7657
|
+
throw new SshDevError("REMOTE_UNSUPPORTED", "spawner \u6CA1\u6709\u63D0\u4F9B stdio\uFF0C\u65E0\u6CD5\u4F5C\u4E3A\u8F6C\u53D1\u901A\u9053", {});
|
|
7658
|
+
}
|
|
7659
|
+
return { readable, writable, destroy: () => child.kill() };
|
|
7660
|
+
}
|
|
7661
|
+
var CHANNEL_FAILURE_GRACE_MS = 150;
|
|
7662
|
+
function createSshForwarder(options) {
|
|
7663
|
+
return createProxyForwarder({
|
|
7664
|
+
connectionId: options.connectionId,
|
|
7665
|
+
...options.idleTimeoutMs === void 0 ? {} : { idleTimeoutMs: options.idleTimeoutMs },
|
|
7666
|
+
...options.now === void 0 ? {} : { now: options.now },
|
|
7667
|
+
...options.onReclaimed === void 0 ? {} : { onReclaimed: options.onReclaimed },
|
|
7668
|
+
openChannel: async (request) => {
|
|
7669
|
+
const child = options.spawn(options.buildArgv(request), options.env === void 0 ? {} : { env: options.env });
|
|
7670
|
+
const early = await Promise.race([
|
|
7671
|
+
child.exited,
|
|
7672
|
+
new Promise((resolve3) => setTimeout(() => resolve3(void 0), CHANNEL_FAILURE_GRACE_MS))
|
|
7673
|
+
]);
|
|
7674
|
+
if (early !== void 0) {
|
|
7675
|
+
const reason = child.stderr().trim();
|
|
7676
|
+
throw new SshDevError(
|
|
7677
|
+
"CONNECTION_UNREACHABLE",
|
|
7678
|
+
`\u8FDC\u7AEF\u62D2\u7EDD\u8F6C\u53D1\u5230 ${request.remoteHost}:${String(request.remotePort)}${reason === "" ? "" : `\uFF1A${reason}`}`,
|
|
7679
|
+
{ remoteHost: request.remoteHost, remotePort: request.remotePort }
|
|
7680
|
+
);
|
|
7681
|
+
}
|
|
7682
|
+
return channelFromChild(child);
|
|
7683
|
+
}
|
|
7684
|
+
});
|
|
7685
|
+
}
|
|
7686
|
+
|
|
7687
|
+
// src/host/infra/transport/node-spawn-long.ts
|
|
7688
|
+
import { spawn } from "node:child_process";
|
|
7689
|
+
var STDERR_TAIL_BYTES = 8 * 1024;
|
|
7690
|
+
function createNodeLongSpawner() {
|
|
7691
|
+
return (argv, options) => {
|
|
7692
|
+
const [command, ...args] = argv;
|
|
7693
|
+
if (command === void 0) {
|
|
7694
|
+
throw new Error("\u957F\u9A7B spawn \u9700\u8981\u81F3\u5C11\u4E00\u4E2A\u547D\u4EE4");
|
|
7695
|
+
}
|
|
7696
|
+
const child = spawn(command, args, {
|
|
7697
|
+
windowsHide: true,
|
|
7698
|
+
// `-W` 通道要双向管道:stdin 写远端、stdout 读远端。stderr 单独收集用于错误文案。
|
|
7699
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
7700
|
+
...options.env === void 0 ? {} : { env: { ...process.env, ...options.env } }
|
|
7701
|
+
});
|
|
7702
|
+
let stderrText = "";
|
|
7703
|
+
let settled = false;
|
|
7704
|
+
let resolveExited = () => void 0;
|
|
7705
|
+
const exited = new Promise((resolve3) => {
|
|
7706
|
+
resolveExited = resolve3;
|
|
7707
|
+
});
|
|
7708
|
+
const settle = (result) => {
|
|
7709
|
+
if (settled) return;
|
|
7710
|
+
settled = true;
|
|
7711
|
+
resolveExited(result);
|
|
7712
|
+
};
|
|
7713
|
+
child.stderr?.on("data", (chunk) => {
|
|
7714
|
+
stderrText += chunk.toString("utf8");
|
|
7715
|
+
if (stderrText.length > STDERR_TAIL_BYTES) stderrText = stderrText.slice(-STDERR_TAIL_BYTES);
|
|
7716
|
+
});
|
|
7717
|
+
child.on("error", (error) => {
|
|
7718
|
+
stderrText += `${stderrText === "" ? "" : "\n"}${error.message}`;
|
|
7719
|
+
settle({ code: null, signal: null });
|
|
7720
|
+
});
|
|
7721
|
+
child.on("close", (code, signal) => {
|
|
7722
|
+
settle({ code, signal: typeof signal === "string" ? signal : null });
|
|
7723
|
+
});
|
|
7724
|
+
return {
|
|
7725
|
+
stdin: child.stdin,
|
|
7726
|
+
stdout: child.stdout,
|
|
7727
|
+
exited,
|
|
7728
|
+
kill: () => {
|
|
7729
|
+
if (settled) return;
|
|
7730
|
+
try {
|
|
7731
|
+
child.kill();
|
|
7732
|
+
} catch {
|
|
7733
|
+
}
|
|
7734
|
+
},
|
|
7735
|
+
stderr: () => stderrText
|
|
7736
|
+
};
|
|
7737
|
+
};
|
|
7738
|
+
}
|
|
7186
7739
|
|
|
7187
7740
|
// src/host/infra/transport/agent/agent-transport.ts
|
|
7188
7741
|
init_transport_base();
|
|
@@ -7208,6 +7761,13 @@ var AgentTransport = class extends TransportBase {
|
|
|
7208
7761
|
#disposeEpoch = 0;
|
|
7209
7762
|
/** Identifies the current session, so a stale session's exit cannot disturb its successor. */
|
|
7210
7763
|
#sessionToken = 0;
|
|
7764
|
+
/**
|
|
7765
|
+
* 端口转发器(懒建)。
|
|
7766
|
+
*
|
|
7767
|
+
* 走**同一条** `sshArgv`:它已经含 `-l`/`-i`/known_hosts/askpass env,重新按别名拼一遍会与真实连接用的
|
|
7768
|
+
* 参数漂移。`-N -L` 只多加两个参数,不改变认证与跳板链。
|
|
7769
|
+
*/
|
|
7770
|
+
#forwarder;
|
|
7211
7771
|
constructor(options) {
|
|
7212
7772
|
super();
|
|
7213
7773
|
this.id = options.connectionId;
|
|
@@ -7306,7 +7866,38 @@ var AgentTransport = class extends TransportBase {
|
|
|
7306
7866
|
*
|
|
7307
7867
|
* A later `connect` opens a fresh session, so the transport is reusable after a disconnect.
|
|
7308
7868
|
*/
|
|
7869
|
+
/** {@inheritDoc Transport.forward} */
|
|
7870
|
+
async forward(request, signal) {
|
|
7871
|
+
return this.#forwarderOf().forward(request, signal);
|
|
7872
|
+
}
|
|
7873
|
+
/** {@inheritDoc Transport.closeForwards} */
|
|
7874
|
+
async closeForwards() {
|
|
7875
|
+
const forwarder = this.#forwarder;
|
|
7876
|
+
if (forwarder === void 0) return;
|
|
7877
|
+
await forwarder.closeAll();
|
|
7878
|
+
}
|
|
7879
|
+
/** 造(或复用)转发器:argv 与携带会话的那条 ssh 同源。 */
|
|
7880
|
+
#forwarderOf() {
|
|
7881
|
+
if (this.#forwarder !== void 0) return this.#forwarder;
|
|
7882
|
+
const base = this.#options.sshArgv;
|
|
7883
|
+
if (base === void 0 || base.length === 0) {
|
|
7884
|
+
throw new SshDevError(
|
|
7885
|
+
"REMOTE_UNSUPPORTED",
|
|
7886
|
+
"\u8FD9\u6761\u8FDE\u63A5\u6CA1\u6709\u53EF\u76F4\u63A5\u590D\u7528\u7684 ssh \u547D\u4EE4\u884C\uFF0C\u65E0\u6CD5\u5EFA\u7ACB\u7AEF\u53E3\u8F6C\u53D1\uFF08Tier 2 \u9700\u8981 sshArgv\uFF09",
|
|
7887
|
+
{ connectionId: this.#options.connectionId }
|
|
7888
|
+
);
|
|
7889
|
+
}
|
|
7890
|
+
this.#forwarder = createSshForwarder({
|
|
7891
|
+
connectionId: this.#options.connectionId,
|
|
7892
|
+
buildArgv: (request) => buildAgentForwardChannelArgv(base, request),
|
|
7893
|
+
spawn: createNodeLongSpawner(),
|
|
7894
|
+
...this.#options.env === void 0 ? {} : { env: this.#options.env },
|
|
7895
|
+
...this.#options.forwardIdleTimeoutMs === void 0 ? {} : { idleTimeoutMs: this.#options.forwardIdleTimeoutMs }
|
|
7896
|
+
});
|
|
7897
|
+
return this.#forwarder;
|
|
7898
|
+
}
|
|
7309
7899
|
async dispose() {
|
|
7900
|
+
await this.closeForwards().catch(() => void 0);
|
|
7310
7901
|
this.#disposing = true;
|
|
7311
7902
|
this.#disposeEpoch++;
|
|
7312
7903
|
this.markState("idle");
|
|
@@ -7658,7 +8249,7 @@ var AgentTransport = class extends TransportBase {
|
|
|
7658
8249
|
async probeEnv(refresh, signal) {
|
|
7659
8250
|
const record2 = await this.#optionalRequest("env.probe", refresh ? { refresh: true } : {}, signal);
|
|
7660
8251
|
const number = (value) => typeof value === "number" ? value : void 0;
|
|
7661
|
-
const
|
|
8252
|
+
const text8 = (value) => typeof value === "string" && value !== "" ? value : void 0;
|
|
7662
8253
|
const tools = {};
|
|
7663
8254
|
const rawTools = record2["tools"];
|
|
7664
8255
|
if (rawTools !== null && typeof rawTools === "object") {
|
|
@@ -7668,19 +8259,19 @@ var AgentTransport = class extends TransportBase {
|
|
|
7668
8259
|
}
|
|
7669
8260
|
const missing = Array.isArray(record2["missing"]) ? record2["missing"].filter((name2) => typeof name2 === "string") : [];
|
|
7670
8261
|
return {
|
|
7671
|
-
os:
|
|
7672
|
-
arch:
|
|
8262
|
+
os: text8(record2["os"]) ?? "",
|
|
8263
|
+
arch: text8(record2["arch"]) ?? "",
|
|
7673
8264
|
cpus: number(record2["cpus"]) ?? 0,
|
|
7674
|
-
user:
|
|
8265
|
+
user: text8(record2["user"]) ?? "",
|
|
7675
8266
|
uid: number(record2["uid"]) ?? -1,
|
|
7676
|
-
home:
|
|
7677
|
-
path:
|
|
8267
|
+
home: text8(record2["home"]) ?? "",
|
|
8268
|
+
path: text8(record2["path"]) ?? "",
|
|
7678
8269
|
tools,
|
|
7679
8270
|
missing,
|
|
7680
8271
|
probedAt: number(record2["probedAt"]) ?? 0,
|
|
7681
|
-
...
|
|
7682
|
-
...
|
|
7683
|
-
...
|
|
8272
|
+
...text8(record2["kernel"]) === void 0 ? {} : { kernel: text8(record2["kernel"]) },
|
|
8273
|
+
...text8(record2["distro"]) === void 0 ? {} : { distro: text8(record2["distro"]) },
|
|
8274
|
+
...text8(record2["shell"]) === void 0 ? {} : { shell: text8(record2["shell"]) },
|
|
7684
8275
|
...number(record2["memTotalBytes"]) === void 0 ? {} : { memTotalBytes: number(record2["memTotalBytes"]) },
|
|
7685
8276
|
...number(record2["memAvailableBytes"]) === void 0 ? {} : { memAvailableBytes: number(record2["memAvailableBytes"]) },
|
|
7686
8277
|
...number(record2["diskFreeBytes"]) === void 0 ? {} : { diskFreeBytes: number(record2["diskFreeBytes"]) },
|
|
@@ -8438,7 +9029,7 @@ var AgentTransport = class extends TransportBase {
|
|
|
8438
9029
|
}
|
|
8439
9030
|
};
|
|
8440
9031
|
var defaultLauncher = async (options) => {
|
|
8441
|
-
const child =
|
|
9032
|
+
const child = spawn2(options.sshArgv[0] ?? "ssh", [...options.sshArgv.slice(1), options.remoteCommand], {
|
|
8442
9033
|
stdio: ["pipe", "pipe", "inherit"],
|
|
8443
9034
|
windowsHide: true,
|
|
8444
9035
|
// Only when the connection supplied entries: a fresh env object would drop PATH.
|
|
@@ -8504,6 +9095,21 @@ var ChildProcessSession = class {
|
|
|
8504
9095
|
this.#child.kill();
|
|
8505
9096
|
}
|
|
8506
9097
|
};
|
|
9098
|
+
function buildAgentForwardChannelArgv(base, request) {
|
|
9099
|
+
if (base.length < 2) {
|
|
9100
|
+
throw new SshDevError("REMOTE_UNSUPPORTED", "ssh argv \u592A\u77ED\uFF0C\u65E0\u6CD5\u63D2\u5165\u7AEF\u53E3\u8F6C\u53D1\u53C2\u6570", {});
|
|
9101
|
+
}
|
|
9102
|
+
const binary = base[0];
|
|
9103
|
+
const host = base[base.length - 1];
|
|
9104
|
+
const options = base.slice(1, -1);
|
|
9105
|
+
return [
|
|
9106
|
+
binary,
|
|
9107
|
+
...options,
|
|
9108
|
+
"-W",
|
|
9109
|
+
`${request.remoteHost}:${String(request.remotePort)}`,
|
|
9110
|
+
host
|
|
9111
|
+
];
|
|
9112
|
+
}
|
|
8507
9113
|
|
|
8508
9114
|
// src/host/infra/transport/agent/agent-installer.ts
|
|
8509
9115
|
init_shell_quote();
|
|
@@ -8551,14 +9157,14 @@ async function ensureAgent(options) {
|
|
|
8551
9157
|
let machine = "";
|
|
8552
9158
|
let tool;
|
|
8553
9159
|
try {
|
|
8554
|
-
const
|
|
8555
|
-
if (
|
|
9160
|
+
const probe2 = await io.exec("uname -s; uname -m", signal);
|
|
9161
|
+
if (probe2.exitCode !== 0) {
|
|
8556
9162
|
return tier1("probe-failed", "\u8FDC\u7AEF\u65E0\u6CD5\u6267\u884C uname\uFF0C\u65E0\u6CD5\u786E\u5B9A\u4EE3\u7406\u4EA7\u7269\uFF1B\u4FDD\u6301\u96F6\u5B89\u88C5\u6A21\u5F0F", {
|
|
8557
|
-
exitCode:
|
|
8558
|
-
stderr:
|
|
9163
|
+
exitCode: probe2.exitCode,
|
|
9164
|
+
stderr: probe2.stderr.slice(-500)
|
|
8559
9165
|
});
|
|
8560
9166
|
}
|
|
8561
|
-
[system = "", machine = ""] =
|
|
9167
|
+
[system = "", machine = ""] = probe2.stdout.split("\n").map((line) => line.trim());
|
|
8562
9168
|
tool = await io.digestTool(signal);
|
|
8563
9169
|
} catch (error) {
|
|
8564
9170
|
if (signal?.aborted === true) throw error;
|
|
@@ -8882,7 +9488,7 @@ var LocalBackend = class {
|
|
|
8882
9488
|
version: chunk.version
|
|
8883
9489
|
};
|
|
8884
9490
|
}
|
|
8885
|
-
async writeText(absolute,
|
|
9491
|
+
async writeText(absolute, text8, expectedVersion, signal) {
|
|
8886
9492
|
signal?.throwIfAborted();
|
|
8887
9493
|
if (expectedVersion !== void 0 && expectedVersion !== "") {
|
|
8888
9494
|
const current = await this.stat(absolute, signal);
|
|
@@ -8900,7 +9506,7 @@ var LocalBackend = class {
|
|
|
8900
9506
|
} catch (error) {
|
|
8901
9507
|
throw ioError("write (parent not writable)", directory, error);
|
|
8902
9508
|
}
|
|
8903
|
-
const payload = Buffer.from(
|
|
9509
|
+
const payload = Buffer.from(text8, "utf8");
|
|
8904
9510
|
const temporary = temporaryNameFor(absolute);
|
|
8905
9511
|
try {
|
|
8906
9512
|
await writeFile(temporary, payload);
|
|
@@ -9070,17 +9676,17 @@ var PlatformLocalBackend = class {
|
|
|
9070
9676
|
return this.#platform.watch(target, changed, signal);
|
|
9071
9677
|
}
|
|
9072
9678
|
/** Fenced by the platform. An empty `expectedVersion` means "no guard", as the backend contract says. */
|
|
9073
|
-
async writeText(absolute,
|
|
9679
|
+
async writeText(absolute, text8, expectedVersion, signal, policy) {
|
|
9074
9680
|
const target = await this.#target(absolute, signal);
|
|
9075
9681
|
const intent = expectedVersion === void 0 || expectedVersion === "" ? void 0 : { kind: "replaceIfVersion", version: expectedVersion };
|
|
9076
|
-
const outcome = await this.#platform.writeText(target,
|
|
9077
|
-
return { bytes: Buffer.byteLength(
|
|
9682
|
+
const outcome = policy === void 0 ? await this.#platform.writeText(target, text8, intent, signal) : await this.#platform.writeText(target, text8, intent, signal, policy);
|
|
9683
|
+
return { bytes: Buffer.byteLength(text8, "utf8"), version: String(outcome.version) };
|
|
9078
9684
|
}
|
|
9079
9685
|
/** Fenced by the platform; the three-state semantics are the platform's, which is what the core expects. */
|
|
9080
|
-
async editText(absolute, oldString, newString, replaceAll, expectedVersion, signal) {
|
|
9686
|
+
async editText(absolute, oldString, newString, replaceAll, expectedVersion, signal, policy) {
|
|
9081
9687
|
const target = await this.#target(absolute, signal);
|
|
9082
9688
|
const expected = expectedVersion === void 0 || expectedVersion === "" ? void 0 : { version: expectedVersion };
|
|
9083
|
-
const outcome = await this.#platform.editText(target, { oldString, newString, replaceAll }, expected, signal);
|
|
9689
|
+
const outcome = policy === void 0 ? await this.#platform.editText(target, { oldString, newString, replaceAll }, expected, signal) : await this.#platform.editText(target, { oldString, newString, replaceAll }, expected, signal, policy);
|
|
9084
9690
|
return {
|
|
9085
9691
|
bytes: Buffer.byteLength(newString, "utf8"),
|
|
9086
9692
|
version: String(outcome.version),
|
|
@@ -9125,6 +9731,26 @@ var PlatformLocalBackend = class {
|
|
|
9125
9731
|
// src/host/infra/backend/remote-backend.ts
|
|
9126
9732
|
init_errors();
|
|
9127
9733
|
|
|
9734
|
+
// src/host/infra/transport/retry.ts
|
|
9735
|
+
init_errors();
|
|
9736
|
+
function isDisconnect(error) {
|
|
9737
|
+
if (!isSshDevError(error)) return false;
|
|
9738
|
+
return error.code === "CONNECTION_LOST";
|
|
9739
|
+
}
|
|
9740
|
+
async function retryOnceOnDisconnect(operation, options = {}) {
|
|
9741
|
+
try {
|
|
9742
|
+
return { value: await operation(), retried: false };
|
|
9743
|
+
} catch (error) {
|
|
9744
|
+
if (options.signal?.aborted === true) throw error;
|
|
9745
|
+
if (!isDisconnect(error)) throw error;
|
|
9746
|
+
await options.beforeRetry?.();
|
|
9747
|
+
return { value: await operation(), retried: true };
|
|
9748
|
+
}
|
|
9749
|
+
}
|
|
9750
|
+
|
|
9751
|
+
// src/host/infra/backend/remote-backend.ts
|
|
9752
|
+
import { createHash } from "node:crypto";
|
|
9753
|
+
|
|
9128
9754
|
// src/host/infra/cache/lru.ts
|
|
9129
9755
|
var LruCache = class {
|
|
9130
9756
|
#maxEntries;
|
|
@@ -9431,20 +10057,118 @@ function parseListing(stdout) {
|
|
|
9431
10057
|
return entries;
|
|
9432
10058
|
}
|
|
9433
10059
|
|
|
9434
|
-
// src/host/infra/backend/
|
|
9435
|
-
|
|
9436
|
-
|
|
9437
|
-
|
|
9438
|
-
|
|
9439
|
-
|
|
9440
|
-
|
|
9441
|
-
|
|
9442
|
-
|
|
9443
|
-
|
|
10060
|
+
// src/host/infra/backend/poll-watch.ts
|
|
10061
|
+
var DEFAULT_POLL_INTERVAL_MS = 2500;
|
|
10062
|
+
var DEFAULT_POLL_FAILURE_THRESHOLD = 3;
|
|
10063
|
+
var DEFAULT_POLL_MAX_BACKOFF_MS = 3e4;
|
|
10064
|
+
var DEFAULT_POLL_MAX_ENTRIES = 2e3;
|
|
10065
|
+
function defaultSleep(ms, signal) {
|
|
10066
|
+
if (signal.aborted) return Promise.resolve();
|
|
10067
|
+
return new Promise((resolve3) => {
|
|
10068
|
+
const finish = () => {
|
|
10069
|
+
clearTimeout(timer);
|
|
10070
|
+
signal.removeEventListener("abort", onAbort);
|
|
10071
|
+
resolve3();
|
|
10072
|
+
};
|
|
10073
|
+
const onAbort = () => finish();
|
|
10074
|
+
const timer = setTimeout(finish, ms);
|
|
10075
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
10076
|
+
});
|
|
9444
10077
|
}
|
|
9445
|
-
|
|
9446
|
-
|
|
9447
|
-
|
|
10078
|
+
function listingSignature(entries) {
|
|
10079
|
+
const rows = entries.map((entry) => `${entry.name}\0${entry.isDirectory ? "d" : entry.isFile ? "f" : "l"}\0${entry.size}\0${entry.mtimeMs}`).sort();
|
|
10080
|
+
return rows.join("");
|
|
10081
|
+
}
|
|
10082
|
+
function startPollWatch(deps) {
|
|
10083
|
+
const intervalMs = Math.max(1, Math.floor(deps.options?.intervalMs ?? DEFAULT_POLL_INTERVAL_MS));
|
|
10084
|
+
const failureThreshold = Math.max(1, Math.floor(deps.options?.failureThreshold ?? DEFAULT_POLL_FAILURE_THRESHOLD));
|
|
10085
|
+
const maxBackoffMs = Math.max(intervalMs, Math.floor(deps.options?.maxBackoffMs ?? DEFAULT_POLL_MAX_BACKOFF_MS));
|
|
10086
|
+
const maxEntries = Math.max(1, Math.floor(deps.options?.maxEntries ?? DEFAULT_POLL_MAX_ENTRIES));
|
|
10087
|
+
const sleep2 = deps.options?.sleep ?? defaultSleep;
|
|
10088
|
+
const isFatal = deps.options?.isFatal ?? (() => false);
|
|
10089
|
+
const stopped = new AbortController();
|
|
10090
|
+
const onCallerAbort = () => stopped.abort();
|
|
10091
|
+
if (deps.signal.aborted) stopped.abort();
|
|
10092
|
+
else deps.signal.addEventListener("abort", onCallerAbort, { once: true });
|
|
10093
|
+
let done = false;
|
|
10094
|
+
let baseline = deps.initial?.signature;
|
|
10095
|
+
let failures = 0;
|
|
10096
|
+
const notify = (error) => {
|
|
10097
|
+
try {
|
|
10098
|
+
if (error === void 0) deps.changed();
|
|
10099
|
+
else deps.changed(error);
|
|
10100
|
+
} catch {
|
|
10101
|
+
}
|
|
10102
|
+
};
|
|
10103
|
+
const loop = async () => {
|
|
10104
|
+
try {
|
|
10105
|
+
while (!stopped.signal.aborted) {
|
|
10106
|
+
try {
|
|
10107
|
+
const snapshot = await deps.snapshot(stopped.signal);
|
|
10108
|
+
if (stopped.signal.aborted) return;
|
|
10109
|
+
if (snapshot.entries > maxEntries) {
|
|
10110
|
+
notify(
|
|
10111
|
+
new Error(
|
|
10112
|
+
`\u8F6E\u8BE2\u76D1\u542C\u653E\u5F03\uFF1A${snapshot.entries} \u4E2A\u6761\u76EE\u8D85\u8FC7\u4E0A\u9650 ${maxEntries}\uFF08\u6BCF ${intervalMs} ms \u4F20\u56DE\u5E76\u6BD4\u8F83\u4E00\u6B21\u7684\u6210\u672C\u4E0D\u53EF\u63A5\u53D7\uFF09\uFF1B\u8BF7\u6539\u4E3A\u76D1\u542C\u66F4\u5C0F\u7684\u76EE\u5F55`
|
|
10113
|
+
)
|
|
10114
|
+
);
|
|
10115
|
+
return;
|
|
10116
|
+
}
|
|
10117
|
+
if (baseline === void 0) {
|
|
10118
|
+
baseline = snapshot.signature;
|
|
10119
|
+
} else if (snapshot.signature !== baseline) {
|
|
10120
|
+
baseline = snapshot.signature;
|
|
10121
|
+
notify();
|
|
10122
|
+
}
|
|
10123
|
+
failures = 0;
|
|
10124
|
+
} catch (error) {
|
|
10125
|
+
if (stopped.signal.aborted) return;
|
|
10126
|
+
if (isFatal(error)) {
|
|
10127
|
+
notify(error instanceof Error ? error : new Error(String(error)));
|
|
10128
|
+
return;
|
|
10129
|
+
}
|
|
10130
|
+
failures += 1;
|
|
10131
|
+
if (failures >= failureThreshold) {
|
|
10132
|
+
notify(error instanceof Error ? error : new Error(String(error)));
|
|
10133
|
+
return;
|
|
10134
|
+
}
|
|
10135
|
+
const backoff = Math.min(maxBackoffMs, intervalMs * 2 ** (failures - 1));
|
|
10136
|
+
await sleep2(backoff, stopped.signal);
|
|
10137
|
+
continue;
|
|
10138
|
+
}
|
|
10139
|
+
await sleep2(intervalMs, stopped.signal);
|
|
10140
|
+
}
|
|
10141
|
+
} finally {
|
|
10142
|
+
deps.signal.removeEventListener("abort", onCallerAbort);
|
|
10143
|
+
}
|
|
10144
|
+
};
|
|
10145
|
+
void loop().catch(() => void 0);
|
|
10146
|
+
return async () => {
|
|
10147
|
+
if (done) return;
|
|
10148
|
+
done = true;
|
|
10149
|
+
stopped.abort();
|
|
10150
|
+
deps.signal.removeEventListener("abort", onCallerAbort);
|
|
10151
|
+
};
|
|
10152
|
+
}
|
|
10153
|
+
|
|
10154
|
+
// src/host/infra/backend/remote-backend.ts
|
|
10155
|
+
init_shell_quote();
|
|
10156
|
+
function isWatchCapable(transport) {
|
|
10157
|
+
if (transport === void 0) return false;
|
|
10158
|
+
const candidate = transport;
|
|
10159
|
+
return typeof candidate.startWatch === "function" && typeof candidate.pollWatch === "function" && typeof candidate.stopWatch === "function" && candidate.agentCapabilities?.watch === "native";
|
|
10160
|
+
}
|
|
10161
|
+
var remoteWatchSequence = 0;
|
|
10162
|
+
function parentRemotePath(absolute) {
|
|
10163
|
+
return remoteDirname(absolute);
|
|
10164
|
+
}
|
|
10165
|
+
var UTF8 = new TextDecoder("utf-8", { ignoreBOM: true });
|
|
10166
|
+
var POLL_LISTING_SHARE_TTL_MS = 1e3;
|
|
10167
|
+
var STAT_CACHE_TTL_MS = 5e3;
|
|
10168
|
+
var STAT_CACHE_MAX_ENTRIES = 2048;
|
|
10169
|
+
var DEFAULT_CACHE = { ttlMs: 5e3, maxEntries: 256, maxBytes: 8 * 1024 * 1024 };
|
|
10170
|
+
var STAT_MARKER = "# DSH_STAT";
|
|
10171
|
+
var BATCH_MARKER = "#DSH_F ";
|
|
9448
10172
|
var BATCH_TOTAL_BYTES = 32 * 1024 * 1024;
|
|
9449
10173
|
function parseBatchRead(stdout, cap) {
|
|
9450
10174
|
const chunks = /* @__PURE__ */ new Map();
|
|
@@ -9506,6 +10230,26 @@ var RemoteBackend = class {
|
|
|
9506
10230
|
return isWatchCapable(this.#resolved ?? this.#options.transport);
|
|
9507
10231
|
}
|
|
9508
10232
|
/** Watchers started by {@link watch}, so {@link dispose} can end every poll loop. */
|
|
10233
|
+
/**
|
|
10234
|
+
* 轮询监听共享的目录快照:`root -> {at, promise}`。
|
|
10235
|
+
*
|
|
10236
|
+
* Tier 1 没有 native watcher,每个订阅各起一条 2.5 s 轮询循环。**同一个目录下的 N 个文件目标会各自
|
|
10237
|
+
* 列举整层**:N=10 就是每 2.5 s 十次 exec 往返(系统 ssh 约 520 ms/次,连接直接被占满)。列一次、N 个
|
|
10238
|
+
* 订阅共用,是这里唯一有效的做法;按 `root` 合并**在飞**请求,所以相位错开也只会共用一次往返。
|
|
10239
|
+
*/
|
|
10240
|
+
#now;
|
|
10241
|
+
/**
|
|
10242
|
+
* 正向 `stat` 缓存:只记「存在」的结果,TTL 5 s,任何写路径都会失效它。
|
|
10243
|
+
*
|
|
10244
|
+
* 为什么只缓存"存在":缓存"不存在"是危险的。`createIfAbsent` 一旦被缓存里的"不存在"骗到,就会把一个
|
|
10245
|
+
* 真实文件当成新文件覆盖掉——那是数据丢失。反过来缓存"存在"最多让一次 `createIfAbsent` 保守地拒绝
|
|
10246
|
+
* (用户重试即可),而版本守卫的 token 由平台持有、不经过这里,因此不会被缓存伪造。
|
|
10247
|
+
*
|
|
10248
|
+
* 收益:平台每打开一个文件都要先 `stat` 再读;Tier 1 每次 `stat` 是一次完整 ssh 往返(≈520 ms),
|
|
10249
|
+
* 这次往返的答案(size/version)读路径刚刚才拿到。
|
|
10250
|
+
*/
|
|
10251
|
+
#statCache = /* @__PURE__ */ new Map();
|
|
10252
|
+
#pollListings = /* @__PURE__ */ new Map();
|
|
9509
10253
|
#watchers = /* @__PURE__ */ new Set();
|
|
9510
10254
|
#options;
|
|
9511
10255
|
#maxReadBytes;
|
|
@@ -9522,9 +10266,11 @@ var RemoteBackend = class {
|
|
|
9522
10266
|
/**
|
|
9523
10267
|
* Read path cache (FR-2.7): fresh bodies, and stale bodies whose version a cheap `stat` just confirmed.
|
|
9524
10268
|
*
|
|
9525
|
-
* Keyed by
|
|
9526
|
-
*
|
|
9527
|
-
*
|
|
10269
|
+
* Keyed by **path only**. The cap used to be part of the key, which made the same file a different entry for
|
|
10270
|
+
* every surface (`ctx.fs` asks for 16 MiB, `remote_read` for 8 MiB) and for every window read in the
|
|
10271
|
+
* `readByteRange` fallback — double memory, half the hit rate, and window entries pushing the useful
|
|
10272
|
+
* full-file entry out. A body now records **how many bytes it covers**; a request is served from it when the
|
|
10273
|
+
* request's cap is at least the body length, and otherwise gets a truncated view of the same body.
|
|
9528
10274
|
*/
|
|
9529
10275
|
#cache;
|
|
9530
10276
|
/**
|
|
@@ -9542,6 +10288,7 @@ var RemoteBackend = class {
|
|
|
9542
10288
|
}
|
|
9543
10289
|
this.id = options.id;
|
|
9544
10290
|
this.#options = options;
|
|
10291
|
+
this.#now = options.now ?? (() => Date.now());
|
|
9545
10292
|
this.#maxReadBytes = options.maxReadBytes ?? 16 * 1024 * 1024;
|
|
9546
10293
|
const policy = { ...DEFAULT_CACHE, ...options.cache };
|
|
9547
10294
|
this.#cache = new VersionedCache({
|
|
@@ -9613,9 +10360,24 @@ var RemoteBackend = class {
|
|
|
9613
10360
|
* FR-2.7 stale-revalidation branch never matched either.
|
|
9614
10361
|
*/
|
|
9615
10362
|
async stat(absolute, signal) {
|
|
10363
|
+
const cachedStat = this.#statCache.get(absolute);
|
|
10364
|
+
if (cachedStat !== void 0 && this.#now() - cachedStat.at < STAT_CACHE_TTL_MS) return cachedStat.info;
|
|
9616
10365
|
const native = await this.#nativeStat(absolute, signal);
|
|
9617
|
-
|
|
9618
|
-
|
|
10366
|
+
const info = native !== void 0 ? statFromNative(native) : await this.#shellStat(absolute, signal);
|
|
10367
|
+
this.#rememberStat(absolute, info);
|
|
10368
|
+
return info;
|
|
10369
|
+
}
|
|
10370
|
+
/** Cache a **positive** stat only (see {@link RemoteBackend.#statCache}); drop the entry otherwise. */
|
|
10371
|
+
#rememberStat(absolute, info) {
|
|
10372
|
+
if (!info.exists) {
|
|
10373
|
+
this.#statCache.delete(absolute);
|
|
10374
|
+
return;
|
|
10375
|
+
}
|
|
10376
|
+
this.#statCache.set(absolute, { at: this.#now(), info });
|
|
10377
|
+
if (this.#statCache.size > STAT_CACHE_MAX_ENTRIES) {
|
|
10378
|
+
const oldest = this.#statCache.keys().next();
|
|
10379
|
+
if (!oldest.done) this.#statCache.delete(oldest.value);
|
|
10380
|
+
}
|
|
9619
10381
|
}
|
|
9620
10382
|
/**
|
|
9621
10383
|
* The transport's own stat, or `undefined` when there is none to use.
|
|
@@ -9692,18 +10454,21 @@ var RemoteBackend = class {
|
|
|
9692
10454
|
*/
|
|
9693
10455
|
async readBytes(absolute, maxBytes, signal) {
|
|
9694
10456
|
const cap = Math.max(0, Math.min(Math.floor(maxBytes), this.#maxReadBytes));
|
|
9695
|
-
const key =
|
|
10457
|
+
const key = absolute;
|
|
9696
10458
|
const hit = this.#cache.get(key);
|
|
9697
10459
|
if (hit !== void 0) {
|
|
9698
|
-
if (hit.fresh) return hit.value;
|
|
10460
|
+
if (hit.fresh) return cappedView(hit.value, cap);
|
|
9699
10461
|
const current = await this.stat(absolute, signal);
|
|
9700
10462
|
if (current.exists && current.version === hit.version) {
|
|
9701
10463
|
this.#cache.touch(key, hit.version);
|
|
9702
|
-
return hit.value;
|
|
10464
|
+
return cappedView(hit.value, cap);
|
|
9703
10465
|
}
|
|
9704
10466
|
this.#cache.invalidate(key);
|
|
9705
10467
|
}
|
|
9706
|
-
const chunk = await (
|
|
10468
|
+
const chunk = await retryOnceOnDisconnect(
|
|
10469
|
+
async () => (await this.#transport()).readFile({ path: absolute, maxBytes: cap }, signal),
|
|
10470
|
+
{ signal }
|
|
10471
|
+
).then((outcome) => outcome.value);
|
|
9707
10472
|
const read = {
|
|
9708
10473
|
bytes: chunk.bytes,
|
|
9709
10474
|
size: chunk.size,
|
|
@@ -9758,7 +10523,10 @@ var RemoteBackend = class {
|
|
|
9758
10523
|
async readText(absolute, maxBytes, signal) {
|
|
9759
10524
|
const chunk = await this.readBytes(absolute, maxBytes, signal);
|
|
9760
10525
|
return {
|
|
9761
|
-
|
|
10526
|
+
// `TextDecoder` 直接解码字节,`Buffer.from(...).toString()` 要先**整份拷贝**再解码——9 MiB 的文件
|
|
10527
|
+
// 就是白白多一次 9 MiB 的 memcpy。`fs-provider` 早就这么做了,这条读路径漏了。
|
|
10528
|
+
// `ignoreBOM: true`:默认行为会把 BOM 吃掉,而 `Buffer#toString` 不会,两者必须一致。
|
|
10529
|
+
text: UTF8.decode(chunk.bytes),
|
|
9762
10530
|
size: chunk.size,
|
|
9763
10531
|
truncated: chunk.truncated,
|
|
9764
10532
|
version: chunk.version
|
|
@@ -9897,24 +10665,28 @@ var RemoteBackend = class {
|
|
|
9897
10665
|
*
|
|
9898
10666
|
* A plain write is one round trip plus one `stat` for the new version.
|
|
9899
10667
|
*/
|
|
9900
|
-
async writeText(absolute,
|
|
9901
|
-
const bytes = new Uint8Array(Buffer.from(
|
|
10668
|
+
async writeText(absolute, text8, expectedVersion, signal) {
|
|
10669
|
+
const bytes = new Uint8Array(Buffer.from(text8, "utf8"));
|
|
9902
10670
|
let digest;
|
|
9903
10671
|
let nativeVersion;
|
|
9904
10672
|
if (expectedVersion !== void 0 && expectedVersion !== "") {
|
|
9905
|
-
const
|
|
9906
|
-
|
|
9907
|
-
|
|
9908
|
-
|
|
9909
|
-
|
|
9910
|
-
|
|
9911
|
-
|
|
9912
|
-
|
|
9913
|
-
|
|
9914
|
-
|
|
10673
|
+
const cached2 = this.#cache.get(absolute);
|
|
10674
|
+
const cachedFull = cached2 !== void 0 && !cached2.value.truncated && cached2.value.version === expectedVersion ? cached2.value : void 0;
|
|
10675
|
+
if (cachedFull === void 0) {
|
|
10676
|
+
const current = await this.stat(absolute, signal);
|
|
10677
|
+
if (!current.exists) {
|
|
10678
|
+
throw new SshDevError("TRANSFER_CONFLICT", "\u8FDC\u7AEF\u6587\u4EF6\u4E0D\u5B58\u5728\uFF0C\u5199\u5165\u88AB\u62D2\u7EDD", { path: absolute, expected: expectedVersion });
|
|
10679
|
+
}
|
|
10680
|
+
if (current.version !== expectedVersion) {
|
|
10681
|
+
throw new SshDevError("TRANSFER_CONFLICT", "\u8FDC\u7AEF\u6587\u4EF6\u5DF2\u88AB\u4FEE\u6539\uFF0C\u5199\u5165\u88AB\u62D2\u7EDD", {
|
|
10682
|
+
path: absolute,
|
|
10683
|
+
expected: expectedVersion,
|
|
10684
|
+
observed: current.version
|
|
10685
|
+
});
|
|
10686
|
+
}
|
|
9915
10687
|
}
|
|
9916
10688
|
if ((await this.#transport()).statPath === void 0) {
|
|
9917
|
-
digest = await this.#digest(absolute, signal);
|
|
10689
|
+
digest = cachedFull === void 0 ? await this.#digest(absolute, signal) : sha256Hex(cachedFull.bytes);
|
|
9918
10690
|
} else {
|
|
9919
10691
|
nativeVersion = expectedVersion;
|
|
9920
10692
|
}
|
|
@@ -9952,19 +10724,21 @@ var RemoteBackend = class {
|
|
|
9952
10724
|
* `changed()` also covers "nothing to re-stat but something happened" — the platform's contract is
|
|
9953
10725
|
* "re-stat this path", so events are coalesced per poll.
|
|
9954
10726
|
*
|
|
10727
|
+
* **Tier 1 没有 native watcher 时走轮询**(`poll-watch.ts`):定时取一次"这一层"的快照并比较签名。
|
|
10728
|
+
* 这是能力缺口上的兜底,不是替代品——native 可用时永远优先 native。轮询的代价被明确限住:只列被 watch
|
|
10729
|
+
* 的那一层(`maxDepth = 1`)、条目数有上限、连续失败到阈值就 `changed(error)` 报死而不是静默重试。
|
|
10730
|
+
*
|
|
9955
10731
|
* @param absolute - root-absolute remote path (directory or file).
|
|
9956
10732
|
* @param changed - platform callback: no argument = re-stat; an error = the subscription is dead.
|
|
9957
10733
|
* @param signal - cancels the subscription; stopping is immediate and idempotent.
|
|
9958
10734
|
* @returns the disposer (stops the watcher; idempotent).
|
|
9959
|
-
* @throws SshDevError `
|
|
9960
|
-
*
|
|
10735
|
+
* @throws SshDevError `REMOTE_NOT_FOUND` when a file target's parent directory does not exist,
|
|
10736
|
+
* `CANCELLED` when aborted during setup.
|
|
9961
10737
|
*/
|
|
9962
10738
|
async watch(absolute, changed, signal) {
|
|
9963
10739
|
const transport = await this.#transport();
|
|
9964
10740
|
if (!isWatchCapable(transport)) {
|
|
9965
|
-
|
|
9966
|
-
path: absolute
|
|
9967
|
-
});
|
|
10741
|
+
return this.#watchByPolling(absolute, changed, signal);
|
|
9968
10742
|
}
|
|
9969
10743
|
const target = normalizeRemotePath(absolute);
|
|
9970
10744
|
if (signal.aborted) throw new SshDevError("CANCELLED", `\u76D1\u542C ${target} \u5728\u5EFA\u7ACB\u524D\u88AB\u53D6\u6D88`, { path: target });
|
|
@@ -10015,6 +10789,96 @@ var RemoteBackend = class {
|
|
|
10015
10789
|
void this.#pollRemoteWatch(transport, watcherId, directory ? void 0 : target, changed, stop, stopped.signal).catch(() => void 0);
|
|
10016
10790
|
return stop;
|
|
10017
10791
|
}
|
|
10792
|
+
/**
|
|
10793
|
+
* Tier 1 的轮询订阅:没有 native watcher 时用"定时快照 + 对比"回答"这个路径变了"。
|
|
10794
|
+
*
|
|
10795
|
+
* 快照**复用目录列举脚本**(`buildListScript`,`-maxdepth 1`),不写第二份:那条脚本已经处理了 GNU
|
|
10796
|
+
* `find -printf` 与 BSD 回退、符号链接分类、名字里的制表符,抄一份只会多一处会分叉的实现。
|
|
10797
|
+
*
|
|
10798
|
+
* 文件目标监听父目录并按名字过滤(与 native 路径同形:inotify 也只能在父目录上报告单个文件的创建/
|
|
10799
|
+
* 写入/删除)。目录目标直接快照自己。
|
|
10800
|
+
*
|
|
10801
|
+
* 建立订阅时先取一次快照:它既是"目标可读"的确认(父目录不存在时明确报 `REMOTE_NOT_FOUND`,而不是
|
|
10802
|
+
* 让轮询循环在后台反复失败),也是比较的基线——所以循环不会立刻多打一次 ssh。
|
|
10803
|
+
*
|
|
10804
|
+
* @param absolute - root-absolute remote path (directory or file).
|
|
10805
|
+
* @param changed - platform callback.
|
|
10806
|
+
* @param signal - cancels the subscription.
|
|
10807
|
+
* @returns the idempotent disposer.
|
|
10808
|
+
*/
|
|
10809
|
+
async #watchByPolling(absolute, changed, signal) {
|
|
10810
|
+
const target = normalizeRemotePath(absolute);
|
|
10811
|
+
if (signal.aborted) throw new SshDevError("CANCELLED", `\u76D1\u542C ${target} \u5728\u5EFA\u7ACB\u524D\u88AB\u53D6\u6D88`, { path: target });
|
|
10812
|
+
const info = await this.stat(target, signal);
|
|
10813
|
+
const directory = info.exists && info.isDirectory;
|
|
10814
|
+
const root = directory ? target : parentRemotePath(target);
|
|
10815
|
+
if (!directory) {
|
|
10816
|
+
const parent = await this.stat(root, signal);
|
|
10817
|
+
if (!parent.exists || !parent.isDirectory) {
|
|
10818
|
+
throw new SshDevError("REMOTE_NOT_FOUND", `\u65E0\u6CD5\u76D1\u542C ${target}\uFF1A\u5B83\u7684\u7236\u76EE\u5F55 ${root} \u4E0D\u5B58\u5728`, { path: target, root });
|
|
10819
|
+
}
|
|
10820
|
+
}
|
|
10821
|
+
const fileName = directory ? void 0 : target.slice(target.lastIndexOf("/") + 1);
|
|
10822
|
+
const initial = await this.#pollSnapshot(root, fileName, signal);
|
|
10823
|
+
const stopPolling = startPollWatch({
|
|
10824
|
+
snapshot: (pollSignal) => this.#pollSnapshot(root, fileName, pollSignal),
|
|
10825
|
+
initial,
|
|
10826
|
+
changed,
|
|
10827
|
+
signal,
|
|
10828
|
+
...this.#options.pollWatch === void 0 ? {} : { options: this.#options.pollWatch }
|
|
10829
|
+
});
|
|
10830
|
+
const stop = async () => {
|
|
10831
|
+
this.#watchers.delete(stop);
|
|
10832
|
+
await stopPolling();
|
|
10833
|
+
};
|
|
10834
|
+
this.#watchers.add(stop);
|
|
10835
|
+
return stop;
|
|
10836
|
+
}
|
|
10837
|
+
/**
|
|
10838
|
+
* 取一次轮询快照。
|
|
10839
|
+
*
|
|
10840
|
+
* 目录目标:整层列举的签名。文件目标:只取**那一个名字**的签名——父目录里别的文件变了不该通知本订阅,
|
|
10841
|
+
* 否则一个目录里 N 个文件订阅会互相放大成 N 倍通知。
|
|
10842
|
+
*
|
|
10843
|
+
* 文件不存在时签名是 `missing`:创建/删除都会改变它,所以"文件被创建"也能通知到。
|
|
10844
|
+
*
|
|
10845
|
+
* @param root - 目录目标本身,或文件目标的父目录。
|
|
10846
|
+
* @param fileName - 文件目标的名字;目录目标是 `undefined`。
|
|
10847
|
+
* @param signal - cancellation.
|
|
10848
|
+
* @returns the comparable snapshot.
|
|
10849
|
+
*/
|
|
10850
|
+
async #pollSnapshot(root, fileName, signal) {
|
|
10851
|
+
const shared = await this.#sharedPollListing(root, signal);
|
|
10852
|
+
if (fileName === void 0) return { signature: shared.signature, entries: shared.entries.length };
|
|
10853
|
+
const match = shared.entries.find((entry) => entry.name === fileName);
|
|
10854
|
+
return { signature: match === void 0 ? "missing" : listingSignature([match]), entries: 0 };
|
|
10855
|
+
}
|
|
10856
|
+
/**
|
|
10857
|
+
* 取一份可共享的目录快照(TTL + 在飞合并)。
|
|
10858
|
+
*
|
|
10859
|
+
* 取消语义:单个订阅被 abort 时**不**取消这次共享列举——它可能正被另一个订阅用着。代价是一次已经在
|
|
10860
|
+
* 路上的 exec 会跑完(它本来就是一个短命令),换来的是 N 个订阅不再互相拖垮。
|
|
10861
|
+
*
|
|
10862
|
+
* @param root - 要列举的目录。
|
|
10863
|
+
* @param signal - 订阅方的取消信号(只用来跳过"已经不需要了"的等待,不影响共享请求本身)。
|
|
10864
|
+
* @returns 词条与它们的签名。
|
|
10865
|
+
*/
|
|
10866
|
+
async #sharedPollListing(root, signal) {
|
|
10867
|
+
if (signal.aborted) throw new SshDevError("CANCELLED", "\u76D1\u542C\u5DF2\u53D6\u6D88", {});
|
|
10868
|
+
const now = this.#now();
|
|
10869
|
+
const cached2 = this.#pollListings.get(root);
|
|
10870
|
+
if (cached2 !== void 0 && now - cached2.at < POLL_LISTING_SHARE_TTL_MS) return cached2.promise;
|
|
10871
|
+
const promise = (async () => {
|
|
10872
|
+
const entries = await this.#listUncached(root);
|
|
10873
|
+
return { entries, signature: listingSignature(entries) };
|
|
10874
|
+
})();
|
|
10875
|
+
const entry = { at: now, promise };
|
|
10876
|
+
this.#pollListings.set(root, entry);
|
|
10877
|
+
promise.catch(() => {
|
|
10878
|
+
if (this.#pollListings.get(root) === entry) this.#pollListings.delete(root);
|
|
10879
|
+
});
|
|
10880
|
+
return promise;
|
|
10881
|
+
}
|
|
10018
10882
|
/**
|
|
10019
10883
|
* Stop every watcher this backend started.
|
|
10020
10884
|
*
|
|
@@ -10022,6 +10886,8 @@ var RemoteBackend = class {
|
|
|
10022
10886
|
* public so whoever retires the backend can end the poll loops, and so tests can prove they stop.
|
|
10023
10887
|
*/
|
|
10024
10888
|
async dispose() {
|
|
10889
|
+
this.#statCache.clear();
|
|
10890
|
+
this.#pollListings.clear();
|
|
10025
10891
|
await Promise.allSettled([...this.#watchers].map((stop) => stop()));
|
|
10026
10892
|
this.#cache.clear();
|
|
10027
10893
|
this.#listings.clear();
|
|
@@ -10085,7 +10951,8 @@ var RemoteBackend = class {
|
|
|
10085
10951
|
* listing does not mention would otherwise hide it for up to a TTL.
|
|
10086
10952
|
*/
|
|
10087
10953
|
#invalidatePath(absolute) {
|
|
10088
|
-
this.#cache.
|
|
10954
|
+
this.#cache.invalidate(absolute);
|
|
10955
|
+
this.#statCache.delete(absolute);
|
|
10089
10956
|
const slash = absolute.lastIndexOf("/");
|
|
10090
10957
|
if (slash > 0) this.#listings.invalidate(absolute.slice(0, slash));
|
|
10091
10958
|
}
|
|
@@ -10173,6 +11040,33 @@ var RemoteBackend = class {
|
|
|
10173
11040
|
}
|
|
10174
11041
|
this.#invalidatePath(absolute);
|
|
10175
11042
|
}
|
|
11043
|
+
/**
|
|
11044
|
+
* Create a symbolic link; refuse an existing path.
|
|
11045
|
+
*
|
|
11046
|
+
* Runs through `exec` for the same reason {@link chmod} does: the agent has no `fs.symlink`, and one script
|
|
11047
|
+
* keeps the two tiers from disagreeing about what "create a link" means on this host.
|
|
11048
|
+
*/
|
|
11049
|
+
async symlink(target, linkPath, signal) {
|
|
11050
|
+
const result = await (await this.#transport()).exec({ script: symlinkCommand(target, linkPath), maxBytes: 64 * 1024 }, signal);
|
|
11051
|
+
const refusal = commandRefusal(result.stdout, result.stderr);
|
|
11052
|
+
if (refusal === "exists") {
|
|
11053
|
+
throw new SshDevError("ROOT_INVALID", `\u62D2\u7EDD\u8986\u76D6\u5DF2\u5B58\u5728\u7684\u8DEF\u5F84\uFF1A${linkPath}`, { path: linkPath, target });
|
|
11054
|
+
}
|
|
11055
|
+
if (refusal !== void 0) {
|
|
11056
|
+
throw new SshDevError("REMOTE_COMMAND_FAILED", `\u521B\u5EFA\u7B26\u53F7\u94FE\u63A5\u5931\u8D25\uFF08${refusal}\uFF09\uFF1A${linkPath}`, { path: linkPath, target, reason: refusal });
|
|
11057
|
+
}
|
|
11058
|
+
this.#invalidatePath(linkPath);
|
|
11059
|
+
}
|
|
11060
|
+
/** Read a link target; `undefined` means "this is not a link", which is an answer, not an error. */
|
|
11061
|
+
async readlink(absolute, signal) {
|
|
11062
|
+
const result = await (await this.#transport()).exec({ script: readlinkCommand(absolute), maxBytes: 64 * 1024 }, signal);
|
|
11063
|
+
const text8 = result.stdout.trim();
|
|
11064
|
+
if (text8 === "DSH_NOTLINK") return void 0;
|
|
11065
|
+
if (result.exitCode !== 0) {
|
|
11066
|
+
throw new SshDevError("REMOTE_COMMAND_FAILED", `\u8BFB\u53D6\u7B26\u53F7\u94FE\u63A5\u5931\u8D25\uFF1A${absolute}`, { path: absolute, stderr: result.stderr.slice(0, 200) });
|
|
11067
|
+
}
|
|
11068
|
+
return text8;
|
|
11069
|
+
}
|
|
10176
11070
|
/** Whether this backend's root contains a path (used by the router's `contains`). */
|
|
10177
11071
|
contains(parent, candidate) {
|
|
10178
11072
|
return remotePathContains(parent, candidate);
|
|
@@ -10188,6 +11082,13 @@ var RemoteBackend = class {
|
|
|
10188
11082
|
return result.stdout.trim();
|
|
10189
11083
|
}
|
|
10190
11084
|
};
|
|
11085
|
+
function sha256Hex(bytes) {
|
|
11086
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
11087
|
+
}
|
|
11088
|
+
function cappedView(read, cap) {
|
|
11089
|
+
if (read.bytes.byteLength <= cap) return read;
|
|
11090
|
+
return { ...read, bytes: read.bytes.subarray(0, cap), truncated: true };
|
|
11091
|
+
}
|
|
10191
11092
|
function statFromNative(native) {
|
|
10192
11093
|
return {
|
|
10193
11094
|
exists: native.exists,
|
|
@@ -10211,40 +11112,9 @@ function parseHeaders(stdout) {
|
|
|
10211
11112
|
|
|
10212
11113
|
// src/host/infra/transport/ssh/alias-transport.ts
|
|
10213
11114
|
init_capability();
|
|
10214
|
-
init_errors();
|
|
10215
11115
|
import { join as join6 } from "node:path";
|
|
11116
|
+
init_errors();
|
|
10216
11117
|
init_shell_quote();
|
|
10217
|
-
|
|
10218
|
-
// src/host/infra/transport/ssh/ssh-argv.ts
|
|
10219
|
-
function credentialArgv(options) {
|
|
10220
|
-
const argv = [];
|
|
10221
|
-
if (options.identityFile !== void 0 && options.identityFile !== "") argv.push("-i", options.identityFile);
|
|
10222
|
-
if (options.knownHostsFile !== void 0 && options.knownHostsFile !== "") {
|
|
10223
|
-
argv.push("-o", `UserKnownHostsFile=${options.knownHostsFile}`);
|
|
10224
|
-
}
|
|
10225
|
-
if (options.user !== void 0 && options.user !== "") argv.push("-l", options.user);
|
|
10226
|
-
return argv;
|
|
10227
|
-
}
|
|
10228
|
-
function commonSshOptions(options) {
|
|
10229
|
-
const argv = [
|
|
10230
|
-
"-o",
|
|
10231
|
-
`BatchMode=${options.batchMode === false ? "no" : "yes"}`
|
|
10232
|
-
];
|
|
10233
|
-
if (options.connectTimeoutMs !== void 0) {
|
|
10234
|
-
argv.push("-o", `ConnectTimeout=${Math.max(1, Math.ceil(options.connectTimeoutMs / 1e3))}`);
|
|
10235
|
-
}
|
|
10236
|
-
if (options.hostKeyPolicy !== void 0) {
|
|
10237
|
-
argv.push("-o", `StrictHostKeyChecking=${options.hostKeyPolicy === "strict" ? "yes" : "accept-new"}`);
|
|
10238
|
-
}
|
|
10239
|
-
for (const option of options.extraOptions ?? []) argv.push("-o", option);
|
|
10240
|
-
argv.push(...credentialArgv(options));
|
|
10241
|
-
return argv;
|
|
10242
|
-
}
|
|
10243
|
-
function sshPrefix(sshBinary, options) {
|
|
10244
|
-
return [sshBinary, "-T", ...commonSshOptions(options), options.alias];
|
|
10245
|
-
}
|
|
10246
|
-
|
|
10247
|
-
// src/host/infra/transport/ssh/alias-transport.ts
|
|
10248
11118
|
init_transport();
|
|
10249
11119
|
function createMultiplexingCache() {
|
|
10250
11120
|
const reasons = /* @__PURE__ */ new Map();
|
|
@@ -10264,6 +11134,13 @@ var AliasTransport = class {
|
|
|
10264
11134
|
id;
|
|
10265
11135
|
#options;
|
|
10266
11136
|
#now;
|
|
11137
|
+
/**
|
|
11138
|
+
* 懒建的转发器:没有转发需求时**不**创建,避免每条连接多一个长驻 spawner 实例。
|
|
11139
|
+
*
|
|
11140
|
+
* 它用系统 ssh 起 `ssh -N -L`,与命令/文件走同一套凭证参数(`-l`/`-i`/known_hosts/askpass env)——
|
|
11141
|
+
* 两套参数漂移是"文件能读、转发连不上"这类问题的来源。
|
|
11142
|
+
*/
|
|
11143
|
+
#forwarder;
|
|
10267
11144
|
#state = "idle";
|
|
10268
11145
|
#capabilities = conservativeCapabilities();
|
|
10269
11146
|
#connecting;
|
|
@@ -10415,7 +11292,43 @@ var AliasTransport = class {
|
|
|
10415
11292
|
this.#connectingAbort?.abort();
|
|
10416
11293
|
}
|
|
10417
11294
|
/** Close the master connection when one exists. Idempotent. */
|
|
11295
|
+
/** {@inheritDoc Transport.forward} */
|
|
11296
|
+
async forward(request, signal) {
|
|
11297
|
+
const forwarder = this.#forwarderOf();
|
|
11298
|
+
return forwarder.forward(request, signal);
|
|
11299
|
+
}
|
|
11300
|
+
/** {@inheritDoc Transport.closeForwards} */
|
|
11301
|
+
async closeForwards() {
|
|
11302
|
+
const forwarder = this.#forwarder;
|
|
11303
|
+
if (forwarder === void 0) return;
|
|
11304
|
+
await forwarder.closeAll();
|
|
11305
|
+
}
|
|
11306
|
+
/** 造(或复用)转发器:参数与命令路径同源。 */
|
|
11307
|
+
#forwarderOf() {
|
|
11308
|
+
if (this.#forwarder !== void 0) return this.#forwarder;
|
|
11309
|
+
const options = this.#options;
|
|
11310
|
+
this.#forwarder = createSshForwarder({
|
|
11311
|
+
connectionId: options.connectionId,
|
|
11312
|
+
buildArgv: (request) => buildForwardChannelArgv({
|
|
11313
|
+
alias: options.alias,
|
|
11314
|
+
remoteHost: request.remoteHost,
|
|
11315
|
+
remotePort: request.remotePort,
|
|
11316
|
+
...options.sshBinary === void 0 ? {} : { sshBinary: options.sshBinary },
|
|
11317
|
+
...options.user === void 0 ? {} : { user: options.user },
|
|
11318
|
+
...options.identityFile === void 0 ? {} : { identityFile: options.identityFile },
|
|
11319
|
+
...options.knownHostsFile === void 0 ? {} : { knownHostsFile: options.knownHostsFile },
|
|
11320
|
+
...options.hostKeyPolicy === void 0 ? {} : { hostKeyPolicy: options.hostKeyPolicy },
|
|
11321
|
+
...options.connectTimeoutMs === void 0 ? {} : { connectTimeoutMs: options.connectTimeoutMs },
|
|
11322
|
+
...options.batchMode === void 0 ? {} : { batchMode: options.batchMode }
|
|
11323
|
+
}),
|
|
11324
|
+
spawn: createNodeLongSpawner(),
|
|
11325
|
+
...options.env === void 0 ? {} : { env: options.env },
|
|
11326
|
+
...options.forwardIdleTimeoutMs === void 0 ? {} : { idleTimeoutMs: options.forwardIdleTimeoutMs }
|
|
11327
|
+
});
|
|
11328
|
+
return this.#forwarder;
|
|
11329
|
+
}
|
|
10418
11330
|
async dispose() {
|
|
11331
|
+
await this.closeForwards().catch(() => void 0);
|
|
10419
11332
|
if (this.#state === "idle") return;
|
|
10420
11333
|
this.#connectingAbort?.abort();
|
|
10421
11334
|
const hadMaster = this.#multiplexing === "master";
|
|
@@ -10429,6 +11342,12 @@ var AliasTransport = class {
|
|
|
10429
11342
|
}
|
|
10430
11343
|
/** Run one remote command. */
|
|
10431
11344
|
async exec(request, signal) {
|
|
11345
|
+
if (request.writeRoots !== void 0 && request.writeRoots.length > 0) {
|
|
11346
|
+
throw new SshDevError("REMOTE_UNSUPPORTED", "\u8BE5\u4F20\u8F93\uFF08Tier 1\uFF0C\u65E0\u4EE3\u7406\uFF09\u65E0\u6CD5\u5EFA\u7ACB Landlock \u5199\u5165\u56F4\u680F", {
|
|
11347
|
+
connectionId: this.id,
|
|
11348
|
+
writeRoots: [...request.writeRoots]
|
|
11349
|
+
});
|
|
11350
|
+
}
|
|
10432
11351
|
await this.connect(signal);
|
|
10433
11352
|
const command = this.#commandFor(request);
|
|
10434
11353
|
const startedAt = this.#now();
|
|
@@ -10724,14 +11643,14 @@ function parseWriteHeaders(stdout) {
|
|
|
10724
11643
|
}
|
|
10725
11644
|
return parsed;
|
|
10726
11645
|
}
|
|
10727
|
-
function sliceAfterLines(
|
|
11646
|
+
function sliceAfterLines(text8, lineCount) {
|
|
10728
11647
|
let offset = 0;
|
|
10729
11648
|
for (let line = 0; line < lineCount; line += 1) {
|
|
10730
|
-
const next =
|
|
11649
|
+
const next = text8.indexOf("\n", offset);
|
|
10731
11650
|
if (next < 0) return "";
|
|
10732
11651
|
offset = next + 1;
|
|
10733
11652
|
}
|
|
10734
|
-
return
|
|
11653
|
+
return text8.slice(offset);
|
|
10735
11654
|
}
|
|
10736
11655
|
function parseReadHeaders(stdout) {
|
|
10737
11656
|
const lines = stdout.split("\n");
|
|
@@ -10888,7 +11807,7 @@ function createAskpassFs() {
|
|
|
10888
11807
|
}
|
|
10889
11808
|
|
|
10890
11809
|
// src/host/infra/transport/node-spawn.ts
|
|
10891
|
-
import { spawn as
|
|
11810
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
10892
11811
|
var DEFAULT_MAX_BYTES2 = 4 * 1024 * 1024;
|
|
10893
11812
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
10894
11813
|
function createNodeSpawner(options = {}) {
|
|
@@ -10902,7 +11821,7 @@ function createNodeSpawner(options = {}) {
|
|
|
10902
11821
|
}
|
|
10903
11822
|
const maxBytes = spawnOptions.maxBytes ?? defaultMaxBytes;
|
|
10904
11823
|
const timeoutMs = spawnOptions.timeoutMs ?? defaultTimeoutMs;
|
|
10905
|
-
const child =
|
|
11824
|
+
const child = spawn3(command, args, {
|
|
10906
11825
|
windowsHide: true,
|
|
10907
11826
|
stdio: ["pipe", "pipe", "pipe"],
|
|
10908
11827
|
// Only override the environment when a caller actually supplied entries: passing a fresh object
|
|
@@ -11007,8 +11926,8 @@ function createAgentInstallIo(options) {
|
|
|
11007
11926
|
return {
|
|
11008
11927
|
exec,
|
|
11009
11928
|
async digestTool() {
|
|
11010
|
-
const
|
|
11011
|
-
const answer =
|
|
11929
|
+
const probe2 = await exec("command -v sha256sum >/dev/null 2>&1 && echo sha256sum || (command -v shasum >/dev/null 2>&1 && echo shasum) || echo none");
|
|
11930
|
+
const answer = probe2.stdout.trim();
|
|
11012
11931
|
if (answer === "sha256sum" || answer === "shasum") return answer;
|
|
11013
11932
|
return void 0;
|
|
11014
11933
|
},
|
|
@@ -11303,10 +12222,21 @@ var RemoteIndex = class {
|
|
|
11303
12222
|
cached: index.cached
|
|
11304
12223
|
};
|
|
11305
12224
|
}
|
|
11306
|
-
/**
|
|
12225
|
+
/**
|
|
12226
|
+
* Drop one root (its files changed) or everything.
|
|
12227
|
+
*
|
|
12228
|
+
* **Byte accounting must follow.** Dropping entries without subtracting their bytes made `#cacheBytes` grow
|
|
12229
|
+
* monotonically: after a few invalidate+remember cycles the cap saw a phantom overrun and evicted every live
|
|
12230
|
+
* entry except the newest one, which turned each `@` completion back into a full scan — the exact cost this
|
|
12231
|
+
* cache exists to remove.
|
|
12232
|
+
*/
|
|
11307
12233
|
invalidate(root) {
|
|
11308
|
-
if (root === void 0)
|
|
11309
|
-
|
|
12234
|
+
if (root === void 0) {
|
|
12235
|
+
this.#cache.clear();
|
|
12236
|
+
this.#cacheBytes = 0;
|
|
12237
|
+
return;
|
|
12238
|
+
}
|
|
12239
|
+
this.#forget(root);
|
|
11310
12240
|
}
|
|
11311
12241
|
/** Whether a root is currently cached and fresh (used by tests and by the status UI). */
|
|
11312
12242
|
isFresh(root) {
|
|
@@ -11474,14 +12404,26 @@ function directoryProbeCommand(root) {
|
|
|
11474
12404
|
function rankMatches(paths, query, limit) {
|
|
11475
12405
|
const needle = query.trim().toLowerCase();
|
|
11476
12406
|
if (needle === "") {
|
|
11477
|
-
const
|
|
11478
|
-
|
|
11479
|
-
|
|
11480
|
-
|
|
11481
|
-
|
|
11482
|
-
|
|
11483
|
-
|
|
11484
|
-
|
|
12407
|
+
const top = [];
|
|
12408
|
+
const better = (left, right) => {
|
|
12409
|
+
const leftDepth = depth2(paths[left]);
|
|
12410
|
+
const rightDepth = depth2(paths[right]);
|
|
12411
|
+
if (leftDepth !== rightDepth) return leftDepth < rightDepth;
|
|
12412
|
+
return paths[left] < paths[right];
|
|
12413
|
+
};
|
|
12414
|
+
for (let index = 0; index < paths.length; index += 1) {
|
|
12415
|
+
if (top.length >= limit && !better(index, top[top.length - 1])) continue;
|
|
12416
|
+
let low = 0;
|
|
12417
|
+
let high = top.length;
|
|
12418
|
+
while (low < high) {
|
|
12419
|
+
const middle = low + high >> 1;
|
|
12420
|
+
if (better(index, top[middle])) high = middle;
|
|
12421
|
+
else low = middle + 1;
|
|
12422
|
+
}
|
|
12423
|
+
top.splice(low, 0, index);
|
|
12424
|
+
if (top.length > limit) top.pop();
|
|
12425
|
+
}
|
|
12426
|
+
return top.map((index) => paths[index]);
|
|
11485
12427
|
}
|
|
11486
12428
|
const scored = [];
|
|
11487
12429
|
for (const path of paths) {
|
|
@@ -11931,6 +12873,11 @@ function defaultAgentManifestPath(moduleUrl) {
|
|
|
11931
12873
|
|
|
11932
12874
|
// src/wiring/connections.ts
|
|
11933
12875
|
var degradedRetryMs = 3e4;
|
|
12876
|
+
function pruneBackendConnections(owners, liveRootKeys) {
|
|
12877
|
+
for (const backendId of [...owners.keys()]) {
|
|
12878
|
+
if (!liveRootKeys.has(backendId)) owners.delete(backendId);
|
|
12879
|
+
}
|
|
12880
|
+
}
|
|
11934
12881
|
function wireConnections(ctx, config) {
|
|
11935
12882
|
const limits = config.limits ?? {};
|
|
11936
12883
|
const controlDir = limits.controlDir ?? join11(resolveStateDir(), "control");
|
|
@@ -11938,7 +12885,7 @@ function wireConnections(ctx, config) {
|
|
|
11938
12885
|
for (const warning of takeStateDirWarnings()) {
|
|
11939
12886
|
ctx.logger?.warn?.(`over-ssh: \u72B6\u6001\u76EE\u5F55\u8FC1\u79FB\uFF1A${warning}`);
|
|
11940
12887
|
}
|
|
11941
|
-
const
|
|
12888
|
+
const spawn4 = createNodeSpawner({ defaultTimeoutMs: limits.commandTimeoutMs ?? 3e4 });
|
|
11942
12889
|
const multiplexingCache = createMultiplexingCache();
|
|
11943
12890
|
const ssh2Available = isSsh2Available();
|
|
11944
12891
|
const profiles = /* @__PURE__ */ new Map();
|
|
@@ -11974,7 +12921,9 @@ function wireConnections(ctx, config) {
|
|
|
11974
12921
|
connectionId: profile.id,
|
|
11975
12922
|
alias: profile.alias ?? profile.host,
|
|
11976
12923
|
controlDir,
|
|
11977
|
-
spawn:
|
|
12924
|
+
spawn: spawn4,
|
|
12925
|
+
// 没人用的转发要能自己消失(否则本地端口一直占着);0 = 关闭回收。
|
|
12926
|
+
...limits.forwardIdleTimeoutMs === void 0 ? {} : { forwardIdleTimeoutMs: limits.forwardIdleTimeoutMs },
|
|
11978
12927
|
multiplexingCache,
|
|
11979
12928
|
hostKeyPolicy: profile.hostKeyPolicy,
|
|
11980
12929
|
// A connection that names a key must use it: the alias transport otherwise relies entirely on the
|
|
@@ -12069,6 +13018,7 @@ function wireConnections(ctx, config) {
|
|
|
12069
13018
|
// Same credential source as the Tier 1 transport: an agent session that authenticates differently
|
|
12070
13019
|
// from the ladder that installed it is exactly the drift the agent-path e2e caught. For a password
|
|
12071
13020
|
// connection that means the same askpass helper and the same BatchMode.
|
|
13021
|
+
...limits.forwardIdleTimeoutMs === void 0 ? {} : { forwardIdleTimeoutMs: limits.forwardIdleTimeoutMs },
|
|
12072
13022
|
sshArgv: sshPrefix("ssh", {
|
|
12073
13023
|
alias: profile.alias ?? profile.host,
|
|
12074
13024
|
user: profile.user,
|
|
@@ -12121,6 +13071,7 @@ function wireConnections(ctx, config) {
|
|
|
12121
13071
|
// Every mutation republishes the terminal manifest, so a terminal opened right after adding a root
|
|
12122
13072
|
// already knows where it may land.
|
|
12123
13073
|
onChanged: (records) => {
|
|
13074
|
+
pruneBackendConnections(backendConnections, new Set(records.map((record2) => rootKey(record2.root))));
|
|
12124
13075
|
publishChain = publishChain.then(() => publishTerminalManifest(records)).then(() => publishTerminalSessions()).catch(() => void 0);
|
|
12125
13076
|
},
|
|
12126
13077
|
createLocalBackend: (root) => {
|
|
@@ -12299,7 +13250,7 @@ function wireConnections(ctx, config) {
|
|
|
12299
13250
|
}));
|
|
12300
13251
|
}
|
|
12301
13252
|
};
|
|
12302
|
-
return { ctx, config, limits, anchorRootDir, profiles, selector, askpass, installations, agentBridge, transports, remoteHome, forgetBackendsOf, browseBackends, readBytesRange, registry, directory, indexes, indexFor, index };
|
|
13253
|
+
return { ctx, config, limits, anchorRootDir, profiles, selector, askpass, installations, agentBridge, transports, remoteHome, forgetBackendsOf, browseBackends, readBytesRange, registry, directory, indexes, indexFor, index, cacheBudget };
|
|
12303
13254
|
}
|
|
12304
13255
|
function readLiveSessions(ctx) {
|
|
12305
13256
|
let list;
|
|
@@ -12321,7 +13272,7 @@ function readLiveSessions(ctx) {
|
|
|
12321
13272
|
}
|
|
12322
13273
|
|
|
12323
13274
|
// src/wiring/fs.ts
|
|
12324
|
-
import { mkdir as
|
|
13275
|
+
import { mkdir as mkdir7 } from "node:fs/promises";
|
|
12325
13276
|
|
|
12326
13277
|
// src/host/integration/remote/remote-face.ts
|
|
12327
13278
|
import { Service } from "@deepseek-ai/cordis";
|
|
@@ -12594,7 +13545,7 @@ function registerSessionTools(ctx, service) {
|
|
|
12594
13545
|
init_errors();
|
|
12595
13546
|
|
|
12596
13547
|
// src/host/app/roots/user-roots.ts
|
|
12597
|
-
import { mkdir as mkdir4, readFile as readFile5, rename as rename3, writeFile as writeFile4 } from "node:fs/promises";
|
|
13548
|
+
import { mkdir as mkdir4, readFile as readFile5, rename as rename3, unlink as unlink3, writeFile as writeFile4 } from "node:fs/promises";
|
|
12598
13549
|
import { join as join12 } from "node:path";
|
|
12599
13550
|
init_errors();
|
|
12600
13551
|
var USER_ROOTS_VERSION = 1;
|
|
@@ -12619,7 +13570,14 @@ var nodeUserRootsIo = {
|
|
|
12619
13570
|
mkdir: async (path) => {
|
|
12620
13571
|
await mkdir4(path, { recursive: true, mode: 448 });
|
|
12621
13572
|
},
|
|
12622
|
-
rename: (from, to) => rename3(from, to)
|
|
13573
|
+
rename: (from, to) => rename3(from, to),
|
|
13574
|
+
unlink: async (path) => {
|
|
13575
|
+
try {
|
|
13576
|
+
await unlink3(path);
|
|
13577
|
+
} catch (error) {
|
|
13578
|
+
if (error.code !== "ENOENT") throw error;
|
|
13579
|
+
}
|
|
13580
|
+
}
|
|
12623
13581
|
};
|
|
12624
13582
|
function emptyUserRootsState() {
|
|
12625
13583
|
return { version: USER_ROOTS_VERSION, roots: [], connections: [] };
|
|
@@ -12634,13 +13592,13 @@ function serializeUserRoots(state) {
|
|
|
12634
13592
|
return `${JSON.stringify(state, null, 2)}
|
|
12635
13593
|
`;
|
|
12636
13594
|
}
|
|
12637
|
-
function parseUserRoots(
|
|
12638
|
-
if (
|
|
13595
|
+
function parseUserRoots(text8) {
|
|
13596
|
+
if (text8 === void 0 || text8.trim() === "") {
|
|
12639
13597
|
return { state: emptyUserRootsState(), warnings: [], degraded: false };
|
|
12640
13598
|
}
|
|
12641
13599
|
let decoded;
|
|
12642
13600
|
try {
|
|
12643
|
-
decoded = JSON.parse(stripBom(
|
|
13601
|
+
decoded = JSON.parse(stripBom(text8));
|
|
12644
13602
|
} catch (error) {
|
|
12645
13603
|
return {
|
|
12646
13604
|
state: emptyUserRootsState(),
|
|
@@ -12969,13 +13927,16 @@ function updateConnectionInState(state, id, patch) {
|
|
|
12969
13927
|
);
|
|
12970
13928
|
return { version: state.version, roots, connections };
|
|
12971
13929
|
}
|
|
13930
|
+
var USER_ROOTS_LOCK_FILE_NAME = `${USER_ROOTS_FILE_NAME}.lock`;
|
|
12972
13931
|
function createUserRootsStore(options) {
|
|
12973
13932
|
const io = options.io ?? nodeUserRootsIo;
|
|
12974
13933
|
const filePath = join12(options.dir, USER_ROOTS_FILE_NAME);
|
|
13934
|
+
let cachedRaw;
|
|
13935
|
+
let cachedLoad;
|
|
12975
13936
|
const load = async () => {
|
|
12976
|
-
let
|
|
13937
|
+
let text8;
|
|
12977
13938
|
try {
|
|
12978
|
-
|
|
13939
|
+
text8 = await io.readFile(filePath);
|
|
12979
13940
|
} catch (error) {
|
|
12980
13941
|
if (isMissingFile(error)) return { state: emptyUserRootsState(), warnings: [], degraded: false };
|
|
12981
13942
|
return {
|
|
@@ -12984,14 +13945,26 @@ function createUserRootsStore(options) {
|
|
|
12984
13945
|
degraded: true
|
|
12985
13946
|
};
|
|
12986
13947
|
}
|
|
12987
|
-
|
|
13948
|
+
if (cachedRaw !== void 0 && text8 === cachedRaw && cachedLoad !== void 0) return cachedLoad;
|
|
13949
|
+
const parsed = parseUserRoots(text8);
|
|
13950
|
+
cachedRaw = text8;
|
|
13951
|
+
cachedLoad = parsed;
|
|
13952
|
+
return parsed;
|
|
12988
13953
|
};
|
|
12989
13954
|
const write = async (next) => {
|
|
12990
13955
|
const validated = validateState(next);
|
|
12991
13956
|
const temporary = `${filePath}.${process.pid}-${++tempSequence2}.tmp`;
|
|
13957
|
+
const text8 = serializeUserRoots(validated);
|
|
12992
13958
|
await io.mkdir(options.dir);
|
|
12993
|
-
|
|
12994
|
-
|
|
13959
|
+
try {
|
|
13960
|
+
await io.writeFile(temporary, text8);
|
|
13961
|
+
await io.rename(temporary, filePath);
|
|
13962
|
+
} catch (error) {
|
|
13963
|
+
await io.unlink(temporary).catch(() => void 0);
|
|
13964
|
+
throw error;
|
|
13965
|
+
}
|
|
13966
|
+
cachedRaw = text8;
|
|
13967
|
+
cachedLoad = { state: validated, warnings: [], degraded: false };
|
|
12995
13968
|
return validated;
|
|
12996
13969
|
};
|
|
12997
13970
|
const queue = createSerialQueue();
|
|
@@ -13001,17 +13974,17 @@ function createUserRootsStore(options) {
|
|
|
13001
13974
|
assertUserRootsWritable(await load());
|
|
13002
13975
|
return write(next);
|
|
13003
13976
|
});
|
|
13004
|
-
const mutate = (transition) =>
|
|
13005
|
-
|
|
13006
|
-
|
|
13007
|
-
serialize(async () => {
|
|
13977
|
+
const mutate = (transition) => {
|
|
13978
|
+
const locked = options.withLock;
|
|
13979
|
+
const run = async () => serialize(async () => {
|
|
13008
13980
|
const loaded = await load();
|
|
13009
13981
|
assertUserRootsWritable(loaded);
|
|
13010
13982
|
const wanted = transition(loaded.state);
|
|
13011
13983
|
if (serializeUserRoots(wanted) === serializeUserRoots(loaded.state)) return loaded.state;
|
|
13012
13984
|
return write(wanted);
|
|
13013
|
-
})
|
|
13014
|
-
|
|
13985
|
+
});
|
|
13986
|
+
return locked === void 0 ? run() : locked(run);
|
|
13987
|
+
};
|
|
13015
13988
|
return {
|
|
13016
13989
|
filePath,
|
|
13017
13990
|
load,
|
|
@@ -13069,21 +14042,34 @@ function dropDuplicates(state, warnings) {
|
|
|
13069
14042
|
}
|
|
13070
14043
|
return { version: USER_ROOTS_VERSION, roots, connections };
|
|
13071
14044
|
}
|
|
14045
|
+
function isWindowsShapedLocalKey(key) {
|
|
14046
|
+
if (!key.startsWith("local:")) return false;
|
|
14047
|
+
const path = key.slice("local:".length);
|
|
14048
|
+
return /^[A-Za-z]:/.test(path) || path.startsWith("\\\\") || path.startsWith("//");
|
|
14049
|
+
}
|
|
13072
14050
|
function sameRootKey(left, right) {
|
|
13073
14051
|
if (left === right) return true;
|
|
13074
|
-
|
|
13075
|
-
return
|
|
14052
|
+
if (!isWindowsShapedLocalKey(left) || !isWindowsShapedLocalKey(right)) return false;
|
|
14053
|
+
return left.toLowerCase() === right.toLowerCase();
|
|
13076
14054
|
}
|
|
13077
14055
|
function sameUserRootKey(left, right) {
|
|
13078
14056
|
return sameRootKey(left, right);
|
|
13079
14057
|
}
|
|
13080
14058
|
function normalizeLocalSpelling(path) {
|
|
14059
|
+
if (/[\u0000-\u001f]/.test(path)) {
|
|
14060
|
+
throw new SshDevError("ROOT_INVALID", "\u6839\u7684 path \u4E0D\u80FD\u5305\u542B\u63A7\u5236\u5B57\u7B26", { path: JSON.stringify(path) });
|
|
14061
|
+
}
|
|
13081
14062
|
const unified = path.trim().replaceAll("\\", "/");
|
|
13082
14063
|
const collapsed = unified.replace(/\/{2,}/g, "/");
|
|
13083
|
-
|
|
13084
|
-
|
|
14064
|
+
const rootish = collapsed === "/" || /^[A-Za-z]:\/?$/.test(collapsed);
|
|
14065
|
+
const resolved = rootish ? collapsed : normalizeLocalPath2(collapsed, { caseInsensitive: false });
|
|
14066
|
+
if (!isAbsoluteLocalPath(resolved)) {
|
|
14067
|
+
throw new SshDevError("ROOT_INVALID", `\u6839\u7684 path \u5FC5\u987B\u662F\u7EDD\u5BF9\u8DEF\u5F84\uFF1A${path}`, { path });
|
|
13085
14068
|
}
|
|
13086
|
-
|
|
14069
|
+
if (resolved === "/" || /^[A-Za-z]:\/?$/.test(resolved)) {
|
|
14070
|
+
return resolved.endsWith("/") ? resolved : `${resolved}/`;
|
|
14071
|
+
}
|
|
14072
|
+
return resolved.replace(/\/+$/, "");
|
|
13087
14073
|
}
|
|
13088
14074
|
function refuseCredentialFields(source) {
|
|
13089
14075
|
for (const field of ["password", "passphrase"]) {
|
|
@@ -13140,8 +14126,8 @@ function record(value) {
|
|
|
13140
14126
|
function arrayOf(value) {
|
|
13141
14127
|
return Array.isArray(value) ? value : [];
|
|
13142
14128
|
}
|
|
13143
|
-
function stripBom(
|
|
13144
|
-
return
|
|
14129
|
+
function stripBom(text8) {
|
|
14130
|
+
return text8.charCodeAt(0) === 65279 ? text8.slice(1) : text8;
|
|
13145
14131
|
}
|
|
13146
14132
|
function messageOf(error) {
|
|
13147
14133
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -13165,11 +14151,11 @@ async function readBody(req, maxBytes) {
|
|
|
13165
14151
|
return Buffer.concat(chunks).toString("utf8");
|
|
13166
14152
|
}
|
|
13167
14153
|
async function readJsonObject(req, maxBytes) {
|
|
13168
|
-
const
|
|
13169
|
-
if (
|
|
14154
|
+
const text8 = (await readBody(req, maxBytes)).trim();
|
|
14155
|
+
if (text8 === "") return {};
|
|
13170
14156
|
let parsed;
|
|
13171
14157
|
try {
|
|
13172
|
-
parsed = JSON.parse(
|
|
14158
|
+
parsed = JSON.parse(text8);
|
|
13173
14159
|
} catch {
|
|
13174
14160
|
throw new SshDevError("ROOT_INVALID", "\u8BF7\u6C42\u4F53\u4E0D\u662F\u5408\u6CD5 JSON");
|
|
13175
14161
|
}
|
|
@@ -13482,32 +14468,45 @@ async function reconcile(deps, transition) {
|
|
|
13482
14468
|
}
|
|
13483
14469
|
|
|
13484
14470
|
// src/host/app/roots/roots-api-roots.ts
|
|
14471
|
+
var addRootQueue = createSerialQueue();
|
|
13485
14472
|
async function handleRootActions(action, body, req, res, deps) {
|
|
13486
14473
|
switch (action) {
|
|
13487
14474
|
case "add-root": {
|
|
13488
|
-
|
|
13489
|
-
|
|
13490
|
-
|
|
13491
|
-
|
|
13492
|
-
|
|
13493
|
-
|
|
13494
|
-
|
|
13495
|
-
|
|
13496
|
-
|
|
13497
|
-
|
|
13498
|
-
|
|
13499
|
-
|
|
13500
|
-
|
|
13501
|
-
|
|
13502
|
-
|
|
13503
|
-
|
|
13504
|
-
|
|
13505
|
-
|
|
13506
|
-
|
|
13507
|
-
|
|
13508
|
-
|
|
14475
|
+
return addRootQueue.run(async () => {
|
|
14476
|
+
const entry = normalizeRootEntry(pick(body, ROOT_FIELDS));
|
|
14477
|
+
const reuseExisting = body["reuseExisting"] === true;
|
|
14478
|
+
const key = userRootKey(entry);
|
|
14479
|
+
const existing = findRootInState(await currentUserState(deps), key);
|
|
14480
|
+
if (existing !== void 0 && entry.workspaces === void 0 && entry.sessions === void 0 && !reuseExisting) {
|
|
14481
|
+
throw new SshDevError("ROOT_INVALID", `\u8BE5\u6839\u5DF2\u5B58\u5728\uFF1A${key}`, { key });
|
|
14482
|
+
}
|
|
14483
|
+
let registeredNow = false;
|
|
14484
|
+
if (!await isRootLive(deps, entry)) {
|
|
14485
|
+
await deps.registerRoot(existing ?? entry);
|
|
14486
|
+
registeredNow = true;
|
|
14487
|
+
}
|
|
14488
|
+
let state;
|
|
14489
|
+
try {
|
|
14490
|
+
state = await reconcile(deps, (current) => {
|
|
14491
|
+
if (!stateHasRoot(current, key)) return addRootToState(current, entry);
|
|
14492
|
+
const withWorkspaceScope = entry.workspaces === void 0 ? current : withWorkspaces(current, key, entry.workspaces);
|
|
14493
|
+
return entry.sessions === void 0 ? withWorkspaceScope : withSessions(withWorkspaceScope, key, entry.sessions);
|
|
14494
|
+
});
|
|
14495
|
+
if (existing !== void 0) await refreshLiveScope(deps, key, state);
|
|
14496
|
+
} catch (error) {
|
|
14497
|
+
if (registeredNow) await deps.unregisterRoot(key);
|
|
14498
|
+
throw error;
|
|
14499
|
+
}
|
|
14500
|
+
const anchorPath = await anchorPathOf(deps, entry);
|
|
14501
|
+
send(req, res, 200, {
|
|
14502
|
+
ok: true,
|
|
14503
|
+
action,
|
|
14504
|
+
state: publicUserState(state),
|
|
14505
|
+
...anchorPath === void 0 ? {} : { anchorPath }
|
|
14506
|
+
});
|
|
14507
|
+
return true;
|
|
14508
|
+
return true;
|
|
13509
14509
|
});
|
|
13510
|
-
return true;
|
|
13511
14510
|
}
|
|
13512
14511
|
case "remove-root": {
|
|
13513
14512
|
const key = requiredKey(body["key"]);
|
|
@@ -13530,6 +14529,30 @@ async function handleRootActions(action, body, req, res, deps) {
|
|
|
13530
14529
|
return false;
|
|
13531
14530
|
}
|
|
13532
14531
|
}
|
|
14532
|
+
async function refreshLiveScope(deps, key, state) {
|
|
14533
|
+
const persisted = findRootInState(state, key);
|
|
14534
|
+
if (persisted === void 0) return;
|
|
14535
|
+
const raw = await Promise.resolve(deps.listRoots());
|
|
14536
|
+
const liveEntry = raw.find((candidate) => liveKeyOfRecord(candidate, persisted) !== void 0);
|
|
14537
|
+
if (liveEntry === void 0) return;
|
|
14538
|
+
if (liveScopeIsCurrent(liveEntry, persisted)) return;
|
|
14539
|
+
const liveKey = liveKeyOfRecord(liveEntry, persisted);
|
|
14540
|
+
if (liveKey === void 0) return;
|
|
14541
|
+
await deps.unregisterRoot(liveKey);
|
|
14542
|
+
await deps.registerRoot(persisted);
|
|
14543
|
+
}
|
|
14544
|
+
function liveScopeIsCurrent(raw, entry) {
|
|
14545
|
+
const outer = asRecord2(raw) ?? {};
|
|
14546
|
+
const source = asRecord2(outer["root"]) ?? outer;
|
|
14547
|
+
if (source["shared"] === true) return true;
|
|
14548
|
+
return sameStrings(source["workspaces"], entry.workspaces) && sameStrings(source["sessions"], entry.sessions);
|
|
14549
|
+
}
|
|
14550
|
+
function sameStrings(left, right) {
|
|
14551
|
+
const asSet = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string").sort() : [];
|
|
14552
|
+
const a = asSet(left);
|
|
14553
|
+
const b = asSet(right);
|
|
14554
|
+
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
14555
|
+
}
|
|
13533
14556
|
function rootAddedBySession(state, key, sessionId) {
|
|
13534
14557
|
const entry = findRootInState(state, key);
|
|
13535
14558
|
return (entry?.sessions ?? []).includes(sessionId);
|
|
@@ -13607,10 +14630,10 @@ function splitKeyValue(line) {
|
|
|
13607
14630
|
if (key === void 0 || value === void 0) return void 0;
|
|
13608
14631
|
return [key.toLowerCase(), unquote(value.trim())];
|
|
13609
14632
|
}
|
|
13610
|
-
function parseSshConfig(
|
|
14633
|
+
function parseSshConfig(text8) {
|
|
13611
14634
|
const entries = [];
|
|
13612
14635
|
let current;
|
|
13613
|
-
const lines =
|
|
14636
|
+
const lines = text8.split(/\r?\n/);
|
|
13614
14637
|
for (let index = 0; index < lines.length; index++) {
|
|
13615
14638
|
const raw = lines[index] ?? "";
|
|
13616
14639
|
const line = stripComment(raw).trim();
|
|
@@ -13730,8 +14753,8 @@ async function handleConnectionActions(action, body, req, res, deps) {
|
|
|
13730
14753
|
throw new SshDevError("CONNECTION_INVALID", "\u8FD9\u4E2A\u5BBF\u4E3B\u7EC4\u5408\u6CA1\u6709\u63D0\u4F9B ~/.ssh/config \u7684\u8BFB\u53D6\u80FD\u529B", {});
|
|
13731
14754
|
}
|
|
13732
14755
|
const byAlias = new Map(found.candidates.map((candidate) => [candidate.alias, candidate]));
|
|
13733
|
-
const
|
|
13734
|
-
const drafts = importableEntries(parseSshConfig(
|
|
14756
|
+
const text8 = await deps.readSshConfig?.();
|
|
14757
|
+
const drafts = importableEntries(parseSshConfig(text8 ?? ""), { home: homedir3() }).drafts;
|
|
13735
14758
|
const draftByAlias = new Map(drafts.map((draft) => [draft.alias, draft]));
|
|
13736
14759
|
const accepted = [];
|
|
13737
14760
|
const refused = [];
|
|
@@ -13995,9 +15018,9 @@ function publicProbeResult(raw) {
|
|
|
13995
15018
|
async function sshConfigCandidates(deps, excluded) {
|
|
13996
15019
|
const read = deps.readSshConfig;
|
|
13997
15020
|
if (read === void 0) return void 0;
|
|
13998
|
-
const
|
|
13999
|
-
if (
|
|
14000
|
-
const result = importableEntries(parseSshConfig(
|
|
15021
|
+
const text8 = await read();
|
|
15022
|
+
if (text8 === void 0 || text8.trim() === "") return { candidates: [], skipped: [] };
|
|
15023
|
+
const result = importableEntries(parseSshConfig(text8), { home: homedir3(), excluded });
|
|
14001
15024
|
const taken = new Set(excluded);
|
|
14002
15025
|
const candidates = [];
|
|
14003
15026
|
const skipped = [...result.skipped];
|
|
@@ -14322,20 +15345,20 @@ async function serveSnapshot(req, res, deps) {
|
|
|
14322
15345
|
});
|
|
14323
15346
|
}
|
|
14324
15347
|
async function serveAction(req, res, deps) {
|
|
14325
|
-
let
|
|
15348
|
+
let text8;
|
|
14326
15349
|
try {
|
|
14327
|
-
|
|
15350
|
+
text8 = await readBody(req, ROOTS_API_MAX_BODY_BYTES);
|
|
14328
15351
|
} catch (error) {
|
|
14329
15352
|
send(req, res, 400, { ok: false, error: messageOf2(error) });
|
|
14330
15353
|
return;
|
|
14331
15354
|
}
|
|
14332
|
-
if (
|
|
15355
|
+
if (text8.trim() === "") {
|
|
14333
15356
|
send(req, res, 400, { ok: false, error: "\u8BF7\u6C42\u4F53\u4E0D\u80FD\u4E3A\u7A7A\uFF1A\u9700\u8981 { action, ... } \u7684 JSON \u5BF9\u8C61" });
|
|
14334
15357
|
return;
|
|
14335
15358
|
}
|
|
14336
15359
|
let decoded;
|
|
14337
15360
|
try {
|
|
14338
|
-
decoded = JSON.parse(
|
|
15361
|
+
decoded = JSON.parse(text8);
|
|
14339
15362
|
} catch (error) {
|
|
14340
15363
|
send(req, res, 400, { ok: false, error: `\u8BF7\u6C42\u4F53\u4E0D\u662F\u5408\u6CD5 JSON\uFF1A${messageOf2(error)}` });
|
|
14341
15364
|
return;
|
|
@@ -14374,55 +15397,172 @@ async function serveAction(req, res, deps) {
|
|
|
14374
15397
|
}
|
|
14375
15398
|
}
|
|
14376
15399
|
|
|
14377
|
-
// src/host/integration/
|
|
14378
|
-
|
|
14379
|
-
|
|
14380
|
-
|
|
14381
|
-
|
|
14382
|
-
|
|
14383
|
-
|
|
14384
|
-
|
|
14385
|
-
|
|
14386
|
-
|
|
14387
|
-
|
|
14388
|
-
|
|
14389
|
-
|
|
14390
|
-
|
|
14391
|
-
const
|
|
14392
|
-
|
|
14393
|
-
|
|
14394
|
-
|
|
14395
|
-
|
|
14396
|
-
|
|
14397
|
-
|
|
14398
|
-
|
|
14399
|
-
|
|
14400
|
-
|
|
14401
|
-
|
|
14402
|
-
|
|
14403
|
-
|
|
14404
|
-
|
|
14405
|
-
|
|
14406
|
-
|
|
14407
|
-
|
|
14408
|
-
|
|
14409
|
-
|
|
14410
|
-
|
|
14411
|
-
|
|
14412
|
-
|
|
14413
|
-
|
|
14414
|
-
|
|
14415
|
-
|
|
14416
|
-
|
|
14417
|
-
|
|
14418
|
-
|
|
14419
|
-
|
|
14420
|
-
|
|
14421
|
-
|
|
14422
|
-
|
|
14423
|
-
|
|
14424
|
-
|
|
14425
|
-
|
|
15400
|
+
// src/host/integration/session/session-bin-lock.ts
|
|
15401
|
+
init_errors();
|
|
15402
|
+
import { mkdir as mkdir5, open as open3, readFile as readFile6, rm as rm3, stat as stat3 } from "node:fs/promises";
|
|
15403
|
+
import { dirname as dirname5 } from "node:path";
|
|
15404
|
+
var DEFAULT_STALE_LOCK_MS = 6e4;
|
|
15405
|
+
var DEFAULT_LOCK_RETRY_MS = 25;
|
|
15406
|
+
var DEFAULT_LOCK_TIMEOUT_MS = 3e4;
|
|
15407
|
+
function createSessionBinLock(options) {
|
|
15408
|
+
const staleMs = options.staleMs ?? DEFAULT_STALE_LOCK_MS;
|
|
15409
|
+
const retryMs = options.retryMs ?? DEFAULT_LOCK_RETRY_MS;
|
|
15410
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
|
|
15411
|
+
const sleep2 = options.sleep ?? ((ms) => new Promise((resolve3) => setTimeout(resolve3, ms)));
|
|
15412
|
+
const now = options.now ?? (() => Date.now());
|
|
15413
|
+
const owner = options.owner ?? `${process.pid}@${hostnameOf()}`;
|
|
15414
|
+
const createLockFile = async () => {
|
|
15415
|
+
let handle;
|
|
15416
|
+
try {
|
|
15417
|
+
handle = await open3(options.path, "wx");
|
|
15418
|
+
} catch (error) {
|
|
15419
|
+
if (error.code === "EEXIST") return void 0;
|
|
15420
|
+
throw error;
|
|
15421
|
+
}
|
|
15422
|
+
const token = `${owner}#${now()}#${Math.random().toString(36).slice(2, 10)}`;
|
|
15423
|
+
try {
|
|
15424
|
+
try {
|
|
15425
|
+
const record2 = { pid: process.pid, host: hostnameOf(), acquiredAtMs: now(), owner, token };
|
|
15426
|
+
await handle.writeFile(`${JSON.stringify(record2)}
|
|
15427
|
+
`, "utf8");
|
|
15428
|
+
} finally {
|
|
15429
|
+
await handle.close();
|
|
15430
|
+
}
|
|
15431
|
+
} catch (error) {
|
|
15432
|
+
await rm3(options.path, { force: true }).catch(() => void 0);
|
|
15433
|
+
throw error;
|
|
15434
|
+
}
|
|
15435
|
+
return token;
|
|
15436
|
+
};
|
|
15437
|
+
const releaseIfOwned = async (token) => {
|
|
15438
|
+
if (token === "") return;
|
|
15439
|
+
try {
|
|
15440
|
+
const text8 = await readFile6(options.path, "utf8");
|
|
15441
|
+
const record2 = JSON.parse(text8);
|
|
15442
|
+
if (record2.token !== token) return;
|
|
15443
|
+
} catch {
|
|
15444
|
+
return;
|
|
15445
|
+
}
|
|
15446
|
+
await rm3(options.path, { force: true });
|
|
15447
|
+
};
|
|
15448
|
+
const tryAcquire = async () => {
|
|
15449
|
+
try {
|
|
15450
|
+
return await createLockFile();
|
|
15451
|
+
} catch (error) {
|
|
15452
|
+
if (error.code !== "ENOENT") throw error;
|
|
15453
|
+
await mkdir5(dirname5(options.path), { recursive: true });
|
|
15454
|
+
return createLockFile();
|
|
15455
|
+
}
|
|
15456
|
+
};
|
|
15457
|
+
const reclaimIfStale = async () => {
|
|
15458
|
+
if (staleMs <= 0) return false;
|
|
15459
|
+
let age;
|
|
15460
|
+
try {
|
|
15461
|
+
const info = await stat3(options.path);
|
|
15462
|
+
age = now() - info.mtimeMs;
|
|
15463
|
+
} catch {
|
|
15464
|
+
return true;
|
|
15465
|
+
}
|
|
15466
|
+
if (age < staleMs) return false;
|
|
15467
|
+
try {
|
|
15468
|
+
const text8 = await readFile6(options.path, "utf8");
|
|
15469
|
+
const record2 = JSON.parse(text8);
|
|
15470
|
+
options.onReclaim?.({ path: options.path, ageMs: age, holder: record2 });
|
|
15471
|
+
} catch {
|
|
15472
|
+
options.onReclaim?.({ path: options.path, ageMs: age, holder: {} });
|
|
15473
|
+
}
|
|
15474
|
+
await rm3(options.path, { force: true });
|
|
15475
|
+
return true;
|
|
15476
|
+
};
|
|
15477
|
+
const withLock = async (_key, operation) => {
|
|
15478
|
+
const deadline = now() + timeoutMs;
|
|
15479
|
+
let token;
|
|
15480
|
+
for (; ; ) {
|
|
15481
|
+
token = await tryAcquire();
|
|
15482
|
+
if (token !== void 0) break;
|
|
15483
|
+
if (await reclaimIfStale()) continue;
|
|
15484
|
+
if (now() >= deadline) {
|
|
15485
|
+
throw new SshDevError("TRANSFER_CONFLICT", `\u4F1A\u8BDD\u56DE\u6536\u7AD9\u7D22\u5F15\u88AB\u53E6\u4E00\u4E2A\u8FDB\u7A0B\u5360\u7528\uFF08\u7B49\u5F85 ${timeoutMs} ms \u8D85\u65F6\uFF09`, {
|
|
15486
|
+
path: options.path,
|
|
15487
|
+
reason: "lock-timeout",
|
|
15488
|
+
timeoutMs
|
|
15489
|
+
});
|
|
15490
|
+
}
|
|
15491
|
+
await sleep2(retryMs);
|
|
15492
|
+
}
|
|
15493
|
+
try {
|
|
15494
|
+
return await operation();
|
|
15495
|
+
} finally {
|
|
15496
|
+
if (token !== void 0) await releaseIfOwned(token);
|
|
15497
|
+
}
|
|
15498
|
+
};
|
|
15499
|
+
return { withLock, lockPath: options.path };
|
|
15500
|
+
}
|
|
15501
|
+
function hostnameOf() {
|
|
15502
|
+
try {
|
|
15503
|
+
return process.env["COMPUTERNAME"] ?? process.env["HOSTNAME"] ?? "unknown";
|
|
15504
|
+
} catch {
|
|
15505
|
+
return "unknown";
|
|
15506
|
+
}
|
|
15507
|
+
}
|
|
15508
|
+
|
|
15509
|
+
// src/host/integration/roots/roots-wiring.ts
|
|
15510
|
+
import { join as join14 } from "node:path";
|
|
15511
|
+
function rootsRouteDiagnostic(facts) {
|
|
15512
|
+
const warnings = facts.warnings.length === 0 ? "(none)" : facts.warnings.map((warning) => `[${warning}]`).join(" ");
|
|
15513
|
+
return [
|
|
15514
|
+
"roots panel[diag]",
|
|
15515
|
+
`route=${facts.routeRegistered ? "registered" : "MISSING"}`,
|
|
15516
|
+
`injectRan=${facts.injectRan}`,
|
|
15517
|
+
`hasWebServerProp=${facts.hasWebServerProperty}`,
|
|
15518
|
+
`tools=${facts.toolsType}`,
|
|
15519
|
+
`warnings=${facts.warnings.length}`,
|
|
15520
|
+
warnings
|
|
15521
|
+
].join(" ");
|
|
15522
|
+
}
|
|
15523
|
+
function createRootsWiring(deps) {
|
|
15524
|
+
const stateLock = deps.io === void 0 ? createSessionBinLock({ path: join14(deps.stateDir, `${USER_ROOTS_LOCK_FILE_NAME}`) }) : void 0;
|
|
15525
|
+
const store2 = createUserRootsStore({
|
|
15526
|
+
dir: deps.stateDir,
|
|
15527
|
+
...deps.io === void 0 ? {} : { io: deps.io },
|
|
15528
|
+
// With an injected `io` (tests) there is no real directory to lock, so the store keeps its in-process
|
|
15529
|
+
// serialisation only — the lock is a property of the real file system, not of the pure layer.
|
|
15530
|
+
...stateLock === void 0 ? {} : { withLock: (operation) => stateLock.withLock("user-roots", operation) }
|
|
15531
|
+
});
|
|
15532
|
+
const listRemoteDirectory = deps.listRemoteDirectory;
|
|
15533
|
+
const remoteHomeDirectory = deps.remoteHomeDirectory;
|
|
15534
|
+
const listLocalDirectory = deps.listLocalDirectory;
|
|
15535
|
+
const readSshConfig = deps.readSshConfig;
|
|
15536
|
+
const remoteFiles = deps.remoteFiles;
|
|
15537
|
+
const transfers = deps.transfers;
|
|
15538
|
+
const readCredentials = deps.readCredentials;
|
|
15539
|
+
const testConnection = deps.testConnection;
|
|
15540
|
+
const apiDeps = {
|
|
15541
|
+
listRoots: () => deps.listRoots(),
|
|
15542
|
+
listConnections: () => deps.listConnections(),
|
|
15543
|
+
loadUserState: () => store2.load(),
|
|
15544
|
+
mutateUserState: (transition) => store2.mutate(transition),
|
|
15545
|
+
registerRoot: async (entry) => {
|
|
15546
|
+
await deps.registerRoot(entry);
|
|
15547
|
+
},
|
|
15548
|
+
unregisterRoot: (key) => {
|
|
15549
|
+
deps.unregisterRoot(key);
|
|
15550
|
+
},
|
|
15551
|
+
addConnection: async (entry) => {
|
|
15552
|
+
const next = await store2.addConnection(entry);
|
|
15553
|
+
deps.applyConnection(entry, entry.id);
|
|
15554
|
+
return next;
|
|
15555
|
+
},
|
|
15556
|
+
applyConnection: async (entry, previousId) => {
|
|
15557
|
+
deps.applyConnection(entry, previousId);
|
|
15558
|
+
},
|
|
15559
|
+
removeConnection: async (id) => {
|
|
15560
|
+
deps.removeConnection(id);
|
|
15561
|
+
},
|
|
15562
|
+
...deps.anchorRootDir === void 0 ? {} : { anchorRootDir: deps.anchorRootDir },
|
|
15563
|
+
...listRemoteDirectory === void 0 ? {} : { listRemoteDirectory: (input) => listRemoteDirectory(input) },
|
|
15564
|
+
...remoteHomeDirectory === void 0 ? {} : { remoteHomeDirectory },
|
|
15565
|
+
...listLocalDirectory === void 0 ? {} : { listLocalDirectory: (input) => listLocalDirectory(input) },
|
|
14426
15566
|
...readSshConfig === void 0 ? {} : { readSshConfig: () => readSshConfig() },
|
|
14427
15567
|
...remoteFiles === void 0 ? {} : { remoteFiles },
|
|
14428
15568
|
...transfers === void 0 ? {} : { transfers },
|
|
@@ -14439,9 +15579,8 @@ function createRootsWiring(deps) {
|
|
|
14439
15579
|
routeRegistered = true;
|
|
14440
15580
|
return disposeRoute;
|
|
14441
15581
|
} catch (error) {
|
|
14442
|
-
routeRegistered = false;
|
|
14443
15582
|
routeWarnings.push(routeRefusedWarning(error));
|
|
14444
|
-
return
|
|
15583
|
+
return void 0;
|
|
14445
15584
|
}
|
|
14446
15585
|
};
|
|
14447
15586
|
let unregisterRoute;
|
|
@@ -14457,6 +15596,7 @@ function createRootsWiring(deps) {
|
|
|
14457
15596
|
return;
|
|
14458
15597
|
}
|
|
14459
15598
|
const disposeRoute = registerRoute(webServer);
|
|
15599
|
+
if (disposeRoute === void 0) return () => void 0;
|
|
14460
15600
|
unregisterRoute = disposeRoute;
|
|
14461
15601
|
return () => {
|
|
14462
15602
|
disposeRoute();
|
|
@@ -14536,9 +15676,9 @@ function isAlreadyLive(entry, live) {
|
|
|
14536
15676
|
}
|
|
14537
15677
|
|
|
14538
15678
|
// src/wiring/fs-panel-routes.ts
|
|
14539
|
-
import { mkdir as
|
|
15679
|
+
import { mkdir as mkdir6, readdir as readdir4, readFile as readFile7, rmdir } from "node:fs/promises";
|
|
14540
15680
|
import { homedir as homedir5 } from "node:os";
|
|
14541
|
-
import { join as
|
|
15681
|
+
import { join as join15 } from "node:path";
|
|
14542
15682
|
init_errors();
|
|
14543
15683
|
|
|
14544
15684
|
// src/host/integration/remote/connection-test.ts
|
|
@@ -14849,21 +15989,38 @@ function readOnlySeams(env) {
|
|
|
14849
15989
|
};
|
|
14850
15990
|
}
|
|
14851
15991
|
function rootLifecycleSeams(env, hooks) {
|
|
14852
|
-
const { anchorRootDir, registry, binRootResolver, prepareBinRoots } = env;
|
|
15992
|
+
const { anchorRootDir, registry, binRootResolver, prepareBinRoots, sessionRoots } = env;
|
|
14853
15993
|
return {
|
|
14854
15994
|
registerRoot: async (entry) => {
|
|
14855
15995
|
const root = toRoot(entry, anchorRootDir, "user");
|
|
14856
|
-
await ensureAnchorDirectory(root, { mkdir:
|
|
15996
|
+
await ensureAnchorDirectory(root, { mkdir: mkdir6 });
|
|
14857
15997
|
registry.add(root);
|
|
14858
|
-
|
|
14859
|
-
|
|
15998
|
+
sessionRoots.invalidate();
|
|
15999
|
+
const prepared = prepareBinRoots([root]);
|
|
16000
|
+
void prepared.then(() => {
|
|
16001
|
+
hooks.runBinHousekeeping();
|
|
16002
|
+
}).catch((error) => {
|
|
16003
|
+
hooks.warn?.(`\u56DE\u6536\u7AD9\u51C6\u5907\u5931\u8D25\uFF08\u6839\u4ECD\u53EF\u7528\uFF0C\u5220\u9664\u4F1A\u5148\u843D\u5728\u5386\u53F2\u4F4D\u7F6E\uFF09\uFF1A${error instanceof Error ? error.message : String(error)}`);
|
|
16004
|
+
});
|
|
14860
16005
|
},
|
|
14861
16006
|
unregisterRoot: (key) => {
|
|
16007
|
+
const anchor = anchorPathOfRecord(registry.records, key);
|
|
14862
16008
|
const removed = registry.removeByLooseKey(key);
|
|
14863
16009
|
binRootResolver.forget(removed ?? key);
|
|
16010
|
+
sessionRoots.invalidate();
|
|
16011
|
+
if (anchor !== void 0) void rmdir(anchor).catch(() => void 0);
|
|
14864
16012
|
}
|
|
14865
16013
|
};
|
|
14866
16014
|
}
|
|
16015
|
+
function anchorPathOfRecord(records, key) {
|
|
16016
|
+
const target = key.toLowerCase();
|
|
16017
|
+
for (const record2 of records) {
|
|
16018
|
+
if (record2.key.toLowerCase() !== target) continue;
|
|
16019
|
+
const anchor = record2.root.anchorPath;
|
|
16020
|
+
return typeof anchor === "string" && anchor !== "" ? anchor : void 0;
|
|
16021
|
+
}
|
|
16022
|
+
return void 0;
|
|
16023
|
+
}
|
|
14867
16024
|
function connectionLifecycleSeams(env, hooks) {
|
|
14868
16025
|
const { profiles, indexes, browseBackends, forgetBackendsOf, transports, askpass } = env;
|
|
14869
16026
|
return {
|
|
@@ -14880,14 +16037,14 @@ function connectionLifecycleSeams(env, hooks) {
|
|
|
14880
16037
|
profiles.delete(previousId);
|
|
14881
16038
|
hooks.forgetPerConnection(previousId);
|
|
14882
16039
|
indexes.delete(previousId);
|
|
14883
|
-
browseBackends
|
|
16040
|
+
retireBrowseBackend(browseBackends, previousId);
|
|
14884
16041
|
forgetBackendsOf(previousId);
|
|
14885
16042
|
void transports.release(previousId).catch(() => void 0);
|
|
14886
16043
|
}
|
|
14887
16044
|
hooks.forgetPerConnection(profile.id);
|
|
14888
16045
|
askpass.revive(profile.id);
|
|
14889
16046
|
indexes.delete(profile.id);
|
|
14890
|
-
browseBackends
|
|
16047
|
+
retireBrowseBackend(browseBackends, profile.id);
|
|
14891
16048
|
forgetBackendsOf(profile.id);
|
|
14892
16049
|
void transports.release(profile.id).catch(() => void 0);
|
|
14893
16050
|
},
|
|
@@ -14899,12 +16056,18 @@ function connectionLifecycleSeams(env, hooks) {
|
|
|
14899
16056
|
profiles.delete(id);
|
|
14900
16057
|
hooks.forgetPerConnection(id);
|
|
14901
16058
|
indexes.delete(id);
|
|
14902
|
-
browseBackends
|
|
16059
|
+
retireBrowseBackend(browseBackends, id);
|
|
14903
16060
|
forgetBackendsOf(id);
|
|
14904
16061
|
void transports.release(id).catch(() => void 0);
|
|
14905
16062
|
}
|
|
14906
16063
|
};
|
|
14907
16064
|
}
|
|
16065
|
+
function retireBrowseBackend(browseBackends, connectionId) {
|
|
16066
|
+
const backend = browseBackends.get(connectionId);
|
|
16067
|
+
if (backend === void 0) return;
|
|
16068
|
+
browseBackends.delete(connectionId);
|
|
16069
|
+
void backend.dispose?.();
|
|
16070
|
+
}
|
|
14908
16071
|
function browsingSeams(env) {
|
|
14909
16072
|
const { profiles, transports, remoteHome, browseBackends } = env;
|
|
14910
16073
|
return {
|
|
@@ -14923,6 +16086,9 @@ function browsingSeams(env) {
|
|
|
14923
16086
|
if (backend === void 0) {
|
|
14924
16087
|
backend = new RemoteBackend({
|
|
14925
16088
|
id: `browse:${connectionId}`,
|
|
16089
|
+
// 浏览后端与所有根共用**同一个**缓存预算。不传的话它每连接自带 8 MiB 读 + 2 MiB 列表缓存,
|
|
16090
|
+
// 十条连接就是 100 MiB,直接绕过"进程总上限"这个预算存在的理由(审计 0112 P3-12 的同一课)。
|
|
16091
|
+
...env.cacheBudget === void 0 ? {} : { cacheBudget: env.cacheBudget },
|
|
14926
16092
|
// Looked up per use, not captured: removing a connection must stop browsing it (the registry and
|
|
14927
16093
|
// the panel share this rule), and `requireProfile` is what reports that as CONNECTION_NOT_FOUND.
|
|
14928
16094
|
getTransport: async () => (await transports.acquire(requireProfile(profiles, connectionId))).transport
|
|
@@ -14956,7 +16122,7 @@ function browsingSeams(env) {
|
|
|
14956
16122
|
},
|
|
14957
16123
|
readSshConfig: async () => {
|
|
14958
16124
|
try {
|
|
14959
|
-
return await
|
|
16125
|
+
return await readFile7(join15(homedir5(), ".ssh", "config"), "utf8");
|
|
14960
16126
|
} catch (error) {
|
|
14961
16127
|
if (error.code === "ENOENT") return void 0;
|
|
14962
16128
|
throw error;
|
|
@@ -15080,7 +16246,7 @@ function registerConfiguredRoots(env) {
|
|
|
15080
16246
|
const root = toRoot(entry, anchorRootDir, "config");
|
|
15081
16247
|
registry.add(root);
|
|
15082
16248
|
binRootPreparation.push(prepareBinRoots([root]));
|
|
15083
|
-
void ensureAnchorDirectory(root, { mkdir:
|
|
16249
|
+
void ensureAnchorDirectory(root, { mkdir: mkdir7 }).catch((error) => {
|
|
15084
16250
|
ctx.logger?.warn?.(`over-ssh: \u8FDC\u7AEF\u6839\u7684\u951A\u70B9\u76EE\u5F55\u65E0\u6CD5\u521B\u5EFA\uFF08${root.anchorPath ?? ""}\uFF09\uFF1A${error instanceof Error ? error.message : String(error)}`);
|
|
15085
16251
|
});
|
|
15086
16252
|
}
|
|
@@ -15178,7 +16344,11 @@ function wireFs(env) {
|
|
|
15178
16344
|
});
|
|
15179
16345
|
const runBinHousekeeping = createBinHousekeeping({ ctx, limits, binService: env.binService });
|
|
15180
16346
|
registerConfiguredRoots({ ctx, config, anchorRootDir, registry, prepareBinRoots, runBinHousekeeping });
|
|
15181
|
-
const routes = registerPanelRoutes(env, {
|
|
16347
|
+
const routes = registerPanelRoutes(env, {
|
|
16348
|
+
forgetPerConnection,
|
|
16349
|
+
runBinHousekeeping,
|
|
16350
|
+
warn: (message) => ctx.logger?.warn?.(`over-ssh: ${message}`)
|
|
16351
|
+
});
|
|
15182
16352
|
const activationTimers = scheduleActivationDiagnostics({ ctx, ...routes });
|
|
15183
16353
|
watchSessionRemovals({ ctx, rootsWiring: routes.rootsWiring, onSessionRemoved: env.onSessionRemoved });
|
|
15184
16354
|
mergePersistedRoots({ ctx, rootsWiring: routes.rootsWiring });
|
|
@@ -15189,124 +16359,13 @@ function wireFs(env) {
|
|
|
15189
16359
|
// src/wiring/roots.ts
|
|
15190
16360
|
init_errors();
|
|
15191
16361
|
import { mkdir as mkdir9, rename as rename5, stat as nodeStat } from "node:fs/promises";
|
|
15192
|
-
|
|
15193
|
-
// src/host/integration/session/session-bin-lock.ts
|
|
15194
|
-
init_errors();
|
|
15195
|
-
import { mkdir as mkdir7, open as open3, readFile as readFile7, rm as rm3, stat as stat3 } from "node:fs/promises";
|
|
15196
|
-
import { dirname as dirname5 } from "node:path";
|
|
15197
|
-
var DEFAULT_STALE_LOCK_MS = 6e4;
|
|
15198
|
-
var DEFAULT_LOCK_RETRY_MS = 25;
|
|
15199
|
-
var DEFAULT_LOCK_TIMEOUT_MS = 3e4;
|
|
15200
|
-
function createSessionBinLock(options) {
|
|
15201
|
-
const staleMs = options.staleMs ?? DEFAULT_STALE_LOCK_MS;
|
|
15202
|
-
const retryMs = options.retryMs ?? DEFAULT_LOCK_RETRY_MS;
|
|
15203
|
-
const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
|
|
15204
|
-
const sleep2 = options.sleep ?? ((ms) => new Promise((resolve3) => setTimeout(resolve3, ms)));
|
|
15205
|
-
const now = options.now ?? (() => Date.now());
|
|
15206
|
-
const owner = options.owner ?? `${process.pid}@${hostnameOf()}`;
|
|
15207
|
-
const createLockFile = async () => {
|
|
15208
|
-
let handle;
|
|
15209
|
-
try {
|
|
15210
|
-
handle = await open3(options.path, "wx");
|
|
15211
|
-
} catch (error) {
|
|
15212
|
-
if (error.code === "EEXIST") return void 0;
|
|
15213
|
-
throw error;
|
|
15214
|
-
}
|
|
15215
|
-
const token = `${owner}#${now()}#${Math.random().toString(36).slice(2, 10)}`;
|
|
15216
|
-
try {
|
|
15217
|
-
try {
|
|
15218
|
-
const record2 = { pid: process.pid, host: hostnameOf(), acquiredAtMs: now(), owner, token };
|
|
15219
|
-
await handle.writeFile(`${JSON.stringify(record2)}
|
|
15220
|
-
`, "utf8");
|
|
15221
|
-
} finally {
|
|
15222
|
-
await handle.close();
|
|
15223
|
-
}
|
|
15224
|
-
} catch (error) {
|
|
15225
|
-
await rm3(options.path, { force: true }).catch(() => void 0);
|
|
15226
|
-
throw error;
|
|
15227
|
-
}
|
|
15228
|
-
return token;
|
|
15229
|
-
};
|
|
15230
|
-
const releaseIfOwned = async (token) => {
|
|
15231
|
-
if (token === "") return;
|
|
15232
|
-
try {
|
|
15233
|
-
const text6 = await readFile7(options.path, "utf8");
|
|
15234
|
-
const record2 = JSON.parse(text6);
|
|
15235
|
-
if (record2.token !== token) return;
|
|
15236
|
-
} catch {
|
|
15237
|
-
return;
|
|
15238
|
-
}
|
|
15239
|
-
await rm3(options.path, { force: true });
|
|
15240
|
-
};
|
|
15241
|
-
const tryAcquire = async () => {
|
|
15242
|
-
try {
|
|
15243
|
-
return await createLockFile();
|
|
15244
|
-
} catch (error) {
|
|
15245
|
-
if (error.code !== "ENOENT") throw error;
|
|
15246
|
-
await mkdir7(dirname5(options.path), { recursive: true });
|
|
15247
|
-
return createLockFile();
|
|
15248
|
-
}
|
|
15249
|
-
};
|
|
15250
|
-
const reclaimIfStale = async () => {
|
|
15251
|
-
if (staleMs <= 0) return false;
|
|
15252
|
-
let age;
|
|
15253
|
-
try {
|
|
15254
|
-
const info = await stat3(options.path);
|
|
15255
|
-
age = now() - info.mtimeMs;
|
|
15256
|
-
} catch {
|
|
15257
|
-
return true;
|
|
15258
|
-
}
|
|
15259
|
-
if (age < staleMs) return false;
|
|
15260
|
-
try {
|
|
15261
|
-
const text6 = await readFile7(options.path, "utf8");
|
|
15262
|
-
const record2 = JSON.parse(text6);
|
|
15263
|
-
options.onReclaim?.({ path: options.path, ageMs: age, holder: record2 });
|
|
15264
|
-
} catch {
|
|
15265
|
-
options.onReclaim?.({ path: options.path, ageMs: age, holder: {} });
|
|
15266
|
-
}
|
|
15267
|
-
await rm3(options.path, { force: true });
|
|
15268
|
-
return true;
|
|
15269
|
-
};
|
|
15270
|
-
const withLock = async (_key, operation) => {
|
|
15271
|
-
const deadline = now() + timeoutMs;
|
|
15272
|
-
let token;
|
|
15273
|
-
for (; ; ) {
|
|
15274
|
-
token = await tryAcquire();
|
|
15275
|
-
if (token !== void 0) break;
|
|
15276
|
-
if (await reclaimIfStale()) continue;
|
|
15277
|
-
if (now() >= deadline) {
|
|
15278
|
-
throw new SshDevError("TRANSFER_CONFLICT", `\u4F1A\u8BDD\u56DE\u6536\u7AD9\u7D22\u5F15\u88AB\u53E6\u4E00\u4E2A\u8FDB\u7A0B\u5360\u7528\uFF08\u7B49\u5F85 ${timeoutMs} ms \u8D85\u65F6\uFF09`, {
|
|
15279
|
-
path: options.path,
|
|
15280
|
-
reason: "lock-timeout",
|
|
15281
|
-
timeoutMs
|
|
15282
|
-
});
|
|
15283
|
-
}
|
|
15284
|
-
await sleep2(retryMs);
|
|
15285
|
-
}
|
|
15286
|
-
try {
|
|
15287
|
-
return await operation();
|
|
15288
|
-
} finally {
|
|
15289
|
-
if (token !== void 0) await releaseIfOwned(token);
|
|
15290
|
-
}
|
|
15291
|
-
};
|
|
15292
|
-
return { withLock, lockPath: options.path };
|
|
15293
|
-
}
|
|
15294
|
-
function hostnameOf() {
|
|
15295
|
-
try {
|
|
15296
|
-
return process.env["COMPUTERNAME"] ?? process.env["HOSTNAME"] ?? "unknown";
|
|
15297
|
-
} catch {
|
|
15298
|
-
return "unknown";
|
|
15299
|
-
}
|
|
15300
|
-
}
|
|
15301
|
-
|
|
15302
|
-
// src/wiring/roots.ts
|
|
15303
|
-
import { createHash as createHash3 } from "node:crypto";
|
|
16362
|
+
import { createHash as createHash4 } from "node:crypto";
|
|
15304
16363
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
15305
|
-
import { dirname as dirname7, join as
|
|
16364
|
+
import { dirname as dirname7, join as join16 } from "node:path";
|
|
15306
16365
|
|
|
15307
16366
|
// src/host/integration/transfer/transfer-adapters.ts
|
|
15308
16367
|
init_errors();
|
|
15309
|
-
import { createHash as
|
|
16368
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
15310
16369
|
import { mkdir as mkdir8, open as open4, readFile as readFile8, readdir as readdir5, rename as rename4, rm as rm4, stat as stat4, writeFile as writeFile5 } from "node:fs/promises";
|
|
15311
16370
|
import { dirname as dirname6 } from "node:path";
|
|
15312
16371
|
function requireRemote(deps, path) {
|
|
@@ -15379,7 +16438,7 @@ function createTransferDeps(deps) {
|
|
|
15379
16438
|
},
|
|
15380
16439
|
async sha256Local(path) {
|
|
15381
16440
|
const handle = await open4(path, "r");
|
|
15382
|
-
const hash =
|
|
16441
|
+
const hash = createHash3("sha256");
|
|
15383
16442
|
try {
|
|
15384
16443
|
for await (const chunk of handle.createReadStream()) hash.update(chunk);
|
|
15385
16444
|
} finally {
|
|
@@ -15495,9 +16554,9 @@ function createRecycleBinDeps(deps) {
|
|
|
15495
16554
|
async readText(path) {
|
|
15496
16555
|
return readFile8(path, "utf8");
|
|
15497
16556
|
},
|
|
15498
|
-
async writeText(path,
|
|
16557
|
+
async writeText(path, text8) {
|
|
15499
16558
|
await mkdir8(dirname6(path), { recursive: true });
|
|
15500
|
-
await writeFile5(path,
|
|
16559
|
+
await writeFile5(path, text8, "utf8");
|
|
15501
16560
|
}
|
|
15502
16561
|
};
|
|
15503
16562
|
return {
|
|
@@ -15523,10 +16582,10 @@ function createRecycleBinDeps(deps) {
|
|
|
15523
16582
|
const { backend, absolute } = resolveLocal(target, path);
|
|
15524
16583
|
return (await backend.readText(absolute, deps.maxReadBytes)).text;
|
|
15525
16584
|
},
|
|
15526
|
-
async writeTextRemote(path,
|
|
16585
|
+
async writeTextRemote(path, text8) {
|
|
15527
16586
|
const target = deps.routed.resolve(path);
|
|
15528
16587
|
const { backend, absolute } = resolveLocal(target, path);
|
|
15529
|
-
await backend.writeText(absolute,
|
|
16588
|
+
await backend.writeText(absolute, text8, void 0);
|
|
15530
16589
|
},
|
|
15531
16590
|
moveRemote: remoteMove,
|
|
15532
16591
|
removeRemote: remoteRemove,
|
|
@@ -15644,8 +16703,8 @@ function createBinRootResolver(deps) {
|
|
|
15644
16703
|
return settle(root, choice);
|
|
15645
16704
|
};
|
|
15646
16705
|
async function migrateOldBin(root, choice) {
|
|
15647
|
-
const note = (
|
|
15648
|
-
choice.note = choice.note === void 0 ?
|
|
16706
|
+
const note = (text8) => {
|
|
16707
|
+
choice.note = choice.note === void 0 ? text8 : `${choice.note}\uFF1B${text8}`;
|
|
15649
16708
|
};
|
|
15650
16709
|
const candidates = [];
|
|
15651
16710
|
for (const legacy of LEGACY_BIN_DIRS) {
|
|
@@ -15819,6 +16878,11 @@ function createTransferTracker(options) {
|
|
|
15819
16878
|
}
|
|
15820
16879
|
|
|
15821
16880
|
// src/wiring/roots.ts
|
|
16881
|
+
function pruneStaleLocks(locks, liveRootKeys) {
|
|
16882
|
+
for (const key of [...locks.keys()]) {
|
|
16883
|
+
if (!liveRootKeys.has(key)) locks.delete(key);
|
|
16884
|
+
}
|
|
16885
|
+
}
|
|
15822
16886
|
function wireRoots(env) {
|
|
15823
16887
|
const { ctx, limits, profiles, transports, registry } = env;
|
|
15824
16888
|
const binRootResolver = createBinRootResolver({
|
|
@@ -15907,6 +16971,7 @@ function wireRoots(env) {
|
|
|
15907
16971
|
const transferTracker = createTransferTracker({ maxEntries: limits.transferHistoryEntries ?? 50 });
|
|
15908
16972
|
const binLocks = /* @__PURE__ */ new Map();
|
|
15909
16973
|
const binLockFor = (key) => {
|
|
16974
|
+
pruneStaleLocks(binLocks, new Set(registry.records.map((record2) => rootKey(record2.root))));
|
|
15910
16975
|
const existing = binLocks.get(key);
|
|
15911
16976
|
if (existing !== void 0) return existing;
|
|
15912
16977
|
let lockPath;
|
|
@@ -15915,7 +16980,7 @@ function wireRoots(env) {
|
|
|
15915
16980
|
if (root === void 0) throw new Error("unknown root");
|
|
15916
16981
|
lockPath = `${binRootResolver.binRootFor(root)}.lock`;
|
|
15917
16982
|
} catch {
|
|
15918
|
-
lockPath = `${limits.sessionBinRoot ??
|
|
16983
|
+
lockPath = `${limits.sessionBinRoot ?? join16(tmpdir2(), "dsh-over-ssh")}/recycle-bin-${createHash4("sha256").update(key).digest("hex").slice(0, 16)}.lock`;
|
|
15919
16984
|
}
|
|
15920
16985
|
const created = createSessionBinLock({
|
|
15921
16986
|
path: lockPath,
|
|
@@ -15956,7 +17021,7 @@ function wireRoots(env) {
|
|
|
15956
17021
|
|
|
15957
17022
|
// src/wiring/tools.ts
|
|
15958
17023
|
import { readFile as readFile10 } from "node:fs/promises";
|
|
15959
|
-
import { join as
|
|
17024
|
+
import { join as join18 } from "node:path";
|
|
15960
17025
|
|
|
15961
17026
|
// src/host/integration/remote/remote-tool-backend.ts
|
|
15962
17027
|
init_errors();
|
|
@@ -15991,8 +17056,10 @@ function toExecOutcome(result) {
|
|
|
15991
17056
|
}
|
|
15992
17057
|
function createRemoteToolBackend(deps) {
|
|
15993
17058
|
const resolveSessionRoot = deps.resolveSessionRoot;
|
|
17059
|
+
const resolveForConnection = deps.resolveRootForConnection;
|
|
15994
17060
|
return {
|
|
15995
17061
|
resolve: (cwd) => deps.resolveRoot(cwd),
|
|
17062
|
+
...resolveForConnection === void 0 ? {} : { resolveForConnection: (connectionId, cwd) => resolveForConnection(connectionId, cwd) },
|
|
15996
17063
|
...resolveSessionRoot === void 0 ? {} : { resolveSessionRoot },
|
|
15997
17064
|
async grep(target, request, signal) {
|
|
15998
17065
|
const transport = await deps.acquire(target.connectionId);
|
|
@@ -16076,12 +17143,14 @@ function createRemoteToolBackend(deps) {
|
|
|
16076
17143
|
},
|
|
16077
17144
|
async exec(target, request, signal) {
|
|
16078
17145
|
const transport = await deps.acquire(target.connectionId);
|
|
17146
|
+
const writeRoots = deps.execWriteRoots?.(target.connectionId, target.root);
|
|
16079
17147
|
const result = await transport.exec(
|
|
16080
17148
|
{
|
|
16081
17149
|
script: request.command,
|
|
16082
17150
|
cwd: joinUnderRoot(target.root, request.cwd),
|
|
16083
17151
|
timeoutMs: request.timeoutMs,
|
|
16084
17152
|
maxBytes: MAX_OUTPUT_BYTES,
|
|
17153
|
+
...writeRoots === void 0 ? {} : { writeRoots },
|
|
16085
17154
|
// Audit 0052 P9: `env` is a *tool* parameter that already has a full transport path
|
|
16086
17155
|
// (`ExecRequest.env` → `scriptPrefixes` for the shell transports, `compactParams` for the agent),
|
|
16087
17156
|
// so the only missing link was this request construction. Conditionally spread because
|
|
@@ -16104,8 +17173,8 @@ function createSessionRootIndex(deps) {
|
|
|
16104
17173
|
let generation = 0;
|
|
16105
17174
|
const readEntries = async () => {
|
|
16106
17175
|
try {
|
|
16107
|
-
const
|
|
16108
|
-
const parsed = parseUserRoots(
|
|
17176
|
+
const text8 = await deps.readFile(deps.filePath, "utf8");
|
|
17177
|
+
const parsed = parseUserRoots(text8);
|
|
16109
17178
|
return parsed.state.roots;
|
|
16110
17179
|
} catch {
|
|
16111
17180
|
return [];
|
|
@@ -16141,6 +17210,33 @@ function createSessionRootIndex(deps) {
|
|
|
16141
17210
|
};
|
|
16142
17211
|
}
|
|
16143
17212
|
|
|
17213
|
+
// src/host/integration/tools/mutation-guard.ts
|
|
17214
|
+
function mutationRefusal(what, context) {
|
|
17215
|
+
const policy = context.policy;
|
|
17216
|
+
if (policy === void 0) return void 0;
|
|
17217
|
+
if (policy.mode === "read-only") {
|
|
17218
|
+
return `\u5F53\u524D\u4F1A\u8BDD\u7684\u6C99\u7BB1\u6A21\u5F0F\u662F\u53EA\u8BFB\uFF08read-only\uFF09\uFF0C\u62D2\u7EDD\u5199\u5165\uFF1A${what}\u3002\u9700\u8981\u5199\u5165\u65F6\u5728\u5E73\u53F0\u91CC\u628A\u672C\u4F1A\u8BDD\u7684\u6A21\u5F0F\u6539\u4E3A workspace-write\uFF08\u6216\u5728\u5141\u8BB8\u7684\u573A\u666F\u4E0B\u7533\u8BF7\u5347\u7EA7\uFF09\u3002`;
|
|
17219
|
+
}
|
|
17220
|
+
if (policy.mode === "danger-full-access") return void 0;
|
|
17221
|
+
const area = context.rootArea?.() ?? "deny";
|
|
17222
|
+
if (area === "whole-root") return void 0;
|
|
17223
|
+
return `\u5F53\u524D\u4F1A\u8BDD\u7684\u6C99\u7BB1\u6A21\u5F0F\u662F workspace-write\uFF0C\u800C\u8FD9\u4E2A\u6839\u4E0D\u662F\u672C\u4F1A\u8BDD\u7684 workspace\u3001\u4E5F\u4E0D\u662F\u672C\u4F1A\u8BDD\u7528\u300C\u6DFB\u52A0\u76EE\u5F55\u300D\u6DFB\u52A0\u7684\u76EE\u5F55\uFF1A${what}\u3002\u5148\u628A\u8BE5\u76EE\u5F55\u6DFB\u52A0\u5230\u4F1A\u8BDD\uFF0C\u6216\u6539\u7528\u4F1A\u8BDD\u81EA\u5DF1\u7684\u5DE5\u4F5C\u76EE\u5F55\u3002`;
|
|
17224
|
+
}
|
|
17225
|
+
|
|
17226
|
+
// src/host/integration/tools/exec-write-roots.ts
|
|
17227
|
+
function execWriteRootsFor(input) {
|
|
17228
|
+
const paths = /* @__PURE__ */ new Set([normalizeRemotePath(input.root)]);
|
|
17229
|
+
for (const record2 of input.records) {
|
|
17230
|
+
const candidate = record2.root;
|
|
17231
|
+
if (candidate.location !== "remote") continue;
|
|
17232
|
+
if (candidate.connectionId !== input.connectionId) continue;
|
|
17233
|
+
if (candidate.writable === false) continue;
|
|
17234
|
+
const area = workspaceWriteArea(candidate, void 0, input.cwd, input.identity);
|
|
17235
|
+
if (area === "whole-root" || area === "workspace-only") paths.add(normalizeRemotePath(candidate.path));
|
|
17236
|
+
}
|
|
17237
|
+
return [...paths];
|
|
17238
|
+
}
|
|
17239
|
+
|
|
16144
17240
|
// src/host/integration/tools/remote-tools.ts
|
|
16145
17241
|
import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools";
|
|
16146
17242
|
|
|
@@ -16165,6 +17261,12 @@ function resolveSessionTarget(exec, seams) {
|
|
|
16165
17261
|
}
|
|
16166
17262
|
|
|
16167
17263
|
// src/host/integration/tools/remote-tools.ts
|
|
17264
|
+
function readConnectionIds(record2) {
|
|
17265
|
+
const raw = record2["connectionIds"];
|
|
17266
|
+
if (!Array.isArray(raw)) return void 0;
|
|
17267
|
+
const ids = [...new Set(raw.filter((item) => typeof item === "string" && item.trim() !== "").map((item) => item.trim()))];
|
|
17268
|
+
return ids.length === 0 ? void 0 : ids;
|
|
17269
|
+
}
|
|
16168
17270
|
function text2(value) {
|
|
16169
17271
|
return [{ type: "text", text: value }];
|
|
16170
17272
|
}
|
|
@@ -16303,7 +17405,7 @@ function remoteToolDefinitions(backend) {
|
|
|
16303
17405
|
}),
|
|
16304
17406
|
defineTool2({
|
|
16305
17407
|
name: "remote_exec",
|
|
16306
|
-
description: "Run one shell command on the remote host in the workspace this session is working in, and return its exit code, stdout and stderr. Output is bounded to 64 KiB per stream; when a stream is cut the result names that stream and, if the agent spilled it, the remote path holding the full text. Use this for remote builds, tests and version checks. Each call is a NEW shell: exported variables and `cd` do not carry over to the next call \u2014 pass `env` or put `source ... && cmd` in one command." + EXEC_SERIAL_NOTE,
|
|
17408
|
+
description: "Run one shell command on the remote host in the workspace this session is working in, and return its exit code, stdout and stderr. **A shell command is not confined by \u300C\u6DFB\u52A0\u76EE\u5F55\u300D**: it runs with the SSH user's own permissions, so it can read and write anything that user can, not only the directories added to this conversation. (A deployment that sets `limits.execWriteFence` puts a Landlock write fence around the workspace root and the directories this session added; the tool report says when that fence is active.) Output is bounded to 64 KiB per stream; when a stream is cut the result names that stream and, if the agent spilled it, the remote path holding the full text. Use this for remote builds, tests and version checks. Each call is a NEW shell: exported variables and `cd` do not carry over to the next call \u2014 pass `env` or put `source ... && cmd` in one command." + EXEC_SERIAL_NOTE,
|
|
16307
17409
|
parameters: {
|
|
16308
17410
|
command: { type: "string", description: "Shell command to run on the remote host.", required: true },
|
|
16309
17411
|
cwd: {
|
|
@@ -16318,6 +17420,11 @@ function remoteToolDefinitions(backend) {
|
|
|
16318
17420
|
timeoutMs: {
|
|
16319
17421
|
type: "number",
|
|
16320
17422
|
description: "Cooperative timeout budget in milliseconds (default 30000, hard cap 300000). When it expires the plugin stops waiting and abandons the call; the result then reports `timedOut: true`. It does not by itself prove the remote process was killed."
|
|
17423
|
+
},
|
|
17424
|
+
connectionIds: {
|
|
17425
|
+
type: "array",
|
|
17426
|
+
items: { type: "string" },
|
|
17427
|
+
description: 'Batch mode: run the same command on **each** of these connections instead of the session\'s own root, in parallel, and return per-connection results. Omit for the normal single-host call. Each connection resolves to the root that covers the session cwd (same longest-match rule as the single-host path); a connection with no covering root gets its own `error` entry and the others still run. Use this for "is it the same on every host" checks \u2014 a shell command is **not** idempotent, so do not use it to fan out a mutating command you cannot afford to run twice.'
|
|
16321
17428
|
}
|
|
16322
17429
|
},
|
|
16323
17430
|
output: {
|
|
@@ -16325,22 +17432,43 @@ function remoteToolDefinitions(backend) {
|
|
|
16325
17432
|
type: "object",
|
|
16326
17433
|
additionalProperties: false,
|
|
16327
17434
|
properties: {
|
|
16328
|
-
|
|
17435
|
+
batch: {
|
|
17436
|
+
type: "boolean",
|
|
17437
|
+
description: "Present and true when `connectionIds` was used; the per-host answers are in `results`."
|
|
17438
|
+
},
|
|
17439
|
+
results: {
|
|
17440
|
+
type: "array",
|
|
17441
|
+
description: "Batch mode only: one entry per requested connection, in the order asked.",
|
|
17442
|
+
items: {
|
|
17443
|
+
type: "object",
|
|
17444
|
+
additionalProperties: false,
|
|
17445
|
+
properties: {
|
|
17446
|
+
connectionId: { type: "string", required: true },
|
|
17447
|
+
error: { type: "string", description: "Why this host could not run (unknown connection, no covering root, transport failure)." },
|
|
17448
|
+
exitCode: { type: "integer" },
|
|
17449
|
+
timedOut: { type: "boolean" },
|
|
17450
|
+
stdout: { type: "string" },
|
|
17451
|
+
stderr: { type: "string" },
|
|
17452
|
+
durationMs: { type: "integer" },
|
|
17453
|
+
truncated: { type: "boolean" }
|
|
17454
|
+
}
|
|
17455
|
+
}
|
|
17456
|
+
},
|
|
17457
|
+
exitCode: { type: "integer" },
|
|
16329
17458
|
signal: {
|
|
16330
17459
|
type: "string",
|
|
16331
17460
|
description: "The signal that ended the command (e.g. `SIGKILL`, `timeout`). Absent when it exited on its own \u2014 this is how `exitCode = -1` becomes readable."
|
|
16332
17461
|
},
|
|
16333
17462
|
timedOut: {
|
|
16334
17463
|
type: "boolean",
|
|
16335
|
-
required: true,
|
|
16336
17464
|
description: "Whether this plugin stopped waiting because the timeout budget expired. True does not prove the remote process died; false does not rule out a signal (see `signal`)."
|
|
16337
17465
|
},
|
|
16338
|
-
stdout: { type: "string"
|
|
16339
|
-
stderr: { type: "string"
|
|
16340
|
-
durationMs: { type: "integer"
|
|
16341
|
-
truncated: { type: "boolean"
|
|
16342
|
-
stdoutTruncated: { type: "boolean"
|
|
16343
|
-
stderrTruncated: { type: "boolean"
|
|
17466
|
+
stdout: { type: "string" },
|
|
17467
|
+
stderr: { type: "string" },
|
|
17468
|
+
durationMs: { type: "integer" },
|
|
17469
|
+
truncated: { type: "boolean" },
|
|
17470
|
+
stdoutTruncated: { type: "boolean" },
|
|
17471
|
+
stderrTruncated: { type: "boolean" },
|
|
16344
17472
|
stdoutSpillPath: { type: "string" },
|
|
16345
17473
|
stderrSpillPath: { type: "string" },
|
|
16346
17474
|
cwdFailure: { type: "string" },
|
|
@@ -16351,11 +17479,46 @@ function remoteToolDefinitions(backend) {
|
|
|
16351
17479
|
hint: { type: "string", description: "Explanation for `remoteMayStillRun`, when present." }
|
|
16352
17480
|
}
|
|
16353
17481
|
},
|
|
16354
|
-
render: (_args, value) => text2(
|
|
17482
|
+
render: (_args, value) => text2(formatExecOrBatch(value))
|
|
16355
17483
|
},
|
|
16356
17484
|
async execute(args, exec) {
|
|
16357
17485
|
const parsed = parseExecArgs(args);
|
|
16358
17486
|
if ("error" in parsed) throw new Error(parsed.error);
|
|
17487
|
+
const batchIds = readConnectionIds(args ?? {});
|
|
17488
|
+
if (batchIds !== void 0) {
|
|
17489
|
+
if (backend.resolveForConnection === void 0) {
|
|
17490
|
+
throw new Error("\u8FD9\u4E2A\u7EC4\u5408\u4E0D\u652F\u6301\u6279\u91CF\u591A\u673A\uFF08\u7F3A\u5C11\u6309\u8FDE\u63A5\u89E3\u6790\u8FDC\u7AEF\u6839\u7684\u80FD\u529B\uFF09");
|
|
17491
|
+
}
|
|
17492
|
+
const sessionCwd = exec.agent?.session.header.cwd;
|
|
17493
|
+
if (sessionCwd === void 0 || sessionCwd === "") {
|
|
17494
|
+
throw new Error("\u6279\u91CF\u591A\u673A\u9700\u8981 agent \u7684\u4F1A\u8BDD\u5DE5\u4F5C\u76EE\u5F55\uFF0C\u7528\u6765\u5B9A\u4F4D\u6BCF\u4E2A\u8FDE\u63A5\u4E0A\u7684\u6839");
|
|
17495
|
+
}
|
|
17496
|
+
const resolveFor = backend.resolveForConnection.bind(backend);
|
|
17497
|
+
const results = await Promise.all(
|
|
17498
|
+
batchIds.map(async (connectionId) => {
|
|
17499
|
+
const target2 = resolveFor(connectionId, sessionCwd);
|
|
17500
|
+
if (target2 === void 0) {
|
|
17501
|
+
return { connectionId, error: `\u8BE5\u8FDE\u63A5\u4E0A\u6CA1\u6709\u8986\u76D6\u5F53\u524D\u5DE5\u4F5C\u76EE\u5F55 ${sessionCwd} \u7684\u8FDC\u7AEF\u6839` };
|
|
17502
|
+
}
|
|
17503
|
+
try {
|
|
17504
|
+
const outcome2 = await backend.exec(target2, { ...parsed, cwd: resolveOrThrow(target2, parsed.cwd) }, exec.signal);
|
|
17505
|
+
const flags2 = readExecTruncation(outcome2);
|
|
17506
|
+
return {
|
|
17507
|
+
connectionId,
|
|
17508
|
+
exitCode: outcome2.exitCode,
|
|
17509
|
+
timedOut: outcome2.timedOut === true,
|
|
17510
|
+
stdout: outcome2.stdout,
|
|
17511
|
+
stderr: outcome2.stderr,
|
|
17512
|
+
durationMs: outcome2.durationMs,
|
|
17513
|
+
truncated: flags2.truncated
|
|
17514
|
+
};
|
|
17515
|
+
} catch (error) {
|
|
17516
|
+
return { connectionId, error: error instanceof Error ? error.message : String(error) };
|
|
17517
|
+
}
|
|
17518
|
+
})
|
|
17519
|
+
);
|
|
17520
|
+
return { batch: true, results };
|
|
17521
|
+
}
|
|
16359
17522
|
const target = targetFor(backend, exec);
|
|
16360
17523
|
const cwd = resolveOrThrow(target, parsed.cwd);
|
|
16361
17524
|
const requestedCwd = parsed.cwd !== void 0;
|
|
@@ -16403,10 +17566,10 @@ function globHint(pattern, matchCount) {
|
|
|
16403
17566
|
const rule = anchored ? "\u542B `/` \u7684\u6A21\u5F0F\u951A\u5B9A\u5728\u4F60\u7ED9\u7684\u76EE\u5F55\u4E0A\uFF0C`*` \u4E0D\u8DE8\u5C42\u2014\u2014\u8981\u8DE8\u5C42\u8BF7\u7528 `**`" : "\u4E0D\u542B `/` \u7684\u6A21\u5F0F\u53EA\u5339\u914D\u6587\u4EF6\u540D\uFF08\u4EFB\u610F\u5C42\u7EA7\uFF09\uFF0C\u4E0D\u662F\u8DEF\u5F84\u5B50\u4E32\u2014\u2014\u8981\u8DE8\u5C42\u8BF7\u7528 `**` \u6216\u5E26\u4E0A\u76EE\u5F55";
|
|
16404
17567
|
return `0 \u6761\u7ED3\u679C\uFF1A\u6309 glob ${JSON.stringify(pattern)} \u6CA1\u6709\u5339\u914D\u5230\u8DEF\u5F84\u3002${rule}\u3002\u8FD4\u56DE\u7684\u8DEF\u5F84\u76F8\u5BF9\u679A\u4E3E\u76EE\u5F55\u3001\u4E0D\u5E26\u524D\u5BFC \`/\`\uFF1B\u6A21\u5F0F\u533A\u5206\u5927\u5C0F\u5199\u3002`;
|
|
16405
17568
|
}
|
|
16406
|
-
function cwdFailureHint(cwd,
|
|
17569
|
+
function cwdFailureHint(cwd, text8, failed, requested) {
|
|
16407
17570
|
if (!requested || !failed) return void 0;
|
|
16408
|
-
const forkExecShaped = /fork\/exec\s+\S*sh\b[^\n]*no such file or directory/i.test(
|
|
16409
|
-
const cdShaped = /\bcd:\s[^\n]*(no such file or directory|not a directory|permission denied)|can't cd to/i.test(
|
|
17571
|
+
const forkExecShaped = /fork\/exec\s+\S*sh\b[^\n]*no such file or directory/i.test(text8);
|
|
17572
|
+
const cdShaped = /\bcd:\s[^\n]*(no such file or directory|not a directory|permission denied)|can't cd to/i.test(text8);
|
|
16410
17573
|
if (!forkExecShaped && !cdShaped) return void 0;
|
|
16411
17574
|
const detail = forkExecShaped ? "\u8FDC\u7AEF\u62A5\u7684\u662F fork/exec \u2026/sh: no such file or directory \u2014\u2014 \u8FD9\u6761\u62A5\u9519\u6307\u5411 shell\uFF0C\u4F46\u771F\u6B63\u7684\u539F\u56E0\u51E0\u4E4E\u603B\u662F\u5DE5\u4F5C\u76EE\u5F55\u65E0\u6548\uFF08cwd \u65E0\u6548\u65F6 fork/exec \u5728 chdir \u9636\u6BB5\u5C31\u5931\u8D25\u4E86\uFF09\u3002" : "\u8FDC\u7AEF shell \u7684 cd \u5931\u8D25\u3002";
|
|
16412
17575
|
return `cwd \u4E0D\u5B58\u5728\u6216\u4E0D\u53EF\u8FDB\u5165\uFF1A${cwd}\u3002${detail}\u8BF7\u5148\u7528 remote_glob \u6216 remote_exec \u786E\u8BA4\u8BE5\u76EE\u5F55\u5B58\u5728\u3002`;
|
|
@@ -16457,6 +17620,26 @@ ${hint2}`;
|
|
|
16457
17620
|
${hint}`;
|
|
16458
17621
|
return `${outcome.paths.join("\n")}${semantics}${suffix}${note}`;
|
|
16459
17622
|
}
|
|
17623
|
+
function formatExecOrBatch(value) {
|
|
17624
|
+
if (value["batch"] === true && Array.isArray(value["results"])) {
|
|
17625
|
+
const lines = [];
|
|
17626
|
+
for (const raw of value["results"]) {
|
|
17627
|
+
const connectionId = String(raw["connectionId"] ?? "?");
|
|
17628
|
+
if (typeof raw["error"] === "string") {
|
|
17629
|
+
lines.push(`${connectionId}: \u5931\u8D25 \u2014 ${raw["error"]}`);
|
|
17630
|
+
continue;
|
|
17631
|
+
}
|
|
17632
|
+
const verdict = raw["timedOut"] === true ? "timeout" : `exit ${String(raw["exitCode"])}`;
|
|
17633
|
+
lines.push(`${connectionId}: ${verdict}`);
|
|
17634
|
+
const stdout = typeof raw["stdout"] === "string" ? raw["stdout"] : "";
|
|
17635
|
+
if (stdout !== "") lines.push(...stdout.split("\n").map((line) => ` ${line}`));
|
|
17636
|
+
const stderr = typeof raw["stderr"] === "string" ? raw["stderr"] : "";
|
|
17637
|
+
if (stderr !== "") lines.push(" [stderr]", ...stderr.split("\n").map((line) => ` ${line}`));
|
|
17638
|
+
}
|
|
17639
|
+
return lines.join("\n");
|
|
17640
|
+
}
|
|
17641
|
+
return formatExec(value);
|
|
17642
|
+
}
|
|
16460
17643
|
function formatExec(outcome) {
|
|
16461
17644
|
const stdout = boundOutput(outcome.stdout);
|
|
16462
17645
|
const stderr = boundOutput(outcome.stderr, MAX_OUTPUT_BYTES, "tail");
|
|
@@ -16590,7 +17773,7 @@ function toOutputView(output) {
|
|
|
16590
17773
|
timedOut: output.timedOut
|
|
16591
17774
|
};
|
|
16592
17775
|
}
|
|
16593
|
-
function
|
|
17776
|
+
function defaultSleep2(ms, signal) {
|
|
16594
17777
|
return new Promise((resolve3, reject) => {
|
|
16595
17778
|
if (signal?.aborted === true) {
|
|
16596
17779
|
reject(new SshDevError("CANCELLED", "\u7B49\u5F85\u540E\u53F0\u4F5C\u4E1A\u65F6\u88AB\u53D6\u6D88", {}));
|
|
@@ -16610,7 +17793,7 @@ function defaultSleep(ms, signal) {
|
|
|
16610
17793
|
function createRemoteJobBackend(deps) {
|
|
16611
17794
|
const pollIntervalMs = Math.max(1, Math.floor(deps.pollIntervalMs ?? DEFAULT_JOB_POLL_MS));
|
|
16612
17795
|
const now = deps.now ?? (() => Date.now());
|
|
16613
|
-
const sleep2 = deps.sleep ??
|
|
17796
|
+
const sleep2 = deps.sleep ?? defaultSleep2;
|
|
16614
17797
|
const require2 = async (target) => {
|
|
16615
17798
|
const transport = await deps.acquire(target.connectionId);
|
|
16616
17799
|
if (!isAgentJobCapable(transport)) throw unsupported(target.connectionId);
|
|
@@ -16625,10 +17808,12 @@ function createRemoteJobBackend(deps) {
|
|
|
16625
17808
|
...deps.resolveSessionRoot === void 0 ? {} : { resolveSessionRoot: deps.resolveSessionRoot },
|
|
16626
17809
|
async start(target, request, signal) {
|
|
16627
17810
|
const transport = await require2(target);
|
|
17811
|
+
const writeRoots = deps.execWriteRoots?.(target.connectionId, target.root);
|
|
16628
17812
|
const started = await transport.procStart(
|
|
16629
17813
|
{
|
|
16630
17814
|
script: request.command,
|
|
16631
17815
|
cwd: request.cwd,
|
|
17816
|
+
...writeRoots === void 0 ? {} : { writeRoots },
|
|
16632
17817
|
...request.env === void 0 ? {} : { env: request.env },
|
|
16633
17818
|
// `undefined` is **omitted**, not defaulted: the agent's own rule is "0 or absent means no limit",
|
|
16634
17819
|
// and sending a default here would silently impose a deadline on a long build.
|
|
@@ -16696,7 +17881,7 @@ function targetFor2(backend, exec) {
|
|
|
16696
17881
|
return resolveSessionTarget(exec, { resolve: (cwd) => backend.resolve(cwd), resolveSessionRoot: backend.resolveSessionRoot });
|
|
16697
17882
|
}
|
|
16698
17883
|
var PATH_SPELLING2 = "Accepts an absolute remote path inside the workspace root, or a path relative to the root. An absolute path outside the root is refused. `..` folds but never leaves the root.";
|
|
16699
|
-
var JOB_LIFETIME_NOTE = "
|
|
17884
|
+
var JOB_LIFETIME_NOTE = " Job lifetime depends on the transport, and the difference matters for long builds. **Tier 2 (the default) runs a resident daemon**: jobs belong to the *session*, not to the ssh channel, so a dropped connection does **not** kill a running job \u2014 reconnect and read it again with the same `jobId` and `offset`. **Tier 1 (pure POSIX) and `--stdio` mode** tie jobs to the connection: closing the channel kills them. A resident daemon is reclaimed only when it goes idle for its timeout or the process stops, and that reclaim kills its jobs \u2014 so do not rely on a job surviving a long idle gap.";
|
|
16700
17885
|
var JOB_POLL_NOTE = " Nothing is pushed: progress is observed by calling this tool, not by waiting for a notification.";
|
|
16701
17886
|
var JOB_DROP_NOTE = " A job keeps a bounded ring of its output that drops the OLDEST bytes; the dropped amount is reported in **bytes**, not lines, and there is no spill file to recover them from.";
|
|
16702
17887
|
function formatJobLine(job) {
|
|
@@ -16944,6 +18129,10 @@ function remoteJobToolDefinitions(backend) {
|
|
|
16944
18129
|
type: "number",
|
|
16945
18130
|
description: "`output` only: byte offset to start at (0-based, default 0). Pass the previous result's `nextOffset` to get only what is new."
|
|
16946
18131
|
},
|
|
18132
|
+
follow: {
|
|
18133
|
+
type: "boolean",
|
|
18134
|
+
description: "`output` only: return as soon as **new output arrives** (a tail), instead of waiting for the job to finish. Polls internally until `offset` grows, the job exits, or `timeoutMs` expires; the answer says which happened via `running` / `timedOut`. Use this to watch a build progress step by step: keep passing the previous `nextOffset`."
|
|
18135
|
+
},
|
|
16947
18136
|
wait: {
|
|
16948
18137
|
type: "boolean",
|
|
16949
18138
|
description: "`output` only: block until the job finishes or the timeout expires; a timed-out wait leaves the job running. Defaults to false."
|
|
@@ -17051,7 +18240,13 @@ function remoteJobToolDefinitions(backend) {
|
|
|
17051
18240
|
}
|
|
17052
18241
|
const offset = rawOffset === void 0 ? 0 : Math.floor(rawOffset);
|
|
17053
18242
|
const waited = record2["wait"] === true;
|
|
18243
|
+
const follow = record2["follow"] === true;
|
|
17054
18244
|
let waitedMs = 0;
|
|
18245
|
+
if (follow) {
|
|
18246
|
+
const timeoutMs = clampInt2(record2["timeoutMs"], DEFAULT_JOB_WAIT_MS, 1, MAX_JOB_WAIT_MS);
|
|
18247
|
+
const followed = await followOutput(backend, target, jobId, offset, timeoutMs, exec.signal);
|
|
18248
|
+
return { action: "output", ...followed.value, summary: formatJobOutput(followed.value) };
|
|
18249
|
+
}
|
|
17055
18250
|
if (waited) {
|
|
17056
18251
|
const timeoutMs = clampInt2(record2["timeoutMs"], DEFAULT_JOB_WAIT_MS, 1, MAX_JOB_WAIT_MS);
|
|
17057
18252
|
const settled = await backend.waitFor(target, jobId, timeoutMs, exec.signal);
|
|
@@ -17080,6 +18275,71 @@ function remoteJobToolDefinitions(backend) {
|
|
|
17080
18275
|
})
|
|
17081
18276
|
];
|
|
17082
18277
|
}
|
|
18278
|
+
var FOLLOW_POLL_MS = 250;
|
|
18279
|
+
async function followOutput(backend, target, jobId, offset, timeoutMs, signal) {
|
|
18280
|
+
const started = Date.now();
|
|
18281
|
+
for (; ; ) {
|
|
18282
|
+
const view = await backend.output(target, jobId, offset, signal);
|
|
18283
|
+
const grew = view.nextOffset > offset;
|
|
18284
|
+
const running = view.state === "running";
|
|
18285
|
+
if (grew || !running) {
|
|
18286
|
+
return {
|
|
18287
|
+
value: {
|
|
18288
|
+
jobId: view.id,
|
|
18289
|
+
state: view.state,
|
|
18290
|
+
data: view.data,
|
|
18291
|
+
nextOffset: view.nextOffset,
|
|
18292
|
+
droppedBytes: view.droppedBytes,
|
|
18293
|
+
totalBytes: view.totalBytes,
|
|
18294
|
+
lost: view.lost,
|
|
18295
|
+
exitCode: view.exitCode,
|
|
18296
|
+
...view.signal === void 0 ? {} : { signal: view.signal },
|
|
18297
|
+
timedOut: false,
|
|
18298
|
+
running,
|
|
18299
|
+
waitedMs: Date.now() - started,
|
|
18300
|
+
waited: false,
|
|
18301
|
+
followed: true
|
|
18302
|
+
}
|
|
18303
|
+
};
|
|
18304
|
+
}
|
|
18305
|
+
if (Date.now() - started >= timeoutMs) {
|
|
18306
|
+
return {
|
|
18307
|
+
value: {
|
|
18308
|
+
jobId: view.id,
|
|
18309
|
+
state: view.state,
|
|
18310
|
+
data: view.data,
|
|
18311
|
+
nextOffset: view.nextOffset,
|
|
18312
|
+
droppedBytes: view.droppedBytes,
|
|
18313
|
+
totalBytes: view.totalBytes,
|
|
18314
|
+
lost: view.lost,
|
|
18315
|
+
exitCode: view.exitCode,
|
|
18316
|
+
...view.signal === void 0 ? {} : { signal: view.signal },
|
|
18317
|
+
timedOut: true,
|
|
18318
|
+
running: true,
|
|
18319
|
+
waitedMs: Date.now() - started,
|
|
18320
|
+
waited: false,
|
|
18321
|
+
followed: true
|
|
18322
|
+
}
|
|
18323
|
+
};
|
|
18324
|
+
}
|
|
18325
|
+
if (signal.aborted) throw new SshDevError("CANCELLED", "\u8DDF\u968F\u8F93\u51FA\u5DF2\u53D6\u6D88", { jobId });
|
|
18326
|
+
await followWait(signal);
|
|
18327
|
+
}
|
|
18328
|
+
}
|
|
18329
|
+
function followWait(signal, ms = FOLLOW_POLL_MS) {
|
|
18330
|
+
if (signal.aborted) return Promise.resolve();
|
|
18331
|
+
return new Promise((resolve3) => {
|
|
18332
|
+
const onAbort = () => {
|
|
18333
|
+
clearTimeout(timer);
|
|
18334
|
+
resolve3();
|
|
18335
|
+
};
|
|
18336
|
+
const timer = setTimeout(() => {
|
|
18337
|
+
signal.removeEventListener("abort", onAbort);
|
|
18338
|
+
resolve3();
|
|
18339
|
+
}, ms);
|
|
18340
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
18341
|
+
});
|
|
18342
|
+
}
|
|
17083
18343
|
function resolveOrThrow2(target, path) {
|
|
17084
18344
|
const resolved = resolveUnderRoot(target.root, path ?? "", void 0, target.connectionId);
|
|
17085
18345
|
if (typeof resolved === "string") return resolved;
|
|
@@ -17134,8 +18394,8 @@ function createRemoteFileBackend(deps) {
|
|
|
17134
18394
|
rememberVersion(target, absolute, chunk.version);
|
|
17135
18395
|
return { text: chunk.text, size: chunk.size, truncated: chunk.truncated, version: chunk.version };
|
|
17136
18396
|
},
|
|
17137
|
-
async write(target, absolute,
|
|
17138
|
-
const outcome = await requireBackend(target).writeText(absolute,
|
|
18397
|
+
async write(target, absolute, text8, expectedVersion, signal) {
|
|
18398
|
+
const outcome = await requireBackend(target).writeText(absolute, text8, expectedVersion, signal);
|
|
17139
18399
|
rememberVersion(target, absolute, outcome.version);
|
|
17140
18400
|
return { bytes: outcome.bytes, version: outcome.version };
|
|
17141
18401
|
},
|
|
@@ -17213,7 +18473,8 @@ function resolveOrThrow3(target, path) {
|
|
|
17213
18473
|
if (typeof resolved === "string") return resolved;
|
|
17214
18474
|
throw new Error(resolved.error);
|
|
17215
18475
|
}
|
|
17216
|
-
function assertWritable(target, path) {
|
|
18476
|
+
function assertWritable(target, path, allow) {
|
|
18477
|
+
allow?.(target);
|
|
17217
18478
|
if (target.writable) return;
|
|
17218
18479
|
throw new Error(`\u8BE5\u6839\u88AB\u6CE8\u518C\u4E3A\u53EA\u8BFB\uFF1A\u62D2\u7EDD\u5199\u5165 ${path}\uFF08\u6839\uFF1A${target.root}\uFF09`);
|
|
17219
18480
|
}
|
|
@@ -17227,14 +18488,24 @@ function truncateLine(line, maxLineLength) {
|
|
|
17227
18488
|
function buildLineWindow(source, request) {
|
|
17228
18489
|
const offset = Math.max(1, Math.floor(request.offset));
|
|
17229
18490
|
const limit = Math.max(1, Math.floor(request.limit));
|
|
17230
|
-
const raw = source.split("\n");
|
|
17231
|
-
const lines = raw.length > 0 && raw[raw.length - 1] === "" ? raw.slice(0, -1) : raw;
|
|
17232
|
-
const totalLines = lines.length;
|
|
17233
18491
|
const selected = [];
|
|
17234
|
-
|
|
17235
|
-
|
|
18492
|
+
let lineNumber = 0;
|
|
18493
|
+
let start = 0;
|
|
18494
|
+
for (; ; ) {
|
|
18495
|
+
const newline = source.indexOf("\n", start);
|
|
18496
|
+
const end = newline === -1 ? source.length : newline;
|
|
18497
|
+
if (newline === -1 && end === start) break;
|
|
18498
|
+
lineNumber += 1;
|
|
18499
|
+
if (lineNumber >= offset && selected.length < limit) {
|
|
18500
|
+
selected.push({
|
|
18501
|
+
number: lineNumber,
|
|
18502
|
+
text: truncateLine(stripCarriageReturn(source.slice(start, end)), request.maxLineLength)
|
|
18503
|
+
});
|
|
18504
|
+
}
|
|
18505
|
+
if (newline === -1) break;
|
|
18506
|
+
start = newline + 1;
|
|
17236
18507
|
}
|
|
17237
|
-
return { lines: selected, totalLines };
|
|
18508
|
+
return { lines: selected, totalLines: lineNumber };
|
|
17238
18509
|
}
|
|
17239
18510
|
function formatRead(view) {
|
|
17240
18511
|
const endLine = view.lines.at(-1)?.number ?? Math.max(0, view.offset - 1);
|
|
@@ -17302,7 +18573,7 @@ function clampInt3(value, fallback, min, max) {
|
|
|
17302
18573
|
if (number < min) return min;
|
|
17303
18574
|
return Math.min(number, max);
|
|
17304
18575
|
}
|
|
17305
|
-
function remoteFileToolDefinitions(backend) {
|
|
18576
|
+
function remoteFileToolDefinitions(backend, allowMutation) {
|
|
17306
18577
|
return [
|
|
17307
18578
|
defineTool4({
|
|
17308
18579
|
name: "remote_read",
|
|
@@ -17436,7 +18707,7 @@ function remoteFileToolDefinitions(backend) {
|
|
|
17436
18707
|
if ("error" in parsedMode) throw new Error(parsedMode.error);
|
|
17437
18708
|
const target = targetFor3(backend, exec);
|
|
17438
18709
|
const path = resolveOrThrow3(target, requested);
|
|
17439
|
-
assertWritable(target, path);
|
|
18710
|
+
assertWritable(target, path, allowMutation);
|
|
17440
18711
|
let effective = expected ?? backend.recall(target, path);
|
|
17441
18712
|
if (effective === void 0 && record2["force"] !== true) {
|
|
17442
18713
|
const info = await backend.stat(target, path, exec.signal);
|
|
@@ -17510,7 +18781,7 @@ function remoteFileToolDefinitions(backend) {
|
|
|
17510
18781
|
}
|
|
17511
18782
|
const target = targetFor3(backend, exec);
|
|
17512
18783
|
const path = resolveOrThrow3(target, requested);
|
|
17513
|
-
assertWritable(target, path);
|
|
18784
|
+
assertWritable(target, path, allowMutation);
|
|
17514
18785
|
const effective = expected ?? backend.recall(target, path);
|
|
17515
18786
|
const fast = await backend.edit?.(target, path, old, replacement, all, effective, exec.signal);
|
|
17516
18787
|
if (fast !== void 0) {
|
|
@@ -17585,7 +18856,7 @@ function remoteFileToolDefinitions(backend) {
|
|
|
17585
18856
|
}
|
|
17586
18857
|
const target = targetFor3(backend, exec);
|
|
17587
18858
|
const path = resolveOrThrow3(target, requested);
|
|
17588
|
-
assertWritable(target, path);
|
|
18859
|
+
assertWritable(target, path, allowMutation);
|
|
17589
18860
|
const effective = expected ?? backend.recall(target, path);
|
|
17590
18861
|
const outcome = await backend.patch(target, path, diff, effective, exec.signal);
|
|
17591
18862
|
return { path, hunks: outcome.hunks, bytes: outcome.bytes, version: outcome.version };
|
|
@@ -17622,7 +18893,7 @@ function remoteFileToolDefinitions(backend) {
|
|
|
17622
18893
|
const from = resolveOrThrow3(target, rawFrom);
|
|
17623
18894
|
const to = resolveOrThrow3(target, rawTo);
|
|
17624
18895
|
if (from === to) throw new Error(`\u6E90\u4E0E\u76EE\u6807\u662F\u540C\u4E00\u4E2A\u8DEF\u5F84\uFF1A${from}`);
|
|
17625
|
-
assertWritable(target, to);
|
|
18896
|
+
assertWritable(target, to, allowMutation);
|
|
17626
18897
|
const createParents = record2["createParents"] !== false;
|
|
17627
18898
|
await backend.move(target, from, to, createParents, exec.signal);
|
|
17628
18899
|
return { from, to, createParents };
|
|
@@ -17672,8 +18943,8 @@ function remoteFileToolDefinitions(backend) {
|
|
|
17672
18943
|
})
|
|
17673
18944
|
];
|
|
17674
18945
|
}
|
|
17675
|
-
function registerRemoteFileTools(ctx, backend) {
|
|
17676
|
-
const disposers = remoteFileToolDefinitions(backend).map((definition) => ctx.tools.register(definition));
|
|
18946
|
+
function registerRemoteFileTools(ctx, backend, options) {
|
|
18947
|
+
const disposers = remoteFileToolDefinitions(backend, options?.allowMutation).map((definition) => ctx.tools.register(definition));
|
|
17677
18948
|
return () => {
|
|
17678
18949
|
for (const dispose of disposers.splice(0)) dispose();
|
|
17679
18950
|
};
|
|
@@ -17822,14 +19093,14 @@ var LspSession = class _LspSession {
|
|
|
17822
19093
|
#sentText = /* @__PURE__ */ new Map();
|
|
17823
19094
|
#sentTextBytes = 0;
|
|
17824
19095
|
static #SENT_TEXT_BUDGET_BYTES = 16 * 1024 * 1024;
|
|
17825
|
-
#rememberSentText(uri,
|
|
19096
|
+
#rememberSentText(uri, text8) {
|
|
17826
19097
|
const previous = this.#sentText.get(uri);
|
|
17827
19098
|
if (previous !== void 0) {
|
|
17828
19099
|
this.#sentTextBytes -= previous.length;
|
|
17829
19100
|
this.#sentText.delete(uri);
|
|
17830
19101
|
}
|
|
17831
|
-
this.#sentText.set(uri,
|
|
17832
|
-
this.#sentTextBytes +=
|
|
19102
|
+
this.#sentText.set(uri, text8);
|
|
19103
|
+
this.#sentTextBytes += text8.length;
|
|
17833
19104
|
while (this.#sentTextBytes > _LspSession.#SENT_TEXT_BUDGET_BYTES && this.#sentText.size > 1) {
|
|
17834
19105
|
const oldest = this.#sentText.keys().next();
|
|
17835
19106
|
if (oldest.done === true) break;
|
|
@@ -17910,11 +19181,11 @@ var LspSession = class _LspSession {
|
|
|
17910
19181
|
* than an error — "no diagnostics" and "did not answer" look the same to a caller that only wants to
|
|
17911
19182
|
* know whether the file is clean.
|
|
17912
19183
|
*/
|
|
17913
|
-
async diagnostics(uri,
|
|
17914
|
-
const unchanged = this.#sentText.get(uri) ===
|
|
19184
|
+
async diagnostics(uri, text8, signal, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
|
|
19185
|
+
const unchanged = this.#sentText.get(uri) === text8;
|
|
17915
19186
|
const cached2 = this.#diagnostics.get(uri);
|
|
17916
19187
|
if (unchanged && cached2 !== void 0) return { diagnostics: cached2, answered: true };
|
|
17917
|
-
this.openDocument(uri,
|
|
19188
|
+
this.openDocument(uri, text8);
|
|
17918
19189
|
this.#diagnostics.delete(uri);
|
|
17919
19190
|
const answered = await this.#waitForDiagnostics(uri, signal, timeoutMs);
|
|
17920
19191
|
return { diagnostics: this.#diagnostics.get(uri) ?? [], answered };
|
|
@@ -17925,16 +19196,16 @@ var LspSession = class _LspSession {
|
|
|
17925
19196
|
* Position-based queries (`hover`, `definition`) need this first: a server answers about documents it
|
|
17926
19197
|
* knows, and an unopened file gets an empty result rather than an error — which reads as "no symbol here".
|
|
17927
19198
|
*/
|
|
17928
|
-
openDocument(uri,
|
|
17929
|
-
if (this.#sentText.get(uri) ===
|
|
19199
|
+
openDocument(uri, text8) {
|
|
19200
|
+
if (this.#sentText.get(uri) === text8) return;
|
|
17930
19201
|
if (this.#opened.has(uri)) {
|
|
17931
|
-
this.notify("textDocument/didChange", { textDocument: { uri, version: this.#nextVersion(uri) }, contentChanges: [{ text:
|
|
17932
|
-
this.#rememberSentText(uri,
|
|
19202
|
+
this.notify("textDocument/didChange", { textDocument: { uri, version: this.#nextVersion(uri) }, contentChanges: [{ text: text8 }] });
|
|
19203
|
+
this.#rememberSentText(uri, text8);
|
|
17933
19204
|
return;
|
|
17934
19205
|
}
|
|
17935
|
-
this.notify("textDocument/didOpen", { textDocument: { uri, languageId: this.#languageId(uri), version: 1, text:
|
|
19206
|
+
this.notify("textDocument/didOpen", { textDocument: { uri, languageId: this.#languageId(uri), version: 1, text: text8 } });
|
|
17936
19207
|
this.#opened.add(uri);
|
|
17937
|
-
this.#rememberSentText(uri,
|
|
19208
|
+
this.#rememberSentText(uri, text8);
|
|
17938
19209
|
}
|
|
17939
19210
|
/** Diagnostics already received for a URI, without asking again. */
|
|
17940
19211
|
cachedDiagnostics(uri) {
|
|
@@ -18142,8 +19413,8 @@ var LspSession = class _LspSession {
|
|
|
18142
19413
|
}
|
|
18143
19414
|
if (method === "window/logMessage" || method === "window/showMessage") {
|
|
18144
19415
|
const record2 = asRecord3(params);
|
|
18145
|
-
const
|
|
18146
|
-
if (
|
|
19416
|
+
const text8 = typeof record2?.["message"] === "string" ? record2["message"] : "";
|
|
19417
|
+
if (text8 !== "") this.#options.onLog?.(text8);
|
|
18147
19418
|
}
|
|
18148
19419
|
}
|
|
18149
19420
|
/**
|
|
@@ -18453,8 +19724,535 @@ ${describeInstalled(available)}`);
|
|
|
18453
19724
|
};
|
|
18454
19725
|
}
|
|
18455
19726
|
|
|
18456
|
-
// src/host/integration/tools/
|
|
19727
|
+
// src/host/integration/tools/forward-tools.ts
|
|
19728
|
+
init_errors();
|
|
18457
19729
|
import { defineTool as defineTool5 } from "@deepseek-ai/dsh-tools";
|
|
19730
|
+
function createForwardToolBackend(acquire) {
|
|
19731
|
+
const records = /* @__PURE__ */ new Map();
|
|
19732
|
+
let sequence2 = 0;
|
|
19733
|
+
return {
|
|
19734
|
+
async start(connectionId, request, signal) {
|
|
19735
|
+
const transport = await acquire(connectionId);
|
|
19736
|
+
if (transport.forward === void 0) {
|
|
19737
|
+
throw new SshDevError(
|
|
19738
|
+
"REMOTE_UNSUPPORTED",
|
|
19739
|
+
`\u8FDE\u63A5 ${connectionId} \u4E0D\u652F\u6301\u7AEF\u53E3\u8F6C\u53D1\uFF08\u8BE5\u4F20\u8F93\u6CA1\u6709\u5B9E\u73B0 forward\uFF1B\u7CFB\u7EDF ssh \u7684 Tier 1/\u9ED8\u8BA4 Tier 2 \u652F\u6301\uFF0Cssh2 \u4F20\u8F93\u4E0D\u652F\u6301\uFF09`,
|
|
19740
|
+
{ connectionId }
|
|
19741
|
+
);
|
|
19742
|
+
}
|
|
19743
|
+
const handle = await transport.forward(request, signal);
|
|
19744
|
+
sequence2 += 1;
|
|
19745
|
+
const id = `fwd-${String(sequence2)}`;
|
|
19746
|
+
records.set(id, {
|
|
19747
|
+
record: {
|
|
19748
|
+
id,
|
|
19749
|
+
connectionId,
|
|
19750
|
+
localPort: handle.localPort,
|
|
19751
|
+
remoteHost: handle.remoteHost,
|
|
19752
|
+
remotePort: handle.remotePort
|
|
19753
|
+
},
|
|
19754
|
+
handle
|
|
19755
|
+
});
|
|
19756
|
+
handle.onClosed?.(() => {
|
|
19757
|
+
records.delete(id);
|
|
19758
|
+
});
|
|
19759
|
+
return handle;
|
|
19760
|
+
},
|
|
19761
|
+
async stop(forwardId) {
|
|
19762
|
+
const entry = records.get(forwardId);
|
|
19763
|
+
if (entry === void 0) return false;
|
|
19764
|
+
records.delete(forwardId);
|
|
19765
|
+
await entry.handle.close();
|
|
19766
|
+
return true;
|
|
19767
|
+
},
|
|
19768
|
+
list: () => [...records.values()].map((entry) => entry.record),
|
|
19769
|
+
async closeAll() {
|
|
19770
|
+
const entries = [...records.values()];
|
|
19771
|
+
records.clear();
|
|
19772
|
+
await Promise.all(entries.map((entry) => entry.handle.close().catch(() => void 0)));
|
|
19773
|
+
}
|
|
19774
|
+
};
|
|
19775
|
+
}
|
|
19776
|
+
function formatResult(value) {
|
|
19777
|
+
if (Array.isArray(value["forwards"])) {
|
|
19778
|
+
const forwards = value["forwards"];
|
|
19779
|
+
if (forwards.length === 0) return "\u5F53\u524D\u6CA1\u6709\u8F6C\u53D1\u3002";
|
|
19780
|
+
return forwards.map((forward) => `${forward.id} http://127.0.0.1:${String(forward.localPort)} -> ${forward.remoteHost}:${String(forward.remotePort)} (${forward.connectionId})`).join("\n");
|
|
19781
|
+
}
|
|
19782
|
+
if (typeof value["localPort"] === "number") {
|
|
19783
|
+
return `\u5DF2\u8F6C\u53D1\uFF1Ahttp://127.0.0.1:${String(value["localPort"])} -> ${String(value["remoteHost"])}:${String(value["remotePort"])}\uFF08id ${String(value["forwardId"])}\uFF0C\u7528\u5B8C\u8BF7 stop\uFF09`;
|
|
19784
|
+
}
|
|
19785
|
+
return String(value["summary"] ?? "");
|
|
19786
|
+
}
|
|
19787
|
+
function text5(value) {
|
|
19788
|
+
return [{ type: "text", text: value }];
|
|
19789
|
+
}
|
|
19790
|
+
function remoteForwardDefinition(backend) {
|
|
19791
|
+
return defineTool5({
|
|
19792
|
+
name: "remote_forward",
|
|
19793
|
+
description: "Forward one remote TCP port to a local one, so a service the agent started on the remote host becomes reachable from this machine (and from the DSH browser sidebar). Typical use: start a dev server with `remote_exec_background`, then `remote_forward` its port and open the returned `http://127.0.0.1:<port>` URL. The local listener binds **127.0.0.1 only** \u2014 never the LAN; open it up via the host sshd's `GatewayPorts` if that is really what you want. `start` returns the real local port (auto-picked when omitted) plus a `forwardId`; `list` shows what is live; `stop` closes one and is idempotent. Forwards live as long as the connection and are closed automatically when the plugin unloads, so a forgotten one does not leak a port forever \u2014 but stop the ones you are done with.",
|
|
19794
|
+
parameters: {
|
|
19795
|
+
action: {
|
|
19796
|
+
type: "string",
|
|
19797
|
+
enum: ["start", "stop", "list"],
|
|
19798
|
+
description: "`start` opens a forward, `stop` closes one, `list` shows the live ones.",
|
|
19799
|
+
required: true
|
|
19800
|
+
},
|
|
19801
|
+
connectionId: { type: "string", description: "`start` only: which configured connection to forward through." },
|
|
19802
|
+
remoteHost: {
|
|
19803
|
+
type: "string",
|
|
19804
|
+
description: "`start` only: the host to connect to **from the remote side** (`localhost` means the remote host itself, which is the common case). IPv6 must be bracketed, e.g. `[::1]`."
|
|
19805
|
+
},
|
|
19806
|
+
remotePort: { type: "integer", description: "`start` only: the port on that remote host (1..65535)." },
|
|
19807
|
+
localPort: { type: "integer", description: "`start` only: requested local port; omit to let the system pick a free one." },
|
|
19808
|
+
forwardId: { type: "string", description: "`stop` only: the id returned by `start`." }
|
|
19809
|
+
},
|
|
19810
|
+
output: {
|
|
19811
|
+
schema: {
|
|
19812
|
+
type: "object",
|
|
19813
|
+
additionalProperties: false,
|
|
19814
|
+
properties: {
|
|
19815
|
+
action: { type: "string", required: true },
|
|
19816
|
+
forwardId: { type: "string" },
|
|
19817
|
+
localPort: { type: "integer" },
|
|
19818
|
+
remoteHost: { type: "string" },
|
|
19819
|
+
remotePort: { type: "integer" },
|
|
19820
|
+
connectionId: { type: "string" },
|
|
19821
|
+
stopped: { type: "boolean", description: "`stop`: whether a forward was actually closed (false = already gone)." },
|
|
19822
|
+
forwards: {
|
|
19823
|
+
type: "array",
|
|
19824
|
+
description: "`list` only.",
|
|
19825
|
+
items: {
|
|
19826
|
+
type: "object",
|
|
19827
|
+
additionalProperties: false,
|
|
19828
|
+
properties: {
|
|
19829
|
+
id: { type: "string", required: true },
|
|
19830
|
+
connectionId: { type: "string", required: true },
|
|
19831
|
+
localPort: { type: "integer", required: true },
|
|
19832
|
+
remoteHost: { type: "string", required: true },
|
|
19833
|
+
remotePort: { type: "integer", required: true }
|
|
19834
|
+
}
|
|
19835
|
+
}
|
|
19836
|
+
},
|
|
19837
|
+
summary: { type: "string", required: true }
|
|
19838
|
+
}
|
|
19839
|
+
},
|
|
19840
|
+
render: (_args, value) => text5(formatResult(value))
|
|
19841
|
+
},
|
|
19842
|
+
async execute(args, exec) {
|
|
19843
|
+
const record2 = args ?? {};
|
|
19844
|
+
const action = record2["action"];
|
|
19845
|
+
if (action === "list") {
|
|
19846
|
+
const forwards = backend.list();
|
|
19847
|
+
return { action, forwards: [...forwards], summary: formatResult({ forwards }) };
|
|
19848
|
+
}
|
|
19849
|
+
if (action === "stop") {
|
|
19850
|
+
const forwardId = typeof record2["forwardId"] === "string" ? record2["forwardId"] : "";
|
|
19851
|
+
if (forwardId === "") throw new Error("stop \u9700\u8981 forwardId");
|
|
19852
|
+
const stopped = await backend.stop(forwardId);
|
|
19853
|
+
return {
|
|
19854
|
+
action,
|
|
19855
|
+
stopped,
|
|
19856
|
+
summary: stopped ? `\u5DF2\u5173\u95ED ${forwardId}\u3002` : `${forwardId} \u5DF2\u7ECF\u4E0D\u5B58\u5728\uFF08stop \u662F\u5E42\u7B49\u7684\uFF09\u3002`
|
|
19857
|
+
};
|
|
19858
|
+
}
|
|
19859
|
+
if (action !== "start") throw new Error("action \u5FC5\u987B\u662F start / stop / list");
|
|
19860
|
+
const connectionId = typeof record2["connectionId"] === "string" ? record2["connectionId"] : "";
|
|
19861
|
+
if (connectionId === "") throw new Error("start \u9700\u8981 connectionId");
|
|
19862
|
+
const remoteHost = typeof record2["remoteHost"] === "string" ? record2["remoteHost"] : "localhost";
|
|
19863
|
+
const remotePort = record2["remotePort"];
|
|
19864
|
+
if (typeof remotePort !== "number" || !Number.isInteger(remotePort)) throw new Error("remotePort \u5FC5\u987B\u662F\u6574\u6570");
|
|
19865
|
+
const localPort = record2["localPort"];
|
|
19866
|
+
const handle = await backend.start(
|
|
19867
|
+
connectionId,
|
|
19868
|
+
{
|
|
19869
|
+
remoteHost,
|
|
19870
|
+
remotePort,
|
|
19871
|
+
...typeof localPort === "number" && Number.isInteger(localPort) ? { localPort } : {}
|
|
19872
|
+
},
|
|
19873
|
+
exec.signal
|
|
19874
|
+
);
|
|
19875
|
+
const created = backend.list().find((entry) => entry.localPort === handle.localPort && entry.connectionId === connectionId);
|
|
19876
|
+
const value = {
|
|
19877
|
+
action,
|
|
19878
|
+
...created === void 0 ? {} : { forwardId: created.id },
|
|
19879
|
+
localPort: handle.localPort,
|
|
19880
|
+
remoteHost: handle.remoteHost,
|
|
19881
|
+
remotePort: handle.remotePort,
|
|
19882
|
+
connectionId
|
|
19883
|
+
};
|
|
19884
|
+
return { ...value, summary: formatResult(value) };
|
|
19885
|
+
}
|
|
19886
|
+
});
|
|
19887
|
+
}
|
|
19888
|
+
function registerRemoteForwardTool(ctx, backend) {
|
|
19889
|
+
return ctx.tools.register(remoteForwardDefinition(backend));
|
|
19890
|
+
}
|
|
19891
|
+
|
|
19892
|
+
// src/host/integration/tools/remote-git-tools.ts
|
|
19893
|
+
init_shell();
|
|
19894
|
+
import { defineTool as defineTool6 } from "@deepseek-ai/dsh-tools";
|
|
19895
|
+
var READ_ACTIONS = ["status", "diff", "log", "show", "branch"];
|
|
19896
|
+
var WRITE_ACTIONS = ["add", "commit", "checkout", "restore", "stash"];
|
|
19897
|
+
var ACTIONS = [...READ_ACTIONS, ...WRITE_ACTIONS];
|
|
19898
|
+
var GIT_ENV = {
|
|
19899
|
+
// 缺凭据时立刻失败。没有这一条,`git pull` 能挂到超时,而 agent 会以为是网络慢。
|
|
19900
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
19901
|
+
GIT_ASKPASS: "echo",
|
|
19902
|
+
// 分页器在非 TTY 下本应自动关闭,但用户的 `core.pager` 配置可能强制打开。
|
|
19903
|
+
GIT_PAGER: "cat",
|
|
19904
|
+
// 输出不随用户的 locale 变(`git status` 的文案会被翻译,parsed 会全错)。
|
|
19905
|
+
LC_ALL: "C",
|
|
19906
|
+
// `-z` 之外的中文文件名:不转义成 `\346\226\207`,否则路径对不上 root 内的真实文件。
|
|
19907
|
+
GIT_CONFIG_PARAMETERS: "'core.quotepath=false'"
|
|
19908
|
+
};
|
|
19909
|
+
var MAX_LINES_DEFAULT = 400;
|
|
19910
|
+
var DEFAULT_TIMEOUT_MS3 = 6e4;
|
|
19911
|
+
var PORCELAIN_CODE = /^[ MADRCUT?!][ MADRCUT?!]$/;
|
|
19912
|
+
function capLines(text8, maxLines) {
|
|
19913
|
+
if (maxLines <= 0) return { text: "", truncated: text8.length > 0 };
|
|
19914
|
+
const lines = text8.split("\n");
|
|
19915
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
19916
|
+
if (lines.length <= maxLines) return { text: lines.join("\n"), truncated: false };
|
|
19917
|
+
return { text: lines.slice(0, maxLines).join("\n"), truncated: true };
|
|
19918
|
+
}
|
|
19919
|
+
function parseStatus(stdout) {
|
|
19920
|
+
const entries = [];
|
|
19921
|
+
let branch;
|
|
19922
|
+
for (const rawLine of stdout.split("\n")) {
|
|
19923
|
+
const line = rawLine.replace(/\r$/, "");
|
|
19924
|
+
if (line === "") continue;
|
|
19925
|
+
if (line.startsWith("## ")) {
|
|
19926
|
+
branch = parseBranchHeader(line.slice(3));
|
|
19927
|
+
continue;
|
|
19928
|
+
}
|
|
19929
|
+
if (line.length < 3) continue;
|
|
19930
|
+
const code = line.slice(0, 2);
|
|
19931
|
+
if (!PORCELAIN_CODE.test(code)) continue;
|
|
19932
|
+
const rest = line.slice(3);
|
|
19933
|
+
if (code.includes("R") || code.includes("C")) {
|
|
19934
|
+
const arrow = rest.lastIndexOf(" -> ");
|
|
19935
|
+
if (arrow >= 0) {
|
|
19936
|
+
entries.push({
|
|
19937
|
+
code,
|
|
19938
|
+
path: rest.slice(arrow + 4),
|
|
19939
|
+
from: rest.slice(0, arrow),
|
|
19940
|
+
staged: code[0] !== " " && code[0] !== "?",
|
|
19941
|
+
unstaged: code[1] !== " " && code[1] !== "?",
|
|
19942
|
+
untracked: false
|
|
19943
|
+
});
|
|
19944
|
+
continue;
|
|
19945
|
+
}
|
|
19946
|
+
}
|
|
19947
|
+
const untracked = code === "??";
|
|
19948
|
+
entries.push({
|
|
19949
|
+
code,
|
|
19950
|
+
path: rest,
|
|
19951
|
+
staged: !untracked && code[0] !== " " && code[0] !== "?",
|
|
19952
|
+
unstaged: !untracked && code[1] !== void 0 && code[1] !== " ",
|
|
19953
|
+
untracked
|
|
19954
|
+
});
|
|
19955
|
+
}
|
|
19956
|
+
return { ...branch === void 0 ? {} : { branch }, entries };
|
|
19957
|
+
}
|
|
19958
|
+
function parseBranchHeader(header) {
|
|
19959
|
+
const noBranch = /^HEAD \(no branch\)$/.exec(header);
|
|
19960
|
+
if (noBranch !== null) return { branch: "HEAD", detached: true, ahead: 0, behind: 0 };
|
|
19961
|
+
const trackMatch = /^(.+?)(?:\.\.\.(\S+))?(?:\s+\[(.+)\])?$/.exec(header);
|
|
19962
|
+
if (trackMatch === null) return { branch: header.trim(), detached: false, ahead: 0, behind: 0 };
|
|
19963
|
+
const [, branch = header, upstream, counts] = trackMatch;
|
|
19964
|
+
let ahead = 0;
|
|
19965
|
+
let behind = 0;
|
|
19966
|
+
for (const part of (counts ?? "").split(",")) {
|
|
19967
|
+
const trimmed = part.trim();
|
|
19968
|
+
const aheadMatch = /^ahead (\d+)$/.exec(trimmed);
|
|
19969
|
+
if (aheadMatch !== null) ahead = Number(aheadMatch[1]);
|
|
19970
|
+
const behindMatch = /^behind (\d+)$/.exec(trimmed);
|
|
19971
|
+
if (behindMatch !== null) behind = Number(behindMatch[1]);
|
|
19972
|
+
}
|
|
19973
|
+
return {
|
|
19974
|
+
branch: branch.trim(),
|
|
19975
|
+
detached: false,
|
|
19976
|
+
ahead,
|
|
19977
|
+
behind,
|
|
19978
|
+
...upstream === void 0 ? {} : { upstream }
|
|
19979
|
+
};
|
|
19980
|
+
}
|
|
19981
|
+
function parseLog(stdout) {
|
|
19982
|
+
const entries = [];
|
|
19983
|
+
for (const line of stdout.split("\n")) {
|
|
19984
|
+
if (line.trim() === "") continue;
|
|
19985
|
+
const [hash, shortHash, author, date, subject] = line.split("");
|
|
19986
|
+
if (hash === void 0 || shortHash === void 0) continue;
|
|
19987
|
+
entries.push({
|
|
19988
|
+
hash,
|
|
19989
|
+
shortHash,
|
|
19990
|
+
author: author ?? "",
|
|
19991
|
+
date: date ?? "",
|
|
19992
|
+
subject: subject ?? ""
|
|
19993
|
+
});
|
|
19994
|
+
}
|
|
19995
|
+
return entries;
|
|
19996
|
+
}
|
|
19997
|
+
function buildGitArgv(action, axis) {
|
|
19998
|
+
switch (action) {
|
|
19999
|
+
case "status":
|
|
20000
|
+
return ["git", "status", "--porcelain=v1", "--branch", "--untracked-files=normal"];
|
|
20001
|
+
case "diff": {
|
|
20002
|
+
const argv = ["git", "diff", "--no-color", "--no-ext-diff"];
|
|
20003
|
+
if (axis.staged === true) argv.push("--staged");
|
|
20004
|
+
if (axis.ref !== void 0) argv.push(axis.ref);
|
|
20005
|
+
if (axis.path !== void 0) argv.push("--", axis.path);
|
|
20006
|
+
return argv;
|
|
20007
|
+
}
|
|
20008
|
+
case "log": {
|
|
20009
|
+
const argv = [
|
|
20010
|
+
"git",
|
|
20011
|
+
"log",
|
|
20012
|
+
"--no-color",
|
|
20013
|
+
`--pretty=format:%H%x1f%h%x1f%an%x1f%aI%x1f%s`,
|
|
20014
|
+
"-n",
|
|
20015
|
+
String(Math.max(1, Math.min(500, axis.maxCount ?? 20)))
|
|
20016
|
+
];
|
|
20017
|
+
if (axis.path !== void 0) argv.push("--", axis.path);
|
|
20018
|
+
else if (axis.ref !== void 0) argv.push(axis.ref);
|
|
20019
|
+
return argv;
|
|
20020
|
+
}
|
|
20021
|
+
case "show": {
|
|
20022
|
+
if (axis.ref === void 0) throw new Error("show \u9700\u8981 ref");
|
|
20023
|
+
return ["git", "show", "--no-color", "--no-ext-diff", axis.ref];
|
|
20024
|
+
}
|
|
20025
|
+
case "branch":
|
|
20026
|
+
return ["git", "branch", "--no-color", "--format=%(HEAD) %(refname:short) %(upstream:short)"];
|
|
20027
|
+
case "add": {
|
|
20028
|
+
if (axis.all === true) return ["git", "add", "--all"];
|
|
20029
|
+
const files = axis.files ?? (axis.path === void 0 ? [] : [axis.path]);
|
|
20030
|
+
if (files.length === 0) throw new Error("add \u9700\u8981 files \u6216 all: true");
|
|
20031
|
+
return ["git", "add", "--", ...files];
|
|
20032
|
+
}
|
|
20033
|
+
case "commit": {
|
|
20034
|
+
if (axis.message === void 0 || axis.message.trim() === "") throw new Error("commit \u9700\u8981 message");
|
|
20035
|
+
return ["git", "commit", "--no-verify", "-m", axis.message];
|
|
20036
|
+
}
|
|
20037
|
+
case "checkout": {
|
|
20038
|
+
if (axis.ref === void 0) throw new Error("checkout \u9700\u8981 ref");
|
|
20039
|
+
const argv = ["git", "checkout", axis.ref];
|
|
20040
|
+
if (axis.path !== void 0) argv.push("--", axis.path);
|
|
20041
|
+
return argv;
|
|
20042
|
+
}
|
|
20043
|
+
case "restore": {
|
|
20044
|
+
const argv = ["git", "restore"];
|
|
20045
|
+
if (axis.staged === true) argv.push("--staged");
|
|
20046
|
+
const files = axis.files ?? (axis.path === void 0 ? [] : [axis.path]);
|
|
20047
|
+
if (files.length === 0) throw new Error("restore \u9700\u8981 files \u6216 path\uFF08\u4E0D\u63D0\u4F9B\u5C31\u662F\u6574\u68F5\u6811\u7684\u7834\u574F\u6027\u64CD\u4F5C\uFF0C\u4E0D\u652F\u6301\uFF09");
|
|
20048
|
+
argv.push("--", ...files);
|
|
20049
|
+
return argv;
|
|
20050
|
+
}
|
|
20051
|
+
case "stash": {
|
|
20052
|
+
const sub = axis.stashAction ?? "list";
|
|
20053
|
+
if (sub !== "list" && sub !== "push" && sub !== "pop" && sub !== "apply") {
|
|
20054
|
+
throw new Error(`stash \u7684 stashAction \u53EA\u63A5\u53D7 list / push / pop / apply\uFF0C\u6536\u5230 ${sub}`);
|
|
20055
|
+
}
|
|
20056
|
+
const argv = ["git", "stash", sub];
|
|
20057
|
+
if (sub === "push" && axis.message !== void 0) argv.push("-m", axis.message);
|
|
20058
|
+
return argv;
|
|
20059
|
+
}
|
|
20060
|
+
default: {
|
|
20061
|
+
const exhaustive = action;
|
|
20062
|
+
throw new Error(`\u672A\u77E5\u7684 git \u52A8\u4F5C\uFF1A${String(exhaustive)}`);
|
|
20063
|
+
}
|
|
20064
|
+
}
|
|
20065
|
+
}
|
|
20066
|
+
function formatOutcome(value) {
|
|
20067
|
+
const parts = [];
|
|
20068
|
+
if (Array.isArray(value["log"])) {
|
|
20069
|
+
for (const entry of value["log"]) parts.push(`${entry.shortHash} ${entry.date} ${entry.author} ${entry.subject}`);
|
|
20070
|
+
}
|
|
20071
|
+
if (Array.isArray(value["entries"])) {
|
|
20072
|
+
for (const entry of value["entries"]) parts.push(`${entry.code} ${entry.path}`);
|
|
20073
|
+
}
|
|
20074
|
+
if (typeof value["diff"] === "string" && value["diff"] !== "") parts.push(value["diff"]);
|
|
20075
|
+
const body = parts.length > 0 ? parts.join("\n") : value["stdout"];
|
|
20076
|
+
const suffix = value["truncated"] === true ? "\n[\u8F93\u51FA\u5DF2\u622A\u65AD]" : "";
|
|
20077
|
+
return `${value["action"]} \u2192 exit ${value["exitCode"]}
|
|
20078
|
+
${body}${suffix}`;
|
|
20079
|
+
}
|
|
20080
|
+
function remoteGitDefinition(backend) {
|
|
20081
|
+
return defineTool6({
|
|
20082
|
+
name: "remote_git",
|
|
20083
|
+
description: "Run a git operation on the remote repository this session is working in, and return its **parsed** result instead of raw terminal text. Prefer this over `remote_exec git ... : the parse is done here (branch/ahead/behind, status entries, log entries), so a new git version's wording cannot break your reading. Read-only actions: `status`, `diff`, `log`, `show`, `branch`. Mutating actions: `add`, `commit`, `checkout`, `restore`, `stash` \u2014 these change the user's repository, so state the intent in your message first. Deliberately **not** offered: `reset --hard`, `clean -fd`, `push --force`, `checkout .`. Those have no safe preview; use `remote_exec` explicitly if you really need one. Git is run with `GIT_TERMINAL_PROMPT=0`, so a missing credential **fails** instead of hanging on a password prompt. Read-only actions may overlap with other read-only remote calls.",
|
|
20084
|
+
parameters: {
|
|
20085
|
+
action: {
|
|
20086
|
+
type: "string",
|
|
20087
|
+
enum: [...ACTIONS],
|
|
20088
|
+
description: "Which git operation. Read-only: status/diff/log/show/branch. Mutating: add/commit/checkout/restore/stash.",
|
|
20089
|
+
required: true
|
|
20090
|
+
},
|
|
20091
|
+
cwd: {
|
|
20092
|
+
type: "string",
|
|
20093
|
+
description: "Directory inside the remote repository. Defaults to the workspace root; git itself walks up to find the repo."
|
|
20094
|
+
},
|
|
20095
|
+
path: { type: "string", description: "Repo-relative path to narrow the operation (diff/log/checkout/restore/add)." },
|
|
20096
|
+
files: {
|
|
20097
|
+
type: "array",
|
|
20098
|
+
items: { type: "string" },
|
|
20099
|
+
description: "Explicit repo-relative paths for add/restore. Mutually redundant with `path`; prefer this when touching several files."
|
|
20100
|
+
},
|
|
20101
|
+
ref: { type: "string", description: "A revision for diff/show/checkout (branch, tag, `HEAD~2`, a hash)." },
|
|
20102
|
+
message: { type: "string", description: "Commit message for `commit`, or the stash message for `stash push`." },
|
|
20103
|
+
staged: { type: "boolean", description: "diff/restore only: operate on the index instead of the working tree." },
|
|
20104
|
+
all: { type: "boolean", description: "add only: stage every change (`git add --all`), including deletions." },
|
|
20105
|
+
maxCount: { type: "integer", description: "log only: how many entries (1\u2013500, default 20)." },
|
|
20106
|
+
stashAction: { type: "string", enum: ["list", "push", "pop", "apply"], description: "stash only: sub-action, default `list`." },
|
|
20107
|
+
maxLines: { type: "integer", description: `Cap on returned lines per stream (default ${MAX_LINES_DEFAULT}).` },
|
|
20108
|
+
timeoutMs: { type: "integer", description: `Cooperative budget in ms (default ${DEFAULT_TIMEOUT_MS3}); large repositories can be slow.` }
|
|
20109
|
+
},
|
|
20110
|
+
output: {
|
|
20111
|
+
schema: {
|
|
20112
|
+
type: "object",
|
|
20113
|
+
additionalProperties: false,
|
|
20114
|
+
properties: {
|
|
20115
|
+
action: { type: "string", required: true },
|
|
20116
|
+
exitCode: { type: "integer", required: true },
|
|
20117
|
+
stdout: { type: "string", required: true },
|
|
20118
|
+
stderr: { type: "string", required: true },
|
|
20119
|
+
durationMs: { type: "integer", required: true },
|
|
20120
|
+
truncated: { type: "boolean", required: true, description: "Whether a stream hit `maxLines`." },
|
|
20121
|
+
branch: {
|
|
20122
|
+
type: "object",
|
|
20123
|
+
additionalProperties: false,
|
|
20124
|
+
description: "status only: the parsed `##` header.",
|
|
20125
|
+
properties: {
|
|
20126
|
+
branch: { type: "string", required: true },
|
|
20127
|
+
detached: { type: "boolean", required: true },
|
|
20128
|
+
ahead: { type: "integer", required: true },
|
|
20129
|
+
behind: { type: "integer", required: true },
|
|
20130
|
+
upstream: { type: "string" }
|
|
20131
|
+
}
|
|
20132
|
+
},
|
|
20133
|
+
entries: {
|
|
20134
|
+
type: "array",
|
|
20135
|
+
description: "status only: parsed porcelain entries.",
|
|
20136
|
+
items: {
|
|
20137
|
+
type: "object",
|
|
20138
|
+
additionalProperties: false,
|
|
20139
|
+
properties: {
|
|
20140
|
+
code: { type: "string", required: true },
|
|
20141
|
+
path: { type: "string", required: true },
|
|
20142
|
+
from: { type: "string" },
|
|
20143
|
+
staged: { type: "boolean", required: true },
|
|
20144
|
+
unstaged: { type: "boolean", required: true },
|
|
20145
|
+
untracked: { type: "boolean", required: true }
|
|
20146
|
+
}
|
|
20147
|
+
}
|
|
20148
|
+
},
|
|
20149
|
+
log: {
|
|
20150
|
+
type: "array",
|
|
20151
|
+
description: "log only: parsed entries, newest first.",
|
|
20152
|
+
items: {
|
|
20153
|
+
type: "object",
|
|
20154
|
+
additionalProperties: false,
|
|
20155
|
+
properties: {
|
|
20156
|
+
hash: { type: "string", required: true },
|
|
20157
|
+
shortHash: { type: "string", required: true },
|
|
20158
|
+
author: { type: "string", required: true },
|
|
20159
|
+
date: { type: "string", required: true },
|
|
20160
|
+
subject: { type: "string", required: true }
|
|
20161
|
+
}
|
|
20162
|
+
}
|
|
20163
|
+
},
|
|
20164
|
+
branches: { type: "array", items: { type: "string" }, description: "branch only: one line per local branch." },
|
|
20165
|
+
diff: { type: "string", description: "diff/show only: the patch text (already line-capped)." }
|
|
20166
|
+
}
|
|
20167
|
+
},
|
|
20168
|
+
render: (_args, value) => text6(formatOutcome(value))
|
|
20169
|
+
},
|
|
20170
|
+
async execute(args, exec) {
|
|
20171
|
+
const record2 = args ?? {};
|
|
20172
|
+
const action = readAction(record2["action"]);
|
|
20173
|
+
const maxLines = readPositiveInt2(record2["maxLines"], MAX_LINES_DEFAULT);
|
|
20174
|
+
const target = resolveTarget(backend, exec);
|
|
20175
|
+
const argv = buildGitArgv(action, {
|
|
20176
|
+
path: readString3(record2["path"]),
|
|
20177
|
+
ref: readString3(record2["ref"]),
|
|
20178
|
+
message: readString3(record2["message"]),
|
|
20179
|
+
files: readStringArray2(record2["files"]),
|
|
20180
|
+
staged: record2["staged"] === true,
|
|
20181
|
+
maxCount: readPositiveInt2(record2["maxCount"], 20),
|
|
20182
|
+
stashAction: readString3(record2["stashAction"]),
|
|
20183
|
+
all: record2["all"] === true
|
|
20184
|
+
});
|
|
20185
|
+
const outcome = await backend.exec(target, {
|
|
20186
|
+
command: quoteAll(argv),
|
|
20187
|
+
cwd: resolveCwd(target, readString3(record2["cwd"])),
|
|
20188
|
+
env: { ...GIT_ENV },
|
|
20189
|
+
timeoutMs: readPositiveInt2(record2["timeoutMs"], DEFAULT_TIMEOUT_MS3)
|
|
20190
|
+
}, exec.signal);
|
|
20191
|
+
const capped = capLines(outcome.stdout, maxLines);
|
|
20192
|
+
const errCapped = capLines(outcome.stderr, maxLines);
|
|
20193
|
+
const base = {
|
|
20194
|
+
action,
|
|
20195
|
+
exitCode: outcome.exitCode,
|
|
20196
|
+
stdout: capped.text,
|
|
20197
|
+
stderr: errCapped.text,
|
|
20198
|
+
durationMs: outcome.durationMs,
|
|
20199
|
+
truncated: capped.truncated || errCapped.truncated
|
|
20200
|
+
};
|
|
20201
|
+
if (action === "status") {
|
|
20202
|
+
const parsed = parseStatus(outcome.stdout);
|
|
20203
|
+
return {
|
|
20204
|
+
...base,
|
|
20205
|
+
...parsed.branch === void 0 ? {} : { branch: parsed.branch },
|
|
20206
|
+
entries: [...parsed.entries]
|
|
20207
|
+
};
|
|
20208
|
+
}
|
|
20209
|
+
if (action === "log") return { ...base, log: [...parseLog(outcome.stdout)] };
|
|
20210
|
+
if (action === "branch") {
|
|
20211
|
+
const branches = outcome.stdout.split("\n").map((line) => line.trim()).filter((line) => line !== "");
|
|
20212
|
+
return { ...base, branches };
|
|
20213
|
+
}
|
|
20214
|
+
if (action === "diff" || action === "show") return { ...base, diff: capped.text };
|
|
20215
|
+
return base;
|
|
20216
|
+
}
|
|
20217
|
+
});
|
|
20218
|
+
}
|
|
20219
|
+
function resolveTarget(backend, exec) {
|
|
20220
|
+
return resolveSessionTarget(exec, {
|
|
20221
|
+
resolve: (cwd) => backend.resolve(cwd),
|
|
20222
|
+
resolveSessionRoot: backend.resolveSessionRoot
|
|
20223
|
+
});
|
|
20224
|
+
}
|
|
20225
|
+
function resolveCwd(target, cwd) {
|
|
20226
|
+
if (cwd === void 0 || cwd === "") return target.root;
|
|
20227
|
+
const normalized = cwd.replace(/\\/g, "/");
|
|
20228
|
+
if (normalized.startsWith("/")) return normalized;
|
|
20229
|
+
return `${target.root.replace(/\/+$/, "")}/${normalized}`.replace(/\/{2,}/g, "/");
|
|
20230
|
+
}
|
|
20231
|
+
function readAction(value) {
|
|
20232
|
+
if (typeof value === "string" && ACTIONS.includes(value)) return value;
|
|
20233
|
+
throw new Error(`action \u5FC5\u987B\u662F ${ACTIONS.join(" / ")} \u4E4B\u4E00`);
|
|
20234
|
+
}
|
|
20235
|
+
function readString3(value) {
|
|
20236
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
20237
|
+
}
|
|
20238
|
+
function readStringArray2(value) {
|
|
20239
|
+
if (!Array.isArray(value)) return void 0;
|
|
20240
|
+
const out = value.filter((item) => typeof item === "string" && item !== "");
|
|
20241
|
+
return out.length === 0 ? void 0 : out;
|
|
20242
|
+
}
|
|
20243
|
+
function readPositiveInt2(value, fallback) {
|
|
20244
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return fallback;
|
|
20245
|
+
return Math.floor(value);
|
|
20246
|
+
}
|
|
20247
|
+
function text6(value) {
|
|
20248
|
+
return [{ type: "text", text: value }];
|
|
20249
|
+
}
|
|
20250
|
+
function registerRemoteGitTool(ctx, backend) {
|
|
20251
|
+
return ctx.tools.register(remoteGitDefinition(backend));
|
|
20252
|
+
}
|
|
20253
|
+
|
|
20254
|
+
// src/host/integration/tools/remote-lsp-tools.ts
|
|
20255
|
+
import { defineTool as defineTool7 } from "@deepseek-ai/dsh-tools";
|
|
18458
20256
|
function toView(diagnostic) {
|
|
18459
20257
|
return {
|
|
18460
20258
|
line: diagnostic.line + 1,
|
|
@@ -18506,7 +20304,7 @@ function parseServer(value) {
|
|
|
18506
20304
|
function remoteLspToolDefinitions(deps) {
|
|
18507
20305
|
const { backend, registry } = deps;
|
|
18508
20306
|
return [
|
|
18509
|
-
|
|
20307
|
+
defineTool7({
|
|
18510
20308
|
name: "remote_lsp",
|
|
18511
20309
|
description: 'Ask a **language server running on the remote host** about a file: diagnostics, hover, or definition. Use this instead of running the compiler and reading its text output \u2014 the answer is structured and scoped to one document, and it is the only way to ask "what does this symbol mean" or "where is this defined". The server is started on first use and reused; it takes seconds to index a project, so the first call is slow and later ones are fast. Line and column are **1-based**, matching `remote_read`. Requires a language server installed on the remote host (gopls, rust-analyzer, typescript-language-server, pylsp, clangd); pass `server` to use a different one. NOT concurrency-safe: it changes remote state (starts a process), so it never joins a parallel group.',
|
|
18512
20310
|
parameters: {
|
|
@@ -18714,7 +20512,7 @@ function registerRemoteLspTools(ctx, deps) {
|
|
|
18714
20512
|
|
|
18715
20513
|
// src/wiring/tools-session.ts
|
|
18716
20514
|
import { cp, mkdir as mkdir10, readFile as readFile9, readdir as readdir6, rename as rename6, rm as rm5, stat as nodeStat2, writeFile as writeFile6 } from "node:fs/promises";
|
|
18717
|
-
import { dirname as dirname8, join as
|
|
20515
|
+
import { dirname as dirname8, join as join17 } from "node:path";
|
|
18718
20516
|
import { homedir as homedir6 } from "node:os";
|
|
18719
20517
|
|
|
18720
20518
|
// src/host/integration/session/session-labels.ts
|
|
@@ -18765,11 +20563,11 @@ function createWorkspaceTitleResolver(ctx) {
|
|
|
18765
20563
|
// src/wiring/tools-session.ts
|
|
18766
20564
|
function wireSessionAdmin(env) {
|
|
18767
20565
|
const { ctx, limits, notifySessionRemoved: notifySessionRemoved2 } = env;
|
|
18768
|
-
const sessionsRoot = limits.sessionsRoot ??
|
|
18769
|
-
const storagesRoot =
|
|
18770
|
-
const sessionBinRoot = limits.sessionBinRoot ??
|
|
20566
|
+
const sessionsRoot = limits.sessionsRoot ?? join17(homedir6(), ".dsh", "sessions");
|
|
20567
|
+
const storagesRoot = join17(homedir6(), ".dsh", "storages");
|
|
20568
|
+
const sessionBinRoot = limits.sessionBinRoot ?? join17(storagesRoot, "over-ssh", "session-trash");
|
|
18771
20569
|
if (limits.sessionBinRoot === void 0) {
|
|
18772
|
-
const carried = carryDirectory(
|
|
20570
|
+
const carried = carryDirectory(join17(storagesRoot, "remote-workspace", "session-trash"), sessionBinRoot);
|
|
18773
20571
|
if (carried.error !== void 0) {
|
|
18774
20572
|
const reason = carried.error instanceof Error ? carried.error.message : String(carried.error);
|
|
18775
20573
|
ctx.logger?.warn?.(`\u4F1A\u8BDD\u56DE\u6536\u7AD9\u4ECE\u65E7\u76EE\u5F55\u642C\u8FC1\u5931\u8D25\uFF08${reason}\uFF09\uFF1B\u65B0\u76EE\u5F55\u4ECD\u53EF\u7528\uFF0C\u65E7\u76EE\u5F55\u7559\u7740\u53EF\u4EBA\u5DE5\u6062\u590D`);
|
|
@@ -18794,7 +20592,7 @@ function wireSessionAdmin(env) {
|
|
|
18794
20592
|
let bytes = 0;
|
|
18795
20593
|
let mtimeMs = 0;
|
|
18796
20594
|
try {
|
|
18797
|
-
const stat5 = await nodeStat2(
|
|
20595
|
+
const stat5 = await nodeStat2(join17(path, entry.name));
|
|
18798
20596
|
mtimeMs = stat5.mtimeMs;
|
|
18799
20597
|
if (entry.isFile()) bytes = stat5.size;
|
|
18800
20598
|
} catch {
|
|
@@ -18821,14 +20619,14 @@ function wireSessionAdmin(env) {
|
|
|
18821
20619
|
}
|
|
18822
20620
|
},
|
|
18823
20621
|
readText: (path) => readFile9(path, "utf8"),
|
|
18824
|
-
writeText: async (path,
|
|
20622
|
+
writeText: async (path, text8) => {
|
|
18825
20623
|
await mkdir10(dirname8(path), { recursive: true });
|
|
18826
|
-
await writeFile6(path,
|
|
20624
|
+
await writeFile6(path, text8, "utf8");
|
|
18827
20625
|
},
|
|
18828
|
-
writeTextAtomic: async (path,
|
|
20626
|
+
writeTextAtomic: async (path, text8) => {
|
|
18829
20627
|
await mkdir10(dirname8(path), { recursive: true });
|
|
18830
20628
|
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
18831
|
-
await writeFile6(temp,
|
|
20629
|
+
await writeFile6(temp, text8, "utf8");
|
|
18832
20630
|
try {
|
|
18833
20631
|
await rename6(temp, path);
|
|
18834
20632
|
} catch (error) {
|
|
@@ -18892,8 +20690,8 @@ init_errors();
|
|
|
18892
20690
|
|
|
18893
20691
|
// src/host/integration/tools/management-tools.ts
|
|
18894
20692
|
import { isAbsolute as isAbsolute3, resolve as resolve2 } from "node:path";
|
|
18895
|
-
import { defineTool as
|
|
18896
|
-
function
|
|
20693
|
+
import { defineTool as defineTool8 } from "@deepseek-ai/dsh-tools";
|
|
20694
|
+
function text7(value) {
|
|
18897
20695
|
return [{ type: "text", text: value }];
|
|
18898
20696
|
}
|
|
18899
20697
|
function targetFor4(backend, exec) {
|
|
@@ -18967,7 +20765,7 @@ function formatEnv(result) {
|
|
|
18967
20765
|
}
|
|
18968
20766
|
function managementToolDefinitions(backend) {
|
|
18969
20767
|
return [
|
|
18970
|
-
|
|
20768
|
+
defineTool8({
|
|
18971
20769
|
name: "remote_transfer",
|
|
18972
20770
|
description: "Upload one local file to the remote workspace this session is working in, or download one remote file to the local machine. Chunked with progress; pass `resume: true` to continue an interrupted transfer instead of rewriting the whole file (the prefix is **compared with the source** before it is kept, so a changed source degrades to a full retransfer instead of splicing two files). The result reports bytes, chunk count, whether it resumed, the absolute local path the bytes landed at, and \u2014 when the host can hash \u2014 the digest of the content that actually landed. `localPath` must be **absolute**: the plugin resolves it in its own process, so a relative spelling would land under the plugin's working directory instead of anywhere the caller expects. An upload that would overwrite an **existing** remote file is refused unless you pass `expectedVersion` (from `remote_stat`/`remote_read`) or `force: true`: a silent overwrite is the one operation that destroys someone else's work without a trace.",
|
|
18973
20771
|
parameters: {
|
|
@@ -19027,7 +20825,7 @@ function managementToolDefinitions(backend) {
|
|
|
19027
20825
|
}
|
|
19028
20826
|
}
|
|
19029
20827
|
},
|
|
19030
|
-
render: (_args, value) =>
|
|
20828
|
+
render: (_args, value) => text7(formatTransfer(value))
|
|
19031
20829
|
},
|
|
19032
20830
|
async execute(args, exec) {
|
|
19033
20831
|
const record2 = args ?? {};
|
|
@@ -19105,7 +20903,7 @@ function managementToolDefinitions(backend) {
|
|
|
19105
20903
|
};
|
|
19106
20904
|
}
|
|
19107
20905
|
}),
|
|
19108
|
-
|
|
20906
|
+
defineTool8({
|
|
19109
20907
|
name: "remote_delete",
|
|
19110
20908
|
description: 'Delete one path inside the remote workspace. `mode: "recycle"` (the default) moves it to the workspace recycle bin, where it is recoverable and can be undone; `mode: "permanent"` removes it for good and requires user approval. Omitting `mode` is therefore the safe call, and the result always names which mode ran.',
|
|
19111
20909
|
parameters: {
|
|
@@ -19132,7 +20930,7 @@ function managementToolDefinitions(backend) {
|
|
|
19132
20930
|
warning: { type: "string" }
|
|
19133
20931
|
}
|
|
19134
20932
|
},
|
|
19135
|
-
render: (_args, value) =>
|
|
20933
|
+
render: (_args, value) => text7(formatDiscard(value))
|
|
19136
20934
|
},
|
|
19137
20935
|
async execute(args, exec) {
|
|
19138
20936
|
const record2 = args ?? {};
|
|
@@ -19153,7 +20951,7 @@ function managementToolDefinitions(backend) {
|
|
|
19153
20951
|
};
|
|
19154
20952
|
}
|
|
19155
20953
|
}),
|
|
19156
|
-
|
|
20954
|
+
defineTool8({
|
|
19157
20955
|
name: "remote_restore",
|
|
19158
20956
|
description: "Undo a delete. With `id`, puts that bin entry back at the path it was deleted from; without `id`, undoes the most recent discard; with `list: true`, shows what the bin holds for this session's root and changes nothing. `remote_delete` returns the entry id as `entryId`. The bin is subject to retention limits (`binMaxEntries` / `binMaxBytes` / `binMaxAgeMs`), so an old entry may already be gone.",
|
|
19159
20957
|
parameters: {
|
|
@@ -19193,7 +20991,7 @@ function managementToolDefinitions(backend) {
|
|
|
19193
20991
|
message: { type: "string" }
|
|
19194
20992
|
}
|
|
19195
20993
|
},
|
|
19196
|
-
render: (_args, value) =>
|
|
20994
|
+
render: (_args, value) => text7(formatRestore(value))
|
|
19197
20995
|
},
|
|
19198
20996
|
async execute(args, exec) {
|
|
19199
20997
|
const record2 = args ?? {};
|
|
@@ -19215,7 +21013,7 @@ function managementToolDefinitions(backend) {
|
|
|
19215
21013
|
return { action: "undone", id: outcome.id, message: outcome.message };
|
|
19216
21014
|
}
|
|
19217
21015
|
}),
|
|
19218
|
-
|
|
21016
|
+
defineTool8({
|
|
19219
21017
|
name: "remote_copy",
|
|
19220
21018
|
description: "Copy one file to another path **on the remote host**. Both paths are inside this session's remote root. On a Tier 2 (agent) connection the copy happens on the remote side, so the bytes never cross this machine; otherwise they are relayed through it, and the result says which happened in `via`. Pass `recursive: true` to copy a whole directory tree in one round trip (Tier 2 only \u2014 it refuses rather than relaying a tree file by file). The destination must not exist, or be an empty directory: a recursive copy never deletes anything.",
|
|
19221
21019
|
parameters: {
|
|
@@ -19245,7 +21043,7 @@ function managementToolDefinitions(backend) {
|
|
|
19245
21043
|
durationMs: { type: "integer", required: true }
|
|
19246
21044
|
}
|
|
19247
21045
|
},
|
|
19248
|
-
render: (_args, value) =>
|
|
21046
|
+
render: (_args, value) => text7(formatCopy(value))
|
|
19249
21047
|
},
|
|
19250
21048
|
async execute(args, exec) {
|
|
19251
21049
|
const record2 = args ?? {};
|
|
@@ -19278,7 +21076,7 @@ function managementToolDefinitions(backend) {
|
|
|
19278
21076
|
};
|
|
19279
21077
|
}
|
|
19280
21078
|
}),
|
|
19281
|
-
|
|
21079
|
+
defineTool8({
|
|
19282
21080
|
name: "remote_env",
|
|
19283
21081
|
description: "Sample what the remote host is and what it can do: OS/arch/distro/kernel, shell, CPU count, memory and disk, user and home, and **which commands exist** (rg, fd, git, python3, gcc, docker, ...). Ask this once instead of guessing and retrying: the answer is cached on the agent until `refresh: true`, so a repeat call costs one round trip and no resampling.",
|
|
19284
21082
|
parameters: {
|
|
@@ -19310,7 +21108,7 @@ function managementToolDefinitions(backend) {
|
|
|
19310
21108
|
diskTotalBytes: { type: "integer", description: "Total size of the home filesystem in bytes." }
|
|
19311
21109
|
}
|
|
19312
21110
|
},
|
|
19313
|
-
render: (_args, value) =>
|
|
21111
|
+
render: (_args, value) => text7(formatEnv(value))
|
|
19314
21112
|
},
|
|
19315
21113
|
async execute(args, exec) {
|
|
19316
21114
|
const record2 = args ?? {};
|
|
@@ -19435,10 +21233,20 @@ async function relayCopy(deps, transport, request, source) {
|
|
|
19435
21233
|
}
|
|
19436
21234
|
const total = source.size;
|
|
19437
21235
|
const staging = deps.renameRemote !== void 0 && deps.removeRemote !== void 0;
|
|
19438
|
-
const destination = staging ? `${request.to}.dsh-part` : request.to;
|
|
21236
|
+
const destination = staging ? `${request.to}.dsh-part-${process.pid}-${Math.random().toString(36).slice(2, 10)}` : request.to;
|
|
19439
21237
|
const publish = async () => {
|
|
19440
|
-
if (staging)
|
|
21238
|
+
if (!staging) return;
|
|
21239
|
+
if (request.overwrite) await deps.removeRemote?.(request.to);
|
|
21240
|
+
await deps.renameRemote?.(destination, request.to);
|
|
19441
21241
|
};
|
|
21242
|
+
if (!staging && request.overwrite) {
|
|
21243
|
+
if (deps.removeRemote === void 0) {
|
|
21244
|
+
throw new SshDevError("REMOTE_UNSUPPORTED", "\u8BE5\u8FDE\u63A5\u4E0D\u652F\u6301\u5220\u9664\u539F\u8BED\uFF0C\u65E0\u6CD5\u5728 relay \u8DEF\u5F84\u4E0A\u5B89\u5168\u8986\u76D6\u5DF2\u6709\u6587\u4EF6", {
|
|
21245
|
+
to: request.to
|
|
21246
|
+
});
|
|
21247
|
+
}
|
|
21248
|
+
await deps.removeRemote(request.to);
|
|
21249
|
+
}
|
|
19442
21250
|
try {
|
|
19443
21251
|
let offset = 0;
|
|
19444
21252
|
if (total === 0) {
|
|
@@ -19602,11 +21410,40 @@ function wireManagementTools(env) {
|
|
|
19602
21410
|
|
|
19603
21411
|
// src/wiring/tools.ts
|
|
19604
21412
|
function sessionRootsFilePath() {
|
|
19605
|
-
return
|
|
21413
|
+
return join18(resolveStateDir(), USER_ROOTS_FILE_NAME);
|
|
21414
|
+
}
|
|
21415
|
+
var platformMutationPolicy = (context) => {
|
|
21416
|
+
const service = context.get("sandboxPolicy");
|
|
21417
|
+
if (service?.resolve === void 0) return {};
|
|
21418
|
+
const sessionId = currentSessionId();
|
|
21419
|
+
const sessions = context.get("sessions");
|
|
21420
|
+
const session = sessionId === void 0 || sessions?.get === void 0 ? void 0 : sessions.get(sessionId);
|
|
21421
|
+
try {
|
|
21422
|
+
const resolved = service.resolve(session === void 0 ? {} : { session });
|
|
21423
|
+
if (resolved.mode === void 0 || resolved.workspaceRoot === void 0) return {};
|
|
21424
|
+
const mode = resolved.mode;
|
|
21425
|
+
if (mode !== "read-only" && mode !== "workspace-write" && mode !== "danger-full-access") return {};
|
|
21426
|
+
return { policy: { mode, workspaceRoot: resolved.workspaceRoot } };
|
|
21427
|
+
} catch {
|
|
21428
|
+
return {};
|
|
21429
|
+
}
|
|
21430
|
+
};
|
|
21431
|
+
function execWriteRootsSeam(env) {
|
|
21432
|
+
if (env.limits.execWriteFence !== true) return void 0;
|
|
21433
|
+
return (connectionId, root) => {
|
|
21434
|
+
const sessionId = currentSessionId();
|
|
21435
|
+
return execWriteRootsFor({
|
|
21436
|
+
connectionId,
|
|
21437
|
+
root,
|
|
21438
|
+
records: env.registry.records,
|
|
21439
|
+
identity: sessionIdentityOf(env.ctx, sessionId),
|
|
21440
|
+
cwd: sessionId === void 0 || sessionId === "" ? void 0 : readSessionCwd(env.ctx, sessionId)
|
|
21441
|
+
});
|
|
21442
|
+
};
|
|
19606
21443
|
}
|
|
19607
21444
|
function wireTools(env) {
|
|
19608
21445
|
const { ctx, limits, profiles, transports, registry, indexFor, transferService, transferTracker, binService, notifySessionRemoved: notifySessionRemoved2 } = env;
|
|
19609
|
-
const { sessionRoots, toolBackend, disposeTools } = wireAgentTools({ ctx, profiles, transports, registry, indexFor });
|
|
21446
|
+
const { sessionRoots, toolBackend, disposeTools } = wireAgentTools({ ctx, limits, profiles, transports, registry, indexFor });
|
|
19610
21447
|
const {
|
|
19611
21448
|
recordFor,
|
|
19612
21449
|
disposeJobTools,
|
|
@@ -19650,12 +21487,13 @@ function wireTools(env) {
|
|
|
19650
21487
|
};
|
|
19651
21488
|
}
|
|
19652
21489
|
function wireAgentTools(env) {
|
|
19653
|
-
const { ctx, profiles, transports, registry, indexFor } = env;
|
|
21490
|
+
const { ctx, limits, profiles, transports, registry, indexFor } = env;
|
|
19654
21491
|
const sessionRoots = createSessionRootIndex({
|
|
19655
21492
|
filePath: sessionRootsFilePath(),
|
|
19656
21493
|
readFile: readFile10,
|
|
19657
21494
|
ttlMs: 2e3
|
|
19658
21495
|
});
|
|
21496
|
+
const execWriteRootsForCommand = execWriteRootsSeam({ ctx, limits, registry });
|
|
19659
21497
|
const toolBackend = createRemoteToolBackend({
|
|
19660
21498
|
resolveRoot: (cwd) => {
|
|
19661
21499
|
const resolved = registry.routed.tryResolve(cwd);
|
|
@@ -19667,13 +21505,35 @@ function wireAgentTools(env) {
|
|
|
19667
21505
|
// ADR-0013: the session id (plus its fork lineage) is the strict scope; `cwd` alone cannot separate
|
|
19668
21506
|
// two conversations opened on the same directory.
|
|
19669
21507
|
resolveSessionRoot: (cwd, sessionId) => sessionRoots.outcomeFor(cwd, sessionIdentityOf(ctx, sessionId)),
|
|
21508
|
+
// 批量多机:把**指定的**连接解析成覆盖 cwd 的那个根。选"覆盖 cwd 的最长根"与单机路径同一条规则
|
|
21509
|
+
// (`longestContainingRoot`),所以同一个 cwd 在两处不会落到不同的根上。
|
|
21510
|
+
resolveRootForConnection: (connectionId, cwd) => {
|
|
21511
|
+
const candidates = registry.records.filter((record2) => record2.root.location === "remote" && record2.root.connectionId === connectionId).map((record2) => record2.root.path);
|
|
21512
|
+
const covering = longestContainingRoot(candidates, normalizeRemotePath(cwd));
|
|
21513
|
+
return covering === void 0 ? void 0 : { connectionId, root: covering };
|
|
21514
|
+
},
|
|
19670
21515
|
acquire: async (connectionId) => (await transports.acquire(requireProfile(profiles, connectionId))).transport,
|
|
19671
21516
|
enumerate: async (connectionId, root, signal) => {
|
|
19672
21517
|
const result = await indexFor(connectionId).enumerate(root, signal === void 0 ? {} : { signal });
|
|
19673
21518
|
return { paths: result.paths, truncated: result.truncated, source: result.source };
|
|
19674
|
-
}
|
|
21519
|
+
},
|
|
21520
|
+
// 可选写入围栏(`limits.execWriteFence`)。默认 undefined = 不套:围栏会挡掉构建工具写
|
|
21521
|
+
// `$HOME/.cache` / `/tmp`,默认开启会弄坏正常命令。打开时只放行"命令起始根 + 本会话添加的可写根"。
|
|
21522
|
+
execWriteRoots: execWriteRootsForCommand
|
|
19675
21523
|
});
|
|
19676
|
-
const
|
|
21524
|
+
const disposeRemoteTools = registerRemoteTools(ctx, toolBackend);
|
|
21525
|
+
const disposeGitTool = registerRemoteGitTool(ctx, toolBackend);
|
|
21526
|
+
const forwardBackend = createForwardToolBackend(async (connectionId) => {
|
|
21527
|
+
const transport = (await transports.acquire(requireProfile(profiles, connectionId))).transport;
|
|
21528
|
+
return transport;
|
|
21529
|
+
});
|
|
21530
|
+
const disposeForwardTool = registerRemoteForwardTool(ctx, forwardBackend);
|
|
21531
|
+
const disposeTools = () => {
|
|
21532
|
+
disposeRemoteTools();
|
|
21533
|
+
disposeGitTool();
|
|
21534
|
+
disposeForwardTool();
|
|
21535
|
+
void forwardBackend.closeAll();
|
|
21536
|
+
};
|
|
19677
21537
|
return { sessionRoots, toolBackend, disposeTools };
|
|
19678
21538
|
}
|
|
19679
21539
|
function wireFileAndJobTools(env) {
|
|
@@ -19688,8 +21548,10 @@ function wireFileAndJobTools(env) {
|
|
|
19688
21548
|
const recordFor = (target) => registry.records.find(
|
|
19689
21549
|
(record2) => record2.root.location === "remote" && record2.root.connectionId === target.connectionId && record2.root.path === target.root
|
|
19690
21550
|
);
|
|
21551
|
+
const execWriteRootsForCommand = execWriteRootsSeam({ ctx, limits, registry });
|
|
19691
21552
|
const jobBackend = createRemoteJobBackend({
|
|
19692
21553
|
resolveRoot: remoteRootFor,
|
|
21554
|
+
execWriteRoots: execWriteRootsForCommand,
|
|
19693
21555
|
// ADR-0013: the session id (plus its fork lineage) is the strict scope; `cwd` alone cannot separate
|
|
19694
21556
|
// two conversations opened on the same directory.
|
|
19695
21557
|
resolveSessionRoot: (cwd, sessionId) => sessionRoots.outcomeFor(cwd, sessionIdentityOf(ctx, sessionId)),
|
|
@@ -19707,7 +21569,21 @@ function wireFileAndJobTools(env) {
|
|
|
19707
21569
|
backendFor: (target) => recordFor(target)?.backend,
|
|
19708
21570
|
...limits.maxReadBytes === void 0 ? {} : { maxReadBytes: limits.maxReadBytes }
|
|
19709
21571
|
});
|
|
19710
|
-
const
|
|
21572
|
+
const allowMutation = (target) => {
|
|
21573
|
+
const refusal = mutationRefusal(
|
|
21574
|
+
`${target.connectionId}:${target.root}`,
|
|
21575
|
+
{
|
|
21576
|
+
...platformMutationPolicy(ctx),
|
|
21577
|
+
rootArea: () => {
|
|
21578
|
+
const record2 = recordFor(target);
|
|
21579
|
+
if (record2 === void 0) return "deny";
|
|
21580
|
+
return workspaceWriteArea(record2.root, void 0, void 0, sessionIdentityOf(ctx, currentSessionId()));
|
|
21581
|
+
}
|
|
21582
|
+
}
|
|
21583
|
+
);
|
|
21584
|
+
if (refusal !== void 0) throw new Error(refusal);
|
|
21585
|
+
};
|
|
21586
|
+
const disposeFileTools = registerRemoteFileTools(ctx, fileBackend, { allowMutation });
|
|
19711
21587
|
const lspRegistry = createLspRegistry({
|
|
19712
21588
|
acquire: async (connectionId) => (await transports.acquire(requireProfile(profiles, connectionId))).transport,
|
|
19713
21589
|
onLog: (connectionId, message) => {
|