dsh-over-ssh 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -49,14 +49,26 @@ var DEFAULTS = {
49
49
  function rankMatches(paths, query, limit) {
50
50
  const needle = query.trim().toLowerCase();
51
51
  if (needle === "") {
52
- const order = [];
53
- for (let index = 0; index < paths.length; index += 1) order.push(index);
54
- const depths = new Int32Array(paths.length);
55
- for (let index = 0; index < paths.length; index += 1) depths[index] = depth(paths[index]);
56
- order.sort(
57
- (left, right) => depths[left] - depths[right] || (paths[left] < paths[right] ? -1 : 1)
58
- );
59
- return order.slice(0, limit).map((index) => paths[index]);
52
+ const top = [];
53
+ const better = (left, right) => {
54
+ const leftDepth = depth(paths[left]);
55
+ const rightDepth = depth(paths[right]);
56
+ if (leftDepth !== rightDepth) return leftDepth < rightDepth;
57
+ return paths[left] < paths[right];
58
+ };
59
+ for (let index = 0; index < paths.length; index += 1) {
60
+ if (top.length >= limit && !better(index, top[top.length - 1])) continue;
61
+ let low = 0;
62
+ let high = top.length;
63
+ while (low < high) {
64
+ const middle = low + high >> 1;
65
+ if (better(index, top[middle])) high = middle;
66
+ else low = middle + 1;
67
+ }
68
+ top.splice(low, 0, index);
69
+ if (top.length > limit) top.pop();
70
+ }
71
+ return top.map((index) => paths[index]);
60
72
  }
61
73
  const scored = [];
62
74
  for (const path of paths) {
package/lib/fs.js CHANGED
@@ -381,6 +381,9 @@ function normalizePosixPath(path) {
381
381
 
382
382
  // src/host/domain/path-grammar.ts
383
383
  function normalizeRemotePath(path) {
384
+ if (/[\u0000-\u001f]/.test(path)) {
385
+ throw new SshDevError("ROOT_INVALID", "\u8FDC\u7A0B\u8DEF\u5F84\u4E0D\u80FD\u5305\u542B\u63A7\u5236\u5B57\u7B26", { path: JSON.stringify(path) });
386
+ }
384
387
  const normalized = normalizePosixPath(path);
385
388
  if (normalized !== void 0) return normalized;
386
389
  if (path === "") throw new SshDevError("ROOT_INVALID", "\u8FDC\u7A0B\u8DEF\u5F84\u4E0D\u80FD\u4E3A\u7A7A", {});
@@ -542,8 +545,21 @@ function relativeDirectory(root, path) {
542
545
  function normalizeLocalPath2(path, options = {}) {
543
546
  return normalizeLocalPath(path, { caseInsensitive: options.caseInsensitive ?? process.platform === "win32" });
544
547
  }
548
+ function localPathContains(root, candidate, options = {}) {
549
+ const normalizedRoot = normalizeLocalPath2(root, options);
550
+ const normalizedCandidate = normalizeLocalPath2(candidate, options);
551
+ if (normalizedRoot === "/" || /^[a-z]:\/$/.test(normalizedRoot)) {
552
+ return normalizedCandidate.startsWith(normalizedRoot);
553
+ }
554
+ return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`);
555
+ }
556
+
557
+ // src/host/app/roots/user-roots.ts
558
+ var USER_ROOTS_FILE_NAME = "user-roots.json";
559
+ var USER_ROOTS_LOCK_FILE_NAME = `${USER_ROOTS_FILE_NAME}.lock`;
545
560
 
546
561
  // src/shared/routes.ts
562
+ var GIT_STATUS_API_PATH = "/api/over-ssh/git-status";
547
563
  function asRouteHost(value) {
548
564
  if (typeof value !== "object" || value === null) return void 0;
549
565
  const register = value.register;
@@ -636,7 +652,7 @@ function messageOf(error) {
636
652
  }
637
653
 
638
654
  // src/host/infra/transport/git-status-route.ts
639
- var GIT_STATUS_API_PATH = "/api/over-ssh/git-status";
655
+ var GIT_STATUS_API_PATH2 = GIT_STATUS_API_PATH;
640
656
  function toPublicAnswer(snapshot) {
641
657
  return {
642
658
  ok: true,
@@ -705,7 +721,7 @@ function registerGitStatusRoute(provider, deps) {
705
721
  const host = asRouteHost(scoped.get("webServer"));
706
722
  if (host === void 0) return void 0;
707
723
  try {
708
- const dispose = host.register({ kind: "exact", path: GIT_STATUS_API_PATH, handler: api.handle });
724
+ const dispose = host.register({ kind: "exact", path: GIT_STATUS_API_PATH2, handler: api.handle });
709
725
  return () => dispose();
710
726
  } catch {
711
727
  return void 0;
@@ -823,6 +839,92 @@ function walkAncestors(ctx, sessionId) {
823
839
  }
824
840
  return { ancestors, settled };
825
841
  }
842
+ function readSessionCwd(ctx, sessionId) {
843
+ const cached = sessionCwdCache.get(sessionId);
844
+ if (cached !== void 0) {
845
+ sessionCwdCache.delete(sessionId);
846
+ sessionCwdCache.set(sessionId, cached);
847
+ return cached.value === "" ? void 0 : cached.value;
848
+ }
849
+ const found = walkSessionCwd(ctx, sessionId);
850
+ if (found.settled) {
851
+ sessionCwdCache.set(sessionId, { value: found.cwd ?? "" });
852
+ while (sessionCwdCache.size > SESSION_CWD_CACHE_MAX) {
853
+ const oldest = sessionCwdCache.keys().next();
854
+ if (oldest.done === true) break;
855
+ sessionCwdCache.delete(oldest.value);
856
+ }
857
+ }
858
+ return found.cwd;
859
+ }
860
+ function readSessionCwds(ctx) {
861
+ let list;
862
+ try {
863
+ const sessions = ctx.get("sessions");
864
+ list = sessions?.list?.();
865
+ } catch {
866
+ return [];
867
+ }
868
+ if (!Array.isArray(list)) return [];
869
+ const cwds = [];
870
+ for (const session of list) {
871
+ const cwd = session?.header?.cwd;
872
+ if (typeof cwd === "string" && cwd !== "") cwds.push(cwd);
873
+ }
874
+ return cwds;
875
+ }
876
+ var sessionCwdCache = /* @__PURE__ */ new Map();
877
+ var SESSION_CWD_CACHE_MAX = 256;
878
+ function walkSessionCwd(ctx, sessionId) {
879
+ let list;
880
+ try {
881
+ const sessions = ctx.get("sessions");
882
+ list = sessions?.list?.();
883
+ } catch {
884
+ return { cwd: void 0, settled: false };
885
+ }
886
+ if (!Array.isArray(list)) return { cwd: void 0, settled: false };
887
+ for (const session of list) {
888
+ if (session?.header?.id !== sessionId) continue;
889
+ const cwd = session.header.cwd;
890
+ return { cwd: typeof cwd === "string" && cwd !== "" ? cwd : void 0, settled: true };
891
+ }
892
+ return { cwd: void 0, settled: false };
893
+ }
894
+
895
+ // src/host/domain/roots/session-roots.ts
896
+ function workspaceWriteArea(root, workspaceRoot, cwd, identity = {}) {
897
+ const sessions = root.sessions ?? [];
898
+ if (sessions.length > 0 && identity.sessionId !== void 0 && identity.sessionId !== "") {
899
+ if (addedBySession(sessions, identity)) return "whole-root";
900
+ } else if (sessions.length === 0 && cwd !== void 0 && cwd !== "") {
901
+ if ((root.workspaces ?? []).some((candidate) => sameWorkspacePath(candidate, cwd))) return "whole-root";
902
+ }
903
+ if (workspaceRoot === void 0 || workspaceRoot === "") return "deny";
904
+ if (root.location === "remote") {
905
+ const anchor = root.anchorPath;
906
+ if (anchor === void 0 || anchor === "") return "deny";
907
+ if (sameWorkspacePath(anchor, workspaceRoot) || localPathContains(anchor, workspaceRoot)) return "whole-root";
908
+ if (localPathContains(workspaceRoot, anchor)) return "whole-root";
909
+ return "deny";
910
+ }
911
+ if (localPathContains(root.path, workspaceRoot)) return "workspace-only";
912
+ if (cwd !== void 0 && cwd !== "" && sameWorkspacePath(root.path, cwd)) return "workspace-only";
913
+ return "deny";
914
+ }
915
+ function addedBySession(sessions, identity) {
916
+ const sessionId = identity.sessionId;
917
+ if (sessionId === void 0 || sessionId === "") return false;
918
+ if (sessions.includes(sessionId)) return true;
919
+ return (identity.ancestors ?? []).some((ancestor) => sessions.includes(ancestor));
920
+ }
921
+ function sameWorkspacePath(left, right) {
922
+ const fold = (value) => {
923
+ const normalized = normalizeLocalPath2(value, { caseInsensitive: false }).replace(/\/+/g, "/");
924
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
925
+ };
926
+ return fold(left) === fold(right);
927
+ }
826
928
 
827
929
  // src/host/integration/fs/fs-provider.ts
828
930
  var MAX_WHOLE_READ_BYTES = 16 * 1024 * 1024;
@@ -1051,7 +1153,7 @@ var RoutedFsProvider = class extends FileSystem {
1051
1153
  }
1052
1154
  const guard = expected?.kind === "replaceIfVersion" ? expected.version : void 0;
1053
1155
  const outcome = await this.call(
1054
- () => routed.backend.writeText(routed.absolute, content, guard, signal)
1156
+ () => routed.backend.writeText(routed.absolute, content, guard, signal, sandboxPolicy)
1055
1157
  ).catch((error) => {
1056
1158
  throw mapWriteError(error, routed.displayPath);
1057
1159
  });
@@ -1096,7 +1198,8 @@ var RoutedFsProvider = class extends FileSystem {
1096
1198
  edit.newString,
1097
1199
  edit.replaceAll === true,
1098
1200
  chunk.version,
1099
- signal
1201
+ signal,
1202
+ sandboxPolicy
1100
1203
  )
1101
1204
  );
1102
1205
  return { version: FsVersion(native.version), before, after };
@@ -1107,7 +1210,7 @@ var RoutedFsProvider = class extends FileSystem {
1107
1210
  }
1108
1211
  }
1109
1212
  const outcome = await this.call(
1110
- () => routed.backend.writeText(routed.absolute, restoreLineEndings(after, lineEndings), chunk.version, signal)
1213
+ () => routed.backend.writeText(routed.absolute, restoreLineEndings(after, lineEndings), chunk.version, signal, sandboxPolicy)
1111
1214
  ).catch((error) => {
1112
1215
  throw mapWriteError(error, routed.displayPath);
1113
1216
  });
@@ -1236,7 +1339,24 @@ var RoutedFsProvider = class extends FileSystem {
1236
1339
  throw isSshDevError(error) ? toFsError(error) : error;
1237
1340
  }
1238
1341
  }
1239
- /** Enforce the per-call policy and the root's own writability. */
1342
+ /**
1343
+ * Enforce the per-call policy and the root's own writability.
1344
+ *
1345
+ * Three gates, in order:
1346
+ * 1. `read-only` denies every mutation (the platform's own contract);
1347
+ * 2. a root registered `writable: false` denies;
1348
+ * 3. under **`workspace-write`**, the target must be inside the session's workspace — where "workspace"
1349
+ * includes the directories **this session added** (product decision 2026-11-04: 「添加到会话的目录」=
1350
+ * 该目录临时成为 workspace 的一部分). Before that decision the plugin admitted every writable root it
1351
+ * could route, which made the mode wider than the platform's own `fs-sandbox` fence
1352
+ * (`workspace-write` = "canonicalizes under `workspaceRoot` or the platform temp area").
1353
+ *
1354
+ * A call without a per-call policy keeps the historical behaviour (the mode comes from
1355
+ * {@link sandboxMode}); the area rule needs `workspaceRoot`, which only the policy carries.
1356
+ *
1357
+ * @param routed - the resolved target.
1358
+ * @param policy - the per-call sandbox policy the tool layer resolved.
1359
+ */
1240
1360
  assertWritable(routed, policy) {
1241
1361
  const mode = policy?.mode ?? this.sandboxMode;
1242
1362
  if (mode === "read-only") {
@@ -1245,6 +1365,19 @@ var RoutedFsProvider = class extends FileSystem {
1245
1365
  if (routed.root.writable === false) {
1246
1366
  throw platformFsError(`the root of "${routed.displayPath}" is registered read-only`, "FS_SANDBOX_DENIED");
1247
1367
  }
1368
+ if (mode !== "workspace-write" || policy === void 0) return;
1369
+ const sessionId = currentSessionId();
1370
+ const identity = sessionId === void 0 || sessionId === "" ? {} : { sessionId, ancestors: this.sessionAncestors(sessionId) };
1371
+ const cwd = sessionId === void 0 || sessionId === "" ? void 0 : this.sessionWorkspaceCwdOf(sessionId);
1372
+ const area = workspaceWriteArea(routed.root, policy.workspaceRoot, cwd, identity);
1373
+ if (area === "whole-root") return;
1374
+ if (area === "workspace-only" && routed.root.location === "local" && localPathContains(policy.workspaceRoot, routed.absolute, { caseInsensitive: process.platform === "win32" })) {
1375
+ return;
1376
+ }
1377
+ throw platformFsError(
1378
+ `sandbox denies writing "${routed.displayPath}": the path is outside this session's workspace (its workspace, or a directory this session added with \u300C\u6DFB\u52A0\u76EE\u5F55\u300D)`,
1379
+ "FS_SANDBOX_DENIED"
1380
+ );
1248
1381
  }
1249
1382
  /**
1250
1383
  * Previous text content, when it is readable text inside the cap; `undefined` otherwise.
@@ -1260,9 +1393,8 @@ var RoutedFsProvider = class extends FileSystem {
1260
1393
  return void 0;
1261
1394
  });
1262
1395
  if (chunk === void 0 || chunk.truncated) return void 0;
1263
- const bytes = Buffer.from(chunk.text, "utf8");
1264
- const sniff = bytes.subarray(0, BINARY_SNIFF_BYTES);
1265
- if (sniff.includes(0)) return void 0;
1396
+ const nul = chunk.text.indexOf("\0");
1397
+ if (nul !== -1 && nul < BINARY_SNIFF_BYTES) return void 0;
1266
1398
  return chunk.text;
1267
1399
  }
1268
1400
  /** Run a backend call, translating domain errors into core codes. */
@@ -1352,38 +1484,6 @@ function sessionSyncSignature(cwds, registry) {
1352
1484
  const roots = registry.sessionWorkspaceRecords.map((record) => record.key).join("\0");
1353
1485
  return `${cwds.join("\0")}${roots}`;
1354
1486
  }
1355
- function readSessionCwd(ctx, sessionId) {
1356
- let list;
1357
- try {
1358
- const sessions = ctx.get("sessions");
1359
- list = sessions?.list?.();
1360
- } catch {
1361
- return void 0;
1362
- }
1363
- if (!Array.isArray(list)) return void 0;
1364
- for (const session of list) {
1365
- if (session?.header?.id !== sessionId) continue;
1366
- const cwd = session.header.cwd;
1367
- return typeof cwd === "string" && cwd !== "" ? cwd : void 0;
1368
- }
1369
- return void 0;
1370
- }
1371
- function readSessionCwds(ctx) {
1372
- let list;
1373
- try {
1374
- const sessions = ctx.get("sessions");
1375
- list = sessions?.list?.();
1376
- } catch {
1377
- return [];
1378
- }
1379
- if (!Array.isArray(list)) return [];
1380
- const cwds = [];
1381
- for (const session of list) {
1382
- const cwd = session?.header?.cwd;
1383
- if (typeof cwd === "string" && cwd !== "") cwds.push(cwd);
1384
- }
1385
- return cwds;
1386
- }
1387
1487
  function assertText(displayPath, bytes) {
1388
1488
  if (bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) {
1389
1489
  throw platformFsError(`cannot read "${displayPath}": not a UTF-8 text file`, "FS_NOT_TEXT");