dsh-over-ssh 0.2.1 → 0.2.2

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
  }
@@ -4494,13 +4545,14 @@ function createSessionAdminContext(deps) {
4494
4545
  const dir = joinPath(joinPath(sessionsRoot, project), session.name);
4495
4546
  const contents = await deps.listDir(dir);
4496
4547
  let bytes = 0;
4497
- let updatedAtMs = session.mtimeMs;
4548
+ let updatedAtMs = Math.trunc(session.mtimeMs);
4498
4549
  for (const item of contents) {
4499
4550
  if (item.isDirectory) continue;
4500
4551
  bytes += item.bytes;
4501
- if (item.mtimeMs > updatedAtMs) updatedAtMs = item.mtimeMs;
4552
+ const itemMs = Math.trunc(item.mtimeMs);
4553
+ if (itemMs > updatedAtMs) updatedAtMs = itemMs;
4502
4554
  }
4503
- const header = await deps.readHeader(dir);
4555
+ const header = await deps.readHeader(dir, contents);
4504
4556
  if (header === void 0) {
4505
4557
  return {
4506
4558
  id: session.name,
@@ -4803,7 +4855,6 @@ function createSessionAdminService(deps) {
4803
4855
  const ordered = [...entries].sort((left, right) => left.deletedAtMs - right.deletedAtMs);
4804
4856
  const doomed = [];
4805
4857
  const nowMs = deps.now();
4806
- let keptBytes = ordered.reduce((sum, item) => sum + item.bytes, 0);
4807
4858
  if (policy.maxAgeMs > 0) {
4808
4859
  for (const entry of ordered) {
4809
4860
  if (nowMs - entry.deletedAtMs > policy.maxAgeMs) doomed.push(entry);
@@ -4812,6 +4863,7 @@ function createSessionAdminService(deps) {
4812
4863
  const doomedIds = new Set(doomed.map((entry) => entry.id));
4813
4864
  const survivors = ordered.filter((entry) => !doomedIds.has(entry.id));
4814
4865
  if (policy.maxBytes > 0) {
4866
+ let keptBytes = survivors.reduce((sum, item) => sum + item.bytes, 0);
4815
4867
  for (const entry of survivors) {
4816
4868
  if (keptBytes <= policy.maxBytes) break;
4817
4869
  doomed.push(entry);
@@ -4824,7 +4876,7 @@ function createSessionAdminService(deps) {
4824
4876
  const excess = stillKept.length - policy.maxEntries;
4825
4877
  for (const entry of stillKept.slice(0, excess)) {
4826
4878
  doomed.push(entry);
4827
- keptBytes -= entry.bytes;
4879
+ doomedIds.add(entry.id);
4828
4880
  }
4829
4881
  }
4830
4882
  const outcome = await purgeEntries(doomed, entries);
@@ -4840,11 +4892,54 @@ function createSessionAdminService(deps) {
4840
4892
  }
4841
4893
 
4842
4894
  // src/host/integration/session/session-header.ts
4843
- import { open, readdir } from "node:fs/promises";
4895
+ import { open, readdir, stat } from "node:fs/promises";
4844
4896
  import { join } from "node:path";
4845
4897
  import { createZstdDecompress } from "node:zlib";
4846
4898
  var SESSION_HEADER_READ_BYTES = 64 * 1024;
4847
4899
  var SESSION_HEADER_RETRY_BYTES = 1024 * 1024;
4900
+ function createCachedSessionHeaderReader(io, options = {}) {
4901
+ const inner = createSessionHeaderReader(io);
4902
+ const stat7 = io.stat;
4903
+ if (stat7 === void 0) return inner;
4904
+ const maxEntries = Math.max(1, Math.floor(options.maxEntries ?? SESSION_HEADER_CACHE_MAX));
4905
+ const cache = /* @__PURE__ */ new Map();
4906
+ return async (dir, listing) => {
4907
+ let signature;
4908
+ if (listing !== void 0) {
4909
+ const log = listing.find((entry) => entry.name.startsWith("session.v") && entry.name.endsWith(".zstd"));
4910
+ if (log !== void 0) signature = `${log.size}:${Math.trunc(log.mtimeMs)}`;
4911
+ } else {
4912
+ try {
4913
+ const names = await io.listDir(dir);
4914
+ const log = names.find((name2) => name2.startsWith("session.v") && name2.endsWith(".zstd"));
4915
+ if (log !== void 0) {
4916
+ const info = await stat7(join(dir, log));
4917
+ signature = `${info.size}:${Math.trunc(info.mtimeMs)}`;
4918
+ }
4919
+ } catch {
4920
+ }
4921
+ }
4922
+ if (signature !== void 0) {
4923
+ const hit = cache.get(dir);
4924
+ if (hit !== void 0 && hit.signature === signature) {
4925
+ cache.delete(dir);
4926
+ cache.set(dir, hit);
4927
+ return hit.header;
4928
+ }
4929
+ }
4930
+ const header = await inner(dir);
4931
+ if (signature !== void 0) {
4932
+ cache.set(dir, { signature, header });
4933
+ while (cache.size > maxEntries) {
4934
+ const oldest = cache.keys().next();
4935
+ if (oldest.done === true) break;
4936
+ cache.delete(oldest.value);
4937
+ }
4938
+ }
4939
+ return header;
4940
+ };
4941
+ }
4942
+ var SESSION_HEADER_CACHE_MAX = 2048;
4848
4943
  function parseSessionHeaderLine(text8) {
4849
4944
  const firstLine = text8.split("\n")[0];
4850
4945
  if (firstLine === void 0 || firstLine === "") return void 0;
@@ -4930,11 +5025,16 @@ function createDiskSessionHeaderIo() {
4930
5025
  return {
4931
5026
  listDir: (dir) => readdir(dir),
4932
5027
  readHead: readHeadFromDisk,
4933
- decompress: decompressPrefix
5028
+ decompress: decompressPrefix,
5029
+ // The cache's only input: `(size, mtime)` is what a session log's append changes.
5030
+ stat: async (path) => {
5031
+ const info = await stat(path);
5032
+ return { size: info.size, mtimeMs: info.mtimeMs };
5033
+ }
4934
5034
  };
4935
5035
  }
4936
5036
  function createDiskSessionHeaderReader() {
4937
- return createSessionHeaderReader(createDiskSessionHeaderIo());
5037
+ return createCachedSessionHeaderReader(createDiskSessionHeaderIo());
4938
5038
  }
4939
5039
 
4940
5040
  // src/host/app/routed-fs.ts
@@ -5820,17 +5920,25 @@ var RootRegistry = class {
5820
5920
  * sessions at all: an empty list means "the session service was not readable", not "no session is live", and
5821
5921
  * dropping every grant on a transient read failure would break live turns.
5822
5922
  *
5823
- * @param cwds - working directories of the live sessions.
5923
+ * @param cwds - working directories of the live sessions, optionally with the session id that owns each one.
5924
+ * With an id the root carries `sessions: [id]` and is writable **only by that session**; without one it is a
5925
+ * workspace-scoped root (the legacy shape tests and agentless compositions use).
5824
5926
  * @returns the session roots this call added (empty when every cwd was already covered).
5825
5927
  */
5826
5928
  syncSessionWorkspaces(cwds) {
5827
5929
  if (cwds.length === 0) return [];
5828
5930
  const live = /* @__PURE__ */ new Set();
5829
5931
  const added = [];
5830
- for (const cwd of cwds) {
5932
+ for (const item of cwds) {
5933
+ const cwd = typeof item === "string" ? item : item.cwd;
5934
+ const sessionId = typeof item === "string" ? void 0 : item.sessionId;
5831
5935
  if (typeof cwd !== "string" || cwd === "") continue;
5832
5936
  if (!isAbsoluteLocalPath(normalizeLocalPath2(cwd, { caseInsensitive: false }))) continue;
5833
- const root = makeLocalRoot(cwd, { workspaces: [cwd], writable: true });
5937
+ const root = makeLocalRoot(cwd, {
5938
+ workspaces: [cwd],
5939
+ ...sessionId === void 0 || sessionId === "" ? {} : { sessions: [sessionId] },
5940
+ writable: true
5941
+ });
5834
5942
  const key = rootKey(root);
5835
5943
  live.add(key);
5836
5944
  if (this.#localRootCovers(cwd)) continue;
@@ -6007,7 +6115,7 @@ function carryMissingState(legacy, current, files, dirs) {
6007
6115
  renameSync(temp, to);
6008
6116
  } catch (error) {
6009
6117
  rmSync(temp, { force: true });
6010
- failures.push(`${from} \u2192 ${to}\uFF1A${describe(error)}`);
6118
+ if (!existsSync(to)) failures.push(`${from} \u2192 ${to}\uFF1A${describe(error)}`);
6011
6119
  }
6012
6120
  }
6013
6121
  for (const name2 of dirs) {
@@ -6020,7 +6128,7 @@ function carryMissingState(legacy, current, files, dirs) {
6020
6128
  renameSync(temp, to);
6021
6129
  } catch (error) {
6022
6130
  rmSync(temp, { recursive: true, force: true });
6023
- failures.push(`${from} \u2192 ${to}\uFF1A${describe(error)}`);
6131
+ if (!existsSync(to)) failures.push(`${from} \u2192 ${to}\uFF1A${describe(error)}`);
6024
6132
  }
6025
6133
  }
6026
6134
  return failures;
@@ -6446,9 +6554,9 @@ var AgentFrameParser = class {
6446
6554
  */
6447
6555
  #append(chunk) {
6448
6556
  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;
6557
+ const pending2 = this.#end - this.#start;
6558
+ if (this.#store.byteLength - this.#start < pending2 + chunk.byteLength) {
6559
+ const needed = pending2 + chunk.byteLength;
6452
6560
  if (this.#store.byteLength >= needed) {
6453
6561
  this.#store.copyWithin(0, this.#start, this.#end);
6454
6562
  } else {
@@ -6457,7 +6565,7 @@ var AgentFrameParser = class {
6457
6565
  this.#store = grown;
6458
6566
  }
6459
6567
  this.#start = 0;
6460
- this.#end = pending;
6568
+ this.#end = pending2;
6461
6569
  }
6462
6570
  this.#store.set(chunk, this.#end);
6463
6571
  this.#end += chunk.byteLength;
@@ -7242,9 +7350,9 @@ var AgentProtocolClient = class {
7242
7350
  failConnection(error) {
7243
7351
  if (this.#lost !== void 0) return;
7244
7352
  this.#lost = error;
7245
- const pending = [...this.#pending.values()];
7353
+ const pending2 = [...this.#pending.values()];
7246
7354
  this.#pending.clear();
7247
- for (const entry of pending) entry.settle({ error });
7355
+ for (const entry of pending2) entry.settle({ error });
7248
7356
  for (const channel of this.#blobQueue) channel.fail(error);
7249
7357
  this.#blobQueue.length = 0;
7250
7358
  this.#blobById.clear();
@@ -8129,14 +8237,14 @@ var AgentTransport = class extends TransportBase {
8129
8237
  }
8130
8238
  }
8131
8239
  if (expectedVersion === void 0) {
8132
- let stat5;
8240
+ let stat7;
8133
8241
  try {
8134
- stat5 = asRecord(await client.request("fs.stat", { path: request.path }, this.#requestOptions(void 0, signal)));
8242
+ stat7 = asRecord(await client.request("fs.stat", { path: request.path }, this.#requestOptions(void 0, signal)));
8135
8243
  } catch (error) {
8136
8244
  throw this.#translate(error);
8137
8245
  }
8138
- const exists = stat5?.["exists"] === true;
8139
- const observed = typeof stat5?.["version"] === "string" ? stat5["version"] : void 0;
8246
+ const exists = stat7?.["exists"] === true;
8247
+ const observed = typeof stat7?.["version"] === "string" ? stat7["version"] : void 0;
8140
8248
  if (!exists || observed === void 0 || observed !== expected) {
8141
8249
  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
8250
  connectionId: this.id,
@@ -9394,7 +9502,7 @@ function localVersion(info) {
9394
9502
  init_temp_name();
9395
9503
  init_errors();
9396
9504
  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";
9505
+ import { access, chmod, lstat, mkdir, open as open2, readdir as readdir2, rename, rm, stat as stat2, unlink, writeFile } from "node:fs/promises";
9398
9506
  import { dirname as dirname3, join as join5 } from "node:path";
9399
9507
  var LIST_STAT_CONCURRENCY = 32;
9400
9508
  var LocalBackend = class {
@@ -9407,12 +9515,12 @@ var LocalBackend = class {
9407
9515
  this.id = options.id;
9408
9516
  this.sandboxMode = options.sandboxMode ?? "workspace-write";
9409
9517
  this.#maxReadBytes = options.maxReadBytes ?? 16 * 1024 * 1024;
9410
- this.#statEntry = options.statEntry ?? ((path) => stat(path));
9518
+ this.#statEntry = options.statEntry ?? ((path) => stat2(path));
9411
9519
  }
9412
9520
  async stat(absolute, signal) {
9413
9521
  signal?.throwIfAborted();
9414
9522
  try {
9415
- const info = await stat(absolute);
9523
+ const info = await stat2(absolute);
9416
9524
  return {
9417
9525
  exists: true,
9418
9526
  isDirectory: info.isDirectory(),
@@ -10069,7 +10177,9 @@ function buildListScript(absolute) {
10069
10177
  `if [ ! -d "$p" ]; then printf 'DSH_ERROR=not-a-dir\\n'; exit 4; fi`,
10070
10178
  // GNU find (Linux). `-printf ''` is the cheap capability probe: it fails on BSD/macOS find.
10071
10179
  `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'`,
10180
+ // **NUL 分隔**:文件名可以含换行(POSIX 合法,`git checkout` 就能造出来),按行切会把一个条目拆成
10181
+ // 两行、解析出一个不存在的名字并丢掉真条目(审计 2026-11-06 第 3 轮 P1)。
10182
+ ` find "$p" -mindepth 1 -maxdepth 1 -printf '%y\\t%Y\\t%s\\t%T@\\t%f\\0'`,
10073
10183
  `else`,
10074
10184
  ` for entry in "$p"/* "$p"/.[!.]*; do`,
10075
10185
  ` [ -e "$entry" ] || continue`,
@@ -10077,28 +10187,28 @@ function buildListScript(absolute) {
10077
10187
  ` if [ -d "$entry" ]; then kind=d; elif [ -L "$entry" ]; then kind=l; else kind=f; fi`,
10078
10188
  ` size=$(wc -c < "$entry" 2>/dev/null | tr -d ' ' || echo 0)`,
10079
10189
  ` 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"`,
10190
+ ` printf '%s\\t%s\\t%s\\t%s\\t%s\\0' "$kind" "$kind" "$size" "$mtime" "$name"`,
10081
10191
  ` done`,
10082
10192
  `fi`
10083
10193
  ].join("\n");
10084
10194
  }
10085
10195
  function parseListing(stdout) {
10086
10196
  const entries = [];
10087
- for (const line of stdout.split("\n")) {
10088
- if (line === "" || line.startsWith("DSH_")) continue;
10089
- const parts = line.split(" ");
10197
+ for (const record2 of stdout.split("\0")) {
10198
+ if (record2 === "" || record2.startsWith("DSH_")) continue;
10199
+ const parts = record2.split(" ");
10090
10200
  if (parts.length < 5) continue;
10091
10201
  const [typeChar, targetChar, sizeText, mtimeText] = parts;
10092
10202
  const kind = typeChar === "d" ? "d" : typeChar === "l" ? targetChar === "d" ? "d" : "l" : "f";
10093
10203
  const size = Number.parseInt(sizeText, 10);
10094
- const seconds = Number.parseInt(mtimeText, 10);
10204
+ const seconds = Number.parseFloat(mtimeText);
10095
10205
  entries.push({
10096
10206
  name: parts.slice(4).join(" "),
10097
10207
  isDirectory: kind === "d",
10098
10208
  isFile: kind === "f",
10099
10209
  isSymlink: kind === "l",
10100
10210
  size: Number.isFinite(size) ? size : 0,
10101
- mtimeMs: (Number.isFinite(seconds) ? seconds : 0) * 1e3
10211
+ mtimeMs: Number.isFinite(seconds) ? Math.trunc(seconds * 1e3) : 0
10102
10212
  });
10103
10213
  }
10104
10214
  entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
@@ -10486,6 +10596,13 @@ var RemoteBackend = class {
10486
10596
  if (result.stdout.includes("DSH_ERROR=not-a-dir")) {
10487
10597
  throw new SshDevError("REMOTE_NOT_FOUND", `\u8FDC\u7AEF\u76EE\u5F55\u4E0D\u5B58\u5728\uFF1A${absolute}`, { path: absolute });
10488
10598
  }
10599
+ if (result.truncated === true) {
10600
+ throw new SshDevError("REMOTE_COMMAND_FAILED", `\u76EE\u5F55\u6761\u76EE\u8FC7\u591A\uFF0C\u5217\u4E3E\u7ED3\u679C\u88AB\u622A\u65AD\uFF1A${absolute}`, {
10601
+ path: absolute,
10602
+ reason: "listing-truncated",
10603
+ maxBytes: 4 * 1024 * 1024
10604
+ });
10605
+ }
10489
10606
  return parseListing(result.stdout);
10490
10607
  }
10491
10608
  /**
@@ -11127,7 +11244,21 @@ var RemoteBackend = class {
11127
11244
  }
11128
11245
  const command = tool === "sha256sum" ? "sha256sum" : "shasum -a 256";
11129
11246
  const result = await (await this.#transport()).exec({ argv: ["sh", "-c", `${command} -- "$1" | cut -d' ' -f1`, "dsh-digest", absolute] }, signal);
11130
- return result.stdout.trim();
11247
+ if (result.exitCode !== 0) {
11248
+ 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}`, {
11249
+ path: absolute,
11250
+ exitCode: result.exitCode,
11251
+ stderr: result.stderr.slice(0, 200)
11252
+ });
11253
+ }
11254
+ const digest = result.stdout.trim();
11255
+ if (!/^[0-9a-f]{64}$/.test(digest)) {
11256
+ 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}`, {
11257
+ path: absolute,
11258
+ digest: digest.slice(0, 120)
11259
+ });
11260
+ }
11261
+ return digest;
11131
11262
  }
11132
11263
  };
11133
11264
  function sha256Hex(bytes) {
@@ -11958,12 +12089,12 @@ init_ssh2_transport();
11958
12089
 
11959
12090
  // src/host/integration/agent/agent-install-io.ts
11960
12091
  init_shell_quote();
11961
- import { readFile as readFile2, stat as stat2 } from "node:fs/promises";
12092
+ import { readFile as readFile2, stat as stat3 } from "node:fs/promises";
11962
12093
  var DEFAULT_MAX_UPLOAD_BYTES = 32 * 1024 * 1024;
11963
12094
  function createAgentInstallIo(options) {
11964
12095
  const maxUploadBytes = options.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
11965
12096
  const readLocal = options.readLocalFile ?? ((path) => readFile2(path).then((buffer) => new Uint8Array(buffer)));
11966
- const sizeOf = options.localSize ?? ((path) => stat2(path).then((info) => info.size));
12097
+ const sizeOf = options.localSize ?? ((path) => stat3(path).then((info) => info.size));
11967
12098
  const exec = async (command, signal) => {
11968
12099
  const result = await options.transport.exec(
11969
12100
  { argv: ["/bin/sh", "-c", command], maxBytes: options.probeMaxBytes ?? 256 * 1024 },
@@ -12046,14 +12177,14 @@ function reviseToTier1(profile, decision, reason, hint) {
12046
12177
  // src/host/integration/agent/askpass-registry.ts
12047
12178
  function createAskpassRegistry(options) {
12048
12179
  const handles = /* @__PURE__ */ new Map();
12049
- const pending = /* @__PURE__ */ new Map();
12180
+ const pending2 = /* @__PURE__ */ new Map();
12050
12181
  const retired = /* @__PURE__ */ new Set();
12051
12182
  const prepare = async (profile) => {
12052
12183
  if (!needsAskpass(profile.auth.kind)) return void 0;
12053
12184
  if (retired.has(profile.id)) return void 0;
12054
12185
  const existing = handles.get(profile.id);
12055
12186
  if (existing !== void 0) return existing.env;
12056
- const inflight = pending.get(profile.id);
12187
+ const inflight = pending2.get(profile.id);
12057
12188
  if (inflight !== void 0) return inflight;
12058
12189
  const attempt = (async () => {
12059
12190
  try {
@@ -12070,16 +12201,16 @@ function createAskpassRegistry(options) {
12070
12201
  return void 0;
12071
12202
  }
12072
12203
  })().finally(() => {
12073
- pending.delete(profile.id);
12204
+ pending2.delete(profile.id);
12074
12205
  });
12075
- pending.set(profile.id, attempt);
12206
+ pending2.set(profile.id, attempt);
12076
12207
  return attempt;
12077
12208
  };
12078
12209
  const forget = async (connectionId) => {
12079
12210
  retired.add(connectionId);
12080
12211
  const handle = handles.get(connectionId);
12081
12212
  handles.delete(connectionId);
12082
- pending.delete(connectionId);
12213
+ pending2.delete(connectionId);
12083
12214
  if (handle !== void 0) await handle.dispose().catch(() => void 0);
12084
12215
  };
12085
12216
  return {
@@ -12097,7 +12228,7 @@ function createAskpassRegistry(options) {
12097
12228
  retired.delete(connectionId);
12098
12229
  },
12099
12230
  disposeAll: async () => {
12100
- const ids = /* @__PURE__ */ new Set([...handles.keys(), ...pending.keys()]);
12231
+ const ids = /* @__PURE__ */ new Set([...handles.keys(), ...pending2.keys()]);
12101
12232
  for (const id of ids) retired.add(id);
12102
12233
  await Promise.all([...ids].map((id) => forget(id)));
12103
12234
  }
@@ -13455,12 +13586,8 @@ function wireConnections(ctx, config) {
13455
13586
  }
13456
13587
  function readLiveSessions(ctx) {
13457
13588
  let list;
13458
- try {
13459
- const sessions = ctx.get("sessions");
13460
- list = sessions?.list?.();
13461
- } catch {
13462
- return [];
13463
- }
13589
+ const sessions = ctx.get("sessions");
13590
+ list = sessions?.list?.();
13464
13591
  if (!Array.isArray(list)) return [];
13465
13592
  const live = [];
13466
13593
  for (const session of list) {
@@ -13482,9 +13609,9 @@ import { bindTypertRemote } from "@deepseek-ai/dsh-typert-protocol";
13482
13609
  // src/shared/remote-identity.ts
13483
13610
  var SSH_DEV_REMOTE_NAMESPACE = "sshdev";
13484
13611
  var SSH_DEV_REMOTE_SERVICE = "sshDevRemote";
13612
+ var SSH_DEV_REMOTE_METHOD = "connections";
13485
13613
 
13486
13614
  // src/host/integration/remote/remote-face.ts
13487
- var SSH_DEV_REMOTE_CONNECTIONS_METHOD = "connections";
13488
13615
  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
13616
  function buildConnectionStatusRows(source) {
13490
13617
  return source.profiles().map((profile) => {
@@ -13549,7 +13676,7 @@ var SshDevRemoteFace = class extends Service {
13549
13676
  return buildConnectionStatusRows(this.#source);
13550
13677
  }
13551
13678
  };
13552
- markRemoteMethod(SshDevRemoteFace.prototype, SSH_DEV_REMOTE_CONNECTIONS_METHOD);
13679
+ markRemoteMethod(SshDevRemoteFace.prototype, SSH_DEV_REMOTE_METHOD);
13553
13680
  function installRemoteFace(ctx) {
13554
13681
  return new SshDevRemoteFace(ctx, {
13555
13682
  profiles: () => ctx.get("sshDevConnections")?.list() ?? [],
@@ -13753,7 +13880,7 @@ import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools";
13753
13880
  init_errors();
13754
13881
 
13755
13882
  // 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";
13883
+ import { mkdir as mkdir4, readFile as readFile5, rename as rename3, stat as stat4, unlink as unlink3, writeFile as writeFile4 } from "node:fs/promises";
13757
13884
  import { join as join12 } from "node:path";
13758
13885
  init_errors();
13759
13886
  var USER_ROOTS_VERSION = 1;
@@ -13779,6 +13906,10 @@ var nodeUserRootsIo = {
13779
13906
  await mkdir4(path, { recursive: true, mode: 448 });
13780
13907
  },
13781
13908
  rename: (from, to) => rename3(from, to),
13909
+ stat: async (path) => {
13910
+ const info = await stat4(path);
13911
+ return { size: info.size, mtimeMs: info.mtimeMs };
13912
+ },
13782
13913
  unlink: async (path) => {
13783
13914
  try {
13784
13915
  await unlink3(path);
@@ -14141,7 +14272,19 @@ function createUserRootsStore(options) {
14141
14272
  const filePath = join12(options.dir, USER_ROOTS_FILE_NAME);
14142
14273
  let cachedRaw;
14143
14274
  let cachedLoad;
14275
+ let cachedSignature;
14276
+ const signatureOf = async () => {
14277
+ if (io.stat === void 0) return void 0;
14278
+ try {
14279
+ const info = await io.stat(filePath);
14280
+ return `${info.size}:${Math.trunc(info.mtimeMs)}`;
14281
+ } catch {
14282
+ return void 0;
14283
+ }
14284
+ };
14144
14285
  const load = async () => {
14286
+ const signature = await signatureOf();
14287
+ if (signature !== void 0 && signature === cachedSignature && cachedLoad !== void 0) return cachedLoad;
14145
14288
  let text8;
14146
14289
  try {
14147
14290
  text8 = await io.readFile(filePath);
@@ -14153,10 +14296,14 @@ function createUserRootsStore(options) {
14153
14296
  degraded: true
14154
14297
  };
14155
14298
  }
14156
- if (cachedRaw !== void 0 && text8 === cachedRaw && cachedLoad !== void 0) return cachedLoad;
14299
+ if (cachedRaw !== void 0 && text8 === cachedRaw && cachedLoad !== void 0) {
14300
+ cachedSignature = signature;
14301
+ return cachedLoad;
14302
+ }
14157
14303
  const parsed = parseUserRoots(text8);
14158
14304
  cachedRaw = text8;
14159
14305
  cachedLoad = parsed;
14306
+ cachedSignature = signature;
14160
14307
  return parsed;
14161
14308
  };
14162
14309
  const write = async (next, preparedText) => {
@@ -14173,6 +14320,7 @@ function createUserRootsStore(options) {
14173
14320
  }
14174
14321
  cachedRaw = text8;
14175
14322
  cachedLoad = { state: validated, warnings: [], degraded: false };
14323
+ cachedSignature = void 0;
14176
14324
  return validated;
14177
14325
  };
14178
14326
  const queue = createSerialQueue();
@@ -14404,6 +14552,9 @@ var STATUS_BY_CODE = {
14404
14552
  ROOT_INVALID: 400,
14405
14553
  CONNECTION_INVALID: 400,
14406
14554
  CONNECTION_NOT_FOUND: 400,
14555
+ // 路径不在任何已注册的根内 / 根没有注册:客户端换一个路径或先加根就能成功,不是服务器故障。
14556
+ // 它此前漏在表外,于是**所有**经 HTTP 暴露的根外访问都报 500(审计 2026-11-06 第 4 轮 P1)。
14557
+ ROOT_OUTSIDE: 400,
14407
14558
  // 目标不存在 / 状态冲突 / 本组合不支持:客户端可以据此换一条路。
14408
14559
  TRASH_NOT_FOUND: 404,
14409
14560
  TRANSFER_CONFLICT: 409,
@@ -14446,6 +14597,9 @@ function optionalPath(value) {
14446
14597
  reason: "invalid-path"
14447
14598
  });
14448
14599
  }
14600
+ if (/[\u0000-\u001f]/.test(value)) {
14601
+ throw new SshDevError("ROOT_INVALID", "path \u4E0D\u80FD\u5305\u542B\u63A7\u5236\u5B57\u7B26\uFF08\u542B NUL\uFF09", { reason: "invalid-path" });
14602
+ }
14449
14603
  return value.trim();
14450
14604
  }
14451
14605
  function pick(source, fields) {
@@ -14492,6 +14646,15 @@ function connectionIdFromAlias(alias) {
14492
14646
  // src/host/app/roots/session-directories.ts
14493
14647
  var SESSION_DIRECTORY_MAX_ROOTS = 32;
14494
14648
  var SYSTEM_TREES = ["/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64", "/boot", "/sys", "/proc", "/dev", "/var", "/root"];
14649
+ var REMOTE_SYSTEM_TREES = ["/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64", "/boot", "/sys", "/proc", "/dev", "/var", "/root"];
14650
+ var REMOTE_BROAD_ROOTS = [...REMOTE_SYSTEM_TREES, "/home", "/opt", "/srv", "/mnt", "/media", "/tmp"];
14651
+ function remotePathTooBroad(normalized) {
14652
+ if (REMOTE_BROAD_ROOTS.includes(normalized)) return true;
14653
+ for (const tree of REMOTE_SYSTEM_TREES) {
14654
+ if (normalized === tree || normalized.startsWith(`${tree}/`)) return true;
14655
+ }
14656
+ return false;
14657
+ }
14495
14658
  function directoryRefusal(request) {
14496
14659
  const trimmed = request.path.trim();
14497
14660
  if (trimmed === "") return "\u76EE\u5F55\u8DEF\u5F84\u4E3A\u7A7A\u3002";
@@ -14507,6 +14670,9 @@ function directoryRefusal(request) {
14507
14670
  return error instanceof Error ? error.message : `\u8FDC\u7AEF\u8DEF\u5F84\u4E0D\u5408\u6CD5\uFF1A${trimmed}`;
14508
14671
  }
14509
14672
  if (normalized2 === "/") return "\u4E0D\u5141\u8BB8\u628A\u8FDC\u7AEF\u7684\u6839\u76EE\u5F55 / \u6DFB\u52A0\u5230\u4F1A\u8BDD\u3002";
14673
+ if (remotePathTooBroad(normalized2)) {
14674
+ 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`;
14675
+ }
14510
14676
  return void 0;
14511
14677
  }
14512
14678
  const normalized = trimmed.replace(/\/+$/u, "") || "/";
@@ -14644,6 +14810,15 @@ async function ensureLiveAndReadAnchor(deps, key, state) {
14644
14810
  }
14645
14811
  return readAnchor(await Promise.resolve(deps.listRoots()));
14646
14812
  }
14813
+ async function liveScopeWarning(deps, key, state) {
14814
+ const persisted = findRootInState(state, key);
14815
+ if (persisted === void 0) return void 0;
14816
+ const roots = await Promise.resolve(deps.listRoots());
14817
+ const liveEntry = roots.find((candidate) => liveKeyOfRecord(candidate, persisted) !== void 0);
14818
+ 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`;
14819
+ if (liveScopeIsCurrent(liveEntry, persisted)) return void 0;
14820
+ 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`;
14821
+ }
14647
14822
  async function findLiveKey(deps, entry) {
14648
14823
  for (const raw of await Promise.resolve(deps.listRoots())) {
14649
14824
  const key = liveKeyOfRecord(raw, entry);
@@ -14910,9 +15085,25 @@ function publicRoot(raw) {
14910
15085
  ...typeof source["connectionId"] === "string" ? { connectionId: source["connectionId"] } : {},
14911
15086
  path: String(source["path"] ?? ""),
14912
15087
  ...typeof source["label"] === "string" ? { label: source["label"] } : {},
14913
- ...typeof source["writable"] === "boolean" ? { writable: source["writable"] } : {}
15088
+ ...typeof source["writable"] === "boolean" ? { writable: source["writable"] } : {},
15089
+ ...stringList(source["workspaces"]) === void 0 ? {} : { workspaces: stringList(source["workspaces"]) },
15090
+ ...stringList(source["sessions"]) === void 0 ? {} : { sessions: stringList(source["sessions"]) }
14914
15091
  };
14915
15092
  }
15093
+ function liveRootScopes(raw) {
15094
+ const outer = asRecord2(raw) ?? {};
15095
+ const source = asRecord2(outer["root"]) ?? outer;
15096
+ const sessions = stringList(source["sessions"]);
15097
+ const workspaces = stringList(source["workspaces"]);
15098
+ return {
15099
+ ...sessions === void 0 ? {} : { sessions },
15100
+ ...workspaces === void 0 ? {} : { workspaces }
15101
+ };
15102
+ }
15103
+ function stringList(value) {
15104
+ if (!Array.isArray(value)) return void 0;
15105
+ return value.filter((item) => typeof item === "string");
15106
+ }
14916
15107
  function publicConnection(raw) {
14917
15108
  const source = asRecord2(raw) ?? {};
14918
15109
  const authKind = source["authKind"] ?? asRecord2(source["auth"])?.["kind"];
@@ -15038,21 +15229,34 @@ async function handleRootActions(action, body, req, res, deps) {
15038
15229
  if (registeredNow) await deps.unregisterRoot(key);
15039
15230
  throw error;
15040
15231
  }
15232
+ const warnings = [];
15233
+ if (existing !== void 0) {
15234
+ const warning = await liveScopeWarning(deps, key, state);
15235
+ if (warning !== void 0) warnings.push(warning);
15236
+ }
15041
15237
  const anchorPath = await anchorPathOf(deps, entry);
15042
15238
  send(req, res, 200, {
15043
15239
  ok: true,
15044
15240
  action,
15045
15241
  state: publicUserState(state),
15046
- ...anchorPath === void 0 ? {} : { anchorPath }
15242
+ ...anchorPath === void 0 ? {} : { anchorPath },
15243
+ ...warnings.length === 0 ? {} : { warnings }
15047
15244
  });
15048
15245
  return true;
15049
- return true;
15050
15246
  });
15051
15247
  }
15052
15248
  case "remove-root": {
15053
15249
  const key = requiredKey(body["key"]);
15054
15250
  const workspace = optionalWorkspaceName(body["workspace"]);
15055
15251
  const session = optionalSessionName(body["session"]);
15252
+ if (session !== void 0) {
15253
+ const current = await currentUserState(deps);
15254
+ const entry = findRootInState(current, key);
15255
+ const owners = entry?.sessions ?? [];
15256
+ if (owners.length > 0 && !owners.includes(session)) {
15257
+ 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 });
15258
+ }
15259
+ }
15056
15260
  const state = await reconcile(deps, (current) => {
15057
15261
  if (session !== void 0 && rootAddedBySession(current, key, session)) {
15058
15262
  return removeRootFromSession(current, key, session);
@@ -15734,16 +15938,40 @@ async function serveSnapshot(req, res, deps) {
15734
15938
  Promise.resolve(deps.listConnections()),
15735
15939
  Promise.resolve(deps.loadUserState())
15736
15940
  ]);
15941
+ const query = queryOf(req.url);
15942
+ const requestedSession = (query.get("session") ?? "").trim();
15943
+ const requestedWorkspace = (query.get("workspace") ?? "").trim();
15944
+ const ancestorsOf = deps.resolveSessionAncestors;
15945
+ const canFilter = requestedWorkspace !== "" || ancestorsOf !== void 0;
15946
+ const filtered = !canFilter || requestedSession === "" && requestedWorkspace === "" ? void 0 : {
15947
+ ids: new Set(
15948
+ requestedSession === "" ? [] : [requestedSession, ...ancestorsOf?.(requestedSession) ?? []]
15949
+ ),
15950
+ workspace: requestedWorkspace
15951
+ };
15952
+ const roots = filtered === void 0 ? liveRoots.map(publicRoot) : liveRoots.filter((raw) => visibleToSession(liveRootScopes(raw), filtered.ids, filtered.workspace)).map(publicRoot);
15737
15953
  send(req, res, 200, {
15738
15954
  ok: true,
15955
+ // 两个标记分开写:客户端只认 `scopedForSession`,而只带 workspace 的调用方也能看出"这份列表被过滤过"。
15956
+ ...filtered === void 0 ? {} : requestedSession === "" ? { scopedForWorkspace: requestedWorkspace } : { scopedForSession: requestedSession },
15739
15957
  // The plugin's **anchor root**, not the roots' individual anchor paths: see `RootsApiDeps.anchorRootDir`.
15740
15958
  ...deps.anchorRootDir === void 0 ? {} : { anchorRoot: deps.anchorRootDir },
15741
- roots: liveRoots.map(publicRoot),
15959
+ roots,
15742
15960
  connections: liveConnections.map(publicConnection),
15743
15961
  userRoots: publicUserState(loaded.state),
15744
15962
  warnings: [...loaded.warnings]
15745
15963
  });
15746
15964
  }
15965
+ function queryOf(url) {
15966
+ const index = url === void 0 ? -1 : url.indexOf("?");
15967
+ return new URLSearchParams(index === -1 || url === void 0 ? "" : url.slice(index + 1));
15968
+ }
15969
+ function visibleToSession(row, sessionIds, workspace) {
15970
+ const sessions = row.sessions ?? [];
15971
+ if (sessions.length > 0) return sessions.some((id) => sessionIds.has(id));
15972
+ if (workspace === "") return false;
15973
+ return (row.workspaces ?? []).some((candidate) => sameWorkspacePath(candidate, workspace));
15974
+ }
15747
15975
  async function serveAction(req, res, deps) {
15748
15976
  let text8;
15749
15977
  try {
@@ -15799,7 +16027,7 @@ async function serveAction(req, res, deps) {
15799
16027
 
15800
16028
  // src/host/integration/session/session-bin-lock.ts
15801
16029
  init_errors();
15802
- import { mkdir as mkdir5, open as open3, readFile as readFile6, rm as rm3, stat as stat3 } from "node:fs/promises";
16030
+ import { mkdir as mkdir5, open as open3, readFile as readFile6, rm as rm3, stat as stat5 } from "node:fs/promises";
15803
16031
  import { dirname as dirname5 } from "node:path";
15804
16032
  var DEFAULT_STALE_LOCK_MS = 6e4;
15805
16033
  var DEFAULT_LOCK_RETRY_MS = 25;
@@ -15857,21 +16085,35 @@ function createSessionBinLock(options) {
15857
16085
  const reclaimIfStale = async () => {
15858
16086
  if (staleMs <= 0) return false;
15859
16087
  let age;
16088
+ let malformed = false;
15860
16089
  try {
15861
- const info = await stat3(options.path);
16090
+ const info = await stat5(options.path);
16091
+ malformed = info.isDirectory();
15862
16092
  age = now() - info.mtimeMs;
15863
16093
  } catch {
15864
16094
  return true;
15865
16095
  }
15866
- if (age < staleMs) return false;
15867
- try {
15868
- const text8 = await readFile6(options.path, "utf8");
15869
- const record2 = JSON.parse(text8);
15870
- options.onReclaim?.({ path: options.path, ageMs: age, holder: record2 });
15871
- } catch {
16096
+ if (!malformed && age < staleMs) return false;
16097
+ if (!malformed) {
16098
+ try {
16099
+ const text8 = await readFile6(options.path, "utf8");
16100
+ const record2 = JSON.parse(text8);
16101
+ options.onReclaim?.({ path: options.path, ageMs: age, holder: record2 });
16102
+ } catch {
16103
+ options.onReclaim?.({ path: options.path, ageMs: age, holder: {} });
16104
+ }
16105
+ } else {
15872
16106
  options.onReclaim?.({ path: options.path, ageMs: age, holder: {} });
15873
16107
  }
15874
- await rm3(options.path, { force: true });
16108
+ try {
16109
+ await rm3(options.path, { force: true, recursive: malformed });
16110
+ } catch (error) {
16111
+ 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}`, {
16112
+ path: options.path,
16113
+ reason: "malformed-lock",
16114
+ cause: error instanceof Error ? error.message : String(error)
16115
+ });
16116
+ }
15875
16117
  return true;
15876
16118
  };
15877
16119
  const withLock = async (_key, operation) => {
@@ -15999,6 +16241,7 @@ function createRootsWiring(deps) {
15999
16241
  deps.removeConnection(id);
16000
16242
  },
16001
16243
  ...deps.anchorRootDir === void 0 ? {} : { anchorRootDir: deps.anchorRootDir },
16244
+ ...deps.resolveSessionAncestors === void 0 ? {} : { resolveSessionAncestors: deps.resolveSessionAncestors },
16002
16245
  ...listRemoteDirectory === void 0 ? {} : { listRemoteDirectory: (input) => listRemoteDirectory(input) },
16003
16246
  ...remoteHomeDirectory === void 0 ? {} : { remoteHomeDirectory },
16004
16247
  ...listLocalDirectory === void 0 ? {} : { listLocalDirectory: (input) => listLocalDirectory(input) },
@@ -16072,10 +16315,55 @@ function createRootsWiring(deps) {
16072
16315
  }
16073
16316
  return { roots, connections, warnings };
16074
16317
  };
16318
+ const inheritAddedRoots = async (sourceSessionId, childSessionId) => {
16319
+ const source = sourceSessionId.trim();
16320
+ const child = childSessionId.trim();
16321
+ if (source === "" || child === "") return;
16322
+ const keys = [];
16323
+ await store2.mutate((current) => {
16324
+ let next = current;
16325
+ for (const entry of current.roots) {
16326
+ const sessions = entry.sessions ?? [];
16327
+ if (!sessions.includes(source) || sessions.includes(child)) continue;
16328
+ const key = userRootKey(entry);
16329
+ next = withSessions(next, key, [child]);
16330
+ keys.push(key);
16331
+ }
16332
+ return next;
16333
+ });
16334
+ if (keys.length > 0) {
16335
+ const { state } = await store2.load();
16336
+ for (const key of keys) await refreshLiveScope(apiDeps, key, state);
16337
+ }
16338
+ };
16339
+ const pruneSession = async (sessionId) => {
16340
+ const trimmed = sessionId.trim();
16341
+ if (trimmed === "") return { changed: [], removed: [] };
16342
+ const before = (await store2.load()).state;
16343
+ const touched = before.roots.filter((entry) => (entry.sessions ?? []).includes(trimmed));
16344
+ if (touched.length === 0) return { changed: [], removed: [] };
16345
+ const after = await store2.mutate((current) => pruneSessionFromRoots(current, trimmed));
16346
+ const afterKeys = new Set(after.roots.map(userRootKey));
16347
+ const removed = [];
16348
+ const changed = [];
16349
+ for (const entry of touched) {
16350
+ const key = userRootKey(entry);
16351
+ if (!afterKeys.has(key)) {
16352
+ removed.push(key);
16353
+ await deps.unregisterRoot(key);
16354
+ } else {
16355
+ changed.push(key);
16356
+ }
16357
+ }
16358
+ for (const key of changed) await refreshLiveScope(apiDeps, key, after);
16359
+ return { changed, removed };
16360
+ };
16075
16361
  return {
16076
16362
  api,
16077
16363
  store: store2,
16078
16364
  sessionDirectories,
16365
+ inheritAddedRoots,
16366
+ pruneSession,
16079
16367
  mergeUserRoots,
16080
16368
  routeWarnings: () => [...routeWarnings],
16081
16369
  routeFacts: () => ({ injectRan, routeRegistered, warnings: [...routeWarnings] }),
@@ -16352,7 +16640,11 @@ async function serveActionWithSignal(action, body, req, res, deps, service, sign
16352
16640
  return;
16353
16641
  }
16354
16642
  const result = await service.purge(entryId, options);
16355
- send(req, res, 200, { ok: true, purged: result.purged, indexStale: result.indexStale });
16643
+ send(req, res, 200, {
16644
+ ...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 },
16645
+ purged: result.purged,
16646
+ indexStale: result.indexStale
16647
+ });
16356
16648
  return;
16357
16649
  }
16358
16650
  case "sweep": {
@@ -16365,8 +16657,9 @@ async function serveActionWithSignal(action, body, req, res, deps, service, sign
16365
16657
  return;
16366
16658
  }
16367
16659
  const result = await service.sweep(deps.policy(), options);
16660
+ const stale = result.purged.filter((item) => item.indexStale === true).length;
16368
16661
  send(req, res, 200, {
16369
- ok: true,
16662
+ ...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
16663
  purged: result.purged.map((item) => ({ entryId: String(item.id), sessionId: item.sessionId, indexStale: item.indexStale })),
16371
16664
  failed: result.failed.map((item) => ({ entryId: String(item.id), reason: item.reason })),
16372
16665
  remainingEntries: result.remainingEntries,
@@ -16441,6 +16734,56 @@ function createSessionRouteWiring(deps) {
16441
16734
  };
16442
16735
  }
16443
16736
 
16737
+ // src/host/integration/roots/fork-inheritance.ts
16738
+ var PATCHED = /* @__PURE__ */ Symbol.for("dsh-over-ssh/fork-inheritance");
16739
+ function sourceIdOf(source) {
16740
+ if (typeof source === "string") return source === "" ? void 0 : source;
16741
+ if (typeof source !== "object" || source === null) return void 0;
16742
+ const candidate = source;
16743
+ const id = candidate.header?.id ?? candidate.id;
16744
+ return typeof id === "string" && id !== "" ? id : void 0;
16745
+ }
16746
+ function installForkInheritance(ctx, deps) {
16747
+ const service = ctx.get("sessions");
16748
+ const original = service?.fork;
16749
+ if (typeof service !== "object" || service === null || typeof original !== "function") {
16750
+ return { installed: false, dispose: () => void 0 };
16751
+ }
16752
+ if (original[PATCHED] === true) {
16753
+ return { installed: true, dispose: () => void 0 };
16754
+ }
16755
+ const invoke = original;
16756
+ const patched2 = function patchedFork(...args) {
16757
+ const child = invoke.apply(this, args);
16758
+ try {
16759
+ const sourceId = sourceIdOf(args[0]);
16760
+ const childId = sourceIdOf(child);
16761
+ if (sourceId !== void 0 && childId !== void 0) {
16762
+ markForkInheritancePending(childId, sourceId);
16763
+ void deps.inherit(sourceId, childId).catch((error) => {
16764
+ deps.warn?.(`fork \u7EE7\u627F\u6DFB\u52A0\u76EE\u5F55\u5931\u8D25\uFF08source=${sourceId} child=${childId}\uFF09\uFF1A${error instanceof Error ? error.message : String(error)}`);
16765
+ }).finally(() => {
16766
+ clearForkInheritancePending(childId);
16767
+ });
16768
+ }
16769
+ } catch (error) {
16770
+ deps.warn?.(`fork \u7EE7\u627F\u6DFB\u52A0\u76EE\u5F55\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`);
16771
+ }
16772
+ return child;
16773
+ };
16774
+ patched2[PATCHED] = true;
16775
+ service.fork = patched2;
16776
+ return {
16777
+ installed: true,
16778
+ dispose: () => {
16779
+ if (service.fork === patched2) {
16780
+ service.fork = original;
16781
+ }
16782
+ clearAllForkInheritancePending();
16783
+ }
16784
+ };
16785
+ }
16786
+
16444
16787
  // src/wiring/fs-panel-routes.ts
16445
16788
  function readOnlySeams(env) {
16446
16789
  const { ctx, profiles, registry, transports } = env;
@@ -16658,6 +17001,8 @@ function registerPanelRoutes(env, hooks) {
16658
17001
  stateDir: resolveStateDir(),
16659
17002
  // Published in the GET snapshot so a client can recognise a session whose cwd is a remote workspace.
16660
17003
  anchorRootDir: env.anchorRootDir,
17004
+ // fork 继承父会话添加的目录(ADR-0013):血缘只有宿主知道,所以 `GET /roots?session=` 由这里回答。
17005
+ resolveSessionAncestors: (sessionId) => readSessionAncestors(env.ctx, sessionId),
16661
17006
  ...readOnlySeams(env),
16662
17007
  ...rootLifecycleSeams(env, hooks),
16663
17008
  ...connectionLifecycleSeams(env, hooks),
@@ -16665,7 +17010,11 @@ function registerPanelRoutes(env, hooks) {
16665
17010
  ...remoteFileSeams(env),
16666
17011
  webServerProvider: ctx
16667
17012
  });
16668
- return { sessionWiring, rootsWiring };
17013
+ const forkInheritance = installForkInheritance(ctx, {
17014
+ inherit: (sourceSessionId, childSessionId) => rootsWiring.inheritAddedRoots(sourceSessionId, childSessionId),
17015
+ warn: (message) => ctx.logger?.warn?.(`over-ssh: ${message}`)
17016
+ });
17017
+ return { sessionWiring, rootsWiring, disposeForkInheritance: () => forkInheritance.dispose() };
16669
17018
  }
16670
17019
 
16671
17020
  // src/wiring/fs.ts
@@ -16706,7 +17055,9 @@ function provideAgentStatus(env) {
16706
17055
  }
16707
17056
  };
16708
17057
  const forgetPerConnection = (connectionId) => {
16709
- void forgetPerConnectionAsync(connectionId);
17058
+ void forgetPerConnectionAsync(connectionId).catch((error) => {
17059
+ 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)}`);
17060
+ });
16710
17061
  };
16711
17062
  const disposeAgent = ctx.provide("sshDevAgent", {
16712
17063
  outcome: (connectionId) => agentBridge.installationFor(connectionId),
@@ -16739,7 +17090,9 @@ function registerConfiguredRoots(env) {
16739
17090
  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
17091
  });
16741
17092
  }
16742
- void Promise.allSettled(binRootPreparation).then(runBinHousekeeping);
17093
+ void Promise.allSettled(binRootPreparation).then(runBinHousekeeping).catch((error) => {
17094
+ 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)}`);
17095
+ });
16743
17096
  }
16744
17097
  function scheduleActivationDiagnostics(env) {
16745
17098
  const { ctx, rootsWiring, sessionWiring } = env;
@@ -16765,7 +17118,7 @@ function scheduleActivationDiagnostics(env) {
16765
17118
  function watchSessionRemovals(env) {
16766
17119
  const { ctx, rootsWiring, onSessionRemoved } = env;
16767
17120
  onSessionRemoved((sessionId) => {
16768
- void rootsWiring.store.pruneSession(sessionId).catch((error) => {
17121
+ void rootsWiring.pruneSession(sessionId).catch((error) => {
16769
17122
  ctx.logger?.warn?.(
16770
17123
  `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
17124
  );
@@ -16784,6 +17137,7 @@ function createTeardown(env) {
16784
17137
  const { activationTimers, routes, hostServices, disposeAgent, tools, disposeSessionDirectories } = env;
16785
17138
  return async () => {
16786
17139
  for (const timer of activationTimers) clearTimeout(timer);
17140
+ routes.disposeForkInheritance();
16787
17141
  routes.rootsWiring.dispose();
16788
17142
  routes.sessionWiring.dispose();
16789
17143
  tools.disposeManagement();
@@ -16861,7 +17215,7 @@ import { dirname as dirname7, join as join16 } from "node:path";
16861
17215
  // src/host/integration/transfer/transfer-adapters.ts
16862
17216
  init_errors();
16863
17217
  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";
17218
+ 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
17219
  import { dirname as dirname6 } from "node:path";
16866
17220
  function requireRemote(deps, path) {
16867
17221
  const target = deps.routed.resolve(path);
@@ -16925,7 +17279,7 @@ function createTransferDeps(deps) {
16925
17279
  },
16926
17280
  async statLocal(path) {
16927
17281
  try {
16928
- const info = await stat4(path);
17282
+ const info = await stat6(path);
16929
17283
  return toBackendStat(info, localVersion(info));
16930
17284
  } catch {
16931
17285
  return toBackendStat(void 0, "0:0");
@@ -17676,7 +18030,7 @@ function createSessionRootIndex(deps) {
17676
18030
  const now = deps.now ?? (() => Date.now());
17677
18031
  let cachedAt;
17678
18032
  let cached2 = [];
17679
- let pending;
18033
+ let pending2;
17680
18034
  let generation = 0;
17681
18035
  const readEntries = async () => {
17682
18036
  try {
@@ -17690,14 +18044,14 @@ function createSessionRootIndex(deps) {
17690
18044
  const entriesNow = () => {
17691
18045
  const at = cachedAt;
17692
18046
  const fresh = at !== void 0 && now() - at < deps.ttlMs;
17693
- if (!fresh && pending === void 0) {
18047
+ if (!fresh && pending2 === void 0) {
17694
18048
  const startedAt = generation;
17695
- pending = readEntries().then((entries) => {
18049
+ pending2 = readEntries().then((entries) => {
17696
18050
  if (startedAt === generation) {
17697
18051
  cached2 = entries;
17698
18052
  cachedAt = now();
17699
18053
  }
17700
- pending = void 0;
18054
+ pending2 = void 0;
17701
18055
  return entries;
17702
18056
  });
17703
18057
  }
@@ -17712,7 +18066,7 @@ function createSessionRootIndex(deps) {
17712
18066
  generation += 1;
17713
18067
  cachedAt = void 0;
17714
18068
  cached2 = [];
17715
- pending = void 0;
18069
+ pending2 = void 0;
17716
18070
  }
17717
18071
  };
17718
18072
  }
@@ -17771,8 +18125,12 @@ function resolveSessionTarget(exec, seams) {
17771
18125
  function readConnectionIds(record2) {
17772
18126
  const raw = record2["connectionIds"];
17773
18127
  if (!Array.isArray(raw)) return void 0;
18128
+ if (raw.length > MAX_EXEC_BATCH_CONNECTIONS) {
18129
+ 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`);
18130
+ }
17774
18131
  const ids = [...new Set(raw.filter((item) => typeof item === "string" && item.trim() !== "").map((item) => item.trim()))];
17775
- return ids.length === 0 ? void 0 : ids;
18132
+ if (ids.length === 0) return void 0;
18133
+ return ids;
17776
18134
  }
17777
18135
  function text2(value) {
17778
18136
  return [{ type: "text", text: value }];
@@ -18239,6 +18597,7 @@ var DEFAULT_JOB_POLL_MS = 500;
18239
18597
  var WAIT_SLICE_MS = 5e3;
18240
18598
  var DEFAULT_JOB_OUTPUT_BYTES = 1 << 20;
18241
18599
  var MAX_JOB_OUTPUT_BYTES = 8 << 20;
18600
+ var MAX_JOB_STDIN_BYTES = 1 << 20;
18242
18601
  function isAgentJobCapable(transport) {
18243
18602
  if (transport === null || typeof transport !== "object") return false;
18244
18603
  const candidate = transport;
@@ -18598,6 +18957,11 @@ function remoteJobToolDefinitions(backend) {
18598
18957
  const maxOutputBytes = clampInt2(record2["maxOutputBytes"], DEFAULT_JOB_OUTPUT_BYTES, 1, MAX_JOB_OUTPUT_BYTES);
18599
18958
  const stdinText = record2["stdin"];
18600
18959
  if (stdinText !== void 0 && typeof stdinText !== "string") throw new Error("stdin \u5FC5\u987B\u662F\u5B57\u7B26\u4E32");
18960
+ if (typeof stdinText === "string" && Buffer.byteLength(stdinText, "utf8") > MAX_JOB_STDIN_BYTES) {
18961
+ throw new Error(
18962
+ `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`
18963
+ );
18964
+ }
18601
18965
  const interactive = record2["interactive"] === true;
18602
18966
  const target = targetFor2(backend, exec);
18603
18967
  const cwd = resolveOrThrow2(target, stringArg2(record2["cwd"]));
@@ -18717,6 +19081,9 @@ function remoteJobToolDefinitions(backend) {
18717
19081
  };
18718
19082
  }
18719
19083
  if (action === "stdin") {
19084
+ if (typeof record2["data"] === "string" && typeof record2["dataBase64"] === "string") {
19085
+ throw new Error("data \u4E0E dataBase64 \u53EA\u80FD\u7ED9\u4E00\u4E2A");
19086
+ }
18720
19087
  const result = await backend.stdin(
18721
19088
  target,
18722
19089
  jobId,
@@ -18935,16 +19302,16 @@ function createRemoteFileBackend(deps) {
18935
19302
  if (carried !== void 0) rememberVersion(target, to, carried);
18936
19303
  },
18937
19304
  async stat(target, absolute, signal) {
18938
- const stat5 = await requireBackend(target).stat(absolute, signal);
18939
- if (stat5.exists) rememberVersion(target, absolute, stat5.version);
19305
+ const stat7 = await requireBackend(target).stat(absolute, signal);
19306
+ if (stat7.exists) rememberVersion(target, absolute, stat7.version);
18940
19307
  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
19308
+ exists: stat7.exists,
19309
+ isDirectory: stat7.isDirectory,
19310
+ isFile: stat7.isFile,
19311
+ isSymlink: stat7.isSymlink,
19312
+ size: stat7.size,
19313
+ mtimeMs: stat7.mtimeMs,
19314
+ version: stat7.version
18948
19315
  };
18949
19316
  },
18950
19317
  async patch(target, absolute, diff, expectedVersion, signal) {
@@ -19400,6 +19767,9 @@ function remoteFileToolDefinitions(backend, allowMutation) {
19400
19767
  const from = resolveOrThrow3(target, rawFrom);
19401
19768
  const to = resolveOrThrow3(target, rawTo);
19402
19769
  if (from === to) throw new Error(`\u6E90\u4E0E\u76EE\u6807\u662F\u540C\u4E00\u4E2A\u8DEF\u5F84\uFF1A${from}`);
19770
+ if (to.startsWith(from.endsWith("/") ? from : `${from}/`)) {
19771
+ throw new Error(`\u4E0D\u80FD\u628A\u76EE\u5F55\u79FB\u52A8\u5230\u5B83\u81EA\u5DF1\u7684\u5B50\u76EE\u5F55\u91CC\uFF1A${from} \u2192 ${to}`);
19772
+ }
19403
19773
  assertWritable(target, to, allowMutation);
19404
19774
  const createParents = record2["createParents"] !== false;
19405
19775
  await backend.move(target, from, to, createParents, exec.signal);
@@ -19436,15 +19806,15 @@ function remoteFileToolDefinitions(backend, allowMutation) {
19436
19806
  const requested = requiredString3(record2["path"], "path");
19437
19807
  const target = targetFor3(backend, exec);
19438
19808
  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";
19809
+ const stat7 = await backend.stat(target, path, exec.signal);
19810
+ const kind = !stat7.exists ? "missing" : stat7.isSymlink ? "symlink" : stat7.isDirectory ? "directory" : "file";
19441
19811
  return {
19442
19812
  path,
19443
- exists: stat5.exists,
19813
+ exists: stat7.exists,
19444
19814
  kind,
19445
- size: stat5.exists ? stat5.size : 0,
19446
- mtimeMs: stat5.exists ? stat5.mtimeMs : 0,
19447
- version: stat5.exists ? stat5.version : ""
19815
+ size: stat7.exists ? stat7.size : 0,
19816
+ mtimeMs: stat7.exists ? stat7.mtimeMs : 0,
19817
+ version: stat7.exists ? stat7.version : ""
19448
19818
  };
19449
19819
  }
19450
19820
  })
@@ -19524,11 +19894,11 @@ var LspFrameParser = class {
19524
19894
  #append(bytes) {
19525
19895
  if (bytes.length === 0) return;
19526
19896
  if (this.#end + bytes.length > this.#store.length) {
19527
- const pending = this.#end - this.#start;
19897
+ const pending2 = this.#end - this.#start;
19528
19898
  if (this.#start > 0) {
19529
19899
  this.#store.copyWithin(0, this.#start, this.#end);
19530
19900
  this.#start = 0;
19531
- this.#end = pending;
19901
+ this.#end = pending2;
19532
19902
  }
19533
19903
  if (this.#end + bytes.length > this.#store.length) {
19534
19904
  let capacity = Math.max(this.#store.length, 64 * 1024);
@@ -19537,7 +19907,7 @@ var LspFrameParser = class {
19537
19907
  this.#store.copy(next, 0, this.#start, this.#end);
19538
19908
  this.#store = next;
19539
19909
  this.#start = 0;
19540
- this.#end = pending;
19910
+ this.#end = pending2;
19541
19911
  }
19542
19912
  }
19543
19913
  this.#store.set(bytes, this.#end);
@@ -19749,11 +20119,11 @@ var LspSession = class _LspSession {
19749
20119
  throw error;
19750
20120
  }
19751
20121
  const onAbort = () => {
19752
- const pending = this.#pending.get(id);
19753
- if (pending === void 0) return;
20122
+ const pending2 = this.#pending.get(id);
20123
+ if (pending2 === void 0) return;
19754
20124
  this.#pending.delete(id);
19755
- clearTimeout(pending.timer);
19756
- pending.reject(new LspSessionError("\u7B49\u5F85\u8BED\u8A00\u670D\u52A1\u5668\u65F6\u88AB\u53D6\u6D88"));
20125
+ clearTimeout(pending2.timer);
20126
+ pending2.reject(new LspSessionError("\u7B49\u5F85\u8BED\u8A00\u670D\u52A1\u5668\u65F6\u88AB\u53D6\u6D88"));
19757
20127
  };
19758
20128
  signal.addEventListener("abort", onAbort, { once: true });
19759
20129
  try {
@@ -19877,15 +20247,15 @@ var LspSession = class _LspSession {
19877
20247
  if (typeof message["method"] !== "string") {
19878
20248
  const id = message["id"];
19879
20249
  if (typeof id === "number") {
19880
- const pending = this.#pending.get(id);
19881
- if (pending === void 0) return;
20250
+ const pending2 = this.#pending.get(id);
20251
+ if (pending2 === void 0) return;
19882
20252
  this.#pending.delete(id);
19883
- clearTimeout(pending.timer);
20253
+ clearTimeout(pending2.timer);
19884
20254
  const error = message["error"];
19885
20255
  if (error !== void 0 && error !== null) {
19886
- pending.reject(new LspSessionError(`${pending.method} \u5931\u8D25\uFF1A${describeError(error)}`));
20256
+ pending2.reject(new LspSessionError(`${pending2.method} \u5931\u8D25\uFF1A${describeError(error)}`));
19887
20257
  } else {
19888
- pending.resolve(message["result"]);
20258
+ pending2.resolve(message["result"]);
19889
20259
  }
19890
20260
  }
19891
20261
  return;
@@ -19941,10 +20311,10 @@ var LspSession = class _LspSession {
19941
20311
  }
19942
20312
  #failAll(error) {
19943
20313
  this.#exitReason ??= error;
19944
- for (const [id, pending] of this.#pending) {
19945
- clearTimeout(pending.timer);
20314
+ for (const [id, pending2] of this.#pending) {
20315
+ clearTimeout(pending2.timer);
19946
20316
  this.#pending.delete(id);
19947
- pending.reject(error);
20317
+ pending2.reject(error);
19948
20318
  }
19949
20319
  }
19950
20320
  };
@@ -20108,8 +20478,12 @@ function describeInstalled(available) {
20108
20478
  return `\u8BE5\u4E3B\u673A\u4E0A\u63A2\u6D4B\u5230\u7684\u8BED\u8A00\u670D\u52A1\u5668\uFF1A${list}\u3002`;
20109
20479
  }
20110
20480
  var MAX_SESSIONS = 8;
20111
- function sessionKey(target, language) {
20112
- return `${target.connectionId}\0${target.root}\0${language}`;
20481
+ function sessionKeyPrefix(target, language) {
20482
+ return `${target.connectionId}\0${target.root}\0${language}\0`;
20483
+ }
20484
+ function sessionKey(target, language, override) {
20485
+ const server = override === void 0 ? "" : `${override.command}\0${(override.args ?? []).join("\0")}`;
20486
+ return `${sessionKeyPrefix(target, language)}${server}`;
20113
20487
  }
20114
20488
  function createLspRegistry(deps) {
20115
20489
  const sessions = /* @__PURE__ */ new Map();
@@ -20128,15 +20502,15 @@ function createLspRegistry(deps) {
20128
20502
  }
20129
20503
  return {
20130
20504
  async session(target, language, override, signal) {
20131
- const key = sessionKey(target, language);
20505
+ const key = sessionKey(target, language, override);
20132
20506
  const existing = sessions.get(key);
20133
20507
  if (existing !== void 0 && !existing.session.stopped) {
20134
20508
  sessions.delete(key);
20135
20509
  sessions.set(key, existing);
20136
20510
  return existing.session;
20137
20511
  }
20138
- const pending = inFlight.get(key);
20139
- if (pending !== void 0) return pending;
20512
+ const pending2 = inFlight.get(key);
20513
+ if (pending2 !== void 0) return pending2;
20140
20514
  const attempt = (async () => {
20141
20515
  const command = override ?? defaultServerFor(language);
20142
20516
  if (command === void 0) {
@@ -20208,17 +20582,21 @@ ${describeInstalled(available)}`);
20208
20582
  }));
20209
20583
  },
20210
20584
  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);
20585
+ const prefix = sessionKeyPrefix(target, language);
20586
+ const keys = [...new Set([...sessions.keys(), ...inFlight.keys()].filter((key) => key.startsWith(prefix)))];
20587
+ if (keys.length === 0) return false;
20588
+ for (const key of keys) {
20589
+ const starting = inFlight.get(key);
20590
+ if (starting !== void 0) {
20591
+ retired.add(key);
20592
+ await starting.catch(() => void 0);
20593
+ retired.delete(key);
20594
+ }
20595
+ const entry = sessions.get(key);
20596
+ if (entry === void 0) continue;
20597
+ sessions.delete(key);
20598
+ await entry.session.close(signal);
20599
+ }
20222
20600
  return true;
20223
20601
  },
20224
20602
  async disposeAll() {
@@ -21020,7 +21398,6 @@ function registerRemoteLspTools(ctx, deps) {
21020
21398
  // src/wiring/tools-session.ts
21021
21399
  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
21400
  import { dirname as dirname8, join as join17 } from "node:path";
21023
- import { homedir as homedir6 } from "node:os";
21024
21401
 
21025
21402
  // src/host/integration/session/session-labels.ts
21026
21403
  function normalizePath(path) {
@@ -21070,8 +21447,8 @@ function createWorkspaceTitleResolver(ctx) {
21070
21447
  // src/wiring/tools-session.ts
21071
21448
  function wireSessionAdmin(env) {
21072
21449
  const { ctx, limits, notifySessionRemoved: notifySessionRemoved2 } = env;
21073
- const sessionsRoot = limits.sessionsRoot ?? join17(homedir6(), ".dsh", "sessions");
21074
- const storagesRoot = join17(homedir6(), ".dsh", "storages");
21450
+ const sessionsRoot = limits.sessionsRoot ?? join17(dshHome(), "sessions");
21451
+ const storagesRoot = join17(dshHome(), "storages");
21075
21452
  const sessionBinRoot = limits.sessionBinRoot ?? join17(storagesRoot, "over-ssh", "session-trash");
21076
21453
  if (limits.sessionBinRoot === void 0) {
21077
21454
  const carried = carryDirectory(join17(storagesRoot, "remote-workspace", "session-trash"), sessionBinRoot);
@@ -21122,15 +21499,19 @@ function wireSessionAdmin(env) {
21122
21499
  throw error;
21123
21500
  }
21124
21501
  },
21125
- readHeader: (dir) => readSessionHeader(dir),
21502
+ // The listing is already in hand: hand it over so the header cache can key on it instead of re-listing.
21503
+ readHeader: (dir, listing) => readSessionHeader(
21504
+ dir,
21505
+ listing?.map((entry) => ({ name: entry.name, size: entry.bytes, mtimeMs: entry.mtimeMs }))
21506
+ ),
21126
21507
  // Both resolvers are optional: a profile without the platform services still lists sessions, labelled
21127
21508
  // by their directory and id rather than by name.
21128
21509
  resolveSessionTitle: createSessionTitleResolver(ctx),
21129
21510
  resolveWorkspaceTitle: createWorkspaceTitleResolver(ctx),
21130
21511
  stat: async (path) => {
21131
21512
  try {
21132
- const stat5 = await nodeStat2(path);
21133
- return { isDirectory: stat5.isDirectory(), bytes: stat5.size };
21513
+ const stat7 = await nodeStat2(path);
21514
+ return { isDirectory: stat7.isDirectory(), bytes: stat7.size };
21134
21515
  } catch {
21135
21516
  return void 0;
21136
21517
  }
@@ -21371,6 +21752,7 @@ function managementToolDefinitions(backend) {
21371
21752
  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
21753
  }
21373
21754
  const target = targetFor4(backend, exec);
21755
+ if (direction === "upload") backend.assertWritable?.(target, "\u4E0A\u4F20\u8FDC\u7AEF\u6587\u4EF6");
21374
21756
  if (direction === "upload" && backend.statRemote !== void 0 && (expectedVersion !== void 0 || !force && expectedSha256 === void 0)) {
21375
21757
  const absolute = resolveUnderRoot(target.root, remotePath);
21376
21758
  if (typeof absolute !== "string") throw new Error(absolute.error);
@@ -21458,6 +21840,7 @@ function managementToolDefinitions(backend) {
21458
21840
  const target = targetFor4(backend, exec);
21459
21841
  const resolved = resolveUnderRoot(target.root, requested, void 0, target.connectionId);
21460
21842
  if (typeof resolved !== "string") throw new Error(resolved.error);
21843
+ backend.assertWritable?.(target, mode === "permanent" ? "\u6C38\u4E45\u5220\u9664\u8FDC\u7AEF\u6587\u4EF6" : "\u5220\u9664\u8FDC\u7AEF\u6587\u4EF6");
21461
21844
  const result = await backend.discard({ target, path: resolved, mode, signal: exec.signal });
21462
21845
  return {
21463
21846
  mode: result.mode,
@@ -21517,6 +21900,8 @@ function managementToolDefinitions(backend) {
21517
21900
  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
21901
  }
21519
21902
  const target = targetFor4(backend, exec);
21903
+ const listing = record2["list"] === true;
21904
+ if (!listing) backend.assertWritable?.(target, "\u4ECE\u56DE\u6536\u7AD9\u6062\u590D\u8FDC\u7AEF\u6587\u4EF6");
21520
21905
  const outcome = await backend.restore({
21521
21906
  target,
21522
21907
  id: typeof id === "string" ? id : void 0,
@@ -21573,6 +21958,7 @@ function managementToolDefinitions(backend) {
21573
21958
  if (typeof source !== "string") throw new Error(source.error);
21574
21959
  const destination = resolveUnderRoot(target.root, to, void 0, target.connectionId);
21575
21960
  if (typeof destination !== "string") throw new Error(destination.error);
21961
+ backend.assertWritable?.(target, "\u590D\u5236\u8FDC\u7AEF\u6587\u4EF6");
21576
21962
  const outcome = await backend.copy({
21577
21963
  target,
21578
21964
  from: source,
@@ -21807,6 +22193,8 @@ function wireManagementTools(env) {
21807
22193
  )?.root;
21808
22194
  const managementBackend = {
21809
22195
  resolve: (cwd) => toolBackend.resolve(cwd),
22196
+ // 管理工具(上传/删除/恢复/复制)之前完全不过写闸:read-only 会话照样能删文件(审计 2026-11-06 宿主 P0)。
22197
+ assertWritable: (target, action) => env.assertWritable(target, action),
21810
22198
  // ADR-0013: the session id (plus its fork lineage) is the strict scope; `cwd` alone cannot separate
21811
22199
  // two conversations opened on the same directory.
21812
22200
  resolveSessionRoot: (cwd, sessionId) => sessionRoots.outcomeFor(cwd, sessionIdentityOf(ctx, sessionId)),
@@ -21926,6 +22314,7 @@ function wireManagementTools(env) {
21926
22314
  }
21927
22315
 
21928
22316
  // src/wiring/tools.ts
22317
+ init_errors();
21929
22318
  function sessionRootsFilePath() {
21930
22319
  return join18(resolveStateDir(), USER_ROOTS_FILE_NAME);
21931
22320
  }
@@ -21966,7 +22355,8 @@ function wireTools(env) {
21966
22355
  disposeJobTools,
21967
22356
  disposeFileTools,
21968
22357
  lspRegistry,
21969
- disposeLspTools
22358
+ disposeLspTools,
22359
+ assertWritable: assertWritable2
21970
22360
  } = wireFileAndJobTools({ ctx, limits, profiles, transports, registry, sessionRoots });
21971
22361
  const sessionAdmin = wireSessionAdmin({ ctx, limits, notifySessionRemoved: notifySessionRemoved2 });
21972
22362
  const { dispose: disposeManagement } = wireManagementTools({
@@ -21980,7 +22370,8 @@ function wireTools(env) {
21980
22370
  sessionRoots,
21981
22371
  recordFor,
21982
22372
  transferService,
21983
- transferTracker
22373
+ transferTracker,
22374
+ assertWritable: assertWritable2
21984
22375
  });
21985
22376
  const disposeStrictArguments = strictArgumentsGate(ctx);
21986
22377
  return {
@@ -22049,7 +22440,9 @@ function wireAgentTools(env) {
22049
22440
  disposeRemoteTools();
22050
22441
  disposeGitTool();
22051
22442
  disposeForwardTool();
22052
- void forwardBackend.closeAll();
22443
+ void forwardBackend.closeAll().catch((error) => {
22444
+ ctx.logger?.warn?.(`over-ssh: \u5173\u95ED\u7AEF\u53E3\u8F6C\u53D1\u65F6\u51FA\u9519\uFF1A${error instanceof Error ? error.message : String(error)}`);
22445
+ });
22053
22446
  };
22054
22447
  return { sessionRoots, toolBackend, disposeTools };
22055
22448
  }
@@ -22086,21 +22479,33 @@ function wireFileAndJobTools(env) {
22086
22479
  backendFor: (target) => recordFor(target)?.backend,
22087
22480
  ...limits.maxReadBytes === void 0 ? {} : { maxReadBytes: limits.maxReadBytes }
22088
22481
  });
22089
- const allowMutation = (target) => {
22482
+ const allowMutation = (target, action) => {
22483
+ const policy = platformMutationPolicy(ctx);
22484
+ const sessionId = currentSessionId();
22485
+ const cwd = sessionId === void 0 || sessionId === "" ? void 0 : readSessionCwd(ctx, sessionId);
22090
22486
  const refusal = mutationRefusal(
22091
- `${target.connectionId}:${target.root}`,
22487
+ action === void 0 ? `${target.connectionId}:${target.root}` : `${action}\uFF1A${target.connectionId}:${target.root}`,
22092
22488
  {
22093
- ...platformMutationPolicy(ctx),
22489
+ ...policy,
22094
22490
  rootArea: () => {
22095
22491
  const record2 = recordFor(target);
22096
22492
  if (record2 === void 0) return "deny";
22097
- return workspaceWriteArea(record2.root, void 0, void 0, sessionIdentityOf(ctx, currentSessionId()));
22493
+ return workspaceWriteArea(record2.root, policy.policy?.workspaceRoot, cwd, sessionIdentityOf(ctx, sessionId));
22098
22494
  }
22099
22495
  }
22100
22496
  );
22101
22497
  if (refusal !== void 0) throw new Error(refusal);
22102
22498
  };
22103
22499
  const disposeFileTools = registerRemoteFileTools(ctx, fileBackend, { allowMutation });
22500
+ const assertWritable2 = (target, action) => {
22501
+ allowMutation(target, action);
22502
+ if (recordFor(target)?.root.writable === false) {
22503
+ 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}`, {
22504
+ connectionId: target.connectionId,
22505
+ root: target.root
22506
+ });
22507
+ }
22508
+ };
22104
22509
  const lspRegistry = createLspRegistry({
22105
22510
  acquire: async (connectionId) => (await transports.acquire(requireProfile(profiles, connectionId))).transport,
22106
22511
  onLog: (connectionId, message) => {
@@ -22108,7 +22513,7 @@ function wireFileAndJobTools(env) {
22108
22513
  }
22109
22514
  });
22110
22515
  const disposeLspTools = registerRemoteLspTools(ctx, { backend: fileBackend, registry: lspRegistry });
22111
- return { recordFor, disposeJobTools, disposeFileTools, lspRegistry, disposeLspTools };
22516
+ return { recordFor, disposeJobTools, disposeFileTools, lspRegistry, disposeLspTools, assertWritable: assertWritable2 };
22112
22517
  }
22113
22518
 
22114
22519
  // src/index.ts