dsh-over-ssh 0.2.1 → 0.2.3

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
@@ -420,7 +420,7 @@ function createProxyForwarder(options) {
420
420
  }
421
421
  const idleMs = now() - current.lastActivityAt;
422
422
  if (idleMs < idleTimeoutMs) return;
423
- void closeEntry(current).then(() => options.onReclaimed?.(handle, idleMs));
423
+ void closeEntry(current).then(() => options.onReclaimed?.(handle, idleMs)).catch(() => void 0);
424
424
  }, checkMs);
425
425
  entry.timer.unref?.();
426
426
  }
@@ -1267,6 +1267,13 @@ ${buildRemoteScript(request.script)}`;
1267
1267
  ...request.env === void 0 ? {} : { env: request.env }
1268
1268
  });
1269
1269
  }
1270
+ /** Mark the transport lost **only if the dead session is still the current one** (a reconnect may have won). */
1271
+ #markSessionLost(session) {
1272
+ if (this.#session !== session) return;
1273
+ this.#sftp = void 0;
1274
+ this.#sftpPending = void 0;
1275
+ this.markState("lost");
1276
+ }
1270
1277
  #requireSession() {
1271
1278
  if (this.#session === void 0) {
1272
1279
  throw new SshDevError("CONNECTION_LOST", "SSH \u4F1A\u8BDD\u5C1A\u672A\u5EFA\u7ACB", { connectionId: this.id });
@@ -1324,8 +1331,13 @@ ${buildRemoteScript(request.script)}`;
1324
1331
  }
1325
1332
  const connector = this.#options.connector ?? defaultConnector;
