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/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(text6, maxChars) {
50
- return text6.length <= maxChars ? text6 : text6.slice(text6.length - maxChars);
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(text6) {
205
- const decoded = Buffer.from(text6, "base64");
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 rmdir = sftp.rmdir?.bind(sftp);
754
+ const rmdir2 = sftp.rmdir?.bind(sftp);
511
755
  return (async () => {
512
756
  throwIfCancelled(guard.signal, directory);
513
- if (readdir7 === void 0 || rmdir === 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
- rmdir(directory, (error) => error === void 0 ? resolve3() : reject(sftpError("rmdir", directory, error)));
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 = createHash("sha256").update(request.bytes).digest("hex");
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 = createHash("sha256").update(request.bytes).digest("hex");
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 connect = this.#options.connect;
1049
- if (connect === void 0) {
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 ? connect : { ...connect, signal });
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:${createHash("sha256").update(key).digest("base64").replace(/=+$/, "")}`;
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 text6 = typeof value === "number" && Number.isInteger(value) ? String(value) : typeof value === "string" ? value.trim() : void 0;
1897
- if (text6 === void 0 || text6 === "") {
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 = text6.startsWith("0o") || text6.startsWith("0O") ? text6.slice(2) : text6;
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(text6, maxBytes = MAX_OUTPUT_BYTES, keep = "head") {
2075
- const bytes = Buffer.byteLength(text6, "utf8");
2076
- if (bytes <= maxBytes) return { text: text6, truncated: false };
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 = text6.slice(-maxBytes);
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 = text6.slice(0, maxBytes);
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(text6) {
3222
- const body = text6.charCodeAt(0) === 65279 ? text6.slice(1) : text6;
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 text6 = await ioFor(root).readText(path, signal);
3325
- const parsed = parseRecycleBinIndex(text6);
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(text6) {
3924
- const body = text6.charCodeAt(0) === 65279 ? text6.slice(1) : text6;
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 text6;
4418
+ let text8;
4078
4419
  try {
4079
- text6 = await deps.readText(indexePath());
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(text6).entries;
4425
+ return parseSessionTrashIndex(text8).entries;
4085
4426
  };
4086
4427
  const writeIndex = async (entries) => {
4087
- const text6 = serializeSessionTrashIndex(entries);
4428
+ const text8 = serializeSessionTrashIndex(entries);
4088
4429
  if (deps.writeTextAtomic !== void 0) {
4089
- await deps.writeTextAtomic(indexePath(), text6);
4430
+ await deps.writeTextAtomic(indexePath(), text8);
4090
4431
  return;
4091
4432
  }
4092
- await deps.writeText(indexePath(), text6);
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(text6) {
4460
- const firstLine = text6.split("\n")[0];
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 text6;
4831
+ let text8;
4491
4832
  try {
4492
- text6 = await io.decompress(await io.readHead(path, cap));
4833
+ text8 = await io.decompress(await io.readHead(path, cap));
4493
4834
  } catch {
4494
4835
  continue;
4495
4836
  }
4496
- if (text6.includes("\n") || index === caps.length - 1) return parseSessionHeaderLine(text6);
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, text6, options = {}) {
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, text6, options.expectedVersion, options.signal);
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 = homedir();
5633
- const current = join3(home, ".dsh", STATE_DIR_NAME);
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, ".dsh", name2), current, CARRIED_FILES, CARRIED_DIRS));
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 text6 = (value) => typeof value === "string" && value !== "" ? value : void 0;
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: text6(record2["os"]) ?? "",
7672
- arch: text6(record2["arch"]) ?? "",
8262
+ os: text8(record2["os"]) ?? "",
8263
+ arch: text8(record2["arch"]) ?? "",
7673
8264
  cpus: number(record2["cpus"]) ?? 0,
7674
- user: text6(record2["user"]) ?? "",
8265
+ user: text8(record2["user"]) ?? "",
7675
8266
  uid: number(record2["uid"]) ?? -1,
7676
- home: text6(record2["home"]) ?? "",
7677
- path: text6(record2["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
- ...text6(record2["kernel"]) === void 0 ? {} : { kernel: text6(record2["kernel"]) },
7682
- ...text6(record2["distro"]) === void 0 ? {} : { distro: text6(record2["distro"]) },
7683
- ...text6(record2["shell"]) === void 0 ? {} : { shell: text6(record2["shell"]) },
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 = spawn(options.sshArgv[0] ?? "ssh", [...options.sshArgv.slice(1), options.remoteCommand], {
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 probe = await io.exec("uname -s; uname -m", signal);
8555
- if (probe.exitCode !== 0) {
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: probe.exitCode,
8558
- stderr: probe.stderr.slice(-500)
9163
+ exitCode: probe2.exitCode,
9164
+ stderr: probe2.stderr.slice(-500)
8559
9165
  });
8560
9166
  }
8561
- [system = "", machine = ""] = probe.stdout.split("\n").map((line) => line.trim());
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, text6, expectedVersion, signal) {
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(text6, "utf8");
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, text6, expectedVersion, signal) {
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, text6, intent, signal);
9077
- return { bytes: Buffer.byteLength(text6, "utf8"), version: String(outcome.version) };
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/remote-backend.ts
9435
- init_shell_quote();
9436
- function isWatchCapable(transport) {
9437
- if (transport === void 0) return false;
9438
- const candidate = transport;
9439
- return typeof candidate.startWatch === "function" && typeof candidate.pollWatch === "function" && typeof candidate.stopWatch === "function" && candidate.agentCapabilities?.watch === "native";
9440
- }
9441
- var remoteWatchSequence = 0;
9442
- function parentRemotePath(absolute) {
9443
- return remoteDirname(absolute);
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
- var DEFAULT_CACHE = { ttlMs: 5e3, maxEntries: 256, maxBytes: 8 * 1024 * 1024 };
9446
- var STAT_MARKER = "# DSH_STAT";
9447
- var BATCH_MARKER = "#DSH_F ";
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 `<path>\u0000<cap>` — the cap is part of the identity because `truncated` is a property of the
9526
- * *request*, not of the file: serving a 100-byte-capped entry to an 8 MiB read would silently truncate.
9527
- * The `\u0000` keeps `invalidatePrefix(path + '\u0000')` from also matching `/srv/a.txt2`.
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
- if (native !== void 0) return statFromNative(native);
9618
- return this.#shellStat(absolute, signal);
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 = `${absolute}\0${cap}`;
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 (await this.#transport()).readFile({ path: absolute, maxBytes: cap }, signal);
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
- text: Buffer.from(chunk.bytes).toString("utf8"),
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, text6, expectedVersion, signal) {
9901
- const bytes = new Uint8Array(Buffer.from(text6, "utf8"));
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 current = await this.stat(absolute, signal);
9906
- if (!current.exists) {
9907
- throw new SshDevError("TRANSFER_CONFLICT", "\u8FDC\u7AEF\u6587\u4EF6\u4E0D\u5B58\u5728\uFF0C\u5199\u5165\u88AB\u62D2\u7EDD", { path: absolute, expected: expectedVersion });
9908
- }
9909
- if (current.version !== expectedVersion) {
9910
- throw new SshDevError("TRANSFER_CONFLICT", "\u8FDC\u7AEF\u6587\u4EF6\u5DF2\u88AB\u4FEE\u6539\uFF0C\u5199\u5165\u88AB\u62D2\u7EDD", {
9911
- path: absolute,
9912
- expected: expectedVersion,
9913
- observed: current.version
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 `REMOTE_UNSUPPORTED` when this connection has no native watcher, `REMOTE_NOT_FOUND`
9960
- * when a file target's parent directory does not exist, `CANCELLED` when aborted during setup.
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
- throw new SshDevError("REMOTE_UNSUPPORTED", '\u8BE5\u8FDE\u63A5\u6CA1\u6709\u4EE3\u7406\u539F\u751F\u76D1\u542C\u80FD\u529B\uFF08capabilities.watch !== "native"\uFF09\uFF1ATier 1 \u6CA1\u6709 watch \u534F\u8BAE\uFF0C\u65E0\u6CD5\u76D1\u542C\u8FDC\u7AEF\u8DEF\u5F84', {
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.invalidatePrefix(`${absolute}\0`);
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(text6, lineCount) {
11646
+ function sliceAfterLines(text8, lineCount) {
10728
11647
  let offset = 0;
10729
11648
  for (let line = 0; line < lineCount; line += 1) {
10730
- const next = text6.indexOf("\n", offset);
11649
+ const next = text8.indexOf("\n", offset);
10731
11650
  if (next < 0) return "";
10732
11651
  offset = next + 1;
10733
11652
  }
10734
- return text6.slice(offset);
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 spawn2 } from "node:child_process";
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 = spawn2(command, args, {
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 probe = await exec("command -v sha256sum >/dev/null 2>&1 && echo sha256sum || (command -v shasum >/dev/null 2>&1 && echo shasum) || echo none");
11011
- const answer = probe.stdout.trim();
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
- /** Drop one root (its files changed) or everything. */
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) this.#cache.clear();
11309
- else this.#cache.delete(root);
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 order = [];
11478
- for (let index = 0; index < paths.length; index += 1) order.push(index);
11479
- const depths = new Int32Array(paths.length);
11480
- for (let index = 0; index < paths.length; index += 1) depths[index] = depth2(paths[index]);
11481
- order.sort(
11482
- (left, right) => depths[left] - depths[right] || (paths[left] < paths[right] ? -1 : 1)
11483
- );
11484
- return order.slice(0, limit).map((index) => paths[index]);
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 spawn3 = createNodeSpawner({ defaultTimeoutMs: limits.commandTimeoutMs ?? 3e4 });
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: spawn3,
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 mkdir6 } from "node:fs/promises";
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(text6) {
12638
- if (text6 === void 0 || text6.trim() === "") {
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(text6));
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 text6;
13937
+ let text8;
12977
13938
  try {
12978
- text6 = await io.readFile(filePath);
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
- return parseUserRoots(text6);
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
- await io.writeFile(temporary, serializeUserRoots(validated));
12994
- await io.rename(temporary, filePath);
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
- // The read is inside the critical section on purpose: reading outside it would reintroduce exactly the
13006
- // lost update this serialisation exists to prevent.
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
- const lowered = left.toLowerCase();
13075
- return lowered.startsWith("local:") && lowered === right.toLowerCase();
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
- if (collapsed === "/" || /^[A-Za-z]:\/?$/.test(collapsed)) {
13084
- return collapsed.endsWith("/") ? collapsed : `${collapsed}/`;
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
- return collapsed.replace(/\/+$/, "");
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(text6) {
13144
- return text6.charCodeAt(0) === 65279 ? text6.slice(1) : text6;
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 text6 = (await readBody(req, maxBytes)).trim();
13169
- if (text6 === "") return {};
14154
+ const text8 = (await readBody(req, maxBytes)).trim();
14155
+ if (text8 === "") return {};
13170
14156
  let parsed;
13171
14157
  try {
13172
- parsed = JSON.parse(text6);
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
- const entry = normalizeRootEntry(pick(body, ROOT_FIELDS));
13489
- const reuseExisting = body["reuseExisting"] === true;
13490
- const key = userRootKey(entry);
13491
- const existing = findRootInState(await currentUserState(deps), key);
13492
- if (existing !== void 0 && entry.workspaces === void 0 && entry.sessions === void 0 && !reuseExisting) {
13493
- throw new SshDevError("ROOT_INVALID", `\u8BE5\u6839\u5DF2\u5B58\u5728\uFF1A${key}`, { key });
13494
- }
13495
- if (!await isRootLive(deps, entry)) {
13496
- await deps.registerRoot(existing ?? entry);
13497
- }
13498
- const state = await reconcile(deps, (current) => {
13499
- if (!stateHasRoot(current, key)) return addRootToState(current, entry);
13500
- const withWorkspaceScope = entry.workspaces === void 0 ? current : withWorkspaces(current, key, entry.workspaces);
13501
- return entry.sessions === void 0 ? withWorkspaceScope : withSessions(withWorkspaceScope, key, entry.sessions);
13502
- });
13503
- const anchorPath = await anchorPathOf(deps, entry);
13504
- send(req, res, 200, {
13505
- ok: true,
13506
- action,
13507
- state: publicUserState(state),
13508
- ...anchorPath === void 0 ? {} : { anchorPath }
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(text6) {
14633
+ function parseSshConfig(text8) {
13611
14634
  const entries = [];
13612
14635
  let current;
13613
- const lines = text6.split(/\r?\n/);
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 text6 = await deps.readSshConfig?.();
13734
- const drafts = importableEntries(parseSshConfig(text6 ?? ""), { home: homedir3() }).drafts;
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 text6 = await read();
13999
- if (text6 === void 0 || text6.trim() === "") return { candidates: [], skipped: [] };
14000
- const result = importableEntries(parseSshConfig(text6), { home: homedir3(), excluded });
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 text6;
15348
+ let text8;
14326
15349
  try {
14327
- text6 = await readBody(req, ROOTS_API_MAX_BODY_BYTES);
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 (text6.trim() === "") {
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(text6);
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/roots/roots-wiring.ts
14378
- function rootsRouteDiagnostic(facts) {
14379
- const warnings = facts.warnings.length === 0 ? "(none)" : facts.warnings.map((warning) => `[${warning}]`).join(" ");
14380
- return [
14381
- "roots panel[diag]",
14382
- `route=${facts.routeRegistered ? "registered" : "MISSING"}`,
14383
- `injectRan=${facts.injectRan}`,
14384
- `hasWebServerProp=${facts.hasWebServerProperty}`,
14385
- `tools=${facts.toolsType}`,
14386
- `warnings=${facts.warnings.length}`,
14387
- warnings
14388
- ].join(" ");
14389
- }
14390
- function createRootsWiring(deps) {
14391
- const store2 = createUserRootsStore({ dir: deps.stateDir, ...deps.io === void 0 ? {} : { io: deps.io } });
14392
- const listRemoteDirectory = deps.listRemoteDirectory;
14393
- const remoteHomeDirectory = deps.remoteHomeDirectory;
14394
- const listLocalDirectory = deps.listLocalDirectory;
14395
- const readSshConfig = deps.readSshConfig;
14396
- const remoteFiles = deps.remoteFiles;
14397
- const transfers = deps.transfers;
14398
- const readCredentials = deps.readCredentials;
14399
- const testConnection = deps.testConnection;
14400
- const apiDeps = {
14401
- listRoots: () => deps.listRoots(),
14402
- listConnections: () => deps.listConnections(),
14403
- loadUserState: () => store2.load(),
14404
- mutateUserState: (transition) => store2.mutate(transition),
14405
- registerRoot: async (entry) => {
14406
- await deps.registerRoot(entry);
14407
- },
14408
- unregisterRoot: (key) => {
14409
- deps.unregisterRoot(key);
14410
- },
14411
- addConnection: async (entry) => {
14412
- const next = await store2.addConnection(entry);
14413
- deps.applyConnection(entry, entry.id);
14414
- return next;
14415
- },
14416
- applyConnection: async (entry, previousId) => {
14417
- deps.applyConnection(entry, previousId);
14418
- },
14419
- removeConnection: async (id) => {
14420
- deps.removeConnection(id);
14421
- },
14422
- ...deps.anchorRootDir === void 0 ? {} : { anchorRootDir: deps.anchorRootDir },
14423
- ...listRemoteDirectory === void 0 ? {} : { listRemoteDirectory: (input) => listRemoteDirectory(input) },
14424
- ...remoteHomeDirectory === void 0 ? {} : { remoteHomeDirectory },
14425
- ...listLocalDirectory === void 0 ? {} : { listLocalDirectory: (input) => listLocalDirectory(input) },
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 () => void 0;
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 mkdir5, readFile as readFile6, readdir as readdir4 } from "node:fs/promises";
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 join14 } from "node:path";
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: mkdir5 });
15996
+ await ensureAnchorDirectory(root, { mkdir: mkdir6 });
14857
15997
  registry.add(root);
14858
- await prepareBinRoots([root]);
14859
- hooks.runBinHousekeeping();
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.delete(previousId);
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.delete(profile.id);
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.delete(id);
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 readFile6(join14(homedir5(), ".ssh", "config"), "utf8");
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: mkdir6 }).catch((error) => {
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, { forgetPerConnection, runBinHousekeeping });
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 join15 } from "node:path";
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 createHash2 } from "node:crypto";
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 = createHash2("sha256");
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, text6) {
16557
+ async writeText(path, text8) {
15499
16558
  await mkdir8(dirname6(path), { recursive: true });
15500
- await writeFile5(path, text6, "utf8");
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, text6) {
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, text6, void 0);
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 = (text6) => {
15648
- choice.note = choice.note === void 0 ? text6 : `${choice.note}\uFF1B${text6}`;
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 ?? join15(tmpdir2(), "dsh-over-ssh")}/recycle-bin-${createHash3("sha256").update(key).digest("hex").slice(0, 16)}.lock`;
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 join17 } from "node:path";
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 text6 = await deps.readFile(deps.filePath, "utf8");
16108
- const parsed = parseUserRoots(text6);
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
- exitCode: { type: "integer", required: true },
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", required: true },
16339
- stderr: { type: "string", required: true },
16340
- durationMs: { type: "integer", required: true },
16341
- truncated: { type: "boolean", required: true },
16342
- stdoutTruncated: { type: "boolean", required: true },
16343
- stderrTruncated: { type: "boolean", required: true },
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(formatExec(value))
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, text6, failed, requested) {
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(text6);
16409
- const cdShaped = /\bcd:\s[^\n]*(no such file or directory|not a directory|permission denied)|can't cd to/i.test(text6);
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 defaultSleep(ms, signal) {
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 ?? defaultSleep;
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 = " Background jobs live inside this connection only: the remote agent kills every one of them when the ssh channel closes, so a job is not durable and does not survive a reconnect.";
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, text6, expectedVersion, signal) {
17138
- const outcome = await requireBackend(target).writeText(absolute, text6, expectedVersion, signal);
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
- for (let index = offset - 1; index < totalLines && selected.length < limit; index++) {
17235
- selected.push({ number: index + 1, text: truncateLine(stripCarriageReturn(lines[index] ?? ""), request.maxLineLength) });
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, text6) {
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, text6);
17832
- this.#sentTextBytes += text6.length;
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, text6, signal, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
17914
- const unchanged = this.#sentText.get(uri) === text6;
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, text6);
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, text6) {
17929
- if (this.#sentText.get(uri) === text6) return;
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: text6 }] });
17932
- this.#rememberSentText(uri, text6);
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: text6 } });
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, text6);
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 text6 = typeof record2?.["message"] === "string" ? record2["message"] : "";
18146
- if (text6 !== "") this.#options.onLog?.(text6);
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/remote-lsp-tools.ts
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
- defineTool5({
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 join16 } from "node:path";
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 ?? join16(homedir6(), ".dsh", "sessions");
18769
- const storagesRoot = join16(homedir6(), ".dsh", "storages");
18770
- const sessionBinRoot = limits.sessionBinRoot ?? join16(storagesRoot, "over-ssh", "session-trash");
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(join16(storagesRoot, "remote-workspace", "session-trash"), sessionBinRoot);
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(join16(path, entry.name));
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, text6) => {
20622
+ writeText: async (path, text8) => {
18825
20623
  await mkdir10(dirname8(path), { recursive: true });
18826
- await writeFile6(path, text6, "utf8");
20624
+ await writeFile6(path, text8, "utf8");
18827
20625
  },
18828
- writeTextAtomic: async (path, text6) => {
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, text6, "utf8");
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 defineTool6 } from "@deepseek-ai/dsh-tools";
18896
- function text5(value) {
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
- defineTool6({
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) => text5(formatTransfer(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
- defineTool6({
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) => text5(formatDiscard(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
- defineTool6({
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) => text5(formatRestore(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
- defineTool6({
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) => text5(formatCopy(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
- defineTool6({
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) => text5(formatEnv(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) await deps.renameRemote?.(destination, request.to);
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 join17(resolveStateDir(), USER_ROOTS_FILE_NAME);
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 disposeTools = registerRemoteTools(ctx, toolBackend);
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 disposeFileTools = registerRemoteFileTools(ctx, fileBackend);
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) => {