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.
- package/README.md +10 -8
- package/agents/agent-manifest.json +6 -6
- package/agents/linux-amd64/dsh-over-ssh-agent +0 -0
- package/agents/linux-arm64/dsh-over-ssh-agent +0 -0
- package/lib/client.js +567 -246
- package/lib/file-references.js +20 -8
- package/lib/fs.js +141 -41
- package/lib/index.js +3268 -875
- package/lib/types/src/config.d.ts +76 -0
- package/lib/types/src/host/app/backend.d.ts +35 -2
- package/lib/types/src/host/domain/roots/session-roots.d.ts +23 -0
- package/lib/types/src/host/domain/session/session-admin.d.ts +5 -0
- package/lib/types/src/host/infra/transport/forward.d.ts +75 -0
- package/lib/types/src/host/infra/transport/ssh/ssh2-transport.d.ts +5 -0
- package/lib/types/src/host/infra/transport/transport.d.ts +26 -0
- package/lib/types/src/host/integration/fs/fs-provider.d.ts +18 -1
- package/lib/types/src/wiring/helpers.d.ts +10 -1
- package/package.json +1 -1
package/lib/file-references.js
CHANGED
|
@@ -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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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:
|
|
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
|
-
/**
|
|
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
|
|
1264
|
-
|
|
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");
|