1326
1333
  try {
1327
- this.#session = await connector(signal === void 0 ? connect2 : { ...connect2, signal });
1334
+ const session = await connector(signal === void 0 ? connect2 : { ...connect2, signal });
1335
+ this.#session = session;
1328
1336
  this.#sessionCount++;
1337
+ const onLost = () => this.#markSessionLost(session);
1338
+ session.on?.("close", onLost);
1339
+ session.on?.("error", onLost);
1340
+ session.on?.("end", onLost);
1329
1341
  this.markState("ready");
1330
1342
  } catch (error) {
1331
1343
  this.markState("lost");
@@ -1651,6 +1663,35 @@ var TransportService = class {
1651
1663
 
1652
1664
  // src/host/integration/session-scope.ts
1653
1665
  import { AsyncLocalStorage } from "node:async_hooks";
1666
+
1667
+ // src/host/integration/session-fork-pending.ts
1668
+ var pending = /* @__PURE__ */ new Map();
1669
+ var MAX_PENDING = 256;
1670
+ function markForkInheritancePending(childSessionId, sourceSessionId) {
1671
+ const child = childSessionId.trim();
1672
+ const source = sourceSessionId.trim();
1673
+ if (child === "" || source === "" || child === source) return;
1674
+ pending.delete(child);
1675
+ pending.set(child, source);
1676
+ while (pending.size > MAX_PENDING) {
1677
+ const oldest = pending.keys().next();
1678
+ if (oldest.done === true) break;
1679
+ pending.delete(oldest.value);
1680
+ }
1681
+ }
1682
+ function pendingForkSources(sessionId) {
1683
+ if (sessionId === void 0 || sessionId === "") return [];
1684
+ const source = pending.get(sessionId);
1685
+ return source === void 0 ? [] : [source];
1686
+ }
1687
+ function clearForkInheritancePending(childSessionId) {
1688
+ pending.delete(childSessionId);
1689
+ }
1690
+ function clearAllForkInheritancePending() {
1691
+ pending.clear();
1692
+ }
1693
+
1694
+ // src/host/integration/session-scope.ts
1654
1695
  var STORE_KEY = /* @__PURE__ */ Symbol.for("dsh-over-ssh/session-scope");
1655
1696
  function store() {
1656
1697
  const holder = globalThis;
@@ -1670,6 +1711,15 @@ var MAX_FORK_DEPTH = 32;
1670
1711
  var ancestorCache = /* @__PURE__ */ new Map();
1671
1712
  var ANCESTOR_CACHE_MAX = 256;
1672
1713
  function readSessionAncestors(ctx, sessionId) {
1714
+ const pending2 = pendingForkSources(sessionId);
1715
+ if (pending2.length > 0) {
1716
+ const inherited = /* @__PURE__ */ new Set();
1717
+ for (const source of pending2) {
1718
+ inherited.add(source);
1719
+ for (const ancestor of walkAncestors(ctx, source).ancestors) inherited.add(ancestor);
1720
+ }
1721
+ return [...inherited];
1722
+ }
1673
1723
  const cached2 = ancestorCache.get(sessionId);
1674
1724
  if (cached2 !== void 0) {
1675
1725
  ancestorCache.delete(sessionId);
@@ -2193,6 +2243,7 @@ var MAX_OUTPUT_BYTES = 64 * 1024;
2193
2243
  var DEFAULT_EXEC_TIMEOUT_MS = 3e4;
2194
2244
  var MAX_EXEC_TIMEOUT_MS = 3e5;
2195
2245
  var MAX_EXEC_ENV_ENTRIES = 64;
2246
+ var MAX_EXEC_BATCH_CONNECTIONS = 8;
2196
2247
  var ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
2197
2248
  function stringArg(value) {
2198
2249
  if (typeof value !== "string") return void 0;
@@ -3016,16 +3067,16 @@ function createTransferService(dependencies) {
3016
3067
  };
3017
3068
  const partialBytesOnRemote = async (absolute, context, signal) => {
3018
3069
  try {
3019
- const stat5 = await dependencies.statRemote(absolute);
3020
- return stat5.exists && stat5.isFile ? stat5.size : void 0;
3070
+ const stat7 = await dependencies.statRemote(absolute);
3071
+ return stat7.exists && stat7.isFile ? stat7.size : void 0;
3021
3072
  } catch (error) {
3022
3073
  throw failureFrom(error, "remote-stat-failed", "\u8BFB\u53D6\u8FDC\u7AEF\u76EE\u6807\u4FE1\u606F\u5931\u8D25\uFF08\u7EED\u4F20\u524D\u9700\u8981\u786E\u8BA4\u5DF2\u6709\u957F\u5EA6\uFF09", { ...context }, signal);
3023
3074
  }
3024
3075
  };
3025
3076
  const partialBytesOnLocal = async (path, context, signal) => {
3026
3077
  try {
3027
- const stat5 = await dependencies.statLocal(path);
3028
- return stat5.exists && stat5.isFile ? stat5.size : void 0;
3078
+ const stat7 = await dependencies.statLocal(path);
3079
+ return stat7.exists && stat7.isFile ? stat7.size : void 0;
3029
3080
  } catch (error) {
3030
3081
  throw failureFrom(error, "local-stat-failed", "\u8BFB\u53D6\u672C\u5730\u76EE\u6807\u4FE1\u606F\u5931\u8D25\uFF08\u7EED\u4F20\u524D\u9700\u8981\u786E\u8BA4\u5DF2\u6709\u957F\u5EA6\uFF09", { ...context }, signal);
3031
3082
  }
@@ -4321,6 +4372,7 @@ function parseSessionTrashIndex(text8) {
4321
4372
  segment: record2["segment"],
4322
4373
  originalSegment: record2["originalSegment"],
4323
4374
  title: typeof record2["title"] === "string" ? record2["title"] : void 0,
4375
+ ...typeof record2["parentSessionId"] === "string" && record2["parentSessionId"] !== "" ? { parentSessionId: record2["parentSessionId"] } : {},
4324
4376
  bytes: typeof record2["bytes"] === "number" && Number.isFinite(record2["bytes"]) ? record2["bytes"] : 0,
4325
4377
  deletedAtMs: record2["deletedAtMs"],
4326
4378
  movedBy: record2["movedBy"] === "copy" ? "copy" : "rename",
@@ -4367,6 +4419,7 @@ function makeSessionTrashEntry(input) {
4367
4419
  segment: input.segment,
4368
4420
  originalSegment: input.originalSegment,
4369
4421
  title: input.title,
4422
+ ...input.parentSessionId === void 0 ? {} : { parentSessionId: input.parentSessionId },
4370
4423
  bytes: input.bytes,
4371
4424
  deletedAtMs: input.deletedAtMs,
4372
4425
  movedBy: input.movedBy,
@@ -4494,13 +4547,14 @@ function createSessionAdminContext(deps) {
4494
4547
  const dir = joinPath(joinPath(sessionsRoot, project), session.name);
4495
4548
  const contents = await deps.listDir(dir);
4496
4549
  let bytes = 0;
4497
- let updatedAtMs = session.mtimeMs;
4550
+ let updatedAtMs = Math.trunc(session.mtimeMs);
4498
4551
  for (const item of contents) {
4499
4552
  if (item.isDirectory) continue;
4500
4553
  bytes += item.bytes;
4501
- if (item.mtimeMs > updatedAtMs) updatedAtMs = item.mtimeMs;
4554
+ const itemMs = Math.trunc(item.mtimeMs);
4555
+ if (itemMs > updatedAtMs) updatedAtMs = itemMs;
4502
4556
  }
4503
- const header = await deps.readHeader(dir);
4557
+ const header = await deps.readHeader(dir, contents);
4504
4558
  if (header === void 0) {
4505
4559
  return {
4506
4560
  id: session.name,
@@ -4641,6 +4695,7 @@ function createSessionAdminDelete(ctx) {
4641
4695
  segment: entryId,
4642
4696
  originalSegment: record2.segment,
4643
4697
  title: record2.title,
4698
+ parentSessionId: record2.parentSessionId,
4644
4699
  bytes: record2.bytes,
4645
4700
  deletedAtMs,
4646
4701
  movedBy,
@@ -4758,31 +4813,63 @@ function createSessionAdminService(deps) {
4758
4813
  if (entry === void 0) {
4759
4814
  throw new SshDevError("TRASH_NOT_FOUND", `\u56DE\u6536\u7AD9\u6761\u76EE\u4E0D\u5B58\u5728\uFF1A${entryId}`, { entryId });
4760
4815
  }
4761
- const target = joinPath(sessionsRoot, entry.projectDir, entry.originalSegment);
4762
- const occupied = await deps.stat(target) !== void 0;
4763
- const plan = planRestoreSession(entry, { occupied });
4764
- if (plan.kind === "refuse") {
4765
- throw new SshDevError("TRANSFER_CONFLICT", `\u539F\u8DEF\u5F84\u5DF2\u88AB\u5360\u7528\uFF0C\u62D2\u7EDD\u9759\u9ED8\u6362\u540D\u8FD8\u539F\uFF1A${target}`, {
4766
- entryId,
4767
- suggestedId: plan.suggestedId,
4768
- target
4769
- });
4770
- }
4771
- const from = entryDir(entry.segment);
4772
- let movedBy;
4773
- try {
4774
- movedBy = await deps.moveDir(from, target);
4775
- } catch (error) {
4776
- const landed = await deps.stat(target) !== void 0;
4777
- if (!landed) {
4778
- throw new SshDevError("REMOTE_COMMAND_FAILED", `\u8FD8\u539F\u4F1A\u8BDD\u5931\u8D25\uFF1A${entry.sessionId}`, {
4779
- entryId,
4780
- cause: error instanceof Error ? error.message : String(error)
4816
+ const restoreOne = async (item) => {
4817
+ const target = joinPath(sessionsRoot, item.projectDir, item.originalSegment);
4818
+ const occupied = await deps.stat(target) !== void 0;
4819
+ const plan = planRestoreSession(item, { occupied });
4820
+ if (plan.kind === "refuse") {
4821
+ throw new SshDevError("TRANSFER_CONFLICT", `\u539F\u8DEF\u5F84\u5DF2\u88AB\u5360\u7528\uFF0C\u62D2\u7EDD\u9759\u9ED8\u6362\u540D\u8FD8\u539F\uFF1A${target}`, {
4822
+ entryId: item.id,
4823
+ suggestedId: plan.suggestedId,
4824
+ target
4825
+ });
4826
+ }
4827
+ const from = entryDir(item.segment);
4828
+ let movedBy;
4829
+ try {
4830
+ movedBy = await deps.moveDir(from, target);
4831
+ } catch (error) {
4832
+ const landed = await deps.stat(target) !== void 0;
4833
+ if (!landed) {
4834
+ throw new SshDevError("REMOTE_COMMAND_FAILED", `\u8FD8\u539F\u4F1A\u8BDD\u5931\u8D25\uFF1A${item.sessionId}`, {
4835
+ entryId: item.id,
4836
+ cause: error instanceof Error ? error.message : String(error)
4837
+ });
4838
+ }
4839
+ movedBy = "copy";
4840
+ }
4841
+ return { entry: item, restoredPath: target, movedBy };
4842
+ };
4843
+ const parentStillBinned = entry.parentSessionId !== void 0 && entries.some((item) => item.sessionId === entry.parentSessionId);
4844
+ const restored = await restoreOne(entry);
4845
+ const childrenOf = /* @__PURE__ */ new Map();
4846
+ for (const item of entries) {
4847
+ if (item.id === entry.id || item.parentSessionId === void 0) continue;
4848
+ const bucket = childrenOf.get(item.parentSessionId);
4849
+ if (bucket === void 0) childrenOf.set(item.parentSessionId, [item]);
4850
+ else bucket.push(item);
4851
+ }
4852
+ const restoredSubsessions = [];
4853
+ const failedSubsessions = [];
4854
+ const removedIds = /* @__PURE__ */ new Set([entry.id]);
4855
+ const queue = [...childrenOf.get(entry.sessionId) ?? []];
4856
+ while (queue.length > 0) {
4857
+ const child = queue.shift();
4858
+ if (child === void 0) break;
4859
+ if (removedIds.has(child.id)) continue;
4860
+ try {
4861
+ const childRestored = await restoreOne(child);
4862
+ restoredSubsessions.push({ sessionId: child.sessionId, restoredPath: childRestored.restoredPath });
4863
+ removedIds.add(child.id);
4864
+ queue.push(...childrenOf.get(child.sessionId) ?? []);
4865
+ } catch (error) {
4866
+ failedSubsessions.push({
4867
+ sessionId: child.sessionId,
4868
+ reason: error instanceof Error ? error.message : String(error)
4781
4869
  });
4782
4870
  }
4783
- movedBy = "copy";
4784
4871
  }
4785
- const remaining = entries.filter((item) => item.id !== entryId);
4872
+ const remaining = entries.filter((item) => !removedIds.has(item.id));
4786
4873
  try {
4787
4874
  await writeIndex(remaining);
4788
4875
  } catch (error) {
@@ -4791,7 +4878,7 @@ function createSessionAdminService(deps) {
4791
4878
  cause: error instanceof Error ? error.message : String(error)
4792
4879
  });
4793
4880
  }
4794
- return { entry, restoredPath: target, movedBy };
4881
+ return { ...restored, restoredSubsessions, failedSubsessions, parentStillBinned };
4795
4882
  });
4796
4883
  const purge = (entryId, options = {}) => {
4797
4884
  void options;
@@ -4803,7 +4890,6 @@ function createSessionAdminService(deps) {
4803
4890
  const ordered = [...entries].sort((left, right) => left.deletedAtMs - right.deletedAtMs);
4804
4891
  const doomed = [];
4805
4892
  const nowMs = deps.now();
4806
- let keptBytes = ordered.reduce((sum, item) => sum + item.bytes, 0);
4807
4893
  if (policy.maxAgeMs > 0) {
4808
4894
  for (const entry of ordered) {
4809
4895
  if (nowMs - entry.deletedAtMs > policy.maxAgeMs) doomed.push(entry);
@@ -4812,6 +4898,7 @@ function createSessionAdminService(deps) {
4812
4898
  const doomedIds = new Set(doomed.map((entry) => entry.id));
4813
4899
  const survivors = ordered.filter((entry) => !doomedIds.has(entry.id));
4814
4900
  if (policy.maxBytes > 0) {
4901
+ let keptBytes = survivors.reduce((sum, item) => sum + item.bytes, 0);
4815
4902
  for (const entry of survivors) {
4816
4903
  if (keptBytes <= policy.maxBytes) break;
4817
4904
  doomed.push(entry);
@@ -4824,7 +4911,7 @@ function createSessionAdminService(deps) {
4824
4911
  const excess = stillKept.length - policy.maxEntries;
4825
4912
  for (const entry of stillKept.slice(0, excess)) {
4826
4913
  doomed.push(entry);
4827
- keptBytes -= entry.bytes;
4914
+ doomedIds.add(entry.id);
4828
4915
  }
4829
4916
  }
4830
4917
  const outcome = await purgeEntries(doomed, entries);
@@ -4840,11 +4927,54 @@ function createSessionAdminService(deps) {
4840
4927
  }
4841
4928
 
4842
4929
  // src/host/integration/session/session-header.ts
4843
- import { open, readdir } from "node:fs/promises";
4930
+ import { open, readdir, stat } from "node:fs/promises";
4844
4931
  import { join } from "node:path";
4845
4932
  import { createZstdDecompress } from "node:zlib";
4846
4933
  var SESSION_HEADER_READ_BYTES = 64 * 1024;
4847
4934
  var SESSION_HEADER_RETRY_BYTES = 1024 * 1024;
4935
+ function createCachedSessionHeaderReader(io, options = {}) {
4936
+ const inner = createSessionHeaderReader(io);
4937
+ const stat7 = io.stat;
4938
+ if (stat7 === void 0) return inner;
4939
+ const maxEntries = Math.max(1, Math.floor(options.maxEntries ?? SESSION_HEADER_CACHE_MAX));
4940
+ const cache = /* @__PURE__ */ new Map();
4941
+ return async (dir, listing) => {
4942
+ let signature;
4943
+ if (listing !== void 0) {
4944
+ const log = listing.find((entry) => entry.name.startsWith("session.v") && entry.name.endsWith(".zstd"));
4945
+ if (log !== void 0) signature = `${log.size}:${Math.trunc(log.mtimeMs)}`;
4946
+ } else {
4947
+ try {
4948
+ const names = await io.listDir(dir);
4949
+ const log = names.find((name2) => name2.startsWith("session.v") && name2.endsWith(".zstd"));
4950
+ if (log !== void 0) {
4951
+ const info = await stat7(join(dir, log));
4952
+ signature = `${info.size}:${Math.trunc(info.mtimeMs)}`;
4953
+ }
4954
+ } catch {
4955
+ }
4956
+ }
4957
+ if (signature !== void 0) {
4958
+ const hit = cache.get(dir);
4959
+ if (hit !== void 0 && hit.signature === signature) {
4960
+ cache.delete(dir);
4961
+ cache.set(dir, hit);
4962
+ return hit.header;
4963
+ }
4964
+ }
4965
+ const header = await inner(dir);
4966
+ if (signature !== void 0) {
4967
+ cache.set(dir, { signature, header });
4968
+ while (cache.size > maxEntries) {
4969
+ const oldest = cache.keys().next();
4970
+ if (oldest.done === true) break;
4971
+ cache.delete(oldest.value);
4972
+ }
4973
+ }
4974
+ return header;
4975
+ };
4976
+ }
4977
+ var SESSION_HEADER_CACHE_MAX = 2048;
4848
4978
  function parseSessionHeaderLine(text8) {
4849
4979
  const firstLine = text8.split("\n")[0];
4850
4980
  if (firstLine === void 0 || firstLine === "") return void 0;
@@ -4930,11 +5060,16 @@ function createDiskSessionHeaderIo() {
4930
5060
  return {
4931
5061
  listDir: (dir) => readdir(dir),
4932
5062
  readHead: readHeadFromDisk,
4933
- decompress: decompressPrefix
5063
+ decompress: decompressPrefix,
5064
+ // The cache's only input: `(size, mtime)` is what a session log's append changes.
5065
+ stat: async (path) => {
5066
+ const info = await stat(path);
5067
+ return { size: info.size, mtimeMs: info.mtimeMs };
5068
+ }
4934
5069
  };
4935
5070
  }
4936
5071
  function createDiskSessionHeaderReader() {
4937
- return createSessionHeaderReader(createDiskSessionHeaderIo());
5072
+ return createCachedSessionHeaderReader(createDiskSessionHeaderIo());
4938
5073
  }
4939
5074
 
4940
5075
  // src/host/app/routed-fs.ts
@@ -5820,17 +5955,25 @@ var RootRegistry = class {
5820
5955
  * sessions at all: an empty list means "the session service was not readable", not "no session is live", and
5821
5956
  * dropping every grant on a transient read failure would break live turns.
5822
5957
  *
5823
- * @param cwds - working directories of the live sessions.
5958
+ * @param cwds - working directories of the live sessions, optionally with the session id that owns each one.
5959
+ * With an id the root carries `sessions: [id]` and is writable **only by that session**; without one it is a
5960
+ * workspace-scoped root (the legacy shape tests and agentless compositions use).
5824
5961
  * @returns the session roots this call added (empty when every cwd was already covered).
5825
5962
  */
5826
5963
  syncSessionWorkspaces(cwds) {
5827
5964
  if (cwds.length === 0) return [];
5828
5965
  const live = /* @__PURE__ */ new Set();
5829
5966
  const added = [];
5830
- for (const cwd of cwds) {
5967
+ for (const item of cwds) {
5968
+ const cwd = typeof item === "string" ? item : item.cwd;
5969
+ const sessionId = typeof item === "string" ? void 0 : item.sessionId;
5831
5970
  if (typeof cwd !== "string" || cwd === "") continue;
5832
5971
  if (!isAbsoluteLocalPath(normalizeLocalPath2(cwd, { caseInsensitive: false }))) continue;
5833
- const root = makeLocalRoot(cwd, { workspaces: [cwd], writable: true });
5972
+ const root = makeLocalRoot(cwd, {
5973
+ workspaces: [cwd],
5974
+ ...sessionId === void 0 || sessionId === "" ? {} : { sessions: [sessionId] },
5975
+ writable: true
5976
+ });
5834
5977
  const key = rootKey(root);
5835
5978
  live.add(key);
5836
5979
  if (this.#localRootCovers(cwd)) continue;
@@ -6007,7 +6150,7 @@ function carryMissingState(legacy, current, files, dirs) {
6007
6150
  renameSync(temp, to);
6008
6151
  } catch (error) {
6009
6152
  rmSync(temp, { force: true });
6010
- failures.push(`${from} \u2192 ${to}\uFF1A${describe(error)}`);
6153
+ if (!existsSync(to)) failures.push(`${from} \u2192 ${to}\uFF1A${describe(error)}`);
6011
6154
  }
6012
6155
  }
6013
6156
  for (const name2 of dirs) {
@@ -6020,7 +6163,7 @@ function carryMissingState(legacy, current, files, dirs) {
6020
6163
  renameSync(temp, to);
6021
6164
  } catch (error) {
6022
6165
  rmSync(temp, { recursive: true, force: true });
6023
- failures.push(`${from} \u2192 ${to}\uFF1A${describe(error)}`);
6166
+ if (!existsSync(to)) failures.push(`${from} \u2192 ${to}\uFF1A${describe(error)}`);
6024
6167
  }
6025
6168
  }
6026
6169
  return failures;
@@ -6446,9 +6589,9 @@ var AgentFrameParser = class {
6446
6589
  */
6447
6590
  #append(chunk) {
6448
6591
  if (chunk.byteLength === 0) return;
6449
- const pending = this.#end - this.#start;
6450
- if (this.#store.byteLength - this.#start < pending + chunk.byteLength) {
6451
- const needed = pending + chunk.byteLength;
6592
+ const pending2 = this.#end - this.#start;
6593
+ if (this.#store.byteLength - this.#start < pending2 + chunk.byteLength) {
6594
+ const needed = pending2 + chunk.byteLength;
6452
6595
  if (this.#store.byteLength >= needed) {
6453
6596
  this.#store.copyWithin(0, this.#start, this.#end);
6454
6597
  } else {
@@ -6457,7 +6600,7 @@ var AgentFrameParser = class {
6457
6600
  this.#store = grown;
6458
6601
  }
6459
6602
  this.#start = 0;
6460
- this.#end = pending;
6603
+ this.#end = pending2;
6461
6604
  }
6462
6605
  this.#store.set(chunk, this.#end);
6463
6606
  this.#end += chunk.byteLength;
@@ -7242,9 +7385,9 @@ var AgentProtocolClient = class {
7242
7385
  failConnection(error) {
7243
7386
  if (this.#lost !== void 0) return;
7244
7387
  this.#lost = error;
7245
- const pending = [...this.#pending.values()];
7388
+ const pending2 = [...this.#pending.values()];
7246
7389
  this.#pending.clear();
7247
- for (const entry of pending) entry.settle({ error });
7390
+ for (const entry of pending2) entry.settle({ error });
7248
7391
  for (const channel of this.#blobQueue) channel.fail(error);
7249
7392
  this.#blobQueue.length = 0;
7250
7393
  this.#blobById.clear();
@@ -8129,14 +8272,14 @@ var AgentTransport = class extends TransportBase {
8129
8272
  }
8130
8273
  }
8131
8274
  if (expectedVersion === void 0) {
8132
- let stat5;
8275
+ let stat7;
8133
8276
  try {
8134
- stat5 = asRecord(await client.request("fs.stat", { path: request.path }, this.#requestOptions(void 0, signal)));
8277
+ stat7 = asRecord(await client.request("fs.stat", { path: request.path }, this.#requestOptions(void 0, signal)));
8135
8278
  } catch (error) {
8136
8279
  throw this.#translate(error);
8137
8280
  }
8138
- const exists = stat5?.["exists"] === true;
8139
- const observed = typeof stat5?.["version"] === "string" ? stat5["version"] : void 0;
8281
+ const exists = stat7?.["exists"] === true;
8282
+ const observed = typeof stat7?.["version"] === "string" ? stat7["version"] : void 0;
8140
8283
  if (!exists || observed === void 0 || observed !== expected) {
8141
8284
  throw new SshDevError("TRANSFER_CONFLICT", "\u8FDC\u7AEF\u6587\u4EF6\u7248\u672C\u4E0E\u8C03\u7528\u65B9\u7ED9\u51FA\u7684\u5B88\u536B\u4E0D\u4E00\u81F4\uFF0C\u5199\u5165\u88AB\u62D2\u7EDD", {
8142
8285
  connectionId: this.id,
@@ -8645,9 +8788,16 @@ var AgentTransport = class extends TransportBase {
8645
8788
  state: typeof record2["state"] === "string" ? record2["state"] : "unknown"
8646
8789
  };
8647
8790
  }
8648
- /** Read a language server's output from `offset`: `lsp.read`. Same offset semantics as {@link procOutput}. */
8649
- async lspRead(id, offset = 0, signal) {
8650
- const result = await this.#rpc("lsp.read", { id, offset }, signal);
8791
+ /**
8792
+ * Read a language server's output from `offset`: `lsp.read`. Same offset semantics as {@link procOutput}.
8793
+ *
8794
+ * `maxBytes` bounds one response. The agent defaults it to its 2 MiB chunk; callers that poll (the LSP pump)
8795
+ * pass a smaller cap so a quiet server does not cost 2 MiB of transient host memory per poll. Agents older
8796
+ * than the field ignore it (Go's JSON decoder ignores unknown fields), so it is safe to always send.
8797
+ */
8798
+ async lspRead(id, offset = 0, signal, maxBytes) {
8799
+ const params = maxBytes === void 0 ? { id, offset } : { id, offset, maxBytes: Math.max(1, Math.floor(maxBytes)) };
8800
+ const result = await this.#rpc("lsp.read", params, signal);
8651
8801
  return readLspRead(result);
8652
8802
  }
8653
8803
  /** One language-server session's state: `lsp.status`. */
@@ -9394,7 +9544,7 @@ function localVersion(info) {
9394
9544
  init_temp_name();
9395
9545
  init_errors();
9396
9546
  import { constants } from "node:fs";
9397
- import { access, chmod, lstat, mkdir, open as open2, readdir as readdir2, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
9547
+ import { access, chmod, lstat, mkdir, open as open2, readdir as readdir2, rename, rm, stat as stat2, unlink, writeFile } from "node:fs/promises";
9398
9548
  import { dirname as dirname3, join as join5 } from "node:path";
9399
9549
  var LIST_STAT_CONCURRENCY = 32;
9400
9550
  var LocalBackend = class {
@@ -9407,12 +9557,12 @@ var LocalBackend = class {
9407
9557
  this.id = options.id;
9408
9558
  this.sandboxMode = options.sandboxMode ?? "workspace-write";
9409
9559
  this.#maxReadBytes = options.maxReadBytes ?? 16 * 1024 * 1024;
9410
- this.#statEntry = options.statEntry ?? ((path) => stat(path));
9560
+ this.#statEntry = options.statEntry ?? ((path) => stat2(path));
9411
9561
  }
9412
9562
  async stat(absolute, signal) {
9413
9563
  signal?.throwIfAborted();
9414
9564
  try {
9415
- const info = await stat(absolute);
9565
+ const info = await stat2(absolute);
9416
9566
  return {
9417
9567
  exists: true,
9418
9568
  isDirectory: info.isDirectory(),
@@ -10069,7 +10219,9 @@ function buildListScript(absolute) {
10069
10219
  `if [ ! -d "$p" ]; then printf 'DSH_ERROR=not-a-dir\\n'; exit 4; fi`,
10070
10220
  // GNU find (Linux). `-printf ''` is the cheap capability probe: it fails on BSD/macOS find.
10071
10221
  `if find "$p" -maxdepth 1 -printf '' >/dev/null 2>&1; then`,
10072
- ` find "$p" -mindepth 1 -maxdepth 1 -printf '%y\\t%Y\\t%s\\t%T@\\t%f\\n'`,
10222
+ // **NUL 分隔**:文件名可以含换行(POSIX 合法,`git checkout` 就能造出来),按行切会把一个条目拆成
10223
+ // 两行、解析出一个不存在的名字并丢掉真条目(审计 2026-11-06 第 3 轮 P1)。
10224
+ ` find "$p" -mindepth 1 -maxdepth 1 -printf '%y\\t%Y\\t%s\\t%T@\\t%f\\0'`,
10073
10225
  `else`,
10074
10226
  ` for entry in "$p"/* "$p"/.[!.]*; do`,
10075
10227
  ` [ -e "$entry" ] || continue`,
@@ -10077,28 +10229,28 @@ function buildListScript(absolute) {
10077
10229
  ` if [ -d "$entry" ]; then kind=d; elif [ -L "$entry" ]; then kind=l; else kind=f; fi`,
10078
10230
  ` size=$(wc -c < "$entry" 2>/dev/null | tr -d ' ' || echo 0)`,
10079
10231
  ` mtime=$(stat -c '%Y' "$entry" 2>/dev/null || stat -f '%m' "$entry" 2>/dev/null || echo 0)`,
10080
- ` printf '%s\\t%s\\t%s\\t%s\\t%s\\n' "$kind" "$kind" "$size" "$mtime" "$name"`,
10232
+ ` printf '%s\\t%s\\t%s\\t%s\\t%s\\0' "$kind" "$kind" "$size" "$mtime" "$name"`,
10081
10233
  ` done`,
10082
10234
  `fi`
10083
10235
  ].join("\n");
10084
10236
  }
10085
10237
  function parseListing(stdout) {
10086
10238
  const entries = [];
10087
- for (const line of stdout.split("\n")) {
10088
- if (line === "" || line.startsWith("DSH_")) continue;
10089
- const parts = line.split(" ");
10239
+ for (const record2 of stdout.split("\0")) {
10240
+ if (record2 === "" || record2.startsWith("DSH_")) continue;
10241
+ const parts = record2.split(" ");
10090
10242
  if (parts.length < 5) continue;
10091
10243
  const [typeChar, targetChar, sizeText, mtimeText] = parts;
10092
10244
  const kind = typeChar === "d" ? "d" : typeChar === "l" ? targetChar === "d" ? "d" : "l" : "f";
10093
10245
  const size = Number.parseInt(sizeText, 10);
10094
- const seconds = Number.parseInt(mtimeText, 10);
10246
+ const seconds = Number.parseFloat(mtimeText);
10095
10247
  entries.push({
10096
10248
  name: parts.slice(4).join(" "),
10097
10249
  isDirectory: kind === "d",
10098
10250
  isFile: kind === "f",
10099
10251
  isSymlink: kind === "l",
10100
10252
  size: Number.isFinite(size) ? size : 0,
10101
- mtimeMs: (Number.isFinite(seconds) ? seconds : 0) * 1e3
10253
+ mtimeMs: Number.isFinite(seconds) ? Math.trunc(seconds * 1e3) : 0
10102
10254
  });
10103
10255
  }
10104
10256
  entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
@@ -10486,6 +10638,13 @@ var RemoteBackend = class {
10486
10638
  if (result.stdout.includes("DSH_ERROR=not-a-dir")) {
10487
10639
  throw new SshDevError("REMOTE_NOT_FOUND", `\u8FDC\u7AEF\u76EE\u5F55\u4E0D\u5B58\u5728\uFF1A${absolute}`, { path: absolute });
10488
10640
  }
10641
+ if (result.truncated === true) {
10642
+ throw new SshDevError("REMOTE_COMMAND_FAILED", `\u76EE\u5F55\u6761\u76EE\u8FC7\u591A\uFF0C\u5217\u4E3E\u7ED3\u679C\u88AB\u622A\u65AD\uFF1A${absolute}`, {
10643
+ path: absolute,
10644
+ reason: "listing-truncated",
10645
+ maxBytes: 4 * 1024 * 1024
10646
+ });
10647
+ }
10489
10648
  return parseListing(result.stdout);
10490
10649
  }
10491
10650
  /**
@@ -11127,7 +11286,21 @@ var RemoteBackend = class {
11127
11286
  }
11128
11287
  const command = tool === "sha256sum" ? "sha256sum" : "shasum -a 256";
11129
11288
  const result = await (await this.#transport()).exec({ argv: ["sh", "-c", `${command} -- "$1" | cut -d' ' -f1`, "dsh-digest", absolute] }, signal);
11130
- return result.stdout.trim();
11289
+ if (result.exitCode !== 0) {
11290
+ throw new SshDevError("REMOTE_COMMAND_FAILED", `\u65E0\u6CD5\u8BA1\u7B97\u8FDC\u7AEF\u6587\u4EF6\u7684 sha256\uFF08\u6458\u8981\u547D\u4EE4\u9000\u51FA\u7801 ${result.exitCode}\uFF09\uFF1A${absolute}`, {
11291
+ path: absolute,
11292
+ exitCode: result.exitCode,
11293
+ stderr: result.stderr.slice(0, 200)
11294
+ });
11295
+ }
11296
+ const digest = result.stdout.trim();
11297
+ if (!/^[0-9a-f]{64}$/.test(digest)) {
11298
+ throw new SshDevError("REMOTE_COMMAND_FAILED", `\u8FDC\u7AEF\u6458\u8981\u5DE5\u5177\u8FD4\u56DE\u4E86\u975E\u6CD5\u6458\u8981\uFF08\u4E0D\u662F 64 \u4F4D\u5C0F\u5199\u5341\u516D\u8FDB\u5236\uFF09\uFF1A${absolute}`, {
11299
+ path: absolute,
11300
+ digest: digest.slice(0, 120)
11301
+ });
11302
+ }
11303
+ return digest;
11131
11304
  }
11132
11305
  };
11133
11306
  function sha256Hex(bytes) {
@@ -11958,12 +12131,12 @@ init_ssh2_transport();
11958
12131
 
11959
12132
  // src/host/integration/agent/agent-install-io.ts
11960
12133
  init_shell_quote();
11961
- import { readFile as readFile2, stat as stat2 } from "node:fs/promises";
12134
+ import { readFile as readFile2, stat as stat3 } from "node:fs/promises";
11962
12135
  var DEFAULT_MAX_UPLOAD_BYTES = 32 * 1024 * 1024;
11963
12136
  function createAgentInstallIo(options) {
11964
12137
  const maxUploadBytes = options.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
11965
12138
  const readLocal = options.readLocalFile ?? ((path) => readFile2(path).then((buffer) => new Uint8Array(buffer)));
11966
- const sizeOf = options.localSize ?? ((path) => stat2(path).then((info) => info.size));
12139
+ const sizeOf = options.localSize ?? ((path) => stat3(path).then((info) => info.size));
11967
12140
  const exec = async (command, signal) => {
11968
12141
  const result = await options.transport.exec(
11969
12142
  { argv: ["/bin/sh", "-c", command], maxBytes: options.probeMaxBytes ?? 256 * 1024 },
@@ -12046,14 +12219,14 @@ function reviseToTier1(profile, decision, reason, hint) {
12046
12219
  // src/host/integration/agent/askpass-registry.ts
12047
12220
  function createAskpassRegistry(options) {
12048
12221
  const handles = /* @__PURE__ */ new Map();
12049
- const pending = /* @__PURE__ */ new Map();
12222
+ const pending2 = /* @__PURE__ */ new Map();
12050
12223
  const retired = /* @__PURE__ */ new Set();
12051
12224
  const prepare = async (profile) => {
12052
12225
  if (!needsAskpass(profile.auth.kind)) return void 0;
12053
12226
  if (retired.has(profile.id)) return void 0;
12054
12227
  const existing = handles.get(profile.id);
12055
12228
  if (existing !== void 0) return existing.env;
12056
- const inflight = pending.get(profile.id);
12229
+ const inflight = pending2.get(profile.id);
12057
12230
  if (inflight !== void 0) return inflight;
12058
12231
  const attempt = (async () => {
12059
12232
  try {
@@ -12070,16 +12243,16 @@ function createAskpassRegistry(options) {
12070
12243
  return void 0;
12071
12244
  }
12072
12245
  })().finally(() => {
12073
- pending.delete(profile.id);
12246
+ pending2.delete(profile.id);
12074
12247
  });
12075
- pending.set(profile.id, attempt);
12248
+ pending2.set(profile.id, attempt);
12076
12249
  return attempt;
12077
12250
  };
12078
12251
  const forget = async (connectionId) => {
12079
12252
  retired.add(connectionId);
12080
12253
  const handle = handles.get(connectionId);
12081
12254
  handles.delete(connectionId);
12082
- pending.delete(connectionId);
12255
+ pending2.delete(connectionId);
12083
12256
  if (handle !== void 0) await handle.dispose().catch(() => void 0);
12084
12257
  };
12085
12258
  return {
@@ -12097,7 +12270,7 @@ function createAskpassRegistry(options) {
12097
12270
  retired.delete(connectionId);
12098
12271
  },
12099
12272
  disposeAll: async () => {
12100
- const ids = /* @__PURE__ */ new Set([...handles.keys(), ...pending.keys()]);
12273
+ const ids = /* @__PURE__ */ new Set([...handles.keys(), ...pending2.keys()]);
12101
12274
  for (const id of ids) retired.add(id);
12102
12275
  await Promise.all([...ids].map((id) => forget(id)));
12103
12276
  }
@@ -12482,7 +12655,7 @@ function directoryProbeCommand(root) {
12482
12655
  return `test -d ${shellQuote(root)}`;
12483
12656
  }
12484
12657
  function rankMatches(paths, query, limit) {
12485
- const needle = query.trim().toLowerCase();
12658
+ const needle = separatorsForMatching(query.trim().toLowerCase());
12486
12659
  if (needle === "") {
12487
12660
  const top = [];
12488
12661
  const better = (left, right) => {
@@ -12507,7 +12680,8 @@ function rankMatches(paths, query, limit) {
12507
12680
  }
12508
12681
  const scored = [];
12509
12682
  for (const path of paths) {
12510
- const lower = path.toLowerCase();
12683
+ const normalized = separatorsForMatching(path);
12684
+ const lower = normalized.toLowerCase();
12511
12685
  const base = lower.slice(lower.lastIndexOf("/") + 1);
12512
12686
  let score;
12513
12687
  if (base.startsWith(needle)) score = 0;
@@ -12522,11 +12696,15 @@ function rankMatches(paths, query, limit) {
12522
12696
  );
12523
12697
  return scored.slice(0, limit).map((entry) => entry.path);
12524
12698
  }
12699
+ function separatorsForMatching(path) {
12700
+ return path.includes("\\") && !path.includes("/") ? path.replaceAll("\\", "/") : path;
12701
+ }
12525
12702
  function depth2(path) {
12526
12703
  let segments = 0;
12527
12704
  let inSegment = false;
12528
12705
  for (let index = 0; index < path.length; index += 1) {
12529
- if (path.charCodeAt(index) === 47) {
12706
+ const code = path.charCodeAt(index);
12707
+ if (code === 47 || code === 92) {
12530
12708
  inSegment = false;
12531
12709
  continue;
12532
12710
  }
@@ -13455,12 +13633,8 @@ function wireConnections(ctx, config) {
13455
13633
  }
13456
13634
  function readLiveSessions(ctx) {
13457
13635
  let list;
13458
- try {
13459
- const sessions = ctx.get("sessions");
13460
- list = sessions?.list?.();
13461
- } catch {
13462
- return [];
13463
- }
13636
+ const sessions = ctx.get("sessions");
13637
+ list = sessions?.list?.();
13464
13638
  if (!Array.isArray(list)) return [];
13465
13639
  const live = [];
13466
13640
  for (const session of list) {
@@ -13482,9 +13656,9 @@ import { bindTypertRemote } from "@deepseek-ai/dsh-typert-protocol";
13482
13656
  // src/shared/remote-identity.ts
13483
13657
  var SSH_DEV_REMOTE_NAMESPACE = "sshdev";
13484
13658
  var SSH_DEV_REMOTE_SERVICE = "sshDevRemote";
13659
+ var SSH_DEV_REMOTE_METHOD = "connections";
13485
13660
 
13486
13661
  // src/host/integration/remote/remote-face.ts
13487
- var SSH_DEV_REMOTE_CONNECTIONS_METHOD = "connections";
13488
13662
  var NOT_CONNECTED_NOTE = "\u5C1A\u672A\u5EFA\u7ACB\u8FDE\u63A5\uFF1A\u4F20\u8F93\u51B3\u7B56\u5728\u9996\u6B21\u8FDE\u63A5\u65F6\u786E\u5B9A\u3002";
13489
13663
  function buildConnectionStatusRows(source) {
13490
13664
  return source.profiles().map((profile) => {
@@ -13549,7 +13723,7 @@ var SshDevRemoteFace = class extends Service {
13549
13723
  return buildConnectionStatusRows(this.#source);
13550
13724
  }
13551
13725
  };
13552
- markRemoteMethod(SshDevRemoteFace.prototype, SSH_DEV_REMOTE_CONNECTIONS_METHOD);
13726
+ markRemoteMethod(SshDevRemoteFace.prototype, SSH_DEV_REMOTE_METHOD);
13553
13727
  function installRemoteFace(ctx) {
13554
13728
  return new SshDevRemoteFace(ctx, {
13555
13729
  profiles: () => ctx.get("sshDevConnections")?.list() ?? [],
@@ -13636,6 +13810,8 @@ function sessionToolDefinitions(service) {
13636
13810
  sessions: { type: "array", items: { type: "string" }, description: "`list`: one formatted line per session." },
13637
13811
  entries: { type: "array", items: { type: "string" }, description: "`restore` without an id: bin entry ids." },
13638
13812
  restoredPath: { type: "string", description: "`restore` with an id: where the entry went back to." },
13813
+ restoredSubsessions: { type: "integer", description: "`restore`: sub-sessions restored together with the entry." },
13814
+ failedSubsessions: { type: "integer", description: "`restore`: sub-sessions that stayed in the bin (path occupied / move failed)." },
13639
13815
  matched: { type: "integer", description: "`delete_workspace`: how many sessions belonged to the directory." },
13640
13816
  mode: { type: "string", description: "`delete` / `delete_workspace`: the mode that was applied." },
13641
13817
  recoverable: { type: "boolean", description: "`delete`: whether the bin can bring it back." },
@@ -13671,10 +13847,16 @@ function sessionToolDefinitions(service) {
13671
13847
  }
13672
13848
  if (typeof entryId !== "string" || entryId === "") throw new Error("entryId \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\uFF08\u7701\u7565\u5219\u5217\u51FA\u56DE\u6536\u7AD9\uFF09");
13673
13849
  const result = await service.restore(entryId);
13850
+ const extra = [];
13851
+ if (result.restoredSubsessions.length > 0) extra.push(`\u5E76\u8FD8\u539F\u5B50\u4F1A\u8BDD ${result.restoredSubsessions.length} \u4E2A`);
13852
+ if (result.failedSubsessions.length > 0) extra.push(`${result.failedSubsessions.length} \u4E2A\u5B50\u4F1A\u8BDD\u4ECD\u5728\u56DE\u6536\u7AD9`);
13853
+ if (result.parentStillBinned) extra.push("\u5176\u7236\u4F1A\u8BDD\u4ECD\u5728\u56DE\u6536\u7AD9");
13674
13854
  return {
13675
13855
  action: "restore",
13676
13856
  restoredPath: result.restoredPath,
13677
- summary: `\u5DF2\u8FD8\u539F\u4F1A\u8BDD ${result.entry.sessionId} -> ${result.restoredPath}`
13857
+ restoredSubsessions: result.restoredSubsessions.length,
13858
+ failedSubsessions: result.failedSubsessions.length,
13859
+ summary: `\u5DF2\u8FD8\u539F\u4F1A\u8BDD ${result.entry.sessionId} -> ${result.restoredPath}${extra.length === 0 ? "" : `\uFF08${extra.join("\uFF0C")}\uFF09`}`
13678
13860
  };
13679
13861
  }
13680
13862
  const mode = record2["mode"] ?? "recycle";
@@ -13753,7 +13935,7 @@ import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools";
13753
13935
  init_errors();
13754
13936
 
13755
13937
  // src/host/app/roots/user-roots.ts
13756
- import { mkdir as mkdir4, readFile as readFile5, rename as rename3, unlink as unlink3, writeFile as writeFile4 } from "node:fs/promises";
13938
+ import { mkdir as mkdir4, readFile as readFile5, rename as rename3, stat as stat4, unlink as unlink3, writeFile as writeFile4 } from "node:fs/promises";
13757
13939
  import { join as join12 } from "node:path";
13758
13940
  init_errors();
13759
13941
  var USER_ROOTS_VERSION = 1;
@@ -13779,6 +13961,10 @@ var nodeUserRootsIo = {
13779
13961
  await mkdir4(path, { recursive: true, mode: 448 });
13780
13962
  },
13781
13963
  rename: (from, to) => rename3(from, to),
13964
+ stat: async (path) => {
13965
+ const info = await stat4(path);
13966
+ return { size: info.size, mtimeMs: info.mtimeMs };
13967
+ },
13782
13968
  unlink: async (path) => {
13783
13969
  try {
13784
13970
  await unlink3(path);
@@ -14141,7 +14327,19 @@ function createUserRootsStore(options) {
14141
14327
  const filePath = join12(options.dir, USER_ROOTS_FILE_NAME);
14142
14328
  let cachedRaw;
14143
14329
  let cachedLoad;
14330
+ let cachedSignature;
14331
+ const signatureOf = async () => {
14332
+ if (io.stat === void 0) return void 0;
14333
+ try {
14334
+ const info = await io.stat(filePath);
14335
+ return `${info.size}:${Math.trunc(info.mtimeMs)}`;
14336
+ } catch {
14337
+ return void 0;
14338
+ }
14339
+ };
14144
14340
  const load = async () => {
14341
+ const signature = await signatureOf();
14342
+ if (signature !== void 0 && signature === cachedSignature && cachedLoad !== void 0) return cachedLoad;
14145
14343
  let text8;
14146
14344
  try {
14147
14345
  text8 = await io.readFile(filePath);
@@ -14153,10 +14351,14 @@ function createUserRootsStore(options) {
14153
14351
  degraded: true
14154
14352
  };
14155
14353
  }
14156
- if (cachedRaw !== void 0 && text8 === cachedRaw && cachedLoad !== void 0) return cachedLoad;
14354
+ if (cachedRaw !== void 0 && text8 === cachedRaw && cachedLoad !== void 0) {
14355
+ cachedSignature = signature;
14356
+ return cachedLoad;
14357
+ }
14157
14358
  const parsed = parseUserRoots(text8);
14158
14359
  cachedRaw = text8;
14159
14360
  cachedLoad = parsed;
14361
+ cachedSignature = signature;
14160
14362
  return parsed;
14161
14363
  };
14162
14364
  const write = async (next, preparedText) => {
@@ -14173,6 +14375,7 @@ function createUserRootsStore(options) {
14173
14375
  }
14174
14376
  cachedRaw = text8;
14175
14377
  cachedLoad = { state: validated, warnings: [], degraded: false };
14378
+ cachedSignature = void 0;
14176
14379
  return validated;
14177
14380
  };
14178
14381
  const queue = createSerialQueue();
@@ -14404,6 +14607,9 @@ var STATUS_BY_CODE = {
14404
14607
  ROOT_INVALID: 400,
14405
14608
  CONNECTION_INVALID: 400,
14406
14609
  CONNECTION_NOT_FOUND: 400,
14610
+ // 路径不在任何已注册的根内 / 根没有注册:客户端换一个路径或先加根就能成功,不是服务器故障。
14611
+ // 它此前漏在表外,于是**所有**经 HTTP 暴露的根外访问都报 500(审计 2026-11-06 第 4 轮 P1)。
14612
+ ROOT_OUTSIDE: 400,
14407
14613
  // 目标不存在 / 状态冲突 / 本组合不支持:客户端可以据此换一条路。
14408
14614
  TRASH_NOT_FOUND: 404,
14409
14615
  TRANSFER_CONFLICT: 409,
@@ -14446,6 +14652,9 @@ function optionalPath(value) {
14446
14652
  reason: "invalid-path"
14447
14653
  });
14448
14654
  }
14655
+ if (/[\u0000-\u001f]/.test(value)) {
14656
+ throw new SshDevError("ROOT_INVALID", "path \u4E0D\u80FD\u5305\u542B\u63A7\u5236\u5B57\u7B26\uFF08\u542B NUL\uFF09", { reason: "invalid-path" });
14657
+ }
14449
14658
  return value.trim();
14450
14659
  }
14451
14660
  function pick(source, fields) {
@@ -14492,6 +14701,15 @@ function connectionIdFromAlias(alias) {
14492
14701
  // src/host/app/roots/session-directories.ts
14493
14702
  var SESSION_DIRECTORY_MAX_ROOTS = 32;
14494
14703
  var SYSTEM_TREES = ["/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64", "/boot", "/sys", "/proc", "/dev", "/var", "/root"];
14704
+ var REMOTE_SYSTEM_TREES = ["/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64", "/boot", "/sys", "/proc", "/dev", "/var", "/root"];
14705
+ var REMOTE_BROAD_ROOTS = [...REMOTE_SYSTEM_TREES, "/home", "/opt", "/srv", "/mnt", "/media", "/tmp"];
14706
+ function remotePathTooBroad(normalized) {
14707
+ if (REMOTE_BROAD_ROOTS.includes(normalized)) return true;
14708
+ for (const tree of REMOTE_SYSTEM_TREES) {
14709
+ if (normalized === tree || normalized.startsWith(`${tree}/`)) return true;
14710
+ }
14711
+ return false;
14712
+ }
14495
14713
  function directoryRefusal(request) {
14496
14714
  const trimmed = request.path.trim();
14497
14715
  if (trimmed === "") return "\u76EE\u5F55\u8DEF\u5F84\u4E3A\u7A7A\u3002";
@@ -14507,6 +14725,9 @@ function directoryRefusal(request) {
14507
14725
  return error instanceof Error ? error.message : `\u8FDC\u7AEF\u8DEF\u5F84\u4E0D\u5408\u6CD5\uFF1A${trimmed}`;
14508
14726
  }
14509
14727
  if (normalized2 === "/") return "\u4E0D\u5141\u8BB8\u628A\u8FDC\u7AEF\u7684\u6839\u76EE\u5F55 / \u6DFB\u52A0\u5230\u4F1A\u8BDD\u3002";
14728
+ if (remotePathTooBroad(normalized2)) {
14729
+ return `\u4E0D\u5141\u8BB8\u628A\u8FC7\u5BBD\u7684\u8FDC\u7AEF\u76EE\u5F55\u6DFB\u52A0\u5230\u4F1A\u8BDD\uFF08${normalized2}\uFF09\uFF1A\u8BF7\u9009\u5230\u5177\u4F53\u9879\u76EE\u76EE\u5F55\u3002`;
14730
+ }
14510
14731
  return void 0;
14511
14732
  }
14512
14733
  const normalized = trimmed.replace(/\/+$/u, "") || "/";
@@ -14644,6 +14865,15 @@ async function ensureLiveAndReadAnchor(deps, key, state) {
14644
14865
  }
14645
14866
  return readAnchor(await Promise.resolve(deps.listRoots()));
14646
14867
  }
14868
+ async function liveScopeWarning(deps, key, state) {
14869
+ const persisted = findRootInState(state, key);
14870
+ if (persisted === void 0) return void 0;
14871
+ const roots = await Promise.resolve(deps.listRoots());
14872
+ const liveEntry = roots.find((candidate) => liveKeyOfRecord(candidate, persisted) !== void 0);
14873
+ if (liveEntry === void 0) return `\u6DFB\u52A0\u76EE\u5F55\uFF1A\u6839 ${key} \u4E0D\u5728\u5B9E\u65F6\u6CE8\u518C\u8868\u91CC\uFF0C\u91CD\u542F\u524D\u53EF\u80FD\u4E0D\u53EF\u7528`;
14874
+ if (liveScopeIsCurrent(liveEntry, persisted)) return void 0;
14875
+ return `\u6DFB\u52A0\u76EE\u5F55\uFF1A\u6839 ${key} \u7684\u5B9E\u65F6\u4F5C\u7528\u57DF\u4E0E\u6587\u4EF6\u4E0D\u4E00\u81F4\uFF0C\u53E6\u4E00\u4E2A\u4F1A\u8BDD\u53EF\u80FD\u8981\u91CD\u542F\u540E\u624D\u53EF\u89C1`;
14876
+ }
14647
14877
  async function findLiveKey(deps, entry) {
14648
14878
  for (const raw of await Promise.resolve(deps.listRoots())) {
14649
14879
  const key = liveKeyOfRecord(raw, entry);
@@ -14910,9 +15140,25 @@ function publicRoot(raw) {
14910
15140
  ...typeof source["connectionId"] === "string" ? { connectionId: source["connectionId"] } : {},
14911
15141
  path: String(source["path"] ?? ""),
14912
15142
  ...typeof source["label"] === "string" ? { label: source["label"] } : {},
14913
- ...typeof source["writable"] === "boolean" ? { writable: source["writable"] } : {}
15143
+ ...typeof source["writable"] === "boolean" ? { writable: source["writable"] } : {},
15144
+ ...stringList(source["workspaces"]) === void 0 ? {} : { workspaces: stringList(source["workspaces"]) },
15145
+ ...stringList(source["sessions"]) === void 0 ? {} : { sessions: stringList(source["sessions"]) }
14914
15146
  };
14915
15147
  }
15148
+ function liveRootScopes(raw) {
15149
+ const outer = asRecord2(raw) ?? {};
15150
+ const source = asRecord2(outer["root"]) ?? outer;
15151
+ const sessions = stringList(source["sessions"]);
15152
+ const workspaces = stringList(source["workspaces"]);
15153
+ return {
15154
+ ...sessions === void 0 ? {} : { sessions },
15155
+ ...workspaces === void 0 ? {} : { workspaces }
15156
+ };
15157
+ }
15158
+ function stringList(value) {
15159
+ if (!Array.isArray(value)) return void 0;
15160
+ return value.filter((item) => typeof item === "string");
15161
+ }
14916
15162
  function publicConnection(raw) {
14917
15163
  const source = asRecord2(raw) ?? {};
14918
15164
  const authKind = source["authKind"] ?? asRecord2(source["auth"])?.["kind"];
@@ -15038,21 +15284,34 @@ async function handleRootActions(action, body, req, res, deps) {
15038
15284
  if (registeredNow) await deps.unregisterRoot(key);
15039
15285
  throw error;
15040
15286
  }
15287
+ const warnings = [];
15288
+ if (existing !== void 0) {
15289
+ const warning = await liveScopeWarning(deps, key, state);
15290
+ if (warning !== void 0) warnings.push(warning);
15291
+ }
15041
15292
  const anchorPath = await anchorPathOf(deps, entry);
15042
15293
  send(req, res, 200, {
15043
15294
  ok: true,
15044
15295
  action,
15045
15296
  state: publicUserState(state),
15046
- ...anchorPath === void 0 ? {} : { anchorPath }
15297
+ ...anchorPath === void 0 ? {} : { anchorPath },
15298
+ ...warnings.length === 0 ? {} : { warnings }
15047
15299
  });
15048
15300
  return true;
15049
- return true;
15050
15301
  });
15051
15302
  }
15052
15303
  case "remove-root": {
15053
15304
  const key = requiredKey(body["key"]);
15054
15305
  const workspace = optionalWorkspaceName(body["workspace"]);
15055
15306
  const session = optionalSessionName(body["session"]);
15307
+ if (session !== void 0) {
15308
+ const current = await currentUserState(deps);
15309
+ const entry = findRootInState(current, key);
15310
+ const owners = entry?.sessions ?? [];
15311
+ if (owners.length > 0 && !owners.includes(session)) {
15312
+ throw new SshDevError("ROOT_INVALID", "\u8FD9\u4E2A\u76EE\u5F55\u662F\u53E6\u4E00\u4E2A\u4F1A\u8BDD\u6DFB\u52A0\u7684\uFF1B\u8BF7\u5230\u6DFB\u52A0\u5B83\u7684\u4F1A\u8BDD\u91CC\u79FB\u9664", { key });
15313
+ }
15314
+ }
15056
15315
  const state = await reconcile(deps, (current) => {
15057
15316
  if (session !== void 0 && rootAddedBySession(current, key, session)) {
15058
15317
  return removeRootFromSession(current, key, session);
@@ -15734,16 +15993,40 @@ async function serveSnapshot(req, res, deps) {
15734
15993
  Promise.resolve(deps.listConnections()),
15735
15994
  Promise.resolve(deps.loadUserState())
15736
15995
  ]);
15996
+ const query = queryOf(req.url);
15997
+ const requestedSession = (query.get("session") ?? "").trim();
15998
+ const requestedWorkspace = (query.get("workspace") ?? "").trim();
15999
+ const ancestorsOf = deps.resolveSessionAncestors;
16000
+ const canFilter = requestedWorkspace !== "" || ancestorsOf !== void 0;
16001
+ const filtered = !canFilter || requestedSession === "" && requestedWorkspace === "" ? void 0 : {
16002
+ ids: new Set(
16003
+ requestedSession === "" ? [] : [requestedSession, ...ancestorsOf?.(requestedSession) ?? []]
16004
+ ),
16005
+ workspace: requestedWorkspace
16006
+ };
16007
+ const roots = filtered === void 0 ? liveRoots.map(publicRoot) : liveRoots.filter((raw) => visibleToSession(liveRootScopes(raw), filtered.ids, filtered.workspace)).map(publicRoot);
15737
16008
  send(req, res, 200, {
15738
16009
  ok: true,
16010
+ // 两个标记分开写:客户端只认 `scopedForSession`,而只带 workspace 的调用方也能看出"这份列表被过滤过"。
16011
+ ...filtered === void 0 ? {} : requestedSession === "" ? { scopedForWorkspace: requestedWorkspace } : { scopedForSession: requestedSession },
15739
16012
  // The plugin's **anchor root**, not the roots' individual anchor paths: see `RootsApiDeps.anchorRootDir`.
15740
16013
  ...deps.anchorRootDir === void 0 ? {} : { anchorRoot: deps.anchorRootDir },
15741
- roots: liveRoots.map(publicRoot),
16014
+ roots,
15742
16015
  connections: liveConnections.map(publicConnection),
15743
16016
  userRoots: publicUserState(loaded.state),
15744
16017
  warnings: [...loaded.warnings]
15745
16018
  });
15746
16019
  }
16020
+ function queryOf(url) {
16021
+ const index = url === void 0 ? -1 : url.indexOf("?");
16022
+ return new URLSearchParams(index === -1 || url === void 0 ? "" : url.slice(index + 1));
16023
+ }
16024
+ function visibleToSession(row, sessionIds, workspace) {
16025
+ const sessions = row.sessions ?? [];
16026
+ if (sessions.length > 0) return sessions.some((id) => sessionIds.has(id));
16027
+ if (workspace === "") return false;
16028
+ return (row.workspaces ?? []).some((candidate) => sameWorkspacePath(candidate, workspace));
16029
+ }
15747
16030
  async function serveAction(req, res, deps) {
15748
16031
  let text8;
15749
16032
  try {
@@ -15799,7 +16082,7 @@ async function serveAction(req, res, deps) {
15799
16082
 
15800
16083
  // src/host/integration/session/session-bin-lock.ts
15801
16084
  init_errors();
15802
- import { mkdir as mkdir5, open as open3, readFile as readFile6, rm as rm3, stat as stat3 } from "node:fs/promises";
16085
+ import { mkdir as mkdir5, open as open3, readFile as readFile6, rm as rm3, stat as stat5, utimes } from "node:fs/promises";
15803
16086
  import { dirname as dirname5 } from "node:path";
15804
16087
  var DEFAULT_STALE_LOCK_MS = 6e4;
15805
16088
  var DEFAULT_LOCK_RETRY_MS = 25;
@@ -15808,6 +16091,7 @@ function createSessionBinLock(options) {
15808
16091
  const staleMs = options.staleMs ?? DEFAULT_STALE_LOCK_MS;
15809
16092
  const retryMs = options.retryMs ?? DEFAULT_LOCK_RETRY_MS;
15810
16093
  const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
16094
+ const heartbeatMs = options.heartbeatMs ?? (staleMs > 0 ? Math.max(1e3, Math.floor(staleMs / 4)) : 0);
15811
16095
  const sleep2 = options.sleep ?? ((ms) => new Promise((resolve3) => setTimeout(resolve3, ms)));
15812
16096
  const now = options.now ?? (() => Date.now());
15813
16097
  const owner = options.owner ?? `${process.pid}@${hostnameOf()}`;
@@ -15857,22 +16141,47 @@ function createSessionBinLock(options) {
15857
16141
  const reclaimIfStale = async () => {
15858
16142
  if (staleMs <= 0) return false;
15859
16143
  let age;
16144
+ let malformed = false;
15860
16145
  try {
15861
- const info = await stat3(options.path);
16146
+ const info = await stat5(options.path);
16147
+ malformed = info.isDirectory();
15862
16148
  age = now() - info.mtimeMs;
15863
16149
  } catch {
15864
16150
  return true;
15865
16151
  }
15866
- if (age < staleMs) return false;
16152
+ if (!malformed && age < staleMs) return false;
16153
+ if (!malformed) {
16154
+ try {
16155
+ const text8 = await readFile6(options.path, "utf8");
16156
+ const record2 = JSON.parse(text8);
16157
+ options.onReclaim?.({ path: options.path, ageMs: age, holder: record2 });
16158
+ } catch {
16159
+ options.onReclaim?.({ path: options.path, ageMs: age, holder: {} });
16160
+ }
16161
+ } else {
16162
+ options.onReclaim?.({ path: options.path, ageMs: age, holder: {} });
16163
+ }
16164
+ try {
16165
+ await rm3(options.path, { force: true, recursive: malformed });
16166
+ } catch (error) {
16167
+ throw new SshDevError("TRANSFER_CONFLICT", `\u4F1A\u8BDD\u56DE\u6536\u7AD9\u9501\u4F4D\u7F6E\u662F\u4E00\u4E2A\u76EE\u5F55\uFF0C\u4E14\u65E0\u6CD5\u5220\u9664\uFF1A${options.path}`, {
16168
+ path: options.path,
16169
+ reason: "malformed-lock",
16170
+ cause: error instanceof Error ? error.message : String(error)
16171
+ });
16172
+ }
16173
+ return true;
16174
+ };
16175
+ const refreshLock = async (token) => {
15867
16176
  try {
15868
16177
  const text8 = await readFile6(options.path, "utf8");
15869
16178
  const record2 = JSON.parse(text8);
15870
- options.onReclaim?.({ path: options.path, ageMs: age, holder: record2 });
15871
- } catch {
15872
- options.onReclaim?.({ path: options.path, ageMs: age, holder: {} });
16179
+ if (record2.token !== token) return;
16180
+ const at = new Date(now());
16181
+ await utimes(options.path, at, at);
16182
+ } catch (error) {
16183
+ options.onHeartbeatError?.(error);
15873
16184
  }
15874
- await rm3(options.path, { force: true });
15875
- return true;
15876
16185
  };
15877
16186
  const withLock = async (_key, operation) => {
15878
16187
  const deadline = now() + timeoutMs;
@@ -15890,9 +16199,17 @@ function createSessionBinLock(options) {
15890
16199
  }
15891
16200
  await sleep2(retryMs);
15892
16201
  }
16202
+ let heartbeat;
16203
+ if (token !== void 0 && heartbeatMs > 0) {
16204
+ heartbeat = setInterval(() => {
16205
+ void refreshLock(token);
16206
+ }, heartbeatMs);
16207
+ heartbeat.unref?.();
16208
+ }
15893
16209
  try {
15894
16210
  return await operation();
15895
16211
  } finally {
16212
+ if (heartbeat !== void 0) clearInterval(heartbeat);
15896
16213
  if (token !== void 0) await releaseIfOwned(token);
15897
16214
  }
15898
16215
  };
@@ -15999,6 +16316,7 @@ function createRootsWiring(deps) {
15999
16316
  deps.removeConnection(id);
16000
16317
  },
16001
16318
  ...deps.anchorRootDir === void 0 ? {} : { anchorRootDir: deps.anchorRootDir },
16319
+ ...deps.resolveSessionAncestors === void 0 ? {} : { resolveSessionAncestors: deps.resolveSessionAncestors },
16002
16320
  ...listRemoteDirectory === void 0 ? {} : { listRemoteDirectory: (input) => listRemoteDirectory(input) },
16003
16321
  ...remoteHomeDirectory === void 0 ? {} : { remoteHomeDirectory },
16004
16322
  ...listLocalDirectory === void 0 ? {} : { listLocalDirectory: (input) => listLocalDirectory(input) },
@@ -16072,10 +16390,55 @@ function createRootsWiring(deps) {
16072
16390
  }
16073
16391
  return { roots, connections, warnings };
16074
16392
  };
16393
+ const inheritAddedRoots = async (sourceSessionId, childSessionId) => {
16394
+ const source = sourceSessionId.trim();
16395
+ const child = childSessionId.trim();
16396
+ if (source === "" || child === "") return;
16397
+ const keys = [];
16398
+ await store2.mutate((current) => {
16399
+ let next = current;
16400
+ for (const entry of current.roots) {
16401
+ const sessions = entry.sessions ?? [];
16402
+ if (!sessions.includes(source) || sessions.includes(child)) continue;
16403
+ const key = userRootKey(entry);
16404
+ next = withSessions(next, key, [child]);
16405
+ keys.push(key);
16406
+ }
16407
+ return next;
16408
+ });
16409
+ if (keys.length > 0) {
16410
+ const { state } = await store2.load();
16411
+ for (const key of keys) await refreshLiveScope(apiDeps, key, state);
16412
+ }
16413
+ };
16414
+ const pruneSession = async (sessionId) => {
16415
+ const trimmed = sessionId.trim();
16416
+ if (trimmed === "") return { changed: [], removed: [] };
16417
+ const before = (await store2.load()).state;
16418
+ const touched = before.roots.filter((entry) => (entry.sessions ?? []).includes(trimmed));
16419
+ if (touched.length === 0) return { changed: [], removed: [] };
16420
+ const after = await store2.mutate((current) => pruneSessionFromRoots(current, trimmed));
16421
+ const afterKeys = new Set(after.roots.map(userRootKey));
16422
+ const removed = [];
16423
+ const changed = [];
16424
+ for (const entry of touched) {
16425
+ const key = userRootKey(entry);
16426
+ if (!afterKeys.has(key)) {
16427
+ removed.push(key);
16428
+ await deps.unregisterRoot(key);
16429
+ } else {
16430
+ changed.push(key);
16431
+ }
16432
+ }
16433
+ for (const key of changed) await refreshLiveScope(apiDeps, key, after);
16434
+ return { changed, removed };
16435
+ };
16075
16436
  return {
16076
16437
  api,
16077
16438
  store: store2,
16078
16439
  sessionDirectories,
16440
+ inheritAddedRoots,
16441
+ pruneSession,
16079
16442
  mergeUserRoots,
16080
16443
  routeWarnings: () => [...routeWarnings],
16081
16444
  routeFacts: () => ({ injectRan, routeRegistered, warnings: [...routeWarnings] }),
@@ -16338,7 +16701,14 @@ async function serveActionWithSignal(action, body, req, res, deps, service, sign
16338
16701
  return;
16339
16702
  }
16340
16703
  const result = await service.restore(entryId, options);
16341
- send(req, res, 200, { ok: true, restoredPath: result.restoredPath, movedBy: result.movedBy });
16704
+ send(req, res, 200, {
16705
+ ok: true,
16706
+ restoredPath: result.restoredPath,
16707
+ movedBy: result.movedBy,
16708
+ restoredSubsessions: result.restoredSubsessions.length,
16709
+ failedSubsessions: result.failedSubsessions.length,
16710
+ parentStillBinned: result.parentStillBinned
16711
+ });
16342
16712
  return;
16343
16713
  }
16344
16714
  case "purge": {
@@ -16352,7 +16722,11 @@ async function serveActionWithSignal(action, body, req, res, deps, service, sign
16352
16722
  return;
16353
16723
  }
16354
16724
  const result = await service.purge(entryId, options);
16355
- send(req, res, 200, { ok: true, purged: result.purged, indexStale: result.indexStale });
16725
+ send(req, res, 200, {
16726
+ ...result.indexStale === true ? { ok: false, error: "\u56DE\u6536\u7AD9\u7D22\u5F15\u66F4\u65B0\u5931\u8D25\uFF1A\u6761\u76EE\u5DF2\u5220\u9664\uFF0C\u4F46\u7D22\u5F15\u4ECD\u5217\u7740\u5B83\uFF08\u4E0B\u6B21\u8FD8\u539F\u4F1A\u5931\u8D25\uFF09\u3002\u91CD\u542F\u540E\u91CD\u8BD5 sweep\u3002", reason: "index-stale" } : { ok: true },
16727
+ purged: result.purged,
16728
+ indexStale: result.indexStale
16729
+ });
16356
16730
  return;
16357
16731
  }
16358
16732
  case "sweep": {
@@ -16365,8 +16739,9 @@ async function serveActionWithSignal(action, body, req, res, deps, service, sign
16365
16739
  return;
16366
16740
  }
16367
16741
  const result = await service.sweep(deps.policy(), options);
16742
+ const stale = result.purged.filter((item) => item.indexStale === true).length;
16368
16743
  send(req, res, 200, {
16369
- ok: true,
16744
+ ...stale > 0 ? { ok: false, error: `\u56DE\u6536\u7AD9\u7D22\u5F15\u66F4\u65B0\u5931\u8D25\uFF1A${stale} \u4E2A\u6761\u76EE\u5DF2\u5220\u9664\u4F46\u7D22\u5F15\u4ECD\u5217\u7740\u5B83\u4EEC\uFF08\u4E0B\u6B21\u8FD8\u539F\u4F1A\u5931\u8D25\uFF09\u3002`, reason: "index-stale" } : { ok: true },
16370
16745
  purged: result.purged.map((item) => ({ entryId: String(item.id), sessionId: item.sessionId, indexStale: item.indexStale })),
16371
16746
  failed: result.failed.map((item) => ({ entryId: String(item.id), reason: item.reason })),
16372
16747
  remainingEntries: result.remainingEntries,
@@ -16441,6 +16816,56 @@ function createSessionRouteWiring(deps) {
16441
16816
  };
16442
16817
  }
16443
16818
 
16819
+ // src/host/integration/roots/fork-inheritance.ts
16820
+ var PATCHED = /* @__PURE__ */ Symbol.for("dsh-over-ssh/fork-inheritance");
16821
+ function sourceIdOf(source) {
16822
+ if (typeof source === "string") return source === "" ? void 0 : source;
16823
+ if (typeof source !== "object" || source === null) return void 0;
16824
+ const candidate = source;
16825
+ const id = candidate.header?.id ?? candidate.id;
16826
+ return typeof id === "string" && id !== "" ? id : void 0;
16827
+ }
16828
+ function installForkInheritance(ctx, deps) {
16829
+ const service = ctx.get("sessions");
16830
+ const original = service?.fork;
16831
+ if (typeof service !== "object" || service === null || typeof original !== "function") {
16832
+ return { installed: false, dispose: () => void 0 };
16833
+ }
16834
+ if (original[PATCHED] === true) {
16835
+ return { installed: true, dispose: () => void 0 };
16836
+ }
16837
+ const invoke = original;
16838
+ const patched2 = function patchedFork(...args) {
16839
+ const child = invoke.apply(this, args);
16840
+ try {
16841
+ const sourceId = sourceIdOf(args[0]);
16842
+ const childId = sourceIdOf(child);
16843
+ if (sourceId !== void 0 && childId !== void 0) {
16844
+ markForkInheritancePending(childId, sourceId);
16845
+ void deps.inherit(sourceId, childId).catch((error) => {
16846
+ deps.warn?.(`fork \u7EE7\u627F\u6DFB\u52A0\u76EE\u5F55\u5931\u8D25\uFF08source=${sourceId} child=${childId}\uFF09\uFF1A${error instanceof Error ? error.message : String(error)}`);
16847
+ }).finally(() => {
16848
+ clearForkInheritancePending(childId);
16849
+ });
16850
+ }
16851
+ } catch (error) {
16852
+ deps.warn?.(`fork \u7EE7\u627F\u6DFB\u52A0\u76EE\u5F55\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`);
16853
+ }
16854
+ return child;
16855
+ };
16856
+ patched2[PATCHED] = true;
16857
+ service.fork = patched2;
16858
+ return {
16859
+ installed: true,
16860
+ dispose: () => {
16861
+ if (service.fork === patched2) {
16862
+ service.fork = original;
16863
+ }
16864
+ clearAllForkInheritancePending();
16865
+ }
16866
+ };
16867
+ }
16868
+
16444
16869
  // src/wiring/fs-panel-routes.ts
16445
16870
  function readOnlySeams(env) {
16446
16871
  const { ctx, profiles, registry, transports } = env;
@@ -16658,6 +17083,8 @@ function registerPanelRoutes(env, hooks) {
16658
17083
  stateDir: resolveStateDir(),
16659
17084
  // Published in the GET snapshot so a client can recognise a session whose cwd is a remote workspace.
16660
17085
  anchorRootDir: env.anchorRootDir,
17086
+ // fork 继承父会话添加的目录(ADR-0013):血缘只有宿主知道,所以 `GET /roots?session=` 由这里回答。
17087
+ resolveSessionAncestors: (sessionId) => readSessionAncestors(env.ctx, sessionId),
16661
17088
  ...readOnlySeams(env),
16662
17089
  ...rootLifecycleSeams(env, hooks),
16663
17090
  ...connectionLifecycleSeams(env, hooks),
@@ -16665,11 +17092,15 @@ function registerPanelRoutes(env, hooks) {
16665
17092
  ...remoteFileSeams(env),
16666
17093
  webServerProvider: ctx
16667
17094
  });
16668
- return { sessionWiring, rootsWiring };
17095
+ const forkInheritance = installForkInheritance(ctx, {
17096
+ inherit: (sourceSessionId, childSessionId) => rootsWiring.inheritAddedRoots(sourceSessionId, childSessionId),
17097
+ warn: (message) => ctx.logger?.warn?.(`over-ssh: ${message}`)
17098
+ });
17099
+ return { sessionWiring, rootsWiring, disposeForkInheritance: () => forkInheritance.dispose() };
16669
17100
  }
16670
17101
 
16671
17102
  // src/wiring/fs.ts
16672
- var LOCAL_WORLD_MISSING_WARNING = 'dsh-over-ssh: \u79C1\u6709\u57DF\u7684 bridge \u672A\u5C31\u4F4D\uFF08ctx.get("localFs") === undefined\uFF09\uFF1A\u672C\u5730\u8DEF\u5F84\u7684\u5199\u5165\u56F4\u680F\u9000\u56DE\u672C\u63D2\u4EF6\u81EA\u5DF1\u7684 LocalBackend\u2014\u2014\u529F\u80FD\u770B\u8D77\u6765\u6B63\u5E38\uFF0C\u4F46\u56F4\u680F\u6362\u4E86\u6267\u884C\u8005\u3002\u8BF7\u68C0\u67E5 cordis.patch.yml \u91CC \u8FDC\u7A0B\u5DE5\u4F5C\u533A-\u79C1\u6709\u57DF \u7EC4\uFF08fs/fileReferences \u7684 isolate \u4E0E\u4E24\u4E2A bridge \u884C\uFF09\u662F\u5426\u88AB\u7981\u7528\u6216\u52A0\u8F7D\u5931\u8D25\u3002';
17103
+ var LOCAL_WORLD_MISSING_WARNING = 'dsh-over-ssh: \u79C1\u6709\u57DF\u7684 bridge \u672A\u5C31\u4F4D\uFF08ctx.get("localFs") === undefined\uFF09\uFF1A\u672C\u5730\u8DEF\u5F84\u7684\u5199\u5165\u56F4\u680F\u9000\u56DE\u672C\u63D2\u4EF6\u81EA\u5DF1\u7684 LocalBackend\u2014\u2014\u529F\u80FD\u770B\u8D77\u6765\u6B63\u5E38\uFF0C\u4F46\u56F4\u680F\u6362\u4E86\u6267\u884C\u8005\u3002\u8BF7\u68C0\u67E5 cordis.patch.yml \u91CC \u8FDC\u7A0B\u5DE5\u4F5C\u533A-\u79C1\u6709\u57DF \u7EC4\uFF08fs \u7684 isolate \u4E0E \u8FDC\u7A0B\u5DE5\u4F5C\u533A-\u6865\u63A5fs \u884C\uFF09\u662F\u5426\u88AB\u7981\u7528\u6216\u52A0\u8F7D\u5931\u8D25\u3002';
16673
17104
  function installClientDataChannel(ctx) {
16674
17105
  try {
16675
17106
  installRemoteFace(ctx);
@@ -16706,7 +17137,9 @@ function provideAgentStatus(env) {
16706
17137
  }
16707
17138
  };
16708
17139
  const forgetPerConnection = (connectionId) => {
16709
- void forgetPerConnectionAsync(connectionId);
17140
+ void forgetPerConnectionAsync(connectionId).catch((error) => {
17141
+ ctx.logger?.warn?.(`over-ssh: \u5FD8\u8BB0\u8FDE\u63A5 ${connectionId} \u7684\u4EE3\u7406\u5B89\u88C5\u8BB0\u5F55\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`);
17142
+ });
16710
17143
  };
16711
17144
  const disposeAgent = ctx.provide("sshDevAgent", {
16712
17145
  outcome: (connectionId) => agentBridge.installationFor(connectionId),
@@ -16739,7 +17172,9 @@ function registerConfiguredRoots(env) {
16739
17172
  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)}`);
16740
17173
  });
16741
17174
  }
16742
- void Promise.allSettled(binRootPreparation).then(runBinHousekeeping);
17175
+ void Promise.allSettled(binRootPreparation).then(runBinHousekeeping).catch((error) => {
17176
+ ctx.logger?.warn?.(`over-ssh: \u56DE\u6536\u7AD9\u6E05\u7406\u7531\u672C\u6B21\u542F\u52A8\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`);
17177
+ });
16743
17178
  }
16744
17179
  function scheduleActivationDiagnostics(env) {
16745
17180
  const { ctx, rootsWiring, sessionWiring } = env;
@@ -16765,7 +17200,7 @@ function scheduleActivationDiagnostics(env) {
16765
17200
  function watchSessionRemovals(env) {
16766
17201
  const { ctx, rootsWiring, onSessionRemoved } = env;
16767
17202
  onSessionRemoved((sessionId) => {
16768
- void rootsWiring.store.pruneSession(sessionId).catch((error) => {
17203
+ void rootsWiring.pruneSession(sessionId).catch((error) => {
16769
17204
  ctx.logger?.warn?.(
16770
17205
  `dsh-over-ssh: \u6E05\u7406\u5DF2\u5220\u4F1A\u8BDD ${sessionId} \u7684\u6839\u8BB0\u5F55\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`
16771
17206
  );
@@ -16784,6 +17219,7 @@ function createTeardown(env) {
16784
17219
  const { activationTimers, routes, hostServices, disposeAgent, tools, disposeSessionDirectories } = env;
16785
17220
  return async () => {
16786
17221
  for (const timer of activationTimers) clearTimeout(timer);
17222
+ routes.disposeForkInheritance();
16787
17223
  routes.rootsWiring.dispose();
16788
17224
  routes.sessionWiring.dispose();
16789
17225
  tools.disposeManagement();
@@ -16861,7 +17297,7 @@ import { dirname as dirname7, join as join16 } from "node:path";
16861
17297
  // src/host/integration/transfer/transfer-adapters.ts
16862
17298
  init_errors();
16863
17299
  import { createHash as createHash3 } from "node:crypto";
16864
- 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";
17300
+ import { mkdir as mkdir8, open as open4, readFile as readFile8, readdir as readdir5, rename as rename4, rm as rm4, stat as stat6, writeFile as writeFile5 } from "node:fs/promises";
16865
17301
  import { dirname as dirname6 } from "node:path";
16866
17302
  function requireRemote(deps, path) {
16867
17303
  const target = deps.routed.resolve(path);
@@ -16925,7 +17361,7 @@ function createTransferDeps(deps) {
16925
17361
  },
16926
17362
  async statLocal(path) {
16927
17363
  try {
16928
- const info = await stat4(path);
17364
+ const info = await stat6(path);
16929
17365
  return toBackendStat(info, localVersion(info));
16930
17366
  } catch {
16931
17367
  return toBackendStat(void 0, "0:0");
@@ -17495,6 +17931,10 @@ function wireRoots(env) {
17495
17931
  ctx.logger?.warn?.(
17496
17932
  `\u56DE\u6536\u7AD9\u7D22\u5F15\u9501\u5DF2\u8FC7\u671F\u88AB\u56DE\u6536\uFF1A\u6301\u6709\u8005 ${JSON.stringify(event.holder)}\uFF0C\u5DF2\u8FC7\u671F ${Math.round(event.ageMs / 1e3)} \u79D2`
17497
17933
  );
17934
+ },
17935
+ // 心跳失败只警告:锁仍然有效,操作继续;但网络盘 EIO/EPERM 值得在日志里留下痕迹。
17936
+ onHeartbeatError: (error) => {
17937
+ ctx.logger?.warn?.(`\u56DE\u6536\u7AD9\u7D22\u5F15\u9501\u5FC3\u8DF3\u5237\u65B0\u5931\u8D25\uFF08\u9501\u4ECD\u6301\u6709\uFF09\uFF1A${error instanceof Error ? error.message : String(error)}`);
17498
17938
  }
17499
17939
  });
17500
17940
  binLocks.set(key, created);
@@ -17676,7 +18116,7 @@ function createSessionRootIndex(deps) {
17676
18116
  const now = deps.now ?? (() => Date.now());
17677
18117
  let cachedAt;
17678
18118
  let cached2 = [];
17679
- let pending;
18119
+ let pending2;
17680
18120
  let generation = 0;
17681
18121
  const readEntries = async () => {
17682
18122
  try {
@@ -17690,14 +18130,14 @@ function createSessionRootIndex(deps) {
17690
18130
  const entriesNow = () => {
17691
18131
  const at = cachedAt;
17692
18132
  const fresh = at !== void 0 && now() - at < deps.ttlMs;
17693
- if (!fresh && pending === void 0) {
18133
+ if (!fresh && pending2 === void 0) {
17694
18134
  const startedAt = generation;
17695
- pending = readEntries().then((entries) => {
18135
+ pending2 = readEntries().then((entries) => {
17696
18136
  if (startedAt === generation) {
17697
18137
  cached2 = entries;
17698
18138
  cachedAt = now();
17699
18139
  }
17700
- pending = void 0;
18140
+ pending2 = void 0;
17701
18141
  return entries;
17702
18142
  });
17703
18143
  }
@@ -17712,7 +18152,7 @@ function createSessionRootIndex(deps) {
17712
18152
  generation += 1;
17713
18153
  cachedAt = void 0;
17714
18154
  cached2 = [];
17715
- pending = void 0;
18155
+ pending2 = void 0;
17716
18156
  }
17717
18157
  };
17718
18158
  }
@@ -17771,8 +18211,12 @@ function resolveSessionTarget(exec, seams) {
17771
18211
  function readConnectionIds(record2) {
17772
18212
  const raw = record2["connectionIds"];
17773
18213
  if (!Array.isArray(raw)) return void 0;
18214
+ if (raw.length > MAX_EXEC_BATCH_CONNECTIONS) {
18215
+ throw new Error(`connectionIds \u4E00\u6B21\u6700\u591A ${MAX_EXEC_BATCH_CONNECTIONS} \u4E2A\uFF08\u6536\u5230 ${raw.length} \u4E2A\uFF09\u3002\u8BF7\u5206\u6279\u6267\u884C\u3002`);
18216
+ }
17774
18217
  const ids = [...new Set(raw.filter((item) => typeof item === "string" && item.trim() !== "").map((item) => item.trim()))];
17775
- return ids.length === 0 ? void 0 : ids;
18218
+ if (ids.length === 0) return void 0;
18219
+ return ids;
17776
18220
  }
17777
18221
  function text2(value) {
17778
18222
  return [{ type: "text", text: value }];
@@ -18239,6 +18683,7 @@ var DEFAULT_JOB_POLL_MS = 500;
18239
18683
  var WAIT_SLICE_MS = 5e3;
18240
18684
  var DEFAULT_JOB_OUTPUT_BYTES = 1 << 20;
18241
18685
  var MAX_JOB_OUTPUT_BYTES = 8 << 20;
18686
+ var MAX_JOB_STDIN_BYTES = 1 << 20;
18242
18687
  function isAgentJobCapable(transport) {
18243
18688
  if (transport === null || typeof transport !== "object") return false;
18244
18689
  const candidate = transport;
@@ -18598,6 +19043,11 @@ function remoteJobToolDefinitions(backend) {
18598
19043
  const maxOutputBytes = clampInt2(record2["maxOutputBytes"], DEFAULT_JOB_OUTPUT_BYTES, 1, MAX_JOB_OUTPUT_BYTES);
18599
19044
  const stdinText = record2["stdin"];
18600
19045
  if (stdinText !== void 0 && typeof stdinText !== "string") throw new Error("stdin \u5FC5\u987B\u662F\u5B57\u7B26\u4E32");
19046
+ if (typeof stdinText === "string" && Buffer.byteLength(stdinText, "utf8") > MAX_JOB_STDIN_BYTES) {
19047
+ throw new Error(
19048
+ `stdin \u4E00\u6B21\u6700\u591A ${MAX_JOB_STDIN_BYTES} \u5B57\u8282\uFF08\u6536\u5230 ${Buffer.byteLength(stdinText, "utf8")}\uFF09\u3002\u8BF7\u7528 interactive:true \u542F\u52A8\uFF0C\u518D\u7528 remote_job {action:"stdin"} \u5206\u5757\u5199\u5165\u3002`
19049
+ );
19050
+ }
18601
19051
  const interactive = record2["interactive"] === true;
18602
19052
  const target = targetFor2(backend, exec);
18603
19053
  const cwd = resolveOrThrow2(target, stringArg2(record2["cwd"]));
@@ -18717,6 +19167,9 @@ function remoteJobToolDefinitions(backend) {
18717
19167
  };
18718
19168
  }
18719
19169
  if (action === "stdin") {
19170
+ if (typeof record2["data"] === "string" && typeof record2["dataBase64"] === "string") {
19171
+ throw new Error("data \u4E0E dataBase64 \u53EA\u80FD\u7ED9\u4E00\u4E2A");
19172
+ }
18720
19173
  const result = await backend.stdin(
18721
19174
  target,
18722
19175
  jobId,
@@ -18935,16 +19388,16 @@ function createRemoteFileBackend(deps) {
18935
19388
  if (carried !== void 0) rememberVersion(target, to, carried);
18936
19389
  },
18937
19390
  async stat(target, absolute, signal) {
18938
- const stat5 = await requireBackend(target).stat(absolute, signal);
18939
- if (stat5.exists) rememberVersion(target, absolute, stat5.version);
19391
+ const stat7 = await requireBackend(target).stat(absolute, signal);
19392
+ if (stat7.exists) rememberVersion(target, absolute, stat7.version);
18940
19393
  return {
18941
- exists: stat5.exists,
18942
- isDirectory: stat5.isDirectory,
18943
- isFile: stat5.isFile,
18944
- isSymlink: stat5.isSymlink,
18945
- size: stat5.size,
18946
- mtimeMs: stat5.mtimeMs,
18947
- version: stat5.version
19394
+ exists: stat7.exists,
19395
+ isDirectory: stat7.isDirectory,
19396
+ isFile: stat7.isFile,
19397
+ isSymlink: stat7.isSymlink,
19398
+ size: stat7.size,
19399
+ mtimeMs: stat7.mtimeMs,
19400
+ version: stat7.version
18948
19401
  };
18949
19402
  },
18950
19403
  async patch(target, absolute, diff, expectedVersion, signal) {
@@ -19400,6 +19853,9 @@ function remoteFileToolDefinitions(backend, allowMutation) {
19400
19853
  const from = resolveOrThrow3(target, rawFrom);
19401
19854
  const to = resolveOrThrow3(target, rawTo);
19402
19855
  if (from === to) throw new Error(`\u6E90\u4E0E\u76EE\u6807\u662F\u540C\u4E00\u4E2A\u8DEF\u5F84\uFF1A${from}`);
19856
+ if (to.startsWith(from.endsWith("/") ? from : `${from}/`)) {
19857
+ throw new Error(`\u4E0D\u80FD\u628A\u76EE\u5F55\u79FB\u52A8\u5230\u5B83\u81EA\u5DF1\u7684\u5B50\u76EE\u5F55\u91CC\uFF1A${from} \u2192 ${to}`);
19858
+ }
19403
19859
  assertWritable(target, to, allowMutation);
19404
19860
  const createParents = record2["createParents"] !== false;
19405
19861
  await backend.move(target, from, to, createParents, exec.signal);
@@ -19436,15 +19892,15 @@ function remoteFileToolDefinitions(backend, allowMutation) {
19436
19892
  const requested = requiredString3(record2["path"], "path");
19437
19893
  const target = targetFor3(backend, exec);
19438
19894
  const path = resolveOrThrow3(target, requested);
19439
- const stat5 = await backend.stat(target, path, exec.signal);
19440
- const kind = !stat5.exists ? "missing" : stat5.isSymlink ? "symlink" : stat5.isDirectory ? "directory" : "file";
19895
+ const stat7 = await backend.stat(target, path, exec.signal);
19896
+ const kind = !stat7.exists ? "missing" : stat7.isSymlink ? "symlink" : stat7.isDirectory ? "directory" : "file";
19441
19897
  return {
19442
19898
  path,
19443
- exists: stat5.exists,
19899
+ exists: stat7.exists,
19444
19900
  kind,
19445
- size: stat5.exists ? stat5.size : 0,
19446
- mtimeMs: stat5.exists ? stat5.mtimeMs : 0,
19447
- version: stat5.exists ? stat5.version : ""
19901
+ size: stat7.exists ? stat7.size : 0,
19902
+ mtimeMs: stat7.exists ? stat7.mtimeMs : 0,
19903
+ version: stat7.exists ? stat7.version : ""
19448
19904
  };
19449
19905
  }
19450
19906
  })
@@ -19524,11 +19980,11 @@ var LspFrameParser = class {
19524
19980
  #append(bytes) {
19525
19981
  if (bytes.length === 0) return;
19526
19982
  if (this.#end + bytes.length > this.#store.length) {
19527
- const pending = this.#end - this.#start;
19983
+ const pending2 = this.#end - this.#start;
19528
19984
  if (this.#start > 0) {
19529
19985
  this.#store.copyWithin(0, this.#start, this.#end);
19530
19986
  this.#start = 0;
19531
- this.#end = pending;
19987
+ this.#end = pending2;
19532
19988
  }
19533
19989
  if (this.#end + bytes.length > this.#store.length) {
19534
19990
  let capacity = Math.max(this.#store.length, 64 * 1024);
@@ -19537,7 +19993,7 @@ var LspFrameParser = class {
19537
19993
  this.#store.copy(next, 0, this.#start, this.#end);
19538
19994
  this.#store = next;
19539
19995
  this.#start = 0;
19540
- this.#end = pending;
19996
+ this.#end = pending2;
19541
19997
  }
19542
19998
  }
19543
19999
  this.#store.set(bytes, this.#end);
@@ -19560,6 +20016,7 @@ function parseContentLength(header) {
19560
20016
  }
19561
20017
 
19562
20018
  // src/host/integration/lsp/lsp-session.ts
20019
+ var LSP_READ_CHUNK_BYTES = 256 * 1024;
19563
20020
  var IDLE_POLL_MS = 60;
19564
20021
  var BUSY_POLL_MS = 0;
19565
20022
  var DEFAULT_REQUEST_TIMEOUT_MS = 2e4;
@@ -19749,11 +20206,11 @@ var LspSession = class _LspSession {
19749
20206
  throw error;
19750
20207
  }
19751
20208
  const onAbort = () => {
19752
- const pending = this.#pending.get(id);
19753
- if (pending === void 0) return;
20209
+ const pending2 = this.#pending.get(id);
20210
+ if (pending2 === void 0) return;
19754
20211
  this.#pending.delete(id);
19755
- clearTimeout(pending.timer);
19756
- pending.reject(new LspSessionError("\u7B49\u5F85\u8BED\u8A00\u670D\u52A1\u5668\u65F6\u88AB\u53D6\u6D88"));
20212
+ clearTimeout(pending2.timer);
20213
+ pending2.reject(new LspSessionError("\u7B49\u5F85\u8BED\u8A00\u670D\u52A1\u5668\u65F6\u88AB\u53D6\u6D88"));
19757
20214
  };
19758
20215
  signal.addEventListener("abort", onAbort, { once: true });
19759
20216
  try {
@@ -19848,7 +20305,7 @@ var LspSession = class _LspSession {
19848
20305
  if (this.#stopped) return;
19849
20306
  let delay = IDLE_POLL_MS;
19850
20307
  try {
19851
- const chunk = await this.#transport.lspRead(this.#id, this.#offset, signal);
20308
+ const chunk = await this.#transport.lspRead(this.#id, this.#offset, signal, LSP_READ_CHUNK_BYTES);
19852
20309
  this.#offset = chunk.nextOffset;
19853
20310
  if (chunk.lost) {
19854
20311
  this.#failAll(new LspSessionError("\u8BED\u8A00\u670D\u52A1\u5668\u8F93\u51FA\u5DF2\u88AB\u4E22\u5F03\uFF08\u73AF\u5F62\u7F13\u51B2\u6EA2\u51FA\uFF09\uFF0C\u6D41\u4E0D\u53EF\u6062\u590D"));
@@ -19877,15 +20334,15 @@ var LspSession = class _LspSession {
19877
20334
  if (typeof message["method"] !== "string") {
19878
20335
  const id = message["id"];
19879
20336
  if (typeof id === "number") {
19880
- const pending = this.#pending.get(id);
19881
- if (pending === void 0) return;
20337
+ const pending2 = this.#pending.get(id);
20338
+ if (pending2 === void 0) return;
19882
20339
  this.#pending.delete(id);
19883
- clearTimeout(pending.timer);
20340
+ clearTimeout(pending2.timer);
19884
20341
  const error = message["error"];
19885
20342
  if (error !== void 0 && error !== null) {
19886
- pending.reject(new LspSessionError(`${pending.method} \u5931\u8D25\uFF1A${describeError(error)}`));
20343
+ pending2.reject(new LspSessionError(`${pending2.method} \u5931\u8D25\uFF1A${describeError(error)}`));
19887
20344
  } else {
19888
- pending.resolve(message["result"]);
20345
+ pending2.resolve(message["result"]);
19889
20346
  }
19890
20347
  }
19891
20348
  return;
@@ -19941,10 +20398,10 @@ var LspSession = class _LspSession {
19941
20398
  }
19942
20399
  #failAll(error) {
19943
20400
  this.#exitReason ??= error;
19944
- for (const [id, pending] of this.#pending) {
19945
- clearTimeout(pending.timer);
20401
+ for (const [id, pending2] of this.#pending) {
20402
+ clearTimeout(pending2.timer);
19946
20403
  this.#pending.delete(id);
19947
- pending.reject(error);
20404
+ pending2.reject(error);
19948
20405
  }
19949
20406
  }
19950
20407
  };
@@ -20108,8 +20565,12 @@ function describeInstalled(available) {
20108
20565
  return `\u8BE5\u4E3B\u673A\u4E0A\u63A2\u6D4B\u5230\u7684\u8BED\u8A00\u670D\u52A1\u5668\uFF1A${list}\u3002`;
20109
20566
  }
20110
20567
  var MAX_SESSIONS = 8;
20111
- function sessionKey(target, language) {
20112
- return `${target.connectionId}\0${target.root}\0${language}`;
20568
+ function sessionKeyPrefix(target, language) {
20569
+ return `${target.connectionId}\0${target.root}\0${language}\0`;
20570
+ }
20571
+ function sessionKey(target, language, override) {
20572
+ const server = override === void 0 ? "" : `${override.command}\0${(override.args ?? []).join("\0")}`;
20573
+ return `${sessionKeyPrefix(target, language)}${server}`;
20113
20574
  }
20114
20575
  function createLspRegistry(deps) {
20115
20576
  const sessions = /* @__PURE__ */ new Map();
@@ -20128,15 +20589,15 @@ function createLspRegistry(deps) {
20128
20589
  }
20129
20590
  return {
20130
20591
  async session(target, language, override, signal) {
20131
- const key = sessionKey(target, language);
20592
+ const key = sessionKey(target, language, override);
20132
20593
  const existing = sessions.get(key);
20133
20594
  if (existing !== void 0 && !existing.session.stopped) {
20134
20595
  sessions.delete(key);
20135
20596
  sessions.set(key, existing);
20136
20597
  return existing.session;
20137
20598
  }
20138
- const pending = inFlight.get(key);
20139
- if (pending !== void 0) return pending;
20599
+ const pending2 = inFlight.get(key);
20600
+ if (pending2 !== void 0) return pending2;
20140
20601
  const attempt = (async () => {
20141
20602
  const command = override ?? defaultServerFor(language);
20142
20603
  if (command === void 0) {
@@ -20208,17 +20669,21 @@ ${describeInstalled(available)}`);
20208
20669
  }));
20209
20670
  },
20210
20671
  async stop(target, language, signal) {
20211
- const key = sessionKey(target, language);
20212
- const starting = inFlight.get(key);
20213
- if (starting !== void 0) {
20214
- retired.add(key);
20215
- await starting.catch(() => void 0);
20216
- retired.delete(key);
20217
- }
20218
- const entry = sessions.get(key);
20219
- if (entry === void 0) return starting !== void 0;
20220
- sessions.delete(key);
20221
- await entry.session.close(signal);
20672
+ const prefix = sessionKeyPrefix(target, language);
20673
+ const keys = [...new Set([...sessions.keys(), ...inFlight.keys()].filter((key) => key.startsWith(prefix)))];
20674
+ if (keys.length === 0) return false;
20675
+ for (const key of keys) {
20676
+ const starting = inFlight.get(key);
20677
+ if (starting !== void 0) {
20678
+ retired.add(key);
20679
+ await starting.catch(() => void 0);
20680
+ retired.delete(key);
20681
+ }
20682
+ const entry = sessions.get(key);
20683
+ if (entry === void 0) continue;
20684
+ sessions.delete(key);
20685
+ await entry.session.close(signal);
20686
+ }
20222
20687
  return true;
20223
20688
  },
20224
20689
  async disposeAll() {
@@ -20802,9 +21267,44 @@ function readArgs(args) {
20802
21267
  ...typeof record2["server"] === "string" && record2["server"] !== "" ? { server: record2["server"] } : {}
20803
21268
  };
20804
21269
  }
21270
+ function splitCommandLine(value) {
21271
+ const parts = [];
21272
+ let current = "";
21273
+ let started = false;
21274
+ let quote2;
21275
+ for (let index = 0; index < value.length; index += 1) {
21276
+ const char = value[index];
21277
+ if (quote2 !== void 0) {
21278
+ if (char === quote2) quote2 = void 0;
21279
+ else if (char === "\\" && quote2 === '"' && index + 1 < value.length) {
21280
+ current += value[index + 1];
21281
+ index += 1;
21282
+ } else current += char;
21283
+ continue;
21284
+ }
21285
+ if (char === '"' || char === "'") {
21286
+ quote2 = char;
21287
+ started = true;
21288
+ continue;
21289
+ }
21290
+ if (/\s/.test(char)) {
21291
+ if (started) {
21292
+ parts.push(current);
21293
+ current = "";
21294
+ started = false;
21295
+ }
21296
+ continue;
21297
+ }
21298
+ current += char;
21299
+ started = true;
21300
+ }
21301
+ if (quote2 !== void 0) throw new Error('server \u7684\u5F15\u53F7\u6CA1\u6709\u95ED\u5408\uFF1B\u5E26\u7A7A\u683C\u7684\u8DEF\u5F84\u8BF7\u5199\u6210 "/opt/my tools/server" --stdio');
21302
+ if (started) parts.push(current);
21303
+ return parts;
21304
+ }
20805
21305
  function parseServer(value) {
20806
21306
  if (value === void 0) return void 0;
20807
- const parts = value.split(/\s+/).filter((part) => part !== "");
21307
+ const parts = splitCommandLine(value);
20808
21308
  if (parts.length === 0) return void 0;
20809
21309
  return parts.length === 1 ? { command: parts[0] } : { command: parts[0], args: parts.slice(1) };
20810
21310
  }
@@ -20826,7 +21326,7 @@ function remoteLspToolDefinitions(deps) {
20826
21326
  column: { type: "integer", description: "Column for `hover` / `definition`, 1-based. Required for those two actions." },
20827
21327
  server: {
20828
21328
  type: "string",
20829
- description: "Executable to run, with optional arguments (`gopls`, `rust-analyzer`, `pylsp -v`). Defaults to the table entry for the file language; required when the language has no entry."
21329
+ description: 'Executable to run, with optional arguments (`gopls`, `rust-analyzer`, `pylsp -v`). Defaults to the table entry for the file language; required when the language has no entry. Quote a path that contains spaces: `"/opt/my tools/server" --stdio`.'
20830
21330
  }
20831
21331
  },
20832
21332
  output: {
@@ -21020,7 +21520,6 @@ function registerRemoteLspTools(ctx, deps) {
21020
21520
  // src/wiring/tools-session.ts
21021
21521
  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";
21022
21522
  import { dirname as dirname8, join as join17 } from "node:path";
21023
- import { homedir as homedir6 } from "node:os";
21024
21523
 
21025
21524
  // src/host/integration/session/session-labels.ts
21026
21525
  function normalizePath(path) {
@@ -21070,8 +21569,8 @@ function createWorkspaceTitleResolver(ctx) {
21070
21569
  // src/wiring/tools-session.ts
21071
21570
  function wireSessionAdmin(env) {
21072
21571
  const { ctx, limits, notifySessionRemoved: notifySessionRemoved2 } = env;
21073
- const sessionsRoot = limits.sessionsRoot ?? join17(homedir6(), ".dsh", "sessions");
21074
- const storagesRoot = join17(homedir6(), ".dsh", "storages");
21572
+ const sessionsRoot = limits.sessionsRoot ?? join17(dshHome(), "sessions");
21573
+ const storagesRoot = join17(dshHome(), "storages");
21075
21574
  const sessionBinRoot = limits.sessionBinRoot ?? join17(storagesRoot, "over-ssh", "session-trash");
21076
21575
  if (limits.sessionBinRoot === void 0) {
21077
21576
  const carried = carryDirectory(join17(storagesRoot, "remote-workspace", "session-trash"), sessionBinRoot);
@@ -21086,6 +21585,10 @@ function wireSessionAdmin(env) {
21086
21585
  ctx.logger?.warn?.(
21087
21586
  `\u4F1A\u8BDD\u56DE\u6536\u7AD9\u7D22\u5F15\u9501\u5DF2\u8FC7\u671F\u88AB\u56DE\u6536\uFF1A\u6301\u6709\u8005 ${JSON.stringify(event.holder)}\uFF0C\u5DF2\u8FC7\u671F ${Math.round(event.ageMs / 1e3)} \u79D2`
21088
21587
  );
21588
+ },
21589
+ // 心跳失败只警告:锁仍有效,操作继续;网络盘 EIO/EPERM 需要留下痕迹。
21590
+ onHeartbeatError: (error) => {
21591
+ ctx.logger?.warn?.(`\u4F1A\u8BDD\u56DE\u6536\u7AD9\u7D22\u5F15\u9501\u5FC3\u8DF3\u5237\u65B0\u5931\u8D25\uFF08\u9501\u4ECD\u6301\u6709\uFF09\uFF1A${error instanceof Error ? error.message : String(error)}`);
21089
21592
  }
21090
21593
  });
21091
21594
  const sessionAdmin = createSessionAdminService({
@@ -21122,15 +21625,19 @@ function wireSessionAdmin(env) {
21122
21625
  throw error;
21123
21626
  }
21124
21627
  },
21125
- readHeader: (dir) => readSessionHeader(dir),
21628
+ // The listing is already in hand: hand it over so the header cache can key on it instead of re-listing.
21629
+ readHeader: (dir, listing) => readSessionHeader(
21630
+ dir,
21631
+ listing?.map((entry) => ({ name: entry.name, size: entry.bytes, mtimeMs: entry.mtimeMs }))
21632
+ ),
21126
21633
  // Both resolvers are optional: a profile without the platform services still lists sessions, labelled
21127
21634
  // by their directory and id rather than by name.
21128
21635
  resolveSessionTitle: createSessionTitleResolver(ctx),
21129
21636
  resolveWorkspaceTitle: createWorkspaceTitleResolver(ctx),
21130
21637
  stat: async (path) => {
21131
21638
  try {
21132
- const stat5 = await nodeStat2(path);
21133
- return { isDirectory: stat5.isDirectory(), bytes: stat5.size };
21639
+ const stat7 = await nodeStat2(path);
21640
+ return { isDirectory: stat7.isDirectory(), bytes: stat7.size };
21134
21641
  } catch {
21135
21642
  return void 0;
21136
21643
  }
@@ -21371,6 +21878,7 @@ function managementToolDefinitions(backend) {
21371
21878
  throw new Error("expectedVersion / expectedSha256 / force \u53EA\u5BF9 upload \u6709\u610F\u4E49\uFF1B\u4E0B\u8F7D\u7531\u672C\u5730\u6587\u4EF6\u7CFB\u7EDF\u8986\u76D6\uFF0C\u4E0D\u505A\u8FDC\u7AEF\u5B88\u536B");
21372
21879
  }
21373
21880
  const target = targetFor4(backend, exec);
21881
+ if (direction === "upload") backend.assertWritable?.(target, "\u4E0A\u4F20\u8FDC\u7AEF\u6587\u4EF6");
21374
21882
  if (direction === "upload" && backend.statRemote !== void 0 && (expectedVersion !== void 0 || !force && expectedSha256 === void 0)) {
21375
21883
  const absolute = resolveUnderRoot(target.root, remotePath);
21376
21884
  if (typeof absolute !== "string") throw new Error(absolute.error);
@@ -21458,6 +21966,7 @@ function managementToolDefinitions(backend) {
21458
21966
  const target = targetFor4(backend, exec);
21459
21967
  const resolved = resolveUnderRoot(target.root, requested, void 0, target.connectionId);
21460
21968
  if (typeof resolved !== "string") throw new Error(resolved.error);
21969
+ backend.assertWritable?.(target, mode === "permanent" ? "\u6C38\u4E45\u5220\u9664\u8FDC\u7AEF\u6587\u4EF6" : "\u5220\u9664\u8FDC\u7AEF\u6587\u4EF6");
21461
21970
  const result = await backend.discard({ target, path: resolved, mode, signal: exec.signal });
21462
21971
  return {
21463
21972
  mode: result.mode,
@@ -21517,6 +22026,8 @@ function managementToolDefinitions(backend) {
21517
22026
  throw new Error("id \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\uFF08\u7701\u7565\u5B83\u8868\u793A\u64A4\u9500\u6700\u8FD1\u4E00\u6B21\u5220\u9664\uFF09");
21518
22027
  }
21519
22028
  const target = targetFor4(backend, exec);
22029
+ const listing = record2["list"] === true;
22030
+ if (!listing) backend.assertWritable?.(target, "\u4ECE\u56DE\u6536\u7AD9\u6062\u590D\u8FDC\u7AEF\u6587\u4EF6");
21520
22031
  const outcome = await backend.restore({
21521
22032
  target,
21522
22033
  id: typeof id === "string" ? id : void 0,
@@ -21573,6 +22084,7 @@ function managementToolDefinitions(backend) {
21573
22084
  if (typeof source !== "string") throw new Error(source.error);
21574
22085
  const destination = resolveUnderRoot(target.root, to, void 0, target.connectionId);
21575
22086
  if (typeof destination !== "string") throw new Error(destination.error);
22087
+ backend.assertWritable?.(target, "\u590D\u5236\u8FDC\u7AEF\u6587\u4EF6");
21576
22088
  const outcome = await backend.copy({
21577
22089
  target,
21578
22090
  from: source,
@@ -21807,6 +22319,8 @@ function wireManagementTools(env) {
21807
22319
  )?.root;
21808
22320
  const managementBackend = {
21809
22321
  resolve: (cwd) => toolBackend.resolve(cwd),
22322
+ // 管理工具(上传/删除/恢复/复制)之前完全不过写闸:read-only 会话照样能删文件(审计 2026-11-06 宿主 P0)。
22323
+ assertWritable: (target, action) => env.assertWritable(target, action),
21810
22324
  // ADR-0013: the session id (plus its fork lineage) is the strict scope; `cwd` alone cannot separate
21811
22325
  // two conversations opened on the same directory.
21812
22326
  resolveSessionRoot: (cwd, sessionId) => sessionRoots.outcomeFor(cwd, sessionIdentityOf(ctx, sessionId)),
@@ -21926,6 +22440,7 @@ function wireManagementTools(env) {
21926
22440
  }
21927
22441
 
21928
22442
  // src/wiring/tools.ts
22443
+ init_errors();
21929
22444
  function sessionRootsFilePath() {
21930
22445
  return join18(resolveStateDir(), USER_ROOTS_FILE_NAME);
21931
22446
  }
@@ -21966,7 +22481,8 @@ function wireTools(env) {
21966
22481
  disposeJobTools,
21967
22482
  disposeFileTools,
21968
22483
  lspRegistry,
21969
- disposeLspTools
22484
+ disposeLspTools,
22485
+ assertWritable: assertWritable2
21970
22486
  } = wireFileAndJobTools({ ctx, limits, profiles, transports, registry, sessionRoots });
21971
22487
  const sessionAdmin = wireSessionAdmin({ ctx, limits, notifySessionRemoved: notifySessionRemoved2 });
21972
22488
  const { dispose: disposeManagement } = wireManagementTools({
@@ -21980,7 +22496,8 @@ function wireTools(env) {
21980
22496
  sessionRoots,
21981
22497
  recordFor,
21982
22498
  transferService,
21983
- transferTracker
22499
+ transferTracker,
22500
+ assertWritable: assertWritable2
21984
22501
  });
21985
22502
  const disposeStrictArguments = strictArgumentsGate(ctx);
21986
22503
  return {
@@ -22049,7 +22566,9 @@ function wireAgentTools(env) {
22049
22566
  disposeRemoteTools();
22050
22567
  disposeGitTool();
22051
22568
  disposeForwardTool();
22052
- void forwardBackend.closeAll();
22569
+ void forwardBackend.closeAll().catch((error) => {
22570
+ ctx.logger?.warn?.(`over-ssh: \u5173\u95ED\u7AEF\u53E3\u8F6C\u53D1\u65F6\u51FA\u9519\uFF1A${error instanceof Error ? error.message : String(error)}`);
22571
+ });
22053
22572
  };
22054
22573
  return { sessionRoots, toolBackend, disposeTools };
22055
22574
  }
@@ -22086,21 +22605,33 @@ function wireFileAndJobTools(env) {
22086
22605
  backendFor: (target) => recordFor(target)?.backend,
22087
22606
  ...limits.maxReadBytes === void 0 ? {} : { maxReadBytes: limits.maxReadBytes }
22088
22607
  });
22089
- const allowMutation = (target) => {
22608
+ const allowMutation = (target, action) => {
22609
+ const policy = platformMutationPolicy(ctx);
22610
+ const sessionId = currentSessionId();
22611
+ const cwd = sessionId === void 0 || sessionId === "" ? void 0 : readSessionCwd(ctx, sessionId);
22090
22612
  const refusal = mutationRefusal(
22091
- `${target.connectionId}:${target.root}`,
22613
+ action === void 0 ? `${target.connectionId}:${target.root}` : `${action}\uFF1A${target.connectionId}:${target.root}`,
22092
22614
  {
22093
- ...platformMutationPolicy(ctx),
22615
+ ...policy,
22094
22616
  rootArea: () => {
22095
22617
  const record2 = recordFor(target);
22096
22618
  if (record2 === void 0) return "deny";
22097
- return workspaceWriteArea(record2.root, void 0, void 0, sessionIdentityOf(ctx, currentSessionId()));
22619
+ return workspaceWriteArea(record2.root, policy.policy?.workspaceRoot, cwd, sessionIdentityOf(ctx, sessionId));
22098
22620
  }
22099
22621
  }
22100
22622
  );
22101
22623
  if (refusal !== void 0) throw new Error(refusal);
22102
22624
  };
22103
22625
  const disposeFileTools = registerRemoteFileTools(ctx, fileBackend, { allowMutation });
22626
+ const assertWritable2 = (target, action) => {
22627
+ allowMutation(target, action);
22628
+ if (recordFor(target)?.root.writable === false) {
22629
+ throw new SshDevError("SANDBOX_DENIED", `\u8FD9\u4E2A\u6839\u5728\u914D\u7F6E\u91CC\u662F\u53EA\u8BFB\u7684\uFF08writable: false\uFF09\uFF0C\u62D2\u7EDD\u5199\u5165\uFF1A${action}`, {
22630
+ connectionId: target.connectionId,
22631
+ root: target.root
22632
+ });
22633
+ }
22634
+ };
22104
22635
  const lspRegistry = createLspRegistry({
22105
22636
  acquire: async (connectionId) => (await transports.acquire(requireProfile(profiles, connectionId))).transport,
22106
22637
  onLog: (connectionId, message) => {
@@ -22108,7 +22639,7 @@ function wireFileAndJobTools(env) {
22108
22639
  }
22109
22640
  });
22110
22641
  const disposeLspTools = registerRemoteLspTools(ctx, { backend: fileBackend, registry: lspRegistry });
22111
- return { recordFor, disposeJobTools, disposeFileTools, lspRegistry, disposeLspTools };
22642
+ return { recordFor, disposeJobTools, disposeFileTools, lspRegistry, disposeLspTools, assertWritable: assertWritable2 };
22112
22643
  }
22113
22644
 
22114
22645
  // src/index.ts